feat: add generic season_matches + match_sub_games tables with CS2 admin UI (Phase 1)
Adds sport-agnostic match data infrastructure: - matchStatusEnum + season_matches + match_sub_games tables in schema - externalSeasonId column on sports_seasons for API linkage - Drizzle migration 0120_tidy_invaders.sql - season-match model (upsert w/ onConflictDoUpdate, queries by event/season/stage) - CS2 admin: Swiss rounds read-only section in cs2-setup + new manual swiss-matches route - 7 unit tests for model (upsert conflict, null stage/round for non-CS2 sports, bulk, sub-games) - Plan doc committed at docs/plans/match-sync-display.md for cross-session reference https://claude.ai/code/session_01WUUM7uWzFoSkGcZRhnEKG6
This commit is contained in:
parent
058e96e67e
commit
0dca36dd5c
11 changed files with 8118 additions and 4 deletions
234
app/models/__tests__/season-match.test.ts
Normal file
234
app/models/__tests__/season-match.test.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("~/database/context", () => ({
|
||||
database: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
upsertSeasonMatch,
|
||||
upsertSeasonMatchBulk,
|
||||
upsertMatchSubGame,
|
||||
} from "../season-match";
|
||||
import { database } from "~/database/context";
|
||||
|
||||
const SPORTS_SEASON_ID = "ss-1";
|
||||
const SCORING_EVENT_ID = "evt-1";
|
||||
const P1_ID = "p1";
|
||||
const P2_ID = "p2";
|
||||
const WINNER_ID = "p1";
|
||||
const EXTERNAL_ID = "panda_123";
|
||||
|
||||
function makeUpsertDb(returnValue: object) {
|
||||
const returningFn = vi.fn().mockResolvedValue([returnValue]);
|
||||
const onConflictFn = vi.fn().mockReturnValue({ returning: returningFn });
|
||||
const valuesFn = vi.fn().mockReturnValue({ onConflictDoUpdate: onConflictFn });
|
||||
return {
|
||||
insert: vi.fn().mockReturnValue({ values: valuesFn }),
|
||||
_returning: returningFn,
|
||||
_onConflict: onConflictFn,
|
||||
_values: valuesFn,
|
||||
};
|
||||
}
|
||||
|
||||
function makeBulkUpsertDb(returnValues: object[]) {
|
||||
const returningFn = vi.fn().mockResolvedValue(returnValues);
|
||||
const onConflictFn = vi.fn().mockReturnValue({ returning: returningFn });
|
||||
const valuesFn = vi.fn().mockReturnValue({ onConflictDoUpdate: onConflictFn });
|
||||
return {
|
||||
insert: vi.fn().mockReturnValue({ values: valuesFn }),
|
||||
_returning: returningFn,
|
||||
};
|
||||
}
|
||||
|
||||
const SAMPLE_MATCH = {
|
||||
id: "match-1",
|
||||
sportsSeasonId: SPORTS_SEASON_ID,
|
||||
scoringEventId: SCORING_EVENT_ID,
|
||||
participant1Id: P1_ID,
|
||||
participant2Id: P2_ID,
|
||||
winnerId: WINNER_ID,
|
||||
participant1Score: 2,
|
||||
participant2Score: 1,
|
||||
matchStage: 1,
|
||||
matchRound: 3,
|
||||
matchday: null,
|
||||
isSeries: true,
|
||||
status: "complete" as const,
|
||||
scheduledAt: null,
|
||||
startedAt: null,
|
||||
completedAt: new Date("2025-01-15T14:00:00Z"),
|
||||
externalMatchId: EXTERNAL_ID,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
describe("upsertSeasonMatch", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("inserts a match and returns it", async () => {
|
||||
const mockDb = makeUpsertDb(SAMPLE_MATCH);
|
||||
vi.mocked(database).mockReturnValue(mockDb as unknown as ReturnType<typeof database>);
|
||||
|
||||
const result = await upsertSeasonMatch({
|
||||
sportsSeasonId: SPORTS_SEASON_ID,
|
||||
scoringEventId: SCORING_EVENT_ID,
|
||||
participant1Id: P1_ID,
|
||||
participant2Id: P2_ID,
|
||||
winnerId: WINNER_ID,
|
||||
participant1Score: 2,
|
||||
participant2Score: 1,
|
||||
matchStage: 1,
|
||||
matchRound: 3,
|
||||
isSeries: true,
|
||||
status: "complete",
|
||||
completedAt: new Date("2025-01-15T14:00:00Z"),
|
||||
externalMatchId: EXTERNAL_ID,
|
||||
});
|
||||
|
||||
expect(result.id).toBe("match-1");
|
||||
expect(result.matchStage).toBe(1);
|
||||
expect(result.matchRound).toBe(3);
|
||||
expect(result.status).toBe("complete");
|
||||
expect(mockDb.insert).toHaveBeenCalledOnce();
|
||||
expect(mockDb._values).toHaveBeenCalledOnce();
|
||||
expect(mockDb._onConflict).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("defaults isSeries to false when not provided", async () => {
|
||||
const expectedMatch = { ...SAMPLE_MATCH, isSeries: false };
|
||||
const mockDb = makeUpsertDb(expectedMatch);
|
||||
vi.mocked(database).mockReturnValue(mockDb as unknown as ReturnType<typeof database>);
|
||||
|
||||
const result = await upsertSeasonMatch({
|
||||
sportsSeasonId: SPORTS_SEASON_ID,
|
||||
externalMatchId: "ext_1",
|
||||
status: "scheduled",
|
||||
});
|
||||
|
||||
const valuesCall = mockDb._values.mock.calls[0][0];
|
||||
expect(valuesCall.isSeries).toBe(false);
|
||||
expect(result.isSeries).toBe(false);
|
||||
});
|
||||
|
||||
it("allows null matchStage and matchRound for non-CS2 sports", async () => {
|
||||
const mlbMatch = { ...SAMPLE_MATCH, matchStage: null, matchRound: null, matchday: null, isSeries: false };
|
||||
const mockDb = makeUpsertDb(mlbMatch);
|
||||
vi.mocked(database).mockReturnValue(mockDb as unknown as ReturnType<typeof database>);
|
||||
|
||||
const result = await upsertSeasonMatch({
|
||||
sportsSeasonId: SPORTS_SEASON_ID,
|
||||
externalMatchId: "mlb_game_456",
|
||||
status: "complete",
|
||||
participant1Score: 5,
|
||||
participant2Score: 3,
|
||||
});
|
||||
|
||||
const valuesCall = mockDb._values.mock.calls[0][0];
|
||||
expect(valuesCall.matchStage).toBeUndefined();
|
||||
expect(valuesCall.matchRound).toBeUndefined();
|
||||
expect(result.matchStage).toBeNull();
|
||||
expect(result.matchRound).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("upsertSeasonMatchBulk", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns empty array for empty input", async () => {
|
||||
vi.mocked(database).mockReturnValue({} as ReturnType<typeof database>);
|
||||
const result = await upsertSeasonMatchBulk([]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("bulk upserts multiple matches", async () => {
|
||||
const match2 = { ...SAMPLE_MATCH, id: "match-2", matchRound: 4, externalMatchId: "panda_456" };
|
||||
const mockDb = makeBulkUpsertDb([SAMPLE_MATCH, match2]);
|
||||
vi.mocked(database).mockReturnValue(mockDb as unknown as ReturnType<typeof database>);
|
||||
|
||||
const result = await upsertSeasonMatchBulk([
|
||||
{ sportsSeasonId: SPORTS_SEASON_ID, externalMatchId: EXTERNAL_ID, status: "complete", matchStage: 1, matchRound: 3 },
|
||||
{ sportsSeasonId: SPORTS_SEASON_ID, externalMatchId: "panda_456", status: "complete", matchStage: 1, matchRound: 4 },
|
||||
]);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(mockDb.insert).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("upsertMatchSubGame", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("upserts a map sub-game", async () => {
|
||||
const subGame = {
|
||||
id: "sg-1",
|
||||
seasonMatchId: "match-1",
|
||||
gameNumber: 1,
|
||||
gameLabel: "Mirage",
|
||||
participant1Score: 16,
|
||||
participant2Score: 14,
|
||||
winnerId: P1_ID,
|
||||
status: "complete" as const,
|
||||
startedAt: null,
|
||||
completedAt: new Date(),
|
||||
externalGameId: "map_1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
const mockDb = makeUpsertDb(subGame);
|
||||
vi.mocked(database).mockReturnValue(mockDb as unknown as ReturnType<typeof database>);
|
||||
|
||||
const result = await upsertMatchSubGame({
|
||||
seasonMatchId: "match-1",
|
||||
gameNumber: 1,
|
||||
gameLabel: "Mirage",
|
||||
participant1Score: 16,
|
||||
participant2Score: 14,
|
||||
winnerId: P1_ID,
|
||||
status: "complete",
|
||||
externalGameId: "map_1",
|
||||
});
|
||||
|
||||
expect(result.gameLabel).toBe("Mirage");
|
||||
expect(result.participant1Score).toBe(16);
|
||||
expect(result.gameNumber).toBe(1);
|
||||
expect(mockDb.insert).toHaveBeenCalledOnce();
|
||||
expect(mockDb._onConflict).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("allows null gameLabel for non-named sub-games", async () => {
|
||||
const subGame = {
|
||||
id: "sg-2",
|
||||
seasonMatchId: "match-1",
|
||||
gameNumber: 2,
|
||||
gameLabel: null,
|
||||
participant1Score: 3,
|
||||
participant2Score: 1,
|
||||
winnerId: P1_ID,
|
||||
status: "complete" as const,
|
||||
startedAt: null,
|
||||
completedAt: new Date(),
|
||||
externalGameId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
const mockDb = makeUpsertDb(subGame);
|
||||
vi.mocked(database).mockReturnValue(mockDb as unknown as ReturnType<typeof database>);
|
||||
|
||||
const result = await upsertMatchSubGame({
|
||||
seasonMatchId: "match-1",
|
||||
gameNumber: 2,
|
||||
participant1Score: 3,
|
||||
participant2Score: 1,
|
||||
status: "complete",
|
||||
});
|
||||
|
||||
expect(result.gameLabel).toBeNull();
|
||||
expect(result.gameNumber).toBe(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -23,3 +23,4 @@ export * from "./participant";
|
|||
export * from "./tournament-result";
|
||||
export * from "./canonical-surface-elo";
|
||||
export * from "./canonical-golf-skills";
|
||||
export * from "./season-match";
|
||||
|
|
|
|||
232
app/models/season-match.ts
Normal file
232
app/models/season-match.ts
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import { and, asc, eq, isNotNull, sql } from "drizzle-orm";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
|
||||
export type SeasonMatch = typeof schema.seasonMatches.$inferSelect;
|
||||
export type MatchSubGame = typeof schema.matchSubGames.$inferSelect;
|
||||
export type MatchStatus = (typeof schema.matchStatusEnum.enumValues)[number];
|
||||
|
||||
export interface UpsertSeasonMatchData {
|
||||
sportsSeasonId: string;
|
||||
scoringEventId?: string | null;
|
||||
participant1Id?: string | null;
|
||||
participant2Id?: string | null;
|
||||
winnerId?: string | null;
|
||||
participant1Score?: number | null;
|
||||
participant2Score?: number | null;
|
||||
matchStage?: number | null;
|
||||
matchRound?: number | null;
|
||||
matchday?: number | null;
|
||||
isSeries?: boolean;
|
||||
status: MatchStatus;
|
||||
scheduledAt?: Date | null;
|
||||
startedAt?: Date | null;
|
||||
completedAt?: Date | null;
|
||||
externalMatchId: string;
|
||||
}
|
||||
|
||||
export interface UpsertMatchSubGameData {
|
||||
seasonMatchId: string;
|
||||
gameNumber: number;
|
||||
gameLabel?: string | null;
|
||||
participant1Score?: number | null;
|
||||
participant2Score?: number | null;
|
||||
winnerId?: string | null;
|
||||
status: MatchStatus;
|
||||
startedAt?: Date | null;
|
||||
completedAt?: Date | null;
|
||||
externalGameId?: string | null;
|
||||
}
|
||||
|
||||
export async function upsertSeasonMatch(
|
||||
data: UpsertSeasonMatchData
|
||||
): Promise<SeasonMatch> {
|
||||
const db = database();
|
||||
const now = new Date();
|
||||
const [result] = await db
|
||||
.insert(schema.seasonMatches)
|
||||
.values({
|
||||
...data,
|
||||
isSeries: data.isSeries ?? false,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.seasonMatches.externalMatchId,
|
||||
targetWhere: isNotNull(schema.seasonMatches.externalMatchId),
|
||||
set: {
|
||||
participant1Id: sql`excluded.participant1_id`,
|
||||
participant2Id: sql`excluded.participant2_id`,
|
||||
winnerId: sql`excluded.winner_id`,
|
||||
participant1Score: sql`excluded.participant1_score`,
|
||||
participant2Score: sql`excluded.participant2_score`,
|
||||
matchStage: sql`excluded.match_stage`,
|
||||
matchRound: sql`excluded.match_round`,
|
||||
matchday: sql`excluded.matchday`,
|
||||
isSeries: sql`excluded.is_series`,
|
||||
status: sql`excluded.status`,
|
||||
scheduledAt: sql`excluded.scheduled_at`,
|
||||
startedAt: sql`excluded.started_at`,
|
||||
completedAt: sql`excluded.completed_at`,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function upsertSeasonMatchBulk(
|
||||
records: UpsertSeasonMatchData[]
|
||||
): Promise<SeasonMatch[]> {
|
||||
if (records.length === 0) return [];
|
||||
const db = database();
|
||||
const now = new Date();
|
||||
return await db
|
||||
.insert(schema.seasonMatches)
|
||||
.values(records.map((r) => ({ ...r, isSeries: r.isSeries ?? false, updatedAt: now })))
|
||||
.onConflictDoUpdate({
|
||||
target: schema.seasonMatches.externalMatchId,
|
||||
targetWhere: isNotNull(schema.seasonMatches.externalMatchId),
|
||||
set: {
|
||||
participant1Id: sql`excluded.participant1_id`,
|
||||
participant2Id: sql`excluded.participant2_id`,
|
||||
winnerId: sql`excluded.winner_id`,
|
||||
participant1Score: sql`excluded.participant1_score`,
|
||||
participant2Score: sql`excluded.participant2_score`,
|
||||
matchStage: sql`excluded.match_stage`,
|
||||
matchRound: sql`excluded.match_round`,
|
||||
matchday: sql`excluded.matchday`,
|
||||
isSeries: sql`excluded.is_series`,
|
||||
status: sql`excluded.status`,
|
||||
scheduledAt: sql`excluded.scheduled_at`,
|
||||
startedAt: sql`excluded.started_at`,
|
||||
completedAt: sql`excluded.completed_at`,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
}
|
||||
|
||||
export async function findSeasonMatchesBySportsSeasonId(
|
||||
sportsSeasonId: string,
|
||||
filters?: { status?: MatchStatus; matchStage?: number }
|
||||
) {
|
||||
const db = database();
|
||||
return await db.query.seasonMatches.findMany({
|
||||
where: and(
|
||||
eq(schema.seasonMatches.sportsSeasonId, sportsSeasonId),
|
||||
filters?.status ? eq(schema.seasonMatches.status, filters.status) : undefined,
|
||||
filters?.matchStage !== undefined
|
||||
? eq(schema.seasonMatches.matchStage, filters.matchStage)
|
||||
: undefined
|
||||
),
|
||||
orderBy: [
|
||||
asc(schema.seasonMatches.matchStage),
|
||||
asc(schema.seasonMatches.matchRound),
|
||||
asc(schema.seasonMatches.matchday),
|
||||
asc(schema.seasonMatches.scheduledAt),
|
||||
asc(schema.seasonMatches.createdAt),
|
||||
],
|
||||
with: {
|
||||
participant1: true,
|
||||
participant2: true,
|
||||
winner: true,
|
||||
subGames: { orderBy: [asc(schema.matchSubGames.gameNumber)] },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function findSeasonMatchesByScoringEventId(scoringEventId: string) {
|
||||
const db = database();
|
||||
return await db.query.seasonMatches.findMany({
|
||||
where: eq(schema.seasonMatches.scoringEventId, scoringEventId),
|
||||
orderBy: [
|
||||
asc(schema.seasonMatches.matchStage),
|
||||
asc(schema.seasonMatches.matchRound),
|
||||
asc(schema.seasonMatches.scheduledAt),
|
||||
asc(schema.seasonMatches.createdAt),
|
||||
],
|
||||
with: {
|
||||
participant1: true,
|
||||
participant2: true,
|
||||
winner: true,
|
||||
subGames: { orderBy: [asc(schema.matchSubGames.gameNumber)] },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function findSeasonMatchById(matchId: string) {
|
||||
const db = database();
|
||||
return await db.query.seasonMatches.findFirst({
|
||||
where: eq(schema.seasonMatches.id, matchId),
|
||||
with: {
|
||||
participant1: true,
|
||||
participant2: true,
|
||||
winner: true,
|
||||
subGames: { orderBy: [asc(schema.matchSubGames.gameNumber)] },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateSeasonMatch(
|
||||
matchId: string,
|
||||
data: Partial<{
|
||||
participant1Id: string | null;
|
||||
participant2Id: string | null;
|
||||
winnerId: string | null;
|
||||
participant1Score: number | null;
|
||||
participant2Score: number | null;
|
||||
status: MatchStatus;
|
||||
scheduledAt: Date | null;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
}>
|
||||
): Promise<SeasonMatch> {
|
||||
const db = database();
|
||||
const [result] = await db
|
||||
.update(schema.seasonMatches)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(schema.seasonMatches.id, matchId))
|
||||
.returning();
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function deleteSeasonMatch(matchId: string): Promise<void> {
|
||||
const db = database();
|
||||
await db
|
||||
.delete(schema.seasonMatches)
|
||||
.where(eq(schema.seasonMatches.id, matchId));
|
||||
}
|
||||
|
||||
export async function upsertMatchSubGame(
|
||||
data: UpsertMatchSubGameData
|
||||
): Promise<MatchSubGame> {
|
||||
const db = database();
|
||||
const now = new Date();
|
||||
const [result] = await db
|
||||
.insert(schema.matchSubGames)
|
||||
.values({ ...data, updatedAt: now })
|
||||
.onConflictDoUpdate({
|
||||
target: [schema.matchSubGames.seasonMatchId, schema.matchSubGames.gameNumber],
|
||||
set: {
|
||||
gameLabel: sql`excluded.game_label`,
|
||||
participant1Score: sql`excluded.participant1_score`,
|
||||
participant2Score: sql`excluded.participant2_score`,
|
||||
winnerId: sql`excluded.winner_id`,
|
||||
status: sql`excluded.status`,
|
||||
startedAt: sql`excluded.started_at`,
|
||||
completedAt: sql`excluded.completed_at`,
|
||||
externalGameId: sql`excluded.external_game_id`,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function findMatchSubGamesByMatchId(matchId: string): Promise<MatchSubGame[]> {
|
||||
const db = database();
|
||||
return await db.query.matchSubGames.findMany({
|
||||
where: eq(schema.matchSubGames.seasonMatchId, matchId),
|
||||
orderBy: [asc(schema.matchSubGames.gameNumber)],
|
||||
});
|
||||
}
|
||||
|
|
@ -110,6 +110,10 @@ export default [
|
|||
"sports-seasons/:id/events/:eventId/cs2-setup",
|
||||
"routes/admin.sports-seasons.$id.events.$eventId.cs2-setup.tsx"
|
||||
),
|
||||
route(
|
||||
"sports-seasons/:id/events/:eventId/swiss-matches",
|
||||
"routes/admin.sports-seasons.$id.events.$eventId.swiss-matches.tsx"
|
||||
),
|
||||
route(
|
||||
"sports-seasons/:id/expected-values",
|
||||
"routes/admin.sports-seasons.$id.expected-values.tsx"
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
markCs2StageEliminations,
|
||||
clearCs2StageAssignments,
|
||||
} from '~/models/cs2-major-stage';
|
||||
import { findSeasonMatchesByScoringEventId } from '~/models/season-match';
|
||||
import { Button } from '~/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
|
|
@ -19,7 +20,7 @@ import {
|
|||
CardTitle,
|
||||
} from '~/components/ui/card';
|
||||
import { Badge } from '~/components/ui/badge';
|
||||
import { ArrowLeft, Save, Trash2, CheckCircle2 } from 'lucide-react';
|
||||
import { ArrowLeft, Save, Trash2, CheckCircle2, RefreshCw } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||
|
|
@ -38,12 +39,13 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
if (!sportsSeason) throw new Response('Sports season not found', { status: 404 });
|
||||
if (!event) throw new Response('Event not found', { status: 404 });
|
||||
|
||||
const [participants, stageResults] = await Promise.all([
|
||||
const [participants, stageResults, seasonMatches] = await Promise.all([
|
||||
findParticipantsBySportsSeasonId(sportsSeasonId),
|
||||
getCs2StageResultsForEvent(eventId),
|
||||
findSeasonMatchesByScoringEventId(eventId),
|
||||
]);
|
||||
|
||||
return { sportsSeason, event, participants, stageResults };
|
||||
return { sportsSeason, event, participants, stageResults, seasonMatches };
|
||||
}
|
||||
|
||||
interface ActionData {
|
||||
|
|
@ -113,8 +115,22 @@ const RECORD_OPTIONS = [
|
|||
{ value: '0', label: '0-3 (earliest exit)' },
|
||||
];
|
||||
|
||||
const MATCH_STATUS_STYLES: Record<string, string> = {
|
||||
scheduled: 'text-muted-foreground',
|
||||
in_progress: 'text-amber-400 font-medium',
|
||||
complete: 'text-emerald-400',
|
||||
canceled: 'text-destructive line-through',
|
||||
postponed: 'text-muted-foreground italic',
|
||||
};
|
||||
|
||||
const STAGE_NAMES: Record<number, string> = {
|
||||
1: 'Opening Stage',
|
||||
2: 'Challengers Stage',
|
||||
3: 'Legends Stage',
|
||||
};
|
||||
|
||||
export default function AdminCs2Setup() {
|
||||
const { sportsSeason, event, participants, stageResults } = useLoaderData<typeof loader>();
|
||||
const { sportsSeason, event, participants, stageResults, seasonMatches } = useLoaderData<typeof loader>();
|
||||
const actionData = useActionData<ActionData>();
|
||||
const navigation = useNavigation();
|
||||
const isSubmitting = navigation.state === 'submitting';
|
||||
|
|
@ -313,6 +329,80 @@ export default function AdminCs2Setup() {
|
|||
</Card>
|
||||
)}
|
||||
|
||||
{/* Swiss Rounds */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
Swiss Rounds
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value="sync-matches" />
|
||||
<Button type="submit" variant="outline" size="sm" disabled>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Sync from API
|
||||
</Button>
|
||||
</Form>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Match-level results imported via API (Phase 2). "Sync from API" will be enabled once a PandaScore tournament ID is configured.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{seasonMatches.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No match data yet. Sync from the API or use the <Link to={`/admin/sports-seasons/${sportsSeason.id}/events/${event.id}/swiss-matches`} className="underline">manual match entry</Link> page.</p>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{[1, 2, 3].map(stage => {
|
||||
const stageMatches = seasonMatches.filter(m => m.matchStage === stage);
|
||||
if (stageMatches.length === 0) return null;
|
||||
|
||||
// Group by round
|
||||
const rounds = new Map<number, typeof stageMatches>();
|
||||
for (const m of stageMatches) {
|
||||
const r = m.matchRound ?? 0;
|
||||
if (!rounds.has(r)) rounds.set(r, []);
|
||||
rounds.get(r)!.push(m);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={stage}>
|
||||
<h3 className="text-sm font-semibold mb-3">
|
||||
Stage {stage} — {STAGE_NAMES[stage] ?? ''}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{[...rounds.entries()].sort(([a], [b]) => a - b).map(([round, matches]) => (
|
||||
<div key={round}>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wide">
|
||||
Round {round}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{matches.map(m => (
|
||||
<div key={m.id} className={`flex items-center gap-3 text-sm py-1 ${MATCH_STATUS_STYLES[m.status] ?? ''}`}>
|
||||
<span className="flex-1">{m.participant1?.name ?? '?'}</span>
|
||||
<span className="tabular-nums font-medium">
|
||||
{m.status === 'complete' || m.status === 'in_progress'
|
||||
? `${m.participant1Score ?? '–'} – ${m.participant2Score ?? '–'}`
|
||||
: 'vs'}
|
||||
</span>
|
||||
<span className="flex-1 text-right">{m.participant2?.name ?? '?'}</span>
|
||||
{m.subGames.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
[{m.subGames.map(g => `${g.participant1Score}-${g.participant2Score}`).join(', ')}]
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Champions Stage link */}
|
||||
{stageResults.length > 0 && (
|
||||
<Card>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,376 @@
|
|||
import { Form, useLoaderData, useActionData, useNavigation, Link } from 'react-router';
|
||||
import type { Route } from './+types/admin.sports-seasons.$id.events.$eventId.swiss-matches';
|
||||
|
||||
import { findSportsSeasonById } from '~/models/sports-season';
|
||||
import { getScoringEventById } from '~/models/scoring-event';
|
||||
import { findParticipantsBySportsSeasonId } from '~/models/season-participant';
|
||||
import {
|
||||
findSeasonMatchesByScoringEventId,
|
||||
upsertSeasonMatch,
|
||||
updateSeasonMatch,
|
||||
deleteSeasonMatch,
|
||||
upsertMatchSubGame,
|
||||
findMatchSubGamesByMatchId,
|
||||
} from '~/models/season-match';
|
||||
import type { MatchStatus } from '~/models/season-match';
|
||||
import { Button } from '~/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '~/components/ui/card';
|
||||
import { Badge } from '~/components/ui/badge';
|
||||
import { ArrowLeft, Plus, Save, Trash2 } from 'lucide-react';
|
||||
|
||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||
return [{ title: `Swiss Matches — ${data?.event?.name ?? "Event"} - Brackt Admin` }];
|
||||
}
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
const sportsSeasonId = params.id;
|
||||
const eventId = params.eventId;
|
||||
|
||||
const [sportsSeason, event] = await Promise.all([
|
||||
findSportsSeasonById(sportsSeasonId),
|
||||
getScoringEventById(eventId),
|
||||
]);
|
||||
|
||||
if (!sportsSeason) throw new Response('Sports season not found', { status: 404 });
|
||||
if (!event) throw new Response('Event not found', { status: 404 });
|
||||
|
||||
const [participants, matches] = await Promise.all([
|
||||
findParticipantsBySportsSeasonId(sportsSeasonId),
|
||||
findSeasonMatchesByScoringEventId(eventId),
|
||||
]);
|
||||
|
||||
return { sportsSeason, event, participants, matches };
|
||||
}
|
||||
|
||||
interface ActionData {
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export async function action({ request, params }: Route.ActionArgs) {
|
||||
const eventId = params.eventId;
|
||||
const sportsSeasonId = params.id;
|
||||
const formData = await request.formData();
|
||||
const intent = formData.get('intent') as string;
|
||||
|
||||
if (intent === 'create-match') {
|
||||
const participant1Id = formData.get('participant1Id') as string;
|
||||
const participant2Id = formData.get('participant2Id') as string;
|
||||
const matchStage = parseInt(formData.get('matchStage') as string, 10);
|
||||
const matchRound = parseInt(formData.get('matchRound') as string, 10);
|
||||
const isSeries = formData.get('isSeries') === 'true';
|
||||
|
||||
if (!participant1Id || !participant2Id || isNaN(matchStage) || isNaN(matchRound)) {
|
||||
return { success: false, message: 'Missing required fields.' };
|
||||
}
|
||||
|
||||
const externalMatchId = `manual_${eventId}_s${matchStage}_r${matchRound}_${participant1Id}_${participant2Id}`;
|
||||
await upsertSeasonMatch({
|
||||
sportsSeasonId,
|
||||
scoringEventId: eventId,
|
||||
participant1Id,
|
||||
participant2Id,
|
||||
matchStage,
|
||||
matchRound,
|
||||
isSeries,
|
||||
status: 'scheduled',
|
||||
externalMatchId,
|
||||
});
|
||||
return { success: true, message: 'Match created.' };
|
||||
}
|
||||
|
||||
if (intent === 'update-result') {
|
||||
const matchId = formData.get('matchId') as string;
|
||||
const participant1Score = formData.get('participant1Score');
|
||||
const participant2Score = formData.get('participant2Score');
|
||||
const winnerId = formData.get('winnerId') as string | null;
|
||||
const status = formData.get('status') as MatchStatus;
|
||||
|
||||
const p1Score = participant1Score ? parseInt(participant1Score as string, 10) : null;
|
||||
const p2Score = participant2Score ? parseInt(participant2Score as string, 10) : null;
|
||||
|
||||
await updateSeasonMatch(matchId, {
|
||||
participant1Score: p1Score,
|
||||
participant2Score: p2Score,
|
||||
winnerId: winnerId || null,
|
||||
status,
|
||||
completedAt: status === 'complete' ? new Date() : null,
|
||||
});
|
||||
|
||||
// Handle sub-games (maps) if provided
|
||||
const mapCount = parseInt(formData.get('mapCount') as string ?? '0', 10);
|
||||
for (let i = 1; i <= mapCount; i++) {
|
||||
const mapLabel = formData.get(`map${i}_label`) as string | null;
|
||||
const map1Score = formData.get(`map${i}_p1`) as string | null;
|
||||
const map2Score = formData.get(`map${i}_p2`) as string | null;
|
||||
if (map1Score !== null && map2Score !== null) {
|
||||
await upsertMatchSubGame({
|
||||
seasonMatchId: matchId,
|
||||
gameNumber: i,
|
||||
gameLabel: mapLabel || null,
|
||||
participant1Score: parseInt(map1Score, 10),
|
||||
participant2Score: parseInt(map2Score, 10),
|
||||
status: 'complete',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, message: 'Result saved.' };
|
||||
}
|
||||
|
||||
if (intent === 'delete-match') {
|
||||
const matchId = formData.get('matchId') as string;
|
||||
await deleteSeasonMatch(matchId);
|
||||
return { success: true, message: 'Match deleted.' };
|
||||
}
|
||||
|
||||
return { success: false, message: 'Unknown action.' };
|
||||
}
|
||||
|
||||
const STAGE_NAMES: Record<number, string> = {
|
||||
1: 'Opening Stage',
|
||||
2: 'Challengers Stage',
|
||||
3: 'Legends Stage',
|
||||
};
|
||||
|
||||
const STATUS_BADGE: Record<MatchStatus, { label: string; variant: 'default' | 'secondary' | 'outline' | 'destructive' }> = {
|
||||
scheduled: { label: 'Scheduled', variant: 'outline' },
|
||||
in_progress: { label: 'Live', variant: 'default' },
|
||||
complete: { label: 'Complete', variant: 'secondary' },
|
||||
canceled: { label: 'Canceled', variant: 'destructive' },
|
||||
postponed: { label: 'Postponed', variant: 'outline' },
|
||||
};
|
||||
|
||||
export default function AdminSwissMatches() {
|
||||
const { sportsSeason, event, participants, matches } = useLoaderData<typeof loader>();
|
||||
const actionData = useActionData<ActionData>();
|
||||
const navigation = useNavigation();
|
||||
const isSubmitting = navigation.state === 'submitting';
|
||||
|
||||
// Group matches by stage → round
|
||||
const byStage = new Map<number, Map<number, typeof matches>>();
|
||||
for (const m of matches) {
|
||||
const stage = m.matchStage ?? 0;
|
||||
const round = m.matchRound ?? 0;
|
||||
if (!byStage.has(stage)) byStage.set(stage, new Map());
|
||||
const stageMap = byStage.get(stage)!;
|
||||
if (!stageMap.has(round)) stageMap.set(round, []);
|
||||
stageMap.get(round)!.push(m);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8">
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
to={`/admin/sports-seasons/${sportsSeason.id}/events/${event.id}/cs2-setup`}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground mb-4"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to CS2 Setup
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold mb-1">Swiss Match Entry</h1>
|
||||
<p className="text-muted-foreground">{event.name} — {sportsSeason.name}</p>
|
||||
</div>
|
||||
|
||||
{actionData?.message && (
|
||||
<div className={`mb-4 p-3 rounded-md text-sm ${actionData.success ? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/30' : 'bg-destructive/10 text-destructive border border-destructive/30'}`}>
|
||||
{actionData.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create new match */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Add Match</CardTitle>
|
||||
<CardDescription>Manually add a Swiss round match.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form method="post" className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<input type="hidden" name="intent" value="create-match" />
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Stage</label>
|
||||
<select name="matchStage" className="h-9 w-full text-sm rounded-md border border-input bg-background px-2">
|
||||
<option value="1">Stage 1 — Opening</option>
|
||||
<option value="2">Stage 2 — Challengers</option>
|
||||
<option value="3">Stage 3 — Legends</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Round</label>
|
||||
<select name="matchRound" className="h-9 w-full text-sm rounded-md border border-input bg-background px-2">
|
||||
{[1, 2, 3, 4, 5].map(r => <option key={r} value={r}>Round {r}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Format</label>
|
||||
<select name="isSeries" className="h-9 w-full text-sm rounded-md border border-input bg-background px-2">
|
||||
<option value="false">Bo1</option>
|
||||
<option value="true">Bo3</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Team 1</label>
|
||||
<select name="participant1Id" className="h-9 w-full text-sm rounded-md border border-input bg-background px-2">
|
||||
<option value="">— select —</option>
|
||||
{participants.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Team 2</label>
|
||||
<select name="participant2Id" className="h-9 w-full text-sm rounded-md border border-input bg-background px-2">
|
||||
<option value="">— select —</option>
|
||||
{participants.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Match
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Existing matches grouped by stage/round */}
|
||||
{matches.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No matches yet.</p>
|
||||
) : (
|
||||
[...byStage.entries()].sort(([a], [b]) => a - b).map(([stage, rounds]) => (
|
||||
<Card key={stage} className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Stage {stage} — {STAGE_NAMES[stage] ?? ''}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{[...rounds.entries()].sort(([a], [b]) => a - b).map(([round, roundMatches]) => (
|
||||
<div key={round}>
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">Round {round}</p>
|
||||
<div className="space-y-4">
|
||||
{roundMatches.map(match => (
|
||||
<div key={match.id} className="border border-border rounded-md p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium">
|
||||
{match.participant1?.name ?? '?'} vs {match.participant2?.name ?? '?'}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={STATUS_BADGE[match.status]?.variant ?? 'outline'}>
|
||||
{STATUS_BADGE[match.status]?.label ?? match.status}
|
||||
</Badge>
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value="delete-match" />
|
||||
<input type="hidden" name="matchId" value={match.id} />
|
||||
<Button type="submit" variant="ghost" size="sm" className="text-destructive h-7 px-2" disabled={isSubmitting}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
<Form method="post" className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<input type="hidden" name="intent" value="update-result" />
|
||||
<input type="hidden" name="matchId" value={match.id} />
|
||||
<input type="hidden" name="mapCount" value={match.isSeries ? '3' : '0'} />
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">{match.participant1?.name ?? 'P1'} score</label>
|
||||
<input
|
||||
type="number"
|
||||
name="participant1Score"
|
||||
defaultValue={match.participant1Score ?? ''}
|
||||
min="0"
|
||||
className="h-8 w-full text-sm rounded-md border border-input bg-background px-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">{match.participant2?.name ?? 'P2'} score</label>
|
||||
<input
|
||||
type="number"
|
||||
name="participant2Score"
|
||||
defaultValue={match.participant2Score ?? ''}
|
||||
min="0"
|
||||
className="h-8 w-full text-sm rounded-md border border-input bg-background px-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">Winner</label>
|
||||
<select
|
||||
name="winnerId"
|
||||
defaultValue={match.winnerId ?? ''}
|
||||
className="h-8 w-full text-sm rounded-md border border-input bg-background px-2"
|
||||
>
|
||||
<option value="">— none —</option>
|
||||
<option value={match.participant1Id ?? ''}>{match.participant1?.name ?? 'P1'}</option>
|
||||
<option value={match.participant2Id ?? ''}>{match.participant2?.name ?? 'P2'}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">Status</label>
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={match.status}
|
||||
className="h-8 w-full text-sm rounded-md border border-input bg-background px-2"
|
||||
>
|
||||
<option value="scheduled">Scheduled</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="complete">Complete</option>
|
||||
<option value="canceled">Canceled</option>
|
||||
<option value="postponed">Postponed</option>
|
||||
</select>
|
||||
</div>
|
||||
{match.isSeries && (
|
||||
<>
|
||||
{[1, 2, 3].map(mapNum => {
|
||||
const subGame = match.subGames.find(g => g.gameNumber === mapNum);
|
||||
return (
|
||||
<div key={mapNum} className="col-span-2 grid grid-cols-3 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
name={`map${mapNum}_label`}
|
||||
placeholder={`Map ${mapNum} name`}
|
||||
defaultValue={subGame?.gameLabel ?? ''}
|
||||
className="h-8 text-xs rounded-md border border-input bg-background px-2"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
name={`map${mapNum}_p1`}
|
||||
placeholder="P1"
|
||||
defaultValue={subGame?.participant1Score ?? ''}
|
||||
min="0"
|
||||
className="h-8 text-xs rounded-md border border-input bg-background px-2"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
name={`map${mapNum}_p2`}
|
||||
placeholder="P2"
|
||||
defaultValue={subGame?.participant2Score ?? ''}
|
||||
min="0"
|
||||
className="h-8 text-xs rounded-md border border-input bg-background px-2"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
<div className="col-span-2 md:col-span-4 flex justify-end">
|
||||
<Button type="submit" size="sm" disabled={isSubmitting}>
|
||||
<Save className="h-3 w-3 mr-1" />
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -371,6 +371,14 @@ export const sports = pgTable("sports", {
|
|||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const matchStatusEnum = pgEnum("match_status", [
|
||||
"scheduled",
|
||||
"in_progress",
|
||||
"complete",
|
||||
"canceled",
|
||||
"postponed",
|
||||
]);
|
||||
|
||||
export const sportsSeasons = pgTable("sports_seasons", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
sportId: uuid("sport_id")
|
||||
|
|
@ -384,6 +392,7 @@ export const sportsSeasons = pgTable("sports_seasons", {
|
|||
startDate: date("start_date"),
|
||||
endDate: date("end_date"),
|
||||
status: sportsSeasonStatusEnum("status").notNull().default("upcoming"),
|
||||
externalSeasonId: varchar("external_season_id", { length: 255 }),
|
||||
scoringType: scoringTypeEnum("scoring_type").notNull(),
|
||||
// New scoring pattern field (replaces/augments scoringType)
|
||||
scoringPattern: scoringPatternEnum("scoring_pattern"),
|
||||
|
|
@ -1004,6 +1013,7 @@ export const sportsSeasonsRelations = relations(sportsSeasons, ({ one, many }) =
|
|||
participantResults: many(seasonParticipantResults),
|
||||
regularSeasonStandings: many(regularSeasonStandings),
|
||||
pendingStandingsMappings: many(pendingStandingsMappings),
|
||||
seasonMatches: many(seasonMatches),
|
||||
}));
|
||||
|
||||
export const simulatorProfilesRelations = relations(simulatorProfiles, ({ many }) => ({
|
||||
|
|
@ -1238,6 +1248,7 @@ export const scoringEventsRelations = relations(scoringEvents, ({ one, many }) =
|
|||
playoffMatches: many(playoffMatches),
|
||||
tournamentGroups: many(tournamentGroups),
|
||||
cs2MajorStageResults: many(cs2MajorStageResults),
|
||||
seasonMatches: many(seasonMatches),
|
||||
}));
|
||||
|
||||
export const eventResultsRelations = relations(eventResults, ({ one }) => ({
|
||||
|
|
@ -1619,6 +1630,111 @@ export const cs2MajorStageResultsRelations = relations(cs2MajorStageResults, ({
|
|||
}),
|
||||
}));
|
||||
|
||||
// ─── Season Matches (Generic) ──────────────────────────────────────────────────
|
||||
// Generic match records for regular season games, Swiss rounds, group stage
|
||||
// fixtures, and any other "two-participant matchup with a score" format.
|
||||
//
|
||||
// CS2 Swiss: match_stage (1-3) + match_round (1-5), is_series = true
|
||||
// MLB/NBA etc: match_stage/match_round = NULL, matchday = NULL or week number
|
||||
// EPL/MLS: matchday = fixture week, match_stage/match_round = NULL
|
||||
//
|
||||
// Playoff bracket matches (with bracket progression logic) stay in playoff_matches.
|
||||
|
||||
export const seasonMatches = pgTable("season_matches", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
sportsSeasonId: uuid("sports_season_id")
|
||||
.notNull()
|
||||
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
|
||||
scoringEventId: uuid("scoring_event_id")
|
||||
.references(() => scoringEvents.id, { onDelete: "cascade" }),
|
||||
participant1Id: uuid("participant1_id")
|
||||
.references(() => seasonParticipants.id, { onDelete: "set null" }),
|
||||
participant2Id: uuid("participant2_id")
|
||||
.references(() => seasonParticipants.id, { onDelete: "set null" }),
|
||||
winnerId: uuid("winner_id")
|
||||
.references(() => seasonParticipants.id, { onDelete: "set null" }),
|
||||
participant1Score: integer("participant1_score"),
|
||||
participant2Score: integer("participant2_score"),
|
||||
matchStage: integer("match_stage"), // CS2: 1/2/3 for Opening/Challengers/Legends
|
||||
matchRound: integer("match_round"), // CS2: round within stage (1-5)
|
||||
matchday: integer("matchday"), // EPL/MLS fixture week
|
||||
isSeries: boolean("is_series").notNull().default(false),
|
||||
status: matchStatusEnum("status").notNull().default("scheduled"),
|
||||
scheduledAt: timestamp("scheduled_at"),
|
||||
startedAt: timestamp("started_at"),
|
||||
completedAt: timestamp("completed_at"),
|
||||
externalMatchId: varchar("external_match_id", { length: 255 }),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
}, (t) => [
|
||||
uniqueIndex("season_matches_external_id_unique")
|
||||
.on(t.externalMatchId)
|
||||
.where(sql`${t.externalMatchId} IS NOT NULL`),
|
||||
index("season_matches_sports_season_status_idx").on(t.sportsSeasonId, t.status),
|
||||
index("season_matches_sports_season_stage_round_idx").on(t.sportsSeasonId, t.matchStage, t.matchRound),
|
||||
index("season_matches_scoring_event_idx").on(t.scoringEventId),
|
||||
]);
|
||||
|
||||
export const matchSubGames = pgTable("match_sub_games", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
seasonMatchId: uuid("season_match_id")
|
||||
.notNull()
|
||||
.references(() => seasonMatches.id, { onDelete: "cascade" }),
|
||||
gameNumber: integer("game_number").notNull(),
|
||||
gameLabel: varchar("game_label", { length: 100 }), // CS2 map name, "Game 1", etc.
|
||||
participant1Score: integer("participant1_score"),
|
||||
participant2Score: integer("participant2_score"),
|
||||
winnerId: uuid("winner_id")
|
||||
.references(() => seasonParticipants.id, { onDelete: "set null" }),
|
||||
status: matchStatusEnum("status").notNull().default("scheduled"),
|
||||
startedAt: timestamp("started_at"),
|
||||
completedAt: timestamp("completed_at"),
|
||||
externalGameId: varchar("external_game_id", { length: 255 }),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
}, (t) => [
|
||||
uniqueIndex("match_sub_games_match_game_number_unique").on(t.seasonMatchId, t.gameNumber),
|
||||
]);
|
||||
|
||||
export const seasonMatchesRelations = relations(seasonMatches, ({ one, many }) => ({
|
||||
sportsSeason: one(sportsSeasons, {
|
||||
fields: [seasonMatches.sportsSeasonId],
|
||||
references: [sportsSeasons.id],
|
||||
}),
|
||||
scoringEvent: one(scoringEvents, {
|
||||
fields: [seasonMatches.scoringEventId],
|
||||
references: [scoringEvents.id],
|
||||
}),
|
||||
participant1: one(seasonParticipants, {
|
||||
fields: [seasonMatches.participant1Id],
|
||||
references: [seasonParticipants.id],
|
||||
relationName: "seasonMatchParticipant1",
|
||||
}),
|
||||
participant2: one(seasonParticipants, {
|
||||
fields: [seasonMatches.participant2Id],
|
||||
references: [seasonParticipants.id],
|
||||
relationName: "seasonMatchParticipant2",
|
||||
}),
|
||||
winner: one(seasonParticipants, {
|
||||
fields: [seasonMatches.winnerId],
|
||||
references: [seasonParticipants.id],
|
||||
relationName: "seasonMatchWinner",
|
||||
}),
|
||||
subGames: many(matchSubGames),
|
||||
}));
|
||||
|
||||
export const matchSubGamesRelations = relations(matchSubGames, ({ one }) => ({
|
||||
match: one(seasonMatches, {
|
||||
fields: [matchSubGames.seasonMatchId],
|
||||
references: [seasonMatches.id],
|
||||
}),
|
||||
winner: one(seasonParticipants, {
|
||||
fields: [matchSubGames.winnerId],
|
||||
references: [seasonParticipants.id],
|
||||
relationName: "matchSubGameWinner",
|
||||
}),
|
||||
}));
|
||||
|
||||
// ─── Commissioner Audit Log ────────────────────────────────────────────────────
|
||||
// Immutable log of significant commissioner/admin actions per season.
|
||||
// Readable by all league members for transparency; writable only by the
|
||||
|
|
|
|||
255
docs/plans/match-sync-display.md
Normal file
255
docs/plans/match-sync-display.md
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
# Plan: Generalized Match Import, Swiss Stage Tracking, Live Scores, and Display System
|
||||
|
||||
## Context
|
||||
|
||||
The current system tracks tournament stage entry/exit manually with no match-level data, no automated schedule/result imports, and no public tournament display. The goal is a sport-agnostic match sync infrastructure (usable for MLB schedules, CS2 Swiss rounds, EPL fixtures, etc.) with CS2 as the first implementation target.
|
||||
|
||||
**Scope decision**: The new `season_matches` table is generic from day one. CS2 Swiss uses nullable `match_stage`/`match_round` columns; MLB just leaves those NULL. Playoff bracket matches remain in the existing `playoff_matches` table (already works well with complex progression logic). This avoids proliferating per-sport match tables.
|
||||
|
||||
---
|
||||
|
||||
## Status
|
||||
|
||||
- [ ] Phase 1 — Generic Data Model + CS2 Admin UI
|
||||
- [ ] Phase 2 — Generic Match Sync Adapter + Cron Job
|
||||
- [ ] Phase 3 — Public Tournament & Schedule Display + Live Scores
|
||||
- [ ] Phase 4 — Automated Playoff Bracket Updates (future)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Generic Data Model + CS2 Admin UI (no external API)
|
||||
|
||||
### 1a. Schema additions (`database/schema.ts`)
|
||||
|
||||
**New enum**: `matchStatusEnum` = `["scheduled", "in_progress", "complete", "canceled", "postponed"]`
|
||||
|
||||
**New table: `season_matches`** — generic match record for regular season, Swiss rounds, group stages, fixtures
|
||||
```
|
||||
id (uuid PK)
|
||||
sports_season_id (uuid FK → sportsSeasons, onDelete: cascade)
|
||||
scoring_event_id (uuid FK → scoringEvents, nullable — set for CS2 stage matches, null for MLB regular season)
|
||||
participant1_id (uuid FK → seasonParticipants, nullable)
|
||||
participant2_id (uuid FK → seasonParticipants, nullable)
|
||||
winner_id (uuid FK → seasonParticipants, nullable)
|
||||
participant1_score (integer nullable)
|
||||
participant2_score (integer nullable)
|
||||
match_stage (integer nullable) — CS2: 1/2/3 for Opening/Challengers/Legends; NULL for MLB
|
||||
match_round (integer nullable) — CS2: round within stage (1-5); MLB: series game number or NULL
|
||||
matchday (integer nullable) — EPL/MLS fixture week; NULL for CS2
|
||||
is_series (boolean default false) — true for Bo3/Bo5 CS2 matches
|
||||
status (matchStatusEnum default 'scheduled')
|
||||
scheduled_at (timestamp nullable)
|
||||
started_at (timestamp nullable)
|
||||
completed_at (timestamp nullable)
|
||||
external_match_id (varchar 255 nullable) — API ID for upsert conflict key
|
||||
created_at, updated_at (timestamps)
|
||||
```
|
||||
Indexes: unique on `external_match_id` (partial, WHERE NOT NULL), index on `(sports_season_id, status)`, index on `(sports_season_id, match_stage, match_round)`, index on `(scoring_event_id)`.
|
||||
|
||||
**New table: `match_sub_games`** — per-sub-game scores (CS2 maps, MLB innings if desired, etc.)
|
||||
```
|
||||
id (uuid PK)
|
||||
season_match_id (uuid FK → seasonMatches, onDelete: cascade)
|
||||
game_number (integer) — map 1/2/3 for CS2, game 1-7 for series, inning for baseball
|
||||
game_label (varchar 100 nullable) — CS2: "Mirage", "Inferno"; others: NULL or "Game 1"
|
||||
participant1_score (integer nullable)
|
||||
participant2_score (integer nullable)
|
||||
winner_id (uuid FK → seasonParticipants, nullable)
|
||||
status (matchStatusEnum default 'scheduled')
|
||||
started_at, completed_at (timestamps nullable)
|
||||
external_game_id (varchar 255 nullable)
|
||||
```
|
||||
Unique index on `(season_match_id, game_number)`.
|
||||
|
||||
**New column on `sportsSeasons`**: `external_season_id varchar(255) nullable` — links to the API's identifier for this season/tournament (PandaScore tournament ID for CS2, ESPN league season ID for MLB, etc.)
|
||||
|
||||
Add Drizzle `relations()` for both new tables following the existing patterns.
|
||||
|
||||
Run `npm run db:generate` after changes.
|
||||
|
||||
### 1b. New model: `app/models/season-match.ts`
|
||||
- `upsertSeasonMatch(data)` — conflict on `external_match_id`, update scores/status/timestamps
|
||||
- `findSeasonMatchesBySportsSeasonId(sportsSeasonId, filters?)` — joined with participant names
|
||||
- `findSeasonMatchesByScoringEventId(scoringEventId)` — for CS2 stage view, ordered stage → round
|
||||
- `findSeasonMatchesByStage(sportsSeasonId, stage)` — CS2 per-stage query
|
||||
- `upsertMatchSubGame(data)`
|
||||
- `findMatchSubGamesByMatchId(matchId)`
|
||||
|
||||
### 1c. Extend cs2-setup admin route
|
||||
File: `app/routes/admin.sports-seasons.$id.events.$eventId.cs2-setup.tsx`
|
||||
- Add loader query calling `findSeasonMatchesByScoringEventId`
|
||||
- Add read-only Swiss rounds section below existing stage assignment UI: per round shows `Team A (W-L) vs Team B (W-L) → maps [16-14, 14-16, 16-12]`
|
||||
- "Sync from API" button (fetcher POST to cron endpoint, wired in Phase 2)
|
||||
|
||||
### 1d. New admin route: manual match entry
|
||||
File: `app/routes/admin.sports-seasons.$id.events.$eventId.swiss-matches.tsx`
|
||||
Actions: `update-match-result`, `create-manual-match`, `delete-match` — manual fallback when API unavailable
|
||||
Register in `app/routes.ts` under admin layout.
|
||||
|
||||
### 1e. Tests
|
||||
- `app/models/__tests__/season-match.test.ts` — upsert conflict resolution, ordering, null stage/round handling
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Generic Match Sync Adapter + Cron Job
|
||||
|
||||
### External Data Sources
|
||||
- **CS2** (and future Dota2/LoL): **PandaScore** — free tier, 1,000 req/hour, no credit card needed. `PANDASCORE_API_KEY` env var.
|
||||
- **MLB/NBA/NFL/MLS/NHL etc.**: ESPN public API (already used for standings), or sport-specific APIs already in `standings-sync/`. Same free, no-auth pattern.
|
||||
|
||||
### 2a. New service directory: `app/services/match-sync/`
|
||||
|
||||
**`types.ts`** — generic adapter interface:
|
||||
```typescript
|
||||
interface MatchRecord {
|
||||
externalMatchId: string;
|
||||
team1ExternalId: string;
|
||||
team2ExternalId: string;
|
||||
team1Score: number | null;
|
||||
team2Score: number | null;
|
||||
winnerExternalId: string | null;
|
||||
status: "scheduled" | "in_progress" | "complete" | "canceled" | "postponed";
|
||||
scheduledAt: Date | null;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
matchStage?: number | null; // CS2: 1/2/3; omit for MLB
|
||||
matchRound?: number | null; // CS2: round within stage; omit for MLB
|
||||
matchday?: number | null; // EPL/MLS fixture week
|
||||
isSeries?: boolean;
|
||||
subGames?: SubGameRecord[];
|
||||
}
|
||||
interface SubGameRecord {
|
||||
externalGameId?: string;
|
||||
gameNumber: number;
|
||||
gameLabel?: string; // CS2 map name, etc.
|
||||
team1Score: number | null;
|
||||
team2Score: number | null;
|
||||
winnerExternalId?: string | null;
|
||||
status: "scheduled" | "in_progress" | "complete";
|
||||
}
|
||||
interface MatchSyncAdapter {
|
||||
fetchMatches(externalSeasonId: string): Promise<MatchRecord[]>;
|
||||
supportsLiveScores: boolean;
|
||||
}
|
||||
interface MatchSyncResult {
|
||||
created: number;
|
||||
updated: number;
|
||||
unchanged: number;
|
||||
unmatchedTeams: {externalId: string; name: string}[];
|
||||
errors: {externalMatchId: string; error: string}[];
|
||||
}
|
||||
```
|
||||
|
||||
**`pandascore.ts`** — `PandaScoreMatchSyncAdapter implements MatchSyncAdapter`. Calls PandaScore CS2 matches endpoint. Maps stage/round/game (map) fields.
|
||||
|
||||
**`espn-schedule.ts`** — `EspnScheduleAdapter implements MatchSyncAdapter` (Phase 2b). Calls ESPN schedule endpoints for MLB, NBA, MLS, etc. `team1Score`/`team2Score` from ESPN scores.
|
||||
|
||||
**`index.ts`** — `syncMatches(sportsSeasonId)` orchestrator, mirrors `syncStandings()` exactly:
|
||||
1. Load sportsSeason + sport, extract `externalSeasonId`
|
||||
2. `getMatchSyncAdapter(simulatorType)` → picks adapter
|
||||
3. Fetch matches, resolve participants via `externalId` → name fallback
|
||||
4. Bulk upsert into `season_matches` + `match_sub_games`
|
||||
5. Return `MatchSyncResult`
|
||||
|
||||
```typescript
|
||||
function getMatchSyncAdapter(simulatorType: string): MatchSyncAdapter {
|
||||
switch (simulatorType) {
|
||||
case "cs2_major_qualifying_points": return new PandaScoreMatchSyncAdapter();
|
||||
case "mlb_bracket": return new EspnScheduleAdapter("baseball/mlb");
|
||||
case "nba_bracket": return new EspnScheduleAdapter("basketball/nba");
|
||||
case "mls_bracket": return new EspnScheduleAdapter("soccer/mls");
|
||||
// ... expand as needed
|
||||
default: throw new Error(`No match sync adapter for "${simulatorType}"`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2b. New cron job: `app/routes/admin/jobs.sync-matches.ts`
|
||||
Exact same pattern as `jobs.sync-and-simulate.ts`:
|
||||
- `POST /admin/jobs/sync-matches` with `requireCronSecret` auth
|
||||
- Finds active sports seasons where `externalSeasonId IS NOT NULL`
|
||||
- Calls `syncMatches(season.id)` for each; triggers simulation if results changed
|
||||
- Returns `{ synced, unchanged, errors }`
|
||||
|
||||
Register in `app/routes.ts`.
|
||||
|
||||
### 2c. Admin form: add `external_season_id` field to sports season admin form
|
||||
|
||||
### 2d. Tests
|
||||
- `app/services/match-sync/__tests__/pandascore.test.ts`
|
||||
- `app/services/match-sync/__tests__/espn-schedule.test.ts`
|
||||
- `app/services/match-sync/__tests__/sync.test.ts` — idempotency, name matching, null stage/round handling
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Public Tournament & Schedule Display + Live Scores
|
||||
|
||||
### 3a. CS2 Tournament component: `app/components/scoring/Cs2TournamentBracket.tsx`
|
||||
Tab layout: **Swiss Stages** | **Champions Stage**
|
||||
- Swiss Stages tab: sub-tabs Stage 1 / 2 / 3. Each shows W-L standings table + rounds accordion. Each round: pairing, map scores, status badge.
|
||||
- Champions Stage tab: reuses existing `<PlayoffBracket />` with `bracketTemplateId="simple_8"`.
|
||||
- Status badges: `scheduled` (gray), `in_progress` (amber pulse), `complete` (green).
|
||||
|
||||
### 3b. Generic schedule component: `app/components/scoring/MatchSchedule.tsx`
|
||||
Reusable across all sports. Shows upcoming + completed matches with scores. Used for MLB schedule view, EPL fixtures, etc. Props: `matches: SeasonMatch[]`, `sport`.
|
||||
|
||||
### 3c. New public route: `app/routes/sports-seasons.$sportsSeasonId.tournament.tsx`
|
||||
URL: `/sports-seasons/:sportsSeasonId/tournament` — league-independent.
|
||||
Loader: fetches `season_matches` + playoff matches (Champions Stage) + cs2 stage results.
|
||||
CS2 renders `<Cs2TournamentBracket>`, other sports render `<MatchSchedule>` based on `simulatorType`.
|
||||
Live polling: if any match `in_progress`, `useRevalidator()` re-fetches every 30 seconds.
|
||||
|
||||
Register in `app/routes.ts` outside admin layout.
|
||||
|
||||
### 3d. Optional: Socket.IO push
|
||||
Emit `season-match-updated` from `syncMatches` when a match transitions to `in_progress` or `complete`. Phase 3 enhancement, deferred until polling is stable.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Automated Playoff Bracket Updates (future)
|
||||
|
||||
Playoff bracket matches (`playoff_matches`) can also be auto-synced using the same `MatchSyncAdapter` interface. This is deferred because playoff sync is more complex than schedule sync — it must trigger `processMatchResult()` and bracket progression logic correctly, not just upsert data.
|
||||
|
||||
**What's needed to enable this:**
|
||||
1. Add `external_match_id varchar(255) nullable` column to `playoff_matches` (one migration)
|
||||
2. Add `fetchPlayoffMatches(externalSeasonId)` to the adapter (or reuse `fetchMatches` with a `matchType` flag)
|
||||
3. Add a `syncPlayoffResults(sportsSeasonId)` path in `match-sync/index.ts` that writes to `playoff_matches` and calls the existing `setMatchWinner()` / `processMatchResult()` pipeline
|
||||
4. Handle idempotency — don't re-trigger scoring if winner is already set
|
||||
|
||||
The same `PandaScoreMatchSyncAdapter` and `EspnScheduleAdapter` used for schedule sync will handle playoff data too, since they already return match results regardless of format.
|
||||
|
||||
---
|
||||
|
||||
## What Does NOT Change (Phases 1–3)
|
||||
- `cs2_major_stage_results` — still the source of truth for CS2 stage entry/exit/placement
|
||||
- Fantasy scoring pipeline — fully untouched
|
||||
- `playoff_matches` / `playoff_match_games` — unchanged, still used for all bracket progression
|
||||
- Existing CS2 admin setup UI — works as before, just gains a new read-only section
|
||||
|
||||
---
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `database/schema.ts` | Add `matchStatusEnum`, `season_matches`, `match_sub_games` tables, `externalSeasonId` on sportsSeasons |
|
||||
| `app/models/season-match.ts` | New — all DB queries for new tables |
|
||||
| `app/services/match-sync/types.ts` | New — generic adapter interface |
|
||||
| `app/services/match-sync/pandascore.ts` | New — PandaScore CS2 implementation |
|
||||
| `app/services/match-sync/espn-schedule.ts` | New — ESPN schedule implementation (MLB etc.) |
|
||||
| `app/services/match-sync/index.ts` | New — orchestrator (mirrors standings-sync/index.ts) |
|
||||
| `app/routes/admin/jobs.sync-matches.ts` | New — cron job (mirrors jobs.sync-and-simulate.ts) |
|
||||
| `app/routes/admin.sports-seasons.$id.events.$eventId.cs2-setup.tsx` | Extend loader + Swiss rounds display section |
|
||||
| `app/routes/admin.sports-seasons.$id.events.$eventId.swiss-matches.tsx` | New — manual match entry admin route |
|
||||
| `app/components/scoring/Cs2TournamentBracket.tsx` | New — CS2 tournament display |
|
||||
| `app/components/scoring/MatchSchedule.tsx` | New — generic match schedule display |
|
||||
| `app/routes/sports-seasons.$sportsSeasonId.tournament.tsx` | New — public tournament/schedule route |
|
||||
| `app/routes.ts` | Register 3 new routes |
|
||||
|
||||
## Verification
|
||||
1. `npm run db:generate` produces migration for 2 new tables + column
|
||||
2. `npm run db:migrate` applies cleanly
|
||||
3. `npm run typecheck` passes
|
||||
4. `npm run test:run` passes including new model + adapter tests
|
||||
5. Manual: set `externalSeasonId` on a CS2 sports season, call `/admin/jobs/sync-matches`, verify `season_matches` rows created with stage/round populated
|
||||
6. Manual: navigate to `/sports-seasons/:id/tournament`, confirm CS2 Swiss stage display renders with rounds and map scores
|
||||
87
drizzle/0120_tidy_invaders.sql
Normal file
87
drizzle/0120_tidy_invaders.sql
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
CREATE TYPE "public"."match_status" AS ENUM('scheduled', 'in_progress', 'complete', 'canceled', 'postponed');--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "match_sub_games" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"season_match_id" uuid NOT NULL,
|
||||
"game_number" integer NOT NULL,
|
||||
"game_label" varchar(100),
|
||||
"participant1_score" integer,
|
||||
"participant2_score" integer,
|
||||
"winner_id" uuid,
|
||||
"status" "match_status" DEFAULT 'scheduled' NOT NULL,
|
||||
"started_at" timestamp,
|
||||
"completed_at" timestamp,
|
||||
"external_game_id" varchar(255),
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "season_matches" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"sports_season_id" uuid NOT NULL,
|
||||
"scoring_event_id" uuid,
|
||||
"participant1_id" uuid,
|
||||
"participant2_id" uuid,
|
||||
"winner_id" uuid,
|
||||
"participant1_score" integer,
|
||||
"participant2_score" integer,
|
||||
"match_stage" integer,
|
||||
"match_round" integer,
|
||||
"matchday" integer,
|
||||
"is_series" boolean DEFAULT false NOT NULL,
|
||||
"status" "match_status" DEFAULT 'scheduled' NOT NULL,
|
||||
"scheduled_at" timestamp,
|
||||
"started_at" timestamp,
|
||||
"completed_at" timestamp,
|
||||
"external_match_id" varchar(255),
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "sports_seasons" ADD COLUMN "external_season_id" varchar(255);--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "match_sub_games" ADD CONSTRAINT "match_sub_games_season_match_id_season_matches_id_fk" FOREIGN KEY ("season_match_id") REFERENCES "public"."season_matches"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "match_sub_games" ADD CONSTRAINT "match_sub_games_winner_id_season_participants_id_fk" FOREIGN KEY ("winner_id") REFERENCES "public"."season_participants"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "season_matches" ADD CONSTRAINT "season_matches_sports_season_id_sports_seasons_id_fk" FOREIGN KEY ("sports_season_id") REFERENCES "public"."sports_seasons"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "season_matches" ADD CONSTRAINT "season_matches_scoring_event_id_scoring_events_id_fk" FOREIGN KEY ("scoring_event_id") REFERENCES "public"."scoring_events"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "season_matches" ADD CONSTRAINT "season_matches_participant1_id_season_participants_id_fk" FOREIGN KEY ("participant1_id") REFERENCES "public"."season_participants"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "season_matches" ADD CONSTRAINT "season_matches_participant2_id_season_participants_id_fk" FOREIGN KEY ("participant2_id") REFERENCES "public"."season_participants"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "season_matches" ADD CONSTRAINT "season_matches_winner_id_season_participants_id_fk" FOREIGN KEY ("winner_id") REFERENCES "public"."season_participants"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "match_sub_games_match_game_number_unique" ON "match_sub_games" USING btree ("season_match_id","game_number");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "season_matches_external_id_unique" ON "season_matches" USING btree ("external_match_id") WHERE "season_matches"."external_match_id" IS NOT NULL;--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "season_matches_sports_season_status_idx" ON "season_matches" USING btree ("sports_season_id","status");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "season_matches_sports_season_stage_round_idx" ON "season_matches" USING btree ("sports_season_id","match_stage","match_round");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "season_matches_scoring_event_idx" ON "season_matches" USING btree ("scoring_event_id");
|
||||
6712
drizzle/meta/0120_snapshot.json
Normal file
6712
drizzle/meta/0120_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -841,6 +841,13 @@
|
|||
"when": 1781031439603,
|
||||
"tag": "0119_eminent_siren",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 120,
|
||||
"version": "7",
|
||||
"when": 1781294866660,
|
||||
"tag": "0120_tidy_invaders",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue