Compare commits
6 commits
claude/qua
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b85f387c79 | ||
|
|
932f36ca07 | ||
| 7e578c714c | |||
|
|
687fb6f040 | ||
|
|
5d0363a309 | ||
| b05bad6554 |
8 changed files with 873 additions and 111 deletions
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1940,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(
|
||||||
|
|
@ -1974,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,
|
||||||
}))
|
}))
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,13 @@ async function scoreQualifyingBracket(
|
||||||
*/
|
*/
|
||||||
newlyEliminatedParticipantIds?: Set<string>
|
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,
|
||||||
|
|
@ -613,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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -790,33 +821,99 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
||||||
expect(desc.indexOf("Novak Djokovic")).toBeLessThan(desc.indexOf("Carlos Alcaraz"));
|
expect(desc.indexOf("Novak Djokovic")).toBeLessThan(desc.indexOf("Carlos Alcaraz"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows Non-scoring Participants section with season total and plain manager name", 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("**Non-scoring Participants**");
|
expect(desc).toContain("**Drafted Participants**");
|
||||||
// Name (seasonTotal, manager) — Rafael has a 20 QP running total, owner "alex"
|
// Both scorers appear as ranked rows...
|
||||||
expect(desc).toContain("• Rafael Nadal (20, alex)");
|
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 Drafted Participants in Top 8 for 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("**Drafted Participants in Top 8**");
|
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 () => {
|
||||||
|
|
@ -828,6 +925,11 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
||||||
{ participantName: "Player B", qpEarned: 5, qpTotal: 25 },
|
{ participantName: "Player B", qpEarned: 5, qpTotal: 25 },
|
||||||
{ participantName: "Player C", qpEarned: 2, qpTotal: 10 },
|
{ participantName: "Player C", qpEarned: 2, qpTotal: 10 },
|
||||||
]),
|
]),
|
||||||
|
scoreboard: withRanks([
|
||||||
|
{ participantName: "Player A", qpEarned: 10, qpTotal: 25 },
|
||||||
|
{ participantName: "Player B", qpEarned: 5, qpTotal: 25 },
|
||||||
|
{ participantName: "Player C", qpEarned: 2, qpTotal: 10 },
|
||||||
|
]),
|
||||||
});
|
});
|
||||||
|
|
||||||
const desc = getDescription();
|
const desc = getDescription();
|
||||||
|
|
@ -866,40 +968,53 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
||||||
expect(desc).not.toContain("1.50");
|
expect(desc).not.toContain("1.50");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("excludes participants ranked outside the top 8 from the Drafted Participants section", async () => {
|
it("lists a rank-9 scorer below the Points Bubble but omits a 0-QP participant", async () => {
|
||||||
// Only the top 8 (plus ties) of the full season field belong in this section, so a
|
// Rank-9 scorers now appear in the standings section (below the bubble), while a drafted
|
||||||
// rank-9 scorer must NOT appear in it — even though it still shows in Points Awarded.
|
// 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({
|
await sendQualifyingPointsUpdateNotification({
|
||||||
webhookUrl: WEBHOOK_URL,
|
webhookUrl: WEBHOOK_URL,
|
||||||
seasonName: "Rumble League 2026",
|
seasonName: "Rumble League 2026",
|
||||||
sportName: "Tennis - Men",
|
sportName: "Tennis - Men",
|
||||||
eventName: "Wimbledon",
|
eventName: "Wimbledon",
|
||||||
entries: [
|
entries: both,
|
||||||
{
|
scoreboard: 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",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const desc = getDescription();
|
const desc = getDescription();
|
||||||
expect(desc).toContain("**Drafted Participants in Top 8**");
|
expect(desc).toContain("**Drafted Participants**");
|
||||||
expect(desc).toContain("8\\. Player Eight (eighthowner) — 10 QP");
|
expect(desc).toContain("8\\. Player Eight (eighthowner) — 10 QP");
|
||||||
// The rank-9 player is filtered out of the standings section entirely.
|
// The rank-9 scorer now appears as a ranked row below the bubble.
|
||||||
expect(desc).not.toContain("9\\. Player Nine");
|
expect(desc).toContain("**═══ Points Bubble ═══**");
|
||||||
// ...but both still earned points, so both remain in Points Awarded.
|
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");
|
expect(desc).toContain("• **Player Nine (ninthowner)** — 3 QP");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -916,12 +1031,20 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
||||||
ownerDiscordUserId: "111222333",
|
ownerDiscordUserId: "111222333",
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
|
scoreboard: withRanks([
|
||||||
|
{
|
||||||
|
participantName: "Novak Djokovic",
|
||||||
|
qpEarned: 20,
|
||||||
|
qpTotal: 45,
|
||||||
|
ownerUsername: "alex",
|
||||||
|
},
|
||||||
|
]),
|
||||||
});
|
});
|
||||||
|
|
||||||
const desc = getDescription();
|
const desc = getDescription();
|
||||||
// Points Awarded (a pinged section) uses the Discord mention...
|
// Points Awarded (a pinged section) uses the Discord mention...
|
||||||
expect(desc).toContain("• **Novak Djokovic (<@111222333>)** — 20 QP");
|
expect(desc).toContain("• **Novak Djokovic (<@111222333>)** — 20 QP");
|
||||||
// ...while the (non-pinged) Top 8 standings section uses the plain username.
|
// ...while the (non-pinged) Drafted Participants standings section uses the plain username.
|
||||||
expect(desc).toContain("1\\. Novak Djokovic (alex) — 45 QP");
|
expect(desc).toContain("1\\. Novak Djokovic (alex) — 45 QP");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -976,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("**Drafted Participants in Top 8**");
|
expect(desc).not.toContain("**Drafted Participants**");
|
||||||
expect(desc).not.toContain("**Points Awarded**");
|
expect(desc).not.toContain("**Points Awarded**");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1021,6 +1144,7 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
||||||
webhookUrl: WEBHOOK_URL,
|
webhookUrl: WEBHOOK_URL,
|
||||||
seasonName: "Slam League 2025",
|
seasonName: "Slam League 2025",
|
||||||
entries: longEntries,
|
entries: longEntries,
|
||||||
|
scoreboard: longEntries,
|
||||||
});
|
});
|
||||||
|
|
||||||
const desc = getDescription();
|
const desc = getDescription();
|
||||||
|
|
|
||||||
|
|
@ -83,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([
|
||||||
|
|
@ -263,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 },
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -281,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();
|
||||||
|
|
||||||
|
|
@ -315,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 },
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -295,6 +295,7 @@ export async function sendQualifyingPointsUpdateNotification({
|
||||||
eventName,
|
eventName,
|
||||||
entries,
|
entries,
|
||||||
eliminated = [],
|
eliminated = [],
|
||||||
|
scoreboard = [],
|
||||||
standingsUrl,
|
standingsUrl,
|
||||||
}: {
|
}: {
|
||||||
webhookUrl: string;
|
webhookUrl: string;
|
||||||
|
|
@ -303,6 +304,13 @@ 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;
|
standingsUrl?: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
if (entries.length === 0 && eliminated.length === 0) return;
|
if (entries.length === 0 && eliminated.length === 0) return;
|
||||||
|
|
@ -333,20 +341,6 @@ export async function sendQualifyingPointsUpdateNotification({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const zeroEntries = entries.filter((e) => e.qpEarned === 0);
|
|
||||||
if (zeroEntries.length > 0) {
|
|
||||||
sections.push("\n**Non-scoring Participants**");
|
|
||||||
for (const e of zeroEntries) {
|
|
||||||
// Plain manager username here (never a <@id> mention): this section is not
|
|
||||||
// pinged, so mention chips would misleadingly look like a ping. Show the
|
|
||||||
// running season QP total alongside the manager, e.g. "Name (1.5, manager)".
|
|
||||||
const details = e.ownerUsername
|
|
||||||
? `${formatQPValue(e.qpTotal)}, ${escapeMarkdown(e.ownerUsername)}`
|
|
||||||
: `${formatQPValue(e.qpTotal)}`;
|
|
||||||
sections.push(`• ${escapeMarkdown(e.participantName)} (${details})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Knocked-out section — drafted players eliminated this sync in a non-scoring
|
// Knocked-out section — drafted players eliminated this sync in a non-scoring
|
||||||
// round. They earn no QP, so they'd otherwise never be surfaced to their manager.
|
// round. They earn no QP, so they'd otherwise never be surfaced to their manager.
|
||||||
if (eliminated.length > 0) {
|
if (eliminated.length > 0) {
|
||||||
|
|
@ -364,21 +358,34 @@ export async function sendQualifyingPointsUpdateNotification({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only drafted participants sitting in the top 8 of the FULL season standings
|
// Drafted Participants: every drafted participant that has actually scored (qpTotal > 0),
|
||||||
// (globalRank supplied by the caller), ordered by rank. Standard competition
|
// drawn from the FULL drafted field (`scoreboard`) not just this sync's movers, so it reads
|
||||||
// ranking collapses ties to a shared rank, so `globalRank <= 8` naturally keeps
|
// as a live standings snapshot. Rendered as ranked lines ordered by full-season standing. A
|
||||||
// any tie group sitting at rank 8 ("top 8 plus ties").
|
// "Points Bubble" divider marks the cutoff between those currently in the points (rank <= 8)
|
||||||
const sorted = [...entries]
|
// and those below it (rank >= 9). Participants with 0 QP are omitted entirely. Never pinged,
|
||||||
.filter((e) => e.globalRank <= 8)
|
// so managers are shown by plain username, never as a <@id> mention.
|
||||||
|
const scored = [...scoreboard]
|
||||||
|
.filter((e) => e.qpTotal > 0)
|
||||||
.toSorted((a, b) => a.globalRank - b.globalRank);
|
.toSorted((a, b) => a.globalRank - b.globalRank);
|
||||||
|
|
||||||
// Skip the section entirely when no drafted participant is in the top 8 (e.g. a
|
// Skip the section entirely when no drafted participant has scored (e.g. a sync that only
|
||||||
// sync that only reported knockouts) 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**Drafted Participants in Top 8**");
|
sections.push("\n**Drafted Participants**");
|
||||||
for (const e of sorted) {
|
// Insert the divider once, before the first below-the-cutoff (rank >= 9) row. `>= 9`
|
||||||
|
// (not `> 8`) keeps a tie AT rank 8 above the bubble ("top 8 plus ties"). Only emit it
|
||||||
|
// after at least one above-the-bubble row exists: globalRank is a season-wide rank while
|
||||||
|
// this list is scoped to one league's drafts, so a league can have drafted nobody in the
|
||||||
|
// global top 8 — guarding on rowsAbove avoids a leading divider with nothing above it.
|
||||||
|
let bubbleInserted = false;
|
||||||
|
let rowsAbove = 0;
|
||||||
|
for (const e of scored) {
|
||||||
|
if (!bubbleInserted && rowsAbove > 0 && e.globalRank >= 9) {
|
||||||
|
sections.push("**═══ Points Bubble ═══**");
|
||||||
|
bubbleInserted = true;
|
||||||
|
}
|
||||||
|
if (e.globalRank <= 8) rowsAbove++;
|
||||||
const rankPrefix = e.globalRankTied ? `T${e.globalRank}` : `${e.globalRank}`;
|
const rankPrefix = e.globalRankTied ? `T${e.globalRank}` : `${e.globalRank}`;
|
||||||
// Plain manager username (no <@id> mention): this section is not pinged.
|
|
||||||
const managerLabel = e.ownerUsername ? ` (${escapeMarkdown(e.ownerUsername)})` : "";
|
const managerLabel = e.ownerUsername ? ` (${escapeMarkdown(e.ownerUsername)})` : "";
|
||||||
sections.push(`${rankPrefix}\\. ${escapeMarkdown(e.participantName)}${managerLabel} — ${formatQPValue(e.qpTotal)} QP`);
|
sections.push(`${rankPrefix}\\. ${escapeMarkdown(e.participantName)}${managerLabel} — ${formatQPValue(e.qpTotal)} QP`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -125,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>();
|
||||||
|
|
@ -208,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,
|
||||||
|
|
@ -215,6 +244,7 @@ export async function notifyQualifyingPointsUpdate(
|
||||||
eventName,
|
eventName,
|
||||||
entries,
|
entries,
|
||||||
eliminated,
|
eliminated,
|
||||||
|
scoreboard,
|
||||||
standingsUrl,
|
standingsUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue