Remove sports_season_tournaments junction table — derive from scoring_events (#373)
The junction table was a redundant second source of truth: syncTournamentResults already fans out by querying scoring_events.tournamentId directly, so the table only served the admin UI and could drift out of sync (causing orphaned events). - Drop sports_season_tournaments table and all link/unlink admin actions - Add getTournamentsBySportsSeason / getSportsSeasonsByTournament helpers that derive the same information from scoring_events.tournamentId - Add "Add Existing Tournament" dropdown to the events admin page (qualifying_points seasons only) — selecting a tournament creates the scoring event in one step - Fix clone: scoring events now carry tournamentId, fixing a latent check-constraint violation when cloning qualifying_points seasons - Tournament admin page "Linked Sports Seasons" is now a read-only derived view Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ef7e098d68
commit
31b48f5a05
15 changed files with 5969 additions and 592 deletions
118
app/models/__tests__/scoring-event-tournament-helpers.test.ts
Normal file
118
app/models/__tests__/scoring-event-tournament-helpers.test.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
const mockFindMany = vi.fn();
|
||||||
|
|
||||||
|
const mockDb = {
|
||||||
|
query: {
|
||||||
|
scoringEvents: { findMany: mockFindMany },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("~/database/context", () => ({
|
||||||
|
database: () => mockDb,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("~/database/schema", () => ({
|
||||||
|
scoringEvents: {
|
||||||
|
sportsSeasonId: "se.sports_season_id",
|
||||||
|
tournamentId: "se.tournament_id",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("drizzle-orm", () => ({
|
||||||
|
eq: (col: unknown, val: unknown) => ({ type: "eq", col, val }),
|
||||||
|
and: (...args: unknown[]) => ({ type: "and", args }),
|
||||||
|
isNotNull: (col: unknown) => ({ type: "isNotNull", col }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
getTournamentsBySportsSeason,
|
||||||
|
getSportsSeasonsByTournament,
|
||||||
|
} from "../scoring-event";
|
||||||
|
|
||||||
|
const SEASON_ID = "ss-1";
|
||||||
|
const TOURNAMENT_ID_A = "t-a";
|
||||||
|
const TOURNAMENT_ID_B = "t-b";
|
||||||
|
|
||||||
|
const mockTournamentA = { id: TOURNAMENT_ID_A, name: "Wimbledon", year: 2026 };
|
||||||
|
const mockTournamentB = { id: TOURNAMENT_ID_B, name: "US Open", year: 2026 };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getTournamentsBySportsSeason", () => {
|
||||||
|
it("returns one row per unique tournament, deduplicating multiple events for the same tournament", async () => {
|
||||||
|
mockFindMany.mockResolvedValue([
|
||||||
|
{ id: "e1", sportsSeasonId: SEASON_ID, tournamentId: TOURNAMENT_ID_A, tournament: mockTournamentA },
|
||||||
|
{ id: "e2", sportsSeasonId: SEASON_ID, tournamentId: TOURNAMENT_ID_A, tournament: mockTournamentA },
|
||||||
|
{ id: "e3", sportsSeasonId: SEASON_ID, tournamentId: TOURNAMENT_ID_B, tournament: mockTournamentB },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await getTournamentsBySportsSeason(SEASON_ID);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].tournamentId).toBe(TOURNAMENT_ID_A);
|
||||||
|
expect(result[1].tournamentId).toBe(TOURNAMENT_ID_B);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array when no events have a tournamentId", async () => {
|
||||||
|
mockFindMany.mockResolvedValue([
|
||||||
|
{ id: "e1", sportsSeasonId: SEASON_ID, tournamentId: null, tournament: null },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await getTournamentsBySportsSeason(SEASON_ID);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array when season has no events", async () => {
|
||||||
|
mockFindMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await getTournamentsBySportsSeason(SEASON_ID);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const SEASON_A_ID = "ss-a";
|
||||||
|
const SEASON_B_ID = "ss-b";
|
||||||
|
|
||||||
|
const mockSeasonA = { id: SEASON_A_ID, name: "Golf June 2026", year: 2026, scoringType: "majors", status: "active", sport: { id: "sp-1", name: "Golf" } };
|
||||||
|
const mockSeasonB = { id: SEASON_B_ID, name: "Golf Sept 2026", year: 2026, scoringType: "majors", status: "upcoming", sport: { id: "sp-1", name: "Golf" } };
|
||||||
|
|
||||||
|
describe("getSportsSeasonsByTournament", () => {
|
||||||
|
it("returns one row per unique sports season, deduplicating multiple events in the same season", async () => {
|
||||||
|
mockFindMany.mockResolvedValue([
|
||||||
|
{ id: "e1", sportsSeasonId: SEASON_A_ID, tournamentId: TOURNAMENT_ID_A, sportsSeason: mockSeasonA },
|
||||||
|
{ id: "e2", sportsSeasonId: SEASON_A_ID, tournamentId: TOURNAMENT_ID_A, sportsSeason: mockSeasonA },
|
||||||
|
{ id: "e3", sportsSeasonId: SEASON_B_ID, tournamentId: TOURNAMENT_ID_A, sportsSeason: mockSeasonB },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await getSportsSeasonsByTournament(TOURNAMENT_ID_A);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].sportsSeasonId).toBe(SEASON_A_ID);
|
||||||
|
expect(result[1].sportsSeasonId).toBe(SEASON_B_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array when no sports seasons use this tournament", async () => {
|
||||||
|
mockFindMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await getSportsSeasonsByTournament(TOURNAMENT_ID_A);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes tournamentId to the query", async () => {
|
||||||
|
mockFindMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await getSportsSeasonsByTournament(TOURNAMENT_ID_A);
|
||||||
|
|
||||||
|
expect(mockFindMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({ type: "eq" }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,149 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
|
|
||||||
vi.mock("~/database/context", () => ({
|
|
||||||
database: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("~/database/schema", () => ({
|
|
||||||
sportsSeasonTournaments: {
|
|
||||||
sportsSeasonId: "sst.sports_season_id",
|
|
||||||
tournamentId: "sst.tournament_id",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("drizzle-orm", () => ({
|
|
||||||
eq: (col: unknown, val: unknown) => ({ type: "eq", col, val }),
|
|
||||||
and: (...args: unknown[]) => ({ type: "and", args }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("~/models/tournament", () => ({
|
|
||||||
getTournamentById: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("~/models/sports-season", () => ({
|
|
||||||
findSportsSeasonById: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import {
|
|
||||||
linkTournamentToSportsSeason,
|
|
||||||
unlinkTournamentFromSportsSeason,
|
|
||||||
} from "../sports-season-tournament";
|
|
||||||
import { database } from "~/database/context";
|
|
||||||
import { getTournamentById } from "~/models/tournament";
|
|
||||||
import { findSportsSeasonById } from "~/models/sports-season";
|
|
||||||
|
|
||||||
const SPORT_ID = "sport-1";
|
|
||||||
const SPORTS_SEASON_ID = "ss-1";
|
|
||||||
const TOURNAMENT_ID = "t-1";
|
|
||||||
|
|
||||||
const mockSportsSeason = {
|
|
||||||
id: SPORTS_SEASON_ID,
|
|
||||||
sportId: SPORT_ID,
|
|
||||||
name: "2026 Tennis",
|
|
||||||
year: 2026,
|
|
||||||
startDate: null,
|
|
||||||
endDate: null,
|
|
||||||
status: "active" as const,
|
|
||||||
scoringType: "majors" as const,
|
|
||||||
scoringPattern: "qualifying_points",
|
|
||||||
totalMajors: 4,
|
|
||||||
majorsCompleted: 0,
|
|
||||||
qualifyingPointsFinalized: false,
|
|
||||||
eloCalibrationExponent: null,
|
|
||||||
eloMinRating: null,
|
|
||||||
eloMaxRating: null,
|
|
||||||
simulationStatus: "idle" as const,
|
|
||||||
draftOn: "2026-01-01",
|
|
||||||
draftOff: "2026-12-31",
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockTournament = {
|
|
||||||
id: TOURNAMENT_ID,
|
|
||||||
sportId: SPORT_ID,
|
|
||||||
name: "Wimbledon",
|
|
||||||
year: 2026,
|
|
||||||
startsAt: null,
|
|
||||||
endsAt: null,
|
|
||||||
surface: "grass",
|
|
||||||
location: "London",
|
|
||||||
status: "scheduled" as const,
|
|
||||||
externalKey: null,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
function makeInsertDb(returnValue: object) {
|
|
||||||
return {
|
|
||||||
insert: vi.fn().mockReturnValue({
|
|
||||||
values: vi.fn().mockReturnValue({
|
|
||||||
returning: vi.fn().mockResolvedValue([returnValue]),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeDeleteDb() {
|
|
||||||
return {
|
|
||||||
delete: vi.fn().mockReturnValue({
|
|
||||||
where: vi.fn().mockResolvedValue(undefined),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("linkTournamentToSportsSeason", () => {
|
|
||||||
it("links a tournament to a sports season when sports match", async () => {
|
|
||||||
vi.mocked(findSportsSeasonById).mockResolvedValue(mockSportsSeason as never);
|
|
||||||
vi.mocked(getTournamentById).mockResolvedValue(mockTournament as never);
|
|
||||||
|
|
||||||
const link = { id: "link-1", sportsSeasonId: SPORTS_SEASON_ID, tournamentId: TOURNAMENT_ID, createdAt: new Date() };
|
|
||||||
vi.mocked(database).mockReturnValue(makeInsertDb(link) as never);
|
|
||||||
|
|
||||||
const result = await linkTournamentToSportsSeason(SPORTS_SEASON_ID, TOURNAMENT_ID);
|
|
||||||
|
|
||||||
expect(result).toEqual(link);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws when sports season not found", async () => {
|
|
||||||
vi.mocked(findSportsSeasonById).mockResolvedValue(undefined as never);
|
|
||||||
vi.mocked(getTournamentById).mockResolvedValue(mockTournament as never);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
linkTournamentToSportsSeason(SPORTS_SEASON_ID, TOURNAMENT_ID)
|
|
||||||
).rejects.toThrow("Sports season ss-1 not found");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws when tournament not found", async () => {
|
|
||||||
vi.mocked(findSportsSeasonById).mockResolvedValue(mockSportsSeason as never);
|
|
||||||
vi.mocked(getTournamentById).mockResolvedValue(null as never);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
linkTournamentToSportsSeason(SPORTS_SEASON_ID, TOURNAMENT_ID)
|
|
||||||
).rejects.toThrow("Tournament t-1 not found");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws when tournament and sports season belong to different sports", async () => {
|
|
||||||
vi.mocked(findSportsSeasonById).mockResolvedValue(mockSportsSeason as never);
|
|
||||||
vi.mocked(getTournamentById).mockResolvedValue({ ...mockTournament, sportId: "different-sport" } as never);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
linkTournamentToSportsSeason(SPORTS_SEASON_ID, TOURNAMENT_ID)
|
|
||||||
).rejects.toThrow("Tournament and sports season must belong to the same sport");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("unlinkTournamentFromSportsSeason", () => {
|
|
||||||
it("deletes the link", async () => {
|
|
||||||
vi.mocked(database).mockReturnValue(makeDeleteDb() as never);
|
|
||||||
|
|
||||||
await unlinkTournamentFromSportsSeason(SPORTS_SEASON_ID, TOURNAMENT_ID);
|
|
||||||
|
|
||||||
const db = vi.mocked(database).mock.results[0].value;
|
|
||||||
expect(db.delete).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -26,10 +26,6 @@ vi.mock("~/models/qualifying-points", () => ({
|
||||||
updateQPConfig: vi.fn(),
|
updateQPConfig: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("~/models/sports-season-tournament", () => ({
|
|
||||||
findTournamentsBySportsSeason: vi.fn().mockResolvedValue([]),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { cloneSportsSeason, type NewSportsSeason } from "../sports-season";
|
import { cloneSportsSeason, type NewSportsSeason } from "../sports-season";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,5 @@ export * from "./audit-log";
|
||||||
export * from "./tournament";
|
export * from "./tournament";
|
||||||
export * from "./participant";
|
export * from "./participant";
|
||||||
export * from "./tournament-result";
|
export * from "./tournament-result";
|
||||||
export * from "./sports-season-tournament";
|
|
||||||
export * from "./canonical-surface-elo";
|
export * from "./canonical-surface-elo";
|
||||||
export * from "./canonical-golf-skills";
|
export * from "./canonical-golf-skills";
|
||||||
|
|
|
||||||
|
|
@ -766,3 +766,34 @@ export async function bulkCreateScoringEvents(
|
||||||
|
|
||||||
return db.insert(schema.scoringEvents).values(rows).returning();
|
return db.insert(schema.scoringEvents).values(rows).returning();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getTournamentsBySportsSeason(sportsSeasonId: string) {
|
||||||
|
const db = database();
|
||||||
|
const rows = await db.query.scoringEvents.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||||||
|
isNotNull(schema.scoringEvents.tournamentId)
|
||||||
|
),
|
||||||
|
with: { tournament: true },
|
||||||
|
});
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return rows.filter((r) => {
|
||||||
|
if (!r.tournamentId || seen.has(r.tournamentId)) return false;
|
||||||
|
seen.add(r.tournamentId);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSportsSeasonsByTournament(tournamentId: string) {
|
||||||
|
const db = database();
|
||||||
|
const rows = await db.query.scoringEvents.findMany({
|
||||||
|
where: eq(schema.scoringEvents.tournamentId, tournamentId),
|
||||||
|
with: { sportsSeason: { with: { sport: true } } },
|
||||||
|
});
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return rows.filter((r) => {
|
||||||
|
if (seen.has(r.sportsSeasonId)) return false;
|
||||||
|
seen.add(r.sportsSeasonId);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
import { eq, and } from "drizzle-orm";
|
|
||||||
import { database } from "~/database/context";
|
|
||||||
import * as schema from "~/database/schema";
|
|
||||||
import { getTournamentById } from "./tournament";
|
|
||||||
import { findSportsSeasonById } from "./sports-season";
|
|
||||||
|
|
||||||
export type SportsSeasonTournament = typeof schema.sportsSeasonTournaments.$inferSelect;
|
|
||||||
export type NewSportsSeasonTournament = typeof schema.sportsSeasonTournaments.$inferInsert;
|
|
||||||
|
|
||||||
export async function linkTournamentToSportsSeason(
|
|
||||||
sportsSeasonId: string,
|
|
||||||
tournamentId: string
|
|
||||||
): Promise<SportsSeasonTournament> {
|
|
||||||
const [sportsSeason, tournament] = await Promise.all([
|
|
||||||
findSportsSeasonById(sportsSeasonId),
|
|
||||||
getTournamentById(tournamentId),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!sportsSeason) {
|
|
||||||
throw new Error(`Sports season ${sportsSeasonId} not found`);
|
|
||||||
}
|
|
||||||
if (!tournament) {
|
|
||||||
throw new Error(`Tournament ${tournamentId} not found`);
|
|
||||||
}
|
|
||||||
if (sportsSeason.sportId !== tournament.sportId) {
|
|
||||||
throw new Error("Tournament and sports season must belong to the same sport");
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = database();
|
|
||||||
const [link] = await db
|
|
||||||
.insert(schema.sportsSeasonTournaments)
|
|
||||||
.values({ sportsSeasonId, tournamentId })
|
|
||||||
.returning();
|
|
||||||
return link;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function unlinkTournamentFromSportsSeason(
|
|
||||||
sportsSeasonId: string,
|
|
||||||
tournamentId: string
|
|
||||||
): Promise<void> {
|
|
||||||
const db = database();
|
|
||||||
await db
|
|
||||||
.delete(schema.sportsSeasonTournaments)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(schema.sportsSeasonTournaments.sportsSeasonId, sportsSeasonId),
|
|
||||||
eq(schema.sportsSeasonTournaments.tournamentId, tournamentId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function findTournamentsBySportsSeason(sportsSeasonId: string) {
|
|
||||||
const db = database();
|
|
||||||
return await db.query.sportsSeasonTournaments.findMany({
|
|
||||||
where: eq(schema.sportsSeasonTournaments.sportsSeasonId, sportsSeasonId),
|
|
||||||
with: {
|
|
||||||
tournament: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function findSportsSeasonsByTournament(tournamentId: string) {
|
|
||||||
const db = database();
|
|
||||||
return await db.query.sportsSeasonTournaments.findMany({
|
|
||||||
where: eq(schema.sportsSeasonTournaments.tournamentId, tournamentId),
|
|
||||||
with: {
|
|
||||||
sportsSeason: {
|
|
||||||
with: {
|
|
||||||
sport: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { eq, inArray, sql, lte, gte } from "drizzle-orm";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { getQPConfig, updateQPConfig } from "~/models/qualifying-points";
|
import { getQPConfig, updateQPConfig } from "~/models/qualifying-points";
|
||||||
import { findTournamentsBySportsSeason } from "~/models/sports-season-tournament";
|
|
||||||
|
|
||||||
export async function countSportsSeasons(): Promise<number> {
|
export async function countSportsSeasons(): Promise<number> {
|
||||||
const db = database();
|
const db = database();
|
||||||
|
|
@ -301,6 +300,11 @@ export async function cloneSportsSeason(
|
||||||
name: e.name,
|
name: e.name,
|
||||||
eventType: e.eventType,
|
eventType: e.eventType,
|
||||||
isQualifyingEvent: e.isQualifyingEvent,
|
isQualifyingEvent: e.isQualifyingEvent,
|
||||||
|
// Carries over the source season's tournament IDs. For qualifying_points seasons
|
||||||
|
// cloned into a new year, qualifying events will still point to the old year's
|
||||||
|
// canonical tournaments — admin should delete and recreate them once the new
|
||||||
|
// year's tournaments exist.
|
||||||
|
tournamentId: e.tournamentId ?? null,
|
||||||
bracketTemplateId: e.bracketTemplateId,
|
bracketTemplateId: e.bracketTemplateId,
|
||||||
scoringStartsAtRound: e.scoringStartsAtRound,
|
scoringStartsAtRound: e.scoringStartsAtRound,
|
||||||
isComplete: false,
|
isComplete: false,
|
||||||
|
|
@ -321,16 +325,6 @@ export async function cloneSportsSeason(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceTournamentLinks = await findTournamentsBySportsSeason(sourceId);
|
|
||||||
if (sourceTournamentLinks.length > 0) {
|
|
||||||
await tx.insert(schema.sportsSeasonTournaments).values(
|
|
||||||
sourceTournamentLinks.map((link: typeof sourceTournamentLinks[number]) => ({
|
|
||||||
sportsSeasonId: newSeason.id,
|
|
||||||
tournamentId: link.tournamentId,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return newSeason;
|
return newSeason;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import {
|
||||||
} from "~/models/scoring-event";
|
} from "~/models/scoring-event";
|
||||||
import { getQPStandings } from "~/models/qualifying-points";
|
import { getQPStandings } from "~/models/qualifying-points";
|
||||||
import { finalizeQualifyingPoints } from "~/models/scoring-calculator";
|
import { finalizeQualifyingPoints } from "~/models/scoring-calculator";
|
||||||
import { upsertTournament } from "~/models/tournament";
|
import { upsertTournament, findTournamentsBySport, getTournamentById } from "~/models/tournament";
|
||||||
import { extractTournamentIdentity } from "~/lib/tournament-identity";
|
import { extractTournamentIdentity } from "~/lib/tournament-identity";
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -29,13 +29,19 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||||
const scoringRules = null;
|
const scoringRules = null;
|
||||||
if (sportsSeason.scoringPattern === "qualifying_points") {
|
if (sportsSeason.scoringPattern === "qualifying_points") {
|
||||||
qpStandings = await getQPStandings(params.id);
|
qpStandings = await getQPStandings(params.id);
|
||||||
|
|
||||||
// Get scoring rules from a linked season (if any)
|
|
||||||
// For now, we'll use default scoring for projection
|
|
||||||
// Note: QP standings are global to the sports season, not league-specific
|
|
||||||
// When showing to league members, we would filter by their league's scoring rules
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tournaments for this sport that don't already have a scoring event in this season.
|
||||||
|
// Only relevant for qualifying_points seasons — skip the query for all others.
|
||||||
|
const allTournamentsForSport =
|
||||||
|
sportsSeason.scoringPattern === "qualifying_points"
|
||||||
|
? await findTournamentsBySport(sportsSeason.sportId)
|
||||||
|
: [];
|
||||||
|
const linkedTournamentIds = new Set(
|
||||||
|
events.map((e) => e.tournamentId).filter((id): id is string => id !== null)
|
||||||
|
);
|
||||||
|
const availableTournaments = allTournamentsForSport.filter((t) => !linkedTournamentIds.has(t.id));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sportsSeason: sportsSeason as typeof sportsSeason & {
|
sportsSeason: sportsSeason as typeof sportsSeason & {
|
||||||
sport: { id: string; name: string; type: string; slug: string };
|
sport: { id: string; name: string; type: string; slug: string };
|
||||||
|
|
@ -43,6 +49,7 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||||
events,
|
events,
|
||||||
qpStandings,
|
qpStandings,
|
||||||
scoringRules,
|
scoringRules,
|
||||||
|
availableTournaments,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,6 +57,33 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const intent = formData.get("intent");
|
const intent = formData.get("intent");
|
||||||
|
|
||||||
|
if (intent === "add-from-tournament") {
|
||||||
|
const tournamentId = formData.get("tournamentId");
|
||||||
|
if (typeof tournamentId !== "string" || !tournamentId) {
|
||||||
|
return { error: "Tournament ID is required" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const tournament = await getTournamentById(tournamentId);
|
||||||
|
if (!tournament) return { error: "Tournament not found" };
|
||||||
|
const eventDate = tournament.startsAt ? new Date(tournament.startsAt) : undefined;
|
||||||
|
// This action is only reachable for qualifying_points seasons (the card is
|
||||||
|
// conditional in the UI), so major_tournament + isQualifyingEvent=true is always correct.
|
||||||
|
const event = await createScoringEvent({
|
||||||
|
sportsSeasonId: params.id,
|
||||||
|
name: `${tournament.name} ${tournament.year}`,
|
||||||
|
eventType: "major_tournament",
|
||||||
|
isQualifyingEvent: true,
|
||||||
|
tournamentId: tournament.id,
|
||||||
|
eventDate,
|
||||||
|
eventStartsAt: tournament.startsAt ? new Date(tournament.startsAt) : undefined,
|
||||||
|
});
|
||||||
|
return redirect(`/admin/sports-seasons/${params.id}/events/${event.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error adding scoring event from tournament:", error);
|
||||||
|
return { error: "Failed to create scoring event from tournament." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (intent === "finalize-qp") {
|
if (intent === "finalize-qp") {
|
||||||
try {
|
try {
|
||||||
await finalizeQualifyingPoints(params.id);
|
await finalizeQualifyingPoints(params.id);
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,8 @@ export default function SportsSeasonEvents({
|
||||||
loaderData,
|
loaderData,
|
||||||
actionData,
|
actionData,
|
||||||
}: Route.ComponentProps) {
|
}: Route.ComponentProps) {
|
||||||
const { sportsSeason, events, qpStandings, scoringRules } = loaderData;
|
const { sportsSeason, events, qpStandings, scoringRules, availableTournaments } = loaderData;
|
||||||
|
const [selectedTournamentId, setSelectedTournamentId] = useState("");
|
||||||
|
|
||||||
const [createEventStartsAt, setCreateEventStartsAt] = useState("");
|
const [createEventStartsAt, setCreateEventStartsAt] = useState("");
|
||||||
|
|
||||||
|
|
@ -134,6 +135,43 @@ export default function SportsSeasonEvents({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Add Existing Tournament (qualifying_points only) */}
|
||||||
|
{sportsSeason.scoringPattern === "qualifying_points" && availableTournaments.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Add Existing Tournament</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Link a canonical tournament as a qualifying scoring event.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Form method="post" className="flex gap-2">
|
||||||
|
<input type="hidden" name="intent" value="add-from-tournament" />
|
||||||
|
<Select
|
||||||
|
name="tournamentId"
|
||||||
|
value={selectedTournamentId}
|
||||||
|
onValueChange={setSelectedTournamentId}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<SelectTrigger className="flex-1">
|
||||||
|
<SelectValue placeholder="Select a tournament" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableTournaments.map((t) => (
|
||||||
|
<SelectItem key={t.id} value={t.id}>
|
||||||
|
{t.name} ({t.year})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button type="submit">
|
||||||
|
<Trophy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Create New Event Card */}
|
{/* Create New Event Card */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
|
||||||
|
|
@ -47,14 +47,8 @@ import {
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "~/components/ui/alert-dialog";
|
} from "~/components/ui/alert-dialog";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Trash2, Users, Trophy, Calculator, CheckCircle2, Zap, AlertTriangle, Loader2, RefreshCw, Copy, Plus, X } from "lucide-react";
|
import { Trash2, Users, Trophy, Calculator, CheckCircle2, Zap, AlertTriangle, Loader2, RefreshCw, Copy } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { findTournamentsBySport } from "~/models/tournament";
|
|
||||||
import {
|
|
||||||
linkTournamentToSportsSeason,
|
|
||||||
unlinkTournamentFromSportsSeason,
|
|
||||||
findTournamentsBySportsSeason,
|
|
||||||
} from "~/models/sports-season-tournament";
|
|
||||||
|
|
||||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||||
return [{ title: `${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
return [{ title: `${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
||||||
|
|
@ -94,11 +88,6 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||||
? await getPendingStandingsMappings(params.id)
|
? await getPendingStandingsMappings(params.id)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const [linkedTournaments, allTournamentsForSport] = await Promise.all([
|
|
||||||
findTournamentsBySportsSeason(params.id),
|
|
||||||
findTournamentsBySport(sportsSeason.sportId),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sportsSeason,
|
sportsSeason,
|
||||||
participants,
|
participants,
|
||||||
|
|
@ -106,8 +95,6 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||||
simulatorInfo,
|
simulatorInfo,
|
||||||
lastStandingsSyncedAt: lastStandingsSyncedAt?.toISOString() ?? null,
|
lastStandingsSyncedAt: lastStandingsSyncedAt?.toISOString() ?? null,
|
||||||
pendingMappings,
|
pendingMappings,
|
||||||
linkedTournaments,
|
|
||||||
allTournamentsForSport,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -227,34 +214,6 @@ export async function action(args: Route.ActionArgs) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (intent === "add-tournament") {
|
|
||||||
const tournamentId = formData.get("tournamentId");
|
|
||||||
if (typeof tournamentId !== "string" || !tournamentId) {
|
|
||||||
return { error: "Tournament ID is required" };
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await linkTournamentToSportsSeason(params.id, tournamentId);
|
|
||||||
return { success: true, intent: "add-tournament", message: "Tournament linked successfully!" };
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Error linking tournament:", error);
|
|
||||||
return { error: "Failed to link tournament. It may already be linked.", intent: "add-tournament" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (intent === "remove-tournament") {
|
|
||||||
const tournamentId = formData.get("tournamentId");
|
|
||||||
if (typeof tournamentId !== "string" || !tournamentId) {
|
|
||||||
return { error: "Tournament ID is required" };
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await unlinkTournamentFromSportsSeason(params.id, tournamentId);
|
|
||||||
return { success: true, intent: "remove-tournament", message: "Tournament unlinked successfully!" };
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Error unlinking tournament:", error);
|
|
||||||
return { error: "Failed to unlink tournament.", intent: "remove-tournament" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update
|
// Update
|
||||||
const name = formData.get("name");
|
const name = formData.get("name");
|
||||||
const year = formData.get("year");
|
const year = formData.get("year");
|
||||||
|
|
@ -339,21 +298,13 @@ export async function action(args: Route.ActionArgs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EditSportsSeason({ loaderData, actionData }: Route.ComponentProps) {
|
export default function EditSportsSeason({ loaderData, actionData }: Route.ComponentProps) {
|
||||||
const { sportsSeason, participants, lastSimulatedDate, simulatorInfo, lastStandingsSyncedAt, pendingMappings, linkedTournaments, allTournamentsForSport } = loaderData;
|
const { sportsSeason, participants, lastSimulatedDate, simulatorInfo, lastStandingsSyncedAt, pendingMappings } = loaderData;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const isSyncingStandings =
|
const isSyncingStandings =
|
||||||
navigation.state === "submitting" &&
|
navigation.state === "submitting" &&
|
||||||
(navigation.formData?.get("intent") as string) === "sync-standings";
|
(navigation.formData?.get("intent") as string) === "sync-standings";
|
||||||
const [scoringPattern, setScoringPattern] = useState<string>(sportsSeason.scoringPattern || "");
|
const [scoringPattern, setScoringPattern] = useState<string>(sportsSeason.scoringPattern || "");
|
||||||
const [selectedTournamentId, setSelectedTournamentId] = useState("");
|
|
||||||
|
|
||||||
const linkedTournamentIds = new Set(
|
|
||||||
linkedTournaments.map((lt) => lt.tournamentId)
|
|
||||||
);
|
|
||||||
const availableTournaments = allTournamentsForSport.filter(
|
|
||||||
(t) => !linkedTournamentIds.has(t.id)
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
|
|
@ -586,119 +537,6 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Tournaments</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{linkedTournaments.length}{" "}
|
|
||||||
{linkedTournaments.length === 1 ? "tournament" : "tournaments"}{" "}
|
|
||||||
linked to this sports season
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{actionData?.success && (actionData.intent === "add-tournament" || actionData.intent === "remove-tournament") && (
|
|
||||||
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm">
|
|
||||||
{actionData.message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{actionData?.error && (actionData.intent === "add-tournament" || actionData.intent === "remove-tournament") && (
|
|
||||||
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
|
||||||
{actionData.error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{linkedTournaments.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{linkedTournaments.map((link) => (
|
|
||||||
<div
|
|
||||||
key={link.id}
|
|
||||||
className="flex items-center justify-between p-3 border rounded-lg"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{link.tournament.name}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{link.tournament.year}
|
|
||||||
{link.tournament.location
|
|
||||||
? ` — ${link.tournament.location}`
|
|
||||||
: ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
link.tournament.status === "completed"
|
|
||||||
? "default"
|
|
||||||
: link.tournament.status === "in_progress"
|
|
||||||
? "secondary"
|
|
||||||
: "outline"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{link.tournament.status}
|
|
||||||
</Badge>
|
|
||||||
<Form method="post">
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="intent"
|
|
||||||
value="remove-tournament"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="tournamentId"
|
|
||||||
value={link.tournamentId}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-destructive hover:text-destructive"
|
|
||||||
aria-label={`Remove ${link.tournament.name}`}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{availableTournaments.length > 0 ? (
|
|
||||||
<Form method="post" className="flex gap-2">
|
|
||||||
<input type="hidden" name="intent" value="add-tournament" />
|
|
||||||
<Select
|
|
||||||
name="tournamentId"
|
|
||||||
value={selectedTournamentId}
|
|
||||||
onValueChange={setSelectedTournamentId}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<SelectTrigger className="flex-1">
|
|
||||||
<SelectValue placeholder="Select a tournament" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{availableTournaments.map((tournament) => (
|
|
||||||
<SelectItem key={tournament.id} value={tournament.id}>
|
|
||||||
{tournament.name} ({tournament.year})
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Button type="submit">
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
) : (
|
|
||||||
linkedTournaments.length > 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground text-center py-2">
|
|
||||||
All tournaments for this sport have been linked
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground text-center py-2">
|
|
||||||
No tournaments exist for this sport yet. Create one first.
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { Link, useFetcher, Form } from "react-router";
|
import { Link, useFetcher } from "react-router";
|
||||||
import { useState } from "react";
|
|
||||||
import { auth } from "~/lib/auth.server";
|
import { auth } from "~/lib/auth.server";
|
||||||
import type { Route } from "./+types/admin.tournaments.$id";
|
import type { Route } from "./+types/admin.tournaments.$id";
|
||||||
|
|
||||||
|
|
@ -18,12 +17,7 @@ import {
|
||||||
syncTournamentResults,
|
syncTournamentResults,
|
||||||
type SyncReport,
|
type SyncReport,
|
||||||
} from "~/services/sync-tournament-results";
|
} from "~/services/sync-tournament-results";
|
||||||
import {
|
import { getSportsSeasonsByTournament } from "~/models/scoring-event";
|
||||||
linkTournamentToSportsSeason,
|
|
||||||
unlinkTournamentFromSportsSeason,
|
|
||||||
findSportsSeasonsByTournament,
|
|
||||||
} from "~/models/sports-season-tournament";
|
|
||||||
import { findSportsSeasonsBySportId } from "~/models/sports-season";
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
|
@ -33,13 +27,6 @@ import {
|
||||||
} from "~/components/ui/card";
|
} from "~/components/ui/card";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "~/components/ui/select";
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|
@ -48,7 +35,7 @@ import {
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "~/components/ui/table";
|
} from "~/components/ui/table";
|
||||||
import { ArrowLeft, CheckCircle2, AlertTriangle, Plus, X } from "lucide-react";
|
import { ArrowLeft, CheckCircle2, AlertTriangle } from "lucide-react";
|
||||||
import { BatchResultEntry } from "~/components/BatchResultEntry";
|
import { BatchResultEntry } from "~/components/BatchResultEntry";
|
||||||
|
|
||||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||||
|
|
@ -65,14 +52,13 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||||
throw new Response("Not Found", { status: 404 });
|
throw new Response("Not Found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const [results, canonicalParticipants, linkedSportsSeasons, allSportsSeasonsForSport] = await Promise.all([
|
const [results, canonicalParticipants, linkedSportsSeasons] = await Promise.all([
|
||||||
getTournamentResults(tournament.id),
|
getTournamentResults(tournament.id),
|
||||||
findCanonicalParticipantsBySport(tournament.sportId),
|
findCanonicalParticipantsBySport(tournament.sportId),
|
||||||
findSportsSeasonsByTournament(tournament.id),
|
getSportsSeasonsByTournament(tournament.id),
|
||||||
findSportsSeasonsBySportId(tournament.sportId),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { tournament, results, canonicalParticipants, linkedSportsSeasons, allSportsSeasonsForSport };
|
return { tournament, results, canonicalParticipants, linkedSportsSeasons };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function action(args: Route.ActionArgs) {
|
export async function action(args: Route.ActionArgs) {
|
||||||
|
|
@ -92,34 +78,6 @@ export async function action(args: Route.ActionArgs) {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const intent = formData.get("intent");
|
const intent = formData.get("intent");
|
||||||
|
|
||||||
if (intent === "add-sports-season") {
|
|
||||||
const sportsSeasonId = formData.get("sportsSeasonId");
|
|
||||||
if (typeof sportsSeasonId !== "string" || !sportsSeasonId) {
|
|
||||||
return { success: false as const, error: "Sports season ID is required", syncReport: null };
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await linkTournamentToSportsSeason(sportsSeasonId, tournament.id);
|
|
||||||
return { success: true as const, error: null, syncReport: null };
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Error linking sports season:", error);
|
|
||||||
return { success: false as const, error: "Failed to link sports season. It may already be linked.", syncReport: null };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (intent === "remove-sports-season") {
|
|
||||||
const sportsSeasonId = formData.get("sportsSeasonId");
|
|
||||||
if (typeof sportsSeasonId !== "string" || !sportsSeasonId) {
|
|
||||||
return { success: false as const, error: "Sports season ID is required", syncReport: null };
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await unlinkTournamentFromSportsSeason(sportsSeasonId, tournament.id);
|
|
||||||
return { success: true as const, error: null, syncReport: null };
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Error unlinking sports season:", error);
|
|
||||||
return { success: false as const, error: "Failed to unlink sports season.", syncReport: null };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (intent === "batch-upsert-results") {
|
if (intent === "batch-upsert-results") {
|
||||||
const resultsRaw = formData.get("results");
|
const resultsRaw = formData.get("results");
|
||||||
if (typeof resultsRaw !== "string" || !resultsRaw) {
|
if (typeof resultsRaw !== "string" || !resultsRaw) {
|
||||||
|
|
@ -216,16 +174,8 @@ export default function AdminTournamentDetail({
|
||||||
loaderData,
|
loaderData,
|
||||||
actionData,
|
actionData,
|
||||||
}: Route.ComponentProps) {
|
}: Route.ComponentProps) {
|
||||||
const { tournament, results, canonicalParticipants, linkedSportsSeasons, allSportsSeasonsForSport } = loaderData;
|
const { tournament, results, canonicalParticipants, linkedSportsSeasons } = loaderData;
|
||||||
const retryFetcher = useFetcher<typeof action>();
|
const retryFetcher = useFetcher<typeof action>();
|
||||||
const [selectedSportsSeasonId, setSelectedSportsSeasonId] = useState("");
|
|
||||||
|
|
||||||
const linkedSeasonIds = new Set(
|
|
||||||
linkedSportsSeasons.map((ls) => ls.sportsSeasonId)
|
|
||||||
);
|
|
||||||
const availableSportsSeasons = allSportsSeasonsForSport.filter(
|
|
||||||
(ss) => !linkedSeasonIds.has(ss.id)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Prefer the latest action/retry response for the sync report
|
// Prefer the latest action/retry response for the sync report
|
||||||
const liveReport: SyncReport | null =
|
const liveReport: SyncReport | null =
|
||||||
|
|
@ -342,21 +292,11 @@ export default function AdminTournamentDetail({
|
||||||
{linkedSportsSeasons.length === 1
|
{linkedSportsSeasons.length === 1
|
||||||
? "sports season"
|
? "sports season"
|
||||||
: "sports seasons"}{" "}
|
: "sports seasons"}{" "}
|
||||||
linked to this tournament
|
use this tournament as a scoring event
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent>
|
||||||
{actionData?.success && (actionData.error === null) && (
|
{linkedSportsSeasons.length > 0 ? (
|
||||||
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm">
|
|
||||||
Updated successfully!
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{actionData?.error && (
|
|
||||||
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
|
||||||
{actionData.error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{linkedSportsSeasons.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{linkedSportsSeasons.map((link) => (
|
{linkedSportsSeasons.map((link) => (
|
||||||
<div
|
<div
|
||||||
|
|
@ -373,7 +313,6 @@ export default function AdminTournamentDetail({
|
||||||
{link.sportsSeason.scoringType.replace("_", " ")}
|
{link.sportsSeason.scoringType.replace("_", " ")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant={
|
||||||
link.sportsSeason.status === "completed"
|
link.sportsSeason.status === "completed"
|
||||||
|
|
@ -385,71 +324,13 @@ export default function AdminTournamentDetail({
|
||||||
>
|
>
|
||||||
{link.sportsSeason.status}
|
{link.sportsSeason.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Form method="post">
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="intent"
|
|
||||||
value="remove-sports-season"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="sportsSeasonId"
|
|
||||||
value={link.sportsSeasonId}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-destructive hover:text-destructive"
|
|
||||||
aria-label={`Remove ${link.sportsSeason.sport.name} - ${link.sportsSeason.name}`}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{availableSportsSeasons.length > 0 ? (
|
|
||||||
<Form method="post" className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="intent"
|
|
||||||
value="add-sports-season"
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
name="sportsSeasonId"
|
|
||||||
value={selectedSportsSeasonId}
|
|
||||||
onValueChange={setSelectedSportsSeasonId}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<SelectTrigger className="flex-1">
|
|
||||||
<SelectValue placeholder="Select a sports season" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{availableSportsSeasons.map((ss) => (
|
|
||||||
<SelectItem key={ss.id} value={ss.id}>
|
|
||||||
{ss.sport.name} - {ss.name} ({ss.year})
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Button type="submit">
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
) : (
|
) : (
|
||||||
linkedSportsSeasons.length > 0 ? (
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
<p className="text-sm text-muted-foreground text-center py-2">
|
No sports seasons are using this tournament yet.
|
||||||
All sports seasons for this sport have been linked
|
|
||||||
</p>
|
</p>
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground text-center py-2">
|
|
||||||
No sports seasons exist for this sport yet. Create one first.
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -510,29 +510,6 @@ export const seasonSports = pgTable("season_sports", {
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sportsSeasonTournaments = pgTable(
|
|
||||||
"sports_season_tournaments",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
sportsSeasonId: uuid("sports_season_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
|
|
||||||
tournamentId: uuid("tournament_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => tournaments.id, { onDelete: "cascade" }),
|
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
uniqueSeasonTournament: uniqueIndex("sports_season_tournaments_unique").on(
|
|
||||||
t.sportsSeasonId,
|
|
||||||
t.tournamentId,
|
|
||||||
),
|
|
||||||
tournamentIdIdx: index("sports_season_tournaments_tournament_id_idx").on(
|
|
||||||
t.tournamentId,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const seasonParticipantResults = pgTable("season_participant_results", {
|
export const seasonParticipantResults = pgTable("season_participant_results", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
participantId: uuid("participant_id")
|
participantId: uuid("participant_id")
|
||||||
|
|
@ -933,7 +910,6 @@ export const sportsSeasonsRelations = relations(sportsSeasons, ({ one, many }) =
|
||||||
participantResults: many(seasonParticipantResults),
|
participantResults: many(seasonParticipantResults),
|
||||||
regularSeasonStandings: many(regularSeasonStandings),
|
regularSeasonStandings: many(regularSeasonStandings),
|
||||||
pendingStandingsMappings: many(pendingStandingsMappings),
|
pendingStandingsMappings: many(pendingStandingsMappings),
|
||||||
sportsSeasonTournaments: many(sportsSeasonTournaments),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const seasonParticipantsRelations = relations(seasonParticipants, ({ one, many }) => ({
|
export const seasonParticipantsRelations = relations(seasonParticipants, ({ one, many }) => ({
|
||||||
|
|
@ -973,7 +949,6 @@ export const tournamentsRelations = relations(tournaments, ({ one, many }) => ({
|
||||||
}),
|
}),
|
||||||
scoringEvents: many(scoringEvents),
|
scoringEvents: many(scoringEvents),
|
||||||
tournamentResults: many(tournamentResults),
|
tournamentResults: many(tournamentResults),
|
||||||
sportsSeasonTournaments: many(sportsSeasonTournaments),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const tournamentResultsRelations = relations(tournamentResults, ({ one }) => ({
|
export const tournamentResultsRelations = relations(tournamentResults, ({ one }) => ({
|
||||||
|
|
@ -1037,20 +1012,6 @@ export const seasonSportsRelations = relations(seasonSports, ({ one }) => ({
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const sportsSeasonTournamentsRelations = relations(
|
|
||||||
sportsSeasonTournaments,
|
|
||||||
({ one }) => ({
|
|
||||||
sportsSeason: one(sportsSeasons, {
|
|
||||||
fields: [sportsSeasonTournaments.sportsSeasonId],
|
|
||||||
references: [sportsSeasons.id],
|
|
||||||
}),
|
|
||||||
tournament: one(tournaments, {
|
|
||||||
fields: [sportsSeasonTournaments.tournamentId],
|
|
||||||
references: [tournaments.id],
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const seasonParticipantResultsRelations = relations(seasonParticipantResults, ({ one }) => ({
|
export const seasonParticipantResultsRelations = relations(seasonParticipantResults, ({ one }) => ({
|
||||||
participant: one(seasonParticipants, {
|
participant: one(seasonParticipants, {
|
||||||
fields: [seasonParticipantResults.participantId],
|
fields: [seasonParticipantResults.participantId],
|
||||||
|
|
|
||||||
1
drizzle/0093_ambiguous_hellcat.sql
Normal file
1
drizzle/0093_ambiguous_hellcat.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE "sports_season_tournaments" CASCADE;
|
||||||
5702
drizzle/meta/0093_snapshot.json
Normal file
5702
drizzle/meta/0093_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -652,6 +652,13 @@
|
||||||
"when": 1777745229462,
|
"when": 1777745229462,
|
||||||
"tag": "0092_robust_klaw",
|
"tag": "0092_robust_klaw",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 93,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777783789103,
|
||||||
|
"tag": "0093_ambiguous_hellcat",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue