136 lines
4.9 KiB
TypeScript
136 lines
4.9 KiB
TypeScript
|
|
import { useEffect } from "react";
|
||
|
|
import { Link, useRevalidator } from "react-router";
|
||
|
|
import { eq, inArray } from "drizzle-orm";
|
||
|
|
import type { Route } from "./+types/sports-seasons.$sportsSeasonId.tournament";
|
||
|
|
|
||
|
|
import { database } from "~/database/context";
|
||
|
|
import * as schema from "~/database/schema";
|
||
|
|
import { type PlayoffMatch } from "~/models/playoff-match";
|
||
|
|
import { findSeasonMatchesBySportsSeasonId } from "~/models/season-match";
|
||
|
|
import { getBracketTemplate, getOrderedRoundsFromMatches } from "~/lib/bracket-templates";
|
||
|
|
import { Cs2TournamentBracket } from "~/components/scoring/Cs2TournamentBracket";
|
||
|
|
import { MatchSchedule } from "~/components/scoring/MatchSchedule";
|
||
|
|
import { ArrowLeft } from "lucide-react";
|
||
|
|
|
||
|
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||
|
|
return [{ title: `${data?.sportsSeason?.name ?? "Tournament"} — Schedule & Results` }];
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function loader({ params }: Route.LoaderArgs) {
|
||
|
|
const { sportsSeasonId } = params;
|
||
|
|
const db = database();
|
||
|
|
|
||
|
|
const sportsSeason = await db.query.sportsSeasons.findFirst({
|
||
|
|
where: eq(schema.sportsSeasons.id, sportsSeasonId),
|
||
|
|
with: { sport: true },
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!sportsSeason) throw new Response("Sports season not found", { status: 404 });
|
||
|
|
|
||
|
|
const simulatorType = sportsSeason.sport?.simulatorType ?? null;
|
||
|
|
|
||
|
|
const seasonMatches = await findSeasonMatchesBySportsSeasonId(sportsSeasonId);
|
||
|
|
|
||
|
|
// Load playoff matches for bracket sports
|
||
|
|
let playoffMatches: (PlayoffMatch & { participant1: { id: string; name: string } | null; participant2: { id: string; name: string } | null; winner: { id: string; name: string } | null; loser: { id: string; name: string } | null; })[] = [];
|
||
|
|
let playoffRounds: string[] = [];
|
||
|
|
let bracketTemplateId: string | null = null;
|
||
|
|
|
||
|
|
if (simulatorType?.endsWith("_bracket") || simulatorType === "cs2_major_qualifying_points") {
|
||
|
|
const events = await db.query.scoringEvents.findMany({
|
||
|
|
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||
|
|
});
|
||
|
|
|
||
|
|
if (events.length > 0) {
|
||
|
|
const eventIds = events.map((e) => e.id);
|
||
|
|
const matches = await db.query.playoffMatches.findMany({
|
||
|
|
where: (pm) => inArray(pm.scoringEventId, eventIds),
|
||
|
|
with: {
|
||
|
|
participant1: true,
|
||
|
|
participant2: true,
|
||
|
|
winner: true,
|
||
|
|
loser: true,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
playoffMatches = matches as typeof playoffMatches;
|
||
|
|
|
||
|
|
const templateId = events.find((e) => e.bracketTemplateId)?.bracketTemplateId ?? undefined;
|
||
|
|
bracketTemplateId = templateId ?? null;
|
||
|
|
const template = templateId ? getBracketTemplate(templateId) : undefined;
|
||
|
|
playoffRounds = getOrderedRoundsFromMatches(matches, template);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const hasLiveMatches =
|
||
|
|
seasonMatches.some((m) => m.status === "in_progress") ||
|
||
|
|
playoffMatches.some((m) => !m.isComplete && (m.participant1Id !== null || m.participant2Id !== null));
|
||
|
|
|
||
|
|
return {
|
||
|
|
sportsSeason,
|
||
|
|
simulatorType,
|
||
|
|
seasonMatches,
|
||
|
|
playoffMatches,
|
||
|
|
playoffRounds,
|
||
|
|
bracketTemplateId,
|
||
|
|
hasLiveMatches,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function TournamentPage({ loaderData }: Route.ComponentProps) {
|
||
|
|
const {
|
||
|
|
sportsSeason,
|
||
|
|
simulatorType,
|
||
|
|
seasonMatches,
|
||
|
|
playoffMatches,
|
||
|
|
playoffRounds,
|
||
|
|
bracketTemplateId,
|
||
|
|
hasLiveMatches,
|
||
|
|
} = loaderData;
|
||
|
|
|
||
|
|
const { revalidate } = useRevalidator();
|
||
|
|
|
||
|
|
// Poll every 30 seconds when any match is live
|
||
|
|
useEffect(() => {
|
||
|
|
if (!hasLiveMatches) return;
|
||
|
|
const interval = setInterval(() => revalidate(), 30_000);
|
||
|
|
return () => clearInterval(interval);
|
||
|
|
}, [hasLiveMatches, revalidate]);
|
||
|
|
|
||
|
|
const isCs2 = simulatorType === "cs2_major_qualifying_points";
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="container mx-auto px-4 py-8 max-w-6xl">
|
||
|
|
<div className="mb-6">
|
||
|
|
<Link
|
||
|
|
to="/"
|
||
|
|
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground mb-4"
|
||
|
|
>
|
||
|
|
<ArrowLeft className="h-4 w-4" />
|
||
|
|
Back
|
||
|
|
</Link>
|
||
|
|
<h1 className="text-3xl font-bold">{sportsSeason.name}</h1>
|
||
|
|
{sportsSeason.sport && (
|
||
|
|
<p className="text-muted-foreground mt-1">{sportsSeason.sport.name}</p>
|
||
|
|
)}
|
||
|
|
{hasLiveMatches && (
|
||
|
|
<div className="mt-2 flex items-center gap-2 text-sm text-amber-400">
|
||
|
|
<span className="inline-block w-2 h-2 rounded-full bg-amber-400 animate-pulse" />
|
||
|
|
Live — updating every 30 seconds
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{isCs2 ? (
|
||
|
|
<Cs2TournamentBracket
|
||
|
|
swissMatches={seasonMatches as Parameters<typeof Cs2TournamentBracket>[0]["swissMatches"]}
|
||
|
|
playoffMatches={playoffMatches as Parameters<typeof Cs2TournamentBracket>[0]["playoffMatches"]}
|
||
|
|
playoffRounds={playoffRounds}
|
||
|
|
bracketTemplateId={bracketTemplateId}
|
||
|
|
/>
|
||
|
|
) : (
|
||
|
|
<MatchSchedule matches={seasonMatches as Parameters<typeof MatchSchedule>[0]["matches"]} />
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|