claude/great-lovelace-r3bznh #88
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[]) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue