brackt/app/models/standings.ts
Claude 430526104c Unify tie-split point math across every screen
A participant tied for a scoring placement splits the combined points of
the tied positions. Four code paths computed a pick's points, each
re-implementing the same bracket/qualifying_points/default cascade, and
two of them omitted the qualifying_points arm entirely. A golfer tied for
8th was therefore worth the full 15 points on the team page and draft
board but the split 7.5 in the standings, so a team read 225 on one
screen and 218 on another.

Collapse the cascade into a single calculatePickPoints helper and route
all six call sites through it, backed by one shared getSharedPlacementCounts
loader replacing the two separate tie-count queries. Tie counts span every
participant in the sports season, not just drafted ones, since an
undrafted tie partner still halves the award.

Also round split awards to the nearest whole point in
calculateAveragedPoints. The /rules page states ties are "combined and
split equally among them, rounded to the nearest whole point", and its own
worked example rounds 18.33 down to 18, so this is nearest rather than
ceiling. Season point values are integer columns, making this averaging
the only source of fractional points; rounding here means the standings'
218 is now correct by construction rather than a display artifact, and
per-pick values visibly sum to the team total.

Existing assertions encoding the unrounded results are updated, and the
rules page's two published examples are asserted directly so the code and
the published rule cannot drift apart.

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

455 lines
15 KiB
TypeScript

import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and } from "drizzle-orm";
import type { TeamStanding, TeamStandingSnapshot, TeamStandingWithChange } from "~/types/standings";
import { calculatePickPoints } from "~/models/scoring-rules";
import {
getSharedPlacementCounts,
lookupSharedPlacementCount,
} from "~/models/participant-result";
import { logger } from "~/lib/logger";
import { getParticipantEV } from "./participant-expected-value";
import { calculateEV } from "~/services/ev-calculator";
// Re-export types from shared types file
export type { TeamStanding, TeamStandingSnapshot, TeamStandingWithChange } from "~/types/standings";
/**
* Get current standings for a season
*/
export async function getSeasonStandings(
seasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<TeamStanding[]> {
const db = providedDb || database();
const standings = await db.query.teamStandings.findMany({
where: eq(schema.teamStandings.seasonId, seasonId),
with: {
team: true,
},
});
// Sort by currentRank ascending (1 is best, 2 is second, etc.)
const sorted = standings.toSorted((a, b) => a.currentRank - b.currentRank);
return sorted.map((standing) => ({
teamId: standing.teamId,
teamName: standing.team.name,
totalPoints: parseFloat(standing.totalPoints),
currentRank: standing.currentRank,
previousRank: standing.previousRank,
rankChange: standing.previousRank
? standing.previousRank - standing.currentRank
: 0,
placementCounts: {
first: standing.firstPlaceCount,
second: standing.secondPlaceCount,
third: standing.thirdPlaceCount,
fourth: standing.fourthPlaceCount,
fifth: standing.fifthPlaceCount,
sixth: standing.sixthPlaceCount,
seventh: standing.seventhPlaceCount,
eighth: standing.eighthPlaceCount,
},
participantsRemaining: standing.participantsRemaining,
calculatedAt: standing.calculatedAt,
// Phase 5.4: Include projected points
actualPoints: standing.actualPoints ? parseFloat(standing.actualPoints) : null,
projectedPoints: standing.projectedPoints ? parseFloat(standing.projectedPoints) : null,
participantsFinished: standing.participantsFinished,
}));
}
/**
* Get standings for a specific team
*/
export async function getTeamStanding(
teamId: string,
seasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<TeamStanding | null> {
const db = providedDb || database();
const standing = await db.query.teamStandings.findFirst({
where: and(
eq(schema.teamStandings.teamId, teamId),
eq(schema.teamStandings.seasonId, seasonId)
),
with: {
team: true,
},
});
if (!standing) return null;
return {
teamId: standing.teamId,
teamName: standing.team.name,
totalPoints: parseFloat(standing.totalPoints),
currentRank: standing.currentRank,
previousRank: standing.previousRank,
rankChange: standing.previousRank
? standing.previousRank - standing.currentRank
: 0,
placementCounts: {
first: standing.firstPlaceCount,
second: standing.secondPlaceCount,
third: standing.thirdPlaceCount,
fourth: standing.fourthPlaceCount,
fifth: standing.fifthPlaceCount,
sixth: standing.sixthPlaceCount,
seventh: standing.seventhPlaceCount,
eighth: standing.eighthPlaceCount,
},
participantsRemaining: standing.participantsRemaining,
calculatedAt: standing.calculatedAt,
// Phase 5.4: Include projected points
actualPoints: standing.actualPoints ? parseFloat(standing.actualPoints) : null,
projectedPoints: standing.projectedPoints ? parseFloat(standing.projectedPoints) : null,
participantsFinished: standing.participantsFinished,
};
}
/**
* Get detailed team breakdown with all picks and their points
*/
export async function getTeamScoreBreakdown(
teamId: string,
seasonId: string,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
// Get season scoring rules
const season = await db.query.seasons.findFirst({
where: eq(schema.seasons.id, seasonId),
});
if (!season) return null;
// Get all draft picks for this team with participant details
const picks = await db.query.draftPicks.findMany({
where: and(
eq(schema.draftPicks.teamId, teamId),
eq(schema.draftPicks.seasonId, seasonId)
),
orderBy: schema.draftPicks.pickNumber,
with: {
participant: {
with: {
sportsSeason: {
with: {
sport: true,
},
},
results: true,
},
},
},
});
// Get scoring rules for EV calculation
const scoringRules = {
pointsFor1st: season.pointsFor1st,
pointsFor2nd: season.pointsFor2nd,
pointsFor3rd: season.pointsFor3rd,
pointsFor4th: season.pointsFor4th,
pointsFor5th: season.pointsFor5th,
pointsFor6th: season.pointsFor6th,
pointsFor7th: season.pointsFor7th,
pointsFor8th: season.pointsFor8th,
};
// Tie counts for qualifying_points picks, so a golfer tied for 8th is worth the
// split award here exactly as it is in calculateTeamScore. Counted across every
// participant in the sports season — an undrafted tie partner still halves it.
const sharedPlacementCounts = await getSharedPlacementCounts(
[...new Set(picks.map((p) => p.participant.sportsSeasonId))],
db
);
// Cache bracket template IDs per sports season (same approach as calculateTeamScore)
const bracketTemplateCache = new Map<string, string | null>();
async function getBracketTemplate(sportsSeasonId: string): Promise<string | null> {
if (bracketTemplateCache.has(sportsSeasonId)) {
return bracketTemplateCache.get(sportsSeasonId) ?? null;
}
const event = await db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
columns: { bracketTemplateId: true },
});
const templateId = event?.bracketTemplateId ?? null;
bracketTemplateCache.set(sportsSeasonId, templateId);
return templateId;
}
// Calculate points and projected points for each pick
const pickBreakdown = await Promise.all(
picks.map(async (pick) => {
const result = pick.participant.results[0];
const pattern = pick.participant.sportsSeason.scoringPattern;
let points = 0;
let projectedPoints: number | null = null;
const getEV = async () => {
const ev = await getParticipantEV(pick.participant.id, pick.participant.sportsSeasonId);
if (!ev) return null;
return calculateEV(
{
probFirst: parseFloat(ev.probFirst),
probSecond: parseFloat(ev.probSecond),
probThird: parseFloat(ev.probThird),
probFourth: parseFloat(ev.probFourth),
probFifth: parseFloat(ev.probFifth),
probSixth: parseFloat(ev.probSixth),
probSeventh: parseFloat(ev.probSeventh),
probEighth: parseFloat(ev.probEighth),
},
scoringRules
);
};
if (result && result.finalPosition !== null && result.finalPosition > 0) {
points = calculatePickPoints(result.finalPosition, pattern, scoringRules, {
bracketTemplateId:
pattern === "playoff_bracket"
? await getBracketTemplate(pick.participant.sportsSeasonId)
: null,
tiedParticipants: lookupSharedPlacementCount(
sharedPlacementCounts,
pick.participant.sportsSeasonId,
result.finalPosition
),
});
if (result.isPartialScore) {
// Still alive with a floor position — use EV for projected since they can advance
projectedPoints = (await getEV()) ?? points;
} else {
projectedPoints = points; // Finalized: projected equals actual
}
} else {
// Participant is unfinished - get EV
projectedPoints = await getEV();
}
return {
pickNumber: pick.pickNumber,
round: pick.round,
participant: {
id: pick.participant.id,
name: pick.participant.name,
sport: pick.participant.sportsSeason.sport.name,
sportsSeasonId: pick.participant.sportsSeasonId,
},
finalPosition: result?.finalPosition ?? null,
points,
projectedPoints,
// isComplete: has a result record (even if partial/floor)
isComplete: !!result,
// isPartialScore: still alive with a provisional floor position
isPartialScore: result?.isPartialScore ?? false,
};
})
);
const actualPoints = pickBreakdown
.filter((p) => p.isComplete)
.reduce((sum, p) => sum + p.points, 0);
const projectedTotalPoints = pickBreakdown.reduce(
(sum, p) => sum + (p.projectedPoints ?? 0),
0
);
return {
team: await db.query.teams.findFirst({
where: eq(schema.teams.id, teamId),
}),
picks: pickBreakdown,
actualPoints,
projectedPoints: projectedTotalPoints,
completedCount: pickBreakdown.filter((p) => p.isComplete && !p.isPartialScore).length,
totalCount: pickBreakdown.length,
};
}
/**
* Get historical standings snapshots for a team
* Used to display point progression over time
*/
export async function getTeamStandingsHistory(
teamId: string,
seasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<TeamStandingSnapshot[]> {
const db = providedDb || database();
const snapshots = await db.query.teamStandingsSnapshots.findMany({
where: and(
eq(schema.teamStandingsSnapshots.teamId, teamId),
eq(schema.teamStandingsSnapshots.seasonId, seasonId)
),
orderBy: schema.teamStandingsSnapshots.snapshotDate,
});
return snapshots.map((snapshot) => ({
date: new Date(snapshot.snapshotDate),
rank: snapshot.rank,
totalPoints: parseFloat(snapshot.totalPoints),
}));
}
/**
* Get standings comparison between current and 7 days ago
* Used for "7-day change" display
*/
export async function getSevenDayStandingsChange(
seasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<TeamStandingWithChange[]> {
const db = providedDb || database();
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
// Get current standings
const current = await getSeasonStandings(seasonId, db);
// Get snapshots from 7 days ago
const snapshots = await db.query.teamStandingsSnapshots.findMany({
where: and(
eq(schema.teamStandingsSnapshots.seasonId, seasonId),
eq(schema.teamStandingsSnapshots.snapshotDate, `${sevenDaysAgo.getFullYear()}-${String(sevenDaysAgo.getMonth() + 1).padStart(2, "0")}-${String(sevenDaysAgo.getDate()).padStart(2, "0")}`)
),
});
// Create a map of team -> old rank and old points
const oldRanks = new Map<string, number>();
const oldPoints = new Map<string, number>();
for (const snapshot of snapshots) {
oldRanks.set(snapshot.teamId, snapshot.rank);
const snapshotPoints = snapshot.actualPoints
? parseFloat(snapshot.actualPoints)
: parseFloat(snapshot.totalPoints);
oldPoints.set(snapshot.teamId, snapshotPoints);
}
// Add 7-day changes to current standings
return current.map((standing) => {
const currentPoints = standing.actualPoints ?? standing.totalPoints;
const oldPoint = oldPoints.get(standing.teamId);
return {
...standing,
sevenDayRankChange: oldRanks.has(standing.teamId)
? (oldRanks.get(standing.teamId) ?? 0) - standing.currentRank
: 0,
sevenDayOldRank: oldRanks.get(standing.teamId) || null,
sevenDayPointChange: oldPoint !== undefined ? currentPoints - oldPoint : 0,
};
});
}
/**
* Create a daily standings snapshot
* Should be called by a scheduled job once per day
*/
export async function createDailySnapshot(
seasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<void> {
const db = providedDb || database();
const now = new Date();
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
const standings = await db.query.teamStandings.findMany({
where: eq(schema.teamStandings.seasonId, seasonId),
});
await db.transaction(async (tx) => {
for (const standing of standings) {
const snapshotData = {
totalPoints: standing.totalPoints,
rank: standing.currentRank,
firstPlaceCount: standing.firstPlaceCount,
secondPlaceCount: standing.secondPlaceCount,
thirdPlaceCount: standing.thirdPlaceCount,
fourthPlaceCount: standing.fourthPlaceCount,
fifthPlaceCount: standing.fifthPlaceCount,
sixthPlaceCount: standing.sixthPlaceCount,
seventhPlaceCount: standing.seventhPlaceCount,
eighthPlaceCount: standing.eighthPlaceCount,
participantsRemaining: standing.participantsRemaining,
actualPoints: standing.actualPoints,
projectedPoints: standing.projectedPoints,
participantsFinished: standing.participantsFinished,
};
await tx
.insert(schema.teamStandingsSnapshots)
.values({ teamId: standing.teamId, seasonId, snapshotDate: today, ...snapshotData })
.onConflictDoUpdate({
target: [
schema.teamStandingsSnapshots.teamId,
schema.teamStandingsSnapshots.seasonId,
schema.teamStandingsSnapshots.snapshotDate,
],
set: snapshotData,
});
}
});
logger.log(`[Standings] Upserted daily snapshot for season ${seasonId}`);
}
/**
* Get point progression data for all teams in a season
* Returns historical snapshots organized by team for charting
*/
export async function getSeasonPointProgression(
seasonId: string,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
// Get all snapshots for this season
const snapshots = await db.query.teamStandingsSnapshots.findMany({
where: eq(schema.teamStandingsSnapshots.seasonId, seasonId),
orderBy: schema.teamStandingsSnapshots.snapshotDate,
with: {
team: true,
},
});
// Get unique teams
const teams = await db.query.teams.findMany({
where: eq(schema.teams.seasonId, seasonId),
});
// Organize data by date
const dateMap = new Map<string, { date: string; [teamName: string]: string | number }>();
for (const snapshot of snapshots) {
const date = snapshot.snapshotDate;
if (!dateMap.has(date)) {
dateMap.set(date, { date });
}
const dateData = dateMap.get(date);
if (!dateData) continue;
dateData[snapshot.team.name] = parseFloat(snapshot.totalPoints);
}
// Convert to array and sort by date
const chartData = Array.from(dateMap.values()).toSorted((a, b) =>
new Date(a.date).getTime() - new Date(b.date).getTime()
);
return {
chartData,
teams: teams.map(t => ({ id: t.id, name: t.name })),
};
}