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>
This commit is contained in:
parent
c8b0da5cad
commit
2f14565d43
11 changed files with 4487 additions and 4 deletions
130
app/models/__tests__/playoff-match-game.test.ts
Normal file
130
app/models/__tests__/playoff-match-game.test.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
computeSeriesScore,
|
||||
isSeriesComplete,
|
||||
getSeriesLeader,
|
||||
} from "../playoff-match-game";
|
||||
|
||||
const P1 = "participant-1";
|
||||
const P2 = "participant-2";
|
||||
|
||||
const game = (winnerId: string | null, status: "complete" | "scheduled" | "postponed" = "complete") => ({
|
||||
winnerId,
|
||||
status,
|
||||
});
|
||||
|
||||
describe("computeSeriesScore", () => {
|
||||
it("returns zero counts when no games are present", () => {
|
||||
const result = computeSeriesScore([], P1, P2);
|
||||
expect(result).toEqual({ participant1Wins: 0, participant2Wins: 0, gamesPlayed: 0 });
|
||||
});
|
||||
|
||||
it("counts only complete games", () => {
|
||||
const games = [
|
||||
game(P1, "complete"),
|
||||
game(null, "scheduled"),
|
||||
game(P2, "postponed"),
|
||||
];
|
||||
const result = computeSeriesScore(games, P1, P2);
|
||||
expect(result.participant1Wins).toBe(1);
|
||||
expect(result.participant2Wins).toBe(0);
|
||||
expect(result.gamesPlayed).toBe(1);
|
||||
});
|
||||
|
||||
it("correctly tallies a 4-3 series", () => {
|
||||
const games = [
|
||||
game(P1), game(P2), game(P1), game(P2),
|
||||
game(P2), game(P1), game(P1),
|
||||
];
|
||||
const result = computeSeriesScore(games, P1, P2);
|
||||
expect(result.participant1Wins).toBe(4);
|
||||
expect(result.participant2Wins).toBe(3);
|
||||
expect(result.gamesPlayed).toBe(7);
|
||||
});
|
||||
|
||||
it("ignores games won by unknown participant", () => {
|
||||
const games = [game("unknown-team"), game(P1)];
|
||||
const result = computeSeriesScore(games, P1, P2);
|
||||
expect(result.participant1Wins).toBe(1);
|
||||
expect(result.participant2Wins).toBe(0);
|
||||
expect(result.gamesPlayed).toBe(2);
|
||||
});
|
||||
|
||||
it("handles a sweep correctly", () => {
|
||||
const games = [game(P1), game(P1), game(P1), game(P1)];
|
||||
const result = computeSeriesScore(games, P1, P2);
|
||||
expect(result.participant1Wins).toBe(4);
|
||||
expect(result.participant2Wins).toBe(0);
|
||||
expect(result.gamesPlayed).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSeriesComplete", () => {
|
||||
it("returns false when no games played", () => {
|
||||
expect(isSeriesComplete([], P1, P2, 4)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when neither team has reached required wins", () => {
|
||||
const games = [game(P1), game(P1), game(P2)];
|
||||
expect(isSeriesComplete(games, P1, P2, 4)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when participant1 reaches required wins (best of 7)", () => {
|
||||
const games = [game(P1), game(P1), game(P1), game(P1)];
|
||||
expect(isSeriesComplete(games, P1, P2, 4)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when participant2 reaches required wins", () => {
|
||||
const games = [game(P2), game(P2), game(P2)];
|
||||
expect(isSeriesComplete(games, P1, P2, 3)).toBe(true);
|
||||
});
|
||||
|
||||
it("works for best of 1", () => {
|
||||
expect(isSeriesComplete([game(P1)], P1, P2, 1)).toBe(true);
|
||||
expect(isSeriesComplete([], P1, P2, 1)).toBe(false);
|
||||
});
|
||||
|
||||
it("works for best of 3", () => {
|
||||
const twoToZero = [game(P1), game(P1)];
|
||||
expect(isSeriesComplete(twoToZero, P1, P2, 2)).toBe(true);
|
||||
|
||||
const oneToOne = [game(P1), game(P2)];
|
||||
expect(isSeriesComplete(oneToOne, P1, P2, 2)).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores scheduled/postponed games", () => {
|
||||
const games = [game(P1, "complete"), game(P1, "scheduled")];
|
||||
expect(isSeriesComplete(games, P1, P2, 2)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSeriesLeader", () => {
|
||||
it("returns null when no games played", () => {
|
||||
expect(getSeriesLeader([], P1, P2)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when tied", () => {
|
||||
const games = [game(P1), game(P2)];
|
||||
expect(getSeriesLeader(games, P1, P2)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns participant1 when they lead", () => {
|
||||
const games = [game(P1), game(P1), game(P2)];
|
||||
expect(getSeriesLeader(games, P1, P2)).toBe(P1);
|
||||
});
|
||||
|
||||
it("returns participant2 when they lead", () => {
|
||||
const games = [game(P2), game(P2), game(P1)];
|
||||
expect(getSeriesLeader(games, P1, P2)).toBe(P2);
|
||||
});
|
||||
|
||||
it("returns correct leader after a sweep", () => {
|
||||
const games = [game(P2), game(P2), game(P2), game(P2)];
|
||||
expect(getSeriesLeader(games, P1, P2)).toBe(P2);
|
||||
});
|
||||
|
||||
it("only counts complete games", () => {
|
||||
const games = [game(P2, "complete"), game(P1, "scheduled"), game(P1, "postponed")];
|
||||
expect(getSeriesLeader(games, P1, P2)).toBe(P2);
|
||||
});
|
||||
});
|
||||
136
app/models/__tests__/playoff-match-odds.test.ts
Normal file
136
app/models/__tests__/playoff-match-odds.test.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
americanToImpliedProbability,
|
||||
impliedProbabilityToAmerican,
|
||||
normalizeOdds,
|
||||
} from "../playoff-match-odds";
|
||||
|
||||
describe("americanToImpliedProbability", () => {
|
||||
it("converts even odds (+100) to 0.5", () => {
|
||||
expect(americanToImpliedProbability(100)).toBeCloseTo(0.5);
|
||||
});
|
||||
|
||||
it("converts favourite negative odds (-110) correctly", () => {
|
||||
// 110 / (110 + 100) = 110/210 ≈ 0.5238
|
||||
expect(americanToImpliedProbability(-110)).toBeCloseTo(110 / 210);
|
||||
});
|
||||
|
||||
it("converts heavy favourite (-400) correctly", () => {
|
||||
// 400 / 500 = 0.8
|
||||
expect(americanToImpliedProbability(-400)).toBeCloseTo(0.8);
|
||||
});
|
||||
|
||||
it("converts underdog (+200) correctly", () => {
|
||||
// 100 / (200 + 100) = 100/300 ≈ 0.3333
|
||||
expect(americanToImpliedProbability(200)).toBeCloseTo(1 / 3);
|
||||
});
|
||||
|
||||
it("converts heavy underdog (+500) correctly", () => {
|
||||
// 100 / 600 ≈ 0.1667
|
||||
expect(americanToImpliedProbability(500)).toBeCloseTo(1 / 6);
|
||||
});
|
||||
|
||||
it("returns 0 for zero odds", () => {
|
||||
expect(americanToImpliedProbability(0)).toBe(0);
|
||||
});
|
||||
|
||||
it("implied probabilities for two-way market sum > 1 (vig included)", () => {
|
||||
// Standard -110/-110 line should sum to > 1 due to vig
|
||||
const p1 = americanToImpliedProbability(-110);
|
||||
const p2 = americanToImpliedProbability(-110);
|
||||
expect(p1 + p2).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("impliedProbabilityToAmerican", () => {
|
||||
it("converts 0.5 to even money (+100)", () => {
|
||||
// At exactly 0.5, should be -100 (technically even both ways)
|
||||
const result = impliedProbabilityToAmerican(0.5);
|
||||
expect(result).toBe(-100);
|
||||
});
|
||||
|
||||
it("converts a favourite probability to negative odds", () => {
|
||||
// 0.8 probability → -(0.8/0.2)*100 = -400
|
||||
expect(impliedProbabilityToAmerican(0.8)).toBe(-400);
|
||||
});
|
||||
|
||||
it("converts an underdog probability to positive odds", () => {
|
||||
// 1/3 ≈ 0.3333 → ((1 - 1/3) / (1/3)) * 100 = 200
|
||||
expect(impliedProbabilityToAmerican(1 / 3)).toBeCloseTo(200, 0);
|
||||
});
|
||||
|
||||
it("converts a heavy underdog probability to large positive number", () => {
|
||||
// 1/6 ≈ 0.1667 → (5/6 / 1/6) * 100 = 500
|
||||
expect(impliedProbabilityToAmerican(1 / 6)).toBeCloseTo(500, 0);
|
||||
});
|
||||
|
||||
it("throws for probability of 0", () => {
|
||||
expect(() => impliedProbabilityToAmerican(0)).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it("throws for probability of 1", () => {
|
||||
expect(() => impliedProbabilityToAmerican(1)).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it("throws for probability > 1", () => {
|
||||
expect(() => impliedProbabilityToAmerican(1.5)).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it("throws for negative probability", () => {
|
||||
expect(() => impliedProbabilityToAmerican(-0.1)).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it("roundtrips correctly for typical favourite", () => {
|
||||
// -200 → prob → back to -200
|
||||
const prob = americanToImpliedProbability(-200);
|
||||
const back = impliedProbabilityToAmerican(prob);
|
||||
expect(back).toBe(-200);
|
||||
});
|
||||
|
||||
it("roundtrips correctly for typical underdog", () => {
|
||||
// +150 → prob → back to +150
|
||||
const prob = americanToImpliedProbability(150);
|
||||
const back = impliedProbabilityToAmerican(prob);
|
||||
expect(back).toBe(150);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeOdds", () => {
|
||||
it("normalized probabilities sum to 1", () => {
|
||||
const [p1, p2] = normalizeOdds(-110, -110);
|
||||
expect(p1 + p2).toBeCloseTo(1);
|
||||
});
|
||||
|
||||
it("equal moneylines give 50/50 after normalization", () => {
|
||||
const [p1, p2] = normalizeOdds(-110, -110);
|
||||
expect(p1).toBeCloseTo(0.5);
|
||||
expect(p2).toBeCloseTo(0.5);
|
||||
});
|
||||
|
||||
it("favourite has higher normalized probability", () => {
|
||||
const [favProb, underdogProb] = normalizeOdds(-200, +170);
|
||||
expect(favProb).toBeGreaterThan(underdogProb);
|
||||
});
|
||||
|
||||
it("normalized probabilities are between 0 and 1", () => {
|
||||
const [p1, p2] = normalizeOdds(-300, +250);
|
||||
expect(p1).toBeGreaterThan(0);
|
||||
expect(p1).toBeLessThan(1);
|
||||
expect(p2).toBeGreaterThan(0);
|
||||
expect(p2).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it("removes vig - heavy favourite still less than 1 after normalization", () => {
|
||||
// Raw implied prob for -400 = 0.8. Normalized should be < 1 since opponent exists.
|
||||
const [p1] = normalizeOdds(-400, +300);
|
||||
expect(p1).toBeLessThan(1);
|
||||
expect(p1).toBeGreaterThan(0.5);
|
||||
});
|
||||
|
||||
it("ordering is consistent with input", () => {
|
||||
const [p1First, p2First] = normalizeOdds(-200, +170);
|
||||
const [p2Second, p1Second] = normalizeOdds(+170, -200);
|
||||
expect(p1First).toBeCloseTo(p1Second);
|
||||
expect(p2First).toBeCloseTo(p2Second);
|
||||
});
|
||||
});
|
||||
130
app/models/playoff-match-game.ts
Normal file
130
app/models/playoff-match-game.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
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));
|
||||
}
|
||||
141
app/models/playoff-match-odds.ts
Normal file
141
app/models/playoff-match-odds.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
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 (0–1) 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)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -40,6 +40,8 @@ export async function findPlayoffMatchById(
|
|||
participant2: true,
|
||||
winner: true,
|
||||
loser: true,
|
||||
games: { orderBy: (g, { asc }) => [asc(g.gameNumber)] },
|
||||
odds: { with: { participant: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -56,6 +58,8 @@ export async function findPlayoffMatchesByEventId(
|
|||
participant2: true,
|
||||
winner: true,
|
||||
loser: true,
|
||||
games: { orderBy: (g, { asc }) => [asc(g.gameNumber)] },
|
||||
odds: { with: { participant: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,16 @@ import {
|
|||
findPlayoffMatchById,
|
||||
assignParticipantsToKnockout,
|
||||
} from "~/models/playoff-match";
|
||||
import {
|
||||
createGame,
|
||||
updateGame,
|
||||
deleteGame,
|
||||
type PlayoffMatchGameStatus,
|
||||
} from "~/models/playoff-match-game";
|
||||
import {
|
||||
upsertMatchOdds,
|
||||
deleteOddsForParticipant,
|
||||
} from "~/models/playoff-match-odds";
|
||||
import { processPlayoffEvent } from "~/models/scoring-calculator";
|
||||
import { getBracketTemplate } from "~/lib/bracket-templates";
|
||||
import {
|
||||
|
|
@ -717,5 +727,113 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Game scheduling ──────────────────────────────────────────────────────
|
||||
|
||||
if (intent === "add-game") {
|
||||
const matchId = formData.get("matchId");
|
||||
const gameNumber = formData.get("gameNumber");
|
||||
const scheduledAt = formData.get("scheduledAt");
|
||||
const notes = formData.get("notes");
|
||||
|
||||
if (typeof matchId !== "string" || !matchId) return { error: "matchId is required" };
|
||||
if (typeof gameNumber !== "string" || !gameNumber) return { error: "gameNumber is required" };
|
||||
|
||||
try {
|
||||
await createGame({
|
||||
playoffMatchId: matchId,
|
||||
gameNumber: parseInt(gameNumber, 10),
|
||||
scheduledAt: scheduledAt ? new Date(scheduledAt as string) : null,
|
||||
notes: notes ? (notes as string) : null,
|
||||
});
|
||||
return { success: "Game added" };
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : "Failed to add game" };
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === "update-game") {
|
||||
const gameId = formData.get("gameId");
|
||||
const scheduledAt = formData.get("scheduledAt");
|
||||
const status = formData.get("status");
|
||||
const participant1Score = formData.get("participant1Score");
|
||||
const participant2Score = formData.get("participant2Score");
|
||||
const winnerId = formData.get("winnerId");
|
||||
const notes = formData.get("notes");
|
||||
|
||||
if (typeof gameId !== "string" || !gameId) return { error: "gameId is required" };
|
||||
|
||||
const validStatuses: PlayoffMatchGameStatus[] = ["scheduled", "complete", "postponed"];
|
||||
if (status && !validStatuses.includes(status as PlayoffMatchGameStatus)) {
|
||||
return { error: `Invalid status: must be one of ${validStatuses.join(", ")}` };
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await updateGame(gameId, {
|
||||
...(scheduledAt !== null && { scheduledAt: scheduledAt ? new Date(scheduledAt as string) : null }),
|
||||
...(status && { status: status as PlayoffMatchGameStatus }),
|
||||
...(participant1Score !== null && { participant1Score: (participant1Score as string) || null }),
|
||||
...(participant2Score !== null && { participant2Score: (participant2Score as string) || null }),
|
||||
...(winnerId !== null && { winnerId: (winnerId as string) || null }),
|
||||
...(notes !== null && { notes: (notes as string) || null }),
|
||||
});
|
||||
if (!updated) return { error: "Game not found" };
|
||||
return { success: "Game updated" };
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : "Failed to update game" };
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === "delete-game") {
|
||||
const gameId = formData.get("gameId");
|
||||
if (typeof gameId !== "string" || !gameId) return { error: "gameId is required" };
|
||||
|
||||
try {
|
||||
await deleteGame(gameId);
|
||||
return { success: "Game deleted" };
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : "Failed to delete game" };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Odds management ──────────────────────────────────────────────────────
|
||||
|
||||
if (intent === "upsert-odds") {
|
||||
const matchId = formData.get("matchId");
|
||||
const participantId = formData.get("participantId");
|
||||
const moneylineOdds = formData.get("moneylineOdds");
|
||||
const oddsSource = formData.get("oddsSource");
|
||||
|
||||
if (typeof matchId !== "string" || !matchId) return { error: "matchId is required" };
|
||||
if (typeof participantId !== "string" || !participantId) return { error: "participantId is required" };
|
||||
if (typeof moneylineOdds !== "string" || !moneylineOdds) return { error: "moneylineOdds is required" };
|
||||
|
||||
const moneylineInt = parseInt(moneylineOdds, 10);
|
||||
if (isNaN(moneylineInt)) return { error: "moneylineOdds must be an integer" };
|
||||
|
||||
try {
|
||||
await upsertMatchOdds(matchId, participantId, {
|
||||
moneylineOdds: moneylineInt,
|
||||
oddsSource: oddsSource ? (oddsSource as string) : undefined,
|
||||
});
|
||||
return { success: "Odds updated" };
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : "Failed to update odds" };
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === "delete-odds") {
|
||||
const matchId = formData.get("matchId");
|
||||
const participantId = formData.get("participantId");
|
||||
if (typeof matchId !== "string" || !matchId) return { error: "matchId is required" };
|
||||
if (typeof participantId !== "string" || !participantId) return { error: "participantId is required" };
|
||||
|
||||
try {
|
||||
await deleteOddsForParticipant(matchId, participantId);
|
||||
return { success: "Odds deleted" };
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : "Failed to delete odds" };
|
||||
}
|
||||
}
|
||||
|
||||
return { error: "Invalid action" };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Form, Link } from "react-router";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Fragment, useState, useEffect, useMemo } from "react";
|
||||
import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket";
|
||||
|
||||
import { loader, action } from "./admin.sports-seasons.$id.events.$eventId.bracket.server";
|
||||
|
|
@ -20,7 +20,7 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { ArrowLeft, Plus, Trophy, X, Check } from "lucide-react";
|
||||
import { ArrowLeft, Plus, Trophy, X, Check, ChevronDown, ChevronRight, Calendar, TrendingUp, Trash2 } from "lucide-react";
|
||||
import { getAllBracketTemplates, getBracketTemplate } from "~/lib/bracket-templates";
|
||||
import {
|
||||
Table,
|
||||
|
|
@ -48,6 +48,16 @@ export default function EventBracket({
|
|||
const [selectedParticipants, setSelectedParticipants] = useState<Record<number, string>>({});
|
||||
const [selectedWinners, setSelectedWinners] = useState<Record<string, string>>({});
|
||||
const [knockoutAssignments, setKnockoutAssignments] = useState<Record<string, string>>({});
|
||||
const [expandedMatches, setExpandedMatches] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleMatchExpanded = (matchId: string) => {
|
||||
setExpandedMatches(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(matchId)) next.delete(matchId);
|
||||
else next.add(matchId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const templates = getAllBracketTemplates();
|
||||
const template = templates.find((t) => t.id === selectedTemplate);
|
||||
|
||||
|
|
@ -581,6 +591,7 @@ export default function EventBracket({
|
|||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8"></TableHead>
|
||||
<TableHead className="w-16">Match</TableHead>
|
||||
<TableHead>Participant 1</TableHead>
|
||||
<TableHead>Score</TableHead>
|
||||
|
|
@ -593,7 +604,20 @@ export default function EventBracket({
|
|||
</TableHeader>
|
||||
<TableBody>
|
||||
{matchesByRound[round].map((match) => (
|
||||
<TableRow key={match.id}>
|
||||
<Fragment key={match.id}>
|
||||
<TableRow>
|
||||
<TableCell className="w-8 p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleMatchExpanded(match.id)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label="Toggle games and odds"
|
||||
>
|
||||
{expandedMatches.has(match.id)
|
||||
? <ChevronDown className="h-4 w-4" />
|
||||
: <ChevronRight className="h-4 w-4" />}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell className="font-semibold">
|
||||
{match.matchNumber}
|
||||
</TableCell>
|
||||
|
|
@ -647,6 +671,162 @@ export default function EventBracket({
|
|||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* Expanded: Games & Odds panel */}
|
||||
{expandedMatches.has(match.id) && (
|
||||
<TableRow className="bg-muted/30">
|
||||
<TableCell colSpan={9} className="p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
|
||||
{/* Games section */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold">Game Schedule</span>
|
||||
</div>
|
||||
|
||||
{/* Existing games */}
|
||||
{(match as any).games?.length > 0 ? (
|
||||
<div className="space-y-2 mb-3">
|
||||
{(match as any).games.map((game: any) => (
|
||||
<div key={game.id} className="flex items-center gap-2 text-sm bg-background rounded p-2 border">
|
||||
<span className="font-medium w-12 shrink-0">Game {game.gameNumber}</span>
|
||||
<span className="text-muted-foreground flex-1">
|
||||
{game.scheduledAt
|
||||
? new Date(game.scheduledAt).toLocaleString()
|
||||
: "No date set"}
|
||||
</span>
|
||||
<Badge variant={
|
||||
game.status === "complete" ? "default"
|
||||
: game.status === "postponed" ? "destructive"
|
||||
: "secondary"
|
||||
} className="text-xs">
|
||||
{game.status}
|
||||
</Badge>
|
||||
{game.status === "complete" && game.winner && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
W: {game.winner.name}
|
||||
</span>
|
||||
)}
|
||||
<Form method="post" className="shrink-0">
|
||||
<input type="hidden" name="intent" value="delete-game" />
|
||||
<input type="hidden" name="gameId" value={game.id} />
|
||||
<button type="submit" className="text-muted-foreground hover:text-destructive">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</Form>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground mb-3">No games scheduled yet.</p>
|
||||
)}
|
||||
|
||||
{/* Add game form */}
|
||||
<Form method="post" className="flex gap-2 items-end">
|
||||
<input type="hidden" name="intent" value="add-game" />
|
||||
<input type="hidden" name="matchId" value={match.id} />
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs">Game #</Label>
|
||||
<Input
|
||||
name="gameNumber"
|
||||
type="number"
|
||||
min={1}
|
||||
max={9}
|
||||
defaultValue={((match as any).games?.length ?? 0) + 1}
|
||||
className="h-8 w-20"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs">Date & Time</Label>
|
||||
<Input
|
||||
name="scheduledAt"
|
||||
type="datetime-local"
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" size="sm" variant="outline" className="h-8">
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{/* Odds section */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold">Moneyline Odds</span>
|
||||
</div>
|
||||
|
||||
{(match as any).odds?.length > 0 ? (
|
||||
<div className="space-y-2 mb-3">
|
||||
{(match as any).odds.map((odd: any) => (
|
||||
<div key={odd.id} className="flex items-center gap-2 text-sm bg-background rounded p-2 border">
|
||||
<span className="flex-1 font-medium">{odd.participant?.name}</span>
|
||||
<span className={`font-mono ${odd.moneylineOdds > 0 ? "text-emerald-500" : "text-muted-foreground"}`}>
|
||||
{odd.moneylineOdds > 0 ? "+" : ""}{odd.moneylineOdds}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({(parseFloat(odd.impliedProbability) * 100).toFixed(1)}%)
|
||||
</span>
|
||||
{odd.oddsSource && (
|
||||
<span className="text-xs text-muted-foreground">{odd.oddsSource}</span>
|
||||
)}
|
||||
<Form method="post" className="shrink-0">
|
||||
<input type="hidden" name="intent" value="delete-odds" />
|
||||
<input type="hidden" name="matchId" value={match.id} />
|
||||
<input type="hidden" name="participantId" value={odd.participantId} />
|
||||
<button type="submit" className="text-muted-foreground hover:text-destructive">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</Form>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground mb-3">No odds set.</p>
|
||||
)}
|
||||
|
||||
{/* Set odds forms for each participant */}
|
||||
{[
|
||||
{ id: match.participant1Id, name: match.participant1?.name },
|
||||
{ id: match.participant2Id, name: match.participant2?.name },
|
||||
].filter(p => p.id).map(participant => (
|
||||
<Form key={participant.id} method="post" className="flex gap-2 items-end mb-2">
|
||||
<input type="hidden" name="intent" value="upsert-odds" />
|
||||
<input type="hidden" name="matchId" value={match.id} />
|
||||
<input type="hidden" name="participantId" value={participant.id!} />
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs">{participant.name ?? "TBD"}</Label>
|
||||
<Input
|
||||
name="moneylineOdds"
|
||||
type="number"
|
||||
placeholder="-110"
|
||||
className="h-8"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs">Source</Label>
|
||||
<Input
|
||||
name="oddsSource"
|
||||
placeholder="e.g. DraftKings"
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" size="sm" variant="outline" className="h-8">
|
||||
Set
|
||||
</Button>
|
||||
</Form>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
|
|
|||
|
|
@ -84,6 +84,12 @@ export const simulatorTypeEnum = pgEnum("simulator_type", [
|
|||
"ucl_bracket",
|
||||
]);
|
||||
|
||||
export const playoffMatchGameStatusEnum = pgEnum("playoff_match_game_status", [
|
||||
"scheduled",
|
||||
"complete",
|
||||
"postponed",
|
||||
]);
|
||||
|
||||
export const leagues = pgTable("leagues", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
|
|
@ -395,6 +401,40 @@ export const playoffMatches = pgTable("playoff_matches", {
|
|||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// Individual games within a playoff matchup (e.g., NBA 7-game series games)
|
||||
export const playoffMatchGames = pgTable("playoff_match_games", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
playoffMatchId: uuid("playoff_match_id")
|
||||
.notNull()
|
||||
.references(() => playoffMatches.id, { onDelete: "cascade" }),
|
||||
gameNumber: integer("game_number").notNull(),
|
||||
scheduledAt: timestamp("scheduled_at"),
|
||||
status: playoffMatchGameStatusEnum("status").notNull().default("scheduled"),
|
||||
participant1Score: decimal("participant1_score", { precision: 10, scale: 2 }),
|
||||
participant2Score: decimal("participant2_score", { precision: 10, scale: 2 }),
|
||||
winnerId: uuid("winner_id").references(() => participants.id, { onDelete: "set null" }),
|
||||
notes: text("notes"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// Moneyline odds per participant per matchup (single record per participant, overwritten on update)
|
||||
export const playoffMatchOdds = pgTable("playoff_match_odds", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
playoffMatchId: uuid("playoff_match_id")
|
||||
.notNull()
|
||||
.references(() => playoffMatches.id, { onDelete: "cascade" }),
|
||||
participantId: uuid("participant_id")
|
||||
.notNull()
|
||||
.references(() => participants.id, { onDelete: "cascade" }),
|
||||
moneylineOdds: integer("moneyline_odds"), // American format e.g. -110, +150
|
||||
impliedProbability: decimal("implied_probability", { precision: 6, scale: 4 }),
|
||||
oddsSource: varchar("odds_source", { length: 100 }),
|
||||
recordedAt: timestamp("recorded_at").defaultNow().notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// Aggregated participant qualifying points (for qualifying_points sports)
|
||||
export const participantQualifyingTotals = pgTable("participant_qualifying_totals", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
|
|
@ -769,7 +809,7 @@ export const eventResultsRelations = relations(eventResults, ({ one }) => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
export const playoffMatchesRelations = relations(playoffMatches, ({ one }) => ({
|
||||
export const playoffMatchesRelations = relations(playoffMatches, ({ one, many }) => ({
|
||||
scoringEvent: one(scoringEvents, {
|
||||
fields: [playoffMatches.scoringEventId],
|
||||
references: [scoringEvents.id],
|
||||
|
|
@ -790,6 +830,30 @@ export const playoffMatchesRelations = relations(playoffMatches, ({ one }) => ({
|
|||
fields: [playoffMatches.loserId],
|
||||
references: [participants.id],
|
||||
}),
|
||||
games: many(playoffMatchGames),
|
||||
odds: many(playoffMatchOdds),
|
||||
}));
|
||||
|
||||
export const playoffMatchGamesRelations = relations(playoffMatchGames, ({ one }) => ({
|
||||
match: one(playoffMatches, {
|
||||
fields: [playoffMatchGames.playoffMatchId],
|
||||
references: [playoffMatches.id],
|
||||
}),
|
||||
winner: one(participants, {
|
||||
fields: [playoffMatchGames.winnerId],
|
||||
references: [participants.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const playoffMatchOddsRelations = relations(playoffMatchOdds, ({ one }) => ({
|
||||
match: one(playoffMatches, {
|
||||
fields: [playoffMatchOdds.playoffMatchId],
|
||||
references: [playoffMatches.id],
|
||||
}),
|
||||
participant: one(participants, {
|
||||
fields: [playoffMatchOdds.participantId],
|
||||
references: [participants.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const participantQualifyingTotalsRelations = relations(participantQualifyingTotals, ({ one }) => ({
|
||||
|
|
|
|||
51
drizzle/0040_fat_puma.sql
Normal file
51
drizzle/0040_fat_puma.sql
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
CREATE TYPE "public"."playoff_match_game_status" AS ENUM('scheduled', 'complete', 'postponed');--> statement-breakpoint
|
||||
ALTER TYPE "public"."simulator_type" ADD VALUE 'ucl_bracket';--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "playoff_match_games" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"playoff_match_id" uuid NOT NULL,
|
||||
"game_number" integer NOT NULL,
|
||||
"scheduled_at" timestamp,
|
||||
"status" "playoff_match_game_status" DEFAULT 'scheduled' NOT NULL,
|
||||
"participant1_score" numeric(10, 2),
|
||||
"participant2_score" numeric(10, 2),
|
||||
"winner_id" uuid,
|
||||
"notes" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "playoff_match_odds" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"playoff_match_id" uuid NOT NULL,
|
||||
"participant_id" uuid NOT NULL,
|
||||
"moneyline_odds" integer,
|
||||
"implied_probability" numeric(6, 4),
|
||||
"odds_source" varchar(100),
|
||||
"recorded_at" timestamp DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "playoff_match_games" ADD CONSTRAINT "playoff_match_games_playoff_match_id_playoff_matches_id_fk" FOREIGN KEY ("playoff_match_id") REFERENCES "public"."playoff_matches"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "playoff_match_games" ADD CONSTRAINT "playoff_match_games_winner_id_participants_id_fk" FOREIGN KEY ("winner_id") REFERENCES "public"."participants"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "playoff_match_odds" ADD CONSTRAINT "playoff_match_odds_playoff_match_id_playoff_matches_id_fk" FOREIGN KEY ("playoff_match_id") REFERENCES "public"."playoff_matches"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "playoff_match_odds" ADD CONSTRAINT "playoff_match_odds_participant_id_participants_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."participants"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
3522
drizzle/meta/0040_snapshot.json
Normal file
3522
drizzle/meta/0040_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -281,6 +281,13 @@
|
|||
"when": 1773200000000,
|
||||
"tag": "0039_add_ucl_bracket_simulator_type",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 40,
|
||||
"version": "7",
|
||||
"when": 1773262080928,
|
||||
"tag": "0040_fat_puma",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue