diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc186a7..5b693c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,15 @@ jobs: - name: Checkout uses: actions/checkout@v7 + - name: Verify trained models are present + run: | + for f in F1Predictor.WebApi/models/podium-model.zip F1Predictor.WebApi/models/points-model.zip; do + if [ ! -s "$f" ]; then + echo "::error::Missing or empty $f — the Dockerfile bakes this into the production image; commit the trained model before merging." >&2 + exit 1 + fi + done + - name: Setup .NET 10 uses: actions/setup-dotnet@v6 with: diff --git a/.gitignore b/.gitignore index 6ac2926..950bd8b 100644 --- a/.gitignore +++ b/.gitignore @@ -390,6 +390,7 @@ appsettings.*.local.json # Local planning and prompt files DOTNET_2026_STANDARDS_PROMPT.md OTEL_ASPIRE_MIGRATION_PROMPT.md +/docs/ /graphify-out diff --git a/Directory.Packages.props b/Directory.Packages.props index 0e58796..a730231 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,7 +6,7 @@ - + @@ -19,7 +19,7 @@ - + @@ -50,10 +50,10 @@ - - + + - + diff --git a/F1Predictor.AppHost/F1Predictor.AppHost.csproj b/F1Predictor.AppHost/F1Predictor.AppHost.csproj index 065ceb1..f534d33 100644 --- a/F1Predictor.AppHost/F1Predictor.AppHost.csproj +++ b/F1Predictor.AppHost/F1Predictor.AppHost.csproj @@ -1,4 +1,4 @@ - + Exe diff --git a/F1Predictor.Application/Abstractions/Data/IApplicationDbContext.cs b/F1Predictor.Application/Abstractions/Data/IApplicationDbContext.cs index c33539b..cbeb64f 100644 --- a/F1Predictor.Application/Abstractions/Data/IApplicationDbContext.cs +++ b/F1Predictor.Application/Abstractions/Data/IApplicationDbContext.cs @@ -2,6 +2,7 @@ using F1Predictor.Domain.RaceData.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Infrastructure; namespace F1Predictor.Application.Abstractions.Data; @@ -25,5 +26,21 @@ public interface IApplicationDbContext /// ChangeTracker ChangeTracker { get; } + /// + /// Provides access to database-level operations such as transactions. Used for advanced + /// scenarios like serializing concurrent writers with an advisory lock (see + /// ). + /// + DatabaseFacade Database { get; } + + /// + /// Takes a Postgres transaction-scoped advisory lock keyed on . + /// Must be called inside an active transaction — the lock releases automatically when that + /// transaction commits or rolls back. Serializes concurrent ingestion of the same session + /// across requests, scheduler ticks, and — once scaled beyond one replica — processes, none + /// of which otherwise coordinate with each other. + /// + Task AcquireSessionAdvisoryLockAsync(int sessionKey, CancellationToken cancellationToken); + Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/F1Predictor.Application/Features/Seasons/GetDataStatus/DataStatusResponse.cs b/F1Predictor.Application/Features/Seasons/GetDataStatus/DataStatusResponse.cs new file mode 100644 index 0000000..87e0b3e --- /dev/null +++ b/F1Predictor.Application/Features/Seasons/GetDataStatus/DataStatusResponse.cs @@ -0,0 +1,17 @@ +namespace F1Predictor.Application.Features.Seasons.GetDataStatus; + +/// True if any of the three signals below means the pipeline needs a re-run. +/// +/// Name of the earliest Grand Prix that has happened but has no classified result yet, or null if +/// none. Null cannot distinguish "nothing pending" from "OpenF1 never published this round's +/// result" — the same ambiguity ChampionshipPoints already accepts elsewhere. +/// +/// Start date of , if any. +/// True if a classified Grand Prix has no feature rows yet. +/// False if the models have never been trained. +public sealed record DataStatusResponse( + bool IsStale, + string? PendingRaceName, + DateTimeOffset? PendingRaceDate, + bool FeaturesStale, + bool ModelsAvailable); diff --git a/F1Predictor.Application/Features/Seasons/GetDataStatus/GetDataStatusQuery.cs b/F1Predictor.Application/Features/Seasons/GetDataStatus/GetDataStatusQuery.cs new file mode 100644 index 0000000..d59b108 --- /dev/null +++ b/F1Predictor.Application/Features/Seasons/GetDataStatus/GetDataStatusQuery.cs @@ -0,0 +1,8 @@ +using F1Predictor.Application.Abstractions.Messaging; + +namespace F1Predictor.Application.Features.Seasons.GetDataStatus; + +/// +/// Whether a season's standings and predictions are current with what has actually raced. +/// +public sealed record GetDataStatusQuery(int Year) : IQuery; diff --git a/F1Predictor.Application/Features/Seasons/GetDataStatus/GetDataStatusQueryHandler.cs b/F1Predictor.Application/Features/Seasons/GetDataStatus/GetDataStatusQueryHandler.cs new file mode 100644 index 0000000..929f2cb --- /dev/null +++ b/F1Predictor.Application/Features/Seasons/GetDataStatus/GetDataStatusQueryHandler.cs @@ -0,0 +1,46 @@ +using F1Predictor.Application.Abstractions.Data; +using F1Predictor.Application.Abstractions.MachineLearning; +using F1Predictor.Application.Abstractions.Messaging; +using Microsoft.EntityFrameworkCore; +using SharedKernel; + +namespace F1Predictor.Application.Features.Seasons.GetDataStatus; + +internal sealed class GetDataStatusQueryHandler(IApplicationDbContext context, IRacePredictor predictor) + : IQueryHandler +{ + public async Task> Handle( + GetDataStatusQuery query, + CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + + var pending = await ( + from session in context.RaceSessions + join meeting in context.Meetings on session.MeetingKey equals meeting.MeetingKey + where meeting.Year == query.Year + && !session.IsSprint + && !session.IsClassified + && session.DateStart < now + orderby session.DateStart + select new { meeting.MeetingName, session.DateStart }) + .AsNoTracking() + .FirstOrDefaultAsync(cancellationToken); + + var featuresStale = await context.RaceSessions + .Join(context.Meetings, session => session.MeetingKey, meeting => meeting.MeetingKey, (session, meeting) => new { session, meeting }) + .Where(x => x.meeting.Year == query.Year && !x.session.IsSprint && x.session.IsClassified) + .AnyAsync(x => !context.DriverRaceFeatures.Any(f => f.SessionKey == x.session.SessionKey), cancellationToken); + + var modelsAvailable = predictor.ModelsAvailable; + + var isStale = pending is not null || featuresStale || !modelsAvailable; + + return Result.Success(new DataStatusResponse( + isStale, + pending?.MeetingName, + pending?.DateStart, + featuresStale, + modelsAvailable)); + } +} diff --git a/F1Predictor.Application/Features/Seasons/Ingest/IngestSeasonCommandHandler.cs b/F1Predictor.Application/Features/Seasons/Ingest/IngestSeasonCommandHandler.cs index 90218f1..f4ca07e 100644 --- a/F1Predictor.Application/Features/Seasons/Ingest/IngestSeasonCommandHandler.cs +++ b/F1Predictor.Application/Features/Seasons/Ingest/IngestSeasonCommandHandler.cs @@ -134,28 +134,31 @@ private static IngestOutcome Summarise(IReadOnlyList outcomes) var isSprint = string.Equals(session.SessionName, SprintSessionName, StringComparison.Ordinal); var label = $"{meeting.MeetingName} ({session.SessionName})"; - var existing = await context.RaceSessions + // Cheap early-out before bothering with a lock or the OpenF1 round trips below. The + // authoritative check — the one that actually decides skip vs. ingest — runs again + // after the advisory lock is acquired, since a concurrent writer may commit between + // this read and that point. + var precheck = await context.RaceSessions .AsNoTracking() .FirstOrDefaultAsync(s => s.SessionKey == session.SessionKey, cancellationToken); - // A session already stored with results is settled; anything else is re-checked so a - // scheduled race is picked up automatically once it runs. `force` re-fetches either way, - // which is the only route by which a provisional classification ever gets corrected. - if (existing is { IsClassified: true } && !force) + if (precheck is { IsClassified: true } && !force) { logger.LogInformation("{Label}: already ingested, skipping.", label); return (IngestOutcome.AlreadyPresent, SessionTotals.Empty); } + // No reason to hold a DB lock across network I/O, so fetch before taking it. var payload = await FetchSessionAsync(session, allSessions, isSprint, cancellationToken); - if (existing is not null) + var written = await WriteSessionUnderLockAsync(session, isSprint, force, payload, cancellationToken); + + if (!written) { - await ClearSessionAsync(session.SessionKey, cancellationToken); + logger.LogInformation("{Label}: already ingested by a concurrent run, skipping.", label); + return (IngestOutcome.AlreadyPresent, SessionTotals.Empty); } - await PersistSessionAsync(session, isSprint, payload, cancellationToken); - var totals = new SessionTotals( payload.Results.Count, payload.Grid.Count, @@ -181,6 +184,48 @@ private static IngestOutcome Summarise(IReadOnlyList outcomes) return (IngestOutcome.Ingested, totals); } + /// + /// Clears and re-persists one session's rows inside a transaction guarded by a Postgres + /// advisory lock keyed on 's key. Two triggers can otherwise race + /// to ingest the same session — a manual API call overlapping the Quartz coordinator's + /// startup tick, or, once this app scales beyond one replica, each replica's own scheduler + /// firing independently. The lock, held for the life of the transaction, serializes them at + /// the database so the loser re-checks post-commit state and skips instead of racing the + /// unique index on DriverEntries. + /// + /// False if a concurrent run already classified this session and is not set. + private async Task WriteSessionUnderLockAsync( + OpenF1Session session, + bool isSprint, + bool force, + SessionPayload payload, + CancellationToken cancellationToken) + { + await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken); + + await context.AcquireSessionAdvisoryLockAsync(session.SessionKey, cancellationToken); + + var existing = await context.RaceSessions + .AsNoTracking() + .FirstOrDefaultAsync(s => s.SessionKey == session.SessionKey, cancellationToken); + + if (existing is { IsClassified: true } && !force) + { + return false; + } + + if (existing is not null) + { + await ClearSessionAsync(session.SessionKey, cancellationToken); + } + + await PersistSessionAsync(session, isSprint, payload, cancellationToken); + + await transaction.CommitAsync(cancellationToken); + + return true; + } + /// /// Pulls everything OpenF1 has for one session. How much that is depends on whether the /// session has run — see the remarks on each call below. diff --git a/F1Predictor.Infrastructure/Database/ApplicationDbContext.cs b/F1Predictor.Infrastructure/Database/ApplicationDbContext.cs index b3d5025..69a6f73 100644 --- a/F1Predictor.Infrastructure/Database/ApplicationDbContext.cs +++ b/F1Predictor.Infrastructure/Database/ApplicationDbContext.cs @@ -21,6 +21,10 @@ public class ApplicationDbContext( public DbSet DriverEntries { get; set; } = null!; public DbSet DriverRaceFeatures { get; set; } = null!; + public Task AcquireSessionAdvisoryLockAsync(int sessionKey, CancellationToken cancellationToken) => + Database.ExecuteSqlInterpolatedAsync( + $"SELECT pg_advisory_xact_lock({sessionKey})", cancellationToken); + public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) { int result = await base.SaveChangesAsync(cancellationToken); diff --git a/F1Predictor.Infrastructure/Ingestion/SeasonIngestionCoordinatorJob.cs b/F1Predictor.Infrastructure/Ingestion/SeasonIngestionCoordinatorJob.cs index bea3cc3..79946dc 100644 --- a/F1Predictor.Infrastructure/Ingestion/SeasonIngestionCoordinatorJob.cs +++ b/F1Predictor.Infrastructure/Ingestion/SeasonIngestionCoordinatorJob.cs @@ -19,6 +19,7 @@ namespace F1Predictor.Infrastructure.Ingestion; /// due — so this can run far more often than a season would ever need re-fetching without /// wasting the free, rate-limited API on seasons that haven't changed. /// +[DisallowConcurrentExecution] internal sealed class SeasonIngestionCoordinatorJob( IApplicationDbContext dbContext, ICommandHandler ingestHandler, diff --git a/F1Predictor.Web/src/app/(marketing)/roadmap/page.tsx b/F1Predictor.Web/src/app/(marketing)/roadmap/page.tsx new file mode 100644 index 0000000..9fc651a --- /dev/null +++ b/F1Predictor.Web/src/app/(marketing)/roadmap/page.tsx @@ -0,0 +1,160 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { buttonClasses } from "@/shared/components/Button"; +import { RevealOnScroll, RevealListItemOnScroll } from "@/shared/components/RevealOnScroll"; + +const title = "Roadmap"; +const description = + "What GridMind is building next, the scope cuts it's shipping around today, and the AI analysis layer already designed but not yet built."; + +export const metadata: Metadata = { + title, + description, + openGraph: { title, description }, + twitter: { title, description }, + alternates: { canonical: "/roadmap" }, +}; + +const NEXT_STEPS = [ + { + title: "More training data", + body: "Ingest 2–3 seasons instead of one, for a less anemic training set.", + }, + { + title: "Tyre & strategy features", + body: "Add the stints endpoint — tyre compound and strategy features.", + }, + { + title: "AutoML upgrade path", + body: "Try ML.NET's AutoML API once the SDCA baseline is trusted.", + }, + { + title: "Background ingestion", + body: "Move ingestion onto a background queue so the endpoint returns 202 immediately.", + }, + { + title: "Retire the legacy import", + body: "Remove the one-off SQLite importer used to migrate the original console prototype.", + }, +] as const; + +const SCOPE_CUTS = [ + { + title: "Single season of data", + body: "~480 driver-race rows — enough to demonstrate the pipeline end to end, not enough for the predictions to be authoritative.", + }, + { + title: "No tyre or strategy features", + body: "The stints endpoint isn't ingested yet.", + }, + { + title: "Qualifying gap sentinel", + body: "A missing qualifying lap gets a 99 sentinel value rather than proper imputation.", + }, + { + title: "Synchronous ingestion", + body: "Ingestion runs inside the request. A background queue is a later concern.", + }, + { + title: "Legacy SQLite import", + body: "Exists only to migrate the original prototype's data — development-only, slated for removal.", + }, +] as const; + +export default function RoadmapPage() { + return ( + <> +
+ +

