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(
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,

View file

@ -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) {

View file

@ -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<typeof vi.fn>).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<typeof vi.fn>).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");
});
});

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 {
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<string, number>;
previousRanks?: Map<string, number>;
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;