- Filter scored matches to only show matchups where a manager scores Brackt points (winner drafted) or has a team eliminated (loser drafted); matches with no manager involvement are suppressed entirely. - Escape Discord markdown characters (_*~`|\) in team names and usernames to prevent formatting issues (e.g. double-underscore names causing unintended underlines). - Replace full standings with a "Standings Changes" section that shows only teams whose points changed, plus any teams whose rank shifted as a result, with ↑N / ↓N indicators for rank movement. - Pass previousRanks map from scoring-calculator to the notification so rank-displaced teams (who didn't score points themselves) are included. - Update test webhook in league settings to demonstrate rank changes and a sample scored match. - Update all discord service tests to cover the new filtering, escaping, and standings-change behaviour. https://claude.ai/code/session_01FciwfdG9Sfr5ZrXUHPeB18
This commit is contained in:
parent
52e7c98805
commit
be9f30753f
4 changed files with 191 additions and 64 deletions
|
|
@ -1280,6 +1280,9 @@ export async function recalculateAffectedLeagues(
|
|||
const previousPointsById = new Map(
|
||||
beforeStandings.map((s) => [s.teamId, parseFloat(s.totalPoints)])
|
||||
);
|
||||
const previousRanksById = new Map(
|
||||
beforeStandings.map((s) => [s.teamId, s.currentRank])
|
||||
);
|
||||
|
||||
await recalculateStandings(seasonId, db);
|
||||
|
||||
|
|
@ -1391,6 +1394,7 @@ export async function recalculateAffectedLeagues(
|
|||
seasonName: `${season.league.name} ${season.year}`,
|
||||
standings,
|
||||
previousStandings: previousPointsById,
|
||||
previousRanks: previousRanksById,
|
||||
sportName,
|
||||
eventName: options?.eventName,
|
||||
scoredMatches,
|
||||
|
|
|
|||
|
|
@ -247,7 +247,15 @@ export async function action(args: Route.ActionArgs) {
|
|||
["2", 125],
|
||||
["3", 100],
|
||||
]),
|
||||
previousRanks: new Map([
|
||||
["1", 2],
|
||||
["2", 1],
|
||||
["3", 3],
|
||||
]),
|
||||
eventName: "Test Notification",
|
||||
scoredMatches: [
|
||||
{ winnerName: "Team Alpha", loserName: "Team Beta", winnerUsername: "manager1", loserUsername: "manager2" },
|
||||
],
|
||||
});
|
||||
return { testSuccess: true };
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -52,14 +52,14 @@ describe("sendStandingsUpdateNotification", () => {
|
|||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "My League 2025",
|
||||
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 150, rank: 1 }],
|
||||
previousStandings: new Map(),
|
||||
previousStandings: new Map([["a", 125]]),
|
||||
});
|
||||
|
||||
const body = JSON.parse((fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body);
|
||||
expect(body.embeds[0].title).toBe("📊 Standings Update — My League 2025");
|
||||
});
|
||||
|
||||
it("shows point deltas as integers in description", async () => {
|
||||
it("shows point deltas as integers in standings changes", async () => {
|
||||
await sendStandingsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Test League",
|
||||
|
|
@ -77,16 +77,16 @@ describe("sendStandingsUpdateNotification", () => {
|
|||
expect(desc).toContain("Alpha");
|
||||
expect(desc).toContain("+25 pts");
|
||||
expect(desc).not.toContain("+25.0");
|
||||
// Beta didn't change — no delta shown
|
||||
expect(desc).toMatch(/Beta.*100 pts(?!\s*\()/);
|
||||
// Beta didn't change — not shown in standings changes
|
||||
expect(desc).not.toContain("Beta");
|
||||
});
|
||||
|
||||
it("includes sport name and event name in header when both provided", async () => {
|
||||
await sendStandingsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "My League 2025",
|
||||
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }],
|
||||
previousStandings: new Map(),
|
||||
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }],
|
||||
previousStandings: new Map([["a", 100]]),
|
||||
sportName: "UEFA Champions League",
|
||||
eventName: "Knockout Stage",
|
||||
});
|
||||
|
|
@ -99,8 +99,8 @@ describe("sendStandingsUpdateNotification", () => {
|
|||
await sendStandingsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "My League 2025",
|
||||
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }],
|
||||
previousStandings: new Map(),
|
||||
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }],
|
||||
previousStandings: new Map([["a", 100]]),
|
||||
eventName: "Quarter Finals",
|
||||
});
|
||||
|
||||
|
|
@ -113,37 +113,36 @@ describe("sendStandingsUpdateNotification", () => {
|
|||
await sendStandingsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "My League 2025",
|
||||
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }],
|
||||
previousStandings: new Map(),
|
||||
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }],
|
||||
previousStandings: new Map([["a", 100]]),
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
// Description should start directly with the standings section
|
||||
expect(desc).toContain("**Current Standings**");
|
||||
expect(desc).toContain("**Standings Changes**");
|
||||
expect(desc).not.toContain("**Scored Matches**");
|
||||
});
|
||||
|
||||
it("lists scored matches above standings", async () => {
|
||||
it("lists scored matches above standings changes", async () => {
|
||||
await sendStandingsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "My League 2025",
|
||||
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }],
|
||||
previousStandings: new Map(),
|
||||
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }],
|
||||
previousStandings: new Map([["a", 100]]),
|
||||
scoredMatches: [
|
||||
{ winnerName: "Real Madrid", loserName: "Bayern Munich" },
|
||||
{ winnerName: "Arsenal", loserName: "PSG" },
|
||||
{ winnerName: "Real Madrid", loserName: "Bayern Munich", winnerUsername: "manager1" },
|
||||
{ winnerName: "Arsenal", loserName: "PSG", loserUsername: "manager2" },
|
||||
],
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
expect(desc).toContain("**Scored Matches**");
|
||||
expect(desc).toContain("• **Real Madrid** def. Bayern Munich");
|
||||
expect(desc).toContain("• **Arsenal** def. PSG");
|
||||
expect(desc).toContain("• **Real Madrid (manager1)** def. Bayern Munich");
|
||||
expect(desc).toContain("• **Arsenal** def. PSG (manager2)");
|
||||
// Scored matches section should appear before standings
|
||||
expect(desc.indexOf("Scored Matches")).toBeLessThan(desc.indexOf("Current Standings"));
|
||||
expect(desc.indexOf("Scored Matches")).toBeLessThan(desc.indexOf("Standings Changes"));
|
||||
});
|
||||
|
||||
it("shows username in parentheses in standings when provided", async () => {
|
||||
it("shows username in parentheses in standings changes when provided", async () => {
|
||||
await sendStandingsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "My League 2025",
|
||||
|
|
@ -151,21 +150,24 @@ describe("sendStandingsUpdateNotification", () => {
|
|||
{ teamId: "a", teamName: "Alpha FC", username: "christhrowsrocks", totalPoints: 150, rank: 1 },
|
||||
{ teamId: "b", teamName: "Beta United", totalPoints: 100, rank: 2 },
|
||||
],
|
||||
previousStandings: new Map(),
|
||||
previousStandings: new Map([
|
||||
["a", 125],
|
||||
["b", 100],
|
||||
]),
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
expect(desc).toContain("1. Alpha FC (christhrowsrocks) — 150 pts");
|
||||
expect(desc).toContain("2. Beta United — 100 pts");
|
||||
expect(desc).not.toContain("Beta United (");
|
||||
// Beta didn't change points or rank, so it's not shown
|
||||
expect(desc).not.toContain("Beta United");
|
||||
});
|
||||
|
||||
it("shows usernames in parentheses in match results when both are drafted", async () => {
|
||||
await sendStandingsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "My League 2025",
|
||||
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }],
|
||||
previousStandings: new Map(),
|
||||
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }],
|
||||
previousStandings: new Map([["a", 100]]),
|
||||
scoredMatches: [
|
||||
{
|
||||
winnerName: "Sporting",
|
||||
|
|
@ -180,27 +182,27 @@ describe("sendStandingsUpdateNotification", () => {
|
|||
expect(desc).toContain("• **Sporting (christhrowsrocks)** def. Bodø/Glimt (apatel)");
|
||||
});
|
||||
|
||||
it("shows no parentheses in match results when neither side has a username", async () => {
|
||||
it("omits scored matches where neither side has a username", async () => {
|
||||
await sendStandingsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "My League 2025",
|
||||
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }],
|
||||
previousStandings: new Map(),
|
||||
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }],
|
||||
previousStandings: new Map([["a", 100]]),
|
||||
scoredMatches: [{ winnerName: "Real Madrid", loserName: "Bayern Munich" }],
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
expect(desc).toContain("• **Real Madrid** def. Bayern Munich");
|
||||
expect(desc).not.toContain("Real Madrid (");
|
||||
expect(desc).not.toContain("Bayern Munich (");
|
||||
expect(desc).not.toContain("**Scored Matches**");
|
||||
expect(desc).not.toContain("Real Madrid");
|
||||
expect(desc).not.toContain("Bayern Munich");
|
||||
});
|
||||
|
||||
it("omits username parenthesis only for the undrafted side in match results", async () => {
|
||||
await sendStandingsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "My League 2025",
|
||||
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }],
|
||||
previousStandings: new Map(),
|
||||
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }],
|
||||
previousStandings: new Map([["a", 100]]),
|
||||
scoredMatches: [
|
||||
{
|
||||
winnerName: "Sporting",
|
||||
|
|
@ -215,7 +217,7 @@ describe("sendStandingsUpdateNotification", () => {
|
|||
expect(desc).not.toContain("Bodø/Glimt (");
|
||||
});
|
||||
|
||||
it("uses plain numbers for all ranks in description", async () => {
|
||||
it("shows teams that changed rank even without a point change", async () => {
|
||||
await sendStandingsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "My League 2025",
|
||||
|
|
@ -223,27 +225,107 @@ describe("sendStandingsUpdateNotification", () => {
|
|||
{ teamId: "a", teamName: "Alpha", totalPoints: 150, rank: 1 },
|
||||
{ teamId: "b", teamName: "Beta", totalPoints: 125, rank: 2 },
|
||||
{ teamId: "c", teamName: "Gamma", totalPoints: 100, rank: 3 },
|
||||
{ teamId: "d", teamName: "Delta", totalPoints: 75, rank: 4 },
|
||||
],
|
||||
previousStandings: new Map(),
|
||||
previousStandings: new Map([
|
||||
["a", 125],
|
||||
["b", 125],
|
||||
["c", 100],
|
||||
]),
|
||||
previousRanks: new Map([
|
||||
["a", 2],
|
||||
["b", 1],
|
||||
["c", 3],
|
||||
]),
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
expect(desc).toContain("1. Alpha");
|
||||
expect(desc).toContain("2. Beta");
|
||||
expect(desc).toContain("3. Gamma");
|
||||
expect(desc).toContain("4. Delta");
|
||||
// Alpha gained points and moved up
|
||||
expect(desc).toContain("Alpha");
|
||||
expect(desc).toContain("+25 pts");
|
||||
expect(desc).toContain("↑1");
|
||||
// Beta didn't gain points but was displaced — rank changed
|
||||
expect(desc).toContain("Beta");
|
||||
expect(desc).toContain("↓1");
|
||||
// Gamma unchanged — not shown
|
||||
expect(desc).not.toContain("Gamma");
|
||||
});
|
||||
|
||||
it("omits rank delta when no previousRanks provided", async () => {
|
||||
await sendStandingsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "My League 2025",
|
||||
standings: [
|
||||
{ teamId: "a", teamName: "Alpha", totalPoints: 150, rank: 1 },
|
||||
],
|
||||
previousStandings: new Map([["a", 125]]),
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
expect(desc).toContain("Alpha");
|
||||
expect(desc).not.toContain("↑");
|
||||
expect(desc).not.toContain("↓");
|
||||
});
|
||||
|
||||
it("escapes Discord markdown characters in team names and usernames", async () => {
|
||||
await sendStandingsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "My League 2025",
|
||||
standings: [
|
||||
{ teamId: "a", teamName: "Team__Underline", username: "user__name", totalPoints: 150, rank: 1 },
|
||||
],
|
||||
previousStandings: new Map([["a", 125]]),
|
||||
scoredMatches: [
|
||||
{
|
||||
winnerName: "Winner*Bold*",
|
||||
loserName: "Loser~Strike~",
|
||||
winnerUsername: "user_one",
|
||||
loserUsername: "user_two",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
expect(desc).toContain("Team\\_\\_Underline");
|
||||
expect(desc).toContain("user\\_\\_name");
|
||||
expect(desc).toContain("Winner\\*Bold\\*");
|
||||
expect(desc).toContain("Loser\\~Strike\\~");
|
||||
expect(desc).toContain("user\\_one");
|
||||
expect(desc).toContain("user\\_two");
|
||||
});
|
||||
|
||||
it("does not show fields on the embed", async () => {
|
||||
await sendStandingsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "My League 2025",
|
||||
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }],
|
||||
previousStandings: new Map(),
|
||||
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }],
|
||||
previousStandings: new Map([["a", 100]]),
|
||||
});
|
||||
|
||||
const body = JSON.parse((fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body);
|
||||
expect(body.embeds[0].fields).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits standings changes section when no teams changed", async () => {
|
||||
await sendStandingsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "My League 2025",
|
||||
standings: [
|
||||
{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 },
|
||||
{ teamId: "b", teamName: "Beta", totalPoints: 75, rank: 2 },
|
||||
],
|
||||
previousStandings: new Map([
|
||||
["a", 100],
|
||||
["b", 75],
|
||||
]),
|
||||
previousRanks: new Map([
|
||||
["a", 1],
|
||||
["b", 2],
|
||||
]),
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
expect(desc).not.toContain("**Standings Changes**");
|
||||
expect(desc).not.toContain("Alpha");
|
||||
expect(desc).not.toContain("Beta");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,6 +26,11 @@ export async function sendDiscordWebhook(
|
|||
}
|
||||
}
|
||||
|
||||
/** Escape characters that trigger Discord markdown formatting. */
|
||||
function escapeMarkdown(text: string): string {
|
||||
return text.replace(/[_*~`|\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
export interface StandingEntry {
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
|
|
@ -46,6 +51,7 @@ export async function sendStandingsUpdateNotification({
|
|||
seasonName,
|
||||
standings,
|
||||
previousStandings,
|
||||
previousRanks,
|
||||
sportName,
|
||||
eventName,
|
||||
scoredMatches,
|
||||
|
|
@ -54,6 +60,7 @@ export async function sendStandingsUpdateNotification({
|
|||
seasonName: string;
|
||||
standings: StandingEntry[];
|
||||
previousStandings: Map<string, number>;
|
||||
previousRanks?: Map<string, number>;
|
||||
sportName?: string;
|
||||
eventName?: string;
|
||||
scoredMatches?: ScoredMatch[];
|
||||
|
|
@ -66,32 +73,58 @@ export async function sendStandingsUpdateNotification({
|
|||
sections.push(`**${parts.join(" — ")}**`);
|
||||
}
|
||||
|
||||
// Scored matches section
|
||||
if (scoredMatches && scoredMatches.length > 0) {
|
||||
// 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 scoredMatches) {
|
||||
const winner = match.winnerUsername
|
||||
? `${match.winnerName} (${match.winnerUsername})`
|
||||
: match.winnerName;
|
||||
const loser = match.loserUsername
|
||||
? `${match.loserName} (${match.loserUsername})`
|
||||
: match.loserName;
|
||||
sections.push(`• **${winner}** def. ${loser}`);
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 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)**`;
|
||||
// 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 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(`${s.rank}. ${label} — ${Math.round(s.totalPoints)} pts${pointDelta}${rankDelta}`);
|
||||
}
|
||||
const label = s.username ? `${s.teamName} (${s.username})` : s.teamName;
|
||||
sections.push(`${s.rank}. ${label} — ${Math.round(s.totalPoints)} pts${delta}`);
|
||||
}
|
||||
|
||||
const MAX_DESCRIPTION = 4096;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue