## What Tennis Grand Slam majors already store the full 128-player draw in `playoff_matches` (synced from Wikipedia), and the `PlayoffBracket` component + `tennis_128` template already exist — but users couldn't see any of it. The public league event page only rendered a bracket for `eventType === "playoff_game"`, while tennis majors are `major_tournament` events, so they fell through to the QP-only results table. ## Change - New `kind: "tennis"` branch in the event loader, gated on `isBracketMajor(simulatorType) && eventType === "major_tournament"`. Loads the bracket (primary-keyed for shared majors, via the existing read-only-sibling ownership remap) plus this window's local QP results. - The event page renders the full draw via the existing `PlayoffBracket`, followed by the QP results table. - Extracted the duplicated results-table markup into a shared `QpResultsTable` used by both the `tennis` and `results` branches. - "In Contention" table now sorts manager-drafted players first, then alphabetically by name. CS2 majors (handled earlier) and golf (`isBracketMajor` false) are unaffected. ## Verification - `npm run typecheck` — clean - `npm run test:run` — 2533 passed - `oxlint` — clean 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com> Reviewed-on: #113
229 lines
7.9 KiB
TypeScript
229 lines
7.9 KiB
TypeScript
import { Link } from "react-router";
|
|
import { ArrowLeft, Calendar } from "lucide-react";
|
|
import type { Route } from "./+types/$leagueId.sports-seasons.$sportsSeasonId.events.$eventId";
|
|
import { loader } from "./$leagueId.sports-seasons.$sportsSeasonId.events.$eventId.server";
|
|
import { formatEventDate } from "~/lib/date-utils";
|
|
import { Badge } from "~/components/ui/badge";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "~/components/ui/card";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "~/components/ui/table";
|
|
import { PlayoffBracket } from "~/components/scoring/PlayoffBracket";
|
|
import { GroupStageStandings } from "~/components/sport-season/GroupStageStandings";
|
|
import { Cs2MajorEventView } from "~/components/events/Cs2MajorEventView";
|
|
|
|
export function meta({ data }: Route.MetaArgs) {
|
|
const eventName = data?.scoringEvent?.name ?? "Event";
|
|
const leagueName = data?.league?.name ?? "League";
|
|
return [{ title: `${eventName} — ${leagueName} — Brackt` }];
|
|
}
|
|
|
|
export { loader };
|
|
|
|
type QpResult = {
|
|
id: string;
|
|
placement: number;
|
|
qualifyingPointsAwarded: string | null;
|
|
rawScore: string | null;
|
|
seasonParticipantId: string;
|
|
participantName: string | null;
|
|
};
|
|
|
|
function QpResultsTable({
|
|
eventResults,
|
|
ownershipMap,
|
|
userParticipantIds,
|
|
}: {
|
|
eventResults: QpResult[];
|
|
ownershipMap: Record<string, { teamName: string; ownerName: string; teamId: string }>;
|
|
userParticipantIds: string[];
|
|
}) {
|
|
if (eventResults.length === 0) {
|
|
return (
|
|
<Card>
|
|
<CardContent className="py-8 text-center">
|
|
<p className="text-muted-foreground text-sm">Results not yet available for this event.</p>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Results</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="pt-0">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-12">#</TableHead>
|
|
<TableHead>Participant</TableHead>
|
|
<TableHead>Manager</TableHead>
|
|
<TableHead className="text-right">QP</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{eventResults.map((r) => {
|
|
const ownership = ownershipMap[r.seasonParticipantId];
|
|
const isUser = userParticipantIds.includes(r.seasonParticipantId);
|
|
return (
|
|
<TableRow key={r.id} className={isUser ? "bg-electric/5" : undefined}>
|
|
<TableCell className="text-muted-foreground">{r.placement}</TableCell>
|
|
<TableCell className="font-medium">
|
|
{r.participantName ?? "—"}
|
|
{isUser && <span className="ml-1.5 text-xs text-electric">★</span>}
|
|
</TableCell>
|
|
<TableCell className="text-muted-foreground text-sm">
|
|
{ownership?.teamName ?? "—"}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
{r.qualifyingPointsAwarded !== null
|
|
? parseFloat(r.qualifyingPointsAwarded).toFixed(2)
|
|
: r.rawScore !== null
|
|
? r.rawScore
|
|
: "—"}
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export default function EventDetailPage({ loaderData }: Route.ComponentProps) {
|
|
const { league, sportsSeason, scoringEvent } = loaderData;
|
|
const leagueId = league.id;
|
|
const sportsSeasonId = sportsSeason.id;
|
|
|
|
const ownershipMap = Object.fromEntries(
|
|
loaderData.teamOwnerships.map((o) => [
|
|
o.participantId,
|
|
{ teamName: o.teamName, ownerName: o.ownerName ?? "", teamId: o.teamId },
|
|
])
|
|
);
|
|
|
|
const eventDate =
|
|
formatEventDate(scoringEvent.eventStartsAt as string | null) ??
|
|
formatEventDate(scoringEvent.eventDate) ??
|
|
"Date TBD";
|
|
|
|
return (
|
|
<div className="container mx-auto py-8 px-4 max-w-5xl">
|
|
{/* Breadcrumb */}
|
|
<div className="mb-6 space-y-1">
|
|
<Link
|
|
to={`/leagues/${leagueId}/sports-seasons/${sportsSeasonId}`}
|
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
<ArrowLeft className="h-3.5 w-3.5" />
|
|
{sportsSeason.name}
|
|
</Link>
|
|
<h1 className="text-2xl font-bold">{scoringEvent.name}</h1>
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<Calendar className="h-3.5 w-3.5" />
|
|
<span suppressHydrationWarning>{eventDate}</span>
|
|
{scoringEvent.isComplete && (
|
|
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 text-xs">
|
|
Complete
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* CS2 Major view */}
|
|
{loaderData.kind === "cs2" && (
|
|
<Cs2MajorEventView
|
|
cs2StageResults={loaderData.cs2StageResults}
|
|
seasonMatches={loaderData.seasonMatches}
|
|
playoffMatches={loaderData.playoffMatches}
|
|
playoffRounds={loaderData.playoffRounds}
|
|
bracketTemplateId={loaderData.bracketTemplateId}
|
|
teamOwnerships={loaderData.teamOwnerships}
|
|
userParticipantIds={loaderData.userParticipantIds}
|
|
/>
|
|
)}
|
|
|
|
{/* Bracket event (tennis/golf/soccer) */}
|
|
{loaderData.kind === "bracket" && (
|
|
<div className="space-y-6">
|
|
{loaderData.groupStandings.length > 0 && (
|
|
<GroupStageStandings
|
|
groups={loaderData.groupStandings}
|
|
ownershipMap={ownershipMap}
|
|
showEmpty={false}
|
|
/>
|
|
)}
|
|
{loaderData.playoffMatches.length > 0 ? (
|
|
<PlayoffBracket
|
|
matches={loaderData.playoffMatches}
|
|
rounds={loaderData.playoffRounds}
|
|
bracketTemplateId={loaderData.bracketTemplateId}
|
|
preEliminatedParticipants={loaderData.preEliminatedParticipants}
|
|
teamOwnerships={loaderData.teamOwnerships}
|
|
userParticipantIds={loaderData.userParticipantIds}
|
|
showOwnership={true}
|
|
mode="bracket"
|
|
/>
|
|
) : (
|
|
<Card>
|
|
<CardContent className="py-8 text-center">
|
|
<p className="text-muted-foreground text-sm">Bracket not yet available.</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Tennis Grand Slam major — draw bracket + qualifying-points results */}
|
|
{loaderData.kind === "tennis" && (
|
|
<div className="space-y-6">
|
|
{loaderData.playoffMatches.length > 0 ? (
|
|
<PlayoffBracket
|
|
matches={loaderData.playoffMatches}
|
|
rounds={loaderData.playoffRounds}
|
|
bracketTemplateId={loaderData.bracketTemplateId}
|
|
preEliminatedParticipants={loaderData.preEliminatedParticipants}
|
|
teamOwnerships={loaderData.bracketTeamOwnerships}
|
|
userParticipantIds={loaderData.bracketUserParticipantIds}
|
|
showOwnership={true}
|
|
mode="bracket"
|
|
/>
|
|
) : (
|
|
<Card>
|
|
<CardContent className="py-8 text-center">
|
|
<p className="text-muted-foreground text-sm">Bracket not yet available.</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
<QpResultsTable
|
|
eventResults={loaderData.eventResults}
|
|
ownershipMap={ownershipMap}
|
|
userParticipantIds={loaderData.userParticipantIds}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Results table (QP tournaments, racing events) */}
|
|
{loaderData.kind === "results" && (
|
|
<QpResultsTable
|
|
eventResults={loaderData.eventResults}
|
|
ownershipMap={ownershipMap}
|
|
userParticipantIds={loaderData.userParticipantIds}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|