Add Discord usernames, fix calculateTeamScore partial-score bug, and add admin re-score action (#160)
**Discord webhook improvements** - Show team owner username in parentheses in standings (e.g. "1. Alpha FC (christhrowsrocks) — 150 pts") - Show username in parentheses in match results for drafted participants (e.g. "Sporting (christhrowsrocks) def. Bodø/Glimt (apatel)"), omitting the parenthesis for the undrafted side - Username lookup only runs after the webhook URL guard to avoid wasted DB queries - 4 new tests covering all username display combinations **Fix calculateTeamScore partial-score counting bug** - isPartialScore participants were incorrectly counted in participantsCompleted and placementCounts, causing standings to show wrong remaining count and phantom placement badges (e.g. "5th×1") for still-alive bracket participants - Floor points still flow into totalPoints (guaranteed value for ranking) - 3 new unit tests covering partial vs finalized behavior in calculateTeamScore **Admin Force Re-score action** - New "Fantasy Standings" card on every sports season admin page with a Force Re-score button that triggers recalculateStandings on all linked fantasy seasons — useful after data corrections without waiting for a new scoring event - Auth guard added to the action (was missing — layout loader only protects GET) - Returns a meaningful message when no linked seasons exist - Intent discriminant on action returns so success banners use actionData.intent instead of fragile message.includes() checks Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
af99f6d002
commit
7236c8aefb
5 changed files with 230 additions and 13 deletions
|
|
@ -20,7 +20,7 @@ vi.mock("../participant-expected-value", () => ({
|
||||||
getParticipantEV: vi.fn(),
|
getParticipantEV: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { calculateTeamProjectedScore } from "../scoring-calculator";
|
import { calculateTeamProjectedScore, calculateTeamScore } from "../scoring-calculator";
|
||||||
import { calculateEV } from "~/services/ev-calculator";
|
import { calculateEV } from "~/services/ev-calculator";
|
||||||
import { getParticipantEV } from "../participant-expected-value";
|
import { getParticipantEV } from "../participant-expected-value";
|
||||||
|
|
||||||
|
|
@ -209,7 +209,7 @@ describe("calculateTeamProjectedScore", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("mixed team", () => {
|
describe("mixed team (calculateTeamProjectedScore)", () => {
|
||||||
it("correctly combines finalized, partial, and pending participants", async () => {
|
it("correctly combines finalized, partial, and pending participants", async () => {
|
||||||
// Pick 1: bracket finalized 1st place → actual=100, no EV
|
// Pick 1: bracket finalized 1st place → actual=100, no EV
|
||||||
// Pick 2: bracket partial pos 5 (floor=20) → actual=20, EV=35 → +15 projected
|
// Pick 2: bracket partial pos 5 (floor=20) → actual=20, EV=35 → +15 projected
|
||||||
|
|
@ -236,3 +236,53 @@ describe("calculateTeamProjectedScore", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("calculateTeamScore", () => {
|
||||||
|
// calculateTeamScore writes totalPoints, placementCounts, and participantsCompleted
|
||||||
|
// to the teamStandings table. Partial-score participants (still alive in bracket)
|
||||||
|
// must NOT be counted as completed or appear in placement tiebreakers.
|
||||||
|
|
||||||
|
it("partial-score participant contributes floor to totalPoints but not to placementCounts or participantsCompleted", async () => {
|
||||||
|
// QF survivor: finalPosition=5 (floor=20), isPartialScore=true
|
||||||
|
const db = makeDb([
|
||||||
|
makePick("p1", "ss1", "playoff_bracket", { finalPosition: 5, isPartialScore: true }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await calculateTeamScore("team1", "season1", db);
|
||||||
|
|
||||||
|
expect(result.totalPoints).toBe(AVG_5_TO_8); // 20 floor in score
|
||||||
|
expect(result.participantsCompleted).toBe(0); // still alive → not completed
|
||||||
|
expect(result.placementCounts[5]).toBe(0); // no placement badge until finalized
|
||||||
|
expect(result.participantsTotal).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finalized bracket participant counts toward placementCounts and participantsCompleted", async () => {
|
||||||
|
// QF loser: finalPosition=5, isPartialScore=false
|
||||||
|
const db = makeDb([
|
||||||
|
makePick("p1", "ss1", "playoff_bracket", { finalPosition: 5, isPartialScore: false }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await calculateTeamScore("team1", "season1", db);
|
||||||
|
|
||||||
|
expect(result.totalPoints).toBe(AVG_5_TO_8); // 20 bracket-averaged
|
||||||
|
expect(result.participantsCompleted).toBe(1);
|
||||||
|
expect(result.placementCounts[5]).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mixed team: partial counts in totalPoints only; finalized counts everywhere", async () => {
|
||||||
|
// p1: finalized 1st → totalPoints +100, placementCounts[1]++, completed++
|
||||||
|
// p2: partial pos 5 (floor 20) → totalPoints +20, no placement, not completed
|
||||||
|
const db = makeDb([
|
||||||
|
makePick("p1", "ss1", "playoff_bracket", { finalPosition: 1, isPartialScore: false }),
|
||||||
|
makePick("p2", "ss1", "playoff_bracket", { finalPosition: 5, isPartialScore: true }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await calculateTeamScore("team1", "season1", db);
|
||||||
|
|
||||||
|
expect(result.totalPoints).toBe(100 + AVG_5_TO_8); // 120
|
||||||
|
expect(result.participantsCompleted).toBe(1);
|
||||||
|
expect(result.participantsTotal).toBe(2);
|
||||||
|
expect(result.placementCounts[1]).toBe(1);
|
||||||
|
expect(result.placementCounts[5]).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -882,11 +882,13 @@ export async function calculateTeamScore(
|
||||||
points = calculateFantasyPoints(result.finalPosition, scoringRules);
|
points = calculateFantasyPoints(result.finalPosition, scoringRules);
|
||||||
}
|
}
|
||||||
totalPoints += points;
|
totalPoints += points;
|
||||||
participantsCompleted++;
|
|
||||||
|
|
||||||
// Track placement for tiebreakers
|
if (!result.isPartialScore) {
|
||||||
if (result.finalPosition >= 1 && result.finalPosition <= 8) {
|
// Only fully finalized participants count toward completed and placement tiebreakers
|
||||||
placementCounts[result.finalPosition]++;
|
participantsCompleted++;
|
||||||
|
if (result.finalPosition >= 1 && result.finalPosition <= 8) {
|
||||||
|
placementCounts[result.finalPosition]++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1297,6 +1299,26 @@ export async function recalculateAffectedLeagues(
|
||||||
const webhookUrl = season?.league?.discordWebhookUrl;
|
const webhookUrl = season?.league?.discordWebhookUrl;
|
||||||
if (!webhookUrl) continue;
|
if (!webhookUrl) continue;
|
||||||
|
|
||||||
|
// Build clerkId → username map for all team owners in this season
|
||||||
|
const ownerClerkIds = afterStandings
|
||||||
|
.map((s) => s.team.ownerId)
|
||||||
|
.filter((id): id is string => id !== null && id !== undefined);
|
||||||
|
const teamOwnerUsers = ownerClerkIds.length
|
||||||
|
? await db.query.users.findMany({
|
||||||
|
where: inArray(schema.users.clerkId, ownerClerkIds),
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const usernameByClerkId = new Map(
|
||||||
|
teamOwnerUsers
|
||||||
|
.filter((u) => u.username != null)
|
||||||
|
.map((u) => [u.clerkId, u.username!])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build teamId → ownerId map from standings data
|
||||||
|
const ownerIdByTeamId = new Map(
|
||||||
|
afterStandings.map((s) => [s.teamId, s.team.ownerId])
|
||||||
|
);
|
||||||
|
|
||||||
// Filter matches to only those involving participants drafted in this season.
|
// Filter matches to only those involving participants drafted in this season.
|
||||||
// We compute this BEFORE the hasChanges gate because 0-point eliminations
|
// We compute this BEFORE the hasChanges gate because 0-point eliminations
|
||||||
// (e.g. R64 losers whose finalPosition=0 doesn't affect team scores) still
|
// (e.g. R64 losers whose finalPosition=0 doesn't affect team scores) still
|
||||||
|
|
@ -1318,11 +1340,25 @@ export async function recalculateAffectedLeagues(
|
||||||
hasDraftedParticipantMatches = relevant.length > 0;
|
hasDraftedParticipantMatches = relevant.length > 0;
|
||||||
|
|
||||||
if (relevant.length > 0) {
|
if (relevant.length > 0) {
|
||||||
|
const teamIdByParticipantId = new Map(
|
||||||
|
draftPicks.map((p) => [p.participantId, p.teamId])
|
||||||
|
);
|
||||||
|
const usernameForParticipant = (participantId: string | null | undefined) => {
|
||||||
|
if (!participantId) return undefined;
|
||||||
|
const teamId = teamIdByParticipantId.get(participantId);
|
||||||
|
if (!teamId) return undefined;
|
||||||
|
const ownerId = ownerIdByTeamId.get(teamId);
|
||||||
|
if (!ownerId) return undefined;
|
||||||
|
return usernameByClerkId.get(ownerId);
|
||||||
|
};
|
||||||
|
|
||||||
scoredMatches = relevant
|
scoredMatches = relevant
|
||||||
.filter((m) => m.winnerName && m.loserName)
|
.filter((m) => m.winnerName && m.loserName)
|
||||||
.map((m) => ({
|
.map((m) => ({
|
||||||
winnerName: m.winnerName!,
|
winnerName: m.winnerName!,
|
||||||
loserName: m.loserName!,
|
loserName: m.loserName!,
|
||||||
|
winnerUsername: usernameForParticipant(m.winnerId),
|
||||||
|
loserUsername: usernameForParticipant(m.loserId),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1333,6 +1369,7 @@ export async function recalculateAffectedLeagues(
|
||||||
const standings = afterStandings.map((s) => ({
|
const standings = afterStandings.map((s) => ({
|
||||||
teamId: s.teamId,
|
teamId: s.teamId,
|
||||||
teamName: s.team.name,
|
teamName: s.team.name,
|
||||||
|
username: usernameByClerkId.get(s.team.ownerId ?? ""),
|
||||||
totalPoints: parseFloat(s.totalPoints),
|
totalPoints: parseFloat(s.totalPoints),
|
||||||
rank: s.currentRank,
|
rank: s.currentRank,
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import { Form, Link, redirect, useNavigate } from "react-router";
|
import { Form, Link, redirect, useNavigate } from "react-router";
|
||||||
|
import { getAuth } from "@clerk/react-router/server";
|
||||||
|
import { isUserAdminByClerkId } from "~/models/user";
|
||||||
import type { Route } from "./+types/admin.sports-seasons.$id";
|
import type { Route } from "./+types/admin.sports-seasons.$id";
|
||||||
|
|
||||||
import { findSportsSeasonById, updateSportsSeason, deleteSportsSeason } from "~/models/sports-season";
|
import { findSportsSeasonById, updateSportsSeason, deleteSportsSeason } from "~/models/sports-season";
|
||||||
import { findParticipantsBySportsSeasonId } from "~/models/participant";
|
import { findParticipantsBySportsSeasonId } from "~/models/participant";
|
||||||
import { processSeasonStandings } from "~/models/scoring-calculator";
|
import { processSeasonStandings, recalculateStandings } from "~/models/scoring-calculator";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import { participantEvSnapshots } from "~/database/schema";
|
import { participantEvSnapshots, seasonSports } from "~/database/schema";
|
||||||
import { eq, desc } from "drizzle-orm";
|
import { eq, desc } from "drizzle-orm";
|
||||||
import { getSimulatorInfo, type SimulatorType } from "~/services/simulations/registry";
|
import { getSimulatorInfo, type SimulatorType } from "~/services/simulations/registry";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
|
|
@ -76,7 +78,14 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function action({ request, params }: Route.ActionArgs) {
|
export async function action(args: Route.ActionArgs) {
|
||||||
|
const { request, params } = args;
|
||||||
|
const { userId } = await getAuth(args);
|
||||||
|
const isAdmin = userId ? await isUserAdminByClerkId(userId) : false;
|
||||||
|
if (!isAdmin) {
|
||||||
|
throw new Response("Forbidden", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const intent = formData.get("intent");
|
const intent = formData.get("intent");
|
||||||
|
|
||||||
|
|
@ -85,11 +94,28 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
return redirect("/admin/sports-seasons");
|
return redirect("/admin/sports-seasons");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (intent === "rescore") {
|
||||||
|
try {
|
||||||
|
const db = database();
|
||||||
|
const links = await db.query.seasonSports.findMany({
|
||||||
|
where: eq(seasonSports.sportsSeasonId, params.id),
|
||||||
|
});
|
||||||
|
if (links.length === 0) {
|
||||||
|
return { success: true, intent: "rescore", message: "No linked fantasy seasons found — nothing to rescore." };
|
||||||
|
}
|
||||||
|
await Promise.all(links.map((link) => recalculateStandings(link.seasonId, db)));
|
||||||
|
return { success: true, intent: "rescore", message: `Rescored ${links.length} linked season(s).` };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error rescoring:", error);
|
||||||
|
return { error: "Failed to rescore. Please try again." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (intent === "finalize-standings") {
|
if (intent === "finalize-standings") {
|
||||||
try {
|
try {
|
||||||
await processSeasonStandings(params.id);
|
await processSeasonStandings(params.id);
|
||||||
await updateSportsSeason(params.id, { status: "completed" });
|
await updateSportsSeason(params.id, { status: "completed" });
|
||||||
return { success: true, message: "Standings finalized and fantasy placements assigned!" };
|
return { success: true, intent: "finalize-standings", message: "Standings finalized and fantasy placements assigned!" };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error finalizing standings:", error);
|
console.error("Error finalizing standings:", error);
|
||||||
return { error: "Failed to finalize standings. Please try again." };
|
return { error: "Failed to finalize standings. Please try again." };
|
||||||
|
|
@ -502,7 +528,7 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
|
||||||
{actionData.error}
|
{actionData.error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{actionData?.success && actionData.message?.includes("finalized") && (
|
{actionData?.success && actionData.intent === "finalize-standings" && (
|
||||||
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm mb-4">
|
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm mb-4">
|
||||||
{actionData.message}
|
{actionData.message}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -537,6 +563,28 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Fantasy Standings</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Force a recalculation of all fantasy standings linked to this sports season. Use this after fixing scoring bugs or data corrections.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{actionData?.success && actionData.intent === "rescore" && (
|
||||||
|
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm mb-4">
|
||||||
|
{actionData.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Form method="post">
|
||||||
|
<input type="hidden" name="intent" value="rescore" />
|
||||||
|
<Button type="submit" variant="outline" className="border-blue-500/50 text-blue-600 hover:bg-blue-500/10 dark:text-blue-400">
|
||||||
|
Force Re-score
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card className="border-destructive">
|
<Card className="border-destructive">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-destructive">Danger Zone</CardTitle>
|
<CardTitle className="text-destructive">Danger Zone</CardTitle>
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,78 @@ describe("sendStandingsUpdateNotification", () => {
|
||||||
expect(desc.indexOf("Scored Matches")).toBeLessThan(desc.indexOf("Current Standings"));
|
expect(desc.indexOf("Scored Matches")).toBeLessThan(desc.indexOf("Current Standings"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows username in parentheses in standings when provided", async () => {
|
||||||
|
await sendStandingsUpdateNotification({
|
||||||
|
webhookUrl: WEBHOOK_URL,
|
||||||
|
seasonName: "My League 2025",
|
||||||
|
standings: [
|
||||||
|
{ teamId: "a", teamName: "Alpha FC", username: "christhrowsrocks", totalPoints: 150, rank: 1 },
|
||||||
|
{ teamId: "b", teamName: "Beta United", totalPoints: 100, rank: 2 },
|
||||||
|
],
|
||||||
|
previousStandings: new Map(),
|
||||||
|
});
|
||||||
|
|
||||||
|
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 (");
|
||||||
|
});
|
||||||
|
|
||||||
|
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(),
|
||||||
|
scoredMatches: [
|
||||||
|
{
|
||||||
|
winnerName: "Sporting",
|
||||||
|
loserName: "Bodø/Glimt",
|
||||||
|
winnerUsername: "christhrowsrocks",
|
||||||
|
loserUsername: "apatel",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const desc = getDescription();
|
||||||
|
expect(desc).toContain("• **Sporting (christhrowsrocks)** def. Bodø/Glimt (apatel)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows no parentheses in match results when 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(),
|
||||||
|
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 (");
|
||||||
|
});
|
||||||
|
|
||||||
|
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(),
|
||||||
|
scoredMatches: [
|
||||||
|
{
|
||||||
|
winnerName: "Sporting",
|
||||||
|
loserName: "Bodø/Glimt",
|
||||||
|
winnerUsername: "christhrowsrocks",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const desc = getDescription();
|
||||||
|
expect(desc).toContain("• **Sporting (christhrowsrocks)** def. Bodø/Glimt");
|
||||||
|
expect(desc).not.toContain("Bodø/Glimt (");
|
||||||
|
});
|
||||||
|
|
||||||
it("uses plain numbers for all ranks in description", async () => {
|
it("uses plain numbers for all ranks in description", async () => {
|
||||||
await sendStandingsUpdateNotification({
|
await sendStandingsUpdateNotification({
|
||||||
webhookUrl: WEBHOOK_URL,
|
webhookUrl: WEBHOOK_URL,
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ export async function sendDiscordWebhook(
|
||||||
export interface StandingEntry {
|
export interface StandingEntry {
|
||||||
teamId: string;
|
teamId: string;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
username?: string;
|
||||||
totalPoints: number;
|
totalPoints: number;
|
||||||
rank: number;
|
rank: number;
|
||||||
}
|
}
|
||||||
|
|
@ -36,6 +37,8 @@ export interface StandingEntry {
|
||||||
export interface ScoredMatch {
|
export interface ScoredMatch {
|
||||||
winnerName: string;
|
winnerName: string;
|
||||||
loserName: string;
|
loserName: string;
|
||||||
|
winnerUsername?: string;
|
||||||
|
loserUsername?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendStandingsUpdateNotification({
|
export async function sendStandingsUpdateNotification({
|
||||||
|
|
@ -67,7 +70,13 @@ export async function sendStandingsUpdateNotification({
|
||||||
if (scoredMatches && scoredMatches.length > 0) {
|
if (scoredMatches && scoredMatches.length > 0) {
|
||||||
sections.push("\n**Scored Matches**");
|
sections.push("\n**Scored Matches**");
|
||||||
for (const match of scoredMatches) {
|
for (const match of scoredMatches) {
|
||||||
sections.push(`• **${match.winnerName}** def. ${match.loserName}`);
|
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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,7 +90,8 @@ export async function sendStandingsUpdateNotification({
|
||||||
const sign = diff > 0 ? "+" : "";
|
const sign = diff > 0 ? "+" : "";
|
||||||
delta = ` **(${sign}${diff} pts)**`;
|
delta = ` **(${sign}${diff} pts)**`;
|
||||||
}
|
}
|
||||||
sections.push(`${s.rank}. ${s.teamName} — ${Math.round(s.totalPoints)} pts${delta}`);
|
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