diff --git a/app/services/__tests__/discord.test.ts b/app/services/__tests__/discord.test.ts index 3b5457c..4f02d65 100644 --- a/app/services/__tests__/discord.test.ts +++ b/app/services/__tests__/discord.test.ts @@ -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**"); }); diff --git a/app/services/__tests__/qualifying-points-discord.server.test.ts b/app/services/__tests__/qualifying-points-discord.server.test.ts index e5e34c1..3975db8 100644 --- a/app/services/__tests__/qualifying-points-discord.server.test.ts +++ b/app/services/__tests__/qualifying-points-discord.server.test.ts @@ -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. diff --git a/app/services/discord.ts b/app/services/discord.ts index 11b03fb..2377384 100644 --- a/app/services/discord.ts +++ b/app/services/discord.ts @@ -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) { diff --git a/app/services/qualifying-points-discord.server.ts b/app/services/qualifying-points-discord.server.ts index 9ed5225..63bb44f 100644 --- a/app/services/qualifying-points-discord.server.ts +++ b/app/services/qualifying-points-discord.server.ts @@ -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))