fix qualifying notification #133

Merged
chrisp merged 2 commits from claude/qualifying-points-format-cs9qrv into main 2026-07-09 15:08:40 +00:00
6 changed files with 95 additions and 71 deletions

View file

@ -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(
<QualifyingPointsStandings
standings={[
@ -52,7 +52,8 @@ describe("QualifyingPointsStandings", () => {
);
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();
});
});

View file

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

View file

@ -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");
@ -854,17 +857,18 @@ 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");
// 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("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 +876,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 +919,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 +937,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 +965,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 +976,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**");
});

View file

@ -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<string, unknown> = {}) {
{
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",

View file

@ -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 916 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.501.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 {
@ -294,6 +295,7 @@ export async function sendQualifyingPointsUpdateNotification({
eventName,
entries,
eliminated = [],
standingsUrl,
}: {
webhookUrl: string;
seasonName: string;
@ -301,6 +303,7 @@ export async function sendQualifyingPointsUpdateNotification({
eventName?: string;
entries: QPEventEntry[];
eliminated?: QPEliminatedEntry[];
standingsUrl?: string;
}): Promise<void> {
if (entries.length === 0 && eliminated.length === 0) return;
@ -326,23 +329,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 +364,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 +390,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<string>();
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 +402,9 @@ export async function sendQualifyingPointsUpdateNotification({
embeds: [
{
title: `🏅 Qualifying Points Update — ${seasonName}`,
url: standingsUrl,
description,
color: 0x5865f2, // Discord blurple
footer: { text: "brackt.com" },
},
],
};

View file

@ -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,
});
}
}