diff --git a/app/components/standings/TeamScoreBreakdown.tsx b/app/components/standings/TeamScoreBreakdown.tsx index 9c2c7be..fef65bb 100644 --- a/app/components/standings/TeamScoreBreakdown.tsx +++ b/app/components/standings/TeamScoreBreakdown.tsx @@ -25,6 +25,7 @@ interface TeamScoreBreakdownProps { points: number; projectedPoints: number | null; isComplete: boolean; + isPartialScore: boolean; }>; actualPoints: number; projectedPoints: number; @@ -107,7 +108,10 @@ export function TeamScoreBreakdown({ Sport Participant Position - Points + +
Points
+
actual / projected
+
@@ -131,7 +135,7 @@ export function TeamScoreBreakdown({ {pick.participant.name} - {pick.isComplete ? ( + {pick.isComplete && !pick.isPartialScore ? ( (pick.finalPosition ?? 0) === 0 ? ( Did Not Score ) : ( @@ -142,19 +146,19 @@ export function TeamScoreBreakdown({ )} - {pick.isComplete ? ( + {pick.isComplete && !pick.isPartialScore ? ( {pick.points > 0 ? pick.points.toFixed(2) : "0.00"} - ) : pick.projectedPoints !== null ? ( -
- Projected: - - {pick.projectedPoints.toFixed(2)} - -
) : ( - - +
+ {pick.points.toFixed(2)} + {pick.projectedPoints !== null && ( + + {pick.projectedPoints.toFixed(2)} + + )} +
)}
diff --git a/app/components/standings/__tests__/TeamScoreBreakdown.test.tsx b/app/components/standings/__tests__/TeamScoreBreakdown.test.tsx index 934ca60..ff19451 100644 --- a/app/components/standings/__tests__/TeamScoreBreakdown.test.tsx +++ b/app/components/standings/__tests__/TeamScoreBreakdown.test.tsx @@ -32,6 +32,7 @@ describe("TeamScoreBreakdown", () => { points: 100, projectedPoints: null, isComplete: true, + isPartialScore: false, }, { pickNumber: 2, @@ -46,6 +47,7 @@ describe("TeamScoreBreakdown", () => { points: 50, projectedPoints: null, isComplete: true, + isPartialScore: false, }, { pickNumber: 3, @@ -60,6 +62,7 @@ describe("TeamScoreBreakdown", () => { points: 0, projectedPoints: 25, isComplete: false, + isPartialScore: false, }, ], actualPoints: 150, @@ -299,6 +302,7 @@ describe("TeamScoreBreakdown", () => { finalPosition: null, points: 0, isComplete: true, + isPartialScore: false, }, ], }; @@ -316,7 +320,7 @@ describe("TeamScoreBreakdown", () => { expect(screen.getByText("Did Not Score")).toBeInTheDocument(); }); - it("should show projected points for incomplete participants", () => { + it("should show projected EV for incomplete participants", () => { renderWithRouter( { /> ); - expect(screen.getByText("Projected:")).toBeInTheDocument(); + // Incomplete participant shows 0.00 (actual) and 25.00 (EV) in the row + expect(screen.getByText("0.00")).toBeInTheDocument(); expect(screen.getByText("25.00")).toBeInTheDocument(); }); diff --git a/app/models/__tests__/team-projected-score.test.ts b/app/models/__tests__/team-projected-score.test.ts new file mode 100644 index 0000000..afb6937 --- /dev/null +++ b/app/models/__tests__/team-projected-score.test.ts @@ -0,0 +1,238 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/** + * Tests for calculateTeamProjectedScore + * + * Key behaviours under test: + * - Finalized bracket participants use calculateBracketPoints (averaged), not raw points + * - Partial-score participants (still alive) contribute floor points to actualPoints + * and their incremental EV (EV - floor) to projectedPoints + * - EV < floor is clamped to 0 extra EV (Math.max guard) + * - Non-bracket finalized participants use calculateFantasyPoints (raw) + * - Pending participants (no result) contribute full EV to projectedPoints + */ + +vi.mock("~/services/ev-calculator", () => ({ + calculateEV: vi.fn(), +})); + +vi.mock("../participant-expected-value", () => ({ + getParticipantEV: vi.fn(), +})); + +import { calculateTeamProjectedScore } from "../scoring-calculator"; +import { calculateEV } from "~/services/ev-calculator"; +import { getParticipantEV } from "../participant-expected-value"; + +// Standard scoring rules used across tests +const SCORING = { + pointsFor1st: 100, + pointsFor2nd: 70, + pointsFor3rd: 50, + pointsFor4th: 40, + pointsFor5th: 25, + pointsFor6th: 25, + pointsFor7th: 15, + pointsFor8th: 15, +}; +// Derived bracket averages for assertions +const AVG_5_TO_8 = (25 + 25 + 15 + 15) / 4; // 20 +const AVG_3_TO_4 = (50 + 40) / 2; // 45 + +/** Build a minimal pick object matching what the query returns */ +function makePick( + participantId: string, + sportsSeasonId: string, + scoringPattern: string, + result: { finalPosition: number | null; isPartialScore: boolean } | null +) { + return { + pickNumber: 1, + round: 1, + participant: { + id: participantId, + sportsSeasonId, + results: result ? [result] : [], + sportsSeason: { scoringPattern }, + }, + }; +} + +/** Build a mock DB that returns the given picks and no bracketTemplateId */ +function makeDb(picks: ReturnType[]) { + return { + query: { + seasons: { + findFirst: vi.fn().mockResolvedValue(SCORING), + }, + draftPicks: { + findMany: vi.fn().mockResolvedValue(picks), + }, + scoringEvents: { + findFirst: vi.fn().mockResolvedValue({ bracketTemplateId: null }), + }, + }, + } as any; +} + +const DUMMY_EV_ROW = { + probFirst: "0.1", + probSecond: "0.1", + probThird: "0.1", + probFourth: "0.1", + probFifth: "0.1", + probSixth: "0.1", + probSeventh: "0.2", + probEighth: "0.2", +} as any; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("calculateTeamProjectedScore", () => { + describe("finalized bracket participant", () => { + it("uses bracket-averaged points, not raw position value", async () => { + // QF loser: finalPosition=5, isPartialScore=false + // calculateBracketPoints(5) = avg(5,6,7,8) = 20, NOT raw pointsFor5th = 25 + const db = makeDb([ + makePick("p1", "ss1", "playoff_bracket", { finalPosition: 5, isPartialScore: false }), + ]); + vi.mocked(getParticipantEV).mockResolvedValue(null); + + const result = await calculateTeamProjectedScore("team1", "season1", db); + + expect(result.actualPoints).toBe(AVG_5_TO_8); // 20, not 25 + expect(result.projectedPoints).toBe(AVG_5_TO_8); + expect(result.participantsFinished).toBe(1); + expect(result.participantsRemaining).toBe(0); + }); + + it("averages 3rd/4th for bracket semi-final losers", async () => { + const db = makeDb([ + makePick("p1", "ss1", "playoff_bracket", { finalPosition: 3, isPartialScore: false }), + ]); + + const result = await calculateTeamProjectedScore("team1", "season1", db); + + expect(result.actualPoints).toBe(AVG_3_TO_4); // 45 + expect(result.projectedPoints).toBe(AVG_3_TO_4); + }); + + it("uses raw points for non-bracket (e.g. season_standings) finalized participant", async () => { + // Non-bracket 3rd place should use pointsFor3rd = 50, not averaged + const db = makeDb([ + makePick("p1", "ss1", "season_standings", { finalPosition: 3, isPartialScore: false }), + ]); + + const result = await calculateTeamProjectedScore("team1", "season1", db); + + expect(result.actualPoints).toBe(50); // raw pointsFor3rd + }); + }); + + describe("partial-score bracket participant (still alive)", () => { + it("floor counts as actual; incremental EV above floor counts toward projected", async () => { + // Won QF: finalPosition=3 (floor = avg[3,4] = 45), isPartialScore=true + // EV = 60 → incremental = 60 - 45 = 15 → projected = 45 + 15 = 60 + const db = makeDb([ + makePick("p1", "ss1", "playoff_bracket", { finalPosition: 3, isPartialScore: true }), + ]); + vi.mocked(getParticipantEV).mockResolvedValue(DUMMY_EV_ROW); + vi.mocked(calculateEV).mockReturnValue(60); + + const result = await calculateTeamProjectedScore("team1", "season1", db); + + expect(result.actualPoints).toBe(AVG_3_TO_4); // 45 floor + expect(result.projectedPoints).toBe(60); // 45 + (60 - 45) + // Partial participants are NOT counted as finished — they're still competing + expect(result.participantsFinished).toBe(0); + expect(result.participantsRemaining).toBe(1); + }); + + it("clamps EV contribution to 0 when EV drops below floor (Math.max guard)", async () => { + // Won SF: finalPosition=2 (floor = 70), isPartialScore=true + // EV = 50 (below floor) → incremental = max(0, 50-70) = 0 → projected = 70 + const db = makeDb([ + makePick("p1", "ss1", "playoff_bracket", { finalPosition: 2, isPartialScore: true }), + ]); + vi.mocked(getParticipantEV).mockResolvedValue(DUMMY_EV_ROW); + vi.mocked(calculateEV).mockReturnValue(50); // EV < floor + + const result = await calculateTeamProjectedScore("team1", "season1", db); + + expect(result.actualPoints).toBe(70); // floor = 2nd place = 70 + expect(result.projectedPoints).toBe(70); // no negative EV contribution + }); + + it("partial-score floor for non-scoring round (pos 5) contributes 20 pts", async () => { + // Won Round of 16 (non-scoring): finalPosition=5, isPartialScore=true → floor=20 + const db = makeDb([ + makePick("p1", "ss1", "playoff_bracket", { finalPosition: 5, isPartialScore: true }), + ]); + vi.mocked(getParticipantEV).mockResolvedValue(DUMMY_EV_ROW); + vi.mocked(calculateEV).mockReturnValue(35); + + const result = await calculateTeamProjectedScore("team1", "season1", db); + + expect(result.actualPoints).toBe(AVG_5_TO_8); // 20 + expect(result.projectedPoints).toBe(35); // 20 + (35 - 20) + }); + }); + + describe("pending participant (no result)", () => { + it("contributes 0 to actualPoints and full EV to projectedPoints", async () => { + const db = makeDb([ + makePick("p1", "ss1", "playoff_bracket", null), + ]); + vi.mocked(getParticipantEV).mockResolvedValue(DUMMY_EV_ROW); + vi.mocked(calculateEV).mockReturnValue(30); + + const result = await calculateTeamProjectedScore("team1", "season1", db); + + expect(result.actualPoints).toBe(0); + expect(result.projectedPoints).toBe(30); + expect(result.participantsFinished).toBe(0); + expect(result.participantsRemaining).toBe(1); + }); + + it("contributes 0 projected when no EV data exists", async () => { + const db = makeDb([ + makePick("p1", "ss1", "playoff_bracket", null), + ]); + vi.mocked(getParticipantEV).mockResolvedValue(null); + + const result = await calculateTeamProjectedScore("team1", "season1", db); + + expect(result.actualPoints).toBe(0); + expect(result.projectedPoints).toBe(0); + }); + }); + + describe("mixed team", () => { + 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 + // Pick 3: pending → actual=0, EV=25 → +25 projected + // Total: actual=120, projected=120+15+25=160 + const db = makeDb([ + makePick("p1", "ss1", "playoff_bracket", { finalPosition: 1, isPartialScore: false }), + makePick("p2", "ss1", "playoff_bracket", { finalPosition: 5, isPartialScore: true }), + makePick("p3", "ss1", "playoff_bracket", null), + ]); + vi.mocked(getParticipantEV) + .mockResolvedValueOnce(DUMMY_EV_ROW) // p2 — partial + .mockResolvedValueOnce(DUMMY_EV_ROW); // p3 — pending + vi.mocked(calculateEV) + .mockReturnValueOnce(35) // p2 EV + .mockReturnValueOnce(25); // p3 EV + + const result = await calculateTeamProjectedScore("team1", "season1", db); + + expect(result.actualPoints).toBe(120); // 100 + 20 + expect(result.projectedPoints).toBe(160); // 120 + 15 + 25 + expect(result.participantsFinished).toBe(1); + expect(result.participantsRemaining).toBe(2); // partial + pending + }); + }); +}); diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index 704f095..b4dcd0c 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -939,22 +939,60 @@ export async function calculateTeamProjectedScore( let actualPoints = 0; let participantsFinished = 0; - const unfinishedParticipants: Array<{ participantId: string; sportsSeasonId: string }> = []; + const unfinishedParticipants: Array<{ participantId: string; sportsSeasonId: string; floorPoints: number }> = []; + + // Cache bracket template IDs per sports season + const bracketTemplateCache = new Map(); + async function getBracketTemplate(sportsSeasonId: string): Promise { + if (bracketTemplateCache.has(sportsSeasonId)) { + return bracketTemplateCache.get(sportsSeasonId)!; + } + const event = await db.query.scoringEvents.findFirst({ + where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), + columns: { bracketTemplateId: true }, + }); + const templateId = event?.bracketTemplateId ?? null; + bracketTemplateCache.set(sportsSeasonId, templateId); + return templateId; + } // Separate finished vs unfinished participants for (const pick of picks) { const result = pick.participant.results[0]; + const isBracket = pick.participant.sportsSeason?.scoringPattern === "playoff_bracket"; - if (result && result.finalPosition !== null) { - // Participant has finished - use actual points - const points = calculateFantasyPoints(result.finalPosition, scoringRules); + if (result && result.finalPosition !== null && !result.isPartialScore) { + // Participant is fully finalized — use bracket-averaged points + let points: number; + if (isBracket) { + const templateId = await getBracketTemplate(pick.participant.sportsSeasonId); + points = calculateBracketPoints(result.finalPosition, scoringRules, templateId); + } else { + points = calculateFantasyPoints(result.finalPosition, scoringRules); + } actualPoints += points; participantsFinished++; + } else if (result && result.finalPosition !== null && result.isPartialScore) { + // Still alive with a provisional floor — count floor as actual, EV for projection. + // Note: NOT incremented in participantsFinished; these participants are still competing. + const templateId = isBracket ? await getBracketTemplate(pick.participant.sportsSeasonId) : null; + const floorPoints = isBracket + ? calculateBracketPoints(result.finalPosition, scoringRules, templateId) + : calculateFantasyPoints(result.finalPosition, scoringRules); + actualPoints += floorPoints; + // EV already accounts for their full projected value, so subtract floor to avoid + // double-counting when we do actualPoints + evSum below + unfinishedParticipants.push({ + participantId: pick.participant.id, + sportsSeasonId: pick.participant.sportsSeasonId, + floorPoints, + }); } else { // Participant is unfinished - will need EV unfinishedParticipants.push({ participantId: pick.participant.id, sportsSeasonId: pick.participant.sportsSeasonId, + floorPoints: 0, }); } } @@ -966,7 +1004,7 @@ export async function calculateTeamProjectedScore( const { getParticipantEV } = await import("./participant-expected-value"); const { calculateEV } = await import("~/services/ev-calculator"); - for (const { participantId, sportsSeasonId } of unfinishedParticipants) { + for (const { participantId, sportsSeasonId, floorPoints } of unfinishedParticipants) { const ev = await getParticipantEV(participantId, sportsSeasonId); if (ev) { @@ -984,9 +1022,13 @@ export async function calculateTeamProjectedScore( }; const leagueSpecificEV = calculateEV(probabilities, scoringRules); - evSum += leagueSpecificEV; + // For partial-score participants, floor is already in actualPoints — add only the + // incremental EV above the floor to avoid double-counting in projectedPoints. + // For pending participants (floorPoints=0) this is just the full EV. + // Math.max guards against the rare case where EV drops below the floor. + evSum += Math.max(0, leagueSpecificEV - floorPoints); } - // If no EV exists, assume 0 (participant hasn't been evaluated yet) + // If no EV data exists, assume 0 additional projected points } } diff --git a/app/models/standings.ts b/app/models/standings.ts index 71ef7a0..9a051b2 100644 --- a/app/models/standings.ts +++ b/app/models/standings.ts @@ -2,6 +2,7 @@ import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq, and, desc } from "drizzle-orm"; import type { TeamStanding, TeamStandingSnapshot } from "~/types/standings"; +import { calculateBracketPoints, calculateFantasyPoints } from "~/models/scoring-rules"; // Re-export types from shared types file export type { TeamStanding, TeamStandingSnapshot } from "~/types/standings"; @@ -153,39 +154,36 @@ export async function getTeamScoreBreakdown( pointsFor8th: season.pointsFor8th, }; + // Cache bracket template IDs per sports season (same approach as calculateTeamScore) + const bracketTemplateCache = new Map(); + async function getBracketTemplate(sportsSeasonId: string): Promise { + if (bracketTemplateCache.has(sportsSeasonId)) { + return bracketTemplateCache.get(sportsSeasonId)!; + } + const event = await db.query.scoringEvents.findFirst({ + where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), + columns: { bracketTemplateId: true }, + }); + const templateId = event?.bracketTemplateId ?? null; + bracketTemplateCache.set(sportsSeasonId, templateId); + return templateId; + } + // Calculate points and projected points for each pick const pickBreakdown = await Promise.all( picks.map(async (pick) => { const result = pick.participant.results[0]; + const isBracket = pick.participant.sportsSeason.scoringPattern === "playoff_bracket"; let points = 0; let projectedPoints: number | null = null; - if (result && result.finalPosition) { - // Calculate points based on placement - const pointsMap: Record = { - 1: season.pointsFor1st, - 2: season.pointsFor2nd, - 3: season.pointsFor3rd, - 4: season.pointsFor4th, - 5: season.pointsFor5th, - 6: season.pointsFor6th, - 7: season.pointsFor7th, - 8: season.pointsFor8th, - }; - points = pointsMap[result.finalPosition] || 0; - projectedPoints = points; // Finished = projected equals actual - } else { - // Participant is unfinished - get EV + const getEV = async () => { const { getParticipantEV } = await import("./participant-expected-value"); const { calculateEV } = await import("~/services/ev-calculator"); - - const ev = await getParticipantEV( - pick.participant.id, - pick.participant.sportsSeasonId - ); - - if (ev) { - const probabilities = { + const ev = await getParticipantEV(pick.participant.id, pick.participant.sportsSeasonId); + if (!ev) return null; + return calculateEV( + { probFirst: parseFloat(ev.probFirst), probSecond: parseFloat(ev.probSecond), probThird: parseFloat(ev.probThird), @@ -194,10 +192,29 @@ export async function getTeamScoreBreakdown( probSixth: parseFloat(ev.probSixth), probSeventh: parseFloat(ev.probSeventh), probEighth: parseFloat(ev.probEighth), - }; + }, + scoringRules + ); + }; - projectedPoints = calculateEV(probabilities, scoringRules); + if (result && result.finalPosition != null && result.finalPosition > 0) { + // Calculate points using bracket-averaged scoring for bracket sports + if (isBracket) { + const templateId = await getBracketTemplate(pick.participant.sportsSeasonId); + points = calculateBracketPoints(result.finalPosition, scoringRules, templateId); + } else { + points = calculateFantasyPoints(result.finalPosition, scoringRules); } + + if (result.isPartialScore) { + // Still alive with a floor position — use EV for projected since they can advance + projectedPoints = (await getEV()) ?? points; + } else { + projectedPoints = points; // Finalized: projected equals actual + } + } else { + // Participant is unfinished - get EV + projectedPoints = await getEV(); } return { @@ -212,8 +229,10 @@ export async function getTeamScoreBreakdown( finalPosition: result?.finalPosition ?? null, points, projectedPoints, - // A participant is complete if they have a result record (even if finalPosition is 0) + // isComplete: has a result record (even if partial/floor) isComplete: !!result, + // isPartialScore: still alive with a provisional floor position + isPartialScore: result?.isPartialScore ?? false, }; }) ); @@ -234,7 +253,7 @@ export async function getTeamScoreBreakdown( picks: pickBreakdown, actualPoints, projectedPoints: projectedTotalPoints, - completedCount: pickBreakdown.filter((p) => p.isComplete).length, + completedCount: pickBreakdown.filter((p) => p.isComplete && !p.isPartialScore).length, totalCount: pickBreakdown.length, }; }