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;