Compare commits

..

No commits in common. "main" and "claude/qualifying-points-format-cs9qrv" have entirely different histories.

8 changed files with 119 additions and 881 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, type BracketTemplate } from "~/lib/bracket-templates"; import { getBracketTemplate } from "~/lib/bracket-templates";
import { NbaBracketLayout } from "./NbaBracketLayout"; import { NbaBracketLayout } from "./NbaBracketLayout";
import { TabbedBracketLayout } from "./TabbedBracketLayout"; import { TabbedBracketLayout } from "./TabbedBracketLayout";
@ -174,138 +174,6 @@ export function computeEliminatedByRound(
return result; return result;
} }
/** The score recorded for one participant in a match, or null if they didn't play in it. */
function participantScore(match: Match, participantId: string | null): string | null {
if (!participantId) return null;
if (participantId === match.participant1Id) return match.participant1Score;
if (participantId === match.participant2Id) return match.participant2Score;
return null;
}
/**
* A consolation final: a round contested by the losers of an earlier round, which
* splits the positions those losers would otherwise share. FIFA's "Third Place Game"
* (fed by the Semifinals) is the only one in the templates today.
*/
export interface ConsolationRound {
/** The consolation round itself, e.g. "Third Place Game". */
round: string;
/** The round whose losers contest it, e.g. "Semifinals". */
feederRound: string;
}
/**
* Find the template's consolation round, if it has one.
* Exported for unit testing.
*/
export function findConsolationRound(
template: BracketTemplate | undefined
): ConsolationRound | undefined {
const feeder = template?.rounds.find((r) => r.loserFeedsInto);
if (!feeder?.loserFeedsInto) return undefined;
return { round: feeder.loserFeedsInto, feederRound: feeder.name };
}
/**
* Build the ordered final-rankings list from completed matches.
*
* Ranks are derived by walking rounds latest-first: the final's loser is 2nd, the
* previous round's losers share the next tier, and so on each round consuming as
* many positions as it has matches.
*
* A consolation round needs different handling, because its winner never loses a
* match and so the loser-driven walk above would leave them unranked and "in
* contention" forever. Its two places are exactly the top of the tier its feeder
* round's losers would otherwise share, so it is resolved *at the feeder round*
* the winner takes that tier's first position and the loser the second and the
* consolation round itself consumes no positions. Positions are derived rather than
* hardcoded, so a consolation round hanging off a different feeder still lands right.
*
* Exported for unit testing.
*/
export function computeRankedEntries(
matches: Match[],
rounds: string[],
matchesByRound: Map<string, Match[]>,
consolation: ConsolationRound | undefined,
ownershipMap: Map<string, TeamOwnership>
): EliminatedEntry[] {
const eliminatedByRound = computeEliminatedByRound(matches, rounds);
// Only take the consolation path when both rounds actually have matches; otherwise
// fall through to the loser-driven walk so nothing is dropped.
const consolationActive =
consolation &&
rounds.includes(consolation.round) &&
rounds.includes(consolation.feederRound);
// Consolation matches we can place exactly. Anything else in that round (still in
// progress, or missing its hydrated winner/loser) deliberately stays eligible for
// the loser-driven walk rather than being silently dropped.
const consolationMatches =
consolationActive && consolation
? (matchesByRound.get(consolation.round) ?? []).filter(
(m): m is Match & { winner: Participant; loser: Participant } =>
m.isComplete && !!m.winner && !!m.loser
)
: [];
const exactlyPlacedMatchIds = new Set(consolationMatches.map((m) => m.id));
const entryFor = (
match: Match,
participant: Participant,
participantId: string | null
): Omit<EliminatedEntry, "rankLabel"> => ({
participant,
score: participantScore(match, participantId),
ownership: ownershipMap.get(participant.id) || null,
});
const losersByRound = new Map<string, Omit<EliminatedEntry, "rankLabel">[]>();
for (const match of matches) {
if (!match.isComplete || !match.loser) continue;
if (exactlyPlacedMatchIds.has(match.id)) continue;
if (!eliminatedByRound.get(match.round)?.includes(match.loser.id)) continue;
if (!losersByRound.has(match.round)) losersByRound.set(match.round, []);
losersByRound.get(match.round)?.push(entryFor(match, match.loser, match.loserId));
}
const rankedEntries: EliminatedEntry[] = [];
let nextRank = 2;
for (let ri = rounds.length - 1; ri >= 0; ri--) {
const roundName = rounds[ri];
// The consolation match splits the top of its feeder round's tier, so it is
// placed first and the round's remaining losers share what's left below it.
let tierRank = nextRank;
if (consolationActive && roundName === consolation?.feederRound) {
for (const match of consolationMatches) {
rankedEntries.push({
...entryFor(match, match.winner, match.winnerId),
rankLabel: `${tierRank}`,
});
rankedEntries.push({
...entryFor(match, match.loser, match.loserId),
rankLabel: `${tierRank + 1}`,
});
tierRank += 2;
}
}
for (const loser of losersByRound.get(roundName) ?? []) {
rankedEntries.push({ ...loser, rankLabel: `T${tierRank}` });
}
// The consolation round's places belong to its feeder round's tier, so it
// consumes none of its own.
if (consolationActive && roundName === consolation?.round) continue;
nextRank += matchesByRound.get(roundName)?.length ?? 0;
}
return rankedEntries;
}
/** Find the index of the first round that has scoring matches. */ /** Find the index of the first round that has scoring matches. */
function firstScoringRoundIdx(matchesByRound: Map<string, Match[]>, rounds: string[]): number { function firstScoringRoundIdx(matchesByRound: Map<string, Match[]>, rounds: string[]): number {
for (let i = 0; i < rounds.length; i++) { for (let i = 0; i < rounds.length; i++) {
@ -348,10 +216,12 @@ export function PlayoffBracket({
const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds); const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds);
const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined; const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined;
const consolation = findConsolationRound(template); const thirdPlaceRound = template?.rounds
const thirdPlaceRound = consolation?.round; .find((r) => template.rounds.some((other) => other.loserFeedsInto === r.name))
?.name;
// Build elimination rankings // Build elimination rankings
const losersByRound = new Map<string, Array<{ participant: Participant; score: string | null; ownership: TeamOwnership | null }>>();
let bracketWinner: Participant | null = null; let bracketWinner: Participant | null = null;
const lastRound = rounds[rounds.length - 1]; const lastRound = rounds[rounds.length - 1];
@ -360,19 +230,43 @@ export function PlayoffBracket({
: null; : null;
if (finalMatch?.winner) bracketWinner = finalMatch.winner; if (finalMatch?.winner) bracketWinner = finalMatch.winner;
const eliminatedByRound = computeEliminatedByRound(matches, rounds);
for (const match of matches) {
if (!match.isComplete || !match.loser) continue;
const eliminatedInRound = eliminatedByRound.get(match.round);
if (!eliminatedInRound?.includes(match.loser.id)) continue;
const loserScore =
match.loserId === match.participant1Id
? match.participant1Score
: match.participant2Score;
if (!losersByRound.has(match.round)) losersByRound.set(match.round, []);
losersByRound.get(match.round)?.push({
participant: match.loser,
score: loserScore,
ownership: ownershipMap.get(match.loser.id) || null,
});
}
const allBracketParticipantIds = new Set<string>(); const allBracketParticipantIds = new Set<string>();
for (const match of matches) { for (const match of matches) {
if (match.participant1Id) allBracketParticipantIds.add(match.participant1Id); if (match.participant1Id) allBracketParticipantIds.add(match.participant1Id);
if (match.participant2Id) allBracketParticipantIds.add(match.participant2Id); if (match.participant2Id) allBracketParticipantIds.add(match.participant2Id);
} }
const rankedEntries: EliminatedEntry[] = [];
const rankedEntries = computeRankedEntries( let nextRank = 2;
matches, for (let ri = rounds.length - 1; ri >= 0; ri--) {
rounds, const roundName = rounds[ri];
matchesByRound, const roundLosers = losersByRound.get(roundName) || [];
consolation, const totalMatchesInRound = matchesByRound.get(roundName)?.length ?? 0;
ownershipMap if (roundLosers.length > 0) {
); const rankLabel = `T${nextRank}`;
for (const loser of roundLosers) {
rankedEntries.push({ ...loser, rankLabel });
}
}
nextRank += totalMatchesInRound;
}
const rankedParticipantIds = new Set(rankedEntries.map((e) => e.participant.id)); const rankedParticipantIds = new Set(rankedEntries.map((e) => e.participant.id));
if (bracketWinner) rankedParticipantIds.add(bracketWinner.id); if (bracketWinner) rankedParticipantIds.add(bracketWinner.id);

View file

@ -1,15 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { render, screen, within } from "@testing-library/react"; import { buildFeederMap, groupMatchesByRound, computeEliminatedByRound } from "../PlayoffBracket";
import {
PlayoffBracket,
buildFeederMap,
groupMatchesByRound,
computeEliminatedByRound,
computeRankedEntries,
findConsolationRound,
type Match,
} from "../PlayoffBracket";
import { getBracketTemplate } from "~/lib/bracket-templates";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
@ -301,392 +291,3 @@ describe("computeEliminatedByRound", () => {
}); });
}); });
}); });
// ---------------------------------------------------------------------------
// computeRankedEntries — consolation ("third place") round handling
// ---------------------------------------------------------------------------
// fifa_48 round order. The Third Place Game sits between the Semifinals (whose
// losers feed it) and the Finals.
const FIFA_ROUNDS = ["Quarterfinals", "Semifinals", "Third Place Game", "Finals"];
const FIFA_CONSOLATION = {
round: "Third Place Game",
feederRound: "Semifinals",
};
type MatchOpts = {
/** Which slot the winner occupies. Defaults to 1. */
winnerSlot?: 1 | 2;
winnerScore?: string;
loserScore?: string;
/**
* Drop the hydrated `winner` relation, keeping `loser` and both ids the shape a
* hand-built match object can arrive in. The loser is still placeable this way.
*/
missingWinnerRelation?: boolean;
};
/** A completed match. */
function makeRankedMatch(
round: string,
matchNumber: number,
winnerId: string,
loserId: string,
opts: MatchOpts = {}
): Match {
const winnerIsP1 = (opts.winnerSlot ?? 1) === 1;
const p1 = winnerIsP1 ? winnerId : loserId;
const p2 = winnerIsP1 ? loserId : winnerId;
return {
id: `${round}-${matchNumber}`,
round,
matchNumber,
participant1Id: p1,
participant2Id: p2,
winnerId,
loserId,
isComplete: true,
participant1Score: (winnerIsP1 ? opts.winnerScore : opts.loserScore) ?? null,
participant2Score: (winnerIsP1 ? opts.loserScore : opts.winnerScore) ?? null,
participant1: { id: p1, name: p1 },
participant2: { id: p2, name: p2 },
winner: opts.missingWinnerRelation ? null : { id: winnerId, name: winnerId },
loser: { id: loserId, name: loserId },
};
}
/**
* A scheduled-but-unplayed match. Bracket rows are pre-generated with their slots
* filled as earlier rounds resolve, so an unplayed 3PG still lists both SF losers.
*/
function makePendingMatch(
round: string,
matchNumber: number,
participant1Id: string | null,
participant2Id: string | null
): Match {
return {
id: `${round}-${matchNumber}`,
round,
matchNumber,
participant1Id,
participant2Id,
winnerId: null,
loserId: null,
isComplete: false,
participant1Score: null,
participant2Score: null,
participant1: participant1Id ? { id: participant1Id, name: participant1Id } : null,
participant2: participant2Id ? { id: participant2Id, name: participant2Id } : null,
winner: null,
loser: null,
};
}
/** A full fifa_48-shaped knockout tail: 4 QF, 2 SF, the 3PG, and the Final. */
function fifaMatches(
overrides: { played?: boolean; thirdPlace?: Match } = {}
): Match[] {
const played = overrides.played ?? true;
return [
makeRankedMatch("Quarterfinals", 1, "sfA", "qf1"),
makeRankedMatch("Quarterfinals", 2, "sfB", "qf2"),
makeRankedMatch("Quarterfinals", 3, "sfC", "qf3"),
makeRankedMatch("Quarterfinals", 4, "sfD", "qf4"),
makeRankedMatch("Semifinals", 1, "sfA", "sfB"),
makeRankedMatch("Semifinals", 2, "sfC", "sfD"),
overrides.thirdPlace ??
(played
? makeRankedMatch("Third Place Game", 1, "sfB", "sfD")
: makePendingMatch("Third Place Game", 1, "sfB", "sfD")),
played
? makeRankedMatch("Finals", 1, "sfA", "sfC")
: makePendingMatch("Finals", 1, "sfA", "sfC"),
];
}
/** Rank the fifa fixture, defaulting to the fifa_48 consolation config. */
function rankFifa(
matches: Match[] = fifaMatches(),
ownership: Map<string, { participantId: string; teamName: string; teamId: string }> = new Map(),
consolation: typeof FIFA_CONSOLATION | undefined = FIFA_CONSOLATION
) {
return computeRankedEntries(
matches,
FIFA_ROUNDS,
groupMatchesByRound(matches),
consolation,
ownership
);
}
function rankOf(entries: ReturnType<typeof computeRankedEntries>, id: string) {
return entries.find((e) => e.participant.id === id)?.rankLabel;
}
describe("findConsolationRound", () => {
it("identifies the fifa_48 third place game and the round that feeds it", () => {
expect(findConsolationRound(getBracketTemplate("fifa_48"))).toEqual({
round: "Third Place Game",
feederRound: "Semifinals",
});
});
it("returns undefined for a template with no consolation round", () => {
expect(findConsolationRound(getBracketTemplate("ncaa_64"))).toBeUndefined();
});
it("returns undefined when there is no template", () => {
expect(findConsolationRound(undefined)).toBeUndefined();
});
});
describe("computeRankedEntries", () => {
describe("fifa_48 third place game", () => {
it("ranks the third place game winner 3rd — they never lose a match after the SF", () => {
// sfB lost the semifinal, then won the 3PG.
expect(rankOf(rankFifa(), "sfB")).toBe("3");
});
it("ranks the third place game loser 4th, not 3rd", () => {
expect(rankOf(rankFifa(), "sfD")).toBe("4");
});
it("gives quarterfinal losers T5 — the 3PG consumes no positions of its own", () => {
const entries = rankFifa();
expect(rankOf(entries, "qf1")).toBe("T5");
expect(rankOf(entries, "qf2")).toBe("T5");
expect(rankOf(entries, "qf3")).toBe("T5");
expect(rankOf(entries, "qf4")).toBe("T5");
});
it("ranks the finals loser 2nd and leaves the champion out of the list", () => {
const entries = rankFifa();
expect(rankOf(entries, "sfC")).toBe("T2");
expect(rankOf(entries, "sfA")).toBeUndefined();
});
it("orders the list by rank: 2nd, 3rd, 4th, then the T5 tier", () => {
expect(rankFifa().map((e) => e.rankLabel)).toEqual([
"T2",
"3",
"4",
"T5",
"T5",
"T5",
"T5",
]);
});
it("leaves semifinal losers unranked until the third place game is played", () => {
const entries = rankFifa(fifaMatches({ played: false }));
// Both SF losers are still alive for the 3PG.
expect(rankOf(entries, "sfB")).toBeUndefined();
expect(rankOf(entries, "sfD")).toBeUndefined();
// QF losers are still T5 — the later rounds still consume their positions.
expect(rankOf(entries, "qf1")).toBe("T5");
});
it("carries ownership through onto the third place entries", () => {
const ownership = new Map([
["sfB", { participantId: "sfB", teamName: "Team Nine", teamId: "t9" }],
]);
const entries = rankFifa(fifaMatches(), ownership);
expect(entries.find((e) => e.participant.id === "sfB")?.ownership?.teamName).toBe(
"Team Nine"
);
expect(entries.find((e) => e.participant.id === "sfD")?.ownership).toBeNull();
});
});
describe("scores", () => {
it("reads each participant's own score regardless of which slot they occupied", () => {
const matches = fifaMatches({
// Winner sits in slot 2 this time, so a slot-blind lookup would swap the scores.
thirdPlace: makeRankedMatch("Third Place Game", 1, "sfB", "sfD", {
winnerSlot: 2,
winnerScore: "3",
loserScore: "1",
}),
});
const entries = rankFifa(matches);
expect(entries.find((e) => e.participant.id === "sfB")?.score).toBe("3");
expect(entries.find((e) => e.participant.id === "sfD")?.score).toBe("1");
});
it("reads a loser's score from the slot they actually played in", () => {
const matches: Match[] = [
makeRankedMatch("Semifinals", 1, "sfA", "sfB", {
winnerSlot: 2,
winnerScore: "4",
loserScore: "2",
}),
];
const entries = computeRankedEntries(
matches,
["Semifinals"],
groupMatchesByRound(matches),
undefined,
new Map()
);
expect(entries.find((e) => e.participant.id === "sfB")?.score).toBe("2");
});
it("reports no score for a participant who occupies neither slot", () => {
// A stale row after a bracket edit: loserId no longer matches either slot.
// Attributing the other team's score here would look entirely plausible.
const stale: Match = {
...makeRankedMatch("Semifinals", 1, "sfA", "sfB", {
winnerScore: "4",
loserScore: "2",
}),
loserId: "ghost",
loser: { id: "ghost", name: "ghost" },
};
const entries = computeRankedEntries(
[stale],
["Semifinals"],
groupMatchesByRound([stale]),
undefined,
new Map()
);
expect(entries.find((e) => e.participant.id === "ghost")?.score).toBeNull();
});
});
describe("a consolation round somewhere other than 3rd/4th", () => {
// No template ships this today, but the positions must come from the feeder
// round rather than being hardcoded to 3 and 4.
const ROUNDS = ["Quarterfinals", "Semifinals", "Fifth Place Game", "Finals"];
const CONSOLATION = { round: "Fifth Place Game", feederRound: "Quarterfinals" };
const matches: Match[] = [
makeRankedMatch("Quarterfinals", 1, "sfA", "qf1"),
makeRankedMatch("Quarterfinals", 2, "sfB", "qf2"),
makeRankedMatch("Quarterfinals", 3, "sfC", "qf3"),
makeRankedMatch("Quarterfinals", 4, "sfD", "qf4"),
makeRankedMatch("Semifinals", 1, "sfA", "sfB"),
makeRankedMatch("Semifinals", 2, "sfC", "sfD"),
makeRankedMatch("Fifth Place Game", 1, "qf1", "qf2"),
makeRankedMatch("Finals", 1, "sfA", "sfC"),
];
it("places the consolation pair at its feeder round's tier, not at 3rd and 4th", () => {
const entries = computeRankedEntries(
matches,
ROUNDS,
groupMatchesByRound(matches),
CONSOLATION,
new Map()
);
expect(rankOf(entries, "qf1")).toBe("5");
expect(rankOf(entries, "qf2")).toBe("6");
// The feeder round's other losers start below the pair, not alongside them.
expect(rankOf(entries, "qf3")).toBe("T7");
expect(rankOf(entries, "qf4")).toBe("T7");
// The rounds above it are unaffected.
expect(rankOf(entries, "sfC")).toBe("T2");
expect(rankOf(entries, "sfB")).toBe("T3");
});
});
describe("consolation matches that cannot be placed exactly", () => {
it("still ranks the loser when the winner relation is missing", () => {
const matches = fifaMatches({
thirdPlace: makeRankedMatch("Third Place Game", 1, "sfB", "sfD", {
missingWinnerRelation: true,
}),
});
const entries = rankFifa(matches);
// The winner cannot be placed without a participant object, but the loser must
// not silently vanish the way it would if the round were skipped wholesale.
expect(rankOf(entries, "sfD")).toBeDefined();
});
it("falls back to the loser-driven walk when the feeder round has no matches", () => {
const matches: Match[] = [
makeRankedMatch("Third Place Game", 1, "sfB", "sfD"),
makeRankedMatch("Finals", 1, "sfA", "sfC"),
];
const entries = computeRankedEntries(
matches,
["Third Place Game", "Finals"],
groupMatchesByRound(matches),
FIFA_CONSOLATION,
new Map()
);
expect(rankOf(entries, "sfC")).toBe("T2");
expect(rankOf(entries, "sfD")).toBeDefined();
});
});
describe("rendered output", () => {
/** The card the 3PG winner was incorrectly appearing in. */
function inContentionNames() {
const card = screen.queryByText("In Contention")?.closest('[data-slot="card"]');
if (!card) return [];
return within(card as HTMLElement)
.getAllByRole("row")
.map((r) => r.textContent ?? "");
}
it("does not list the third place game winner as in contention", () => {
render(
<PlayoffBracket matches={fifaMatches()} rounds={FIFA_ROUNDS} bracketTemplateId="fifa_48" />
);
// sfB won the third place game — they are finished, not still playing.
expect(inContentionNames().some((t) => t.includes("sfB"))).toBe(false);
});
it("still lists semifinalists as in contention before the third place game", () => {
render(
<PlayoffBracket
matches={fifaMatches({ played: false })}
rounds={FIFA_ROUNDS}
bracketTemplateId="fifa_48"
/>
);
expect(inContentionNames().some((t) => t.includes("sfB"))).toBe(true);
});
});
describe("brackets without a consolation round", () => {
const ROUNDS = ["Quarterfinals", "Semifinals", "Finals"];
it("ranks losers by round with tie labels, unchanged", () => {
const matches: Match[] = [
makeRankedMatch("Quarterfinals", 1, "sfA", "qf1"),
makeRankedMatch("Quarterfinals", 2, "sfB", "qf2"),
makeRankedMatch("Quarterfinals", 3, "sfC", "qf3"),
makeRankedMatch("Quarterfinals", 4, "sfD", "qf4"),
makeRankedMatch("Semifinals", 1, "sfA", "sfB"),
makeRankedMatch("Semifinals", 2, "sfC", "sfD"),
makeRankedMatch("Finals", 1, "sfA", "sfC"),
];
const entries = computeRankedEntries(
matches,
ROUNDS,
groupMatchesByRound(matches),
undefined,
new Map()
);
expect(rankOf(entries, "sfC")).toBe("T2");
expect(rankOf(entries, "sfB")).toBe("T3");
expect(rankOf(entries, "sfD")).toBe("T3");
expect(rankOf(entries, "qf1")).toBe("T5");
expect(rankOf(entries, "sfA")).toBeUndefined();
});
});
});

