- Fix 1 (CRITICAL): CS2 "Sync from API" button was always 401 because it POSTed to /admin/jobs/sync-matches which requires a cron secret header that browsers can't send. Added sync-matches intent to cs2-setup action. - Fix 2 (CRITICAL): loserAdvances was hardcoded false in match-sync/index.ts, breaking NBA Play-In scoring. Now calls doesLoserAdvance(). - Fix 3: ESPN score "0" was falsy → stored as null. Non-numeric strings like "F/OT" produced NaN. Added parseScore() helper. - Fix 4: ESPN limit=1000 silently truncated MLB/NBA playoff games. Added seasonType param to EspnScheduleAdapter; all *_bracket types pass 3 (postseason only). - Fix 5: Cron job was syncing completed seasons unnecessarily. Added active status filter. - Fix 6: hasLiveMatches triggered perpetual 30s polling for unseeded brackets. Added participant presence check. - Fix 7: autoCompleteRoundIfDone was duplicated between bracket.server.ts and scoring-calculator.ts. Removed inline copy, imported shared version. - Fix 8: MatchSchedule date grouping never ran for ESPN sports (no matchday field). Removed hasMatchdays gate, always call groupByMatchday with stable UTC date key. - Fix 9: Silent failure when bracket matches exist but no scoring events. Added warning log. - Fix 10: Added 3 tests — zero score preservation, NaN from non-numeric score, seasonType in URL. https://claude.ai/code/session_01WUUM7uWzFoSkGcZRhnEKG6
138 lines
4.9 KiB
TypeScript
138 lines
4.9 KiB
TypeScript
import { useEffect } from "react";
|
|
import { 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";
|
|
import { Link } from "react-router";
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
type LoaderData = Awaited<ReturnType<typeof loader>>;
|
|
|
|
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>
|
|
);
|
|
}
|