- 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
132 lines
5 KiB
TypeScript
132 lines
5 KiB
TypeScript
import type { SeasonMatch, MatchSubGame } from "~/models/season-match";
|
|
|
|
type MatchWithRelations = SeasonMatch & {
|
|
participant1: { name: string } | null;
|
|
participant2: { name: string } | null;
|
|
winner: { name: string } | null;
|
|
subGames: MatchSubGame[];
|
|
};
|
|
|
|
interface MatchScheduleProps {
|
|
matches: MatchWithRelations[];
|
|
title?: string;
|
|
}
|
|
|
|
const STATUS_BADGE: Record<string, string> = {
|
|
scheduled: "bg-muted text-muted-foreground",
|
|
in_progress: "bg-amber-500/15 text-amber-400 border border-amber-500/30",
|
|
complete: "bg-emerald-500/15 text-emerald-400 border border-emerald-500/30",
|
|
canceled: "bg-destructive/15 text-destructive border border-destructive/30",
|
|
postponed: "bg-muted text-muted-foreground",
|
|
};
|
|
|
|
const STATUS_LABEL: Record<string, string> = {
|
|
scheduled: "Scheduled",
|
|
in_progress: "Live",
|
|
complete: "Final",
|
|
canceled: "Canceled",
|
|
postponed: "Postponed",
|
|
};
|
|
|
|
function formatDate(d: Date | null) {
|
|
if (!d) return "TBD";
|
|
return new Intl.DateTimeFormat("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
hour: "numeric",
|
|
minute: "2-digit",
|
|
timeZoneName: "short",
|
|
}).format(new Date(d));
|
|
}
|
|
|
|
function MatchRow({ match }: { match: MatchWithRelations }) {
|
|
const p1 = match.participant1?.name ?? "TBD";
|
|
const p2 = match.participant2?.name ?? "TBD";
|
|
const isComplete = match.status === "complete";
|
|
const winnerId = match.winnerId;
|
|
|
|
return (
|
|
<div className="flex items-center justify-between py-3 px-4 border-b border-border last:border-0">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex-1 min-w-0">
|
|
<div className={`text-sm font-medium truncate ${isComplete && winnerId === match.participant1Id ? "text-foreground" : isComplete ? "text-muted-foreground" : "text-foreground"}`}>
|
|
{p1}
|
|
</div>
|
|
<div className={`text-sm font-medium truncate ${isComplete && winnerId === match.participant2Id ? "text-foreground" : isComplete ? "text-muted-foreground" : "text-foreground"}`}>
|
|
{p2}
|
|
</div>
|
|
</div>
|
|
{isComplete && (
|
|
<div className="text-right tabular-nums shrink-0">
|
|
<div className={`text-sm font-bold ${winnerId === match.participant1Id ? "text-foreground" : "text-muted-foreground"}`}>
|
|
{match.participant1Score ?? "-"}
|
|
</div>
|
|
<div className={`text-sm font-bold ${winnerId === match.participant2Id ? "text-foreground" : "text-muted-foreground"}`}>
|
|
{match.participant2Score ?? "-"}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{match.status === "in_progress" && (
|
|
<div className="text-right tabular-nums shrink-0">
|
|
<div className="text-sm font-bold text-amber-400">{match.participant1Score ?? "-"}</div>
|
|
<div className="text-sm font-bold text-amber-400">{match.participant2Score ?? "-"}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{match.matchday != null && (
|
|
<div className="text-xs text-muted-foreground mt-1">Matchday {match.matchday}</div>
|
|
)}
|
|
</div>
|
|
<div className="ml-4 flex flex-col items-end gap-1 shrink-0">
|
|
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_BADGE[match.status] ?? STATUS_BADGE.scheduled}`}>
|
|
{match.status === "in_progress" && <span className="inline-block w-1.5 h-1.5 rounded-full bg-amber-400 mr-1 animate-pulse" />}
|
|
{STATUS_LABEL[match.status] ?? match.status}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground">{formatDate(match.scheduledAt)}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Groups by matchday number when available, or by UTC calendar date (YYYY-MM-DD) for date-based sports.
|
|
// UTC date keys ensure consistent grouping regardless of the viewer's timezone.
|
|
function groupByMatchday(matches: MatchWithRelations[]) {
|
|
const groups = new Map<string, MatchWithRelations[]>();
|
|
for (const m of matches) {
|
|
const key = m.matchday != null
|
|
? `Matchday ${m.matchday}`
|
|
: m.scheduledAt?.toISOString().slice(0, 10) ?? "TBD";
|
|
if (!groups.has(key)) groups.set(key, []);
|
|
groups.get(key)!.push(m);
|
|
}
|
|
return groups;
|
|
}
|
|
|
|
export function MatchSchedule({ matches, title = "Schedule" }: MatchScheduleProps) {
|
|
if (matches.length === 0) {
|
|
return (
|
|
<div className="rounded-lg border border-border p-6 text-center text-muted-foreground text-sm">
|
|
No matches scheduled yet.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const grouped = groupByMatchday(matches);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{title && <h3 className="text-lg font-semibold">{title}</h3>}
|
|
{[...grouped.entries()].map(([label, group]) => (
|
|
<div key={label} className="rounded-lg border border-border overflow-hidden">
|
|
<div className="px-4 py-2 bg-muted/30 text-sm font-medium text-muted-foreground">
|
|
{label}
|
|
</div>
|
|
{group.map((m) => (
|
|
<MatchRow key={m.id} match={m} />
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|