Fix slow bracket save and stale projected points (#54)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 1m31s
🚀 Deploy / ʦ TypeScript (push) Successful in 1m20s
🚀 Deploy / 🔍 Lint (push) Successful in 51s
🚀 Deploy / 🐳 Build (push) Successful in 15m9s
🚀 Deploy / 🚀 Deploy (push) Successful in 25s

## Summary
- Swap side-effect order so `updateProbabilitiesAfterResult` runs before `recalculateAffectedLeagues` in `processMatchResult`, `processPlayoffEvent`, and the `set-round-winners` route — projected points now reflect the new result immediately instead of waiting on the next simulation.
- Eliminate the N+1 EV lookup in `calculateTeamProjectedScore` by pre-fetching every EV the season needs in a single `inArray` query inside `recalculateStandings` and passing the map down.
- Add `skipProbabilities` option to `processPlayoffEvent`, used from `autoCompleteRoundIfDone` (callers have already refreshed EVs), avoiding a redundant pass on round-completing saves.

Fixes #31

## Test plan
- [x] `npm run typecheck`
- [x] `npx vitest run app/models/__tests__/team-projected-score.test.ts app/models/__tests__/process-match-result.test.ts app/services/__tests__/probability-updater.test.ts` — 57/57 passing
- [ ] Manual: save a round of bracket winners on a sports season with at least one fantasy league and confirm (a) the request returns faster and (b) the team standings show projected points updated to reflect the result without re-running the simulator

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #54
This commit is contained in:
chrisp 2026-05-27 04:28:10 +00:00
parent b4c224b368
commit a02f603575
3 changed files with 61 additions and 22 deletions

View file

@ -26,7 +26,10 @@ import {
getQPStandings, getQPStandings,
updateFinalRankings, updateFinalRankings,
} from "./qualifying-points"; } from "./qualifying-points";
import { getParticipantEV } from "./participant-expected-value"; import {
getParticipantEV,
type ParticipantEV,
} from "./participant-expected-value";
import { calculateEV } from "~/services/ev-calculator"; import { calculateEV } from "~/services/ev-calculator";
/** /**
@ -165,7 +168,7 @@ function getRoundConfig(
export async function processPlayoffEvent( export async function processPlayoffEvent(
eventId: string, eventId: string,
providedDb?: ReturnType<typeof database>, providedDb?: ReturnType<typeof database>,
options?: { skipRecalculate?: boolean } options?: { skipRecalculate?: boolean; skipProbabilities?: boolean }
): Promise<void> { ): Promise<void> {
const db = providedDb || database(); const db = providedDb || database();
@ -358,23 +361,25 @@ export async function processPlayoffEvent(
} }
} }
// Update probabilities first so the standings recalc reads fresh EVs.
if (!options?.skipProbabilities) {
try {
await updateProbabilitiesAfterResult(event.sportsSeasonId, true);
logger.log(
`[ScoringCalculator] Auto-updated probabilities for sports season ${event.sportsSeasonId}`
);
} catch (error) {
logger.error(
`[ScoringCalculator] Failed to auto-update probabilities for sports season ${event.sportsSeasonId}:`,
error
);
}
}
// Recalculate standings for all affected leagues (skipped when caller handles it) // Recalculate standings for all affected leagues (skipped when caller handles it)
if (!options?.skipRecalculate) { if (!options?.skipRecalculate) {
await recalculateAffectedLeagues(event.sportsSeasonId, db, { eventName: event.name, eventId }); await recalculateAffectedLeagues(event.sportsSeasonId, db, { eventName: event.name, eventId });
} }
// Auto-trigger probability recalculation after result
try {
await updateProbabilitiesAfterResult(event.sportsSeasonId, true);
logger.log(
`[ScoringCalculator] Auto-updated probabilities for sports season ${event.sportsSeasonId}`
);
} catch (error) {
logger.error(
`[ScoringCalculator] Failed to auto-update probabilities for sports season ${event.sportsSeasonId}:`,
error
);
}
} }
/** /**
@ -489,7 +494,8 @@ export async function processMatchResult(
const sideEffectOptions = (eventName || eventId) const sideEffectOptions = (eventName || eventId)
? { eventName, eventId, matchIds: matchId ? [matchId] : undefined } ? { eventName, eventId, matchIds: matchId ? [matchId] : undefined }
: undefined; : undefined;
await recalculateAffectedLeagues(sportsSeasonId, db, sideEffectOptions); // Update probabilities first so the standings recalc reads fresh EVs and
// projected points reflect the new result.
try { try {
await updateProbabilitiesAfterResult(sportsSeasonId, true); await updateProbabilitiesAfterResult(sportsSeasonId, true);
} catch (error) { } catch (error) {
@ -498,6 +504,7 @@ export async function processMatchResult(
error error
); );
} }
await recalculateAffectedLeagues(sportsSeasonId, db, sideEffectOptions);
} }
} }
@ -1061,7 +1068,8 @@ export async function calculateTeamScore(
export async function calculateTeamProjectedScore( export async function calculateTeamProjectedScore(
teamId: string, teamId: string,
seasonId: string, seasonId: string,
providedDb?: ReturnType<typeof database> providedDb?: ReturnType<typeof database>,
evByParticipantId?: Map<string, ParticipantEV>
): Promise<{ ): Promise<{
actualPoints: number; actualPoints: number;
projectedPoints: number; projectedPoints: number;
@ -1185,7 +1193,9 @@ export async function calculateTeamProjectedScore(
let evSum = 0; let evSum = 0;
if (unfinishedParticipants.length > 0) { if (unfinishedParticipants.length > 0) {
for (const { participantId, sportsSeasonId, floorPoints } of unfinishedParticipants) { for (const { participantId, sportsSeasonId, floorPoints } of unfinishedParticipants) {
const ev = await getParticipantEV(participantId, sportsSeasonId); const ev = evByParticipantId
? evByParticipantId.get(participantId) ?? null
: await getParticipantEV(participantId, sportsSeasonId);
if (ev) { if (ev) {
// EV is already calculated with default scoring (100/70/50/40/25/25/15/15) // EV is already calculated with default scoring (100/70/50/40/25/25/15/15)
@ -1317,11 +1327,32 @@ export async function recalculateStandings(
where: eq(schema.teams.seasonId, seasonId), where: eq(schema.teams.seasonId, seasonId),
}); });
// Pre-fetch every EV referenced by this season's draft picks in one query, then
// pass the map into calculateTeamProjectedScore to avoid an N+1 per team.
const evByParticipantId = new Map<string, ParticipantEV>();
const allPicks = await db.query.draftPicks.findMany({
where: eq(schema.draftPicks.seasonId, seasonId),
columns: { participantId: true },
with: { participant: { columns: { sportsSeasonId: true } } },
});
if (allPicks.length > 0) {
const sportsSeasonIds = Array.from(
new Set(allPicks.map((p) => p.participant.sportsSeasonId))
);
const evRows = await db
.select()
.from(schema.seasonParticipantExpectedValues)
.where(inArray(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonIds));
for (const ev of evRows) {
evByParticipantId.set(ev.participantId, ev);
}
}
// Calculate scores for all teams // Calculate scores for all teams
const teamScores = await Promise.all( const teamScores = await Promise.all(
teams.map(async (team) => { teams.map(async (team) => {
const score = await calculateTeamScore(team.id, seasonId, db); const score = await calculateTeamScore(team.id, seasonId, db);
const projected = await calculateTeamProjectedScore(team.id, seasonId, db); const projected = await calculateTeamProjectedScore(team.id, seasonId, db, evByParticipantId);
return { return {
teamId: team.id, teamId: team.id,
totalPoints: score.totalPoints, totalPoints: score.totalPoints,

View file

@ -238,7 +238,9 @@ export async function action({ request, params }: Route.ActionArgs) {
.set({ playoffRound: round, updatedAt: new Date() }) .set({ playoffRound: round, updatedAt: new Date() })
.where(eq(schema.scoringEvents.id, eventId)); .where(eq(schema.scoringEvents.id, eventId));
await processPlayoffEvent(eventId, db, { skipRecalculate: true }); // skipProbabilities=true: callers (set-winner / set-round-winners) already ran
// updateProbabilitiesAfterResult before reaching here, so re-running would be wasted.
await processPlayoffEvent(eventId, db, { skipRecalculate: true, skipProbabilities: true });
logger.log(`[AutoComplete] Round "${round}" auto-completed for event ${eventId}`); logger.log(`[AutoComplete] Round "${round}" auto-completed for event ${eventId}`);
} }
@ -444,12 +446,14 @@ export async function action({ request, params }: Route.ActionArgs) {
// previously completed matches in the event. // previously completed matches in the event.
if (successCount > 0) { if (successCount > 0) {
const db = database(); const db = database();
await recalculateAffectedLeagues(event.sportsSeasonId, db, { eventId: event.id, eventName: event.name ?? undefined, matchIds: processedMatchIds }); // Update probabilities first so recalculateAffectedLeagues reads fresh EVs
// when computing projected points.
try { try {
await updateProbabilitiesAfterResult(event.sportsSeasonId, true); await updateProbabilitiesAfterResult(event.sportsSeasonId, true);
} catch (error) { } catch (error) {
logger.error(`Error updating probabilities after batch round winners:`, error); logger.error(`Error updating probabilities after batch round winners:`, error);
} }
await recalculateAffectedLeagues(event.sportsSeasonId, db, { eventId: event.id, eventName: event.name ?? undefined, matchIds: processedMatchIds });
await autoCompleteRoundIfDone(event.id, round, event.sportsSeasonId, db); await autoCompleteRoundIfDone(event.id, round, event.sportsSeasonId, db);
} }

View file

@ -143,6 +143,9 @@ export async function updateProbabilitiesAfterResult(
pointsFor8th: 15, pointsFor8th: 15,
}; };
// Sequential: upsertParticipantEV calls syncVorpForSeason internally, which
// reads and rewrites every seasonParticipants row for this sportsSeasonId.
// Running these in parallel would race on that shared state.
for (const [participantId, finalPosition] of finishedMap.entries()) { for (const [participantId, finalPosition] of finishedMap.entries()) {
try { try {
const probs = createFinishedProbabilities(finalPosition); const probs = createFinishedProbabilities(finalPosition);
@ -185,7 +188,8 @@ export async function updateProbabilitiesAfterResult(
// Recalculate ICM for unfinished participants // Recalculate ICM for unfinished participants
const icmResults = calculateICMFromOdds(unfinishedOdds); const icmResults = calculateICMFromOdds(unfinishedOdds);
// Update each unfinished participant // Sequential for the same reason as the finished loop above:
// upsertParticipantEV rewrites shared per-season state via syncVorpForSeason.
for (const [participantId, icmResult] of icmResults.entries()) { for (const [participantId, icmResult] of icmResults.entries()) {
try { try {
const probs = [ const probs = [