Compare commits

..

2 commits

Author SHA1 Message Date
7e578c714c Merge pull request 'Merge QP scoreboard into a single Drafted Participants list with a Points Bubble divider' (#135) from claude/qp-points-bubble into main
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 2m49s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m19s
🚀 Deploy / 🐳 Build (push) Successful in 1m11s
🚀 Deploy / 🚀 Deploy (push) Successful in 12s
2026-07-10 15:16:50 +00:00
Chris Parsons
687fb6f040 Merge QP scoreboard into a single Drafted Participants list with a Points Bubble divider
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m4s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m19s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Collapse the "Drafted Participants in Top 8" and "Non-scoring Participants"
sections of the qualifying-points Discord announcement into one "Drafted
Participants" standings section. It lists every drafted participant who has
scored (qpTotal > 0) as ranked lines, with a "═══ Points Bubble ═══" divider
marking the points cutoff between rank 8 and rank 9. Participants with 0 QP are
no longer shown.

The divider is only emitted once at least one above-the-cutoff row exists:
globalRank is a season-wide rank while the scoreboard is scoped to one league's
drafts, so a league can have drafted nobody in the global top 8 — guarding on
that avoids a leading divider with nothing above it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 23:22:24 -07:00
4 changed files with 117 additions and 109 deletions

View file

@ -790,10 +790,9 @@ describe("sendQualifyingPointsUpdateNotification", () => {
expect(desc.indexOf("Novak Djokovic")).toBeLessThan(desc.indexOf("Carlos Alcaraz"));
});
it("lists non-scoring participants (outside top 8) as one comma-separated line below Top 8", async () => {
// Non-scoring now draws from the full scoreboard: every drafted participant NOT in
// the top 8 (rank 9+ or unranked), rendered on a single line "Name (total, manager)"
// ordered by season total desc — not just this event's zero-earners.
it("omits zero-QP drafted participants entirely from the Drafted Participants section", async () => {
// Only participants who have actually scored (qpTotal > 0) are listed; a 0-QP drafted
// player no longer appears anywhere in the standings section.
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
@ -808,45 +807,19 @@ describe("sendQualifyingPointsUpdateNotification", () => {
});
const desc = getDescription();
expect(desc).toContain("**Non-scoring Participants**");
// Single comma-separated line: "Name (seasonTotal, manager)", higher total first.
expect(desc).toContain("Also Ran (5, chris), Winless Wonder (0, sam)");
// Champ is in the top 8, so it must NOT appear in the non-scoring line.
expect(desc).not.toContain("Champ (100");
// Non-scoring appears below the Top 8 section.
expect(desc.indexOf("Drafted Participants in Top 8")).toBeLessThan(
desc.indexOf("Non-scoring Participants")
);
expect(desc).toContain("**Drafted Participants**");
// Both scorers appear as ranked rows...
expect(desc).toContain("1\\. Champ (alex) — 100 QP");
expect(desc).toContain("9\\. Also Ran (chris) — 5 QP");
// ...with a Points Bubble divider separating the rank-9 scorer.
expect(desc).toContain("**═══ Points Bubble ═══**");
// The 0-QP player is omitted entirely.
expect(desc).not.toContain("Winless Wonder");
// The old Non-scoring section is gone.
expect(desc).not.toContain("Non-scoring Participants");
});
it("keeps zero-QP participants tied into the top-8 rank band out of the Top 8 section", async () => {
// Regression: early in a season, standard competition ranking ties every winless
// player into a low rank (here rank 2). A rank-only Top 8 filter would flood the
// section with "T2. Name — 0 QP" rows; the qpTotal>0 guard drops them to Non-scoring.
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: [
{ participantName: "Only Scorer", qpEarned: 10, qpTotal: 10, globalRank: 1, globalRankTied: false, ownerUsername: "alex" },
],
scoreboard: [
{ participantName: "Only Scorer", qpEarned: 10, qpTotal: 10, globalRank: 1, globalRankTied: false, ownerUsername: "alex" },
{ participantName: "Winless A", qpEarned: 0, qpTotal: 0, globalRank: 2, globalRankTied: true, ownerUsername: "chris" },
{ participantName: "Winless B", qpEarned: 0, qpTotal: 0, globalRank: 2, globalRankTied: true, ownerUsername: "sam" },
],
});
const desc = getDescription();
// Top 8 contains only the real scorer.
expect(desc).toContain("1\\. Only Scorer (alex) — 10 QP");
// The tied 0-QP players must NOT appear as top-8 rows.
expect(desc).not.toContain("T2\\. Winless A");
expect(desc).not.toContain("T2\\. Winless B");
// They land in the Non-scoring line instead.
expect(desc).toContain("Winless A (0, chris), Winless B (0, sam)");
});
it("shows Drafted Participants in Top 8 for scoreboard sorted by qpTotal desc", async () => {
it("shows the Drafted Participants section for scored participants sorted by rank", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
@ -855,12 +828,61 @@ describe("sendQualifyingPointsUpdateNotification", () => {
});
const desc = getDescription();
expect(desc).toContain("**Drafted Participants in Top 8**");
expect(desc).toContain("**Drafted Participants**");
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");
// Djokovic should rank above Alcaraz
expect(desc.indexOf("1\\. Novak")).toBeLessThan(desc.indexOf("2\\. Carlos"));
// Everyone is rank <= 8, so no divider is emitted.
expect(desc).not.toContain("Points Bubble");
});
it("inserts a Points Bubble divider between the rank-8 and rank-9 scorers", async () => {
const scoreboard = [
{ participantName: "Player Eight", qpEarned: 5, qpTotal: 12, globalRank: 8, globalRankTied: false, ownerUsername: "chris" },
{ participantName: "Player Nine", qpEarned: 3, qpTotal: 8, globalRank: 9, globalRankTied: false, ownerUsername: "alex" },
];
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: scoreboard,
scoreboard,
});
const desc = getDescription();
const eightIdx = desc.indexOf("8\\. Player Eight");
const bubbleIdx = desc.indexOf("**═══ Points Bubble ═══**");
const nineIdx = desc.indexOf("9\\. Player Nine");
expect(eightIdx).toBeGreaterThan(-1);
expect(bubbleIdx).toBeGreaterThan(-1);
expect(nineIdx).toBeGreaterThan(-1);
// Divider sits between the rank-8 and rank-9 rows.
expect(eightIdx).toBeLessThan(bubbleIdx);
expect(bubbleIdx).toBeLessThan(nineIdx);
});
it("omits the Points Bubble divider when every scorer is below the cutoff", async () => {
// globalRank is a season-wide rank but the scoreboard is scoped to one league's drafts,
// so a league can have drafted nobody in the global top 8. The divider must not lead the
// section with nothing above it.
const scoreboard = [
{ participantName: "Player Nine", qpEarned: 3, qpTotal: 8, globalRank: 9, globalRankTied: false, ownerUsername: "alex" },
{ participantName: "Player Ten", qpEarned: 2, qpTotal: 5, globalRank: 10, globalRankTied: false, ownerUsername: "chris" },
];
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: scoreboard,
scoreboard,
});
const desc = getDescription();
expect(desc).toContain("**Drafted Participants**");
expect(desc).toContain("9\\. Player Nine (alex) — 8 QP");
expect(desc).toContain("10\\. Player Ten (chris) — 5 QP");
// No rank <= 8 row exists, so the divider must not appear.
expect(desc).not.toContain("Points Bubble");
});
it("uses T-prefix for tied QP totals in standings", async () => {
@ -915,9 +937,9 @@ describe("sendQualifyingPointsUpdateNotification", () => {
expect(desc).not.toContain("1.50");
});
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.
it("lists a rank-9 scorer below the Points Bubble but omits a 0-QP participant", async () => {
// Rank-9 scorers now appear in the standings section (below the bubble), while a drafted
// participant with no points is dropped entirely.
const both = [
{
participantName: "Player Eight",
@ -935,6 +957,14 @@ describe("sendQualifyingPointsUpdateNotification", () => {
globalRankTied: false,
ownerUsername: "ninthowner",
},
{
participantName: "Player Winless",
qpEarned: 0,
qpTotal: 0,
globalRank: 10,
globalRankTied: false,
ownerUsername: "winlessowner",
},
];
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
@ -946,13 +976,14 @@ describe("sendQualifyingPointsUpdateNotification", () => {
});
const desc = getDescription();
expect(desc).toContain("**Drafted Participants in Top 8**");
expect(desc).toContain("**Drafted Participants**");
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");
// ...instead the rank-9 player drops into the Non-scoring line.
expect(desc).toContain("Player Nine (8, ninthowner)");
// ...and both still earned points, so both remain in Points Awarded.
// The rank-9 scorer now appears as a ranked row below the bubble.
expect(desc).toContain("**═══ Points Bubble ═══**");
expect(desc).toContain("9\\. Player Nine (ninthowner) — 8 QP");
// The 0-QP player is omitted from the standings section entirely.
expect(desc).not.toContain("Player Winless");
// ...but both scorers still earned points, so both remain in Points Awarded.
expect(desc).toContain("• **Player Nine (ninthowner)** — 3 QP");
});
@ -982,7 +1013,7 @@ describe("sendQualifyingPointsUpdateNotification", () => {
const desc = getDescription();
// 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.
// ...while the (non-pinged) Drafted Participants standings section uses the plain username.
expect(desc).toContain("1\\. Novak Djokovic (alex) — 45 QP");
});
@ -1037,7 +1068,7 @@ describe("sendQualifyingPointsUpdateNotification", () => {
expect(fetch).toHaveBeenCalledOnce();
const desc = getDescription();
expect(desc).toContain("**Knocked Out**");
expect(desc).not.toContain("**Drafted Participants in Top 8**");
expect(desc).not.toContain("**Drafted Participants**");
expect(desc).not.toContain("**Points Awarded**");
});

View file

@ -282,7 +282,7 @@ describe("notifyQualifyingPointsUpdate", () => {
});
it("scoreboard includes every drafted participant even when entries are filtered", async () => {
// The scoreboard powers the Top 8 / Non-scoring sections and must reflect the full
// The scoreboard powers the Drafted Participants section and must reflect the full
// drafted field, not just this sync's changed participants. Here Nadal (p-2) did not
// change this sync (filtered out of entries) but is drafted, so he belongs on the
// scoreboard with his running total and no QP earned this event.

View file

@ -288,20 +288,6 @@ export interface QPEliminatedEntry {
ownerDiscordUserId?: string;
}
/**
* Whether a scoreboard entry belongs in the "Drafted Participants in Top 8" section.
* Requires the participant to have actually scored (qpTotal > 0) AND sit in the top 8
* of the full season standings. The qpTotal>0 guard matters early in a season: when
* fewer than 8 players have scored, standard competition ranking ties every 0-QP player
* into a rank <= 8 band (e.g. everyone winless is "T5"), and a rank-only filter would
* flood the section with "T5. Name — 0 QP" rows. Rank ties among real scorers at rank 8
* are still kept ("top 8 plus ties"); globalRank 0 means unranked. The Non-scoring
* section is the exact complement (`!inTopEight`), so the field is partitioned cleanly.
*/
function inTopEight(e: QPEventEntry): boolean {
return e.qpTotal > 0 && e.globalRank >= 1 && e.globalRank <= 8;
}
export async function sendQualifyingPointsUpdateNotification({
webhookUrl,
seasonName,
@ -320,8 +306,8 @@ export async function sendQualifyingPointsUpdateNotification({
eliminated?: QPEliminatedEntry[];
/**
* The full current scoreboard for the league every drafted participant, not just
* those whose QP changed this sync. Drives the "Top 8" and "Non-scoring Participants"
* sections. `entries`/`eliminated` remain scoped to this sync's changes and drive the
* those whose QP changed this sync. Drives the "Drafted Participants" standings section.
* `entries`/`eliminated` remain scoped to this sync's changes and drive the
* "Points Awarded"/"Knocked Out" sections and the ping list.
*/
scoreboard?: QPEventEntry[];
@ -372,48 +358,39 @@ export async function sendQualifyingPointsUpdateNotification({
}
}
// Scoreboard sections below draw from the FULL drafted field (`scoreboard`), not just
// this sync's movers, so they read as a live standings snapshot. The two sections
// partition the field exactly via `inTopEight`, so nobody is dropped or listed twice.
// Top 8 = a drafted participant that has actually scored (qpTotal > 0) AND sits in the
// top 8 of the FULL season standings (see `inTopEight`).
const topEight = [...scoreboard]
.filter(inTopEight)
// Drafted Participants: every drafted participant that has actually scored (qpTotal > 0),
// drawn from the FULL drafted field (`scoreboard`) not just this sync's movers, so it reads
// as a live standings snapshot. Rendered as ranked lines ordered by full-season standing. A
// "Points Bubble" divider marks the cutoff between those currently in the points (rank <= 8)
// and those below it (rank >= 9). Participants with 0 QP are omitted entirely. Never pinged,
// so managers are shown by plain username, never as a <@id> mention.
const scored = [...scoreboard]
.filter((e) => e.qpTotal > 0)
.toSorted((a, b) => a.globalRank - b.globalRank);
// 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 (topEight.length > 0) {
sections.push("\n**Drafted Participants in Top 8**");
for (const e of topEight) {
// Skip the section entirely when no drafted participant has scored (e.g. a sync that only
// reported knockouts) so we don't emit an empty header.
if (scored.length > 0) {
sections.push("\n**Drafted Participants**");
// Insert the divider once, before the first below-the-cutoff (rank >= 9) row. `>= 9`
// (not `> 8`) keeps a tie AT rank 8 above the bubble ("top 8 plus ties"). Only emit it
// after at least one above-the-bubble row exists: globalRank is a season-wide rank while
// this list is scoped to one league's drafts, so a league can have drafted nobody in the
// global top 8 — guarding on rowsAbove avoids a leading divider with nothing above it.
let bubbleInserted = false;
let rowsAbove = 0;
for (const e of scored) {
if (!bubbleInserted && rowsAbove > 0 && e.globalRank >= 9) {
sections.push("**═══ Points Bubble ═══**");
bubbleInserted = true;
}
if (e.globalRank <= 8) rowsAbove++;
const rankPrefix = e.globalRankTied ? `T${e.globalRank}` : `${e.globalRank}`;
// 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`);
}
}
// Non-scoring section — the rest of the drafted field (everyone not in the top 8, i.e.
// the exact complement of `inTopEight`: rank 9+, unranked, or scored-nothing players
// tied into the top-8 rank band). Rendered as a single compact comma-separated line of
// "Name (points, manager)", highest QP total first. Never pinged, so managers are shown
// by plain username, never as a <@id> mention.
const nonTopEight = [...scoreboard]
.filter((e) => !inTopEight(e))
.toSorted((a, b) => b.qpTotal - a.qpTotal);
if (nonTopEight.length > 0) {
sections.push("\n**Non-scoring Participants**");
const parts = nonTopEight.map((e) => {
const details = e.ownerUsername
? `${formatQPValue(e.qpTotal)}, ${escapeMarkdown(e.ownerUsername)}`
: `${formatQPValue(e.qpTotal)}`;
return `${escapeMarkdown(e.participantName)} (${details})`;
});
sections.push(parts.join(", "));
}
const MAX_DESCRIPTION = 4096;
let description = sections.join("\n");
if (description.length > MAX_DESCRIPTION) {

View file

@ -126,8 +126,8 @@ export async function notifyQualifyingPointsUpdate(
const seasonMap = new Map(seasons.map((s) => [s.id, s]));
// Batch-fetch participant display names once (same participants across all leagues).
// Includes every drafted participant — the scoreboard sections (Top 8 / Non-scoring)
// list the whole drafted field, not just this sync's changed participants.
// Includes every drafted participant — the Drafted Participants scoreboard section
// lists the whole scored field, not just this sync's changed participants.
const allParticipantIds = [
...new Set([...qpEarnedById.keys(), ...eliminatedIds, ...draftedParticipantIds]),
];
@ -137,7 +137,7 @@ export async function notifyQualifyingPointsUpdate(
const participantNameById = new Map(participants.map((p) => [p.id, p.name]));
// A league's draft picks span every sport in that fantasy season, so the scoreboard
// must be scoped to participants belonging to the sports season being announced —
// otherwise a golf pick would surface in a tennis event's Non-scoring line.
// otherwise a golf pick would surface in a tennis event's standings section.
const sportsSeasonParticipantIds = new Set(
participants.filter((p) => p.sportsSeasonId === sportsSeasonId).map((p) => p.id)
);
@ -219,8 +219,8 @@ export async function notifyQualifyingPointsUpdate(
});
// Full current scoreboard for this league: every drafted participant, regardless of
// whether their QP changed this sync. Drives the Top 8 and Non-scoring sections so
// they read as a season-standings snapshot rather than only this event's movers.
// whether their QP changed this sync. Drives the Drafted Participants section so
// it reads as a season-standings snapshot rather than only this event's movers.
// Never pinged, so ownerDiscordUserId is intentionally omitted.
const scoreboard: QPEventEntry[] = [...teamByParticipantId.keys()]
.filter((participantId) => sportsSeasonParticipantIds.has(participantId))