scoring notifications (#115)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 2m48s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m17s
🚀 Deploy / 🐳 Build (push) Successful in 1m11s
🚀 Deploy / 🚀 Deploy (push) Successful in 13s

This commit is contained in:
chrisp 2026-06-30 01:58:20 +00:00
parent e3e8485e26
commit 070b825077
3 changed files with 48 additions and 15 deletions

View file

@ -399,6 +399,7 @@ describe("isLoserNotifiable", () => {
}); });
}); });
describe("processPlayoffEvent - NBA Play-In Round 1 loserAdvances fix", () => { describe("processPlayoffEvent - NBA Play-In Round 1 loserAdvances fix", () => {
function makePlayInDb(matches: object[]) { function makePlayInDb(matches: object[]) {
const insertedRows: Array<{ participantId: string; finalPosition: number; isPartialScore: boolean }> = []; const insertedRows: Array<{ participantId: string; finalPosition: number; isPartialScore: boolean }> = [];

View file

@ -623,6 +623,7 @@ export function isLoserNotifiable(
return scoreChanged || finalizedLoserIds.has(loserId); return scoreChanged || finalizedLoserIds.has(loserId);
} }
export function getGuaranteedMinimumPosition( export function getGuaranteedMinimumPosition(
round: string, round: string,
bracketTemplateId: string | null | undefined, bracketTemplateId: string | null | undefined,
@ -1826,12 +1827,17 @@ export async function recalculateAffectedLeagues(
); );
// Build scored matches for the notification. // Build scored matches for the notification.
// Winners only appear if their team's score changed (they earned points this round). // A match is included only when it is "notable": winner earned points this round OR
// Losers appear if their team's score changed OR they were definitively eliminated // the loser is notifiable (eliminated / scored). A drafted winner that merely advanced
// (finalPosition set, non-partial) — the latter catches 0-pt eliminations that // without earning points is NOT enough on its own.
// produce no score delta. Losers who advance to another match (loserAdvances=true, // On any included match, the winning team's owner is shown in the embed (winnerUsername),
// e.g. NBA 7v8 → PIR2) have no finalized result and no score change, so they're // but Discord-pinged only when they actually scored (winnerDiscordUserId gated on
// correctly suppressed. // winnerScoreChanged) — advancing-only wins don't warrant a ping.
// Losers appear if their score changed or they were definitively eliminated (finalPosition
// set, non-partial); losers who advance to another match (loserAdvances=true, e.g. NBA
// 7v8 → PIR2) are correctly suppressed.
// Only entries with at least one displayable username are kept, so scoredMatches always
// reflects exactly what will be shown in the embed.
let scoredMatches: ScoredMatch[] | undefined; let scoredMatches: ScoredMatch[] | undefined;
if (allCompletedMatches.length > 0) { if (allCompletedMatches.length > 0) {
const relevant = allCompletedMatches.filter( const relevant = allCompletedMatches.filter(
@ -1857,15 +1863,18 @@ export async function recalculateAffectedLeagues(
const winnerOwnerId = lookupOwner(m.winnerId); const winnerOwnerId = lookupOwner(m.winnerId);
const loserOwnerId = lookupOwner(m.loserId); const loserOwnerId = lookupOwner(m.loserId);
const showLoser = isLoserNotifiable(m.loserId, loserTeamId, changedTeamIds, finalizedLoserIds); const showLoser = isLoserNotifiable(m.loserId, loserTeamId, changedTeamIds, finalizedLoserIds);
return { return { m, winnerScoreChanged, showLoser, winnerOwnerId, loserOwnerId };
winnerName: m.winnerName ?? "", })
loserName: m.loserName ?? "", .filter((x) => x.winnerScoreChanged || x.showLoser)
winnerUsername: winnerScoreChanged && winnerOwnerId ? usernameByUserId.get(winnerOwnerId) : undefined, .map((x) => ({
loserUsername: showLoser && loserOwnerId ? usernameByUserId.get(loserOwnerId) : undefined, winnerName: x.m.winnerName ?? "",
winnerDiscordUserId: winnerScoreChanged && winnerOwnerId ? discordIdByUserId.get(winnerOwnerId) : undefined, loserName: x.m.loserName ?? "",
loserDiscordUserId: showLoser && loserOwnerId ? discordIdByUserId.get(loserOwnerId) : undefined, winnerUsername: x.winnerOwnerId ? usernameByUserId.get(x.winnerOwnerId) : undefined,
}; loserUsername: x.showLoser && x.loserOwnerId ? usernameByUserId.get(x.loserOwnerId) : undefined,
}); winnerDiscordUserId: x.winnerScoreChanged && x.winnerOwnerId ? discordIdByUserId.get(x.winnerOwnerId) : undefined,
loserDiscordUserId: x.showLoser && x.loserOwnerId ? discordIdByUserId.get(x.loserOwnerId) : undefined,
}))
.filter((m) => m.winnerUsername !== undefined || m.loserUsername !== undefined);
} }
} }

View file

@ -186,6 +186,29 @@ describe("sendStandingsUpdateNotification", () => {
expect(desc).toContain("• **Sporting (christhrowsrocks)** def. Bodø/Glimt (apatel)"); expect(desc).toContain("• **Sporting (christhrowsrocks)** def. Bodø/Glimt (apatel)");
}); });
it("shows winner's owner even when winner only advanced (loser was eliminated)", async () => {
// Mirrors the Brazil/Japan scenario: Brazil advanced without earning points,
// Japan was eliminated. winnerUsername is shown in the embed; winnerDiscordUserId
// is left unset (no ping) because the winner did not score this round.
await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Rumble League 2026",
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }],
previousStandings: new Map([["a", 100]]),
scoredMatches: [
{
winnerName: "Brazil",
loserName: "Japan",
winnerUsername: "someManager",
loserUsername: "ikyn",
},
],
});
const desc = getDescription();
expect(desc).toContain("• **Brazil (someManager)** def. Japan (ikyn)");
});
it("omits scored matches where neither side has a username", async () => { it("omits scored matches where neither side has a username", async () => {
await sendStandingsUpdateNotification({ await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL, webhookUrl: WEBHOOK_URL,