Compare commits

...
Sign in to create a new pull request.

6 commits

Author SHA1 Message Date
Chris Parsons
b85f387c79 Rank the third place game winner instead of leaving them in contention
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m8s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m18s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (push) Successful in 3m28s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m22s
🚀 Deploy / 🐳 Build (push) Successful in 1m22s
🚀 Deploy / 🚀 Deploy (push) Successful in 12s
The Third Place Game winner kept showing under "In Contention" on the FIFA
World Cup bracket even after the game was scored.

The scoring side was already correct — TEMPLATE_ROUND_CONFIG.fifa_48 finalizes
the 3PG winner at finalPosition 3 with isPartialScore false, so they got their
50 points. The bug was in PlayoffBracket, which re-derives rankings from match
records rather than participant results and only ever ranks losers. The 3PG
winner lost the semifinal but was correctly excluded there by the double-chance
guard, then won the 3PG — so they were never a loser anywhere, never ranked,
and fell through into the active list.

Fixing that also fixes the rank labels, which double-counted positions 3-4:
the 3PG loser was labelled T3 rather than 4, and quarterfinal losers T6 rather
than T5.

Rankings are now built by computeRankedEntries, which resolves a consolation
round at its feeder round: the winner takes the top of the tier the feeder's
losers would otherwise share and the loser the next position, with the
consolation round itself consuming none. Positions are derived rather than
hardcoded to 3/4, so a consolation round hanging off a different feeder lands
correctly too. Only matches that can be placed exactly are withheld from the
loser-driven walk, so an incomplete or partially hydrated consolation match
falls back to the old path instead of being dropped.

loserFeedsInto is set on exactly one round in the templates (fifa_48
Semifinals), so every other bracket keeps its existing behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 19:25:10 -07:00
Chris Parsons
932f36ca07 Show non-eliminated loser's manager tag in Scored Matches notifications
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m7s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m24s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (push) Successful in 3m5s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m22s
🚀 Deploy / 🐳 Build (push) Successful in 1m12s
🚀 Deploy / 🚀 Deploy (push) Successful in 12s
In playoff "Scored Matches" Discord notifications, the winner's manager tag
was shown unconditionally but the loser's was gated behind isLoserNotifiable,
which is only true when the loser scored or was eliminated. A World Cup
semifinal loser (drops to the 3rd-place playoff) or an AFL Qualifying-Final
loser (drops to a Semi Final) is neither, so their tag was dropped:

  • Argentina (philosohraptors) def. England

Decouple the loser's display name from the ping gate, mirroring the winner:
loserUsername is now shown whenever the loser's team is drafted, while the
@-ping (loserDiscordUserId) stays gated by showLoser. A still-alive loser is
named for context but not pinged:

  • Argentina (philosohraptors) def. England (elementsoul)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 21:36:23 -07:00
7e578c714c Merge pull request 'Merge QP scoreboard into a single Drafted Participants list with a Points Bubble divider' (#135) from claude/qp-points-bubble into main
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 2m49s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m19s
🚀 Deploy / 🐳 Build (push) Successful in 1m11s
🚀 Deploy / 🚀 Deploy (push) Successful in 12s
2026-07-10 15:16:50 +00:00
Chris Parsons
687fb6f040 Merge QP scoreboard into a single Drafted Participants list with a Points Bubble divider
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m4s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m19s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Collapse the "Drafted Participants in Top 8" and "Non-scoring Participants"
sections of the qualifying-points Discord announcement into one "Drafted
Participants" standings section. It lists every drafted participant who has
scored (qpTotal > 0) as ranked lines, with a "═══ Points Bubble ═══" divider
marking the points cutoff between rank 8 and rank 9. Participants with 0 QP are
no longer shown.

The divider is only emitted once at least one above-the-cutoff row exists:
globalRank is a season-wide rank while the scoreboard is scoped to one league's
drafts, so a league can have drafted nobody in the global top 8 — guarding on
that avoids a leading divider with nothing above it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 23:22:24 -07:00
Chris Parsons
5d0363a309 Fix qualifying-points notifications: full-field scoreboard + manual-set announcements
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m58s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m19s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (push) Successful in 2m53s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m23s
🚀 Deploy / 🐳 Build (push) Successful in 1m8s
🚀 Deploy / 🚀 Deploy (push) Successful in 12s
The QP Discord embed's two "scoreboard" sections now reflect the whole drafted
field instead of only participants whose QP changed this sync:

- Non-scoring / Top 8 draw from a new per-league `scoreboard` (all drafted
  participants), scoped to the sports season being announced so a golf pick can't
  leak into a tennis event. Points Awarded / Knocked Out stay scoped to the sync's
  changes and remain the only pinged sections.
- Non-scoring is now a single compact "Name (points, manager)" line below Top 8,
  covering everyone not in the top 8 (the exact complement of the Top 8 filter).
- Top 8 requires qpTotal > 0 as well as rank <= 8, so early-season winless players
  tied into a low rank band no longer flood the section with "T5. Name — 0 QP".

Manually setting a bracket match result (e.g. a Wimbledon semifinal) now announces
the QP update. The set-winner / set-round-winners / complete-round paths route
through processQualifyingEvent (which snapshots, diffs, and notifies) instead of
processQualifyingBracketEvent (which scored silently). The tournament fan-out still
skips the primary window via skipEventId, so mirror windows aren't double-posted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 10:04:22 -07:00
b05bad6554 Merge pull request 'fix qualifying notification' (#133) from claude/qualifying-points-format-cs9qrv into main
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m10s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m20s
🚀 Deploy / 🐳 Build (push) Successful in 1m9s
🚀 Deploy / 🚀 Deploy (push) Successful in 11s
2026-07-09 15:08:39 +00:00
8 changed files with 873 additions and 111 deletions

View file

@ -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);

View file

@ -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();
});
});
});

View file

@ -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,
})) }))

View file

@ -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.

View file

@ -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();

View file

@ -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 },
]), ]),
}, },
}); });

View file

@ -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`);
} }

View file

@ -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,
}); });
} }