View file

@ -1940,10 +1940,8 @@ export async function recalculateAffectedLeagues(
// in the World Cup) does not qualify on its own — their owner hasn't earned anything yet. // in the World Cup) does not qualify on its own — their owner hasn't earned anything yet.
// When a match qualifies because the loser is owned, the winner's manager tag is still // When a match qualifies because the loser is owned, the winner's manager tag is still
// shown for context (who beat them), but the winner is not Discord-pinged. // shown for context (who beat them), but the winner is not Discord-pinged.
// Both managers' tags are always shown for context when their teams are drafted; the // Losers who advance to another match (loserAdvances=true, e.g. NBA 7v8 → PIR2) have
// showLoser flag (isLoserNotifiable) only gates whether the loser is @-pinged — a loser // showLoser=false and are correctly suppressed.
// who advanced rather than being eliminated (loserAdvances=true, e.g. NBA 7v8 → PIR2, or
// a World Cup semifinal loser) is named but not pinged.
let scoredMatches: ScoredMatch[] | undefined; let scoredMatches: ScoredMatch[] | undefined;
if (allCompletedMatches.length > 0) { if (allCompletedMatches.length > 0) {
const relevant = allCompletedMatches.filter( const relevant = allCompletedMatches.filter(
@ -1976,12 +1974,7 @@ export async function recalculateAffectedLeagues(
winnerName: x.m.winnerName ?? "", winnerName: x.m.winnerName ?? "",
loserName: x.m.loserName ?? "", loserName: x.m.loserName ?? "",
winnerUsername: x.winnerOwnerId ? usernameByUserId.get(x.winnerOwnerId) : undefined, winnerUsername: x.winnerOwnerId ? usernameByUserId.get(x.winnerOwnerId) : undefined,
// Show the loser's manager tag whenever their team is drafted, mirroring the loserUsername: x.showLoser && x.loserOwnerId ? usernameByUserId.get(x.loserOwnerId) : undefined,
// winner above — even when the loser advances rather than being eliminated
// (World Cup semifinal → 3rd-place playoff, AFL Qualifying Final → Semi Final).
// The @-ping stays gated by showLoser (loserDiscordUserId below): a still-alive
// loser who neither scored nor was eliminated is named for context but not pinged.
loserUsername: x.loserOwnerId ? usernameByUserId.get(x.loserOwnerId) : undefined,
winnerDiscordUserId: x.winnerScoreChanged && x.winnerOwnerId ? discordIdByUserId.get(x.winnerOwnerId) : undefined, winnerDiscordUserId: x.winnerScoreChanged && x.winnerOwnerId ? discordIdByUserId.get(x.winnerOwnerId) : undefined,
loserDiscordUserId: x.showLoser && x.loserOwnerId ? discordIdByUserId.get(x.loserOwnerId) : undefined, loserDiscordUserId: x.showLoser && x.loserOwnerId ? discordIdByUserId.get(x.loserOwnerId) : undefined,
})) }))

