Restructure qualifying-points Discord embed

- Drop the "+" prefix on Points Awarded values (they are totals awarded,
  not increments).
- Rename "No Points" to "Non-scoring Participants" and format each line as
  "Name (seasonTotal, manager)" using the plain manager name.
- Rename "QP Standings" to "Drafted Participants in Top 8" and limit it to
  participants in the top 8 of the full field (plus ties), with plain
  manager names.
- Ping only Points Awarded and Knocked Out managers; non-scoring managers
  are no longer pinged.
- Link the embed title to the league's sport-season standings page and
  remove the plain "brackt.com" footer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011U7mTxDVK2cH8E2BWjCiUv
This commit is contained in:
Claude 2026-07-09 05:12:40 +00:00
parent 036411e9d8
commit 3f16d5e1d3
No known key found for this signature in database
4 changed files with 82 additions and 64 deletions

View file

@ -747,17 +747,19 @@ describe("sendQualifyingPointsUpdateNotification", () => {
{ participantName: "Rafael Nadal", qpEarned: 0, qpTotal: 20, ownerUsername: "alex" }, { participantName: "Rafael Nadal", qpEarned: 0, qpTotal: 20, ownerUsername: "alex" },
]); ]);
it("sends an embed with gold color and QP title", async () => { it("sends an embed with gold color, QP title, no footer, and links the title to the standings page", async () => {
await sendQualifyingPointsUpdateNotification({ await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL, webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025", seasonName: "Slam League 2025",
entries: BASE_ENTRIES, entries: BASE_ENTRIES,
standingsUrl: "https://brackt.com/leagues/abc/sports-seasons/ss-1",
}); });
const payload = getPayload(); const payload = getPayload();
expect(payload.embeds[0].title).toBe("🏅 Qualifying Points Update — Slam League 2025"); expect(payload.embeds[0].title).toBe("🏅 Qualifying Points Update — Slam League 2025");
expect(payload.embeds[0].color).toBe(0x5865f2); expect(payload.embeds[0].color).toBe(0x5865f2);
expect(payload.embeds[0].footer).toEqual({ text: "brackt.com" }); expect(payload.embeds[0].url).toBe("https://brackt.com/leagues/abc/sports-seasons/ss-1");
expect(payload.embeds[0].footer).toBeUndefined();
}); });
it("shows sport and event header", async () => { it("shows sport and event header", async () => {
@ -782,13 +784,13 @@ describe("sendQualifyingPointsUpdateNotification", () => {
const desc = getDescription(); const desc = getDescription();
expect(desc).toContain("**Points Awarded**"); expect(desc).toContain("**Points Awarded**");
expect(desc).toContain("• **Novak Djokovic (alex)** — +20 QP"); expect(desc).toContain("• **Novak Djokovic (alex)** — 20 QP");
expect(desc).toContain("• **Carlos Alcaraz (chris)** — +14 QP"); expect(desc).toContain("• **Carlos Alcaraz (chris)** — 14 QP");
// Djokovic (20 QP) should appear before Alcaraz (14 QP) // Djokovic (20 QP) should appear before Alcaraz (14 QP)
expect(desc.indexOf("Novak Djokovic")).toBeLessThan(desc.indexOf("Carlos Alcaraz")); expect(desc.indexOf("Novak Djokovic")).toBeLessThan(desc.indexOf("Carlos Alcaraz"));
}); });
it("shows No Points section for entries with qpEarned == 0", async () => { it("shows Non-scoring Participants section with season total and plain manager name", async () => {
await sendQualifyingPointsUpdateNotification({ await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL, webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025", seasonName: "Slam League 2025",
@ -796,11 +798,12 @@ describe("sendQualifyingPointsUpdateNotification", () => {
}); });
const desc = getDescription(); const desc = getDescription();
expect(desc).toContain("**No Points**"); expect(desc).toContain("**Non-scoring Participants**");
expect(desc).toContain("Rafael Nadal (alex)"); // Name (seasonTotal, manager) — Rafael has a 20 QP running total, owner "alex"
expect(desc).toContain("• Rafael Nadal (20, alex)");
}); });
it("shows QP Standings for all entries sorted by qpTotal desc", async () => { it("shows Drafted Participants in Top 8 for entries sorted by qpTotal desc", async () => {
await sendQualifyingPointsUpdateNotification({ await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL, webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025", seasonName: "Slam League 2025",
@ -808,7 +811,7 @@ describe("sendQualifyingPointsUpdateNotification", () => {
}); });
const desc = getDescription(); const desc = getDescription();
expect(desc).toContain("**QP Standings**"); expect(desc).toContain("**Drafted Participants in Top 8**");
expect(desc).toContain("1\\. Novak Djokovic (alex) — 45 QP"); expect(desc).toContain("1\\. Novak Djokovic (alex) — 45 QP");
expect(desc).toContain("2\\. Carlos Alcaraz (chris) — 34 QP"); expect(desc).toContain("2\\. Carlos Alcaraz (chris) — 34 QP");
expect(desc).toContain("3\\. Rafael Nadal (alex) — 20 QP"); expect(desc).toContain("3\\. Rafael Nadal (alex) — 20 QP");
@ -855,16 +858,15 @@ describe("sendQualifyingPointsUpdateNotification", () => {
const desc = getDescription(); const desc = getDescription();
// Mirrors the web UI's formatQP: fractional QP renders with 2 decimals ("1.50"), // Mirrors the web UI's formatQP: fractional QP renders with 2 decimals ("1.50"),
// never rounded to an integer. // never rounded to an integer. The Points Awarded line no longer carries a "+".
expect(desc).toContain("+1.50 QP"); expect(desc).toContain("• **Novak Djokovic (snarkymcgee)** — 1.50 QP");
expect(desc).not.toContain("+2 QP"); expect(desc).not.toContain("+");
expect(desc).toContain("1.50 QP"); expect(desc).not.toContain("2 QP");
expect(desc).not.toContain("— 2 QP");
}); });
it("ranks the standings by caller-provided full-field globalRank, not position among entries", async () => { it("excludes participants ranked outside the top 8 from the Drafted Participants section", async () => {
// Screenshot scenario: only two players scored this event, but they sit at T9 in // Only the top 8 (plus ties) of the full season field belong in this section, so a
// the full season field (8 players ahead). They must show T9, not T1. // rank-9 scorer must NOT appear in it — even though it still shows in Points Awarded.
await sendQualifyingPointsUpdateNotification({ await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL, webhookUrl: WEBHOOK_URL,
seasonName: "Rumble League 2026", seasonName: "Rumble League 2026",
@ -872,28 +874,31 @@ describe("sendQualifyingPointsUpdateNotification", () => {
eventName: "Wimbledon", eventName: "Wimbledon",
entries: [ entries: [
{ {
participantName: "Novak Djokovic", participantName: "Player Eight",
qpEarned: 1.5, qpEarned: 5,
qpTotal: 1.5, qpTotal: 10,
globalRank: 9, globalRank: 8,
globalRankTied: true, globalRankTied: false,
ownerUsername: "snarkymcgee", ownerUsername: "eighthowner",
}, },
{ {
participantName: "Jannik Sinner", participantName: "Player Nine",
qpEarned: 1.5, qpEarned: 3,
qpTotal: 1.5, qpTotal: 8,
globalRank: 9, globalRank: 9,
globalRankTied: true, globalRankTied: false,
ownerUsername: "tanay002", ownerUsername: "ninthowner",
}, },
], ],
}); });
const desc = getDescription(); const desc = getDescription();
expect(desc).toContain("T9\\. Novak Djokovic (snarkymcgee) — 1.50 QP"); expect(desc).toContain("**Drafted Participants in Top 8**");
expect(desc).toContain("T9\\. Jannik Sinner (tanay002) — 1.50 QP"); expect(desc).toContain("8\\. Player Eight (eighthowner) — 10 QP");
expect(desc).not.toContain("T1\\."); // The rank-9 player is filtered out of the standings section entirely.
expect(desc).not.toContain("9\\. Player Nine");
// ...but both still earned points, so both remain in Points Awarded.
expect(desc).toContain("• **Player Nine (ninthowner)** — 3 QP");
}); });
it("uses Discord mention instead of username when ownerDiscordUserId provided", async () => { it("uses Discord mention instead of username when ownerDiscordUserId provided", async () => {
@ -912,11 +917,13 @@ describe("sendQualifyingPointsUpdateNotification", () => {
}); });
const desc = getDescription(); const desc = getDescription();
expect(desc).toContain("Novak Djokovic (<@111222333>)"); // Points Awarded (a pinged section) uses the Discord mention...
expect(desc).not.toContain("(alex)"); expect(desc).toContain("• **Novak Djokovic (<@111222333>)** — 20 QP");
// ...while the (non-pinged) Top 8 standings section uses the plain username.
expect(desc).toContain("1\\. Novak Djokovic (alex) — 45 QP");
}); });
it("pings opted-in owners who appear in awarded or no-points sections", async () => { it("pings awarded owners but not non-scoring owners", async () => {
await sendQualifyingPointsUpdateNotification({ await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL, webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025", seasonName: "Slam League 2025",
@ -928,9 +935,9 @@ describe("sendQualifyingPointsUpdateNotification", () => {
const payload = getPayload(); const payload = getPayload();
expect(payload.content).toContain("<@111>"); expect(payload.content).toContain("<@111>");
expect(payload.content).toContain("<@222>"); expect(payload.content).not.toContain("<@222>");
expect(payload.allowed_mentions.users).toContain("111"); expect(payload.allowed_mentions.users).toContain("111");
expect(payload.allowed_mentions.users).toContain("222"); expect(payload.allowed_mentions.users).not.toContain("222");
}); });
it("does not send when entries array is empty", async () => { it("does not send when entries array is empty", async () => {
@ -956,7 +963,7 @@ describe("sendQualifyingPointsUpdateNotification", () => {
expect(desc).toContain("• Jakob Mensik (chris)"); expect(desc).toContain("• Jakob Mensik (chris)");
}); });
it("sends with only a Knocked Out section when there are no QP entries, omitting QP Standings", async () => { it("sends with only a Knocked Out section when there are no QP entries, omitting the standings section", async () => {
await sendQualifyingPointsUpdateNotification({ await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL, webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025", seasonName: "Slam League 2025",
@ -967,7 +974,7 @@ describe("sendQualifyingPointsUpdateNotification", () => {
expect(fetch).toHaveBeenCalledOnce(); expect(fetch).toHaveBeenCalledOnce();
const desc = getDescription(); const desc = getDescription();
expect(desc).toContain("**Knocked Out**"); expect(desc).toContain("**Knocked Out**");
expect(desc).not.toContain("**QP Standings**"); expect(desc).not.toContain("**Drafted Participants in Top 8**");
expect(desc).not.toContain("**Points Awarded**"); expect(desc).not.toContain("**Points Awarded**");
}); });

View file

@ -27,6 +27,7 @@ import { findDiscordIdsByUserIds } from "~/models/account";
const SPORTS_SEASON_ID = "ss-1"; const SPORTS_SEASON_ID = "ss-1";
const SCORING_EVENT_ID = "ev-1"; const SCORING_EVENT_ID = "ev-1";
const SEASON_ID = "season-1"; const SEASON_ID = "season-1";
const LEAGUE_ID = "league-1";
const PARTICIPANT_ID = "p-1"; const PARTICIPANT_ID = "p-1";
const OWNER_ID = "u-1"; const OWNER_ID = "u-1";
const WEBHOOK_URL = "https://discord.com/api/webhooks/123/abc"; const WEBHOOK_URL = "https://discord.com/api/webhooks/123/abc";
@ -68,7 +69,7 @@ function makeDb(overrides: Record<string, unknown> = {}) {
{ {
id: SEASON_ID, id: SEASON_ID,
year: 2025, year: 2025,
league: { name: "Slam League", discordWebhookUrl: WEBHOOK_URL }, league: { id: LEAGUE_ID, name: "Slam League", discordWebhookUrl: WEBHOOK_URL },
}, },
]), ]),
}, },
@ -176,6 +177,7 @@ describe("notifyQualifyingPointsUpdate", () => {
seasonName: "Slam League 2025", seasonName: "Slam League 2025",
eventName: "Roland Garros 2025", eventName: "Roland Garros 2025",
sportName: "Tennis", sportName: "Tennis",
standingsUrl: `${process.env.APP_URL ?? "https://brackt.com"}/leagues/${LEAGUE_ID}/sports-seasons/${SPORTS_SEASON_ID}`,
entries: [ entries: [
expect.objectContaining({ expect.objectContaining({
participantName: "Carlos Alcaraz", participantName: "Carlos Alcaraz",

View file

@ -294,6 +294,7 @@ export async function sendQualifyingPointsUpdateNotification({
eventName, eventName,
entries, entries,
eliminated = [], eliminated = [],
standingsUrl,
}: { }: {
webhookUrl: string; webhookUrl: string;
seasonName: string; seasonName: string;
@ -301,6 +302,7 @@ export async function sendQualifyingPointsUpdateNotification({
eventName?: string; eventName?: string;
entries: QPEventEntry[]; entries: QPEventEntry[];
eliminated?: QPEliminatedEntry[]; eliminated?: QPEliminatedEntry[];
standingsUrl?: string;
}): Promise<void> { }): Promise<void> {
if (entries.length === 0 && eliminated.length === 0) return; if (entries.length === 0 && eliminated.length === 0) return;
@ -326,23 +328,21 @@ export async function sendQualifyingPointsUpdateNotification({
const label = managerLabel const label = managerLabel
? `${escapeMarkdown(e.participantName)} (${managerLabel})` ? `${escapeMarkdown(e.participantName)} (${managerLabel})`
: escapeMarkdown(e.participantName); : escapeMarkdown(e.participantName);
sections.push(`• **${label}** — +${formatQPValue(e.qpEarned)} QP`); sections.push(`• **${label}** — ${formatQPValue(e.qpEarned)} QP`);
} }
} }
const zeroEntries = entries.filter((e) => e.qpEarned === 0); const zeroEntries = entries.filter((e) => e.qpEarned === 0);
if (zeroEntries.length > 0) { if (zeroEntries.length > 0) {
sections.push("\n**No Points**"); sections.push("\n**Non-scoring Participants**");
for (const e of zeroEntries) { for (const e of zeroEntries) {
const managerLabel = e.ownerDiscordUserId // Plain manager username here (never a <@id> mention): this section is not
? `<@${e.ownerDiscordUserId}>` // pinged, so mention chips would misleadingly look like a ping. Show the
: e.ownerUsername // running season QP total alongside the manager, e.g. "Name (1.5, manager)".
? escapeMarkdown(e.ownerUsername) const details = e.ownerUsername
: undefined; ? `${formatQPValue(e.qpTotal)}, ${escapeMarkdown(e.ownerUsername)}`
const label = managerLabel : `${formatQPValue(e.qpTotal)}`;
? `${escapeMarkdown(e.participantName)} (${managerLabel})` sections.push(`${escapeMarkdown(e.participantName)} (${details})`);
: escapeMarkdown(e.participantName);
sections.push(`${label}`);
} }
} }
@ -363,22 +363,22 @@ export async function sendQualifyingPointsUpdateNotification({
} }
} }
// Order by the participant's rank in the FULL season standings (globalRank), // Only drafted participants sitting in the top 8 of the FULL season standings
// supplied by the caller, so the block reads as a season leaderboard slice. // (globalRank supplied by the caller), ordered by rank. Standard competition
const sorted = [...entries].toSorted((a, b) => a.globalRank - b.globalRank); // ranking collapses ties to a shared rank, so `globalRank <= 8` naturally keeps
// any tie group sitting at rank 8 ("top 8 plus ties").
const sorted = [...entries]
.filter((e) => e.globalRank <= 8)
.toSorted((a, b) => a.globalRank - b.globalRank);
// The standings block reflects QP earners; skip it entirely when this sync only // Skip the section entirely when no drafted participant is in the top 8 (e.g. a
// reported knockouts (no QP change) so we don't emit an empty header. // sync that only reported knockouts) so we don't emit an empty header.
if (sorted.length > 0) { if (sorted.length > 0) {
sections.push("\n**QP Standings**"); sections.push("\n**Drafted Participants in Top 8**");
for (const e of sorted) { for (const e of sorted) {
const rankPrefix = e.globalRankTied ? `T${e.globalRank}` : `${e.globalRank}`; const rankPrefix = e.globalRankTied ? `T${e.globalRank}` : `${e.globalRank}`;
const ownerLabel = e.ownerDiscordUserId // Plain manager username (no <@id> mention): this section is not pinged.
? `<@${e.ownerDiscordUserId}>` const managerLabel = e.ownerUsername ? ` (${escapeMarkdown(e.ownerUsername)})` : "";
: e.ownerUsername
? escapeMarkdown(e.ownerUsername)
: undefined;
const managerLabel = ownerLabel ? ` (${ownerLabel})` : "";
sections.push(`${rankPrefix}\\. ${escapeMarkdown(e.participantName)}${managerLabel}${formatQPValue(e.qpTotal)} QP`); sections.push(`${rankPrefix}\\. ${escapeMarkdown(e.participantName)}${managerLabel}${formatQPValue(e.qpTotal)} QP`);
} }
} }
@ -389,8 +389,10 @@ export async function sendQualifyingPointsUpdateNotification({
description = description.slice(0, MAX_DESCRIPTION - 3) + "..."; description = description.slice(0, MAX_DESCRIPTION - 3) + "...";
} }
// Ping only managers who earned points or lost a drafted player this sync —
// never the non-scoring (zeroEntries) managers.
const pingUserIds = new Set<string>(); const pingUserIds = new Set<string>();
for (const e of [...awardedEntries, ...zeroEntries, ...eliminated]) { for (const e of [...awardedEntries, ...eliminated]) {
if (e.ownerDiscordUserId) pingUserIds.add(e.ownerDiscordUserId); if (e.ownerDiscordUserId) pingUserIds.add(e.ownerDiscordUserId);
} }
const pingIds = [...pingUserIds]; const pingIds = [...pingUserIds];
@ -399,9 +401,9 @@ export async function sendQualifyingPointsUpdateNotification({
embeds: [ embeds: [
{ {
title: `🏅 Qualifying Points Update — ${seasonName}`, title: `🏅 Qualifying Points Update — ${seasonName}`,
url: standingsUrl,
description, description,
color: 0x5865f2, // Discord blurple color: 0x5865f2, // Discord blurple
footer: { text: "brackt.com" },
}, },
], ],
}; };

View file

@ -157,6 +157,12 @@ export async function notifyQualifyingPointsUpdate(
const seasonName = `${league.name} ${season.year}`; const seasonName = `${league.name} ${season.year}`;
// Link the embed title to this league's sport-season standings page. The
// sportsSeasonId is exactly what the /leagues/:leagueId/sports-seasons/:sportsSeasonId
// route expects (the page filters seasonSports by leagueId).
const appUrl = process.env.APP_URL ?? "https://brackt.com";
const standingsUrl = `${appUrl}/leagues/${league.id}/sports-seasons/${sportsSeasonId}`;
const picks = picksBySeasonId.get(seasonId) ?? []; const picks = picksBySeasonId.get(seasonId) ?? [];
const teamByParticipantId = new Map< const teamByParticipantId = new Map<
string, string,
@ -209,6 +215,7 @@ export async function notifyQualifyingPointsUpdate(
eventName, eventName,
entries, entries,
eliminated, eliminated,
standingsUrl,
}); });
} }
} }