Fix bracket point averaging and partial-score display in standings/breakdown (#159)

* Fix bracket point averaging and partial-score display in standings/breakdown

Bracket sports (UCL, NBA, NFL, etc.) use averaged points for tied
positions (e.g. QF losers all share avg of 5th-8th = 20 pts), but
both calculateTeamProjectedScore and getTeamScoreBreakdown were using
raw pointsFor5th (25) instead of the bracket-averaged value. This caused
the team breakdown page to show incorrect actual points (25 vs 20) and
the standings page to show incorrect actualPoints/projectedPoints totals.

Additionally, participants with isPartialScore=true (still alive in a
bracket with a provisional floor position) were being displayed with a
final placement badge (e.g. "5th") instead of "Pending".

Changes:
- calculateTeamProjectedScore: use calculateBracketPoints for bracket
  sports; handle isPartialScore participants separately (floor in
  actualPoints, incremental EV in projectedPoints, Math.max guard for
  EV < floor edge case)
- getTeamScoreBreakdown: same bracket averaging fix; pass isPartialScore
  through to picks; use explicit finalPosition != null && > 0 check
- TeamScoreBreakdown component: show Pending badge for isPartialScore
  participants; display actual/floor + EV row for all incomplete picks;
  add "actual / projected" column header subtitle
- 9 new unit tests covering all scoring branches including the EV-below-
  floor clamp edge case

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix typecheck: cast DUMMY_EV_ROW to any in test fixture

ParticipantEV has additional required fields (id, participantId, etc.)
that aren't needed for the mock — cast to any to satisfy the type checker.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-03-17 14:00:32 -07:00 committed by GitHub
parent beab6400b2
commit af99f6d002
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 356 additions and 48 deletions

View file

@ -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({
<TableHead>Sport</TableHead>
<TableHead>Participant</TableHead>
<TableHead className="text-center">Position</TableHead>
<TableHead className="text-right">Points</TableHead>
<TableHead className="text-right">
<div>Points</div>
<div className="text-xs font-normal text-muted-foreground">actual / projected</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@ -131,7 +135,7 @@ export function TeamScoreBreakdown({
{pick.participant.name}
</TableCell>
<TableCell className="text-center">
{pick.isComplete ? (
{pick.isComplete && !pick.isPartialScore ? (
(pick.finalPosition ?? 0) === 0 ? (
<Badge variant="secondary">Did Not Score</Badge>
) : (
@ -142,19 +146,19 @@ export function TeamScoreBreakdown({
)}
</TableCell>
<TableCell className="text-right">
{pick.isComplete ? (
{pick.isComplete && !pick.isPartialScore ? (
<span className="font-semibold">
{pick.points > 0 ? pick.points.toFixed(2) : "0.00"}
</span>
) : pick.projectedPoints !== null ? (
<div className="flex flex-col items-end">
<span className="text-xs text-muted-foreground">Projected:</span>
<span className="font-semibold text-primary">
{pick.projectedPoints.toFixed(2)}
</span>
</div>
) : (
<span className="text-muted-foreground">-</span>
<div className="flex flex-col items-end">
<span className="font-semibold">{pick.points.toFixed(2)}</span>
{pick.projectedPoints !== null && (
<span className="text-xs text-muted-foreground">
{pick.projectedPoints.toFixed(2)}
</span>
)}
</div>
)}
</TableCell>
</TableRow>

View file

@ -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(
<TeamScoreBreakdown
leagueId={mockLeagueId}
@ -327,7 +331,8 @@ describe("TeamScoreBreakdown", () => {
/>
);
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();
});

View file

@ -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<typeof makePick>[]) {
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
});
});
});

View file

@ -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<string, string | null>();
async function getBracketTemplate(sportsSeasonId: string): Promise<string | null> {
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
}
}

View file

@ -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<string, string | null>();
async function getBracketTemplate(sportsSeasonId: string): Promise<string | null> {
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<number, number> = {
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,
};
}