View file

@ -146,13 +146,7 @@ async function scoreQualifyingBracket(
*/ */
newlyEliminatedParticipantIds?: Set<string> newlyEliminatedParticipantIds?: Set<string>
): Promise<void> { ): Promise<void> {
// processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent), await processQualifyingBracketEvent(event.id, db);
// recalcs participant QP totals, AND announces the QP change to this window's leagues.
// Calling processQualifyingBracketEvent directly here would score silently — the QP
// Discord notification only fires from processQualifyingEvent. The fan-out below skips
// this (primary) window via skipEventId, so mirror windows are announced separately
// with no double-post.
await processQualifyingEvent(event.id, db, { newlyEliminatedParticipantIds });
await recalculateAffectedLeagues( await recalculateAffectedLeagues(
event.sportsSeasonId, event.sportsSeasonId,
db, db,
@ -619,14 +613,9 @@ export async function action({ request, params }: Route.ActionArgs) {
// previously completed matches in the event. // previously completed matches in the event.
if (successCount > 0) { if (successCount > 0) {
const db = database(); const db = database();
// Qualifying majors: derive QP from the full bracket once for the batch, and // Qualifying majors: derive QP from the full bracket once for the batch.
// announce the QP change to this window's leagues. processQualifyingEvent (not
// processQualifyingBracketEvent) is what sends the QP Discord notification; the
// fan-out below skips this window (skipEventId) so mirrors don't double-post.
if (event.isQualifyingEvent) { if (event.isQualifyingEvent) {
await processQualifyingEvent(event.id, db, { await processQualifyingBracketEvent(event.id, db);
newlyEliminatedParticipantIds: new Set(newlyDecidedLosers(decidedEntries)),
});
} }
// Update probabilities first so recalculateAffectedLeagues reads fresh EVs // Update probabilities first so recalculateAffectedLeagues reads fresh EVs
// when computing projected points. // when computing projected points.

View file

@ -186,37 +186,6 @@ describe("sendStandingsUpdateNotification", () => {
expect(desc).toContain("• **Sporting (christhrowsrocks)** def. Bodø/Glimt (apatel)"); expect(desc).toContain("• **Sporting (christhrowsrocks)** def. Bodø/Glimt (apatel)");
}); });
it("names a non-eliminated loser for context without @-pinging them", async () => {
// Argentina beats England in the World Cup semifinal. Argentina scored, so it's
// pinged; England advances to the 3rd-place playoff (not eliminated, no points
// change), so its manager is shown by plain username but NOT @-pinged.
await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Diablo League 2026",
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 160, rank: 7 }],
previousStandings: new Map([["a", 130]]),
scoredMatches: [
{
winnerName: "Argentina",
loserName: "England",
winnerUsername: "philosohraptors",
winnerDiscordUserId: "111",
loserUsername: "elementsoul",
// no loserDiscordUserId — still alive, no ping
},
],
});
const payload = getPayload();
const desc = payload.embeds[0].description as string;
// Winner scored → rendered as an @-mention; loser is named by plain username.
expect(desc).toContain("• **Argentina (<@111>)** def. England (elementsoul)");
// England's manager is named but not mentioned/pinged.
expect(desc).not.toContain("England (<@");
expect(payload.content ?? "").toContain("<@111>");
expect(payload.content ?? "").not.toContain("elementsoul");
});
it("shows winner's manager for context when the match fires due to an owned loser", async () => { it("shows winner's manager for context when the match fires due to an owned loser", async () => {
// Brazil beats Japan (R32, non-scoring). Japan's manager is the reason for the // Brazil beats Japan (R32, non-scoring). Japan's manager is the reason for the
// notification; Brazil's manager is shown for context even though they didn't score. // notification; Brazil's manager is shown for context even though they didn't score.
@ -821,99 +790,33 @@ describe("sendQualifyingPointsUpdateNotification", () => {
expect(desc.indexOf("Novak Djokovic")).toBeLessThan(desc.indexOf("Carlos Alcaraz")); expect(desc.indexOf("Novak Djokovic")).toBeLessThan(desc.indexOf("Carlos Alcaraz"));
}); });
it("omits zero-QP drafted participants entirely from the Drafted Participants section", async () => { it("shows Non-scoring Participants section with season total and plain manager name", async () => {
// Only participants who have actually scored (qpTotal > 0) are listed; a 0-QP drafted
// player no longer appears anywhere in the standings section.
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: [
{ participantName: "Champ", qpEarned: 10, qpTotal: 100, globalRank: 1, globalRankTied: false, ownerUsername: "alex" },
],
scoreboard: [
{ participantName: "Champ", qpEarned: 10, qpTotal: 100, globalRank: 1, globalRankTied: false, ownerUsername: "alex" },
{ participantName: "Also Ran", qpEarned: 0, qpTotal: 5, globalRank: 9, globalRankTied: false, ownerUsername: "chris" },
{ participantName: "Winless Wonder", qpEarned: 0, qpTotal: 0, globalRank: 0, globalRankTied: false, ownerUsername: "sam" },
],
});
const desc = getDescription();
expect(desc).toContain("**Drafted Participants**");
// Both scorers appear as ranked rows...
expect(desc).toContain("1\\. Champ (alex) — 100 QP");
expect(desc).toContain("9\\. Also Ran (chris) — 5 QP");
// ...with a Points Bubble divider separating the rank-9 scorer.
expect(desc).toContain("**═══ Points Bubble ═══**");
// The 0-QP player is omitted entirely.
expect(desc).not.toContain("Winless Wonder");
// The old Non-scoring section is gone.
expect(desc).not.toContain("Non-scoring Participants");
});
it("shows the Drafted Participants section for scored participants sorted by rank", async () => {
await sendQualifyingPointsUpdateNotification({ await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL, webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025", seasonName: "Slam League 2025",
entries: BASE_ENTRIES, entries: BASE_ENTRIES,
scoreboard: BASE_ENTRIES,
}); });
const desc = getDescription(); const desc = getDescription();
expect(desc).toContain("**Drafted Participants**"); expect(desc).toContain("**Non-scoring Participants**");
// Name (seasonTotal, manager) — Rafael has a 20 QP running total, owner "alex"
expect(desc).toContain("• Rafael Nadal (20, alex)");
});
it("shows Drafted Participants in Top 8 for entries sorted by qpTotal desc", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: BASE_ENTRIES,
});
const desc = getDescription();
expect(desc).toContain("**Drafted Participants in Top 8**");
expect(desc).toContain("1\\. Novak Djokovic (alex) — 45 QP"); expect(desc).toContain("1\\. Novak Djokovic (alex) — 45 QP");
expect(desc).toContain("2\\. Carlos Alcaraz (chris) — 34 QP"); expect(desc).toContain("2\\. Carlos Alcaraz (chris) — 34 QP");
expect(desc).toContain("3\\. Rafael Nadal (alex) — 20 QP"); expect(desc).toContain("3\\. Rafael Nadal (alex) — 20 QP");
// Djokovic should rank above Alcaraz // Djokovic should rank above Alcaraz
expect(desc.indexOf("1\\. Novak")).toBeLessThan(desc.indexOf("2\\. Carlos")); expect(desc.indexOf("1\\. Novak")).toBeLessThan(desc.indexOf("2\\. Carlos"));
// Everyone is rank <= 8, so no divider is emitted.
expect(desc).not.toContain("Points Bubble");
});
it("inserts a Points Bubble divider between the rank-8 and rank-9 scorers", async () => {
const scoreboard = [
{ participantName: "Player Eight", qpEarned: 5, qpTotal: 12, globalRank: 8, globalRankTied: false, ownerUsername: "chris" },
{ participantName: "Player Nine", qpEarned: 3, qpTotal: 8, globalRank: 9, globalRankTied: false, ownerUsername: "alex" },
];
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: scoreboard,
scoreboard,
});
const desc = getDescription();
const eightIdx = desc.indexOf("8\\. Player Eight");
const bubbleIdx = desc.indexOf("**═══ Points Bubble ═══**");
const nineIdx = desc.indexOf("9\\. Player Nine");
expect(eightIdx).toBeGreaterThan(-1);
expect(bubbleIdx).toBeGreaterThan(-1);
expect(nineIdx).toBeGreaterThan(-1);
// Divider sits between the rank-8 and rank-9 rows.
expect(eightIdx).toBeLessThan(bubbleIdx);
expect(bubbleIdx).toBeLessThan(nineIdx);
});
it("omits the Points Bubble divider when every scorer is below the cutoff", async () => {
// globalRank is a season-wide rank but the scoreboard is scoped to one league's drafts,
// so a league can have drafted nobody in the global top 8. The divider must not lead the
// section with nothing above it.
const scoreboard = [
{ participantName: "Player Nine", qpEarned: 3, qpTotal: 8, globalRank: 9, globalRankTied: false, ownerUsername: "alex" },
{ participantName: "Player Ten", qpEarned: 2, qpTotal: 5, globalRank: 10, globalRankTied: false, ownerUsername: "chris" },
];
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: scoreboard,
scoreboard,
});
const desc = getDescription();
expect(desc).toContain("**Drafted Participants**");
expect(desc).toContain("9\\. Player Nine (alex) — 8 QP");
expect(desc).toContain("10\\. Player Ten (chris) — 5 QP");
// No rank <= 8 row exists, so the divider must not appear.
expect(desc).not.toContain("Points Bubble");
}); });
it("uses T-prefix for tied QP totals in standings", async () => { it("uses T-prefix for tied QP totals in standings", async () => {
@ -925,11 +828,6 @@ describe("sendQualifyingPointsUpdateNotification", () => {
{ participantName: "Player B", qpEarned: 5, qpTotal: 25 }, { participantName: "Player B", qpEarned: 5, qpTotal: 25 },
{ participantName: "Player C", qpEarned: 2, qpTotal: 10 }, { participantName: "Player C", qpEarned: 2, qpTotal: 10 },
]), ]),
scoreboard: withRanks([
{ participantName: "Player A", qpEarned: 10, qpTotal: 25 },
{ participantName: "Player B", qpEarned: 5, qpTotal: 25 },
{ participantName: "Player C", qpEarned: 2, qpTotal: 10 },
]),
}); });
const desc = getDescription(); const desc = getDescription();
@ -968,53 +866,40 @@ describe("sendQualifyingPointsUpdateNotification", () => {
expect(desc).not.toContain("1.50"); expect(desc).not.toContain("1.50");
}); });
it("lists a rank-9 scorer below the Points Bubble but omits a 0-QP participant", async () => { it("excludes participants ranked outside the top 8 from the Drafted Participants section", async () => {
// Rank-9 scorers now appear in the standings section (below the bubble), while a drafted // Only the top 8 (plus ties) of the full season field belong in this section, so a
// participant with no points is dropped entirely. // rank-9 scorer must NOT appear in it — even though it still shows in Points Awarded.
const both = [
{
participantName: "Player Eight",
qpEarned: 5,
qpTotal: 10,
globalRank: 8,
globalRankTied: false,
ownerUsername: "eighthowner",
},
{
participantName: "Player Nine",
qpEarned: 3,
qpTotal: 8,
globalRank: 9,
globalRankTied: false,
ownerUsername: "ninthowner",
},
{
participantName: "Player Winless",
qpEarned: 0,
qpTotal: 0,
globalRank: 10,
globalRankTied: false,
ownerUsername: "winlessowner",
},
];
await sendQualifyingPointsUpdateNotification({ await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL, webhookUrl: WEBHOOK_URL,
seasonName: "Rumble League 2026", seasonName: "Rumble League 2026",
sportName: "Tennis - Men", sportName: "Tennis - Men",
eventName: "Wimbledon", eventName: "Wimbledon",
entries: both, entries: [
scoreboard: both, {
participantName: "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**"); expect(desc).toContain("**Drafted Participants in Top 8**");
expect(desc).toContain("8\\. Player Eight (eighthowner) — 10 QP"); expect(desc).toContain("8\\. Player Eight (eighthowner) — 10 QP");
// The rank-9 scorer now appears as a ranked row below the bubble. // The rank-9 player is filtered out of the standings section entirely.
expect(desc).toContain("**═══ Points Bubble ═══**"); expect(desc).not.toContain("9\\. Player Nine");
expect(desc).toContain("9\\. Player Nine (ninthowner) — 8 QP"); // ...but both still earned points, so both remain in Points Awarded.
// 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");
}); });
@ -1031,20 +916,12 @@ 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) Drafted Participants standings section uses the plain username. // ...while the (non-pinged) Top 8 standings section uses the plain username.
expect(desc).toContain("1\\. Novak Djokovic (alex) — 45 QP"); expect(desc).toContain("1\\. Novak Djokovic (alex) — 45 QP");
}); });
@ -1099,7 +976,7 @@ describe("sendQualifyingPointsUpdateNotification", () => {
expect(fetch).toHaveBeenCalledOnce(); expect(fetch).toHaveBeenCalledOnce();
const desc = getDescription(); const desc = getDescription();
expect(desc).toContain("**Knocked Out**"); expect(desc).toContain("**Knocked Out**");
expect(desc).not.toContain("**Drafted Participants**"); expect(desc).not.toContain("**Drafted Participants in Top 8**");
expect(desc).not.toContain("**Points Awarded**"); expect(desc).not.toContain("**Points Awarded**");
}); });
@ -1144,7 +1021,6 @@ describe("sendQualifyingPointsUpdateNotification", () => {
webhookUrl: WEBHOOK_URL, webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025", seasonName: "Slam League 2025",
entries: longEntries, entries: longEntries,
scoreboard: longEntries,
}); });
const desc = getDescription(); const desc = getDescription();

