brackt/app/models/playoff-match-game.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

130 lines
4.2 KiB
TypeScript

import { eq, asc } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
export type PlayoffMatchGame = typeof schema.playoffMatchGames.$inferSelect;
export type NewPlayoffMatchGame = typeof schema.playoffMatchGames.$inferInsert;
export type PlayoffMatchGameStatus = (typeof schema.playoffMatchGameStatusEnum.enumValues)[number];
// ─── Pure helpers ────────────────────────────────────────────────────────────
export interface SeriesScore {
participant1Wins: number;
participant2Wins: number;
gamesPlayed: number;
}
/**
* Tally wins from a list of completed games.
* participant1Id / participant2Id on the *match* are the two series participants;
* winnerId on each game must equal one of them.
*/
export function computeSeriesScore(
games: Pick<PlayoffMatchGame, "winnerId" | "status">[],
participant1Id: string,
participant2Id: string
): SeriesScore {
let participant1Wins = 0;
let participant2Wins = 0;
let gamesPlayed = 0;
for (const game of games) {
if (game.status !== "complete") continue;
gamesPlayed++;
if (game.winnerId === participant1Id) participant1Wins++;
else if (game.winnerId === participant2Id) participant2Wins++;
}
return { participant1Wins, participant2Wins, gamesPlayed };
}
/**
* Returns true when one participant has enough wins to clinch a best-of-N series.
* e.g. best-of-7 requires 4 wins.
*/
export function isSeriesComplete(
games: Pick<PlayoffMatchGame, "winnerId" | "status">[],
participant1Id: string,
participant2Id: string,
requiredWins: number
): boolean {
const { participant1Wins, participant2Wins } = computeSeriesScore(
games,
participant1Id,
participant2Id
);
return participant1Wins >= requiredWins || participant2Wins >= requiredWins;
}
/**
* Derive the series leader's participantId, or null if tied / no completed games.
*/
export function getSeriesLeader(
games: Pick<PlayoffMatchGame, "winnerId" | "status">[],
participant1Id: string,
participant2Id: string
): string | null {
const { participant1Wins, participant2Wins } = computeSeriesScore(
games,
participant1Id,
participant2Id
);
if (participant1Wins > participant2Wins) return participant1Id;
if (participant2Wins > participant1Wins) return participant2Id;
return null;
}
// ─── DB operations ───────────────────────────────────────────────────────────
export async function createGame(data: NewPlayoffMatchGame): Promise<PlayoffMatchGame> {
const db = database();
const [game] = await db
.insert(schema.playoffMatchGames)
.values(data)
.returning();
return game;
}
export async function findGamesByMatchId(
playoffMatchId: string
): Promise<PlayoffMatchGame[]> {
const db = database();
return await db.query.playoffMatchGames.findMany({
where: eq(schema.playoffMatchGames.playoffMatchId, playoffMatchId),
orderBy: [asc(schema.playoffMatchGames.gameNumber)],
with: { winner: true },
});
}
export async function findGameById(id: string): Promise<PlayoffMatchGame | undefined> {
const db = database();
return await db.query.playoffMatchGames.findFirst({
where: eq(schema.playoffMatchGames.id, id),
with: { winner: true },
});
}
export async function updateGame(
id: string,
data: Partial<NewPlayoffMatchGame>
): Promise<PlayoffMatchGame | undefined> {
const db = database();
const [game] = await db
.update(schema.playoffMatchGames)
.set({ ...data, updatedAt: new Date() })
.where(eq(schema.playoffMatchGames.id, id))
.returning();
return game;
}
export async function deleteGame(id: string): Promise<void> {
const db = database();
await db.delete(schema.playoffMatchGames).where(eq(schema.playoffMatchGames.id, id));
}
export async function deleteGamesByMatchId(playoffMatchId: string): Promise<void> {
const db = database();
await db
.delete(schema.playoffMatchGames)
.where(eq(schema.playoffMatchGames.playoffMatchId, playoffMatchId));
}