Show winning team's owner in scoring notifications when loser is eliminated

When a match is displayed because the loser was eliminated (0-pt), the winner's
manager was previously omitted if the winner only advanced without earning points.
Now the winner's owner is always credited on any match that passes the notability
gate, while the gate itself (winnerScoreChanged || loserNotifiable) still prevents
purely-advancing matches from appearing.

Adds isMatchNotable() pure helper alongside isLoserNotifiable() to make the
display rule testable in isolation.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Claude 2026-06-29 22:19:30 +00:00
parent e3e8485e26
commit 4a9a70ca7c
No known key found for this signature in database
3 changed files with 79 additions and 16 deletions

View file

@ -59,7 +59,7 @@ function makeDb(existingResult?: { id: string; isPartialScore: boolean }) {
};
}
import { processMatchResult, isLoserNotifiable, processPlayoffEvent } from "../scoring-calculator";
import { processMatchResult, isLoserNotifiable, isMatchNotable, processPlayoffEvent } from "../scoring-calculator";
import { doesLoserAdvance } from "../playoff-match";
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
@ -399,6 +399,24 @@ describe("isLoserNotifiable", () => {
});
});
describe("isMatchNotable", () => {
it("returns true when winner scored (loser not notifiable)", () => {
expect(isMatchNotable(true, false)).toBe(true);
});
it("returns true when loser is notifiable (winner did not score)", () => {
expect(isMatchNotable(false, true)).toBe(true);
});
it("returns true when both winner scored and loser is notifiable", () => {
expect(isMatchNotable(true, true)).toBe(true);
});
it("returns false when winner only advanced (no points) and loser is not notifiable", () => {
expect(isMatchNotable(false, false)).toBe(false);
});
});
describe("processPlayoffEvent - NBA Play-In Round 1 loserAdvances fix", () => {
function makePlayInDb(matches: object[]) {
const insertedRows: Array<{ participantId: string; finalPosition: number; isPartialScore: boolean }> = [];

View file

@ -623,6 +623,25 @@ export function isLoserNotifiable(
return scoreChanged || finalizedLoserIds.has(loserId);
}
/**
* Returns true if a scored match should be displayed in a Discord standings
* notification at all.
*
* A match is shown only when the winner earned points this round or the loser is
* notifiable (eliminated / scored see {@link isLoserNotifiable}). A drafted
* winner that merely advanced without earning points is NOT enough on its own.
*
* The winner's owner is still credited on any match that passes this gate (even
* a points-less advance) this function only decides whether the match line
* appears, not which managers are labelled within it.
*/
export function isMatchNotable(
winnerScoreChanged: boolean,
loserNotifiable: boolean
): boolean {
return winnerScoreChanged || loserNotifiable;
}
export function getGuaranteedMinimumPosition(
round: string,
bracketTemplateId: string | null | undefined,
@ -1826,12 +1845,13 @@ export async function recalculateAffectedLeagues(
);
// Build scored matches for the notification.
// Winners only appear if their team's score changed (they earned points this round).
// Losers appear if their team's score changed OR they were definitively eliminated
// (finalPosition set, non-partial) — the latter catches 0-pt eliminations that
// produce no score delta. Losers who advance to another match (loserAdvances=true,
// e.g. NBA 7v8 → PIR2) have no finalized result and no score change, so they're
// correctly suppressed.
// A match is included only when it is "notable": either the winner earned points this
// round OR the loser is notifiable (eliminated / scored). A drafted winner that merely
// advanced without earning points is NOT enough on its own (see isMatchNotable).
// On any match that IS included, the winning team's owner is always credited — even
// when the winner only advanced. 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.
let scoredMatches: ScoredMatch[] | undefined;
if (allCompletedMatches.length > 0) {
const relevant = allCompletedMatches.filter(
@ -1857,15 +1877,17 @@ export async function recalculateAffectedLeagues(
const winnerOwnerId = lookupOwner(m.winnerId);
const loserOwnerId = lookupOwner(m.loserId);
const showLoser = isLoserNotifiable(m.loserId, loserTeamId, changedTeamIds, finalizedLoserIds);
return {
winnerName: m.winnerName ?? "",
loserName: m.loserName ?? "",
winnerUsername: winnerScoreChanged && winnerOwnerId ? usernameByUserId.get(winnerOwnerId) : undefined,
loserUsername: showLoser && loserOwnerId ? usernameByUserId.get(loserOwnerId) : undefined,
winnerDiscordUserId: winnerScoreChanged && winnerOwnerId ? discordIdByUserId.get(winnerOwnerId) : undefined,
loserDiscordUserId: showLoser && loserOwnerId ? discordIdByUserId.get(loserOwnerId) : undefined,
};
});
return { m, winnerScoreChanged, showLoser, winnerOwnerId, loserOwnerId };
})
.filter((x) => isMatchNotable(x.winnerScoreChanged, x.showLoser))
.map((x) => ({
winnerName: x.m.winnerName ?? "",
loserName: x.m.loserName ?? "",
winnerUsername: x.winnerOwnerId ? usernameByUserId.get(x.winnerOwnerId) : undefined,
loserUsername: x.showLoser && x.loserOwnerId ? usernameByUserId.get(x.loserOwnerId) : undefined,
winnerDiscordUserId: x.winnerOwnerId ? discordIdByUserId.get(x.winnerOwnerId) : undefined,
loserDiscordUserId: x.showLoser && x.loserOwnerId ? discordIdByUserId.get(x.loserOwnerId) : undefined,
}));
}
}

View file

@ -186,6 +186,29 @@ describe("sendStandingsUpdateNotification", () => {
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. The calculator now sets winnerUsername unconditionally
// on any match that passes the notability gate.
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 () => {
await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL,