brackt/app/services/discord.ts
Chris Parsons 611e1ccf0a
Show tied ranks with T prefix across standings and Discord (#239)
* 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>
2026-03-27 20:45:15 -07:00

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