Fix notification filter: only include matches where an owner earned points or lost
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m56s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m18s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped

The previous filter let matches through if showLoser was true (loser got a
finalized result) regardless of whether the loser or winner was actually
owned by a manager. This caused spurious notifications when a managed team
advanced through a non-scoring round (e.g. R32 → R16 in World Cup) while
beating an unowned opponent who happened to get a 0-pt finalized result.

New rule: a match is worth announcing only when the winner is owned AND
earned points, OR the loser is owned. When a match fires because the loser
is owned, the winner's manager tag is still shown for context but they are
not Discord-pinged (winnerDiscordUserId remains gated on winnerScoreChanged).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXKpR3MZJn5Zof1AKeETFM
This commit is contained in:
Claude 2026-06-30 23:40:41 +00:00
parent 177555f043
commit 613106cb9e
No known key found for this signature in database
2 changed files with 17 additions and 19 deletions

View file

@ -1827,16 +1827,15 @@ export async function recalculateAffectedLeagues(
); );
// Build scored matches for the notification. // Build scored matches for the notification.
// A match is included only when it is "notable": winner earned points this round OR // A match is worth announcing when:
// the loser is notifiable (eliminated / scored). A drafted winner that merely advanced // • the winner is owned by a manager AND earned points this round, OR
// without earning points is NOT enough on its own. // • the loser is owned by a manager (eliminated or scored).
// winnerUsername (and winnerDiscordUserId) are only set when the winner actually scored — // A winning team that merely advanced through a non-scoring round (e.g. R32 → R16
// advancing through a non-scoring round does not warrant mentioning or pinging the owner. // in the World Cup) does not qualify on its own — their owner hasn't earned anything yet.
// Losers appear if their score changed or they were definitively eliminated (finalPosition // When a match qualifies because the loser is owned, the winner's manager tag is still
// set, non-partial); losers who advance to another match (loserAdvances=true, e.g. NBA // shown for context (who beat them), but the winner is not Discord-pinged.
// 7v8 → PIR2) are correctly suppressed. // Losers who advance to another match (loserAdvances=true, e.g. NBA 7v8 → PIR2) have
// Only entries with at least one displayable username are kept, so scoredMatches always // showLoser=false and are correctly suppressed.
// 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(
@ -1864,11 +1863,11 @@ export async function recalculateAffectedLeagues(
const showLoser = isLoserNotifiable(m.loserId, loserTeamId, changedTeamIds, finalizedLoserIds); const showLoser = isLoserNotifiable(m.loserId, loserTeamId, changedTeamIds, finalizedLoserIds);
return { m, winnerScoreChanged, showLoser, winnerOwnerId, loserOwnerId }; return { m, winnerScoreChanged, showLoser, winnerOwnerId, loserOwnerId };
}) })
.filter((x) => x.winnerScoreChanged || x.showLoser) .filter((x) => (x.winnerScoreChanged && !!x.winnerOwnerId) || (x.showLoser && !!x.loserOwnerId))
.map((x) => ({ .map((x) => ({
winnerName: x.m.winnerName ?? "", winnerName: x.m.winnerName ?? "",
loserName: x.m.loserName ?? "", loserName: x.m.loserName ?? "",
winnerUsername: x.winnerScoreChanged && 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, loserUsername: x.showLoser && 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

@ -186,10 +186,10 @@ describe("sendStandingsUpdateNotification", () => {
expect(desc).toContain("• **Sporting (christhrowsrocks)** def. Bodø/Glimt (apatel)"); expect(desc).toContain("• **Sporting (christhrowsrocks)** def. Bodø/Glimt (apatel)");
}); });
it("omits winner's manager when they only advanced without earning points", async () => { it("shows winner's manager for context when the match fires due to an owned loser", async () => {
// Brazil advances through a non-scoring round (R32 → R16 in World Cup). // Brazil beats Japan (R32, non-scoring). Japan's manager is the reason for the
// scoring-calculator.ts does not set winnerUsername for advancing-only winners, // notification; Brazil's manager is shown for context even though they didn't score.
// so only Japan's manager appears in the notification. // scoring-calculator.ts controls whether to include the match; discord.ts just renders.
await sendStandingsUpdateNotification({ await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL, webhookUrl: WEBHOOK_URL,
seasonName: "Rumble League 2026", seasonName: "Rumble League 2026",
@ -199,15 +199,14 @@ describe("sendStandingsUpdateNotification", () => {
{ {
winnerName: "Brazil", winnerName: "Brazil",
loserName: "Japan", loserName: "Japan",
// winnerUsername intentionally absent — Brazil advanced without scoring winnerUsername: "aliceManager",
loserUsername: "ikyn", loserUsername: "ikyn",
}, },
], ],
}); });
const desc = getDescription(); const desc = getDescription();
expect(desc).toContain("• **Brazil** def. Japan (ikyn)"); expect(desc).toContain("• **Brazil (aliceManager)** def. Japan (ikyn)");
expect(desc).not.toContain("Brazil (");
}); });
it("omits scored matches where neither side has a username", async () => { it("omits scored matches where neither side has a username", async () => {