Roadmap

+

+ GridMind is a working prediction pipeline, not a finished product. Here's + what's shipping next, the boundaries it's deliberately shipping around + today, and an analysis layer that's already designed but not yet built. +

+
+
+ +
+

What's next

+
    + {NEXT_STEPS.map((step, index) => ( + + + {index + 1} + +

    {step.title}

    +

    {step.body}

    +
    + ))} +
+
+ +
+
+

Known scope cuts

+

+ Deliberate boundaries for a first release, not oversights. +

+
    + {SCOPE_CUTS.map((cut, index) => ( + +

    {cut.title}

    +

    {cut.body}

    +
    + ))} +
+
+
+ +
+ +

Planned: an AI analysis layer

+

+ Designed, not yet built. Two approved specs add interpretation beside the + prediction pipeline, never inside it — the classifiers and the Monte Carlo + simulator stay the only source of numbers. +

+
+ +
+ +

Explanations & analyst chat

+

+ Coefficient-grounded, per-feature explanations for a prediction; a generated + race-preview narrative; and a tool-calling analyst chat that answers questions by + calling GridMind's own use cases — running on a local Ollama model. +

+
+ +

Hosted provider & semantic search

+

+ A hosted OpenAI provider for chat and embeddings in production, explanations + extended to races that have already run, a background job that keeps previews + fresh, and semantic search over past races via embedded fact sheets in Postgres. +

