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:
parent
beab6400b2
commit
af99f6d002
5 changed files with 356 additions and 48 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
238
app/models/__tests__/team-projected-score.test.ts
Normal file
238
app/models/__tests__/team-projected-score.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue