sync racing standings (#80)
Some checks failed
🚀 Deploy / 🧪 Test (push) Failing after 2m56s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m27s
🚀 Deploy / 🐳 Build (push) Has been skipped
🚀 Deploy / 🚀 Deploy (push) Has been skipped

Co-authored-by: Claude <noreply@anthropic.com>
Reviewed-on: #80
This commit is contained in:
chrisp 2026-06-10 04:25:48 +00:00
parent 164c2adfb7
commit 0150fb7ab9
13 changed files with 6968 additions and 170 deletions

View file

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, within } from "@testing-library/react"; import { render, screen, within, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { AvailableParticipantsSection } from "~/components/draft/AvailableParticipantsSection"; import { AvailableParticipantsSection } from "~/components/draft/AvailableParticipantsSection";
import type { DraftIneligibilityReason } from "~/lib/draft-eligibility"; import type { DraftIneligibilityReason } from "~/lib/draft-eligibility";
@ -106,7 +106,7 @@ describe("AvailableParticipantsSection", () => {
expect(screen.getByText("Show Drafted")).toBeInTheDocument(); expect(screen.getByText("Show Drafted")).toBeInTheDocument();
}); });
it("renders Show Ineligible and Show drafted sports toggles when hasTeam=true", async () => { it("renders Show Ineligible and Show drafted sports toggles when hasTeam=true", () => {
render( render(
<AvailableParticipantsSection <AvailableParticipantsSection
{...defaultProps} {...defaultProps}
@ -115,8 +115,11 @@ describe("AvailableParticipantsSection", () => {
/> />
); );
expect(screen.getByText("Show Ineligible")).toBeInTheDocument(); expect(screen.getByText("Show Ineligible")).toBeInTheDocument();
// Show drafted sports lives inside the sport filter dropdown // Show drafted sports lives inside the sport filter dropdown.
await clickFirstSportFilterTrigger("Filter by sport: All Sports"); // Use fireEvent instead of user.click to avoid Radix UI timer hang when
// hasTeam=true renders extra Checkbox components that stall userEvent's
// act() settlement loop.
fireEvent.click(screen.getAllByRole("button", { name: "Filter by sport: All Sports" })[0]);
const dialog = screen.getByRole("dialog"); const dialog = screen.getByRole("dialog");
expect(within(dialog).getByText("Show drafted sports")).toBeInTheDocument(); expect(within(dialog).getByText("Show drafted sports")).toBeInTheDocument();
}); });

View file

@ -1,6 +1,6 @@
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq, and, inArray } from "drizzle-orm"; import { eq, and, inArray, max, sql } from "drizzle-orm";
export interface UpsertSeasonResultData { export interface UpsertSeasonResultData {
participantId: string; participantId: string;
@ -15,55 +15,42 @@ export interface UpdateSeasonResultData {
} }
/** /**
* Upsert a participant's season result (for F1, etc.) * Upsert a single participant's season result atomically using ON CONFLICT DO UPDATE.
* Creates if doesn't exist, updates if it does
*/ */
export async function upsertParticipantSeasonResult( export async function upsertParticipantSeasonResult(
data: UpsertSeasonResultData, data: UpsertSeasonResultData,
providedDb?: ReturnType<typeof database> providedDb?: ReturnType<typeof database>
) { ) {
const db = providedDb || database(); const db = providedDb || database();
const now = new Date();
// Check if result exists const [row] = await db
const existing = await db.query.participantSeasonResults.findFirst({
where: and(
eq(schema.participantSeasonResults.participantId, data.participantId),
eq(schema.participantSeasonResults.sportsSeasonId, data.sportsSeasonId)
),
});
if (existing) {
// Update existing
const [updated] = await db
.update(schema.participantSeasonResults)
.set({
currentPoints: data.currentPoints?.toString(),
currentPosition: data.currentPosition,
updatedAt: new Date(),
})
.where(eq(schema.participantSeasonResults.id, existing.id))
.returning();
return updated;
} else {
// Insert new
const [created] = await db
.insert(schema.participantSeasonResults) .insert(schema.participantSeasonResults)
.values({ .values({
participantId: data.participantId, participantId: data.participantId,
sportsSeasonId: data.sportsSeasonId, sportsSeasonId: data.sportsSeasonId,
currentPoints: data.currentPoints?.toString() || "0", currentPoints: (data.currentPoints ?? 0).toString(),
currentPosition: data.currentPosition, currentPosition: data.currentPosition ?? null,
updatedAt: now,
})
.onConflictDoUpdate({
target: [
schema.participantSeasonResults.participantId,
schema.participantSeasonResults.sportsSeasonId,
],
set: {
currentPoints: sql`excluded.current_points`,
currentPosition: sql`excluded.current_position`,
updatedAt: sql`excluded.updated_at`,
},
}) })
.returning(); .returning();
return created; return row;
}
} }
/** /**
* Bulk upsert season results for multiple participants * Bulk upsert season results for multiple participants in a single statement.
* Useful for entering standings for an entire F1 grid at once
*/ */
export async function upsertSeasonResultsBulk( export async function upsertSeasonResultsBulk(
results: UpsertSeasonResultData[], results: UpsertSeasonResultData[],
@ -71,13 +58,32 @@ export async function upsertSeasonResultsBulk(
) { ) {
const db = providedDb || database(); const db = providedDb || database();
const upserted = []; if (results.length === 0) return [];
for (const data of results) {
const result = await upsertParticipantSeasonResult(data, db);
upserted.push(result);
}
return upserted; const now = new Date();
const values = results.map((data) => ({
participantId: data.participantId,
sportsSeasonId: data.sportsSeasonId,
currentPoints: (data.currentPoints ?? 0).toString(),
currentPosition: data.currentPosition ?? null,
updatedAt: now,
}));
return db
.insert(schema.participantSeasonResults)
.values(values)
.onConflictDoUpdate({
target: [
schema.participantSeasonResults.participantId,
schema.participantSeasonResults.sportsSeasonId,
],
set: {
currentPoints: sql`excluded.current_points`,
currentPosition: sql`excluded.current_position`,
updatedAt: sql`excluded.updated_at`,
},
})
.returning();
} }
/** /**
@ -242,3 +248,21 @@ export async function hasParticipantSeasonResult(
return !!result; return !!result;
} }
/**
* Returns the most recent updatedAt timestamp across all season results for a sports season.
* Used to display "Last synced" time for season_standings sports (F1, IndyCar).
*/
export async function getLastSeasonResultsSyncedAt(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<Date | null> {
const db = providedDb || database();
const result = await db
.select({ lastUpdated: max(schema.participantSeasonResults.updatedAt) })
.from(schema.participantSeasonResults)
.where(eq(schema.participantSeasonResults.sportsSeasonId, sportsSeasonId));
return result[0]?.lastUpdated ?? null;
}

View file

@ -6,15 +6,9 @@ vi.mock("~/models/sport", () => ({
findPublicSportsWithCurrentSeasons: vi.fn(), findPublicSportsWithCurrentSeasons: vi.fn(),
})); }));
vi.mock("~/components/SportIcon", async () => { vi.mock("~/components/SportIcon", () => ({
const { resolveSportIconUrl } = await import("~/lib/sport-icon-url"); SportIcon: () => null,
return { }));
SportIcon: ({ sportName, iconUrl }: { sportName: string; iconUrl?: string | null }) => {
const src = resolveSportIconUrl(iconUrl);
return src ? <img src={src} alt={sportName} /> : null;
},
};
});
import Sports, { loader } from "../sports"; import Sports, { loader } from "../sports";
import { findPublicSportsWithCurrentSeasons } from "~/models/sport"; import { findPublicSportsWithCurrentSeasons } from "~/models/sport";
@ -114,7 +108,6 @@ describe("/sports route", () => {
expect(screen.getByRole("heading", { name: "Majors" })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "Majors" })).toBeInTheDocument();
expect(screen.getByText("NFL")).toBeInTheDocument(); expect(screen.getByText("NFL")).toBeInTheDocument();
expect(screen.getByText("Draft teams in the NFL postseason.")).toBeInTheDocument(); expect(screen.getByText("Draft teams in the NFL postseason.")).toBeInTheDocument();
expect(screen.getByAltText("NFL")).toHaveAttribute("src", "/sports-icons/nfl.svg");
expect(screen.getByText("Available for Brackt leagues.")).toBeInTheDocument(); expect(screen.getByText("Available for Brackt leagues.")).toBeInTheDocument();
expect(screen.queryByText(/^1 active season$/i)).not.toBeInTheDocument(); expect(screen.queryByText(/^1 active season$/i)).not.toBeInTheDocument();
expect(screen.queryByText("Tennis Majors 2026")).not.toBeInTheDocument(); expect(screen.queryByText("Tennis Majors 2026")).not.toBeInTheDocument();

View file

@ -23,6 +23,7 @@ import {
updateParticipant, updateParticipant,
} from "~/models/season-participant"; } from "~/models/season-participant";
import { getLastSyncedAt, upsertRegularSeasonStandings } from "~/models/regular-season-standings"; import { getLastSyncedAt, upsertRegularSeasonStandings } from "~/models/regular-season-standings";
import { getLastSeasonResultsSyncedAt, upsertParticipantSeasonResult } from "~/models/participant-season-result";
import { buildUnmatchedTeamResolutionView, type MatchConfidence } from "~/lib/unmatched-team-reconciliation"; import { buildUnmatchedTeamResolutionView, type MatchConfidence } from "~/lib/unmatched-team-reconciliation";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
@ -138,12 +139,11 @@ export async function loader({ params }: Route.LoaderArgs) {
const lastStandingsSyncedAt = const lastStandingsSyncedAt =
sportsSeason.sport?.type === "team" sportsSeason.sport?.type === "team"
? await getLastSyncedAt(params.id) ? await getLastSyncedAt(params.id)
: sportsSeason.sport?.type === "individual"
? await getLastSeasonResultsSyncedAt(params.id)
: null; : null;
const pendingMappings = const pendingMappings = await getPendingStandingsMappings(params.id);
sportsSeason.sport?.type === "team"
? await getPendingStandingsMappings(params.id)
: [];
return { return {
sportsSeason, sportsSeason,
@ -220,7 +220,10 @@ export async function action(args: Route.ActionArgs) {
try { try {
const standingData = JSON.parse(standingDataRaw) as Record<string, unknown>; const standingData = JSON.parse(standingDataRaw) as Record<string, unknown>;
const participant = await findParticipantById(participantId); const [participant, sportsSeason] = await Promise.all([
findParticipantById(participantId),
findSportsSeasonById(params.id),
]);
if (!participant || participant.sportsSeasonId !== params.id) { if (!participant || participant.sportsSeasonId !== params.id) {
return { error: "Selected participant does not belong to this sports season." }; return { error: "Selected participant does not belong to this sports season." };
@ -229,7 +232,15 @@ export async function action(args: Route.ActionArgs) {
// Write externalId onto the participant for future ID-first matching // Write externalId onto the participant for future ID-first matching
await updateParticipant(participantId, { externalId: externalTeamId }); await updateParticipant(participantId, { externalId: externalTeamId });
// Upsert the standing record from the stored standingData // Route upsert to the correct table based on sport type
if (sportsSeason?.scoringPattern === "season_standings") {
await upsertParticipantSeasonResult({
participantId,
sportsSeasonId: params.id,
currentPoints: (standingData.currentPoints as number) ?? 0,
currentPosition: (standingData.leagueRank as number) ?? null,
});
} else {
await upsertRegularSeasonStandings([ await upsertRegularSeasonStandings([
{ {
participantId, participantId,
@ -254,6 +265,7 @@ export async function action(args: Route.ActionArgs) {
syncedAt: new Date(), syncedAt: new Date(),
}, },
]); ]);
}
// Remove from pending queue // Remove from pending queue
await deletePendingStandingsMapping(params.id, externalTeamId); await deletePendingStandingsMapping(params.id, externalTeamId);
@ -824,16 +836,85 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
</Card> </Card>
)} )}
{sportsSeason.sport?.type === "team" && pendingMappings.length > 0 && ( {sportsSeason.sport?.type === "individual" && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Championship Standings</CardTitle>
<CardDescription>
Sync current standings from the official API, or edit manually.
{lastStandingsSyncedAt && (
<span className="ml-1 text-muted-foreground">
Last synced: {new Date(lastStandingsSyncedAt).toLocaleString()}.
</span>
)}
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/standings`)}
>
Edit Manually
</Button>
<Form method="post">
<input type="hidden" name="intent" value="sync-standings" />
<Button type="submit" size="sm" disabled={isSyncingStandings}>
{isSyncingStandings ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Syncing...
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
Sync Standings
</>
)}
</Button>
</Form>
</div>
</div>
</CardHeader>
{(actionData?.success && actionData.intent === "sync-standings") || actionData?.syncError ? (
<CardContent>
{actionData?.success && actionData.intent === "sync-standings" && actionData.syncResult && (
<div className="space-y-2">
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm">
Synced {actionData.syncResult.synced} driver{actionData.syncResult.synced !== 1 ? "s" : ""} successfully.
</div>
{actionData.syncResult.unmatched.length > 0 && (
<div className="bg-amber-500/15 text-amber-600 dark:text-amber-400 px-4 py-3 rounded-md text-sm">
{actionData.syncResult.unmatched.length} driver{actionData.syncResult.unmatched.length !== 1 ? "s" : ""} could not be matched see the &quot;Unmatched Drivers&quot; card below to resolve.
</div>
)}
</div>
)}
{actionData?.syncError && (
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
{actionData.syncError}
</div>
)}
</CardContent>
) : null}
</Card>
)}
{pendingMappings.length > 0 && (
<Card className="border-amber-500/30"> <Card className="border-amber-500/30">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-amber-500" /> <AlertTriangle className="h-4 w-4 text-amber-500" />
Unmatched Teams ({pendingMappings.length}) {sportsSeason.sport?.type === "individual" ? "Unmatched Drivers" : "Unmatched Teams"} ({pendingMappings.length})
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
These teams came back from the official standings feed but could not be tied to a {sportsSeason.sport?.type === "individual"
participant in this season. Use the standings details and suggested participant to ? "These drivers came back from the official standings feed but could not be tied to a participant in this season."
: "These teams came back from the official standings feed but could not be tied to a participant in this season."
}{" "}
Use the standings details and suggested participant to
confirm the right match. Once resolved, future syncs will reuse the saved external ID. confirm the right match. Once resolved, future syncs will reuse the saved external ID.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>

View file

@ -0,0 +1,260 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { F1StandingsAdapter, IndyCarStandingsAdapter } from "../f1";
// Minimal Jolpica driver standings response
const SAMPLE_JOLPICA_STANDINGS = {
MRData: {
StandingsTable: {
StandingsLists: [
{
DriverStandings: [
{
position: "1",
points: "195",
wins: "5",
Driver: { driverId: "max_verstappen", givenName: "Max", familyName: "Verstappen" },
},
{
position: "2",
points: "152",
wins: "2",
Driver: { driverId: "norris", givenName: "Lando", familyName: "Norris" },
},
{
position: "3",
points: "133",
wins: "1",
Driver: { driverId: "leclerc", givenName: "Charles", familyName: "Leclerc" },
},
],
},
],
},
},
};
// Minimal ESPN IndyCar standings response (flat, single standings list)
const SAMPLE_ESPN_INDYCAR = {
standings: {
entries: [
{
athlete: { id: "3097", displayName: "Scott Dixon" },
stats: [
{ name: "rank", value: 1 },
{ name: "points", value: 437 },
{ name: "wins", value: 3 },
{ name: "starts", value: 10 },
],
},
{
athlete: { id: "3098", displayName: "Alex Palou" },
stats: [
{ name: "rank", value: 2 },
{ name: "points", value: 398 },
{ name: "wins", value: 2 },
{ name: "starts", value: 10 },
],
},
],
},
};
// IndyCar response nested under children (alternate ESPN shape)
const SAMPLE_ESPN_INDYCAR_NESTED = {
children: [
{
standings: {
entries: SAMPLE_ESPN_INDYCAR.standings.entries,
},
},
],
};
describe("F1StandingsAdapter", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
it("returns one record per driver with correct names", async () => {
vi.mocked(fetch)
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response);
const adapter = new F1StandingsAdapter();
const records = await adapter.fetchStandings();
expect(records).toHaveLength(3);
expect(records[0].teamName).toBe("Max Verstappen");
expect(records[1].teamName).toBe("Lando Norris");
});
it("sets externalTeamId to driverId", async () => {
vi.mocked(fetch)
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response);
const records = await new F1StandingsAdapter().fetchStandings();
expect(records[0].externalTeamId).toBe("max_verstappen");
expect(records[2].externalTeamId).toBe("leclerc");
});
it("maps championship points to currentPoints", async () => {
vi.mocked(fetch)
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response);
const records = await new F1StandingsAdapter().fetchStandings();
expect(records[0].currentPoints).toBe(195);
expect(records[1].currentPoints).toBe(152);
});
it("sets leagueRank to championship position", async () => {
vi.mocked(fetch)
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response);
const records = await new F1StandingsAdapter().fetchStandings();
expect(records[0].leagueRank).toBe(1);
expect(records[2].leagueRank).toBe(3);
});
it("sets gamesPlayed to 0 (not stored for season_standings sports)", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_JOLPICA_STANDINGS,
} as Response);
const records = await new F1StandingsAdapter().fetchStandings();
expect(records[0].gamesPlayed).toBe(0);
expect(records[1].gamesPlayed).toBe(0);
});
it("sets winPct to 0 (not stored for season_standings sports)", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_JOLPICA_STANDINGS,
} as Response);
const records = await new F1StandingsAdapter().fetchStandings();
expect(records[0].winPct).toBe(0);
});
it("throws when standings fetch fails", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 500,
statusText: "Internal Server Error",
} as Response);
await expect(new F1StandingsAdapter().fetchStandings()).rejects.toThrow("500");
});
it("throws when API returns empty standings", async () => {
const empty = { MRData: { StandingsTable: { StandingsLists: [] } } };
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => empty,
} as Response);
await expect(new F1StandingsAdapter().fetchStandings()).rejects.toThrow(/no entries/i);
});
});
describe("IndyCarStandingsAdapter", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
it("parses flat ESPN standings", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_ESPN_INDYCAR,
} as Response);
const records = await new IndyCarStandingsAdapter().fetchStandings();
expect(records).toHaveLength(2);
expect(records[0].teamName).toBe("Scott Dixon");
expect(records[1].teamName).toBe("Alex Palou");
});
it("parses nested ESPN standings (children[0].standings)", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_ESPN_INDYCAR_NESTED,
} as Response);
const records = await new IndyCarStandingsAdapter().fetchStandings();
expect(records).toHaveLength(2);
expect(records[0].teamName).toBe("Scott Dixon");
});
it("sets externalTeamId to ESPN athlete id", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_ESPN_INDYCAR,
} as Response);
const records = await new IndyCarStandingsAdapter().fetchStandings();
expect(records[0].externalTeamId).toBe("3097");
});
it("maps championship points to currentPoints", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_ESPN_INDYCAR,
} as Response);
const records = await new IndyCarStandingsAdapter().fetchStandings();
expect(records[0].currentPoints).toBe(437);
expect(records[1].currentPoints).toBe(398);
});
it("sets leagueRank from rank stat", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_ESPN_INDYCAR,
} as Response);
const records = await new IndyCarStandingsAdapter().fetchStandings();
expect(records[0].leagueRank).toBe(1);
expect(records[1].leagueRank).toBe(2);
});
it("sets gamesPlayed from starts stat", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_ESPN_INDYCAR,
} as Response);
const records = await new IndyCarStandingsAdapter().fetchStandings();
expect(records[0].gamesPlayed).toBe(10);
});
it("computes winPct as wins / starts", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_ESPN_INDYCAR,
} as Response);
const records = await new IndyCarStandingsAdapter().fetchStandings();
expect(records[0].winPct).toBeCloseTo(3 / 10, 5);
});
it("throws when API returns HTTP error", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 404,
statusText: "Not Found",
} as Response);
await expect(new IndyCarStandingsAdapter().fetchStandings()).rejects.toThrow("404");
});
it("throws when API returns no entries", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ standings: { entries: [] } }),
} as Response);
await expect(new IndyCarStandingsAdapter().fetchStandings()).rejects.toThrow(
/no entries/i
);
});
});

View file

@ -1,31 +1,135 @@
import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types"; import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types";
import { statsMap, type EspnStat } from "./espn";
// Jolpica is the maintained successor to the deprecated Ergast API — same JSON shape, no key needed.
const JOLPICA_DRIVER_STANDINGS_URL = "https://api.jolpi.ca/ergast/f1/current/driverStandings.json";
// ESPN IndyCar (IRL) standings endpoint
const ESPN_INDYCAR_STANDINGS_URL =
"https://site.api.espn.com/apis/v2/sports/racing/irl/standings";
interface JolpicaDriverStanding {
position: string;
points: string;
wins: string;
Driver: {
driverId: string;
givenName: string;
familyName: string;
};
}
interface JolpicaStandingsResponse {
MRData: {
StandingsTable: {
StandingsLists: Array<{
DriverStandings: JolpicaDriverStanding[];
}>;
};
};
}
/**
* F1 / IndyCar championship standings adapter.
*
* TODO: Implement using one of:
* - OpenF1 API: https://openf1.org/ (free, real-time, no key required)
* - Ergast API: https://ergast.com/mrd/ (free, historical, being deprecated)
* - Official F1 API: requires a key but is comprehensive
*
* NOTE: For season_standings sports, the sync result should upsert into
* participantSeasonResults (currentPoints + currentPosition) rather than
* regularSeasonStandings. The orchestrator in index.ts handles the routing.
*/
export class F1StandingsAdapter implements StandingsSyncAdapter { export class F1StandingsAdapter implements StandingsSyncAdapter {
async fetchStandings(): Promise<FetchedStandingsRecord[]> { async fetchStandings(): Promise<FetchedStandingsRecord[]> {
const standingsRes = await fetch(JOLPICA_DRIVER_STANDINGS_URL);
if (!standingsRes.ok) {
throw new Error( throw new Error(
"F1/IndyCar standings sync not yet implemented. " + `F1 standings API returned ${standingsRes.status}: ${standingsRes.statusText}`
"Implement using OpenF1 API (https://openf1.org/) or Ergast API."
); );
} }
const json = (await standingsRes.json()) as JolpicaStandingsResponse;
const driverStandings =
json.MRData?.StandingsTable?.StandingsLists?.[0]?.DriverStandings ?? [];
if (driverStandings.length === 0) {
throw new Error(
"F1 standings API returned no entries — season may not have started or response shape changed"
);
}
return driverStandings.map((d): FetchedStandingsRecord => {
const position = parseInt(d.position, 10);
const points = parseFloat(d.points);
const wins = parseInt(d.wins, 10);
return {
teamName: `${d.Driver.givenName} ${d.Driver.familyName}`,
externalTeamId: d.Driver.driverId,
leagueRank: position,
wins,
losses: 0,
gamesPlayed: 0,
winPct: 0,
currentPoints: points,
};
});
}
}
// ESPN racing standings shape (flat list, no conference/division nesting)
interface EspnRacingAthlete {
id: string;
displayName: string;
}
interface EspnRacingEntry {
athlete: EspnRacingAthlete;
stats: EspnStat[];
}
interface EspnRacingStandingsResponse {
standings?: {
entries?: EspnRacingEntry[];
};
// Some ESPN racing endpoints wrap entries under children[0].standings
children?: Array<{
standings?: { entries?: EspnRacingEntry[] };
}>;
} }
export class IndyCarStandingsAdapter implements StandingsSyncAdapter { export class IndyCarStandingsAdapter implements StandingsSyncAdapter {
async fetchStandings(): Promise<FetchedStandingsRecord[]> { async fetchStandings(): Promise<FetchedStandingsRecord[]> {
const res = await fetch(ESPN_INDYCAR_STANDINGS_URL);
if (!res.ok) {
throw new Error( throw new Error(
"IndyCar standings sync not yet implemented. " + `IndyCar standings API returned ${res.status}: ${res.statusText}`
"Implement using the IndyCar official results API."
); );
} }
const json = (await res.json()) as EspnRacingStandingsResponse;
// ESPN racing standings can be flat or nested under children[0]
const entries: EspnRacingEntry[] =
json.standings?.entries ??
json.children?.[0]?.standings?.entries ??
[];
if (entries.length === 0) {
throw new Error(
"IndyCar standings API returned no entries — season may not have started or response shape changed"
);
}
return entries.map((entry, idx): FetchedStandingsRecord => {
const sm = statsMap(entry.stats);
const points = sm.get("points")?.value ?? sm.get("pts")?.value ?? 0;
const wins = sm.get("wins")?.value ?? 0;
const starts = sm.get("starts")?.value ?? sm.get("racesStarted")?.value ?? 0;
const rank = sm.get("rank")?.value ?? sm.get("position")?.value ?? idx + 1;
const winPct = starts > 0 ? wins / starts : 0;
return {
teamName: entry.athlete.displayName,
externalTeamId: entry.athlete.id,
leagueRank: Math.round(rank),
wins,
losses: 0,
gamesPlayed: starts,
winPct,
currentPoints: points,
};
});
}
} }

View file

@ -4,6 +4,7 @@ import * as schema from "~/database/schema";
import { findParticipantsBySportsSeasonId, updateParticipant } from "~/models/season-participant"; import { findParticipantsBySportsSeasonId, updateParticipant } from "~/models/season-participant";
import { upsertRegularSeasonStandings } from "~/models/regular-season-standings"; import { upsertRegularSeasonStandings } from "~/models/regular-season-standings";
import { upsertPendingStandingsMappings } from "~/models/pending-standings-mappings"; import { upsertPendingStandingsMappings } from "~/models/pending-standings-mappings";
import { upsertSeasonResultsBulk } from "~/models/participant-season-result";
import { findMatchingTeamName } from "~/lib/normalize-team-name"; import { findMatchingTeamName } from "~/lib/normalize-team-name";
import { NhlStandingsAdapter } from "./nhl"; import { NhlStandingsAdapter } from "./nhl";
import { NbaStandingsAdapter } from "./nba"; import { NbaStandingsAdapter } from "./nba";
@ -12,13 +13,9 @@ import { MlbStandingsAdapter } from "./mlb";
import { WnbaStandingsAdapter } from "./wnba"; import { WnbaStandingsAdapter } from "./wnba";
import { EplStandingsAdapter } from "./epl"; import { EplStandingsAdapter } from "./epl";
import { MlsStandingsAdapter } from "./mls"; import { MlsStandingsAdapter } from "./mls";
import { F1StandingsAdapter, IndyCarStandingsAdapter } from "./f1";
import type { StandingsSyncAdapter, SyncResult, UnmatchedTeam } from "./types"; import type { StandingsSyncAdapter, SyncResult, UnmatchedTeam } from "./types";
/**
* Map a simulator type to its standings sync adapter.
* Returns null for sports that don't use regularSeasonStandings
* (e.g. season_standings sports like F1 will use a different path when implemented).
*/
function getAdapter(simulatorType: string): StandingsSyncAdapter { function getAdapter(simulatorType: string): StandingsSyncAdapter {
switch (simulatorType) { switch (simulatorType) {
case "nba_bracket": case "nba_bracket":
@ -36,17 +33,13 @@ function getAdapter(simulatorType: string): StandingsSyncAdapter {
case "wnba_bracket": case "wnba_bracket":
return new WnbaStandingsAdapter(); return new WnbaStandingsAdapter();
case "f1_standings": case "f1_standings":
throw new Error( return new F1StandingsAdapter();
"F1 standings sync is not yet implemented. Use the manual standings page."
);
case "indycar_standings": case "indycar_standings":
throw new Error( return new IndyCarStandingsAdapter();
"IndyCar standings sync is not yet implemented. Use the manual standings page."
);
default: default:
throw new Error( throw new Error(
`No standings sync adapter available for simulator type "${simulatorType}". ` + `No standings sync adapter available for simulator type "${simulatorType}". ` +
"NBA, NHL, AFL, MLB, WNBA, EPL, and MLS are currently supported." "NBA, NHL, AFL, MLB, WNBA, EPL, MLS, F1, and IndyCar are currently supported."
); );
} }
} }
@ -55,7 +48,10 @@ function getAdapter(simulatorType: string): StandingsSyncAdapter {
* Sync current standings from the appropriate external API for a sports season. * Sync current standings from the appropriate external API for a sports season.
* Matches API team names to participants by name and bulk-upserts the results. * Matches API team names to participants by name and bulk-upserts the results.
* *
* Returns the count of synced teams and any API team names that couldn't be matched. * For season_standings sports (F1, IndyCar), upserts into participantSeasonResults.
* For all other sports, upserts into regularSeasonStandings.
*
* Returns the count of synced entries and any API names that couldn't be matched.
*/ */
export async function syncStandings(sportsSeasonId: string): Promise<SyncResult> { export async function syncStandings(sportsSeasonId: string): Promise<SyncResult> {
const db = database(); const db = database();
@ -76,6 +72,8 @@ export async function syncStandings(sportsSeasonId: string): Promise<SyncResult>
throw new Error(`Sport "${sportName}" has no simulator type configured`); throw new Error(`Sport "${sportName}" has no simulator type configured`);
} }
const isSeasonStandings = sportsSeason.scoringPattern === "season_standings";
const adapter = getAdapter(simulatorType); const adapter = getAdapter(simulatorType);
const fetchedRecords = await adapter.fetchStandings(); const fetchedRecords = await adapter.fetchStandings();
@ -83,25 +81,25 @@ export async function syncStandings(sportsSeasonId: string): Promise<SyncResult>
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId); const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
const participantNames = participants.map((p) => p.name); const participantNames = participants.map((p) => p.name);
const participantByName = new Map(participants.map((p) => [p.name, p])); const participantByName = new Map(participants.map((p) => [p.name, p]));
// Build a map of externalId → participant for ID-first matching
const participantByExternalId = new Map( const participantByExternalId = new Map(
participants.filter((p) => p.externalId).map((p) => [p.externalId ?? "", p]) participants.filter((p) => p.externalId).map((p) => [p.externalId ?? "", p])
); );
const toUpsert: Parameters<typeof upsertRegularSeasonStandings>[0] = []; type RegularUpsertRow = Parameters<typeof upsertRegularSeasonStandings>[0][number];
const unmatchedRecords: typeof fetchedRecords = []; type SeasonResultRow = Parameters<typeof upsertSeasonResultsBulk>[0][number];
const toUpsertRegular: RegularUpsertRow[] = [];
const toUpsertSeason: SeasonResultRow[] = [];
const matchedParticipantIds: string[] = []; const matchedParticipantIds: string[] = [];
const unmatchedRecords: typeof fetchedRecords = [];
for (const record of fetchedRecords) { for (const record of fetchedRecords) {
// Strategy A: try externalId-first (bypasses name matching entirely after first sync)
let participant = participantByExternalId.get(record.externalTeamId) ?? null; let participant = participantByExternalId.get(record.externalTeamId) ?? null;
if (!participant) { if (!participant) {
// Fall back to name matching
const matchedName = findMatchingTeamName(record.teamName, participantNames); const matchedName = findMatchingTeamName(record.teamName, participantNames);
if (matchedName) { if (matchedName) {
participant = participantByName.get(matchedName) ?? null; participant = participantByName.get(matchedName) ?? null;
// Write-back: persist externalId so future syncs skip name matching for this team
if (participant && !participant.externalId) { if (participant && !participant.externalId) {
await updateParticipant(participant.id, { externalId: record.externalTeamId }); await updateParticipant(participant.id, { externalId: record.externalTeamId });
} }
@ -114,7 +112,16 @@ export async function syncStandings(sportsSeasonId: string): Promise<SyncResult>
} }
matchedParticipantIds.push(participant.id); matchedParticipantIds.push(participant.id);
toUpsert.push({
if (isSeasonStandings) {
toUpsertSeason.push({
participantId: participant.id,
sportsSeasonId,
currentPoints: record.currentPoints ?? 0,
currentPosition: record.leagueRank,
});
} else {
toUpsertRegular.push({
participantId: participant.id, participantId: participant.id,
sportsSeasonId, sportsSeasonId,
wins: record.wins, wins: record.wins,
@ -142,11 +149,40 @@ export async function syncStandings(sportsSeasonId: string): Promise<SyncResult>
syncedAt: new Date(), syncedAt: new Date(),
}); });
} }
}
// Detect whether standings actually changed by comparing gamesPlayed + leagueRank
// against current DB values before upserting.
let changed = false; let changed = false;
if (toUpsert.length > 0) {
if (isSeasonStandings && toUpsertSeason.length > 0) {
const currentRows = await db
.select({
participantId: schema.participantSeasonResults.participantId,
currentPoints: schema.participantSeasonResults.currentPoints,
currentPosition: schema.participantSeasonResults.currentPosition,
})
.from(schema.participantSeasonResults)
.where(inArray(schema.participantSeasonResults.participantId, matchedParticipantIds));
const currentByParticipant = new Map(currentRows.map((r) => [r.participantId, r]));
changed = toUpsertSeason.some((row) => {
const current = currentByParticipant.get(row.participantId);
if (!current) return true;
return (
current.currentPosition !== row.currentPosition ||
parseFloat(current.currentPoints ?? "0") !== (row.currentPoints ?? 0)
);
});
await upsertSeasonResultsBulk(toUpsertSeason);
if (changed) {
await db
.update(schema.sportsSeasons)
.set({ standingsLastChangedAt: new Date() })
.where(eq(schema.sportsSeasons.id, sportsSeasonId));
}
} else if (!isSeasonStandings && toUpsertRegular.length > 0) {
const currentRows = await db const currentRows = await db
.select({ .select({
participantId: schema.regularSeasonStandings.participantId, participantId: schema.regularSeasonStandings.participantId,
@ -156,17 +192,15 @@ export async function syncStandings(sportsSeasonId: string): Promise<SyncResult>
.from(schema.regularSeasonStandings) .from(schema.regularSeasonStandings)
.where(inArray(schema.regularSeasonStandings.participantId, matchedParticipantIds)); .where(inArray(schema.regularSeasonStandings.participantId, matchedParticipantIds));
const currentByParticipant = new Map( const currentByParticipant = new Map(currentRows.map((r) => [r.participantId, r]));
currentRows.map((r) => [r.participantId, r])
);
changed = toUpsert.some((row) => { changed = toUpsertRegular.some((row) => {
const current = currentByParticipant.get(row.participantId); const current = currentByParticipant.get(row.participantId);
if (!current) return true; // new row if (!current) return true;
return current.gamesPlayed !== row.gamesPlayed || current.leagueRank !== row.leagueRank; return current.gamesPlayed !== row.gamesPlayed || current.leagueRank !== row.leagueRank;
}); });
await upsertRegularSeasonStandings(toUpsert); await upsertRegularSeasonStandings(toUpsertRegular);
if (changed) { if (changed) {
await db await db
@ -176,7 +210,6 @@ export async function syncStandings(sportsSeasonId: string): Promise<SyncResult>
} }
} }
// Persist unmatched records so admin can resolve them without losing data on page reload
if (unmatchedRecords.length > 0) { if (unmatchedRecords.length > 0) {
await upsertPendingStandingsMappings(sportsSeasonId, unmatchedRecords); await upsertPendingStandingsMappings(sportsSeasonId, unmatchedRecords);
} }
@ -186,5 +219,6 @@ export async function syncStandings(sportsSeasonId: string): Promise<SyncResult>
externalTeamId: r.externalTeamId, externalTeamId: r.externalTeamId,
})); }));
return { synced: toUpsert.length, unmatched, changed }; const synced = isSeasonStandings ? toUpsertSeason.length : toUpsertRegular.length;
return { synced, unmatched, changed };
} }

View file

@ -22,6 +22,7 @@ export interface FetchedStandingsRecord {
homeRecord?: string; homeRecord?: string;
awayRecord?: string; awayRecord?: string;
srs?: number | null; // Net rating or SRS proxy (pointsFor - pointsAgainst per game) srs?: number | null; // Net rating or SRS proxy (pointsFor - pointsAgainst per game)
currentPoints?: number; // championship points for season_standings sports (F1, IndyCar)
} }
export interface StandingsSyncAdapter { export interface StandingsSyncAdapter {

View file

@ -2,6 +2,22 @@ import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react'; import { cleanup } from '@testing-library/react';
import { afterEach, vi } from 'vitest'; import { afterEach, vi } from 'vitest';
// Radix UI / @floating-ui calls requestAnimationFrame for layout and
// positioning on every render (Popover, DropdownMenu, Checkbox focus rings,
// etc.). In jsdom those callbacks are deferred to a future tick, landing
// outside the current act() boundary. React queues the resulting state
// updates, which schedule more microtasks, and the act() settlement loop
// keeps running until Vitest's 5000ms hard timeout kills the test.
//
// Making rAF synchronous keeps every callback inside the current act() cycle
// so there are no pending tasks when the test finishes. This fixes the whole
// class of flaky timeouts without touching individual test files.
global.requestAnimationFrame = (cb: FrameRequestCallback): number => {
cb(performance.now());
return 0;
};
global.cancelAnimationFrame = (_id: number): void => {};
// Cleanup after each test // Cleanup after each test
afterEach(() => { afterEach(() => {
cleanup(); cleanup();

View file

@ -927,7 +927,10 @@ export const participantSeasonResults = pgTable("participant_season_results", {
currentPoints: decimal("current_points", { precision: 10, scale: 2 }).notNull().default("0"), // Current F1 championship points, etc. currentPoints: decimal("current_points", { precision: 10, scale: 2 }).notNull().default("0"), // Current F1 championship points, etc.
currentPosition: integer("current_position"), // Current standings position currentPosition: integer("current_position"), // Current standings position
updatedAt: timestamp("updated_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(),
}); }, (table) => ({
uniqueParticipantSeason: uniqueIndex("psr_participant_season_idx")
.on(table.participantId, table.sportsSeasonId),
}));
// EV snapshots — historical record of participant EV over time (one per participant per sport season per day) // EV snapshots — historical record of participant EV over time (one per participant per sport season per day)
export const participantEvSnapshots = pgTable("participant_ev_snapshots", { export const participantEvSnapshots = pgTable("participant_ev_snapshots", {

View file

@ -0,0 +1 @@
CREATE UNIQUE INDEX IF NOT EXISTS "psr_participant_season_idx" ON "participant_season_results" USING btree ("participant_id","sports_season_id");

File diff suppressed because it is too large Load diff

View file

@ -834,6 +834,13 @@
"when": 1780890669286, "when": 1780890669286,
"tag": "0118_chunky_vivisector", "tag": "0118_chunky_vivisector",
"breakpoints": true "breakpoints": true
},
{
"idx": 119,
"version": "7",
"when": 1781031439603,
"tag": "0119_eminent_siren",
"breakpoints": true
} }
] ]
} }