brackt/app/models/playoff-match-odds.ts

142 lines
4.4 KiB
TypeScript
Raw Normal View History

Add playoff match game scheduling and odds management (#135) * Add playoff match games and odds storage Introduces two new tables for bracket matchup detail storage: - `playoff_match_games`: tracks individual game schedules within a series matchup (game number, scheduledAt, status, per-game scores, winner). Supports scheduled/complete/postponed status enum. - `playoff_match_odds`: stores moneyline odds per participant per matchup (single upsert record, no isLatest complexity). Includes: - Drizzle schema + relations with CASCADE deletes from playoff_matches - Migration 0040_fat_puma.sql - playoff-match-game.ts model with pure helpers: computeSeriesScore, isSeriesComplete, getSeriesLeader — plus full CRUD - playoff-match-odds.ts model with pure helpers: americanToImpliedProbability, impliedProbabilityToAmerican, normalizeOdds — plus upsert/read/delete - findPlayoffMatchesByEventId and findPlayoffMatchById updated to include games and odds in their query results - Bracket server route: add-game, update-game, delete-game, upsert-odds, delete-odds actions - Bracket admin UI: expandable per-match panel for game schedule management and moneyline odds entry - 41 new unit tests (18 game + 23 odds), all 810 tests passing https://claude.ai/code/session_01Twt3D1bsEK3eXUhMaz6ee7 * Code review fixes: type safety, abstraction, and React correctness - Derive PlayoffMatchGameStatus from schema enum instead of hardcoding the string union, eliminating the duplicate source of truth - updateGame now returns PlayoffMatchGame | undefined to reflect reality when no row matches the ID - Remove TOCTOU check-then-act in update-game action: call updateGame directly and check the return value instead of a pre-flight findGameById - Add status enum validation before the cast in update-game action - Move impliedProbability computation inside upsertMatchOdds so callers only provide moneylineOdds; the model owns the derivation - Remove unnecessary dynamic import of americanToImpliedProbability in the upsert-odds action (was already imported from the same module) - Fix React list reconciliation bug: replace bare <> fragment with <Fragment key={match.id}> so React can correctly track rows https://claude.ai/code/session_01Twt3D1bsEK3eXUhMaz6ee7 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-03-11 14:17:43 -07:00
import { eq, and } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
export type PlayoffMatchOdds = typeof schema.playoffMatchOdds.$inferSelect;
export type NewPlayoffMatchOdds = typeof schema.playoffMatchOdds.$inferInsert;
// ─── Pure helpers ────────────────────────────────────────────────────────────
/**
* Convert American moneyline odds to raw implied probability (includes vig).
*
* Positive odds (e.g. +150): probability = 100 / (odds + 100)
* Negative odds (e.g. -110): probability = |odds| / (|odds| + 100)
*
* Returns a value in [0, 1].
*/
export function americanToImpliedProbability(moneyline: number): number {
if (moneyline === 0) return 0;
if (moneyline > 0) {
return 100 / (moneyline + 100);
}
const abs = Math.abs(moneyline);
return abs / (abs + 100);
}
/**
* Convert an implied probability (01) to American moneyline odds.
*
* prob >= 0.5 negative (favourite): -(prob / (1 - prob)) * 100
* prob < 0.5 positive (underdog): ((1 - prob) / prob) * 100
*
* Returns rounded integer.
*/
export function impliedProbabilityToAmerican(probability: number): number {
if (probability <= 0 || probability >= 1) {
throw new RangeError("Probability must be strictly between 0 and 1");
}
if (probability >= 0.5) {
return Math.round(-(probability / (1 - probability)) * 100);
}
return Math.round(((1 - probability) / probability) * 100);
}
/**
* Remove the vig from a two-way market to get fair probabilities summing to 1.
*
* Returns [fairP1, fairP2].
*/
export function normalizeOdds(
moneyline1: number,
moneyline2: number
): [number, number] {
const raw1 = americanToImpliedProbability(moneyline1);
const raw2 = americanToImpliedProbability(moneyline2);
const total = raw1 + raw2;
return [raw1 / total, raw2 / total];
}
// ─── DB operations ───────────────────────────────────────────────────────────
export async function findOddsByMatchId(
playoffMatchId: string
): Promise<PlayoffMatchOdds[]> {
const db = database();
return await db.query.playoffMatchOdds.findMany({
where: eq(schema.playoffMatchOdds.playoffMatchId, playoffMatchId),
with: { participant: true },
});
}
export async function findOddsForParticipant(
playoffMatchId: string,
participantId: string
): Promise<PlayoffMatchOdds | undefined> {
const db = database();
return await db.query.playoffMatchOdds.findFirst({
where: and(
eq(schema.playoffMatchOdds.playoffMatchId, playoffMatchId),
eq(schema.playoffMatchOdds.participantId, participantId)
),
});
}
/**
* Insert or update odds for a participant in a matchup.
* One row per (playoffMatchId, participantId) overwritten on each call.
* Implied probability is computed automatically from moneylineOdds.
*/
export async function upsertMatchOdds(
playoffMatchId: string,
participantId: string,
data: {
moneylineOdds: number;
oddsSource?: string;
}
): Promise<PlayoffMatchOdds> {
const db = database();
const impliedProbability = americanToImpliedProbability(data.moneylineOdds).toFixed(4);
const payload = { ...data, impliedProbability };
const existing = await findOddsForParticipant(playoffMatchId, participantId);
if (existing) {
const [updated] = await db
.update(schema.playoffMatchOdds)
.set({ ...payload, recordedAt: new Date(), updatedAt: new Date() })
.where(eq(schema.playoffMatchOdds.id, existing.id))
.returning();
return updated;
}
const [created] = await db
.insert(schema.playoffMatchOdds)
.values({ playoffMatchId, participantId, ...payload })
.returning();
return created;
}
export async function deleteOddsByMatchId(playoffMatchId: string): Promise<void> {
const db = database();
await db
.delete(schema.playoffMatchOdds)
.where(eq(schema.playoffMatchOdds.playoffMatchId, playoffMatchId));
}
export async function deleteOddsForParticipant(
playoffMatchId: string,
participantId: string
): Promise<void> {
const db = database();
await db
.delete(schema.playoffMatchOdds)
.where(
and(
eq(schema.playoffMatchOdds.playoffMatchId, playoffMatchId),
eq(schema.playoffMatchOdds.participantId, participantId)
)
);
}