brackt/app/models/draft-pick.ts
Claude f3d63922d0
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-07 07:29:37 +00:00

365 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 } 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>();
const finalizedQPSeasonIds = new Set<string>();
for (const pick of picks) {
const pattern = pick.participant.sportsSeason.scoringPattern;
const ssId = pick.participant.sportsSeasonId;
if (pattern === "playoff_bracket") bracketSeasonIds.add(ssId);
if (pattern === "qualifying_points") {
qpSeasonIds.add(ssId);
qpParticipantIds.add(pick.participant.id);
if (pick.participant.results[0]?.finalPosition !== null && pick.participant.results[0]?.finalPosition !== undefined) {
finalizedQPSeasonIds.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 qpSharedPlacementCounts = await getSharedPlacementCounts(
[...finalizedQPSeasonIds],
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(
qpSharedPlacementCounts,
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));
}