brackt/app/models/team-score-events.ts
Claude a143df51f6
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m4s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m22s
🚀 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-08 07:32:14 +00:00

478 lines
16 KiB
TypeScript

import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, inArray, desc, sql, and, isNull } from "drizzle-orm";
import {
calculateBracketPoints,
calculatePickPoints,
type ScoringRules,
} from "~/models/scoring-rules";
import {
countSharedPlacements,
lookupSharedPlacementCount,
} from "~/models/participant-result";
import { findParticipantNamesByIds } from "~/models/season-participant";
import { logger } from "~/lib/logger";
/**
* Low-level primitive: writes one team_score_events row with an explicit pointsDelta.
* participantIds are captured at write time so attribution is accurate regardless
* of future match results.
*
* When matchId is provided (bracket sports), one row is written per match using
* a partial unique index on (teamId, seasonId, matchId). When matchId is absent
* (non-bracket fallback), one row per (teamId, seasonId, scoringEventId) is used.
*
* For bracket sports, prefer calling recordMatchScoreEvents instead — it computes
* the exact per-season delta from scoring rules rather than requiring the caller
* to supply a pre-computed pointsDelta.
*/
export async function recordTeamScoreEvent(
params: {
teamId: string;
seasonId: string;
scoringEventId: string;
scoringEventName: string | null;
sportName: string | null;
participantIds: string[];
pointsDelta: number;
occurredAt?: Date;
matchId?: string;
},
providedDb?: ReturnType<typeof database>
): Promise<void> {
const db = providedDb || database();
const values = {
teamId: params.teamId,
seasonId: params.seasonId,
scoringEventId: params.scoringEventId,
scoringEventName: params.scoringEventName,
sportName: params.sportName,
matchId: params.matchId ?? null,
participantIds: params.participantIds,
pointsDelta: params.pointsDelta.toString(),
occurredAt: params.occurredAt ?? new Date(),
};
if (params.matchId) {
// Per-match path: unique on (teamId, seasonId, matchId) WHERE matchId IS NOT NULL
await db
.insert(schema.teamScoreEvents)
.values(values)
.onConflictDoUpdate({
target: [
schema.teamScoreEvents.teamId,
schema.teamScoreEvents.seasonId,
schema.teamScoreEvents.matchId,
],
targetWhere: sql`match_id IS NOT NULL`,
set: {
participantIds: params.participantIds,
pointsDelta: params.pointsDelta.toString(),
},
});
} else {
// Event-level fallback: unique on (teamId, seasonId, scoringEventId) WHERE matchId IS NULL
await db
.insert(schema.teamScoreEvents)
.values(values)
.onConflictDoUpdate({
target: [
schema.teamScoreEvents.teamId,
schema.teamScoreEvents.seasonId,
schema.teamScoreEvents.scoringEventId,
],
targetWhere: sql`match_id IS NULL`,
set: {
participantIds: params.participantIds,
pointsDelta: params.pointsDelta.toString(),
// Refreshed too, so a row written with a stale label can be repaired
// by a later correct run instead of keeping the wrong name forever.
scoringEventName: params.scoringEventName,
sportName: params.sportName,
},
});
}
}
/**
* Records a score event for every fantasy season that uses the given sports season,
* attributing the exact point delta to the specific match winner.
*
* Called from processMatchResult immediately after upsertParticipantResult sets the
* winner's new floor, so the delta is computed from the exact position change rather
* than from aggregate before/after standings totals.
*
* oldFloor is 0 when the participant had no prior result row (first match win).
*/
export async function recordMatchScoreEvents(
params: {
participantId: string;
sportsSeasonId: string;
oldFloor: number;
newFloor: number;
bracketTemplateId: string | null;
matchId: string;
eventId: string;
eventName: string | null;
},
providedDb?: ReturnType<typeof database>
): Promise<void> {
const db = providedDb || database();
// Fetch sport name for display (one query, shared across all seasons)
const sportsSeason = await db.query.sportsSeasons.findFirst({
where: eq(schema.sportsSeasons.id, params.sportsSeasonId),
with: { sport: { columns: { name: true } } },
});
const sportName = sportsSeason?.sport?.name ?? null;
// All fantasy seasons that include this sports season
const seasonSports = await db.query.seasonSports.findMany({
where: eq(schema.seasonSports.sportsSeasonId, params.sportsSeasonId),
columns: { seasonId: true },
});
if (seasonSports.length === 0) return;
const seasonIds = seasonSports.map((ss) => ss.seasonId);
// Batch fetch: which team in each season drafted this participant?
const picks = await db.query.draftPicks.findMany({
where: and(
inArray(schema.draftPicks.seasonId, seasonIds),
eq(schema.draftPicks.participantId, params.participantId)
),
columns: { teamId: true, seasonId: true },
});
if (picks.length === 0) return;
const teamBySeasonId = new Map(picks.map((p) => [p.seasonId, p.teamId]));
// Batch fetch scoring rules for all seasons in one query
const seasonRows = await db.query.seasons.findMany({
where: inArray(schema.seasons.id, seasonIds),
columns: {
id: true,
pointsFor1st: true, pointsFor2nd: true, pointsFor3rd: true,
pointsFor4th: true, pointsFor5th: true, pointsFor6th: true,
pointsFor7th: true, pointsFor8th: true,
},
});
const rulesBySeasonId = new Map<string, ScoringRules>(
seasonRows.map((s) => [s.id, {
pointsFor1st: s.pointsFor1st, pointsFor2nd: s.pointsFor2nd,
pointsFor3rd: s.pointsFor3rd, pointsFor4th: s.pointsFor4th,
pointsFor5th: s.pointsFor5th, pointsFor6th: s.pointsFor6th,
pointsFor7th: s.pointsFor7th, pointsFor8th: s.pointsFor8th,
}])
);
for (const seasonId of seasonIds) {
const teamId = teamBySeasonId.get(seasonId);
if (!teamId) continue;
const rules = rulesBySeasonId.get(seasonId);
if (!rules) continue;
const delta =
calculateBracketPoints(params.newFloor, rules, params.bracketTemplateId) -
calculateBracketPoints(params.oldFloor, rules, params.bracketTemplateId);
if (delta <= 0) continue;
try {
await recordTeamScoreEvent(
{
teamId,
seasonId,
scoringEventId: params.eventId,
scoringEventName: params.eventName,
sportName,
participantIds: [params.participantId],
pointsDelta: delta,
matchId: params.matchId,
},
db
);
} catch (err) {
logger.error(
`[TeamScoreEvents] Failed to record match score event for team ${teamId} match ${params.matchId}:`,
err
);
}
}
}
/**
* Ledger label for a one-shot finalization, derived from the scoring pattern so
* every caller agrees. Deriving it here rather than requiring a parameter is
* deliberate: recordTeamScoreEvent's upsert can rewrite the label, but a caller
* that simply forgets to pass one would otherwise stamp rows with the anchor
* event's own name ("The Open") instead of "Final Standings".
*/
function defaultEventName(scoringPattern: string | null | undefined): string {
return scoringPattern === "season_standings" ? "Season Complete" : "Final Standings";
}
/**
* Records ledger rows for a sports season whose final placements have just been
* assigned — qualifying_points (golf, tennis, CS2) and season_standings (F1).
*
* These patterns award all of their points in one step at finalization, so they
* have no per-match deltas and were never written to team_score_events at all:
* recordMatchScoreEvents is bracket-only and fires from match processing. The
* effect was that golf results silently never appeared in Recent Scores.
*
* One row per team (matching the event-level unique index), carrying that team's
* summed award and every participant that contributed. Points come from
* calculatePickPoints, so a tied golfer contributes the same split award the
* standings show.
*
* Every row is anchored to a real scoring event, because the event-level unique
* index is (teamId, seasonId, scoringEventId) and Postgres treats NULLs as
* distinct — a null anchor would defeat the upsert entirely. If no anchor can be
* found the ledger write is skipped rather than risking duplicates; standings are
* unaffected either way.
*
* Re-running is safe even if the anchor moves (a later event completes, or the
* backfill runs against changed data). Stale rows for this sports season are
* cleared before writing, so the upsert alone is not load-bearing.
*/
export async function recordFinalPlacementScoreEvents(
params: {
sportsSeasonId: string;
eventId?: string | null;
eventName?: string | null;
},
providedDb?: ReturnType<typeof database>
): Promise<void> {
const db = providedDb || database();
const sportsSeason = await db.query.sportsSeasons.findFirst({
where: eq(schema.sportsSeasons.id, params.sportsSeasonId),
columns: { id: true, scoringPattern: true },
with: { sport: { columns: { name: true } } },
});
if (!sportsSeason) return;
// Resolve the anchor event: the caller's, else the most recently completed
// event for this sports season.
//
// The completed filter is not cosmetic. drizzle's desc() emits a bare `desc`,
// and Postgres orders DESC as NULLS FIRST, so ordering on completedAt alone
// would rank a never-completed event (common for sibling major windows) above
// every finished one. Restrict to completed events and order explicitly.
let eventId = params.eventId ?? null;
const eventName = params.eventName ?? defaultEventName(sportsSeason.scoringPattern);
if (!eventId) {
const anchor = await db.query.scoringEvents.findFirst({
where: and(
eq(schema.scoringEvents.sportsSeasonId, params.sportsSeasonId),
eq(schema.scoringEvents.isComplete, true)
),
columns: { id: true },
orderBy: [
sql`${schema.scoringEvents.completedAt} DESC NULLS LAST`,
sql`${schema.scoringEvents.eventDate} DESC NULLS LAST`,
desc(schema.scoringEvents.id),
],
});
if (!anchor) {
logger.warn(
`[TeamScoreEvents] No completed scoring event to anchor final placements for sports season ${params.sportsSeasonId}; skipping ledger write`
);
return;
}
eventId = anchor.id;
}
// Scoring placements for this sports season, plus the tie spans they imply.
// Counted from these same rows rather than re-querying them.
const results = await db.query.seasonParticipantResults.findMany({
where: eq(schema.seasonParticipantResults.sportsSeasonId, params.sportsSeasonId),
columns: { sportsSeasonId: true, participantId: true, finalPosition: true },
});
const positionByParticipantId = new Map<string, number>();
for (const row of results) {
if (row.finalPosition !== null && row.finalPosition > 0) {
positionByParticipantId.set(row.participantId, row.finalPosition);
}
}
if (positionByParticipantId.size === 0) return;
const sharedPlacementCounts = countSharedPlacements(results);
const seasonSports = await db.query.seasonSports.findMany({
where: eq(schema.seasonSports.sportsSeasonId, params.sportsSeasonId),
columns: { seasonId: true },
});
if (seasonSports.length === 0) return;
const seasonIds = seasonSports.map((ss) => ss.seasonId);
const picks = await db.query.draftPicks.findMany({
where: and(
inArray(schema.draftPicks.seasonId, seasonIds),
inArray(schema.draftPicks.participantId, [...positionByParticipantId.keys()])
),
columns: { teamId: true, seasonId: true, participantId: true },
});
if (picks.length === 0) return;
const seasonRows = await db.query.seasons.findMany({
where: inArray(schema.seasons.id, seasonIds),
columns: {
id: true,
pointsFor1st: true, pointsFor2nd: true, pointsFor3rd: true,
pointsFor4th: true, pointsFor5th: true, pointsFor6th: true,
pointsFor7th: true, pointsFor8th: true,
},
});
const rulesBySeasonId = new Map<string, ScoringRules>(
seasonRows.map((s) => [s.id, {
pointsFor1st: s.pointsFor1st, pointsFor2nd: s.pointsFor2nd,
pointsFor3rd: s.pointsFor3rd, pointsFor4th: s.pointsFor4th,
pointsFor5th: s.pointsFor5th, pointsFor6th: s.pointsFor6th,
pointsFor7th: s.pointsFor7th, pointsFor8th: s.pointsFor8th,
}])
);
// Accumulate per (season, team) — the ledger's event-level grain.
const byTeam = new Map<
string,
{ teamId: string; seasonId: string; points: number; participantIds: string[] }
>();
for (const pick of picks) {
const rules = rulesBySeasonId.get(pick.seasonId);
const finalPosition = positionByParticipantId.get(pick.participantId);
if (!rules || finalPosition === undefined) continue;
const points = calculatePickPoints(
finalPosition,
sportsSeason.scoringPattern,
rules,
{
tiedParticipants: lookupSharedPlacementCount(
sharedPlacementCounts,
params.sportsSeasonId,
finalPosition
),
}
);
if (points <= 0) continue;
const key = `${pick.seasonId}:${pick.teamId}`;
const entry = byTeam.get(key) ?? {
teamId: pick.teamId,
seasonId: pick.seasonId,
points: 0,
participantIds: [],
};
entry.points += points;
entry.participantIds.push(pick.participantId);
byTeam.set(key, entry);
}
// Clear this sports season's existing event-level rows before writing, so a
// re-run whose anchor landed on a different event replaces rather than adds.
// Scoped to match_id IS NULL, which is what makes this safe: one-shot patterns
// write no other event-level rows (QP majors award qualifying points, not
// fantasy points), and qualifying-bracket matches carry a non-null matchId.
const eventIds = await db.query.scoringEvents.findMany({
where: eq(schema.scoringEvents.sportsSeasonId, params.sportsSeasonId),
columns: { id: true },
});
if (eventIds.length > 0) {
await db
.delete(schema.teamScoreEvents)
.where(
and(
isNull(schema.teamScoreEvents.matchId),
inArray(
schema.teamScoreEvents.scoringEventId,
eventIds.map((e) => e.id)
),
inArray(schema.teamScoreEvents.seasonId, seasonIds)
)
);
}
for (const entry of byTeam.values()) {
try {
await recordTeamScoreEvent(
{
teamId: entry.teamId,
seasonId: entry.seasonId,
scoringEventId: eventId,
scoringEventName: eventName,
sportName: sportsSeason.sport?.name ?? null,
participantIds: entry.participantIds,
pointsDelta: entry.points,
},
db
);
} catch (err) {
logger.error(
`[TeamScoreEvents] Failed to record final placement score event for team ${entry.teamId} sports season ${params.sportsSeasonId}:`,
err
);
}
}
}
export interface TeamScoreEventEntry {
id: string;
teamId: string;
teamName: string;
scoringEventId: string | null;
scoringEventName: string | null;
sportName: string | null;
pointsDelta: string;
occurredAt: Date;
participants: Array<{ id: string; name: string }>;
}
/**
* Returns the most recent scoring events for a league season, ordered by
* occurredAt DESC. Participant names are fetched from the stored participantIds.
*/
export async function getRecentTeamScoreEvents(
seasonId: string,
limit = 10,
providedDb?: ReturnType<typeof database>
): Promise<TeamScoreEventEntry[]> {
const db = providedDb || database();
const rows = await db.query.teamScoreEvents.findMany({
where: eq(schema.teamScoreEvents.seasonId, seasonId),
with: {
team: { columns: { id: true, name: true } },
},
orderBy: [desc(schema.teamScoreEvents.occurredAt)],
limit,
});
if (rows.length === 0) return [];
// Batch-fetch participant names for all stored participant IDs
const allParticipantIds = [
...new Set(rows.flatMap((r) => r.participantIds ?? [])),
];
const participantNameById = await findParticipantNamesByIds(db, allParticipantIds);
return rows.map((row) => ({
id: row.id,
teamId: row.teamId,
teamName: row.team.name,
scoringEventId: row.scoringEventId,
scoringEventName: row.scoringEventName,
sportName: row.sportName,
pointsDelta: row.pointsDelta,
occurredAt: row.occurredAt,
participants: (row.participantIds ?? [])
.map((id) => {
const name = participantNameById.get(id);
return name ? { id, name } : null;
})
.filter((p): p is { id: string; name: string } => p !== null),
}));
}