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 * 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 {
participantId: string;
@ -15,55 +15,42 @@ export interface UpdateSeasonResultData {
}
/**
* Upsert a participant's season result (for F1, etc.)
* Creates if doesn't exist, updates if it does
* Upsert a single participant's season result atomically using ON CONFLICT DO UPDATE.
*/
export async function upsertParticipantSeasonResult(
data: UpsertSeasonResultData,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
const now = new Date();
// Check if result exists
const existing = await db.query.participantSeasonResults.findFirst({
where: and(
eq(schema.participantSeasonResults.participantId, data.participantId),
eq(schema.participantSeasonResults.sportsSeasonId, data.sportsSeasonId)
),
});
const [row] = await db
.insert(schema.participantSeasonResults)
.values({
participantId: data.participantId,
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) {
// 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;
}
return row;
}
/**
* Bulk upsert season results for multiple participants
* Useful for entering standings for an entire F1 grid at once
* Bulk upsert season results for multiple participants in a single statement.
*/
export async function upsertSeasonResultsBulk(
results: UpsertSeasonResultData[],
@ -71,13 +58,32 @@ export async function upsertSeasonResultsBulk(
) {
const db = providedDb || database();
const upserted = [];
for (const data of results) {
const result = await upsertParticipantSeasonResult(data, db);
upserted.push(result);
}
if (results.length === 0) return [];
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,
} from "~/models/season-participant";
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 { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
@ -143,10 +143,7 @@ export async function loader({ params }: Route.LoaderArgs) {
? await getLastSeasonResultsSyncedAt(params.id)
: null;
const pendingMappings =
sportsSeason.sport?.type === "team"
? await getPendingStandingsMappings(params.id)
: [];
const pendingMappings = await getPendingStandingsMappings(params.id);
return {
sportsSeason,
@ -223,7 +220,10 @@ export async function action(args: Route.ActionArgs) {
try {
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) {
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
await updateParticipant(participantId, { externalId: externalTeamId });
// Upsert the standing record from the stored standingData
await upsertRegularSeasonStandings([
{
// Route upsert to the correct table based on sport type
if (sportsSeason?.scoringPattern === "season_standings") {
await upsertParticipantSeasonResult({
participantId,
sportsSeasonId: params.id,
wins: (standingData.wins as number) ?? 0,
losses: (standingData.losses as number) ?? 0,
otLosses: (standingData.otLosses as number | null) ?? null,
ties: (standingData.ties as number | null) ?? null,
winPct: (standingData.winPct as number) ?? 0,
gamesPlayed: (standingData.gamesPlayed as number) ?? 0,
gamesBack: (standingData.gamesBack as number | null) ?? null,
conference: (standingData.conference as string | null) ?? null,
division: (standingData.division as string | null) ?? null,
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(),
},
]);
currentPoints: (standingData.currentPoints as number) ?? 0,
currentPosition: (standingData.leagueRank as number) ?? null,
});
} else {
await upsertRegularSeasonStandings([
{
participantId,
sportsSeasonId: params.id,
wins: (standingData.wins as number) ?? 0,
losses: (standingData.losses as number) ?? 0,
otLosses: (standingData.otLosses as number | null) ?? null,
ties: (standingData.ties as number | null) ?? null,
winPct: (standingData.winPct as number) ?? 0,
gamesPlayed: (standingData.gamesPlayed as number) ?? 0,
gamesBack: (standingData.gamesBack as number | null) ?? null,
conference: (standingData.conference as string | null) ?? null,
division: (standingData.division as string | null) ?? null,
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
await deletePendingStandingsMapping(params.id, externalTeamId);
@ -878,17 +887,7 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
</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">
<p className="font-medium mb-1">
{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>
{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>
@ -903,16 +902,19 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
</Card>
)}
{sportsSeason.sport?.type === "team" && pendingMappings.length > 0 && (
{pendingMappings.length > 0 && (
<Card className="border-amber-500/30">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-amber-500" />
Unmatched Teams ({pendingMappings.length})
{sportsSeason.sport?.type === "individual" ? "Unmatched Drivers" : "Unmatched Teams"} ({pendingMappings.length})
</CardTitle>
<CardDescription>
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
{sportsSeason.sport?.type === "individual"
? "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.
</CardDescription>
</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)
const SAMPLE_ESPN_INDYCAR = {
@ -96,8 +78,7 @@ describe("F1StandingsAdapter", () => {
it("returns one record per driver with correct names", async () => {
vi.mocked(fetch)
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response)
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_SCHEDULE } as Response);
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response);
const adapter = new F1StandingsAdapter();
const records = await adapter.fetchStandings();
@ -109,8 +90,7 @@ describe("F1StandingsAdapter", () => {
it("sets externalTeamId to driverId", async () => {
vi.mocked(fetch)
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response)
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_SCHEDULE } as Response);
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response);
const records = await new F1StandingsAdapter().fetchStandings();
expect(records[0].externalTeamId).toBe("max_verstappen");
@ -119,8 +99,7 @@ describe("F1StandingsAdapter", () => {
it("maps championship points to currentPoints", async () => {
vi.mocked(fetch)
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response)
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_SCHEDULE } as Response);
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response);
const records = await new F1StandingsAdapter().fetchStandings();
expect(records[0].currentPoints).toBe(195);
@ -129,61 +108,52 @@ describe("F1StandingsAdapter", () => {
it("sets leagueRank to championship position", async () => {
vi.mocked(fetch)
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response)
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_SCHEDULE } as Response);
.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("counts only completed races for 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);
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();
// 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[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)
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_SCHEDULE } as Response);
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)
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_SCHEDULE } as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => empty,
} as Response);
await expect(new F1StandingsAdapter().fetchStandings()).rejects.toThrow(
/no entries/i
);
await expect(new F1StandingsAdapter().fetchStandings()).rejects.toThrow(/no entries/i);
});
});

View file

@ -1,8 +1,8 @@
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";
const JOLPICA_SCHEDULE_URL = "https://api.jolpi.ca/ergast/f1/current.json";
// ESPN IndyCar (IRL) standings endpoint
const ESPN_INDYCAR_STANDINGS_URL =
@ -19,18 +19,6 @@ interface JolpicaDriverStanding {
};
}
interface JolpicaRace {
date: string;
}
interface JolpicaScheduleResponse {
MRData: {
RaceTable: {
Races: JolpicaRace[];
};
};
}
interface JolpicaStandingsResponse {
MRData: {
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 {
async fetchStandings(): Promise<FetchedStandingsRecord[]> {
const [standingsRes, completedRaces] = await Promise.all([
fetch(JOLPICA_DRIVER_STANDINGS_URL),
countCompletedRaces(),
]);
const standingsRes = await fetch(JOLPICA_DRIVER_STANDINGS_URL);
if (!standingsRes.ok) {
throw new Error(
@ -77,8 +53,6 @@ export class F1StandingsAdapter implements StandingsSyncAdapter {
const position = parseInt(d.position, 10);
const points = parseFloat(d.points);
const wins = parseInt(d.wins, 10);
const gamesPlayed = completedRaces;
const winPct = gamesPlayed > 0 ? wins / gamesPlayed : 0;
return {
teamName: `${d.Driver.givenName} ${d.Driver.familyName}`,
@ -86,8 +60,8 @@ export class F1StandingsAdapter implements StandingsSyncAdapter {
leagueRank: position,
wins,
losses: 0,
gamesPlayed,
winPct,
gamesPlayed: 0,
winPct: 0,
currentPoints: points,
};
});
@ -100,15 +74,9 @@ interface EspnRacingAthlete {
displayName: string;
}
interface EspnRacingStat {
name: string;
value?: number;
displayValue?: string;
}
interface EspnRacingEntry {
athlete: EspnRacingAthlete;
stats: EspnRacingStat[];
stats: EspnStat[];
}
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 {
async fetchStandings(): Promise<FetchedStandingsRecord[]> {
const res = await fetch(ESPN_INDYCAR_STANDINGS_URL);
@ -151,7 +113,7 @@ export class IndyCarStandingsAdapter implements StandingsSyncAdapter {
}
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 wins = sm.get("wins")?.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 type { StandingsSyncAdapter, SyncResult, UnmatchedTeam } from "./types";
const SEASON_STANDINGS_SIMULATOR_TYPES = new Set(["f1_standings", "indycar_standings"]);
function getAdapter(simulatorType: string): StandingsSyncAdapter {
switch (simulatorType) {
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`);
}
const isSeasonStandings = SEASON_STANDINGS_SIMULATOR_TYPES.has(simulatorType);
const isSeasonStandings = sportsSeason.scoringPattern === "season_standings";
const adapter = getAdapter(simulatorType);
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.
currentPosition: integer("current_position"), // Current standings position
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)
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,
"tag": "0118_chunky_vivisector",
"breakpoints": true
},
{
"idx": 119,
"version": "7",
"when": 1781031439603,
"tag": "0119_eminent_siren",
"breakpoints": true
}
]
}