fix: address VORP code review issues (#283)

* fix: address VORP code review issues

- Switch admin EV form to batchUpsertParticipantEVs to avoid N×N
  concurrent UPDATE storm (was calling upsertParticipantEV per
  participant, each triggering a full syncVorpForSeason)
- deleteParticipantEV now resets vorpValue to "0" and calls
  syncVorpForSeason so remaining participants' ranks stay correct
- syncVorpForSeason issues a single bulk CASE UPDATE instead of
  N individual UPDATE statements
- Add doc warning on recalculateEV that callers must sync VORP manually
- Extract REPLACEMENT_LEVEL_START/END_IDX constants; clarify comment
  that 12-14 is a fixed product decision, not derived from league size
- Include vorpValue in draft room participant select projection
- Update drizzle-orm mock to support sql.join; update test assertions
  to reflect single bulk-update call

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

* fix: resolve lint errors (toSorted, unused var)

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-04-08 23:20:02 -04:00 committed by GitHub
parent 8ee5a8008e
commit d5aa8a3de4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 69 additions and 57 deletions

View file

@ -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");
});
});
});

View file

@ -63,7 +63,7 @@ export async function syncVorpForSeason(sportsSeasonId: string): Promise<void> {
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<void> {
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,

View file

@ -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);

View file

@ -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)

View file

@ -169,12 +169,19 @@ export function calculateProjectedTotal(
};
}
// Replacement level is defined as the average EV of players ranked 12th14th
// (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 12th14th (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;