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:
Chris Parsons 2026-03-11 14:17:43 -07:00 committed by GitHub
parent c8b0da5cad
commit 2f14565d43
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 4487 additions and 4 deletions

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

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

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

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

View file

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

View file

@ -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" };
}

View file

@ -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 &amp; 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>

View file

@ -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
View 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 $$;

File diff suppressed because it is too large Load diff

View file

@ -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
}
]
}