diff --git a/app/services/__tests__/discord.test.ts b/app/services/__tests__/discord.test.ts index 242a114..786d49a 100644 --- a/app/services/__tests__/discord.test.ts +++ b/app/services/__tests__/discord.test.ts @@ -277,6 +277,43 @@ describe("sendStandingsUpdateNotification", () => { expect(desc).not.toContain("Gamma"); }); + it("pings scorers but not rank-only shufflers", async () => { + await sendStandingsUpdateNotification({ + webhookUrl: WEBHOOK_URL, + seasonName: "My League 2025", + standings: [ + // Alpha scored — points changed, moved up. + { teamId: "a", teamName: "Alpha", totalPoints: 150, rank: 1, discordUserId: "111" }, + // Beta was displaced down without scoring — rank changed only. + { teamId: "b", teamName: "Beta", totalPoints: 125, rank: 2, username: "beta_owner", discordUserId: "222" }, + ], + previousStandings: new Map([ + ["a", 125], + ["b", 125], + ]), + previousRanks: new Map([ + ["a", 2], + ["b", 1], + ]), + }); + + const desc = getDescription(); + // Beta is still displayed with its rank movement… + expect(desc).toContain("Beta"); + expect(desc).toContain("↓1"); + // …but by name, not as an @-mention. + expect(desc).not.toContain("<@222>"); + // Alpha scored, so it keeps its mention. + expect(desc).toContain("<@111>"); + + const payload = getPayload(); + // Only the scorer is pinged. + expect(payload.content).toContain("111"); + expect(payload.content).not.toContain("222"); + expect(payload.allowed_mentions.users).toContain("111"); + expect(payload.allowed_mentions.users).not.toContain("222"); + }); + it("omits rank delta when no previousRanks provided", async () => { await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL, diff --git a/app/services/discord.ts b/app/services/discord.ts index 501ef77..6023bc5 100644 --- a/app/services/discord.ts +++ b/app/services/discord.ts @@ -175,13 +175,19 @@ export async function sendStandingsUpdateNotification({ const isTied = buildTiedRankChecker(standings.map((s) => s.rank)); const rankLabel = (rank: number) => (isTied(rank) ? `T${rank}` : `${rank}`); + // A team's points genuinely changed this event (vs. merely being displaced in + // rank because someone else scored). Only real scorers are pinged; rank-only + // shufflers are still displayed, but by name and without an @-mention. + const pointsChanged = (s: StandingEntry) => { + const prevPoints = previousStandings.get(s.teamId); + return prevPoints !== undefined && prevPoints !== s.totalPoints; + }; + // Standings changes section — show teams whose points or rank changed. const changedTeams = standings.filter((s) => { - const prevPoints = previousStandings.get(s.teamId); - const pointsChanged = prevPoints !== undefined && prevPoints !== s.totalPoints; const prevRank = previousRanks?.get(s.teamId); const rankChanged = prevRank !== undefined && prevRank !== s.rank; - return pointsChanged || rankChanged; + return pointsChanged(s) || rankChanged; }); if (changedTeams.length > 0) { @@ -206,7 +212,7 @@ export async function sendStandingsUpdateNotification({ } const escapedName = escapeMarkdown(s.teamName); - const managerLabel = s.discordUserId + const managerLabel = s.discordUserId && pointsChanged(s) ? `<@${s.discordUserId}>` : s.username ? escapeMarkdown(s.username) @@ -225,7 +231,7 @@ export async function sendStandingsUpdateNotification({ // Collect Discord user IDs of all opted-in managers appearing in this notification. const pingUserIds = new Set(); for (const s of changedTeams) { - if (s.discordUserId) pingUserIds.add(s.discordUserId); + if (s.discordUserId && pointsChanged(s)) pingUserIds.add(s.discordUserId); } for (const m of relevantMatches ?? []) { if (m.winnerDiscordUserId) pingUserIds.add(m.winnerDiscordUserId);