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(),
|
||||
}));
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue