From 3f16d5e1d31e7a7f26b21f1d41d9dacd7c136afe Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 05:12:40 +0000 Subject: [PATCH 1/2] 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 Claude-Session: https://claude.ai/code/session_011U7mTxDVK2cH8E2BWjCiUv --- app/services/__tests__/discord.test.ts | 83 ++++++++++--------- .../qualifying-points-discord.server.test.ts | 4 +- app/services/discord.ts | 52 ++++++------ .../qualifying-points-discord.server.ts | 7 ++ 4 files changed, 82 insertions(+), 64 deletions(-) diff --git a/app/services/__tests__/discord.test.ts b/app/services/__tests__/discord.test.ts index 786d49a..c009174 100644 --- a/app/services/__tests__/discord.test.ts +++ b/app/services/__tests__/discord.test.ts @@ -747,17 +747,19 @@ describe("sendQualifyingPointsUpdateNotification", () => { { 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({ webhookUrl: WEBHOOK_URL, seasonName: "Slam League 2025", entries: BASE_ENTRIES, + standingsUrl: "https://brackt.com/leagues/abc/sports-seasons/ss-1", }); const payload = getPayload(); expect(payload.embeds[0].title).toBe("🏅 Qualifying Points Update — Slam League 2025"); 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 () => { @@ -782,13 +784,13 @@ describe("sendQualifyingPointsUpdateNotification", () => { const desc = getDescription(); expect(desc).toContain("**Points Awarded**"); - expect(desc).toContain("• **Novak Djokovic (alex)** — +20 QP"); - expect(desc).toContain("• **Carlos Alcaraz (chris)** — +14 QP"); + expect(desc).toContain("• **Novak Djokovic (alex)** — 20 QP"); + expect(desc).toContain("• **Carlos Alcaraz (chris)** — 14 QP"); // Djokovic (20 QP) should appear before Alcaraz (14 QP) 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({ webhookUrl: WEBHOOK_URL, seasonName: "Slam League 2025", @@ -796,11 +798,12 @@ describe("sendQualifyingPointsUpdateNotification", () => { }); const desc = getDescription(); - expect(desc).toContain("**No Points**"); - expect(desc).toContain("Rafael Nadal (alex)"); + expect(desc).toContain("**Non-scoring Participants**"); + // 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({ webhookUrl: WEBHOOK_URL, seasonName: "Slam League 2025", @@ -808,7 +811,7 @@ describe("sendQualifyingPointsUpdateNotification", () => { }); 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("2\\. Carlos Alcaraz (chris) — 34 QP"); expect(desc).toContain("3\\. Rafael Nadal (alex) — 20 QP"); @@ -855,16 +858,15 @@ describe("sendQualifyingPointsUpdateNotification", () => { const desc = getDescription(); // Mirrors the web UI's formatQP: fractional QP renders with 2 decimals ("1.50"), - // never rounded to an integer. - expect(desc).toContain("+1.50 QP"); - expect(desc).not.toContain("+2 QP"); - expect(desc).toContain("1.50 QP"); - expect(desc).not.toContain("— 2 QP"); + // never rounded to an integer. The Points Awarded line no longer carries a "+". + expect(desc).toContain("• **Novak Djokovic (snarkymcgee)** — 1.50 QP"); + expect(desc).not.toContain("+"); + expect(desc).not.toContain("2 QP"); }); - it("ranks the standings by caller-provided full-field globalRank, not position among entries", async () => { - // Screenshot scenario: only two players scored this event, but they sit at T9 in - // the full season field (8 players ahead). They must show T9, not T1. + it("excludes participants ranked outside the top 8 from the Drafted Participants section", async () => { + // Only the top 8 (plus ties) of the full season field belong in this section, so a + // rank-9 scorer must NOT appear in it — even though it still shows in Points Awarded. await sendQualifyingPointsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "Rumble League 2026", @@ -872,28 +874,31 @@ describe("sendQualifyingPointsUpdateNotification", () => { eventName: "Wimbledon", entries: [ { - participantName: "Novak Djokovic", - qpEarned: 1.5, - qpTotal: 1.5, - globalRank: 9, - globalRankTied: true, - ownerUsername: "snarkymcgee", + participantName: "Player Eight", + qpEarned: 5, + qpTotal: 10, + globalRank: 8, + globalRankTied: false, + ownerUsername: "eighthowner", }, { - participantName: "Jannik Sinner", - qpEarned: 1.5, - qpTotal: 1.5, + participantName: "Player Nine", + qpEarned: 3, + qpTotal: 8, globalRank: 9, - globalRankTied: true, - ownerUsername: "tanay002", + globalRankTied: false, + ownerUsername: "ninthowner", }, ], }); const desc = getDescription(); - expect(desc).toContain("T9\\. Novak Djokovic (snarkymcgee) — 1.50 QP"); - expect(desc).toContain("T9\\. Jannik Sinner (tanay002) — 1.50 QP"); - expect(desc).not.toContain("T1\\."); + expect(desc).toContain("**Drafted Participants in Top 8**"); + expect(desc).toContain("8\\. Player Eight (eighthowner) — 10 QP"); + // 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 () => { @@ -912,11 +917,13 @@ describe("sendQualifyingPointsUpdateNotification", () => { }); const desc = getDescription(); - expect(desc).toContain("Novak Djokovic (<@111222333>)"); - expect(desc).not.toContain("(alex)"); + // Points Awarded (a pinged section) uses the Discord mention... + 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({ webhookUrl: WEBHOOK_URL, seasonName: "Slam League 2025", @@ -928,9 +935,9 @@ describe("sendQualifyingPointsUpdateNotification", () => { const payload = getPayload(); 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("222"); + expect(payload.allowed_mentions.users).not.toContain("222"); }); it("does not send when entries array is empty", async () => { @@ -956,7 +963,7 @@ describe("sendQualifyingPointsUpdateNotification", () => { 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({ webhookUrl: WEBHOOK_URL, seasonName: "Slam League 2025", @@ -967,7 +974,7 @@ describe("sendQualifyingPointsUpdateNotification", () => { expect(fetch).toHaveBeenCalledOnce(); const desc = getDescription(); 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**"); }); diff --git a/app/services/__tests__/qualifying-points-discord.server.test.ts b/app/services/__tests__/qualifying-points-discord.server.test.ts index d258f92..f8ca771 100644 --- a/app/services/__tests__/qualifying-points-discord.server.test.ts +++ b/app/services/__tests__/qualifying-points-discord.server.test.ts @@ -27,6 +27,7 @@ import { findDiscordIdsByUserIds } from "~/models/account"; const SPORTS_SEASON_ID = "ss-1"; const SCORING_EVENT_ID = "ev-1"; const SEASON_ID = "season-1"; +const LEAGUE_ID = "league-1"; const PARTICIPANT_ID = "p-1"; const OWNER_ID = "u-1"; const WEBHOOK_URL = "https://discord.com/api/webhooks/123/abc"; @@ -68,7 +69,7 @@ function makeDb(overrides: Record = {}) { { id: SEASON_ID, 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", eventName: "Roland Garros 2025", sportName: "Tennis", + standingsUrl: `${process.env.APP_URL ?? "https://brackt.com"}/leagues/${LEAGUE_ID}/sports-seasons/${SPORTS_SEASON_ID}`, entries: [ expect.objectContaining({ participantName: "Carlos Alcaraz", diff --git a/app/services/discord.ts b/app/services/discord.ts index 6023bc5..8541820 100644 --- a/app/services/discord.ts +++ b/app/services/discord.ts @@ -294,6 +294,7 @@ export async function sendQualifyingPointsUpdateNotification({ eventName, entries, eliminated = [], + standingsUrl, }: { webhookUrl: string; seasonName: string; @@ -301,6 +302,7 @@ export async function sendQualifyingPointsUpdateNotification({ eventName?: string; entries: QPEventEntry[]; eliminated?: QPEliminatedEntry[]; + standingsUrl?: string; }): Promise { if (entries.length === 0 && eliminated.length === 0) return; @@ -326,23 +328,21 @@ export async function sendQualifyingPointsUpdateNotification({ const label = managerLabel ? `${escapeMarkdown(e.participantName)} (${managerLabel})` : 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); if (zeroEntries.length > 0) { - sections.push("\n**No Points**"); + sections.push("\n**Non-scoring Participants**"); for (const e of zeroEntries) { - const managerLabel = e.ownerDiscordUserId - ? `<@${e.ownerDiscordUserId}>` - : e.ownerUsername - ? escapeMarkdown(e.ownerUsername) - : undefined; - const label = managerLabel - ? `${escapeMarkdown(e.participantName)} (${managerLabel})` - : escapeMarkdown(e.participantName); - sections.push(`• ${label}`); + // Plain manager username here (never a <@id> mention): this section is not + // pinged, so mention chips would misleadingly look like a ping. Show the + // running season QP total alongside the manager, e.g. "Name (1.5, manager)". + const details = e.ownerUsername + ? `${formatQPValue(e.qpTotal)}, ${escapeMarkdown(e.ownerUsername)}` + : `${formatQPValue(e.qpTotal)}`; + sections.push(`• ${escapeMarkdown(e.participantName)} (${details})`); } } @@ -363,22 +363,22 @@ export async function sendQualifyingPointsUpdateNotification({ } } - // Order by the participant's rank in the FULL season standings (globalRank), - // supplied by the caller, so the block reads as a season leaderboard slice. - const sorted = [...entries].toSorted((a, b) => a.globalRank - b.globalRank); + // Only drafted participants sitting in the top 8 of the FULL season standings + // (globalRank supplied by the caller), ordered by rank. Standard competition + // 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 - // reported knockouts (no QP change) so we don't emit an empty header. + // Skip the section entirely when no drafted participant is in the top 8 (e.g. a + // sync that only reported knockouts) so we don't emit an empty header. if (sorted.length > 0) { - sections.push("\n**QP Standings**"); + sections.push("\n**Drafted Participants in Top 8**"); for (const e of sorted) { const rankPrefix = e.globalRankTied ? `T${e.globalRank}` : `${e.globalRank}`; - const ownerLabel = e.ownerDiscordUserId - ? `<@${e.ownerDiscordUserId}>` - : e.ownerUsername - ? escapeMarkdown(e.ownerUsername) - : undefined; - const managerLabel = ownerLabel ? ` (${ownerLabel})` : ""; + // Plain manager username (no <@id> mention): this section is not pinged. + const managerLabel = e.ownerUsername ? ` (${escapeMarkdown(e.ownerUsername)})` : ""; 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) + "..."; } + // Ping only managers who earned points or lost a drafted player this sync — + // never the non-scoring (zeroEntries) managers. const pingUserIds = new Set(); - for (const e of [...awardedEntries, ...zeroEntries, ...eliminated]) { + for (const e of [...awardedEntries, ...eliminated]) { if (e.ownerDiscordUserId) pingUserIds.add(e.ownerDiscordUserId); } const pingIds = [...pingUserIds]; @@ -399,9 +401,9 @@ export async function sendQualifyingPointsUpdateNotification({ embeds: [ { title: `🏅 Qualifying Points Update — ${seasonName}`, + url: standingsUrl, description, color: 0x5865f2, // Discord blurple - footer: { text: "brackt.com" }, }, ], }; diff --git a/app/services/qualifying-points-discord.server.ts b/app/services/qualifying-points-discord.server.ts index 5acb909..852e3ff 100644 --- a/app/services/qualifying-points-discord.server.ts +++ b/app/services/qualifying-points-discord.server.ts @@ -157,6 +157,12 @@ export async function notifyQualifyingPointsUpdate( 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 teamByParticipantId = new Map< string, @@ -209,6 +215,7 @@ export async function notifyQualifyingPointsUpdate( eventName, entries, eliminated, + standingsUrl, }); } } From cab161424e417d3c3a82e1fbc34cf16567feb52c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 06:34:25 +0000 Subject: [PATCH 2/2] Trim trailing zeros on qualifying-points values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Display fractional QP with up to 2 decimals and trailing zeros trimmed (1.50→1.5, 0.50→0.5) in both the Discord embed (formatQPValue) and the web UI (formatQP) so the two surfaces stay consistent. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011U7mTxDVK2cH8E2BWjCiUv --- .../__tests__/QualifyingPointsStandings.test.tsx | 5 +++-- app/components/scoring/QualifyingPointsStandings.tsx | 4 +++- app/services/__tests__/discord.test.ts | 8 +++++--- app/services/discord.ts | 7 ++++--- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/app/components/__tests__/QualifyingPointsStandings.test.tsx b/app/components/__tests__/QualifyingPointsStandings.test.tsx index d66718b..536b561 100644 --- a/app/components/__tests__/QualifyingPointsStandings.test.tsx +++ b/app/components/__tests__/QualifyingPointsStandings.test.tsx @@ -14,7 +14,7 @@ const scoringRules = { }; describe("QualifyingPointsStandings", () => { - it("renders fractional QP to hundredths", () => { + it("renders fractional QP to at most hundredths with trailing zeros trimmed", () => { render( { ); expect(screen.getByText("14.67 QP")).toBeInTheDocument(); - expect(screen.getByText("0.50 QP")).toBeInTheDocument(); + // 0.50 trims its trailing zero to 0.5; 14.67 and 0.43 are unaffected. + expect(screen.getByText("0.5 QP")).toBeInTheDocument(); expect(screen.getByText("0.43 QP")).toBeInTheDocument(); }); }); diff --git a/app/components/scoring/QualifyingPointsStandings.tsx b/app/components/scoring/QualifyingPointsStandings.tsx index 21ecfc0..835426e 100644 --- a/app/components/scoring/QualifyingPointsStandings.tsx +++ b/app/components/scoring/QualifyingPointsStandings.tsx @@ -55,7 +55,9 @@ interface QualifyingPointsStandingsProps { function formatQP(raw: string): string { const n = parseFloat(raw); if (isNaN(n)) return "—"; - return n % 1 === 0 ? n.toString() : n.toFixed(2); + // At most 2 decimals, trailing zeros trimmed (1.50→1.5). Mirrors the Discord + // embed's formatQPValue (app/services/discord.ts) so the site and Discord agree. + return parseFloat(n.toFixed(2)).toString(); } export function QualifyingPointsStandings({ diff --git a/app/services/__tests__/discord.test.ts b/app/services/__tests__/discord.test.ts index c009174..96ad65d 100644 --- a/app/services/__tests__/discord.test.ts +++ b/app/services/__tests__/discord.test.ts @@ -857,11 +857,13 @@ describe("sendQualifyingPointsUpdateNotification", () => { }); const desc = getDescription(); - // Mirrors the web UI's formatQP: fractional QP renders with 2 decimals ("1.50"), - // never rounded to an integer. The Points Awarded line no longer carries a "+". - expect(desc).toContain("• **Novak Djokovic (snarkymcgee)** — 1.50 QP"); + // Mirrors the web UI's formatQP: fractional QP renders with up to 2 decimals and + // trailing zeros trimmed ("1.5"), never rounded to an integer. The Points Awarded + // line no longer carries a "+". + expect(desc).toContain("• **Novak Djokovic (snarkymcgee)** — 1.5 QP"); expect(desc).not.toContain("+"); expect(desc).not.toContain("2 QP"); + expect(desc).not.toContain("1.50"); }); it("excludes participants ranked outside the top 8 from the Drafted Participants section", async () => { diff --git a/app/services/discord.ts b/app/services/discord.ts index 8541820..7db95d4 100644 --- a/app/services/discord.ts +++ b/app/services/discord.ts @@ -66,11 +66,12 @@ function escapeMarkdown(text: string): string { /** * Format a QP value for display. QP is genuinely fractional (e.g. a tennis R16 loser * earns 1.5 QP from the 9–16 split), so we must NOT round: show whole numbers plainly - * and fractional values to 2 decimals. Mirrors the web UI's formatQP - * (app/components/scoring/QualifyingPointsStandings.tsx) so Discord and the site agree. + * and fractional values to at most 2 decimals with trailing zeros trimmed (1.50→1.5). + * Mirrors the web UI's formatQP (app/components/scoring/QualifyingPointsStandings.tsx) + * so Discord and the site agree. */ function formatQPValue(n: number): string { - return n % 1 === 0 ? n.toString() : n.toFixed(2); + return parseFloat(n.toFixed(2)).toString(); } export interface StandingEntry {