2026-03-15 21:52:47 -07:00
|
|
|
import { integer, pgTable, varchar, uuid, timestamp, pgEnum, boolean, text, date, decimal, uniqueIndex, jsonb } from "drizzle-orm/pg-core";
|
2025-10-12 21:16:00 -07:00
|
|
|
import { relations } from "drizzle-orm";
|
2025-10-10 23:04:50 -07:00
|
|
|
|
2025-10-11 00:53:39 -07:00
|
|
|
// Users table - synced from Clerk
|
|
|
|
|
export const users = pgTable("users", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
clerkId: varchar("clerk_id", { length: 255 }).notNull().unique(),
|
|
|
|
|
email: varchar("email", { length: 255 }).notNull(),
|
2025-10-14 21:20:58 -07:00
|
|
|
username: varchar("username", { length: 255 }),
|
2025-10-11 00:53:39 -07:00
|
|
|
displayName: varchar("display_name", { length: 255 }),
|
|
|
|
|
firstName: varchar("first_name", { length: 255 }),
|
|
|
|
|
lastName: varchar("last_name", { length: 255 }),
|
|
|
|
|
imageUrl: varchar("image_url", { length: 512 }),
|
2025-10-12 21:16:00 -07:00
|
|
|
isAdmin: boolean("is_admin").notNull().default(false),
|
2025-10-11 00:53:39 -07:00
|
|
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
|
|
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
|
|
|
|
});
|
|
|
|
|
|
2025-10-11 00:07:39 -07:00
|
|
|
// Fantasy League Tables
|
|
|
|
|
export const seasonStatusEnum = pgEnum("season_status", [
|
|
|
|
|
"pre_draft",
|
|
|
|
|
"draft",
|
|
|
|
|
"active",
|
|
|
|
|
"completed",
|
|
|
|
|
]);
|
|
|
|
|
|
2025-10-12 21:16:00 -07:00
|
|
|
// Sports Tables - Enums
|
|
|
|
|
export const sportTypeEnum = pgEnum("sport_type", ["team", "individual"]);
|
|
|
|
|
|
|
|
|
|
export const sportsSeasonStatusEnum = pgEnum("sports_season_status", [
|
|
|
|
|
"upcoming",
|
|
|
|
|
"active",
|
|
|
|
|
"completed",
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
export const scoringTypeEnum = pgEnum("scoring_type", [
|
|
|
|
|
"playoffs",
|
|
|
|
|
"regular_season",
|
|
|
|
|
"majors",
|
|
|
|
|
]);
|
|
|
|
|
|
2025-10-28 23:39:34 -07:00
|
|
|
export const scoringPatternEnum = pgEnum("scoring_pattern", [
|
2026-03-07 21:59:29 -08:00
|
|
|
"playoff_bracket",
|
2025-10-28 23:39:34 -07:00
|
|
|
"season_standings",
|
|
|
|
|
"qualifying_points",
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
export const eventTypeEnum = pgEnum("event_type", [
|
|
|
|
|
"playoff_game",
|
|
|
|
|
"major_tournament",
|
|
|
|
|
"final_standings",
|
2026-03-07 21:59:29 -08:00
|
|
|
"schedule_event",
|
2025-10-28 23:39:34 -07:00
|
|
|
]);
|
|
|
|
|
|
2025-10-16 00:32:48 -07:00
|
|
|
export const pickedByTypeEnum = pgEnum("picked_by_type", [
|
|
|
|
|
"owner",
|
|
|
|
|
"commissioner",
|
Grant sitewide admins commissioner-level access in leagues (#162)
* Grant sitewide admins commissioner-level access in leagues
- `isCommissioner()` now returns true for site admins, covering all
commissioner-gated loaders (league home, settings, sport season detail)
and draft API routes (start, pause, resume, rollback, replace-pick,
force-autopick, force-manual-pick, adjust-time-bank, make-pick)
- Added `hasCommissionerRecord()` (DB-only, no admin bypass) for the
"already a commissioner" duplicate-entry check in the settings action,
preventing a false positive when adding a site admin as commissioner
- `isCommissioner()` now runs the admin check and DB query in parallel
via Promise.all to avoid a serial roundtrip on every check
- Added "admin" to the `picked_by_type` enum (migration 0049) so picks
forced by a site admin are recorded accurately in the audit log rather
than as "commissioner"
- 8 unit tests covering both isCommissioner and hasCommissionerRecord
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix draft.force-manual-pick tests broken by isUserAdminByClerkId
The route now calls isUserAdminByClerkId which hits database().query.users,
but the test's mock DB had no query.users entry. Add a vi.mock for
~/models/user and default isUserAdminByClerkId to false in beforeEach.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 00:41:56 -07:00
|
|
|
"admin",
|
2025-10-16 00:32:48 -07:00
|
|
|
"auto",
|
|
|
|
|
]);
|
|
|
|
|
|
2025-10-21 23:22:17 -07:00
|
|
|
export const autodraftModeEnum = pgEnum("autodraft_mode", [
|
|
|
|
|
"next_pick",
|
|
|
|
|
"while_on",
|
|
|
|
|
]);
|
|
|
|
|
|
2025-11-17 22:19:46 -08:00
|
|
|
export const probabilitySourceEnum = pgEnum("probability_source", [
|
|
|
|
|
"manual",
|
|
|
|
|
"futures_odds",
|
|
|
|
|
"elo_simulation",
|
|
|
|
|
"performance_model",
|
|
|
|
|
]);
|
|
|
|
|
|
2026-03-09 15:34:31 -07:00
|
|
|
export const simulationStatusEnum = pgEnum("simulation_status", [
|
|
|
|
|
"idle",
|
|
|
|
|
"running",
|
|
|
|
|
"failed",
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
export const simulatorTypeEnum = pgEnum("simulator_type", [
|
|
|
|
|
"f1_standings",
|
|
|
|
|
"indycar_standings",
|
|
|
|
|
"golf_qualifying_points",
|
|
|
|
|
"playoff_bracket",
|
2026-03-10 23:04:51 -07:00
|
|
|
"ucl_bracket",
|
Add NCAAM and NCAAW bracket Monte Carlo simulators (#150)
- NCAAM: KenPom AEM logistic formula (1/(1+exp(-diff/7.5))), data through 2025-26 March 15
- NCAAW: Barttorvik Barthag Log5 formula (A*(1-B)/(A*(1-B)+B*(1-A))), same bracket structure
- Both simulators: 50,000-iteration Monte Carlo, First Four simulation, honors completed matches
- Track E8+ placements only: champion, finalist, FF losers (3rd/4th), E8 losers (5th–8th)
- Add bracket configuration validation: null R64 slots must exactly match First Four mapping
- Fix DEFAULT_SCORING_RULES to 100/70/45/45/20/20/20/20 (3rd/4th=45, 5th–8th=20)
- Align scoring constants across simulate route, expected-values display, and server action
- Zero out EVs for non-bracket participants on every simulation run (prevents EV inflation)
- Add EV total invariant warning (expected ~340) on expected-values admin page
- 98 unit tests across NCAAM, NCAAW, and UCL simulators — all passing
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 23:31:47 -07:00
|
|
|
"ncaam_bracket",
|
|
|
|
|
"ncaaw_bracket",
|
2026-03-16 21:43:39 -07:00
|
|
|
"nba_bracket",
|
2026-03-18 01:23:53 -07:00
|
|
|
"nhl_bracket",
|
2026-03-09 15:34:31 -07:00
|
|
|
]);
|
|
|
|
|
|
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
|
|
|
export const playoffMatchGameStatusEnum = pgEnum("playoff_match_game_status", [
|
|
|
|
|
"scheduled",
|
|
|
|
|
"complete",
|
|
|
|
|
"postponed",
|
|
|
|
|
]);
|
|
|
|
|
|
2025-10-11 00:07:39 -07:00
|
|
|
export const leagues = pgTable("leagues", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
name: varchar("name", { length: 255 }).notNull(),
|
|
|
|
|
createdBy: varchar("created_by", { length: 255 }).notNull(), // Clerk user ID
|
2025-10-11 21:10:01 -07:00
|
|
|
currentSeasonId: uuid("current_season_id"), // References the active season
|
2025-10-20 15:03:11 -07:00
|
|
|
isPublicDraftBoard: boolean("is_public_draft_board").notNull().default(false),
|
2026-03-17 11:16:36 -07:00
|
|
|
discordWebhookUrl: text("discord_webhook_url"),
|
2025-10-11 00:07:39 -07:00
|
|
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
|
|
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
|
|
|
|
});
|
|
|
|
|
|
2025-10-12 21:16:00 -07:00
|
|
|
export const seasonTemplates = pgTable("season_templates", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
name: varchar("name", { length: 255 }).notNull(),
|
|
|
|
|
description: text("description"),
|
|
|
|
|
year: integer("year").notNull(),
|
|
|
|
|
isActive: boolean("is_active").notNull().default(true),
|
|
|
|
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
|
|
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
|
|
|
|
});
|
|
|
|
|
|
2025-10-11 00:07:39 -07:00
|
|
|
export const seasons = pgTable("seasons", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
leagueId: uuid("league_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => leagues.id, { onDelete: "cascade" }),
|
|
|
|
|
year: integer("year").notNull(),
|
|
|
|
|
status: seasonStatusEnum("status").notNull().default("pre_draft"),
|
2025-10-12 21:16:00 -07:00
|
|
|
templateId: uuid("template_id")
|
|
|
|
|
.references(() => seasonTemplates.id, { onDelete: "set null" }),
|
2025-10-15 21:50:02 -07:00
|
|
|
draftRounds: integer("draft_rounds").notNull().default(20),
|
2025-10-12 21:16:00 -07:00
|
|
|
flexSpots: integer("flex_spots").notNull().default(0),
|
2025-10-15 22:02:21 -07:00
|
|
|
draftDateTime: timestamp("draft_date_time"),
|
2025-10-16 00:32:48 -07:00
|
|
|
draftInitialTime: integer("draft_initial_time").notNull().default(120), // seconds
|
|
|
|
|
draftIncrementTime: integer("draft_increment_time").notNull().default(30), // seconds
|
|
|
|
|
currentPickNumber: integer("current_pick_number").default(1),
|
|
|
|
|
draftStartedAt: timestamp("draft_started_at"),
|
|
|
|
|
draftPaused: boolean("draft_paused").notNull().default(false),
|
2025-10-14 12:20:36 -07:00
|
|
|
inviteCode: varchar("invite_code", { length: 20 }).notNull().unique(),
|
2025-10-28 23:39:34 -07:00
|
|
|
// Scoring configuration (8 values for 1st through 8th)
|
|
|
|
|
pointsFor1st: integer("points_for_1st").notNull().default(100),
|
|
|
|
|
pointsFor2nd: integer("points_for_2nd").notNull().default(70),
|
|
|
|
|
pointsFor3rd: integer("points_for_3rd").notNull().default(50),
|
|
|
|
|
pointsFor4th: integer("points_for_4th").notNull().default(40),
|
|
|
|
|
pointsFor5th: integer("points_for_5th").notNull().default(25),
|
|
|
|
|
pointsFor6th: integer("points_for_6th").notNull().default(25),
|
|
|
|
|
pointsFor7th: integer("points_for_7th").notNull().default(15),
|
|
|
|
|
pointsFor8th: integer("points_for_8th").notNull().default(15),
|
2025-10-11 00:07:39 -07:00
|
|
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
|
|
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const teams = pgTable("teams", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
seasonId: uuid("season_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => seasons.id, { onDelete: "cascade" }),
|
|
|
|
|
name: varchar("name", { length: 255 }).notNull(),
|
2025-10-14 22:04:37 -07:00
|
|
|
logoUrl: varchar("logo_url", { length: 512 }),
|
2025-10-11 00:07:39 -07:00
|
|
|
ownerId: varchar("owner_id", { length: 255 }), // Clerk user ID, nullable
|
|
|
|
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
|
|
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
|
|
|
|
});
|
2025-10-11 00:29:04 -07:00
|
|
|
|
2025-10-15 08:58:35 -07:00
|
|
|
export const draftSlots = pgTable("draft_slots", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
seasonId: uuid("season_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => seasons.id, { onDelete: "cascade" }),
|
|
|
|
|
teamId: uuid("team_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => teams.id, { onDelete: "cascade" }),
|
|
|
|
|
draftOrder: integer("draft_order").notNull(),
|
|
|
|
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
|
|
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
|
|
|
|
});
|
|
|
|
|
|
2025-10-16 00:32:48 -07:00
|
|
|
export const draftPicks = pgTable("draft_picks", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
seasonId: uuid("season_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => seasons.id, { onDelete: "cascade" }),
|
|
|
|
|
teamId: uuid("team_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => teams.id, { onDelete: "cascade" }),
|
|
|
|
|
participantId: uuid("participant_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => participants.id, { onDelete: "cascade" }),
|
|
|
|
|
pickNumber: integer("pick_number").notNull(),
|
|
|
|
|
round: integer("round").notNull(),
|
|
|
|
|
pickInRound: integer("pick_in_round").notNull(),
|
|
|
|
|
pickedByUserId: varchar("picked_by_user_id", { length: 255 }).notNull(), // Clerk user ID
|
|
|
|
|
pickedByType: pickedByTypeEnum("picked_by_type").notNull(),
|
2026-02-23 23:23:24 -08:00
|
|
|
timeUsed: integer("time_used").notNull().default(0), // team's time bank (seconds) at the moment the pick was made
|
2025-10-16 00:32:48 -07:00
|
|
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
2026-02-23 23:23:24 -08:00
|
|
|
}, (t) => ({
|
|
|
|
|
// Prevents two concurrent picks from landing in the same slot
|
|
|
|
|
uniquePickSlot: uniqueIndex("draft_picks_season_pick_unique").on(t.seasonId, t.pickNumber),
|
|
|
|
|
}));
|
2025-10-16 00:32:48 -07:00
|
|
|
|
|
|
|
|
export const draftQueue = pgTable("draft_queue", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
seasonId: uuid("season_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => seasons.id, { onDelete: "cascade" }),
|
|
|
|
|
teamId: uuid("team_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => teams.id, { onDelete: "cascade" }),
|
|
|
|
|
participantId: uuid("participant_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => participants.id, { onDelete: "cascade" }),
|
|
|
|
|
queuePosition: integer("queue_position").notNull(),
|
|
|
|
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
|
|
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const draftTimers = pgTable("draft_timers", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
seasonId: uuid("season_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => seasons.id, { onDelete: "cascade" }),
|
|
|
|
|
teamId: uuid("team_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => teams.id, { onDelete: "cascade" }),
|
|
|
|
|
timeRemaining: integer("time_remaining").notNull(), // seconds
|
|
|
|
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
|
|
|
|
});
|
|
|
|
|
|
2025-10-21 23:22:17 -07:00
|
|
|
export const autodraftSettings = pgTable("autodraft_settings", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
seasonId: uuid("season_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => seasons.id, { onDelete: "cascade" }),
|
|
|
|
|
teamId: uuid("team_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => teams.id, { onDelete: "cascade" }),
|
|
|
|
|
isEnabled: boolean("is_enabled").notNull().default(false),
|
|
|
|
|
mode: autodraftModeEnum("mode").notNull().default("next_pick"),
|
Claude/redesign autodraft queue c4 kp r (#40)
* Redesign autodraft queue system with three-state control and queue-only constraint
Core Logic & Database:
- Add `queue_only` boolean column to `autodraft_settings` (migration 0031)
- Rename autodraft UI states: Off / Next Pick / All Picks (while_on mode maps to All Picks)
- `autoPickForTeam`: respects new `queueOnly` param — skips EV fallback when enabled
- `executeAutoPick`: auto-disables autodraft + emits socket event when queue empties with queueOnly ON (AC3)
- `autodraft-updated` socket event now includes `queueOnly` field
Mobile UI Overhaul:
- Rename "Lobby" tab → "Available" (AC6)
- Add new "Queue" tab to mobile bottom nav with drag-reorder, per-item Draft buttons, and autodraft controls (AC5)
- Controls tab retains commissioner tools, notifications, exit; queue controls moved to Queue tab
- Turn indicator appears on both Available and Queue tabs
Components:
- `AutodraftSettings`: replaces toggle+radio with three-state button group (Off | Next Pick | All Picks) + "Only autodraft from queue" switch (AC1, AC2)
- `QueueSection`: adds `canPick` prop + per-item Draft buttons for instant drafting when on the clock
Desktop (AC4):
- Sidebar QueueSection unchanged in position; gains same three-state controls and Draft buttons
Tests (AC7):
- `autodraft.test.ts`: updated for queueOnly field and socket event shape
- `timer-autodraft.test.ts`: new tests for queue-only constraint, auto-shutoff transitions, and all three autodraft states
https://claude.ai/code/session_01PYhJicAStoJ2u6q6dV1naB
* fix: remove erroneous ?? fallbacks in autodraft socket emissions and add autoPickForTeam tests
- Remove `?? true` default on queueOnly in queue-empty auto-disable socket emit
(line 488) — was dead code since the column is NOT NULL, but semantically wrong
and would have caused client-side UI desync if the type ever relaxed
- Remove `?? false` default on the next_pick auto-disable path for consistency
- Add app/models/__tests__/auto-pick.test.ts with 6 tests covering the queueOnly
constraint: empty queue, all items drafted, partial queue skip, and EV fallback
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: add missing queueOnly prop to AutodraftSettings test fixtures
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test: rewrite AutodraftSettings tests for three-state button group UI
The component was redesigned from a switch + radio buttons to Off/Next Pick/All Picks
buttons with a separate queue-only Switch toggle. Updated 17 stale tests and added 5
new tests covering the queue-only toggle and the All Picks/Off button interactions.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-27 22:16:26 -08:00
|
|
|
queueOnly: boolean("queue_only").notNull().default(false),
|
2025-10-21 23:22:17 -07:00
|
|
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
|
|
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
|
|
|
|
});
|
|
|
|
|
|
2025-10-11 00:29:04 -07:00
|
|
|
export const commissioners = pgTable("commissioners", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
leagueId: uuid("league_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => leagues.id, { onDelete: "cascade" }),
|
|
|
|
|
userId: varchar("user_id", { length: 255 }).notNull(), // Clerk user ID
|
|
|
|
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
|
|
|
});
|
2025-10-12 21:16:00 -07:00
|
|
|
|
|
|
|
|
// Sports Tables
|
|
|
|
|
export const sports = pgTable("sports", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
name: varchar("name", { length: 255 }).notNull(),
|
|
|
|
|
type: sportTypeEnum("type").notNull(),
|
|
|
|
|
slug: varchar("slug", { length: 255 }).notNull().unique(),
|
|
|
|
|
description: text("description"),
|
2025-10-13 10:30:47 -07:00
|
|
|
iconUrl: varchar("icon_url", { length: 255 }), // Filename of icon in /public/sports-icons/
|
2026-03-09 15:34:31 -07:00
|
|
|
simulatorType: simulatorTypeEnum("simulator_type"),
|
2025-10-12 21:16:00 -07:00
|
|
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
|
|
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const sportsSeasons = pgTable("sports_seasons", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
sportId: uuid("sport_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => sports.id, { onDelete: "cascade" }),
|
|
|
|
|
name: varchar("name", { length: 255 }).notNull(),
|
|
|
|
|
year: integer("year").notNull(),
|
|
|
|
|
startDate: date("start_date"),
|
|
|
|
|
endDate: date("end_date"),
|
|
|
|
|
status: sportsSeasonStatusEnum("status").notNull().default("upcoming"),
|
|
|
|
|
scoringType: scoringTypeEnum("scoring_type").notNull(),
|
2025-10-28 23:39:34 -07:00
|
|
|
// New scoring pattern field (replaces/augments scoringType)
|
|
|
|
|
scoringPattern: scoringPatternEnum("scoring_pattern"),
|
|
|
|
|
// For qualifying points sports
|
|
|
|
|
totalMajors: integer("total_majors"),
|
|
|
|
|
majorsCompleted: integer("majors_completed").notNull().default(0),
|
|
|
|
|
qualifyingPointsFinalized: boolean("qualifying_points_finalized").notNull().default(false),
|
2025-11-17 22:19:46 -08:00
|
|
|
// EV Calibration parameters (for Elo-based probability generation)
|
|
|
|
|
eloCalibrationExponent: decimal("elo_calibration_exponent", { precision: 3, scale: 2 }), // e.g., 0.33
|
|
|
|
|
eloMinRating: integer("elo_min_rating").default(1250),
|
|
|
|
|
eloMaxRating: integer("elo_max_rating").default(1750),
|
2026-03-09 15:34:31 -07:00
|
|
|
// Simulation status (prevents concurrent simulation runs)
|
|
|
|
|
simulationStatus: simulationStatusEnum("simulation_status").notNull().default("idle"),
|
2026-03-18 22:40:19 -07:00
|
|
|
// Controls whether this sport season appears in league creation/settings
|
|
|
|
|
isDraftable: boolean("is_draftable").notNull().default(true),
|
2025-10-12 21:16:00 -07:00
|
|
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
|
|
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const participants = pgTable("participants", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
sportsSeasonId: uuid("sports_season_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
|
|
|
|
|
name: varchar("name", { length: 255 }).notNull(),
|
|
|
|
|
shortName: varchar("short_name", { length: 100 }),
|
|
|
|
|
externalId: varchar("external_id", { length: 255 }),
|
2026-03-09 15:34:31 -07:00
|
|
|
expectedValue: decimal("expected_value", { precision: 10, scale: 4 }).notNull().default("0"),
|
2025-10-12 21:16:00 -07:00
|
|
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
|
|
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const seasonTemplateSports = pgTable("season_template_sports", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
templateId: uuid("template_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => seasonTemplates.id, { onDelete: "cascade" }),
|
|
|
|
|
sportsSeasonId: uuid("sports_season_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
|
|
|
|
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const seasonSports = pgTable("season_sports", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
seasonId: uuid("season_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => seasons.id, { onDelete: "cascade" }),
|
|
|
|
|
sportsSeasonId: uuid("sports_season_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
|
|
|
|
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const participantResults = pgTable("participant_results", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
participantId: uuid("participant_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => participants.id, { onDelete: "cascade" }),
|
|
|
|
|
sportsSeasonId: uuid("sports_season_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
|
|
|
|
|
finalPosition: integer("final_position"),
|
2026-03-10 10:27:58 -07:00
|
|
|
isPartialScore: boolean("is_partial_score").notNull().default(false),
|
2025-10-12 21:16:00 -07:00
|
|
|
qualifyingPoints: decimal("qualifying_points", { precision: 10, scale: 2 }),
|
|
|
|
|
notes: text("notes"),
|
|
|
|
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
|
|
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
|
|
|
|
});
|
|
|
|
|
|
2025-10-28 23:39:34 -07:00
|
|
|
// Scoring System Tables
|
|
|
|
|
|
|
|
|
|
// Individual scoring events (games, tournaments, races)
|
|
|
|
|
export const scoringEvents = pgTable("scoring_events", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
sportsSeasonId: uuid("sports_season_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
|
|
|
|
|
name: varchar("name", { length: 255 }).notNull(), // "2024 Masters", "Super Bowl LIX", etc.
|
|
|
|
|
eventDate: date("event_date"),
|
2026-03-15 10:22:42 -07:00
|
|
|
eventStartsAt: timestamp("event_starts_at", { withTimezone: true }),
|
2025-10-28 23:39:34 -07:00
|
|
|
eventType: eventTypeEnum("event_type").notNull(),
|
|
|
|
|
// For playoff events
|
|
|
|
|
playoffRound: varchar("playoff_round", { length: 50 }), // "Quarterfinals", "Semifinals", "Finals"
|
|
|
|
|
// For qualifying events
|
|
|
|
|
isQualifyingEvent: boolean("is_qualifying_event").notNull().default(false),
|
2025-11-03 13:16:37 -08:00
|
|
|
// Template system (Phase 2.6)
|
|
|
|
|
bracketTemplateId: varchar("bracket_template_id", { length: 50 }), // "ncaa_68", "nfl_14", "simple_16", etc.
|
|
|
|
|
scoringStartsAtRound: varchar("scoring_starts_at_round", { length: 50 }), // "Elite Eight", "Quarterfinals", etc.
|
2026-03-15 21:52:47 -07:00
|
|
|
// Per-event region config for NCAA-style brackets (overrides template defaults)
|
|
|
|
|
bracketRegionConfig: jsonb("bracket_region_config"), // BracketRegion[] | null
|
2025-10-28 23:39:34 -07:00
|
|
|
isComplete: boolean("is_complete").notNull().default(false),
|
|
|
|
|
completedAt: timestamp("completed_at"),
|
|
|
|
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
|
|
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Results for participants in specific events
|
|
|
|
|
export const eventResults = pgTable("event_results", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
scoringEventId: uuid("scoring_event_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => scoringEvents.id, { onDelete: "cascade" }),
|
|
|
|
|
participantId: uuid("participant_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => participants.id, { onDelete: "cascade" }),
|
|
|
|
|
// Placement in this specific event
|
|
|
|
|
placement: integer("placement"),
|
|
|
|
|
// For qualifying events: QP awarded in this event
|
|
|
|
|
qualifyingPointsAwarded: decimal("qualifying_points_awarded", { precision: 10, scale: 2 }),
|
|
|
|
|
// For playoff events: eliminated or advanced
|
|
|
|
|
eliminated: boolean("eliminated"),
|
|
|
|
|
// Raw sport-specific data (optional)
|
|
|
|
|
rawScore: decimal("raw_score", { precision: 10, scale: 2 }), // strokes, points, time, etc.
|
|
|
|
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
|
|
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Playoff bracket matches (for display and tracking)
|
|
|
|
|
export const playoffMatches = pgTable("playoff_matches", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
scoringEventId: uuid("scoring_event_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => scoringEvents.id, { onDelete: "cascade" }),
|
|
|
|
|
round: varchar("round", { length: 50 }).notNull(), // "Quarterfinals", "Semifinals", "Finals"
|
|
|
|
|
matchNumber: integer("match_number").notNull(), // 1, 2, 3, 4 (for QF); 1, 2 (for SF); 1 (for F)
|
|
|
|
|
participant1Id: uuid("participant1_id").references(() => participants.id, { onDelete: "set null" }),
|
|
|
|
|
participant2Id: uuid("participant2_id").references(() => participants.id, { onDelete: "set null" }),
|
|
|
|
|
winnerId: uuid("winner_id").references(() => participants.id, { onDelete: "set null" }),
|
|
|
|
|
loserId: uuid("loser_id").references(() => participants.id, { onDelete: "set null" }),
|
|
|
|
|
isComplete: boolean("is_complete").notNull().default(false),
|
|
|
|
|
// Optional detailed data
|
|
|
|
|
participant1Score: decimal("participant1_score", { precision: 10, scale: 2 }),
|
|
|
|
|
participant2Score: decimal("participant2_score", { precision: 10, scale: 2 }),
|
2025-11-03 13:16:37 -08:00
|
|
|
// Template system (Phase 2.6)
|
|
|
|
|
isScoring: boolean("is_scoring").notNull().default(true), // Does this match affect fantasy points?
|
|
|
|
|
templateRound: varchar("template_round", { length: 50 }), // "First Four", "Round of 64", "Elite Eight", etc.
|
|
|
|
|
seedInfo: varchar("seed_info", { length: 50 }), // "1 vs 16", "11a vs 11b", etc. (for display)
|
2025-10-28 23:39:34 -07:00
|
|
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
|
|
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
|
|
|
|
});
|
|
|
|
|
|
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
|
|
|
// 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(),
|
|
|
|
|
});
|
|
|
|
|
|
2025-10-28 23:39:34 -07:00
|
|
|
// Aggregated participant qualifying points (for qualifying_points sports)
|
|
|
|
|
export const participantQualifyingTotals = pgTable("participant_qualifying_totals", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
participantId: uuid("participant_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => participants.id, { onDelete: "cascade" }),
|
|
|
|
|
sportsSeasonId: uuid("sports_season_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
|
|
|
|
|
totalQualifyingPoints: decimal("total_qualifying_points", { precision: 10, scale: 2 }).notNull().default("0"),
|
|
|
|
|
eventsScored: integer("events_scored").notNull().default(0),
|
|
|
|
|
// After finalization
|
|
|
|
|
finalRanking: integer("final_ranking"), // 1-8 based on QP totals
|
|
|
|
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Configurable qualifying point values per sports season
|
|
|
|
|
export const qualifyingPointConfig = pgTable("qualifying_point_config", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
sportsSeasonId: uuid("sports_season_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
|
|
|
|
|
placement: integer("placement").notNull(), // 1-16
|
|
|
|
|
points: decimal("points", { precision: 10, scale: 2 }).notNull(),
|
|
|
|
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
|
|
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Team scores aggregated by sport season
|
|
|
|
|
export const teamSportScores = pgTable("team_sport_scores", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
teamId: uuid("team_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => teams.id, { onDelete: "cascade" }),
|
|
|
|
|
sportsSeasonId: uuid("sports_season_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
|
|
|
|
|
totalPoints: decimal("total_points", { precision: 10, scale: 2 }).notNull().default("0"),
|
|
|
|
|
participantsCompleted: integer("participants_completed").notNull().default(0), // How many picks have finished
|
|
|
|
|
participantsTotal: integer("participants_total").notNull().default(0), // Total picks for this sport
|
|
|
|
|
calculatedAt: timestamp("calculated_at").defaultNow().notNull(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Overall team standings (snapshot-based for movement tracking)
|
|
|
|
|
export const teamStandings = pgTable("team_standings", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
teamId: uuid("team_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => teams.id, { onDelete: "cascade" }),
|
|
|
|
|
seasonId: uuid("season_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => seasons.id, { onDelete: "cascade" }),
|
|
|
|
|
totalPoints: decimal("total_points", { precision: 10, scale: 2 }).notNull().default("0"),
|
|
|
|
|
currentRank: integer("current_rank").notNull(),
|
|
|
|
|
previousRank: integer("previous_rank"), // For movement indicators
|
|
|
|
|
// Tiebreaker data
|
|
|
|
|
firstPlaceCount: integer("first_place_count").notNull().default(0),
|
|
|
|
|
secondPlaceCount: integer("second_place_count").notNull().default(0),
|
|
|
|
|
thirdPlaceCount: integer("third_place_count").notNull().default(0),
|
|
|
|
|
fourthPlaceCount: integer("fourth_place_count").notNull().default(0),
|
|
|
|
|
fifthPlaceCount: integer("fifth_place_count").notNull().default(0),
|
|
|
|
|
sixthPlaceCount: integer("sixth_place_count").notNull().default(0),
|
|
|
|
|
seventhPlaceCount: integer("seventh_place_count").notNull().default(0),
|
|
|
|
|
eighthPlaceCount: integer("eighth_place_count").notNull().default(0),
|
|
|
|
|
participantsRemaining: integer("participants_remaining").notNull().default(0), // Not yet finished
|
2026-02-14 22:30:12 -08:00
|
|
|
// Expected value tracking (Phase 5.4)
|
|
|
|
|
actualPoints: decimal("actual_points", { precision: 10, scale: 2 }), // Points from finished participants
|
|
|
|
|
projectedPoints: decimal("projected_points", { precision: 10, scale: 2 }), // actualPoints + EVs of unfinished
|
|
|
|
|
participantsFinished: integer("participants_finished"), // Count of finished participants
|
2025-10-28 23:39:34 -07:00
|
|
|
calculatedAt: timestamp("calculated_at").defaultNow().notNull(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Daily standings snapshots for historical tracking
|
|
|
|
|
export const teamStandingsSnapshots = pgTable("team_standings_snapshots", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
teamId: uuid("team_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => teams.id, { onDelete: "cascade" }),
|
|
|
|
|
seasonId: uuid("season_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => seasons.id, { onDelete: "cascade" }),
|
|
|
|
|
snapshotDate: date("snapshot_date").notNull(),
|
|
|
|
|
totalPoints: decimal("total_points", { precision: 10, scale: 2 }).notNull().default("0"),
|
|
|
|
|
rank: integer("rank").notNull(),
|
|
|
|
|
// Tiebreaker data
|
|
|
|
|
firstPlaceCount: integer("first_place_count").notNull().default(0),
|
|
|
|
|
secondPlaceCount: integer("second_place_count").notNull().default(0),
|
|
|
|
|
thirdPlaceCount: integer("third_place_count").notNull().default(0),
|
|
|
|
|
fourthPlaceCount: integer("fourth_place_count").notNull().default(0),
|
|
|
|
|
fifthPlaceCount: integer("fifth_place_count").notNull().default(0),
|
|
|
|
|
sixthPlaceCount: integer("sixth_place_count").notNull().default(0),
|
|
|
|
|
seventhPlaceCount: integer("seventh_place_count").notNull().default(0),
|
|
|
|
|
eighthPlaceCount: integer("eighth_place_count").notNull().default(0),
|
|
|
|
|
participantsRemaining: integer("participants_remaining").notNull().default(0),
|
2025-11-17 22:19:46 -08:00
|
|
|
// Expected value tracking
|
|
|
|
|
actualPoints: decimal("actual_points", { precision: 10, scale: 2 }), // Points from finished participants
|
|
|
|
|
projectedPoints: decimal("projected_points", { precision: 10, scale: 2 }), // actualPoints + EVs of unfinished
|
|
|
|
|
participantsFinished: integer("participants_finished"), // Count of finished participants
|
2025-10-28 23:39:34 -07:00
|
|
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
2026-03-18 22:15:28 -07:00
|
|
|
}, (t) => ({
|
|
|
|
|
uniqueTeamSeasonDate: uniqueIndex("team_standings_snapshots_unique").on(
|
|
|
|
|
t.teamId, t.seasonId, t.snapshotDate
|
|
|
|
|
),
|
|
|
|
|
}));
|
2025-10-28 23:39:34 -07:00
|
|
|
|
2025-11-17 22:19:46 -08:00
|
|
|
// Expected value tracking (sports-season-specific)
|
2025-10-28 23:39:34 -07:00
|
|
|
export const participantExpectedValues = pgTable("participant_expected_values", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
participantId: uuid("participant_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => participants.id, { onDelete: "cascade" }),
|
2025-11-17 22:19:46 -08:00
|
|
|
sportsSeasonId: uuid("sports_season_id")
|
2025-10-28 23:39:34 -07:00
|
|
|
.notNull()
|
2025-11-17 22:19:46 -08:00
|
|
|
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
|
2026-03-09 15:34:31 -07:00
|
|
|
// Probability distribution (stored as fractions, e.g. 0.1550 = 15.5%)
|
|
|
|
|
probFirst: decimal("prob_first", { precision: 6, scale: 4 }).notNull().default("0"),
|
|
|
|
|
probSecond: decimal("prob_second", { precision: 6, scale: 4 }).notNull().default("0"),
|
|
|
|
|
probThird: decimal("prob_third", { precision: 6, scale: 4 }).notNull().default("0"),
|
|
|
|
|
probFourth: decimal("prob_fourth", { precision: 6, scale: 4 }).notNull().default("0"),
|
|
|
|
|
probFifth: decimal("prob_fifth", { precision: 6, scale: 4 }).notNull().default("0"),
|
|
|
|
|
probSixth: decimal("prob_sixth", { precision: 6, scale: 4 }).notNull().default("0"),
|
|
|
|
|
probSeventh: decimal("prob_seventh", { precision: 6, scale: 4 }).notNull().default("0"),
|
|
|
|
|
probEighth: decimal("prob_eighth", { precision: 6, scale: 4 }).notNull().default("0"),
|
2025-10-28 23:39:34 -07:00
|
|
|
// Calculated EV
|
2026-03-09 15:34:31 -07:00
|
|
|
expectedValue: decimal("expected_value", { precision: 10, scale: 4 }).notNull().default("0"),
|
2025-11-17 22:19:46 -08:00
|
|
|
// Metadata
|
|
|
|
|
source: probabilitySourceEnum("source").default("manual"), // How probabilities were generated
|
|
|
|
|
sourceOdds: integer("source_odds"), // Original odds if source is futures_odds (American odds format)
|
2025-10-28 23:39:34 -07:00
|
|
|
calculatedAt: timestamp("calculated_at").defaultNow().notNull(),
|
|
|
|
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-14 22:30:12 -08:00
|
|
|
// Tournament group stage tables (for FIFA World Cup style tournaments)
|
|
|
|
|
export const tournamentGroups = pgTable("tournament_groups", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
scoringEventId: uuid("scoring_event_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => scoringEvents.id, { onDelete: "cascade" }),
|
|
|
|
|
groupName: varchar("group_name", { length: 10 }).notNull(), // "A" through "L"
|
|
|
|
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const tournamentGroupMembers = pgTable("tournament_group_members", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
tournamentGroupId: uuid("tournament_group_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => tournamentGroups.id, { onDelete: "cascade" }),
|
|
|
|
|
participantId: uuid("participant_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => participants.id, { onDelete: "cascade" }),
|
|
|
|
|
eliminated: boolean("eliminated").notNull().default(false),
|
|
|
|
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
|
|
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
|
|
|
|
});
|
|
|
|
|
|
2025-10-28 23:39:34 -07:00
|
|
|
// Participant season results (for F1 current points tracking during season)
|
|
|
|
|
export const participantSeasonResults = pgTable("participant_season_results", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
participantId: uuid("participant_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => participants.id, { onDelete: "cascade" }),
|
|
|
|
|
sportsSeasonId: uuid("sports_season_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
|
|
|
|
|
currentPoints: decimal("current_points", { precision: 10, scale: 2 }).notNull().default("0"), // Current F1 championship points, etc.
|
|
|
|
|
currentPosition: integer("current_position"), // Current standings position
|
|
|
|
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-09 15:34:31 -07:00
|
|
|
// EV snapshots — historical record of participant EV over time (one per participant per sport season per day)
|
|
|
|
|
export const participantEvSnapshots = pgTable("participant_ev_snapshots", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
participantId: uuid("participant_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => participants.id, { onDelete: "cascade" }),
|
|
|
|
|
sportsSeasonId: uuid("sports_season_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
|
|
|
|
|
snapshotDate: date("snapshot_date").notNull(),
|
|
|
|
|
// Probability distribution (stored as fractions, e.g. 0.1550 = 15.5%)
|
|
|
|
|
probFirst: decimal("prob_first", { precision: 5, scale: 2 }).notNull().default("0"),
|
|
|
|
|
probSecond: decimal("prob_second", { precision: 5, scale: 2 }).notNull().default("0"),
|
|
|
|
|
probThird: decimal("prob_third", { precision: 5, scale: 2 }).notNull().default("0"),
|
|
|
|
|
probFourth: decimal("prob_fourth", { precision: 5, scale: 2 }).notNull().default("0"),
|
|
|
|
|
probFifth: decimal("prob_fifth", { precision: 5, scale: 2 }).notNull().default("0"),
|
|
|
|
|
probSixth: decimal("prob_sixth", { precision: 5, scale: 2 }).notNull().default("0"),
|
|
|
|
|
probSeventh: decimal("prob_seventh", { precision: 5, scale: 2 }).notNull().default("0"),
|
|
|
|
|
probEighth: decimal("prob_eighth", { precision: 5, scale: 2 }).notNull().default("0"),
|
|
|
|
|
calculatedEV: decimal("calculated_ev", { precision: 10, scale: 4 }).notNull().default("0"),
|
|
|
|
|
source: varchar("source", { length: 100 }).notNull(), // e.g. 'bracket_monte_carlo', 'f1_standings_model'
|
|
|
|
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
|
|
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
|
|
|
|
}, (t) => ({
|
|
|
|
|
uniqueParticipantSeasonDate: uniqueIndex("participant_ev_snapshots_unique").on(
|
|
|
|
|
t.participantId, t.sportsSeasonId, t.snapshotDate
|
|
|
|
|
),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// Team EV snapshots — historical projected points per team per fantasy season per day
|
|
|
|
|
export const teamEvSnapshots = pgTable("team_ev_snapshots", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
teamId: uuid("team_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => teams.id, { onDelete: "cascade" }),
|
|
|
|
|
seasonId: uuid("season_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => seasons.id, { onDelete: "cascade" }),
|
|
|
|
|
snapshotDate: date("snapshot_date").notNull(),
|
|
|
|
|
projectedPoints: decimal("projected_points", { precision: 10, scale: 2 }).notNull().default("0"),
|
|
|
|
|
actualPoints: decimal("actual_points", { precision: 10, scale: 2 }).notNull().default("0"),
|
|
|
|
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
|
|
|
}, (t) => ({
|
|
|
|
|
uniqueTeamSeasonDate: uniqueIndex("team_ev_snapshots_unique").on(
|
|
|
|
|
t.teamId, t.seasonId, t.snapshotDate
|
|
|
|
|
),
|
|
|
|
|
}));
|
|
|
|
|
|
2025-10-12 21:16:00 -07:00
|
|
|
// Relations
|
|
|
|
|
export const sportsRelations = relations(sports, ({ many }) => ({
|
|
|
|
|
sportsSeasons: many(sportsSeasons),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const sportsSeasonsRelations = relations(sportsSeasons, ({ one, many }) => ({
|
|
|
|
|
sport: one(sports, {
|
|
|
|
|
fields: [sportsSeasons.sportId],
|
|
|
|
|
references: [sports.id],
|
|
|
|
|
}),
|
|
|
|
|
participants: many(participants),
|
|
|
|
|
seasonTemplateSports: many(seasonTemplateSports),
|
|
|
|
|
seasonSports: many(seasonSports),
|
|
|
|
|
participantResults: many(participantResults),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const participantsRelations = relations(participants, ({ one, many }) => ({
|
|
|
|
|
sportsSeason: one(sportsSeasons, {
|
|
|
|
|
fields: [participants.sportsSeasonId],
|
|
|
|
|
references: [sportsSeasons.id],
|
|
|
|
|
}),
|
|
|
|
|
results: many(participantResults),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const seasonTemplatesRelations = relations(seasonTemplates, ({ many }) => ({
|
|
|
|
|
seasonTemplateSports: many(seasonTemplateSports),
|
|
|
|
|
seasons: many(seasons),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const seasonTemplateSportsRelations = relations(seasonTemplateSports, ({ one }) => ({
|
|
|
|
|
template: one(seasonTemplates, {
|
|
|
|
|
fields: [seasonTemplateSports.templateId],
|
|
|
|
|
references: [seasonTemplates.id],
|
|
|
|
|
}),
|
|
|
|
|
sportsSeason: one(sportsSeasons, {
|
|
|
|
|
fields: [seasonTemplateSports.sportsSeasonId],
|
|
|
|
|
references: [sportsSeasons.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const seasonsRelations = relations(seasons, ({ one, many }) => ({
|
|
|
|
|
league: one(leagues, {
|
|
|
|
|
fields: [seasons.leagueId],
|
|
|
|
|
references: [leagues.id],
|
|
|
|
|
}),
|
|
|
|
|
template: one(seasonTemplates, {
|
|
|
|
|
fields: [seasons.templateId],
|
|
|
|
|
references: [seasonTemplates.id],
|
|
|
|
|
}),
|
|
|
|
|
teams: many(teams),
|
|
|
|
|
seasonSports: many(seasonSports),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const seasonSportsRelations = relations(seasonSports, ({ one }) => ({
|
|
|
|
|
season: one(seasons, {
|
|
|
|
|
fields: [seasonSports.seasonId],
|
|
|
|
|
references: [seasons.id],
|
|
|
|
|
}),
|
|
|
|
|
sportsSeason: one(sportsSeasons, {
|
|
|
|
|
fields: [seasonSports.sportsSeasonId],
|
|
|
|
|
references: [sportsSeasons.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const participantResultsRelations = relations(participantResults, ({ one }) => ({
|
|
|
|
|
participant: one(participants, {
|
|
|
|
|
fields: [participantResults.participantId],
|
|
|
|
|
references: [participants.id],
|
|
|
|
|
}),
|
|
|
|
|
sportsSeason: one(sportsSeasons, {
|
|
|
|
|
fields: [participantResults.sportsSeasonId],
|
|
|
|
|
references: [sportsSeasons.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
2025-10-15 08:58:35 -07:00
|
|
|
|
|
|
|
|
export const teamsRelations = relations(teams, ({ one }) => ({
|
|
|
|
|
season: one(seasons, {
|
|
|
|
|
fields: [teams.seasonId],
|
|
|
|
|
references: [seasons.id],
|
|
|
|
|
}),
|
|
|
|
|
draftSlot: one(draftSlots, {
|
|
|
|
|
fields: [teams.id],
|
|
|
|
|
references: [draftSlots.teamId],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const draftSlotsRelations = relations(draftSlots, ({ one }) => ({
|
|
|
|
|
season: one(seasons, {
|
|
|
|
|
fields: [draftSlots.seasonId],
|
|
|
|
|
references: [seasons.id],
|
|
|
|
|
}),
|
|
|
|
|
team: one(teams, {
|
|
|
|
|
fields: [draftSlots.teamId],
|
|
|
|
|
references: [teams.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
2025-10-18 14:55:26 -07:00
|
|
|
|
2025-10-28 23:50:50 -07:00
|
|
|
export const draftPicksRelations = relations(draftPicks, ({ one }) => ({
|
|
|
|
|
season: one(seasons, {
|
|
|
|
|
fields: [draftPicks.seasonId],
|
|
|
|
|
references: [seasons.id],
|
|
|
|
|
}),
|
|
|
|
|
team: one(teams, {
|
|
|
|
|
fields: [draftPicks.teamId],
|
|
|
|
|
references: [teams.id],
|
|
|
|
|
}),
|
|
|
|
|
participant: one(participants, {
|
|
|
|
|
fields: [draftPicks.participantId],
|
|
|
|
|
references: [participants.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
2025-10-18 14:55:26 -07:00
|
|
|
export const draftQueueRelations = relations(draftQueue, ({ one }) => ({
|
|
|
|
|
season: one(seasons, {
|
|
|
|
|
fields: [draftQueue.seasonId],
|
|
|
|
|
references: [seasons.id],
|
|
|
|
|
}),
|
|
|
|
|
team: one(teams, {
|
|
|
|
|
fields: [draftQueue.teamId],
|
|
|
|
|
references: [teams.id],
|
|
|
|
|
}),
|
|
|
|
|
participant: one(participants, {
|
|
|
|
|
fields: [draftQueue.participantId],
|
|
|
|
|
references: [participants.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
2025-10-21 23:22:17 -07:00
|
|
|
|
|
|
|
|
export const autodraftSettingsRelations = relations(autodraftSettings, ({ one }) => ({
|
|
|
|
|
season: one(seasons, {
|
|
|
|
|
fields: [autodraftSettings.seasonId],
|
|
|
|
|
references: [seasons.id],
|
|
|
|
|
}),
|
|
|
|
|
team: one(teams, {
|
|
|
|
|
fields: [autodraftSettings.teamId],
|
|
|
|
|
references: [teams.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
2025-10-28 23:39:34 -07:00
|
|
|
|
|
|
|
|
// Scoring System Relations
|
|
|
|
|
|
|
|
|
|
export const scoringEventsRelations = relations(scoringEvents, ({ one, many }) => ({
|
|
|
|
|
sportsSeason: one(sportsSeasons, {
|
|
|
|
|
fields: [scoringEvents.sportsSeasonId],
|
|
|
|
|
references: [sportsSeasons.id],
|
|
|
|
|
}),
|
|
|
|
|
eventResults: many(eventResults),
|
|
|
|
|
playoffMatches: many(playoffMatches),
|
2026-02-14 22:30:12 -08:00
|
|
|
tournamentGroups: many(tournamentGroups),
|
2025-10-28 23:39:34 -07:00
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const eventResultsRelations = relations(eventResults, ({ one }) => ({
|
|
|
|
|
scoringEvent: one(scoringEvents, {
|
|
|
|
|
fields: [eventResults.scoringEventId],
|
|
|
|
|
references: [scoringEvents.id],
|
|
|
|
|
}),
|
|
|
|
|
participant: one(participants, {
|
|
|
|
|
fields: [eventResults.participantId],
|
|
|
|
|
references: [participants.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
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
|
|
|
export const playoffMatchesRelations = relations(playoffMatches, ({ one, many }) => ({
|
2025-10-28 23:39:34 -07:00
|
|
|
scoringEvent: one(scoringEvents, {
|
|
|
|
|
fields: [playoffMatches.scoringEventId],
|
|
|
|
|
references: [scoringEvents.id],
|
|
|
|
|
}),
|
|
|
|
|
participant1: one(participants, {
|
|
|
|
|
fields: [playoffMatches.participant1Id],
|
|
|
|
|
references: [participants.id],
|
|
|
|
|
}),
|
|
|
|
|
participant2: one(participants, {
|
|
|
|
|
fields: [playoffMatches.participant2Id],
|
|
|
|
|
references: [participants.id],
|
|
|
|
|
}),
|
|
|
|
|
winner: one(participants, {
|
|
|
|
|
fields: [playoffMatches.winnerId],
|
|
|
|
|
references: [participants.id],
|
|
|
|
|
}),
|
|
|
|
|
loser: one(participants, {
|
|
|
|
|
fields: [playoffMatches.loserId],
|
|
|
|
|
references: [participants.id],
|
|
|
|
|
}),
|
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
|
|
|
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],
|
|
|
|
|
}),
|
2025-10-28 23:39:34 -07:00
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const participantQualifyingTotalsRelations = relations(participantQualifyingTotals, ({ one }) => ({
|
|
|
|
|
participant: one(participants, {
|
|
|
|
|
fields: [participantQualifyingTotals.participantId],
|
|
|
|
|
references: [participants.id],
|
|
|
|
|
}),
|
|
|
|
|
sportsSeason: one(sportsSeasons, {
|
|
|
|
|
fields: [participantQualifyingTotals.sportsSeasonId],
|
|
|
|
|
references: [sportsSeasons.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const qualifyingPointConfigRelations = relations(qualifyingPointConfig, ({ one }) => ({
|
|
|
|
|
sportsSeason: one(sportsSeasons, {
|
|
|
|
|
fields: [qualifyingPointConfig.sportsSeasonId],
|
|
|
|
|
references: [sportsSeasons.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const teamSportScoresRelations = relations(teamSportScores, ({ one }) => ({
|
|
|
|
|
team: one(teams, {
|
|
|
|
|
fields: [teamSportScores.teamId],
|
|
|
|
|
references: [teams.id],
|
|
|
|
|
}),
|
|
|
|
|
sportsSeason: one(sportsSeasons, {
|
|
|
|
|
fields: [teamSportScores.sportsSeasonId],
|
|
|
|
|
references: [sportsSeasons.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const teamStandingsRelations = relations(teamStandings, ({ one }) => ({
|
|
|
|
|
team: one(teams, {
|
|
|
|
|
fields: [teamStandings.teamId],
|
|
|
|
|
references: [teams.id],
|
|
|
|
|
}),
|
|
|
|
|
season: one(seasons, {
|
|
|
|
|
fields: [teamStandings.seasonId],
|
|
|
|
|
references: [seasons.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const teamStandingsSnapshotsRelations = relations(teamStandingsSnapshots, ({ one }) => ({
|
|
|
|
|
team: one(teams, {
|
|
|
|
|
fields: [teamStandingsSnapshots.teamId],
|
|
|
|
|
references: [teams.id],
|
|
|
|
|
}),
|
|
|
|
|
season: one(seasons, {
|
|
|
|
|
fields: [teamStandingsSnapshots.seasonId],
|
|
|
|
|
references: [seasons.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const participantExpectedValuesRelations = relations(participantExpectedValues, ({ one }) => ({
|
|
|
|
|
participant: one(participants, {
|
|
|
|
|
fields: [participantExpectedValues.participantId],
|
|
|
|
|
references: [participants.id],
|
|
|
|
|
}),
|
2025-11-17 22:19:46 -08:00
|
|
|
sportsSeason: one(sportsSeasons, {
|
|
|
|
|
fields: [participantExpectedValues.sportsSeasonId],
|
|
|
|
|
references: [sportsSeasons.id],
|
2025-10-28 23:39:34 -07:00
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const participantSeasonResultsRelations = relations(participantSeasonResults, ({ one }) => ({
|
|
|
|
|
participant: one(participants, {
|
|
|
|
|
fields: [participantSeasonResults.participantId],
|
|
|
|
|
references: [participants.id],
|
|
|
|
|
}),
|
|
|
|
|
sportsSeason: one(sportsSeasons, {
|
|
|
|
|
fields: [participantSeasonResults.sportsSeasonId],
|
|
|
|
|
references: [sportsSeasons.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
2026-02-14 22:30:12 -08:00
|
|
|
|
|
|
|
|
// Tournament Group Relations
|
|
|
|
|
export const tournamentGroupsRelations = relations(tournamentGroups, ({ one, many }) => ({
|
|
|
|
|
scoringEvent: one(scoringEvents, {
|
|
|
|
|
fields: [tournamentGroups.scoringEventId],
|
|
|
|
|
references: [scoringEvents.id],
|
|
|
|
|
}),
|
|
|
|
|
members: many(tournamentGroupMembers),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const tournamentGroupMembersRelations = relations(tournamentGroupMembers, ({ one }) => ({
|
|
|
|
|
group: one(tournamentGroups, {
|
|
|
|
|
fields: [tournamentGroupMembers.tournamentGroupId],
|
|
|
|
|
references: [tournamentGroups.id],
|
|
|
|
|
}),
|
|
|
|
|
participant: one(participants, {
|
|
|
|
|
fields: [tournamentGroupMembers.participantId],
|
|
|
|
|
references: [participants.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
2026-03-09 15:34:31 -07:00
|
|
|
|
|
|
|
|
export const participantEvSnapshotsRelations = relations(participantEvSnapshots, ({ one }) => ({
|
|
|
|
|
participant: one(participants, {
|
|
|
|
|
fields: [participantEvSnapshots.participantId],
|
|
|
|
|
references: [participants.id],
|
|
|
|
|
}),
|
|
|
|
|
sportsSeason: one(sportsSeasons, {
|
|
|
|
|
fields: [participantEvSnapshots.sportsSeasonId],
|
|
|
|
|
references: [sportsSeasons.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const teamEvSnapshotsRelations = relations(teamEvSnapshots, ({ one }) => ({
|
|
|
|
|
team: one(teams, {
|
|
|
|
|
fields: [teamEvSnapshots.teamId],
|
|
|
|
|
references: [teams.id],
|
|
|
|
|
}),
|
|
|
|
|
season: one(seasons, {
|
|
|
|
|
fields: [teamEvSnapshots.seasonId],
|
|
|
|
|
references: [seasons.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|