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" && (