Compare commits
3 commits
main
...
claude/cha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff9bc6717c | ||
|
|
e5728fd7ed | ||
|
|
f91f7dd7ad |
201 changed files with 3632 additions and 40589 deletions
|
|
@ -9,14 +9,17 @@
|
||||||
"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",
|
||||||
"if": "Write(*.ts)|Write(*.tsx)|Edit(*.ts)|Edit(*.tsx)|MultiEdit(*.ts)|MultiEdit(*.tsx)",
|
"command": "output=$(npm run typecheck 2>&1); rc=$?; if [ $rc -ne 0 ]; then printf '{\"systemMessage\":\"TypeCheck failed:\\n%s\"}' \"$(echo \"$output\" | tail -30 | sed 's/\"/\\\\\"/g; s/$/\\\\n/' | tr -d '\\n')\"; fi",
|
||||||
"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
|
||||||
"timeout": 60,
|
|
||||||
"statusMessage": "Type-checking...",
|
|
||||||
"async": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,3 @@ 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 at most hundredths with trailing zeros trimmed", () => {
|
it("renders fractional QP to hundredths", () => {
|
||||||
render(
|
render(
|
||||||
<QualifyingPointsStandings
|
<QualifyingPointsStandings
|
||||||
standings={[
|
standings={[
|
||||||
|
|
@ -52,8 +52,7 @@ describe("QualifyingPointsStandings", () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("14.67 QP")).toBeInTheDocument();
|
expect(screen.getByText("14.67 QP")).toBeInTheDocument();
|
||||||
// 0.50 trims its trailing zero to 0.5; 14.67 and 0.43 are unaffected.
|
expect(screen.getByText("0.50 QP")).toBeInTheDocument();
|
||||||
expect(screen.getByText("0.5 QP")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("0.43 QP")).toBeInTheDocument();
|
expect(screen.getByText("0.43 QP")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,236 +0,0 @@
|
||||||
import { Link } from "react-router";
|
|
||||||
import {
|
|
||||||
addMonths,
|
|
||||||
differenceInCalendarDays,
|
|
||||||
eachMonthOfInterval,
|
|
||||||
format,
|
|
||||||
parseISO,
|
|
||||||
} from "date-fns";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "~/components/ui/card";
|
|
||||||
import type { DraftScheduleWindow } from "~/models/sports-season";
|
|
||||||
|
|
||||||
export interface GanttSport {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
iconUrl: string | null;
|
|
||||||
windows: DraftScheduleWindow[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DraftScheduleGanttProps {
|
|
||||||
sports: GanttSport[];
|
|
||||||
/** Horizon start (today) as a YYYY-MM-DD string. */
|
|
||||||
today: string;
|
|
||||||
/** Number of months the timeline spans. */
|
|
||||||
months: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bar color by sport-season status, using the theme chart tokens from app.css.
|
|
||||||
const STATUS_COLORS: Record<DraftScheduleWindow["status"], string> = {
|
|
||||||
active: "var(--chart-1)",
|
|
||||||
upcoming: "var(--chart-2)",
|
|
||||||
completed: "var(--muted-foreground)",
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_LABELS: Record<DraftScheduleWindow["status"], string> = {
|
|
||||||
active: "Active",
|
|
||||||
upcoming: "Upcoming",
|
|
||||||
completed: "Completed",
|
|
||||||
};
|
|
||||||
|
|
||||||
const clampPct = (n: number) => Math.max(0, Math.min(100, n));
|
|
||||||
|
|
||||||
// Row layout (px). A single-lane row is LANE_HEIGHT + ROW_V_PAD tall; extra
|
|
||||||
// concurrent windows add one lane each so overlapping bars never stack on top
|
|
||||||
// of one another.
|
|
||||||
const LANE_HEIGHT = 30;
|
|
||||||
const LANE_GAP = 8;
|
|
||||||
const ROW_V_PAD = 14;
|
|
||||||
|
|
||||||
const rowHeight = (laneCount: number) => Math.max(laneCount, 1) * LANE_HEIGHT + ROW_V_PAD;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Greedy interval-scheduling: assign each window to the first lane whose last
|
|
||||||
* bar ends before this one starts, otherwise open a new lane. Windows arrive
|
|
||||||
* sorted by draftOn (see findDraftScheduleForHorizon).
|
|
||||||
*/
|
|
||||||
function assignLanes(
|
|
||||||
windows: DraftScheduleWindow[],
|
|
||||||
start: Date
|
|
||||||
): { placed: Array<{ window: DraftScheduleWindow; lane: number }>; laneCount: number } {
|
|
||||||
const laneEnds: number[] = [];
|
|
||||||
const placed = windows.map((window) => {
|
|
||||||
const startDay = differenceInCalendarDays(parseISO(window.draftOn), start);
|
|
||||||
const endDay = differenceInCalendarDays(parseISO(window.draftOff), start);
|
|
||||||
let lane = laneEnds.findIndex((laneEnd) => laneEnd < startDay);
|
|
||||||
if (lane === -1) {
|
|
||||||
lane = laneEnds.length;
|
|
||||||
laneEnds.push(endDay);
|
|
||||||
} else {
|
|
||||||
laneEnds[lane] = endDay;
|
|
||||||
}
|
|
||||||
return { window, lane };
|
|
||||||
});
|
|
||||||
return { placed, laneCount: Math.max(laneEnds.length, 1) };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DraftScheduleGantt({ sports, today, months }: DraftScheduleGanttProps) {
|
|
||||||
const start = parseISO(today);
|
|
||||||
const end = addMonths(start, months);
|
|
||||||
const totalDays = Math.max(differenceInCalendarDays(end, start), 1);
|
|
||||||
|
|
||||||
const pct = (date: Date) => (differenceInCalendarDays(date, start) / totalDays) * 100;
|
|
||||||
|
|
||||||
// Month-boundary gridlines that fall inside the horizon.
|
|
||||||
const monthLines = eachMonthOfInterval({ start, end })
|
|
||||||
.map((date) => ({ date, left: pct(date) }))
|
|
||||||
.filter((m) => m.left >= 0 && m.left <= 100);
|
|
||||||
|
|
||||||
// Pre-compute lane assignment + height for each sport row.
|
|
||||||
const rows = sports.map((sport) => {
|
|
||||||
const { placed, laneCount } = assignLanes(sport.windows, start);
|
|
||||||
return { sport, placed, height: rowHeight(laneCount) };
|
|
||||||
});
|
|
||||||
|
|
||||||
if (sports.length === 0) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Draft Schedule</CardTitle>
|
|
||||||
<CardDescription>Draft windows across the next {months} months</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-center py-12 text-muted-foreground">
|
|
||||||
<p>No sports found.</p>
|
|
||||||
<p className="text-sm mt-2">Create a sport to start scheduling draft windows.</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<CardTitle>Draft Schedule</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Draft windows across the next {months} months (each bar spans a
|
|
||||||
sport-season’s draft-on → draft-off window)
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
|
|
||||||
{(Object.keys(STATUS_COLORS) as DraftScheduleWindow["status"][]).map((status) => (
|
|
||||||
<div key={status} className="flex items-center gap-1.5">
|
|
||||||
<span
|
|
||||||
className="h-3 w-3 rounded-sm"
|
|
||||||
style={{ backgroundColor: STATUS_COLORS[status] }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<span>{STATUS_LABELS[status]}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<div className="flex" style={{ minWidth: 160 + months * 80 }}>
|
|
||||||
{/* Sport labels column */}
|
|
||||||
<div className="w-40 shrink-0">
|
|
||||||
<div className="h-8" aria-hidden="true" />
|
|
||||||
{rows.map(({ sport, height }) => (
|
|
||||||
<div
|
|
||||||
key={sport.id}
|
|
||||||
style={{ height }}
|
|
||||||
className="flex items-center border-b border-border/50 pr-2"
|
|
||||||
>
|
|
||||||
<span className="truncate text-sm font-medium" title={sport.name}>
|
|
||||||
{sport.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Timeline column */}
|
|
||||||
<div className="relative flex-1">
|
|
||||||
{/* Month gridlines (full height) */}
|
|
||||||
{monthLines.map((m) => (
|
|
||||||
<div
|
|
||||||
key={`line-${m.date.toISOString()}`}
|
|
||||||
className="pointer-events-none absolute bottom-0 top-0 w-px bg-border/60"
|
|
||||||
style={{ left: `${m.left}%` }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{/* Today marker (full height) */}
|
|
||||||
<div
|
|
||||||
className="pointer-events-none absolute bottom-0 top-0 w-0.5 bg-primary/80"
|
|
||||||
style={{ left: 0 }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Month labels header */}
|
|
||||||
<div className="relative h-8">
|
|
||||||
{monthLines.map((m) => (
|
|
||||||
<span
|
|
||||||
key={`label-${m.date.toISOString()}`}
|
|
||||||
className="absolute top-1 text-xs text-muted-foreground"
|
|
||||||
style={{ left: `calc(${m.left}% + 4px)` }}
|
|
||||||
>
|
|
||||||
{format(m.date, "MMM yyyy")}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Rows */}
|
|
||||||
{rows.map(({ sport, placed, height }) => (
|
|
||||||
<div
|
|
||||||
key={sport.id}
|
|
||||||
style={{ height }}
|
|
||||||
className="relative border-b border-border/50"
|
|
||||||
>
|
|
||||||
{placed.length === 0 ? (
|
|
||||||
<span className="absolute left-2 top-1/2 -translate-y-1/2 text-xs italic text-muted-foreground/70">
|
|
||||||
No draft window
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
placed.map(({ window: w, lane }) => {
|
|
||||||
const left = clampPct(pct(parseISO(w.draftOn)));
|
|
||||||
const right = clampPct(pct(parseISO(w.draftOff)));
|
|
||||||
const width = Math.max(right - left, 0.75);
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={w.id}
|
|
||||||
to={`/admin/sports-seasons/${w.id}`}
|
|
||||||
className="absolute flex items-center overflow-hidden rounded px-1.5 text-xs font-medium text-background transition-opacity hover:opacity-80"
|
|
||||||
style={{
|
|
||||||
left: `${left}%`,
|
|
||||||
width: `${width}%`,
|
|
||||||
top: ROW_V_PAD / 2 + lane * LANE_HEIGHT,
|
|
||||||
height: LANE_HEIGHT - LANE_GAP,
|
|
||||||
backgroundColor: STATUS_COLORS[w.status],
|
|
||||||
}}
|
|
||||||
title={`${w.name} (${w.year}) · ${w.draftOn} → ${w.draftOff}`}
|
|
||||||
>
|
|
||||||
<span className="truncate">{w.name}</span>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { render, screen } from "@testing-library/react";
|
|
||||||
import { MemoryRouter } from "react-router";
|
|
||||||
import { DraftScheduleGantt, type GanttSport } from "../DraftScheduleGantt";
|
|
||||||
|
|
||||||
const nbaWindow = {
|
|
||||||
id: "ss-1",
|
|
||||||
name: "2026 NBA Playoffs",
|
|
||||||
year: 2026,
|
|
||||||
status: "upcoming" as const,
|
|
||||||
draftOn: "2026-08-01",
|
|
||||||
draftOff: "2026-09-15",
|
|
||||||
sport: { id: "nba", name: "NBA", slug: "nba", iconUrl: null },
|
|
||||||
};
|
|
||||||
|
|
||||||
function renderGantt(sports: GanttSport[], months = 6) {
|
|
||||||
return render(
|
|
||||||
<MemoryRouter>
|
|
||||||
<DraftScheduleGantt sports={sports} today="2026-07-02" months={months} />
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("DraftScheduleGantt", () => {
|
|
||||||
it("renders empty state when there are no sports", () => {
|
|
||||||
renderGantt([]);
|
|
||||||
expect(screen.getByText("No sports found.")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders a bar linking to the sport-season for each draft window", () => {
|
|
||||||
renderGantt([
|
|
||||||
{ id: "nba", name: "NBA", slug: "nba", iconUrl: null, windows: [nbaWindow] },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const bar = screen.getByRole("link", { name: /2026 NBA Playoffs/ });
|
|
||||||
expect(bar).toHaveAttribute("href", "/admin/sports-seasons/ss-1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders overlapping windows as separate, clickable bars", () => {
|
|
||||||
const overlappingWindow = {
|
|
||||||
id: "ss-2",
|
|
||||||
name: "2027 NBA Playoffs",
|
|
||||||
year: 2027,
|
|
||||||
status: "active" as const,
|
|
||||||
draftOn: "2026-08-15",
|
|
||||||
draftOff: "2026-10-01",
|
|
||||||
sport: { id: "nba", name: "NBA", slug: "nba", iconUrl: null },
|
|
||||||
};
|
|
||||||
|
|
||||||
renderGantt([
|
|
||||||
{
|
|
||||||
id: "nba",
|
|
||||||
name: "NBA",
|
|
||||||
slug: "nba",
|
|
||||||
iconUrl: null,
|
|
||||||
windows: [nbaWindow, overlappingWindow],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
screen.getByRole("link", { name: /2026 NBA Playoffs/ })
|
|
||||||
).toHaveAttribute("href", "/admin/sports-seasons/ss-1");
|
|
||||||
expect(
|
|
||||||
screen.getByRole("link", { name: /2027 NBA Playoffs/ })
|
|
||||||
).toHaveAttribute("href", "/admin/sports-seasons/ss-2");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows a 'No draft window' hint for a sport with no windows", () => {
|
|
||||||
renderGantt([
|
|
||||||
{ id: "golf", name: "Golf", slug: "golf", iconUrl: null, windows: [] },
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(screen.getByText("Golf")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("No draft window")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the status legend", () => {
|
|
||||||
renderGantt([
|
|
||||||
{ id: "nba", name: "NBA", slug: "nba", iconUrl: null, windows: [nbaWindow] },
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(screen.getByText("Active")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Upcoming")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Completed")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reflects the horizon length in the description", () => {
|
|
||||||
renderGantt(
|
|
||||||
[{ id: "nba", name: "NBA", slug: "nba", iconUrl: null, windows: [nbaWindow] }],
|
|
||||||
12
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
screen.getByText(/Draft windows across the next 12 months/)
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -118,23 +118,20 @@ function ActiveRow({
|
||||||
</div>
|
</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-start gap-4 border-t border-border/50 pt-2 sm:border-0 sm:pt-0 sm:shrink-0">
|
<div className="flex items-center gap-4 border-t border-border/50 pt-2 sm:border-0 sm:pt-0 sm:shrink-0">
|
||||||
{displayRank !== undefined && (
|
{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 className="h-8" />}
|
{currentRank !== undefined && totalPoints !== undefined && <StatDivider />}
|
||||||
{totalPoints !== undefined && (
|
{totalPoints !== undefined && (
|
||||||
<StatColumn
|
<StatColumn label="Points">
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
|
|
@ -172,21 +169,17 @@ 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
|
<StatColumn label="Draft">
|
||||||
label="Draft"
|
<span className="text-2xl font-bold leading-none">{draftTimeValue}</span>
|
||||||
value={<span className="text-2xl font-bold leading-none">{draftTimeValue}</span>}
|
</StatColumn>
|
||||||
/>
|
|
||||||
{draftPosition !== undefined && (
|
{draftPosition !== undefined && (
|
||||||
<>
|
<>
|
||||||
<StatDivider className="h-8" />
|
<StatDivider />
|
||||||
<StatColumn
|
<StatColumn label="Position">
|
||||||
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,52 +137,6 @@ export const FullLeague: Story = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WithProjections: Story = {
|
|
||||||
args: {
|
|
||||||
showProjected: true,
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
teamId: "t1",
|
|
||||||
teamName: "Lightning Wolves",
|
|
||||||
ownerName: "alice",
|
|
||||||
displayRank: 1,
|
|
||||||
currentRank: 1,
|
|
||||||
points: 2810,
|
|
||||||
rankChange: 2,
|
|
||||||
pointChange: 87.5,
|
|
||||||
projectedPoints: 3120,
|
|
||||||
participantsRemaining: 3,
|
|
||||||
href: "/leagues/1/standings/1/teams/t1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
teamId: "t2",
|
|
||||||
teamName: "Shadow Hawks",
|
|
||||||
ownerName: "bob",
|
|
||||||
displayRank: 2,
|
|
||||||
currentRank: 2,
|
|
||||||
points: 2654.5,
|
|
||||||
rankChange: -1,
|
|
||||||
pointChange: 42.0,
|
|
||||||
projectedPoints: 2980,
|
|
||||||
participantsRemaining: 2,
|
|
||||||
href: "/leagues/1/standings/1/teams/t2",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
teamId: "t3",
|
|
||||||
teamName: "Iron Eagles",
|
|
||||||
ownerName: "carol",
|
|
||||||
displayRank: 3,
|
|
||||||
currentRank: 3,
|
|
||||||
points: 2493,
|
|
||||||
// No 7-day change and no live participants — placeholders keep alignment.
|
|
||||||
projectedPoints: 2493,
|
|
||||||
participantsRemaining: 0,
|
|
||||||
href: "/leagues/1/standings/1/teams/t3",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const WithTies: Story = {
|
export const WithTies: Story = {
|
||||||
args: {
|
args: {
|
||||||
entries: [
|
entries: [
|
||||||
|
|
|
||||||
|
|
@ -5,17 +5,7 @@ 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, DeltaBadge, RankingDisplay } from "./StatHelpers";
|
import { StatColumn, StatDivider, PointChangeIndicator, 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 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -33,20 +23,7 @@ function rowClasses(currentRank: number | undefined, hasHref: boolean): string {
|
||||||
|
|
||||||
// ─── Row content ──────────────────────────────────────────────────────────────
|
// ─── Row content ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function RowContent({
|
function RowContent({ entry }: { entry: StandingsPreviewEntry }) {
|
||||||
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 */}
|
||||||
|
|
@ -69,44 +46,16 @@ function RowContent({
|
||||||
|
|
||||||
{/* 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
|
<RankingDisplay displayRank={entry.displayRank} rankChange={entry.rankChange} />
|
||||||
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
|
<StatColumn label="Points">
|
||||||
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 && (
|
||||||
delta={
|
<PointChangeIndicator delta={entry.pointChange} />
|
||||||
entry.pointChange !== undefined && entry.pointChange !== 0 ? (
|
)}
|
||||||
<DeltaBadge delta={entry.pointChange} kind="points" />
|
</StatColumn>
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -132,26 +81,15 @@ 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({
|
export function StandingsPreview({ entries, description, fullStandingsHref }: StandingsPreviewProps) {
|
||||||
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">
|
||||||
|
|
@ -181,11 +119,11 @@ export function StandingsPreview({
|
||||||
to={entry.href}
|
to={entry.href}
|
||||||
className={rowClasses(entry.currentRank, true)}
|
className={rowClasses(entry.currentRank, true)}
|
||||||
>
|
>
|
||||||
<RowContent entry={entry} showProjected={showProjected} />
|
<RowContent entry={entry} />
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<div key={entry.teamId} className={rowClasses(entry.currentRank, false)}>
|
<div key={entry.teamId} className={rowClasses(entry.currentRank, false)}>
|
||||||
<RowContent entry={entry} showProjected={showProjected} />
|
<RowContent entry={entry} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,71 +1,30 @@
|
||||||
/**
|
|
||||||
* Stacked stat column: label / value / delta. The delta slot is always rendered
|
|
||||||
* (a muted "—" placeholder when there is no change) so that every row reserves the
|
|
||||||
* same vertical space and the columns line up across the standings table.
|
|
||||||
*/
|
|
||||||
export function StatColumn({
|
export function StatColumn({
|
||||||
label,
|
label,
|
||||||
value,
|
children,
|
||||||
delta,
|
|
||||||
reserveDelta = false,
|
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: React.ReactNode;
|
children: 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">{value}</div>
|
<div className="flex items-baseline justify-end gap-1">{children}</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({ className = "h-12" }: { className?: string } = {}) {
|
export function StatDivider() {
|
||||||
return <div className={`${className} w-px bg-border shrink-0 self-center`} />;
|
return <div className="h-8 w-px bg-border shrink-0 self-center" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function RankChangeIndicator({ delta }: { delta: number }) {
|
||||||
* 7-day change indicator. `rank` deltas are unitless places; `points` deltas are
|
if (delta === 0) return null;
|
||||||
* rounded point totals. Positive = up (green ▲), negative = down (coral ▼).
|
if (delta > 0) {
|
||||||
*/
|
|
||||||
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={label}>
|
<span className="text-xs font-semibold text-primary" aria-label={`up ${delta}`}>
|
||||||
▲{magnitude}
|
▲{delta}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -73,9 +32,9 @@ export function DeltaBadge({
|
||||||
<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={label}
|
aria-label={`down ${Math.abs(delta)}`}
|
||||||
>
|
>
|
||||||
▼{magnitude}
|
▼{Math.abs(delta)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -83,22 +42,35 @@ export function DeltaBadge({
|
||||||
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
|
<StatColumn label="Ranking">
|
||||||
label="Ranking"
|
<span className="text-2xl font-bold leading-none">{displayRank}</span>
|
||||||
reserveDelta={reserveDelta}
|
{rankChange !== undefined && rankChange !== 0 && (
|
||||||
value={<span className="text-2xl font-bold leading-none">{displayRank}</span>}
|
<RankChangeIndicator delta={rankChange} />
|
||||||
delta={
|
)}
|
||||||
rankChange !== undefined && rankChange !== 0 ? (
|
</StatColumn>
|
||||||
<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,54 +103,4 @@ 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,7 +1,6 @@
|
||||||
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 = {
|
||||||
|
|
@ -18,20 +17,28 @@ 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;
|
|
||||||
}) {
|
}) {
|
||||||
const cardClassName = (section: SettingsGridSection) =>
|
return (
|
||||||
cn(
|
<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) => (
|
||||||
|
<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",
|
||||||
|
|
@ -46,33 +53,8 @@ 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>
|
||||||
);
|
);
|
||||||
|
|
@ -80,30 +62,19 @@ export function SettingsMobileGridNav({
|
||||||
|
|
||||||
export function SettingsMobileSectionPill({
|
export function SettingsMobileSectionPill({
|
||||||
onShowGrid,
|
onShowGrid,
|
||||||
backHref,
|
|
||||||
}: {
|
}: {
|
||||||
onShowGrid?: () => void;
|
onShowGrid: () => void;
|
||||||
backHref?: string;
|
|
||||||
}) {
|
}) {
|
||||||
const className =
|
|
||||||
"flex cursor-pointer items-center gap-1.5 rounded-full border bg-card px-3 py-1.5 text-sm font-medium hover:bg-muted";
|
|
||||||
const content = (
|
|
||||||
<>
|
|
||||||
<ArrowLeft className="h-3.5 w-3.5" />
|
|
||||||
Back to all settings
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-5 lg:hidden">
|
<div className="mb-5 lg:hidden">
|
||||||
{backHref ? (
|
<button
|
||||||
<Link to={backHref} className={className}>
|
type="button"
|
||||||
{content}
|
onClick={onShowGrid}
|
||||||
</Link>
|
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"
|
||||||
) : (
|
>
|
||||||
<button type="button" onClick={onShowGrid} className={className}>
|
<ArrowLeft className="h-3.5 w-3.5" />
|
||||||
{content}
|
Back to all settings
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -112,57 +83,33 @@ 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={navLabel} className="space-y-1">
|
<nav aria-label="League settings" 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={itemClassName(section)}
|
className={cn(
|
||||||
>
|
"flex w-full cursor-pointer items-center gap-2.5 rounded-md px-3 py-2 text-left text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
{itemInner(section)}
|
activeSection === section.id && "bg-muted text-foreground"
|
||||||
</button>
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
<section.icon className={cn("h-4 w-4 shrink-0", section.isDanger && "text-destructive")} aria-hidden="true" />
|
||||||
|
{section.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
import { describe, it, expect, vi } from "vitest";
|
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
|
||||||
import { MemoryRouter } from "react-router";
|
|
||||||
import { Bell, User } from "lucide-react";
|
|
||||||
import {
|
|
||||||
SettingsDesktopNav,
|
|
||||||
SettingsMobileGridNav,
|
|
||||||
SettingsMobileSectionPill,
|
|
||||||
type SettingsGridSection,
|
|
||||||
} from "../SettingsNavigation";
|
|
||||||
|
|
||||||
const sections: readonly SettingsGridSection[] = [
|
|
||||||
{ id: "profile", label: "Profile", icon: User, subtitle: "Name, avatar" },
|
|
||||||
{ id: "notifications", label: "Notifications", icon: Bell, subtitle: "Email & Discord" },
|
|
||||||
];
|
|
||||||
|
|
||||||
describe("SettingsNavigation link mode", () => {
|
|
||||||
it("renders desktop nav entries as anchors built from buildHref", () => {
|
|
||||||
render(
|
|
||||||
<MemoryRouter>
|
|
||||||
<SettingsDesktopNav
|
|
||||||
sections={sections}
|
|
||||||
activeSection="profile"
|
|
||||||
buildHref={(id) => `/settings/${id}`}
|
|
||||||
/>
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByRole("link", { name: "Profile" })).toHaveAttribute("href", "/settings/profile");
|
|
||||||
expect(screen.getByRole("link", { name: "Notifications" })).toHaveAttribute(
|
|
||||||
"href",
|
|
||||||
"/settings/notifications"
|
|
||||||
);
|
|
||||||
expect(screen.getByRole("link", { name: "Profile" })).toHaveAttribute("aria-current", "page");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the mobile grid as anchors and the back pill as an anchor", () => {
|
|
||||||
render(
|
|
||||||
<MemoryRouter>
|
|
||||||
<SettingsMobileGridNav sections={sections} buildHref={(id) => `/settings/${id}`} />
|
|
||||||
<SettingsMobileSectionPill backHref="/settings" />
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByRole("link", { name: /Profile/i })).toHaveAttribute("href", "/settings/profile");
|
|
||||||
expect(screen.getByRole("link", { name: /back to all settings/i })).toHaveAttribute(
|
|
||||||
"href",
|
|
||||||
"/settings"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("SettingsNavigation button mode (league settings)", () => {
|
|
||||||
it("still fires onSectionChange when no buildHref is provided", () => {
|
|
||||||
const onSectionChange = vi.fn();
|
|
||||||
render(
|
|
||||||
<SettingsDesktopNav
|
|
||||||
sections={sections}
|
|
||||||
activeSection="profile"
|
|
||||||
onSectionChange={onSectionChange}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Notifications" }));
|
|
||||||
expect(onSectionChange).toHaveBeenCalledWith("notifications");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,16 +1,13 @@
|
||||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
import { 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,
|
||||||
|
|
@ -24,8 +21,6 @@ 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({
|
||||||
|
|
@ -35,68 +30,63 @@ 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 ? Math.max(0, firstScoringRoundIdx - 1) : lastPage,
|
firstScoringRoundIdx !== undefined
|
||||||
lastPage,
|
? Math.max(0, firstScoringRoundIdx - 1)
|
||||||
|
: mainRounds.length - 2,
|
||||||
|
mainRounds.length - 2,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { page, anim, stripRef, navigate, handleTransitionEnd } = useRoundTransition(
|
const { page, anim, stripRef, navigate, handleTransitionEnd } = useRoundTransition(
|
||||||
lastPage,
|
mainRounds.length - 2,
|
||||||
defaultPage,
|
defaultPage,
|
||||||
);
|
);
|
||||||
|
|
||||||
const pageGeometry = (p: number) => windowGeometry(geometry, p, p + 1);
|
const targetPage = anim ? anim.toPage : page;
|
||||||
const labelFor = (p: number) => {
|
const labelRounds = mainRounds.slice(targetPage, targetPage + 2);
|
||||||
const [a, b] = [columns[p]?.label, columns[p + 1]?.label];
|
const label = labelRounds[1] ? `${labelRounds[0]} → ${labelRounds[1]}` : labelRounds[0];
|
||||||
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 label = labelFor(anim ? anim.toPage : page);
|
const pageHeight = calcHeight(page);
|
||||||
|
const animFromHeight = anim ? calcHeight(anim.fromPage) : pageHeight;
|
||||||
|
const animToHeight = anim ? calcHeight(anim.toPage) : pageHeight;
|
||||||
|
|
||||||
const pageG = pageGeometry(page);
|
const visibleRounds = mainRounds.slice(page, page + 2);
|
||||||
const animFromG = anim ? pageGeometry(anim.fromPage) : pageG;
|
const fromRounds = anim ? mainRounds.slice(anim.fromPage, anim.fromPage + 2) : visibleRounds;
|
||||||
const animToG = anim ? pageGeometry(anim.toPage) : pageG;
|
const toRounds = anim ? mainRounds.slice(anim.toPage, anim.toPage + 2) : visibleRounds;
|
||||||
|
|
||||||
let leftPage: number;
|
let leftRounds: string[];
|
||||||
let rightPage: number | null = null;
|
let rightRounds: string[] = [];
|
||||||
let leftG = pageG;
|
let leftHeight: number;
|
||||||
let rightG = pageG;
|
let rightHeight = 0;
|
||||||
let settlingTransition = false;
|
let settlingTransition = false;
|
||||||
if (anim?.phase === "sliding") {
|
if (anim?.phase === "sliding") {
|
||||||
leftPage = anim.dir === "right" ? anim.fromPage : anim.toPage;
|
leftRounds = anim.dir === "right" ? fromRounds : toRounds;
|
||||||
rightPage = anim.dir === "right" ? anim.toPage : anim.fromPage;
|
rightRounds = anim.dir === "right" ? toRounds : fromRounds;
|
||||||
leftG = anim.dir === "right" ? animFromG : animToG;
|
leftHeight = anim.dir === "right" ? animFromHeight : animToHeight;
|
||||||
rightG = anim.dir === "right" ? animToG : animFromG;
|
rightHeight = anim.dir === "right" ? animToHeight : animFromHeight;
|
||||||
} else if (anim?.phase === "settling") {
|
} else if (anim?.phase === "settling") {
|
||||||
leftPage = anim.toPage;
|
leftRounds = toRounds;
|
||||||
leftG = animToG;
|
leftHeight = animToHeight;
|
||||||
settlingTransition = true;
|
settlingTransition = true;
|
||||||
} else {
|
} else {
|
||||||
leftPage = page;
|
leftRounds = visibleRounds;
|
||||||
|
leftHeight = pageHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
const containerMinHeight =
|
const containerMinHeight = anim?.phase === "settling" ? animToHeight : animFromHeight;
|
||||||
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 (
|
||||||
|
|
@ -119,7 +109,7 @@ export function BracketTreePaginated({
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => navigate(page + 1)}
|
onClick={() => navigate(page + 1)}
|
||||||
disabled={page >= lastPage || !!anim}
|
disabled={page + 2 >= mainRounds.length || !!anim}
|
||||||
className="h-7 w-7 shrink-0"
|
className="h-7 w-7 shrink-0"
|
||||||
aria-label="Next rounds"
|
aria-label="Next rounds"
|
||||||
>
|
>
|
||||||
|
|
@ -139,24 +129,22 @@ export function BracketTreePaginated({
|
||||||
>
|
>
|
||||||
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
|
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
|
||||||
<TreeColumns
|
<TreeColumns
|
||||||
geometry={leftG}
|
visibleRounds={leftRounds}
|
||||||
columnRange={[leftPage, leftPage + 1]}
|
matchesByRound={matchesByRound}
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
feeders={feeders}
|
bracketHeight={leftHeight}
|
||||||
template={template}
|
|
||||||
transitionDuration={settlingTransition ? 500 : undefined}
|
transitionDuration={settlingTransition ? 500 : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{anim?.phase === "sliding" && rightPage !== null && (
|
{anim?.phase === "sliding" && (
|
||||||
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
|
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
|
||||||
<TreeColumns
|
<TreeColumns
|
||||||
geometry={rightG}
|
visibleRounds={rightRounds}
|
||||||
columnRange={[rightPage, rightPage + 1]}
|
matchesByRound={matchesByRound}
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
feeders={feeders}
|
bracketHeight={rightHeight}
|
||||||
template={template}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -176,8 +164,6 @@ 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,13 +1,5 @@
|
||||||
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;
|
||||||
|
|
@ -54,8 +46,6 @@ 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;
|
||||||
|
|
@ -70,7 +60,6 @@ interface ParticipantRowProps {
|
||||||
|
|
||||||
function ParticipantRow({
|
function ParticipantRow({
|
||||||
name,
|
name,
|
||||||
feedLabel,
|
|
||||||
isTbd,
|
isTbd,
|
||||||
isWinner,
|
isWinner,
|
||||||
isLoser,
|
isLoser,
|
||||||
|
|
@ -125,7 +114,7 @@ function ParticipantRow({
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ")}
|
.join(" ")}
|
||||||
>
|
>
|
||||||
{name ?? feedLabel ?? "TBD"}
|
{name ?? "TBD"}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Owner name below participant name */}
|
{/* Owner name below participant name */}
|
||||||
|
|
@ -161,8 +150,6 @@ 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({
|
||||||
|
|
@ -170,8 +157,6 @@ 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;
|
||||||
|
|
@ -202,13 +187,6 @@ 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 */}
|
||||||
|
|
@ -230,7 +208,6 @@ 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}
|
||||||
|
|
@ -244,7 +221,6 @@ 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}
|
||||||
|
|
@ -261,45 +237,54 @@ export function BracketMatchSlot({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Connector column ─────────────────────────────────────────────────────────
|
// ─── Per-pair connector column ────────────────────────────────────────────────
|
||||||
|
|
||||||
interface ConnectorColumnProps {
|
interface ConnectorColumnProps {
|
||||||
/** Edges crossing this gutter, in slot units. */
|
currentMatches: BracketMatch[];
|
||||||
edges: { fromCenter: number; toCenter: number }[];
|
nextMatches: BracketMatch[];
|
||||||
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 destY = toCenter * rowHeight - offset;
|
const currentSlotH = bracketHeight / Math.max(currentMatches.length, 1);
|
||||||
const ys = sources.map((c) => c * rowHeight - offset).toSorted((a, b) => a - b);
|
const nextSlotH = bracketHeight / Math.max(nextMatches.length, 1);
|
||||||
if (ys.length === 1) {
|
|
||||||
paths.push(`M 0 ${ys[0]} H ${mid} V ${destY} H ${CONNECTOR_WIDTH}`);
|
// Use halving U-shapes only when prev > 1 (avoids false-positive 1→1 side branches like 3PG→Finals)
|
||||||
continue;
|
if (nextMatches.length === Math.ceil(currentMatches.length / 2) && currentMatches.length > 1) {
|
||||||
|
// Standard single-elimination halving: U-shape connectors
|
||||||
|
for (let k = 0; k < nextMatches.length; k++) {
|
||||||
|
const topY = (2 * k) * currentSlotH + currentSlotH / 2;
|
||||||
|
const midY = k * nextSlotH + nextSlotH / 2;
|
||||||
|
const botIdx = 2 * k + 1;
|
||||||
|
|
||||||
|
if (botIdx < currentMatches.length) {
|
||||||
|
const botY = botIdx * currentSlotH + currentSlotH / 2;
|
||||||
|
paths.push(`M 0 ${topY} H ${mid} V ${botY} H 0`);
|
||||||
|
paths.push(`M ${mid} ${midY} H ${CONNECTOR_WIDTH}`);
|
||||||
|
} else {
|
||||||
|
paths.push(`M 0 ${topY} H ${CONNECTOR_WIDTH}`);
|
||||||
}
|
}
|
||||||
paths.push(`M 0 ${ys[0]} H ${mid} V ${ys[ys.length - 1]} H 0`);
|
}
|
||||||
for (const y of ys.slice(1, -1)) paths.push(`M 0 ${y} H ${mid}`);
|
} else {
|
||||||
paths.push(`M ${mid} ${destY} H ${CONNECTOR_WIDTH}`);
|
// Non-standard (byes, play-ins, etc.): trace winners by participantId
|
||||||
|
const winnerToIdx = new Map<string, number>();
|
||||||
|
currentMatches.forEach((m, idx) => {
|
||||||
|
if (m.winnerId) winnerToIdx.set(m.winnerId, idx);
|
||||||
|
});
|
||||||
|
|
||||||
|
nextMatches.forEach((nextMatch, nextIdx) => {
|
||||||
|
const destY = nextIdx * nextSlotH + nextSlotH / 2;
|
||||||
|
for (const pId of [nextMatch.participant1Id, nextMatch.participant2Id]) {
|
||||||
|
if (!pId) continue;
|
||||||
|
const srcIdx = winnerToIdx.get(pId);
|
||||||
|
if (srcIdx === undefined) continue;
|
||||||
|
const srcY = srcIdx * currentSlotH + currentSlotH / 2;
|
||||||
|
paths.push(`M 0 ${srcY} H ${mid} V ${destY} H ${CONNECTOR_WIDTH}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -325,131 +310,52 @@ function ConnectorColumn({ edges, rowHeight, offset, bracketHeight }: ConnectorC
|
||||||
|
|
||||||
// ─── 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 {
|
||||||
geometry: BracketGeometry;
|
visibleRounds: string[];
|
||||||
|
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({
|
||||||
geometry,
|
visibleRounds,
|
||||||
|
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;
|
||||||
const { layout, rowHeight, bracketHeight, offset } = geometry;
|
|
||||||
|
|
||||||
const [firstColumn, lastColumn] = columnRange ?? [0, layout.columns.length - 1];
|
|
||||||
const visible = layout.columns.slice(firstColumn, lastColumn + 1);
|
|
||||||
|
|
||||||
// Cards keep a fixed height regardless of how many share a column — stretching a
|
|
||||||
// lone final to fill its column is what made it tower over the rest of the bracket.
|
|
||||||
const cardHeight = Math.min(
|
|
||||||
Math.max(rowHeight - CARD_GAP, 1),
|
|
||||||
MAX_CARD_HEIGHT
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", width: "100%", height: bracketHeight + LABEL_HEIGHT, transition: tr ? `height ${tr}` : undefined }}>
|
<div style={{ display: "flex", width: "100%", height: bracketHeight + LABEL_HEIGHT, transition: tr ? `height ${tr}` : undefined }}>
|
||||||
{visible.map((column, vi) => {
|
{visibleRounds.map((round, ri) => {
|
||||||
const ci = firstColumn + vi;
|
const roundMatches = matchesByRound.get(round) ?? [];
|
||||||
const gutterEdges = layout.edges.filter((e) => e.fromColumn === ci);
|
const slotHeight = bracketHeight / Math.max(roundMatches.length, 1);
|
||||||
|
const cardHeight = Math.min(slotHeight - CARD_GAP, MAX_CARD_HEIGHT);
|
||||||
|
const cardTop = (slotHeight - cardHeight) / 2;
|
||||||
|
const nextRound = ri < visibleRounds.length - 1 ? visibleRounds[ri + 1] : null;
|
||||||
|
const nextMatches = nextRound ? (matchesByRound.get(nextRound) ?? []) : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={column.label + ci} style={{ display: "contents" }}>
|
<div key={round} 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" }}
|
||||||
>
|
>
|
||||||
{column.label}
|
{round}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ position: "relative", height: bracketHeight, transition: tr ? `height ${tr}` : undefined }}>
|
<div style={{ position: "relative", height: bracketHeight, transition: tr ? `height ${tr}` : undefined }}>
|
||||||
{column.matches.map(({ match, center }) => (
|
{roundMatches.map((match, matchIdx) => (
|
||||||
<div
|
<div
|
||||||
key={match.id}
|
key={match.id}
|
||||||
data-match-id={match.id}
|
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: center * rowHeight - offset - cardHeight / 2,
|
top: matchIdx * slotHeight + cardTop,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
height: cardHeight,
|
height: cardHeight,
|
||||||
|
|
@ -461,8 +367,6 @@ export function TreeColumns({
|
||||||
slotHeight={cardHeight}
|
slotHeight={cardHeight}
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
feeders={feeders}
|
|
||||||
template={template}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -470,11 +374,10 @@ export function TreeColumns({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Connector between this column and the next */}
|
{/* Connector between this column and the next */}
|
||||||
{vi < visible.length - 1 && (
|
{nextRound && (
|
||||||
<ConnectorColumn
|
<ConnectorColumn
|
||||||
edges={gutterEdges}
|
currentMatches={roundMatches}
|
||||||
rowHeight={rowHeight}
|
nextMatches={nextMatches}
|
||||||
offset={offset}
|
|
||||||
bracketHeight={bracketHeight}
|
bracketHeight={bracketHeight}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -493,8 +396,6 @@ 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({
|
||||||
|
|
@ -503,19 +404,13 @@ 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 geometry = bracketGeometry(
|
const maxMatches = Math.max(...mainRounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
|
||||||
mainRounds,
|
const bracketHeight = maxMatches * (DESIRED_CARD_HEIGHT + CARD_GAP);
|
||||||
matchesByRound,
|
const minWidth = mainRounds.length * COLUMN_WIDTH + Math.max(0, mainRounds.length - 1) * CONNECTOR_WIDTH;
|
||||||
feeders,
|
|
||||||
template?.rounds.map((r) => r.name) ?? mainRounds
|
|
||||||
);
|
|
||||||
const { bracketHeight, minWidth } = geometry;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -524,11 +419,11 @@ export function BracketTreeView({
|
||||||
>
|
>
|
||||||
<div style={{ minWidth }}>
|
<div style={{ minWidth }}>
|
||||||
<TreeColumns
|
<TreeColumns
|
||||||
geometry={geometry}
|
visibleRounds={mainRounds}
|
||||||
|
matchesByRound={matchesByRound}
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
feeders={feeders}
|
bracketHeight={bracketHeight}
|
||||||
template={template}
|
|
||||||
/>
|
/>
|
||||||
{thirdPlaceMatch && (
|
{thirdPlaceMatch && (
|
||||||
<div style={{ display: "flex", paddingTop: 20 }}>
|
<div style={{ display: "flex", paddingTop: 20 }}>
|
||||||
|
|
@ -546,8 +441,6 @@ 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,11 +1,5 @@
|
||||||
import type { BracketTemplate, ConferenceGroup } from "~/lib/bracket-templates";
|
import type { ConferenceGroup } from "~/lib/bracket-templates";
|
||||||
import type { FeederMap } from "~/lib/bracket-layout";
|
import { TreeColumns, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
|
||||||
import {
|
|
||||||
TreeColumns,
|
|
||||||
bracketGeometry,
|
|
||||||
type BracketMatch,
|
|
||||||
type BracketOwnership,
|
|
||||||
} from "./BracketTreeView";
|
|
||||||
import { BracketTreePaginated } from "./BracketTreePaginated";
|
import { BracketTreePaginated } from "./BracketTreePaginated";
|
||||||
|
|
||||||
interface NbaBracketLayoutProps {
|
interface NbaBracketLayoutProps {
|
||||||
|
|
@ -16,10 +10,11 @@ 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
|
||||||
|
|
@ -33,6 +28,11 @@ 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,10 +40,7 @@ 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))
|
||||||
|
|
@ -60,7 +57,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 sharedGeometry = bracketGeometry(sharedRounds, sharedMatches, feeders, roundOrder);
|
const sharedHeight = bracketHeight(sharedMatches, sharedRounds);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -69,7 +66,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 geometry = bracketGeometry(confRounds, confMatches, feeders, roundOrder);
|
const height = bracketHeight(confMatches, confRounds);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={group.name}>
|
<div key={group.name}>
|
||||||
|
|
@ -77,11 +74,11 @@ export function NbaBracketLayout({
|
||||||
{group.name}
|
{group.name}
|
||||||
</p>
|
</p>
|
||||||
<TreeColumns
|
<TreeColumns
|
||||||
geometry={geometry}
|
visibleRounds={confRounds}
|
||||||
|
matchesByRound={confMatches}
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
feeders={feeders}
|
bracketHeight={height}
|
||||||
template={template}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -90,11 +87,11 @@ export function NbaBracketLayout({
|
||||||
{sharedRounds.length > 0 && (
|
{sharedRounds.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<TreeColumns
|
<TreeColumns
|
||||||
geometry={sharedGeometry}
|
visibleRounds={sharedRounds}
|
||||||
|
matchesByRound={sharedMatches}
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
feeders={feeders}
|
bracketHeight={sharedHeight}
|
||||||
template={template}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -108,8 +105,6 @@ export function NbaBracketLayout({
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
firstScoringRoundIdx={scoringRoundIdx}
|
firstScoringRoundIdx={scoringRoundIdx}
|
||||||
feeders={feeders}
|
|
||||||
template={template}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,7 @@ 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, type BracketTemplate } from "~/lib/bracket-templates";
|
import { getBracketTemplate } 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";
|
||||||
|
|
||||||
|
|
@ -77,6 +76,43 @@ 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;
|
||||||
|
|
@ -138,182 +174,6 @@ 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++) {
|
||||||
|
|
@ -355,13 +215,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 consolation = findConsolationRound(template);
|
const thirdPlaceRound = template?.rounds
|
||||||
const thirdPlaceRound = consolation?.round;
|
.find((r) => template.rounds.some((other) => other.loserFeedsInto === r.name))
|
||||||
|
?.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];
|
||||||
|
|
@ -370,20 +230,43 @@ 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[] = [];
|
||||||
const rankedEntries = computeRankedEntries(
|
let nextRank = 2;
|
||||||
matches,
|
for (let ri = rounds.length - 1; ri >= 0; ri--) {
|
||||||
rounds,
|
const roundName = rounds[ri];
|
||||||
matchesByRound,
|
const roundLosers = losersByRound.get(roundName) || [];
|
||||||
consolation,
|
const totalMatchesInRound = matchesByRound.get(roundName)?.length ?? 0;
|
||||||
ownershipMap,
|
if (roundLosers.length > 0) {
|
||||||
roundsWithLosersPlacedLater(template, consolation)
|
const rankLabel = `T${nextRank}`;
|
||||||
);
|
for (const loser of roundLosers) {
|
||||||
|
rankedEntries.push({ ...loser, rankLabel });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextRank += totalMatchesInRound;
|
||||||
|
}
|
||||||
|
|
||||||
const rankedParticipantIds = new Set(rankedEntries.map((e) => e.participant.id));
|
const rankedParticipantIds = new Set(rankedEntries.map((e) => e.participant.id));
|
||||||
if (bracketWinner) rankedParticipantIds.add(bracketWinner.id);
|
if (bracketWinner) rankedParticipantIds.add(bracketWinner.id);
|
||||||
|
|
@ -401,14 +284,7 @@ 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;
|
||||||
|
|
@ -444,8 +320,6 @@ 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
|
||||||
|
|
@ -456,8 +330,6 @@ export function PlayoffBracket({
|
||||||
userParticipantIds={userParticipantSet}
|
userParticipantIds={userParticipantSet}
|
||||||
conferenceGroups={template.conferenceGroups}
|
conferenceGroups={template.conferenceGroups}
|
||||||
scoringRoundIdx={scoringRoundIdx}
|
scoringRoundIdx={scoringRoundIdx}
|
||||||
feeders={feeders}
|
|
||||||
template={template}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
@ -469,8 +341,6 @@ 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>
|
||||||
|
|
||||||
|
|
@ -483,8 +353,6 @@ export function PlayoffBracket({
|
||||||
userParticipantIds={userParticipantSet}
|
userParticipantIds={userParticipantSet}
|
||||||
firstScoringRoundIdx={scoringRoundIdx}
|
firstScoringRoundIdx={scoringRoundIdx}
|
||||||
thirdPlaceRound={thirdPlaceRound}
|
thirdPlaceRound={thirdPlaceRound}
|
||||||
feeders={feeders}
|
|
||||||
template={template}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -55,9 +55,7 @@ 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 "—";
|
||||||
// At most 2 decimals, trailing zeros trimmed (1.50→1.5). Mirrors the Discord
|
return n % 1 === 0 ? n.toString() : n.toFixed(2);
|
||||||
// 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,18 +1,8 @@
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import type { BracketPhase, BracketTemplate, ConferenceGroup } from "~/lib/bracket-templates";
|
import type { BracketPhase, ConferenceGroup } from "~/lib/bracket-templates";
|
||||||
import type { FeederMap } from "~/lib/bracket-layout";
|
import { TreeColumns, BracketMatchSlot, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
|
||||||
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[]>;
|
||||||
|
|
@ -20,10 +10,11 @@ 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
|
||||||
|
|
@ -38,6 +29,11 @@ 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 {
|
||||||
|
|
@ -145,10 +141,7 @@ 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) => {
|
||||||
|
|
@ -159,22 +152,7 @@ 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;
|
||||||
// Restrict each round to the match numbers this phase's groups actually claim.
|
const phaseMatchesByRound = new Map(phaseRounds.map((r) => [r, matchesByRound.get(r) ?? []]));
|
||||||
// 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);
|
||||||
|
|
@ -201,87 +179,50 @@ 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
|
||||||
geometry={geometry}
|
visibleRounds={gRounds}
|
||||||
|
matchesByRound={gMatches}
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
feeders={feeders}
|
bracketHeight={phaseHeight(gMatches, gRounds)}
|
||||||
template={template}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{sharedRounds.length > 0 && (
|
{sharedRounds.length > 0 && (
|
||||||
<TreeColumns
|
<TreeColumns
|
||||||
geometry={bracketGeometry(sharedRounds, sharedMatchesByRound, feeders, roundOrder)}
|
visibleRounds={sharedRounds}
|
||||||
|
matchesByRound={sharedMatchesByRound}
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
feeders={feeders}
|
bracketHeight={phaseHeight(sharedMatchesByRound, sharedRounds)}
|
||||||
template={template}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<TreeColumns
|
<TreeColumns
|
||||||
geometry={bracketGeometry(phaseRounds, phaseMatchesByRound, feeders, roundOrder)}
|
visibleRounds={phaseRounds}
|
||||||
|
matchesByRound={phaseMatchesByRound}
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
feeders={feeders}
|
bracketHeight={phaseHeight(phaseMatchesByRound, phaseRounds)}
|
||||||
template={template}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile — paged one group at a time, matching the desktop split. Paging a
|
{/* Mobile */}
|
||||||
whole phase would merge the winners and elimination brackets into one
|
<div className="md:hidden">
|
||||||
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}
|
||||||
|
|
@ -289,8 +230,6 @@ 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>
|
||||||
|
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { render, within } from "@testing-library/react";
|
|
||||||
import { NbaBracketLayout } from "../NbaBracketLayout";
|
|
||||||
import { buildFeederMap } from "~/lib/bracket-layout";
|
|
||||||
import type { BracketTemplate } from "~/lib/bracket-templates";
|
|
||||||
import type { BracketMatch } from "../BracketTreeView";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* NbaBracketLayout renders a desktop view and a mobile pager side by side, hidden from
|
|
||||||
* each other by Tailwind breakpoints. Both need `feeders` and `template` — without them
|
|
||||||
* bracketGeometry falls back to index-derived positions and an unplayed slot reads "TBD"
|
|
||||||
* where the feeder graph would name the game it is waiting on.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const TEMPLATE: BracketTemplate = {
|
|
||||||
id: "test_conf_4",
|
|
||||||
name: "Two-conference test bracket",
|
|
||||||
totalTeams: 4,
|
|
||||||
scoringStartsAtRound: "Final",
|
|
||||||
rounds: [
|
|
||||||
{ name: "Semis", matchCount: 2, feedsInto: "Final", isScoring: false },
|
|
||||||
{ name: "Final", matchCount: 1, feedsInto: null, isScoring: true },
|
|
||||||
],
|
|
||||||
conferenceGroups: [
|
|
||||||
{ name: "East", roundMatchNumbers: { Semis: [1] } },
|
|
||||||
{ name: "West", roundMatchNumbers: { Semis: [2] } },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const ROUNDS = ["Semis", "Final"];
|
|
||||||
|
|
||||||
function match(
|
|
||||||
round: string,
|
|
||||||
matchNumber: number,
|
|
||||||
overrides: Partial<BracketMatch> = {}
|
|
||||||
): BracketMatch {
|
|
||||||
return {
|
|
||||||
id: `${round}-${matchNumber}`,
|
|
||||||
round,
|
|
||||||
matchNumber,
|
|
||||||
participant1Id: null,
|
|
||||||
participant2Id: null,
|
|
||||||
winnerId: null,
|
|
||||||
loserId: null,
|
|
||||||
isComplete: false,
|
|
||||||
participant1Score: null,
|
|
||||||
participant2Score: null,
|
|
||||||
...overrides,
|
|
||||||
} as BracketMatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Semis are played; the Final's two slots are still empty. */
|
|
||||||
const MATCHES_BY_ROUND = new Map<string, BracketMatch[]>([
|
|
||||||
[
|
|
||||||
"Semis",
|
|
||||||
[
|
|
||||||
match("Semis", 1, { participant1Id: "p1", participant2Id: "p2" }),
|
|
||||||
match("Semis", 2, { participant1Id: "p3", participant2Id: "p4" }),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
["Final", [match("Final", 1)]],
|
|
||||||
]);
|
|
||||||
|
|
||||||
function renderLayout(withGraph: boolean) {
|
|
||||||
const { container } = render(
|
|
||||||
<NbaBracketLayout
|
|
||||||
matches={[...MATCHES_BY_ROUND.values()].flat()}
|
|
||||||
rounds={ROUNDS}
|
|
||||||
matchesByRound={MATCHES_BY_ROUND}
|
|
||||||
ownershipMap={new Map()}
|
|
||||||
userParticipantIds={new Set()}
|
|
||||||
conferenceGroups={TEMPLATE.conferenceGroups ?? []}
|
|
||||||
scoringRoundIdx={1}
|
|
||||||
feeders={withGraph ? buildFeederMap(TEMPLATE) : undefined}
|
|
||||||
template={withGraph ? TEMPLATE : undefined}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Both panes render in jsdom — media queries are class-based, not applied — so scope
|
|
||||||
// each assertion to the pane it is about.
|
|
||||||
const mobile = container.querySelector<HTMLElement>(".md\\:hidden");
|
|
||||||
const desktop = container.querySelector<HTMLElement>(".md\\:flex");
|
|
||||||
if (!mobile || !desktop) throw new Error("Expected both a mobile and a desktop pane");
|
|
||||||
return { mobile, desktop };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("NbaBracketLayout", () => {
|
|
||||||
it("names the feeding game in the mobile pager", () => {
|
|
||||||
// Only slots filled by advancement get a label; a directly seeded slot with no
|
|
||||||
// participant still reads "TBD", which is why this asserts on the Final's slots.
|
|
||||||
const { mobile } = renderLayout(true);
|
|
||||||
|
|
||||||
expect(within(mobile).getAllByText(/Winner of/).length).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the mobile pager the same slot labels as the desktop view", () => {
|
|
||||||
const { mobile, desktop } = renderLayout(true);
|
|
||||||
|
|
||||||
const labels = (pane: HTMLElement) =>
|
|
||||||
within(pane)
|
|
||||||
.getAllByText(/Winner of/)
|
|
||||||
.map((el) => el.textContent)
|
|
||||||
.toSorted();
|
|
||||||
|
|
||||||
expect(labels(mobile)).toEqual(labels(desktop));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to TBD when the feeder graph is unavailable", () => {
|
|
||||||
// Guards the assertions above: without feeders/template there is nothing to name a
|
|
||||||
// slot with, which is exactly the state the mobile pane was stuck in.
|
|
||||||
const { mobile } = renderLayout(false);
|
|
||||||
|
|
||||||
expect(within(mobile).queryByText(/Winner of/)).toBeNull();
|
|
||||||
expect(within(mobile).getAllByText("TBD").length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,16 +1,5 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { render, screen, within } from "@testing-library/react";
|
import { buildFeederMap, groupMatchesByRound, computeEliminatedByRound } from "../PlayoffBracket";
|
||||||
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
|
||||||
|
|
@ -63,72 +52,88 @@ describe("groupMatchesByRound", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Rendered LLWS bracket — geometry and empty-slot labels
|
// buildFeederMap
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe("PlayoffBracket — rendered LLWS bracket", () => {
|
describe("buildFeederMap", () => {
|
||||||
const LLWS_ROUNDS = (getBracketTemplate("llws_20")?.rounds ?? []).map((r) => r.name);
|
it("returns an empty map when there is only one round", () => {
|
||||||
|
const matches = [makeMatch("Finals", 1)];
|
||||||
/** Every LLWS match, all unplayed, so each slot shows what will fill it. */
|
const map = buildFeederMap(groupMatchesByRound(matches), ["Finals"]);
|
||||||
function emptyLlwsMatches(): Match[] {
|
expect(map.size).toBe(0);
|
||||||
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("still shows TBD for a directly seeded slot", () => {
|
it("maps SF slots to the correct QF matches for an 8-team bracket", () => {
|
||||||
render(
|
const rounds = ["Quarterfinals", "Semifinals", "Finals"];
|
||||||
<PlayoffBracket
|
const matches = [
|
||||||
matches={emptyLlwsMatches()}
|
makeMatch("Quarterfinals", 1),
|
||||||
rounds={LLWS_ROUNDS}
|
makeMatch("Quarterfinals", 2),
|
||||||
bracketTemplateId="llws_20"
|
makeMatch("Quarterfinals", 3),
|
||||||
/>
|
makeMatch("Quarterfinals", 4),
|
||||||
);
|
makeMatch("Semifinals", 1),
|
||||||
|
makeMatch("Semifinals", 2),
|
||||||
|
makeMatch("Finals", 1),
|
||||||
|
];
|
||||||
|
|
||||||
// The opening round is seeded, not fed, so it has nothing to name.
|
const map = buildFeederMap(groupMatchesByRound(matches), rounds);
|
||||||
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("gives every card the same height, including a lone final", () => {
|
it("maps Finals slots to the correct SF matches", () => {
|
||||||
const { container } = render(
|
const rounds = ["Quarterfinals", "Semifinals", "Finals"];
|
||||||
<PlayoffBracket
|
const matches = [
|
||||||
matches={emptyLlwsMatches()}
|
makeMatch("Quarterfinals", 1),
|
||||||
rounds={LLWS_ROUNDS}
|
makeMatch("Quarterfinals", 2),
|
||||||
bracketTemplateId="llws_20"
|
makeMatch("Quarterfinals", 3),
|
||||||
/>
|
makeMatch("Quarterfinals", 4),
|
||||||
);
|
makeMatch("Semifinals", 1),
|
||||||
|
makeMatch("Semifinals", 2),
|
||||||
|
makeMatch("Finals", 1),
|
||||||
|
];
|
||||||
|
|
||||||
const heights = new Set(
|
const map = buildFeederMap(groupMatchesByRound(matches), rounds);
|
||||||
[...container.querySelectorAll<HTMLElement>("[data-match-id]")].map(
|
|
||||||
(el) => el.style.height
|
expect(map.get("Finals:1:p1")).toEqual({ round: "Semifinals", matchNumber: 1 });
|
||||||
)
|
expect(map.get("Finals:1:p2")).toEqual({ round: "Semifinals", matchNumber: 2 });
|
||||||
);
|
});
|
||||||
// Previously a one-match column stretched its card to fill the bracket height.
|
|
||||||
expect(heights.size).toBe(1);
|
it("does not add an entry when the source match does not exist in the previous round", () => {
|
||||||
|
const rounds = ["Quarterfinals", "Finals"];
|
||||||
|
const matches = [
|
||||||
|
makeMatch("Quarterfinals", 1),
|
||||||
|
makeMatch("Quarterfinals", 2),
|
||||||
|
makeMatch("Finals", 1),
|
||||||
|
];
|
||||||
|
|
||||||
|
const map = buildFeederMap(groupMatchesByRound(matches), rounds);
|
||||||
|
|
||||||
|
expect(map.get("Finals:1:p1")).toEqual({ round: "Quarterfinals", matchNumber: 1 });
|
||||||
|
expect(map.get("Finals:1:p2")).toEqual({ round: "Quarterfinals", matchNumber: 2 });
|
||||||
|
expect(map.has("Finals:2:p1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles a 16-team bracket correctly for Round of 16 → Quarterfinals", () => {
|
||||||
|
const rounds = ["Round of 16", "Quarterfinals", "Semifinals", "Finals"];
|
||||||
|
const matches = [
|
||||||
|
...[1, 2, 3, 4, 5, 6, 7, 8].map((n) => makeMatch("Round of 16", n)),
|
||||||
|
...[1, 2, 3, 4].map((n) => makeMatch("Quarterfinals", n)),
|
||||||
|
...[1, 2].map((n) => makeMatch("Semifinals", n)),
|
||||||
|
makeMatch("Finals", 1),
|
||||||
|
];
|
||||||
|
|
||||||
|
const map = buildFeederMap(groupMatchesByRound(matches), rounds);
|
||||||
|
|
||||||
|
expect(map.get("Quarterfinals:1:p1")).toEqual({ round: "Round of 16", matchNumber: 1 });
|
||||||
|
expect(map.get("Quarterfinals:1:p2")).toEqual({ round: "Round of 16", matchNumber: 2 });
|
||||||
|
expect(map.get("Quarterfinals:4:p1")).toEqual({ round: "Round of 16", matchNumber: 7 });
|
||||||
|
expect(map.get("Quarterfinals:4:p2")).toEqual({ round: "Round of 16", matchNumber: 8 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -286,526 +291,3 @@ describe("computeEliminatedByRound", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// computeRankedEntries — consolation ("third place") round handling
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// fifa_48 round order. The Third Place Game sits between the Semifinals (whose
|
|
||||||
// losers feed it) and the Finals.
|
|
||||||
const FIFA_ROUNDS = ["Quarterfinals", "Semifinals", "Third Place Game", "Finals"];
|
|
||||||
|
|
||||||
const FIFA_CONSOLATION = {
|
|
||||||
round: "Third Place Game",
|
|
||||||
feederRound: "Semifinals",
|
|
||||||
};
|
|
||||||
|
|
||||||
type MatchOpts = {
|
|
||||||
/** Which slot the winner occupies. Defaults to 1. */
|
|
||||||
winnerSlot?: 1 | 2;
|
|
||||||
winnerScore?: string;
|
|
||||||
loserScore?: string;
|
|
||||||
/**
|
|
||||||
* Drop the hydrated `winner` relation, keeping `loser` and both ids — the shape a
|
|
||||||
* hand-built match object can arrive in. The loser is still placeable this way.
|
|
||||||
*/
|
|
||||||
missingWinnerRelation?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** A completed match. */
|
|
||||||
function makeRankedMatch(
|
|
||||||
round: string,
|
|
||||||
matchNumber: number,
|
|
||||||
winnerId: string,
|
|
||||||
loserId: string,
|
|
||||||
opts: MatchOpts = {}
|
|
||||||
): Match {
|
|
||||||
const winnerIsP1 = (opts.winnerSlot ?? 1) === 1;
|
|
||||||
const p1 = winnerIsP1 ? winnerId : loserId;
|
|
||||||
const p2 = winnerIsP1 ? loserId : winnerId;
|
|
||||||
return {
|
|
||||||
id: `${round}-${matchNumber}`,
|
|
||||||
round,
|
|
||||||
matchNumber,
|
|
||||||
participant1Id: p1,
|
|
||||||
participant2Id: p2,
|
|
||||||
winnerId,
|
|
||||||
loserId,
|
|
||||||
isComplete: true,
|
|
||||||
participant1Score: (winnerIsP1 ? opts.winnerScore : opts.loserScore) ?? null,
|
|
||||||
participant2Score: (winnerIsP1 ? opts.loserScore : opts.winnerScore) ?? null,
|
|
||||||
participant1: { id: p1, name: p1 },
|
|
||||||
participant2: { id: p2, name: p2 },
|
|
||||||
winner: opts.missingWinnerRelation ? null : { id: winnerId, name: winnerId },
|
|
||||||
loser: { id: loserId, name: loserId },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A scheduled-but-unplayed match. Bracket rows are pre-generated with their slots
|
|
||||||
* filled as earlier rounds resolve, so an unplayed 3PG still lists both SF losers.
|
|
||||||
*/
|
|
||||||
function makePendingMatch(
|
|
||||||
round: string,
|
|
||||||
matchNumber: number,
|
|
||||||
participant1Id: string | null,
|
|
||||||
participant2Id: string | null
|
|
||||||
): Match {
|
|
||||||
return {
|
|
||||||
id: `${round}-${matchNumber}`,
|
|
||||||
round,
|
|
||||||
matchNumber,
|
|
||||||
participant1Id,
|
|
||||||
participant2Id,
|
|
||||||
winnerId: null,
|
|
||||||
loserId: null,
|
|
||||||
isComplete: false,
|
|
||||||
participant1Score: null,
|
|
||||||
participant2Score: null,
|
|
||||||
participant1: participant1Id ? { id: participant1Id, name: participant1Id } : null,
|
|
||||||
participant2: participant2Id ? { id: participant2Id, name: participant2Id } : null,
|
|
||||||
winner: null,
|
|
||||||
loser: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A full fifa_48-shaped knockout tail: 4 QF, 2 SF, the 3PG, and the Final. */
|
|
||||||
function fifaMatches(
|
|
||||||
overrides: { played?: boolean; thirdPlace?: Match } = {}
|
|
||||||
): Match[] {
|
|
||||||
const played = overrides.played ?? true;
|
|
||||||
return [
|
|
||||||
makeRankedMatch("Quarterfinals", 1, "sfA", "qf1"),
|
|
||||||
makeRankedMatch("Quarterfinals", 2, "sfB", "qf2"),
|
|
||||||
makeRankedMatch("Quarterfinals", 3, "sfC", "qf3"),
|
|
||||||
makeRankedMatch("Quarterfinals", 4, "sfD", "qf4"),
|
|
||||||
makeRankedMatch("Semifinals", 1, "sfA", "sfB"),
|
|
||||||
makeRankedMatch("Semifinals", 2, "sfC", "sfD"),
|
|
||||||
overrides.thirdPlace ??
|
|
||||||
(played
|
|
||||||
? makeRankedMatch("Third Place Game", 1, "sfB", "sfD")
|
|
||||||
: makePendingMatch("Third Place Game", 1, "sfB", "sfD")),
|
|
||||||
played
|
|
||||||
? makeRankedMatch("Finals", 1, "sfA", "sfC")
|
|
||||||
: makePendingMatch("Finals", 1, "sfA", "sfC"),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Rank the fifa fixture, defaulting to the fifa_48 consolation config. */
|
|
||||||
function rankFifa(
|
|
||||||
matches: Match[] = fifaMatches(),
|
|
||||||
ownership: Map<string, { participantId: string; teamName: string; teamId: string }> = new Map(),
|
|
||||||
consolation: typeof FIFA_CONSOLATION | undefined = FIFA_CONSOLATION
|
|
||||||
) {
|
|
||||||
return computeRankedEntries(
|
|
||||||
matches,
|
|
||||||
FIFA_ROUNDS,
|
|
||||||
groupMatchesByRound(matches),
|
|
||||||
consolation,
|
|
||||||
ownership
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function rankOf(entries: ReturnType<typeof computeRankedEntries>, id: string) {
|
|
||||||
return entries.find((e) => e.participant.id === id)?.rankLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// llws_20 — double elimination, where a winners-bracket loss places nobody
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Stable participant id for an llws_20 bracket slot. */
|
|
||||||
function llwsTeam(i: number): string {
|
|
||||||
return `t${String(i).padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Play a full 20-team LLWS tournament, always advancing the lower-numbered
|
|
||||||
* participant id so the outcome is deterministic, and return every match.
|
|
||||||
* Routing comes from the real advancement map rather than being hand-listed.
|
|
||||||
*/
|
|
||||||
function llwsMatches(): Match[] {
|
|
||||||
const template = getBracketTemplate("llws_20");
|
|
||||||
if (!template) throw new Error("llws_20 template missing");
|
|
||||||
|
|
||||||
// round → matchNumber → [p1, p2]
|
|
||||||
const slots = new Map<string, Map<number, [string | null, string | null]>>();
|
|
||||||
for (const round of template.rounds) {
|
|
||||||
const byNumber = new Map<number, [string | null, string | null]>();
|
|
||||||
for (let n = 1; n <= round.matchCount; n++) byNumber.set(n, [null, null]);
|
|
||||||
slots.set(round.name, byNumber);
|
|
||||||
}
|
|
||||||
const put = (round: string, n: number, slot: 0 | 1, id: string) => {
|
|
||||||
const pair = slots.get(round)?.get(n);
|
|
||||||
if (pair) pair[slot] = id;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Seed the Opening Round and the four byes, mirroring generateLLWS20Bracket.
|
|
||||||
for (const [base, roundBase] of [[0, 1], [10, 5]] as const) {
|
|
||||||
for (let local = 0; local < 4; local++) {
|
|
||||||
put("Opening Round", roundBase + local, 0, llwsTeam(base + local * 2));
|
|
||||||
put("Opening Round", roundBase + local, 1, llwsTeam(base + local * 2 + 1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
put("Winners Round 2", 1, 0, llwsTeam(8));
|
|
||||||
put("Winners Round 2", 2, 0, llwsTeam(9));
|
|
||||||
put("Winners Round 2", 3, 0, llwsTeam(18));
|
|
||||||
put("Winners Round 2", 4, 0, llwsTeam(19));
|
|
||||||
|
|
||||||
const matches: Match[] = [];
|
|
||||||
for (const round of template.rounds) {
|
|
||||||
for (let n = 1; n <= round.matchCount; n++) {
|
|
||||||
const [p1, p2] = slots.get(round.name)?.get(n) ?? [null, null];
|
|
||||||
if (!p1 || !p2) throw new Error(`${round.name} #${n} was not filled`);
|
|
||||||
// Deterministic: the lower id always wins.
|
|
||||||
const winnerId = p1 < p2 ? p1 : p2;
|
|
||||||
const loserId = p1 < p2 ? p2 : p1;
|
|
||||||
matches.push(
|
|
||||||
makeRankedMatch(round.name, n, winnerId, loserId, {
|
|
||||||
winnerSlot: winnerId === p1 ? 1 : 2,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const { winner, loser } = resolveLLWSAdvancement(round.name, n);
|
|
||||||
if (winner) put(winner.round, winner.matchNumber, winner.slot === "participant1Id" ? 0 : 1, winnerId);
|
|
||||||
if (loser) put(loser.round, loser.matchNumber, loser.slot === "participant1Id" ? 0 : 1, loserId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("computeRankedEntries — llws_20 double elimination", () => {
|
|
||||||
const template = getBracketTemplate("llws_20");
|
|
||||||
const rounds = template?.rounds.map((r) => r.name) ?? [];
|
|
||||||
|
|
||||||
function rankLlws() {
|
|
||||||
const matches = llwsMatches();
|
|
||||||
const consolation = findConsolationRound(template);
|
|
||||||
return computeRankedEntries(
|
|
||||||
matches,
|
|
||||||
rounds,
|
|
||||||
groupMatchesByRound(matches),
|
|
||||||
consolation,
|
|
||||||
new Map(),
|
|
||||||
roundsWithLosersPlacedLater(template, consolation)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
it("ranks all 19 non-champions exactly once", () => {
|
|
||||||
const entries = rankLlws();
|
|
||||||
expect(entries).toHaveLength(19);
|
|
||||||
expect(new Set(entries.map((e) => e.participant.id)).size).toBe(19);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("gives the top 8 the positions the scoring tiers depend on", () => {
|
|
||||||
const entries = rankLlws();
|
|
||||||
const labels = entries.map((e) => e.rankLabel);
|
|
||||||
// 2nd (World Championship loser), then 3rd and 4th decided by the consolation
|
|
||||||
// game, then the two 5–6 and two 7–8 tier teams.
|
|
||||||
expect(labels[0]).toBe("T2");
|
|
||||||
expect(labels.filter((l) => l === "3")).toHaveLength(1);
|
|
||||||
expect(labels.filter((l) => l === "4")).toHaveLength(1);
|
|
||||||
expect(labels.filter((l) => l === "T5")).toHaveLength(2);
|
|
||||||
expect(labels.filter((l) => l === "T7")).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not inflate positions below the top 8", () => {
|
|
||||||
// Winners-bracket losses place nobody — those teams are ranked by the
|
|
||||||
// elimination-bracket game that actually knocks them out. If the winners
|
|
||||||
// rounds consumed positions, the last tier would read T23 in a 20-team field.
|
|
||||||
const entries = rankLlws();
|
|
||||||
const labels = entries.map((e) => e.rankLabel);
|
|
||||||
expect(labels.filter((l) => l === "T9")).toHaveLength(4);
|
|
||||||
expect(labels.filter((l) => l === "T13")).toHaveLength(4);
|
|
||||||
expect(labels.filter((l) => l === "T17")).toHaveLength(4);
|
|
||||||
// 1 champion (not in the list) + 19 ranked = the full 20-team field.
|
|
||||||
expect(labels.some((l) => Number(l.replace("T", "")) > 17)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("never ranks a winners-bracket loser at the round they first lost", () => {
|
|
||||||
const entries = rankLlws();
|
|
||||||
// t00 wins every game it plays (lowest id), so take a team that loses in the
|
|
||||||
// winners bracket but survives: the Opening Round M1 loser, t01.
|
|
||||||
const t01 = entries.find((e) => e.participant.id === "t01");
|
|
||||||
expect(t01).toBeDefined();
|
|
||||||
// Losing the opening game must not park them in the bottom tier — they got a
|
|
||||||
// second life in the elimination bracket.
|
|
||||||
expect(t01?.rankLabel).not.toBe("T17");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("findConsolationRound", () => {
|
|
||||||
it("identifies the fifa_48 third place game and the round that feeds it", () => {
|
|
||||||
expect(findConsolationRound(getBracketTemplate("fifa_48"))).toEqual({
|
|
||||||
round: "Third Place Game",
|
|
||||||
feederRound: "Semifinals",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns undefined for a template with no consolation round", () => {
|
|
||||||
expect(findConsolationRound(getBracketTemplate("ncaa_64"))).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns undefined when there is no template", () => {
|
|
||||||
expect(findConsolationRound(undefined)).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores double-elimination loser routing and finds the real consolation game", () => {
|
|
||||||
// llws_20 sets loserFeedsInto on every winners-bracket round to route losers
|
|
||||||
// into the elimination bracket. Only the Bracket Championship feeds a terminal
|
|
||||||
// round; taking the first loserFeedsInto instead would mistake "Elimination
|
|
||||||
// Round 1" for a third-place game and corrupt the final rankings.
|
|
||||||
expect(findConsolationRound(getBracketTemplate("llws_20"))).toEqual({
|
|
||||||
round: "Consolation Third Place",
|
|
||||||
feederRound: "Bracket Championship",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("computeRankedEntries", () => {
|
|
||||||
describe("fifa_48 third place game", () => {
|
|
||||||
it("ranks the third place game winner 3rd — they never lose a match after the SF", () => {
|
|
||||||
// sfB lost the semifinal, then won the 3PG.
|
|
||||||
expect(rankOf(rankFifa(), "sfB")).toBe("3");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ranks the third place game loser 4th, not 3rd", () => {
|
|
||||||
expect(rankOf(rankFifa(), "sfD")).toBe("4");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("gives quarterfinal losers T5 — the 3PG consumes no positions of its own", () => {
|
|
||||||
const entries = rankFifa();
|
|
||||||
expect(rankOf(entries, "qf1")).toBe("T5");
|
|
||||||
expect(rankOf(entries, "qf2")).toBe("T5");
|
|
||||||
expect(rankOf(entries, "qf3")).toBe("T5");
|
|
||||||
expect(rankOf(entries, "qf4")).toBe("T5");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ranks the finals loser 2nd and leaves the champion out of the list", () => {
|
|
||||||
const entries = rankFifa();
|
|
||||||
expect(rankOf(entries, "sfC")).toBe("T2");
|
|
||||||
expect(rankOf(entries, "sfA")).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("orders the list by rank: 2nd, 3rd, 4th, then the T5 tier", () => {
|
|
||||||
expect(rankFifa().map((e) => e.rankLabel)).toEqual([
|
|
||||||
"T2",
|
|
||||||
"3",
|
|
||||||
"4",
|
|
||||||
"T5",
|
|
||||||
"T5",
|
|
||||||
"T5",
|
|
||||||
"T5",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves semifinal losers unranked until the third place game is played", () => {
|
|
||||||
const entries = rankFifa(fifaMatches({ played: false }));
|
|
||||||
// Both SF losers are still alive for the 3PG.
|
|
||||||
expect(rankOf(entries, "sfB")).toBeUndefined();
|
|
||||||
expect(rankOf(entries, "sfD")).toBeUndefined();
|
|
||||||
// QF losers are still T5 — the later rounds still consume their positions.
|
|
||||||
expect(rankOf(entries, "qf1")).toBe("T5");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("carries ownership through onto the third place entries", () => {
|
|
||||||
const ownership = new Map([
|
|
||||||
["sfB", { participantId: "sfB", teamName: "Team Nine", teamId: "t9" }],
|
|
||||||
]);
|
|
||||||
const entries = rankFifa(fifaMatches(), ownership);
|
|
||||||
expect(entries.find((e) => e.participant.id === "sfB")?.ownership?.teamName).toBe(
|
|
||||||
"Team Nine"
|
|
||||||
);
|
|
||||||
expect(entries.find((e) => e.participant.id === "sfD")?.ownership).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("scores", () => {
|
|
||||||
it("reads each participant's own score regardless of which slot they occupied", () => {
|
|
||||||
const matches = fifaMatches({
|
|
||||||
// Winner sits in slot 2 this time, so a slot-blind lookup would swap the scores.
|
|
||||||
thirdPlace: makeRankedMatch("Third Place Game", 1, "sfB", "sfD", {
|
|
||||||
winnerSlot: 2,
|
|
||||||
winnerScore: "3",
|
|
||||||
loserScore: "1",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const entries = rankFifa(matches);
|
|
||||||
|
|
||||||
expect(entries.find((e) => e.participant.id === "sfB")?.score).toBe("3");
|
|
||||||
expect(entries.find((e) => e.participant.id === "sfD")?.score).toBe("1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reads a loser's score from the slot they actually played in", () => {
|
|
||||||
const matches: Match[] = [
|
|
||||||
makeRankedMatch("Semifinals", 1, "sfA", "sfB", {
|
|
||||||
winnerSlot: 2,
|
|
||||||
winnerScore: "4",
|
|
||||||
loserScore: "2",
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
const entries = computeRankedEntries(
|
|
||||||
matches,
|
|
||||||
["Semifinals"],
|
|
||||||
groupMatchesByRound(matches),
|
|
||||||
undefined,
|
|
||||||
new Map()
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(entries.find((e) => e.participant.id === "sfB")?.score).toBe("2");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports no score for a participant who occupies neither slot", () => {
|
|
||||||
// A stale row after a bracket edit: loserId no longer matches either slot.
|
|
||||||
// Attributing the other team's score here would look entirely plausible.
|
|
||||||
const stale: Match = {
|
|
||||||
...makeRankedMatch("Semifinals", 1, "sfA", "sfB", {
|
|
||||||
winnerScore: "4",
|
|
||||||
loserScore: "2",
|
|
||||||
}),
|
|
||||||
loserId: "ghost",
|
|
||||||
loser: { id: "ghost", name: "ghost" },
|
|
||||||
};
|
|
||||||
const entries = computeRankedEntries(
|
|
||||||
[stale],
|
|
||||||
["Semifinals"],
|
|
||||||
groupMatchesByRound([stale]),
|
|
||||||
undefined,
|
|
||||||
new Map()
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(entries.find((e) => e.participant.id === "ghost")?.score).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("a consolation round somewhere other than 3rd/4th", () => {
|
|
||||||
// No template ships this today, but the positions must come from the feeder
|
|
||||||
// round rather than being hardcoded to 3 and 4.
|
|
||||||
const ROUNDS = ["Quarterfinals", "Semifinals", "Fifth Place Game", "Finals"];
|
|
||||||
const CONSOLATION = { round: "Fifth Place Game", feederRound: "Quarterfinals" };
|
|
||||||
|
|
||||||
const matches: Match[] = [
|
|
||||||
makeRankedMatch("Quarterfinals", 1, "sfA", "qf1"),
|
|
||||||
makeRankedMatch("Quarterfinals", 2, "sfB", "qf2"),
|
|
||||||
makeRankedMatch("Quarterfinals", 3, "sfC", "qf3"),
|
|
||||||
makeRankedMatch("Quarterfinals", 4, "sfD", "qf4"),
|
|
||||||
makeRankedMatch("Semifinals", 1, "sfA", "sfB"),
|
|
||||||
makeRankedMatch("Semifinals", 2, "sfC", "sfD"),
|
|
||||||
makeRankedMatch("Fifth Place Game", 1, "qf1", "qf2"),
|
|
||||||
makeRankedMatch("Finals", 1, "sfA", "sfC"),
|
|
||||||
];
|
|
||||||
|
|
||||||
it("places the consolation pair at its feeder round's tier, not at 3rd and 4th", () => {
|
|
||||||
const entries = computeRankedEntries(
|
|
||||||
matches,
|
|
||||||
ROUNDS,
|
|
||||||
groupMatchesByRound(matches),
|
|
||||||
CONSOLATION,
|
|
||||||
new Map()
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(rankOf(entries, "qf1")).toBe("5");
|
|
||||||
expect(rankOf(entries, "qf2")).toBe("6");
|
|
||||||
// The feeder round's other losers start below the pair, not alongside them.
|
|
||||||
expect(rankOf(entries, "qf3")).toBe("T7");
|
|
||||||
expect(rankOf(entries, "qf4")).toBe("T7");
|
|
||||||
// The rounds above it are unaffected.
|
|
||||||
expect(rankOf(entries, "sfC")).toBe("T2");
|
|
||||||
expect(rankOf(entries, "sfB")).toBe("T3");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("consolation matches that cannot be placed exactly", () => {
|
|
||||||
it("still ranks the loser when the winner relation is missing", () => {
|
|
||||||
const matches = fifaMatches({
|
|
||||||
thirdPlace: makeRankedMatch("Third Place Game", 1, "sfB", "sfD", {
|
|
||||||
missingWinnerRelation: true,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const entries = rankFifa(matches);
|
|
||||||
|
|
||||||
// The winner cannot be placed without a participant object, but the loser must
|
|
||||||
// not silently vanish the way it would if the round were skipped wholesale.
|
|
||||||
expect(rankOf(entries, "sfD")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the loser-driven walk when the feeder round has no matches", () => {
|
|
||||||
const matches: Match[] = [
|
|
||||||
makeRankedMatch("Third Place Game", 1, "sfB", "sfD"),
|
|
||||||
makeRankedMatch("Finals", 1, "sfA", "sfC"),
|
|
||||||
];
|
|
||||||
const entries = computeRankedEntries(
|
|
||||||
matches,
|
|
||||||
["Third Place Game", "Finals"],
|
|
||||||
groupMatchesByRound(matches),
|
|
||||||
FIFA_CONSOLATION,
|
|
||||||
new Map()
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(rankOf(entries, "sfC")).toBe("T2");
|
|
||||||
expect(rankOf(entries, "sfD")).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("rendered output", () => {
|
|
||||||
/** The card the 3PG winner was incorrectly appearing in. */
|
|
||||||
function inContentionNames() {
|
|
||||||
const card = screen.queryByText("In Contention")?.closest('[data-slot="card"]');
|
|
||||||
if (!card) return [];
|
|
||||||
return within(card as HTMLElement)
|
|
||||||
.getAllByRole("row")
|
|
||||||
.map((r) => r.textContent ?? "");
|
|
||||||
}
|
|
||||||
|
|
||||||
it("does not list the third place game winner as in contention", () => {
|
|
||||||
render(
|
|
||||||
<PlayoffBracket matches={fifaMatches()} rounds={FIFA_ROUNDS} bracketTemplateId="fifa_48" />
|
|
||||||
);
|
|
||||||
|
|
||||||
// sfB won the third place game — they are finished, not still playing.
|
|
||||||
expect(inContentionNames().some((t) => t.includes("sfB"))).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still lists semifinalists as in contention before the third place game", () => {
|
|
||||||
render(
|
|
||||||
<PlayoffBracket
|
|
||||||
matches={fifaMatches({ played: false })}
|
|
||||||
rounds={FIFA_ROUNDS}
|
|
||||||
bracketTemplateId="fifa_48"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(inContentionNames().some((t) => t.includes("sfB"))).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("brackets without a consolation round", () => {
|
|
||||||
const ROUNDS = ["Quarterfinals", "Semifinals", "Finals"];
|
|
||||||
|
|
||||||
it("ranks losers by round with tie labels, unchanged", () => {
|
|
||||||
const matches: Match[] = [
|
|
||||||
makeRankedMatch("Quarterfinals", 1, "sfA", "qf1"),
|
|
||||||
makeRankedMatch("Quarterfinals", 2, "sfB", "qf2"),
|
|
||||||
makeRankedMatch("Quarterfinals", 3, "sfC", "qf3"),
|
|
||||||
makeRankedMatch("Quarterfinals", 4, "sfD", "qf4"),
|
|
||||||
makeRankedMatch("Semifinals", 1, "sfA", "sfB"),
|
|
||||||
makeRankedMatch("Semifinals", 2, "sfC", "sfD"),
|
|
||||||
makeRankedMatch("Finals", 1, "sfA", "sfC"),
|
|
||||||
];
|
|
||||||
|
|
||||||
const entries = computeRankedEntries(
|
|
||||||
matches,
|
|
||||||
ROUNDS,
|
|
||||||
groupMatchesByRound(matches),
|
|
||||||
undefined,
|
|
||||||
new Map()
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(rankOf(entries, "sfC")).toBe("T2");
|
|
||||||
expect(rankOf(entries, "sfB")).toBe("T3");
|
|
||||||
expect(rankOf(entries, "sfD")).toBe("T3");
|
|
||||||
expect(rankOf(entries, "qf1")).toBe("T5");
|
|
||||||
expect(rankOf(entries, "sfA")).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { format } from "date-fns";
|
import { format, parseISO } 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";
|
||||||
|
|
@ -10,7 +9,6 @@ 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;
|
||||||
|
|
@ -27,13 +25,28 @@ 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 {
|
||||||
const [year, month, day] = dateStr.split("-").map(Number);
|
// eventDate is stored as a date string "YYYY-MM-DD"
|
||||||
return format(new Date(year, month - 1, day), "MMM d, yyyy");
|
return format(parseISO(dateStr), "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) {
|
||||||
|
|
@ -51,19 +64,6 @@ 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,14 +112,12 @@ 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>{getDisplayDate(event)}</span>
|
<span suppressHydrationWarning>{getDisplayDate(event)}</span>
|
||||||
{isMounted && (() => {
|
{event.eventStartsAt && (
|
||||||
const ts = event.earliestGameTime ?? (event.eventStartsAt as string | null | undefined);
|
<span suppressHydrationWarning>
|
||||||
if (!ts) return null;
|
· {format(new Date(event.eventStartsAt), "h:mm a")}
|
||||||
const d = new Date(ts);
|
</span>
|
||||||
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" ? (
|
||||||
|
|
@ -180,7 +178,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>{getDisplayDate(event)}</span>
|
<span suppressHydrationWarning>{getDisplayDate(event)}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{event.eventType !== "schedule_event" && (
|
{event.eventType !== "schedule_event" && (
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
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 {
|
||||||
|
|
@ -40,23 +38,6 @@ 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;
|
||||||
|
|
@ -80,6 +61,7 @@ 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">
|
||||||
|
|
@ -87,9 +69,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>
|
||||||
{kickoffDisplay && (
|
{kickoff && (
|
||||||
<span className="text-[10px] text-muted-foreground/50">
|
<span className="text-[10px] text-muted-foreground/50">
|
||||||
{kickoffDisplay}
|
{kickoff.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -101,12 +83,6 @@ 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);
|
||||||
|
|
@ -117,7 +93,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">
|
||||||
{visibleGroups.map((group) => (
|
{groups.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"
|
||||||
|
|
@ -193,46 +169,26 @@ 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
|
||||||
? (mounted
|
? new Date(m.scheduledAt).toLocaleDateString("en-US", { month: "short", day: "numeric" })
|
||||||
? 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(([key, matches]) => {
|
{[...byDay.entries()].map(([day, matches]) => (
|
||||||
// Before mount the key is a UTC date (format in UTC); after mount it is
|
<div key={day} className="space-y-0.5">
|
||||||
// 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">
|
||||||
{dayLabel}
|
{day}
|
||||||
</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,7 +1,6 @@
|
||||||
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 {
|
||||||
|
|
@ -59,10 +58,9 @@ 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 = isMounted && gameDate
|
const dateStr = 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;
|
||||||
|
|
@ -75,11 +73,14 @@ 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">
|
<span className="text-xs font-semibold text-electric" suppressHydrationWarning>
|
||||||
{dateStr ?? "TBD"}
|
{dateStr ?? "TBD"}
|
||||||
</span>
|
</span>
|
||||||
{isMounted && gameDate && (
|
{gameDate && (
|
||||||
<p className="text-xs text-muted-foreground leading-tight">
|
<p
|
||||||
|
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,15 +1,7 @@
|
||||||
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";
|
||||||
|
|
||||||
|
|
@ -105,54 +97,6 @@ function sortPicks(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
type Pick = TeamScoreBreakdownProps["breakdown"]["picks"][number];
|
|
||||||
|
|
||||||
const SORT_OPTIONS: Array<{ value: SortColumn; label: string }> = [
|
|
||||||
{ value: "pick", label: "Pick #" },
|
|
||||||
{ value: "sport", label: "Sport" },
|
|
||||||
{ value: "participant", label: "Participant" },
|
|
||||||
{ value: "points", label: "Points" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function pickLabel(pick: Pick, numTeams: number): string {
|
|
||||||
return numTeams > 0
|
|
||||||
? `${pick.round}.${String(pick.pickNumber - (pick.round - 1) * numTeams).padStart(2, "0")}`
|
|
||||||
: `#${pick.pickNumber}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Position badge shared by the desktop table and the mobile cards. */
|
|
||||||
function PositionCell({ pick }: { pick: Pick }) {
|
|
||||||
if (pick.isComplete && !pick.isPartialScore) {
|
|
||||||
return (pick.finalPosition ?? 0) === 0 ? (
|
|
||||||
<Badge variant="secondary">Did Not Score</Badge>
|
|
||||||
) : (
|
|
||||||
<PlacementBadge position={pick.finalPosition ?? 0} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <Badge variant="outline">Pending</Badge>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Points value (actual, with projected underneath when incomplete). */
|
|
||||||
function PointsValue({ pick }: { pick: Pick }) {
|
|
||||||
if (pick.isComplete && !pick.isPartialScore) {
|
|
||||||
return (
|
|
||||||
<span className="font-semibold">
|
|
||||||
{pick.points > 0 ? pick.points.toFixed(2) : "0.00"}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-end">
|
|
||||||
<span className="font-semibold">{pick.points.toFixed(2)}</span>
|
|
||||||
{pick.projectedPoints !== null && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{pick.projectedPoints.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display detailed team score breakdown with all drafted participants
|
* Display detailed team score breakdown with all drafted participants
|
||||||
* Phase 4.3: Team breakdown pages
|
* Phase 4.3: Team breakdown pages
|
||||||
|
|
@ -186,23 +130,17 @@ export function TeamScoreBreakdown({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mobile sort control: picking a column applies its default direction.
|
|
||||||
function handleSortColumn(column: SortColumn) {
|
|
||||||
setSortColumn(column);
|
|
||||||
setSortDirection(column === "points" ? "desc" : "asc");
|
|
||||||
}
|
|
||||||
|
|
||||||
const allPicks = sortPicks(breakdown.picks, sortColumn, sortDirection);
|
const allPicks = sortPicks(breakdown.picks, sortColumn, sortDirection);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl sm:text-3xl font-bold">{breakdown.team.name}</h1>
|
<h1 className="text-3xl font-bold">{breakdown.team.name}</h1>
|
||||||
<p className="text-muted-foreground mt-1">Team Score Breakdown</p>
|
<p className="text-muted-foreground mt-1">Team Score Breakdown</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left sm:text-right">
|
<div className="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>
|
||||||
|
|
@ -226,8 +164,8 @@ export function TeamScoreBreakdown({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* All picks — desktop table */}
|
{/* All picks — single flat table */}
|
||||||
<Card className="hidden md:block">
|
<Card>
|
||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
|
|
@ -260,7 +198,9 @@ 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">
|
||||||
{pickLabel(pick, numTeams)}
|
{numTeams > 0
|
||||||
|
? `${pick.round}.${String(pick.pickNumber - (pick.round - 1) * numTeams).padStart(2, "0")}`
|
||||||
|
: `#${pick.pickNumber}`}
|
||||||
<span className="text-xs ml-1">(#{pick.pickNumber})</span>
|
<span className="text-xs ml-1">(#{pick.pickNumber})</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
|
@ -275,10 +215,31 @@ export function TeamScoreBreakdown({
|
||||||
{pick.participant.name}
|
{pick.participant.name}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<PositionCell pick={pick} />
|
{pick.isComplete && !pick.isPartialScore ? (
|
||||||
|
(pick.finalPosition ?? 0) === 0 ? (
|
||||||
|
<Badge variant="secondary">Did Not Score</Badge>
|
||||||
|
) : (
|
||||||
|
<PlacementBadge position={pick.finalPosition ?? 0} />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline">Pending</Badge>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<PointsValue pick={pick} />
|
{pick.isComplete && !pick.isPartialScore ? (
|
||||||
|
<span className="font-semibold">
|
||||||
|
{pick.points > 0 ? pick.points.toFixed(2) : "0.00"}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-end">
|
||||||
|
<span className="font-semibold">{pick.points.toFixed(2)}</span>
|
||||||
|
{pick.projectedPoints !== null && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{pick.projectedPoints.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|
@ -287,70 +248,6 @@ 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, within } from "@testing-library/react";
|
import { render, screen } 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,10 +160,9 @@ describe("TeamScoreBreakdown", () => {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Pick labels render in both the desktop table and mobile cards.
|
expect(screen.getByText("1.01")).toBeInTheDocument();
|
||||||
expect(screen.getAllByText("1.01").length).toBeGreaterThanOrEqual(1);
|
expect(screen.getByText("1.02")).toBeInTheDocument();
|
||||||
expect(screen.getAllByText("1.02").length).toBeGreaterThanOrEqual(1);
|
expect(screen.getByText("2.01")).toBeInTheDocument();
|
||||||
expect(screen.getAllByText("2.01").length).toBeGreaterThanOrEqual(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should show overall pick number in parentheses", () => {
|
it("should show overall pick number in parentheses", () => {
|
||||||
|
|
@ -177,9 +176,9 @@ describe("TeamScoreBreakdown", () => {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getAllByText("(#1)").length).toBeGreaterThanOrEqual(1);
|
expect(screen.getByText("(#1)")).toBeInTheDocument();
|
||||||
expect(screen.getAllByText("(#2)").length).toBeGreaterThanOrEqual(1);
|
expect(screen.getByText("(#2)")).toBeInTheDocument();
|
||||||
expect(screen.getAllByText("(#3)").length).toBeGreaterThanOrEqual(1);
|
expect(screen.getByText("(#3)")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should fall back to #pickNumber when numTeams is 0", () => {
|
it("should fall back to #pickNumber when numTeams is 0", () => {
|
||||||
|
|
@ -193,9 +192,9 @@ describe("TeamScoreBreakdown", () => {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getAllByText("#1").length).toBeGreaterThanOrEqual(1);
|
expect(screen.getByText("#1")).toBeInTheDocument();
|
||||||
expect(screen.getAllByText("#2").length).toBeGreaterThanOrEqual(1);
|
expect(screen.getByText("#2")).toBeInTheDocument();
|
||||||
expect(screen.getAllByText("#3").length).toBeGreaterThanOrEqual(1);
|
expect(screen.getByText("#3")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -211,10 +210,9 @@ describe("TeamScoreBreakdown", () => {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = within(screen.getByRole("table"));
|
expect(screen.getByText("Team A")).toBeInTheDocument();
|
||||||
expect(table.getByText("Team A")).toBeInTheDocument();
|
expect(screen.getByText("Driver B")).toBeInTheDocument();
|
||||||
expect(table.getByText("Driver B")).toBeInTheDocument();
|
expect(screen.getByText("Team C")).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", () => {
|
||||||
|
|
@ -228,15 +226,14 @@ describe("TeamScoreBreakdown", () => {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = within(screen.getByRole("table"));
|
const nflLinks = screen.getAllByRole("link", { name: "NFL" });
|
||||||
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 = table.getByRole("link", { name: "F1" });
|
const f1Link = screen.getByRole("link", { name: "F1" });
|
||||||
expect(f1Link).toHaveAttribute(
|
expect(f1Link).toHaveAttribute(
|
||||||
"href",
|
"href",
|
||||||
`/leagues/${mockLeagueId}/sports-seasons/ss-f1`
|
`/leagues/${mockLeagueId}/sports-seasons/ss-f1`
|
||||||
|
|
@ -254,9 +251,8 @@ describe("TeamScoreBreakdown", () => {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = within(screen.getByRole("table"));
|
expect(screen.getByText("1st")).toBeInTheDocument();
|
||||||
expect(table.getByText("1st")).toBeInTheDocument();
|
expect(screen.getByText("3rd")).toBeInTheDocument();
|
||||||
expect(table.getByText("3rd")).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should show Pending badge for incomplete participants", () => {
|
it("should show Pending badge for incomplete participants", () => {
|
||||||
|
|
@ -270,8 +266,7 @@ describe("TeamScoreBreakdown", () => {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = within(screen.getByRole("table"));
|
expect(screen.getByText("Pending")).toBeInTheDocument();
|
||||||
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", () => {
|
||||||
|
|
@ -296,8 +291,7 @@ describe("TeamScoreBreakdown", () => {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = within(screen.getByRole("table"));
|
expect(screen.getByText("Did Not Score")).toBeInTheDocument();
|
||||||
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", () => {
|
||||||
|
|
@ -324,8 +318,7 @@ describe("TeamScoreBreakdown", () => {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = within(screen.getByRole("table"));
|
expect(screen.getByText("Did Not Score")).toBeInTheDocument();
|
||||||
expect(table.getByText("Did Not Score")).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should show projected EV for incomplete participants", () => {
|
it("should show projected EV for incomplete participants", () => {
|
||||||
|
|
@ -340,9 +333,8 @@ describe("TeamScoreBreakdown", () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Incomplete participant shows 0.00 (actual) and 25.00 (EV) in the row
|
// Incomplete participant shows 0.00 (actual) and 25.00 (EV) in the row
|
||||||
const table = within(screen.getByRole("table"));
|
expect(screen.getByText("0.00")).toBeInTheDocument();
|
||||||
expect(table.getByText("0.00")).toBeInTheDocument();
|
expect(screen.getByText("25.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", () => {
|
||||||
|
|
@ -361,8 +353,7 @@ describe("TeamScoreBreakdown", () => {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = within(screen.getByRole("table"));
|
expect(screen.getByText("0.00")).toBeInTheDocument();
|
||||||
expect(table.getByText("0.00")).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -510,96 +501,6 @@ describe("TeamScoreBreakdown", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Mobile view", () => {
|
|
||||||
function getCardOrder() {
|
|
||||||
const mobile = screen.getByTestId("picks-mobile");
|
|
||||||
const cards = within(mobile).getAllByTestId("pick-card");
|
|
||||||
return cards.map((card) =>
|
|
||||||
["Team A", "Driver B", "Team C"].find((n) => card.textContent?.includes(n))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectSortColumn(user: ReturnType<typeof userEvent.setup>, label: string) {
|
|
||||||
await user.click(screen.getByRole("combobox", { name: /sort by/i }));
|
|
||||||
await user.click(screen.getByRole("option", { name: label }));
|
|
||||||
}
|
|
||||||
|
|
||||||
it("renders a card per pick with all fields visible", () => {
|
|
||||||
renderWithRouter(
|
|
||||||
<TeamScoreBreakdown
|
|
||||||
leagueId={mockLeagueId}
|
|
||||||
seasonId={mockSeasonId}
|
|
||||||
numTeams={numTeams}
|
|
||||||
breakdown={mockBreakdown}
|
|
||||||
standing={mockStanding}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const mobile = within(screen.getByTestId("picks-mobile"));
|
|
||||||
expect(mobile.getAllByTestId("pick-card")).toHaveLength(3);
|
|
||||||
// Participant names, sports, positions all visible without hiding data
|
|
||||||
expect(mobile.getByText("Team A")).toBeInTheDocument();
|
|
||||||
expect(mobile.getByText("Driver B")).toBeInTheDocument();
|
|
||||||
expect(mobile.getByText("Team C")).toBeInTheDocument();
|
|
||||||
expect(mobile.getByText("1st")).toBeInTheDocument();
|
|
||||||
expect(mobile.getByText("Pending")).toBeInTheDocument();
|
|
||||||
expect(mobile.getByRole("link", { name: "F1" })).toHaveAttribute(
|
|
||||||
"href",
|
|
||||||
`/leagues/${mockLeagueId}/sports-seasons/ss-f1`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defaults to pick order", () => {
|
|
||||||
renderWithRouter(
|
|
||||||
<TeamScoreBreakdown
|
|
||||||
leagueId={mockLeagueId}
|
|
||||||
seasonId={mockSeasonId}
|
|
||||||
numTeams={numTeams}
|
|
||||||
breakdown={mockBreakdown}
|
|
||||||
standing={mockStanding}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(getCardOrder()).toEqual(["Team A", "Driver B", "Team C"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reorders cards when sorting by points (desc) via the select", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
renderWithRouter(
|
|
||||||
<TeamScoreBreakdown
|
|
||||||
leagueId={mockLeagueId}
|
|
||||||
seasonId={mockSeasonId}
|
|
||||||
numTeams={numTeams}
|
|
||||||
breakdown={mockBreakdown}
|
|
||||||
standing={mockStanding}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
// picks: Team A=100, Driver B=50, Team C=0; points defaults to descending
|
|
||||||
await selectSortColumn(user, "Points");
|
|
||||||
expect(getCardOrder()).toEqual(["Team A", "Driver B", "Team C"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("toggles sort direction with the direction button", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
renderWithRouter(
|
|
||||||
<TeamScoreBreakdown
|
|
||||||
leagueId={mockLeagueId}
|
|
||||||
seasonId={mockSeasonId}
|
|
||||||
numTeams={numTeams}
|
|
||||||
breakdown={mockBreakdown}
|
|
||||||
standing={mockStanding}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
await selectSortColumn(user, "Points"); // desc
|
|
||||||
// While descending, the toggle's action is to sort ascending.
|
|
||||||
await user.click(screen.getByRole("button", { name: /sort ascending/i }));
|
|
||||||
// now ascending: Team C (0), Driver B (50), Team A (100)
|
|
||||||
expect(getCardOrder()).toEqual(["Team C", "Driver B", "Team A"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Without Standing Data", () => {
|
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/account" });
|
await authClient.linkSocial({ provider: "discord", callbackURL: "/settings?section=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 { Link, useFetcher } from "react-router";
|
import { 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,9 +14,10 @@ type Props = {
|
||||||
discordPingEnabled: boolean;
|
discordPingEnabled: boolean;
|
||||||
hasDiscordLinked: boolean;
|
hasDiscordLinked: boolean;
|
||||||
draftEmailNotificationsEnabled: boolean;
|
draftEmailNotificationsEnabled: boolean;
|
||||||
|
onNavigateToAccount: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, draftEmailNotificationsEnabled }: Props) {
|
export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, draftEmailNotificationsEnabled, onNavigateToAccount }: Props) {
|
||||||
const discordFetcher = useFetcher();
|
const discordFetcher = useFetcher();
|
||||||
const emailFetcher = useFetcher();
|
const emailFetcher = useFetcher();
|
||||||
|
|
||||||
|
|
@ -86,12 +87,13 @@ 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{" "}
|
||||||
<Link
|
<button
|
||||||
to="/settings/account"
|
type="button"
|
||||||
|
onClick={onNavigateToAccount}
|
||||||
className="text-primary underline-offset-4 hover:underline"
|
className="text-primary underline-offset-4 hover:underline"
|
||||||
>
|
>
|
||||||
Account settings
|
Account settings
|
||||||
</Link>{" "}
|
</button>{" "}
|
||||||
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/account",
|
callbackURL: "/settings?section=account",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,18 @@
|
||||||
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, HandleErrorFunction } from "react-router";
|
import type { AppLoadContext, EntryContext } 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";
|
||||||
|
|
||||||
const sentryHandleError = Sentry.createSentryHandleError({
|
export const handleError = 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(
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import { useSyncExternalStore } from "react";
|
|
||||||
|
|
||||||
const subscribe = () => () => {};
|
|
||||||
|
|
||||||
export function useHasMounted(): boolean {
|
|
||||||
return useSyncExternalStore(subscribe, () => true, () => false);
|
|
||||||
}
|
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
/**
|
|
||||||
* The AFL Wildcard winners are re-seeded into the Elimination Finals by ladder position
|
|
||||||
* (5th draws the lower-ranked winner, 6th the higher-ranked one) rather than crossing
|
|
||||||
* over from a fixed Wildcard match. These tests pin that mapping for every combination
|
|
||||||
* of results, and for either order of entry.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import {
|
|
||||||
resolveAflWildcardPlacements,
|
|
||||||
AFL_WILDCARD_DRAW,
|
|
||||||
AFL_ELIMINATION_HOSTS,
|
|
||||||
type AflWildcardResult,
|
|
||||||
} from "../afl-wildcard-reseed";
|
|
||||||
|
|
||||||
/** Both Wildcard games decided, addressed by the seed that won each. */
|
|
||||||
function bothDecided(match1Winner: 7 | 10, match2Winner: 8 | 9): AflWildcardResult[] {
|
|
||||||
return [
|
|
||||||
{ matchNumber: 1, winnerSlot: match1Winner === 7 ? 1 : 2 },
|
|
||||||
{ matchNumber: 2, winnerSlot: match2Winner === 8 ? 1 : 2 },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Elimination Finals match number each winning seed was sent to. */
|
|
||||||
function slotsBySeed(results: AflWildcardResult[]): Record<number, number> {
|
|
||||||
return Object.fromEntries(
|
|
||||||
resolveAflWildcardPlacements(results).map((p) => [p.seed, p.eliminationMatchNumber])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("AFL Wildcard draw constants", () => {
|
|
||||||
it("draws 7v10 and 8v9", () => {
|
|
||||||
expect(AFL_WILDCARD_DRAW[1]).toEqual([7, 10]);
|
|
||||||
expect(AFL_WILDCARD_DRAW[2]).toEqual([8, 9]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hosts the Elimination Finals with seeds 5 and 6", () => {
|
|
||||||
expect(AFL_ELIMINATION_HOSTS[1]).toBe(5);
|
|
||||||
expect(AFL_ELIMINATION_HOSTS[2]).toBe(6);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("resolveAflWildcardPlacements", () => {
|
|
||||||
it("sends the higher-ranked winner to 6th and the lower to 5th (7 and 8 win)", () => {
|
|
||||||
expect(slotsBySeed(bothDecided(7, 8))).toEqual({ 7: 2, 8: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("re-seeds when the lower seed wins the 7v10 game (10 and 8 win)", () => {
|
|
||||||
// The bug this replaces sent the 7v10 winner to 6th regardless, pairing 5th with
|
|
||||||
// 8th and handing 6th the weakest survivor.
|
|
||||||
expect(slotsBySeed(bothDecided(10, 8))).toEqual({ 8: 2, 10: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("re-seeds when the lower seed wins the 8v9 game (7 and 9 win)", () => {
|
|
||||||
expect(slotsBySeed(bothDecided(7, 9))).toEqual({ 7: 2, 9: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("re-seeds when both lower seeds win (10 and 9 win)", () => {
|
|
||||||
expect(slotsBySeed(bothDecided(10, 9))).toEqual({ 9: 2, 10: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("places the 7v10 winner alone, since its rank is settled either way", () => {
|
|
||||||
// 7th outranks both possible 8v9 winners; 10th is outranked by both.
|
|
||||||
expect(slotsBySeed([
|
|
||||||
{ matchNumber: 1, winnerSlot: 1 },
|
|
||||||
{ matchNumber: 2, winnerSlot: null },
|
|
||||||
])).toEqual({ 7: 2 });
|
|
||||||
|
|
||||||
expect(slotsBySeed([
|
|
||||||
{ matchNumber: 1, winnerSlot: 2 },
|
|
||||||
{ matchNumber: 2, winnerSlot: null },
|
|
||||||
])).toEqual({ 10: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("holds an 8v9 winner back until the 7v10 game is decided", () => {
|
|
||||||
// 8th and 9th both sit between 7th and 10th, so either slot is still possible.
|
|
||||||
expect(slotsBySeed([
|
|
||||||
{ matchNumber: 1, winnerSlot: null },
|
|
||||||
{ matchNumber: 2, winnerSlot: 1 },
|
|
||||||
])).toEqual({});
|
|
||||||
|
|
||||||
expect(slotsBySeed([
|
|
||||||
{ matchNumber: 1, winnerSlot: null },
|
|
||||||
{ matchNumber: 2, winnerSlot: 2 },
|
|
||||||
])).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("places nothing while both games are undecided", () => {
|
|
||||||
expect(resolveAflWildcardPlacements([
|
|
||||||
{ matchNumber: 1, winnerSlot: null },
|
|
||||||
{ matchNumber: 2, winnerSlot: null },
|
|
||||||
])).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("gives the same answer whichever result is entered first", () => {
|
|
||||||
for (const m1 of [7, 10] as const) {
|
|
||||||
for (const m2 of [8, 9] as const) {
|
|
||||||
const final = slotsBySeed(bothDecided(m1, m2));
|
|
||||||
|
|
||||||
// Whatever a single result places must survive the second result unchanged.
|
|
||||||
const m1First = slotsBySeed([
|
|
||||||
{ matchNumber: 1, winnerSlot: m1 === 7 ? 1 : 2 },
|
|
||||||
{ matchNumber: 2, winnerSlot: null },
|
|
||||||
]);
|
|
||||||
for (const [seed, slot] of Object.entries(m1First)) {
|
|
||||||
expect(final[Number(seed)]).toBe(slot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a match number outside the Wildcard draw", () => {
|
|
||||||
expect(() => resolveAflWildcardPlacements([{ matchNumber: 3, winnerSlot: 1 }])).toThrow(
|
|
||||||
/Unknown AFL Wildcard Round match number 3/
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,533 +0,0 @@
|
||||||
/**
|
|
||||||
* Bracket layout tests.
|
|
||||||
*
|
|
||||||
* The load-bearing assertions check the LLWS geometry against the official 2026 LLBWS
|
|
||||||
* bracket, in the PDF's own game numbers. A bracket "lines up" when each card sits level
|
|
||||||
* with the game that feeds it, so these tests assert column membership, top-to-bottom
|
|
||||||
* order, and vertical alignment — not just that a layout was produced.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import {
|
|
||||||
LLWS_20,
|
|
||||||
SIMPLE_16,
|
|
||||||
NFL_14,
|
|
||||||
BRACKET_TEMPLATES,
|
|
||||||
getBracketTemplate,
|
|
||||||
type BracketTemplate,
|
|
||||||
type ConferenceGroup,
|
|
||||||
} from "~/lib/bracket-templates";
|
|
||||||
import {
|
|
||||||
buildFeederMap,
|
|
||||||
computeGroupLayout,
|
|
||||||
describeSlotSource,
|
|
||||||
matchKey,
|
|
||||||
type SlotSource,
|
|
||||||
} from "~/lib/bracket-layout";
|
|
||||||
import { GAME_TO_MATCH, MATCH_TO_GAME } from "~/test/fixtures/llws-bracket";
|
|
||||||
|
|
||||||
interface TestMatch {
|
|
||||||
round: string;
|
|
||||||
matchNumber: number;
|
|
||||||
/** Only the fallback reads these, to trace edges through an unrecognised shape. */
|
|
||||||
winnerId?: string | null;
|
|
||||||
participant1Id?: string | null;
|
|
||||||
participant2Id?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every match a template defines, as the renderer would receive them. */
|
|
||||||
function allMatches(template: BracketTemplate): Map<string, TestMatch[]> {
|
|
||||||
const byRound = new Map<string, TestMatch[]>();
|
|
||||||
for (const round of template.rounds) {
|
|
||||||
byRound.set(
|
|
||||||
round.name,
|
|
||||||
Array.from({ length: round.matchCount }, (_, i) => ({
|
|
||||||
round: round.name,
|
|
||||||
matchNumber: i + 1,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return byRound;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The matches of one phase group, filtered the way TabbedBracketLayout filters them. */
|
|
||||||
function groupMatches(group: ConferenceGroup): Map<string, TestMatch[]> {
|
|
||||||
const byRound = new Map<string, TestMatch[]>();
|
|
||||||
for (const [round, nums] of Object.entries(group.roundMatchNumbers)) {
|
|
||||||
byRound.set(
|
|
||||||
round,
|
|
||||||
nums.map((matchNumber) => ({ round, matchNumber }))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return byRound;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findGroup(name: string): ConferenceGroup {
|
|
||||||
for (const phase of LLWS_20.phases ?? []) {
|
|
||||||
for (const group of phase.groups ?? []) {
|
|
||||||
if (group.name === name) return group;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error(`No LLWS group named ${name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lay out one LLWS group and describe it in PDF game numbers. */
|
|
||||||
function layOutLLWSGroup(name: string) {
|
|
||||||
const group = findGroup(name);
|
|
||||||
const byRound = groupMatches(group);
|
|
||||||
const roundOrder = LLWS_20.rounds.map((r) => r.name);
|
|
||||||
const rounds = roundOrder.filter((r) => byRound.has(r));
|
|
||||||
|
|
||||||
const layout = computeGroupLayout(rounds, byRound, buildFeederMap(LLWS_20), roundOrder);
|
|
||||||
|
|
||||||
const game = (m: TestMatch) => {
|
|
||||||
const n = MATCH_TO_GAME.get(`${m.round}#${m.matchNumber}`);
|
|
||||||
if (n === undefined) throw new Error(`No PDF game for ${m.round} #${m.matchNumber}`);
|
|
||||||
return n;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
layout,
|
|
||||||
labels: layout.columns.map((c) => c.label),
|
|
||||||
/** Column contents, top to bottom, as PDF game numbers. */
|
|
||||||
columns: layout.columns.map((c) => c.matches.map((m) => game(m.match))),
|
|
||||||
/** Vertical centre of a game's card, in leaf-row units. */
|
|
||||||
centerOf(gameNumber: number): number {
|
|
||||||
const target = GAME_TO_MATCH[gameNumber];
|
|
||||||
for (const column of layout.columns) {
|
|
||||||
for (const { match, center } of column.matches) {
|
|
||||||
if (match.round === target.round && match.matchNumber === target.matchNumber) {
|
|
||||||
return center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error(`G${gameNumber} is not in this group`);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("computeGroupLayout — LLWS winners brackets", () => {
|
|
||||||
// The International side is the one in the reported screenshot. Under the old index
|
|
||||||
// math, G5 and G7 were stranded in the first column: they skip Winners Round 2 and go
|
|
||||||
// straight to the semifinals, so nothing in column two lined up with them.
|
|
||||||
it("puts the International winners bracket in the printed bracket's columns", () => {
|
|
||||||
const { columns } = layOutLLWSGroup("International Winner's Bracket");
|
|
||||||
expect(columns).toEqual([
|
|
||||||
[1, 3],
|
|
||||||
[5, 9, 11, 7],
|
|
||||||
[18, 20],
|
|
||||||
[29],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("mirrors that layout on the U.S. side", () => {
|
|
||||||
const { columns } = layOutLLWSGroup("U.S. Winner's Bracket");
|
|
||||||
expect(columns).toEqual([
|
|
||||||
[2, 4],
|
|
||||||
[6, 10, 12, 8],
|
|
||||||
[17, 19],
|
|
||||||
[30],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("names a mixed column for the latest round it holds", () => {
|
|
||||||
// Column two holds two Opening Round games (G5, G7) alongside Winners Round 2.
|
|
||||||
const { labels } = layOutLLWSGroup("International Winner's Bracket");
|
|
||||||
expect(labels).toEqual([
|
|
||||||
"Opening Round",
|
|
||||||
"Winners Round 2",
|
|
||||||
"Winners Semifinals",
|
|
||||||
"Winners Final",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("levels each card with the game that feeds it", () => {
|
|
||||||
const { centerOf } = layOutLLWSGroup("International Winner's Bracket");
|
|
||||||
|
|
||||||
// G1's winner fills a slot of G9, so the two sit at the same height.
|
|
||||||
expect(centerOf(1)).toBe(centerOf(9));
|
|
||||||
expect(centerOf(3)).toBe(centerOf(11));
|
|
||||||
|
|
||||||
// G18 = W5 v W9, so it sits midway between them.
|
|
||||||
expect(centerOf(18)).toBe((centerOf(5) + centerOf(9)) / 2);
|
|
||||||
expect(centerOf(20)).toBe((centerOf(11) + centerOf(7)) / 2);
|
|
||||||
expect(centerOf(29)).toBe((centerOf(18) + centerOf(20)) / 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("draws an edge for every in-group feed, played or not", () => {
|
|
||||||
const { layout } = layOutLLWSGroup("International Winner's Bracket");
|
|
||||||
// G9←G1, G11←G3, G18←{G5,G9}, G20←{G11,G7}, G29←{G18,G20}: 8 in-group edges.
|
|
||||||
expect(layout.edges).toHaveLength(8);
|
|
||||||
// Every edge crosses exactly one gutter, which is what makes them drawable.
|
|
||||||
for (const edge of layout.edges) {
|
|
||||||
expect(edge.fromColumn).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(edge.fromColumn).toBeLessThan(layout.columns.length - 1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("computeGroupLayout — LLWS elimination brackets", () => {
|
|
||||||
it("orders Elimination Round 3 the way the printed bracket does", () => {
|
|
||||||
// G31 = W27 v W25, so the later game is printed on top — the reverse of match
|
|
||||||
// number order, which is how the old index-based sort got it wrong.
|
|
||||||
const { columns } = layOutLLWSGroup("International Elimination Bracket");
|
|
||||||
expect(columns).toEqual([
|
|
||||||
[13, 15],
|
|
||||||
[21, 23],
|
|
||||||
[27, 25],
|
|
||||||
[31],
|
|
||||||
[33],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("orders the U.S. elimination bracket the same way", () => {
|
|
||||||
const { columns } = layOutLLWSGroup("U.S. Elimination Bracket");
|
|
||||||
expect(columns).toEqual([
|
|
||||||
[14, 16],
|
|
||||||
[22, 24],
|
|
||||||
[28, 26],
|
|
||||||
[32],
|
|
||||||
[34],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores feeds arriving from the winners bracket", () => {
|
|
||||||
// G21 = L9 v W13. L9 is in the winners bracket group, so only W13 is an edge here.
|
|
||||||
const { layout, centerOf } = layOutLLWSGroup("International Elimination Bracket");
|
|
||||||
expect(centerOf(21)).toBe(centerOf(13));
|
|
||||||
expect(layout.edges).toHaveLength(7);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("buildFeederMap", () => {
|
|
||||||
it("routes LLWS winners and losers to the slots the printed bracket shows", () => {
|
|
||||||
const feeders = buildFeederMap(LLWS_20);
|
|
||||||
|
|
||||||
// G18 = W5 v W9.
|
|
||||||
const g18 = GAME_TO_MATCH[18];
|
|
||||||
expect(feeders.get(matchKey(g18.round, g18.matchNumber))).toEqual([
|
|
||||||
{ kind: "match", ref: GAME_TO_MATCH[5], result: "winner" },
|
|
||||||
{ kind: "match", ref: GAME_TO_MATCH[9], result: "winner" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
// G13 = L3 v L5 — a winners-bracket loss drops into the elimination bracket.
|
|
||||||
const g13 = GAME_TO_MATCH[13];
|
|
||||||
expect(feeders.get(matchKey(g13.round, g13.matchNumber))).toEqual([
|
|
||||||
{ kind: "match", ref: GAME_TO_MATCH[3], result: "loser" },
|
|
||||||
{ kind: "match", ref: GAME_TO_MATCH[5], result: "loser" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("marks directly seeded slots as seeds", () => {
|
|
||||||
const feeders = buildFeederMap(LLWS_20);
|
|
||||||
// G9 = a bye team v W1: slot one is seeded, slot two is fed.
|
|
||||||
const g9 = GAME_TO_MATCH[9];
|
|
||||||
const [p1, p2] = feeders.get(matchKey(g9.round, g9.matchNumber)) ?? [];
|
|
||||||
expect(p1).toEqual({ kind: "seed" });
|
|
||||||
expect(p2).toEqual({ kind: "match", ref: GAME_TO_MATCH[1], result: "winner" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("applies the standard halving rule to other templates", () => {
|
|
||||||
const feeders = buildFeederMap(SIMPLE_16);
|
|
||||||
expect(feeders.get(matchKey("Quarterfinals", 1))).toEqual([
|
|
||||||
{ kind: "match", ref: { round: "Round of 16", matchNumber: 1 }, result: "winner" },
|
|
||||||
{ kind: "match", ref: { round: "Round of 16", matchNumber: 2 }, result: "winner" },
|
|
||||||
]);
|
|
||||||
expect(feeders.get(matchKey("Quarterfinals", 4))).toEqual([
|
|
||||||
{ kind: "match", ref: { round: "Round of 16", matchNumber: 7 }, result: "winner" },
|
|
||||||
{ kind: "match", ref: { round: "Round of 16", matchNumber: 8 }, result: "winner" },
|
|
||||||
]);
|
|
||||||
// The first round is seeded, not fed.
|
|
||||||
expect(feeders.get(matchKey("Round of 16", 1))).toEqual([
|
|
||||||
{ kind: "seed" },
|
|
||||||
{ kind: "seed" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns an empty map without a template", () => {
|
|
||||||
expect(buildFeederMap(undefined).size).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("computeGroupLayout — standard brackets are unchanged", () => {
|
|
||||||
it("halves a 16-team bracket evenly, first round in seeded order", () => {
|
|
||||||
const byRound = allMatches(SIMPLE_16);
|
|
||||||
const roundOrder = SIMPLE_16.rounds.map((r) => r.name);
|
|
||||||
const layout = computeGroupLayout(
|
|
||||||
roundOrder,
|
|
||||||
byRound,
|
|
||||||
buildFeederMap(SIMPLE_16),
|
|
||||||
roundOrder
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(layout.leafCount).toBe(8);
|
|
||||||
expect(layout.columns.map((c) => c.label)).toEqual(roundOrder);
|
|
||||||
expect(layout.columns.map((c) => c.matches.map((m) => m.match.matchNumber))).toEqual([
|
|
||||||
[1, 2, 3, 4, 5, 6, 7, 8],
|
|
||||||
[1, 2, 3, 4],
|
|
||||||
[1, 2],
|
|
||||||
[1],
|
|
||||||
]);
|
|
||||||
// Evenly spread, exactly as the previous index math placed them.
|
|
||||||
expect(layout.columns[0].matches.map((m) => m.center)).toEqual([
|
|
||||||
0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5,
|
|
||||||
]);
|
|
||||||
expect(layout.columns[3].matches[0].center).toBe(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles byes, placing a seeded team level with the round it enters", () => {
|
|
||||||
// The NFL bracket's top seeds skip the wild card round.
|
|
||||||
const byRound = allMatches(NFL_14);
|
|
||||||
const roundOrder = NFL_14.rounds.map((r) => r.name);
|
|
||||||
const layout = computeGroupLayout(roundOrder, byRound, buildFeederMap(NFL_14), roundOrder);
|
|
||||||
expect(layout.columns.length).toBeGreaterThan(0);
|
|
||||||
for (const column of layout.columns) {
|
|
||||||
expect(column.matches.length).toBeGreaterThan(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to even spreading when a group has no single root", () => {
|
|
||||||
// Two finals and no way to join them — the shape can't resolve to one tree.
|
|
||||||
const byRound = new Map<string, TestMatch[]>([
|
|
||||||
["Semifinals", [{ round: "Semifinals", matchNumber: 1 }]],
|
|
||||||
[
|
|
||||||
"Finals",
|
|
||||||
[
|
|
||||||
{ round: "Finals", matchNumber: 1 },
|
|
||||||
{ round: "Finals", matchNumber: 2 },
|
|
||||||
],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
const layout = computeGroupLayout(
|
|
||||||
["Semifinals", "Finals"],
|
|
||||||
byRound,
|
|
||||||
new Map(),
|
|
||||||
["Semifinals", "Finals"]
|
|
||||||
);
|
|
||||||
expect(layout.columns.map((c) => c.label)).toEqual(["Semifinals", "Finals"]);
|
|
||||||
expect(layout.edges).toEqual([]);
|
|
||||||
expect(layout.columns[0].matches[0].center).toBe(1);
|
|
||||||
expect(layout.columns[1].matches.map((m) => m.center)).toEqual([0.5, 1.5]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns nothing for an empty group", () => {
|
|
||||||
const layout = computeGroupLayout([], new Map(), new Map(), []);
|
|
||||||
expect(layout).toEqual({ columns: [], leafCount: 0, edges: [] });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("buildFeederMap — templates with routing of their own", () => {
|
|
||||||
// The halving rule describes advanceWinnerTemplate, not every bracket. Inventing it
|
|
||||||
// where it doesn't hold draws confident, wrong connectors and mislabels slots, which
|
|
||||||
// is worse than drawing nothing.
|
|
||||||
|
|
||||||
it("follows feedsInto rather than the order rounds are listed in", () => {
|
|
||||||
// AFL's Wildcard Round feeds the Elimination Finals, skipping the round printed
|
|
||||||
// next to it, so array order would fabricate the whole chain.
|
|
||||||
const afl = BRACKET_TEMPLATES.afl_10;
|
|
||||||
const feeders = buildFeederMap(afl);
|
|
||||||
const fed = [...feeders.entries()].filter(([, pair]) =>
|
|
||||||
pair.some((s) => s.kind === "match")
|
|
||||||
);
|
|
||||||
// Only Preliminary Finals → Grand Final actually halves.
|
|
||||||
expect(fed.map(([key]) => key)).toEqual(["Grand Final#1"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves a bye round's slots seeded rather than inventing feeds", () => {
|
|
||||||
// CFP's First Round (4) feeds the Quarterfinals (4) — the top seeds have byes.
|
|
||||||
const feeders = buildFeederMap(BRACKET_TEMPLATES.cfp_12);
|
|
||||||
expect(feeders.get(matchKey("Quarterfinals", 1))).toEqual([
|
|
||||||
{ kind: "seed" },
|
|
||||||
{ kind: "seed" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves the First Four out of the Round of 64", () => {
|
|
||||||
// advanceFirstFourWinner puts each winner in a specific seed slot, not games 1-2.
|
|
||||||
const feeders = buildFeederMap(BRACKET_TEMPLATES.ncaa_68);
|
|
||||||
expect(feeders.get(matchKey("Round of 64", 1))).toEqual([
|
|
||||||
{ kind: "seed" },
|
|
||||||
{ kind: "seed" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves the NBA play-in alone, where a loser feeds forward", () => {
|
|
||||||
// Play-In Round 2 pairs the 7v8 loser with the 9v10 winner, so the round sizes
|
|
||||||
// halve but the winners-only rule still doesn't describe it.
|
|
||||||
const feeders = buildFeederMap(BRACKET_TEMPLATES.nba_20);
|
|
||||||
expect(feeders.get(matchKey("Play-In Round 2", 1))).toEqual([
|
|
||||||
{ kind: "seed" },
|
|
||||||
{ kind: "seed" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not route the FIFA final through the third place game", () => {
|
|
||||||
// Third Place Game sits between Semifinals and Finals in round order, so array
|
|
||||||
// order made it the Finals' feeder and left the Finals' second slot empty.
|
|
||||||
const feeders = buildFeederMap(BRACKET_TEMPLATES.fifa_48);
|
|
||||||
expect(feeders.get(matchKey("Finals", 1))).toEqual([
|
|
||||||
{ kind: "match", ref: { round: "Semifinals", matchNumber: 1 }, result: "winner" },
|
|
||||||
{ kind: "match", ref: { round: "Semifinals", matchNumber: 2 }, result: "winner" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("computeGroupLayout — every template still draws connectors", () => {
|
|
||||||
/** Lay a whole template out the way BracketTreeView would. */
|
|
||||||
function layOut(template: BracketTemplate) {
|
|
||||||
const byRound = allMatches(template);
|
|
||||||
const order = template.rounds.map((r) => r.name);
|
|
||||||
// BracketTreeView renders a third place game outside the tree.
|
|
||||||
const rounds = order.filter((r) => r !== "Third Place Game");
|
|
||||||
return computeGroupLayout(rounds, byRound, buildFeederMap(template), order);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A gutter joining a column to one exactly half its size is a plain bracket join and
|
|
||||||
// must always be drawn. Where the sizes don't halve — a bye round, a play-in, the
|
|
||||||
// First Four — the routing is bespoke and nothing is drawn until the games decide it,
|
|
||||||
// which is what these brackets did before.
|
|
||||||
it.each(Object.keys(BRACKET_TEMPLATES).filter((id) => id !== "llws_20"))(
|
|
||||||
"%s draws every gutter that halves",
|
|
||||||
(id) => {
|
|
||||||
const layout = layOut(BRACKET_TEMPLATES[id]);
|
|
||||||
const gutters = new Set(layout.edges.map((e) => e.fromColumn));
|
|
||||||
let halvingGutters = 0;
|
|
||||||
for (let ci = 0; ci < layout.columns.length - 1; ci++) {
|
|
||||||
const from = layout.columns[ci].matches.length;
|
|
||||||
const to = layout.columns[ci + 1].matches.length;
|
|
||||||
if (from !== to * 2) continue;
|
|
||||||
halvingGutters += 1;
|
|
||||||
expect(gutters).toContain(ci);
|
|
||||||
}
|
|
||||||
// Every template has at least one, so a template that lost all its lines fails.
|
|
||||||
expect(halvingGutters).toBeGreaterThan(0);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
it("keeps the FIFA bracket a single tree once the third place game is set aside", () => {
|
|
||||||
const layout = layOut(BRACKET_TEMPLATES.fifa_48);
|
|
||||||
expect(layout.columns.map((c) => c.label)).toEqual([
|
|
||||||
"Round of 32",
|
|
||||||
"Round of 16",
|
|
||||||
"Quarterfinals",
|
|
||||||
"Semifinals",
|
|
||||||
"Finals",
|
|
||||||
]);
|
|
||||||
expect(layout.edges).toHaveLength(30);
|
|
||||||
});
|
|
||||||
|
|
||||||
// llws_20 is excluded above because both sides in one group is genuinely not a tree;
|
|
||||||
// it renders per side, which the tests further up cover.
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("computeGroupLayout — fallback keeps the old connectors", () => {
|
|
||||||
const rounds = ["Quarterfinals", "Semifinals", "Finals"];
|
|
||||||
const byRound = new Map<string, TestMatch[]>([
|
|
||||||
["Quarterfinals", [1, 2, 3, 4].map((n) => ({ round: "Quarterfinals", matchNumber: n }))],
|
|
||||||
["Semifinals", [1, 2].map((n) => ({ round: "Semifinals", matchNumber: n }))],
|
|
||||||
["Finals", [{ round: "Finals", matchNumber: 1 }]],
|
|
||||||
]);
|
|
||||||
|
|
||||||
it("infers halving edges when there is no feeder map at all", () => {
|
|
||||||
// A bracket with no template id, which SportSeasonDisplay renders.
|
|
||||||
const layout = computeGroupLayout(rounds, byRound, new Map(), rounds);
|
|
||||||
expect(layout.edges).toHaveLength(6);
|
|
||||||
// Quarterfinals 1 and 2 both join Semifinal 1.
|
|
||||||
const intoFirstSemi = layout.edges.filter((e) => e.toCenter === 1);
|
|
||||||
expect(intoFirstSemi.map((e) => e.fromCenter)).toEqual([0.5, 1.5]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("traces played winners when the shape is not a halving", () => {
|
|
||||||
const irregular = new Map<string, TestMatch[]>([
|
|
||||||
[
|
|
||||||
"Wildcard",
|
|
||||||
[
|
|
||||||
{ round: "Wildcard", matchNumber: 1, winnerId: "a" },
|
|
||||||
{ round: "Wildcard", matchNumber: 2, winnerId: "b" },
|
|
||||||
],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"Semifinals",
|
|
||||||
[
|
|
||||||
{ round: "Semifinals", matchNumber: 1, participant1Id: "seeded", participant2Id: "b" },
|
|
||||||
{ round: "Semifinals", matchNumber: 2, participant1Id: "seeded2", participant2Id: "a" },
|
|
||||||
],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
const layout = computeGroupLayout(
|
|
||||||
["Wildcard", "Semifinals"],
|
|
||||||
irregular,
|
|
||||||
new Map(),
|
|
||||||
["Wildcard", "Semifinals"]
|
|
||||||
);
|
|
||||||
// b won Wildcard 2 (centre 1.5) and plays Semifinal 1 (centre 0.5) — a crossing
|
|
||||||
// edge that only the actual result can reveal.
|
|
||||||
expect(layout.edges).toContainEqual({ fromColumn: 0, fromCenter: 1.5, toCenter: 0.5 });
|
|
||||||
expect(layout.edges).toContainEqual({ fromColumn: 0, fromCenter: 0.5, toCenter: 1.5 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("describeSlotSource", () => {
|
|
||||||
const feeders = buildFeederMap(LLWS_20);
|
|
||||||
const sourcesFor = (game: number): [SlotSource, SlotSource] => {
|
|
||||||
const m = GAME_TO_MATCH[game];
|
|
||||||
const pair = feeders.get(matchKey(m.round, m.matchNumber));
|
|
||||||
if (!pair) throw new Error(`No feeders for G${game}`);
|
|
||||||
return pair;
|
|
||||||
};
|
|
||||||
|
|
||||||
it("names a winner feed", () => {
|
|
||||||
// G18 = W5 v W9; G5 is Opening Round match 3 on the International side.
|
|
||||||
expect(describeSlotSource(sourcesFor(18)[0], LLWS_20)).toBe("Winner of Opening 3");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("names a loser feed, which is the one no line can show", () => {
|
|
||||||
// G21 = L9 v W13; G9 is Winners Round 2 match 1 on the International side.
|
|
||||||
expect(describeSlotSource(sourcesFor(21)[0], LLWS_20)).toBe("Loser of Winners R2 1");
|
|
||||||
// G25 = L18 v W23; G18 is International semifinal 1.
|
|
||||||
expect(describeSlotSource(sourcesFor(25)[0], LLWS_20)).toBe("Loser of Winners SF 1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses side-local numbers, as the printed bracket does", () => {
|
|
||||||
// G27 = L20 v W21. G20 is Winners Semifinals match 4 globally, but International
|
|
||||||
// semifinal 2 — the number the printed bracket uses.
|
|
||||||
expect(describeSlotSource(sourcesFor(27)[0], LLWS_20)).toBe("Loser of Winners SF 2");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("names the side where each side plays only one such game", () => {
|
|
||||||
// G37 = L36 v L35: both feeds are Bracket Championship losers, one per side, so a
|
|
||||||
// number would say nothing and the side is the only thing that tells them apart.
|
|
||||||
const [p1, p2] = sourcesFor(37);
|
|
||||||
expect(describeSlotSource(p1, LLWS_20)).toBe("Loser of U.S. Bracket Final");
|
|
||||||
expect(describeSlotSource(p2, LLWS_20)).toBe("Loser of Intl Bracket Final");
|
|
||||||
|
|
||||||
// Same rule inside a side bracket: G34 = L30 v W32.
|
|
||||||
const [elimP1, elimP2] = sourcesFor(34);
|
|
||||||
expect(describeSlotSource(elimP1, LLWS_20)).toBe("Loser of U.S. Winners Final");
|
|
||||||
expect(describeSlotSource(elimP2, LLWS_20)).toBe("Winner of U.S. Elim R4");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops both number and side for the shared final games", () => {
|
|
||||||
// The two sides meet here, so there is only one of each game in the whole bracket.
|
|
||||||
const wc = describeSlotSource(
|
|
||||||
{ kind: "match", ref: GAME_TO_MATCH[38], result: "winner" },
|
|
||||||
LLWS_20
|
|
||||||
);
|
|
||||||
expect(wc).toBe("Winner of World Championship");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for a seeded slot so the caller can render TBD", () => {
|
|
||||||
expect(describeSlotSource({ kind: "seed" }, LLWS_20)).toBeNull();
|
|
||||||
expect(describeSlotSource(undefined, LLWS_20)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses plain round names for non-LLWS templates", () => {
|
|
||||||
const template = getBracketTemplate("simple_16");
|
|
||||||
const source: SlotSource = {
|
|
||||||
kind: "match",
|
|
||||||
ref: { round: "Quarterfinals", matchNumber: 3 },
|
|
||||||
result: "winner",
|
|
||||||
};
|
|
||||||
expect(describeSlotSource(source, template)).toBe("Winner of Quarterfinals 3");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { format, parseISO } from "date-fns";
|
import { format, parseISO } from "date-fns";
|
||||||
import { localDateTimeToUtcIso, toEventSortKey, utcIsoToLocalDateTime } from "../date-utils";
|
import { localDateTimeToUtcIso, toEventSortKey } from "../date-utils";
|
||||||
|
|
||||||
describe("localDateTimeToUtcIso", () => {
|
describe("localDateTimeToUtcIso", () => {
|
||||||
it("returns null for empty string", () => {
|
it("returns null for empty string", () => {
|
||||||
|
|
@ -118,46 +118,6 @@ describe("event date display — UTC midnight rollover bug", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("utcIsoToLocalDateTime", () => {
|
|
||||||
it("returns empty string for empty string", () => {
|
|
||||||
expect(utcIsoToLocalDateTime("")).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty string for null", () => {
|
|
||||||
expect(utcIsoToLocalDateTime(null)).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty string for undefined", () => {
|
|
||||||
expect(utcIsoToLocalDateTime(undefined)).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty string for an invalid date string", () => {
|
|
||||||
expect(utcIsoToLocalDateTime("not-a-date")).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("produces a datetime-local formatted string", () => {
|
|
||||||
expect(utcIsoToLocalDateTime("2026-06-17T19:00:00.000Z")).toMatch(
|
|
||||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts a Date instance", () => {
|
|
||||||
const result = utcIsoToLocalDateTime(new Date("2026-06-17T19:00:00.000Z"));
|
|
||||||
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("round-trips with localDateTimeToUtcIso to the same instant", () => {
|
|
||||||
// utcIsoToLocalDateTime renders a UTC instant in local time; feeding that
|
|
||||||
// back through localDateTimeToUtcIso (which interprets local time) must
|
|
||||||
// yield the original instant, regardless of the runtime timezone.
|
|
||||||
const utc = "2026-06-17T19:00:00.000Z";
|
|
||||||
const local = utcIsoToLocalDateTime(utc);
|
|
||||||
const backToUtc = localDateTimeToUtcIso(local);
|
|
||||||
expect(backToUtc).not.toBeNull();
|
|
||||||
expect(new Date(backToUtc ?? "").getTime()).toBe(new Date(utc).getTime());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("toEventSortKey", () => {
|
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" });
|
||||||
|
|
|
||||||
|
|
@ -1,230 +0,0 @@
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { createStaticHandler } from "react-router";
|
|
||||||
import { shouldReportServerError } from "../error-reporting";
|
|
||||||
|
|
||||||
const ORIGIN = "https://brackt.com";
|
|
||||||
|
|
||||||
/** Shaped like the ErrorResponse React Router hands to `handleError`. */
|
|
||||||
function routeError(
|
|
||||||
status: number,
|
|
||||||
internal: boolean,
|
|
||||||
statusText = "Not Found",
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
status,
|
|
||||||
statusText,
|
|
||||||
internal,
|
|
||||||
data: `Error: No route matches URL "/blog/wp/v2/posts/999999"`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function request(path: string, referer?: string, method = "GET") {
|
|
||||||
return new Request(`${ORIGIN}${path}`, {
|
|
||||||
method,
|
|
||||||
headers: referer ? { referer } : {},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("shouldReportServerError", () => {
|
|
||||||
it("drops a router 404 for a scanner hitting a URL cold", () => {
|
|
||||||
expect(
|
|
||||||
shouldReportServerError(
|
|
||||||
routeError(404, true),
|
|
||||||
request("/blog/wp/v2/posts/999999"),
|
|
||||||
),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops a router 404 linked from another site", () => {
|
|
||||||
expect(
|
|
||||||
shouldReportServerError(
|
|
||||||
routeError(404, true),
|
|
||||||
request("/blog/", "https://evil.example/"),
|
|
||||||
),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports a router 404 linked from one of our own pages", () => {
|
|
||||||
expect(
|
|
||||||
shouldReportServerError(
|
|
||||||
routeError(404, true),
|
|
||||||
request("/leagues/gone", `${ORIGIN}/leagues`),
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops the 405 from a POST to a route with no action", () => {
|
|
||||||
expect(
|
|
||||||
shouldReportServerError(
|
|
||||||
routeError(405, true, "Method Not Allowed"),
|
|
||||||
request("/", undefined, "POST"),
|
|
||||||
),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports a 404 the app threw deliberately", () => {
|
|
||||||
expect(
|
|
||||||
shouldReportServerError(
|
|
||||||
routeError(404, false),
|
|
||||||
request("/leagues/missing"),
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports a 403 the app threw from an ownership check", () => {
|
|
||||||
expect(
|
|
||||||
shouldReportServerError(
|
|
||||||
routeError(403, false, "Forbidden"),
|
|
||||||
request("/admin/sports"),
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports a router-internal 500", () => {
|
|
||||||
expect(
|
|
||||||
shouldReportServerError(
|
|
||||||
routeError(500, true, "Internal Server Error"),
|
|
||||||
request("/leagues"),
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports a plain exception", () => {
|
|
||||||
expect(
|
|
||||||
shouldReportServerError(new Error("boom"), request("/leagues")),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports anything that is not a route error response", () => {
|
|
||||||
expect(shouldReportServerError("just a string", request("/leagues"))).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(shouldReportServerError(null, request("/leagues"))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops a router 404 whose referer header is not a URL", () => {
|
|
||||||
expect(
|
|
||||||
shouldReportServerError(
|
|
||||||
routeError(404, true),
|
|
||||||
request("/blog/", "not a url"),
|
|
||||||
),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The unit tests above use hand-written error objects. These drive real requests
|
|
||||||
* through React Router so the suite fails if the shape it throws ever changes.
|
|
||||||
*/
|
|
||||||
describe("shouldReportServerError against real React Router errors", () => {
|
|
||||||
const handler = createStaticHandler([
|
|
||||||
{
|
|
||||||
id: "root",
|
|
||||||
path: "/",
|
|
||||||
children: [{ id: "home", index: true, loader: () => null }],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
async function errorFor(req: Request) {
|
|
||||||
const ctx = await handler.query(req);
|
|
||||||
if (ctx instanceof Response) return null;
|
|
||||||
return Object.values(ctx.errors ?? {})[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
it('drops the 404 for an unmatched URL (No route matches URL "...")', async () => {
|
|
||||||
const req = request("/blog/wp/v2/posts/999999");
|
|
||||||
const error = await errorFor(req);
|
|
||||||
expect(error).toMatchObject({ status: 404, internal: true });
|
|
||||||
expect(shouldReportServerError(error, req)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports the same 404 when it came from a link on our own site", async () => {
|
|
||||||
const req = request("/nope", `${ORIGIN}/leagues`);
|
|
||||||
expect(shouldReportServerError(await errorFor(req), req)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops the 405 from a POST to a route with no action", async () => {
|
|
||||||
const req = request("/", undefined, "POST");
|
|
||||||
const error = await errorFor(req);
|
|
||||||
expect(error).toMatchObject({ status: 405, internal: true });
|
|
||||||
expect(shouldReportServerError(error, req)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("static asset 404s", () => {
|
|
||||||
it("drops a stale hashed bundle even with a same-host referer", () => {
|
|
||||||
// Every deploy leaves clients requesting the previous build's assets.
|
|
||||||
const req = request("/assets/index-OLDHASH.js", `${ORIGIN}/leagues`);
|
|
||||||
expect(shouldReportServerError(routeError(404, true), req)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
"/assets/app-x1.css",
|
|
||||||
"/fonts/inter.woff2",
|
|
||||||
"/images/logo.png",
|
|
||||||
"/favicon.ico",
|
|
||||||
])("drops a 404 for %s", (path) => {
|
|
||||||
expect(
|
|
||||||
shouldReportServerError(
|
|
||||||
routeError(404, true),
|
|
||||||
request(path, `${ORIGIN}/`),
|
|
||||||
),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still follows the referer rule for a non-asset path containing a dot", () => {
|
|
||||||
expect(
|
|
||||||
shouldReportServerError(
|
|
||||||
routeError(404, true),
|
|
||||||
request("/leagues/v1.2", `${ORIGIN}/leagues`),
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
shouldReportServerError(routeError(404, true), request("/leagues/v1.2")),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("React Router internal statuses that are not 404/405", () => {
|
|
||||||
it("reports an internal 400 (route is missing a loader)", () => {
|
|
||||||
expect(
|
|
||||||
shouldReportServerError(
|
|
||||||
routeError(400, true, "Bad Request"),
|
|
||||||
request("/leagues"),
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports an internal 403 (route does not match URL)", () => {
|
|
||||||
expect(
|
|
||||||
shouldReportServerError(
|
|
||||||
routeError(403, true, "Forbidden"),
|
|
||||||
request("/leagues"),
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("production shape: TLS terminated upstream", () => {
|
|
||||||
it("reports a 404 linked from our own site when the proxy strips https", () => {
|
|
||||||
// Express builds request.url from req.protocol, which is `http` inside the
|
|
||||||
// container. Real browsers send an https referer. Comparing full origins
|
|
||||||
// would never match, silencing every broken internal link.
|
|
||||||
const req = new Request("http://brackt.com/leagues/gone", {
|
|
||||||
headers: { referer: "https://brackt.com/leagues" },
|
|
||||||
});
|
|
||||||
expect(shouldReportServerError(routeError(404, true), req)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still drops a cold scanner hit under that same shape", () => {
|
|
||||||
const req = new Request("http://brackt.com/blog/wp/v2/posts/999999");
|
|
||||||
expect(shouldReportServerError(routeError(404, true), req)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still drops a 404 linked from another site under that same shape", () => {
|
|
||||||
const req = new Request("http://brackt.com/nope", {
|
|
||||||
headers: { referer: "https://evil.example/" },
|
|
||||||
});
|
|
||||||
expect(shouldReportServerError(routeError(404, true), req)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import {
|
|
||||||
FIFA_2026_R32_TEMPLATE,
|
|
||||||
THIRD_PLACE_SLOTS,
|
|
||||||
assignThirdPlaceSlots,
|
|
||||||
type R32Slot,
|
|
||||||
} from "../fifa-2026-bracket";
|
|
||||||
|
|
||||||
const GROUPS = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L"];
|
|
||||||
|
|
||||||
describe("FIFA_2026_R32_TEMPLATE", () => {
|
|
||||||
it("has 16 matches with unique DB match numbers 1–16", () => {
|
|
||||||
expect(FIFA_2026_R32_TEMPLATE).toHaveLength(16);
|
|
||||||
const dbNums = FIFA_2026_R32_TEMPLATE.map((m) => m.dbMatchNumber).toSorted((a, b) => a - b);
|
|
||||||
expect(dbNums).toEqual(Array.from({ length: 16 }, (_, i) => i + 1));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps onto the official FIFA match numbers 73–88", () => {
|
|
||||||
const fifaNums = FIFA_2026_R32_TEMPLATE.map((m) => m.fifaMatchNumber).toSorted((a, b) => a - b);
|
|
||||||
expect(fifaNums).toEqual(Array.from({ length: 16 }, (_, i) => i + 73));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses each group winner and runner-up exactly once across the 32 slots", () => {
|
|
||||||
const winners: string[] = [];
|
|
||||||
const runnersUp: string[] = [];
|
|
||||||
for (const m of FIFA_2026_R32_TEMPLATE) {
|
|
||||||
for (const slot of [m.slot1, m.slot2] as R32Slot[]) {
|
|
||||||
if (slot.kind === "winner") winners.push(slot.group);
|
|
||||||
if (slot.kind === "runnerUp") runnersUp.push(slot.group);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
expect(winners.toSorted()).toEqual(GROUPS);
|
|
||||||
expect(runnersUp.toSorted()).toEqual(GROUPS);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("has exactly 8 third-place slots", () => {
|
|
||||||
expect(THIRD_PLACE_SLOTS).toHaveLength(8);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("assignThirdPlaceSlots", () => {
|
|
||||||
it("assigns all 8 qualifying thirds to distinct, eligible slots", () => {
|
|
||||||
// Best-8 thirds come from groups A–H (a plausible qualifying set).
|
|
||||||
const qualifying = ["A", "B", "C", "D", "E", "F", "G", "H"];
|
|
||||||
const assignment = assignThirdPlaceSlots(qualifying);
|
|
||||||
|
|
||||||
expect(assignment.size).toBe(8);
|
|
||||||
|
|
||||||
// Every slot filled, each by an eligible & qualifying group, no group reused.
|
|
||||||
const usedGroups = new Set<string>();
|
|
||||||
for (const slot of THIRD_PLACE_SLOTS) {
|
|
||||||
const group = assignment.get(slot.thirdSlotId) ?? "";
|
|
||||||
expect(group).not.toBe("");
|
|
||||||
expect(slot.eligibleGroups).toContain(group);
|
|
||||||
expect(qualifying).toContain(group);
|
|
||||||
expect(usedGroups.has(group)).toBe(false);
|
|
||||||
usedGroups.add(group);
|
|
||||||
}
|
|
||||||
expect(usedGroups.size).toBe(8);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("produces a valid matching for every 8-of-12 qualifying combination", () => {
|
|
||||||
// Exhaustively check all C(12,8) = 495 combinations resolve to a complete,
|
|
||||||
// eligibility-respecting, conflict-free assignment.
|
|
||||||
let combinations = 0;
|
|
||||||
const combos = (start: number, chosen: string[]) => {
|
|
||||||
if (chosen.length === 8) {
|
|
||||||
combinations++;
|
|
||||||
const assignment = assignThirdPlaceSlots(chosen);
|
|
||||||
expect(assignment.size).toBe(8);
|
|
||||||
const used = new Set(assignment.values());
|
|
||||||
expect(used.size).toBe(8);
|
|
||||||
for (const slot of THIRD_PLACE_SLOTS) {
|
|
||||||
const group = assignment.get(slot.thirdSlotId);
|
|
||||||
expect(slot.eligibleGroups).toContain(group);
|
|
||||||
expect(chosen).toContain(group);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (let i = start; i < GROUPS.length; i++) {
|
|
||||||
combos(i + 1, [...chosen, GROUPS[i]]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
combos(0, []);
|
|
||||||
expect(combinations).toBe(495);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is deterministic for a given combination", () => {
|
|
||||||
const qualifying = ["B", "D", "E", "F", "H", "I", "J", "L"];
|
|
||||||
const a = assignThirdPlaceSlots(qualifying);
|
|
||||||
const b = assignThirdPlaceSlots(qualifying.toReversed());
|
|
||||||
expect([...a.entries()].toSorted()).toEqual([...b.entries()].toSorted());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches FIFA's exact Annex C allocation for option 1 (groups E–L qualify)", () => {
|
|
||||||
// Published row 1: 1A vs 3E, 1B vs 3J, 1D vs 3I, 1E vs 3F,
|
|
||||||
// 1G vs 3H, 1I vs 3G, 1K vs 3L, 1L vs 3K.
|
|
||||||
// Mapped to DB third-slot ids (the match each winner plays in):
|
|
||||||
const assignment = assignThirdPlaceSlots(["E", "F", "G", "H", "I", "J", "K", "L"]);
|
|
||||||
expect(assignment.get(11)).toBe("E"); // winner A's match
|
|
||||||
expect(assignment.get(15)).toBe("J"); // winner B's match
|
|
||||||
expect(assignment.get(7)).toBe("I"); // winner D's match
|
|
||||||
expect(assignment.get(1)).toBe("F"); // winner E's match
|
|
||||||
expect(assignment.get(8)).toBe("H"); // winner G's match
|
|
||||||
expect(assignment.get(2)).toBe("G"); // winner I's match
|
|
||||||
expect(assignment.get(16)).toBe("L"); // winner K's match
|
|
||||||
expect(assignment.get(12)).toBe("K"); // winner L's match
|
|
||||||
});
|
|
||||||
|
|
||||||
it("template eligible-group lists exactly match the Annex C table", () => {
|
|
||||||
// For every slot, collect the set of groups the published table ever assigns
|
|
||||||
// to it across all 495 combinations, and assert it equals the hand-written
|
|
||||||
// eligibleGroups list in the template.
|
|
||||||
const observed = new Map<number, Set<string>>(
|
|
||||||
THIRD_PLACE_SLOTS.map((s) => [s.thirdSlotId, new Set<string>()])
|
|
||||||
);
|
|
||||||
const combos = (start: number, chosen: string[]) => {
|
|
||||||
if (chosen.length === 8) {
|
|
||||||
const assignment = assignThirdPlaceSlots(chosen);
|
|
||||||
for (const [slotId, group] of assignment) observed.get(slotId)?.add(group);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (let i = start; i < GROUPS.length; i++) combos(i + 1, [...chosen, GROUPS[i]]);
|
|
||||||
};
|
|
||||||
combos(0, []);
|
|
||||||
|
|
||||||
for (const slot of THIRD_PLACE_SLOTS) {
|
|
||||||
expect([...(observed.get(slot.thirdSlotId) ?? [])].toSorted()).toEqual(
|
|
||||||
[...slot.eligibleGroups].toSorted()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -14,11 +14,6 @@ describe("normalizeTeamName", () => {
|
||||||
const name = "oklahoma city thunder";
|
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", () => {
|
||||||
|
|
@ -43,12 +38,6 @@ 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();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
/**
|
|
||||||
* AFL Wildcard Round → Elimination Finals re-seeding.
|
|
||||||
*
|
|
||||||
* The Wildcard Round is drawn 7 v 10 and 8 v 9, and its two winners fill the open slots
|
|
||||||
* in the Elimination Finals opposite the 5th and 6th seeds. Those slots are NOT a fixed
|
|
||||||
* crossover: the winners are re-seeded by ladder position, exactly as the classic final
|
|
||||||
* eight pairs 5 v 8 and 6 v 7 — the higher seed of the two hosts meets the lower-ranked
|
|
||||||
* winner. So 5th plays whichever winner finished further down the ladder and 6th plays
|
|
||||||
* the other, whichever Wildcard game each came out of.
|
|
||||||
*
|
|
||||||
* Worked example: 10th beats 7th and 9th beats 8th. A fixed crossover would send the
|
|
||||||
* 7v10 winner (10th) to 6th and the 8v9 winner (9th) to 5th — handing the higher host
|
|
||||||
* the better opponent. Re-seeded, 5th plays 10th and 6th plays 9th.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Seeds drawn into each Wildcard Round match, in [participant1, participant2] order. */
|
|
||||||
export const AFL_WILDCARD_DRAW: Readonly<Record<number, readonly [number, number]>> = {
|
|
||||||
1: [7, 10],
|
|
||||||
2: [8, 9],
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Seed hosting each Elimination Finals match (its participant1 slot). */
|
|
||||||
export const AFL_ELIMINATION_HOSTS: Readonly<Record<number, number>> = {
|
|
||||||
1: 5,
|
|
||||||
2: 6,
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface AflWildcardResult {
|
|
||||||
matchNumber: number;
|
|
||||||
/** Slot the winner occupied, or null while the match is still to be played. */
|
|
||||||
winnerSlot: 1 | 2 | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AflWildcardPlacement {
|
|
||||||
wildcardMatchNumber: number;
|
|
||||||
/** Seed of the Wildcard winner being placed. */
|
|
||||||
seed: number;
|
|
||||||
eliminationMatchNumber: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decide which Elimination Final each decided Wildcard winner belongs in.
|
|
||||||
*
|
|
||||||
* A winner is only placed once its destination is settled whichever way the other
|
|
||||||
* Wildcard game falls, so results can be entered in either order:
|
|
||||||
* - 7th winning match 1 outranks both possible match 2 winners → always meets 6th.
|
|
||||||
* - 10th winning match 1 is outranked by both → always meets 5th.
|
|
||||||
* - A match 2 winner (8th or 9th) sits between them, so it is held back until match 1
|
|
||||||
* is decided rather than being placed and then moved.
|
|
||||||
*
|
|
||||||
* Undecided winners are simply omitted; the caller fills the slots it is handed and
|
|
||||||
* leaves the rest TBD.
|
|
||||||
*/
|
|
||||||
export function resolveAflWildcardPlacements(
|
|
||||||
results: readonly AflWildcardResult[]
|
|
||||||
): AflWildcardPlacement[] {
|
|
||||||
const entries = results.map((result) => {
|
|
||||||
const draw = AFL_WILDCARD_DRAW[result.matchNumber];
|
|
||||||
if (!draw) {
|
|
||||||
throw new Error(`Unknown AFL Wildcard Round match number ${result.matchNumber}`);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
matchNumber: result.matchNumber,
|
|
||||||
seed: result.winnerSlot === null ? null : draw[result.winnerSlot - 1],
|
|
||||||
// Every seed the match could still send through — one entry once it is decided.
|
|
||||||
possibleSeeds: result.winnerSlot === null ? [...draw] : [draw[result.winnerSlot - 1]],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Best-ranked winner takes the weakest host, so order the hosts worst seed first.
|
|
||||||
const hostsWorstFirst = Object.keys(AFL_ELIMINATION_HOSTS)
|
|
||||||
.map(Number)
|
|
||||||
.toSorted((a, b) => AFL_ELIMINATION_HOSTS[b] - AFL_ELIMINATION_HOSTS[a]);
|
|
||||||
|
|
||||||
const placements: AflWildcardPlacement[] = [];
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const seed = entry.seed;
|
|
||||||
if (seed === null) continue;
|
|
||||||
|
|
||||||
const others = entries.filter((other) => other !== entry);
|
|
||||||
const outranks = (other: (typeof entries)[number]) => other.possibleSeeds.every((s) => s < seed);
|
|
||||||
const outrankedBy = (other: (typeof entries)[number]) => other.possibleSeeds.every((s) => s > seed);
|
|
||||||
|
|
||||||
// This winner's rank is only knowable while every other one sits wholly above or
|
|
||||||
// wholly below it — an undecided game straddling this seed leaves it unplaceable.
|
|
||||||
if (!others.every((other) => outranks(other) || outrankedBy(other))) continue;
|
|
||||||
|
|
||||||
const rank = others.filter(outranks).length;
|
|
||||||
const eliminationMatchNumber = hostsWorstFirst[rank];
|
|
||||||
if (eliminationMatchNumber === undefined) {
|
|
||||||
throw new Error(`No Elimination Finals slot for AFL Wildcard winner ranked ${rank + 1}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
placements.push({ wildcardMatchNumber: entry.matchNumber, seed, eliminationMatchNumber });
|
|
||||||
}
|
|
||||||
|
|
||||||
return placements;
|
|
||||||
}
|
|
||||||
|
|
@ -1,420 +0,0 @@
|
||||||
/**
|
|
||||||
* Bracket geometry, derived from the real feeder graph.
|
|
||||||
*
|
|
||||||
* The renderer used to place cards by index within a round — match i at
|
|
||||||
* `i * (height / roundSize)` — and drew connectors assuming matches 2k and 2k+1 feed
|
|
||||||
* match k. That holds only when each round is an exact halving of the previous one.
|
|
||||||
*
|
|
||||||
* The LLWS winners bracket is not a halving: two of the four Opening Round games skip
|
|
||||||
* Winners Round 2 entirely and go straight to the semifinals (see LLWS_ADVANCEMENT).
|
|
||||||
* Under index math those games get pulled to the bottom of column one with nothing
|
|
||||||
* above them in column two, and the connectors confidently join the wrong pairs.
|
|
||||||
*
|
|
||||||
* So lay out from the graph instead:
|
|
||||||
* column = depth from the group's final, counted backwards
|
|
||||||
* vertical order = the parent's slot order (participant1 above participant2)
|
|
||||||
* connectors = actual feeder edges
|
|
||||||
*
|
|
||||||
* Counting columns back from the final is what makes a printed bracket line up: a team
|
|
||||||
* entering late sits in the column where it actually plays, not the column its round
|
|
||||||
* name suggests. For the LLWS International side this reproduces the official bracket
|
|
||||||
* exactly, including putting the Australia/Mexico game alongside Winners Round 2.
|
|
||||||
*
|
|
||||||
* Pure — no React, no DB — so the geometry can be asserted against the printed bracket
|
|
||||||
* in tests.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
llwsSideAndLocal,
|
|
||||||
type BracketTemplate,
|
|
||||||
} from "~/lib/bracket-templates";
|
|
||||||
import { resolveLLWSAdvancement } from "~/lib/llws-bracket";
|
|
||||||
|
|
||||||
// ── Feeder graph ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface MatchRef {
|
|
||||||
round: string;
|
|
||||||
matchNumber: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What fills one participant slot of a match. */
|
|
||||||
export type SlotSource =
|
|
||||||
| { kind: "match"; ref: MatchRef; result: "winner" | "loser" }
|
|
||||||
| { kind: "seed" };
|
|
||||||
|
|
||||||
/** Keyed by `${round}#${matchNumber}`; the pair is [participant1, participant2]. */
|
|
||||||
export type FeederMap = Map<string, [SlotSource, SlotSource]>;
|
|
||||||
|
|
||||||
const SEED: SlotSource = { kind: "seed" };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `template.id:roundName` for transitions routed by a dedicated advancement function
|
|
||||||
* rather than advanceWinnerTemplate's ceil(n/2) rule, and whose round sizes happen to
|
|
||||||
* halve so the check in buildFeederMap can't rule them out on shape alone.
|
|
||||||
*
|
|
||||||
* The NBA play-in is the case: Play-In Round 2 pairs the 7v8 *loser* with the 9v10
|
|
||||||
* winner (advanceNBAPlayInWinner), which no winners-only halving describes.
|
|
||||||
*/
|
|
||||||
const BESPOKE_TRANSITIONS = new Set(["nba_20:Play-In Round 1"]);
|
|
||||||
|
|
||||||
export function matchKey(round: string, matchNumber: number): string {
|
|
||||||
return `${round}#${matchNumber}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invert a template's advancement rules into "what fills each slot".
|
|
||||||
*
|
|
||||||
* `llws_20` has an explicit, hand-verified routing table with deliberate cross-overs, so
|
|
||||||
* it is inverted from that. Everything else follows the standard rule: slot p1 of match N
|
|
||||||
* is the winner of match 2N-1 in the previous round, slot p2 the winner of match 2N.
|
|
||||||
*/
|
|
||||||
export function buildFeederMap(template: BracketTemplate | undefined): FeederMap {
|
|
||||||
const feeders: FeederMap = new Map();
|
|
||||||
if (!template) return feeders;
|
|
||||||
|
|
||||||
const slots = (key: string): [SlotSource, SlotSource] => {
|
|
||||||
let pair = feeders.get(key);
|
|
||||||
if (!pair) {
|
|
||||||
pair = [SEED, SEED];
|
|
||||||
feeders.set(key, pair);
|
|
||||||
}
|
|
||||||
return pair;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Seed every match in the template so unfed slots read as directly seeded.
|
|
||||||
for (const round of template.rounds) {
|
|
||||||
for (let n = 1; n <= round.matchCount; n++) slots(matchKey(round.name, n));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (template.id === "llws_20") {
|
|
||||||
for (const round of template.rounds) {
|
|
||||||
for (let n = 1; n <= round.matchCount; n++) {
|
|
||||||
const { winner, loser } = resolveLLWSAdvancement(round.name, n);
|
|
||||||
const ref: MatchRef = { round: round.name, matchNumber: n };
|
|
||||||
for (const [destination, result] of [
|
|
||||||
[winner, "winner"],
|
|
||||||
[loser, "loser"],
|
|
||||||
] as const) {
|
|
||||||
if (!destination) continue;
|
|
||||||
const pair = slots(matchKey(destination.round, destination.matchNumber));
|
|
||||||
pair[destination.slot === "participant1Id" ? 0 : 1] = { kind: "match", ref, result };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return feeders;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Follow each round's declared `feedsInto` rather than array order — AFL's Wildcard
|
|
||||||
// Round feeds the Elimination Finals, skipping the round printed next to it.
|
|
||||||
for (const prev of template.rounds) {
|
|
||||||
if (!prev.feedsInto) continue;
|
|
||||||
const round = template.rounds.find((r) => r.name === prev.feedsInto);
|
|
||||||
if (!round) continue;
|
|
||||||
|
|
||||||
// advanceWinnerTemplate sends match n to ceil(n/2) in the next round, slot by
|
|
||||||
// parity. That describes the bracket only where the round halves exactly; a
|
|
||||||
// play-in, a bye round, or a First Four routes by rules of its own, and inventing
|
|
||||||
// a halving there would draw connectors and slot labels that are simply wrong.
|
|
||||||
// Leaving those edges out drops the group to computeGroupLayout's fallback, which
|
|
||||||
// is the geometry these brackets already had.
|
|
||||||
if (prev.matchCount !== round.matchCount * 2) continue;
|
|
||||||
if (BESPOKE_TRANSITIONS.has(`${template.id}:${prev.name}`)) continue;
|
|
||||||
|
|
||||||
for (let n = 1; n <= round.matchCount; n++) {
|
|
||||||
const pair = slots(matchKey(round.name, n));
|
|
||||||
pair[0] = {
|
|
||||||
kind: "match",
|
|
||||||
ref: { round: prev.name, matchNumber: 2 * n - 1 },
|
|
||||||
result: "winner",
|
|
||||||
};
|
|
||||||
pair[1] = {
|
|
||||||
kind: "match",
|
|
||||||
ref: { round: prev.name, matchNumber: 2 * n },
|
|
||||||
result: "winner",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return feeders;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Slot labels ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Round names as they read inside a card, where there is room for about twenty
|
|
||||||
* characters. Anything not listed keeps its full name.
|
|
||||||
*/
|
|
||||||
const SHORT_ROUND_NAMES: Record<string, string> = {
|
|
||||||
"Opening Round": "Opening",
|
|
||||||
"Winners Round 2": "Winners R2",
|
|
||||||
"Winners Semifinals": "Winners SF",
|
|
||||||
"Winners Final": "Winners Final",
|
|
||||||
"Elimination Round 1": "Elim R1",
|
|
||||||
"Elimination Round 2": "Elim R2",
|
|
||||||
"Elimination Round 3": "Elim R3",
|
|
||||||
"Elimination Round 4": "Elim R4",
|
|
||||||
"Elimination Final": "Elim Final",
|
|
||||||
"Bracket Championship": "Bracket Final",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How an empty slot should read: "Winner of Winners SF 2" rather than "TBD".
|
|
||||||
*
|
|
||||||
* Returns null for a directly seeded slot, which the caller renders as "TBD".
|
|
||||||
*
|
|
||||||
* The cross-bracket feeds matter most here — a winners-bracket loser dropping into the
|
|
||||||
* elimination bracket is a real edge that no line can show, because the two sides render
|
|
||||||
* as separate trees.
|
|
||||||
*/
|
|
||||||
export function describeSlotSource(
|
|
||||||
source: SlotSource | undefined,
|
|
||||||
template: BracketTemplate | undefined
|
|
||||||
): string | null {
|
|
||||||
if (!source || source.kind !== "match") return null;
|
|
||||||
|
|
||||||
const { round, matchNumber } = source.ref;
|
|
||||||
const name = SHORT_ROUND_NAMES[round] ?? round;
|
|
||||||
const verb = source.result === "winner" ? "Winner" : "Loser";
|
|
||||||
const roundMatchCount = template?.rounds.find((r) => r.name === round)?.matchCount ?? 0;
|
|
||||||
|
|
||||||
if (template?.id !== "llws_20") {
|
|
||||||
return `${verb} of ${name}${roundMatchCount <= 1 ? "" : ` ${matchNumber}`}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// LLWS numbers matches globally across both sides, so semifinal 4 is International
|
|
||||||
// semifinal 2. Name it the way the printed bracket does — by side-local number, or by
|
|
||||||
// side where each side plays only one such game and the number would say nothing.
|
|
||||||
const { side, localMatch } = llwsSideAndLocal(round, matchNumber);
|
|
||||||
const isShared = round === "Consolation Third Place" || round === "World Championship";
|
|
||||||
const perSideCount = isShared ? roundMatchCount : roundMatchCount / 2;
|
|
||||||
|
|
||||||
if (perSideCount > 1) return `${verb} of ${name} ${localMatch}`;
|
|
||||||
if (isShared) return `${verb} of ${name}`;
|
|
||||||
return `${verb} of ${side === 0 ? "U.S." : "Intl"} ${name}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Layout ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface LaidOutMatch<M> {
|
|
||||||
match: M;
|
|
||||||
/** Centre of the card, in slot units (1 unit = one leaf row). */
|
|
||||||
center: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LayoutColumn<M> {
|
|
||||||
label: string;
|
|
||||||
matches: LaidOutMatch<M>[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BracketLayout<M> {
|
|
||||||
columns: LayoutColumn<M>[];
|
|
||||||
/** Number of leaf rows; multiply by row height for the pixel height of the bracket. */
|
|
||||||
leafCount: number;
|
|
||||||
/** Edges to draw, as (column index of the source, source centre, target centre). */
|
|
||||||
edges: { fromColumn: number; fromCenter: number; toCenter: number }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PositionedMatch {
|
|
||||||
round: string;
|
|
||||||
matchNumber: number;
|
|
||||||
/** Only read by the fallback, to trace edges through an unrecognised shape. */
|
|
||||||
winnerId?: string | null;
|
|
||||||
participant1Id?: string | null;
|
|
||||||
participant2Id?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lay out one rendered group — a winners bracket, an elimination bracket, a region.
|
|
||||||
*
|
|
||||||
* `matchesByRound` should already be filtered to the group; cross-group feeds are
|
|
||||||
* dropped, matching the printed bracket, which labels those slots rather than drawing
|
|
||||||
* lines to another tree.
|
|
||||||
*
|
|
||||||
* Falls back to the previous index-based geometry when the group has no single root
|
|
||||||
* (disjoint or unrecognised shapes), so no existing template can regress to a blank
|
|
||||||
* column.
|
|
||||||
*/
|
|
||||||
export function computeGroupLayout<M extends PositionedMatch>(
|
|
||||||
visibleRounds: string[],
|
|
||||||
matchesByRound: Map<string, M[]>,
|
|
||||||
feeders: FeederMap,
|
|
||||||
templateRoundOrder: string[]
|
|
||||||
): BracketLayout<M> {
|
|
||||||
const nodes = new Map<string, M>();
|
|
||||||
const roundOf = new Map<string, string>();
|
|
||||||
for (const round of visibleRounds) {
|
|
||||||
for (const match of matchesByRound.get(round) ?? []) {
|
|
||||||
const key = matchKey(match.round, match.matchNumber);
|
|
||||||
nodes.set(key, match);
|
|
||||||
roundOf.set(key, round);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (nodes.size === 0) return { columns: [], leafCount: 0, edges: [] };
|
|
||||||
|
|
||||||
// In-group children, in slot order. A slot fed from outside the group has no card
|
|
||||||
// here, so it contributes no edge.
|
|
||||||
const childrenOf = new Map<string, string[]>();
|
|
||||||
const hasParent = new Set<string>();
|
|
||||||
for (const key of nodes.keys()) {
|
|
||||||
const pair = feeders.get(key);
|
|
||||||
const kids: string[] = [];
|
|
||||||
for (const source of pair ?? []) {
|
|
||||||
if (source.kind !== "match") continue;
|
|
||||||
const childKey = matchKey(source.ref.round, source.ref.matchNumber);
|
|
||||||
if (!nodes.has(childKey) || kids.includes(childKey)) continue;
|
|
||||||
kids.push(childKey);
|
|
||||||
hasParent.add(childKey);
|
|
||||||
}
|
|
||||||
childrenOf.set(key, kids);
|
|
||||||
}
|
|
||||||
|
|
||||||
const roots = [...nodes.keys()].filter((k) => !hasParent.has(k));
|
|
||||||
if (roots.length !== 1) {
|
|
||||||
return fallbackLayout(visibleRounds, matchesByRound);
|
|
||||||
}
|
|
||||||
const [root] = roots;
|
|
||||||
|
|
||||||
// Depth from the root, then flip so leaves are column 0 and the final is last.
|
|
||||||
//
|
|
||||||
// Take the longest path, not the first one found: in a double-elimination bracket a
|
|
||||||
// match feeds two places (its winner forward, its loser into the elimination side), so
|
|
||||||
// the graph is a DAG and a node can be reached at several depths. The longest path is
|
|
||||||
// the one that leaves room for every game on the way.
|
|
||||||
const depth = new Map<string, number>();
|
|
||||||
const assignDepth = (key: string, d: number) => {
|
|
||||||
const known = depth.get(key);
|
|
||||||
if (known !== undefined && known >= d) return;
|
|
||||||
depth.set(key, d);
|
|
||||||
for (const child of childrenOf.get(key) ?? []) assignDepth(child, d + 1);
|
|
||||||
};
|
|
||||||
assignDepth(root, 0);
|
|
||||||
if (depth.size !== nodes.size) {
|
|
||||||
return fallbackLayout(visibleRounds, matchesByRound);
|
|
||||||
}
|
|
||||||
const maxDepth = Math.max(...depth.values());
|
|
||||||
const columnOf = (key: string) => maxDepth - (depth.get(key) ?? 0);
|
|
||||||
|
|
||||||
// Vertical order comes from a depth-first walk in slot order: participant1's feeder
|
|
||||||
// sits above participant2's. This is why the elimination bracket's later game ends up
|
|
||||||
// on top, as the printed bracket has it.
|
|
||||||
const center = new Map<string, number>();
|
|
||||||
let leafCount = 0;
|
|
||||||
const place = (key: string): number => {
|
|
||||||
const already = center.get(key);
|
|
||||||
if (already !== undefined) return already;
|
|
||||||
const kids = childrenOf.get(key) ?? [];
|
|
||||||
if (kids.length === 0) {
|
|
||||||
const y = leafCount + 0.5;
|
|
||||||
leafCount += 1;
|
|
||||||
center.set(key, y);
|
|
||||||
return y;
|
|
||||||
}
|
|
||||||
const kidCenters = kids.map(place);
|
|
||||||
const y = kidCenters.reduce((sum, c) => sum + c, 0) / kidCenters.length;
|
|
||||||
center.set(key, y);
|
|
||||||
return y;
|
|
||||||
};
|
|
||||||
place(root);
|
|
||||||
|
|
||||||
const columns: LayoutColumn<M>[] = Array.from({ length: maxDepth + 1 }, () => ({
|
|
||||||
label: "",
|
|
||||||
matches: [],
|
|
||||||
}));
|
|
||||||
for (const [key, match] of nodes) {
|
|
||||||
columns[columnOf(key)].matches.push({ match, center: center.get(key) ?? 0 });
|
|
||||||
}
|
|
||||||
for (const column of columns) {
|
|
||||||
column.matches.sort((a, b) => a.center - b.center);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A column can mix rounds — the LLWS second column holds two Opening Round games
|
|
||||||
// alongside Winners Round 2. Name it for the latest round it contains, which is how
|
|
||||||
// the printed bracket labels that column.
|
|
||||||
for (let ci = 0; ci < columns.length; ci++) {
|
|
||||||
const rounds = columns[ci].matches.map((m) => m.match.round);
|
|
||||||
columns[ci].label = rounds.reduce((latest, r) =>
|
|
||||||
templateRoundOrder.indexOf(r) > templateRoundOrder.indexOf(latest) ? r : latest
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connectors live in the single gutter between adjacent columns, so only edges that
|
|
||||||
// span exactly one gutter can be drawn. In a tree every edge does; in the DAG case a
|
|
||||||
// feed can reach further back, and a line that stopped short would be worse than none.
|
|
||||||
const edges: BracketLayout<M>["edges"] = [];
|
|
||||||
for (const [key] of nodes) {
|
|
||||||
const toCenter = center.get(key) ?? 0;
|
|
||||||
for (const child of childrenOf.get(key) ?? []) {
|
|
||||||
const fromColumn = columnOf(child);
|
|
||||||
if (fromColumn !== columnOf(key) - 1) continue;
|
|
||||||
edges.push({ fromColumn, fromCenter: center.get(child) ?? 0, toCenter });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { columns, leafCount, edges };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The previous behaviour, kept for groups whose shape can't be resolved into a single
|
|
||||||
* tree: one column per round, matches spread evenly over it, and edges inferred from the
|
|
||||||
* round sizes. Brackets with bespoke routing (AFL, CFP byes, a bracket with no template)
|
|
||||||
* land here, so it has to keep drawing what they drew before rather than nothing.
|
|
||||||
*/
|
|
||||||
function fallbackLayout<M extends PositionedMatch>(
|
|
||||||
visibleRounds: string[],
|
|
||||||
matchesByRound: Map<string, M[]>
|
|
||||||
): BracketLayout<M> {
|
|
||||||
const leafCount = Math.max(
|
|
||||||
...visibleRounds.map((r) => matchesByRound.get(r)?.length ?? 0),
|
|
||||||
1
|
|
||||||
);
|
|
||||||
const centersFor = (matches: M[]) => {
|
|
||||||
const span = leafCount / Math.max(matches.length, 1);
|
|
||||||
return matches.map((_, i) => (i + 0.5) * span);
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns = visibleRounds.map((round) => {
|
|
||||||
const matches = matchesByRound.get(round) ?? [];
|
|
||||||
const centers = centersFor(matches);
|
|
||||||
return {
|
|
||||||
label: round,
|
|
||||||
matches: matches.map((match, i) => ({ match, center: centers[i] })),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const edges: BracketLayout<M>["edges"] = [];
|
|
||||||
for (let ci = 0; ci < columns.length - 1; ci++) {
|
|
||||||
const from = columns[ci].matches;
|
|
||||||
const to = columns[ci + 1].matches;
|
|
||||||
|
|
||||||
if (to.length === Math.ceil(from.length / 2) && from.length > 1) {
|
|
||||||
// A halving: matches 2k and 2k+1 feed match k.
|
|
||||||
for (let k = 0; k < to.length; k++) {
|
|
||||||
for (const idx of [2 * k, 2 * k + 1]) {
|
|
||||||
if (idx >= from.length) continue;
|
|
||||||
edges.push({
|
|
||||||
fromColumn: ci,
|
|
||||||
fromCenter: from[idx].center,
|
|
||||||
toCenter: to[k].center,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise the only thing that can be known is where a winner actually went, so
|
|
||||||
// nothing is drawn until the games are played.
|
|
||||||
const winnerToCenter = new Map<string, number>();
|
|
||||||
for (const { match, center } of from) {
|
|
||||||
if (match.winnerId) winnerToCenter.set(match.winnerId, center);
|
|
||||||
}
|
|
||||||
for (const { match, center } of to) {
|
|
||||||
for (const id of [match.participant1Id, match.participant2Id]) {
|
|
||||||
const fromCenter = id ? winnerToCenter.get(id) : undefined;
|
|
||||||
if (fromCenter === undefined) continue;
|
|
||||||
edges.push({ fromColumn: ci, fromCenter, toCenter: center });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { columns, leafCount, edges };
|
|
||||||
}
|
|
||||||
|
|
@ -19,32 +19,6 @@ export interface BracketRound {
|
||||||
* When set, the loser of each match in this round is placed into the target round.
|
* 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 {
|
||||||
|
|
@ -481,31 +455,6 @@ export const DARTS_128: BracketTemplate = {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Tennis Grand Slam (128-player single-elimination draw)
|
|
||||||
* R128 → R64 → R32 → Round of 16 → Quarterfinals → Semifinals → Final
|
|
||||||
*
|
|
||||||
* Qualifying-points sport: QP is derived from how far each player advances
|
|
||||||
* (see TEMPLATE_ROUND_CONFIG["tennis_128"] in scoring-calculator.ts). Scoring
|
|
||||||
* begins at the Round of 16 — R16 losers share placements 9–16; earlier rounds
|
|
||||||
* award nothing. Seeding: top 32 fixed, remaining 96 randomly drawn.
|
|
||||||
*/
|
|
||||||
export const TENNIS_128: BracketTemplate = {
|
|
||||||
id: "tennis_128",
|
|
||||||
name: "Tennis Grand Slam (128 Players)",
|
|
||||||
totalTeams: 128,
|
|
||||||
scoringStartsAtRound: "Round of 16",
|
|
||||||
rounds: [
|
|
||||||
{ name: "Round of 128", matchCount: 64, feedsInto: "Round of 64", isScoring: false },
|
|
||||||
{ name: "Round of 64", matchCount: 32, feedsInto: "Round of 32", isScoring: false },
|
|
||||||
{ name: "Round of 32", matchCount: 16, feedsInto: "Round of 16", isScoring: false },
|
|
||||||
{ name: "Round of 16", matchCount: 8, feedsInto: "Quarterfinals", isScoring: true }, // losers share 9th–16th
|
|
||||||
{ name: "Quarterfinals", matchCount: 4, feedsInto: "Semifinals", isScoring: true }, // losers share 5th–8th
|
|
||||||
{ name: "Semifinals", matchCount: 2, feedsInto: "Final", isScoring: true }, // losers share 3rd–4th
|
|
||||||
{ name: "Final", matchCount: 1, feedsInto: null, isScoring: true }, // winner 1st, loser 2nd
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NCAA March Madness (68 teams)
|
* 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
|
||||||
|
|
@ -703,8 +652,7 @@ 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: the two Wildcard winners are re-seeded by ladder position, so
|
* - Elimination Finals: 5v8(wildcard winner), 6v7(wildcard winner) (losers share 7th-8th)
|
||||||
* 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)
|
||||||
|
|
@ -722,32 +670,18 @@ 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",
|
||||||
|
|
@ -975,258 +909,6 @@ export const NBA_20: BracketTemplate = {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── LLWS 20 ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Side-local match numbers → global match numbers, per round shape. */
|
|
||||||
const LLWS_OPENING_OFFSET = 4; // Opening Round: US M1–4, Intl M5–8
|
|
||||||
const LLWS_PAIR_OFFSET = 2; // 4-match rounds: US M1–2, Intl M3–4
|
|
||||||
const LLWS_SOLO_OFFSET = 1; // 2-match rounds: US M1, Intl M2
|
|
||||||
|
|
||||||
/** Rounds with 4 matches (2 per side). Opening Round has 8; the rest have 2. */
|
|
||||||
export const LLWS_FOUR_MATCH_ROUNDS = new Set([
|
|
||||||
"Winners Round 2",
|
|
||||||
"Elimination Round 1",
|
|
||||||
"Winners Semifinals",
|
|
||||||
"Elimination Round 2",
|
|
||||||
"Elimination Round 3",
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the global match number for a side-local match in an LLWS round.
|
|
||||||
* side 0 = United States, side 1 = International.
|
|
||||||
*/
|
|
||||||
export function llwsMatchNumber(round: string, side: 0 | 1, localMatch: number): number {
|
|
||||||
const offset =
|
|
||||||
round === "Opening Round"
|
|
||||||
? LLWS_OPENING_OFFSET
|
|
||||||
: LLWS_FOUR_MATCH_ROUNDS.has(round)
|
|
||||||
? LLWS_PAIR_OFFSET
|
|
||||||
: LLWS_SOLO_OFFSET;
|
|
||||||
return localMatch + side * offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Inverse of llwsMatchNumber: global match number → { side, localMatch }.
|
|
||||||
*/
|
|
||||||
export function llwsSideAndLocal(
|
|
||||||
round: string,
|
|
||||||
matchNumber: number
|
|
||||||
): { side: 0 | 1; localMatch: number } {
|
|
||||||
const offset =
|
|
||||||
round === "Opening Round"
|
|
||||||
? LLWS_OPENING_OFFSET
|
|
||||||
: LLWS_FOUR_MATCH_ROUNDS.has(round)
|
|
||||||
? LLWS_PAIR_OFFSET
|
|
||||||
: LLWS_SOLO_OFFSET;
|
|
||||||
const side: 0 | 1 = matchNumber > offset ? 1 : 0;
|
|
||||||
return { side, localMatch: matchNumber - side * offset };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Little League Baseball World Series (20 teams, 2025+ double-elimination format)
|
|
||||||
*
|
|
||||||
* Two independent 10-team double-elimination brackets — United States and
|
|
||||||
* International — each producing a side champion, then a World Championship game and
|
|
||||||
* a Consolation Third Place game between the two side runners-up.
|
|
||||||
*
|
|
||||||
* Rounds are shared across both sides: U.S. matches take the low match numbers and
|
|
||||||
* International the high ones (see llwsMatchNumber). The phases/groups config splits
|
|
||||||
* them back apart for display.
|
|
||||||
*
|
|
||||||
* A loss in the winners bracket is NOT an elimination — it drops the team into the
|
|
||||||
* elimination bracket at a specific slot (see advanceLLWSWinner in models/playoff-match).
|
|
||||||
* A loss in the elimination bracket is final.
|
|
||||||
*
|
|
||||||
* There is deliberately NO "if necessary" game: the winners-bracket champion is out if
|
|
||||||
* it loses the Bracket Championship, dropping to the Consolation game rather than
|
|
||||||
* forcing a rematch. This is the official LLWS modified double-elimination format.
|
|
||||||
*
|
|
||||||
* Placement tiers (only 8 teams score — the field is exactly 8 when Elim R4 begins):
|
|
||||||
* 1st / 2nd World Championship
|
|
||||||
* 3rd / 4th Consolation Third Place (real game, so positions are distinct)
|
|
||||||
* 5th / 6th Elimination Final losers
|
|
||||||
* 7th / 8th Elimination Round 4 losers
|
|
||||||
* 0 pts the 12 teams eliminated in Elimination Rounds 1–3
|
|
||||||
*
|
|
||||||
* Participant array layout (20 slots):
|
|
||||||
* [0–7] U.S. Opening Round teams, two per game (M1..M4)
|
|
||||||
* [8, 9] U.S. bye teams, entering Winners Round 2 M1 / M2 at participant1
|
|
||||||
* [10–17] International Opening Round teams, two per game (M5..M8)
|
|
||||||
* [18,19] International bye teams, entering Winners Round 2 M3 / M4 at participant1
|
|
||||||
*/
|
|
||||||
export const LLWS_20: BracketTemplate = {
|
|
||||||
id: "llws_20",
|
|
||||||
name: "Little League World Series (20 teams)",
|
|
||||||
totalTeams: 20,
|
|
||||||
scoringStartsAtRound: "Winners Final",
|
|
||||||
// Ordered by the real schedule so non-phased views read chronologically.
|
|
||||||
rounds: [
|
|
||||||
{
|
|
||||||
name: "Opening Round",
|
|
||||||
matchCount: 8,
|
|
||||||
feedsInto: "Winners Round 2",
|
|
||||||
isScoring: false,
|
|
||||||
loserFeedsInto: "Elimination Round 1",
|
|
||||||
nonScoringWinnerFloor: null, // 16 teams still alive — nothing guaranteed
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Winners Round 2",
|
|
||||||
matchCount: 4,
|
|
||||||
feedsInto: "Winners Semifinals",
|
|
||||||
isScoring: false,
|
|
||||||
loserFeedsInto: "Elimination Round 2",
|
|
||||||
nonScoringWinnerFloor: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Elimination Round 1",
|
|
||||||
matchCount: 4,
|
|
||||||
feedsInto: "Elimination Round 2",
|
|
||||||
isScoring: false, // losers finish 13th–16th
|
|
||||||
nonScoringWinnerFloor: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Winners Semifinals",
|
|
||||||
matchCount: 4,
|
|
||||||
feedsInto: "Winners Final",
|
|
||||||
isScoring: false,
|
|
||||||
loserFeedsInto: "Elimination Round 3",
|
|
||||||
// Reaching the Winners Final guarantees at worst 5th (lose it, then lose the
|
|
||||||
// Elimination Final). Same value as the engine default, stated explicitly.
|
|
||||||
nonScoringWinnerFloor: 5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Elimination Round 2",
|
|
||||||
matchCount: 4,
|
|
||||||
feedsInto: "Elimination Round 3",
|
|
||||||
isScoring: false, // losers finish 11th–12th
|
|
||||||
nonScoringWinnerFloor: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Elimination Round 3",
|
|
||||||
matchCount: 4,
|
|
||||||
feedsInto: "Elimination Round 4",
|
|
||||||
isScoring: false, // losers finish 9th–10th
|
|
||||||
// Winners reach Elimination Round 4, where a loss is 7th — not 5th.
|
|
||||||
nonScoringWinnerFloor: 7,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Winners Final",
|
|
||||||
matchCount: 2,
|
|
||||||
feedsInto: "Bracket Championship",
|
|
||||||
isScoring: true, // loser drops to the Elimination Final (provisional 5th)
|
|
||||||
loserFeedsInto: "Elimination Final",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Elimination Round 4",
|
|
||||||
matchCount: 2,
|
|
||||||
feedsInto: "Elimination Final",
|
|
||||||
isScoring: true, // losers share 7th–8th
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Elimination Final",
|
|
||||||
matchCount: 2,
|
|
||||||
feedsInto: "Bracket Championship",
|
|
||||||
isScoring: true, // losers share 5th–6th
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Bracket Championship",
|
|
||||||
matchCount: 2,
|
|
||||||
feedsInto: "World Championship",
|
|
||||||
isScoring: true, // loser drops to the Consolation game (provisional 4th)
|
|
||||||
loserFeedsInto: "Consolation Third Place",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Consolation Third Place",
|
|
||||||
matchCount: 1,
|
|
||||||
feedsInto: null,
|
|
||||||
isScoring: true, // winner 3rd, loser 4th
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "World Championship",
|
|
||||||
matchCount: 1,
|
|
||||||
feedsInto: null,
|
|
||||||
isScoring: true, // winner 1st, loser 2nd
|
|
||||||
},
|
|
||||||
],
|
|
||||||
// Region assignments rotate year to year (which region draws the bye changes), so
|
|
||||||
// these are positional slot labels rather than region names. Kept short — the admin
|
|
||||||
// form renders them in a narrow fixed-width column alongside each participant picker.
|
|
||||||
participantLabels: [
|
|
||||||
"US G1 Home", "US G1 Away",
|
|
||||||
"US G2 Home", "US G2 Away",
|
|
||||||
"US G3 Home", "US G3 Away",
|
|
||||||
"US G4 Home", "US G4 Away",
|
|
||||||
"US Bye 1", "US Bye 2",
|
|
||||||
"Intl G1 Home", "Intl G1 Away",
|
|
||||||
"Intl G2 Home", "Intl G2 Away",
|
|
||||||
"Intl G3 Home", "Intl G3 Away",
|
|
||||||
"Intl G4 Home", "Intl G4 Away",
|
|
||||||
"Intl Bye 1", "Intl Bye 2",
|
|
||||||
],
|
|
||||||
phases: [
|
|
||||||
{
|
|
||||||
name: "United States",
|
|
||||||
groups: [
|
|
||||||
{
|
|
||||||
name: "U.S. Winner's Bracket",
|
|
||||||
roundMatchNumbers: {
|
|
||||||
"Opening Round": [1, 2, 3, 4],
|
|
||||||
"Winners Round 2": [1, 2],
|
|
||||||
"Winners Semifinals": [1, 2],
|
|
||||||
"Winners Final": [1],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "U.S. Elimination Bracket",
|
|
||||||
roundMatchNumbers: {
|
|
||||||
"Elimination Round 1": [1, 2],
|
|
||||||
"Elimination Round 2": [1, 2],
|
|
||||||
"Elimination Round 3": [1, 2],
|
|
||||||
"Elimination Round 4": [1],
|
|
||||||
"Elimination Final": [1],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "U.S. Championship",
|
|
||||||
roundMatchNumbers: { "Bracket Championship": [1] },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "International",
|
|
||||||
groups: [
|
|
||||||
{
|
|
||||||
name: "International Winner's Bracket",
|
|
||||||
roundMatchNumbers: {
|
|
||||||
"Opening Round": [5, 6, 7, 8],
|
|
||||||
"Winners Round 2": [3, 4],
|
|
||||||
"Winners Semifinals": [3, 4],
|
|
||||||
"Winners Final": [2],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "International Elimination Bracket",
|
|
||||||
roundMatchNumbers: {
|
|
||||||
"Elimination Round 1": [3, 4],
|
|
||||||
"Elimination Round 2": [3, 4],
|
|
||||||
"Elimination Round 3": [3, 4],
|
|
||||||
"Elimination Round 4": [2],
|
|
||||||
"Elimination Final": [2],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "International Championship",
|
|
||||||
roundMatchNumbers: { "Bracket Championship": [2] },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Championship",
|
|
||||||
rounds: ["Consolation Third Place", "World Championship"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All available bracket templates
|
* All available bracket templates
|
||||||
*/
|
*/
|
||||||
|
|
@ -1241,10 +923,8 @@ 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,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1300,18 +980,6 @@ 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,9 +16,7 @@
|
||||||
* 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 } from "date-fns";
|
import { format, parseISO } 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").
|
||||||
|
|
@ -27,8 +25,7 @@ const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
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 {
|
||||||
const [year, month, day] = dateStr.split("-").map(Number);
|
return format(parseISO(dateStr), "MMM d");
|
||||||
return format(new Date(year, month - 1, day), "MMM d");
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -59,28 +56,3 @@ 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}`;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
/**
|
|
||||||
* Decides which server-side errors are worth sending to Sentry.
|
|
||||||
*
|
|
||||||
* Automated scanners probe for CMS paths that have never existed here
|
|
||||||
* (`/blog/wp/v2/posts/999999`, `/wp-login.php`, a bare `POST /`). React Router
|
|
||||||
* throws for each one — a 404 when no route matches, a 405 when a route has no
|
|
||||||
* `action` — and every throw reaches `handleError` in `app/entry.server.tsx`.
|
|
||||||
* Reporting those burns the Sentry quota without ever describing a real bug.
|
|
||||||
*/
|
|
||||||
import { isRouteErrorResponse } from "react-router";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Statuses React Router uses to say "nothing here matched this request":
|
|
||||||
* 404 when no route matches the URL, 405 when the route has no `action` or the
|
|
||||||
* method is invalid. Its other internal statuses (400 "did not provide a
|
|
||||||
* `loader`", 403 "Route does not match URL") describe a misconfigured route
|
|
||||||
* rather than an unrecognised request, so those keep reporting.
|
|
||||||
*/
|
|
||||||
const UNMATCHED_REQUEST_STATUSES = new Set([404, 405]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Static assets 404 in bulk for reasons that are never actionable: scanners
|
|
||||||
* guessing filenames, and clients running stale HTML that still references the
|
|
||||||
* previous deploy's hashed bundles.
|
|
||||||
*/
|
|
||||||
const ASSET_EXT_RE =
|
|
||||||
/\.(css|js|mjs|map|png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|eot)$/i;
|
|
||||||
|
|
||||||
/** React Router stamps `internal: true` on the errors it generates itself. */
|
|
||||||
function isInternalRouterError(error: unknown): boolean {
|
|
||||||
return (error as { internal?: unknown }).internal === true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True when the request was linked from a page on this same site.
|
|
||||||
*
|
|
||||||
* Compares host rather than origin on purpose. Production terminates TLS
|
|
||||||
* upstream and serves plain HTTP in the container, so `request.url` — which
|
|
||||||
* `@react-router/express` builds from `req.protocol` — says `http` while the
|
|
||||||
* browser sends an `https` referer. Comparing full origins would therefore
|
|
||||||
* never match in production. (`app/routes/leagues/$leagueId.server.ts` works
|
|
||||||
* around the same mismatch for invite URLs.) Protocol tells us nothing about
|
|
||||||
* whether the link was ours; host does.
|
|
||||||
*/
|
|
||||||
function hasSameHostReferer(request: Request): boolean {
|
|
||||||
const referer = request.headers.get("referer");
|
|
||||||
if (!referer) return false;
|
|
||||||
try {
|
|
||||||
return new URL(referer).host === new URL(request.url).host;
|
|
||||||
} catch {
|
|
||||||
// Scanners send garbage in this header; a referer we can't parse isn't ours.
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether `error` should be reported to Sentry.
|
|
||||||
*
|
|
||||||
* Drops the 404s and 405s React Router generated for a request that matched
|
|
||||||
* nothing. Everything else is reported: real exceptions, 5xx, React Router's
|
|
||||||
* other internal statuses, and responses the app threw deliberately
|
|
||||||
* (`internal: false`), so a 403 from an ownership check still shows up.
|
|
||||||
*
|
|
||||||
* The exception is a request carrying a same-host `Referer`: a 404 reached from
|
|
||||||
* one of our own pages is a broken internal link, not a scanner, and stays
|
|
||||||
* visible in Sentry. Asset paths are excluded from that exception — a stale
|
|
||||||
* client requesting last deploy's bundle sends a same-host referer too, and
|
|
||||||
* would otherwise spike Sentry on every release.
|
|
||||||
*/
|
|
||||||
export function shouldReportServerError(
|
|
||||||
error: unknown,
|
|
||||||
request: Request,
|
|
||||||
): boolean {
|
|
||||||
if (!isRouteErrorResponse(error)) return true;
|
|
||||||
if (!isInternalRouterError(error)) return true;
|
|
||||||
if (!UNMATCHED_REQUEST_STATUSES.has(error.status)) return true;
|
|
||||||
|
|
||||||
let pathname: string;
|
|
||||||
try {
|
|
||||||
pathname = new URL(request.url).pathname;
|
|
||||||
} catch {
|
|
||||||
pathname = "";
|
|
||||||
}
|
|
||||||
if (ASSET_EXT_RE.test(pathname)) return false;
|
|
||||||
|
|
||||||
return hasSameHostReferer(request);
|
|
||||||
}
|
|
||||||
|
|
@ -1,162 +0,0 @@
|
||||||
/**
|
|
||||||
* Official 2026 FIFA World Cup knockout bracket structure (48-team format).
|
|
||||||
*
|
|
||||||
* Encodes how the 32 knockout qualifiers are seeded into the Round of 32 from
|
|
||||||
* their group-stage finishing positions, so the simulator can reproduce the
|
|
||||||
* real tournament draw *before* the admin has manually populated the bracket
|
|
||||||
* (i.e. while the group stage is still being played).
|
|
||||||
*
|
|
||||||
* Two pieces:
|
|
||||||
* (a) FIFA_2026_R32_TEMPLATE — the fixed group-position → slot mapping.
|
|
||||||
* (b) assignThirdPlaceSlots() — which group's 3rd-place team fills each of the
|
|
||||||
* eight "best third-place" slots, given which 8 of the 12 groups produced
|
|
||||||
* a qualifying third.
|
|
||||||
*
|
|
||||||
* Bracket-tree note: the official FIFA match numbers (73–88 for the R32) do NOT
|
|
||||||
* pair adjacently into the Round of 16 (e.g. R16 match 89 = winner of FIFA 74 vs
|
|
||||||
* winner of FIFA 77). The codebase advances brackets with the canonical
|
|
||||||
* `nextMatchNumber = ceil(matchNumber / 2)` rule (see `advanceWinnerTemplate` in
|
|
||||||
* app/models/playoff-match.ts). To make that rule reproduce FIFA's real bracket
|
|
||||||
* halves all the way to the final, the official matches are assigned to DB
|
|
||||||
* matchNumbers 1–16 in the deliberate order below — verified through
|
|
||||||
* R16 → QF → SF so both halves and the semifinal pairings match the real draw.
|
|
||||||
*
|
|
||||||
* Source: Wikipedia "2026 FIFA World Cup knockout stage" and
|
|
||||||
* "Template:2026 FIFA World Cup third-place table" (FIFA regulations Annex C).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
ANNEX_C_THIRD_PLACE_ALLOCATION,
|
|
||||||
THIRD_PLACE_WINNER_COLUMNS,
|
|
||||||
} from "./fifa-2026-third-place-allocation";
|
|
||||||
|
|
||||||
export type R32Slot =
|
|
||||||
| { kind: "winner"; group: string }
|
|
||||||
| { kind: "runnerUp"; group: string }
|
|
||||||
/** thirdSlotId is the DB matchNumber this slot belongs to (each is unique). */
|
|
||||||
| { kind: "third"; thirdSlotId: number; eligibleGroups: string[] };
|
|
||||||
|
|
||||||
export interface R32MatchSpec {
|
|
||||||
/** 1–16; drives the codebase's ceil(n/2) bracket tree. */
|
|
||||||
dbMatchNumber: number;
|
|
||||||
/** Official FIFA match number (73–88); for traceability only. */
|
|
||||||
fifaMatchNumber: number;
|
|
||||||
slot1: R32Slot;
|
|
||||||
slot2: R32Slot;
|
|
||||||
}
|
|
||||||
|
|
||||||
const winner = (group: string): R32Slot => ({ kind: "winner", group });
|
|
||||||
const runnerUp = (group: string): R32Slot => ({ kind: "runnerUp", group });
|
|
||||||
const third = (dbMatchNumber: number, eligibleGroups: string[]): R32Slot => ({
|
|
||||||
kind: "third",
|
|
||||||
thirdSlotId: dbMatchNumber,
|
|
||||||
eligibleGroups,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const FIFA_2026_R32_TEMPLATE: R32MatchSpec[] = [
|
|
||||||
{ dbMatchNumber: 1, fifaMatchNumber: 74, slot1: winner("E"), slot2: third(1, ["A", "B", "C", "D", "F"]) },
|
|
||||||
{ dbMatchNumber: 2, fifaMatchNumber: 77, slot1: winner("I"), slot2: third(2, ["C", "D", "F", "G", "H"]) },
|
|
||||||
{ dbMatchNumber: 3, fifaMatchNumber: 73, slot1: runnerUp("A"), slot2: runnerUp("B") },
|
|
||||||
{ dbMatchNumber: 4, fifaMatchNumber: 75, slot1: winner("F"), slot2: runnerUp("C") },
|
|
||||||
{ dbMatchNumber: 5, fifaMatchNumber: 83, slot1: runnerUp("K"), slot2: runnerUp("L") },
|
|
||||||
{ dbMatchNumber: 6, fifaMatchNumber: 84, slot1: winner("H"), slot2: runnerUp("J") },
|
|
||||||
{ dbMatchNumber: 7, fifaMatchNumber: 81, slot1: winner("D"), slot2: third(7, ["B", "E", "F", "I", "J"]) },
|
|
||||||
{ dbMatchNumber: 8, fifaMatchNumber: 82, slot1: winner("G"), slot2: third(8, ["A", "E", "H", "I", "J"]) },
|
|
||||||
{ dbMatchNumber: 9, fifaMatchNumber: 76, slot1: winner("C"), slot2: runnerUp("F") },
|
|
||||||
{ dbMatchNumber: 10, fifaMatchNumber: 78, slot1: runnerUp("E"), slot2: runnerUp("I") },
|
|
||||||
{ dbMatchNumber: 11, fifaMatchNumber: 79, slot1: winner("A"), slot2: third(11, ["C", "E", "F", "H", "I"]) },
|
|
||||||
{ dbMatchNumber: 12, fifaMatchNumber: 80, slot1: winner("L"), slot2: third(12, ["E", "H", "I", "J", "K"]) },
|
|
||||||
{ dbMatchNumber: 13, fifaMatchNumber: 86, slot1: winner("J"), slot2: runnerUp("H") },
|
|
||||||
{ dbMatchNumber: 14, fifaMatchNumber: 88, slot1: runnerUp("D"), slot2: runnerUp("G") },
|
|
||||||
{ dbMatchNumber: 15, fifaMatchNumber: 85, slot1: winner("B"), slot2: third(15, ["E", "F", "G", "I", "J"]) },
|
|
||||||
{ dbMatchNumber: 16, fifaMatchNumber: 87, slot1: winner("K"), slot2: third(16, ["D", "E", "I", "J", "L"]) },
|
|
||||||
];
|
|
||||||
|
|
||||||
/** The eight third-place slots (DB matchNumber → eligible source groups). */
|
|
||||||
export const THIRD_PLACE_SLOTS: Array<{ thirdSlotId: number; eligibleGroups: string[] }> =
|
|
||||||
FIFA_2026_R32_TEMPLATE.flatMap((m) =>
|
|
||||||
[m.slot1, m.slot2].filter((s): s is Extract<R32Slot, { kind: "third" }> => s.kind === "third")
|
|
||||||
).map((s) => ({ thirdSlotId: s.thirdSlotId, eligibleGroups: s.eligibleGroups }));
|
|
||||||
|
|
||||||
/**
|
|
||||||
* For each third-place slot, the group winner it faces — derived from the
|
|
||||||
* template (a third slot always shares its match with a winner slot). This is
|
|
||||||
* how the Annex C columns (keyed by group winner) map onto our DB slots.
|
|
||||||
*/
|
|
||||||
const WINNER_BY_THIRD_SLOT = new Map<number, string>(
|
|
||||||
FIFA_2026_R32_TEMPLATE.flatMap((m) => {
|
|
||||||
const slots = [m.slot1, m.slot2];
|
|
||||||
const thirdSlot = slots.find((s) => s.kind === "third");
|
|
||||||
const winnerSlot = slots.find((s) => s.kind === "winner");
|
|
||||||
return thirdSlot && winnerSlot
|
|
||||||
? [[thirdSlot.thirdSlotId, winnerSlot.group] as [number, string]]
|
|
||||||
: [];
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assign each qualifying third-place group to its third-place slot, using FIFA's
|
|
||||||
* exact published Annex C allocation (495-row lookup). The lookup value lists the
|
|
||||||
* third-place group facing each group winner in the column order
|
|
||||||
* [1A, 1B, 1D, 1E, 1G, 1I, 1K, 1L]; we map each onto the slot that faces that
|
|
||||||
* winner.
|
|
||||||
*
|
|
||||||
* Returns a map of thirdSlotId (DB matchNumber) → group letter. Falls back to a
|
|
||||||
* constrained matching only for non-standard inputs (e.g. fewer than 8 thirds in
|
|
||||||
* degraded data) where no Annex C row exists.
|
|
||||||
*/
|
|
||||||
export function assignThirdPlaceSlots(qualifyingGroups: string[]): Map<number, string> {
|
|
||||||
const groups = qualifyingGroups.map((g) => g.toUpperCase());
|
|
||||||
const key = groups.toSorted().join("");
|
|
||||||
const allocation = ANNEX_C_THIRD_PLACE_ALLOCATION[key];
|
|
||||||
|
|
||||||
const assignment = new Map<number, string>();
|
|
||||||
if (allocation && allocation.length === THIRD_PLACE_WINNER_COLUMNS.length) {
|
|
||||||
THIRD_PLACE_WINNER_COLUMNS.forEach((winnerGroup, i) => {
|
|
||||||
const thirdGroup = allocation[i];
|
|
||||||
const slotId = [...WINNER_BY_THIRD_SLOT].find(([, w]) => w === winnerGroup)?.[0];
|
|
||||||
if (slotId !== undefined) assignment.set(slotId, thirdGroup);
|
|
||||||
});
|
|
||||||
return assignment;
|
|
||||||
}
|
|
||||||
|
|
||||||
return assignThirdPlaceSlotsByMatching(groups);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Constrained-matching fallback used only when no Annex C row applies (non-8
|
|
||||||
* qualifying groups). A perfect, eligibility-respecting matching is found via
|
|
||||||
* backtracking so the simulation still completes deterministically.
|
|
||||||
*/
|
|
||||||
function assignThirdPlaceSlotsByMatching(groups: string[]): Map<number, string> {
|
|
||||||
const slots = THIRD_PLACE_SLOTS.map((s) => ({
|
|
||||||
thirdSlotId: s.thirdSlotId,
|
|
||||||
options: groups.filter((g) => s.eligibleGroups.includes(g)),
|
|
||||||
})).toSorted((a, b) => a.options.length - b.options.length || a.thirdSlotId - b.thirdSlotId);
|
|
||||||
|
|
||||||
const assignment = new Map<number, string>();
|
|
||||||
const used = new Set<string>();
|
|
||||||
|
|
||||||
const backtrack = (i: number): boolean => {
|
|
||||||
if (i === slots.length) return true;
|
|
||||||
for (const group of slots[i].options) {
|
|
||||||
if (used.has(group)) continue;
|
|
||||||
assignment.set(slots[i].thirdSlotId, group);
|
|
||||||
used.add(group);
|
|
||||||
if (backtrack(i + 1)) return true;
|
|
||||||
used.delete(group);
|
|
||||||
assignment.delete(slots[i].thirdSlotId);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (backtrack(0)) return assignment;
|
|
||||||
|
|
||||||
const leftover = groups.filter((g) => !used.has(g));
|
|
||||||
for (const s of THIRD_PLACE_SLOTS) {
|
|
||||||
if (assignment.has(s.thirdSlotId)) continue;
|
|
||||||
const g = leftover.shift();
|
|
||||||
if (g) assignment.set(s.thirdSlotId, g);
|
|
||||||
}
|
|
||||||
return assignment;
|
|
||||||
}
|
|
||||||
|
|
@ -1,506 +0,0 @@
|
||||||
// AUTO-GENERATED from FIFA 2026 World Cup Annex C third-place allocation.
|
|
||||||
// Source: Wikipedia "Template:2026 FIFA World Cup third-place table".
|
|
||||||
// Do not edit by hand — regenerate with scripts/gen-fifa-third-place.mjs.
|
|
||||||
//
|
|
||||||
// Key: the eight qualifying third-place group letters, sorted (e.g. "EFGHIJKL").
|
|
||||||
// Value: the qualifying third-place group facing each group winner, in the
|
|
||||||
// column order [1A, 1B, 1D, 1E, 1G, 1I, 1K, 1L].
|
|
||||||
export const THIRD_PLACE_WINNER_COLUMNS = ["A", "B", "D", "E", "G", "I", "K", "L"] as const;
|
|
||||||
|
|
||||||
export const ANNEX_C_THIRD_PLACE_ALLOCATION: Record<string, string> = {
|
|
||||||
ABCDEFGH: "HGBCAFDE",
|
|
||||||
ABCDEFGI: "CGBDAFEI",
|
|
||||||
ABCDEFGJ: "CGBDAFEJ",
|
|
||||||
ABCDEFGK: "CGBDAFEK",
|
|
||||||
ABCDEFGL: "CGBDAFLE",
|
|
||||||
ABCDEFHI: "HEBCAFDI",
|
|
||||||
ABCDEFHJ: "HJBCAFDE",
|
|
||||||
ABCDEFHK: "HEBCAFDK",
|
|
||||||
ABCDEFHL: "HFBCADLE",
|
|
||||||
ABCDEFIJ: "CJBDAFEI",
|
|
||||||
ABCDEFIK: "CEBDAFIK",
|
|
||||||
ABCDEFIL: "CEBDAFLI",
|
|
||||||
ABCDEFJK: "CJBDAFEK",
|
|
||||||
ABCDEFJL: "CJBDAFLE",
|
|
||||||
ABCDEFKL: "CEBDAFLK",
|
|
||||||
ABCDEGHI: "HGBCADEI",
|
|
||||||
ABCDEGHJ: "HGBCADEJ",
|
|
||||||
ABCDEGHK: "HGBCADEK",
|
|
||||||
ABCDEGHL: "HGBCADLE",
|
|
||||||
ABCDEGIJ: "EGBCADIJ",
|
|
||||||
ABCDEGIK: "EGBCADIK",
|
|
||||||
ABCDEGIL: "EGBCADLI",
|
|
||||||
ABCDEGJK: "EGBCADJK",
|
|
||||||
ABCDEGJL: "EGBCADLJ",
|
|
||||||
ABCDEGKL: "EGBCADLK",
|
|
||||||
ABCDEHIJ: "HJBCADEI",
|
|
||||||
ABCDEHIK: "HEBCADIK",
|
|
||||||
ABCDEHIL: "HEBCADLI",
|
|
||||||
ABCDEHJK: "HJBCADEK",
|
|
||||||
ABCDEHJL: "HJBCADLE",
|
|
||||||
ABCDEHKL: "HEBCADLK",
|
|
||||||
ABCDEIJK: "EJBCADIK",
|
|
||||||
ABCDEIJL: "EJBCADLI",
|
|
||||||
ABCDEIKL: "EIBCADLK",
|
|
||||||
ABCDEJKL: "EJBCADLK",
|
|
||||||
ABCDFGHI: "HGBCAFDI",
|
|
||||||
ABCDFGHJ: "HGBCAFDJ",
|
|
||||||
ABCDFGHK: "HGBCAFDK",
|
|
||||||
ABCDFGHL: "CGBDAFLH",
|
|
||||||
ABCDFGIJ: "CGBDAFIJ",
|
|
||||||
ABCDFGIK: "CGBDAFIK",
|
|
||||||
ABCDFGIL: "CGBDAFLI",
|
|
||||||
ABCDFGJK: "CGBDAFJK",
|
|
||||||
ABCDFGJL: "CGBDAFLJ",
|
|
||||||
ABCDFGKL: "CGBDAFLK",
|
|
||||||
ABCDFHIJ: "HJBCAFDI",
|
|
||||||
ABCDFHIK: "HFBCADIK",
|
|
||||||
ABCDFHIL: "HFBCADLI",
|
|
||||||
ABCDFHJK: "HJBCAFDK",
|
|
||||||
ABCDFHJL: "CJBDAFLH",
|
|
||||||
ABCDFHKL: "HFBCADLK",
|
|
||||||
ABCDFIJK: "CJBDAFIK",
|
|
||||||
ABCDFIJL: "CJBDAFLI",
|
|
||||||
ABCDFIKL: "CIBDAFLK",
|
|
||||||
ABCDFJKL: "CJBDAFLK",
|
|
||||||
ABCDGHIJ: "HGBCADIJ",
|
|
||||||
ABCDGHIK: "HGBCADIK",
|
|
||||||
ABCDGHIL: "HGBCADLI",
|
|
||||||
ABCDGHJK: "HGBCADJK",
|
|
||||||
ABCDGHJL: "HGBCADLJ",
|
|
||||||
ABCDGHKL: "HGBCADLK",
|
|
||||||
ABCDGIJK: "CJBDAGIK",
|
|
||||||
ABCDGIJL: "CJBDAGLI",
|
|
||||||
ABCDGIKL: "IGBCADLK",
|
|
||||||
ABCDGJKL: "CJBDAGLK",
|
|
||||||
ABCDHIJK: "HJBCADIK",
|
|
||||||
ABCDHIJL: "HJBCADLI",
|
|
||||||
ABCDHIKL: "HIBCADLK",
|
|
||||||
ABCDHJKL: "HJBCADLK",
|
|
||||||
ABCDIJKL: "IJBCADLK",
|
|
||||||
ABCEFGHI: "HGBCAFEI",
|
|
||||||
ABCEFGHJ: "HGBCAFEJ",
|
|
||||||
ABCEFGHK: "HGBCAFEK",
|
|
||||||
ABCEFGHL: "HGBCAFLE",
|
|
||||||
ABCEFGIJ: "EGBCAFIJ",
|
|
||||||
ABCEFGIK: "EGBCAFIK",
|
|
||||||
ABCEFGIL: "EGBCAFLI",
|
|
||||||
ABCEFGJK: "EGBCAFJK",
|
|
||||||
ABCEFGJL: "EGBCAFLJ",
|
|
||||||
ABCEFGKL: "EGBCAFLK",
|
|
||||||
ABCEFHIJ: "HJBCAFEI",
|
|
||||||
ABCEFHIK: "HEBCAFIK",
|
|
||||||
ABCEFHIL: "HEBCAFLI",
|
|
||||||
ABCEFHJK: "HJBCAFEK",
|
|
||||||
ABCEFHJL: "HJBCAFLE",
|
|
||||||
ABCEFHKL: "HEBCAFLK",
|
|
||||||
ABCEFIJK: "EJBCAFIK",
|
|
||||||
ABCEFIJL: "EJBCAFLI",
|
|
||||||
ABCEFIKL: "EIBCAFLK",
|
|
||||||
ABCEFJKL: "EJBCAFLK",
|
|
||||||
ABCEGHIJ: "HJBCAGEI",
|
|
||||||
ABCEGHIK: "EGBCAHIK",
|
|
||||||
ABCEGHIL: "EGBCAHLI",
|
|
||||||
ABCEGHJK: "HJBCAGEK",
|
|
||||||
ABCEGHJL: "HJBCAGLE",
|
|
||||||
ABCEGHKL: "EGBCAHLK",
|
|
||||||
ABCEGIJK: "EJBCAGIK",
|
|
||||||
ABCEGIJL: "EJBCAGLI",
|
|
||||||
ABCEGIKL: "EGBAICLK",
|
|
||||||
ABCEGJKL: "EJBCAGLK",
|
|
||||||
ABCEHIJK: "EJBCAHIK",
|
|
||||||
ABCEHIJL: "EJBCAHLI",
|
|
||||||
ABCEHIKL: "EIBCAHLK",
|
|
||||||
ABCEHJKL: "EJBCAHLK",
|
|
||||||
ABCEIJKL: "EJBAICLK",
|
|
||||||
ABCFGHIJ: "HGBCAFIJ",
|
|
||||||
ABCFGHIK: "HGBCAFIK",
|
|
||||||
ABCFGHIL: "HGBCAFLI",
|
|
||||||
ABCFGHJK: "HGBCAFJK",
|
|
||||||
ABCFGHJL: "HGBCAFLJ",
|
|
||||||
ABCFGHKL: "HGBCAFLK",
|
|
||||||
ABCFGIJK: "CJBFAGIK",
|
|
||||||
ABCFGIJL: "CJBFAGLI",
|
|
||||||
ABCFGIKL: "IGBCAFLK",
|
|
||||||
ABCFGJKL: "CJBFAGLK",
|
|
||||||
ABCFHIJK: "HJBCAFIK",
|
|
||||||
ABCFHIJL: "HJBCAFLI",
|
|
||||||
ABCFHIKL: "HIBCAFLK",
|
|
||||||
ABCFHJKL: "HJBCAFLK",
|
|
||||||
ABCFIJKL: "IJBCAFLK",
|
|
||||||
ABCGHIJK: "HJBCAGIK",
|
|
||||||
ABCGHIJL: "HJBCAGLI",
|
|
||||||
ABCGHIKL: "IGBCAHLK",
|
|
||||||
ABCGHJKL: "HJBCAGLK",
|
|
||||||
ABCGIJKL: "IJBCAGLK",
|
|
||||||
ABCHIJKL: "IJBCAHLK",
|
|
||||||
ABDEFGHI: "HGBDAFEI",
|
|
||||||
ABDEFGHJ: "HGBDAFEJ",
|
|
||||||
ABDEFGHK: "HGBDAFEK",
|
|
||||||
ABDEFGHL: "HGBDAFLE",
|
|
||||||
ABDEFGIJ: "EGBDAFIJ",
|
|
||||||
ABDEFGIK: "EGBDAFIK",
|
|
||||||
ABDEFGIL: "EGBDAFLI",
|
|
||||||
ABDEFGJK: "EGBDAFJK",
|
|
||||||
ABDEFGJL: "EGBDAFLJ",
|
|
||||||
ABDEFGKL: "EGBDAFLK",
|
|
||||||
ABDEFHIJ: "HJBDAFEI",
|
|
||||||
ABDEFHIK: "HEBDAFIK",
|
|
||||||
ABDEFHIL: "HEBDAFLI",
|
|
||||||
ABDEFHJK: "HJBDAFEK",
|
|
||||||
ABDEFHJL: "HJBDAFLE",
|
|
||||||
ABDEFHKL: "HEBDAFLK",
|
|
||||||
ABDEFIJK: "EJBDAFIK",
|
|
||||||
ABDEFIJL: "EJBDAFLI",
|
|
||||||
ABDEFIKL: "EIBDAFLK",
|
|
||||||
ABDEFJKL: "EJBDAFLK",
|
|
||||||
ABDEGHIJ: "HJBDAGEI",
|
|
||||||
ABDEGHIK: "EGBDAHIK",
|
|
||||||
ABDEGHIL: "EGBDAHLI",
|
|
||||||
ABDEGHJK: "HJBDAGEK",
|
|
||||||
ABDEGHJL: "HJBDAGLE",
|
|
||||||
ABDEGHKL: "EGBDAHLK",
|
|
||||||
ABDEGIJK: "EJBDAGIK",
|
|
||||||
ABDEGIJL: "EJBDAGLI",
|
|
||||||
ABDEGIKL: "EGBAIDLK",
|
|
||||||
ABDEGJKL: "EJBDAGLK",
|
|
||||||
ABDEHIJK: "EJBDAHIK",
|
|
||||||
ABDEHIJL: "EJBDAHLI",
|
|
||||||
ABDEHIKL: "EIBDAHLK",
|
|
||||||
ABDEHJKL: "EJBDAHLK",
|
|
||||||
ABDEIJKL: "EJBAIDLK",
|
|
||||||
ABDFGHIJ: "HGBDAFIJ",
|
|
||||||
ABDFGHIK: "HGBDAFIK",
|
|
||||||
ABDFGHIL: "HGBDAFLI",
|
|
||||||
ABDFGHJK: "HGBDAFJK",
|
|
||||||
ABDFGHJL: "HGBDAFLJ",
|
|
||||||
ABDFGHKL: "HGBDAFLK",
|
|
||||||
ABDFGIJK: "FJBDAGIK",
|
|
||||||
ABDFGIJL: "FJBDAGLI",
|
|
||||||
ABDFGIKL: "IGBDAFLK",
|
|
||||||
ABDFGJKL: "FJBDAGLK",
|
|
||||||
ABDFHIJK: "HJBDAFIK",
|
|
||||||
ABDFHIJL: "HJBDAFLI",
|
|
||||||
ABDFHIKL: "HIBDAFLK",
|
|
||||||
ABDFHJKL: "HJBDAFLK",
|
|
||||||
ABDFIJKL: "IJBDAFLK",
|
|
||||||
ABDGHIJK: "HJBDAGIK",
|
|
||||||
ABDGHIJL: "HJBDAGLI",
|
|
||||||
ABDGHIKL: "IGBDAHLK",
|
|
||||||
ABDGHJKL: "HJBDAGLK",
|
|
||||||
ABDGIJKL: "IJBDAGLK",
|
|
||||||
ABDHIJKL: "IJBDAHLK",
|
|
||||||
ABEFGHIJ: "HJBFAGEI",
|
|
||||||
ABEFGHIK: "EGBFAHIK",
|
|
||||||
ABEFGHIL: "EGBFAHLI",
|
|
||||||
ABEFGHJK: "HJBFAGEK",
|
|
||||||
ABEFGHJL: "HJBFAGLE",
|
|
||||||
ABEFGHKL: "EGBFAHLK",
|
|
||||||
ABEFGIJK: "EJBFAGIK",
|
|
||||||
ABEFGIJL: "EJBFAGLI",
|
|
||||||
ABEFGIKL: "EGBAIFLK",
|
|
||||||
ABEFGJKL: "EJBFAGLK",
|
|
||||||
ABEFHIJK: "EJBFAHIK",
|
|
||||||
ABEFHIJL: "EJBFAHLI",
|
|
||||||
ABEFHIKL: "EIBFAHLK",
|
|
||||||
ABEFHJKL: "EJBFAHLK",
|
|
||||||
ABEFIJKL: "EJBAIFLK",
|
|
||||||
ABEGHIJK: "EJBAHGIK",
|
|
||||||
ABEGHIJL: "EJBAHGLI",
|
|
||||||
ABEGHIKL: "EGBAIHLK",
|
|
||||||
ABEGHJKL: "EJBAHGLK",
|
|
||||||
ABEGIJKL: "EJBAIGLK",
|
|
||||||
ABEHIJKL: "EJBAIHLK",
|
|
||||||
ABFGHIJK: "HJBFAGIK",
|
|
||||||
ABFGHIJL: "HJBFAGLI",
|
|
||||||
ABFGHIKL: "HGBAIFLK",
|
|
||||||
ABFGHJKL: "HJBFAGLK",
|
|
||||||
ABFGIJKL: "IJBFAGLK",
|
|
||||||
ABFHIJKL: "HJBAIFLK",
|
|
||||||
ABGHIJKL: "HJBAIGLK",
|
|
||||||
ACDEFGHI: "HGECAFDI",
|
|
||||||
ACDEFGHJ: "HGJCAFDE",
|
|
||||||
ACDEFGHK: "HGECAFDK",
|
|
||||||
ACDEFGHL: "HGFCADLE",
|
|
||||||
ACDEFGIJ: "CGJDAFEI",
|
|
||||||
ACDEFGIK: "CGEDAFIK",
|
|
||||||
ACDEFGIL: "CGEDAFLI",
|
|
||||||
ACDEFGJK: "CGJDAFEK",
|
|
||||||
ACDEFGJL: "CGJDAFLE",
|
|
||||||
ACDEFGKL: "CGEDAFLK",
|
|
||||||
ACDEFHIJ: "HJECAFDI",
|
|
||||||
ACDEFHIK: "HEFCADIK",
|
|
||||||
ACDEFHIL: "HEFCADLI",
|
|
||||||
ACDEFHJK: "HJECAFDK",
|
|
||||||
ACDEFHJL: "HJFCADLE",
|
|
||||||
ACDEFHKL: "HEFCADLK",
|
|
||||||
ACDEFIJK: "CJEDAFIK",
|
|
||||||
ACDEFIJL: "CJEDAFLI",
|
|
||||||
ACDEFIKL: "CEIDAFLK",
|
|
||||||
ACDEFJKL: "CJEDAFLK",
|
|
||||||
ACDEGHIJ: "HGJCADEI",
|
|
||||||
ACDEGHIK: "HGECADIK",
|
|
||||||
ACDEGHIL: "HGECADLI",
|
|
||||||
ACDEGHJK: "HGJCADEK",
|
|
||||||
ACDEGHJL: "HGJCADLE",
|
|
||||||
ACDEGHKL: "HGECADLK",
|
|
||||||
ACDEGIJK: "EGJCADIK",
|
|
||||||
ACDEGIJL: "EGJCADLI",
|
|
||||||
ACDEGIKL: "EGICADLK",
|
|
||||||
ACDEGJKL: "EGJCADLK",
|
|
||||||
ACDEHIJK: "HJECADIK",
|
|
||||||
ACDEHIJL: "HJECADLI",
|
|
||||||
ACDEHIKL: "HEICADLK",
|
|
||||||
ACDEHJKL: "HJECADLK",
|
|
||||||
ACDEIJKL: "EJICADLK",
|
|
||||||
ACDFGHIJ: "HGJCAFDI",
|
|
||||||
ACDFGHIK: "HGFCADIK",
|
|
||||||
ACDFGHIL: "HGFCADLI",
|
|
||||||
ACDFGHJK: "HGJCAFDK",
|
|
||||||
ACDFGHJL: "CGJDAFLH",
|
|
||||||
ACDFGHKL: "HGFCADLK",
|
|
||||||
ACDFGIJK: "CGJDAFIK",
|
|
||||||
ACDFGIJL: "CGJDAFLI",
|
|
||||||
ACDFGIKL: "CGIDAFLK",
|
|
||||||
ACDFGJKL: "CGJDAFLK",
|
|
||||||
ACDFHIJK: "HJFCADIK",
|
|
||||||
ACDFHIJL: "HJFCADLI",
|
|
||||||
ACDFHIKL: "HFICADLK",
|
|
||||||
ACDFHJKL: "HJFCADLK",
|
|
||||||
ACDFIJKL: "CJIDAFLK",
|
|
||||||
ACDGHIJK: "HGJCADIK",
|
|
||||||
ACDGHIJL: "HGJCADLI",
|
|
||||||
ACDGHIKL: "HGICADLK",
|
|
||||||
ACDGHJKL: "HGJCADLK",
|
|
||||||
ACDGIJKL: "IGJCADLK",
|
|
||||||
ACDHIJKL: "HJICADLK",
|
|
||||||
ACEFGHIJ: "HGJCAFEI",
|
|
||||||
ACEFGHIK: "HGECAFIK",
|
|
||||||
ACEFGHIL: "HGECAFLI",
|
|
||||||
ACEFGHJK: "HGJCAFEK",
|
|
||||||
ACEFGHJL: "HGJCAFLE",
|
|
||||||
ACEFGHKL: "HGECAFLK",
|
|
||||||
ACEFGIJK: "EGJCAFIK",
|
|
||||||
ACEFGIJL: "EGJCAFLI",
|
|
||||||
ACEFGIKL: "EGICAFLK",
|
|
||||||
ACEFGJKL: "EGJCAFLK",
|
|
||||||
ACEFHIJK: "HJECAFIK",
|
|
||||||
ACEFHIJL: "HJECAFLI",
|
|
||||||
ACEFHIKL: "HEICAFLK",
|
|
||||||
ACEFHJKL: "HJECAFLK",
|
|
||||||
ACEFIJKL: "EJICAFLK",
|
|
||||||
ACEGHIJK: "EGJCAHIK",
|
|
||||||
ACEGHIJL: "EGJCAHLI",
|
|
||||||
ACEGHIKL: "EGICAHLK",
|
|
||||||
ACEGHJKL: "EGJCAHLK",
|
|
||||||
ACEGIJKL: "EJICAGLK",
|
|
||||||
ACEHIJKL: "EJICAHLK",
|
|
||||||
ACFGHIJK: "HGJCAFIK",
|
|
||||||
ACFGHIJL: "HGJCAFLI",
|
|
||||||
ACFGHIKL: "HGICAFLK",
|
|
||||||
ACFGHJKL: "HGJCAFLK",
|
|
||||||
ACFGIJKL: "IGJCAFLK",
|
|
||||||
ACFHIJKL: "HJICAFLK",
|
|
||||||
ACGHIJKL: "HJICAGLK",
|
|
||||||
ADEFGHIJ: "HGJDAFEI",
|
|
||||||
ADEFGHIK: "HGEDAFIK",
|
|
||||||
ADEFGHIL: "HGEDAFLI",
|
|
||||||
ADEFGHJK: "HGJDAFEK",
|
|
||||||
ADEFGHJL: "HGJDAFLE",
|
|
||||||
ADEFGHKL: "HGEDAFLK",
|
|
||||||
ADEFGIJK: "EGJDAFIK",
|
|
||||||
ADEFGIJL: "EGJDAFLI",
|
|
||||||
ADEFGIKL: "EGIDAFLK",
|
|
||||||
ADEFGJKL: "EGJDAFLK",
|
|
||||||
ADEFHIJK: "HJEDAFIK",
|
|
||||||
ADEFHIJL: "HJEDAFLI",
|
|
||||||
ADEFHIKL: "HEIDAFLK",
|
|
||||||
ADEFHJKL: "HJEDAFLK",
|
|
||||||
ADEFIJKL: "EJIDAFLK",
|
|
||||||
ADEGHIJK: "EGJDAHIK",
|
|
||||||
ADEGHIJL: "EGJDAHLI",
|
|
||||||
ADEGHIKL: "EGIDAHLK",
|
|
||||||
ADEGHJKL: "EGJDAHLK",
|
|
||||||
ADEGIJKL: "EJIDAGLK",
|
|
||||||
ADEHIJKL: "EJIDAHLK",
|
|
||||||
ADFGHIJK: "HGJDAFIK",
|
|
||||||
ADFGHIJL: "HGJDAFLI",
|
|
||||||
ADFGHIKL: "HGIDAFLK",
|
|
||||||
ADFGHJKL: "HGJDAFLK",
|
|
||||||
ADFGIJKL: "IGJDAFLK",
|
|
||||||
ADFHIJKL: "HJIDAFLK",
|
|
||||||
ADGHIJKL: "HJIDAGLK",
|
|
||||||
AEFGHIJK: "EGJFAHIK",
|
|
||||||
AEFGHIJL: "EGJFAHLI",
|
|
||||||
AEFGHIKL: "EGIFAHLK",
|
|
||||||
AEFGHJKL: "EGJFAHLK",
|
|
||||||
AEFGIJKL: "EJIFAGLK",
|
|
||||||
AEFHIJKL: "EJIFAHLK",
|
|
||||||
AEGHIJKL: "EJIAHGLK",
|
|
||||||
AFGHIJKL: "HJIFAGLK",
|
|
||||||
BCDEFGHI: "CGBDHFEI",
|
|
||||||
BCDEFGHJ: "HGBCJFDE",
|
|
||||||
BCDEFGHK: "CGBDHFEK",
|
|
||||||
BCDEFGHL: "CGBDHFLE",
|
|
||||||
BCDEFGIJ: "CGBDJFEI",
|
|
||||||
BCDEFGIK: "CGBDEFIK",
|
|
||||||
BCDEFGIL: "CGBDEFLI",
|
|
||||||
BCDEFGJK: "CGBDJFEK",
|
|
||||||
BCDEFGJL: "CGBDJFLE",
|
|
||||||
BCDEFGKL: "CGBDEFLK",
|
|
||||||
BCDEFHIJ: "CJBDHFEI",
|
|
||||||
BCDEFHIK: "CEBDHFIK",
|
|
||||||
BCDEFHIL: "CEBDHFLI",
|
|
||||||
BCDEFHJK: "CJBDHFEK",
|
|
||||||
BCDEFHJL: "CJBDHFLE",
|
|
||||||
BCDEFHKL: "CEBDHFLK",
|
|
||||||
BCDEFIJK: "CJBDEFIK",
|
|
||||||
BCDEFIJL: "CJBDEFLI",
|
|
||||||
BCDEFIKL: "CEBDIFLK",
|
|
||||||
BCDEFJKL: "CJBDEFLK",
|
|
||||||
BCDEGHIJ: "HGBCJDEI",
|
|
||||||
BCDEGHIK: "EGBCHDIK",
|
|
||||||
BCDEGHIL: "EGBCHDLI",
|
|
||||||
BCDEGHJK: "HGBCJDEK",
|
|
||||||
BCDEGHJL: "HGBCJDLE",
|
|
||||||
BCDEGHKL: "EGBCHDLK",
|
|
||||||
BCDEGIJK: "EGBCJDIK",
|
|
||||||
BCDEGIJL: "EGBCJDLI",
|
|
||||||
BCDEGIKL: "EGBCIDLK",
|
|
||||||
BCDEGJKL: "EGBCJDLK",
|
|
||||||
BCDEHIJK: "EJBCHDIK",
|
|
||||||
BCDEHIJL: "EJBCHDLI",
|
|
||||||
BCDEHIKL: "EIBCHDLK",
|
|
||||||
BCDEHJKL: "EJBCHDLK",
|
|
||||||
BCDEIJKL: "EJBCIDLK",
|
|
||||||
BCDFGHIJ: "HGBCJFDI",
|
|
||||||
BCDFGHIK: "CGBDHFIK",
|
|
||||||
BCDFGHIL: "CGBDHFLI",
|
|
||||||
BCDFGHJK: "HGBCJFDK",
|
|
||||||
BCDFGHJL: "CGBDHFLJ",
|
|
||||||
BCDFGHKL: "CGBDHFLK",
|
|
||||||
BCDFGIJK: "CGBDJFIK",
|
|
||||||
BCDFGIJL: "CGBDJFLI",
|
|
||||||
BCDFGIKL: "CGBDIFLK",
|
|
||||||
BCDFGJKL: "CGBDJFLK",
|
|
||||||
BCDFHIJK: "CJBDHFIK",
|
|
||||||
BCDFHIJL: "CJBDHFLI",
|
|
||||||
BCDFHIKL: "CIBDHFLK",
|
|
||||||
BCDFHJKL: "CJBDHFLK",
|
|
||||||
BCDFIJKL: "CJBDIFLK",
|
|
||||||
BCDGHIJK: "HGBCJDIK",
|
|
||||||
BCDGHIJL: "HGBCJDLI",
|
|
||||||
BCDGHIKL: "HGBCIDLK",
|
|
||||||
BCDGHJKL: "HGBCJDLK",
|
|
||||||
BCDGIJKL: "IGBCJDLK",
|
|
||||||
BCDHIJKL: "HJBCIDLK",
|
|
||||||
BCEFGHIJ: "HGBCJFEI",
|
|
||||||
BCEFGHIK: "EGBCHFIK",
|
|
||||||
BCEFGHIL: "EGBCHFLI",
|
|
||||||
BCEFGHJK: "HGBCJFEK",
|
|
||||||
BCEFGHJL: "HGBCJFLE",
|
|
||||||
BCEFGHKL: "EGBCHFLK",
|
|
||||||
BCEFGIJK: "EGBCJFIK",
|
|
||||||
BCEFGIJL: "EGBCJFLI",
|
|
||||||
BCEFGIKL: "EGBCIFLK",
|
|
||||||
BCEFGJKL: "EGBCJFLK",
|
|
||||||
BCEFHIJK: "EJBCHFIK",
|
|
||||||
BCEFHIJL: "EJBCHFLI",
|
|
||||||
BCEFHIKL: "EIBCHFLK",
|
|
||||||
BCEFHJKL: "EJBCHFLK",
|
|
||||||
BCEFIJKL: "EJBCIFLK",
|
|
||||||
BCEGHIJK: "EJBCHGIK",
|
|
||||||
BCEGHIJL: "EJBCHGLI",
|
|
||||||
BCEGHIKL: "EGBCIHLK",
|
|
||||||
BCEGHJKL: "EJBCHGLK",
|
|
||||||
BCEGIJKL: "EJBCIGLK",
|
|
||||||
BCEHIJKL: "EJBCIHLK",
|
|
||||||
BCFGHIJK: "HGBCJFIK",
|
|
||||||
BCFGHIJL: "HGBCJFLI",
|
|
||||||
BCFGHIKL: "HGBCIFLK",
|
|
||||||
BCFGHJKL: "HGBCJFLK",
|
|
||||||
BCFGIJKL: "IGBCJFLK",
|
|
||||||
BCFHIJKL: "HJBCIFLK",
|
|
||||||
BCGHIJKL: "HJBCIGLK",
|
|
||||||
BDEFGHIJ: "HGBDJFEI",
|
|
||||||
BDEFGHIK: "EGBDHFIK",
|
|
||||||
BDEFGHIL: "EGBDHFLI",
|
|
||||||
BDEFGHJK: "HGBDJFEK",
|
|
||||||
BDEFGHJL: "HGBDJFLE",
|
|
||||||
BDEFGHKL: "EGBDHFLK",
|
|
||||||
BDEFGIJK: "EGBDJFIK",
|
|
||||||
BDEFGIJL: "EGBDJFLI",
|
|
||||||
BDEFGIKL: "EGBDIFLK",
|
|
||||||
BDEFGJKL: "EGBDJFLK",
|
|
||||||
BDEFHIJK: "EJBDHFIK",
|
|
||||||
BDEFHIJL: "EJBDHFLI",
|
|
||||||
BDEFHIKL: "EIBDHFLK",
|
|
||||||
BDEFHJKL: "EJBDHFLK",
|
|
||||||
BDEFIJKL: "EJBDIFLK",
|
|
||||||
BDEGHIJK: "EJBDHGIK",
|
|
||||||
BDEGHIJL: "EJBDHGLI",
|
|
||||||
BDEGHIKL: "EGBDIHLK",
|
|
||||||
BDEGHJKL: "EJBDHGLK",
|
|
||||||
BDEGIJKL: "EJBDIGLK",
|
|
||||||
BDEHIJKL: "EJBDIHLK",
|
|
||||||
BDFGHIJK: "HGBDJFIK",
|
|
||||||
BDFGHIJL: "HGBDJFLI",
|
|
||||||
BDFGHIKL: "HGBDIFLK",
|
|
||||||
BDFGHJKL: "HGBDJFLK",
|
|
||||||
BDFGIJKL: "IGBDJFLK",
|
|
||||||
BDFHIJKL: "HJBDIFLK",
|
|
||||||
BDGHIJKL: "HJBDIGLK",
|
|
||||||
BEFGHIJK: "EJBFHGIK",
|
|
||||||
BEFGHIJL: "EJBFHGLI",
|
|
||||||
BEFGHIKL: "EGBFIHLK",
|
|
||||||
BEFGHJKL: "EJBFHGLK",
|
|
||||||
BEFGIJKL: "EJBFIGLK",
|
|
||||||
BEFHIJKL: "EJBFIHLK",
|
|
||||||
BEGHIJKL: "EJIBHGLK",
|
|
||||||
BFGHIJKL: "HJBFIGLK",
|
|
||||||
CDEFGHIJ: "CGJDHFEI",
|
|
||||||
CDEFGHIK: "CGEDHFIK",
|
|
||||||
CDEFGHIL: "CGEDHFLI",
|
|
||||||
CDEFGHJK: "CGJDHFEK",
|
|
||||||
CDEFGHJL: "CGJDHFLE",
|
|
||||||
CDEFGHKL: "CGEDHFLK",
|
|
||||||
CDEFGIJK: "CGEDJFIK",
|
|
||||||
CDEFGIJL: "CGEDJFLI",
|
|
||||||
CDEFGIKL: "CGEDIFLK",
|
|
||||||
CDEFGJKL: "CGEDJFLK",
|
|
||||||
CDEFHIJK: "CJEDHFIK",
|
|
||||||
CDEFHIJL: "CJEDHFLI",
|
|
||||||
CDEFHIKL: "CEIDHFLK",
|
|
||||||
CDEFHJKL: "CJEDHFLK",
|
|
||||||
CDEFIJKL: "CJEDIFLK",
|
|
||||||
CDEGHIJK: "EGJCHDIK",
|
|
||||||
CDEGHIJL: "EGJCHDLI",
|
|
||||||
CDEGHIKL: "EGICHDLK",
|
|
||||||
CDEGHJKL: "EGJCHDLK",
|
|
||||||
CDEGIJKL: "EGICJDLK",
|
|
||||||
CDEHIJKL: "EJICHDLK",
|
|
||||||
CDFGHIJK: "CGJDHFIK",
|
|
||||||
CDFGHIJL: "CGJDHFLI",
|
|
||||||
CDFGHIKL: "CGIDHFLK",
|
|
||||||
CDFGHJKL: "CGJDHFLK",
|
|
||||||
CDFGIJKL: "CGIDJFLK",
|
|
||||||
CDFHIJKL: "CJIDHFLK",
|
|
||||||
CDGHIJKL: "HGICJDLK",
|
|
||||||
CEFGHIJK: "EGJCHFIK",
|
|
||||||
CEFGHIJL: "EGJCHFLI",
|
|
||||||
CEFGHIKL: "EGICHFLK",
|
|
||||||
CEFGHJKL: "EGJCHFLK",
|
|
||||||
CEFGIJKL: "EGICJFLK",
|
|
||||||
CEFHIJKL: "EJICHFLK",
|
|
||||||
CEGHIJKL: "EJICHGLK",
|
|
||||||
CFGHIJKL: "HGICJFLK",
|
|
||||||
DEFGHIJK: "EGJDHFIK",
|
|
||||||
DEFGHIJL: "EGJDHFLI",
|
|
||||||
DEFGHIKL: "EGIDHFLK",
|
|
||||||
DEFGHJKL: "EGJDHFLK",
|
|
||||||
DEFGIJKL: "EGIDJFLK",
|
|
||||||
DEFHIJKL: "EJIDHFLK",
|
|
||||||
DEGHIJKL: "EJIDHGLK",
|
|
||||||
DFGHIJKL: "HGIDJFLK",
|
|
||||||
EFGHIJKL: "EJIFHGLK",
|
|
||||||
};
|
|
||||||
|
|
@ -1,194 +0,0 @@
|
||||||
/**
|
|
||||||
* LLWS 20-team double-elimination routing — the pure half of the bracket.
|
|
||||||
*
|
|
||||||
* Lives in lib/ rather than models/ because the renderer needs it: models/playoff-match
|
|
||||||
* pulls in the database context and drizzle, which must not reach the browser bundle.
|
|
||||||
* models/playoff-match re-exports everything here, so server-side callers are unchanged.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { llwsMatchNumber, llwsSideAndLocal } from "~/lib/bracket-templates";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where one participant goes after an LLWS match: a round, a side-local match number,
|
|
||||||
* and which slot to fill. `null` means eliminated (or, for winners, no further game).
|
|
||||||
*/
|
|
||||||
interface LLWSDestination {
|
|
||||||
round: string;
|
|
||||||
localMatch: number;
|
|
||||||
slot: "participant1Id" | "participant2Id";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* LLWS advancement map, in SIDE-LOCAL match numbers.
|
|
||||||
*
|
|
||||||
* Keyed by round, then by the local match number of the completed game. Each entry
|
|
||||||
* says where the winner goes and where the loser goes (null = eliminated).
|
|
||||||
*
|
|
||||||
* Verified game-by-game against the official 2026 LLBWS bracket. Note the deliberate
|
|
||||||
* cross-overs — the elimination bracket does NOT feed straight across:
|
|
||||||
* Elim R1: L(Opening m2) v L(Opening m3) and L(Opening m1) v L(Opening m4)
|
|
||||||
* Elim R3: L(Semi m1) v W(Elim R2 m2) and L(Semi m2) v W(Elim R2 m1)
|
|
||||||
* Elim R4: W(Elim R3 m1) v W(Elim R3 m2)
|
|
||||||
*
|
|
||||||
* A loss in the winners bracket routes into the elimination bracket rather than
|
|
||||||
* eliminating the team; a loss in the elimination bracket is final.
|
|
||||||
*/
|
|
||||||
const LLWS_ADVANCEMENT: Record<
|
|
||||||
string,
|
|
||||||
Record<number, { winner: LLWSDestination | null; loser: LLWSDestination | null }>
|
|
||||||
> = {
|
|
||||||
"Opening Round": {
|
|
||||||
1: {
|
|
||||||
winner: { round: "Winners Round 2", localMatch: 1, slot: "participant2Id" },
|
|
||||||
loser: { round: "Elimination Round 1", localMatch: 2, slot: "participant1Id" },
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
winner: { round: "Winners Round 2", localMatch: 2, slot: "participant2Id" },
|
|
||||||
loser: { round: "Elimination Round 1", localMatch: 1, slot: "participant1Id" },
|
|
||||||
},
|
|
||||||
3: {
|
|
||||||
winner: { round: "Winners Semifinals", localMatch: 1, slot: "participant1Id" },
|
|
||||||
loser: { round: "Elimination Round 1", localMatch: 1, slot: "participant2Id" },
|
|
||||||
},
|
|
||||||
4: {
|
|
||||||
winner: { round: "Winners Semifinals", localMatch: 2, slot: "participant2Id" },
|
|
||||||
loser: { round: "Elimination Round 1", localMatch: 2, slot: "participant2Id" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Winners Round 2": {
|
|
||||||
1: {
|
|
||||||
winner: { round: "Winners Semifinals", localMatch: 1, slot: "participant2Id" },
|
|
||||||
loser: { round: "Elimination Round 2", localMatch: 1, slot: "participant1Id" },
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
winner: { round: "Winners Semifinals", localMatch: 2, slot: "participant1Id" },
|
|
||||||
loser: { round: "Elimination Round 2", localMatch: 2, slot: "participant1Id" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Winners Semifinals": {
|
|
||||||
1: {
|
|
||||||
winner: { round: "Winners Final", localMatch: 1, slot: "participant1Id" },
|
|
||||||
loser: { round: "Elimination Round 3", localMatch: 1, slot: "participant1Id" },
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
winner: { round: "Winners Final", localMatch: 1, slot: "participant2Id" },
|
|
||||||
loser: { round: "Elimination Round 3", localMatch: 2, slot: "participant1Id" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Winners Final": {
|
|
||||||
1: {
|
|
||||||
winner: { round: "Bracket Championship", localMatch: 1, slot: "participant1Id" },
|
|
||||||
// A winners-bracket final loss is not an elimination — it drops to the
|
|
||||||
// Elimination Final for a second chance at the side championship.
|
|
||||||
loser: { round: "Elimination Final", localMatch: 1, slot: "participant1Id" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Elimination Round 1": {
|
|
||||||
1: {
|
|
||||||
winner: { round: "Elimination Round 2", localMatch: 1, slot: "participant2Id" },
|
|
||||||
loser: null,
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
winner: { round: "Elimination Round 2", localMatch: 2, slot: "participant2Id" },
|
|
||||||
loser: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Elimination Round 2": {
|
|
||||||
// Cross-over: R2 m1's winner meets the OTHER semifinal loser.
|
|
||||||
1: {
|
|
||||||
winner: { round: "Elimination Round 3", localMatch: 2, slot: "participant2Id" },
|
|
||||||
loser: null,
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
winner: { round: "Elimination Round 3", localMatch: 1, slot: "participant2Id" },
|
|
||||||
loser: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Elimination Round 3": {
|
|
||||||
// The later game (m2) is printed on top: G32 = W28 v W26, G31 = W27 v W25.
|
|
||||||
1: {
|
|
||||||
winner: { round: "Elimination Round 4", localMatch: 1, slot: "participant2Id" },
|
|
||||||
loser: null,
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
winner: { round: "Elimination Round 4", localMatch: 1, slot: "participant1Id" },
|
|
||||||
loser: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Elimination Round 4": {
|
|
||||||
1: {
|
|
||||||
winner: { round: "Elimination Final", localMatch: 1, slot: "participant2Id" },
|
|
||||||
loser: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Elimination Final": {
|
|
||||||
1: {
|
|
||||||
winner: { round: "Bracket Championship", localMatch: 1, slot: "participant2Id" },
|
|
||||||
loser: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Rounds whose losers drop into the elimination bracket instead of going out. */
|
|
||||||
export const LLWS_LOSER_ADVANCES_ROUNDS = new Set([
|
|
||||||
"Opening Round",
|
|
||||||
"Winners Round 2",
|
|
||||||
"Winners Semifinals",
|
|
||||||
]);
|
|
||||||
|
|
||||||
/** A resolved LLWS destination, in global (not side-local) match numbers. */
|
|
||||||
export interface LLWSResolvedDestination {
|
|
||||||
round: string;
|
|
||||||
matchNumber: number;
|
|
||||||
slot: "participant1Id" | "participant2Id";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve where the winner and loser of a completed LLWS match go, in global match
|
|
||||||
* numbers. `null` means that participant has no further game (eliminated, or the
|
|
||||||
* tournament is over for them).
|
|
||||||
*
|
|
||||||
* Pure — no DB access — so the whole 38-game routing can be verified against the
|
|
||||||
* official bracket in tests. advanceLLWSWinner is a thin writer on top of this.
|
|
||||||
*/
|
|
||||||
export function resolveLLWSAdvancement(
|
|
||||||
round: string,
|
|
||||||
matchNumber: number
|
|
||||||
): { winner: LLWSResolvedDestination | null; loser: LLWSResolvedDestination | null } {
|
|
||||||
// Terminal rounds — nobody advances.
|
|
||||||
if (round === "Consolation Third Place" || round === "World Championship") {
|
|
||||||
return { winner: null, loser: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bracket Championship is the crossover: the winner goes to the World Championship
|
|
||||||
// and the loser to the Consolation game. The side fixes the slot in both (U.S. takes
|
|
||||||
// participant1, International participant2), so the two sides can't collide.
|
|
||||||
if (round === "Bracket Championship") {
|
|
||||||
const { side } = llwsSideAndLocal("Bracket Championship", matchNumber);
|
|
||||||
const slot: "participant1Id" | "participant2Id" =
|
|
||||||
side === 0 ? "participant1Id" : "participant2Id";
|
|
||||||
return {
|
|
||||||
winner: { round: "World Championship", matchNumber: 1, slot },
|
|
||||||
loser: { round: "Consolation Third Place", matchNumber: 1, slot },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const roundMap = LLWS_ADVANCEMENT[round];
|
|
||||||
if (!roundMap) {
|
|
||||||
throw new Error(`Round '${round}' is not part of the LLWS bracket`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { side, localMatch } = llwsSideAndLocal(round, matchNumber);
|
|
||||||
const routes = roundMap[localMatch];
|
|
||||||
if (!routes) {
|
|
||||||
throw new Error(`No LLWS advancement defined for ${round} match ${matchNumber}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Winner and loser stay on their own side, so the same side offset applies to both.
|
|
||||||
const toGlobal = (d: LLWSDestination | null): LLWSResolvedDestination | null =>
|
|
||||||
d === null
|
|
||||||
? null
|
|
||||||
: { round: d.round, matchNumber: llwsMatchNumber(d.round, side, d.localMatch), slot: d.slot };
|
|
||||||
|
|
||||||
return { winner: toGlobal(routes.winner), loser: toGlobal(routes.loser) };
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +1,9 @@
|
||||||
/**
|
/**
|
||||||
* Normalize a team/participant name for fuzzy matching across data sources.
|
* Normalize a team name for fuzzy matching across data sources.
|
||||||
* Folds accents (so "Stéfanos Tsitsipás" matches a plain "Stefanos Tsitsipas"),
|
* Lowercases, trims, and collapses whitespace.
|
||||||
* lowercases, trims, and collapses whitespace.
|
|
||||||
*/
|
*/
|
||||||
export function normalizeTeamName(name: string): string {
|
export function normalizeTeamName(name: string): string {
|
||||||
return name
|
return name.toLowerCase().trim().replace(/\s+/g, " ");
|
||||||
.normalize("NFKD")
|
|
||||||
.replace(/[̀-ͯ]/g, "") // strip combining diacritical marks
|
|
||||||
.toLowerCase()
|
|
||||||
.trim()
|
|
||||||
.replace(/\s+/g, " ");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,7 @@
|
||||||
|
|
||||||
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, calculateBracketPoints, type ScoringRules } from "../scoring-rules";
|
import { calculateFantasyPoints, calculateAveragedPoints, type ScoringRules } from "../scoring-rules";
|
||||||
import { getBracketEntryFloor } from "../scoring-calculator";
|
|
||||||
|
|
||||||
const DEFAULT_SCORING: ScoringRules = {
|
const DEFAULT_SCORING: ScoringRules = {
|
||||||
pointsFor1st: 100,
|
pointsFor1st: 100,
|
||||||
|
|
@ -207,69 +206,3 @@ describe("AFL Finals System - Phase 3.3", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("AFL guaranteed floors from seeding (afl_10)", () => {
|
|
||||||
const byName = (name: string) => AFL_10.rounds.find((r) => r.name === name);
|
|
||||||
|
|
||||||
describe("getBracketEntryFloor — banked the moment the bracket is set", () => {
|
|
||||||
it("gives seeds 1-4 the 5th-6th tier: the double chance is locked in at seeding", () => {
|
|
||||||
// Worst case for a top-4 seed is lose the Qualifying Final, then lose the
|
|
||||||
// Semi-Final — which is the 5th-6th tier. They can never finish below it.
|
|
||||||
expect(getBracketEntryFloor("Qualifying Finals", "afl_10")).toBe(5);
|
|
||||||
expect(calculateBracketPoints(5, DEFAULT_SCORING, "afl_10")).toBe(25);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("gives seeds 5-6 the 7th-8th tier: they are seeded straight into a scoring round", () => {
|
|
||||||
expect(getBracketEntryFloor("Elimination Finals", "afl_10")).toBe(7);
|
|
||||||
expect(calculateBracketPoints(7, DEFAULT_SCORING, "afl_10")).toBe(15);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("gives seeds 7-10 nothing: a Wildcard loss is worth 0", () => {
|
|
||||||
expect(getBracketEntryFloor("Wildcard Round", "afl_10")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when the template is unknown or missing", () => {
|
|
||||||
expect(getBracketEntryFloor("Qualifying Finals", null)).toBeNull();
|
|
||||||
expect(getBracketEntryFloor("Qualifying Finals", "not_a_template")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not hand out floors for TBD rounds nobody is seeded into yet", () => {
|
|
||||||
// These rounds do carry a loser tier, but every slot is empty at generation,
|
|
||||||
// so applyBracketEntryFloors has no participant to write against.
|
|
||||||
expect(byName("Semi-Finals")?.entryFloor).toBeUndefined();
|
|
||||||
expect(byName("Preliminary Finals")?.entryFloor).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("nonScoringWinnerFloor — the generic top-8 default is wrong for both AFL non-scoring rounds", () => {
|
|
||||||
it("Qualifying Finals winners bank 3, not 5 — the bye means a Prelim loss is 3rd-4th", () => {
|
|
||||||
expect(byName("Qualifying Finals")?.nonScoringWinnerFloor).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Wildcard winners bank 7, not 5 — winning only buys an Elimination Final", () => {
|
|
||||||
expect(byName("Wildcard Round")?.nonScoringWinnerFloor).toBe(7);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("floors only ever improve along every AFL path", () => {
|
|
||||||
const pts = (position: number) => calculateBracketPoints(position, DEFAULT_SCORING, "afl_10");
|
|
||||||
|
|
||||||
it("top-4 seed: entry 5 → QF win 3 → PF win 2 → GF win 1", () => {
|
|
||||||
expect(pts(5)).toBeLessThan(pts(3));
|
|
||||||
expect(pts(3)).toBeLessThan(pts(2));
|
|
||||||
expect(pts(2)).toBeLessThan(pts(1));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("top-4 seed losing the QF holds the entry floor, then finalizes at 5th-6th", () => {
|
|
||||||
// QF losers advance to the Semi-Final, so nothing is written at the QF —
|
|
||||||
// the entry floor of 5 carries them until the Semi-Final resolves.
|
|
||||||
const entryFloor = getBracketEntryFloor("Qualifying Finals", "afl_10");
|
|
||||||
expect(entryFloor).toBe(5);
|
|
||||||
expect(pts(entryFloor ?? 0)).toBe(25); // unchanged by the loss
|
|
||||||
});
|
|
||||||
|
|
||||||
it("seeds 5-6 and Wildcard winners share a 7th-8th floor, below the top-4's", () => {
|
|
||||||
expect(pts(7)).toBeLessThan(pts(5));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,273 +0,0 @@
|
||||||
/**
|
|
||||||
* Advancing an AFL Elimination Finals winner into the Semi-Finals.
|
|
||||||
*
|
|
||||||
* Unlike the Wildcard Round, this pathway is fixed: Elimination Final n feeds Semi-Final
|
|
||||||
* n. The crossover comes a round later, at Semi-Finals → Preliminary Finals, so that a
|
|
||||||
* Qualifying Final loser cannot meet the side that just beat it.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { AFL_10 } from "~/lib/bracket-templates";
|
|
||||||
|
|
||||||
interface MatchRow {
|
|
||||||
id: string;
|
|
||||||
scoringEventId: string;
|
|
||||||
round: string;
|
|
||||||
matchNumber: number;
|
|
||||||
participant1Id: string | null;
|
|
||||||
participant2Id: string | null;
|
|
||||||
isComplete: boolean;
|
|
||||||
winnerId: string | null;
|
|
||||||
loserId: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let rows: MatchRow[] = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The literal values drizzle put in a where clause (`eq(col, value)`), which is all this
|
|
||||||
* mock needs to tell one lookup from another — there is no query engine behind it.
|
|
||||||
*/
|
|
||||||
function whereValues(node: unknown, depth = 0): string[] {
|
|
||||||
if (!node || depth > 10) return [];
|
|
||||||
if (Array.isArray(node)) return node.flatMap((child) => whereValues(child, depth + 1));
|
|
||||||
if (typeof node !== "object") return [];
|
|
||||||
const obj = node as Record<string, unknown>;
|
|
||||||
const own = typeof obj.value === "string" ? [obj.value] : [];
|
|
||||||
return [...own, ...whereValues(obj.queryChunks, depth + 1)];
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = {
|
|
||||||
query: {
|
|
||||||
playoffMatches: {
|
|
||||||
findFirst: vi.fn(({ where }: { where: unknown }) => {
|
|
||||||
const values = whereValues(where);
|
|
||||||
return Promise.resolve(rows.find((r) => values.includes(r.id)));
|
|
||||||
}),
|
|
||||||
findMany: vi.fn(({ where }: { where: unknown }) => {
|
|
||||||
const values = whereValues(where);
|
|
||||||
return Promise.resolve(
|
|
||||||
rows
|
|
||||||
.filter((r) => values.includes(r.scoringEventId) && values.includes(r.round))
|
|
||||||
.toSorted((a, b) => a.matchNumber - b.matchNumber)
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
update: vi.fn(() => ({
|
|
||||||
set: (data: Partial<MatchRow>) => {
|
|
||||||
const applyTo = (where: unknown) => {
|
|
||||||
const values = whereValues(where);
|
|
||||||
const target = rows.find((r) => values.includes(r.id));
|
|
||||||
if (target) Object.assign(target, data);
|
|
||||||
return target;
|
|
||||||
};
|
|
||||||
// Advancement writes through the query builder with and without .returning().
|
|
||||||
return {
|
|
||||||
where: (where: unknown) => {
|
|
||||||
const applied = Promise.resolve([applyTo(where)]);
|
|
||||||
return Object.assign(applied, { returning: () => applied });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
// No rollback: the tests assert the writes that were attempted, in order.
|
|
||||||
transaction: vi.fn((fn: (tx: typeof db) => Promise<unknown>) => fn(db)),
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mock("~/database/context", () => ({ database: () => db }));
|
|
||||||
|
|
||||||
const { advanceWinnerTemplate, reseedAflSemiFinals } = await import("../playoff-match");
|
|
||||||
|
|
||||||
const EVENT = "event-1";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The real 2026 finals, which is what surfaced the crossover bug. Ladder: 1 Fremantle,
|
|
||||||
* 2 Sydney, 3 Brisbane, 4 Hawthorn, 5 Geelong, 6 Adelaide, 7 Melbourne, 8 Bulldogs,
|
|
||||||
* 9 Collingwood, 10 Carlton. Carlton (10th) and the Bulldogs (8th) came through the
|
|
||||||
* Wildcard Round, so 5th hosts Carlton and 6th hosts the Bulldogs.
|
|
||||||
*/
|
|
||||||
const FREO = "fremantle";
|
|
||||||
const SYDNEY = "sydney";
|
|
||||||
const BRISBANE = "brisbane";
|
|
||||||
const HAWTHORN = "hawthorn";
|
|
||||||
const GEELONG = "geelong";
|
|
||||||
const ADELAIDE = "adelaide";
|
|
||||||
const BULLDOGS = "bulldogs";
|
|
||||||
const CARLTON = "carlton";
|
|
||||||
|
|
||||||
/** An afl_10 bracket with week one played: Freo and Brisbane lost their Qualifying Finals. */
|
|
||||||
function bracket(): MatchRow[] {
|
|
||||||
const base = { scoringEventId: EVENT, isComplete: false, winnerId: null, loserId: null };
|
|
||||||
return [
|
|
||||||
{ ...base, id: "qf1", round: "Qualifying Finals", matchNumber: 1, participant1Id: FREO, participant2Id: HAWTHORN, isComplete: true, winnerId: HAWTHORN, loserId: FREO },
|
|
||||||
{ ...base, id: "qf2", round: "Qualifying Finals", matchNumber: 2, participant1Id: SYDNEY, participant2Id: BRISBANE, isComplete: true, winnerId: SYDNEY, loserId: BRISBANE },
|
|
||||||
{ ...base, id: "ef1", round: "Elimination Finals", matchNumber: 1, participant1Id: GEELONG, participant2Id: CARLTON },
|
|
||||||
{ ...base, id: "ef2", round: "Elimination Finals", matchNumber: 2, participant1Id: ADELAIDE, participant2Id: BULLDOGS },
|
|
||||||
// Filled by the Qualifying Final losers, as advancement already does.
|
|
||||||
{ ...base, id: "sf1", round: "Semi-Finals", matchNumber: 1, participant1Id: FREO, participant2Id: null },
|
|
||||||
{ ...base, id: "sf2", round: "Semi-Finals", matchNumber: 2, participant1Id: BRISBANE, participant2Id: null },
|
|
||||||
{ ...base, id: "pf1", round: "Preliminary Finals", matchNumber: 1, participant1Id: HAWTHORN, participant2Id: null },
|
|
||||||
{ ...base, id: "pf2", round: "Preliminary Finals", matchNumber: 2, participant1Id: SYDNEY, participant2Id: null },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function row(id: string): MatchRow {
|
|
||||||
const found = rows.find((r) => r.id === id);
|
|
||||||
if (!found) throw new Error(`No such match ${id}`);
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Record a result the way setMatchWinner does, then advance it. */
|
|
||||||
async function win(id: string, winnerId: string) {
|
|
||||||
const match = row(id);
|
|
||||||
match.winnerId = winnerId;
|
|
||||||
match.loserId = match.participant1Id === winnerId ? match.participant2Id : match.participant1Id;
|
|
||||||
match.isComplete = true;
|
|
||||||
await advanceWinnerTemplate(id, winnerId, AFL_10);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pairing = () => ({
|
|
||||||
sf1: [row("sf1").participant1Id, row("sf1").participant2Id],
|
|
||||||
sf2: [row("sf2").participant1Id, row("sf2").participant2Id],
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
rows = bracket();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Elimination Finals → Semi-Finals advancement", () => {
|
|
||||||
it("feeds Elimination Final 1 into Semi-Final 1", async () => {
|
|
||||||
await win("ef1", GEELONG);
|
|
||||||
|
|
||||||
expect(row("sf1").participant2Id).toBe(GEELONG);
|
|
||||||
expect(row("sf2").participant2Id).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("feeds Elimination Final 2 into Semi-Final 2", async () => {
|
|
||||||
await win("ef2", ADELAIDE);
|
|
||||||
|
|
||||||
expect(row("sf2").participant2Id).toBe(ADELAIDE);
|
|
||||||
expect(row("sf1").participant2Id).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("draws the real 2026 Semi-Finals: Freo v Geelong and Brisbane v Adelaide", async () => {
|
|
||||||
await win("ef1", GEELONG);
|
|
||||||
await win("ef2", ADELAIDE);
|
|
||||||
|
|
||||||
expect(pairing()).toEqual({
|
|
||||||
sf1: [FREO, GEELONG],
|
|
||||||
sf2: [BRISBANE, ADELAIDE],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("draws the same Semi-Finals whichever order the results are entered", async () => {
|
|
||||||
await win("ef2", ADELAIDE);
|
|
||||||
await win("ef1", GEELONG);
|
|
||||||
|
|
||||||
expect(pairing()).toEqual({
|
|
||||||
sf1: [FREO, GEELONG],
|
|
||||||
sf2: [BRISBANE, ADELAIDE],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the Preliminary Finals crossover so a QF loser dodges the side that beat it", async () => {
|
|
||||||
await win("ef1", GEELONG);
|
|
||||||
await win("ef2", ADELAIDE);
|
|
||||||
// Freo (lost QF1 to Hawthorn) wins its semi, so it must land in Sydney's Prelim.
|
|
||||||
await win("sf1", FREO);
|
|
||||||
|
|
||||||
expect(row("pf2").participant2Id).toBe(FREO);
|
|
||||||
expect(row("pf1").participant2Id).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("pulls the beaten team back out when an Elimination Final result is corrected", async () => {
|
|
||||||
await win("ef1", GEELONG);
|
|
||||||
expect(row("sf1").participant2Id).toBe(GEELONG);
|
|
||||||
|
|
||||||
await win("ef1", CARLTON);
|
|
||||||
|
|
||||||
expect(row("sf1").participant2Id).toBe(CARLTON);
|
|
||||||
expect(row("sf2").participant2Id).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("reseedAflSemiFinals", () => {
|
|
||||||
it("repairs a bracket left crossed by the old fixed crossover", async () => {
|
|
||||||
// What advancement wrote before the fix: EF1 winner into SF2, EF2 winner into SF1.
|
|
||||||
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
|
|
||||||
Object.assign(row("ef2"), { isComplete: true, winnerId: ADELAIDE, loserId: BULLDOGS });
|
|
||||||
row("sf1").participant2Id = ADELAIDE;
|
|
||||||
row("sf2").participant2Id = GEELONG;
|
|
||||||
|
|
||||||
const reseed = await reseedAflSemiFinals(EVENT);
|
|
||||||
|
|
||||||
expect(pairing()).toEqual({
|
|
||||||
sf1: [FREO, GEELONG],
|
|
||||||
sf2: [BRISBANE, ADELAIDE],
|
|
||||||
});
|
|
||||||
expect(reseed.vacated.toSorted()).toEqual([1, 2]);
|
|
||||||
expect(reseed.filled.toSorted((a, b) => a.matchNumber - b.matchNumber)).toEqual([
|
|
||||||
{ matchNumber: 1, participantId: GEELONG },
|
|
||||||
{ matchNumber: 2, participantId: ADELAIDE },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("writes nothing when the pairings are already right", async () => {
|
|
||||||
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
|
|
||||||
Object.assign(row("ef2"), { isComplete: true, winnerId: ADELAIDE, loserId: BULLDOGS });
|
|
||||||
row("sf1").participant2Id = GEELONG;
|
|
||||||
row("sf2").participant2Id = ADELAIDE;
|
|
||||||
|
|
||||||
const reseed = await reseedAflSemiFinals(EVENT);
|
|
||||||
|
|
||||||
expect(reseed).toEqual({ vacated: [], filled: [] });
|
|
||||||
expect(db.update).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves an undecided Elimination Final's slot TBD", async () => {
|
|
||||||
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
|
|
||||||
|
|
||||||
await reseedAflSemiFinals(EVENT);
|
|
||||||
|
|
||||||
expect(row("sf1").participant2Id).toBe(GEELONG);
|
|
||||||
expect(row("sf2").participant2Id).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refuses a slot held by someone who never played an Elimination Final", async () => {
|
|
||||||
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
|
|
||||||
row("sf1").participant2Id = SYDNEY;
|
|
||||||
|
|
||||||
await expect(reseedAflSemiFinals(EVENT)).rejects.toThrow("SF 1 participant2 already filled");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refuses to move a qualifier out of a Semi-Final that has been played", async () => {
|
|
||||||
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
|
|
||||||
Object.assign(row("sf1"), {
|
|
||||||
participant2Id: ADELAIDE,
|
|
||||||
isComplete: true,
|
|
||||||
winnerId: FREO,
|
|
||||||
loserId: ADELAIDE,
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(reseedAflSemiFinals(EVENT)).rejects.toThrow(
|
|
||||||
"Semi-Finals match 1 already has a recorded result"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects an Elimination Final winner who is not one of its participants", async () => {
|
|
||||||
Object.assign(row("ef1"), { isComplete: true, winnerId: SYDNEY, loserId: CARLTON });
|
|
||||||
|
|
||||||
await expect(reseedAflSemiFinals(EVENT)).rejects.toThrow(
|
|
||||||
"Elimination Finals match 1 winner is not one of its participants"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws on an event with no Semi-Finals to re-seed", async () => {
|
|
||||||
rows = rows.filter((r) => r.round !== "Semi-Finals");
|
|
||||||
|
|
||||||
await expect(reseedAflSemiFinals(EVENT)).rejects.toThrow(
|
|
||||||
"no AFL Elimination Finals / Semi-Finals matches to re-seed"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,271 +0,0 @@
|
||||||
/**
|
|
||||||
* Advancing an AFL Wildcard Round winner into the Elimination Finals.
|
|
||||||
*
|
|
||||||
* The two winners are re-seeded by ladder position — 5th hosts the lower-ranked winner,
|
|
||||||
* 6th the higher-ranked one — so the destination is not a fixed crossover from a given
|
|
||||||
* Wildcard match, and results can be recorded in either order.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { AFL_10 } from "~/lib/bracket-templates";
|
|
||||||
|
|
||||||
interface MatchRow {
|
|
||||||
id: string;
|
|
||||||
scoringEventId: string;
|
|
||||||
round: string;
|
|
||||||
matchNumber: number;
|
|
||||||
participant1Id: string | null;
|
|
||||||
participant2Id: string | null;
|
|
||||||
isComplete: boolean;
|
|
||||||
winnerId: string | null;
|
|
||||||
loserId: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let rows: MatchRow[] = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The literal values drizzle put in a where clause (`eq(col, value)`), which is all this
|
|
||||||
* mock needs to tell one lookup from another — there is no query engine behind it.
|
|
||||||
*/
|
|
||||||
function whereValues(node: unknown, depth = 0): string[] {
|
|
||||||
if (!node || depth > 10) return [];
|
|
||||||
if (Array.isArray(node)) return node.flatMap((child) => whereValues(child, depth + 1));
|
|
||||||
if (typeof node !== "object") return [];
|
|
||||||
const obj = node as Record<string, unknown>;
|
|
||||||
const own = typeof obj.value === "string" ? [obj.value] : [];
|
|
||||||
return [...own, ...whereValues(obj.queryChunks, depth + 1)];
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = {
|
|
||||||
query: {
|
|
||||||
playoffMatches: {
|
|
||||||
findFirst: vi.fn(({ where }: { where: unknown }) => {
|
|
||||||
const values = whereValues(where);
|
|
||||||
return Promise.resolve(rows.find((r) => values.includes(r.id)));
|
|
||||||
}),
|
|
||||||
findMany: vi.fn(({ where }: { where: unknown }) => {
|
|
||||||
const values = whereValues(where);
|
|
||||||
return Promise.resolve(
|
|
||||||
rows
|
|
||||||
.filter((r) => values.includes(r.scoringEventId) && values.includes(r.round))
|
|
||||||
.toSorted((a, b) => a.matchNumber - b.matchNumber)
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
update: vi.fn(() => ({
|
|
||||||
set: (data: Partial<MatchRow>) => {
|
|
||||||
const applyTo = (where: unknown) => {
|
|
||||||
const values = whereValues(where);
|
|
||||||
const target = rows.find((r) => values.includes(r.id));
|
|
||||||
if (target) Object.assign(target, data);
|
|
||||||
return target;
|
|
||||||
};
|
|
||||||
// Advancement writes through the query builder with and without .returning().
|
|
||||||
return {
|
|
||||||
where: (where: unknown) => {
|
|
||||||
const applied = Promise.resolve([applyTo(where)]);
|
|
||||||
return Object.assign(applied, { returning: () => applied });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
// No rollback: the tests assert the writes that were attempted, in order.
|
|
||||||
transaction: vi.fn((fn: (tx: typeof db) => Promise<unknown>) => fn(db)),
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mock("~/database/context", () => ({ database: () => db }));
|
|
||||||
|
|
||||||
const { advanceWinnerTemplate, reseedAflEliminationFinals } = await import("../playoff-match");
|
|
||||||
|
|
||||||
const EVENT = "event-1";
|
|
||||||
|
|
||||||
/** Ladder seed n → participant id. */
|
|
||||||
const seed = (n: number) => `seed-${n}`;
|
|
||||||
|
|
||||||
/** A freshly generated afl_10 Wildcard Round (7v10, 8v9) and Elimination Finals (5, 6). */
|
|
||||||
function bracket(): MatchRow[] {
|
|
||||||
const base = { scoringEventId: EVENT, isComplete: false, winnerId: null, loserId: null };
|
|
||||||
return [
|
|
||||||
{ ...base, id: "wc1", round: "Wildcard Round", matchNumber: 1, participant1Id: seed(7), participant2Id: seed(10) },
|
|
||||||
{ ...base, id: "wc2", round: "Wildcard Round", matchNumber: 2, participant1Id: seed(8), participant2Id: seed(9) },
|
|
||||||
{ ...base, id: "ef1", round: "Elimination Finals", matchNumber: 1, participant1Id: seed(5), participant2Id: null },
|
|
||||||
{ ...base, id: "ef2", round: "Elimination Finals", matchNumber: 2, participant1Id: seed(6), participant2Id: null },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function row(id: string): MatchRow {
|
|
||||||
const found = rows.find((r) => r.id === id);
|
|
||||||
if (!found) throw new Error(`No such match ${id}`);
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Record a Wildcard result the way setMatchWinner does, then advance it. */
|
|
||||||
async function winWildcard(id: string, winnerId: string) {
|
|
||||||
const match = row(id);
|
|
||||||
match.winnerId = winnerId;
|
|
||||||
match.loserId = match.participant1Id === winnerId ? match.participant2Id : match.participant1Id;
|
|
||||||
match.isComplete = true;
|
|
||||||
await advanceWinnerTemplate(id, winnerId, AFL_10);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("AFL Wildcard Round advancement", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
rows = bracket();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sends 5th the lower-ranked winner and 6th the higher-ranked one", async () => {
|
|
||||||
await winWildcard("wc1", seed(7));
|
|
||||||
await winWildcard("wc2", seed(8));
|
|
||||||
|
|
||||||
expect(row("ef1").participant2Id).toBe(seed(8));
|
|
||||||
expect(row("ef2").participant2Id).toBe(seed(7));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("re-seeds when the lower seed wins through", async () => {
|
|
||||||
// The reported bug: 10th beating 7th used to be crossed straight to 6th, leaving
|
|
||||||
// 5th with the better survivor.
|
|
||||||
await winWildcard("wc1", seed(10));
|
|
||||||
await winWildcard("wc2", seed(8));
|
|
||||||
|
|
||||||
expect(row("ef1").participant2Id).toBe(seed(10));
|
|
||||||
expect(row("ef2").participant2Id).toBe(seed(8));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("re-seeds a 9th-placed winner above a 10th-placed one", async () => {
|
|
||||||
await winWildcard("wc1", seed(10));
|
|
||||||
await winWildcard("wc2", seed(9));
|
|
||||||
|
|
||||||
expect(row("ef1").participant2Id).toBe(seed(10));
|
|
||||||
expect(row("ef2").participant2Id).toBe(seed(9));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("places the same pairings whichever result is entered first", async () => {
|
|
||||||
await winWildcard("wc2", seed(8));
|
|
||||||
await winWildcard("wc1", seed(10));
|
|
||||||
|
|
||||||
expect(row("ef1").participant2Id).toBe(seed(10));
|
|
||||||
expect(row("ef2").participant2Id).toBe(seed(8));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("places the 7v10 winner immediately, since its slot is settled either way", async () => {
|
|
||||||
await winWildcard("wc1", seed(7));
|
|
||||||
|
|
||||||
expect(row("ef2").participant2Id).toBe(seed(7));
|
|
||||||
expect(row("ef1").participant2Id).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("holds an 8v9 winner back until the 7v10 game is decided", async () => {
|
|
||||||
// 8th and 9th sit between 7th and 10th, so placing one now could need undoing.
|
|
||||||
await winWildcard("wc2", seed(8));
|
|
||||||
|
|
||||||
expect(row("ef1").participant2Id).toBeNull();
|
|
||||||
expect(row("ef2").participant2Id).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not disturb a winner it already placed", async () => {
|
|
||||||
await winWildcard("wc1", seed(7));
|
|
||||||
await winWildcard("wc2", seed(9));
|
|
||||||
|
|
||||||
expect(row("ef2").participant2Id).toBe(seed(7));
|
|
||||||
expect(row("ef1").participant2Id).toBe(seed(9));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refuses to overwrite a slot already holding someone else", async () => {
|
|
||||||
row("ef1").participant2Id = "stranger";
|
|
||||||
|
|
||||||
await expect(winWildcard("wc1", seed(10))).rejects.toThrow(/already filled/);
|
|
||||||
expect(row("ef1").participant2Id).toBe("stranger");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("moves the winner when a recorded Wildcard result is corrected", async () => {
|
|
||||||
await winWildcard("wc1", seed(7));
|
|
||||||
expect(row("ef2").participant2Id).toBe(seed(7));
|
|
||||||
|
|
||||||
// The result was wrong: 10th won. 7th must not be left alive in the other slot.
|
|
||||||
await winWildcard("wc1", seed(10));
|
|
||||||
|
|
||||||
expect(row("ef1").participant2Id).toBe(seed(10));
|
|
||||||
expect(row("ef2").participant2Id).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("re-seeds a pairing left behind by the old fixed crossover", async () => {
|
|
||||||
// Pre-fix state: the 7v10 winner was crossed to 6th whatever its ladder position.
|
|
||||||
row("wc1").winnerId = seed(10);
|
|
||||||
row("wc1").loserId = seed(7);
|
|
||||||
row("wc1").isComplete = true;
|
|
||||||
row("ef2").participant2Id = seed(10);
|
|
||||||
|
|
||||||
await winWildcard("wc2", seed(8));
|
|
||||||
|
|
||||||
expect(row("ef1").participant2Id).toBe(seed(10));
|
|
||||||
expect(row("ef2").participant2Id).toBe(seed(8));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("swaps both winners when re-resolving an already-placed pair", async () => {
|
|
||||||
row("wc1").winnerId = seed(10);
|
|
||||||
row("wc1").loserId = seed(7);
|
|
||||||
row("wc1").isComplete = true;
|
|
||||||
row("ef2").participant2Id = seed(10);
|
|
||||||
row("ef1").participant2Id = seed(8);
|
|
||||||
|
|
||||||
await winWildcard("wc2", seed(8));
|
|
||||||
|
|
||||||
expect(row("ef1").participant2Id).toBe(seed(10));
|
|
||||||
expect(row("ef2").participant2Id).toBe(seed(8));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refuses to re-seed an Elimination Final that has already been played", async () => {
|
|
||||||
await winWildcard("wc1", seed(7));
|
|
||||||
Object.assign(row("ef2"), { isComplete: true, winnerId: seed(6), loserId: seed(7) });
|
|
||||||
|
|
||||||
await expect(winWildcard("wc1", seed(10))).rejects.toThrow(/already has a recorded result/);
|
|
||||||
expect(row("ef2").participant2Id).toBe(seed(7));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("repairs an already-advanced bracket from the recorded results alone", async () => {
|
|
||||||
// What scripts/fix-afl-wildcard-reseed.ts does: no new result, just the rows a
|
|
||||||
// bracket advanced under the old fixed crossover left behind.
|
|
||||||
Object.assign(row("wc1"), { isComplete: true, winnerId: seed(10), loserId: seed(7) });
|
|
||||||
Object.assign(row("wc2"), { isComplete: true, winnerId: seed(8), loserId: seed(9) });
|
|
||||||
row("ef2").participant2Id = seed(10);
|
|
||||||
row("ef1").participant2Id = seed(8);
|
|
||||||
|
|
||||||
const reseed = await reseedAflEliminationFinals(EVENT);
|
|
||||||
|
|
||||||
expect(row("ef1").participant2Id).toBe(seed(10));
|
|
||||||
expect(row("ef2").participant2Id).toBe(seed(8));
|
|
||||||
expect(reseed.vacated.toSorted()).toEqual([1, 2]);
|
|
||||||
expect(reseed.filled.toSorted((a, b) => a.matchNumber - b.matchNumber)).toEqual([
|
|
||||||
{ matchNumber: 1, participantId: seed(10) },
|
|
||||||
{ matchNumber: 2, participantId: seed(8) },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports no change when a repair run finds the pairings correct", async () => {
|
|
||||||
await winWildcard("wc1", seed(7));
|
|
||||||
await winWildcard("wc2", seed(8));
|
|
||||||
db.transaction.mockClear();
|
|
||||||
|
|
||||||
const reseed = await reseedAflEliminationFinals(EVENT);
|
|
||||||
|
|
||||||
expect(reseed).toEqual({ vacated: [], filled: [] });
|
|
||||||
expect(db.transaction).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects an event with no AFL bracket rather than reporting nothing to do", async () => {
|
|
||||||
await expect(reseedAflEliminationFinals("no-such-event")).rejects.toThrow(/no AFL Wildcard/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves the bracket alone when the pairings are already right", async () => {
|
|
||||||
await winWildcard("wc1", seed(7));
|
|
||||||
await winWildcard("wc2", seed(8));
|
|
||||||
db.transaction.mockClear();
|
|
||||||
|
|
||||||
await winWildcard("wc2", seed(8));
|
|
||||||
|
|
||||||
expect(db.transaction).not.toHaveBeenCalled();
|
|
||||||
expect(row("ef1").participant2Id).toBe(seed(8));
|
|
||||||
expect(row("ef2").participant2Id).toBe(seed(7));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,243 +0,0 @@
|
||||||
/**
|
|
||||||
* Entry-floor scoring: points a bracket guarantees at seeding time.
|
|
||||||
*
|
|
||||||
* Some seedings lock in a scoring tier before a single match is played. The AFL
|
|
||||||
* finals are the clearest case: a top-4 seed has the double chance, so losing the
|
|
||||||
* Qualifying Final still leaves them a Semi-Final, and losing that is the 5th-6th
|
|
||||||
* tier. Those teams must not sit on 0 fantasy points until their first game.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
|
|
||||||
interface MatchRow {
|
|
||||||
round: string;
|
|
||||||
participant1Id: string | null;
|
|
||||||
participant2Id: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimal db mock. applyBracketEntryFloors calls, in order:
|
|
||||||
* 1. db.query.scoringEvents.findFirst → the event (for template + sportsSeasonId)
|
|
||||||
* 2. db.query.playoffMatches.findMany → the bracket's match slots
|
|
||||||
* 3. upsertParticipantResult per floored participant → findFirst + insert/update
|
|
||||||
*/
|
|
||||||
function makeDb(
|
|
||||||
event: { bracketTemplateId: string | null; sportsSeasonId: string } | null,
|
|
||||||
matches: MatchRow[],
|
|
||||||
existingByParticipant: Record<string, { id: string; finalPosition: number; isPartialScore: boolean }> = {}
|
|
||||||
) {
|
|
||||||
const existingRows = Object.entries(existingByParticipant).map(([participantId, row]) => ({
|
|
||||||
participantId,
|
|
||||||
finalPosition: row.finalPosition,
|
|
||||||
}));
|
|
||||||
const insertedRows: Array<Record<string, unknown>> = [];
|
|
||||||
const updatedRows: Array<Record<string, unknown>> = [];
|
|
||||||
|
|
||||||
return {
|
|
||||||
db: {
|
|
||||||
insert: vi.fn().mockReturnValue({
|
|
||||||
values: vi.fn().mockImplementation((values: Record<string, unknown>) => {
|
|
||||||
insertedRows.push(values);
|
|
||||||
return Promise.resolve();
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
update: vi.fn().mockReturnValue({
|
|
||||||
set: vi.fn().mockImplementation((values: Record<string, unknown>) => {
|
|
||||||
updatedRows.push(values);
|
|
||||||
return { where: vi.fn().mockResolvedValue(undefined) };
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
query: {
|
|
||||||
scoringEvents: { findFirst: vi.fn().mockResolvedValue(event) },
|
|
||||||
playoffMatches: { findMany: vi.fn().mockResolvedValue(matches) },
|
|
||||||
seasonParticipantResults: {
|
|
||||||
// The pre-pass that stops a floor from downgrading an existing placement.
|
|
||||||
findMany: vi.fn().mockResolvedValue(existingRows),
|
|
||||||
findFirst: vi.fn().mockImplementation((args: { where?: unknown }) => {
|
|
||||||
// Resolve by scanning the seeded map — the mock has no real query engine,
|
|
||||||
// so tests that need an existing row use a single-participant bracket.
|
|
||||||
void args;
|
|
||||||
const only = Object.values(existingByParticipant)[0];
|
|
||||||
return Promise.resolve(only);
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
insertedRows,
|
|
||||||
updatedRows,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
import { applyBracketEntryFloors, getBracketEntryFloor } from "../scoring-calculator";
|
|
||||||
|
|
||||||
/** The AFL bracket exactly as generateAFL10Bracket writes it: later rounds are TBD. */
|
|
||||||
const AFL_BRACKET: MatchRow[] = [
|
|
||||||
{ round: "Wildcard Round", participant1Id: "seed7", participant2Id: "seed10" },
|
|
||||||
{ round: "Wildcard Round", participant1Id: "seed8", participant2Id: "seed9" },
|
|
||||||
{ round: "Qualifying Finals", participant1Id: "seed1", participant2Id: "seed4" },
|
|
||||||
{ round: "Qualifying Finals", participant1Id: "seed2", participant2Id: "seed3" },
|
|
||||||
{ round: "Elimination Finals", participant1Id: "seed5", participant2Id: null },
|
|
||||||
{ round: "Elimination Finals", participant1Id: "seed6", participant2Id: null },
|
|
||||||
{ round: "Semi-Finals", participant1Id: null, participant2Id: null },
|
|
||||||
{ round: "Semi-Finals", participant1Id: null, participant2Id: null },
|
|
||||||
{ round: "Preliminary Finals", participant1Id: null, participant2Id: null },
|
|
||||||
{ round: "Preliminary Finals", participant1Id: null, participant2Id: null },
|
|
||||||
{ round: "Grand Final", participant1Id: null, participant2Id: null },
|
|
||||||
];
|
|
||||||
|
|
||||||
describe("applyBracketEntryFloors", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("afl_10", () => {
|
|
||||||
it("banks 5 for the top 4 and 7 for seeds 5-6, and nothing for the wildcard teams", async () => {
|
|
||||||
const { db, insertedRows } = makeDb(
|
|
||||||
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
|
|
||||||
AFL_BRACKET
|
|
||||||
);
|
|
||||||
|
|
||||||
const applied = await applyBracketEntryFloors("event-1", db);
|
|
||||||
|
|
||||||
expect(applied).toBe(6);
|
|
||||||
const floors = Object.fromEntries(
|
|
||||||
insertedRows.map((r) => [r.participantId as string, r.finalPosition as number])
|
|
||||||
);
|
|
||||||
expect(floors).toEqual({
|
|
||||||
seed1: 5, seed2: 5, seed3: 5, seed4: 5, // double chance → 5th-6th tier
|
|
||||||
seed5: 7, seed6: 7, // seeded into the Elimination Finals
|
|
||||||
});
|
|
||||||
// Seeds 7-10 lose the Wildcard Round for 0, so nothing is guaranteed yet.
|
|
||||||
expect(floors).not.toHaveProperty("seed7");
|
|
||||||
expect(floors).not.toHaveProperty("seed10");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("writes every floor as provisional so real results supersede it", async () => {
|
|
||||||
const { db, insertedRows } = makeDb(
|
|
||||||
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
|
|
||||||
AFL_BRACKET
|
|
||||||
);
|
|
||||||
|
|
||||||
await applyBracketEntryFloors("event-1", db);
|
|
||||||
|
|
||||||
expect(insertedRows.every((r) => r.isPartialScore === true)).toBe(true);
|
|
||||||
expect(insertedRows.every((r) => r.sportsSeasonId === "ss-1")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves TBD slots alone — a Semi-Final nobody has reached grants nothing", async () => {
|
|
||||||
const { db, insertedRows } = makeDb(
|
|
||||||
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
|
|
||||||
[{ round: "Semi-Finals", participant1Id: null, participant2Id: null }]
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
|
|
||||||
expect(insertedRows).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("never downgrades a better placement — a finalist regenerating stays a finalist", async () => {
|
|
||||||
// clear-bracket → generate-bracket mid-tournament must not knock a team sitting
|
|
||||||
// on a 2nd-place floor back down to their 5th-6th seeding floor.
|
|
||||||
const { db, insertedRows, updatedRows } = makeDb(
|
|
||||||
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
|
|
||||||
[{ round: "Qualifying Finals", participant1Id: "seed1", participant2Id: null }],
|
|
||||||
{ seed1: { id: "row-1", finalPosition: 2, isPartialScore: true } }
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
|
|
||||||
expect(insertedRows).toHaveLength(0);
|
|
||||||
expect(updatedRows).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats position 0 as eliminated, not as a better placement", async () => {
|
|
||||||
// A 0 means "missed the bracket". Re-seeding a team into the bracket must still
|
|
||||||
// give them their floor rather than reading 0 as an unbeatable placement.
|
|
||||||
const { db, updatedRows } = makeDb(
|
|
||||||
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
|
|
||||||
[{ round: "Qualifying Finals", participant1Id: "seed1", participant2Id: null }],
|
|
||||||
{ seed1: { id: "row-1", finalPosition: 0, isPartialScore: true } }
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(await applyBracketEntryFloors("event-1", db)).toBe(1);
|
|
||||||
expect(updatedRows).toHaveLength(1);
|
|
||||||
expect(updatedRows[0]).toMatchObject({ finalPosition: 5, isPartialScore: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not un-finalize a participant who already has a real result", async () => {
|
|
||||||
// upsertParticipantResult's never-un-finalize guard: a finalized row must not be
|
|
||||||
// dragged back to a provisional floor when the bracket is regenerated.
|
|
||||||
const { db, insertedRows, updatedRows } = makeDb(
|
|
||||||
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
|
|
||||||
[{ round: "Qualifying Finals", participant1Id: "seed1", participant2Id: null }],
|
|
||||||
{ seed1: { id: "row-1", finalPosition: 1, isPartialScore: false } }
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
|
|
||||||
expect(insertedRows).toHaveLength(0);
|
|
||||||
expect(updatedRows).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("other brackets", () => {
|
|
||||||
it("is a no-op for an event with no bracket template", async () => {
|
|
||||||
const { db, insertedRows } = makeDb(
|
|
||||||
{ bracketTemplateId: null, sportsSeasonId: "ss-1" },
|
|
||||||
AFL_BRACKET
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
|
|
||||||
expect(insertedRows).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is a no-op when the event does not exist", async () => {
|
|
||||||
const { db } = makeDb(null, AFL_BRACKET);
|
|
||||||
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("grants nothing to an NBA bracket: every seeded round is non-scoring", async () => {
|
|
||||||
const { db, insertedRows } = makeDb(
|
|
||||||
{ bracketTemplateId: "nba_20", sportsSeasonId: "ss-1" },
|
|
||||||
[
|
|
||||||
{ round: "Play-In Round 1", participant1Id: "e7", participant2Id: "e8" },
|
|
||||||
{ round: "First Round", participant1Id: "e1", participant2Id: null },
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
|
|
||||||
expect(insertedRows).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("grants the T5-8 tier to a simple_8 field: every entrant is already in a scoring round", async () => {
|
|
||||||
const { db, insertedRows } = makeDb(
|
|
||||||
{ bracketTemplateId: "simple_8", sportsSeasonId: "ss-1" },
|
|
||||||
[{ round: "Quarterfinals", participant1Id: "a", participant2Id: "b" }]
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(await applyBracketEntryFloors("event-1", db)).toBe(2);
|
|
||||||
expect(insertedRows.map((r) => r.finalPosition)).toEqual([5, 5]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getBracketEntryFloor", () => {
|
|
||||||
it("prefers a round's explicit entryFloor over its loser position", () => {
|
|
||||||
// Qualifying Finals is non-scoring, so only the explicit entryFloor makes it pay.
|
|
||||||
expect(getBracketEntryFloor("Qualifying Finals", "afl_10")).toBe(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to a scoring round's own loser position", () => {
|
|
||||||
expect(getBracketEntryFloor("Quarterfinals", "simple_8")).toBe(5);
|
|
||||||
expect(getBracketEntryFloor("Semifinals", "simple_8")).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for non-scoring rounds with no explicit floor", () => {
|
|
||||||
expect(getBracketEntryFloor("Wildcard Round", "afl_10")).toBeNull();
|
|
||||||
expect(getBracketEntryFloor("First Round", "nba_20")).toBeNull();
|
|
||||||
expect(getBracketEntryFloor("Round of 64", "ncaa_68")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for unknown rounds and templates", () => {
|
|
||||||
expect(getBracketEntryFloor("Not A Round", "afl_10")).toBeNull();
|
|
||||||
expect(getBracketEntryFloor("Quarterfinals", "not_a_template")).toBeNull();
|
|
||||||
expect(getBracketEntryFloor("Quarterfinals", null)).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import {
|
|
||||||
getBracketTemplateIdForSportsSeason,
|
|
||||||
getBracketTemplateIdsForSportsSeasons,
|
|
||||||
} from "../bracket-template";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A sports season can own several scoring events — a bracket plus schedule events, or a
|
|
||||||
* re-created bracket alongside a stale one. Resolving the template from an arbitrary row
|
|
||||||
* is not harmless: calculateBracketPoints falls back to the flat 5th–8th average when the
|
|
||||||
* template id is null, so losing "llws_20" makes a team locked into 5th–6th and one
|
|
||||||
* locked into 7th–8th both score 20.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimal db stub. Applies the same filter and ordering the real query does, so the
|
|
||||||
* assertions exercise the helper's row-picking rather than re-stating the query.
|
|
||||||
*/
|
|
||||||
function makeDb(
|
|
||||||
rows: Array<{ sportsSeasonId: string; bracketTemplateId: string | null; createdAt: Date }>
|
|
||||||
) {
|
|
||||||
const findMany = vi.fn(async () =>
|
|
||||||
rows
|
|
||||||
.filter((row) => row.bracketTemplateId !== null)
|
|
||||||
.toSorted((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
|
||||||
);
|
|
||||||
return { db: { query: { scoringEvents: { findMany } } } as any, findMany };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("getBracketTemplateIdForSportsSeason", () => {
|
|
||||||
it("ignores a non-bracket event and returns the bracket event's template", async () => {
|
|
||||||
const { db } = makeDb([
|
|
||||||
{ sportsSeasonId: "ss1", bracketTemplateId: null, createdAt: new Date("2026-08-01") },
|
|
||||||
{ sportsSeasonId: "ss1", bracketTemplateId: "llws_20", createdAt: new Date("2026-07-01") },
|
|
||||||
]);
|
|
||||||
|
|
||||||
await expect(getBracketTemplateIdForSportsSeason("ss1", db)).resolves.toBe("llws_20");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("takes the most recent bracket event when a stale one is still around", async () => {
|
|
||||||
const { db } = makeDb([
|
|
||||||
{ sportsSeasonId: "ss1", bracketTemplateId: "simple_16", createdAt: new Date("2026-06-01") },
|
|
||||||
{ sportsSeasonId: "ss1", bracketTemplateId: "llws_20", createdAt: new Date("2026-08-01") },
|
|
||||||
]);
|
|
||||||
|
|
||||||
await expect(getBracketTemplateIdForSportsSeason("ss1", db)).resolves.toBe("llws_20");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when the season has no bracket event", async () => {
|
|
||||||
const { db } = makeDb([
|
|
||||||
{ sportsSeasonId: "ss1", bracketTemplateId: null, createdAt: new Date("2026-08-01") },
|
|
||||||
]);
|
|
||||||
|
|
||||||
await expect(getBracketTemplateIdForSportsSeason("ss1", db)).resolves.toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getBracketTemplateIdsForSportsSeasons", () => {
|
|
||||||
it("resolves each season independently in one query", async () => {
|
|
||||||
const { db, findMany } = makeDb([
|
|
||||||
{ sportsSeasonId: "ss1", bracketTemplateId: "llws_20", createdAt: new Date("2026-08-01") },
|
|
||||||
{ sportsSeasonId: "ss2", bracketTemplateId: "afl_10", createdAt: new Date("2026-08-02") },
|
|
||||||
{ sportsSeasonId: "ss3", bracketTemplateId: null, createdAt: new Date("2026-08-03") },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const resolved = await getBracketTemplateIdsForSportsSeasons(["ss1", "ss2", "ss3"], db);
|
|
||||||
|
|
||||||
expect(resolved.get("ss1")).toBe("llws_20");
|
|
||||||
expect(resolved.get("ss2")).toBe("afl_10");
|
|
||||||
expect(resolved.get("ss3")).toBeNull();
|
|
||||||
expect(findMany).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("gives every requested season an entry so callers can cache the miss", async () => {
|
|
||||||
const { db } = makeDb([]);
|
|
||||||
|
|
||||||
const resolved = await getBracketTemplateIdsForSportsSeasons(["ss1", "ss2"], db);
|
|
||||||
|
|
||||||
expect([...resolved.entries()]).toEqual([
|
|
||||||
["ss1", null],
|
|
||||||
["ss2", null],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not query at all for an empty season list", async () => {
|
|
||||||
const { db, findMany } = makeDb([]);
|
|
||||||
|
|
||||||
await expect(getBracketTemplateIdsForSportsSeasons([], db)).resolves.toEqual(new Map());
|
|
||||||
expect(findMany).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,17 +1,6 @@
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { computeStage3ExitQP } from "../cs2-major-stage";
|
||||||
// Stub only the cached-total recompute so resetCs2Event can be exercised without
|
import { calculateSplitQualifyingPoints } from "../qualifying-points";
|
||||||
// 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>();
|
||||||
|
|
@ -268,57 +257,3 @@ 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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
|
|
||||||
// Mock the database context before importing any model
|
|
||||||
vi.mock("~/database/context", () => ({
|
|
||||||
database: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { database } from "~/database/context";
|
|
||||||
import { batchUpsertParticipantSimulatorInputs } from "../simulator";
|
|
||||||
|
|
||||||
type SetPayload = Record<string, unknown>;
|
|
||||||
|
|
||||||
const conflictSetCalls: SetPayload[] = [];
|
|
||||||
|
|
||||||
const onConflictDoUpdate = vi.fn((arg: { set: SetPayload }) => {
|
|
||||||
conflictSetCalls.push(arg.set);
|
|
||||||
return Promise.resolve(undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
const tx = {
|
|
||||||
insert: vi.fn(() => ({
|
|
||||||
values: vi.fn(() => ({ onConflictDoUpdate })),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockDb = {
|
|
||||||
transaction: vi.fn((cb: (t: typeof tx) => Promise<unknown>) => cb(tx)),
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
conflictSetCalls.length = 0;
|
|
||||||
(database as ReturnType<typeof vi.fn>).mockReturnValue(mockDb);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Recursively flatten a Drizzle `sql` template into its static text so we can
|
|
||||||
// assert how a conflict-update column is built (e.g. wrapped in COALESCE).
|
|
||||||
function sqlToText(value: unknown): string {
|
|
||||||
if (value === null || value === undefined) return "";
|
|
||||||
const chunks = (value as { queryChunks?: unknown[] }).queryChunks;
|
|
||||||
if (Array.isArray(chunks)) return chunks.map(sqlToText).join("");
|
|
||||||
const stringValue = (value as { value?: unknown }).value;
|
|
||||||
if (Array.isArray(stringValue)) return stringValue.join("");
|
|
||||||
if (typeof stringValue === "string") return stringValue;
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("batchUpsertParticipantSimulatorInputs", () => {
|
|
||||||
it("updates only the columns it is given, preserving the rest (non-destructive)", async () => {
|
|
||||||
await batchUpsertParticipantSimulatorInputs([
|
|
||||||
// Odds-only import (e.g. from the bulk futures paste): no Elo/rating supplied.
|
|
||||||
{ participantId: "team-1", sportsSeasonId: "season-1", sourceOdds: 550 },
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Runs in a transaction, writing the simulator-inputs row first then the
|
|
||||||
// legacy EV bridge row.
|
|
||||||
expect(mockDb.transaction).toHaveBeenCalledTimes(1);
|
|
||||||
expect(conflictSetCalls.length).toBeGreaterThanOrEqual(1);
|
|
||||||
|
|
||||||
const simulatorSet = conflictSetCalls[0];
|
|
||||||
// Every input column participates in the conflict update so partial pastes
|
|
||||||
// can target any field...
|
|
||||||
for (const column of ["sourceOdds", "sourceElo", "worldRanking", "rating", "seed", "region"]) {
|
|
||||||
expect(simulatorSet).toHaveProperty(column);
|
|
||||||
}
|
|
||||||
// ...but each is COALESCE-wrapped, so a null incoming value keeps the stored
|
|
||||||
// one instead of clobbering it. A bare `excluded.*` here would be the bug.
|
|
||||||
const sourceEloSql = sqlToText(simulatorSet.sourceElo).toLowerCase();
|
|
||||||
expect(sourceEloSql).toContain("coalesce");
|
|
||||||
const ratingSql = sqlToText(simulatorSet.rating).toLowerCase();
|
|
||||||
expect(ratingSql).toContain("coalesce");
|
|
||||||
|
|
||||||
// Metadata must drop the per-column method flag whenever that column gets a
|
|
||||||
// fresh direct value, so a stale "generated" flag can't hide a newly entered
|
|
||||||
// Elo/rating. Blindly preserving metadata (e.g. COALESCE) would be the bug.
|
|
||||||
const metadataSql = sqlToText(simulatorSet.metadata).toLowerCase();
|
|
||||||
expect(metadataSql).toContain("sourceelomethod");
|
|
||||||
expect(metadataSql).toContain("ratingmethod");
|
|
||||||
expect(metadataSql).toContain("excluded.source_elo");
|
|
||||||
expect(metadataSql).toContain("excluded.rating");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is a no-op when given no inputs", async () => {
|
|
||||||
await batchUpsertParticipantSimulatorInputs([]);
|
|
||||||
expect(mockDb.transaction).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,481 +0,0 @@
|
||||||
/**
|
|
||||||
* LLWS 20-Team Double-Elimination Bracket Tests
|
|
||||||
*
|
|
||||||
* Verifies the llws_20 template against the official 2026 LLBWS bracket
|
|
||||||
* (Williamsport, Aug 19–30). The PDF numbers its games 1–38; those numbers appear
|
|
||||||
* throughout as `G<n>` so the routing can be checked against the printed bracket.
|
|
||||||
*
|
|
||||||
* The critical property under test is the double-elimination loser routing: a loss in
|
|
||||||
* the winners bracket drops a team into the elimination bracket at a specific slot,
|
|
||||||
* while a loss in the elimination bracket is final.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi } from "vitest";
|
|
||||||
import {
|
|
||||||
LLWS_20,
|
|
||||||
getScoringRoundType,
|
|
||||||
llwsMatchNumber,
|
|
||||||
llwsSideAndLocal,
|
|
||||||
} from "~/lib/bracket-templates";
|
|
||||||
import {
|
|
||||||
doesLoserAdvance,
|
|
||||||
generateBracketFromTemplate,
|
|
||||||
resolveLLWSAdvancement,
|
|
||||||
} from "../playoff-match";
|
|
||||||
import {
|
|
||||||
calculateBracketPoints,
|
|
||||||
calculateAveragedPoints,
|
|
||||||
type ScoringRules,
|
|
||||||
} from "../scoring-rules";
|
|
||||||
import {
|
|
||||||
GAME_TO_MATCH,
|
|
||||||
EXPECTED_SLOTS,
|
|
||||||
gameNumberFor,
|
|
||||||
required,
|
|
||||||
destinationGame,
|
|
||||||
} from "~/test/fixtures/llws-bracket";
|
|
||||||
|
|
||||||
// generateBracketFromTemplate's only DB touch for llws_20 is the bulk insert, so a
|
|
||||||
// minimal stub is enough to capture the generated rows.
|
|
||||||
const insertedRows: Record<string, unknown>[] = [];
|
|
||||||
vi.mock("~/database/context", () => ({
|
|
||||||
database: () => ({
|
|
||||||
insert: () => ({
|
|
||||||
values: (rows: Record<string, unknown>[]) => ({
|
|
||||||
returning: async () => {
|
|
||||||
insertedRows.push(...rows);
|
|
||||||
return rows;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const DEFAULT_SCORING: ScoringRules = {
|
|
||||||
pointsFor1st: 100,
|
|
||||||
pointsFor2nd: 70,
|
|
||||||
pointsFor3rd: 50,
|
|
||||||
pointsFor4th: 40,
|
|
||||||
pointsFor5th: 25,
|
|
||||||
pointsFor6th: 20,
|
|
||||||
pointsFor7th: 15,
|
|
||||||
pointsFor8th: 10,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("LLWS 20 Bracket Template", () => {
|
|
||||||
describe("Template structure", () => {
|
|
||||||
it("has correct identity and size", () => {
|
|
||||||
expect(LLWS_20.id).toBe("llws_20");
|
|
||||||
expect(LLWS_20.totalTeams).toBe(20);
|
|
||||||
expect(LLWS_20.scoringStartsAtRound).toBe("Winners Final");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("has 12 rounds totalling 38 matches", () => {
|
|
||||||
expect(LLWS_20.rounds).toHaveLength(12);
|
|
||||||
const total = LLWS_20.rounds.reduce((sum, r) => sum + r.matchCount, 0);
|
|
||||||
expect(total).toBe(38);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("has the expected match count per round", () => {
|
|
||||||
const counts = Object.fromEntries(
|
|
||||||
LLWS_20.rounds.map((r) => [r.name, r.matchCount])
|
|
||||||
);
|
|
||||||
expect(counts).toEqual({
|
|
||||||
"Opening Round": 8,
|
|
||||||
"Winners Round 2": 4,
|
|
||||||
"Elimination Round 1": 4,
|
|
||||||
"Winners Semifinals": 4,
|
|
||||||
"Elimination Round 2": 4,
|
|
||||||
"Elimination Round 3": 4,
|
|
||||||
"Winners Final": 2,
|
|
||||||
"Elimination Round 4": 2,
|
|
||||||
"Elimination Final": 2,
|
|
||||||
"Bracket Championship": 2,
|
|
||||||
"Consolation Third Place": 1,
|
|
||||||
"World Championship": 1,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("marks exactly the point-awarding rounds as scoring", () => {
|
|
||||||
const scoring = LLWS_20.rounds.filter((r) => r.isScoring).map((r) => r.name);
|
|
||||||
expect(scoring).toEqual([
|
|
||||||
"Winners Final",
|
|
||||||
"Elimination Round 4",
|
|
||||||
"Elimination Final",
|
|
||||||
"Bracket Championship",
|
|
||||||
"Consolation Third Place",
|
|
||||||
"World Championship",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lists rounds in chronological order", () => {
|
|
||||||
// Elimination Round 1 (Aug 22) is played before Winners Semifinals (Aug 23).
|
|
||||||
const names = LLWS_20.rounds.map((r) => r.name);
|
|
||||||
expect(names.indexOf("Elimination Round 1")).toBeLessThan(
|
|
||||||
names.indexOf("Winners Semifinals")
|
|
||||||
);
|
|
||||||
expect(names.indexOf("Winners Final")).toBeLessThan(
|
|
||||||
names.indexOf("Elimination Final")
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("gives elimination-bracket winners a floor matching their real worst case", () => {
|
|
||||||
const byName = (n: string) => LLWS_20.rounds.find((r) => r.name === n);
|
|
||||||
// Winning Elim R3 only guarantees 7th (a loss in Elim R4 is the 7–8 tier),
|
|
||||||
// so the engine's default floor of 5 would overstate it.
|
|
||||||
expect(byName("Elimination Round 3")?.nonScoringWinnerFloor).toBe(7);
|
|
||||||
// Reaching the Winners Final guarantees 5th at worst.
|
|
||||||
expect(byName("Winners Semifinals")?.nonScoringWinnerFloor).toBe(5);
|
|
||||||
// Nothing is guaranteed earlier than that.
|
|
||||||
expect(byName("Opening Round")?.nonScoringWinnerFloor).toBeNull();
|
|
||||||
expect(byName("Winners Round 2")?.nonScoringWinnerFloor).toBeNull();
|
|
||||||
expect(byName("Elimination Round 1")?.nonScoringWinnerFloor).toBeNull();
|
|
||||||
expect(byName("Elimination Round 2")?.nonScoringWinnerFloor).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("has 20 participant labels", () => {
|
|
||||||
expect(LLWS_20.participantLabels).toHaveLength(20);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("splits display into U.S., International and Championship phases", () => {
|
|
||||||
expect(LLWS_20.phases?.map((p) => p.name)).toEqual([
|
|
||||||
"United States",
|
|
||||||
"International",
|
|
||||||
"Championship",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("assigns every match to exactly one phase group", () => {
|
|
||||||
const claimed = new Map<string, number>();
|
|
||||||
for (const phase of LLWS_20.phases ?? []) {
|
|
||||||
for (const group of phase.groups ?? []) {
|
|
||||||
for (const [round, numbers] of Object.entries(group.roundMatchNumbers)) {
|
|
||||||
for (const n of numbers) {
|
|
||||||
const key = `${round}#${n}`;
|
|
||||||
claimed.set(key, (claimed.get(key) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Every per-side match claimed exactly once (36 games; the 2 finals live in
|
|
||||||
// the Championship phase's plain round list, not in a group).
|
|
||||||
expect(claimed.size).toBe(36);
|
|
||||||
expect([...claimed.values()].every((c) => c === 1)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Bracket generation", () => {
|
|
||||||
const PARTICIPANTS = Array.from({ length: 20 }, (_, i) => `team-${i}`);
|
|
||||||
|
|
||||||
async function generate() {
|
|
||||||
insertedRows.length = 0;
|
|
||||||
await generateBracketFromTemplate("event-1", "llws_20", PARTICIPANTS);
|
|
||||||
return insertedRows.map((r) => ({
|
|
||||||
round: r.round as string,
|
|
||||||
matchNumber: r.matchNumber as number,
|
|
||||||
participant1Id: (r.participant1Id ?? null) as string | null,
|
|
||||||
participant2Id: (r.participant2Id ?? null) as string | null,
|
|
||||||
isScoring: r.isScoring as boolean,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
it("creates all 38 matches", async () => {
|
|
||||||
const rows = await generate();
|
|
||||||
expect(rows).toHaveLength(38);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates the right number of matches per round", async () => {
|
|
||||||
const rows = await generate();
|
|
||||||
for (const round of LLWS_20.rounds) {
|
|
||||||
expect(
|
|
||||||
rows.filter((r) => r.round === round.name),
|
|
||||||
`${round.name} match count`
|
|
||||||
).toHaveLength(round.matchCount);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("numbers matches 1..n within each round", async () => {
|
|
||||||
const rows = await generate();
|
|
||||||
for (const round of LLWS_20.rounds) {
|
|
||||||
const numbers = rows
|
|
||||||
.filter((r) => r.round === round.name)
|
|
||||||
.map((r) => r.matchNumber)
|
|
||||||
.toSorted((a, b) => a - b);
|
|
||||||
expect(numbers).toEqual(
|
|
||||||
Array.from({ length: round.matchCount }, (_, i) => i + 1)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("seeds the Opening Round two teams at a time, U.S. then International", async () => {
|
|
||||||
const rows = await generate();
|
|
||||||
const opening = rows
|
|
||||||
.filter((r) => r.round === "Opening Round")
|
|
||||||
.toSorted((a, b) => a.matchNumber - b.matchNumber);
|
|
||||||
// U.S. slots 0–7 fill matches 1–4; International slots 10–17 fill matches 5–8.
|
|
||||||
expect(opening.map((m) => [m.participant1Id, m.participant2Id])).toEqual([
|
|
||||||
["team-0", "team-1"],
|
|
||||||
["team-2", "team-3"],
|
|
||||||
["team-4", "team-5"],
|
|
||||||
["team-6", "team-7"],
|
|
||||||
["team-10", "team-11"],
|
|
||||||
["team-12", "team-13"],
|
|
||||||
["team-14", "team-15"],
|
|
||||||
["team-16", "team-17"],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("seats the four bye teams in Winners Round 2 awaiting an opponent", async () => {
|
|
||||||
const rows = await generate();
|
|
||||||
const wr2 = rows
|
|
||||||
.filter((r) => r.round === "Winners Round 2")
|
|
||||||
.toSorted((a, b) => a.matchNumber - b.matchNumber);
|
|
||||||
expect(wr2.map((m) => [m.participant1Id, m.participant2Id])).toEqual([
|
|
||||||
["team-8", null],
|
|
||||||
["team-9", null],
|
|
||||||
["team-18", null],
|
|
||||||
["team-19", null],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses each participant exactly once and leaves every other slot empty", async () => {
|
|
||||||
const rows = await generate();
|
|
||||||
const seeded = rows
|
|
||||||
.flatMap((r) => [r.participant1Id, r.participant2Id])
|
|
||||||
.filter((id): id is string => id !== null);
|
|
||||||
expect(seeded).toHaveLength(20);
|
|
||||||
expect(new Set(seeded).size).toBe(20);
|
|
||||||
expect(new Set(seeded)).toEqual(new Set(PARTICIPANTS));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stamps isScoring from the template", async () => {
|
|
||||||
const rows = await generate();
|
|
||||||
for (const round of LLWS_20.rounds) {
|
|
||||||
for (const row of rows.filter((r) => r.round === round.name)) {
|
|
||||||
expect(row.isScoring, `${round.name} #${row.matchNumber}`).toBe(round.isScoring);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a participant count other than 20", async () => {
|
|
||||||
await expect(
|
|
||||||
generateBracketFromTemplate("event-1", "llws_20", PARTICIPANTS.slice(0, 19))
|
|
||||||
).rejects.toThrow(/requires 20 participants/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Side / match-number mapping", () => {
|
|
||||||
it("round-trips every match number through side-local form", () => {
|
|
||||||
for (const round of LLWS_20.rounds) {
|
|
||||||
if (round.matchCount === 1) continue; // shared finals have no side
|
|
||||||
for (let n = 1; n <= round.matchCount; n++) {
|
|
||||||
const { side, localMatch } = llwsSideAndLocal(round.name, n);
|
|
||||||
expect(llwsMatchNumber(round.name, side, localMatch)).toBe(n);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("puts U.S. matches in the low half and International in the high half", () => {
|
|
||||||
expect(llwsSideAndLocal("Opening Round", 4).side).toBe(0);
|
|
||||||
expect(llwsSideAndLocal("Opening Round", 5).side).toBe(1);
|
|
||||||
expect(llwsSideAndLocal("Winners Semifinals", 2).side).toBe(0);
|
|
||||||
expect(llwsSideAndLocal("Winners Semifinals", 3).side).toBe(1);
|
|
||||||
expect(llwsSideAndLocal("Winners Final", 1).side).toBe(0);
|
|
||||||
expect(llwsSideAndLocal("Winners Final", 2).side).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Advancement matches the official bracket", () => {
|
|
||||||
/**
|
|
||||||
* Replay the whole tournament through resolveLLWSAdvancement and record which
|
|
||||||
* feed label ends up in each slot, then compare against the printed bracket.
|
|
||||||
*/
|
|
||||||
const actualSlots: Record<number, [string | null, string | null]> = {};
|
|
||||||
for (const game of Object.keys(EXPECTED_SLOTS)) {
|
|
||||||
actualSlots[Number(game)] = [null, null];
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [gameStr, { round, matchNumber }] of Object.entries(GAME_TO_MATCH)) {
|
|
||||||
const game = Number(gameStr);
|
|
||||||
const { winner, loser } = resolveLLWSAdvancement(round, matchNumber);
|
|
||||||
for (const [dest, label] of [
|
|
||||||
[winner, `W${game}`],
|
|
||||||
[loser, `L${game}`],
|
|
||||||
] as const) {
|
|
||||||
if (!dest) continue;
|
|
||||||
const targetGame = gameNumberFor(dest.round, dest.matchNumber);
|
|
||||||
const slotIndex = dest.slot === "participant1Id" ? 0 : 1;
|
|
||||||
actualSlots[targetGame][slotIndex] = label;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
it.each(Object.keys(EXPECTED_SLOTS).map(Number).toSorted((a, b) => a - b))(
|
|
||||||
"Game %i has the printed participants",
|
|
||||||
(game) => {
|
|
||||||
expect(actualSlots[game]).toEqual(EXPECTED_SLOTS[game]);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
it("fills every slot in the bracket exactly once", () => {
|
|
||||||
// 38 games × 2 slots = 76. 20 are seeded directly (16 opening teams + 4 byes),
|
|
||||||
// leaving 56 to be filled by advancement.
|
|
||||||
const filled = Object.values(actualSlots)
|
|
||||||
.flat()
|
|
||||||
.filter((s) => s !== null).length;
|
|
||||||
expect(filled).toBe(56);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Double-elimination loser routing", () => {
|
|
||||||
it("routes every winners-bracket loser into the elimination bracket", () => {
|
|
||||||
const winnersRounds = [
|
|
||||||
"Opening Round",
|
|
||||||
"Winners Round 2",
|
|
||||||
"Winners Semifinals",
|
|
||||||
"Winners Final",
|
|
||||||
];
|
|
||||||
for (const roundName of winnersRounds) {
|
|
||||||
const round = LLWS_20.rounds.find((r) => r.name === roundName);
|
|
||||||
if (!round) throw new Error(`missing round ${roundName}`);
|
|
||||||
for (let n = 1; n <= round.matchCount; n++) {
|
|
||||||
const { loser } = resolveLLWSAdvancement(roundName, n);
|
|
||||||
expect(loser, `${roundName} #${n} loser should advance`).not.toBeNull();
|
|
||||||
expect(loser?.round.startsWith("Elimination")).toBe(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("eliminates every elimination-bracket loser", () => {
|
|
||||||
const elimRounds = [
|
|
||||||
"Elimination Round 1",
|
|
||||||
"Elimination Round 2",
|
|
||||||
"Elimination Round 3",
|
|
||||||
"Elimination Round 4",
|
|
||||||
"Elimination Final",
|
|
||||||
];
|
|
||||||
for (const roundName of elimRounds) {
|
|
||||||
const round = LLWS_20.rounds.find((r) => r.name === roundName);
|
|
||||||
if (!round) throw new Error(`missing round ${roundName}`);
|
|
||||||
for (let n = 1; n <= round.matchCount; n++) {
|
|
||||||
const { loser } = resolveLLWSAdvancement(roundName, n);
|
|
||||||
expect(loser, `${roundName} #${n} loser should be out`).toBeNull();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the winners-bracket final loser alive via the Elimination Final", () => {
|
|
||||||
// G30 (U.S. Winners Final) loser → G34, not out. This is the defining
|
|
||||||
// double-elimination behavior: a first loss never eliminates.
|
|
||||||
const { winner, loser } = resolveLLWSAdvancement("Winners Final", 1);
|
|
||||||
expect(destinationGame(loser)).toBe(34);
|
|
||||||
expect(destinationGame(winner)).toBe(36);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sends the side-championship loser to the consolation game, not out", () => {
|
|
||||||
// No "if necessary" rematch: the winners-bracket champion that loses G36 is
|
|
||||||
// done in the bracket, but still plays G37 for 3rd/4th.
|
|
||||||
const us = resolveLLWSAdvancement("Bracket Championship", 1);
|
|
||||||
expect(destinationGame(us.winner)).toBe(38);
|
|
||||||
expect(destinationGame(us.loser)).toBe(37);
|
|
||||||
expect(required(us.winner).slot).toBe("participant1Id");
|
|
||||||
expect(required(us.loser).slot).toBe("participant1Id");
|
|
||||||
|
|
||||||
const intl = resolveLLWSAdvancement("Bracket Championship", 2);
|
|
||||||
expect(required(intl.winner).slot).toBe("participant2Id");
|
|
||||||
expect(required(intl.loser).slot).toBe("participant2Id");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("flags winners-bracket losers as advancing so they are not marked eliminated", () => {
|
|
||||||
// doesLoserAdvance is what stops the scoring engine writing a 0-point
|
|
||||||
// elimination (and announcing a knockout) for a team that is still alive.
|
|
||||||
// Winners Final and Bracket Championship are scoring rounds and are covered
|
|
||||||
// by loserIsPartial instead, so they are deliberately not listed here.
|
|
||||||
for (const round of ["Opening Round", "Winners Round 2", "Winners Semifinals"]) {
|
|
||||||
expect(doesLoserAdvance(round, 1, "llws_20"), round).toBe(true);
|
|
||||||
}
|
|
||||||
for (const round of [
|
|
||||||
"Elimination Round 1",
|
|
||||||
"Elimination Round 2",
|
|
||||||
"Elimination Round 3",
|
|
||||||
"Elimination Round 4",
|
|
||||||
"Elimination Final",
|
|
||||||
]) {
|
|
||||||
expect(doesLoserAdvance(round, 1, "llws_20"), round).toBe(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not apply LLWS loser routing to other templates", () => {
|
|
||||||
expect(doesLoserAdvance("Opening Round", 1, "ncaa_68")).toBe(false);
|
|
||||||
expect(doesLoserAdvance("Winners Semifinals", 1, "")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("advances nobody out of the two final games", () => {
|
|
||||||
for (const round of ["Consolation Third Place", "World Championship"]) {
|
|
||||||
expect(resolveLLWSAdvancement(round, 1)).toEqual({ winner: null, loser: null });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("never crosses a team between the U.S. and International sides", () => {
|
|
||||||
for (const round of LLWS_20.rounds) {
|
|
||||||
if (round.name === "Bracket Championship") continue; // the crossover point
|
|
||||||
if (round.matchCount === 1) continue;
|
|
||||||
for (let n = 1; n <= round.matchCount; n++) {
|
|
||||||
const { side } = llwsSideAndLocal(round.name, n);
|
|
||||||
const { winner, loser } = resolveLLWSAdvancement(round.name, n);
|
|
||||||
for (const dest of [winner, loser]) {
|
|
||||||
if (!dest) continue;
|
|
||||||
const destRound = LLWS_20.rounds.find((r) => r.name === dest.round);
|
|
||||||
if (!destRound || destRound.matchCount === 1) continue;
|
|
||||||
expect(llwsSideAndLocal(dest.round, dest.matchNumber).side).toBe(side);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Placement tiers", () => {
|
|
||||||
it("classifies scoring rounds correctly", () => {
|
|
||||||
expect(getScoringRoundType("Elimination Round 4", LLWS_20)).toBe("quarterfinals");
|
|
||||||
expect(getScoringRoundType("Elimination Final", LLWS_20)).toBe("quarterfinals");
|
|
||||||
expect(getScoringRoundType("Bracket Championship", LLWS_20)).toBe("semifinals");
|
|
||||||
expect(getScoringRoundType("World Championship", LLWS_20)).toBe("finals");
|
|
||||||
// Nobody is eliminated in the Winners Final — the loser drops to the
|
|
||||||
// elimination bracket — so it has no placement tier.
|
|
||||||
expect(getScoringRoundType("Winners Final", LLWS_20)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("pays 3rd and 4th distinctly (there is a real consolation game)", () => {
|
|
||||||
expect(calculateBracketPoints(3, DEFAULT_SCORING, "llws_20")).toBe(50);
|
|
||||||
expect(calculateBracketPoints(4, DEFAULT_SCORING, "llws_20")).toBe(40);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("splits 5–8 into two two-team tiers", () => {
|
|
||||||
const upper = calculateAveragedPoints([5, 6], DEFAULT_SCORING); // (25+20)/2
|
|
||||||
const lower = calculateAveragedPoints([7, 8], DEFAULT_SCORING); // (15+10)/2
|
|
||||||
expect(calculateBracketPoints(5, DEFAULT_SCORING, "llws_20")).toBe(upper);
|
|
||||||
expect(calculateBracketPoints(6, DEFAULT_SCORING, "llws_20")).toBe(upper);
|
|
||||||
expect(calculateBracketPoints(7, DEFAULT_SCORING, "llws_20")).toBe(lower);
|
|
||||||
expect(calculateBracketPoints(8, DEFAULT_SCORING, "llws_20")).toBe(lower);
|
|
||||||
// Surviving Elimination Round 4 is worth more than losing it.
|
|
||||||
expect(upper).toBeGreaterThan(lower);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("awards nothing below 8th", () => {
|
|
||||||
// The 12 teams knocked out in Elimination Rounds 1–3 finish 9th–20th.
|
|
||||||
expect(calculateBracketPoints(9, DEFAULT_SCORING, "llws_20")).toBe(0);
|
|
||||||
expect(calculateBracketPoints(0, DEFAULT_SCORING, "llws_20")).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("has exactly 8 teams alive when the first scoring elimination game is played", () => {
|
|
||||||
// Elimination Round 4 is the 7th–8th tier, so the field must be 8 at that point:
|
|
||||||
// per side the Winners Final winner, the Winners Final loser, and the two
|
|
||||||
// Elimination Round 3 winners.
|
|
||||||
const eliminatedBeforeElimR4 =
|
|
||||||
(LLWS_20.rounds.find((r) => r.name === "Elimination Round 1")?.matchCount ?? 0) +
|
|
||||||
(LLWS_20.rounds.find((r) => r.name === "Elimination Round 2")?.matchCount ?? 0) +
|
|
||||||
(LLWS_20.rounds.find((r) => r.name === "Elimination Round 3")?.matchCount ?? 0);
|
|
||||||
expect(eliminatedBeforeElimR4).toBe(12);
|
|
||||||
expect(LLWS_20.totalTeams - eliminatedBeforeElimR4).toBe(8);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
|
|
||||||
// populateBracketFromDraw upserts a full draw into playoff_matches and reports
|
|
||||||
// which losers' matches *transitioned to complete on this run* — the signal used
|
|
||||||
// to announce knockouts once, idempotently across re-syncs.
|
|
||||||
|
|
||||||
const existingRows: Array<Record<string, unknown>> = [];
|
|
||||||
|
|
||||||
const mockDb = {
|
|
||||||
query: {
|
|
||||||
playoffMatches: {
|
|
||||||
findMany: vi.fn(async () => existingRows),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
update: vi.fn(() => ({
|
|
||||||
set: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })),
|
|
||||||
})),
|
|
||||||
insert: vi.fn(() => ({
|
|
||||||
values: vi.fn(() => ({ returning: vi.fn().mockResolvedValue([]) })),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mock("~/database/context", () => ({ database: () => mockDb }));
|
|
||||||
|
|
||||||
import { populateBracketFromDraw, type ResolvedDrawMatch } from "../playoff-match";
|
|
||||||
|
|
||||||
const EVENT_ID = "ev-1";
|
|
||||||
|
|
||||||
function match(overrides: Partial<ResolvedDrawMatch> = {}): ResolvedDrawMatch {
|
|
||||||
return {
|
|
||||||
externalMatchId: "m-1",
|
|
||||||
round: "Round of 64",
|
|
||||||
matchNumber: 1,
|
|
||||||
participant1Id: "winner",
|
|
||||||
participant2Id: "loser",
|
|
||||||
winnerId: "winner",
|
|
||||||
loserId: "loser",
|
|
||||||
isScoring: false,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
existingRows.length = 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("populateBracketFromDraw newlyDecidedLoserIds", () => {
|
|
||||||
it("reports the loser of a brand-new completed match", async () => {
|
|
||||||
const { newlyDecidedLoserIds } = await populateBracketFromDraw(EVENT_ID, [
|
|
||||||
match({ externalMatchId: "m-1", loserId: "mensik" }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(newlyDecidedLoserIds).toEqual(["mensik"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports the loser of an existing match that just reached completion", async () => {
|
|
||||||
existingRows.push({
|
|
||||||
id: "row-1",
|
|
||||||
externalMatchId: "m-1",
|
|
||||||
isComplete: false,
|
|
||||||
loserId: null,
|
|
||||||
winnerId: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { newlyDecidedLoserIds } = await populateBracketFromDraw(EVENT_ID, [
|
|
||||||
match({ externalMatchId: "m-1", loserId: "mensik" }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(newlyDecidedLoserIds).toEqual(["mensik"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports nothing on an idempotent re-sync of an already-complete match", async () => {
|
|
||||||
existingRows.push({
|
|
||||||
id: "row-1",
|
|
||||||
externalMatchId: "m-1",
|
|
||||||
isComplete: true,
|
|
||||||
loserId: "mensik",
|
|
||||||
winnerId: "winner",
|
|
||||||
});
|
|
||||||
|
|
||||||
const { newlyDecidedLoserIds } = await populateBracketFromDraw(EVENT_ID, [
|
|
||||||
match({ externalMatchId: "m-1", loserId: "mensik" }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(newlyDecidedLoserIds).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores incomplete matches (no winner/loser yet)", async () => {
|
|
||||||
const { newlyDecidedLoserIds, completed } = await populateBracketFromDraw(EVENT_ID, [
|
|
||||||
match({ externalMatchId: "m-2", winnerId: null, loserId: null }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(newlyDecidedLoserIds).toEqual([]);
|
|
||||||
expect(completed).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("collects only the newly-decided losers in a mixed batch", async () => {
|
|
||||||
existingRows.push(
|
|
||||||
{ id: "row-1", externalMatchId: "m-1", isComplete: true, loserId: "old", winnerId: "w1" },
|
|
||||||
{ id: "row-2", externalMatchId: "m-2", isComplete: false, loserId: null, winnerId: null },
|
|
||||||
);
|
|
||||||
|
|
||||||
const { newlyDecidedLoserIds } = await populateBracketFromDraw(EVENT_ID, [
|
|
||||||
match({ externalMatchId: "m-1", loserId: "old" }), // already complete → skip
|
|
||||||
match({ externalMatchId: "m-2", loserId: "freshly-out" }), // transitioned → include
|
|
||||||
match({ externalMatchId: "m-3", loserId: "brand-new-out" }), // new complete → include
|
|
||||||
match({ externalMatchId: "m-4", winnerId: null, loserId: null }), // incomplete → skip
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(newlyDecidedLoserIds.toSorted()).toEqual(["brand-new-out", "freshly-out"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -190,26 +190,12 @@ describe("processMatchResult", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("AFL Wildcard Round: loser=0, winner gets T7 floor (a Wildcard win only buys an Elimination Final)", async () => {
|
it("AFL Wildcard Round: loser=0, winner gets T5 floor (feeds into Elimination Finals = scoring)", 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: 7, isPartialScore: true });
|
expect(insertedRows[1]).toMatchObject({ participantId: "winner-1", finalPosition: 5, 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)", () => {
|
||||||
|
|
@ -319,22 +305,6 @@ 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")
|
||||||
|
|
@ -429,7 +399,6 @@ 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 }> = [];
|
||||||
|
|
|
||||||
|
|
@ -1,251 +0,0 @@
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import {
|
|
||||||
deriveBracketQualifyingStates,
|
|
||||||
getRoundConfig,
|
|
||||||
getGuaranteedMinimumPosition,
|
|
||||||
type BracketMatchInput,
|
|
||||||
} from "../scoring-calculator";
|
|
||||||
import { BRACKET_TEMPLATES } from "../../lib/bracket-templates";
|
|
||||||
import { calculateSplitQualifyingPoints, DEFAULT_QP_VALUES } from "../qualifying-points";
|
|
||||||
|
|
||||||
// ── Test fixtures ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const SIMPLE_8 = BRACKET_TEMPLATES["simple_8"];
|
|
||||||
const getConfig = (round: string) => getRoundConfig(round, "simple_8");
|
|
||||||
const states = (matches: BracketMatchInput[]) =>
|
|
||||||
deriveBracketQualifyingStates(matches, SIMPLE_8.rounds, getConfig);
|
|
||||||
|
|
||||||
const QP_MAP = new Map<number, number>(
|
|
||||||
DEFAULT_QP_VALUES.map((v) => [v.placement, v.points])
|
|
||||||
);
|
|
||||||
const qpFor = (placement: number, tieCount: number) =>
|
|
||||||
calculateSplitQualifyingPoints(placement, tieCount, QP_MAP);
|
|
||||||
|
|
||||||
/** Helper to build a played match. */
|
|
||||||
const played = (
|
|
||||||
round: string,
|
|
||||||
winnerId: string,
|
|
||||||
loserId: string
|
|
||||||
): BracketMatchInput => ({
|
|
||||||
round,
|
|
||||||
winnerId,
|
|
||||||
loserId,
|
|
||||||
participant1Id: winnerId,
|
|
||||||
participant2Id: loserId,
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Helper to build an as-yet-unplayed match (participants seeded, no result). */
|
|
||||||
const pending = (
|
|
||||||
round: string,
|
|
||||||
p1: string,
|
|
||||||
p2: string
|
|
||||||
): BracketMatchInput => ({
|
|
||||||
round,
|
|
||||||
winnerId: null,
|
|
||||||
loserId: null,
|
|
||||||
participant1Id: p1,
|
|
||||||
participant2Id: p2,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Full simple_8 quarterfinal field: winners t1,t2,t3,t4 / losers t5,t6,t7,t8.
|
|
||||||
const QF_ALL = [
|
|
||||||
played("Quarterfinals", "t1", "t8"),
|
|
||||||
played("Quarterfinals", "t2", "t7"),
|
|
||||||
played("Quarterfinals", "t3", "t6"),
|
|
||||||
played("Quarterfinals", "t4", "t5"),
|
|
||||||
];
|
|
||||||
// Semifinals: t1 & t3 advance.
|
|
||||||
const SF_ALL = [
|
|
||||||
played("Semifinals", "t1", "t2"),
|
|
||||||
played("Semifinals", "t3", "t4"),
|
|
||||||
];
|
|
||||||
// Final: t1 champion.
|
|
||||||
const FINAL = [played("Finals", "t1", "t3")];
|
|
||||||
|
|
||||||
// ── deriveBracketQualifyingStates ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("deriveBracketQualifyingStates (simple_8)", () => {
|
|
||||||
it("returns an empty map for no matches", () => {
|
|
||||||
expect(states([]).size).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("QF entered: losers lock T5–8, winners floor at T3–4", () => {
|
|
||||||
const s = states(QF_ALL);
|
|
||||||
// Losers — final placement 5, tie span 4 (T5–8).
|
|
||||||
for (const id of ["t5", "t6", "t7", "t8"]) {
|
|
||||||
expect(s.get(id)).toEqual({ placement: 5, tieCount: 4 });
|
|
||||||
}
|
|
||||||
// Winners — guaranteed at least T3–4: floor placement 3, tie span 2.
|
|
||||||
for (const id of ["t1", "t2", "t3", "t4"]) {
|
|
||||||
expect(s.get(id)).toEqual({ placement: 3, tieCount: 2 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("QF+SF entered: SF losers lock T3–4, SF winners floor at 2nd", () => {
|
|
||||||
const s = states([...QF_ALL, ...SF_ALL]);
|
|
||||||
// SF losers — final placement 3, tie span 2.
|
|
||||||
expect(s.get("t2")).toEqual({ placement: 3, tieCount: 2 });
|
|
||||||
expect(s.get("t4")).toEqual({ placement: 3, tieCount: 2 });
|
|
||||||
// SF winners (finalists-in-waiting) — floor placement 2, tie span 1.
|
|
||||||
expect(s.get("t1")).toEqual({ placement: 2, tieCount: 1 });
|
|
||||||
expect(s.get("t3")).toEqual({ placement: 2, tieCount: 1 });
|
|
||||||
// QF losers unchanged.
|
|
||||||
expect(s.get("t8")).toEqual({ placement: 5, tieCount: 4 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("full bracket: champion 1st, finalist 2nd, SF losers T3–4, QF losers T5–8", () => {
|
|
||||||
const s = states([...QF_ALL, ...SF_ALL, ...FINAL]);
|
|
||||||
expect(s.get("t1")).toEqual({ placement: 1, tieCount: 1 }); // champion
|
|
||||||
expect(s.get("t3")).toEqual({ placement: 2, tieCount: 1 }); // finalist
|
|
||||||
expect(s.get("t2")).toEqual({ placement: 3, tieCount: 2 }); // SF loser
|
|
||||||
expect(s.get("t4")).toEqual({ placement: 3, tieCount: 2 }); // SF loser
|
|
||||||
expect(s.get("t5")).toEqual({ placement: 5, tieCount: 4 }); // QF loser
|
|
||||||
});
|
|
||||||
|
|
||||||
it("partial QF (2 of 4 played): unplayed teams sit at the T5–8 entry floor", () => {
|
|
||||||
const matches = [
|
|
||||||
played("Quarterfinals", "t1", "t8"),
|
|
||||||
played("Quarterfinals", "t4", "t5"),
|
|
||||||
pending("Quarterfinals", "t3", "t6"),
|
|
||||||
pending("Quarterfinals", "t2", "t7"),
|
|
||||||
];
|
|
||||||
const s = states(matches);
|
|
||||||
// Played winners floor at T3–4.
|
|
||||||
expect(s.get("t1")).toEqual({ placement: 3, tieCount: 2 });
|
|
||||||
expect(s.get("t4")).toEqual({ placement: 3, tieCount: 2 });
|
|
||||||
// Played losers lock T5–8.
|
|
||||||
expect(s.get("t8")).toEqual({ placement: 5, tieCount: 4 });
|
|
||||||
expect(s.get("t5")).toEqual({ placement: 5, tieCount: 4 });
|
|
||||||
// Unplayed teams: entry floor T5–8 (no crash on incomplete round).
|
|
||||||
for (const id of ["t2", "t3", "t6", "t7"]) {
|
|
||||||
expect(s.get(id)).toEqual({ placement: 5, tieCount: 4 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("un-setting the Finals winner reverts both finalists to the 2nd-place floor", () => {
|
|
||||||
const matches = [
|
|
||||||
...QF_ALL,
|
|
||||||
...SF_ALL,
|
|
||||||
pending("Finals", "t1", "t3"), // winner cleared
|
|
||||||
];
|
|
||||||
const s = states(matches);
|
|
||||||
expect(s.get("t1")).toEqual({ placement: 2, tieCount: 1 });
|
|
||||||
expect(s.get("t3")).toEqual({ placement: 2, tieCount: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a team eliminated in the QF never gains a later-round floor", () => {
|
|
||||||
const s = states([...QF_ALL, ...SF_ALL, ...FINAL]);
|
|
||||||
// t8 lost in the QF and stays T5–8 through the rest of the bracket.
|
|
||||||
expect(s.get("t8")).toEqual({ placement: 5, tieCount: 4 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is idempotent — same matches yield the same states", () => {
|
|
||||||
const a = states([...QF_ALL, ...SF_ALL]);
|
|
||||||
const b = states([...QF_ALL, ...SF_ALL]);
|
|
||||||
expect([...a.entries()].toSorted()).toEqual([...b.entries()].toSorted());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── tennis_128: scoring starts deep (Round of 16), not at round 1 ─────────────
|
|
||||||
|
|
||||||
describe("deriveBracketQualifyingStates (tennis_128 — deep scoring start)", () => {
|
|
||||||
const TENNIS = BRACKET_TEMPLATES["tennis_128"];
|
|
||||||
const tStates = (matches: BracketMatchInput[]) =>
|
|
||||||
deriveBracketQualifyingStates(matches, TENNIS.rounds, (round) =>
|
|
||||||
getRoundConfig(round, "tennis_128"),
|
|
||||||
);
|
|
||||||
|
|
||||||
it("awards NO QP to a player who lost before the Round of 16", () => {
|
|
||||||
const s = tStates([
|
|
||||||
played("Round of 128", "winner", "earlyLoser"),
|
|
||||||
]);
|
|
||||||
expect(s.has("earlyLoser")).toBe(false); // 0 QP — didn't reach scoring
|
|
||||||
});
|
|
||||||
|
|
||||||
it("awards NO QP just for winning a pre-scoring round (still short of R16)", () => {
|
|
||||||
// Won R128, next match (R64) not played → not yet in the Round of 16.
|
|
||||||
const s = tStates([played("Round of 128", "p", "x")]);
|
|
||||||
expect(s.has("p")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("gives NO entry floor to undrawn/unplayed players (the 9th-place bug)", () => {
|
|
||||||
const s = tStates([
|
|
||||||
pending("Round of 128", "a", "b"),
|
|
||||||
pending("Round of 128", "c", "d"),
|
|
||||||
]);
|
|
||||||
expect(s.size).toBe(0); // nobody has earned anything yet
|
|
||||||
});
|
|
||||||
|
|
||||||
it("floors a player who reached the Round of 16 (won R32) at T9–16", () => {
|
|
||||||
// a wins R128 → R64 → R32, so a is now IN the Round of 16 (not yet played).
|
|
||||||
const s = tStates([
|
|
||||||
played("Round of 128", "a", "x1"),
|
|
||||||
played("Round of 64", "a", "x2"),
|
|
||||||
played("Round of 32", "a", "x3"),
|
|
||||||
]);
|
|
||||||
expect(s.get("a")).toEqual({ placement: 9, tieCount: 8 });
|
|
||||||
// The players a beat in non-scoring rounds earn nothing.
|
|
||||||
expect(s.has("x1")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("locks an actual Round of 16 loser at T9–16 and floors the winner at T5–8", () => {
|
|
||||||
const s = tStates([
|
|
||||||
played("Round of 16", "r16winner", "r16loser"),
|
|
||||||
]);
|
|
||||||
expect(s.get("r16loser")).toEqual({ placement: 9, tieCount: 8 });
|
|
||||||
expect(s.get("r16winner")).toEqual({ placement: 5, tieCount: 4 }); // QF floor
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── QP values (the worked example) ────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("guaranteed-minimum QP per outcome (default config)", () => {
|
|
||||||
it("matches the worked example: QF win → 9, SF win → 14, Final win → 20", () => {
|
|
||||||
expect(qpFor(3, 2)).toBeCloseTo(9); // QF winner, guaranteed T3–4
|
|
||||||
expect(qpFor(2, 1)).toBeCloseTo(14); // SF winner, guaranteed 2nd
|
|
||||||
expect(qpFor(1, 1)).toBeCloseTo(20); // champion
|
|
||||||
});
|
|
||||||
|
|
||||||
it("losers: QF loss → 4, SF loss → 9, Final loss → 14", () => {
|
|
||||||
expect(qpFor(5, 4)).toBeCloseTo(4); // (5+5+3+3)/4
|
|
||||||
expect(qpFor(3, 2)).toBeCloseTo(9); // (10+8)/2
|
|
||||||
expect(qpFor(2, 1)).toBeCloseTo(14);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("end-to-end: a QF win immediately yields a 9 QP floor", () => {
|
|
||||||
const s = states(QF_ALL);
|
|
||||||
expect(s.get("t1")).toEqual({ placement: 3, tieCount: 2 });
|
|
||||||
expect(qpFor(3, 2)).toBeCloseTo(9);
|
|
||||||
expect(s.get("t8")).toEqual({ placement: 5, tieCount: 4 });
|
|
||||||
expect(qpFor(5, 4)).toBeCloseTo(4);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Regression guard: structural tieCount, not live count (the #1 fix) ─────────
|
|
||||||
|
|
||||||
describe("structural tie span vs. live-count regrouping", () => {
|
|
||||||
it("4 QF winners share placement 3 but each keeps the 2-slot (9 QP) floor", () => {
|
|
||||||
const s = states(QF_ALL);
|
|
||||||
// All four QF winners sit at placement 3, but with the STRUCTURAL tie span of 2.
|
|
||||||
for (const id of ["t1", "t2", "t3", "t4"]) {
|
|
||||||
expect(s.get(id)).toEqual({ placement: 3, tieCount: 2 });
|
|
||||||
}
|
|
||||||
expect(qpFor(3, 2)).toBeCloseTo(9);
|
|
||||||
// A naive regroup by LIVE count (4 rows at placement 3) would average over
|
|
||||||
// slots 3–6 and wrongly yield 7 QP — the bug processQualifyingEvent must avoid
|
|
||||||
// by delegating bracket events to the structural-tieCount path.
|
|
||||||
expect(qpFor(3, 4)).toBeCloseTo(7);
|
|
||||||
expect(qpFor(3, 4)).not.toBeCloseTo(9);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Regression guard: reused ROUND_CONFIG floors are intact ────────────────────
|
|
||||||
|
|
||||||
describe("ROUND_CONFIG floors (regression guard)", () => {
|
|
||||||
it("simple_8 Quarterfinals still floors winners at 3rd", () => {
|
|
||||||
expect(getGuaranteedMinimumPosition("Quarterfinals", "simple_8", true)).toBe(3);
|
|
||||||
expect(getRoundConfig("Quarterfinals", "simple_8")?.loserPosition).toBe(5);
|
|
||||||
expect(getRoundConfig("Semifinals", "simple_8")?.winnerFloor).toBe(2);
|
|
||||||
expect(getRoundConfig("Finals", "simple_8")?.winnerFloor).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -2,47 +2,9 @@ import { describe, it, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
calculateSplitQualifyingPoints,
|
calculateSplitQualifyingPoints,
|
||||||
DEFAULT_QP_VALUES,
|
DEFAULT_QP_VALUES,
|
||||||
diffChangedQualifyingPoints,
|
hasProcessedQualifyingPlacement,
|
||||||
} 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", () => {
|
||||||
|
|
@ -309,6 +271,52 @@ 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", () => {
|
||||||
|
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import type * as QualifyingPointsModule from "~/models/qualifying-points";
|
|
||||||
|
|
||||||
// Spy on the QP Discord notification, and no-op the per-participant total recalc
|
|
||||||
// (its own eventResults query would otherwise interfere with the before/after
|
|
||||||
// snapshot counter below). Everything else in the scorer runs for real.
|
|
||||||
vi.mock("~/services/qualifying-points-discord.server", () => ({
|
|
||||||
notifyQualifyingPointsUpdate: vi.fn().mockResolvedValue(undefined),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("~/models/qualifying-points", async (importActual) => {
|
|
||||||
const actual = await importActual<typeof QualifyingPointsModule>();
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
recalculateParticipantQP: vi.fn().mockResolvedValue({ totalQP: 0, eventsScored: 0 }),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
import { processQualifyingEvent } from "../scoring-calculator";
|
|
||||||
import { DEFAULT_QP_VALUES } from "../qualifying-points";
|
|
||||||
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
|
|
||||||
|
|
||||||
const EVENT_ID = "event-1";
|
|
||||||
const SPORTS_SEASON_ID = "sports-season-1";
|
|
||||||
|
|
||||||
type ResultRow = {
|
|
||||||
id: string;
|
|
||||||
seasonParticipantId: string;
|
|
||||||
placement: number | null;
|
|
||||||
qualifyingPointsAwarded: string | null;
|
|
||||||
scoringEvent: { id: string; sportsSeasonId: string };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mock db for the non-bracket `processQualifyingEvent` path. `eventResults.findMany`
|
|
||||||
* returns `before` on its first call (the initial getEventResults snapshot) and
|
|
||||||
* `after` on its second (the post-reprocess re-query that drives change detection).
|
|
||||||
* Awarding updates go to a no-op update chain — `after` stands in for the DB state
|
|
||||||
* once the scorer has written its QP.
|
|
||||||
*/
|
|
||||||
function makeDb(before: ResultRow[], after: ResultRow[]) {
|
|
||||||
const results = [before, after];
|
|
||||||
let findManyCall = 0;
|
|
||||||
return {
|
|
||||||
query: {
|
|
||||||
scoringEvents: {
|
|
||||||
findFirst: async () => ({
|
|
||||||
id: EVENT_ID,
|
|
||||||
sportsSeasonId: SPORTS_SEASON_ID,
|
|
||||||
isQualifyingEvent: true,
|
|
||||||
bracketTemplateId: null,
|
|
||||||
sportsSeason: { majorsCompleted: 1 },
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
eventResults: {
|
|
||||||
findMany: async () => results[Math.min(findManyCall++, 1)],
|
|
||||||
},
|
|
||||||
qualifyingPointConfig: {
|
|
||||||
findMany: async () =>
|
|
||||||
DEFAULT_QP_VALUES.map((qp) => ({ placement: qp.placement, points: qp.points.toString() })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
update: () => ({ set: () => ({ where: async () => [] }) }),
|
|
||||||
} as never;
|
|
||||||
}
|
|
||||||
|
|
||||||
const scoringEvent = { id: EVENT_ID, sportsSeasonId: SPORTS_SEASON_ID };
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("processQualifyingEvent — QP change notification", () => {
|
|
||||||
it("notifies only participants whose QP changed (first-time score: null → value)", async () => {
|
|
||||||
const db = makeDb(
|
|
||||||
[
|
|
||||||
{ id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: null, scoringEvent },
|
|
||||||
{ id: "r2", seasonParticipantId: "p2", placement: 20, qualifyingPointsAwarded: "0.00", scoringEvent },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: "100.00", scoringEvent },
|
|
||||||
{ id: "r2", seasonParticipantId: "p2", placement: 20, qualifyingPointsAwarded: "0.00", scoringEvent },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
await processQualifyingEvent(EVENT_ID, db);
|
|
||||||
|
|
||||||
expect(notifyQualifyingPointsUpdate).toHaveBeenCalledTimes(1);
|
|
||||||
const [ssId, evId, , filter] = vi.mocked(notifyQualifyingPointsUpdate).mock.calls[0];
|
|
||||||
expect(ssId).toBe(SPORTS_SEASON_ID);
|
|
||||||
expect(evId).toBe(EVENT_ID);
|
|
||||||
// p1 went null → 100 (changed); p2 stayed at 0 (unchanged).
|
|
||||||
expect([...(filter as Set<string>)]).toEqual(["p1"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not notify when a re-sync produces identical QP", async () => {
|
|
||||||
const rows: ResultRow[] = [
|
|
||||||
{ id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: "100.00", scoringEvent },
|
|
||||||
{ id: "r2", seasonParticipantId: "p2", placement: 20, qualifyingPointsAwarded: "0.00", scoringEvent },
|
|
||||||
];
|
|
||||||
const db = makeDb(rows, rows.map((r) => ({ ...r })));
|
|
||||||
|
|
||||||
await processQualifyingEvent(EVENT_ID, db);
|
|
||||||
|
|
||||||
expect(notifyQualifyingPointsUpdate).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("announces newly-eliminated players even when no QP changed (mirror non-scoring-round exit)", async () => {
|
|
||||||
// A mirror window re-scored on fan-out: nobody's QP changed, but the primary
|
|
||||||
// bracket reports p2 knocked out in a non-scoring round. The notification must
|
|
||||||
// still fire, passing the eliminated id through as the 5th arg so the "Knocked
|
|
||||||
// Out" section isn't dropped on the mirror.
|
|
||||||
const rows: ResultRow[] = [
|
|
||||||
{ id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: "100.00", scoringEvent },
|
|
||||||
{ id: "r2", seasonParticipantId: "p2", placement: null, qualifyingPointsAwarded: "0.00", scoringEvent },
|
|
||||||
];
|
|
||||||
const db = makeDb(rows, rows.map((r) => ({ ...r })));
|
|
||||||
|
|
||||||
await processQualifyingEvent(EVENT_ID, db, {
|
|
||||||
newlyEliminatedParticipantIds: new Set(["p2"]),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(notifyQualifyingPointsUpdate).toHaveBeenCalledTimes(1);
|
|
||||||
const [, , , changed, eliminated] = vi.mocked(notifyQualifyingPointsUpdate).mock.calls[0];
|
|
||||||
// No QP change this sync.
|
|
||||||
expect([...(changed as Set<string>)]).toEqual([]);
|
|
||||||
// The knocked-out player is forwarded to the notifier.
|
|
||||||
expect([...(eliminated as Set<string>)]).toEqual(["p2"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not notify on a re-sync with no QP change and no eliminations", async () => {
|
|
||||||
const rows: ResultRow[] = [
|
|
||||||
{ id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: "100.00", scoringEvent },
|
|
||||||
];
|
|
||||||
const db = makeDb(rows, rows.map((r) => ({ ...r })));
|
|
||||||
|
|
||||||
await processQualifyingEvent(EVENT_ID, db, {
|
|
||||||
newlyEliminatedParticipantIds: new Set(),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(notifyQualifyingPointsUpdate).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -200,21 +200,7 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("processQualifyingEvent", () => {
|
describe("processQualifyingEvent", () => {
|
||||||
function makeProcessQPMockDb(
|
function makeProcessQPMockDb(eventResults: Array<Record<string, unknown>>) {
|
||||||
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) => {
|
||||||
|
|
@ -231,8 +217,6 @@ 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 },
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
@ -254,23 +238,6 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Two select shapes flow through the non-bracket path:
|
|
||||||
// • playoff-match existence check (has an `id` projection, uses .limit(1))
|
|
||||||
// • canonical tournament_results tie span (has a `placement` projection).
|
|
||||||
// Discriminate by the projection keys and return a thenable that also
|
|
||||||
// exposes .limit so both call shapes resolve.
|
|
||||||
select: (fields: Record<string, unknown>) => {
|
|
||||||
const isPlayoff = fields && "id" in fields;
|
|
||||||
const rows = isPlayoff
|
|
||||||
? playoffMatchIds.map((id) => ({ id }))
|
|
||||||
: canonicalPlacements.map((placement) => ({ placement }));
|
|
||||||
return {
|
|
||||||
from: () => ({
|
|
||||||
where: () =>
|
|
||||||
Object.assign(Promise.resolve(rows), { limit: async () => rows }),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
update: () => updateChain,
|
update: () => updateChain,
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
|
|
@ -306,123 +273,6 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("splits a tied placement by the FULL canonical field, not the window's roster subset", async () => {
|
|
||||||
// Regression for the reported 2-vs-1.5 QP bug. Tennis Round-of-16 losers all
|
|
||||||
// land at placement 9 with a structural tie span of 8 (positions 9–16 →
|
|
||||||
// (2+2+2+2+1+1+1+1)/8 = 1.5). A sibling/mirror window only holds the drafted
|
|
||||||
// subset — here just 2 of the 8 tied players — but the split must still use 8,
|
|
||||||
// not the 2 present locally (which would wrongly give (2+2)/2 = 2.00).
|
|
||||||
const windowRows = [
|
|
||||||
{
|
|
||||||
id: "result-1",
|
|
||||||
seasonParticipantId: "participant-1",
|
|
||||||
placement: 9,
|
|
||||||
qualifyingPointsAwarded: null,
|
|
||||||
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "result-2",
|
|
||||||
seasonParticipantId: "participant-2",
|
|
||||||
placement: 9,
|
|
||||||
qualifyingPointsAwarded: null,
|
|
||||||
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const { db, setCalls } = makeProcessQPMockDb(windowRows, {
|
|
||||||
tournamentId: "tournament-1",
|
|
||||||
canonicalPlacements: Array.from({ length: 8 }, () => 9), // full field: 8 at 9th
|
|
||||||
});
|
|
||||||
|
|
||||||
await processQualifyingEvent("event-1", db, { skipNotifications: true });
|
|
||||||
|
|
||||||
// Both present players earn the correct split of 1.50, not 2.00.
|
|
||||||
expect(setCalls).toEqual(
|
|
||||||
expect.arrayContaining([
|
|
||||||
expect.objectContaining({ qualifyingPointsAwarded: "1.50" }),
|
|
||||||
])
|
|
||||||
);
|
|
||||||
expect(setCalls).not.toEqual(
|
|
||||||
expect.arrayContaining([
|
|
||||||
expect.objectContaining({ qualifyingPointsAwarded: "2.00" }),
|
|
||||||
])
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("scores a cloned window (bracketTemplateId but no playoff matches) via the canonical path", async () => {
|
|
||||||
// cloneSportsSeason copies bracketTemplateId to league windows but not the
|
|
||||||
// playoff matches. Such a window must NOT take the bracket branch (which would
|
|
||||||
// derive zero states and write no QP) — it has to fall through to the
|
|
||||||
// placement/canonical path and still split R16 losers to 1.5.
|
|
||||||
const windowRows = [
|
|
||||||
{
|
|
||||||
id: "result-1",
|
|
||||||
seasonParticipantId: "participant-1",
|
|
||||||
placement: 9,
|
|
||||||
qualifyingPointsAwarded: null,
|
|
||||||
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "result-2",
|
|
||||||
seasonParticipantId: "participant-2",
|
|
||||||
placement: 9,
|
|
||||||
qualifyingPointsAwarded: null,
|
|
||||||
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const { db, setCalls } = makeProcessQPMockDb(windowRows, {
|
|
||||||
tournamentId: "tournament-1",
|
|
||||||
bracketTemplateId: "tennis_128", // copied by clone…
|
|
||||||
playoffMatchIds: [], // …but no matches exist on this window
|
|
||||||
canonicalPlacements: Array.from({ length: 8 }, () => 9),
|
|
||||||
});
|
|
||||||
|
|
||||||
await processQualifyingEvent("event-1", db, { skipNotifications: true });
|
|
||||||
|
|
||||||
expect(setCalls).toEqual(
|
|
||||||
expect.arrayContaining([
|
|
||||||
expect.objectContaining({ qualifyingPointsAwarded: "1.50" }),
|
|
||||||
])
|
|
||||||
);
|
|
||||||
expect(setCalls).not.toEqual(
|
|
||||||
expect.arrayContaining([
|
|
||||||
expect.objectContaining({ qualifyingPointsAwarded: "2.00" }),
|
|
||||||
])
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the live roster count for standalone events with no tournament", async () => {
|
|
||||||
// No canonical field exists for a manual/standalone qualifying event, so the
|
|
||||||
// tie span is the players actually present: 2 players tied at 9th →
|
|
||||||
// (2+2)/2 = 2.00. This preserves existing behavior where there is no full field.
|
|
||||||
const windowRows = [
|
|
||||||
{
|
|
||||||
id: "result-1",
|
|
||||||
seasonParticipantId: "participant-1",
|
|
||||||
placement: 9,
|
|
||||||
qualifyingPointsAwarded: null,
|
|
||||||
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "result-2",
|
|
||||||
seasonParticipantId: "participant-2",
|
|
||||||
placement: 9,
|
|
||||||
qualifyingPointsAwarded: null,
|
|
||||||
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const { db, setCalls } = makeProcessQPMockDb(windowRows, {
|
|
||||||
tournamentId: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
await processQualifyingEvent("event-1", db, { skipNotifications: true });
|
|
||||||
|
|
||||||
expect(setCalls).toEqual(
|
|
||||||
expect.arrayContaining([
|
|
||||||
expect.objectContaining({ qualifyingPointsAwarded: "2.00" }),
|
|
||||||
])
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not increment majorsCompleted when reprocessing a placed zero-QP result", async () => {
|
it("does not increment majorsCompleted when reprocessing a placed zero-QP result", async () => {
|
||||||
const { db, setCalls } = makeProcessQPMockDb([
|
const { db, setCalls } = makeProcessQPMockDb([
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,7 @@ vi.mock("../qualifying-points", async (importOriginal) => {
|
||||||
import { deleteScoringEvent } from "../scoring-event";
|
import { deleteScoringEvent } from "../scoring-event";
|
||||||
|
|
||||||
describe("deleteScoringEvent", () => {
|
describe("deleteScoringEvent", () => {
|
||||||
it("does not write majorsCompleted on delete (it is derived on read)", async () => {
|
it("decrements majorsCompleted for a processed zero-QP qualifying event", 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 = {
|
||||||
|
|
@ -46,6 +43,12 @@ 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),
|
||||||
|
|
@ -54,125 +57,10 @@ describe("deleteScoringEvent", () => {
|
||||||
|
|
||||||
await deleteScoringEvent("event-1", db);
|
await deleteScoringEvent("event-1", db);
|
||||||
|
|
||||||
expect(setCalls).not.toEqual(
|
expect(setCalls).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({ majorsCompleted: expect.anything() }),
|
expect.objectContaining({ majorsCompleted: 0 }),
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("shared tournament handling", () => {
|
|
||||||
// Builds a mock db for a non-qualifying schedule_event linked to a tournament,
|
|
||||||
// so the delete skips the QP/league branches and exercises only the
|
|
||||||
// shared-tournament bookkeeping.
|
|
||||||
function makeSharedDb({
|
|
||||||
deletedEvent,
|
|
||||||
remaining,
|
|
||||||
siblingTournamentId = "tournament-1",
|
|
||||||
}: {
|
|
||||||
deletedEvent: { tournamentId: string | null; isPrimary: boolean };
|
|
||||||
remaining: Array<{ id: string; isPrimary: boolean }>;
|
|
||||||
siblingTournamentId?: string;
|
|
||||||
}) {
|
|
||||||
const findFirst = vi
|
|
||||||
.fn()
|
|
||||||
// 1st call: the event being deleted
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
id: "event-1",
|
|
||||||
sportsSeasonId: "sports-season-1",
|
|
||||||
eventType: "schedule_event",
|
|
||||||
isQualifyingEvent: false,
|
|
||||||
...deletedEvent,
|
|
||||||
})
|
|
||||||
// subsequent calls: setPrimaryEvent looking up the promoted event
|
|
||||||
.mockResolvedValue({ id: "promoted", tournamentId: siblingTournamentId });
|
|
||||||
|
|
||||||
const deleteWhere = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const updateWhere = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const db = {
|
|
||||||
query: {
|
|
||||||
scoringEvents: {
|
|
||||||
findFirst,
|
|
||||||
findMany: vi.fn().mockResolvedValue(remaining),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
transaction: vi.fn(async (callback) => callback(db)),
|
|
||||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
|
||||||
update: vi.fn(() => ({ set: vi.fn(() => ({ where: updateWhere })) })),
|
|
||||||
} as any;
|
|
||||||
return { db, deleteWhere };
|
|
||||||
}
|
|
||||||
|
|
||||||
it("keeps the tournament and promotes nothing when a non-primary window is deleted", async () => {
|
|
||||||
const { db } = makeSharedDb({
|
|
||||||
deletedEvent: { tournamentId: "tournament-1", isPrimary: false },
|
|
||||||
remaining: [{ id: "event-2", isPrimary: true }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await deleteScoringEvent("event-1", db);
|
|
||||||
|
|
||||||
expect(result.remainingWindows).toBe(1);
|
|
||||||
expect(result.promotedPrimaryId).toBeNull();
|
|
||||||
expect(result.deletedTournament).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not promote a primary when a golf-style tournament (no primary) loses a window", async () => {
|
|
||||||
// Golf-style shared majors intentionally have no primary window — every
|
|
||||||
// linked event is isPrimary=false. Deleting one must not flip a sibling
|
|
||||||
// into a primary, which would change its scoring/guard behavior.
|
|
||||||
const { db } = makeSharedDb({
|
|
||||||
deletedEvent: { tournamentId: "tournament-1", isPrimary: false },
|
|
||||||
remaining: [
|
|
||||||
{ id: "event-2", isPrimary: false },
|
|
||||||
{ id: "event-3", isPrimary: false },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await deleteScoringEvent("event-1", db);
|
|
||||||
|
|
||||||
expect(result.promotedPrimaryId).toBeNull();
|
|
||||||
expect(result.remainingWindows).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("promotes the earliest remaining window when the primary window is deleted", async () => {
|
|
||||||
const { db } = makeSharedDb({
|
|
||||||
deletedEvent: { tournamentId: "tournament-1", isPrimary: true },
|
|
||||||
// findMany is ordered asc(createdAt); first is the earliest.
|
|
||||||
remaining: [
|
|
||||||
{ id: "event-2", isPrimary: false },
|
|
||||||
{ id: "event-3", isPrimary: false },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await deleteScoringEvent("event-1", db);
|
|
||||||
|
|
||||||
expect(result.promotedPrimaryId).toBe("event-2");
|
|
||||||
expect(result.remainingWindows).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves the orphaned tournament intact when the last window is deleted without the flag", async () => {
|
|
||||||
const { db } = makeSharedDb({
|
|
||||||
deletedEvent: { tournamentId: "tournament-1", isPrimary: true },
|
|
||||||
remaining: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await deleteScoringEvent("event-1", db);
|
|
||||||
|
|
||||||
expect(result.remainingWindows).toBe(0);
|
|
||||||
expect(result.deletedTournament).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("deletes the orphaned tournament when the last window is deleted with the flag", async () => {
|
|
||||||
const { db } = makeSharedDb({
|
|
||||||
deletedEvent: { tournamentId: "tournament-1", isPrimary: true },
|
|
||||||
remaining: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await deleteScoringEvent("event-1", db, {
|
|
||||||
deleteOrphanTournament: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.deletedTournament).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,11 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
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, findFirst: mockFindFirst },
|
scoringEvents: { findMany: mockFindMany },
|
||||||
},
|
},
|
||||||
update: mockUpdate,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mock("~/database/context", () => ({
|
vi.mock("~/database/context", () => ({
|
||||||
|
|
@ -26,10 +14,8 @@ 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",
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -37,14 +23,11 @@ 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";
|
||||||
|
|
@ -56,7 +39,6 @@ const mockTournamentB = { id: TOURNAMENT_ID_B, name: "US Open", year: 2026 };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
updateCalls.length = 0;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getTournamentsBySportsSeason", () => {
|
describe("getTournamentsBySportsSeason", () => {
|
||||||
|
|
@ -134,37 +116,3 @@ describe("getSportsSeasonsByTournament", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ensurePrimaryEvent", () => {
|
|
||||||
it("sets the event primary when the tournament has no primary yet", async () => {
|
|
||||||
mockFindFirst.mockResolvedValue(undefined); // no existing primary
|
|
||||||
const result = await ensurePrimaryEvent(TOURNAMENT_ID_A, "ev-1");
|
|
||||||
expect(result).toBe("ev-1");
|
|
||||||
expect(updateCalls).toHaveLength(1);
|
|
||||||
expect(updateCalls[0].patch).toMatchObject({ isPrimary: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is idempotent: keeps the existing primary and writes nothing", async () => {
|
|
||||||
mockFindFirst.mockResolvedValue({ id: "ev-existing" });
|
|
||||||
const result = await ensurePrimaryEvent(TOURNAMENT_ID_A, "ev-2");
|
|
||||||
expect(result).toBe("ev-existing");
|
|
||||||
expect(updateCalls).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("setPrimaryEvent", () => {
|
|
||||||
it("clears siblings then sets the chosen event primary", async () => {
|
|
||||||
mockFindFirst.mockResolvedValue({ id: "ev-3", tournamentId: TOURNAMENT_ID_A });
|
|
||||||
await setPrimaryEvent("ev-3");
|
|
||||||
// Two updates: clear-all (isPrimary:false) then set-one (isPrimary:true).
|
|
||||||
expect(updateCalls).toHaveLength(2);
|
|
||||||
expect(updateCalls[0].patch).toMatchObject({ isPrimary: false });
|
|
||||||
expect(updateCalls[1].patch).toMatchObject({ isPrimary: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws when the event is not linked to a tournament", async () => {
|
|
||||||
mockFindFirst.mockResolvedValue({ id: "ev-4", tournamentId: null });
|
|
||||||
await expect(setPrimaryEvent("ev-4")).rejects.toThrow(/not linked to a tournament/);
|
|
||||||
expect(updateCalls).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,192 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
|
|
||||||
import { countSeasonRaces, hasRaceRun } from "../season-races";
|
|
||||||
|
|
||||||
vi.mock("~/database/context", () => ({
|
|
||||||
database: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const NOW = new Date("2026-08-17T12:00:00.000Z");
|
|
||||||
const TODAY = "2026-08-17";
|
|
||||||
|
|
||||||
interface EventRow {
|
|
||||||
eventType: string;
|
|
||||||
isComplete: boolean;
|
|
||||||
eventDate: string | null;
|
|
||||||
eventStartsAt: Date | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeEvent(overrides: Partial<EventRow> = {}): EventRow {
|
|
||||||
return {
|
|
||||||
eventType: "schedule_event",
|
|
||||||
isComplete: false,
|
|
||||||
eventDate: null,
|
|
||||||
eventStartsAt: null,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mockEvents(events: EventRow[]) {
|
|
||||||
const { database } = await import("~/database/context");
|
|
||||||
(database as unknown as MockInstance).mockReturnValue({
|
|
||||||
query: {
|
|
||||||
scoringEvents: {
|
|
||||||
findMany: vi.fn().mockResolvedValue(events),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await mockEvents([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("hasRaceRun", () => {
|
|
||||||
it("trusts isComplete when an admin has set it", () => {
|
|
||||||
expect(
|
|
||||||
hasRaceRun(
|
|
||||||
{ isComplete: true, eventDate: "2026-12-31", eventStartsAt: null },
|
|
||||||
NOW,
|
|
||||||
TODAY
|
|
||||||
)
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prefers eventStartsAt over eventDate", () => {
|
|
||||||
// Started yesterday and long finished, even though eventDate is unset.
|
|
||||||
expect(
|
|
||||||
hasRaceRun(
|
|
||||||
{
|
|
||||||
isComplete: false,
|
|
||||||
eventDate: null,
|
|
||||||
eventStartsAt: new Date("2026-08-16T18:00:00.000Z"),
|
|
||||||
},
|
|
||||||
NOW,
|
|
||||||
TODAY
|
|
||||||
)
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
hasRaceRun(
|
|
||||||
{
|
|
||||||
isComplete: false,
|
|
||||||
eventDate: TODAY,
|
|
||||||
eventStartsAt: new Date("2026-08-17T18:00:00.000Z"),
|
|
||||||
},
|
|
||||||
NOW,
|
|
||||||
TODAY
|
|
||||||
)
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not call a race run the moment it goes green", () => {
|
|
||||||
// Declaring the finale finished at the green flag would publish the
|
|
||||||
// pre-race leader as champion at 100%, from standings without that race.
|
|
||||||
const greenFlag = new Date(NOW.getTime() - 30 * 60 * 1000);
|
|
||||||
expect(
|
|
||||||
hasRaceRun({ isComplete: false, eventDate: TODAY, eventStartsAt: greenFlag }, NOW, TODAY)
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("counts a race run once it has had time to finish", () => {
|
|
||||||
const greenFlag = new Date(NOW.getTime() - 7 * 60 * 60 * 1000);
|
|
||||||
expect(
|
|
||||||
hasRaceRun({ isComplete: false, eventDate: TODAY, eventStartsAt: greenFlag }, NOW, TODAY)
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still honours isComplete for a race that just started", () => {
|
|
||||||
const greenFlag = new Date(NOW.getTime() - 30 * 60 * 1000);
|
|
||||||
expect(
|
|
||||||
hasRaceRun({ isComplete: true, eventDate: TODAY, eventStartsAt: greenFlag }, NOW, TODAY)
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats a past date as run even when nobody marked it complete", () => {
|
|
||||||
expect(
|
|
||||||
hasRaceRun(
|
|
||||||
{ isComplete: false, eventDate: "2026-08-16", eventStartsAt: null },
|
|
||||||
NOW,
|
|
||||||
TODAY
|
|
||||||
)
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats a race happening today as still upcoming", () => {
|
|
||||||
expect(
|
|
||||||
hasRaceRun({ isComplete: false, eventDate: TODAY, eventStartsAt: null }, NOW, TODAY)
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats an undated row as upcoming", () => {
|
|
||||||
expect(
|
|
||||||
hasRaceRun({ isComplete: false, eventDate: null, eventStartsAt: null }, NOW, TODAY)
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("countSeasonRaces", () => {
|
|
||||||
it("counts schedule_event rows as races", async () => {
|
|
||||||
// This is the whole bug: a season_standings calendar is stored as
|
|
||||||
// schedule_event rows, and the simulator used to skip them.
|
|
||||||
await mockEvents([
|
|
||||||
makeEvent({ eventDate: "2026-03-01" }),
|
|
||||||
makeEvent({ eventDate: "2026-04-01" }),
|
|
||||||
makeEvent({ eventDate: "2026-09-01" }),
|
|
||||||
]);
|
|
||||||
expect(await countSeasonRaces("s1", NOW)).toEqual({
|
|
||||||
completed: 2,
|
|
||||||
remaining: 1,
|
|
||||||
total: 3,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("excludes the final_standings scoring row", async () => {
|
|
||||||
await mockEvents([
|
|
||||||
makeEvent({ eventDate: "2026-03-01" }),
|
|
||||||
makeEvent({ eventType: "final_standings", eventDate: "2026-11-01" }),
|
|
||||||
]);
|
|
||||||
expect(await countSeasonRaces("s1", NOW)).toEqual({
|
|
||||||
completed: 1,
|
|
||||||
remaining: 0,
|
|
||||||
total: 1,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("counts other event types too, whichever type the admin used", async () => {
|
|
||||||
await mockEvents([
|
|
||||||
makeEvent({ eventType: "major_tournament", eventDate: "2026-03-01" }),
|
|
||||||
makeEvent({ eventType: "playoff_game", eventDate: "2026-09-01" }),
|
|
||||||
]);
|
|
||||||
expect(await countSeasonRaces("s1", NOW)).toEqual({
|
|
||||||
completed: 1,
|
|
||||||
remaining: 1,
|
|
||||||
total: 2,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns zeroes when the season has no events", async () => {
|
|
||||||
expect(await countSeasonRaces("s1", NOW)).toEqual({
|
|
||||||
completed: 0,
|
|
||||||
remaining: 0,
|
|
||||||
total: 0,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("counts a realistic late-season IndyCar calendar", async () => {
|
|
||||||
const calendar = [
|
|
||||||
...Array.from({ length: 15 }, (_, i) =>
|
|
||||||
makeEvent({ eventDate: `2026-0${((i % 6) + 3)}-0${(i % 9) + 1}` })
|
|
||||||
),
|
|
||||||
// The next race goes green in a few hours — still remaining.
|
|
||||||
makeEvent({ eventStartsAt: new Date("2026-08-17T18:00:00.000Z") }),
|
|
||||||
makeEvent({ eventStartsAt: new Date("2026-08-30T18:00:00.000Z") }),
|
|
||||||
makeEvent({ eventType: "final_standings" }),
|
|
||||||
];
|
|
||||||
await mockEvents(calendar);
|
|
||||||
expect(await countSeasonRaces("s1", NOW)).toEqual({
|
|
||||||
completed: 15,
|
|
||||||
remaining: 2,
|
|
||||||
total: 17,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -71,90 +71,4 @@ describe("simulator input model", () => {
|
||||||
expect(byParticipant.get("generated-rating")?.rating).toBeNull();
|
expect(byParticipant.get("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,11 +27,7 @@ vi.mock("drizzle-orm", () => ({
|
||||||
asc: (col: unknown) => ({ type: "asc", col }),
|
asc: (col: unknown) => ({ type: "asc", col }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import {
|
import { findAllSportsSeasons, findDraftableSportsSeasons } from "../sports-season";
|
||||||
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);
|
||||||
|
|
@ -129,58 +125,3 @@ describe("findDraftableSportsSeasons", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("findDraftScheduleForHorizon", () => {
|
|
||||||
const horizonWindows = [
|
|
||||||
{
|
|
||||||
id: "w1",
|
|
||||||
name: "2026 NBA Playoffs",
|
|
||||||
year: 2026,
|
|
||||||
status: "upcoming",
|
|
||||||
draftOn: today,
|
|
||||||
draftOff: future,
|
|
||||||
sport: { id: "nba", name: "NBA", slug: "nba", iconUrl: null },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "w2",
|
|
||||||
name: "2026 F1 Season",
|
|
||||||
year: 2026,
|
|
||||||
status: "active",
|
|
||||||
draftOn: today,
|
|
||||||
draftOff: future,
|
|
||||||
sport: { id: "f1", name: "F1", slug: "f1", iconUrl: null },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
it("maps rows to windows carrying their sport info", async () => {
|
|
||||||
vi.mocked(database).mockReturnValue(makeMockDb(horizonWindows) as never);
|
|
||||||
const result = await findDraftScheduleForHorizon(6);
|
|
||||||
expect(result).toHaveLength(2);
|
|
||||||
expect(result[0]).toMatchObject({
|
|
||||||
id: "w1",
|
|
||||||
draftOn: today,
|
|
||||||
draftOff: future,
|
|
||||||
sport: { id: "nba", name: "NBA" },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns an empty array when no windows overlap the horizon", async () => {
|
|
||||||
vi.mocked(database).mockReturnValue(makeMockDb([]) as never);
|
|
||||||
const result = await findDraftScheduleForHorizon(12);
|
|
||||||
expect(result).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("queries with a where clause, ordering, and the sport relation", async () => {
|
|
||||||
const mockDb = makeMockDb([]);
|
|
||||||
vi.mocked(database).mockReturnValue(mockDb as never);
|
|
||||||
|
|
||||||
await findDraftScheduleForHorizon(6);
|
|
||||||
expect(mockDb.query.sportsSeasons.findMany).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
where: expect.anything(),
|
|
||||||
orderBy: expect.anything(),
|
|
||||||
with: expect.anything(),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -80,9 +80,6 @@ function makeDb(
|
||||||
},
|
},
|
||||||
scoringEvents: {
|
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,7 +11,6 @@ import {
|
||||||
findTournamentBySportNameYear,
|
findTournamentBySportNameYear,
|
||||||
upsertTournament,
|
upsertTournament,
|
||||||
updateTournamentStatus,
|
updateTournamentStatus,
|
||||||
deleteTournament,
|
|
||||||
} from "../tournament";
|
} from "../tournament";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
|
|
||||||
|
|
@ -173,26 +172,3 @@ 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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
import { database } from "~/database/context";
|
|
||||||
import * as schema from "~/database/schema";
|
|
||||||
import { and, desc, inArray, isNotNull } from "drizzle-orm";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve which bracket template a sports season's placements should be scored against.
|
|
||||||
*
|
|
||||||
* A sports season can own several scoring events — a bracket plus schedule events, or a
|
|
||||||
* re-created bracket alongside a stale one — and only some of them carry a
|
|
||||||
* bracketTemplateId. Picking an arbitrary row is not harmless: calculateBracketPoints
|
|
||||||
* falls back to the standard single 5th–8th tier when the template id is null, which
|
|
||||||
* silently collapses the two-tier templates (llws_20, afl_10) so a team locked into
|
|
||||||
* 5th–6th and one locked into 7th–8th both score the flat 5–8 average. The 3rd/4th
|
|
||||||
* distinction that llws_20 and fifa_48 have goes the same way.
|
|
||||||
*
|
|
||||||
* So: only events that actually carry a template are considered, most recent first —
|
|
||||||
* matching the "a re-created event wins over a stale one" rule the LLWS simulator uses
|
|
||||||
* when it picks its bracket event.
|
|
||||||
*
|
|
||||||
* Every requested season gets an entry, null when it has no bracket event, so callers
|
|
||||||
* can cache the negative result too.
|
|
||||||
*/
|
|
||||||
export async function getBracketTemplateIdsForSportsSeasons(
|
|
||||||
sportsSeasonIds: string[],
|
|
||||||
providedDb?: ReturnType<typeof database>
|
|
||||||
): Promise<Map<string, string | null>> {
|
|
||||||
const resolved = new Map<string, string | null>(
|
|
||||||
sportsSeasonIds.map((id) => [id, null])
|
|
||||||
);
|
|
||||||
if (sportsSeasonIds.length === 0) return resolved;
|
|
||||||
|
|
||||||
const db = providedDb || database();
|
|
||||||
|
|
||||||
const events = await db.query.scoringEvents.findMany({
|
|
||||||
where: and(
|
|
||||||
inArray(schema.scoringEvents.sportsSeasonId, sportsSeasonIds),
|
|
||||||
isNotNull(schema.scoringEvents.bracketTemplateId)
|
|
||||||
),
|
|
||||||
columns: { sportsSeasonId: true, bracketTemplateId: true },
|
|
||||||
// createdAt can tie when a bracket is generated in the same transaction as a
|
|
||||||
// sibling event, so id breaks the tie and keeps the choice deterministic.
|
|
||||||
orderBy: [desc(schema.scoringEvents.createdAt), desc(schema.scoringEvents.id)],
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const event of events) {
|
|
||||||
// Ordered newest-first, so the first row seen for a season is the one to keep.
|
|
||||||
// The isNotNull filter means bracketTemplateId is set, but a mocked or partial row
|
|
||||||
// could still carry null — skip those rather than caching a null as a real answer.
|
|
||||||
if (resolved.get(event.sportsSeasonId) === null && event.bracketTemplateId) {
|
|
||||||
resolved.set(event.sportsSeasonId, event.bracketTemplateId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Single-season form of getBracketTemplateIdsForSportsSeasons.
|
|
||||||
*/
|
|
||||||
export async function getBracketTemplateIdForSportsSeason(
|
|
||||||
sportsSeasonId: string,
|
|
||||||
providedDb?: ReturnType<typeof database>
|
|
||||||
): Promise<string | null> {
|
|
||||||
const resolved = await getBracketTemplateIdsForSportsSeasons([sportsSeasonId], providedDb);
|
|
||||||
return resolved.get(sportsSeasonId) ?? null;
|
|
||||||
}
|
|
||||||
|
|
@ -15,10 +15,7 @@
|
||||||
import { database } from "~/database/context";
|
import { 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, calculateSplitQualifyingPoints, writeEventResultsQP, recalculateParticipantQP } from "~/models/qualifying-points";
|
import { getQPConfig, recalculateParticipantQP, calculateSplitQualifyingPoints } 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;
|
||||||
|
|
@ -129,57 +126,14 @@ 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 = providedDb || database();
|
const db = 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 —
|
||||||
|
|
@ -440,30 +394,34 @@ export async function assignCs2EliminationQP(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot existing QP before writing so the notification only covers new/changed participants,
|
const now = new Date();
|
||||||
// preventing earlier stage exits from being re-announced on each subsequent stage call.
|
|
||||||
const existingRows = await db.query.eventResults.findMany({
|
await Promise.all(
|
||||||
where: eq(eventResults.scoringEventId, scoringEventId),
|
[...resultsByParticipant.entries()].map(([seasonParticipantId, { qp, placement }]) =>
|
||||||
});
|
db
|
||||||
const existingQPBySPId = new Map<string, number>(
|
.insert(eventResults)
|
||||||
existingRows
|
.values({
|
||||||
.filter((r) => r.qualifyingPointsAwarded !== null)
|
scoringEventId,
|
||||||
.map((r) => [r.seasonParticipantId, parseFloat(r.qualifyingPointsAwarded as string)])
|
seasonParticipantId,
|
||||||
|
qualifyingPointsAwarded: qp.toString(),
|
||||||
|
placement,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [eventResults.scoringEventId, eventResults.seasonParticipantId],
|
||||||
|
set: {
|
||||||
|
qualifyingPointsAwarded: qp.toString(),
|
||||||
|
placement,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
await writeEventResultsQP(scoringEventId, sportsSeasonId, resultsByParticipant, db);
|
await Promise.all(
|
||||||
|
[...resultsByParticipant.keys()].map((seasonParticipantId) =>
|
||||||
const changedIds = new Set<string>(
|
recalculateParticipantQP(seasonParticipantId, sportsSeasonId)
|
||||||
[...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,7 +7,6 @@ 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;
|
||||||
|
|
@ -176,10 +175,18 @@ export async function getDraftedParticipantsWithPoints(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Batch-fetch bracket template IDs (one per sports season)
|
// Batch-fetch bracket template IDs (one per sports season)
|
||||||
const bracketTemplateMap =
|
const bracketTemplateMap = new Map<string, string | null>();
|
||||||
bracketSeasonIds.size > 0
|
if (bracketSeasonIds.size > 0) {
|
||||||
? await getBracketTemplateIdsForSportsSeasons([...bracketSeasonIds], db)
|
const events = await db.query.scoringEvents.findMany({
|
||||||
: new Map<string, string | null>();
|
where: inArray(schema.scoringEvents.sportsSeasonId, [...bracketSeasonIds]),
|
||||||
|
columns: { sportsSeasonId: true, bracketTemplateId: true },
|
||||||
|
});
|
||||||
|
for (const ev of events) {
|
||||||
|
if (!bracketTemplateMap.has(ev.sportsSeasonId)) {
|
||||||
|
bracketTemplateMap.set(ev.sportsSeasonId, ev.bracketTemplateId ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Batch-fetch QP totals for qualifying_points participants
|
// 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 } from "~/database/schema";
|
import { seasonParticipantExpectedValues, seasonParticipants, seasonParticipantSimulatorInputs } from "~/database/schema";
|
||||||
import { eq, and, count, sql } from "drizzle-orm";
|
import { eq, and, count, inArray, 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 { batchUpsertParticipantSimulatorInputs } from "~/models/simulator";
|
import { batchSaveParticipantSimulatorSourceOdds, 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,6 +366,95 @@ 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, inArray } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
|
|
||||||
|
|
@ -95,42 +95,14 @@ 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 = providedDb || database();
|
const db = 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,19 +6,8 @@ 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;
|
||||||
|
|
@ -153,108 +142,6 @@ 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
|
||||||
*/
|
*/
|
||||||
|
|
@ -478,11 +365,6 @@ 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
|
||||||
|
|
@ -746,11 +628,9 @@ 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: 5 and 6 host the two Wildcard winners, re-seeded by ladder
|
* - Elimination Finals: 5v8, 6v7 (where 7 and 8 are wildcard winners)
|
||||||
* position — 5th draws the lower-ranked winner, 6th the higher-ranked one
|
* - Semi-Finals: QF losers vs EF winners
|
||||||
* - Semi-Finals: SF1 = QF1 loser v EF1 winner, SF2 = QF2 loser v EF2 winner
|
* - Preliminary Finals: QF winners vs SF winners
|
||||||
* - 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(
|
||||||
|
|
@ -802,16 +682,14 @@ async function generateAFL10Bracket(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Elimination Finals: 5th and 6th host the two Wildcard winners. Which winner lands
|
// Elimination Finals: 5th vs TBD (wildcard winner), 6th vs TBD (wildcard winner)
|
||||||
// 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, opponent: "lower-ranked WC winner" }, // #5 (index 4)
|
{ higher: 4, wildcard: 2 }, // #5 (index 4) vs Wildcard Match 2 winner
|
||||||
{ higher: 5, opponent: "higher-ranked WC winner" }, // #6 (index 5)
|
{ higher: 5, wildcard: 1 }, // #6 (index 5) vs Wildcard Match 1 winner
|
||||||
];
|
];
|
||||||
|
|
||||||
for (let i = 0; i < eliminationSeeding.length; i++) {
|
for (let i = 0; i < eliminationSeeding.length; i++) {
|
||||||
const { higher, opponent } = eliminationSeeding[i];
|
const { higher, wildcard } = eliminationSeeding[i];
|
||||||
matches.push({
|
matches.push({
|
||||||
scoringEventId: eventId,
|
scoringEventId: eventId,
|
||||||
round: "Elimination Finals",
|
round: "Elimination Finals",
|
||||||
|
|
@ -821,11 +699,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 ${opponent}` : null,
|
seedInfo: participantIds ? `${higher + 1} vs WC${wildcard}` : null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Semi-Finals: SF n = QF n loser vs EF n winner (TBD vs TBD)
|
// Semi-Finals: QF losers vs EF winners (TBD vs TBD)
|
||||||
for (let i = 0; i < 2; i++) {
|
for (let i = 0; i < 2; i++) {
|
||||||
matches.push({
|
matches.push({
|
||||||
scoringEventId: eventId,
|
scoringEventId: eventId,
|
||||||
|
|
@ -871,257 +749,15 @@ 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 (re-seeded by ladder position)
|
* - Wildcard Round: Winner → Elimination Finals
|
||||||
* - Qualifying Finals: Winner → Preliminary Finals, Loser → Semi-Finals (QF n → PF n, SF n)
|
* - Qualifying Finals: Winner → Preliminary Finals, Loser → Semi-Finals
|
||||||
* - Elimination Finals: Winner → Semi-Finals (EF n → SF n, a fixed pathway)
|
* - Elimination Finals: Winner → Semi-Finals
|
||||||
* - Semi-Finals: Winner → Preliminary Finals (SF n crosses over: SF1 → PF2, SF2 → PF1)
|
* - Semi-Finals: Winner → Preliminary Finals
|
||||||
* - Preliminary Finals: Winner → Grand Final
|
* - Preliminary Finals: Winner → Grand Final
|
||||||
*/
|
*/
|
||||||
async function advanceAFLWinner(
|
async function advanceAFLWinner(
|
||||||
|
|
@ -1131,10 +767,18 @@ async function advanceAFLWinner(
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const eventId = match.scoringEventId;
|
const eventId = match.scoringEventId;
|
||||||
|
|
||||||
// Wildcard Round: winners are re-seeded into the Elimination Finals by ladder
|
// Wildcard Round: Winner advances to Elimination Finals
|
||||||
// position, so every result re-resolves both slots.
|
|
||||||
if (match.round === "Wildcard Round") {
|
if (match.round === "Wildcard Round") {
|
||||||
await reseedAflEliminationFinals(eventId, { matchId: match.id, winnerId });
|
// Wildcard Match 1 winner → EF Match 2, participant2Id
|
||||||
|
// Wildcard Match 2 winner → EF Match 1, participant2Id
|
||||||
|
const efMatchNumber = match.matchNumber === 1 ? 2 : 1;
|
||||||
|
const efMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Elimination Finals");
|
||||||
|
const efMatch = efMatches.find((m) => m.matchNumber === efMatchNumber);
|
||||||
|
|
||||||
|
if (!efMatch) throw new Error(`Elimination Finals match ${efMatchNumber} not found`);
|
||||||
|
if (efMatch.participant2Id) throw new Error(`EF ${efMatchNumber} participant2 already filled`);
|
||||||
|
|
||||||
|
await updatePlayoffMatch(efMatch.id, { participant2Id: winnerId });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1162,11 +806,18 @@ async function advanceAFLWinner(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Elimination Finals: Winner → Semi-Finals. EF n feeds SF n — the crossover in this
|
// Elimination Finals: Winner → Semi-Finals
|
||||||
// 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") {
|
||||||
await reseedAflSemiFinals(eventId, { matchId: match.id, winnerId });
|
// EF Match 1 winner → SF2 participant2
|
||||||
|
// EF Match 2 winner → SF1 participant2
|
||||||
|
const sfMatchNumber = match.matchNumber === 1 ? 2 : 1;
|
||||||
|
const sfMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Semi-Finals");
|
||||||
|
const sfMatch = sfMatches.find((m) => m.matchNumber === sfMatchNumber);
|
||||||
|
|
||||||
|
if (!sfMatch) throw new Error(`Semi-Finals match ${sfMatchNumber} not found`);
|
||||||
|
if (sfMatch.participant2Id) throw new Error(`SF ${sfMatchNumber} participant2 already filled`);
|
||||||
|
|
||||||
|
await updatePlayoffMatch(sfMatch.id, { participant2Id: winnerId });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1227,15 +878,6 @@ 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") {
|
||||||
|
|
@ -1683,12 +1325,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1800,140 +1436,3 @@ 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,33 +33,6 @@ 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,
|
||||||
|
|
@ -75,6 +48,21 @@ 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
|
||||||
*/
|
*/
|
||||||
|
|
@ -295,57 +283,6 @@ 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,23 +9,20 @@ 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, type EliminatedTeam } from "~/services/discord";
|
import { sendStandingsUpdateNotification, type ScoredMatch } from "~/services/discord";
|
||||||
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
|
import { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
|
||||||
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";
|
||||||
|
|
@ -114,169 +111,31 @@ 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 the floor position that winners of a NON-scoring round should bank, or null
|
* Returns true if a non-scoring round's winners are entering the first scoring round
|
||||||
* to bank nothing.
|
* (i.e., they've guaranteed a top-8 fantasy placement and should receive a T5–T8 floor).
|
||||||
*
|
*
|
||||||
* Default: winners entering the first scoring round have guaranteed a top-8 fantasy
|
* For multi-round pre-bracket sequences like NCAA (Round of 64 → Round of 32 →
|
||||||
* placement and receive a T5–T8 floor (5); everyone else gets nothing yet. For
|
* Sweet Sixteen → Elite Eight), only Sweet Sixteen winners are entering the scoring
|
||||||
* multi-round pre-bracket sequences like NCAA (Round of 64 → Round of 32 → Sweet
|
* bracket — Round of 64 and Round of 32 winners should not receive any floor yet.
|
||||||
* Sixteen → Elite Eight), only Sweet Sixteen winners are entering the scoring bracket.
|
|
||||||
*
|
*
|
||||||
* A round may override this with `nonScoringWinnerFloor` when the default is wrong —
|
* Falls back to true when template/round info is unavailable to preserve legacy behavior.
|
||||||
* 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 nonScoringWinnerFloorFor(
|
function doesNonScoringRoundFeedIntoScoringRound(
|
||||||
round: string,
|
round: string,
|
||||||
bracketTemplateId: string | null | undefined
|
bracketTemplateId: string | null | undefined
|
||||||
): number | null {
|
): boolean {
|
||||||
if (!bracketTemplateId) return 5; // Legacy: preserve old behavior
|
if (!bracketTemplateId) return true; // Legacy: preserve old behavior
|
||||||
const template = BRACKET_TEMPLATES[bracketTemplateId];
|
const template = BRACKET_TEMPLATES[bracketTemplateId];
|
||||||
if (!template) return 5; // Unknown template: preserve old behavior
|
if (!template) return true; // Unknown template: preserve old behavior
|
||||||
const currentRound = template.rounds.find((r) => r.name === round);
|
const currentRound = template.rounds.find((r) => r.name === round);
|
||||||
if (!currentRound) return 5; // Unknown round: preserve old behavior
|
if (!currentRound) return true; // 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 null; // No next round (shouldn't happen for non-scoring)
|
if (!nextRoundName) return false; // No next round (shouldn't happen for non-scoring)
|
||||||
const nextRound = template.rounds.find((r) => r.name === nextRoundName);
|
const nextRound = template.rounds.find((r) => r.name === nextRoundName);
|
||||||
return nextRound?.isScoring === true ? 5 : null;
|
return nextRound?.isScoring === true;
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -284,7 +143,7 @@ export async function applyBracketEntryFloors(
|
||||||
* 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.
|
||||||
*/
|
*/
|
||||||
export function getRoundConfig(
|
function getRoundConfig(
|
||||||
round: string,
|
round: string,
|
||||||
bracketTemplateId?: string | null
|
bracketTemplateId?: string | null
|
||||||
): RoundScoringConfig | null {
|
): RoundScoringConfig | null {
|
||||||
|
|
@ -410,18 +269,19 @@ export async function processPlayoffEvent(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isScoring) {
|
if (!isScoring) {
|
||||||
// Non-scoring round: losers are permanently eliminated (0 pts) unless they
|
// Non-scoring (pre-bracket) round: losers are permanently eliminated (0 pts).
|
||||||
// advance (double-elimination winners-bracket losers). Winners bank a
|
// Winners only bank a provisional T5–T8 floor if they're entering the first
|
||||||
// provisional floor only when this round guarantees them one — see
|
// scoring round (i.e., guaranteed top-8). For multi-round pre-bracket sequences
|
||||||
// nonScoringWinnerFloorFor for how that is derived per template.
|
// like NCAA (R64 → R32 → Sweet 16 → Elite Eight), only Sweet 16 winners should
|
||||||
const winnerFloor = nonScoringWinnerFloorFor(round, event.bracketTemplateId);
|
// receive floor points — R64 and R32 winners are not yet guaranteed top-8.
|
||||||
|
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 && winnerFloor !== null) {
|
if (match.winnerId && awardFloor) {
|
||||||
await upsertParticipantResult(match.winnerId, event.sportsSeasonId, winnerFloor, db, true);
|
await upsertParticipantResult(match.winnerId, event.sportsSeasonId, 5, db, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -481,7 +341,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; those winners were given their floor inline above.
|
// returns null (winners were given floor 5 inline above).
|
||||||
const guaranteedMinimum = getGuaranteedMinimumPosition(
|
const guaranteedMinimum = getGuaranteedMinimumPosition(
|
||||||
round,
|
round,
|
||||||
event.bracketTemplateId,
|
event.bracketTemplateId,
|
||||||
|
|
@ -547,19 +407,6 @@ export async function processMatchResult(
|
||||||
/** When set, Discord notification only shows this match (not all completed matches for the event). */
|
/** 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
|
||||||
|
|
@ -570,7 +417,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, skipProbabilities, loserAdvances } = params;
|
const { round, winnerId, loserId, isScoring, sportsSeasonId, bracketTemplateId, eventId, eventName, matchId, skipSideEffects, 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),
|
||||||
|
|
@ -582,9 +429,8 @@ export async function processMatchResult(
|
||||||
if (!loserAdvances) {
|
if (!loserAdvances) {
|
||||||
await upsertParticipantResult(loserId, sportsSeasonId, 0, db);
|
await upsertParticipantResult(loserId, sportsSeasonId, 0, db);
|
||||||
}
|
}
|
||||||
const nonScoringFloor = nonScoringWinnerFloorFor(round, bracketTemplateId);
|
if (doesNonScoringRoundFeedIntoScoringRound(round, bracketTemplateId)) {
|
||||||
if (nonScoringFloor !== null) {
|
await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true);
|
||||||
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 {
|
||||||
|
|
@ -650,7 +496,6 @@ 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) {
|
||||||
|
|
@ -659,7 +504,6 @@ export async function processMatchResult(
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
await recalculateAffectedLeagues(sportsSeasonId, db, sideEffectOptions);
|
await recalculateAffectedLeagues(sportsSeasonId, db, sideEffectOptions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -768,7 +612,6 @@ 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,
|
||||||
|
|
@ -780,260 +623,13 @@ 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();
|
||||||
|
|
||||||
|
|
@ -1056,40 +652,9 @@ 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);
|
||||||
|
|
||||||
// Snapshot awarded QP before reprocessing so the Discord notification below can
|
// Check if this was already processed (for majorsCompleted counter)
|
||||||
// announce only the participants whose QP actually changed. Without this, a
|
const wasAlreadyProcessed = hasProcessedQualifyingPlacement(results);
|
||||||
// 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
|
||||||
|
|
@ -1106,28 +671,6 @@ 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) {
|
||||||
|
|
@ -1140,7 +683,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 = canonicalTieCountByPlacement?.get(placement) ?? group.length;
|
const tieCount = group.length;
|
||||||
|
|
||||||
const qpPerParticipant = calculateSplitQualifyingPoints(
|
const qpPerParticipant = calculateSplitQualifyingPoints(
|
||||||
placement,
|
placement,
|
||||||
|
|
@ -1159,7 +702,6 @@ 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
|
||||||
|
|
@ -1168,46 +710,21 @@ export async function processQualifyingEvent(
|
||||||
await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
|
await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: majorsCompleted is NOT a stored counter. It is derived on read via
|
// Increment majorsCompleted counter (only if this is the first time processing)
|
||||||
// getMajorsCompleted() (count of completed qualifying events). Incrementing here
|
if (!wasAlreadyProcessed) {
|
||||||
// per fan-out sync over-counted it past totalMajors ("11 of 4"), so the write was
|
const sportsSeason = event.sportsSeason;
|
||||||
// removed. See app/models/scoring-event.ts:getMajorsCompleted.
|
await db
|
||||||
|
.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1481,7 +998,11 @@ export async function calculateTeamScore(
|
||||||
if (bracketTemplateCache.has(sportsSeasonId)) {
|
if (bracketTemplateCache.has(sportsSeasonId)) {
|
||||||
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
||||||
}
|
}
|
||||||
const templateId = await getBracketTemplateIdForSportsSeason(sportsSeasonId, db);
|
const event = await db.query.scoringEvents.findFirst({
|
||||||
|
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||||||
|
columns: { bracketTemplateId: true },
|
||||||
|
});
|
||||||
|
const templateId = event?.bracketTemplateId ?? null;
|
||||||
bracketTemplateCache.set(sportsSeasonId, templateId);
|
bracketTemplateCache.set(sportsSeasonId, templateId);
|
||||||
return templateId;
|
return templateId;
|
||||||
}
|
}
|
||||||
|
|
@ -1590,7 +1111,11 @@ export async function calculateTeamProjectedScore(
|
||||||
if (bracketTemplateCache.has(sportsSeasonId)) {
|
if (bracketTemplateCache.has(sportsSeasonId)) {
|
||||||
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
||||||
}
|
}
|
||||||
const templateId = await getBracketTemplateIdForSportsSeason(sportsSeasonId, db);
|
const event = await db.query.scoringEvents.findFirst({
|
||||||
|
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||||||
|
columns: { bracketTemplateId: true },
|
||||||
|
});
|
||||||
|
const templateId = event?.bracketTemplateId ?? null;
|
||||||
bracketTemplateCache.set(sportsSeasonId, templateId);
|
bracketTemplateCache.set(sportsSeasonId, templateId);
|
||||||
return templateId;
|
return templateId;
|
||||||
}
|
}
|
||||||
|
|
@ -1900,7 +1425,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; eliminatedParticipantIds?: string[] }
|
options?: { eventName?: string; eventId?: string; matchIds?: string[]; skipDiscord?: boolean }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const db = providedDb || database();
|
const db = providedDb || database();
|
||||||
|
|
||||||
|
|
@ -1969,18 +1494,6 @@ 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
|
||||||
|
|
@ -2055,33 +1568,20 @@ 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.
|
||||||
// A match is worth announcing when:
|
// Winners only appear if their team's score changed (they earned points this round).
|
||||||
// • the winner is owned by a manager AND earned points this round, OR
|
// Losers appear if their team's score changed OR they were definitively eliminated
|
||||||
// • the loser is owned by a manager (eliminated or scored).
|
// (finalPosition set, non-partial) — the latter catches 0-pt eliminations that
|
||||||
// A winning team that merely advanced through a non-scoring round (e.g. R32 → R16
|
// produce no score delta. Losers who advance to another match (loserAdvances=true,
|
||||||
// in the World Cup) does not qualify on its own — their owner hasn't earned anything yet.
|
// e.g. NBA 7v8 → PIR2) have no finalized result and no score change, so they're
|
||||||
// When a match qualifies because the loser is owned, the winner's manager tag is still
|
// correctly suppressed.
|
||||||
// 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)) ||
|
||||||
|
|
@ -2089,6 +1589,10 @@ 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);
|
||||||
|
|
@ -2105,52 +1609,23 @@ 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 { m, winnerScoreChanged, showLoser, winnerOwnerId, loserOwnerId };
|
|
||||||
})
|
|
||||||
.filter((x) => (x.winnerScoreChanged && !!x.winnerOwnerId) || (x.showLoser && !!x.loserOwnerId))
|
|
||||||
.map((x) => ({
|
|
||||||
winnerName: x.m.winnerName ?? "",
|
|
||||||
loserName: x.m.loserName ?? "",
|
|
||||||
winnerUsername: x.winnerOwnerId ? usernameByUserId.get(x.winnerOwnerId) : undefined,
|
|
||||||
// Show the loser's manager tag whenever their team is drafted, mirroring the
|
|
||||||
// winner above — even when the loser advances rather than being eliminated
|
|
||||||
// (World Cup semifinal → 3rd-place playoff, AFL Qualifying Final → Semi Final).
|
|
||||||
// The @-ping stays gated by showLoser (loserDiscordUserId below): a still-alive
|
|
||||||
// loser who neither scored nor was eliminated is named for context but not pinged.
|
|
||||||
loserUsername: x.loserOwnerId ? usernameByUserId.get(x.loserOwnerId) : undefined,
|
|
||||||
winnerDiscordUserId: x.winnerScoreChanged && x.winnerOwnerId ? discordIdByUserId.get(x.winnerOwnerId) : undefined,
|
|
||||||
loserDiscordUserId: x.showLoser && x.loserOwnerId ? discordIdByUserId.get(x.loserOwnerId) : undefined,
|
|
||||||
}))
|
|
||||||
.filter((m) => m.winnerUsername !== undefined || m.loserUsername !== undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
return {
|
||||||
participantName: eliminatedNameById.get(id) ?? "Unknown",
|
winnerName: m.winnerName ?? "",
|
||||||
username: ownerId ? usernameByUserId.get(ownerId) : undefined,
|
loserName: m.loserName ?? "",
|
||||||
discordUserId: ownerId ? discordIdByUserId.get(ownerId) : undefined,
|
winnerUsername: winnerScoreChanged && winnerOwnerId ? usernameByUserId.get(winnerOwnerId) : undefined,
|
||||||
|
loserUsername: showLoser && loserOwnerId ? usernameByUserId.get(loserOwnerId) : undefined,
|
||||||
|
winnerDiscordUserId: winnerScoreChanged && winnerOwnerId ? discordIdByUserId.get(winnerOwnerId) : undefined,
|
||||||
|
loserDiscordUserId: showLoser && loserOwnerId ? discordIdByUserId.get(loserOwnerId) : undefined,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
if (teams.length > 0) eliminatedTeams = teams;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip notification if scores didn't change AND no match has a displayable
|
// Skip notification if scores didn't change AND no match has a displayable username.
|
||||||
// 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
|
||||||
);
|
);
|
||||||
const hasEliminatedToShow = (eliminatedTeams?.length ?? 0) > 0;
|
if (!hasChanges && !hasScoredMatchesToShow) continue;
|
||||||
if (!hasChanges && !hasScoredMatchesToShow && !hasEliminatedToShow) continue;
|
|
||||||
|
|
||||||
const standings = afterStandings.map((s) => ({
|
const standings = afterStandings.map((s) => ({
|
||||||
teamId: s.teamId,
|
teamId: s.teamId,
|
||||||
|
|
@ -2171,7 +1646,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
export type EventType = "playoff_game" | "major_tournament" | "final_standings" | "schedule_event";
|
|
||||||
|
|
||||||
export function getEventTypeLabel(eventType: string): string {
|
|
||||||
switch (eventType) {
|
|
||||||
case "playoff_game": return "Bracket";
|
|
||||||
case "major_tournament": return "Major Tournament";
|
|
||||||
case "final_standings": return "Final Standings";
|
|
||||||
case "schedule_event": return "Non-Scoring";
|
|
||||||
default: return eventType;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -3,11 +3,20 @@ import * as schema from "~/database/schema";
|
||||||
import { eq, and, desc, asc, gte, lte, or, inArray, isNotNull, sql } from "drizzle-orm";
|
import { 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 { recalculateParticipantQP } from "./qualifying-points";
|
import { hasProcessedQualifyingPlacement, recalculateParticipantQP } from "./qualifying-points";
|
||||||
import { findParticipantNamesByIds } from "./season-participant";
|
import { findParticipantNamesByIds } from "./season-participant";
|
||||||
import { deleteTournament } from "./tournament";
|
|
||||||
import type { EventType } from "./scoring-event-types";
|
export type EventType = "playoff_game" | "major_tournament" | "final_standings" | "schedule_event";
|
||||||
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;
|
||||||
|
|
@ -33,8 +42,6 @@ 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -144,33 +151,6 @@ export async function getQualifyingEvents(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Number of "majors completed" for a sports season, derived on read as the count of
|
|
||||||
* qualifying events that have been marked complete. Replaces the old stored
|
|
||||||
* sportsSeasons.majorsCompleted counter, which was incremented per fan-out sync and
|
|
||||||
* over-counted past totalMajors ("11 of 4"). Computing it makes the value
|
|
||||||
* self-correcting and immune to double-counting.
|
|
||||||
*/
|
|
||||||
export async function getMajorsCompleted(
|
|
||||||
sportsSeasonId: string,
|
|
||||||
providedDb?: ReturnType<typeof database>
|
|
||||||
): Promise<number> {
|
|
||||||
const db = providedDb || database();
|
|
||||||
|
|
||||||
const rows = await db
|
|
||||||
.select({ count: sql<number>`count(*)::int` })
|
|
||||||
.from(schema.scoringEvents)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
|
||||||
eq(schema.scoringEvents.isQualifyingEvent, true),
|
|
||||||
eq(schema.scoringEvents.isComplete, true)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
return rows[0]?.count ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a scoring event
|
* Update a scoring event
|
||||||
*/
|
*/
|
||||||
|
|
@ -193,7 +173,6 @@ 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)
|
||||||
|
|
@ -229,55 +208,28 @@ 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) {
|
if (!event) return;
|
||||||
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) => {
|
||||||
|
|
@ -300,49 +252,21 @@ export async function deleteScoringEvent(
|
||||||
await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
|
await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
|
||||||
}
|
}
|
||||||
|
|
||||||
// majorsCompleted is derived on read (getMajorsCompleted), so deleting the event
|
// Decrement majorsCompleted if this event had already been processed
|
||||||
// — which removes it from the completed-qualifying-event count — self-corrects the
|
if (wasQPProcessed) {
|
||||||
// number. No stored counter to decrement.
|
const sportsSeason = await db.query.sportsSeasons.findFirst({
|
||||||
|
where: eq(schema.sportsSeasons.id, event.sportsSeasonId),
|
||||||
|
});
|
||||||
|
if (sportsSeason && (sportsSeason.majorsCompleted ?? 0) > 0) {
|
||||||
|
await db
|
||||||
|
.update(schema.sportsSeasons)
|
||||||
|
.set({ majorsCompleted: (sportsSeason.majorsCompleted ?? 1) - 1, updatedAt: new Date() })
|
||||||
|
.where(eq(schema.sportsSeasons.id, event.sportsSeasonId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await recalculateAffectedLeagues(event.sportsSeasonId, db);
|
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -386,13 +310,10 @@ export async function getUpcomingScoringEvents(
|
||||||
|
|
||||||
if (events.length === 0) return [];
|
if (events.length === 0) return [];
|
||||||
|
|
||||||
// Find earliest upcoming game time per event from both bracket match games
|
// Find earliest upcoming game time per event from 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,
|
||||||
|
|
@ -412,28 +333,10 @@ 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, ...groupStageTimeRows]) {
|
for (const row of gameTimeRows) {
|
||||||
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) {
|
||||||
|
|
@ -450,15 +353,7 @@ export async function getUpcomingScoringEvents(
|
||||||
return candidates.length > 0 ? Math.min(...candidates) : Infinity;
|
return candidates.length > 0 ? Math.min(...candidates) : Infinity;
|
||||||
};
|
};
|
||||||
|
|
||||||
return events
|
return events.toSorted((a, b) => getEffectiveMs(a) - getEffectiveMs(b)).slice(0, limit);
|
||||||
.toSorted((a, b) => getEffectiveMs(a) - getEffectiveMs(b))
|
|
||||||
.slice(0, limit)
|
|
||||||
.map((e) => ({
|
|
||||||
...e,
|
|
||||||
earliestGameTime: earliestGameById.get(e.id)?.toISOString()
|
|
||||||
?? e.eventStartsAt?.toISOString()
|
|
||||||
?? null,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1143,112 +1038,3 @@ 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,21 +134,14 @@ 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", "llws_20"]);
|
const SPLIT_5678_TEMPLATE_IDS = new Set(["afl_10"]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Brackets with a real 3rd place game, meaning positions 3 and 4 are distinct
|
* 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", "llws_20"]);
|
const DISTINCT_34_TEMPLATE_IDS = new Set(["fifa_48"]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate fantasy points for a bracket placement, averaging tied positions.
|
* Calculate fantasy points for a bracket placement, averaging tied positions.
|
||||||
|
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
/**
|
|
||||||
* Race-calendar state for season-standings sports (F1, IndyCar).
|
|
||||||
*
|
|
||||||
* Kept in its own leaf module rather than in `scoring-event.ts` so that
|
|
||||||
* `simulator.ts` can read it: `scoring-event.ts` pulls in `scoring-calculator`,
|
|
||||||
* which reaches `participant-expected-value` and back into `simulator`. This
|
|
||||||
* file imports nothing but the database.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { database } from "~/database/context";
|
|
||||||
import * as schema from "~/database/schema";
|
|
||||||
|
|
||||||
export interface SeasonRaceCounts {
|
|
||||||
completed: number;
|
|
||||||
remaining: number;
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How long after the green flag a race is assumed to have finished.
|
|
||||||
*
|
|
||||||
* `event_starts_at` is a start time, so treating it as "already run" would
|
|
||||||
* declare the season over the moment the finale goes green — and the simulator
|
|
||||||
* would publish the pre-race leader as champion at 100%, from standings that do
|
|
||||||
* not yet include the race being run. No race in these series comes close to
|
|
||||||
* six hours, and the standings feed updates within hours of a finish.
|
|
||||||
*/
|
|
||||||
const RACE_DURATION_MS = 6 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Has this race already been run?
|
|
||||||
*
|
|
||||||
* `is_complete` wins when an admin has set it, but a racing calendar is stored
|
|
||||||
* as "Non-Scoring" rows that nobody ever marks complete, so the date is the real
|
|
||||||
* signal. Mirrors the Upcoming / Results Pending badge on the admin events page.
|
|
||||||
* A race happening today is still upcoming, and a row with no date at all counts
|
|
||||||
* as upcoming.
|
|
||||||
*
|
|
||||||
* @param today `now` as a `YYYY-MM-DD` string, to compare against the date-only
|
|
||||||
* `event_date` column.
|
|
||||||
*/
|
|
||||||
export function hasRaceRun(
|
|
||||||
event: {
|
|
||||||
isComplete: boolean;
|
|
||||||
eventDate: string | null;
|
|
||||||
eventStartsAt: Date | string | null;
|
|
||||||
},
|
|
||||||
now: Date,
|
|
||||||
today: string
|
|
||||||
): boolean {
|
|
||||||
if (event.isComplete) return true;
|
|
||||||
if (event.eventStartsAt) {
|
|
||||||
return new Date(event.eventStartsAt).getTime() + RACE_DURATION_MS < now.getTime();
|
|
||||||
}
|
|
||||||
if (event.eventDate) return event.eventDate < today;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count the races on a season-standings calendar (F1, IndyCar).
|
|
||||||
*
|
|
||||||
* `event_type` has no race value, so a racing calendar is stored as
|
|
||||||
* `schedule_event` rows — the admin default for the `season_standings` scoring
|
|
||||||
* pattern. The only other row such a season carries is the single
|
|
||||||
* `final_standings` event that assigns fantasy placements once the championship
|
|
||||||
* is settled. A race is therefore "every event except `final_standings`", not
|
|
||||||
* "every event except `schedule_event`" — getting that backwards leaves the
|
|
||||||
* simulator with zero remaining races and no idea the season is in progress.
|
|
||||||
*/
|
|
||||||
export async function countSeasonRaces(
|
|
||||||
sportsSeasonId: string,
|
|
||||||
now: Date = new Date(),
|
|
||||||
providedDb?: ReturnType<typeof database>
|
|
||||||
): Promise<SeasonRaceCounts> {
|
|
||||||
const db = providedDb || database();
|
|
||||||
|
|
||||||
const events = await db.query.scoringEvents.findMany({
|
|
||||||
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
|
||||||
columns: {
|
|
||||||
eventType: true,
|
|
||||||
isComplete: true,
|
|
||||||
eventDate: true,
|
|
||||||
eventStartsAt: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const today = now.toISOString().split("T")[0];
|
|
||||||
let completed = 0;
|
|
||||||
let remaining = 0;
|
|
||||||
|
|
||||||
for (const event of events) {
|
|
||||||
if (event.eventType === "final_standings") continue;
|
|
||||||
if (hasRaceRun(event, now, today)) completed++;
|
|
||||||
else remaining++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { completed, remaining, total: completed + remaining };
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { and, eq, inArray, isNotNull, sql } from "drizzle-orm";
|
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import {
|
import {
|
||||||
|
|
@ -8,17 +8,12 @@ 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;
|
||||||
|
|
@ -87,12 +82,6 @@ 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 {
|
||||||
|
|
@ -136,8 +125,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", "blend", "fallbackRating", "averageKnown", "worstKnownMinus"] as const;
|
const GENERATED_RATING_METHODS = ["sourceOdds", "fallbackRating", "averageKnown", "worstKnownMinus"] as const;
|
||||||
const GENERATED_SOURCE_ELO_METHODS = ["projectedWins", "projectedTablePoints", "sourceOdds", "blend", "fallbackElo", "averageKnown", "worstKnownMinus"] as const;
|
const GENERATED_SOURCE_ELO_METHODS = ["projectedWins", "projectedTablePoints", "sourceOdds", "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]);
|
||||||
|
|
@ -311,6 +300,112 @@ export async function getParticipantSimulatorInputs(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generatedRatingMethodSql() {
|
||||||
|
return sql`${schema.seasonParticipantSimulatorInputs.metadata}->>'ratingMethod' in ('sourceOdds', 'fallbackRating', 'averageKnown', 'worstKnownMinus')`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function batchSaveParticipantSimulatorSourceOdds(
|
||||||
|
inputs: Array<{ participantId: string; sportsSeasonId: string; sourceOdds: number }>
|
||||||
|
): Promise<void> {
|
||||||
|
if (inputs.length === 0) return;
|
||||||
|
const db = database();
|
||||||
|
const now = new Date();
|
||||||
|
const seasonIds = [...new Set(inputs.map((input) => input.sportsSeasonId))];
|
||||||
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.update(schema.seasonParticipantSimulatorInputs)
|
||||||
|
.set({
|
||||||
|
rating: null,
|
||||||
|
metadata: sql`coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod'`,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(schema.seasonParticipantSimulatorInputs.sportsSeasonId, seasonIds),
|
||||||
|
generatedRatingMethodSql()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.insert(schema.seasonParticipantSimulatorInputs)
|
||||||
|
.values(
|
||||||
|
inputs.map((input) => ({
|
||||||
|
participantId: input.participantId,
|
||||||
|
sportsSeasonId: input.sportsSeasonId,
|
||||||
|
sourceOdds: input.sourceOdds,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [
|
||||||
|
schema.seasonParticipantSimulatorInputs.participantId,
|
||||||
|
schema.seasonParticipantSimulatorInputs.sportsSeasonId,
|
||||||
|
],
|
||||||
|
set: {
|
||||||
|
sourceOdds: sql`excluded.source_odds`,
|
||||||
|
rating: sql`case when ${generatedRatingMethodSql()} then null else ${schema.seasonParticipantSimulatorInputs.rating} end`,
|
||||||
|
metadata: sql`case when ${generatedRatingMethodSql()} then coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod' else ${schema.seasonParticipantSimulatorInputs.metadata} end`,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function batchSaveFuturesOddsForSimulator(
|
||||||
|
inputs: Array<{ participantId: string; sportsSeasonId: string; sourceOdds: number }>
|
||||||
|
): Promise<void> {
|
||||||
|
if (inputs.length === 0) return;
|
||||||
|
const db = database();
|
||||||
|
const now = new Date();
|
||||||
|
const participantIds = inputs.map((input) => input.participantId);
|
||||||
|
const seasonIds = [...new Set(inputs.map((input) => input.sportsSeasonId))];
|
||||||
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
|
||||||
|
// Clear ALL ratings (both manual and generated) so the simulation
|
||||||
|
// re-derives ratings from the newly saved futures odds.
|
||||||
|
await tx
|
||||||
|
.update(schema.seasonParticipantSimulatorInputs)
|
||||||
|
.set({
|
||||||
|
rating: null,
|
||||||
|
metadata: sql`coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod'`,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(schema.seasonParticipantSimulatorInputs.sportsSeasonId, seasonIds),
|
||||||
|
inArray(schema.seasonParticipantSimulatorInputs.participantId, participantIds)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.insert(schema.seasonParticipantSimulatorInputs)
|
||||||
|
.values(
|
||||||
|
inputs.map((input) => ({
|
||||||
|
participantId: input.participantId,
|
||||||
|
sportsSeasonId: input.sportsSeasonId,
|
||||||
|
sourceOdds: input.sourceOdds,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [
|
||||||
|
schema.seasonParticipantSimulatorInputs.participantId,
|
||||||
|
schema.seasonParticipantSimulatorInputs.sportsSeasonId,
|
||||||
|
],
|
||||||
|
set: {
|
||||||
|
sourceOdds: sql`excluded.source_odds`,
|
||||||
|
rating: null,
|
||||||
|
metadata: sql`coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod'`,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function batchUpsertParticipantSimulatorInputs(
|
export async function batchUpsertParticipantSimulatorInputs(
|
||||||
inputs: UpsertParticipantSimulatorInput[]
|
inputs: UpsertParticipantSimulatorInput[]
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|
@ -343,44 +438,16 @@ 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`COALESCE(excluded.source_odds, ${schema.seasonParticipantSimulatorInputs.sourceOdds})`,
|
sourceOdds: sql`excluded.source_odds`,
|
||||||
sourceElo: sql`COALESCE(excluded.source_elo, ${schema.seasonParticipantSimulatorInputs.sourceElo})`,
|
sourceElo: sql`excluded.source_elo`,
|
||||||
worldRanking: sql`COALESCE(excluded.world_ranking, ${schema.seasonParticipantSimulatorInputs.worldRanking})`,
|
worldRanking: sql`excluded.world_ranking`,
|
||||||
rating: sql`COALESCE(excluded.rating, ${schema.seasonParticipantSimulatorInputs.rating})`,
|
rating: sql`excluded.rating`,
|
||||||
projectedWins: sql`COALESCE(excluded.projected_wins, ${schema.seasonParticipantSimulatorInputs.projectedWins})`,
|
projectedWins: sql`excluded.projected_wins`,
|
||||||
projectedTablePoints: sql`COALESCE(excluded.projected_table_points, ${schema.seasonParticipantSimulatorInputs.projectedTablePoints})`,
|
projectedTablePoints: sql`excluded.projected_table_points`,
|
||||||
seed: sql`COALESCE(excluded.seed, ${schema.seasonParticipantSimulatorInputs.seed})`,
|
seed: sql`excluded.seed`,
|
||||||
region: sql`COALESCE(excluded.region, ${schema.seasonParticipantSimulatorInputs.region})`,
|
region: sql`excluded.region`,
|
||||||
// Metadata carries the method flags (sourceEloMethod/ratingMethod) that
|
metadata: sql`excluded.metadata`,
|
||||||
// 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`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -502,19 +569,6 @@ export async function validateSimulatorReadiness(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (RACE_CALENDAR_SIMULATORS.includes(config.simulatorType)) {
|
|
||||||
// Without a calendar the simulator cannot tell how many races are left, so
|
|
||||||
// it falls back to futures odds and ignores the championship standings
|
|
||||||
// entirely. A warning, not a blocker — a season drafted before the schedule
|
|
||||||
// is published still needs to run.
|
|
||||||
const races = await countSeasonRaces(sportsSeasonId);
|
|
||||||
if (races.total === 0) {
|
|
||||||
warnings.push(
|
|
||||||
"No race calendar found for this season. Add the schedule on the events page — until then the simulation uses futures odds only and ignores championship standings."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.profile.setupSections.includes("regularStandings")) {
|
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.");
|
||||||
}
|
}
|
||||||
|
|
@ -609,23 +663,6 @@ 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(
|
||||||
|
|
@ -633,8 +670,6 @@ 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,
|
||||||
|
|
@ -652,9 +687,6 @@ 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,57 +169,6 @@ export async function findDraftableSportsSeasonBySportId(sportId: string) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DraftScheduleWindow = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
year: number;
|
|
||||||
status: SportsSeasonStatus;
|
|
||||||
draftOn: string;
|
|
||||||
draftOff: string;
|
|
||||||
sport: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
iconUrl: string | null;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns admin sport-seasons (fantasySeasonId IS NULL) whose draft window
|
|
||||||
* [draftOn, draftOff] overlaps the horizon [today, today + monthsAhead]. Uses an
|
|
||||||
* overlap test (not containment) so windows already open, or spanning the horizon
|
|
||||||
* edges, are still included. Powers the admin draft-schedule Gantt chart.
|
|
||||||
*/
|
|
||||||
export async function findDraftScheduleForHorizon(
|
|
||||||
monthsAhead: number
|
|
||||||
): Promise<DraftScheduleWindow[]> {
|
|
||||||
const db = database();
|
|
||||||
const horizonEnd = sql`CURRENT_DATE + make_interval(months => ${monthsAhead})`;
|
|
||||||
const seasons = await db.query.sportsSeasons.findMany({
|
|
||||||
where: (ss, { and }) =>
|
|
||||||
and(
|
|
||||||
isNull(ss.fantasySeasonId),
|
|
||||||
gte(ss.draftOff, sql`CURRENT_DATE`),
|
|
||||||
lte(ss.draftOn, horizonEnd)
|
|
||||||
),
|
|
||||||
orderBy: (sportsSeasons, { asc }) => [asc(sportsSeasons.draftOn)],
|
|
||||||
with: {
|
|
||||||
sport: {
|
|
||||||
columns: { id: true, name: true, slug: true, iconUrl: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return seasons.map((s) => ({
|
|
||||||
id: s.id,
|
|
||||||
name: s.name,
|
|
||||||
year: s.year,
|
|
||||||
status: s.status,
|
|
||||||
draftOn: s.draftOn,
|
|
||||||
draftOff: s.draftOff,
|
|
||||||
sport: s.sport,
|
|
||||||
})) as DraftScheduleWindow[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateSportsSeason(
|
export async function updateSportsSeason(
|
||||||
id: string,
|
id: string,
|
||||||
data: Partial<NewSportsSeason>
|
data: Partial<NewSportsSeason>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ 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
|
||||||
|
|
@ -164,7 +163,11 @@ export async function getTeamScoreBreakdown(
|
||||||
if (bracketTemplateCache.has(sportsSeasonId)) {
|
if (bracketTemplateCache.has(sportsSeasonId)) {
|
||||||
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
||||||
}
|
}
|
||||||
const templateId = await getBracketTemplateIdForSportsSeason(sportsSeasonId, db);
|
const event = await db.query.scoringEvents.findFirst({
|
||||||
|
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||||||
|
columns: { bracketTemplateId: true },
|
||||||
|
});
|
||||||
|
const templateId = event?.bracketTemplateId ?? null;
|
||||||
bracketTemplateCache.set(sportsSeasonId, templateId);
|
bracketTemplateCache.set(sportsSeasonId, templateId);
|
||||||
return templateId;
|
return templateId;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -136,17 +136,3 @@ 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/:section?", "routes/settings.tsx"),
|
route("settings", "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,7 +96,6 @@ 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"),
|
||||||
|
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The Expected Values admin page renders EV from the stored probability columns.
|
|
||||||
*
|
|
||||||
* It used to carry its own hardcoded scoring table (100/70/45/45/20/20/20/20), which
|
|
||||||
* flattened positions 5–8 to 20 points each. For a standard single-elimination bracket
|
|
||||||
* that was invisible — all four quarterfinal losers share one tier worth
|
|
||||||
* avg(25,25,15,15) = 20 anyway — but for the templates that split 5–8 into two tiers
|
|
||||||
* (llws_20, afl_10) it reported a team locked into 5th–6th and a team locked into
|
|
||||||
* 7th–8th as the same 20 points. These pin it to the shared DEFAULT_SCORING_RULES.
|
|
||||||
*/
|
|
||||||
|
|
||||||
vi.mock("../admin.sports-seasons.$id.expected-values.server", () => ({
|
|
||||||
loader: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { evFromProbs } from "../admin.sports-seasons.$id.expected-values";
|
|
||||||
|
|
||||||
const ZERO = {
|
|
||||||
probFirst: "0", probSecond: "0", probThird: "0", probFourth: "0",
|
|
||||||
probFifth: "0", probSixth: "0", probSeventh: "0", probEighth: "0",
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("evFromProbs", () => {
|
|
||||||
it("gives a team locked into the 5th–6th tier 25 points, not 20", () => {
|
|
||||||
expect(evFromProbs({ ...ZERO, probFifth: "0.5", probSixth: "0.5" })).toBe(25);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("gives a team locked into the 7th–8th tier 15 points, not 20", () => {
|
|
||||||
expect(evFromProbs({ ...ZERO, probSeventh: "0.5", probEighth: "0.5" })).toBe(15);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still gives a single 5th–8th tier (4 QF losers) 20 points", () => {
|
|
||||||
const ev = evFromProbs({
|
|
||||||
...ZERO,
|
|
||||||
probFifth: "0.25", probSixth: "0.25", probSeventh: "0.25", probEighth: "0.25",
|
|
||||||
});
|
|
||||||
expect(ev).toBe(20);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps 3rd and 4th distinct rather than a flat 45 each", () => {
|
|
||||||
expect(evFromProbs({ ...ZERO, probThird: "1" })).toBe(50);
|
|
||||||
expect(evFromProbs({ ...ZERO, probFourth: "1" })).toBe(40);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves the 340 total-EV invariant across a full set of unit columns", () => {
|
|
||||||
const perPosition = [
|
|
||||||
evFromProbs({ ...ZERO, probFirst: "1" }),
|
|
||||||
evFromProbs({ ...ZERO, probSecond: "1" }),
|
|
||||||
evFromProbs({ ...ZERO, probThird: "1" }),
|
|
||||||
evFromProbs({ ...ZERO, probFourth: "1" }),
|
|
||||||
evFromProbs({ ...ZERO, probFifth: "1" }),
|
|
||||||
evFromProbs({ ...ZERO, probSixth: "1" }),
|
|
||||||
evFromProbs({ ...ZERO, probSeventh: "1" }),
|
|
||||||
evFromProbs({ ...ZERO, probEighth: "1" }),
|
|
||||||
];
|
|
||||||
expect(perPosition.reduce((sum, ev) => sum + ev, 0)).toBe(340);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 0 for a participant with no probability mass", () => {
|
|
||||||
expect(evFromProbs(ZERO)).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import {
|
|
||||||
parseBaseEloPriorityChoice,
|
|
||||||
projectionMethodMetadata,
|
|
||||||
resolvedInputMethodLabel,
|
|
||||||
} from "../admin.sports-seasons.$id.simulator.helpers";
|
|
||||||
import { DEFAULT_BASE_ELO_PRIORITY } from "~/services/simulations/input-policy";
|
|
||||||
|
|
||||||
describe("projectionMethodMetadata", () => {
|
|
||||||
it("flags a row that supplies projected wins and no Elo", () => {
|
|
||||||
expect(projectionMethodMetadata(undefined, 95, undefined)).toEqual({
|
|
||||||
sourceEloMethod: "projectedWins",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("flags a row that supplies projected table points and no Elo", () => {
|
|
||||||
expect(projectionMethodMetadata(undefined, undefined, 76.5)).toEqual({
|
|
||||||
sourceEloMethod: "projectedTablePoints",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves metadata alone when the row supplies an explicit Elo", () => {
|
|
||||||
// An explicit Elo is a direct entry and must stay trusted, even alongside a
|
|
||||||
// projection — the upsert then clears any stale generated flag.
|
|
||||||
expect(projectionMethodMetadata(1600, 95, undefined)).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves metadata alone for a row with neither", () => {
|
|
||||||
expect(projectionMethodMetadata(undefined, undefined, undefined)).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prefers wins over table points when a row somehow carries both", () => {
|
|
||||||
expect(projectionMethodMetadata(undefined, 95, 76.5)).toEqual({
|
|
||||||
sourceEloMethod: "projectedWins",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("parseBaseEloPriorityChoice", () => {
|
|
||||||
it("puts projections ahead of raw Elo", () => {
|
|
||||||
expect(parseBaseEloPriorityChoice("projectionsFirst", DEFAULT_BASE_ELO_PRIORITY)).toEqual([
|
|
||||||
"projectedWins",
|
|
||||||
"projectedTablePoints",
|
|
||||||
"sourceElo",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("puts raw Elo first for eloFirst", () => {
|
|
||||||
expect(parseBaseEloPriorityChoice("eloFirst", DEFAULT_BASE_ELO_PRIORITY)).toEqual(
|
|
||||||
DEFAULT_BASE_ELO_PRIORITY
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the stored ordering when the select was not on the form", () => {
|
|
||||||
// Simulators with no projection alternative never render the control; saving
|
|
||||||
// other config must not rewrite their ordering.
|
|
||||||
const custom: typeof DEFAULT_BASE_ELO_PRIORITY = ["projectedWins", "sourceElo"];
|
|
||||||
expect(parseBaseEloPriorityChoice(null, custom)).toEqual(custom);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves the relative order of the projection keys", () => {
|
|
||||||
expect(
|
|
||||||
parseBaseEloPriorityChoice("projectionsFirst", [
|
|
||||||
"projectedTablePoints",
|
|
||||||
"sourceElo",
|
|
||||||
"projectedWins",
|
|
||||||
])
|
|
||||||
).toEqual(["projectedTablePoints", "projectedWins", "sourceElo"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("round-trips: flipping back restores Elo-first", () => {
|
|
||||||
const flipped = parseBaseEloPriorityChoice("projectionsFirst", DEFAULT_BASE_ELO_PRIORITY);
|
|
||||||
expect(parseBaseEloPriorityChoice("eloFirst", flipped)).toEqual(DEFAULT_BASE_ELO_PRIORITY);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("resolvedInputMethodLabel", () => {
|
|
||||||
it("badges nothing for a directly entered Elo or rating", () => {
|
|
||||||
expect(resolvedInputMethodLabel("direct")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("badges both projection methods the same way", () => {
|
|
||||||
expect(resolvedInputMethodLabel("projectedWins")).toBe("from projections");
|
|
||||||
expect(resolvedInputMethodLabel("projectedTablePoints")).toBe("from projections");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("distinguishes futures and blended Elo", () => {
|
|
||||||
expect(resolvedInputMethodLabel("sourceOdds")).toBe("from futures");
|
|
||||||
expect(resolvedInputMethodLabel("blend")).toBe("blended");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("badges every missing-input strategy as a fallback", () => {
|
|
||||||
expect(resolvedInputMethodLabel("fallbackElo")).toBe("fallback");
|
|
||||||
expect(resolvedInputMethodLabel("fallbackRating")).toBe("fallback");
|
|
||||||
expect(resolvedInputMethodLabel("averageKnown")).toBe("fallback");
|
|
||||||
expect(resolvedInputMethodLabel("worstKnownMinus")).toBe("fallback");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
/**
|
|
||||||
* clear-bracket is the only path that can tear down a bracket, so the guard around it
|
|
||||||
* matters: it discards recorded results and the placements derived from them.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import {
|
|
||||||
findPlayoffMatchesByEventId,
|
|
||||||
deletePlayoffMatchesByEventId,
|
|
||||||
} from "~/models/playoff-match";
|
|
||||||
import { deleteParticipantResultsBySportsSeasonId } from "~/models/participant-result";
|
|
||||||
import { recalculateAffectedLeagues } from "~/models/scoring-calculator";
|
|
||||||
import { getScoringEventById } from "~/models/scoring-event";
|
|
||||||
import { action } from "../admin.sports-seasons.$id.events.$eventId.bracket.server";
|
|
||||||
|
|
||||||
vi.mock("~/models/scoring-event", async (importOriginal) => ({
|
|
||||||
...(await importOriginal<object>()),
|
|
||||||
getScoringEventById: vi.fn(),
|
|
||||||
updateScoringEvent: vi.fn(),
|
|
||||||
isReadOnlySibling: vi.fn(() => false),
|
|
||||||
}));
|
|
||||||
vi.mock("~/models/playoff-match", async (importOriginal) => ({
|
|
||||||
...(await importOriginal<object>()),
|
|
||||||
findPlayoffMatchesByEventId: vi.fn(),
|
|
||||||
deletePlayoffMatchesByEventId: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("~/models/participant-result", async (importOriginal) => ({
|
|
||||||
...(await importOriginal<object>()),
|
|
||||||
deleteParticipantResultsBySportsSeasonId: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("~/models/scoring-calculator", async (importOriginal) => ({
|
|
||||||
...(await importOriginal<object>()),
|
|
||||||
recalculateAffectedLeagues: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const EVENT = { id: "event-1", sportsSeasonId: "season-1" };
|
|
||||||
const params = { id: "season-1", eventId: "event-1" };
|
|
||||||
|
|
||||||
function clearRequest(confirm?: string): Request {
|
|
||||||
const body = new FormData();
|
|
||||||
body.set("intent", "clear-bracket");
|
|
||||||
if (confirm !== undefined) body.set("confirm", confirm);
|
|
||||||
return new Request("http://localhost/clear", { method: "POST", body });
|
|
||||||
}
|
|
||||||
|
|
||||||
function match(isComplete: boolean) {
|
|
||||||
return { id: `m-${Math.random()}`, isComplete };
|
|
||||||
}
|
|
||||||
|
|
||||||
// The action's real signature carries React Router's generated types; the clear path
|
|
||||||
// only reads request and params.
|
|
||||||
const run = (request: Request) =>
|
|
||||||
(action as unknown as (args: { request: Request; params: typeof params }) => Promise<{
|
|
||||||
error?: string;
|
|
||||||
success?: string;
|
|
||||||
}>)({ request, params });
|
|
||||||
|
|
||||||
describe("clear-bracket", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
vi.mocked(getScoringEventById).mockResolvedValue(
|
|
||||||
EVENT as unknown as Awaited<ReturnType<typeof getScoringEventById>>
|
|
||||||
);
|
|
||||||
vi.mocked(deletePlayoffMatchesByEventId).mockResolvedValue(undefined);
|
|
||||||
vi.mocked(deleteParticipantResultsBySportsSeasonId).mockResolvedValue(undefined);
|
|
||||||
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(
|
|
||||||
undefined as unknown as Awaited<ReturnType<typeof recalculateAffectedLeagues>>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("deletes the matches", async () => {
|
|
||||||
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([
|
|
||||||
match(false),
|
|
||||||
match(false),
|
|
||||||
] as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>);
|
|
||||||
|
|
||||||
const result = await run(clearRequest());
|
|
||||||
|
|
||||||
expect(result.success).toContain("2 match(es) removed");
|
|
||||||
expect(deletePlayoffMatchesByEventId).toHaveBeenCalledWith("event-1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves placements alone — they belong to the whole season, not this event", async () => {
|
|
||||||
// seasonParticipantResults is keyed by sports season, so deleting here would wipe
|
|
||||||
// every other event's placements with nothing to rebuild them. Reprocess Bracket is
|
|
||||||
// the tool that rebuilds them correctly.
|
|
||||||
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([
|
|
||||||
match(true),
|
|
||||||
] as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>);
|
|
||||||
|
|
||||||
const result = await run(clearRequest("true"));
|
|
||||||
|
|
||||||
expect(deleteParticipantResultsBySportsSeasonId).not.toHaveBeenCalled();
|
|
||||||
expect(result.success).toContain("Reprocess Bracket");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refuses to discard completed matches without confirmation", async () => {
|
|
||||||
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([
|
|
||||||
match(true),
|
|
||||||
match(false),
|
|
||||||
] as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>);
|
|
||||||
|
|
||||||
const result = await run(clearRequest());
|
|
||||||
|
|
||||||
expect(result.error).toContain("1 completed match(es)");
|
|
||||||
expect(deletePlayoffMatchesByEventId).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("discards completed matches once confirmed", async () => {
|
|
||||||
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([
|
|
||||||
match(true),
|
|
||||||
] as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>);
|
|
||||||
|
|
||||||
const result = await run(clearRequest("true"));
|
|
||||||
|
|
||||||
expect(result.success).toBeDefined();
|
|
||||||
expect(deletePlayoffMatchesByEventId).toHaveBeenCalledWith("event-1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects an event with no bracket rather than reporting a no-op success", async () => {
|
|
||||||
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue(
|
|
||||||
[] as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await run(clearRequest("true"));
|
|
||||||
|
|
||||||
expect(result.error).toContain("no bracket to clear");
|
|
||||||
expect(deletePlayoffMatchesByEventId).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,203 +0,0 @@
|
||||||
/**
|
|
||||||
* generate-bracket banks the floors a seeding guarantees before anyone plays (an AFL
|
|
||||||
* top-4 seed cannot finish below the 5th-6th tier). Those floors only reach
|
|
||||||
* teamStandings.totalPoints through a standings recalculation, so the action has to be
|
|
||||||
* sure one ran — markEliminatedAndAnnounce runs one for its Discord announcement in some
|
|
||||||
* cases but not others.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { generateBracketFromTemplate } from "~/models/playoff-match";
|
|
||||||
import {
|
|
||||||
findParticipantResultsBySportsSeasonId,
|
|
||||||
setParticipantResult,
|
|
||||||
} from "~/models/participant-result";
|
|
||||||
import {
|
|
||||||
applyBracketEntryFloors,
|
|
||||||
recalculateAffectedLeagues,
|
|
||||||
} from "~/models/scoring-calculator";
|
|
||||||
import { getScoringEventById, updateScoringEvent } from "~/models/scoring-event";
|
|
||||||
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
|
||||||
import { action } from "../admin.sports-seasons.$id.events.$eventId.bracket.server";
|
|
||||||
|
|
||||||
vi.mock("~/database/context", () => ({ database: vi.fn(() => ({})) }));
|
|
||||||
vi.mock("~/models/scoring-event", async (importOriginal) => ({
|
|
||||||
...(await importOriginal<object>()),
|
|
||||||
getScoringEventById: vi.fn(),
|
|
||||||
updateScoringEvent: vi.fn(),
|
|
||||||
isReadOnlySibling: vi.fn(() => false),
|
|
||||||
}));
|
|
||||||
vi.mock("~/models/playoff-match", async (importOriginal) => ({
|
|
||||||
...(await importOriginal<object>()),
|
|
||||||
generateBracketFromTemplate: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("~/models/participant-result", async (importOriginal) => ({
|
|
||||||
...(await importOriginal<object>()),
|
|
||||||
findParticipantResultsBySportsSeasonId: vi.fn(),
|
|
||||||
setParticipantResult: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("~/models/scoring-calculator", async (importOriginal) => ({
|
|
||||||
...(await importOriginal<object>()),
|
|
||||||
applyBracketEntryFloors: vi.fn(),
|
|
||||||
recalculateAffectedLeagues: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("~/models/season-participant", async (importOriginal) => ({
|
|
||||||
...(await importOriginal<object>()),
|
|
||||||
findParticipantsBySportsSeasonId: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const params = { id: "season-1", eventId: "event-1" };
|
|
||||||
|
|
||||||
const EVENT = {
|
|
||||||
id: "event-1",
|
|
||||||
name: "AFL Finals",
|
|
||||||
sportsSeasonId: "season-1",
|
|
||||||
isQualifyingEvent: false,
|
|
||||||
bracketTemplateId: "afl_10",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** afl_10 takes exactly 10 seeded participants. */
|
|
||||||
const SEEDED = Array.from({ length: 10 }, (_, i) => `seed-${i + 1}`);
|
|
||||||
|
|
||||||
function generateRequest(): Request {
|
|
||||||
const body = new FormData();
|
|
||||||
body.set("intent", "generate-bracket");
|
|
||||||
body.set("templateId", "afl_10");
|
|
||||||
SEEDED.forEach((id, i) => body.set(`participant${i}`, id));
|
|
||||||
return new Request("http://localhost/generate", { method: "POST", body });
|
|
||||||
}
|
|
||||||
|
|
||||||
const run = (request: Request) =>
|
|
||||||
(action as unknown as (args: { request: Request; params: typeof params }) => Promise<{
|
|
||||||
error?: string;
|
|
||||||
success?: string;
|
|
||||||
}>)({ request, params });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param extras participants in the season beyond the 10 seeded into the bracket —
|
|
||||||
* these are the ones generate-bracket marks eliminated.
|
|
||||||
* @param withExistingResults ids that already carry a result row, so
|
|
||||||
* markEliminatedAndAnnounce treats them as not newly eliminated.
|
|
||||||
*/
|
|
||||||
function setSeason(extras: string[], withExistingResults: string[] = []) {
|
|
||||||
vi.mocked(findParticipantsBySportsSeasonId).mockResolvedValue(
|
|
||||||
[...SEEDED, ...extras].map((id) => ({ id })) as unknown as Awaited<
|
|
||||||
ReturnType<typeof findParticipantsBySportsSeasonId>
|
|
||||||
>
|
|
||||||
);
|
|
||||||
vi.mocked(findParticipantResultsBySportsSeasonId).mockResolvedValue(
|
|
||||||
withExistingResults.map((participantId) => ({ participantId })) as unknown as Awaited<
|
|
||||||
ReturnType<typeof findParticipantResultsBySportsSeasonId>
|
|
||||||
>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("generate-bracket entry-floor standings recalculation", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
vi.mocked(getScoringEventById).mockResolvedValue(
|
|
||||||
EVENT as unknown as Awaited<ReturnType<typeof getScoringEventById>>
|
|
||||||
);
|
|
||||||
vi.mocked(generateBracketFromTemplate).mockResolvedValue(
|
|
||||||
undefined as unknown as Awaited<ReturnType<typeof generateBracketFromTemplate>>
|
|
||||||
);
|
|
||||||
vi.mocked(updateScoringEvent).mockResolvedValue(
|
|
||||||
undefined as unknown as Awaited<ReturnType<typeof updateScoringEvent>>
|
|
||||||
);
|
|
||||||
vi.mocked(setParticipantResult).mockResolvedValue(
|
|
||||||
undefined as unknown as Awaited<ReturnType<typeof setParticipantResult>>
|
|
||||||
);
|
|
||||||
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(
|
|
||||||
undefined as unknown as Awaited<ReturnType<typeof recalculateAffectedLeagues>>
|
|
||||||
);
|
|
||||||
// afl_10 seeds 1-4 into the Qualifying Finals, whose entry floor is the 5th-6th tier.
|
|
||||||
vi.mocked(applyBracketEntryFloors).mockResolvedValue(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("recalculates when every eliminated team already had a result row", async () => {
|
|
||||||
// The second run of a generation: the first wrote position 0 for the non-bracket
|
|
||||||
// participants, so nobody is *newly* eliminated and the announcement is skipped.
|
|
||||||
// The floors banked moments ago would never reach the standings.
|
|
||||||
setSeason(["extra-1"], ["extra-1"]);
|
|
||||||
|
|
||||||
const result = await run(generateRequest());
|
|
||||||
|
|
||||||
expect(result.success).toBeDefined();
|
|
||||||
expect(recalculateAffectedLeagues).toHaveBeenCalledTimes(1);
|
|
||||||
expect(recalculateAffectedLeagues).toHaveBeenCalledWith(
|
|
||||||
"season-1",
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({ skipDiscord: true })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("recalculates for a qualifying event, which never announces eliminations", async () => {
|
|
||||||
vi.mocked(getScoringEventById).mockResolvedValue(
|
|
||||||
{ ...EVENT, isQualifyingEvent: true } as unknown as Awaited<
|
|
||||||
ReturnType<typeof getScoringEventById>
|
|
||||||
>
|
|
||||||
);
|
|
||||||
setSeason(["extra-1"]);
|
|
||||||
|
|
||||||
await run(generateRequest());
|
|
||||||
|
|
||||||
expect(recalculateAffectedLeagues).toHaveBeenCalledTimes(1);
|
|
||||||
expect(recalculateAffectedLeagues).toHaveBeenCalledWith(
|
|
||||||
"season-1",
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({ skipDiscord: true })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("recalculates when the bracket field is the whole season", async () => {
|
|
||||||
setSeason([]);
|
|
||||||
|
|
||||||
await run(generateRequest());
|
|
||||||
|
|
||||||
expect(recalculateAffectedLeagues).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("recalculates when the elimination announcement threw", async () => {
|
|
||||||
// The announcement is best-effort and its failure is swallowed — but a failed recalc
|
|
||||||
// is exactly when the floors still need one.
|
|
||||||
setSeason(["extra-1"]);
|
|
||||||
vi.mocked(recalculateAffectedLeagues)
|
|
||||||
.mockRejectedValueOnce(new Error("discord down"))
|
|
||||||
.mockResolvedValue(undefined as unknown as Awaited<ReturnType<typeof recalculateAffectedLeagues>>);
|
|
||||||
|
|
||||||
const result = await run(generateRequest());
|
|
||||||
|
|
||||||
expect(result.success).toBeDefined();
|
|
||||||
expect(recalculateAffectedLeagues).toHaveBeenCalledTimes(2);
|
|
||||||
expect(recalculateAffectedLeagues).toHaveBeenLastCalledWith(
|
|
||||||
"season-1",
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({ skipDiscord: true })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not recalculate twice when the announcement already did", async () => {
|
|
||||||
setSeason(["extra-1"]);
|
|
||||||
|
|
||||||
await run(generateRequest());
|
|
||||||
|
|
||||||
expect(recalculateAffectedLeagues).toHaveBeenCalledTimes(1);
|
|
||||||
// The announcing call, not the floor fallback.
|
|
||||||
expect(recalculateAffectedLeagues).toHaveBeenCalledWith(
|
|
||||||
"season-1",
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({ eliminatedParticipantIds: ["extra-1"] })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not recalculate at all when no floors were banked", async () => {
|
|
||||||
// A template that guarantees nothing at seeding: no floors, nobody to eliminate,
|
|
||||||
// so there is nothing for a recalculation to pick up.
|
|
||||||
vi.mocked(applyBracketEntryFloors).mockResolvedValue(0);
|
|
||||||
setSeason([]);
|
|
||||||
|
|
||||||
await run(generateRequest());
|
|
||||||
|
|
||||||
expect(recalculateAffectedLeagues).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { newlyDecidedLosers } from "../admin.sports-seasons.$id.events.$eventId.bracket.helpers";
|
|
||||||
|
|
||||||
describe("newlyDecidedLosers", () => {
|
|
||||||
it("returns losers of matches that were not already complete", () => {
|
|
||||||
expect(
|
|
||||||
newlyDecidedLosers([
|
|
||||||
{ wasComplete: false, loserId: "sp-1" },
|
|
||||||
{ wasComplete: false, loserId: "sp-2" },
|
|
||||||
])
|
|
||||||
).toEqual(["sp-1", "sp-2"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops re-scores of already-complete matches (no re-announce)", () => {
|
|
||||||
// sp-1's match just finished; sp-2's match was already complete before this
|
|
||||||
// action — only sp-1 is a NEW knockout.
|
|
||||||
expect(
|
|
||||||
newlyDecidedLosers([
|
|
||||||
{ wasComplete: false, loserId: "sp-1" },
|
|
||||||
{ wasComplete: true, loserId: "sp-2" },
|
|
||||||
])
|
|
||||||
).toEqual(["sp-1"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skips entries with an unknown loser", () => {
|
|
||||||
expect(
|
|
||||||
newlyDecidedLosers([
|
|
||||||
{ wasComplete: false, loserId: null },
|
|
||||||
{ wasComplete: false, loserId: "sp-3" },
|
|
||||||
])
|
|
||||||
).toEqual(["sp-3"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns an empty array when nothing was newly decided", () => {
|
|
||||||
expect(
|
|
||||||
newlyDecidedLosers([
|
|
||||||
{ wasComplete: true, loserId: "sp-1" },
|
|
||||||
{ wasComplete: true, loserId: "sp-2" },
|
|
||||||
])
|
|
||||||
).toEqual([]);
|
|
||||||
expect(newlyDecidedLosers([])).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,208 +0,0 @@
|
||||||
/**
|
|
||||||
* reprocess-bracket rebuilds a bracket's placements from scratch. What it wipes first
|
|
||||||
* decides whether the clear-bracket → regenerate → reprocess repair path actually works,
|
|
||||||
* and whether it takes the rest of the season's placements down with it.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { findPlayoffMatchesByEventId } from "~/models/playoff-match";
|
|
||||||
import {
|
|
||||||
deleteParticipantResultsBySportsSeasonId,
|
|
||||||
deleteParticipantResultsForParticipants,
|
|
||||||
setParticipantResult,
|
|
||||||
} from "~/models/participant-result";
|
|
||||||
import {
|
|
||||||
applyBracketEntryFloors,
|
|
||||||
processMatchResult,
|
|
||||||
processQualifyingBracketEvent,
|
|
||||||
recalculateAffectedLeagues,
|
|
||||||
} from "~/models/scoring-calculator";
|
|
||||||
import { getScoringEventById } from "~/models/scoring-event";
|
|
||||||
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
|
||||||
import { findSportsSeasonById } from "~/models/sports-season";
|
|
||||||
import { action } from "../admin.sports-seasons.$id.events.$eventId.bracket.server";
|
|
||||||
|
|
||||||
vi.mock("~/database/context", () => ({ database: vi.fn(() => ({})) }));
|
|
||||||
vi.mock("~/models/scoring-event", async (importOriginal) => ({
|
|
||||||
...(await importOriginal<object>()),
|
|
||||||
getScoringEventById: vi.fn(),
|
|
||||||
updateScoringEvent: vi.fn(),
|
|
||||||
isReadOnlySibling: vi.fn(() => false),
|
|
||||||
}));
|
|
||||||
vi.mock("~/models/playoff-match", async (importOriginal) => ({
|
|
||||||
...(await importOriginal<object>()),
|
|
||||||
findPlayoffMatchesByEventId: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("~/models/participant-result", async (importOriginal) => ({
|
|
||||||
...(await importOriginal<object>()),
|
|
||||||
deleteParticipantResultsBySportsSeasonId: vi.fn(),
|
|
||||||
deleteParticipantResultsForParticipants: vi.fn(),
|
|
||||||
setParticipantResult: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("~/models/scoring-calculator", async (importOriginal) => ({
|
|
||||||
...(await importOriginal<object>()),
|
|
||||||
applyBracketEntryFloors: vi.fn(),
|
|
||||||
processMatchResult: vi.fn(),
|
|
||||||
recalculateAffectedLeagues: vi.fn(),
|
|
||||||
processQualifyingBracketEvent: vi.fn(),
|
|
||||||
finalizeQualifyingPoints: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("~/models/season-participant", async (importOriginal) => ({
|
|
||||||
...(await importOriginal<object>()),
|
|
||||||
findParticipantsBySportsSeasonId: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("~/models/sports-season", async (importOriginal) => ({
|
|
||||||
...(await importOriginal<object>()),
|
|
||||||
findSportsSeasonById: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const params = { id: "season-1", eventId: "event-1" };
|
|
||||||
|
|
||||||
const EVENT = {
|
|
||||||
id: "event-1",
|
|
||||||
name: "AFL Finals",
|
|
||||||
sportsSeasonId: "season-1",
|
|
||||||
isQualifyingEvent: false,
|
|
||||||
isPrimary: false,
|
|
||||||
tournamentId: null,
|
|
||||||
bracketTemplateId: "afl_10",
|
|
||||||
};
|
|
||||||
|
|
||||||
function reprocessRequest(): Request {
|
|
||||||
const body = new FormData();
|
|
||||||
body.set("intent", "reprocess-bracket");
|
|
||||||
return new Request("http://localhost/reprocess", { method: "POST", body });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A seeded, unplayed bracket slot. */
|
|
||||||
function slot(matchNumber: number, participant1Id: string, participant2Id: string) {
|
|
||||||
return {
|
|
||||||
id: `m-${matchNumber}`,
|
|
||||||
round: "Qualifying Finals",
|
|
||||||
matchNumber,
|
|
||||||
participant1Id,
|
|
||||||
participant2Id,
|
|
||||||
winnerId: null,
|
|
||||||
loserId: null,
|
|
||||||
isComplete: false,
|
|
||||||
isScoring: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const run = (request: Request) =>
|
|
||||||
(action as unknown as (args: { request: Request; params: typeof params }) => Promise<{
|
|
||||||
error?: string;
|
|
||||||
success?: string;
|
|
||||||
}>)({ request, params });
|
|
||||||
|
|
||||||
function setEvent(overrides: Partial<typeof EVENT> = {}) {
|
|
||||||
vi.mocked(getScoringEventById).mockResolvedValue(
|
|
||||||
{ ...EVENT, ...overrides } as unknown as Awaited<ReturnType<typeof getScoringEventById>>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setMatches(matches: ReturnType<typeof slot>[]) {
|
|
||||||
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue(
|
|
||||||
matches as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("reprocess-bracket", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
setEvent();
|
|
||||||
vi.mocked(applyBracketEntryFloors).mockResolvedValue(4);
|
|
||||||
vi.mocked(findParticipantsBySportsSeasonId).mockResolvedValue(
|
|
||||||
[] as unknown as Awaited<ReturnType<typeof findParticipantsBySportsSeasonId>>
|
|
||||||
);
|
|
||||||
vi.mocked(setParticipantResult).mockResolvedValue(
|
|
||||||
undefined as unknown as Awaited<ReturnType<typeof setParticipantResult>>
|
|
||||||
);
|
|
||||||
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(
|
|
||||||
undefined as unknown as Awaited<ReturnType<typeof recalculateAffectedLeagues>>
|
|
||||||
);
|
|
||||||
vi.mocked(processMatchResult).mockResolvedValue(
|
|
||||||
undefined as unknown as Awaited<ReturnType<typeof processMatchResult>>
|
|
||||||
);
|
|
||||||
vi.mocked(processQualifyingBracketEvent).mockResolvedValue(
|
|
||||||
undefined as unknown as Awaited<ReturnType<typeof processQualifyingBracketEvent>>
|
|
||||||
);
|
|
||||||
vi.mocked(findSportsSeasonById).mockResolvedValue(
|
|
||||||
{ qualifyingPointsFinalized: false } as unknown as Awaited<
|
|
||||||
ReturnType<typeof findSportsSeasonById>
|
|
||||||
>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clears placements even when no match has been played", async () => {
|
|
||||||
// The clear-bracket → regenerate → reprocess repair path lands here: the freshly
|
|
||||||
// re-seeded bracket has nothing completed, yet the discarded bracket's finalized
|
|
||||||
// placements are exactly what has to go. Skipping the wipe leaves them permanently,
|
|
||||||
// because upsertParticipantResult refuses to un-finalize a result.
|
|
||||||
setMatches([slot(1, "p1", "p2"), slot(2, "p3", "p4")]);
|
|
||||||
|
|
||||||
const result = await run(reprocessRequest());
|
|
||||||
|
|
||||||
expect(result.success).toBeDefined();
|
|
||||||
expect(deleteParticipantResultsForParticipants).toHaveBeenCalledTimes(1);
|
|
||||||
const [sportsSeasonId, ids] = vi.mocked(deleteParticipantResultsForParticipants).mock.calls[0];
|
|
||||||
expect(sportsSeasonId).toBe("season-1");
|
|
||||||
expect([...ids].toSorted()).toEqual(["p1", "p2", "p3", "p4"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("scopes the wipe to this bracket, never the whole season", async () => {
|
|
||||||
// A season-wide delete would take every other event's placements with it, with only
|
|
||||||
// this bracket's replay able to rebuild them.
|
|
||||||
setMatches([slot(1, "p1", "p2")]);
|
|
||||||
|
|
||||||
await run(reprocessRequest());
|
|
||||||
|
|
||||||
expect(deleteParticipantResultsBySportsSeasonId).not.toHaveBeenCalled();
|
|
||||||
const [, ids] = vi.mocked(deleteParticipantResultsForParticipants).mock.calls[0];
|
|
||||||
expect(ids).not.toContain("p3");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes each participant once when a team appears in more than one slot", async () => {
|
|
||||||
setMatches([slot(1, "p1", "p2"), slot(2, "p1", "p3")]);
|
|
||||||
|
|
||||||
await run(reprocessRequest());
|
|
||||||
|
|
||||||
const [, ids] = vi.mocked(deleteParticipantResultsForParticipants).mock.calls[0];
|
|
||||||
expect(ids).toHaveLength(3);
|
|
||||||
expect([...ids].toSorted()).toEqual(["p1", "p2", "p3"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skips empty slots rather than passing nulls through", async () => {
|
|
||||||
setMatches([
|
|
||||||
{ ...slot(1, "p1", "p2"), participant2Id: null as unknown as string },
|
|
||||||
]);
|
|
||||||
|
|
||||||
await run(reprocessRequest());
|
|
||||||
|
|
||||||
const [, ids] = vi.mocked(deleteParticipantResultsForParticipants).mock.calls[0];
|
|
||||||
expect(ids).toEqual(["p1"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still takes the season-wide delete for a qualifying event", async () => {
|
|
||||||
// Qualifying seasons have no legitimate per-major fantasy placements — those come
|
|
||||||
// from finalizeQualifyingPoints across all majors — so that path wipes the season
|
|
||||||
// on purpose and rebuilds QP from the bracket.
|
|
||||||
setEvent({ isQualifyingEvent: true });
|
|
||||||
setMatches([slot(1, "p1", "p2")]);
|
|
||||||
|
|
||||||
await run(reprocessRequest());
|
|
||||||
|
|
||||||
expect(deleteParticipantResultsBySportsSeasonId).toHaveBeenCalledWith("season-1", {});
|
|
||||||
expect(deleteParticipantResultsForParticipants).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects an event with no bracket rather than wiping anything", async () => {
|
|
||||||
setMatches([]);
|
|
||||||
|
|
||||||
const result = await run(reprocessRequest());
|
|
||||||
|
|
||||||
expect(result.error).toContain("No bracket to reprocess");
|
|
||||||
expect(deleteParticipantResultsForParticipants).not.toHaveBeenCalled();
|
|
||||||
expect(deleteParticipantResultsBySportsSeasonId).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,146 +0,0 @@
|
||||||
/**
|
|
||||||
* The Fix Semi-Final Pairings admin action.
|
|
||||||
*
|
|
||||||
* Elimination Final n feeds Semi-Final n, but brackets advanced before that was fixed
|
|
||||||
* crossed the two winners, and nothing re-runs advancement — a completed match cannot be
|
|
||||||
* re-submitted from the UI.
|
|
||||||
*
|
|
||||||
* It moves qualifier slots only — no scoring runs, so nothing reaches Discord.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { reseedAflSemiFinals } from "~/models/playoff-match";
|
|
||||||
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
|
||||||
import { getScoringEventById } from "~/models/scoring-event";
|
|
||||||
import { processMatchResult, recalculateAffectedLeagues } from "~/models/scoring-calculator";
|
|
||||||
import { sendDiscordWebhook } from "~/services/discord";
|
|
||||||
import { action } from "../admin.sports-seasons.$id.events.$eventId.bracket.server";
|
|
||||||
|
|
||||||
vi.mock("~/database/context", () => ({ database: vi.fn(() => ({})) }));
|
|
||||||
vi.mock("~/models/scoring-event", async (importOriginal) => ({
|
|
||||||
...(await importOriginal<object>()),
|
|
||||||
getScoringEventById: vi.fn(),
|
|
||||||
isReadOnlySibling: vi.fn(() => false),
|
|
||||||
}));
|
|
||||||
vi.mock("~/models/playoff-match", async (importOriginal) => ({
|
|
||||||
...(await importOriginal<object>()),
|
|
||||||
reseedAflSemiFinals: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("~/models/season-participant", async (importOriginal) => ({
|
|
||||||
...(await importOriginal<object>()),
|
|
||||||
findParticipantsBySportsSeasonId: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("~/models/scoring-calculator", async (importOriginal) => ({
|
|
||||||
...(await importOriginal<object>()),
|
|
||||||
processMatchResult: vi.fn(),
|
|
||||||
recalculateAffectedLeagues: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("~/services/discord", async (importOriginal) => ({
|
|
||||||
...(await importOriginal<object>()),
|
|
||||||
sendDiscordWebhook: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const params = { id: "season-1", eventId: "event-1" };
|
|
||||||
|
|
||||||
const EVENT = {
|
|
||||||
id: "event-1",
|
|
||||||
name: "AFL Finals",
|
|
||||||
sportsSeasonId: "season-1",
|
|
||||||
isQualifyingEvent: false,
|
|
||||||
bracketTemplateId: "afl_10",
|
|
||||||
};
|
|
||||||
|
|
||||||
function request() {
|
|
||||||
const body = new FormData();
|
|
||||||
body.set("intent", "reseed-afl-semifinals");
|
|
||||||
return new Request("http://localhost/bracket", { method: "POST", body });
|
|
||||||
}
|
|
||||||
|
|
||||||
const run = () => action({ request: request(), params } as never);
|
|
||||||
|
|
||||||
describe("reseed-afl-semifinals", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
vi.mocked(getScoringEventById).mockResolvedValue(EVENT as never);
|
|
||||||
vi.mocked(findParticipantsBySportsSeasonId).mockResolvedValue([
|
|
||||||
{ id: "geelong", name: "Geelong Cats" },
|
|
||||||
{ id: "adelaide", name: "Adelaide Crows" },
|
|
||||||
] as never);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("names the teams that moved", async () => {
|
|
||||||
vi.mocked(reseedAflSemiFinals).mockResolvedValue({
|
|
||||||
vacated: [1, 2],
|
|
||||||
filled: [
|
|
||||||
{ matchNumber: 2, participantId: "adelaide" },
|
|
||||||
{ matchNumber: 1, participantId: "geelong" },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await run();
|
|
||||||
|
|
||||||
expect(reseedAflSemiFinals).toHaveBeenCalledWith("event-1");
|
|
||||||
expect(result).toEqual({
|
|
||||||
success:
|
|
||||||
"Re-seeded the Semi-Finals: match 1 now hosts Geelong Cats, " +
|
|
||||||
"match 2 now hosts Adelaide Crows.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports a slot that was emptied without being refilled", async () => {
|
|
||||||
// Un-recording an Elimination Final result takes its winner back out of the semi.
|
|
||||||
vi.mocked(reseedAflSemiFinals).mockResolvedValue({
|
|
||||||
vacated: [1, 2],
|
|
||||||
filled: [{ matchNumber: 2, participantId: "adelaide" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await run()).toEqual({
|
|
||||||
success:
|
|
||||||
"Re-seeded the Semi-Finals: match 1 is back to TBD, " +
|
|
||||||
"match 2 now hosts Adelaide Crows.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("says so when the pairings are already right", async () => {
|
|
||||||
vi.mocked(reseedAflSemiFinals).mockResolvedValue({ vacated: [], filled: [] });
|
|
||||||
|
|
||||||
expect(await run()).toEqual({
|
|
||||||
success: "Semi-Finals already match the Elimination Finals results — nothing to re-seed.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("scores nothing and announces nothing", async () => {
|
|
||||||
vi.mocked(reseedAflSemiFinals).mockResolvedValue({
|
|
||||||
vacated: [1, 2],
|
|
||||||
filled: [{ matchNumber: 1, participantId: "geelong" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
await run();
|
|
||||||
|
|
||||||
expect(processMatchResult).not.toHaveBeenCalled();
|
|
||||||
expect(recalculateAffectedLeagues).not.toHaveBeenCalled();
|
|
||||||
expect(sendDiscordWebhook).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refuses a bracket that is not an AFL finals bracket", async () => {
|
|
||||||
vi.mocked(getScoringEventById).mockResolvedValue({
|
|
||||||
...EVENT,
|
|
||||||
bracketTemplateId: "nfl_14",
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
expect(await run()).toEqual({
|
|
||||||
error: "This action only applies to AFL finals brackets",
|
|
||||||
});
|
|
||||||
expect(reseedAflSemiFinals).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("surfaces a refusal to re-seed a game that has been played", async () => {
|
|
||||||
vi.mocked(reseedAflSemiFinals).mockRejectedValue(
|
|
||||||
new Error("Semi-Finals match 1 already has a recorded result")
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(await run()).toEqual({
|
|
||||||
error: "Semi-Finals match 1 already has a recorded result",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
/**
|
|
||||||
* The Re-seed Wildcard Winners admin action.
|
|
||||||
*
|
|
||||||
* Advancement pairs the Wildcard winners with 5th and 6th by ladder position on every
|
|
||||||
* result, so this action exists for brackets advanced before that rule: their winners sit
|
|
||||||
* in the wrong Elimination Finals and nothing re-runs advancement, because a completed
|
|
||||||
* match cannot be re-submitted from the UI.
|
|
||||||
*
|
|
||||||
* It moves qualifier slots only — no scoring runs, so nothing reaches Discord.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { reseedAflEliminationFinals } from "~/models/playoff-match";
|
|
||||||
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
|
||||||
import { getScoringEventById } from "~/models/scoring-event";
|
|
||||||
import { processMatchResult, recalculateAffectedLeagues } from "~/models/scoring-calculator";
|
|
||||||
import { sendDiscordWebhook } from "~/services/discord";
|
|
||||||
import { action } from "../admin.sports-seasons.$id.events.$eventId.bracket.server";
|
|
||||||
|
|
||||||
vi.mock("~/database/context", () => ({ database: vi.fn(() => ({})) }));
|
|
||||||
vi.mock("~/models/scoring-event", async (importOriginal) => ({
|
|
||||||
...(await importOriginal<object>()),
|
|
||||||
getScoringEventById: vi.fn(),
|
|
||||||
isReadOnlySibling: vi.fn(() => false),
|
|
||||||
}));
|
|
||||||
vi.mock("~/models/playoff-match", async (importOriginal) => ({
|
|
||||||
...(await importOriginal<object>()),
|
|
||||||
reseedAflEliminationFinals: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("~/models/season-participant", async (importOriginal) => ({
|
|
||||||
...(await importOriginal<object>()),
|
|
||||||
findParticipantsBySportsSeasonId: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("~/models/scoring-calculator", async (importOriginal) => ({
|
|
||||||
...(await importOriginal<object>()),
|
|
||||||
processMatchResult: vi.fn(),
|
|
||||||
recalculateAffectedLeagues: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("~/services/discord", async (importOriginal) => ({
|
|
||||||
...(await importOriginal<object>()),
|
|
||||||
sendDiscordWebhook: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const params = { id: "season-1", eventId: "event-1" };
|
|
||||||
|
|
||||||
const EVENT = {
|
|
||||||
id: "event-1",
|
|
||||||
name: "AFL Finals",
|
|
||||||
sportsSeasonId: "season-1",
|
|
||||||
isQualifyingEvent: false,
|
|
||||||
bracketTemplateId: "afl_10",
|
|
||||||
};
|
|
||||||
|
|
||||||
function request() {
|
|
||||||
const body = new FormData();
|
|
||||||
body.set("intent", "reseed-afl-wildcard");
|
|
||||||
return new Request("http://localhost/bracket", { method: "POST", body });
|
|
||||||
}
|
|
||||||
|
|
||||||
const run = () => action({ request: request(), params } as never);
|
|
||||||
|
|
||||||
describe("reseed-afl-wildcard", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
vi.mocked(getScoringEventById).mockResolvedValue(EVENT as never);
|
|
||||||
vi.mocked(findParticipantsBySportsSeasonId).mockResolvedValue([
|
|
||||||
{ id: "carlton", name: "Carlton Blues" },
|
|
||||||
{ id: "bulldogs", name: "Western Bulldogs" },
|
|
||||||
] as never);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("names the teams that moved", async () => {
|
|
||||||
vi.mocked(reseedAflEliminationFinals).mockResolvedValue({
|
|
||||||
vacated: [1, 2],
|
|
||||||
filled: [
|
|
||||||
{ matchNumber: 2, participantId: "bulldogs" },
|
|
||||||
{ matchNumber: 1, participantId: "carlton" },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await run();
|
|
||||||
|
|
||||||
expect(reseedAflEliminationFinals).toHaveBeenCalledWith("event-1");
|
|
||||||
expect(result).toEqual({
|
|
||||||
success:
|
|
||||||
"Re-seeded the Elimination Finals: match 1 now hosts Carlton Blues, " +
|
|
||||||
"match 2 now hosts Western Bulldogs.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("says so when the pairings are already right", async () => {
|
|
||||||
vi.mocked(reseedAflEliminationFinals).mockResolvedValue({ vacated: [], filled: [] });
|
|
||||||
|
|
||||||
expect(await run()).toEqual({
|
|
||||||
success: "Elimination Finals already match the Wildcard results — nothing to re-seed.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("scores nothing and announces nothing", async () => {
|
|
||||||
vi.mocked(reseedAflEliminationFinals).mockResolvedValue({
|
|
||||||
vacated: [1, 2],
|
|
||||||
filled: [{ matchNumber: 1, participantId: "carlton" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
await run();
|
|
||||||
|
|
||||||
expect(processMatchResult).not.toHaveBeenCalled();
|
|
||||||
expect(recalculateAffectedLeagues).not.toHaveBeenCalled();
|
|
||||||
expect(sendDiscordWebhook).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refuses a bracket that is not an AFL finals bracket", async () => {
|
|
||||||
vi.mocked(getScoringEventById).mockResolvedValue({
|
|
||||||
...EVENT,
|
|
||||||
bracketTemplateId: "nfl_14",
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
expect(await run()).toEqual({
|
|
||||||
error: "This action only applies to AFL finals brackets",
|
|
||||||
});
|
|
||||||
expect(reseedAflEliminationFinals).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("surfaces a refusal to re-seed a game that has been played", async () => {
|
|
||||||
vi.mocked(reseedAflEliminationFinals).mockRejectedValue(
|
|
||||||
new Error("Elimination Finals match 1 already has a recorded result")
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(await run()).toEqual({
|
|
||||||
error: "Elimination Finals match 1 already has a recorded result",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
import { describe, it, expect, vi } from "vitest";
|
|
||||||
|
|
||||||
vi.mock("~/lib/auth.server", () => ({
|
|
||||||
auth: { api: { getSession: vi.fn() } },
|
|
||||||
}));
|
|
||||||
vi.mock("~/lib/cloudinary.server", () => ({ deleteCloudinaryImageByUrl: vi.fn() }));
|
|
||||||
vi.mock("~/lib/email.server", () => ({
|
|
||||||
sendEmail: vi.fn(),
|
|
||||||
wrapInEmailTemplate: vi.fn(),
|
|
||||||
emailParagraph: vi.fn(),
|
|
||||||
escapeHtml: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("~/models/user", () => ({
|
|
||||||
findUserById: vi.fn(),
|
|
||||||
findUserByUsername: vi.fn(),
|
|
||||||
isUserInActiveDraft: vi.fn(),
|
|
||||||
updateUser: vi.fn(),
|
|
||||||
anonymizeUserAccount: vi.fn(),
|
|
||||||
USERNAME_RE: /^[a-zA-Z0-9_-]{3,30}$/,
|
|
||||||
}));
|
|
||||||
vi.mock("~/models/account", () => ({ findLinkedAccountsByUserId: vi.fn() }));
|
|
||||||
vi.mock("~/models/team", () => ({ findTeamsByOwnerId: vi.fn(), removeTeamOwner: vi.fn() }));
|
|
||||||
vi.mock("~/models/commissioner", () => ({ removeAllCommissionersByUserId: vi.fn() }));
|
|
||||||
|
|
||||||
import { loader, shouldRevalidate } from "../settings";
|
|
||||||
import { auth } from "~/lib/auth.server";
|
|
||||||
|
|
||||||
type RevalidateArgs = Parameters<typeof shouldRevalidate>[0];
|
|
||||||
|
|
||||||
function revalidateArgs(overrides: Partial<RevalidateArgs>): RevalidateArgs {
|
|
||||||
return {
|
|
||||||
currentParams: {},
|
|
||||||
nextParams: {},
|
|
||||||
formMethod: undefined,
|
|
||||||
defaultShouldRevalidate: true,
|
|
||||||
...overrides,
|
|
||||||
} as unknown as RevalidateArgs;
|
|
||||||
}
|
|
||||||
|
|
||||||
type LoaderArgs = Parameters<typeof loader>[0];
|
|
||||||
|
|
||||||
function loaderArgs(section?: string): LoaderArgs {
|
|
||||||
const path = section ? `/settings/${section}` : "/settings";
|
|
||||||
return {
|
|
||||||
params: { section },
|
|
||||||
request: new Request(`http://localhost${path}`),
|
|
||||||
context: {},
|
|
||||||
} as unknown as LoaderArgs;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("/settings loader section handling", () => {
|
|
||||||
it("redirects an unknown section back to /settings", async () => {
|
|
||||||
const result = (await loader(loaderArgs("bogus"))) as Response;
|
|
||||||
expect(result).toBeInstanceOf(Response);
|
|
||||||
expect(result.status).toBe(302);
|
|
||||||
expect(result.headers.get("Location")).toBe("/settings");
|
|
||||||
// The bad section short-circuits before we ever touch the session.
|
|
||||||
expect(auth.api.getSession).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not redirect a valid section before auth runs", async () => {
|
|
||||||
vi.mocked(auth.api.getSession).mockResolvedValue(null as never);
|
|
||||||
const result = (await loader(loaderArgs("account"))) as Response;
|
|
||||||
// Falls through to the auth check, which redirects to login (not /settings).
|
|
||||||
expect(result.headers.get("Location")).toBe("/login?redirectTo=/settings");
|
|
||||||
expect(auth.api.getSession).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("/settings shouldRevalidate", () => {
|
|
||||||
it("skips revalidation when only the section param changes", () => {
|
|
||||||
expect(
|
|
||||||
shouldRevalidate(
|
|
||||||
revalidateArgs({ currentParams: { section: "profile" }, nextParams: { section: "account" } })
|
|
||||||
)
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("revalidates after a mutation regardless of section", () => {
|
|
||||||
expect(
|
|
||||||
shouldRevalidate(
|
|
||||||
revalidateArgs({
|
|
||||||
currentParams: { section: "profile" },
|
|
||||||
nextParams: { section: "account" },
|
|
||||||
formMethod: "POST",
|
|
||||||
})
|
|
||||||
)
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defers to the default when the section is unchanged", () => {
|
|
||||||
expect(
|
|
||||||
shouldRevalidate(
|
|
||||||
revalidateArgs({
|
|
||||||
currentParams: { section: "account" },
|
|
||||||
nextParams: { section: "account" },
|
|
||||||
defaultShouldRevalidate: true,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
import { Link } from "react-router";
|
|
||||||
import type { Route } from "./+types/admin.draft-schedule";
|
|
||||||
|
|
||||||
import { findAllSports } from "~/models/sport";
|
|
||||||
import { findDraftScheduleForHorizon } from "~/models/sports-season";
|
|
||||||
import {
|
|
||||||
DraftScheduleGantt,
|
|
||||||
type GanttSport,
|
|
||||||
} from "~/components/admin/DraftScheduleGantt";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "~/components/ui/card";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { AlertTriangle, ArrowRight } from "lucide-react";
|
|
||||||
|
|
||||||
const ALLOWED_MONTHS = [6, 12] as const;
|
|
||||||
|
|
||||||
export function meta(): Route.MetaDescriptors {
|
|
||||||
return [{ title: "Draft Schedule - Brackt" }];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const monthsParam = Number(url.searchParams.get("months"));
|
|
||||||
const months = ALLOWED_MONTHS.includes(monthsParam as (typeof ALLOWED_MONTHS)[number])
|
|
||||||
? monthsParam
|
|
||||||
: 6;
|
|
||||||
|
|
||||||
const [allSports, windows] = await Promise.all([
|
|
||||||
findAllSports(),
|
|
||||||
findDraftScheduleForHorizon(months),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const windowsBySport = new Map<string, typeof windows>();
|
|
||||||
for (const w of windows) {
|
|
||||||
const list = windowsBySport.get(w.sport.id) ?? [];
|
|
||||||
list.push(w);
|
|
||||||
windowsBySport.set(w.sport.id, list);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sports: GanttSport[] = allSports.map((s) => ({
|
|
||||||
id: s.id,
|
|
||||||
name: s.name,
|
|
||||||
slug: s.slug,
|
|
||||||
iconUrl: s.iconUrl,
|
|
||||||
windows: windowsBySport.get(s.id) ?? [],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const sportsWithoutCoverage = sports.filter((s) => s.windows.length === 0);
|
|
||||||
const today = new Date().toISOString().split("T")[0];
|
|
||||||
|
|
||||||
return { sports, sportsWithoutCoverage, today, months };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdminDraftSchedule({ loaderData }: Route.ComponentProps) {
|
|
||||||
const { sports, sportsWithoutCoverage, today, months } = loaderData;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-4 md:p-8">
|
|
||||||
<div className="mb-8 flex flex-wrap items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold">Draft Schedule</h1>
|
|
||||||
<p className="text-muted-foreground mt-2">
|
|
||||||
See when each sport is scheduled to be drafted and spot gaps in coverage.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 rounded-md border p-1">
|
|
||||||
{ALLOWED_MONTHS.map((m) => (
|
|
||||||
<Button
|
|
||||||
key={m}
|
|
||||||
variant={m === months ? "default" : "ghost"}
|
|
||||||
size="sm"
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<Link to={`?months=${m}`}>{m} months</Link>
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{sportsWithoutCoverage.length > 0 && (
|
|
||||||
<Card className="mb-6 border-amber-300 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2 text-amber-800 dark:text-amber-300">
|
|
||||||
<AlertTriangle className="h-5 w-5" />
|
|
||||||
Sports without a draft window
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-amber-700 dark:text-amber-400/80">
|
|
||||||
{sportsWithoutCoverage.length} sport
|
|
||||||
{sportsWithoutCoverage.length !== 1 ? "s" : ""}{" "}
|
|
||||||
{sportsWithoutCoverage.length !== 1 ? "have" : "has"} no draft window
|
|
||||||
open or upcoming in the next {months} months.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{sportsWithoutCoverage.map((s) => (
|
|
||||||
<li
|
|
||||||
key={s.id}
|
|
||||||
className="flex items-center justify-between gap-3 rounded-md border border-amber-200 bg-background/60 px-3 py-2 dark:border-amber-900"
|
|
||||||
>
|
|
||||||
<span className="text-sm font-medium">{s.name}</span>
|
|
||||||
<Button variant="outline" size="sm" asChild className="h-7 text-xs">
|
|
||||||
<Link to="/admin/sports-seasons/new">
|
|
||||||
Schedule a season
|
|
||||||
<ArrowRight className="ml-1 h-3 w-3" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DraftScheduleGantt sports={sports} today={today} months={months} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -77,12 +77,6 @@ export async function action({ request }: Route.ActionArgs): Promise<ActionData>
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function futuresBlendLabel(oddsWeight: number): string {
|
|
||||||
if (oddsWeight >= 1) return "overrides Elo";
|
|
||||||
if (oddsWeight <= 0) return "Elo only";
|
|
||||||
return `${Math.round(oddsWeight * 100)}% blend`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusBadge(status: string) {
|
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>;
|
||||||
|
|
@ -251,14 +245,7 @@ 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">
|
||||||
<div>{sim.participantInputCount}/{sim.participantCount}</div>
|
{sim.participantInputCount}/{sim.participantCount}
|
||||||
{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 { getParticipantSimulatorInputs, getSportsSeasonSimulatorConfig } from '~/models/simulator';
|
import { 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,38 +80,13 @@ 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);
|
|
||||||
|
|
||||||
// The projection a participant was actually saved with. Read it back verbatim:
|
const existingData: Record<string, { elo: number | null; ranking: number | null }> = {};
|
||||||
// deriving the field from the stored Elo instead (as this page used to) shows the
|
|
||||||
// admin a different number than they typed, because wins → Elo rounds to an
|
|
||||||
// integer Elo and a simulation run then re-resolves that Elo through the input
|
|
||||||
// policy (clamping, and blending in futures odds when a season has them).
|
|
||||||
const projectionsByParticipant = new Map(
|
|
||||||
simulatorInputs.map((input) => [
|
|
||||||
input.participantId,
|
|
||||||
{ projectedWins: input.projectedWins, projectedTablePoints: input.projectedTablePoints },
|
|
||||||
])
|
|
||||||
);
|
|
||||||
|
|
||||||
const existingData: Record<
|
|
||||||
string,
|
|
||||||
{ elo: number | null; ranking: number | null; projectedWins: number | null; projectedTablePoints: number | null }
|
|
||||||
> = {};
|
|
||||||
for (const participant of participants) {
|
|
||||||
const projection = projectionsByParticipant.get(participant.id);
|
|
||||||
existingData[participant.id] = {
|
|
||||||
elo: null,
|
|
||||||
ranking: null,
|
|
||||||
projectedWins: projection?.projectedWins ?? null,
|
|
||||||
projectedTablePoints: projection?.projectedTablePoints ?? null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
for (const ev of existingEVs) {
|
for (const ev of existingEVs) {
|
||||||
const existing = existingData[ev.participantId];
|
existingData[ev.participantId] = {
|
||||||
if (!existing) continue;
|
elo: ev.sourceElo ?? null,
|
||||||
existing.elo = ev.sourceElo ?? null;
|
ranking: ev.worldRanking ?? null,
|
||||||
existing.ranking = ev.worldRanking ?? null;
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const usesRanking = RANKING_SIMULATOR_TYPES.has(sportsSeason.sport?.simulatorType ?? '');
|
const usesRanking = RANKING_SIMULATOR_TYPES.has(sportsSeason.sport?.simulatorType ?? '');
|
||||||
|
|
@ -277,16 +252,7 @@ export default function AdminSportsSeasonEloRatings() {
|
||||||
if (simulatorConfig) {
|
if (simulatorConfig) {
|
||||||
participants.forEach(p => {
|
participants.forEach(p => {
|
||||||
const d = existingData[p.id];
|
const d = existingData[p.id];
|
||||||
// A stored projection is shown exactly as it was entered. Only fall back to
|
if (d?.elo !== null && d?.elo !== undefined) {
|
||||||
// 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)
|
||||||
|
|
@ -299,8 +265,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; projection: number | null; inputName: string }>;
|
matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; inputName: string }>;
|
||||||
unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null; projection: number | null }>;
|
unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null }>;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
function findParticipantMatch(inputName: string) {
|
function findParticipantMatch(inputName: string) {
|
||||||
|
|
@ -325,8 +291,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; projection: number | null; inputName: string }> = [];
|
const matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; inputName: string }> = [];
|
||||||
const unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null; projection: number | null }> = [];
|
const unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null }> = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
|
|
@ -349,9 +315,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, projection: projectedWins, inputName });
|
matched.push({ participantId: participant.id, name: participant.name, elo, ranking: null, inputName });
|
||||||
} else if (!participant) {
|
} else if (!participant) {
|
||||||
unmatched.push({ inputName, elo, ranking: null, projection: projectedWins });
|
unmatched.push({ inputName, elo, ranking: null });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const match = usesRanking
|
const match = usesRanking
|
||||||
|
|
@ -376,9 +342,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, projection: null, inputName });
|
matched.push({ participantId: participant.id, name: participant.name, elo, ranking, inputName });
|
||||||
} else if (!participant) {
|
} else if (!participant) {
|
||||||
unmatched.push({ inputName, elo, ranking, projection: null });
|
unmatched.push({ inputName, elo, ranking });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -394,11 +360,11 @@ export default function AdminSportsSeasonEloRatings() {
|
||||||
for (const m of parseResults.matched) {
|
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();
|
||||||
// The pasted number goes in as typed. Round-tripping it through the derived
|
if (inputMode === 'projectedWins' && simulatorConfig && m.elo !== null) {
|
||||||
// Elo (as this used to) drifts it by up to half an Elo point — a pasted 95
|
newWins[m.participantId] = (simulatorConfig.projectionInput === 'tablePoints'
|
||||||
// came back as 95.1 before anything was even saved.
|
? eloToProjectedTablePoints(m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
|
||||||
if (inputMode === 'projectedWins' && m.projection !== null) {
|
: eloToProjectedWins(m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
|
||||||
newWins[m.participantId] = m.projection.toString();
|
).toFixed(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setEloValues(newElos);
|
setEloValues(newElos);
|
||||||
|
|
@ -523,10 +489,7 @@ 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.name} → {m.elo !== null ? `Elo ${m.elo}` : 'No Elo'}
|
||||||
{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>
|
||||||
|
|
@ -546,9 +509,7 @@ 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.projection !== null
|
{u.elo !== null ? `Elo ${u.elo}` : 'No Elo'}
|
||||||
? `${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>
|
||||||
|
|
@ -579,7 +540,7 @@ Mark Selby, 2432`
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{inputMode === 'projectedWins'
|
{inputMode === 'projectedWins'
|
||||||
? `Enter each team's projected total season ${projectionUnit} — the number you enter is stored as-is and re-derives the Elo on every run. Mid-season it is treated as a projected final total, so the simulation spreads the difference over the games still to play. Saving will run the simulation and update expected values.`
|
? `Enter each team's projected total season ${projectionUnit}. Converted to Elo automatically. Saving will run the simulation and update expected values.`
|
||||||
: usesRanking
|
: 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.`}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
/**
|
|
||||||
* Pure helpers for the bracket-scoring route action, extracted so the
|
|
||||||
* knockout-detection logic can be unit-tested without the full action harness.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* From a set of matches being scored this action, return the season_participant
|
|
||||||
* ids knocked out for the FIRST time. A loser counts only when its match was NOT
|
|
||||||
* already complete — a re-score/correction of a finished match must not
|
|
||||||
* re-announce the exit (mirrors populateBracketFromDraw's first-completion rule in
|
|
||||||
* the live-sync path). Entries with an unknown loser (`null`) are skipped.
|
|
||||||
*
|
|
||||||
* The ids are season_participant ids of the primary window (playoff_matches are
|
|
||||||
* keyed by season_participant); the fan-out translates them to each mirror
|
|
||||||
* window's own season_participant id before announcing the "Knocked Out" section.
|
|
||||||
*/
|
|
||||||
export function newlyDecidedLosers(
|
|
||||||
entries: Array<{ wasComplete: boolean; loserId: string | null }>
|
|
||||||
): string[] {
|
|
||||||
return entries
|
|
||||||
.filter((e): e is { wasComplete: boolean; loserId: string } =>
|
|
||||||
!e.wasComplete && e.loserId !== null
|
|
||||||
)
|
|
||||||
.map((e) => e.loserId);
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +1,16 @@
|
||||||
import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket";
|
import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket";
|
||||||
|
|
||||||
import { findSportsSeasonById } from "~/models/sports-season";
|
import { findSportsSeasonById } from "~/models/sports-season";
|
||||||
import {
|
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
||||||
findParticipantsBySportsSeasonId,
|
import { getScoringEventById, updateScoringEvent } from "~/models/scoring-event";
|
||||||
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,
|
||||||
|
|
@ -32,22 +25,13 @@ 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 {
|
import { setParticipantResult, findParticipantResultsBySportsSeasonId } from "~/models/participant-result";
|
||||||
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 {
|
||||||
|
|
@ -70,10 +54,6 @@ 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);
|
||||||
|
|
@ -123,230 +103,10 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Score a qualifying-event bracket (e.g. CS2 Champions Stage): derive each team's
|
|
||||||
* guaranteed-minimum QP from the bracket and refresh affected league standings.
|
|
||||||
*
|
|
||||||
* Qualifying brackets award QUALIFYING POINTS, not fantasy points, so this never
|
|
||||||
* writes seasonParticipantResults and never records team_score_events — match
|
|
||||||
* results surface via the QP Standings and the Discord standings update. Final
|
|
||||||
* fantasy placements come from finalizeQualifyingPoints across all majors.
|
|
||||||
*/
|
|
||||||
async function scoreQualifyingBracket(
|
|
||||||
event: {
|
|
||||||
id: string;
|
|
||||||
sportsSeasonId: string;
|
|
||||||
name: string | null;
|
|
||||||
isPrimary: boolean;
|
|
||||||
tournamentId: string | null;
|
|
||||||
},
|
|
||||||
db: ReturnType<typeof database>,
|
|
||||||
recalcOptions?: Parameters<typeof recalculateAffectedLeagues>[2],
|
|
||||||
/**
|
|
||||||
* Season_participant ids of players knocked out for the first time by this
|
|
||||||
* operation (losers of matches that just reached completion). Forwarded to the
|
|
||||||
* fan-out so every mirror window announces the "Knocked Out" section — a
|
|
||||||
* non-scoring-round exit earns no QP and is otherwise invisible to the mirror.
|
|
||||||
* Empty for re-scores/reprocesses, which decide no new losers.
|
|
||||||
*/
|
|
||||||
newlyEliminatedParticipantIds?: Set<string>
|
|
||||||
): Promise<void> {
|
|
||||||
// processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent),
|
|
||||||
// recalcs participant QP totals, AND announces the QP change to this window's leagues.
|
|
||||||
// Calling processQualifyingBracketEvent directly here would score silently — the QP
|
|
||||||
// Discord notification only fires from processQualifyingEvent. The fan-out below skips
|
|
||||||
// this (primary) window via skipEventId, so mirror windows are announced separately
|
|
||||||
// with no double-post.
|
|
||||||
await processQualifyingEvent(event.id, db, { newlyEliminatedParticipantIds });
|
|
||||||
await recalculateAffectedLeagues(
|
|
||||||
event.sportsSeasonId,
|
|
||||||
db,
|
|
||||||
recalcOptions ?? { eventId: event.id, eventName: event.name ?? undefined }
|
|
||||||
);
|
|
||||||
// If this is the shared major's primary window, propagate to siblings.
|
|
||||||
// Mid-tournament (a single round): don't mark complete yet.
|
|
||||||
await fanOutMajorIfPrimary(event, {
|
|
||||||
markComplete: false,
|
|
||||||
newlyEliminatedParticipantIds,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark the given participants as eliminated (finalPosition = 0) and, for fantasy
|
|
||||||
* (non-qualifying) events, announce the teams newly eliminated by this run to the
|
|
||||||
* affected leagues' Discord channels.
|
|
||||||
*
|
|
||||||
* "Newly eliminated" = participants with no prior result row, so re-running a
|
|
||||||
* generation step never re-announces the same teams. The announcement is a
|
|
||||||
* best-effort side effect: a failure must not fail the generation action, since
|
|
||||||
* the eliminations themselves are already committed. eventId is deliberately
|
|
||||||
* omitted from the recalc call so the announcement doesn't pull in unrelated
|
|
||||||
* completed matches as "Scored Matches".
|
|
||||||
*
|
|
||||||
* Returns the number of participants marked alongside whether a standings recalculation
|
|
||||||
* actually ran. The caller banks entry floors before calling this and needs them to
|
|
||||||
* reach teamStandings.totalPoints; it cannot infer that from the participant count,
|
|
||||||
* because the recalc is skipped for qualifying events, when every eliminated team
|
|
||||||
* already had a result row (the second run of a generation), and when the announcement
|
|
||||||
* threw. `recalculated` reports the fact rather than making the caller re-derive it.
|
|
||||||
*/
|
|
||||||
async function markEliminatedAndAnnounce(
|
|
||||||
event: { id: string; name: string | null; sportsSeasonId: string; isQualifyingEvent: boolean },
|
|
||||||
participantIds: string[]
|
|
||||||
): Promise<{ markedCount: number; recalculated: boolean }> {
|
|
||||||
const existingResults = await findParticipantResultsBySportsSeasonId(event.sportsSeasonId);
|
|
||||||
const alreadyHadResult = new Set(existingResults.map((r) => r.participantId));
|
|
||||||
const newlyEliminatedIds = participantIds.filter((id) => !alreadyHadResult.has(id));
|
|
||||||
|
|
||||||
for (const participantId of participantIds) {
|
|
||||||
await setParticipantResult(participantId, event.sportsSeasonId, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
let recalculated = false;
|
|
||||||
|
|
||||||
// QPs (e.g. tennis/CS2 majors) don't get elimination announcements.
|
|
||||||
if (!event.isQualifyingEvent && newlyEliminatedIds.length > 0) {
|
|
||||||
try {
|
|
||||||
await recalculateAffectedLeagues(event.sportsSeasonId, database(), {
|
|
||||||
eventName: event.name ?? undefined,
|
|
||||||
eliminatedParticipantIds: newlyEliminatedIds,
|
|
||||||
});
|
|
||||||
recalculated = true;
|
|
||||||
} catch (err) {
|
|
||||||
logger.error("[Eliminations] Discord announcement failed:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { markedCount: participantIds.length, recalculated };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function action({ request, params }: Route.ActionArgs) {
|
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");
|
||||||
|
|
||||||
|
|
@ -410,50 +170,36 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
try {
|
try {
|
||||||
await generateBracketFromTemplate(params.eventId, templateId, participantIds, regionOverride);
|
await generateBracketFromTemplate(params.eventId, templateId, participantIds, regionOverride);
|
||||||
|
|
||||||
// The template ID has to land on the event before entry floors can be derived
|
// PHASE 5.3: Mark participants NOT in the bracket as eliminated
|
||||||
// (getBracketEntryFloor reads it), so persist it here rather than after the
|
const event = await getScoringEventById(params.eventId);
|
||||||
// elimination pass below.
|
if (event) {
|
||||||
|
// Get all participants in the sports season
|
||||||
|
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
|
||||||
|
|
||||||
|
// Create a set of participants in the bracket for fast lookup
|
||||||
|
const participantsInBracket = new Set(participantIds);
|
||||||
|
|
||||||
|
// Mark participants NOT in the bracket as eliminated
|
||||||
|
for (const participant of allParticipants) {
|
||||||
|
if (!participantsInBracket.has(participant.id)) {
|
||||||
|
await setParticipantResult(
|
||||||
|
participant.id,
|
||||||
|
event.sportsSeasonId,
|
||||||
|
0 // 0 = didn't make playoffs, eliminated
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log(`[BracketGeneration] Marked ${allParticipants.length - participantIds.length} participants as eliminated`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the event to store the template ID, scoring start round, and region config
|
||||||
await updateScoringEvent(params.eventId, {
|
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);
|
||||||
|
|
@ -500,13 +246,6 @@ 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);
|
||||||
|
|
||||||
|
|
@ -528,22 +267,6 @@ 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
|
||||||
|
|
@ -562,8 +285,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) {
|
||||||
|
|
@ -614,10 +337,6 @@ 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 {
|
||||||
|
|
@ -666,9 +385,6 @@ 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,
|
||||||
|
|
@ -682,14 +398,9 @@ 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(
|
||||||
|
|
@ -703,15 +414,6 @@ 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 {
|
||||||
|
|
@ -720,18 +422,7 @@ 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) {
|
||||||
|
|
@ -783,14 +474,6 @@ 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
|
||||||
|
|
@ -868,101 +551,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-seed the AFL Wildcard winners into the Elimination Finals they belong in.
|
|
||||||
// Advancement does this on every Wildcard result, so this is only needed for a
|
|
||||||
// bracket advanced before that rule existed: the winners sit in the wrong games and
|
|
||||||
// no admin action re-runs advancement (a completed match cannot be re-submitted).
|
|
||||||
if (intent === "reseed-afl-wildcard") {
|
|
||||||
try {
|
|
||||||
const event = await getScoringEventById(params.eventId);
|
|
||||||
if (!event) return { error: "Event not found" };
|
|
||||||
if (event.bracketTemplateId !== "afl_10") {
|
|
||||||
return { error: "This action only applies to AFL finals brackets" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const participants = await findParticipantsBySportsSeasonId(params.id);
|
|
||||||
const nameOf = (id: string) => participants.find((p) => p.id === id)?.name ?? id;
|
|
||||||
|
|
||||||
const reseed = await reseedAflEliminationFinals(params.eventId);
|
|
||||||
if (reseed.vacated.length === 0 && reseed.filled.length === 0) {
|
|
||||||
return {
|
|
||||||
success:
|
|
||||||
"Elimination Finals already match the Wildcard results — nothing to re-seed.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only the qualifier slots move, so there is nothing to re-score: no placement,
|
|
||||||
// score or elimination changes, and so nothing to announce.
|
|
||||||
const moves = reseed.filled
|
|
||||||
.toSorted((a, b) => a.matchNumber - b.matchNumber)
|
|
||||||
.map((slot) => `match ${slot.matchNumber} now hosts ${nameOf(slot.participantId)}`)
|
|
||||||
.join(", ");
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: `Re-seeded the Elimination Finals: ${moves}.`,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Error re-seeding AFL Wildcard winners:", error);
|
|
||||||
return {
|
|
||||||
error:
|
|
||||||
error instanceof Error ? error.message : "Failed to re-seed the Elimination Finals",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Put the Elimination Final winners in the Semi-Finals they belong in. Elimination
|
|
||||||
// Final n feeds Semi-Final n, but brackets advanced before that was fixed crossed the
|
|
||||||
// two winners, and no admin action re-runs advancement (a completed match cannot be
|
|
||||||
// re-submitted).
|
|
||||||
if (intent === "reseed-afl-semifinals") {
|
|
||||||
try {
|
|
||||||
const event = await getScoringEventById(params.eventId);
|
|
||||||
if (!event) return { error: "Event not found" };
|
|
||||||
if (event.bracketTemplateId !== "afl_10") {
|
|
||||||
return { error: "This action only applies to AFL finals brackets" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const participants = await findParticipantsBySportsSeasonId(params.id);
|
|
||||||
const nameOf = (id: string) => participants.find((p) => p.id === id)?.name ?? id;
|
|
||||||
|
|
||||||
const reseed = await reseedAflSemiFinals(params.eventId);
|
|
||||||
if (reseed.vacated.length === 0 && reseed.filled.length === 0) {
|
|
||||||
return {
|
|
||||||
success:
|
|
||||||
"Semi-Finals already match the Elimination Finals results — nothing to re-seed.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only the qualifier slots move, so there is nothing to re-score: no placement,
|
|
||||||
// score or elimination changes, and so nothing to announce.
|
|
||||||
//
|
|
||||||
// A slot can be vacated without being refilled — un-recording an Elimination Final
|
|
||||||
// result takes its winner back out — so report those too rather than rendering an
|
|
||||||
// empty list.
|
|
||||||
const filled = reseed.filled.map((slot) => ({
|
|
||||||
matchNumber: slot.matchNumber,
|
|
||||||
text: `match ${slot.matchNumber} now hosts ${nameOf(slot.participantId)}`,
|
|
||||||
}));
|
|
||||||
const emptied = reseed.vacated
|
|
||||||
.filter((matchNumber) => !reseed.filled.some((slot) => slot.matchNumber === matchNumber))
|
|
||||||
.map((matchNumber) => ({ matchNumber, text: `match ${matchNumber} is back to TBD` }));
|
|
||||||
const moves = [...filled, ...emptied]
|
|
||||||
.toSorted((a, b) => a.matchNumber - b.matchNumber)
|
|
||||||
.map((move) => move.text)
|
|
||||||
.join(", ");
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: `Re-seeded the Semi-Finals: ${moves}.`,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Error re-seeding AFL Elimination Finals winners:", error);
|
|
||||||
return {
|
|
||||||
error:
|
|
||||||
error instanceof Error ? error.message : "Failed to re-seed the Semi-Finals",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (intent === "reprocess-bracket") {
|
if (intent === "reprocess-bracket") {
|
||||||
try {
|
try {
|
||||||
const event = await getScoringEventById(params.eventId);
|
const event = await getScoringEventById(params.eventId);
|
||||||
|
|
@ -971,87 +559,20 @@ 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);
|
||||||
|
|
||||||
// Qualifying majors: this is also the cleanup tool for majors that wrongly banked
|
if (completed.length === 0) {
|
||||||
// fantasy points under the old path. Delete the stale seasonParticipantResults rows
|
return { error: "No completed matches to reprocess" };
|
||||||
// (erasing those points), then rebuild correct QP from the bracket. Qualifying sports
|
|
||||||
// have no legitimate per-major fantasy placements — those come from
|
|
||||||
// finalizeQualifyingPoints across all majors.
|
|
||||||
if (event.isQualifyingEvent) {
|
|
||||||
const db = database();
|
|
||||||
await deleteParticipantResultsBySportsSeasonId(event.sportsSeasonId, db);
|
|
||||||
await processQualifyingBracketEvent(params.eventId, db);
|
|
||||||
// If the season's QP was already finalized, the delete above wiped the final
|
|
||||||
// placements — recompute them from QP totals so standings aren't left blank.
|
|
||||||
const sportsSeason = await findSportsSeasonById(params.id);
|
|
||||||
if (sportsSeason?.qualifyingPointsFinalized) {
|
|
||||||
await finalizeQualifyingPoints(event.sportsSeasonId, db); // recalcs leagues itself
|
|
||||||
} else {
|
|
||||||
// skipDiscord: reprocess is a data-correction tool, not a result announcement.
|
|
||||||
await recalculateAffectedLeagues(event.sportsSeasonId, db, { skipDiscord: true });
|
|
||||||
}
|
|
||||||
// Re-propagate corrected QP to sibling/mirror windows (data-correction; not
|
|
||||||
// final). Call syncMajorFromPrimaryEvent directly rather than the
|
|
||||||
// swallow-and-log fanOutMajorIfPrimary so the admin actually sees whether the
|
|
||||||
// mirrors were re-scored — a silent failure here is exactly how mirrors got
|
|
||||||
// left showing stale QP behind a green "success".
|
|
||||||
const baseMessage = `Reprocessed qualifying bracket: cleared stale fantasy points and recomputed QP (${completed.length} completed match(es)).`;
|
|
||||||
if (event.isPrimary && event.tournamentId) {
|
|
||||||
try {
|
|
||||||
const report = await syncMajorFromPrimaryEvent(event.id, { markComplete: false });
|
|
||||||
// Surface a partial fan-out as an error so it renders as a warning
|
|
||||||
// banner, not a green success the admin might skim past while some
|
|
||||||
// mirror windows are left stale.
|
|
||||||
if (report.windowsFailed > 0) {
|
|
||||||
const reasons = report.failures.map((f) => f.error).join("; ");
|
|
||||||
return {
|
|
||||||
error: `${baseMessage} Synced ${report.windowsSynced} mirror window(s), but ${report.windowsFailed} failed (those windows may be stale): ${reasons}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { success: `${baseMessage} Synced ${report.windowsSynced} mirror window(s).` };
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
error: `Primary window recomputed, but mirror fan-out failed (mirror windows may be stale): ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { success: `${baseMessage} No mirror windows to sync.` };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matches.length === 0) {
|
// Delete ALL results for this sports season and rebuild from scratch.
|
||||||
return { error: "No bracket to reprocess" };
|
// Only deleting partial rows leaves stale finalized rows that block
|
||||||
}
|
// 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();
|
||||||
// Reused further down to decide who is *not* in the bracket and so eliminated.
|
await db
|
||||||
const bracketParticipantIds = new Set<string>();
|
.delete(schema.seasonParticipantResults)
|
||||||
for (const match of matches) {
|
.where(
|
||||||
if (match.participant1Id) bracketParticipantIds.add(match.participant1Id);
|
eq(schema.seasonParticipantResults.sportsSeasonId, event.sportsSeasonId)
|
||||||
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) : [];
|
||||||
|
|
@ -1091,6 +612,11 @@ 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)) {
|
||||||
|
|
@ -1106,12 +632,7 @@ 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 {
|
return { success: `Reprocessed bracket: ${sortedMatches.length} match(es) replayed, ${eliminatedCount} non-bracket participant(s) eliminated` };
|
||||||
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 {
|
||||||
|
|
@ -1148,31 +669,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Qualifying majors (e.g. CS2): lock in final bracket placements/QP, then run the
|
|
||||||
// standard qualifying finalizer for THIS event. Do not mark the whole sports season
|
|
||||||
// completed or recalc fantasy standings — a season spans multiple majors and final
|
|
||||||
// fantasy placements come from finalizeQualifyingPoints across all of them.
|
|
||||||
if (event.isQualifyingEvent) {
|
|
||||||
const db = database();
|
|
||||||
// processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent)
|
|
||||||
// and recalcs participant QP totals. majorsCompleted is derived on read from
|
|
||||||
// completed qualifying events (see getMajorsCompleted) — marking this event
|
|
||||||
// complete below is what advances it. Season-wide fantasy finalization stays with
|
|
||||||
// finalizeQualifyingPoints across all majors.
|
|
||||||
await processQualifyingEvent(params.eventId, db);
|
|
||||||
await db
|
|
||||||
.update(schema.scoringEvents)
|
|
||||||
.set({ isComplete: true, completedAt: new Date(), updatedAt: new Date() })
|
|
||||||
.where(eq(schema.scoringEvents.id, params.eventId));
|
|
||||||
await recalculateAffectedLeagues(event.sportsSeasonId, db, {
|
|
||||||
eventId: params.eventId,
|
|
||||||
eventName: event.name ?? undefined,
|
|
||||||
});
|
|
||||||
// Finalize: propagate to siblings AND mark every window complete.
|
|
||||||
await fanOutMajorIfPrimary(event, { markComplete: true });
|
|
||||||
return { success: "Major bracket finalized — qualifying points awarded." };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all participants in this sports season
|
// Get all participants in this sports season
|
||||||
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
|
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
|
||||||
|
|
||||||
|
|
@ -1287,19 +783,14 @@ 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);
|
||||||
const toEliminate = allParticipants
|
let eliminatedCount = 0;
|
||||||
.filter((p) => !uniqueParticipants.has(p.id))
|
for (const participant of allParticipants) {
|
||||||
.map((p) => p.id);
|
if (!uniqueParticipants.has(participant.id)) {
|
||||||
const { markedCount: eliminatedCount } = await markEliminatedAndAnnounce(
|
await setParticipantResult(participant.id, params.id, 0);
|
||||||
groupsEvent,
|
eliminatedCount++;
|
||||||
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)` : ""}`,
|
||||||
|
|
@ -1424,10 +915,11 @@ 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 (and announce
|
// Mark eliminated group participants with finalPosition = 0
|
||||||
// the group-stage eliminations to leagues for fantasy events).
|
|
||||||
const eliminatedIds = await getEliminatedParticipantIds(params.eventId);
|
const eliminatedIds = await getEliminatedParticipantIds(params.eventId);
|
||||||
await markEliminatedAndAnnounce(event, eliminatedIds);
|
for (const participantId of 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, useFetcher } from "react-router";
|
import { Form, Link } from "react-router";
|
||||||
import { Fragment, useState, useEffect, useMemo, useRef } from "react";
|
import { Fragment, useState, useEffect, useMemo } from "react";
|
||||||
import { localDateTimeToUtcIso, utcIsoToLocalDateTime } from "~/lib/date-utils";
|
import { localDateTimeToUtcIso } 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,116 +39,8 @@ 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,
|
||||||
|
|
@ -431,164 +323,6 @@ 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. */}
|
||||||
|
|
@ -613,110 +347,6 @@ 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>
|
||||||
|
|
@ -993,7 +623,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-28 text-sm text-muted-foreground shrink-0">
|
<Label className="w-20 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">
|
||||||
|
|
@ -1176,7 +806,24 @@ 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 */}
|
||||||
<GroupMatchScheduleForm match={match} localTzAbbr={localTzAbbr} />
|
<Form method="post" className="flex items-center gap-1">
|
||||||
|
<input type="hidden" name="intent" value="update-group-match-schedule" />
|
||||||
|
<input type="hidden" name="matchId" value={match.id} />
|
||||||
|
<Calendar className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
name="scheduledAt"
|
||||||
|
defaultValue={
|
||||||
|
match.scheduledAt
|
||||||
|
? new Date(match.scheduledAt).toISOString().slice(0, 16)
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
className="h-6 text-xs px-1 py-0"
|
||||||
|
/>
|
||||||
|
<Button type="submit" size="sm" variant="ghost" className="h-6 px-1.5 text-xs">
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
{/* Score row */}
|
{/* 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,19 +2,18 @@ import { Form, useLoaderData, useActionData, useNavigation, useFetcher, Link } f
|
||||||
import type { Route } from './+types/admin.sports-seasons.$id.events.$eventId.cs2-setup';
|
import 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, isReadOnlySibling } from '~/models/scoring-event';
|
import { getScoringEventById } from '~/models/scoring-event';
|
||||||
import { findParticipantsBySportsSeasonId } from '~/models/season-participant';
|
import { findParticipantsBySportsSeasonId } from '~/models/season-participant';
|
||||||
import {
|
import {
|
||||||
getCs2StageResultsForEvent,
|
getCs2StageResultsForEvent,
|
||||||
upsertCs2StageAssignments,
|
upsertCs2StageAssignments,
|
||||||
markCs2StageEliminations,
|
markCs2StageEliminations,
|
||||||
resetCs2Event,
|
clearCs2StageAssignments,
|
||||||
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,
|
||||||
|
|
@ -62,22 +61,9 @@ 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') {
|
||||||
// Local recovery tool: clears only the primary's torn state. Siblings are not
|
await clearCs2StageAssignments(eventId);
|
||||||
// touched here — re-entering results re-propagates via the scoring fan-out.
|
return { success: true, message: 'Stage assignments cleared.' };
|
||||||
await resetCs2Event(eventId, params.id);
|
|
||||||
return { success: true, message: 'Stage assignments and recorded results cleared.' };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (intent === 'assign') {
|
if (intent === 'assign') {
|
||||||
|
|
@ -140,10 +126,6 @@ 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.' };
|
||||||
|
|
@ -286,14 +268,7 @@ 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
|
<Form method="post">
|
||||||
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,7 +6,6 @@ import {
|
||||||
getScoringEventById,
|
getScoringEventById,
|
||||||
completeScoringEvent,
|
completeScoringEvent,
|
||||||
updateScoringEvent,
|
updateScoringEvent,
|
||||||
isReadOnlySibling,
|
|
||||||
} from "~/models/scoring-event";
|
} from "~/models/scoring-event";
|
||||||
import {
|
import {
|
||||||
getEventResults,
|
getEventResults,
|
||||||
|
|
@ -78,9 +77,6 @@ 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 };
|
||||||
|
|
@ -93,45 +89,13 @@ 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