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:
parent
9541318adf
commit
a6d8162bdf
8 changed files with 146 additions and 197 deletions
|
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 "Unmatched Drivers" 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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
|
|
|
|||
1
drizzle/0119_eminent_siren.sql
Normal file
1
drizzle/0119_eminent_siren.sql
Normal 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");
|
||||
|
|
@ -834,6 +834,13 @@
|
|||
"when": 1780890669286,
|
||||
"tag": "0118_chunky_vivisector",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 119,
|
||||
"version": "7",
|
||||
"when": 1781031439603,
|
||||
"tag": "0119_eminent_siren",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue