brackt/app/models/draft-pick.ts
Claude 75960a8826
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m5s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m20s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Fix review findings in the tie-split ledger and backfill
A review of the previous two commits found five defects in the new ledger
writer and backfill, plus one pre-existing scoring bug the refactor
exposed.

season_standings ties were never split. processSeasonStandings
deliberately writes the same finalPosition to every driver in a tied group
-- its comment says "the scoring system will handle averaging" -- but no
path ever did, so two drivers tied for 3rd each banked the full 50 instead
of the published 45. This predates the tie-split work; the original
cascade had only bracket and qualifying_points arms. Introduce
usesSharedPlacementSplit as the single definition of which patterns record
ties as a repeated placement, and route both calculatePickPoints and every
caller-side gate through it. The caller gates matter as much as the
helper: a gate left hardcoded to qualifying_points silently passes a tie
count of 1, which reads as "no tie" and makes the fix inert.

The ledger anchor picked the wrong event. Ordering on completedAt with no
isComplete filter ranked never-completed events first, because drizzle's
desc() emits a bare desc and Postgres orders DESC as NULLS FIRST.
Restrict to completed events and order explicitly with NULLS LAST plus a
stable tiebreak. The anchor is also no longer load-bearing for
idempotence: stale event-level rows for the sports season are cleared
before writing, so a re-run whose anchor moved replaces rather than
duplicates.

A ledger failure could abort finalization. The call sat unguarded after
the season was already marked completed, so a throw in any of its queries
would skip the standings recalculation and the Discord notification. Guard
both call sites the way the probability refresh directly below already is.

Rows could be mislabelled permanently. The backfill passed no eventName,
and the upsert never rewrote scoringEventName. Derive the label from the
scoring pattern inside the writer so omitting it is impossible, and
refresh it on conflict so existing rows can be repaired.

The backfill damaged unrelated leagues. recalculateStandings rewrites
previousRank, so sweeping every season wiped rank-movement arrows league
wide, including leagues holding no tie at all. Scope it to seasons
drafting from a sports season that actually contains a tied placement, and
correct the docblock that called it a pure recompute.

Also drops the inert Number.EPSILON guard from calculateAveragedPoints
(EPSILON is below the ULP for any value >= 2, and integer averages landing
on .5 are exactly representable) and extracts countSharedPlacements so the
ledger writer stops re-querying rows it already holds.

Every fix is covered by a test confirmed to fail when that fix alone is
reverted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-23 01:57:54 +00:00

371 lines
12 KiB
TypeScript

