brackt/app/models/playoff-match-odds.ts
Chris Parsons 2f14565d43
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

141 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
)
);
}