+
+
+
+ +
+ +

+ Follow along in the dashboard. +

+ + Open the dashboard + +
+
+ + ); +} diff --git a/F1Predictor.Web/src/features/championship/components/ChampionshipView.tsx b/F1Predictor.Web/src/features/championship/components/ChampionshipView.tsx index b7ad6a2..4f97172 100644 --- a/F1Predictor.Web/src/features/championship/components/ChampionshipView.tsx +++ b/F1Predictor.Web/src/features/championship/components/ChampionshipView.tsx @@ -4,8 +4,10 @@ import { useCallback, useState } from "react"; import { getChampionshipForecast } from "@/features/championship/championship-service"; import { DriverStandingsTable } from "@/features/championship/components/DriverStandingsTable"; import { ConstructorStandingsTable } from "@/features/championship/components/ConstructorStandingsTable"; +import { getDataStatus } from "@/features/seasons/seasons-service"; import { LiveFooter } from "@/shared/components/LiveFooter"; import { Card } from "@/shared/components/Card"; +import { DataStalenessBanner } from "@/shared/components/DataStalenessBanner"; import { ErrorBanner } from "@/shared/components/ErrorBanner"; import { TableSkeleton } from "@/shared/components/Skeleton"; import { getErrorDisplay } from "@/shared/lib/error-display"; @@ -23,6 +25,9 @@ export function ChampionshipView({ year }: ChampionshipViewProps) { const fetcher = useCallback(() => getChampionshipForecast(year), [year]); const { data, error, loading, refreshing, lastUpdated, refresh } = useLiveData(fetcher, [year]); + const statusFetcher = useCallback(() => getDataStatus(year), [year]); + const { data: dataStatus } = useLiveData(statusFetcher, [year]); + if (loading) { return ( @@ -61,6 +66,8 @@ export function ChampionshipView({ year }: ChampionshipViewProps) { return (
+ +

{data.year} Championship

diff --git a/F1Predictor.Web/src/features/predictions/components/HoldoutView.tsx b/F1Predictor.Web/src/features/predictions/components/HoldoutView.tsx index 1b948eb..1f11d96 100644 --- a/F1Predictor.Web/src/features/predictions/components/HoldoutView.tsx +++ b/F1Predictor.Web/src/features/predictions/components/HoldoutView.tsx @@ -4,7 +4,10 @@ import { useEffect, useState } from "react"; import { getHoldoutPredictions } from "@/features/predictions/predictions-service"; import type { HoldoutPredictionsResponse } from "@/features/predictions/predictions-types"; import { PredictionsTable } from "@/features/predictions/components/PredictionsTable"; +import { getDataStatus } from "@/features/seasons/seasons-service"; +import type { DataStatus } from "@/features/seasons/seasons-types"; import { Card } from "@/shared/components/Card"; +import { DataStalenessBanner } from "@/shared/components/DataStalenessBanner"; import { ErrorBanner } from "@/shared/components/ErrorBanner"; import { TableSkeleton } from "@/shared/components/Skeleton"; import { ApiError } from "@/shared/lib/api-error"; @@ -18,6 +21,23 @@ export function HoldoutView({ year }: HoldoutViewProps) { const [data, setData] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); + const [dataStatus, setDataStatus] = useState(null); + + useEffect(() => { + let cancelled = false; + + getDataStatus(year) + .then((response) => { + if (!cancelled) setDataStatus(response); + }) + .catch(() => { + // Purely a nudge — a failed status check just means no banner, not a page error. + }); + + return () => { + cancelled = true; + }; + }, [year]); useEffect(() => { let cancelled = false; @@ -74,6 +94,8 @@ export function HoldoutView({ year }: HoldoutViewProps) { return (
+ +

Holdout Predictions

diff --git a/F1Predictor.Web/src/features/predictions/components/NextRaceView.tsx b/F1Predictor.Web/src/features/predictions/components/NextRaceView.tsx index a8b6905..c5e89df 100644 --- a/F1Predictor.Web/src/features/predictions/components/NextRaceView.tsx +++ b/F1Predictor.Web/src/features/predictions/components/NextRaceView.tsx @@ -3,8 +3,10 @@ import { useCallback } from "react"; import { getNextRacePreview } from "@/features/predictions/predictions-service"; import { PreviewTable } from "@/features/predictions/components/PreviewTable"; +import { getDataStatus } from "@/features/seasons/seasons-service"; import { Card } from "@/shared/components/Card"; import { Countdown } from "@/shared/components/Countdown"; +import { DataStalenessBanner } from "@/shared/components/DataStalenessBanner"; import { ErrorBanner } from "@/shared/components/ErrorBanner"; import { LiveFooter } from "@/shared/components/LiveFooter"; import { TableSkeleton } from "@/shared/components/Skeleton"; @@ -19,6 +21,9 @@ export function NextRaceView({ year }: NextRaceViewProps) { const fetcher = useCallback(() => getNextRacePreview(year), [year]); const { data, error, loading, refreshing, lastUpdated, refresh } = useLiveData(fetcher, [year]); + const statusFetcher = useCallback(() => getDataStatus(year), [year]); + const { data: dataStatus } = useLiveData(statusFetcher, [year]); + if (loading) { return ( @@ -64,6 +69,8 @@ export function NextRaceView({ year }: NextRaceViewProps) { return (
+ +

{data.meetingName}

diff --git a/F1Predictor.Web/src/features/seasons/components/RaceList.tsx b/F1Predictor.Web/src/features/seasons/components/RaceList.tsx index 0a1c386..f5aadea 100644 --- a/F1Predictor.Web/src/features/seasons/components/RaceList.tsx +++ b/F1Predictor.Web/src/features/seasons/components/RaceList.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useEffect, useState } from "react"; +import { useEffect, useState, type ReactNode } from "react"; import { useSeasonsStore } from "@/features/seasons/seasons-store"; import { getSeasonRaces } from "@/features/seasons/seasons-service"; import type { SeasonRace } from "@/features/seasons/seasons-types"; @@ -43,14 +43,24 @@ export function RaceList() { {!error && races !== null && races.length > 0 && (
    {races.map((race) => ( -
  • +
  • - - {race.meetingName} - + + {race.featureRowCount > 0 ? ( + + {race.meetingName} + + ) : ( + {race.meetingName} + )} + {race.isSprint && ( + Sprint + )} + {!race.isClassified && Scheduled} +

    {race.circuitShortName}, {race.countryName}

    - {race.featureRowCount} feature rows + {describeStatus(race)}
  • ))}
@@ -58,3 +68,21 @@ export function RaceList() { ); } + +/** Why a row has no predictions, or how many driver rows back them when it does. */ +function describeStatus(race: SeasonRace): string { + if (race.isSprint) return "Not predicted"; + if (!race.isClassified) return "Not yet run"; + return `${race.featureRowCount} feature rows`; +} + +function RaceTag({ title, children }: { title: string; children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/F1Predictor.Web/src/features/seasons/seasons-service.ts b/F1Predictor.Web/src/features/seasons/seasons-service.ts index b6e0ffa..a793ccf 100644 --- a/F1Predictor.Web/src/features/seasons/seasons-service.ts +++ b/F1Predictor.Web/src/features/seasons/seasons-service.ts @@ -1,5 +1,10 @@ import { api } from "@/shared/lib/api"; -import type { IngestSeasonResponse, RebuildFeaturesResponse, SeasonRace } from "@/features/seasons/seasons-types"; +import type { + DataStatus, + IngestSeasonResponse, + RebuildFeaturesResponse, + SeasonRace, +} from "@/features/seasons/seasons-types"; // A full season ingest is verified to take 300s+ (a ~500ms politeness delay per OpenF1 call, // across ~20+ meetings x ~5 calls each) — override the default axios timeout so the browser @@ -19,3 +24,7 @@ export function getSeasonRaces(year: number): Promise { export function rebuildFeatures(): Promise { return api.post("/features/rebuild").then((response) => response.data); } + +export function getDataStatus(year: number): Promise { + return api.get(`/seasons/${year}/data-status`).then((response) => response.data); +} diff --git a/F1Predictor.Web/src/features/seasons/seasons-types.ts b/F1Predictor.Web/src/features/seasons/seasons-types.ts index c919e7d..13e9070 100644 --- a/F1Predictor.Web/src/features/seasons/seasons-types.ts +++ b/F1Predictor.Web/src/features/seasons/seasons-types.ts @@ -46,6 +46,10 @@ export interface SeasonRace { circuitShortName: string; countryName: string; dateStart: string; + /** The weekend's sprint rather than its Grand Prix. Scores points but is never predicted. */ + isSprint: boolean; + /** Results published. False means the session has not run yet. */ + isClassified: boolean; featureRowCount: number; } @@ -54,3 +58,11 @@ export interface RebuildFeaturesResponse { racesSkipped: number; featureRows: number; } + +export interface DataStatus { + isStale: boolean; + pendingRaceName: string | null; + pendingRaceDate: string | null; + featuresStale: boolean; + modelsAvailable: boolean; +} diff --git a/F1Predictor.Web/src/shared/components/DataStalenessBanner.tsx b/F1Predictor.Web/src/shared/components/DataStalenessBanner.tsx new file mode 100644 index 0000000..61afe3d --- /dev/null +++ b/F1Predictor.Web/src/shared/components/DataStalenessBanner.tsx @@ -0,0 +1,37 @@ +import type { DataStatus } from "@/features/seasons/seasons-types"; + +interface DataStalenessBannerProps { + status: DataStatus | null; +} + +/** + * Nudges the user back to the dashboard's Ingest → Rebuild → Train panels when the pipeline has + * fallen behind — not an error, so it uses `--color-podium` rather than the error palette and + * never replaces the page's own content. + */ +export function DataStalenessBanner({ status }: DataStalenessBannerProps) { + if (!status?.isStale) { + return null; + } + + const message = status.pendingRaceName + ? `${status.pendingRaceName} (${new Date(status.pendingRaceDate!).toLocaleDateString()}) has been raced but hasn't been ingested yet.` + : status.featuresStale + ? "New results are ingested but features haven't been rebuilt yet." + : "Models haven't been trained yet, so predictions may be unavailable or out of date."; + + return ( +
+

+ Data may be out of date +

+

{message}

+ + Go to dashboard + +
+ ); +} diff --git a/F1Predictor.Web/src/shared/components/MarketingFooter.tsx b/F1Predictor.Web/src/shared/components/MarketingFooter.tsx index 77d8381..4d39cd1 100644 --- a/F1Predictor.Web/src/shared/components/MarketingFooter.tsx +++ b/F1Predictor.Web/src/shared/components/MarketingFooter.tsx @@ -21,9 +21,14 @@ export function MarketingFooter() { {" "} · ML.NET SDCA classifiers · Monte Carlo championship simulation

- - Open the dashboard - +
+ + Roadmap + + + Open the dashboard + +
); diff --git a/F1Predictor.Web/src/shared/components/MarketingNav.tsx b/F1Predictor.Web/src/shared/components/MarketingNav.tsx index 5114a04..2ed2d7f 100644 --- a/F1Predictor.Web/src/shared/components/MarketingNav.tsx +++ b/F1Predictor.Web/src/shared/components/MarketingNav.tsx @@ -21,6 +21,9 @@ export function MarketingNav() { How it works + + Roadmap + diff --git a/F1Predictor.WebApi/Endpoints/Seasons/GetDataStatus.cs b/F1Predictor.WebApi/Endpoints/Seasons/GetDataStatus.cs new file mode 100644 index 0000000..c22b0af --- /dev/null +++ b/F1Predictor.WebApi/Endpoints/Seasons/GetDataStatus.cs @@ -0,0 +1,27 @@ +using F1Predictor.Application.Abstractions.Messaging; +using F1Predictor.Application.Features.Seasons.GetDataStatus; +using F1Predictor.WebApi.Extensions; +using F1Predictor.WebApi.Infrastructure; + +namespace F1Predictor.WebApi.Endpoints.Seasons; + +internal sealed class GetDataStatus : IEndpoint +{ + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapGet("/api/seasons/{year:int}/data-status", async ( + int year, + IQueryHandler handler, + CancellationToken cancellationToken) => + { + var result = await handler.Handle(new GetDataStatusQuery(year), cancellationToken); + + return result.Match(Results.Ok, CustomResults.Problem); + }) + .WithTags(Tags.Seasons) + .WithName("GetDataStatus") + .WithSummary("Reports whether a season's standings and predictions are current.") + .WithDescription("True IsStale means a race has happened but hasn't been ingested, features haven't been rebuilt since, or the models have never been trained.") + .Produces(StatusCodes.Status200OK); + } +} diff --git a/F1Predictor.WebApi/models/podium-model.zip b/F1Predictor.WebApi/models/podium-model.zip index ffad116..b0c35d3 100644 Binary files a/F1Predictor.WebApi/models/podium-model.zip and b/F1Predictor.WebApi/models/podium-model.zip differ diff --git a/F1Predictor.WebApi/models/points-model.zip b/F1Predictor.WebApi/models/points-model.zip index f58e866..84d5b94 100644 Binary files a/F1Predictor.WebApi/models/points-model.zip and b/F1Predictor.WebApi/models/points-model.zip differ