Add sports season ↔ tournament linking with admin UI (#372)
Adds a sports_season_tournaments junction table and bidirectional link/unlink UI on both the sports season and tournament admin pages. - New junction table with unique index on (sports_season_id, tournament_id) and a separate index on tournament_id for reverse lookups - New model with link/unlink/query functions and cross-sport validation - Migration includes backfill from existing scoring_events tournament links - Admin sports season page: Tournaments card with add/remove UI - Admin tournament page: Linked Sports Seasons card with add/remove UI - Inline success/error feedback, empty states, aria-labels on remove buttons - cloneSportsSeason now copies tournament links - Fixes darts simulator test timeout (15s for 128-player pre-bracket sim)
This commit is contained in:
parent
21dfe3627c
commit
ef7e098d68
12 changed files with 6448 additions and 16 deletions
149
app/models/__tests__/sports-season-tournament.test.ts
Normal file
149
app/models/__tests__/sports-season-tournament.test.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
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,6 +26,10 @@ vi.mock("~/models/qualifying-points", () => ({
|
|||
updateQPConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("~/models/sports-season-tournament", () => ({
|
||||
findTournamentsBySportsSeason: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
import * as schema from "~/database/schema";
|
||||
import { cloneSportsSeason, type NewSportsSeason } from "../sports-season";
|
||||
import { database } from "~/database/context";
|
||||
|
|
|
|||
|
|
@ -21,5 +21,6 @@ export * from "./audit-log";
|
|||
export * from "./tournament";
|
||||
export * from "./participant";
|
||||
export * from "./tournament-result";
|
||||
export * from "./sports-season-tournament";
|
||||
export * from "./canonical-surface-elo";
|
||||
export * from "./canonical-golf-skills";
|
||||
|
|
|
|||
74
app/models/sports-season-tournament.ts
Normal file
74
app/models/sports-season-tournament.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
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,6 +2,7 @@ import { eq, inArray, sql, lte, gte } from "drizzle-orm";
|
|||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { getQPConfig, updateQPConfig } from "~/models/qualifying-points";
|
||||
import { findTournamentsBySportsSeason } from "~/models/sports-season-tournament";
|
||||
|
||||
export async function countSportsSeasons(): Promise<number> {
|
||||
const db = database();
|
||||
|
|
@ -50,7 +51,7 @@ export async function findSportsSeasonsByIds(ids: string[]): Promise<SportsSeaso
|
|||
}) as SportsSeasonWithSport[];
|
||||
}
|
||||
|
||||
export async function findSportsSeasonsBySportId(sportId: string): Promise<SportsSeason[]> {
|
||||
export async function findSportsSeasonsBySportId(sportId: string): Promise<SportsSeasonWithSport[]> {
|
||||
const db = database();
|
||||
return await db.query.sportsSeasons.findMany({
|
||||
where: eq(schema.sportsSeasons.sportId, sportId),
|
||||
|
|
@ -58,7 +59,7 @@ export async function findSportsSeasonsBySportId(sportId: string): Promise<Sport
|
|||
with: {
|
||||
sport: true,
|
||||
},
|
||||
});
|
||||
}) as SportsSeasonWithSport[];
|
||||
}
|
||||
|
||||
export async function findActiveSportsSeasonsBySportId(sportId: string): Promise<SportsSeason[]> {
|
||||
|
|
@ -320,6 +321,16 @@ 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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,8 +47,14 @@ import {
|
|||
AlertDialogTrigger,
|
||||
} from "~/components/ui/alert-dialog";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Trash2, Users, Trophy, Calculator, CheckCircle2, Zap, AlertTriangle, Loader2, RefreshCw, Copy } from "lucide-react";
|
||||
import { Trash2, Users, Trophy, Calculator, CheckCircle2, Zap, AlertTriangle, Loader2, RefreshCw, Copy, Plus, X } from "lucide-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 {
|
||||
return [{ title: `${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
||||
|
|
@ -88,6 +94,11 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
? await getPendingStandingsMappings(params.id)
|
||||
: [];
|
||||
|
||||
const [linkedTournaments, allTournamentsForSport] = await Promise.all([
|
||||
findTournamentsBySportsSeason(params.id),
|
||||
findTournamentsBySport(sportsSeason.sportId),
|
||||
]);
|
||||
|
||||
return {
|
||||
sportsSeason,
|
||||
participants,
|
||||
|
|
@ -95,6 +106,8 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
simulatorInfo,
|
||||
lastStandingsSyncedAt: lastStandingsSyncedAt?.toISOString() ?? null,
|
||||
pendingMappings,
|
||||
linkedTournaments,
|
||||
allTournamentsForSport,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -214,6 +227,34 @@ 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
|
||||
const name = formData.get("name");
|
||||
const year = formData.get("year");
|
||||
|
|
@ -298,13 +339,21 @@ export async function action(args: Route.ActionArgs) {
|
|||
}
|
||||
|
||||
export default function EditSportsSeason({ loaderData, actionData }: Route.ComponentProps) {
|
||||
const { sportsSeason, participants, lastSimulatedDate, simulatorInfo, lastStandingsSyncedAt, pendingMappings } = loaderData;
|
||||
const { sportsSeason, participants, lastSimulatedDate, simulatorInfo, lastStandingsSyncedAt, pendingMappings, linkedTournaments, allTournamentsForSport } = loaderData;
|
||||
const navigate = useNavigate();
|
||||
const navigation = useNavigation();
|
||||
const isSyncingStandings =
|
||||
navigation.state === "submitting" &&
|
||||
(navigation.formData?.get("intent") as string) === "sync-standings";
|
||||
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 (
|
||||
<div className="p-8">
|
||||
|
|
@ -537,6 +586,119 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
|
|||
</CardContent>
|
||||
</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>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Link, useFetcher } from "react-router";
|
||||
import { Link, useFetcher, Form } from "react-router";
|
||||
import { useState } from "react";
|
||||
import { auth } from "~/lib/auth.server";
|
||||
import type { Route } from "./+types/admin.tournaments.$id";
|
||||
|
||||
|
|
@ -17,6 +18,12 @@ import {
|
|||
syncTournamentResults,
|
||||
type SyncReport,
|
||||
} from "~/services/sync-tournament-results";
|
||||
import {
|
||||
linkTournamentToSportsSeason,
|
||||
unlinkTournamentFromSportsSeason,
|
||||
findSportsSeasonsByTournament,
|
||||
} from "~/models/sports-season-tournament";
|
||||
import { findSportsSeasonsBySportId } from "~/models/sports-season";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
|
|
@ -26,6 +33,13 @@ import {
|
|||
} from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
|
|
@ -34,7 +48,7 @@ import {
|
|||
TableHeader,
|
||||
TableRow,
|
||||
} from "~/components/ui/table";
|
||||
import { ArrowLeft, CheckCircle2, AlertTriangle } from "lucide-react";
|
||||
import { ArrowLeft, CheckCircle2, AlertTriangle, Plus, X } from "lucide-react";
|
||||
import { BatchResultEntry } from "~/components/BatchResultEntry";
|
||||
|
||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||
|
|
@ -51,12 +65,14 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const [results, canonicalParticipants] = await Promise.all([
|
||||
const [results, canonicalParticipants, linkedSportsSeasons, allSportsSeasonsForSport] = await Promise.all([
|
||||
getTournamentResults(tournament.id),
|
||||
findCanonicalParticipantsBySport(tournament.sportId),
|
||||
findSportsSeasonsByTournament(tournament.id),
|
||||
findSportsSeasonsBySportId(tournament.sportId),
|
||||
]);
|
||||
|
||||
return { tournament, results, canonicalParticipants };
|
||||
return { tournament, results, canonicalParticipants, linkedSportsSeasons, allSportsSeasonsForSport };
|
||||
}
|
||||
|
||||
export async function action(args: Route.ActionArgs) {
|
||||
|
|
@ -76,6 +92,34 @@ export async function action(args: Route.ActionArgs) {
|
|||
const formData = await request.formData();
|
||||
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") {
|
||||
const resultsRaw = formData.get("results");
|
||||
if (typeof resultsRaw !== "string" || !resultsRaw) {
|
||||
|
|
@ -172,8 +216,16 @@ export default function AdminTournamentDetail({
|
|||
loaderData,
|
||||
actionData,
|
||||
}: Route.ComponentProps) {
|
||||
const { tournament, results, canonicalParticipants } = loaderData;
|
||||
const { tournament, results, canonicalParticipants, linkedSportsSeasons, allSportsSeasonsForSport } = loaderData;
|
||||
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
|
||||
const liveReport: SyncReport | null =
|
||||
|
|
@ -282,11 +334,125 @@ export default function AdminTournamentDetail({
|
|||
</Card>
|
||||
)}
|
||||
|
||||
{actionData?.error && (
|
||||
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm mb-6">
|
||||
{actionData.error}
|
||||
</div>
|
||||
)}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Linked Sports Seasons</CardTitle>
|
||||
<CardDescription>
|
||||
{linkedSportsSeasons.length}{" "}
|
||||
{linkedSportsSeasons.length === 1
|
||||
? "sports season"
|
||||
: "sports seasons"}{" "}
|
||||
linked to this tournament
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{actionData?.success && (actionData.error === null) && (
|
||||
<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">
|
||||
{linkedSportsSeasons.map((link) => (
|
||||
<div
|
||||
key={link.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{link.sportsSeason.sport.name} -{" "}
|
||||
{link.sportsSeason.name}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{link.sportsSeason.year} •{" "}
|
||||
{link.sportsSeason.scoringType.replace("_", " ")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant={
|
||||
link.sportsSeason.status === "completed"
|
||||
? "default"
|
||||
: link.sportsSeason.status === "active"
|
||||
? "secondary"
|
||||
: "outline"
|
||||
}
|
||||
>
|
||||
{link.sportsSeason.status}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{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-2">
|
||||
All sports seasons for this sport have been linked
|
||||
</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>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
|
|
|
|||
|
|
@ -404,7 +404,7 @@ describe("DartsSimulator.simulate() — Path B (pre-bracket)", () => {
|
|||
const results = await sim.simulate("season-1");
|
||||
|
||||
expect(results).toHaveLength(128);
|
||||
});
|
||||
}, 15_000);
|
||||
|
||||
it("each probability column sums to 1.0 across all 128 participants", async () => {
|
||||
const sim = new DartsSimulator();
|
||||
|
|
|
|||
|
|
@ -510,6 +510,29 @@ export const seasonSports = pgTable("season_sports", {
|
|||
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", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
participantId: uuid("participant_id")
|
||||
|
|
@ -910,6 +933,7 @@ export const sportsSeasonsRelations = relations(sportsSeasons, ({ one, many }) =
|
|||
participantResults: many(seasonParticipantResults),
|
||||
regularSeasonStandings: many(regularSeasonStandings),
|
||||
pendingStandingsMappings: many(pendingStandingsMappings),
|
||||
sportsSeasonTournaments: many(sportsSeasonTournaments),
|
||||
}));
|
||||
|
||||
export const seasonParticipantsRelations = relations(seasonParticipants, ({ one, many }) => ({
|
||||
|
|
@ -949,6 +973,7 @@ export const tournamentsRelations = relations(tournaments, ({ one, many }) => ({
|
|||
}),
|
||||
scoringEvents: many(scoringEvents),
|
||||
tournamentResults: many(tournamentResults),
|
||||
sportsSeasonTournaments: many(sportsSeasonTournaments),
|
||||
}));
|
||||
|
||||
export const tournamentResultsRelations = relations(tournamentResults, ({ one }) => ({
|
||||
|
|
@ -1012,6 +1037,20 @@ 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 }) => ({
|
||||
participant: one(seasonParticipants, {
|
||||
fields: [seasonParticipantResults.participantId],
|
||||
|
|
|
|||
29
drizzle/0092_robust_klaw.sql
Normal file
29
drizzle/0092_robust_klaw.sql
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
CREATE TABLE IF NOT EXISTS "sports_season_tournaments" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"sports_season_id" uuid NOT NULL,
|
||||
"tournament_id" uuid NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "sports_season_tournaments" ADD CONSTRAINT "sports_season_tournaments_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 "sports_season_tournaments" ADD CONSTRAINT "sports_season_tournaments_tournament_id_tournaments_id_fk" FOREIGN KEY ("tournament_id") REFERENCES "public"."tournaments"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "sports_season_tournaments_unique" ON "sports_season_tournaments" USING btree ("sports_season_id","tournament_id");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "sports_season_tournaments_tournament_id_idx" ON "sports_season_tournaments" USING btree ("tournament_id");
|
||||
--> statement-breakpoint
|
||||
-- Backfill: seed from existing scoring_events that already associate tournaments with sports seasons
|
||||
INSERT INTO "sports_season_tournaments" ("sports_season_id", "tournament_id")
|
||||
SELECT DISTINCT "sports_season_id", "tournament_id"
|
||||
FROM "scoring_events"
|
||||
WHERE "tournament_id" IS NOT NULL
|
||||
ON CONFLICT DO NOTHING;
|
||||
5790
drizzle/meta/0092_snapshot.json
Normal file
5790
drizzle/meta/0092_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -645,6 +645,13 @@
|
|||
"when": 1777700829535,
|
||||
"tag": "0091_fast_inhumans",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 92,
|
||||
"version": "7",
|
||||
"when": 1777745229462,
|
||||
"tag": "0092_robust_klaw",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue