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
455 lines
15 KiB
TypeScript
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 })),
|
|
};
|
|
}
|