View file

@ -83,7 +83,7 @@ function makeDb(overrides: Record<string, unknown> = {}) {
]), ]),
}, },
seasonParticipants: { seasonParticipants: {
findMany: vi.fn().mockResolvedValue([{ id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID }]), findMany: vi.fn().mockResolvedValue([{ id: PARTICIPANT_ID, name: "Carlos Alcaraz" }]),
}, },
users: { users: {
findMany: vi.fn().mockResolvedValue([ findMany: vi.fn().mockResolvedValue([
@ -263,8 +263,8 @@ describe("notifyQualifyingPointsUpdate", () => {
}, },
seasonParticipants: { seasonParticipants: {
findMany: vi.fn().mockResolvedValue([ findMany: vi.fn().mockResolvedValue([
{ id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID }, { id: PARTICIPANT_ID, name: "Carlos Alcaraz" },
{ id: "p-2", name: "Rafael Nadal", sportsSeasonId: SPORTS_SEASON_ID }, { id: "p-2", name: "Rafael Nadal" },
]), ]),
}, },
}); });
@ -281,84 +281,6 @@ describe("notifyQualifyingPointsUpdate", () => {
expect(call.entries[0].participantName).toBe("Carlos Alcaraz"); expect(call.entries[0].participantName).toBe("Carlos Alcaraz");
}); });
it("scoreboard includes every drafted participant even when entries are filtered", async () => {
// The scoreboard powers the Drafted Participants section and must reflect the full
// drafted field, not just this sync's changed participants. Here Nadal (p-2) did not
// change this sync (filtered out of entries) but is drafted, so he belongs on the
// scoreboard with his running total and no QP earned this event.
const db = makeDb({
eventResults: {
findMany: vi.fn().mockResolvedValue([
{ seasonParticipantId: PARTICIPANT_ID, qualifyingPointsAwarded: "10" },
{ seasonParticipantId: "p-2", qualifyingPointsAwarded: "5" },
]),
},
draftPicks: {
findMany: vi.fn().mockResolvedValue([
{ participantId: PARTICIPANT_ID, seasonId: SEASON_ID, team: { name: "Alpha FC", ownerId: null } },
{ participantId: "p-2", seasonId: SEASON_ID, team: { name: "Beta FC", ownerId: null } },
]),
},
seasonParticipantQualifyingTotals: {
findMany: vi.fn().mockResolvedValue([
{ participantId: PARTICIPANT_ID, totalQualifyingPoints: "45", sportsSeasonId: SPORTS_SEASON_ID },
{ participantId: "p-2", totalQualifyingPoints: "20", sportsSeasonId: SPORTS_SEASON_ID },
]),
},
seasonParticipants: {
findMany: vi.fn().mockResolvedValue([
{ id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID },
{ id: "p-2", name: "Rafael Nadal", sportsSeasonId: SPORTS_SEASON_ID },
]),
},
});
await notifyQualifyingPointsUpdate(
SPORTS_SEASON_ID,
SCORING_EVENT_ID,
db as never,
new Set([PARTICIPANT_ID])
);
const call = vi.mocked(sendQualifyingPointsUpdateNotification).mock.calls[0][0];
// entries is scoped to the changed participant…
expect(call.entries).toHaveLength(1);
expect(call.entries[0].participantName).toBe("Carlos Alcaraz");
// …but the scoreboard carries the whole drafted field.
expect(call.scoreboard).toEqual(
expect.arrayContaining([
expect.objectContaining({ participantName: "Carlos Alcaraz", qpTotal: 45 }),
expect.objectContaining({ participantName: "Rafael Nadal", qpEarned: 0, qpTotal: 20 }),
])
);
expect(call.scoreboard).toHaveLength(2);
});
it("excludes participants from other sports seasons drafted in the same fantasy season", async () => {
// Draft picks span every sport in a fantasy season, so a golf pick can share the
// league with this tennis event. It must not leak into the tennis scoreboard.
const db = makeDb({
draftPicks: {
findMany: vi.fn().mockResolvedValue([
{ participantId: PARTICIPANT_ID, seasonId: SEASON_ID, team: { name: "Alpha FC", ownerId: null } },
{ participantId: "p-golf", seasonId: SEASON_ID, team: { name: "Alpha FC", ownerId: null } },
]),
},
seasonParticipants: {
findMany: vi.fn().mockResolvedValue([
{ id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID },
{ id: "p-golf", name: "Rory McIlroy", sportsSeasonId: "ss-golf" },
]),
},
});
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
const call = vi.mocked(sendQualifyingPointsUpdateNotification).mock.calls[0][0];
expect(call.scoreboard).toHaveLength(1);
expect(call.scoreboard?.[0]?.participantName).toBe("Carlos Alcaraz");
});
it("does not call sendQualifyingPointsUpdateNotification when all participants are filtered out", async () => { it("does not call sendQualifyingPointsUpdateNotification when all participants are filtered out", async () => {
const db = makeDb(); const db = makeDb();
@ -393,8 +315,8 @@ describe("notifyQualifyingPointsUpdate", () => {
}, },
seasonParticipants: { seasonParticipants: {
findMany: vi.fn().mockResolvedValue([ findMany: vi.fn().mockResolvedValue([
{ id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID }, { id: PARTICIPANT_ID, name: "Carlos Alcaraz" },
{ id: MENSIK_ID, name: "Jakob Mensik", sportsSeasonId: SPORTS_SEASON_ID }, { id: MENSIK_ID, name: "Jakob Mensik" },
]), ]),
}, },
}); });

