Fix 5 code review issues in F1/IndyCar standings sync

- Add unique constraint on participantSeasonResults(participantId, sportsSeasonId)
  and rewrite upsert to use onConflictDoUpdate, eliminating race conditions and
  sequential N*2 queries on sync
- Fetch pending mappings for all sport types; extend Unmatched card to show for
  individual sports and route resolve-mapping to participantSeasonResults for
  season_standings sports
- Replace hardcoded SEASON_STANDINGS_SIMULATOR_TYPES Set with
  sportsSeason.scoringPattern === "season_standings" to avoid drift
- Remove countCompletedRaces() from F1 adapter — gamesPlayed/winPct were computed
  but discarded by the orchestrator for season_standings sports
- Replace duplicate parseRacingStatsMap() with the shared statsMap() from espn.ts

https://claude.ai/code/session_012ACmUs2vAwEF9jZu7Guos2
This commit is contained in:
Claude 2026-06-09 19:02:07 +00:00
parent 9541318adf
commit a6d8162bdf
No known key found for this signature in database
8 changed files with 146 additions and 197 deletions

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, max } 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({ .insert(schema.participantSeasonResults)
where: and( .values({
eq(schema.participantSeasonResults.participantId, data.participantId), participantId: data.participantId,
eq(schema.participantSeasonResults.sportsSeasonId, data.sportsSeasonId) sportsSeasonId: data.sportsSeasonId,
), currentPoints: (data.currentPoints ?? 0).toString(),
}); 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();
if (existing) { return row;
// 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)
.values({
participantId: data.participantId,
sportsSeasonId: data.sportsSeasonId,
currentPoints: data.currentPoints?.toString() || "0",
currentPosition: data.currentPosition,
})
.returning();
return created;
}
} }
/** /**
* 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();
} }
/** /**

View file

@ -23,7 +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 } from "~/models/participant-season-result"; 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";
@ -143,10 +143,7 @@ export async function loader({ params }: Route.LoaderArgs) {
? await getLastSeasonResultsSyncedAt(params.id) ? 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,
@ -223,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." };
@ -232,31 +232,40 @@ 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
await upsertRegularSeasonStandings([ if (sportsSeason?.scoringPattern === "season_standings") {
{ await upsertParticipantSeasonResult({
participantId, participantId,
sportsSeasonId: params.id, sportsSeasonId: params.id,
wins: (standingData.wins as number) ?? 0, currentPoints: (standingData.currentPoints as number) ?? 0,
losses: (standingData.losses as number) ?? 0, currentPosition: (standingData.leagueRank as number) ?? null,
otLosses: (standingData.otLosses as number | null) ?? null, });
ties: (standingData.ties as number | null) ?? null, } else {
winPct: (standingData.winPct as number) ?? 0, await upsertRegularSeasonStandings([
gamesPlayed: (standingData.gamesPlayed as number) ?? 0, {
gamesBack: (standingData.gamesBack as number | null) ?? null, participantId,
conference: (standingData.conference as string | null) ?? null, sportsSeasonId: params.id,
division: (standingData.division as string | null) ?? null, wins: (standingData.wins as number) ?? 0,
conferenceRank: (standingData.conferenceRank as number | null) ?? null, losses: (standingData.losses as number) ?? 0,
divisionRank: (standingData.divisionRank as number | null) ?? null, otLosses: (standingData.otLosses as number | null) ?? null,
leagueRank: (standingData.leagueRank as number) ?? 0, ties: (standingData.ties as number | null) ?? null,
streak: (standingData.streak as string | null) ?? null, winPct: (standingData.winPct as number) ?? 0,
lastTen: (standingData.lastTen as string | null) ?? null, gamesPlayed: (standingData.gamesPlayed as number) ?? 0,
homeRecord: (standingData.homeRecord as string | null) ?? null, gamesBack: (standingData.gamesBack as number | null) ?? null,
awayRecord: (standingData.awayRecord as string | null) ?? null, conference: (standingData.conference as string | null) ?? null,
externalTeamId, division: (standingData.division as string | null) ?? null,
syncedAt: new Date(), conferenceRank: (standingData.conferenceRank as number | null) ?? null,
}, divisionRank: (standingData.divisionRank as number | null) ?? null,
]); leagueRank: (standingData.leagueRank as number) ?? 0,
streak: (standingData.streak as string | null) ?? null,
lastTen: (standingData.lastTen as string | null) ?? null,
homeRecord: (standingData.homeRecord as string | null) ?? null,
awayRecord: (standingData.awayRecord as string | null) ?? null,
externalTeamId,
syncedAt: new Date(),
},
]);
}
// Remove from pending queue // Remove from pending queue
await deletePendingStandingsMapping(params.id, externalTeamId); await deletePendingStandingsMapping(params.id, externalTeamId);
@ -878,17 +887,7 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
</div> </div>
{actionData.syncResult.unmatched.length > 0 && ( {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"> <div className="bg-amber-500/15 text-amber-600 dark:text-amber-400 px-4 py-3 rounded-md text-sm">
<p className="font-medium mb-1"> {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.
{actionData.syncResult.unmatched.length} driver{actionData.syncResult.unmatched.length !== 1 ? "s" : ""} could not be matched to participants:
</p>
<ul className="list-disc list-inside space-y-0.5">
{actionData.syncResult.unmatched.map((u: { teamName: string; externalTeamId: string }) => (
<li key={u.externalTeamId}>{u.teamName}</li>
))}
</ul>
<p className="mt-2 text-xs">
Check that participant names match driver names from the data feed. You can edit names manually via the Participants section.
</p>
</div> </div>
)} )}
</div> </div>
@ -903,16 +902,19 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
</Card> </Card>
)} )}
{sportsSeason.sport?.type === "team" && pendingMappings.length > 0 && ( {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

@ -33,24 +33,6 @@ const SAMPLE_JOLPICA_STANDINGS = {
}, },
}; };
// Minimal Jolpica schedule response — 8 races, 2 in the future (date > 2026-06-09)
const SAMPLE_JOLPICA_SCHEDULE = {
MRData: {
RaceTable: {
Races: [
{ date: "2026-03-16" },
{ date: "2026-03-23" },
{ date: "2026-04-06" },
{ date: "2026-04-20" },
{ date: "2026-05-04" },
{ date: "2026-05-25" },
{ date: "2026-06-08" },
{ date: "2026-07-06" }, // future
{ date: "2026-07-27" }, // future
],
},
},
};
// Minimal ESPN IndyCar standings response (flat, single standings list) // Minimal ESPN IndyCar standings response (flat, single standings list)
const SAMPLE_ESPN_INDYCAR = { const SAMPLE_ESPN_INDYCAR = {
@ -96,8 +78,7 @@ describe("F1StandingsAdapter", () => {
it("returns one record per driver with correct names", async () => { it("returns one record per driver with correct names", async () => {
vi.mocked(fetch) vi.mocked(fetch)
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response) .mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response);
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_SCHEDULE } as Response);
const adapter = new F1StandingsAdapter(); const adapter = new F1StandingsAdapter();
const records = await adapter.fetchStandings(); const records = await adapter.fetchStandings();
@ -109,8 +90,7 @@ describe("F1StandingsAdapter", () => {
it("sets externalTeamId to driverId", async () => { it("sets externalTeamId to driverId", async () => {
vi.mocked(fetch) vi.mocked(fetch)
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response) .mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response);
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_SCHEDULE } as Response);
const records = await new F1StandingsAdapter().fetchStandings(); const records = await new F1StandingsAdapter().fetchStandings();
expect(records[0].externalTeamId).toBe("max_verstappen"); expect(records[0].externalTeamId).toBe("max_verstappen");
@ -119,8 +99,7 @@ describe("F1StandingsAdapter", () => {
it("maps championship points to currentPoints", async () => { it("maps championship points to currentPoints", async () => {
vi.mocked(fetch) vi.mocked(fetch)
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response) .mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response);
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_SCHEDULE } as Response);
const records = await new F1StandingsAdapter().fetchStandings(); const records = await new F1StandingsAdapter().fetchStandings();
expect(records[0].currentPoints).toBe(195); expect(records[0].currentPoints).toBe(195);
@ -129,61 +108,52 @@ describe("F1StandingsAdapter", () => {
it("sets leagueRank to championship position", async () => { it("sets leagueRank to championship position", async () => {
vi.mocked(fetch) vi.mocked(fetch)
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response) .mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response);
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_SCHEDULE } as Response);
const records = await new F1StandingsAdapter().fetchStandings(); const records = await new F1StandingsAdapter().fetchStandings();
expect(records[0].leagueRank).toBe(1); expect(records[0].leagueRank).toBe(1);
expect(records[2].leagueRank).toBe(3); expect(records[2].leagueRank).toBe(3);
}); });
it("counts only completed races for gamesPlayed", async () => { it("sets gamesPlayed to 0 (not stored for season_standings sports)", async () => {
vi.mocked(fetch) vi.mocked(fetch).mockResolvedValueOnce({
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response) ok: true,
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_SCHEDULE } as Response); json: async () => SAMPLE_JOLPICA_STANDINGS,
} as Response);
const records = await new F1StandingsAdapter().fetchStandings(); const records = await new F1StandingsAdapter().fetchStandings();
// 7 races on/before 2026-06-09, 2 in the future
expect(records[0].gamesPlayed).toBe(7);
});
it("computes winPct as wins / gamesPlayed", async () => {
vi.mocked(fetch)
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response)
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_SCHEDULE } as Response);
const records = await new F1StandingsAdapter().fetchStandings();
// Verstappen: 5 wins / 7 races
expect(records[0].winPct).toBeCloseTo(5 / 7, 5);
});
it("falls back gracefully when schedule fetch fails", async () => {
vi.mocked(fetch)
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response)
.mockResolvedValueOnce({ ok: false, status: 503, statusText: "Service Unavailable" } as Response);
const records = await new F1StandingsAdapter().fetchStandings();
// gamesPlayed falls back to 0 from countCompletedRaces
expect(records[0].gamesPlayed).toBe(0); 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 () => { it("throws when standings fetch fails", async () => {
vi.mocked(fetch) vi.mocked(fetch).mockResolvedValueOnce({
.mockResolvedValueOnce({ ok: false, status: 500, statusText: "Internal Server Error" } as Response) ok: false,
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_SCHEDULE } as Response); status: 500,
statusText: "Internal Server Error",
} as Response);
await expect(new F1StandingsAdapter().fetchStandings()).rejects.toThrow("500"); await expect(new F1StandingsAdapter().fetchStandings()).rejects.toThrow("500");
}); });
it("throws when API returns empty standings", async () => { it("throws when API returns empty standings", async () => {
const empty = { MRData: { StandingsTable: { StandingsLists: [] } } }; const empty = { MRData: { StandingsTable: { StandingsLists: [] } } };
vi.mocked(fetch) vi.mocked(fetch).mockResolvedValueOnce({
.mockResolvedValueOnce({ ok: true, json: async () => empty } as Response) ok: true,
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_SCHEDULE } as Response); json: async () => empty,
} as Response);
await expect(new F1StandingsAdapter().fetchStandings()).rejects.toThrow( await expect(new F1StandingsAdapter().fetchStandings()).rejects.toThrow(/no entries/i);
/no entries/i
);
}); });
}); });

View file

@ -1,8 +1,8 @@
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. // 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"; const JOLPICA_DRIVER_STANDINGS_URL = "https://api.jolpi.ca/ergast/f1/current/driverStandings.json";
const JOLPICA_SCHEDULE_URL = "https://api.jolpi.ca/ergast/f1/current.json";
// ESPN IndyCar (IRL) standings endpoint // ESPN IndyCar (IRL) standings endpoint
const ESPN_INDYCAR_STANDINGS_URL = const ESPN_INDYCAR_STANDINGS_URL =
@ -19,18 +19,6 @@ interface JolpicaDriverStanding {
}; };
} }
interface JolpicaRace {
date: string;
}
interface JolpicaScheduleResponse {
MRData: {
RaceTable: {
Races: JolpicaRace[];
};
};
}
interface JolpicaStandingsResponse { interface JolpicaStandingsResponse {
MRData: { MRData: {
StandingsTable: { StandingsTable: {
@ -41,21 +29,9 @@ interface JolpicaStandingsResponse {
}; };
} }
async function countCompletedRaces(): Promise<number> {
const res = await fetch(JOLPICA_SCHEDULE_URL);
if (!res.ok) return 0;
const json = (await res.json()) as JolpicaScheduleResponse;
const races = json.MRData?.RaceTable?.Races ?? [];
const today = new Date().toISOString().slice(0, 10);
return races.filter((r) => r.date <= today).length;
}
export class F1StandingsAdapter implements StandingsSyncAdapter { export class F1StandingsAdapter implements StandingsSyncAdapter {
async fetchStandings(): Promise<FetchedStandingsRecord[]> { async fetchStandings(): Promise<FetchedStandingsRecord[]> {
const [standingsRes, completedRaces] = await Promise.all([ const standingsRes = await fetch(JOLPICA_DRIVER_STANDINGS_URL);
fetch(JOLPICA_DRIVER_STANDINGS_URL),
countCompletedRaces(),
]);
if (!standingsRes.ok) { if (!standingsRes.ok) {
throw new Error( throw new Error(
@ -77,8 +53,6 @@ export class F1StandingsAdapter implements StandingsSyncAdapter {
const position = parseInt(d.position, 10); const position = parseInt(d.position, 10);
const points = parseFloat(d.points); const points = parseFloat(d.points);
const wins = parseInt(d.wins, 10); const wins = parseInt(d.wins, 10);
const gamesPlayed = completedRaces;
const winPct = gamesPlayed > 0 ? wins / gamesPlayed : 0;
return { return {
teamName: `${d.Driver.givenName} ${d.Driver.familyName}`, teamName: `${d.Driver.givenName} ${d.Driver.familyName}`,
@ -86,8 +60,8 @@ export class F1StandingsAdapter implements StandingsSyncAdapter {
leagueRank: position, leagueRank: position,
wins, wins,
losses: 0, losses: 0,
gamesPlayed, gamesPlayed: 0,
winPct, winPct: 0,
currentPoints: points, currentPoints: points,
}; };
}); });
@ -100,15 +74,9 @@ interface EspnRacingAthlete {
displayName: string; displayName: string;
} }
interface EspnRacingStat {
name: string;
value?: number;
displayValue?: string;
}
interface EspnRacingEntry { interface EspnRacingEntry {
athlete: EspnRacingAthlete; athlete: EspnRacingAthlete;
stats: EspnRacingStat[]; stats: EspnStat[];
} }
interface EspnRacingStandingsResponse { interface EspnRacingStandingsResponse {
@ -121,12 +89,6 @@ interface EspnRacingStandingsResponse {
}>; }>;
} }
function parseRacingStatsMap(stats: EspnRacingStat[]): Map<string, EspnRacingStat> {
const map = new Map<string, EspnRacingStat>();
for (const s of stats) map.set(s.name, s);
return map;
}
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); const res = await fetch(ESPN_INDYCAR_STANDINGS_URL);
@ -151,7 +113,7 @@ export class IndyCarStandingsAdapter implements StandingsSyncAdapter {
} }
return entries.map((entry, idx): FetchedStandingsRecord => { return entries.map((entry, idx): FetchedStandingsRecord => {
const sm = parseRacingStatsMap(entry.stats); const sm = statsMap(entry.stats);
const points = sm.get("points")?.value ?? sm.get("pts")?.value ?? 0; const points = sm.get("points")?.value ?? sm.get("pts")?.value ?? 0;
const wins = sm.get("wins")?.value ?? 0; const wins = sm.get("wins")?.value ?? 0;
const starts = sm.get("starts")?.value ?? sm.get("racesStarted")?.value ?? 0; const starts = sm.get("starts")?.value ?? sm.get("racesStarted")?.value ?? 0;

View file

@ -16,8 +16,6 @@ import { MlsStandingsAdapter } from "./mls";
import { F1StandingsAdapter, IndyCarStandingsAdapter } from "./f1"; import { F1StandingsAdapter, IndyCarStandingsAdapter } from "./f1";
import type { StandingsSyncAdapter, SyncResult, UnmatchedTeam } from "./types"; import type { StandingsSyncAdapter, SyncResult, UnmatchedTeam } from "./types";
const SEASON_STANDINGS_SIMULATOR_TYPES = new Set(["f1_standings", "indycar_standings"]);
function getAdapter(simulatorType: string): StandingsSyncAdapter { function getAdapter(simulatorType: string): StandingsSyncAdapter {
switch (simulatorType) { switch (simulatorType) {
case "nba_bracket": case "nba_bracket":
@ -74,7 +72,7 @@ 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 = SEASON_STANDINGS_SIMULATOR_TYPES.has(simulatorType); const isSeasonStandings = sportsSeason.scoringPattern === "season_standings";
const adapter = getAdapter(simulatorType); const adapter = getAdapter(simulatorType);
const fetchedRecords = await adapter.fetchStandings(); const fetchedRecords = await adapter.fetchStandings();

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");

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
} }
] ]
} }