fix: address 10 code review issues in match sync system
- 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
This commit is contained in:
parent
999f70c4eb
commit
ac33e9e223
8 changed files with 131 additions and 78 deletions
|
|
@ -89,10 +89,14 @@ function MatchRow({ match }: { match: MatchWithRelations }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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[]) {
|
function groupByMatchday(matches: MatchWithRelations[]) {
|
||||||
const groups = new Map<string, MatchWithRelations[]>();
|
const groups = new Map<string, MatchWithRelations[]>();
|
||||||
for (const m of matches) {
|
for (const m of matches) {
|
||||||
const key = m.matchday != null ? `Matchday ${m.matchday}` : formatDate(m.scheduledAt)?.split(",")[0] ?? "TBD";
|
const key = m.matchday != null
|
||||||
|
? `Matchday ${m.matchday}`
|
||||||
|
: m.scheduledAt?.toISOString().slice(0, 10) ?? "TBD";
|
||||||
if (!groups.has(key)) groups.set(key, []);
|
if (!groups.has(key)) groups.set(key, []);
|
||||||
groups.get(key)!.push(m);
|
groups.get(key)!.push(m);
|
||||||
}
|
}
|
||||||
|
|
@ -108,35 +112,21 @@ export function MatchSchedule({ matches, title = "Schedule" }: MatchScheduleProp
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasMatchdays = matches.some((m) => m.matchday != null);
|
const grouped = groupByMatchday(matches);
|
||||||
const grouped = hasMatchdays ? groupByMatchday(matches) : null;
|
|
||||||
|
|
||||||
if (grouped) {
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{title && <h3 className="text-lg font-semibold">{title}</h3>}
|
{title && <h3 className="text-lg font-semibold">{title}</h3>}
|
||||||
<div className="rounded-lg border border-border overflow-hidden">
|
{[...grouped.entries()].map(([label, group]) => (
|
||||||
{matches.map((m) => (
|
<div key={label} className="rounded-lg border border-border overflow-hidden">
|
||||||
<MatchRow key={m.id} match={m} />
|
<div className="px-4 py-2 bg-muted/30 text-sm font-medium text-muted-foreground">
|
||||||
))}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
|
{group.map((m) => (
|
||||||
|
<MatchRow key={m.id} match={m} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import {
|
||||||
processMatchResult,
|
processMatchResult,
|
||||||
recalculateAffectedLeagues,
|
recalculateAffectedLeagues,
|
||||||
recalculateStandings,
|
recalculateStandings,
|
||||||
|
autoCompleteRoundIfDone,
|
||||||
} from "~/models/scoring-calculator";
|
} from "~/models/scoring-calculator";
|
||||||
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
||||||
import { getBracketTemplate, ALL_16_SEEDS, type BracketRegion } from "~/lib/bracket-templates";
|
import { getBracketTemplate, ALL_16_SEEDS, type BracketRegion } from "~/lib/bracket-templates";
|
||||||
|
|
@ -211,39 +212,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Auto-complete a round when every match in it is marked complete.
|
|
||||||
* Updates scoringEvents.playoffRound and calls processPlayoffEvent so that
|
|
||||||
* round placements are recorded and probabilities are refreshed.
|
|
||||||
* This replaces the manual "Complete Round" button.
|
|
||||||
*
|
|
||||||
* skipRecalculate=true because the caller already sent a Discord notification
|
|
||||||
* scoped to the current batch of matches — we don't want a second one that
|
|
||||||
* would show all previously-completed matches in the event.
|
|
||||||
*/
|
|
||||||
async function autoCompleteRoundIfDone(
|
|
||||||
eventId: string,
|
|
||||||
round: string,
|
|
||||||
sportsSeasonId: string,
|
|
||||||
db: ReturnType<typeof database>
|
|
||||||
): Promise<void> {
|
|
||||||
const allMatches = await findPlayoffMatchesByEventId(eventId);
|
|
||||||
const roundMatches = allMatches.filter((m) => m.round === round);
|
|
||||||
if (roundMatches.length === 0) return;
|
|
||||||
const allDone = roundMatches.every((m) => m.isComplete);
|
|
||||||
if (!allDone) return;
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(schema.scoringEvents)
|
|
||||||
.set({ playoffRound: round, updatedAt: new Date() })
|
|
||||||
.where(eq(schema.scoringEvents.id, eventId));
|
|
||||||
|
|
||||||
// skipProbabilities=true: callers (set-winner / set-round-winners) already ran
|
|
||||||
// updateProbabilitiesAfterResult before reaching here, so re-running would be wasted.
|
|
||||||
await processPlayoffEvent(eventId, db, { skipRecalculate: true, skipProbabilities: true });
|
|
||||||
logger.log(`[AutoComplete] Round "${round}" auto-completed for event ${eventId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (intent === "set-winner") {
|
if (intent === "set-winner") {
|
||||||
const matchId = formData.get("matchId");
|
const matchId = formData.get("matchId");
|
||||||
const winnerId = formData.get("winnerId");
|
const winnerId = formData.get("winnerId");
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
clearCs2StageAssignments,
|
clearCs2StageAssignments,
|
||||||
} from '~/models/cs2-major-stage';
|
} from '~/models/cs2-major-stage';
|
||||||
import { findSeasonMatchesByScoringEventId } from '~/models/season-match';
|
import { findSeasonMatchesByScoringEventId } from '~/models/season-match';
|
||||||
|
import { syncMatches } from '~/services/match-sync';
|
||||||
import { Button } from '~/components/ui/button';
|
import { Button } from '~/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
|
|
@ -100,6 +101,17 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
return { success: true, message: `Marked ${eliminations.length} eliminations.` };
|
return { success: true, message: `Marked ${eliminations.length} eliminations.` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (intent === 'sync-matches') {
|
||||||
|
try {
|
||||||
|
const result = await syncMatches(params.id);
|
||||||
|
const total = result.swissCreated + result.swissUpdated;
|
||||||
|
const summary = `Synced: ${total} Swiss match(es), ${result.playoffUpdated} playoff match(es).`;
|
||||||
|
return { success: true, message: summary };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, message: err instanceof Error ? err.message : 'Sync failed.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { success: false, message: 'Unknown action.' };
|
return { success: false, message: 'Unknown action.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -336,7 +348,8 @@ export default function AdminCs2Setup() {
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center justify-between">
|
<CardTitle className="flex items-center justify-between">
|
||||||
Swiss Rounds
|
Swiss Rounds
|
||||||
<syncFetcher.Form method="post" action="/admin/jobs/sync-matches">
|
<syncFetcher.Form method="post">
|
||||||
|
<input type="hidden" name="intent" value="sync-matches" />
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { isNotNull } from "drizzle-orm";
|
import { and, eq, isNotNull } from "drizzle-orm";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { requireCronSecret } from "~/lib/cron-auth";
|
import { requireCronSecret } from "~/lib/cron-auth";
|
||||||
|
|
@ -9,7 +9,10 @@ export async function action({ request }: { request: Request }) {
|
||||||
|
|
||||||
const db = database();
|
const db = database();
|
||||||
const seasons = await db.query.sportsSeasons.findMany({
|
const seasons = await db.query.sportsSeasons.findMany({
|
||||||
where: isNotNull(schema.sportsSeasons.externalSeasonId),
|
where: and(
|
||||||
|
isNotNull(schema.sportsSeasons.externalSeasonId),
|
||||||
|
eq(schema.sportsSeasons.status, "active")
|
||||||
|
),
|
||||||
with: { sport: true },
|
with: { sport: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,8 +62,9 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasLiveMatches = seasonMatches.some((m) => m.status === "in_progress")
|
const hasLiveMatches =
|
||||||
|| playoffMatches.some((m) => !m.isComplete);
|
seasonMatches.some((m) => m.status === "in_progress") ||
|
||||||
|
playoffMatches.some((m) => !m.isComplete && (m.participant1Id != null || m.participant2Id != null));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sportsSeason,
|
sportsSeason,
|
||||||
|
|
|
||||||
|
|
@ -168,4 +168,69 @@ describe("EspnScheduleAdapter", () => {
|
||||||
expect(m.startedAt).toBeInstanceOf(Date);
|
expect(m.startedAt).toBeInstanceOf(Date);
|
||||||
expect(m.completedAt).toBeNull();
|
expect(m.completedAt).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves score of 0 — does not coerce falsy string to null", async () => {
|
||||||
|
const response = {
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
id: "777",
|
||||||
|
date: "2025-04-05T18:00:00Z",
|
||||||
|
competitions: [
|
||||||
|
{
|
||||||
|
competitors: [
|
||||||
|
{ id: "10", team: { id: "10", displayName: "Team A" }, score: "0", homeAway: "home" as const, winner: false },
|
||||||
|
{ id: "11", team: { id: "11", displayName: "Team B" }, score: "3", homeAway: "away" as const, winner: true },
|
||||||
|
],
|
||||||
|
status: { type: { name: "STATUS_FINAL", completed: true } },
|
||||||
|
matchday: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: async () => response } as Response);
|
||||||
|
|
||||||
|
const [m] = await new EspnScheduleAdapter("baseball/mlb").fetchMatches("2025");
|
||||||
|
expect(m.team1Score).toBe(0);
|
||||||
|
expect(m.team2Score).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null (not NaN) for non-numeric score strings", async () => {
|
||||||
|
const response = {
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
id: "778",
|
||||||
|
date: "2025-04-06T18:00:00Z",
|
||||||
|
competitions: [
|
||||||
|
{
|
||||||
|
competitors: [
|
||||||
|
{ id: "10", team: { id: "10", displayName: "Team A" }, score: "F/OT", homeAway: "home" as const, winner: true },
|
||||||
|
{ id: "11", team: { id: "11", displayName: "Team B" }, score: "TBD", homeAway: "away" as const, winner: false },
|
||||||
|
],
|
||||||
|
status: { type: { name: "STATUS_FINAL", completed: true } },
|
||||||
|
matchday: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: async () => response } as Response);
|
||||||
|
|
||||||
|
const [m] = await new EspnScheduleAdapter("baseball/mlb").fetchMatches("2025");
|
||||||
|
expect(m.team1Score).toBeNull();
|
||||||
|
expect(m.team2Score).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends seasontype param to URL when seasonType is provided", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ events: [] }),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await new EspnScheduleAdapter("baseball/mlb", 3).fetchMatches("2025");
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("seasontype=3")
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -28,17 +28,24 @@ function mapStatus(name: string | undefined, completed: boolean | undefined): Ma
|
||||||
return "scheduled";
|
return "scheduled";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseScore(s: string | undefined): number | null {
|
||||||
|
if (s == null || s === "") return null;
|
||||||
|
const n = parseInt(s, 10);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
export class EspnScheduleAdapter implements MatchSyncAdapter {
|
export class EspnScheduleAdapter implements MatchSyncAdapter {
|
||||||
readonly supportsLiveScores = false;
|
readonly supportsLiveScores = false;
|
||||||
private readonly sport: string; // e.g. "baseball/mlb", "basketball/nba"
|
|
||||||
|
|
||||||
constructor(sport: string) {
|
// sport: e.g. "baseball/mlb", "basketball/nba"
|
||||||
this.sport = sport;
|
// seasonType: ESPN season type (1=preseason, 2=regular, 3=postseason). Pass 3 for bracket sports
|
||||||
}
|
// to avoid the scoreboard limit silently truncating regular-season games.
|
||||||
|
constructor(private readonly sport: string, private readonly seasonType?: number) {}
|
||||||
|
|
||||||
// externalSeasonId is "YYYY" year string for ESPN sports
|
// externalSeasonId is "YYYY" year string for ESPN sports
|
||||||
async fetchMatches(externalSeasonId: string): Promise<MatchRecord[]> {
|
async fetchMatches(externalSeasonId: string): Promise<MatchRecord[]> {
|
||||||
const url = `https://site.api.espn.com/apis/site/v2/sports/${this.sport}/scoreboard?limit=1000&dates=${externalSeasonId}`;
|
const seasonTypeParam = this.seasonType != null ? `&seasontype=${this.seasonType}` : "";
|
||||||
|
const url = `https://site.api.espn.com/apis/site/v2/sports/${this.sport}/scoreboard?limit=1000&dates=${externalSeasonId}${seasonTypeParam}`;
|
||||||
const resp = await fetch(url);
|
const resp = await fetch(url);
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const body = await resp.text();
|
const body = await resp.text();
|
||||||
|
|
@ -60,8 +67,8 @@ export class EspnScheduleAdapter implements MatchSyncAdapter {
|
||||||
const statusType = comp.status?.type;
|
const statusType = comp.status?.type;
|
||||||
const status = mapStatus(statusType?.name, statusType?.completed);
|
const status = mapStatus(statusType?.name, statusType?.completed);
|
||||||
|
|
||||||
const homeScore = home.score ? parseInt(home.score, 10) : null;
|
const homeScore = parseScore(home.score);
|
||||||
const awayScore = away.score ? parseInt(away.score, 10) : null;
|
const awayScore = parseScore(away.score);
|
||||||
const winnerExternalId = home.winner ? home.team.id : away.winner ? away.team.id : null;
|
const winnerExternalId = home.winner ? home.team.id : away.winner ? away.team.id : null;
|
||||||
|
|
||||||
matches.push({
|
matches.push({
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { eq, isNull, or } from "drizzle-orm";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { findParticipantsBySportsSeasonId, updateParticipant } from "~/models/season-participant";
|
import { findParticipantsBySportsSeasonId, updateParticipant } from "~/models/season-participant";
|
||||||
import { upsertSeasonMatchBulk, upsertMatchSubGame } from "~/models/season-match";
|
import { upsertSeasonMatchBulk, upsertMatchSubGame } from "~/models/season-match";
|
||||||
import { setMatchWinner, updatePlayoffMatch, advanceWinnerTemplate, findPlayoffMatchesByEventId } from "~/models/playoff-match";
|
import { setMatchWinner, updatePlayoffMatch, advanceWinnerTemplate, findPlayoffMatchesByEventId, doesLoserAdvance } from "~/models/playoff-match";
|
||||||
import { processMatchResult, autoCompleteRoundIfDone } from "~/models/scoring-calculator";
|
import { processMatchResult, autoCompleteRoundIfDone } from "~/models/scoring-calculator";
|
||||||
import { findMatchingTeamName } from "~/lib/normalize-team-name";
|
import { findMatchingTeamName } from "~/lib/normalize-team-name";
|
||||||
import { getBracketTemplate } from "~/lib/bracket-templates";
|
import { getBracketTemplate } from "~/lib/bracket-templates";
|
||||||
|
|
@ -17,15 +17,15 @@ function getMatchSyncAdapter(simulatorType: string): MatchSyncAdapter {
|
||||||
case "cs2_major_qualifying_points":
|
case "cs2_major_qualifying_points":
|
||||||
return new PandaScoreMatchSyncAdapter();
|
return new PandaScoreMatchSyncAdapter();
|
||||||
case "mlb_bracket":
|
case "mlb_bracket":
|
||||||
return new EspnScheduleAdapter("baseball/mlb");
|
return new EspnScheduleAdapter("baseball/mlb", 3);
|
||||||
case "nba_bracket":
|
case "nba_bracket":
|
||||||
return new EspnScheduleAdapter("basketball/nba");
|
return new EspnScheduleAdapter("basketball/nba", 3);
|
||||||
case "mls_bracket":
|
case "mls_bracket":
|
||||||
return new EspnScheduleAdapter("soccer/mls");
|
return new EspnScheduleAdapter("soccer/mls", 3);
|
||||||
case "wnba_bracket":
|
case "wnba_bracket":
|
||||||
return new EspnScheduleAdapter("basketball/wnba");
|
return new EspnScheduleAdapter("basketball/wnba", 3);
|
||||||
case "nhl_bracket":
|
case "nhl_bracket":
|
||||||
return new EspnScheduleAdapter("hockey/nhl");
|
return new EspnScheduleAdapter("hockey/nhl", 3);
|
||||||
default:
|
default:
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`No match sync adapter available for simulator type "${simulatorType}". ` +
|
`No match sync adapter available for simulator type "${simulatorType}". ` +
|
||||||
|
|
@ -169,6 +169,10 @@ export async function syncMatches(sportsSeasonId: string): Promise<MatchSyncResu
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Bracket matches → playoff_matches ---
|
// --- Bracket matches → playoff_matches ---
|
||||||
|
if (bracketMatches.length > 0 && scoringEvents.length === 0) {
|
||||||
|
logger.warn(`[match-sync] ${bracketMatches.length} bracket match(es) found but no scoring events exist for season ${sportsSeasonId} — bracket sync skipped`);
|
||||||
|
}
|
||||||
|
|
||||||
if (bracketMatches.length > 0) {
|
if (bracketMatches.length > 0) {
|
||||||
for (const event of scoringEvents) {
|
for (const event of scoringEvents) {
|
||||||
const existingPlayoffMatches = await findPlayoffMatchesByEventId(event.id);
|
const existingPlayoffMatches = await findPlayoffMatchesByEventId(event.id);
|
||||||
|
|
@ -244,7 +248,9 @@ export async function syncMatches(sportsSeasonId: string): Promise<MatchSyncResu
|
||||||
eventId: event.id,
|
eventId: event.id,
|
||||||
eventName: event.name ?? undefined,
|
eventName: event.name ?? undefined,
|
||||||
matchId: playoffMatch.id,
|
matchId: playoffMatch.id,
|
||||||
loserAdvances: false,
|
loserAdvances: event.bracketTemplateId
|
||||||
|
? doesLoserAdvance(playoffMatch.round, playoffMatch.matchNumber, event.bracketTemplateId)
|
||||||
|
: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await autoCompleteRoundIfDone(event.id, playoffMatch.round, sportsSeasonId, db);
|
await autoCompleteRoundIfDone(event.id, playoffMatch.round, sportsSeasonId, db);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue