Fix qualifying-points notifications: full-field scoreboard + manual-set announcements
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m58s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m19s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (push) Successful in 2m53s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m23s
🚀 Deploy / 🐳 Build (push) Successful in 1m8s
🚀 Deploy / 🚀 Deploy (push) Successful in 12s
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m58s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m19s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (push) Successful in 2m53s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m23s
🚀 Deploy / 🐳 Build (push) Successful in 1m8s
🚀 Deploy / 🚀 Deploy (push) Successful in 12s
The QP Discord embed's two "scoreboard" sections now reflect the whole drafted field instead of only participants whose QP changed this sync: - Non-scoring / Top 8 draw from a new per-league `scoreboard` (all drafted participants), scoped to the sports season being announced so a golf pick can't leak into a tennis event. Points Awarded / Knocked Out stay scoped to the sync's changes and remain the only pinged sections. - Non-scoring is now a single compact "Name (points, manager)" line below Top 8, covering everyone not in the top 8 (the exact complement of the Top 8 filter). - Top 8 requires qpTotal > 0 as well as rank <= 8, so early-season winless players tied into a low rank band no longer flood the section with "T5. Name — 0 QP". Manually setting a bracket match result (e.g. a Wimbledon semifinal) now announces the QP update. The set-winner / set-round-winners / complete-round paths route through processQualifyingEvent (which snapshots, diffs, and notifies) instead of processQualifyingBracketEvent (which scored silently). The tournament fan-out still skips the primary window via skipEventId, so mirror windows aren't double-posted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
b05bad6554
commit
5d0363a309
5 changed files with 267 additions and 56 deletions
|
|
@ -146,7 +146,13 @@ async function scoreQualifyingBracket(
|
|||
*/
|
||||
newlyEliminatedParticipantIds?: Set<string>
|
||||
): Promise<void> {
|
||||
await processQualifyingBracketEvent(event.id, db);
|
||||
// processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent),
|
||||
// recalcs participant QP totals, AND announces the QP change to this window's leagues.
|
||||
// Calling processQualifyingBracketEvent directly here would score silently — the QP
|
||||
// Discord notification only fires from processQualifyingEvent. The fan-out below skips
|
||||
// this (primary) window via skipEventId, so mirror windows are announced separately
|
||||
// with no double-post.
|
||||
await processQualifyingEvent(event.id, db, { newlyEliminatedParticipantIds });
|
||||
await recalculateAffectedLeagues(
|
||||
event.sportsSeasonId,
|
||||
db,
|
||||
|
|
@ -613,9 +619,14 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
// previously completed matches in the event.
|
||||
if (successCount > 0) {
|
||||
const db = database();
|
||||
// Qualifying majors: derive QP from the full bracket once for the batch.
|
||||
// Qualifying majors: derive QP from the full bracket once for the batch, and
|
||||
// announce the QP change to this window's leagues. processQualifyingEvent (not
|
||||
// processQualifyingBracketEvent) is what sends the QP Discord notification; the
|
||||
// fan-out below skips this window (skipEventId) so mirrors don't double-post.
|
||||
if (event.isQualifyingEvent) {
|
||||
await processQualifyingBracketEvent(event.id, db);
|
||||
await processQualifyingEvent(event.id, db, {
|
||||
newlyEliminatedParticipantIds: new Set(newlyDecidedLosers(decidedEntries)),
|
||||
});
|
||||
}
|
||||
// Update probabilities first so recalculateAffectedLeagues reads fresh EVs
|
||||
// when computing projected points.
|
||||
|
|
|
|||
|
|
@ -790,24 +790,68 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
|||
expect(desc.indexOf("Novak Djokovic")).toBeLessThan(desc.indexOf("Carlos Alcaraz"));
|
||||
});
|
||||
|
||||
it("shows Non-scoring Participants section with season total and plain manager name", async () => {
|
||||
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.
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
entries: BASE_ENTRIES,
|
||||
entries: [
|
||||
{ participantName: "Champ", qpEarned: 10, qpTotal: 100, globalRank: 1, globalRankTied: false, ownerUsername: "alex" },
|
||||
],
|
||||
scoreboard: [
|
||||
{ participantName: "Champ", qpEarned: 10, qpTotal: 100, globalRank: 1, globalRankTied: false, ownerUsername: "alex" },
|
||||
{ participantName: "Also Ran", qpEarned: 0, qpTotal: 5, globalRank: 9, globalRankTied: false, ownerUsername: "chris" },
|
||||
{ participantName: "Winless Wonder", qpEarned: 0, qpTotal: 0, globalRank: 0, globalRankTied: false, ownerUsername: "sam" },
|
||||
],
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
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)");
|
||||
// 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")
|
||||
);
|
||||
});
|
||||
|
||||
it("shows Drafted Participants in Top 8 for entries sorted by qpTotal desc", async () => {
|
||||
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 () => {
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
entries: BASE_ENTRIES,
|
||||
scoreboard: BASE_ENTRIES,
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
|
|
@ -828,6 +872,11 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
|||
{ participantName: "Player B", qpEarned: 5, qpTotal: 25 },
|
||||
{ participantName: "Player C", qpEarned: 2, qpTotal: 10 },
|
||||
]),
|
||||
scoreboard: withRanks([
|
||||
{ participantName: "Player A", qpEarned: 10, qpTotal: 25 },
|
||||
{ participantName: "Player B", qpEarned: 5, qpTotal: 25 },
|
||||
{ participantName: "Player C", qpEarned: 2, qpTotal: 10 },
|
||||
]),
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
|
|
@ -869,29 +918,31 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
|||
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.
|
||||
const both = [
|
||||
{
|
||||
participantName: "Player Eight",
|
||||
qpEarned: 5,
|
||||
qpTotal: 10,
|
||||
globalRank: 8,
|
||||
globalRankTied: false,
|
||||
ownerUsername: "eighthowner",
|
||||
},
|
||||
{
|
||||
participantName: "Player Nine",
|
||||
qpEarned: 3,
|
||||
qpTotal: 8,
|
||||
globalRank: 9,
|
||||
globalRankTied: false,
|
||||
ownerUsername: "ninthowner",
|
||||
},
|
||||
];
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Rumble League 2026",
|
||||
sportName: "Tennis - Men",
|
||||
eventName: "Wimbledon",
|
||||
entries: [
|
||||
{
|
||||
participantName: "Player Eight",
|
||||
qpEarned: 5,
|
||||
qpTotal: 10,
|
||||
globalRank: 8,
|
||||
globalRankTied: false,
|
||||
ownerUsername: "eighthowner",
|
||||
},
|
||||
{
|
||||
participantName: "Player Nine",
|
||||
qpEarned: 3,
|
||||
qpTotal: 8,
|
||||
globalRank: 9,
|
||||
globalRankTied: false,
|
||||
ownerUsername: "ninthowner",
|
||||
},
|
||||
],
|
||||
entries: both,
|
||||
scoreboard: both,
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
|
|
@ -899,7 +950,9 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
|||
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.
|
||||
// ...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.
|
||||
expect(desc).toContain("• **Player Nine (ninthowner)** — 3 QP");
|
||||
});
|
||||
|
||||
|
|
@ -916,6 +969,14 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
|||
ownerDiscordUserId: "111222333",
|
||||
},
|
||||
]),
|
||||
scoreboard: withRanks([
|
||||
{
|
||||
participantName: "Novak Djokovic",
|
||||
qpEarned: 20,
|
||||
qpTotal: 45,
|
||||
ownerUsername: "alex",
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
|
|
@ -1021,6 +1082,7 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
|||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
entries: longEntries,
|
||||
scoreboard: longEntries,
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ function makeDb(overrides: Record<string, unknown> = {}) {
|
|||
]),
|
||||
},
|
||||
seasonParticipants: {
|
||||
findMany: vi.fn().mockResolvedValue([{ id: PARTICIPANT_ID, name: "Carlos Alcaraz" }]),
|
||||
findMany: vi.fn().mockResolvedValue([{ id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID }]),
|
||||
},
|
||||
users: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
|
|
@ -263,8 +263,8 @@ describe("notifyQualifyingPointsUpdate", () => {
|
|||
},
|
||||
seasonParticipants: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ id: PARTICIPANT_ID, name: "Carlos Alcaraz" },
|
||||
{ id: "p-2", name: "Rafael Nadal" },
|
||||
{ id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID },
|
||||
{ id: "p-2", name: "Rafael Nadal", sportsSeasonId: SPORTS_SEASON_ID },
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
|
@ -281,6 +281,84 @@ describe("notifyQualifyingPointsUpdate", () => {
|
|||
expect(call.entries[0].participantName).toBe("Carlos Alcaraz");
|
||||
});
|
||||
|
||||
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
|
||||
// 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.
|
||||
const db = makeDb({
|
||||
eventResults: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ seasonParticipantId: PARTICIPANT_ID, qualifyingPointsAwarded: "10" },
|
||||
{ seasonParticipantId: "p-2", qualifyingPointsAwarded: "5" },
|
||||
]),
|
||||
},
|
||||
draftPicks: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ participantId: PARTICIPANT_ID, seasonId: SEASON_ID, team: { name: "Alpha FC", ownerId: null } },
|
||||
{ participantId: "p-2", seasonId: SEASON_ID, team: { name: "Beta FC", ownerId: null } },
|
||||
]),
|
||||
},
|
||||
seasonParticipantQualifyingTotals: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ participantId: PARTICIPANT_ID, totalQualifyingPoints: "45", sportsSeasonId: SPORTS_SEASON_ID },
|
||||
{ participantId: "p-2", totalQualifyingPoints: "20", sportsSeasonId: SPORTS_SEASON_ID },
|
||||
]),
|
||||
},
|
||||
seasonParticipants: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID },
|
||||
{ id: "p-2", name: "Rafael Nadal", sportsSeasonId: SPORTS_SEASON_ID },
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
await notifyQualifyingPointsUpdate(
|
||||
SPORTS_SEASON_ID,
|
||||
SCORING_EVENT_ID,
|
||||
db as never,
|
||||
new Set([PARTICIPANT_ID])
|
||||
);
|
||||
|
||||
const call = vi.mocked(sendQualifyingPointsUpdateNotification).mock.calls[0][0];
|
||||
// entries is scoped to the changed participant…
|
||||
expect(call.entries).toHaveLength(1);
|
||||
expect(call.entries[0].participantName).toBe("Carlos Alcaraz");
|
||||
// …but the scoreboard carries the whole drafted field.
|
||||
expect(call.scoreboard).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ participantName: "Carlos Alcaraz", qpTotal: 45 }),
|
||||
expect.objectContaining({ participantName: "Rafael Nadal", qpEarned: 0, qpTotal: 20 }),
|
||||
])
|
||||
);
|
||||
expect(call.scoreboard).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("excludes participants from other sports seasons drafted in the same fantasy season", async () => {
|
||||
// Draft picks span every sport in a fantasy season, so a golf pick can share the
|
||||
// league with this tennis event. It must not leak into the tennis scoreboard.
|
||||
const db = makeDb({
|
||||
draftPicks: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ participantId: PARTICIPANT_ID, seasonId: SEASON_ID, team: { name: "Alpha FC", ownerId: null } },
|
||||
{ participantId: "p-golf", seasonId: SEASON_ID, team: { name: "Alpha FC", ownerId: null } },
|
||||
]),
|
||||
},
|
||||
seasonParticipants: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID },
|
||||
{ id: "p-golf", name: "Rory McIlroy", sportsSeasonId: "ss-golf" },
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
|
||||
|
||||
const call = vi.mocked(sendQualifyingPointsUpdateNotification).mock.calls[0][0];
|
||||
expect(call.scoreboard).toHaveLength(1);
|
||||
expect(call.scoreboard?.[0]?.participantName).toBe("Carlos Alcaraz");
|
||||
});
|
||||
|
||||
it("does not call sendQualifyingPointsUpdateNotification when all participants are filtered out", async () => {
|
||||
const db = makeDb();
|
||||
|
||||
|
|
@ -315,8 +393,8 @@ describe("notifyQualifyingPointsUpdate", () => {
|
|||
},
|
||||
seasonParticipants: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ id: PARTICIPANT_ID, name: "Carlos Alcaraz" },
|
||||
{ id: MENSIK_ID, name: "Jakob Mensik" },
|
||||
{ id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID },
|
||||
{ id: MENSIK_ID, name: "Jakob Mensik", sportsSeasonId: SPORTS_SEASON_ID },
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -288,6 +288,20 @@ 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,
|
||||
|
|
@ -295,6 +309,7 @@ export async function sendQualifyingPointsUpdateNotification({
|
|||
eventName,
|
||||
entries,
|
||||
eliminated = [],
|
||||
scoreboard = [],
|
||||
standingsUrl,
|
||||
}: {
|
||||
webhookUrl: string;
|
||||
|
|
@ -303,6 +318,13 @@ export async function sendQualifyingPointsUpdateNotification({
|
|||
eventName?: string;
|
||||
entries: QPEventEntry[];
|
||||
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
|
||||
* "Points Awarded"/"Knocked Out" sections and the ping list.
|
||||
*/
|
||||
scoreboard?: QPEventEntry[];
|
||||
standingsUrl?: string;
|
||||
}): Promise<void> {
|
||||
if (entries.length === 0 && eliminated.length === 0) return;
|
||||
|
|
@ -333,20 +355,6 @@ export async function sendQualifyingPointsUpdateNotification({
|
|||
}
|
||||
}
|
||||
|
||||
const zeroEntries = entries.filter((e) => e.qpEarned === 0);
|
||||
if (zeroEntries.length > 0) {
|
||||
sections.push("\n**Non-scoring Participants**");
|
||||
for (const e of zeroEntries) {
|
||||
// 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})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Knocked-out section — drafted players eliminated this sync in a non-scoring
|
||||
// round. They earn no QP, so they'd otherwise never be surfaced to their manager.
|
||||
if (eliminated.length > 0) {
|
||||
|
|
@ -364,19 +372,21 @@ export async function sendQualifyingPointsUpdateNotification({
|
|||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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)
|
||||
.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 (sorted.length > 0) {
|
||||
if (topEight.length > 0) {
|
||||
sections.push("\n**Drafted Participants in Top 8**");
|
||||
for (const e of sorted) {
|
||||
for (const e of topEight) {
|
||||
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)})` : "";
|
||||
|
|
@ -384,6 +394,26 @@ export async function sendQualifyingPointsUpdateNotification({
|
|||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
|
|
|||
|
|
@ -125,12 +125,22 @@ 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)
|
||||
const allParticipantIds = [...new Set([...qpEarnedById.keys(), ...eliminatedIds])];
|
||||
// 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.
|
||||
const allParticipantIds = [
|
||||
...new Set([...qpEarnedById.keys(), ...eliminatedIds, ...draftedParticipantIds]),
|
||||
];
|
||||
const participants = await db.query.seasonParticipants.findMany({
|
||||
where: inArray(schema.seasonParticipants.id, allParticipantIds),
|
||||
});
|
||||
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.
|
||||
const sportsSeasonParticipantIds = new Set(
|
||||
participants.filter((p) => p.sportsSeasonId === sportsSeasonId).map((p) => p.id)
|
||||
);
|
||||
|
||||
// Batch-fetch all team owners and their Discord IDs in two queries (not N per league)
|
||||
const allOwnerIds = new Set<string>();
|
||||
|
|
@ -208,6 +218,25 @@ 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.
|
||||
// Never pinged, so ownerDiscordUserId is intentionally omitted.
|
||||
const scoreboard: QPEventEntry[] = [...teamByParticipantId.keys()]
|
||||
.filter((participantId) => sportsSeasonParticipantIds.has(participantId))
|
||||
.map((participantId) => {
|
||||
const ownerId = teamByParticipantId.get(participantId)?.ownerId ?? null;
|
||||
const globalRank = globalRankById.get(participantId) ?? 0;
|
||||
return {
|
||||
participantName: participantNameById.get(participantId) ?? participantId,
|
||||
qpEarned: qpEarnedById.get(participantId) ?? 0,
|
||||
qpTotal: qpTotalById.get(participantId) ?? 0,
|
||||
globalRank,
|
||||
globalRankTied: (countByRank.get(globalRank) ?? 0) > 1,
|
||||
ownerUsername: ownerId ? (usernameByUserId.get(ownerId) ?? undefined) : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl,
|
||||
seasonName,
|
||||
|
|
@ -215,6 +244,7 @@ export async function notifyQualifyingPointsUpdate(
|
|||
eventName,
|
||||
entries,
|
||||
eliminated,
|
||||
scoreboard,
|
||||
standingsUrl,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue