brackt/app/services/discord.ts
Chris Parsons beab6400b2
Fix partial bracket scoring, rank labels, and Discord notification bugs (#158)
- NCAAM R64/R32 winners no longer receive T5 floor points; only Sweet
  Sixteen winners entering the first scoring round get a floor
- AFL T5-T6 and T7-T8 score as separate tiers (avg([5,6]) and avg([7,8]))
  instead of one shared avg([5-8]) pool
- Eliminated teams now always show their correct final rank (e.g. T33 for
  NCAAM R64 losers) even during partial round scoring, by deriving the
  rank label from the total match count per round rather than the dynamic
  still-alive count
- Discord notifications now fire when a drafted participant is eliminated
  with 0 points (e.g. R64 losers whose team score doesn't change)
- Discord notifications no longer include all prior matches — scoped to
  the current batch via matchIds
- Discord section headers have spacing; rank medals replaced with numbers
- Rounds auto-complete when all matches are marked done; manual Complete
  Round dropdown removed
- Fixed double Discord notification when the last match in a round
  triggers auto-complete: processPlayoffEvent now accepts skipRecalculate
  so the caller controls when the notification fires
- Fixed falsy check on finalPosition=0 in calculateTeamScore
- Undrafted 0-point participants filtered from Eliminated Teams display

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 12:34:10 -07:00

103 lines
2.5 KiB
TypeScript

interface DiscordEmbed {
title?: string;
description?: string;
color?: number;
footer?: { text: string };
}
interface DiscordWebhookPayload {
content?: string;
embeds?: DiscordEmbed[];
}
export async function sendDiscordWebhook(
webhookUrl: string,
payload: DiscordWebhookPayload
): Promise<void> {
const response = await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(`Discord webhook failed: ${response.status} ${text}`);
}
}
export interface StandingEntry {
teamId: string;
teamName: string;
totalPoints: number;
rank: number;
}
export interface ScoredMatch {
winnerName: string;
loserName: string;
}
export async function sendStandingsUpdateNotification({
webhookUrl,
seasonName,
standings,
previousStandings,
sportName,
eventName,
scoredMatches,
}: {
webhookUrl: string;
seasonName: string;
standings: StandingEntry[];
previousStandings: Map<string, number>;
sportName?: string;
eventName?: string;
scoredMatches?: ScoredMatch[];
}): Promise<void> {
const sections: string[] = [];
// Header: "Sport Name — Event Name"
if (sportName || eventName) {
const parts = [sportName, eventName].filter(Boolean);
sections.push(`**${parts.join(" — ")}**`);
}
// Scored matches section
if (scoredMatches && scoredMatches.length > 0) {
sections.push("\n**Scored Matches**");
for (const match of scoredMatches) {
sections.push(`• **${match.winnerName}** def. ${match.loserName}`);
}
}
// Standings section
sections.push("\n**Current Standings**");
for (const s of standings) {
const prev = previousStandings.get(s.teamId);
let delta = "";
if (prev !== undefined && prev !== s.totalPoints) {
const diff = Math.round(s.totalPoints - prev);
const sign = diff > 0 ? "+" : "";
delta = ` **(${sign}${diff} pts)**`;
}
sections.push(`${s.rank}. ${s.teamName}${Math.round(s.totalPoints)} pts${delta}`);
}
const MAX_DESCRIPTION = 4096;
let description = sections.join("\n");
if (description.length > MAX_DESCRIPTION) {
description = description.slice(0, MAX_DESCRIPTION - 3) + "...";
}
await sendDiscordWebhook(webhookUrl, {
embeds: [
{
title: `📊 Standings Update — ${seasonName}`,
description,
color: 0x5865f2, // Discord blurple
footer: { text: "brackt.com" },
},
],
});
}