Compare commits
54 commits
claude/pra
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b85f387c79 | ||
|
|
932f36ca07 | ||
| 7e578c714c | |||
|
|
687fb6f040 | ||
|
|
5d0363a309 | ||
| b05bad6554 | |||
|
|
cab161424e | ||
|
|
3f16d5e1d3 | ||
| 036411e9d8 | |||
|
|
07f1356cd3 | ||
|
|
3f6af2665e | ||
| 91227b2cb3 | |||
|
|
b9a9eb4c4b | ||
|
|
7ca89aafc4 | ||
| 8416b54052 | |||
|
|
698c6a6509 | ||
|
|
12c165a481 | ||
|
|
0baef5a948 | ||
| 58e656f15e | |||
| 8a948e1388 | |||
|
|
5be8ac3f2f | ||
|
|
8900bf79dc | ||
| 0c100aa834 | |||
|
|
ec89e3eb89 | ||
|
|
7f7ea4e29d | ||
| 7cbcc6fb14 | |||
|
|
77963a13f4 | ||
|
|
7f021aa534 | ||
| f527fc8f7f | |||
|
|
ef7a71485c | ||
| 6b0e32c3d5 | |||
| b0c25beb90 | |||
| 64e5cb23ef | |||
| 3e50619629 | |||
| 8efa4aab9e | |||
| 3273cf1bcb | |||
| 070b825077 | |||
| e3e8485e26 | |||
| b8c21d227b | |||
| dfbb25771d | |||
|
|
81d063faa1 | ||
| 120056b0bd | |||
| ee099c64cd | |||
| ab74b19420 | |||
|
|
88248e349c | ||
|
|
6772079e86 | ||
|
|
9480501932 | ||
|
|
b960d39be3 | ||
| 4a5ea6fdc5 | |||
|
|
dfbcb7d953 | ||
| 472875f151 | |||
| dedd2f3d86 | |||
| f078eab492 | |||
| d31c23d63b |
142 changed files with 30073 additions and 2541 deletions
|
|
@ -9,17 +9,14 @@
|
|||
"command": ".claude/hooks/lint-on-edit.sh",
|
||||
"timeout": 30,
|
||||
"statusMessage": "Linting..."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "output=$(npm run typecheck 2>&1); rc=$?; if [ $rc -ne 0 ]; then printf '{\"systemMessage\":\"TypeCheck failed:\\n%s\"}' \"$(echo \"$output\" | tail -30 | sed 's/\"/\\\\\"/g; s/$/\\\\n/' | tr -d '\\n')\"; fi",
|
||||
"timeout": 60
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,3 +31,8 @@ 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 hundredths", () => {
|
||||
it("renders fractional QP to at most hundredths with trailing zeros trimmed", () => {
|
||||
render(
|
||||
<QualifyingPointsStandings
|
||||
standings={[
|
||||
|
|
@ -52,7 +52,8 @@ describe("QualifyingPointsStandings", () => {
|
|||
);
|
||||
|
||||
expect(screen.getByText("14.67 QP")).toBeInTheDocument();
|
||||
expect(screen.getByText("0.50 QP")).toBeInTheDocument();
|
||||
// 0.50 trims its trailing zero to 0.5; 14.67 and 0.43 are unaffected.
|
||||
expect(screen.getByText("0.5 QP")).toBeInTheDocument();
|
||||
expect(screen.getByText("0.43 QP")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
236
app/components/admin/DraftScheduleGantt.tsx
Normal file
236
app/components/admin/DraftScheduleGantt.tsx
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import { Link } from "react-router";
|
||||
import {
|
||||
addMonths,
|
||||
differenceInCalendarDays,
|
||||
eachMonthOfInterval,
|
||||
format,
|
||||
parseISO,
|
||||
} from "date-fns";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import type { DraftScheduleWindow } from "~/models/sports-season";
|
||||
|
||||
export interface GanttSport {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
iconUrl: string | null;
|
||||
windows: DraftScheduleWindow[];
|
||||
}
|
||||
|
||||
interface DraftScheduleGanttProps {
|
||||
sports: GanttSport[];
|
||||
/** Horizon start (today) as a YYYY-MM-DD string. */
|
||||
today: string;
|
||||
/** Number of months the timeline spans. */
|
||||
months: number;
|
||||
}
|
||||
|
||||
// Bar color by sport-season status, using the theme chart tokens from app.css.
|
||||
const STATUS_COLORS: Record<DraftScheduleWindow["status"], string> = {
|
||||
active: "var(--chart-1)",
|
||||
upcoming: "var(--chart-2)",
|
||||
completed: "var(--muted-foreground)",
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<DraftScheduleWindow["status"], string> = {
|
||||
active: "Active",
|
||||
upcoming: "Upcoming",
|
||||
completed: "Completed",
|
||||
};
|
||||
|
||||
const clampPct = (n: number) => Math.max(0, Math.min(100, n));
|
||||
|
||||
// Row layout (px). A single-lane row is LANE_HEIGHT + ROW_V_PAD tall; extra
|
||||
// concurrent windows add one lane each so overlapping bars never stack on top
|
||||
// of one another.
|
||||
const LANE_HEIGHT = 30;
|
||||
const LANE_GAP = 8;
|
||||
const ROW_V_PAD = 14;
|
||||
|
||||
const rowHeight = (laneCount: number) => Math.max(laneCount, 1) * LANE_HEIGHT + ROW_V_PAD;
|
||||
|
||||
/**
|
||||
* Greedy interval-scheduling: assign each window to the first lane whose last
|
||||
* bar ends before this one starts, otherwise open a new lane. Windows arrive
|
||||
* sorted by draftOn (see findDraftScheduleForHorizon).
|
||||
*/
|
||||
function assignLanes(
|
||||
windows: DraftScheduleWindow[],
|
||||
start: Date
|
||||
): { placed: Array<{ window: DraftScheduleWindow; lane: number }>; laneCount: number } {
|
||||
const laneEnds: number[] = [];
|
||||
const placed = windows.map((window) => {
|
||||
const startDay = differenceInCalendarDays(parseISO(window.draftOn), start);
|
||||
const endDay = differenceInCalendarDays(parseISO(window.draftOff), start);
|
||||
let lane = laneEnds.findIndex((laneEnd) => laneEnd < startDay);
|
||||
if (lane === -1) {
|
||||
lane = laneEnds.length;
|
||||
laneEnds.push(endDay);
|
||||
} else {
|
||||
laneEnds[lane] = endDay;
|
||||
}
|
||||
return { window, lane };
|
||||
});
|
||||
return { placed, laneCount: Math.max(laneEnds.length, 1) };
|
||||
}
|
||||
|
||||
export function DraftScheduleGantt({ sports, today, months }: DraftScheduleGanttProps) {
|
||||
const start = parseISO(today);
|
||||
const end = addMonths(start, months);
|
||||
const totalDays = Math.max(differenceInCalendarDays(end, start), 1);
|
||||
|
||||
const pct = (date: Date) => (differenceInCalendarDays(date, start) / totalDays) * 100;
|
||||
|
||||
// Month-boundary gridlines that fall inside the horizon.
|
||||
const monthLines = eachMonthOfInterval({ start, end })
|
||||
.map((date) => ({ date, left: pct(date) }))
|
||||
.filter((m) => m.left >= 0 && m.left <= 100);
|
||||
|
||||
// Pre-compute lane assignment + height for each sport row.
|
||||
const rows = sports.map((sport) => {
|
||||
const { placed, laneCount } = assignLanes(sport.windows, start);
|
||||
return { sport, placed, height: rowHeight(laneCount) };
|
||||
});
|
||||
|
||||
if (sports.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Draft Schedule</CardTitle>
|
||||
<CardDescription>Draft windows across the next {months} months</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<p>No sports found.</p>
|
||||
<p className="text-sm mt-2">Create a sport to start scheduling draft windows.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>Draft Schedule</CardTitle>
|
||||
<CardDescription>
|
||||
Draft windows across the next {months} months (each bar spans a
|
||||
sport-season’s draft-on → draft-off window)
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||
{(Object.keys(STATUS_COLORS) as DraftScheduleWindow["status"][]).map((status) => (
|
||||
<div key={status} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-3 w-3 rounded-sm"
|
||||
style={{ backgroundColor: STATUS_COLORS[status] }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{STATUS_LABELS[status]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<div className="flex" style={{ minWidth: 160 + months * 80 }}>
|
||||
{/* Sport labels column */}
|
||||
<div className="w-40 shrink-0">
|
||||
<div className="h-8" aria-hidden="true" />
|
||||
{rows.map(({ sport, height }) => (
|
||||
<div
|
||||
key={sport.id}
|
||||
style={{ height }}
|
||||
className="flex items-center border-b border-border/50 pr-2"
|
||||
>
|
||||
<span className="truncate text-sm font-medium" title={sport.name}>
|
||||
{sport.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Timeline column */}
|
||||
<div className="relative flex-1">
|
||||
{/* Month gridlines (full height) */}
|
||||
{monthLines.map((m) => (
|
||||
<div
|
||||
key={`line-${m.date.toISOString()}`}
|
||||
className="pointer-events-none absolute bottom-0 top-0 w-px bg-border/60"
|
||||
style={{ left: `${m.left}%` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
{/* Today marker (full height) */}
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-0 top-0 w-0.5 bg-primary/80"
|
||||
style={{ left: 0 }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Month labels header */}
|
||||
<div className="relative h-8">
|
||||
{monthLines.map((m) => (
|
||||
<span
|
||||
key={`label-${m.date.toISOString()}`}
|
||||
className="absolute top-1 text-xs text-muted-foreground"
|
||||
style={{ left: `calc(${m.left}% + 4px)` }}
|
||||
>
|
||||
{format(m.date, "MMM yyyy")}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
{rows.map(({ sport, placed, height }) => (
|
||||
<div
|
||||
key={sport.id}
|
||||
style={{ height }}
|
||||
className="relative border-b border-border/50"
|
||||
>
|
||||
{placed.length === 0 ? (
|
||||
<span className="absolute left-2 top-1/2 -translate-y-1/2 text-xs italic text-muted-foreground/70">
|
||||
No draft window
|
||||
</span>
|
||||
) : (
|
||||
placed.map(({ window: w, lane }) => {
|
||||
const left = clampPct(pct(parseISO(w.draftOn)));
|
||||
const right = clampPct(pct(parseISO(w.draftOff)));
|
||||
const width = Math.max(right - left, 0.75);
|
||||
return (
|
||||
<Link
|
||||
key={w.id}
|
||||
to={`/admin/sports-seasons/${w.id}`}
|
||||
className="absolute flex items-center overflow-hidden rounded px-1.5 text-xs font-medium text-background transition-opacity hover:opacity-80"
|
||||
style={{
|
||||
left: `${left}%`,
|
||||
width: `${width}%`,
|
||||
top: ROW_V_PAD / 2 + lane * LANE_HEIGHT,
|
||||
height: LANE_HEIGHT - LANE_GAP,
|
||||
backgroundColor: STATUS_COLORS[w.status],
|
||||
}}
|
||||
title={`${w.name} (${w.year}) · ${w.draftOn} → ${w.draftOff}`}
|
||||
>
|
||||
<span className="truncate">{w.name}</span>
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
97
app/components/admin/__tests__/DraftScheduleGantt.test.tsx
Normal file
97
app/components/admin/__tests__/DraftScheduleGantt.test.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { DraftScheduleGantt, type GanttSport } from "../DraftScheduleGantt";
|
||||
|
||||
const nbaWindow = {
|
||||
id: "ss-1",
|
||||
name: "2026 NBA Playoffs",
|
||||
year: 2026,
|
||||
status: "upcoming" as const,
|
||||
draftOn: "2026-08-01",
|
||||
draftOff: "2026-09-15",
|
||||
sport: { id: "nba", name: "NBA", slug: "nba", iconUrl: null },
|
||||
};
|
||||
|
||||
function renderGantt(sports: GanttSport[], months = 6) {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<DraftScheduleGantt sports={sports} today="2026-07-02" months={months} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
describe("DraftScheduleGantt", () => {
|
||||
it("renders empty state when there are no sports", () => {
|
||||
renderGantt([]);
|
||||
expect(screen.getByText("No sports found.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a bar linking to the sport-season for each draft window", () => {
|
||||
renderGantt([
|
||||
{ id: "nba", name: "NBA", slug: "nba", iconUrl: null, windows: [nbaWindow] },
|
||||
]);
|
||||
|
||||
const bar = screen.getByRole("link", { name: /2026 NBA Playoffs/ });
|
||||
expect(bar).toHaveAttribute("href", "/admin/sports-seasons/ss-1");
|
||||
});
|
||||
|
||||
it("renders overlapping windows as separate, clickable bars", () => {
|
||||
const overlappingWindow = {
|
||||
id: "ss-2",
|
||||
name: "2027 NBA Playoffs",
|
||||
year: 2027,
|
||||
status: "active" as const,
|
||||
draftOn: "2026-08-15",
|
||||
draftOff: "2026-10-01",
|
||||
sport: { id: "nba", name: "NBA", slug: "nba", iconUrl: null },
|
||||
};
|
||||
|
||||
renderGantt([
|
||||
{
|
||||
id: "nba",
|
||||
name: "NBA",
|
||||
slug: "nba",
|
||||
iconUrl: null,
|
||||
windows: [nbaWindow, overlappingWindow],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
screen.getByRole("link", { name: /2026 NBA Playoffs/ })
|
||||
).toHaveAttribute("href", "/admin/sports-seasons/ss-1");
|
||||
expect(
|
||||
screen.getByRole("link", { name: /2027 NBA Playoffs/ })
|
||||
).toHaveAttribute("href", "/admin/sports-seasons/ss-2");
|
||||
});
|
||||
|
||||
it("shows a 'No draft window' hint for a sport with no windows", () => {
|
||||
renderGantt([
|
||||
{ id: "golf", name: "Golf", slug: "golf", iconUrl: null, windows: [] },
|
||||
]);
|
||||
|
||||
expect(screen.getByText("Golf")).toBeInTheDocument();
|
||||
expect(screen.getByText("No draft window")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the status legend", () => {
|
||||
renderGantt([
|
||||
{ id: "nba", name: "NBA", slug: "nba", iconUrl: null, windows: [nbaWindow] },
|
||||
]);
|
||||
|
||||
expect(screen.getByText("Active")).toBeInTheDocument();
|
||||
expect(screen.getByText("Upcoming")).toBeInTheDocument();
|
||||
expect(screen.getByText("Completed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reflects the horizon length in the description", () => {
|
||||
renderGantt(
|
||||
[{ id: "nba", name: "NBA", slug: "nba", iconUrl: null, windows: [nbaWindow] }],
|
||||
12
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(/Draft windows across the next 12 months/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -118,20 +118,23 @@ function ActiveRow({
|
|||
</div>
|
||||
{/* Stats — second row on mobile, right side on desktop */}
|
||||
{showStats && (
|
||||
<div className="flex items-center gap-4 border-t border-border/50 pt-2 sm:border-0 sm:pt-0 sm:shrink-0">
|
||||
<div className="flex items-start gap-4 border-t border-border/50 pt-2 sm:border-0 sm:pt-0 sm:shrink-0">
|
||||
{displayRank !== undefined && (
|
||||
<RankingDisplay
|
||||
displayRank={displayRank}
|
||||
rankChange={previousRank !== undefined && currentRank !== undefined && previousRank !== currentRank ? previousRank - currentRank : undefined}
|
||||
/>
|
||||
)}
|
||||
{currentRank !== undefined && totalPoints !== undefined && <StatDivider />}
|
||||
{currentRank !== undefined && totalPoints !== undefined && <StatDivider className="h-8" />}
|
||||
{totalPoints !== undefined && (
|
||||
<StatColumn label="Points">
|
||||
<span className="text-2xl font-bold leading-none text-electric">
|
||||
{Math.round(totalPoints).toLocaleString("en-US")}
|
||||
</span>
|
||||
</StatColumn>
|
||||
<StatColumn
|
||||
label="Points"
|
||||
value={
|
||||
<span className="text-2xl font-bold leading-none text-electric">
|
||||
{Math.round(totalPoints).toLocaleString("en-US")}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -169,17 +172,21 @@ function PreDraftRow({
|
|||
</div>
|
||||
{/* Stats — second row on mobile, right side on desktop */}
|
||||
<div className="flex items-center gap-4 border-t border-border/50 pt-2 sm:border-0 sm:pt-0 sm:shrink-0">
|
||||
<StatColumn label="Draft">
|
||||
<span className="text-2xl font-bold leading-none">{draftTimeValue}</span>
|
||||
</StatColumn>
|
||||
<StatColumn
|
||||
label="Draft"
|
||||
value={<span className="text-2xl font-bold leading-none">{draftTimeValue}</span>}
|
||||
/>
|
||||
{draftPosition !== undefined && (
|
||||
<>
|
||||
<StatDivider />
|
||||
<StatColumn label="Position">
|
||||
<span className="text-2xl font-bold leading-none">
|
||||
{ordinal(draftPosition)}
|
||||
</span>
|
||||
</StatColumn>
|
||||
<StatDivider className="h-8" />
|
||||
<StatColumn
|
||||
label="Position"
|
||||
value={
|
||||
<span className="text-2xl font-bold leading-none">
|
||||
{ordinal(draftPosition)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -137,6 +137,52 @@ export const FullLeague: Story = {
|
|||
},
|
||||
};
|
||||
|
||||
export const WithProjections: Story = {
|
||||
args: {
|
||||
showProjected: true,
|
||||
entries: [
|
||||
{
|
||||
teamId: "t1",
|
||||
teamName: "Lightning Wolves",
|
||||
ownerName: "alice",
|
||||
displayRank: 1,
|
||||
currentRank: 1,
|
||||
points: 2810,
|
||||
rankChange: 2,
|
||||
pointChange: 87.5,
|
||||
projectedPoints: 3120,
|
||||
participantsRemaining: 3,
|
||||
href: "/leagues/1/standings/1/teams/t1",
|
||||
},
|
||||
{
|
||||
teamId: "t2",
|
||||
teamName: "Shadow Hawks",
|
||||
ownerName: "bob",
|
||||
displayRank: 2,
|
||||
currentRank: 2,
|
||||
points: 2654.5,
|
||||
rankChange: -1,
|
||||
pointChange: 42.0,
|
||||
projectedPoints: 2980,
|
||||
participantsRemaining: 2,
|
||||
href: "/leagues/1/standings/1/teams/t2",
|
||||
},
|
||||
{
|
||||
teamId: "t3",
|
||||
teamName: "Iron Eagles",
|
||||
ownerName: "carol",
|
||||
displayRank: 3,
|
||||
currentRank: 3,
|
||||
points: 2493,
|
||||
// No 7-day change and no live participants — placeholders keep alignment.
|
||||
projectedPoints: 2493,
|
||||
participantsRemaining: 0,
|
||||
href: "/leagues/1/standings/1/teams/t3",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithTies: Story = {
|
||||
args: {
|
||||
entries: [
|
||||
|
|
|
|||
|
|
@ -5,7 +5,17 @@ import { Card, CardContent, CardHeader } from "~/components/ui/card";
|
|||
import { GradientIcon } from "~/components/ui/GradientIcon";
|
||||
import { TeamAvatar } from "~/components/TeamAvatar";
|
||||
import type { AvatarData, RawFlagConfig } from "~/lib/flag-types";
|
||||
import { StatColumn, StatDivider, PointChangeIndicator, RankingDisplay } from "./StatHelpers";
|
||||
import { StatColumn, StatDivider, DeltaBadge, RankingDisplay } from "./StatHelpers";
|
||||
|
||||
/** Mirrors PointsDisplay gating: only project while the team still has participants live. */
|
||||
function hasProjection(entry: StandingsPreviewEntry): boolean {
|
||||
return (
|
||||
entry.projectedPoints !== null &&
|
||||
entry.projectedPoints !== undefined &&
|
||||
entry.participantsRemaining !== undefined &&
|
||||
entry.participantsRemaining > 0
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Row styles ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -23,7 +33,20 @@ function rowClasses(currentRank: number | undefined, hasHref: boolean): string {
|
|||
|
||||
// ─── Row content ──────────────────────────────────────────────────────────────
|
||||
|
||||
function RowContent({ entry }: { entry: StandingsPreviewEntry }) {
|
||||
function RowContent({
|
||||
entry,
|
||||
showProjected,
|
||||
}: {
|
||||
entry: StandingsPreviewEntry;
|
||||
showProjected: boolean;
|
||||
}) {
|
||||
// Only reserve the delta line when this row actually changed. A row with no rank
|
||||
// or point movement drops the line entirely (no "—" placeholders); rows that moved
|
||||
// keep aligned columns by showing "—" for whichever stat didn't change.
|
||||
const rowHasChange =
|
||||
(entry.rankChange !== undefined && entry.rankChange !== 0) ||
|
||||
(entry.pointChange !== undefined && entry.pointChange !== 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Left: avatar + name */}
|
||||
|
|
@ -46,16 +69,44 @@ function RowContent({ entry }: { entry: StandingsPreviewEntry }) {
|
|||
|
||||
{/* Right: stats — second row on mobile */}
|
||||
<div className="flex items-center gap-4 w-full border-t border-border/30 pt-2 mt-1 sm:w-auto sm:border-0 sm:pt-0 sm:mt-0 sm:shrink-0">
|
||||
<RankingDisplay displayRank={entry.displayRank} rankChange={entry.rankChange} />
|
||||
<RankingDisplay
|
||||
displayRank={entry.displayRank}
|
||||
rankChange={entry.rankChange}
|
||||
reserveDelta={rowHasChange}
|
||||
/>
|
||||
{showProjected && <StatDivider />}
|
||||
{showProjected && (
|
||||
<StatColumn
|
||||
label="Projected"
|
||||
reserveDelta={rowHasChange}
|
||||
value={
|
||||
hasProjection(entry) ? (
|
||||
<span className="text-2xl font-normal leading-none text-muted-foreground">
|
||||
{Math.round(entry.projectedPoints as number).toLocaleString("en-US")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-2xl font-normal leading-none text-muted-foreground/40">
|
||||
—
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<StatDivider />
|
||||
<StatColumn label="Points">
|
||||
<span className="text-2xl font-bold leading-none text-electric">
|
||||
{Math.round(entry.points).toLocaleString("en-US")}
|
||||
</span>
|
||||
{entry.pointChange !== undefined && entry.pointChange !== 0 && (
|
||||
<PointChangeIndicator delta={entry.pointChange} />
|
||||
)}
|
||||
</StatColumn>
|
||||
<StatColumn
|
||||
label="Points"
|
||||
reserveDelta={rowHasChange}
|
||||
value={
|
||||
<span className="text-2xl font-bold leading-none text-electric">
|
||||
{Math.round(entry.points).toLocaleString("en-US")}
|
||||
</span>
|
||||
}
|
||||
delta={
|
||||
entry.pointChange !== undefined && entry.pointChange !== 0 ? (
|
||||
<DeltaBadge delta={entry.pointChange} kind="points" />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
@ -81,15 +132,26 @@ export interface StandingsPreviewEntry {
|
|||
rankChange?: number;
|
||||
/** 7-day point delta. From TeamStanding.sevenDayPointChange. */
|
||||
pointChange?: number;
|
||||
/** Projected final points. From TeamStanding.projectedPoints. */
|
||||
projectedPoints?: number | null;
|
||||
/** Live participants left; projection only shown while > 0. */
|
||||
participantsRemaining?: number;
|
||||
}
|
||||
|
||||
export interface StandingsPreviewProps {
|
||||
entries: StandingsPreviewEntry[];
|
||||
description?: string;
|
||||
fullStandingsHref?: string;
|
||||
/** When true, adds a "Projected" column (projected final points). Default false. */
|
||||
showProjected?: boolean;
|
||||
}
|
||||
|
||||
export function StandingsPreview({ entries, description, fullStandingsHref }: StandingsPreviewProps) {
|
||||
export function StandingsPreview({
|
||||
entries,
|
||||
description,
|
||||
fullStandingsHref,
|
||||
showProjected = false,
|
||||
}: StandingsPreviewProps) {
|
||||
return (
|
||||
<Card className="gap-2">
|
||||
<CardHeader className="px-3 sm:px-6 pb-2">
|
||||
|
|
@ -119,11 +181,11 @@ export function StandingsPreview({ entries, description, fullStandingsHref }: St
|
|||
to={entry.href}
|
||||
className={rowClasses(entry.currentRank, true)}
|
||||
>
|
||||
<RowContent entry={entry} />
|
||||
<RowContent entry={entry} showProjected={showProjected} />
|
||||
</Link>
|
||||
) : (
|
||||
<div key={entry.teamId} className={rowClasses(entry.currentRank, false)}>
|
||||
<RowContent entry={entry} />
|
||||
<RowContent entry={entry} showProjected={showProjected} />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,30 +1,71 @@
|
|||
/**
|
||||
* Stacked stat column: label / value / delta. The delta slot is always rendered
|
||||
* (a muted "—" placeholder when there is no change) so that every row reserves the
|
||||
* same vertical space and the columns line up across the standings table.
|
||||
*/
|
||||
export function StatColumn({
|
||||
label,
|
||||
children,
|
||||
value,
|
||||
delta,
|
||||
reserveDelta = false,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
value: React.ReactNode;
|
||||
/** Optional change indicator rendered on its own line below the value. */
|
||||
delta?: React.ReactNode;
|
||||
/**
|
||||
* When true, the delta line is always rendered (a muted placeholder when there is
|
||||
* no delta) so columns stay vertically aligned across rows. When false and no delta
|
||||
* is provided, the line is omitted entirely.
|
||||
*/
|
||||
reserveDelta?: boolean;
|
||||
}) {
|
||||
const showDeltaLine = delta !== undefined || reserveDelta;
|
||||
return (
|
||||
<div className="text-right shrink-0 flex-1 sm:flex-none">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
<div className="flex items-baseline justify-end gap-1">{children}</div>
|
||||
<div className="flex items-baseline justify-end">{value}</div>
|
||||
{showDeltaLine && (
|
||||
<div className="flex items-baseline justify-end leading-none mt-0.5">
|
||||
{delta ?? <span className="text-xs text-muted-foreground/60">—</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatDivider() {
|
||||
return <div className="h-8 w-px bg-border shrink-0 self-center" />;
|
||||
export function StatDivider({ className = "h-12" }: { className?: string } = {}) {
|
||||
return <div className={`${className} w-px bg-border shrink-0 self-center`} />;
|
||||
}
|
||||
|
||||
export function RankChangeIndicator({ delta }: { delta: number }) {
|
||||
if (delta === 0) return null;
|
||||
if (delta > 0) {
|
||||
/**
|
||||
* 7-day change indicator. `rank` deltas are unitless places; `points` deltas are
|
||||
* rounded point totals. Positive = up (green ▲), negative = down (coral ▼).
|
||||
*/
|
||||
export function DeltaBadge({
|
||||
delta,
|
||||
kind,
|
||||
}: {
|
||||
delta: number;
|
||||
kind: "rank" | "points";
|
||||
}) {
|
||||
const magnitude = kind === "rank" ? Math.abs(delta) : Math.abs(Math.round(delta));
|
||||
const up = delta > 0;
|
||||
const label =
|
||||
kind === "rank"
|
||||
? up
|
||||
? `up ${magnitude}`
|
||||
: `down ${magnitude}`
|
||||
: up
|
||||
? `+${magnitude} points`
|
||||
: `-${magnitude} points`;
|
||||
|
||||
if (up) {
|
||||
return (
|
||||
<span className="text-xs font-semibold text-primary" aria-label={`up ${delta}`}>
|
||||
▲{delta}
|
||||
<span className="text-xs font-semibold text-primary" aria-label={label}>
|
||||
▲{magnitude}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -32,9 +73,9 @@ export function RankChangeIndicator({ delta }: { delta: number }) {
|
|||
<span
|
||||
className="text-xs font-semibold"
|
||||
style={{ color: "var(--coral-accent, #ef4444)" }}
|
||||
aria-label={`down ${Math.abs(delta)}`}
|
||||
aria-label={label}
|
||||
>
|
||||
▼{Math.abs(delta)}
|
||||
▼{magnitude}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -42,35 +83,22 @@ export function RankChangeIndicator({ delta }: { delta: number }) {
|
|||
export function RankingDisplay({
|
||||
displayRank,
|
||||
rankChange,
|
||||
reserveDelta = false,
|
||||
}: {
|
||||
displayRank: string | number;
|
||||
rankChange?: number;
|
||||
reserveDelta?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<StatColumn label="Ranking">
|
||||
<span className="text-2xl font-bold leading-none">{displayRank}</span>
|
||||
{rankChange !== undefined && rankChange !== 0 && (
|
||||
<RankChangeIndicator delta={rankChange} />
|
||||
)}
|
||||
</StatColumn>
|
||||
);
|
||||
}
|
||||
|
||||
export function PointChangeIndicator({ delta }: { delta: number }) {
|
||||
if (delta >= 0) {
|
||||
return (
|
||||
<span className="text-xs font-semibold text-primary" aria-label={`+${Math.round(delta)} points`}>
|
||||
▲{Math.round(delta)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="text-xs font-semibold"
|
||||
style={{ color: "var(--coral-accent, #ef4444)" }}
|
||||
aria-label={`${Math.round(delta)} points`}
|
||||
>
|
||||
▼{Math.abs(Math.round(delta))}
|
||||
</span>
|
||||
<StatColumn
|
||||
label="Ranking"
|
||||
reserveDelta={reserveDelta}
|
||||
value={<span className="text-2xl font-bold leading-none">{displayRank}</span>}
|
||||
delta={
|
||||
rankChange !== undefined && rankChange !== 0 ? (
|
||||
<DeltaBadge delta={rankChange} kind="rank" />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,4 +103,54 @@ describe("StandingsPreview", () => {
|
|||
expect(link).toHaveAttribute("href", "/leagues/1/standings/1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Projections", () => {
|
||||
it("does not render a Projected column by default", () => {
|
||||
const entry: StandingsPreviewEntry = {
|
||||
...baseEntry,
|
||||
projectedPoints: 3000,
|
||||
participantsRemaining: 3,
|
||||
};
|
||||
renderWithRouter(<StandingsPreview entries={[entry]} />);
|
||||
|
||||
expect(screen.queryByText("Projected")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("3,000")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the projected value when showProjected and participants remain", () => {
|
||||
const entry: StandingsPreviewEntry = {
|
||||
...baseEntry,
|
||||
projectedPoints: 3000,
|
||||
participantsRemaining: 3,
|
||||
};
|
||||
renderWithRouter(<StandingsPreview entries={[entry]} showProjected />);
|
||||
|
||||
expect(screen.getByText("Projected")).toBeInTheDocument();
|
||||
expect(screen.getByText("3,000")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a placeholder instead of a value when no participants remain", () => {
|
||||
const entry: StandingsPreviewEntry = {
|
||||
...baseEntry,
|
||||
projectedPoints: 9999,
|
||||
participantsRemaining: 0,
|
||||
};
|
||||
renderWithRouter(<StandingsPreview entries={[entry]} showProjected />);
|
||||
|
||||
// Column header still present, but the projected total is not shown.
|
||||
expect(screen.getByText("Projected")).toBeInTheDocument();
|
||||
expect(screen.queryByText("9,999")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a placeholder when projectedPoints is missing", () => {
|
||||
const entry: StandingsPreviewEntry = {
|
||||
...baseEntry,
|
||||
projectedPoints: null,
|
||||
participantsRemaining: 3,
|
||||
};
|
||||
renderWithRouter(<StandingsPreview entries={[entry]} showProjected />);
|
||||
|
||||
expect(screen.getByText("Projected")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
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 = {
|
||||
|
|
@ -17,10 +18,37 @@ export type SettingsGridSection = SettingsNavSection & {
|
|||
export function SettingsMobileGridNav({
|
||||
sections,
|
||||
onSectionChange,
|
||||
buildHref,
|
||||
}: {
|
||||
sections: readonly SettingsGridSection[];
|
||||
onSectionChange: (sectionId: string) => void;
|
||||
onSectionChange?: (sectionId: string) => void;
|
||||
buildHref?: (sectionId: string) => string;
|
||||
}) {
|
||||
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">
|
||||
|
|
@ -29,32 +57,22 @@ export function SettingsMobileGridNav({
|
|||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{sections.map((section) => (
|
||||
<button
|
||||
key={section.id}
|
||||
type="button"
|
||||
onClick={() => onSectionChange(section.id)}
|
||||
className={cn(
|
||||
"flex cursor-pointer flex-col gap-3 rounded-xl border bg-card p-4 text-left transition-colors hover:border-primary/40 active:scale-[0.98]",
|
||||
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"
|
||||
)}
|
||||
{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)}
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
{cardInner(section)}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -62,19 +80,30 @@ export function SettingsMobileGridNav({
|
|||
|
||||
export function SettingsMobileSectionPill({
|
||||
onShowGrid,
|
||||
backHref,
|
||||
}: {
|
||||
onShowGrid: () => void;
|
||||
onShowGrid?: () => void;
|
||||
backHref?: string;
|
||||
}) {
|
||||
const className =
|
||||
"flex cursor-pointer items-center gap-1.5 rounded-full border bg-card px-3 py-1.5 text-sm font-medium hover:bg-muted";
|
||||
const content = (
|
||||
<>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Back to all settings
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<div className="mb-5 lg:hidden">
|
||||
<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>
|
||||
{backHref ? (
|
||||
<Link to={backHref} className={className}>
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<button type="button" onClick={onShowGrid} className={className}>
|
||||
{content}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -83,33 +112,57 @@ export function SettingsDesktopNav({
|
|||
sections,
|
||||
activeSection,
|
||||
onSectionChange,
|
||||
buildHref,
|
||||
navLabel = "League settings",
|
||||
}: {
|
||||
sections: readonly SettingsGridSection[];
|
||||
activeSection: string;
|
||||
onSectionChange: (sectionId: string) => void;
|
||||
onSectionChange?: (sectionId: string) => void;
|
||||
buildHref?: (sectionId: string) => string;
|
||||
navLabel?: string;
|
||||
}) {
|
||||
const itemClassName = (section: SettingsGridSection) =>
|
||||
cn(
|
||||
"flex w-full cursor-pointer items-center gap-2.5 rounded-md px-3 py-2 text-left text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
activeSection === section.id && "bg-muted text-foreground"
|
||||
);
|
||||
|
||||
const itemInner = (section: SettingsGridSection) => (
|
||||
<>
|
||||
<section.icon className={cn("h-4 w-4 shrink-0", section.isDanger && "text-destructive")} aria-hidden="true" />
|
||||
{section.label}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<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="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 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>
|
||||
</div>
|
||||
</aside>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { Bell, User } from "lucide-react";
|
||||
import {
|
||||
SettingsDesktopNav,
|
||||
SettingsMobileGridNav,
|
||||
SettingsMobileSectionPill,
|
||||
type SettingsGridSection,
|
||||
} from "../SettingsNavigation";
|
||||
|
||||
const sections: readonly SettingsGridSection[] = [
|
||||
{ id: "profile", label: "Profile", icon: User, subtitle: "Name, avatar" },
|
||||
{ id: "notifications", label: "Notifications", icon: Bell, subtitle: "Email & Discord" },
|
||||
];
|
||||
|
||||
describe("SettingsNavigation link mode", () => {
|
||||
it("renders desktop nav entries as anchors built from buildHref", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SettingsDesktopNav
|
||||
sections={sections}
|
||||
activeSection="profile"
|
||||
buildHref={(id) => `/settings/${id}`}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByRole("link", { name: "Profile" })).toHaveAttribute("href", "/settings/profile");
|
||||
expect(screen.getByRole("link", { name: "Notifications" })).toHaveAttribute(
|
||||
"href",
|
||||
"/settings/notifications"
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Profile" })).toHaveAttribute("aria-current", "page");
|
||||
});
|
||||
|
||||
it("renders the mobile grid as anchors and the back pill as an anchor", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SettingsMobileGridNav sections={sections} buildHref={(id) => `/settings/${id}`} />
|
||||
<SettingsMobileSectionPill backHref="/settings" />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByRole("link", { name: /Profile/i })).toHaveAttribute("href", "/settings/profile");
|
||||
expect(screen.getByRole("link", { name: /back to all settings/i })).toHaveAttribute(
|
||||
"href",
|
||||
"/settings"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsNavigation button mode (league settings)", () => {
|
||||
it("still fires onSectionChange when no buildHref is provided", () => {
|
||||
const onSectionChange = vi.fn();
|
||||
render(
|
||||
<SettingsDesktopNav
|
||||
sections={sections}
|
||||
activeSection="profile"
|
||||
onSectionChange={onSectionChange}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Notifications" }));
|
||||
expect(onSectionChange).toHaveBeenCalledWith("notifications");
|
||||
});
|
||||
});
|
||||
|
|
@ -13,7 +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 } from "~/lib/bracket-templates";
|
||||
import { getBracketTemplate, type BracketTemplate } from "~/lib/bracket-templates";
|
||||
import { NbaBracketLayout } from "./NbaBracketLayout";
|
||||
import { TabbedBracketLayout } from "./TabbedBracketLayout";
|
||||
|
||||
|
|
@ -174,6 +174,138 @@ 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.
|
||||
* Exported for unit testing.
|
||||
*/
|
||||
export function findConsolationRound(
|
||||
template: BracketTemplate | undefined
|
||||
): ConsolationRound | undefined {
|
||||
const feeder = template?.rounds.find((r) => r.loserFeedsInto);
|
||||
if (!feeder?.loserFeedsInto) return undefined;
|
||||
return { round: feeder.loserFeedsInto, feederRound: feeder.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>
|
||||
): 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;
|
||||
|
||||
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++) {
|
||||
|
|
@ -216,12 +348,10 @@ export function PlayoffBracket({
|
|||
const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds);
|
||||
const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined;
|
||||
|
||||
const thirdPlaceRound = template?.rounds
|
||||
.find((r) => template.rounds.some((other) => other.loserFeedsInto === r.name))
|
||||
?.name;
|
||||
const consolation = findConsolationRound(template);
|
||||
const thirdPlaceRound = consolation?.round;
|
||||
|
||||
// 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];
|
||||
|
|
@ -230,43 +360,19 @@ 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: 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 rankedEntries = computeRankedEntries(
|
||||
matches,
|
||||
rounds,
|
||||
matchesByRound,
|
||||
consolation,
|
||||
ownershipMap
|
||||
);
|
||||
|
||||
const rankedParticipantIds = new Set(rankedEntries.map((e) => e.participant.id));
|
||||
if (bracketWinner) rankedParticipantIds.add(bracketWinner.id);
|
||||
|
|
@ -284,7 +390,14 @@ export function PlayoffBracket({
|
|||
const activeParticipants = [...allBracketParticipantIds]
|
||||
.filter((id) => !rankedParticipantIds.has(id))
|
||||
.map((id) => participantMap.get(id))
|
||||
.filter((p): p is Participant => p !== undefined);
|
||||
.filter((p): p is Participant => p !== undefined)
|
||||
// Owned-by-a-manager players first, then alphabetical by name.
|
||||
.toSorted((a, b) => {
|
||||
const aOwned = ownershipMap.has(a.id);
|
||||
const bOwned = ownershipMap.has(b.id);
|
||||
if (aOwned !== bOwned) return aOwned ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
const isDraftedOrScoring = (participantId: string) =>
|
||||
ownershipMap.has(participantId) || (pointsMap.get(participantId) ?? 0) > 0;
|
||||
|
|
|
|||
|
|
@ -55,7 +55,9 @@ interface QualifyingPointsStandingsProps {
|
|||
function formatQP(raw: string): string {
|
||||
const n = parseFloat(raw);
|
||||
if (isNaN(n)) return "—";
|
||||
return n % 1 === 0 ? n.toString() : n.toFixed(2);
|
||||
// At most 2 decimals, trailing zeros trimmed (1.50→1.5). Mirrors the Discord
|
||||
// embed's formatQPValue (app/services/discord.ts) so the site and Discord agree.
|
||||
return parseFloat(n.toFixed(2)).toString();
|
||||
}
|
||||
|
||||
export function QualifyingPointsStandings({
|
||||
|
|
|
|||
|
|
@ -1,5 +1,15 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { buildFeederMap, groupMatchesByRound, computeEliminatedByRound } from "../PlayoffBracket";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import {
|
||||
PlayoffBracket,
|
||||
buildFeederMap,
|
||||
groupMatchesByRound,
|
||||
computeEliminatedByRound,
|
||||
computeRankedEntries,
|
||||
findConsolationRound,
|
||||
type Match,
|
||||
} from "../PlayoffBracket";
|
||||
import { getBracketTemplate } from "~/lib/bracket-templates";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
|
|
@ -291,3 +301,392 @@ 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;
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeRankedEntries", () => {
|
||||
describe("fifa_48 third place game", () => {
|
||||
it("ranks the third place game winner 3rd — they never lose a match after the SF", () => {
|
||||
// sfB lost the semifinal, then won the 3PG.
|
||||
expect(rankOf(rankFifa(), "sfB")).toBe("3");
|
||||
});
|
||||
|
||||
it("ranks the third place game loser 4th, not 3rd", () => {
|
||||
expect(rankOf(rankFifa(), "sfD")).toBe("4");
|
||||
});
|
||||
|
||||
it("gives quarterfinal losers T5 — the 3PG consumes no positions of its own", () => {
|
||||
const entries = rankFifa();
|
||||
expect(rankOf(entries, "qf1")).toBe("T5");
|
||||
expect(rankOf(entries, "qf2")).toBe("T5");
|
||||
expect(rankOf(entries, "qf3")).toBe("T5");
|
||||
expect(rankOf(entries, "qf4")).toBe("T5");
|
||||
});
|
||||
|
||||
it("ranks the finals loser 2nd and leaves the champion out of the list", () => {
|
||||
const entries = rankFifa();
|
||||
expect(rankOf(entries, "sfC")).toBe("T2");
|
||||
expect(rankOf(entries, "sfA")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("orders the list by rank: 2nd, 3rd, 4th, then the T5 tier", () => {
|
||||
expect(rankFifa().map((e) => e.rankLabel)).toEqual([
|
||||
"T2",
|
||||
"3",
|
||||
"4",
|
||||
"T5",
|
||||
"T5",
|
||||
"T5",
|
||||
"T5",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves semifinal losers unranked until the third place game is played", () => {
|
||||
const entries = rankFifa(fifaMatches({ played: false }));
|
||||
// Both SF losers are still alive for the 3PG.
|
||||
expect(rankOf(entries, "sfB")).toBeUndefined();
|
||||
expect(rankOf(entries, "sfD")).toBeUndefined();
|
||||
// QF losers are still T5 — the later rounds still consume their positions.
|
||||
expect(rankOf(entries, "qf1")).toBe("T5");
|
||||
});
|
||||
|
||||
it("carries ownership through onto the third place entries", () => {
|
||||
const ownership = new Map([
|
||||
["sfB", { participantId: "sfB", teamName: "Team Nine", teamId: "t9" }],
|
||||
]);
|
||||
const entries = rankFifa(fifaMatches(), ownership);
|
||||
expect(entries.find((e) => e.participant.id === "sfB")?.ownership?.teamName).toBe(
|
||||
"Team Nine"
|
||||
);
|
||||
expect(entries.find((e) => e.participant.id === "sfD")?.ownership).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("scores", () => {
|
||||
it("reads each participant's own score regardless of which slot they occupied", () => {
|
||||
const matches = fifaMatches({
|
||||
// Winner sits in slot 2 this time, so a slot-blind lookup would swap the scores.
|
||||
thirdPlace: makeRankedMatch("Third Place Game", 1, "sfB", "sfD", {
|
||||
winnerSlot: 2,
|
||||
winnerScore: "3",
|
||||
loserScore: "1",
|
||||
}),
|
||||
});
|
||||
const entries = rankFifa(matches);
|
||||
|
||||
expect(entries.find((e) => e.participant.id === "sfB")?.score).toBe("3");
|
||||
expect(entries.find((e) => e.participant.id === "sfD")?.score).toBe("1");
|
||||
});
|
||||
|
||||
it("reads a loser's score from the slot they actually played in", () => {
|
||||
const matches: Match[] = [
|
||||
makeRankedMatch("Semifinals", 1, "sfA", "sfB", {
|
||||
winnerSlot: 2,
|
||||
winnerScore: "4",
|
||||
loserScore: "2",
|
||||
}),
|
||||
];
|
||||
const entries = computeRankedEntries(
|
||||
matches,
|
||||
["Semifinals"],
|
||||
groupMatchesByRound(matches),
|
||||
undefined,
|
||||
new Map()
|
||||
);
|
||||
|
||||
expect(entries.find((e) => e.participant.id === "sfB")?.score).toBe("2");
|
||||
});
|
||||
|
||||
it("reports no score for a participant who occupies neither slot", () => {
|
||||
// A stale row after a bracket edit: loserId no longer matches either slot.
|
||||
// Attributing the other team's score here would look entirely plausible.
|
||||
const stale: Match = {
|
||||
...makeRankedMatch("Semifinals", 1, "sfA", "sfB", {
|
||||
winnerScore: "4",
|
||||
loserScore: "2",
|
||||
}),
|
||||
loserId: "ghost",
|
||||
loser: { id: "ghost", name: "ghost" },
|
||||
};
|
||||
const entries = computeRankedEntries(
|
||||
[stale],
|
||||
["Semifinals"],
|
||||
groupMatchesByRound([stale]),
|
||||
undefined,
|
||||
new Map()
|
||||
);
|
||||
|
||||
expect(entries.find((e) => e.participant.id === "ghost")?.score).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("a consolation round somewhere other than 3rd/4th", () => {
|
||||
// No template ships this today, but the positions must come from the feeder
|
||||
// round rather than being hardcoded to 3 and 4.
|
||||
const ROUNDS = ["Quarterfinals", "Semifinals", "Fifth Place Game", "Finals"];
|
||||
const CONSOLATION = { round: "Fifth Place Game", feederRound: "Quarterfinals" };
|
||||
|
||||
const matches: Match[] = [
|
||||
makeRankedMatch("Quarterfinals", 1, "sfA", "qf1"),
|
||||
makeRankedMatch("Quarterfinals", 2, "sfB", "qf2"),
|
||||
makeRankedMatch("Quarterfinals", 3, "sfC", "qf3"),
|
||||
makeRankedMatch("Quarterfinals", 4, "sfD", "qf4"),
|
||||
makeRankedMatch("Semifinals", 1, "sfA", "sfB"),
|
||||
makeRankedMatch("Semifinals", 2, "sfC", "sfD"),
|
||||
makeRankedMatch("Fifth Place Game", 1, "qf1", "qf2"),
|
||||
makeRankedMatch("Finals", 1, "sfA", "sfC"),
|
||||
];
|
||||
|
||||
it("places the consolation pair at its feeder round's tier, not at 3rd and 4th", () => {
|
||||
const entries = computeRankedEntries(
|
||||
matches,
|
||||
ROUNDS,
|
||||
groupMatchesByRound(matches),
|
||||
CONSOLATION,
|
||||
new Map()
|
||||
);
|
||||
|
||||
expect(rankOf(entries, "qf1")).toBe("5");
|
||||
expect(rankOf(entries, "qf2")).toBe("6");
|
||||
// The feeder round's other losers start below the pair, not alongside them.
|
||||
expect(rankOf(entries, "qf3")).toBe("T7");
|
||||
expect(rankOf(entries, "qf4")).toBe("T7");
|
||||
// The rounds above it are unaffected.
|
||||
expect(rankOf(entries, "sfC")).toBe("T2");
|
||||
expect(rankOf(entries, "sfB")).toBe("T3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("consolation matches that cannot be placed exactly", () => {
|
||||
it("still ranks the loser when the winner relation is missing", () => {
|
||||
const matches = fifaMatches({
|
||||
thirdPlace: makeRankedMatch("Third Place Game", 1, "sfB", "sfD", {
|
||||
missingWinnerRelation: true,
|
||||
}),
|
||||
});
|
||||
const entries = rankFifa(matches);
|
||||
|
||||
// The winner cannot be placed without a participant object, but the loser must
|
||||
// not silently vanish the way it would if the round were skipped wholesale.
|
||||
expect(rankOf(entries, "sfD")).toBeDefined();
|
||||
});
|
||||
|
||||
it("falls back to the loser-driven walk when the feeder round has no matches", () => {
|
||||
const matches: Match[] = [
|
||||
makeRankedMatch("Third Place Game", 1, "sfB", "sfD"),
|
||||
makeRankedMatch("Finals", 1, "sfA", "sfC"),
|
||||
];
|
||||
const entries = computeRankedEntries(
|
||||
matches,
|
||||
["Third Place Game", "Finals"],
|
||||
groupMatchesByRound(matches),
|
||||
FIFA_CONSOLATION,
|
||||
new Map()
|
||||
);
|
||||
|
||||
expect(rankOf(entries, "sfC")).toBe("T2");
|
||||
expect(rankOf(entries, "sfD")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("rendered output", () => {
|
||||
/** The card the 3PG winner was incorrectly appearing in. */
|
||||
function inContentionNames() {
|
||||
const card = screen.queryByText("In Contention")?.closest('[data-slot="card"]');
|
||||
if (!card) return [];
|
||||
return within(card as HTMLElement)
|
||||
.getAllByRole("row")
|
||||
.map((r) => r.textContent ?? "");
|
||||
}
|
||||
|
||||
it("does not list the third place game winner as in contention", () => {
|
||||
render(
|
||||
<PlayoffBracket matches={fifaMatches()} rounds={FIFA_ROUNDS} bracketTemplateId="fifa_48" />
|
||||
);
|
||||
|
||||
// sfB won the third place game — they are finished, not still playing.
|
||||
expect(inContentionNames().some((t) => t.includes("sfB"))).toBe(false);
|
||||
});
|
||||
|
||||
it("still lists semifinalists as in contention before the third place game", () => {
|
||||
render(
|
||||
<PlayoffBracket
|
||||
matches={fifaMatches({ played: false })}
|
||||
rounds={FIFA_ROUNDS}
|
||||
bracketTemplateId="fifa_48"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(inContentionNames().some((t) => t.includes("sfB"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("brackets without a consolation round", () => {
|
||||
const ROUNDS = ["Quarterfinals", "Semifinals", "Finals"];
|
||||
|
||||
it("ranks losers by round with tie labels, unchanged", () => {
|
||||
const matches: Match[] = [
|
||||
makeRankedMatch("Quarterfinals", 1, "sfA", "qf1"),
|
||||
makeRankedMatch("Quarterfinals", 2, "sfB", "qf2"),
|
||||
makeRankedMatch("Quarterfinals", 3, "sfC", "qf3"),
|
||||
makeRankedMatch("Quarterfinals", 4, "sfD", "qf4"),
|
||||
makeRankedMatch("Semifinals", 1, "sfA", "sfB"),
|
||||
makeRankedMatch("Semifinals", 2, "sfC", "sfD"),
|
||||
makeRankedMatch("Finals", 1, "sfA", "sfC"),
|
||||
];
|
||||
|
||||
const entries = computeRankedEntries(
|
||||
matches,
|
||||
ROUNDS,
|
||||
groupMatchesByRound(matches),
|
||||
undefined,
|
||||
new Map()
|
||||
);
|
||||
|
||||
expect(rankOf(entries, "sfC")).toBe("T2");
|
||||
expect(rankOf(entries, "sfB")).toBe("T3");
|
||||
expect(rankOf(entries, "sfD")).toBe("T3");
|
||||
expect(rankOf(entries, "qf1")).toBe("T5");
|
||||
expect(rankOf(entries, "sfA")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import type { GroupStandingsRow } from "~/models/group-stage-match";
|
||||
import { utcIsoToLocalDateTime } from "~/lib/date-utils";
|
||||
import { TeamOwnerBadge } from "~/components/ui/team-owner-badge";
|
||||
|
||||
interface GroupMatch {
|
||||
|
|
@ -100,6 +101,12 @@ export function GroupStageStandings({
|
|||
ownershipMap,
|
||||
showEmpty = false,
|
||||
}: GroupStageStandingsProps) {
|
||||
// SSR (and first client paint) groups/labels by UTC date for deterministic markup;
|
||||
// after hydration we switch to the viewer's local date so evening matches don't roll
|
||||
// into the wrong day header.
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
const visibleGroups = showEmpty
|
||||
? groups
|
||||
: groups.filter((g) => g.matches.some((m) => m.isComplete) || g.standings.length > 0);
|
||||
|
|
@ -186,25 +193,35 @@ export function GroupStageStandings({
|
|||
if (!b.scheduledAt) return -1;
|
||||
return new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime();
|
||||
});
|
||||
// Use the UTC date portion as a stable key so server and client always
|
||||
// produce the same Map structure regardless of the user's timezone.
|
||||
// Key by UTC date on SSR/first paint (deterministic); after mount, key by
|
||||
// the viewer's local date so the day headers match local wall-clock.
|
||||
const byDay = new Map<string, GroupMatch[]>();
|
||||
for (const m of sorted) {
|
||||
const key = m.scheduledAt ? m.scheduledAt.slice(0, 10) : "TBD";
|
||||
const key = m.scheduledAt
|
||||
? (mounted
|
||||
? utcIsoToLocalDateTime(m.scheduledAt).slice(0, 10)
|
||||
: m.scheduledAt.slice(0, 10))
|
||||
: "TBD";
|
||||
if (!byDay.has(key)) byDay.set(key, []);
|
||||
byDay.get(key)?.push(m);
|
||||
}
|
||||
return (
|
||||
<div className="px-4 pb-3 space-y-0.5 border-t pt-2">
|
||||
{[...byDay.entries()].map(([key, matches]) => {
|
||||
// Format from the UTC date string so label is identical on server and client.
|
||||
// Before mount the key is a UTC date (format in UTC); after mount it is
|
||||
// a local date (format in local time) so the label matches the grouping.
|
||||
const dayLabel = key === "TBD"
|
||||
? "TBD"
|
||||
: new Date(key + "T12:00:00Z").toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
: mounted
|
||||
? new Date(key + "T12:00:00").toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
: new Date(key + "T12:00:00Z").toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
return (
|
||||
<div key={key} className="space-y-0.5">
|
||||
<p className="text-[10px] text-muted-foreground/70 uppercase tracking-wide pt-1">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,15 @@
|
|||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { ArrowUp, ArrowDown, ArrowUpDown } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "~/components/ui/table";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
|
||||
|
|
@ -97,6 +105,54 @@ function sortPicks(
|
|||
});
|
||||
}
|
||||
|
||||
type Pick = TeamScoreBreakdownProps["breakdown"]["picks"][number];
|
||||
|
||||
const SORT_OPTIONS: Array<{ value: SortColumn; label: string }> = [
|
||||
{ value: "pick", label: "Pick #" },
|
||||
{ value: "sport", label: "Sport" },
|
||||
{ value: "participant", label: "Participant" },
|
||||
{ value: "points", label: "Points" },
|
||||
];
|
||||
|
||||
function pickLabel(pick: Pick, numTeams: number): string {
|
||||
return numTeams > 0
|
||||
? `${pick.round}.${String(pick.pickNumber - (pick.round - 1) * numTeams).padStart(2, "0")}`
|
||||
: `#${pick.pickNumber}`;
|
||||
}
|
||||
|
||||
/** Position badge shared by the desktop table and the mobile cards. */
|
||||
function PositionCell({ pick }: { pick: Pick }) {
|
||||
if (pick.isComplete && !pick.isPartialScore) {
|
||||
return (pick.finalPosition ?? 0) === 0 ? (
|
||||
<Badge variant="secondary">Did Not Score</Badge>
|
||||
) : (
|
||||
<PlacementBadge position={pick.finalPosition ?? 0} />
|
||||
);
|
||||
}
|
||||
return <Badge variant="outline">Pending</Badge>;
|
||||
}
|
||||
|
||||
/** Points value (actual, with projected underneath when incomplete). */
|
||||
function PointsValue({ pick }: { pick: Pick }) {
|
||||
if (pick.isComplete && !pick.isPartialScore) {
|
||||
return (
|
||||
<span className="font-semibold">
|
||||
{pick.points > 0 ? pick.points.toFixed(2) : "0.00"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="font-semibold">{pick.points.toFixed(2)}</span>
|
||||
{pick.projectedPoints !== null && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{pick.projectedPoints.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display detailed team score breakdown with all drafted participants
|
||||
* Phase 4.3: Team breakdown pages
|
||||
|
|
@ -130,17 +186,23 @@ export function TeamScoreBreakdown({
|
|||
}
|
||||
}
|
||||
|
||||
// Mobile sort control: picking a column applies its default direction.
|
||||
function handleSortColumn(column: SortColumn) {
|
||||
setSortColumn(column);
|
||||
setSortDirection(column === "points" ? "desc" : "asc");
|
||||
}
|
||||
|
||||
const allPicks = sortPicks(breakdown.picks, sortColumn, sortDirection);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">{breakdown.team.name}</h1>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold">{breakdown.team.name}</h1>
|
||||
<p className="text-muted-foreground mt-1">Team Score Breakdown</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-left sm:text-right">
|
||||
<div className="text-4xl font-bold text-primary">
|
||||
{breakdown.actualPoints.toFixed(2)}
|
||||
</div>
|
||||
|
|
@ -164,8 +226,8 @@ export function TeamScoreBreakdown({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* All picks — single flat table */}
|
||||
<Card>
|
||||
{/* All picks — desktop table */}
|
||||
<Card className="hidden md:block">
|
||||
<CardContent className="pt-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
|
|
@ -198,9 +260,7 @@ export function TeamScoreBreakdown({
|
|||
{allPicks.map((pick) => (
|
||||
<TableRow key={pick.pickNumber}>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{numTeams > 0
|
||||
? `${pick.round}.${String(pick.pickNumber - (pick.round - 1) * numTeams).padStart(2, "0")}`
|
||||
: `#${pick.pickNumber}`}
|
||||
{pickLabel(pick, numTeams)}
|
||||
<span className="text-xs ml-1">(#{pick.pickNumber})</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
|
@ -215,31 +275,10 @@ export function TeamScoreBreakdown({
|
|||
{pick.participant.name}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{pick.isComplete && !pick.isPartialScore ? (
|
||||
(pick.finalPosition ?? 0) === 0 ? (
|
||||
<Badge variant="secondary">Did Not Score</Badge>
|
||||
) : (
|
||||
<PlacementBadge position={pick.finalPosition ?? 0} />
|
||||
)
|
||||
) : (
|
||||
<Badge variant="outline">Pending</Badge>
|
||||
)}
|
||||
<PositionCell pick={pick} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{pick.isComplete && !pick.isPartialScore ? (
|
||||
<span className="font-semibold">
|
||||
{pick.points > 0 ? pick.points.toFixed(2) : "0.00"}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="font-semibold">{pick.points.toFixed(2)}</span>
|
||||
{pick.projectedPoints !== null && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{pick.projectedPoints.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<PointsValue pick={pick} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
|
@ -248,6 +287,70 @@ export function TeamScoreBreakdown({
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* All picks — mobile cards (no horizontal scroll, all fields visible) */}
|
||||
<div className="md:hidden space-y-3" data-testid="picks-mobile">
|
||||
{/* Sort control */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span id="picks-sort-label" className="text-sm text-muted-foreground shrink-0">
|
||||
Sort by
|
||||
</span>
|
||||
<Select
|
||||
value={sortColumn}
|
||||
onValueChange={(v) => handleSortColumn(v as SortColumn)}
|
||||
>
|
||||
<SelectTrigger className="flex-1" aria-labelledby="picks-sort-label">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setSortDirection(sortDirection === "asc" ? "desc" : "asc")}
|
||||
aria-label={`Sort ${sortDirection === "asc" ? "descending" : "ascending"}`}
|
||||
>
|
||||
{sortDirection === "asc" ? (
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{allPicks.map((pick) => (
|
||||
<Card key={pick.pickNumber} className="py-0" data-testid="pick-card">
|
||||
<CardContent className="p-3 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{pickLabel(pick, numTeams)}
|
||||
<span className="text-xs ml-1">(#{pick.pickNumber})</span>
|
||||
</div>
|
||||
<PositionCell pick={pick} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium break-words">{pick.participant.name}</div>
|
||||
<Link
|
||||
to={`/leagues/${leagueId}/sports-seasons/${pick.participant.sportsSeasonId}`}
|
||||
className="text-sm font-medium hover:underline text-primary"
|
||||
>
|
||||
{pick.participant.sport}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between border-t border-border/30 pt-2">
|
||||
<span className="text-sm text-muted-foreground">Points</span>
|
||||
<PointsValue pick={pick} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between pt-4">
|
||||
<Link
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { BrowserRouter } from "react-router";
|
||||
import { TeamScoreBreakdown } from "../TeamScoreBreakdown";
|
||||
|
|
@ -160,9 +160,10 @@ describe("TeamScoreBreakdown", () => {
|
|||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("1.01")).toBeInTheDocument();
|
||||
expect(screen.getByText("1.02")).toBeInTheDocument();
|
||||
expect(screen.getByText("2.01")).toBeInTheDocument();
|
||||
// Pick labels render in both the desktop table and mobile cards.
|
||||
expect(screen.getAllByText("1.01").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("1.02").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("2.01").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("should show overall pick number in parentheses", () => {
|
||||
|
|
@ -176,9 +177,9 @@ describe("TeamScoreBreakdown", () => {
|
|||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("(#1)")).toBeInTheDocument();
|
||||
expect(screen.getByText("(#2)")).toBeInTheDocument();
|
||||
expect(screen.getByText("(#3)")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("(#1)").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("(#2)").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("(#3)").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("should fall back to #pickNumber when numTeams is 0", () => {
|
||||
|
|
@ -192,9 +193,9 @@ describe("TeamScoreBreakdown", () => {
|
|||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("#1")).toBeInTheDocument();
|
||||
expect(screen.getByText("#2")).toBeInTheDocument();
|
||||
expect(screen.getByText("#3")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("#1").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("#2").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("#3").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -210,9 +211,10 @@ describe("TeamScoreBreakdown", () => {
|
|||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Team A")).toBeInTheDocument();
|
||||
expect(screen.getByText("Driver B")).toBeInTheDocument();
|
||||
expect(screen.getByText("Team C")).toBeInTheDocument();
|
||||
const table = within(screen.getByRole("table"));
|
||||
expect(table.getByText("Team A")).toBeInTheDocument();
|
||||
expect(table.getByText("Driver B")).toBeInTheDocument();
|
||||
expect(table.getByText("Team C")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show sport names as links to sport season pages", () => {
|
||||
|
|
@ -226,14 +228,15 @@ describe("TeamScoreBreakdown", () => {
|
|||
/>
|
||||
);
|
||||
|
||||
const nflLinks = screen.getAllByRole("link", { name: "NFL" });
|
||||
const table = within(screen.getByRole("table"));
|
||||
const nflLinks = table.getAllByRole("link", { name: "NFL" });
|
||||
expect(nflLinks.length).toBeGreaterThanOrEqual(1);
|
||||
expect(nflLinks[0]).toHaveAttribute(
|
||||
"href",
|
||||
`/leagues/${mockLeagueId}/sports-seasons/ss-nfl`
|
||||
);
|
||||
|
||||
const f1Link = screen.getByRole("link", { name: "F1" });
|
||||
const f1Link = table.getByRole("link", { name: "F1" });
|
||||
expect(f1Link).toHaveAttribute(
|
||||
"href",
|
||||
`/leagues/${mockLeagueId}/sports-seasons/ss-f1`
|
||||
|
|
@ -251,8 +254,9 @@ describe("TeamScoreBreakdown", () => {
|
|||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("1st")).toBeInTheDocument();
|
||||
expect(screen.getByText("3rd")).toBeInTheDocument();
|
||||
const table = within(screen.getByRole("table"));
|
||||
expect(table.getByText("1st")).toBeInTheDocument();
|
||||
expect(table.getByText("3rd")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show Pending badge for incomplete participants", () => {
|
||||
|
|
@ -266,7 +270,8 @@ describe("TeamScoreBreakdown", () => {
|
|||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Pending")).toBeInTheDocument();
|
||||
const table = within(screen.getByRole("table"));
|
||||
expect(table.getByText("Pending")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show Did Not Score badge when finalPosition is 0", () => {
|
||||
|
|
@ -291,7 +296,8 @@ describe("TeamScoreBreakdown", () => {
|
|||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Did Not Score")).toBeInTheDocument();
|
||||
const table = within(screen.getByRole("table"));
|
||||
expect(table.getByText("Did Not Score")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show Did Not Score badge when isComplete but finalPosition is null", () => {
|
||||
|
|
@ -318,7 +324,8 @@ describe("TeamScoreBreakdown", () => {
|
|||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Did Not Score")).toBeInTheDocument();
|
||||
const table = within(screen.getByRole("table"));
|
||||
expect(table.getByText("Did Not Score")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show projected EV for incomplete participants", () => {
|
||||
|
|
@ -333,8 +340,9 @@ describe("TeamScoreBreakdown", () => {
|
|||
);
|
||||
|
||||
// Incomplete participant shows 0.00 (actual) and 25.00 (EV) in the row
|
||||
expect(screen.getByText("0.00")).toBeInTheDocument();
|
||||
expect(screen.getByText("25.00")).toBeInTheDocument();
|
||||
const table = within(screen.getByRole("table"));
|
||||
expect(table.getByText("0.00")).toBeInTheDocument();
|
||||
expect(table.getByText("25.00")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display 0.00 for completed picks with zero points", () => {
|
||||
|
|
@ -353,7 +361,8 @@ describe("TeamScoreBreakdown", () => {
|
|||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("0.00")).toBeInTheDocument();
|
||||
const table = within(screen.getByRole("table"));
|
||||
expect(table.getByText("0.00")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -501,6 +510,96 @@ describe("TeamScoreBreakdown", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("Mobile view", () => {
|
||||
function getCardOrder() {
|
||||
const mobile = screen.getByTestId("picks-mobile");
|
||||
const cards = within(mobile).getAllByTestId("pick-card");
|
||||
return cards.map((card) =>
|
||||
["Team A", "Driver B", "Team C"].find((n) => card.textContent?.includes(n))
|
||||
);
|
||||
}
|
||||
|
||||
async function selectSortColumn(user: ReturnType<typeof userEvent.setup>, label: string) {
|
||||
await user.click(screen.getByRole("combobox", { name: /sort by/i }));
|
||||
await user.click(screen.getByRole("option", { name: label }));
|
||||
}
|
||||
|
||||
it("renders a card per pick with all fields visible", () => {
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
numTeams={numTeams}
|
||||
breakdown={mockBreakdown}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
);
|
||||
|
||||
const mobile = within(screen.getByTestId("picks-mobile"));
|
||||
expect(mobile.getAllByTestId("pick-card")).toHaveLength(3);
|
||||
// Participant names, sports, positions all visible without hiding data
|
||||
expect(mobile.getByText("Team A")).toBeInTheDocument();
|
||||
expect(mobile.getByText("Driver B")).toBeInTheDocument();
|
||||
expect(mobile.getByText("Team C")).toBeInTheDocument();
|
||||
expect(mobile.getByText("1st")).toBeInTheDocument();
|
||||
expect(mobile.getByText("Pending")).toBeInTheDocument();
|
||||
expect(mobile.getByRole("link", { name: "F1" })).toHaveAttribute(
|
||||
"href",
|
||||
`/leagues/${mockLeagueId}/sports-seasons/ss-f1`
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults to pick order", () => {
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
numTeams={numTeams}
|
||||
breakdown={mockBreakdown}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(getCardOrder()).toEqual(["Team A", "Driver B", "Team C"]);
|
||||
});
|
||||
|
||||
it("reorders cards when sorting by points (desc) via the select", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
numTeams={numTeams}
|
||||
breakdown={mockBreakdown}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
);
|
||||
|
||||
// picks: Team A=100, Driver B=50, Team C=0; points defaults to descending
|
||||
await selectSortColumn(user, "Points");
|
||||
expect(getCardOrder()).toEqual(["Team A", "Driver B", "Team C"]);
|
||||
});
|
||||
|
||||
it("toggles sort direction with the direction button", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
numTeams={numTeams}
|
||||
breakdown={mockBreakdown}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
);
|
||||
|
||||
await selectSortColumn(user, "Points"); // desc
|
||||
// While descending, the toggle's action is to sort ascending.
|
||||
await user.click(screen.getByRole("button", { name: /sort ascending/i }));
|
||||
// now ascending: Team C (0), Driver B (50), Team A (100)
|
||||
expect(getCardOrder()).toEqual(["Team C", "Driver B", "Team A"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Without Standing Data", () => {
|
||||
it("should render without standing prop", () => {
|
||||
renderWithRouter(
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export function AccountSection({ email, linkedAccounts }: Props) {
|
|||
setLinkingDiscord(true);
|
||||
setLinkError(null);
|
||||
try {
|
||||
await authClient.linkSocial({ provider: "discord", callbackURL: "/settings?section=account" });
|
||||
await authClient.linkSocial({ provider: "discord", callbackURL: "/settings/account" });
|
||||
} catch {
|
||||
setLinkError("Failed to connect Discord. Please try again.");
|
||||
setLinkingDiscord(false);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useFetcher } from "react-router";
|
||||
import { Link, useFetcher } from "react-router";
|
||||
import { Switch } from "~/components/ui/switch";
|
||||
import { Label } from "~/components/ui/label";
|
||||
|
||||
|
|
@ -14,10 +14,9 @@ type Props = {
|
|||
discordPingEnabled: boolean;
|
||||
hasDiscordLinked: boolean;
|
||||
draftEmailNotificationsEnabled: boolean;
|
||||
onNavigateToAccount: () => void;
|
||||
};
|
||||
|
||||
export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, draftEmailNotificationsEnabled, onNavigateToAccount }: Props) {
|
||||
export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, draftEmailNotificationsEnabled }: Props) {
|
||||
const discordFetcher = useFetcher();
|
||||
const emailFetcher = useFetcher();
|
||||
|
||||
|
|
@ -87,13 +86,12 @@ export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, dra
|
|||
{!hasDiscordLinked ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Link your Discord account in{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNavigateToAccount}
|
||||
<Link
|
||||
to="/settings/account"
|
||||
className="text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
Account settings
|
||||
</button>{" "}
|
||||
</Link>{" "}
|
||||
to enable Discord pings for league notifications.
|
||||
</p>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ describe("AccountSection", () => {
|
|||
await waitFor(() => {
|
||||
expect(mockLinkSocial).toHaveBeenCalledWith({
|
||||
provider: "discord",
|
||||
callbackURL: "/settings?section=account",
|
||||
callbackURL: "/settings/account",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { localDateTimeToUtcIso, toEventSortKey } from "../date-utils";
|
||||
import { localDateTimeToUtcIso, toEventSortKey, utcIsoToLocalDateTime } from "../date-utils";
|
||||
|
||||
describe("localDateTimeToUtcIso", () => {
|
||||
it("returns null for empty string", () => {
|
||||
|
|
@ -118,6 +118,46 @@ describe("event date display — UTC midnight rollover bug", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("utcIsoToLocalDateTime", () => {
|
||||
it("returns empty string for empty string", () => {
|
||||
expect(utcIsoToLocalDateTime("")).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for null", () => {
|
||||
expect(utcIsoToLocalDateTime(null)).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for undefined", () => {
|
||||
expect(utcIsoToLocalDateTime(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for an invalid date string", () => {
|
||||
expect(utcIsoToLocalDateTime("not-a-date")).toBe("");
|
||||
});
|
||||
|
||||
it("produces a datetime-local formatted string", () => {
|
||||
expect(utcIsoToLocalDateTime("2026-06-17T19:00:00.000Z")).toMatch(
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a Date instance", () => {
|
||||
const result = utcIsoToLocalDateTime(new Date("2026-06-17T19:00:00.000Z"));
|
||||
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
|
||||
});
|
||||
|
||||
it("round-trips with localDateTimeToUtcIso to the same instant", () => {
|
||||
// utcIsoToLocalDateTime renders a UTC instant in local time; feeding that
|
||||
// back through localDateTimeToUtcIso (which interprets local time) must
|
||||
// yield the original instant, regardless of the runtime timezone.
|
||||
const utc = "2026-06-17T19:00:00.000Z";
|
||||
const local = utcIsoToLocalDateTime(utc);
|
||||
const backToUtc = localDateTimeToUtcIso(local);
|
||||
expect(backToUtc).not.toBeNull();
|
||||
expect(new Date(backToUtc ?? "").getTime()).toBe(new Date(utc).getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe("toEventSortKey", () => {
|
||||
it("prefers earliestGameTime over eventDate", () => {
|
||||
const key = toEventSortKey({ eventDate: "2026-03-01", earliestGameTime: "2026-03-17T10:45:00.000Z" });
|
||||
|
|
|
|||
133
app/lib/__tests__/fifa-2026-bracket.test.ts
Normal file
133
app/lib/__tests__/fifa-2026-bracket.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
FIFA_2026_R32_TEMPLATE,
|
||||
THIRD_PLACE_SLOTS,
|
||||
assignThirdPlaceSlots,
|
||||
type R32Slot,
|
||||
} from "../fifa-2026-bracket";
|
||||
|
||||
const GROUPS = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L"];
|
||||
|
||||
describe("FIFA_2026_R32_TEMPLATE", () => {
|
||||
it("has 16 matches with unique DB match numbers 1–16", () => {
|
||||
expect(FIFA_2026_R32_TEMPLATE).toHaveLength(16);
|
||||
const dbNums = FIFA_2026_R32_TEMPLATE.map((m) => m.dbMatchNumber).toSorted((a, b) => a - b);
|
||||
expect(dbNums).toEqual(Array.from({ length: 16 }, (_, i) => i + 1));
|
||||
});
|
||||
|
||||
it("maps onto the official FIFA match numbers 73–88", () => {
|
||||
const fifaNums = FIFA_2026_R32_TEMPLATE.map((m) => m.fifaMatchNumber).toSorted((a, b) => a - b);
|
||||
expect(fifaNums).toEqual(Array.from({ length: 16 }, (_, i) => i + 73));
|
||||
});
|
||||
|
||||
it("uses each group winner and runner-up exactly once across the 32 slots", () => {
|
||||
const winners: string[] = [];
|
||||
const runnersUp: string[] = [];
|
||||
for (const m of FIFA_2026_R32_TEMPLATE) {
|
||||
for (const slot of [m.slot1, m.slot2] as R32Slot[]) {
|
||||
if (slot.kind === "winner") winners.push(slot.group);
|
||||
if (slot.kind === "runnerUp") runnersUp.push(slot.group);
|
||||
}
|
||||
}
|
||||
expect(winners.toSorted()).toEqual(GROUPS);
|
||||
expect(runnersUp.toSorted()).toEqual(GROUPS);
|
||||
});
|
||||
|
||||
it("has exactly 8 third-place slots", () => {
|
||||
expect(THIRD_PLACE_SLOTS).toHaveLength(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assignThirdPlaceSlots", () => {
|
||||
it("assigns all 8 qualifying thirds to distinct, eligible slots", () => {
|
||||
// Best-8 thirds come from groups A–H (a plausible qualifying set).
|
||||
const qualifying = ["A", "B", "C", "D", "E", "F", "G", "H"];
|
||||
const assignment = assignThirdPlaceSlots(qualifying);
|
||||
|
||||
expect(assignment.size).toBe(8);
|
||||
|
||||
// Every slot filled, each by an eligible & qualifying group, no group reused.
|
||||
const usedGroups = new Set<string>();
|
||||
for (const slot of THIRD_PLACE_SLOTS) {
|
||||
const group = assignment.get(slot.thirdSlotId) ?? "";
|
||||
expect(group).not.toBe("");
|
||||
expect(slot.eligibleGroups).toContain(group);
|
||||
expect(qualifying).toContain(group);
|
||||
expect(usedGroups.has(group)).toBe(false);
|
||||
usedGroups.add(group);
|
||||
}
|
||||
expect(usedGroups.size).toBe(8);
|
||||
});
|
||||
|
||||
it("produces a valid matching for every 8-of-12 qualifying combination", () => {
|
||||
// Exhaustively check all C(12,8) = 495 combinations resolve to a complete,
|
||||
// eligibility-respecting, conflict-free assignment.
|
||||
let combinations = 0;
|
||||
const combos = (start: number, chosen: string[]) => {
|
||||
if (chosen.length === 8) {
|
||||
combinations++;
|
||||
const assignment = assignThirdPlaceSlots(chosen);
|
||||
expect(assignment.size).toBe(8);
|
||||
const used = new Set(assignment.values());
|
||||
expect(used.size).toBe(8);
|
||||
for (const slot of THIRD_PLACE_SLOTS) {
|
||||
const group = assignment.get(slot.thirdSlotId);
|
||||
expect(slot.eligibleGroups).toContain(group);
|
||||
expect(chosen).toContain(group);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (let i = start; i < GROUPS.length; i++) {
|
||||
combos(i + 1, [...chosen, GROUPS[i]]);
|
||||
}
|
||||
};
|
||||
combos(0, []);
|
||||
expect(combinations).toBe(495);
|
||||
});
|
||||
|
||||
it("is deterministic for a given combination", () => {
|
||||
const qualifying = ["B", "D", "E", "F", "H", "I", "J", "L"];
|
||||
const a = assignThirdPlaceSlots(qualifying);
|
||||
const b = assignThirdPlaceSlots(qualifying.toReversed());
|
||||
expect([...a.entries()].toSorted()).toEqual([...b.entries()].toSorted());
|
||||
});
|
||||
|
||||
it("matches FIFA's exact Annex C allocation for option 1 (groups E–L qualify)", () => {
|
||||
// Published row 1: 1A vs 3E, 1B vs 3J, 1D vs 3I, 1E vs 3F,
|
||||
// 1G vs 3H, 1I vs 3G, 1K vs 3L, 1L vs 3K.
|
||||
// Mapped to DB third-slot ids (the match each winner plays in):
|
||||
const assignment = assignThirdPlaceSlots(["E", "F", "G", "H", "I", "J", "K", "L"]);
|
||||
expect(assignment.get(11)).toBe("E"); // winner A's match
|
||||
expect(assignment.get(15)).toBe("J"); // winner B's match
|
||||
expect(assignment.get(7)).toBe("I"); // winner D's match
|
||||
expect(assignment.get(1)).toBe("F"); // winner E's match
|
||||
expect(assignment.get(8)).toBe("H"); // winner G's match
|
||||
expect(assignment.get(2)).toBe("G"); // winner I's match
|
||||
expect(assignment.get(16)).toBe("L"); // winner K's match
|
||||
expect(assignment.get(12)).toBe("K"); // winner L's match
|
||||
});
|
||||
|
||||
it("template eligible-group lists exactly match the Annex C table", () => {
|
||||
// For every slot, collect the set of groups the published table ever assigns
|
||||
// to it across all 495 combinations, and assert it equals the hand-written
|
||||
// eligibleGroups list in the template.
|
||||
const observed = new Map<number, Set<string>>(
|
||||
THIRD_PLACE_SLOTS.map((s) => [s.thirdSlotId, new Set<string>()])
|
||||
);
|
||||
const combos = (start: number, chosen: string[]) => {
|
||||
if (chosen.length === 8) {
|
||||
const assignment = assignThirdPlaceSlots(chosen);
|
||||
for (const [slotId, group] of assignment) observed.get(slotId)?.add(group);
|
||||
return;
|
||||
}
|
||||
for (let i = start; i < GROUPS.length; i++) combos(i + 1, [...chosen, GROUPS[i]]);
|
||||
};
|
||||
combos(0, []);
|
||||
|
||||
for (const slot of THIRD_PLACE_SLOTS) {
|
||||
expect([...(observed.get(slot.thirdSlotId) ?? [])].toSorted()).toEqual(
|
||||
[...slot.eligibleGroups].toSorted()
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -14,6 +14,11 @@ describe("normalizeTeamName", () => {
|
|||
const name = "oklahoma city thunder";
|
||||
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", () => {
|
||||
|
|
@ -38,6 +43,12 @@ 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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -455,6 +455,31 @@ export const DARTS_128: BracketTemplate = {
|
|||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Tennis Grand Slam (128-player single-elimination draw)
|
||||
* R128 → R64 → R32 → Round of 16 → Quarterfinals → Semifinals → Final
|
||||
*
|
||||
* Qualifying-points sport: QP is derived from how far each player advances
|
||||
* (see TEMPLATE_ROUND_CONFIG["tennis_128"] in scoring-calculator.ts). Scoring
|
||||
* begins at the Round of 16 — R16 losers share placements 9–16; earlier rounds
|
||||
* award nothing. Seeding: top 32 fixed, remaining 96 randomly drawn.
|
||||
*/
|
||||
export const TENNIS_128: BracketTemplate = {
|
||||
id: "tennis_128",
|
||||
name: "Tennis Grand Slam (128 Players)",
|
||||
totalTeams: 128,
|
||||
scoringStartsAtRound: "Round of 16",
|
||||
rounds: [
|
||||
{ name: "Round of 128", matchCount: 64, feedsInto: "Round of 64", isScoring: false },
|
||||
{ name: "Round of 64", matchCount: 32, feedsInto: "Round of 32", isScoring: false },
|
||||
{ name: "Round of 32", matchCount: 16, feedsInto: "Round of 16", isScoring: false },
|
||||
{ name: "Round of 16", matchCount: 8, feedsInto: "Quarterfinals", isScoring: true }, // losers share 9th–16th
|
||||
{ name: "Quarterfinals", matchCount: 4, feedsInto: "Semifinals", isScoring: true }, // losers share 5th–8th
|
||||
{ name: "Semifinals", matchCount: 2, feedsInto: "Final", isScoring: true }, // losers share 3rd–4th
|
||||
{ name: "Final", matchCount: 1, feedsInto: null, isScoring: true }, // winner 1st, loser 2nd
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* NCAA March Madness (68 teams)
|
||||
* First Four (play-in) → Round of 64 → Round of 32 → Sweet Sixteen → Elite Eight → Final Four → Championship
|
||||
|
|
@ -923,6 +948,7 @@ export const BRACKET_TEMPLATES: Record<string, BracketTemplate> = {
|
|||
afl_10: AFL_10,
|
||||
fifa_48: FIFA_48,
|
||||
darts_128: DARTS_128,
|
||||
tennis_128: TENNIS_128,
|
||||
cfp_12: CFP_12,
|
||||
nba_20: NBA_20,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@
|
|||
*/
|
||||
import { format } from "date-fns";
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
/**
|
||||
* Format a YYYY-MM-DD date string for display (e.g. "Apr 9").
|
||||
* Returns null when the value is missing or unparseable — callers decide how to handle that case.
|
||||
|
|
@ -57,3 +59,28 @@ export function localDateTimeToUtcIso(value: string | null | undefined): string
|
|||
if (isNaN(date.getTime())) return null;
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a stored UTC value (ISO string or Date) to the "YYYY-MM-DDTHH:MM"
|
||||
* string a `datetime-local` input expects, expressed in the **browser's local**
|
||||
* timezone. This is the inverse of `localDateTimeToUtcIso`.
|
||||
*
|
||||
* Built from local date getters (not `toISOString`, which is UTC) so the input
|
||||
* shows the viewer's wall-clock time. Because it depends on the runtime
|
||||
* timezone, call it on the client (e.g. inside `useEffect`) to avoid SSR
|
||||
* hydration mismatches.
|
||||
*
|
||||
* Returns "" for nullish or unparseable input so it can be assigned directly to
|
||||
* an input value.
|
||||
*/
|
||||
export function utcIsoToLocalDateTime(value: string | Date | null | undefined): string {
|
||||
if (!value) return "";
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (isNaN(date.getTime())) return "";
|
||||
const year = date.getFullYear();
|
||||
const month = pad(date.getMonth() + 1);
|
||||
const day = pad(date.getDate());
|
||||
const hours = pad(date.getHours());
|
||||
const minutes = pad(date.getMinutes());
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
}
|
||||
|
|
|
|||
162
app/lib/fifa-2026-bracket.ts
Normal file
162
app/lib/fifa-2026-bracket.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
/**
|
||||
* Official 2026 FIFA World Cup knockout bracket structure (48-team format).
|
||||
*
|
||||
* Encodes how the 32 knockout qualifiers are seeded into the Round of 32 from
|
||||
* their group-stage finishing positions, so the simulator can reproduce the
|
||||
* real tournament draw *before* the admin has manually populated the bracket
|
||||
* (i.e. while the group stage is still being played).
|
||||
*
|
||||
* Two pieces:
|
||||
* (a) FIFA_2026_R32_TEMPLATE — the fixed group-position → slot mapping.
|
||||
* (b) assignThirdPlaceSlots() — which group's 3rd-place team fills each of the
|
||||
* eight "best third-place" slots, given which 8 of the 12 groups produced
|
||||
* a qualifying third.
|
||||
*
|
||||
* Bracket-tree note: the official FIFA match numbers (73–88 for the R32) do NOT
|
||||
* pair adjacently into the Round of 16 (e.g. R16 match 89 = winner of FIFA 74 vs
|
||||
* winner of FIFA 77). The codebase advances brackets with the canonical
|
||||
* `nextMatchNumber = ceil(matchNumber / 2)` rule (see `advanceWinnerTemplate` in
|
||||
* app/models/playoff-match.ts). To make that rule reproduce FIFA's real bracket
|
||||
* halves all the way to the final, the official matches are assigned to DB
|
||||
* matchNumbers 1–16 in the deliberate order below — verified through
|
||||
* R16 → QF → SF so both halves and the semifinal pairings match the real draw.
|
||||
*
|
||||
* Source: Wikipedia "2026 FIFA World Cup knockout stage" and
|
||||
* "Template:2026 FIFA World Cup third-place table" (FIFA regulations Annex C).
|
||||
*/
|
||||
|
||||
import {
|
||||
ANNEX_C_THIRD_PLACE_ALLOCATION,
|
||||
THIRD_PLACE_WINNER_COLUMNS,
|
||||
} from "./fifa-2026-third-place-allocation";
|
||||
|
||||
export type R32Slot =
|
||||
| { kind: "winner"; group: string }
|
||||
| { kind: "runnerUp"; group: string }
|
||||
/** thirdSlotId is the DB matchNumber this slot belongs to (each is unique). */
|
||||
| { kind: "third"; thirdSlotId: number; eligibleGroups: string[] };
|
||||
|
||||
export interface R32MatchSpec {
|
||||
/** 1–16; drives the codebase's ceil(n/2) bracket tree. */
|
||||
dbMatchNumber: number;
|
||||
/** Official FIFA match number (73–88); for traceability only. */
|
||||
fifaMatchNumber: number;
|
||||
slot1: R32Slot;
|
||||
slot2: R32Slot;
|
||||
}
|
||||
|
||||
const winner = (group: string): R32Slot => ({ kind: "winner", group });
|
||||
const runnerUp = (group: string): R32Slot => ({ kind: "runnerUp", group });
|
||||
const third = (dbMatchNumber: number, eligibleGroups: string[]): R32Slot => ({
|
||||
kind: "third",
|
||||
thirdSlotId: dbMatchNumber,
|
||||
eligibleGroups,
|
||||
});
|
||||
|
||||
export const FIFA_2026_R32_TEMPLATE: R32MatchSpec[] = [
|
||||
{ dbMatchNumber: 1, fifaMatchNumber: 74, slot1: winner("E"), slot2: third(1, ["A", "B", "C", "D", "F"]) },
|
||||
{ dbMatchNumber: 2, fifaMatchNumber: 77, slot1: winner("I"), slot2: third(2, ["C", "D", "F", "G", "H"]) },
|
||||
{ dbMatchNumber: 3, fifaMatchNumber: 73, slot1: runnerUp("A"), slot2: runnerUp("B") },
|
||||
{ dbMatchNumber: 4, fifaMatchNumber: 75, slot1: winner("F"), slot2: runnerUp("C") },
|
||||
{ dbMatchNumber: 5, fifaMatchNumber: 83, slot1: runnerUp("K"), slot2: runnerUp("L") },
|
||||
{ dbMatchNumber: 6, fifaMatchNumber: 84, slot1: winner("H"), slot2: runnerUp("J") },
|
||||
{ dbMatchNumber: 7, fifaMatchNumber: 81, slot1: winner("D"), slot2: third(7, ["B", "E", "F", "I", "J"]) },
|
||||
{ dbMatchNumber: 8, fifaMatchNumber: 82, slot1: winner("G"), slot2: third(8, ["A", "E", "H", "I", "J"]) },
|
||||
{ dbMatchNumber: 9, fifaMatchNumber: 76, slot1: winner("C"), slot2: runnerUp("F") },
|
||||
{ dbMatchNumber: 10, fifaMatchNumber: 78, slot1: runnerUp("E"), slot2: runnerUp("I") },
|
||||
{ dbMatchNumber: 11, fifaMatchNumber: 79, slot1: winner("A"), slot2: third(11, ["C", "E", "F", "H", "I"]) },
|
||||
{ dbMatchNumber: 12, fifaMatchNumber: 80, slot1: winner("L"), slot2: third(12, ["E", "H", "I", "J", "K"]) },
|
||||
{ dbMatchNumber: 13, fifaMatchNumber: 86, slot1: winner("J"), slot2: runnerUp("H") },
|
||||
{ dbMatchNumber: 14, fifaMatchNumber: 88, slot1: runnerUp("D"), slot2: runnerUp("G") },
|
||||
{ dbMatchNumber: 15, fifaMatchNumber: 85, slot1: winner("B"), slot2: third(15, ["E", "F", "G", "I", "J"]) },
|
||||
{ dbMatchNumber: 16, fifaMatchNumber: 87, slot1: winner("K"), slot2: third(16, ["D", "E", "I", "J", "L"]) },
|
||||
];
|
||||
|
||||
/** The eight third-place slots (DB matchNumber → eligible source groups). */
|
||||
export const THIRD_PLACE_SLOTS: Array<{ thirdSlotId: number; eligibleGroups: string[] }> =
|
||||
FIFA_2026_R32_TEMPLATE.flatMap((m) =>
|
||||
[m.slot1, m.slot2].filter((s): s is Extract<R32Slot, { kind: "third" }> => s.kind === "third")
|
||||
).map((s) => ({ thirdSlotId: s.thirdSlotId, eligibleGroups: s.eligibleGroups }));
|
||||
|
||||
/**
|
||||
* For each third-place slot, the group winner it faces — derived from the
|
||||
* template (a third slot always shares its match with a winner slot). This is
|
||||
* how the Annex C columns (keyed by group winner) map onto our DB slots.
|
||||
*/
|
||||
const WINNER_BY_THIRD_SLOT = new Map<number, string>(
|
||||
FIFA_2026_R32_TEMPLATE.flatMap((m) => {
|
||||
const slots = [m.slot1, m.slot2];
|
||||
const thirdSlot = slots.find((s) => s.kind === "third");
|
||||
const winnerSlot = slots.find((s) => s.kind === "winner");
|
||||
return thirdSlot && winnerSlot
|
||||
? [[thirdSlot.thirdSlotId, winnerSlot.group] as [number, string]]
|
||||
: [];
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Assign each qualifying third-place group to its third-place slot, using FIFA's
|
||||
* exact published Annex C allocation (495-row lookup). The lookup value lists the
|
||||
* third-place group facing each group winner in the column order
|
||||
* [1A, 1B, 1D, 1E, 1G, 1I, 1K, 1L]; we map each onto the slot that faces that
|
||||
* winner.
|
||||
*
|
||||
* Returns a map of thirdSlotId (DB matchNumber) → group letter. Falls back to a
|
||||
* constrained matching only for non-standard inputs (e.g. fewer than 8 thirds in
|
||||
* degraded data) where no Annex C row exists.
|
||||
*/
|
||||
export function assignThirdPlaceSlots(qualifyingGroups: string[]): Map<number, string> {
|
||||
const groups = qualifyingGroups.map((g) => g.toUpperCase());
|
||||
const key = groups.toSorted().join("");
|
||||
const allocation = ANNEX_C_THIRD_PLACE_ALLOCATION[key];
|
||||
|
||||
const assignment = new Map<number, string>();
|
||||
if (allocation && allocation.length === THIRD_PLACE_WINNER_COLUMNS.length) {
|
||||
THIRD_PLACE_WINNER_COLUMNS.forEach((winnerGroup, i) => {
|
||||
const thirdGroup = allocation[i];
|
||||
const slotId = [...WINNER_BY_THIRD_SLOT].find(([, w]) => w === winnerGroup)?.[0];
|
||||
if (slotId !== undefined) assignment.set(slotId, thirdGroup);
|
||||
});
|
||||
return assignment;
|
||||
}
|
||||
|
||||
return assignThirdPlaceSlotsByMatching(groups);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constrained-matching fallback used only when no Annex C row applies (non-8
|
||||
* qualifying groups). A perfect, eligibility-respecting matching is found via
|
||||
* backtracking so the simulation still completes deterministically.
|
||||
*/
|
||||
function assignThirdPlaceSlotsByMatching(groups: string[]): Map<number, string> {
|
||||
const slots = THIRD_PLACE_SLOTS.map((s) => ({
|
||||
thirdSlotId: s.thirdSlotId,
|
||||
options: groups.filter((g) => s.eligibleGroups.includes(g)),
|
||||
})).toSorted((a, b) => a.options.length - b.options.length || a.thirdSlotId - b.thirdSlotId);
|
||||
|
||||
const assignment = new Map<number, string>();
|
||||
const used = new Set<string>();
|
||||
|
||||
const backtrack = (i: number): boolean => {
|
||||
if (i === slots.length) return true;
|
||||
for (const group of slots[i].options) {
|
||||
if (used.has(group)) continue;
|
||||
assignment.set(slots[i].thirdSlotId, group);
|
||||
used.add(group);
|
||||
if (backtrack(i + 1)) return true;
|
||||
used.delete(group);
|
||||
assignment.delete(slots[i].thirdSlotId);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (backtrack(0)) return assignment;
|
||||
|
||||
const leftover = groups.filter((g) => !used.has(g));
|
||||
for (const s of THIRD_PLACE_SLOTS) {
|
||||
if (assignment.has(s.thirdSlotId)) continue;
|
||||
const g = leftover.shift();
|
||||
if (g) assignment.set(s.thirdSlotId, g);
|
||||
}
|
||||
return assignment;
|
||||
}
|
||||
506
app/lib/fifa-2026-third-place-allocation.ts
Normal file
506
app/lib/fifa-2026-third-place-allocation.ts
Normal file
|
|
@ -0,0 +1,506 @@
|
|||
// AUTO-GENERATED from FIFA 2026 World Cup Annex C third-place allocation.
|
||||
// Source: Wikipedia "Template:2026 FIFA World Cup third-place table".
|
||||
// Do not edit by hand — regenerate with scripts/gen-fifa-third-place.mjs.
|
||||
//
|
||||
// Key: the eight qualifying third-place group letters, sorted (e.g. "EFGHIJKL").
|
||||
// Value: the qualifying third-place group facing each group winner, in the
|
||||
// column order [1A, 1B, 1D, 1E, 1G, 1I, 1K, 1L].
|
||||
export const THIRD_PLACE_WINNER_COLUMNS = ["A", "B", "D", "E", "G", "I", "K", "L"] as const;
|
||||
|
||||
export const ANNEX_C_THIRD_PLACE_ALLOCATION: Record<string, string> = {
|
||||
ABCDEFGH: "HGBCAFDE",
|
||||
ABCDEFGI: "CGBDAFEI",
|
||||
ABCDEFGJ: "CGBDAFEJ",
|
||||
ABCDEFGK: "CGBDAFEK",
|
||||
ABCDEFGL: "CGBDAFLE",
|
||||
ABCDEFHI: "HEBCAFDI",
|
||||
ABCDEFHJ: "HJBCAFDE",
|
||||
ABCDEFHK: "HEBCAFDK",
|
||||
ABCDEFHL: "HFBCADLE",
|
||||
ABCDEFIJ: "CJBDAFEI",
|
||||
ABCDEFIK: "CEBDAFIK",
|
||||
ABCDEFIL: "CEBDAFLI",
|
||||
ABCDEFJK: "CJBDAFEK",
|
||||
ABCDEFJL: "CJBDAFLE",
|
||||
ABCDEFKL: "CEBDAFLK",
|
||||
ABCDEGHI: "HGBCADEI",
|
||||
ABCDEGHJ: "HGBCADEJ",
|
||||
ABCDEGHK: "HGBCADEK",
|
||||
ABCDEGHL: "HGBCADLE",
|
||||
ABCDEGIJ: "EGBCADIJ",
|
||||
ABCDEGIK: "EGBCADIK",
|
||||
ABCDEGIL: "EGBCADLI",
|
||||
ABCDEGJK: "EGBCADJK",
|
||||
ABCDEGJL: "EGBCADLJ",
|
||||
ABCDEGKL: "EGBCADLK",
|
||||
ABCDEHIJ: "HJBCADEI",
|
||||
ABCDEHIK: "HEBCADIK",
|
||||
ABCDEHIL: "HEBCADLI",
|
||||
ABCDEHJK: "HJBCADEK",
|
||||
ABCDEHJL: "HJBCADLE",
|
||||
ABCDEHKL: "HEBCADLK",
|
||||
ABCDEIJK: "EJBCADIK",
|
||||
ABCDEIJL: "EJBCADLI",
|
||||
ABCDEIKL: "EIBCADLK",
|
||||
ABCDEJKL: "EJBCADLK",
|
||||
ABCDFGHI: "HGBCAFDI",
|
||||
ABCDFGHJ: "HGBCAFDJ",
|
||||
ABCDFGHK: "HGBCAFDK",
|
||||
ABCDFGHL: "CGBDAFLH",
|
||||
ABCDFGIJ: "CGBDAFIJ",
|
||||
ABCDFGIK: "CGBDAFIK",
|
||||
ABCDFGIL: "CGBDAFLI",
|
||||
ABCDFGJK: "CGBDAFJK",
|
||||
ABCDFGJL: "CGBDAFLJ",
|
||||
ABCDFGKL: "CGBDAFLK",
|
||||
ABCDFHIJ: "HJBCAFDI",
|
||||
ABCDFHIK: "HFBCADIK",
|
||||
ABCDFHIL: "HFBCADLI",
|
||||
ABCDFHJK: "HJBCAFDK",
|
||||
ABCDFHJL: "CJBDAFLH",
|
||||
ABCDFHKL: "HFBCADLK",
|
||||
ABCDFIJK: "CJBDAFIK",
|
||||
ABCDFIJL: "CJBDAFLI",
|
||||
ABCDFIKL: "CIBDAFLK",
|
||||
ABCDFJKL: "CJBDAFLK",
|
||||
ABCDGHIJ: "HGBCADIJ",
|
||||
ABCDGHIK: "HGBCADIK",
|
||||
ABCDGHIL: "HGBCADLI",
|
||||
ABCDGHJK: "HGBCADJK",
|
||||
ABCDGHJL: "HGBCADLJ",
|
||||
ABCDGHKL: "HGBCADLK",
|
||||
ABCDGIJK: "CJBDAGIK",
|
||||
ABCDGIJL: "CJBDAGLI",
|
||||
ABCDGIKL: "IGBCADLK",
|
||||
ABCDGJKL: "CJBDAGLK",
|
||||
ABCDHIJK: "HJBCADIK",
|
||||
ABCDHIJL: "HJBCADLI",
|
||||
ABCDHIKL: "HIBCADLK",
|
||||
ABCDHJKL: "HJBCADLK",
|
||||
ABCDIJKL: "IJBCADLK",
|
||||
ABCEFGHI: "HGBCAFEI",
|
||||
ABCEFGHJ: "HGBCAFEJ",
|
||||
ABCEFGHK: "HGBCAFEK",
|
||||
ABCEFGHL: "HGBCAFLE",
|
||||
ABCEFGIJ: "EGBCAFIJ",
|
||||
ABCEFGIK: "EGBCAFIK",
|
||||
ABCEFGIL: "EGBCAFLI",
|
||||
ABCEFGJK: "EGBCAFJK",
|
||||
ABCEFGJL: "EGBCAFLJ",
|
||||
ABCEFGKL: "EGBCAFLK",
|
||||
ABCEFHIJ: "HJBCAFEI",
|
||||
ABCEFHIK: "HEBCAFIK",
|
||||
ABCEFHIL: "HEBCAFLI",
|
||||
ABCEFHJK: "HJBCAFEK",
|
||||
ABCEFHJL: "HJBCAFLE",
|
||||
ABCEFHKL: "HEBCAFLK",
|
||||
ABCEFIJK: "EJBCAFIK",
|
||||
ABCEFIJL: "EJBCAFLI",
|
||||
ABCEFIKL: "EIBCAFLK",
|
||||
ABCEFJKL: "EJBCAFLK",
|
||||
ABCEGHIJ: "HJBCAGEI",
|
||||
ABCEGHIK: "EGBCAHIK",
|
||||
ABCEGHIL: "EGBCAHLI",
|
||||
ABCEGHJK: "HJBCAGEK",
|
||||
ABCEGHJL: "HJBCAGLE",
|
||||
ABCEGHKL: "EGBCAHLK",
|
||||
ABCEGIJK: "EJBCAGIK",
|
||||
ABCEGIJL: "EJBCAGLI",
|
||||
ABCEGIKL: "EGBAICLK",
|
||||
ABCEGJKL: "EJBCAGLK",
|
||||
ABCEHIJK: "EJBCAHIK",
|
||||
ABCEHIJL: "EJBCAHLI",
|
||||
ABCEHIKL: "EIBCAHLK",
|
||||
ABCEHJKL: "EJBCAHLK",
|
||||
ABCEIJKL: "EJBAICLK",
|
||||
ABCFGHIJ: "HGBCAFIJ",
|
||||
ABCFGHIK: "HGBCAFIK",
|
||||
ABCFGHIL: "HGBCAFLI",
|
||||
ABCFGHJK: "HGBCAFJK",
|
||||
ABCFGHJL: "HGBCAFLJ",
|
||||
ABCFGHKL: "HGBCAFLK",
|
||||
ABCFGIJK: "CJBFAGIK",
|
||||
ABCFGIJL: "CJBFAGLI",
|
||||
ABCFGIKL: "IGBCAFLK",
|
||||
ABCFGJKL: "CJBFAGLK",
|
||||
ABCFHIJK: "HJBCAFIK",
|
||||
ABCFHIJL: "HJBCAFLI",
|
||||
ABCFHIKL: "HIBCAFLK",
|
||||
ABCFHJKL: "HJBCAFLK",
|
||||
ABCFIJKL: "IJBCAFLK",
|
||||
ABCGHIJK: "HJBCAGIK",
|
||||
ABCGHIJL: "HJBCAGLI",
|
||||
ABCGHIKL: "IGBCAHLK",
|
||||
ABCGHJKL: "HJBCAGLK",
|
||||
ABCGIJKL: "IJBCAGLK",
|
||||
ABCHIJKL: "IJBCAHLK",
|
||||
ABDEFGHI: "HGBDAFEI",
|
||||
ABDEFGHJ: "HGBDAFEJ",
|
||||
ABDEFGHK: "HGBDAFEK",
|
||||
ABDEFGHL: "HGBDAFLE",
|
||||
ABDEFGIJ: "EGBDAFIJ",
|
||||
ABDEFGIK: "EGBDAFIK",
|
||||
ABDEFGIL: "EGBDAFLI",
|
||||
ABDEFGJK: "EGBDAFJK",
|
||||
ABDEFGJL: "EGBDAFLJ",
|
||||
ABDEFGKL: "EGBDAFLK",
|
||||
ABDEFHIJ: "HJBDAFEI",
|
||||
ABDEFHIK: "HEBDAFIK",
|
||||
ABDEFHIL: "HEBDAFLI",
|
||||
ABDEFHJK: "HJBDAFEK",
|
||||
ABDEFHJL: "HJBDAFLE",
|
||||
ABDEFHKL: "HEBDAFLK",
|
||||
ABDEFIJK: "EJBDAFIK",
|
||||
ABDEFIJL: "EJBDAFLI",
|
||||
ABDEFIKL: "EIBDAFLK",
|
||||
ABDEFJKL: "EJBDAFLK",
|
||||
ABDEGHIJ: "HJBDAGEI",
|
||||
ABDEGHIK: "EGBDAHIK",
|
||||
ABDEGHIL: "EGBDAHLI",
|
||||
ABDEGHJK: "HJBDAGEK",
|
||||
ABDEGHJL: "HJBDAGLE",
|
||||
ABDEGHKL: "EGBDAHLK",
|
||||
ABDEGIJK: "EJBDAGIK",
|
||||
ABDEGIJL: "EJBDAGLI",
|
||||
ABDEGIKL: "EGBAIDLK",
|
||||
ABDEGJKL: "EJBDAGLK",
|
||||
ABDEHIJK: "EJBDAHIK",
|
||||
ABDEHIJL: "EJBDAHLI",
|
||||
ABDEHIKL: "EIBDAHLK",
|
||||
ABDEHJKL: "EJBDAHLK",
|
||||
ABDEIJKL: "EJBAIDLK",
|
||||
ABDFGHIJ: "HGBDAFIJ",
|
||||
ABDFGHIK: "HGBDAFIK",
|
||||
ABDFGHIL: "HGBDAFLI",
|
||||
ABDFGHJK: "HGBDAFJK",
|
||||
ABDFGHJL: "HGBDAFLJ",
|
||||
ABDFGHKL: "HGBDAFLK",
|
||||
ABDFGIJK: "FJBDAGIK",
|
||||
ABDFGIJL: "FJBDAGLI",
|
||||
ABDFGIKL: "IGBDAFLK",
|
||||
ABDFGJKL: "FJBDAGLK",
|
||||
ABDFHIJK: "HJBDAFIK",
|
||||
ABDFHIJL: "HJBDAFLI",
|
||||
ABDFHIKL: "HIBDAFLK",
|
||||
ABDFHJKL: "HJBDAFLK",
|
||||
ABDFIJKL: "IJBDAFLK",
|
||||
ABDGHIJK: "HJBDAGIK",
|
||||
ABDGHIJL: "HJBDAGLI",
|
||||
ABDGHIKL: "IGBDAHLK",
|
||||
ABDGHJKL: "HJBDAGLK",
|
||||
ABDGIJKL: "IJBDAGLK",
|
||||
ABDHIJKL: "IJBDAHLK",
|
||||
ABEFGHIJ: "HJBFAGEI",
|
||||
ABEFGHIK: "EGBFAHIK",
|
||||
ABEFGHIL: "EGBFAHLI",
|
||||
ABEFGHJK: "HJBFAGEK",
|
||||
ABEFGHJL: "HJBFAGLE",
|
||||
ABEFGHKL: "EGBFAHLK",
|
||||
ABEFGIJK: "EJBFAGIK",
|
||||
ABEFGIJL: "EJBFAGLI",
|
||||
ABEFGIKL: "EGBAIFLK",
|
||||
ABEFGJKL: "EJBFAGLK",
|
||||
ABEFHIJK: "EJBFAHIK",
|
||||
ABEFHIJL: "EJBFAHLI",
|
||||
ABEFHIKL: "EIBFAHLK",
|
||||
ABEFHJKL: "EJBFAHLK",
|
||||
ABEFIJKL: "EJBAIFLK",
|
||||
ABEGHIJK: "EJBAHGIK",
|
||||
ABEGHIJL: "EJBAHGLI",
|
||||
ABEGHIKL: "EGBAIHLK",
|
||||
ABEGHJKL: "EJBAHGLK",
|
||||
ABEGIJKL: "EJBAIGLK",
|
||||
ABEHIJKL: "EJBAIHLK",
|
||||
ABFGHIJK: "HJBFAGIK",
|
||||
ABFGHIJL: "HJBFAGLI",
|
||||
ABFGHIKL: "HGBAIFLK",
|
||||
ABFGHJKL: "HJBFAGLK",
|
||||
ABFGIJKL: "IJBFAGLK",
|
||||
ABFHIJKL: "HJBAIFLK",
|
||||
ABGHIJKL: "HJBAIGLK",
|
||||
ACDEFGHI: "HGECAFDI",
|
||||
ACDEFGHJ: "HGJCAFDE",
|
||||
ACDEFGHK: "HGECAFDK",
|
||||
ACDEFGHL: "HGFCADLE",
|
||||
ACDEFGIJ: "CGJDAFEI",
|
||||
ACDEFGIK: "CGEDAFIK",
|
||||
ACDEFGIL: "CGEDAFLI",
|
||||
ACDEFGJK: "CGJDAFEK",
|
||||
ACDEFGJL: "CGJDAFLE",
|
||||
ACDEFGKL: "CGEDAFLK",
|
||||
ACDEFHIJ: "HJECAFDI",
|
||||
ACDEFHIK: "HEFCADIK",
|
||||
ACDEFHIL: "HEFCADLI",
|
||||
ACDEFHJK: "HJECAFDK",
|
||||
ACDEFHJL: "HJFCADLE",
|
||||
ACDEFHKL: "HEFCADLK",
|
||||
ACDEFIJK: "CJEDAFIK",
|
||||
ACDEFIJL: "CJEDAFLI",
|
||||
ACDEFIKL: "CEIDAFLK",
|
||||
ACDEFJKL: "CJEDAFLK",
|
||||
ACDEGHIJ: "HGJCADEI",
|
||||
ACDEGHIK: "HGECADIK",
|
||||
ACDEGHIL: "HGECADLI",
|
||||
ACDEGHJK: "HGJCADEK",
|
||||
ACDEGHJL: "HGJCADLE",
|
||||
ACDEGHKL: "HGECADLK",
|
||||
ACDEGIJK: "EGJCADIK",
|
||||
ACDEGIJL: "EGJCADLI",
|
||||
ACDEGIKL: "EGICADLK",
|
||||
ACDEGJKL: "EGJCADLK",
|
||||
ACDEHIJK: "HJECADIK",
|
||||
ACDEHIJL: "HJECADLI",
|
||||
ACDEHIKL: "HEICADLK",
|
||||
ACDEHJKL: "HJECADLK",
|
||||
ACDEIJKL: "EJICADLK",
|
||||
ACDFGHIJ: "HGJCAFDI",
|
||||
ACDFGHIK: "HGFCADIK",
|
||||
ACDFGHIL: "HGFCADLI",
|
||||
ACDFGHJK: "HGJCAFDK",
|
||||
ACDFGHJL: "CGJDAFLH",
|
||||
ACDFGHKL: "HGFCADLK",
|
||||
ACDFGIJK: "CGJDAFIK",
|
||||
ACDFGIJL: "CGJDAFLI",
|
||||
ACDFGIKL: "CGIDAFLK",
|
||||
ACDFGJKL: "CGJDAFLK",
|
||||
ACDFHIJK: "HJFCADIK",
|
||||
ACDFHIJL: "HJFCADLI",
|
||||
ACDFHIKL: "HFICADLK",
|
||||
ACDFHJKL: "HJFCADLK",
|
||||
ACDFIJKL: "CJIDAFLK",
|
||||
ACDGHIJK: "HGJCADIK",
|
||||
ACDGHIJL: "HGJCADLI",
|
||||
ACDGHIKL: "HGICADLK",
|
||||
ACDGHJKL: "HGJCADLK",
|
||||
ACDGIJKL: "IGJCADLK",
|
||||
ACDHIJKL: "HJICADLK",
|
||||
ACEFGHIJ: "HGJCAFEI",
|
||||
ACEFGHIK: "HGECAFIK",
|
||||
ACEFGHIL: "HGECAFLI",
|
||||
ACEFGHJK: "HGJCAFEK",
|
||||
ACEFGHJL: "HGJCAFLE",
|
||||
ACEFGHKL: "HGECAFLK",
|
||||
ACEFGIJK: "EGJCAFIK",
|
||||
ACEFGIJL: "EGJCAFLI",
|
||||
ACEFGIKL: "EGICAFLK",
|
||||
ACEFGJKL: "EGJCAFLK",
|
||||
ACEFHIJK: "HJECAFIK",
|
||||
ACEFHIJL: "HJECAFLI",
|
||||
ACEFHIKL: "HEICAFLK",
|
||||
ACEFHJKL: "HJECAFLK",
|
||||
ACEFIJKL: "EJICAFLK",
|
||||
ACEGHIJK: "EGJCAHIK",
|
||||
ACEGHIJL: "EGJCAHLI",
|
||||
ACEGHIKL: "EGICAHLK",
|
||||
ACEGHJKL: "EGJCAHLK",
|
||||
ACEGIJKL: "EJICAGLK",
|
||||
ACEHIJKL: "EJICAHLK",
|
||||
ACFGHIJK: "HGJCAFIK",
|
||||
ACFGHIJL: "HGJCAFLI",
|
||||
ACFGHIKL: "HGICAFLK",
|
||||
ACFGHJKL: "HGJCAFLK",
|
||||
ACFGIJKL: "IGJCAFLK",
|
||||
ACFHIJKL: "HJICAFLK",
|
||||
ACGHIJKL: "HJICAGLK",
|
||||
ADEFGHIJ: "HGJDAFEI",
|
||||
ADEFGHIK: "HGEDAFIK",
|
||||
ADEFGHIL: "HGEDAFLI",
|
||||
ADEFGHJK: "HGJDAFEK",
|
||||
ADEFGHJL: "HGJDAFLE",
|
||||
ADEFGHKL: "HGEDAFLK",
|
||||
ADEFGIJK: "EGJDAFIK",
|
||||
ADEFGIJL: "EGJDAFLI",
|
||||
ADEFGIKL: "EGIDAFLK",
|
||||
ADEFGJKL: "EGJDAFLK",
|
||||
ADEFHIJK: "HJEDAFIK",
|
||||
ADEFHIJL: "HJEDAFLI",
|
||||
ADEFHIKL: "HEIDAFLK",
|
||||
ADEFHJKL: "HJEDAFLK",
|
||||
ADEFIJKL: "EJIDAFLK",
|
||||
ADEGHIJK: "EGJDAHIK",
|
||||
ADEGHIJL: "EGJDAHLI",
|
||||
ADEGHIKL: "EGIDAHLK",
|
||||
ADEGHJKL: "EGJDAHLK",
|
||||
ADEGIJKL: "EJIDAGLK",
|
||||
ADEHIJKL: "EJIDAHLK",
|
||||
ADFGHIJK: "HGJDAFIK",
|
||||
ADFGHIJL: "HGJDAFLI",
|
||||
ADFGHIKL: "HGIDAFLK",
|
||||
ADFGHJKL: "HGJDAFLK",
|
||||
ADFGIJKL: "IGJDAFLK",
|
||||
ADFHIJKL: "HJIDAFLK",
|
||||
ADGHIJKL: "HJIDAGLK",
|
||||
AEFGHIJK: "EGJFAHIK",
|
||||
AEFGHIJL: "EGJFAHLI",
|
||||
AEFGHIKL: "EGIFAHLK",
|
||||
AEFGHJKL: "EGJFAHLK",
|
||||
AEFGIJKL: "EJIFAGLK",
|
||||
AEFHIJKL: "EJIFAHLK",
|
||||
AEGHIJKL: "EJIAHGLK",
|
||||
AFGHIJKL: "HJIFAGLK",
|
||||
BCDEFGHI: "CGBDHFEI",
|
||||
BCDEFGHJ: "HGBCJFDE",
|
||||
BCDEFGHK: "CGBDHFEK",
|
||||
BCDEFGHL: "CGBDHFLE",
|
||||
BCDEFGIJ: "CGBDJFEI",
|
||||
BCDEFGIK: "CGBDEFIK",
|
||||
BCDEFGIL: "CGBDEFLI",
|
||||
BCDEFGJK: "CGBDJFEK",
|
||||
BCDEFGJL: "CGBDJFLE",
|
||||
BCDEFGKL: "CGBDEFLK",
|
||||
BCDEFHIJ: "CJBDHFEI",
|
||||
BCDEFHIK: "CEBDHFIK",
|
||||
BCDEFHIL: "CEBDHFLI",
|
||||
BCDEFHJK: "CJBDHFEK",
|
||||
BCDEFHJL: "CJBDHFLE",
|
||||
BCDEFHKL: "CEBDHFLK",
|
||||
BCDEFIJK: "CJBDEFIK",
|
||||
BCDEFIJL: "CJBDEFLI",
|
||||
BCDEFIKL: "CEBDIFLK",
|
||||
BCDEFJKL: "CJBDEFLK",
|
||||
BCDEGHIJ: "HGBCJDEI",
|
||||
BCDEGHIK: "EGBCHDIK",
|
||||
BCDEGHIL: "EGBCHDLI",
|
||||
BCDEGHJK: "HGBCJDEK",
|
||||
BCDEGHJL: "HGBCJDLE",
|
||||
BCDEGHKL: "EGBCHDLK",
|
||||
BCDEGIJK: "EGBCJDIK",
|
||||
BCDEGIJL: "EGBCJDLI",
|
||||
BCDEGIKL: "EGBCIDLK",
|
||||
BCDEGJKL: "EGBCJDLK",
|
||||
BCDEHIJK: "EJBCHDIK",
|
||||
BCDEHIJL: "EJBCHDLI",
|
||||
BCDEHIKL: "EIBCHDLK",
|
||||
BCDEHJKL: "EJBCHDLK",
|
||||
BCDEIJKL: "EJBCIDLK",
|
||||
BCDFGHIJ: "HGBCJFDI",
|
||||
BCDFGHIK: "CGBDHFIK",
|
||||
BCDFGHIL: "CGBDHFLI",
|
||||
BCDFGHJK: "HGBCJFDK",
|
||||
BCDFGHJL: "CGBDHFLJ",
|
||||
BCDFGHKL: "CGBDHFLK",
|
||||
BCDFGIJK: "CGBDJFIK",
|
||||
BCDFGIJL: "CGBDJFLI",
|
||||
BCDFGIKL: "CGBDIFLK",
|
||||
BCDFGJKL: "CGBDJFLK",
|
||||
BCDFHIJK: "CJBDHFIK",
|
||||
BCDFHIJL: "CJBDHFLI",
|
||||
BCDFHIKL: "CIBDHFLK",
|
||||
BCDFHJKL: "CJBDHFLK",
|
||||
BCDFIJKL: "CJBDIFLK",
|
||||
BCDGHIJK: "HGBCJDIK",
|
||||
BCDGHIJL: "HGBCJDLI",
|
||||
BCDGHIKL: "HGBCIDLK",
|
||||
BCDGHJKL: "HGBCJDLK",
|
||||
BCDGIJKL: "IGBCJDLK",
|
||||
BCDHIJKL: "HJBCIDLK",
|
||||
BCEFGHIJ: "HGBCJFEI",
|
||||
BCEFGHIK: "EGBCHFIK",
|
||||
BCEFGHIL: "EGBCHFLI",
|
||||
BCEFGHJK: "HGBCJFEK",
|
||||
BCEFGHJL: "HGBCJFLE",
|
||||
BCEFGHKL: "EGBCHFLK",
|
||||
BCEFGIJK: "EGBCJFIK",
|
||||
BCEFGIJL: "EGBCJFLI",
|
||||
BCEFGIKL: "EGBCIFLK",
|
||||
BCEFGJKL: "EGBCJFLK",
|
||||
BCEFHIJK: "EJBCHFIK",
|
||||
BCEFHIJL: "EJBCHFLI",
|
||||
BCEFHIKL: "EIBCHFLK",
|
||||
BCEFHJKL: "EJBCHFLK",
|
||||
BCEFIJKL: "EJBCIFLK",
|
||||
BCEGHIJK: "EJBCHGIK",
|
||||
BCEGHIJL: "EJBCHGLI",
|
||||
BCEGHIKL: "EGBCIHLK",
|
||||
BCEGHJKL: "EJBCHGLK",
|
||||
BCEGIJKL: "EJBCIGLK",
|
||||
BCEHIJKL: "EJBCIHLK",
|
||||
BCFGHIJK: "HGBCJFIK",
|
||||
BCFGHIJL: "HGBCJFLI",
|
||||
BCFGHIKL: "HGBCIFLK",
|
||||
BCFGHJKL: "HGBCJFLK",
|
||||
BCFGIJKL: "IGBCJFLK",
|
||||
BCFHIJKL: "HJBCIFLK",
|
||||
BCGHIJKL: "HJBCIGLK",
|
||||
BDEFGHIJ: "HGBDJFEI",
|
||||
BDEFGHIK: "EGBDHFIK",
|
||||
BDEFGHIL: "EGBDHFLI",
|
||||
BDEFGHJK: "HGBDJFEK",
|
||||
BDEFGHJL: "HGBDJFLE",
|
||||
BDEFGHKL: "EGBDHFLK",
|
||||
BDEFGIJK: "EGBDJFIK",
|
||||
BDEFGIJL: "EGBDJFLI",
|
||||
BDEFGIKL: "EGBDIFLK",
|
||||
BDEFGJKL: "EGBDJFLK",
|
||||
BDEFHIJK: "EJBDHFIK",
|
||||
BDEFHIJL: "EJBDHFLI",
|
||||
BDEFHIKL: "EIBDHFLK",
|
||||
BDEFHJKL: "EJBDHFLK",
|
||||
BDEFIJKL: "EJBDIFLK",
|
||||
BDEGHIJK: "EJBDHGIK",
|
||||
BDEGHIJL: "EJBDHGLI",
|
||||
BDEGHIKL: "EGBDIHLK",
|
||||
BDEGHJKL: "EJBDHGLK",
|
||||
BDEGIJKL: "EJBDIGLK",
|
||||
BDEHIJKL: "EJBDIHLK",
|
||||
BDFGHIJK: "HGBDJFIK",
|
||||
BDFGHIJL: "HGBDJFLI",
|
||||
BDFGHIKL: "HGBDIFLK",
|
||||
BDFGHJKL: "HGBDJFLK",
|
||||
BDFGIJKL: "IGBDJFLK",
|
||||
BDFHIJKL: "HJBDIFLK",
|
||||
BDGHIJKL: "HJBDIGLK",
|
||||
BEFGHIJK: "EJBFHGIK",
|
||||
BEFGHIJL: "EJBFHGLI",
|
||||
BEFGHIKL: "EGBFIHLK",
|
||||
BEFGHJKL: "EJBFHGLK",
|
||||
BEFGIJKL: "EJBFIGLK",
|
||||
BEFHIJKL: "EJBFIHLK",
|
||||
BEGHIJKL: "EJIBHGLK",
|
||||
BFGHIJKL: "HJBFIGLK",
|
||||
CDEFGHIJ: "CGJDHFEI",
|
||||
CDEFGHIK: "CGEDHFIK",
|
||||
CDEFGHIL: "CGEDHFLI",
|
||||
CDEFGHJK: "CGJDHFEK",
|
||||
CDEFGHJL: "CGJDHFLE",
|
||||
CDEFGHKL: "CGEDHFLK",
|
||||
CDEFGIJK: "CGEDJFIK",
|
||||
CDEFGIJL: "CGEDJFLI",
|
||||
CDEFGIKL: "CGEDIFLK",
|
||||
CDEFGJKL: "CGEDJFLK",
|
||||
CDEFHIJK: "CJEDHFIK",
|
||||
CDEFHIJL: "CJEDHFLI",
|
||||
CDEFHIKL: "CEIDHFLK",
|
||||
CDEFHJKL: "CJEDHFLK",
|
||||
CDEFIJKL: "CJEDIFLK",
|
||||
CDEGHIJK: "EGJCHDIK",
|
||||
CDEGHIJL: "EGJCHDLI",
|
||||
CDEGHIKL: "EGICHDLK",
|
||||
CDEGHJKL: "EGJCHDLK",
|
||||
CDEGIJKL: "EGICJDLK",
|
||||
CDEHIJKL: "EJICHDLK",
|
||||
CDFGHIJK: "CGJDHFIK",
|
||||
CDFGHIJL: "CGJDHFLI",
|
||||
CDFGHIKL: "CGIDHFLK",
|
||||
CDFGHJKL: "CGJDHFLK",
|
||||
CDFGIJKL: "CGIDJFLK",
|
||||
CDFHIJKL: "CJIDHFLK",
|
||||
CDGHIJKL: "HGICJDLK",
|
||||
CEFGHIJK: "EGJCHFIK",
|
||||
CEFGHIJL: "EGJCHFLI",
|
||||
CEFGHIKL: "EGICHFLK",
|
||||
CEFGHJKL: "EGJCHFLK",
|
||||
CEFGIJKL: "EGICJFLK",
|
||||
CEFHIJKL: "EJICHFLK",
|
||||
CEGHIJKL: "EJICHGLK",
|
||||
CFGHIJKL: "HGICJFLK",
|
||||
DEFGHIJK: "EGJDHFIK",
|
||||
DEFGHIJL: "EGJDHFLI",
|
||||
DEFGHIKL: "EGIDHFLK",
|
||||
DEFGHJKL: "EGJDHFLK",
|
||||
DEFGIJKL: "EGIDJFLK",
|
||||
DEFHIJKL: "EJIDHFLK",
|
||||
DEGHIJKL: "EJIDHGLK",
|
||||
DFGHIJKL: "HGIDJFLK",
|
||||
EFGHIJKL: "EJIFHGLK",
|
||||
};
|
||||
|
|
@ -1,9 +1,15 @@
|
|||
/**
|
||||
* Normalize a team name for fuzzy matching across data sources.
|
||||
* Lowercases, trims, and collapses whitespace.
|
||||
* 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.
|
||||
*/
|
||||
export function normalizeTeamName(name: string): string {
|
||||
return name.toLowerCase().trim().replace(/\s+/g, " ");
|
||||
return name
|
||||
.normalize("NFKD")
|
||||
.replace(/[̀-ͯ]/g, "") // strip combining diacritical marks
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,6 +1,17 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { computeStage3ExitQP } from "../cs2-major-stage";
|
||||
import { calculateSplitQualifyingPoints } from "../qualifying-points";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
// Stub only the cached-total recompute so resetCs2Event can be exercised without
|
||||
// a real DB; the rest of qualifying-points stays real for the pure-function tests.
|
||||
vi.mock("../qualifying-points", async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
recalculateParticipantQP: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { computeStage3ExitQP, resetCs2Event } from "../cs2-major-stage";
|
||||
import { calculateSplitQualifyingPoints, recalculateParticipantQP } from "../qualifying-points";
|
||||
|
||||
function makeStage3QPConfig(): Map<number, number> {
|
||||
const config = new Map<number, number>();
|
||||
|
|
@ -257,3 +268,57 @@ describe("Champions Stage floor QP calculation", () => {
|
|||
expect(floorQP).toBeCloseTo((10 + 8 + 6 + 4) / 4); // = 7
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetCs2Event", () => {
|
||||
/**
|
||||
* Build a mock db whose `transaction` runs its callback with the same handle
|
||||
* (so the deletes + recalcs run inline), capturing which tables were deleted.
|
||||
* The eventResults SELECT returns `resultRows`.
|
||||
*/
|
||||
function makeMockDb(resultRows: Array<{ participantId: string }>) {
|
||||
const deletedTables: unknown[] = [];
|
||||
const db: Record<string, unknown> = {
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({ where: vi.fn().mockResolvedValue(resultRows) })),
|
||||
})),
|
||||
delete: vi.fn((table: unknown) => {
|
||||
deletedTables.push(table);
|
||||
return { where: vi.fn().mockResolvedValue(undefined) };
|
||||
}),
|
||||
};
|
||||
db.transaction = vi.fn(async (cb: (tx: unknown) => unknown) => cb(db));
|
||||
return { db, deletedTables };
|
||||
}
|
||||
|
||||
it("clears assignments + results and recomputes each distinct participant's QP total", async () => {
|
||||
vi.mocked(recalculateParticipantQP).mockClear();
|
||||
// Two participants, one duplicated across rows → recompute exactly twice.
|
||||
const { db, deletedTables } = makeMockDb([
|
||||
{ participantId: "p1" },
|
||||
{ participantId: "p1" },
|
||||
{ participantId: "p2" },
|
||||
]);
|
||||
|
||||
await resetCs2Event("event-1", "season-1", db as never);
|
||||
|
||||
// Both the stage-results table and the event-results table were deleted,
|
||||
// inside the transaction.
|
||||
expect((db as Record<string, ReturnType<typeof vi.fn>>).transaction).toHaveBeenCalledTimes(1);
|
||||
expect(deletedTables).toHaveLength(2);
|
||||
|
||||
const recalcCalls = vi.mocked(recalculateParticipantQP).mock.calls;
|
||||
expect(recalcCalls).toHaveLength(2);
|
||||
expect(recalcCalls.map((c) => c[0]).toSorted()).toEqual(["p1", "p2"]);
|
||||
for (const call of recalcCalls) expect(call[1]).toBe("season-1");
|
||||
});
|
||||
|
||||
it("still clears the event when there are no recorded results", async () => {
|
||||
vi.mocked(recalculateParticipantQP).mockClear();
|
||||
const { db, deletedTables } = makeMockDb([]);
|
||||
|
||||
await resetCs2Event("event-1", "season-1", db as never);
|
||||
|
||||
expect(deletedTables).toHaveLength(2);
|
||||
expect(recalculateParticipantQP).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
87
app/models/__tests__/futures-odds-simulator.test.ts
Normal file
87
app/models/__tests__/futures-odds-simulator.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Mock the database context before importing any model
|
||||
vi.mock("~/database/context", () => ({
|
||||
database: vi.fn(),
|
||||
}));
|
||||
|
||||
import { database } from "~/database/context";
|
||||
import { batchUpsertParticipantSimulatorInputs } from "../simulator";
|
||||
|
||||
type SetPayload = Record<string, unknown>;
|
||||
|
||||
const conflictSetCalls: SetPayload[] = [];
|
||||
|
||||
const onConflictDoUpdate = vi.fn((arg: { set: SetPayload }) => {
|
||||
conflictSetCalls.push(arg.set);
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
|
||||
const tx = {
|
||||
insert: vi.fn(() => ({
|
||||
values: vi.fn(() => ({ onConflictDoUpdate })),
|
||||
})),
|
||||
};
|
||||
|
||||
const mockDb = {
|
||||
transaction: vi.fn((cb: (t: typeof tx) => Promise<unknown>) => cb(tx)),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
conflictSetCalls.length = 0;
|
||||
(database as ReturnType<typeof vi.fn>).mockReturnValue(mockDb);
|
||||
});
|
||||
|
||||
// Recursively flatten a Drizzle `sql` template into its static text so we can
|
||||
// assert how a conflict-update column is built (e.g. wrapped in COALESCE).
|
||||
function sqlToText(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
const chunks = (value as { queryChunks?: unknown[] }).queryChunks;
|
||||
if (Array.isArray(chunks)) return chunks.map(sqlToText).join("");
|
||||
const stringValue = (value as { value?: unknown }).value;
|
||||
if (Array.isArray(stringValue)) return stringValue.join("");
|
||||
if (typeof stringValue === "string") return stringValue;
|
||||
return "";
|
||||
}
|
||||
|
||||
describe("batchUpsertParticipantSimulatorInputs", () => {
|
||||
it("updates only the columns it is given, preserving the rest (non-destructive)", async () => {
|
||||
await batchUpsertParticipantSimulatorInputs([
|
||||
// Odds-only import (e.g. from the bulk futures paste): no Elo/rating supplied.
|
||||
{ participantId: "team-1", sportsSeasonId: "season-1", sourceOdds: 550 },
|
||||
]);
|
||||
|
||||
// Runs in a transaction, writing the simulator-inputs row first then the
|
||||
// legacy EV bridge row.
|
||||
expect(mockDb.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(conflictSetCalls.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const simulatorSet = conflictSetCalls[0];
|
||||
// Every input column participates in the conflict update so partial pastes
|
||||
// can target any field...
|
||||
for (const column of ["sourceOdds", "sourceElo", "worldRanking", "rating", "seed", "region"]) {
|
||||
expect(simulatorSet).toHaveProperty(column);
|
||||
}
|
||||
// ...but each is COALESCE-wrapped, so a null incoming value keeps the stored
|
||||
// one instead of clobbering it. A bare `excluded.*` here would be the bug.
|
||||
const sourceEloSql = sqlToText(simulatorSet.sourceElo).toLowerCase();
|
||||
expect(sourceEloSql).toContain("coalesce");
|
||||
const ratingSql = sqlToText(simulatorSet.rating).toLowerCase();
|
||||
expect(ratingSql).toContain("coalesce");
|
||||
|
||||
// Metadata must drop the per-column method flag whenever that column gets a
|
||||
// fresh direct value, so a stale "generated" flag can't hide a newly entered
|
||||
// Elo/rating. Blindly preserving metadata (e.g. COALESCE) would be the bug.
|
||||
const metadataSql = sqlToText(simulatorSet.metadata).toLowerCase();
|
||||
expect(metadataSql).toContain("sourceelomethod");
|
||||
expect(metadataSql).toContain("ratingmethod");
|
||||
expect(metadataSql).toContain("excluded.source_elo");
|
||||
expect(metadataSql).toContain("excluded.rating");
|
||||
});
|
||||
|
||||
it("is a no-op when given no inputs", async () => {
|
||||
await batchUpsertParticipantSimulatorInputs([]);
|
||||
expect(mockDb.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
113
app/models/__tests__/populate-bracket-from-draw.test.ts
Normal file
113
app/models/__tests__/populate-bracket-from-draw.test.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// populateBracketFromDraw upserts a full draw into playoff_matches and reports
|
||||
// which losers' matches *transitioned to complete on this run* — the signal used
|
||||
// to announce knockouts once, idempotently across re-syncs.
|
||||
|
||||
const existingRows: Array<Record<string, unknown>> = [];
|
||||
|
||||
const mockDb = {
|
||||
query: {
|
||||
playoffMatches: {
|
||||
findMany: vi.fn(async () => existingRows),
|
||||
},
|
||||
},
|
||||
update: vi.fn(() => ({
|
||||
set: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })),
|
||||
})),
|
||||
insert: vi.fn(() => ({
|
||||
values: vi.fn(() => ({ returning: vi.fn().mockResolvedValue([]) })),
|
||||
})),
|
||||
};
|
||||
|
||||
vi.mock("~/database/context", () => ({ database: () => mockDb }));
|
||||
|
||||
import { populateBracketFromDraw, type ResolvedDrawMatch } from "../playoff-match";
|
||||
|
||||
const EVENT_ID = "ev-1";
|
||||
|
||||
function match(overrides: Partial<ResolvedDrawMatch> = {}): ResolvedDrawMatch {
|
||||
return {
|
||||
externalMatchId: "m-1",
|
||||
round: "Round of 64",
|
||||
matchNumber: 1,
|
||||
participant1Id: "winner",
|
||||
participant2Id: "loser",
|
||||
winnerId: "winner",
|
||||
loserId: "loser",
|
||||
isScoring: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
existingRows.length = 0;
|
||||
});
|
||||
|
||||
describe("populateBracketFromDraw newlyDecidedLoserIds", () => {
|
||||
it("reports the loser of a brand-new completed match", async () => {
|
||||
const { newlyDecidedLoserIds } = await populateBracketFromDraw(EVENT_ID, [
|
||||
match({ externalMatchId: "m-1", loserId: "mensik" }),
|
||||
]);
|
||||
|
||||
expect(newlyDecidedLoserIds).toEqual(["mensik"]);
|
||||
});
|
||||
|
||||
it("reports the loser of an existing match that just reached completion", async () => {
|
||||
existingRows.push({
|
||||
id: "row-1",
|
||||
externalMatchId: "m-1",
|
||||
isComplete: false,
|
||||
loserId: null,
|
||||
winnerId: null,
|
||||
});
|
||||
|
||||
const { newlyDecidedLoserIds } = await populateBracketFromDraw(EVENT_ID, [
|
||||
match({ externalMatchId: "m-1", loserId: "mensik" }),
|
||||
]);
|
||||
|
||||
expect(newlyDecidedLoserIds).toEqual(["mensik"]);
|
||||
});
|
||||
|
||||
it("reports nothing on an idempotent re-sync of an already-complete match", async () => {
|
||||
existingRows.push({
|
||||
id: "row-1",
|
||||
externalMatchId: "m-1",
|
||||
isComplete: true,
|
||||
loserId: "mensik",
|
||||
winnerId: "winner",
|
||||
});
|
||||
|
||||
const { newlyDecidedLoserIds } = await populateBracketFromDraw(EVENT_ID, [
|
||||
match({ externalMatchId: "m-1", loserId: "mensik" }),
|
||||
]);
|
||||
|
||||
expect(newlyDecidedLoserIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores incomplete matches (no winner/loser yet)", async () => {
|
||||
const { newlyDecidedLoserIds, completed } = await populateBracketFromDraw(EVENT_ID, [
|
||||
match({ externalMatchId: "m-2", winnerId: null, loserId: null }),
|
||||
]);
|
||||
|
||||
expect(newlyDecidedLoserIds).toEqual([]);
|
||||
expect(completed).toBe(0);
|
||||
});
|
||||
|
||||
it("collects only the newly-decided losers in a mixed batch", async () => {
|
||||
existingRows.push(
|
||||
{ id: "row-1", externalMatchId: "m-1", isComplete: true, loserId: "old", winnerId: "w1" },
|
||||
{ id: "row-2", externalMatchId: "m-2", isComplete: false, loserId: null, winnerId: null },
|
||||
);
|
||||
|
||||
const { newlyDecidedLoserIds } = await populateBracketFromDraw(EVENT_ID, [
|
||||
match({ externalMatchId: "m-1", loserId: "old" }), // already complete → skip
|
||||
match({ externalMatchId: "m-2", loserId: "freshly-out" }), // transitioned → include
|
||||
match({ externalMatchId: "m-3", loserId: "brand-new-out" }), // new complete → include
|
||||
match({ externalMatchId: "m-4", winnerId: null, loserId: null }), // incomplete → skip
|
||||
]);
|
||||
|
||||
expect(newlyDecidedLoserIds.toSorted()).toEqual(["brand-new-out", "freshly-out"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -399,6 +399,7 @@ describe("isLoserNotifiable", () => {
|
|||
});
|
||||
});
|
||||
|
||||
|
||||
describe("processPlayoffEvent - NBA Play-In Round 1 loserAdvances fix", () => {
|
||||
function makePlayInDb(matches: object[]) {
|
||||
const insertedRows: Array<{ participantId: string; finalPosition: number; isPartialScore: boolean }> = [];
|
||||
|
|
|
|||
251
app/models/__tests__/qualifying-bracket-scoring.test.ts
Normal file
251
app/models/__tests__/qualifying-bracket-scoring.test.ts
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
deriveBracketQualifyingStates,
|
||||
getRoundConfig,
|
||||
getGuaranteedMinimumPosition,
|
||||
type BracketMatchInput,
|
||||
} from "../scoring-calculator";
|
||||
import { BRACKET_TEMPLATES } from "../../lib/bracket-templates";
|
||||
import { calculateSplitQualifyingPoints, DEFAULT_QP_VALUES } from "../qualifying-points";
|
||||
|
||||
// ── Test fixtures ─────────────────────────────────────────────────────────────
|
||||
|
||||
const SIMPLE_8 = BRACKET_TEMPLATES["simple_8"];
|
||||
const getConfig = (round: string) => getRoundConfig(round, "simple_8");
|
||||
const states = (matches: BracketMatchInput[]) =>
|
||||
deriveBracketQualifyingStates(matches, SIMPLE_8.rounds, getConfig);
|
||||
|
||||
const QP_MAP = new Map<number, number>(
|
||||
DEFAULT_QP_VALUES.map((v) => [v.placement, v.points])
|
||||
);
|
||||
const qpFor = (placement: number, tieCount: number) =>
|
||||
calculateSplitQualifyingPoints(placement, tieCount, QP_MAP);
|
||||
|
||||
/** Helper to build a played match. */
|
||||
const played = (
|
||||
round: string,
|
||||
winnerId: string,
|
||||
loserId: string
|
||||
): BracketMatchInput => ({
|
||||
round,
|
||||
winnerId,
|
||||
loserId,
|
||||
participant1Id: winnerId,
|
||||
participant2Id: loserId,
|
||||
});
|
||||
|
||||
/** Helper to build an as-yet-unplayed match (participants seeded, no result). */
|
||||
const pending = (
|
||||
round: string,
|
||||
p1: string,
|
||||
p2: string
|
||||
): BracketMatchInput => ({
|
||||
round,
|
||||
winnerId: null,
|
||||
loserId: null,
|
||||
participant1Id: p1,
|
||||
participant2Id: p2,
|
||||
});
|
||||
|
||||
// Full simple_8 quarterfinal field: winners t1,t2,t3,t4 / losers t5,t6,t7,t8.
|
||||
const QF_ALL = [
|
||||
played("Quarterfinals", "t1", "t8"),
|
||||
played("Quarterfinals", "t2", "t7"),
|
||||
played("Quarterfinals", "t3", "t6"),
|
||||
played("Quarterfinals", "t4", "t5"),
|
||||
];
|
||||
// Semifinals: t1 & t3 advance.
|
||||
const SF_ALL = [
|
||||
played("Semifinals", "t1", "t2"),
|
||||
played("Semifinals", "t3", "t4"),
|
||||
];
|
||||
// Final: t1 champion.
|
||||
const FINAL = [played("Finals", "t1", "t3")];
|
||||
|
||||
// ── deriveBracketQualifyingStates ─────────────────────────────────────────────
|
||||
|
||||
describe("deriveBracketQualifyingStates (simple_8)", () => {
|
||||
it("returns an empty map for no matches", () => {
|
||||
expect(states([]).size).toBe(0);
|
||||
});
|
||||
|
||||
it("QF entered: losers lock T5–8, winners floor at T3–4", () => {
|
||||
const s = states(QF_ALL);
|
||||
// Losers — final placement 5, tie span 4 (T5–8).
|
||||
for (const id of ["t5", "t6", "t7", "t8"]) {
|
||||
expect(s.get(id)).toEqual({ placement: 5, tieCount: 4 });
|
||||
}
|
||||
// Winners — guaranteed at least T3–4: floor placement 3, tie span 2.
|
||||
for (const id of ["t1", "t2", "t3", "t4"]) {
|
||||
expect(s.get(id)).toEqual({ placement: 3, tieCount: 2 });
|
||||
}
|
||||
});
|
||||
|
||||
it("QF+SF entered: SF losers lock T3–4, SF winners floor at 2nd", () => {
|
||||
const s = states([...QF_ALL, ...SF_ALL]);
|
||||
// SF losers — final placement 3, tie span 2.
|
||||
expect(s.get("t2")).toEqual({ placement: 3, tieCount: 2 });
|
||||
expect(s.get("t4")).toEqual({ placement: 3, tieCount: 2 });
|
||||
// SF winners (finalists-in-waiting) — floor placement 2, tie span 1.
|
||||
expect(s.get("t1")).toEqual({ placement: 2, tieCount: 1 });
|
||||
expect(s.get("t3")).toEqual({ placement: 2, tieCount: 1 });
|
||||
// QF losers unchanged.
|
||||
expect(s.get("t8")).toEqual({ placement: 5, tieCount: 4 });
|
||||
});
|
||||
|
||||
it("full bracket: champion 1st, finalist 2nd, SF losers T3–4, QF losers T5–8", () => {
|
||||
const s = states([...QF_ALL, ...SF_ALL, ...FINAL]);
|
||||
expect(s.get("t1")).toEqual({ placement: 1, tieCount: 1 }); // champion
|
||||
expect(s.get("t3")).toEqual({ placement: 2, tieCount: 1 }); // finalist
|
||||
expect(s.get("t2")).toEqual({ placement: 3, tieCount: 2 }); // SF loser
|
||||
expect(s.get("t4")).toEqual({ placement: 3, tieCount: 2 }); // SF loser
|
||||
expect(s.get("t5")).toEqual({ placement: 5, tieCount: 4 }); // QF loser
|
||||
});
|
||||
|
||||
it("partial QF (2 of 4 played): unplayed teams sit at the T5–8 entry floor", () => {
|
||||
const matches = [
|
||||
played("Quarterfinals", "t1", "t8"),
|
||||
played("Quarterfinals", "t4", "t5"),
|
||||
pending("Quarterfinals", "t3", "t6"),
|
||||
pending("Quarterfinals", "t2", "t7"),
|
||||
];
|
||||
const s = states(matches);
|
||||
// Played winners floor at T3–4.
|
||||
expect(s.get("t1")).toEqual({ placement: 3, tieCount: 2 });
|
||||
expect(s.get("t4")).toEqual({ placement: 3, tieCount: 2 });
|
||||
// Played losers lock T5–8.
|
||||
expect(s.get("t8")).toEqual({ placement: 5, tieCount: 4 });
|
||||
expect(s.get("t5")).toEqual({ placement: 5, tieCount: 4 });
|
||||
// Unplayed teams: entry floor T5–8 (no crash on incomplete round).
|
||||
for (const id of ["t2", "t3", "t6", "t7"]) {
|
||||
expect(s.get(id)).toEqual({ placement: 5, tieCount: 4 });
|
||||
}
|
||||
});
|
||||
|
||||
it("un-setting the Finals winner reverts both finalists to the 2nd-place floor", () => {
|
||||
const matches = [
|
||||
...QF_ALL,
|
||||
...SF_ALL,
|
||||
pending("Finals", "t1", "t3"), // winner cleared
|
||||
];
|
||||
const s = states(matches);
|
||||
expect(s.get("t1")).toEqual({ placement: 2, tieCount: 1 });
|
||||
expect(s.get("t3")).toEqual({ placement: 2, tieCount: 1 });
|
||||
});
|
||||
|
||||
it("a team eliminated in the QF never gains a later-round floor", () => {
|
||||
const s = states([...QF_ALL, ...SF_ALL, ...FINAL]);
|
||||
// t8 lost in the QF and stays T5–8 through the rest of the bracket.
|
||||
expect(s.get("t8")).toEqual({ placement: 5, tieCount: 4 });
|
||||
});
|
||||
|
||||
it("is idempotent — same matches yield the same states", () => {
|
||||
const a = states([...QF_ALL, ...SF_ALL]);
|
||||
const b = states([...QF_ALL, ...SF_ALL]);
|
||||
expect([...a.entries()].toSorted()).toEqual([...b.entries()].toSorted());
|
||||
});
|
||||
});
|
||||
|
||||
// ── tennis_128: scoring starts deep (Round of 16), not at round 1 ─────────────
|
||||
|
||||
describe("deriveBracketQualifyingStates (tennis_128 — deep scoring start)", () => {
|
||||
const TENNIS = BRACKET_TEMPLATES["tennis_128"];
|
||||
const tStates = (matches: BracketMatchInput[]) =>
|
||||
deriveBracketQualifyingStates(matches, TENNIS.rounds, (round) =>
|
||||
getRoundConfig(round, "tennis_128"),
|
||||
);
|
||||
|
||||
it("awards NO QP to a player who lost before the Round of 16", () => {
|
||||
const s = tStates([
|
||||
played("Round of 128", "winner", "earlyLoser"),
|
||||
]);
|
||||
expect(s.has("earlyLoser")).toBe(false); // 0 QP — didn't reach scoring
|
||||
});
|
||||
|
||||
it("awards NO QP just for winning a pre-scoring round (still short of R16)", () => {
|
||||
// Won R128, next match (R64) not played → not yet in the Round of 16.
|
||||
const s = tStates([played("Round of 128", "p", "x")]);
|
||||
expect(s.has("p")).toBe(false);
|
||||
});
|
||||
|
||||
it("gives NO entry floor to undrawn/unplayed players (the 9th-place bug)", () => {
|
||||
const s = tStates([
|
||||
pending("Round of 128", "a", "b"),
|
||||
pending("Round of 128", "c", "d"),
|
||||
]);
|
||||
expect(s.size).toBe(0); // nobody has earned anything yet
|
||||
});
|
||||
|
||||
it("floors a player who reached the Round of 16 (won R32) at T9–16", () => {
|
||||
// a wins R128 → R64 → R32, so a is now IN the Round of 16 (not yet played).
|
||||
const s = tStates([
|
||||
played("Round of 128", "a", "x1"),
|
||||
played("Round of 64", "a", "x2"),
|
||||
played("Round of 32", "a", "x3"),
|
||||
]);
|
||||
expect(s.get("a")).toEqual({ placement: 9, tieCount: 8 });
|
||||
// The players a beat in non-scoring rounds earn nothing.
|
||||
expect(s.has("x1")).toBe(false);
|
||||
});
|
||||
|
||||
it("locks an actual Round of 16 loser at T9–16 and floors the winner at T5–8", () => {
|
||||
const s = tStates([
|
||||
played("Round of 16", "r16winner", "r16loser"),
|
||||
]);
|
||||
expect(s.get("r16loser")).toEqual({ placement: 9, tieCount: 8 });
|
||||
expect(s.get("r16winner")).toEqual({ placement: 5, tieCount: 4 }); // QF floor
|
||||
});
|
||||
});
|
||||
|
||||
// ── QP values (the worked example) ────────────────────────────────────────────
|
||||
|
||||
describe("guaranteed-minimum QP per outcome (default config)", () => {
|
||||
it("matches the worked example: QF win → 9, SF win → 14, Final win → 20", () => {
|
||||
expect(qpFor(3, 2)).toBeCloseTo(9); // QF winner, guaranteed T3–4
|
||||
expect(qpFor(2, 1)).toBeCloseTo(14); // SF winner, guaranteed 2nd
|
||||
expect(qpFor(1, 1)).toBeCloseTo(20); // champion
|
||||
});
|
||||
|
||||
it("losers: QF loss → 4, SF loss → 9, Final loss → 14", () => {
|
||||
expect(qpFor(5, 4)).toBeCloseTo(4); // (5+5+3+3)/4
|
||||
expect(qpFor(3, 2)).toBeCloseTo(9); // (10+8)/2
|
||||
expect(qpFor(2, 1)).toBeCloseTo(14);
|
||||
});
|
||||
|
||||
it("end-to-end: a QF win immediately yields a 9 QP floor", () => {
|
||||
const s = states(QF_ALL);
|
||||
expect(s.get("t1")).toEqual({ placement: 3, tieCount: 2 });
|
||||
expect(qpFor(3, 2)).toBeCloseTo(9);
|
||||
expect(s.get("t8")).toEqual({ placement: 5, tieCount: 4 });
|
||||
expect(qpFor(5, 4)).toBeCloseTo(4);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Regression guard: structural tieCount, not live count (the #1 fix) ─────────
|
||||
|
||||
describe("structural tie span vs. live-count regrouping", () => {
|
||||
it("4 QF winners share placement 3 but each keeps the 2-slot (9 QP) floor", () => {
|
||||
const s = states(QF_ALL);
|
||||
// All four QF winners sit at placement 3, but with the STRUCTURAL tie span of 2.
|
||||
for (const id of ["t1", "t2", "t3", "t4"]) {
|
||||
expect(s.get(id)).toEqual({ placement: 3, tieCount: 2 });
|
||||
}
|
||||
expect(qpFor(3, 2)).toBeCloseTo(9);
|
||||
// A naive regroup by LIVE count (4 rows at placement 3) would average over
|
||||
// slots 3–6 and wrongly yield 7 QP — the bug processQualifyingEvent must avoid
|
||||
// by delegating bracket events to the structural-tieCount path.
|
||||
expect(qpFor(3, 4)).toBeCloseTo(7);
|
||||
expect(qpFor(3, 4)).not.toBeCloseTo(9);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Regression guard: reused ROUND_CONFIG floors are intact ────────────────────
|
||||
|
||||
describe("ROUND_CONFIG floors (regression guard)", () => {
|
||||
it("simple_8 Quarterfinals still floors winners at 3rd", () => {
|
||||
expect(getGuaranteedMinimumPosition("Quarterfinals", "simple_8", true)).toBe(3);
|
||||
expect(getRoundConfig("Quarterfinals", "simple_8")?.loserPosition).toBe(5);
|
||||
expect(getRoundConfig("Semifinals", "simple_8")?.winnerFloor).toBe(2);
|
||||
expect(getRoundConfig("Finals", "simple_8")?.winnerFloor).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -2,9 +2,47 @@ import { describe, it, expect } from "vitest";
|
|||
import {
|
||||
calculateSplitQualifyingPoints,
|
||||
DEFAULT_QP_VALUES,
|
||||
hasProcessedQualifyingPlacement,
|
||||
diffChangedQualifyingPoints,
|
||||
} 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", () => {
|
||||
|
|
@ -271,52 +309,6 @@ 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", () => {
|
||||
|
|
|
|||
143
app/models/__tests__/scoring-calculator-qp-notify.test.ts
Normal file
143
app/models/__tests__/scoring-calculator-qp-notify.test.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type * as QualifyingPointsModule from "~/models/qualifying-points";
|
||||
|
||||
// Spy on the QP Discord notification, and no-op the per-participant total recalc
|
||||
// (its own eventResults query would otherwise interfere with the before/after
|
||||
// snapshot counter below). Everything else in the scorer runs for real.
|
||||
vi.mock("~/services/qualifying-points-discord.server", () => ({
|
||||
notifyQualifyingPointsUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("~/models/qualifying-points", async (importActual) => {
|
||||
const actual = await importActual<typeof QualifyingPointsModule>();
|
||||
return {
|
||||
...actual,
|
||||
recalculateParticipantQP: vi.fn().mockResolvedValue({ totalQP: 0, eventsScored: 0 }),
|
||||
};
|
||||
});
|
||||
|
||||
import { processQualifyingEvent } from "../scoring-calculator";
|
||||
import { DEFAULT_QP_VALUES } from "../qualifying-points";
|
||||
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
|
||||
|
||||
const EVENT_ID = "event-1";
|
||||
const SPORTS_SEASON_ID = "sports-season-1";
|
||||
|
||||
type ResultRow = {
|
||||
id: string;
|
||||
seasonParticipantId: string;
|
||||
placement: number | null;
|
||||
qualifyingPointsAwarded: string | null;
|
||||
scoringEvent: { id: string; sportsSeasonId: string };
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock db for the non-bracket `processQualifyingEvent` path. `eventResults.findMany`
|
||||
* returns `before` on its first call (the initial getEventResults snapshot) and
|
||||
* `after` on its second (the post-reprocess re-query that drives change detection).
|
||||
* Awarding updates go to a no-op update chain — `after` stands in for the DB state
|
||||
* once the scorer has written its QP.
|
||||
*/
|
||||
function makeDb(before: ResultRow[], after: ResultRow[]) {
|
||||
const results = [before, after];
|
||||
let findManyCall = 0;
|
||||
return {
|
||||
query: {
|
||||
scoringEvents: {
|
||||
findFirst: async () => ({
|
||||
id: EVENT_ID,
|
||||
sportsSeasonId: SPORTS_SEASON_ID,
|
||||
isQualifyingEvent: true,
|
||||
bracketTemplateId: null,
|
||||
sportsSeason: { majorsCompleted: 1 },
|
||||
}),
|
||||
},
|
||||
eventResults: {
|
||||
findMany: async () => results[Math.min(findManyCall++, 1)],
|
||||
},
|
||||
qualifyingPointConfig: {
|
||||
findMany: async () =>
|
||||
DEFAULT_QP_VALUES.map((qp) => ({ placement: qp.placement, points: qp.points.toString() })),
|
||||
},
|
||||
},
|
||||
update: () => ({ set: () => ({ where: async () => [] }) }),
|
||||
} as never;
|
||||
}
|
||||
|
||||
const scoringEvent = { id: EVENT_ID, sportsSeasonId: SPORTS_SEASON_ID };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("processQualifyingEvent — QP change notification", () => {
|
||||
it("notifies only participants whose QP changed (first-time score: null → value)", async () => {
|
||||
const db = makeDb(
|
||||
[
|
||||
{ id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: null, scoringEvent },
|
||||
{ id: "r2", seasonParticipantId: "p2", placement: 20, qualifyingPointsAwarded: "0.00", scoringEvent },
|
||||
],
|
||||
[
|
||||
{ id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: "100.00", scoringEvent },
|
||||
{ id: "r2", seasonParticipantId: "p2", placement: 20, qualifyingPointsAwarded: "0.00", scoringEvent },
|
||||
],
|
||||
);
|
||||
|
||||
await processQualifyingEvent(EVENT_ID, db);
|
||||
|
||||
expect(notifyQualifyingPointsUpdate).toHaveBeenCalledTimes(1);
|
||||
const [ssId, evId, , filter] = vi.mocked(notifyQualifyingPointsUpdate).mock.calls[0];
|
||||
expect(ssId).toBe(SPORTS_SEASON_ID);
|
||||
expect(evId).toBe(EVENT_ID);
|
||||
// p1 went null → 100 (changed); p2 stayed at 0 (unchanged).
|
||||
expect([...(filter as Set<string>)]).toEqual(["p1"]);
|
||||
});
|
||||
|
||||
it("does not notify when a re-sync produces identical QP", async () => {
|
||||
const rows: ResultRow[] = [
|
||||
{ id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: "100.00", scoringEvent },
|
||||
{ id: "r2", seasonParticipantId: "p2", placement: 20, qualifyingPointsAwarded: "0.00", scoringEvent },
|
||||
];
|
||||
const db = makeDb(rows, rows.map((r) => ({ ...r })));
|
||||
|
||||
await processQualifyingEvent(EVENT_ID, db);
|
||||
|
||||
expect(notifyQualifyingPointsUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("announces newly-eliminated players even when no QP changed (mirror non-scoring-round exit)", async () => {
|
||||
// A mirror window re-scored on fan-out: nobody's QP changed, but the primary
|
||||
// bracket reports p2 knocked out in a non-scoring round. The notification must
|
||||
// still fire, passing the eliminated id through as the 5th arg so the "Knocked
|
||||
// Out" section isn't dropped on the mirror.
|
||||
const rows: ResultRow[] = [
|
||||
{ id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: "100.00", scoringEvent },
|
||||
{ id: "r2", seasonParticipantId: "p2", placement: null, qualifyingPointsAwarded: "0.00", scoringEvent },
|
||||
];
|
||||
const db = makeDb(rows, rows.map((r) => ({ ...r })));
|
||||
|
||||
await processQualifyingEvent(EVENT_ID, db, {
|
||||
newlyEliminatedParticipantIds: new Set(["p2"]),
|
||||
});
|
||||
|
||||
expect(notifyQualifyingPointsUpdate).toHaveBeenCalledTimes(1);
|
||||
const [, , , changed, eliminated] = vi.mocked(notifyQualifyingPointsUpdate).mock.calls[0];
|
||||
// No QP change this sync.
|
||||
expect([...(changed as Set<string>)]).toEqual([]);
|
||||
// The knocked-out player is forwarded to the notifier.
|
||||
expect([...(eliminated as Set<string>)]).toEqual(["p2"]);
|
||||
});
|
||||
|
||||
it("does not notify on a re-sync with no QP change and no eliminations", async () => {
|
||||
const rows: ResultRow[] = [
|
||||
{ id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: "100.00", scoringEvent },
|
||||
];
|
||||
const db = makeDb(rows, rows.map((r) => ({ ...r })));
|
||||
|
||||
await processQualifyingEvent(EVENT_ID, db, {
|
||||
newlyEliminatedParticipantIds: new Set(),
|
||||
});
|
||||
|
||||
expect(notifyQualifyingPointsUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -200,7 +200,21 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
|
|||
});
|
||||
|
||||
describe("processQualifyingEvent", () => {
|
||||
function makeProcessQPMockDb(eventResults: Array<Record<string, unknown>>) {
|
||||
function makeProcessQPMockDb(
|
||||
eventResults: Array<Record<string, unknown>>,
|
||||
options: {
|
||||
tournamentId?: string | null;
|
||||
canonicalPlacements?: number[];
|
||||
bracketTemplateId?: string | null;
|
||||
playoffMatchIds?: string[];
|
||||
} = {}
|
||||
) {
|
||||
const {
|
||||
tournamentId = null,
|
||||
canonicalPlacements = [],
|
||||
bracketTemplateId = null,
|
||||
playoffMatchIds = [],
|
||||
} = options;
|
||||
const setCalls: unknown[] = [];
|
||||
const updateChain = {
|
||||
set: (values: unknown) => {
|
||||
|
|
@ -217,6 +231,8 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
|
|||
id: "event-1",
|
||||
sportsSeasonId: "sports-season-1",
|
||||
isQualifyingEvent: true,
|
||||
tournamentId,
|
||||
bracketTemplateId,
|
||||
sportsSeason: { majorsCompleted: 1 },
|
||||
}),
|
||||
},
|
||||
|
|
@ -238,6 +254,23 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
|
|||
}),
|
||||
},
|
||||
},
|
||||
// Two select shapes flow through the non-bracket path:
|
||||
// • playoff-match existence check (has an `id` projection, uses .limit(1))
|
||||
// • canonical tournament_results tie span (has a `placement` projection).
|
||||
// Discriminate by the projection keys and return a thenable that also
|
||||
// exposes .limit so both call shapes resolve.
|
||||
select: (fields: Record<string, unknown>) => {
|
||||
const isPlayoff = fields && "id" in fields;
|
||||
const rows = isPlayoff
|
||||
? playoffMatchIds.map((id) => ({ id }))
|
||||
: canonicalPlacements.map((placement) => ({ placement }));
|
||||
return {
|
||||
from: () => ({
|
||||
where: () =>
|
||||
Object.assign(Promise.resolve(rows), { limit: async () => rows }),
|
||||
}),
|
||||
};
|
||||
},
|
||||
update: () => updateChain,
|
||||
} as any;
|
||||
|
||||
|
|
@ -273,6 +306,123 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("splits a tied placement by the FULL canonical field, not the window's roster subset", async () => {
|
||||
// Regression for the reported 2-vs-1.5 QP bug. Tennis Round-of-16 losers all
|
||||
// land at placement 9 with a structural tie span of 8 (positions 9–16 →
|
||||
// (2+2+2+2+1+1+1+1)/8 = 1.5). A sibling/mirror window only holds the drafted
|
||||
// subset — here just 2 of the 8 tied players — but the split must still use 8,
|
||||
// not the 2 present locally (which would wrongly give (2+2)/2 = 2.00).
|
||||
const windowRows = [
|
||||
{
|
||||
id: "result-1",
|
||||
seasonParticipantId: "participant-1",
|
||||
placement: 9,
|
||||
qualifyingPointsAwarded: null,
|
||||
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
|
||||
},
|
||||
{
|
||||
id: "result-2",
|
||||
seasonParticipantId: "participant-2",
|
||||
placement: 9,
|
||||
qualifyingPointsAwarded: null,
|
||||
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
|
||||
},
|
||||
];
|
||||
const { db, setCalls } = makeProcessQPMockDb(windowRows, {
|
||||
tournamentId: "tournament-1",
|
||||
canonicalPlacements: Array.from({ length: 8 }, () => 9), // full field: 8 at 9th
|
||||
});
|
||||
|
||||
await processQualifyingEvent("event-1", db, { skipNotifications: true });
|
||||
|
||||
// Both present players earn the correct split of 1.50, not 2.00.
|
||||
expect(setCalls).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ qualifyingPointsAwarded: "1.50" }),
|
||||
])
|
||||
);
|
||||
expect(setCalls).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ qualifyingPointsAwarded: "2.00" }),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("scores a cloned window (bracketTemplateId but no playoff matches) via the canonical path", async () => {
|
||||
// cloneSportsSeason copies bracketTemplateId to league windows but not the
|
||||
// playoff matches. Such a window must NOT take the bracket branch (which would
|
||||
// derive zero states and write no QP) — it has to fall through to the
|
||||
// placement/canonical path and still split R16 losers to 1.5.
|
||||
const windowRows = [
|
||||
{
|
||||
id: "result-1",
|
||||
seasonParticipantId: "participant-1",
|
||||
placement: 9,
|
||||
qualifyingPointsAwarded: null,
|
||||
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
|
||||
},
|
||||
{
|
||||
id: "result-2",
|
||||
seasonParticipantId: "participant-2",
|
||||
placement: 9,
|
||||
qualifyingPointsAwarded: null,
|
||||
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
|
||||
},
|
||||
];
|
||||
const { db, setCalls } = makeProcessQPMockDb(windowRows, {
|
||||
tournamentId: "tournament-1",
|
||||
bracketTemplateId: "tennis_128", // copied by clone…
|
||||
playoffMatchIds: [], // …but no matches exist on this window
|
||||
canonicalPlacements: Array.from({ length: 8 }, () => 9),
|
||||
});
|
||||
|
||||
await processQualifyingEvent("event-1", db, { skipNotifications: true });
|
||||
|
||||
expect(setCalls).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ qualifyingPointsAwarded: "1.50" }),
|
||||
])
|
||||
);
|
||||
expect(setCalls).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ qualifyingPointsAwarded: "2.00" }),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the live roster count for standalone events with no tournament", async () => {
|
||||
// No canonical field exists for a manual/standalone qualifying event, so the
|
||||
// tie span is the players actually present: 2 players tied at 9th →
|
||||
// (2+2)/2 = 2.00. This preserves existing behavior where there is no full field.
|
||||
const windowRows = [
|
||||
{
|
||||
id: "result-1",
|
||||
seasonParticipantId: "participant-1",
|
||||
placement: 9,
|
||||
qualifyingPointsAwarded: null,
|
||||
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
|
||||
},
|
||||
{
|
||||
id: "result-2",
|
||||
seasonParticipantId: "participant-2",
|
||||
placement: 9,
|
||||
qualifyingPointsAwarded: null,
|
||||
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
|
||||
},
|
||||
];
|
||||
const { db, setCalls } = makeProcessQPMockDb(windowRows, {
|
||||
tournamentId: null,
|
||||
});
|
||||
|
||||
await processQualifyingEvent("event-1", db, { skipNotifications: true });
|
||||
|
||||
expect(setCalls).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ qualifyingPointsAwarded: "2.00" }),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("does not increment majorsCompleted when reprocessing a placed zero-QP result", async () => {
|
||||
const { db, setCalls } = makeProcessQPMockDb([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -15,7 +15,10 @@ vi.mock("../qualifying-points", async (importOriginal) => {
|
|||
import { deleteScoringEvent } from "../scoring-event";
|
||||
|
||||
describe("deleteScoringEvent", () => {
|
||||
it("decrements majorsCompleted for a processed zero-QP qualifying event", async () => {
|
||||
it("does not write majorsCompleted on delete (it is derived on read)", async () => {
|
||||
// majorsCompleted is no longer a stored counter — it is computed via
|
||||
// getMajorsCompleted from completed qualifying events. Deleting a qualifying
|
||||
// event must therefore never issue a sportsSeasons.majorsCompleted update.
|
||||
const setCalls: unknown[] = [];
|
||||
const deleteChain = { where: vi.fn().mockResolvedValue(undefined) };
|
||||
const updateChain = {
|
||||
|
|
@ -43,12 +46,6 @@ describe("deleteScoringEvent", () => {
|
|||
},
|
||||
]),
|
||||
},
|
||||
sportsSeasons: {
|
||||
findFirst: vi.fn().mockResolvedValue({
|
||||
id: "sports-season-1",
|
||||
majorsCompleted: 1,
|
||||
}),
|
||||
},
|
||||
},
|
||||
transaction: vi.fn(async (callback) => callback(db)),
|
||||
delete: vi.fn(() => deleteChain),
|
||||
|
|
@ -57,10 +54,125 @@ describe("deleteScoringEvent", () => {
|
|||
|
||||
await deleteScoringEvent("event-1", db);
|
||||
|
||||
expect(setCalls).toEqual(
|
||||
expect(setCalls).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ majorsCompleted: 0 }),
|
||||
expect.objectContaining({ majorsCompleted: expect.anything() }),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
describe("shared tournament handling", () => {
|
||||
// Builds a mock db for a non-qualifying schedule_event linked to a tournament,
|
||||
// so the delete skips the QP/league branches and exercises only the
|
||||
// shared-tournament bookkeeping.
|
||||
function makeSharedDb({
|
||||
deletedEvent,
|
||||
remaining,
|
||||
siblingTournamentId = "tournament-1",
|
||||
}: {
|
||||
deletedEvent: { tournamentId: string | null; isPrimary: boolean };
|
||||
remaining: Array<{ id: string; isPrimary: boolean }>;
|
||||
siblingTournamentId?: string;
|
||||
}) {
|
||||
const findFirst = vi
|
||||
.fn()
|
||||
// 1st call: the event being deleted
|
||||
.mockResolvedValueOnce({
|
||||
id: "event-1",
|
||||
sportsSeasonId: "sports-season-1",
|
||||
eventType: "schedule_event",
|
||||
isQualifyingEvent: false,
|
||||
...deletedEvent,
|
||||
})
|
||||
// subsequent calls: setPrimaryEvent looking up the promoted event
|
||||
.mockResolvedValue({ id: "promoted", tournamentId: siblingTournamentId });
|
||||
|
||||
const deleteWhere = vi.fn().mockResolvedValue(undefined);
|
||||
const updateWhere = vi.fn().mockResolvedValue(undefined);
|
||||
const db = {
|
||||
query: {
|
||||
scoringEvents: {
|
||||
findFirst,
|
||||
findMany: vi.fn().mockResolvedValue(remaining),
|
||||
},
|
||||
},
|
||||
transaction: vi.fn(async (callback) => callback(db)),
|
||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||
update: vi.fn(() => ({ set: vi.fn(() => ({ where: updateWhere })) })),
|
||||
} as any;
|
||||
return { db, deleteWhere };
|
||||
}
|
||||
|
||||
it("keeps the tournament and promotes nothing when a non-primary window is deleted", async () => {
|
||||
const { db } = makeSharedDb({
|
||||
deletedEvent: { tournamentId: "tournament-1", isPrimary: false },
|
||||
remaining: [{ id: "event-2", isPrimary: true }],
|
||||
});
|
||||
|
||||
const result = await deleteScoringEvent("event-1", db);
|
||||
|
||||
expect(result.remainingWindows).toBe(1);
|
||||
expect(result.promotedPrimaryId).toBeNull();
|
||||
expect(result.deletedTournament).toBe(false);
|
||||
});
|
||||
|
||||
it("does not promote a primary when a golf-style tournament (no primary) loses a window", async () => {
|
||||
// Golf-style shared majors intentionally have no primary window — every
|
||||
// linked event is isPrimary=false. Deleting one must not flip a sibling
|
||||
// into a primary, which would change its scoring/guard behavior.
|
||||
const { db } = makeSharedDb({
|
||||
deletedEvent: { tournamentId: "tournament-1", isPrimary: false },
|
||||
remaining: [
|
||||
{ id: "event-2", isPrimary: false },
|
||||
{ id: "event-3", isPrimary: false },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await deleteScoringEvent("event-1", db);
|
||||
|
||||
expect(result.promotedPrimaryId).toBeNull();
|
||||
expect(result.remainingWindows).toBe(2);
|
||||
});
|
||||
|
||||
it("promotes the earliest remaining window when the primary window is deleted", async () => {
|
||||
const { db } = makeSharedDb({
|
||||
deletedEvent: { tournamentId: "tournament-1", isPrimary: true },
|
||||
// findMany is ordered asc(createdAt); first is the earliest.
|
||||
remaining: [
|
||||
{ id: "event-2", isPrimary: false },
|
||||
{ id: "event-3", isPrimary: false },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await deleteScoringEvent("event-1", db);
|
||||
|
||||
expect(result.promotedPrimaryId).toBe("event-2");
|
||||
expect(result.remainingWindows).toBe(2);
|
||||
});
|
||||
|
||||
it("leaves the orphaned tournament intact when the last window is deleted without the flag", async () => {
|
||||
const { db } = makeSharedDb({
|
||||
deletedEvent: { tournamentId: "tournament-1", isPrimary: true },
|
||||
remaining: [],
|
||||
});
|
||||
|
||||
const result = await deleteScoringEvent("event-1", db);
|
||||
|
||||
expect(result.remainingWindows).toBe(0);
|
||||
expect(result.deletedTournament).toBe(false);
|
||||
});
|
||||
|
||||
it("deletes the orphaned tournament when the last window is deleted with the flag", async () => {
|
||||
const { db } = makeSharedDb({
|
||||
deletedEvent: { tournamentId: "tournament-1", isPrimary: true },
|
||||
remaining: [],
|
||||
});
|
||||
|
||||
const result = await deleteScoringEvent("event-1", db, {
|
||||
deleteOrphanTournament: true,
|
||||
});
|
||||
|
||||
expect(result.deletedTournament).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,23 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const mockFindMany = vi.fn();
|
||||
const mockFindFirst = vi.fn();
|
||||
// Records each db.update(...).set(patch).where(pred) call for assertions.
|
||||
const updateCalls: Array<{ patch: unknown; where: unknown }> = [];
|
||||
const mockUpdate = vi.fn(() => ({
|
||||
set: (patch: unknown) => ({
|
||||
where: (where: unknown) => {
|
||||
updateCalls.push({ patch, where });
|
||||
return Promise.resolve();
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockDb = {
|
||||
query: {
|
||||
scoringEvents: { findMany: mockFindMany },
|
||||
scoringEvents: { findMany: mockFindMany, findFirst: mockFindFirst },
|
||||
},
|
||||
update: mockUpdate,
|
||||
};
|
||||
|
||||
vi.mock("~/database/context", () => ({
|
||||
|
|
@ -14,8 +26,10 @@ vi.mock("~/database/context", () => ({
|
|||
|
||||
vi.mock("~/database/schema", () => ({
|
||||
scoringEvents: {
|
||||
id: "se.id",
|
||||
sportsSeasonId: "se.sports_season_id",
|
||||
tournamentId: "se.tournament_id",
|
||||
isPrimary: "se.is_primary",
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
@ -23,11 +37,14 @@ vi.mock("drizzle-orm", () => ({
|
|||
eq: (col: unknown, val: unknown) => ({ type: "eq", col, val }),
|
||||
and: (...args: unknown[]) => ({ type: "and", args }),
|
||||
isNotNull: (col: unknown) => ({ type: "isNotNull", col }),
|
||||
asc: (col: unknown) => ({ type: "asc", col }),
|
||||
}));
|
||||
|
||||
import {
|
||||
getTournamentsBySportsSeason,
|
||||
getSportsSeasonsByTournament,
|
||||
ensurePrimaryEvent,
|
||||
setPrimaryEvent,
|
||||
} from "../scoring-event";
|
||||
|
||||
const SEASON_ID = "ss-1";
|
||||
|
|
@ -39,6 +56,7 @@ const mockTournamentB = { id: TOURNAMENT_ID_B, name: "US Open", year: 2026 };
|
|||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
updateCalls.length = 0;
|
||||
});
|
||||
|
||||
describe("getTournamentsBySportsSeason", () => {
|
||||
|
|
@ -116,3 +134,37 @@ describe("getSportsSeasonsByTournament", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensurePrimaryEvent", () => {
|
||||
it("sets the event primary when the tournament has no primary yet", async () => {
|
||||
mockFindFirst.mockResolvedValue(undefined); // no existing primary
|
||||
const result = await ensurePrimaryEvent(TOURNAMENT_ID_A, "ev-1");
|
||||
expect(result).toBe("ev-1");
|
||||
expect(updateCalls).toHaveLength(1);
|
||||
expect(updateCalls[0].patch).toMatchObject({ isPrimary: true });
|
||||
});
|
||||
|
||||
it("is idempotent: keeps the existing primary and writes nothing", async () => {
|
||||
mockFindFirst.mockResolvedValue({ id: "ev-existing" });
|
||||
const result = await ensurePrimaryEvent(TOURNAMENT_ID_A, "ev-2");
|
||||
expect(result).toBe("ev-existing");
|
||||
expect(updateCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setPrimaryEvent", () => {
|
||||
it("clears siblings then sets the chosen event primary", async () => {
|
||||
mockFindFirst.mockResolvedValue({ id: "ev-3", tournamentId: TOURNAMENT_ID_A });
|
||||
await setPrimaryEvent("ev-3");
|
||||
// Two updates: clear-all (isPrimary:false) then set-one (isPrimary:true).
|
||||
expect(updateCalls).toHaveLength(2);
|
||||
expect(updateCalls[0].patch).toMatchObject({ isPrimary: false });
|
||||
expect(updateCalls[1].patch).toMatchObject({ isPrimary: true });
|
||||
});
|
||||
|
||||
it("throws when the event is not linked to a tournament", async () => {
|
||||
mockFindFirst.mockResolvedValue({ id: "ev-4", tournamentId: null });
|
||||
await expect(setPrimaryEvent("ev-4")).rejects.toThrow(/not linked to a tournament/);
|
||||
expect(updateCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -71,4 +71,44 @@ describe("simulator input model", () => {
|
|||
expect(byParticipant.get("generated-rating")?.rating).toBeNull();
|
||||
expect(byParticipant.get("legacy-direct-rating")?.rating).toBe(27.5);
|
||||
});
|
||||
|
||||
it("keeps direct sourceElo but suppresses generated sourceElo", async () => {
|
||||
mockDb.query.seasonParticipants.findMany.mockResolvedValue([
|
||||
{ id: "direct-elo" },
|
||||
{ id: "generated-elo" },
|
||||
]);
|
||||
mockDb.query.seasonParticipantSimulatorInputs.findMany.mockResolvedValue([
|
||||
{
|
||||
participantId: "direct-elo",
|
||||
sourceOdds: null,
|
||||
sourceElo: 1600,
|
||||
worldRanking: null,
|
||||
rating: null,
|
||||
projectedWins: null,
|
||||
projectedTablePoints: null,
|
||||
seed: null,
|
||||
region: null,
|
||||
metadata: { sourceEloMethod: "direct" },
|
||||
},
|
||||
{
|
||||
participantId: "generated-elo",
|
||||
sourceOdds: 750,
|
||||
sourceElo: 1480,
|
||||
worldRanking: null,
|
||||
rating: null,
|
||||
projectedWins: null,
|
||||
projectedTablePoints: null,
|
||||
seed: null,
|
||||
region: null,
|
||||
metadata: { sourceEloMethod: "sourceOdds" },
|
||||
},
|
||||
]);
|
||||
mockDb.query.seasonParticipantExpectedValues.findMany.mockResolvedValue([]);
|
||||
|
||||
const inputs = await getParticipantSimulatorInputs("season-1");
|
||||
const byParticipant = new Map(inputs.map((input) => [input.participantId, input]));
|
||||
|
||||
expect(byParticipant.get("direct-elo")?.sourceElo).toBe(1600);
|
||||
expect(byParticipant.get("generated-elo")?.sourceElo).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,7 +27,11 @@ vi.mock("drizzle-orm", () => ({
|
|||
asc: (col: unknown) => ({ type: "asc", col }),
|
||||
}));
|
||||
|
||||
import { findAllSportsSeasons, findDraftableSportsSeasons } from "../sports-season";
|
||||
import {
|
||||
findAllSportsSeasons,
|
||||
findDraftableSportsSeasons,
|
||||
findDraftScheduleForHorizon,
|
||||
} from "../sports-season";
|
||||
import { database } from "~/database/context";
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
|
@ -125,3 +129,58 @@ describe("findDraftableSportsSeasons", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findDraftScheduleForHorizon", () => {
|
||||
const horizonWindows = [
|
||||
{
|
||||
id: "w1",
|
||||
name: "2026 NBA Playoffs",
|
||||
year: 2026,
|
||||
status: "upcoming",
|
||||
draftOn: today,
|
||||
draftOff: future,
|
||||
sport: { id: "nba", name: "NBA", slug: "nba", iconUrl: null },
|
||||
},
|
||||
{
|
||||
id: "w2",
|
||||
name: "2026 F1 Season",
|
||||
year: 2026,
|
||||
status: "active",
|
||||
draftOn: today,
|
||||
draftOff: future,
|
||||
sport: { id: "f1", name: "F1", slug: "f1", iconUrl: null },
|
||||
},
|
||||
];
|
||||
|
||||
it("maps rows to windows carrying their sport info", async () => {
|
||||
vi.mocked(database).mockReturnValue(makeMockDb(horizonWindows) as never);
|
||||
const result = await findDraftScheduleForHorizon(6);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toMatchObject({
|
||||
id: "w1",
|
||||
draftOn: today,
|
||||
draftOff: future,
|
||||
sport: { id: "nba", name: "NBA" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an empty array when no windows overlap the horizon", async () => {
|
||||
vi.mocked(database).mockReturnValue(makeMockDb([]) as never);
|
||||
const result = await findDraftScheduleForHorizon(12);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("queries with a where clause, ordering, and the sport relation", async () => {
|
||||
const mockDb = makeMockDb([]);
|
||||
vi.mocked(database).mockReturnValue(mockDb as never);
|
||||
|
||||
await findDraftScheduleForHorizon(6);
|
||||
expect(mockDb.query.sportsSeasons.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.anything(),
|
||||
orderBy: expect.anything(),
|
||||
with: expect.anything(),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
findTournamentBySportNameYear,
|
||||
upsertTournament,
|
||||
updateTournamentStatus,
|
||||
deleteTournament,
|
||||
} from "../tournament";
|
||||
import { database } from "~/database/context";
|
||||
|
||||
|
|
@ -172,3 +173,26 @@ 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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,7 +15,10 @@
|
|||
import { database } from "~/database/context";
|
||||
import { cs2MajorStageResults, seasonParticipants, eventResults } from "~/database/schema";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { getQPConfig, recalculateParticipantQP, calculateSplitQualifyingPoints } from "~/models/qualifying-points";
|
||||
import { getQPConfig, calculateSplitQualifyingPoints, writeEventResultsQP, recalculateParticipantQP } from "~/models/qualifying-points";
|
||||
import { deleteEventResults } from "~/models/event-result";
|
||||
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
|
||||
import { logger } from "~/lib/logger";
|
||||
|
||||
export interface Cs2StageResult {
|
||||
id: string;
|
||||
|
|
@ -126,14 +129,57 @@ export async function upsertCs2StageAssignments(
|
|||
* Called when the admin resets the event setup.
|
||||
*/
|
||||
export async function clearCs2StageAssignments(
|
||||
scoringEventId: string
|
||||
scoringEventId: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<void> {
|
||||
const db = database();
|
||||
const db = providedDb || database();
|
||||
await db
|
||||
.delete(cs2MajorStageResults)
|
||||
.where(eq(cs2MajorStageResults.scoringEventId, scoringEventId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully reset a CS2 Major event so it can be set up again from scratch.
|
||||
*
|
||||
* Clears more than stage assignments alone: it also removes every recorded
|
||||
* result for the event and recomputes the cached QP totals of the affected
|
||||
* participants. This matters because a stale stage assignment can leave a
|
||||
* phantom participant (e.g. an academy team entered in place of the main
|
||||
* roster) with provisional QP in event_results and a cached total that would
|
||||
* otherwise survive a stage-only reset and keep polluting the standings and
|
||||
* the EV simulation field.
|
||||
*
|
||||
* Deliberately does NOT touch the Champions-Stage bracket (playoffMatches) —
|
||||
* re-entry realigns the stage data to the existing bracket.
|
||||
*/
|
||||
export async function resetCs2Event(
|
||||
scoringEventId: string,
|
||||
sportsSeasonId: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<void> {
|
||||
const db = providedDb || database();
|
||||
|
||||
// Capture participants with results first so we can recompute their cached
|
||||
// QP totals after the rows are deleted (mirrors deleteScoringEvent).
|
||||
const existing = await db
|
||||
.select({ participantId: eventResults.seasonParticipantId })
|
||||
.from(eventResults)
|
||||
.where(eq(eventResults.scoringEventId, scoringEventId));
|
||||
const affectedParticipantIds = [...new Set(existing.map((r) => r.participantId))];
|
||||
|
||||
// Delete + recompute atomically so a mid-operation failure can't leave the
|
||||
// event torn (e.g. assignments gone but stale QP totals surviving in the
|
||||
// standings) — the exact phantom-QP state this reset exists to clear.
|
||||
await db.transaction(async (tx) => {
|
||||
await clearCs2StageAssignments(scoringEventId, tx);
|
||||
await deleteEventResults(scoringEventId, tx);
|
||||
|
||||
for (const participantId of affectedParticipantIds) {
|
||||
await recalculateParticipantQP(participantId, sportsSeasonId, tx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark teams as eliminated from a specific stage.
|
||||
* Each elimination entry specifies which stage the team was eliminated at —
|
||||
|
|
@ -394,34 +440,30 @@ export async function assignCs2EliminationQP(
|
|||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
await Promise.all(
|
||||
[...resultsByParticipant.entries()].map(([seasonParticipantId, { qp, placement }]) =>
|
||||
db
|
||||
.insert(eventResults)
|
||||
.values({
|
||||
scoringEventId,
|
||||
seasonParticipantId,
|
||||
qualifyingPointsAwarded: qp.toString(),
|
||||
placement,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [eventResults.scoringEventId, eventResults.seasonParticipantId],
|
||||
set: {
|
||||
qualifyingPointsAwarded: qp.toString(),
|
||||
placement,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
)
|
||||
// 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 Promise.all(
|
||||
[...resultsByParticipant.keys()].map((seasonParticipantId) =>
|
||||
recalculateParticipantQP(seasonParticipantId, sportsSeasonId)
|
||||
)
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@
|
|||
*/
|
||||
|
||||
import { database } from "~/database/context";
|
||||
import { seasonParticipantExpectedValues, seasonParticipants, seasonParticipantSimulatorInputs } from "~/database/schema";
|
||||
import { eq, and, count, inArray, sql } from "drizzle-orm";
|
||||
import { seasonParticipantExpectedValues, seasonParticipants } from "~/database/schema";
|
||||
import { eq, and, count, sql } from "drizzle-orm";
|
||||
import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator";
|
||||
import { calculateEV, normalizeProbabilities, calculateReplacementLevel, calculateVORP } from "~/services/ev-calculator";
|
||||
import { batchSaveParticipantSimulatorSourceOdds, batchUpsertParticipantSimulatorInputs } from "~/models/simulator";
|
||||
import { batchUpsertParticipantSimulatorInputs } from "~/models/simulator";
|
||||
|
||||
export type ProbabilitySource = "manual" | "futures_odds" | "elo_simulation" | "performance_model";
|
||||
|
||||
|
|
@ -366,95 +366,6 @@ export async function batchUpsertParticipantEVs(
|
|||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save American odds for a batch of seasonParticipants without touching probabilities or EV.
|
||||
* Used by the futures-odds admin page to persist odds before running the full simulation.
|
||||
*/
|
||||
export async function batchSaveSourceOdds(
|
||||
inputs: Array<{ participantId: string; sportsSeasonId: string; sourceOdds: number }>
|
||||
): Promise<void> {
|
||||
const db = database();
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const now = new Date();
|
||||
|
||||
for (const { participantId, sportsSeasonId, sourceOdds } of inputs) {
|
||||
const existing = await tx
|
||||
.select({ id: seasonParticipantExpectedValues.id })
|
||||
.from(seasonParticipantExpectedValues)
|
||||
.where(
|
||||
and(
|
||||
eq(seasonParticipantExpectedValues.participantId, participantId),
|
||||
eq(seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
await tx
|
||||
.update(seasonParticipantExpectedValues)
|
||||
.set({ sourceOdds, updatedAt: now })
|
||||
.where(eq(seasonParticipantExpectedValues.id, existing[0].id));
|
||||
} else {
|
||||
// Insert a stub record — probabilities/EV will be filled in by the simulator
|
||||
await tx.insert(seasonParticipantExpectedValues).values({
|
||||
participantId,
|
||||
sportsSeasonId,
|
||||
probFirst: "0",
|
||||
probSecond: "0",
|
||||
probThird: "0",
|
||||
probFourth: "0",
|
||||
probFifth: "0",
|
||||
probSixth: "0",
|
||||
probSeventh: "0",
|
||||
probEighth: "0",
|
||||
expectedValue: "0",
|
||||
source: "futures_odds",
|
||||
sourceOdds,
|
||||
calculatedAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await batchSaveParticipantSimulatorSourceOdds(inputs);
|
||||
}
|
||||
|
||||
export async function clearSourceOddsForParticipants(
|
||||
sportsSeasonId: string,
|
||||
participantIds: string[]
|
||||
): Promise<void> {
|
||||
if (participantIds.length === 0) return;
|
||||
const db = database();
|
||||
const now = new Date();
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(seasonParticipantExpectedValues)
|
||||
.set({ sourceOdds: null, updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId),
|
||||
inArray(seasonParticipantExpectedValues.participantId, participantIds)
|
||||
)
|
||||
);
|
||||
await tx
|
||||
.update(seasonParticipantSimulatorInputs)
|
||||
.set({
|
||||
sourceOdds: null,
|
||||
rating: null,
|
||||
metadata: sql`coalesce(${seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod'`,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(seasonParticipantSimulatorInputs.sportsSeasonId, sportsSeasonId),
|
||||
inArray(seasonParticipantSimulatorInputs.participantId, participantIds)
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist raw Elo ratings (and optional world rankings) for a batch of seasonParticipants.
|
||||
* Used by Elo-based simulators like snooker_bracket and darts_bracket.
|
||||
|
|
|
|||
|
|
@ -95,9 +95,10 @@ export async function deleteParticipantResult(id: string): Promise<void> {
|
|||
}
|
||||
|
||||
export async function deleteParticipantResultsBySportsSeasonId(
|
||||
sportsSeasonId: string
|
||||
sportsSeasonId: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<void> {
|
||||
const db = database();
|
||||
const db = providedDb || database();
|
||||
await db
|
||||
.delete(schema.seasonParticipantResults)
|
||||
.where(eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId));
|
||||
|
|
|
|||
|
|
@ -142,6 +142,108 @@ 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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -33,6 +33,33 @@ 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,
|
||||
|
|
@ -48,21 +75,6 @@ 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
|
||||
*/
|
||||
|
|
@ -283,6 +295,57 @@ export async function recalculateParticipantQP(
|
|||
return { totalQP, eventsScored };
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert qualifying points + placement into event_results for a set of participants,
|
||||
* then recalculate each participant's QP total.
|
||||
*
|
||||
* Shared by the QP writers (assignCs2EliminationQP for Swiss-stage exits,
|
||||
* processQualifyingBracketEvent for Champions-Stage / knockout brackets) so the
|
||||
* upsert + recalc loop lives in one place. Upserts on
|
||||
* (scoringEventId, seasonParticipantId), so repeated calls overwrite cleanly.
|
||||
*
|
||||
* @param entries Map from seasonParticipantId → { qp, placement } to write.
|
||||
*/
|
||||
export async function writeEventResultsQP(
|
||||
scoringEventId: string,
|
||||
sportsSeasonId: string,
|
||||
entries: Map<string, { qp: number; placement: number }>,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<void> {
|
||||
if (entries.size === 0) return;
|
||||
const db = providedDb || database();
|
||||
const now = new Date();
|
||||
|
||||
await Promise.all(
|
||||
[...entries.entries()].map(([seasonParticipantId, { qp, placement }]) =>
|
||||
db
|
||||
.insert(schema.eventResults)
|
||||
.values({
|
||||
scoringEventId,
|
||||
seasonParticipantId,
|
||||
qualifyingPointsAwarded: qp.toFixed(2),
|
||||
placement,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [schema.eventResults.scoringEventId, schema.eventResults.seasonParticipantId],
|
||||
set: {
|
||||
qualifyingPointsAwarded: qp.toFixed(2),
|
||||
placement,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
[...entries.keys()].map((seasonParticipantId) =>
|
||||
recalculateParticipantQP(seasonParticipantId, sportsSeasonId, db)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate eventsScored for a participant by counting unique events with QP awarded
|
||||
* @deprecated Use recalculateParticipantQP instead for more accurate recalculation
|
||||
|
|
|
|||
|
|
@ -9,8 +9,9 @@ import {
|
|||
} from "./scoring-rules";
|
||||
import { getSeasonResults } from "./participant-season-result";
|
||||
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
||||
import { sendStandingsUpdateNotification, type ScoredMatch } from "~/services/discord";
|
||||
import { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
|
||||
import { sendStandingsUpdateNotification, type ScoredMatch, type EliminatedTeam } from "~/services/discord";
|
||||
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
|
||||
import { BRACKET_TEMPLATES, type BracketRound } from "~/lib/bracket-templates";
|
||||
import { doesLoserAdvance, findPlayoffMatchesByEventId } from "~/models/playoff-match";
|
||||
import { getUserDisplayName } from "~/models/user";
|
||||
import { findDiscordIdsByUserIds } from "~/models/account";
|
||||
|
|
@ -20,9 +21,10 @@ import { logger } from "~/lib/logger";
|
|||
import { getEventResults } from "./event-result";
|
||||
import {
|
||||
calculateSplitQualifyingPoints,
|
||||
diffChangedQualifyingPoints,
|
||||
getQPConfig,
|
||||
hasProcessedQualifyingPlacement,
|
||||
recalculateParticipantQP,
|
||||
writeEventResultsQP,
|
||||
getQPStandings,
|
||||
updateFinalRankings,
|
||||
} from "./qualifying-points";
|
||||
|
|
@ -111,6 +113,16 @@ 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 },
|
||||
},
|
||||
tennis_128: {
|
||||
// R16 losers share 9th–16th; winner advances to QF (floor 5th–8th).
|
||||
"Round of 16": { loserPosition: 9, loserIsPartial: false, winnerFloor: 5 },
|
||||
// QF losers share 5th–8th; winner advances to SF (floor 3rd–4th).
|
||||
Quarterfinals: { loserPosition: 5, loserIsPartial: false, winnerFloor: 3 },
|
||||
// SF losers share 3rd–4th; winner advances to Final (floor 2nd).
|
||||
Semifinals: { loserPosition: 3, loserIsPartial: false, winnerFloor: 2 },
|
||||
// Final finalizes both: winner 1st, loser 2nd.
|
||||
Final: { loserPosition: 2, loserIsPartial: false, winnerFloor: null },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -143,7 +155,7 @@ function doesNonScoringRoundFeedIntoScoringRound(
|
|||
* overrides before falling back to the standard ROUND_CONFIG.
|
||||
* Returns null for unrecognized rounds.
|
||||
*/
|
||||
function getRoundConfig(
|
||||
export function getRoundConfig(
|
||||
round: string,
|
||||
bracketTemplateId?: string | null
|
||||
): RoundScoringConfig | null {
|
||||
|
|
@ -612,6 +624,7 @@ export function isLoserNotifiable(
|
|||
return scoreChanged || finalizedLoserIds.has(loserId);
|
||||
}
|
||||
|
||||
|
||||
export function getGuaranteedMinimumPosition(
|
||||
round: string,
|
||||
bracketTemplateId: string | null | undefined,
|
||||
|
|
@ -623,13 +636,260 @@ export function getGuaranteedMinimumPosition(
|
|||
return config.winnerFloor; // null for Finals; actual floor for all other rounds
|
||||
}
|
||||
|
||||
// ── Qualifying-points bracket scoring ────────────────────────────────────────
|
||||
|
||||
/** Minimal shape of a playoff match needed to derive qualifying-bracket states. */
|
||||
export interface BracketMatchInput {
|
||||
round: string;
|
||||
winnerId: string | null;
|
||||
loserId: string | null;
|
||||
participant1Id: string | null;
|
||||
participant2Id: string | null;
|
||||
}
|
||||
|
||||
/** The guaranteed-minimum placement (and its structural tie span) for one team. */
|
||||
export interface BracketQualifyingState {
|
||||
/** Placement tier the team has locked in (1, 2, 3, 5, …). */
|
||||
placement: number;
|
||||
/**
|
||||
* Number of bracket slots this placement tier spans (e.g. 4 for T5–8, 2 for
|
||||
* T3–4, 1 for 1st/2nd). Used to tie-split QP across the tier. This is the
|
||||
* *structural* span from the template — not the live count of teams currently
|
||||
* sitting at this placement — so the floor value is stable and idempotent and
|
||||
* agrees with processQualifyingEvent's finalization re-grouping.
|
||||
*/
|
||||
tieCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive each bracket team's guaranteed-minimum (placement, tieCount) from the
|
||||
* current set of playoff matches, reusing the standard ROUND_CONFIG floors.
|
||||
*
|
||||
* Rules per team (single-elimination ⇒ a team loses at most once):
|
||||
* - Lost in round R → final placement = loserPosition(R), tier span = matchCount(R).
|
||||
* (QF loss → 5/T5–8, SF loss → 3/T3–4, Finals loss → 2/finalist)
|
||||
* - Still alive, highest round won = R:
|
||||
* · R is the finalization round (winnerFloor null) → champion: winnerPosition ?? 1.
|
||||
* · otherwise → floor = winnerFloor(R), tier span = matchCount(R.feedsInto).
|
||||
* (QF win → 3/T3–4 = 9 QP, SF win → 2/finalist = 14 QP)
|
||||
* - Alive but no match resolved yet → entry floor of the first scoring round
|
||||
* (QF → 5/T5–8 = 4 QP), mirroring assignCs2EliminationQP's provisional floor.
|
||||
*
|
||||
* Pure and exported for unit testing. Teams eliminated in a non-scoring round
|
||||
* (getConfig returns null) are omitted — they earn no QP from the bracket.
|
||||
*/
|
||||
export function deriveBracketQualifyingStates(
|
||||
matches: BracketMatchInput[],
|
||||
rounds: BracketRound[],
|
||||
getConfig: (round: string) => RoundScoringConfig | null
|
||||
): Map<string, BracketQualifyingState> {
|
||||
const roundIndex = new Map(rounds.map((r, i) => [r.name, i]));
|
||||
const matchCountByRound = new Map(rounds.map((r) => [r.name, r.matchCount]));
|
||||
const feedsIntoByRound = new Map(rounds.map((r) => [r.name, r.feedsInto]));
|
||||
const firstScoringRound = rounds.find((r) => r.isScoring) ?? rounds[0];
|
||||
|
||||
const participants = new Set<string>();
|
||||
const wonRounds = new Map<string, Set<string>>();
|
||||
const lostRound = new Map<string, string>();
|
||||
|
||||
for (const m of matches) {
|
||||
if (m.participant1Id) participants.add(m.participant1Id);
|
||||
if (m.participant2Id) participants.add(m.participant2Id);
|
||||
if (m.winnerId) {
|
||||
participants.add(m.winnerId);
|
||||
if (!wonRounds.has(m.winnerId)) wonRounds.set(m.winnerId, new Set());
|
||||
wonRounds.get(m.winnerId)?.add(m.round);
|
||||
}
|
||||
if (m.loserId) {
|
||||
participants.add(m.loserId);
|
||||
lostRound.set(m.loserId, m.round);
|
||||
}
|
||||
}
|
||||
|
||||
const result = new Map<string, BracketQualifyingState>();
|
||||
|
||||
for (const id of participants) {
|
||||
const lost = lostRound.get(id);
|
||||
if (lost) {
|
||||
const cfg = getConfig(lost);
|
||||
if (!cfg) continue; // non-scoring elimination: no bracket QP
|
||||
result.set(id, { placement: cfg.loserPosition, tieCount: matchCountByRound.get(lost) ?? 1 });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Still alive: floor from the highest round they have won.
|
||||
const won = wonRounds.get(id);
|
||||
let highest: string | null = null;
|
||||
if (won) {
|
||||
for (const r of won) {
|
||||
if (highest === null || (roundIndex.get(r) ?? -1) > (roundIndex.get(highest) ?? -1)) {
|
||||
highest = r;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!highest) {
|
||||
// In the bracket but no match resolved yet → sitting in the first round.
|
||||
// Only grant an entry floor if that first round is itself a scoring round
|
||||
// (e.g. simple_8 / CS2 Champions Stage, where every entrant is already in
|
||||
// the scoring stage). For deep brackets whose scoring starts later
|
||||
// (tennis_128: Round of 128 → … → Round of 16), merely being drawn earns
|
||||
// nothing — no QP until the player reaches the first scoring round.
|
||||
if (!rounds[0]?.isScoring || !firstScoringRound) continue;
|
||||
const cfg = getConfig(firstScoringRound.name);
|
||||
if (!cfg) continue;
|
||||
result.set(id, { placement: cfg.loserPosition, tieCount: firstScoringRound.matchCount });
|
||||
continue;
|
||||
}
|
||||
|
||||
const cfg = getConfig(highest);
|
||||
if (!cfg) {
|
||||
// Won a non-scoring round. If that win advanced them INTO a scoring round,
|
||||
// they've guaranteed that round's loser floor (e.g. a tennis R32 win → in
|
||||
// the Round of 16 → guaranteed T9–16). Otherwise (won an earlier
|
||||
// non-scoring round) they've earned no QP yet.
|
||||
const feedsInto = feedsIntoByRound.get(highest) ?? null;
|
||||
const nextRound = feedsInto ? rounds.find((r) => r.name === feedsInto) : null;
|
||||
if (nextRound?.isScoring) {
|
||||
const floorCfg = getConfig(nextRound.name);
|
||||
if (floorCfg) {
|
||||
result.set(id, {
|
||||
placement: floorCfg.loserPosition,
|
||||
tieCount: nextRound.matchCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (cfg.winnerFloor === null) {
|
||||
// Won the finalization round → champion (or 3rd-place-game winner).
|
||||
result.set(id, { placement: cfg.winnerPosition ?? 1, tieCount: 1 });
|
||||
} else {
|
||||
const next = feedsIntoByRound.get(highest) ?? null;
|
||||
const tieCount = next ? (matchCountByRound.get(next) ?? 1) : 1;
|
||||
result.set(id, { placement: cfg.winnerFloor, tieCount });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Score a qualifying event whose Champions-Stage / knockout bracket lives in
|
||||
* playoffMatches (e.g. a CS2 Major). Derives each bracket team's guaranteed
|
||||
* minimum QP from the matches and upserts it into event_results, then refreshes
|
||||
* each participant's QP total.
|
||||
*
|
||||
* Unlike processPlayoffEvent (which writes the fantasy-points table
|
||||
* seasonParticipantResults), this writes ONLY the QP path. For a
|
||||
* qualifying_points sport the per-major fantasy placement is meaningless — final
|
||||
* fantasy placements come solely from finalizeQualifyingPoints across all majors.
|
||||
*
|
||||
* QP is written directly with the placement tier's STRUCTURAL tie span (QF tier
|
||||
* spans 4 slots, SF tier 2, finalist/champion 1) — not the live count of teams
|
||||
* currently at a placement. This is what makes a QF-winner floor 9 QP (avg of
|
||||
* 3rd+4th) even while all four QF winners transiently share placement 3.
|
||||
* processQualifyingEvent delegates to this function for bracket events precisely
|
||||
* so it does NOT re-group those rows by live count (which would average four
|
||||
* placement-3 rows down to 7).
|
||||
*
|
||||
* Idempotent: re-running on the same match state recomputes identical values.
|
||||
*/
|
||||
export async function processQualifyingBracketEvent(
|
||||
eventId: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<void> {
|
||||
const db = providedDb || database();
|
||||
|
||||
const event = await db.query.scoringEvents.findFirst({
|
||||
where: eq(schema.scoringEvents.id, eventId),
|
||||
});
|
||||
if (!event) throw new Error(`Event ${eventId} not found`);
|
||||
if (!event.isQualifyingEvent) {
|
||||
throw new Error(`Event ${eventId} is not a qualifying event`);
|
||||
}
|
||||
|
||||
const template = event.bracketTemplateId ? BRACKET_TEMPLATES[event.bracketTemplateId] : undefined;
|
||||
if (!template) {
|
||||
throw new Error(`Event ${eventId} has no bracket template; cannot derive qualifying bracket QP`);
|
||||
}
|
||||
|
||||
const matches = await db.query.playoffMatches.findMany({
|
||||
where: eq(schema.playoffMatches.scoringEventId, eventId),
|
||||
});
|
||||
|
||||
const states = deriveBracketQualifyingStates(
|
||||
matches.map((m) => ({
|
||||
round: m.round,
|
||||
winnerId: m.winnerId,
|
||||
loserId: m.loserId,
|
||||
participant1Id: m.participant1Id,
|
||||
participant2Id: m.participant2Id,
|
||||
})),
|
||||
template.rounds,
|
||||
(round) => getRoundConfig(round, event.bracketTemplateId)
|
||||
);
|
||||
|
||||
if (states.size === 0) return;
|
||||
|
||||
const qpConfigArray = await getQPConfig(event.sportsSeasonId, db);
|
||||
const qpMap = new Map<number, number>(
|
||||
qpConfigArray.map((c) => [c.placement, parseFloat(c.points)])
|
||||
);
|
||||
|
||||
const entries = new Map<string, { qp: number; placement: number }>(
|
||||
[...states.entries()].map(([id, { placement, tieCount }]) => [
|
||||
id,
|
||||
{ qp: calculateSplitQualifyingPoints(placement, tieCount, qpMap), placement },
|
||||
])
|
||||
);
|
||||
|
||||
await writeEventResultsQP(eventId, event.sportsSeasonId, entries, db);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count how many results share each placement — the structural tie span used to
|
||||
* split QP across a tied group. Callers pass the FULL canonical field
|
||||
* (tournament_results) so the span reflects the whole tournament, not one window's
|
||||
* roster subset. Null placements (filler / not-participating) are ignored.
|
||||
*/
|
||||
export function buildTieCountByPlacement(
|
||||
results: Array<{ placement: number | null }>
|
||||
): Map<number, number> {
|
||||
const map = new Map<number, number>();
|
||||
for (const r of results) {
|
||||
if (r.placement === null) continue;
|
||||
map.set(r.placement, (map.get(r.placement) ?? 0) + 1);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a qualifying event completion and update QP totals.
|
||||
* Ties in QP are handled by sharing placements (averaged points).
|
||||
*/
|
||||
export async function processQualifyingEvent(
|
||||
eventId: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
providedDb?: ReturnType<typeof database>,
|
||||
options: {
|
||||
skipNotifications?: boolean;
|
||||
/**
|
||||
* Pre-computed full-field tie span (placement → count) from the canonical
|
||||
* tournament_results. When the fan-out already loaded the canonical results it
|
||||
* passes this in so we don't re-query per window. Omitted for direct callers,
|
||||
* which fall back to querying it here.
|
||||
*/
|
||||
canonicalTieCountByPlacement?: Map<number, number>;
|
||||
/**
|
||||
* This window's season_participant ids that were knocked out this sync in a
|
||||
* non-scoring round. They earn no QP (so they never surface via changed QP),
|
||||
* but a manager who drafted them should still be told. Threaded down from the
|
||||
* primary bracket by the fan-out (syncTournamentResults), already translated
|
||||
* to THIS window's season_participant ids. See the primary path in
|
||||
* app/services/match-sync/index.ts (newlyEliminatedIds).
|
||||
*/
|
||||
newlyEliminatedParticipantIds?: Set<string>;
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
const db = providedDb || database();
|
||||
|
||||
|
|
@ -652,55 +912,109 @@ export async function processQualifyingEvent(
|
|||
// Get all event results for this qualifying event
|
||||
const results = await getEventResults(eventId, db);
|
||||
|
||||
// Check if this was already processed (for majorsCompleted counter)
|
||||
const wasAlreadyProcessed = hasProcessedQualifyingPlacement(results);
|
||||
// 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,
|
||||
}));
|
||||
|
||||
// Clear stale QP before reprocessing so removed/null placements do not keep old awards.
|
||||
for (const result of results) {
|
||||
await db
|
||||
.update(schema.eventResults)
|
||||
.set({
|
||||
qualifyingPointsAwarded: "0",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.eventResults.id, result.id));
|
||||
// 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;
|
||||
}
|
||||
|
||||
const qpConfig = await getQPConfig(event.sportsSeasonId, db);
|
||||
const pointsByPlacement = new Map(
|
||||
qpConfig.map((config) => [config.placement, parseFloat(config.points)])
|
||||
);
|
||||
|
||||
// Group results by placement to handle ties
|
||||
const placementGroups = new Map<number, typeof results>();
|
||||
for (const result of results) {
|
||||
if (result.placement) {
|
||||
const group = placementGroups.get(result.placement) || [];
|
||||
group.push(result);
|
||||
placementGroups.set(result.placement, group);
|
||||
}
|
||||
}
|
||||
|
||||
// Process each placement group and update event_results with QP awarded
|
||||
for (const [placement, group] of placementGroups) {
|
||||
const tieCount = group.length;
|
||||
|
||||
const qpPerParticipant = calculateSplitQualifyingPoints(
|
||||
placement,
|
||||
tieCount,
|
||||
pointsByPlacement
|
||||
);
|
||||
|
||||
// Update the event result with the QP awarded
|
||||
for (const result of group) {
|
||||
if (hasBracketMatches) {
|
||||
// Bracket-based qualifying event (e.g. CS2 Champions Stage): QP is owned by the
|
||||
// bracket/stage writers, which assign each placement its STRUCTURAL tie span.
|
||||
// Re-derive via processQualifyingBracketEvent and leave the Swiss-exit rows
|
||||
// (written by assignCs2EliminationQP) untouched. This deliberately skips the
|
||||
// placement-regroup below — re-grouping provisional floors by their LIVE count
|
||||
// would average four placement-3 QF winners down to 7 QP instead of their 9 floor.
|
||||
await processQualifyingBracketEvent(eventId, db);
|
||||
} else {
|
||||
// Clear stale QP before reprocessing so removed/null placements do not keep old awards.
|
||||
for (const result of results) {
|
||||
await db
|
||||
.update(schema.eventResults)
|
||||
.set({
|
||||
qualifyingPointsAwarded: qpPerParticipant.toFixed(2),
|
||||
qualifyingPointsAwarded: "0",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.eventResults.id, result.id));
|
||||
}
|
||||
|
||||
const qpConfig = await getQPConfig(event.sportsSeasonId, db);
|
||||
const pointsByPlacement = new Map(
|
||||
qpConfig.map((config) => [config.placement, parseFloat(config.points)])
|
||||
);
|
||||
|
||||
// Full-field tie span. The number of players tied at a placement is a property
|
||||
// of the whole tournament field (canonical tournament_results), NOT of who
|
||||
// happens to be on THIS window's roster. Sibling/mirror windows only hold the
|
||||
// draftable subset of the field, so counting the placements present locally
|
||||
// (group.length) under-counts a tied group and over-awards it: tennis R16 losers
|
||||
// all sit at placement 9 with a structural span of 8 → (2+2+2+2+1+1+1+1)/8 = 1.5
|
||||
// QP; a window holding only 4 of them would wrongly split 4 ways → 2 QP. Deriving
|
||||
// the span from canonical results keeps every window/league identical. The fan-out
|
||||
// passes this map in (already loaded once per tournament); direct callers with a
|
||||
// tournament link query it here. Standalone events (no tournamentId, no map) have
|
||||
// no canonical field, so fall back to the live count.
|
||||
const canonicalTieCountByPlacement: Map<number, number> | null =
|
||||
options.canonicalTieCountByPlacement ??
|
||||
(event.tournamentId
|
||||
? buildTieCountByPlacement(
|
||||
await db
|
||||
.select({ placement: schema.tournamentResults.placement })
|
||||
.from(schema.tournamentResults)
|
||||
.where(eq(schema.tournamentResults.tournamentId, event.tournamentId))
|
||||
)
|
||||
: null);
|
||||
|
||||
// Group results by placement to handle ties
|
||||
const placementGroups = new Map<number, typeof results>();
|
||||
for (const result of results) {
|
||||
if (result.placement) {
|
||||
const group = placementGroups.get(result.placement) || [];
|
||||
group.push(result);
|
||||
placementGroups.set(result.placement, group);
|
||||
}
|
||||
}
|
||||
|
||||
// Process each placement group and update event_results with QP awarded
|
||||
for (const [placement, group] of placementGroups) {
|
||||
const tieCount = canonicalTieCountByPlacement?.get(placement) ?? group.length;
|
||||
|
||||
const qpPerParticipant = calculateSplitQualifyingPoints(
|
||||
placement,
|
||||
tieCount,
|
||||
pointsByPlacement
|
||||
);
|
||||
|
||||
// Update the event result with the QP awarded
|
||||
for (const result of group) {
|
||||
await db
|
||||
.update(schema.eventResults)
|
||||
.set({
|
||||
qualifyingPointsAwarded: qpPerParticipant.toFixed(2),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.eventResults.id, result.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recalculate totals for all participants from scratch
|
||||
|
|
@ -710,21 +1024,46 @@ export async function processQualifyingEvent(
|
|||
await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
|
||||
}
|
||||
|
||||
// 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));
|
||||
}
|
||||
// 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.
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1425,7 +1764,7 @@ export async function recalculateStandings(
|
|||
export async function recalculateAffectedLeagues(
|
||||
sportsSeasonId: string,
|
||||
providedDb?: ReturnType<typeof database>,
|
||||
options?: { eventName?: string; eventId?: string; matchIds?: string[]; skipDiscord?: boolean }
|
||||
options?: { eventName?: string; eventId?: string; matchIds?: string[]; skipDiscord?: boolean; eliminatedParticipantIds?: string[] }
|
||||
): Promise<void> {
|
||||
const db = providedDb || database();
|
||||
|
||||
|
|
@ -1494,6 +1833,18 @@ 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
|
||||
|
|
@ -1568,20 +1919,33 @@ 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.
|
||||
// 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.
|
||||
// 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.
|
||||
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)) ||
|
||||
|
|
@ -1589,10 +1953,6 @@ 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);
|
||||
|
|
@ -1609,23 +1969,52 @@ export async function recalculateAffectedLeagues(
|
|||
const winnerOwnerId = lookupOwner(m.winnerId);
|
||||
const loserOwnerId = lookupOwner(m.loserId);
|
||||
const showLoser = isLoserNotifiable(m.loserId, loserTeamId, changedTeamIds, finalizedLoserIds);
|
||||
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,
|
||||
};
|
||||
});
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Skip notification if scores didn't change AND no match has a displayable username.
|
||||
// Build the eliminated-teams section: drafted participants in this league that
|
||||
// were knocked out by this action (bracket/knockout generation). These have a
|
||||
// 0-pt result and no match, so they only surface here.
|
||||
let eliminatedTeams: EliminatedTeam[] | undefined;
|
||||
if (eliminatedIds.length > 0) {
|
||||
const teams = eliminatedIds
|
||||
.filter((id) => teamIdByParticipantId.has(id))
|
||||
.map((id) => {
|
||||
const teamId = teamIdByParticipantId.get(id);
|
||||
const ownerId = teamId ? ownerIdByTeamId.get(teamId) : undefined;
|
||||
return {
|
||||
participantName: eliminatedNameById.get(id) ?? "Unknown",
|
||||
username: ownerId ? usernameByUserId.get(ownerId) : undefined,
|
||||
discordUserId: ownerId ? discordIdByUserId.get(ownerId) : undefined,
|
||||
};
|
||||
});
|
||||
if (teams.length > 0) eliminatedTeams = teams;
|
||||
}
|
||||
|
||||
// Skip notification if scores didn't change AND no match has a displayable
|
||||
// username AND there's nothing eliminated to announce.
|
||||
const hasScoredMatchesToShow = scoredMatches?.some(
|
||||
(m) => m.winnerUsername !== undefined || m.loserUsername !== undefined
|
||||
);
|
||||
if (!hasChanges && !hasScoredMatchesToShow) continue;
|
||||
const hasEliminatedToShow = (eliminatedTeams?.length ?? 0) > 0;
|
||||
if (!hasChanges && !hasScoredMatchesToShow && !hasEliminatedToShow) continue;
|
||||
|
||||
const standings = afterStandings.map((s) => ({
|
||||
teamId: s.teamId,
|
||||
|
|
@ -1646,6 +2035,7 @@ export async function recalculateAffectedLeagues(
|
|||
sportName,
|
||||
eventName: options?.eventName,
|
||||
scoredMatches,
|
||||
eliminatedTeams,
|
||||
});
|
||||
} catch (err) {
|
||||
// Log but don't fail the scoring pipeline if Discord is unreachable
|
||||
|
|
|
|||
11
app/models/scoring-event-types.ts
Normal file
11
app/models/scoring-event-types.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export type EventType = "playoff_game" | "major_tournament" | "final_standings" | "schedule_event";
|
||||
|
||||
export function getEventTypeLabel(eventType: string): string {
|
||||
switch (eventType) {
|
||||
case "playoff_game": return "Bracket";
|
||||
case "major_tournament": return "Major Tournament";
|
||||
case "final_standings": return "Final Standings";
|
||||
case "schedule_event": return "Non-Scoring";
|
||||
default: return eventType;
|
||||
}
|
||||
}
|
||||
|
|
@ -3,20 +3,11 @@ import * as schema from "~/database/schema";
|
|||
import { eq, and, desc, asc, gte, lte, or, inArray, isNotNull, sql } from "drizzle-orm";
|
||||
import type { BracketRegion } from "~/lib/bracket-templates";
|
||||
import { recalculateAffectedLeagues } from "./scoring-calculator";
|
||||
import { hasProcessedQualifyingPlacement, recalculateParticipantQP } from "./qualifying-points";
|
||||
import { recalculateParticipantQP } from "./qualifying-points";
|
||||
import { findParticipantNamesByIds } from "./season-participant";
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
import { deleteTournament } from "./tournament";
|
||||
import type { EventType } from "./scoring-event-types";
|
||||
export { type EventType, getEventTypeLabel } from "./scoring-event-types";
|
||||
|
||||
export interface CreateScoringEventData {
|
||||
sportsSeasonId: string;
|
||||
|
|
@ -42,6 +33,8 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -151,6 +144,33 @@ export async function getQualifyingEvents(
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of "majors completed" for a sports season, derived on read as the count of
|
||||
* qualifying events that have been marked complete. Replaces the old stored
|
||||
* sportsSeasons.majorsCompleted counter, which was incremented per fan-out sync and
|
||||
* over-counted past totalMajors ("11 of 4"). Computing it makes the value
|
||||
* self-correcting and immune to double-counting.
|
||||
*/
|
||||
export async function getMajorsCompleted(
|
||||
sportsSeasonId: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<number> {
|
||||
const db = providedDb || database();
|
||||
|
||||
const rows = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(schema.scoringEvents)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||||
eq(schema.scoringEvents.isQualifyingEvent, true),
|
||||
eq(schema.scoringEvents.isComplete, true)
|
||||
)
|
||||
);
|
||||
|
||||
return rows[0]?.count ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a scoring event
|
||||
*/
|
||||
|
|
@ -173,6 +193,7 @@ 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)
|
||||
|
|
@ -208,28 +229,55 @@ 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>
|
||||
) {
|
||||
providedDb?: ReturnType<typeof database>,
|
||||
opts?: DeleteScoringEventOptions
|
||||
): Promise<DeleteScoringEventResult> {
|
||||
const db = providedDb || database();
|
||||
|
||||
const event = await db.query.scoringEvents.findFirst({
|
||||
where: eq(schema.scoringEvents.id, eventId),
|
||||
});
|
||||
|
||||
if (!event) return;
|
||||
if (!event) {
|
||||
return {
|
||||
tournamentId: null,
|
||||
remainingWindows: 0,
|
||||
promotedPrimaryId: null,
|
||||
deletedTournament: false,
|
||||
};
|
||||
}
|
||||
|
||||
// For qualifying events: capture affected participant IDs before the cascade deletes
|
||||
// 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) => {
|
||||
|
|
@ -252,21 +300,49 @@ export async function deleteScoringEvent(
|
|||
await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
|
||||
}
|
||||
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1067,3 +1143,112 @@ export async function getSportsSeasonsByTournament(tournamentId: string) {
|
|||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the distinct sports-season windows linked to a tournament. Cheaper than
|
||||
* getSportsSeasonsByTournament when only the count is needed (no relations).
|
||||
*/
|
||||
export async function countWindowsByTournament(
|
||||
tournamentId: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<number> {
|
||||
const db = providedDb || database();
|
||||
const [row] = await db
|
||||
.select({
|
||||
count: sql<number>`count(distinct ${schema.scoringEvents.sportsSeasonId})::int`,
|
||||
})
|
||||
.from(schema.scoringEvents)
|
||||
.where(eq(schema.scoringEvents.tournamentId, tournamentId));
|
||||
return row?.count ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The primary scoring event for a tournament — the single window where the admin
|
||||
* builds the bracket/stages and scoring happens; its results fan out to siblings.
|
||||
* Returns the explicitly-flagged primary, falling back to the earliest-created
|
||||
* linked event so callers (league/sim loaders) always have a structure source
|
||||
* even before a primary is designated.
|
||||
*/
|
||||
export async function getPrimaryEventForTournament(
|
||||
tournamentId: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
) {
|
||||
const db = providedDb || database();
|
||||
const primary = await db.query.scoringEvents.findFirst({
|
||||
where: and(
|
||||
eq(schema.scoringEvents.tournamentId, tournamentId),
|
||||
eq(schema.scoringEvents.isPrimary, true)
|
||||
),
|
||||
});
|
||||
if (primary) return primary;
|
||||
|
||||
return db.query.scoringEvents.findFirst({
|
||||
where: eq(schema.scoringEvents.tournamentId, tournamentId),
|
||||
orderBy: asc(schema.scoringEvents.createdAt),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A tournament-linked, non-primary event is a read-only mirror: its results are
|
||||
* owned by the shared major's primary window and fan out to it. Direct scoring
|
||||
* on such an event must be rejected so windows can't diverge. Single source of
|
||||
* truth for that predicate, shared by the admin route guards and the loaders.
|
||||
*/
|
||||
export function isReadOnlySibling(event: {
|
||||
tournamentId: string | null;
|
||||
isPrimary: boolean;
|
||||
}): boolean {
|
||||
return !!event.tournamentId && !event.isPrimary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make `eventId` the single primary window for its tournament — set it primary
|
||||
* and clear the flag on every sibling. Used by the "Make this the primary
|
||||
* window" admin action and to seed a primary when the first bracket-major event
|
||||
* is created.
|
||||
*/
|
||||
export async function setPrimaryEvent(
|
||||
eventId: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
) {
|
||||
const db = providedDb || database();
|
||||
const event = await db.query.scoringEvents.findFirst({
|
||||
where: eq(schema.scoringEvents.id, eventId),
|
||||
});
|
||||
if (!event?.tournamentId) {
|
||||
throw new Error(`Event ${eventId} is not linked to a tournament`);
|
||||
}
|
||||
await db
|
||||
.update(schema.scoringEvents)
|
||||
.set({ isPrimary: false, updatedAt: new Date() })
|
||||
.where(eq(schema.scoringEvents.tournamentId, event.tournamentId));
|
||||
await db
|
||||
.update(schema.scoringEvents)
|
||||
.set({ isPrimary: true, updatedAt: new Date() })
|
||||
.where(eq(schema.scoringEvents.id, eventId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Designate `eventId` as the tournament's primary window only if none is set
|
||||
* yet. Idempotent: returns the existing primary's id when one already exists, so
|
||||
* creating/linking additional windows never steals primary from the first.
|
||||
*/
|
||||
export async function ensurePrimaryEvent(
|
||||
tournamentId: string,
|
||||
eventId: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<string> {
|
||||
const db = providedDb || database();
|
||||
const existing = await db.query.scoringEvents.findFirst({
|
||||
where: and(
|
||||
eq(schema.scoringEvents.tournamentId, tournamentId),
|
||||
eq(schema.scoringEvents.isPrimary, true)
|
||||
),
|
||||
});
|
||||
if (existing) return existing.id;
|
||||
await db
|
||||
.update(schema.scoringEvents)
|
||||
.set({ isPrimary: true, updatedAt: new Date() })
|
||||
.where(eq(schema.scoringEvents.id, eventId));
|
||||
return eventId;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { and, eq, inArray, isNotNull, sql } from "drizzle-orm";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import {
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
type SimulatorManifestProfile,
|
||||
} from "~/services/simulations/manifest";
|
||||
import {
|
||||
getSimulatorInputPolicy,
|
||||
ratingRequirementLabel,
|
||||
resolveRatings,
|
||||
resolveSourceElos,
|
||||
|
|
@ -82,6 +83,12 @@ export interface SportsSeasonSimulatorSummary {
|
|||
participantInputCount: number;
|
||||
lastSimulatedDate: string | null;
|
||||
readiness: SimulatorReadiness;
|
||||
/** Whether this simulator exposes a Futures Odds setup section. */
|
||||
supportsFuturesOdds: boolean;
|
||||
/** Number of participants that currently have stored futures odds. */
|
||||
oddsParticipantCount: number;
|
||||
/** Weight (0–1) the season's policy gives futures odds when blending into Elo. */
|
||||
oddsWeight: number;
|
||||
}
|
||||
|
||||
function parseDecimal(value: string | null | undefined): number | null {
|
||||
|
|
@ -125,8 +132,8 @@ function isFallbackMethod(method: string): boolean {
|
|||
return method === "fallbackElo" || method === "fallbackRating" || method === "averageKnown" || method === "worstKnownMinus";
|
||||
}
|
||||
|
||||
const GENERATED_RATING_METHODS = ["sourceOdds", "fallbackRating", "averageKnown", "worstKnownMinus"] as const;
|
||||
const GENERATED_SOURCE_ELO_METHODS = ["projectedWins", "projectedTablePoints", "sourceOdds", "fallbackElo", "averageKnown", "worstKnownMinus"] as const;
|
||||
const GENERATED_RATING_METHODS = ["sourceOdds", "blend", "fallbackRating", "averageKnown", "worstKnownMinus"] as const;
|
||||
const GENERATED_SOURCE_ELO_METHODS = ["projectedWins", "projectedTablePoints", "sourceOdds", "blend", "fallbackElo", "averageKnown", "worstKnownMinus"] as const;
|
||||
|
||||
function isGeneratedRatingMethod(method: unknown): boolean {
|
||||
return typeof method === "string" && GENERATED_RATING_METHODS.includes(method as (typeof GENERATED_RATING_METHODS)[number]);
|
||||
|
|
@ -300,112 +307,6 @@ export async function getParticipantSimulatorInputs(
|
|||
});
|
||||
}
|
||||
|
||||
function generatedRatingMethodSql() {
|
||||
return sql`${schema.seasonParticipantSimulatorInputs.metadata}->>'ratingMethod' in ('sourceOdds', 'fallbackRating', 'averageKnown', 'worstKnownMinus')`;
|
||||
}
|
||||
|
||||
export async function batchSaveParticipantSimulatorSourceOdds(
|
||||
inputs: Array<{ participantId: string; sportsSeasonId: string; sourceOdds: number }>
|
||||
): Promise<void> {
|
||||
if (inputs.length === 0) return;
|
||||
const db = database();
|
||||
const now = new Date();
|
||||
const seasonIds = [...new Set(inputs.map((input) => input.sportsSeasonId))];
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(schema.seasonParticipantSimulatorInputs)
|
||||
.set({
|
||||
rating: null,
|
||||
metadata: sql`coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod'`,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
inArray(schema.seasonParticipantSimulatorInputs.sportsSeasonId, seasonIds),
|
||||
generatedRatingMethodSql()
|
||||
)
|
||||
);
|
||||
|
||||
await tx
|
||||
.insert(schema.seasonParticipantSimulatorInputs)
|
||||
.values(
|
||||
inputs.map((input) => ({
|
||||
participantId: input.participantId,
|
||||
sportsSeasonId: input.sportsSeasonId,
|
||||
sourceOdds: input.sourceOdds,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}))
|
||||
)
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
schema.seasonParticipantSimulatorInputs.participantId,
|
||||
schema.seasonParticipantSimulatorInputs.sportsSeasonId,
|
||||
],
|
||||
set: {
|
||||
sourceOdds: sql`excluded.source_odds`,
|
||||
rating: sql`case when ${generatedRatingMethodSql()} then null else ${schema.seasonParticipantSimulatorInputs.rating} end`,
|
||||
metadata: sql`case when ${generatedRatingMethodSql()} then coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod' else ${schema.seasonParticipantSimulatorInputs.metadata} end`,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function batchSaveFuturesOddsForSimulator(
|
||||
inputs: Array<{ participantId: string; sportsSeasonId: string; sourceOdds: number }>
|
||||
): Promise<void> {
|
||||
if (inputs.length === 0) return;
|
||||
const db = database();
|
||||
const now = new Date();
|
||||
const participantIds = inputs.map((input) => input.participantId);
|
||||
const seasonIds = [...new Set(inputs.map((input) => input.sportsSeasonId))];
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
|
||||
// Clear ALL ratings (both manual and generated) so the simulation
|
||||
// re-derives ratings from the newly saved futures odds.
|
||||
await tx
|
||||
.update(schema.seasonParticipantSimulatorInputs)
|
||||
.set({
|
||||
rating: null,
|
||||
metadata: sql`coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod'`,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
inArray(schema.seasonParticipantSimulatorInputs.sportsSeasonId, seasonIds),
|
||||
inArray(schema.seasonParticipantSimulatorInputs.participantId, participantIds)
|
||||
)
|
||||
);
|
||||
|
||||
await tx
|
||||
.insert(schema.seasonParticipantSimulatorInputs)
|
||||
.values(
|
||||
inputs.map((input) => ({
|
||||
participantId: input.participantId,
|
||||
sportsSeasonId: input.sportsSeasonId,
|
||||
sourceOdds: input.sourceOdds,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}))
|
||||
)
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
schema.seasonParticipantSimulatorInputs.participantId,
|
||||
schema.seasonParticipantSimulatorInputs.sportsSeasonId,
|
||||
],
|
||||
set: {
|
||||
sourceOdds: sql`excluded.source_odds`,
|
||||
rating: null,
|
||||
metadata: sql`coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod'`,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function batchUpsertParticipantSimulatorInputs(
|
||||
inputs: UpsertParticipantSimulatorInput[]
|
||||
): Promise<void> {
|
||||
|
|
@ -438,16 +339,34 @@ 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`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`,
|
||||
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. When a caller supplies explicit metadata, use it as-is
|
||||
// (prepareSimulatorInputsForRun and the projection importer set the
|
||||
// correct flags). Otherwise preserve existing metadata, but 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).
|
||||
metadata: sql`CASE
|
||||
WHEN excluded.metadata IS NOT NULL THEN excluded.metadata
|
||||
ELSE 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)
|
||||
END`,
|
||||
updatedAt: sql`excluded.updated_at`,
|
||||
},
|
||||
});
|
||||
|
|
@ -663,6 +582,23 @@ export async function listSportsSeasonSimulatorSummaries(): Promise<SportsSeason
|
|||
: [];
|
||||
const lastSnapshotBySeason = new Map(snapshotRows.map((row) => [row.sportsSeasonId, row.lastSimulatedDate]));
|
||||
|
||||
const oddsCountRows = seasonIds.length > 0
|
||||
? await db
|
||||
.select({
|
||||
sportsSeasonId: schema.seasonParticipantSimulatorInputs.sportsSeasonId,
|
||||
oddsCount: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(schema.seasonParticipantSimulatorInputs)
|
||||
.where(
|
||||
and(
|
||||
inArray(schema.seasonParticipantSimulatorInputs.sportsSeasonId, seasonIds),
|
||||
isNotNull(schema.seasonParticipantSimulatorInputs.sourceOdds)
|
||||
)
|
||||
)
|
||||
.groupBy(schema.seasonParticipantSimulatorInputs.sportsSeasonId)
|
||||
: [];
|
||||
const oddsCountBySeason = new Map(oddsCountRows.map((row) => [row.sportsSeasonId, Number(row.oddsCount)]));
|
||||
|
||||
// Each season calls getSimulatorProfile + validateSimulatorReadiness (~4 queries each).
|
||||
// Acceptable for an admin-only page; revisit if season counts grow past ~50.
|
||||
const summaries = await Promise.all(
|
||||
|
|
@ -670,6 +606,8 @@ export async function listSportsSeasonSimulatorSummaries(): Promise<SportsSeason
|
|||
const simulatorType = (season.simulatorConfig?.simulatorType ?? season.sport.simulatorType) as SimulatorType;
|
||||
const profile = await getSimulatorProfile(simulatorType);
|
||||
const readiness = await validateSimulatorReadiness(season.id);
|
||||
const mergedConfig = { ...profile.defaultConfig, ...season.simulatorConfig?.config };
|
||||
const policy = getSimulatorInputPolicy(mergedConfig);
|
||||
return {
|
||||
sportsSeasonId: season.id,
|
||||
seasonName: season.name,
|
||||
|
|
@ -687,6 +625,9 @@ export async function listSportsSeasonSimulatorSummaries(): Promise<SportsSeason
|
|||
participantInputCount: readiness.participantInputCount,
|
||||
lastSimulatedDate: lastSnapshotBySeason.get(season.id) ?? null,
|
||||
readiness,
|
||||
supportsFuturesOdds: profile.setupSections.includes("futuresOdds"),
|
||||
oddsParticipantCount: oddsCountBySeason.get(season.id) ?? 0,
|
||||
oddsWeight: policy.oddsWeight,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
|
|
|||
|
|
@ -169,6 +169,57 @@ export async function findDraftableSportsSeasonBySportId(sportId: string) {
|
|||
});
|
||||
}
|
||||
|
||||
export type DraftScheduleWindow = {
|
||||
id: string;
|
||||
name: string;
|
||||
year: number;
|
||||
status: SportsSeasonStatus;
|
||||
draftOn: string;
|
||||
draftOff: string;
|
||||
sport: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
iconUrl: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns admin sport-seasons (fantasySeasonId IS NULL) whose draft window
|
||||
* [draftOn, draftOff] overlaps the horizon [today, today + monthsAhead]. Uses an
|
||||
* overlap test (not containment) so windows already open, or spanning the horizon
|
||||
* edges, are still included. Powers the admin draft-schedule Gantt chart.
|
||||
*/
|
||||
export async function findDraftScheduleForHorizon(
|
||||
monthsAhead: number
|
||||
): Promise<DraftScheduleWindow[]> {
|
||||
const db = database();
|
||||
const horizonEnd = sql`CURRENT_DATE + make_interval(months => ${monthsAhead})`;
|
||||
const seasons = await db.query.sportsSeasons.findMany({
|
||||
where: (ss, { and }) =>
|
||||
and(
|
||||
isNull(ss.fantasySeasonId),
|
||||
gte(ss.draftOff, sql`CURRENT_DATE`),
|
||||
lte(ss.draftOn, horizonEnd)
|
||||
),
|
||||
orderBy: (sportsSeasons, { asc }) => [asc(sportsSeasons.draftOn)],
|
||||
with: {
|
||||
sport: {
|
||||
columns: { id: true, name: true, slug: true, iconUrl: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
return seasons.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
year: s.year,
|
||||
status: s.status,
|
||||
draftOn: s.draftOn,
|
||||
draftOff: s.draftOff,
|
||||
sport: s.sport,
|
||||
})) as DraftScheduleWindow[];
|
||||
}
|
||||
|
||||
export async function updateSportsSeason(
|
||||
id: string,
|
||||
data: Partial<NewSportsSeason>
|
||||
|
|
|
|||
|
|
@ -136,3 +136,17 @@ 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", "routes/settings.tsx"),
|
||||
route("settings/:section?", "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,6 +96,7 @@ 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"),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { newlyDecidedLosers } from "../admin.sports-seasons.$id.events.$eventId.bracket.helpers";
|
||||
|
||||
describe("newlyDecidedLosers", () => {
|
||||
it("returns losers of matches that were not already complete", () => {
|
||||
expect(
|
||||
newlyDecidedLosers([
|
||||
{ wasComplete: false, loserId: "sp-1" },
|
||||
{ wasComplete: false, loserId: "sp-2" },
|
||||
])
|
||||
).toEqual(["sp-1", "sp-2"]);
|
||||
});
|
||||
|
||||
it("drops re-scores of already-complete matches (no re-announce)", () => {
|
||||
// sp-1's match just finished; sp-2's match was already complete before this
|
||||
// action — only sp-1 is a NEW knockout.
|
||||
expect(
|
||||
newlyDecidedLosers([
|
||||
{ wasComplete: false, loserId: "sp-1" },
|
||||
{ wasComplete: true, loserId: "sp-2" },
|
||||
])
|
||||
).toEqual(["sp-1"]);
|
||||
});
|
||||
|
||||
it("skips entries with an unknown loser", () => {
|
||||
expect(
|
||||
newlyDecidedLosers([
|
||||
{ wasComplete: false, loserId: null },
|
||||
{ wasComplete: false, loserId: "sp-3" },
|
||||
])
|
||||
).toEqual(["sp-3"]);
|
||||
});
|
||||
|
||||
it("returns an empty array when nothing was newly decided", () => {
|
||||
expect(
|
||||
newlyDecidedLosers([
|
||||
{ wasComplete: true, loserId: "sp-1" },
|
||||
{ wasComplete: true, loserId: "sp-2" },
|
||||
])
|
||||
).toEqual([]);
|
||||
expect(newlyDecidedLosers([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
102
app/routes/__tests__/settings.test.ts
Normal file
102
app/routes/__tests__/settings.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
vi.mock("~/lib/auth.server", () => ({
|
||||
auth: { api: { getSession: vi.fn() } },
|
||||
}));
|
||||
vi.mock("~/lib/cloudinary.server", () => ({ deleteCloudinaryImageByUrl: vi.fn() }));
|
||||
vi.mock("~/lib/email.server", () => ({
|
||||
sendEmail: vi.fn(),
|
||||
wrapInEmailTemplate: vi.fn(),
|
||||
emailParagraph: vi.fn(),
|
||||
escapeHtml: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/user", () => ({
|
||||
findUserById: vi.fn(),
|
||||
findUserByUsername: vi.fn(),
|
||||
isUserInActiveDraft: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
anonymizeUserAccount: vi.fn(),
|
||||
USERNAME_RE: /^[a-zA-Z0-9_-]{3,30}$/,
|
||||
}));
|
||||
vi.mock("~/models/account", () => ({ findLinkedAccountsByUserId: vi.fn() }));
|
||||
vi.mock("~/models/team", () => ({ findTeamsByOwnerId: vi.fn(), removeTeamOwner: vi.fn() }));
|
||||
vi.mock("~/models/commissioner", () => ({ removeAllCommissionersByUserId: vi.fn() }));
|
||||
|
||||
import { loader, shouldRevalidate } from "../settings";
|
||||
import { auth } from "~/lib/auth.server";
|
||||
|
||||
type RevalidateArgs = Parameters<typeof shouldRevalidate>[0];
|
||||
|
||||
function revalidateArgs(overrides: Partial<RevalidateArgs>): RevalidateArgs {
|
||||
return {
|
||||
currentParams: {},
|
||||
nextParams: {},
|
||||
formMethod: undefined,
|
||||
defaultShouldRevalidate: true,
|
||||
...overrides,
|
||||
} as unknown as RevalidateArgs;
|
||||
}
|
||||
|
||||
type LoaderArgs = Parameters<typeof loader>[0];
|
||||
|
||||
function loaderArgs(section?: string): LoaderArgs {
|
||||
const path = section ? `/settings/${section}` : "/settings";
|
||||
return {
|
||||
params: { section },
|
||||
request: new Request(`http://localhost${path}`),
|
||||
context: {},
|
||||
} as unknown as LoaderArgs;
|
||||
}
|
||||
|
||||
describe("/settings loader section handling", () => {
|
||||
it("redirects an unknown section back to /settings", async () => {
|
||||
const result = (await loader(loaderArgs("bogus"))) as Response;
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result.status).toBe(302);
|
||||
expect(result.headers.get("Location")).toBe("/settings");
|
||||
// The bad section short-circuits before we ever touch the session.
|
||||
expect(auth.api.getSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not redirect a valid section before auth runs", async () => {
|
||||
vi.mocked(auth.api.getSession).mockResolvedValue(null as never);
|
||||
const result = (await loader(loaderArgs("account"))) as Response;
|
||||
// Falls through to the auth check, which redirects to login (not /settings).
|
||||
expect(result.headers.get("Location")).toBe("/login?redirectTo=/settings");
|
||||
expect(auth.api.getSession).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("/settings shouldRevalidate", () => {
|
||||
it("skips revalidation when only the section param changes", () => {
|
||||
expect(
|
||||
shouldRevalidate(
|
||||
revalidateArgs({ currentParams: { section: "profile" }, nextParams: { section: "account" } })
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("revalidates after a mutation regardless of section", () => {
|
||||
expect(
|
||||
shouldRevalidate(
|
||||
revalidateArgs({
|
||||
currentParams: { section: "profile" },
|
||||
nextParams: { section: "account" },
|
||||
formMethod: "POST",
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("defers to the default when the section is unchanged", () => {
|
||||
expect(
|
||||
shouldRevalidate(
|
||||
revalidateArgs({
|
||||
currentParams: { section: "account" },
|
||||
nextParams: { section: "account" },
|
||||
defaultShouldRevalidate: true,
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
123
app/routes/admin.draft-schedule.tsx
Normal file
123
app/routes/admin.draft-schedule.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { Link } from "react-router";
|
||||
import type { Route } from "./+types/admin.draft-schedule";
|
||||
|
||||
import { findAllSports } from "~/models/sport";
|
||||
import { findDraftScheduleForHorizon } from "~/models/sports-season";
|
||||
import {
|
||||
DraftScheduleGantt,
|
||||
type GanttSport,
|
||||
} from "~/components/admin/DraftScheduleGantt";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { AlertTriangle, ArrowRight } from "lucide-react";
|
||||
|
||||
const ALLOWED_MONTHS = [6, 12] as const;
|
||||
|
||||
export function meta(): Route.MetaDescriptors {
|
||||
return [{ title: "Draft Schedule - Brackt" }];
|
||||
}
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const url = new URL(request.url);
|
||||
const monthsParam = Number(url.searchParams.get("months"));
|
||||
const months = ALLOWED_MONTHS.includes(monthsParam as (typeof ALLOWED_MONTHS)[number])
|
||||
? monthsParam
|
||||
: 6;
|
||||
|
||||
const [allSports, windows] = await Promise.all([
|
||||
findAllSports(),
|
||||
findDraftScheduleForHorizon(months),
|
||||
]);
|
||||
|
||||
const windowsBySport = new Map<string, typeof windows>();
|
||||
for (const w of windows) {
|
||||
const list = windowsBySport.get(w.sport.id) ?? [];
|
||||
list.push(w);
|
||||
windowsBySport.set(w.sport.id, list);
|
||||
}
|
||||
|
||||
const sports: GanttSport[] = allSports.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
slug: s.slug,
|
||||
iconUrl: s.iconUrl,
|
||||
windows: windowsBySport.get(s.id) ?? [],
|
||||
}));
|
||||
|
||||
const sportsWithoutCoverage = sports.filter((s) => s.windows.length === 0);
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
return { sports, sportsWithoutCoverage, today, months };
|
||||
}
|
||||
|
||||
export default function AdminDraftSchedule({ loaderData }: Route.ComponentProps) {
|
||||
const { sports, sportsWithoutCoverage, today, months } = loaderData;
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8">
|
||||
<div className="mb-8 flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Draft Schedule</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
See when each sport is scheduled to be drafted and spot gaps in coverage.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 rounded-md border p-1">
|
||||
{ALLOWED_MONTHS.map((m) => (
|
||||
<Button
|
||||
key={m}
|
||||
variant={m === months ? "default" : "ghost"}
|
||||
size="sm"
|
||||
asChild
|
||||
>
|
||||
<Link to={`?months=${m}`}>{m} months</Link>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sportsWithoutCoverage.length > 0 && (
|
||||
<Card className="mb-6 border-amber-300 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-amber-800 dark:text-amber-300">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
Sports without a draft window
|
||||
</CardTitle>
|
||||
<CardDescription className="text-amber-700 dark:text-amber-400/80">
|
||||
{sportsWithoutCoverage.length} sport
|
||||
{sportsWithoutCoverage.length !== 1 ? "s" : ""}{" "}
|
||||
{sportsWithoutCoverage.length !== 1 ? "have" : "has"} no draft window
|
||||
open or upcoming in the next {months} months.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2">
|
||||
{sportsWithoutCoverage.map((s) => (
|
||||
<li
|
||||
key={s.id}
|
||||
className="flex items-center justify-between gap-3 rounded-md border border-amber-200 bg-background/60 px-3 py-2 dark:border-amber-900"
|
||||
>
|
||||
<span className="text-sm font-medium">{s.name}</span>
|
||||
<Button variant="outline" size="sm" asChild className="h-7 text-xs">
|
||||
<Link to="/admin/sports-seasons/new">
|
||||
Schedule a season
|
||||
<ArrowRight className="ml-1 h-3 w-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<DraftScheduleGantt sports={sports} today={today} months={months} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -77,6 +77,12 @@ export async function action({ request }: Route.ActionArgs): Promise<ActionData>
|
|||
};
|
||||
}
|
||||
|
||||
function futuresBlendLabel(oddsWeight: number): string {
|
||||
if (oddsWeight >= 1) return "overrides Elo";
|
||||
if (oddsWeight <= 0) return "Elo only";
|
||||
return `${Math.round(oddsWeight * 100)}% blend`;
|
||||
}
|
||||
|
||||
function statusBadge(status: string) {
|
||||
if (status === "ready") return <Badge className="gap-1"><CheckCircle2 className="h-3 w-3" /> Ready</Badge>;
|
||||
return <Badge variant="secondary" className="gap-1"><XCircle className="h-3 w-3" /> Needs setup</Badge>;
|
||||
|
|
@ -245,7 +251,14 @@ export default function AdminSimulators({ loaderData }: Route.ComponentProps) {
|
|||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{sim.participantInputCount}/{sim.participantCount}
|
||||
<div>{sim.participantInputCount}/{sim.participantCount}</div>
|
||||
{sim.supportsFuturesOdds && (
|
||||
<Badge variant={sim.oddsWeight > 0 ? "default" : "outline"} className="mt-1 text-xs font-normal">
|
||||
{sim.oddsParticipantCount > 0
|
||||
? `Futures: ${sim.oddsParticipantCount} · ${futuresBlendLabel(sim.oddsWeight)}`
|
||||
: "No futures odds"}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{sim.lastSimulatedDate ?? "Never"}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
/**
|
||||
* Pure helpers for the bracket-scoring route action, extracted so the
|
||||
* knockout-detection logic can be unit-tested without the full action harness.
|
||||
*/
|
||||
|
||||
/**
|
||||
* From a set of matches being scored this action, return the season_participant
|
||||
* ids knocked out for the FIRST time. A loser counts only when its match was NOT
|
||||
* already complete — a re-score/correction of a finished match must not
|
||||
* re-announce the exit (mirrors populateBracketFromDraw's first-completion rule in
|
||||
* the live-sync path). Entries with an unknown loser (`null`) are skipped.
|
||||
*
|
||||
* The ids are season_participant ids of the primary window (playoff_matches are
|
||||
* keyed by season_participant); the fan-out translates them to each mirror
|
||||
* window's own season_participant id before announcing the "Knocked Out" section.
|
||||
*/
|
||||
export function newlyDecidedLosers(
|
||||
entries: Array<{ wasComplete: boolean; loserId: string | null }>
|
||||
): string[] {
|
||||
return entries
|
||||
.filter((e): e is { wasComplete: boolean; loserId: string } =>
|
||||
!e.wasComplete && e.loserId !== null
|
||||
)
|
||||
.map((e) => e.loserId);
|
||||
}
|
||||
|
|
@ -1,8 +1,12 @@
|
|||
import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket";
|
||||
|
||||
import { findSportsSeasonById } from "~/models/sports-season";
|
||||
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
||||
import { getScoringEventById, updateScoringEvent } from "~/models/scoring-event";
|
||||
import {
|
||||
findParticipantsBySportsSeasonId,
|
||||
createParticipant,
|
||||
updateParticipant,
|
||||
} from "~/models/season-participant";
|
||||
import { getScoringEventById, updateScoringEvent, isReadOnlySibling } from "~/models/scoring-event";
|
||||
import {
|
||||
findPlayoffMatchesByEventId,
|
||||
generateBracketFromTemplate,
|
||||
|
|
@ -25,13 +29,20 @@ import {
|
|||
import {
|
||||
processPlayoffEvent,
|
||||
processMatchResult,
|
||||
processQualifyingBracketEvent,
|
||||
processQualifyingEvent,
|
||||
finalizeQualifyingPoints,
|
||||
recalculateAffectedLeagues,
|
||||
recalculateStandings,
|
||||
autoCompleteRoundIfDone,
|
||||
} from "~/models/scoring-calculator";
|
||||
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
||||
import { getBracketTemplate, ALL_16_SEEDS, type BracketRegion } from "~/lib/bracket-templates";
|
||||
import { setParticipantResult, findParticipantResultsBySportsSeasonId } from "~/models/participant-result";
|
||||
import {
|
||||
setParticipantResult,
|
||||
findParticipantResultsBySportsSeasonId,
|
||||
deleteParticipantResultsBySportsSeasonId,
|
||||
} from "~/models/participant-result";
|
||||
import { findSeasonSportsBySportsSeasonId } from "~/models/season-sport";
|
||||
import { createDailySnapshot } from "~/models/standings";
|
||||
import {
|
||||
|
|
@ -54,6 +65,10 @@ import { database } from "~/database/context";
|
|||
import * as schema from "~/database/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.server";
|
||||
import { fanOutMajorIfPrimary, syncMajorFromPrimaryEvent } from "~/services/sync-tournament-results";
|
||||
import { syncTennisDraw, previewTennisDraw } from "~/services/match-sync";
|
||||
import { articleTitleFromInput } from "~/services/match-sync/wikipedia-tennis";
|
||||
import { newlyDecidedLosers } from "./admin.sports-seasons.$id.events.$eventId.bracket.helpers";
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
const sportsSeason = await findSportsSeasonById(params.id);
|
||||
|
|
@ -103,10 +118,176 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Score a qualifying-event bracket (e.g. CS2 Champions Stage): derive each team's
|
||||
* guaranteed-minimum QP from the bracket and refresh affected league standings.
|
||||
*
|
||||
* Qualifying brackets award QUALIFYING POINTS, not fantasy points, so this never
|
||||
* writes seasonParticipantResults and never records team_score_events — match
|
||||
* results surface via the QP Standings and the Discord standings update. Final
|
||||
* fantasy placements come from finalizeQualifyingPoints across all majors.
|
||||
*/
|
||||
async function scoreQualifyingBracket(
|
||||
event: {
|
||||
id: string;
|
||||
sportsSeasonId: string;
|
||||
name: string | null;
|
||||
isPrimary: boolean;
|
||||
tournamentId: string | null;
|
||||
},
|
||||
db: ReturnType<typeof database>,
|
||||
recalcOptions?: Parameters<typeof recalculateAffectedLeagues>[2],
|
||||
/**
|
||||
* Season_participant ids of players knocked out for the first time by this
|
||||
* operation (losers of matches that just reached completion). Forwarded to the
|
||||
* fan-out so every mirror window announces the "Knocked Out" section — a
|
||||
* non-scoring-round exit earns no QP and is otherwise invisible to the mirror.
|
||||
* Empty for re-scores/reprocesses, which decide no new losers.
|
||||
*/
|
||||
newlyEliminatedParticipantIds?: Set<string>
|
||||
): Promise<void> {
|
||||
// processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent),
|
||||
// recalcs participant QP totals, AND announces the QP change to this window's leagues.
|
||||
// Calling processQualifyingBracketEvent directly here would score silently — the QP
|
||||
// Discord notification only fires from processQualifyingEvent. The fan-out below skips
|
||||
// this (primary) window via skipEventId, so mirror windows are announced separately
|
||||
// with no double-post.
|
||||
await processQualifyingEvent(event.id, db, { newlyEliminatedParticipantIds });
|
||||
await recalculateAffectedLeagues(
|
||||
event.sportsSeasonId,
|
||||
db,
|
||||
recalcOptions ?? { eventId: event.id, eventName: event.name ?? undefined }
|
||||
);
|
||||
// If this is the shared major's primary window, propagate to siblings.
|
||||
// Mid-tournament (a single round): don't mark complete yet.
|
||||
await fanOutMajorIfPrimary(event, {
|
||||
markComplete: false,
|
||||
newlyEliminatedParticipantIds,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the given participants as eliminated (finalPosition = 0) and, for fantasy
|
||||
* (non-qualifying) events, announce the teams newly eliminated by this run to the
|
||||
* affected leagues' Discord channels. Returns the number of participants marked.
|
||||
*
|
||||
* "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".
|
||||
*/
|
||||
async function markEliminatedAndAnnounce(
|
||||
event: { id: string; name: string | null; sportsSeasonId: string; isQualifyingEvent: boolean },
|
||||
participantIds: string[]
|
||||
): Promise<number> {
|
||||
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);
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error("[Eliminations] Discord announcement failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
return participantIds.length;
|
||||
}
|
||||
|
||||
export async function action({ request, params }: Route.ActionArgs) {
|
||||
const formData = await request.formData();
|
||||
const intent = formData.get("intent");
|
||||
|
||||
// Brackets are built/scored only on the major's primary window. A
|
||||
// tournament-linked, non-primary event is a read-only mirror that receives
|
||||
// results via fan-out, so reject every mutating action here.
|
||||
{
|
||||
const ev = await getScoringEventById(params.eventId);
|
||||
if (ev && isReadOnlySibling(ev)) {
|
||||
return {
|
||||
error:
|
||||
"This bracket belongs to a shared major. Build and score it on the primary window (linked from Admin → Tournaments); results fan out here automatically.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === "create-draw-participant") {
|
||||
const name = (formData.get("name") as string | null)?.trim();
|
||||
const externalId = (formData.get("externalId") as string | null)?.trim() || null;
|
||||
if (!name) return { error: "Participant name is required" };
|
||||
try {
|
||||
await createParticipant({ sportsSeasonId: params.id, name, externalId });
|
||||
return { success: `Created "${name}". Re-run Preview or Sync Draw to apply.` };
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : "Failed to create participant" };
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === "relink-draw-participant") {
|
||||
const participantId = formData.get("participantId") as string | null;
|
||||
const name = (formData.get("name") as string | null)?.trim();
|
||||
const externalId = (formData.get("externalId") as string | null)?.trim() || null;
|
||||
if (!participantId || !name) return { error: "Participant and name are required" };
|
||||
try {
|
||||
await updateParticipant(participantId, { name, externalId });
|
||||
return { success: `Renamed and linked to "${name}". Re-run Preview or Sync Draw to apply.` };
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : "Failed to update participant" };
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === "preview-draw") {
|
||||
const rawInput = (formData.get("externalSourceKey") as string | null)?.trim();
|
||||
const articleTitle = rawInput ? articleTitleFromInput(rawInput) : "";
|
||||
// Persist the article so the user can preview, then sync, without re-entering.
|
||||
if (articleTitle) {
|
||||
await updateScoringEvent(params.eventId, { externalSourceKey: articleTitle });
|
||||
}
|
||||
try {
|
||||
const preview = await previewTennisDraw(params.eventId);
|
||||
return { drawPreview: preview };
|
||||
} catch (error) {
|
||||
logger.error("[preview-draw] error:", error);
|
||||
return { error: error instanceof Error ? error.message : "Failed to preview draw" };
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === "sync-draw") {
|
||||
const rawInput = (formData.get("externalSourceKey") as string | null)?.trim();
|
||||
// Accept a pasted Wikipedia URL or a plain article title; store the title.
|
||||
const articleTitle = rawInput ? articleTitleFromInput(rawInput) : "";
|
||||
if (articleTitle) {
|
||||
await updateScoringEvent(params.eventId, { externalSourceKey: articleTitle });
|
||||
}
|
||||
try {
|
||||
const result = await syncTennisDraw(params.eventId);
|
||||
const reviewNote =
|
||||
result.unmatched.length > 0
|
||||
? ` ${result.unmatched.length} auto-created player(s) need a quick review for possible duplicates.`
|
||||
: "";
|
||||
return {
|
||||
success:
|
||||
`Synced draw: ${result.matchesWritten} matches written ` +
|
||||
`(${result.completed} completed), ${result.participantsCreated} participant(s) added.${reviewNote}`,
|
||||
drawSyncResult: result,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("[sync-draw] error:", error);
|
||||
return { error: error instanceof Error ? error.message : "Failed to sync draw" };
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === "generate-bracket") {
|
||||
const templateId = formData.get("templateId");
|
||||
|
||||
|
|
@ -170,27 +351,16 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
try {
|
||||
await generateBracketFromTemplate(params.eventId, templateId, participantIds, regionOverride);
|
||||
|
||||
// PHASE 5.3: Mark participants NOT in the bracket as eliminated
|
||||
// PHASE 5.3: Mark participants NOT in the bracket as eliminated (and announce).
|
||||
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`);
|
||||
const toEliminate = allParticipants
|
||||
.filter((p) => !participantsInBracket.has(p.id))
|
||||
.map((p) => p.id);
|
||||
const eliminatedCount = await markEliminatedAndAnnounce(event, toEliminate);
|
||||
logger.log(`[BracketGeneration] Marked ${eliminatedCount} participants as eliminated`);
|
||||
}
|
||||
|
||||
// Update the event to store the template ID, scoring start round, and region config
|
||||
|
|
@ -246,6 +416,13 @@ 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);
|
||||
|
||||
|
|
@ -267,26 +444,42 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
}
|
||||
}
|
||||
|
||||
// Immediately score this match: loser gets their final placement,
|
||||
// winner gets provisional floor points (isPartialScore=true).
|
||||
// Prefer the template-defined isScoring over the DB field: the DB column
|
||||
// defaults to true, so legacy play-in rows may be incorrectly marked as scoring.
|
||||
const setWinnerRoundIsScoring = setWinnerTemplate?.rounds.find((r) => r.name === match.round)?.isScoring;
|
||||
await processMatchResult({
|
||||
round: match.round,
|
||||
winnerId,
|
||||
loserId,
|
||||
isScoring: setWinnerRoundIsScoring !== undefined ? setWinnerRoundIsScoring : (match.isScoring ?? true),
|
||||
sportsSeasonId: event.sportsSeasonId,
|
||||
bracketTemplateId: event.bracketTemplateId,
|
||||
eventId: event.id,
|
||||
eventName: event.name ?? undefined,
|
||||
matchId,
|
||||
loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""),
|
||||
});
|
||||
|
||||
const db = database();
|
||||
await autoCompleteRoundIfDone(event.id, match.round, event.sportsSeasonId, db);
|
||||
|
||||
if (event.isQualifyingEvent) {
|
||||
// Qualifying major (e.g. CS2): bracket results award QUALIFYING POINTS, not
|
||||
// fantasy points. matchIds scopes the Discord notification to just this match.
|
||||
await scoreQualifyingBracket(
|
||||
event,
|
||||
db,
|
||||
{
|
||||
eventId: event.id,
|
||||
eventName: event.name ?? undefined,
|
||||
matchIds: [matchId],
|
||||
},
|
||||
setWinnerNewlyEliminated
|
||||
);
|
||||
} else {
|
||||
// Immediately score this match: loser gets their final placement,
|
||||
// winner gets provisional floor points (isPartialScore=true).
|
||||
// Prefer the template-defined isScoring over the DB field: the DB column
|
||||
// defaults to true, so legacy play-in rows may be incorrectly marked as scoring.
|
||||
const setWinnerRoundIsScoring = setWinnerTemplate?.rounds.find((r) => r.name === match.round)?.isScoring;
|
||||
await processMatchResult({
|
||||
round: match.round,
|
||||
winnerId,
|
||||
loserId,
|
||||
isScoring: setWinnerRoundIsScoring !== undefined ? setWinnerRoundIsScoring : (match.isScoring ?? true),
|
||||
sportsSeasonId: event.sportsSeasonId,
|
||||
bracketTemplateId: event.bracketTemplateId,
|
||||
eventId: event.id,
|
||||
eventName: event.name ?? undefined,
|
||||
matchId,
|
||||
loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""),
|
||||
});
|
||||
|
||||
await autoCompleteRoundIfDone(event.id, match.round, event.sportsSeasonId, db);
|
||||
}
|
||||
|
||||
return { success: "Winner set successfully" };
|
||||
} catch (error) {
|
||||
|
|
@ -337,6 +530,10 @@ 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 {
|
||||
|
|
@ -385,22 +582,30 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
|
||||
// Score this match without triggering standings/probability recalc yet —
|
||||
// we batch those side effects into a single call after the loop.
|
||||
await processMatchResult({
|
||||
round: match.round,
|
||||
winnerId,
|
||||
loserId,
|
||||
isScoring: roundIsScoring.get(match.round) ?? (match.isScoring ?? true),
|
||||
sportsSeasonId: event.sportsSeasonId,
|
||||
bracketTemplateId: event.bracketTemplateId,
|
||||
eventId: event.id,
|
||||
eventName: event.name ?? undefined,
|
||||
matchId,
|
||||
skipSideEffects: true,
|
||||
loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""),
|
||||
});
|
||||
// Qualifying majors derive QP from the whole bracket once after the loop
|
||||
// (processQualifyingBracketEvent), so skip the per-match fantasy scoring here.
|
||||
if (!event.isQualifyingEvent) {
|
||||
await processMatchResult({
|
||||
round: match.round,
|
||||
winnerId,
|
||||
loserId,
|
||||
isScoring: roundIsScoring.get(match.round) ?? (match.isScoring ?? true),
|
||||
sportsSeasonId: event.sportsSeasonId,
|
||||
bracketTemplateId: event.bracketTemplateId,
|
||||
eventId: event.id,
|
||||
eventName: event.name ?? undefined,
|
||||
matchId,
|
||||
skipSideEffects: true,
|
||||
loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""),
|
||||
});
|
||||
}
|
||||
|
||||
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(
|
||||
|
|
@ -414,6 +619,15 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
// previously completed matches in the event.
|
||||
if (successCount > 0) {
|
||||
const db = database();
|
||||
// Qualifying majors: derive QP from the full bracket once for the batch, and
|
||||
// announce the QP change to this window's leagues. processQualifyingEvent (not
|
||||
// processQualifyingBracketEvent) is what sends the QP Discord notification; the
|
||||
// fan-out below skips this window (skipEventId) so mirrors don't double-post.
|
||||
if (event.isQualifyingEvent) {
|
||||
await processQualifyingEvent(event.id, db, {
|
||||
newlyEliminatedParticipantIds: new Set(newlyDecidedLosers(decidedEntries)),
|
||||
});
|
||||
}
|
||||
// Update probabilities first so recalculateAffectedLeagues reads fresh EVs
|
||||
// when computing projected points.
|
||||
try {
|
||||
|
|
@ -422,7 +636,18 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
logger.error(`Error updating probabilities after batch round winners:`, error);
|
||||
}
|
||||
await recalculateAffectedLeagues(event.sportsSeasonId, db, { eventId: event.id, eventName: event.name ?? undefined, matchIds: processedMatchIds });
|
||||
await autoCompleteRoundIfDone(event.id, round, event.sportsSeasonId, db);
|
||||
// autoCompleteRoundIfDone runs processPlayoffEvent (fantasy path); skip for
|
||||
// qualifying majors, which are scored via processQualifyingBracketEvent above.
|
||||
if (!event.isQualifyingEvent) {
|
||||
await autoCompleteRoundIfDone(event.id, round, event.sportsSeasonId, db);
|
||||
} else {
|
||||
// Shared major primary window: propagate this round to siblings, carrying
|
||||
// the batch's newly-decided knockouts so mirrors announce them too.
|
||||
await fanOutMajorIfPrimary(event, {
|
||||
markComplete: false,
|
||||
newlyEliminatedParticipantIds: new Set(newlyDecidedLosers(decidedEntries)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0 && successCount === 0) {
|
||||
|
|
@ -474,6 +699,14 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
return { error: `Not all matches in ${round} are complete` };
|
||||
}
|
||||
|
||||
// Qualifying majors (e.g. CS2): QP is derived directly from the bracket via
|
||||
// processQualifyingBracketEvent — there are no seasonParticipantResults rows to
|
||||
// validate against, and final fantasy placements come from finalizeQualifyingPoints.
|
||||
if (event.isQualifyingEvent) {
|
||||
await scoreQualifyingBracket(event, database());
|
||||
return { success: `${round} completed and qualifying points updated` };
|
||||
}
|
||||
|
||||
// Validate round order: ensure previous rounds are complete
|
||||
const existingResults = await findParticipantResultsBySportsSeasonId(
|
||||
params.id
|
||||
|
|
@ -559,6 +792,52 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
const matches = await findPlayoffMatchesByEventId(params.eventId);
|
||||
const completed = matches.filter((m) => m.isComplete && m.winnerId && m.loserId);
|
||||
|
||||
// Qualifying majors: this is also the cleanup tool for majors that wrongly banked
|
||||
// fantasy points under the old path. Delete the stale seasonParticipantResults rows
|
||||
// (erasing those points), then rebuild correct QP from the bracket. Qualifying sports
|
||||
// have no legitimate per-major fantasy placements — those come from
|
||||
// finalizeQualifyingPoints across all majors.
|
||||
if (event.isQualifyingEvent) {
|
||||
const db = database();
|
||||
await deleteParticipantResultsBySportsSeasonId(event.sportsSeasonId, db);
|
||||
await processQualifyingBracketEvent(params.eventId, db);
|
||||
// If the season's QP was already finalized, the delete above wiped the final
|
||||
// placements — recompute them from QP totals so standings aren't left blank.
|
||||
const sportsSeason = await findSportsSeasonById(params.id);
|
||||
if (sportsSeason?.qualifyingPointsFinalized) {
|
||||
await finalizeQualifyingPoints(event.sportsSeasonId, db); // recalcs leagues itself
|
||||
} else {
|
||||
// skipDiscord: reprocess is a data-correction tool, not a result announcement.
|
||||
await recalculateAffectedLeagues(event.sportsSeasonId, db, { skipDiscord: true });
|
||||
}
|
||||
// Re-propagate corrected QP to sibling/mirror windows (data-correction; not
|
||||
// final). Call syncMajorFromPrimaryEvent directly rather than the
|
||||
// swallow-and-log fanOutMajorIfPrimary so the admin actually sees whether the
|
||||
// mirrors were re-scored — a silent failure here is exactly how mirrors got
|
||||
// left showing stale QP behind a green "success".
|
||||
const baseMessage = `Reprocessed qualifying bracket: cleared stale fantasy points and recomputed QP (${completed.length} completed match(es)).`;
|
||||
if (event.isPrimary && event.tournamentId) {
|
||||
try {
|
||||
const report = await syncMajorFromPrimaryEvent(event.id, { markComplete: false });
|
||||
// Surface a partial fan-out as an error so it renders as a warning
|
||||
// banner, not a green success the admin might skim past while some
|
||||
// mirror windows are left stale.
|
||||
if (report.windowsFailed > 0) {
|
||||
const reasons = report.failures.map((f) => f.error).join("; ");
|
||||
return {
|
||||
error: `${baseMessage} Synced ${report.windowsSynced} mirror window(s), but ${report.windowsFailed} failed (those windows may be stale): ${reasons}`,
|
||||
};
|
||||
}
|
||||
return { success: `${baseMessage} Synced ${report.windowsSynced} mirror window(s).` };
|
||||
} catch (error) {
|
||||
return {
|
||||
error: `Primary window recomputed, but mirror fan-out failed (mirror windows may be stale): ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { success: `${baseMessage} No mirror windows to sync.` };
|
||||
}
|
||||
|
||||
if (completed.length === 0) {
|
||||
return { error: "No completed matches to reprocess" };
|
||||
}
|
||||
|
|
@ -567,11 +846,7 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
// Only deleting partial rows leaves stale finalized rows that block
|
||||
// the "never un-finalize" guard in upsertParticipantResult.
|
||||
const db = database();
|
||||
await db
|
||||
.delete(schema.seasonParticipantResults)
|
||||
.where(
|
||||
eq(schema.seasonParticipantResults.sportsSeasonId, event.sportsSeasonId)
|
||||
);
|
||||
await deleteParticipantResultsBySportsSeasonId(event.sportsSeasonId, db);
|
||||
|
||||
// Replay each completed match in bracket order (earlier rounds first).
|
||||
const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null;
|
||||
|
|
@ -669,6 +944,31 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
};
|
||||
}
|
||||
|
||||
// Qualifying majors (e.g. CS2): lock in final bracket placements/QP, then run the
|
||||
// standard qualifying finalizer for THIS event. Do not mark the whole sports season
|
||||
// completed or recalc fantasy standings — a season spans multiple majors and final
|
||||
// fantasy placements come from finalizeQualifyingPoints across all of them.
|
||||
if (event.isQualifyingEvent) {
|
||||
const db = database();
|
||||
// processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent)
|
||||
// and recalcs participant QP totals. majorsCompleted is derived on read from
|
||||
// completed qualifying events (see getMajorsCompleted) — marking this event
|
||||
// complete below is what advances it. Season-wide fantasy finalization stays with
|
||||
// finalizeQualifyingPoints across all majors.
|
||||
await processQualifyingEvent(params.eventId, db);
|
||||
await db
|
||||
.update(schema.scoringEvents)
|
||||
.set({ isComplete: true, completedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(schema.scoringEvents.id, params.eventId));
|
||||
await recalculateAffectedLeagues(event.sportsSeasonId, db, {
|
||||
eventId: params.eventId,
|
||||
eventName: event.name ?? undefined,
|
||||
});
|
||||
// Finalize: propagate to siblings AND mark every window complete.
|
||||
await fanOutMajorIfPrimary(event, { markComplete: true });
|
||||
return { success: "Major bracket finalized — qualifying points awarded." };
|
||||
}
|
||||
|
||||
// Get all participants in this sports season
|
||||
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
|
||||
|
||||
|
|
@ -783,14 +1083,16 @@ 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
|
||||
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
|
||||
let eliminatedCount = 0;
|
||||
for (const participant of allParticipants) {
|
||||
if (!uniqueParticipants.has(participant.id)) {
|
||||
await setParticipantResult(participant.id, params.id, 0);
|
||||
eliminatedCount++;
|
||||
}
|
||||
// (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 eliminatedCount = await markEliminatedAndAnnounce(groupsEvent, toEliminate);
|
||||
|
||||
return {
|
||||
success: `Groups and knockout bracket structure created successfully${eliminatedCount > 0 ? ` (${eliminatedCount} participant(s) not in any group marked as eliminated)` : ""}`,
|
||||
|
|
@ -915,11 +1217,10 @@ 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
|
||||
// Mark eliminated group participants with finalPosition = 0 (and announce
|
||||
// the group-stage eliminations to leagues for fantasy events).
|
||||
const eliminatedIds = await getEliminatedParticipantIds(params.eventId);
|
||||
for (const participantId of eliminatedIds) {
|
||||
await setParticipantResult(participantId, event.sportsSeasonId, 0);
|
||||
}
|
||||
await markEliminatedAndAnnounce(event, eliminatedIds);
|
||||
|
||||
logger.log(
|
||||
`[PopulateKnockout] Assigned 32 participants to knockout, marked ${eliminatedIds.length} as eliminated`
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Form, Link } from "react-router";
|
||||
import { Fragment, useState, useEffect, useMemo } from "react";
|
||||
import { localDateTimeToUtcIso } from "~/lib/date-utils";
|
||||
import { Form, Link, useFetcher } 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";
|
||||
|
||||
import { loader, action } from "./admin.sports-seasons.$id.events.$eventId.bracket.server";
|
||||
|
|
@ -39,8 +39,116 @@ export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
|||
return [{ title: `Bracket — ${data?.event?.name ?? "Event"} - Brackt Admin` }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule editor for a single group-stage match.
|
||||
*
|
||||
* The admin enters a local wall-clock time; on submit it is converted to a UTC
|
||||
* ISO string (in the hidden `scheduledAt` field) so the DB stores a true UTC
|
||||
* instant. The stored UTC value is rendered back into the input in the
|
||||
* browser's local timezone via a client-only effect (avoids SSR hydration
|
||||
* mismatch, since the server runs in UTC).
|
||||
*/
|
||||
function GroupMatchScheduleForm({
|
||||
match,
|
||||
localTzAbbr,
|
||||
}: {
|
||||
match: { id: string; scheduledAt: string | Date | null | undefined };
|
||||
localTzAbbr: string;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = utcIsoToLocalDateTime(match.scheduledAt);
|
||||
}
|
||||
}, [match.scheduledAt]);
|
||||
return (
|
||||
<Form
|
||||
method="post"
|
||||
className="flex items-center gap-1"
|
||||
onSubmit={(e) => {
|
||||
const form = e.currentTarget;
|
||||
const local = form.elements.namedItem("scheduledAtLocal") as HTMLInputElement;
|
||||
const hidden = form.elements.namedItem("scheduledAt") as HTMLInputElement;
|
||||
if (hidden) hidden.value = localDateTimeToUtcIso(local?.value) ?? "";
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="intent" value="update-group-match-schedule" />
|
||||
<input type="hidden" name="matchId" value={match.id} />
|
||||
{/* Hidden field holds the UTC ISO string written by onSubmit */}
|
||||
<input type="hidden" name="scheduledAt" defaultValue="" />
|
||||
<Calendar className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="datetime-local"
|
||||
name="scheduledAtLocal"
|
||||
title={`Time zone: ${localTzAbbr}`}
|
||||
className="h-6 text-xs px-1 py-0"
|
||||
/>
|
||||
<Button type="submit" size="sm" variant="ghost" className="h-6 px-1.5 text-xs">
|
||||
Save
|
||||
</Button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export { loader, action };
|
||||
|
||||
/**
|
||||
* One "possible duplicate" row in the draw preview. Uses a fetcher so resolving
|
||||
* it (rename existing / create as new) submits in the background — the preview
|
||||
* stays on screen instead of reloading the page and forcing a fresh dry run.
|
||||
*/
|
||||
function DuplicateRow({
|
||||
p,
|
||||
}: {
|
||||
p: { name: string; externalId: string | null; suggestion: string; suggestionId: string };
|
||||
}) {
|
||||
const fetcher = useFetcher<{ success?: string; error?: string }>();
|
||||
const busy = fetcher.state !== "idle";
|
||||
|
||||
return (
|
||||
<li className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span>
|
||||
<span className="font-medium">{p.name}</span>
|
||||
<span className="text-muted-foreground"> → looks like </span>
|
||||
<span className="font-medium">{p.suggestion}</span>
|
||||
</span>
|
||||
{fetcher.data?.success ? (
|
||||
<span className="text-xs font-medium text-emerald-500">✓ {fetcher.data.success}</span>
|
||||
) : (
|
||||
<fetcher.Form method="post" className="flex items-center gap-2">
|
||||
<input type="hidden" name="name" value={p.name} />
|
||||
<input type="hidden" name="externalId" value={p.externalId ?? ""} />
|
||||
<input type="hidden" name="participantId" value={p.suggestionId} />
|
||||
<Button
|
||||
type="submit"
|
||||
name="intent"
|
||||
value="relink-draw-participant"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
>
|
||||
Rename “{p.suggestion}” → “{p.name}”
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
name="intent"
|
||||
value="create-draw-participant"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
>
|
||||
Create as new
|
||||
</Button>
|
||||
</fetcher.Form>
|
||||
)}
|
||||
{fetcher.data?.error && (
|
||||
<span className="text-xs text-destructive">{fetcher.data.error}</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EventBracket({
|
||||
loaderData,
|
||||
actionData,
|
||||
|
|
@ -323,6 +431,164 @@ 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. */}
|
||||
|
|
@ -806,24 +1072,7 @@ export default function EventBracket({
|
|||
{groupMatches.map((match) => (
|
||||
<div key={match.id} className="text-xs space-y-1">
|
||||
{/* Schedule row */}
|
||||
<Form method="post" className="flex items-center gap-1">
|
||||
<input type="hidden" name="intent" value="update-group-match-schedule" />
|
||||
<input type="hidden" name="matchId" value={match.id} />
|
||||
<Calendar className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
<Input
|
||||
type="datetime-local"
|
||||
name="scheduledAt"
|
||||
defaultValue={
|
||||
match.scheduledAt
|
||||
? new Date(match.scheduledAt).toISOString().slice(0, 16)
|
||||
: ""
|
||||
}
|
||||
className="h-6 text-xs px-1 py-0"
|
||||
/>
|
||||
<Button type="submit" size="sm" variant="ghost" className="h-6 px-1.5 text-xs">
|
||||
Save
|
||||
</Button>
|
||||
</Form>
|
||||
<GroupMatchScheduleForm match={match} localTzAbbr={localTzAbbr} />
|
||||
{/* Score row */}
|
||||
<Form method="post" className="flex items-center gap-1">
|
||||
<input type="hidden" name="intent" value="update-group-match" />
|
||||
|
|
|
|||
|
|
@ -2,18 +2,19 @@ import { Form, useLoaderData, useActionData, useNavigation, useFetcher, Link } f
|
|||
import type { Route } from './+types/admin.sports-seasons.$id.events.$eventId.cs2-setup';
|
||||
|
||||
import { findSportsSeasonById } from '~/models/sports-season';
|
||||
import { getScoringEventById } from '~/models/scoring-event';
|
||||
import { getScoringEventById, isReadOnlySibling } from '~/models/scoring-event';
|
||||
import { findParticipantsBySportsSeasonId } from '~/models/season-participant';
|
||||
import {
|
||||
getCs2StageResultsForEvent,
|
||||
upsertCs2StageAssignments,
|
||||
markCs2StageEliminations,
|
||||
clearCs2StageAssignments,
|
||||
resetCs2Event,
|
||||
clearCs2EliminationsAtStage,
|
||||
assignCs2EliminationQP,
|
||||
} from '~/models/cs2-major-stage';
|
||||
import { findSeasonMatchesByScoringEventId } from '~/models/season-match';
|
||||
import { syncMatches } from '~/services/match-sync';
|
||||
import { fanOutMajorIfPrimary } from '~/services/sync-tournament-results';
|
||||
import { Button } from '~/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
|
|
@ -61,9 +62,22 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
const formData = await request.formData();
|
||||
const intent = formData.get('intent') as string;
|
||||
|
||||
// CS2 stages are set up only on the major's primary window; a tournament-linked
|
||||
// non-primary event is a read-only mirror fed by fan-out.
|
||||
const guardEvent = await getScoringEventById(eventId);
|
||||
if (guardEvent && isReadOnlySibling(guardEvent)) {
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
'This CS2 major is set up on its primary window; results fan out here automatically.',
|
||||
};
|
||||
}
|
||||
|
||||
if (intent === 'reset') {
|
||||
await clearCs2StageAssignments(eventId);
|
||||
return { success: true, message: 'Stage assignments cleared.' };
|
||||
// Local recovery tool: clears only the primary's torn state. Siblings are not
|
||||
// touched here — re-entering results re-propagates via the scoring fan-out.
|
||||
await resetCs2Event(eventId, params.id);
|
||||
return { success: true, message: 'Stage assignments and recorded results cleared.' };
|
||||
}
|
||||
|
||||
if (intent === 'assign') {
|
||||
|
|
@ -126,6 +140,10 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
}
|
||||
await markCs2StageEliminations(eventId, eliminations);
|
||||
await assignCs2EliminationQP(eventId, params.id);
|
||||
// Propagate the derived QP/placements to every sibling window (in-progress:
|
||||
// don't mark complete — that happens when the bracket is finalized).
|
||||
const event = await getScoringEventById(eventId);
|
||||
if (event) await fanOutMajorIfPrimary(event, { markComplete: false });
|
||||
return { success: true, message: `Marked ${eliminations.length} eliminations.` };
|
||||
} catch (err) {
|
||||
return { success: false, message: err instanceof Error ? err.message : 'Failed to save eliminations.' };
|
||||
|
|
@ -268,7 +286,14 @@ export default function AdminCs2Setup() {
|
|||
<CardTitle className="flex items-center justify-between">
|
||||
Stage Assignments
|
||||
{stageResults.length > 0 && (
|
||||
<Form method="post">
|
||||
<Form
|
||||
method="post"
|
||||
onSubmit={(e) => {
|
||||
if (!confirm('Reset this event? This clears all stage assignments AND recorded results/QP for the event so you can set it up again. The Champions Stage bracket is kept.')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="intent" value="reset" />
|
||||
<Button type="submit" variant="ghost" size="sm" className="text-destructive">
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
getScoringEventById,
|
||||
completeScoringEvent,
|
||||
updateScoringEvent,
|
||||
isReadOnlySibling,
|
||||
} from "~/models/scoring-event";
|
||||
import {
|
||||
getEventResults,
|
||||
|
|
@ -77,6 +78,9 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
)
|
||||
: new Set();
|
||||
|
||||
// A tournament-linked event that is NOT the primary is a read-only mirror:
|
||||
// scoring happens once on the canonical tournament (or its primary window) and
|
||||
// fans out here. Surface this so the UI can point the admin to the right place.
|
||||
return {
|
||||
sportsSeason: sportsSeason as typeof sportsSeason & {
|
||||
sport: { id: string; name: string; type: string; slug: string; simulatorType: string | null };
|
||||
|
|
@ -89,13 +93,45 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
qpConfig,
|
||||
qpStandings,
|
||||
notParticipatingIds,
|
||||
isReadOnlySibling: isReadOnlySibling(event),
|
||||
canonicalTournamentId: event.tournamentId,
|
||||
};
|
||||
}
|
||||
|
||||
// Scoring intents that mutate this window's results directly. For a read-only
|
||||
// sibling (tournament-linked, non-primary) these are rejected — score on the
|
||||
// canonical tournament page instead, and the result fans out automatically.
|
||||
//
|
||||
// mark/unmark-not-participating are deliberately NOT here: they are per-window
|
||||
// simulator inputs (exclude a participant from this window's EV draws), have no
|
||||
// canonical-tournament equivalent, and must stay editable on every window.
|
||||
const SCORING_INTENTS = new Set([
|
||||
"process-qp",
|
||||
"complete",
|
||||
"uncomplete",
|
||||
"add-result",
|
||||
"update-result",
|
||||
"delete-result",
|
||||
"update-standings",
|
||||
"batch-add-results",
|
||||
]);
|
||||
|
||||
export async function action({ request, params }: Route.ActionArgs) {
|
||||
const formData = await request.formData();
|
||||
const intent = formData.get("intent");
|
||||
|
||||
// Enforce single-source scoring: reject direct scoring on a read-only sibling.
|
||||
if (typeof intent === "string" && SCORING_INTENTS.has(intent)) {
|
||||
const ev = await getScoringEventById(params.eventId);
|
||||
if (ev && isReadOnlySibling(ev)) {
|
||||
return {
|
||||
error:
|
||||
"This event belongs to a shared major. Score it once on the tournament page (Admin → Tournaments) and it fans out to every window automatically.",
|
||||
canonicalTournamentId: ev.tournamentId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === "mark-qualifying") {
|
||||
try {
|
||||
const event = await getScoringEventById(params.eventId);
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import {
|
|||
} from "~/components/ui/select";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { ArrowLeft, Trophy, CheckCircle2, Pencil, Trash2, Brackets, Save } from "lucide-react";
|
||||
import { getEventTypeLabel } from "~/models/scoring-event";
|
||||
import { getEventTypeLabel } from "~/models/scoring-event-types";
|
||||
import { isBracketMajor } from "~/lib/event-utils";
|
||||
import {
|
||||
Table,
|
||||
|
|
@ -95,9 +95,24 @@ export default function EventResults({
|
|||
loaderData,
|
||||
actionData,
|
||||
}: Route.ComponentProps) {
|
||||
const { sportsSeason, event, participants, results, participantResults, seasonResults, notParticipatingIds } = loaderData;
|
||||
const { sportsSeason, event, participants, results, participantResults, seasonResults, notParticipatingIds, isReadOnlySibling, canonicalTournamentId } = loaderData;
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
// Banner shown on read-only sibling events: scoring lives on the canonical
|
||||
// tournament page, and results fan out here automatically.
|
||||
const readOnlySiblingBanner = isReadOnlySibling ? (
|
||||
<div className="bg-amber-500/15 text-amber-500 border border-amber-500/30 px-4 py-3 rounded-md text-sm mb-4">
|
||||
This event is part of a shared major. Scoring is done once on the{" "}
|
||||
<Link
|
||||
to={`/admin/tournaments/${canonicalTournamentId}`}
|
||||
className="underline font-medium"
|
||||
>
|
||||
tournament page
|
||||
</Link>{" "}
|
||||
and fans out to every window automatically — the controls here are read-only.
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const isBracketEvent = event.eventType === "playoff_game" || isBracketMajor(sportsSeason.sport?.simulatorType);
|
||||
|
||||
// Create a map of participants with results for easy lookup
|
||||
|
|
@ -218,6 +233,7 @@ export default function EventResults({
|
|||
return (
|
||||
<div className="p-8">
|
||||
<div className="max-w-4xl">
|
||||
{readOnlySiblingBanner}
|
||||
<div className="mb-6">
|
||||
<Button variant="ghost" size="sm" asChild className="mb-2">
|
||||
<Link to={`/admin/sports-seasons/${sportsSeason.id}/events`}>
|
||||
|
|
|
|||
|
|
@ -7,8 +7,12 @@ import {
|
|||
createScoringEvent,
|
||||
deleteScoringEvent,
|
||||
bulkCreateScoringEvents,
|
||||
ensurePrimaryEvent,
|
||||
countWindowsByTournament,
|
||||
getMajorsCompleted,
|
||||
type CreateScoringEventData,
|
||||
} from "~/models/scoring-event";
|
||||
import { isBracketMajor } from "~/lib/event-utils";
|
||||
import { getQPStandings } from "~/models/qualifying-points";
|
||||
import { finalizeQualifyingPoints } from "~/models/scoring-calculator";
|
||||
import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.server";
|
||||
|
|
@ -25,10 +29,14 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
|
||||
const events = await getScoringEventsForSportsSeason(params.id);
|
||||
|
||||
// For qualifying sports seasons, get QP standings with global ranks attached
|
||||
// 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.
|
||||
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;
|
||||
|
|
@ -57,11 +65,38 @@ 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 as typeof sportsSeason & {
|
||||
sportsSeason: { ...sportsSeason, majorsCompleted } as typeof sportsSeason & {
|
||||
sport: { id: string; name: string; type: string; slug: string };
|
||||
},
|
||||
events,
|
||||
events: eventsWithSharing,
|
||||
qpStandings,
|
||||
scoringRules,
|
||||
availableTournaments,
|
||||
|
|
@ -92,6 +127,12 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
eventDate,
|
||||
eventStartsAt: tournament.startsAt ? new Date(tournament.startsAt) : undefined,
|
||||
});
|
||||
// Bracket majors (tennis/CS2) need a primary window to be scorable. Seed it
|
||||
// on the first linked window; later windows stay siblings (idempotent).
|
||||
const ss = await findSportsSeasonById(params.id);
|
||||
if (isBracketMajor(ss?.sport?.simulatorType)) {
|
||||
await ensurePrimaryEvent(tournament.id, event.id);
|
||||
}
|
||||
return redirect(`/admin/sports-seasons/${params.id}/events/${event.id}`);
|
||||
} catch (error) {
|
||||
logger.error("Error adding scoring event from tournament:", error);
|
||||
|
|
@ -185,7 +226,13 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
}
|
||||
|
||||
try {
|
||||
await bulkCreateScoringEvents(params.id, validEvents);
|
||||
const created = await bulkCreateScoringEvents(params.id, validEvents);
|
||||
// Seed a primary window per bracket-major tournament that lacks one.
|
||||
if (isBracketMajor(sportsSeason.sport?.simulatorType)) {
|
||||
for (const ev of created) {
|
||||
if (ev.tournamentId) await ensurePrimaryEvent(ev.tournamentId, ev.id);
|
||||
}
|
||||
}
|
||||
return { success: `Created ${validEvents.length} event${validEvents.length !== 1 ? "s" : ""} successfully` };
|
||||
} catch (error) {
|
||||
logger.error("Error bulk creating events:", error);
|
||||
|
|
@ -201,8 +248,19 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
}
|
||||
|
||||
try {
|
||||
await deleteScoringEvent(eventId);
|
||||
return { success: "Event deleted successfully" };
|
||||
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 };
|
||||
} catch (error) {
|
||||
logger.error("Error deleting event:", error);
|
||||
return { error: "Failed to delete event" };
|
||||
|
|
@ -282,6 +340,11 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
|
||||
try {
|
||||
const event = await createScoringEvent(eventData);
|
||||
// Bracket majors need a primary window to be scorable — seed it on the first
|
||||
// linked window (idempotent for later windows).
|
||||
if (eventData.tournamentId && isBracketMajor(sportsSeason.sport?.simulatorType)) {
|
||||
await ensurePrimaryEvent(eventData.tournamentId, event.id);
|
||||
}
|
||||
return redirect(`/admin/sports-seasons/${params.id}/events/${event.id}`);
|
||||
} catch (error) {
|
||||
logger.error("Error creating scoring event:", error);
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ import {
|
|||
import { Calendar, Trophy, ArrowLeft, Trash2, ListPlus } from "lucide-react";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { QualifyingPointsStandings } from "~/components/scoring/QualifyingPointsStandings";
|
||||
import { getEventTypeLabel } from "~/models/scoring-event";
|
||||
import { getEventTypeLabel } from "~/models/scoring-event-types";
|
||||
|
||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||
return [{ title: `Events — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
||||
|
|
@ -305,7 +305,11 @@ 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 }) => (
|
||||
{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 (
|
||||
<Card key={event.id} className="hover:border-primary/50 transition-colors">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
|
|
@ -352,28 +356,65 @@ export default function SportsSeasonEvents({
|
|||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<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} />
|
||||
<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>
|
||||
<AlertDialogAction type="submit" className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</Form>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogFooter>
|
||||
</Form>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -1,394 +1,9 @@
|
|||
import { Form, redirect, useLoaderData, useActionData, useNavigation } from 'react-router';
|
||||
import { redirect } from 'react-router';
|
||||
import type { Route } from './+types/admin.sports-seasons.$id.futures-odds';
|
||||
|
||||
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` }];
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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>
|
||||
);
|
||||
return redirect(`/admin/sports-seasons/${params.id}/simulator`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { Form, Link, redirect, useActionData, useNavigation } from "react-router";
|
||||
import type { Route } from "./+types/admin.sports-seasons.$id.simulator";
|
||||
|
||||
|
|
@ -25,7 +26,15 @@ import {
|
|||
type UpsertParticipantSimulatorInput,
|
||||
} from "~/models/simulator";
|
||||
import { normalizeName } from "~/lib/fuzzy-match";
|
||||
import { getSimulatorInputPolicy, type MissingEloStrategy, type MissingRatingStrategy } from "~/services/simulations/input-policy";
|
||||
import {
|
||||
simulatorInputLabel,
|
||||
type SimulatorInputKey,
|
||||
} from "~/services/simulations/manifest";
|
||||
import {
|
||||
getSimulatorInputPolicy,
|
||||
type MissingEloStrategy,
|
||||
type MissingRatingStrategy,
|
||||
} from "~/services/simulations/input-policy";
|
||||
import { runSportsSeasonSimulation } from "~/services/simulations/runner";
|
||||
|
||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||
|
|
@ -57,7 +66,22 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
|
||||
const inputPolicy = getSimulatorInputPolicy(config.config);
|
||||
|
||||
return { sportsSeason, participants, config, inputRows, readiness, inputPolicy };
|
||||
// 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,
|
||||
]);
|
||||
const inputColumns = DISPLAY_INPUT_ORDER.filter((key) => relevantInputs.has(key)).map((key) => ({
|
||||
key,
|
||||
label: simulatorInputLabel(key),
|
||||
required: config.profile.requiredInputs.includes(key),
|
||||
}));
|
||||
|
||||
return { sportsSeason, participants, config, inputRows, readiness, inputPolicy, inputColumns };
|
||||
}
|
||||
|
||||
interface ActionData {
|
||||
|
|
@ -65,6 +89,43 @@ 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",
|
||||
]);
|
||||
|
||||
function parseOptionalNumber(value: string | undefined): number | null {
|
||||
if (value === undefined || value.trim() === "") return null;
|
||||
const parsed = Number(value);
|
||||
|
|
@ -106,6 +167,39 @@ 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,
|
||||
|
|
@ -118,7 +212,10 @@ function parseInputCsv(
|
|||
const indexes = new Map(header.map((value, index) => [value, index]));
|
||||
const nameIndex = indexes.get("name");
|
||||
if (nameIndex === undefined) {
|
||||
throw new Error("Bulk input CSV must include a `name` column.");
|
||||
// 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);
|
||||
}
|
||||
|
||||
const inputs: UpsertParticipantSimulatorInput[] = [];
|
||||
|
|
@ -192,31 +289,48 @@ export async function action({ request, params }: Route.ActionArgs): Promise<Act
|
|||
}
|
||||
}
|
||||
|
||||
if (intent === "save-input-policy") {
|
||||
if (intent === "save-configuration") {
|
||||
const currentConfig = await getSportsSeasonSimulatorConfig(sportsSeasonId);
|
||||
if (!currentConfig) return { success: false, message: "Simulator config not found." };
|
||||
|
||||
const currentPolicy = getSimulatorInputPolicy(currentConfig.config);
|
||||
// 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")),
|
||||
// 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),
|
||||
};
|
||||
}
|
||||
|
||||
await upsertSportsSeasonSimulatorConfig({
|
||||
sportsSeasonId,
|
||||
simulatorType: currentConfig.simulatorType,
|
||||
config: {
|
||||
...currentConfig.config,
|
||||
inputPolicy: {
|
||||
missingEloStrategy: parseMissingEloStrategy(formData.get("missingEloStrategy")),
|
||||
missingRatingStrategy: parseMissingRatingStrategy(formData.get("missingRatingStrategy")),
|
||||
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),
|
||||
},
|
||||
},
|
||||
config: next,
|
||||
});
|
||||
return { success: true, message: "Simulator input policy saved." };
|
||||
return { success: true, message: "Simulator configuration saved." };
|
||||
}
|
||||
|
||||
if (intent === "save-inputs") {
|
||||
|
|
@ -235,7 +349,26 @@ 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(", ")}.`
|
||||
: "";
|
||||
return { success: true, message: `Saved ${parsed.inputs.length} simulator input row(s).${suffix}` };
|
||||
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}` };
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to save simulator inputs." };
|
||||
}
|
||||
|
|
@ -255,6 +388,49 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
|||
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]);
|
||||
|
||||
// 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 } = 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;
|
||||
|
||||
const isRowIncomplete = (input: (typeof inputRows)[number]["input"]) =>
|
||||
requiredInputs.some((key) => input?.[key] === null || input?.[key] === undefined);
|
||||
|
||||
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(input)) return false;
|
||||
return true;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [inputRows, search, onlyMissing, requiredInputs]);
|
||||
|
||||
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);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
|
|
@ -328,8 +504,7 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
|||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{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("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>}
|
||||
|
|
@ -347,120 +522,164 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{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" />
|
||||
{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} />
|
||||
</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>
|
||||
<CardTitle>Simulator Configuration</CardTitle>
|
||||
<CardDescription>
|
||||
JSON overrides for this specific sports season. Defaults come from the simulator profile.
|
||||
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>
|
||||
<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)}
|
||||
/>
|
||||
<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>
|
||||
<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.
|
||||
</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>
|
||||
)}
|
||||
</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>
|
||||
{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} />
|
||||
</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>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Save Config
|
||||
Save Configuration
|
||||
</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>
|
||||
|
||||
|
|
@ -468,8 +687,12 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
|||
<CardHeader>
|
||||
<CardTitle>Bulk Simulator Inputs</CardTitle>
|
||||
<CardDescription>
|
||||
Paste CSV with a header row. Supported columns: name, sourceElo, sourceOdds, worldRanking, rating,
|
||||
projectedWins, projectedTablePoints, seed, region. Values must not contain commas.
|
||||
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.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
|
|
@ -479,7 +702,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`}
|
||||
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`}
|
||||
/>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
|
|
@ -487,29 +710,125 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
|||
</Button>
|
||||
</Form>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<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>
|
||||
{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>
|
||||
{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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
{pageRows.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
No participants match.
|
||||
</div>
|
||||
) : (
|
||||
pageRows.map(({ participant, input }) => {
|
||||
const incomplete = isRowIncomplete(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) => {
|
||||
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>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -675,14 +675,6 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
|
|||
<Calculator className="mr-2 h-4 w-4" />
|
||||
Simulator Setup
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/futures-odds`)}
|
||||
>
|
||||
<Calculator className="mr-2 h-4 w-4" />
|
||||
Futures Odds
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
|
|
|||
|
|
@ -17,7 +17,13 @@ import {
|
|||
syncTournamentResults,
|
||||
type SyncReport,
|
||||
} from "~/services/sync-tournament-results";
|
||||
import { getSportsSeasonsByTournament } from "~/models/scoring-event";
|
||||
import {
|
||||
getSportsSeasonsByTournament,
|
||||
getPrimaryEventForTournament,
|
||||
setPrimaryEvent,
|
||||
deleteScoringEvent,
|
||||
} from "~/models/scoring-event";
|
||||
import { isBracketMajor } from "~/lib/event-utils";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
|
|
@ -25,6 +31,17 @@ 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 {
|
||||
|
|
@ -52,13 +69,30 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const [results, canonicalParticipants, linkedSportsSeasons] = await Promise.all([
|
||||
getTournamentResults(tournament.id),
|
||||
findCanonicalParticipantsBySport(tournament.sportId),
|
||||
getSportsSeasonsByTournament(tournament.id),
|
||||
]);
|
||||
const [results, canonicalParticipants, linkedSportsSeasons, primaryEvent] =
|
||||
await Promise.all([
|
||||
getTournamentResults(tournament.id),
|
||||
findCanonicalParticipantsBySport(tournament.sportId),
|
||||
getSportsSeasonsByTournament(tournament.id),
|
||||
getPrimaryEventForTournament(tournament.id),
|
||||
]);
|
||||
|
||||
return { tournament, results, canonicalParticipants, linkedSportsSeasons };
|
||||
// Bracket majors (tennis, CS2) are scored on their primary window's bracket /
|
||||
// stage UI; golf is entered right here. Surface a link for the bracket case.
|
||||
const simulatorType =
|
||||
linkedSportsSeasons[0]?.sportsSeason?.sport?.simulatorType ?? null;
|
||||
const bracketMajor = isBracketMajor(simulatorType);
|
||||
const isCs2 = simulatorType === "cs2_major_qualifying_points";
|
||||
|
||||
return {
|
||||
tournament,
|
||||
results,
|
||||
canonicalParticipants,
|
||||
linkedSportsSeasons,
|
||||
primaryEvent,
|
||||
bracketMajor,
|
||||
isCs2,
|
||||
};
|
||||
}
|
||||
|
||||
export async function action(args: Route.ActionArgs) {
|
||||
|
|
@ -127,11 +161,17 @@ export async function action(args: Route.ActionArgs) {
|
|||
});
|
||||
}
|
||||
|
||||
if (tournament.status !== "completed") {
|
||||
await updateTournamentStatus(tournament.id, "completed");
|
||||
const syncReport = await syncTournamentResults(tournament.id);
|
||||
|
||||
// Status reflects reality: completed only when every linked window synced.
|
||||
// If any window failed, leave it in_progress so the failure is visible and
|
||||
// retryable rather than masked by a green "completed" badge.
|
||||
const newStatus =
|
||||
syncReport.windowsFailed === 0 ? "completed" : "in_progress";
|
||||
if (tournament.status !== newStatus) {
|
||||
await updateTournamentStatus(tournament.id, newStatus);
|
||||
}
|
||||
|
||||
const syncReport = await syncTournamentResults(tournament.id);
|
||||
return {
|
||||
success: true as const,
|
||||
error: null,
|
||||
|
|
@ -151,6 +191,11 @@ export async function action(args: Route.ActionArgs) {
|
|||
if (intent === "retry-window-sync") {
|
||||
try {
|
||||
const syncReport = await syncTournamentResults(tournament.id);
|
||||
const newStatus =
|
||||
syncReport.windowsFailed === 0 ? "completed" : "in_progress";
|
||||
if (tournament.status !== newStatus) {
|
||||
await updateTournamentStatus(tournament.id, newStatus);
|
||||
}
|
||||
return { success: true as const, error: null, syncReport };
|
||||
} catch (error) {
|
||||
logger.error("retry-window-sync failed:", error);
|
||||
|
|
@ -163,6 +208,44 @@ export async function action(args: Route.ActionArgs) {
|
|||
}
|
||||
}
|
||||
|
||||
if (intent === "set-primary") {
|
||||
const eventId = formData.get("eventId");
|
||||
if (typeof eventId !== "string" || !eventId) {
|
||||
return { success: false as const, error: "Event ID is required", syncReport: null };
|
||||
}
|
||||
try {
|
||||
await setPrimaryEvent(eventId);
|
||||
return { success: true as const, error: null, syncReport: null };
|
||||
} catch (error) {
|
||||
logger.error("set-primary failed:", error);
|
||||
return {
|
||||
success: false as const,
|
||||
error: error instanceof Error ? error.message : "Failed to set primary window",
|
||||
syncReport: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
|
|
@ -174,8 +257,26 @@ export default function AdminTournamentDetail({
|
|||
loaderData,
|
||||
actionData,
|
||||
}: Route.ComponentProps) {
|
||||
const { tournament, results, canonicalParticipants, linkedSportsSeasons } = loaderData;
|
||||
const {
|
||||
tournament,
|
||||
results,
|
||||
canonicalParticipants,
|
||||
linkedSportsSeasons,
|
||||
primaryEvent,
|
||||
bracketMajor,
|
||||
isCs2,
|
||||
} = 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 =
|
||||
bracketMajor && primaryEvent
|
||||
? isCs2
|
||||
? `/admin/sports-seasons/${primaryEvent.sportsSeasonId}/events/${primaryEvent.id}/cs2-setup`
|
||||
: `/admin/sports-seasons/${primaryEvent.sportsSeasonId}/events/${primaryEvent.id}/bracket`
|
||||
: null;
|
||||
|
||||
// Prefer the latest action/retry response for the sync report
|
||||
const liveReport: SyncReport | null =
|
||||
|
|
@ -195,19 +296,28 @@ export default function AdminTournamentDetail({
|
|||
Back to tournaments
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold">{tournament.name}</h1>
|
||||
<Badge
|
||||
variant={
|
||||
tournament.status === "completed"
|
||||
? "default"
|
||||
: tournament.status === "in_progress"
|
||||
? "secondary"
|
||||
: "outline"
|
||||
}
|
||||
>
|
||||
{tournament.status}
|
||||
</Badge>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold">{tournament.name}</h1>
|
||||
<Badge
|
||||
variant={
|
||||
tournament.status === "completed"
|
||||
? "default"
|
||||
: tournament.status === "in_progress"
|
||||
? "secondary"
|
||||
: "outline"
|
||||
}
|
||||
>
|
||||
{tournament.status}
|
||||
</Badge>
|
||||
</div>
|
||||
{primaryScoringHref && (
|
||||
<Button asChild>
|
||||
<Link to={primaryScoringHref}>
|
||||
{isCs2 ? "Manage stages & bracket →" : "Manage bracket →"}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{tournament.year}
|
||||
|
|
@ -313,17 +423,81 @@ export default function AdminTournamentDetail({
|
|||
{link.sportsSeason.scoringType.replace("_", " ")}
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant={
|
||||
link.sportsSeason.status === "completed"
|
||||
? "default"
|
||||
: link.sportsSeason.status === "active"
|
||||
? "secondary"
|
||||
: "outline"
|
||||
}
|
||||
>
|
||||
{link.sportsSeason.status}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-2">
|
||||
{bracketMajor &&
|
||||
(link.isPrimary ? (
|
||||
<Badge variant="default">Primary</Badge>
|
||||
) : (
|
||||
<primaryFetcher.Form method="post">
|
||||
<input type="hidden" name="intent" value="set-primary" />
|
||||
<input type="hidden" name="eventId" value={link.id} />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={primaryFetcher.state !== "idle"}
|
||||
>
|
||||
Make primary
|
||||
</Button>
|
||||
</primaryFetcher.Form>
|
||||
))}
|
||||
<Badge
|
||||
variant={
|
||||
link.sportsSeason.status === "completed"
|
||||
? "default"
|
||||
: link.sportsSeason.status === "active"
|
||||
? "secondary"
|
||||
: "outline"
|
||||
}
|
||||
>
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -382,17 +556,43 @@ export default function AdminTournamentDetail({
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<BatchResultEntry
|
||||
participants={canonicalParticipants.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
}))}
|
||||
sportsSeasonId=""
|
||||
existingResultParticipantIds={
|
||||
new Set(results.map((r) => r.participantId))
|
||||
}
|
||||
intent="batch-upsert-results"
|
||||
/>
|
||||
{bracketMajor ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Scoring</CardTitle>
|
||||
<CardDescription>
|
||||
This is a bracket major. Results are derived from the
|
||||
bracket/stage workflow on the primary window and fan out here
|
||||
automatically.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{primaryScoringHref ? (
|
||||
<Button asChild>
|
||||
<Link to={primaryScoringHref}>
|
||||
{isCs2 ? "Manage stages & bracket →" : "Manage bracket →"}
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No primary window is set up yet for this tournament.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<BatchResultEntry
|
||||
participants={canonicalParticipants.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
}))}
|
||||
sportsSeasonId=""
|
||||
existingResultParticipantIds={
|
||||
new Set(results.map((r) => r.participantId))
|
||||
}
|
||||
intent="batch-upsert-results"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
Users,
|
||||
Menu,
|
||||
Shield,
|
||||
CalendarRange,
|
||||
} from "lucide-react";
|
||||
|
||||
export function meta(): Route.MetaDescriptors {
|
||||
|
|
@ -72,6 +73,12 @@ 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 } from "~/services/match-sync";
|
||||
import { syncMatches, syncTennisDraw } from "~/services/match-sync";
|
||||
|
||||
export async function action({ request }: { request: Request }) {
|
||||
requireCronSecret(request);
|
||||
|
|
@ -37,5 +37,39 @@ export async function action({ request }: { request: Request }) {
|
|||
}
|
||||
}
|
||||
|
||||
return Response.json({ synced, errors }, { status: errors.length > 0 && synced.length === 0 ? 500 : 200 });
|
||||
// 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 },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
findDraftSlotsBySeasonId,
|
||||
} from "~/models";
|
||||
import { findCurrentSeasonWithSports } from "~/models/season";
|
||||
import { getSeasonStandings } from "~/models/standings";
|
||||
import { getSevenDayStandingsChange } from "~/models/standings";
|
||||
import { getUpcomingEventsForDraftedParticipants } from "~/models/scoring-event";
|
||||
import { getUpcomingGroupStageMatchesForParticipants } from "~/models/group-stage-match";
|
||||
import { getDraftedParticipantsBySportsSeason, getDraftedParticipantsWithPoints, type DraftedParticipantWithPoints } from "~/models/draft-pick";
|
||||
|
|
@ -77,7 +77,7 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
// Fetch standings for active/completed seasons
|
||||
const standings =
|
||||
season && (season.status === "active" || season.status === "completed")
|
||||
? await getSeasonStandings(season.id)
|
||||
? await getSevenDayStandingsChange(season.id)
|
||||
: [];
|
||||
|
||||
// Batch-fetch all users needed for owner and commissioner maps in one query
|
||||
|
|
|
|||
|
|
@ -8,11 +8,13 @@ import {
|
|||
getUserDisplayName,
|
||||
} from "~/models";
|
||||
import { getDraftPicks } from "~/models/draft-pick";
|
||||
import { getScoringEventById } from "~/models/scoring-event";
|
||||
import { getScoringEventById, getPrimaryEventForTournament, isReadOnlySibling } from "~/models/scoring-event";
|
||||
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
||||
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";
|
||||
|
|
@ -99,6 +101,55 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||
|
||||
const simulatorType = sportsSeason.sport?.simulatorType ?? null;
|
||||
|
||||
// Shared-major fan-out for DISPLAY: a tournament-linked, non-primary event has
|
||||
// no bracket/stage rows of its own — those live on the primary window. Read the
|
||||
// structure from the primary, then re-key this league's ownership overlay from
|
||||
// its own season_participant ids to the PRIMARY's season_participant ids (the
|
||||
// ids the bracket structure uses) via the shared canonical participant. QP/
|
||||
// results branches keep using this window's local rows and are untouched.
|
||||
let structureEventId = eventId;
|
||||
let structureBracketTemplateId = scoringEvent.bracketTemplateId;
|
||||
let bracketTeamOwnerships = teamOwnerships;
|
||||
let bracketUserParticipantIds = userParticipantIds;
|
||||
|
||||
if (isReadOnlySibling(scoringEvent) && scoringEvent.tournamentId) {
|
||||
const primaryEvent = await getPrimaryEventForTournament(scoringEvent.tournamentId);
|
||||
if (primaryEvent && primaryEvent.id !== eventId) {
|
||||
structureEventId = primaryEvent.id;
|
||||
structureBracketTemplateId = primaryEvent.bracketTemplateId;
|
||||
|
||||
const [siblingParticipants, primaryParticipants] = await Promise.all([
|
||||
findParticipantsBySportsSeasonId(sportsSeasonId),
|
||||
findParticipantsBySportsSeasonId(primaryEvent.sportsSeasonId),
|
||||
]);
|
||||
// sibling season_participant id → canonical participant id
|
||||
const siblingSpToCanonical = new Map(
|
||||
siblingParticipants
|
||||
.filter((p) => p.participantId)
|
||||
.map((p) => [p.id, p.participantId as string])
|
||||
);
|
||||
// canonical participant id → primary season_participant id
|
||||
const canonicalToPrimarySp = new Map(
|
||||
primaryParticipants
|
||||
.filter((p) => p.participantId)
|
||||
.map((p) => [p.participantId as string, p.id])
|
||||
);
|
||||
const toPrimarySp = (siblingSpId: string): string | undefined => {
|
||||
const canonical = siblingSpToCanonical.get(siblingSpId);
|
||||
return canonical ? canonicalToPrimarySp.get(canonical) : undefined;
|
||||
};
|
||||
|
||||
bracketTeamOwnerships = teamOwnerships.flatMap((o) => {
|
||||
const primarySpId = toPrimarySp(o.participantId);
|
||||
return primarySpId ? [{ ...o, participantId: primarySpId }] : [];
|
||||
});
|
||||
bracketUserParticipantIds = userParticipantIds.flatMap((id) => {
|
||||
const primarySpId = toPrimarySp(id);
|
||||
return primarySpId ? [primarySpId] : [];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type PlayoffMatchWithRelations = Omit<PlayoffMatch, "createdAt" | "updatedAt"> & {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
|
@ -118,10 +169,10 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||
// CS2 Major — load Swiss stage data + bracket
|
||||
if (simulatorType === "cs2_major_qualifying_points") {
|
||||
const [cs2StageResults, seasonMatches, playoffMatchRows] = await Promise.all([
|
||||
getCs2StageResultsForEvent(eventId),
|
||||
findSeasonMatchesByScoringEventId(eventId),
|
||||
getCs2StageResultsForEvent(structureEventId),
|
||||
findSeasonMatchesByScoringEventId(structureEventId),
|
||||
db.query.playoffMatches.findMany({
|
||||
where: eq(schema.playoffMatches.scoringEventId, eventId),
|
||||
where: eq(schema.playoffMatches.scoringEventId, structureEventId),
|
||||
with: { participant1: true, participant2: true, winner: true, loser: true },
|
||||
}),
|
||||
]);
|
||||
|
|
@ -131,7 +182,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||
createdAt: m.createdAt.toISOString(),
|
||||
updatedAt: m.updatedAt.toISOString(),
|
||||
}));
|
||||
const templateId = scoringEvent.bracketTemplateId ?? undefined;
|
||||
const templateId = structureBracketTemplateId ?? undefined;
|
||||
const template = templateId ? getBracketTemplate(templateId) : undefined;
|
||||
const playoffRounds = getOrderedRoundsFromMatches(playoffMatches, template);
|
||||
|
||||
|
|
@ -140,8 +191,8 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||
league,
|
||||
sportsSeason,
|
||||
scoringEvent,
|
||||
teamOwnerships,
|
||||
userParticipantIds,
|
||||
teamOwnerships: bracketTeamOwnerships,
|
||||
userParticipantIds: bracketUserParticipantIds,
|
||||
cs2StageResults,
|
||||
seasonMatches: seasonMatches.map((m) => ({
|
||||
...m,
|
||||
|
|
@ -155,10 +206,73 @@ 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({
|
||||
where: eq(schema.playoffMatches.scoringEventId, eventId),
|
||||
where: eq(schema.playoffMatches.scoringEventId, structureEventId),
|
||||
with: { participant1: true, participant2: true, winner: true, loser: true },
|
||||
});
|
||||
const playoffMatches: PlayoffMatchWithRelations[] = playoffMatchRows.map((m) => ({
|
||||
|
|
@ -166,13 +280,13 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||
createdAt: m.createdAt.toISOString(),
|
||||
updatedAt: m.updatedAt.toISOString(),
|
||||
}));
|
||||
const templateId = scoringEvent.bracketTemplateId ?? undefined;
|
||||
const templateId = structureBracketTemplateId ?? undefined;
|
||||
const template = templateId ? getBracketTemplate(templateId) : undefined;
|
||||
const playoffRounds = getOrderedRoundsFromMatches(playoffMatches, template);
|
||||
|
||||
let groupStandings: GroupStandingData[] = [];
|
||||
if (template?.groupStage) {
|
||||
const groups = await findGroupsByEventId(eventId);
|
||||
const groups = await findGroupsByEventId(structureEventId);
|
||||
const matchesByGroupId = await findMatchesByGroupIds(groups.map((g) => g.id));
|
||||
groupStandings = groups.map((group) => {
|
||||
const groupMatches = matchesByGroupId.get(group.id) ?? [];
|
||||
|
|
@ -216,8 +330,8 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||
league,
|
||||
sportsSeason,
|
||||
scoringEvent,
|
||||
teamOwnerships,
|
||||
userParticipantIds,
|
||||
teamOwnerships: bracketTeamOwnerships,
|
||||
userParticipantIds: bracketUserParticipantIds,
|
||||
playoffMatches,
|
||||
playoffRounds,
|
||||
bracketTemplateId: templateId ?? null,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,79 @@ 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;
|
||||
|
|
@ -114,58 +187,42 @@ 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" && (
|
||||
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>
|
||||
)
|
||||
<QpResultsTable
|
||||
eventResults={loaderData.eventResults}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={loaderData.userParticipantIds}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ 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";
|
||||
|
|
@ -157,6 +158,7 @@ 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;
|
||||
|
|
@ -293,6 +295,9 @@ 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.
|
||||
|
|
@ -341,7 +346,7 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
return {
|
||||
league,
|
||||
season,
|
||||
sportsSeason,
|
||||
sportsSeason: { ...sportsSeason, majorsCompleted },
|
||||
scoringPattern,
|
||||
playoffMatches,
|
||||
playoffRounds,
|
||||
|
|
|
|||
|
|
@ -140,6 +140,8 @@ export default function LeagueStandings() {
|
|||
points: s.totalPoints,
|
||||
rankChange: s.rankChange,
|
||||
pointChange: s.sevenDayPointChange,
|
||||
projectedPoints: s.projectedPoints,
|
||||
participantsRemaining: s.participantsRemaining,
|
||||
href: `/leagues/${league.id}/standings/${season.id}/teams/${s.teamId}`,
|
||||
}));
|
||||
|
||||
|
|
@ -181,6 +183,7 @@ export default function LeagueStandings() {
|
|||
<StandingsPreview
|
||||
entries={previewEntries}
|
||||
description={seasonComplete ? "Final Standings" : "Current Standings"}
|
||||
showProjected={!seasonComplete}
|
||||
/>
|
||||
|
||||
{progressionData.chartData.length > 0 && (
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
|
|||
currentRank: standing?.currentRank,
|
||||
points: standing ? (standing.actualPoints ?? standing.totalPoints) : 0,
|
||||
href: season ? `/leagues/${league.id}/standings/${season.id}/teams/${team.id}` : undefined,
|
||||
rankChange: standing?.rankChange,
|
||||
rankChange: standing?.sevenDayRankChange,
|
||||
pointChange: standing?.sevenDayPointChange,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { redirect, useSearchParams } from "react-router";
|
||||
import { useState } from "react";
|
||||
import { redirect, type ShouldRevalidateFunctionArgs } from "react-router";
|
||||
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";
|
||||
|
|
@ -57,7 +56,26 @@ 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");
|
||||
|
|
@ -213,18 +231,12 @@ export async function action(args: Route.ActionArgs): Promise<ActionData | Respo
|
|||
return { intent: intent as string, error: "Unknown action." };
|
||||
}
|
||||
|
||||
export default function SettingsPage({ loaderData, actionData }: Route.ComponentProps) {
|
||||
export default function SettingsPage({ loaderData, actionData, params }: Route.ComponentProps) {
|
||||
const { user, isInActiveDraft, linkedAccounts, dataRequestCooldownUntil } = loaderData;
|
||||
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 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 ad = actionData as ActionData | undefined;
|
||||
const profileSuccess = ad?.intent === "update-profile" && "success" in ad;
|
||||
|
|
@ -246,20 +258,21 @@ export default function SettingsPage({ loaderData, actionData }: Route.Component
|
|||
<div className="lg:hidden">
|
||||
<SettingsMobileGridNav
|
||||
sections={SECTIONS}
|
||||
onSectionChange={(id) => handleSectionChange(id, true)}
|
||||
buildHref={(id) => `/settings/${id}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mobileView === "section" && (
|
||||
<SettingsMobileSectionPill onShowGrid={() => setMobileView("grid")} />
|
||||
<SettingsMobileSectionPill backHref="/settings" />
|
||||
)}
|
||||
|
||||
<div className={`grid gap-6 lg:grid-cols-[220px_minmax(0,1fr)] ${mobileView === "grid" ? "hidden lg:grid" : "grid"}`}>
|
||||
<SettingsDesktopNav
|
||||
sections={SECTIONS}
|
||||
activeSection={activeSection}
|
||||
onSectionChange={(id) => handleSectionChange(id)}
|
||||
buildHref={(id) => `/settings/${id}`}
|
||||
navLabel="Account settings"
|
||||
/>
|
||||
|
||||
<main className="min-w-0">
|
||||
|
|
@ -279,7 +292,6 @@ export default function SettingsPage({ loaderData, actionData }: Route.Component
|
|||
discordPingEnabled={user.discordPingEnabled}
|
||||
hasDiscordLinked={linkedAccounts.some((a) => a.providerId === "discord")}
|
||||
draftEmailNotificationsEnabled={user.draftEmailNotificationsEnabled}
|
||||
onNavigateToAccount={() => handleSectionChange("account")}
|
||||
/>
|
||||
)}
|
||||
{activeSection === "api" && <ApiSection />}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { sendDiscordWebhook, sendStandingsUpdateNotification, sendDraftOrderNotification, sendPickAnnouncementNotification } from "../discord";
|
||||
import { sendDiscordWebhook, sendStandingsUpdateNotification, sendDraftOrderNotification, sendPickAnnouncementNotification, sendQualifyingPointsUpdateNotification } from "../discord";
|
||||
|
||||
const WEBHOOK_URL = "https://discord.com/api/webhooks/123/abc";
|
||||
|
||||
|
|
@ -186,6 +186,60 @@ describe("sendStandingsUpdateNotification", () => {
|
|||
expect(desc).toContain("• **Sporting (christhrowsrocks)** def. Bodø/Glimt (apatel)");
|
||||
});
|
||||
|
||||
it("names a non-eliminated loser for context without @-pinging them", async () => {
|
||||
// Argentina beats England in the World Cup semifinal. Argentina scored, so it's
|
||||
// pinged; England advances to the 3rd-place playoff (not eliminated, no points
|
||||
// change), so its manager is shown by plain username but NOT @-pinged.
|
||||
await sendStandingsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Diablo League 2026",
|
||||
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 160, rank: 7 }],
|
||||
previousStandings: new Map([["a", 130]]),
|
||||
scoredMatches: [
|
||||
{
|
||||
winnerName: "Argentina",
|
||||
loserName: "England",
|
||||
winnerUsername: "philosohraptors",
|
||||
winnerDiscordUserId: "111",
|
||||
loserUsername: "elementsoul",
|
||||
// no loserDiscordUserId — still alive, no ping
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const payload = getPayload();
|
||||
const desc = payload.embeds[0].description as string;
|
||||
// Winner scored → rendered as an @-mention; loser is named by plain username.
|
||||
expect(desc).toContain("• **Argentina (<@111>)** def. England (elementsoul)");
|
||||
// England's manager is named but not mentioned/pinged.
|
||||
expect(desc).not.toContain("England (<@");
|
||||
expect(payload.content ?? "").toContain("<@111>");
|
||||
expect(payload.content ?? "").not.toContain("elementsoul");
|
||||
});
|
||||
|
||||
it("shows winner's manager for context when the match fires due to an owned loser", async () => {
|
||||
// Brazil beats Japan (R32, non-scoring). Japan's manager is the reason for the
|
||||
// notification; Brazil's manager is shown for context even though they didn't score.
|
||||
// scoring-calculator.ts controls whether to include the match; discord.ts just renders.
|
||||
await sendStandingsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Rumble League 2026",
|
||||
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }],
|
||||
previousStandings: new Map([["a", 100]]),
|
||||
scoredMatches: [
|
||||
{
|
||||
winnerName: "Brazil",
|
||||
loserName: "Japan",
|
||||
winnerUsername: "aliceManager",
|
||||
loserUsername: "ikyn",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
expect(desc).toContain("• **Brazil (aliceManager)** def. Japan (ikyn)");
|
||||
});
|
||||
|
||||
it("omits scored matches where neither side has a username", async () => {
|
||||
await sendStandingsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
|
|
@ -254,6 +308,43 @@ describe("sendStandingsUpdateNotification", () => {
|
|||
expect(desc).not.toContain("Gamma");
|
||||
});
|
||||
|
||||
it("pings scorers but not rank-only shufflers", async () => {
|
||||
await sendStandingsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "My League 2025",
|
||||
standings: [
|
||||
// Alpha scored — points changed, moved up.
|
||||
{ teamId: "a", teamName: "Alpha", totalPoints: 150, rank: 1, discordUserId: "111" },
|
||||
// Beta was displaced down without scoring — rank changed only.
|
||||
{ teamId: "b", teamName: "Beta", totalPoints: 125, rank: 2, username: "beta_owner", discordUserId: "222" },
|
||||
],
|
||||
previousStandings: new Map([
|
||||
["a", 125],
|
||||
["b", 125],
|
||||
]),
|
||||
previousRanks: new Map([
|
||||
["a", 2],
|
||||
["b", 1],
|
||||
]),
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
// Beta is still displayed with its rank movement…
|
||||
expect(desc).toContain("Beta");
|
||||
expect(desc).toContain("↓1");
|
||||
// …but by name, not as an @-mention.
|
||||
expect(desc).not.toContain("<@222>");
|
||||
// Alpha scored, so it keeps its mention.
|
||||
expect(desc).toContain("<@111>");
|
||||
|
||||
const payload = getPayload();
|
||||
// Only the scorer is pinged.
|
||||
expect(payload.content).toContain("111");
|
||||
expect(payload.content).not.toContain("222");
|
||||
expect(payload.allowed_mentions.users).toContain("111");
|
||||
expect(payload.allowed_mentions.users).not.toContain("222");
|
||||
});
|
||||
|
||||
it("omits rank delta when no previousRanks provided", async () => {
|
||||
await sendStandingsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
|
|
@ -441,6 +532,70 @@ describe("sendStandingsUpdateNotification", () => {
|
|||
expect(desc).not.toContain("Alpha");
|
||||
expect(desc).not.toContain("Beta");
|
||||
});
|
||||
|
||||
it("lists eliminated teams with their managers", async () => {
|
||||
await sendStandingsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "My League 2025",
|
||||
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }],
|
||||
previousStandings: new Map([["a", 100]]),
|
||||
eliminatedTeams: [
|
||||
{ participantName: "Phoenix Suns", username: "manager1" },
|
||||
{ participantName: "Toronto Raptors" },
|
||||
],
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
expect(desc).toContain("**Eliminated**");
|
||||
expect(desc).toContain("• **Phoenix Suns (manager1)**");
|
||||
expect(desc).toContain("• **Toronto Raptors**");
|
||||
});
|
||||
|
||||
it("uses Discord mention for opted-in eliminated managers and pings them", async () => {
|
||||
await sendStandingsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "My League 2025",
|
||||
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }],
|
||||
previousStandings: new Map([["a", 100]]),
|
||||
eliminatedTeams: [
|
||||
{ participantName: "Phoenix Suns", username: "manager1", discordUserId: "555" },
|
||||
],
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
expect(desc).toContain("• **Phoenix Suns (<@555>)**");
|
||||
expect(desc).not.toContain("manager1");
|
||||
|
||||
const payload = getPayload();
|
||||
expect(payload.content).toBe("<@555>");
|
||||
expect(payload.allowed_mentions).toEqual({ parse: [], users: ["555"] });
|
||||
});
|
||||
|
||||
it("escapes Discord markdown in eliminated team names", async () => {
|
||||
await sendStandingsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "My League 2025",
|
||||
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }],
|
||||
previousStandings: new Map([["a", 100]]),
|
||||
eliminatedTeams: [{ participantName: "Team__Bold", username: "user_name" }],
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
expect(desc).toContain("Team\\_\\_Bold");
|
||||
expect(desc).toContain("user\\_name");
|
||||
});
|
||||
|
||||
it("omits the eliminated section when none provided", async () => {
|
||||
await sendStandingsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "My League 2025",
|
||||
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 150, rank: 1 }],
|
||||
previousStandings: new Map([["a", 125]]),
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
expect(desc).not.toContain("**Eliminated**");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendDraftOrderNotification", () => {
|
||||
|
|
@ -588,6 +743,416 @@ describe("sendDraftOrderNotification", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// The caller now supplies each entry's rank in the FULL season field. This helper
|
||||
// mirrors the production ranking (qualifying-points-discord.server.ts): sort by
|
||||
// qpTotal desc, competition ranking with ties sharing the lower rank, so existing
|
||||
// tests keep asserting ranks derived from qpTotal.
|
||||
function withRanks<T extends { qpTotal: number }>(entries: T[]) {
|
||||
const sorted = [...entries].toSorted((a, b) => b.qpTotal - a.qpTotal);
|
||||
const rankByTotal = new Map<number, number>();
|
||||
let prevTotal = Number.NaN;
|
||||
let prevRank = 0;
|
||||
sorted.forEach((e, i) => {
|
||||
const rank = i > 0 && Math.abs(e.qpTotal - prevTotal) < 0.001 ? prevRank : i + 1;
|
||||
rankByTotal.set(e.qpTotal, rank);
|
||||
prevTotal = e.qpTotal;
|
||||
prevRank = rank;
|
||||
});
|
||||
const countByTotal = new Map<number, number>();
|
||||
for (const e of entries) countByTotal.set(e.qpTotal, (countByTotal.get(e.qpTotal) ?? 0) + 1);
|
||||
return entries.map((e) => ({
|
||||
...e,
|
||||
globalRank: rankByTotal.get(e.qpTotal) ?? 0,
|
||||
globalRankTied: (countByTotal.get(e.qpTotal) ?? 0) > 1,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("sendQualifyingPointsUpdateNotification", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetch(204));
|
||||
});
|
||||
|
||||
const BASE_ENTRIES = withRanks([
|
||||
{ participantName: "Novak Djokovic", qpEarned: 20, qpTotal: 45, ownerUsername: "alex" },
|
||||
{ participantName: "Carlos Alcaraz", qpEarned: 14, qpTotal: 34, ownerUsername: "chris" },
|
||||
{ participantName: "Rafael Nadal", qpEarned: 0, qpTotal: 20, ownerUsername: "alex" },
|
||||
]);
|
||||
|
||||
it("sends an embed with gold color, QP title, no footer, and links the title to the standings page", async () => {
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
entries: BASE_ENTRIES,
|
||||
standingsUrl: "https://brackt.com/leagues/abc/sports-seasons/ss-1",
|
||||
});
|
||||
|
||||
const payload = getPayload();
|
||||
expect(payload.embeds[0].title).toBe("🏅 Qualifying Points Update — Slam League 2025");
|
||||
expect(payload.embeds[0].color).toBe(0x5865f2);
|
||||
expect(payload.embeds[0].url).toBe("https://brackt.com/leagues/abc/sports-seasons/ss-1");
|
||||
expect(payload.embeds[0].footer).toBeUndefined();
|
||||
});
|
||||
|
||||
it("shows sport and event header", async () => {
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
sportName: "ATP Tennis",
|
||||
eventName: "Wimbledon 2025",
|
||||
entries: BASE_ENTRIES,
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
expect(desc).toContain("**ATP Tennis — Wimbledon 2025**");
|
||||
});
|
||||
|
||||
it("shows Points Awarded section for entries with qpEarned > 0, sorted by QP desc", async () => {
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
entries: BASE_ENTRIES,
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
expect(desc).toContain("**Points Awarded**");
|
||||
expect(desc).toContain("• **Novak Djokovic (alex)** — 20 QP");
|
||||
expect(desc).toContain("• **Carlos Alcaraz (chris)** — 14 QP");
|
||||
// Djokovic (20 QP) should appear before Alcaraz (14 QP)
|
||||
expect(desc.indexOf("Novak Djokovic")).toBeLessThan(desc.indexOf("Carlos Alcaraz"));
|
||||
});
|
||||
|
||||
it("omits zero-QP drafted participants entirely from the Drafted Participants section", async () => {
|
||||
// Only participants who have actually scored (qpTotal > 0) are listed; a 0-QP drafted
|
||||
// player no longer appears anywhere in the standings section.
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
entries: [
|
||||
{ participantName: "Champ", qpEarned: 10, qpTotal: 100, globalRank: 1, globalRankTied: false, ownerUsername: "alex" },
|
||||
],
|
||||
scoreboard: [
|
||||
{ participantName: "Champ", qpEarned: 10, qpTotal: 100, globalRank: 1, globalRankTied: false, ownerUsername: "alex" },
|
||||
{ participantName: "Also Ran", qpEarned: 0, qpTotal: 5, globalRank: 9, globalRankTied: false, ownerUsername: "chris" },
|
||||
{ participantName: "Winless Wonder", qpEarned: 0, qpTotal: 0, globalRank: 0, globalRankTied: false, ownerUsername: "sam" },
|
||||
],
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
expect(desc).toContain("**Drafted Participants**");
|
||||
// Both scorers appear as ranked rows...
|
||||
expect(desc).toContain("1\\. Champ (alex) — 100 QP");
|
||||
expect(desc).toContain("9\\. Also Ran (chris) — 5 QP");
|
||||
// ...with a Points Bubble divider separating the rank-9 scorer.
|
||||
expect(desc).toContain("**═══ Points Bubble ═══**");
|
||||
// The 0-QP player is omitted entirely.
|
||||
expect(desc).not.toContain("Winless Wonder");
|
||||
// The old Non-scoring section is gone.
|
||||
expect(desc).not.toContain("Non-scoring Participants");
|
||||
});
|
||||
|
||||
it("shows the Drafted Participants section for scored participants sorted by rank", async () => {
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
entries: BASE_ENTRIES,
|
||||
scoreboard: BASE_ENTRIES,
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
expect(desc).toContain("**Drafted Participants**");
|
||||
expect(desc).toContain("1\\. Novak Djokovic (alex) — 45 QP");
|
||||
expect(desc).toContain("2\\. Carlos Alcaraz (chris) — 34 QP");
|
||||
expect(desc).toContain("3\\. Rafael Nadal (alex) — 20 QP");
|
||||
// Djokovic should rank above Alcaraz
|
||||
expect(desc.indexOf("1\\. Novak")).toBeLessThan(desc.indexOf("2\\. Carlos"));
|
||||
// Everyone is rank <= 8, so no divider is emitted.
|
||||
expect(desc).not.toContain("Points Bubble");
|
||||
});
|
||||
|
||||
it("inserts a Points Bubble divider between the rank-8 and rank-9 scorers", async () => {
|
||||
const scoreboard = [
|
||||
{ participantName: "Player Eight", qpEarned: 5, qpTotal: 12, globalRank: 8, globalRankTied: false, ownerUsername: "chris" },
|
||||
{ participantName: "Player Nine", qpEarned: 3, qpTotal: 8, globalRank: 9, globalRankTied: false, ownerUsername: "alex" },
|
||||
];
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
entries: scoreboard,
|
||||
scoreboard,
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
const eightIdx = desc.indexOf("8\\. Player Eight");
|
||||
const bubbleIdx = desc.indexOf("**═══ Points Bubble ═══**");
|
||||
const nineIdx = desc.indexOf("9\\. Player Nine");
|
||||
expect(eightIdx).toBeGreaterThan(-1);
|
||||
expect(bubbleIdx).toBeGreaterThan(-1);
|
||||
expect(nineIdx).toBeGreaterThan(-1);
|
||||
// Divider sits between the rank-8 and rank-9 rows.
|
||||
expect(eightIdx).toBeLessThan(bubbleIdx);
|
||||
expect(bubbleIdx).toBeLessThan(nineIdx);
|
||||
});
|
||||
|
||||
it("omits the Points Bubble divider when every scorer is below the cutoff", async () => {
|
||||
// globalRank is a season-wide rank but the scoreboard is scoped to one league's drafts,
|
||||
// so a league can have drafted nobody in the global top 8. The divider must not lead the
|
||||
// section with nothing above it.
|
||||
const scoreboard = [
|
||||
{ participantName: "Player Nine", qpEarned: 3, qpTotal: 8, globalRank: 9, globalRankTied: false, ownerUsername: "alex" },
|
||||
{ participantName: "Player Ten", qpEarned: 2, qpTotal: 5, globalRank: 10, globalRankTied: false, ownerUsername: "chris" },
|
||||
];
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
entries: scoreboard,
|
||||
scoreboard,
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
expect(desc).toContain("**Drafted Participants**");
|
||||
expect(desc).toContain("9\\. Player Nine (alex) — 8 QP");
|
||||
expect(desc).toContain("10\\. Player Ten (chris) — 5 QP");
|
||||
// No rank <= 8 row exists, so the divider must not appear.
|
||||
expect(desc).not.toContain("Points Bubble");
|
||||
});
|
||||
|
||||
it("uses T-prefix for tied QP totals in standings", async () => {
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
entries: withRanks([
|
||||
{ participantName: "Player A", qpEarned: 10, qpTotal: 25 },
|
||||
{ participantName: "Player B", qpEarned: 5, qpTotal: 25 },
|
||||
{ participantName: "Player C", qpEarned: 2, qpTotal: 10 },
|
||||
]),
|
||||
scoreboard: withRanks([
|
||||
{ participantName: "Player A", qpEarned: 10, qpTotal: 25 },
|
||||
{ participantName: "Player B", qpEarned: 5, qpTotal: 25 },
|
||||
{ participantName: "Player C", qpEarned: 2, qpTotal: 10 },
|
||||
]),
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
expect(desc).toContain("T1\\. Player A");
|
||||
expect(desc).toContain("T1\\. Player B");
|
||||
expect(desc).toContain("3\\. Player C");
|
||||
});
|
||||
|
||||
it("does not round fractional QP — a 1.5 QP award shows as 1.5, not 2", async () => {
|
||||
// Regression for the reported bug: a tennis Round-of-16 loser earns 1.5 QP
|
||||
// (positions 9–16 split) but Discord rounded it to 2 via Math.round.
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Rumble League 2026",
|
||||
sportName: "Tennis - Men",
|
||||
eventName: "Wimbledon",
|
||||
entries: [
|
||||
{
|
||||
participantName: "Novak Djokovic",
|
||||
qpEarned: 1.5,
|
||||
qpTotal: 1.5,
|
||||
globalRank: 9,
|
||||
globalRankTied: true,
|
||||
ownerUsername: "snarkymcgee",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
// Mirrors the web UI's formatQP: fractional QP renders with up to 2 decimals and
|
||||
// trailing zeros trimmed ("1.5"), never rounded to an integer. The Points Awarded
|
||||
// line no longer carries a "+".
|
||||
expect(desc).toContain("• **Novak Djokovic (snarkymcgee)** — 1.5 QP");
|
||||
expect(desc).not.toContain("+");
|
||||
expect(desc).not.toContain("2 QP");
|
||||
expect(desc).not.toContain("1.50");
|
||||
});
|
||||
|
||||
it("lists a rank-9 scorer below the Points Bubble but omits a 0-QP participant", async () => {
|
||||
// Rank-9 scorers now appear in the standings section (below the bubble), while a drafted
|
||||
// participant with no points is dropped entirely.
|
||||
const both = [
|
||||
{
|
||||
participantName: "Player Eight",
|
||||
qpEarned: 5,
|
||||
qpTotal: 10,
|
||||
globalRank: 8,
|
||||
globalRankTied: false,
|
||||
ownerUsername: "eighthowner",
|
||||
},
|
||||
{
|
||||
participantName: "Player Nine",
|
||||
qpEarned: 3,
|
||||
qpTotal: 8,
|
||||
globalRank: 9,
|
||||
globalRankTied: false,
|
||||
ownerUsername: "ninthowner",
|
||||
},
|
||||
{
|
||||
participantName: "Player Winless",
|
||||
qpEarned: 0,
|
||||
qpTotal: 0,
|
||||
globalRank: 10,
|
||||
globalRankTied: false,
|
||||
ownerUsername: "winlessowner",
|
||||
},
|
||||
];
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Rumble League 2026",
|
||||
sportName: "Tennis - Men",
|
||||
eventName: "Wimbledon",
|
||||
entries: both,
|
||||
scoreboard: both,
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
expect(desc).toContain("**Drafted Participants**");
|
||||
expect(desc).toContain("8\\. Player Eight (eighthowner) — 10 QP");
|
||||
// The rank-9 scorer now appears as a ranked row below the bubble.
|
||||
expect(desc).toContain("**═══ Points Bubble ═══**");
|
||||
expect(desc).toContain("9\\. Player Nine (ninthowner) — 8 QP");
|
||||
// The 0-QP player is omitted from the standings section entirely.
|
||||
expect(desc).not.toContain("Player Winless");
|
||||
// ...but both scorers still earned points, so both remain in Points Awarded.
|
||||
expect(desc).toContain("• **Player Nine (ninthowner)** — 3 QP");
|
||||
});
|
||||
|
||||
it("uses Discord mention instead of username when ownerDiscordUserId provided", async () => {
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
entries: withRanks([
|
||||
{
|
||||
participantName: "Novak Djokovic",
|
||||
qpEarned: 20,
|
||||
qpTotal: 45,
|
||||
ownerUsername: "alex",
|
||||
ownerDiscordUserId: "111222333",
|
||||
},
|
||||
]),
|
||||
scoreboard: withRanks([
|
||||
{
|
||||
participantName: "Novak Djokovic",
|
||||
qpEarned: 20,
|
||||
qpTotal: 45,
|
||||
ownerUsername: "alex",
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
// Points Awarded (a pinged section) uses the Discord mention...
|
||||
expect(desc).toContain("• **Novak Djokovic (<@111222333>)** — 20 QP");
|
||||
// ...while the (non-pinged) Drafted Participants standings section uses the plain username.
|
||||
expect(desc).toContain("1\\. Novak Djokovic (alex) — 45 QP");
|
||||
});
|
||||
|
||||
it("pings awarded owners but not non-scoring owners", async () => {
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
entries: withRanks([
|
||||
{ participantName: "Player A", qpEarned: 20, qpTotal: 20, ownerDiscordUserId: "111" },
|
||||
{ participantName: "Player B", qpEarned: 0, qpTotal: 0, ownerDiscordUserId: "222" },
|
||||
]),
|
||||
});
|
||||
|
||||
const payload = getPayload();
|
||||
expect(payload.content).toContain("<@111>");
|
||||
expect(payload.content).not.toContain("<@222>");
|
||||
expect(payload.allowed_mentions.users).toContain("111");
|
||||
expect(payload.allowed_mentions.users).not.toContain("222");
|
||||
});
|
||||
|
||||
it("does not send when entries array is empty", async () => {
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
entries: [],
|
||||
});
|
||||
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows Knocked Out section for eliminated entries, tagging the manager", async () => {
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
entries: BASE_ENTRIES,
|
||||
eliminated: [{ participantName: "Jakob Mensik", ownerUsername: "chris" }],
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
expect(desc).toContain("**Knocked Out**");
|
||||
expect(desc).toContain("• Jakob Mensik (chris)");
|
||||
});
|
||||
|
||||
it("sends with only a Knocked Out section when there are no QP entries, omitting the standings section", async () => {
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
entries: [],
|
||||
eliminated: [{ participantName: "Jakob Mensik", ownerUsername: "chris" }],
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledOnce();
|
||||
const desc = getDescription();
|
||||
expect(desc).toContain("**Knocked Out**");
|
||||
expect(desc).not.toContain("**Drafted Participants**");
|
||||
expect(desc).not.toContain("**Points Awarded**");
|
||||
});
|
||||
|
||||
it("pings opted-in owners who appear only in the Knocked Out section", async () => {
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
entries: [],
|
||||
eliminated: [{ participantName: "Jakob Mensik", ownerDiscordUserId: "777" }],
|
||||
});
|
||||
|
||||
const payload = getPayload();
|
||||
expect(payload.content).toContain("<@777>");
|
||||
expect(payload.allowed_mentions.users).toContain("777");
|
||||
});
|
||||
|
||||
it("escapes markdown in participant names and usernames", async () => {
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
entries: withRanks([
|
||||
{ participantName: "Player_One", qpEarned: 10, qpTotal: 10, ownerUsername: "user_name" },
|
||||
]),
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
expect(desc).toContain("Player\\_One");
|
||||
expect(desc).toContain("user\\_name");
|
||||
});
|
||||
|
||||
it("truncates description at 4096 characters", async () => {
|
||||
const longEntries = withRanks(
|
||||
Array.from({ length: 200 }, (_, i) => ({
|
||||
participantName: `Very Long Participant Name Number ${i}`,
|
||||
qpEarned: i % 2 === 0 ? 5 : 0,
|
||||
qpTotal: 200 - i,
|
||||
ownerUsername: `owner_with_long_username_${i}`,
|
||||
}))
|
||||
);
|
||||
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
entries: longEntries,
|
||||
scoreboard: longEntries,
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
expect(desc.length).toBeLessThanOrEqual(4096);
|
||||
expect(desc.endsWith("...")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendPickAnnouncementNotification", () => {
|
||||
const BASE = {
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
|
|
|
|||
498
app/services/__tests__/qualifying-points-discord.server.test.ts
Normal file
498
app/services/__tests__/qualifying-points-discord.server.test.ts
Normal file
|
|
@ -0,0 +1,498 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("~/database/context", () => ({
|
||||
database: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("~/services/discord", () => ({
|
||||
sendQualifyingPointsUpdateNotification: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("~/models/account", () => ({
|
||||
findDiscordIdsByUserIds: vi.fn().mockResolvedValue(new Map()),
|
||||
}));
|
||||
|
||||
vi.mock("~/models/user", () => ({
|
||||
getUserDisplayName: vi.fn((u: { username?: string }) => u.username ?? null),
|
||||
}));
|
||||
|
||||
import { notifyQualifyingPointsUpdate } from "../qualifying-points-discord.server";
|
||||
import { sendQualifyingPointsUpdateNotification } from "~/services/discord";
|
||||
import { findDiscordIdsByUserIds } from "~/models/account";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SPORTS_SEASON_ID = "ss-1";
|
||||
const SCORING_EVENT_ID = "ev-1";
|
||||
const SEASON_ID = "season-1";
|
||||
const LEAGUE_ID = "league-1";
|
||||
const PARTICIPANT_ID = "p-1";
|
||||
const OWNER_ID = "u-1";
|
||||
const WEBHOOK_URL = "https://discord.com/api/webhooks/123/abc";
|
||||
|
||||
function makeEvent(overrides = {}) {
|
||||
return {
|
||||
id: SCORING_EVENT_ID,
|
||||
name: "Roland Garros 2025",
|
||||
sportsSeason: { sport: { name: "Tennis" } },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDb(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
query: {
|
||||
scoringEvents: {
|
||||
findFirst: vi.fn().mockResolvedValue(makeEvent()),
|
||||
},
|
||||
seasonSports: {
|
||||
findMany: vi.fn().mockResolvedValue([{ seasonId: SEASON_ID }]),
|
||||
},
|
||||
eventResults: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
seasonParticipantId: PARTICIPANT_ID,
|
||||
qualifyingPointsAwarded: "10",
|
||||
scoringEventId: SCORING_EVENT_ID,
|
||||
},
|
||||
]),
|
||||
},
|
||||
seasonParticipantQualifyingTotals: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ participantId: PARTICIPANT_ID, totalQualifyingPoints: "45", sportsSeasonId: SPORTS_SEASON_ID },
|
||||
]),
|
||||
},
|
||||
seasons: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: SEASON_ID,
|
||||
year: 2025,
|
||||
league: { id: LEAGUE_ID, name: "Slam League", discordWebhookUrl: WEBHOOK_URL },
|
||||
},
|
||||
]),
|
||||
},
|
||||
draftPicks: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
participantId: PARTICIPANT_ID,
|
||||
seasonId: SEASON_ID,
|
||||
team: { id: "t-1", name: "Alpha FC", ownerId: OWNER_ID },
|
||||
},
|
||||
]),
|
||||
},
|
||||
seasonParticipants: {
|
||||
findMany: vi.fn().mockResolvedValue([{ id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID }]),
|
||||
},
|
||||
users: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ id: OWNER_ID, username: "chris", discordPingEnabled: false },
|
||||
]),
|
||||
},
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(findDiscordIdsByUserIds).mockResolvedValue(new Map());
|
||||
});
|
||||
|
||||
describe("notifyQualifyingPointsUpdate", () => {
|
||||
it("returns early when the scoring event is not found", async () => {
|
||||
const db = makeDb({
|
||||
scoringEvents: { findFirst: vi.fn().mockResolvedValue(null) },
|
||||
});
|
||||
|
||||
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
|
||||
|
||||
expect(sendQualifyingPointsUpdateNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns early when no season sports exist for the sports season", async () => {
|
||||
const db = makeDb({
|
||||
seasonSports: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
});
|
||||
|
||||
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
|
||||
|
||||
expect(sendQualifyingPointsUpdateNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns early when no qualifying points were awarded in the event", async () => {
|
||||
const db = makeDb({
|
||||
eventResults: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ seasonParticipantId: PARTICIPANT_ID, qualifyingPointsAwarded: null },
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
|
||||
|
||||
expect(sendQualifyingPointsUpdateNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips leagues without a Discord webhook URL", async () => {
|
||||
const db = makeDb({
|
||||
seasons: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ id: SEASON_ID, year: 2025, league: { name: "Slam League", discordWebhookUrl: null } },
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
|
||||
|
||||
expect(sendQualifyingPointsUpdateNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips leagues where no QP participants were drafted", async () => {
|
||||
const db = makeDb({
|
||||
draftPicks: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
});
|
||||
|
||||
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
|
||||
|
||||
expect(sendQualifyingPointsUpdateNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls sendQualifyingPointsUpdateNotification with correct participant data", async () => {
|
||||
const db = makeDb();
|
||||
|
||||
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
|
||||
|
||||
expect(sendQualifyingPointsUpdateNotification).toHaveBeenCalledOnce();
|
||||
expect(sendQualifyingPointsUpdateNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
eventName: "Roland Garros 2025",
|
||||
sportName: "Tennis",
|
||||
standingsUrl: `${process.env.APP_URL ?? "https://brackt.com"}/leagues/${LEAGUE_ID}/sports-seasons/${SPORTS_SEASON_ID}`,
|
||||
entries: [
|
||||
expect.objectContaining({
|
||||
participantName: "Carlos Alcaraz",
|
||||
qpEarned: 10,
|
||||
qpTotal: 45,
|
||||
ownerUsername: "chris",
|
||||
ownerDiscordUserId: undefined,
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("sends once per league, not once per participant", async () => {
|
||||
const db = makeDb({
|
||||
seasonSports: {
|
||||
findMany: vi.fn().mockResolvedValue([{ seasonId: SEASON_ID }, { seasonId: "season-2" }]),
|
||||
},
|
||||
seasons: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ id: SEASON_ID, year: 2025, league: { name: "League A", discordWebhookUrl: WEBHOOK_URL } },
|
||||
{ id: "season-2", year: 2025, league: { name: "League B", discordWebhookUrl: "https://discord.com/api/webhooks/456/def" } },
|
||||
]),
|
||||
},
|
||||
draftPicks: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ participantId: PARTICIPANT_ID, seasonId: SEASON_ID, team: { name: "Alpha FC", ownerId: null } },
|
||||
{ participantId: PARTICIPANT_ID, seasonId: "season-2", team: { name: "Beta FC", ownerId: null } },
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
|
||||
|
||||
expect(sendQualifyingPointsUpdateNotification).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("resolves owner Discord ID and passes it when user has discordPingEnabled", async () => {
|
||||
vi.mocked(findDiscordIdsByUserIds).mockResolvedValue(new Map([[OWNER_ID, "discord-999"]]));
|
||||
const db = makeDb({
|
||||
users: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ id: OWNER_ID, username: "chris", discordPingEnabled: true },
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
|
||||
|
||||
expect(sendQualifyingPointsUpdateNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entries: [expect.objectContaining({ ownerDiscordUserId: "discord-999" })],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("omits ownerDiscordUserId when user has discordPingEnabled false", async () => {
|
||||
const db = makeDb();
|
||||
|
||||
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
|
||||
|
||||
expect(findDiscordIdsByUserIds).toHaveBeenCalledWith([]);
|
||||
expect(sendQualifyingPointsUpdateNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entries: [expect.objectContaining({ ownerDiscordUserId: undefined })],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("participantIdFilter limits notification to only specified participants", async () => {
|
||||
const db = makeDb({
|
||||
eventResults: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ seasonParticipantId: PARTICIPANT_ID, qualifyingPointsAwarded: "10" },
|
||||
{ seasonParticipantId: "p-2", qualifyingPointsAwarded: "5" },
|
||||
]),
|
||||
},
|
||||
draftPicks: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ participantId: PARTICIPANT_ID, seasonId: SEASON_ID, team: { name: "Alpha FC", ownerId: null } },
|
||||
{ participantId: "p-2", seasonId: SEASON_ID, team: { name: "Beta FC", ownerId: null } },
|
||||
]),
|
||||
},
|
||||
seasonParticipants: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID },
|
||||
{ id: "p-2", name: "Rafael Nadal", sportsSeasonId: SPORTS_SEASON_ID },
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
await notifyQualifyingPointsUpdate(
|
||||
SPORTS_SEASON_ID,
|
||||
SCORING_EVENT_ID,
|
||||
db as never,
|
||||
new Set([PARTICIPANT_ID])
|
||||
);
|
||||
|
||||
const call = vi.mocked(sendQualifyingPointsUpdateNotification).mock.calls[0][0];
|
||||
expect(call.entries).toHaveLength(1);
|
||||
expect(call.entries[0].participantName).toBe("Carlos Alcaraz");
|
||||
});
|
||||
|
||||
it("scoreboard includes every drafted participant even when entries are filtered", async () => {
|
||||
// The scoreboard powers the Drafted Participants section and must reflect the full
|
||||
// drafted field, not just this sync's changed participants. Here Nadal (p-2) did not
|
||||
// change this sync (filtered out of entries) but is drafted, so he belongs on the
|
||||
// scoreboard with his running total and no QP earned this event.
|
||||
const db = makeDb({
|
||||
eventResults: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ seasonParticipantId: PARTICIPANT_ID, qualifyingPointsAwarded: "10" },
|
||||
{ seasonParticipantId: "p-2", qualifyingPointsAwarded: "5" },
|
||||
]),
|
||||
},
|
||||
draftPicks: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ participantId: PARTICIPANT_ID, seasonId: SEASON_ID, team: { name: "Alpha FC", ownerId: null } },
|
||||
{ participantId: "p-2", seasonId: SEASON_ID, team: { name: "Beta FC", ownerId: null } },
|
||||
]),
|
||||
},
|
||||
seasonParticipantQualifyingTotals: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ participantId: PARTICIPANT_ID, totalQualifyingPoints: "45", sportsSeasonId: SPORTS_SEASON_ID },
|
||||
{ participantId: "p-2", totalQualifyingPoints: "20", sportsSeasonId: SPORTS_SEASON_ID },
|
||||
]),
|
||||
},
|
||||
seasonParticipants: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID },
|
||||
{ id: "p-2", name: "Rafael Nadal", sportsSeasonId: SPORTS_SEASON_ID },
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
await notifyQualifyingPointsUpdate(
|
||||
SPORTS_SEASON_ID,
|
||||
SCORING_EVENT_ID,
|
||||
db as never,
|
||||
new Set([PARTICIPANT_ID])
|
||||
);
|
||||
|
||||
const call = vi.mocked(sendQualifyingPointsUpdateNotification).mock.calls[0][0];
|
||||
// entries is scoped to the changed participant…
|
||||
expect(call.entries).toHaveLength(1);
|
||||
expect(call.entries[0].participantName).toBe("Carlos Alcaraz");
|
||||
// …but the scoreboard carries the whole drafted field.
|
||||
expect(call.scoreboard).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ participantName: "Carlos Alcaraz", qpTotal: 45 }),
|
||||
expect.objectContaining({ participantName: "Rafael Nadal", qpEarned: 0, qpTotal: 20 }),
|
||||
])
|
||||
);
|
||||
expect(call.scoreboard).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("excludes participants from other sports seasons drafted in the same fantasy season", async () => {
|
||||
// Draft picks span every sport in a fantasy season, so a golf pick can share the
|
||||
// league with this tennis event. It must not leak into the tennis scoreboard.
|
||||
const db = makeDb({
|
||||
draftPicks: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ participantId: PARTICIPANT_ID, seasonId: SEASON_ID, team: { name: "Alpha FC", ownerId: null } },
|
||||
{ participantId: "p-golf", seasonId: SEASON_ID, team: { name: "Alpha FC", ownerId: null } },
|
||||
]),
|
||||
},
|
||||
seasonParticipants: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID },
|
||||
{ id: "p-golf", name: "Rory McIlroy", sportsSeasonId: "ss-golf" },
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
|
||||
|
||||
const call = vi.mocked(sendQualifyingPointsUpdateNotification).mock.calls[0][0];
|
||||
expect(call.scoreboard).toHaveLength(1);
|
||||
expect(call.scoreboard?.[0]?.participantName).toBe("Carlos Alcaraz");
|
||||
});
|
||||
|
||||
it("does not call sendQualifyingPointsUpdateNotification when all participants are filtered out", async () => {
|
||||
const db = makeDb();
|
||||
|
||||
await notifyQualifyingPointsUpdate(
|
||||
SPORTS_SEASON_ID,
|
||||
SCORING_EVENT_ID,
|
||||
db as never,
|
||||
new Set(["p-nonexistent"])
|
||||
);
|
||||
|
||||
expect(sendQualifyingPointsUpdateNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// A knocked-out drafted player (e.g. a 2nd-round loser) earns no QP and so has
|
||||
// no event_results row — surfaced only via the eliminatedParticipantIds arg.
|
||||
const MENSIK_ID = "p-2";
|
||||
function makeDbWithEliminated() {
|
||||
return makeDb({
|
||||
draftPicks: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
participantId: PARTICIPANT_ID,
|
||||
seasonId: SEASON_ID,
|
||||
team: { id: "t-1", name: "Alpha FC", ownerId: OWNER_ID },
|
||||
},
|
||||
{
|
||||
participantId: MENSIK_ID,
|
||||
seasonId: SEASON_ID,
|
||||
team: { id: "t-1", name: "Alpha FC", ownerId: OWNER_ID },
|
||||
},
|
||||
]),
|
||||
},
|
||||
seasonParticipants: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID },
|
||||
{ id: MENSIK_ID, name: "Jakob Mensik", sportsSeasonId: SPORTS_SEASON_ID },
|
||||
]),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it("announces a knocked-out drafted player who earned no QP", async () => {
|
||||
const db = makeDbWithEliminated();
|
||||
|
||||
await notifyQualifyingPointsUpdate(
|
||||
SPORTS_SEASON_ID,
|
||||
SCORING_EVENT_ID,
|
||||
db as never,
|
||||
new Set([PARTICIPANT_ID]),
|
||||
new Set([MENSIK_ID])
|
||||
);
|
||||
|
||||
expect(sendQualifyingPointsUpdateNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
eliminated: [
|
||||
expect.objectContaining({ participantName: "Jakob Mensik", ownerUsername: "chris" }),
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("does not announce a knocked-out player who is not drafted in the league", async () => {
|
||||
const db = makeDb();
|
||||
|
||||
await notifyQualifyingPointsUpdate(
|
||||
SPORTS_SEASON_ID,
|
||||
SCORING_EVENT_ID,
|
||||
db as never,
|
||||
new Set([PARTICIPANT_ID]),
|
||||
new Set(["p-undrafted"])
|
||||
);
|
||||
|
||||
const call = vi.mocked(sendQualifyingPointsUpdateNotification).mock.calls[0][0];
|
||||
expect(call.eliminated).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not double-list a QP earner that is also passed as eliminated", async () => {
|
||||
const db = makeDb();
|
||||
|
||||
// PARTICIPANT_ID earned 10 QP this sync AND is passed as eliminated
|
||||
// (e.g. a Round-of-16 loss). It should stay in entries, not the eliminated list.
|
||||
await notifyQualifyingPointsUpdate(
|
||||
SPORTS_SEASON_ID,
|
||||
SCORING_EVENT_ID,
|
||||
db as never,
|
||||
new Set([PARTICIPANT_ID]),
|
||||
new Set([PARTICIPANT_ID])
|
||||
);
|
||||
|
||||
const call = vi.mocked(sendQualifyingPointsUpdateNotification).mock.calls[0][0];
|
||||
expect(call.entries).toHaveLength(1);
|
||||
expect(call.eliminated).toEqual([]);
|
||||
});
|
||||
|
||||
it("short-circuits before the QP-total/season lookups when nothing drafted is involved", async () => {
|
||||
// A knockout occurred but the player isn't drafted in any league. The notifier
|
||||
// is invoked (the sync fires it on any elimination) but must not run the rest
|
||||
// of its query battery just to send nothing.
|
||||
const db = makeDb({
|
||||
draftPicks: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
});
|
||||
|
||||
await notifyQualifyingPointsUpdate(
|
||||
SPORTS_SEASON_ID,
|
||||
SCORING_EVENT_ID,
|
||||
db as never,
|
||||
new Set(["p-nonexistent"]),
|
||||
new Set(["p-undrafted"])
|
||||
);
|
||||
|
||||
expect(db.query.draftPicks.findMany).toHaveBeenCalledOnce();
|
||||
expect(db.query.seasonParticipantQualifyingTotals.findMany).not.toHaveBeenCalled();
|
||||
expect(db.query.seasons.findMany).not.toHaveBeenCalled();
|
||||
expect(db.query.seasonParticipants.findMany).not.toHaveBeenCalled();
|
||||
expect(sendQualifyingPointsUpdateNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fires when a drafted player is knocked out even though no QP changed", async () => {
|
||||
const db = makeDbWithEliminated();
|
||||
|
||||
// participantIdFilter matches nobody → no QP entries, but a knockout exists.
|
||||
await notifyQualifyingPointsUpdate(
|
||||
SPORTS_SEASON_ID,
|
||||
SCORING_EVENT_ID,
|
||||
db as never,
|
||||
new Set(["p-nonexistent"]),
|
||||
new Set([MENSIK_ID])
|
||||
);
|
||||
|
||||
expect(sendQualifyingPointsUpdateNotification).toHaveBeenCalledOnce();
|
||||
const call = vi.mocked(sendQualifyingPointsUpdateNotification).mock.calls[0][0];
|
||||
expect(call.entries).toEqual([]);
|
||||
expect(call.eliminated).toEqual([
|
||||
expect.objectContaining({ participantName: "Jakob Mensik" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,17 +1,58 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { Mock } from "vitest";
|
||||
import type * as DrizzleOrm from "drizzle-orm";
|
||||
import type * as ScoringCalculatorModule from "~/models/scoring-calculator";
|
||||
|
||||
vi.mock("~/database/context", () => ({
|
||||
database: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("~/models/scoring-calculator", () => ({
|
||||
processQualifyingEvent: vi.fn(),
|
||||
vi.mock("~/models/scoring-calculator", async () => {
|
||||
// Keep the real implementations of the PURE helpers so the tie-count map handed
|
||||
// to processQualifyingEvent reflects the mock canonical results AND the bracket's
|
||||
// structural tie span (deriveBracketQualifyingStates / getRoundConfig). Only the
|
||||
// DB-touching orchestrators are stubbed.
|
||||
const actual = await vi.importActual<typeof ScoringCalculatorModule>(
|
||||
"~/models/scoring-calculator"
|
||||
);
|
||||
return {
|
||||
processQualifyingEvent: vi.fn(),
|
||||
recalculateAffectedLeagues: vi.fn(),
|
||||
buildTieCountByPlacement: actual.buildTieCountByPlacement,
|
||||
deriveBracketQualifyingStates: actual.deriveBracketQualifyingStates,
|
||||
getRoundConfig: actual.getRoundConfig,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("~/models/scoring-event", () => ({
|
||||
completeScoringEvent: vi.fn(),
|
||||
getScoringEventById: vi.fn(),
|
||||
}));
|
||||
|
||||
import { syncTournamentResults } from "../sync-tournament-results";
|
||||
vi.mock("~/models/event-result", () => ({
|
||||
getEventResults: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("~/models/tournament-result", () => ({
|
||||
upsertTournamentResult: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("~/lib/logger", () => ({
|
||||
logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
import {
|
||||
syncTournamentResults,
|
||||
syncMajorFromPrimaryEvent,
|
||||
} from "../sync-tournament-results";
|
||||
import { database } from "~/database/context";
|
||||
import { processQualifyingEvent } from "~/models/scoring-calculator";
|
||||
import {
|
||||
processQualifyingEvent,
|
||||
recalculateAffectedLeagues,
|
||||
} from "~/models/scoring-calculator";
|
||||
import { completeScoringEvent, getScoringEventById } from "~/models/scoring-event";
|
||||
import { getEventResults } from "~/models/event-result";
|
||||
import { upsertTournamentResult } from "~/models/tournament-result";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock data types
|
||||
|
|
@ -28,6 +69,7 @@ interface FakeScoringEvent {
|
|||
id: string;
|
||||
sportsSeasonId: string;
|
||||
tournamentId: string | null;
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
interface FakeSeasonParticipant {
|
||||
|
|
@ -61,11 +103,21 @@ interface FakeEventResult {
|
|||
// - event_results
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FakePlayoffMatch {
|
||||
scoringEventId: string;
|
||||
round: string;
|
||||
winnerId: string | null;
|
||||
loserId: string | null;
|
||||
participant1Id: string | null;
|
||||
participant2Id: string | null;
|
||||
}
|
||||
|
||||
interface FakeDbState {
|
||||
tournamentResults: FakeTournamentResult[];
|
||||
scoringEvents: FakeScoringEvent[];
|
||||
seasonParticipants: FakeSeasonParticipant[];
|
||||
eventResults: FakeEventResult[];
|
||||
playoffMatches: FakePlayoffMatch[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -113,6 +165,8 @@ function makeFakeDb(state: FakeDbState) {
|
|||
return state.seasonParticipants;
|
||||
case "event_results":
|
||||
return state.eventResults;
|
||||
case "playoff_matches":
|
||||
return state.playoffMatches;
|
||||
default:
|
||||
throw new Error(`Unknown table in fake db: ${name}`);
|
||||
}
|
||||
|
|
@ -220,6 +274,12 @@ vi.mock("drizzle-orm", async () => {
|
|||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
and: (...preds: any[]) => (row: any) => preds.every((p) => p(row)),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
inArray: (col: any, vals: any[]) => {
|
||||
const key = colKey(col);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (row: any) => vals.includes(row[key]);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -253,6 +313,7 @@ function seedBasicState(overrides: Partial<FakeDbState> = {}): FakeDbState {
|
|||
scoringEvents: [],
|
||||
seasonParticipants: [],
|
||||
eventResults: [],
|
||||
playoffMatches: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
@ -332,7 +393,10 @@ describe("syncTournamentResults", () => {
|
|||
expect(c?.qualifyingPointsAwarded).toBe("0");
|
||||
|
||||
expect(processQualifyingEvent).toHaveBeenCalledTimes(1);
|
||||
expect(processQualifyingEvent).toHaveBeenCalledWith("ev-1", db);
|
||||
expect(processQualifyingEvent).toHaveBeenCalledWith("ev-1", db, {
|
||||
skipNotifications: false,
|
||||
canonicalTieCountByPlacement: expect.any(Map),
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
@ -384,8 +448,14 @@ describe("syncTournamentResults", () => {
|
|||
expect(bRows.find((r) => r.seasonParticipantId === "sp-BZ")?.placement).toBe(3);
|
||||
|
||||
expect(processQualifyingEvent).toHaveBeenCalledTimes(2);
|
||||
expect(processQualifyingEvent).toHaveBeenNthCalledWith(1, "ev-A", db);
|
||||
expect(processQualifyingEvent).toHaveBeenNthCalledWith(2, "ev-B", db);
|
||||
expect(processQualifyingEvent).toHaveBeenNthCalledWith(1, "ev-A", db, {
|
||||
skipNotifications: false,
|
||||
canonicalTieCountByPlacement: expect.any(Map),
|
||||
});
|
||||
expect(processQualifyingEvent).toHaveBeenNthCalledWith(2, "ev-B", db, {
|
||||
skipNotifications: false,
|
||||
canonicalTieCountByPlacement: expect.any(Map),
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
@ -574,4 +644,498 @@ describe("syncTournamentResults", () => {
|
|||
expect(c?.placement).toBeNull();
|
||||
expect(c?.qualifyingPointsAwarded).toBe("0");
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 7: Completion fan-out (the core "score once" fix)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("marks each synced window complete by default", async () => {
|
||||
const state = seedBasicState({
|
||||
tournamentResults: [
|
||||
{ tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: null },
|
||||
],
|
||||
scoringEvents: [
|
||||
{ id: "ev-A", sportsSeasonId: "ss-A", tournamentId: "t-1" },
|
||||
{ id: "ev-B", sportsSeasonId: "ss-B", tournamentId: "t-1" },
|
||||
],
|
||||
seasonParticipants: [
|
||||
{ id: "sp-AA", sportsSeasonId: "ss-A", participantId: "cp-A", name: "A" },
|
||||
{ id: "sp-BA", sportsSeasonId: "ss-B", participantId: "cp-A", name: "A" },
|
||||
],
|
||||
});
|
||||
const db = makeFakeDb(state);
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
|
||||
vi.mocked(completeScoringEvent).mockResolvedValue(undefined as never);
|
||||
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(undefined);
|
||||
|
||||
await syncTournamentResults("t-1");
|
||||
|
||||
expect(completeScoringEvent).toHaveBeenCalledTimes(2);
|
||||
expect(completeScoringEvent).toHaveBeenCalledWith("ev-A", db);
|
||||
expect(completeScoringEvent).toHaveBeenCalledWith("ev-B", db);
|
||||
});
|
||||
|
||||
it("does NOT mark windows complete when markComplete is false", async () => {
|
||||
const state = seedBasicState({
|
||||
tournamentResults: [
|
||||
{ tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: null },
|
||||
],
|
||||
scoringEvents: [{ id: "ev-A", sportsSeasonId: "ss-A", tournamentId: "t-1" }],
|
||||
seasonParticipants: [
|
||||
{ id: "sp-AA", sportsSeasonId: "ss-A", participantId: "cp-A", name: "A" },
|
||||
],
|
||||
});
|
||||
const db = makeFakeDb(state);
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
|
||||
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(undefined);
|
||||
|
||||
await syncTournamentResults("t-1", { markComplete: false });
|
||||
|
||||
expect(completeScoringEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 8: League recalculation per synced window
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("recalculates affected leagues once per cleanly-synced window", async () => {
|
||||
const state = seedBasicState({
|
||||
tournamentResults: [
|
||||
{ tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: null },
|
||||
],
|
||||
scoringEvents: [
|
||||
{ id: "ev-A", sportsSeasonId: "ss-A", tournamentId: "t-1", name: "Major A" },
|
||||
{ id: "ev-B", sportsSeasonId: "ss-B", tournamentId: "t-1", name: "Major B" },
|
||||
],
|
||||
seasonParticipants: [
|
||||
{ id: "sp-AA", sportsSeasonId: "ss-A", participantId: "cp-A", name: "A" },
|
||||
{ id: "sp-BA", sportsSeasonId: "ss-B", participantId: "cp-A", name: "A" },
|
||||
],
|
||||
});
|
||||
const db = makeFakeDb(state);
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
|
||||
vi.mocked(completeScoringEvent).mockResolvedValue(undefined as never);
|
||||
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(undefined);
|
||||
|
||||
await syncTournamentResults("t-1");
|
||||
|
||||
expect(recalculateAffectedLeagues).toHaveBeenCalledTimes(2);
|
||||
expect(recalculateAffectedLeagues).toHaveBeenCalledWith("ss-A", undefined, {
|
||||
eventId: "ev-A",
|
||||
eventName: "Major A",
|
||||
skipDiscord: false,
|
||||
});
|
||||
expect(recalculateAffectedLeagues).toHaveBeenCalledWith("ss-B", undefined, {
|
||||
eventId: "ev-B",
|
||||
eventName: "Major B",
|
||||
skipDiscord: false,
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 9: skipEventId excludes the primary window
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("skips the primary window when skipEventId is given", async () => {
|
||||
const state = seedBasicState({
|
||||
tournamentResults: [
|
||||
{ tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: null },
|
||||
],
|
||||
scoringEvents: [
|
||||
{ id: "ev-PRIMARY", sportsSeasonId: "ss-P", tournamentId: "t-1" },
|
||||
{ id: "ev-SIBLING", sportsSeasonId: "ss-S", tournamentId: "t-1" },
|
||||
],
|
||||
seasonParticipants: [
|
||||
{ id: "sp-PA", sportsSeasonId: "ss-P", participantId: "cp-A", name: "A" },
|
||||
{ id: "sp-SA", sportsSeasonId: "ss-S", participantId: "cp-A", name: "A" },
|
||||
],
|
||||
});
|
||||
const db = makeFakeDb(state);
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
|
||||
vi.mocked(completeScoringEvent).mockResolvedValue(undefined as never);
|
||||
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(undefined);
|
||||
|
||||
const report = await syncTournamentResults("t-1", {
|
||||
skipEventId: "ev-PRIMARY",
|
||||
});
|
||||
|
||||
expect(report.windowsSynced).toBe(1);
|
||||
expect(processQualifyingEvent).toHaveBeenCalledTimes(1);
|
||||
expect(processQualifyingEvent).toHaveBeenCalledWith("ev-SIBLING", db, {
|
||||
skipNotifications: false,
|
||||
canonicalTieCountByPlacement: expect.any(Map),
|
||||
});
|
||||
// No event_results written for the skipped primary window.
|
||||
expect(
|
||||
state.eventResults.some((r) => r.scoringEventId === "ev-PRIMARY")
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// syncMajorFromPrimaryEvent: bracket/CS2 majors promote primary results to
|
||||
// canonical tournament_results, then fan out to siblings (primary skipped).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("syncMajorFromPrimaryEvent", () => {
|
||||
it("promotes primary event_results to canonical and fans out to siblings", async () => {
|
||||
const state = seedBasicState({
|
||||
scoringEvents: [
|
||||
{ id: "ev-PRIMARY", sportsSeasonId: "ss-P", tournamentId: "t-1", name: "Major" },
|
||||
{ id: "ev-SIBLING", sportsSeasonId: "ss-S", tournamentId: "t-1", name: "Major" },
|
||||
],
|
||||
seasonParticipants: [
|
||||
{ id: "sp-PA", sportsSeasonId: "ss-P", participantId: "cp-A", name: "A" },
|
||||
{ id: "sp-PB", sportsSeasonId: "ss-P", participantId: "cp-B", name: "B" },
|
||||
{ id: "sp-SA", sportsSeasonId: "ss-S", participantId: "cp-A", name: "A" },
|
||||
{ id: "sp-SB", sportsSeasonId: "ss-S", participantId: "cp-B", name: "B" },
|
||||
],
|
||||
});
|
||||
const db = makeFakeDb(state);
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
|
||||
vi.mocked(completeScoringEvent).mockResolvedValue(undefined as never);
|
||||
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(undefined);
|
||||
|
||||
vi.mocked(getScoringEventById).mockResolvedValue({
|
||||
id: "ev-PRIMARY",
|
||||
sportsSeasonId: "ss-P",
|
||||
tournamentId: "t-1",
|
||||
name: "Major",
|
||||
} as never);
|
||||
|
||||
// Primary window's derived results: cp-A 1st, cp-B 2nd.
|
||||
vi.mocked(getEventResults).mockResolvedValue([
|
||||
{
|
||||
placement: 1,
|
||||
rawScore: "1",
|
||||
notParticipating: false,
|
||||
seasonParticipantId: "sp-PA",
|
||||
seasonParticipant: { participantId: "cp-A" },
|
||||
},
|
||||
{
|
||||
placement: 2,
|
||||
rawScore: "2",
|
||||
notParticipating: false,
|
||||
seasonParticipantId: "sp-PB",
|
||||
seasonParticipant: { participantId: "cp-B" },
|
||||
},
|
||||
] as never);
|
||||
|
||||
// upsertTournamentResult writes into our fake canonical table so the
|
||||
// subsequent syncTournamentResults can read them back.
|
||||
vi.mocked(upsertTournamentResult).mockImplementation(
|
||||
async (data: { tournamentId: string; participantId: string; placement?: number | null; rawScore?: string | null }) => {
|
||||
state.tournamentResults.push({
|
||||
tournamentId: data.tournamentId,
|
||||
participantId: data.participantId,
|
||||
placement: data.placement ?? null,
|
||||
rawScore: data.rawScore ?? null,
|
||||
});
|
||||
return data as never;
|
||||
}
|
||||
);
|
||||
|
||||
const report = await syncMajorFromPrimaryEvent("ev-PRIMARY");
|
||||
|
||||
// Two canonical results promoted from the primary.
|
||||
expect(upsertTournamentResult).toHaveBeenCalledTimes(2);
|
||||
expect(state.tournamentResults).toHaveLength(2);
|
||||
|
||||
// Only the sibling window is synced (primary is skipped).
|
||||
expect(report.windowsSynced).toBe(1);
|
||||
expect(
|
||||
state.eventResults.some((r) => r.scoringEventId === "ev-PRIMARY")
|
||||
).toBe(false);
|
||||
const sib = state.eventResults.filter((r) => r.scoringEventId === "ev-SIBLING");
|
||||
expect(sib.find((r) => r.seasonParticipantId === "sp-SA")?.placement).toBe(1);
|
||||
expect(sib.find((r) => r.seasonParticipantId === "sp-SB")?.placement).toBe(2);
|
||||
|
||||
// In-progress fan-out: siblings are NOT marked complete by default.
|
||||
expect(completeScoringEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("splits mirror QP by the bracket's structural tie span, not the canonical row count (R16 in progress)", async () => {
|
||||
// Tennis R16 in progress: two R16 matches are decided (losers cp-L1/cp-L2 land
|
||||
// final at 9th) and two players (cp-F1/cp-F2) won their R32 match and are still
|
||||
// "floored" at the R16 tier (also placement 9). So only FOUR players sit at
|
||||
// placement 9 right now — the canonical row-count is 4, which would wrongly
|
||||
// split 9th–16th four ways (→ 2 QP). The R16 tier structurally spans 8 slots,
|
||||
// so every window must split (2+2+2+2+1+1+1+1)/8 = 1.5. This asserts the map
|
||||
// handed to each mirror's processQualifyingEvent carries the structural span (8),
|
||||
// not the live count (4).
|
||||
const state = seedBasicState({
|
||||
scoringEvents: [
|
||||
{
|
||||
id: "ev-PRIMARY",
|
||||
sportsSeasonId: "ss-P",
|
||||
tournamentId: "t-1",
|
||||
name: "Wimbledon",
|
||||
},
|
||||
{
|
||||
id: "ev-MIRROR",
|
||||
sportsSeasonId: "ss-M",
|
||||
tournamentId: "t-1",
|
||||
name: "Wimbledon",
|
||||
},
|
||||
],
|
||||
seasonParticipants: [
|
||||
{ id: "sp-ML1", sportsSeasonId: "ss-M", participantId: "cp-L1", name: "L1" },
|
||||
{ id: "sp-MF1", sportsSeasonId: "ss-M", participantId: "cp-F1", name: "F1" },
|
||||
],
|
||||
// Primary bracket state. R32 wins establish the "floored at R16" players;
|
||||
// decided R16 matches establish the final 9th-place losers and the QF-floored
|
||||
// winners (placement 5).
|
||||
playoffMatches: [
|
||||
{
|
||||
scoringEventId: "ev-PRIMARY",
|
||||
round: "Round of 32",
|
||||
winnerId: "cp-F1",
|
||||
loserId: "cp-out1",
|
||||
participant1Id: "cp-F1",
|
||||
participant2Id: "cp-out1",
|
||||
},
|
||||
{
|
||||
scoringEventId: "ev-PRIMARY",
|
||||
round: "Round of 32",
|
||||
winnerId: "cp-F2",
|
||||
loserId: "cp-out2",
|
||||
participant1Id: "cp-F2",
|
||||
participant2Id: "cp-out2",
|
||||
},
|
||||
{
|
||||
scoringEventId: "ev-PRIMARY",
|
||||
round: "Round of 16",
|
||||
winnerId: "cp-W1",
|
||||
loserId: "cp-L1",
|
||||
participant1Id: "cp-W1",
|
||||
participant2Id: "cp-L1",
|
||||
},
|
||||
{
|
||||
scoringEventId: "ev-PRIMARY",
|
||||
round: "Round of 16",
|
||||
winnerId: "cp-W2",
|
||||
loserId: "cp-L2",
|
||||
participant1Id: "cp-W2",
|
||||
participant2Id: "cp-L2",
|
||||
},
|
||||
],
|
||||
});
|
||||
const db = makeFakeDb(state);
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
|
||||
vi.mocked(completeScoringEvent).mockResolvedValue(undefined as never);
|
||||
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(undefined);
|
||||
|
||||
vi.mocked(getScoringEventById).mockResolvedValue({
|
||||
id: "ev-PRIMARY",
|
||||
sportsSeasonId: "ss-P",
|
||||
tournamentId: "t-1",
|
||||
name: "Wimbledon",
|
||||
bracketTemplateId: "tennis_128",
|
||||
} as never);
|
||||
|
||||
// Primary window's derived results (what processQualifyingBracketEvent wrote):
|
||||
// four players at placement 9 (2 final losers + 2 floored), two at placement 5.
|
||||
vi.mocked(getEventResults).mockResolvedValue(
|
||||
[
|
||||
["sp-PL1", "cp-L1", 9],
|
||||
["sp-PL2", "cp-L2", 9],
|
||||
["sp-PF1", "cp-F1", 9],
|
||||
["sp-PF2", "cp-F2", 9],
|
||||
["sp-PW1", "cp-W1", 5],
|
||||
["sp-PW2", "cp-W2", 5],
|
||||
].map(([seasonParticipantId, participantId, placement]) => ({
|
||||
placement,
|
||||
rawScore: null,
|
||||
notParticipating: false,
|
||||
seasonParticipantId,
|
||||
seasonParticipant: { participantId },
|
||||
})) as never
|
||||
);
|
||||
|
||||
vi.mocked(upsertTournamentResult).mockImplementation(
|
||||
async (data: {
|
||||
tournamentId: string;
|
||||
participantId: string;
|
||||
placement?: number | null;
|
||||
rawScore?: string | null;
|
||||
}) => {
|
||||
state.tournamentResults.push({
|
||||
tournamentId: data.tournamentId,
|
||||
participantId: data.participantId,
|
||||
placement: data.placement ?? null,
|
||||
rawScore: data.rawScore ?? null,
|
||||
});
|
||||
return data as never;
|
||||
}
|
||||
);
|
||||
|
||||
await syncMajorFromPrimaryEvent("ev-PRIMARY", { markComplete: false });
|
||||
|
||||
// Sanity: only four canonical rows sit at placement 9 (the live count is 4).
|
||||
expect(
|
||||
state.tournamentResults.filter((r) => r.placement === 9)
|
||||
).toHaveLength(4);
|
||||
|
||||
// The mirror window was scored with the STRUCTURAL span, not the row count.
|
||||
const mirrorCall = (processQualifyingEvent as Mock).mock.calls.find(
|
||||
(c) => c[0] === "ev-MIRROR"
|
||||
);
|
||||
expect(mirrorCall).toBeDefined();
|
||||
if (!mirrorCall) return;
|
||||
const tieMap = mirrorCall[2].canonicalTieCountByPlacement as Map<number, number>;
|
||||
expect(tieMap.get(9)).toBe(8); // R16 tier spans 8, not the 4 rows currently there
|
||||
expect(tieMap.get(5)).toBe(4); // QF tier spans 4
|
||||
});
|
||||
|
||||
it("fans a non-scoring-round elimination out to each mirror window (translated to its own season_participant id)", async () => {
|
||||
// The reported bug: a player knocked out in a non-scoring round earns no QP, so
|
||||
// canonical promotion skips them (null placement) and the mirror can't detect the
|
||||
// knockout locally. The primary passes its eliminated season_participant id
|
||||
// (sp-PX) down; syncMajorFromPrimaryEvent must translate it to the canonical
|
||||
// participant (cp-X) and each mirror window must re-translate to ITS own
|
||||
// season_participant (sp-MX) before handing it to processQualifyingEvent.
|
||||
const state = seedBasicState({
|
||||
scoringEvents: [
|
||||
{ id: "ev-PRIMARY", sportsSeasonId: "ss-P", tournamentId: "t-1", name: "Wimbledon" },
|
||||
{ id: "ev-MIRROR", sportsSeasonId: "ss-M", tournamentId: "t-1", name: "Wimbledon" },
|
||||
],
|
||||
seasonParticipants: [
|
||||
// Placed finalist, present on both windows.
|
||||
{ id: "sp-PA", sportsSeasonId: "ss-P", participantId: "cp-A", name: "A" },
|
||||
{ id: "sp-MA", sportsSeasonId: "ss-M", participantId: "cp-A", name: "A" },
|
||||
// Knocked-out player: different season_participant row per window, same
|
||||
// canonical participant cp-X.
|
||||
{ id: "sp-PX", sportsSeasonId: "ss-P", participantId: "cp-X", name: "X" },
|
||||
{ id: "sp-MX", sportsSeasonId: "ss-M", participantId: "cp-X", name: "X" },
|
||||
],
|
||||
});
|
||||
const db = makeFakeDb(state);
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
|
||||
vi.mocked(completeScoringEvent).mockResolvedValue(undefined as never);
|
||||
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(undefined);
|
||||
|
||||
vi.mocked(getScoringEventById).mockResolvedValue({
|
||||
id: "ev-PRIMARY",
|
||||
sportsSeasonId: "ss-P",
|
||||
tournamentId: "t-1",
|
||||
name: "Wimbledon",
|
||||
} as never);
|
||||
|
||||
// Primary derived results promote only the PLACED player. cp-X (the non-scoring
|
||||
// loser) has no placement and is not promoted to canonical — exactly why the
|
||||
// mirror needs the elimination threaded separately.
|
||||
vi.mocked(getEventResults).mockResolvedValue([
|
||||
{
|
||||
placement: 1,
|
||||
rawScore: "1",
|
||||
notParticipating: false,
|
||||
seasonParticipantId: "sp-PA",
|
||||
seasonParticipant: { participantId: "cp-A" },
|
||||
},
|
||||
] as never);
|
||||
|
||||
vi.mocked(upsertTournamentResult).mockImplementation(
|
||||
async (data: { tournamentId: string; participantId: string; placement?: number | null; rawScore?: string | null }) => {
|
||||
state.tournamentResults.push({
|
||||
tournamentId: data.tournamentId,
|
||||
participantId: data.participantId,
|
||||
placement: data.placement ?? null,
|
||||
rawScore: data.rawScore ?? null,
|
||||
});
|
||||
return data as never;
|
||||
}
|
||||
);
|
||||
|
||||
await syncMajorFromPrimaryEvent("ev-PRIMARY", {
|
||||
newlyEliminatedParticipantIds: new Set(["sp-PX"]),
|
||||
});
|
||||
|
||||
const mirrorCall = (processQualifyingEvent as Mock).mock.calls.find(
|
||||
(c) => c[0] === "ev-MIRROR"
|
||||
);
|
||||
expect(mirrorCall).toBeDefined();
|
||||
if (!mirrorCall) return;
|
||||
const eliminated = mirrorCall[2].newlyEliminatedParticipantIds as Set<string>;
|
||||
// cp-X → the MIRROR's own season_participant id, not the primary's.
|
||||
expect([...eliminated]).toEqual(["sp-MX"]);
|
||||
});
|
||||
|
||||
it("throws when the primary event is not linked to a tournament", async () => {
|
||||
const db = makeFakeDb(seedBasicState());
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
vi.mocked(getScoringEventById).mockResolvedValue({
|
||||
id: "ev-X",
|
||||
sportsSeasonId: "ss-X",
|
||||
tournamentId: null,
|
||||
name: "Standalone",
|
||||
} as never);
|
||||
|
||||
await expect(syncMajorFromPrimaryEvent("ev-X")).rejects.toThrow(
|
||||
/not linked to a tournament/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Regression: review fixes #1 (recalc failure counts) and #2 (stale removal)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("syncTournamentResults — recalc failure & stale removal", () => {
|
||||
it("counts a league-recalc failure as a failed window (so status can't show completed)", async () => {
|
||||
const state = seedBasicState({
|
||||
tournamentResults: [
|
||||
{ tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: null },
|
||||
],
|
||||
scoringEvents: [{ id: "ev-1", sportsSeasonId: "ss-1", tournamentId: "t-1" }],
|
||||
seasonParticipants: [
|
||||
{ id: "sp-A", sportsSeasonId: "ss-1", participantId: "cp-A", name: "A" },
|
||||
],
|
||||
});
|
||||
const db = makeFakeDb(state);
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
|
||||
vi.mocked(completeScoringEvent).mockResolvedValue(undefined as never);
|
||||
vi.mocked(recalculateAffectedLeagues).mockRejectedValue(new Error("recalc boom"));
|
||||
|
||||
const report = await syncTournamentResults("t-1");
|
||||
|
||||
expect(report.windowsFailed).toBe(1);
|
||||
expect(report.failures.some((f) => f.error.includes("League recalc failed"))).toBe(true);
|
||||
});
|
||||
|
||||
it("resets a previously-placed participant to filler when dropped from canonical", async () => {
|
||||
const state = seedBasicState({
|
||||
tournamentResults: [
|
||||
{ tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: "10" },
|
||||
],
|
||||
scoringEvents: [{ id: "ev-1", sportsSeasonId: "ss-1", tournamentId: "t-1" }],
|
||||
seasonParticipants: [
|
||||
{ id: "sp-A", sportsSeasonId: "ss-1", participantId: "cp-A", name: "A" },
|
||||
],
|
||||
});
|
||||
const db = makeFakeDb(state);
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
|
||||
vi.mocked(completeScoringEvent).mockResolvedValue(undefined as never);
|
||||
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(undefined);
|
||||
|
||||
await syncTournamentResults("t-1");
|
||||
expect(state.eventResults.find((r) => r.seasonParticipantId === "sp-A")?.placement).toBe(1);
|
||||
|
||||
// Correction: cp-A is removed from the canonical results entirely.
|
||||
state.tournamentResults.length = 0;
|
||||
await syncTournamentResults("t-1");
|
||||
|
||||
const row = state.eventResults.find((r) => r.seasonParticipantId === "sp-A");
|
||||
expect(row?.placement).toBeNull();
|
||||
expect(row?.rawScore).toBeNull();
|
||||
expect(row?.qualifyingPointsAwarded).toBe("0");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -63,6 +63,17 @@ function escapeMarkdown(text: string): string {
|
|||
return text.replace(/[_*~`|\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a QP value for display. QP is genuinely fractional (e.g. a tennis R16 loser
|
||||
* earns 1.5 QP from the 9–16 split), so we must NOT round: show whole numbers plainly
|
||||
* and fractional values to at most 2 decimals with trailing zeros trimmed (1.50→1.5).
|
||||
* Mirrors the web UI's formatQP (app/components/scoring/QualifyingPointsStandings.tsx)
|
||||
* so Discord and the site agree.
|
||||
*/
|
||||
function formatQPValue(n: number): string {
|
||||
return parseFloat(n.toFixed(2)).toString();
|
||||
}
|
||||
|
||||
export interface StandingEntry {
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
|
|
@ -81,6 +92,12 @@ export interface ScoredMatch {
|
|||
loserDiscordUserId?: string;
|
||||
}
|
||||
|
||||
export interface EliminatedTeam {
|
||||
participantName: string;
|
||||
username?: string;
|
||||
discordUserId?: string;
|
||||
}
|
||||
|
||||
export async function sendStandingsUpdateNotification({
|
||||
webhookUrl,
|
||||
seasonName,
|
||||
|
|
@ -90,6 +107,7 @@ export async function sendStandingsUpdateNotification({
|
|||
sportName,
|
||||
eventName,
|
||||
scoredMatches,
|
||||
eliminatedTeams,
|
||||
}: {
|
||||
webhookUrl: string;
|
||||
seasonName: string;
|
||||
|
|
@ -99,6 +117,7 @@ export async function sendStandingsUpdateNotification({
|
|||
sportName?: string;
|
||||
eventName?: string;
|
||||
scoredMatches?: ScoredMatch[];
|
||||
eliminatedTeams?: EliminatedTeam[];
|
||||
}): Promise<void> {
|
||||
const sections: string[] = [];
|
||||
|
||||
|
|
@ -136,16 +155,40 @@ export async function sendStandingsUpdateNotification({
|
|||
}
|
||||
}
|
||||
|
||||
// Eliminated teams section — drafted teams knocked out when a bracket/knockout
|
||||
// was generated (e.g. NBA teams that missed the playoffs, FIFA group-stage
|
||||
// losers). These produce no score change, so they wouldn't appear above.
|
||||
if (eliminatedTeams && eliminatedTeams.length > 0) {
|
||||
sections.push("\n**Eliminated**");
|
||||
for (const team of eliminatedTeams) {
|
||||
const managerLabel = team.discordUserId
|
||||
? `<@${team.discordUserId}>`
|
||||
: team.username
|
||||
? escapeMarkdown(team.username)
|
||||
: undefined;
|
||||
const label = managerLabel
|
||||
? `${escapeMarkdown(team.participantName)} (${managerLabel})`
|
||||
: escapeMarkdown(team.participantName);
|
||||
sections.push(`• **${label}**`);
|
||||
}
|
||||
}
|
||||
|
||||
const isTied = buildTiedRankChecker(standings.map((s) => s.rank));
|
||||
const rankLabel = (rank: number) => (isTied(rank) ? `T${rank}` : `${rank}`);
|
||||
|
||||
// A team's points genuinely changed this event (vs. merely being displaced in
|
||||
// rank because someone else scored). Only real scorers are pinged; rank-only
|
||||
// shufflers are still displayed, but by name and without an @-mention.
|
||||
const pointsChanged = (s: StandingEntry) => {
|
||||
const prevPoints = previousStandings.get(s.teamId);
|
||||
return prevPoints !== undefined && prevPoints !== s.totalPoints;
|
||||
};
|
||||
|
||||
// Standings changes section — show teams whose points or rank changed.
|
||||
const changedTeams = standings.filter((s) => {
|
||||
const prevPoints = previousStandings.get(s.teamId);
|
||||
const pointsChanged = prevPoints !== undefined && prevPoints !== s.totalPoints;
|
||||
const prevRank = previousRanks?.get(s.teamId);
|
||||
const rankChanged = prevRank !== undefined && prevRank !== s.rank;
|
||||
return pointsChanged || rankChanged;
|
||||
return pointsChanged(s) || rankChanged;
|
||||
});
|
||||
|
||||
if (changedTeams.length > 0) {
|
||||
|
|
@ -170,7 +213,7 @@ export async function sendStandingsUpdateNotification({
|
|||
}
|
||||
|
||||
const escapedName = escapeMarkdown(s.teamName);
|
||||
const managerLabel = s.discordUserId
|
||||
const managerLabel = s.discordUserId && pointsChanged(s)
|
||||
? `<@${s.discordUserId}>`
|
||||
: s.username
|
||||
? escapeMarkdown(s.username)
|
||||
|
|
@ -189,12 +232,15 @@ export async function sendStandingsUpdateNotification({
|
|||
// Collect Discord user IDs of all opted-in managers appearing in this notification.
|
||||
const pingUserIds = new Set<string>();
|
||||
for (const s of changedTeams) {
|
||||
if (s.discordUserId) pingUserIds.add(s.discordUserId);
|
||||
if (s.discordUserId && pointsChanged(s)) pingUserIds.add(s.discordUserId);
|
||||
}
|
||||
for (const m of relevantMatches ?? []) {
|
||||
if (m.winnerDiscordUserId) pingUserIds.add(m.winnerDiscordUserId);
|
||||
if (m.loserDiscordUserId) pingUserIds.add(m.loserDiscordUserId);
|
||||
}
|
||||
for (const t of eliminatedTeams ?? []) {
|
||||
if (t.discordUserId) pingUserIds.add(t.discordUserId);
|
||||
}
|
||||
const pingIds = [...pingUserIds];
|
||||
|
||||
const payload: DiscordWebhookPayload = {
|
||||
|
|
@ -202,7 +248,7 @@ export async function sendStandingsUpdateNotification({
|
|||
{
|
||||
title: `📊 Standings Update — ${seasonName}`,
|
||||
description,
|
||||
color: 0x5865f2, // Discord blurple
|
||||
color: 0xffd700, // Gold
|
||||
footer: { text: "brackt.com" },
|
||||
},
|
||||
],
|
||||
|
|
@ -218,6 +264,167 @@ export async function sendStandingsUpdateNotification({
|
|||
await sendDiscordWebhook(webhookUrl, payload);
|
||||
}
|
||||
|
||||
export interface QPEventEntry {
|
||||
participantName: string;
|
||||
qpEarned: number;
|
||||
qpTotal: number;
|
||||
/**
|
||||
* The participant's rank in the FULL season QP standings (all participants), not
|
||||
* their position among this event's scorers. Computed by the caller so the "QP
|
||||
* Standings" block reflects the whole sport season — e.g. two R16 losers on 1.5 QP
|
||||
* show as T9 (8 players ahead) rather than T1 among just the two of them.
|
||||
*/
|
||||
globalRank: number;
|
||||
/** True when another participant in the full field shares this globalRank. */
|
||||
globalRankTied: boolean;
|
||||
ownerUsername?: string;
|
||||
ownerDiscordUserId?: string;
|
||||
}
|
||||
|
||||
/** A drafted player knocked out this sync in a non-scoring round (0 QP). */
|
||||
export interface QPEliminatedEntry {
|
||||
participantName: string;
|
||||
ownerUsername?: string;
|
||||
ownerDiscordUserId?: string;
|
||||
}
|
||||
|
||||
export async function sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl,
|
||||
seasonName,
|
||||
sportName,
|
||||
eventName,
|
||||
entries,
|
||||
eliminated = [],
|
||||
scoreboard = [],
|
||||
standingsUrl,
|
||||
}: {
|
||||
webhookUrl: string;
|
||||
seasonName: string;
|
||||
sportName?: string;
|
||||
eventName?: string;
|
||||
entries: QPEventEntry[];
|
||||
eliminated?: QPEliminatedEntry[];
|
||||
/**
|
||||
* The full current scoreboard for the league — every drafted participant, not just
|
||||
* those whose QP changed this sync. Drives the "Drafted Participants" standings section.
|
||||
* `entries`/`eliminated` remain scoped to this sync's changes and drive the
|
||||
* "Points Awarded"/"Knocked Out" sections and the ping list.
|
||||
*/
|
||||
scoreboard?: QPEventEntry[];
|
||||
standingsUrl?: string;
|
||||
}): Promise<void> {
|
||||
if (entries.length === 0 && eliminated.length === 0) return;
|
||||
|
||||
const sections: string[] = [];
|
||||
|
||||
if (sportName || eventName) {
|
||||
const parts = [sportName, eventName].filter(Boolean);
|
||||
sections.push(`**${parts.join(" — ")}**`);
|
||||
}
|
||||
|
||||
const awardedEntries = entries
|
||||
.filter((e) => e.qpEarned > 0)
|
||||
.toSorted((a, b) => b.qpEarned - a.qpEarned);
|
||||
|
||||
if (awardedEntries.length > 0) {
|
||||
sections.push("\n**Points Awarded**");
|
||||
for (const e of awardedEntries) {
|
||||
const managerLabel = e.ownerDiscordUserId
|
||||
? `<@${e.ownerDiscordUserId}>`
|
||||
: e.ownerUsername
|
||||
? escapeMarkdown(e.ownerUsername)
|
||||
: undefined;
|
||||
const label = managerLabel
|
||||
? `${escapeMarkdown(e.participantName)} (${managerLabel})`
|
||||
: escapeMarkdown(e.participantName);
|
||||
sections.push(`• **${label}** — ${formatQPValue(e.qpEarned)} QP`);
|
||||
}
|
||||
}
|
||||
|
||||
// Knocked-out section — drafted players eliminated this sync in a non-scoring
|
||||
// round. They earn no QP, so they'd otherwise never be surfaced to their manager.
|
||||
if (eliminated.length > 0) {
|
||||
sections.push("\n**Knocked Out**");
|
||||
for (const e of eliminated) {
|
||||
const managerLabel = e.ownerDiscordUserId
|
||||
? `<@${e.ownerDiscordUserId}>`
|
||||
: e.ownerUsername
|
||||
? escapeMarkdown(e.ownerUsername)
|
||||
: undefined;
|
||||
const label = managerLabel
|
||||
? `${escapeMarkdown(e.participantName)} (${managerLabel})`
|
||||
: escapeMarkdown(e.participantName);
|
||||
sections.push(`• ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Drafted Participants: every drafted participant that has actually scored (qpTotal > 0),
|
||||
// drawn from the FULL drafted field (`scoreboard`) not just this sync's movers, so it reads
|
||||
// as a live standings snapshot. Rendered as ranked lines ordered by full-season standing. A
|
||||
// "Points Bubble" divider marks the cutoff between those currently in the points (rank <= 8)
|
||||
// and those below it (rank >= 9). Participants with 0 QP are omitted entirely. Never pinged,
|
||||
// so managers are shown by plain username, never as a <@id> mention.
|
||||
const scored = [...scoreboard]
|
||||
.filter((e) => e.qpTotal > 0)
|
||||
.toSorted((a, b) => a.globalRank - b.globalRank);
|
||||
|
||||
// Skip the section entirely when no drafted participant has scored (e.g. a sync that only
|
||||
// reported knockouts) so we don't emit an empty header.
|
||||
if (scored.length > 0) {
|
||||
sections.push("\n**Drafted Participants**");
|
||||
// Insert the divider once, before the first below-the-cutoff (rank >= 9) row. `>= 9`
|
||||
// (not `> 8`) keeps a tie AT rank 8 above the bubble ("top 8 plus ties"). Only emit it
|
||||
// after at least one above-the-bubble row exists: globalRank is a season-wide rank while
|
||||
// this list is scoped to one league's drafts, so a league can have drafted nobody in the
|
||||
// global top 8 — guarding on rowsAbove avoids a leading divider with nothing above it.
|
||||
let bubbleInserted = false;
|
||||
let rowsAbove = 0;
|
||||
for (const e of scored) {
|
||||
if (!bubbleInserted && rowsAbove > 0 && e.globalRank >= 9) {
|
||||
sections.push("**═══ Points Bubble ═══**");
|
||||
bubbleInserted = true;
|
||||
}
|
||||
if (e.globalRank <= 8) rowsAbove++;
|
||||
const rankPrefix = e.globalRankTied ? `T${e.globalRank}` : `${e.globalRank}`;
|
||||
const managerLabel = e.ownerUsername ? ` (${escapeMarkdown(e.ownerUsername)})` : "";
|
||||
sections.push(`${rankPrefix}\\. ${escapeMarkdown(e.participantName)}${managerLabel} — ${formatQPValue(e.qpTotal)} QP`);
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_DESCRIPTION = 4096;
|
||||
let description = sections.join("\n");
|
||||
if (description.length > MAX_DESCRIPTION) {
|
||||
description = description.slice(0, MAX_DESCRIPTION - 3) + "...";
|
||||
}
|
||||
|
||||
// Ping only managers who earned points or lost a drafted player this sync —
|
||||
// never the non-scoring (zeroEntries) managers.
|
||||
const pingUserIds = new Set<string>();
|
||||
for (const e of [...awardedEntries, ...eliminated]) {
|
||||
if (e.ownerDiscordUserId) pingUserIds.add(e.ownerDiscordUserId);
|
||||
}
|
||||
const pingIds = [...pingUserIds];
|
||||
|
||||
const payload: DiscordWebhookPayload = {
|
||||
embeds: [
|
||||
{
|
||||
title: `🏅 Qualifying Points Update — ${seasonName}`,
|
||||
url: standingsUrl,
|
||||
description,
|
||||
color: 0x5865f2, // Discord blurple
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (pingIds.length > 0) {
|
||||
const cappedIds = pingIds.slice(0, 100);
|
||||
payload.content = cappedIds.map((id) => `<@${id}>`).join(" ");
|
||||
payload.allowed_mentions = { parse: [], users: cappedIds };
|
||||
}
|
||||
|
||||
await sendDiscordWebhook(webhookUrl, payload);
|
||||
}
|
||||
|
||||
export async function sendPickAnnouncementNotification({
|
||||
webhookUrl,
|
||||
draftUrl,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
120
app/services/match-sync/__tests__/sync-tennis-rescore.test.ts
Normal file
120
app/services/match-sync/__tests__/sync-tennis-rescore.test.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type * as QualifyingPointsModule from "~/models/qualifying-points";
|
||||
|
||||
// The helper under test re-derives QP via processQualifyingBracketEvent and
|
||||
// recalculates dropped participants — both stubbed so the test drives the
|
||||
// before/after event_results rows directly and asserts the change-detection diff.
|
||||
vi.mock("~/models/scoring-calculator", () => ({
|
||||
processMatchResult: vi.fn(),
|
||||
autoCompleteRoundIfDone: vi.fn(),
|
||||
processQualifyingBracketEvent: vi.fn().mockResolvedValue(undefined),
|
||||
recalculateAffectedLeagues: vi.fn(),
|
||||
}));
|
||||
|
||||
// Keep the real diffChangedQualifyingPoints (the change-detection primitive under
|
||||
// test here); only stub the dropped-participant total recalc.
|
||||
vi.mock("~/models/qualifying-points", async (importActual) => {
|
||||
const actual = await importActual<typeof QualifyingPointsModule>();
|
||||
return {
|
||||
...actual,
|
||||
recalculateParticipantQP: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
import { rescoreTennisBracketAndDetectChanges } from "../index";
|
||||
import { processQualifyingBracketEvent } from "~/models/scoring-calculator";
|
||||
import { recalculateParticipantQP } from "~/models/qualifying-points";
|
||||
|
||||
const EVENT_ID = "ev-1";
|
||||
const SPORTS_SEASON_ID = "ss-1";
|
||||
|
||||
type Row = { id: string; qp: string | null };
|
||||
|
||||
/**
|
||||
* Fake Drizzle db whose two `select().from().where()` calls resolve, in order, to
|
||||
* the provided before-rows then after-rows. `transaction` runs its callback with
|
||||
* the same fake, and `delete().where()` is a no-op.
|
||||
*/
|
||||
function makeDb(beforeRows: Row[], afterRows: Row[]) {
|
||||
const results = [beforeRows, afterRows];
|
||||
let selectCall = 0;
|
||||
const db = {
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => Promise.resolve(results[selectCall++] ?? [])),
|
||||
})),
|
||||
})),
|
||||
delete: vi.fn(() => ({ where: vi.fn(() => Promise.resolve(undefined)) })),
|
||||
transaction: vi.fn(async (fn: (tx: unknown) => Promise<void>) => fn(db)),
|
||||
};
|
||||
return db;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("rescoreTennisBracketAndDetectChanges", () => {
|
||||
it("flags participants whose QP changed and newly-scored participants, ignoring unchanged ones", async () => {
|
||||
const db = makeDb(
|
||||
[
|
||||
{ id: "A", qp: "10" },
|
||||
{ id: "B", qp: "5" },
|
||||
],
|
||||
[
|
||||
{ id: "A", qp: "10" }, // unchanged
|
||||
{ id: "B", qp: "8" }, // changed 5 -> 8
|
||||
{ id: "C", qp: "3" }, // newly scored
|
||||
],
|
||||
);
|
||||
|
||||
const changed = await rescoreTennisBracketAndDetectChanges(EVENT_ID, SPORTS_SEASON_ID, db as never);
|
||||
|
||||
expect([...changed].toSorted()).toEqual(["B", "C"]);
|
||||
expect(processQualifyingBracketEvent).toHaveBeenCalledWith(EVENT_ID, db);
|
||||
// No participant dropped out, so no manual total recalc.
|
||||
expect(recalculateParticipantQP).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats a null-before → value-after transition as a change", async () => {
|
||||
const db = makeDb([{ id: "A", qp: null }], [{ id: "A", qp: "10" }]);
|
||||
|
||||
const changed = await rescoreTennisBracketAndDetectChanges(EVENT_ID, SPORTS_SEASON_ID, db as never);
|
||||
|
||||
expect([...changed]).toEqual(["A"]);
|
||||
});
|
||||
|
||||
it("announces nothing when a re-sync produces identical QP", async () => {
|
||||
const db = makeDb(
|
||||
[
|
||||
{ id: "A", qp: "10" },
|
||||
{ id: "B", qp: "5" },
|
||||
],
|
||||
[
|
||||
{ id: "A", qp: "10" },
|
||||
{ id: "B", qp: "5" },
|
||||
],
|
||||
);
|
||||
|
||||
const changed = await rescoreTennisBracketAndDetectChanges(EVENT_ID, SPORTS_SEASON_ID, db as never);
|
||||
|
||||
expect(changed.size).toBe(0);
|
||||
expect(recalculateParticipantQP).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("recalculates totals for participants dropped from the rewritten results", async () => {
|
||||
const db = makeDb(
|
||||
[
|
||||
{ id: "A", qp: "10" },
|
||||
{ id: "D", qp: "2" }, // present before, gone after (early-round loser)
|
||||
],
|
||||
[{ id: "A", qp: "10" }],
|
||||
);
|
||||
|
||||
const changed = await rescoreTennisBracketAndDetectChanges(EVENT_ID, SPORTS_SEASON_ID, db as never);
|
||||
|
||||
expect(changed.size).toBe(0); // A unchanged; D no longer in results
|
||||
expect(recalculateParticipantQP).toHaveBeenCalledTimes(1);
|
||||
expect(recalculateParticipantQP).toHaveBeenCalledWith("D", SPORTS_SEASON_ID, db);
|
||||
});
|
||||
});
|
||||
147
app/services/match-sync/__tests__/tennis-draw-mapping.test.ts
Normal file
147
app/services/match-sync/__tests__/tennis-draw-mapping.test.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
playerKey,
|
||||
canonicalPlayerName,
|
||||
buildResolvedMatches,
|
||||
} from "../tennis-draw-mapping";
|
||||
import type { FetchedDraw, FetchedPlayer } from "../types";
|
||||
|
||||
const sinner: FetchedPlayer = { name: "J Sinner", externalId: "Jannik Sinner", seed: 1 };
|
||||
const djokovic: FetchedPlayer = { name: "N Djokovic", externalId: "Novak Djokovic", seed: 6 };
|
||||
const noLink: FetchedPlayer = { name: "Some Qualifier", externalId: null, seed: null };
|
||||
|
||||
describe("playerKey", () => {
|
||||
it("prefers the external id", () => {
|
||||
expect(playerKey(sinner)).toBe("id:Jannik Sinner");
|
||||
});
|
||||
it("falls back to a normalized name when there is no external id", () => {
|
||||
expect(playerKey(noLink)).toBe("name:some qualifier");
|
||||
});
|
||||
});
|
||||
|
||||
describe("canonicalPlayerName", () => {
|
||||
it("uses the full name from the external id, not the abbreviated display", () => {
|
||||
expect(canonicalPlayerName(sinner)).toBe("Jannik Sinner");
|
||||
});
|
||||
it("uses the display name when there is no external id", () => {
|
||||
expect(canonicalPlayerName(noLink)).toBe("Some Qualifier");
|
||||
});
|
||||
|
||||
it("strips the trailing Wikipedia disambiguator", () => {
|
||||
expect(
|
||||
canonicalPlayerName({ name: "Arthur Fils", externalId: "Arthur Fils (tennis)", seed: null }),
|
||||
).toBe("Arthur Fils");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildResolvedMatches", () => {
|
||||
const draw: FetchedDraw = {
|
||||
templateId: "tennis_128",
|
||||
rounds: [
|
||||
{
|
||||
roundName: "Round of 128",
|
||||
matches: [
|
||||
{
|
||||
externalMatchId: "m-r128-1",
|
||||
round: "Round of 128",
|
||||
matchNumber: 1,
|
||||
player1: sinner,
|
||||
player2: noLink,
|
||||
winner: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
roundName: "Final",
|
||||
matches: [
|
||||
{
|
||||
externalMatchId: "m-final",
|
||||
round: "Final",
|
||||
matchNumber: 1,
|
||||
player1: djokovic,
|
||||
player2: sinner,
|
||||
winner: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const idByKey = new Map<string, string>([
|
||||
[playerKey(sinner), "sp-sinner"],
|
||||
[playerKey(djokovic), "sp-djokovic"],
|
||||
[playerKey(noLink), "sp-qual"],
|
||||
]);
|
||||
const roundIsScoring = new Map<string, boolean>([
|
||||
["Round of 128", false],
|
||||
["Final", true],
|
||||
]);
|
||||
|
||||
const resolved = buildResolvedMatches(draw, idByKey, roundIsScoring);
|
||||
|
||||
it("maps winner slot 1 to winnerId/loserId", () => {
|
||||
const r128 = resolved.find((m) => m.externalMatchId === "m-r128-1");
|
||||
expect(r128?.winnerId).toBe("sp-sinner");
|
||||
expect(r128?.loserId).toBe("sp-qual");
|
||||
expect(r128?.isScoring).toBe(false);
|
||||
});
|
||||
|
||||
it("maps winner slot 2 to the second player", () => {
|
||||
const final = resolved.find((m) => m.externalMatchId === "m-final");
|
||||
expect(final?.participant1Id).toBe("sp-djokovic");
|
||||
expect(final?.participant2Id).toBe("sp-sinner");
|
||||
expect(final?.winnerId).toBe("sp-sinner");
|
||||
expect(final?.loserId).toBe("sp-djokovic");
|
||||
expect(final?.isScoring).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves winner/loser null when the source reported no winner", () => {
|
||||
const noWinnerDraw: FetchedDraw = {
|
||||
templateId: "tennis_128",
|
||||
rounds: [
|
||||
{
|
||||
roundName: "Round of 128",
|
||||
matches: [
|
||||
{
|
||||
externalMatchId: "m-x",
|
||||
round: "Round of 128",
|
||||
matchNumber: 1,
|
||||
player1: sinner,
|
||||
player2: djokovic,
|
||||
winner: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const [m] = buildResolvedMatches(noWinnerDraw, idByKey, roundIsScoring);
|
||||
expect(m.winnerId).toBeNull();
|
||||
expect(m.loserId).toBeNull();
|
||||
});
|
||||
|
||||
it("defaults isScoring to false for an unknown round", () => {
|
||||
const [m] = buildResolvedMatches(
|
||||
{
|
||||
templateId: "tennis_128",
|
||||
rounds: [
|
||||
{
|
||||
roundName: "Mystery",
|
||||
matches: [
|
||||
{
|
||||
externalMatchId: "m-y",
|
||||
round: "Mystery",
|
||||
matchNumber: 1,
|
||||
player1: sinner,
|
||||
player2: djokovic,
|
||||
winner: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
idByKey,
|
||||
new Map(),
|
||||
);
|
||||
expect(m.isScoring).toBe(false);
|
||||
});
|
||||
});
|
||||
176
app/services/match-sync/__tests__/wikipedia-tennis.test.ts
Normal file
176
app/services/match-sync/__tests__/wikipedia-tennis.test.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
parseTennisDrawWikitext,
|
||||
parsePlayerCell,
|
||||
parseTemplateParams,
|
||||
extractTemplates,
|
||||
slugifyArticle,
|
||||
articleTitleFromInput,
|
||||
} from "../wikipedia-tennis";
|
||||
import type { FetchedRound } from "../types";
|
||||
|
||||
const FIXTURE = readFileSync(
|
||||
join(__dirname, "fixtures", "wimbledon-2025-mens-singles.wikitext"),
|
||||
"utf-8",
|
||||
);
|
||||
const ARTICLE = "2025 Wimbledon Championships – Men's singles";
|
||||
|
||||
function roundOrThrow(rounds: FetchedRound[], name: string): FetchedRound {
|
||||
const round = rounds.find((r) => r.roundName === name);
|
||||
if (!round) throw new Error(`round ${name} missing`);
|
||||
return round;
|
||||
}
|
||||
|
||||
describe("parsePlayerCell", () => {
|
||||
it("extracts name + wikilink target and detects the bolded winner", () => {
|
||||
const cell = parsePlayerCell("'''{{flagicon|ITA}} [[Jannik Sinner]]'''");
|
||||
expect(cell).toEqual({
|
||||
player: { name: "Jannik Sinner", externalId: "Jannik Sinner", seed: null },
|
||||
isWinner: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a non-bolded slot as a loser", () => {
|
||||
const cell = parsePlayerCell("{{flagicon|USA}} [[Ben Shelton]]");
|
||||
expect(cell?.isWinner).toBe(false);
|
||||
expect(cell?.player.name).toBe("Ben Shelton");
|
||||
});
|
||||
|
||||
it("handles piped wikilinks (qualifier disambiguation)", () => {
|
||||
const cell = parsePlayerCell("{{flagicon|FRA}} [[Arthur Fils (tennis)|Arthur Fils]]");
|
||||
expect(cell?.player.name).toBe("Arthur Fils");
|
||||
expect(cell?.player.externalId).toBe("Arthur Fils (tennis)");
|
||||
});
|
||||
|
||||
it("parses a player whose cell is wrapped in {{nowrap}} (long names)", () => {
|
||||
const loser = parsePlayerCell(
|
||||
"{{nowrap|{{flagicon|ESP}} [[Alejandro Davidovich Fokina|A Davidovich Fokina]]}}",
|
||||
);
|
||||
expect(loser?.player.externalId).toBe("Alejandro Davidovich Fokina");
|
||||
expect(loser?.player.name).toBe("A Davidovich Fokina");
|
||||
expect(loser?.isWinner).toBe(false);
|
||||
|
||||
const winner = parsePlayerCell(
|
||||
"'''{{nowrap|{{flagicon|ARG}} [[Juan Manuel Cerúndolo|JM Cerúndolo]]}}'''",
|
||||
);
|
||||
expect(winner?.player.externalId).toBe("Juan Manuel Cerúndolo");
|
||||
expect(winner?.isWinner).toBe(true);
|
||||
});
|
||||
|
||||
it("returns null for empty, Bye, and not-yet-determined placeholder slots", () => {
|
||||
expect(parsePlayerCell("")).toBeNull();
|
||||
expect(parsePlayerCell("Bye")).toBeNull();
|
||||
expect(parsePlayerCell("Qualifier")).toBeNull();
|
||||
expect(parsePlayerCell("{{flagicon|GBR}} Qualifier")).toBeNull();
|
||||
expect(parsePlayerCell("TBD")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTemplateParams", () => {
|
||||
it("splits on top-level pipes only, preserving piped links and templates", () => {
|
||||
const params = parseTemplateParams(
|
||||
"RD1-team1={{flagicon|ITA}} [[A B|A]]|RD1-seed1=1|RD1-score1-1=6",
|
||||
);
|
||||
expect(params.get("RD1-team1")).toBe("{{flagicon|ITA}} [[A B|A]]");
|
||||
expect(params.get("RD1-seed1")).toBe("1");
|
||||
expect(params.get("RD1-score1-1")).toBe("6");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractTemplates", () => {
|
||||
it("finds the 8 section brackets and 1 finals bracket", () => {
|
||||
expect(extractTemplates(FIXTURE, ["16TeamBracket"]).length).toBe(8);
|
||||
expect(extractTemplates(FIXTURE, ["8TeamBracket"]).length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTennisDrawWikitext (2025 Wimbledon men's singles)", () => {
|
||||
const draw = parseTennisDrawWikitext(FIXTURE, ARTICLE);
|
||||
|
||||
it("maps to the tennis_128 template", () => {
|
||||
expect(draw.templateId).toBe("tennis_128");
|
||||
});
|
||||
|
||||
it("produces the full 127-match draw with correct per-round counts", () => {
|
||||
const byRound = Object.fromEntries(draw.rounds.map((r) => [r.roundName, r.matches.length]));
|
||||
expect(byRound).toEqual({
|
||||
"Round of 128": 64,
|
||||
"Round of 64": 32,
|
||||
"Round of 32": 16,
|
||||
"Round of 16": 8,
|
||||
Quarterfinals: 4,
|
||||
Semifinals: 2,
|
||||
Final: 1,
|
||||
});
|
||||
const total = draw.rounds.reduce((sum, r) => sum + r.matches.length, 0);
|
||||
expect(total).toBe(127);
|
||||
});
|
||||
|
||||
it("populates both players in section rounds (not just the finals bracket)", () => {
|
||||
const r128 = roundOrThrow(draw.rounds, "Round of 128");
|
||||
// A full 128 draw has no byes — every R128 slot is filled.
|
||||
const populated = r128.matches.filter((m) => m.player1 && m.player2);
|
||||
expect(populated.length).toBe(64);
|
||||
// Section brackets abbreviate display names ("J Sinner") but the wikilink
|
||||
// target stays the full name — so externalId is the reliable key.
|
||||
const sinnerMatch = r128.matches.find(
|
||||
(m) => m.player1?.externalId === "Jannik Sinner" || m.player2?.externalId === "Jannik Sinner",
|
||||
);
|
||||
expect(sinnerMatch).toBeDefined();
|
||||
const sinner =
|
||||
sinnerMatch?.player1?.externalId === "Jannik Sinner"
|
||||
? sinnerMatch?.player1
|
||||
: sinnerMatch?.player2;
|
||||
expect(sinner?.name).toBe("J Sinner");
|
||||
expect(sinner?.seed).toBe(1);
|
||||
});
|
||||
|
||||
it("identifies Sinner as the champion (won the Final)", () => {
|
||||
const final = roundOrThrow(draw.rounds, "Final").matches[0];
|
||||
const winner = final.winner === 1 ? final.player1 : final.player2;
|
||||
expect(winner?.name).toBe("Jannik Sinner");
|
||||
});
|
||||
|
||||
it("gives every match a unique externalMatchId", () => {
|
||||
const ids = draw.rounds.flatMap((r) => r.matches.map((m) => m.externalMatchId));
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it("records seeds for the 32 seeded players in round 1", () => {
|
||||
const r128 = roundOrThrow(draw.rounds, "Round of 128");
|
||||
const seeded = r128.matches
|
||||
.flatMap((m) => [m.player1, m.player2])
|
||||
.filter((p) => typeof p?.seed === "number");
|
||||
expect(seeded.length).toBe(32);
|
||||
});
|
||||
});
|
||||
|
||||
describe("articleTitleFromInput", () => {
|
||||
it("extracts the title from a pasted Wikipedia URL", () => {
|
||||
expect(
|
||||
articleTitleFromInput(
|
||||
"https://en.wikipedia.org/wiki/2025_Wimbledon_Championships_%E2%80%93_Men%27s_singles",
|
||||
),
|
||||
).toBe("2025 Wimbledon Championships – Men's singles");
|
||||
});
|
||||
|
||||
it("strips query strings and fragments", () => {
|
||||
expect(articleTitleFromInput("https://en.wikipedia.org/wiki/French_Open?action=raw#Draw")).toBe(
|
||||
"French Open",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes a plain title through unchanged", () => {
|
||||
expect(articleTitleFromInput(" 2025 US Open – Men's singles ")).toBe(
|
||||
"2025 US Open – Men's singles",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("slugifyArticle", () => {
|
||||
it("produces an id-safe slug and strips diacritics", () => {
|
||||
expect(slugifyArticle(ARTICLE)).toBe("2025-wimbledon-championships-men-s-singles");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,15 +1,50 @@
|
|||
import { database } from "~/database/context";
|
||||
import { eq } from "drizzle-orm";
|
||||
import * as schema from "~/database/schema";
|
||||
import { findParticipantsBySportsSeasonId, updateParticipant } from "~/models/season-participant";
|
||||
import {
|
||||
findParticipantsBySportsSeasonId,
|
||||
updateParticipant,
|
||||
createParticipant,
|
||||
createManyParticipants,
|
||||
} from "~/models/season-participant";
|
||||
import { upsertSeasonMatchBulk, upsertMatchSubGame } from "~/models/season-match";
|
||||
import { setMatchWinner, updatePlayoffMatch, advanceWinnerTemplate, findPlayoffMatchesByEventId, doesLoserAdvance } from "~/models/playoff-match";
|
||||
import { processMatchResult, autoCompleteRoundIfDone } from "~/models/scoring-calculator";
|
||||
import { findMatchingTeamName } from "~/lib/normalize-team-name";
|
||||
import {
|
||||
setMatchWinner,
|
||||
updatePlayoffMatch,
|
||||
advanceWinnerTemplate,
|
||||
findPlayoffMatchesByEventId,
|
||||
doesLoserAdvance,
|
||||
populateBracketFromDraw,
|
||||
} from "~/models/playoff-match";
|
||||
import {
|
||||
processMatchResult,
|
||||
autoCompleteRoundIfDone,
|
||||
processQualifyingBracketEvent,
|
||||
recalculateAffectedLeagues,
|
||||
} from "~/models/scoring-calculator";
|
||||
import { fanOutMajorIfPrimary } from "~/services/sync-tournament-results";
|
||||
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
|
||||
import {
|
||||
recalculateParticipantQP,
|
||||
diffChangedQualifyingPoints,
|
||||
} from "~/models/qualifying-points";
|
||||
import { getScoringEventById, updateScoringEvent, isReadOnlySibling } from "~/models/scoring-event";
|
||||
import { findMatchingTeamName, normalizeTeamName } from "~/lib/normalize-team-name";
|
||||
import { buildUnmatchedTeamResolutionView } from "~/lib/unmatched-team-reconciliation";
|
||||
import { getBracketTemplate } from "~/lib/bracket-templates";
|
||||
import { PandaScoreMatchSyncAdapter } from "./pandascore";
|
||||
import { EspnScheduleAdapter } from "./espn-schedule";
|
||||
import type { MatchSyncAdapter, MatchSyncResult } from "./types";
|
||||
import { WikipediaTennisAdapter } from "./wikipedia-tennis";
|
||||
import { playerKey, canonicalPlayerName, buildResolvedMatches } from "./tennis-draw-mapping";
|
||||
import type {
|
||||
MatchSyncAdapter,
|
||||
MatchSyncResult,
|
||||
DrawSyncAdapter,
|
||||
DrawSyncResult,
|
||||
DrawSyncPreview,
|
||||
FetchedDraw,
|
||||
FetchedPlayer,
|
||||
} from "./types";
|
||||
import { logger } from "~/lib/logger";
|
||||
|
||||
function getMatchSyncAdapter(simulatorType: string): MatchSyncAdapter {
|
||||
|
|
@ -269,3 +304,403 @@ export async function syncMatches(sportsSeasonId: string): Promise<MatchSyncResu
|
|||
|
||||
return { swissCreated, swissUpdated, playoffUpdated, unmatchedTeams, errors };
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Tennis Grand Slam draw sync
|
||||
// ===========================================================================
|
||||
|
||||
function getDrawAdapter(simulatorType: string): DrawSyncAdapter {
|
||||
switch (simulatorType) {
|
||||
case "tennis_qualifying_points":
|
||||
return new WikipediaTennisAdapter();
|
||||
default:
|
||||
throw new Error(
|
||||
`No draw sync adapter available for simulator type "${simulatorType}". ` +
|
||||
"Tennis is currently supported.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate the event, fetch + parse its draw. Read-only — used by sync & preview. */
|
||||
async function loadTennisDraw(eventId: string) {
|
||||
const db = database();
|
||||
const event = await getScoringEventById(eventId);
|
||||
if (!event) throw new Error(`Scoring event ${eventId} not found`);
|
||||
if (isReadOnlySibling(event)) {
|
||||
throw new Error(
|
||||
"This event is a read-only window of a shared major. Sync the draw on the primary window; " +
|
||||
"results fan out here automatically.",
|
||||
);
|
||||
}
|
||||
if (!event.isQualifyingEvent) {
|
||||
throw new Error(`Event "${event.name}" is not a qualifying event; cannot sync a tennis draw.`);
|
||||
}
|
||||
const sourceKey = event.externalSourceKey;
|
||||
if (!sourceKey) {
|
||||
throw new Error(
|
||||
`Event "${event.name}" has no externalSourceKey (Wikipedia article title) configured.`,
|
||||
);
|
||||
}
|
||||
const sportsSeason = await db.query.sportsSeasons.findFirst({
|
||||
where: eq(schema.sportsSeasons.id, event.sportsSeasonId),
|
||||
with: { sport: true },
|
||||
});
|
||||
const simulatorType = sportsSeason?.sport?.simulatorType;
|
||||
if (!simulatorType) {
|
||||
throw new Error(`Sport for season ${event.sportsSeasonId} has no simulator type configured`);
|
||||
}
|
||||
const draw = await getDrawAdapter(simulatorType).fetchDraw(sourceKey);
|
||||
const template = getBracketTemplate(draw.templateId);
|
||||
if (!template) throw new Error(`Bracket template "${draw.templateId}" not found`);
|
||||
return { event, sportsSeasonId: event.sportsSeasonId, draw, template };
|
||||
}
|
||||
|
||||
/** Distinct players across the whole draw, keyed by stable identity. */
|
||||
function collectUniquePlayers(draw: FetchedDraw): Map<string, FetchedPlayer> {
|
||||
const players = new Map<string, FetchedPlayer>();
|
||||
for (const round of draw.rounds) {
|
||||
for (const match of round.matches) {
|
||||
for (const player of [match.player1, match.player2]) {
|
||||
if (player) players.set(playerKey(player), player);
|
||||
}
|
||||
}
|
||||
}
|
||||
return players;
|
||||
}
|
||||
|
||||
type SeasonParticipant = Awaited<ReturnType<typeof findParticipantsBySportsSeasonId>>[number];
|
||||
|
||||
type PlayerLookups = {
|
||||
byExternalId: Map<string, SeasonParticipant>;
|
||||
byName: Map<string, SeasonParticipant>;
|
||||
names: string[];
|
||||
recon: { id: string; name: string; externalId: string | null }[];
|
||||
};
|
||||
|
||||
function buildLookups(participants: SeasonParticipant[]): PlayerLookups {
|
||||
return {
|
||||
byExternalId: new Map(
|
||||
participants.filter((p) => p.externalId).map((p) => [p.externalId as string, p]),
|
||||
),
|
||||
byName: new Map(participants.map((p) => [p.name, p])),
|
||||
names: participants.map((p) => p.name),
|
||||
recon: participants.map((p) => ({ id: p.id, name: p.name, externalId: p.externalId })),
|
||||
};
|
||||
}
|
||||
|
||||
type Classification =
|
||||
| { kind: "matched"; participant: SeasonParticipant; backfill: boolean }
|
||||
| {
|
||||
kind: "create";
|
||||
name: string;
|
||||
suggestion: string | null;
|
||||
suggestionId: string | null;
|
||||
};
|
||||
|
||||
/** Read-only: decide how a drawn player resolves against existing participants. */
|
||||
function classifyPlayer(player: FetchedPlayer, lk: PlayerLookups): Classification {
|
||||
const name = canonicalPlayerName(player);
|
||||
const byId = player.externalId ? lk.byExternalId.get(player.externalId) : undefined;
|
||||
if (byId) return { kind: "matched", participant: byId, backfill: false };
|
||||
|
||||
const matchedName = findMatchingTeamName(name, lk.names);
|
||||
if (matchedName) {
|
||||
const p = lk.byName.get(matchedName);
|
||||
if (p) return { kind: "matched", participant: p, backfill: !p.externalId && !!player.externalId };
|
||||
}
|
||||
|
||||
// No confident match. Surface the closest existing participant (if any) as a
|
||||
// possible duplicate to review.
|
||||
const view = buildUnmatchedTeamResolutionView(name, lk.recon);
|
||||
const top = view.confidence !== "none" ? view.topCandidates[0] ?? null : null;
|
||||
return {
|
||||
kind: "create",
|
||||
name,
|
||||
suggestion: top?.participantName ?? null,
|
||||
suggestionId: top?.participantId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dry run: report what a draw sync would do (matches, would-be participant
|
||||
* creations, likely duplicates, unfilled first-round slots) without any writes.
|
||||
*/
|
||||
export async function previewTennisDraw(eventId: string): Promise<DrawSyncPreview> {
|
||||
const { event, sportsSeasonId, draw } = await loadTennisDraw(eventId);
|
||||
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
|
||||
const lk = buildLookups(participants);
|
||||
const uniquePlayers = collectUniquePlayers(draw);
|
||||
|
||||
let matched = 0;
|
||||
const willCreate: DrawSyncPreview["willCreate"] = [];
|
||||
const possibleDuplicates: DrawSyncPreview["possibleDuplicates"] = [];
|
||||
|
||||
for (const player of uniquePlayers.values()) {
|
||||
const c = classifyPlayer(player, lk);
|
||||
if (c.kind === "matched") {
|
||||
matched++;
|
||||
} else {
|
||||
willCreate.push({ name: c.name, externalId: player.externalId });
|
||||
if (c.suggestion && c.suggestionId) {
|
||||
possibleDuplicates.push({
|
||||
name: c.name,
|
||||
externalId: player.externalId,
|
||||
suggestion: c.suggestion,
|
||||
suggestionId: c.suggestionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const firstRound = draw.rounds[0];
|
||||
const tbdFirstRound = firstRound
|
||||
? firstRound.matches.reduce((n, m) => n + (m.player1 ? 0 : 1) + (m.player2 ? 0 : 1), 0)
|
||||
: 0;
|
||||
const allMatches = draw.rounds.flatMap((r) => r.matches);
|
||||
|
||||
return {
|
||||
article: event.externalSourceKey ?? "",
|
||||
totalPlayers: uniquePlayers.size,
|
||||
matched,
|
||||
willCreate,
|
||||
possibleDuplicates,
|
||||
tbdFirstRound,
|
||||
totalMatches: allMatches.length,
|
||||
completedMatches: allMatches.filter((m) => m.winner !== null).length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate and auto-score a tennis major's bracket from its external draw
|
||||
* (Wikipedia). Resolves every drawn player to a participant — matching by
|
||||
* external id, then name, else auto-creating — propagates new participants to
|
||||
* every sports season linked to the same tournament (so result fan-out lands),
|
||||
* writes the full bracket, and runs the qualifying-points scorer.
|
||||
*
|
||||
* Runs only on a major's primary window; read-only siblings receive results via
|
||||
* fan-out, not direct scoring.
|
||||
*/
|
||||
export async function syncTennisDraw(eventId: string): Promise<DrawSyncResult> {
|
||||
const db = database();
|
||||
|
||||
const { event, sportsSeasonId, draw, template } = await loadTennisDraw(eventId);
|
||||
const roundIsScoring = new Map(template.rounds.map((r) => [r.name, r.isScoring]));
|
||||
|
||||
// ---- Resolve players → participants (primary season) ----------------------
|
||||
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
|
||||
const lk = buildLookups(participants);
|
||||
const uniquePlayers = collectUniquePlayers(draw);
|
||||
|
||||
const keyToParticipantId = new Map<string, string>();
|
||||
const resolvedParticipants = new Map<
|
||||
string,
|
||||
{ canonicalId: string | null; name: string; externalId: string | null }
|
||||
>();
|
||||
const unmatched: DrawSyncResult["unmatched"] = [];
|
||||
let participantsCreated = 0;
|
||||
|
||||
for (const [key, player] of uniquePlayers) {
|
||||
const c = classifyPlayer(player, lk);
|
||||
let participant: SeasonParticipant;
|
||||
|
||||
if (c.kind === "matched") {
|
||||
participant = c.participant;
|
||||
if (c.backfill && player.externalId) {
|
||||
await updateParticipant(participant.id, { externalId: player.externalId });
|
||||
}
|
||||
} else {
|
||||
// No confident match → auto-create so the bracket is complete; flag a
|
||||
// likely duplicate for admin review when it resembles an existing player.
|
||||
if (c.suggestion) unmatched.push({ name: c.name, externalId: player.externalId });
|
||||
participant = await createParticipant({
|
||||
sportsSeasonId,
|
||||
name: c.name,
|
||||
externalId: player.externalId ?? null,
|
||||
});
|
||||
lk.names.push(participant.name);
|
||||
lk.byName.set(participant.name, participant);
|
||||
lk.recon.push({ id: participant.id, name: participant.name, externalId: participant.externalId });
|
||||
if (participant.externalId) lk.byExternalId.set(participant.externalId, participant);
|
||||
participantsCreated++;
|
||||
}
|
||||
|
||||
keyToParticipantId.set(key, participant.id);
|
||||
resolvedParticipants.set(key, {
|
||||
canonicalId: participant.participantId ?? null,
|
||||
name: participant.name,
|
||||
externalId: participant.externalId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Propagate participants to linked sibling seasons ---------------------
|
||||
if (event.tournamentId) {
|
||||
const linkedRows = await db
|
||||
.select({ sportsSeasonId: schema.scoringEvents.sportsSeasonId })
|
||||
.from(schema.scoringEvents)
|
||||
.where(eq(schema.scoringEvents.tournamentId, event.tournamentId));
|
||||
const linkedSeasonIds = [...new Set(linkedRows.map((r) => r.sportsSeasonId))].filter(
|
||||
(id) => id !== sportsSeasonId,
|
||||
);
|
||||
|
||||
// Dedupe by canonical participant: two drawn players can fuzzy-match the same
|
||||
// existing participant, which would otherwise queue duplicate sibling inserts
|
||||
// and trip the (sportsSeasonId, name) unique index.
|
||||
const resolved = [
|
||||
...new Map(
|
||||
[...resolvedParticipants.values()]
|
||||
.filter((r) => r.canonicalId)
|
||||
.map((r) => [r.canonicalId, r]),
|
||||
).values(),
|
||||
];
|
||||
|
||||
for (const seasonId of linkedSeasonIds) {
|
||||
const sib = await findParticipantsBySportsSeasonId(seasonId);
|
||||
const sibCanon = new Set(sib.map((s) => s.participantId).filter(Boolean));
|
||||
const sibNames = new Set(sib.map((s) => normalizeTeamName(s.name)));
|
||||
|
||||
const toCreate = resolved
|
||||
.filter(
|
||||
(r) =>
|
||||
r.canonicalId &&
|
||||
!sibCanon.has(r.canonicalId) &&
|
||||
!sibNames.has(normalizeTeamName(r.name)),
|
||||
)
|
||||
.map((r) => ({
|
||||
sportsSeasonId: seasonId,
|
||||
name: r.name,
|
||||
externalId: r.externalId,
|
||||
participantId: r.canonicalId,
|
||||
}));
|
||||
|
||||
if (toCreate.length > 0) {
|
||||
await createManyParticipants(toCreate);
|
||||
participantsCreated += toCreate.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Write the bracket ----------------------------------------------------
|
||||
const resolvedMatches = buildResolvedMatches(draw, keyToParticipantId, roundIsScoring);
|
||||
|
||||
// Ensure the event is configured for QP bracket scoring before populating.
|
||||
if (
|
||||
event.bracketTemplateId !== draw.templateId ||
|
||||
event.scoringStartsAtRound !== template.scoringStartsAtRound
|
||||
) {
|
||||
await updateScoringEvent(eventId, {
|
||||
bracketTemplateId: draw.templateId,
|
||||
scoringStartsAtRound: template.scoringStartsAtRound,
|
||||
});
|
||||
}
|
||||
|
||||
const { written, completed, newlyDecidedLoserIds } = await populateBracketFromDraw(
|
||||
eventId,
|
||||
resolvedMatches,
|
||||
);
|
||||
|
||||
// ---- Score (qualifying points) + fan out to siblings ----------------------
|
||||
// Re-derive QP from the bracket and learn which participants' QP changed so the
|
||||
// Discord notification below only announces new/changed results on a re-sync.
|
||||
const changedParticipantIds = await rescoreTennisBracketAndDetectChanges(
|
||||
eventId,
|
||||
sportsSeasonId,
|
||||
db,
|
||||
);
|
||||
|
||||
await recalculateAffectedLeagues(sportsSeasonId, db, {
|
||||
eventId,
|
||||
eventName: event.name ?? undefined,
|
||||
});
|
||||
// Players knocked out this sync in a non-scoring round (rounds 1–3 of a Grand
|
||||
// Slam) earn no QP and so never appear via changedParticipantIds. Computed here
|
||||
// so it feeds BOTH the primary's own notification below AND the fan-out, which
|
||||
// propagates it to every mirror window (whose placement-only data can't detect a
|
||||
// knockout on its own).
|
||||
const newlyEliminatedIds = new Set(newlyDecidedLoserIds);
|
||||
|
||||
await fanOutMajorIfPrimary(
|
||||
{ id: event.id, isPrimary: event.isPrimary, tournamentId: event.tournamentId },
|
||||
{ markComplete: false, newlyEliminatedParticipantIds: newlyEliminatedIds },
|
||||
);
|
||||
|
||||
// Announce QP changes for the primary window. Sibling windows are announced by
|
||||
// the fan-out (via processQualifyingEvent); the primary is scored directly by
|
||||
// processQualifyingBracketEvent, which does NOT notify — so do it here. Run
|
||||
// outside the rescore transaction so the webhook HTTP call neither holds the
|
||||
// transaction open nor rolls back the score if Discord fails.
|
||||
//
|
||||
// Fire even when no QP changed, so an early-round elimination is still surfaced.
|
||||
if (changedParticipantIds.size > 0 || newlyEliminatedIds.size > 0) {
|
||||
try {
|
||||
await notifyQualifyingPointsUpdate(
|
||||
sportsSeasonId,
|
||||
eventId,
|
||||
db,
|
||||
changedParticipantIds,
|
||||
newlyEliminatedIds,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`[syncTennisDraw] QP Discord notification failed for event ${eventId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return { matchesWritten: written, completed, participantsCreated, unmatched };
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-derive a tennis major's qualifying points from its bracket and report which
|
||||
* season participants' awarded QP changed since the previous scoring.
|
||||
*
|
||||
* The synced draw is authoritative for tennis, so this clears ALL event_results
|
||||
* for the event (including any manually-entered rows, e.g. a notParticipating
|
||||
* withdrawal) and rebuilds them from the bracket. Change detection therefore
|
||||
* snapshots each participant's awarded QP *before* the delete and diffs it against
|
||||
* the freshly written rows: a value that differs, or a participant scored for the
|
||||
* first time (null → value), counts as changed. Wrapped in a transaction so a
|
||||
* mid-rescore failure can't leave the event with results deleted-but-not-rebuilt.
|
||||
*
|
||||
* writeEventResultsQP (inside processQualifyingBracketEvent) upserts but never
|
||||
* deletes, so QP totals for participants who no longer earn points (e.g. an
|
||||
* early-round loser dropped from the rewritten results — only Round of 16+ losers
|
||||
* score) are recalculated by hand.
|
||||
*
|
||||
* Returns the set of season_participant ids whose QP changed, used to scope the
|
||||
* QP Discord notification so a re-sync that changes nothing announces nothing.
|
||||
*/
|
||||
export async function rescoreTennisBracketAndDetectChanges(
|
||||
eventId: string,
|
||||
sportsSeasonId: string,
|
||||
db: ReturnType<typeof database>,
|
||||
): Promise<Set<string>> {
|
||||
let changed = new Set<string>();
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const beforeRows = await tx
|
||||
.select({
|
||||
id: schema.eventResults.seasonParticipantId,
|
||||
qp: schema.eventResults.qualifyingPointsAwarded,
|
||||
})
|
||||
.from(schema.eventResults)
|
||||
.where(eq(schema.eventResults.scoringEventId, eventId));
|
||||
await tx.delete(schema.eventResults).where(eq(schema.eventResults.scoringEventId, eventId));
|
||||
|
||||
await processQualifyingBracketEvent(eventId, tx);
|
||||
|
||||
const afterRows = await tx
|
||||
.select({
|
||||
id: schema.eventResults.seasonParticipantId,
|
||||
qp: schema.eventResults.qualifyingPointsAwarded,
|
||||
})
|
||||
.from(schema.eventResults)
|
||||
.where(eq(schema.eventResults.scoringEventId, eventId));
|
||||
const afterIds = new Set(afterRows.map((r) => r.id));
|
||||
|
||||
for (const { id } of beforeRows) {
|
||||
if (!afterIds.has(id)) await recalculateParticipantQP(id, sportsSeasonId, tx);
|
||||
}
|
||||
|
||||
changed = diffChangedQualifyingPoints(beforeRows, afterRows);
|
||||
});
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
|
|
|||
63
app/services/match-sync/tennis-draw-mapping.ts
Normal file
63
app/services/match-sync/tennis-draw-mapping.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* Pure (DB-free) mapping helpers for tennis draw sync, separated so the
|
||||
* scoring-critical logic — winner/loser assignment and which rounds score — can
|
||||
* be unit-tested without a database.
|
||||
*/
|
||||
import { normalizeTeamName } from "~/lib/normalize-team-name";
|
||||
import type { ResolvedDrawMatch } from "~/models/playoff-match";
|
||||
import type { FetchedDraw, FetchedPlayer } from "./types";
|
||||
|
||||
/** Stable identity key for a drawn player (prefer the source's external id). */
|
||||
export function playerKey(p: FetchedPlayer): string {
|
||||
return p.externalId ? `id:${p.externalId}` : `name:${normalizeTeamName(p.name)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical participant name for a drawn player. Section brackets abbreviate the
|
||||
* display name ("J Sinner"); the wikilink target (externalId) is the full name,
|
||||
* so prefer it. Strips the trailing Wikipedia disambiguator (e.g. "Arthur Fils
|
||||
* (tennis)" → "Arthur Fils") so the stored name and fuzzy matching use the plain
|
||||
* name — the raw externalId (with the disambiguator) is kept as the stable key.
|
||||
*/
|
||||
export function canonicalPlayerName(p: FetchedPlayer): string {
|
||||
return (p.externalId ?? p.name).replace(/\s*\([^)]*\)\s*$/, "").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a fetched draw into bracket rows with participants/winner resolved to ids.
|
||||
* `winnerId`/`loserId` are set only when both players resolved and the source
|
||||
* reported a winner. `isScoring` comes from the bracket template's round config.
|
||||
*/
|
||||
export function buildResolvedMatches(
|
||||
draw: FetchedDraw,
|
||||
keyToParticipantId: Map<string, string>,
|
||||
roundIsScoring: Map<string, boolean>,
|
||||
): ResolvedDrawMatch[] {
|
||||
const resolved: ResolvedDrawMatch[] = [];
|
||||
for (const round of draw.rounds) {
|
||||
for (const m of round.matches) {
|
||||
const p1 = m.player1 ? keyToParticipantId.get(playerKey(m.player1)) ?? null : null;
|
||||
const p2 = m.player2 ? keyToParticipantId.get(playerKey(m.player2)) ?? null : null;
|
||||
let winnerId: string | null = null;
|
||||
let loserId: string | null = null;
|
||||
if (m.winner === 1) {
|
||||
winnerId = p1;
|
||||
loserId = p2;
|
||||
} else if (m.winner === 2) {
|
||||
winnerId = p2;
|
||||
loserId = p1;
|
||||
}
|
||||
resolved.push({
|
||||
externalMatchId: m.externalMatchId,
|
||||
round: m.round,
|
||||
matchNumber: m.matchNumber,
|
||||
participant1Id: p1,
|
||||
participant2Id: p2,
|
||||
winnerId,
|
||||
loserId,
|
||||
isScoring: roundIsScoring.get(m.round) ?? false,
|
||||
});
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
|
@ -42,3 +42,103 @@ export interface MatchSyncResult {
|
|||
unmatchedTeams: { externalId: string; name: string }[];
|
||||
errors: { externalMatchId: string; error: string }[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tennis Grand Slam draw sync
|
||||
//
|
||||
// Unlike the team-sport `MatchSyncAdapter` (result-only updates on a
|
||||
// pre-existing bracket), a tennis major must be populated from an empty draw:
|
||||
// 128 players, scaffolded across all 7 rounds, with winners auto-scored as the
|
||||
// source reports them. `DrawSyncAdapter` returns the full draw; `syncTennisDraw`
|
||||
// (index.ts) auto-creates/links participants, builds the bracket, propagates
|
||||
// participants to linked sibling seasons, and runs the qualifying-points scorer.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A competitor in a fetched draw match, as seen by the external source. */
|
||||
export interface FetchedPlayer {
|
||||
/** Display name, e.g. "Jannik Sinner". */
|
||||
name: string;
|
||||
/**
|
||||
* Stable source identifier, the primary key against
|
||||
* `seasonParticipants.externalId`. For Wikipedia this is the article link
|
||||
* target (stable across years). Null when the source gives only plain text
|
||||
* (e.g. a qualifier with no article).
|
||||
*/
|
||||
externalId: string | null;
|
||||
/** Tournament seed, if seeded. */
|
||||
seed: number | null;
|
||||
}
|
||||
|
||||
/** A single fetched draw match. */
|
||||
export interface FetchedDrawMatch {
|
||||
/** Stable, source-derived id, e.g. "wimbledon-2025-ms-Round-of-16-m03". */
|
||||
externalMatchId: string;
|
||||
/** Template round name, e.g. "Round of 128" … "Final". */
|
||||
round: string;
|
||||
/** 1-based position within the round (cosmetic ordering only). */
|
||||
matchNumber: number;
|
||||
player1: FetchedPlayer | null;
|
||||
player2: FetchedPlayer | null;
|
||||
/** Which slot won; null while undrawn or not yet completed. */
|
||||
winner: 1 | 2 | null;
|
||||
}
|
||||
|
||||
export interface FetchedRound {
|
||||
/** Template round name. */
|
||||
roundName: string;
|
||||
matches: FetchedDrawMatch[];
|
||||
}
|
||||
|
||||
/** A complete (or partially-completed) knockout draw. */
|
||||
export interface FetchedDraw {
|
||||
/** Bracket template this draw maps onto, e.g. "tennis_128". */
|
||||
templateId: string;
|
||||
/** Ordered earliest → latest round (Round of 128 → Final). */
|
||||
rounds: FetchedRound[];
|
||||
}
|
||||
|
||||
/** Adapter that fetches a knockout draw from an external source. */
|
||||
export interface DrawSyncAdapter {
|
||||
/** @param sourceKey provider locator (Wikipedia article title, etc.). */
|
||||
fetchDraw(sourceKey: string): Promise<FetchedDraw>;
|
||||
}
|
||||
|
||||
/** A drawn player that couldn't be confidently auto-resolved to a participant. */
|
||||
export interface UnmatchedPlayer {
|
||||
name: string;
|
||||
externalId: string | null;
|
||||
}
|
||||
|
||||
export interface DrawSyncResult {
|
||||
/** Matches inserted or updated in the bracket. */
|
||||
matchesWritten: number;
|
||||
/** Of those, how many carried a completed result this sync. */
|
||||
completed: number;
|
||||
/** New participants auto-created (across all linked seasons). */
|
||||
participantsCreated: number;
|
||||
/** Ambiguous players queued for admin reconciliation rather than guessed. */
|
||||
unmatched: UnmatchedPlayer[];
|
||||
}
|
||||
|
||||
/** Read-only preview of what a draw sync would do, with no DB writes. */
|
||||
export interface DrawSyncPreview {
|
||||
/** Resolved Wikipedia article title. */
|
||||
article: string;
|
||||
totalPlayers: number;
|
||||
/** Players that resolve to an existing participant. */
|
||||
matched: number;
|
||||
/** Players that would be auto-created. */
|
||||
willCreate: UnmatchedPlayer[];
|
||||
/** Of those, ones that resemble an existing participant (likely duplicates). */
|
||||
possibleDuplicates: {
|
||||
name: string;
|
||||
externalId: string | null;
|
||||
/** Existing participant this resembles. */
|
||||
suggestion: string;
|
||||
suggestionId: string;
|
||||
}[];
|
||||
/** Unfilled slots in the first round (e.g. a qualifier not yet drawn). */
|
||||
tbdFirstRound: number;
|
||||
totalMatches: number;
|
||||
completedMatches: number;
|
||||
}
|
||||
|
|
|
|||
307
app/services/match-sync/wikipedia-tennis.ts
Normal file
307
app/services/match-sync/wikipedia-tennis.ts
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
/**
|
||||
* Wikipedia tennis Grand Slam draw adapter.
|
||||
*
|
||||
* Source of truth: the singles-draw article (e.g.
|
||||
* "2025 Wimbledon Championships – Men's singles"), whose full bracket is encoded
|
||||
* as MediaWiki bracket templates:
|
||||
* - eight `{{16TeamBracket-Compact-Tennis5}}` sections (Round of 128 → Round
|
||||
* of 16), 16 players each = 128, and
|
||||
* - one `{{8TeamBracket-Tennis5}}` finals bracket (Quarterfinals → Final).
|
||||
* (`-Tennis3` best-of-3 variants render women's draws identically for our needs.)
|
||||
*
|
||||
* The winner of each match is the slot whose `team` value is bolded (`'''…'''`)
|
||||
* — losers are never bolded — which lets us derive winner/loser without parsing
|
||||
* set scores. Player names come from `[[wikilinks]]`, whose target is a stable
|
||||
* cross-year id used to match `seasonParticipants.externalId`.
|
||||
*
|
||||
* Parsing is split out (pure functions) so it can be unit-tested against a saved
|
||||
* wikitext fixture without network access.
|
||||
*/
|
||||
import type {
|
||||
DrawSyncAdapter,
|
||||
FetchedDraw,
|
||||
FetchedDrawMatch,
|
||||
FetchedPlayer,
|
||||
FetchedRound,
|
||||
} from "./types";
|
||||
|
||||
const WIKIPEDIA_API = "https://en.wikipedia.org/w/api.php";
|
||||
const CONTACT = process.env.WIKIPEDIA_CONTACT_EMAIL ?? "admin@brackt.com";
|
||||
const USER_AGENT = `Brackt.com tennis draw sync - ${CONTACT}`;
|
||||
|
||||
/** Round names for a 16-player draw section, earliest → latest. */
|
||||
const SECTION_ROUNDS = ["Round of 128", "Round of 64", "Round of 32", "Round of 16"] as const;
|
||||
/** Round names for the 8-player finals bracket, earliest → latest. */
|
||||
const FINALS_ROUNDS = ["Quarterfinals", "Semifinals", "Final"] as const;
|
||||
|
||||
/**
|
||||
* Accept either a plain article title or a pasted Wikipedia URL and return the
|
||||
* article title. URLs like
|
||||
* `https://en.wikipedia.org/wiki/2025_Wimbledon_Championships_%E2%80%93_Men%27s_singles`
|
||||
* become "2025 Wimbledon Championships – Men's singles".
|
||||
*/
|
||||
export function articleTitleFromInput(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
const match = trimmed.match(/\/wiki\/([^?#]+)/);
|
||||
if (!match) return trimmed;
|
||||
let title = match[1];
|
||||
try {
|
||||
title = decodeURIComponent(title);
|
||||
} catch {
|
||||
// leave as-is if it isn't valid percent-encoding
|
||||
}
|
||||
return title.replace(/_/g, " ").trim();
|
||||
}
|
||||
|
||||
/** Turn an article title into a stable, id-safe slug. */
|
||||
export function slugifyArticle(title: string): string {
|
||||
return title
|
||||
.normalize("NFKD")
|
||||
.replace(/[̀-ͯ]/g, "") // strip combining diacritics
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan wikitext for `{{<TemplateName>...}}` invocations, returning each body
|
||||
* (text between the outer braces) with balanced-brace handling so nested
|
||||
* templates like `{{flagicon|ITA}}` don't terminate the capture early.
|
||||
*/
|
||||
export function extractTemplates(wikitext: string, namePrefixes: string[]): { name: string; body: string }[] {
|
||||
const results: { name: string; body: string }[] = [];
|
||||
for (let i = 0; i < wikitext.length - 1; i++) {
|
||||
if (wikitext[i] !== "{" || wikitext[i + 1] !== "{") continue;
|
||||
const afterBraces = wikitext.slice(i + 2);
|
||||
const match = afterBraces.match(/^\s*([A-Za-z0-9 _-]+)/);
|
||||
const name = match?.[1]?.trim() ?? "";
|
||||
if (!namePrefixes.some((p) => name.startsWith(p))) continue;
|
||||
|
||||
// Walk forward tracking {{ }} depth to find the matching close.
|
||||
let depth = 0;
|
||||
let j = i;
|
||||
for (; j < wikitext.length - 1; j++) {
|
||||
if (wikitext[j] === "{" && wikitext[j + 1] === "{") {
|
||||
depth++;
|
||||
j++;
|
||||
} else if (wikitext[j] === "}" && wikitext[j + 1] === "}") {
|
||||
depth--;
|
||||
j++;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
const body = wikitext.slice(i + 2, j - 1);
|
||||
results.push({ name, body });
|
||||
i = j; // continue past this template
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a template body into `key=value` params on top-level `|` only —
|
||||
* pipes inside `{{…}}` or `[[…]]` are part of a value (flag templates, piped
|
||||
* wikilinks) and must not split.
|
||||
*/
|
||||
export function parseTemplateParams(body: string): Map<string, string> {
|
||||
const params = new Map<string, string>();
|
||||
let braceDepth = 0;
|
||||
let linkDepth = 0;
|
||||
let current = "";
|
||||
const parts: string[] = [];
|
||||
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const two = body.slice(i, i + 2);
|
||||
if (two === "{{") { braceDepth++; current += two; i++; continue; }
|
||||
if (two === "}}") { braceDepth--; current += two; i++; continue; }
|
||||
if (two === "[[") { linkDepth++; current += two; i++; continue; }
|
||||
if (two === "]]") { linkDepth--; current += two; i++; continue; }
|
||||
if (body[i] === "|" && braceDepth === 0 && linkDepth === 0) {
|
||||
parts.push(current);
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += body[i];
|
||||
}
|
||||
parts.push(current);
|
||||
|
||||
for (const part of parts) {
|
||||
const eq = part.indexOf("=");
|
||||
if (eq === -1) continue;
|
||||
const key = part.slice(0, eq).trim();
|
||||
const value = part.slice(eq + 1).trim();
|
||||
if (key) params.set(key, value);
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a bracket `team` value into a player + whether it won.
|
||||
* Returns null for an empty/Bye slot.
|
||||
*/
|
||||
export function parsePlayerCell(value: string): { player: FetchedPlayer; isWinner: boolean } | null {
|
||||
if (!value) return null;
|
||||
// Winner detection: only the winning slot's name is bolded.
|
||||
const isWinner = value.includes("'''");
|
||||
let text = value.replace(/'''/g, "");
|
||||
|
||||
// Extract the wikilink FIRST, before stripping templates. A team cell may wrap
|
||||
// the flag + link in a display template, e.g.
|
||||
// {{nowrap|{{flagicon|ESP}} [[Alejandro Davidovich Fokina|A Davidovich Fokina]]}}
|
||||
// Blanket template-stripping would, after removing the inner {{flagicon}}, treat
|
||||
// the outer {{nowrap| … [[link]] … }} as a single template and delete the link
|
||||
// with it — yielding an empty cell (TBD). Pulling the link out first avoids that.
|
||||
const linkMatch = text.match(/\[\[([^\]]+)\]\]/);
|
||||
let name: string;
|
||||
let externalId: string | null;
|
||||
if (linkMatch) {
|
||||
const [target, display] = linkMatch[1].split("|");
|
||||
externalId = target.trim();
|
||||
name = (display ?? target).trim();
|
||||
} else {
|
||||
// No link: unwrap display templates ({{nowrap|X}}) to their content, drop flag
|
||||
// templates, and keep whatever plain text remains (e.g. an unlinked qualifier).
|
||||
let prev: string;
|
||||
do {
|
||||
prev = text;
|
||||
text = text.replace(/\{\{\s*(?:nowrap|nobr)\s*\|([^{}]*)\}\}/gi, "$1");
|
||||
} while (text !== prev);
|
||||
do {
|
||||
prev = text;
|
||||
text = text.replace(/\{\{[^{}]*\}\}/g, " ");
|
||||
} while (text !== prev);
|
||||
name = text.replace(/\[\[|\]\]/g, "").trim();
|
||||
externalId = null;
|
||||
}
|
||||
name = name.replace(/\s+/g, " ").trim();
|
||||
// Treat empty slots and not-yet-determined placeholders (an unfilled qualifier,
|
||||
// a bye, a TBD feeder) as no player → the bracket shows TBD rather than
|
||||
// inventing a shared "Qualifier" participant across many slots.
|
||||
if (!name || /^(bye|tbd|qualifier|to be determined)$/i.test(name)) return null;
|
||||
return { player: { name, externalId, seed: null }, isWinner };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build matches for one round of a bracket. Within a round, consecutive slots
|
||||
* pair up: (team1,team2)=match1, (team3,team4)=match2, …
|
||||
*/
|
||||
function parseRound(
|
||||
params: Map<string, string>,
|
||||
rdIndex: number,
|
||||
roundName: string,
|
||||
idPrefix: string,
|
||||
matchNumberOffset: number,
|
||||
): FetchedDrawMatch[] {
|
||||
// Collect team slot tokens present for this round, preserving the raw token
|
||||
// (slots are zero-padded in the compact section brackets, e.g. "team01", but
|
||||
// unpadded in the finals bracket, e.g. "team1"). Sort by numeric value.
|
||||
const slots: string[] = [];
|
||||
for (const key of params.keys()) {
|
||||
const m = key.match(new RegExp(`^RD${rdIndex}-team(\\d+)$`));
|
||||
if (m) slots.push(m[1]);
|
||||
}
|
||||
slots.sort((a, b) => Number(a) - Number(b));
|
||||
|
||||
const matches: FetchedDrawMatch[] = [];
|
||||
for (let p = 0; p < slots.length; p += 2) {
|
||||
const slot1 = slots[p];
|
||||
const slot2 = slots[p + 1];
|
||||
const cell1 = parsePlayerCell(params.get(`RD${rdIndex}-team${slot1}`) ?? "");
|
||||
const cell2 = slot2 !== undefined ? parsePlayerCell(params.get(`RD${rdIndex}-team${slot2}`) ?? "") : null;
|
||||
|
||||
const seed1 = params.get(`RD${rdIndex}-seed${slot1}`);
|
||||
const seed2 = slot2 !== undefined ? params.get(`RD${rdIndex}-seed${slot2}`) : undefined;
|
||||
const player1 = cell1 ? { ...cell1.player, seed: parseSeed(seed1) } : null;
|
||||
const player2 = cell2 ? { ...cell2.player, seed: parseSeed(seed2) } : null;
|
||||
|
||||
let winner: 1 | 2 | null = null;
|
||||
if (cell1?.isWinner) winner = 1;
|
||||
else if (cell2?.isWinner) winner = 2;
|
||||
|
||||
const matchNumber = matchNumberOffset + matches.length + 1;
|
||||
matches.push({
|
||||
externalMatchId: `${idPrefix}:${roundName.replace(/\s+/g, "-")}:m${matchNumber}`,
|
||||
round: roundName,
|
||||
matchNumber,
|
||||
player1,
|
||||
player2,
|
||||
winner,
|
||||
});
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
function parseSeed(raw: string | undefined): number | null {
|
||||
if (!raw) return null;
|
||||
const n = parseInt(raw.replace(/[^0-9]/g, ""), 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a full Grand Slam singles draw article into a `FetchedDraw`.
|
||||
* @param wikitext raw article wikitext
|
||||
* @param articleTitle used to build stable match ids
|
||||
*/
|
||||
export function parseTennisDrawWikitext(wikitext: string, articleTitle: string): FetchedDraw {
|
||||
const slug = slugifyArticle(articleTitle);
|
||||
const sectionBrackets = extractTemplates(wikitext, ["16TeamBracket"]);
|
||||
const finalsBrackets = extractTemplates(wikitext, ["8TeamBracket"]);
|
||||
|
||||
if (finalsBrackets.length !== 1 || sectionBrackets.length !== 8) {
|
||||
throw new Error(
|
||||
`Unexpected tennis draw layout in "${articleTitle}": found ${sectionBrackets.length} ` +
|
||||
`16-team section bracket(s) and ${finalsBrackets.length} 8-team finals bracket(s); ` +
|
||||
"expected 8 sections + 1 finals. Template variant may be unsupported.",
|
||||
);
|
||||
}
|
||||
|
||||
// Accumulate matches per round across all sections (section order is cosmetic).
|
||||
const roundMatches = new Map<string, FetchedDrawMatch[]>();
|
||||
const pushMatches = (round: string, ms: FetchedDrawMatch[]) => {
|
||||
const existing = roundMatches.get(round) ?? [];
|
||||
roundMatches.set(round, existing.concat(ms));
|
||||
};
|
||||
|
||||
sectionBrackets.forEach((bracket, sectionIdx) => {
|
||||
const params = parseTemplateParams(bracket.body);
|
||||
SECTION_ROUNDS.forEach((roundName, rdIdx) => {
|
||||
const offset = roundMatches.get(roundName)?.length ?? 0;
|
||||
pushMatches(roundName, parseRound(params, rdIdx + 1, roundName, `${slug}:s${sectionIdx}`, offset));
|
||||
});
|
||||
});
|
||||
|
||||
const finalsParams = parseTemplateParams(finalsBrackets[0].body);
|
||||
FINALS_ROUNDS.forEach((roundName, rdIdx) => {
|
||||
pushMatches(roundName, parseRound(finalsParams, rdIdx + 1, roundName, `${slug}:f`, 0));
|
||||
});
|
||||
|
||||
const orderedRoundNames = [...SECTION_ROUNDS, ...FINALS_ROUNDS];
|
||||
const rounds: FetchedRound[] = orderedRoundNames.map((roundName) => ({
|
||||
roundName,
|
||||
matches: roundMatches.get(roundName) ?? [],
|
||||
}));
|
||||
|
||||
return { templateId: "tennis_128", rounds };
|
||||
}
|
||||
|
||||
export class WikipediaTennisAdapter implements DrawSyncAdapter {
|
||||
async fetchDraw(sourceKey: string): Promise<FetchedDraw> {
|
||||
const title = articleTitleFromInput(sourceKey);
|
||||
const url = `${WIKIPEDIA_API}?action=parse&prop=wikitext&format=json&formatversion=2&redirects=1&page=${encodeURIComponent(title)}`;
|
||||
const response = await fetch(url, { headers: { "User-Agent": USER_AGENT } });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Wikipedia API returned ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
const json = (await response.json()) as {
|
||||
error?: { info?: string };
|
||||
parse?: { wikitext?: string };
|
||||
};
|
||||
if (json.error) {
|
||||
throw new Error(`Wikipedia API error for "${title}": ${json.error.info ?? "unknown error"}`);
|
||||
}
|
||||
const wikitext = json.parse?.wikitext;
|
||||
if (!wikitext) {
|
||||
throw new Error(`No wikitext returned for "${title}"`);
|
||||
}
|
||||
return parseTennisDrawWikitext(wikitext, title);
|
||||
}
|
||||
}
|
||||
251
app/services/qualifying-points-discord.server.ts
Normal file
251
app/services/qualifying-points-discord.server.ts
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
import type { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { findDiscordIdsByUserIds } from "~/models/account";
|
||||
import { getUserDisplayName } from "~/models/user";
|
||||
import {
|
||||
sendQualifyingPointsUpdateNotification,
|
||||
type QPEventEntry,
|
||||
type QPEliminatedEntry,
|
||||
} from "~/services/discord";
|
||||
|
||||
export async function notifyQualifyingPointsUpdate(
|
||||
sportsSeasonId: string,
|
||||
scoringEventId: string,
|
||||
db: ReturnType<typeof database>,
|
||||
participantIdFilter?: Set<string>,
|
||||
/**
|
||||
* Participants knocked out this sync in a non-scoring round: they earn no QP,
|
||||
* so they never appear via qualifyingPointsAwarded, but a manager who drafted
|
||||
* them should still be told their player is out. Surfaced in a "Knocked Out"
|
||||
* section, deduped against QP earners.
|
||||
*/
|
||||
eliminatedParticipantIds?: Set<string>
|
||||
): Promise<void> {
|
||||
const event = await db.query.scoringEvents.findFirst({
|
||||
where: eq(schema.scoringEvents.id, scoringEventId),
|
||||
with: {
|
||||
sportsSeason: {
|
||||
with: { sport: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!event) return;
|
||||
|
||||
const eventName = event.name;
|
||||
const sportName = event.sportsSeason?.sport?.name;
|
||||
|
||||
const seasonSports = await db.query.seasonSports.findMany({
|
||||
where: eq(schema.seasonSports.sportsSeasonId, sportsSeasonId),
|
||||
});
|
||||
if (seasonSports.length === 0) return;
|
||||
|
||||
// QP earned in this specific event (optionally scoped to participants new/changed in this call)
|
||||
const eventResultRows = await db.query.eventResults.findMany({
|
||||
where: eq(schema.eventResults.scoringEventId, scoringEventId),
|
||||
});
|
||||
const qpEarnedById = new Map<string, number>(
|
||||
eventResultRows
|
||||
.filter((r) => r.qualifyingPointsAwarded !== null)
|
||||
.filter((r) => !participantIdFilter || participantIdFilter.has(r.seasonParticipantId))
|
||||
.map((r) => [r.seasonParticipantId, parseFloat(r.qualifyingPointsAwarded as string)])
|
||||
);
|
||||
|
||||
// Knocked-out players with no QP change. Exclude anyone who also earned QP this
|
||||
// sync (e.g. a Round-of-16 loser) so they aren't listed twice.
|
||||
const eliminatedIds = new Set(
|
||||
[...(eliminatedParticipantIds ?? [])].filter((id) => !qpEarnedById.has(id))
|
||||
);
|
||||
|
||||
if (qpEarnedById.size === 0 && eliminatedIds.size === 0) return;
|
||||
|
||||
const seasonIds = seasonSports.map((s) => s.seasonId);
|
||||
|
||||
// Batch-fetch all draft picks for all leagues at once, grouped by season.
|
||||
// Fetched before the remaining lookups so we can short-circuit when nothing
|
||||
// that happened this sync was actually drafted: during a Grand Slam's early
|
||||
// rounds most eliminated players are undrafted, and the notifier now fires on
|
||||
// every sync that has any elimination — no point running the rest of the
|
||||
// queries just to send nothing.
|
||||
const allPicks = await db.query.draftPicks.findMany({
|
||||
where: inArray(schema.draftPicks.seasonId, seasonIds),
|
||||
with: { team: true },
|
||||
});
|
||||
const picksBySeasonId = new Map<string, (typeof allPicks)[number][]>();
|
||||
const draftedParticipantIds = new Set<string>();
|
||||
for (const pick of allPicks) {
|
||||
draftedParticipantIds.add(pick.participantId);
|
||||
const bucket = picksBySeasonId.get(pick.seasonId);
|
||||
if (bucket) {
|
||||
bucket.push(pick);
|
||||
} else {
|
||||
picksBySeasonId.set(pick.seasonId, [pick]);
|
||||
}
|
||||
}
|
||||
|
||||
const hasDraftedQP = [...qpEarnedById.keys()].some((id) => draftedParticipantIds.has(id));
|
||||
const hasDraftedEliminated = [...eliminatedIds].some((id) => draftedParticipantIds.has(id));
|
||||
if (!hasDraftedQP && !hasDraftedEliminated) return;
|
||||
|
||||
// Current running QP totals for the season
|
||||
const qpTotals = await db.query.seasonParticipantQualifyingTotals.findMany({
|
||||
where: eq(schema.seasonParticipantQualifyingTotals.sportsSeasonId, sportsSeasonId),
|
||||
});
|
||||
const qpTotalById = new Map<string, number>(
|
||||
qpTotals.map((t) => [t.participantId, parseFloat(t.totalQualifyingPoints)])
|
||||
);
|
||||
|
||||
// Rank every participant across the FULL season field (not just this event's
|
||||
// scorers) so the notification's "QP Standings" block reads as a season-leaderboard
|
||||
// slice and matches the website's globalRank. Standard competition ranking: ties
|
||||
// share the lower rank (…, 9, 9, 11, …). Two R16 losers on 1.5 QP with 8 players
|
||||
// ahead therefore render as T9, not T1 among just the two of them.
|
||||
const rankedField = [...qpTotalById.entries()]
|
||||
.map(([id, total]) => ({ id, total }))
|
||||
.toSorted((a, b) => b.total - a.total);
|
||||
const globalRankById = new Map<string, number>();
|
||||
let prevTotal = Number.NaN;
|
||||
let prevRank = 0;
|
||||
rankedField.forEach((row, index) => {
|
||||
const rank =
|
||||
index > 0 && Math.abs(row.total - prevTotal) < 0.001 ? prevRank : index + 1;
|
||||
globalRankById.set(row.id, rank);
|
||||
prevTotal = row.total;
|
||||
prevRank = rank;
|
||||
});
|
||||
const countByRank = new Map<number, number>();
|
||||
for (const rank of globalRankById.values()) {
|
||||
countByRank.set(rank, (countByRank.get(rank) ?? 0) + 1);
|
||||
}
|
||||
|
||||
// Batch-fetch all season + league metadata in one query
|
||||
const seasons = await db.query.seasons.findMany({
|
||||
where: inArray(schema.seasons.id, seasonIds),
|
||||
with: { league: true },
|
||||
});
|
||||
const seasonMap = new Map(seasons.map((s) => [s.id, s]));
|
||||
|
||||
// Batch-fetch participant display names once (same participants across all leagues).
|
||||
// Includes every drafted participant — the Drafted Participants scoreboard section
|
||||
// lists the whole scored field, not just this sync's changed participants.
|
||||
const allParticipantIds = [
|
||||
...new Set([...qpEarnedById.keys(), ...eliminatedIds, ...draftedParticipantIds]),
|
||||
];
|
||||
const participants = await db.query.seasonParticipants.findMany({
|
||||
where: inArray(schema.seasonParticipants.id, allParticipantIds),
|
||||
});
|
||||
const participantNameById = new Map(participants.map((p) => [p.id, p.name]));
|
||||
// A league's draft picks span every sport in that fantasy season, so the scoreboard
|
||||
// must be scoped to participants belonging to the sports season being announced —
|
||||
// otherwise a golf pick would surface in a tennis event's standings section.
|
||||
const sportsSeasonParticipantIds = new Set(
|
||||
participants.filter((p) => p.sportsSeasonId === sportsSeasonId).map((p) => p.id)
|
||||
);
|
||||
|
||||
// Batch-fetch all team owners and their Discord IDs in two queries (not N per league)
|
||||
const allOwnerIds = new Set<string>();
|
||||
for (const pick of allPicks) {
|
||||
if (pick.team.ownerId) allOwnerIds.add(pick.team.ownerId);
|
||||
}
|
||||
const allUsers =
|
||||
allOwnerIds.size > 0
|
||||
? await db.query.users.findMany({ where: inArray(schema.users.id, [...allOwnerIds]) })
|
||||
: [];
|
||||
const usernameByUserId = new Map(
|
||||
allUsers
|
||||
.map((u) => [u.id, getUserDisplayName(u)] as [string, string | null])
|
||||
.filter((entry): entry is [string, string] => entry[1] !== null)
|
||||
);
|
||||
const optedInUserIds = allUsers.filter((u) => u.discordPingEnabled).map((u) => u.id);
|
||||
const discordIdByUserId = await findDiscordIdsByUserIds(optedInUserIds);
|
||||
|
||||
for (const { seasonId } of seasonSports) {
|
||||
const season = seasonMap.get(seasonId);
|
||||
const league = season?.league;
|
||||
const webhookUrl = league?.discordWebhookUrl;
|
||||
if (!webhookUrl || !season || !league) continue;
|
||||
|
||||
const seasonName = `${league.name} ${season.year}`;
|
||||
|
||||
// Link the embed title to this league's sport-season standings page. The
|
||||
// sportsSeasonId is exactly what the /leagues/:leagueId/sports-seasons/:sportsSeasonId
|
||||
// route expects (the page filters seasonSports by leagueId).
|
||||
const appUrl = process.env.APP_URL ?? "https://brackt.com";
|
||||
const standingsUrl = `${appUrl}/leagues/${league.id}/sports-seasons/${sportsSeasonId}`;
|
||||
|
||||
const picks = picksBySeasonId.get(seasonId) ?? [];
|
||||
const teamByParticipantId = new Map<
|
||||
string,
|
||||
{ teamName: string; ownerId: string | null }
|
||||
>();
|
||||
for (const pick of picks) {
|
||||
teamByParticipantId.set(pick.participantId, {
|
||||
teamName: pick.team.name,
|
||||
ownerId: pick.team.ownerId,
|
||||
});
|
||||
}
|
||||
|
||||
// Only include participants that appear in both the event and this league's draft
|
||||
const relevantParticipantIds = [...qpEarnedById.keys()].filter((id) =>
|
||||
teamByParticipantId.has(id)
|
||||
);
|
||||
// Knocked-out players drafted in this league (0 QP, non-scoring-round exits)
|
||||
const eliminatedForLeague = [...eliminatedIds].filter((id) =>
|
||||
teamByParticipantId.has(id)
|
||||
);
|
||||
if (relevantParticipantIds.length === 0 && eliminatedForLeague.length === 0) continue;
|
||||
|
||||
const entries: QPEventEntry[] = relevantParticipantIds.map((participantId) => {
|
||||
const ownerId = teamByParticipantId.get(participantId)?.ownerId ?? null;
|
||||
const globalRank = globalRankById.get(participantId) ?? 0;
|
||||
return {
|
||||
participantName: participantNameById.get(participantId) ?? participantId,
|
||||
qpEarned: qpEarnedById.get(participantId) ?? 0,
|
||||
qpTotal: qpTotalById.get(participantId) ?? 0,
|
||||
globalRank,
|
||||
globalRankTied: (countByRank.get(globalRank) ?? 0) > 1,
|
||||
ownerUsername: ownerId ? (usernameByUserId.get(ownerId) ?? undefined) : undefined,
|
||||
ownerDiscordUserId: ownerId ? discordIdByUserId.get(ownerId) : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const eliminated: QPEliminatedEntry[] = eliminatedForLeague.map((participantId) => {
|
||||
const ownerId = teamByParticipantId.get(participantId)?.ownerId ?? null;
|
||||
return {
|
||||
participantName: participantNameById.get(participantId) ?? participantId,
|
||||
ownerUsername: ownerId ? (usernameByUserId.get(ownerId) ?? undefined) : undefined,
|
||||
ownerDiscordUserId: ownerId ? discordIdByUserId.get(ownerId) : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
// Full current scoreboard for this league: every drafted participant, regardless of
|
||||
// whether their QP changed this sync. Drives the Drafted Participants section so
|
||||
// it reads as a season-standings snapshot rather than only this event's movers.
|
||||
// Never pinged, so ownerDiscordUserId is intentionally omitted.
|
||||
const scoreboard: QPEventEntry[] = [...teamByParticipantId.keys()]
|
||||
.filter((participantId) => sportsSeasonParticipantIds.has(participantId))
|
||||
.map((participantId) => {
|
||||
const ownerId = teamByParticipantId.get(participantId)?.ownerId ?? null;
|
||||
const globalRank = globalRankById.get(participantId) ?? 0;
|
||||
return {
|
||||
participantName: participantNameById.get(participantId) ?? participantId,
|
||||
qpEarned: qpEarnedById.get(participantId) ?? 0,
|
||||
qpTotal: qpTotalById.get(participantId) ?? 0,
|
||||
globalRank,
|
||||
globalRankTied: (countByRank.get(globalRank) ?? 0) > 1,
|
||||
ownerUsername: ownerId ? (usernameByUserId.get(ownerId) ?? undefined) : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl,
|
||||
seasonName,
|
||||
sportName,
|
||||
eventName,
|
||||
entries,
|
||||
eliminated,
|
||||
scoreboard,
|
||||
standingsUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -7,8 +7,10 @@ import {
|
|||
simulateChampionsStage,
|
||||
calcStage3ExitQP,
|
||||
simulateOneMajor,
|
||||
reconcileAdvancers,
|
||||
} from "../cs-major-simulator";
|
||||
import type { AdvancedTeam } from "../cs-major-simulator";
|
||||
import { buildSpTranslator } from "../shared-major";
|
||||
import type { AdvancedTeam, BracketMatchInput, SwissMatchInput, StageResultsMap } from "../cs-major-simulator";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -542,3 +544,344 @@ describe("not-participating exclusion (CS2)", () => {
|
|||
expect(resultEvent2.has(excluded.id)).toBe(false); // excluded from event 2
|
||||
});
|
||||
});
|
||||
|
||||
// ─── simulateSwiss partial-stage locking (initialRecords) ─────────────────────
|
||||
|
||||
describe("simulateSwiss with initialRecords (partial stage locking)", () => {
|
||||
it("locks in seeded results: 3-loss team stays eliminated, 3-win team stays advanced", () => {
|
||||
const teams = makeTeams(16);
|
||||
// Two pre-resolved teams keeps the remaining active count even.
|
||||
const initial = new Map([
|
||||
["team-0", { wins: 1, losses: 3 }], // eliminated, carries 1 win
|
||||
["team-15", { wins: 3, losses: 2 }], // advanced, carries 2 losses
|
||||
]);
|
||||
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const r = simulateSwiss(teams, false, false, initial);
|
||||
const elim0 = r.eliminated.find((t) => t.id === "team-0");
|
||||
const adv15 = r.advanced.find((t) => t.id === "team-15");
|
||||
expect(elim0).toBeDefined();
|
||||
expect(elim0?.wins).toBe(1); // recorded win count preserved
|
||||
expect(r.advanced.some((t) => t.id === "team-0")).toBe(false);
|
||||
expect(adv15).toBeDefined();
|
||||
expect(adv15?.losses).toBe(2); // recorded loss count preserved
|
||||
expect(r.eliminated.some((t) => t.id === "team-15")).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("a fresh stage (empty initialRecords) still returns 8 advanced / 8 eliminated", () => {
|
||||
const r = simulateSwiss(makeTeams(16), false, false, new Map());
|
||||
expect(r.advanced).toHaveLength(8);
|
||||
expect(r.eliminated).toHaveLength(8);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── reconcileAdvancers (ragged-snapshot repair, protecting locked teams) ─────
|
||||
|
||||
const adv = (id: string, losses: number, rank: number): AdvancedTeam => ({ id, elo: 1500, rank, losses });
|
||||
const elim = (id: string, wins: number, rank: number) => ({ id, elo: 1500, rank, wins });
|
||||
|
||||
describe("reconcileAdvancers", () => {
|
||||
|
||||
it("is a no-op when the advancer count already matches the target", () => {
|
||||
const input = {
|
||||
advanced: [adv("a", 0, 1), adv("b", 1, 2)],
|
||||
eliminated: [elim("c", 2, 3)],
|
||||
};
|
||||
expect(reconcileAdvancers(input, 2)).toBe(input); // same reference, untouched
|
||||
});
|
||||
|
||||
it("demotes simulated advancers before locked ones when over-filled", () => {
|
||||
// 3 advancers, target 2. The locked team is the weakest (most losses) so a
|
||||
// naive demotion would drop it; protection must keep it and demote a
|
||||
// simulated team instead.
|
||||
const input = {
|
||||
advanced: [adv("sim-strong", 0, 1), adv("sim-weak", 2, 3), adv("locked", 2, 2)],
|
||||
eliminated: [] as Array<{ id: string; elo: number; rank: number; wins: number }>,
|
||||
};
|
||||
const result = reconcileAdvancers(input, 2, new Set(["locked"]));
|
||||
expect(result.advanced.map((t) => t.id).toSorted()).toEqual(["locked", "sim-strong"]);
|
||||
expect(result.eliminated.map((t) => t.id)).toEqual(["sim-weak"]);
|
||||
});
|
||||
|
||||
it("promotes non-locked eliminated teams before locked ones when under-filled", () => {
|
||||
// 1 advancer, target 2 → must promote one eliminated team. The locked
|
||||
// (real) elimination must be preserved; the non-locked team is promoted
|
||||
// even though it is the weaker (fewer wins) of the two.
|
||||
const input = {
|
||||
advanced: [adv("a", 0, 1)],
|
||||
eliminated: [elim("locked-strong", 2, 2), elim("sim-weak", 0, 3)],
|
||||
};
|
||||
const result = reconcileAdvancers(input, 2, new Set(["locked-strong"]));
|
||||
expect(result.advanced.map((t) => t.id).toSorted()).toEqual(["a", "sim-weak"]);
|
||||
expect(result.eliminated.map((t) => t.id)).toEqual(["locked-strong"]);
|
||||
});
|
||||
|
||||
it("falls back to locked teams only when non-locked candidates run out", () => {
|
||||
// 3 advancers, target 1, all locked → forced to demote locked teams,
|
||||
// keeping the strongest (fewest losses).
|
||||
const input = {
|
||||
advanced: [adv("x", 2, 3), adv("y", 0, 1), adv("z", 1, 2)],
|
||||
eliminated: [] as Array<{ id: string; elo: number; rank: number; wins: number }>,
|
||||
};
|
||||
const result = reconcileAdvancers(input, 1, new Set(["x", "y", "z"]));
|
||||
expect(result.advanced.map((t) => t.id)).toEqual(["y"]); // strongest kept
|
||||
});
|
||||
});
|
||||
|
||||
// ─── simulateChampionsStage honoring a real bracket ───────────────────────────
|
||||
|
||||
function makeChampTeams(): AdvancedTeam[] {
|
||||
// a strongest … h weakest, all 0 losses.
|
||||
return ["a", "b", "c", "d", "e", "f", "g", "h"].map((id, i) => ({
|
||||
id,
|
||||
elo: 2000 - i * 100,
|
||||
rank: i + 1,
|
||||
losses: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Bracket where the weakest team (h) wins everything, contradicting Elo. */
|
||||
function makeFullBracket(): BracketMatchInput[] {
|
||||
return [
|
||||
{ round: "Quarterfinals", matchNumber: 1, participant1Id: "a", participant2Id: "h", winnerId: "h", isComplete: true },
|
||||
{ round: "Quarterfinals", matchNumber: 2, participant1Id: "b", participant2Id: "g", winnerId: "g", isComplete: true },
|
||||
{ round: "Quarterfinals", matchNumber: 3, participant1Id: "c", participant2Id: "f", winnerId: "f", isComplete: true },
|
||||
{ round: "Quarterfinals", matchNumber: 4, participant1Id: "d", participant2Id: "e", winnerId: "e", isComplete: true },
|
||||
{ round: "Semifinals", matchNumber: 1, participant1Id: "h", participant2Id: "g", winnerId: "h", isComplete: true },
|
||||
{ round: "Semifinals", matchNumber: 2, participant1Id: "f", participant2Id: "e", winnerId: "f", isComplete: true },
|
||||
{ round: "Finals", matchNumber: 1, participant1Id: "h", participant2Id: "f", winnerId: "h", isComplete: true },
|
||||
];
|
||||
}
|
||||
|
||||
describe("simulateChampionsStage honoring a real bracket", () => {
|
||||
it("a fully completed bracket yields deterministic placements (ignores Elo)", () => {
|
||||
const teams = makeChampTeams();
|
||||
const bracket = makeFullBracket();
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const result = simulateChampionsStage(teams, bracket);
|
||||
expect(result.placements.get("h")).toBe(1); // weakest team won it all
|
||||
expect(result.placements.get("f")).toBe(2);
|
||||
expect(result.placements.get("g")).toBe(3); // SF losers
|
||||
expect(result.placements.get("e")).toBe(3);
|
||||
for (const id of ["a", "b", "c", "d"]) { // QF losers
|
||||
expect(result.placements.get(id)).toBe(5);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("a partially completed bracket fixes decided rounds, randomizes only the rest", () => {
|
||||
const teams = makeChampTeams();
|
||||
// QF + SF complete, Final undecided → finalists fixed (h, f), champion varies.
|
||||
const bracket = makeFullBracket().map((m) =>
|
||||
m.round === "Finals" ? { ...m, winnerId: null, isComplete: false } : m
|
||||
);
|
||||
|
||||
const championSeen = new Set<number>();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const result = simulateChampionsStage(teams, bracket);
|
||||
// SF losers and QF losers are locked regardless of the Final.
|
||||
expect(result.placements.get("g")).toBe(3);
|
||||
expect(result.placements.get("e")).toBe(3);
|
||||
for (const id of ["a", "b", "c", "d"]) expect(result.placements.get(id)).toBe(5);
|
||||
// Champion/finalist are always the two SF winners.
|
||||
const champion = [...result.placements.entries()].find(([, p]) => p === 1)?.[0];
|
||||
const finalist = [...result.placements.entries()].find(([, p]) => p === 2)?.[0];
|
||||
expect(["h", "f"]).toContain(champion);
|
||||
expect(["h", "f"]).toContain(finalist);
|
||||
expect(champion).not.toBe(finalist);
|
||||
championSeen.add(result.placements.get("h") === 1 ? 1 : 0);
|
||||
}
|
||||
// Over 100 runs with the Final undecided, h should win at least once and lose
|
||||
// at least once (it's the weaker of the two finalists by Elo but not certain).
|
||||
expect(championSeen.size).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("falls back to Elo-based seeding when no bracket is provided", () => {
|
||||
const teams = makeChampTeams();
|
||||
let aWins = 0;
|
||||
for (let i = 0; i < 500; i++) {
|
||||
if (simulateChampionsStage(teams).placements.get("a") === 1) aWins++;
|
||||
}
|
||||
expect(aWins / 500).toBeGreaterThan(0.25); // strongest team wins well above 1/8
|
||||
});
|
||||
|
||||
it("ignores recorded results when the bracket's teams are not this field", () => {
|
||||
// Bracket describes a different set of teams (x1…x8) than the actual
|
||||
// qualifiers (a…h), so it is not a usable real bracket: recorded winners
|
||||
// must not bleed into the Elo-seeded simulation of the real field.
|
||||
const teams = makeChampTeams();
|
||||
const foreignBracket: BracketMatchInput[] = makeFullBracket().map((m) => ({
|
||||
...m,
|
||||
participant1Id: m.participant1Id ? `x-${m.participant1Id}` : null,
|
||||
participant2Id: m.participant2Id ? `x-${m.participant2Id}` : null,
|
||||
winnerId: m.winnerId ? `x-${m.winnerId}` : null,
|
||||
}));
|
||||
|
||||
let aWins = 0;
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const result = simulateChampionsStage(teams, foreignBracket);
|
||||
// All 8 real teams still get a placement (the bracket was not applied).
|
||||
expect(result.placements.size).toBe(8);
|
||||
if (result.placements.get("a") === 1) aWins++;
|
||||
}
|
||||
// Strongest real team wins well above 1/8 → Elo seeding drove it, not the
|
||||
// foreign bracket (which would have forced its own — absent — winner).
|
||||
expect(aWins / 500).toBeGreaterThan(0.25);
|
||||
});
|
||||
|
||||
it("discards the whole bracket when a single QF participant is not in the field", () => {
|
||||
// Regression for IEM Cologne 2026: the Champions field held "Spirit Academy"
|
||||
// while the bracket seeded the (different) "Team Spirit" participant in QF1.
|
||||
// The realBracket gate is all-or-nothing, so one out-of-field participant
|
||||
// makes the entire bracket — including the 3 valid completed QFs — be ignored
|
||||
// and the stage falls back to Elo seeding. (When this changes to per-match
|
||||
// honoring, update this test deliberately.)
|
||||
const teams = makeChampTeams();
|
||||
const bracket = makeFullBracket().map((mm) =>
|
||||
mm.round === "Quarterfinals" && mm.matchNumber === 1
|
||||
? { ...mm, participant1Id: "ghost-not-in-field" }
|
||||
: mm
|
||||
);
|
||||
|
||||
let hWins = 0; // h wins everything if (and only if) the bracket is honored.
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const result = simulateChampionsStage(teams, bracket);
|
||||
expect(result.placements.size).toBe(8);
|
||||
if (result.placements.get("h") === 1) hWins++;
|
||||
}
|
||||
// Weakest team h would be champion every run if the bracket applied; under
|
||||
// Elo fallback it wins far less than the ~1.0 a honored bracket would force.
|
||||
expect(hWins / 500).toBeLessThan(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── simulateOneMajor full conditioning (stages + bracket + recorded QP) ──────
|
||||
|
||||
/**
|
||||
* Stage results that fully lock the field down to 8 Champions Stage qualifiers
|
||||
* (team-24…team-31): team-0–7 out at stage 1, team-8–15 out at stage 2,
|
||||
* team-16–23 out at stage 3.
|
||||
*/
|
||||
function makeLockedStageResults(): StageResultsMap {
|
||||
const stageResults: StageResultsMap = new Map();
|
||||
for (let i = 0; i < 8; i++) stageResults.set(`team-${i}`, { stageEntry: 1, stageEliminated: 1, stageEliminatedWins: 1 });
|
||||
for (let i = 8; i < 16; i++) stageResults.set(`team-${i}`, { stageEntry: 1, stageEliminated: 2, stageEliminatedWins: 1 });
|
||||
for (let i = 16; i < 24; i++) stageResults.set(`team-${i}`, { stageEntry: 2, stageEliminated: 3, stageEliminatedWins: 1 });
|
||||
for (let i = 24; i < 32; i++) stageResults.set(`team-${i}`, { stageEntry: 3, stageEliminated: null, stageEliminatedWins: null });
|
||||
return stageResults;
|
||||
}
|
||||
|
||||
const m = (round: string, matchNumber: number, p1: string, p2: string, winner: string): BracketMatchInput =>
|
||||
({ round, matchNumber, participant1Id: p1, participant2Id: p2, winnerId: winner, isComplete: true });
|
||||
|
||||
/** Champions bracket among team-24…team-31 where team-31 wins everything. */
|
||||
function makeQualifierBracket(): BracketMatchInput[] {
|
||||
return [
|
||||
m("Quarterfinals", 1, "team-31", "team-24", "team-31"),
|
||||
m("Quarterfinals", 2, "team-30", "team-25", "team-30"),
|
||||
m("Quarterfinals", 3, "team-29", "team-26", "team-29"),
|
||||
m("Quarterfinals", 4, "team-28", "team-27", "team-28"),
|
||||
m("Semifinals", 1, "team-31", "team-30", "team-31"),
|
||||
m("Semifinals", 2, "team-29", "team-28", "team-29"),
|
||||
m("Finals", 1, "team-31", "team-29", "team-31"),
|
||||
];
|
||||
}
|
||||
|
||||
const loss = (loser: string, winner: string): SwissMatchInput =>
|
||||
({ matchStage: 1, participant1Id: loser, participant2Id: winner, winnerId: winner });
|
||||
|
||||
describe("simulateOneMajor full conditioning", () => {
|
||||
const qpConfig = makeQPConfig();
|
||||
|
||||
it("honors a completed bracket: the recorded champion deterministically earns 1st-place QP", () => {
|
||||
const pool = makeTeams(32);
|
||||
const stageResults = makeLockedStageResults();
|
||||
const bracket = makeQualifierBracket();
|
||||
|
||||
for (let run = 0; run < 10; run++) {
|
||||
const result = simulateOneMajor(pool, stageResults, qpConfig, { bracketMatches: bracket });
|
||||
expect(result.get("team-31")).toBe(qpConfig.get(1)); // 500, the champion
|
||||
// team-29 lost the final → 2nd place QP (400).
|
||||
expect(result.get("team-29")).toBe(qpConfig.get(2));
|
||||
// Stage 1 exits earn nothing.
|
||||
for (let i = 0; i < 8; i++) expect(result.get(`team-${i}`)).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses recorded provisional QP verbatim for locked-eliminated teams", () => {
|
||||
const pool = makeTeams(32);
|
||||
const stageResults = makeLockedStageResults();
|
||||
const recordedResults = new Map([["team-0", 123]]); // a stage-1 exit (normally 0)
|
||||
|
||||
const result = simulateOneMajor(pool, stageResults, qpConfig, { recordedResults });
|
||||
expect(result.get("team-0")).toBe(123); // recorded value overrides the derived 0
|
||||
expect(result.get("team-1")).toBe(0); // no recorded entry → derived value stays
|
||||
});
|
||||
|
||||
it("locks in partial stage progress from Swiss match results", () => {
|
||||
const pool = makeTeams(32);
|
||||
// Stage entrants assigned, but NO eliminations recorded (stage 1 incomplete).
|
||||
const stageResults: StageResultsMap = new Map();
|
||||
for (let i = 0; i < 16; i++) stageResults.set(`team-${i}`, { stageEntry: 1, stageEliminated: null, stageEliminatedWins: null });
|
||||
for (let i = 16; i < 24; i++) stageResults.set(`team-${i}`, { stageEntry: 2, stageEliminated: null, stageEliminatedWins: null });
|
||||
for (let i = 24; i < 32; i++) stageResults.set(`team-${i}`, { stageEntry: 3, stageEliminated: null, stageEliminatedWins: null });
|
||||
|
||||
// Swiss results give team-0 and team-1 three stage-1 losses each (eliminated).
|
||||
const swissMatches: SwissMatchInput[] = [
|
||||
loss("team-0", "team-2"), loss("team-0", "team-3"), loss("team-0", "team-4"),
|
||||
loss("team-1", "team-5"), loss("team-1", "team-6"), loss("team-1", "team-7"),
|
||||
];
|
||||
|
||||
for (let run = 0; run < 20; run++) {
|
||||
const result = simulateOneMajor(pool, stageResults, qpConfig, { swissMatches });
|
||||
// Teams with 3 recorded stage-1 losses are eliminated → 0 QP, never champions.
|
||||
expect(result.get("team-0")).toBe(0);
|
||||
expect(result.get("team-1")).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── buildSpTranslator (shared-major id bridging) ───────────────────────────────
|
||||
|
||||
describe("buildSpTranslator", () => {
|
||||
const primary = [
|
||||
{ id: "P-1", participantId: "C-A" },
|
||||
{ id: "P-2", participantId: "C-B" },
|
||||
{ id: "P-3", participantId: "C-C" },
|
||||
];
|
||||
const local = [
|
||||
{ id: "L-1", participantId: "C-A" },
|
||||
{ id: "L-2", participantId: "C-B" },
|
||||
{ id: "L-3", participantId: "C-C" },
|
||||
];
|
||||
|
||||
it("maps primary season_participant ids to the local window's via canonical", () => {
|
||||
const tr = buildSpTranslator(primary, local);
|
||||
expect(tr("P-1")).toBe("L-1");
|
||||
expect(tr("P-2")).toBe("L-2");
|
||||
expect(tr("P-3")).toBe("L-3");
|
||||
});
|
||||
|
||||
it("passes null/undefined through unchanged", () => {
|
||||
const tr = buildSpTranslator(primary, local);
|
||||
expect(tr(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("passes ids through when no canonical link exists on the primary side", () => {
|
||||
const tr = buildSpTranslator(
|
||||
[{ id: "P-X", participantId: null }],
|
||||
local
|
||||
);
|
||||
expect(tr("P-X")).toBe("P-X");
|
||||
});
|
||||
|
||||
it("passes ids through when the local window has no counterpart", () => {
|
||||
const tr = buildSpTranslator(primary, [
|
||||
{ id: "L-1", participantId: "C-A" },
|
||||
// C-B and C-C absent from the local field
|
||||
]);
|
||||
expect(tr("P-1")).toBe("L-1"); // bridged
|
||||
expect(tr("P-2")).toBe("P-2"); // no local counterpart → unchanged
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { resolveRatings, resolveSourceElos } from "../input-policy";
|
||||
import {
|
||||
getSimulatorInputPolicy,
|
||||
resolveRatings,
|
||||
resolveSourceElos,
|
||||
DEFAULT_BASE_ELO_PRIORITY,
|
||||
} from "../input-policy";
|
||||
import type { SimulatorManifestProfile } from "../manifest";
|
||||
|
||||
const profile = {
|
||||
|
|
@ -77,6 +82,54 @@ describe("simulator input policy", () => {
|
|||
expect(resolved.get("tail")).toMatchObject({ sourceElo: 1400, method: "worstKnownMinus" });
|
||||
});
|
||||
|
||||
it("derives Elo from futures odds when no direct Elo is present", () => {
|
||||
const resolved = resolveSourceElos(
|
||||
[
|
||||
{ participantId: "favorite", sourceElo: null, rating: null, sourceOdds: 300, projectedWins: null, projectedTablePoints: null },
|
||||
{ participantId: "longshot", sourceElo: null, rating: null, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null },
|
||||
],
|
||||
profile,
|
||||
{}
|
||||
);
|
||||
|
||||
expect(resolved.get("favorite")?.method).toBe("sourceOdds");
|
||||
expect(resolved.get("longshot")?.method).toBe("sourceOdds");
|
||||
expect(resolved.get("favorite")?.sourceElo).toBeGreaterThan(resolved.get("longshot")?.sourceElo ?? Infinity);
|
||||
});
|
||||
|
||||
it("ignores a lone sourceOdds participant rather than assigning a flat Elo", () => {
|
||||
const inputs = [
|
||||
{ participantId: "only", sourceElo: null, rating: null, sourceOdds: 300, projectedWins: null, projectedTablePoints: null },
|
||||
];
|
||||
|
||||
// Needs at least 2 odds to derive a spread; with block strategy it stays unresolved.
|
||||
expect(resolveSourceElos(inputs, profile, {}).has("only")).toBe(false);
|
||||
|
||||
// With a fallback strategy it resolves via that method, not "sourceOdds".
|
||||
expect(
|
||||
resolveSourceElos(inputs, profile, {
|
||||
inputPolicy: { missingEloStrategy: "fallbackElo", fallbackElo: 1400 },
|
||||
}).get("only")
|
||||
).toMatchObject({ sourceElo: 1400, method: "fallbackElo" });
|
||||
});
|
||||
|
||||
it("derives Elo from odds once a prior direct Elo has been cleared", () => {
|
||||
// Regression: after entering futures odds the simulator inputs row keeps
|
||||
// sourceOdds but has its sourceElo nulled. The resolver must pick "sourceOdds",
|
||||
// not resurrect a "direct" Elo.
|
||||
const resolved = resolveSourceElos(
|
||||
[
|
||||
{ participantId: "favorite", sourceElo: null, rating: null, sourceOdds: 300, projectedWins: null, projectedTablePoints: null },
|
||||
{ participantId: "longshot", sourceElo: null, rating: null, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null },
|
||||
],
|
||||
profile,
|
||||
{}
|
||||
);
|
||||
|
||||
expect(resolved.get("favorite")?.method).toBe("sourceOdds");
|
||||
expect(resolved.get("longshot")?.method).toBe("sourceOdds");
|
||||
});
|
||||
|
||||
it("derives ratings from futures odds when the profile allows it", () => {
|
||||
const resolved = resolveRatings(
|
||||
[
|
||||
|
|
@ -135,6 +188,113 @@ describe("simulator input policy", () => {
|
|||
expect(resolved.get("missing-tail")).toMatchObject({ rating: -10, method: "worstKnownMinus" });
|
||||
});
|
||||
|
||||
it("does not clamp a directly entered Elo above the policy ceiling", () => {
|
||||
// Snooker/darts enter manual Elos well above the default 1900 ceiling and
|
||||
// have no odds source — the direct value must pass through unclamped.
|
||||
const eloOnlyProfile = {} as Pick<SimulatorManifestProfile, "derivableInputs">;
|
||||
const resolved = resolveSourceElos(
|
||||
[
|
||||
{ participantId: "ronnie", sourceElo: 2450, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null },
|
||||
{ participantId: "judd", sourceElo: 2300, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null },
|
||||
],
|
||||
eloOnlyProfile,
|
||||
{}
|
||||
);
|
||||
|
||||
expect(resolved.get("ronnie")).toMatchObject({ sourceElo: 2450, method: "direct" });
|
||||
expect(resolved.get("judd")).toMatchObject({ sourceElo: 2300, method: "direct" });
|
||||
});
|
||||
|
||||
it("blends base Elo and futures odds into a single Elo by oddsWeight", () => {
|
||||
const inputs = [
|
||||
{ participantId: "favorite", sourceElo: 1800, rating: null, sourceOdds: 300, projectedWins: null, projectedTablePoints: null },
|
||||
{ participantId: "longshot", sourceElo: 1200, rating: null, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null },
|
||||
];
|
||||
|
||||
// oddsWeight 0: the stored base Elo wins outright.
|
||||
const eloOnly = resolveSourceElos(inputs, profile, { inputPolicy: { oddsWeight: 0 } });
|
||||
expect(eloOnly.get("favorite")).toMatchObject({ sourceElo: 1800, method: "direct" });
|
||||
|
||||
// oddsWeight 1: futures fully override the base Elo.
|
||||
const oddsOnly = resolveSourceElos(inputs, profile, { inputPolicy: { oddsWeight: 1 } });
|
||||
expect(oddsOnly.get("favorite")?.method).toBe("sourceOdds");
|
||||
expect(oddsOnly.get("longshot")?.method).toBe("sourceOdds");
|
||||
|
||||
// Between: a blend that sits strictly between the base and the odds-derived Elo.
|
||||
const oddsElo = oddsOnly.get("favorite")?.sourceElo ?? 0;
|
||||
const blended = resolveSourceElos(inputs, profile, { inputPolicy: { oddsWeight: 0.5 } });
|
||||
expect(blended.get("favorite")?.method).toBe("blend");
|
||||
const blendedElo = blended.get("favorite")?.sourceElo ?? 0;
|
||||
expect(blendedElo).toBeGreaterThan(Math.min(1800, oddsElo));
|
||||
expect(blendedElo).toBeLessThan(Math.max(1800, oddsElo));
|
||||
expect(blendedElo).toBeCloseTo(Math.round(0.5 * 1800 + 0.5 * oddsElo), 0);
|
||||
});
|
||||
|
||||
it("blends projected wins with futures odds when both are present", () => {
|
||||
const inputs = [
|
||||
{ participantId: "team-1", sourceElo: null, rating: null, sourceOdds: 300, projectedWins: 60, projectedTablePoints: null },
|
||||
{ participantId: "team-2", sourceElo: null, rating: null, sourceOdds: 20000, projectedWins: 20, projectedTablePoints: null },
|
||||
];
|
||||
|
||||
// oddsWeight 0: projectedWins is the base.
|
||||
expect(resolveSourceElos(inputs, profile, { seasonGames: 82, inputPolicy: { oddsWeight: 0 } }).get("team-1")?.method)
|
||||
.toBe("projectedWins");
|
||||
|
||||
// oddsWeight 1: futures override the projection.
|
||||
expect(resolveSourceElos(inputs, profile, { seasonGames: 82, inputPolicy: { oddsWeight: 1 } }).get("team-1")?.method)
|
||||
.toBe("sourceOdds");
|
||||
|
||||
// Between: blended.
|
||||
expect(resolveSourceElos(inputs, profile, { seasonGames: 82, inputPolicy: { oddsWeight: 0.4 } }).get("team-1")?.method)
|
||||
.toBe("blend");
|
||||
});
|
||||
|
||||
it("blends a direct rating with futures odds by oddsWeight", () => {
|
||||
const ratingProfile = { derivableInputs: { rating: ["sourceOdds"] } } as Pick<SimulatorManifestProfile, "derivableInputs">;
|
||||
const inputs = [
|
||||
{ participantId: "favorite", sourceElo: null, rating: 30, sourceOdds: 300, projectedWins: null, projectedTablePoints: null },
|
||||
{ participantId: "longshot", sourceElo: null, rating: -5, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null },
|
||||
];
|
||||
|
||||
// oddsWeight 0: direct rating wins.
|
||||
expect(resolveRatings(inputs, ratingProfile, { inputPolicy: { ratingMin: -10, ratingMax: 35, oddsWeight: 0 } }).get("favorite"))
|
||||
.toMatchObject({ rating: 30, method: "direct" });
|
||||
|
||||
// oddsWeight 1: odds override the rating.
|
||||
expect(
|
||||
resolveRatings(inputs, ratingProfile, {
|
||||
inputPolicy: { ratingMin: -10, ratingMax: 35, oddsWeight: 1 },
|
||||
}).get("favorite")?.method
|
||||
).toBe("sourceOdds");
|
||||
|
||||
// Between: blended.
|
||||
expect(
|
||||
resolveRatings(inputs, ratingProfile, {
|
||||
inputPolicy: { ratingMin: -10, ratingMax: 35, oddsWeight: 0.5 },
|
||||
}).get("favorite")?.method
|
||||
).toBe("blend");
|
||||
});
|
||||
|
||||
it("parses and clamps the base Elo priority and odds weight", () => {
|
||||
const defaults = getSimulatorInputPolicy({});
|
||||
expect(defaults.baseEloPriority).toEqual(DEFAULT_BASE_ELO_PRIORITY);
|
||||
expect(defaults.oddsWeight).toBe(0.3);
|
||||
|
||||
// oddsWeight lives under inputPolicy and is clamped to [0, 1]; a top-level
|
||||
// config.oddsWeight is ignored (single home); junk base-priority entries drop.
|
||||
expect(getSimulatorInputPolicy({ inputPolicy: { oddsWeight: 0.6 } }).oddsWeight).toBe(0.6);
|
||||
expect(getSimulatorInputPolicy({ oddsWeight: 0.6 }).oddsWeight).toBe(0.3);
|
||||
expect(getSimulatorInputPolicy({ inputPolicy: { oddsWeight: 5 } }).oddsWeight).toBe(1);
|
||||
expect(getSimulatorInputPolicy({ inputPolicy: { oddsWeight: -2 } }).oddsWeight).toBe(0);
|
||||
expect(
|
||||
getSimulatorInputPolicy({ inputPolicy: { baseEloPriority: ["projectedWins", "sourceOdds", "sourceElo"] } }).baseEloPriority
|
||||
).toEqual(["projectedWins", "sourceElo"]);
|
||||
// An empty/all-invalid priority falls back to the default.
|
||||
expect(getSimulatorInputPolicy({ inputPolicy: { baseEloPriority: ["nope"] } }).baseEloPriority).toEqual(
|
||||
DEFAULT_BASE_ELO_PRIORITY
|
||||
);
|
||||
});
|
||||
|
||||
it("uses NCAAW-style rating scale when deriving and falling back", () => {
|
||||
const resolved = resolveRatings(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||
import * as schema from "~/database/schema";
|
||||
import { SIMULATOR_MANIFEST } from "../manifest";
|
||||
import { SIMULATOR_TYPES } from "../registry";
|
||||
import { getSimulatorInputPolicy } from "../input-policy";
|
||||
import { assertRegistrySchemaDriftFree } from "~/models/simulator";
|
||||
|
||||
describe("simulator manifest", () => {
|
||||
|
|
@ -40,6 +41,17 @@ describe("simulator manifest", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("exposes the per-profile futures blend weight through the input policy", () => {
|
||||
expect(getSimulatorInputPolicy(SIMULATOR_MANIFEST.ncaa_football_bracket.defaultConfig).oddsWeight).toBe(0.4);
|
||||
expect(getSimulatorInputPolicy(SIMULATOR_MANIFEST.world_cup.defaultConfig).oddsWeight).toBe(0.3);
|
||||
expect(getSimulatorInputPolicy(SIMULATOR_MANIFEST.ucl_bracket.defaultConfig).oddsWeight).toBe(0.3);
|
||||
// College hockey blends odds internally, so the central blend is disabled.
|
||||
expect(getSimulatorInputPolicy(SIMULATOR_MANIFEST.college_hockey_bracket.defaultConfig).oddsWeight).toBe(0);
|
||||
// Every profile resolves to the default base-Elo priority unless overridden.
|
||||
expect(getSimulatorInputPolicy(SIMULATOR_MANIFEST.nba_bracket.defaultConfig).baseEloPriority)
|
||||
.toEqual(["sourceElo", "projectedWins", "projectedTablePoints"]);
|
||||
});
|
||||
|
||||
it("keeps EPL projection and match parity as separate config knobs", () => {
|
||||
expect(SIMULATOR_MANIFEST.epl_standings.defaultConfig).toMatchObject({
|
||||
parityFactor: 400,
|
||||
|
|
|
|||
|
|
@ -143,8 +143,9 @@ describe("NCAAFootballSimulator", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("uses futures odds when sourceOdds present", async () => {
|
||||
setupMockDb(TEAM_IDS, { includeOdds: true });
|
||||
it("runs from the resolved Elo (futures already blended upstream)", async () => {
|
||||
// sourceElo is the single resolved Elo; the sim no longer reads sourceOdds.
|
||||
setupMockDb(TEAM_IDS);
|
||||
const results = await new NCAAFootballSimulator().simulate("season-1");
|
||||
expect(results).toHaveLength(12);
|
||||
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
||||
|
|
|
|||
|
|
@ -119,6 +119,13 @@ describe("eloWinProbability (PARITY_FACTOR = 1000)", () => {
|
|||
const p = eloWinProbability(1700, 1500);
|
||||
expect(p).toBeCloseTo(0.613, 2);
|
||||
});
|
||||
|
||||
it("a higher parity factor flattens the per-game win probability toward 0.5", () => {
|
||||
const standard = eloWinProbability(1700, 1500); // default parity 1000
|
||||
const flatter = eloWinProbability(1700, 1500, 2500); // season config override
|
||||
expect(flatter).toBeLessThan(standard);
|
||||
expect(flatter).toBeGreaterThan(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── simulateProjectedPoints ──────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type * as SimulationsRegistry from "~/services/simulations/registry";
|
||||
|
||||
vi.mock("~/models/sports-season", () => ({
|
||||
findSportsSeasonById: vi.fn(),
|
||||
|
|
@ -21,9 +22,12 @@ vi.mock("~/models/ev-snapshot", () => ({
|
|||
vi.mock("~/models/scoring-calculator", () => ({
|
||||
recalculateStandings: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/services/simulations/registry", () => ({
|
||||
getSimulator: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/services/simulations/registry", async (importOriginal) => {
|
||||
// Keep the real SIMULATOR_TYPES / getSimulatorInfo so the manifest (pulled in
|
||||
// transitively via input-policy) can build; only stub getSimulator.
|
||||
const actual = await importOriginal<typeof SimulationsRegistry>();
|
||||
return { ...actual, getSimulator: vi.fn() };
|
||||
});
|
||||
vi.mock("~/services/simulations/simulation-probabilities", () => ({
|
||||
normalizeSimulationResultColumns: vi.fn(),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -1,5 +1,15 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { eloWinProb, buildDraw, simulateMajor } from "../tennis-simulator";
|
||||
import {
|
||||
eloWinProb,
|
||||
buildDraw,
|
||||
simulateMajor,
|
||||
drawFromBracket,
|
||||
buildHonoredMap,
|
||||
DEFAULT_SLAM_QP,
|
||||
type RealBracketMatch,
|
||||
} from "../tennis-simulator";
|
||||
import { deriveBracketQualifyingStates, getRoundConfig } from "~/models/scoring-calculator";
|
||||
import { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
|
||||
|
||||
// ─── eloWinProb ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -337,3 +347,187 @@ describe("QP accumulation ranking (unit)", () => {
|
|||
expect(winRate).toBeGreaterThan(0.02); // > 2% (well above random 0.78%)
|
||||
});
|
||||
});
|
||||
|
||||
// ─── drawFromBracket ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("drawFromBracket", () => {
|
||||
const ids = makeIds(128);
|
||||
// 64 Round-of-128 matches: match N holds ids[2N-2], ids[2N-1].
|
||||
const fullR128 = Array.from({ length: 64 }, (_, i) => ({
|
||||
round: "Round of 128",
|
||||
matchNumber: i + 1,
|
||||
participant1Id: ids[i * 2],
|
||||
participant2Id: ids[i * 2 + 1],
|
||||
}));
|
||||
|
||||
it("reconstructs a 128-slot draw in match order", () => {
|
||||
const draw = drawFromBracket(fullR128, (id) => id);
|
||||
expect(draw).not.toBeNull();
|
||||
expect(draw).toHaveLength(128);
|
||||
expect(draw?.[0]).toBe("p001");
|
||||
expect(draw?.[1]).toBe("p002");
|
||||
expect(draw?.[127]).toBe("p128");
|
||||
});
|
||||
|
||||
it("returns null when the draw is not fully populated", () => {
|
||||
const partial = fullR128.slice(0, 60); // only 60 of 64 matches
|
||||
expect(drawFromBracket(partial, (id) => id)).toBeNull();
|
||||
const withNull = fullR128.map((m, i) =>
|
||||
i === 0 ? { ...m, participant1Id: null } : m
|
||||
);
|
||||
expect(drawFromBracket(withNull, (id) => id)).toBeNull();
|
||||
});
|
||||
|
||||
it("applies the id translator", () => {
|
||||
const draw = drawFromBracket(fullR128, (id) => (id ? `x-${id}` : id));
|
||||
expect(draw?.[0]).toBe("x-p001");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── simulateMajor honoring a real bracket ──────────────────────────────────────
|
||||
|
||||
describe("simulateMajor with realBracket", () => {
|
||||
const ids = makeIds(128);
|
||||
const eloMap = makeEloMap(ids, ids.map(() => 1500));
|
||||
|
||||
// A fully-decided bracket where the lower draw index always wins. Champion is
|
||||
// therefore ids[0]; the result is deterministic regardless of RNG.
|
||||
function decideBracket(draw: string[]): RealBracketMatch[] {
|
||||
const rounds = [
|
||||
"Round of 128", "Round of 64", "Round of 32", "Round of 16",
|
||||
"Quarterfinals", "Semifinals", "Final",
|
||||
];
|
||||
const order = new Map(draw.map((id, i) => [id, i]));
|
||||
const matches: RealBracketMatch[] = [];
|
||||
let current = [...draw];
|
||||
for (const round of rounds) {
|
||||
const next: string[] = [];
|
||||
for (let i = 0; i < current.length; i += 2) {
|
||||
const p1 = current[i];
|
||||
const p2 = current[i + 1];
|
||||
const winner = (order.get(p1) ?? 0) < (order.get(p2) ?? 0) ? p1 : p2;
|
||||
matches.push({ round, matchNumber: i / 2 + 1, winnerId: winner, isComplete: true });
|
||||
next.push(winner);
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
it("honors a fully-decided bracket deterministically (champion = draw[0])", () => {
|
||||
const draw = [...ids];
|
||||
const realBracket = decideBracket(draw);
|
||||
const results = simulateMajor(draw, eloMap, "hard", { realBracket });
|
||||
const champ = results.find((r) => r.qp === DEFAULT_SLAM_QP.winner);
|
||||
expect(champ?.participantId).toBe(draw[0]);
|
||||
// Structural counts are unchanged by honoring.
|
||||
expect(results.filter((r) => r.qp === DEFAULT_SLAM_QP.winner)).toHaveLength(1);
|
||||
expect(results.filter((r) => r.qp === DEFAULT_SLAM_QP.finalist)).toHaveLength(1);
|
||||
expect(results.filter((r) => r.qp === DEFAULT_SLAM_QP.sf)).toHaveLength(2);
|
||||
expect(results.filter((r) => r.qp === DEFAULT_SLAM_QP.qf)).toHaveLength(4);
|
||||
expect(results.filter((r) => r.qp === DEFAULT_SLAM_QP.r16)).toHaveLength(8);
|
||||
});
|
||||
|
||||
it("honors a completed Final winner even against a heavy Elo underdog", () => {
|
||||
// Underdog at draw[0] (low Elo) but the Final is recorded as their win.
|
||||
const draw = [...ids];
|
||||
const skewed = makeEloMap(draw, draw.map((_, i) => (i === 0 ? 100 : 3000)));
|
||||
const realBracket = decideBracket(draw); // draw[0] wins everything
|
||||
const results = simulateMajor(draw, skewed, "hard", { realBracket });
|
||||
const champ = results.find((r) => r.qp === DEFAULT_SLAM_QP.winner);
|
||||
expect(champ?.participantId).toBe(draw[0]);
|
||||
});
|
||||
|
||||
it("uses config QP values from opts.qp", () => {
|
||||
const draw = [...ids];
|
||||
const realBracket = decideBracket(draw);
|
||||
const qp = { winner: 100, finalist: 70, sf: 40, qf: 20, r16: 5 };
|
||||
const results = simulateMajor(draw, eloMap, "hard", { realBracket, qp });
|
||||
expect(results.find((r) => r.participantId === draw[0])?.qp).toBe(100);
|
||||
expect(results.filter((r) => r.qp === 70)).toHaveLength(1);
|
||||
expect(results.filter((r) => r.qp === 5)).toHaveLength(8);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── tennis_128 bracket scoring (QP placement derivation) ───────────────────────
|
||||
|
||||
describe("tennis_128 qualifying bracket scoring", () => {
|
||||
it("derives placements 1/2/3/5/9 from the tennis_128 round config", () => {
|
||||
const template = BRACKET_TEMPLATES.tennis_128;
|
||||
// A wins R16 → QF → SF → Final (champion). Losers exit at each scoring round.
|
||||
const matches = [
|
||||
{ round: "Round of 16", winnerId: "A", loserId: "I", participant1Id: "A", participant2Id: "I" },
|
||||
{ round: "Quarterfinals", winnerId: "A", loserId: "E", participant1Id: "A", participant2Id: "E" },
|
||||
{ round: "Semifinals", winnerId: "A", loserId: "C", participant1Id: "A", participant2Id: "C" },
|
||||
{ round: "Final", winnerId: "A", loserId: "B", participant1Id: "A", participant2Id: "B" },
|
||||
];
|
||||
const states = deriveBracketQualifyingStates(
|
||||
matches,
|
||||
template.rounds,
|
||||
(round) => getRoundConfig(round, "tennis_128")
|
||||
);
|
||||
expect(states.get("A")?.placement).toBe(1); // champion
|
||||
expect(states.get("B")?.placement).toBe(2); // Final loser
|
||||
expect(states.get("C")).toMatchObject({ placement: 3, tieCount: 2 }); // SF loser
|
||||
expect(states.get("E")).toMatchObject({ placement: 5, tieCount: 4 }); // QF loser
|
||||
expect(states.get("I")).toMatchObject({ placement: 9, tieCount: 8 }); // R16 loser
|
||||
});
|
||||
});
|
||||
|
||||
// ─── exclusions (not-participating) + prebuilt honored map ──────────────────────
|
||||
|
||||
describe("simulateMajor exclusions & prebuilt honored", () => {
|
||||
const ids = makeIds(128);
|
||||
|
||||
it("a withdrawn player walks over (earns 0 QP) even with a dominant Elo", () => {
|
||||
// draw[0] would otherwise win almost everything; excluding them must zero them.
|
||||
const dominant = makeEloMap(ids, ids.map((_, i) => (i === 0 ? 5000 : 1000)));
|
||||
const excluded = new Set([ids[0]]);
|
||||
for (let t = 0; t < 20; t++) {
|
||||
const results = simulateMajor(ids, dominant, "hard", { excluded });
|
||||
expect(results.find((r) => r.participantId === ids[0])?.qp).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("a completed match is still honored even if the winner is later excluded", () => {
|
||||
// Fully-decided bracket says draw[0] wins; excluding them must NOT override a
|
||||
// recorded real result (the match already happened).
|
||||
function decide(draw: string[]): RealBracketMatch[] {
|
||||
const rounds = [
|
||||
"Round of 128", "Round of 64", "Round of 32", "Round of 16",
|
||||
"Quarterfinals", "Semifinals", "Final",
|
||||
];
|
||||
const order = new Map(draw.map((id, i) => [id, i]));
|
||||
const matches: RealBracketMatch[] = [];
|
||||
let cur = [...draw];
|
||||
for (const round of rounds) {
|
||||
const next: string[] = [];
|
||||
for (let i = 0; i < cur.length; i += 2) {
|
||||
const w = (order.get(cur[i]) ?? 0) < (order.get(cur[i + 1]) ?? 0) ? cur[i] : cur[i + 1];
|
||||
matches.push({ round, matchNumber: i / 2 + 1, winnerId: w, isComplete: true });
|
||||
next.push(w);
|
||||
}
|
||||
cur = next;
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
const honored = buildHonoredMap(decide(ids));
|
||||
const eloMap = makeEloMap(ids, ids.map(() => 1500));
|
||||
const results = simulateMajor(ids, eloMap, "hard", {
|
||||
honored,
|
||||
excluded: new Set([ids[0]]),
|
||||
});
|
||||
expect(results.find((r) => r.qp === DEFAULT_SLAM_QP.winner)?.participantId).toBe(ids[0]);
|
||||
});
|
||||
|
||||
it("prebuilt honored map produces the same champion as realBracket input", () => {
|
||||
const realBracket: RealBracketMatch[] = [
|
||||
{ round: "Final", matchNumber: 1, winnerId: null, isComplete: false },
|
||||
];
|
||||
const honored = buildHonoredMap(realBracket);
|
||||
// Empty honored (no completed matches) → no crash, full simulation runs.
|
||||
const eloMap = makeEloMap(ids, ids.map(() => 1500));
|
||||
const results = simulateMajor(ids, eloMap, "hard", { honored });
|
||||
expect(results.filter((r) => r.qp === DEFAULT_SLAM_QP.winner)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -78,6 +78,30 @@ function makeParticipants(count = 48) {
|
|||
}));
|
||||
}
|
||||
|
||||
/** 12 canonical groups A–L, 4 members each (p0..p47), no completed matches. */
|
||||
function makeCanonicalGroups() {
|
||||
return Array.from({ length: 12 }, (_, gi) => ({
|
||||
id: `group-${gi}`,
|
||||
groupName: String.fromCharCode(65 + gi), // A..L
|
||||
scoringEventId: "event-1",
|
||||
members: [0, 1, 2, 3].map((s) => ({ participantId: `p${gi * 4 + s}` })),
|
||||
matches: [],
|
||||
}));
|
||||
}
|
||||
|
||||
/** 16 Round-of-32 matches with both slots filled (p0..p31), seq pairing. */
|
||||
function makeFullR32Draw() {
|
||||
return Array.from({ length: 16 }, (_, i) => ({
|
||||
round: "Round of 32",
|
||||
matchNumber: i + 1,
|
||||
participant1Id: `p${i * 2}`,
|
||||
participant2Id: `p${i * 2 + 1}`,
|
||||
winnerId: null as string | null,
|
||||
loserId: null as string | null,
|
||||
isComplete: false,
|
||||
}));
|
||||
}
|
||||
|
||||
vi.mock("~/database/context", () => ({
|
||||
database: vi.fn(),
|
||||
}));
|
||||
|
|
@ -87,7 +111,7 @@ import { database } from "~/database/context";
|
|||
const mockDb = {
|
||||
query: {
|
||||
seasonParticipants: { findMany: vi.fn() },
|
||||
scoringEvents: { findFirst: vi.fn() },
|
||||
scoringEvents: { findMany: vi.fn() },
|
||||
tournamentGroups: { findMany: vi.fn() },
|
||||
playoffMatches: { findMany: vi.fn() },
|
||||
},
|
||||
|
|
@ -96,10 +120,15 @@ const mockDb = {
|
|||
where: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
/** Set the resolved bracket scoring event (or null for none). */
|
||||
function mockBracketEvent(event: Record<string, unknown> | null) {
|
||||
mockDb.query.scoringEvents.findMany.mockResolvedValue(event ? [event] : []);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(database).mockReturnValue(mockDb as never);
|
||||
mockDb.query.seasonParticipants.findMany.mockReset();
|
||||
mockDb.query.scoringEvents.findFirst.mockReset();
|
||||
mockDb.query.scoringEvents.findMany.mockReset();
|
||||
mockDb.query.tournamentGroups.findMany.mockReset();
|
||||
mockDb.query.playoffMatches.findMany.mockReset();
|
||||
mockDb.select.mockReturnValue(mockDb);
|
||||
|
|
@ -110,7 +139,7 @@ beforeEach(() => {
|
|||
describe("WorldCupSimulator", () => {
|
||||
it("throws when no participants are found", async () => {
|
||||
mockDb.query.seasonParticipants.findMany.mockResolvedValue([]);
|
||||
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
|
||||
mockBracketEvent(null);
|
||||
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
|
||||
|
||||
|
|
@ -121,7 +150,7 @@ describe("WorldCupSimulator", () => {
|
|||
it("returns one result per participant", async () => {
|
||||
const participants = makeParticipants(48);
|
||||
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
|
||||
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
|
||||
mockBracketEvent(null);
|
||||
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
|
||||
|
||||
|
|
@ -137,7 +166,7 @@ describe("WorldCupSimulator", () => {
|
|||
it("column sums for champion, runner-up, 3rd, 4th are each ≈1.0", async () => {
|
||||
const participants = makeParticipants(48);
|
||||
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
|
||||
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
|
||||
mockBracketEvent(null);
|
||||
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
|
||||
|
||||
|
|
@ -158,7 +187,7 @@ describe("WorldCupSimulator", () => {
|
|||
it("SF losers land in 3rd or 4th, never 1st or 2nd", async () => {
|
||||
const participants = makeParticipants(48);
|
||||
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
|
||||
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
|
||||
mockBracketEvent(null);
|
||||
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
|
||||
|
||||
|
|
@ -180,7 +209,7 @@ describe("WorldCupSimulator", () => {
|
|||
it("a team with pre-completed group stage result is fixed in simulation", async () => {
|
||||
const participants = makeParticipants(48);
|
||||
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
|
||||
mockDb.query.scoringEvents.findFirst.mockResolvedValue({ id: "event-1" });
|
||||
mockBracketEvent({ id: "event-1" });
|
||||
|
||||
// One group fully complete: p0 wins everything, p3 loses everything
|
||||
const group = {
|
||||
|
|
@ -234,7 +263,7 @@ describe("WorldCupSimulator", () => {
|
|||
it("probFifth through probEighth are equal for each participant (QF losers split evenly)", async () => {
|
||||
const participants = makeParticipants(48);
|
||||
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
|
||||
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
|
||||
mockBracketEvent(null);
|
||||
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
|
||||
|
||||
|
|
@ -248,4 +277,116 @@ describe("WorldCupSimulator", () => {
|
|||
expect(probSeventh).toBeCloseTo(probEighth, 10);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Regime selection & bracket honoring ──────────────────────────────────
|
||||
|
||||
it("regime=draw: honors the real R32 draw and flags source as real bracket", async () => {
|
||||
const participants = makeParticipants(48);
|
||||
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
|
||||
mockBracketEvent({ id: "event-1", bracketTemplateId: "fifa_48" });
|
||||
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]); // no groups, but draw exists
|
||||
|
||||
const r32 = makeFullR32Draw();
|
||||
// Lock R32 match 1: p0 beats p1, so p1 is eliminated in the Round of 32.
|
||||
r32[0] = { ...r32[0], winnerId: "p0", loserId: "p1", isComplete: true };
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue(r32);
|
||||
|
||||
const sim = new WorldCupSimulator(50);
|
||||
const results = await sim.simulate("season-1");
|
||||
|
||||
expect(results[0].source).toContain("real bracket");
|
||||
|
||||
// p1 lost in the R32 → never earns any placement (scoring starts at QF).
|
||||
const p1 = results.find((r) => r.participantId === "p1")?.probabilities;
|
||||
const p1Mass =
|
||||
(p1?.probFirst ?? 0) + (p1?.probSecond ?? 0) + (p1?.probThird ?? 0) + (p1?.probFourth ?? 0) +
|
||||
(p1?.probFifth ?? 0) + (p1?.probSixth ?? 0) + (p1?.probSeventh ?? 0) + (p1?.probEighth ?? 0);
|
||||
expect(p1Mass).toBe(0);
|
||||
|
||||
// Teams p32..p47 are not in the 32-team draw → also zero.
|
||||
const p40 = results.find((r) => r.participantId === "p40")?.probabilities;
|
||||
expect((p40?.probFirst ?? 0) + (p40?.probFifth ?? 0)).toBe(0);
|
||||
|
||||
// Champion mass still normalizes to ~1.
|
||||
const sumFirst = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
||||
expect(sumFirst).toBeCloseTo(1.0, 2);
|
||||
});
|
||||
|
||||
it("partial R32 draw never places an already-drawn team into an empty slot twice", async () => {
|
||||
const participants = makeParticipants(48);
|
||||
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
|
||||
mockBracketEvent({ id: "event-1", bracketTemplateId: "fifa_48" });
|
||||
// Real, canonical groups so the empty slots get filled from group seeding.
|
||||
mockDb.query.tournamentGroups.findMany.mockResolvedValue(makeCanonicalGroups());
|
||||
|
||||
// Partial draw: only match 1 is populated (p0 vs p1); matches 2–16 are empty,
|
||||
// so the seeded group results fill them — but must not re-place p0 or p1.
|
||||
const r32 = makeFullR32Draw().map((m, i) =>
|
||||
i === 0 ? m : { ...m, participant1Id: null, loserId: null, participant2Id: null }
|
||||
);
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue(r32);
|
||||
|
||||
const sim = new WorldCupSimulator(40);
|
||||
const results = await sim.simulate("season-1");
|
||||
|
||||
// A team can finish at most one placement per sim, so its total probability
|
||||
// mass is ≤ 1. A duplicated team could exceed that (e.g. champion + QF-loser
|
||||
// in the same iteration). Assert no team's mass exceeds 1.
|
||||
for (const r of results) {
|
||||
const p = r.probabilities;
|
||||
const mass =
|
||||
p.probFirst + p.probSecond + p.probThird + p.probFourth +
|
||||
p.probFifth + p.probSixth + p.probSeventh + p.probEighth;
|
||||
expect(mass).toBeLessThanOrEqual(1 + 1e-9);
|
||||
}
|
||||
// Champion mass still normalizes to ~1 (exactly one champion per sim).
|
||||
const sumFirst = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
||||
expect(sumFirst).toBeCloseTo(1.0, 2);
|
||||
});
|
||||
|
||||
it("completed Final locks in the champion and runner-up", async () => {
|
||||
const participants = makeParticipants(48);
|
||||
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
|
||||
mockBracketEvent({ id: "event-1" });
|
||||
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue([
|
||||
{ round: "Finals", matchNumber: 1, participant1Id: "p5", participant2Id: "p6", winnerId: "p5", loserId: "p6", isComplete: true },
|
||||
]);
|
||||
|
||||
const sim = new WorldCupSimulator(30);
|
||||
const results = await sim.simulate("season-1");
|
||||
|
||||
expect(results.find((r) => r.participantId === "p5")?.probabilities.probFirst).toBe(1);
|
||||
expect(results.find((r) => r.participantId === "p6")?.probabilities.probSecond).toBe(1);
|
||||
});
|
||||
|
||||
it("regime=groups: real 12-group stage uses the 2026 bracket seeding source", async () => {
|
||||
const participants = makeParticipants(48);
|
||||
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
|
||||
mockBracketEvent({ id: "event-1", bracketTemplateId: "fifa_48" });
|
||||
mockDb.query.tournamentGroups.findMany.mockResolvedValue(makeCanonicalGroups());
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue([]); // no draw yet
|
||||
|
||||
const sim = new WorldCupSimulator(40);
|
||||
const results = await sim.simulate("season-1");
|
||||
|
||||
expect(results[0].source).toContain("2026 bracket seeding");
|
||||
const sumFirst = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
||||
expect(sumFirst).toBeCloseTo(1.0, 2);
|
||||
});
|
||||
|
||||
it("regime=futures: no groups and no draw falls back with a flagged source", async () => {
|
||||
const participants = makeParticipants(48);
|
||||
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
|
||||
mockBracketEvent(null);
|
||||
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
|
||||
|
||||
const sim = new WorldCupSimulator(30);
|
||||
const results = await sim.simulate("season-1");
|
||||
|
||||
expect(results[0].source).toContain("futures fallback");
|
||||
const sumFirst = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
||||
expect(sumFirst).toBeCloseTo(1.0, 2);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue