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
This commit is contained in:
Claude 2026-03-19 21:54:29 +00:00
parent 52e7c98805
commit be9f30753f
No known key found for this signature in database
4 changed files with 191 additions and 64 deletions

View file

@ -1280,6 +1280,9 @@ export async function recalculateAffectedLeagues(
const previousPointsById = new Map( const previousPointsById = new Map(
beforeStandings.map((s) => [s.teamId, parseFloat(s.totalPoints)]) beforeStandings.map((s) => [s.teamId, parseFloat(s.totalPoints)])
); );
const previousRanksById = new Map(
beforeStandings.map((s) => [s.teamId, s.currentRank])
);
await recalculateStandings(seasonId, db); await recalculateStandings(seasonId, db);
@ -1391,6 +1394,7 @@ export async function recalculateAffectedLeagues(
seasonName: `${season.league.name} ${season.year}`, seasonName: `${season.league.name} ${season.year}`,
standings, standings,
previousStandings: previousPointsById, previousStandings: previousPointsById,
previousRanks: previousRanksById,
sportName, sportName,
eventName: options?.eventName, eventName: options?.eventName,
scoredMatches, scoredMatches,

View file

@ -247,7 +247,15 @@ export async function action(args: Route.ActionArgs) {
["2", 125], ["2", 125],
["3", 100], ["3", 100],
]), ]),
previousRanks: new Map([
["1", 2],
["2", 1],
["3", 3],
]),
eventName: "Test Notification", eventName: "Test Notification",
scoredMatches: [
{ winnerName: "Team Alpha", loserName: "Team Beta", winnerUsername: "manager1", loserUsername: "manager2" },
],
}); });
return { testSuccess: true }; return { testSuccess: true };
} catch (err) { } catch (err) {

View file

@ -52,14 +52,14 @@ describe("sendStandingsUpdateNotification", () => {
webhookUrl: WEBHOOK_URL, webhookUrl: WEBHOOK_URL,
seasonName: "My League 2025", seasonName: "My League 2025",
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 150, rank: 1 }], standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 150, rank: 1 }],
previousStandings: new Map(), previousStandings: new Map([["a", 125]]),
}); });
const body = JSON.parse((fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body); const body = JSON.parse((fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body);
expect(body.embeds[0].title).toBe("📊 Standings Update — My League 2025"); 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({ await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL, webhookUrl: WEBHOOK_URL,
seasonName: "Test League", seasonName: "Test League",
@ -77,16 +77,16 @@ describe("sendStandingsUpdateNotification", () => {
expect(desc).toContain("Alpha"); expect(desc).toContain("Alpha");
expect(desc).toContain("+25 pts"); expect(desc).toContain("+25 pts");
expect(desc).not.toContain("+25.0"); expect(desc).not.toContain("+25.0");
// Beta didn't change — no delta shown // Beta didn't change — not shown in standings changes
expect(desc).toMatch(/Beta.*100 pts(?!\s*\()/); expect(desc).not.toContain("Beta");
}); });
it("includes sport name and event name in header when both provided", async () => { it("includes sport name and event name in header when both provided", async () => {
await sendStandingsUpdateNotification({ await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL, webhookUrl: WEBHOOK_URL,
seasonName: "My League 2025", seasonName: "My League 2025",
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }], standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }],
previousStandings: new Map(), previousStandings: new Map([["a", 100]]),
sportName: "UEFA Champions League", sportName: "UEFA Champions League",
eventName: "Knockout Stage", eventName: "Knockout Stage",
}); });
@ -99,8 +99,8 @@ describe("sendStandingsUpdateNotification", () => {
await sendStandingsUpdateNotification({ await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL, webhookUrl: WEBHOOK_URL,
seasonName: "My League 2025", seasonName: "My League 2025",
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }], standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }],
previousStandings: new Map(), previousStandings: new Map([["a", 100]]),
eventName: "Quarter Finals", eventName: "Quarter Finals",
}); });
@ -113,37 +113,36 @@ describe("sendStandingsUpdateNotification", () => {
await sendStandingsUpdateNotification({ await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL, webhookUrl: WEBHOOK_URL,
seasonName: "My League 2025", seasonName: "My League 2025",
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }], standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }],
previousStandings: new Map(), previousStandings: new Map([["a", 100]]),
}); });
const desc = getDescription(); const desc = getDescription();
// Description should start directly with the standings section expect(desc).toContain("**Standings Changes**");
expect(desc).toContain("**Current Standings**");
expect(desc).not.toContain("**Scored Matches**"); expect(desc).not.toContain("**Scored Matches**");
}); });
it("lists scored matches above standings", async () => { it("lists scored matches above standings changes", async () => {
await sendStandingsUpdateNotification({ await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL, webhookUrl: WEBHOOK_URL,
seasonName: "My League 2025", seasonName: "My League 2025",
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }], standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }],
previousStandings: new Map(), previousStandings: new Map([["a", 100]]),
scoredMatches: [ scoredMatches: [
{ winnerName: "Real Madrid", loserName: "Bayern Munich" }, { winnerName: "Real Madrid", loserName: "Bayern Munich", winnerUsername: "manager1" },
{ winnerName: "Arsenal", loserName: "PSG" }, { winnerName: "Arsenal", loserName: "PSG", loserUsername: "manager2" },
], ],
}); });
const desc = getDescription(); const desc = getDescription();
expect(desc).toContain("**Scored Matches**"); expect(desc).toContain("**Scored Matches**");
expect(desc).toContain("• **Real Madrid** def. Bayern Munich"); expect(desc).toContain("• **Real Madrid (manager1)** def. Bayern Munich");
expect(desc).toContain("• **Arsenal** def. PSG"); expect(desc).toContain("• **Arsenal** def. PSG (manager2)");
// Scored matches section should appear before standings // 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({ await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL, webhookUrl: WEBHOOK_URL,
seasonName: "My League 2025", seasonName: "My League 2025",
@ -151,21 +150,24 @@ describe("sendStandingsUpdateNotification", () => {
{ teamId: "a", teamName: "Alpha FC", username: "christhrowsrocks", totalPoints: 150, rank: 1 }, { teamId: "a", teamName: "Alpha FC", username: "christhrowsrocks", totalPoints: 150, rank: 1 },
{ teamId: "b", teamName: "Beta United", totalPoints: 100, rank: 2 }, { teamId: "b", teamName: "Beta United", totalPoints: 100, rank: 2 },
], ],
previousStandings: new Map(), previousStandings: new Map([
["a", 125],
["b", 100],
]),
}); });
const desc = getDescription(); const desc = getDescription();
expect(desc).toContain("1. Alpha FC (christhrowsrocks) — 150 pts"); expect(desc).toContain("1. Alpha FC (christhrowsrocks) — 150 pts");
expect(desc).toContain("2. Beta United — 100 pts"); // Beta didn't change points or rank, so it's not shown
expect(desc).not.toContain("Beta United ("); expect(desc).not.toContain("Beta United");
}); });
it("shows usernames in parentheses in match results when both are drafted", async () => { it("shows usernames in parentheses in match results when both are drafted", async () => {
await sendStandingsUpdateNotification({ await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL, webhookUrl: WEBHOOK_URL,
seasonName: "My League 2025", seasonName: "My League 2025",
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }], standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }],
previousStandings: new Map(), previousStandings: new Map([["a", 100]]),
scoredMatches: [ scoredMatches: [
{ {
winnerName: "Sporting", winnerName: "Sporting",
@ -180,27 +182,27 @@ describe("sendStandingsUpdateNotification", () => {
expect(desc).toContain("• **Sporting (christhrowsrocks)** def. Bodø/Glimt (apatel)"); 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({ await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL, webhookUrl: WEBHOOK_URL,
seasonName: "My League 2025", seasonName: "My League 2025",
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }], standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }],
previousStandings: new Map(), previousStandings: new Map([["a", 100]]),
scoredMatches: [{ winnerName: "Real Madrid", loserName: "Bayern Munich" }], scoredMatches: [{ winnerName: "Real Madrid", loserName: "Bayern Munich" }],
}); });
const desc = getDescription(); const desc = getDescription();
expect(desc).toContain("• **Real Madrid** def. Bayern Munich"); expect(desc).not.toContain("**Scored Matches**");
expect(desc).not.toContain("Real Madrid ("); expect(desc).not.toContain("Real Madrid");
expect(desc).not.toContain("Bayern Munich ("); expect(desc).not.toContain("Bayern Munich");
}); });
it("omits username parenthesis only for the undrafted side in match results", async () => { it("omits username parenthesis only for the undrafted side in match results", async () => {
await sendStandingsUpdateNotification({ await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL, webhookUrl: WEBHOOK_URL,
seasonName: "My League 2025", seasonName: "My League 2025",
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }], standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }],
previousStandings: new Map(), previousStandings: new Map([["a", 100]]),
scoredMatches: [ scoredMatches: [
{ {
winnerName: "Sporting", winnerName: "Sporting",
@ -215,7 +217,7 @@ describe("sendStandingsUpdateNotification", () => {
expect(desc).not.toContain("Bodø/Glimt ("); 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({ await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL, webhookUrl: WEBHOOK_URL,
seasonName: "My League 2025", seasonName: "My League 2025",
@ -223,27 +225,107 @@ describe("sendStandingsUpdateNotification", () => {
{ teamId: "a", teamName: "Alpha", totalPoints: 150, rank: 1 }, { teamId: "a", teamName: "Alpha", totalPoints: 150, rank: 1 },
{ teamId: "b", teamName: "Beta", totalPoints: 125, rank: 2 }, { teamId: "b", teamName: "Beta", totalPoints: 125, rank: 2 },
{ teamId: "c", teamName: "Gamma", totalPoints: 100, rank: 3 }, { 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(); const desc = getDescription();
expect(desc).toContain("1. Alpha"); // Alpha gained points and moved up
expect(desc).toContain("2. Beta"); expect(desc).toContain("Alpha");
expect(desc).toContain("3. Gamma"); expect(desc).toContain("+25 pts");
expect(desc).toContain("4. Delta"); 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 () => { it("does not show fields on the embed", async () => {
await sendStandingsUpdateNotification({ await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL, webhookUrl: WEBHOOK_URL,
seasonName: "My League 2025", seasonName: "My League 2025",
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }], standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }],
previousStandings: new Map(), previousStandings: new Map([["a", 100]]),
}); });
const body = JSON.parse((fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body); const body = JSON.parse((fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body);
expect(body.embeds[0].fields).toBeUndefined(); 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");
});
}); });

View file

@ -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 { export interface StandingEntry {
teamId: string; teamId: string;
teamName: string; teamName: string;
@ -46,6 +51,7 @@ export async function sendStandingsUpdateNotification({
seasonName, seasonName,
standings, standings,
previousStandings, previousStandings,
previousRanks,
sportName, sportName,
eventName, eventName,
scoredMatches, scoredMatches,
@ -54,6 +60,7 @@ export async function sendStandingsUpdateNotification({
seasonName: string; seasonName: string;
standings: StandingEntry[]; standings: StandingEntry[];
previousStandings: Map<string, number>; previousStandings: Map<string, number>;
previousRanks?: Map<string, number>;
sportName?: string; sportName?: string;
eventName?: string; eventName?: string;
scoredMatches?: ScoredMatch[]; scoredMatches?: ScoredMatch[];
@ -66,32 +73,58 @@ export async function sendStandingsUpdateNotification({
sections.push(`**${parts.join(" — ")}**`); sections.push(`**${parts.join(" — ")}**`);
} }
// Scored matches section // Scored matches section — only show matches where at least one manager
if (scoredMatches && scoredMatches.length > 0) { // (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**"); sections.push("\n**Scored Matches**");
for (const match of scoredMatches) { for (const match of relevantMatches) {
const winner = match.winnerUsername const winnerLabel = match.winnerUsername
? `${match.winnerName} (${match.winnerUsername})` ? `${escapeMarkdown(match.winnerName)} (${escapeMarkdown(match.winnerUsername)})`
: match.winnerName; : escapeMarkdown(match.winnerName);
const loser = match.loserUsername const loserLabel = match.loserUsername
? `${match.loserName} (${match.loserUsername})` ? `${escapeMarkdown(match.loserName)} (${escapeMarkdown(match.loserUsername)})`
: match.loserName; : escapeMarkdown(match.loserName);
sections.push(`• **${winner}** def. ${loser}`); sections.push(`• **${winnerLabel}** def. ${loserLabel}`);
} }
} }
// Standings section // Standings changes section — show teams whose points or rank changed.
sections.push("\n**Current Standings**"); const changedTeams = standings.filter((s) => {
for (const s of standings) { const prevPoints = previousStandings.get(s.teamId);
const prev = previousStandings.get(s.teamId); const pointsChanged = prevPoints !== undefined && prevPoints !== s.totalPoints;
let delta = ""; const prevRank = previousRanks?.get(s.teamId);
if (prev !== undefined && prev !== s.totalPoints) { const rankChanged = prevRank !== undefined && prevRank !== s.rank;
const diff = Math.round(s.totalPoints - prev); 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 ? "+" : ""; const sign = diff > 0 ? "+" : "";
delta = ` **(${sign}${diff} pts)**`; 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; const MAX_DESCRIPTION = 4096;