From d271cc6792975e6b10134452d903369bb31fc2cc Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:52:57 -0700 Subject: [PATCH] Refactor standings notifications to show only changed teams (#182) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refine Discord webhook scoring notifications (fixes #180, #181) - Filter scored matches to only show matchups where a manager scores Brackt points (winner drafted) or has a team eliminated (loser drafted); matches with no manager involvement are suppressed entirely. - Escape Discord markdown characters (_*~`|\) in team names and usernames to prevent formatting issues (e.g. double-underscore names causing unintended underlines). - Replace full standings with a "Standings Changes" section that shows only teams whose points changed, plus any teams whose rank shifted as a result, with ↑N / ↓N indicators for rank movement. - Pass previousRanks map from scoring-calculator to the notification so rank-displaced teams (who didn't score points themselves) are included. - Update test webhook in league settings to demonstrate rank changes and a sample scored match. - Update all discord service tests to cover the new filtering, escaping, and standings-change behaviour. https://claude.ai/code/session_01FciwfdG9Sfr5ZrXUHPeB18 * fix: only set winnerUsername when winning team's score actually changed Previously, winnerUsername was set for any drafted winner regardless of whether points were actually scored. This caused R64 wins (where the scoring system may not award points until later rounds) to appear in Discord's Scored Matches section even when no Brackt points were earned. Now winnerUsername is only set when the winner's team's totalPoints changed after recalculation. loserUsername (eliminations) is always set when the loser is drafted, since being knocked out is always notable. https://claude.ai/code/session_01FciwfdG9Sfr5ZrXUHPeB18 * Refactor Discord notification logic in scoring calculator - Compute changedTeamIds once up front; derive hasChanges from it instead of duplicating the same iteration - Merge usernameForParticipant and winnerUsernameForParticipant into a single function with a requireScoreChange flag - Replace hasDraftedParticipantMatches with hasScoredMatchesToShow, which checks that at least one scoredMatch has a displayable username β€” prevents sending a contentless Discord embed when a drafted winner doesn't score any points and the loser isn't drafted - Update inline comment to be sport-agnostic https://claude.ai/code/session_01FciwfdG9Sfr5ZrXUHPeB18 --------- Co-authored-by: Claude --- app/models/scoring-calculator.ts | 46 ++++-- app/routes/leagues/$leagueId.settings.tsx | 8 ++ app/services/__tests__/discord.test.ts | 168 ++++++++++++++++------ app/services/discord.ts | 75 +++++++--- 4 files changed, 218 insertions(+), 79 deletions(-) diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index f8d43be..4ea65c9 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -1280,6 +1280,9 @@ export async function recalculateAffectedLeagues( const previousPointsById = new Map( beforeStandings.map((s) => [s.teamId, parseFloat(s.totalPoints)]) ); + const previousRanksById = new Map( + beforeStandings.map((s) => [s.teamId, s.currentRank]) + ); await recalculateStandings(seasonId, db); @@ -1296,10 +1299,15 @@ export async function recalculateAffectedLeagues( orderBy: (ts, { asc }) => [asc(ts.currentRank)], }); - const hasChanges = afterStandings.some((s) => { - const prev = previousPointsById.get(s.teamId); - return prev === undefined || prev !== parseFloat(s.totalPoints); - }); + const changedTeamIds = new Set( + afterStandings + .filter((s) => { + const prev = previousPointsById.get(s.teamId); + return prev === undefined || prev !== parseFloat(s.totalPoints); + }) + .map((s) => s.teamId) + ); + const hasChanges = changedTeamIds.size > 0; // Look up the league's Discord webhook URL via the season const season = await db.query.seasons.findFirst({ @@ -1330,12 +1338,10 @@ export async function recalculateAffectedLeagues( afterStandings.map((s) => [s.teamId, s.team.ownerId]) ); - // Filter matches to only those involving participants drafted in this season. - // We compute this BEFORE the hasChanges gate because 0-point eliminations - // (e.g. R64 losers whose finalPosition=0 doesn't affect team scores) still - // warrant a Discord notification when a drafted team is eliminated. + // Build scored matches for the notification β€” computed before the gate + // because an elimination (0 points, no score change) still warrants a + // notification when a drafted participant is knocked out. let scoredMatches: ScoredMatch[] | undefined; - let hasDraftedParticipantMatches = false; if (allCompletedMatches.length > 0) { const draftPicks = await db.query.draftPicks.findMany({ where: eq(schema.draftPicks.seasonId, seasonId), @@ -1348,16 +1354,22 @@ export async function recalculateAffectedLeagues( (m.loserId && draftedIds.has(m.loserId)) ); - hasDraftedParticipantMatches = relevant.length > 0; - if (relevant.length > 0) { const teamIdByParticipantId = new Map( draftPicks.map((p) => [p.participantId, p.teamId]) ); - const usernameForParticipant = (participantId: string | null | undefined) => { + + // Look up the fantasy owner's display name for a participant. + // Pass requireScoreChange=true for the winner to avoid surfacing wins + // that didn't award points this round. + const usernameForParticipant = ( + participantId: string | null | undefined, + requireScoreChange = false + ) => { if (!participantId) return undefined; const teamId = teamIdByParticipantId.get(participantId); if (!teamId) return undefined; + if (requireScoreChange && !changedTeamIds.has(teamId)) return undefined; const ownerId = ownerIdByTeamId.get(teamId); if (!ownerId) return undefined; return usernameByClerkId.get(ownerId); @@ -1368,14 +1380,17 @@ export async function recalculateAffectedLeagues( .map((m) => ({ winnerName: m.winnerName!, loserName: m.loserName!, - winnerUsername: usernameForParticipant(m.winnerId), + winnerUsername: usernameForParticipant(m.winnerId, true), loserUsername: usernameForParticipant(m.loserId), })); } } - // Skip notification if scores didn't change AND no drafted participants were scored. - if (!hasChanges && !hasDraftedParticipantMatches) continue; + // Skip notification if scores didn't change AND no match has a displayable username. + const hasScoredMatchesToShow = scoredMatches?.some( + (m) => m.winnerUsername !== undefined || m.loserUsername !== undefined + ); + if (!hasChanges && !hasScoredMatchesToShow) continue; const standings = afterStandings.map((s) => ({ teamId: s.teamId, @@ -1391,6 +1406,7 @@ export async function recalculateAffectedLeagues( seasonName: `${season.league.name} ${season.year}`, standings, previousStandings: previousPointsById, + previousRanks: previousRanksById, sportName, eventName: options?.eventName, scoredMatches, diff --git a/app/routes/leagues/$leagueId.settings.tsx b/app/routes/leagues/$leagueId.settings.tsx index e68a877..451c596 100644 --- a/app/routes/leagues/$leagueId.settings.tsx +++ b/app/routes/leagues/$leagueId.settings.tsx @@ -247,7 +247,15 @@ export async function action(args: Route.ActionArgs) { ["2", 125], ["3", 100], ]), + previousRanks: new Map([ + ["1", 2], + ["2", 1], + ["3", 3], + ]), eventName: "Test Notification", + scoredMatches: [ + { winnerName: "Team Alpha", loserName: "Team Beta", winnerUsername: "manager1", loserUsername: "manager2" }, + ], }); return { testSuccess: true }; } catch (err) { diff --git a/app/services/__tests__/discord.test.ts b/app/services/__tests__/discord.test.ts index b9138dc..b8b8660 100644 --- a/app/services/__tests__/discord.test.ts +++ b/app/services/__tests__/discord.test.ts @@ -52,14 +52,14 @@ describe("sendStandingsUpdateNotification", () => { webhookUrl: WEBHOOK_URL, seasonName: "My League 2025", standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 150, rank: 1 }], - previousStandings: new Map(), + previousStandings: new Map([["a", 125]]), }); const body = JSON.parse((fetch as ReturnType).mock.calls[0][1].body); expect(body.embeds[0].title).toBe("πŸ“Š Standings Update β€” My League 2025"); }); - it("shows point deltas as integers in description", async () => { + it("shows point deltas as integers in standings changes", async () => { await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "Test League", @@ -77,16 +77,16 @@ describe("sendStandingsUpdateNotification", () => { expect(desc).toContain("Alpha"); expect(desc).toContain("+25 pts"); expect(desc).not.toContain("+25.0"); - // Beta didn't change β€” no delta shown - expect(desc).toMatch(/Beta.*100 pts(?!\s*\()/); + // Beta didn't change β€” not shown in standings changes + expect(desc).not.toContain("Beta"); }); it("includes sport name and event name in header when both provided", async () => { await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "My League 2025", - standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }], - previousStandings: new Map(), + standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }], + previousStandings: new Map([["a", 100]]), sportName: "UEFA Champions League", eventName: "Knockout Stage", }); @@ -99,8 +99,8 @@ describe("sendStandingsUpdateNotification", () => { await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "My League 2025", - standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }], - previousStandings: new Map(), + standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }], + previousStandings: new Map([["a", 100]]), eventName: "Quarter Finals", }); @@ -113,37 +113,36 @@ describe("sendStandingsUpdateNotification", () => { await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "My League 2025", - standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }], - previousStandings: new Map(), + standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }], + previousStandings: new Map([["a", 100]]), }); const desc = getDescription(); - // Description should start directly with the standings section - expect(desc).toContain("**Current Standings**"); + expect(desc).toContain("**Standings Changes**"); expect(desc).not.toContain("**Scored Matches**"); }); - it("lists scored matches above standings", async () => { + it("lists scored matches above standings changes", async () => { await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "My League 2025", - standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }], - previousStandings: new Map(), + standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }], + previousStandings: new Map([["a", 100]]), scoredMatches: [ - { winnerName: "Real Madrid", loserName: "Bayern Munich" }, - { winnerName: "Arsenal", loserName: "PSG" }, + { winnerName: "Real Madrid", loserName: "Bayern Munich", winnerUsername: "manager1" }, + { winnerName: "Arsenal", loserName: "PSG", loserUsername: "manager2" }, ], }); const desc = getDescription(); expect(desc).toContain("**Scored Matches**"); - expect(desc).toContain("β€’ **Real Madrid** def. Bayern Munich"); - expect(desc).toContain("β€’ **Arsenal** def. PSG"); + expect(desc).toContain("β€’ **Real Madrid (manager1)** def. Bayern Munich"); + expect(desc).toContain("β€’ **Arsenal** def. PSG (manager2)"); // Scored matches section should appear before standings - expect(desc.indexOf("Scored Matches")).toBeLessThan(desc.indexOf("Current Standings")); + expect(desc.indexOf("Scored Matches")).toBeLessThan(desc.indexOf("Standings Changes")); }); - it("shows username in parentheses in standings when provided", async () => { + it("shows username in parentheses in standings changes when provided", async () => { await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "My League 2025", @@ -151,21 +150,24 @@ describe("sendStandingsUpdateNotification", () => { { teamId: "a", teamName: "Alpha FC", username: "christhrowsrocks", totalPoints: 150, rank: 1 }, { teamId: "b", teamName: "Beta United", totalPoints: 100, rank: 2 }, ], - previousStandings: new Map(), + previousStandings: new Map([ + ["a", 125], + ["b", 100], + ]), }); const desc = getDescription(); expect(desc).toContain("1. Alpha FC (christhrowsrocks) β€” 150 pts"); - expect(desc).toContain("2. Beta United β€” 100 pts"); - expect(desc).not.toContain("Beta United ("); + // Beta didn't change points or rank, so it's not shown + expect(desc).not.toContain("Beta United"); }); it("shows usernames in parentheses in match results when both are drafted", async () => { await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "My League 2025", - standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }], - previousStandings: new Map(), + standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }], + previousStandings: new Map([["a", 100]]), scoredMatches: [ { winnerName: "Sporting", @@ -180,27 +182,27 @@ describe("sendStandingsUpdateNotification", () => { expect(desc).toContain("β€’ **Sporting (christhrowsrocks)** def. BodΓΈ/Glimt (apatel)"); }); - it("shows no parentheses in match results when neither side has a username", async () => { + it("omits scored matches where neither side has a username", async () => { await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "My League 2025", - standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }], - previousStandings: new Map(), + standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }], + previousStandings: new Map([["a", 100]]), scoredMatches: [{ winnerName: "Real Madrid", loserName: "Bayern Munich" }], }); const desc = getDescription(); - expect(desc).toContain("β€’ **Real Madrid** def. Bayern Munich"); - expect(desc).not.toContain("Real Madrid ("); - expect(desc).not.toContain("Bayern Munich ("); + expect(desc).not.toContain("**Scored Matches**"); + expect(desc).not.toContain("Real Madrid"); + expect(desc).not.toContain("Bayern Munich"); }); it("omits username parenthesis only for the undrafted side in match results", async () => { await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "My League 2025", - standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }], - previousStandings: new Map(), + standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }], + previousStandings: new Map([["a", 100]]), scoredMatches: [ { winnerName: "Sporting", @@ -215,7 +217,7 @@ describe("sendStandingsUpdateNotification", () => { expect(desc).not.toContain("BodΓΈ/Glimt ("); }); - it("uses plain numbers for all ranks in description", async () => { + it("shows teams that changed rank even without a point change", async () => { await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "My League 2025", @@ -223,27 +225,107 @@ describe("sendStandingsUpdateNotification", () => { { teamId: "a", teamName: "Alpha", totalPoints: 150, rank: 1 }, { teamId: "b", teamName: "Beta", totalPoints: 125, rank: 2 }, { teamId: "c", teamName: "Gamma", totalPoints: 100, rank: 3 }, - { teamId: "d", teamName: "Delta", totalPoints: 75, rank: 4 }, ], - previousStandings: new Map(), + previousStandings: new Map([ + ["a", 125], + ["b", 125], + ["c", 100], + ]), + previousRanks: new Map([ + ["a", 2], + ["b", 1], + ["c", 3], + ]), }); const desc = getDescription(); - expect(desc).toContain("1. Alpha"); - expect(desc).toContain("2. Beta"); - expect(desc).toContain("3. Gamma"); - expect(desc).toContain("4. Delta"); + // Alpha gained points and moved up + expect(desc).toContain("Alpha"); + expect(desc).toContain("+25 pts"); + expect(desc).toContain("↑1"); + // Beta didn't gain points but was displaced β€” rank changed + expect(desc).toContain("Beta"); + expect(desc).toContain("↓1"); + // Gamma unchanged β€” not shown + expect(desc).not.toContain("Gamma"); + }); + + it("omits rank delta when no previousRanks provided", async () => { + await sendStandingsUpdateNotification({ + webhookUrl: WEBHOOK_URL, + seasonName: "My League 2025", + standings: [ + { teamId: "a", teamName: "Alpha", totalPoints: 150, rank: 1 }, + ], + previousStandings: new Map([["a", 125]]), + }); + + const desc = getDescription(); + expect(desc).toContain("Alpha"); + expect(desc).not.toContain("↑"); + expect(desc).not.toContain("↓"); + }); + + it("escapes Discord markdown characters in team names and usernames", async () => { + await sendStandingsUpdateNotification({ + webhookUrl: WEBHOOK_URL, + seasonName: "My League 2025", + standings: [ + { teamId: "a", teamName: "Team__Underline", username: "user__name", totalPoints: 150, rank: 1 }, + ], + previousStandings: new Map([["a", 125]]), + scoredMatches: [ + { + winnerName: "Winner*Bold*", + loserName: "Loser~Strike~", + winnerUsername: "user_one", + loserUsername: "user_two", + }, + ], + }); + + const desc = getDescription(); + expect(desc).toContain("Team\\_\\_Underline"); + expect(desc).toContain("user\\_\\_name"); + expect(desc).toContain("Winner\\*Bold\\*"); + expect(desc).toContain("Loser\\~Strike\\~"); + expect(desc).toContain("user\\_one"); + expect(desc).toContain("user\\_two"); }); it("does not show fields on the embed", async () => { await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "My League 2025", - standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }], - previousStandings: new Map(), + standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }], + previousStandings: new Map([["a", 100]]), }); const body = JSON.parse((fetch as ReturnType).mock.calls[0][1].body); expect(body.embeds[0].fields).toBeUndefined(); }); + + it("omits standings changes section when no teams changed", async () => { + await sendStandingsUpdateNotification({ + webhookUrl: WEBHOOK_URL, + seasonName: "My League 2025", + standings: [ + { teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }, + { teamId: "b", teamName: "Beta", totalPoints: 75, rank: 2 }, + ], + previousStandings: new Map([ + ["a", 100], + ["b", 75], + ]), + previousRanks: new Map([ + ["a", 1], + ["b", 2], + ]), + }); + + const desc = getDescription(); + expect(desc).not.toContain("**Standings Changes**"); + expect(desc).not.toContain("Alpha"); + expect(desc).not.toContain("Beta"); + }); }); diff --git a/app/services/discord.ts b/app/services/discord.ts index 6ee7d5c..5a8e4bd 100644 --- a/app/services/discord.ts +++ b/app/services/discord.ts @@ -26,6 +26,11 @@ export async function sendDiscordWebhook( } } +/** Escape characters that trigger Discord markdown formatting. */ +function escapeMarkdown(text: string): string { + return text.replace(/[_*~`|\\]/g, "\\$&"); +} + export interface StandingEntry { teamId: string; teamName: string; @@ -46,6 +51,7 @@ export async function sendStandingsUpdateNotification({ seasonName, standings, previousStandings, + previousRanks, sportName, eventName, scoredMatches, @@ -54,6 +60,7 @@ export async function sendStandingsUpdateNotification({ seasonName: string; standings: StandingEntry[]; previousStandings: Map; + previousRanks?: Map; sportName?: string; eventName?: string; scoredMatches?: ScoredMatch[]; @@ -66,32 +73,58 @@ export async function sendStandingsUpdateNotification({ sections.push(`**${parts.join(" β€” ")}**`); } - // Scored matches section - if (scoredMatches && scoredMatches.length > 0) { + // Scored matches section β€” only show matches where at least one manager + // (fantasy team owner) scored Brackt points or had a team eliminated. + const relevantMatches = scoredMatches?.filter( + (m) => m.winnerUsername !== undefined || m.loserUsername !== undefined + ); + if (relevantMatches && relevantMatches.length > 0) { sections.push("\n**Scored Matches**"); - for (const match of scoredMatches) { - const winner = match.winnerUsername - ? `${match.winnerName} (${match.winnerUsername})` - : match.winnerName; - const loser = match.loserUsername - ? `${match.loserName} (${match.loserUsername})` - : match.loserName; - sections.push(`β€’ **${winner}** def. ${loser}`); + for (const match of relevantMatches) { + const winnerLabel = match.winnerUsername + ? `${escapeMarkdown(match.winnerName)} (${escapeMarkdown(match.winnerUsername)})` + : escapeMarkdown(match.winnerName); + const loserLabel = match.loserUsername + ? `${escapeMarkdown(match.loserName)} (${escapeMarkdown(match.loserUsername)})` + : escapeMarkdown(match.loserName); + sections.push(`β€’ **${winnerLabel}** def. ${loserLabel}`); } } - // Standings section - sections.push("\n**Current Standings**"); - for (const s of standings) { - const prev = previousStandings.get(s.teamId); - let delta = ""; - if (prev !== undefined && prev !== s.totalPoints) { - const diff = Math.round(s.totalPoints - prev); - const sign = diff > 0 ? "+" : ""; - delta = ` **(${sign}${diff} pts)**`; + // 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; + }); + + if (changedTeams.length > 0) { + sections.push("\n**Standings Changes**"); + for (const s of changedTeams) { + const prevPoints = previousStandings.get(s.teamId); + let pointDelta = ""; + if (prevPoints !== undefined && prevPoints !== s.totalPoints) { + const diff = Math.round(s.totalPoints - prevPoints); + const sign = diff > 0 ? "+" : ""; + pointDelta = ` **(${sign}${diff} pts)**`; + } + + let rankDelta = ""; + if (previousRanks) { + const prevRank = previousRanks.get(s.teamId); + if (prevRank !== undefined && prevRank !== s.rank) { + const moved = prevRank - s.rank; // positive = moved up + rankDelta = moved > 0 ? ` ↑${moved}` : ` ↓${Math.abs(moved)}`; + } + } + + const escapedName = escapeMarkdown(s.teamName); + const escapedUsername = s.username ? escapeMarkdown(s.username) : undefined; + const label = escapedUsername ? `${escapedName} (${escapedUsername})` : escapedName; + sections.push(`${s.rank}. ${label} β€” ${Math.round(s.totalPoints)} pts${pointDelta}${rankDelta}`); } - const label = s.username ? `${s.teamName} (${s.username})` : s.teamName; - sections.push(`${s.rank}. ${label} β€” ${Math.round(s.totalPoints)} pts${delta}`); } const MAX_DESCRIPTION = 4096;