claude/great-lovelace-r3bznh #88

Merged
chrisp merged 4 commits from claude/great-lovelace-r3bznh into main 2026-06-12 22:35:36 +00:00
8 changed files with 131 additions and 78 deletions
Showing only changes of commit ac33e9e223 - Show all commits

View file

@ -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[]) {
const groups = new Map<string, MatchWithRelations[]>();
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, []);
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 = 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>
);
}
const grouped = groupByMatchday(matches);
return (
<div className="space-y-4">
{title && <h3 className="text-lg font-semibold">{title}</h3>}
<div className="rounded-lg border border-border overflow-hidden">
{matches.map((m) => (
<MatchRow key={m.id} match={m} />
))}
</div>
{[...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>
);
}

View file

@ -27,6 +27,7 @@ import {
processMatchResult,
recalculateAffectedLeagues,
recalculateStandings,
autoCompleteRoundIfDone,
} from "~/models/scoring-calculator";
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
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") {
const matchId = formData.get("matchId");
const winnerId = formData.get("winnerId");

View file

@ -11,6 +11,7 @@ import {
clearCs2StageAssignments,
} from '~/models/cs2-major-stage';
import { findSeasonMatchesByScoringEventId } from '~/models/season-match';
import { syncMatches } from '~/services/match-sync';
import { Button } from '~/components/ui/button';
import {
Card,
@ -100,6 +101,17 @@ export async function action({ request, params }: Route.ActionArgs) {
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.' };
}
@ -336,7 +348,8 @@ export default function AdminCs2Setup() {
<CardHeader>
<CardTitle className="flex items-center justify-between">
Swiss Rounds
<syncFetcher.Form method="post" action="/admin/jobs/sync-matches">
<syncFetcher.Form method="post">
<input type="hidden" name="intent" value="sync-matches" />
<Button
type="submit"
variant="outline"

View file

@ -1,4 +1,4 @@
import { isNotNull } from "drizzle-orm";
import { and, eq, isNotNull } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { requireCronSecret } from "~/lib/cron-auth";
@ -9,7 +9,10 @@ export async function action({ request }: { request: Request }) {
const db = database();
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 },
});

View file

@ -62,8 +62,9 @@ export async function loader({ params }: Route.LoaderArgs) {
}
}
const hasLiveMatches = seasonMatches.some((m) => m.status === "in_progress")
|| playoffMatches.some((m) => !m.isComplete);
const hasLiveMatches =
seasonMatches.some((m) => m.status === "in_progress") ||
playoffMatches.some((m) => !m.isComplete && (m.participant1Id != null || m.participant2Id != null));
return {
sportsSeason,

View file

@ -168,4 +168,69 @@ describe("EspnScheduleAdapter", () => {
expect(m.startedAt).toBeInstanceOf(Date);
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")
);
});
});

View file

@ -28,17 +28,24 @@ function mapStatus(name: string | undefined, completed: boolean | undefined): Ma
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 {
readonly supportsLiveScores = false;
private readonly sport: string; // e.g. "baseball/mlb", "basketball/nba"
constructor(sport: string) {
this.sport = sport;
}
// sport: e.g. "baseball/mlb", "basketball/nba"
// 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
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);
if (!resp.ok) {
const body = await resp.text();
@ -60,8 +67,8 @@ export class EspnScheduleAdapter implements MatchSyncAdapter {
const statusType = comp.status?.type;
const status = mapStatus(statusType?.name, statusType?.completed);
const homeScore = home.score ? parseInt(home.score, 10) : null;
const awayScore = away.score ? parseInt(away.score, 10) : null;
const homeScore = parseScore(home.score);
const awayScore = parseScore(away.score);
const winnerExternalId = home.winner ? home.team.id : away.winner ? away.team.id : null;
matches.push({

View file

@ -3,7 +3,7 @@ import { eq, isNull, or } from "drizzle-orm";
import * as schema from "~/database/schema";
import { findParticipantsBySportsSeasonId, updateParticipant } from "~/models/season-participant";
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 { findMatchingTeamName } from "~/lib/normalize-team-name";
import { getBracketTemplate } from "~/lib/bracket-templates";
@ -17,15 +17,15 @@ function getMatchSyncAdapter(simulatorType: string): MatchSyncAdapter {
case "cs2_major_qualifying_points":
return new PandaScoreMatchSyncAdapter();
case "mlb_bracket":
return new EspnScheduleAdapter("baseball/mlb");
return new EspnScheduleAdapter("baseball/mlb", 3);
case "nba_bracket":
return new EspnScheduleAdapter("basketball/nba");
return new EspnScheduleAdapter("basketball/nba", 3);
case "mls_bracket":
return new EspnScheduleAdapter("soccer/mls");
return new EspnScheduleAdapter("soccer/mls", 3);
case "wnba_bracket":
return new EspnScheduleAdapter("basketball/wnba");
return new EspnScheduleAdapter("basketball/wnba", 3);
case "nhl_bracket":
return new EspnScheduleAdapter("hockey/nhl");
return new EspnScheduleAdapter("hockey/nhl", 3);
default:
throw new Error(
`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 ---
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) {
for (const event of scoringEvents) {
const existingPlayoffMatches = await findPlayoffMatchesByEventId(event.id);
@ -244,7 +248,9 @@ export async function syncMatches(sportsSeasonId: string): Promise<MatchSyncResu
eventId: event.id,
eventName: event.name ?? undefined,
matchId: playoffMatch.id,
loserAdvances: false,
loserAdvances: event.bracketTemplateId
? doesLoserAdvance(playoffMatch.round, playoffMatch.matchNumber, event.bracketTemplateId)
: false,
});
await autoCompleteRoundIfDone(event.id, playoffMatch.round, sportsSeasonId, db);