/** * Race-calendar state for season-standings sports (F1, IndyCar). * * Kept in its own leaf module rather than in `scoring-event.ts` so that * `simulator.ts` can read it: `scoring-event.ts` pulls in `scoring-calculator`, * which reaches `participant-expected-value` and back into `simulator`. This * file imports nothing but the database. */ import { eq } from "drizzle-orm"; import { database } from "~/database/context"; import * as schema from "~/database/schema"; export interface SeasonRaceCounts { completed: number; remaining: number; total: number; } /** * How long after the green flag a race is assumed to have finished. * * `event_starts_at` is a start time, so treating it as "already run" would * declare the season over the moment the finale goes green — and the simulator * would publish the pre-race leader as champion at 100%, from standings that do * not yet include the race being run. No race in these series comes close to * six hours, and the standings feed updates within hours of a finish. */ const RACE_DURATION_MS = 6 * 60 * 60 * 1000; /** * Has this race already been run? * * `is_complete` wins when an admin has set it, but a racing calendar is stored * as "Non-Scoring" rows that nobody ever marks complete, so the date is the real * signal. Mirrors the Upcoming / Results Pending badge on the admin events page. * A race happening today is still upcoming, and a row with no date at all counts * as upcoming. * * @param today `now` as a `YYYY-MM-DD` string, to compare against the date-only * `event_date` column. */ export function hasRaceRun( event: { isComplete: boolean; eventDate: string | null; eventStartsAt: Date | string | null; }, now: Date, today: string ): boolean { if (event.isComplete) return true; if (event.eventStartsAt) { return new Date(event.eventStartsAt).getTime() + RACE_DURATION_MS < now.getTime(); } if (event.eventDate) return event.eventDate < today; return false; } /** * Count the races on a season-standings calendar (F1, IndyCar). * * `event_type` has no race value, so a racing calendar is stored as * `schedule_event` rows — the admin default for the `season_standings` scoring * pattern. The only other row such a season carries is the single * `final_standings` event that assigns fantasy placements once the championship * is settled. A race is therefore "every event except `final_standings`", not * "every event except `schedule_event`" — getting that backwards leaves the * simulator with zero remaining races and no idea the season is in progress. */ export async function countSeasonRaces( sportsSeasonId: string, now: Date = new Date(), providedDb?: ReturnType ): Promise { const db = providedDb || database(); const events = await db.query.scoringEvents.findMany({ where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), columns: { eventType: true, isComplete: true, eventDate: true, eventStartsAt: true, }, }); const today = now.toISOString().split("T")[0]; let completed = 0; let remaining = 0; for (const event of events) { if (event.eventType === "final_standings") continue; if (hasRaceRun(event, now, today)) completed++; else remaining++; } return { completed, remaining, total: completed + remaining }; }