Refactor standings notifications to show only changed teams (#182)

* 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 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-03-19 15:52:57 -07:00 committed by GitHub
parent 52e7c98805
commit d271cc6792
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 218 additions and 79 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);
@ -1296,10 +1299,15 @@ export async function recalculateAffectedLeagues(
orderBy: (ts, { asc }) => [asc(ts.currentRank)], orderBy: (ts, { asc }) => [asc(ts.currentRank)],
}); });
const hasChanges = afterStandings.some((s) => { const changedTeamIds = new Set(
const prev = previousPointsById.get(s.teamId); afterStandings
return prev === undefined || prev !== parseFloat(s.totalPoints); .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 // Look up the league's Discord webhook URL via the season
const season = await db.query.seasons.findFirst({ const season = await db.query.seasons.findFirst({
@ -1330,12 +1338,10 @@ export async function recalculateAffectedLeagues(
afterStandings.map((s) => [s.teamId, s.team.ownerId]) afterStandings.map((s) => [s.teamId, s.team.ownerId])
); );
// Filter matches to only those involving participants drafted in this season. // Build scored matches for the notification — computed before the gate
// We compute this BEFORE the hasChanges gate because 0-point eliminations // because an elimination (0 points, no score change) still warrants a
// (e.g. R64 losers whose finalPosition=0 doesn't affect team scores) still // notification when a drafted participant is knocked out.
// warrant a Discord notification when a drafted team is eliminated.
let scoredMatches: ScoredMatch[] | undefined; let scoredMatches: ScoredMatch[] | undefined;
let hasDraftedParticipantMatches = false;
if (allCompletedMatches.length > 0) { if (allCompletedMatches.length > 0) {
const draftPicks = await db.query.draftPicks.findMany({ const draftPicks = await db.query.draftPicks.findMany({
where: eq(schema.draftPicks.seasonId, seasonId), where: eq(schema.draftPicks.seasonId, seasonId),
@ -1348,16 +1354,22 @@ export async function recalculateAffectedLeagues(
(m.loserId && draftedIds.has(m.loserId)) (m.loserId && draftedIds.has(m.loserId))
); );
hasDraftedParticipantMatches = relevant.length > 0;
if (relevant.length > 0) { if (relevant.length > 0) {
const teamIdByParticipantId = new Map( const teamIdByParticipantId = new Map(
draftPicks.map((p) => [p.participantId, p.teamId]) 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; if (!participantId) return undefined;
const teamId = teamIdByParticipantId.get(participantId); const teamId = teamIdByParticipantId.get(participantId);
if (!teamId) return undefined; if (!teamId) return undefined;
if (requireScoreChange && !changedTeamIds.has(teamId)) return undefined;
const ownerId = ownerIdByTeamId.get(teamId); const ownerId = ownerIdByTeamId.get(teamId);
if (!ownerId) return undefined; if (!ownerId) return undefined;
return usernameByClerkId.get(ownerId); return usernameByClerkId.get(ownerId);
@ -1368,14 +1380,17 @@ export async function recalculateAffectedLeagues(
.map((m) => ({ .map((m) => ({
winnerName: m.winnerName!, winnerName: m.winnerName!,
loserName: m.loserName!, loserName: m.loserName!,
winnerUsername: usernameForParticipant(m.winnerId), winnerUsername: usernameForParticipant(m.winnerId, true),
loserUsername: usernameForParticipant(m.loserId), loserUsername: usernameForParticipant(m.loserId),
})); }));
} }
} }
// Skip notification if scores didn't change AND no drafted participants were scored. // Skip notification if scores didn't change AND no match has a displayable username.
if (!hasChanges && !hasDraftedParticipantMatches) continue; const hasScoredMatchesToShow = scoredMatches?.some(
(m) => m.winnerUsername !== undefined || m.loserUsername !== undefined
);
if (!hasChanges && !hasScoredMatchesToShow) continue;
const standings = afterStandings.map((s) => ({ const standings = afterStandings.map((s) => ({
teamId: s.teamId, teamId: s.teamId,
@ -1391,6 +1406,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;
const sign = diff > 0 ? "+" : ""; });
delta = ` **(${sign}${diff} pts)**`;
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; const MAX_DESCRIPTION = 4096;