Refactor standings notifications to show only changed teams (#182)
* Refine Discord webhook scoring notifications (fixes #180, #181) - 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 * fix: only set winnerUsername when winning team's score actually changed Previously, winnerUsername was set for any drafted winner regardless of whether points were actually scored. This caused R64 wins (where the scoring system may not award points until later rounds) to appear in Discord's Scored Matches section even when no Brackt points were earned. Now winnerUsername is only set when the winner's team's totalPoints changed after recalculation. loserUsername (eliminations) is always set when the loser is drafted, since being knocked out is always notable. https://claude.ai/code/session_01FciwfdG9Sfr5ZrXUHPeB18 * Refactor Discord notification logic in scoring calculator - Compute changedTeamIds once up front; derive hasChanges from it instead of duplicating the same iteration - Merge usernameForParticipant and winnerUsernameForParticipant into a single function with a requireScoreChange flag - Replace hasDraftedParticipantMatches with hasScoredMatchesToShow, which checks that at least one scoredMatch has a displayable username — prevents sending a contentless Discord embed when a drafted winner doesn't score any points and the loser isn't drafted - Update inline comment to be sport-agnostic https://claude.ai/code/session_01FciwfdG9Sfr5ZrXUHPeB18 --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
52e7c98805
commit
d271cc6792
4 changed files with 218 additions and 79 deletions
|
|
@ -1280,6 +1280,9 @@ export async function recalculateAffectedLeagues(
|
||||||
const previousPointsById = new Map(
|
const previousPointsById = new Map(
|
||||||
beforeStandings.map((s) => [s.teamId, parseFloat(s.totalPoints)])
|
beforeStandings.map((s) => [s.teamId, parseFloat(s.totalPoints)])
|
||||||
);
|
);
|
||||||
|
const previousRanksById = new Map(
|
||||||
|
beforeStandings.map((s) => [s.teamId, s.currentRank])
|
||||||
|
);
|
||||||
|
|
||||||
await recalculateStandings(seasonId, db);
|
await recalculateStandings(seasonId, db);
|
||||||
|
|
||||||
|
|
@ -1296,10 +1299,15 @@ export async function recalculateAffectedLeagues(
|
||||||
orderBy: (ts, { asc }) => [asc(ts.currentRank)],
|
orderBy: (ts, { asc }) => [asc(ts.currentRank)],
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasChanges = afterStandings.some((s) => {
|
const changedTeamIds = new Set(
|
||||||
const prev = previousPointsById.get(s.teamId);
|
afterStandings
|
||||||
return prev === undefined || prev !== parseFloat(s.totalPoints);
|
.filter((s) => {
|
||||||
});
|
const prev = previousPointsById.get(s.teamId);
|
||||||
|
return prev === undefined || prev !== parseFloat(s.totalPoints);
|
||||||
|
})
|
||||||
|
.map((s) => s.teamId)
|
||||||
|
);
|
||||||
|
const hasChanges = changedTeamIds.size > 0;
|
||||||
|
|
||||||
// Look up the league's Discord webhook URL via the season
|
// Look up the league's Discord webhook URL via the season
|
||||||
const season = await db.query.seasons.findFirst({
|
const season = await db.query.seasons.findFirst({
|
||||||
|
|
@ -1330,12 +1338,10 @@ export async function recalculateAffectedLeagues(
|
||||||
afterStandings.map((s) => [s.teamId, s.team.ownerId])
|
afterStandings.map((s) => [s.teamId, s.team.ownerId])
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter matches to only those involving participants drafted in this season.
|
// Build scored matches for the notification — computed before the gate
|
||||||
// We compute this BEFORE the hasChanges gate because 0-point eliminations
|
// because an elimination (0 points, no score change) still warrants a
|
||||||
// (e.g. R64 losers whose finalPosition=0 doesn't affect team scores) still
|
// notification when a drafted participant is knocked out.
|
||||||
// warrant a Discord notification when a drafted team is eliminated.
|
|
||||||
let scoredMatches: ScoredMatch[] | undefined;
|
let scoredMatches: ScoredMatch[] | undefined;
|
||||||
let hasDraftedParticipantMatches = false;
|
|
||||||
if (allCompletedMatches.length > 0) {
|
if (allCompletedMatches.length > 0) {
|
||||||
const draftPicks = await db.query.draftPicks.findMany({
|
const draftPicks = await db.query.draftPicks.findMany({
|
||||||
where: eq(schema.draftPicks.seasonId, seasonId),
|
where: eq(schema.draftPicks.seasonId, seasonId),
|
||||||
|
|
@ -1348,16 +1354,22 @@ export async function recalculateAffectedLeagues(
|
||||||
(m.loserId && draftedIds.has(m.loserId))
|
(m.loserId && draftedIds.has(m.loserId))
|
||||||
);
|
);
|
||||||
|
|
||||||
hasDraftedParticipantMatches = relevant.length > 0;
|
|
||||||
|
|
||||||
if (relevant.length > 0) {
|
if (relevant.length > 0) {
|
||||||
const teamIdByParticipantId = new Map(
|
const teamIdByParticipantId = new Map(
|
||||||
draftPicks.map((p) => [p.participantId, p.teamId])
|
draftPicks.map((p) => [p.participantId, p.teamId])
|
||||||
);
|
);
|
||||||
const usernameForParticipant = (participantId: string | null | undefined) => {
|
|
||||||
|
// Look up the fantasy owner's display name for a participant.
|
||||||
|
// Pass requireScoreChange=true for the winner to avoid surfacing wins
|
||||||
|
// that didn't award points this round.
|
||||||
|
const usernameForParticipant = (
|
||||||
|
participantId: string | null | undefined,
|
||||||
|
requireScoreChange = false
|
||||||
|
) => {
|
||||||
if (!participantId) return undefined;
|
if (!participantId) return undefined;
|
||||||
const teamId = teamIdByParticipantId.get(participantId);
|
const teamId = teamIdByParticipantId.get(participantId);
|
||||||
if (!teamId) return undefined;
|
if (!teamId) return undefined;
|
||||||
|
if (requireScoreChange && !changedTeamIds.has(teamId)) return undefined;
|
||||||
const ownerId = ownerIdByTeamId.get(teamId);
|
const ownerId = ownerIdByTeamId.get(teamId);
|
||||||
if (!ownerId) return undefined;
|
if (!ownerId) return undefined;
|
||||||
return usernameByClerkId.get(ownerId);
|
return usernameByClerkId.get(ownerId);
|
||||||
|
|
@ -1368,14 +1380,17 @@ export async function recalculateAffectedLeagues(
|
||||||
.map((m) => ({
|
.map((m) => ({
|
||||||
winnerName: m.winnerName!,
|
winnerName: m.winnerName!,
|
||||||
loserName: m.loserName!,
|
loserName: m.loserName!,
|
||||||
winnerUsername: usernameForParticipant(m.winnerId),
|
winnerUsername: usernameForParticipant(m.winnerId, true),
|
||||||
loserUsername: usernameForParticipant(m.loserId),
|
loserUsername: usernameForParticipant(m.loserId),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip notification if scores didn't change AND no drafted participants were scored.
|
// Skip notification if scores didn't change AND no match has a displayable username.
|
||||||
if (!hasChanges && !hasDraftedParticipantMatches) continue;
|
const hasScoredMatchesToShow = scoredMatches?.some(
|
||||||
|
(m) => m.winnerUsername !== undefined || m.loserUsername !== undefined
|
||||||
|
);
|
||||||
|
if (!hasChanges && !hasScoredMatchesToShow) continue;
|
||||||
|
|
||||||
const standings = afterStandings.map((s) => ({
|
const standings = afterStandings.map((s) => ({
|
||||||
teamId: s.teamId,
|
teamId: s.teamId,
|
||||||
|
|
@ -1391,6 +1406,7 @@ export async function recalculateAffectedLeagues(
|
||||||
seasonName: `${season.league.name} ${season.year}`,
|
seasonName: `${season.league.name} ${season.year}`,
|
||||||
standings,
|
standings,
|
||||||
previousStandings: previousPointsById,
|
previousStandings: previousPointsById,
|
||||||
|
previousRanks: previousRanksById,
|
||||||
sportName,
|
sportName,
|
||||||
eventName: options?.eventName,
|
eventName: options?.eventName,
|
||||||
scoredMatches,
|
scoredMatches,
|
||||||
|
|
|
||||||
|
|
@ -247,7 +247,15 @@ export async function action(args: Route.ActionArgs) {
|
||||||
["2", 125],
|
["2", 125],
|
||||||
["3", 100],
|
["3", 100],
|
||||||
]),
|
]),
|
||||||
|
previousRanks: new Map([
|
||||||
|
["1", 2],
|
||||||
|
["2", 1],
|
||||||
|
["3", 3],
|
||||||
|
]),
|
||||||
eventName: "Test Notification",
|
eventName: "Test Notification",
|
||||||
|
scoredMatches: [
|
||||||
|
{ winnerName: "Team Alpha", loserName: "Team Beta", winnerUsername: "manager1", loserUsername: "manager2" },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
return { testSuccess: true };
|
return { testSuccess: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -52,14 +52,14 @@ describe("sendStandingsUpdateNotification", () => {
|
||||||
webhookUrl: WEBHOOK_URL,
|
webhookUrl: WEBHOOK_URL,
|
||||||
seasonName: "My League 2025",
|
seasonName: "My League 2025",
|
||||||
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 150, rank: 1 }],
|
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);
|
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");
|
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({
|
await sendStandingsUpdateNotification({
|
||||||
webhookUrl: WEBHOOK_URL,
|
webhookUrl: WEBHOOK_URL,
|
||||||
seasonName: "Test League",
|
seasonName: "Test League",
|
||||||
|
|
@ -77,16 +77,16 @@ describe("sendStandingsUpdateNotification", () => {
|
||||||
expect(desc).toContain("Alpha");
|
expect(desc).toContain("Alpha");
|
||||||
expect(desc).toContain("+25 pts");
|
expect(desc).toContain("+25 pts");
|
||||||
expect(desc).not.toContain("+25.0");
|
expect(desc).not.toContain("+25.0");
|
||||||
// Beta didn't change — no delta shown
|
// Beta didn't change — not shown in standings changes
|
||||||
expect(desc).toMatch(/Beta.*100 pts(?!\s*\()/);
|
expect(desc).not.toContain("Beta");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes sport name and event name in header when both provided", async () => {
|
it("includes sport name and event name in header when both provided", async () => {
|
||||||
await sendStandingsUpdateNotification({
|
await sendStandingsUpdateNotification({
|
||||||
webhookUrl: WEBHOOK_URL,
|
webhookUrl: WEBHOOK_URL,
|
||||||
seasonName: "My League 2025",
|
seasonName: "My League 2025",
|
||||||
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }],
|
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }],
|
||||||
previousStandings: new Map(),
|
previousStandings: new Map([["a", 100]]),
|
||||||
sportName: "UEFA Champions League",
|
sportName: "UEFA Champions League",
|
||||||
eventName: "Knockout Stage",
|
eventName: "Knockout Stage",
|
||||||
});
|
});
|
||||||
|
|
@ -99,8 +99,8 @@ describe("sendStandingsUpdateNotification", () => {
|
||||||
await sendStandingsUpdateNotification({
|
await sendStandingsUpdateNotification({
|
||||||
webhookUrl: WEBHOOK_URL,
|
webhookUrl: WEBHOOK_URL,
|
||||||
seasonName: "My League 2025",
|
seasonName: "My League 2025",
|
||||||
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }],
|
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }],
|
||||||
previousStandings: new Map(),
|
previousStandings: new Map([["a", 100]]),
|
||||||
eventName: "Quarter Finals",
|
eventName: "Quarter Finals",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -113,37 +113,36 @@ describe("sendStandingsUpdateNotification", () => {
|
||||||
await sendStandingsUpdateNotification({
|
await sendStandingsUpdateNotification({
|
||||||
webhookUrl: WEBHOOK_URL,
|
webhookUrl: WEBHOOK_URL,
|
||||||
seasonName: "My League 2025",
|
seasonName: "My League 2025",
|
||||||
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }],
|
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }],
|
||||||
previousStandings: new Map(),
|
previousStandings: new Map([["a", 100]]),
|
||||||
});
|
});
|
||||||
|
|
||||||
const desc = getDescription();
|
const desc = getDescription();
|
||||||
// Description should start directly with the standings section
|
expect(desc).toContain("**Standings Changes**");
|
||||||
expect(desc).toContain("**Current Standings**");
|
|
||||||
expect(desc).not.toContain("**Scored Matches**");
|
expect(desc).not.toContain("**Scored Matches**");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists scored matches above standings", async () => {
|
it("lists scored matches above standings changes", async () => {
|
||||||
await sendStandingsUpdateNotification({
|
await sendStandingsUpdateNotification({
|
||||||
webhookUrl: WEBHOOK_URL,
|
webhookUrl: WEBHOOK_URL,
|
||||||
seasonName: "My League 2025",
|
seasonName: "My League 2025",
|
||||||
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }],
|
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }],
|
||||||
previousStandings: new Map(),
|
previousStandings: new Map([["a", 100]]),
|
||||||
scoredMatches: [
|
scoredMatches: [
|
||||||
{ winnerName: "Real Madrid", loserName: "Bayern Munich" },
|
{ winnerName: "Real Madrid", loserName: "Bayern Munich", winnerUsername: "manager1" },
|
||||||
{ winnerName: "Arsenal", loserName: "PSG" },
|
{ winnerName: "Arsenal", loserName: "PSG", loserUsername: "manager2" },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const desc = getDescription();
|
const desc = getDescription();
|
||||||
expect(desc).toContain("**Scored Matches**");
|
expect(desc).toContain("**Scored Matches**");
|
||||||
expect(desc).toContain("• **Real Madrid** def. Bayern Munich");
|
expect(desc).toContain("• **Real Madrid (manager1)** def. Bayern Munich");
|
||||||
expect(desc).toContain("• **Arsenal** def. PSG");
|
expect(desc).toContain("• **Arsenal** def. PSG (manager2)");
|
||||||
// Scored matches section should appear before standings
|
// 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({
|
await sendStandingsUpdateNotification({
|
||||||
webhookUrl: WEBHOOK_URL,
|
webhookUrl: WEBHOOK_URL,
|
||||||
seasonName: "My League 2025",
|
seasonName: "My League 2025",
|
||||||
|
|
@ -151,21 +150,24 @@ describe("sendStandingsUpdateNotification", () => {
|
||||||
{ teamId: "a", teamName: "Alpha FC", username: "christhrowsrocks", totalPoints: 150, rank: 1 },
|
{ teamId: "a", teamName: "Alpha FC", username: "christhrowsrocks", totalPoints: 150, rank: 1 },
|
||||||
{ teamId: "b", teamName: "Beta United", totalPoints: 100, rank: 2 },
|
{ teamId: "b", teamName: "Beta United", totalPoints: 100, rank: 2 },
|
||||||
],
|
],
|
||||||
previousStandings: new Map(),
|
previousStandings: new Map([
|
||||||
|
["a", 125],
|
||||||
|
["b", 100],
|
||||||
|
]),
|
||||||
});
|
});
|
||||||
|
|
||||||
const desc = getDescription();
|
const desc = getDescription();
|
||||||
expect(desc).toContain("1. Alpha FC (christhrowsrocks) — 150 pts");
|
expect(desc).toContain("1. Alpha FC (christhrowsrocks) — 150 pts");
|
||||||
expect(desc).toContain("2. Beta United — 100 pts");
|
// Beta didn't change points or rank, so it's not shown
|
||||||
expect(desc).not.toContain("Beta United (");
|
expect(desc).not.toContain("Beta United");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows usernames in parentheses in match results when both are drafted", async () => {
|
it("shows usernames in parentheses in match results when both are drafted", async () => {
|
||||||
await sendStandingsUpdateNotification({
|
await sendStandingsUpdateNotification({
|
||||||
webhookUrl: WEBHOOK_URL,
|
webhookUrl: WEBHOOK_URL,
|
||||||
seasonName: "My League 2025",
|
seasonName: "My League 2025",
|
||||||
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }],
|
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }],
|
||||||
previousStandings: new Map(),
|
previousStandings: new Map([["a", 100]]),
|
||||||
scoredMatches: [
|
scoredMatches: [
|
||||||
{
|
{
|
||||||
winnerName: "Sporting",
|
winnerName: "Sporting",
|
||||||
|
|
@ -180,27 +182,27 @@ describe("sendStandingsUpdateNotification", () => {
|
||||||
expect(desc).toContain("• **Sporting (christhrowsrocks)** def. Bodø/Glimt (apatel)");
|
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({
|
await sendStandingsUpdateNotification({
|
||||||
webhookUrl: WEBHOOK_URL,
|
webhookUrl: WEBHOOK_URL,
|
||||||
seasonName: "My League 2025",
|
seasonName: "My League 2025",
|
||||||
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }],
|
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }],
|
||||||
previousStandings: new Map(),
|
previousStandings: new Map([["a", 100]]),
|
||||||
scoredMatches: [{ winnerName: "Real Madrid", loserName: "Bayern Munich" }],
|
scoredMatches: [{ winnerName: "Real Madrid", loserName: "Bayern Munich" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const desc = getDescription();
|
const desc = getDescription();
|
||||||
expect(desc).toContain("• **Real Madrid** def. Bayern Munich");
|
expect(desc).not.toContain("**Scored Matches**");
|
||||||
expect(desc).not.toContain("Real Madrid (");
|
expect(desc).not.toContain("Real Madrid");
|
||||||
expect(desc).not.toContain("Bayern Munich (");
|
expect(desc).not.toContain("Bayern Munich");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits username parenthesis only for the undrafted side in match results", async () => {
|
it("omits username parenthesis only for the undrafted side in match results", async () => {
|
||||||
await sendStandingsUpdateNotification({
|
await sendStandingsUpdateNotification({
|
||||||
webhookUrl: WEBHOOK_URL,
|
webhookUrl: WEBHOOK_URL,
|
||||||
seasonName: "My League 2025",
|
seasonName: "My League 2025",
|
||||||
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }],
|
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }],
|
||||||
previousStandings: new Map(),
|
previousStandings: new Map([["a", 100]]),
|
||||||
scoredMatches: [
|
scoredMatches: [
|
||||||
{
|
{
|
||||||
winnerName: "Sporting",
|
winnerName: "Sporting",
|
||||||
|
|
@ -215,7 +217,7 @@ describe("sendStandingsUpdateNotification", () => {
|
||||||
expect(desc).not.toContain("Bodø/Glimt (");
|
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({
|
await sendStandingsUpdateNotification({
|
||||||
webhookUrl: WEBHOOK_URL,
|
webhookUrl: WEBHOOK_URL,
|
||||||
seasonName: "My League 2025",
|
seasonName: "My League 2025",
|
||||||
|
|
@ -223,27 +225,107 @@ describe("sendStandingsUpdateNotification", () => {
|
||||||
{ teamId: "a", teamName: "Alpha", totalPoints: 150, rank: 1 },
|
{ teamId: "a", teamName: "Alpha", totalPoints: 150, rank: 1 },
|
||||||
{ teamId: "b", teamName: "Beta", totalPoints: 125, rank: 2 },
|
{ teamId: "b", teamName: "Beta", totalPoints: 125, rank: 2 },
|
||||||
{ teamId: "c", teamName: "Gamma", totalPoints: 100, rank: 3 },
|
{ 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();
|
const desc = getDescription();
|
||||||
expect(desc).toContain("1. Alpha");
|
// Alpha gained points and moved up
|
||||||
expect(desc).toContain("2. Beta");
|
expect(desc).toContain("Alpha");
|
||||||
expect(desc).toContain("3. Gamma");
|
expect(desc).toContain("+25 pts");
|
||||||
expect(desc).toContain("4. Delta");
|
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 () => {
|
it("does not show fields on the embed", async () => {
|
||||||
await sendStandingsUpdateNotification({
|
await sendStandingsUpdateNotification({
|
||||||
webhookUrl: WEBHOOK_URL,
|
webhookUrl: WEBHOOK_URL,
|
||||||
seasonName: "My League 2025",
|
seasonName: "My League 2025",
|
||||||
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }],
|
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }],
|
||||||
previousStandings: new Map(),
|
previousStandings: new Map([["a", 100]]),
|
||||||
});
|
});
|
||||||
|
|
||||||
const body = JSON.parse((fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body);
|
const body = JSON.parse((fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body);
|
||||||
expect(body.embeds[0].fields).toBeUndefined();
|
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 {
|
export interface StandingEntry {
|
||||||
teamId: string;
|
teamId: string;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
|
@ -46,6 +51,7 @@ export async function sendStandingsUpdateNotification({
|
||||||
seasonName,
|
seasonName,
|
||||||
standings,
|
standings,
|
||||||
previousStandings,
|
previousStandings,
|
||||||
|
previousRanks,
|
||||||
sportName,
|
sportName,
|
||||||
eventName,
|
eventName,
|
||||||
scoredMatches,
|
scoredMatches,
|
||||||
|
|
@ -54,6 +60,7 @@ export async function sendStandingsUpdateNotification({
|
||||||
seasonName: string;
|
seasonName: string;
|
||||||
standings: StandingEntry[];
|
standings: StandingEntry[];
|
||||||
previousStandings: Map<string, number>;
|
previousStandings: Map<string, number>;
|
||||||
|
previousRanks?: Map<string, number>;
|
||||||
sportName?: string;
|
sportName?: string;
|
||||||
eventName?: string;
|
eventName?: string;
|
||||||
scoredMatches?: ScoredMatch[];
|
scoredMatches?: ScoredMatch[];
|
||||||
|
|
@ -66,32 +73,58 @@ export async function sendStandingsUpdateNotification({
|
||||||
sections.push(`**${parts.join(" — ")}**`);
|
sections.push(`**${parts.join(" — ")}**`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scored matches section
|
// Scored matches section — only show matches where at least one manager
|
||||||
if (scoredMatches && scoredMatches.length > 0) {
|
// (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**");
|
sections.push("\n**Scored Matches**");
|
||||||
for (const match of scoredMatches) {
|
for (const match of relevantMatches) {
|
||||||
const winner = match.winnerUsername
|
const winnerLabel = match.winnerUsername
|
||||||
? `${match.winnerName} (${match.winnerUsername})`
|
? `${escapeMarkdown(match.winnerName)} (${escapeMarkdown(match.winnerUsername)})`
|
||||||
: match.winnerName;
|
: escapeMarkdown(match.winnerName);
|
||||||
const loser = match.loserUsername
|
const loserLabel = match.loserUsername
|
||||||
? `${match.loserName} (${match.loserUsername})`
|
? `${escapeMarkdown(match.loserName)} (${escapeMarkdown(match.loserUsername)})`
|
||||||
: match.loserName;
|
: escapeMarkdown(match.loserName);
|
||||||
sections.push(`• **${winner}** def. ${loser}`);
|
sections.push(`• **${winnerLabel}** def. ${loserLabel}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Standings section
|
// Standings changes section — show teams whose points or rank changed.
|
||||||
sections.push("\n**Current Standings**");
|
const changedTeams = standings.filter((s) => {
|
||||||
for (const s of standings) {
|
const prevPoints = previousStandings.get(s.teamId);
|
||||||
const prev = previousStandings.get(s.teamId);
|
const pointsChanged = prevPoints !== undefined && prevPoints !== s.totalPoints;
|
||||||
let delta = "";
|
const prevRank = previousRanks?.get(s.teamId);
|
||||||
if (prev !== undefined && prev !== s.totalPoints) {
|
const rankChanged = prevRank !== undefined && prevRank !== s.rank;
|
||||||
const diff = Math.round(s.totalPoints - prev);
|
return pointsChanged || rankChanged;
|
||||||
const sign = diff > 0 ? "+" : "";
|
});
|
||||||
delta = ` **(${sign}${diff} pts)**`;
|
|
||||||
|
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;
|
const MAX_DESCRIPTION = 4096;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue