Compare commits
3 commits
036411e9d8
...
b05bad6554
| Author | SHA1 | Date | |
|---|---|---|---|
| b05bad6554 | |||
|
|
cab161424e | ||
|
|
3f16d5e1d3 |
6 changed files with 95 additions and 71 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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**");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -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" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue