* Show tied ranks with T prefix across standings and Discord notifications, fixes #197 - Add buildTiedRankChecker() utility to standings-display.ts; replaces four copies of inline rank-count logic spread across components, the league home route, and the Discord service - Prefix shared ranks with "T" (e.g. "T3") in StandingsTable (league panel), StandingsTable (full standings), the league home preview, and Discord webhook notifications - Add useMemo wrapping in StandingsTable components so tie detection does not recompute on every render - Fix compareTeamsForRanking to round total points to hundredths before comparing, preventing floating-point noise from producing spurious non-ties in the standings Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix discord test to expect escaped period in rank display Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
152 lines
4.7 KiB
TypeScript
152 lines
4.7 KiB
TypeScript
import { buildTiedRankChecker } from "~/lib/standings-display";
|
|
|
|
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}`);
|
|
}
|
|
}
|
|
|
|
/** Escape characters that trigger Discord markdown formatting. */
|
|
function escapeMarkdown(text: string): string {
|
|
return text.replace(/[_*~`|\\]/g, "\\$&");
|
|
}
|
|
|
|
export interface StandingEntry {
|
|
teamId: string;
|
|
teamName: string;
|
|
username?: string;
|
|
totalPoints: number;
|
|
rank: number;
|
|
}
|
|
|
|
export interface ScoredMatch {
|
|
winnerName: string;
|
|
loserName: string;
|
|
winnerUsername?: string;
|
|
loserUsername?: string;
|
|
}
|
|
|
|
export async function sendStandingsUpdateNotification({
|
|
webhookUrl,
|
|
seasonName,
|
|
standings,
|
|
previousStandings,
|
|
previousRanks,
|
|
sportName,
|
|
eventName,
|
|
scoredMatches,
|
|
}: {
|
|
webhookUrl: string;
|
|
seasonName: string;
|
|
standings: StandingEntry[];
|
|
previousStandings: Map<string, number>;
|
|
previousRanks?: 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 — only show matches where at least one manager
|
|
// (fantasy team owner) scored Brackt points or had a team eliminated.
|
|
const relevantMatches = scoredMatches?.filter(
|
|
(m) => m.winnerUsername !== undefined || m.loserUsername !== undefined
|
|
);
|
|
if (relevantMatches && relevantMatches.length > 0) {
|
|
sections.push("\n**Scored Matches**");
|
|
for (const match of relevantMatches) {
|
|
const winnerLabel = match.winnerUsername
|
|
? `${escapeMarkdown(match.winnerName)} (${escapeMarkdown(match.winnerUsername)})`
|
|
: escapeMarkdown(match.winnerName);
|
|
const loserLabel = match.loserUsername
|
|
? `${escapeMarkdown(match.loserName)} (${escapeMarkdown(match.loserUsername)})`
|
|
: escapeMarkdown(match.loserName);
|
|
sections.push(`• **${winnerLabel}** def. ${loserLabel}`);
|
|
}
|
|
}
|
|
|
|
const isTied = buildTiedRankChecker(standings.map((s) => s.rank));
|
|
const rankLabel = (rank: number) => (isTied(rank) ? `T${rank}` : `${rank}`);
|
|
|
|
// Standings changes section — show teams whose points or rank changed.
|
|
const changedTeams = standings.filter((s) => {
|
|
const prevPoints = previousStandings.get(s.teamId);
|
|
const pointsChanged = prevPoints !== undefined && prevPoints !== s.totalPoints;
|
|
const prevRank = previousRanks?.get(s.teamId);
|
|
const rankChanged = prevRank !== undefined && prevRank !== s.rank;
|
|
return pointsChanged || rankChanged;
|
|
});
|
|
|
|
if (changedTeams.length > 0) {
|
|
sections.push("\n**Standings Changes**");
|
|
for (const s of changedTeams) {
|
|
const rankPrefix = rankLabel(s.rank);
|
|
const prevPoints = previousStandings.get(s.teamId);
|
|
let pointDelta = "";
|
|
if (prevPoints !== undefined && prevPoints !== s.totalPoints) {
|
|
const diff = Math.round(s.totalPoints - prevPoints);
|
|
const sign = diff > 0 ? "+" : "";
|
|
pointDelta = ` **(${sign}${diff} pts)**`;
|
|
}
|
|
|
|
let rankDelta = "";
|
|
if (previousRanks) {
|
|
const prevRank = previousRanks.get(s.teamId);
|
|
if (prevRank !== undefined && prevRank !== s.rank) {
|
|
const moved = prevRank - s.rank; // positive = moved up
|
|
rankDelta = moved > 0 ? ` ↑${moved}` : ` ↓${Math.abs(moved)}`;
|
|
}
|
|
}
|
|
|
|
const escapedName = escapeMarkdown(s.teamName);
|
|
const escapedUsername = s.username ? escapeMarkdown(s.username) : undefined;
|
|
const label = escapedUsername ? `${escapedName} (${escapedUsername})` : escapedName;
|
|
sections.push(`${rankPrefix}\\. ${label} — ${Math.round(s.totalPoints)} pts${pointDelta}${rankDelta}`);
|
|
}
|
|
}
|
|
|
|
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" },
|
|
},
|
|
],
|
|
});
|
|
}
|