diff --git a/app/models/__tests__/participant-expected-value.test.ts b/app/models/__tests__/participant-expected-value.test.ts index 0486a8c..6f605a0 100644 --- a/app/models/__tests__/participant-expected-value.test.ts +++ b/app/models/__tests__/participant-expected-value.test.ts @@ -14,7 +14,7 @@ import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calcul // Mock database context const mockUpdate = vi.fn(); const mockSet = vi.fn(); -const mockWhere = vi.fn(); +const _mockWhere = vi.fn(); const mockDb = { update: mockUpdate, select: vi.fn(), @@ -32,11 +32,15 @@ vi.mock("~/database/schema", () => ({ }, })); +const mockSqlFn = Object.assign(vi.fn(() => ({})), { + join: vi.fn(() => ({})), +}); + vi.mock("drizzle-orm", () => ({ eq: vi.fn((field, value) => ({ field, value })), and: vi.fn((...args) => ({ and: args })), count: vi.fn(() => ({ count: true })), - sql: vi.fn(), + sql: mockSqlFn, })); describe("participant-expected-value model", () => { @@ -230,16 +234,14 @@ describe("participant-expected-value model", () => { await syncVorpForSeason("season-1"); - // Should have called db.update 3 times (once per participant) - expect(mockUpdate).toHaveBeenCalledTimes(3); + // Bulk update: db.update is called once for all participants + expect(mockUpdate).toHaveBeenCalledTimes(1); - // Verify the set calls include vorpValue - const setCalls = mockSet.mock.calls; - const vorpValues = setCalls.map((call) => call[0].vorpValue); - - expect(vorpValues).toContain("60.0000"); - expect(vorpValues).toContain("30.0000"); - expect(vorpValues).toContain("0.0000"); + // set() is called once with a CASE expression for vorpValue + expect(mockSet).toHaveBeenCalledTimes(1); + const setArg = mockSet.mock.calls[0][0]; + expect(setArg).toHaveProperty("vorpValue"); + expect(setArg).toHaveProperty("updatedAt"); }); }); }); diff --git a/app/models/participant-expected-value.ts b/app/models/participant-expected-value.ts index 03f642e..c33f39f 100644 --- a/app/models/participant-expected-value.ts +++ b/app/models/participant-expected-value.ts @@ -63,7 +63,7 @@ export async function syncVorpForSeason(sportsSeasonId: string): Promise { const allEvs = await getAllParticipantEVsForSeason(sportsSeasonId); if (allEvs.length === 0) return; - const sorted = [...allEvs].sort( + const sorted = [...allEvs].toSorted( (a, b) => parseFloat(b.expectedValue) - parseFloat(a.expectedValue) ); @@ -72,15 +72,22 @@ export async function syncVorpForSeason(sportsSeasonId: string): Promise { const now = new Date(); - await Promise.all( + // Build a single CASE expression to update all participants in one query + // instead of N individual UPDATE statements. + const vorpCaseExpr = sql`CASE ${sql.join( sorted.map((ev) => { const vorp = calculateVORP(parseFloat(ev.expectedValue), replacementLevel); - return db - .update(participants) - .set({ vorpValue: vorp.toFixed(4), updatedAt: now }) - .where(eq(participants.id, ev.participantId)); - }) - ); + return sql`WHEN ${participants.id} = ${ev.participantId} THEN ${vorp.toFixed(4)}::numeric`; + }), + sql` ` + )} END`; + + const ids = sorted.map((ev) => ev.participantId); + + await db + .update(participants) + .set({ vorpValue: vorpCaseExpr, updatedAt: now }) + .where(sql`${participants.id} = ANY(${sql`ARRAY[${sql.join(ids.map((id) => sql`${id}`), sql`, `)}]::uuid[]`})`); } /** @@ -258,11 +265,14 @@ export async function deleteParticipantEV( ) ); - // Reset participant EV to 0 + // Reset this participant's EV and VORP to 0 (no longer ranked) await db .update(participants) - .set({ expectedValue: "0", updatedAt: new Date() }) + .set({ expectedValue: "0", vorpValue: "0", updatedAt: new Date() }) .where(eq(participants.id, participantId)); + + // Recalculate VORP for remaining participants — replacement level may have shifted + await syncVorpForSeason(sportsSeasonId); } /** @@ -465,8 +475,12 @@ export function toProbabilityDistribution(ev: ParticipantEV): ProbabilityDistrib } /** - * Recalculate EV for a participant with new scoring rules - * Keeps probabilities the same, only updates EV based on new scoring + * Recalculate EV for a participant with new scoring rules. + * Keeps probabilities the same, only updates EV based on new scoring. + * + * NOTE: This function does NOT sync VORP. If calling this directly (outside + * of recalculateAllEVsForSeason), call syncVorpForSeason(sportsSeasonId) + * afterwards to keep draft order correct. */ export async function recalculateEV( participantId: string, diff --git a/app/routes/admin.sports-seasons.$id.expected-values.server.ts b/app/routes/admin.sports-seasons.$id.expected-values.server.ts index 5552e64..9dcbe4b 100644 --- a/app/routes/admin.sports-seasons.$id.expected-values.server.ts +++ b/app/routes/admin.sports-seasons.$id.expected-values.server.ts @@ -3,7 +3,7 @@ import { logger } from "~/lib/logger"; import { findSportsSeasonById } from "~/models/sports-season"; import { findParticipantsBySportsSeasonId } from "~/models/participant"; import { - upsertParticipantEV, + batchUpsertParticipantEVs, getAllParticipantEVsForSeason } from "~/models/participant-expected-value"; @@ -45,35 +45,24 @@ export async function action({ request, params }: Route.ActionArgs) { const participantIds = participants.map((p: { id: string }) => p.id); try { - const results = await Promise.all( - participantIds.map(async (participantId) => { - const probFirst = parseFloat(formData.get(`probFirst_${participantId}`) as string || "0") / 100; - const probSecond = parseFloat(formData.get(`probSecond_${participantId}`) as string || "0") / 100; - const probThird = parseFloat(formData.get(`probThird_${participantId}`) as string || "0") / 100; - const probFourth = parseFloat(formData.get(`probFourth_${participantId}`) as string || "0") / 100; - const probFifth = parseFloat(formData.get(`probFifth_${participantId}`) as string || "0") / 100; - const probSixth = parseFloat(formData.get(`probSixth_${participantId}`) as string || "0") / 100; - const probSeventh = parseFloat(formData.get(`probSeventh_${participantId}`) as string || "0") / 100; - const probEighth = parseFloat(formData.get(`probEighth_${participantId}`) as string || "0") / 100; + const inputs = participantIds.map((participantId) => ({ + participantId, + sportsSeasonId: params.id, + probabilities: { + probFirst: parseFloat(formData.get(`probFirst_${participantId}`) as string || "0") / 100, + probSecond: parseFloat(formData.get(`probSecond_${participantId}`) as string || "0") / 100, + probThird: parseFloat(formData.get(`probThird_${participantId}`) as string || "0") / 100, + probFourth: parseFloat(formData.get(`probFourth_${participantId}`) as string || "0") / 100, + probFifth: parseFloat(formData.get(`probFifth_${participantId}`) as string || "0") / 100, + probSixth: parseFloat(formData.get(`probSixth_${participantId}`) as string || "0") / 100, + probSeventh: parseFloat(formData.get(`probSeventh_${participantId}`) as string || "0") / 100, + probEighth: parseFloat(formData.get(`probEighth_${participantId}`) as string || "0") / 100, + }, + scoringRules, + source: "manual" as const, + })); - return upsertParticipantEV({ - participantId, - sportsSeasonId: params.id, - probabilities: { - probFirst, - probSecond, - probThird, - probFourth, - probFifth, - probSixth, - probSeventh, - probEighth, - }, - scoringRules, - source: "manual", - }); - }) - ); + const results = await batchUpsertParticipantEVs(inputs); const totalEV = results.reduce((sum, r) => sum + parseFloat(r.expectedValue), 0); diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index 1dd83c0..6eb8ca7 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -161,6 +161,7 @@ export async function loader(args: Route.LoaderArgs) { .select({ id: schema.participants.id, name: schema.participants.name, + vorpValue: schema.participants.vorpValue, sport: schema.sports, }) .from(schema.participants) diff --git a/app/services/ev-calculator.ts b/app/services/ev-calculator.ts index 781ae07..ff8bae9 100644 --- a/app/services/ev-calculator.ts +++ b/app/services/ev-calculator.ts @@ -169,12 +169,19 @@ export function calculateProjectedTotal( }; } +// Replacement level is defined as the average EV of players ranked 12th–14th +// (1-indexed). This range is a fixed product decision that applies regardless +// of actual league size — it represents the typical last pick in a standard draft. +const REPLACEMENT_LEVEL_START_IDX = 11; // 0-indexed (1-indexed position 12) +const REPLACEMENT_LEVEL_END_IDX = 13; // 0-indexed (1-indexed position 14) + /** * Calculate the replacement level EV for a sport's season. * * Replacement level = average EV of players ranked 12th–14th (1-indexed). - * These are the last picks in a standard 12-team draft, representing the - * baseline any manager could pick up off the waiver wire. + * This is a fixed constant by product decision — it does not vary by league + * size. It represents the baseline value any manager could expect from the + * last drafter-worthy player in a sport. * * @param sortedEvs - All participant EVs for a sport season, sorted descending * @returns Replacement level EV @@ -182,9 +189,8 @@ export function calculateProjectedTotal( export function calculateReplacementLevel(sortedEvs: number[]): number { if (sortedEvs.length === 0) return 0; - // Target: average EVs at 1-indexed positions 12-14 (0-indexed: 11-13) - const startIdx = Math.min(11, sortedEvs.length - 1); - const endIdx = Math.min(13, sortedEvs.length - 1); + const startIdx = Math.min(REPLACEMENT_LEVEL_START_IDX, sortedEvs.length - 1); + const endIdx = Math.min(REPLACEMENT_LEVEL_END_IDX, sortedEvs.length - 1); const slice = sortedEvs.slice(startIdx, endIdx + 1); const avg = slice.reduce((sum, ev) => sum + ev, 0) / slice.length; return avg;