Compare commits
2 commits
main
...
claude/tru
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f70852998 | ||
|
|
23904c9e84 |
168 changed files with 2776 additions and 27807 deletions
|
|
@ -9,14 +9,17 @@
|
|||
"command": ".claude/hooks/lint-on-edit.sh",
|
||||
"timeout": 30,
|
||||
"statusMessage": "Linting..."
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Write(*.ts)|Write(*.tsx)|Edit(*.ts)|Edit(*.tsx)|MultiEdit(*.ts)|MultiEdit(*.tsx)",
|
||||
"command": "output=$(npm run typecheck 2>&1); rc=$?; if [ $rc -ne 0 ]; then printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PostToolUse\",\"additionalContext\":\"TypeCheck failed:\\n%s\"}}' \"$(echo \"$output\" | tail -30 | sed 's/\"/\\\\\"/g; s/$/\\\\n/' | tr -d '\\n')\"; fi",
|
||||
"timeout": 60,
|
||||
"statusMessage": "Type-checking...",
|
||||
"async": true
|
||||
"command": "output=$(npm run typecheck 2>&1); rc=$?; if [ $rc -ne 0 ]; then printf '{\"systemMessage\":\"TypeCheck failed:\\n%s\"}' \"$(echo \"$output\" | tail -30 | sed 's/\"/\\\\\"/g; s/$/\\\\n/' | tr -d '\\n')\"; fi",
|
||||
"timeout": 60
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,8 +31,3 @@ CLOUDINARY_API_SECRET=""
|
|||
# Must match the CRON_SECRET repo secret in Forgejo.
|
||||
# Generate with: openssl rand -hex 32
|
||||
CRON_SECRET=""
|
||||
|
||||
# OC Blacktop motorsport API — used to sync IndyCar championship standings
|
||||
# (fresher than ESPN's aggregate). Free tier at https://ocblacktop.com/api.
|
||||
# If unset, IndyCar standings fall back to ESPN.
|
||||
OCBLACKTOP_API_KEY=""
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ const scoringRules = {
|
|||
};
|
||||
|
||||
describe("QualifyingPointsStandings", () => {
|
||||
it("renders fractional QP to at most hundredths with trailing zeros trimmed", () => {
|
||||
it("renders fractional QP to hundredths", () => {
|
||||
render(
|
||||
<QualifyingPointsStandings
|
||||
standings={[
|
||||
|
|
@ -52,8 +52,7 @@ describe("QualifyingPointsStandings", () => {
|
|||
);
|
||||
|
||||
expect(screen.getByText("14.67 QP")).toBeInTheDocument();
|
||||
// 0.50 trims its trailing zero to 0.5; 14.67 and 0.43 are unaffected.
|
||||
expect(screen.getByText("0.5 QP")).toBeInTheDocument();
|
||||
expect(screen.getByText("0.50 QP")).toBeInTheDocument();
|
||||
expect(screen.getByText("0.43 QP")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,236 +0,0 @@
|
|||
import { Link } from "react-router";
|
||||
import {
|
||||
addMonths,
|
||||
differenceInCalendarDays,
|
||||
eachMonthOfInterval,
|
||||
format,
|
||||
parseISO,
|
||||
} from "date-fns";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import type { DraftScheduleWindow } from "~/models/sports-season";
|
||||
|
||||
export interface GanttSport {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
iconUrl: string | null;
|
||||
windows: DraftScheduleWindow[];
|
||||
}
|
||||
|
||||
interface DraftScheduleGanttProps {
|
||||
sports: GanttSport[];
|
||||
/** Horizon start (today) as a YYYY-MM-DD string. */
|
||||
today: string;
|
||||
/** Number of months the timeline spans. */
|
||||
months: number;
|
||||
}
|
||||
|
||||
// Bar color by sport-season status, using the theme chart tokens from app.css.
|
||||
const STATUS_COLORS: Record<DraftScheduleWindow["status"], string> = {
|
||||
active: "var(--chart-1)",
|
||||
upcoming: "var(--chart-2)",
|
||||
completed: "var(--muted-foreground)",
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<DraftScheduleWindow["status"], string> = {
|
||||
active: "Active",
|
||||
upcoming: "Upcoming",
|
||||
completed: "Completed",
|
||||
};
|
||||
|
||||
const clampPct = (n: number) => Math.max(0, Math.min(100, n));
|
||||
|
||||
// Row layout (px). A single-lane row is LANE_HEIGHT + ROW_V_PAD tall; extra
|
||||
// concurrent windows add one lane each so overlapping bars never stack on top
|
||||
// of one another.
|
||||
const LANE_HEIGHT = 30;
|
||||
const LANE_GAP = 8;
|
||||
const ROW_V_PAD = 14;
|
||||
|
||||
const rowHeight = (laneCount: number) => Math.max(laneCount, 1) * LANE_HEIGHT + ROW_V_PAD;
|
||||
|
||||
/**
|
||||
* Greedy interval-scheduling: assign each window to the first lane whose last
|
||||
* bar ends before this one starts, otherwise open a new lane. Windows arrive
|
||||
* sorted by draftOn (see findDraftScheduleForHorizon).
|
||||
*/
|
||||
function assignLanes(
|
||||
windows: DraftScheduleWindow[],
|
||||
start: Date
|
||||
): { placed: Array<{ window: DraftScheduleWindow; lane: number }>; laneCount: number } {
|
||||
const laneEnds: number[] = [];
|
||||
const placed = windows.map((window) => {
|
||||
const startDay = differenceInCalendarDays(parseISO(window.draftOn), start);
|
||||
const endDay = differenceInCalendarDays(parseISO(window.draftOff), start);
|
||||
let lane = laneEnds.findIndex((laneEnd) => laneEnd < startDay);
|
||||
if (lane === -1) {
|
||||
lane = laneEnds.length;
|
||||
laneEnds.push(endDay);
|
||||
} else {
|
||||
laneEnds[lane] = endDay;
|
||||
}
|
||||
return { window, lane };
|
||||
});
|
||||
return { placed, laneCount: Math.max(laneEnds.length, 1) };
|
||||
}
|
||||
|
||||
export function DraftScheduleGantt({ sports, today, months }: DraftScheduleGanttProps) {
|
||||
const start = parseISO(today);
|
||||
const end = addMonths(start, months);
|
||||
const totalDays = Math.max(differenceInCalendarDays(end, start), 1);
|
||||
|
||||
const pct = (date: Date) => (differenceInCalendarDays(date, start) / totalDays) * 100;
|
||||
|
||||
// Month-boundary gridlines that fall inside the horizon.
|
||||
const monthLines = eachMonthOfInterval({ start, end })
|
||||
.map((date) => ({ date, left: pct(date) }))
|
||||
.filter((m) => m.left >= 0 && m.left <= 100);
|
||||
|
||||
// Pre-compute lane assignment + height for each sport row.
|
||||
const rows = sports.map((sport) => {
|
||||
const { placed, laneCount } = assignLanes(sport.windows, start);
|
||||
return { sport, placed, height: rowHeight(laneCount) };
|
||||
});
|
||||
|
||||
if (sports.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Draft Schedule</CardTitle>
|
||||
<CardDescription>Draft windows across the next {months} months</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<p>No sports found.</p>
|
||||
<p className="text-sm mt-2">Create a sport to start scheduling draft windows.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>Draft Schedule</CardTitle>
|
||||
<CardDescription>
|
||||
Draft windows across the next {months} months (each bar spans a
|
||||
sport-season’s draft-on → draft-off window)
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||
{(Object.keys(STATUS_COLORS) as DraftScheduleWindow["status"][]).map((status) => (
|
||||
<div key={status} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-3 w-3 rounded-sm"
|
||||
style={{ backgroundColor: STATUS_COLORS[status] }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{STATUS_LABELS[status]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<div className="flex" style={{ minWidth: 160 + months * 80 }}>
|
||||
{/* Sport labels column */}
|
||||
<div className="w-40 shrink-0">
|
||||
<div className="h-8" aria-hidden="true" />
|
||||
{rows.map(({ sport, height }) => (
|
||||
<div
|
||||
key={sport.id}
|
||||
style={{ height }}
|
||||
className="flex items-center border-b border-border/50 pr-2"
|
||||
>
|
||||
<span className="truncate text-sm font-medium" title={sport.name}>
|
||||
{sport.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Timeline column */}
|
||||
<div className="relative flex-1">
|
||||
{/* Month gridlines (full height) */}
|
||||
{monthLines.map((m) => (
|
||||
<div
|
||||
key={`line-${m.date.toISOString()}`}
|
||||
className="pointer-events-none absolute bottom-0 top-0 w-px bg-border/60"
|
||||
style={{ left: `${m.left}%` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
{/* Today marker (full height) */}
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-0 top-0 w-0.5 bg-primary/80"
|
||||
style={{ left: 0 }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Month labels header */}
|
||||
<div className="relative h-8">
|
||||
{monthLines.map((m) => (
|
||||
<span
|
||||
key={`label-${m.date.toISOString()}`}
|
||||
className="absolute top-1 text-xs text-muted-foreground"
|
||||
style={{ left: `calc(${m.left}% + 4px)` }}
|
||||
>
|
||||
{format(m.date, "MMM yyyy")}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
{rows.map(({ sport, placed, height }) => (
|
||||
<div
|
||||
key={sport.id}
|
||||
style={{ height }}
|
||||
className="relative border-b border-border/50"
|
||||
>
|
||||
{placed.length === 0 ? (
|
||||
<span className="absolute left-2 top-1/2 -translate-y-1/2 text-xs italic text-muted-foreground/70">
|
||||
No draft window
|
||||
</span>
|
||||
) : (
|
||||
placed.map(({ window: w, lane }) => {
|
||||
const left = clampPct(pct(parseISO(w.draftOn)));
|
||||
const right = clampPct(pct(parseISO(w.draftOff)));
|
||||
const width = Math.max(right - left, 0.75);
|
||||
return (
|
||||
<Link
|
||||
key={w.id}
|
||||
to={`/admin/sports-seasons/${w.id}`}
|
||||
className="absolute flex items-center overflow-hidden rounded px-1.5 text-xs font-medium text-background transition-opacity hover:opacity-80"
|
||||
style={{
|
||||
left: `${left}%`,
|
||||
width: `${width}%`,
|
||||
top: ROW_V_PAD / 2 + lane * LANE_HEIGHT,
|
||||
height: LANE_HEIGHT - LANE_GAP,
|
||||
backgroundColor: STATUS_COLORS[w.status],
|
||||
}}
|
||||
title={`${w.name} (${w.year}) · ${w.draftOn} → ${w.draftOff}`}
|
||||
>
|
||||
<span className="truncate">{w.name}</span>
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { DraftScheduleGantt, type GanttSport } from "../DraftScheduleGantt";
|
||||
|
||||
const nbaWindow = {
|
||||
id: "ss-1",
|
||||
name: "2026 NBA Playoffs",
|
||||
year: 2026,
|
||||
status: "upcoming" as const,
|
||||
draftOn: "2026-08-01",
|
||||
draftOff: "2026-09-15",
|
||||
sport: { id: "nba", name: "NBA", slug: "nba", iconUrl: null },
|
||||
};
|
||||
|
||||
function renderGantt(sports: GanttSport[], months = 6) {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<DraftScheduleGantt sports={sports} today="2026-07-02" months={months} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
describe("DraftScheduleGantt", () => {
|
||||
it("renders empty state when there are no sports", () => {
|
||||
renderGantt([]);
|
||||
expect(screen.getByText("No sports found.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a bar linking to the sport-season for each draft window", () => {
|
||||
renderGantt([
|
||||
{ id: "nba", name: "NBA", slug: "nba", iconUrl: null, windows: [nbaWindow] },
|
||||
]);
|
||||
|
||||
const bar = screen.getByRole("link", { name: /2026 NBA Playoffs/ });
|
||||
expect(bar).toHaveAttribute("href", "/admin/sports-seasons/ss-1");
|
||||
});
|
||||
|
||||
it("renders overlapping windows as separate, clickable bars", () => {
|
||||
const overlappingWindow = {
|
||||
id: "ss-2",
|
||||
name: "2027 NBA Playoffs",
|
||||
year: 2027,
|
||||
status: "active" as const,
|
||||
draftOn: "2026-08-15",
|
||||
draftOff: "2026-10-01",
|
||||
sport: { id: "nba", name: "NBA", slug: "nba", iconUrl: null },
|
||||
};
|
||||
|
||||
renderGantt([
|
||||
{
|
||||
id: "nba",
|
||||
name: "NBA",
|
||||
slug: "nba",
|
||||
iconUrl: null,
|
||||
windows: [nbaWindow, overlappingWindow],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
screen.getByRole("link", { name: /2026 NBA Playoffs/ })
|
||||
).toHaveAttribute("href", "/admin/sports-seasons/ss-1");
|
||||
expect(
|
||||
screen.getByRole("link", { name: /2027 NBA Playoffs/ })
|
||||
).toHaveAttribute("href", "/admin/sports-seasons/ss-2");
|
||||
});
|
||||
|
||||
it("shows a 'No draft window' hint for a sport with no windows", () => {
|
||||
renderGantt([
|
||||
{ id: "golf", name: "Golf", slug: "golf", iconUrl: null, windows: [] },
|
||||
]);
|
||||
|
||||
expect(screen.getByText("Golf")).toBeInTheDocument();
|
||||
expect(screen.getByText("No draft window")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the status legend", () => {
|
||||
renderGantt([
|
||||
{ id: "nba", name: "NBA", slug: "nba", iconUrl: null, windows: [nbaWindow] },
|
||||
]);
|
||||
|
||||
expect(screen.getByText("Active")).toBeInTheDocument();
|
||||
expect(screen.getByText("Upcoming")).toBeInTheDocument();
|
||||
expect(screen.getByText("Completed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reflects the horizon length in the description", () => {
|
||||
renderGantt(
|
||||
[{ id: "nba", name: "NBA", slug: "nba", iconUrl: null, windows: [nbaWindow] }],
|
||||
12
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(/Draft windows across the next 12 months/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import type { ComponentType } from "react";
|
||||
import type { LucideProps } from "lucide-react";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
type SettingsNavSection = {
|
||||
|
|
@ -18,37 +17,10 @@ export type SettingsGridSection = SettingsNavSection & {
|
|||
export function SettingsMobileGridNav({
|
||||
sections,
|
||||
onSectionChange,
|
||||
buildHref,
|
||||
}: {
|
||||
sections: readonly SettingsGridSection[];
|
||||
onSectionChange?: (sectionId: string) => void;
|
||||
buildHref?: (sectionId: string) => string;
|
||||
onSectionChange: (sectionId: string) => void;
|
||||
}) {
|
||||
const cardClassName = (section: SettingsGridSection) =>
|
||||
cn(
|
||||
"flex cursor-pointer flex-col gap-3 rounded-xl border bg-card p-4 text-left transition-colors hover:border-primary/40 active:scale-[0.98]",
|
||||
section.isDanger && "border-destructive/40"
|
||||
);
|
||||
|
||||
const cardInner = (section: SettingsGridSection) => (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 w-10 items-center justify-center rounded-lg",
|
||||
section.isDanger
|
||||
? "bg-destructive/15 text-destructive"
|
||||
: "bg-primary/20 text-primary"
|
||||
)}
|
||||
>
|
||||
<section.icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold leading-tight">{section.label}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{section.subtitle}</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mb-5 lg:hidden">
|
||||
<div className="mb-3">
|
||||
|
|
@ -57,22 +29,32 @@ export function SettingsMobileGridNav({
|
|||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{sections.map((section) =>
|
||||
buildHref ? (
|
||||
<Link key={section.id} to={buildHref(section.id)} className={cardClassName(section)}>
|
||||
{cardInner(section)}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
key={section.id}
|
||||
type="button"
|
||||
onClick={() => onSectionChange?.(section.id)}
|
||||
className={cardClassName(section)}
|
||||
{sections.map((section) => (
|
||||
<button
|
||||
key={section.id}
|
||||
type="button"
|
||||
onClick={() => onSectionChange(section.id)}
|
||||
className={cn(
|
||||
"flex cursor-pointer flex-col gap-3 rounded-xl border bg-card p-4 text-left transition-colors hover:border-primary/40 active:scale-[0.98]",
|
||||
section.isDanger && "border-destructive/40"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 w-10 items-center justify-center rounded-lg",
|
||||
section.isDanger
|
||||
? "bg-destructive/15 text-destructive"
|
||||
: "bg-primary/20 text-primary"
|
||||
)}
|
||||
>
|
||||
{cardInner(section)}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<section.icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold leading-tight">{section.label}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{section.subtitle}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -80,30 +62,19 @@ export function SettingsMobileGridNav({
|
|||
|
||||
export function SettingsMobileSectionPill({
|
||||
onShowGrid,
|
||||
backHref,
|
||||
}: {
|
||||
onShowGrid?: () => void;
|
||||
backHref?: string;
|
||||
onShowGrid: () => void;
|
||||
}) {
|
||||
const className =
|
||||
"flex cursor-pointer items-center gap-1.5 rounded-full border bg-card px-3 py-1.5 text-sm font-medium hover:bg-muted";
|
||||
const content = (
|
||||
<>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Back to all settings
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<div className="mb-5 lg:hidden">
|
||||
{backHref ? (
|
||||
<Link to={backHref} className={className}>
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<button type="button" onClick={onShowGrid} className={className}>
|
||||
{content}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShowGrid}
|
||||
className="flex cursor-pointer items-center gap-1.5 rounded-full border bg-card px-3 py-1.5 text-sm font-medium hover:bg-muted"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Back to all settings
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -112,57 +83,33 @@ export function SettingsDesktopNav({
|
|||
sections,
|
||||
activeSection,
|
||||
onSectionChange,
|
||||
buildHref,
|
||||
navLabel = "League settings",
|
||||
}: {
|
||||
sections: readonly SettingsGridSection[];
|
||||
activeSection: string;
|
||||
onSectionChange?: (sectionId: string) => void;
|
||||
buildHref?: (sectionId: string) => string;
|
||||
navLabel?: string;
|
||||
onSectionChange: (sectionId: string) => void;
|
||||
}) {
|
||||
const itemClassName = (section: SettingsGridSection) =>
|
||||
cn(
|
||||
"flex w-full cursor-pointer items-center gap-2.5 rounded-md px-3 py-2 text-left text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
activeSection === section.id && "bg-muted text-foreground"
|
||||
);
|
||||
|
||||
const itemInner = (section: SettingsGridSection) => (
|
||||
<>
|
||||
<section.icon className={cn("h-4 w-4 shrink-0", section.isDanger && "text-destructive")} aria-hidden="true" />
|
||||
{section.label}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block">
|
||||
<div className="sticky top-6 rounded-xl border bg-card p-3">
|
||||
<p className="px-3 pb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Manage
|
||||
</p>
|
||||
<nav aria-label={navLabel} className="space-y-1">
|
||||
{sections.map((section) =>
|
||||
buildHref ? (
|
||||
<Link
|
||||
key={section.id}
|
||||
to={buildHref(section.id)}
|
||||
aria-current={activeSection === section.id ? "page" : undefined}
|
||||
className={itemClassName(section)}
|
||||
>
|
||||
{itemInner(section)}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
key={section.id}
|
||||
type="button"
|
||||
onClick={() => onSectionChange?.(section.id)}
|
||||
aria-current={activeSection === section.id ? "page" : undefined}
|
||||
className={itemClassName(section)}
|
||||
>
|
||||
{itemInner(section)}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<nav aria-label="League settings" className="space-y-1">
|
||||
{sections.map((section) => (
|
||||
<button
|
||||
key={section.id}
|
||||
type="button"
|
||||
onClick={() => onSectionChange(section.id)}
|
||||
aria-current={activeSection === section.id ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-2.5 rounded-md px-3 py-2 text-left text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
activeSection === section.id && "bg-muted text-foreground"
|
||||
)}
|
||||
>
|
||||
<section.icon className={cn("h-4 w-4 shrink-0", section.isDanger && "text-destructive")} aria-hidden="true" />
|
||||
{section.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { Bell, User } from "lucide-react";
|
||||
import {
|
||||
SettingsDesktopNav,
|
||||
SettingsMobileGridNav,
|
||||
SettingsMobileSectionPill,
|
||||
type SettingsGridSection,
|
||||
} from "../SettingsNavigation";
|
||||
|
||||
const sections: readonly SettingsGridSection[] = [
|
||||
{ id: "profile", label: "Profile", icon: User, subtitle: "Name, avatar" },
|
||||
{ id: "notifications", label: "Notifications", icon: Bell, subtitle: "Email & Discord" },
|
||||
];
|
||||
|
||||
describe("SettingsNavigation link mode", () => {
|
||||
it("renders desktop nav entries as anchors built from buildHref", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SettingsDesktopNav
|
||||
sections={sections}
|
||||
activeSection="profile"
|
||||
buildHref={(id) => `/settings/${id}`}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByRole("link", { name: "Profile" })).toHaveAttribute("href", "/settings/profile");
|
||||
expect(screen.getByRole("link", { name: "Notifications" })).toHaveAttribute(
|
||||
"href",
|
||||
"/settings/notifications"
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Profile" })).toHaveAttribute("aria-current", "page");
|
||||
});
|
||||
|
||||
it("renders the mobile grid as anchors and the back pill as an anchor", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SettingsMobileGridNav sections={sections} buildHref={(id) => `/settings/${id}`} />
|
||||
<SettingsMobileSectionPill backHref="/settings" />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByRole("link", { name: /Profile/i })).toHaveAttribute("href", "/settings/profile");
|
||||
expect(screen.getByRole("link", { name: /back to all settings/i })).toHaveAttribute(
|
||||
"href",
|
||||
"/settings"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsNavigation button mode (league settings)", () => {
|
||||
it("still fires onSectionChange when no buildHref is provided", () => {
|
||||
const onSectionChange = vi.fn();
|
||||
render(
|
||||
<SettingsDesktopNav
|
||||
sections={sections}
|
||||
activeSection="profile"
|
||||
onSectionChange={onSectionChange}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Notifications" }));
|
||||
expect(onSectionChange).toHaveBeenCalledWith("notifications");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,16 +1,13 @@
|
|||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { useRoundTransition } from "~/hooks/useRoundTransition";
|
||||
import type { FeederMap } from "~/lib/bracket-layout";
|
||||
import type { BracketTemplate } from "~/lib/bracket-templates";
|
||||
import {
|
||||
TreeColumns,
|
||||
BracketMatchSlot,
|
||||
bracketGeometry,
|
||||
windowGeometry,
|
||||
SLOT_WIDTH,
|
||||
LABEL_HEIGHT,
|
||||
DESIRED_CARD_HEIGHT,
|
||||
CARD_GAP,
|
||||
MAX_CARD_HEIGHT,
|
||||
type BracketMatch,
|
||||
type BracketOwnership,
|
||||
|
|
@ -24,8 +21,6 @@ interface BracketTreePaginatedProps {
|
|||
/** Index of the first scoring round — default page starts here */
|
||||
firstScoringRoundIdx?: number;
|
||||
thirdPlaceRound?: string;
|
||||
feeders?: FeederMap;
|
||||
template?: BracketTemplate;
|
||||
}
|
||||
|
||||
export function BracketTreePaginated({
|
||||
|
|
@ -35,68 +30,63 @@ export function BracketTreePaginated({
|
|||
userParticipantIds,
|
||||
firstScoringRoundIdx,
|
||||
thirdPlaceRound,
|
||||
feeders,
|
||||
template,
|
||||
}: BracketTreePaginatedProps) {
|
||||
const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds;
|
||||
const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined;
|
||||
|
||||
// Pages are pairs of layout columns, not pairs of rounds: a column can mix rounds
|
||||
// when teams enter the bracket at different points (see computeGroupLayout).
|
||||
const geometry = bracketGeometry(
|
||||
mainRounds,
|
||||
matchesByRound,
|
||||
feeders,
|
||||
template?.rounds.map((r) => r.name) ?? mainRounds
|
||||
);
|
||||
const columns = geometry.layout.columns;
|
||||
const lastPage = Math.max(columns.length - 2, 0);
|
||||
|
||||
const defaultPage = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
firstScoringRoundIdx !== undefined ? Math.max(0, firstScoringRoundIdx - 1) : lastPage,
|
||||
lastPage,
|
||||
firstScoringRoundIdx !== undefined
|
||||
? Math.max(0, firstScoringRoundIdx - 1)
|
||||
: mainRounds.length - 2,
|
||||
mainRounds.length - 2,
|
||||
),
|
||||
);
|
||||
|
||||
const { page, anim, stripRef, navigate, handleTransitionEnd } = useRoundTransition(
|
||||
lastPage,
|
||||
mainRounds.length - 2,
|
||||
defaultPage,
|
||||
);
|
||||
|
||||
const pageGeometry = (p: number) => windowGeometry(geometry, p, p + 1);
|
||||
const labelFor = (p: number) => {
|
||||
const [a, b] = [columns[p]?.label, columns[p + 1]?.label];
|
||||
return b ? `${a} → ${b}` : (a ?? "");
|
||||
const targetPage = anim ? anim.toPage : page;
|
||||
const labelRounds = mainRounds.slice(targetPage, targetPage + 2);
|
||||
const label = labelRounds[1] ? `${labelRounds[0]} → ${labelRounds[1]}` : labelRounds[0];
|
||||
|
||||
const calcHeight = (p: number) => {
|
||||
const rs = mainRounds.slice(p, p + 2);
|
||||
const max = Math.max(...rs.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
|
||||
return max * (DESIRED_CARD_HEIGHT + CARD_GAP);
|
||||
};
|
||||
|
||||
const label = labelFor(anim ? anim.toPage : page);
|
||||
const pageHeight = calcHeight(page);
|
||||
const animFromHeight = anim ? calcHeight(anim.fromPage) : pageHeight;
|
||||
const animToHeight = anim ? calcHeight(anim.toPage) : pageHeight;
|
||||
|
||||
const pageG = pageGeometry(page);
|
||||
const animFromG = anim ? pageGeometry(anim.fromPage) : pageG;
|
||||
const animToG = anim ? pageGeometry(anim.toPage) : pageG;
|
||||
const visibleRounds = mainRounds.slice(page, page + 2);
|
||||
const fromRounds = anim ? mainRounds.slice(anim.fromPage, anim.fromPage + 2) : visibleRounds;
|
||||
const toRounds = anim ? mainRounds.slice(anim.toPage, anim.toPage + 2) : visibleRounds;
|
||||
|
||||
let leftPage: number;
|
||||
let rightPage: number | null = null;
|
||||
let leftG = pageG;
|
||||
let rightG = pageG;
|
||||
let leftRounds: string[];
|
||||
let rightRounds: string[] = [];
|
||||
let leftHeight: number;
|
||||
let rightHeight = 0;
|
||||
let settlingTransition = false;
|
||||
if (anim?.phase === "sliding") {
|
||||
leftPage = anim.dir === "right" ? anim.fromPage : anim.toPage;
|
||||
rightPage = anim.dir === "right" ? anim.toPage : anim.fromPage;
|
||||
leftG = anim.dir === "right" ? animFromG : animToG;
|
||||
rightG = anim.dir === "right" ? animToG : animFromG;
|
||||
leftRounds = anim.dir === "right" ? fromRounds : toRounds;
|
||||
rightRounds = anim.dir === "right" ? toRounds : fromRounds;
|
||||
leftHeight = anim.dir === "right" ? animFromHeight : animToHeight;
|
||||
rightHeight = anim.dir === "right" ? animToHeight : animFromHeight;
|
||||
} else if (anim?.phase === "settling") {
|
||||
leftPage = anim.toPage;
|
||||
leftG = animToG;
|
||||
leftRounds = toRounds;
|
||||
leftHeight = animToHeight;
|
||||
settlingTransition = true;
|
||||
} else {
|
||||
leftPage = page;
|
||||
leftRounds = visibleRounds;
|
||||
leftHeight = pageHeight;
|
||||
}
|
||||
|
||||
const containerMinHeight =
|
||||
anim?.phase === "settling" ? animToG.bracketHeight : animFromG.bracketHeight;
|
||||
const containerMinHeight = anim?.phase === "settling" ? animToHeight : animFromHeight;
|
||||
const initialX = anim?.phase === "sliding" && anim.dir === "left" ? -SLOT_WIDTH : 0;
|
||||
|
||||
return (
|
||||
|
|
@ -119,7 +109,7 @@ export function BracketTreePaginated({
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(page + 1)}
|
||||
disabled={page >= lastPage || !!anim}
|
||||
disabled={page + 2 >= mainRounds.length || !!anim}
|
||||
className="h-7 w-7 shrink-0"
|
||||
aria-label="Next rounds"
|
||||
>
|
||||
|
|
@ -139,24 +129,22 @@ export function BracketTreePaginated({
|
|||
>
|
||||
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
|
||||
<TreeColumns
|
||||
geometry={leftG}
|
||||
columnRange={[leftPage, leftPage + 1]}
|
||||
visibleRounds={leftRounds}
|
||||
matchesByRound={matchesByRound}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
bracketHeight={leftHeight}
|
||||
transitionDuration={settlingTransition ? 500 : undefined}
|
||||
/>
|
||||
</div>
|
||||
{anim?.phase === "sliding" && rightPage !== null && (
|
||||
{anim?.phase === "sliding" && (
|
||||
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
|
||||
<TreeColumns
|
||||
geometry={rightG}
|
||||
columnRange={[rightPage, rightPage + 1]}
|
||||
visibleRounds={rightRounds}
|
||||
matchesByRound={matchesByRound}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
bracketHeight={rightHeight}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -176,8 +164,6 @@ export function BracketTreePaginated({
|
|||
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,5 @@
|
|||
import { avatarColor } from "~/lib/avatar-colors";
|
||||
import { BRACKT_GRADIENT } from "~/lib/brand";
|
||||
import {
|
||||
computeGroupLayout,
|
||||
describeSlotSource,
|
||||
matchKey,
|
||||
type BracketLayout,
|
||||
type FeederMap,
|
||||
} from "~/lib/bracket-layout";
|
||||
import type { BracketTemplate } from "~/lib/bracket-templates";
|
||||
|
||||
export interface BracketMatch {
|
||||
id: string;
|
||||
|
|
@ -54,8 +46,6 @@ function formatScore(score: string | null): string | null {
|
|||
|
||||
interface ParticipantRowProps {
|
||||
name: string | null;
|
||||
/** What fills this slot when it's still empty, e.g. "Winner of Winners SF 2". */
|
||||
feedLabel?: string | null;
|
||||
isTbd: boolean;
|
||||
isWinner: boolean;
|
||||
isLoser: boolean;
|
||||
|
|
@ -70,7 +60,6 @@ interface ParticipantRowProps {
|
|||
|
||||
function ParticipantRow({
|
||||
name,
|
||||
feedLabel,
|
||||
isTbd,
|
||||
isWinner,
|
||||
isLoser,
|
||||
|
|
@ -125,7 +114,7 @@ function ParticipantRow({
|
|||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{name ?? feedLabel ?? "TBD"}
|
||||
{name ?? "TBD"}
|
||||
</span>
|
||||
|
||||
{/* Owner name below participant name */}
|
||||
|
|
@ -161,8 +150,6 @@ interface BracketMatchSlotProps {
|
|||
slotHeight: number;
|
||||
ownershipMap: Map<string, BracketOwnership>;
|
||||
userParticipantIds: Set<string>;
|
||||
feeders?: FeederMap;
|
||||
template?: BracketTemplate;
|
||||
}
|
||||
|
||||
export function BracketMatchSlot({
|
||||
|
|
@ -170,8 +157,6 @@ export function BracketMatchSlot({
|
|||
slotHeight,
|
||||
ownershipMap,
|
||||
userParticipantIds,
|
||||
feeders,
|
||||
template,
|
||||
}: BracketMatchSlotProps) {
|
||||
const rowHeight = slotHeight / 2;
|
||||
const showText = rowHeight >= 10;
|
||||
|
|
@ -202,13 +187,6 @@ export function BracketMatchSlot({
|
|||
|
||||
const INSET = Math.max(1, Math.min(2, Math.floor(slotHeight / 20)));
|
||||
|
||||
// An empty slot reads better as "Loser of Winners SF 2" than "TBD" — especially for
|
||||
// the feeds that cross between the winners and elimination brackets, which render as
|
||||
// separate trees and so can never be joined by a line.
|
||||
const slotSources = feeders?.get(matchKey(match.round, match.matchNumber));
|
||||
const feed1 = describeSlotSource(slotSources?.[0], template);
|
||||
const feed2 = describeSlotSource(slotSources?.[1], template);
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden" style={{ height: slotHeight }}>
|
||||
{/* Corona layer — left-2 matches card borderRadius to prevent gradient bleed at left corners */}
|
||||
|
|
@ -230,7 +208,6 @@ export function BracketMatchSlot({
|
|||
>
|
||||
<ParticipantRow
|
||||
name={match.participant1?.name ?? null}
|
||||
feedLabel={feed1}
|
||||
isTbd={isTbd1}
|
||||
isWinner={p1IsWinner}
|
||||
isLoser={p1IsLoser}
|
||||
|
|
@ -244,7 +221,6 @@ export function BracketMatchSlot({
|
|||
/>
|
||||
<ParticipantRow
|
||||
name={match.participant2?.name ?? null}
|
||||
feedLabel={feed2}
|
||||
isTbd={isTbd2}
|
||||
isWinner={p2IsWinner}
|
||||
isLoser={p2IsLoser}
|
||||
|
|
@ -261,45 +237,54 @@ export function BracketMatchSlot({
|
|||
);
|
||||
}
|
||||
|
||||
// ─── Connector column ─────────────────────────────────────────────────────────
|
||||
// ─── Per-pair connector column ────────────────────────────────────────────────
|
||||
|
||||
interface ConnectorColumnProps {
|
||||
/** Edges crossing this gutter, in slot units. */
|
||||
edges: { fromCenter: number; toCenter: number }[];
|
||||
rowHeight: number;
|
||||
offset: number;
|
||||
currentMatches: BracketMatch[];
|
||||
nextMatches: BracketMatch[];
|
||||
bracketHeight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the feeder edges crossing one gutter. Because the layout assigns columns by
|
||||
* depth from the final, every edge spans exactly one gutter — so a card that enters the
|
||||
* bracket late is drawn in the column where it actually plays, and there is never an
|
||||
* edge to route across a skipped column.
|
||||
*/
|
||||
function ConnectorColumn({ edges, rowHeight, offset, bracketHeight }: ConnectorColumnProps) {
|
||||
function ConnectorColumn({ currentMatches, nextMatches, bracketHeight }: ConnectorColumnProps) {
|
||||
const mid = CONNECTOR_WIDTH / 2;
|
||||
|
||||
// Merge the two edges feeding one card into a single elbow, so a pair reads as one
|
||||
// bracket join rather than two overlapping lines.
|
||||
const byTarget = new Map<number, number[]>();
|
||||
for (const { fromCenter, toCenter } of edges) {
|
||||
const sources = byTarget.get(toCenter) ?? [];
|
||||
sources.push(fromCenter);
|
||||
byTarget.set(toCenter, sources);
|
||||
}
|
||||
|
||||
const paths: string[] = [];
|
||||
for (const [toCenter, sources] of byTarget) {
|
||||
const destY = toCenter * rowHeight - offset;
|
||||
const ys = sources.map((c) => c * rowHeight - offset).toSorted((a, b) => a - b);
|
||||
if (ys.length === 1) {
|
||||
paths.push(`M 0 ${ys[0]} H ${mid} V ${destY} H ${CONNECTOR_WIDTH}`);
|
||||
continue;
|
||||
|
||||
const currentSlotH = bracketHeight / Math.max(currentMatches.length, 1);
|
||||
const nextSlotH = bracketHeight / Math.max(nextMatches.length, 1);
|
||||
|
||||
// Use halving U-shapes only when prev > 1 (avoids false-positive 1→1 side branches like 3PG→Finals)
|
||||
if (nextMatches.length === Math.ceil(currentMatches.length / 2) && currentMatches.length > 1) {
|
||||
// Standard single-elimination halving: U-shape connectors
|
||||
for (let k = 0; k < nextMatches.length; k++) {
|
||||
const topY = (2 * k) * currentSlotH + currentSlotH / 2;
|
||||
const midY = k * nextSlotH + nextSlotH / 2;
|
||||
const botIdx = 2 * k + 1;
|
||||
|
||||
if (botIdx < currentMatches.length) {
|
||||
const botY = botIdx * currentSlotH + currentSlotH / 2;
|
||||
paths.push(`M 0 ${topY} H ${mid} V ${botY} H 0`);
|
||||
paths.push(`M ${mid} ${midY} H ${CONNECTOR_WIDTH}`);
|
||||
} else {
|
||||
paths.push(`M 0 ${topY} H ${CONNECTOR_WIDTH}`);
|
||||
}
|
||||
}
|
||||
paths.push(`M 0 ${ys[0]} H ${mid} V ${ys[ys.length - 1]} H 0`);
|
||||
for (const y of ys.slice(1, -1)) paths.push(`M 0 ${y} H ${mid}`);
|
||||
paths.push(`M ${mid} ${destY} H ${CONNECTOR_WIDTH}`);
|
||||
} else {
|
||||
// Non-standard (byes, play-ins, etc.): trace winners by participantId
|
||||
const winnerToIdx = new Map<string, number>();
|
||||
currentMatches.forEach((m, idx) => {
|
||||
if (m.winnerId) winnerToIdx.set(m.winnerId, idx);
|
||||
});
|
||||
|
||||
nextMatches.forEach((nextMatch, nextIdx) => {
|
||||
const destY = nextIdx * nextSlotH + nextSlotH / 2;
|
||||
for (const pId of [nextMatch.participant1Id, nextMatch.participant2Id]) {
|
||||
if (!pId) continue;
|
||||
const srcIdx = winnerToIdx.get(pId);
|
||||
if (srcIdx === undefined) continue;
|
||||
const srcY = srcIdx * currentSlotH + currentSlotH / 2;
|
||||
paths.push(`M 0 ${srcY} H ${mid} V ${destY} H ${CONNECTOR_WIDTH}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -325,131 +310,52 @@ function ConnectorColumn({ edges, rowHeight, offset, bracketHeight }: ConnectorC
|
|||
|
||||
// ─── Tree columns (shared by full + paginated) ───────────────────────────────
|
||||
|
||||
export interface BracketGeometry {
|
||||
layout: BracketLayout<BracketMatch>;
|
||||
/** Height of one leaf row. */
|
||||
rowHeight: number;
|
||||
/** Height of the card area, excluding the round labels. */
|
||||
bracketHeight: number;
|
||||
/** Narrowest the columns and gutters can be drawn without overlapping. */
|
||||
minWidth: number;
|
||||
/** Pixels trimmed off the top, non-zero only for a cropped column window. */
|
||||
offset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay out a group's matches from the feeder graph and derive its pixel geometry.
|
||||
*
|
||||
* Height comes from the number of leaf rows rather than the largest round, so a bracket
|
||||
* whose widest column isn't its first still gets the room it needs.
|
||||
*/
|
||||
export function bracketGeometry(
|
||||
visibleRounds: string[],
|
||||
matchesByRound: Map<string, BracketMatch[]>,
|
||||
feeders: FeederMap | undefined,
|
||||
templateRoundOrder: string[]
|
||||
): BracketGeometry {
|
||||
const layout = computeGroupLayout(
|
||||
visibleRounds,
|
||||
matchesByRound,
|
||||
feeders ?? new Map(),
|
||||
templateRoundOrder
|
||||
);
|
||||
const rowHeight = DESIRED_CARD_HEIGHT + CARD_GAP;
|
||||
const columnCount = Math.max(layout.columns.length, 1);
|
||||
return {
|
||||
layout,
|
||||
rowHeight,
|
||||
bracketHeight: Math.max(layout.leafCount, 1) * rowHeight,
|
||||
minWidth: columnCount * COLUMN_WIDTH + (columnCount - 1) * CONNECTOR_WIDTH,
|
||||
offset: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Crop a layout to a window of columns, as the mobile pager does.
|
||||
*
|
||||
* Card positions are absolute within the whole bracket, so showing a slice of columns
|
||||
* means trimming the empty space above them rather than re-flowing — otherwise a later
|
||||
* page would render its two columns stranded at the bottom of a full-height bracket.
|
||||
*/
|
||||
export function windowGeometry(
|
||||
geometry: BracketGeometry,
|
||||
firstColumn: number,
|
||||
lastColumn: number
|
||||
): BracketGeometry {
|
||||
const centers = geometry.layout.columns
|
||||
.slice(firstColumn, lastColumn + 1)
|
||||
.flatMap((c) => c.matches.map((m) => m.center));
|
||||
if (centers.length === 0) return geometry;
|
||||
|
||||
const min = Math.min(...centers);
|
||||
const max = Math.max(...centers);
|
||||
return {
|
||||
...geometry,
|
||||
bracketHeight: (max - min + 1) * geometry.rowHeight,
|
||||
offset: (min - 0.5) * geometry.rowHeight,
|
||||
};
|
||||
}
|
||||
|
||||
interface TreeColumnsProps {
|
||||
geometry: BracketGeometry;
|
||||
visibleRounds: string[];
|
||||
matchesByRound: Map<string, BracketMatch[]>;
|
||||
ownershipMap: Map<string, BracketOwnership>;
|
||||
userParticipantIds: Set<string>;
|
||||
bracketHeight: number;
|
||||
transitionDuration?: number;
|
||||
feeders?: FeederMap;
|
||||
template?: BracketTemplate;
|
||||
/** Restrict rendering to a window of columns (used by the mobile pager). */
|
||||
columnRange?: [number, number];
|
||||
}
|
||||
|
||||
export function TreeColumns({
|
||||
geometry,
|
||||
visibleRounds,
|
||||
matchesByRound,
|
||||
ownershipMap,
|
||||
userParticipantIds,
|
||||
bracketHeight,
|
||||
transitionDuration,
|
||||
feeders,
|
||||
template,
|
||||
columnRange,
|
||||
}: TreeColumnsProps) {
|
||||
const tr = transitionDuration ? `${transitionDuration}ms ease` : undefined;
|
||||
const { layout, rowHeight, bracketHeight, offset } = geometry;
|
||||
|
||||
const [firstColumn, lastColumn] = columnRange ?? [0, layout.columns.length - 1];
|
||||
const visible = layout.columns.slice(firstColumn, lastColumn + 1);
|
||||
|
||||
// Cards keep a fixed height regardless of how many share a column — stretching a
|
||||
// lone final to fill its column is what made it tower over the rest of the bracket.
|
||||
const cardHeight = Math.min(
|
||||
Math.max(rowHeight - CARD_GAP, 1),
|
||||
MAX_CARD_HEIGHT
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", width: "100%", height: bracketHeight + LABEL_HEIGHT, transition: tr ? `height ${tr}` : undefined }}>
|
||||
{visible.map((column, vi) => {
|
||||
const ci = firstColumn + vi;
|
||||
const gutterEdges = layout.edges.filter((e) => e.fromColumn === ci);
|
||||
{visibleRounds.map((round, ri) => {
|
||||
const roundMatches = matchesByRound.get(round) ?? [];
|
||||
const slotHeight = bracketHeight / Math.max(roundMatches.length, 1);
|
||||
const cardHeight = Math.min(slotHeight - CARD_GAP, MAX_CARD_HEIGHT);
|
||||
const cardTop = (slotHeight - cardHeight) / 2;
|
||||
const nextRound = ri < visibleRounds.length - 1 ? visibleRounds[ri + 1] : null;
|
||||
const nextMatches = nextRound ? (matchesByRound.get(nextRound) ?? []) : [];
|
||||
|
||||
return (
|
||||
<div key={column.label + ci} style={{ display: "contents" }}>
|
||||
<div key={round} style={{ display: "contents" }}>
|
||||
{/* Round column */}
|
||||
<div style={{ flex: "1 1 0", minWidth: COLUMN_WIDTH, position: "relative" }}>
|
||||
<div
|
||||
className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground truncate text-center"
|
||||
style={{ height: LABEL_HEIGHT, display: "flex", alignItems: "center", justifyContent: "center" }}
|
||||
>
|
||||
{column.label}
|
||||
{round}
|
||||
</div>
|
||||
|
||||
<div style={{ position: "relative", height: bracketHeight, transition: tr ? `height ${tr}` : undefined }}>
|
||||
{column.matches.map(({ match, center }) => (
|
||||
{roundMatches.map((match, matchIdx) => (
|
||||
<div
|
||||
key={match.id}
|
||||
data-match-id={match.id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: center * rowHeight - offset - cardHeight / 2,
|
||||
top: matchIdx * slotHeight + cardTop,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: cardHeight,
|
||||
|
|
@ -461,8 +367,6 @@ export function TreeColumns({
|
|||
slotHeight={cardHeight}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -470,11 +374,10 @@ export function TreeColumns({
|
|||
</div>
|
||||
|
||||
{/* Connector between this column and the next */}
|
||||
{vi < visible.length - 1 && (
|
||||
{nextRound && (
|
||||
<ConnectorColumn
|
||||
edges={gutterEdges}
|
||||
rowHeight={rowHeight}
|
||||
offset={offset}
|
||||
currentMatches={roundMatches}
|
||||
nextMatches={nextMatches}
|
||||
bracketHeight={bracketHeight}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -493,8 +396,6 @@ interface BracketTreeViewProps {
|
|||
ownershipMap: Map<string, BracketOwnership>;
|
||||
userParticipantIds: Set<string>;
|
||||
thirdPlaceRound?: string;
|
||||
feeders?: FeederMap;
|
||||
template?: BracketTemplate;
|
||||
}
|
||||
|
||||
export function BracketTreeView({
|
||||
|
|
@ -503,19 +404,13 @@ export function BracketTreeView({
|
|||
ownershipMap,
|
||||
userParticipantIds,
|
||||
thirdPlaceRound,
|
||||
feeders,
|
||||
template,
|
||||
}: BracketTreeViewProps) {
|
||||
const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds;
|
||||
const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined;
|
||||
|
||||
const geometry = bracketGeometry(
|
||||
mainRounds,
|
||||
matchesByRound,
|
||||
feeders,
|
||||
template?.rounds.map((r) => r.name) ?? mainRounds
|
||||
);
|
||||
const { bracketHeight, minWidth } = geometry;
|
||||
const maxMatches = Math.max(...mainRounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
|
||||
const bracketHeight = maxMatches * (DESIRED_CARD_HEIGHT + CARD_GAP);
|
||||
const minWidth = mainRounds.length * COLUMN_WIDTH + Math.max(0, mainRounds.length - 1) * CONNECTOR_WIDTH;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -524,11 +419,11 @@ export function BracketTreeView({
|
|||
>
|
||||
<div style={{ minWidth }}>
|
||||
<TreeColumns
|
||||
geometry={geometry}
|
||||
visibleRounds={mainRounds}
|
||||
matchesByRound={matchesByRound}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
bracketHeight={bracketHeight}
|
||||
/>
|
||||
{thirdPlaceMatch && (
|
||||
<div style={{ display: "flex", paddingTop: 20 }}>
|
||||
|
|
@ -546,8 +441,6 @@ export function BracketTreeView({
|
|||
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,5 @@
|
|||
import type { BracketTemplate, ConferenceGroup } from "~/lib/bracket-templates";
|
||||
import type { FeederMap } from "~/lib/bracket-layout";
|
||||
import {
|
||||
TreeColumns,
|
||||
bracketGeometry,
|
||||
type BracketMatch,
|
||||
type BracketOwnership,
|
||||
} from "./BracketTreeView";
|
||||
import type { ConferenceGroup } from "~/lib/bracket-templates";
|
||||
import { TreeColumns, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
|
||||
import { BracketTreePaginated } from "./BracketTreePaginated";
|
||||
|
||||
interface NbaBracketLayoutProps {
|
||||
|
|
@ -16,10 +10,11 @@ interface NbaBracketLayoutProps {
|
|||
userParticipantIds: Set<string>;
|
||||
conferenceGroups: ConferenceGroup[];
|
||||
scoringRoundIdx: number;
|
||||
feeders?: FeederMap;
|
||||
template?: BracketTemplate;
|
||||
}
|
||||
|
||||
const DESIRED_CARD_HEIGHT = 112;
|
||||
const CARD_GAP = 14;
|
||||
|
||||
function splitMatchesByConference(
|
||||
matchesByRound: Map<string, BracketMatch[]>,
|
||||
group: ConferenceGroup
|
||||
|
|
@ -33,6 +28,11 @@ function splitMatchesByConference(
|
|||
return result;
|
||||
}
|
||||
|
||||
function bracketHeight(matchesByRound: Map<string, BracketMatch[]>, rounds: string[]): number {
|
||||
const max = Math.max(...rounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
|
||||
return max * (DESIRED_CARD_HEIGHT + CARD_GAP);
|
||||
}
|
||||
|
||||
export function NbaBracketLayout({
|
||||
rounds,
|
||||
matchesByRound,
|
||||
|
|
@ -40,10 +40,7 @@ export function NbaBracketLayout({
|
|||
userParticipantIds,
|
||||
conferenceGroups,
|
||||
scoringRoundIdx,
|
||||
feeders,
|
||||
template,
|
||||
}: NbaBracketLayoutProps) {
|
||||
const roundOrder = template?.rounds.map((r) => r.name) ?? rounds;
|
||||
// Rounds that belong to any conference group
|
||||
const conferenceRoundSet = new Set(
|
||||
conferenceGroups.flatMap((g) => Object.keys(g.roundMatchNumbers))
|
||||
|
|
@ -60,7 +57,7 @@ export function NbaBracketLayout({
|
|||
const sharedMatches = new Map(
|
||||
sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []])
|
||||
);
|
||||
const sharedGeometry = bracketGeometry(sharedRounds, sharedMatches, feeders, roundOrder);
|
||||
const sharedHeight = bracketHeight(sharedMatches, sharedRounds);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -69,7 +66,7 @@ export function NbaBracketLayout({
|
|||
{conferenceGroups.map((group, gi) => {
|
||||
const confRounds = conferenceRounds[gi];
|
||||
const confMatches = splitMatchesByConference(matchesByRound, group);
|
||||
const geometry = bracketGeometry(confRounds, confMatches, feeders, roundOrder);
|
||||
const height = bracketHeight(confMatches, confRounds);
|
||||
|
||||
return (
|
||||
<div key={group.name}>
|
||||
|
|
@ -77,11 +74,11 @@ export function NbaBracketLayout({
|
|||
{group.name}
|
||||
</p>
|
||||
<TreeColumns
|
||||
geometry={geometry}
|
||||
visibleRounds={confRounds}
|
||||
matchesByRound={confMatches}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
bracketHeight={height}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -90,11 +87,11 @@ export function NbaBracketLayout({
|
|||
{sharedRounds.length > 0 && (
|
||||
<div>
|
||||
<TreeColumns
|
||||
geometry={sharedGeometry}
|
||||
visibleRounds={sharedRounds}
|
||||
matchesByRound={sharedMatches}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
bracketHeight={sharedHeight}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -108,8 +105,6 @@ export function NbaBracketLayout({
|
|||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
firstScoringRoundIdx={scoringRoundIdx}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -13,8 +13,7 @@ import { GradientIcon } from "~/components/ui/GradientIcon";
|
|||
import { RankingsRow } from "./RankingsRow";
|
||||
import { BracketTreeView, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
|
||||
import { BracketTreePaginated } from "./BracketTreePaginated";
|
||||
import { getBracketTemplate, type BracketTemplate } from "~/lib/bracket-templates";
|
||||
import { buildFeederMap } from "~/lib/bracket-layout";
|
||||
import { getBracketTemplate } from "~/lib/bracket-templates";
|
||||
import { NbaBracketLayout } from "./NbaBracketLayout";
|
||||
import { TabbedBracketLayout } from "./TabbedBracketLayout";
|
||||
|
||||
|
|
@ -77,6 +76,43 @@ export function groupMatchesByRound(matches: Match[]): Map<string, Match[]> {
|
|||
return byRound;
|
||||
}
|
||||
|
||||
/**
|
||||
* For a standard single-elimination bracket, slot p1 of match N in round R
|
||||
* comes from match (2N-1) in the previous round, and slot p2 from match 2N.
|
||||
*/
|
||||
export function buildFeederMap(
|
||||
matchesByRound: Map<string, Match[]>,
|
||||
orderedRounds: string[]
|
||||
): Map<string, { round: string; matchNumber: number }> {
|
||||
const feederMap = new Map<string, { round: string; matchNumber: number }>();
|
||||
|
||||
for (let ri = 1; ri < orderedRounds.length; ri++) {
|
||||
const currentRound = orderedRounds[ri];
|
||||
const prevRound = orderedRounds[ri - 1];
|
||||
const prevMatchNums = new Set(
|
||||
(matchesByRound.get(prevRound) || []).map((m) => m.matchNumber)
|
||||
);
|
||||
for (const match of matchesByRound.get(currentRound) || []) {
|
||||
const p1Src = 2 * (match.matchNumber - 1) + 1;
|
||||
const p2Src = 2 * (match.matchNumber - 1) + 2;
|
||||
if (prevMatchNums.has(p1Src)) {
|
||||
feederMap.set(`${currentRound}:${match.matchNumber}:p1`, {
|
||||
round: prevRound,
|
||||
matchNumber: p1Src,
|
||||
});
|
||||
}
|
||||
if (prevMatchNums.has(p2Src)) {
|
||||
feederMap.set(`${currentRound}:${match.matchNumber}:p2`, {
|
||||
round: prevRound,
|
||||
matchNumber: p2Src,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return feederMap;
|
||||
}
|
||||
|
||||
interface EliminatedEntry {
|
||||
participant: Participant;
|
||||
score: string | null;
|
||||
|
|
@ -138,182 +174,6 @@ export function computeEliminatedByRound(
|
|||
return result;
|
||||
}
|
||||
|
||||
/** The score recorded for one participant in a match, or null if they didn't play in it. */
|
||||
function participantScore(match: Match, participantId: string | null): string | null {
|
||||
if (!participantId) return null;
|
||||
if (participantId === match.participant1Id) return match.participant1Score;
|
||||
if (participantId === match.participant2Id) return match.participant2Score;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A consolation final: a round contested by the losers of an earlier round, which
|
||||
* splits the positions those losers would otherwise share. FIFA's "Third Place Game"
|
||||
* (fed by the Semifinals) is the only one in the templates today.
|
||||
*/
|
||||
export interface ConsolationRound {
|
||||
/** The consolation round itself, e.g. "Third Place Game". */
|
||||
round: string;
|
||||
/** The round whose losers contest it, e.g. "Semifinals". */
|
||||
feederRound: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the template's consolation round, if it has one.
|
||||
*
|
||||
* A consolation round must be TERMINAL — its winner plays no further game, which is
|
||||
* what lets its result split two exact positions. `loserFeedsInto` alone is not
|
||||
* enough: a double-elimination bracket (llws_20) uses it on every winners-bracket
|
||||
* round to route losers into the elimination bracket, and those targets are ordinary
|
||||
* rounds that feed onward. Picking the first `loserFeedsInto` there would mistake
|
||||
* "Elimination Round 1" for a third-place game and corrupt the final rankings.
|
||||
*
|
||||
* Exported for unit testing.
|
||||
*/
|
||||
export function findConsolationRound(
|
||||
template: BracketTemplate | undefined
|
||||
): ConsolationRound | undefined {
|
||||
const isTerminal = (roundName: string) =>
|
||||
template?.rounds.find((r) => r.name === roundName)?.feedsInto === null;
|
||||
|
||||
const feeder = template?.rounds.find(
|
||||
(r) => r.loserFeedsInto && isTerminal(r.loserFeedsInto)
|
||||
);
|
||||
if (!feeder?.loserFeedsInto) return undefined;
|
||||
return { round: feeder.loserFeedsInto, feederRound: feeder.name };
|
||||
}
|
||||
|
||||
/**
|
||||
* Round names whose losers are placed by some LATER round rather than finishing where
|
||||
* they lost — i.e. double-elimination winners-bracket rounds, whose losers drop into
|
||||
* the elimination bracket.
|
||||
*
|
||||
* The consolation feeder is deliberately excluded: its losers do finish at that tier
|
||||
* (the consolation game splits their two positions), so it still consumes them.
|
||||
*
|
||||
* Exported for unit testing.
|
||||
*/
|
||||
export function roundsWithLosersPlacedLater(
|
||||
template: BracketTemplate | undefined,
|
||||
consolation: ConsolationRound | undefined
|
||||
): Set<string> {
|
||||
return new Set(
|
||||
(template?.rounds ?? [])
|
||||
.filter((r) => r.loserFeedsInto && r.name !== consolation?.feederRound)
|
||||
.map((r) => r.name)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the ordered final-rankings list from completed matches.
|
||||
*
|
||||
* Ranks are derived by walking rounds latest-first: the final's loser is 2nd, the
|
||||
* previous round's losers share the next tier, and so on — each round consuming as
|
||||
* many positions as it has matches.
|
||||
*
|
||||
* A consolation round needs different handling, because its winner never loses a
|
||||
* match and so the loser-driven walk above would leave them unranked and "in
|
||||
* contention" forever. Its two places are exactly the top of the tier its feeder
|
||||
* round's losers would otherwise share, so it is resolved *at the feeder round* —
|
||||
* the winner takes that tier's first position and the loser the second — and the
|
||||
* consolation round itself consumes no positions. Positions are derived rather than
|
||||
* hardcoded, so a consolation round hanging off a different feeder still lands right.
|
||||
*
|
||||
* Exported for unit testing.
|
||||
*/
|
||||
export function computeRankedEntries(
|
||||
matches: Match[],
|
||||
rounds: string[],
|
||||
matchesByRound: Map<string, Match[]>,
|
||||
consolation: ConsolationRound | undefined,
|
||||
ownershipMap: Map<string, TeamOwnership>,
|
||||
/** See roundsWithLosersPlacedLater. Empty for single-elimination brackets. */
|
||||
losersPlacedLater: Set<string> = new Set()
|
||||
): EliminatedEntry[] {
|
||||
const eliminatedByRound = computeEliminatedByRound(matches, rounds);
|
||||
|
||||
// Only take the consolation path when both rounds actually have matches; otherwise
|
||||
// fall through to the loser-driven walk so nothing is dropped.
|
||||
const consolationActive =
|
||||
consolation &&
|
||||
rounds.includes(consolation.round) &&
|
||||
rounds.includes(consolation.feederRound);
|
||||
|
||||
// Consolation matches we can place exactly. Anything else in that round (still in
|
||||
// progress, or missing its hydrated winner/loser) deliberately stays eligible for
|
||||
// the loser-driven walk rather than being silently dropped.
|
||||
const consolationMatches =
|
||||
consolationActive && consolation
|
||||
? (matchesByRound.get(consolation.round) ?? []).filter(
|
||||
(m): m is Match & { winner: Participant; loser: Participant } =>
|
||||
m.isComplete && !!m.winner && !!m.loser
|
||||
)
|
||||
: [];
|
||||
const exactlyPlacedMatchIds = new Set(consolationMatches.map((m) => m.id));
|
||||
|
||||
const entryFor = (
|
||||
match: Match,
|
||||
participant: Participant,
|
||||
participantId: string | null
|
||||
): Omit<EliminatedEntry, "rankLabel"> => ({
|
||||
participant,
|
||||
score: participantScore(match, participantId),
|
||||
ownership: ownershipMap.get(participant.id) || null,
|
||||
});
|
||||
|
||||
const losersByRound = new Map<string, Omit<EliminatedEntry, "rankLabel">[]>();
|
||||
for (const match of matches) {
|
||||
if (!match.isComplete || !match.loser) continue;
|
||||
if (exactlyPlacedMatchIds.has(match.id)) continue;
|
||||
if (!eliminatedByRound.get(match.round)?.includes(match.loser.id)) continue;
|
||||
if (!losersByRound.has(match.round)) losersByRound.set(match.round, []);
|
||||
losersByRound.get(match.round)?.push(entryFor(match, match.loser, match.loserId));
|
||||
}
|
||||
|
||||
const rankedEntries: EliminatedEntry[] = [];
|
||||
let nextRank = 2;
|
||||
for (let ri = rounds.length - 1; ri >= 0; ri--) {
|
||||
const roundName = rounds[ri];
|
||||
|
||||
// The consolation match splits the top of its feeder round's tier, so it is
|
||||
// placed first and the round's remaining losers share what's left below it.
|
||||
let tierRank = nextRank;
|
||||
if (consolationActive && roundName === consolation?.feederRound) {
|
||||
for (const match of consolationMatches) {
|
||||
rankedEntries.push({
|
||||
...entryFor(match, match.winner, match.winnerId),
|
||||
rankLabel: `${tierRank}`,
|
||||
});
|
||||
rankedEntries.push({
|
||||
...entryFor(match, match.loser, match.loserId),
|
||||
rankLabel: `${tierRank + 1}`,
|
||||
});
|
||||
tierRank += 2;
|
||||
}
|
||||
}
|
||||
|
||||
for (const loser of losersByRound.get(roundName) ?? []) {
|
||||
rankedEntries.push({ ...loser, rankLabel: `T${tierRank}` });
|
||||
}
|
||||
|
||||
// The consolation round's places belong to its feeder round's tier, so it
|
||||
// consumes none of its own.
|
||||
if (consolationActive && roundName === consolation?.round) continue;
|
||||
|
||||
// A round normally consumes one position per match — its losers finish here,
|
||||
// whether or not the games have been played yet (four semifinalists occupy 1–4
|
||||
// regardless). But in a double-elimination bracket a winners-bracket loss places
|
||||
// nobody: the loser drops into the elimination bracket and is ranked by whatever
|
||||
// knocks them out later. Those rounds must consume nothing, or every position
|
||||
// below inflates (a 20-team llws_20 bracket would end at "T23").
|
||||
if (losersPlacedLater.has(roundName)) continue;
|
||||
|
||||
nextRank += matchesByRound.get(roundName)?.length ?? 0;
|
||||
}
|
||||
|
||||
return rankedEntries;
|
||||
}
|
||||
|
||||
/** Find the index of the first round that has scoring matches. */
|
||||
function firstScoringRoundIdx(matchesByRound: Map<string, Match[]>, rounds: string[]): number {
|
||||
for (let i = 0; i < rounds.length; i++) {
|
||||
|
|
@ -355,13 +215,13 @@ export function PlayoffBracket({
|
|||
const matchesByRound = groupMatchesByRound(matches);
|
||||
const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds);
|
||||
const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined;
|
||||
// What fills each slot, used for both card placement and naming empty slots.
|
||||
const feeders = buildFeederMap(template);
|
||||
|
||||
const consolation = findConsolationRound(template);
|
||||
const thirdPlaceRound = consolation?.round;
|
||||
const thirdPlaceRound = template?.rounds
|
||||
.find((r) => template.rounds.some((other) => other.loserFeedsInto === r.name))
|
||||
?.name;
|
||||
|
||||
// Build elimination rankings
|
||||
const losersByRound = new Map<string, Array<{ participant: Participant; score: string | null; ownership: TeamOwnership | null }>>();
|
||||
let bracketWinner: Participant | null = null;
|
||||
|
||||
const lastRound = rounds[rounds.length - 1];
|
||||
|
|
@ -370,20 +230,43 @@ export function PlayoffBracket({
|
|||
: null;
|
||||
if (finalMatch?.winner) bracketWinner = finalMatch.winner;
|
||||
|
||||
const eliminatedByRound = computeEliminatedByRound(matches, rounds);
|
||||
|
||||
for (const match of matches) {
|
||||
if (!match.isComplete || !match.loser) continue;
|
||||
const eliminatedInRound = eliminatedByRound.get(match.round);
|
||||
if (!eliminatedInRound?.includes(match.loser.id)) continue;
|
||||
const loserScore =
|
||||
match.loserId === match.participant1Id
|
||||
? match.participant1Score
|
||||
: match.participant2Score;
|
||||
if (!losersByRound.has(match.round)) losersByRound.set(match.round, []);
|
||||
losersByRound.get(match.round)?.push({
|
||||
participant: match.loser,
|
||||
score: loserScore,
|
||||
ownership: ownershipMap.get(match.loser.id) || null,
|
||||
});
|
||||
}
|
||||
|
||||
const allBracketParticipantIds = new Set<string>();
|
||||
for (const match of matches) {
|
||||
if (match.participant1Id) allBracketParticipantIds.add(match.participant1Id);
|
||||
if (match.participant2Id) allBracketParticipantIds.add(match.participant2Id);
|
||||
}
|
||||
|
||||
const rankedEntries = computeRankedEntries(
|
||||
matches,
|
||||
rounds,
|
||||
matchesByRound,
|
||||
consolation,
|
||||
ownershipMap,
|
||||
roundsWithLosersPlacedLater(template, consolation)
|
||||
);
|
||||
const rankedEntries: EliminatedEntry[] = [];
|
||||
let nextRank = 2;
|
||||
for (let ri = rounds.length - 1; ri >= 0; ri--) {
|
||||
const roundName = rounds[ri];
|
||||
const roundLosers = losersByRound.get(roundName) || [];
|
||||
const totalMatchesInRound = matchesByRound.get(roundName)?.length ?? 0;
|
||||
if (roundLosers.length > 0) {
|
||||
const rankLabel = `T${nextRank}`;
|
||||
for (const loser of roundLosers) {
|
||||
rankedEntries.push({ ...loser, rankLabel });
|
||||
}
|
||||
}
|
||||
nextRank += totalMatchesInRound;
|
||||
}
|
||||
|
||||
const rankedParticipantIds = new Set(rankedEntries.map((e) => e.participant.id));
|
||||
if (bracketWinner) rankedParticipantIds.add(bracketWinner.id);
|
||||
|
|
@ -401,14 +284,7 @@ export function PlayoffBracket({
|
|||
const activeParticipants = [...allBracketParticipantIds]
|
||||
.filter((id) => !rankedParticipantIds.has(id))
|
||||
.map((id) => participantMap.get(id))
|
||||
.filter((p): p is Participant => p !== undefined)
|
||||
// Owned-by-a-manager players first, then alphabetical by name.
|
||||
.toSorted((a, b) => {
|
||||
const aOwned = ownershipMap.has(a.id);
|
||||
const bOwned = ownershipMap.has(b.id);
|
||||
if (aOwned !== bOwned) return aOwned ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
.filter((p): p is Participant => p !== undefined);
|
||||
|
||||
const isDraftedOrScoring = (participantId: string) =>
|
||||
ownershipMap.has(participantId) || (pointsMap.get(participantId) ?? 0) > 0;
|
||||
|
|
@ -444,8 +320,6 @@ export function PlayoffBracket({
|
|||
userParticipantIds={userParticipantSet}
|
||||
phases={template.phases}
|
||||
scoringRoundIdx={scoringRoundIdx}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
) : template?.conferenceGroups ? (
|
||||
<NbaBracketLayout
|
||||
|
|
@ -456,8 +330,6 @@ export function PlayoffBracket({
|
|||
userParticipantIds={userParticipantSet}
|
||||
conferenceGroups={template.conferenceGroups}
|
||||
scoringRoundIdx={scoringRoundIdx}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -469,8 +341,6 @@ export function PlayoffBracket({
|
|||
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
|
||||
userParticipantIds={userParticipantSet}
|
||||
thirdPlaceRound={thirdPlaceRound}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -483,8 +353,6 @@ export function PlayoffBracket({
|
|||
userParticipantIds={userParticipantSet}
|
||||
firstScoringRoundIdx={scoringRoundIdx}
|
||||
thirdPlaceRound={thirdPlaceRound}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -55,9 +55,7 @@ interface QualifyingPointsStandingsProps {
|
|||
function formatQP(raw: string): string {
|
||||
const n = parseFloat(raw);
|
||||
if (isNaN(n)) return "—";
|
||||
// At most 2 decimals, trailing zeros trimmed (1.50→1.5). Mirrors the Discord
|
||||
// embed's formatQPValue (app/services/discord.ts) so the site and Discord agree.
|
||||
return parseFloat(n.toFixed(2)).toString();
|
||||
return n % 1 === 0 ? n.toString() : n.toFixed(2);
|
||||
}
|
||||
|
||||
export function QualifyingPointsStandings({
|
||||
|
|
|
|||
|
|
@ -1,18 +1,8 @@
|
|||
import { cn } from "~/lib/utils";
|
||||
import type { BracketPhase, BracketTemplate, ConferenceGroup } from "~/lib/bracket-templates";
|
||||
import type { FeederMap } from "~/lib/bracket-layout";
|
||||
import {
|
||||
TreeColumns,
|
||||
BracketMatchSlot,
|
||||
bracketGeometry,
|
||||
type BracketMatch,
|
||||
type BracketOwnership,
|
||||
} from "./BracketTreeView";
|
||||
import type { BracketPhase, ConferenceGroup } from "~/lib/bracket-templates";
|
||||
import { TreeColumns, BracketMatchSlot, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
|
||||
import { BracketTreePaginated } from "./BracketTreePaginated";
|
||||
|
||||
/** Card height for the play-in columns, which lay themselves out rather than via TreeColumns. */
|
||||
const CARD_H = 112;
|
||||
|
||||
interface TabbedBracketLayoutProps {
|
||||
rounds: string[];
|
||||
matchesByRound: Map<string, BracketMatch[]>;
|
||||
|
|
@ -20,10 +10,11 @@ interface TabbedBracketLayoutProps {
|
|||
userParticipantIds: Set<string>;
|
||||
phases: BracketPhase[];
|
||||
scoringRoundIdx: number;
|
||||
feeders?: FeederMap;
|
||||
template?: BracketTemplate;
|
||||
}
|
||||
|
||||
const CARD_H = 112;
|
||||
const CARD_GAP = 14;
|
||||
|
||||
function groupMatches(
|
||||
matchesByRound: Map<string, BracketMatch[]>,
|
||||
group: ConferenceGroup
|
||||
|
|
@ -38,6 +29,11 @@ function groupMatches(
|
|||
return out;
|
||||
}
|
||||
|
||||
function phaseHeight(matchesByRound: Map<string, BracketMatch[]>, rounds: string[]): number {
|
||||
const max = Math.max(...rounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
|
||||
return max * (CARD_H + CARD_GAP);
|
||||
}
|
||||
|
||||
// ─── Play-In Layout ───────────────────────────────────────────────────────────
|
||||
|
||||
interface PlayInColumnProps {
|
||||
|
|
@ -145,10 +141,7 @@ export function TabbedBracketLayout({
|
|||
userParticipantIds,
|
||||
phases,
|
||||
scoringRoundIdx,
|
||||
feeders,
|
||||
template,
|
||||
}: TabbedBracketLayoutProps) {
|
||||
const roundOrder = template?.rounds.map((r) => r.name) ?? rounds;
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
{phases.map((phase) => {
|
||||
|
|
@ -159,22 +152,7 @@ export function TabbedBracketLayout({
|
|||
const simpleRounds = phase.groups ? [] : (phase.rounds ?? []).filter((r) => rounds.includes(r));
|
||||
|
||||
const phaseRounds = phase.groups ? [...groupRounds, ...sharedRounds] : simpleRounds;
|
||||
// Restrict each round to the match numbers this phase's groups actually claim.
|
||||
// Rounds can be shared across phases (LLWS runs U.S. and International through
|
||||
// the same rounds), so without this the mobile view would merge both sides into
|
||||
// one column. No-op where a phase's groups already cover every match in the
|
||||
// round (NCAA regions, NBA conferences) and for sharedRounds, which have no
|
||||
// group filter.
|
||||
const phaseMatchesByRound = new Map(
|
||||
phaseRounds.map((r) => {
|
||||
const all = matchesByRound.get(r) ?? [];
|
||||
if (!phase.groups || sharedRounds.includes(r)) return [r, all] as const;
|
||||
const allowed = new Set(
|
||||
phase.groups.flatMap((g) => g.roundMatchNumbers[r] ?? [])
|
||||
);
|
||||
return [r, allowed.size > 0 ? all.filter((m) => allowed.has(m.matchNumber)) : all] as const;
|
||||
})
|
||||
);
|
||||
const phaseMatchesByRound = new Map(phaseRounds.map((r) => [r, matchesByRound.get(r) ?? []]));
|
||||
const sharedMatchesByRound = new Map(sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []]));
|
||||
|
||||
const phaseFirstScoringIdx = phaseRounds.findIndex((r) => rounds.indexOf(r) >= scoringRoundIdx);
|
||||
|
|
@ -201,87 +179,50 @@ export function TabbedBracketLayout({
|
|||
{phase.groups.map((group) => {
|
||||
const gMatches = groupMatches(matchesByRound, group);
|
||||
const gRounds = groupRounds.filter((r) => group.roundMatchNumbers[r] !== undefined);
|
||||
const geometry = bracketGeometry(gRounds, gMatches, feeders, roundOrder);
|
||||
return (
|
||||
<div key={group.name}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
|
||||
{group.name}
|
||||
</p>
|
||||
<div className="w-full overflow-x-auto">
|
||||
<div style={{ minWidth: geometry.minWidth }}>
|
||||
<TreeColumns
|
||||
geometry={geometry}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TreeColumns
|
||||
visibleRounds={gRounds}
|
||||
matchesByRound={gMatches}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
bracketHeight={phaseHeight(gMatches, gRounds)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{sharedRounds.length > 0 && (
|
||||
<TreeColumns
|
||||
geometry={bracketGeometry(sharedRounds, sharedMatchesByRound, feeders, roundOrder)}
|
||||
visibleRounds={sharedRounds}
|
||||
matchesByRound={sharedMatchesByRound}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
bracketHeight={phaseHeight(sharedMatchesByRound, sharedRounds)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<TreeColumns
|
||||
geometry={bracketGeometry(phaseRounds, phaseMatchesByRound, feeders, roundOrder)}
|
||||
visibleRounds={phaseRounds}
|
||||
matchesByRound={phaseMatchesByRound}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
bracketHeight={phaseHeight(phaseMatchesByRound, phaseRounds)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile — paged one group at a time, matching the desktop split. Paging a
|
||||
whole phase would merge the winners and elimination brackets into one
|
||||
tree, and a double-elimination phase is a DAG rather than a tree: the
|
||||
same game feeds forward and sideways, so its column placement would be
|
||||
arbitrary. */}
|
||||
<div className="md:hidden space-y-6">
|
||||
{/* Mobile */}
|
||||
<div className="md:hidden">
|
||||
{phase.layout === "play-in" ? (
|
||||
<PlayInLayout
|
||||
matchesByRound={phaseMatchesByRound}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
/>
|
||||
) : phase.groups ? (
|
||||
<>
|
||||
{phase.groups.map((group) => (
|
||||
<div key={group.name}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
|
||||
{group.name}
|
||||
</p>
|
||||
<BracketTreePaginated
|
||||
rounds={groupRounds.filter((r) => group.roundMatchNumbers[r] !== undefined)}
|
||||
matchesByRound={groupMatches(matchesByRound, group)}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{sharedRounds.length > 0 && (
|
||||
<BracketTreePaginated
|
||||
rounds={sharedRounds}
|
||||
matchesByRound={sharedMatchesByRound}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<BracketTreePaginated
|
||||
rounds={phaseRounds}
|
||||
|
|
@ -289,8 +230,6 @@ export function TabbedBracketLayout({
|
|||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
firstScoringRoundIdx={phaseFirstScoringIdx >= 0 ? phaseFirstScoringIdx : undefined}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,116 +0,0 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { render, within } from "@testing-library/react";
|
||||
import { NbaBracketLayout } from "../NbaBracketLayout";
|
||||
import { buildFeederMap } from "~/lib/bracket-layout";
|
||||
import type { BracketTemplate } from "~/lib/bracket-templates";
|
||||
import type { BracketMatch } from "../BracketTreeView";
|
||||
|
||||
/**
|
||||
* NbaBracketLayout renders a desktop view and a mobile pager side by side, hidden from
|
||||
* each other by Tailwind breakpoints. Both need `feeders` and `template` — without them
|
||||
* bracketGeometry falls back to index-derived positions and an unplayed slot reads "TBD"
|
||||
* where the feeder graph would name the game it is waiting on.
|
||||
*/
|
||||
|
||||
const TEMPLATE: BracketTemplate = {
|
||||
id: "test_conf_4",
|
||||
name: "Two-conference test bracket",
|
||||
totalTeams: 4,
|
||||
scoringStartsAtRound: "Final",
|
||||
rounds: [
|
||||
{ name: "Semis", matchCount: 2, feedsInto: "Final", isScoring: false },
|
||||
{ name: "Final", matchCount: 1, feedsInto: null, isScoring: true },
|
||||
],
|
||||
conferenceGroups: [
|
||||
{ name: "East", roundMatchNumbers: { Semis: [1] } },
|
||||
{ name: "West", roundMatchNumbers: { Semis: [2] } },
|
||||
],
|
||||
};
|
||||
|
||||
const ROUNDS = ["Semis", "Final"];
|
||||
|
||||
function match(
|
||||
round: string,
|
||||
matchNumber: number,
|
||||
overrides: Partial<BracketMatch> = {}
|
||||
): BracketMatch {
|
||||
return {
|
||||
id: `${round}-${matchNumber}`,
|
||||
round,
|
||||
matchNumber,
|
||||
participant1Id: null,
|
||||
participant2Id: null,
|
||||
winnerId: null,
|
||||
loserId: null,
|
||||
isComplete: false,
|
||||
participant1Score: null,
|
||||
participant2Score: null,
|
||||
...overrides,
|
||||
} as BracketMatch;
|
||||
}
|
||||
|
||||
/** Semis are played; the Final's two slots are still empty. */
|
||||
const MATCHES_BY_ROUND = new Map<string, BracketMatch[]>([
|
||||
[
|
||||
"Semis",
|
||||
[
|
||||
match("Semis", 1, { participant1Id: "p1", participant2Id: "p2" }),
|
||||
match("Semis", 2, { participant1Id: "p3", participant2Id: "p4" }),
|
||||
],
|
||||
],
|
||||
["Final", [match("Final", 1)]],
|
||||
]);
|
||||
|
||||
function renderLayout(withGraph: boolean) {
|
||||
const { container } = render(
|
||||
<NbaBracketLayout
|
||||
matches={[...MATCHES_BY_ROUND.values()].flat()}
|
||||
rounds={ROUNDS}
|
||||
matchesByRound={MATCHES_BY_ROUND}
|
||||
ownershipMap={new Map()}
|
||||
userParticipantIds={new Set()}
|
||||
conferenceGroups={TEMPLATE.conferenceGroups ?? []}
|
||||
scoringRoundIdx={1}
|
||||
feeders={withGraph ? buildFeederMap(TEMPLATE) : undefined}
|
||||
template={withGraph ? TEMPLATE : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
// Both panes render in jsdom — media queries are class-based, not applied — so scope
|
||||
// each assertion to the pane it is about.
|
||||
const mobile = container.querySelector<HTMLElement>(".md\\:hidden");
|
||||
const desktop = container.querySelector<HTMLElement>(".md\\:flex");
|
||||
if (!mobile || !desktop) throw new Error("Expected both a mobile and a desktop pane");
|
||||
return { mobile, desktop };
|
||||
}
|
||||
|
||||
describe("NbaBracketLayout", () => {
|
||||
it("names the feeding game in the mobile pager", () => {
|
||||
// Only slots filled by advancement get a label; a directly seeded slot with no
|
||||
// participant still reads "TBD", which is why this asserts on the Final's slots.
|
||||
const { mobile } = renderLayout(true);
|
||||
|
||||
expect(within(mobile).getAllByText(/Winner of/).length).toBe(2);
|
||||
});
|
||||
|
||||
it("shows the mobile pager the same slot labels as the desktop view", () => {
|
||||
const { mobile, desktop } = renderLayout(true);
|
||||
|
||||
const labels = (pane: HTMLElement) =>
|
||||
within(pane)
|
||||
.getAllByText(/Winner of/)
|
||||
.map((el) => el.textContent)
|
||||
.toSorted();
|
||||
|
||||
expect(labels(mobile)).toEqual(labels(desktop));
|
||||
});
|
||||
|
||||
it("falls back to TBD when the feeder graph is unavailable", () => {
|
||||
// Guards the assertions above: without feeders/template there is nothing to name a
|
||||
// slot with, which is exactly the state the mobile pane was stuck in.
|
||||
const { mobile } = renderLayout(false);
|
||||
|
||||
expect(within(mobile).queryByText(/Winner of/)).toBeNull();
|
||||
expect(within(mobile).getAllByText("TBD").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,16 +1,5 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import {
|
||||
PlayoffBracket,
|
||||
groupMatchesByRound,
|
||||
computeEliminatedByRound,
|
||||
computeRankedEntries,
|
||||
findConsolationRound,
|
||||
roundsWithLosersPlacedLater,
|
||||
type Match,
|
||||
} from "../PlayoffBracket";
|
||||
import { getBracketTemplate } from "~/lib/bracket-templates";
|
||||
import { resolveLLWSAdvancement } from "~/models/playoff-match";
|
||||
import { buildFeederMap, groupMatchesByRound, computeEliminatedByRound } from "../PlayoffBracket";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
|
|
@ -63,72 +52,88 @@ describe("groupMatchesByRound", () => {
|
|||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendered LLWS bracket — geometry and empty-slot labels
|
||||
// buildFeederMap
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("PlayoffBracket — rendered LLWS bracket", () => {
|
||||
const LLWS_ROUNDS = (getBracketTemplate("llws_20")?.rounds ?? []).map((r) => r.name);
|
||||
|
||||
/** Every LLWS match, all unplayed, so each slot shows what will fill it. */
|
||||
function emptyLlwsMatches(): Match[] {
|
||||
const template = getBracketTemplate("llws_20");
|
||||
const matches: Match[] = [];
|
||||
for (const round of template?.rounds ?? []) {
|
||||
for (let n = 1; n <= round.matchCount; n++) {
|
||||
matches.push({
|
||||
...makeMatch(round.name, n, { participant1Id: null, participant2Id: null }),
|
||||
participant1: null,
|
||||
participant2: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
it("names empty slots after the game that feeds them", () => {
|
||||
render(
|
||||
<PlayoffBracket
|
||||
matches={emptyLlwsMatches()}
|
||||
rounds={LLWS_ROUNDS}
|
||||
bracketTemplateId="llws_20"
|
||||
/>
|
||||
);
|
||||
|
||||
// A winners-bracket loss drops into the elimination bracket — an edge that spans
|
||||
// two separately rendered trees, so the label is the only way to show it.
|
||||
expect(screen.getAllByText("Loser of Winners SF 1").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Winner of Opening 1").length).toBeGreaterThan(0);
|
||||
describe("buildFeederMap", () => {
|
||||
it("returns an empty map when there is only one round", () => {
|
||||
const matches = [makeMatch("Finals", 1)];
|
||||
const map = buildFeederMap(groupMatchesByRound(matches), ["Finals"]);
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
|
||||
it("still shows TBD for a directly seeded slot", () => {
|
||||
render(
|
||||
<PlayoffBracket
|
||||
matches={emptyLlwsMatches()}
|
||||
rounds={LLWS_ROUNDS}
|
||||
bracketTemplateId="llws_20"
|
||||
/>
|
||||
);
|
||||
it("maps SF slots to the correct QF matches for an 8-team bracket", () => {
|
||||
const rounds = ["Quarterfinals", "Semifinals", "Finals"];
|
||||
const matches = [
|
||||
makeMatch("Quarterfinals", 1),
|
||||
makeMatch("Quarterfinals", 2),
|
||||
makeMatch("Quarterfinals", 3),
|
||||
makeMatch("Quarterfinals", 4),
|
||||
makeMatch("Semifinals", 1),
|
||||
makeMatch("Semifinals", 2),
|
||||
makeMatch("Finals", 1),
|
||||
];
|
||||
|
||||
// The opening round is seeded, not fed, so it has nothing to name.
|
||||
expect(screen.getAllByText("TBD").length).toBeGreaterThan(0);
|
||||
const map = buildFeederMap(groupMatchesByRound(matches), rounds);
|
||||
|
||||
// SF Match 1, slot p1 ← QF Match 1
|
||||
expect(map.get("Semifinals:1:p1")).toEqual({ round: "Quarterfinals", matchNumber: 1 });
|
||||
// SF Match 1, slot p2 ← QF Match 2
|
||||
expect(map.get("Semifinals:1:p2")).toEqual({ round: "Quarterfinals", matchNumber: 2 });
|
||||
// SF Match 2, slot p1 ← QF Match 3
|
||||
expect(map.get("Semifinals:2:p1")).toEqual({ round: "Quarterfinals", matchNumber: 3 });
|
||||
// SF Match 2, slot p2 ← QF Match 4
|
||||
expect(map.get("Semifinals:2:p2")).toEqual({ round: "Quarterfinals", matchNumber: 4 });
|
||||
});
|
||||
|
||||
it("gives every card the same height, including a lone final", () => {
|
||||
const { container } = render(
|
||||
<PlayoffBracket
|
||||
matches={emptyLlwsMatches()}
|
||||
rounds={LLWS_ROUNDS}
|
||||
bracketTemplateId="llws_20"
|
||||
/>
|
||||
);
|
||||
it("maps Finals slots to the correct SF matches", () => {
|
||||
const rounds = ["Quarterfinals", "Semifinals", "Finals"];
|
||||
const matches = [
|
||||
makeMatch("Quarterfinals", 1),
|
||||
makeMatch("Quarterfinals", 2),
|
||||
makeMatch("Quarterfinals", 3),
|
||||
makeMatch("Quarterfinals", 4),
|
||||
makeMatch("Semifinals", 1),
|
||||
makeMatch("Semifinals", 2),
|
||||
makeMatch("Finals", 1),
|
||||
];
|
||||
|
||||
const heights = new Set(
|
||||
[...container.querySelectorAll<HTMLElement>("[data-match-id]")].map(
|
||||
(el) => el.style.height
|
||||
)
|
||||
);
|
||||
// Previously a one-match column stretched its card to fill the bracket height.
|
||||
expect(heights.size).toBe(1);
|
||||
const map = buildFeederMap(groupMatchesByRound(matches), rounds);
|
||||
|
||||
expect(map.get("Finals:1:p1")).toEqual({ round: "Semifinals", matchNumber: 1 });
|
||||
expect(map.get("Finals:1:p2")).toEqual({ round: "Semifinals", matchNumber: 2 });
|
||||
});
|
||||
|
||||
it("does not add an entry when the source match does not exist in the previous round", () => {
|
||||
const rounds = ["Quarterfinals", "Finals"];
|
||||
const matches = [
|
||||
makeMatch("Quarterfinals", 1),
|
||||
makeMatch("Quarterfinals", 2),
|
||||
makeMatch("Finals", 1),
|
||||
];
|
||||
|
||||
const map = buildFeederMap(groupMatchesByRound(matches), rounds);
|
||||
|
||||
expect(map.get("Finals:1:p1")).toEqual({ round: "Quarterfinals", matchNumber: 1 });
|
||||
expect(map.get("Finals:1:p2")).toEqual({ round: "Quarterfinals", matchNumber: 2 });
|
||||
expect(map.has("Finals:2:p1")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles a 16-team bracket correctly for Round of 16 → Quarterfinals", () => {
|
||||
const rounds = ["Round of 16", "Quarterfinals", "Semifinals", "Finals"];
|
||||
const matches = [
|
||||
...[1, 2, 3, 4, 5, 6, 7, 8].map((n) => makeMatch("Round of 16", n)),
|
||||
...[1, 2, 3, 4].map((n) => makeMatch("Quarterfinals", n)),
|
||||
...[1, 2].map((n) => makeMatch("Semifinals", n)),
|
||||
makeMatch("Finals", 1),
|
||||
];
|
||||
|
||||
const map = buildFeederMap(groupMatchesByRound(matches), rounds);
|
||||
|
||||
expect(map.get("Quarterfinals:1:p1")).toEqual({ round: "Round of 16", matchNumber: 1 });
|
||||
expect(map.get("Quarterfinals:1:p2")).toEqual({ round: "Round of 16", matchNumber: 2 });
|
||||
expect(map.get("Quarterfinals:4:p1")).toEqual({ round: "Round of 16", matchNumber: 7 });
|
||||
expect(map.get("Quarterfinals:4:p2")).toEqual({ round: "Round of 16", matchNumber: 8 });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -286,526 +291,3 @@ describe("computeEliminatedByRound", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// computeRankedEntries — consolation ("third place") round handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// fifa_48 round order. The Third Place Game sits between the Semifinals (whose
|
||||
// losers feed it) and the Finals.
|
||||
const FIFA_ROUNDS = ["Quarterfinals", "Semifinals", "Third Place Game", "Finals"];
|
||||
|
||||
const FIFA_CONSOLATION = {
|
||||
round: "Third Place Game",
|
||||
feederRound: "Semifinals",
|
||||
};
|
||||
|
||||
type MatchOpts = {
|
||||
/** Which slot the winner occupies. Defaults to 1. */
|
||||
winnerSlot?: 1 | 2;
|
||||
winnerScore?: string;
|
||||
loserScore?: string;
|
||||
/**
|
||||
* Drop the hydrated `winner` relation, keeping `loser` and both ids — the shape a
|
||||
* hand-built match object can arrive in. The loser is still placeable this way.
|
||||
*/
|
||||
missingWinnerRelation?: boolean;
|
||||
};
|
||||
|
||||
/** A completed match. */
|
||||
function makeRankedMatch(
|
||||
round: string,
|
||||
matchNumber: number,
|
||||
winnerId: string,
|
||||
loserId: string,
|
||||
opts: MatchOpts = {}
|
||||
): Match {
|
||||
const winnerIsP1 = (opts.winnerSlot ?? 1) === 1;
|
||||
const p1 = winnerIsP1 ? winnerId : loserId;
|
||||
const p2 = winnerIsP1 ? loserId : winnerId;
|
||||
return {
|
||||
id: `${round}-${matchNumber}`,
|
||||
round,
|
||||
matchNumber,
|
||||
participant1Id: p1,
|
||||
participant2Id: p2,
|
||||
winnerId,
|
||||
loserId,
|
||||
isComplete: true,
|
||||
participant1Score: (winnerIsP1 ? opts.winnerScore : opts.loserScore) ?? null,
|
||||
participant2Score: (winnerIsP1 ? opts.loserScore : opts.winnerScore) ?? null,
|
||||
participant1: { id: p1, name: p1 },
|
||||
participant2: { id: p2, name: p2 },
|
||||
winner: opts.missingWinnerRelation ? null : { id: winnerId, name: winnerId },
|
||||
loser: { id: loserId, name: loserId },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A scheduled-but-unplayed match. Bracket rows are pre-generated with their slots
|
||||
* filled as earlier rounds resolve, so an unplayed 3PG still lists both SF losers.
|
||||
*/
|
||||
function makePendingMatch(
|
||||
round: string,
|
||||
matchNumber: number,
|
||||
participant1Id: string | null,
|
||||
participant2Id: string | null
|
||||
): Match {
|
||||
return {
|
||||
id: `${round}-${matchNumber}`,
|
||||
round,
|
||||
matchNumber,
|
||||
participant1Id,
|
||||
participant2Id,
|
||||
winnerId: null,
|
||||
loserId: null,
|
||||
isComplete: false,
|
||||
participant1Score: null,
|
||||
participant2Score: null,
|
||||
participant1: participant1Id ? { id: participant1Id, name: participant1Id } : null,
|
||||
participant2: participant2Id ? { id: participant2Id, name: participant2Id } : null,
|
||||
winner: null,
|
||||
loser: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** A full fifa_48-shaped knockout tail: 4 QF, 2 SF, the 3PG, and the Final. */
|
||||
function fifaMatches(
|
||||
overrides: { played?: boolean; thirdPlace?: Match } = {}
|
||||
): Match[] {
|
||||
const played = overrides.played ?? true;
|
||||
return [
|
||||
makeRankedMatch("Quarterfinals", 1, "sfA", "qf1"),
|
||||
makeRankedMatch("Quarterfinals", 2, "sfB", "qf2"),
|
||||
makeRankedMatch("Quarterfinals", 3, "sfC", "qf3"),
|
||||
makeRankedMatch("Quarterfinals", 4, "sfD", "qf4"),
|
||||
makeRankedMatch("Semifinals", 1, "sfA", "sfB"),
|
||||
makeRankedMatch("Semifinals", 2, "sfC", "sfD"),
|
||||
overrides.thirdPlace ??
|
||||
(played
|
||||
? makeRankedMatch("Third Place Game", 1, "sfB", "sfD")
|
||||
: makePendingMatch("Third Place Game", 1, "sfB", "sfD")),
|
||||
played
|
||||
? makeRankedMatch("Finals", 1, "sfA", "sfC")
|
||||
: makePendingMatch("Finals", 1, "sfA", "sfC"),
|
||||
];
|
||||
}
|
||||
|
||||
/** Rank the fifa fixture, defaulting to the fifa_48 consolation config. */
|
||||
function rankFifa(
|
||||
matches: Match[] = fifaMatches(),
|
||||
ownership: Map<string, { participantId: string; teamName: string; teamId: string }> = new Map(),
|
||||
consolation: typeof FIFA_CONSOLATION | undefined = FIFA_CONSOLATION
|
||||
) {
|
||||
return computeRankedEntries(
|
||||
matches,
|
||||
FIFA_ROUNDS,
|
||||
groupMatchesByRound(matches),
|
||||
consolation,
|
||||
ownership
|
||||
);
|
||||
}
|
||||
|
||||
function rankOf(entries: ReturnType<typeof computeRankedEntries>, id: string) {
|
||||
return entries.find((e) => e.participant.id === id)?.rankLabel;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// llws_20 — double elimination, where a winners-bracket loss places nobody
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Stable participant id for an llws_20 bracket slot. */
|
||||
function llwsTeam(i: number): string {
|
||||
return `t${String(i).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a full 20-team LLWS tournament, always advancing the lower-numbered
|
||||
* participant id so the outcome is deterministic, and return every match.
|
||||
* Routing comes from the real advancement map rather than being hand-listed.
|
||||
*/
|
||||
function llwsMatches(): Match[] {
|
||||
const template = getBracketTemplate("llws_20");
|
||||
if (!template) throw new Error("llws_20 template missing");
|
||||
|
||||
// round → matchNumber → [p1, p2]
|
||||
const slots = new Map<string, Map<number, [string | null, string | null]>>();
|
||||
for (const round of template.rounds) {
|
||||
const byNumber = new Map<number, [string | null, string | null]>();
|
||||
for (let n = 1; n <= round.matchCount; n++) byNumber.set(n, [null, null]);
|
||||
slots.set(round.name, byNumber);
|
||||
}
|
||||
const put = (round: string, n: number, slot: 0 | 1, id: string) => {
|
||||
const pair = slots.get(round)?.get(n);
|
||||
if (pair) pair[slot] = id;
|
||||
};
|
||||
|
||||
// Seed the Opening Round and the four byes, mirroring generateLLWS20Bracket.
|
||||
for (const [base, roundBase] of [[0, 1], [10, 5]] as const) {
|
||||
for (let local = 0; local < 4; local++) {
|
||||
put("Opening Round", roundBase + local, 0, llwsTeam(base + local * 2));
|
||||
put("Opening Round", roundBase + local, 1, llwsTeam(base + local * 2 + 1));
|
||||
}
|
||||
}
|
||||
put("Winners Round 2", 1, 0, llwsTeam(8));
|
||||
put("Winners Round 2", 2, 0, llwsTeam(9));
|
||||
put("Winners Round 2", 3, 0, llwsTeam(18));
|
||||
put("Winners Round 2", 4, 0, llwsTeam(19));
|
||||
|
||||
const matches: Match[] = [];
|
||||
for (const round of template.rounds) {
|
||||
for (let n = 1; n <= round.matchCount; n++) {
|
||||
const [p1, p2] = slots.get(round.name)?.get(n) ?? [null, null];
|
||||
if (!p1 || !p2) throw new Error(`${round.name} #${n} was not filled`);
|
||||
// Deterministic: the lower id always wins.
|
||||
const winnerId = p1 < p2 ? p1 : p2;
|
||||
const loserId = p1 < p2 ? p2 : p1;
|
||||
matches.push(
|
||||
makeRankedMatch(round.name, n, winnerId, loserId, {
|
||||
winnerSlot: winnerId === p1 ? 1 : 2,
|
||||
})
|
||||
);
|
||||
const { winner, loser } = resolveLLWSAdvancement(round.name, n);
|
||||
if (winner) put(winner.round, winner.matchNumber, winner.slot === "participant1Id" ? 0 : 1, winnerId);
|
||||
if (loser) put(loser.round, loser.matchNumber, loser.slot === "participant1Id" ? 0 : 1, loserId);
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
describe("computeRankedEntries — llws_20 double elimination", () => {
|
||||
const template = getBracketTemplate("llws_20");
|
||||
const rounds = template?.rounds.map((r) => r.name) ?? [];
|
||||
|
||||
function rankLlws() {
|
||||
const matches = llwsMatches();
|
||||
const consolation = findConsolationRound(template);
|
||||
return computeRankedEntries(
|
||||
matches,
|
||||
rounds,
|
||||
groupMatchesByRound(matches),
|
||||
consolation,
|
||||
new Map(),
|
||||
roundsWithLosersPlacedLater(template, consolation)
|
||||
);
|
||||
}
|
||||
|
||||
it("ranks all 19 non-champions exactly once", () => {
|
||||
const entries = rankLlws();
|
||||
expect(entries).toHaveLength(19);
|
||||
expect(new Set(entries.map((e) => e.participant.id)).size).toBe(19);
|
||||
});
|
||||
|
||||
it("gives the top 8 the positions the scoring tiers depend on", () => {
|
||||
const entries = rankLlws();
|
||||
const labels = entries.map((e) => e.rankLabel);
|
||||
// 2nd (World Championship loser), then 3rd and 4th decided by the consolation
|
||||
// game, then the two 5–6 and two 7–8 tier teams.
|
||||
expect(labels[0]).toBe("T2");
|
||||
expect(labels.filter((l) => l === "3")).toHaveLength(1);
|
||||
expect(labels.filter((l) => l === "4")).toHaveLength(1);
|
||||
expect(labels.filter((l) => l === "T5")).toHaveLength(2);
|
||||
expect(labels.filter((l) => l === "T7")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not inflate positions below the top 8", () => {
|
||||
// Winners-bracket losses place nobody — those teams are ranked by the
|
||||
// elimination-bracket game that actually knocks them out. If the winners
|
||||
// rounds consumed positions, the last tier would read T23 in a 20-team field.
|
||||
const entries = rankLlws();
|
||||
const labels = entries.map((e) => e.rankLabel);
|
||||
expect(labels.filter((l) => l === "T9")).toHaveLength(4);
|
||||
expect(labels.filter((l) => l === "T13")).toHaveLength(4);
|
||||
expect(labels.filter((l) => l === "T17")).toHaveLength(4);
|
||||
// 1 champion (not in the list) + 19 ranked = the full 20-team field.
|
||||
expect(labels.some((l) => Number(l.replace("T", "")) > 17)).toBe(false);
|
||||
});
|
||||
|
||||
it("never ranks a winners-bracket loser at the round they first lost", () => {
|
||||
const entries = rankLlws();
|
||||
// t00 wins every game it plays (lowest id), so take a team that loses in the
|
||||
// winners bracket but survives: the Opening Round M1 loser, t01.
|
||||
const t01 = entries.find((e) => e.participant.id === "t01");
|
||||
expect(t01).toBeDefined();
|
||||
// Losing the opening game must not park them in the bottom tier — they got a
|
||||
// second life in the elimination bracket.
|
||||
expect(t01?.rankLabel).not.toBe("T17");
|
||||
});
|
||||
});
|
||||
|
||||
describe("findConsolationRound", () => {
|
||||
it("identifies the fifa_48 third place game and the round that feeds it", () => {
|
||||
expect(findConsolationRound(getBracketTemplate("fifa_48"))).toEqual({
|
||||
round: "Third Place Game",
|
||||
feederRound: "Semifinals",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns undefined for a template with no consolation round", () => {
|
||||
expect(findConsolationRound(getBracketTemplate("ncaa_64"))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when there is no template", () => {
|
||||
expect(findConsolationRound(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores double-elimination loser routing and finds the real consolation game", () => {
|
||||
// llws_20 sets loserFeedsInto on every winners-bracket round to route losers
|
||||
// into the elimination bracket. Only the Bracket Championship feeds a terminal
|
||||
// round; taking the first loserFeedsInto instead would mistake "Elimination
|
||||
// Round 1" for a third-place game and corrupt the final rankings.
|
||||
expect(findConsolationRound(getBracketTemplate("llws_20"))).toEqual({
|
||||
round: "Consolation Third Place",
|
||||
feederRound: "Bracket Championship",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeRankedEntries", () => {
|
||||
describe("fifa_48 third place game", () => {
|
||||
it("ranks the third place game winner 3rd — they never lose a match after the SF", () => {
|
||||
// sfB lost the semifinal, then won the 3PG.
|
||||
expect(rankOf(rankFifa(), "sfB")).toBe("3");
|
||||
});
|
||||
|
||||
it("ranks the third place game loser 4th, not 3rd", () => {
|
||||
expect(rankOf(rankFifa(), "sfD")).toBe("4");
|
||||
});
|
||||
|
||||
it("gives quarterfinal losers T5 — the 3PG consumes no positions of its own", () => {
|
||||
const entries = rankFifa();
|
||||
expect(rankOf(entries, "qf1")).toBe("T5");
|
||||
expect(rankOf(entries, "qf2")).toBe("T5");
|
||||
expect(rankOf(entries, "qf3")).toBe("T5");
|
||||
expect(rankOf(entries, "qf4")).toBe("T5");
|
||||
});
|
||||
|
||||
it("ranks the finals loser 2nd and leaves the champion out of the list", () => {
|
||||
const entries = rankFifa();
|
||||
expect(rankOf(entries, "sfC")).toBe("T2");
|
||||
expect(rankOf(entries, "sfA")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("orders the list by rank: 2nd, 3rd, 4th, then the T5 tier", () => {
|
||||
expect(rankFifa().map((e) => e.rankLabel)).toEqual([
|
||||
"T2",
|
||||
"3",
|
||||
"4",
|
||||
"T5",
|
||||
"T5",
|
||||
"T5",
|
||||
"T5",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves semifinal losers unranked until the third place game is played", () => {
|
||||
const entries = rankFifa(fifaMatches({ played: false }));
|
||||
// Both SF losers are still alive for the 3PG.
|
||||
expect(rankOf(entries, "sfB")).toBeUndefined();
|
||||
expect(rankOf(entries, "sfD")).toBeUndefined();
|
||||
// QF losers are still T5 — the later rounds still consume their positions.
|
||||
expect(rankOf(entries, "qf1")).toBe("T5");
|
||||
});
|
||||
|
||||
it("carries ownership through onto the third place entries", () => {
|
||||
const ownership = new Map([
|
||||
["sfB", { participantId: "sfB", teamName: "Team Nine", teamId: "t9" }],
|
||||
]);
|
||||
const entries = rankFifa(fifaMatches(), ownership);
|
||||
expect(entries.find((e) => e.participant.id === "sfB")?.ownership?.teamName).toBe(
|
||||
"Team Nine"
|
||||
);
|
||||
expect(entries.find((e) => e.participant.id === "sfD")?.ownership).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("scores", () => {
|
||||
it("reads each participant's own score regardless of which slot they occupied", () => {
|
||||
const matches = fifaMatches({
|
||||
// Winner sits in slot 2 this time, so a slot-blind lookup would swap the scores.
|
||||
thirdPlace: makeRankedMatch("Third Place Game", 1, "sfB", "sfD", {
|
||||
winnerSlot: 2,
|
||||
winnerScore: "3",
|
||||
loserScore: "1",
|
||||
}),
|
||||
});
|
||||
const entries = rankFifa(matches);
|
||||
|
||||
expect(entries.find((e) => e.participant.id === "sfB")?.score).toBe("3");
|
||||
expect(entries.find((e) => e.participant.id === "sfD")?.score).toBe("1");
|
||||
});
|
||||
|
||||
it("reads a loser's score from the slot they actually played in", () => {
|
||||
const matches: Match[] = [
|
||||
makeRankedMatch("Semifinals", 1, "sfA", "sfB", {
|
||||
winnerSlot: 2,
|
||||
winnerScore: "4",
|
||||
loserScore: "2",
|
||||
}),
|
||||
];
|
||||
const entries = computeRankedEntries(
|
||||
matches,
|
||||
["Semifinals"],
|
||||
groupMatchesByRound(matches),
|
||||
undefined,
|
||||
new Map()
|
||||
);
|
||||
|
||||
expect(entries.find((e) => e.participant.id === "sfB")?.score).toBe("2");
|
||||
});
|
||||
|
||||
it("reports no score for a participant who occupies neither slot", () => {
|
||||
// A stale row after a bracket edit: loserId no longer matches either slot.
|
||||
// Attributing the other team's score here would look entirely plausible.
|
||||
const stale: Match = {
|
||||
...makeRankedMatch("Semifinals", 1, "sfA", "sfB", {
|
||||
winnerScore: "4",
|
||||
loserScore: "2",
|
||||
}),
|
||||
loserId: "ghost",
|
||||
loser: { id: "ghost", name: "ghost" },
|
||||
};
|
||||
const entries = computeRankedEntries(
|
||||
[stale],
|
||||
["Semifinals"],
|
||||
groupMatchesByRound([stale]),
|
||||
undefined,
|
||||
new Map()
|
||||
);
|
||||
|
||||
expect(entries.find((e) => e.participant.id === "ghost")?.score).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("a consolation round somewhere other than 3rd/4th", () => {
|
||||
// No template ships this today, but the positions must come from the feeder
|
||||
// round rather than being hardcoded to 3 and 4.
|
||||
const ROUNDS = ["Quarterfinals", "Semifinals", "Fifth Place Game", "Finals"];
|
||||
const CONSOLATION = { round: "Fifth Place Game", feederRound: "Quarterfinals" };
|
||||
|
||||
const matches: Match[] = [
|
||||
makeRankedMatch("Quarterfinals", 1, "sfA", "qf1"),
|
||||
makeRankedMatch("Quarterfinals", 2, "sfB", "qf2"),
|
||||
makeRankedMatch("Quarterfinals", 3, "sfC", "qf3"),
|
||||
makeRankedMatch("Quarterfinals", 4, "sfD", "qf4"),
|
||||
makeRankedMatch("Semifinals", 1, "sfA", "sfB"),
|
||||
makeRankedMatch("Semifinals", 2, "sfC", "sfD"),
|
||||
makeRankedMatch("Fifth Place Game", 1, "qf1", "qf2"),
|
||||
makeRankedMatch("Finals", 1, "sfA", "sfC"),
|
||||
];
|
||||
|
||||
it("places the consolation pair at its feeder round's tier, not at 3rd and 4th", () => {
|
||||
const entries = computeRankedEntries(
|
||||
matches,
|
||||
ROUNDS,
|
||||
groupMatchesByRound(matches),
|
||||
CONSOLATION,
|
||||
new Map()
|
||||
);
|
||||
|
||||
expect(rankOf(entries, "qf1")).toBe("5");
|
||||
expect(rankOf(entries, "qf2")).toBe("6");
|
||||
// The feeder round's other losers start below the pair, not alongside them.
|
||||
expect(rankOf(entries, "qf3")).toBe("T7");
|
||||
expect(rankOf(entries, "qf4")).toBe("T7");
|
||||
// The rounds above it are unaffected.
|
||||
expect(rankOf(entries, "sfC")).toBe("T2");
|
||||
expect(rankOf(entries, "sfB")).toBe("T3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("consolation matches that cannot be placed exactly", () => {
|
||||
it("still ranks the loser when the winner relation is missing", () => {
|
||||
const matches = fifaMatches({
|
||||
thirdPlace: makeRankedMatch("Third Place Game", 1, "sfB", "sfD", {
|
||||
missingWinnerRelation: true,
|
||||
}),
|
||||
});
|
||||
const entries = rankFifa(matches);
|
||||
|
||||
// The winner cannot be placed without a participant object, but the loser must
|
||||
// not silently vanish the way it would if the round were skipped wholesale.
|
||||
expect(rankOf(entries, "sfD")).toBeDefined();
|
||||
});
|
||||
|
||||
it("falls back to the loser-driven walk when the feeder round has no matches", () => {
|
||||
const matches: Match[] = [
|
||||
makeRankedMatch("Third Place Game", 1, "sfB", "sfD"),
|
||||
makeRankedMatch("Finals", 1, "sfA", "sfC"),
|
||||
];
|
||||
const entries = computeRankedEntries(
|
||||
matches,
|
||||
["Third Place Game", "Finals"],
|
||||
groupMatchesByRound(matches),
|
||||
FIFA_CONSOLATION,
|
||||
new Map()
|
||||
);
|
||||
|
||||
expect(rankOf(entries, "sfC")).toBe("T2");
|
||||
expect(rankOf(entries, "sfD")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("rendered output", () => {
|
||||
/** The card the 3PG winner was incorrectly appearing in. */
|
||||
function inContentionNames() {
|
||||
const card = screen.queryByText("In Contention")?.closest('[data-slot="card"]');
|
||||
if (!card) return [];
|
||||
return within(card as HTMLElement)
|
||||
.getAllByRole("row")
|
||||
.map((r) => r.textContent ?? "");
|
||||
}
|
||||
|
||||
it("does not list the third place game winner as in contention", () => {
|
||||
render(
|
||||
<PlayoffBracket matches={fifaMatches()} rounds={FIFA_ROUNDS} bracketTemplateId="fifa_48" />
|
||||
);
|
||||
|
||||
// sfB won the third place game — they are finished, not still playing.
|
||||
expect(inContentionNames().some((t) => t.includes("sfB"))).toBe(false);
|
||||
});
|
||||
|
||||
it("still lists semifinalists as in contention before the third place game", () => {
|
||||
render(
|
||||
<PlayoffBracket
|
||||
matches={fifaMatches({ played: false })}
|
||||
rounds={FIFA_ROUNDS}
|
||||
bracketTemplateId="fifa_48"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(inContentionNames().some((t) => t.includes("sfB"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("brackets without a consolation round", () => {
|
||||
const ROUNDS = ["Quarterfinals", "Semifinals", "Finals"];
|
||||
|
||||
it("ranks losers by round with tie labels, unchanged", () => {
|
||||
const matches: Match[] = [
|
||||
makeRankedMatch("Quarterfinals", 1, "sfA", "qf1"),
|
||||
makeRankedMatch("Quarterfinals", 2, "sfB", "qf2"),
|
||||
makeRankedMatch("Quarterfinals", 3, "sfC", "qf3"),
|
||||
makeRankedMatch("Quarterfinals", 4, "sfD", "qf4"),
|
||||
makeRankedMatch("Semifinals", 1, "sfA", "sfB"),
|
||||
makeRankedMatch("Semifinals", 2, "sfC", "sfD"),
|
||||
makeRankedMatch("Finals", 1, "sfA", "sfC"),
|
||||
];
|
||||
|
||||
const entries = computeRankedEntries(
|
||||
matches,
|
||||
ROUNDS,
|
||||
groupMatchesByRound(matches),
|
||||
undefined,
|
||||
new Map()
|
||||
);
|
||||
|
||||
expect(rankOf(entries, "sfC")).toBe("T2");
|
||||
expect(rankOf(entries, "sfB")).toBe("T3");
|
||||
expect(rankOf(entries, "sfD")).toBe("T3");
|
||||
expect(rankOf(entries, "qf1")).toBe("T5");
|
||||
expect(rankOf(entries, "sfA")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export function AccountSection({ email, linkedAccounts }: Props) {
|
|||
setLinkingDiscord(true);
|
||||
setLinkError(null);
|
||||
try {
|
||||
await authClient.linkSocial({ provider: "discord", callbackURL: "/settings/account" });
|
||||
await authClient.linkSocial({ provider: "discord", callbackURL: "/settings?section=account" });
|
||||
} catch {
|
||||
setLinkError("Failed to connect Discord. Please try again.");
|
||||
setLinkingDiscord(false);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Link, useFetcher } from "react-router";
|
||||
import { useFetcher } from "react-router";
|
||||
import { Switch } from "~/components/ui/switch";
|
||||
import { Label } from "~/components/ui/label";
|
||||
|
||||
|
|
@ -14,9 +14,10 @@ type Props = {
|
|||
discordPingEnabled: boolean;
|
||||
hasDiscordLinked: boolean;
|
||||
draftEmailNotificationsEnabled: boolean;
|
||||
onNavigateToAccount: () => void;
|
||||
};
|
||||
|
||||
export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, draftEmailNotificationsEnabled }: Props) {
|
||||
export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, draftEmailNotificationsEnabled, onNavigateToAccount }: Props) {
|
||||
const discordFetcher = useFetcher();
|
||||
const emailFetcher = useFetcher();
|
||||
|
||||
|
|
@ -86,12 +87,13 @@ export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, dra
|
|||
{!hasDiscordLinked ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Link your Discord account in{" "}
|
||||
<Link
|
||||
to="/settings/account"
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNavigateToAccount}
|
||||
className="text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
Account settings
|
||||
</Link>{" "}
|
||||
</button>{" "}
|
||||
to enable Discord pings for league notifications.
|
||||
</p>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ describe("AccountSection", () => {
|
|||
await waitFor(() => {
|
||||
expect(mockLinkSocial).toHaveBeenCalledWith({
|
||||
provider: "discord",
|
||||
callbackURL: "/settings/account",
|
||||
callbackURL: "/settings?section=account",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,26 +1,18 @@
|
|||
import * as Sentry from "@sentry/react-router";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { logger } from "~/lib/logger";
|
||||
import { shouldReportServerError } from "~/lib/error-reporting";
|
||||
|
||||
import type { AppLoadContext, EntryContext, HandleErrorFunction } from "react-router";
|
||||
import type { AppLoadContext, EntryContext } from "react-router";
|
||||
import { createReadableStreamFromReadable } from "@react-router/node";
|
||||
import { ServerRouter } from "react-router";
|
||||
import { isbot } from "isbot";
|
||||
import type { RenderToPipeableStreamOptions } from "react-dom/server";
|
||||
import { renderToPipeableStream } from "react-dom/server";
|
||||
|
||||
const sentryHandleError = Sentry.createSentryHandleError({
|
||||
export const handleError = Sentry.createSentryHandleError({
|
||||
logErrors: true,
|
||||
});
|
||||
|
||||
export const handleError: HandleErrorFunction = (error, args) => {
|
||||
// Unrecognised URLs and methods are bot scans, not bugs. Skipping early also
|
||||
// keeps them out of the `logErrors` console output; morgan still logs the request.
|
||||
if (!shouldReportServerError(error, args.request)) return;
|
||||
return sentryHandleError(error, args);
|
||||
};
|
||||
|
||||
export const streamTimeout = 5_000;
|
||||
|
||||
async function handleRequest(
|
||||
|
|
|
|||
|
|
@ -1,117 +0,0 @@
|
|||
/**
|
||||
* The AFL Wildcard winners are re-seeded into the Elimination Finals by ladder position
|
||||
* (5th draws the lower-ranked winner, 6th the higher-ranked one) rather than crossing
|
||||
* over from a fixed Wildcard match. These tests pin that mapping for every combination
|
||||
* of results, and for either order of entry.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
resolveAflWildcardPlacements,
|
||||
AFL_WILDCARD_DRAW,
|
||||
AFL_ELIMINATION_HOSTS,
|
||||
type AflWildcardResult,
|
||||
} from "../afl-wildcard-reseed";
|
||||
|
||||
/** Both Wildcard games decided, addressed by the seed that won each. */
|
||||
function bothDecided(match1Winner: 7 | 10, match2Winner: 8 | 9): AflWildcardResult[] {
|
||||
return [
|
||||
{ matchNumber: 1, winnerSlot: match1Winner === 7 ? 1 : 2 },
|
||||
{ matchNumber: 2, winnerSlot: match2Winner === 8 ? 1 : 2 },
|
||||
];
|
||||
}
|
||||
|
||||
/** Elimination Finals match number each winning seed was sent to. */
|
||||
function slotsBySeed(results: AflWildcardResult[]): Record<number, number> {
|
||||
return Object.fromEntries(
|
||||
resolveAflWildcardPlacements(results).map((p) => [p.seed, p.eliminationMatchNumber])
|
||||
);
|
||||
}
|
||||
|
||||
describe("AFL Wildcard draw constants", () => {
|
||||
it("draws 7v10 and 8v9", () => {
|
||||
expect(AFL_WILDCARD_DRAW[1]).toEqual([7, 10]);
|
||||
expect(AFL_WILDCARD_DRAW[2]).toEqual([8, 9]);
|
||||
});
|
||||
|
||||
it("hosts the Elimination Finals with seeds 5 and 6", () => {
|
||||
expect(AFL_ELIMINATION_HOSTS[1]).toBe(5);
|
||||
expect(AFL_ELIMINATION_HOSTS[2]).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAflWildcardPlacements", () => {
|
||||
it("sends the higher-ranked winner to 6th and the lower to 5th (7 and 8 win)", () => {
|
||||
expect(slotsBySeed(bothDecided(7, 8))).toEqual({ 7: 2, 8: 1 });
|
||||
});
|
||||
|
||||
it("re-seeds when the lower seed wins the 7v10 game (10 and 8 win)", () => {
|
||||
// The bug this replaces sent the 7v10 winner to 6th regardless, pairing 5th with
|
||||
// 8th and handing 6th the weakest survivor.
|
||||
expect(slotsBySeed(bothDecided(10, 8))).toEqual({ 8: 2, 10: 1 });
|
||||
});
|
||||
|
||||
it("re-seeds when the lower seed wins the 8v9 game (7 and 9 win)", () => {
|
||||
expect(slotsBySeed(bothDecided(7, 9))).toEqual({ 7: 2, 9: 1 });
|
||||
});
|
||||
|
||||
it("re-seeds when both lower seeds win (10 and 9 win)", () => {
|
||||
expect(slotsBySeed(bothDecided(10, 9))).toEqual({ 9: 2, 10: 1 });
|
||||
});
|
||||
|
||||
it("places the 7v10 winner alone, since its rank is settled either way", () => {
|
||||
// 7th outranks both possible 8v9 winners; 10th is outranked by both.
|
||||
expect(slotsBySeed([
|
||||
{ matchNumber: 1, winnerSlot: 1 },
|
||||
{ matchNumber: 2, winnerSlot: null },
|
||||
])).toEqual({ 7: 2 });
|
||||
|
||||
expect(slotsBySeed([
|
||||
{ matchNumber: 1, winnerSlot: 2 },
|
||||
{ matchNumber: 2, winnerSlot: null },
|
||||
])).toEqual({ 10: 1 });
|
||||
});
|
||||
|
||||
it("holds an 8v9 winner back until the 7v10 game is decided", () => {
|
||||
// 8th and 9th both sit between 7th and 10th, so either slot is still possible.
|
||||
expect(slotsBySeed([
|
||||
{ matchNumber: 1, winnerSlot: null },
|
||||
{ matchNumber: 2, winnerSlot: 1 },
|
||||
])).toEqual({});
|
||||
|
||||
expect(slotsBySeed([
|
||||
{ matchNumber: 1, winnerSlot: null },
|
||||
{ matchNumber: 2, winnerSlot: 2 },
|
||||
])).toEqual({});
|
||||
});
|
||||
|
||||
it("places nothing while both games are undecided", () => {
|
||||
expect(resolveAflWildcardPlacements([
|
||||
{ matchNumber: 1, winnerSlot: null },
|
||||
{ matchNumber: 2, winnerSlot: null },
|
||||
])).toEqual([]);
|
||||
});
|
||||
|
||||
it("gives the same answer whichever result is entered first", () => {
|
||||
for (const m1 of [7, 10] as const) {
|
||||
for (const m2 of [8, 9] as const) {
|
||||
const final = slotsBySeed(bothDecided(m1, m2));
|
||||
|
||||
// Whatever a single result places must survive the second result unchanged.
|
||||
const m1First = slotsBySeed([
|
||||
{ matchNumber: 1, winnerSlot: m1 === 7 ? 1 : 2 },
|
||||
{ matchNumber: 2, winnerSlot: null },
|
||||
]);
|
||||
for (const [seed, slot] of Object.entries(m1First)) {
|
||||
expect(final[Number(seed)]).toBe(slot);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a match number outside the Wildcard draw", () => {
|
||||
expect(() => resolveAflWildcardPlacements([{ matchNumber: 3, winnerSlot: 1 }])).toThrow(
|
||||
/Unknown AFL Wildcard Round match number 3/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,533 +0,0 @@
|
|||
/**
|
||||
* Bracket layout tests.
|
||||
*
|
||||
* The load-bearing assertions check the LLWS geometry against the official 2026 LLBWS
|
||||
* bracket, in the PDF's own game numbers. A bracket "lines up" when each card sits level
|
||||
* with the game that feeds it, so these tests assert column membership, top-to-bottom
|
||||
* order, and vertical alignment — not just that a layout was produced.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
LLWS_20,
|
||||
SIMPLE_16,
|
||||
NFL_14,
|
||||
BRACKET_TEMPLATES,
|
||||
getBracketTemplate,
|
||||
type BracketTemplate,
|
||||
type ConferenceGroup,
|
||||
} from "~/lib/bracket-templates";
|
||||
import {
|
||||
buildFeederMap,
|
||||
computeGroupLayout,
|
||||
describeSlotSource,
|
||||
matchKey,
|
||||
type SlotSource,
|
||||
} from "~/lib/bracket-layout";
|
||||
import { GAME_TO_MATCH, MATCH_TO_GAME } from "~/test/fixtures/llws-bracket";
|
||||
|
||||
interface TestMatch {
|
||||
round: string;
|
||||
matchNumber: number;
|
||||
/** Only the fallback reads these, to trace edges through an unrecognised shape. */
|
||||
winnerId?: string | null;
|
||||
participant1Id?: string | null;
|
||||
participant2Id?: string | null;
|
||||
}
|
||||
|
||||
/** Every match a template defines, as the renderer would receive them. */
|
||||
function allMatches(template: BracketTemplate): Map<string, TestMatch[]> {
|
||||
const byRound = new Map<string, TestMatch[]>();
|
||||
for (const round of template.rounds) {
|
||||
byRound.set(
|
||||
round.name,
|
||||
Array.from({ length: round.matchCount }, (_, i) => ({
|
||||
round: round.name,
|
||||
matchNumber: i + 1,
|
||||
}))
|
||||
);
|
||||
}
|
||||
return byRound;
|
||||
}
|
||||
|
||||
/** The matches of one phase group, filtered the way TabbedBracketLayout filters them. */
|
||||
function groupMatches(group: ConferenceGroup): Map<string, TestMatch[]> {
|
||||
const byRound = new Map<string, TestMatch[]>();
|
||||
for (const [round, nums] of Object.entries(group.roundMatchNumbers)) {
|
||||
byRound.set(
|
||||
round,
|
||||
nums.map((matchNumber) => ({ round, matchNumber }))
|
||||
);
|
||||
}
|
||||
return byRound;
|
||||
}
|
||||
|
||||
function findGroup(name: string): ConferenceGroup {
|
||||
for (const phase of LLWS_20.phases ?? []) {
|
||||
for (const group of phase.groups ?? []) {
|
||||
if (group.name === name) return group;
|
||||
}
|
||||
}
|
||||
throw new Error(`No LLWS group named ${name}`);
|
||||
}
|
||||
|
||||
/** Lay out one LLWS group and describe it in PDF game numbers. */
|
||||
function layOutLLWSGroup(name: string) {
|
||||
const group = findGroup(name);
|
||||
const byRound = groupMatches(group);
|
||||
const roundOrder = LLWS_20.rounds.map((r) => r.name);
|
||||
const rounds = roundOrder.filter((r) => byRound.has(r));
|
||||
|
||||
const layout = computeGroupLayout(rounds, byRound, buildFeederMap(LLWS_20), roundOrder);
|
||||
|
||||
const game = (m: TestMatch) => {
|
||||
const n = MATCH_TO_GAME.get(`${m.round}#${m.matchNumber}`);
|
||||
if (n === undefined) throw new Error(`No PDF game for ${m.round} #${m.matchNumber}`);
|
||||
return n;
|
||||
};
|
||||
|
||||
return {
|
||||
layout,
|
||||
labels: layout.columns.map((c) => c.label),
|
||||
/** Column contents, top to bottom, as PDF game numbers. */
|
||||
columns: layout.columns.map((c) => c.matches.map((m) => game(m.match))),
|
||||
/** Vertical centre of a game's card, in leaf-row units. */
|
||||
centerOf(gameNumber: number): number {
|
||||
const target = GAME_TO_MATCH[gameNumber];
|
||||
for (const column of layout.columns) {
|
||||
for (const { match, center } of column.matches) {
|
||||
if (match.round === target.round && match.matchNumber === target.matchNumber) {
|
||||
return center;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(`G${gameNumber} is not in this group`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("computeGroupLayout — LLWS winners brackets", () => {
|
||||
// The International side is the one in the reported screenshot. Under the old index
|
||||
// math, G5 and G7 were stranded in the first column: they skip Winners Round 2 and go
|
||||
// straight to the semifinals, so nothing in column two lined up with them.
|
||||
it("puts the International winners bracket in the printed bracket's columns", () => {
|
||||
const { columns } = layOutLLWSGroup("International Winner's Bracket");
|
||||
expect(columns).toEqual([
|
||||
[1, 3],
|
||||
[5, 9, 11, 7],
|
||||
[18, 20],
|
||||
[29],
|
||||
]);
|
||||
});
|
||||
|
||||
it("mirrors that layout on the U.S. side", () => {
|
||||
const { columns } = layOutLLWSGroup("U.S. Winner's Bracket");
|
||||
expect(columns).toEqual([
|
||||
[2, 4],
|
||||
[6, 10, 12, 8],
|
||||
[17, 19],
|
||||
[30],
|
||||
]);
|
||||
});
|
||||
|
||||
it("names a mixed column for the latest round it holds", () => {
|
||||
// Column two holds two Opening Round games (G5, G7) alongside Winners Round 2.
|
||||
const { labels } = layOutLLWSGroup("International Winner's Bracket");
|
||||
expect(labels).toEqual([
|
||||
"Opening Round",
|
||||
"Winners Round 2",
|
||||
"Winners Semifinals",
|
||||
"Winners Final",
|
||||
]);
|
||||
});
|
||||
|
||||
it("levels each card with the game that feeds it", () => {
|
||||
const { centerOf } = layOutLLWSGroup("International Winner's Bracket");
|
||||
|
||||
// G1's winner fills a slot of G9, so the two sit at the same height.
|
||||
expect(centerOf(1)).toBe(centerOf(9));
|
||||
expect(centerOf(3)).toBe(centerOf(11));
|
||||
|
||||
// G18 = W5 v W9, so it sits midway between them.
|
||||
expect(centerOf(18)).toBe((centerOf(5) + centerOf(9)) / 2);
|
||||
expect(centerOf(20)).toBe((centerOf(11) + centerOf(7)) / 2);
|
||||
expect(centerOf(29)).toBe((centerOf(18) + centerOf(20)) / 2);
|
||||
});
|
||||
|
||||
it("draws an edge for every in-group feed, played or not", () => {
|
||||
const { layout } = layOutLLWSGroup("International Winner's Bracket");
|
||||
// G9←G1, G11←G3, G18←{G5,G9}, G20←{G11,G7}, G29←{G18,G20}: 8 in-group edges.
|
||||
expect(layout.edges).toHaveLength(8);
|
||||
// Every edge crosses exactly one gutter, which is what makes them drawable.
|
||||
for (const edge of layout.edges) {
|
||||
expect(edge.fromColumn).toBeGreaterThanOrEqual(0);
|
||||
expect(edge.fromColumn).toBeLessThan(layout.columns.length - 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeGroupLayout — LLWS elimination brackets", () => {
|
||||
it("orders Elimination Round 3 the way the printed bracket does", () => {
|
||||
// G31 = W27 v W25, so the later game is printed on top — the reverse of match
|
||||
// number order, which is how the old index-based sort got it wrong.
|
||||
const { columns } = layOutLLWSGroup("International Elimination Bracket");
|
||||
expect(columns).toEqual([
|
||||
[13, 15],
|
||||
[21, 23],
|
||||
[27, 25],
|
||||
[31],
|
||||
[33],
|
||||
]);
|
||||
});
|
||||
|
||||
it("orders the U.S. elimination bracket the same way", () => {
|
||||
const { columns } = layOutLLWSGroup("U.S. Elimination Bracket");
|
||||
expect(columns).toEqual([
|
||||
[14, 16],
|
||||
[22, 24],
|
||||
[28, 26],
|
||||
[32],
|
||||
[34],
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores feeds arriving from the winners bracket", () => {
|
||||
// G21 = L9 v W13. L9 is in the winners bracket group, so only W13 is an edge here.
|
||||
const { layout, centerOf } = layOutLLWSGroup("International Elimination Bracket");
|
||||
expect(centerOf(21)).toBe(centerOf(13));
|
||||
expect(layout.edges).toHaveLength(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFeederMap", () => {
|
||||
it("routes LLWS winners and losers to the slots the printed bracket shows", () => {
|
||||
const feeders = buildFeederMap(LLWS_20);
|
||||
|
||||
// G18 = W5 v W9.
|
||||
const g18 = GAME_TO_MATCH[18];
|
||||
expect(feeders.get(matchKey(g18.round, g18.matchNumber))).toEqual([
|
||||
{ kind: "match", ref: GAME_TO_MATCH[5], result: "winner" },
|
||||
{ kind: "match", ref: GAME_TO_MATCH[9], result: "winner" },
|
||||
]);
|
||||
|
||||
// G13 = L3 v L5 — a winners-bracket loss drops into the elimination bracket.
|
||||
const g13 = GAME_TO_MATCH[13];
|
||||
expect(feeders.get(matchKey(g13.round, g13.matchNumber))).toEqual([
|
||||
{ kind: "match", ref: GAME_TO_MATCH[3], result: "loser" },
|
||||
{ kind: "match", ref: GAME_TO_MATCH[5], result: "loser" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks directly seeded slots as seeds", () => {
|
||||
const feeders = buildFeederMap(LLWS_20);
|
||||
// G9 = a bye team v W1: slot one is seeded, slot two is fed.
|
||||
const g9 = GAME_TO_MATCH[9];
|
||||
const [p1, p2] = feeders.get(matchKey(g9.round, g9.matchNumber)) ?? [];
|
||||
expect(p1).toEqual({ kind: "seed" });
|
||||
expect(p2).toEqual({ kind: "match", ref: GAME_TO_MATCH[1], result: "winner" });
|
||||
});
|
||||
|
||||
it("applies the standard halving rule to other templates", () => {
|
||||
const feeders = buildFeederMap(SIMPLE_16);
|
||||
expect(feeders.get(matchKey("Quarterfinals", 1))).toEqual([
|
||||
{ kind: "match", ref: { round: "Round of 16", matchNumber: 1 }, result: "winner" },
|
||||
{ kind: "match", ref: { round: "Round of 16", matchNumber: 2 }, result: "winner" },
|
||||
]);
|
||||
expect(feeders.get(matchKey("Quarterfinals", 4))).toEqual([
|
||||
{ kind: "match", ref: { round: "Round of 16", matchNumber: 7 }, result: "winner" },
|
||||
{ kind: "match", ref: { round: "Round of 16", matchNumber: 8 }, result: "winner" },
|
||||
]);
|
||||
// The first round is seeded, not fed.
|
||||
expect(feeders.get(matchKey("Round of 16", 1))).toEqual([
|
||||
{ kind: "seed" },
|
||||
{ kind: "seed" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an empty map without a template", () => {
|
||||
expect(buildFeederMap(undefined).size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeGroupLayout — standard brackets are unchanged", () => {
|
||||
it("halves a 16-team bracket evenly, first round in seeded order", () => {
|
||||
const byRound = allMatches(SIMPLE_16);
|
||||
const roundOrder = SIMPLE_16.rounds.map((r) => r.name);
|
||||
const layout = computeGroupLayout(
|
||||
roundOrder,
|
||||
byRound,
|
||||
buildFeederMap(SIMPLE_16),
|
||||
roundOrder
|
||||
);
|
||||
|
||||
expect(layout.leafCount).toBe(8);
|
||||
expect(layout.columns.map((c) => c.label)).toEqual(roundOrder);
|
||||
expect(layout.columns.map((c) => c.matches.map((m) => m.match.matchNumber))).toEqual([
|
||||
[1, 2, 3, 4, 5, 6, 7, 8],
|
||||
[1, 2, 3, 4],
|
||||
[1, 2],
|
||||
[1],
|
||||
]);
|
||||
// Evenly spread, exactly as the previous index math placed them.
|
||||
expect(layout.columns[0].matches.map((m) => m.center)).toEqual([
|
||||
0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5,
|
||||
]);
|
||||
expect(layout.columns[3].matches[0].center).toBe(4);
|
||||
});
|
||||
|
||||
it("handles byes, placing a seeded team level with the round it enters", () => {
|
||||
// The NFL bracket's top seeds skip the wild card round.
|
||||
const byRound = allMatches(NFL_14);
|
||||
const roundOrder = NFL_14.rounds.map((r) => r.name);
|
||||
const layout = computeGroupLayout(roundOrder, byRound, buildFeederMap(NFL_14), roundOrder);
|
||||
expect(layout.columns.length).toBeGreaterThan(0);
|
||||
for (const column of layout.columns) {
|
||||
expect(column.matches.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to even spreading when a group has no single root", () => {
|
||||
// Two finals and no way to join them — the shape can't resolve to one tree.
|
||||
const byRound = new Map<string, TestMatch[]>([
|
||||
["Semifinals", [{ round: "Semifinals", matchNumber: 1 }]],
|
||||
[
|
||||
"Finals",
|
||||
[
|
||||
{ round: "Finals", matchNumber: 1 },
|
||||
{ round: "Finals", matchNumber: 2 },
|
||||
],
|
||||
],
|
||||
]);
|
||||
const layout = computeGroupLayout(
|
||||
["Semifinals", "Finals"],
|
||||
byRound,
|
||||
new Map(),
|
||||
["Semifinals", "Finals"]
|
||||
);
|
||||
expect(layout.columns.map((c) => c.label)).toEqual(["Semifinals", "Finals"]);
|
||||
expect(layout.edges).toEqual([]);
|
||||
expect(layout.columns[0].matches[0].center).toBe(1);
|
||||
expect(layout.columns[1].matches.map((m) => m.center)).toEqual([0.5, 1.5]);
|
||||
});
|
||||
|
||||
it("returns nothing for an empty group", () => {
|
||||
const layout = computeGroupLayout([], new Map(), new Map(), []);
|
||||
expect(layout).toEqual({ columns: [], leafCount: 0, edges: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFeederMap — templates with routing of their own", () => {
|
||||
// The halving rule describes advanceWinnerTemplate, not every bracket. Inventing it
|
||||
// where it doesn't hold draws confident, wrong connectors and mislabels slots, which
|
||||
// is worse than drawing nothing.
|
||||
|
||||
it("follows feedsInto rather than the order rounds are listed in", () => {
|
||||
// AFL's Wildcard Round feeds the Elimination Finals, skipping the round printed
|
||||
// next to it, so array order would fabricate the whole chain.
|
||||
const afl = BRACKET_TEMPLATES.afl_10;
|
||||
const feeders = buildFeederMap(afl);
|
||||
const fed = [...feeders.entries()].filter(([, pair]) =>
|
||||
pair.some((s) => s.kind === "match")
|
||||
);
|
||||
// Only Preliminary Finals → Grand Final actually halves.
|
||||
expect(fed.map(([key]) => key)).toEqual(["Grand Final#1"]);
|
||||
});
|
||||
|
||||
it("leaves a bye round's slots seeded rather than inventing feeds", () => {
|
||||
// CFP's First Round (4) feeds the Quarterfinals (4) — the top seeds have byes.
|
||||
const feeders = buildFeederMap(BRACKET_TEMPLATES.cfp_12);
|
||||
expect(feeders.get(matchKey("Quarterfinals", 1))).toEqual([
|
||||
{ kind: "seed" },
|
||||
{ kind: "seed" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves the First Four out of the Round of 64", () => {
|
||||
// advanceFirstFourWinner puts each winner in a specific seed slot, not games 1-2.
|
||||
const feeders = buildFeederMap(BRACKET_TEMPLATES.ncaa_68);
|
||||
expect(feeders.get(matchKey("Round of 64", 1))).toEqual([
|
||||
{ kind: "seed" },
|
||||
{ kind: "seed" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves the NBA play-in alone, where a loser feeds forward", () => {
|
||||
// Play-In Round 2 pairs the 7v8 loser with the 9v10 winner, so the round sizes
|
||||
// halve but the winners-only rule still doesn't describe it.
|
||||
const feeders = buildFeederMap(BRACKET_TEMPLATES.nba_20);
|
||||
expect(feeders.get(matchKey("Play-In Round 2", 1))).toEqual([
|
||||
{ kind: "seed" },
|
||||
{ kind: "seed" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not route the FIFA final through the third place game", () => {
|
||||
// Third Place Game sits between Semifinals and Finals in round order, so array
|
||||
// order made it the Finals' feeder and left the Finals' second slot empty.
|
||||
const feeders = buildFeederMap(BRACKET_TEMPLATES.fifa_48);
|
||||
expect(feeders.get(matchKey("Finals", 1))).toEqual([
|
||||
{ kind: "match", ref: { round: "Semifinals", matchNumber: 1 }, result: "winner" },
|
||||
{ kind: "match", ref: { round: "Semifinals", matchNumber: 2 }, result: "winner" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeGroupLayout — every template still draws connectors", () => {
|
||||
/** Lay a whole template out the way BracketTreeView would. */
|
||||
function layOut(template: BracketTemplate) {
|
||||
const byRound = allMatches(template);
|
||||
const order = template.rounds.map((r) => r.name);
|
||||
// BracketTreeView renders a third place game outside the tree.
|
||||
const rounds = order.filter((r) => r !== "Third Place Game");
|
||||
return computeGroupLayout(rounds, byRound, buildFeederMap(template), order);
|
||||
}
|
||||
|
||||
// A gutter joining a column to one exactly half its size is a plain bracket join and
|
||||
// must always be drawn. Where the sizes don't halve — a bye round, a play-in, the
|
||||
// First Four — the routing is bespoke and nothing is drawn until the games decide it,
|
||||
// which is what these brackets did before.
|
||||
it.each(Object.keys(BRACKET_TEMPLATES).filter((id) => id !== "llws_20"))(
|
||||
"%s draws every gutter that halves",
|
||||
(id) => {
|
||||
const layout = layOut(BRACKET_TEMPLATES[id]);
|
||||
const gutters = new Set(layout.edges.map((e) => e.fromColumn));
|
||||
let halvingGutters = 0;
|
||||
for (let ci = 0; ci < layout.columns.length - 1; ci++) {
|
||||
const from = layout.columns[ci].matches.length;
|
||||
const to = layout.columns[ci + 1].matches.length;
|
||||
if (from !== to * 2) continue;
|
||||
halvingGutters += 1;
|
||||
expect(gutters).toContain(ci);
|
||||
}
|
||||
// Every template has at least one, so a template that lost all its lines fails.
|
||||
expect(halvingGutters).toBeGreaterThan(0);
|
||||
}
|
||||
);
|
||||
|
||||
it("keeps the FIFA bracket a single tree once the third place game is set aside", () => {
|
||||
const layout = layOut(BRACKET_TEMPLATES.fifa_48);
|
||||
expect(layout.columns.map((c) => c.label)).toEqual([
|
||||
"Round of 32",
|
||||
"Round of 16",
|
||||
"Quarterfinals",
|
||||
"Semifinals",
|
||||
"Finals",
|
||||
]);
|
||||
expect(layout.edges).toHaveLength(30);
|
||||
});
|
||||
|
||||
// llws_20 is excluded above because both sides in one group is genuinely not a tree;
|
||||
// it renders per side, which the tests further up cover.
|
||||
});
|
||||
|
||||
describe("computeGroupLayout — fallback keeps the old connectors", () => {
|
||||
const rounds = ["Quarterfinals", "Semifinals", "Finals"];
|
||||
const byRound = new Map<string, TestMatch[]>([
|
||||
["Quarterfinals", [1, 2, 3, 4].map((n) => ({ round: "Quarterfinals", matchNumber: n }))],
|
||||
["Semifinals", [1, 2].map((n) => ({ round: "Semifinals", matchNumber: n }))],
|
||||
["Finals", [{ round: "Finals", matchNumber: 1 }]],
|
||||
]);
|
||||
|
||||
it("infers halving edges when there is no feeder map at all", () => {
|
||||
// A bracket with no template id, which SportSeasonDisplay renders.
|
||||
const layout = computeGroupLayout(rounds, byRound, new Map(), rounds);
|
||||
expect(layout.edges).toHaveLength(6);
|
||||
// Quarterfinals 1 and 2 both join Semifinal 1.
|
||||
const intoFirstSemi = layout.edges.filter((e) => e.toCenter === 1);
|
||||
expect(intoFirstSemi.map((e) => e.fromCenter)).toEqual([0.5, 1.5]);
|
||||
});
|
||||
|
||||
it("traces played winners when the shape is not a halving", () => {
|
||||
const irregular = new Map<string, TestMatch[]>([
|
||||
[
|
||||
"Wildcard",
|
||||
[
|
||||
{ round: "Wildcard", matchNumber: 1, winnerId: "a" },
|
||||
{ round: "Wildcard", matchNumber: 2, winnerId: "b" },
|
||||
],
|
||||
],
|
||||
[
|
||||
"Semifinals",
|
||||
[
|
||||
{ round: "Semifinals", matchNumber: 1, participant1Id: "seeded", participant2Id: "b" },
|
||||
{ round: "Semifinals", matchNumber: 2, participant1Id: "seeded2", participant2Id: "a" },
|
||||
],
|
||||
],
|
||||
]);
|
||||
const layout = computeGroupLayout(
|
||||
["Wildcard", "Semifinals"],
|
||||
irregular,
|
||||
new Map(),
|
||||
["Wildcard", "Semifinals"]
|
||||
);
|
||||
// b won Wildcard 2 (centre 1.5) and plays Semifinal 1 (centre 0.5) — a crossing
|
||||
// edge that only the actual result can reveal.
|
||||
expect(layout.edges).toContainEqual({ fromColumn: 0, fromCenter: 1.5, toCenter: 0.5 });
|
||||
expect(layout.edges).toContainEqual({ fromColumn: 0, fromCenter: 0.5, toCenter: 1.5 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeSlotSource", () => {
|
||||
const feeders = buildFeederMap(LLWS_20);
|
||||
const sourcesFor = (game: number): [SlotSource, SlotSource] => {
|
||||
const m = GAME_TO_MATCH[game];
|
||||
const pair = feeders.get(matchKey(m.round, m.matchNumber));
|
||||
if (!pair) throw new Error(`No feeders for G${game}`);
|
||||
return pair;
|
||||
};
|
||||
|
||||
it("names a winner feed", () => {
|
||||
// G18 = W5 v W9; G5 is Opening Round match 3 on the International side.
|
||||
expect(describeSlotSource(sourcesFor(18)[0], LLWS_20)).toBe("Winner of Opening 3");
|
||||
});
|
||||
|
||||
it("names a loser feed, which is the one no line can show", () => {
|
||||
// G21 = L9 v W13; G9 is Winners Round 2 match 1 on the International side.
|
||||
expect(describeSlotSource(sourcesFor(21)[0], LLWS_20)).toBe("Loser of Winners R2 1");
|
||||
// G25 = L18 v W23; G18 is International semifinal 1.
|
||||
expect(describeSlotSource(sourcesFor(25)[0], LLWS_20)).toBe("Loser of Winners SF 1");
|
||||
});
|
||||
|
||||
it("uses side-local numbers, as the printed bracket does", () => {
|
||||
// G27 = L20 v W21. G20 is Winners Semifinals match 4 globally, but International
|
||||
// semifinal 2 — the number the printed bracket uses.
|
||||
expect(describeSlotSource(sourcesFor(27)[0], LLWS_20)).toBe("Loser of Winners SF 2");
|
||||
});
|
||||
|
||||
it("names the side where each side plays only one such game", () => {
|
||||
// G37 = L36 v L35: both feeds are Bracket Championship losers, one per side, so a
|
||||
// number would say nothing and the side is the only thing that tells them apart.
|
||||
const [p1, p2] = sourcesFor(37);
|
||||
expect(describeSlotSource(p1, LLWS_20)).toBe("Loser of U.S. Bracket Final");
|
||||
expect(describeSlotSource(p2, LLWS_20)).toBe("Loser of Intl Bracket Final");
|
||||
|
||||
// Same rule inside a side bracket: G34 = L30 v W32.
|
||||
const [elimP1, elimP2] = sourcesFor(34);
|
||||
expect(describeSlotSource(elimP1, LLWS_20)).toBe("Loser of U.S. Winners Final");
|
||||
expect(describeSlotSource(elimP2, LLWS_20)).toBe("Winner of U.S. Elim R4");
|
||||
});
|
||||
|
||||
it("drops both number and side for the shared final games", () => {
|
||||
// The two sides meet here, so there is only one of each game in the whole bracket.
|
||||
const wc = describeSlotSource(
|
||||
{ kind: "match", ref: GAME_TO_MATCH[38], result: "winner" },
|
||||
LLWS_20
|
||||
);
|
||||
expect(wc).toBe("Winner of World Championship");
|
||||
});
|
||||
|
||||
it("returns null for a seeded slot so the caller can render TBD", () => {
|
||||
expect(describeSlotSource({ kind: "seed" }, LLWS_20)).toBeNull();
|
||||
expect(describeSlotSource(undefined, LLWS_20)).toBeNull();
|
||||
});
|
||||
|
||||
it("uses plain round names for non-LLWS templates", () => {
|
||||
const template = getBracketTemplate("simple_16");
|
||||
const source: SlotSource = {
|
||||
kind: "match",
|
||||
ref: { round: "Quarterfinals", matchNumber: 3 },
|
||||
result: "winner",
|
||||
};
|
||||
expect(describeSlotSource(source, template)).toBe("Winner of Quarterfinals 3");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,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);
|
||||
});
|
||||
});
|
||||
|
|
@ -14,11 +14,6 @@ describe("normalizeTeamName", () => {
|
|||
const name = "oklahoma city thunder";
|
||||
expect(normalizeTeamName(name)).toBe(name);
|
||||
});
|
||||
|
||||
it("folds accents so diacritics don't block a match", () => {
|
||||
expect(normalizeTeamName("Stéfanos Tsitsipás")).toBe("stefanos tsitsipas");
|
||||
expect(normalizeTeamName("Félix Auger-Aliassime")).toBe("felix auger-aliassime");
|
||||
});
|
||||
});
|
||||
|
||||
describe("findMatchingTeamName", () => {
|
||||
|
|
@ -43,12 +38,6 @@ describe("findMatchingTeamName", () => {
|
|||
expect(findMatchingTeamName("Oklahoma City", participants)).toBe("Oklahoma City Thunder");
|
||||
});
|
||||
|
||||
it("matches across accents (API has them, our list doesn't)", () => {
|
||||
expect(findMatchingTeamName("Stéfanos Tsitsipás", ["Stefanos Tsitsipas"])).toBe(
|
||||
"Stefanos Tsitsipas",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for unmatched name", () => {
|
||||
expect(findMatchingTeamName("Phoenix Suns", participants)).toBeNull();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,99 +0,0 @@
|
|||
/**
|
||||
* AFL Wildcard Round → Elimination Finals re-seeding.
|
||||
*
|
||||
* The Wildcard Round is drawn 7 v 10 and 8 v 9, and its two winners fill the open slots
|
||||
* in the Elimination Finals opposite the 5th and 6th seeds. Those slots are NOT a fixed
|
||||
* crossover: the winners are re-seeded by ladder position, exactly as the classic final
|
||||
* eight pairs 5 v 8 and 6 v 7 — the higher seed of the two hosts meets the lower-ranked
|
||||
* winner. So 5th plays whichever winner finished further down the ladder and 6th plays
|
||||
* the other, whichever Wildcard game each came out of.
|
||||
*
|
||||
* Worked example: 10th beats 7th and 9th beats 8th. A fixed crossover would send the
|
||||
* 7v10 winner (10th) to 6th and the 8v9 winner (9th) to 5th — handing the higher host
|
||||
* the better opponent. Re-seeded, 5th plays 10th and 6th plays 9th.
|
||||
*/
|
||||
|
||||
/** Seeds drawn into each Wildcard Round match, in [participant1, participant2] order. */
|
||||
export const AFL_WILDCARD_DRAW: Readonly<Record<number, readonly [number, number]>> = {
|
||||
1: [7, 10],
|
||||
2: [8, 9],
|
||||
};
|
||||
|
||||
/** Seed hosting each Elimination Finals match (its participant1 slot). */
|
||||
export const AFL_ELIMINATION_HOSTS: Readonly<Record<number, number>> = {
|
||||
1: 5,
|
||||
2: 6,
|
||||
};
|
||||
|
||||
export interface AflWildcardResult {
|
||||
matchNumber: number;
|
||||
/** Slot the winner occupied, or null while the match is still to be played. */
|
||||
winnerSlot: 1 | 2 | null;
|
||||
}
|
||||
|
||||
export interface AflWildcardPlacement {
|
||||
wildcardMatchNumber: number;
|
||||
/** Seed of the Wildcard winner being placed. */
|
||||
seed: number;
|
||||
eliminationMatchNumber: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide which Elimination Final each decided Wildcard winner belongs in.
|
||||
*
|
||||
* A winner is only placed once its destination is settled whichever way the other
|
||||
* Wildcard game falls, so results can be entered in either order:
|
||||
* - 7th winning match 1 outranks both possible match 2 winners → always meets 6th.
|
||||
* - 10th winning match 1 is outranked by both → always meets 5th.
|
||||
* - A match 2 winner (8th or 9th) sits between them, so it is held back until match 1
|
||||
* is decided rather than being placed and then moved.
|
||||
*
|
||||
* Undecided winners are simply omitted; the caller fills the slots it is handed and
|
||||
* leaves the rest TBD.
|
||||
*/
|
||||
export function resolveAflWildcardPlacements(
|
||||
results: readonly AflWildcardResult[]
|
||||
): AflWildcardPlacement[] {
|
||||
const entries = results.map((result) => {
|
||||
const draw = AFL_WILDCARD_DRAW[result.matchNumber];
|
||||
if (!draw) {
|
||||
throw new Error(`Unknown AFL Wildcard Round match number ${result.matchNumber}`);
|
||||
}
|
||||
return {
|
||||
matchNumber: result.matchNumber,
|
||||
seed: result.winnerSlot === null ? null : draw[result.winnerSlot - 1],
|
||||
// Every seed the match could still send through — one entry once it is decided.
|
||||
possibleSeeds: result.winnerSlot === null ? [...draw] : [draw[result.winnerSlot - 1]],
|
||||
};
|
||||
});
|
||||
|
||||
// Best-ranked winner takes the weakest host, so order the hosts worst seed first.
|
||||
const hostsWorstFirst = Object.keys(AFL_ELIMINATION_HOSTS)
|
||||
.map(Number)
|
||||
.toSorted((a, b) => AFL_ELIMINATION_HOSTS[b] - AFL_ELIMINATION_HOSTS[a]);
|
||||
|
||||
const placements: AflWildcardPlacement[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const seed = entry.seed;
|
||||
if (seed === null) continue;
|
||||
|
||||
const others = entries.filter((other) => other !== entry);
|
||||
const outranks = (other: (typeof entries)[number]) => other.possibleSeeds.every((s) => s < seed);
|
||||
const outrankedBy = (other: (typeof entries)[number]) => other.possibleSeeds.every((s) => s > seed);
|
||||
|
||||
// This winner's rank is only knowable while every other one sits wholly above or
|
||||
// wholly below it — an undecided game straddling this seed leaves it unplaceable.
|
||||
if (!others.every((other) => outranks(other) || outrankedBy(other))) continue;
|
||||
|
||||
const rank = others.filter(outranks).length;
|
||||
const eliminationMatchNumber = hostsWorstFirst[rank];
|
||||
if (eliminationMatchNumber === undefined) {
|
||||
throw new Error(`No Elimination Finals slot for AFL Wildcard winner ranked ${rank + 1}`);
|
||||
}
|
||||
|
||||
placements.push({ wildcardMatchNumber: entry.matchNumber, seed, eliminationMatchNumber });
|
||||
}
|
||||
|
||||
return placements;
|
||||
}
|
||||
|
|
@ -1,420 +0,0 @@
|
|||
/**
|
||||
* Bracket geometry, derived from the real feeder graph.
|
||||
*
|
||||
* The renderer used to place cards by index within a round — match i at
|
||||
* `i * (height / roundSize)` — and drew connectors assuming matches 2k and 2k+1 feed
|
||||
* match k. That holds only when each round is an exact halving of the previous one.
|
||||
*
|
||||
* The LLWS winners bracket is not a halving: two of the four Opening Round games skip
|
||||
* Winners Round 2 entirely and go straight to the semifinals (see LLWS_ADVANCEMENT).
|
||||
* Under index math those games get pulled to the bottom of column one with nothing
|
||||
* above them in column two, and the connectors confidently join the wrong pairs.
|
||||
*
|
||||
* So lay out from the graph instead:
|
||||
* column = depth from the group's final, counted backwards
|
||||
* vertical order = the parent's slot order (participant1 above participant2)
|
||||
* connectors = actual feeder edges
|
||||
*
|
||||
* Counting columns back from the final is what makes a printed bracket line up: a team
|
||||
* entering late sits in the column where it actually plays, not the column its round
|
||||
* name suggests. For the LLWS International side this reproduces the official bracket
|
||||
* exactly, including putting the Australia/Mexico game alongside Winners Round 2.
|
||||
*
|
||||
* Pure — no React, no DB — so the geometry can be asserted against the printed bracket
|
||||
* in tests.
|
||||
*/
|
||||
|
||||
import {
|
||||
llwsSideAndLocal,
|
||||
type BracketTemplate,
|
||||
} from "~/lib/bracket-templates";
|
||||
import { resolveLLWSAdvancement } from "~/lib/llws-bracket";
|
||||
|
||||
// ── Feeder graph ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MatchRef {
|
||||
round: string;
|
||||
matchNumber: number;
|
||||
}
|
||||
|
||||
/** What fills one participant slot of a match. */
|
||||
export type SlotSource =
|
||||
| { kind: "match"; ref: MatchRef; result: "winner" | "loser" }
|
||||
| { kind: "seed" };
|
||||
|
||||
/** Keyed by `${round}#${matchNumber}`; the pair is [participant1, participant2]. */
|
||||
export type FeederMap = Map<string, [SlotSource, SlotSource]>;
|
||||
|
||||
const SEED: SlotSource = { kind: "seed" };
|
||||
|
||||
/**
|
||||
* `template.id:roundName` for transitions routed by a dedicated advancement function
|
||||
* rather than advanceWinnerTemplate's ceil(n/2) rule, and whose round sizes happen to
|
||||
* halve so the check in buildFeederMap can't rule them out on shape alone.
|
||||
*
|
||||
* The NBA play-in is the case: Play-In Round 2 pairs the 7v8 *loser* with the 9v10
|
||||
* winner (advanceNBAPlayInWinner), which no winners-only halving describes.
|
||||
*/
|
||||
const BESPOKE_TRANSITIONS = new Set(["nba_20:Play-In Round 1"]);
|
||||
|
||||
export function matchKey(round: string, matchNumber: number): string {
|
||||
return `${round}#${matchNumber}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invert a template's advancement rules into "what fills each slot".
|
||||
*
|
||||
* `llws_20` has an explicit, hand-verified routing table with deliberate cross-overs, so
|
||||
* it is inverted from that. Everything else follows the standard rule: slot p1 of match N
|
||||
* is the winner of match 2N-1 in the previous round, slot p2 the winner of match 2N.
|
||||
*/
|
||||
export function buildFeederMap(template: BracketTemplate | undefined): FeederMap {
|
||||
const feeders: FeederMap = new Map();
|
||||
if (!template) return feeders;
|
||||
|
||||
const slots = (key: string): [SlotSource, SlotSource] => {
|
||||
let pair = feeders.get(key);
|
||||
if (!pair) {
|
||||
pair = [SEED, SEED];
|
||||
feeders.set(key, pair);
|
||||
}
|
||||
return pair;
|
||||
};
|
||||
|
||||
// Seed every match in the template so unfed slots read as directly seeded.
|
||||
for (const round of template.rounds) {
|
||||
for (let n = 1; n <= round.matchCount; n++) slots(matchKey(round.name, n));
|
||||
}
|
||||
|
||||
if (template.id === "llws_20") {
|
||||
for (const round of template.rounds) {
|
||||
for (let n = 1; n <= round.matchCount; n++) {
|
||||
const { winner, loser } = resolveLLWSAdvancement(round.name, n);
|
||||
const ref: MatchRef = { round: round.name, matchNumber: n };
|
||||
for (const [destination, result] of [
|
||||
[winner, "winner"],
|
||||
[loser, "loser"],
|
||||
] as const) {
|
||||
if (!destination) continue;
|
||||
const pair = slots(matchKey(destination.round, destination.matchNumber));
|
||||
pair[destination.slot === "participant1Id" ? 0 : 1] = { kind: "match", ref, result };
|
||||
}
|
||||
}
|
||||
}
|
||||
return feeders;
|
||||
}
|
||||
|
||||
// Follow each round's declared `feedsInto` rather than array order — AFL's Wildcard
|
||||
// Round feeds the Elimination Finals, skipping the round printed next to it.
|
||||
for (const prev of template.rounds) {
|
||||
if (!prev.feedsInto) continue;
|
||||
const round = template.rounds.find((r) => r.name === prev.feedsInto);
|
||||
if (!round) continue;
|
||||
|
||||
// advanceWinnerTemplate sends match n to ceil(n/2) in the next round, slot by
|
||||
// parity. That describes the bracket only where the round halves exactly; a
|
||||
// play-in, a bye round, or a First Four routes by rules of its own, and inventing
|
||||
// a halving there would draw connectors and slot labels that are simply wrong.
|
||||
// Leaving those edges out drops the group to computeGroupLayout's fallback, which
|
||||
// is the geometry these brackets already had.
|
||||
if (prev.matchCount !== round.matchCount * 2) continue;
|
||||
if (BESPOKE_TRANSITIONS.has(`${template.id}:${prev.name}`)) continue;
|
||||
|
||||
for (let n = 1; n <= round.matchCount; n++) {
|
||||
const pair = slots(matchKey(round.name, n));
|
||||
pair[0] = {
|
||||
kind: "match",
|
||||
ref: { round: prev.name, matchNumber: 2 * n - 1 },
|
||||
result: "winner",
|
||||
};
|
||||
pair[1] = {
|
||||
kind: "match",
|
||||
ref: { round: prev.name, matchNumber: 2 * n },
|
||||
result: "winner",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return feeders;
|
||||
}
|
||||
|
||||
// ── Slot labels ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Round names as they read inside a card, where there is room for about twenty
|
||||
* characters. Anything not listed keeps its full name.
|
||||
*/
|
||||
const SHORT_ROUND_NAMES: Record<string, string> = {
|
||||
"Opening Round": "Opening",
|
||||
"Winners Round 2": "Winners R2",
|
||||
"Winners Semifinals": "Winners SF",
|
||||
"Winners Final": "Winners Final",
|
||||
"Elimination Round 1": "Elim R1",
|
||||
"Elimination Round 2": "Elim R2",
|
||||
"Elimination Round 3": "Elim R3",
|
||||
"Elimination Round 4": "Elim R4",
|
||||
"Elimination Final": "Elim Final",
|
||||
"Bracket Championship": "Bracket Final",
|
||||
};
|
||||
|
||||
/**
|
||||
* How an empty slot should read: "Winner of Winners SF 2" rather than "TBD".
|
||||
*
|
||||
* Returns null for a directly seeded slot, which the caller renders as "TBD".
|
||||
*
|
||||
* The cross-bracket feeds matter most here — a winners-bracket loser dropping into the
|
||||
* elimination bracket is a real edge that no line can show, because the two sides render
|
||||
* as separate trees.
|
||||
*/
|
||||
export function describeSlotSource(
|
||||
source: SlotSource | undefined,
|
||||
template: BracketTemplate | undefined
|
||||
): string | null {
|
||||
if (!source || source.kind !== "match") return null;
|
||||
|
||||
const { round, matchNumber } = source.ref;
|
||||
const name = SHORT_ROUND_NAMES[round] ?? round;
|
||||
const verb = source.result === "winner" ? "Winner" : "Loser";
|
||||
const roundMatchCount = template?.rounds.find((r) => r.name === round)?.matchCount ?? 0;
|
||||
|
||||
if (template?.id !== "llws_20") {
|
||||
return `${verb} of ${name}${roundMatchCount <= 1 ? "" : ` ${matchNumber}`}`;
|
||||
}
|
||||
|
||||
// LLWS numbers matches globally across both sides, so semifinal 4 is International
|
||||
// semifinal 2. Name it the way the printed bracket does — by side-local number, or by
|
||||
// side where each side plays only one such game and the number would say nothing.
|
||||
const { side, localMatch } = llwsSideAndLocal(round, matchNumber);
|
||||
const isShared = round === "Consolation Third Place" || round === "World Championship";
|
||||
const perSideCount = isShared ? roundMatchCount : roundMatchCount / 2;
|
||||
|
||||
if (perSideCount > 1) return `${verb} of ${name} ${localMatch}`;
|
||||
if (isShared) return `${verb} of ${name}`;
|
||||
return `${verb} of ${side === 0 ? "U.S." : "Intl"} ${name}`;
|
||||
}
|
||||
|
||||
// ── Layout ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface LaidOutMatch<M> {
|
||||
match: M;
|
||||
/** Centre of the card, in slot units (1 unit = one leaf row). */
|
||||
center: number;
|
||||
}
|
||||
|
||||
export interface LayoutColumn<M> {
|
||||
label: string;
|
||||
matches: LaidOutMatch<M>[];
|
||||
}
|
||||
|
||||
export interface BracketLayout<M> {
|
||||
columns: LayoutColumn<M>[];
|
||||
/** Number of leaf rows; multiply by row height for the pixel height of the bracket. */
|
||||
leafCount: number;
|
||||
/** Edges to draw, as (column index of the source, source centre, target centre). */
|
||||
edges: { fromColumn: number; fromCenter: number; toCenter: number }[];
|
||||
}
|
||||
|
||||
interface PositionedMatch {
|
||||
round: string;
|
||||
matchNumber: number;
|
||||
/** Only read by the fallback, to trace edges through an unrecognised shape. */
|
||||
winnerId?: string | null;
|
||||
participant1Id?: string | null;
|
||||
participant2Id?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay out one rendered group — a winners bracket, an elimination bracket, a region.
|
||||
*
|
||||
* `matchesByRound` should already be filtered to the group; cross-group feeds are
|
||||
* dropped, matching the printed bracket, which labels those slots rather than drawing
|
||||
* lines to another tree.
|
||||
*
|
||||
* Falls back to the previous index-based geometry when the group has no single root
|
||||
* (disjoint or unrecognised shapes), so no existing template can regress to a blank
|
||||
* column.
|
||||
*/
|
||||
export function computeGroupLayout<M extends PositionedMatch>(
|
||||
visibleRounds: string[],
|
||||
matchesByRound: Map<string, M[]>,
|
||||
feeders: FeederMap,
|
||||
templateRoundOrder: string[]
|
||||
): BracketLayout<M> {
|
||||
const nodes = new Map<string, M>();
|
||||
const roundOf = new Map<string, string>();
|
||||
for (const round of visibleRounds) {
|
||||
for (const match of matchesByRound.get(round) ?? []) {
|
||||
const key = matchKey(match.round, match.matchNumber);
|
||||
nodes.set(key, match);
|
||||
roundOf.set(key, round);
|
||||
}
|
||||
}
|
||||
if (nodes.size === 0) return { columns: [], leafCount: 0, edges: [] };
|
||||
|
||||
// In-group children, in slot order. A slot fed from outside the group has no card
|
||||
// here, so it contributes no edge.
|
||||
const childrenOf = new Map<string, string[]>();
|
||||
const hasParent = new Set<string>();
|
||||
for (const key of nodes.keys()) {
|
||||
const pair = feeders.get(key);
|
||||
const kids: string[] = [];
|
||||
for (const source of pair ?? []) {
|
||||
if (source.kind !== "match") continue;
|
||||
const childKey = matchKey(source.ref.round, source.ref.matchNumber);
|
||||
if (!nodes.has(childKey) || kids.includes(childKey)) continue;
|
||||
kids.push(childKey);
|
||||
hasParent.add(childKey);
|
||||
}
|
||||
childrenOf.set(key, kids);
|
||||
}
|
||||
|
||||
const roots = [...nodes.keys()].filter((k) => !hasParent.has(k));
|
||||
if (roots.length !== 1) {
|
||||
return fallbackLayout(visibleRounds, matchesByRound);
|
||||
}
|
||||
const [root] = roots;
|
||||
|
||||
// Depth from the root, then flip so leaves are column 0 and the final is last.
|
||||
//
|
||||
// Take the longest path, not the first one found: in a double-elimination bracket a
|
||||
// match feeds two places (its winner forward, its loser into the elimination side), so
|
||||
// the graph is a DAG and a node can be reached at several depths. The longest path is
|
||||
// the one that leaves room for every game on the way.
|
||||
const depth = new Map<string, number>();
|
||||
const assignDepth = (key: string, d: number) => {
|
||||
const known = depth.get(key);
|
||||
if (known !== undefined && known >= d) return;
|
||||
depth.set(key, d);
|
||||
for (const child of childrenOf.get(key) ?? []) assignDepth(child, d + 1);
|
||||
};
|
||||
assignDepth(root, 0);
|
||||
if (depth.size !== nodes.size) {
|
||||
return fallbackLayout(visibleRounds, matchesByRound);
|
||||
}
|
||||
const maxDepth = Math.max(...depth.values());
|
||||
const columnOf = (key: string) => maxDepth - (depth.get(key) ?? 0);
|
||||
|
||||
// Vertical order comes from a depth-first walk in slot order: participant1's feeder
|
||||
// sits above participant2's. This is why the elimination bracket's later game ends up
|
||||
// on top, as the printed bracket has it.
|
||||
const center = new Map<string, number>();
|
||||
let leafCount = 0;
|
||||
const place = (key: string): number => {
|
||||
const already = center.get(key);
|
||||
if (already !== undefined) return already;
|
||||
const kids = childrenOf.get(key) ?? [];
|
||||
if (kids.length === 0) {
|
||||
const y = leafCount + 0.5;
|
||||
leafCount += 1;
|
||||
center.set(key, y);
|
||||
return y;
|
||||
}
|
||||
const kidCenters = kids.map(place);
|
||||
const y = kidCenters.reduce((sum, c) => sum + c, 0) / kidCenters.length;
|
||||
center.set(key, y);
|
||||
return y;
|
||||
};
|
||||
place(root);
|
||||
|
||||
const columns: LayoutColumn<M>[] = Array.from({ length: maxDepth + 1 }, () => ({
|
||||
label: "",
|
||||
matches: [],
|
||||
}));
|
||||
for (const [key, match] of nodes) {
|
||||
columns[columnOf(key)].matches.push({ match, center: center.get(key) ?? 0 });
|
||||
}
|
||||
for (const column of columns) {
|
||||
column.matches.sort((a, b) => a.center - b.center);
|
||||
}
|
||||
|
||||
// A column can mix rounds — the LLWS second column holds two Opening Round games
|
||||
// alongside Winners Round 2. Name it for the latest round it contains, which is how
|
||||
// the printed bracket labels that column.
|
||||
for (let ci = 0; ci < columns.length; ci++) {
|
||||
const rounds = columns[ci].matches.map((m) => m.match.round);
|
||||
columns[ci].label = rounds.reduce((latest, r) =>
|
||||
templateRoundOrder.indexOf(r) > templateRoundOrder.indexOf(latest) ? r : latest
|
||||
);
|
||||
}
|
||||
|
||||
// Connectors live in the single gutter between adjacent columns, so only edges that
|
||||
// span exactly one gutter can be drawn. In a tree every edge does; in the DAG case a
|
||||
// feed can reach further back, and a line that stopped short would be worse than none.
|
||||
const edges: BracketLayout<M>["edges"] = [];
|
||||
for (const [key] of nodes) {
|
||||
const toCenter = center.get(key) ?? 0;
|
||||
for (const child of childrenOf.get(key) ?? []) {
|
||||
const fromColumn = columnOf(child);
|
||||
if (fromColumn !== columnOf(key) - 1) continue;
|
||||
edges.push({ fromColumn, fromCenter: center.get(child) ?? 0, toCenter });
|
||||
}
|
||||
}
|
||||
|
||||
return { columns, leafCount, edges };
|
||||
}
|
||||
|
||||
/**
|
||||
* The previous behaviour, kept for groups whose shape can't be resolved into a single
|
||||
* tree: one column per round, matches spread evenly over it, and edges inferred from the
|
||||
* round sizes. Brackets with bespoke routing (AFL, CFP byes, a bracket with no template)
|
||||
* land here, so it has to keep drawing what they drew before rather than nothing.
|
||||
*/
|
||||
function fallbackLayout<M extends PositionedMatch>(
|
||||
visibleRounds: string[],
|
||||
matchesByRound: Map<string, M[]>
|
||||
): BracketLayout<M> {
|
||||
const leafCount = Math.max(
|
||||
...visibleRounds.map((r) => matchesByRound.get(r)?.length ?? 0),
|
||||
1
|
||||
);
|
||||
const centersFor = (matches: M[]) => {
|
||||
const span = leafCount / Math.max(matches.length, 1);
|
||||
return matches.map((_, i) => (i + 0.5) * span);
|
||||
};
|
||||
|
||||
const columns = visibleRounds.map((round) => {
|
||||
const matches = matchesByRound.get(round) ?? [];
|
||||
const centers = centersFor(matches);
|
||||
return {
|
||||
label: round,
|
||||
matches: matches.map((match, i) => ({ match, center: centers[i] })),
|
||||
};
|
||||
});
|
||||
|
||||
const edges: BracketLayout<M>["edges"] = [];
|
||||
for (let ci = 0; ci < columns.length - 1; ci++) {
|
||||
const from = columns[ci].matches;
|
||||
const to = columns[ci + 1].matches;
|
||||
|
||||
if (to.length === Math.ceil(from.length / 2) && from.length > 1) {
|
||||
// A halving: matches 2k and 2k+1 feed match k.
|
||||
for (let k = 0; k < to.length; k++) {
|
||||
for (const idx of [2 * k, 2 * k + 1]) {
|
||||
if (idx >= from.length) continue;
|
||||
edges.push({
|
||||
fromColumn: ci,
|
||||
fromCenter: from[idx].center,
|
||||
toCenter: to[k].center,
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Otherwise the only thing that can be known is where a winner actually went, so
|
||||
// nothing is drawn until the games are played.
|
||||
const winnerToCenter = new Map<string, number>();
|
||||
for (const { match, center } of from) {
|
||||
if (match.winnerId) winnerToCenter.set(match.winnerId, center);
|
||||
}
|
||||
for (const { match, center } of to) {
|
||||
for (const id of [match.participant1Id, match.participant2Id]) {
|
||||
const fromCenter = id ? winnerToCenter.get(id) : undefined;
|
||||
if (fromCenter === undefined) continue;
|
||||
edges.push({ fromColumn: ci, fromCenter, toCenter: center });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { columns, leafCount, edges };
|
||||
}
|
||||
|
|
@ -19,32 +19,6 @@ export interface BracketRound {
|
|||
* When set, the loser of each match in this round is placed into the target round.
|
||||
*/
|
||||
loserFeedsInto?: string | null;
|
||||
/**
|
||||
* Floor position banked by the WINNER of a *non-scoring* round.
|
||||
*
|
||||
* Omit for the default behavior: winners entering the first scoring round bank a
|
||||
* T5–T8 floor (position 5), everyone else banks nothing. Set an explicit number when
|
||||
* that default is wrong — in a double-elimination losers bracket a win can guarantee
|
||||
* a worse finish than 5th (llws_20 "Elimination Round 3" → 7). Set null to bank no
|
||||
* floor even though the next round scores.
|
||||
*
|
||||
* Has no effect on scoring rounds, which use RoundScoringConfig.winnerFloor instead.
|
||||
*/
|
||||
nonScoringWinnerFloor?: number | null;
|
||||
/**
|
||||
* Floor position every team is guaranteed simply by being *seeded into* this
|
||||
* round when the bracket is generated — before a single match is played.
|
||||
*
|
||||
* Omit (the default) for rounds where entering guarantees nothing: a team that
|
||||
* loses its first match earns 0. Set a number when the bracket structure locks
|
||||
* in a scoring tier on entry — e.g. afl_10's Qualifying Finals, where the loser
|
||||
* still gets a Semi-Final and so cannot finish worse than the 5th-6th tier.
|
||||
*
|
||||
* Only teams actually assigned to a match slot at generation receive this floor;
|
||||
* TBD slots filled later by advancing winners get their floor from the round they
|
||||
* won (nonScoringWinnerFloor / RoundScoringConfig.winnerFloor) instead.
|
||||
*/
|
||||
entryFloor?: number | null;
|
||||
}
|
||||
|
||||
export interface GroupStageConfig {
|
||||
|
|
@ -703,8 +677,7 @@ export const NFL_14: BracketTemplate = {
|
|||
* - Wildcard Round: 7v10, 8v9 (losers eliminated with 0 points)
|
||||
* - Week 1 Finals:
|
||||
* - Qualifying Finals: 1v4, 2v3 (losers get second chance)
|
||||
* - Elimination Finals: the two Wildcard winners are re-seeded by ladder position, so
|
||||
* 5th hosts the lower-ranked winner and 6th the higher-ranked one (losers share 7th-8th)
|
||||
* - Elimination Finals: 5v8(wildcard winner), 6v7(wildcard winner) (losers share 7th-8th)
|
||||
* - Week 2: Semi-Finals (QF losers vs EF winners, losers share 5th-6th)
|
||||
* - Week 3: Preliminary Finals (QF winners vs SF winners, losers share 3rd-4th)
|
||||
* - Week 4: Grand Final (1st vs 2nd)
|
||||
|
|
@ -722,32 +695,18 @@ export const AFL_10: BracketTemplate = {
|
|||
matchCount: 2,
|
||||
feedsInto: "Elimination Finals",
|
||||
isScoring: false, // Losers get 0 points (9th-10th)
|
||||
// A Wildcard win only buys an Elimination Final; losing that is the 7th-8th
|
||||
// tier, so the winner banks 7 — not the generic "entering a scoring round
|
||||
// means top-8" default of 5, which would over-award them a 5th-6th floor.
|
||||
nonScoringWinnerFloor: 7,
|
||||
},
|
||||
{
|
||||
name: "Qualifying Finals",
|
||||
matchCount: 2,
|
||||
feedsInto: "Preliminary Finals", // Winners get bye
|
||||
isScoring: false, // Losers get second chance (go to Semi-Finals)
|
||||
// Seeds 1-4 have the double chance from the moment the bracket is drawn:
|
||||
// lose the QF, lose the Semi-Final, and you still finish in the 5th-6th tier.
|
||||
entryFloor: 5,
|
||||
// Winning the QF is a bye straight to a Preliminary Final; losing that is the
|
||||
// 3rd-4th tier, so the winner's floor is 3 rather than the generic default of 5.
|
||||
nonScoringWinnerFloor: 3,
|
||||
},
|
||||
{
|
||||
name: "Elimination Finals",
|
||||
matchCount: 2,
|
||||
feedsInto: "Semi-Finals",
|
||||
isScoring: true, // Losers share 7th-8th
|
||||
// Seeds 5-6 are seeded straight into this round, so the 7th-8th tier is
|
||||
// locked in for them at generation. (The other slot is a TBD Wildcard winner,
|
||||
// who banks the same floor by winning the Wildcard Round.)
|
||||
entryFloor: 7,
|
||||
},
|
||||
{
|
||||
name: "Semi-Finals",
|
||||
|
|
@ -975,258 +934,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
|
||||
*/
|
||||
|
|
@ -1244,7 +951,6 @@ export const BRACKET_TEMPLATES: Record<string, BracketTemplate> = {
|
|||
tennis_128: TENNIS_128,
|
||||
cfp_12: CFP_12,
|
||||
nba_20: NBA_20,
|
||||
llws_20: LLWS_20,
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -1300,18 +1006,6 @@ export function getScoringRoundType(
|
|||
const round = template.rounds.find((r) => r.name === roundName);
|
||||
if (!round || !round.isScoring) return null;
|
||||
|
||||
// Special handling for LLWS double elimination: match counts don't identify the
|
||||
// tier (Elimination Round 4 and the Elimination Final both have 2 matches), and
|
||||
// the Winners Final eliminates nobody.
|
||||
if (template.id === "llws_20") {
|
||||
if (roundName === "Elimination Round 4") return "quarterfinals"; // losers share 7-8th
|
||||
if (roundName === "Elimination Final") return "quarterfinals"; // losers share 5-6th
|
||||
if (roundName === "Bracket Championship") return "semifinals"; // losers play for 3-4th
|
||||
if (roundName === "Consolation Third Place") return "semifinals"; // finalizes 3rd/4th
|
||||
if (roundName === "World Championship") return "finals"; // 1st and 2nd
|
||||
return null; // Winners Final: loser drops to the elimination bracket, nobody is out
|
||||
}
|
||||
|
||||
// Special handling for AFL finals
|
||||
if (template.id === "afl_10") {
|
||||
if (roundName === "Elimination Finals") return "quarterfinals"; // Losers share 7-8th
|
||||
|
|
|
|||
|
|
@ -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,194 +0,0 @@
|
|||
/**
|
||||
* LLWS 20-team double-elimination routing — the pure half of the bracket.
|
||||
*
|
||||
* Lives in lib/ rather than models/ because the renderer needs it: models/playoff-match
|
||||
* pulls in the database context and drizzle, which must not reach the browser bundle.
|
||||
* models/playoff-match re-exports everything here, so server-side callers are unchanged.
|
||||
*/
|
||||
|
||||
import { llwsMatchNumber, llwsSideAndLocal } from "~/lib/bracket-templates";
|
||||
|
||||
/**
|
||||
* Where one participant goes after an LLWS match: a round, a side-local match number,
|
||||
* and which slot to fill. `null` means eliminated (or, for winners, no further game).
|
||||
*/
|
||||
interface LLWSDestination {
|
||||
round: string;
|
||||
localMatch: number;
|
||||
slot: "participant1Id" | "participant2Id";
|
||||
}
|
||||
|
||||
/**
|
||||
* LLWS advancement map, in SIDE-LOCAL match numbers.
|
||||
*
|
||||
* Keyed by round, then by the local match number of the completed game. Each entry
|
||||
* says where the winner goes and where the loser goes (null = eliminated).
|
||||
*
|
||||
* Verified game-by-game against the official 2026 LLBWS bracket. Note the deliberate
|
||||
* cross-overs — the elimination bracket does NOT feed straight across:
|
||||
* Elim R1: L(Opening m2) v L(Opening m3) and L(Opening m1) v L(Opening m4)
|
||||
* Elim R3: L(Semi m1) v W(Elim R2 m2) and L(Semi m2) v W(Elim R2 m1)
|
||||
* Elim R4: W(Elim R3 m1) v W(Elim R3 m2)
|
||||
*
|
||||
* A loss in the winners bracket routes into the elimination bracket rather than
|
||||
* eliminating the team; a loss in the elimination bracket is final.
|
||||
*/
|
||||
const LLWS_ADVANCEMENT: Record<
|
||||
string,
|
||||
Record<number, { winner: LLWSDestination | null; loser: LLWSDestination | null }>
|
||||
> = {
|
||||
"Opening Round": {
|
||||
1: {
|
||||
winner: { round: "Winners Round 2", localMatch: 1, slot: "participant2Id" },
|
||||
loser: { round: "Elimination Round 1", localMatch: 2, slot: "participant1Id" },
|
||||
},
|
||||
2: {
|
||||
winner: { round: "Winners Round 2", localMatch: 2, slot: "participant2Id" },
|
||||
loser: { round: "Elimination Round 1", localMatch: 1, slot: "participant1Id" },
|
||||
},
|
||||
3: {
|
||||
winner: { round: "Winners Semifinals", localMatch: 1, slot: "participant1Id" },
|
||||
loser: { round: "Elimination Round 1", localMatch: 1, slot: "participant2Id" },
|
||||
},
|
||||
4: {
|
||||
winner: { round: "Winners Semifinals", localMatch: 2, slot: "participant2Id" },
|
||||
loser: { round: "Elimination Round 1", localMatch: 2, slot: "participant2Id" },
|
||||
},
|
||||
},
|
||||
"Winners Round 2": {
|
||||
1: {
|
||||
winner: { round: "Winners Semifinals", localMatch: 1, slot: "participant2Id" },
|
||||
loser: { round: "Elimination Round 2", localMatch: 1, slot: "participant1Id" },
|
||||
},
|
||||
2: {
|
||||
winner: { round: "Winners Semifinals", localMatch: 2, slot: "participant1Id" },
|
||||
loser: { round: "Elimination Round 2", localMatch: 2, slot: "participant1Id" },
|
||||
},
|
||||
},
|
||||
"Winners Semifinals": {
|
||||
1: {
|
||||
winner: { round: "Winners Final", localMatch: 1, slot: "participant1Id" },
|
||||
loser: { round: "Elimination Round 3", localMatch: 1, slot: "participant1Id" },
|
||||
},
|
||||
2: {
|
||||
winner: { round: "Winners Final", localMatch: 1, slot: "participant2Id" },
|
||||
loser: { round: "Elimination Round 3", localMatch: 2, slot: "participant1Id" },
|
||||
},
|
||||
},
|
||||
"Winners Final": {
|
||||
1: {
|
||||
winner: { round: "Bracket Championship", localMatch: 1, slot: "participant1Id" },
|
||||
// A winners-bracket final loss is not an elimination — it drops to the
|
||||
// Elimination Final for a second chance at the side championship.
|
||||
loser: { round: "Elimination Final", localMatch: 1, slot: "participant1Id" },
|
||||
},
|
||||
},
|
||||
"Elimination Round 1": {
|
||||
1: {
|
||||
winner: { round: "Elimination Round 2", localMatch: 1, slot: "participant2Id" },
|
||||
loser: null,
|
||||
},
|
||||
2: {
|
||||
winner: { round: "Elimination Round 2", localMatch: 2, slot: "participant2Id" },
|
||||
loser: null,
|
||||
},
|
||||
},
|
||||
"Elimination Round 2": {
|
||||
// Cross-over: R2 m1's winner meets the OTHER semifinal loser.
|
||||
1: {
|
||||
winner: { round: "Elimination Round 3", localMatch: 2, slot: "participant2Id" },
|
||||
loser: null,
|
||||
},
|
||||
2: {
|
||||
winner: { round: "Elimination Round 3", localMatch: 1, slot: "participant2Id" },
|
||||
loser: null,
|
||||
},
|
||||
},
|
||||
"Elimination Round 3": {
|
||||
// The later game (m2) is printed on top: G32 = W28 v W26, G31 = W27 v W25.
|
||||
1: {
|
||||
winner: { round: "Elimination Round 4", localMatch: 1, slot: "participant2Id" },
|
||||
loser: null,
|
||||
},
|
||||
2: {
|
||||
winner: { round: "Elimination Round 4", localMatch: 1, slot: "participant1Id" },
|
||||
loser: null,
|
||||
},
|
||||
},
|
||||
"Elimination Round 4": {
|
||||
1: {
|
||||
winner: { round: "Elimination Final", localMatch: 1, slot: "participant2Id" },
|
||||
loser: null,
|
||||
},
|
||||
},
|
||||
"Elimination Final": {
|
||||
1: {
|
||||
winner: { round: "Bracket Championship", localMatch: 1, slot: "participant2Id" },
|
||||
loser: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Rounds whose losers drop into the elimination bracket instead of going out. */
|
||||
export const LLWS_LOSER_ADVANCES_ROUNDS = new Set([
|
||||
"Opening Round",
|
||||
"Winners Round 2",
|
||||
"Winners Semifinals",
|
||||
]);
|
||||
|
||||
/** A resolved LLWS destination, in global (not side-local) match numbers. */
|
||||
export interface LLWSResolvedDestination {
|
||||
round: string;
|
||||
matchNumber: number;
|
||||
slot: "participant1Id" | "participant2Id";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve where the winner and loser of a completed LLWS match go, in global match
|
||||
* numbers. `null` means that participant has no further game (eliminated, or the
|
||||
* tournament is over for them).
|
||||
*
|
||||
* Pure — no DB access — so the whole 38-game routing can be verified against the
|
||||
* official bracket in tests. advanceLLWSWinner is a thin writer on top of this.
|
||||
*/
|
||||
export function resolveLLWSAdvancement(
|
||||
round: string,
|
||||
matchNumber: number
|
||||
): { winner: LLWSResolvedDestination | null; loser: LLWSResolvedDestination | null } {
|
||||
// Terminal rounds — nobody advances.
|
||||
if (round === "Consolation Third Place" || round === "World Championship") {
|
||||
return { winner: null, loser: null };
|
||||
}
|
||||
|
||||
// Bracket Championship is the crossover: the winner goes to the World Championship
|
||||
// and the loser to the Consolation game. The side fixes the slot in both (U.S. takes
|
||||
// participant1, International participant2), so the two sides can't collide.
|
||||
if (round === "Bracket Championship") {
|
||||
const { side } = llwsSideAndLocal("Bracket Championship", matchNumber);
|
||||
const slot: "participant1Id" | "participant2Id" =
|
||||
side === 0 ? "participant1Id" : "participant2Id";
|
||||
return {
|
||||
winner: { round: "World Championship", matchNumber: 1, slot },
|
||||
loser: { round: "Consolation Third Place", matchNumber: 1, slot },
|
||||
};
|
||||
}
|
||||
|
||||
const roundMap = LLWS_ADVANCEMENT[round];
|
||||
if (!roundMap) {
|
||||
throw new Error(`Round '${round}' is not part of the LLWS bracket`);
|
||||
}
|
||||
|
||||
const { side, localMatch } = llwsSideAndLocal(round, matchNumber);
|
||||
const routes = roundMap[localMatch];
|
||||
if (!routes) {
|
||||
throw new Error(`No LLWS advancement defined for ${round} match ${matchNumber}`);
|
||||
}
|
||||
|
||||
// Winner and loser stay on their own side, so the same side offset applies to both.
|
||||
const toGlobal = (d: LLWSDestination | null): LLWSResolvedDestination | null =>
|
||||
d === null
|
||||
? null
|
||||
: { round: d.round, matchNumber: llwsMatchNumber(d.round, side, d.localMatch), slot: d.slot };
|
||||
|
||||
return { winner: toGlobal(routes.winner), loser: toGlobal(routes.loser) };
|
||||
}
|
||||
|
|
@ -1,15 +1,9 @@
|
|||
/**
|
||||
* Normalize a team/participant name for fuzzy matching across data sources.
|
||||
* Folds accents (so "Stéfanos Tsitsipás" matches a plain "Stefanos Tsitsipas"),
|
||||
* lowercases, trims, and collapses whitespace.
|
||||
* Normalize a team name for fuzzy matching across data sources.
|
||||
* Lowercases, trims, and collapses whitespace.
|
||||
*/
|
||||
export function normalizeTeamName(name: string): string {
|
||||
return name
|
||||
.normalize("NFKD")
|
||||
.replace(/[̀-ͯ]/g, "") // strip combining diacritical marks
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/\s+/g, " ");
|
||||
return name.toLowerCase().trim().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -7,8 +7,7 @@
|
|||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { AFL_10, getScoringRoundType } from "~/lib/bracket-templates";
|
||||
import { calculateFantasyPoints, calculateAveragedPoints, calculateBracketPoints, type ScoringRules } from "../scoring-rules";
|
||||
import { getBracketEntryFloor } from "../scoring-calculator";
|
||||
import { calculateFantasyPoints, calculateAveragedPoints, type ScoringRules } from "../scoring-rules";
|
||||
|
||||
const DEFAULT_SCORING: ScoringRules = {
|
||||
pointsFor1st: 100,
|
||||
|
|
@ -207,69 +206,3 @@ describe("AFL Finals System - Phase 3.3", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("AFL guaranteed floors from seeding (afl_10)", () => {
|
||||
const byName = (name: string) => AFL_10.rounds.find((r) => r.name === name);
|
||||
|
||||
describe("getBracketEntryFloor — banked the moment the bracket is set", () => {
|
||||
it("gives seeds 1-4 the 5th-6th tier: the double chance is locked in at seeding", () => {
|
||||
// Worst case for a top-4 seed is lose the Qualifying Final, then lose the
|
||||
// Semi-Final — which is the 5th-6th tier. They can never finish below it.
|
||||
expect(getBracketEntryFloor("Qualifying Finals", "afl_10")).toBe(5);
|
||||
expect(calculateBracketPoints(5, DEFAULT_SCORING, "afl_10")).toBe(25);
|
||||
});
|
||||
|
||||
it("gives seeds 5-6 the 7th-8th tier: they are seeded straight into a scoring round", () => {
|
||||
expect(getBracketEntryFloor("Elimination Finals", "afl_10")).toBe(7);
|
||||
expect(calculateBracketPoints(7, DEFAULT_SCORING, "afl_10")).toBe(15);
|
||||
});
|
||||
|
||||
it("gives seeds 7-10 nothing: a Wildcard loss is worth 0", () => {
|
||||
expect(getBracketEntryFloor("Wildcard Round", "afl_10")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the template is unknown or missing", () => {
|
||||
expect(getBracketEntryFloor("Qualifying Finals", null)).toBeNull();
|
||||
expect(getBracketEntryFloor("Qualifying Finals", "not_a_template")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not hand out floors for TBD rounds nobody is seeded into yet", () => {
|
||||
// These rounds do carry a loser tier, but every slot is empty at generation,
|
||||
// so applyBracketEntryFloors has no participant to write against.
|
||||
expect(byName("Semi-Finals")?.entryFloor).toBeUndefined();
|
||||
expect(byName("Preliminary Finals")?.entryFloor).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("nonScoringWinnerFloor — the generic top-8 default is wrong for both AFL non-scoring rounds", () => {
|
||||
it("Qualifying Finals winners bank 3, not 5 — the bye means a Prelim loss is 3rd-4th", () => {
|
||||
expect(byName("Qualifying Finals")?.nonScoringWinnerFloor).toBe(3);
|
||||
});
|
||||
|
||||
it("Wildcard winners bank 7, not 5 — winning only buys an Elimination Final", () => {
|
||||
expect(byName("Wildcard Round")?.nonScoringWinnerFloor).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("floors only ever improve along every AFL path", () => {
|
||||
const pts = (position: number) => calculateBracketPoints(position, DEFAULT_SCORING, "afl_10");
|
||||
|
||||
it("top-4 seed: entry 5 → QF win 3 → PF win 2 → GF win 1", () => {
|
||||
expect(pts(5)).toBeLessThan(pts(3));
|
||||
expect(pts(3)).toBeLessThan(pts(2));
|
||||
expect(pts(2)).toBeLessThan(pts(1));
|
||||
});
|
||||
|
||||
it("top-4 seed losing the QF holds the entry floor, then finalizes at 5th-6th", () => {
|
||||
// QF losers advance to the Semi-Final, so nothing is written at the QF —
|
||||
// the entry floor of 5 carries them until the Semi-Final resolves.
|
||||
const entryFloor = getBracketEntryFloor("Qualifying Finals", "afl_10");
|
||||
expect(entryFloor).toBe(5);
|
||||
expect(pts(entryFloor ?? 0)).toBe(25); // unchanged by the loss
|
||||
});
|
||||
|
||||
it("seeds 5-6 and Wildcard winners share a 7th-8th floor, below the top-4's", () => {
|
||||
expect(pts(7)).toBeLessThan(pts(5));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,273 +0,0 @@
|
|||
/**
|
||||
* Advancing an AFL Elimination Finals winner into the Semi-Finals.
|
||||
*
|
||||
* Unlike the Wildcard Round, this pathway is fixed: Elimination Final n feeds Semi-Final
|
||||
* n. The crossover comes a round later, at Semi-Finals → Preliminary Finals, so that a
|
||||
* Qualifying Final loser cannot meet the side that just beat it.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { AFL_10 } from "~/lib/bracket-templates";
|
||||
|
||||
interface MatchRow {
|
||||
id: string;
|
||||
scoringEventId: string;
|
||||
round: string;
|
||||
matchNumber: number;
|
||||
participant1Id: string | null;
|
||||
participant2Id: string | null;
|
||||
isComplete: boolean;
|
||||
winnerId: string | null;
|
||||
loserId: string | null;
|
||||
}
|
||||
|
||||
let rows: MatchRow[] = [];
|
||||
|
||||
/**
|
||||
* The literal values drizzle put in a where clause (`eq(col, value)`), which is all this
|
||||
* mock needs to tell one lookup from another — there is no query engine behind it.
|
||||
*/
|
||||
function whereValues(node: unknown, depth = 0): string[] {
|
||||
if (!node || depth > 10) return [];
|
||||
if (Array.isArray(node)) return node.flatMap((child) => whereValues(child, depth + 1));
|
||||
if (typeof node !== "object") return [];
|
||||
const obj = node as Record<string, unknown>;
|
||||
const own = typeof obj.value === "string" ? [obj.value] : [];
|
||||
return [...own, ...whereValues(obj.queryChunks, depth + 1)];
|
||||
}
|
||||
|
||||
const db = {
|
||||
query: {
|
||||
playoffMatches: {
|
||||
findFirst: vi.fn(({ where }: { where: unknown }) => {
|
||||
const values = whereValues(where);
|
||||
return Promise.resolve(rows.find((r) => values.includes(r.id)));
|
||||
}),
|
||||
findMany: vi.fn(({ where }: { where: unknown }) => {
|
||||
const values = whereValues(where);
|
||||
return Promise.resolve(
|
||||
rows
|
||||
.filter((r) => values.includes(r.scoringEventId) && values.includes(r.round))
|
||||
.toSorted((a, b) => a.matchNumber - b.matchNumber)
|
||||
);
|
||||
}),
|
||||
},
|
||||
},
|
||||
update: vi.fn(() => ({
|
||||
set: (data: Partial<MatchRow>) => {
|
||||
const applyTo = (where: unknown) => {
|
||||
const values = whereValues(where);
|
||||
const target = rows.find((r) => values.includes(r.id));
|
||||
if (target) Object.assign(target, data);
|
||||
return target;
|
||||
};
|
||||
// Advancement writes through the query builder with and without .returning().
|
||||
return {
|
||||
where: (where: unknown) => {
|
||||
const applied = Promise.resolve([applyTo(where)]);
|
||||
return Object.assign(applied, { returning: () => applied });
|
||||
},
|
||||
};
|
||||
},
|
||||
})),
|
||||
// No rollback: the tests assert the writes that were attempted, in order.
|
||||
transaction: vi.fn((fn: (tx: typeof db) => Promise<unknown>) => fn(db)),
|
||||
};
|
||||
|
||||
vi.mock("~/database/context", () => ({ database: () => db }));
|
||||
|
||||
const { advanceWinnerTemplate, reseedAflSemiFinals } = await import("../playoff-match");
|
||||
|
||||
const EVENT = "event-1";
|
||||
|
||||
/**
|
||||
* The real 2026 finals, which is what surfaced the crossover bug. Ladder: 1 Fremantle,
|
||||
* 2 Sydney, 3 Brisbane, 4 Hawthorn, 5 Geelong, 6 Adelaide, 7 Melbourne, 8 Bulldogs,
|
||||
* 9 Collingwood, 10 Carlton. Carlton (10th) and the Bulldogs (8th) came through the
|
||||
* Wildcard Round, so 5th hosts Carlton and 6th hosts the Bulldogs.
|
||||
*/
|
||||
const FREO = "fremantle";
|
||||
const SYDNEY = "sydney";
|
||||
const BRISBANE = "brisbane";
|
||||
const HAWTHORN = "hawthorn";
|
||||
const GEELONG = "geelong";
|
||||
const ADELAIDE = "adelaide";
|
||||
const BULLDOGS = "bulldogs";
|
||||
const CARLTON = "carlton";
|
||||
|
||||
/** An afl_10 bracket with week one played: Freo and Brisbane lost their Qualifying Finals. */
|
||||
function bracket(): MatchRow[] {
|
||||
const base = { scoringEventId: EVENT, isComplete: false, winnerId: null, loserId: null };
|
||||
return [
|
||||
{ ...base, id: "qf1", round: "Qualifying Finals", matchNumber: 1, participant1Id: FREO, participant2Id: HAWTHORN, isComplete: true, winnerId: HAWTHORN, loserId: FREO },
|
||||
{ ...base, id: "qf2", round: "Qualifying Finals", matchNumber: 2, participant1Id: SYDNEY, participant2Id: BRISBANE, isComplete: true, winnerId: SYDNEY, loserId: BRISBANE },
|
||||
{ ...base, id: "ef1", round: "Elimination Finals", matchNumber: 1, participant1Id: GEELONG, participant2Id: CARLTON },
|
||||
{ ...base, id: "ef2", round: "Elimination Finals", matchNumber: 2, participant1Id: ADELAIDE, participant2Id: BULLDOGS },
|
||||
// Filled by the Qualifying Final losers, as advancement already does.
|
||||
{ ...base, id: "sf1", round: "Semi-Finals", matchNumber: 1, participant1Id: FREO, participant2Id: null },
|
||||
{ ...base, id: "sf2", round: "Semi-Finals", matchNumber: 2, participant1Id: BRISBANE, participant2Id: null },
|
||||
{ ...base, id: "pf1", round: "Preliminary Finals", matchNumber: 1, participant1Id: HAWTHORN, participant2Id: null },
|
||||
{ ...base, id: "pf2", round: "Preliminary Finals", matchNumber: 2, participant1Id: SYDNEY, participant2Id: null },
|
||||
];
|
||||
}
|
||||
|
||||
function row(id: string): MatchRow {
|
||||
const found = rows.find((r) => r.id === id);
|
||||
if (!found) throw new Error(`No such match ${id}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Record a result the way setMatchWinner does, then advance it. */
|
||||
async function win(id: string, winnerId: string) {
|
||||
const match = row(id);
|
||||
match.winnerId = winnerId;
|
||||
match.loserId = match.participant1Id === winnerId ? match.participant2Id : match.participant1Id;
|
||||
match.isComplete = true;
|
||||
await advanceWinnerTemplate(id, winnerId, AFL_10);
|
||||
}
|
||||
|
||||
const pairing = () => ({
|
||||
sf1: [row("sf1").participant1Id, row("sf1").participant2Id],
|
||||
sf2: [row("sf2").participant1Id, row("sf2").participant2Id],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
rows = bracket();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Elimination Finals → Semi-Finals advancement", () => {
|
||||
it("feeds Elimination Final 1 into Semi-Final 1", async () => {
|
||||
await win("ef1", GEELONG);
|
||||
|
||||
expect(row("sf1").participant2Id).toBe(GEELONG);
|
||||
expect(row("sf2").participant2Id).toBeNull();
|
||||
});
|
||||
|
||||
it("feeds Elimination Final 2 into Semi-Final 2", async () => {
|
||||
await win("ef2", ADELAIDE);
|
||||
|
||||
expect(row("sf2").participant2Id).toBe(ADELAIDE);
|
||||
expect(row("sf1").participant2Id).toBeNull();
|
||||
});
|
||||
|
||||
it("draws the real 2026 Semi-Finals: Freo v Geelong and Brisbane v Adelaide", async () => {
|
||||
await win("ef1", GEELONG);
|
||||
await win("ef2", ADELAIDE);
|
||||
|
||||
expect(pairing()).toEqual({
|
||||
sf1: [FREO, GEELONG],
|
||||
sf2: [BRISBANE, ADELAIDE],
|
||||
});
|
||||
});
|
||||
|
||||
it("draws the same Semi-Finals whichever order the results are entered", async () => {
|
||||
await win("ef2", ADELAIDE);
|
||||
await win("ef1", GEELONG);
|
||||
|
||||
expect(pairing()).toEqual({
|
||||
sf1: [FREO, GEELONG],
|
||||
sf2: [BRISBANE, ADELAIDE],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the Preliminary Finals crossover so a QF loser dodges the side that beat it", async () => {
|
||||
await win("ef1", GEELONG);
|
||||
await win("ef2", ADELAIDE);
|
||||
// Freo (lost QF1 to Hawthorn) wins its semi, so it must land in Sydney's Prelim.
|
||||
await win("sf1", FREO);
|
||||
|
||||
expect(row("pf2").participant2Id).toBe(FREO);
|
||||
expect(row("pf1").participant2Id).toBeNull();
|
||||
});
|
||||
|
||||
it("pulls the beaten team back out when an Elimination Final result is corrected", async () => {
|
||||
await win("ef1", GEELONG);
|
||||
expect(row("sf1").participant2Id).toBe(GEELONG);
|
||||
|
||||
await win("ef1", CARLTON);
|
||||
|
||||
expect(row("sf1").participant2Id).toBe(CARLTON);
|
||||
expect(row("sf2").participant2Id).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reseedAflSemiFinals", () => {
|
||||
it("repairs a bracket left crossed by the old fixed crossover", async () => {
|
||||
// What advancement wrote before the fix: EF1 winner into SF2, EF2 winner into SF1.
|
||||
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
|
||||
Object.assign(row("ef2"), { isComplete: true, winnerId: ADELAIDE, loserId: BULLDOGS });
|
||||
row("sf1").participant2Id = ADELAIDE;
|
||||
row("sf2").participant2Id = GEELONG;
|
||||
|
||||
const reseed = await reseedAflSemiFinals(EVENT);
|
||||
|
||||
expect(pairing()).toEqual({
|
||||
sf1: [FREO, GEELONG],
|
||||
sf2: [BRISBANE, ADELAIDE],
|
||||
});
|
||||
expect(reseed.vacated.toSorted()).toEqual([1, 2]);
|
||||
expect(reseed.filled.toSorted((a, b) => a.matchNumber - b.matchNumber)).toEqual([
|
||||
{ matchNumber: 1, participantId: GEELONG },
|
||||
{ matchNumber: 2, participantId: ADELAIDE },
|
||||
]);
|
||||
});
|
||||
|
||||
it("writes nothing when the pairings are already right", async () => {
|
||||
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
|
||||
Object.assign(row("ef2"), { isComplete: true, winnerId: ADELAIDE, loserId: BULLDOGS });
|
||||
row("sf1").participant2Id = GEELONG;
|
||||
row("sf2").participant2Id = ADELAIDE;
|
||||
|
||||
const reseed = await reseedAflSemiFinals(EVENT);
|
||||
|
||||
expect(reseed).toEqual({ vacated: [], filled: [] });
|
||||
expect(db.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves an undecided Elimination Final's slot TBD", async () => {
|
||||
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
|
||||
|
||||
await reseedAflSemiFinals(EVENT);
|
||||
|
||||
expect(row("sf1").participant2Id).toBe(GEELONG);
|
||||
expect(row("sf2").participant2Id).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a slot held by someone who never played an Elimination Final", async () => {
|
||||
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
|
||||
row("sf1").participant2Id = SYDNEY;
|
||||
|
||||
await expect(reseedAflSemiFinals(EVENT)).rejects.toThrow("SF 1 participant2 already filled");
|
||||
});
|
||||
|
||||
it("refuses to move a qualifier out of a Semi-Final that has been played", async () => {
|
||||
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
|
||||
Object.assign(row("sf1"), {
|
||||
participant2Id: ADELAIDE,
|
||||
isComplete: true,
|
||||
winnerId: FREO,
|
||||
loserId: ADELAIDE,
|
||||
});
|
||||
|
||||
await expect(reseedAflSemiFinals(EVENT)).rejects.toThrow(
|
||||
"Semi-Finals match 1 already has a recorded result"
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an Elimination Final winner who is not one of its participants", async () => {
|
||||
Object.assign(row("ef1"), { isComplete: true, winnerId: SYDNEY, loserId: CARLTON });
|
||||
|
||||
await expect(reseedAflSemiFinals(EVENT)).rejects.toThrow(
|
||||
"Elimination Finals match 1 winner is not one of its participants"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on an event with no Semi-Finals to re-seed", async () => {
|
||||
rows = rows.filter((r) => r.round !== "Semi-Finals");
|
||||
|
||||
await expect(reseedAflSemiFinals(EVENT)).rejects.toThrow(
|
||||
"no AFL Elimination Finals / Semi-Finals matches to re-seed"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,271 +0,0 @@
|
|||
/**
|
||||
* Advancing an AFL Wildcard Round winner into the Elimination Finals.
|
||||
*
|
||||
* The two winners are re-seeded by ladder position — 5th hosts the lower-ranked winner,
|
||||
* 6th the higher-ranked one — so the destination is not a fixed crossover from a given
|
||||
* Wildcard match, and results can be recorded in either order.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { AFL_10 } from "~/lib/bracket-templates";
|
||||
|
||||
interface MatchRow {
|
||||
id: string;
|
||||
scoringEventId: string;
|
||||
round: string;
|
||||
matchNumber: number;
|
||||
participant1Id: string | null;
|
||||
participant2Id: string | null;
|
||||
isComplete: boolean;
|
||||
winnerId: string | null;
|
||||
loserId: string | null;
|
||||
}
|
||||
|
||||
let rows: MatchRow[] = [];
|
||||
|
||||
/**
|
||||
* The literal values drizzle put in a where clause (`eq(col, value)`), which is all this
|
||||
* mock needs to tell one lookup from another — there is no query engine behind it.
|
||||
*/
|
||||
function whereValues(node: unknown, depth = 0): string[] {
|
||||
if (!node || depth > 10) return [];
|
||||
if (Array.isArray(node)) return node.flatMap((child) => whereValues(child, depth + 1));
|
||||
if (typeof node !== "object") return [];
|
||||
const obj = node as Record<string, unknown>;
|
||||
const own = typeof obj.value === "string" ? [obj.value] : [];
|
||||
return [...own, ...whereValues(obj.queryChunks, depth + 1)];
|
||||
}
|
||||
|
||||
const db = {
|
||||
query: {
|
||||
playoffMatches: {
|
||||
findFirst: vi.fn(({ where }: { where: unknown }) => {
|
||||
const values = whereValues(where);
|
||||
return Promise.resolve(rows.find((r) => values.includes(r.id)));
|
||||
}),
|
||||
findMany: vi.fn(({ where }: { where: unknown }) => {
|
||||
const values = whereValues(where);
|
||||
return Promise.resolve(
|
||||
rows
|
||||
.filter((r) => values.includes(r.scoringEventId) && values.includes(r.round))
|
||||
.toSorted((a, b) => a.matchNumber - b.matchNumber)
|
||||
);
|
||||
}),
|
||||
},
|
||||
},
|
||||
update: vi.fn(() => ({
|
||||
set: (data: Partial<MatchRow>) => {
|
||||
const applyTo = (where: unknown) => {
|
||||
const values = whereValues(where);
|
||||
const target = rows.find((r) => values.includes(r.id));
|
||||
if (target) Object.assign(target, data);
|
||||
return target;
|
||||
};
|
||||
// Advancement writes through the query builder with and without .returning().
|
||||
return {
|
||||
where: (where: unknown) => {
|
||||
const applied = Promise.resolve([applyTo(where)]);
|
||||
return Object.assign(applied, { returning: () => applied });
|
||||
},
|
||||
};
|
||||
},
|
||||
})),
|
||||
// No rollback: the tests assert the writes that were attempted, in order.
|
||||
transaction: vi.fn((fn: (tx: typeof db) => Promise<unknown>) => fn(db)),
|
||||
};
|
||||
|
||||
vi.mock("~/database/context", () => ({ database: () => db }));
|
||||
|
||||
const { advanceWinnerTemplate, reseedAflEliminationFinals } = await import("../playoff-match");
|
||||
|
||||
const EVENT = "event-1";
|
||||
|
||||
/** Ladder seed n → participant id. */
|
||||
const seed = (n: number) => `seed-${n}`;
|
||||
|
||||
/** A freshly generated afl_10 Wildcard Round (7v10, 8v9) and Elimination Finals (5, 6). */
|
||||
function bracket(): MatchRow[] {
|
||||
const base = { scoringEventId: EVENT, isComplete: false, winnerId: null, loserId: null };
|
||||
return [
|
||||
{ ...base, id: "wc1", round: "Wildcard Round", matchNumber: 1, participant1Id: seed(7), participant2Id: seed(10) },
|
||||
{ ...base, id: "wc2", round: "Wildcard Round", matchNumber: 2, participant1Id: seed(8), participant2Id: seed(9) },
|
||||
{ ...base, id: "ef1", round: "Elimination Finals", matchNumber: 1, participant1Id: seed(5), participant2Id: null },
|
||||
{ ...base, id: "ef2", round: "Elimination Finals", matchNumber: 2, participant1Id: seed(6), participant2Id: null },
|
||||
];
|
||||
}
|
||||
|
||||
function row(id: string): MatchRow {
|
||||
const found = rows.find((r) => r.id === id);
|
||||
if (!found) throw new Error(`No such match ${id}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Record a Wildcard result the way setMatchWinner does, then advance it. */
|
||||
async function winWildcard(id: string, winnerId: string) {
|
||||
const match = row(id);
|
||||
match.winnerId = winnerId;
|
||||
match.loserId = match.participant1Id === winnerId ? match.participant2Id : match.participant1Id;
|
||||
match.isComplete = true;
|
||||
await advanceWinnerTemplate(id, winnerId, AFL_10);
|
||||
}
|
||||
|
||||
describe("AFL Wildcard Round advancement", () => {
|
||||
beforeEach(() => {
|
||||
rows = bracket();
|
||||
});
|
||||
|
||||
it("sends 5th the lower-ranked winner and 6th the higher-ranked one", async () => {
|
||||
await winWildcard("wc1", seed(7));
|
||||
await winWildcard("wc2", seed(8));
|
||||
|
||||
expect(row("ef1").participant2Id).toBe(seed(8));
|
||||
expect(row("ef2").participant2Id).toBe(seed(7));
|
||||
});
|
||||
|
||||
it("re-seeds when the lower seed wins through", async () => {
|
||||
// The reported bug: 10th beating 7th used to be crossed straight to 6th, leaving
|
||||
// 5th with the better survivor.
|
||||
await winWildcard("wc1", seed(10));
|
||||
await winWildcard("wc2", seed(8));
|
||||
|
||||
expect(row("ef1").participant2Id).toBe(seed(10));
|
||||
expect(row("ef2").participant2Id).toBe(seed(8));
|
||||
});
|
||||
|
||||
it("re-seeds a 9th-placed winner above a 10th-placed one", async () => {
|
||||
await winWildcard("wc1", seed(10));
|
||||
await winWildcard("wc2", seed(9));
|
||||
|
||||
expect(row("ef1").participant2Id).toBe(seed(10));
|
||||
expect(row("ef2").participant2Id).toBe(seed(9));
|
||||
});
|
||||
|
||||
it("places the same pairings whichever result is entered first", async () => {
|
||||
await winWildcard("wc2", seed(8));
|
||||
await winWildcard("wc1", seed(10));
|
||||
|
||||
expect(row("ef1").participant2Id).toBe(seed(10));
|
||||
expect(row("ef2").participant2Id).toBe(seed(8));
|
||||
});
|
||||
|
||||
it("places the 7v10 winner immediately, since its slot is settled either way", async () => {
|
||||
await winWildcard("wc1", seed(7));
|
||||
|
||||
expect(row("ef2").participant2Id).toBe(seed(7));
|
||||
expect(row("ef1").participant2Id).toBeNull();
|
||||
});
|
||||
|
||||
it("holds an 8v9 winner back until the 7v10 game is decided", async () => {
|
||||
// 8th and 9th sit between 7th and 10th, so placing one now could need undoing.
|
||||
await winWildcard("wc2", seed(8));
|
||||
|
||||
expect(row("ef1").participant2Id).toBeNull();
|
||||
expect(row("ef2").participant2Id).toBeNull();
|
||||
});
|
||||
|
||||
it("does not disturb a winner it already placed", async () => {
|
||||
await winWildcard("wc1", seed(7));
|
||||
await winWildcard("wc2", seed(9));
|
||||
|
||||
expect(row("ef2").participant2Id).toBe(seed(7));
|
||||
expect(row("ef1").participant2Id).toBe(seed(9));
|
||||
});
|
||||
|
||||
it("refuses to overwrite a slot already holding someone else", async () => {
|
||||
row("ef1").participant2Id = "stranger";
|
||||
|
||||
await expect(winWildcard("wc1", seed(10))).rejects.toThrow(/already filled/);
|
||||
expect(row("ef1").participant2Id).toBe("stranger");
|
||||
});
|
||||
|
||||
it("moves the winner when a recorded Wildcard result is corrected", async () => {
|
||||
await winWildcard("wc1", seed(7));
|
||||
expect(row("ef2").participant2Id).toBe(seed(7));
|
||||
|
||||
// The result was wrong: 10th won. 7th must not be left alive in the other slot.
|
||||
await winWildcard("wc1", seed(10));
|
||||
|
||||
expect(row("ef1").participant2Id).toBe(seed(10));
|
||||
expect(row("ef2").participant2Id).toBeNull();
|
||||
});
|
||||
|
||||
it("re-seeds a pairing left behind by the old fixed crossover", async () => {
|
||||
// Pre-fix state: the 7v10 winner was crossed to 6th whatever its ladder position.
|
||||
row("wc1").winnerId = seed(10);
|
||||
row("wc1").loserId = seed(7);
|
||||
row("wc1").isComplete = true;
|
||||
row("ef2").participant2Id = seed(10);
|
||||
|
||||
await winWildcard("wc2", seed(8));
|
||||
|
||||
expect(row("ef1").participant2Id).toBe(seed(10));
|
||||
expect(row("ef2").participant2Id).toBe(seed(8));
|
||||
});
|
||||
|
||||
it("swaps both winners when re-resolving an already-placed pair", async () => {
|
||||
row("wc1").winnerId = seed(10);
|
||||
row("wc1").loserId = seed(7);
|
||||
row("wc1").isComplete = true;
|
||||
row("ef2").participant2Id = seed(10);
|
||||
row("ef1").participant2Id = seed(8);
|
||||
|
||||
await winWildcard("wc2", seed(8));
|
||||
|
||||
expect(row("ef1").participant2Id).toBe(seed(10));
|
||||
expect(row("ef2").participant2Id).toBe(seed(8));
|
||||
});
|
||||
|
||||
it("refuses to re-seed an Elimination Final that has already been played", async () => {
|
||||
await winWildcard("wc1", seed(7));
|
||||
Object.assign(row("ef2"), { isComplete: true, winnerId: seed(6), loserId: seed(7) });
|
||||
|
||||
await expect(winWildcard("wc1", seed(10))).rejects.toThrow(/already has a recorded result/);
|
||||
expect(row("ef2").participant2Id).toBe(seed(7));
|
||||
});
|
||||
|
||||
it("repairs an already-advanced bracket from the recorded results alone", async () => {
|
||||
// What scripts/fix-afl-wildcard-reseed.ts does: no new result, just the rows a
|
||||
// bracket advanced under the old fixed crossover left behind.
|
||||
Object.assign(row("wc1"), { isComplete: true, winnerId: seed(10), loserId: seed(7) });
|
||||
Object.assign(row("wc2"), { isComplete: true, winnerId: seed(8), loserId: seed(9) });
|
||||
row("ef2").participant2Id = seed(10);
|
||||
row("ef1").participant2Id = seed(8);
|
||||
|
||||
const reseed = await reseedAflEliminationFinals(EVENT);
|
||||
|
||||
expect(row("ef1").participant2Id).toBe(seed(10));
|
||||
expect(row("ef2").participant2Id).toBe(seed(8));
|
||||
expect(reseed.vacated.toSorted()).toEqual([1, 2]);
|
||||
expect(reseed.filled.toSorted((a, b) => a.matchNumber - b.matchNumber)).toEqual([
|
||||
{ matchNumber: 1, participantId: seed(10) },
|
||||
{ matchNumber: 2, participantId: seed(8) },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports no change when a repair run finds the pairings correct", async () => {
|
||||
await winWildcard("wc1", seed(7));
|
||||
await winWildcard("wc2", seed(8));
|
||||
db.transaction.mockClear();
|
||||
|
||||
const reseed = await reseedAflEliminationFinals(EVENT);
|
||||
|
||||
expect(reseed).toEqual({ vacated: [], filled: [] });
|
||||
expect(db.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an event with no AFL bracket rather than reporting nothing to do", async () => {
|
||||
await expect(reseedAflEliminationFinals("no-such-event")).rejects.toThrow(/no AFL Wildcard/);
|
||||
});
|
||||
|
||||
it("leaves the bracket alone when the pairings are already right", async () => {
|
||||
await winWildcard("wc1", seed(7));
|
||||
await winWildcard("wc2", seed(8));
|
||||
db.transaction.mockClear();
|
||||
|
||||
await winWildcard("wc2", seed(8));
|
||||
|
||||
expect(db.transaction).not.toHaveBeenCalled();
|
||||
expect(row("ef1").participant2Id).toBe(seed(8));
|
||||
expect(row("ef2").participant2Id).toBe(seed(7));
|
||||
});
|
||||
});
|
||||
|
|
@ -1,243 +0,0 @@
|
|||
/**
|
||||
* Entry-floor scoring: points a bracket guarantees at seeding time.
|
||||
*
|
||||
* Some seedings lock in a scoring tier before a single match is played. The AFL
|
||||
* finals are the clearest case: a top-4 seed has the double chance, so losing the
|
||||
* Qualifying Final still leaves them a Semi-Final, and losing that is the 5th-6th
|
||||
* tier. Those teams must not sit on 0 fantasy points until their first game.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
interface MatchRow {
|
||||
round: string;
|
||||
participant1Id: string | null;
|
||||
participant2Id: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal db mock. applyBracketEntryFloors calls, in order:
|
||||
* 1. db.query.scoringEvents.findFirst → the event (for template + sportsSeasonId)
|
||||
* 2. db.query.playoffMatches.findMany → the bracket's match slots
|
||||
* 3. upsertParticipantResult per floored participant → findFirst + insert/update
|
||||
*/
|
||||
function makeDb(
|
||||
event: { bracketTemplateId: string | null; sportsSeasonId: string } | null,
|
||||
matches: MatchRow[],
|
||||
existingByParticipant: Record<string, { id: string; finalPosition: number; isPartialScore: boolean }> = {}
|
||||
) {
|
||||
const existingRows = Object.entries(existingByParticipant).map(([participantId, row]) => ({
|
||||
participantId,
|
||||
finalPosition: row.finalPosition,
|
||||
}));
|
||||
const insertedRows: Array<Record<string, unknown>> = [];
|
||||
const updatedRows: Array<Record<string, unknown>> = [];
|
||||
|
||||
return {
|
||||
db: {
|
||||
insert: vi.fn().mockReturnValue({
|
||||
values: vi.fn().mockImplementation((values: Record<string, unknown>) => {
|
||||
insertedRows.push(values);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
}),
|
||||
update: vi.fn().mockReturnValue({
|
||||
set: vi.fn().mockImplementation((values: Record<string, unknown>) => {
|
||||
updatedRows.push(values);
|
||||
return { where: vi.fn().mockResolvedValue(undefined) };
|
||||
}),
|
||||
}),
|
||||
query: {
|
||||
scoringEvents: { findFirst: vi.fn().mockResolvedValue(event) },
|
||||
playoffMatches: { findMany: vi.fn().mockResolvedValue(matches) },
|
||||
seasonParticipantResults: {
|
||||
// The pre-pass that stops a floor from downgrading an existing placement.
|
||||
findMany: vi.fn().mockResolvedValue(existingRows),
|
||||
findFirst: vi.fn().mockImplementation((args: { where?: unknown }) => {
|
||||
// Resolve by scanning the seeded map — the mock has no real query engine,
|
||||
// so tests that need an existing row use a single-participant bracket.
|
||||
void args;
|
||||
const only = Object.values(existingByParticipant)[0];
|
||||
return Promise.resolve(only);
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
insertedRows,
|
||||
updatedRows,
|
||||
};
|
||||
}
|
||||
|
||||
import { applyBracketEntryFloors, getBracketEntryFloor } from "../scoring-calculator";
|
||||
|
||||
/** The AFL bracket exactly as generateAFL10Bracket writes it: later rounds are TBD. */
|
||||
const AFL_BRACKET: MatchRow[] = [
|
||||
{ round: "Wildcard Round", participant1Id: "seed7", participant2Id: "seed10" },
|
||||
{ round: "Wildcard Round", participant1Id: "seed8", participant2Id: "seed9" },
|
||||
{ round: "Qualifying Finals", participant1Id: "seed1", participant2Id: "seed4" },
|
||||
{ round: "Qualifying Finals", participant1Id: "seed2", participant2Id: "seed3" },
|
||||
{ round: "Elimination Finals", participant1Id: "seed5", participant2Id: null },
|
||||
{ round: "Elimination Finals", participant1Id: "seed6", participant2Id: null },
|
||||
{ round: "Semi-Finals", participant1Id: null, participant2Id: null },
|
||||
{ round: "Semi-Finals", participant1Id: null, participant2Id: null },
|
||||
{ round: "Preliminary Finals", participant1Id: null, participant2Id: null },
|
||||
{ round: "Preliminary Finals", participant1Id: null, participant2Id: null },
|
||||
{ round: "Grand Final", participant1Id: null, participant2Id: null },
|
||||
];
|
||||
|
||||
describe("applyBracketEntryFloors", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("afl_10", () => {
|
||||
it("banks 5 for the top 4 and 7 for seeds 5-6, and nothing for the wildcard teams", async () => {
|
||||
const { db, insertedRows } = makeDb(
|
||||
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
|
||||
AFL_BRACKET
|
||||
);
|
||||
|
||||
const applied = await applyBracketEntryFloors("event-1", db);
|
||||
|
||||
expect(applied).toBe(6);
|
||||
const floors = Object.fromEntries(
|
||||
insertedRows.map((r) => [r.participantId as string, r.finalPosition as number])
|
||||
);
|
||||
expect(floors).toEqual({
|
||||
seed1: 5, seed2: 5, seed3: 5, seed4: 5, // double chance → 5th-6th tier
|
||||
seed5: 7, seed6: 7, // seeded into the Elimination Finals
|
||||
});
|
||||
// Seeds 7-10 lose the Wildcard Round for 0, so nothing is guaranteed yet.
|
||||
expect(floors).not.toHaveProperty("seed7");
|
||||
expect(floors).not.toHaveProperty("seed10");
|
||||
});
|
||||
|
||||
it("writes every floor as provisional so real results supersede it", async () => {
|
||||
const { db, insertedRows } = makeDb(
|
||||
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
|
||||
AFL_BRACKET
|
||||
);
|
||||
|
||||
await applyBracketEntryFloors("event-1", db);
|
||||
|
||||
expect(insertedRows.every((r) => r.isPartialScore === true)).toBe(true);
|
||||
expect(insertedRows.every((r) => r.sportsSeasonId === "ss-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves TBD slots alone — a Semi-Final nobody has reached grants nothing", async () => {
|
||||
const { db, insertedRows } = makeDb(
|
||||
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
|
||||
[{ round: "Semi-Finals", participant1Id: null, participant2Id: null }]
|
||||
);
|
||||
|
||||
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
|
||||
expect(insertedRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("never downgrades a better placement — a finalist regenerating stays a finalist", async () => {
|
||||
// clear-bracket → generate-bracket mid-tournament must not knock a team sitting
|
||||
// on a 2nd-place floor back down to their 5th-6th seeding floor.
|
||||
const { db, insertedRows, updatedRows } = makeDb(
|
||||
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
|
||||
[{ round: "Qualifying Finals", participant1Id: "seed1", participant2Id: null }],
|
||||
{ seed1: { id: "row-1", finalPosition: 2, isPartialScore: true } }
|
||||
);
|
||||
|
||||
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
|
||||
expect(insertedRows).toHaveLength(0);
|
||||
expect(updatedRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("treats position 0 as eliminated, not as a better placement", async () => {
|
||||
// A 0 means "missed the bracket". Re-seeding a team into the bracket must still
|
||||
// give them their floor rather than reading 0 as an unbeatable placement.
|
||||
const { db, updatedRows } = makeDb(
|
||||
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
|
||||
[{ round: "Qualifying Finals", participant1Id: "seed1", participant2Id: null }],
|
||||
{ seed1: { id: "row-1", finalPosition: 0, isPartialScore: true } }
|
||||
);
|
||||
|
||||
expect(await applyBracketEntryFloors("event-1", db)).toBe(1);
|
||||
expect(updatedRows).toHaveLength(1);
|
||||
expect(updatedRows[0]).toMatchObject({ finalPosition: 5, isPartialScore: true });
|
||||
});
|
||||
|
||||
it("does not un-finalize a participant who already has a real result", async () => {
|
||||
// upsertParticipantResult's never-un-finalize guard: a finalized row must not be
|
||||
// dragged back to a provisional floor when the bracket is regenerated.
|
||||
const { db, insertedRows, updatedRows } = makeDb(
|
||||
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
|
||||
[{ round: "Qualifying Finals", participant1Id: "seed1", participant2Id: null }],
|
||||
{ seed1: { id: "row-1", finalPosition: 1, isPartialScore: false } }
|
||||
);
|
||||
|
||||
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
|
||||
expect(insertedRows).toHaveLength(0);
|
||||
expect(updatedRows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("other brackets", () => {
|
||||
it("is a no-op for an event with no bracket template", async () => {
|
||||
const { db, insertedRows } = makeDb(
|
||||
{ bracketTemplateId: null, sportsSeasonId: "ss-1" },
|
||||
AFL_BRACKET
|
||||
);
|
||||
|
||||
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
|
||||
expect(insertedRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("is a no-op when the event does not exist", async () => {
|
||||
const { db } = makeDb(null, AFL_BRACKET);
|
||||
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
|
||||
});
|
||||
|
||||
it("grants nothing to an NBA bracket: every seeded round is non-scoring", async () => {
|
||||
const { db, insertedRows } = makeDb(
|
||||
{ bracketTemplateId: "nba_20", sportsSeasonId: "ss-1" },
|
||||
[
|
||||
{ round: "Play-In Round 1", participant1Id: "e7", participant2Id: "e8" },
|
||||
{ round: "First Round", participant1Id: "e1", participant2Id: null },
|
||||
]
|
||||
);
|
||||
|
||||
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
|
||||
expect(insertedRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("grants the T5-8 tier to a simple_8 field: every entrant is already in a scoring round", async () => {
|
||||
const { db, insertedRows } = makeDb(
|
||||
{ bracketTemplateId: "simple_8", sportsSeasonId: "ss-1" },
|
||||
[{ round: "Quarterfinals", participant1Id: "a", participant2Id: "b" }]
|
||||
);
|
||||
|
||||
expect(await applyBracketEntryFloors("event-1", db)).toBe(2);
|
||||
expect(insertedRows.map((r) => r.finalPosition)).toEqual([5, 5]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBracketEntryFloor", () => {
|
||||
it("prefers a round's explicit entryFloor over its loser position", () => {
|
||||
// Qualifying Finals is non-scoring, so only the explicit entryFloor makes it pay.
|
||||
expect(getBracketEntryFloor("Qualifying Finals", "afl_10")).toBe(5);
|
||||
});
|
||||
|
||||
it("falls back to a scoring round's own loser position", () => {
|
||||
expect(getBracketEntryFloor("Quarterfinals", "simple_8")).toBe(5);
|
||||
expect(getBracketEntryFloor("Semifinals", "simple_8")).toBe(3);
|
||||
});
|
||||
|
||||
it("returns null for non-scoring rounds with no explicit floor", () => {
|
||||
expect(getBracketEntryFloor("Wildcard Round", "afl_10")).toBeNull();
|
||||
expect(getBracketEntryFloor("First Round", "nba_20")).toBeNull();
|
||||
expect(getBracketEntryFloor("Round of 64", "ncaa_68")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for unknown rounds and templates", () => {
|
||||
expect(getBracketEntryFloor("Not A Round", "afl_10")).toBeNull();
|
||||
expect(getBracketEntryFloor("Quarterfinals", "not_a_template")).toBeNull();
|
||||
expect(getBracketEntryFloor("Quarterfinals", null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
getBracketTemplateIdForSportsSeason,
|
||||
getBracketTemplateIdsForSportsSeasons,
|
||||
} from "../bracket-template";
|
||||
|
||||
/**
|
||||
* A sports season can own several scoring events — a bracket plus schedule events, or a
|
||||
* re-created bracket alongside a stale one. Resolving the template from an arbitrary row
|
||||
* is not harmless: calculateBracketPoints falls back to the flat 5th–8th average when the
|
||||
* template id is null, so losing "llws_20" makes a team locked into 5th–6th and one
|
||||
* locked into 7th–8th both score 20.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Minimal db stub. Applies the same filter and ordering the real query does, so the
|
||||
* assertions exercise the helper's row-picking rather than re-stating the query.
|
||||
*/
|
||||
function makeDb(
|
||||
rows: Array<{ sportsSeasonId: string; bracketTemplateId: string | null; createdAt: Date }>
|
||||
) {
|
||||
const findMany = vi.fn(async () =>
|
||||
rows
|
||||
.filter((row) => row.bracketTemplateId !== null)
|
||||
.toSorted((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||||
);
|
||||
return { db: { query: { scoringEvents: { findMany } } } as any, findMany };
|
||||
}
|
||||
|
||||
describe("getBracketTemplateIdForSportsSeason", () => {
|
||||
it("ignores a non-bracket event and returns the bracket event's template", async () => {
|
||||
const { db } = makeDb([
|
||||
{ sportsSeasonId: "ss1", bracketTemplateId: null, createdAt: new Date("2026-08-01") },
|
||||
{ sportsSeasonId: "ss1", bracketTemplateId: "llws_20", createdAt: new Date("2026-07-01") },
|
||||
]);
|
||||
|
||||
await expect(getBracketTemplateIdForSportsSeason("ss1", db)).resolves.toBe("llws_20");
|
||||
});
|
||||
|
||||
it("takes the most recent bracket event when a stale one is still around", async () => {
|
||||
const { db } = makeDb([
|
||||
{ sportsSeasonId: "ss1", bracketTemplateId: "simple_16", createdAt: new Date("2026-06-01") },
|
||||
{ sportsSeasonId: "ss1", bracketTemplateId: "llws_20", createdAt: new Date("2026-08-01") },
|
||||
]);
|
||||
|
||||
await expect(getBracketTemplateIdForSportsSeason("ss1", db)).resolves.toBe("llws_20");
|
||||
});
|
||||
|
||||
it("returns null when the season has no bracket event", async () => {
|
||||
const { db } = makeDb([
|
||||
{ sportsSeasonId: "ss1", bracketTemplateId: null, createdAt: new Date("2026-08-01") },
|
||||
]);
|
||||
|
||||
await expect(getBracketTemplateIdForSportsSeason("ss1", db)).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBracketTemplateIdsForSportsSeasons", () => {
|
||||
it("resolves each season independently in one query", async () => {
|
||||
const { db, findMany } = makeDb([
|
||||
{ sportsSeasonId: "ss1", bracketTemplateId: "llws_20", createdAt: new Date("2026-08-01") },
|
||||
{ sportsSeasonId: "ss2", bracketTemplateId: "afl_10", createdAt: new Date("2026-08-02") },
|
||||
{ sportsSeasonId: "ss3", bracketTemplateId: null, createdAt: new Date("2026-08-03") },
|
||||
]);
|
||||
|
||||
const resolved = await getBracketTemplateIdsForSportsSeasons(["ss1", "ss2", "ss3"], db);
|
||||
|
||||
expect(resolved.get("ss1")).toBe("llws_20");
|
||||
expect(resolved.get("ss2")).toBe("afl_10");
|
||||
expect(resolved.get("ss3")).toBeNull();
|
||||
expect(findMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("gives every requested season an entry so callers can cache the miss", async () => {
|
||||
const { db } = makeDb([]);
|
||||
|
||||
const resolved = await getBracketTemplateIdsForSportsSeasons(["ss1", "ss2"], db);
|
||||
|
||||
expect([...resolved.entries()]).toEqual([
|
||||
["ss1", null],
|
||||
["ss2", null],
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not query at all for an empty season list", async () => {
|
||||
const { db, findMany } = makeDb([]);
|
||||
|
||||
await expect(getBracketTemplateIdsForSportsSeasons([], db)).resolves.toEqual(new Map());
|
||||
expect(findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -6,7 +6,7 @@ vi.mock("~/database/context", () => ({
|
|||
}));
|
||||
|
||||
import { database } from "~/database/context";
|
||||
import { batchUpsertParticipantSimulatorInputs } from "../simulator";
|
||||
import { batchSaveFuturesOddsForSimulator } from "../simulator";
|
||||
|
||||
type SetPayload = Record<string, unknown>;
|
||||
|
||||
|
|
@ -17,71 +17,36 @@ const onConflictDoUpdate = vi.fn((arg: { set: SetPayload }) => {
|
|||
return Promise.resolve(undefined);
|
||||
});
|
||||
|
||||
const tx = {
|
||||
const mockDb = {
|
||||
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.
|
||||
describe("batchSaveFuturesOddsForSimulator", () => {
|
||||
it("persists odds without destroying a stored Elo/rating", async () => {
|
||||
await batchSaveFuturesOddsForSimulator([
|
||||
{ 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");
|
||||
// The upsert writes the odds and leaves Elo/rating untouched — whether the
|
||||
// odds override them is now decided by the configurable source priority,
|
||||
// not by nulling stored values here.
|
||||
expect(mockDb.insert).toHaveBeenCalledTimes(1);
|
||||
expect(conflictSetCalls).toHaveLength(1);
|
||||
expect(conflictSetCalls[0]).toHaveProperty("sourceOdds");
|
||||
expect(conflictSetCalls[0]).not.toHaveProperty("sourceElo");
|
||||
expect(conflictSetCalls[0]).not.toHaveProperty("rating");
|
||||
});
|
||||
|
||||
it("is a no-op when given no inputs", async () => {
|
||||
await batchUpsertParticipantSimulatorInputs([]);
|
||||
expect(mockDb.transaction).not.toHaveBeenCalled();
|
||||
await batchSaveFuturesOddsForSimulator([]);
|
||||
expect(mockDb.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,481 +0,0 @@
|
|||
/**
|
||||
* LLWS 20-Team Double-Elimination Bracket Tests
|
||||
*
|
||||
* Verifies the llws_20 template against the official 2026 LLBWS bracket
|
||||
* (Williamsport, Aug 19–30). The PDF numbers its games 1–38; those numbers appear
|
||||
* throughout as `G<n>` so the routing can be checked against the printed bracket.
|
||||
*
|
||||
* The critical property under test is the double-elimination loser routing: a loss in
|
||||
* the winners bracket drops a team into the elimination bracket at a specific slot,
|
||||
* while a loss in the elimination bracket is final.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
LLWS_20,
|
||||
getScoringRoundType,
|
||||
llwsMatchNumber,
|
||||
llwsSideAndLocal,
|
||||
} from "~/lib/bracket-templates";
|
||||
import {
|
||||
doesLoserAdvance,
|
||||
generateBracketFromTemplate,
|
||||
resolveLLWSAdvancement,
|
||||
} from "../playoff-match";
|
||||
import {
|
||||
calculateBracketPoints,
|
||||
calculateAveragedPoints,
|
||||
type ScoringRules,
|
||||
} from "../scoring-rules";
|
||||
import {
|
||||
GAME_TO_MATCH,
|
||||
EXPECTED_SLOTS,
|
||||
gameNumberFor,
|
||||
required,
|
||||
destinationGame,
|
||||
} from "~/test/fixtures/llws-bracket";
|
||||
|
||||
// generateBracketFromTemplate's only DB touch for llws_20 is the bulk insert, so a
|
||||
// minimal stub is enough to capture the generated rows.
|
||||
const insertedRows: Record<string, unknown>[] = [];
|
||||
vi.mock("~/database/context", () => ({
|
||||
database: () => ({
|
||||
insert: () => ({
|
||||
values: (rows: Record<string, unknown>[]) => ({
|
||||
returning: async () => {
|
||||
insertedRows.push(...rows);
|
||||
return rows;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
const DEFAULT_SCORING: ScoringRules = {
|
||||
pointsFor1st: 100,
|
||||
pointsFor2nd: 70,
|
||||
pointsFor3rd: 50,
|
||||
pointsFor4th: 40,
|
||||
pointsFor5th: 25,
|
||||
pointsFor6th: 20,
|
||||
pointsFor7th: 15,
|
||||
pointsFor8th: 10,
|
||||
};
|
||||
|
||||
describe("LLWS 20 Bracket Template", () => {
|
||||
describe("Template structure", () => {
|
||||
it("has correct identity and size", () => {
|
||||
expect(LLWS_20.id).toBe("llws_20");
|
||||
expect(LLWS_20.totalTeams).toBe(20);
|
||||
expect(LLWS_20.scoringStartsAtRound).toBe("Winners Final");
|
||||
});
|
||||
|
||||
it("has 12 rounds totalling 38 matches", () => {
|
||||
expect(LLWS_20.rounds).toHaveLength(12);
|
||||
const total = LLWS_20.rounds.reduce((sum, r) => sum + r.matchCount, 0);
|
||||
expect(total).toBe(38);
|
||||
});
|
||||
|
||||
it("has the expected match count per round", () => {
|
||||
const counts = Object.fromEntries(
|
||||
LLWS_20.rounds.map((r) => [r.name, r.matchCount])
|
||||
);
|
||||
expect(counts).toEqual({
|
||||
"Opening Round": 8,
|
||||
"Winners Round 2": 4,
|
||||
"Elimination Round 1": 4,
|
||||
"Winners Semifinals": 4,
|
||||
"Elimination Round 2": 4,
|
||||
"Elimination Round 3": 4,
|
||||
"Winners Final": 2,
|
||||
"Elimination Round 4": 2,
|
||||
"Elimination Final": 2,
|
||||
"Bracket Championship": 2,
|
||||
"Consolation Third Place": 1,
|
||||
"World Championship": 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("marks exactly the point-awarding rounds as scoring", () => {
|
||||
const scoring = LLWS_20.rounds.filter((r) => r.isScoring).map((r) => r.name);
|
||||
expect(scoring).toEqual([
|
||||
"Winners Final",
|
||||
"Elimination Round 4",
|
||||
"Elimination Final",
|
||||
"Bracket Championship",
|
||||
"Consolation Third Place",
|
||||
"World Championship",
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists rounds in chronological order", () => {
|
||||
// Elimination Round 1 (Aug 22) is played before Winners Semifinals (Aug 23).
|
||||
const names = LLWS_20.rounds.map((r) => r.name);
|
||||
expect(names.indexOf("Elimination Round 1")).toBeLessThan(
|
||||
names.indexOf("Winners Semifinals")
|
||||
);
|
||||
expect(names.indexOf("Winners Final")).toBeLessThan(
|
||||
names.indexOf("Elimination Final")
|
||||
);
|
||||
});
|
||||
|
||||
it("gives elimination-bracket winners a floor matching their real worst case", () => {
|
||||
const byName = (n: string) => LLWS_20.rounds.find((r) => r.name === n);
|
||||
// Winning Elim R3 only guarantees 7th (a loss in Elim R4 is the 7–8 tier),
|
||||
// so the engine's default floor of 5 would overstate it.
|
||||
expect(byName("Elimination Round 3")?.nonScoringWinnerFloor).toBe(7);
|
||||
// Reaching the Winners Final guarantees 5th at worst.
|
||||
expect(byName("Winners Semifinals")?.nonScoringWinnerFloor).toBe(5);
|
||||
// Nothing is guaranteed earlier than that.
|
||||
expect(byName("Opening Round")?.nonScoringWinnerFloor).toBeNull();
|
||||
expect(byName("Winners Round 2")?.nonScoringWinnerFloor).toBeNull();
|
||||
expect(byName("Elimination Round 1")?.nonScoringWinnerFloor).toBeNull();
|
||||
expect(byName("Elimination Round 2")?.nonScoringWinnerFloor).toBeNull();
|
||||
});
|
||||
|
||||
it("has 20 participant labels", () => {
|
||||
expect(LLWS_20.participantLabels).toHaveLength(20);
|
||||
});
|
||||
|
||||
it("splits display into U.S., International and Championship phases", () => {
|
||||
expect(LLWS_20.phases?.map((p) => p.name)).toEqual([
|
||||
"United States",
|
||||
"International",
|
||||
"Championship",
|
||||
]);
|
||||
});
|
||||
|
||||
it("assigns every match to exactly one phase group", () => {
|
||||
const claimed = new Map<string, number>();
|
||||
for (const phase of LLWS_20.phases ?? []) {
|
||||
for (const group of phase.groups ?? []) {
|
||||
for (const [round, numbers] of Object.entries(group.roundMatchNumbers)) {
|
||||
for (const n of numbers) {
|
||||
const key = `${round}#${n}`;
|
||||
claimed.set(key, (claimed.get(key) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every per-side match claimed exactly once (36 games; the 2 finals live in
|
||||
// the Championship phase's plain round list, not in a group).
|
||||
expect(claimed.size).toBe(36);
|
||||
expect([...claimed.values()].every((c) => c === 1)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Bracket generation", () => {
|
||||
const PARTICIPANTS = Array.from({ length: 20 }, (_, i) => `team-${i}`);
|
||||
|
||||
async function generate() {
|
||||
insertedRows.length = 0;
|
||||
await generateBracketFromTemplate("event-1", "llws_20", PARTICIPANTS);
|
||||
return insertedRows.map((r) => ({
|
||||
round: r.round as string,
|
||||
matchNumber: r.matchNumber as number,
|
||||
participant1Id: (r.participant1Id ?? null) as string | null,
|
||||
participant2Id: (r.participant2Id ?? null) as string | null,
|
||||
isScoring: r.isScoring as boolean,
|
||||
}));
|
||||
}
|
||||
|
||||
it("creates all 38 matches", async () => {
|
||||
const rows = await generate();
|
||||
expect(rows).toHaveLength(38);
|
||||
});
|
||||
|
||||
it("creates the right number of matches per round", async () => {
|
||||
const rows = await generate();
|
||||
for (const round of LLWS_20.rounds) {
|
||||
expect(
|
||||
rows.filter((r) => r.round === round.name),
|
||||
`${round.name} match count`
|
||||
).toHaveLength(round.matchCount);
|
||||
}
|
||||
});
|
||||
|
||||
it("numbers matches 1..n within each round", async () => {
|
||||
const rows = await generate();
|
||||
for (const round of LLWS_20.rounds) {
|
||||
const numbers = rows
|
||||
.filter((r) => r.round === round.name)
|
||||
.map((r) => r.matchNumber)
|
||||
.toSorted((a, b) => a - b);
|
||||
expect(numbers).toEqual(
|
||||
Array.from({ length: round.matchCount }, (_, i) => i + 1)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("seeds the Opening Round two teams at a time, U.S. then International", async () => {
|
||||
const rows = await generate();
|
||||
const opening = rows
|
||||
.filter((r) => r.round === "Opening Round")
|
||||
.toSorted((a, b) => a.matchNumber - b.matchNumber);
|
||||
// U.S. slots 0–7 fill matches 1–4; International slots 10–17 fill matches 5–8.
|
||||
expect(opening.map((m) => [m.participant1Id, m.participant2Id])).toEqual([
|
||||
["team-0", "team-1"],
|
||||
["team-2", "team-3"],
|
||||
["team-4", "team-5"],
|
||||
["team-6", "team-7"],
|
||||
["team-10", "team-11"],
|
||||
["team-12", "team-13"],
|
||||
["team-14", "team-15"],
|
||||
["team-16", "team-17"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("seats the four bye teams in Winners Round 2 awaiting an opponent", async () => {
|
||||
const rows = await generate();
|
||||
const wr2 = rows
|
||||
.filter((r) => r.round === "Winners Round 2")
|
||||
.toSorted((a, b) => a.matchNumber - b.matchNumber);
|
||||
expect(wr2.map((m) => [m.participant1Id, m.participant2Id])).toEqual([
|
||||
["team-8", null],
|
||||
["team-9", null],
|
||||
["team-18", null],
|
||||
["team-19", null],
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses each participant exactly once and leaves every other slot empty", async () => {
|
||||
const rows = await generate();
|
||||
const seeded = rows
|
||||
.flatMap((r) => [r.participant1Id, r.participant2Id])
|
||||
.filter((id): id is string => id !== null);
|
||||
expect(seeded).toHaveLength(20);
|
||||
expect(new Set(seeded).size).toBe(20);
|
||||
expect(new Set(seeded)).toEqual(new Set(PARTICIPANTS));
|
||||
});
|
||||
|
||||
it("stamps isScoring from the template", async () => {
|
||||
const rows = await generate();
|
||||
for (const round of LLWS_20.rounds) {
|
||||
for (const row of rows.filter((r) => r.round === round.name)) {
|
||||
expect(row.isScoring, `${round.name} #${row.matchNumber}`).toBe(round.isScoring);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a participant count other than 20", async () => {
|
||||
await expect(
|
||||
generateBracketFromTemplate("event-1", "llws_20", PARTICIPANTS.slice(0, 19))
|
||||
).rejects.toThrow(/requires 20 participants/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Side / match-number mapping", () => {
|
||||
it("round-trips every match number through side-local form", () => {
|
||||
for (const round of LLWS_20.rounds) {
|
||||
if (round.matchCount === 1) continue; // shared finals have no side
|
||||
for (let n = 1; n <= round.matchCount; n++) {
|
||||
const { side, localMatch } = llwsSideAndLocal(round.name, n);
|
||||
expect(llwsMatchNumber(round.name, side, localMatch)).toBe(n);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("puts U.S. matches in the low half and International in the high half", () => {
|
||||
expect(llwsSideAndLocal("Opening Round", 4).side).toBe(0);
|
||||
expect(llwsSideAndLocal("Opening Round", 5).side).toBe(1);
|
||||
expect(llwsSideAndLocal("Winners Semifinals", 2).side).toBe(0);
|
||||
expect(llwsSideAndLocal("Winners Semifinals", 3).side).toBe(1);
|
||||
expect(llwsSideAndLocal("Winners Final", 1).side).toBe(0);
|
||||
expect(llwsSideAndLocal("Winners Final", 2).side).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Advancement matches the official bracket", () => {
|
||||
/**
|
||||
* Replay the whole tournament through resolveLLWSAdvancement and record which
|
||||
* feed label ends up in each slot, then compare against the printed bracket.
|
||||
*/
|
||||
const actualSlots: Record<number, [string | null, string | null]> = {};
|
||||
for (const game of Object.keys(EXPECTED_SLOTS)) {
|
||||
actualSlots[Number(game)] = [null, null];
|
||||
}
|
||||
|
||||
for (const [gameStr, { round, matchNumber }] of Object.entries(GAME_TO_MATCH)) {
|
||||
const game = Number(gameStr);
|
||||
const { winner, loser } = resolveLLWSAdvancement(round, matchNumber);
|
||||
for (const [dest, label] of [
|
||||
[winner, `W${game}`],
|
||||
[loser, `L${game}`],
|
||||
] as const) {
|
||||
if (!dest) continue;
|
||||
const targetGame = gameNumberFor(dest.round, dest.matchNumber);
|
||||
const slotIndex = dest.slot === "participant1Id" ? 0 : 1;
|
||||
actualSlots[targetGame][slotIndex] = label;
|
||||
}
|
||||
}
|
||||
|
||||
it.each(Object.keys(EXPECTED_SLOTS).map(Number).toSorted((a, b) => a - b))(
|
||||
"Game %i has the printed participants",
|
||||
(game) => {
|
||||
expect(actualSlots[game]).toEqual(EXPECTED_SLOTS[game]);
|
||||
}
|
||||
);
|
||||
|
||||
it("fills every slot in the bracket exactly once", () => {
|
||||
// 38 games × 2 slots = 76. 20 are seeded directly (16 opening teams + 4 byes),
|
||||
// leaving 56 to be filled by advancement.
|
||||
const filled = Object.values(actualSlots)
|
||||
.flat()
|
||||
.filter((s) => s !== null).length;
|
||||
expect(filled).toBe(56);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Double-elimination loser routing", () => {
|
||||
it("routes every winners-bracket loser into the elimination bracket", () => {
|
||||
const winnersRounds = [
|
||||
"Opening Round",
|
||||
"Winners Round 2",
|
||||
"Winners Semifinals",
|
||||
"Winners Final",
|
||||
];
|
||||
for (const roundName of winnersRounds) {
|
||||
const round = LLWS_20.rounds.find((r) => r.name === roundName);
|
||||
if (!round) throw new Error(`missing round ${roundName}`);
|
||||
for (let n = 1; n <= round.matchCount; n++) {
|
||||
const { loser } = resolveLLWSAdvancement(roundName, n);
|
||||
expect(loser, `${roundName} #${n} loser should advance`).not.toBeNull();
|
||||
expect(loser?.round.startsWith("Elimination")).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("eliminates every elimination-bracket loser", () => {
|
||||
const elimRounds = [
|
||||
"Elimination Round 1",
|
||||
"Elimination Round 2",
|
||||
"Elimination Round 3",
|
||||
"Elimination Round 4",
|
||||
"Elimination Final",
|
||||
];
|
||||
for (const roundName of elimRounds) {
|
||||
const round = LLWS_20.rounds.find((r) => r.name === roundName);
|
||||
if (!round) throw new Error(`missing round ${roundName}`);
|
||||
for (let n = 1; n <= round.matchCount; n++) {
|
||||
const { loser } = resolveLLWSAdvancement(roundName, n);
|
||||
expect(loser, `${roundName} #${n} loser should be out`).toBeNull();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the winners-bracket final loser alive via the Elimination Final", () => {
|
||||
// G30 (U.S. Winners Final) loser → G34, not out. This is the defining
|
||||
// double-elimination behavior: a first loss never eliminates.
|
||||
const { winner, loser } = resolveLLWSAdvancement("Winners Final", 1);
|
||||
expect(destinationGame(loser)).toBe(34);
|
||||
expect(destinationGame(winner)).toBe(36);
|
||||
});
|
||||
|
||||
it("sends the side-championship loser to the consolation game, not out", () => {
|
||||
// No "if necessary" rematch: the winners-bracket champion that loses G36 is
|
||||
// done in the bracket, but still plays G37 for 3rd/4th.
|
||||
const us = resolveLLWSAdvancement("Bracket Championship", 1);
|
||||
expect(destinationGame(us.winner)).toBe(38);
|
||||
expect(destinationGame(us.loser)).toBe(37);
|
||||
expect(required(us.winner).slot).toBe("participant1Id");
|
||||
expect(required(us.loser).slot).toBe("participant1Id");
|
||||
|
||||
const intl = resolveLLWSAdvancement("Bracket Championship", 2);
|
||||
expect(required(intl.winner).slot).toBe("participant2Id");
|
||||
expect(required(intl.loser).slot).toBe("participant2Id");
|
||||
});
|
||||
|
||||
it("flags winners-bracket losers as advancing so they are not marked eliminated", () => {
|
||||
// doesLoserAdvance is what stops the scoring engine writing a 0-point
|
||||
// elimination (and announcing a knockout) for a team that is still alive.
|
||||
// Winners Final and Bracket Championship are scoring rounds and are covered
|
||||
// by loserIsPartial instead, so they are deliberately not listed here.
|
||||
for (const round of ["Opening Round", "Winners Round 2", "Winners Semifinals"]) {
|
||||
expect(doesLoserAdvance(round, 1, "llws_20"), round).toBe(true);
|
||||
}
|
||||
for (const round of [
|
||||
"Elimination Round 1",
|
||||
"Elimination Round 2",
|
||||
"Elimination Round 3",
|
||||
"Elimination Round 4",
|
||||
"Elimination Final",
|
||||
]) {
|
||||
expect(doesLoserAdvance(round, 1, "llws_20"), round).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not apply LLWS loser routing to other templates", () => {
|
||||
expect(doesLoserAdvance("Opening Round", 1, "ncaa_68")).toBe(false);
|
||||
expect(doesLoserAdvance("Winners Semifinals", 1, "")).toBe(false);
|
||||
});
|
||||
|
||||
it("advances nobody out of the two final games", () => {
|
||||
for (const round of ["Consolation Third Place", "World Championship"]) {
|
||||
expect(resolveLLWSAdvancement(round, 1)).toEqual({ winner: null, loser: null });
|
||||
}
|
||||
});
|
||||
|
||||
it("never crosses a team between the U.S. and International sides", () => {
|
||||
for (const round of LLWS_20.rounds) {
|
||||
if (round.name === "Bracket Championship") continue; // the crossover point
|
||||
if (round.matchCount === 1) continue;
|
||||
for (let n = 1; n <= round.matchCount; n++) {
|
||||
const { side } = llwsSideAndLocal(round.name, n);
|
||||
const { winner, loser } = resolveLLWSAdvancement(round.name, n);
|
||||
for (const dest of [winner, loser]) {
|
||||
if (!dest) continue;
|
||||
const destRound = LLWS_20.rounds.find((r) => r.name === dest.round);
|
||||
if (!destRound || destRound.matchCount === 1) continue;
|
||||
expect(llwsSideAndLocal(dest.round, dest.matchNumber).side).toBe(side);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Placement tiers", () => {
|
||||
it("classifies scoring rounds correctly", () => {
|
||||
expect(getScoringRoundType("Elimination Round 4", LLWS_20)).toBe("quarterfinals");
|
||||
expect(getScoringRoundType("Elimination Final", LLWS_20)).toBe("quarterfinals");
|
||||
expect(getScoringRoundType("Bracket Championship", LLWS_20)).toBe("semifinals");
|
||||
expect(getScoringRoundType("World Championship", LLWS_20)).toBe("finals");
|
||||
// Nobody is eliminated in the Winners Final — the loser drops to the
|
||||
// elimination bracket — so it has no placement tier.
|
||||
expect(getScoringRoundType("Winners Final", LLWS_20)).toBeNull();
|
||||
});
|
||||
|
||||
it("pays 3rd and 4th distinctly (there is a real consolation game)", () => {
|
||||
expect(calculateBracketPoints(3, DEFAULT_SCORING, "llws_20")).toBe(50);
|
||||
expect(calculateBracketPoints(4, DEFAULT_SCORING, "llws_20")).toBe(40);
|
||||
});
|
||||
|
||||
it("splits 5–8 into two two-team tiers", () => {
|
||||
const upper = calculateAveragedPoints([5, 6], DEFAULT_SCORING); // (25+20)/2
|
||||
const lower = calculateAveragedPoints([7, 8], DEFAULT_SCORING); // (15+10)/2
|
||||
expect(calculateBracketPoints(5, DEFAULT_SCORING, "llws_20")).toBe(upper);
|
||||
expect(calculateBracketPoints(6, DEFAULT_SCORING, "llws_20")).toBe(upper);
|
||||
expect(calculateBracketPoints(7, DEFAULT_SCORING, "llws_20")).toBe(lower);
|
||||
expect(calculateBracketPoints(8, DEFAULT_SCORING, "llws_20")).toBe(lower);
|
||||
// Surviving Elimination Round 4 is worth more than losing it.
|
||||
expect(upper).toBeGreaterThan(lower);
|
||||
});
|
||||
|
||||
it("awards nothing below 8th", () => {
|
||||
// The 12 teams knocked out in Elimination Rounds 1–3 finish 9th–20th.
|
||||
expect(calculateBracketPoints(9, DEFAULT_SCORING, "llws_20")).toBe(0);
|
||||
expect(calculateBracketPoints(0, DEFAULT_SCORING, "llws_20")).toBe(0);
|
||||
});
|
||||
|
||||
it("has exactly 8 teams alive when the first scoring elimination game is played", () => {
|
||||
// Elimination Round 4 is the 7th–8th tier, so the field must be 8 at that point:
|
||||
// per side the Winners Final winner, the Winners Final loser, and the two
|
||||
// Elimination Round 3 winners.
|
||||
const eliminatedBeforeElimR4 =
|
||||
(LLWS_20.rounds.find((r) => r.name === "Elimination Round 1")?.matchCount ?? 0) +
|
||||
(LLWS_20.rounds.find((r) => r.name === "Elimination Round 2")?.matchCount ?? 0) +
|
||||
(LLWS_20.rounds.find((r) => r.name === "Elimination Round 3")?.matchCount ?? 0);
|
||||
expect(eliminatedBeforeElimR4).toBe(12);
|
||||
expect(LLWS_20.totalTeams - eliminatedBeforeElimR4).toBe(8);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// populateBracketFromDraw upserts a full draw into playoff_matches and reports
|
||||
// which losers' matches *transitioned to complete on this run* — the signal used
|
||||
// to announce knockouts once, idempotently across re-syncs.
|
||||
|
||||
const existingRows: Array<Record<string, unknown>> = [];
|
||||
|
||||
const mockDb = {
|
||||
query: {
|
||||
playoffMatches: {
|
||||
findMany: vi.fn(async () => existingRows),
|
||||
},
|
||||
},
|
||||
update: vi.fn(() => ({
|
||||
set: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })),
|
||||
})),
|
||||
insert: vi.fn(() => ({
|
||||
values: vi.fn(() => ({ returning: vi.fn().mockResolvedValue([]) })),
|
||||
})),
|
||||
};
|
||||
|
||||
vi.mock("~/database/context", () => ({ database: () => mockDb }));
|
||||
|
||||
import { populateBracketFromDraw, type ResolvedDrawMatch } from "../playoff-match";
|
||||
|
||||
const EVENT_ID = "ev-1";
|
||||
|
||||
function match(overrides: Partial<ResolvedDrawMatch> = {}): ResolvedDrawMatch {
|
||||
return {
|
||||
externalMatchId: "m-1",
|
||||
round: "Round of 64",
|
||||
matchNumber: 1,
|
||||
participant1Id: "winner",
|
||||
participant2Id: "loser",
|
||||
winnerId: "winner",
|
||||
loserId: "loser",
|
||||
isScoring: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
existingRows.length = 0;
|
||||
});
|
||||
|
||||
describe("populateBracketFromDraw newlyDecidedLoserIds", () => {
|
||||
it("reports the loser of a brand-new completed match", async () => {
|
||||
const { newlyDecidedLoserIds } = await populateBracketFromDraw(EVENT_ID, [
|
||||
match({ externalMatchId: "m-1", loserId: "mensik" }),
|
||||
]);
|
||||
|
||||
expect(newlyDecidedLoserIds).toEqual(["mensik"]);
|
||||
});
|
||||
|
||||
it("reports the loser of an existing match that just reached completion", async () => {
|
||||
existingRows.push({
|
||||
id: "row-1",
|
||||
externalMatchId: "m-1",
|
||||
isComplete: false,
|
||||
loserId: null,
|
||||
winnerId: null,
|
||||
});
|
||||
|
||||
const { newlyDecidedLoserIds } = await populateBracketFromDraw(EVENT_ID, [
|
||||
match({ externalMatchId: "m-1", loserId: "mensik" }),
|
||||
]);
|
||||
|
||||
expect(newlyDecidedLoserIds).toEqual(["mensik"]);
|
||||
});
|
||||
|
||||
it("reports nothing on an idempotent re-sync of an already-complete match", async () => {
|
||||
existingRows.push({
|
||||
id: "row-1",
|
||||
externalMatchId: "m-1",
|
||||
isComplete: true,
|
||||
loserId: "mensik",
|
||||
winnerId: "winner",
|
||||
});
|
||||
|
||||
const { newlyDecidedLoserIds } = await populateBracketFromDraw(EVENT_ID, [
|
||||
match({ externalMatchId: "m-1", loserId: "mensik" }),
|
||||
]);
|
||||
|
||||
expect(newlyDecidedLoserIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores incomplete matches (no winner/loser yet)", async () => {
|
||||
const { newlyDecidedLoserIds, completed } = await populateBracketFromDraw(EVENT_ID, [
|
||||
match({ externalMatchId: "m-2", winnerId: null, loserId: null }),
|
||||
]);
|
||||
|
||||
expect(newlyDecidedLoserIds).toEqual([]);
|
||||
expect(completed).toBe(0);
|
||||
});
|
||||
|
||||
it("collects only the newly-decided losers in a mixed batch", async () => {
|
||||
existingRows.push(
|
||||
{ id: "row-1", externalMatchId: "m-1", isComplete: true, loserId: "old", winnerId: "w1" },
|
||||
{ id: "row-2", externalMatchId: "m-2", isComplete: false, loserId: null, winnerId: null },
|
||||
);
|
||||
|
||||
const { newlyDecidedLoserIds } = await populateBracketFromDraw(EVENT_ID, [
|
||||
match({ externalMatchId: "m-1", loserId: "old" }), // already complete → skip
|
||||
match({ externalMatchId: "m-2", loserId: "freshly-out" }), // transitioned → include
|
||||
match({ externalMatchId: "m-3", loserId: "brand-new-out" }), // new complete → include
|
||||
match({ externalMatchId: "m-4", winnerId: null, loserId: null }), // incomplete → skip
|
||||
]);
|
||||
|
||||
expect(newlyDecidedLoserIds.toSorted()).toEqual(["brand-new-out", "freshly-out"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -190,26 +190,12 @@ describe("processMatchResult", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("AFL Wildcard Round: loser=0, winner gets T7 floor (a Wildcard win only buys an Elimination Final)", async () => {
|
||||
// The generic "entering a scoring round ⇒ top-8" default would bank 5 here,
|
||||
// over-awarding the 5th-6th tier to a team whose next loss is the 7th-8th tier.
|
||||
it("AFL Wildcard Round: loser=0, winner gets T5 floor (feeds into Elimination Finals = scoring)", async () => {
|
||||
const { db, insertedRows } = makeDb();
|
||||
await processMatchResult({ ...BASE, bracketTemplateId: "afl_10", round: "Wildcard Round", isScoring: false }, db);
|
||||
expect(insertedRows).toHaveLength(2);
|
||||
expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 0, isPartialScore: false });
|
||||
expect(insertedRows[1]).toMatchObject({ participantId: "winner-1", finalPosition: 7, isPartialScore: true });
|
||||
});
|
||||
|
||||
it("AFL Qualifying Finals: winner gets T3 floor (bye to a Preliminary Final), loser holds their entry floor", async () => {
|
||||
const { db, insertedRows } = makeDb();
|
||||
await processMatchResult(
|
||||
{ ...BASE, bracketTemplateId: "afl_10", round: "Qualifying Finals", isScoring: false, loserAdvances: true },
|
||||
db
|
||||
);
|
||||
// Only the winner is written: the loser still has a Semi-Final, so their
|
||||
// seeding-derived floor of 5 stands untouched.
|
||||
expect(insertedRows).toHaveLength(1);
|
||||
expect(insertedRows[0]).toMatchObject({ participantId: "winner-1", finalPosition: 3, isPartialScore: true });
|
||||
expect(insertedRows[1]).toMatchObject({ participantId: "winner-1", finalPosition: 5, isPartialScore: true });
|
||||
});
|
||||
|
||||
describe("NBA Play-In loserAdvances=true (7v8 game)", () => {
|
||||
|
|
@ -319,22 +305,6 @@ describe("processMatchResult", () => {
|
|||
expect(updateProbabilitiesAfterResult).toHaveBeenCalledWith("ss-1", true);
|
||||
});
|
||||
|
||||
it("skips only the probability refresh when asked, still announcing", async () => {
|
||||
// For a caller scoring several matches in a loop: the refresh is season-wide and, for a
|
||||
// bracket-aware sport, a full Monte Carlo run, so it belongs once after the loop rather
|
||||
// than once per match. Standings and the announcement still happen per match.
|
||||
const { db } = makeDb();
|
||||
|
||||
await processMatchResult(
|
||||
{ ...BASE, round: "Quarterfinals", isScoring: true, skipProbabilities: true },
|
||||
db
|
||||
);
|
||||
|
||||
expect(updateProbabilitiesAfterResult).not.toHaveBeenCalled();
|
||||
// recalculateAffectedLeagues still ran: it is the only thing that reads seasonSports.
|
||||
expect(db.query.seasonSports.findMany).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not throw even if probability update fails", async () => {
|
||||
(updateProbabilitiesAfterResult as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||
new Error("network error")
|
||||
|
|
@ -429,7 +399,6 @@ describe("isLoserNotifiable", () => {
|
|||
});
|
||||
});
|
||||
|
||||
|
||||
describe("processPlayoffEvent - NBA Play-In Round 1 loserAdvances fix", () => {
|
||||
function makePlayInDb(matches: object[]) {
|
||||
const insertedRows: Array<{ participantId: string; finalPosition: number; isPartialScore: boolean }> = [];
|
||||
|
|
|
|||
|
|
@ -146,57 +146,6 @@ describe("deriveBracketQualifyingStates (simple_8)", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ── 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)", () => {
|
||||
|
|
|
|||
|
|
@ -2,47 +2,9 @@ import { describe, it, expect } from "vitest";
|
|||
import {
|
||||
calculateSplitQualifyingPoints,
|
||||
DEFAULT_QP_VALUES,
|
||||
diffChangedQualifyingPoints,
|
||||
hasProcessedQualifyingPlacement,
|
||||
} from "../qualifying-points";
|
||||
|
||||
describe("diffChangedQualifyingPoints", () => {
|
||||
it("flags a changed value, a first-time score (null → value), and ignores unchanged", () => {
|
||||
const before = [
|
||||
{ id: "A", qp: "10.00" },
|
||||
{ id: "B", qp: "5.00" },
|
||||
{ id: "C", qp: null },
|
||||
];
|
||||
const after = [
|
||||
{ id: "A", qp: "10.00" }, // unchanged
|
||||
{ id: "B", qp: "8.00" }, // changed
|
||||
{ id: "C", qp: "3.00" }, // first-time score
|
||||
];
|
||||
|
||||
expect([...diffChangedQualifyingPoints(before, after)].toSorted()).toEqual(["B", "C"]);
|
||||
});
|
||||
|
||||
it("normalizes decimal formatting so 10 and 10.00 are equal", () => {
|
||||
const changed = diffChangedQualifyingPoints([{ id: "A", qp: "10" }], [{ id: "A", qp: "10.00" }]);
|
||||
expect(changed.size).toBe(0);
|
||||
});
|
||||
|
||||
it("does not report participants absent from the after set", () => {
|
||||
const changed = diffChangedQualifyingPoints(
|
||||
[
|
||||
{ id: "A", qp: "10.00" },
|
||||
{ id: "D", qp: "2.00" },
|
||||
],
|
||||
[{ id: "A", qp: "10.00" }],
|
||||
);
|
||||
expect(changed.size).toBe(0);
|
||||
});
|
||||
|
||||
it("treats a value → zero drop as a change", () => {
|
||||
const changed = diffChangedQualifyingPoints([{ id: "A", qp: "10.00" }], [{ id: "A", qp: "0.00" }]);
|
||||
expect([...changed]).toEqual(["A"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Qualifying Points Configuration", () => {
|
||||
describe("DEFAULT_QP_VALUES", () => {
|
||||
it("should have 16 placement values", () => {
|
||||
|
|
@ -309,6 +271,52 @@ describe("Qualifying Points Configuration", () => {
|
|||
expect(totalQP2).toBe(20); // Should have 20 QP (1st place)
|
||||
});
|
||||
|
||||
it("should not increment majorsCompleted when reprocessing", () => {
|
||||
// First processing
|
||||
let majorsCompleted = 0;
|
||||
const wasAlreadyProcessed = false;
|
||||
|
||||
if (!wasAlreadyProcessed) {
|
||||
majorsCompleted += 1;
|
||||
}
|
||||
|
||||
expect(majorsCompleted).toBe(1);
|
||||
|
||||
// Reprocessing (wasAlreadyProcessed = true)
|
||||
const reprocessing = true;
|
||||
|
||||
if (!reprocessing) {
|
||||
majorsCompleted += 1;
|
||||
}
|
||||
|
||||
expect(majorsCompleted).toBe(1); // Should still be 1, not 2
|
||||
});
|
||||
});
|
||||
|
||||
describe("Processed event detection", () => {
|
||||
it("treats a placed zero-QP result as already processed", () => {
|
||||
expect(
|
||||
hasProcessedQualifyingPlacement([
|
||||
{ placement: 20, qualifyingPointsAwarded: "0.00" },
|
||||
])
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat filler zero-QP rows without placements as processed", () => {
|
||||
expect(
|
||||
hasProcessedQualifyingPlacement([
|
||||
{ placement: null, qualifyingPointsAwarded: "0" },
|
||||
])
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not treat unprocessed placed rows as processed", () => {
|
||||
expect(
|
||||
hasProcessedQualifyingPlacement([
|
||||
{ placement: 15, qualifyingPointsAwarded: null },
|
||||
])
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scoring Workflow", () => {
|
||||
|
|
|
|||
|
|
@ -1,143 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type * as QualifyingPointsModule from "~/models/qualifying-points";
|
||||
|
||||
// Spy on the QP Discord notification, and no-op the per-participant total recalc
|
||||
// (its own eventResults query would otherwise interfere with the before/after
|
||||
// snapshot counter below). Everything else in the scorer runs for real.
|
||||
vi.mock("~/services/qualifying-points-discord.server", () => ({
|
||||
notifyQualifyingPointsUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("~/models/qualifying-points", async (importActual) => {
|
||||
const actual = await importActual<typeof QualifyingPointsModule>();
|
||||
return {
|
||||
...actual,
|
||||
recalculateParticipantQP: vi.fn().mockResolvedValue({ totalQP: 0, eventsScored: 0 }),
|
||||
};
|
||||
});
|
||||
|
||||
import { processQualifyingEvent } from "../scoring-calculator";
|
||||
import { DEFAULT_QP_VALUES } from "../qualifying-points";
|
||||
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
|
||||
|
||||
const EVENT_ID = "event-1";
|
||||
const SPORTS_SEASON_ID = "sports-season-1";
|
||||
|
||||
type ResultRow = {
|
||||
id: string;
|
||||
seasonParticipantId: string;
|
||||
placement: number | null;
|
||||
qualifyingPointsAwarded: string | null;
|
||||
scoringEvent: { id: string; sportsSeasonId: string };
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock db for the non-bracket `processQualifyingEvent` path. `eventResults.findMany`
|
||||
* returns `before` on its first call (the initial getEventResults snapshot) and
|
||||
* `after` on its second (the post-reprocess re-query that drives change detection).
|
||||
* Awarding updates go to a no-op update chain — `after` stands in for the DB state
|
||||
* once the scorer has written its QP.
|
||||
*/
|
||||
function makeDb(before: ResultRow[], after: ResultRow[]) {
|
||||
const results = [before, after];
|
||||
let findManyCall = 0;
|
||||
return {
|
||||
query: {
|
||||
scoringEvents: {
|
||||
findFirst: async () => ({
|
||||
id: EVENT_ID,
|
||||
sportsSeasonId: SPORTS_SEASON_ID,
|
||||
isQualifyingEvent: true,
|
||||
bracketTemplateId: null,
|
||||
sportsSeason: { majorsCompleted: 1 },
|
||||
}),
|
||||
},
|
||||
eventResults: {
|
||||
findMany: async () => results[Math.min(findManyCall++, 1)],
|
||||
},
|
||||
qualifyingPointConfig: {
|
||||
findMany: async () =>
|
||||
DEFAULT_QP_VALUES.map((qp) => ({ placement: qp.placement, points: qp.points.toString() })),
|
||||
},
|
||||
},
|
||||
update: () => ({ set: () => ({ where: async () => [] }) }),
|
||||
} as never;
|
||||
}
|
||||
|
||||
const scoringEvent = { id: EVENT_ID, sportsSeasonId: SPORTS_SEASON_ID };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("processQualifyingEvent — QP change notification", () => {
|
||||
it("notifies only participants whose QP changed (first-time score: null → value)", async () => {
|
||||
const db = makeDb(
|
||||
[
|
||||
{ id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: null, scoringEvent },
|
||||
{ id: "r2", seasonParticipantId: "p2", placement: 20, qualifyingPointsAwarded: "0.00", scoringEvent },
|
||||
],
|
||||
[
|
||||
{ id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: "100.00", scoringEvent },
|
||||
{ id: "r2", seasonParticipantId: "p2", placement: 20, qualifyingPointsAwarded: "0.00", scoringEvent },
|
||||
],
|
||||
);
|
||||
|
||||
await processQualifyingEvent(EVENT_ID, db);
|
||||
|
||||
expect(notifyQualifyingPointsUpdate).toHaveBeenCalledTimes(1);
|
||||
const [ssId, evId, , filter] = vi.mocked(notifyQualifyingPointsUpdate).mock.calls[0];
|
||||
expect(ssId).toBe(SPORTS_SEASON_ID);
|
||||
expect(evId).toBe(EVENT_ID);
|
||||
// p1 went null → 100 (changed); p2 stayed at 0 (unchanged).
|
||||
expect([...(filter as Set<string>)]).toEqual(["p1"]);
|
||||
});
|
||||
|
||||
it("does not notify when a re-sync produces identical QP", async () => {
|
||||
const rows: ResultRow[] = [
|
||||
{ id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: "100.00", scoringEvent },
|
||||
{ id: "r2", seasonParticipantId: "p2", placement: 20, qualifyingPointsAwarded: "0.00", scoringEvent },
|
||||
];
|
||||
const db = makeDb(rows, rows.map((r) => ({ ...r })));
|
||||
|
||||
await processQualifyingEvent(EVENT_ID, db);
|
||||
|
||||
expect(notifyQualifyingPointsUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("announces newly-eliminated players even when no QP changed (mirror non-scoring-round exit)", async () => {
|
||||
// A mirror window re-scored on fan-out: nobody's QP changed, but the primary
|
||||
// bracket reports p2 knocked out in a non-scoring round. The notification must
|
||||
// still fire, passing the eliminated id through as the 5th arg so the "Knocked
|
||||
// Out" section isn't dropped on the mirror.
|
||||
const rows: ResultRow[] = [
|
||||
{ id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: "100.00", scoringEvent },
|
||||
{ id: "r2", seasonParticipantId: "p2", placement: null, qualifyingPointsAwarded: "0.00", scoringEvent },
|
||||
];
|
||||
const db = makeDb(rows, rows.map((r) => ({ ...r })));
|
||||
|
||||
await processQualifyingEvent(EVENT_ID, db, {
|
||||
newlyEliminatedParticipantIds: new Set(["p2"]),
|
||||
});
|
||||
|
||||
expect(notifyQualifyingPointsUpdate).toHaveBeenCalledTimes(1);
|
||||
const [, , , changed, eliminated] = vi.mocked(notifyQualifyingPointsUpdate).mock.calls[0];
|
||||
// No QP change this sync.
|
||||
expect([...(changed as Set<string>)]).toEqual([]);
|
||||
// The knocked-out player is forwarded to the notifier.
|
||||
expect([...(eliminated as Set<string>)]).toEqual(["p2"]);
|
||||
});
|
||||
|
||||
it("does not notify on a re-sync with no QP change and no eliminations", async () => {
|
||||
const rows: ResultRow[] = [
|
||||
{ id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: "100.00", scoringEvent },
|
||||
];
|
||||
const db = makeDb(rows, rows.map((r) => ({ ...r })));
|
||||
|
||||
await processQualifyingEvent(EVENT_ID, db, {
|
||||
newlyEliminatedParticipantIds: new Set(),
|
||||
});
|
||||
|
||||
expect(notifyQualifyingPointsUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -200,21 +200,7 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
|
|||
});
|
||||
|
||||
describe("processQualifyingEvent", () => {
|
||||
function makeProcessQPMockDb(
|
||||
eventResults: Array<Record<string, unknown>>,
|
||||
options: {
|
||||
tournamentId?: string | null;
|
||||
canonicalPlacements?: number[];
|
||||
bracketTemplateId?: string | null;
|
||||
playoffMatchIds?: string[];
|
||||
} = {}
|
||||
) {
|
||||
const {
|
||||
tournamentId = null,
|
||||
canonicalPlacements = [],
|
||||
bracketTemplateId = null,
|
||||
playoffMatchIds = [],
|
||||
} = options;
|
||||
function makeProcessQPMockDb(eventResults: Array<Record<string, unknown>>) {
|
||||
const setCalls: unknown[] = [];
|
||||
const updateChain = {
|
||||
set: (values: unknown) => {
|
||||
|
|
@ -231,8 +217,6 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
|
|||
id: "event-1",
|
||||
sportsSeasonId: "sports-season-1",
|
||||
isQualifyingEvent: true,
|
||||
tournamentId,
|
||||
bracketTemplateId,
|
||||
sportsSeason: { majorsCompleted: 1 },
|
||||
}),
|
||||
},
|
||||
|
|
@ -254,23 +238,6 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
|
|||
}),
|
||||
},
|
||||
},
|
||||
// Two select shapes flow through the non-bracket path:
|
||||
// • playoff-match existence check (has an `id` projection, uses .limit(1))
|
||||
// • canonical tournament_results tie span (has a `placement` projection).
|
||||
// Discriminate by the projection keys and return a thenable that also
|
||||
// exposes .limit so both call shapes resolve.
|
||||
select: (fields: Record<string, unknown>) => {
|
||||
const isPlayoff = fields && "id" in fields;
|
||||
const rows = isPlayoff
|
||||
? playoffMatchIds.map((id) => ({ id }))
|
||||
: canonicalPlacements.map((placement) => ({ placement }));
|
||||
return {
|
||||
from: () => ({
|
||||
where: () =>
|
||||
Object.assign(Promise.resolve(rows), { limit: async () => rows }),
|
||||
}),
|
||||
};
|
||||
},
|
||||
update: () => updateChain,
|
||||
} as any;
|
||||
|
||||
|
|
@ -306,123 +273,6 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("splits a tied placement by the FULL canonical field, not the window's roster subset", async () => {
|
||||
// Regression for the reported 2-vs-1.5 QP bug. Tennis Round-of-16 losers all
|
||||
// land at placement 9 with a structural tie span of 8 (positions 9–16 →
|
||||
// (2+2+2+2+1+1+1+1)/8 = 1.5). A sibling/mirror window only holds the drafted
|
||||
// subset — here just 2 of the 8 tied players — but the split must still use 8,
|
||||
// not the 2 present locally (which would wrongly give (2+2)/2 = 2.00).
|
||||
const windowRows = [
|
||||
{
|
||||
id: "result-1",
|
||||
seasonParticipantId: "participant-1",
|
||||
placement: 9,
|
||||
qualifyingPointsAwarded: null,
|
||||
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
|
||||
},
|
||||
{
|
||||
id: "result-2",
|
||||
seasonParticipantId: "participant-2",
|
||||
placement: 9,
|
||||
qualifyingPointsAwarded: null,
|
||||
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
|
||||
},
|
||||
];
|
||||
const { db, setCalls } = makeProcessQPMockDb(windowRows, {
|
||||
tournamentId: "tournament-1",
|
||||
canonicalPlacements: Array.from({ length: 8 }, () => 9), // full field: 8 at 9th
|
||||
});
|
||||
|
||||
await processQualifyingEvent("event-1", db, { skipNotifications: true });
|
||||
|
||||
// Both present players earn the correct split of 1.50, not 2.00.
|
||||
expect(setCalls).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ qualifyingPointsAwarded: "1.50" }),
|
||||
])
|
||||
);
|
||||
expect(setCalls).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ qualifyingPointsAwarded: "2.00" }),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("scores a cloned window (bracketTemplateId but no playoff matches) via the canonical path", async () => {
|
||||
// cloneSportsSeason copies bracketTemplateId to league windows but not the
|
||||
// playoff matches. Such a window must NOT take the bracket branch (which would
|
||||
// derive zero states and write no QP) — it has to fall through to the
|
||||
// placement/canonical path and still split R16 losers to 1.5.
|
||||
const windowRows = [
|
||||
{
|
||||
id: "result-1",
|
||||
seasonParticipantId: "participant-1",
|
||||
placement: 9,
|
||||
qualifyingPointsAwarded: null,
|
||||
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
|
||||
},
|
||||
{
|
||||
id: "result-2",
|
||||
seasonParticipantId: "participant-2",
|
||||
placement: 9,
|
||||
qualifyingPointsAwarded: null,
|
||||
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
|
||||
},
|
||||
];
|
||||
const { db, setCalls } = makeProcessQPMockDb(windowRows, {
|
||||
tournamentId: "tournament-1",
|
||||
bracketTemplateId: "tennis_128", // copied by clone…
|
||||
playoffMatchIds: [], // …but no matches exist on this window
|
||||
canonicalPlacements: Array.from({ length: 8 }, () => 9),
|
||||
});
|
||||
|
||||
await processQualifyingEvent("event-1", db, { skipNotifications: true });
|
||||
|
||||
expect(setCalls).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ qualifyingPointsAwarded: "1.50" }),
|
||||
])
|
||||
);
|
||||
expect(setCalls).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ qualifyingPointsAwarded: "2.00" }),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the live roster count for standalone events with no tournament", async () => {
|
||||
// No canonical field exists for a manual/standalone qualifying event, so the
|
||||
// tie span is the players actually present: 2 players tied at 9th →
|
||||
// (2+2)/2 = 2.00. This preserves existing behavior where there is no full field.
|
||||
const windowRows = [
|
||||
{
|
||||
id: "result-1",
|
||||
seasonParticipantId: "participant-1",
|
||||
placement: 9,
|
||||
qualifyingPointsAwarded: null,
|
||||
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
|
||||
},
|
||||
{
|
||||
id: "result-2",
|
||||
seasonParticipantId: "participant-2",
|
||||
placement: 9,
|
||||
qualifyingPointsAwarded: null,
|
||||
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
|
||||
},
|
||||
];
|
||||
const { db, setCalls } = makeProcessQPMockDb(windowRows, {
|
||||
tournamentId: null,
|
||||
});
|
||||
|
||||
await processQualifyingEvent("event-1", db, { skipNotifications: true });
|
||||
|
||||
expect(setCalls).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ qualifyingPointsAwarded: "2.00" }),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("does not increment majorsCompleted when reprocessing a placed zero-QP result", async () => {
|
||||
const { db, setCalls } = makeProcessQPMockDb([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -15,10 +15,7 @@ vi.mock("../qualifying-points", async (importOriginal) => {
|
|||
import { deleteScoringEvent } from "../scoring-event";
|
||||
|
||||
describe("deleteScoringEvent", () => {
|
||||
it("does not write majorsCompleted on delete (it is derived on read)", async () => {
|
||||
// majorsCompleted is no longer a stored counter — it is computed via
|
||||
// getMajorsCompleted from completed qualifying events. Deleting a qualifying
|
||||
// event must therefore never issue a sportsSeasons.majorsCompleted update.
|
||||
it("decrements majorsCompleted for a processed zero-QP qualifying event", async () => {
|
||||
const setCalls: unknown[] = [];
|
||||
const deleteChain = { where: vi.fn().mockResolvedValue(undefined) };
|
||||
const updateChain = {
|
||||
|
|
@ -46,6 +43,12 @@ describe("deleteScoringEvent", () => {
|
|||
},
|
||||
]),
|
||||
},
|
||||
sportsSeasons: {
|
||||
findFirst: vi.fn().mockResolvedValue({
|
||||
id: "sports-season-1",
|
||||
majorsCompleted: 1,
|
||||
}),
|
||||
},
|
||||
},
|
||||
transaction: vi.fn(async (callback) => callback(db)),
|
||||
delete: vi.fn(() => deleteChain),
|
||||
|
|
@ -54,125 +57,10 @@ describe("deleteScoringEvent", () => {
|
|||
|
||||
await deleteScoringEvent("event-1", db);
|
||||
|
||||
expect(setCalls).not.toEqual(
|
||||
expect(setCalls).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ majorsCompleted: expect.anything() }),
|
||||
expect.objectContaining({ majorsCompleted: 0 }),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
describe("shared tournament handling", () => {
|
||||
// Builds a mock db for a non-qualifying schedule_event linked to a tournament,
|
||||
// so the delete skips the QP/league branches and exercises only the
|
||||
// shared-tournament bookkeeping.
|
||||
function makeSharedDb({
|
||||
deletedEvent,
|
||||
remaining,
|
||||
siblingTournamentId = "tournament-1",
|
||||
}: {
|
||||
deletedEvent: { tournamentId: string | null; isPrimary: boolean };
|
||||
remaining: Array<{ id: string; isPrimary: boolean }>;
|
||||
siblingTournamentId?: string;
|
||||
}) {
|
||||
const findFirst = vi
|
||||
.fn()
|
||||
// 1st call: the event being deleted
|
||||
.mockResolvedValueOnce({
|
||||
id: "event-1",
|
||||
sportsSeasonId: "sports-season-1",
|
||||
eventType: "schedule_event",
|
||||
isQualifyingEvent: false,
|
||||
...deletedEvent,
|
||||
})
|
||||
// subsequent calls: setPrimaryEvent looking up the promoted event
|
||||
.mockResolvedValue({ id: "promoted", tournamentId: siblingTournamentId });
|
||||
|
||||
const deleteWhere = vi.fn().mockResolvedValue(undefined);
|
||||
const updateWhere = vi.fn().mockResolvedValue(undefined);
|
||||
const db = {
|
||||
query: {
|
||||
scoringEvents: {
|
||||
findFirst,
|
||||
findMany: vi.fn().mockResolvedValue(remaining),
|
||||
},
|
||||
},
|
||||
transaction: vi.fn(async (callback) => callback(db)),
|
||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||
update: vi.fn(() => ({ set: vi.fn(() => ({ where: updateWhere })) })),
|
||||
} as any;
|
||||
return { db, deleteWhere };
|
||||
}
|
||||
|
||||
it("keeps the tournament and promotes nothing when a non-primary window is deleted", async () => {
|
||||
const { db } = makeSharedDb({
|
||||
deletedEvent: { tournamentId: "tournament-1", isPrimary: false },
|
||||
remaining: [{ id: "event-2", isPrimary: true }],
|
||||
});
|
||||
|
||||
const result = await deleteScoringEvent("event-1", db);
|
||||
|
||||
expect(result.remainingWindows).toBe(1);
|
||||
expect(result.promotedPrimaryId).toBeNull();
|
||||
expect(result.deletedTournament).toBe(false);
|
||||
});
|
||||
|
||||
it("does not promote a primary when a golf-style tournament (no primary) loses a window", async () => {
|
||||
// Golf-style shared majors intentionally have no primary window — every
|
||||
// linked event is isPrimary=false. Deleting one must not flip a sibling
|
||||
// into a primary, which would change its scoring/guard behavior.
|
||||
const { db } = makeSharedDb({
|
||||
deletedEvent: { tournamentId: "tournament-1", isPrimary: false },
|
||||
remaining: [
|
||||
{ id: "event-2", isPrimary: false },
|
||||
{ id: "event-3", isPrimary: false },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await deleteScoringEvent("event-1", db);
|
||||
|
||||
expect(result.promotedPrimaryId).toBeNull();
|
||||
expect(result.remainingWindows).toBe(2);
|
||||
});
|
||||
|
||||
it("promotes the earliest remaining window when the primary window is deleted", async () => {
|
||||
const { db } = makeSharedDb({
|
||||
deletedEvent: { tournamentId: "tournament-1", isPrimary: true },
|
||||
// findMany is ordered asc(createdAt); first is the earliest.
|
||||
remaining: [
|
||||
{ id: "event-2", isPrimary: false },
|
||||
{ id: "event-3", isPrimary: false },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await deleteScoringEvent("event-1", db);
|
||||
|
||||
expect(result.promotedPrimaryId).toBe("event-2");
|
||||
expect(result.remainingWindows).toBe(2);
|
||||
});
|
||||
|
||||
it("leaves the orphaned tournament intact when the last window is deleted without the flag", async () => {
|
||||
const { db } = makeSharedDb({
|
||||
deletedEvent: { tournamentId: "tournament-1", isPrimary: true },
|
||||
remaining: [],
|
||||
});
|
||||
|
||||
const result = await deleteScoringEvent("event-1", db);
|
||||
|
||||
expect(result.remainingWindows).toBe(0);
|
||||
expect(result.deletedTournament).toBe(false);
|
||||
});
|
||||
|
||||
it("deletes the orphaned tournament when the last window is deleted with the flag", async () => {
|
||||
const { db } = makeSharedDb({
|
||||
deletedEvent: { tournamentId: "tournament-1", isPrimary: true },
|
||||
remaining: [],
|
||||
});
|
||||
|
||||
const result = await deleteScoringEvent("event-1", db, {
|
||||
deleteOrphanTournament: true,
|
||||
});
|
||||
|
||||
expect(result.deletedTournament).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -111,50 +111,4 @@ describe("simulator input model", () => {
|
|||
expect(byParticipant.get("direct-elo")?.sourceElo).toBe(1600);
|
||||
expect(byParticipant.get("generated-elo")?.sourceElo).toBeNull();
|
||||
});
|
||||
|
||||
it("hides an Elo flagged as projection-derived so the projection is re-derived", async () => {
|
||||
// This is what stops a stale Elo from winning the baseEloPriority race. A row
|
||||
// carrying projectedWins and a projectedWins method flag must surface with a
|
||||
// null sourceElo, so resolveSourceElos falls through to the projection rather
|
||||
// than reusing an Elo that was itself derived from an older projection.
|
||||
mockDb.query.seasonParticipants.findMany.mockResolvedValue([
|
||||
{ id: "projected" },
|
||||
{ id: "hand-entered" },
|
||||
]);
|
||||
mockDb.query.seasonParticipantSimulatorInputs.findMany.mockResolvedValue([
|
||||
{
|
||||
participantId: "projected",
|
||||
sourceOdds: null,
|
||||
sourceElo: 1561,
|
||||
worldRanking: null,
|
||||
rating: null,
|
||||
projectedWins: "95.00",
|
||||
projectedTablePoints: null,
|
||||
seed: null,
|
||||
region: null,
|
||||
metadata: { sourceEloMethod: "projectedWins" },
|
||||
},
|
||||
{
|
||||
participantId: "hand-entered",
|
||||
sourceOdds: null,
|
||||
sourceElo: 1561,
|
||||
worldRanking: null,
|
||||
rating: null,
|
||||
projectedWins: "95.00",
|
||||
projectedTablePoints: null,
|
||||
seed: null,
|
||||
region: null,
|
||||
metadata: {},
|
||||
},
|
||||
]);
|
||||
mockDb.query.seasonParticipantExpectedValues.findMany.mockResolvedValue([]);
|
||||
|
||||
const inputs = await getParticipantSimulatorInputs("season-1");
|
||||
const byParticipant = new Map(inputs.map((input) => [input.participantId, input]));
|
||||
|
||||
expect(byParticipant.get("projected")?.sourceElo).toBeNull();
|
||||
expect(byParticipant.get("projected")?.projectedWins).toBe(95);
|
||||
// No flag means the admin entered that Elo themselves — it is trusted as direct.
|
||||
expect(byParticipant.get("hand-entered")?.sourceElo).toBe(1561);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,11 +27,7 @@ vi.mock("drizzle-orm", () => ({
|
|||
asc: (col: unknown) => ({ type: "asc", col }),
|
||||
}));
|
||||
|
||||
import {
|
||||
findAllSportsSeasons,
|
||||
findDraftableSportsSeasons,
|
||||
findDraftScheduleForHorizon,
|
||||
} from "../sports-season";
|
||||
import { findAllSportsSeasons, findDraftableSportsSeasons } from "../sports-season";
|
||||
import { database } from "~/database/context";
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
|
@ -129,58 +125,3 @@ describe("findDraftableSportsSeasons", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findDraftScheduleForHorizon", () => {
|
||||
const horizonWindows = [
|
||||
{
|
||||
id: "w1",
|
||||
name: "2026 NBA Playoffs",
|
||||
year: 2026,
|
||||
status: "upcoming",
|
||||
draftOn: today,
|
||||
draftOff: future,
|
||||
sport: { id: "nba", name: "NBA", slug: "nba", iconUrl: null },
|
||||
},
|
||||
{
|
||||
id: "w2",
|
||||
name: "2026 F1 Season",
|
||||
year: 2026,
|
||||
status: "active",
|
||||
draftOn: today,
|
||||
draftOff: future,
|
||||
sport: { id: "f1", name: "F1", slug: "f1", iconUrl: null },
|
||||
},
|
||||
];
|
||||
|
||||
it("maps rows to windows carrying their sport info", async () => {
|
||||
vi.mocked(database).mockReturnValue(makeMockDb(horizonWindows) as never);
|
||||
const result = await findDraftScheduleForHorizon(6);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toMatchObject({
|
||||
id: "w1",
|
||||
draftOn: today,
|
||||
draftOff: future,
|
||||
sport: { id: "nba", name: "NBA" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an empty array when no windows overlap the horizon", async () => {
|
||||
vi.mocked(database).mockReturnValue(makeMockDb([]) as never);
|
||||
const result = await findDraftScheduleForHorizon(12);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("queries with a where clause, ordering, and the sport relation", async () => {
|
||||
const mockDb = makeMockDb([]);
|
||||
vi.mocked(database).mockReturnValue(mockDb as never);
|
||||
|
||||
await findDraftScheduleForHorizon(6);
|
||||
expect(mockDb.query.sportsSeasons.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.anything(),
|
||||
orderBy: expect.anything(),
|
||||
with: expect.anything(),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -80,9 +80,6 @@ function makeDb(
|
|||
},
|
||||
scoringEvents: {
|
||||
findFirst: vi.fn().mockResolvedValue({ bracketTemplateId: null }),
|
||||
// getBracketTemplateIdsForSportsSeasons filters to events that carry a
|
||||
// template, so "no bracket template" is an empty result, not a null row.
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
seasonParticipantResults: {
|
||||
findMany: vi.fn().mockResolvedValue(seasonResults),
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import {
|
|||
findTournamentBySportNameYear,
|
||||
upsertTournament,
|
||||
updateTournamentStatus,
|
||||
deleteTournament,
|
||||
} from "../tournament";
|
||||
import { database } from "~/database/context";
|
||||
|
||||
|
|
@ -173,26 +172,3 @@ describe("updateTournamentStatus", () => {
|
|||
expect(result.status).toBe("in_progress");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteTournament", () => {
|
||||
it("deletes the tournament row by id", async () => {
|
||||
const where = vi.fn().mockResolvedValue(undefined);
|
||||
const db = { delete: vi.fn().mockReturnValue({ where }) };
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
|
||||
await deleteTournament(TOURNAMENT_ID);
|
||||
|
||||
expect(db.delete).toHaveBeenCalledTimes(1);
|
||||
expect(where).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses a provided db when passed (transaction)", async () => {
|
||||
const where = vi.fn().mockResolvedValue(undefined);
|
||||
const providedDb = { delete: vi.fn().mockReturnValue({ where }) };
|
||||
|
||||
await deleteTournament(TOURNAMENT_ID, providedDb as never);
|
||||
|
||||
expect(providedDb.delete).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(database)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { and, desc, inArray, isNotNull } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Resolve which bracket template a sports season's placements should be scored against.
|
||||
*
|
||||
* A sports season can own several scoring events — a bracket plus schedule events, or a
|
||||
* re-created bracket alongside a stale one — and only some of them carry a
|
||||
* bracketTemplateId. Picking an arbitrary row is not harmless: calculateBracketPoints
|
||||
* falls back to the standard single 5th–8th tier when the template id is null, which
|
||||
* silently collapses the two-tier templates (llws_20, afl_10) so a team locked into
|
||||
* 5th–6th and one locked into 7th–8th both score the flat 5–8 average. The 3rd/4th
|
||||
* distinction that llws_20 and fifa_48 have goes the same way.
|
||||
*
|
||||
* So: only events that actually carry a template are considered, most recent first —
|
||||
* matching the "a re-created event wins over a stale one" rule the LLWS simulator uses
|
||||
* when it picks its bracket event.
|
||||
*
|
||||
* Every requested season gets an entry, null when it has no bracket event, so callers
|
||||
* can cache the negative result too.
|
||||
*/
|
||||
export async function getBracketTemplateIdsForSportsSeasons(
|
||||
sportsSeasonIds: string[],
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<Map<string, string | null>> {
|
||||
const resolved = new Map<string, string | null>(
|
||||
sportsSeasonIds.map((id) => [id, null])
|
||||
);
|
||||
if (sportsSeasonIds.length === 0) return resolved;
|
||||
|
||||
const db = providedDb || database();
|
||||
|
||||
const events = await db.query.scoringEvents.findMany({
|
||||
where: and(
|
||||
inArray(schema.scoringEvents.sportsSeasonId, sportsSeasonIds),
|
||||
isNotNull(schema.scoringEvents.bracketTemplateId)
|
||||
),
|
||||
columns: { sportsSeasonId: true, bracketTemplateId: true },
|
||||
// createdAt can tie when a bracket is generated in the same transaction as a
|
||||
// sibling event, so id breaks the tie and keeps the choice deterministic.
|
||||
orderBy: [desc(schema.scoringEvents.createdAt), desc(schema.scoringEvents.id)],
|
||||
});
|
||||
|
||||
for (const event of events) {
|
||||
// Ordered newest-first, so the first row seen for a season is the one to keep.
|
||||
// The isNotNull filter means bracketTemplateId is set, but a mocked or partial row
|
||||
// could still carry null — skip those rather than caching a null as a real answer.
|
||||
if (resolved.get(event.sportsSeasonId) === null && event.bracketTemplateId) {
|
||||
resolved.set(event.sportsSeasonId, event.bracketTemplateId);
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-season form of getBracketTemplateIdsForSportsSeasons.
|
||||
*/
|
||||
export async function getBracketTemplateIdForSportsSeason(
|
||||
sportsSeasonId: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<string | null> {
|
||||
const resolved = await getBracketTemplateIdsForSportsSeasons([sportsSeasonId], providedDb);
|
||||
return resolved.get(sportsSeasonId) ?? null;
|
||||
}
|
||||
|
|
@ -17,8 +17,6 @@ import { cs2MajorStageResults, seasonParticipants, eventResults } from "~/databa
|
|||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { getQPConfig, calculateSplitQualifyingPoints, writeEventResultsQP, recalculateParticipantQP } from "~/models/qualifying-points";
|
||||
import { deleteEventResults } from "~/models/event-result";
|
||||
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
|
||||
import { logger } from "~/lib/logger";
|
||||
|
||||
export interface Cs2StageResult {
|
||||
id: string;
|
||||
|
|
@ -440,30 +438,5 @@ export async function assignCs2EliminationQP(
|
|||
}
|
||||
}
|
||||
|
||||
// Snapshot existing QP before writing so the notification only covers new/changed participants,
|
||||
// preventing earlier stage exits from being re-announced on each subsequent stage call.
|
||||
const existingRows = await db.query.eventResults.findMany({
|
||||
where: eq(eventResults.scoringEventId, scoringEventId),
|
||||
});
|
||||
const existingQPBySPId = new Map<string, number>(
|
||||
existingRows
|
||||
.filter((r) => r.qualifyingPointsAwarded !== null)
|
||||
.map((r) => [r.seasonParticipantId, parseFloat(r.qualifyingPointsAwarded as string)])
|
||||
);
|
||||
|
||||
await writeEventResultsQP(scoringEventId, sportsSeasonId, resultsByParticipant, db);
|
||||
|
||||
const changedIds = new Set<string>(
|
||||
[...resultsByParticipant.entries()]
|
||||
.filter(([id, { qp }]) => existingQPBySPId.get(id) !== qp)
|
||||
.map(([id]) => id)
|
||||
);
|
||||
|
||||
if (changedIds.size > 0) {
|
||||
try {
|
||||
await notifyQualifyingPointsUpdate(sportsSeasonId, scoringEventId, db, changedIds);
|
||||
} catch (error) {
|
||||
logger.error(`[CS2MajorStage] QP Discord notification failed for event ${scoringEventId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import {
|
|||
calculateBracketPoints,
|
||||
calculateSharedPlacementPoints,
|
||||
} from "./scoring-rules";
|
||||
import { getBracketTemplateIdsForSportsSeasons } from "./bracket-template";
|
||||
|
||||
export async function createDraftPick(data: {
|
||||
seasonId: string;
|
||||
|
|
@ -176,10 +175,18 @@ export async function getDraftedParticipantsWithPoints(
|
|||
}
|
||||
|
||||
// Batch-fetch bracket template IDs (one per sports season)
|
||||
const bracketTemplateMap =
|
||||
bracketSeasonIds.size > 0
|
||||
? await getBracketTemplateIdsForSportsSeasons([...bracketSeasonIds], db)
|
||||
: new Map<string, string | null>();
|
||||
const bracketTemplateMap = new Map<string, string | null>();
|
||||
if (bracketSeasonIds.size > 0) {
|
||||
const events = await db.query.scoringEvents.findMany({
|
||||
where: inArray(schema.scoringEvents.sportsSeasonId, [...bracketSeasonIds]),
|
||||
columns: { sportsSeasonId: true, bracketTemplateId: true },
|
||||
});
|
||||
for (const ev of events) {
|
||||
if (!bracketTemplateMap.has(ev.sportsSeasonId)) {
|
||||
bracketTemplateMap.set(ev.sportsSeasonId, ev.bracketTemplateId ?? null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Batch-fetch QP totals for qualifying_points participants
|
||||
const qpMap = new Map<string, number>(); // participantId → totalQP
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@
|
|||
*/
|
||||
|
||||
import { database } from "~/database/context";
|
||||
import { seasonParticipantExpectedValues, seasonParticipants } from "~/database/schema";
|
||||
import { eq, and, count, sql } from "drizzle-orm";
|
||||
import { seasonParticipantExpectedValues, seasonParticipants, seasonParticipantSimulatorInputs } from "~/database/schema";
|
||||
import { eq, and, count, inArray, sql } from "drizzle-orm";
|
||||
import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator";
|
||||
import { calculateEV, normalizeProbabilities, calculateReplacementLevel, calculateVORP } from "~/services/ev-calculator";
|
||||
import { batchUpsertParticipantSimulatorInputs } from "~/models/simulator";
|
||||
import { batchSaveParticipantSimulatorSourceOdds, batchUpsertParticipantSimulatorInputs } from "~/models/simulator";
|
||||
|
||||
export type ProbabilitySource = "manual" | "futures_odds" | "elo_simulation" | "performance_model";
|
||||
|
||||
|
|
@ -366,6 +366,100 @@ export async function batchUpsertParticipantEVs(
|
|||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save American odds for a batch of seasonParticipants without touching probabilities or EV.
|
||||
* Used by the futures-odds admin page to persist odds before running the full simulation.
|
||||
*/
|
||||
export async function batchSaveSourceOdds(
|
||||
inputs: Array<{ participantId: string; sportsSeasonId: string; sourceOdds: number }>
|
||||
): Promise<void> {
|
||||
const db = database();
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const now = new Date();
|
||||
|
||||
for (const { participantId, sportsSeasonId, sourceOdds } of inputs) {
|
||||
const existing = await tx
|
||||
.select({ id: seasonParticipantExpectedValues.id })
|
||||
.from(seasonParticipantExpectedValues)
|
||||
.where(
|
||||
and(
|
||||
eq(seasonParticipantExpectedValues.participantId, participantId),
|
||||
eq(seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
await tx
|
||||
.update(seasonParticipantExpectedValues)
|
||||
// Persist the odds and label the source as odds-driven for display.
|
||||
// We no longer null a stored Elo here: how much these odds move it is
|
||||
// decided at run time by the season's blend weight
|
||||
// (SimulatorInputPolicy.oddsWeight), and prepareSimulatorInputsForRun
|
||||
// overwrites the resolved Elo before the simulator reads it.
|
||||
.set({ sourceOdds, source: "futures_odds", updatedAt: now })
|
||||
.where(eq(seasonParticipantExpectedValues.id, existing[0].id));
|
||||
} else {
|
||||
// Insert a stub record — probabilities/EV will be filled in by the simulator
|
||||
await tx.insert(seasonParticipantExpectedValues).values({
|
||||
participantId,
|
||||
sportsSeasonId,
|
||||
probFirst: "0",
|
||||
probSecond: "0",
|
||||
probThird: "0",
|
||||
probFourth: "0",
|
||||
probFifth: "0",
|
||||
probSixth: "0",
|
||||
probSeventh: "0",
|
||||
probEighth: "0",
|
||||
expectedValue: "0",
|
||||
source: "futures_odds",
|
||||
sourceOdds,
|
||||
calculatedAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await batchSaveParticipantSimulatorSourceOdds(inputs);
|
||||
}
|
||||
|
||||
export async function clearSourceOddsForParticipants(
|
||||
sportsSeasonId: string,
|
||||
participantIds: string[]
|
||||
): Promise<void> {
|
||||
if (participantIds.length === 0) return;
|
||||
const db = database();
|
||||
const now = new Date();
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(seasonParticipantExpectedValues)
|
||||
.set({ sourceOdds: null, updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId),
|
||||
inArray(seasonParticipantExpectedValues.participantId, participantIds)
|
||||
)
|
||||
);
|
||||
await tx
|
||||
.update(seasonParticipantSimulatorInputs)
|
||||
.set({
|
||||
sourceOdds: null,
|
||||
rating: null,
|
||||
metadata: sql`coalesce(${seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod'`,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(seasonParticipantSimulatorInputs.sportsSeasonId, sportsSeasonId),
|
||||
inArray(seasonParticipantSimulatorInputs.participantId, participantIds)
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist raw Elo ratings (and optional world rankings) for a batch of seasonParticipants.
|
||||
* Used by Elo-based simulators like snooker_bracket and darts_bracket.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { eq, and, inArray } from "drizzle-orm";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
|
||||
|
|
@ -104,33 +104,6 @@ export async function deleteParticipantResultsBySportsSeasonId(
|
|||
.where(eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the results of specific participants within one sports season.
|
||||
*
|
||||
* The season-wide delete above is too blunt for a single bracket: results are keyed by
|
||||
* sports season, not by event, so wiping the season takes every other event's placements
|
||||
* with it. Scoping to the participants a bracket actually holds lets reprocess-bracket
|
||||
* rebuild that bracket from scratch while leaving the rest of the season alone.
|
||||
*
|
||||
* No-ops on an empty id list — `inArray` with no values is not a valid SQL predicate.
|
||||
*/
|
||||
export async function deleteParticipantResultsForParticipants(
|
||||
sportsSeasonId: string,
|
||||
participantIds: string[],
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<void> {
|
||||
if (participantIds.length === 0) return;
|
||||
const db = providedDb || database();
|
||||
await db
|
||||
.delete(schema.seasonParticipantResults)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId),
|
||||
inArray(schema.seasonParticipantResults.participantId, participantIds)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set result for a participant in a sports season
|
||||
* Points are calculated on-demand based on each fantasy league's scoring rules
|
||||
|
|
|
|||
|
|
@ -6,19 +6,8 @@ import {
|
|||
getBracketTemplate,
|
||||
buildNCAA68SlotMap,
|
||||
matchIndexForSeedSlot,
|
||||
llwsMatchNumber,
|
||||
llwsSideAndLocal,
|
||||
STANDARD_BRACKET_SEEDING,
|
||||
} from "~/lib/bracket-templates";
|
||||
import {
|
||||
LLWS_LOSER_ADVANCES_ROUNDS,
|
||||
resolveLLWSAdvancement,
|
||||
type LLWSResolvedDestination,
|
||||
} from "~/lib/llws-bracket";
|
||||
import {
|
||||
resolveAflWildcardPlacements,
|
||||
type AflWildcardResult,
|
||||
} from "~/lib/afl-wildcard-reseed";
|
||||
|
||||
export type PlayoffMatch = typeof schema.playoffMatches.$inferSelect;
|
||||
export type NewPlayoffMatch = typeof schema.playoffMatches.$inferInsert;
|
||||
|
|
@ -153,108 +142,6 @@ export async function setMatchWinner(
|
|||
return match;
|
||||
}
|
||||
|
||||
/** A draw match with participants/winner already resolved to ids. */
|
||||
export interface ResolvedDrawMatch {
|
||||
externalMatchId: string;
|
||||
round: string;
|
||||
matchNumber: number;
|
||||
participant1Id: string | null;
|
||||
participant2Id: string | null;
|
||||
winnerId: string | null;
|
||||
loserId: string | null;
|
||||
isScoring: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate (or advance) a bracket from a fully-resolved external draw, keyed on
|
||||
* `externalMatchId` so repeated syncs update in place rather than duplicating.
|
||||
*
|
||||
* Unlike `generateBracketFromTemplate` (which seeds only round 1 and leaves later
|
||||
* rounds empty for `advanceWinnerTemplate`), this writes every round's actual
|
||||
* matchups and known winners directly from the source — appropriate for a feed
|
||||
* (e.g. Wikipedia) that reports the full draw including completed rounds. A row
|
||||
* is marked complete when both its winner and loser are known.
|
||||
*
|
||||
* @returns counts of rows written (inserted or updated), those carrying a result,
|
||||
* and the participant ids of losers whose match *transitioned to complete on this
|
||||
* run* (a row that was absent or not-yet-complete before and is complete now).
|
||||
* That set is the newly-decided eliminations — used to announce knockouts once,
|
||||
* idempotently across re-syncs, since playoff_matches persist between syncs.
|
||||
*/
|
||||
export async function populateBracketFromDraw(
|
||||
eventId: string,
|
||||
matches: ResolvedDrawMatch[]
|
||||
): Promise<{ written: number; completed: number; newlyDecidedLoserIds: string[] }> {
|
||||
const db = database();
|
||||
|
||||
const existing = await db.query.playoffMatches.findMany({
|
||||
where: eq(schema.playoffMatches.scoringEventId, eventId),
|
||||
});
|
||||
const byExternalId = new Map(
|
||||
existing
|
||||
.filter((m) => m.externalMatchId)
|
||||
.map((m) => [m.externalMatchId as string, m])
|
||||
);
|
||||
|
||||
const toInsert: NewPlayoffMatch[] = [];
|
||||
let written = 0;
|
||||
let completed = 0;
|
||||
const newlyDecidedLoserIds: string[] = [];
|
||||
|
||||
for (const m of matches) {
|
||||
const isComplete = m.winnerId !== null && m.loserId !== null;
|
||||
if (isComplete) completed++;
|
||||
|
||||
const existingRow = byExternalId.get(m.externalMatchId);
|
||||
// Loser is "newly decided" when this match reaches completion for the first
|
||||
// time: either a brand-new complete row, or an existing row that was not
|
||||
// complete before. Re-syncing an already-complete match yields nothing.
|
||||
if (isComplete && m.loserId && !existingRow?.isComplete) {
|
||||
newlyDecidedLoserIds.push(m.loserId);
|
||||
}
|
||||
|
||||
if (existingRow) {
|
||||
await db
|
||||
.update(schema.playoffMatches)
|
||||
.set({
|
||||
round: m.round,
|
||||
matchNumber: m.matchNumber,
|
||||
participant1Id: m.participant1Id,
|
||||
participant2Id: m.participant2Id,
|
||||
winnerId: m.winnerId,
|
||||
loserId: m.loserId,
|
||||
isComplete,
|
||||
isScoring: m.isScoring,
|
||||
templateRound: m.round,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.playoffMatches.id, existingRow.id));
|
||||
written++;
|
||||
} else {
|
||||
toInsert.push({
|
||||
scoringEventId: eventId,
|
||||
round: m.round,
|
||||
matchNumber: m.matchNumber,
|
||||
participant1Id: m.participant1Id,
|
||||
participant2Id: m.participant2Id,
|
||||
winnerId: m.winnerId,
|
||||
loserId: m.loserId,
|
||||
isComplete,
|
||||
isScoring: m.isScoring,
|
||||
templateRound: m.round,
|
||||
externalMatchId: m.externalMatchId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (toInsert.length > 0) {
|
||||
await createManyPlayoffMatches(toInsert);
|
||||
written += toInsert.length;
|
||||
}
|
||||
|
||||
return { written, completed, newlyDecidedLoserIds };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a standard single elimination bracket structure
|
||||
*/
|
||||
|
|
@ -478,11 +365,6 @@ export async function generateBracketFromTemplate(
|
|||
return await generateNBA20Bracket(eventId, template, participantIds);
|
||||
}
|
||||
|
||||
// LLWS 20 requires special handling for its two double-elimination brackets
|
||||
if (templateId === "llws_20") {
|
||||
return await generateLLWS20Bracket(eventId, template, participantIds);
|
||||
}
|
||||
|
||||
const matches: NewPlayoffMatch[] = [];
|
||||
|
||||
// Generate matches for each round in the template
|
||||
|
|
@ -746,11 +628,9 @@ async function generateNFL14Bracket(
|
|||
* Structure:
|
||||
* - Wildcard Round: 7v10, 8v9
|
||||
* - Qualifying Finals: 1v4, 2v3 (winners get bye to Preliminary Finals, losers to Semi-Finals)
|
||||
* - Elimination Finals: 5 and 6 host the two Wildcard winners, re-seeded by ladder
|
||||
* position — 5th draws the lower-ranked winner, 6th the higher-ranked one
|
||||
* - Semi-Finals: SF1 = QF1 loser v EF1 winner, SF2 = QF2 loser v EF2 winner
|
||||
* - Preliminary Finals: PF1 = QF1 winner v SF2 winner, PF2 = QF2 winner v SF1 winner
|
||||
* (the crossover keeps a QF loser away from the side that just beat it)
|
||||
* - Elimination Finals: 5v8, 6v7 (where 7 and 8 are wildcard winners)
|
||||
* - Semi-Finals: QF losers vs EF winners
|
||||
* - Preliminary Finals: QF winners vs SF winners
|
||||
* - Grand Final: PF winners
|
||||
*/
|
||||
async function generateAFL10Bracket(
|
||||
|
|
@ -802,16 +682,14 @@ async function generateAFL10Bracket(
|
|||
});
|
||||
}
|
||||
|
||||
// Elimination Finals: 5th and 6th host the two Wildcard winners. Which winner lands
|
||||
// where is decided by ladder position once both games are played (see
|
||||
// resolveAflWildcardPlacements), not by a fixed crossover from a Wildcard match.
|
||||
// Elimination Finals: 5th vs TBD (wildcard winner), 6th vs TBD (wildcard winner)
|
||||
const eliminationSeeding = [
|
||||
{ higher: 4, opponent: "lower-ranked WC winner" }, // #5 (index 4)
|
||||
{ higher: 5, opponent: "higher-ranked WC winner" }, // #6 (index 5)
|
||||
{ higher: 4, wildcard: 2 }, // #5 (index 4) vs Wildcard Match 2 winner
|
||||
{ higher: 5, wildcard: 1 }, // #6 (index 5) vs Wildcard Match 1 winner
|
||||
];
|
||||
|
||||
for (let i = 0; i < eliminationSeeding.length; i++) {
|
||||
const { higher, opponent } = eliminationSeeding[i];
|
||||
const { higher, wildcard } = eliminationSeeding[i];
|
||||
matches.push({
|
||||
scoringEventId: eventId,
|
||||
round: "Elimination Finals",
|
||||
|
|
@ -821,11 +699,11 @@ async function generateAFL10Bracket(
|
|||
isComplete: false,
|
||||
isScoring: true, // Losers share 7th-8th
|
||||
templateRound: "Elimination Finals",
|
||||
seedInfo: participantIds ? `${higher + 1} vs ${opponent}` : null,
|
||||
seedInfo: participantIds ? `${higher + 1} vs WC${wildcard}` : null,
|
||||
});
|
||||
}
|
||||
|
||||
// Semi-Finals: SF n = QF n loser vs EF n winner (TBD vs TBD)
|
||||
// Semi-Finals: QF losers vs EF winners (TBD vs TBD)
|
||||
for (let i = 0; i < 2; i++) {
|
||||
matches.push({
|
||||
scoringEventId: eventId,
|
||||
|
|
@ -871,257 +749,15 @@ async function generateAFL10Bracket(
|
|||
return await createManyPlayoffMatches(matches);
|
||||
}
|
||||
|
||||
/** What a re-seed changed, by Elimination Finals match number. */
|
||||
export interface AflEliminationReseed {
|
||||
vacated: number[];
|
||||
filled: Array<{ matchNumber: number; participantId: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the decided Wildcard winners in the Elimination Finals they belong in.
|
||||
*
|
||||
* The two winners are re-seeded by ladder position — 5th meets the lower-ranked one and
|
||||
* 6th the higher-ranked one — rather than crossing over from a fixed Wildcard match. That
|
||||
* destination depends on both games, so this reconciles both slots against the results
|
||||
* recorded so far every time it runs: it places a winner whose slot only became certain
|
||||
* once the other game was decided, and moves one that an earlier (or corrected) result,
|
||||
* or a bracket advanced before this rule existed, put in the other slot.
|
||||
*
|
||||
* `pending` supplies a result that may not be in the database yet — the row read back
|
||||
* while advancing a match can predate the winner being written to it.
|
||||
*
|
||||
* Idempotent: pairings that are already right do no writes.
|
||||
*/
|
||||
export async function reseedAflEliminationFinals(
|
||||
eventId: string,
|
||||
pending?: { matchId: string; winnerId: string }
|
||||
): Promise<AflEliminationReseed> {
|
||||
const [wcMatches, efMatches] = await Promise.all([
|
||||
findPlayoffMatchesByEventIdAndRound(eventId, "Wildcard Round"),
|
||||
findPlayoffMatchesByEventIdAndRound(eventId, "Elimination Finals"),
|
||||
]);
|
||||
|
||||
// Nothing to reconcile against is a bad event id or a broken bracket, not a no-op.
|
||||
if (wcMatches.length === 0 || efMatches.length === 0) {
|
||||
throw new Error(
|
||||
`Event ${eventId} has no AFL Wildcard Round / Elimination Finals matches to re-seed`
|
||||
);
|
||||
}
|
||||
|
||||
const winnerByMatchNumber = new Map<number, string>();
|
||||
for (const wc of wcMatches) {
|
||||
const decidedWinner =
|
||||
pending && wc.id === pending.matchId ? pending.winnerId : wc.isComplete ? wc.winnerId : null;
|
||||
if (decidedWinner) winnerByMatchNumber.set(wc.matchNumber, decidedWinner);
|
||||
}
|
||||
|
||||
const results: AflWildcardResult[] = wcMatches.map((wc) => {
|
||||
const decidedWinner = winnerByMatchNumber.get(wc.matchNumber) ?? null;
|
||||
if (decidedWinner === null) return { matchNumber: wc.matchNumber, winnerSlot: null };
|
||||
if (decidedWinner === wc.participant1Id) return { matchNumber: wc.matchNumber, winnerSlot: 1 };
|
||||
if (decidedWinner === wc.participant2Id) return { matchNumber: wc.matchNumber, winnerSlot: 2 };
|
||||
throw new Error(
|
||||
`Wildcard Round match ${wc.matchNumber} winner is not one of its participants`
|
||||
);
|
||||
});
|
||||
|
||||
const wanted = new Map<number, string>();
|
||||
for (const placement of resolveAflWildcardPlacements(results)) {
|
||||
const placedWinner = winnerByMatchNumber.get(placement.wildcardMatchNumber);
|
||||
if (placedWinner) wanted.set(placement.eliminationMatchNumber, placedWinner);
|
||||
}
|
||||
|
||||
// Only these teams can legitimately be moved between the two Elimination Finals;
|
||||
// anyone else in a slot came from somewhere this function knows nothing about.
|
||||
const wildcardParticipants = new Set<string>();
|
||||
for (const wc of wcMatches) {
|
||||
if (wc.participant1Id) wildcardParticipants.add(wc.participant1Id);
|
||||
if (wc.participant2Id) wildcardParticipants.add(wc.participant2Id);
|
||||
}
|
||||
|
||||
const slotsToClear: Array<{ id: string; matchNumber: number }> = [];
|
||||
const slotsToFill: Array<{ id: string; matchNumber: number; participantId: string }> = [];
|
||||
|
||||
for (const efMatch of efMatches) {
|
||||
const occupant = efMatch.participant2Id;
|
||||
const belongsHere = wanted.get(efMatch.matchNumber) ?? null;
|
||||
if (occupant === belongsHere) continue;
|
||||
|
||||
if (occupant !== null && !wildcardParticipants.has(occupant)) {
|
||||
throw new Error(`EF ${efMatch.matchNumber} participant2 already filled`);
|
||||
}
|
||||
// Re-seeding a game that has already been played would rewrite who contested a
|
||||
// recorded result. Surface that (this message is not one callers swallow) rather
|
||||
// than quietly corrupting the bracket.
|
||||
if (occupant !== null && (efMatch.isComplete || efMatch.winnerId)) {
|
||||
throw new Error(
|
||||
`Elimination Finals match ${efMatch.matchNumber} already has a recorded result, ` +
|
||||
`so its Wildcard qualifier cannot be re-seeded — clear and regenerate the bracket`
|
||||
);
|
||||
}
|
||||
if (occupant !== null) slotsToClear.push({ id: efMatch.id, matchNumber: efMatch.matchNumber });
|
||||
if (belongsHere !== null) {
|
||||
slotsToFill.push({ id: efMatch.id, matchNumber: efMatch.matchNumber, participantId: belongsHere });
|
||||
}
|
||||
}
|
||||
|
||||
const reseed: AflEliminationReseed = {
|
||||
vacated: slotsToClear.map((slot) => slot.matchNumber),
|
||||
filled: slotsToFill.map(({ matchNumber, participantId }) => ({ matchNumber, participantId })),
|
||||
};
|
||||
|
||||
if (slotsToClear.length === 0 && slotsToFill.length === 0) return reseed;
|
||||
|
||||
// One transaction, vacating before filling: a half-applied re-seed would leave the
|
||||
// same team in both Elimination Finals.
|
||||
const db = database();
|
||||
await db.transaction(async (tx) => {
|
||||
const now = new Date();
|
||||
for (const slot of slotsToClear) {
|
||||
await tx
|
||||
.update(schema.playoffMatches)
|
||||
.set({ participant2Id: null, updatedAt: now })
|
||||
.where(eq(schema.playoffMatches.id, slot.id));
|
||||
}
|
||||
for (const slot of slotsToFill) {
|
||||
await tx
|
||||
.update(schema.playoffMatches)
|
||||
.set({ participant2Id: slot.participantId, updatedAt: now })
|
||||
.where(eq(schema.playoffMatches.id, slot.id));
|
||||
}
|
||||
});
|
||||
|
||||
return reseed;
|
||||
}
|
||||
|
||||
/** What a Semi-Finals re-seed changed, by Semi-Finals match number. */
|
||||
export interface AflSemiFinalReseed {
|
||||
vacated: number[];
|
||||
filled: Array<{ matchNumber: number; participantId: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the decided Elimination Final winners in the Semi-Finals they belong in.
|
||||
*
|
||||
* Unlike the Wildcard Round, this pathway is fixed: Elimination Final n feeds Semi-Final
|
||||
* n, so SF1 is the QF1 loser against the EF1 winner and SF2 the QF2 loser against the EF2
|
||||
* winner. The crossover in this system comes a round later, at Semi-Final → Preliminary
|
||||
* Final, so that a Qualifying Final loser cannot meet the side that just beat it.
|
||||
*
|
||||
* Brackets advanced before this was fixed crossed the two winners — the EF1 winner went
|
||||
* to SF2 and the EF2 winner to SF1 — which is why this reconciles both slots against the
|
||||
* results recorded so far rather than writing the one it was called for: a winner sitting
|
||||
* in the wrong Semi-Final is vacated, and a corrected Elimination Final result pulls the
|
||||
* beaten team back out instead of leaving it alive.
|
||||
*
|
||||
* `pending` supplies a result that may not be in the database yet — the row read back
|
||||
* while advancing a match can predate the winner being written to it.
|
||||
*
|
||||
* Idempotent: pairings that are already right do no writes.
|
||||
*/
|
||||
export async function reseedAflSemiFinals(
|
||||
eventId: string,
|
||||
pending?: { matchId: string; winnerId: string }
|
||||
): Promise<AflSemiFinalReseed> {
|
||||
const [efMatches, sfMatches] = await Promise.all([
|
||||
findPlayoffMatchesByEventIdAndRound(eventId, "Elimination Finals"),
|
||||
findPlayoffMatchesByEventIdAndRound(eventId, "Semi-Finals"),
|
||||
]);
|
||||
|
||||
// Nothing to reconcile against is a bad event id or a broken bracket, not a no-op.
|
||||
if (efMatches.length === 0 || sfMatches.length === 0) {
|
||||
throw new Error(
|
||||
`Event ${eventId} has no AFL Elimination Finals / Semi-Finals matches to re-seed`
|
||||
);
|
||||
}
|
||||
|
||||
// Elimination Final n feeds Semi-Final n, so a decided winner's destination never
|
||||
// depends on the other game.
|
||||
const wanted = new Map<number, string>();
|
||||
for (const ef of efMatches) {
|
||||
const decidedWinner =
|
||||
pending && ef.id === pending.matchId ? pending.winnerId : ef.isComplete ? ef.winnerId : null;
|
||||
if (!decidedWinner) continue;
|
||||
if (decidedWinner !== ef.participant1Id && decidedWinner !== ef.participant2Id) {
|
||||
throw new Error(
|
||||
`Elimination Finals match ${ef.matchNumber} winner is not one of its participants`
|
||||
);
|
||||
}
|
||||
wanted.set(ef.matchNumber, decidedWinner);
|
||||
}
|
||||
|
||||
// Only these teams can legitimately be moved between the two Semi-Finals; anyone else
|
||||
// in a slot came from somewhere this function knows nothing about.
|
||||
const eliminationParticipants = new Set<string>();
|
||||
for (const ef of efMatches) {
|
||||
if (ef.participant1Id) eliminationParticipants.add(ef.participant1Id);
|
||||
if (ef.participant2Id) eliminationParticipants.add(ef.participant2Id);
|
||||
}
|
||||
|
||||
const slotsToClear: Array<{ id: string; matchNumber: number }> = [];
|
||||
const slotsToFill: Array<{ id: string; matchNumber: number; participantId: string }> = [];
|
||||
|
||||
for (const sfMatch of sfMatches) {
|
||||
const occupant = sfMatch.participant2Id;
|
||||
const belongsHere = wanted.get(sfMatch.matchNumber) ?? null;
|
||||
if (occupant === belongsHere) continue;
|
||||
|
||||
if (occupant !== null && !eliminationParticipants.has(occupant)) {
|
||||
throw new Error(`SF ${sfMatch.matchNumber} participant2 already filled`);
|
||||
}
|
||||
// Re-seeding a game that has already been played would rewrite who contested a
|
||||
// recorded result. Surface that (this message is not one callers swallow) rather
|
||||
// than quietly corrupting the bracket.
|
||||
if (occupant !== null && (sfMatch.isComplete || sfMatch.winnerId)) {
|
||||
throw new Error(
|
||||
`Semi-Finals match ${sfMatch.matchNumber} already has a recorded result, ` +
|
||||
`so its Elimination Finals qualifier cannot be re-seeded — clear and regenerate the bracket`
|
||||
);
|
||||
}
|
||||
if (occupant !== null) slotsToClear.push({ id: sfMatch.id, matchNumber: sfMatch.matchNumber });
|
||||
if (belongsHere !== null) {
|
||||
slotsToFill.push({ id: sfMatch.id, matchNumber: sfMatch.matchNumber, participantId: belongsHere });
|
||||
}
|
||||
}
|
||||
|
||||
const reseed: AflSemiFinalReseed = {
|
||||
vacated: slotsToClear.map((slot) => slot.matchNumber),
|
||||
filled: slotsToFill.map(({ matchNumber, participantId }) => ({ matchNumber, participantId })),
|
||||
};
|
||||
|
||||
if (slotsToClear.length === 0 && slotsToFill.length === 0) return reseed;
|
||||
|
||||
// One transaction, vacating before filling: a half-applied re-seed would leave the
|
||||
// same team in both Semi-Finals.
|
||||
const db = database();
|
||||
await db.transaction(async (tx) => {
|
||||
const now = new Date();
|
||||
for (const slot of slotsToClear) {
|
||||
await tx
|
||||
.update(schema.playoffMatches)
|
||||
.set({ participant2Id: null, updatedAt: now })
|
||||
.where(eq(schema.playoffMatches.id, slot.id));
|
||||
}
|
||||
for (const slot of slotsToFill) {
|
||||
await tx
|
||||
.update(schema.playoffMatches)
|
||||
.set({ participant2Id: slot.participantId, updatedAt: now })
|
||||
.where(eq(schema.playoffMatches.id, slot.id));
|
||||
}
|
||||
});
|
||||
|
||||
return reseed;
|
||||
}
|
||||
|
||||
/**
|
||||
* AFL-specific advancement logic for the complex double-chance system
|
||||
* Phase 3.3: Handles both winners and losers advancing to different rounds
|
||||
*
|
||||
* Advancement rules:
|
||||
* - Wildcard Round: Winner → Elimination Finals (re-seeded by ladder position)
|
||||
* - Qualifying Finals: Winner → Preliminary Finals, Loser → Semi-Finals (QF n → PF n, SF n)
|
||||
* - Elimination Finals: Winner → Semi-Finals (EF n → SF n, a fixed pathway)
|
||||
* - Semi-Finals: Winner → Preliminary Finals (SF n crosses over: SF1 → PF2, SF2 → PF1)
|
||||
* - Wildcard Round: Winner → Elimination Finals
|
||||
* - Qualifying Finals: Winner → Preliminary Finals, Loser → Semi-Finals
|
||||
* - Elimination Finals: Winner → Semi-Finals
|
||||
* - Semi-Finals: Winner → Preliminary Finals
|
||||
* - Preliminary Finals: Winner → Grand Final
|
||||
*/
|
||||
async function advanceAFLWinner(
|
||||
|
|
@ -1131,10 +767,18 @@ async function advanceAFLWinner(
|
|||
): Promise<void> {
|
||||
const eventId = match.scoringEventId;
|
||||
|
||||
// Wildcard Round: winners are re-seeded into the Elimination Finals by ladder
|
||||
// position, so every result re-resolves both slots.
|
||||
// Wildcard Round: Winner advances to Elimination Finals
|
||||
if (match.round === "Wildcard Round") {
|
||||
await reseedAflEliminationFinals(eventId, { matchId: match.id, winnerId });
|
||||
// Wildcard Match 1 winner → EF Match 2, participant2Id
|
||||
// Wildcard Match 2 winner → EF Match 1, participant2Id
|
||||
const efMatchNumber = match.matchNumber === 1 ? 2 : 1;
|
||||
const efMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Elimination Finals");
|
||||
const efMatch = efMatches.find((m) => m.matchNumber === efMatchNumber);
|
||||
|
||||
if (!efMatch) throw new Error(`Elimination Finals match ${efMatchNumber} not found`);
|
||||
if (efMatch.participant2Id) throw new Error(`EF ${efMatchNumber} participant2 already filled`);
|
||||
|
||||
await updatePlayoffMatch(efMatch.id, { participant2Id: winnerId });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1162,11 +806,18 @@ async function advanceAFLWinner(
|
|||
return;
|
||||
}
|
||||
|
||||
// Elimination Finals: Winner → Semi-Finals. EF n feeds SF n — the crossover in this
|
||||
// system is a round later, at Semi-Finals → Preliminary Finals. Reconcile both slots so
|
||||
// a corrected result moves the qualifier instead of leaving the beaten team alive.
|
||||
// Elimination Finals: Winner → Semi-Finals
|
||||
if (match.round === "Elimination Finals") {
|
||||
await reseedAflSemiFinals(eventId, { matchId: match.id, winnerId });
|
||||
// EF Match 1 winner → SF2 participant2
|
||||
// EF Match 2 winner → SF1 participant2
|
||||
const sfMatchNumber = match.matchNumber === 1 ? 2 : 1;
|
||||
const sfMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Semi-Finals");
|
||||
const sfMatch = sfMatches.find((m) => m.matchNumber === sfMatchNumber);
|
||||
|
||||
if (!sfMatch) throw new Error(`Semi-Finals match ${sfMatchNumber} not found`);
|
||||
if (sfMatch.participant2Id) throw new Error(`SF ${sfMatchNumber} participant2 already filled`);
|
||||
|
||||
await updatePlayoffMatch(sfMatch.id, { participant2Id: winnerId });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1227,15 +878,6 @@ export async function advanceWinnerTemplate(
|
|||
return await advanceNBAPlayInWinner(match, winnerId, loserId);
|
||||
}
|
||||
|
||||
// Special handling for LLWS 20 double elimination: winners-bracket losers route
|
||||
// into the elimination bracket instead of being knocked out.
|
||||
if (template.id === "llws_20") {
|
||||
const loserId =
|
||||
match.participant1Id === winnerId ? match.participant2Id : match.participant1Id;
|
||||
if (!loserId) throw new Error("Cannot determine loser for LLWS advancement");
|
||||
return await advanceLLWSWinner(match, winnerId, loserId);
|
||||
}
|
||||
|
||||
// Special handling for AFL 10 double-chance system
|
||||
// Phase 3.3: AFL has complex winner/loser advancement rules
|
||||
if (template.id === "afl_10") {
|
||||
|
|
@ -1683,12 +1325,6 @@ export function doesLoserAdvance(
|
|||
if (templateId === "afl_10" && round === "Qualifying Finals") {
|
||||
return true;
|
||||
}
|
||||
// LLWS winners bracket: a loss drops the team into the elimination bracket, so it
|
||||
// must not be recorded as an elimination. (Winners Final and Bracket Championship
|
||||
// are scoring rounds and are handled via loserIsPartial instead.)
|
||||
if (templateId === "llws_20" && LLWS_LOSER_ADVANCES_ROUNDS.has(round)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -1800,140 +1436,3 @@ async function advanceNBAPlayInWinner(
|
|||
throw new Error(`Unknown Play-In Round 2 match number: ${match.matchNumber}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── LLWS 20 (double elimination) ──────────────────────────────────────────────
|
||||
|
||||
// The routing table itself is pure and lives in lib/ so the renderer can import it
|
||||
// without pulling the database context into the browser bundle. Re-exported here so
|
||||
// existing server-side callers and tests keep their import path.
|
||||
export { LLWS_LOSER_ADVANCES_ROUNDS, resolveLLWSAdvancement, type LLWSResolvedDestination };
|
||||
|
||||
/**
|
||||
* Generate the 20-team LLWS double-elimination bracket (38 matches).
|
||||
*
|
||||
* Only the Opening Round and the four bye slots receive participants up front;
|
||||
* everything else is filled by advanceLLWSWinner as games complete.
|
||||
*
|
||||
* Participant array layout (see LLWS_20 in lib/bracket-templates):
|
||||
* [0–7] U.S. Opening Round teams, two per game
|
||||
* [8, 9] U.S. bye teams → Winners Round 2 M1 / M2 participant1
|
||||
* [10–17] International Opening Round teams, two per game
|
||||
* [18,19] International bye teams → Winners Round 2 M3 / M4 participant1
|
||||
*/
|
||||
async function generateLLWS20Bracket(
|
||||
eventId: string,
|
||||
template: BracketTemplate,
|
||||
participantIds?: string[]
|
||||
): Promise<PlayoffMatch[]> {
|
||||
const matches: NewPlayoffMatch[] = [];
|
||||
const p = (idx: number): string | null =>
|
||||
participantIds ? (participantIds[idx] ?? null) : null;
|
||||
|
||||
const sides = [
|
||||
{ side: 0 as const, label: "U.S.", openingBase: 0, byeBase: 8 },
|
||||
{ side: 1 as const, label: "Intl", openingBase: 10, byeBase: 18 },
|
||||
];
|
||||
|
||||
// ── Opening Round: 4 games per side, both slots seeded ──────────────────────
|
||||
for (const { side, label, openingBase } of sides) {
|
||||
for (let local = 1; local <= 4; local++) {
|
||||
matches.push({
|
||||
scoringEventId: eventId,
|
||||
round: "Opening Round",
|
||||
matchNumber: llwsMatchNumber("Opening Round", side, local),
|
||||
participant1Id: p(openingBase + (local - 1) * 2),
|
||||
participant2Id: p(openingBase + (local - 1) * 2 + 1),
|
||||
isComplete: false,
|
||||
isScoring: false,
|
||||
templateRound: "Opening Round",
|
||||
seedInfo: `${label} Opening ${local}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Winners Round 2: bye team at participant1, Opening winner at participant2 ─
|
||||
for (const { side, label, byeBase } of sides) {
|
||||
for (let local = 1; local <= 2; local++) {
|
||||
matches.push({
|
||||
scoringEventId: eventId,
|
||||
round: "Winners Round 2",
|
||||
matchNumber: llwsMatchNumber("Winners Round 2", side, local),
|
||||
participant1Id: p(byeBase + (local - 1)),
|
||||
participant2Id: null, // Opening Round winner
|
||||
isComplete: false,
|
||||
isScoring: false,
|
||||
templateRound: "Winners Round 2",
|
||||
seedInfo: `${label} Bye ${local} vs Opening ${local} winner`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Every remaining round starts empty ──────────────────────────────────────
|
||||
const remaining = template.rounds.filter(
|
||||
(r) => r.name !== "Opening Round" && r.name !== "Winners Round 2"
|
||||
);
|
||||
for (const round of remaining) {
|
||||
for (let i = 1; i <= round.matchCount; i++) {
|
||||
// Championship/Consolation are single shared games; everything else is per-side.
|
||||
const perSide = round.matchCount > 1;
|
||||
const label = perSide
|
||||
? llwsSideAndLocal(round.name, i).side === 0
|
||||
? "U.S."
|
||||
: "Intl"
|
||||
: null;
|
||||
matches.push({
|
||||
scoringEventId: eventId,
|
||||
round: round.name,
|
||||
matchNumber: i,
|
||||
participant1Id: null,
|
||||
participant2Id: null,
|
||||
isComplete: false,
|
||||
isScoring: round.isScoring,
|
||||
templateRound: round.name,
|
||||
seedInfo: label ? `${label} ${round.name}` : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return await createManyPlayoffMatches(matches);
|
||||
}
|
||||
|
||||
/**
|
||||
* LLWS advancement: routes the winner forward and, in the winners bracket, routes the
|
||||
* loser into the elimination bracket rather than eliminating them.
|
||||
*
|
||||
* All routing decisions live in resolveLLWSAdvancement; this function only writes.
|
||||
*/
|
||||
async function advanceLLWSWinner(
|
||||
match: PlayoffMatch,
|
||||
winnerId: string,
|
||||
loserId: string
|
||||
): Promise<void> {
|
||||
const eventId = match.scoringEventId;
|
||||
const { winner, loser } = resolveLLWSAdvancement(match.round, match.matchNumber);
|
||||
|
||||
// Winner and loser can land in different rounds, so resolve each independently.
|
||||
const moves: Array<{ destination: LLWSResolvedDestination; participantId: string }> = [];
|
||||
if (winner) moves.push({ destination: winner, participantId: winnerId });
|
||||
if (loser) moves.push({ destination: loser, participantId: loserId });
|
||||
|
||||
for (const { destination, participantId } of moves) {
|
||||
const targetMatches = await findPlayoffMatchesByEventIdAndRound(
|
||||
eventId,
|
||||
destination.round
|
||||
);
|
||||
const target = targetMatches.find((m) => m.matchNumber === destination.matchNumber);
|
||||
if (!target) {
|
||||
throw new Error(
|
||||
`Next match not found: round=${destination.round}, matchNumber=${destination.matchNumber}`
|
||||
);
|
||||
}
|
||||
if (target[destination.slot]) {
|
||||
throw new Error(
|
||||
`Next match ${destination.slot} is already filled ` +
|
||||
`(round=${destination.round}, matchNumber=${destination.matchNumber})`
|
||||
);
|
||||
}
|
||||
await updatePlayoffMatch(target.id, { [destination.slot]: participantId });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,33 +33,6 @@ export function roundQualifyingPoints(points: number): number {
|
|||
return Math.round((points + Number.EPSILON) * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a participant's awarded QP before and after a re-score, return the ids
|
||||
* whose QP changed. A row's `qp` is the raw decimal string (or null when no QP
|
||||
* was awarded). A participant counts as changed when their new value differs from
|
||||
* the old, including a first-time score (null → value). Participants absent from
|
||||
* `after` are not reported (their removal is handled by the caller's recalc).
|
||||
*
|
||||
* Shared by the two QP re-score paths — processQualifyingEvent (sibling windows,
|
||||
* CS2, manual admin) and rescoreTennisBracketAndDetectChanges (tennis primary) —
|
||||
* so the "only announce real changes" semantics live in one place.
|
||||
*/
|
||||
export function diffChangedQualifyingPoints(
|
||||
before: Iterable<{ id: string; qp: string | null }>,
|
||||
after: Iterable<{ id: string; qp: string | null }>
|
||||
): Set<string> {
|
||||
const beforeQP = new Map<string, number>();
|
||||
for (const r of before) {
|
||||
if (r.qp !== null) beforeQP.set(r.id, parseFloat(r.qp));
|
||||
}
|
||||
const changed = new Set<string>();
|
||||
for (const r of after) {
|
||||
if (r.qp === null) continue;
|
||||
if (beforeQP.get(r.id) !== parseFloat(r.qp)) changed.add(r.id);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
export function calculateSplitQualifyingPoints(
|
||||
placement: number,
|
||||
tieCount: number,
|
||||
|
|
@ -75,6 +48,21 @@ export function calculateSplitQualifyingPoints(
|
|||
return roundQualifyingPoints(totalQP / tieCount);
|
||||
}
|
||||
|
||||
export function hasProcessedQualifyingPlacement(
|
||||
results: Array<{
|
||||
placement?: number | null;
|
||||
qualifyingPointsAwarded?: string | null;
|
||||
}>
|
||||
): boolean {
|
||||
return results.some(
|
||||
(result) =>
|
||||
result.placement !== null &&
|
||||
result.placement !== undefined &&
|
||||
result.qualifyingPointsAwarded !== null &&
|
||||
result.qualifyingPointsAwarded !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize default qualifying point configuration for a sports season
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -9,21 +9,19 @@ import {
|
|||
} from "./scoring-rules";
|
||||
import { getSeasonResults } from "./participant-season-result";
|
||||
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
||||
import { sendStandingsUpdateNotification, type ScoredMatch, type EliminatedTeam } from "~/services/discord";
|
||||
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
|
||||
import { sendStandingsUpdateNotification, type ScoredMatch } from "~/services/discord";
|
||||
import { BRACKET_TEMPLATES, type BracketRound } from "~/lib/bracket-templates";
|
||||
import { doesLoserAdvance, findPlayoffMatchesByEventId } from "~/models/playoff-match";
|
||||
import { getUserDisplayName } from "~/models/user";
|
||||
import { findDiscordIdsByUserIds } from "~/models/account";
|
||||
import { createDailySnapshot } from "~/models/standings";
|
||||
import { getBracketTemplateIdForSportsSeason } from "~/models/bracket-template";
|
||||
import { recordMatchScoreEvents } from "~/models/team-score-events";
|
||||
import { logger } from "~/lib/logger";
|
||||
import { getEventResults } from "./event-result";
|
||||
import {
|
||||
calculateSplitQualifyingPoints,
|
||||
diffChangedQualifyingPoints,
|
||||
getQPConfig,
|
||||
hasProcessedQualifyingPlacement,
|
||||
recalculateParticipantQP,
|
||||
writeEventResultsQP,
|
||||
getQPStandings,
|
||||
|
|
@ -114,21 +112,6 @@ const TEMPLATE_ROUND_CONFIG: Record<string, Record<string, RoundScoringConfig>>
|
|||
// 3rd place game finalizes both positions distinctly.
|
||||
"Third Place Game": { loserPosition: 4, loserIsPartial: false, winnerFloor: null, winnerPosition: 3 },
|
||||
},
|
||||
llws_20: {
|
||||
// Winners Final loser drops to the Elimination Final, so 5th is provisional —
|
||||
// winning that game lifts them back to a 4th-place floor.
|
||||
"Winners Final": { loserPosition: 5, loserIsPartial: true, winnerFloor: 4 },
|
||||
// Elimination Round 4 losers are the 7th–8th tier (8 teams alive at this point).
|
||||
"Elimination Round 4": { loserPosition: 7, loserIsPartial: false, winnerFloor: 5 },
|
||||
// Elimination Final losers are the 5th–6th tier; the winner reaches the side
|
||||
// championship, where the worst case is 4th (lose it, then lose the consolation).
|
||||
"Elimination Final": { loserPosition: 5, loserIsPartial: false, winnerFloor: 4 },
|
||||
// Side championship loser still has the consolation game — provisional 4th.
|
||||
"Bracket Championship": { loserPosition: 4, loserIsPartial: true, winnerFloor: 2 },
|
||||
// Consolation finalizes 3rd and 4th distinctly.
|
||||
"Consolation Third Place": { loserPosition: 4, loserIsPartial: false, winnerFloor: null, winnerPosition: 3 },
|
||||
"World Championship": { loserPosition: 2, loserIsPartial: false, winnerFloor: null },
|
||||
},
|
||||
tennis_128: {
|
||||
// R16 losers share 9th–16th; winner advances to QF (floor 5th–8th).
|
||||
"Round of 16": { loserPosition: 9, loserIsPartial: false, winnerFloor: 5 },
|
||||
|
|
@ -142,141 +125,28 @@ const TEMPLATE_ROUND_CONFIG: Record<string, Record<string, RoundScoringConfig>>
|
|||
};
|
||||
|
||||
/**
|
||||
* Returns the floor position that winners of a NON-scoring round should bank, or null
|
||||
* to bank nothing.
|
||||
* Returns true if a non-scoring round's winners are entering the first scoring round
|
||||
* (i.e., they've guaranteed a top-8 fantasy placement and should receive a T5–T8 floor).
|
||||
*
|
||||
* Default: winners entering the first scoring round have guaranteed a top-8 fantasy
|
||||
* placement and receive a T5–T8 floor (5); everyone else gets nothing yet. For
|
||||
* multi-round pre-bracket sequences like NCAA (Round of 64 → Round of 32 → Sweet
|
||||
* Sixteen → Elite Eight), only Sweet Sixteen winners are entering the scoring bracket.
|
||||
* For multi-round pre-bracket sequences like NCAA (Round of 64 → Round of 32 →
|
||||
* Sweet Sixteen → Elite Eight), only Sweet Sixteen winners are entering the scoring
|
||||
* bracket — Round of 64 and Round of 32 winners should not receive any floor yet.
|
||||
*
|
||||
* A round may override this with `nonScoringWinnerFloor` when the default is wrong —
|
||||
* in a double-elimination losers bracket a win can guarantee a worse finish than 5th
|
||||
* (llws_20 "Elimination Round 3" → 7), or nothing at all.
|
||||
*
|
||||
* Falls back to 5 when template/round info is unavailable, preserving legacy behavior.
|
||||
* Falls back to true when template/round info is unavailable to preserve legacy behavior.
|
||||
*/
|
||||
function nonScoringWinnerFloorFor(
|
||||
function doesNonScoringRoundFeedIntoScoringRound(
|
||||
round: string,
|
||||
bracketTemplateId: string | null | undefined
|
||||
): number | null {
|
||||
if (!bracketTemplateId) return 5; // Legacy: preserve old behavior
|
||||
): boolean {
|
||||
if (!bracketTemplateId) return true; // Legacy: preserve old behavior
|
||||
const template = BRACKET_TEMPLATES[bracketTemplateId];
|
||||
if (!template) return 5; // Unknown template: preserve old behavior
|
||||
if (!template) return true; // Unknown template: preserve old behavior
|
||||
const currentRound = template.rounds.find((r) => r.name === round);
|
||||
if (!currentRound) return 5; // Unknown round: preserve old behavior
|
||||
// Explicit per-round override wins, including an explicit null (bank nothing).
|
||||
if (currentRound.nonScoringWinnerFloor !== undefined) {
|
||||
return currentRound.nonScoringWinnerFloor;
|
||||
}
|
||||
if (!currentRound) return true; // Unknown round: preserve old behavior
|
||||
const nextRoundName = currentRound.feedsInto;
|
||||
if (!nextRoundName) return null; // No next round (shouldn't happen for non-scoring)
|
||||
if (!nextRoundName) return false; // No next round (shouldn't happen for non-scoring)
|
||||
const nextRound = template.rounds.find((r) => r.name === nextRoundName);
|
||||
return nextRound?.isScoring === true ? 5 : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the floor position a participant banks purely by being *seeded into* the
|
||||
* given round when the bracket is generated, or null when entry guarantees nothing.
|
||||
*
|
||||
* Two sources, in order:
|
||||
* 1. The template round's explicit `entryFloor` (e.g. afl_10 "Qualifying Finals" → 5:
|
||||
* seeds 1-4 have the double chance, so the 5th-6th tier is locked in on day one).
|
||||
* 2. Otherwise a scoring round's own loser position — being drawn into a round whose
|
||||
* losers score means the worst case is that round's loser tier.
|
||||
*
|
||||
* Non-scoring rounds with no explicit `entryFloor` return null: losing your first game
|
||||
* there is worth 0, so there is nothing to bank yet.
|
||||
*/
|
||||
export function getBracketEntryFloor(
|
||||
round: string,
|
||||
bracketTemplateId: string | null | undefined
|
||||
): number | null {
|
||||
const template = bracketTemplateId ? BRACKET_TEMPLATES[bracketTemplateId] : undefined;
|
||||
const templateRound = template?.rounds.find((r) => r.name === round);
|
||||
if (templateRound?.entryFloor !== undefined) return templateRound.entryFloor;
|
||||
if (!templateRound?.isScoring) return null;
|
||||
return getRoundConfig(round, bracketTemplateId)?.loserPosition ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the provisional entry floors for a freshly generated (or reprocessed) bracket.
|
||||
*
|
||||
* A seeded bracket can guarantee points before anyone plays: an AFL top-4 seed cannot
|
||||
* finish below the 5th-6th tier because a Qualifying Final loss still leaves them a
|
||||
* Semi-Final. Without this, those teams sit on 0 fantasy points until their first game
|
||||
* resolves, which understates every roster holding them.
|
||||
*
|
||||
* Only participants already assigned to a match slot are touched, and every write is
|
||||
* provisional (isPartialScore=true) so it is superseded the moment a real result lands.
|
||||
*
|
||||
* Floors never go backwards. A participant already sitting on an equal or better
|
||||
* placement is skipped, so regenerating a bracket mid-tournament (clear-bracket →
|
||||
* generate-bracket) cannot knock a finalist back down to their seeding floor. Combined
|
||||
* with upsertParticipantResult's never-un-finalize guard, re-running over the same
|
||||
* bracket is a no-op.
|
||||
*
|
||||
* Returns the number of participants whose floor this call actually raised.
|
||||
*/
|
||||
export async function applyBracketEntryFloors(
|
||||
eventId: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<number> {
|
||||
const db = providedDb || database();
|
||||
|
||||
const event = await db.query.scoringEvents.findFirst({
|
||||
where: eq(schema.scoringEvents.id, eventId),
|
||||
});
|
||||
if (!event?.bracketTemplateId) return 0;
|
||||
|
||||
const matches = await db.query.playoffMatches.findMany({
|
||||
where: eq(schema.playoffMatches.scoringEventId, eventId),
|
||||
});
|
||||
|
||||
// Highest (best) floor wins when a participant somehow appears in more than one
|
||||
// round's slots — a lower position number is a better guarantee.
|
||||
const floorByParticipant = new Map<string, number>();
|
||||
for (const match of matches) {
|
||||
const floor = getBracketEntryFloor(match.round, event.bracketTemplateId);
|
||||
if (floor === null) continue;
|
||||
for (const participantId of [match.participant1Id, match.participant2Id]) {
|
||||
if (!participantId) continue;
|
||||
const existing = floorByParticipant.get(participantId);
|
||||
if (existing === undefined || floor < existing) {
|
||||
floorByParticipant.set(participantId, floor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Existing placements, so a floor is only ever written when it improves on what
|
||||
// the participant already has. Position 0 means eliminated / missed the bracket —
|
||||
// not a better placement — so it never blocks a floor.
|
||||
const existingRows = await db.query.seasonParticipantResults.findMany({
|
||||
where: eq(schema.seasonParticipantResults.sportsSeasonId, event.sportsSeasonId),
|
||||
columns: { participantId: true, finalPosition: true },
|
||||
});
|
||||
const existingPosition = new Map(
|
||||
existingRows
|
||||
.filter((r) => r.finalPosition !== null && r.finalPosition > 0)
|
||||
.map((r) => [r.participantId, r.finalPosition as number])
|
||||
);
|
||||
|
||||
let applied = 0;
|
||||
for (const [participantId, floor] of floorByParticipant) {
|
||||
const current = existingPosition.get(participantId);
|
||||
if (current !== undefined && current <= floor) continue; // already as good or better
|
||||
|
||||
const oldFloor = await upsertParticipantResult(
|
||||
participantId,
|
||||
event.sportsSeasonId,
|
||||
floor,
|
||||
db,
|
||||
true // provisional: replaced as soon as the participant wins or is eliminated
|
||||
);
|
||||
if (oldFloor !== null) applied++;
|
||||
}
|
||||
|
||||
return applied;
|
||||
return nextRound?.isScoring === true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -410,18 +280,19 @@ export async function processPlayoffEvent(
|
|||
}
|
||||
|
||||
if (!isScoring) {
|
||||
// Non-scoring round: losers are permanently eliminated (0 pts) unless they
|
||||
// advance (double-elimination winners-bracket losers). Winners bank a
|
||||
// provisional floor only when this round guarantees them one — see
|
||||
// nonScoringWinnerFloorFor for how that is derived per template.
|
||||
const winnerFloor = nonScoringWinnerFloorFor(round, event.bracketTemplateId);
|
||||
// Non-scoring (pre-bracket) round: losers are permanently eliminated (0 pts).
|
||||
// Winners only bank a provisional T5–T8 floor if they're entering the first
|
||||
// scoring round (i.e., guaranteed top-8). For multi-round pre-bracket sequences
|
||||
// like NCAA (R64 → R32 → Sweet 16 → Elite Eight), only Sweet 16 winners should
|
||||
// receive floor points — R64 and R32 winners are not yet guaranteed top-8.
|
||||
const awardFloor = doesNonScoringRoundFeedIntoScoringRound(round, event.bracketTemplateId);
|
||||
for (const match of matches) {
|
||||
const loserAdvances = doesLoserAdvance(round, match.matchNumber, event.bracketTemplateId ?? "");
|
||||
if (match.loserId && !loserAdvances) {
|
||||
await upsertParticipantResult(match.loserId, event.sportsSeasonId, 0, db);
|
||||
}
|
||||
if (match.winnerId && winnerFloor !== null) {
|
||||
await upsertParticipantResult(match.winnerId, event.sportsSeasonId, winnerFloor, db, true);
|
||||
if (match.winnerId && awardFloor) {
|
||||
await upsertParticipantResult(match.winnerId, event.sportsSeasonId, 5, db, true);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -481,7 +352,7 @@ export async function processPlayoffEvent(
|
|||
// Progressive floor scoring: assign guaranteed minimum points to winners.
|
||||
// For Finals (winnerFloor=null) getGuaranteedMinimumPosition returns null — the
|
||||
// winner is already finalized as 1st above. For non-scoring rounds it also
|
||||
// returns null; those winners were given their floor inline above.
|
||||
// returns null (winners were given floor 5 inline above).
|
||||
const guaranteedMinimum = getGuaranteedMinimumPosition(
|
||||
round,
|
||||
event.bracketTemplateId,
|
||||
|
|
@ -547,19 +418,6 @@ export async function processMatchResult(
|
|||
/** When set, Discord notification only shows this match (not all completed matches for the event). */
|
||||
matchId?: string;
|
||||
skipSideEffects?: boolean;
|
||||
/**
|
||||
* Skip only the probability refresh, still recalculating standings and announcing.
|
||||
*
|
||||
* For a caller scoring several matches in a loop: the refresh is season-wide and
|
||||
* idempotent, so running it per match repeats the whole thing needlessly — and for a
|
||||
* bracket-aware sport that now means a full Monte Carlo run each time. Set this in the
|
||||
* loop and call updateProbabilitiesAfterResult once when it finishes. Per-match
|
||||
* announcements then project from the previous probabilities until that final call.
|
||||
*
|
||||
* Distinct from skipSideEffects, which also suppresses the standings recalculation and
|
||||
* the announcement.
|
||||
*/
|
||||
skipProbabilities?: boolean;
|
||||
/**
|
||||
* When true, the loser of this non-scoring round advances to another match
|
||||
* (e.g. NBA Play-In Round 1 7v8 loser → Play-In Round 2) and must NOT be
|
||||
|
|
@ -570,7 +428,7 @@ export async function processMatchResult(
|
|||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<void> {
|
||||
const db = providedDb || database();
|
||||
const { round, winnerId, loserId, isScoring, sportsSeasonId, bracketTemplateId, eventId, eventName, matchId, skipSideEffects, skipProbabilities, loserAdvances } = params;
|
||||
const { round, winnerId, loserId, isScoring, sportsSeasonId, bracketTemplateId, eventId, eventName, matchId, skipSideEffects, loserAdvances } = params;
|
||||
|
||||
if (!isScoring) {
|
||||
// Non-scoring (pre-bracket) round: loser permanently eliminated (0 pts),
|
||||
|
|
@ -582,9 +440,8 @@ export async function processMatchResult(
|
|||
if (!loserAdvances) {
|
||||
await upsertParticipantResult(loserId, sportsSeasonId, 0, db);
|
||||
}
|
||||
const nonScoringFloor = nonScoringWinnerFloorFor(round, bracketTemplateId);
|
||||
if (nonScoringFloor !== null) {
|
||||
await upsertParticipantResult(winnerId, sportsSeasonId, nonScoringFloor, db, true);
|
||||
if (doesNonScoringRoundFeedIntoScoringRound(round, bracketTemplateId)) {
|
||||
await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true);
|
||||
}
|
||||
// Non-scoring round wins are not surfaced in the Recent Scores feed.
|
||||
} else {
|
||||
|
|
@ -650,15 +507,13 @@ export async function processMatchResult(
|
|||
: undefined;
|
||||
// Update probabilities first so the standings recalc reads fresh EVs and
|
||||
// projected points reflect the new result.
|
||||
if (!skipProbabilities) {
|
||||
try {
|
||||
await updateProbabilitiesAfterResult(sportsSeasonId, true);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[ScoringCalculator] Failed to auto-update probabilities for sports season ${sportsSeasonId}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
try {
|
||||
await updateProbabilitiesAfterResult(sportsSeasonId, true);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[ScoringCalculator] Failed to auto-update probabilities for sports season ${sportsSeasonId}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
await recalculateAffectedLeagues(sportsSeasonId, db, sideEffectOptions);
|
||||
}
|
||||
|
|
@ -768,7 +623,6 @@ export function isLoserNotifiable(
|
|||
return scoreChanged || finalizedLoserIds.has(loserId);
|
||||
}
|
||||
|
||||
|
||||
export function getGuaranteedMinimumPosition(
|
||||
round: string,
|
||||
bracketTemplateId: string | null | undefined,
|
||||
|
|
@ -873,38 +727,15 @@ export function deriveBracketQualifyingStates(
|
|||
}
|
||||
|
||||
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);
|
||||
// In the bracket but no match resolved yet → entry floor.
|
||||
const cfg = firstScoringRound ? getConfig(firstScoringRound.name) : null;
|
||||
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) continue;
|
||||
if (cfg.winnerFloor === null) {
|
||||
// Won the finalization round → champion (or 3rd-place-game winner).
|
||||
result.set(id, { placement: cfg.winnerPosition ?? 1, tieCount: 1 });
|
||||
|
|
@ -991,49 +822,13 @@ export async function processQualifyingBracketEvent(
|
|||
await writeEventResultsQP(eventId, event.sportsSeasonId, entries, db);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count how many results share each placement — the structural tie span used to
|
||||
* split QP across a tied group. Callers pass the FULL canonical field
|
||||
* (tournament_results) so the span reflects the whole tournament, not one window's
|
||||
* roster subset. Null placements (filler / not-participating) are ignored.
|
||||
*/
|
||||
export function buildTieCountByPlacement(
|
||||
results: Array<{ placement: number | null }>
|
||||
): Map<number, number> {
|
||||
const map = new Map<number, number>();
|
||||
for (const r of results) {
|
||||
if (r.placement === null) continue;
|
||||
map.set(r.placement, (map.get(r.placement) ?? 0) + 1);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a qualifying event completion and update QP totals.
|
||||
* Ties in QP are handled by sharing placements (averaged points).
|
||||
*/
|
||||
export async function processQualifyingEvent(
|
||||
eventId: string,
|
||||
providedDb?: ReturnType<typeof database>,
|
||||
options: {
|
||||
skipNotifications?: boolean;
|
||||
/**
|
||||
* Pre-computed full-field tie span (placement → count) from the canonical
|
||||
* tournament_results. When the fan-out already loaded the canonical results it
|
||||
* passes this in so we don't re-query per window. Omitted for direct callers,
|
||||
* which fall back to querying it here.
|
||||
*/
|
||||
canonicalTieCountByPlacement?: Map<number, number>;
|
||||
/**
|
||||
* This window's season_participant ids that were knocked out this sync in a
|
||||
* non-scoring round. They earn no QP (so they never surface via changed QP),
|
||||
* but a manager who drafted them should still be told. Threaded down from the
|
||||
* primary bracket by the fan-out (syncTournamentResults), already translated
|
||||
* to THIS window's season_participant ids. See the primary path in
|
||||
* app/services/match-sync/index.ts (newlyEliminatedIds).
|
||||
*/
|
||||
newlyEliminatedParticipantIds?: Set<string>;
|
||||
} = {}
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<void> {
|
||||
const db = providedDb || database();
|
||||
|
||||
|
|
@ -1056,32 +851,10 @@ export async function processQualifyingEvent(
|
|||
// Get all event results for this qualifying event
|
||||
const results = await getEventResults(eventId, db);
|
||||
|
||||
// Snapshot awarded QP before reprocessing so the Discord notification below can
|
||||
// announce only the participants whose QP actually changed. Without this, a
|
||||
// sibling window re-scored on every fan-out sync (syncTournamentResults) would
|
||||
// re-ping the full QP standings each run even when nothing changed.
|
||||
const beforeQP = results.map((r) => ({
|
||||
id: r.seasonParticipantId,
|
||||
qp: r.qualifyingPointsAwarded,
|
||||
}));
|
||||
// Check if this was already processed (for majorsCompleted counter)
|
||||
const wasAlreadyProcessed = hasProcessedQualifyingPlacement(results);
|
||||
|
||||
// Route to the bracket writer only when this window actually OWNS a bracket (has
|
||||
// playoff matches). A window can carry a bracketTemplateId with no matches — e.g. a
|
||||
// league window cloned from a bracket season copies the template id but not the
|
||||
// matches (cloneSportsSeason) — and processQualifyingBracketEvent would derive zero
|
||||
// states and write NO QP. Those windows must be scored via the placement/canonical
|
||||
// path below, exactly like a no-template sibling.
|
||||
let hasBracketMatches = false;
|
||||
if (event.bracketTemplateId) {
|
||||
const existing = await db
|
||||
.select({ id: schema.playoffMatches.id })
|
||||
.from(schema.playoffMatches)
|
||||
.where(eq(schema.playoffMatches.scoringEventId, eventId))
|
||||
.limit(1);
|
||||
hasBracketMatches = existing.length > 0;
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -1106,28 +879,6 @@ export async function processQualifyingEvent(
|
|||
qpConfig.map((config) => [config.placement, parseFloat(config.points)])
|
||||
);
|
||||
|
||||
// Full-field tie span. The number of players tied at a placement is a property
|
||||
// of the whole tournament field (canonical tournament_results), NOT of who
|
||||
// happens to be on THIS window's roster. Sibling/mirror windows only hold the
|
||||
// draftable subset of the field, so counting the placements present locally
|
||||
// (group.length) under-counts a tied group and over-awards it: tennis R16 losers
|
||||
// all sit at placement 9 with a structural span of 8 → (2+2+2+2+1+1+1+1)/8 = 1.5
|
||||
// QP; a window holding only 4 of them would wrongly split 4 ways → 2 QP. Deriving
|
||||
// the span from canonical results keeps every window/league identical. The fan-out
|
||||
// passes this map in (already loaded once per tournament); direct callers with a
|
||||
// tournament link query it here. Standalone events (no tournamentId, no map) have
|
||||
// no canonical field, so fall back to the live count.
|
||||
const canonicalTieCountByPlacement: Map<number, number> | null =
|
||||
options.canonicalTieCountByPlacement ??
|
||||
(event.tournamentId
|
||||
? buildTieCountByPlacement(
|
||||
await db
|
||||
.select({ placement: schema.tournamentResults.placement })
|
||||
.from(schema.tournamentResults)
|
||||
.where(eq(schema.tournamentResults.tournamentId, event.tournamentId))
|
||||
)
|
||||
: null);
|
||||
|
||||
// Group results by placement to handle ties
|
||||
const placementGroups = new Map<number, typeof results>();
|
||||
for (const result of results) {
|
||||
|
|
@ -1140,7 +891,7 @@ export async function processQualifyingEvent(
|
|||
|
||||
// Process each placement group and update event_results with QP awarded
|
||||
for (const [placement, group] of placementGroups) {
|
||||
const tieCount = canonicalTieCountByPlacement?.get(placement) ?? group.length;
|
||||
const tieCount = group.length;
|
||||
|
||||
const qpPerParticipant = calculateSplitQualifyingPoints(
|
||||
placement,
|
||||
|
|
@ -1168,46 +919,21 @@ export async function processQualifyingEvent(
|
|||
await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
|
||||
}
|
||||
|
||||
// NOTE: majorsCompleted is NOT a stored counter. It is derived on read via
|
||||
// getMajorsCompleted() (count of completed qualifying events). Incrementing here
|
||||
// per fan-out sync over-counted it past totalMajors ("11 of 4"), so the write was
|
||||
// removed. See app/models/scoring-event.ts:getMajorsCompleted.
|
||||
// Increment majorsCompleted counter (only if this is the first time processing)
|
||||
if (!wasAlreadyProcessed) {
|
||||
const sportsSeason = event.sportsSeason;
|
||||
await db
|
||||
.update(schema.sportsSeasons)
|
||||
.set({
|
||||
majorsCompleted: (sportsSeason.majorsCompleted || 0) + 1,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.sportsSeasons.id, event.sportsSeasonId));
|
||||
}
|
||||
|
||||
logger.log(
|
||||
`[ScoringCalculator] Processed qualifying event ${eventId}: awarded QP to ${results.length} participants`
|
||||
);
|
||||
|
||||
// Announce only participants whose awarded QP changed vs. the pre-reprocess
|
||||
// snapshot (a differing value, or a first-time score: null → value). This keeps
|
||||
// repeated fan-out syncs of an unchanged window from re-pinging the standings.
|
||||
const afterRows = await db.query.eventResults.findMany({
|
||||
where: eq(schema.eventResults.scoringEventId, eventId),
|
||||
});
|
||||
const changedParticipantIds = diffChangedQualifyingPoints(
|
||||
beforeQP,
|
||||
afterRows.map((r) => ({ id: r.seasonParticipantId, qp: r.qualifyingPointsAwarded }))
|
||||
);
|
||||
|
||||
// Players knocked out this sync in a non-scoring round earn no QP, so they never
|
||||
// appear in changedParticipantIds. Announce them too (mirroring the primary path
|
||||
// in syncTennisDraw), so a mirror window's "Knocked Out" section isn't dropped.
|
||||
const eliminatedIds = options.newlyEliminatedParticipantIds ?? new Set<string>();
|
||||
if (
|
||||
(changedParticipantIds.size > 0 || eliminatedIds.size > 0) &&
|
||||
!options.skipNotifications
|
||||
) {
|
||||
try {
|
||||
await notifyQualifyingPointsUpdate(
|
||||
event.sportsSeasonId,
|
||||
eventId,
|
||||
db,
|
||||
changedParticipantIds,
|
||||
eliminatedIds,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`[ScoringCalculator] QP Discord notification failed for event ${eventId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1481,7 +1207,11 @@ export async function calculateTeamScore(
|
|||
if (bracketTemplateCache.has(sportsSeasonId)) {
|
||||
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
||||
}
|
||||
const templateId = await getBracketTemplateIdForSportsSeason(sportsSeasonId, db);
|
||||
const event = await db.query.scoringEvents.findFirst({
|
||||
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||||
columns: { bracketTemplateId: true },
|
||||
});
|
||||
const templateId = event?.bracketTemplateId ?? null;
|
||||
bracketTemplateCache.set(sportsSeasonId, templateId);
|
||||
return templateId;
|
||||
}
|
||||
|
|
@ -1590,7 +1320,11 @@ export async function calculateTeamProjectedScore(
|
|||
if (bracketTemplateCache.has(sportsSeasonId)) {
|
||||
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
||||
}
|
||||
const templateId = await getBracketTemplateIdForSportsSeason(sportsSeasonId, db);
|
||||
const event = await db.query.scoringEvents.findFirst({
|
||||
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||||
columns: { bracketTemplateId: true },
|
||||
});
|
||||
const templateId = event?.bracketTemplateId ?? null;
|
||||
bracketTemplateCache.set(sportsSeasonId, templateId);
|
||||
return templateId;
|
||||
}
|
||||
|
|
@ -1900,7 +1634,7 @@ export async function recalculateStandings(
|
|||
export async function recalculateAffectedLeagues(
|
||||
sportsSeasonId: string,
|
||||
providedDb?: ReturnType<typeof database>,
|
||||
options?: { eventName?: string; eventId?: string; matchIds?: string[]; skipDiscord?: boolean; eliminatedParticipantIds?: string[] }
|
||||
options?: { eventName?: string; eventId?: string; matchIds?: string[]; skipDiscord?: boolean }
|
||||
): Promise<void> {
|
||||
const db = providedDb || database();
|
||||
|
||||
|
|
@ -1969,18 +1703,6 @@ export async function recalculateAffectedLeagues(
|
|||
for (const r of loserResults) finalizedLoserIds.add(r.participantId);
|
||||
}
|
||||
|
||||
// Look up names for participants eliminated by this action (e.g. bracket/knockout
|
||||
// generation). These produce no score delta, so they're surfaced via a dedicated
|
||||
// "Eliminated" section rather than the match/standings-change sections.
|
||||
const eliminatedIds = options?.eliminatedParticipantIds ?? [];
|
||||
const eliminatedNameById = new Map<string, string>();
|
||||
if (eliminatedIds.length > 0) {
|
||||
const eliminatedParticipants = await db.query.seasonParticipants.findMany({
|
||||
where: inArray(schema.seasonParticipants.id, eliminatedIds),
|
||||
});
|
||||
for (const p of eliminatedParticipants) eliminatedNameById.set(p.id, p.name);
|
||||
}
|
||||
|
||||
// Recalculate each affected season
|
||||
for (const seasonId of seasonIds) {
|
||||
// Capture standings before recalculation for delta calculation
|
||||
|
|
@ -2055,33 +1777,20 @@ export async function recalculateAffectedLeagues(
|
|||
afterStandings.map((s) => [s.teamId, s.team.ownerId])
|
||||
);
|
||||
|
||||
// Shared draft-pick lookup for both the scored-match and elimination sections,
|
||||
// loaded once per season only when at least one section needs it.
|
||||
const needDraftPicks = allCompletedMatches.length > 0 || eliminatedIds.length > 0;
|
||||
const draftPicks = needDraftPicks
|
||||
? await db.query.draftPicks.findMany({
|
||||
where: eq(schema.draftPicks.seasonId, seasonId),
|
||||
})
|
||||
: [];
|
||||
const draftedIds = new Set(draftPicks.map((p) => p.participantId));
|
||||
const teamIdByParticipantId = new Map(
|
||||
draftPicks.map((p) => [p.participantId, p.teamId])
|
||||
);
|
||||
|
||||
// Build scored matches for the notification.
|
||||
// A match is worth announcing when:
|
||||
// • the winner is owned by a manager AND earned points this round, OR
|
||||
// • the loser is owned by a manager (eliminated or scored).
|
||||
// A winning team that merely advanced through a non-scoring round (e.g. R32 → R16
|
||||
// in the World Cup) does not qualify on its own — their owner hasn't earned anything yet.
|
||||
// When a match qualifies because the loser is owned, the winner's manager tag is still
|
||||
// shown for context (who beat them), but the winner is not Discord-pinged.
|
||||
// Both managers' tags are always shown for context when their teams are drafted; the
|
||||
// showLoser flag (isLoserNotifiable) only gates whether the loser is @-pinged — a loser
|
||||
// who advanced rather than being eliminated (loserAdvances=true, e.g. NBA 7v8 → PIR2, or
|
||||
// a World Cup semifinal loser) is named but not pinged.
|
||||
// Winners only appear if their team's score changed (they earned points this round).
|
||||
// Losers appear if their team's score changed OR they were definitively eliminated
|
||||
// (finalPosition set, non-partial) — the latter catches 0-pt eliminations that
|
||||
// produce no score delta. Losers who advance to another match (loserAdvances=true,
|
||||
// e.g. NBA 7v8 → PIR2) have no finalized result and no score change, so they're
|
||||
// correctly suppressed.
|
||||
let scoredMatches: ScoredMatch[] | undefined;
|
||||
if (allCompletedMatches.length > 0) {
|
||||
const draftPicks = await db.query.draftPicks.findMany({
|
||||
where: eq(schema.draftPicks.seasonId, seasonId),
|
||||
});
|
||||
const draftedIds = new Set(draftPicks.map((p) => p.participantId));
|
||||
|
||||
const relevant = allCompletedMatches.filter(
|
||||
(m) =>
|
||||
(m.winnerId && draftedIds.has(m.winnerId)) ||
|
||||
|
|
@ -2089,6 +1798,10 @@ export async function recalculateAffectedLeagues(
|
|||
);
|
||||
|
||||
if (relevant.length > 0) {
|
||||
const teamIdByParticipantId = new Map(
|
||||
draftPicks.map((p) => [p.participantId, p.teamId])
|
||||
);
|
||||
|
||||
const lookupOwner = (participantId: string | null | undefined) => {
|
||||
if (!participantId) return undefined;
|
||||
const teamId = teamIdByParticipantId.get(participantId);
|
||||
|
|
@ -2105,52 +1818,23 @@ export async function recalculateAffectedLeagues(
|
|||
const winnerOwnerId = lookupOwner(m.winnerId);
|
||||
const loserOwnerId = lookupOwner(m.loserId);
|
||||
const showLoser = isLoserNotifiable(m.loserId, loserTeamId, changedTeamIds, finalizedLoserIds);
|
||||
return { m, winnerScoreChanged, showLoser, winnerOwnerId, loserOwnerId };
|
||||
})
|
||||
.filter((x) => (x.winnerScoreChanged && !!x.winnerOwnerId) || (x.showLoser && !!x.loserOwnerId))
|
||||
.map((x) => ({
|
||||
winnerName: x.m.winnerName ?? "",
|
||||
loserName: x.m.loserName ?? "",
|
||||
winnerUsername: x.winnerOwnerId ? usernameByUserId.get(x.winnerOwnerId) : undefined,
|
||||
// Show the loser's manager tag whenever their team is drafted, mirroring the
|
||||
// winner above — even when the loser advances rather than being eliminated
|
||||
// (World Cup semifinal → 3rd-place playoff, AFL Qualifying Final → Semi Final).
|
||||
// The @-ping stays gated by showLoser (loserDiscordUserId below): a still-alive
|
||||
// loser who neither scored nor was eliminated is named for context but not pinged.
|
||||
loserUsername: x.loserOwnerId ? usernameByUserId.get(x.loserOwnerId) : undefined,
|
||||
winnerDiscordUserId: x.winnerScoreChanged && x.winnerOwnerId ? discordIdByUserId.get(x.winnerOwnerId) : undefined,
|
||||
loserDiscordUserId: x.showLoser && x.loserOwnerId ? discordIdByUserId.get(x.loserOwnerId) : undefined,
|
||||
}))
|
||||
.filter((m) => m.winnerUsername !== undefined || m.loserUsername !== undefined);
|
||||
return {
|
||||
winnerName: m.winnerName ?? "",
|
||||
loserName: m.loserName ?? "",
|
||||
winnerUsername: winnerScoreChanged && winnerOwnerId ? usernameByUserId.get(winnerOwnerId) : undefined,
|
||||
loserUsername: showLoser && loserOwnerId ? usernameByUserId.get(loserOwnerId) : undefined,
|
||||
winnerDiscordUserId: winnerScoreChanged && winnerOwnerId ? discordIdByUserId.get(winnerOwnerId) : undefined,
|
||||
loserDiscordUserId: showLoser && loserOwnerId ? discordIdByUserId.get(loserOwnerId) : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Build the eliminated-teams section: drafted participants in this league that
|
||||
// were knocked out by this action (bracket/knockout generation). These have a
|
||||
// 0-pt result and no match, so they only surface here.
|
||||
let eliminatedTeams: EliminatedTeam[] | undefined;
|
||||
if (eliminatedIds.length > 0) {
|
||||
const teams = eliminatedIds
|
||||
.filter((id) => teamIdByParticipantId.has(id))
|
||||
.map((id) => {
|
||||
const teamId = teamIdByParticipantId.get(id);
|
||||
const ownerId = teamId ? ownerIdByTeamId.get(teamId) : undefined;
|
||||
return {
|
||||
participantName: eliminatedNameById.get(id) ?? "Unknown",
|
||||
username: ownerId ? usernameByUserId.get(ownerId) : undefined,
|
||||
discordUserId: ownerId ? discordIdByUserId.get(ownerId) : undefined,
|
||||
};
|
||||
});
|
||||
if (teams.length > 0) eliminatedTeams = teams;
|
||||
}
|
||||
|
||||
// Skip notification if scores didn't change AND no match has a displayable
|
||||
// username AND there's nothing eliminated to announce.
|
||||
// Skip notification if scores didn't change AND no match has a displayable username.
|
||||
const hasScoredMatchesToShow = scoredMatches?.some(
|
||||
(m) => m.winnerUsername !== undefined || m.loserUsername !== undefined
|
||||
);
|
||||
const hasEliminatedToShow = (eliminatedTeams?.length ?? 0) > 0;
|
||||
if (!hasChanges && !hasScoredMatchesToShow && !hasEliminatedToShow) continue;
|
||||
if (!hasChanges && !hasScoredMatchesToShow) continue;
|
||||
|
||||
const standings = afterStandings.map((s) => ({
|
||||
teamId: s.teamId,
|
||||
|
|
@ -2171,7 +1855,6 @@ export async function recalculateAffectedLeagues(
|
|||
sportName,
|
||||
eventName: options?.eventName,
|
||||
scoredMatches,
|
||||
eliminatedTeams,
|
||||
});
|
||||
} catch (err) {
|
||||
// Log but don't fail the scoring pipeline if Discord is unreachable
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
export type EventType = "playoff_game" | "major_tournament" | "final_standings" | "schedule_event";
|
||||
|
||||
export function getEventTypeLabel(eventType: string): string {
|
||||
switch (eventType) {
|
||||
case "playoff_game": return "Bracket";
|
||||
case "major_tournament": return "Major Tournament";
|
||||
case "final_standings": return "Final Standings";
|
||||
case "schedule_event": return "Non-Scoring";
|
||||
default: return eventType;
|
||||
}
|
||||
}
|
||||
|
|
@ -3,11 +3,20 @@ import * as schema from "~/database/schema";
|
|||
import { eq, and, desc, asc, gte, lte, or, inArray, isNotNull, sql } from "drizzle-orm";
|
||||
import type { BracketRegion } from "~/lib/bracket-templates";
|
||||
import { recalculateAffectedLeagues } from "./scoring-calculator";
|
||||
import { recalculateParticipantQP } from "./qualifying-points";
|
||||
import { hasProcessedQualifyingPlacement, recalculateParticipantQP } from "./qualifying-points";
|
||||
import { findParticipantNamesByIds } from "./season-participant";
|
||||
import { deleteTournament } from "./tournament";
|
||||
import type { EventType } from "./scoring-event-types";
|
||||
export { type EventType, getEventTypeLabel } from "./scoring-event-types";
|
||||
|
||||
export type EventType = "playoff_game" | "major_tournament" | "final_standings" | "schedule_event";
|
||||
|
||||
export function getEventTypeLabel(eventType: string): string {
|
||||
switch (eventType) {
|
||||
case "playoff_game": return "Bracket";
|
||||
case "major_tournament": return "Major Tournament";
|
||||
case "final_standings": return "Final Standings";
|
||||
case "schedule_event": return "Non-Scoring";
|
||||
default: return eventType;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CreateScoringEventData {
|
||||
sportsSeasonId: string;
|
||||
|
|
@ -33,8 +42,6 @@ export interface UpdateScoringEventData {
|
|||
scoringStartsAtRound?: string;
|
||||
/** Per-event region config for NCAA-style brackets (overrides template defaults) */
|
||||
bracketRegionConfig?: BracketRegion[] | null;
|
||||
/** External data-source locator (e.g. Wikipedia article title for tennis draws) */
|
||||
externalSourceKey?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -144,33 +151,6 @@ export async function getQualifyingEvents(
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of "majors completed" for a sports season, derived on read as the count of
|
||||
* qualifying events that have been marked complete. Replaces the old stored
|
||||
* sportsSeasons.majorsCompleted counter, which was incremented per fan-out sync and
|
||||
* over-counted past totalMajors ("11 of 4"). Computing it makes the value
|
||||
* self-correcting and immune to double-counting.
|
||||
*/
|
||||
export async function getMajorsCompleted(
|
||||
sportsSeasonId: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<number> {
|
||||
const db = providedDb || database();
|
||||
|
||||
const rows = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(schema.scoringEvents)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||||
eq(schema.scoringEvents.isQualifyingEvent, true),
|
||||
eq(schema.scoringEvents.isComplete, true)
|
||||
)
|
||||
);
|
||||
|
||||
return rows[0]?.count ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a scoring event
|
||||
*/
|
||||
|
|
@ -193,7 +173,6 @@ export async function updateScoringEvent(
|
|||
if (data.bracketTemplateId !== undefined) updateData.bracketTemplateId = data.bracketTemplateId;
|
||||
if (data.scoringStartsAtRound !== undefined) updateData.scoringStartsAtRound = data.scoringStartsAtRound;
|
||||
if (data.bracketRegionConfig !== undefined) updateData.bracketRegionConfig = data.bracketRegionConfig;
|
||||
if (data.externalSourceKey !== undefined) updateData.externalSourceKey = data.externalSourceKey;
|
||||
|
||||
const [updated] = await db
|
||||
.update(schema.scoringEvents)
|
||||
|
|
@ -229,55 +208,28 @@ export async function completeScoringEvent(
|
|||
* sports season (placements are stored there with no FK back to the event) and
|
||||
* recalculates league standings.
|
||||
*/
|
||||
export interface DeleteScoringEventOptions {
|
||||
/**
|
||||
* When this was the last window linked to its shared tournament, also delete
|
||||
* the now-orphaned canonical tournament (and its results). No-op when other
|
||||
* windows remain. Off by default so deleting one season's event never touches
|
||||
* the shared tournament unless explicitly requested.
|
||||
*/
|
||||
deleteOrphanTournament?: boolean;
|
||||
}
|
||||
|
||||
export interface DeleteScoringEventResult {
|
||||
/** The shared tournament this event was linked to, if any. */
|
||||
tournamentId: string | null;
|
||||
/** How many other windows still link to that tournament after the delete. */
|
||||
remainingWindows: number;
|
||||
/** Event promoted to primary because the deleted event was the primary window. */
|
||||
promotedPrimaryId: string | null;
|
||||
/** Whether the orphaned canonical tournament was deleted. */
|
||||
deletedTournament: boolean;
|
||||
}
|
||||
|
||||
export async function deleteScoringEvent(
|
||||
eventId: string,
|
||||
providedDb?: ReturnType<typeof database>,
|
||||
opts?: DeleteScoringEventOptions
|
||||
): Promise<DeleteScoringEventResult> {
|
||||
providedDb?: ReturnType<typeof database>
|
||||
) {
|
||||
const db = providedDb || database();
|
||||
|
||||
const event = await db.query.scoringEvents.findFirst({
|
||||
where: eq(schema.scoringEvents.id, eventId),
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
return {
|
||||
tournamentId: null,
|
||||
remainingWindows: 0,
|
||||
promotedPrimaryId: null,
|
||||
deletedTournament: false,
|
||||
};
|
||||
}
|
||||
if (!event) return;
|
||||
|
||||
// For qualifying events: capture affected participant IDs before the cascade deletes
|
||||
// eventResults (which is how we know who had QP awarded from this event).
|
||||
let affectedParticipantIds: string[] = [];
|
||||
let wasQPProcessed = false;
|
||||
if (event.isQualifyingEvent) {
|
||||
const results = await db.query.eventResults.findMany({
|
||||
where: eq(schema.eventResults.scoringEventId, eventId),
|
||||
});
|
||||
affectedParticipantIds = results.map((r) => r.seasonParticipantId);
|
||||
wasQPProcessed = hasProcessedQualifyingPlacement(results);
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
|
|
@ -300,49 +252,21 @@ export async function deleteScoringEvent(
|
|||
await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
|
||||
}
|
||||
|
||||
// majorsCompleted is derived on read (getMajorsCompleted), so deleting the event
|
||||
// — which removes it from the completed-qualifying-event count — self-corrects the
|
||||
// number. No stored counter to decrement.
|
||||
// Decrement majorsCompleted if this event had already been processed
|
||||
if (wasQPProcessed) {
|
||||
const sportsSeason = await db.query.sportsSeasons.findFirst({
|
||||
where: eq(schema.sportsSeasons.id, event.sportsSeasonId),
|
||||
});
|
||||
if (sportsSeason && (sportsSeason.majorsCompleted ?? 0) > 0) {
|
||||
await db
|
||||
.update(schema.sportsSeasons)
|
||||
.set({ majorsCompleted: (sportsSeason.majorsCompleted ?? 1) - 1, updatedAt: new Date() })
|
||||
.where(eq(schema.sportsSeasons.id, event.sportsSeasonId));
|
||||
}
|
||||
}
|
||||
|
||||
await recalculateAffectedLeagues(event.sportsSeasonId, db);
|
||||
}
|
||||
|
||||
// Shared-tournament bookkeeping: keep the primary window valid and optionally
|
||||
// clean up a canonical tournament left with no windows.
|
||||
let remainingWindows = 0;
|
||||
let promotedPrimaryId: string | null = null;
|
||||
let deletedTournament = false;
|
||||
|
||||
if (event.tournamentId) {
|
||||
const remaining = await db.query.scoringEvents.findMany({
|
||||
where: eq(schema.scoringEvents.tournamentId, event.tournamentId),
|
||||
orderBy: asc(schema.scoringEvents.createdAt),
|
||||
});
|
||||
remainingWindows = remaining.length;
|
||||
|
||||
if (remaining.length > 0) {
|
||||
// Auto-heal: if the deleted event was the primary window, promote the
|
||||
// earliest remaining window so the shared major stays scorable and
|
||||
// fan-out stays deterministic. Only heal when we actually removed the
|
||||
// primary — golf-style majors intentionally have no primary (scored on the
|
||||
// canonical tournament page), and event.isPrimary is false for them, so
|
||||
// they are left untouched.
|
||||
if (event.isPrimary) {
|
||||
await setPrimaryEvent(remaining[0].id, db);
|
||||
promotedPrimaryId = remaining[0].id;
|
||||
}
|
||||
} else if (opts?.deleteOrphanTournament) {
|
||||
await deleteTournament(event.tournamentId, db);
|
||||
deletedTournament = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tournamentId: event.tournamentId,
|
||||
remainingWindows,
|
||||
promotedPrimaryId,
|
||||
deletedTournament,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1144,24 +1068,6 @@ export async function getSportsSeasonsByTournament(tournamentId: string) {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
|
|
|||
|
|
@ -134,21 +134,14 @@ export function calculateSharedPlacementPoints(
|
|||
* AFL is different: it has TWO distinct tiers in the 5–8 zone:
|
||||
* - T5-T6: Semi-Finals losers (positions 5 and 6) → avg([5,6])
|
||||
* - T7-T8: Elimination Finals losers (positions 7 and 8) → avg([7,8])
|
||||
*
|
||||
* LLWS has the same shape from its two elimination brackets:
|
||||
* - T5-T6: Elimination Final losers (one per side) → avg([5,6])
|
||||
* - T7-T8: Elimination Round 4 losers (one per side) → avg([7,8])
|
||||
*/
|
||||
const SPLIT_5678_TEMPLATE_IDS = new Set(["afl_10", "llws_20"]);
|
||||
const SPLIT_5678_TEMPLATE_IDS = new Set(["afl_10"]);
|
||||
|
||||
/**
|
||||
* Brackets with a real 3rd place game, meaning positions 3 and 4 are distinct
|
||||
* (not averaged). Standard brackets average them because both SF losers tie.
|
||||
*
|
||||
* llws_20's Consolation Third Place game decides 3rd and 4th head-to-head between
|
||||
* the two side runners-up.
|
||||
*/
|
||||
const DISTINCT_34_TEMPLATE_IDS = new Set(["fifa_48", "llws_20"]);
|
||||
const DISTINCT_34_TEMPLATE_IDS = new Set(["fifa_48"]);
|
||||
|
||||
/**
|
||||
* Calculate fantasy points for a bracket placement, averaging tied positions.
|
||||
|
|
|
|||
|
|
@ -1,99 +0,0 @@
|
|||
/**
|
||||
* Race-calendar state for season-standings sports (F1, IndyCar).
|
||||
*
|
||||
* Kept in its own leaf module rather than in `scoring-event.ts` so that
|
||||
* `simulator.ts` can read it: `scoring-event.ts` pulls in `scoring-calculator`,
|
||||
* which reaches `participant-expected-value` and back into `simulator`. This
|
||||
* file imports nothing but the database.
|
||||
*/
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
|
||||
export interface SeasonRaceCounts {
|
||||
completed: number;
|
||||
remaining: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* How long after the green flag a race is assumed to have finished.
|
||||
*
|
||||
* `event_starts_at` is a start time, so treating it as "already run" would
|
||||
* declare the season over the moment the finale goes green — and the simulator
|
||||
* would publish the pre-race leader as champion at 100%, from standings that do
|
||||
* not yet include the race being run. No race in these series comes close to
|
||||
* six hours, and the standings feed updates within hours of a finish.
|
||||
*/
|
||||
const RACE_DURATION_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Has this race already been run?
|
||||
*
|
||||
* `is_complete` wins when an admin has set it, but a racing calendar is stored
|
||||
* as "Non-Scoring" rows that nobody ever marks complete, so the date is the real
|
||||
* signal. Mirrors the Upcoming / Results Pending badge on the admin events page.
|
||||
* A race happening today is still upcoming, and a row with no date at all counts
|
||||
* as upcoming.
|
||||
*
|
||||
* @param today `now` as a `YYYY-MM-DD` string, to compare against the date-only
|
||||
* `event_date` column.
|
||||
*/
|
||||
export function hasRaceRun(
|
||||
event: {
|
||||
isComplete: boolean;
|
||||
eventDate: string | null;
|
||||
eventStartsAt: Date | string | null;
|
||||
},
|
||||
now: Date,
|
||||
today: string
|
||||
): boolean {
|
||||
if (event.isComplete) return true;
|
||||
if (event.eventStartsAt) {
|
||||
return new Date(event.eventStartsAt).getTime() + RACE_DURATION_MS < now.getTime();
|
||||
}
|
||||
if (event.eventDate) return event.eventDate < today;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the races on a season-standings calendar (F1, IndyCar).
|
||||
*
|
||||
* `event_type` has no race value, so a racing calendar is stored as
|
||||
* `schedule_event` rows — the admin default for the `season_standings` scoring
|
||||
* pattern. The only other row such a season carries is the single
|
||||
* `final_standings` event that assigns fantasy placements once the championship
|
||||
* is settled. A race is therefore "every event except `final_standings`", not
|
||||
* "every event except `schedule_event`" — getting that backwards leaves the
|
||||
* simulator with zero remaining races and no idea the season is in progress.
|
||||
*/
|
||||
export async function countSeasonRaces(
|
||||
sportsSeasonId: string,
|
||||
now: Date = new Date(),
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<SeasonRaceCounts> {
|
||||
const db = providedDb || database();
|
||||
|
||||
const events = await db.query.scoringEvents.findMany({
|
||||
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||||
columns: {
|
||||
eventType: true,
|
||||
isComplete: true,
|
||||
eventDate: true,
|
||||
eventStartsAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const today = now.toISOString().split("T")[0];
|
||||
let completed = 0;
|
||||
let remaining = 0;
|
||||
|
||||
for (const event of events) {
|
||||
if (event.eventType === "final_standings") continue;
|
||||
if (hasRaceRun(event, now, today)) completed++;
|
||||
else remaining++;
|
||||
}
|
||||
|
||||
return { completed, remaining, total: completed + remaining };
|
||||
}
|
||||
|
|
@ -15,10 +15,6 @@ import {
|
|||
sourceEloRequirementLabel,
|
||||
} from "~/services/simulations/input-policy";
|
||||
import { SIMULATOR_TYPES, type SimulatorType } from "~/services/simulations/registry";
|
||||
import { countSeasonRaces } from "~/models/season-races";
|
||||
|
||||
/** Simulator types driven by a race calendar plus championship standings. */
|
||||
const RACE_CALENDAR_SIMULATORS: SimulatorType[] = ["f1_standings", "indycar_standings"];
|
||||
|
||||
export interface SimulatorProfile extends SimulatorManifestProfile {
|
||||
isActive: boolean;
|
||||
|
|
@ -311,6 +307,93 @@ 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();
|
||||
|
||||
// Persist the odds non-destructively. How much these odds move a stored
|
||||
// Elo/rating is now governed by the season's blend weight (see resolveSourceElos
|
||||
// / SimulatorInputPolicy.oddsWeight), so we no longer null out manually entered
|
||||
// Elo here — that policy decides at run time.
|
||||
await db
|
||||
.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`,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function batchUpsertParticipantSimulatorInputs(
|
||||
inputs: UpsertParticipantSimulatorInput[]
|
||||
): Promise<void> {
|
||||
|
|
@ -343,44 +426,16 @@ export async function batchUpsertParticipantSimulatorInputs(
|
|||
schema.seasonParticipantSimulatorInputs.participantId,
|
||||
schema.seasonParticipantSimulatorInputs.sportsSeasonId,
|
||||
],
|
||||
// Non-destructive: only overwrite a column when the incoming value is
|
||||
// non-null. This lets a partial import (e.g. odds-only) update just the
|
||||
// columns it provides without clobbering previously stored inputs like a
|
||||
// manually entered Elo or rating. Mirrors the COALESCE bridge used for the
|
||||
// legacy EV table below.
|
||||
set: {
|
||||
sourceOdds: sql`COALESCE(excluded.source_odds, ${schema.seasonParticipantSimulatorInputs.sourceOdds})`,
|
||||
sourceElo: sql`COALESCE(excluded.source_elo, ${schema.seasonParticipantSimulatorInputs.sourceElo})`,
|
||||
worldRanking: sql`COALESCE(excluded.world_ranking, ${schema.seasonParticipantSimulatorInputs.worldRanking})`,
|
||||
rating: sql`COALESCE(excluded.rating, ${schema.seasonParticipantSimulatorInputs.rating})`,
|
||||
projectedWins: sql`COALESCE(excluded.projected_wins, ${schema.seasonParticipantSimulatorInputs.projectedWins})`,
|
||||
projectedTablePoints: sql`COALESCE(excluded.projected_table_points, ${schema.seasonParticipantSimulatorInputs.projectedTablePoints})`,
|
||||
seed: sql`COALESCE(excluded.seed, ${schema.seasonParticipantSimulatorInputs.seed})`,
|
||||
region: sql`COALESCE(excluded.region, ${schema.seasonParticipantSimulatorInputs.region})`,
|
||||
// Metadata carries the method flags (sourceEloMethod/ratingMethod) that
|
||||
// tell readers whether the stored Elo/rating is generated vs. a trusted
|
||||
// direct value. Two rules apply, and both always apply — they are not
|
||||
// alternatives:
|
||||
//
|
||||
// 1. Drop the method flag for any column receiving a fresh direct
|
||||
// value, otherwise a stale "generated" flag would cause that
|
||||
// newly-entered Elo/rating to be filtered out as derived (see
|
||||
// getParticipantSimulatorInputs).
|
||||
// 2. Merge any metadata the caller supplied over the result
|
||||
// (prepareSimulatorInputsForRun and the projection importers set the
|
||||
// correct flags) — a merge rather than a replace so a caller that
|
||||
// only needs to stamp one method flag does not wipe unrelated keys.
|
||||
//
|
||||
// Ordering matters: strip first, then merge, so a caller stamping one flag
|
||||
// still gets the other column's stale flag cleared. Running these as
|
||||
// exclusive CASE branches instead would mean a bulk row carrying both a
|
||||
// direct `rating` and a `projectedWins` (which stamps sourceEloMethod)
|
||||
// silently kept a stale ratingMethod, hiding the rating it just set.
|
||||
metadata: sql`(
|
||||
COALESCE(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb)
|
||||
- (CASE WHEN excluded.source_elo IS NOT NULL THEN 'sourceEloMethod' ELSE '' END)
|
||||
- (CASE WHEN excluded.rating IS NOT NULL THEN 'ratingMethod' ELSE '' END)
|
||||
) || COALESCE(excluded.metadata, '{}'::jsonb)`,
|
||||
sourceOdds: sql`excluded.source_odds`,
|
||||
sourceElo: sql`excluded.source_elo`,
|
||||
worldRanking: sql`excluded.world_ranking`,
|
||||
rating: sql`excluded.rating`,
|
||||
projectedWins: sql`excluded.projected_wins`,
|
||||
projectedTablePoints: sql`excluded.projected_table_points`,
|
||||
seed: sql`excluded.seed`,
|
||||
region: sql`excluded.region`,
|
||||
metadata: sql`excluded.metadata`,
|
||||
updatedAt: sql`excluded.updated_at`,
|
||||
},
|
||||
});
|
||||
|
|
@ -502,19 +557,6 @@ export async function validateSimulatorReadiness(
|
|||
}
|
||||
}
|
||||
|
||||
if (RACE_CALENDAR_SIMULATORS.includes(config.simulatorType)) {
|
||||
// Without a calendar the simulator cannot tell how many races are left, so
|
||||
// it falls back to futures odds and ignores the championship standings
|
||||
// entirely. A warning, not a blocker — a season drafted before the schedule
|
||||
// is published still needs to run.
|
||||
const races = await countSeasonRaces(sportsSeasonId);
|
||||
if (races.total === 0) {
|
||||
warnings.push(
|
||||
"No race calendar found for this season. Add the schedule on the events page — until then the simulation uses futures odds only and ignores championship standings."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (config.profile.setupSections.includes("regularStandings")) {
|
||||
warnings.push("Regular-season standings may be needed for in-season accuracy.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,57 +169,6 @@ export async function findDraftableSportsSeasonBySportId(sportId: string) {
|
|||
});
|
||||
}
|
||||
|
||||
export type DraftScheduleWindow = {
|
||||
id: string;
|
||||
name: string;
|
||||
year: number;
|
||||
status: SportsSeasonStatus;
|
||||
draftOn: string;
|
||||
draftOff: string;
|
||||
sport: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
iconUrl: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns admin sport-seasons (fantasySeasonId IS NULL) whose draft window
|
||||
* [draftOn, draftOff] overlaps the horizon [today, today + monthsAhead]. Uses an
|
||||
* overlap test (not containment) so windows already open, or spanning the horizon
|
||||
* edges, are still included. Powers the admin draft-schedule Gantt chart.
|
||||
*/
|
||||
export async function findDraftScheduleForHorizon(
|
||||
monthsAhead: number
|
||||
): Promise<DraftScheduleWindow[]> {
|
||||
const db = database();
|
||||
const horizonEnd = sql`CURRENT_DATE + make_interval(months => ${monthsAhead})`;
|
||||
const seasons = await db.query.sportsSeasons.findMany({
|
||||
where: (ss, { and }) =>
|
||||
and(
|
||||
isNull(ss.fantasySeasonId),
|
||||
gte(ss.draftOff, sql`CURRENT_DATE`),
|
||||
lte(ss.draftOn, horizonEnd)
|
||||
),
|
||||
orderBy: (sportsSeasons, { asc }) => [asc(sportsSeasons.draftOn)],
|
||||
with: {
|
||||
sport: {
|
||||
columns: { id: true, name: true, slug: true, iconUrl: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
return seasons.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
year: s.year,
|
||||
status: s.status,
|
||||
draftOn: s.draftOn,
|
||||
draftOff: s.draftOff,
|
||||
sport: s.sport,
|
||||
})) as DraftScheduleWindow[];
|
||||
}
|
||||
|
||||
export async function updateSportsSeason(
|
||||
id: string,
|
||||
data: Partial<NewSportsSeason>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import type { TeamStanding, TeamStandingSnapshot, TeamStandingWithChange } from
|
|||
import { calculateBracketPoints, calculateFantasyPoints } from "~/models/scoring-rules";
|
||||
import { logger } from "~/lib/logger";
|
||||
import { getParticipantEV } from "./participant-expected-value";
|
||||
import { getBracketTemplateIdForSportsSeason } from "./bracket-template";
|
||||
import { calculateEV } from "~/services/ev-calculator";
|
||||
|
||||
// Re-export types from shared types file
|
||||
|
|
@ -164,7 +163,11 @@ export async function getTeamScoreBreakdown(
|
|||
if (bracketTemplateCache.has(sportsSeasonId)) {
|
||||
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
||||
}
|
||||
const templateId = await getBracketTemplateIdForSportsSeason(sportsSeasonId, db);
|
||||
const event = await db.query.scoringEvents.findFirst({
|
||||
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||||
columns: { bracketTemplateId: true },
|
||||
});
|
||||
const templateId = event?.bracketTemplateId ?? null;
|
||||
bracketTemplateCache.set(sportsSeasonId, templateId);
|
||||
return templateId;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,17 +136,3 @@ export async function updateTournamentStatus(
|
|||
.returning();
|
||||
return tournament;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a canonical tournament. The DB cascades remove its tournament_results;
|
||||
* any surviving scoring_events have their tournamentId set to NULL (onDelete:
|
||||
* "set null"). Callers should only use this once the last linked window has been
|
||||
* removed, so no scoring events are left pointing at it.
|
||||
*/
|
||||
export async function deleteTournament(
|
||||
id: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<void> {
|
||||
const db = providedDb || database();
|
||||
await db.delete(schema.tournaments).where(eq(schema.tournaments.id, id));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ export default [
|
|||
route("forgot-password", "routes/forgot-password.tsx"),
|
||||
route("reset-password", "routes/reset-password.tsx"),
|
||||
route("onboarding", "routes/onboarding.tsx"),
|
||||
route("settings/:section?", "routes/settings.tsx"),
|
||||
route("settings", "routes/settings.tsx"),
|
||||
route("user-profile", "routes/user-profile-redirect.tsx"),
|
||||
route("how-to-play", "routes/how-to-play.tsx"),
|
||||
route("rules", "routes/rules.tsx"),
|
||||
|
|
@ -96,7 +96,6 @@ export default [
|
|||
route("simulators", "routes/admin.simulators.tsx"),
|
||||
route("sports/new", "routes/admin.sports.new.tsx"),
|
||||
route("sports/:id", "routes/admin.sports.$id.tsx"),
|
||||
route("draft-schedule", "routes/admin.draft-schedule.tsx"),
|
||||
route("sports-seasons", "routes/admin.sports-seasons.tsx"),
|
||||
route("sports-seasons/new", "routes/admin.sports-seasons.new.tsx"),
|
||||
route("sports-seasons/:id", "routes/admin.sports-seasons.$id.tsx"),
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ vi.mock("~/services/simulations/runner", () => ({
|
|||
runSportsSeasonSimulation: vi.fn(),
|
||||
}));
|
||||
|
||||
import { action, loader } from "../admin.simulators";
|
||||
import { action, futuresBlendLabel, loader } from "../admin.simulators";
|
||||
import { listSportsSeasonSimulatorSummaries } from "~/models/simulator";
|
||||
import { runSportsSeasonSimulation } from "~/services/simulations/runner";
|
||||
|
||||
|
|
@ -102,6 +102,23 @@ describe("admin simulators loader", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("futuresBlendLabel", () => {
|
||||
it("shows the full Elo/Futures split for an in-between weight", () => {
|
||||
expect(futuresBlendLabel(0.3)).toBe("70% Elo / 30% Futures");
|
||||
expect(futuresBlendLabel(0.5)).toBe("50% Elo / 50% Futures");
|
||||
});
|
||||
|
||||
it("labels the extremes without percentages", () => {
|
||||
expect(futuresBlendLabel(0)).toBe("Elo only");
|
||||
expect(futuresBlendLabel(1)).toBe("overrides Elo");
|
||||
});
|
||||
|
||||
it("keeps a near-extreme blend within 1–99% so it never reads as 0/100", () => {
|
||||
expect(futuresBlendLabel(0.999)).toBe("1% Elo / 99% Futures");
|
||||
expect(futuresBlendLabel(0.004)).toBe("99% Elo / 1% Futures");
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin simulators action", () => {
|
||||
it("runs one simulator", async () => {
|
||||
vi.mocked(runSportsSeasonSimulation).mockResolvedValue({
|
||||
|
|
|
|||
|
|
@ -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,10 +77,14 @@ export async function action({ request }: Route.ActionArgs): Promise<ActionData>
|
|||
};
|
||||
}
|
||||
|
||||
function futuresBlendLabel(oddsWeight: number): string {
|
||||
export function futuresBlendLabel(oddsWeight: number): string {
|
||||
if (oddsWeight >= 1) return "overrides Elo";
|
||||
if (oddsWeight <= 0) return "Elo only";
|
||||
return `${Math.round(oddsWeight * 100)}% blend`;
|
||||
// A genuine blend (0 < oddsWeight < 1) should never read as a 0/100 split:
|
||||
// keep the displayed futures share within [1, 99] so the extremes stay
|
||||
// reserved for the "Elo only" / "overrides Elo" labels above.
|
||||
const futuresPct = Math.min(99, Math.max(1, Math.round(oddsWeight * 100)));
|
||||
return `${100 - futuresPct}% Elo / ${futuresPct}% Futures`;
|
||||
}
|
||||
|
||||
function statusBadge(status: string) {
|
||||
|
|
@ -271,6 +275,11 @@ export default function AdminSimulators({ loaderData }: Route.ComponentProps) {
|
|||
Setup
|
||||
</Link>
|
||||
</Button>
|
||||
{sim.supportsFuturesOdds && (
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to={`/admin/sports-seasons/${sim.sportsSeasonId}/futures-odds`}>Futures</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to={`/admin/sports-seasons/${sim.sportsSeasonId}/expected-values`}>EVs</Link>
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ import {
|
|||
projectedWinsToElo,
|
||||
} from '~/services/probability-engine';
|
||||
import { runSportsSeasonSimulation } from '~/services/simulations/runner';
|
||||
import { getParticipantSimulatorInputs, getSportsSeasonSimulatorConfig } from '~/models/simulator';
|
||||
import { getSportsSeasonSimulatorConfig } from '~/models/simulator';
|
||||
|
||||
// Simulator types that use worldRanking in addition to sourceElo
|
||||
const RANKING_SIMULATOR_TYPES = new Set(['darts_bracket', 'cs2_major_qualifying_points', 'college_hockey_bracket']);
|
||||
|
|
@ -80,38 +80,13 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
|
||||
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
|
||||
const existingEVs = await getAllParticipantEVsForSeason(sportsSeasonId);
|
||||
const simulatorInputs = await getParticipantSimulatorInputs(sportsSeasonId);
|
||||
|
||||
// The projection a participant was actually saved with. Read it back verbatim:
|
||||
// deriving the field from the stored Elo instead (as this page used to) shows the
|
||||
// admin a different number than they typed, because wins → Elo rounds to an
|
||||
// integer Elo and a simulation run then re-resolves that Elo through the input
|
||||
// policy (clamping, and blending in futures odds when a season has them).
|
||||
const projectionsByParticipant = new Map(
|
||||
simulatorInputs.map((input) => [
|
||||
input.participantId,
|
||||
{ projectedWins: input.projectedWins, projectedTablePoints: input.projectedTablePoints },
|
||||
])
|
||||
);
|
||||
|
||||
const existingData: Record<
|
||||
string,
|
||||
{ elo: number | null; ranking: number | null; projectedWins: number | null; projectedTablePoints: number | null }
|
||||
> = {};
|
||||
for (const participant of participants) {
|
||||
const projection = projectionsByParticipant.get(participant.id);
|
||||
existingData[participant.id] = {
|
||||
elo: null,
|
||||
ranking: null,
|
||||
projectedWins: projection?.projectedWins ?? null,
|
||||
projectedTablePoints: projection?.projectedTablePoints ?? null,
|
||||
};
|
||||
}
|
||||
const existingData: Record<string, { elo: number | null; ranking: number | null }> = {};
|
||||
for (const ev of existingEVs) {
|
||||
const existing = existingData[ev.participantId];
|
||||
if (!existing) continue;
|
||||
existing.elo = ev.sourceElo ?? null;
|
||||
existing.ranking = ev.worldRanking ?? null;
|
||||
existingData[ev.participantId] = {
|
||||
elo: ev.sourceElo ?? null,
|
||||
ranking: ev.worldRanking ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const usesRanking = RANKING_SIMULATOR_TYPES.has(sportsSeason.sport?.simulatorType ?? '');
|
||||
|
|
@ -277,16 +252,7 @@ export default function AdminSportsSeasonEloRatings() {
|
|||
if (simulatorConfig) {
|
||||
participants.forEach(p => {
|
||||
const d = existingData[p.id];
|
||||
// A stored projection is shown exactly as it was entered. Only fall back to
|
||||
// deriving it from the Elo when this season has no projection saved (a
|
||||
// season that has only ever had Elos entered still gets a useful starting
|
||||
// point) — that derived value is lossy and must never overwrite a real one.
|
||||
const stored = simulatorConfig.projectionInput === 'tablePoints'
|
||||
? d?.projectedTablePoints
|
||||
: d?.projectedWins;
|
||||
if (stored !== null && stored !== undefined) {
|
||||
initial[p.id] = stored.toString();
|
||||
} else if (d?.elo !== null && d?.elo !== undefined) {
|
||||
if (d?.elo !== null && d?.elo !== undefined) {
|
||||
initial[p.id] = (simulatorConfig.projectionInput === 'tablePoints'
|
||||
? eloToProjectedTablePoints(d.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
|
||||
: eloToProjectedWins(d.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
|
||||
|
|
@ -299,8 +265,8 @@ export default function AdminSportsSeasonEloRatings() {
|
|||
|
||||
const [bulkText, setBulkText] = useState('');
|
||||
const [parseResults, setParseResults] = useState<{
|
||||
matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; projection: number | null; inputName: string }>;
|
||||
unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null; projection: number | null }>;
|
||||
matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; inputName: string }>;
|
||||
unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null }>;
|
||||
} | null>(null);
|
||||
|
||||
function findParticipantMatch(inputName: string) {
|
||||
|
|
@ -325,8 +291,8 @@ export default function AdminSportsSeasonEloRatings() {
|
|||
|
||||
function parseBulkText() {
|
||||
const lines = bulkText.split('\n');
|
||||
const matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; projection: number | null; inputName: string }> = [];
|
||||
const unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null; projection: number | null }> = [];
|
||||
const matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; inputName: string }> = [];
|
||||
const unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null }> = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const line of lines) {
|
||||
|
|
@ -349,9 +315,9 @@ export default function AdminSportsSeasonEloRatings() {
|
|||
const participant = findParticipantMatch(inputName);
|
||||
if (participant && !seen.has(participant.id)) {
|
||||
seen.add(participant.id);
|
||||
matched.push({ participantId: participant.id, name: participant.name, elo, ranking: null, projection: projectedWins, inputName });
|
||||
matched.push({ participantId: participant.id, name: participant.name, elo, ranking: null, inputName });
|
||||
} else if (!participant) {
|
||||
unmatched.push({ inputName, elo, ranking: null, projection: projectedWins });
|
||||
unmatched.push({ inputName, elo, ranking: null });
|
||||
}
|
||||
} else {
|
||||
const match = usesRanking
|
||||
|
|
@ -376,9 +342,9 @@ export default function AdminSportsSeasonEloRatings() {
|
|||
const participant = findParticipantMatch(inputName);
|
||||
if (participant && !seen.has(participant.id)) {
|
||||
seen.add(participant.id);
|
||||
matched.push({ participantId: participant.id, name: participant.name, elo, ranking, projection: null, inputName });
|
||||
matched.push({ participantId: participant.id, name: participant.name, elo, ranking, inputName });
|
||||
} else if (!participant) {
|
||||
unmatched.push({ inputName, elo, ranking, projection: null });
|
||||
unmatched.push({ inputName, elo, ranking });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -394,11 +360,11 @@ export default function AdminSportsSeasonEloRatings() {
|
|||
for (const m of parseResults.matched) {
|
||||
if (m.elo !== null) newElos[m.participantId] = m.elo.toString();
|
||||
if (m.ranking !== null) newRanks[m.participantId] = m.ranking.toString();
|
||||
// The pasted number goes in as typed. Round-tripping it through the derived
|
||||
// Elo (as this used to) drifts it by up to half an Elo point — a pasted 95
|
||||
// came back as 95.1 before anything was even saved.
|
||||
if (inputMode === 'projectedWins' && m.projection !== null) {
|
||||
newWins[m.participantId] = m.projection.toString();
|
||||
if (inputMode === 'projectedWins' && simulatorConfig && m.elo !== null) {
|
||||
newWins[m.participantId] = (simulatorConfig.projectionInput === 'tablePoints'
|
||||
? eloToProjectedTablePoints(m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
|
||||
: eloToProjectedWins(m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
|
||||
).toFixed(1);
|
||||
}
|
||||
}
|
||||
setEloValues(newElos);
|
||||
|
|
@ -523,10 +489,7 @@ Mark Selby, 2432`
|
|||
<div key={m.participantId} className="flex justify-between px-3 py-1.5">
|
||||
<span className="text-muted-foreground">{m.inputName}</span>
|
||||
<span className="font-medium">
|
||||
{m.name} →{' '}
|
||||
{m.projection !== null
|
||||
? `${m.projection} ${projectionUnit} (Elo ${m.elo})`
|
||||
: m.elo !== null ? `Elo ${m.elo}` : 'No Elo'}
|
||||
{m.name} → {m.elo !== null ? `Elo ${m.elo}` : 'No Elo'}
|
||||
{usesRanking && m.ranking !== null ? `, ${rankLabel} #${m.ranking}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -546,9 +509,7 @@ Mark Selby, 2432`
|
|||
<div key={u.inputName} className="flex justify-between px-3 py-1.5">
|
||||
<span>{u.inputName}</span>
|
||||
<span className="font-medium">
|
||||
{u.projection !== null
|
||||
? `${u.projection} ${projectionUnit} (Elo ${u.elo})`
|
||||
: u.elo !== null ? `Elo ${u.elo}` : 'No Elo'}
|
||||
{u.elo !== null ? `Elo ${u.elo}` : 'No Elo'}
|
||||
{usesRanking && u.ranking !== null ? `, ${rankLabel} #${u.ranking}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -579,7 +540,7 @@ Mark Selby, 2432`
|
|||
</CardTitle>
|
||||
<CardDescription>
|
||||
{inputMode === 'projectedWins'
|
||||
? `Enter each team's projected total season ${projectionUnit} — the number you enter is stored as-is and re-derives the Elo on every run. Mid-season it is treated as a projected final total, so the simulation spreads the difference over the games still to play. Saving will run the simulation and update expected values.`
|
||||
? `Enter each team's projected total season ${projectionUnit}. Converted to Elo automatically. Saving will run the simulation and update expected values.`
|
||||
: usesRanking
|
||||
? `Enter each ${participantLabel.toLowerCase()}'s Elo${allowsRankOnly ? ' (optional)' : ''} and ${rankLabel}. Saving will automatically run the simulation and update expected values.`
|
||||
: `Enter each ${participantLabel.toLowerCase()}'s current Elo rating. Saving will automatically run the simulation and update expected values.`}
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
/**
|
||||
* Pure helpers for the bracket-scoring route action, extracted so the
|
||||
* knockout-detection logic can be unit-tested without the full action harness.
|
||||
*/
|
||||
|
||||
/**
|
||||
* From a set of matches being scored this action, return the season_participant
|
||||
* ids knocked out for the FIRST time. A loser counts only when its match was NOT
|
||||
* already complete — a re-score/correction of a finished match must not
|
||||
* re-announce the exit (mirrors populateBracketFromDraw's first-completion rule in
|
||||
* the live-sync path). Entries with an unknown loser (`null`) are skipped.
|
||||
*
|
||||
* The ids are season_participant ids of the primary window (playoff_matches are
|
||||
* keyed by season_participant); the fan-out translates them to each mirror
|
||||
* window's own season_participant id before announcing the "Knocked Out" section.
|
||||
*/
|
||||
export function newlyDecidedLosers(
|
||||
entries: Array<{ wasComplete: boolean; loserId: string | null }>
|
||||
): string[] {
|
||||
return entries
|
||||
.filter((e): e is { wasComplete: boolean; loserId: string } =>
|
||||
!e.wasComplete && e.loserId !== null
|
||||
)
|
||||
.map((e) => e.loserId);
|
||||
}
|
||||
|
|
@ -1,23 +1,16 @@
|
|||
import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket";
|
||||
|
||||
import { findSportsSeasonById } from "~/models/sports-season";
|
||||
import {
|
||||
findParticipantsBySportsSeasonId,
|
||||
createParticipant,
|
||||
updateParticipant,
|
||||
} from "~/models/season-participant";
|
||||
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
||||
import { getScoringEventById, updateScoringEvent, isReadOnlySibling } from "~/models/scoring-event";
|
||||
import {
|
||||
findPlayoffMatchesByEventId,
|
||||
deletePlayoffMatchesByEventId,
|
||||
generateBracketFromTemplate,
|
||||
setMatchWinner,
|
||||
advanceWinnerTemplate,
|
||||
findPlayoffMatchById,
|
||||
assignParticipantsToKnockout,
|
||||
doesLoserAdvance,
|
||||
reseedAflEliminationFinals,
|
||||
reseedAflSemiFinals,
|
||||
} from "~/models/playoff-match";
|
||||
import {
|
||||
createGame,
|
||||
|
|
@ -38,7 +31,6 @@ import {
|
|||
recalculateAffectedLeagues,
|
||||
recalculateStandings,
|
||||
autoCompleteRoundIfDone,
|
||||
applyBracketEntryFloors,
|
||||
} from "~/models/scoring-calculator";
|
||||
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
||||
import { getBracketTemplate, ALL_16_SEEDS, type BracketRegion } from "~/lib/bracket-templates";
|
||||
|
|
@ -46,7 +38,6 @@ import {
|
|||
setParticipantResult,
|
||||
findParticipantResultsBySportsSeasonId,
|
||||
deleteParticipantResultsBySportsSeasonId,
|
||||
deleteParticipantResultsForParticipants,
|
||||
} from "~/models/participant-result";
|
||||
import { findSeasonSportsBySportsSeasonId } from "~/models/season-sport";
|
||||
import { createDailySnapshot } from "~/models/standings";
|
||||
|
|
@ -70,10 +61,7 @@ import { database } from "~/database/context";
|
|||
import * as schema from "~/database/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.server";
|
||||
import { fanOutMajorIfPrimary, syncMajorFromPrimaryEvent } from "~/services/sync-tournament-results";
|
||||
import { syncTennisDraw, previewTennisDraw } from "~/services/match-sync";
|
||||
import { articleTitleFromInput } from "~/services/match-sync/wikipedia-tennis";
|
||||
import { newlyDecidedLosers } from "./admin.sports-seasons.$id.events.$eventId.bracket.helpers";
|
||||
import { fanOutMajorIfPrimary } from "~/services/sync-tournament-results";
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
const sportsSeason = await findSportsSeasonById(params.id);
|
||||
|
|
@ -141,23 +129,9 @@ async function scoreQualifyingBracket(
|
|||
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>
|
||||
recalcOptions?: Parameters<typeof recalculateAffectedLeagues>[2]
|
||||
): 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 processQualifyingBracketEvent(event.id, db);
|
||||
await recalculateAffectedLeagues(
|
||||
event.sportsSeasonId,
|
||||
db,
|
||||
|
|
@ -165,59 +139,7 @@ async function scoreQualifyingBracket(
|
|||
);
|
||||
// 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 };
|
||||
await fanOutMajorIfPrimary(event, { markComplete: false });
|
||||
}
|
||||
|
||||
export async function action({ request, params }: Route.ActionArgs) {
|
||||
|
|
@ -237,116 +159,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
}
|
||||
}
|
||||
|
||||
if (intent === "create-draw-participant") {
|
||||
const name = (formData.get("name") as string | null)?.trim();
|
||||
const externalId = (formData.get("externalId") as string | null)?.trim() || null;
|
||||
if (!name) return { error: "Participant name is required" };
|
||||
try {
|
||||
await createParticipant({ sportsSeasonId: params.id, name, externalId });
|
||||
return { success: `Created "${name}". Re-run Preview or Sync Draw to apply.` };
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : "Failed to create participant" };
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === "relink-draw-participant") {
|
||||
const participantId = formData.get("participantId") as string | null;
|
||||
const name = (formData.get("name") as string | null)?.trim();
|
||||
const externalId = (formData.get("externalId") as string | null)?.trim() || null;
|
||||
if (!participantId || !name) return { error: "Participant and name are required" };
|
||||
try {
|
||||
await updateParticipant(participantId, { name, externalId });
|
||||
return { success: `Renamed and linked to "${name}". Re-run Preview or Sync Draw to apply.` };
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : "Failed to update participant" };
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === "preview-draw") {
|
||||
const rawInput = (formData.get("externalSourceKey") as string | null)?.trim();
|
||||
const articleTitle = rawInput ? articleTitleFromInput(rawInput) : "";
|
||||
// Persist the article so the user can preview, then sync, without re-entering.
|
||||
if (articleTitle) {
|
||||
await updateScoringEvent(params.eventId, { externalSourceKey: articleTitle });
|
||||
}
|
||||
try {
|
||||
const preview = await previewTennisDraw(params.eventId);
|
||||
return { drawPreview: preview };
|
||||
} catch (error) {
|
||||
logger.error("[preview-draw] error:", error);
|
||||
return { error: error instanceof Error ? error.message : "Failed to preview draw" };
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === "sync-draw") {
|
||||
const rawInput = (formData.get("externalSourceKey") as string | null)?.trim();
|
||||
// Accept a pasted Wikipedia URL or a plain article title; store the title.
|
||||
const articleTitle = rawInput ? articleTitleFromInput(rawInput) : "";
|
||||
if (articleTitle) {
|
||||
await updateScoringEvent(params.eventId, { externalSourceKey: articleTitle });
|
||||
}
|
||||
try {
|
||||
const result = await syncTennisDraw(params.eventId);
|
||||
const reviewNote =
|
||||
result.unmatched.length > 0
|
||||
? ` ${result.unmatched.length} auto-created player(s) need a quick review for possible duplicates.`
|
||||
: "";
|
||||
return {
|
||||
success:
|
||||
`Synced draw: ${result.matchesWritten} matches written ` +
|
||||
`(${result.completed} completed), ${result.participantsCreated} participant(s) added.${reviewNote}`,
|
||||
drawSyncResult: result,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("[sync-draw] error:", error);
|
||||
return { error: error instanceof Error ? error.message : "Failed to sync draw" };
|
||||
}
|
||||
}
|
||||
|
||||
// The only way to repair a mis-seeded bracket: nothing else can rewrite a match's
|
||||
// participants. Clearing brings back the setup form, so the admin re-seeds from there.
|
||||
if (intent === "clear-bracket") {
|
||||
try {
|
||||
const event = await getScoringEventById(params.eventId);
|
||||
if (!event) return { error: "Event not found" };
|
||||
|
||||
const existing = await findPlayoffMatchesByEventId(params.eventId);
|
||||
if (existing.length === 0) {
|
||||
return { error: "This event has no bracket to clear" };
|
||||
}
|
||||
// Clearing discards recorded results, so make the admin confirm once games have
|
||||
// actually been played.
|
||||
const completed = existing.filter((m) => m.isComplete).length;
|
||||
if (completed > 0 && formData.get("confirm") !== "true") {
|
||||
return {
|
||||
error: `This bracket has ${completed} completed match(es). Confirm to discard those results.`,
|
||||
};
|
||||
}
|
||||
|
||||
await deletePlayoffMatchesByEventId(params.eventId);
|
||||
|
||||
// Placements are deliberately left alone. seasonParticipantResults is keyed by
|
||||
// sports season, not by event, so a season-wide delete here would wipe the
|
||||
// placements of every other event in the season with nothing to rebuild them —
|
||||
// and on a finalized qualifying season that means permanently zeroed standings.
|
||||
// Reprocess Bracket already rebuilds placements correctly, qualifying path
|
||||
// included, so point the admin at it once the new bracket is in place.
|
||||
const note =
|
||||
completed > 0
|
||||
? " Run Reprocess Bracket after rebuilding to clear the placements those results produced."
|
||||
: "";
|
||||
|
||||
return {
|
||||
success: `Bracket cleared (${existing.length} match(es) removed). Set it up again below.${note}`,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Error clearing bracket:", error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : "Failed to clear bracket",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === "generate-bracket") {
|
||||
const templateId = formData.get("templateId");
|
||||
|
||||
|
|
@ -410,50 +222,36 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
try {
|
||||
await generateBracketFromTemplate(params.eventId, templateId, participantIds, regionOverride);
|
||||
|
||||
// The template ID has to land on the event before entry floors can be derived
|
||||
// (getBracketEntryFloor reads it), so persist it here rather than after the
|
||||
// elimination pass below.
|
||||
// PHASE 5.3: Mark participants NOT in the bracket as eliminated
|
||||
const event = await getScoringEventById(params.eventId);
|
||||
if (event) {
|
||||
// Get all participants in the sports season
|
||||
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
|
||||
|
||||
// Create a set of participants in the bracket for fast lookup
|
||||
const participantsInBracket = new Set(participantIds);
|
||||
|
||||
// Mark participants NOT in the bracket as eliminated
|
||||
for (const participant of allParticipants) {
|
||||
if (!participantsInBracket.has(participant.id)) {
|
||||
await setParticipantResult(
|
||||
participant.id,
|
||||
event.sportsSeasonId,
|
||||
0 // 0 = didn't make playoffs, eliminated
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.log(`[BracketGeneration] Marked ${allParticipants.length - participantIds.length} participants as eliminated`);
|
||||
}
|
||||
|
||||
// Update the event to store the template ID, scoring start round, and region config
|
||||
await updateScoringEvent(params.eventId, {
|
||||
bracketTemplateId: templateId,
|
||||
scoringStartsAtRound: template.scoringStartsAtRound,
|
||||
bracketRegionConfig: regionOverride,
|
||||
});
|
||||
|
||||
// Some seedings guarantee points before a ball is bounced — an AFL top-4 seed
|
||||
// has the double chance, so the 5th-6th tier is locked in at generation. Bank
|
||||
// those provisional floors now, ahead of the elimination announcement below so
|
||||
// the standings it posts already reflect them.
|
||||
const entryFloorCount = await applyBracketEntryFloors(params.eventId);
|
||||
if (entryFloorCount > 0) {
|
||||
logger.log(`[BracketGeneration] Applied entry floors to ${entryFloorCount} participant(s)`);
|
||||
}
|
||||
|
||||
// PHASE 5.3: Mark participants NOT in the bracket as eliminated (and announce).
|
||||
const event = await getScoringEventById(params.eventId);
|
||||
if (event) {
|
||||
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
|
||||
const participantsInBracket = new Set(participantIds);
|
||||
const toEliminate = allParticipants
|
||||
.filter((p) => !participantsInBracket.has(p.id))
|
||||
.map((p) => p.id);
|
||||
const { markedCount, recalculated } = await markEliminatedAndAnnounce(event, toEliminate);
|
||||
logger.log(`[BracketGeneration] Marked ${markedCount} participants as eliminated`);
|
||||
|
||||
// The floors banked above only reach teamStandings.totalPoints via a recalc, and
|
||||
// markEliminatedAndAnnounce runs one for its announcement in some cases but not
|
||||
// others: not for a qualifying event, not when every eliminated team already had
|
||||
// a result row (the second run of a generation, since the first wrote 0 for all
|
||||
// of them), not when there was nobody to eliminate, and not when the announcement
|
||||
// threw. Drive off what it reports rather than re-deriving it from toEliminate.
|
||||
// skipDiscord: seeding floors are not a result to announce.
|
||||
if (entryFloorCount > 0 && !recalculated) {
|
||||
await recalculateAffectedLeagues(event.sportsSeasonId, database(), {
|
||||
eventName: event.name ?? undefined,
|
||||
skipDiscord: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { success: "Bracket generated successfully" };
|
||||
} catch (error) {
|
||||
logger.error("Error generating bracket:", error);
|
||||
|
|
@ -500,13 +298,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
return { error: "Could not determine loser" };
|
||||
}
|
||||
|
||||
// A knockout is "newly decided" only when the match wasn't already complete
|
||||
// (mirrors populateBracketFromDraw's first-completion rule) — a re-score/
|
||||
// correction of an already-finished match must not re-announce the exit.
|
||||
const setWinnerNewlyEliminated = new Set(
|
||||
newlyDecidedLosers([{ wasComplete: match.isComplete, loserId }])
|
||||
);
|
||||
|
||||
// Set the winner
|
||||
await setMatchWinner(matchId, winnerId, loserId);
|
||||
|
||||
|
|
@ -533,16 +324,11 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
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
|
||||
);
|
||||
await scoreQualifyingBracket(event, db, {
|
||||
eventId: event.id,
|
||||
eventName: event.name ?? undefined,
|
||||
matchIds: [matchId],
|
||||
});
|
||||
} else {
|
||||
// Immediately score this match: loser gets their final placement,
|
||||
// winner gets provisional floor points (isPartialScore=true).
|
||||
|
|
@ -614,10 +400,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
let successCount = 0;
|
||||
const errors: string[] = [];
|
||||
const processedMatchIds: string[] = [];
|
||||
// Per-match completion + loser, collected so newlyDecidedLosers() can pick out
|
||||
// the batch's first-time knockouts to fan out to mirror windows (a non-scoring-
|
||||
// round exit earns no QP and is otherwise invisible to the mirror).
|
||||
const decidedEntries: Array<{ wasComplete: boolean; loserId: string | null }> = [];
|
||||
|
||||
for (const { matchId, winnerId } of winnerAssignments) {
|
||||
try {
|
||||
|
|
@ -686,10 +468,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
|
||||
successCount++;
|
||||
processedMatchIds.push(matchId);
|
||||
// Only after the match fully succeeded: record its prior completion so
|
||||
// newlyDecidedLosers() announces this loser only if it's a first-time exit
|
||||
// (and never for a match whose write failed above).
|
||||
decidedEntries.push({ wasComplete: match.isComplete, loserId });
|
||||
} catch (error) {
|
||||
logger.error(`Error setting winner for match ${matchId}:`, error);
|
||||
errors.push(
|
||||
|
|
@ -703,14 +481,9 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
// previously completed matches in the event.
|
||||
if (successCount > 0) {
|
||||
const db = database();
|
||||
// Qualifying majors: derive QP from the full bracket once for the batch, and
|
||||
// announce the QP change to this window's leagues. processQualifyingEvent (not
|
||||
// processQualifyingBracketEvent) is what sends the QP Discord notification; the
|
||||
// fan-out below skips this window (skipEventId) so mirrors don't double-post.
|
||||
// Qualifying majors: derive QP from the full bracket once for the batch.
|
||||
if (event.isQualifyingEvent) {
|
||||
await processQualifyingEvent(event.id, db, {
|
||||
newlyEliminatedParticipantIds: new Set(newlyDecidedLosers(decidedEntries)),
|
||||
});
|
||||
await processQualifyingBracketEvent(event.id, db);
|
||||
}
|
||||
// Update probabilities first so recalculateAffectedLeagues reads fresh EVs
|
||||
// when computing projected points.
|
||||
|
|
@ -725,12 +498,8 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
if (!event.isQualifyingEvent) {
|
||||
await autoCompleteRoundIfDone(event.id, round, event.sportsSeasonId, db);
|
||||
} else {
|
||||
// Shared major primary window: propagate this round to siblings, carrying
|
||||
// the batch's newly-decided knockouts so mirrors announce them too.
|
||||
await fanOutMajorIfPrimary(event, {
|
||||
markComplete: false,
|
||||
newlyEliminatedParticipantIds: new Set(newlyDecidedLosers(decidedEntries)),
|
||||
});
|
||||
// Shared major primary window: propagate this round to siblings.
|
||||
await fanOutMajorIfPrimary(event, { markComplete: false });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -868,101 +637,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
}
|
||||
}
|
||||
|
||||
// Re-seed the AFL Wildcard winners into the Elimination Finals they belong in.
|
||||
// Advancement does this on every Wildcard result, so this is only needed for a
|
||||
// bracket advanced before that rule existed: the winners sit in the wrong games and
|
||||
// no admin action re-runs advancement (a completed match cannot be re-submitted).
|
||||
if (intent === "reseed-afl-wildcard") {
|
||||
try {
|
||||
const event = await getScoringEventById(params.eventId);
|
||||
if (!event) return { error: "Event not found" };
|
||||
if (event.bracketTemplateId !== "afl_10") {
|
||||
return { error: "This action only applies to AFL finals brackets" };
|
||||
}
|
||||
|
||||
const participants = await findParticipantsBySportsSeasonId(params.id);
|
||||
const nameOf = (id: string) => participants.find((p) => p.id === id)?.name ?? id;
|
||||
|
||||
const reseed = await reseedAflEliminationFinals(params.eventId);
|
||||
if (reseed.vacated.length === 0 && reseed.filled.length === 0) {
|
||||
return {
|
||||
success:
|
||||
"Elimination Finals already match the Wildcard results — nothing to re-seed.",
|
||||
};
|
||||
}
|
||||
|
||||
// Only the qualifier slots move, so there is nothing to re-score: no placement,
|
||||
// score or elimination changes, and so nothing to announce.
|
||||
const moves = reseed.filled
|
||||
.toSorted((a, b) => a.matchNumber - b.matchNumber)
|
||||
.map((slot) => `match ${slot.matchNumber} now hosts ${nameOf(slot.participantId)}`)
|
||||
.join(", ");
|
||||
|
||||
return {
|
||||
success: `Re-seeded the Elimination Finals: ${moves}.`,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Error re-seeding AFL Wildcard winners:", error);
|
||||
return {
|
||||
error:
|
||||
error instanceof Error ? error.message : "Failed to re-seed the Elimination Finals",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Put the Elimination Final winners in the Semi-Finals they belong in. Elimination
|
||||
// Final n feeds Semi-Final n, but brackets advanced before that was fixed crossed the
|
||||
// two winners, and no admin action re-runs advancement (a completed match cannot be
|
||||
// re-submitted).
|
||||
if (intent === "reseed-afl-semifinals") {
|
||||
try {
|
||||
const event = await getScoringEventById(params.eventId);
|
||||
if (!event) return { error: "Event not found" };
|
||||
if (event.bracketTemplateId !== "afl_10") {
|
||||
return { error: "This action only applies to AFL finals brackets" };
|
||||
}
|
||||
|
||||
const participants = await findParticipantsBySportsSeasonId(params.id);
|
||||
const nameOf = (id: string) => participants.find((p) => p.id === id)?.name ?? id;
|
||||
|
||||
const reseed = await reseedAflSemiFinals(params.eventId);
|
||||
if (reseed.vacated.length === 0 && reseed.filled.length === 0) {
|
||||
return {
|
||||
success:
|
||||
"Semi-Finals already match the Elimination Finals results — nothing to re-seed.",
|
||||
};
|
||||
}
|
||||
|
||||
// Only the qualifier slots move, so there is nothing to re-score: no placement,
|
||||
// score or elimination changes, and so nothing to announce.
|
||||
//
|
||||
// A slot can be vacated without being refilled — un-recording an Elimination Final
|
||||
// result takes its winner back out — so report those too rather than rendering an
|
||||
// empty list.
|
||||
const filled = reseed.filled.map((slot) => ({
|
||||
matchNumber: slot.matchNumber,
|
||||
text: `match ${slot.matchNumber} now hosts ${nameOf(slot.participantId)}`,
|
||||
}));
|
||||
const emptied = reseed.vacated
|
||||
.filter((matchNumber) => !reseed.filled.some((slot) => slot.matchNumber === matchNumber))
|
||||
.map((matchNumber) => ({ matchNumber, text: `match ${matchNumber} is back to TBD` }));
|
||||
const moves = [...filled, ...emptied]
|
||||
.toSorted((a, b) => a.matchNumber - b.matchNumber)
|
||||
.map((move) => move.text)
|
||||
.join(", ");
|
||||
|
||||
return {
|
||||
success: `Re-seeded the Semi-Finals: ${moves}.`,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Error re-seeding AFL Elimination Finals winners:", error);
|
||||
return {
|
||||
error:
|
||||
error instanceof Error ? error.message : "Failed to re-seed the Semi-Finals",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === "reprocess-bracket") {
|
||||
try {
|
||||
const event = await getScoringEventById(params.eventId);
|
||||
|
|
@ -989,68 +663,22 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
// 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.` };
|
||||
// Re-propagate corrected QP to sibling windows (data-correction; not final).
|
||||
await fanOutMajorIfPrimary(event, { markComplete: false });
|
||||
return {
|
||||
success: `Reprocessed qualifying bracket: cleared stale fantasy points and recomputed QP (${completed.length} completed match(es)).`,
|
||||
};
|
||||
}
|
||||
|
||||
if (matches.length === 0) {
|
||||
return { error: "No bracket to reprocess" };
|
||||
if (completed.length === 0) {
|
||||
return { error: "No completed matches to reprocess" };
|
||||
}
|
||||
|
||||
// Wipe this bracket's participants' results and rebuild from scratch. Deleting
|
||||
// only the partial rows would leave stale finalized ones, which the "never
|
||||
// un-finalize" guard in upsertParticipantResult then refuses to correct.
|
||||
//
|
||||
// Scoped to the participants this bracket actually holds, not the whole season:
|
||||
// seasonParticipantResults is keyed by sports season, not by event, so a
|
||||
// season-wide delete takes every other event's placements with it and only this
|
||||
// bracket's replay could rebuild them (the hazard clear-bracket documents).
|
||||
//
|
||||
// Unconditional, because zero completed matches is precisely the clear-bracket →
|
||||
// regenerate → reprocess repair path: the discarded bracket's finalized
|
||||
// placements are exactly what needs clearing, and there is always something to
|
||||
// rebuild from — the entry floors below, then the replay.
|
||||
// Delete ALL results for this sports season and rebuild from scratch.
|
||||
// Only deleting partial rows leaves stale finalized rows that block
|
||||
// the "never un-finalize" guard in upsertParticipantResult.
|
||||
const db = database();
|
||||
// Reused further down to decide who is *not* in the bracket and so eliminated.
|
||||
const bracketParticipantIds = new Set<string>();
|
||||
for (const match of matches) {
|
||||
if (match.participant1Id) bracketParticipantIds.add(match.participant1Id);
|
||||
if (match.participant2Id) bracketParticipantIds.add(match.participant2Id);
|
||||
}
|
||||
await deleteParticipantResultsForParticipants(
|
||||
event.sportsSeasonId,
|
||||
[...bracketParticipantIds],
|
||||
db
|
||||
);
|
||||
|
||||
// Re-bank the seeding-derived floors the delete above wipes (e.g. the AFL
|
||||
// top-4's 5th-6th tier). Done before the replay so real match results overwrite
|
||||
// them; a bracket with no completed matches still gets its guaranteed points.
|
||||
const entryFloorCount = await applyBracketEntryFloors(params.eventId, db);
|
||||
await deleteParticipantResultsBySportsSeasonId(event.sportsSeasonId, db);
|
||||
|
||||
// Replay each completed match in bracket order (earlier rounds first).
|
||||
const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null;
|
||||
|
|
@ -1091,6 +719,11 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
// Mark participants NOT in any bracket match as eliminated (finalPosition = 0).
|
||||
// This covers teams that didn't make the playoffs/play-in tournament.
|
||||
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
|
||||
const bracketParticipantIds = new Set<string>();
|
||||
for (const match of matches) {
|
||||
if (match.participant1Id) bracketParticipantIds.add(match.participant1Id);
|
||||
if (match.participant2Id) bracketParticipantIds.add(match.participant2Id);
|
||||
}
|
||||
let eliminatedCount = 0;
|
||||
for (const participant of allParticipants) {
|
||||
if (!bracketParticipantIds.has(participant.id)) {
|
||||
|
|
@ -1106,12 +739,7 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
// skipDiscord: reprocess-bracket is a data-correction tool, not a result announcement.
|
||||
await recalculateAffectedLeagues(event.sportsSeasonId, undefined, { skipDiscord: true });
|
||||
|
||||
return {
|
||||
success:
|
||||
`Reprocessed bracket: ${sortedMatches.length} match(es) replayed, ` +
|
||||
`${entryFloorCount} seeded participant(s) given their guaranteed entry floor, ` +
|
||||
`${eliminatedCount} non-bracket participant(s) eliminated`,
|
||||
};
|
||||
return { success: `Reprocessed bracket: ${sortedMatches.length} match(es) replayed, ${eliminatedCount} non-bracket participant(s) eliminated` };
|
||||
} catch (error) {
|
||||
logger.error("Error reprocessing bracket:", error);
|
||||
return {
|
||||
|
|
@ -1154,11 +782,9 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
// 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.
|
||||
// processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent),
|
||||
// increments majorsCompleted, and recalcs participant QP totals. Season-wide
|
||||
// fantasy finalization stays with finalizeQualifyingPoints across all majors.
|
||||
await processQualifyingEvent(params.eventId, db);
|
||||
await db
|
||||
.update(schema.scoringEvents)
|
||||
|
|
@ -1287,19 +913,14 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
await generateBracketFromTemplate(params.eventId, templateId);
|
||||
|
||||
// Eliminate participants from this sport season who are not in any group
|
||||
// (and announce to leagues for fantasy events).
|
||||
const groupsEvent = await getScoringEventById(params.eventId);
|
||||
if (!groupsEvent) {
|
||||
return { error: "Event not found" };
|
||||
}
|
||||
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
|
||||
const toEliminate = allParticipants
|
||||
.filter((p) => !uniqueParticipants.has(p.id))
|
||||
.map((p) => p.id);
|
||||
const { markedCount: eliminatedCount } = await markEliminatedAndAnnounce(
|
||||
groupsEvent,
|
||||
toEliminate
|
||||
);
|
||||
let eliminatedCount = 0;
|
||||
for (const participant of allParticipants) {
|
||||
if (!uniqueParticipants.has(participant.id)) {
|
||||
await setParticipantResult(participant.id, params.id, 0);
|
||||
eliminatedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: `Groups and knockout bracket structure created successfully${eliminatedCount > 0 ? ` (${eliminatedCount} participant(s) not in any group marked as eliminated)` : ""}`,
|
||||
|
|
@ -1424,10 +1045,11 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
// Assign participants to knockout bracket
|
||||
await assignParticipantsToKnockout(params.eventId, assignments);
|
||||
|
||||
// Mark eliminated group participants with finalPosition = 0 (and announce
|
||||
// the group-stage eliminations to leagues for fantasy events).
|
||||
// Mark eliminated group participants with finalPosition = 0
|
||||
const eliminatedIds = await getEliminatedParticipantIds(params.eventId);
|
||||
await markEliminatedAndAnnounce(event, eliminatedIds);
|
||||
for (const participantId of eliminatedIds) {
|
||||
await setParticipantResult(participantId, event.sportsSeasonId, 0);
|
||||
}
|
||||
|
||||
logger.log(
|
||||
`[PopulateKnockout] Assigned 32 participants to knockout, marked ${eliminatedIds.length} as eliminated`
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Form, Link, useFetcher } from "react-router";
|
||||
import { Form, Link } from "react-router";
|
||||
import { Fragment, useState, useEffect, useMemo, useRef } from "react";
|
||||
import { localDateTimeToUtcIso, utcIsoToLocalDateTime } from "~/lib/date-utils";
|
||||
import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket";
|
||||
|
|
@ -93,62 +93,6 @@ function GroupMatchScheduleForm({
|
|||
|
||||
export { loader, action };
|
||||
|
||||
/**
|
||||
* One "possible duplicate" row in the draw preview. Uses a fetcher so resolving
|
||||
* it (rename existing / create as new) submits in the background — the preview
|
||||
* stays on screen instead of reloading the page and forcing a fresh dry run.
|
||||
*/
|
||||
function DuplicateRow({
|
||||
p,
|
||||
}: {
|
||||
p: { name: string; externalId: string | null; suggestion: string; suggestionId: string };
|
||||
}) {
|
||||
const fetcher = useFetcher<{ success?: string; error?: string }>();
|
||||
const busy = fetcher.state !== "idle";
|
||||
|
||||
return (
|
||||
<li className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span>
|
||||
<span className="font-medium">{p.name}</span>
|
||||
<span className="text-muted-foreground"> → looks like </span>
|
||||
<span className="font-medium">{p.suggestion}</span>
|
||||
</span>
|
||||
{fetcher.data?.success ? (
|
||||
<span className="text-xs font-medium text-emerald-500">✓ {fetcher.data.success}</span>
|
||||
) : (
|
||||
<fetcher.Form method="post" className="flex items-center gap-2">
|
||||
<input type="hidden" name="name" value={p.name} />
|
||||
<input type="hidden" name="externalId" value={p.externalId ?? ""} />
|
||||
<input type="hidden" name="participantId" value={p.suggestionId} />
|
||||
<Button
|
||||
type="submit"
|
||||
name="intent"
|
||||
value="relink-draw-participant"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
>
|
||||
Rename “{p.suggestion}” → “{p.name}”
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
name="intent"
|
||||
value="create-draw-participant"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
>
|
||||
Create as new
|
||||
</Button>
|
||||
</fetcher.Form>
|
||||
)}
|
||||
{fetcher.data?.error && (
|
||||
<span className="text-xs text-destructive">{fetcher.data.error}</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EventBracket({
|
||||
loaderData,
|
||||
actionData,
|
||||
|
|
@ -431,164 +375,6 @@ export default function EventBracket({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Possible-duplicate review after a draw sync */}
|
||||
{actionData?.drawSyncResult && actionData.drawSyncResult.unmatched.length > 0 && (
|
||||
<Card className="border-amber-500/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-amber-500">
|
||||
Review {actionData.drawSyncResult.unmatched.length} possible duplicate
|
||||
{actionData.drawSyncResult.unmatched.length === 1 ? "" : "s"}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
These players were auto-created but closely resemble an existing participant —
|
||||
if a duplicate, results won't reach the drafted copy. To fix: on the{" "}
|
||||
<Link
|
||||
to={`/admin/sports-seasons/${sportsSeason.id}/participants`}
|
||||
className="underline font-medium"
|
||||
>
|
||||
participants page
|
||||
</Link>
|
||||
, set the correct participant's external ID to the Wikipedia name, delete the
|
||||
duplicate, then click <span className="font-medium">Sync Draw</span> again.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{actionData.drawSyncResult.unmatched.map((p) => (
|
||||
<li key={p.externalId ?? p.name} className="flex items-center gap-2">
|
||||
<span className="font-medium">{p.name}</span>
|
||||
{p.externalId && p.externalId !== p.name && (
|
||||
<span className="text-muted-foreground text-xs">({p.externalId})</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Dry-run preview of a draw sync */}
|
||||
{actionData?.drawPreview && (
|
||||
<Card className="border-sky-500/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sky-500">Draw preview (no changes made)</CardTitle>
|
||||
<CardDescription>{actionData.drawPreview.article}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 sm:grid-cols-3">
|
||||
<div>
|
||||
Players: <span className="font-medium">{actionData.drawPreview.totalPlayers}</span>
|
||||
</div>
|
||||
<div>
|
||||
Matched:{" "}
|
||||
<span className="font-medium text-emerald-500">
|
||||
{actionData.drawPreview.matched}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
Will create:{" "}
|
||||
<span className="font-medium">{actionData.drawPreview.willCreate.length}</span>
|
||||
</div>
|
||||
<div>
|
||||
Matches:{" "}
|
||||
<span className="font-medium">
|
||||
{actionData.drawPreview.completedMatches}/{actionData.drawPreview.totalMatches}{" "}
|
||||
done
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
Unfilled R1 slots:{" "}
|
||||
<span className="font-medium">{actionData.drawPreview.tbdFirstRound}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{actionData.drawPreview.possibleDuplicates.length > 0 && (
|
||||
<div>
|
||||
<p className="font-medium text-amber-500">
|
||||
Possible duplicates ({actionData.drawPreview.possibleDuplicates.length}) — would
|
||||
be created but resemble an existing participant:
|
||||
</p>
|
||||
<ul className="mt-2 space-y-2">
|
||||
{actionData.drawPreview.possibleDuplicates.map((p) => (
|
||||
<DuplicateRow key={p.externalId ?? p.name} p={p} />
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium">Rename</span> if it's the same player (links
|
||||
the existing participant so it matches);{" "}
|
||||
<span className="font-medium">Create as new</span> if they're different
|
||||
people. Then re-run Preview or Sync Draw.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionData.drawPreview.willCreate.length > 0 && (
|
||||
<details>
|
||||
<summary className="cursor-pointer text-muted-foreground">
|
||||
Show all {actionData.drawPreview.willCreate.length} players that would be created
|
||||
</summary>
|
||||
<ul className="mt-1 columns-2 sm:columns-3">
|
||||
{actionData.drawPreview.willCreate.map((p) => (
|
||||
<li key={p.externalId ?? p.name}>{p.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Fix any duplicates on the{" "}
|
||||
<Link
|
||||
to={`/admin/sports-seasons/${sportsSeason.id}/participants`}
|
||||
className="underline font-medium"
|
||||
>
|
||||
participants page
|
||||
</Link>{" "}
|
||||
first, then click <span className="font-medium">Sync Draw</span>.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Sync Draw from Wikipedia — tennis Grand Slam auto-populate + auto-score */}
|
||||
{event.isQualifyingEvent &&
|
||||
sportsSeason.sport.simulatorType === "tennis_qualifying_points" && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sync Draw from Wikipedia</CardTitle>
|
||||
<CardDescription>
|
||||
Auto-populate and score this Grand Slam bracket from its Wikipedia draw
|
||||
article. Re-run during the tournament to pull in completed matches.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form method="post" className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="externalSourceKey">Wikipedia draw page (URL or title)</Label>
|
||||
<Input
|
||||
id="externalSourceKey"
|
||||
name="externalSourceKey"
|
||||
placeholder="https://en.wikipedia.org/wiki/2025_Wimbledon_Championships_–_Men's_singles"
|
||||
defaultValue={event.externalSourceKey ?? ""}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Paste the article URL or type the title — both work. Use{" "}
|
||||
<span className="font-medium">Preview</span> first to see matches and any
|
||||
new/duplicate players before writing anything.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" name="intent" value="preview-draw" variant="outline">
|
||||
Preview (dry run)
|
||||
</Button>
|
||||
<Button type="submit" name="intent" value="sync-draw">
|
||||
Sync Draw
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Reprocess Bracket - Full rebuild of participant results.
|
||||
Hidden once every match is complete — at that point all placements are final
|
||||
and "finalize bracket" should be used instead. */}
|
||||
|
|
@ -613,110 +399,6 @@ export default function EventBracket({
|
|||
</Card>
|
||||
)}
|
||||
|
||||
{/* Re-seed AFL Wildcard winners. Advancement pairs them by ladder position on
|
||||
every Wildcard result, so this is only for a bracket advanced before that
|
||||
rule existed — a completed match cannot be re-submitted to re-run it. */}
|
||||
{event.bracketTemplateId === "afl_10" && matches.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Re-seed Wildcard Winners</CardTitle>
|
||||
<CardDescription>
|
||||
Pair the Elimination Finals by ladder position: 5th hosts the
|
||||
lower-ranked Wildcard winner and 6th the higher-ranked one. Only moves
|
||||
the qualifier slots — no results, scores or placements change, and
|
||||
nothing is announced. Does nothing if the pairings are already right.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value="reseed-afl-wildcard" />
|
||||
<Button type="submit" variant="outline">
|
||||
Re-seed Wildcard Winners
|
||||
</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Fix the Semi-Final pairings. Elimination Final n feeds Semi-Final n, but
|
||||
brackets advanced before that was fixed crossed the two winners, and no
|
||||
admin action re-runs advancement. */}
|
||||
{event.bracketTemplateId === "afl_10" && matches.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Fix Semi-Final Pairings</CardTitle>
|
||||
<CardDescription>
|
||||
Feed each Elimination Final into the Semi-Final it belongs to: EF1
|
||||
winner into SF1 and EF2 winner into SF2. Only moves the qualifier slots
|
||||
— no results, scores or placements change, and nothing is announced.
|
||||
Does nothing if the pairings are already right.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value="reseed-afl-semifinals" />
|
||||
<Button type="submit" variant="outline">
|
||||
Fix Semi-Final Pairings
|
||||
</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Clear Bracket - the only escape hatch for a mis-seeded bracket. Nothing else
|
||||
can rewrite a match's participants, so a wrong seeding has to be torn down
|
||||
and rebuilt via the setup form below, which reappears once this runs. */}
|
||||
{matches.length > 0 && (
|
||||
<Card className="border-destructive/40">
|
||||
<CardHeader>
|
||||
<CardTitle>Clear Bracket</CardTitle>
|
||||
<CardDescription>
|
||||
Delete every match in this bracket so it can be set up again from
|
||||
scratch. Use this when the wrong participants were seeded. Placements
|
||||
are left alone — run Reprocess Bracket after rebuilding to clear any
|
||||
that the discarded results produced.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form
|
||||
method="post"
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => {
|
||||
if (
|
||||
!confirm(
|
||||
`Delete all ${matches.length} match(es) in this bracket? Recorded results will be lost.`
|
||||
)
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="intent" value="clear-bracket" />
|
||||
{/* The server refuses to discard completed matches unless this is
|
||||
checked. Sending it unconditionally from a hidden field would make
|
||||
that guard unreachable, including for a submit without JS. */}
|
||||
{matches.some((m: { isComplete: boolean }) => m.isComplete) && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="confirm-clear-bracket"
|
||||
name="confirm"
|
||||
value="true"
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Label htmlFor="confirm-clear-bracket" className="font-normal">
|
||||
Yes, discard the results already recorded in this bracket
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
<Button type="submit" variant="destructive">
|
||||
Clear Bracket
|
||||
</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ====== SETUP PHASE ====== */}
|
||||
{showSetup && (
|
||||
<Card>
|
||||
|
|
@ -993,7 +675,7 @@ export default function EventBracket({
|
|||
return (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Label className="w-28 text-sm text-muted-foreground shrink-0">
|
||||
<Label className="w-20 text-sm text-muted-foreground shrink-0">
|
||||
{slotLabel}
|
||||
</Label>
|
||||
<div className="flex-1 min-w-0">
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import {
|
|||
} from "~/components/ui/select";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { ArrowLeft, Trophy, CheckCircle2, Pencil, Trash2, Brackets, Save } from "lucide-react";
|
||||
import { getEventTypeLabel } from "~/models/scoring-event-types";
|
||||
import { getEventTypeLabel } from "~/models/scoring-event";
|
||||
import { isBracketMajor } from "~/lib/event-utils";
|
||||
import {
|
||||
Table,
|
||||
|
|
|
|||
|
|
@ -8,8 +8,6 @@ import {
|
|||
deleteScoringEvent,
|
||||
bulkCreateScoringEvents,
|
||||
ensurePrimaryEvent,
|
||||
countWindowsByTournament,
|
||||
getMajorsCompleted,
|
||||
type CreateScoringEventData,
|
||||
} from "~/models/scoring-event";
|
||||
import { isBracketMajor } from "~/lib/event-utils";
|
||||
|
|
@ -29,14 +27,10 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
|
||||
const events = await getScoringEventsForSportsSeason(params.id);
|
||||
|
||||
// For qualifying sports seasons, get QP standings with global ranks attached.
|
||||
// majorsCompleted is derived on read (count of completed qualifying events), not a
|
||||
// stored counter — see getMajorsCompleted.
|
||||
// For qualifying sports seasons, get QP standings with global ranks attached
|
||||
let qpStandings = null;
|
||||
const scoringRules = null;
|
||||
let majorsCompleted = 0;
|
||||
if (sportsSeason.scoringPattern === "qualifying_points") {
|
||||
majorsCompleted = await getMajorsCompleted(params.id);
|
||||
const standings = await getQPStandings(params.id);
|
||||
let prevQP = -1;
|
||||
let prevRankStart = 1;
|
||||
|
|
@ -65,38 +59,11 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
);
|
||||
const availableTournaments = allTournamentsForSport.filter((t) => !linkedTournamentIds.has(t.id));
|
||||
|
||||
// Shared-tournament context for the delete confirmation dialog: for each event
|
||||
// linked to a canonical tournament, how many OTHER seasons ("windows") also use
|
||||
// it, and the tournament's name. Lets the UI explain exactly what a delete does.
|
||||
const sharedInfo = new Map<string, { name: string; windowCount: number }>();
|
||||
await Promise.all(
|
||||
[...linkedTournamentIds].map(async (tid) => {
|
||||
const cached = allTournamentsForSport.find((t) => t.id === tid);
|
||||
const [tournament, windowCount] = await Promise.all([
|
||||
cached ?? getTournamentById(tid),
|
||||
countWindowsByTournament(tid),
|
||||
]);
|
||||
sharedInfo.set(tid, {
|
||||
name: tournament?.name ?? "shared tournament",
|
||||
windowCount,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const eventsWithSharing = events.map((e) => {
|
||||
const info = e.tournamentId ? sharedInfo.get(e.tournamentId) : null;
|
||||
return {
|
||||
...e,
|
||||
tournamentName: info?.name ?? null,
|
||||
otherWindowCount: info ? Math.max(0, info.windowCount - 1) : 0,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
sportsSeason: { ...sportsSeason, majorsCompleted } as typeof sportsSeason & {
|
||||
sportsSeason: sportsSeason as typeof sportsSeason & {
|
||||
sport: { id: string; name: string; type: string; slug: string };
|
||||
},
|
||||
events: eventsWithSharing,
|
||||
events,
|
||||
qpStandings,
|
||||
scoringRules,
|
||||
availableTournaments,
|
||||
|
|
@ -248,19 +215,8 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
}
|
||||
|
||||
try {
|
||||
const result = await deleteScoringEvent(eventId, undefined, {
|
||||
deleteOrphanTournament: formData.get("deleteTournament") === "1",
|
||||
});
|
||||
let success = "Event deleted";
|
||||
if (result.deletedTournament) {
|
||||
success = "Event deleted and its shared tournament removed";
|
||||
} else if (result.tournamentId && result.remainingWindows > 0) {
|
||||
success = `Event removed from this season (shared tournament kept — still used by ${result.remainingWindows} season${result.remainingWindows !== 1 ? "s" : ""})`;
|
||||
if (result.promotedPrimaryId) {
|
||||
success += "; another season was promoted to primary";
|
||||
}
|
||||
}
|
||||
return { success };
|
||||
await deleteScoringEvent(eventId);
|
||||
return { success: "Event deleted successfully" };
|
||||
} catch (error) {
|
||||
logger.error("Error deleting event:", error);
|
||||
return { error: "Failed to delete event" };
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ import {
|
|||
import { Calendar, Trophy, ArrowLeft, Trash2, ListPlus } from "lucide-react";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { QualifyingPointsStandings } from "~/components/scoring/QualifyingPointsStandings";
|
||||
import { getEventTypeLabel } from "~/models/scoring-event-types";
|
||||
import { getEventTypeLabel } from "~/models/scoring-event";
|
||||
|
||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||
return [{ title: `Events — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
||||
|
|
@ -305,11 +305,7 @@ export default function SportsSeasonEvents({
|
|||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{events.map((event: { id: string; name: string; eventType: string; eventDate?: string | null; eventStartsAt?: Date | string | null; isComplete: boolean; tournamentId: string | null; isPrimary: boolean; tournamentName: string | null; otherWindowCount: number }) => {
|
||||
const isLinked = !!event.tournamentId;
|
||||
const hasOtherWindows = event.otherWindowCount > 0;
|
||||
const isLastWindow = isLinked && !hasOtherWindows;
|
||||
return (
|
||||
{events.map((event: { id: string; name: string; eventType: string; eventDate?: string | null; eventStartsAt?: Date | string | null; isComplete: boolean }) => (
|
||||
<Card key={event.id} className="hover:border-primary/50 transition-colors">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
|
|
@ -356,65 +352,28 @@ export default function SportsSeasonEvents({
|
|||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value="delete-event" />
|
||||
<input type="hidden" name="eventId" value={event.id} />
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete "{event.name}"?</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
{!isLinked ? (
|
||||
<span>
|
||||
This will also delete all results for this event. This action cannot be undone.
|
||||
</span>
|
||||
) : hasOtherWindows ? (
|
||||
<span>
|
||||
This event is part of the shared tournament{" "}
|
||||
<span className="font-medium">"{event.tournamentName}"</span>, also used by{" "}
|
||||
<span className="font-medium">
|
||||
{event.otherWindowCount} other season{event.otherWindowCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
. Deleting removes it from <span className="font-medium">this season only</span> — the
|
||||
tournament and the other seasons keep their data.
|
||||
{event.isPrimary && (
|
||||
<> This is the primary scoring window, so another season will be promoted to primary automatically.</>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
This is the only season linked to the shared tournament{" "}
|
||||
<span className="font-medium">"{event.tournamentName}"</span>. Deleting removes this
|
||||
event and its results. This action cannot be undone.
|
||||
</span>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{isLastWindow && (
|
||||
<label className="flex items-start gap-2 text-sm my-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="deleteTournament"
|
||||
value="1"
|
||||
className="mt-0.5 h-4 w-4 rounded border-input"
|
||||
/>
|
||||
<span>
|
||||
Also delete the shared tournament and its recorded results
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete "{event.name}"?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will also delete all results for this event. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value="delete-event" />
|
||||
<input type="hidden" name="eventId" value={event.id} />
|
||||
<AlertDialogAction type="submit" className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</Form>
|
||||
</Form>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
batchUpsertParticipantEVs,
|
||||
getAllParticipantEVsForSeason
|
||||
} from "~/models/participant-expected-value";
|
||||
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
const sportsSeason = await findSportsSeasonById(params.id);
|
||||
|
|
@ -28,6 +27,17 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
};
|
||||
}
|
||||
|
||||
const scoringRules = {
|
||||
pointsFor1st: 100,
|
||||
pointsFor2nd: 70,
|
||||
pointsFor3rd: 45,
|
||||
pointsFor4th: 45,
|
||||
pointsFor5th: 20,
|
||||
pointsFor6th: 20,
|
||||
pointsFor7th: 20,
|
||||
pointsFor8th: 20,
|
||||
};
|
||||
|
||||
export async function action({ request, params }: Route.ActionArgs) {
|
||||
const formData = await request.formData();
|
||||
|
||||
|
|
@ -48,7 +58,7 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
probSeventh: parseFloat(formData.get(`probSeventh_${participantId}`) as string || "0") / 100,
|
||||
probEighth: parseFloat(formData.get(`probEighth_${participantId}`) as string || "0") / 100,
|
||||
},
|
||||
scoringRules: DEFAULT_SCORING_RULES,
|
||||
scoringRules,
|
||||
source: "manual" as const,
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -19,8 +19,6 @@ import {
|
|||
TableRow,
|
||||
} from "~/components/ui/table";
|
||||
import { ArrowLeft, Calculator } from "lucide-react";
|
||||
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
|
||||
import { calculateEV } from "~/services/ev-calculator";
|
||||
|
||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||
return [{ title: `Expected Values — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
||||
|
|
@ -28,18 +26,9 @@ export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
|||
|
||||
export { loader };
|
||||
|
||||
// EV is shown on the same reference scale the runner persists it with: a sports season
|
||||
// is shared across leagues with different scoring, so DEFAULT_SCORING_RULES is the
|
||||
// common scale and each league re-derives its own EV from the stored probabilities
|
||||
// (see getPersistenceContext in services/simulations/runner.ts).
|
||||
//
|
||||
// Scoring: 1st=100, 2nd=70, 3rd=50, 4th=40, 5th/6th=25 each, 7th/8th=15 each.
|
||||
// Sum = 100+70+50+40+25+25+15+15 = 340.
|
||||
//
|
||||
// The 5th–8th values must stay distinct rather than collapsing to a flat 20: templates
|
||||
// that split that zone into two tiers (llws_20, afl_10) put a team locked into 5th–6th
|
||||
// at probFifth=probSixth=0.5 (EV 25) and one locked into 7th–8th at
|
||||
// probSeventh=probEighth=0.5 (EV 15). A flat table reports both as 20.
|
||||
// DEFAULT scoring values — must match DEFAULT_SCORING_RULES in the simulate route.
|
||||
// Scoring: 1st=100, 2nd=70, 3rd/4th (FF losers)=45 each, 5th–8th (E8 losers)=20 each.
|
||||
// Sum = 100+70+45+45+20+20+20+20 = 340.
|
||||
//
|
||||
// Total EV invariant: Σ EV across all participants = Σ scoring values = 340,
|
||||
// because each probability column sums to 1.0 across all participants.
|
||||
|
|
@ -47,23 +36,20 @@ export { loader };
|
|||
// 1. Stale EV records from a prior simulation run (fix: re-run simulation, which now
|
||||
// zeros non-bracket participants automatically)
|
||||
// 2. DB precision truncation (numeric(6,4) = 4dp; max drift ≈ ±1 for 68 teams)
|
||||
export function evFromProbs(ev: {
|
||||
const SCORING = [100, 70, 45, 45, 20, 20, 20, 20] as const;
|
||||
|
||||
function evFromProbs(ev: {
|
||||
probFirst: string; probSecond: string; probThird: string; probFourth: string;
|
||||
probFifth: string; probSixth: string; probSeventh: string; probEighth: string;
|
||||
}): number {
|
||||
return calculateEV(
|
||||
{
|
||||
probFirst: parseFloat(ev.probFirst),
|
||||
probSecond: parseFloat(ev.probSecond),
|
||||
probThird: parseFloat(ev.probThird),
|
||||
probFourth: parseFloat(ev.probFourth),
|
||||
probFifth: parseFloat(ev.probFifth),
|
||||
probSixth: parseFloat(ev.probSixth),
|
||||
probSeventh: parseFloat(ev.probSeventh),
|
||||
probEighth: parseFloat(ev.probEighth),
|
||||
},
|
||||
DEFAULT_SCORING_RULES
|
||||
);
|
||||
return parseFloat(ev.probFirst) * SCORING[0]
|
||||
+ parseFloat(ev.probSecond) * SCORING[1]
|
||||
+ parseFloat(ev.probThird) * SCORING[2]
|
||||
+ parseFloat(ev.probFourth) * SCORING[3]
|
||||
+ parseFloat(ev.probFifth) * SCORING[4]
|
||||
+ parseFloat(ev.probSixth) * SCORING[5]
|
||||
+ parseFloat(ev.probSeventh) * SCORING[6]
|
||||
+ parseFloat(ev.probEighth) * SCORING[7];
|
||||
}
|
||||
|
||||
function fmt(val: string | number) {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,394 @@
|
|||
import { redirect } from 'react-router';
|
||||
import { Form, redirect, useLoaderData, useActionData, useNavigation } from 'react-router';
|
||||
import type { Route } from './+types/admin.sports-seasons.$id.futures-odds';
|
||||
|
||||
// Futures odds entry has been consolidated into the Bulk Simulator Inputs card on
|
||||
// the simulator setup page (paste sportsbook lines like `Chiefs +450` or a CSV with
|
||||
// a sourceOdds column). This route now just redirects old bookmarks/links there.
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
return redirect(`/admin/sports-seasons/${params.id}/simulator`);
|
||||
import { logger } from '~/lib/logger';
|
||||
import { findSportsSeasonById } from '~/models/sports-season';
|
||||
import { findParticipantsBySportsSeasonId } from '~/models/season-participant';
|
||||
import { getAllParticipantEVsForSeason, batchSaveSourceOdds, clearSourceOddsForParticipants } from '~/models/participant-expected-value';
|
||||
import { batchSaveFuturesOddsForSimulator } from '~/models/simulator';
|
||||
import { Button } from '~/components/ui/button';
|
||||
import { Input } from '~/components/ui/input';
|
||||
import { Label } from '~/components/ui/label';
|
||||
import { Textarea } from '~/components/ui/textarea';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '~/components/ui/card';
|
||||
import { useState } from 'react';
|
||||
import { Loader2, Info, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import { runSportsSeasonSimulation } from '~/services/simulations/runner';
|
||||
|
||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||
return [{ title: `Futures Odds — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
||||
}
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
const sportsSeasonId = params.id;
|
||||
|
||||
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
|
||||
|
||||
if (!sportsSeason) {
|
||||
throw new Response('Sports season not found', { status: 404 });
|
||||
}
|
||||
|
||||
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
|
||||
const existingEVs = await getAllParticipantEVsForSeason(sportsSeasonId);
|
||||
|
||||
// Show any saved odds regardless of what simulation ran afterwards
|
||||
const existingOdds = new Map(
|
||||
existingEVs
|
||||
.filter(ev => ev.sourceOdds !== null)
|
||||
.map(ev => [ev.participantId, ev.sourceOdds])
|
||||
);
|
||||
|
||||
return {
|
||||
sportsSeason,
|
||||
participants,
|
||||
existingOdds,
|
||||
};
|
||||
}
|
||||
|
||||
interface ActionData {
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export async function action({ request, params }: Route.ActionArgs) {
|
||||
const sportsSeasonId = params.id;
|
||||
const formData = await request.formData();
|
||||
|
||||
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
|
||||
|
||||
if (!sportsSeason) {
|
||||
return { success: false, message: 'Sports season not found' };
|
||||
}
|
||||
|
||||
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
|
||||
|
||||
// Parse odds from form
|
||||
const futuresOdds: Array<{ participantId: string; odds: number; name: string }> = [];
|
||||
|
||||
for (const participant of participants) {
|
||||
const oddsValue = formData.get(`odds_${participant.id}`) as string;
|
||||
if (oddsValue && oddsValue.trim() !== '') {
|
||||
const odds = Number(oddsValue);
|
||||
if (!isNaN(odds)) {
|
||||
futuresOdds.push({
|
||||
participantId: participant.id,
|
||||
odds,
|
||||
name: participant.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (futuresOdds.length === 0) {
|
||||
return { success: false, message: 'Please enter odds for at least one participant' };
|
||||
}
|
||||
|
||||
if (!sportsSeason.sport?.simulatorType) {
|
||||
return { success: false, message: 'This sport has no simulator type configured. Set one on the sport in the admin panel.' };
|
||||
}
|
||||
|
||||
if (sportsSeason.simulationStatus === 'running') {
|
||||
return { success: false, message: 'A simulation is already running. Please wait.' };
|
||||
}
|
||||
|
||||
const shouldClearExisting = formData.get('clearExisting') === '1';
|
||||
|
||||
try {
|
||||
const oddsInputs = futuresOdds.map(({ participantId, odds }) => ({ participantId, sportsSeasonId, sourceOdds: odds }));
|
||||
|
||||
// Save to legacy table (EV-page display), then clear all ratings and save
|
||||
// sourceOdds to the simulator inputs table so odds always drive the run.
|
||||
// Sequential: batchSaveFuturesOddsForSimulator must win on the inputs table.
|
||||
await batchSaveSourceOdds(oddsInputs);
|
||||
await batchSaveFuturesOddsForSimulator(oddsInputs);
|
||||
|
||||
if (shouldClearExisting) {
|
||||
const keptIds = new Set(futuresOdds.map(f => f.participantId));
|
||||
const clearedIds = participants.filter(p => !keptIds.has(p.id)).map(p => p.id);
|
||||
await clearSourceOddsForParticipants(sportsSeasonId, clearedIds);
|
||||
}
|
||||
await runSportsSeasonSimulation(sportsSeasonId);
|
||||
} catch (error) {
|
||||
logger.error('Error running simulation:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Simulation failed',
|
||||
};
|
||||
}
|
||||
|
||||
return redirect(`/admin/sports-seasons/${sportsSeasonId}/expected-values`);
|
||||
}
|
||||
|
||||
function normalizeName(name: string): string {
|
||||
return name.toLowerCase().replace(/[^a-z0-9\s]/g, '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
export default function AdminSportsSeasonFuturesOdds() {
|
||||
const { sportsSeason, participants, existingOdds } = useLoaderData<typeof loader>();
|
||||
const actionData = useActionData<ActionData>();
|
||||
const navigation = useNavigation();
|
||||
|
||||
// Initialize odds values from existing data
|
||||
const [oddsValues, setOddsValues] = useState<Record<string, string>>(() => {
|
||||
const initial: Record<string, string> = {};
|
||||
participants.forEach(p => {
|
||||
const existingOdd = existingOdds.get(p.id);
|
||||
if (existingOdd !== undefined && existingOdd !== null) {
|
||||
initial[p.id] = existingOdd.toString();
|
||||
}
|
||||
});
|
||||
return initial;
|
||||
});
|
||||
|
||||
// Bulk import state
|
||||
const [clearExisting, setClearExisting] = useState(false);
|
||||
const [bulkText, setBulkText] = useState('');
|
||||
const [parseResults, setParseResults] = useState<{
|
||||
matched: Array<{ participantId: string; name: string; odds: number; inputName: string }>;
|
||||
unmatched: Array<{ inputName: string; odds: number }>;
|
||||
} | null>(null);
|
||||
|
||||
function findParticipantMatch(inputName: string) {
|
||||
const normalizedInput = normalizeName(inputName);
|
||||
const normalized = participants.map(p => ({ p, n: normalizeName(p.name) }));
|
||||
|
||||
const exact = normalized.find(({ n }) => n === normalizedInput);
|
||||
if (exact) return exact.p;
|
||||
|
||||
const contains = normalized.find(({ n }) => n.includes(normalizedInput) || normalizedInput.includes(n));
|
||||
if (contains) return contains.p;
|
||||
|
||||
const inputWords = normalizedInput.split(' ').filter(w => w.length > 2);
|
||||
const overlap = normalized.find(({ n }) => {
|
||||
const pWords = n.split(' ').filter(w => w.length > 2);
|
||||
const shared = inputWords.filter(w => pWords.includes(w));
|
||||
return shared.length > 0 && shared.length >= Math.min(inputWords.length, pWords.length) * 0.5;
|
||||
});
|
||||
|
||||
return overlap?.p ?? null;
|
||||
}
|
||||
|
||||
function parseBulkText() {
|
||||
const lines = bulkText.split('\n');
|
||||
const oddsPattern = /^(.+?)\s+([+-]\d{2,6})\s*$/;
|
||||
|
||||
const matched: Array<{ participantId: string; name: string; odds: number; inputName: string }> = [];
|
||||
const unmatched: Array<{ inputName: string; odds: number }> = [];
|
||||
const seenParticipants = new Set<string>();
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.length < 3) continue;
|
||||
|
||||
const match = oddsPattern.exec(trimmed);
|
||||
if (!match) continue;
|
||||
|
||||
const inputName = match[1].trim();
|
||||
const odds = parseInt(match[2], 10);
|
||||
if (isNaN(odds)) continue;
|
||||
|
||||
const participant = findParticipantMatch(inputName);
|
||||
if (participant && !seenParticipants.has(participant.id)) {
|
||||
seenParticipants.add(participant.id);
|
||||
matched.push({ participantId: participant.id, name: participant.name, odds, inputName });
|
||||
} else if (!participant) {
|
||||
unmatched.push({ inputName, odds });
|
||||
}
|
||||
}
|
||||
|
||||
setParseResults({ matched, unmatched });
|
||||
}
|
||||
|
||||
function applyMatches() {
|
||||
if (!parseResults) return;
|
||||
const newOdds = clearExisting ? {} : { ...oddsValues };
|
||||
for (const m of parseResults.matched) {
|
||||
newOdds[m.participantId] = m.odds.toString();
|
||||
}
|
||||
setOddsValues(newOdds);
|
||||
setParseResults(null);
|
||||
setBulkText('');
|
||||
setClearExisting(false);
|
||||
}
|
||||
|
||||
const isSubmitting = navigation.state === 'submitting';
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold mb-2">Futures Odds Entry</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{sportsSeason.sport.name} - {sportsSeason.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bulk Import */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Bulk Import</CardTitle>
|
||||
<CardDescription>
|
||||
Paste odds from FanDuel, DraftKings, OddsChecker, or any site. Each line should end with
|
||||
American odds (e.g. <code>Kansas City Chiefs +450</code>). Team names are fuzzy-matched to
|
||||
participants.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Textarea
|
||||
placeholder={`Paste odds here, one team per line:\nKansas City Chiefs +450\nSan Francisco 49ers +600\nBaltimore Ravens +700`}
|
||||
value={bulkText}
|
||||
onChange={e => { setBulkText(e.target.value); setParseResults(null); }}
|
||||
rows={8}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button type="button" variant="outline" onClick={parseBulkText} disabled={!bulkText.trim()}>
|
||||
Parse Odds
|
||||
</Button>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={clearExisting}
|
||||
onChange={e => setClearExisting(e.target.checked)}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
Clear existing odds not in this import
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{parseResults && (
|
||||
<div className="space-y-3">
|
||||
{parseResults.matched.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-emerald-400 mb-2">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Matched ({parseResults.matched.length})
|
||||
</div>
|
||||
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/10 divide-y divide-emerald-500/20 text-sm">
|
||||
{parseResults.matched.map(m => (
|
||||
<div key={m.participantId} className="flex justify-between px-3 py-1.5">
|
||||
<span className="text-muted-foreground">{m.inputName}</span>
|
||||
<span className="font-medium">
|
||||
{m.name} → {m.odds > 0 ? '+' : ''}{m.odds}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parseResults.unmatched.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-amber-700 mb-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
Not matched ({parseResults.unmatched.length}) — enter manually below
|
||||
</div>
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 divide-y divide-amber-100 text-sm">
|
||||
{parseResults.unmatched.map((u) => (
|
||||
<div key={u.inputName} className="flex justify-between px-3 py-1.5">
|
||||
<span>{u.inputName}</span>
|
||||
<span className="font-medium">{u.odds > 0 ? '+' : ''}{u.odds}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parseResults.matched.length === 0 && parseResults.unmatched.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No odds found. Make sure each line ends with a value like +450 or -200.</p>
|
||||
)}
|
||||
|
||||
{parseResults.matched.length > 0 && (
|
||||
<Button type="button" onClick={applyMatches}>
|
||||
Apply {parseResults.matched.length} matched odds to form
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Championship Futures Odds</CardTitle>
|
||||
<CardDescription>
|
||||
Enter American odds (e.g., +550, -200) for each participant's championship probability.
|
||||
Enter American odds, then run the ICM simulation to compute and save probability distributions.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form method="post" className="space-y-4">
|
||||
<input type="hidden" name="clearExisting" value={clearExisting ? "1" : ""} />
|
||||
<div className="space-y-3">
|
||||
{participants.map((participant) => (
|
||||
<div key={participant.id} className="grid grid-cols-2 gap-4 items-center">
|
||||
<Label htmlFor={`odds_${participant.id}`}>{participant.name}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
id={`odds_${participant.id}`}
|
||||
name={`odds_${participant.id}`}
|
||||
placeholder="+550"
|
||||
value={oddsValues[participant.id] ?? ''}
|
||||
onChange={(e) =>
|
||||
setOddsValues((prev) => ({
|
||||
...prev,
|
||||
[participant.id]: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-muted p-4 rounded-lg space-y-2">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<Info className="h-4 w-4" />
|
||||
ICM Calculation
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div>Uses Independent Chip Model from poker tournaments</div>
|
||||
<div>Works with any number of participants</div>
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
Every team gets probabilities for 1st-8th place, even longshots.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{actionData && !actionData.success && actionData.message && (
|
||||
<div className="text-sm text-destructive">{actionData.message}</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{isSubmitting ? 'Running...' : 'Run Simulation'}
|
||||
</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mt-4">
|
||||
<CardHeader>
|
||||
<CardTitle>How It Works</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm space-y-2">
|
||||
<ol className="list-decimal list-inside space-y-2">
|
||||
<li>Converts American odds to championship win probabilities</li>
|
||||
<li>Removes bookmaker vig (normalizes to 100%)</li>
|
||||
<li>Uses ICM algorithm to distribute probabilities across all placements</li>
|
||||
<li>Generates probability distribution (1st through 8th place) for ALL participants</li>
|
||||
<li>Even teams with +100000 odds get non-zero probabilities</li>
|
||||
</ol>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@ import { batchUpsertParticipantEVs } from '~/models/participant-expected-value';
|
|||
import { batchUpsertParticipantEvSnapshots } from '~/models/ev-snapshot';
|
||||
import { getGolfSkillsForSeason, batchUpsertGolfSkills } from '~/models/golf-skills';
|
||||
import { getSimulator, type SimulatorType } from '~/services/simulations/registry';
|
||||
import { calculateEV } from '~/services/ev-calculator';
|
||||
import { DEFAULT_SCORING_RULES } from '~/lib/scoring-types';
|
||||
import { calculateEV, type ScoringRules } from '~/services/ev-calculator';
|
||||
import { recalculateStandings } from '~/models/scoring-calculator';
|
||||
import { database } from '~/database/context';
|
||||
import * as schema from '~/database/schema';
|
||||
|
|
@ -29,6 +28,17 @@ import { useEffect, useRef, useState } from 'react';
|
|||
import { Loader2, CheckCircle2, AlertCircle, UserPlus } from 'lucide-react';
|
||||
import { normalizeName, diceCoefficient } from '~/lib/fuzzy-match';
|
||||
|
||||
const DEFAULT_SCORING_RULES: ScoringRules = {
|
||||
pointsFor1st: 100,
|
||||
pointsFor2nd: 70,
|
||||
pointsFor3rd: 45,
|
||||
pointsFor4th: 45,
|
||||
pointsFor5th: 20,
|
||||
pointsFor6th: 20,
|
||||
pointsFor7th: 20,
|
||||
pointsFor8th: 20,
|
||||
};
|
||||
|
||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||
return [{ title: `Golf Skills — ${data?.sportsSeason?.name ?? 'Sports Season'} - Brackt Admin` }];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
/**
|
||||
* Admin: Simulate action endpoint (stub)
|
||||
*
|
||||
* Simulation is now handled by intent="simulate" on the season admin page.
|
||||
* This stub redirects any direct GET navigation to that page.
|
||||
* Simulation is now handled by the "Run Simulation" button on the Simulator
|
||||
* setup page (/admin/sports-seasons/:id/simulator). This stub redirects any
|
||||
* direct GET navigation to the season admin page.
|
||||
*/
|
||||
|
||||
import { redirect } from "react-router";
|
||||
|
|
|
|||
|
|
@ -1,86 +0,0 @@
|
|||
/**
|
||||
* Pure helpers for the Simulator Setup page, split out so they can be unit tested
|
||||
* without pulling the route's server-only imports into the test.
|
||||
*/
|
||||
|
||||
import type {
|
||||
BaseEloKey,
|
||||
ResolvedRating,
|
||||
ResolvedSourceElo,
|
||||
} from "~/services/simulations/input-policy";
|
||||
|
||||
/**
|
||||
* Short badge text for how a participant's Elo or rating was produced, or null for a
|
||||
* directly entered one — the unremarkable case, which needs no badge.
|
||||
*
|
||||
* The preview table needs this because a generated value is deliberately hidden from
|
||||
* `getParticipantSimulatorInputs`, so without the resolved value plus this label the
|
||||
* row reads as "nothing saved" and a projection losing to a raw Elo is invisible.
|
||||
*
|
||||
* Every remaining method is a missing-input fallback (`fallbackElo`,
|
||||
* `fallbackRating`, `averageKnown`, `worstKnownMinus`, `block`), which all read the
|
||||
* same way to an admin: this participant had nothing usable of its own.
|
||||
*/
|
||||
export function resolvedInputMethodLabel(
|
||||
method: ResolvedSourceElo["method"] | ResolvedRating["method"]
|
||||
): string | null {
|
||||
switch (method) {
|
||||
case "direct":
|
||||
return null;
|
||||
case "projectedWins":
|
||||
case "projectedTablePoints":
|
||||
return "from projections";
|
||||
case "sourceOdds":
|
||||
return "from futures";
|
||||
case "blend":
|
||||
return "blended";
|
||||
default:
|
||||
return "fallback";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Method flag for a bulk-input row that carries a projection instead of an Elo, or
|
||||
* undefined when the row says nothing about how its Elo was produced.
|
||||
*
|
||||
* A row supplying a projection but no explicit Elo means "derive the Elo from this
|
||||
* projection". Stamping the flag marks whatever Elo is already stored as generated,
|
||||
* so `getParticipantSimulatorInputs` hides it and `resolveSourceElos` re-derives
|
||||
* from the projection — without it, the non-destructive upsert leaves a stale
|
||||
* hand-entered Elo in place, and that Elo wins the `baseEloPriority` race so the
|
||||
* projection is written to the database and then ignored on every run.
|
||||
*
|
||||
* Returning undefined (rather than an empty object) matters: the upsert only
|
||||
* preserves existing metadata, and clears a stale flag for a fresh direct Elo, when
|
||||
* the incoming metadata is null.
|
||||
*/
|
||||
export function projectionMethodMetadata(
|
||||
sourceElo: number | undefined,
|
||||
projectedWins: number | undefined,
|
||||
projectedTablePoints: number | undefined
|
||||
): Record<string, unknown> | undefined {
|
||||
if (sourceElo !== undefined) return undefined;
|
||||
if (projectedWins !== undefined) return { sourceEloMethod: "projectedWins" };
|
||||
if (projectedTablePoints !== undefined) return { sourceEloMethod: "projectedTablePoints" };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate the Base Elo Source select into a full `baseEloPriority` list. Only the
|
||||
* head of the list is user-facing (raw Elo vs. projections); the remaining keys keep
|
||||
* their existing relative order so a season that already has a custom ordering is
|
||||
* not silently flattened.
|
||||
*/
|
||||
export function parseBaseEloPriorityChoice(
|
||||
value: FormDataEntryValue | null,
|
||||
current: BaseEloKey[]
|
||||
): BaseEloKey[] {
|
||||
// The select only renders for simulators that can derive Elo from a projection.
|
||||
// When it was not on the form there is no choice to apply, so keep what is stored
|
||||
// rather than silently rewriting the season's ordering.
|
||||
if (value === null) return current;
|
||||
const projections = current.filter((key) => key !== "sourceElo");
|
||||
return value === "projectionsFirst"
|
||||
? [...projections, "sourceElo"]
|
||||
: ["sourceElo", ...projections];
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { Form, Link, redirect, useActionData, useNavigation } from "react-router";
|
||||
import type { Route } from "./+types/admin.sports-seasons.$id.simulator";
|
||||
|
||||
|
|
@ -26,23 +26,12 @@ import {
|
|||
type UpsertParticipantSimulatorInput,
|
||||
} from "~/models/simulator";
|
||||
import { normalizeName } from "~/lib/fuzzy-match";
|
||||
import {
|
||||
simulatorInputLabel,
|
||||
type SimulatorInputKey,
|
||||
} from "~/services/simulations/manifest";
|
||||
import {
|
||||
getSimulatorInputPolicy,
|
||||
resolveRatings,
|
||||
resolveSourceElos,
|
||||
type MissingEloStrategy,
|
||||
type MissingRatingStrategy,
|
||||
} from "~/services/simulations/input-policy";
|
||||
import { runSportsSeasonSimulation } from "~/services/simulations/runner";
|
||||
import {
|
||||
parseBaseEloPriorityChoice,
|
||||
projectionMethodMetadata,
|
||||
resolvedInputMethodLabel,
|
||||
} from "./admin.sports-seasons.$id.simulator.helpers";
|
||||
|
||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||
return [{ title: `Simulator Setup - ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
||||
|
|
@ -73,73 +62,7 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
|
||||
const inputPolicy = getSimulatorInputPolicy(config.config);
|
||||
|
||||
// Sport-aware preview columns: the intersection of the displayable numeric keys
|
||||
// with this simulator's required + optional inputs, so each season shows exactly
|
||||
// the inputs its simulator consumes (F1 = odds, NBA = Elo, NCAA = rating, ...).
|
||||
// Resolved here (server-only) so the client bundle never imports the simulator
|
||||
// manifest/registry, which transitively pulls in `.server` modules.
|
||||
const relevantInputs = new Set<SimulatorInputKey>([
|
||||
...config.profile.requiredInputs,
|
||||
...config.profile.optionalInputs,
|
||||
]);
|
||||
|
||||
// The Elo each participant will actually run with, and which source produced it.
|
||||
// Without this the preview is misleading: getParticipantSimulatorInputs blanks a
|
||||
// generated Elo (so it is re-derived rather than frozen), which reads as "nothing
|
||||
// saved" — and a raw Elo silently beating a projection is invisible.
|
||||
//
|
||||
// Keyed off `relevantInputs`, not requiredInputs: the preview renders these
|
||||
// columns solely from these maps, so gating on "required" would blank a stored
|
||||
// value for every simulator that treats the input as optional (playoff_bracket
|
||||
// and ncaam_bracket for Elo, golf_qualifying_points for rating).
|
||||
const resolvedEloRows = Object.fromEntries(
|
||||
relevantInputs.has("sourceElo")
|
||||
? [...resolveSourceElos(inputs, config.profile, config.config).values()].map((resolved) => [
|
||||
resolved.participantId,
|
||||
{ sourceElo: resolved.sourceElo, method: resolved.method },
|
||||
])
|
||||
: []
|
||||
);
|
||||
// Same for ratings, which are blanked by the same rule when generated. The
|
||||
// preview's "missing a required input" marker reads both, so it agrees with
|
||||
// readiness instead of flagging every participant a projection resolved.
|
||||
const resolvedRatingRows = Object.fromEntries(
|
||||
relevantInputs.has("rating")
|
||||
? [...resolveRatings(inputs, config.profile, config.config).values()].map((resolved) => [
|
||||
resolved.participantId,
|
||||
{ rating: resolved.rating, method: resolved.method },
|
||||
])
|
||||
: []
|
||||
);
|
||||
const inputColumns = DISPLAY_INPUT_ORDER.filter((key) => relevantInputs.has(key)).map((key) => ({
|
||||
key,
|
||||
label: simulatorInputLabel(key),
|
||||
required: config.profile.requiredInputs.includes(key),
|
||||
}));
|
||||
|
||||
// The projection this simulator can derive Elo from, labelled here for the same
|
||||
// reason as inputColumns: calling simulatorInputLabel from the rendered component
|
||||
// would pull the manifest (and through it the registry and every simulator) into
|
||||
// the client bundle.
|
||||
const projectionEloKey = (config.profile.derivableInputs?.sourceElo ?? []).find(
|
||||
(key) => key === "projectedWins" || key === "projectedTablePoints"
|
||||
);
|
||||
const projectionEloOption = projectionEloKey
|
||||
? { key: projectionEloKey, label: simulatorInputLabel(projectionEloKey) }
|
||||
: null;
|
||||
|
||||
return {
|
||||
sportsSeason,
|
||||
participants,
|
||||
config,
|
||||
inputRows,
|
||||
readiness,
|
||||
inputPolicy,
|
||||
inputColumns,
|
||||
resolvedEloRows,
|
||||
resolvedRatingRows,
|
||||
projectionEloOption,
|
||||
};
|
||||
return { sportsSeason, participants, config, inputRows, readiness, inputPolicy };
|
||||
}
|
||||
|
||||
interface ActionData {
|
||||
|
|
@ -147,44 +70,6 @@ interface ActionData {
|
|||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input keys the participant preview can render as a numeric column, in the order
|
||||
* they appear. Keys not listed (e.g. `region`, `metadata`) are not shown as
|
||||
* columns; the visible columns for a season are the intersection of this order
|
||||
* with the simulator's required + optional inputs.
|
||||
*/
|
||||
const DISPLAY_INPUT_ORDER: SimulatorInputKey[] = [
|
||||
"sourceElo",
|
||||
"sourceOdds",
|
||||
"worldRanking",
|
||||
"rating",
|
||||
"projectedWins",
|
||||
"projectedTablePoints",
|
||||
"seed",
|
||||
];
|
||||
|
||||
const PARTICIPANT_PAGE_SIZE = 50;
|
||||
|
||||
/**
|
||||
* Engine knobs that a simulator (or the shared input-policy resolver) actually
|
||||
* reads from config. The structured Engine fields are limited to these so the UI
|
||||
* never shows a control that silently does nothing — bespoke per-sim constants
|
||||
* (e.g. homeFieldElo, eloDivisor, srsEloScale, raceNoise) that live in a profile
|
||||
* but are not read from config stay editable only via the raw-JSON escape hatch.
|
||||
*/
|
||||
const HONORED_ENGINE_KNOBS = new Set([
|
||||
"iterations",
|
||||
"parityFactor",
|
||||
"seasonGames",
|
||||
"overtimeRate",
|
||||
"matchParityFactor",
|
||||
"averageOpponentElo",
|
||||
"baseDrawRate",
|
||||
"drawDecay",
|
||||
"ratingScaleFactor",
|
||||
"projectedWinsWeight",
|
||||
]);
|
||||
|
||||
function parseOptionalNumber(value: string | undefined): number | null {
|
||||
if (value === undefined || value.trim() === "") return null;
|
||||
const parsed = Number(value);
|
||||
|
|
@ -226,39 +111,6 @@ function findParticipantId(name: string, participants: Array<{ id: string; name:
|
|||
);
|
||||
}
|
||||
|
||||
const ODDS_LINE_PATTERN = /^(.+?)\s+([+-]\d{2,6})\s*$/;
|
||||
|
||||
function parseOddsLines(
|
||||
lines: string[],
|
||||
sportsSeasonId: string,
|
||||
participants: Array<{ id: string; name: string }>
|
||||
): { inputs: UpsertParticipantSimulatorInput[]; unmatched: string[] } {
|
||||
const inputs: UpsertParticipantSimulatorInput[] = [];
|
||||
const unmatched: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const line of lines) {
|
||||
const match = ODDS_LINE_PATTERN.exec(line);
|
||||
if (!match) continue;
|
||||
|
||||
const name = match[1].trim();
|
||||
const sourceOdds = Number(match[2]);
|
||||
if (!Number.isFinite(sourceOdds)) continue;
|
||||
|
||||
const participantId = findParticipantId(name, participants);
|
||||
if (!participantId) {
|
||||
unmatched.push(name);
|
||||
continue;
|
||||
}
|
||||
if (seen.has(participantId)) continue;
|
||||
seen.add(participantId);
|
||||
|
||||
inputs.push({ participantId, sportsSeasonId, sourceOdds });
|
||||
}
|
||||
|
||||
return { inputs, unmatched };
|
||||
}
|
||||
|
||||
function parseInputCsv(
|
||||
text: string,
|
||||
sportsSeasonId: string,
|
||||
|
|
@ -271,10 +123,7 @@ function parseInputCsv(
|
|||
const indexes = new Map(header.map((value, index) => [value, index]));
|
||||
const nameIndex = indexes.get("name");
|
||||
if (nameIndex === undefined) {
|
||||
// No CSV header — treat the paste as sportsbook futures odds, one team per
|
||||
// line ending in American odds (e.g. `Kansas City Chiefs +450`). This is the
|
||||
// friendly bulk-futures path; team names are fuzzy-matched to participants.
|
||||
return parseOddsLines(lines, sportsSeasonId, participants);
|
||||
throw new Error("Bulk input CSV must include a `name` column.");
|
||||
}
|
||||
|
||||
const inputs: UpsertParticipantSimulatorInput[] = [];
|
||||
|
|
@ -291,28 +140,17 @@ function parseInputCsv(
|
|||
continue;
|
||||
}
|
||||
|
||||
const sourceElo = parseOptionalNumber(cols[indexes.get("sourceElo") ?? -1]) ?? undefined;
|
||||
const projectedWins = parseOptionalNumber(cols[indexes.get("projectedWins") ?? -1]) ?? undefined;
|
||||
const projectedTablePoints = parseOptionalNumber(cols[indexes.get("projectedTablePoints") ?? -1]) ?? undefined;
|
||||
|
||||
inputs.push({
|
||||
participantId,
|
||||
sportsSeasonId,
|
||||
sourceElo,
|
||||
sourceElo: parseOptionalNumber(cols[indexes.get("sourceElo") ?? -1]) ?? undefined,
|
||||
sourceOdds: parseOptionalNumber(cols[indexes.get("sourceOdds") ?? -1]) ?? undefined,
|
||||
worldRanking: parseOptionalNumber(cols[indexes.get("worldRanking") ?? -1]) ?? undefined,
|
||||
rating: parseOptionalNumber(cols[indexes.get("rating") ?? -1]) ?? undefined,
|
||||
projectedWins,
|
||||
projectedTablePoints,
|
||||
projectedWins: parseOptionalNumber(cols[indexes.get("projectedWins") ?? -1]) ?? undefined,
|
||||
projectedTablePoints: parseOptionalNumber(cols[indexes.get("projectedTablePoints") ?? -1]) ?? undefined,
|
||||
seed: parseOptionalNumber(cols[indexes.get("seed") ?? -1]) ?? undefined,
|
||||
region: cols[indexes.get("region") ?? -1] || undefined,
|
||||
// A row that supplies a projection but no explicit Elo means "derive the Elo
|
||||
// from this projection". Stamping the method flag marks whatever Elo is
|
||||
// already stored as generated, so getParticipantSimulatorInputs hides it and
|
||||
// resolveSourceElos re-derives from the projection instead of letting a stale
|
||||
// Elo win the baseEloPriority race. Mirrors the Elo Ratings page's
|
||||
// projections mode.
|
||||
metadata: projectionMethodMetadata(sourceElo, projectedWins, projectedTablePoints),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -359,49 +197,33 @@ export async function action({ request, params }: Route.ActionArgs): Promise<Act
|
|||
}
|
||||
}
|
||||
|
||||
if (intent === "save-configuration") {
|
||||
if (intent === "save-input-policy") {
|
||||
const currentConfig = await getSportsSeasonSimulatorConfig(sportsSeasonId);
|
||||
if (!currentConfig) return { success: false, message: "Simulator config not found." };
|
||||
|
||||
// Start from the current merged config so keys not exposed as structured
|
||||
// fields (e.g. string knobs) are preserved untouched.
|
||||
const next: Record<string, unknown> = { ...currentConfig.config };
|
||||
|
||||
// Engine knobs: every numeric field rendered as `engine.<key>`.
|
||||
for (const [field, value] of formData.entries()) {
|
||||
if (typeof value !== "string" || !field.startsWith("engine.")) continue;
|
||||
const key = field.slice("engine.".length);
|
||||
const parsed = Number(value);
|
||||
if (value.trim() !== "" && Number.isFinite(parsed)) next[key] = parsed;
|
||||
}
|
||||
|
||||
// Input-derivation policy (only when the simulator consumes Elo/ratings).
|
||||
if (formData.get("hasInputPolicy") === "1") {
|
||||
const currentPolicy = getSimulatorInputPolicy(currentConfig.config);
|
||||
next.inputPolicy = {
|
||||
...currentPolicy,
|
||||
missingEloStrategy: parseMissingEloStrategy(formData.get("missingEloStrategy")),
|
||||
missingRatingStrategy: parseMissingRatingStrategy(formData.get("missingRatingStrategy")),
|
||||
baseEloPriority: parseBaseEloPriorityChoice(formData.get("baseEloPriority"), currentPolicy.baseEloPriority),
|
||||
// Stored as-is; getSimulatorInputPolicy clamps to [0,1] on read.
|
||||
oddsWeight: parsePolicyNumber(formData, "oddsWeight", currentPolicy.oddsWeight),
|
||||
fallbackElo: parsePolicyNumber(formData, "fallbackElo", currentPolicy.fallbackElo),
|
||||
fallbackEloDelta: parsePolicyNumber(formData, "fallbackEloDelta", currentPolicy.fallbackEloDelta),
|
||||
eloMin: parsePolicyNumber(formData, "eloMin", currentPolicy.eloMin),
|
||||
eloMax: parsePolicyNumber(formData, "eloMax", currentPolicy.eloMax),
|
||||
fallbackRating: parsePolicyNumber(formData, "fallbackRating", currentPolicy.fallbackRating),
|
||||
fallbackRatingDelta: parsePolicyNumber(formData, "fallbackRatingDelta", currentPolicy.fallbackRatingDelta),
|
||||
ratingMin: parsePolicyNumber(formData, "ratingMin", currentPolicy.ratingMin),
|
||||
ratingMax: parsePolicyNumber(formData, "ratingMax", currentPolicy.ratingMax),
|
||||
};
|
||||
}
|
||||
|
||||
const currentPolicy = getSimulatorInputPolicy(currentConfig.config);
|
||||
await upsertSportsSeasonSimulatorConfig({
|
||||
sportsSeasonId,
|
||||
simulatorType: currentConfig.simulatorType,
|
||||
config: next,
|
||||
config: {
|
||||
...currentConfig.config,
|
||||
inputPolicy: {
|
||||
missingEloStrategy: parseMissingEloStrategy(formData.get("missingEloStrategy")),
|
||||
missingRatingStrategy: parseMissingRatingStrategy(formData.get("missingRatingStrategy")),
|
||||
// Stored as-is; getSimulatorInputPolicy clamps to [0,1] on read.
|
||||
oddsWeight: parsePolicyNumber(formData, "oddsWeight", currentPolicy.oddsWeight),
|
||||
fallbackElo: parsePolicyNumber(formData, "fallbackElo", currentPolicy.fallbackElo),
|
||||
fallbackEloDelta: parsePolicyNumber(formData, "fallbackEloDelta", currentPolicy.fallbackEloDelta),
|
||||
eloMin: parsePolicyNumber(formData, "eloMin", currentPolicy.eloMin),
|
||||
eloMax: parsePolicyNumber(formData, "eloMax", currentPolicy.eloMax),
|
||||
fallbackRating: parsePolicyNumber(formData, "fallbackRating", currentPolicy.fallbackRating),
|
||||
fallbackRatingDelta: parsePolicyNumber(formData, "fallbackRatingDelta", currentPolicy.fallbackRatingDelta),
|
||||
ratingMin: parsePolicyNumber(formData, "ratingMin", currentPolicy.ratingMin),
|
||||
ratingMax: parsePolicyNumber(formData, "ratingMax", currentPolicy.ratingMax),
|
||||
},
|
||||
},
|
||||
});
|
||||
return { success: true, message: "Simulator configuration saved." };
|
||||
return { success: true, message: "Simulator input policy saved." };
|
||||
}
|
||||
|
||||
if (intent === "save-inputs") {
|
||||
|
|
@ -420,26 +242,7 @@ export async function action({ request, params }: Route.ActionArgs): Promise<Act
|
|||
const suffix = parsed.unmatched.length > 0
|
||||
? ` ${parsed.unmatched.length} row(s) were unmatched: ${parsed.unmatched.join(", ")}.`
|
||||
: "";
|
||||
const saved = `Saved ${parsed.inputs.length} simulator input row(s).${suffix}`;
|
||||
|
||||
// Auto-run the simulation so saved inputs immediately drive the standings.
|
||||
// The save itself already succeeded, so a run that never started (e.g.
|
||||
// participants missing required inputs) is reported as success with the
|
||||
// readiness gap — never as a failed save.
|
||||
try {
|
||||
await runSportsSeasonSimulation(sportsSeasonId);
|
||||
return redirect(`/admin/sports-seasons/${sportsSeasonId}/expected-values`);
|
||||
} catch (runError) {
|
||||
const reason = runError instanceof Error ? runError.message : "could not run.";
|
||||
// Distinguish "saved but never ran" (readiness/already-running) from
|
||||
// "started running and failed mid-run": the runner only flips the season
|
||||
// to status 'failed' once the simulation itself throws.
|
||||
const season = await findSportsSeasonById(sportsSeasonId);
|
||||
if (season?.simulationStatus === "failed") {
|
||||
return { success: false, message: `${saved} The simulation failed: ${reason}` };
|
||||
}
|
||||
return { success: true, message: `${saved} Simulation not run yet: ${reason}` };
|
||||
}
|
||||
return { success: true, message: `Saved ${parsed.inputs.length} simulator input row(s).${suffix}` };
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to save simulator inputs." };
|
||||
}
|
||||
|
|
@ -455,62 +258,32 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
|||
const isSubmitting = navigation.state === "submitting";
|
||||
const setupSections = config.profile.setupSections;
|
||||
const sourceEloAlternatives = config.profile.derivableInputs?.sourceElo ?? [];
|
||||
const projectionsOutrankElo = inputPolicy.baseEloPriority[0] !== "sourceElo";
|
||||
const ratingAlternatives = config.profile.derivableInputs?.rating ?? [];
|
||||
const showsInputPolicy =
|
||||
config.profile.requiredInputs.includes("sourceElo") || config.profile.requiredInputs.includes("rating");
|
||||
|
||||
// Structured engine knobs: every top-level numeric config key (inputPolicy is a
|
||||
// nested object edited in its own section). Driving the fields from the merged
|
||||
// config means each simulator shows exactly the knobs it actually reads.
|
||||
const engineEntries = Object.entries(config.config)
|
||||
.filter(([key, value]) => HONORED_ENGINE_KNOBS.has(key) && typeof value === "number")
|
||||
.map(([key, value]) => [key, value as unknown as number] as [string, number]);
|
||||
// Two synced blend weights that always total 100%. `oddsWeight` (0–1) stays
|
||||
// the single stored source of truth; we present it (and its complement) as
|
||||
// percentages here. `futuresPct` is the committed value (drives the hidden
|
||||
// field and the complementary input); `draft` holds the raw text of whichever
|
||||
// field is being typed in, so it can be cleared/edited freely and only
|
||||
// re-syncs on a parseable number — then normalizes away on blur.
|
||||
function clampPct(value: number): number {
|
||||
return Math.min(100, Math.max(0, Math.round(value)));
|
||||
}
|
||||
const [futuresPct, setFuturesPct] = useState(() =>
|
||||
clampPct(Math.min(1, Math.max(0, inputPolicy.oddsWeight)) * 100)
|
||||
);
|
||||
const [draft, setDraft] = useState<{ field: "base" | "futures"; text: string } | null>(null);
|
||||
const basePct = 100 - futuresPct;
|
||||
|
||||
// Preview columns are resolved server-side in the loader (see note there) and
|
||||
// arrive as plain data, so this client component never imports the manifest.
|
||||
const { inputColumns, resolvedEloRows, resolvedRatingRows, projectionEloOption } = loaderData;
|
||||
const requiredInputs = config.profile.requiredInputs;
|
||||
const gridTemplate = `2fr repeat(${Math.max(inputColumns.length, 1)}, 1fr)`;
|
||||
// For this sport the inputs live on a dedicated page, not the shared bulk paste.
|
||||
const externalInputsSection = requiredInputs.length === 0
|
||||
? (setupSections.includes("surfaceElo")
|
||||
? { label: "Surface Elo", to: `/admin/sports-seasons/${sportsSeason.id}/surface-elo` }
|
||||
: setupSections.includes("golfSkills")
|
||||
? { label: "Golf Skills", to: `/admin/sports-seasons/${sportsSeason.id}/golf-skills` }
|
||||
: null)
|
||||
: null;
|
||||
|
||||
// A required Elo/rating counts as present when the input policy resolves one,
|
||||
// not only when it is stored directly: getParticipantSimulatorInputs deliberately
|
||||
// blanks a generated value so it is re-derived each run, so reading the raw input
|
||||
// alone would mark every projection-configured participant as missing.
|
||||
const isRowIncomplete = (participantId: string, input: (typeof inputRows)[number]["input"]) =>
|
||||
requiredInputs.some((key) => {
|
||||
if (input?.[key] !== null && input?.[key] !== undefined) return false;
|
||||
if (key === "sourceElo") return resolvedEloRows[participantId] === undefined;
|
||||
if (key === "rating") return resolvedRatingRows[participantId] === undefined;
|
||||
return true;
|
||||
});
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [onlyMissing, setOnlyMissing] = useState(false);
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
const normalizedSearch = normalizeName(search);
|
||||
return inputRows.filter(({ participant, input }) => {
|
||||
if (normalizedSearch && !normalizeName(participant.name).includes(normalizedSearch)) return false;
|
||||
if (onlyMissing && !isRowIncomplete(participant.id, input)) return false;
|
||||
return true;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [inputRows, search, onlyMissing, requiredInputs, resolvedEloRows, resolvedRatingRows]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredRows.length / PARTICIPANT_PAGE_SIZE));
|
||||
const safePage = Math.min(page, totalPages - 1);
|
||||
const pageStart = safePage * PARTICIPANT_PAGE_SIZE;
|
||||
const pageRows = filteredRows.slice(pageStart, pageStart + PARTICIPANT_PAGE_SIZE);
|
||||
function handlePctChange(field: "base" | "futures", text: string) {
|
||||
setDraft({ field, text });
|
||||
const parsed = Number(text);
|
||||
if (text.trim() !== "" && Number.isFinite(parsed)) {
|
||||
setFuturesPct(field === "futures" ? clampPct(parsed) : 100 - clampPct(parsed));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
|
|
@ -585,7 +358,8 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
|||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{setupSections.includes("eloRatings") &&<Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/elo-ratings`}>Elo Ratings</Link></Button>}
|
||||
{setupSections.includes("futuresOdds") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/futures-odds`}>Futures Odds</Link></Button>}
|
||||
{setupSections.includes("eloRatings") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/elo-ratings`}>Elo Ratings</Link></Button>}
|
||||
{setupSections.includes("surfaceElo") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/surface-elo`}>Surface Elo</Link></Button>}
|
||||
{setupSections.includes("golfSkills") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/golf-skills`}>Golf Skills</Link></Button>}
|
||||
{setupSections.includes("regularStandings") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/regular-standings`}>Regular Standings</Link></Button>}
|
||||
|
|
@ -603,184 +377,160 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Simulator Configuration</CardTitle>
|
||||
<CardDescription>
|
||||
One place for this season's settings. <strong>Engine</strong> controls how the Monte Carlo
|
||||
runs; <strong>Input derivation</strong> controls how raw inputs become the single Elo/rating
|
||||
the engine consumes. Defaults come from the simulator profile; values set here override them
|
||||
for this season only. Both sections write the same stored config.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<Form method="post" className="space-y-6">
|
||||
<input type="hidden" name="intent" value="save-configuration" />
|
||||
{showsInputPolicy && <input type="hidden" name="hasInputPolicy" value="1" />}
|
||||
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Engine</h3>
|
||||
{showsInputPolicy && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Input Policy</CardTitle>
|
||||
<CardDescription>
|
||||
Direct inputs win. This simulator can derive Elo from{" "}
|
||||
{sourceEloAlternatives.length > 0 ? sourceEloAlternatives.join(", ") : "no alternate Elo inputs"}
|
||||
{ratingAlternatives.length > 0 ? ` and ratings from ${ratingAlternatives.join(", ")}` : ""}.
|
||||
Tail fallbacks are explicit so low-impact missing participants do not silently get invented ratings.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form method="post" className="grid gap-4 md:grid-cols-5">
|
||||
<input type="hidden" name="intent" value="save-input-policy" />
|
||||
{/* oddsWeight (0–1) is the stored value; the two percentage fields below drive it. */}
|
||||
<input type="hidden" name="oddsWeight" value={futuresPct / 100} />
|
||||
<div className="space-y-2 md:col-span-5">
|
||||
<Label>Blend Weights — Base Elo vs. Futures (must total 100%)</Label>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="baseEloPct" className="text-xs font-normal text-muted-foreground">Base Elo / Projections weight (%)</Label>
|
||||
<Input
|
||||
id="baseEloPct"
|
||||
type="number"
|
||||
step={5}
|
||||
min={0}
|
||||
max={100}
|
||||
value={draft?.field === "base" ? draft.text : String(basePct)}
|
||||
onChange={(e) => handlePctChange("base", e.target.value)}
|
||||
onBlur={() => setDraft(null)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="futuresPct" className="text-xs font-normal text-muted-foreground">Futures Odds weight (%)</Label>
|
||||
<Input
|
||||
id="futuresPct"
|
||||
type="number"
|
||||
step={5}
|
||||
min={0}
|
||||
max={100}
|
||||
value={draft?.field === "futures" ? draft.text : String(futuresPct)}
|
||||
onChange={(e) => handlePctChange("futures", e.target.value)}
|
||||
onBlur={() => setDraft(null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
How the simulation runs. A higher <code>parityFactor</code> flattens the finish-position
|
||||
distribution (favorites win less often); <code>iterations</code> trades speed for precision.
|
||||
Every source (raw Elo, projections, futures odds) becomes an Elo, then they blend into
|
||||
the single Elo that feeds the simulator. The two weights always total 100% (editing one
|
||||
updates the other): <strong>0% futures</strong> = Elo / projections only,
|
||||
<strong> 100% futures</strong> = futures fully override, in between = blend
|
||||
(e.g. 70% Elo / 30% futures).
|
||||
</p>
|
||||
</div>
|
||||
{engineEntries.length > 0 ? (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{engineEntries.map(([key, value]) => (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label htmlFor={`engine.${key}`} className="font-mono text-xs">{key}</Label>
|
||||
<Input id={`engine.${key}`} name={`engine.${key}`} type="number" step="any" defaultValue={value} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">This simulator exposes no numeric engine knobs.</p>
|
||||
{config.profile.requiredInputs.includes("sourceElo") && (
|
||||
<>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor="missingEloStrategy">Missing Elo Strategy</Label>
|
||||
<select
|
||||
id="missingEloStrategy"
|
||||
name="missingEloStrategy"
|
||||
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
|
||||
defaultValue={inputPolicy.missingEloStrategy}
|
||||
>
|
||||
<option value="block">Block until complete</option>
|
||||
<option value="fallbackElo">Use fixed fallback Elo</option>
|
||||
<option value="averageKnown">Use average known Elo</option>
|
||||
<option value="worstKnownMinus">Use worst known Elo minus delta</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fallbackElo">Fallback Elo</Label>
|
||||
<Input id="fallbackElo" name="fallbackElo" type="number" defaultValue={inputPolicy.fallbackElo} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fallbackEloDelta">Worst Elo Delta</Label>
|
||||
<Input id="fallbackEloDelta" name="fallbackEloDelta" type="number" defaultValue={inputPolicy.fallbackEloDelta} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="eloMin">Elo Floor</Label>
|
||||
<Input id="eloMin" name="eloMin" type="number" defaultValue={inputPolicy.eloMin} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="eloMax">Elo Ceiling</Label>
|
||||
<Input id="eloMax" name="eloMax" type="number" defaultValue={inputPolicy.eloMax} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{showsInputPolicy && (
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Input derivation</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Direct inputs win. This simulator can derive Elo from{" "}
|
||||
{sourceEloAlternatives.length > 0 ? sourceEloAlternatives.join(", ") : "no alternate Elo inputs"}
|
||||
{ratingAlternatives.length > 0 ? ` and ratings from ${ratingAlternatives.join(", ")}` : ""}.
|
||||
Tail fallbacks are explicit so low-impact missing participants do not silently get invented ratings.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-5">
|
||||
<div className="space-y-2 md:col-span-5">
|
||||
<Label htmlFor="oddsWeight">Futures vs. Elo — Odds Blend Weight (0–1)</Label>
|
||||
<Input id="oddsWeight" name="oddsWeight" type="number" step="0.05" min="0" max="1" defaultValue={inputPolicy.oddsWeight} />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Every source (raw Elo, projections, futures odds) becomes an Elo, then they blend into
|
||||
the single Elo that feeds the simulator. This is the weight given to futures odds:
|
||||
<strong> 0</strong> = Elo / projections only, <strong>1</strong> = futures fully override,
|
||||
in between = blend (e.g. 0.3 = 70% Elo / 30% futures). Odds enter the engine only through
|
||||
this Elo — they are not blended again per game.
|
||||
</p>
|
||||
</div>
|
||||
{projectionEloOption && (
|
||||
<div className="space-y-2 md:col-span-5">
|
||||
<Label htmlFor="baseEloPriority">Base Elo Source</Label>
|
||||
<select
|
||||
id="baseEloPriority"
|
||||
name="baseEloPriority"
|
||||
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
|
||||
defaultValue={projectionsOutrankElo ? "projectionsFirst" : "eloFirst"}
|
||||
>
|
||||
<option value="eloFirst">Entered Elo first, then {projectionEloOption.label}</option>
|
||||
<option value="projectionsFirst">{projectionEloOption.label} first, then entered Elo</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Raw Elo and projections are substitutes — the first one a participant has wins, and
|
||||
the other is ignored (futures odds are separate and blend on top via the weight above).
|
||||
Pick <strong>{projectionEloOption.label} first</strong> when projections are
|
||||
the source of truth for this season and a previously entered Elo should not override them.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{config.profile.requiredInputs.includes("sourceElo") && (
|
||||
<>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor="missingEloStrategy">Missing Elo Strategy</Label>
|
||||
<select
|
||||
id="missingEloStrategy"
|
||||
name="missingEloStrategy"
|
||||
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
|
||||
defaultValue={inputPolicy.missingEloStrategy}
|
||||
>
|
||||
<option value="block">Block until complete</option>
|
||||
<option value="fallbackElo">Use fixed fallback Elo</option>
|
||||
<option value="averageKnown">Use average known Elo</option>
|
||||
<option value="worstKnownMinus">Use worst known Elo minus delta</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fallbackElo">Fallback Elo</Label>
|
||||
<Input id="fallbackElo" name="fallbackElo" type="number" defaultValue={inputPolicy.fallbackElo} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fallbackEloDelta">Worst Elo Delta</Label>
|
||||
<Input id="fallbackEloDelta" name="fallbackEloDelta" type="number" defaultValue={inputPolicy.fallbackEloDelta} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="eloMin">Elo Floor</Label>
|
||||
<Input id="eloMin" name="eloMin" type="number" defaultValue={inputPolicy.eloMin} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="eloMax">Elo Ceiling</Label>
|
||||
<Input id="eloMax" name="eloMax" type="number" defaultValue={inputPolicy.eloMax} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{config.profile.requiredInputs.includes("rating") && (
|
||||
<>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor="missingRatingStrategy">Missing Rating Strategy</Label>
|
||||
<select
|
||||
id="missingRatingStrategy"
|
||||
name="missingRatingStrategy"
|
||||
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
|
||||
defaultValue={inputPolicy.missingRatingStrategy}
|
||||
>
|
||||
<option value="block">Block until complete</option>
|
||||
<option value="fallbackRating">Use fixed fallback rating</option>
|
||||
<option value="averageKnown">Use average known rating</option>
|
||||
<option value="worstKnownMinus">Use worst known rating minus delta</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fallbackRating">Fallback Rating</Label>
|
||||
<Input id="fallbackRating" name="fallbackRating" type="number" step="0.01" defaultValue={inputPolicy.fallbackRating} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fallbackRatingDelta">Worst Rating Delta</Label>
|
||||
<Input id="fallbackRatingDelta" name="fallbackRatingDelta" type="number" step="0.01" defaultValue={inputPolicy.fallbackRatingDelta} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ratingMin">Rating Floor</Label>
|
||||
<Input id="ratingMin" name="ratingMin" type="number" step="0.01" defaultValue={inputPolicy.ratingMin} />
|
||||
{config.profile.requiredInputs.includes("rating") && (
|
||||
<>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor="missingRatingStrategy">Missing Rating Strategy</Label>
|
||||
<select
|
||||
id="missingRatingStrategy"
|
||||
name="missingRatingStrategy"
|
||||
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
|
||||
defaultValue={inputPolicy.missingRatingStrategy}
|
||||
>
|
||||
<option value="block">Block until complete</option>
|
||||
<option value="fallbackRating">Use fixed fallback rating</option>
|
||||
<option value="averageKnown">Use average known rating</option>
|
||||
<option value="worstKnownMinus">Use worst known rating minus delta</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ratingMax">Rating Ceiling</Label>
|
||||
<Input id="ratingMax" name="ratingMax" type="number" step="0.01" defaultValue={inputPolicy.ratingMax} />
|
||||
<Label htmlFor="fallbackRating">Fallback Rating</Label>
|
||||
<Input id="fallbackRating" name="fallbackRating" type="number" step="0.01" defaultValue={inputPolicy.fallbackRating} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fallbackRatingDelta">Worst Rating Delta</Label>
|
||||
<Input id="fallbackRatingDelta" name="fallbackRatingDelta" type="number" step="0.01" defaultValue={inputPolicy.fallbackRatingDelta} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ratingMin">Rating Floor</Label>
|
||||
<Input id="ratingMin" name="ratingMin" type="number" step="0.01" defaultValue={inputPolicy.ratingMin} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ratingMax">Rating Ceiling</Label>
|
||||
<Input id="ratingMax" name="ratingMax" type="number" step="0.01" defaultValue={inputPolicy.ratingMax} />
|
||||
</div>
|
||||
<div className="md:col-span-5">
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Save Input Policy
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Season Config</CardTitle>
|
||||
<CardDescription>
|
||||
JSON overrides for this specific sports season. Defaults come from the simulator profile.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form method="post" className="space-y-4">
|
||||
<input type="hidden" name="intent" value="save-config" />
|
||||
<Textarea
|
||||
name="config"
|
||||
rows={10}
|
||||
className="font-mono text-sm"
|
||||
defaultValue={JSON.stringify(config.config, null, 2)}
|
||||
/>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Save Configuration
|
||||
Save Config
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm font-medium">Advanced: edit raw config JSON</summary>
|
||||
<Form method="post" className="space-y-3 mt-3">
|
||||
<input type="hidden" name="intent" value="save-config" />
|
||||
<Textarea
|
||||
name="config"
|
||||
rows={10}
|
||||
className="font-mono text-sm"
|
||||
defaultValue={JSON.stringify(config.config, null, 2)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Power-user escape hatch for keys not shown above. This is the same stored config the fields
|
||||
edit; saving here writes the whole object.
|
||||
</p>
|
||||
<Button type="submit" variant="outline" disabled={isSubmitting}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Save Raw JSON
|
||||
</Button>
|
||||
</Form>
|
||||
</details>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
|
@ -788,12 +538,8 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
|||
<CardHeader>
|
||||
<CardTitle>Bulk Simulator Inputs</CardTitle>
|
||||
<CardDescription>
|
||||
Two ways to paste, then the simulation re-runs automatically on save:
|
||||
<strong> CSV</strong> with a header row — supported columns: name, sourceElo, sourceOdds,
|
||||
worldRanking, rating, projectedWins, projectedTablePoints, seed, region (values must not
|
||||
contain commas); or <strong>futures odds</strong>, one team per line ending in American
|
||||
odds (e.g. <code>Kansas City Chiefs +450</code>). Team names are fuzzy-matched to
|
||||
participants. A partial paste only updates the columns it includes — other inputs are kept.
|
||||
Paste CSV with a header row. Supported columns: name, sourceElo, sourceOdds, worldRanking, rating,
|
||||
projectedWins, projectedTablePoints, seed, region. Values must not contain commas.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
|
|
@ -803,7 +549,7 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
|||
name="bulkInputs"
|
||||
rows={8}
|
||||
className="font-mono text-sm"
|
||||
placeholder={`name,sourceElo,sourceOdds,worldRanking,rating\n${inputRows[0]?.participant.name ?? "Team Name"},1500,+2500,12,28.5\n\n— or paste futures odds only —\n${inputRows[0]?.participant.name ?? "Team Name"} +2500`}
|
||||
placeholder={`name,sourceElo,sourceOdds,worldRanking,rating\n${inputRows[0]?.participant.name ?? "Team Name"},1500,+2500,12,28.5`}
|
||||
/>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
|
|
@ -811,152 +557,28 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
|||
</Button>
|
||||
</Form>
|
||||
|
||||
{externalInputsSection && (
|
||||
<div className="rounded-md border border-sky-500/30 bg-sky-500/10 p-3 text-sm flex items-center justify-between gap-4">
|
||||
<span>
|
||||
This simulator's participant inputs are managed on the{" "}
|
||||
<strong>{externalInputsSection.label}</strong> page — the list below is a roster only.
|
||||
</span>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to={externalInputsSection.to}>Go to {externalInputsSection.label}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search participants…"
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
{requiredInputs.length > 0 && (
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onlyMissing}
|
||||
onChange={(event) => {
|
||||
setOnlyMissing(event.target.checked);
|
||||
setPage(0);
|
||||
}}
|
||||
/>
|
||||
Only show participants missing a required input
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<div
|
||||
className="grid gap-2 border-b px-3 py-2 text-xs font-medium text-muted-foreground"
|
||||
style={{ gridTemplateColumns: gridTemplate }}
|
||||
>
|
||||
<div>Participant</div>
|
||||
{inputColumns.length > 0 ? (
|
||||
inputColumns.map((column) => (
|
||||
<div key={column.key} className="capitalize">
|
||||
{column.label}
|
||||
{column.required && <span className="text-amber-500"> *</span>}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div>—</div>
|
||||
)}
|
||||
<div className="grid grid-cols-6 gap-2 border-b px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<div className="col-span-2">Participant</div>
|
||||
<div>Elo</div>
|
||||
<div>Odds</div>
|
||||
<div>Rank</div>
|
||||
<div>Rating</div>
|
||||
</div>
|
||||
{pageRows.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
No participants match.
|
||||
{inputRows.slice(0, 20).map(({ participant, input }) => (
|
||||
<div key={participant.id} className="grid grid-cols-6 gap-2 border-b last:border-b-0 px-3 py-2 text-sm">
|
||||
<div className="col-span-2 font-medium">{participant.name}</div>
|
||||
<div>{input?.sourceElo ?? "—"}</div>
|
||||
<div>{input?.sourceOdds ?? "—"}</div>
|
||||
<div>{input?.worldRanking ?? "—"}</div>
|
||||
<div>{input?.rating ?? "—"}</div>
|
||||
</div>
|
||||
))}
|
||||
{inputRows.length > 20 && (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground">
|
||||
Showing 20 of {inputRows.length} participants
|
||||
</div>
|
||||
) : (
|
||||
pageRows.map(({ participant, input }) => {
|
||||
const incomplete = isRowIncomplete(participant.id, input);
|
||||
return (
|
||||
<div
|
||||
key={participant.id}
|
||||
className="grid gap-2 border-b last:border-b-0 px-3 py-2 text-sm"
|
||||
style={{ gridTemplateColumns: gridTemplate }}
|
||||
>
|
||||
<div className="font-medium flex items-center gap-2">
|
||||
{incomplete && <span className="h-2 w-2 shrink-0 rounded-full bg-amber-500" title="Missing a required input" />}
|
||||
{participant.name}
|
||||
</div>
|
||||
{inputColumns.length > 0 ? (
|
||||
inputColumns.map((column) => {
|
||||
if (column.key === "sourceElo") {
|
||||
const resolved = resolvedEloRows[participant.id];
|
||||
const methodLabel = resolved ? resolvedInputMethodLabel(resolved.method) : null;
|
||||
return (
|
||||
<div key={column.key} className="flex items-center gap-1.5">
|
||||
{resolved ? resolved.sourceElo : "—"}
|
||||
{methodLabel && (
|
||||
<Badge variant="outline" className="text-[10px] font-normal">
|
||||
{methodLabel}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (column.key === "rating") {
|
||||
const resolved = resolvedRatingRows[participant.id];
|
||||
const methodLabel = resolved ? resolvedInputMethodLabel(resolved.method) : null;
|
||||
return (
|
||||
<div key={column.key} className="flex items-center gap-1.5">
|
||||
{resolved ? resolved.rating : "—"}
|
||||
{methodLabel && (
|
||||
<Badge variant="outline" className="text-[10px] font-normal">
|
||||
{methodLabel}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const value = input?.[column.key];
|
||||
return <div key={column.key}>{typeof value === "number" || typeof value === "string" ? value : "—"}</div>;
|
||||
})
|
||||
) : (
|
||||
<div>—</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-4 px-3 py-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{filteredRows.length === 0
|
||||
? "0 participants"
|
||||
: `Showing ${pageStart + 1}–${pageStart + pageRows.length} of ${filteredRows.length}${
|
||||
filteredRows.length !== inputRows.length ? ` (${inputRows.length} total)` : ""
|
||||
}`}
|
||||
</span>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={safePage === 0}
|
||||
onClick={() => setPage(safePage - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span>
|
||||
Page {safePage + 1} of {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={safePage >= totalPages - 1}
|
||||
onClick={() => setPage(safePage + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@ import {
|
|||
import { batchUpsertParticipantEvSnapshots } from '~/models/ev-snapshot';
|
||||
import { getSurfaceElosForSeason, batchUpsertSurfaceElos } from '~/models/surface-elo';
|
||||
import { getSimulator, type SimulatorType } from '~/services/simulations/registry';
|
||||
import { calculateEV } from '~/services/ev-calculator';
|
||||
import { DEFAULT_SCORING_RULES } from '~/lib/scoring-types';
|
||||
import { calculateEV, type ScoringRules } from '~/services/ev-calculator';
|
||||
import { recalculateStandings } from '~/models/scoring-calculator';
|
||||
import { database } from '~/database/context';
|
||||
import * as schema from '~/database/schema';
|
||||
|
|
@ -31,6 +30,17 @@ import { useEffect, useRef, useState } from 'react';
|
|||
import { Loader2, CheckCircle2, AlertCircle, UserPlus } from 'lucide-react';
|
||||
import { normalizeName, diceCoefficient } from '~/lib/fuzzy-match';
|
||||
|
||||
const DEFAULT_SCORING_RULES: ScoringRules = {
|
||||
pointsFor1st: 100,
|
||||
pointsFor2nd: 70,
|
||||
pointsFor3rd: 45,
|
||||
pointsFor4th: 45,
|
||||
pointsFor5th: 20,
|
||||
pointsFor6th: 20,
|
||||
pointsFor7th: 20,
|
||||
pointsFor8th: 20,
|
||||
};
|
||||
|
||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||
return [{ title: `Surface Elo — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,8 @@ import { findSportsSeasonById, updateSportsSeason, deleteSportsSeason, type NewS
|
|||
import { processSeasonStandings, recalculateStandings } from "~/models/scoring-calculator";
|
||||
import { createDailySnapshot } from "~/models/standings";
|
||||
import { database } from "~/database/context";
|
||||
import { participantEvSnapshots, seasonSports } from "~/database/schema";
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
import { getSimulatorInfo, type SimulatorType } from "~/services/simulations/registry";
|
||||
import { runSportsSeasonSimulation } from "~/services/simulations/runner";
|
||||
import { seasonSports } from "~/database/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { syncStandings } from "~/services/standings-sync/index";
|
||||
import {
|
||||
getPendingStandingsMappings,
|
||||
|
|
@ -48,7 +46,7 @@ import {
|
|||
} from "~/components/ui/alert-dialog";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "~/components/ui/select";
|
||||
import { Trash2, Users, Trophy, Calculator, CheckCircle2, Zap, AlertTriangle, Loader2, RefreshCw, Copy } from "lucide-react";
|
||||
import { Trash2, Users, Trophy, CheckCircle2, AlertTriangle, Loader2, RefreshCw, Copy } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
const SELECT_CLASS = "h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm";
|
||||
|
|
@ -121,21 +119,6 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
|
||||
const participants = await findParticipantsBySportsSeasonId(params.id);
|
||||
|
||||
// Get the most recent snapshot date (if any)
|
||||
const db = database();
|
||||
const lastSnapshot = await db
|
||||
.select({ snapshotDate: participantEvSnapshots.snapshotDate })
|
||||
.from(participantEvSnapshots)
|
||||
.where(eq(participantEvSnapshots.sportsSeasonId, params.id))
|
||||
.orderBy(desc(participantEvSnapshots.snapshotDate))
|
||||
.limit(1);
|
||||
|
||||
const lastSimulatedDate = lastSnapshot[0]?.snapshotDate ?? null;
|
||||
|
||||
const simulatorInfo = sportsSeason.sport?.simulatorType
|
||||
? getSimulatorInfo(sportsSeason.sport.simulatorType as SimulatorType)
|
||||
: null;
|
||||
|
||||
const lastStandingsSyncedAt =
|
||||
sportsSeason.sport?.type === "team"
|
||||
? await getLastSyncedAt(params.id)
|
||||
|
|
@ -148,8 +131,6 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
return {
|
||||
sportsSeason,
|
||||
participants,
|
||||
lastSimulatedDate,
|
||||
simulatorInfo,
|
||||
lastStandingsSyncedAt: lastStandingsSyncedAt?.toISOString() ?? null,
|
||||
pendingMappings,
|
||||
};
|
||||
|
|
@ -293,17 +274,6 @@ export async function action(args: Route.ActionArgs) {
|
|||
}
|
||||
}
|
||||
|
||||
if (intent === "simulate") {
|
||||
try {
|
||||
await runSportsSeasonSimulation(params.id);
|
||||
return redirect(`/admin/sports-seasons/${params.id}/expected-values`);
|
||||
} catch (error) {
|
||||
return {
|
||||
simulateError: error instanceof Error ? error.message : "Simulation failed",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Update
|
||||
const name = formData.get("name");
|
||||
const year = formData.get("year");
|
||||
|
|
@ -393,7 +363,7 @@ export async function action(args: Route.ActionArgs) {
|
|||
}
|
||||
|
||||
export default function EditSportsSeason({ loaderData, actionData }: Route.ComponentProps) {
|
||||
const { sportsSeason, participants, lastSimulatedDate, simulatorInfo, lastStandingsSyncedAt, pendingMappings } = loaderData;
|
||||
const { sportsSeason, participants, lastStandingsSyncedAt, pendingMappings } = loaderData;
|
||||
const navigate = useNavigate();
|
||||
const navigation = useNavigation();
|
||||
const isSyncingStandings =
|
||||
|
|
@ -649,103 +619,6 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Expected Values</CardTitle>
|
||||
<CardDescription>
|
||||
{simulatorInfo
|
||||
? simulatorInfo.name
|
||||
: "Manage probability distributions and projected points"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{sportsSeason.simulationStatus === "failed" && (
|
||||
<Badge variant="outline" className="bg-destructive/15 text-destructive border-destructive/30">
|
||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||
Last run failed
|
||||
</Badge>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/simulator`)}
|
||||
>
|
||||
<Calculator className="mr-2 h-4 w-4" />
|
||||
Simulator Setup
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/elo-ratings`)}
|
||||
>
|
||||
<Calculator className="mr-2 h-4 w-4" />
|
||||
Elo Ratings
|
||||
</Button>
|
||||
{sportsSeason.sport?.simulatorType === "tennis_qualifying_points" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/surface-elo`)}
|
||||
>
|
||||
<Calculator className="mr-2 h-4 w-4" />
|
||||
Surface Elo
|
||||
</Button>
|
||||
)}
|
||||
{sportsSeason.sport?.simulatorType === "golf_qualifying_points" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/golf-skills`)}
|
||||
>
|
||||
<Calculator className="mr-2 h-4 w-4" />
|
||||
Golf Skills
|
||||
</Button>
|
||||
)}
|
||||
{simulatorInfo && (
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value="simulate" />
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={sportsSeason.simulationStatus === "running"}
|
||||
>
|
||||
{sportsSeason.simulationStatus === "running" ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Running...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
Run Simulation
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{simulatorInfo
|
||||
? <>
|
||||
{simulatorInfo.description}.{" "}
|
||||
{lastSimulatedDate ? `Last simulated: ${lastSimulatedDate}.` : "No simulation has been run yet."}
|
||||
{" "}Import futures odds first, then run the simulation to update EVs and save a snapshot.
|
||||
</>
|
||||
: "Import futures odds to set probability distributions."}
|
||||
</p>
|
||||
{"simulateError" in (actionData ?? {}) && actionData?.simulateError && (
|
||||
<div className="mt-3 bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
||||
{actionData.simulateError}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import {
|
|||
getSportsSeasonsByTournament,
|
||||
getPrimaryEventForTournament,
|
||||
setPrimaryEvent,
|
||||
deleteScoringEvent,
|
||||
} from "~/models/scoring-event";
|
||||
import { isBracketMajor } from "~/lib/event-utils";
|
||||
import {
|
||||
|
|
@ -31,17 +30,6 @@ import {
|
|||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "~/components/ui/alert-dialog";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
|
|
@ -226,26 +214,6 @@ export async function action(args: Route.ActionArgs) {
|
|||
}
|
||||
}
|
||||
|
||||
if (intent === "unlink-window") {
|
||||
const eventId = formData.get("eventId");
|
||||
if (typeof eventId !== "string" || !eventId) {
|
||||
return { success: false as const, error: "Event ID is required", syncReport: null };
|
||||
}
|
||||
try {
|
||||
// Remove that season's scoring event. The tournament stays (we're on its
|
||||
// page); if this was the primary window, another is auto-promoted.
|
||||
await deleteScoringEvent(eventId);
|
||||
return { success: true as const, error: null, syncReport: null };
|
||||
} catch (error) {
|
||||
logger.error("unlink-window failed:", error);
|
||||
return {
|
||||
success: false as const,
|
||||
error: error instanceof Error ? error.message : "Failed to remove season",
|
||||
syncReport: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false as const,
|
||||
error: "Invalid intent",
|
||||
|
|
@ -268,7 +236,6 @@ export default function AdminTournamentDetail({
|
|||
} = loaderData;
|
||||
const retryFetcher = useFetcher<typeof action>();
|
||||
const primaryFetcher = useFetcher<typeof action>();
|
||||
const unlinkFetcher = useFetcher<typeof action>();
|
||||
|
||||
// For bracket majors, scoring lives on the primary window's bracket/stage UI.
|
||||
const primaryScoringHref =
|
||||
|
|
@ -452,51 +419,6 @@ export default function AdminTournamentDetail({
|
|||
>
|
||||
{link.sportsSeason.status}
|
||||
</Badge>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={unlinkFetcher.state !== "idle"}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Remove {link.sportsSeason.sport.name} - {link.sportsSeason.name}?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<span>
|
||||
This deletes that season's scoring event (and its results) but keeps{" "}
|
||||
<span className="font-medium">{tournament.name}</span> and the other linked seasons.
|
||||
{link.isPrimary && linkedSportsSeasons.length > 1 && (
|
||||
<> Because this is the primary scoring window, another season will be promoted to primary automatically.</>
|
||||
)}
|
||||
{linkedSportsSeasons.length === 1 && (
|
||||
<> This is the only linked season — the tournament will remain but have no windows until you link one again.</>
|
||||
)}
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<unlinkFetcher.Form method="post">
|
||||
<input type="hidden" name="intent" value="unlink-window" />
|
||||
<input type="hidden" name="eventId" value={link.id} />
|
||||
<AlertDialogAction
|
||||
type="submit"
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Remove
|
||||
</AlertDialogAction>
|
||||
</unlinkFetcher.Form>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ import {
|
|||
Users,
|
||||
Menu,
|
||||
Shield,
|
||||
CalendarRange,
|
||||
} from "lucide-react";
|
||||
|
||||
export function meta(): Route.MetaDescriptors {
|
||||
|
|
@ -73,12 +72,6 @@ function AdminNavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
|||
Sports Seasons
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" className="w-full justify-start" asChild onClick={onNavigate}>
|
||||
<Link to="/admin/draft-schedule">
|
||||
<CalendarRange className="mr-2 h-4 w-4" />
|
||||
Draft Schedule
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" className="w-full justify-start" asChild onClick={onNavigate}>
|
||||
<Link to="/admin/simulators">
|
||||
<Activity className="mr-2 h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { and, eq, isNotNull } from "drizzle-orm";
|
|||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { requireCronSecret } from "~/lib/cron-auth";
|
||||
import { syncMatches, syncTennisDraw } from "~/services/match-sync";
|
||||
import { syncMatches } from "~/services/match-sync";
|
||||
|
||||
export async function action({ request }: { request: Request }) {
|
||||
requireCronSecret(request);
|
||||
|
|
@ -37,39 +37,5 @@ export async function action({ request }: { request: Request }) {
|
|||
}
|
||||
}
|
||||
|
||||
// Tennis Grand Slam draws: sync each active tennis major's primary window from
|
||||
// its configured Wikipedia article. (Tennis uses a per-event externalSourceKey,
|
||||
// not a per-season externalSeasonId, so it isn't covered by the loop above.)
|
||||
const tennisEvents = await db.query.scoringEvents.findMany({
|
||||
where: and(
|
||||
isNotNull(schema.scoringEvents.externalSourceKey),
|
||||
eq(schema.scoringEvents.isQualifyingEvent, true),
|
||||
eq(schema.scoringEvents.isComplete, false),
|
||||
),
|
||||
with: { sportsSeason: { with: { sport: true } } },
|
||||
});
|
||||
|
||||
const drawSynced: string[] = [];
|
||||
for (const ev of tennisEvents) {
|
||||
if (ev.sportsSeason?.status !== "active") continue;
|
||||
if (ev.sportsSeason?.sport?.simulatorType !== "tennis_qualifying_points") continue;
|
||||
// Skip read-only siblings of a shared major — results fan out from the primary.
|
||||
if (ev.tournamentId && !ev.isPrimary) continue;
|
||||
try {
|
||||
await syncTennisDraw(ev.id);
|
||||
drawSynced.push(ev.id);
|
||||
} catch (err) {
|
||||
errors.push({
|
||||
id: ev.id,
|
||||
name: ev.name ?? "(tennis draw)",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const okCount = synced.length + drawSynced.length;
|
||||
return Response.json(
|
||||
{ synced, drawSynced, errors },
|
||||
{ status: errors.length > 0 && okCount === 0 ? 500 : 200 },
|
||||
);
|
||||
return Response.json({ synced, errors }, { status: errors.length > 0 && synced.length === 0 ? 500 : 200 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import { getCs2StageResultsForEvent } from "~/models/cs2-major-stage";
|
|||
import { findSeasonMatchesByScoringEventId } from "~/models/season-match";
|
||||
import { getEventResults } from "~/models/event-result";
|
||||
import { getBracketTemplate, getOrderedRoundsFromMatches } from "~/lib/bracket-templates";
|
||||
import { isBracketMajor } from "~/lib/event-utils";
|
||||
import { findGroupsByEventId } from "~/models/tournament-group";
|
||||
import { findMatchesByGroupIds, computeGroupStandings } from "~/models/group-stage-match";
|
||||
import type { PlayoffMatch } from "~/models/playoff-match";
|
||||
|
|
@ -206,69 +205,6 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||
};
|
||||
}
|
||||
|
||||
// Tennis Grand Slam major — bracket draw (primary-keyed) + this window's QP results.
|
||||
// major_tournament + tennis_qualifying_points. CS2 (also a bracket major) is handled
|
||||
// above; golf (golf_qualifying_points) returns false from isBracketMajor and falls
|
||||
// through to the plain QP results branch.
|
||||
if (isBracketMajor(simulatorType) && scoringEvent.eventType === "major_tournament") {
|
||||
const [playoffMatchRows, eventResultRows] = await Promise.all([
|
||||
db.query.playoffMatches.findMany({
|
||||
where: eq(schema.playoffMatches.scoringEventId, structureEventId),
|
||||
with: { participant1: true, participant2: true, winner: true, loser: true },
|
||||
}),
|
||||
// QP/results rows live on this window (local eventId), not the primary structure.
|
||||
getEventResults(eventId),
|
||||
]);
|
||||
const playoffMatches: PlayoffMatchWithRelations[] = playoffMatchRows.map((m) => ({
|
||||
...m,
|
||||
createdAt: m.createdAt.toISOString(),
|
||||
updatedAt: m.updatedAt.toISOString(),
|
||||
}));
|
||||
const templateId = structureBracketTemplateId ?? undefined;
|
||||
const template = templateId ? getBracketTemplate(templateId) : undefined;
|
||||
const playoffRounds = getOrderedRoundsFromMatches(playoffMatches, template);
|
||||
|
||||
const allResults = await db.query.seasonParticipantResults.findMany({
|
||||
where: and(
|
||||
eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId),
|
||||
eq(schema.seasonParticipantResults.finalPosition, 0)
|
||||
),
|
||||
with: { participant: true },
|
||||
});
|
||||
const preEliminatedParticipants = allResults
|
||||
.filter((r): r is typeof r & { participant: NonNullable<typeof r.participant> } => r.participant !== null)
|
||||
.map((r) => ({ id: r.participant.id, name: r.participant.name }));
|
||||
|
||||
const eventResults = eventResultRows
|
||||
.filter((r): r is typeof r & { placement: number } => r.placement !== null && !r.notParticipating)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
placement: r.placement,
|
||||
qualifyingPointsAwarded: r.qualifyingPointsAwarded,
|
||||
rawScore: r.rawScore,
|
||||
seasonParticipantId: r.seasonParticipantId,
|
||||
participantName: r.seasonParticipant?.name ?? null,
|
||||
}));
|
||||
|
||||
return {
|
||||
kind: "tennis" as const,
|
||||
league,
|
||||
sportsSeason,
|
||||
scoringEvent,
|
||||
// Bracket structure + ownership keyed to the primary window (shared majors).
|
||||
playoffMatches,
|
||||
playoffRounds,
|
||||
bracketTemplateId: templateId ?? null,
|
||||
preEliminatedParticipants,
|
||||
bracketTeamOwnerships,
|
||||
bracketUserParticipantIds,
|
||||
// QP results table uses this window's local ownership + participant ids.
|
||||
teamOwnerships,
|
||||
userParticipantIds,
|
||||
eventResults,
|
||||
};
|
||||
}
|
||||
|
||||
// Bracket events (tennis/golf bracket majors, soccer/UCL group+bracket)
|
||||
if (scoringEvent.eventType === "playoff_game") {
|
||||
const playoffMatchRows = await db.query.playoffMatches.findMany({
|
||||
|
|
|
|||
|
|
@ -30,79 +30,6 @@ export function meta({ data }: Route.MetaArgs) {
|
|||
|
||||
export { loader };
|
||||
|
||||
type QpResult = {
|
||||
id: string;
|
||||
placement: number;
|
||||
qualifyingPointsAwarded: string | null;
|
||||
rawScore: string | null;
|
||||
seasonParticipantId: string;
|
||||
participantName: string | null;
|
||||
};
|
||||
|
||||
function QpResultsTable({
|
||||
eventResults,
|
||||
ownershipMap,
|
||||
userParticipantIds,
|
||||
}: {
|
||||
eventResults: QpResult[];
|
||||
ownershipMap: Record<string, { teamName: string; ownerName: string; teamId: string }>;
|
||||
userParticipantIds: string[];
|
||||
}) {
|
||||
if (eventResults.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center">
|
||||
<p className="text-muted-foreground text-sm">Results not yet available for this event.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Results</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead>Participant</TableHead>
|
||||
<TableHead>Manager</TableHead>
|
||||
<TableHead className="text-right">QP</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{eventResults.map((r) => {
|
||||
const ownership = ownershipMap[r.seasonParticipantId];
|
||||
const isUser = userParticipantIds.includes(r.seasonParticipantId);
|
||||
return (
|
||||
<TableRow key={r.id} className={isUser ? "bg-electric/5" : undefined}>
|
||||
<TableCell className="text-muted-foreground">{r.placement}</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{r.participantName ?? "—"}
|
||||
{isUser && <span className="ml-1.5 text-xs text-electric">★</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{ownership?.teamName ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{r.qualifyingPointsAwarded !== null
|
||||
? parseFloat(r.qualifyingPointsAwarded).toFixed(2)
|
||||
: r.rawScore !== null
|
||||
? r.rawScore
|
||||
: "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EventDetailPage({ loaderData }: Route.ComponentProps) {
|
||||
const { league, sportsSeason, scoringEvent } = loaderData;
|
||||
const leagueId = league.id;
|
||||
|
|
@ -187,42 +114,58 @@ export default function EventDetailPage({ loaderData }: Route.ComponentProps) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Tennis Grand Slam major — draw bracket + qualifying-points results */}
|
||||
{loaderData.kind === "tennis" && (
|
||||
<div className="space-y-6">
|
||||
{loaderData.playoffMatches.length > 0 ? (
|
||||
<PlayoffBracket
|
||||
matches={loaderData.playoffMatches}
|
||||
rounds={loaderData.playoffRounds}
|
||||
bracketTemplateId={loaderData.bracketTemplateId}
|
||||
preEliminatedParticipants={loaderData.preEliminatedParticipants}
|
||||
teamOwnerships={loaderData.bracketTeamOwnerships}
|
||||
userParticipantIds={loaderData.bracketUserParticipantIds}
|
||||
showOwnership={true}
|
||||
mode="bracket"
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center">
|
||||
<p className="text-muted-foreground text-sm">Bracket not yet available.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<QpResultsTable
|
||||
eventResults={loaderData.eventResults}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={loaderData.userParticipantIds}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results table (QP tournaments, racing events) */}
|
||||
{loaderData.kind === "results" && (
|
||||
<QpResultsTable
|
||||
eventResults={loaderData.eventResults}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={loaderData.userParticipantIds}
|
||||
/>
|
||||
loaderData.eventResults.length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Results</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead>Participant</TableHead>
|
||||
<TableHead>Manager</TableHead>
|
||||
<TableHead className="text-right">QP</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loaderData.eventResults.map((r) => {
|
||||
const ownership = ownershipMap[r.seasonParticipantId];
|
||||
const isUser = loaderData.userParticipantIds.includes(r.seasonParticipantId);
|
||||
return (
|
||||
<TableRow key={r.id} className={isUser ? "bg-electric/5" : undefined}>
|
||||
<TableCell className="text-muted-foreground">{r.placement}</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{r.participantName ?? "—"}
|
||||
{isUser && <span className="ml-1.5 text-xs text-electric">★</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{ownership?.teamName ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{r.qualifyingPointsAwarded !== null
|
||||
? parseFloat(r.qualifyingPointsAwarded).toFixed(2)
|
||||
: r.rawScore !== null
|
||||
? r.rawScore
|
||||
: "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center">
|
||||
<p className="text-muted-foreground text-sm">Results not yet available for this event.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import { getQPStandings } from "~/models/qualifying-points";
|
|||
import {
|
||||
getUpcomingScoringEvents,
|
||||
getRecentCompletedEvents,
|
||||
getMajorsCompleted,
|
||||
} from "~/models/scoring-event";
|
||||
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
||||
import { getAllParticipantEVsForSeason } from "~/models/participant-expected-value";
|
||||
|
|
@ -158,7 +157,6 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
let seasonStandings: SeasonStanding[] = [];
|
||||
type QPStanding = Awaited<ReturnType<typeof getQPStandings>>[number] & { globalRank: number };
|
||||
let qpStandings: QPStanding[] = [];
|
||||
let majorsCompleted = 0;
|
||||
|
||||
// Group standings for group-stage events (e.g. FIFA World Cup)
|
||||
type RawGroupMatch = Awaited<ReturnType<typeof findMatchesByGroupIds>> extends Map<string, Array<infer T>> ? T : never;
|
||||
|
|
@ -295,9 +293,6 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
},
|
||||
}));
|
||||
} else if (scoringPattern === "qualifying_points") {
|
||||
// majorsCompleted is derived on read (count of completed qualifying events), not a
|
||||
// stored counter — see getMajorsCompleted.
|
||||
majorsCompleted = await getMajorsCompleted(sportsSeasonId);
|
||||
const standings = await getQPStandings(sportsSeasonId);
|
||||
// Compute global ranks with tie handling across the full field before filtering,
|
||||
// so the displayed rank numbers remain correct after undrafted/zero-QP rows are removed.
|
||||
|
|
@ -346,7 +341,7 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
return {
|
||||
league,
|
||||
season,
|
||||
sportsSeason: { ...sportsSeason, majorsCompleted },
|
||||
sportsSeason,
|
||||
scoringPattern,
|
||||
playoffMatches,
|
||||
playoffRounds,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { redirect, type ShouldRevalidateFunctionArgs } from "react-router";
|
||||
import { redirect, useSearchParams } from "react-router";
|
||||
import { useState } from "react";
|
||||
import { Bell, Key, Lock, Shield, User } from "lucide-react";
|
||||
import { auth } from "~/lib/auth.server";
|
||||
import { findUserById, findUserByUsername, isUserInActiveDraft, updateUser, anonymizeUserAccount, USERNAME_RE } from "~/models/user";
|
||||
|
|
@ -56,26 +57,7 @@ export function meta(): Route.MetaDescriptors {
|
|||
return [{ title: "Settings - Brackt" }];
|
||||
}
|
||||
|
||||
export function shouldRevalidate({
|
||||
currentParams,
|
||||
nextParams,
|
||||
formMethod,
|
||||
defaultShouldRevalidate,
|
||||
}: ShouldRevalidateFunctionArgs) {
|
||||
// Mutations (profile/avatar/notification updates) must refresh loader data.
|
||||
if (formMethod && formMethod !== "GET") return true;
|
||||
// Switching sections only changes the path param, and the loader payload is the
|
||||
// same for every section, so don't refetch user/draft status/linked accounts.
|
||||
if (currentParams.section !== nextParams.section) return false;
|
||||
return defaultShouldRevalidate;
|
||||
}
|
||||
|
||||
export async function loader(args: Route.LoaderArgs) {
|
||||
const section = args.params.section;
|
||||
if (section && !(VALID_SECTION_IDS as Set<string>).has(section)) {
|
||||
return redirect("/settings");
|
||||
}
|
||||
|
||||
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||
if (!session) {
|
||||
return redirect("/login?redirectTo=/settings");
|
||||
|
|
@ -231,12 +213,18 @@ export async function action(args: Route.ActionArgs): Promise<ActionData | Respo
|
|||
return { intent: intent as string, error: "Unknown action." };
|
||||
}
|
||||
|
||||
export default function SettingsPage({ loaderData, actionData, params }: Route.ComponentProps) {
|
||||
export default function SettingsPage({ loaderData, actionData }: Route.ComponentProps) {
|
||||
const { user, isInActiveDraft, linkedAccounts, dataRequestCooldownUntil } = loaderData;
|
||||
const section = params.section;
|
||||
const activeSection: SectionId =
|
||||
section && (VALID_SECTION_IDS as Set<string>).has(section) ? (section as SectionId) : "profile";
|
||||
const mobileView: "grid" | "section" = section ? "section" : "grid";
|
||||
const [searchParams] = useSearchParams();
|
||||
const raw = searchParams.get("section");
|
||||
const initialSection: SectionId = raw && (VALID_SECTION_IDS as Set<string>).has(raw) ? (raw as SectionId) : "profile";
|
||||
const [activeSection, setActiveSection] = useState<SectionId>(initialSection);
|
||||
const [mobileView, setMobileView] = useState<"grid" | "section">("grid");
|
||||
|
||||
const handleSectionChange = (id: string, mobile = false) => {
|
||||
setActiveSection(id as SectionId);
|
||||
if (mobile) setMobileView("section");
|
||||
};
|
||||
|
||||
const ad = actionData as ActionData | undefined;
|
||||
const profileSuccess = ad?.intent === "update-profile" && "success" in ad;
|
||||
|
|
@ -258,21 +246,20 @@ export default function SettingsPage({ loaderData, actionData, params }: Route.C
|
|||
<div className="lg:hidden">
|
||||
<SettingsMobileGridNav
|
||||
sections={SECTIONS}
|
||||
buildHref={(id) => `/settings/${id}`}
|
||||
onSectionChange={(id) => handleSectionChange(id, true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mobileView === "section" && (
|
||||
<SettingsMobileSectionPill backHref="/settings" />
|
||||
<SettingsMobileSectionPill onShowGrid={() => setMobileView("grid")} />
|
||||
)}
|
||||
|
||||
<div className={`grid gap-6 lg:grid-cols-[220px_minmax(0,1fr)] ${mobileView === "grid" ? "hidden lg:grid" : "grid"}`}>
|
||||
<SettingsDesktopNav
|
||||
sections={SECTIONS}
|
||||
activeSection={activeSection}
|
||||
buildHref={(id) => `/settings/${id}`}
|
||||
navLabel="Account settings"
|
||||
onSectionChange={(id) => handleSectionChange(id)}
|
||||
/>
|
||||
|
||||
<main className="min-w-0">
|
||||
|
|
@ -292,6 +279,7 @@ export default function SettingsPage({ loaderData, actionData, params }: Route.C
|
|||
discordPingEnabled={user.discordPingEnabled}
|
||||
hasDiscordLinked={linkedAccounts.some((a) => a.providerId === "discord")}
|
||||
draftEmailNotificationsEnabled={user.draftEmailNotificationsEnabled}
|
||||
onNavigateToAccount={() => handleSectionChange("account")}
|
||||
/>
|
||||
)}
|
||||
{activeSection === "api" && <ApiSection />}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue