From 7236c8aefb671802a6e841575dbe7d26ec9d69ca Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Tue, 17 Mar 2026 14:34:09 -0700 Subject: [PATCH] Add Discord usernames, fix calculateTeamScore partial-score bug, and add admin re-score action (#160) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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 --- .../__tests__/team-projected-score.test.ts | 54 +++++++++++++- app/models/scoring-calculator.ts | 45 ++++++++++-- app/routes/admin.sports-seasons.$id.tsx | 58 +++++++++++++-- app/services/__tests__/discord.test.ts | 72 +++++++++++++++++++ app/services/discord.ts | 14 +++- 5 files changed, 230 insertions(+), 13 deletions(-) diff --git a/app/models/__tests__/team-projected-score.test.ts b/app/models/__tests__/team-projected-score.test.ts index afb6937..7e1df07 100644 --- a/app/models/__tests__/team-projected-score.test.ts +++ b/app/models/__tests__/team-projected-score.test.ts @@ -20,7 +20,7 @@ vi.mock("../participant-expected-value", () => ({ getParticipantEV: vi.fn(), })); -import { calculateTeamProjectedScore } from "../scoring-calculator"; +import { calculateTeamProjectedScore, calculateTeamScore } from "../scoring-calculator"; import { calculateEV } from "~/services/ev-calculator"; 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 () => { // Pick 1: bracket finalized 1st place → actual=100, no EV // 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); + }); +}); diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index b4dcd0c..0dcaba8 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -882,11 +882,13 @@ export async function calculateTeamScore( points = calculateFantasyPoints(result.finalPosition, scoringRules); } totalPoints += points; - participantsCompleted++; - // Track placement for tiebreakers - if (result.finalPosition >= 1 && result.finalPosition <= 8) { - placementCounts[result.finalPosition]++; + if (!result.isPartialScore) { + // Only fully finalized participants count toward completed and placement tiebreakers + participantsCompleted++; + if (result.finalPosition >= 1 && result.finalPosition <= 8) { + placementCounts[result.finalPosition]++; + } } } } @@ -1297,6 +1299,26 @@ export async function recalculateAffectedLeagues( const webhookUrl = season?.league?.discordWebhookUrl; 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. // We compute this BEFORE the hasChanges gate because 0-point eliminations // (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; 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 .filter((m) => m.winnerName && m.loserName) .map((m) => ({ winnerName: m.winnerName!, loserName: m.loserName!, + winnerUsername: usernameForParticipant(m.winnerId), + loserUsername: usernameForParticipant(m.loserId), })); } } @@ -1333,6 +1369,7 @@ export async function recalculateAffectedLeagues( const standings = afterStandings.map((s) => ({ teamId: s.teamId, teamName: s.team.name, + username: usernameByClerkId.get(s.team.ownerId ?? ""), totalPoints: parseFloat(s.totalPoints), rank: s.currentRank, })); diff --git a/app/routes/admin.sports-seasons.$id.tsx b/app/routes/admin.sports-seasons.$id.tsx index cbd1b8e..b7838fd 100644 --- a/app/routes/admin.sports-seasons.$id.tsx +++ b/app/routes/admin.sports-seasons.$id.tsx @@ -1,11 +1,13 @@ 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 { findSportsSeasonById, updateSportsSeason, deleteSportsSeason } from "~/models/sports-season"; import { findParticipantsBySportsSeasonId } from "~/models/participant"; -import { processSeasonStandings } from "~/models/scoring-calculator"; +import { processSeasonStandings, recalculateStandings } from "~/models/scoring-calculator"; import { database } from "~/database/context"; -import { participantEvSnapshots } from "~/database/schema"; +import { participantEvSnapshots, seasonSports } from "~/database/schema"; import { eq, desc } from "drizzle-orm"; import { getSimulatorInfo, type SimulatorType } from "~/services/simulations/registry"; 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 intent = formData.get("intent"); @@ -85,11 +94,28 @@ export async function action({ request, params }: Route.ActionArgs) { 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") { try { await processSeasonStandings(params.id); 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) { console.error("Error finalizing standings:", error); return { error: "Failed to finalize standings. Please try again." }; @@ -502,7 +528,7 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo {actionData.error} )} - {actionData?.success && actionData.message?.includes("finalized") && ( + {actionData?.success && actionData.intent === "finalize-standings" && (
{actionData.message}
@@ -537,6 +563,28 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo )} + + + Fantasy Standings + + Force a recalculation of all fantasy standings linked to this sports season. Use this after fixing scoring bugs or data corrections. + + + + {actionData?.success && actionData.intent === "rescore" && ( +
+ {actionData.message} +
+ )} +
+ + +
+
+
+ Danger Zone diff --git a/app/services/__tests__/discord.test.ts b/app/services/__tests__/discord.test.ts index 7e2719d..b9138dc 100644 --- a/app/services/__tests__/discord.test.ts +++ b/app/services/__tests__/discord.test.ts @@ -143,6 +143,78 @@ describe("sendStandingsUpdateNotification", () => { 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 () => { await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL, diff --git a/app/services/discord.ts b/app/services/discord.ts index 63fe1d3..6ee7d5c 100644 --- a/app/services/discord.ts +++ b/app/services/discord.ts @@ -29,6 +29,7 @@ export async function sendDiscordWebhook( export interface StandingEntry { teamId: string; teamName: string; + username?: string; totalPoints: number; rank: number; } @@ -36,6 +37,8 @@ export interface StandingEntry { export interface ScoredMatch { winnerName: string; loserName: string; + winnerUsername?: string; + loserUsername?: string; } export async function sendStandingsUpdateNotification({ @@ -67,7 +70,13 @@ export async function sendStandingsUpdateNotification({ if (scoredMatches && scoredMatches.length > 0) { sections.push("\n**Scored Matches**"); 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 ? "+" : ""; 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;