import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and, inArray, asc } from "drizzle-orm";
import {
getScoringRules,
calculatePickPoints,
usesSharedPlacementSplit,
} from "./scoring-rules";
import {
getSharedPlacementCounts,
lookupSharedPlacementCount,
} from "./participant-result";
export async function createDraftPick(data: {
seasonId: string;
teamId: string;
participantId: string;
pickNumber: number;
round: number;
pickInRound: number;
pickedByUserId: string;
pickedByType: "owner" | "commissioner" | "auto";
timeUsed: number;
}) {
const db = database();
const [pick] = await db.insert(schema.draftPicks).values(data).returning();
return pick;
}
export async function getDraftPicks(seasonId: string) {
const db = database();
return await db
.select()
.from(schema.draftPicks)
.where(eq(schema.draftPicks.seasonId, seasonId))
.orderBy(schema.draftPicks.pickNumber);
}
export async function getDraftPickByNumber(seasonId: string, pickNumber: number) {
const db = database();
const [pick] = await db
.select()
.from(schema.draftPicks)
.where(
and(
eq(schema.draftPicks.seasonId, seasonId),
eq(schema.draftPicks.pickNumber, pickNumber)
)
);
return pick;
}
export async function getTeamDraftPicks(teamId: string) {
const db = database();
return await db
.select()
.from(schema.draftPicks)
.where(eq(schema.draftPicks.teamId, teamId))
.orderBy(schema.draftPicks.pickNumber);
}
export async function isParticipantDrafted(seasonId: string, participantId: string, providedDb?: ReturnType<typeof database>) {
const db = providedDb || database();
const [pick] = await db
.select()
.from(schema.draftPicks)
.where(
and(
eq(schema.draftPicks.seasonId, seasonId),
eq(schema.draftPicks.participantId, participantId)
)
);
return !!pick;
}
/**
* Get a team's drafted participants grouped by sports season.
* Returns a map of sportsSeasonId → [{id, name}].
*/
export async function getDraftedParticipantsBySportsSeason(
teamId: string,
seasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<Map<string, Array<{ id: string; name: string }>>> {
const db = providedDb || database();
const results = await db
.select({
sportsSeasonId: schema.seasonParticipants.sportsSeasonId,
participantId: schema.seasonParticipants.id,
participantName: schema.seasonParticipants.name,
})
.from(schema.draftPicks)
.innerJoin(
schema.seasonParticipants,
eq(schema.draftPicks.participantId, schema.seasonParticipants.id)
)
.where(
and(
eq(schema.draftPicks.teamId, teamId),
eq(schema.draftPicks.seasonId, seasonId)
)
);
const map = new Map<string, Array<{ id: string; name: string }>>();
for (const row of results) {
if (!map.has(row.sportsSeasonId)) {
map.set(row.sportsSeasonId, []);
}
map.get(row.sportsSeasonId)?.push({ id: row.participantId, name: row.participantName });
}
return map;
}
export interface DraftedParticipantWithPoints {
id: string;
name: string;
/** Fantasy league points earned (position → scoring rules). null = no result yet. */
earnedPoints: number | null;
/** Accumulated qualifying points for qualifying_points sports. null for other sports. */
currentQP: number | null;
}
/**
* Get a team's drafted participants with their current earned points, grouped by
* sports season. Used for the league home summary card.
*
* - playoff_bracket / season_standings: earnedPoints = finalPosition → scoring rules
* - qualifying_points (active): currentQP = accumulated QP from participantQualifyingTotals
* - qualifying_points (finalized): earnedPoints = shared finalPosition slots → scoring rules
* - No EV / projected points — actual earned only.
*/
export async function getDraftedParticipantsWithPoints(
teamId: string,
seasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<Map<string, DraftedParticipantWithPoints[]>> {
const db = providedDb || database();
const scoringRules = await getScoringRules(seasonId, db);
if (!scoringRules) return new Map();
// One query: picks → participant → sportsSeason (scoringPattern) + results (position)
const picks = await db.query.draftPicks.findMany({
where: and(
eq(schema.draftPicks.teamId, teamId),
eq(schema.draftPicks.seasonId, seasonId)
),
columns: {},
with: {
participant: {
columns: { id: true, name: true, sportsSeasonId: true },
with: {
sportsSeason: { columns: { id: true, scoringPattern: true } },
results: { columns: { finalPosition: true }, limit: 1 },
},
},
},
});
// Collect unique sportsSeasonIds per scoring pattern
const bracketSeasonIds = new Set<string>();
const qpSeasonIds = new Set<string>();
const qpParticipantIds = new Set<string>();
// Seasons needing a tie count. Broader than qpSeasonIds: season_standings also
// records ties as a repeated finalPosition, and gating this on qualifying_points
// alone would silently score tied F1 drivers at the full placement value.
const tieSplitSeasonIds = new Set<string>();
for (const pick of picks) {
const pattern = pick.participant.sportsSeason.scoringPattern;
const ssId = pick.participant.sportsSeasonId;
const finalPosition = pick.participant.results[0]?.finalPosition;
if (pattern === "playoff_bracket") bracketSeasonIds.add(ssId);
if (pattern === "qualifying_points") {
// Accumulated QP is a qualifying_points-only concept — no F1 equivalent.
qpSeasonIds.add(ssId);
qpParticipantIds.add(pick.participant.id);
}
if (usesSharedPlacementSplit(pattern) && finalPosition !== null && finalPosition !== undefined) {
tieSplitSeasonIds.add(ssId);
}
}
// Batch-fetch bracket template IDs (one per sports season)
const bracketTemplateMap = new Map<string, string | null>();
if (bracketSeasonIds.size > 0) {
const events = await db.query.scoringEvents.findMany({
where: inArray(schema.scoringEvents.sportsSeasonId, [...bracketSeasonIds]),
columns: { sportsSeasonId: true, bracketTemplateId: true },
});
for (const ev of events) {
if (!bracketTemplateMap.has(ev.sportsSeasonId)) {
bracketTemplateMap.set(ev.sportsSeasonId, ev.bracketTemplateId ?? null);
}
}
}
// Batch-fetch QP totals for qualifying_points participants
const qpMap = new Map<string, number>(); // participantId → totalQP
if (qpParticipantIds.size > 0 && qpSeasonIds.size > 0) {
const totals = await db.query.seasonParticipantQualifyingTotals.findMany({
where: and(
inArray(schema.seasonParticipantQualifyingTotals.participantId, [...qpParticipantIds]),
inArray(schema.seasonParticipantQualifyingTotals.sportsSeasonId, [...qpSeasonIds])
),
columns: { participantId: true, totalQualifyingPoints: true },
});
for (const row of totals) {
qpMap.set(row.participantId, parseFloat(row.totalQualifyingPoints));
}
}
const sharedPlacementCounts = await getSharedPlacementCounts([...tieSplitSeasonIds], db);
// Assemble result grouped by sportsSeasonId
const result = new Map<string, DraftedParticipantWithPoints[]>();
for (const pick of picks) {
const { id, name, sportsSeasonId } = pick.participant;
const pattern = pick.participant.sportsSeason.scoringPattern;
const resultRow = pick.participant.results[0] ?? null;
let earnedPoints: number | null = null;
let currentQP: number | null = null;
if (pattern === "qualifying_points" && (resultRow?.finalPosition === null || resultRow?.finalPosition === undefined)) {
// Active QP season: show accumulated qualifying points
currentQP = qpMap.get(id) ?? null;
} else if (resultRow?.finalPosition !== null && resultRow?.finalPosition !== undefined) {
// Finalized result for any pattern (including finalized QP seasons)
earnedPoints = calculatePickPoints(resultRow.finalPosition, pattern, scoringRules, {
bracketTemplateId: bracketTemplateMap.get(sportsSeasonId) ?? null,
tiedParticipants: lookupSharedPlacementCount(
sharedPlacementCounts,
sportsSeasonId,
resultRow.finalPosition
),
});
}
const arr = result.get(sportsSeasonId) ?? [];
result.set(sportsSeasonId, arr);
arr.push({ id, name, earnedPoints, currentQP });
}
return result;
}
export async function deleteAllDraftPicks(seasonId: string) {
const db = database();
await db
.delete(schema.draftPicks)
.where(eq(schema.draftPicks.seasonId, seasonId));
}
/**
* Get all draft picks for a season with participant and sport information
* Used for draft eligibility calculations
*/
export async function getDraftPicksWithSports(seasonId: string, providedDb?: ReturnType<typeof database>) {
const db = providedDb || database();
const results = await db
.select({
id: schema.draftPicks.id,
teamId: schema.draftPicks.teamId,
pickNumber: schema.draftPicks.pickNumber,
participantId: schema.seasonParticipants.id,
participantName: schema.seasonParticipants.name,
sportId: schema.sports.id,
sportName: schema.sports.name,
})
.from(schema.draftPicks)
.innerJoin(
schema.seasonParticipants,
eq(schema.draftPicks.participantId, schema.seasonParticipants.id)
)
.innerJoin(
schema.sportsSeasons,
eq(schema.seasonParticipants.sportsSeasonId, schema.sportsSeasons.id)
)
.innerJoin(
schema.sports,
eq(schema.sportsSeasons.sportId, schema.sports.id)
)
.where(eq(schema.draftPicks.seasonId, seasonId))
.orderBy(schema.draftPicks.pickNumber);
// Transform to expected format
return results.map((r) => ({
teamId: r.teamId,
participant: {
id: r.participantId,
sport: {
id: r.sportId,
name: r.sportName,
},
},
}));
}
/**
* Get team's draft picks with participant and sport information
*/
export async function getTeamDraftPicksWithSports(teamId: string, seasonId: string, providedDb?: ReturnType<typeof database>) {
const db = providedDb || database();
const results = await db
.select({
id: schema.draftPicks.id,
teamId: schema.draftPicks.teamId,
pickNumber: schema.draftPicks.pickNumber,
participantId: schema.seasonParticipants.id,
participantName: schema.seasonParticipants.name,
sportId: schema.sports.id,
sportName: schema.sports.name,
})
.from(schema.draftPicks)
.innerJoin(
schema.seasonParticipants,
eq(schema.draftPicks.participantId, schema.seasonParticipants.id)
)
.innerJoin(
schema.sportsSeasons,
eq(schema.seasonParticipants.sportsSeasonId, schema.sportsSeasons.id)
)
.innerJoin(
schema.sports,
eq(schema.sportsSeasons.sportId, schema.sports.id)
)
.where(
and(
eq(schema.draftPicks.teamId, teamId),
eq(schema.draftPicks.seasonId, seasonId)
)
)
.orderBy(schema.draftPicks.pickNumber);
// Transform to expected format
return results.map((r) => ({
teamId: r.teamId,
participant: {
id: r.participantId,
sport: {
id: r.sportId,
name: r.sportName,
},
},
}));
}
export async function getDraftPicksForSeason(seasonId: string) {
const db = database();
return await db
.select({
id: schema.draftPicks.id,
pickNumber: schema.draftPicks.pickNumber,
round: schema.draftPicks.round,
pickInRound: schema.draftPicks.pickInRound,
timeUsed: schema.draftPicks.timeUsed,
team: schema.teams,
participant: schema.seasonParticipants,
sport: schema.sports,
})
.from(schema.draftPicks)
.innerJoin(schema.teams, eq(schema.draftPicks.teamId, schema.teams.id))
.innerJoin(schema.seasonParticipants, eq(schema.draftPicks.participantId, schema.seasonParticipants.id))
.innerJoin(schema.sportsSeasons, eq(schema.seasonParticipants.sportsSeasonId, schema.sportsSeasons.id))
.innerJoin(schema.sports, eq(schema.sportsSeasons.sportId, schema.sports.id))
.where(eq(schema.draftPicks.seasonId, seasonId))
.orderBy(asc(schema.draftPicks.pickNumber));
}