Compare commits
19 commits
claude/wim
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b85f387c79 | ||
|
|
932f36ca07 | ||
| 7e578c714c | |||
|
|
687fb6f040 | ||
|
|
5d0363a309 | ||
| b05bad6554 | |||
|
|
cab161424e | ||
|
|
3f16d5e1d3 | ||
| 036411e9d8 | |||
|
|
07f1356cd3 | ||
|
|
3f6af2665e | ||
| 91227b2cb3 | |||
|
|
b9a9eb4c4b | ||
|
|
7ca89aafc4 | ||
| 8416b54052 | |||
|
|
698c6a6509 | ||
|
|
12c165a481 | ||
|
|
0baef5a948 | ||
| 58e656f15e |
30 changed files with 2601 additions and 321 deletions
|
|
@ -9,17 +9,14 @@
|
||||||
"command": ".claude/hooks/lint-on-edit.sh",
|
"command": ".claude/hooks/lint-on-edit.sh",
|
||||||
"timeout": 30,
|
"timeout": 30,
|
||||||
"statusMessage": "Linting..."
|
"statusMessage": "Linting..."
|
||||||
}
|
},
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"Stop": [
|
|
||||||
{
|
|
||||||
"hooks": [
|
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "output=$(npm run typecheck 2>&1); rc=$?; if [ $rc -ne 0 ]; then printf '{\"systemMessage\":\"TypeCheck failed:\\n%s\"}' \"$(echo \"$output\" | tail -30 | sed 's/\"/\\\\\"/g; s/$/\\\\n/' | tr -d '\\n')\"; fi",
|
"if": "Write(*.ts)|Write(*.tsx)|Edit(*.ts)|Edit(*.tsx)|MultiEdit(*.ts)|MultiEdit(*.tsx)",
|
||||||
"timeout": 60
|
"command": "output=$(npm run typecheck 2>&1); rc=$?; if [ $rc -ne 0 ]; then printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PostToolUse\",\"additionalContext\":\"TypeCheck failed:\\n%s\"}}' \"$(echo \"$output\" | tail -30 | sed 's/\"/\\\\\"/g; s/$/\\\\n/' | tr -d '\\n')\"; fi",
|
||||||
|
"timeout": 60,
|
||||||
|
"statusMessage": "Type-checking...",
|
||||||
|
"async": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,3 +31,8 @@ CLOUDINARY_API_SECRET=""
|
||||||
# Must match the CRON_SECRET repo secret in Forgejo.
|
# Must match the CRON_SECRET repo secret in Forgejo.
|
||||||
# Generate with: openssl rand -hex 32
|
# Generate with: openssl rand -hex 32
|
||||||
CRON_SECRET=""
|
CRON_SECRET=""
|
||||||
|
|
||||||
|
# OC Blacktop motorsport API — used to sync IndyCar championship standings
|
||||||
|
# (fresher than ESPN's aggregate). Free tier at https://ocblacktop.com/api.
|
||||||
|
# If unset, IndyCar standings fall back to ESPN.
|
||||||
|
OCBLACKTOP_API_KEY=""
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ const scoringRules = {
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("QualifyingPointsStandings", () => {
|
describe("QualifyingPointsStandings", () => {
|
||||||
it("renders fractional QP to hundredths", () => {
|
it("renders fractional QP to at most hundredths with trailing zeros trimmed", () => {
|
||||||
render(
|
render(
|
||||||
<QualifyingPointsStandings
|
<QualifyingPointsStandings
|
||||||
standings={[
|
standings={[
|
||||||
|
|
@ -52,7 +52,8 @@ describe("QualifyingPointsStandings", () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("14.67 QP")).toBeInTheDocument();
|
expect(screen.getByText("14.67 QP")).toBeInTheDocument();
|
||||||
expect(screen.getByText("0.50 QP")).toBeInTheDocument();
|
// 0.50 trims its trailing zero to 0.5; 14.67 and 0.43 are unaffected.
|
||||||
|
expect(screen.getByText("0.5 QP")).toBeInTheDocument();
|
||||||
expect(screen.getByText("0.43 QP")).toBeInTheDocument();
|
expect(screen.getByText("0.43 QP")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 } from "~/lib/bracket-templates";
|
import { getBracketTemplate, type BracketTemplate } from "~/lib/bracket-templates";
|
||||||
import { NbaBracketLayout } from "./NbaBracketLayout";
|
import { NbaBracketLayout } from "./NbaBracketLayout";
|
||||||
import { TabbedBracketLayout } from "./TabbedBracketLayout";
|
import { TabbedBracketLayout } from "./TabbedBracketLayout";
|
||||||
|
|
||||||
|
|
@ -174,6 +174,138 @@ 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++) {
|
||||||
|
|
@ -216,12 +348,10 @@ 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 thirdPlaceRound = template?.rounds
|
const consolation = findConsolationRound(template);
|
||||||
.find((r) => template.rounds.some((other) => other.loserFeedsInto === r.name))
|
const thirdPlaceRound = consolation?.round;
|
||||||
?.name;
|
|
||||||
|
|
||||||
// Build elimination rankings
|
// Build elimination rankings
|
||||||
const losersByRound = new Map<string, Array<{ participant: Participant; score: string | null; ownership: TeamOwnership | null }>>();
|
|
||||||
let bracketWinner: Participant | null = null;
|
let bracketWinner: Participant | null = null;
|
||||||
|
|
||||||
const lastRound = rounds[rounds.length - 1];
|
const lastRound = rounds[rounds.length - 1];
|
||||||
|
|
@ -230,43 +360,19 @@ export function PlayoffBracket({
|
||||||
: null;
|
: null;
|
||||||
if (finalMatch?.winner) bracketWinner = finalMatch.winner;
|
if (finalMatch?.winner) bracketWinner = finalMatch.winner;
|
||||||
|
|
||||||
const eliminatedByRound = computeEliminatedByRound(matches, rounds);
|
|
||||||
|
|
||||||
for (const match of matches) {
|
|
||||||
if (!match.isComplete || !match.loser) continue;
|
|
||||||
const eliminatedInRound = eliminatedByRound.get(match.round);
|
|
||||||
if (!eliminatedInRound?.includes(match.loser.id)) continue;
|
|
||||||
const loserScore =
|
|
||||||
match.loserId === match.participant1Id
|
|
||||||
? match.participant1Score
|
|
||||||
: match.participant2Score;
|
|
||||||
if (!losersByRound.has(match.round)) losersByRound.set(match.round, []);
|
|
||||||
losersByRound.get(match.round)?.push({
|
|
||||||
participant: match.loser,
|
|
||||||
score: loserScore,
|
|
||||||
ownership: ownershipMap.get(match.loser.id) || null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const allBracketParticipantIds = new Set<string>();
|
const allBracketParticipantIds = new Set<string>();
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
if (match.participant1Id) allBracketParticipantIds.add(match.participant1Id);
|
if (match.participant1Id) allBracketParticipantIds.add(match.participant1Id);
|
||||||
if (match.participant2Id) allBracketParticipantIds.add(match.participant2Id);
|
if (match.participant2Id) allBracketParticipantIds.add(match.participant2Id);
|
||||||
}
|
}
|
||||||
const rankedEntries: EliminatedEntry[] = [];
|
|
||||||
let nextRank = 2;
|
const rankedEntries = computeRankedEntries(
|
||||||
for (let ri = rounds.length - 1; ri >= 0; ri--) {
|
matches,
|
||||||
const roundName = rounds[ri];
|
rounds,
|
||||||
const roundLosers = losersByRound.get(roundName) || [];
|
matchesByRound,
|
||||||
const totalMatchesInRound = matchesByRound.get(roundName)?.length ?? 0;
|
consolation,
|
||||||
if (roundLosers.length > 0) {
|
ownershipMap
|
||||||
const rankLabel = `T${nextRank}`;
|
);
|
||||||
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);
|
||||||
|
|
@ -286,7 +392,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.
|
||||||
.sort((a, b) => {
|
.toSorted((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,7 +55,9 @@ interface QualifyingPointsStandingsProps {
|
||||||
function formatQP(raw: string): string {
|
function formatQP(raw: string): string {
|
||||||
const n = parseFloat(raw);
|
const n = parseFloat(raw);
|
||||||
if (isNaN(n)) return "—";
|
if (isNaN(n)) return "—";
|
||||||
return n % 1 === 0 ? n.toString() : n.toFixed(2);
|
// At most 2 decimals, trailing zeros trimmed (1.50→1.5). Mirrors the Discord
|
||||||
|
// embed's formatQPValue (app/services/discord.ts) so the site and Discord agree.
|
||||||
|
return parseFloat(n.toFixed(2)).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QualifyingPointsStandings({
|
export function QualifyingPointsStandings({
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,15 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { buildFeederMap, groupMatchesByRound, computeEliminatedByRound } from "../PlayoffBracket";
|
import { render, screen, within } from "@testing-library/react";
|
||||||
|
import {
|
||||||
|
PlayoffBracket,
|
||||||
|
buildFeederMap,
|
||||||
|
groupMatchesByRound,
|
||||||
|
computeEliminatedByRound,
|
||||||
|
computeRankedEntries,
|
||||||
|
findConsolationRound,
|
||||||
|
type Match,
|
||||||
|
} from "../PlayoffBracket";
|
||||||
|
import { getBracketTemplate } from "~/lib/bracket-templates";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
|
|
@ -291,3 +301,392 @@ describe("computeEliminatedByRound", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// computeRankedEntries — consolation ("third place") round handling
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// fifa_48 round order. The Third Place Game sits between the Semifinals (whose
|
||||||
|
// losers feed it) and the Finals.
|
||||||
|
const FIFA_ROUNDS = ["Quarterfinals", "Semifinals", "Third Place Game", "Finals"];
|
||||||
|
|
||||||
|
const FIFA_CONSOLATION = {
|
||||||
|
round: "Third Place Game",
|
||||||
|
feederRound: "Semifinals",
|
||||||
|
};
|
||||||
|
|
||||||
|
type MatchOpts = {
|
||||||
|
/** Which slot the winner occupies. Defaults to 1. */
|
||||||
|
winnerSlot?: 1 | 2;
|
||||||
|
winnerScore?: string;
|
||||||
|
loserScore?: string;
|
||||||
|
/**
|
||||||
|
* Drop the hydrated `winner` relation, keeping `loser` and both ids — the shape a
|
||||||
|
* hand-built match object can arrive in. The loser is still placeable this way.
|
||||||
|
*/
|
||||||
|
missingWinnerRelation?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A completed match. */
|
||||||
|
function makeRankedMatch(
|
||||||
|
round: string,
|
||||||
|
matchNumber: number,
|
||||||
|
winnerId: string,
|
||||||
|
loserId: string,
|
||||||
|
opts: MatchOpts = {}
|
||||||
|
): Match {
|
||||||
|
const winnerIsP1 = (opts.winnerSlot ?? 1) === 1;
|
||||||
|
const p1 = winnerIsP1 ? winnerId : loserId;
|
||||||
|
const p2 = winnerIsP1 ? loserId : winnerId;
|
||||||
|
return {
|
||||||
|
id: `${round}-${matchNumber}`,
|
||||||
|
round,
|
||||||
|
matchNumber,
|
||||||
|
participant1Id: p1,
|
||||||
|
participant2Id: p2,
|
||||||
|
winnerId,
|
||||||
|
loserId,
|
||||||
|
isComplete: true,
|
||||||
|
participant1Score: (winnerIsP1 ? opts.winnerScore : opts.loserScore) ?? null,
|
||||||
|
participant2Score: (winnerIsP1 ? opts.loserScore : opts.winnerScore) ?? null,
|
||||||
|
participant1: { id: p1, name: p1 },
|
||||||
|
participant2: { id: p2, name: p2 },
|
||||||
|
winner: opts.missingWinnerRelation ? null : { id: winnerId, name: winnerId },
|
||||||
|
loser: { id: loserId, name: loserId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A scheduled-but-unplayed match. Bracket rows are pre-generated with their slots
|
||||||
|
* filled as earlier rounds resolve, so an unplayed 3PG still lists both SF losers.
|
||||||
|
*/
|
||||||
|
function makePendingMatch(
|
||||||
|
round: string,
|
||||||
|
matchNumber: number,
|
||||||
|
participant1Id: string | null,
|
||||||
|
participant2Id: string | null
|
||||||
|
): Match {
|
||||||
|
return {
|
||||||
|
id: `${round}-${matchNumber}`,
|
||||||
|
round,
|
||||||
|
matchNumber,
|
||||||
|
participant1Id,
|
||||||
|
participant2Id,
|
||||||
|
winnerId: null,
|
||||||
|
loserId: null,
|
||||||
|
isComplete: false,
|
||||||
|
participant1Score: null,
|
||||||
|
participant2Score: null,
|
||||||
|
participant1: participant1Id ? { id: participant1Id, name: participant1Id } : null,
|
||||||
|
participant2: participant2Id ? { id: participant2Id, name: participant2Id } : null,
|
||||||
|
winner: null,
|
||||||
|
loser: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A full fifa_48-shaped knockout tail: 4 QF, 2 SF, the 3PG, and the Final. */
|
||||||
|
function fifaMatches(
|
||||||
|
overrides: { played?: boolean; thirdPlace?: Match } = {}
|
||||||
|
): Match[] {
|
||||||
|
const played = overrides.played ?? true;
|
||||||
|
return [
|
||||||
|
makeRankedMatch("Quarterfinals", 1, "sfA", "qf1"),
|
||||||
|
makeRankedMatch("Quarterfinals", 2, "sfB", "qf2"),
|
||||||
|
makeRankedMatch("Quarterfinals", 3, "sfC", "qf3"),
|
||||||
|
makeRankedMatch("Quarterfinals", 4, "sfD", "qf4"),
|
||||||
|
makeRankedMatch("Semifinals", 1, "sfA", "sfB"),
|
||||||
|
makeRankedMatch("Semifinals", 2, "sfC", "sfD"),
|
||||||
|
overrides.thirdPlace ??
|
||||||
|
(played
|
||||||
|
? makeRankedMatch("Third Place Game", 1, "sfB", "sfD")
|
||||||
|
: makePendingMatch("Third Place Game", 1, "sfB", "sfD")),
|
||||||
|
played
|
||||||
|
? makeRankedMatch("Finals", 1, "sfA", "sfC")
|
||||||
|
: makePendingMatch("Finals", 1, "sfA", "sfC"),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rank the fifa fixture, defaulting to the fifa_48 consolation config. */
|
||||||
|
function rankFifa(
|
||||||
|
matches: Match[] = fifaMatches(),
|
||||||
|
ownership: Map<string, { participantId: string; teamName: string; teamId: string }> = new Map(),
|
||||||
|
consolation: typeof FIFA_CONSOLATION | undefined = FIFA_CONSOLATION
|
||||||
|
) {
|
||||||
|
return computeRankedEntries(
|
||||||
|
matches,
|
||||||
|
FIFA_ROUNDS,
|
||||||
|
groupMatchesByRound(matches),
|
||||||
|
consolation,
|
||||||
|
ownership
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rankOf(entries: ReturnType<typeof computeRankedEntries>, id: string) {
|
||||||
|
return entries.find((e) => e.participant.id === id)?.rankLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("findConsolationRound", () => {
|
||||||
|
it("identifies the fifa_48 third place game and the round that feeds it", () => {
|
||||||
|
expect(findConsolationRound(getBracketTemplate("fifa_48"))).toEqual({
|
||||||
|
round: "Third Place Game",
|
||||||
|
feederRound: "Semifinals",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for a template with no consolation round", () => {
|
||||||
|
expect(findConsolationRound(getBracketTemplate("ncaa_64"))).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when there is no template", () => {
|
||||||
|
expect(findConsolationRound(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeRankedEntries", () => {
|
||||||
|
describe("fifa_48 third place game", () => {
|
||||||
|
it("ranks the third place game winner 3rd — they never lose a match after the SF", () => {
|
||||||
|
// sfB lost the semifinal, then won the 3PG.
|
||||||
|
expect(rankOf(rankFifa(), "sfB")).toBe("3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ranks the third place game loser 4th, not 3rd", () => {
|
||||||
|
expect(rankOf(rankFifa(), "sfD")).toBe("4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives quarterfinal losers T5 — the 3PG consumes no positions of its own", () => {
|
||||||
|
const entries = rankFifa();
|
||||||
|
expect(rankOf(entries, "qf1")).toBe("T5");
|
||||||
|
expect(rankOf(entries, "qf2")).toBe("T5");
|
||||||
|
expect(rankOf(entries, "qf3")).toBe("T5");
|
||||||
|
expect(rankOf(entries, "qf4")).toBe("T5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ranks the finals loser 2nd and leaves the champion out of the list", () => {
|
||||||
|
const entries = rankFifa();
|
||||||
|
expect(rankOf(entries, "sfC")).toBe("T2");
|
||||||
|
expect(rankOf(entries, "sfA")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders the list by rank: 2nd, 3rd, 4th, then the T5 tier", () => {
|
||||||
|
expect(rankFifa().map((e) => e.rankLabel)).toEqual([
|
||||||
|
"T2",
|
||||||
|
"3",
|
||||||
|
"4",
|
||||||
|
"T5",
|
||||||
|
"T5",
|
||||||
|
"T5",
|
||||||
|
"T5",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves semifinal losers unranked until the third place game is played", () => {
|
||||||
|
const entries = rankFifa(fifaMatches({ played: false }));
|
||||||
|
// Both SF losers are still alive for the 3PG.
|
||||||
|
expect(rankOf(entries, "sfB")).toBeUndefined();
|
||||||
|
expect(rankOf(entries, "sfD")).toBeUndefined();
|
||||||
|
// QF losers are still T5 — the later rounds still consume their positions.
|
||||||
|
expect(rankOf(entries, "qf1")).toBe("T5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries ownership through onto the third place entries", () => {
|
||||||
|
const ownership = new Map([
|
||||||
|
["sfB", { participantId: "sfB", teamName: "Team Nine", teamId: "t9" }],
|
||||||
|
]);
|
||||||
|
const entries = rankFifa(fifaMatches(), ownership);
|
||||||
|
expect(entries.find((e) => e.participant.id === "sfB")?.ownership?.teamName).toBe(
|
||||||
|
"Team Nine"
|
||||||
|
);
|
||||||
|
expect(entries.find((e) => e.participant.id === "sfD")?.ownership).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("scores", () => {
|
||||||
|
it("reads each participant's own score regardless of which slot they occupied", () => {
|
||||||
|
const matches = fifaMatches({
|
||||||
|
// Winner sits in slot 2 this time, so a slot-blind lookup would swap the scores.
|
||||||
|
thirdPlace: makeRankedMatch("Third Place Game", 1, "sfB", "sfD", {
|
||||||
|
winnerSlot: 2,
|
||||||
|
winnerScore: "3",
|
||||||
|
loserScore: "1",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const entries = rankFifa(matches);
|
||||||
|
|
||||||
|
expect(entries.find((e) => e.participant.id === "sfB")?.score).toBe("3");
|
||||||
|
expect(entries.find((e) => e.participant.id === "sfD")?.score).toBe("1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a loser's score from the slot they actually played in", () => {
|
||||||
|
const matches: Match[] = [
|
||||||
|
makeRankedMatch("Semifinals", 1, "sfA", "sfB", {
|
||||||
|
winnerSlot: 2,
|
||||||
|
winnerScore: "4",
|
||||||
|
loserScore: "2",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const entries = computeRankedEntries(
|
||||||
|
matches,
|
||||||
|
["Semifinals"],
|
||||||
|
groupMatchesByRound(matches),
|
||||||
|
undefined,
|
||||||
|
new Map()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(entries.find((e) => e.participant.id === "sfB")?.score).toBe("2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports no score for a participant who occupies neither slot", () => {
|
||||||
|
// A stale row after a bracket edit: loserId no longer matches either slot.
|
||||||
|
// Attributing the other team's score here would look entirely plausible.
|
||||||
|
const stale: Match = {
|
||||||
|
...makeRankedMatch("Semifinals", 1, "sfA", "sfB", {
|
||||||
|
winnerScore: "4",
|
||||||
|
loserScore: "2",
|
||||||
|
}),
|
||||||
|
loserId: "ghost",
|
||||||
|
loser: { id: "ghost", name: "ghost" },
|
||||||
|
};
|
||||||
|
const entries = computeRankedEntries(
|
||||||
|
[stale],
|
||||||
|
["Semifinals"],
|
||||||
|
groupMatchesByRound([stale]),
|
||||||
|
undefined,
|
||||||
|
new Map()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(entries.find((e) => e.participant.id === "ghost")?.score).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a consolation round somewhere other than 3rd/4th", () => {
|
||||||
|
// No template ships this today, but the positions must come from the feeder
|
||||||
|
// round rather than being hardcoded to 3 and 4.
|
||||||
|
const ROUNDS = ["Quarterfinals", "Semifinals", "Fifth Place Game", "Finals"];
|
||||||
|
const CONSOLATION = { round: "Fifth Place Game", feederRound: "Quarterfinals" };
|
||||||
|
|
||||||
|
const matches: Match[] = [
|
||||||
|
makeRankedMatch("Quarterfinals", 1, "sfA", "qf1"),
|
||||||
|
makeRankedMatch("Quarterfinals", 2, "sfB", "qf2"),
|
||||||
|
makeRankedMatch("Quarterfinals", 3, "sfC", "qf3"),
|
||||||
|
makeRankedMatch("Quarterfinals", 4, "sfD", "qf4"),
|
||||||
|
makeRankedMatch("Semifinals", 1, "sfA", "sfB"),
|
||||||
|
makeRankedMatch("Semifinals", 2, "sfC", "sfD"),
|
||||||
|
makeRankedMatch("Fifth Place Game", 1, "qf1", "qf2"),
|
||||||
|
makeRankedMatch("Finals", 1, "sfA", "sfC"),
|
||||||
|
];
|
||||||
|
|
||||||
|
it("places the consolation pair at its feeder round's tier, not at 3rd and 4th", () => {
|
||||||
|
const entries = computeRankedEntries(
|
||||||
|
matches,
|
||||||
|
ROUNDS,
|
||||||
|
groupMatchesByRound(matches),
|
||||||
|
CONSOLATION,
|
||||||
|
new Map()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rankOf(entries, "qf1")).toBe("5");
|
||||||
|
expect(rankOf(entries, "qf2")).toBe("6");
|
||||||
|
// The feeder round's other losers start below the pair, not alongside them.
|
||||||
|
expect(rankOf(entries, "qf3")).toBe("T7");
|
||||||
|
expect(rankOf(entries, "qf4")).toBe("T7");
|
||||||
|
// The rounds above it are unaffected.
|
||||||
|
expect(rankOf(entries, "sfC")).toBe("T2");
|
||||||
|
expect(rankOf(entries, "sfB")).toBe("T3");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("consolation matches that cannot be placed exactly", () => {
|
||||||
|
it("still ranks the loser when the winner relation is missing", () => {
|
||||||
|
const matches = fifaMatches({
|
||||||
|
thirdPlace: makeRankedMatch("Third Place Game", 1, "sfB", "sfD", {
|
||||||
|
missingWinnerRelation: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const entries = rankFifa(matches);
|
||||||
|
|
||||||
|
// The winner cannot be placed without a participant object, but the loser must
|
||||||
|
// not silently vanish the way it would if the round were skipped wholesale.
|
||||||
|
expect(rankOf(entries, "sfD")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the loser-driven walk when the feeder round has no matches", () => {
|
||||||
|
const matches: Match[] = [
|
||||||
|
makeRankedMatch("Third Place Game", 1, "sfB", "sfD"),
|
||||||
|
makeRankedMatch("Finals", 1, "sfA", "sfC"),
|
||||||
|
];
|
||||||
|
const entries = computeRankedEntries(
|
||||||
|
matches,
|
||||||
|
["Third Place Game", "Finals"],
|
||||||
|
groupMatchesByRound(matches),
|
||||||
|
FIFA_CONSOLATION,
|
||||||
|
new Map()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rankOf(entries, "sfC")).toBe("T2");
|
||||||
|
expect(rankOf(entries, "sfD")).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rendered output", () => {
|
||||||
|
/** The card the 3PG winner was incorrectly appearing in. */
|
||||||
|
function inContentionNames() {
|
||||||
|
const card = screen.queryByText("In Contention")?.closest('[data-slot="card"]');
|
||||||
|
if (!card) return [];
|
||||||
|
return within(card as HTMLElement)
|
||||||
|
.getAllByRole("row")
|
||||||
|
.map((r) => r.textContent ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
it("does not list the third place game winner as in contention", () => {
|
||||||
|
render(
|
||||||
|
<PlayoffBracket matches={fifaMatches()} rounds={FIFA_ROUNDS} bracketTemplateId="fifa_48" />
|
||||||
|
);
|
||||||
|
|
||||||
|
// sfB won the third place game — they are finished, not still playing.
|
||||||
|
expect(inContentionNames().some((t) => t.includes("sfB"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still lists semifinalists as in contention before the third place game", () => {
|
||||||
|
render(
|
||||||
|
<PlayoffBracket
|
||||||
|
matches={fifaMatches({ played: false })}
|
||||||
|
rounds={FIFA_ROUNDS}
|
||||||
|
bracketTemplateId="fifa_48"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(inContentionNames().some((t) => t.includes("sfB"))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("brackets without a consolation round", () => {
|
||||||
|
const ROUNDS = ["Quarterfinals", "Semifinals", "Finals"];
|
||||||
|
|
||||||
|
it("ranks losers by round with tie labels, unchanged", () => {
|
||||||
|
const matches: Match[] = [
|
||||||
|
makeRankedMatch("Quarterfinals", 1, "sfA", "qf1"),
|
||||||
|
makeRankedMatch("Quarterfinals", 2, "sfB", "qf2"),
|
||||||
|
makeRankedMatch("Quarterfinals", 3, "sfC", "qf3"),
|
||||||
|
makeRankedMatch("Quarterfinals", 4, "sfD", "qf4"),
|
||||||
|
makeRankedMatch("Semifinals", 1, "sfA", "sfB"),
|
||||||
|
makeRankedMatch("Semifinals", 2, "sfC", "sfD"),
|
||||||
|
makeRankedMatch("Finals", 1, "sfA", "sfC"),
|
||||||
|
];
|
||||||
|
|
||||||
|
const entries = computeRankedEntries(
|
||||||
|
matches,
|
||||||
|
ROUNDS,
|
||||||
|
groupMatchesByRound(matches),
|
||||||
|
undefined,
|
||||||
|
new Map()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rankOf(entries, "sfC")).toBe("T2");
|
||||||
|
expect(rankOf(entries, "sfB")).toBe("T3");
|
||||||
|
expect(rankOf(entries, "sfD")).toBe("T3");
|
||||||
|
expect(rankOf(entries, "qf1")).toBe("T5");
|
||||||
|
expect(rankOf(entries, "sfA")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import {
|
||||||
calculateSplitQualifyingPoints,
|
calculateSplitQualifyingPoints,
|
||||||
DEFAULT_QP_VALUES,
|
DEFAULT_QP_VALUES,
|
||||||
diffChangedQualifyingPoints,
|
diffChangedQualifyingPoints,
|
||||||
hasProcessedQualifyingPlacement,
|
|
||||||
} from "../qualifying-points";
|
} from "../qualifying-points";
|
||||||
|
|
||||||
describe("diffChangedQualifyingPoints", () => {
|
describe("diffChangedQualifyingPoints", () => {
|
||||||
|
|
@ -310,52 +309,6 @@ describe("Qualifying Points Configuration", () => {
|
||||||
expect(totalQP2).toBe(20); // Should have 20 QP (1st place)
|
expect(totalQP2).toBe(20); // Should have 20 QP (1st place)
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not increment majorsCompleted when reprocessing", () => {
|
|
||||||
// First processing
|
|
||||||
let majorsCompleted = 0;
|
|
||||||
const wasAlreadyProcessed = false;
|
|
||||||
|
|
||||||
if (!wasAlreadyProcessed) {
|
|
||||||
majorsCompleted += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(majorsCompleted).toBe(1);
|
|
||||||
|
|
||||||
// Reprocessing (wasAlreadyProcessed = true)
|
|
||||||
const reprocessing = true;
|
|
||||||
|
|
||||||
if (!reprocessing) {
|
|
||||||
majorsCompleted += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(majorsCompleted).toBe(1); // Should still be 1, not 2
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Processed event detection", () => {
|
|
||||||
it("treats a placed zero-QP result as already processed", () => {
|
|
||||||
expect(
|
|
||||||
hasProcessedQualifyingPlacement([
|
|
||||||
{ placement: 20, qualifyingPointsAwarded: "0.00" },
|
|
||||||
])
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not treat filler zero-QP rows without placements as processed", () => {
|
|
||||||
expect(
|
|
||||||
hasProcessedQualifyingPlacement([
|
|
||||||
{ placement: null, qualifyingPointsAwarded: "0" },
|
|
||||||
])
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not treat unprocessed placed rows as processed", () => {
|
|
||||||
expect(
|
|
||||||
hasProcessedQualifyingPlacement([
|
|
||||||
{ placement: 15, qualifyingPointsAwarded: null },
|
|
||||||
])
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Scoring Workflow", () => {
|
describe("Scoring Workflow", () => {
|
||||||
|
|
|
||||||
|
|
@ -104,4 +104,40 @@ 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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -200,7 +200,21 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("processQualifyingEvent", () => {
|
describe("processQualifyingEvent", () => {
|
||||||
function makeProcessQPMockDb(eventResults: Array<Record<string, unknown>>) {
|
function makeProcessQPMockDb(
|
||||||
|
eventResults: Array<Record<string, unknown>>,
|
||||||
|
options: {
|
||||||
|
tournamentId?: string | null;
|
||||||
|
canonicalPlacements?: number[];
|
||||||
|
bracketTemplateId?: string | null;
|
||||||
|
playoffMatchIds?: string[];
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
const {
|
||||||
|
tournamentId = null,
|
||||||
|
canonicalPlacements = [],
|
||||||
|
bracketTemplateId = null,
|
||||||
|
playoffMatchIds = [],
|
||||||
|
} = options;
|
||||||
const setCalls: unknown[] = [];
|
const setCalls: unknown[] = [];
|
||||||
const updateChain = {
|
const updateChain = {
|
||||||
set: (values: unknown) => {
|
set: (values: unknown) => {
|
||||||
|
|
@ -217,6 +231,8 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
|
||||||
id: "event-1",
|
id: "event-1",
|
||||||
sportsSeasonId: "sports-season-1",
|
sportsSeasonId: "sports-season-1",
|
||||||
isQualifyingEvent: true,
|
isQualifyingEvent: true,
|
||||||
|
tournamentId,
|
||||||
|
bracketTemplateId,
|
||||||
sportsSeason: { majorsCompleted: 1 },
|
sportsSeason: { majorsCompleted: 1 },
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
@ -238,6 +254,23 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Two select shapes flow through the non-bracket path:
|
||||||
|
// • playoff-match existence check (has an `id` projection, uses .limit(1))
|
||||||
|
// • canonical tournament_results tie span (has a `placement` projection).
|
||||||
|
// Discriminate by the projection keys and return a thenable that also
|
||||||
|
// exposes .limit so both call shapes resolve.
|
||||||
|
select: (fields: Record<string, unknown>) => {
|
||||||
|
const isPlayoff = fields && "id" in fields;
|
||||||
|
const rows = isPlayoff
|
||||||
|
? playoffMatchIds.map((id) => ({ id }))
|
||||||
|
: canonicalPlacements.map((placement) => ({ placement }));
|
||||||
|
return {
|
||||||
|
from: () => ({
|
||||||
|
where: () =>
|
||||||
|
Object.assign(Promise.resolve(rows), { limit: async () => rows }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
update: () => updateChain,
|
update: () => updateChain,
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
|
|
@ -273,6 +306,123 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("splits a tied placement by the FULL canonical field, not the window's roster subset", async () => {
|
||||||
|
// Regression for the reported 2-vs-1.5 QP bug. Tennis Round-of-16 losers all
|
||||||
|
// land at placement 9 with a structural tie span of 8 (positions 9–16 →
|
||||||
|
// (2+2+2+2+1+1+1+1)/8 = 1.5). A sibling/mirror window only holds the drafted
|
||||||
|
// subset — here just 2 of the 8 tied players — but the split must still use 8,
|
||||||
|
// not the 2 present locally (which would wrongly give (2+2)/2 = 2.00).
|
||||||
|
const windowRows = [
|
||||||
|
{
|
||||||
|
id: "result-1",
|
||||||
|
seasonParticipantId: "participant-1",
|
||||||
|
placement: 9,
|
||||||
|
qualifyingPointsAwarded: null,
|
||||||
|
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "result-2",
|
||||||
|
seasonParticipantId: "participant-2",
|
||||||
|
placement: 9,
|
||||||
|
qualifyingPointsAwarded: null,
|
||||||
|
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const { db, setCalls } = makeProcessQPMockDb(windowRows, {
|
||||||
|
tournamentId: "tournament-1",
|
||||||
|
canonicalPlacements: Array.from({ length: 8 }, () => 9), // full field: 8 at 9th
|
||||||
|
});
|
||||||
|
|
||||||
|
await processQualifyingEvent("event-1", db, { skipNotifications: true });
|
||||||
|
|
||||||
|
// Both present players earn the correct split of 1.50, not 2.00.
|
||||||
|
expect(setCalls).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ qualifyingPointsAwarded: "1.50" }),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(setCalls).not.toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ qualifyingPointsAwarded: "2.00" }),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scores a cloned window (bracketTemplateId but no playoff matches) via the canonical path", async () => {
|
||||||
|
// cloneSportsSeason copies bracketTemplateId to league windows but not the
|
||||||
|
// playoff matches. Such a window must NOT take the bracket branch (which would
|
||||||
|
// derive zero states and write no QP) — it has to fall through to the
|
||||||
|
// placement/canonical path and still split R16 losers to 1.5.
|
||||||
|
const windowRows = [
|
||||||
|
{
|
||||||
|
id: "result-1",
|
||||||
|
seasonParticipantId: "participant-1",
|
||||||
|
placement: 9,
|
||||||
|
qualifyingPointsAwarded: null,
|
||||||
|
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "result-2",
|
||||||
|
seasonParticipantId: "participant-2",
|
||||||
|
placement: 9,
|
||||||
|
qualifyingPointsAwarded: null,
|
||||||
|
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const { db, setCalls } = makeProcessQPMockDb(windowRows, {
|
||||||
|
tournamentId: "tournament-1",
|
||||||
|
bracketTemplateId: "tennis_128", // copied by clone…
|
||||||
|
playoffMatchIds: [], // …but no matches exist on this window
|
||||||
|
canonicalPlacements: Array.from({ length: 8 }, () => 9),
|
||||||
|
});
|
||||||
|
|
||||||
|
await processQualifyingEvent("event-1", db, { skipNotifications: true });
|
||||||
|
|
||||||
|
expect(setCalls).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ qualifyingPointsAwarded: "1.50" }),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(setCalls).not.toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ qualifyingPointsAwarded: "2.00" }),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the live roster count for standalone events with no tournament", async () => {
|
||||||
|
// No canonical field exists for a manual/standalone qualifying event, so the
|
||||||
|
// tie span is the players actually present: 2 players tied at 9th →
|
||||||
|
// (2+2)/2 = 2.00. This preserves existing behavior where there is no full field.
|
||||||
|
const windowRows = [
|
||||||
|
{
|
||||||
|
id: "result-1",
|
||||||
|
seasonParticipantId: "participant-1",
|
||||||
|
placement: 9,
|
||||||
|
qualifyingPointsAwarded: null,
|
||||||
|
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "result-2",
|
||||||
|
seasonParticipantId: "participant-2",
|
||||||
|
placement: 9,
|
||||||
|
qualifyingPointsAwarded: null,
|
||||||
|
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const { db, setCalls } = makeProcessQPMockDb(windowRows, {
|
||||||
|
tournamentId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await processQualifyingEvent("event-1", db, { skipNotifications: true });
|
||||||
|
|
||||||
|
expect(setCalls).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ qualifyingPointsAwarded: "2.00" }),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not increment majorsCompleted when reprocessing a placed zero-QP result", async () => {
|
it("does not increment majorsCompleted when reprocessing a placed zero-QP result", async () => {
|
||||||
const { db, setCalls } = makeProcessQPMockDb([
|
const { db, setCalls } = makeProcessQPMockDb([
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,10 @@ vi.mock("../qualifying-points", async (importOriginal) => {
|
||||||
import { deleteScoringEvent } from "../scoring-event";
|
import { deleteScoringEvent } from "../scoring-event";
|
||||||
|
|
||||||
describe("deleteScoringEvent", () => {
|
describe("deleteScoringEvent", () => {
|
||||||
it("decrements majorsCompleted for a processed zero-QP qualifying event", async () => {
|
it("does not write majorsCompleted on delete (it is derived on read)", async () => {
|
||||||
|
// majorsCompleted is no longer a stored counter — it is computed via
|
||||||
|
// getMajorsCompleted from completed qualifying events. Deleting a qualifying
|
||||||
|
// event must therefore never issue a sportsSeasons.majorsCompleted update.
|
||||||
const setCalls: unknown[] = [];
|
const setCalls: unknown[] = [];
|
||||||
const deleteChain = { where: vi.fn().mockResolvedValue(undefined) };
|
const deleteChain = { where: vi.fn().mockResolvedValue(undefined) };
|
||||||
const updateChain = {
|
const updateChain = {
|
||||||
|
|
@ -43,12 +46,6 @@ describe("deleteScoringEvent", () => {
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
sportsSeasons: {
|
|
||||||
findFirst: vi.fn().mockResolvedValue({
|
|
||||||
id: "sports-season-1",
|
|
||||||
majorsCompleted: 1,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
transaction: vi.fn(async (callback) => callback(db)),
|
transaction: vi.fn(async (callback) => callback(db)),
|
||||||
delete: vi.fn(() => deleteChain),
|
delete: vi.fn(() => deleteChain),
|
||||||
|
|
@ -57,9 +54,9 @@ describe("deleteScoringEvent", () => {
|
||||||
|
|
||||||
await deleteScoringEvent("event-1", db);
|
await deleteScoringEvent("event-1", db);
|
||||||
|
|
||||||
expect(setCalls).toEqual(
|
expect(setCalls).not.toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({ majorsCompleted: 0 }),
|
expect.objectContaining({ majorsCompleted: expect.anything() }),
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -75,21 +75,6 @@ export function calculateSplitQualifyingPoints(
|
||||||
return roundQualifyingPoints(totalQP / tieCount);
|
return roundQualifyingPoints(totalQP / tieCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasProcessedQualifyingPlacement(
|
|
||||||
results: Array<{
|
|
||||||
placement?: number | null;
|
|
||||||
qualifyingPointsAwarded?: string | null;
|
|
||||||
}>
|
|
||||||
): boolean {
|
|
||||||
return results.some(
|
|
||||||
(result) =>
|
|
||||||
result.placement !== null &&
|
|
||||||
result.placement !== undefined &&
|
|
||||||
result.qualifyingPointsAwarded !== null &&
|
|
||||||
result.qualifyingPointsAwarded !== undefined
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize default qualifying point configuration for a sports season
|
* Initialize default qualifying point configuration for a sports season
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ import {
|
||||||
calculateSplitQualifyingPoints,
|
calculateSplitQualifyingPoints,
|
||||||
diffChangedQualifyingPoints,
|
diffChangedQualifyingPoints,
|
||||||
getQPConfig,
|
getQPConfig,
|
||||||
hasProcessedQualifyingPlacement,
|
|
||||||
recalculateParticipantQP,
|
recalculateParticipantQP,
|
||||||
writeEventResultsQP,
|
writeEventResultsQP,
|
||||||
getQPStandings,
|
getQPStandings,
|
||||||
|
|
@ -848,13 +847,49 @@ export async function processQualifyingBracketEvent(
|
||||||
await writeEventResultsQP(eventId, event.sportsSeasonId, entries, db);
|
await writeEventResultsQP(eventId, event.sportsSeasonId, entries, db);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count how many results share each placement — the structural tie span used to
|
||||||
|
* split QP across a tied group. Callers pass the FULL canonical field
|
||||||
|
* (tournament_results) so the span reflects the whole tournament, not one window's
|
||||||
|
* roster subset. Null placements (filler / not-participating) are ignored.
|
||||||
|
*/
|
||||||
|
export function buildTieCountByPlacement(
|
||||||
|
results: Array<{ placement: number | null }>
|
||||||
|
): Map<number, number> {
|
||||||
|
const map = new Map<number, number>();
|
||||||
|
for (const r of results) {
|
||||||
|
if (r.placement === null) continue;
|
||||||
|
map.set(r.placement, (map.get(r.placement) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process a qualifying event completion and update QP totals.
|
* Process a qualifying event completion and update QP totals.
|
||||||
* Ties in QP are handled by sharing placements (averaged points).
|
* Ties in QP are handled by sharing placements (averaged points).
|
||||||
*/
|
*/
|
||||||
export async function processQualifyingEvent(
|
export async function processQualifyingEvent(
|
||||||
eventId: string,
|
eventId: string,
|
||||||
providedDb?: ReturnType<typeof database>
|
providedDb?: ReturnType<typeof database>,
|
||||||
|
options: {
|
||||||
|
skipNotifications?: boolean;
|
||||||
|
/**
|
||||||
|
* Pre-computed full-field tie span (placement → count) from the canonical
|
||||||
|
* tournament_results. When the fan-out already loaded the canonical results it
|
||||||
|
* passes this in so we don't re-query per window. Omitted for direct callers,
|
||||||
|
* which fall back to querying it here.
|
||||||
|
*/
|
||||||
|
canonicalTieCountByPlacement?: Map<number, number>;
|
||||||
|
/**
|
||||||
|
* This window's season_participant ids that were knocked out this sync in a
|
||||||
|
* non-scoring round. They earn no QP (so they never surface via changed QP),
|
||||||
|
* but a manager who drafted them should still be told. Threaded down from the
|
||||||
|
* primary bracket by the fan-out (syncTournamentResults), already translated
|
||||||
|
* to THIS window's season_participant ids. See the primary path in
|
||||||
|
* app/services/match-sync/index.ts (newlyEliminatedIds).
|
||||||
|
*/
|
||||||
|
newlyEliminatedParticipantIds?: Set<string>;
|
||||||
|
} = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const db = providedDb || database();
|
const db = providedDb || database();
|
||||||
|
|
||||||
|
|
@ -877,9 +912,6 @@ export async function processQualifyingEvent(
|
||||||
// Get all event results for this qualifying event
|
// Get all event results for this qualifying event
|
||||||
const results = await getEventResults(eventId, db);
|
const results = await getEventResults(eventId, db);
|
||||||
|
|
||||||
// Check if this was already processed (for majorsCompleted counter)
|
|
||||||
const wasAlreadyProcessed = hasProcessedQualifyingPlacement(results);
|
|
||||||
|
|
||||||
// Snapshot awarded QP before reprocessing so the Discord notification below can
|
// Snapshot awarded QP before reprocessing so the Discord notification below can
|
||||||
// announce only the participants whose QP actually changed. Without this, a
|
// announce only the participants whose QP actually changed. Without this, a
|
||||||
// sibling window re-scored on every fan-out sync (syncTournamentResults) would
|
// sibling window re-scored on every fan-out sync (syncTournamentResults) would
|
||||||
|
|
@ -889,7 +921,23 @@ export async function processQualifyingEvent(
|
||||||
qp: r.qualifyingPointsAwarded,
|
qp: r.qualifyingPointsAwarded,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Route to the bracket writer only when this window actually OWNS a bracket (has
|
||||||
|
// playoff matches). A window can carry a bracketTemplateId with no matches — e.g. a
|
||||||
|
// league window cloned from a bracket season copies the template id but not the
|
||||||
|
// matches (cloneSportsSeason) — and processQualifyingBracketEvent would derive zero
|
||||||
|
// states and write NO QP. Those windows must be scored via the placement/canonical
|
||||||
|
// path below, exactly like a no-template sibling.
|
||||||
|
let hasBracketMatches = false;
|
||||||
if (event.bracketTemplateId) {
|
if (event.bracketTemplateId) {
|
||||||
|
const existing = await db
|
||||||
|
.select({ id: schema.playoffMatches.id })
|
||||||
|
.from(schema.playoffMatches)
|
||||||
|
.where(eq(schema.playoffMatches.scoringEventId, eventId))
|
||||||
|
.limit(1);
|
||||||
|
hasBracketMatches = existing.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasBracketMatches) {
|
||||||
// Bracket-based qualifying event (e.g. CS2 Champions Stage): QP is owned by the
|
// Bracket-based qualifying event (e.g. CS2 Champions Stage): QP is owned by the
|
||||||
// bracket/stage writers, which assign each placement its STRUCTURAL tie span.
|
// bracket/stage writers, which assign each placement its STRUCTURAL tie span.
|
||||||
// Re-derive via processQualifyingBracketEvent and leave the Swiss-exit rows
|
// Re-derive via processQualifyingBracketEvent and leave the Swiss-exit rows
|
||||||
|
|
@ -914,6 +962,28 @@ export async function processQualifyingEvent(
|
||||||
qpConfig.map((config) => [config.placement, parseFloat(config.points)])
|
qpConfig.map((config) => [config.placement, parseFloat(config.points)])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Full-field tie span. The number of players tied at a placement is a property
|
||||||
|
// of the whole tournament field (canonical tournament_results), NOT of who
|
||||||
|
// happens to be on THIS window's roster. Sibling/mirror windows only hold the
|
||||||
|
// draftable subset of the field, so counting the placements present locally
|
||||||
|
// (group.length) under-counts a tied group and over-awards it: tennis R16 losers
|
||||||
|
// all sit at placement 9 with a structural span of 8 → (2+2+2+2+1+1+1+1)/8 = 1.5
|
||||||
|
// QP; a window holding only 4 of them would wrongly split 4 ways → 2 QP. Deriving
|
||||||
|
// the span from canonical results keeps every window/league identical. The fan-out
|
||||||
|
// passes this map in (already loaded once per tournament); direct callers with a
|
||||||
|
// tournament link query it here. Standalone events (no tournamentId, no map) have
|
||||||
|
// no canonical field, so fall back to the live count.
|
||||||
|
const canonicalTieCountByPlacement: Map<number, number> | null =
|
||||||
|
options.canonicalTieCountByPlacement ??
|
||||||
|
(event.tournamentId
|
||||||
|
? buildTieCountByPlacement(
|
||||||
|
await db
|
||||||
|
.select({ placement: schema.tournamentResults.placement })
|
||||||
|
.from(schema.tournamentResults)
|
||||||
|
.where(eq(schema.tournamentResults.tournamentId, event.tournamentId))
|
||||||
|
)
|
||||||
|
: null);
|
||||||
|
|
||||||
// Group results by placement to handle ties
|
// Group results by placement to handle ties
|
||||||
const placementGroups = new Map<number, typeof results>();
|
const placementGroups = new Map<number, typeof results>();
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
|
|
@ -926,7 +996,7 @@ export async function processQualifyingEvent(
|
||||||
|
|
||||||
// Process each placement group and update event_results with QP awarded
|
// Process each placement group and update event_results with QP awarded
|
||||||
for (const [placement, group] of placementGroups) {
|
for (const [placement, group] of placementGroups) {
|
||||||
const tieCount = group.length;
|
const tieCount = canonicalTieCountByPlacement?.get(placement) ?? group.length;
|
||||||
|
|
||||||
const qpPerParticipant = calculateSplitQualifyingPoints(
|
const qpPerParticipant = calculateSplitQualifyingPoints(
|
||||||
placement,
|
placement,
|
||||||
|
|
@ -954,17 +1024,10 @@ export async function processQualifyingEvent(
|
||||||
await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
|
await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Increment majorsCompleted counter (only if this is the first time processing)
|
// NOTE: majorsCompleted is NOT a stored counter. It is derived on read via
|
||||||
if (!wasAlreadyProcessed) {
|
// getMajorsCompleted() (count of completed qualifying events). Incrementing here
|
||||||
const sportsSeason = event.sportsSeason;
|
// per fan-out sync over-counted it past totalMajors ("11 of 4"), so the write was
|
||||||
await db
|
// removed. See app/models/scoring-event.ts:getMajorsCompleted.
|
||||||
.update(schema.sportsSeasons)
|
|
||||||
.set({
|
|
||||||
majorsCompleted: (sportsSeason.majorsCompleted || 0) + 1,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(schema.sportsSeasons.id, event.sportsSeasonId));
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.log(
|
logger.log(
|
||||||
`[ScoringCalculator] Processed qualifying event ${eventId}: awarded QP to ${results.length} participants`
|
`[ScoringCalculator] Processed qualifying event ${eventId}: awarded QP to ${results.length} participants`
|
||||||
|
|
@ -981,9 +1044,22 @@ export async function processQualifyingEvent(
|
||||||
afterRows.map((r) => ({ id: r.seasonParticipantId, qp: r.qualifyingPointsAwarded }))
|
afterRows.map((r) => ({ id: r.seasonParticipantId, qp: r.qualifyingPointsAwarded }))
|
||||||
);
|
);
|
||||||
|
|
||||||
if (changedParticipantIds.size > 0) {
|
// Players knocked out this sync in a non-scoring round earn no QP, so they never
|
||||||
|
// appear in changedParticipantIds. Announce them too (mirroring the primary path
|
||||||
|
// in syncTennisDraw), so a mirror window's "Knocked Out" section isn't dropped.
|
||||||
|
const eliminatedIds = options.newlyEliminatedParticipantIds ?? new Set<string>();
|
||||||
|
if (
|
||||||
|
(changedParticipantIds.size > 0 || eliminatedIds.size > 0) &&
|
||||||
|
!options.skipNotifications
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
await notifyQualifyingPointsUpdate(event.sportsSeasonId, eventId, db, changedParticipantIds);
|
await notifyQualifyingPointsUpdate(
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
@ -1864,8 +1940,10 @@ 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.
|
||||||
// Losers who advance to another match (loserAdvances=true, e.g. NBA 7v8 → PIR2) have
|
// Both managers' tags are always shown for context when their teams are drafted; the
|
||||||
// showLoser=false and are correctly suppressed.
|
// showLoser flag (isLoserNotifiable) only gates whether the loser is @-pinged — a loser
|
||||||
|
// who advanced rather than being eliminated (loserAdvances=true, e.g. NBA 7v8 → PIR2, or
|
||||||
|
// a World Cup semifinal loser) is named but not pinged.
|
||||||
let scoredMatches: ScoredMatch[] | undefined;
|
let scoredMatches: ScoredMatch[] | undefined;
|
||||||
if (allCompletedMatches.length > 0) {
|
if (allCompletedMatches.length > 0) {
|
||||||
const relevant = allCompletedMatches.filter(
|
const relevant = allCompletedMatches.filter(
|
||||||
|
|
@ -1898,7 +1976,12 @@ 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,
|
||||||
loserUsername: x.showLoser && x.loserOwnerId ? usernameByUserId.get(x.loserOwnerId) : undefined,
|
// Show the loser's manager tag whenever their team is drafted, mirroring the
|
||||||
|
// winner above — even when the loser advances rather than being eliminated
|
||||||
|
// (World Cup semifinal → 3rd-place playoff, AFL Qualifying Final → Semi Final).
|
||||||
|
// The @-ping stays gated by showLoser (loserDiscordUserId below): a still-alive
|
||||||
|
// loser who neither scored nor was eliminated is named for context but not pinged.
|
||||||
|
loserUsername: x.loserOwnerId ? usernameByUserId.get(x.loserOwnerId) : undefined,
|
||||||
winnerDiscordUserId: x.winnerScoreChanged && x.winnerOwnerId ? discordIdByUserId.get(x.winnerOwnerId) : undefined,
|
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,
|
||||||
}))
|
}))
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import * as schema from "~/database/schema";
|
||||||
import { eq, and, desc, asc, gte, lte, or, inArray, isNotNull, sql } from "drizzle-orm";
|
import { eq, and, desc, asc, gte, lte, or, inArray, isNotNull, sql } from "drizzle-orm";
|
||||||
import type { BracketRegion } from "~/lib/bracket-templates";
|
import type { BracketRegion } from "~/lib/bracket-templates";
|
||||||
import { recalculateAffectedLeagues } from "./scoring-calculator";
|
import { recalculateAffectedLeagues } from "./scoring-calculator";
|
||||||
import { hasProcessedQualifyingPlacement, recalculateParticipantQP } from "./qualifying-points";
|
import { recalculateParticipantQP } from "./qualifying-points";
|
||||||
import { findParticipantNamesByIds } from "./season-participant";
|
import { findParticipantNamesByIds } from "./season-participant";
|
||||||
import { deleteTournament } from "./tournament";
|
import { deleteTournament } from "./tournament";
|
||||||
import type { EventType } from "./scoring-event-types";
|
import type { EventType } from "./scoring-event-types";
|
||||||
|
|
@ -144,6 +144,33 @@ export async function getQualifyingEvents(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of "majors completed" for a sports season, derived on read as the count of
|
||||||
|
* qualifying events that have been marked complete. Replaces the old stored
|
||||||
|
* sportsSeasons.majorsCompleted counter, which was incremented per fan-out sync and
|
||||||
|
* over-counted past totalMajors ("11 of 4"). Computing it makes the value
|
||||||
|
* self-correcting and immune to double-counting.
|
||||||
|
*/
|
||||||
|
export async function getMajorsCompleted(
|
||||||
|
sportsSeasonId: string,
|
||||||
|
providedDb?: ReturnType<typeof database>
|
||||||
|
): Promise<number> {
|
||||||
|
const db = providedDb || database();
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(schema.scoringEvents)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||||||
|
eq(schema.scoringEvents.isQualifyingEvent, true),
|
||||||
|
eq(schema.scoringEvents.isComplete, true)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows[0]?.count ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a scoring event
|
* Update a scoring event
|
||||||
*/
|
*/
|
||||||
|
|
@ -246,13 +273,11 @@ export async function deleteScoringEvent(
|
||||||
// For qualifying events: capture affected participant IDs before the cascade deletes
|
// For qualifying events: capture affected participant IDs before the cascade deletes
|
||||||
// eventResults (which is how we know who had QP awarded from this event).
|
// eventResults (which is how we know who had QP awarded from this event).
|
||||||
let affectedParticipantIds: string[] = [];
|
let affectedParticipantIds: string[] = [];
|
||||||
let wasQPProcessed = false;
|
|
||||||
if (event.isQualifyingEvent) {
|
if (event.isQualifyingEvent) {
|
||||||
const results = await db.query.eventResults.findMany({
|
const results = await db.query.eventResults.findMany({
|
||||||
where: eq(schema.eventResults.scoringEventId, eventId),
|
where: eq(schema.eventResults.scoringEventId, eventId),
|
||||||
});
|
});
|
||||||
affectedParticipantIds = results.map((r) => r.seasonParticipantId);
|
affectedParticipantIds = results.map((r) => r.seasonParticipantId);
|
||||||
wasQPProcessed = hasProcessedQualifyingPlacement(results);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
|
|
@ -275,18 +300,9 @@ export async function deleteScoringEvent(
|
||||||
await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
|
await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrement majorsCompleted if this event had already been processed
|
// majorsCompleted is derived on read (getMajorsCompleted), so deleting the event
|
||||||
if (wasQPProcessed) {
|
// — which removes it from the completed-qualifying-event count — self-corrects the
|
||||||
const sportsSeason = await db.query.sportsSeasons.findFirst({
|
// number. No stored counter to decrement.
|
||||||
where: eq(schema.sportsSeasons.id, event.sportsSeasonId),
|
|
||||||
});
|
|
||||||
if (sportsSeason && (sportsSeason.majorsCompleted ?? 0) > 0) {
|
|
||||||
await db
|
|
||||||
.update(schema.sportsSeasons)
|
|
||||||
.set({ majorsCompleted: (sportsSeason.majorsCompleted ?? 1) - 1, updatedAt: new Date() })
|
|
||||||
.where(eq(schema.sportsSeasons.id, event.sportsSeasonId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await recalculateAffectedLeagues(event.sportsSeasonId, db);
|
await recalculateAffectedLeagues(event.sportsSeasonId, db);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { newlyDecidedLosers } from "../admin.sports-seasons.$id.events.$eventId.bracket.helpers";
|
||||||
|
|
||||||
|
describe("newlyDecidedLosers", () => {
|
||||||
|
it("returns losers of matches that were not already complete", () => {
|
||||||
|
expect(
|
||||||
|
newlyDecidedLosers([
|
||||||
|
{ wasComplete: false, loserId: "sp-1" },
|
||||||
|
{ wasComplete: false, loserId: "sp-2" },
|
||||||
|
])
|
||||||
|
).toEqual(["sp-1", "sp-2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops re-scores of already-complete matches (no re-announce)", () => {
|
||||||
|
// sp-1's match just finished; sp-2's match was already complete before this
|
||||||
|
// action — only sp-1 is a NEW knockout.
|
||||||
|
expect(
|
||||||
|
newlyDecidedLosers([
|
||||||
|
{ wasComplete: false, loserId: "sp-1" },
|
||||||
|
{ wasComplete: true, loserId: "sp-2" },
|
||||||
|
])
|
||||||
|
).toEqual(["sp-1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips entries with an unknown loser", () => {
|
||||||
|
expect(
|
||||||
|
newlyDecidedLosers([
|
||||||
|
{ wasComplete: false, loserId: null },
|
||||||
|
{ wasComplete: false, loserId: "sp-3" },
|
||||||
|
])
|
||||||
|
).toEqual(["sp-3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty array when nothing was newly decided", () => {
|
||||||
|
expect(
|
||||||
|
newlyDecidedLosers([
|
||||||
|
{ wasComplete: true, loserId: "sp-1" },
|
||||||
|
{ wasComplete: true, loserId: "sp-2" },
|
||||||
|
])
|
||||||
|
).toEqual([]);
|
||||||
|
expect(newlyDecidedLosers([])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
/**
|
||||||
|
* Pure helpers for the bracket-scoring route action, extracted so the
|
||||||
|
* knockout-detection logic can be unit-tested without the full action harness.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* From a set of matches being scored this action, return the season_participant
|
||||||
|
* ids knocked out for the FIRST time. A loser counts only when its match was NOT
|
||||||
|
* already complete — a re-score/correction of a finished match must not
|
||||||
|
* re-announce the exit (mirrors populateBracketFromDraw's first-completion rule in
|
||||||
|
* the live-sync path). Entries with an unknown loser (`null`) are skipped.
|
||||||
|
*
|
||||||
|
* The ids are season_participant ids of the primary window (playoff_matches are
|
||||||
|
* keyed by season_participant); the fan-out translates them to each mirror
|
||||||
|
* window's own season_participant id before announcing the "Knocked Out" section.
|
||||||
|
*/
|
||||||
|
export function newlyDecidedLosers(
|
||||||
|
entries: Array<{ wasComplete: boolean; loserId: string | null }>
|
||||||
|
): string[] {
|
||||||
|
return entries
|
||||||
|
.filter((e): e is { wasComplete: boolean; loserId: string } =>
|
||||||
|
!e.wasComplete && e.loserId !== null
|
||||||
|
)
|
||||||
|
.map((e) => e.loserId);
|
||||||
|
}
|
||||||
|
|
@ -65,9 +65,10 @@ import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.server";
|
import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.server";
|
||||||
import { fanOutMajorIfPrimary } from "~/services/sync-tournament-results";
|
import { fanOutMajorIfPrimary, syncMajorFromPrimaryEvent } 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);
|
||||||
|
|
@ -135,9 +136,23 @@ 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> {
|
||||||
await processQualifyingBracketEvent(event.id, db);
|
// processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent),
|
||||||
|
// recalcs participant QP totals, AND announces the QP change to this window's leagues.
|
||||||
|
// Calling processQualifyingBracketEvent directly here would score silently — the QP
|
||||||
|
// Discord notification only fires from processQualifyingEvent. The fan-out below skips
|
||||||
|
// this (primary) window via skipEventId, so mirror windows are announced separately
|
||||||
|
// with no double-post.
|
||||||
|
await processQualifyingEvent(event.id, db, { newlyEliminatedParticipantIds });
|
||||||
await recalculateAffectedLeagues(
|
await recalculateAffectedLeagues(
|
||||||
event.sportsSeasonId,
|
event.sportsSeasonId,
|
||||||
db,
|
db,
|
||||||
|
|
@ -145,7 +160,10 @@ 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, { markComplete: false });
|
await fanOutMajorIfPrimary(event, {
|
||||||
|
markComplete: false,
|
||||||
|
newlyEliminatedParticipantIds,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -398,6 +416,13 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
return { error: "Could not determine loser" };
|
return { error: "Could not determine loser" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A knockout is "newly decided" only when the match wasn't already complete
|
||||||
|
// (mirrors populateBracketFromDraw's first-completion rule) — a re-score/
|
||||||
|
// correction of an already-finished match must not re-announce the exit.
|
||||||
|
const setWinnerNewlyEliminated = new Set(
|
||||||
|
newlyDecidedLosers([{ wasComplete: match.isComplete, loserId }])
|
||||||
|
);
|
||||||
|
|
||||||
// Set the winner
|
// Set the winner
|
||||||
await setMatchWinner(matchId, winnerId, loserId);
|
await setMatchWinner(matchId, winnerId, loserId);
|
||||||
|
|
||||||
|
|
@ -424,11 +449,16 @@ 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(event, db, {
|
await scoreQualifyingBracket(
|
||||||
eventId: event.id,
|
event,
|
||||||
eventName: event.name ?? undefined,
|
db,
|
||||||
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).
|
||||||
|
|
@ -500,6 +530,10 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
const processedMatchIds: string[] = [];
|
const processedMatchIds: string[] = [];
|
||||||
|
// Per-match completion + loser, collected so newlyDecidedLosers() can pick out
|
||||||
|
// the batch's first-time knockouts to fan out to mirror windows (a non-scoring-
|
||||||
|
// round exit earns no QP and is otherwise invisible to the mirror).
|
||||||
|
const decidedEntries: Array<{ wasComplete: boolean; loserId: string | null }> = [];
|
||||||
|
|
||||||
for (const { matchId, winnerId } of winnerAssignments) {
|
for (const { matchId, winnerId } of winnerAssignments) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -568,6 +602,10 @@ 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(
|
||||||
|
|
@ -581,9 +619,14 @@ 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.
|
// Qualifying majors: derive QP from the full bracket once for the batch, and
|
||||||
|
// announce the QP change to this window's leagues. processQualifyingEvent (not
|
||||||
|
// processQualifyingBracketEvent) is what sends the QP Discord notification; the
|
||||||
|
// fan-out below skips this window (skipEventId) so mirrors don't double-post.
|
||||||
if (event.isQualifyingEvent) {
|
if (event.isQualifyingEvent) {
|
||||||
await processQualifyingBracketEvent(event.id, db);
|
await processQualifyingEvent(event.id, db, {
|
||||||
|
newlyEliminatedParticipantIds: new Set(newlyDecidedLosers(decidedEntries)),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// Update probabilities first so recalculateAffectedLeagues reads fresh EVs
|
// Update probabilities first so recalculateAffectedLeagues reads fresh EVs
|
||||||
// when computing projected points.
|
// when computing projected points.
|
||||||
|
|
@ -598,8 +641,12 @@ 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.
|
// Shared major primary window: propagate this round to siblings, carrying
|
||||||
await fanOutMajorIfPrimary(event, { markComplete: false });
|
// the batch's newly-decided knockouts so mirrors announce them too.
|
||||||
|
await fanOutMajorIfPrimary(event, {
|
||||||
|
markComplete: false,
|
||||||
|
newlyEliminatedParticipantIds: new Set(newlyDecidedLosers(decidedEntries)),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -763,11 +810,32 @@ 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 windows (data-correction; not final).
|
// Re-propagate corrected QP to sibling/mirror windows (data-correction; not
|
||||||
await fanOutMajorIfPrimary(event, { markComplete: false });
|
// final). Call syncMajorFromPrimaryEvent directly rather than the
|
||||||
return {
|
// swallow-and-log fanOutMajorIfPrimary so the admin actually sees whether the
|
||||||
success: `Reprocessed qualifying bracket: cleared stale fantasy points and recomputed QP (${completed.length} completed match(es)).`,
|
// mirrors were re-scored — a silent failure here is exactly how mirrors got
|
||||||
};
|
// left showing stale QP behind a green "success".
|
||||||
|
const baseMessage = `Reprocessed qualifying bracket: cleared stale fantasy points and recomputed QP (${completed.length} completed match(es)).`;
|
||||||
|
if (event.isPrimary && event.tournamentId) {
|
||||||
|
try {
|
||||||
|
const report = await syncMajorFromPrimaryEvent(event.id, { markComplete: false });
|
||||||
|
// Surface a partial fan-out as an error so it renders as a warning
|
||||||
|
// banner, not a green success the admin might skim past while some
|
||||||
|
// mirror windows are left stale.
|
||||||
|
if (report.windowsFailed > 0) {
|
||||||
|
const reasons = report.failures.map((f) => f.error).join("; ");
|
||||||
|
return {
|
||||||
|
error: `${baseMessage} Synced ${report.windowsSynced} mirror window(s), but ${report.windowsFailed} failed (those windows may be stale): ${reasons}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { success: `${baseMessage} Synced ${report.windowsSynced} mirror window(s).` };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
error: `Primary window recomputed, but mirror fan-out failed (mirror windows may be stale): ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: `${baseMessage} No mirror windows to sync.` };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (completed.length === 0) {
|
if (completed.length === 0) {
|
||||||
|
|
@ -882,9 +950,11 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
// fantasy placements come from finalizeQualifyingPoints across all of them.
|
// fantasy placements come from finalizeQualifyingPoints across all of them.
|
||||||
if (event.isQualifyingEvent) {
|
if (event.isQualifyingEvent) {
|
||||||
const db = database();
|
const db = database();
|
||||||
// processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent),
|
// processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent)
|
||||||
// increments majorsCompleted, and recalcs participant QP totals. Season-wide
|
// and recalcs participant QP totals. majorsCompleted is derived on read from
|
||||||
// fantasy finalization stays with finalizeQualifyingPoints across all majors.
|
// completed qualifying events (see getMajorsCompleted) — marking this event
|
||||||
|
// complete below is what advances it. Season-wide fantasy finalization stays with
|
||||||
|
// finalizeQualifyingPoints across all majors.
|
||||||
await processQualifyingEvent(params.eventId, db);
|
await processQualifyingEvent(params.eventId, db);
|
||||||
await db
|
await db
|
||||||
.update(schema.scoringEvents)
|
.update(schema.scoringEvents)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
bulkCreateScoringEvents,
|
bulkCreateScoringEvents,
|
||||||
ensurePrimaryEvent,
|
ensurePrimaryEvent,
|
||||||
countWindowsByTournament,
|
countWindowsByTournament,
|
||||||
|
getMajorsCompleted,
|
||||||
type CreateScoringEventData,
|
type CreateScoringEventData,
|
||||||
} from "~/models/scoring-event";
|
} from "~/models/scoring-event";
|
||||||
import { isBracketMajor } from "~/lib/event-utils";
|
import { isBracketMajor } from "~/lib/event-utils";
|
||||||
|
|
@ -28,10 +29,14 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||||
|
|
||||||
const events = await getScoringEventsForSportsSeason(params.id);
|
const events = await getScoringEventsForSportsSeason(params.id);
|
||||||
|
|
||||||
// For qualifying sports seasons, get QP standings with global ranks attached
|
// For qualifying sports seasons, get QP standings with global ranks attached.
|
||||||
|
// majorsCompleted is derived on read (count of completed qualifying events), not a
|
||||||
|
// stored counter — see getMajorsCompleted.
|
||||||
let qpStandings = null;
|
let qpStandings = null;
|
||||||
const scoringRules = null;
|
const scoringRules = null;
|
||||||
|
let majorsCompleted = 0;
|
||||||
if (sportsSeason.scoringPattern === "qualifying_points") {
|
if (sportsSeason.scoringPattern === "qualifying_points") {
|
||||||
|
majorsCompleted = await getMajorsCompleted(params.id);
|
||||||
const standings = await getQPStandings(params.id);
|
const standings = await getQPStandings(params.id);
|
||||||
let prevQP = -1;
|
let prevQP = -1;
|
||||||
let prevRankStart = 1;
|
let prevRankStart = 1;
|
||||||
|
|
@ -88,7 +93,7 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sportsSeason: sportsSeason as typeof sportsSeason & {
|
sportsSeason: { ...sportsSeason, majorsCompleted } as typeof sportsSeason & {
|
||||||
sport: { id: string; name: string; type: string; slug: string };
|
sport: { id: string; name: string; type: string; slug: string };
|
||||||
},
|
},
|
||||||
events: eventsWithSharing,
|
events: eventsWithSharing,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
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";
|
||||||
|
|
||||||
|
|
@ -25,6 +26,10 @@ 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,
|
||||||
|
|
@ -61,7 +66,22 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||||
|
|
||||||
const inputPolicy = getSimulatorInputPolicy(config.config);
|
const inputPolicy = getSimulatorInputPolicy(config.config);
|
||||||
|
|
||||||
return { sportsSeason, participants, config, inputRows, readiness, inputPolicy };
|
// Sport-aware preview columns: the intersection of the displayable numeric keys
|
||||||
|
// with this simulator's required + optional inputs, so each season shows exactly
|
||||||
|
// the inputs its simulator consumes (F1 = odds, NBA = Elo, NCAA = rating, ...).
|
||||||
|
// Resolved here (server-only) so the client bundle never imports the simulator
|
||||||
|
// manifest/registry, which transitively pulls in `.server` modules.
|
||||||
|
const relevantInputs = new Set<SimulatorInputKey>([
|
||||||
|
...config.profile.requiredInputs,
|
||||||
|
...config.profile.optionalInputs,
|
||||||
|
]);
|
||||||
|
const inputColumns = DISPLAY_INPUT_ORDER.filter((key) => relevantInputs.has(key)).map((key) => ({
|
||||||
|
key,
|
||||||
|
label: simulatorInputLabel(key),
|
||||||
|
required: config.profile.requiredInputs.includes(key),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { sportsSeason, participants, config, inputRows, readiness, inputPolicy, inputColumns };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ActionData {
|
interface ActionData {
|
||||||
|
|
@ -69,6 +89,24 @@ 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
|
||||||
|
|
@ -357,6 +395,42 @@ 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">
|
||||||
|
|
@ -636,29 +710,125 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<div className="rounded-md border">
|
{externalInputsSection && (
|
||||||
<div className="grid grid-cols-6 gap-2 border-b px-3 py-2 text-xs font-medium text-muted-foreground">
|
<div className="rounded-md border border-sky-500/30 bg-sky-500/10 p-3 text-sm flex items-center justify-between gap-4">
|
||||||
<div className="col-span-2">Participant</div>
|
<span>
|
||||||
<div>Elo</div>
|
This simulator's participant inputs are managed on the{" "}
|
||||||
<div>Odds</div>
|
<strong>{externalInputsSection.label}</strong> page — the list below is a roster only.
|
||||||
<div>Rank</div>
|
</span>
|
||||||
<div>Rating</div>
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<Link to={externalInputsSection.to}>Go to {externalInputsSection.label}</Link>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{inputRows.slice(0, 20).map(({ participant, input }) => (
|
)}
|
||||||
<div key={participant.id} className="grid grid-cols-6 gap-2 border-b last:border-b-0 px-3 py-2 text-sm">
|
|
||||||
<div className="col-span-2 font-medium">{participant.name}</div>
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div>{input?.sourceElo ?? "—"}</div>
|
<Input
|
||||||
<div>{input?.sourceOdds ?? "—"}</div>
|
type="search"
|
||||||
<div>{input?.worldRanking ?? "—"}</div>
|
placeholder="Search participants…"
|
||||||
<div>{input?.rating ?? "—"}</div>
|
value={search}
|
||||||
</div>
|
onChange={(event) => {
|
||||||
))}
|
setSearch(event.target.value);
|
||||||
{inputRows.length > 20 && (
|
setPage(0);
|
||||||
<div className="px-3 py-2 text-xs text-muted-foreground">
|
}}
|
||||||
Showing 20 of {inputRows.length} participants
|
className="max-w-xs"
|
||||||
</div>
|
/>
|
||||||
|
{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>
|
||||||
|
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<div
|
||||||
|
className="grid gap-2 border-b px-3 py-2 text-xs font-medium text-muted-foreground"
|
||||||
|
style={{ gridTemplateColumns: gridTemplate }}
|
||||||
|
>
|
||||||
|
<div>Participant</div>
|
||||||
|
{inputColumns.length > 0 ? (
|
||||||
|
inputColumns.map((column) => (
|
||||||
|
<div key={column.key} className="capitalize">
|
||||||
|
{column.label}
|
||||||
|
{column.required && <span className="text-amber-500"> *</span>}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div>—</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{pageRows.length === 0 ? (
|
||||||
|
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
No participants match.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
pageRows.map(({ participant, input }) => {
|
||||||
|
const incomplete = isRowIncomplete(input);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={participant.id}
|
||||||
|
className="grid gap-2 border-b last:border-b-0 px-3 py-2 text-sm"
|
||||||
|
style={{ gridTemplateColumns: gridTemplate }}
|
||||||
|
>
|
||||||
|
<div className="font-medium flex items-center gap-2">
|
||||||
|
{incomplete && <span className="h-2 w-2 shrink-0 rounded-full bg-amber-500" title="Missing a required input" />}
|
||||||
|
{participant.name}
|
||||||
|
</div>
|
||||||
|
{inputColumns.length > 0 ? (
|
||||||
|
inputColumns.map((column) => {
|
||||||
|
const value = input?.[column.key];
|
||||||
|
return <div key={column.key}>{typeof value === "number" || typeof value === "string" ? value : "—"}</div>;
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div>—</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between gap-4 px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{filteredRows.length === 0
|
||||||
|
? "0 participants"
|
||||||
|
: `Showing ${pageStart + 1}–${pageStart + pageRows.length} of ${filteredRows.length}${
|
||||||
|
filteredRows.length !== inputRows.length ? ` (${inputRows.length} total)` : ""
|
||||||
|
}`}
|
||||||
|
</span>
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={safePage === 0}
|
||||||
|
onClick={() => setPage(safePage - 1)}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span>
|
||||||
|
Page {safePage + 1} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={safePage >= totalPages - 1}
|
||||||
|
onClick={() => setPage(safePage + 1)}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { getQPStandings } from "~/models/qualifying-points";
|
||||||
import {
|
import {
|
||||||
getUpcomingScoringEvents,
|
getUpcomingScoringEvents,
|
||||||
getRecentCompletedEvents,
|
getRecentCompletedEvents,
|
||||||
|
getMajorsCompleted,
|
||||||
} from "~/models/scoring-event";
|
} from "~/models/scoring-event";
|
||||||
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
||||||
import { getAllParticipantEVsForSeason } from "~/models/participant-expected-value";
|
import { getAllParticipantEVsForSeason } from "~/models/participant-expected-value";
|
||||||
|
|
@ -157,6 +158,7 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
let seasonStandings: SeasonStanding[] = [];
|
let seasonStandings: SeasonStanding[] = [];
|
||||||
type QPStanding = Awaited<ReturnType<typeof getQPStandings>>[number] & { globalRank: number };
|
type QPStanding = Awaited<ReturnType<typeof getQPStandings>>[number] & { globalRank: number };
|
||||||
let qpStandings: QPStanding[] = [];
|
let qpStandings: QPStanding[] = [];
|
||||||
|
let majorsCompleted = 0;
|
||||||
|
|
||||||
// Group standings for group-stage events (e.g. FIFA World Cup)
|
// Group standings for group-stage events (e.g. FIFA World Cup)
|
||||||
type RawGroupMatch = Awaited<ReturnType<typeof findMatchesByGroupIds>> extends Map<string, Array<infer T>> ? T : never;
|
type RawGroupMatch = Awaited<ReturnType<typeof findMatchesByGroupIds>> extends Map<string, Array<infer T>> ? T : never;
|
||||||
|
|
@ -293,6 +295,9 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
} else if (scoringPattern === "qualifying_points") {
|
} else if (scoringPattern === "qualifying_points") {
|
||||||
|
// majorsCompleted is derived on read (count of completed qualifying events), not a
|
||||||
|
// stored counter — see getMajorsCompleted.
|
||||||
|
majorsCompleted = await getMajorsCompleted(sportsSeasonId);
|
||||||
const standings = await getQPStandings(sportsSeasonId);
|
const standings = await getQPStandings(sportsSeasonId);
|
||||||
// Compute global ranks with tie handling across the full field before filtering,
|
// Compute global ranks with tie handling across the full field before filtering,
|
||||||
// so the displayed rank numbers remain correct after undrafted/zero-QP rows are removed.
|
// so the displayed rank numbers remain correct after undrafted/zero-QP rows are removed.
|
||||||
|
|
@ -341,7 +346,7 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
return {
|
return {
|
||||||
league,
|
league,
|
||||||
season,
|
season,
|
||||||
sportsSeason,
|
sportsSeason: { ...sportsSeason, majorsCompleted },
|
||||||
scoringPattern,
|
scoringPattern,
|
||||||
playoffMatches,
|
playoffMatches,
|
||||||
playoffRounds,
|
playoffRounds,
|
||||||
|
|
|
||||||
|
|
@ -186,6 +186,37 @@ 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.
|
||||||
|
|
@ -277,6 +308,43 @@ 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,
|
||||||
|
|
@ -675,28 +743,54 @@ describe("sendDraftOrderNotification", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The caller now supplies each entry's rank in the FULL season field. This helper
|
||||||
|
// mirrors the production ranking (qualifying-points-discord.server.ts): sort by
|
||||||
|
// qpTotal desc, competition ranking with ties sharing the lower rank, so existing
|
||||||
|
// tests keep asserting ranks derived from qpTotal.
|
||||||
|
function withRanks<T extends { qpTotal: number }>(entries: T[]) {
|
||||||
|
const sorted = [...entries].toSorted((a, b) => b.qpTotal - a.qpTotal);
|
||||||
|
const rankByTotal = new Map<number, number>();
|
||||||
|
let prevTotal = Number.NaN;
|
||||||
|
let prevRank = 0;
|
||||||
|
sorted.forEach((e, i) => {
|
||||||
|
const rank = i > 0 && Math.abs(e.qpTotal - prevTotal) < 0.001 ? prevRank : i + 1;
|
||||||
|
rankByTotal.set(e.qpTotal, rank);
|
||||||
|
prevTotal = e.qpTotal;
|
||||||
|
prevRank = rank;
|
||||||
|
});
|
||||||
|
const countByTotal = new Map<number, number>();
|
||||||
|
for (const e of entries) countByTotal.set(e.qpTotal, (countByTotal.get(e.qpTotal) ?? 0) + 1);
|
||||||
|
return entries.map((e) => ({
|
||||||
|
...e,
|
||||||
|
globalRank: rankByTotal.get(e.qpTotal) ?? 0,
|
||||||
|
globalRankTied: (countByTotal.get(e.qpTotal) ?? 0) > 1,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
describe("sendQualifyingPointsUpdateNotification", () => {
|
describe("sendQualifyingPointsUpdateNotification", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.stubGlobal("fetch", mockFetch(204));
|
vi.stubGlobal("fetch", mockFetch(204));
|
||||||
});
|
});
|
||||||
|
|
||||||
const BASE_ENTRIES = [
|
const BASE_ENTRIES = withRanks([
|
||||||
{ participantName: "Novak Djokovic", qpEarned: 20, qpTotal: 45, ownerUsername: "alex" },
|
{ participantName: "Novak Djokovic", qpEarned: 20, qpTotal: 45, ownerUsername: "alex" },
|
||||||
{ participantName: "Carlos Alcaraz", qpEarned: 14, qpTotal: 34, ownerUsername: "chris" },
|
{ participantName: "Carlos Alcaraz", qpEarned: 14, qpTotal: 34, ownerUsername: "chris" },
|
||||||
{ 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 and QP title", async () => {
|
it("sends an embed with gold color, QP title, no footer, and links the title to the standings page", 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].footer).toEqual({ text: "brackt.com" });
|
expect(payload.embeds[0].url).toBe("https://brackt.com/leagues/abc/sports-seasons/ss-1");
|
||||||
|
expect(payload.embeds[0].footer).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows sport and event header", async () => {
|
it("shows sport and event header", async () => {
|
||||||
|
|
@ -721,49 +815,121 @@ 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("shows No Points section for entries with qpEarned == 0", async () => {
|
it("omits zero-QP drafted participants entirely from the Drafted Participants section", async () => {
|
||||||
|
// Only participants who have actually scored (qpTotal > 0) are listed; a 0-QP drafted
|
||||||
|
// player no longer appears anywhere in the standings section.
|
||||||
await sendQualifyingPointsUpdateNotification({
|
await sendQualifyingPointsUpdateNotification({
|
||||||
webhookUrl: WEBHOOK_URL,
|
webhookUrl: WEBHOOK_URL,
|
||||||
seasonName: "Slam League 2025",
|
seasonName: "Slam League 2025",
|
||||||
entries: BASE_ENTRIES,
|
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();
|
const desc = getDescription();
|
||||||
expect(desc).toContain("**No Points**");
|
expect(desc).toContain("**Drafted Participants**");
|
||||||
expect(desc).toContain("Rafael Nadal (alex)");
|
// 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 QP Standings for all entries sorted by qpTotal desc", async () => {
|
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("**QP Standings**");
|
expect(desc).toContain("**Drafted Participants**");
|
||||||
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 () => {
|
||||||
await sendQualifyingPointsUpdateNotification({
|
await sendQualifyingPointsUpdateNotification({
|
||||||
webhookUrl: WEBHOOK_URL,
|
webhookUrl: WEBHOOK_URL,
|
||||||
seasonName: "Slam League 2025",
|
seasonName: "Slam League 2025",
|
||||||
entries: [
|
entries: withRanks([
|
||||||
{ participantName: "Player A", qpEarned: 10, qpTotal: 25 },
|
{ participantName: "Player A", qpEarned: 10, qpTotal: 25 },
|
||||||
{ 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();
|
||||||
|
|
@ -772,11 +938,91 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
||||||
expect(desc).toContain("3\\. Player C");
|
expect(desc).toContain("3\\. Player C");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not round fractional QP — a 1.5 QP award shows as 1.5, not 2", async () => {
|
||||||
|
// Regression for the reported bug: a tennis Round-of-16 loser earns 1.5 QP
|
||||||
|
// (positions 9–16 split) but Discord rounded it to 2 via Math.round.
|
||||||
|
await sendQualifyingPointsUpdateNotification({
|
||||||
|
webhookUrl: WEBHOOK_URL,
|
||||||
|
seasonName: "Rumble League 2026",
|
||||||
|
sportName: "Tennis - Men",
|
||||||
|
eventName: "Wimbledon",
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
participantName: "Novak Djokovic",
|
||||||
|
qpEarned: 1.5,
|
||||||
|
qpTotal: 1.5,
|
||||||
|
globalRank: 9,
|
||||||
|
globalRankTied: true,
|
||||||
|
ownerUsername: "snarkymcgee",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const desc = getDescription();
|
||||||
|
// Mirrors the web UI's formatQP: fractional QP renders with up to 2 decimals and
|
||||||
|
// trailing zeros trimmed ("1.5"), never rounded to an integer. The Points Awarded
|
||||||
|
// line no longer carries a "+".
|
||||||
|
expect(desc).toContain("• **Novak Djokovic (snarkymcgee)** — 1.5 QP");
|
||||||
|
expect(desc).not.toContain("+");
|
||||||
|
expect(desc).not.toContain("2 QP");
|
||||||
|
expect(desc).not.toContain("1.50");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists a rank-9 scorer below the Points Bubble but omits a 0-QP participant", async () => {
|
||||||
|
// Rank-9 scorers now appear in the standings section (below the bubble), while a drafted
|
||||||
|
// participant with no points is dropped entirely.
|
||||||
|
const both = [
|
||||||
|
{
|
||||||
|
participantName: "Player Eight",
|
||||||
|
qpEarned: 5,
|
||||||
|
qpTotal: 10,
|
||||||
|
globalRank: 8,
|
||||||
|
globalRankTied: false,
|
||||||
|
ownerUsername: "eighthowner",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
participantName: "Player Nine",
|
||||||
|
qpEarned: 3,
|
||||||
|
qpTotal: 8,
|
||||||
|
globalRank: 9,
|
||||||
|
globalRankTied: false,
|
||||||
|
ownerUsername: "ninthowner",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
participantName: "Player Winless",
|
||||||
|
qpEarned: 0,
|
||||||
|
qpTotal: 0,
|
||||||
|
globalRank: 10,
|
||||||
|
globalRankTied: false,
|
||||||
|
ownerUsername: "winlessowner",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
await sendQualifyingPointsUpdateNotification({
|
||||||
|
webhookUrl: WEBHOOK_URL,
|
||||||
|
seasonName: "Rumble League 2026",
|
||||||
|
sportName: "Tennis - Men",
|
||||||
|
eventName: "Wimbledon",
|
||||||
|
entries: both,
|
||||||
|
scoreboard: both,
|
||||||
|
});
|
||||||
|
|
||||||
|
const desc = getDescription();
|
||||||
|
expect(desc).toContain("**Drafted Participants**");
|
||||||
|
expect(desc).toContain("8\\. Player Eight (eighthowner) — 10 QP");
|
||||||
|
// The rank-9 scorer now appears as a ranked row below the bubble.
|
||||||
|
expect(desc).toContain("**═══ Points Bubble ═══**");
|
||||||
|
expect(desc).toContain("9\\. Player Nine (ninthowner) — 8 QP");
|
||||||
|
// The 0-QP player is omitted from the standings section entirely.
|
||||||
|
expect(desc).not.toContain("Player Winless");
|
||||||
|
// ...but both scorers still earned points, so both remain in Points Awarded.
|
||||||
|
expect(desc).toContain("• **Player Nine (ninthowner)** — 3 QP");
|
||||||
|
});
|
||||||
|
|
||||||
it("uses Discord mention instead of username when ownerDiscordUserId provided", async () => {
|
it("uses Discord mention instead of username when ownerDiscordUserId provided", async () => {
|
||||||
await sendQualifyingPointsUpdateNotification({
|
await sendQualifyingPointsUpdateNotification({
|
||||||
webhookUrl: WEBHOOK_URL,
|
webhookUrl: WEBHOOK_URL,
|
||||||
seasonName: "Slam League 2025",
|
seasonName: "Slam League 2025",
|
||||||
entries: [
|
entries: withRanks([
|
||||||
{
|
{
|
||||||
participantName: "Novak Djokovic",
|
participantName: "Novak Djokovic",
|
||||||
qpEarned: 20,
|
qpEarned: 20,
|
||||||
|
|
@ -784,29 +1030,39 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
||||||
ownerUsername: "alex",
|
ownerUsername: "alex",
|
||||||
ownerDiscordUserId: "111222333",
|
ownerDiscordUserId: "111222333",
|
||||||
},
|
},
|
||||||
],
|
]),
|
||||||
|
scoreboard: withRanks([
|
||||||
|
{
|
||||||
|
participantName: "Novak Djokovic",
|
||||||
|
qpEarned: 20,
|
||||||
|
qpTotal: 45,
|
||||||
|
ownerUsername: "alex",
|
||||||
|
},
|
||||||
|
]),
|
||||||
});
|
});
|
||||||
|
|
||||||
const desc = getDescription();
|
const desc = getDescription();
|
||||||
expect(desc).toContain("Novak Djokovic (<@111222333>)");
|
// Points Awarded (a pinged section) uses the Discord mention...
|
||||||
expect(desc).not.toContain("(alex)");
|
expect(desc).toContain("• **Novak Djokovic (<@111222333>)** — 20 QP");
|
||||||
|
// ...while the (non-pinged) Drafted Participants standings section uses the plain username.
|
||||||
|
expect(desc).toContain("1\\. Novak Djokovic (alex) — 45 QP");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("pings opted-in owners who appear in awarded or no-points sections", async () => {
|
it("pings awarded owners but not non-scoring owners", async () => {
|
||||||
await sendQualifyingPointsUpdateNotification({
|
await sendQualifyingPointsUpdateNotification({
|
||||||
webhookUrl: WEBHOOK_URL,
|
webhookUrl: WEBHOOK_URL,
|
||||||
seasonName: "Slam League 2025",
|
seasonName: "Slam League 2025",
|
||||||
entries: [
|
entries: withRanks([
|
||||||
{ participantName: "Player A", qpEarned: 20, qpTotal: 20, ownerDiscordUserId: "111" },
|
{ participantName: "Player A", qpEarned: 20, qpTotal: 20, ownerDiscordUserId: "111" },
|
||||||
{ participantName: "Player B", qpEarned: 0, qpTotal: 0, ownerDiscordUserId: "222" },
|
{ participantName: "Player B", qpEarned: 0, qpTotal: 0, ownerDiscordUserId: "222" },
|
||||||
],
|
]),
|
||||||
});
|
});
|
||||||
|
|
||||||
const payload = getPayload();
|
const payload = getPayload();
|
||||||
expect(payload.content).toContain("<@111>");
|
expect(payload.content).toContain("<@111>");
|
||||||
expect(payload.content).toContain("<@222>");
|
expect(payload.content).not.toContain("<@222>");
|
||||||
expect(payload.allowed_mentions.users).toContain("111");
|
expect(payload.allowed_mentions.users).toContain("111");
|
||||||
expect(payload.allowed_mentions.users).toContain("222");
|
expect(payload.allowed_mentions.users).not.toContain("222");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not send when entries array is empty", async () => {
|
it("does not send when entries array is empty", async () => {
|
||||||
|
|
@ -832,7 +1088,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 QP Standings", async () => {
|
it("sends with only a Knocked Out section when there are no QP entries, omitting the standings section", async () => {
|
||||||
await sendQualifyingPointsUpdateNotification({
|
await sendQualifyingPointsUpdateNotification({
|
||||||
webhookUrl: WEBHOOK_URL,
|
webhookUrl: WEBHOOK_URL,
|
||||||
seasonName: "Slam League 2025",
|
seasonName: "Slam League 2025",
|
||||||
|
|
@ -843,7 +1099,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("**QP Standings**");
|
expect(desc).not.toContain("**Drafted Participants**");
|
||||||
expect(desc).not.toContain("**Points Awarded**");
|
expect(desc).not.toContain("**Points Awarded**");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -864,9 +1120,9 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
||||||
await sendQualifyingPointsUpdateNotification({
|
await sendQualifyingPointsUpdateNotification({
|
||||||
webhookUrl: WEBHOOK_URL,
|
webhookUrl: WEBHOOK_URL,
|
||||||
seasonName: "Slam League 2025",
|
seasonName: "Slam League 2025",
|
||||||
entries: [
|
entries: withRanks([
|
||||||
{ participantName: "Player_One", qpEarned: 10, qpTotal: 10, ownerUsername: "user_name" },
|
{ participantName: "Player_One", qpEarned: 10, qpTotal: 10, ownerUsername: "user_name" },
|
||||||
],
|
]),
|
||||||
});
|
});
|
||||||
|
|
||||||
const desc = getDescription();
|
const desc = getDescription();
|
||||||
|
|
@ -875,17 +1131,20 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("truncates description at 4096 characters", async () => {
|
it("truncates description at 4096 characters", async () => {
|
||||||
const longEntries = Array.from({ length: 200 }, (_, i) => ({
|
const longEntries = withRanks(
|
||||||
participantName: `Very Long Participant Name Number ${i}`,
|
Array.from({ length: 200 }, (_, i) => ({
|
||||||
qpEarned: i % 2 === 0 ? 5 : 0,
|
participantName: `Very Long Participant Name Number ${i}`,
|
||||||
qpTotal: 200 - i,
|
qpEarned: i % 2 === 0 ? 5 : 0,
|
||||||
ownerUsername: `owner_with_long_username_${i}`,
|
qpTotal: 200 - i,
|
||||||
}));
|
ownerUsername: `owner_with_long_username_${i}`,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
await sendQualifyingPointsUpdateNotification({
|
await 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,6 +27,7 @@ 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";
|
||||||
|
|
@ -68,7 +69,7 @@ function makeDb(overrides: Record<string, unknown> = {}) {
|
||||||
{
|
{
|
||||||
id: SEASON_ID,
|
id: SEASON_ID,
|
||||||
year: 2025,
|
year: 2025,
|
||||||
league: { name: "Slam League", discordWebhookUrl: WEBHOOK_URL },
|
league: { id: LEAGUE_ID, name: "Slam League", discordWebhookUrl: WEBHOOK_URL },
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
|
|
@ -82,7 +83,7 @@ function makeDb(overrides: Record<string, unknown> = {}) {
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
seasonParticipants: {
|
seasonParticipants: {
|
||||||
findMany: vi.fn().mockResolvedValue([{ id: PARTICIPANT_ID, name: "Carlos Alcaraz" }]),
|
findMany: vi.fn().mockResolvedValue([{ id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID }]),
|
||||||
},
|
},
|
||||||
users: {
|
users: {
|
||||||
findMany: vi.fn().mockResolvedValue([
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
|
@ -176,6 +177,7 @@ 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",
|
||||||
|
|
@ -261,8 +263,8 @@ describe("notifyQualifyingPointsUpdate", () => {
|
||||||
},
|
},
|
||||||
seasonParticipants: {
|
seasonParticipants: {
|
||||||
findMany: vi.fn().mockResolvedValue([
|
findMany: vi.fn().mockResolvedValue([
|
||||||
{ id: PARTICIPANT_ID, name: "Carlos Alcaraz" },
|
{ id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID },
|
||||||
{ id: "p-2", name: "Rafael Nadal" },
|
{ id: "p-2", name: "Rafael Nadal", sportsSeasonId: SPORTS_SEASON_ID },
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -279,6 +281,84 @@ 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();
|
||||||
|
|
||||||
|
|
@ -313,8 +393,8 @@ describe("notifyQualifyingPointsUpdate", () => {
|
||||||
},
|
},
|
||||||
seasonParticipants: {
|
seasonParticipants: {
|
||||||
findMany: vi.fn().mockResolvedValue([
|
findMany: vi.fn().mockResolvedValue([
|
||||||
{ id: PARTICIPANT_ID, name: "Carlos Alcaraz" },
|
{ id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID },
|
||||||
{ id: MENSIK_ID, name: "Jakob Mensik" },
|
{ id: MENSIK_ID, name: "Jakob Mensik", sportsSeasonId: SPORTS_SEASON_ID },
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,28 @@
|
||||||
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", () => ({
|
vi.mock("~/models/scoring-calculator", async () => {
|
||||||
processQualifyingEvent: vi.fn(),
|
// Keep the real implementations of the PURE helpers so the tie-count map handed
|
||||||
recalculateAffectedLeagues: vi.fn(),
|
// to processQualifyingEvent reflects the mock canonical results AND the bracket's
|
||||||
}));
|
// structural tie span (deriveBracketQualifyingStates / getRoundConfig). Only the
|
||||||
|
// DB-touching orchestrators are stubbed.
|
||||||
|
const actual = await vi.importActual<typeof ScoringCalculatorModule>(
|
||||||
|
"~/models/scoring-calculator"
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
processQualifyingEvent: vi.fn(),
|
||||||
|
recalculateAffectedLeagues: vi.fn(),
|
||||||
|
buildTieCountByPlacement: actual.buildTieCountByPlacement,
|
||||||
|
deriveBracketQualifyingStates: actual.deriveBracketQualifyingStates,
|
||||||
|
getRoundConfig: actual.getRoundConfig,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock("~/models/scoring-event", () => ({
|
vi.mock("~/models/scoring-event", () => ({
|
||||||
completeScoringEvent: vi.fn(),
|
completeScoringEvent: vi.fn(),
|
||||||
|
|
@ -89,11 +103,21 @@ 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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -141,6 +165,8 @@ 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}`);
|
||||||
}
|
}
|
||||||
|
|
@ -248,6 +274,12 @@ 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]);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -281,6 +313,7 @@ function seedBasicState(overrides: Partial<FakeDbState> = {}): FakeDbState {
|
||||||
scoringEvents: [],
|
scoringEvents: [],
|
||||||
seasonParticipants: [],
|
seasonParticipants: [],
|
||||||
eventResults: [],
|
eventResults: [],
|
||||||
|
playoffMatches: [],
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -360,7 +393,10 @@ describe("syncTournamentResults", () => {
|
||||||
expect(c?.qualifyingPointsAwarded).toBe("0");
|
expect(c?.qualifyingPointsAwarded).toBe("0");
|
||||||
|
|
||||||
expect(processQualifyingEvent).toHaveBeenCalledTimes(1);
|
expect(processQualifyingEvent).toHaveBeenCalledTimes(1);
|
||||||
expect(processQualifyingEvent).toHaveBeenCalledWith("ev-1", db);
|
expect(processQualifyingEvent).toHaveBeenCalledWith("ev-1", db, {
|
||||||
|
skipNotifications: false,
|
||||||
|
canonicalTieCountByPlacement: expect.any(Map),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
@ -412,8 +448,14 @@ describe("syncTournamentResults", () => {
|
||||||
expect(bRows.find((r) => r.seasonParticipantId === "sp-BZ")?.placement).toBe(3);
|
expect(bRows.find((r) => r.seasonParticipantId === "sp-BZ")?.placement).toBe(3);
|
||||||
|
|
||||||
expect(processQualifyingEvent).toHaveBeenCalledTimes(2);
|
expect(processQualifyingEvent).toHaveBeenCalledTimes(2);
|
||||||
expect(processQualifyingEvent).toHaveBeenNthCalledWith(1, "ev-A", db);
|
expect(processQualifyingEvent).toHaveBeenNthCalledWith(1, "ev-A", db, {
|
||||||
expect(processQualifyingEvent).toHaveBeenNthCalledWith(2, "ev-B", db);
|
skipNotifications: false,
|
||||||
|
canonicalTieCountByPlacement: expect.any(Map),
|
||||||
|
});
|
||||||
|
expect(processQualifyingEvent).toHaveBeenNthCalledWith(2, "ev-B", db, {
|
||||||
|
skipNotifications: false,
|
||||||
|
canonicalTieCountByPlacement: expect.any(Map),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
@ -723,7 +765,10 @@ describe("syncTournamentResults", () => {
|
||||||
|
|
||||||
expect(report.windowsSynced).toBe(1);
|
expect(report.windowsSynced).toBe(1);
|
||||||
expect(processQualifyingEvent).toHaveBeenCalledTimes(1);
|
expect(processQualifyingEvent).toHaveBeenCalledTimes(1);
|
||||||
expect(processQualifyingEvent).toHaveBeenCalledWith("ev-SIBLING", db);
|
expect(processQualifyingEvent).toHaveBeenCalledWith("ev-SIBLING", db, {
|
||||||
|
skipNotifications: false,
|
||||||
|
canonicalTieCountByPlacement: expect.any(Map),
|
||||||
|
});
|
||||||
// No event_results written for the skipped primary window.
|
// No event_results written for the skipped primary window.
|
||||||
expect(
|
expect(
|
||||||
state.eventResults.some((r) => r.scoringEventId === "ev-PRIMARY")
|
state.eventResults.some((r) => r.scoringEventId === "ev-PRIMARY")
|
||||||
|
|
@ -814,6 +859,214 @@ 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);
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,17 @@ function escapeMarkdown(text: string): string {
|
||||||
return text.replace(/[_*~`|\\]/g, "\\$&");
|
return text.replace(/[_*~`|\\]/g, "\\$&");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a QP value for display. QP is genuinely fractional (e.g. a tennis R16 loser
|
||||||
|
* earns 1.5 QP from the 9–16 split), so we must NOT round: show whole numbers plainly
|
||||||
|
* and fractional values to at most 2 decimals with trailing zeros trimmed (1.50→1.5).
|
||||||
|
* Mirrors the web UI's formatQP (app/components/scoring/QualifyingPointsStandings.tsx)
|
||||||
|
* so Discord and the site agree.
|
||||||
|
*/
|
||||||
|
function formatQPValue(n: number): string {
|
||||||
|
return parseFloat(n.toFixed(2)).toString();
|
||||||
|
}
|
||||||
|
|
||||||
export interface StandingEntry {
|
export interface StandingEntry {
|
||||||
teamId: string;
|
teamId: string;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
|
@ -165,13 +176,19 @@ 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 || rankChanged;
|
return pointsChanged(s) || rankChanged;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (changedTeams.length > 0) {
|
if (changedTeams.length > 0) {
|
||||||
|
|
@ -196,7 +213,7 @@ export async function sendStandingsUpdateNotification({
|
||||||
}
|
}
|
||||||
|
|
||||||
const escapedName = escapeMarkdown(s.teamName);
|
const escapedName = escapeMarkdown(s.teamName);
|
||||||
const managerLabel = s.discordUserId
|
const managerLabel = s.discordUserId && pointsChanged(s)
|
||||||
? `<@${s.discordUserId}>`
|
? `<@${s.discordUserId}>`
|
||||||
: s.username
|
: s.username
|
||||||
? escapeMarkdown(s.username)
|
? escapeMarkdown(s.username)
|
||||||
|
|
@ -215,7 +232,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) pingUserIds.add(s.discordUserId);
|
if (s.discordUserId && pointsChanged(s)) 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);
|
||||||
|
|
@ -251,6 +268,15 @@ export interface QPEventEntry {
|
||||||
participantName: string;
|
participantName: string;
|
||||||
qpEarned: number;
|
qpEarned: number;
|
||||||
qpTotal: number;
|
qpTotal: number;
|
||||||
|
/**
|
||||||
|
* The participant's rank in the FULL season QP standings (all participants), not
|
||||||
|
* their position among this event's scorers. Computed by the caller so the "QP
|
||||||
|
* Standings" block reflects the whole sport season — e.g. two R16 losers on 1.5 QP
|
||||||
|
* show as T9 (8 players ahead) rather than T1 among just the two of them.
|
||||||
|
*/
|
||||||
|
globalRank: number;
|
||||||
|
/** True when another participant in the full field shares this globalRank. */
|
||||||
|
globalRankTied: boolean;
|
||||||
ownerUsername?: string;
|
ownerUsername?: string;
|
||||||
ownerDiscordUserId?: string;
|
ownerDiscordUserId?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -269,6 +295,8 @@ export async function sendQualifyingPointsUpdateNotification({
|
||||||
eventName,
|
eventName,
|
||||||
entries,
|
entries,
|
||||||
eliminated = [],
|
eliminated = [],
|
||||||
|
scoreboard = [],
|
||||||
|
standingsUrl,
|
||||||
}: {
|
}: {
|
||||||
webhookUrl: string;
|
webhookUrl: string;
|
||||||
seasonName: string;
|
seasonName: string;
|
||||||
|
|
@ -276,6 +304,14 @@ 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;
|
||||||
|
|
||||||
|
|
@ -301,23 +337,7 @@ 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}** — +${Math.round(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}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -338,33 +358,36 @@ export async function sendQualifyingPointsUpdateNotification({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sorted = [...entries].toSorted((a, b) => b.qpTotal - a.qpTotal);
|
// Drafted Participants: every drafted participant that has actually scored (qpTotal > 0),
|
||||||
// Compute ranks with tie detection
|
// drawn from the FULL drafted field (`scoreboard`) not just this sync's movers, so it reads
|
||||||
const ranks: number[] = [];
|
// as a live standings snapshot. Rendered as ranked lines ordered by full-season standing. A
|
||||||
for (let i = 0; i < sorted.length; i++) {
|
// "Points Bubble" divider marks the cutoff between those currently in the points (rank <= 8)
|
||||||
if (i > 0 && sorted[i].qpTotal === sorted[i - 1].qpTotal) {
|
// and those below it (rank >= 9). Participants with 0 QP are omitted entirely. Never pinged,
|
||||||
ranks.push(ranks[ranks.length - 1]);
|
// so managers are shown by plain username, never as a <@id> mention.
|
||||||
} else {
|
const scored = [...scoreboard]
|
||||||
ranks.push(i + 1);
|
.filter((e) => e.qpTotal > 0)
|
||||||
}
|
.toSorted((a, b) => a.globalRank - b.globalRank);
|
||||||
}
|
|
||||||
const isTied = buildTiedRankChecker(ranks);
|
|
||||||
|
|
||||||
// The standings block reflects QP earners; skip it entirely when this sync only
|
// Skip the section entirely when no drafted participant has scored (e.g. a sync that only
|
||||||
// reported knockouts (no QP change) so we don't emit an empty header.
|
// reported knockouts) so we don't emit an empty header.
|
||||||
if (sorted.length > 0) {
|
if (scored.length > 0) {
|
||||||
sections.push("\n**QP Standings**");
|
sections.push("\n**Drafted Participants**");
|
||||||
for (let i = 0; i < sorted.length; i++) {
|
// Insert the divider once, before the first below-the-cutoff (rank >= 9) row. `>= 9`
|
||||||
const e = sorted[i];
|
// (not `> 8`) keeps a tie AT rank 8 above the bubble ("top 8 plus ties"). Only emit it
|
||||||
const r = ranks[i];
|
// after at least one above-the-bubble row exists: globalRank is a season-wide rank while
|
||||||
const rankPrefix = isTied(r) ? `T${r}` : `${r}`;
|
// this list is scoped to one league's drafts, so a league can have drafted nobody in the
|
||||||
const ownerLabel = e.ownerDiscordUserId
|
// global top 8 — guarding on rowsAbove avoids a leading divider with nothing above it.
|
||||||
? `<@${e.ownerDiscordUserId}>`
|
let bubbleInserted = false;
|
||||||
: e.ownerUsername
|
let rowsAbove = 0;
|
||||||
? escapeMarkdown(e.ownerUsername)
|
for (const e of scored) {
|
||||||
: undefined;
|
if (!bubbleInserted && rowsAbove > 0 && e.globalRank >= 9) {
|
||||||
const managerLabel = ownerLabel ? ` (${ownerLabel})` : "";
|
sections.push("**═══ Points Bubble ═══**");
|
||||||
sections.push(`${rankPrefix}\\. ${escapeMarkdown(e.participantName)}${managerLabel} — ${Math.round(e.qpTotal)} QP`);
|
bubbleInserted = true;
|
||||||
|
}
|
||||||
|
if (e.globalRank <= 8) rowsAbove++;
|
||||||
|
const rankPrefix = e.globalRankTied ? `T${e.globalRank}` : `${e.globalRank}`;
|
||||||
|
const managerLabel = e.ownerUsername ? ` (${escapeMarkdown(e.ownerUsername)})` : "";
|
||||||
|
sections.push(`${rankPrefix}\\. ${escapeMarkdown(e.participantName)}${managerLabel} — ${formatQPValue(e.qpTotal)} QP`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -374,8 +397,10 @@ 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, ...zeroEntries, ...eliminated]) {
|
for (const e of [...awardedEntries, ...eliminated]) {
|
||||||
if (e.ownerDiscordUserId) pingUserIds.add(e.ownerDiscordUserId);
|
if (e.ownerDiscordUserId) pingUserIds.add(e.ownerDiscordUserId);
|
||||||
}
|
}
|
||||||
const pingIds = [...pingUserIds];
|
const pingIds = [...pingUserIds];
|
||||||
|
|
@ -384,9 +409,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,9 +611,16 @@ 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 },
|
{ markComplete: false, newlyEliminatedParticipantIds: newlyEliminatedIds },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Announce QP changes for the primary window. Sibling windows are announced by
|
// Announce QP changes for the primary window. Sibling windows are announced by
|
||||||
|
|
@ -622,10 +629,7 @@ 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(
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,29 @@ export async function notifyQualifyingPointsUpdate(
|
||||||
qpTotals.map((t) => [t.participantId, parseFloat(t.totalQualifyingPoints)])
|
qpTotals.map((t) => [t.participantId, parseFloat(t.totalQualifyingPoints)])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Rank every participant across the FULL season field (not just this event's
|
||||||
|
// scorers) so the notification's "QP Standings" block reads as a season-leaderboard
|
||||||
|
// slice and matches the website's globalRank. Standard competition ranking: ties
|
||||||
|
// share the lower rank (…, 9, 9, 11, …). Two R16 losers on 1.5 QP with 8 players
|
||||||
|
// ahead therefore render as T9, not T1 among just the two of them.
|
||||||
|
const rankedField = [...qpTotalById.entries()]
|
||||||
|
.map(([id, total]) => ({ id, total }))
|
||||||
|
.toSorted((a, b) => b.total - a.total);
|
||||||
|
const globalRankById = new Map<string, number>();
|
||||||
|
let prevTotal = Number.NaN;
|
||||||
|
let prevRank = 0;
|
||||||
|
rankedField.forEach((row, index) => {
|
||||||
|
const rank =
|
||||||
|
index > 0 && Math.abs(row.total - prevTotal) < 0.001 ? prevRank : index + 1;
|
||||||
|
globalRankById.set(row.id, rank);
|
||||||
|
prevTotal = row.total;
|
||||||
|
prevRank = rank;
|
||||||
|
});
|
||||||
|
const countByRank = new Map<number, number>();
|
||||||
|
for (const rank of globalRankById.values()) {
|
||||||
|
countByRank.set(rank, (countByRank.get(rank) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
// Batch-fetch all season + league metadata in one query
|
// Batch-fetch all season + league metadata in one query
|
||||||
const seasons = await db.query.seasons.findMany({
|
const seasons = await db.query.seasons.findMany({
|
||||||
where: inArray(schema.seasons.id, seasonIds),
|
where: inArray(schema.seasons.id, seasonIds),
|
||||||
|
|
@ -102,12 +125,22 @@ 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).
|
||||||
const allParticipantIds = [...new Set([...qpEarnedById.keys(), ...eliminatedIds])];
|
// Includes every drafted participant — the Drafted Participants scoreboard section
|
||||||
|
// lists the whole scored field, not just this sync's changed participants.
|
||||||
|
const allParticipantIds = [
|
||||||
|
...new Set([...qpEarnedById.keys(), ...eliminatedIds, ...draftedParticipantIds]),
|
||||||
|
];
|
||||||
const participants = await db.query.seasonParticipants.findMany({
|
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>();
|
||||||
|
|
@ -134,6 +167,12 @@ 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,
|
||||||
|
|
@ -158,10 +197,13 @@ export async function notifyQualifyingPointsUpdate(
|
||||||
|
|
||||||
const entries: QPEventEntry[] = relevantParticipantIds.map((participantId) => {
|
const entries: QPEventEntry[] = relevantParticipantIds.map((participantId) => {
|
||||||
const ownerId = teamByParticipantId.get(participantId)?.ownerId ?? null;
|
const ownerId = teamByParticipantId.get(participantId)?.ownerId ?? null;
|
||||||
|
const globalRank = globalRankById.get(participantId) ?? 0;
|
||||||
return {
|
return {
|
||||||
participantName: participantNameById.get(participantId) ?? participantId,
|
participantName: participantNameById.get(participantId) ?? participantId,
|
||||||
qpEarned: qpEarnedById.get(participantId) ?? 0,
|
qpEarned: qpEarnedById.get(participantId) ?? 0,
|
||||||
qpTotal: qpTotalById.get(participantId) ?? 0,
|
qpTotal: qpTotalById.get(participantId) ?? 0,
|
||||||
|
globalRank,
|
||||||
|
globalRankTied: (countByRank.get(globalRank) ?? 0) > 1,
|
||||||
ownerUsername: ownerId ? (usernameByUserId.get(ownerId) ?? undefined) : undefined,
|
ownerUsername: ownerId ? (usernameByUserId.get(ownerId) ?? undefined) : undefined,
|
||||||
ownerDiscordUserId: ownerId ? discordIdByUserId.get(ownerId) : undefined,
|
ownerDiscordUserId: ownerId ? discordIdByUserId.get(ownerId) : undefined,
|
||||||
};
|
};
|
||||||
|
|
@ -176,6 +218,25 @@ 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,
|
||||||
|
|
@ -183,6 +244,8 @@ export async function notifyQualifyingPointsUpdate(
|
||||||
eventName,
|
eventName,
|
||||||
entries,
|
entries,
|
||||||
eliminated,
|
eliminated,
|
||||||
|
scoreboard,
|
||||||
|
standingsUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, vi, afterEach } 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.
|
||||||
|
|
@ -46,3 +48,143 @@ 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,5 +1,6 @@
|
||||||
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";
|
||||||
|
|
@ -8,6 +9,11 @@ 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;
|
||||||
|
|
@ -137,3 +143,92 @@ 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, IndyCarStandingsAdapter } from "./f1";
|
import { F1StandingsAdapter, OcBlacktopIndyCarStandingsAdapter } 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 IndyCarStandingsAdapter();
|
return new OcBlacktopIndyCarStandingsAdapter();
|
||||||
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}". ` +
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,14 @@ import * as schema from "~/database/schema";
|
||||||
import {
|
import {
|
||||||
processQualifyingEvent,
|
processQualifyingEvent,
|
||||||
recalculateAffectedLeagues,
|
recalculateAffectedLeagues,
|
||||||
|
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 {
|
||||||
|
|
@ -35,6 +39,37 @@ export interface SyncOptions {
|
||||||
* primary is already scored in place, so re-running it would be wasted work.
|
* primary is already scored in place, so re-running it would be wasted work.
|
||||||
*/
|
*/
|
||||||
skipEventId?: string;
|
skipEventId?: string;
|
||||||
|
/**
|
||||||
|
* Suppress ALL Discord notifications for this fan-out (both the per-window QP
|
||||||
|
* update from processQualifyingEvent and the league standings recalc). Used by
|
||||||
|
* one-off backfills that re-score historical events — the QP values change
|
||||||
|
* (e.g. a mis-split 2 → correct 1.5), which would otherwise re-ping every league.
|
||||||
|
*/
|
||||||
|
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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -57,7 +92,13 @@ export async function syncTournamentResults(
|
||||||
options: SyncOptions = {}
|
options: SyncOptions = {}
|
||||||
): Promise<SyncReport> {
|
): Promise<SyncReport> {
|
||||||
const db = database();
|
const db = database();
|
||||||
const { markComplete = true, skipEventId } = options;
|
const {
|
||||||
|
markComplete = true,
|
||||||
|
skipEventId,
|
||||||
|
skipNotifications = false,
|
||||||
|
tieCountByPlacement,
|
||||||
|
newlyEliminatedCanonicalParticipantIds,
|
||||||
|
} = options;
|
||||||
|
|
||||||
const report: SyncReport = {
|
const report: SyncReport = {
|
||||||
tournamentId,
|
tournamentId,
|
||||||
|
|
@ -72,6 +113,17 @@ 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
|
||||||
|
// is the count of canonical rows at each placement (a whole-tournament property,
|
||||||
|
// computed once here). For bracket majors the caller also passes the STRUCTURAL
|
||||||
|
// 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 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
|
||||||
.select()
|
.select()
|
||||||
|
|
@ -175,13 +227,33 @@ export async function syncTournamentResults(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3d. Delegate to scoring engine inside the same transaction.
|
// 3d. Translate the primary's newly-eliminated canonical participants into
|
||||||
await processQualifyingEvent(ev.id, tx);
|
// 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. Mark the window event complete (final-results sync only). This is
|
// 3e. Delegate to scoring engine inside the same transaction.
|
||||||
|
await processQualifyingEvent(ev.id, tx, {
|
||||||
|
skipNotifications,
|
||||||
|
canonicalTieCountByPlacement: effectiveTieCountByPlacement,
|
||||||
|
newlyEliminatedParticipantIds: windowEliminatedSpIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3f. 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.
|
||||||
if (markComplete) {
|
// Skip windows already complete so a re-run (e.g. a backfill) doesn't
|
||||||
|
// needlessly re-stamp completedAt.
|
||||||
|
if (markComplete && !ev.isComplete) {
|
||||||
await completeScoringEvent(ev.id, tx);
|
await completeScoringEvent(ev.id, tx);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -213,8 +285,9 @@ export async function syncTournamentResults(
|
||||||
eventName: w.eventName ?? undefined,
|
eventName: w.eventName ?? undefined,
|
||||||
// Mid-tournament fan-out (markComplete=false) updates standings silently;
|
// Mid-tournament fan-out (markComplete=false) updates standings silently;
|
||||||
// the primary window already announced the round. Only the final sync
|
// the primary window already announced the round. Only the final sync
|
||||||
// (completion) announces to each window's leagues.
|
// (completion) announces to each window's leagues. A backfill
|
||||||
skipDiscord: !markComplete,
|
// (skipNotifications) is always silent.
|
||||||
|
skipDiscord: skipNotifications || !markComplete,
|
||||||
});
|
});
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const msg =
|
const msg =
|
||||||
|
|
@ -250,9 +323,23 @@ export async function syncTournamentResults(
|
||||||
*/
|
*/
|
||||||
export async function syncMajorFromPrimaryEvent(
|
export async function syncMajorFromPrimaryEvent(
|
||||||
primaryEventId: string,
|
primaryEventId: string,
|
||||||
options: { markComplete?: boolean } = {}
|
options: {
|
||||||
|
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 { markComplete = false } = options;
|
const {
|
||||||
|
markComplete = false,
|
||||||
|
skipNotifications = false,
|
||||||
|
newlyEliminatedParticipantIds,
|
||||||
|
} = options;
|
||||||
|
|
||||||
const primaryEvent = await getScoringEventById(primaryEventId);
|
const primaryEvent = await getScoringEventById(primaryEventId);
|
||||||
if (!primaryEvent) {
|
if (!primaryEvent) {
|
||||||
|
|
@ -314,12 +401,91 @@ 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,
|
||||||
|
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
|
||||||
|
|
@ -329,7 +495,15 @@ export async function syncMajorFromPrimaryEvent(
|
||||||
*/
|
*/
|
||||||
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: { markComplete?: boolean } = {}
|
options: {
|
||||||
|
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 {
|
||||||
|
|
|
||||||
136
scripts/backfill-qp-resplit.ts
Normal file
136
scripts/backfill-qp-resplit.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
/**
|
||||||
|
* Backfill: re-score qualifying majors so mis-split QP is corrected.
|
||||||
|
*
|
||||||
|
* Sibling/mirror windows used to compute a placement's tie span from the players
|
||||||
|
* present on THAT window's roster (a subset of the field), so a tied group split
|
||||||
|
* fewer ways and over-awarded — tennis Round-of-16 losers landed at 2 QP instead of
|
||||||
|
* the correct 1.5 (positions 9–16: (2+2+2+2+1+1+1+1)/8). processQualifyingEvent now
|
||||||
|
* derives the tie span from the canonical full field, so re-running the fan-out
|
||||||
|
* rewrites the stored event_results QP, participant totals, and league standings.
|
||||||
|
*
|
||||||
|
* This is idempotent and SILENT: notifications are suppressed so re-scoring history
|
||||||
|
* (2 → 1.5 for many participants) does not re-ping every league's Discord. It does
|
||||||
|
* NOT re-link events or designate primaries — run backfill-major-linking.ts first if
|
||||||
|
* the data predates the shared-major model. majorsCompleted needs no fix (it is now
|
||||||
|
* derived on read from completed qualifying events).
|
||||||
|
*
|
||||||
|
* 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 --dry # report only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
|
import postgres from "postgres";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import * as schema from "../database/schema.js";
|
||||||
|
import { DatabaseContext, database } from "../database/context.js";
|
||||||
|
import { isBracketMajor } from "../app/lib/event-utils.js";
|
||||||
|
import {
|
||||||
|
syncTournamentResults,
|
||||||
|
syncMajorFromPrimaryEvent,
|
||||||
|
} from "../app/services/sync-tournament-results.js";
|
||||||
|
|
||||||
|
const DRY = process.argv.includes("--dry");
|
||||||
|
const log = (...a: unknown[]) => console.log(...a);
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const db = database();
|
||||||
|
|
||||||
|
// Every tournament-linked qualifying event, grouped by canonical tournament.
|
||||||
|
const events = await db.query.scoringEvents.findMany({
|
||||||
|
where: eq(schema.scoringEvents.isQualifyingEvent, true),
|
||||||
|
with: { sportsSeason: { with: { sport: true } } },
|
||||||
|
});
|
||||||
|
const byTournament = new Map<string, typeof events>();
|
||||||
|
for (const ev of events) {
|
||||||
|
if (!ev.tournamentId) continue; // standalone/manual events: nothing to fan out
|
||||||
|
const arr = byTournament.get(ev.tournamentId) ?? [];
|
||||||
|
arr.push(ev);
|
||||||
|
byTournament.set(ev.tournamentId, arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`Tournaments with linked qualifying events: ${byTournament.size}`);
|
||||||
|
let ok = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const [tournamentId, evs] of byTournament) {
|
||||||
|
const simulatorType = evs[0]?.sportsSeason?.sport?.simulatorType ?? null;
|
||||||
|
const bracketMajor = isBracketMajor(simulatorType);
|
||||||
|
const name = evs[0]?.name ?? tournamentId;
|
||||||
|
|
||||||
|
// Only re-score fully-scored majors so we don't mark in-progress ones complete.
|
||||||
|
const anyComplete = evs.some((e) => e.isComplete);
|
||||||
|
const markComplete = evs.every((e) => e.isComplete);
|
||||||
|
|
||||||
|
if (DRY) {
|
||||||
|
log(
|
||||||
|
` (dry) ${name}: ${evs.length} window(s), bracket=${bracketMajor}, ` +
|
||||||
|
`complete=${markComplete} — would re-score silently`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (bracketMajor) {
|
||||||
|
// Re-derive canonical from the primary, then fan out to siblings (which now
|
||||||
|
// split ties by the full field). Primary is scored in place and skipped.
|
||||||
|
const primary = evs.find((e) => e.isPrimary);
|
||||||
|
if (!primary) {
|
||||||
|
log(` ! ${name}: no primary window — skipping (run backfill-major-linking.ts)`);
|
||||||
|
failed += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const report = await syncMajorFromPrimaryEvent(primary.id, {
|
||||||
|
markComplete,
|
||||||
|
skipNotifications: true,
|
||||||
|
});
|
||||||
|
ok += report.windowsSynced;
|
||||||
|
failed += report.windowsFailed;
|
||||||
|
log(
|
||||||
|
` ${name}: re-scored via primary — ${report.windowsSynced} ok, ${report.windowsFailed} failed`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Golf/placement: canonical tournament_results already exist; just fan out.
|
||||||
|
if (!anyComplete) {
|
||||||
|
log(` ${name}: no completed window — skipping`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const report = await syncTournamentResults(tournamentId, {
|
||||||
|
markComplete,
|
||||||
|
skipNotifications: true,
|
||||||
|
});
|
||||||
|
ok += report.windowsSynced;
|
||||||
|
failed += report.windowsFailed;
|
||||||
|
log(
|
||||||
|
` ${name}: re-scored — ${report.windowsSynced} ok, ${report.windowsFailed} failed`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
failed += 1;
|
||||||
|
log(` ! ${name}: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`\nDone${DRY ? " (dry run — no writes)" : ""}. windows ok=${ok}, failed=${failed}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const dbUrl = process.env.DATABASE_URL;
|
||||||
|
if (!dbUrl) {
|
||||||
|
console.error("ERROR: DATABASE_URL is required");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const client = postgres(dbUrl, { max: 1 });
|
||||||
|
const db = drizzle(client, { schema });
|
||||||
|
try {
|
||||||
|
await DatabaseContext.run(db, run);
|
||||||
|
} finally {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue