Compare commits
4 commits
main
...
claude/wim
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
929e30c58c | ||
|
|
83e41e9cf1 | ||
|
|
ad238e6bfb | ||
|
|
efec504c08 |
23 changed files with 265 additions and 2042 deletions
|
|
@ -9,14 +9,17 @@
|
||||||
"command": ".claude/hooks/lint-on-edit.sh",
|
"command": ".claude/hooks/lint-on-edit.sh",
|
||||||
"timeout": 30,
|
"timeout": 30,
|
||||||
"statusMessage": "Linting..."
|
"statusMessage": "Linting..."
|
||||||
},
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Stop": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"if": "Write(*.ts)|Write(*.tsx)|Edit(*.ts)|Edit(*.tsx)|MultiEdit(*.ts)|MultiEdit(*.tsx)",
|
"command": "output=$(npm run typecheck 2>&1); rc=$?; if [ $rc -ne 0 ]; then printf '{\"systemMessage\":\"TypeCheck failed:\\n%s\"}' \"$(echo \"$output\" | tail -30 | sed 's/\"/\\\\\"/g; s/$/\\\\n/' | tr -d '\\n')\"; fi",
|
||||||
"command": "output=$(npm run typecheck 2>&1); rc=$?; if [ $rc -ne 0 ]; then printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PostToolUse\",\"additionalContext\":\"TypeCheck failed:\\n%s\"}}' \"$(echo \"$output\" | tail -30 | sed 's/\"/\\\\\"/g; s/$/\\\\n/' | tr -d '\\n')\"; fi",
|
"timeout": 60
|
||||||
"timeout": 60,
|
|
||||||
"statusMessage": "Type-checking...",
|
|
||||||
"async": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,3 @@ CLOUDINARY_API_SECRET=""
|
||||||
# Must match the CRON_SECRET repo secret in Forgejo.
|
# Must match the CRON_SECRET repo secret in Forgejo.
|
||||||
# Generate with: openssl rand -hex 32
|
# Generate with: openssl rand -hex 32
|
||||||
CRON_SECRET=""
|
CRON_SECRET=""
|
||||||
|
|
||||||
# OC Blacktop motorsport API — used to sync IndyCar championship standings
|
|
||||||
# (fresher than ESPN's aggregate). Free tier at https://ocblacktop.com/api.
|
|
||||||
# If unset, IndyCar standings fall back to ESPN.
|
|
||||||
OCBLACKTOP_API_KEY=""
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ const scoringRules = {
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("QualifyingPointsStandings", () => {
|
describe("QualifyingPointsStandings", () => {
|
||||||
it("renders fractional QP to at most hundredths with trailing zeros trimmed", () => {
|
it("renders fractional QP to hundredths", () => {
|
||||||
render(
|
render(
|
||||||
<QualifyingPointsStandings
|
<QualifyingPointsStandings
|
||||||
standings={[
|
standings={[
|
||||||
|
|
@ -52,8 +52,7 @@ describe("QualifyingPointsStandings", () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("14.67 QP")).toBeInTheDocument();
|
expect(screen.getByText("14.67 QP")).toBeInTheDocument();
|
||||||
// 0.50 trims its trailing zero to 0.5; 14.67 and 0.43 are unaffected.
|
expect(screen.getByText("0.50 QP")).toBeInTheDocument();
|
||||||
expect(screen.getByText("0.5 QP")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("0.43 QP")).toBeInTheDocument();
|
expect(screen.getByText("0.43 QP")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { GradientIcon } from "~/components/ui/GradientIcon";
|
||||||
import { RankingsRow } from "./RankingsRow";
|
import { RankingsRow } from "./RankingsRow";
|
||||||
import { BracketTreeView, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
|
import { BracketTreeView, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
|
||||||
import { BracketTreePaginated } from "./BracketTreePaginated";
|
import { BracketTreePaginated } from "./BracketTreePaginated";
|
||||||
import { getBracketTemplate, type BracketTemplate } from "~/lib/bracket-templates";
|
import { getBracketTemplate } from "~/lib/bracket-templates";
|
||||||
import { NbaBracketLayout } from "./NbaBracketLayout";
|
import { NbaBracketLayout } from "./NbaBracketLayout";
|
||||||
import { TabbedBracketLayout } from "./TabbedBracketLayout";
|
import { TabbedBracketLayout } from "./TabbedBracketLayout";
|
||||||
|
|
||||||
|
|
@ -174,138 +174,6 @@ export function computeEliminatedByRound(
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The score recorded for one participant in a match, or null if they didn't play in it. */
|
|
||||||
function participantScore(match: Match, participantId: string | null): string | null {
|
|
||||||
if (!participantId) return null;
|
|
||||||
if (participantId === match.participant1Id) return match.participant1Score;
|
|
||||||
if (participantId === match.participant2Id) return match.participant2Score;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A consolation final: a round contested by the losers of an earlier round, which
|
|
||||||
* splits the positions those losers would otherwise share. FIFA's "Third Place Game"
|
|
||||||
* (fed by the Semifinals) is the only one in the templates today.
|
|
||||||
*/
|
|
||||||
export interface ConsolationRound {
|
|
||||||
/** The consolation round itself, e.g. "Third Place Game". */
|
|
||||||
round: string;
|
|
||||||
/** The round whose losers contest it, e.g. "Semifinals". */
|
|
||||||
feederRound: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the template's consolation round, if it has one.
|
|
||||||
* 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. */
|
/** Find the index of the first round that has scoring matches. */
|
||||||
function firstScoringRoundIdx(matchesByRound: Map<string, Match[]>, rounds: string[]): number {
|
function firstScoringRoundIdx(matchesByRound: Map<string, Match[]>, rounds: string[]): number {
|
||||||
for (let i = 0; i < rounds.length; i++) {
|
for (let i = 0; i < rounds.length; i++) {
|
||||||
|
|
@ -348,10 +216,12 @@ export function PlayoffBracket({
|
||||||
const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds);
|
const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds);
|
||||||
const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined;
|
const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined;
|
||||||
|
|
||||||
const consolation = findConsolationRound(template);
|
const thirdPlaceRound = template?.rounds
|
||||||
const thirdPlaceRound = consolation?.round;
|
.find((r) => template.rounds.some((other) => other.loserFeedsInto === r.name))
|
||||||
|
?.name;
|
||||||
|
|
||||||
// Build elimination rankings
|
// Build elimination rankings
|
||||||
|
const losersByRound = new Map<string, Array<{ participant: Participant; score: string | null; ownership: TeamOwnership | null }>>();
|
||||||
let bracketWinner: Participant | null = null;
|
let bracketWinner: Participant | null = null;
|
||||||
|
|
||||||
const lastRound = rounds[rounds.length - 1];
|
const lastRound = rounds[rounds.length - 1];
|
||||||
|
|
@ -360,19 +230,43 @@ export function PlayoffBracket({
|
||||||
: null;
|
: null;
|
||||||
if (finalMatch?.winner) bracketWinner = finalMatch.winner;
|
if (finalMatch?.winner) bracketWinner = finalMatch.winner;
|
||||||
|
|
||||||
|
const eliminatedByRound = computeEliminatedByRound(matches, rounds);
|
||||||
|
|
||||||
|
for (const match of matches) {
|
||||||
|
if (!match.isComplete || !match.loser) continue;
|
||||||
|
const eliminatedInRound = eliminatedByRound.get(match.round);
|
||||||
|
if (!eliminatedInRound?.includes(match.loser.id)) continue;
|
||||||
|
const loserScore =
|
||||||
|
match.loserId === match.participant1Id
|
||||||
|
? match.participant1Score
|
||||||
|
: match.participant2Score;
|
||||||
|
if (!losersByRound.has(match.round)) losersByRound.set(match.round, []);
|
||||||
|
losersByRound.get(match.round)?.push({
|
||||||
|
participant: match.loser,
|
||||||
|
score: loserScore,
|
||||||
|
ownership: ownershipMap.get(match.loser.id) || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const allBracketParticipantIds = new Set<string>();
|
const allBracketParticipantIds = new Set<string>();
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
if (match.participant1Id) allBracketParticipantIds.add(match.participant1Id);
|
if (match.participant1Id) allBracketParticipantIds.add(match.participant1Id);
|
||||||
if (match.participant2Id) allBracketParticipantIds.add(match.participant2Id);
|
if (match.participant2Id) allBracketParticipantIds.add(match.participant2Id);
|
||||||
}
|
}
|
||||||
|
const rankedEntries: EliminatedEntry[] = [];
|
||||||
const rankedEntries = computeRankedEntries(
|
let nextRank = 2;
|
||||||
matches,
|
for (let ri = rounds.length - 1; ri >= 0; ri--) {
|
||||||
rounds,
|
const roundName = rounds[ri];
|
||||||
matchesByRound,
|
const roundLosers = losersByRound.get(roundName) || [];
|
||||||
consolation,
|
const totalMatchesInRound = matchesByRound.get(roundName)?.length ?? 0;
|
||||||
ownershipMap
|
if (roundLosers.length > 0) {
|
||||||
);
|
const rankLabel = `T${nextRank}`;
|
||||||
|
for (const loser of roundLosers) {
|
||||||
|
rankedEntries.push({ ...loser, rankLabel });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextRank += totalMatchesInRound;
|
||||||
|
}
|
||||||
|
|
||||||
const rankedParticipantIds = new Set(rankedEntries.map((e) => e.participant.id));
|
const rankedParticipantIds = new Set(rankedEntries.map((e) => e.participant.id));
|
||||||
if (bracketWinner) rankedParticipantIds.add(bracketWinner.id);
|
if (bracketWinner) rankedParticipantIds.add(bracketWinner.id);
|
||||||
|
|
@ -392,7 +286,7 @@ export function PlayoffBracket({
|
||||||
.map((id) => participantMap.get(id))
|
.map((id) => participantMap.get(id))
|
||||||
.filter((p): p is Participant => p !== undefined)
|
.filter((p): p is Participant => p !== undefined)
|
||||||
// Owned-by-a-manager players first, then alphabetical by name.
|
// Owned-by-a-manager players first, then alphabetical by name.
|
||||||
.toSorted((a, b) => {
|
.sort((a, b) => {
|
||||||
const aOwned = ownershipMap.has(a.id);
|
const aOwned = ownershipMap.has(a.id);
|
||||||
const bOwned = ownershipMap.has(b.id);
|
const bOwned = ownershipMap.has(b.id);
|
||||||
if (aOwned !== bOwned) return aOwned ? -1 : 1;
|
if (aOwned !== bOwned) return aOwned ? -1 : 1;
|
||||||
|
|
|
||||||
|
|
@ -55,9 +55,7 @@ interface QualifyingPointsStandingsProps {
|
||||||
function formatQP(raw: string): string {
|
function formatQP(raw: string): string {
|
||||||
const n = parseFloat(raw);
|
const n = parseFloat(raw);
|
||||||
if (isNaN(n)) return "—";
|
if (isNaN(n)) return "—";
|
||||||
// At most 2 decimals, trailing zeros trimmed (1.50→1.5). Mirrors the Discord
|
return n % 1 === 0 ? n.toString() : n.toFixed(2);
|
||||||
// embed's formatQPValue (app/services/discord.ts) so the site and Discord agree.
|
|
||||||
return parseFloat(n.toFixed(2)).toString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QualifyingPointsStandings({
|
export function QualifyingPointsStandings({
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,5 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { render, screen, within } from "@testing-library/react";
|
import { buildFeederMap, groupMatchesByRound, computeEliminatedByRound } from "../PlayoffBracket";
|
||||||
import {
|
|
||||||
PlayoffBracket,
|
|
||||||
buildFeederMap,
|
|
||||||
groupMatchesByRound,
|
|
||||||
computeEliminatedByRound,
|
|
||||||
computeRankedEntries,
|
|
||||||
findConsolationRound,
|
|
||||||
type Match,
|
|
||||||
} from "../PlayoffBracket";
|
|
||||||
import { getBracketTemplate } from "~/lib/bracket-templates";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
|
|
@ -301,392 +291,3 @@ describe("computeEliminatedByRound", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// computeRankedEntries — consolation ("third place") round handling
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// fifa_48 round order. The Third Place Game sits between the Semifinals (whose
|
|
||||||
// losers feed it) and the Finals.
|
|
||||||
const FIFA_ROUNDS = ["Quarterfinals", "Semifinals", "Third Place Game", "Finals"];
|
|
||||||
|
|
||||||
const FIFA_CONSOLATION = {
|
|
||||||
round: "Third Place Game",
|
|
||||||
feederRound: "Semifinals",
|
|
||||||
};
|
|
||||||
|
|
||||||
type MatchOpts = {
|
|
||||||
/** Which slot the winner occupies. Defaults to 1. */
|
|
||||||
winnerSlot?: 1 | 2;
|
|
||||||
winnerScore?: string;
|
|
||||||
loserScore?: string;
|
|
||||||
/**
|
|
||||||
* Drop the hydrated `winner` relation, keeping `loser` and both ids — the shape a
|
|
||||||
* hand-built match object can arrive in. The loser is still placeable this way.
|
|
||||||
*/
|
|
||||||
missingWinnerRelation?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** A completed match. */
|
|
||||||
function makeRankedMatch(
|
|
||||||
round: string,
|
|
||||||
matchNumber: number,
|
|
||||||
winnerId: string,
|
|
||||||
loserId: string,
|
|
||||||
opts: MatchOpts = {}
|
|
||||||
): Match {
|
|
||||||
const winnerIsP1 = (opts.winnerSlot ?? 1) === 1;
|
|
||||||
const p1 = winnerIsP1 ? winnerId : loserId;
|
|
||||||
const p2 = winnerIsP1 ? loserId : winnerId;
|
|
||||||
return {
|
|
||||||
id: `${round}-${matchNumber}`,
|
|
||||||
round,
|
|
||||||
matchNumber,
|
|
||||||
participant1Id: p1,
|
|
||||||
participant2Id: p2,
|
|
||||||
winnerId,
|
|
||||||
loserId,
|
|
||||||
isComplete: true,
|
|
||||||
participant1Score: (winnerIsP1 ? opts.winnerScore : opts.loserScore) ?? null,
|
|
||||||
participant2Score: (winnerIsP1 ? opts.loserScore : opts.winnerScore) ?? null,
|
|
||||||
participant1: { id: p1, name: p1 },
|
|
||||||
participant2: { id: p2, name: p2 },
|
|
||||||
winner: opts.missingWinnerRelation ? null : { id: winnerId, name: winnerId },
|
|
||||||
loser: { id: loserId, name: loserId },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A scheduled-but-unplayed match. Bracket rows are pre-generated with their slots
|
|
||||||
* filled as earlier rounds resolve, so an unplayed 3PG still lists both SF losers.
|
|
||||||
*/
|
|
||||||
function makePendingMatch(
|
|
||||||
round: string,
|
|
||||||
matchNumber: number,
|
|
||||||
participant1Id: string | null,
|
|
||||||
participant2Id: string | null
|
|
||||||
): Match {
|
|
||||||
return {
|
|
||||||
id: `${round}-${matchNumber}`,
|
|
||||||
round,
|
|
||||||
matchNumber,
|
|
||||||
participant1Id,
|
|
||||||
participant2Id,
|
|
||||||
winnerId: null,
|
|
||||||
loserId: null,
|
|
||||||
isComplete: false,
|
|
||||||
participant1Score: null,
|
|
||||||
participant2Score: null,
|
|
||||||
participant1: participant1Id ? { id: participant1Id, name: participant1Id } : null,
|
|
||||||
participant2: participant2Id ? { id: participant2Id, name: participant2Id } : null,
|
|
||||||
winner: null,
|
|
||||||
loser: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A full fifa_48-shaped knockout tail: 4 QF, 2 SF, the 3PG, and the Final. */
|
|
||||||
function fifaMatches(
|
|
||||||
overrides: { played?: boolean; thirdPlace?: Match } = {}
|
|
||||||
): Match[] {
|
|
||||||
const played = overrides.played ?? true;
|
|
||||||
return [
|
|
||||||
makeRankedMatch("Quarterfinals", 1, "sfA", "qf1"),
|
|
||||||
makeRankedMatch("Quarterfinals", 2, "sfB", "qf2"),
|
|
||||||
makeRankedMatch("Quarterfinals", 3, "sfC", "qf3"),
|
|
||||||
makeRankedMatch("Quarterfinals", 4, "sfD", "qf4"),
|
|
||||||
makeRankedMatch("Semifinals", 1, "sfA", "sfB"),
|
|
||||||
makeRankedMatch("Semifinals", 2, "sfC", "sfD"),
|
|
||||||
overrides.thirdPlace ??
|
|
||||||
(played
|
|
||||||
? makeRankedMatch("Third Place Game", 1, "sfB", "sfD")
|
|
||||||
: makePendingMatch("Third Place Game", 1, "sfB", "sfD")),
|
|
||||||
played
|
|
||||||
? makeRankedMatch("Finals", 1, "sfA", "sfC")
|
|
||||||
: makePendingMatch("Finals", 1, "sfA", "sfC"),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Rank the fifa fixture, defaulting to the fifa_48 consolation config. */
|
|
||||||
function rankFifa(
|
|
||||||
matches: Match[] = fifaMatches(),
|
|
||||||
ownership: Map<string, { participantId: string; teamName: string; teamId: string }> = new Map(),
|
|
||||||
consolation: typeof FIFA_CONSOLATION | undefined = FIFA_CONSOLATION
|
|
||||||
) {
|
|
||||||
return computeRankedEntries(
|
|
||||||
matches,
|
|
||||||
FIFA_ROUNDS,
|
|
||||||
groupMatchesByRound(matches),
|
|
||||||
consolation,
|
|
||||||
ownership
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function rankOf(entries: ReturnType<typeof computeRankedEntries>, id: string) {
|
|
||||||
return entries.find((e) => e.participant.id === id)?.rankLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -104,40 +104,4 @@ describe("processQualifyingEvent — QP change notification", () => {
|
||||||
|
|
||||||
expect(notifyQualifyingPointsUpdate).not.toHaveBeenCalled();
|
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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -880,15 +880,6 @@ export async function processQualifyingEvent(
|
||||||
* which fall back to querying it here.
|
* which fall back to querying it here.
|
||||||
*/
|
*/
|
||||||
canonicalTieCountByPlacement?: Map<number, number>;
|
canonicalTieCountByPlacement?: Map<number, number>;
|
||||||
/**
|
|
||||||
* This window's season_participant ids that were knocked out this sync in a
|
|
||||||
* non-scoring round. They earn no QP (so they never surface via changed QP),
|
|
||||||
* but a manager who drafted them should still be told. Threaded down from the
|
|
||||||
* primary bracket by the fan-out (syncTournamentResults), already translated
|
|
||||||
* to THIS window's season_participant ids. See the primary path in
|
|
||||||
* app/services/match-sync/index.ts (newlyEliminatedIds).
|
|
||||||
*/
|
|
||||||
newlyEliminatedParticipantIds?: Set<string>;
|
|
||||||
} = {}
|
} = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const db = providedDb || database();
|
const db = providedDb || database();
|
||||||
|
|
@ -1044,22 +1035,9 @@ export async function processQualifyingEvent(
|
||||||
afterRows.map((r) => ({ id: r.seasonParticipantId, qp: r.qualifyingPointsAwarded }))
|
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
|
if (changedParticipantIds.size > 0 && !options.skipNotifications) {
|
||||||
// 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 {
|
try {
|
||||||
await notifyQualifyingPointsUpdate(
|
await notifyQualifyingPointsUpdate(event.sportsSeasonId, eventId, db, changedParticipantIds);
|
||||||
event.sportsSeasonId,
|
|
||||||
eventId,
|
|
||||||
db,
|
|
||||||
changedParticipantIds,
|
|
||||||
eliminatedIds,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[ScoringCalculator] QP Discord notification failed for event ${eventId}:`, error);
|
logger.error(`[ScoringCalculator] QP Discord notification failed for event ${eventId}:`, error);
|
||||||
}
|
}
|
||||||
|
|
@ -1940,10 +1918,8 @@ export async function recalculateAffectedLeagues(
|
||||||
// in the World Cup) does not qualify on its own — their owner hasn't earned anything yet.
|
// 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
|
// 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.
|
// 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
|
// Losers who advance to another match (loserAdvances=true, e.g. NBA 7v8 → PIR2) have
|
||||||
// showLoser flag (isLoserNotifiable) only gates whether the loser is @-pinged — a loser
|
// showLoser=false and are correctly suppressed.
|
||||||
// who advanced rather than being eliminated (loserAdvances=true, e.g. NBA 7v8 → PIR2, or
|
|
||||||
// a World Cup semifinal loser) is named but not pinged.
|
|
||||||
let scoredMatches: ScoredMatch[] | undefined;
|
let scoredMatches: ScoredMatch[] | undefined;
|
||||||
if (allCompletedMatches.length > 0) {
|
if (allCompletedMatches.length > 0) {
|
||||||
const relevant = allCompletedMatches.filter(
|
const relevant = allCompletedMatches.filter(
|
||||||
|
|
@ -1976,12 +1952,7 @@ export async function recalculateAffectedLeagues(
|
||||||
winnerName: x.m.winnerName ?? "",
|
winnerName: x.m.winnerName ?? "",
|
||||||
loserName: x.m.loserName ?? "",
|
loserName: x.m.loserName ?? "",
|
||||||
winnerUsername: x.winnerOwnerId ? usernameByUserId.get(x.winnerOwnerId) : undefined,
|
winnerUsername: x.winnerOwnerId ? usernameByUserId.get(x.winnerOwnerId) : undefined,
|
||||||
// Show the loser's manager tag whenever their team is drafted, mirroring the
|
loserUsername: x.showLoser && x.loserOwnerId ? usernameByUserId.get(x.loserOwnerId) : undefined,
|
||||||
// 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,
|
winnerDiscordUserId: x.winnerScoreChanged && x.winnerOwnerId ? discordIdByUserId.get(x.winnerOwnerId) : undefined,
|
||||||
loserDiscordUserId: x.showLoser && x.loserOwnerId ? discordIdByUserId.get(x.loserOwnerId) : undefined,
|
loserDiscordUserId: x.showLoser && x.loserOwnerId ? discordIdByUserId.get(x.loserOwnerId) : undefined,
|
||||||
}))
|
}))
|
||||||
|
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { newlyDecidedLosers } from "../admin.sports-seasons.$id.events.$eventId.bracket.helpers";
|
|
||||||
|
|
||||||
describe("newlyDecidedLosers", () => {
|
|
||||||
it("returns losers of matches that were not already complete", () => {
|
|
||||||
expect(
|
|
||||||
newlyDecidedLosers([
|
|
||||||
{ wasComplete: false, loserId: "sp-1" },
|
|
||||||
{ wasComplete: false, loserId: "sp-2" },
|
|
||||||
])
|
|
||||||
).toEqual(["sp-1", "sp-2"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops re-scores of already-complete matches (no re-announce)", () => {
|
|
||||||
// sp-1's match just finished; sp-2's match was already complete before this
|
|
||||||
// action — only sp-1 is a NEW knockout.
|
|
||||||
expect(
|
|
||||||
newlyDecidedLosers([
|
|
||||||
{ wasComplete: false, loserId: "sp-1" },
|
|
||||||
{ wasComplete: true, loserId: "sp-2" },
|
|
||||||
])
|
|
||||||
).toEqual(["sp-1"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skips entries with an unknown loser", () => {
|
|
||||||
expect(
|
|
||||||
newlyDecidedLosers([
|
|
||||||
{ wasComplete: false, loserId: null },
|
|
||||||
{ wasComplete: false, loserId: "sp-3" },
|
|
||||||
])
|
|
||||||
).toEqual(["sp-3"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns an empty array when nothing was newly decided", () => {
|
|
||||||
expect(
|
|
||||||
newlyDecidedLosers([
|
|
||||||
{ wasComplete: true, loserId: "sp-1" },
|
|
||||||
{ wasComplete: true, loserId: "sp-2" },
|
|
||||||
])
|
|
||||||
).toEqual([]);
|
|
||||||
expect(newlyDecidedLosers([])).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
/**
|
|
||||||
* Pure helpers for the bracket-scoring route action, extracted so the
|
|
||||||
* knockout-detection logic can be unit-tested without the full action harness.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* From a set of matches being scored this action, return the season_participant
|
|
||||||
* ids knocked out for the FIRST time. A loser counts only when its match was NOT
|
|
||||||
* already complete — a re-score/correction of a finished match must not
|
|
||||||
* re-announce the exit (mirrors populateBracketFromDraw's first-completion rule in
|
|
||||||
* the live-sync path). Entries with an unknown loser (`null`) are skipped.
|
|
||||||
*
|
|
||||||
* The ids are season_participant ids of the primary window (playoff_matches are
|
|
||||||
* keyed by season_participant); the fan-out translates them to each mirror
|
|
||||||
* window's own season_participant id before announcing the "Knocked Out" section.
|
|
||||||
*/
|
|
||||||
export function newlyDecidedLosers(
|
|
||||||
entries: Array<{ wasComplete: boolean; loserId: string | null }>
|
|
||||||
): string[] {
|
|
||||||
return entries
|
|
||||||
.filter((e): e is { wasComplete: boolean; loserId: string } =>
|
|
||||||
!e.wasComplete && e.loserId !== null
|
|
||||||
)
|
|
||||||
.map((e) => e.loserId);
|
|
||||||
}
|
|
||||||
|
|
@ -65,10 +65,9 @@ import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.server";
|
import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.server";
|
||||||
import { fanOutMajorIfPrimary, syncMajorFromPrimaryEvent } from "~/services/sync-tournament-results";
|
import { fanOutMajorIfPrimary } from "~/services/sync-tournament-results";
|
||||||
import { syncTennisDraw, previewTennisDraw } from "~/services/match-sync";
|
import { syncTennisDraw, previewTennisDraw } from "~/services/match-sync";
|
||||||
import { articleTitleFromInput } from "~/services/match-sync/wikipedia-tennis";
|
import { articleTitleFromInput } from "~/services/match-sync/wikipedia-tennis";
|
||||||
import { newlyDecidedLosers } from "./admin.sports-seasons.$id.events.$eventId.bracket.helpers";
|
|
||||||
|
|
||||||
export async function loader({ params }: Route.LoaderArgs) {
|
export async function loader({ params }: Route.LoaderArgs) {
|
||||||
const sportsSeason = await findSportsSeasonById(params.id);
|
const sportsSeason = await findSportsSeasonById(params.id);
|
||||||
|
|
@ -136,23 +135,9 @@ async function scoreQualifyingBracket(
|
||||||
tournamentId: string | null;
|
tournamentId: string | null;
|
||||||
},
|
},
|
||||||
db: ReturnType<typeof database>,
|
db: ReturnType<typeof database>,
|
||||||
recalcOptions?: Parameters<typeof recalculateAffectedLeagues>[2],
|
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> {
|
): Promise<void> {
|
||||||
// processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent),
|
await processQualifyingBracketEvent(event.id, db);
|
||||||
// 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(
|
await recalculateAffectedLeagues(
|
||||||
event.sportsSeasonId,
|
event.sportsSeasonId,
|
||||||
db,
|
db,
|
||||||
|
|
@ -160,10 +145,7 @@ async function scoreQualifyingBracket(
|
||||||
);
|
);
|
||||||
// If this is the shared major's primary window, propagate to siblings.
|
// If this is the shared major's primary window, propagate to siblings.
|
||||||
// Mid-tournament (a single round): don't mark complete yet.
|
// Mid-tournament (a single round): don't mark complete yet.
|
||||||
await fanOutMajorIfPrimary(event, {
|
await fanOutMajorIfPrimary(event, { markComplete: false });
|
||||||
markComplete: false,
|
|
||||||
newlyEliminatedParticipantIds,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -416,13 +398,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
return { error: "Could not determine loser" };
|
return { error: "Could not determine loser" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// A knockout is "newly decided" only when the match wasn't already complete
|
|
||||||
// (mirrors populateBracketFromDraw's first-completion rule) — a re-score/
|
|
||||||
// correction of an already-finished match must not re-announce the exit.
|
|
||||||
const setWinnerNewlyEliminated = new Set(
|
|
||||||
newlyDecidedLosers([{ wasComplete: match.isComplete, loserId }])
|
|
||||||
);
|
|
||||||
|
|
||||||
// Set the winner
|
// Set the winner
|
||||||
await setMatchWinner(matchId, winnerId, loserId);
|
await setMatchWinner(matchId, winnerId, loserId);
|
||||||
|
|
||||||
|
|
@ -449,16 +424,11 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
if (event.isQualifyingEvent) {
|
if (event.isQualifyingEvent) {
|
||||||
// Qualifying major (e.g. CS2): bracket results award QUALIFYING POINTS, not
|
// Qualifying major (e.g. CS2): bracket results award QUALIFYING POINTS, not
|
||||||
// fantasy points. matchIds scopes the Discord notification to just this match.
|
// fantasy points. matchIds scopes the Discord notification to just this match.
|
||||||
await scoreQualifyingBracket(
|
await scoreQualifyingBracket(event, db, {
|
||||||
event,
|
eventId: event.id,
|
||||||
db,
|
eventName: event.name ?? undefined,
|
||||||
{
|
matchIds: [matchId],
|
||||||
eventId: event.id,
|
});
|
||||||
eventName: event.name ?? undefined,
|
|
||||||
matchIds: [matchId],
|
|
||||||
},
|
|
||||||
setWinnerNewlyEliminated
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
// Immediately score this match: loser gets their final placement,
|
// Immediately score this match: loser gets their final placement,
|
||||||
// winner gets provisional floor points (isPartialScore=true).
|
// winner gets provisional floor points (isPartialScore=true).
|
||||||
|
|
@ -530,10 +500,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
const processedMatchIds: string[] = [];
|
const processedMatchIds: string[] = [];
|
||||||
// Per-match completion + loser, collected so newlyDecidedLosers() can pick out
|
|
||||||
// the batch's first-time knockouts to fan out to mirror windows (a non-scoring-
|
|
||||||
// round exit earns no QP and is otherwise invisible to the mirror).
|
|
||||||
const decidedEntries: Array<{ wasComplete: boolean; loserId: string | null }> = [];
|
|
||||||
|
|
||||||
for (const { matchId, winnerId } of winnerAssignments) {
|
for (const { matchId, winnerId } of winnerAssignments) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -602,10 +568,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
|
|
||||||
successCount++;
|
successCount++;
|
||||||
processedMatchIds.push(matchId);
|
processedMatchIds.push(matchId);
|
||||||
// Only after the match fully succeeded: record its prior completion so
|
|
||||||
// newlyDecidedLosers() announces this loser only if it's a first-time exit
|
|
||||||
// (and never for a match whose write failed above).
|
|
||||||
decidedEntries.push({ wasComplete: match.isComplete, loserId });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error setting winner for match ${matchId}:`, error);
|
logger.error(`Error setting winner for match ${matchId}:`, error);
|
||||||
errors.push(
|
errors.push(
|
||||||
|
|
@ -619,14 +581,9 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
// previously completed matches in the event.
|
// previously completed matches in the event.
|
||||||
if (successCount > 0) {
|
if (successCount > 0) {
|
||||||
const db = database();
|
const db = database();
|
||||||
// Qualifying majors: derive QP from the full bracket once for the batch, and
|
// Qualifying majors: derive QP from the full bracket once for the batch.
|
||||||
// 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) {
|
if (event.isQualifyingEvent) {
|
||||||
await processQualifyingEvent(event.id, db, {
|
await processQualifyingBracketEvent(event.id, db);
|
||||||
newlyEliminatedParticipantIds: new Set(newlyDecidedLosers(decidedEntries)),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
// Update probabilities first so recalculateAffectedLeagues reads fresh EVs
|
// Update probabilities first so recalculateAffectedLeagues reads fresh EVs
|
||||||
// when computing projected points.
|
// when computing projected points.
|
||||||
|
|
@ -641,12 +598,8 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
if (!event.isQualifyingEvent) {
|
if (!event.isQualifyingEvent) {
|
||||||
await autoCompleteRoundIfDone(event.id, round, event.sportsSeasonId, db);
|
await autoCompleteRoundIfDone(event.id, round, event.sportsSeasonId, db);
|
||||||
} else {
|
} else {
|
||||||
// Shared major primary window: propagate this round to siblings, carrying
|
// Shared major primary window: propagate this round to siblings.
|
||||||
// the batch's newly-decided knockouts so mirrors announce them too.
|
await fanOutMajorIfPrimary(event, { markComplete: false });
|
||||||
await fanOutMajorIfPrimary(event, {
|
|
||||||
markComplete: false,
|
|
||||||
newlyEliminatedParticipantIds: new Set(newlyDecidedLosers(decidedEntries)),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -810,32 +763,11 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
// skipDiscord: reprocess is a data-correction tool, not a result announcement.
|
// skipDiscord: reprocess is a data-correction tool, not a result announcement.
|
||||||
await recalculateAffectedLeagues(event.sportsSeasonId, db, { skipDiscord: true });
|
await recalculateAffectedLeagues(event.sportsSeasonId, db, { skipDiscord: true });
|
||||||
}
|
}
|
||||||
// Re-propagate corrected QP to sibling/mirror windows (data-correction; not
|
// Re-propagate corrected QP to sibling windows (data-correction; not final).
|
||||||
// final). Call syncMajorFromPrimaryEvent directly rather than the
|
await fanOutMajorIfPrimary(event, { markComplete: false });
|
||||||
// swallow-and-log fanOutMajorIfPrimary so the admin actually sees whether the
|
return {
|
||||||
// mirrors were re-scored — a silent failure here is exactly how mirrors got
|
success: `Reprocessed qualifying bracket: cleared stale fantasy points and recomputed QP (${completed.length} completed match(es)).`,
|
||||||
// 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) {
|
if (completed.length === 0) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { Form, Link, redirect, useActionData, useNavigation } from "react-router";
|
import { Form, Link, redirect, useActionData, useNavigation } from "react-router";
|
||||||
import type { Route } from "./+types/admin.sports-seasons.$id.simulator";
|
import type { Route } from "./+types/admin.sports-seasons.$id.simulator";
|
||||||
|
|
||||||
|
|
@ -26,10 +25,6 @@ import {
|
||||||
type UpsertParticipantSimulatorInput,
|
type UpsertParticipantSimulatorInput,
|
||||||
} from "~/models/simulator";
|
} from "~/models/simulator";
|
||||||
import { normalizeName } from "~/lib/fuzzy-match";
|
import { normalizeName } from "~/lib/fuzzy-match";
|
||||||
import {
|
|
||||||
simulatorInputLabel,
|
|
||||||
type SimulatorInputKey,
|
|
||||||
} from "~/services/simulations/manifest";
|
|
||||||
import {
|
import {
|
||||||
getSimulatorInputPolicy,
|
getSimulatorInputPolicy,
|
||||||
type MissingEloStrategy,
|
type MissingEloStrategy,
|
||||||
|
|
@ -66,22 +61,7 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||||
|
|
||||||
const inputPolicy = getSimulatorInputPolicy(config.config);
|
const inputPolicy = getSimulatorInputPolicy(config.config);
|
||||||
|
|
||||||
// Sport-aware preview columns: the intersection of the displayable numeric keys
|
return { sportsSeason, participants, config, inputRows, readiness, inputPolicy };
|
||||||
// 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 {
|
interface ActionData {
|
||||||
|
|
@ -89,24 +69,6 @@ interface ActionData {
|
||||||
message: string;
|
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
|
* 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
|
* reads from config. The structured Engine fields are limited to these so the UI
|
||||||
|
|
@ -395,42 +357,6 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
||||||
.filter(([key, value]) => HONORED_ENGINE_KNOBS.has(key) && typeof value === "number")
|
.filter(([key, value]) => HONORED_ENGINE_KNOBS.has(key) && typeof value === "number")
|
||||||
.map(([key, value]) => [key, value as unknown as number] as [string, 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 (
|
return (
|
||||||
<div className="container mx-auto p-6 space-y-6">
|
<div className="container mx-auto p-6 space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
|
@ -710,124 +636,28 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
{externalInputsSection && (
|
|
||||||
<div className="rounded-md border border-sky-500/30 bg-sky-500/10 p-3 text-sm flex items-center justify-between gap-4">
|
|
||||||
<span>
|
|
||||||
This simulator's participant inputs are managed on the{" "}
|
|
||||||
<strong>{externalInputsSection.label}</strong> page — the list below is a roster only.
|
|
||||||
</span>
|
|
||||||
<Button variant="outline" size="sm" asChild>
|
|
||||||
<Link to={externalInputsSection.to}>Go to {externalInputsSection.label}</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<Input
|
|
||||||
type="search"
|
|
||||||
placeholder="Search participants…"
|
|
||||||
value={search}
|
|
||||||
onChange={(event) => {
|
|
||||||
setSearch(event.target.value);
|
|
||||||
setPage(0);
|
|
||||||
}}
|
|
||||||
className="max-w-xs"
|
|
||||||
/>
|
|
||||||
{requiredInputs.length > 0 && (
|
|
||||||
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={onlyMissing}
|
|
||||||
onChange={(event) => {
|
|
||||||
setOnlyMissing(event.target.checked);
|
|
||||||
setPage(0);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
Only show participants missing a required input
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<div
|
<div className="grid grid-cols-6 gap-2 border-b px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||||
className="grid gap-2 border-b px-3 py-2 text-xs font-medium text-muted-foreground"
|
<div className="col-span-2">Participant</div>
|
||||||
style={{ gridTemplateColumns: gridTemplate }}
|
<div>Elo</div>
|
||||||
>
|
<div>Odds</div>
|
||||||
<div>Participant</div>
|
<div>Rank</div>
|
||||||
{inputColumns.length > 0 ? (
|
<div>Rating</div>
|
||||||
inputColumns.map((column) => (
|
|
||||||
<div key={column.key} className="capitalize">
|
|
||||||
{column.label}
|
|
||||||
{column.required && <span className="text-amber-500"> *</span>}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div>—</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{pageRows.length === 0 ? (
|
{inputRows.slice(0, 20).map(({ participant, input }) => (
|
||||||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
<div key={participant.id} className="grid grid-cols-6 gap-2 border-b last:border-b-0 px-3 py-2 text-sm">
|
||||||
No participants match.
|
<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>
|
||||||
) : (
|
|
||||||
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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -186,37 +186,6 @@ describe("sendStandingsUpdateNotification", () => {
|
||||||
expect(desc).toContain("• **Sporting (christhrowsrocks)** def. Bodø/Glimt (apatel)");
|
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 () => {
|
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
|
// 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.
|
// notification; Brazil's manager is shown for context even though they didn't score.
|
||||||
|
|
@ -308,43 +277,6 @@ describe("sendStandingsUpdateNotification", () => {
|
||||||
expect(desc).not.toContain("Gamma");
|
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 () => {
|
it("omits rank delta when no previousRanks provided", async () => {
|
||||||
await sendStandingsUpdateNotification({
|
await sendStandingsUpdateNotification({
|
||||||
webhookUrl: WEBHOOK_URL,
|
webhookUrl: WEBHOOK_URL,
|
||||||
|
|
@ -778,19 +710,17 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
||||||
{ participantName: "Rafael Nadal", qpEarned: 0, qpTotal: 20, ownerUsername: "alex" },
|
{ 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 () => {
|
it("sends an embed with gold color and QP title", async () => {
|
||||||
await sendQualifyingPointsUpdateNotification({
|
await sendQualifyingPointsUpdateNotification({
|
||||||
webhookUrl: WEBHOOK_URL,
|
webhookUrl: WEBHOOK_URL,
|
||||||
seasonName: "Slam League 2025",
|
seasonName: "Slam League 2025",
|
||||||
entries: BASE_ENTRIES,
|
entries: BASE_ENTRIES,
|
||||||
standingsUrl: "https://brackt.com/leagues/abc/sports-seasons/ss-1",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const payload = getPayload();
|
const payload = getPayload();
|
||||||
expect(payload.embeds[0].title).toBe("🏅 Qualifying Points Update — Slam League 2025");
|
expect(payload.embeds[0].title).toBe("🏅 Qualifying Points Update — Slam League 2025");
|
||||||
expect(payload.embeds[0].color).toBe(0x5865f2);
|
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).toEqual({ text: "brackt.com" });
|
||||||
expect(payload.embeds[0].footer).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows sport and event header", async () => {
|
it("shows sport and event header", async () => {
|
||||||
|
|
@ -815,105 +745,38 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
||||||
|
|
||||||
const desc = getDescription();
|
const desc = getDescription();
|
||||||
expect(desc).toContain("**Points Awarded**");
|
expect(desc).toContain("**Points Awarded**");
|
||||||
expect(desc).toContain("• **Novak Djokovic (alex)** — 20 QP");
|
expect(desc).toContain("• **Novak Djokovic (alex)** — +20 QP");
|
||||||
expect(desc).toContain("• **Carlos Alcaraz (chris)** — 14 QP");
|
expect(desc).toContain("• **Carlos Alcaraz (chris)** — +14 QP");
|
||||||
// Djokovic (20 QP) should appear before Alcaraz (14 QP)
|
// Djokovic (20 QP) should appear before Alcaraz (14 QP)
|
||||||
expect(desc.indexOf("Novak Djokovic")).toBeLessThan(desc.indexOf("Carlos Alcaraz"));
|
expect(desc.indexOf("Novak Djokovic")).toBeLessThan(desc.indexOf("Carlos Alcaraz"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits zero-QP drafted participants entirely from the Drafted Participants section", async () => {
|
it("shows No Points section for entries with qpEarned == 0", 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({
|
await sendQualifyingPointsUpdateNotification({
|
||||||
webhookUrl: WEBHOOK_URL,
|
webhookUrl: WEBHOOK_URL,
|
||||||
seasonName: "Slam League 2025",
|
seasonName: "Slam League 2025",
|
||||||
entries: BASE_ENTRIES,
|
entries: BASE_ENTRIES,
|
||||||
scoreboard: BASE_ENTRIES,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const desc = getDescription();
|
const desc = getDescription();
|
||||||
expect(desc).toContain("**Drafted Participants**");
|
expect(desc).toContain("**No Points**");
|
||||||
|
expect(desc).toContain("Rafael Nadal (alex)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows QP Standings for all entries sorted by qpTotal desc", async () => {
|
||||||
|
await sendQualifyingPointsUpdateNotification({
|
||||||
|
webhookUrl: WEBHOOK_URL,
|
||||||
|
seasonName: "Slam League 2025",
|
||||||
|
entries: BASE_ENTRIES,
|
||||||
|
});
|
||||||
|
|
||||||
|
const desc = getDescription();
|
||||||
|
expect(desc).toContain("**QP Standings**");
|
||||||
expect(desc).toContain("1\\. Novak Djokovic (alex) — 45 QP");
|
expect(desc).toContain("1\\. Novak Djokovic (alex) — 45 QP");
|
||||||
expect(desc).toContain("2\\. Carlos Alcaraz (chris) — 34 QP");
|
expect(desc).toContain("2\\. Carlos Alcaraz (chris) — 34 QP");
|
||||||
expect(desc).toContain("3\\. Rafael Nadal (alex) — 20 QP");
|
expect(desc).toContain("3\\. Rafael Nadal (alex) — 20 QP");
|
||||||
// Djokovic should rank above Alcaraz
|
// Djokovic should rank above Alcaraz
|
||||||
expect(desc.indexOf("1\\. Novak")).toBeLessThan(desc.indexOf("2\\. Carlos"));
|
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 () => {
|
it("uses T-prefix for tied QP totals in standings", async () => {
|
||||||
|
|
@ -925,11 +788,6 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
||||||
{ participantName: "Player B", qpEarned: 5, qpTotal: 25 },
|
{ participantName: "Player B", qpEarned: 5, qpTotal: 25 },
|
||||||
{ participantName: "Player C", qpEarned: 2, qpTotal: 10 },
|
{ 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();
|
const desc = getDescription();
|
||||||
|
|
@ -959,63 +817,46 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const desc = getDescription();
|
const desc = getDescription();
|
||||||
// Mirrors the web UI's formatQP: fractional QP renders with up to 2 decimals and
|
// Mirrors the web UI's formatQP: fractional QP renders with 2 decimals ("1.50"),
|
||||||
// trailing zeros trimmed ("1.5"), never rounded to an integer. The Points Awarded
|
// never rounded to an integer.
|
||||||
// line no longer carries a "+".
|
expect(desc).toContain("+1.50 QP");
|
||||||
expect(desc).toContain("• **Novak Djokovic (snarkymcgee)** — 1.5 QP");
|
expect(desc).not.toContain("+2 QP");
|
||||||
expect(desc).not.toContain("+");
|
expect(desc).toContain("1.50 QP");
|
||||||
expect(desc).not.toContain("2 QP");
|
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 () => {
|
it("ranks the standings by caller-provided full-field globalRank, not position among entries", async () => {
|
||||||
// Rank-9 scorers now appear in the standings section (below the bubble), while a drafted
|
// Screenshot scenario: only two players scored this event, but they sit at T9 in
|
||||||
// participant with no points is dropped entirely.
|
// the full season field (8 players ahead). They must show T9, not T1.
|
||||||
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({
|
await sendQualifyingPointsUpdateNotification({
|
||||||
webhookUrl: WEBHOOK_URL,
|
webhookUrl: WEBHOOK_URL,
|
||||||
seasonName: "Rumble League 2026",
|
seasonName: "Rumble League 2026",
|
||||||
sportName: "Tennis - Men",
|
sportName: "Tennis - Men",
|
||||||
eventName: "Wimbledon",
|
eventName: "Wimbledon",
|
||||||
entries: both,
|
entries: [
|
||||||
scoreboard: both,
|
{
|
||||||
|
participantName: "Novak Djokovic",
|
||||||
|
qpEarned: 1.5,
|
||||||
|
qpTotal: 1.5,
|
||||||
|
globalRank: 9,
|
||||||
|
globalRankTied: true,
|
||||||
|
ownerUsername: "snarkymcgee",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
participantName: "Jannik Sinner",
|
||||||
|
qpEarned: 1.5,
|
||||||
|
qpTotal: 1.5,
|
||||||
|
globalRank: 9,
|
||||||
|
globalRankTied: true,
|
||||||
|
ownerUsername: "tanay002",
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const desc = getDescription();
|
const desc = getDescription();
|
||||||
expect(desc).toContain("**Drafted Participants**");
|
expect(desc).toContain("T9\\. Novak Djokovic (snarkymcgee) — 1.50 QP");
|
||||||
expect(desc).toContain("8\\. Player Eight (eighthowner) — 10 QP");
|
expect(desc).toContain("T9\\. Jannik Sinner (tanay002) — 1.50 QP");
|
||||||
// The rank-9 scorer now appears as a ranked row below the bubble.
|
expect(desc).not.toContain("T1\\.");
|
||||||
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 () => {
|
it("uses Discord mention instead of username when ownerDiscordUserId provided", async () => {
|
||||||
|
|
@ -1031,24 +872,14 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
||||||
ownerDiscordUserId: "111222333",
|
ownerDiscordUserId: "111222333",
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
scoreboard: withRanks([
|
|
||||||
{
|
|
||||||
participantName: "Novak Djokovic",
|
|
||||||
qpEarned: 20,
|
|
||||||
qpTotal: 45,
|
|
||||||
ownerUsername: "alex",
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const desc = getDescription();
|
const desc = getDescription();
|
||||||
// Points Awarded (a pinged section) uses the Discord mention...
|
expect(desc).toContain("Novak Djokovic (<@111222333>)");
|
||||||
expect(desc).toContain("• **Novak Djokovic (<@111222333>)** — 20 QP");
|
expect(desc).not.toContain("(alex)");
|
||||||
// ...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 () => {
|
it("pings opted-in owners who appear in awarded or no-points sections", async () => {
|
||||||
await sendQualifyingPointsUpdateNotification({
|
await sendQualifyingPointsUpdateNotification({
|
||||||
webhookUrl: WEBHOOK_URL,
|
webhookUrl: WEBHOOK_URL,
|
||||||
seasonName: "Slam League 2025",
|
seasonName: "Slam League 2025",
|
||||||
|
|
@ -1060,9 +891,9 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
||||||
|
|
||||||
const payload = getPayload();
|
const payload = getPayload();
|
||||||
expect(payload.content).toContain("<@111>");
|
expect(payload.content).toContain("<@111>");
|
||||||
expect(payload.content).not.toContain("<@222>");
|
expect(payload.content).toContain("<@222>");
|
||||||
expect(payload.allowed_mentions.users).toContain("111");
|
expect(payload.allowed_mentions.users).toContain("111");
|
||||||
expect(payload.allowed_mentions.users).not.toContain("222");
|
expect(payload.allowed_mentions.users).toContain("222");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not send when entries array is empty", async () => {
|
it("does not send when entries array is empty", async () => {
|
||||||
|
|
@ -1088,7 +919,7 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
||||||
expect(desc).toContain("• Jakob Mensik (chris)");
|
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 () => {
|
it("sends with only a Knocked Out section when there are no QP entries, omitting QP Standings", async () => {
|
||||||
await sendQualifyingPointsUpdateNotification({
|
await sendQualifyingPointsUpdateNotification({
|
||||||
webhookUrl: WEBHOOK_URL,
|
webhookUrl: WEBHOOK_URL,
|
||||||
seasonName: "Slam League 2025",
|
seasonName: "Slam League 2025",
|
||||||
|
|
@ -1099,7 +930,7 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
||||||
expect(fetch).toHaveBeenCalledOnce();
|
expect(fetch).toHaveBeenCalledOnce();
|
||||||
const desc = getDescription();
|
const desc = getDescription();
|
||||||
expect(desc).toContain("**Knocked Out**");
|
expect(desc).toContain("**Knocked Out**");
|
||||||
expect(desc).not.toContain("**Drafted Participants**");
|
expect(desc).not.toContain("**QP Standings**");
|
||||||
expect(desc).not.toContain("**Points Awarded**");
|
expect(desc).not.toContain("**Points Awarded**");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1144,7 +975,6 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
||||||
webhookUrl: WEBHOOK_URL,
|
webhookUrl: WEBHOOK_URL,
|
||||||
seasonName: "Slam League 2025",
|
seasonName: "Slam League 2025",
|
||||||
entries: longEntries,
|
entries: longEntries,
|
||||||
scoreboard: longEntries,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const desc = getDescription();
|
const desc = getDescription();
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ import { findDiscordIdsByUserIds } from "~/models/account";
|
||||||
const SPORTS_SEASON_ID = "ss-1";
|
const SPORTS_SEASON_ID = "ss-1";
|
||||||
const SCORING_EVENT_ID = "ev-1";
|
const SCORING_EVENT_ID = "ev-1";
|
||||||
const SEASON_ID = "season-1";
|
const SEASON_ID = "season-1";
|
||||||
const LEAGUE_ID = "league-1";
|
|
||||||
const PARTICIPANT_ID = "p-1";
|
const PARTICIPANT_ID = "p-1";
|
||||||
const OWNER_ID = "u-1";
|
const OWNER_ID = "u-1";
|
||||||
const WEBHOOK_URL = "https://discord.com/api/webhooks/123/abc";
|
const WEBHOOK_URL = "https://discord.com/api/webhooks/123/abc";
|
||||||
|
|
@ -69,7 +68,7 @@ function makeDb(overrides: Record<string, unknown> = {}) {
|
||||||
{
|
{
|
||||||
id: SEASON_ID,
|
id: SEASON_ID,
|
||||||
year: 2025,
|
year: 2025,
|
||||||
league: { id: LEAGUE_ID, name: "Slam League", discordWebhookUrl: WEBHOOK_URL },
|
league: { name: "Slam League", discordWebhookUrl: WEBHOOK_URL },
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
|
|
@ -83,7 +82,7 @@ function makeDb(overrides: Record<string, unknown> = {}) {
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
seasonParticipants: {
|
seasonParticipants: {
|
||||||
findMany: vi.fn().mockResolvedValue([{ id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID }]),
|
findMany: vi.fn().mockResolvedValue([{ id: PARTICIPANT_ID, name: "Carlos Alcaraz" }]),
|
||||||
},
|
},
|
||||||
users: {
|
users: {
|
||||||
findMany: vi.fn().mockResolvedValue([
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
|
@ -177,7 +176,6 @@ describe("notifyQualifyingPointsUpdate", () => {
|
||||||
seasonName: "Slam League 2025",
|
seasonName: "Slam League 2025",
|
||||||
eventName: "Roland Garros 2025",
|
eventName: "Roland Garros 2025",
|
||||||
sportName: "Tennis",
|
sportName: "Tennis",
|
||||||
standingsUrl: `${process.env.APP_URL ?? "https://brackt.com"}/leagues/${LEAGUE_ID}/sports-seasons/${SPORTS_SEASON_ID}`,
|
|
||||||
entries: [
|
entries: [
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
participantName: "Carlos Alcaraz",
|
participantName: "Carlos Alcaraz",
|
||||||
|
|
@ -263,8 +261,8 @@ describe("notifyQualifyingPointsUpdate", () => {
|
||||||
},
|
},
|
||||||
seasonParticipants: {
|
seasonParticipants: {
|
||||||
findMany: vi.fn().mockResolvedValue([
|
findMany: vi.fn().mockResolvedValue([
|
||||||
{ id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID },
|
{ id: PARTICIPANT_ID, name: "Carlos Alcaraz" },
|
||||||
{ id: "p-2", name: "Rafael Nadal", sportsSeasonId: SPORTS_SEASON_ID },
|
{ id: "p-2", name: "Rafael Nadal" },
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -281,84 +279,6 @@ describe("notifyQualifyingPointsUpdate", () => {
|
||||||
expect(call.entries[0].participantName).toBe("Carlos Alcaraz");
|
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 () => {
|
it("does not call sendQualifyingPointsUpdateNotification when all participants are filtered out", async () => {
|
||||||
const db = makeDb();
|
const db = makeDb();
|
||||||
|
|
||||||
|
|
@ -393,8 +313,8 @@ describe("notifyQualifyingPointsUpdate", () => {
|
||||||
},
|
},
|
||||||
seasonParticipants: {
|
seasonParticipants: {
|
||||||
findMany: vi.fn().mockResolvedValue([
|
findMany: vi.fn().mockResolvedValue([
|
||||||
{ id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID },
|
{ id: PARTICIPANT_ID, name: "Carlos Alcaraz" },
|
||||||
{ id: MENSIK_ID, name: "Jakob Mensik", sportsSeasonId: SPORTS_SEASON_ID },
|
{ id: MENSIK_ID, name: "Jakob Mensik" },
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,24 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import type { Mock } from "vitest";
|
|
||||||
import type * as DrizzleOrm from "drizzle-orm";
|
import type * as DrizzleOrm from "drizzle-orm";
|
||||||
import type * as ScoringCalculatorModule from "~/models/scoring-calculator";
|
|
||||||
|
|
||||||
vi.mock("~/database/context", () => ({
|
vi.mock("~/database/context", () => ({
|
||||||
database: vi.fn(),
|
database: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("~/models/scoring-calculator", async () => {
|
vi.mock("~/models/scoring-calculator", () => ({
|
||||||
// Keep the real implementations of the PURE helpers so the tie-count map handed
|
processQualifyingEvent: vi.fn(),
|
||||||
// to processQualifyingEvent reflects the mock canonical results AND the bracket's
|
recalculateAffectedLeagues: vi.fn(),
|
||||||
// structural tie span (deriveBracketQualifyingStates / getRoundConfig). Only the
|
// Pure helper — keep the real implementation so the tie-count map handed to
|
||||||
// DB-touching orchestrators are stubbed.
|
// processQualifyingEvent reflects the mock canonical results.
|
||||||
const actual = await vi.importActual<typeof ScoringCalculatorModule>(
|
buildTieCountByPlacement: (results: Array<{ placement: number | null }>) => {
|
||||||
"~/models/scoring-calculator"
|
const map = new Map<number, number>();
|
||||||
);
|
for (const r of results) {
|
||||||
return {
|
if (r.placement === null) continue;
|
||||||
processQualifyingEvent: vi.fn(),
|
map.set(r.placement, (map.get(r.placement) ?? 0) + 1);
|
||||||
recalculateAffectedLeagues: vi.fn(),
|
}
|
||||||
buildTieCountByPlacement: actual.buildTieCountByPlacement,
|
return map;
|
||||||
deriveBracketQualifyingStates: actual.deriveBracketQualifyingStates,
|
},
|
||||||
getRoundConfig: actual.getRoundConfig,
|
}));
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("~/models/scoring-event", () => ({
|
vi.mock("~/models/scoring-event", () => ({
|
||||||
completeScoringEvent: vi.fn(),
|
completeScoringEvent: vi.fn(),
|
||||||
|
|
@ -103,21 +99,11 @@ interface FakeEventResult {
|
||||||
// - event_results
|
// - event_results
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface FakePlayoffMatch {
|
|
||||||
scoringEventId: string;
|
|
||||||
round: string;
|
|
||||||
winnerId: string | null;
|
|
||||||
loserId: string | null;
|
|
||||||
participant1Id: string | null;
|
|
||||||
participant2Id: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FakeDbState {
|
interface FakeDbState {
|
||||||
tournamentResults: FakeTournamentResult[];
|
tournamentResults: FakeTournamentResult[];
|
||||||
scoringEvents: FakeScoringEvent[];
|
scoringEvents: FakeScoringEvent[];
|
||||||
seasonParticipants: FakeSeasonParticipant[];
|
seasonParticipants: FakeSeasonParticipant[];
|
||||||
eventResults: FakeEventResult[];
|
eventResults: FakeEventResult[];
|
||||||
playoffMatches: FakePlayoffMatch[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -165,8 +151,6 @@ function makeFakeDb(state: FakeDbState) {
|
||||||
return state.seasonParticipants;
|
return state.seasonParticipants;
|
||||||
case "event_results":
|
case "event_results":
|
||||||
return state.eventResults;
|
return state.eventResults;
|
||||||
case "playoff_matches":
|
|
||||||
return state.playoffMatches;
|
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown table in fake db: ${name}`);
|
throw new Error(`Unknown table in fake db: ${name}`);
|
||||||
}
|
}
|
||||||
|
|
@ -274,12 +258,6 @@ vi.mock("drizzle-orm", async () => {
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
and: (...preds: any[]) => (row: any) => preds.every((p) => p(row)),
|
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]);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -313,7 +291,6 @@ function seedBasicState(overrides: Partial<FakeDbState> = {}): FakeDbState {
|
||||||
scoringEvents: [],
|
scoringEvents: [],
|
||||||
seasonParticipants: [],
|
seasonParticipants: [],
|
||||||
eventResults: [],
|
eventResults: [],
|
||||||
playoffMatches: [],
|
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -859,214 +836,6 @@ describe("syncMajorFromPrimaryEvent", () => {
|
||||||
expect(completeScoringEvent).not.toHaveBeenCalled();
|
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 () => {
|
it("throws when the primary event is not linked to a tournament", async () => {
|
||||||
const db = makeFakeDb(seedBasicState());
|
const db = makeFakeDb(seedBasicState());
|
||||||
vi.mocked(database).mockReturnValue(db as never);
|
vi.mocked(database).mockReturnValue(db as never);
|
||||||
|
|
|
||||||
|
|
@ -66,12 +66,11 @@ function escapeMarkdown(text: string): string {
|
||||||
/**
|
/**
|
||||||
* Format a QP value for display. QP is genuinely fractional (e.g. a tennis R16 loser
|
* 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
|
* 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).
|
* and fractional values to 2 decimals. Mirrors the web UI's formatQP
|
||||||
* Mirrors the web UI's formatQP (app/components/scoring/QualifyingPointsStandings.tsx)
|
* (app/components/scoring/QualifyingPointsStandings.tsx) so Discord and the site agree.
|
||||||
* so Discord and the site agree.
|
|
||||||
*/
|
*/
|
||||||
function formatQPValue(n: number): string {
|
function formatQPValue(n: number): string {
|
||||||
return parseFloat(n.toFixed(2)).toString();
|
return n % 1 === 0 ? n.toString() : n.toFixed(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StandingEntry {
|
export interface StandingEntry {
|
||||||
|
|
@ -176,19 +175,13 @@ export async function sendStandingsUpdateNotification({
|
||||||
const isTied = buildTiedRankChecker(standings.map((s) => s.rank));
|
const isTied = buildTiedRankChecker(standings.map((s) => s.rank));
|
||||||
const rankLabel = (rank: number) => (isTied(rank) ? `T${rank}` : `${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.
|
// Standings changes section — show teams whose points or rank changed.
|
||||||
const changedTeams = standings.filter((s) => {
|
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 prevRank = previousRanks?.get(s.teamId);
|
||||||
const rankChanged = prevRank !== undefined && prevRank !== s.rank;
|
const rankChanged = prevRank !== undefined && prevRank !== s.rank;
|
||||||
return pointsChanged(s) || rankChanged;
|
return pointsChanged || rankChanged;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (changedTeams.length > 0) {
|
if (changedTeams.length > 0) {
|
||||||
|
|
@ -213,7 +206,7 @@ export async function sendStandingsUpdateNotification({
|
||||||
}
|
}
|
||||||
|
|
||||||
const escapedName = escapeMarkdown(s.teamName);
|
const escapedName = escapeMarkdown(s.teamName);
|
||||||
const managerLabel = s.discordUserId && pointsChanged(s)
|
const managerLabel = s.discordUserId
|
||||||
? `<@${s.discordUserId}>`
|
? `<@${s.discordUserId}>`
|
||||||
: s.username
|
: s.username
|
||||||
? escapeMarkdown(s.username)
|
? escapeMarkdown(s.username)
|
||||||
|
|
@ -232,7 +225,7 @@ export async function sendStandingsUpdateNotification({
|
||||||
// Collect Discord user IDs of all opted-in managers appearing in this notification.
|
// Collect Discord user IDs of all opted-in managers appearing in this notification.
|
||||||
const pingUserIds = new Set<string>();
|
const pingUserIds = new Set<string>();
|
||||||
for (const s of changedTeams) {
|
for (const s of changedTeams) {
|
||||||
if (s.discordUserId && pointsChanged(s)) pingUserIds.add(s.discordUserId);
|
if (s.discordUserId) pingUserIds.add(s.discordUserId);
|
||||||
}
|
}
|
||||||
for (const m of relevantMatches ?? []) {
|
for (const m of relevantMatches ?? []) {
|
||||||
if (m.winnerDiscordUserId) pingUserIds.add(m.winnerDiscordUserId);
|
if (m.winnerDiscordUserId) pingUserIds.add(m.winnerDiscordUserId);
|
||||||
|
|
@ -295,8 +288,6 @@ export async function sendQualifyingPointsUpdateNotification({
|
||||||
eventName,
|
eventName,
|
||||||
entries,
|
entries,
|
||||||
eliminated = [],
|
eliminated = [],
|
||||||
scoreboard = [],
|
|
||||||
standingsUrl,
|
|
||||||
}: {
|
}: {
|
||||||
webhookUrl: string;
|
webhookUrl: string;
|
||||||
seasonName: string;
|
seasonName: string;
|
||||||
|
|
@ -304,14 +295,6 @@ export async function sendQualifyingPointsUpdateNotification({
|
||||||
eventName?: string;
|
eventName?: string;
|
||||||
entries: QPEventEntry[];
|
entries: QPEventEntry[];
|
||||||
eliminated?: QPEliminatedEntry[];
|
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> {
|
}): Promise<void> {
|
||||||
if (entries.length === 0 && eliminated.length === 0) return;
|
if (entries.length === 0 && eliminated.length === 0) return;
|
||||||
|
|
||||||
|
|
@ -337,7 +320,23 @@ export async function sendQualifyingPointsUpdateNotification({
|
||||||
const label = managerLabel
|
const label = managerLabel
|
||||||
? `${escapeMarkdown(e.participantName)} (${managerLabel})`
|
? `${escapeMarkdown(e.participantName)} (${managerLabel})`
|
||||||
: escapeMarkdown(e.participantName);
|
: escapeMarkdown(e.participantName);
|
||||||
sections.push(`• **${label}** — ${formatQPValue(e.qpEarned)} QP`);
|
sections.push(`• **${label}** — +${formatQPValue(e.qpEarned)} QP`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const zeroEntries = entries.filter((e) => e.qpEarned === 0);
|
||||||
|
if (zeroEntries.length > 0) {
|
||||||
|
sections.push("\n**No Points**");
|
||||||
|
for (const e of zeroEntries) {
|
||||||
|
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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -358,35 +357,22 @@ export async function sendQualifyingPointsUpdateNotification({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drafted Participants: every drafted participant that has actually scored (qpTotal > 0),
|
// Order by the participant's rank in the FULL season standings (globalRank),
|
||||||
// drawn from the FULL drafted field (`scoreboard`) not just this sync's movers, so it reads
|
// supplied by the caller, so the block reads as a season leaderboard slice.
|
||||||
// as a live standings snapshot. Rendered as ranked lines ordered by full-season standing. A
|
const sorted = [...entries].toSorted((a, b) => a.globalRank - b.globalRank);
|
||||||
// "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
|
// The standings block reflects QP earners; skip it entirely when this sync only
|
||||||
// reported knockouts) so we don't emit an empty header.
|
// reported knockouts (no QP change) so we don't emit an empty header.
|
||||||
if (scored.length > 0) {
|
if (sorted.length > 0) {
|
||||||
sections.push("\n**Drafted Participants**");
|
sections.push("\n**QP Standings**");
|
||||||
// Insert the divider once, before the first below-the-cutoff (rank >= 9) row. `>= 9`
|
for (const e of sorted) {
|
||||||
// (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 rankPrefix = e.globalRankTied ? `T${e.globalRank}` : `${e.globalRank}`;
|
||||||
const managerLabel = e.ownerUsername ? ` (${escapeMarkdown(e.ownerUsername)})` : "";
|
const ownerLabel = e.ownerDiscordUserId
|
||||||
|
? `<@${e.ownerDiscordUserId}>`
|
||||||
|
: e.ownerUsername
|
||||||
|
? escapeMarkdown(e.ownerUsername)
|
||||||
|
: undefined;
|
||||||
|
const managerLabel = ownerLabel ? ` (${ownerLabel})` : "";
|
||||||
sections.push(`${rankPrefix}\\. ${escapeMarkdown(e.participantName)}${managerLabel} — ${formatQPValue(e.qpTotal)} QP`);
|
sections.push(`${rankPrefix}\\. ${escapeMarkdown(e.participantName)}${managerLabel} — ${formatQPValue(e.qpTotal)} QP`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -397,10 +383,8 @@ export async function sendQualifyingPointsUpdateNotification({
|
||||||
description = description.slice(0, MAX_DESCRIPTION - 3) + "...";
|
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>();
|
const pingUserIds = new Set<string>();
|
||||||
for (const e of [...awardedEntries, ...eliminated]) {
|
for (const e of [...awardedEntries, ...zeroEntries, ...eliminated]) {
|
||||||
if (e.ownerDiscordUserId) pingUserIds.add(e.ownerDiscordUserId);
|
if (e.ownerDiscordUserId) pingUserIds.add(e.ownerDiscordUserId);
|
||||||
}
|
}
|
||||||
const pingIds = [...pingUserIds];
|
const pingIds = [...pingUserIds];
|
||||||
|
|
@ -409,9 +393,9 @@ export async function sendQualifyingPointsUpdateNotification({
|
||||||
embeds: [
|
embeds: [
|
||||||
{
|
{
|
||||||
title: `🏅 Qualifying Points Update — ${seasonName}`,
|
title: `🏅 Qualifying Points Update — ${seasonName}`,
|
||||||
url: standingsUrl,
|
|
||||||
description,
|
description,
|
||||||
color: 0x5865f2, // Discord blurple
|
color: 0x5865f2, // Discord blurple
|
||||||
|
footer: { text: "brackt.com" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -611,16 +611,9 @@ export async function syncTennisDraw(eventId: string): Promise<DrawSyncResult> {
|
||||||
eventId,
|
eventId,
|
||||||
eventName: event.name ?? undefined,
|
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(
|
await fanOutMajorIfPrimary(
|
||||||
{ id: event.id, isPrimary: event.isPrimary, tournamentId: event.tournamentId },
|
{ id: event.id, isPrimary: event.isPrimary, tournamentId: event.tournamentId },
|
||||||
{ markComplete: false, newlyEliminatedParticipantIds: newlyEliminatedIds },
|
{ markComplete: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Announce QP changes for the primary window. Sibling windows are announced by
|
// Announce QP changes for the primary window. Sibling windows are announced by
|
||||||
|
|
@ -629,7 +622,10 @@ export async function syncTennisDraw(eventId: string): Promise<DrawSyncResult> {
|
||||||
// outside the rescore transaction so the webhook HTTP call neither holds the
|
// outside the rescore transaction so the webhook HTTP call neither holds the
|
||||||
// transaction open nor rolls back the score if Discord fails.
|
// transaction open nor rolls back the score if Discord fails.
|
||||||
//
|
//
|
||||||
|
// Also announce players knocked out this sync in a non-scoring round (rounds
|
||||||
|
// 1–3 of a Grand Slam), who earn no QP and so never appear via changedParticipantIds.
|
||||||
// Fire even when no QP changed, so an early-round elimination is still surfaced.
|
// Fire even when no QP changed, so an early-round elimination is still surfaced.
|
||||||
|
const newlyEliminatedIds = new Set(newlyDecidedLoserIds);
|
||||||
if (changedParticipantIds.size > 0 || newlyEliminatedIds.size > 0) {
|
if (changedParticipantIds.size > 0 || newlyEliminatedIds.size > 0) {
|
||||||
try {
|
try {
|
||||||
await notifyQualifyingPointsUpdate(
|
await notifyQualifyingPointsUpdate(
|
||||||
|
|
|
||||||
|
|
@ -125,22 +125,12 @@ export async function notifyQualifyingPointsUpdate(
|
||||||
});
|
});
|
||||||
const seasonMap = new Map(seasons.map((s) => [s.id, s]));
|
const seasonMap = new Map(seasons.map((s) => [s.id, s]));
|
||||||
|
|
||||||
// Batch-fetch participant display names once (same participants across all leagues).
|
// Batch-fetch participant display names once (same participants across all leagues)
|
||||||
// Includes every drafted participant — the Drafted Participants scoreboard section
|
const allParticipantIds = [...new Set([...qpEarnedById.keys(), ...eliminatedIds])];
|
||||||
// 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({
|
const participants = await db.query.seasonParticipants.findMany({
|
||||||
where: inArray(schema.seasonParticipants.id, allParticipantIds),
|
where: inArray(schema.seasonParticipants.id, allParticipantIds),
|
||||||
});
|
});
|
||||||
const participantNameById = new Map(participants.map((p) => [p.id, p.name]));
|
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)
|
// Batch-fetch all team owners and their Discord IDs in two queries (not N per league)
|
||||||
const allOwnerIds = new Set<string>();
|
const allOwnerIds = new Set<string>();
|
||||||
|
|
@ -167,12 +157,6 @@ export async function notifyQualifyingPointsUpdate(
|
||||||
|
|
||||||
const seasonName = `${league.name} ${season.year}`;
|
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 picks = picksBySeasonId.get(seasonId) ?? [];
|
||||||
const teamByParticipantId = new Map<
|
const teamByParticipantId = new Map<
|
||||||
string,
|
string,
|
||||||
|
|
@ -218,25 +202,6 @@ export async function notifyQualifyingPointsUpdate(
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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({
|
await sendQualifyingPointsUpdateNotification({
|
||||||
webhookUrl,
|
webhookUrl,
|
||||||
seasonName,
|
seasonName,
|
||||||
|
|
@ -244,8 +209,6 @@ export async function notifyQualifyingPointsUpdate(
|
||||||
eventName,
|
eventName,
|
||||||
entries,
|
entries,
|
||||||
eliminated,
|
eliminated,
|
||||||
scoreboard,
|
|
||||||
standingsUrl,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { findMatchingTeamName } from "~/lib/normalize-team-name";
|
import { findMatchingTeamName } from "~/lib/normalize-team-name";
|
||||||
import { OcBlacktopIndyCarStandingsAdapter } from "../f1";
|
|
||||||
import type { FetchedStandingsRecord, StandingsSyncAdapter } from "../types";
|
|
||||||
|
|
||||||
// Pure unit tests for the name-matching logic used by syncStandings.
|
// Pure unit tests for the name-matching logic used by syncStandings.
|
||||||
// The full orchestrator requires DB context; those are covered by integration tests.
|
// The full orchestrator requires DB context; those are covered by integration tests.
|
||||||
|
|
@ -48,143 +46,3 @@ describe("findMatchingTeamName (sync name-matching logic)", () => {
|
||||||
expect(findMatchingTeamName(" Boston Celtics ", participants)).toBe("Boston Celtics");
|
expect(findMatchingTeamName(" Boston Celtics ", participants)).toBe("Boston Celtics");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("OcBlacktopIndyCarStandingsAdapter", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
// A fallback that records whether it was invoked and returns a sentinel result.
|
|
||||||
function makeFallback(): StandingsSyncAdapter & { called: boolean } {
|
|
||||||
const stub = {
|
|
||||||
called: false,
|
|
||||||
async fetchStandings(): Promise<FetchedStandingsRecord[]> {
|
|
||||||
stub.called = true;
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
teamName: "ESPN Fallback Driver",
|
|
||||||
externalTeamId: "espn-1",
|
|
||||||
wins: 0,
|
|
||||||
losses: 0,
|
|
||||||
winPct: 0,
|
|
||||||
gamesPlayed: 0,
|
|
||||||
leagueRank: 1,
|
|
||||||
currentPoints: 1,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return stub;
|
|
||||||
}
|
|
||||||
|
|
||||||
it("maps OC Blacktop driver standings to FetchedStandingsRecord", async () => {
|
|
||||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
||||||
new Response(
|
|
||||||
JSON.stringify([
|
|
||||||
{ id: "ocb-palou", position: 1, points: "409.00", firstName: "Alex", lastName: "Palou" },
|
|
||||||
{ id: "ocb-kirkwood", position: 2, points: "348.00", firstName: "Kyle", lastName: "Kirkwood" },
|
|
||||||
]),
|
|
||||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const fallback = makeFallback();
|
|
||||||
const adapter = new OcBlacktopIndyCarStandingsAdapter("test-key", fallback);
|
|
||||||
const records = await adapter.fetchStandings();
|
|
||||||
|
|
||||||
expect(fallback.called).toBe(false);
|
|
||||||
expect(records).toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
teamName: "Alex Palou",
|
|
||||||
externalTeamId: "ocb-palou",
|
|
||||||
leagueRank: 1,
|
|
||||||
currentPoints: 409,
|
|
||||||
}),
|
|
||||||
expect.objectContaining({
|
|
||||||
teamName: "Kyle Kirkwood",
|
|
||||||
externalTeamId: "ocb-kirkwood",
|
|
||||||
leagueRank: 2,
|
|
||||||
currentPoints: 348,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to ESPN when the API responds non-OK", async () => {
|
|
||||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
||||||
new Response("nope", { status: 500, statusText: "Internal Server Error" })
|
|
||||||
);
|
|
||||||
|
|
||||||
const fallback = makeFallback();
|
|
||||||
const adapter = new OcBlacktopIndyCarStandingsAdapter("test-key", fallback);
|
|
||||||
const records = await adapter.fetchStandings();
|
|
||||||
|
|
||||||
expect(fallback.called).toBe(true);
|
|
||||||
expect(records[0].teamName).toBe("ESPN Fallback Driver");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to ESPN when the payload is empty", async () => {
|
|
||||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
||||||
new Response("[]", { status: 200, headers: { "Content-Type": "application/json" } })
|
|
||||||
);
|
|
||||||
|
|
||||||
const fallback = makeFallback();
|
|
||||||
const adapter = new OcBlacktopIndyCarStandingsAdapter("test-key", fallback);
|
|
||||||
await adapter.fetchStandings();
|
|
||||||
|
|
||||||
expect(fallback.called).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to ESPN when the payload is missing expected fields (schema drift)", async () => {
|
|
||||||
// Non-empty array, but `points` was renamed upstream — must fail over, not
|
|
||||||
// silently sync zeros over real standings.
|
|
||||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
||||||
new Response(
|
|
||||||
JSON.stringify([
|
|
||||||
{ id: "ocb-palou", position: 1, championshipPoints: "409.00", firstName: "Alex", lastName: "Palou" },
|
|
||||||
]),
|
|
||||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const fallback = makeFallback();
|
|
||||||
const adapter = new OcBlacktopIndyCarStandingsAdapter("test-key", fallback);
|
|
||||||
const records = await adapter.fetchStandings();
|
|
||||||
|
|
||||||
expect(fallback.called).toBe(true);
|
|
||||||
expect(records[0].teamName).toBe("ESPN Fallback Driver");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps legitimate zero-point drivers (0 points is valid, not a failure)", async () => {
|
|
||||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
||||||
new Response(
|
|
||||||
JSON.stringify([
|
|
||||||
{ id: "ocb-1", position: 1, points: "409.00", firstName: "Alex", lastName: "Palou" },
|
|
||||||
{ id: "ocb-2", position: 33, points: "0.00", firstName: "Rookie", lastName: "Driver" },
|
|
||||||
]),
|
|
||||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const fallback = makeFallback();
|
|
||||||
const adapter = new OcBlacktopIndyCarStandingsAdapter("test-key", fallback);
|
|
||||||
const records = await adapter.fetchStandings();
|
|
||||||
|
|
||||||
expect(fallback.called).toBe(false);
|
|
||||||
expect(records[1]).toEqual(
|
|
||||||
expect.objectContaining({ teamName: "Rookie Driver", leagueRank: 33, currentPoints: 0 })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to ESPN when no API key is configured", async () => {
|
|
||||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
|
||||||
|
|
||||||
const fallback = makeFallback();
|
|
||||||
// Empty string is falsy but (unlike undefined) does not trigger the env default.
|
|
||||||
const adapter = new OcBlacktopIndyCarStandingsAdapter("", fallback);
|
|
||||||
const records = await adapter.fetchStandings();
|
|
||||||
|
|
||||||
expect(fetchSpy).not.toHaveBeenCalled();
|
|
||||||
expect(fallback.called).toBe(true);
|
|
||||||
expect(records[0].teamName).toBe("ESPN Fallback Driver");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types";
|
import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types";
|
||||||
import { statsMap, type EspnStat } from "./espn";
|
import { statsMap, type EspnStat } from "./espn";
|
||||||
import { logger } from "~/lib/logger";
|
|
||||||
|
|
||||||
// Jolpica is the maintained successor to the deprecated Ergast API — same JSON shape, no key needed.
|
// Jolpica is the maintained successor to the deprecated Ergast API — same JSON shape, no key needed.
|
||||||
const JOLPICA_DRIVER_STANDINGS_URL = "https://api.jolpi.ca/ergast/f1/current/driverStandings.json";
|
const JOLPICA_DRIVER_STANDINGS_URL = "https://api.jolpi.ca/ergast/f1/current/driverStandings.json";
|
||||||
|
|
@ -9,11 +8,6 @@ const JOLPICA_DRIVER_STANDINGS_URL = "https://api.jolpi.ca/ergast/f1/current/dri
|
||||||
const ESPN_INDYCAR_STANDINGS_URL =
|
const ESPN_INDYCAR_STANDINGS_URL =
|
||||||
"https://site.api.espn.com/apis/v2/sports/racing/irl/standings";
|
"https://site.api.espn.com/apis/v2/sports/racing/irl/standings";
|
||||||
|
|
||||||
// OC Blacktop IndyCar driver standings — updates within hours of a race finishing,
|
|
||||||
// unlike ESPN's aggregate which can lag most of a day. Requires an API key.
|
|
||||||
const OCBLACKTOP_INDYCAR_STANDINGS_URL =
|
|
||||||
"https://api.ocblacktop.com/v1/indycar/standings/drivers";
|
|
||||||
|
|
||||||
interface JolpicaDriverStanding {
|
interface JolpicaDriverStanding {
|
||||||
position: string;
|
position: string;
|
||||||
points: string;
|
points: string;
|
||||||
|
|
@ -143,92 +137,3 @@ export class IndyCarStandingsAdapter implements StandingsSyncAdapter {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OC Blacktop returns a flat array of driver standings. Fields are typed as
|
|
||||||
// optional because this is untrusted external JSON — fetchFromOcBlacktop validates
|
|
||||||
// them at runtime before use.
|
|
||||||
interface OcBlacktopDriverStanding {
|
|
||||||
id?: string;
|
|
||||||
position?: number;
|
|
||||||
points?: string; // e.g. "409.00"
|
|
||||||
firstName?: string;
|
|
||||||
lastName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* IndyCar standings from OC Blacktop, which posts updated championship points within
|
|
||||||
* hours of a race — far fresher than ESPN's aggregate, which can stay stale most of a
|
|
||||||
* day. Falls back to ESPN on any failure (missing key, network error, empty response)
|
|
||||||
* so a flaky third party never breaks the sync.
|
|
||||||
*/
|
|
||||||
export class OcBlacktopIndyCarStandingsAdapter implements StandingsSyncAdapter {
|
|
||||||
constructor(
|
|
||||||
private readonly apiKey = process.env.OCBLACKTOP_API_KEY,
|
|
||||||
private readonly fallback: StandingsSyncAdapter = new IndyCarStandingsAdapter()
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async fetchStandings(): Promise<FetchedStandingsRecord[]> {
|
|
||||||
try {
|
|
||||||
return await this.fetchFromOcBlacktop();
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
logger.warn(
|
|
||||||
`OC Blacktop IndyCar standings unavailable (${message}); falling back to ESPN.`
|
|
||||||
);
|
|
||||||
return this.fallback.fetchStandings();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchFromOcBlacktop(): Promise<FetchedStandingsRecord[]> {
|
|
||||||
if (!this.apiKey) {
|
|
||||||
throw new Error(
|
|
||||||
"OCBLACKTOP_API_KEY is required to sync IndyCar standings from OC Blacktop."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(OCBLACKTOP_INDYCAR_STANDINGS_URL, {
|
|
||||||
headers: { "x-api-key": this.apiKey },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(
|
|
||||||
`OC Blacktop IndyCar standings API returned ${res.status}: ${res.statusText}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const drivers = (await res.json()) as OcBlacktopDriverStanding[];
|
|
||||||
|
|
||||||
if (!Array.isArray(drivers) || drivers.length === 0) {
|
|
||||||
throw new Error(
|
|
||||||
"OC Blacktop IndyCar standings API returned no entries — season may not have started or response shape changed"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return drivers.map((d): FetchedStandingsRecord => {
|
|
||||||
// Validate the fields we depend on rather than coercing missing data into
|
|
||||||
// plausible-looking values. A field going missing/renamed upstream means the
|
|
||||||
// response shape drifted — throwing routes us to the ESPN fallback instead of
|
|
||||||
// silently overwriting real standings with zeros. (A 0-point backmarker is
|
|
||||||
// legitimate; an *unparseable* points value is not.)
|
|
||||||
const points = Number.parseFloat(d.points ?? "");
|
|
||||||
const position = Number(d.position);
|
|
||||||
const name = `${d.firstName ?? ""} ${d.lastName ?? ""}`.trim();
|
|
||||||
if (!d.id || !name || !Number.isFinite(position) || !Number.isFinite(points)) {
|
|
||||||
throw new Error(
|
|
||||||
`OC Blacktop IndyCar standings entry is missing expected fields (${JSON.stringify(d)}) — response shape may have changed`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
teamName: name,
|
|
||||||
externalTeamId: d.id,
|
|
||||||
leagueRank: position,
|
|
||||||
wins: 0,
|
|
||||||
losses: 0,
|
|
||||||
gamesPlayed: 0,
|
|
||||||
winPct: 0,
|
|
||||||
currentPoints: points,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { MlbStandingsAdapter } from "./mlb";
|
||||||
import { WnbaStandingsAdapter } from "./wnba";
|
import { WnbaStandingsAdapter } from "./wnba";
|
||||||
import { EplStandingsAdapter } from "./epl";
|
import { EplStandingsAdapter } from "./epl";
|
||||||
import { MlsStandingsAdapter } from "./mls";
|
import { MlsStandingsAdapter } from "./mls";
|
||||||
import { F1StandingsAdapter, OcBlacktopIndyCarStandingsAdapter } from "./f1";
|
import { F1StandingsAdapter, IndyCarStandingsAdapter } from "./f1";
|
||||||
import type { StandingsSyncAdapter, SyncResult, UnmatchedTeam } from "./types";
|
import type { StandingsSyncAdapter, SyncResult, UnmatchedTeam } from "./types";
|
||||||
|
|
||||||
function getAdapter(simulatorType: string): StandingsSyncAdapter {
|
function getAdapter(simulatorType: string): StandingsSyncAdapter {
|
||||||
|
|
@ -35,7 +35,7 @@ function getAdapter(simulatorType: string): StandingsSyncAdapter {
|
||||||
case "f1_standings":
|
case "f1_standings":
|
||||||
return new F1StandingsAdapter();
|
return new F1StandingsAdapter();
|
||||||
case "indycar_standings":
|
case "indycar_standings":
|
||||||
return new OcBlacktopIndyCarStandingsAdapter();
|
return new IndyCarStandingsAdapter();
|
||||||
default:
|
default:
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`No standings sync adapter available for simulator type "${simulatorType}". ` +
|
`No standings sync adapter available for simulator type "${simulatorType}". ` +
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,10 @@ import {
|
||||||
processQualifyingEvent,
|
processQualifyingEvent,
|
||||||
recalculateAffectedLeagues,
|
recalculateAffectedLeagues,
|
||||||
buildTieCountByPlacement,
|
buildTieCountByPlacement,
|
||||||
deriveBracketQualifyingStates,
|
|
||||||
getRoundConfig,
|
|
||||||
} from "~/models/scoring-calculator";
|
} from "~/models/scoring-calculator";
|
||||||
import { completeScoringEvent, getScoringEventById } from "~/models/scoring-event";
|
import { completeScoringEvent, getScoringEventById } from "~/models/scoring-event";
|
||||||
import { getEventResults } from "~/models/event-result";
|
import { getEventResults } from "~/models/event-result";
|
||||||
import { upsertTournamentResult } from "~/models/tournament-result";
|
import { upsertTournamentResult } from "~/models/tournament-result";
|
||||||
import { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
|
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
|
|
||||||
export interface SyncReport {
|
export interface SyncReport {
|
||||||
|
|
@ -46,30 +43,6 @@ export interface SyncOptions {
|
||||||
* (e.g. a mis-split 2 → correct 1.5), which would otherwise re-ping every league.
|
* (e.g. a mis-split 2 → correct 1.5), which would otherwise re-ping every league.
|
||||||
*/
|
*/
|
||||||
skipNotifications?: boolean;
|
skipNotifications?: boolean;
|
||||||
/**
|
|
||||||
* Pre-computed tie span (placement → tieCount) to split QP across each tied
|
|
||||||
* group, overriding the row-count derived from canonical tournament_results.
|
|
||||||
*
|
|
||||||
* For bracket majors (tennis, CS2) the tie span is a STRUCTURAL property of the
|
|
||||||
* round — R16 spans 8 slots (9th–16th), QF 4, SF 2, Final 1 — not the live count
|
|
||||||
* of players currently sitting at a placement. Mid-tournament, players "floored"
|
|
||||||
* at a tier make the canonical row-count diverge from the structural span, so a
|
|
||||||
* mirror window would split differently than the primary (e.g. R16 losers at
|
|
||||||
* 2 QP instead of 1.5). syncMajorFromPrimaryEvent derives this map from the
|
|
||||||
* primary bracket so every window splits identically to the primary at every
|
|
||||||
* stage. Omitted for golf-style majors (no bracket) → canonical row-count is used.
|
|
||||||
*/
|
|
||||||
tieCountByPlacement?: Map<number, number>;
|
|
||||||
/**
|
|
||||||
* Canonical participant ids (participants.id) knocked out this sync in a
|
|
||||||
* non-scoring round on the primary bracket. Threaded through so each mirror
|
|
||||||
* window can translate them to its own season_participant ids and announce the
|
|
||||||
* "Knocked Out" section — these players earn no QP and so are otherwise invisible
|
|
||||||
* to the mirror (a null-placement filler row indistinguishable from "not yet
|
|
||||||
* played"). Canonical ids because they cross window boundaries; each window holds
|
|
||||||
* a different season_participant row for the same canonical participant.
|
|
||||||
*/
|
|
||||||
newlyEliminatedCanonicalParticipantIds?: Set<string>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -92,13 +65,7 @@ export async function syncTournamentResults(
|
||||||
options: SyncOptions = {}
|
options: SyncOptions = {}
|
||||||
): Promise<SyncReport> {
|
): Promise<SyncReport> {
|
||||||
const db = database();
|
const db = database();
|
||||||
const {
|
const { markComplete = true, skipEventId, skipNotifications = false } = options;
|
||||||
markComplete = true,
|
|
||||||
skipEventId,
|
|
||||||
skipNotifications = false,
|
|
||||||
tieCountByPlacement,
|
|
||||||
newlyEliminatedCanonicalParticipantIds,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
const report: SyncReport = {
|
const report: SyncReport = {
|
||||||
tournamentId,
|
tournamentId,
|
||||||
|
|
@ -113,16 +80,10 @@ export async function syncTournamentResults(
|
||||||
.from(schema.tournamentResults)
|
.from(schema.tournamentResults)
|
||||||
.where(eq(schema.tournamentResults.tournamentId, tournamentId));
|
.where(eq(schema.tournamentResults.tournamentId, tournamentId));
|
||||||
|
|
||||||
// The tie span (placement → count) used to split QP across a tied group. Default
|
// The full-field tie span (placement → count) is a property of the whole
|
||||||
// is the count of canonical rows at each placement (a whole-tournament property,
|
// tournament, so compute it once here and hand it to every window's
|
||||||
// computed once here). For bracket majors the caller also passes the STRUCTURAL
|
// processQualifyingEvent instead of re-querying canonical results per window.
|
||||||
// span from the primary bracket (R16 = 8, QF = 4, …); merge it OVER the counts so
|
|
||||||
// bracket placements split like the primary even mid-tournament while any
|
|
||||||
// non-bracket placements (e.g. CS2 Swiss exits, golf) keep their canonical count.
|
|
||||||
const canonicalTieCountByPlacement = buildTieCountByPlacement(canonicalResults);
|
const canonicalTieCountByPlacement = buildTieCountByPlacement(canonicalResults);
|
||||||
const effectiveTieCountByPlacement = tieCountByPlacement
|
|
||||||
? new Map([...canonicalTieCountByPlacement, ...tieCountByPlacement])
|
|
||||||
: canonicalTieCountByPlacement;
|
|
||||||
|
|
||||||
// 2. Load every scoring_event that points at this tournament.
|
// 2. Load every scoring_event that points at this tournament.
|
||||||
const allLinkedEvents = await db
|
const allLinkedEvents = await db
|
||||||
|
|
@ -227,28 +188,13 @@ export async function syncTournamentResults(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3d. Translate the primary's newly-eliminated canonical participants into
|
// 3d. Delegate to scoring engine inside the same transaction.
|
||||||
// THIS window's season_participant ids (same participantId → sp.id key the
|
|
||||||
// canonical result copy uses above), so processQualifyingEvent can announce
|
|
||||||
// the "Knocked Out" section for players drafted in this window's leagues.
|
|
||||||
let windowEliminatedSpIds: Set<string> | undefined;
|
|
||||||
if (newlyEliminatedCanonicalParticipantIds?.size) {
|
|
||||||
windowEliminatedSpIds = new Set<string>();
|
|
||||||
for (const sp of rosters) {
|
|
||||||
if (sp.participantId && newlyEliminatedCanonicalParticipantIds.has(sp.participantId)) {
|
|
||||||
windowEliminatedSpIds.add(sp.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3e. Delegate to scoring engine inside the same transaction.
|
|
||||||
await processQualifyingEvent(ev.id, tx, {
|
await processQualifyingEvent(ev.id, tx, {
|
||||||
skipNotifications,
|
skipNotifications,
|
||||||
canonicalTieCountByPlacement: effectiveTieCountByPlacement,
|
canonicalTieCountByPlacement,
|
||||||
newlyEliminatedParticipantIds: windowEliminatedSpIds,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3f. Mark the window event complete (final-results sync only). This is
|
// 3e. Mark the window event complete (final-results sync only). This is
|
||||||
// what makes "score once" actually complete every window — without it,
|
// what makes "score once" actually complete every window — without it,
|
||||||
// each sibling stayed "In Progress" and had to be completed by hand.
|
// each sibling stayed "In Progress" and had to be completed by hand.
|
||||||
// Skip windows already complete so a re-run (e.g. a backfill) doesn't
|
// Skip windows already complete so a re-run (e.g. a backfill) doesn't
|
||||||
|
|
@ -323,23 +269,9 @@ export async function syncTournamentResults(
|
||||||
*/
|
*/
|
||||||
export async function syncMajorFromPrimaryEvent(
|
export async function syncMajorFromPrimaryEvent(
|
||||||
primaryEventId: string,
|
primaryEventId: string,
|
||||||
options: {
|
options: { markComplete?: boolean; skipNotifications?: boolean } = {}
|
||||||
markComplete?: boolean;
|
|
||||||
skipNotifications?: boolean;
|
|
||||||
/**
|
|
||||||
* Primary-window season_participant ids knocked out this sync in a non-scoring
|
|
||||||
* round (from the primary bracket's newlyDecidedLoserIds). Translated to
|
|
||||||
* canonical participant ids here, then fanned out so each mirror window can
|
|
||||||
* announce its own "Knocked Out" section.
|
|
||||||
*/
|
|
||||||
newlyEliminatedParticipantIds?: Set<string>;
|
|
||||||
} = {}
|
|
||||||
): Promise<SyncReport> {
|
): Promise<SyncReport> {
|
||||||
const {
|
const { markComplete = false, skipNotifications = false } = options;
|
||||||
markComplete = false,
|
|
||||||
skipNotifications = false,
|
|
||||||
newlyEliminatedParticipantIds,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
const primaryEvent = await getScoringEventById(primaryEventId);
|
const primaryEvent = await getScoringEventById(primaryEventId);
|
||||||
if (!primaryEvent) {
|
if (!primaryEvent) {
|
||||||
|
|
@ -401,91 +333,13 @@ export async function syncMajorFromPrimaryEvent(
|
||||||
`[syncMajorFromPrimaryEvent] promoted ${promotedCanonicalIds.size} result(s) from primary ${primaryEventId} to tournament ${tournamentId}; removed ${staleIds.length} stale`
|
`[syncMajorFromPrimaryEvent] promoted ${promotedCanonicalIds.size} result(s) from primary ${primaryEventId} to tournament ${tournamentId}; removed ${staleIds.length} stale`
|
||||||
);
|
);
|
||||||
|
|
||||||
// For a bracket major, split each placement's QP by the round's STRUCTURAL tie
|
|
||||||
// span (the same span the primary used via processQualifyingBracketEvent), not by
|
|
||||||
// the live count of canonical rows at that placement. Mid-tournament, players
|
|
||||||
// floored at a tier inflate the row-count and would make mirror windows split
|
|
||||||
// differently than the primary (e.g. R16 losers → 2 QP instead of 1.5). Derived
|
|
||||||
// here so every fan-out caller (reprocess, live rounds, finalize) stays consistent.
|
|
||||||
const tieCountByPlacement = await deriveStructuralTieSpanForBracket(
|
|
||||||
primaryEvent,
|
|
||||||
db
|
|
||||||
);
|
|
||||||
|
|
||||||
// Translate the primary window's eliminated season_participant ids into canonical
|
|
||||||
// participant ids so the fan-out can re-key them per mirror window. These are
|
|
||||||
// non-scoring-round losers (null placement), so they were skipped from canonical
|
|
||||||
// promotion above — resolve them directly from season_participants, not from
|
|
||||||
// tournament_results.
|
|
||||||
let newlyEliminatedCanonicalParticipantIds: Set<string> | undefined;
|
|
||||||
if (newlyEliminatedParticipantIds?.size) {
|
|
||||||
const eliminatedRows = await db
|
|
||||||
.select({ participantId: schema.seasonParticipants.participantId })
|
|
||||||
.from(schema.seasonParticipants)
|
|
||||||
.where(
|
|
||||||
inArray(schema.seasonParticipants.id, [...newlyEliminatedParticipantIds])
|
|
||||||
);
|
|
||||||
newlyEliminatedCanonicalParticipantIds = new Set(
|
|
||||||
eliminatedRows
|
|
||||||
.map((r) => r.participantId)
|
|
||||||
.filter((id): id is string => id !== null)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return syncTournamentResults(tournamentId, {
|
return syncTournamentResults(tournamentId, {
|
||||||
markComplete,
|
markComplete,
|
||||||
skipEventId: primaryEventId,
|
skipEventId: primaryEventId,
|
||||||
skipNotifications,
|
skipNotifications,
|
||||||
tieCountByPlacement,
|
|
||||||
newlyEliminatedCanonicalParticipantIds,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a placement → structural tie-span map for a bracket major's primary
|
|
||||||
* window, mirroring the spans processQualifyingBracketEvent assigns (R16 losers
|
|
||||||
* span 8 slots, QF 4, SF 2, Final 1). Returns undefined for a primary with no
|
|
||||||
* bracket template or no matches (e.g. golf), so the fan-out falls back to counting
|
|
||||||
* canonical rows exactly as before.
|
|
||||||
*
|
|
||||||
* The span for a given placement is consistent across the bracket (every R16-tier
|
|
||||||
* state carries tieCount 8, whether the player is a final loser or still floored),
|
|
||||||
* so collapsing the per-participant states into a placement → tieCount map is safe.
|
|
||||||
*/
|
|
||||||
async function deriveStructuralTieSpanForBracket(
|
|
||||||
primaryEvent: { id: string; bracketTemplateId: string | null },
|
|
||||||
db: ReturnType<typeof database>
|
|
||||||
): Promise<Map<number, number> | undefined> {
|
|
||||||
if (!primaryEvent.bracketTemplateId) return undefined;
|
|
||||||
const template = BRACKET_TEMPLATES[primaryEvent.bracketTemplateId];
|
|
||||||
if (!template) return undefined;
|
|
||||||
|
|
||||||
const matches = await db
|
|
||||||
.select()
|
|
||||||
.from(schema.playoffMatches)
|
|
||||||
.where(eq(schema.playoffMatches.scoringEventId, primaryEvent.id));
|
|
||||||
if (matches.length === 0) return undefined;
|
|
||||||
|
|
||||||
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, primaryEvent.bracketTemplateId)
|
|
||||||
);
|
|
||||||
if (states.size === 0) return undefined;
|
|
||||||
|
|
||||||
const map = new Map<number, number>();
|
|
||||||
for (const { placement, tieCount } of states.values()) {
|
|
||||||
map.set(placement, tieCount);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convenience guard for route handlers: fan out a just-scored bracket/stage event
|
* Convenience guard for route handlers: fan out a just-scored bracket/stage event
|
||||||
* to its sibling windows ONLY when it's the designated primary of a shared
|
* to its sibling windows ONLY when it's the designated primary of a shared
|
||||||
|
|
@ -495,15 +349,7 @@ async function deriveStructuralTieSpanForBracket(
|
||||||
*/
|
*/
|
||||||
export async function fanOutMajorIfPrimary(
|
export async function fanOutMajorIfPrimary(
|
||||||
event: { id: string; isPrimary: boolean; tournamentId: string | null },
|
event: { id: string; isPrimary: boolean; tournamentId: string | null },
|
||||||
options: {
|
options: { markComplete?: boolean } = {}
|
||||||
markComplete?: boolean;
|
|
||||||
/**
|
|
||||||
* Primary-window season_participant ids knocked out this sync in a non-scoring
|
|
||||||
* round. Forwarded to the fan-out so each mirror window announces its own
|
|
||||||
* "Knocked Out" section.
|
|
||||||
*/
|
|
||||||
newlyEliminatedParticipantIds?: Set<string>;
|
|
||||||
} = {}
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!event.isPrimary || !event.tournamentId) return;
|
if (!event.isPrimary || !event.tournamentId) return;
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,18 @@
|
||||||
*
|
*
|
||||||
* Safe to re-run. Validate on a DB snapshot first. Reads DATABASE_URL.
|
* Safe to re-run. Validate on a DB snapshot first. Reads DATABASE_URL.
|
||||||
*
|
*
|
||||||
* npx tsx scripts/backfill-qp-resplit.ts # apply
|
* npx tsx scripts/backfill-qp-resplit.ts # apply, all tournaments
|
||||||
* npx tsx scripts/backfill-qp-resplit.ts --dry # report only
|
* npx tsx scripts/backfill-qp-resplit.ts --dry # report only
|
||||||
|
* npx tsx scripts/backfill-qp-resplit.ts --name Wimbledon # scope to matching event names
|
||||||
|
* npx tsx scripts/backfill-qp-resplit.ts --name Wimbledon --sport Men # + scope to a sport (e.g. gender)
|
||||||
|
* npx tsx scripts/backfill-qp-resplit.ts --name Wimbledon --sport Men --dry
|
||||||
|
*
|
||||||
|
* --name matches case-insensitively against each linked scoring event's name;
|
||||||
|
* matches ALL tournaments with a matching event (e.g. both years of "Wimbledon"),
|
||||||
|
* since the reported bug spans seasons. Tournament names don't encode gender (both
|
||||||
|
* men's and women's majors are just "Wimbledon") — use --sport to scope further,
|
||||||
|
* matched case-insensitively against the sport's name (e.g. "Tennis - Men").
|
||||||
|
* Both filters apply to a tournament if ANY of its linked events match.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { drizzle } from "drizzle-orm/postgres-js";
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
|
|
@ -32,6 +42,12 @@ import {
|
||||||
} from "../app/services/sync-tournament-results.js";
|
} from "../app/services/sync-tournament-results.js";
|
||||||
|
|
||||||
const DRY = process.argv.includes("--dry");
|
const DRY = process.argv.includes("--dry");
|
||||||
|
function argValue(flag: string): string | null {
|
||||||
|
const idx = process.argv.indexOf(flag);
|
||||||
|
return idx !== -1 && process.argv[idx + 1] ? process.argv[idx + 1].toLowerCase() : null;
|
||||||
|
}
|
||||||
|
const NAME_FILTER = argValue("--name");
|
||||||
|
const SPORT_FILTER = argValue("--sport");
|
||||||
const log = (...a: unknown[]) => console.log(...a);
|
const log = (...a: unknown[]) => console.log(...a);
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
|
|
@ -50,6 +66,23 @@ async function run() {
|
||||||
byTournament.set(ev.tournamentId, arr);
|
byTournament.set(ev.tournamentId, arr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (NAME_FILTER) {
|
||||||
|
for (const [tournamentId, evs] of byTournament) {
|
||||||
|
const matches = evs.some((e) => e.name?.toLowerCase().includes(NAME_FILTER));
|
||||||
|
if (!matches) byTournament.delete(tournamentId);
|
||||||
|
}
|
||||||
|
log(`Filtering to tournaments matching --name "${NAME_FILTER}"`);
|
||||||
|
}
|
||||||
|
if (SPORT_FILTER) {
|
||||||
|
for (const [tournamentId, evs] of byTournament) {
|
||||||
|
const matches = evs.some((e) =>
|
||||||
|
e.sportsSeason?.sport?.name?.toLowerCase().includes(SPORT_FILTER)
|
||||||
|
);
|
||||||
|
if (!matches) byTournament.delete(tournamentId);
|
||||||
|
}
|
||||||
|
log(`Filtering to tournaments matching --sport "${SPORT_FILTER}"`);
|
||||||
|
}
|
||||||
|
|
||||||
log(`Tournaments with linked qualifying events: ${byTournament.size}`);
|
log(`Tournaments with linked qualifying events: ${byTournament.size}`);
|
||||||
let ok = 0;
|
let ok = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
|
@ -57,7 +90,8 @@ async function run() {
|
||||||
for (const [tournamentId, evs] of byTournament) {
|
for (const [tournamentId, evs] of byTournament) {
|
||||||
const simulatorType = evs[0]?.sportsSeason?.sport?.simulatorType ?? null;
|
const simulatorType = evs[0]?.sportsSeason?.sport?.simulatorType ?? null;
|
||||||
const bracketMajor = isBracketMajor(simulatorType);
|
const bracketMajor = isBracketMajor(simulatorType);
|
||||||
const name = evs[0]?.name ?? tournamentId;
|
const sportName = evs[0]?.sportsSeason?.sport?.name ?? "unknown sport";
|
||||||
|
const name = `${evs[0]?.name ?? tournamentId} [${sportName}]`;
|
||||||
|
|
||||||
// Only re-score fully-scored majors so we don't mark in-progress ones complete.
|
// Only re-score fully-scored majors so we don't mark in-progress ones complete.
|
||||||
const anyComplete = evs.some((e) => e.isComplete);
|
const anyComplete = evs.some((e) => e.isComplete);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue