Fix qualifying-points notifications: full-field scoreboard + manual-set announcements #134

Merged
chrisp merged 1 commit from claude/qp-scoreboard-all-participants into main 2026-07-09 17:24:28 +00:00
5 changed files with 267 additions and 56 deletions
Showing only changes of commit 5d0363a309 - Show all commits

View file

@ -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.

View file

@ -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();

View file

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

View file

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

View file

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