View file

@ -295,7 +295,6 @@ export async function sendQualifyingPointsUpdateNotification({
eventName, eventName,
entries, entries,
eliminated = [], eliminated = [],
scoreboard = [],
standingsUrl, standingsUrl,
}: { }: {
webhookUrl: string; webhookUrl: string;
@ -304,13 +303,6 @@ export async function sendQualifyingPointsUpdateNotification({
eventName?: string; eventName?: string;
entries: QPEventEntry[]; entries: QPEventEntry[];
eliminated?: QPEliminatedEntry[]; eliminated?: QPEliminatedEntry[];
/**
* The full current scoreboard for the league every drafted participant, not just
* those whose QP changed this sync. Drives the "Drafted Participants" standings section.
* `entries`/`eliminated` remain scoped to this sync's changes and drive the
* "Points Awarded"/"Knocked Out" sections and the ping list.
*/
scoreboard?: QPEventEntry[];
standingsUrl?: string; standingsUrl?: string;
}): Promise<void> { }): Promise<void> {
if (entries.length === 0 && eliminated.length === 0) return; if (entries.length === 0 && eliminated.length === 0) return;
@ -341,6 +333,20 @@ 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) {
@ -358,34 +364,21 @@ export async function sendQualifyingPointsUpdateNotification({
} }
} }
// Drafted Participants: every drafted participant that has actually scored (qpTotal > 0), // Only drafted participants sitting in the top 8 of the FULL season standings
// drawn from the FULL drafted field (`scoreboard`) not just this sync's movers, so it reads // (globalRank supplied by the caller), ordered by rank. Standard competition
// as a live standings snapshot. Rendered as ranked lines ordered by full-season standing. A // ranking collapses ties to a shared rank, so `globalRank <= 8` naturally keeps
// "Points Bubble" divider marks the cutoff between those currently in the points (rank <= 8) // any tie group sitting at rank 8 ("top 8 plus ties").
// and those below it (rank >= 9). Participants with 0 QP are omitted entirely. Never pinged, const sorted = [...entries]
// so managers are shown by plain username, never as a <@id> mention. .filter((e) => e.globalRank <= 8)
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 has scored (e.g. a sync that only // Skip the section entirely when no drafted participant is in the top 8 (e.g. a
// reported knockouts) so we don't emit an empty header. // sync that only reported knockouts) so we don't emit an empty header.
if (scored.length > 0) { if (sorted.length > 0) {
sections.push("\n**Drafted Participants**"); sections.push("\n**Drafted Participants in Top 8**");
// Insert the divider once, before the first below-the-cutoff (rank >= 9) row. `>= 9` for (const e of sorted) {
// (not `> 8`) keeps a tie AT rank 8 above the bubble ("top 8 plus ties"). Only emit it
// after at least one above-the-bubble row exists: globalRank is a season-wide rank while
// this list is scoped to one league's drafts, so a league can have drafted nobody in the
// global top 8 — guarding on rowsAbove avoids a leading divider with nothing above it.
let bubbleInserted = false;
let rowsAbove = 0;
for (const e of scored) {
if (!bubbleInserted && rowsAbove > 0 && e.globalRank >= 9) {
sections.push("**═══ Points Bubble ═══**");
bubbleInserted = true;
}
if (e.globalRank <= 8) rowsAbove++;
const rankPrefix = e.globalRankTied ? `T${e.globalRank}` : `${e.globalRank}`; const rankPrefix = e.globalRankTied ? `T${e.globalRank}` : `${e.globalRank}`;
// 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,22 +125,12 @@ export async function notifyQualifyingPointsUpdate(
}); });
const seasonMap = new Map(seasons.map((s) => [s.id, s])); const seasonMap = new Map(seasons.map((s) => [s.id, s]));
// Batch-fetch participant display names once (same participants across all leagues). // Batch-fetch participant display names once (same participants across all leagues)
// Includes every drafted participant — the Drafted Participants scoreboard section const allParticipantIds = [...new Set([...qpEarnedById.keys(), ...eliminatedIds])];
// lists the whole scored field, not just this sync's changed participants.
const allParticipantIds = [
...new Set([...qpEarnedById.keys(), ...eliminatedIds, ...draftedParticipantIds]),
];
const participants = await db.query.seasonParticipants.findMany({ const participants = await db.query.seasonParticipants.findMany({
where: inArray(schema.seasonParticipants.id, allParticipantIds), where: inArray(schema.seasonParticipants.id, allParticipantIds),
}); });
const participantNameById = new Map(participants.map((p) => [p.id, p.name])); const participantNameById = new Map(participants.map((p) => [p.id, p.name]));
// A league's draft picks span every sport in that fantasy season, so the scoreboard
// must be scoped to participants belonging to the sports season being announced —
// otherwise a golf pick would surface in a tennis event's standings section.
const sportsSeasonParticipantIds = new Set(
participants.filter((p) => p.sportsSeasonId === sportsSeasonId).map((p) => p.id)
);
// Batch-fetch all team owners and their Discord IDs in two queries (not N per league) // Batch-fetch all team owners and their Discord IDs in two queries (not N per league)
const allOwnerIds = new Set<string>(); const allOwnerIds = new Set<string>();
@ -218,25 +208,6 @@ export async function notifyQualifyingPointsUpdate(
}; };
}); });
// Full current scoreboard for this league: every drafted participant, regardless of
// whether their QP changed this sync. Drives the Drafted Participants section so
// it reads as a season-standings snapshot rather than only this event's movers.
// Never pinged, so ownerDiscordUserId is intentionally omitted.
const scoreboard: QPEventEntry[] = [...teamByParticipantId.keys()]
.filter((participantId) => sportsSeasonParticipantIds.has(participantId))
.map((participantId) => {
const ownerId = teamByParticipantId.get(participantId)?.ownerId ?? null;
const globalRank = globalRankById.get(participantId) ?? 0;
return {
participantName: participantNameById.get(participantId) ?? participantId,
qpEarned: qpEarnedById.get(participantId) ?? 0,
qpTotal: qpTotalById.get(participantId) ?? 0,
globalRank,
globalRankTied: (countByRank.get(globalRank) ?? 0) > 1,
ownerUsername: ownerId ? (usernameByUserId.get(ownerId) ?? undefined) : undefined,
};
});
await sendQualifyingPointsUpdateNotification({ await sendQualifyingPointsUpdateNotification({
webhookUrl, webhookUrl,
seasonName, seasonName,
@ -244,7 +215,6 @@ export async function notifyQualifyingPointsUpdate(
eventName, eventName,
entries, entries,
eliminated, eliminated,
scoreboard,
standingsUrl, standingsUrl,
}); });
} }