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:
Chris Parsons 2026-03-17 14:34:09 -07:00 committed by GitHub
parent af99f6d002
commit 7236c8aefb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 230 additions and 13 deletions

View file

@ -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);
});
});

View file

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

View file

@ -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}
</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">
{actionData.message}
</div>
@ -537,6 +563,28 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
</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">
<CardHeader>
<CardTitle className="text-destructive">Danger Zone</CardTitle>

View file

@ -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,

View file

@ -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;