From c6ae9355a93802e119ed150658d4216ec0f7c1dc Mon Sep 17 00:00:00 2001 From: gmalbert Date: Tue, 29 Sep 2026 21:54:16 -0400 Subject: [PATCH 001/131] Match React application shell to Streamlit reference --- fastapi_react/frontend/src/App.jsx | 127 +++++++++++++---------------- 1 file changed, 55 insertions(+), 72 deletions(-) diff --git a/fastapi_react/frontend/src/App.jsx b/fastapi_react/frontend/src/App.jsx index abe00fff..8cc338b5 100644 --- a/fastapi_react/frontend/src/App.jsx +++ b/fastapi_react/frontend/src/App.jsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState } from "react"; import { api } from "./api"; import DataExplorer from "./pages/DataExplorer"; import Analytics from "./pages/Analytics"; @@ -8,98 +8,81 @@ import Models from "./pages/Models"; import RawData from "./pages/RawData"; import BettingResearch from "./pages/BettingResearch"; -const pages = { - "Data Explorer": DataExplorer, - "Analytics": Analytics, - "Current Season": CurrentSeason, - "Next Race": NextRace, - "Predictive Models": Models, - "Raw Data": RawData, - "Betting Research": BettingResearch, -}; +const pages = [ + ["Data Explorer", "📊 Data Explorer", DataExplorer], + ["Analytics", "📈 Analytics & Visualizations", Analytics], + ["Current Season", "🏎️ Schedule", CurrentSeason], + ["Next Race", "🏁 Next Race", NextRace], + ["Predictive Models", "🤖 Predictive Models", Models], + ["Raw Data", "💾 Data & Debug", RawData], + ["Betting Research", "📐 Betting Research", BettingResearch], +]; -const icons = { - "Data Explorer": "\u25A6", - "Analytics": "\u2301", - "Current Season": "\u25F7", - "Next Race": "\uD83C\uDFC1", - "Predictive Models": "\u25C6", - "Raw Data": "\u2261", - "Betting Research": "\uD83D\uDCD0", -}; - -const BASE_TITLE = "F1 Analysis"; +const BASE_TITLE = "Gridlocked - Formula 1 Betting & Analytics"; export default function App() { const [active, setActive] = useState("Data Explorer"); - const [health, setHealth] = useState(null); - const [theme, setTheme] = useState(() => { - try { return localStorage.getItem("f1analysis.theme") === "light" ? "light" : "dark"; } - catch { return "dark"; } - }); + const [meta, setMeta] = useState(null); useEffect(() => { - api.get("/api/health").then(setHealth).catch(() => {}); + api.get("/api/meta").then(setMeta).catch(() => {}); const hash = decodeURIComponent(location.hash.replace("#/", "")); - if (pages[hash]) setActive(hash); + if (pages.some(([key]) => key === hash)) setActive(hash); }, []); useEffect(() => { - document.title = `${active} \u2014 ${BASE_TITLE}`; + const current = pages.find(([key]) => key === active); + document.title = current ? `${current[1].replace(/^\S+\s/, "")} — ${BASE_TITLE}` : BASE_TITLE; }, [active]); - useEffect(() => { - document.documentElement.dataset.theme = theme; - try { localStorage.setItem("f1analysis.theme", theme); } catch { /* storage is optional */ } - }, [theme]); - function navigate(page) { setActive(page); location.hash = `/${encodeURIComponent(page)}`; - window.scrollTo({ top: 0, behavior: "smooth" }); + window.scrollTo({ top: 0, behavior: "auto" }); } - const Page = pages[active]; + const Page = pages.find(([key]) => key === active)?.[2] || DataExplorer; + const startYear = meta?.race_start_year ?? 2016; + const currentYear = meta?.current_year ?? new Date().getFullYear(); + return (
Skip to main content -
-
- Gridlocked -
F1 Races from 2016 to {new Date().getFullYear()}
-
-
-
- -
- -
- Powered by - Betting Oracle - Sports Prediction Analytics - All content is for informational purposes only and does not constitute betting advice. Wager responsibly. -
); From 1661fa187d1d30f7ee2484ab26687aa34238cd70 Mon Sep 17 00:00:00 2001 From: gmalbert Date: Tue, 29 Sep 2026 21:55:13 -0400 Subject: [PATCH 002/131] Restyle React app to match Streamlit layout and theme --- fastapi_react/frontend/src/styles.css | 352 ++++++++++++-------------- 1 file changed, 160 insertions(+), 192 deletions(-) diff --git a/fastapi_react/frontend/src/styles.css b/fastapi_react/frontend/src/styles.css index c82e047c..62cc355d 100644 --- a/fastapi_react/frontend/src/styles.css +++ b/fastapi_react/frontend/src/styles.css @@ -1,223 +1,191 @@ :root { - color-scheme: dark; - --page-bg: #0c0c0f; - --surface: #15151a; - --surface-raised: #222229; - --border: #292931; - --text: #ececf1; - --muted: #b2b2bd; - --accent: #e10600; - font-family: "Source Sans Pro", "Segoe UI", sans-serif; + color-scheme: light; + --page-bg: #ffffff; + --secondary-bg: #f0f2f6; + --text: #31333f; + --muted: #6b6f76; + --border: #d9dadd; + --accent: #ff4b4b; + --accent-hover: #ff2b2b; + --code-bg: #f5f5f7; + font-family: "Source Sans Pro", "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif; color: var(--text); background: var(--page-bg); font-synthesis: none; } * { box-sizing: border-box; } +html { background: var(--page-bg); } body { margin: 0; min-width: 320px; min-height: 100vh; background: var(--page-bg); color: var(--text); } -button, input, select { font: inherit; } +button, input, select, textarea { font: inherit; } button { cursor: pointer; } -a { color: #ff595f; } -code { color: #ff9296; } - -/* Accessibility: visible focus indicator on every focusable element. */ -:focus-visible { - outline: 2px solid #ff595f; - outline-offset: 2px; - border-radius: 4px; -} +a { color: #0068c9; } +code { background: var(--code-bg); border-radius: .25rem; padding: .1rem .25rem; color: #31333f; } -/* Accessibility: skip-to-main-content link. Visible only when focused. */ +:focus-visible { outline: 3px solid rgba(255,75,75,.55); outline-offset: 2px; border-radius: 4px; } .skip-link { - position: absolute; - left: -9999px; - top: 0; - z-index: 1000; - padding: 8px 12px; - background: #ff595f; - color: white; - font-weight: 600; - text-decoration: none; - border-radius: 0 0 4px 0; -} -.skip-link:focus { - left: 0; -} - -/* Accessibility: main content becomes the focus target of the skip link. */ + position: fixed; left: -9999px; top: 0; z-index: 10000; padding: .55rem .8rem; + background: var(--accent); color: white; font-weight: 600; text-decoration: none; +} +.skip-link:focus { left: 0; } main:focus { outline: none; } -.app-shell { min-height: 100vh; } -.site-header { - max-width: 1800px; margin: 0 auto; padding: 22px 38px 12px; - display: flex; align-items: center; justify-content: space-between; gap: 24px; -} -.site-brand { display: flex; align-items: center; gap: 24px; min-width: 0; } -.site-brand img { display: block; width: min(450px, 38vw); height: auto; object-fit: contain; } -.site-title { color: #ececf1; font-size: 28px; font-weight: 650; line-height: 1.2; } -.section-nav { - max-width: 1800px; margin: 0 auto; padding: 0 38px; - display: flex; gap: 4px; overflow-x: auto; border-bottom: 1px solid #36363e; - scrollbar-width: thin; -} -.section-nav button { - flex: 0 0 auto; border: 0; border-bottom: 3px solid transparent; border-radius: 0; - background: transparent; color: #b7b7c2; padding: 13px 14px 10px; - display: flex; align-items: center; gap: 8px; white-space: nowrap; -} -.section-nav button:hover { background: #202027; color: white; } -.section-nav button.active { border-bottom-color: #e10600; color: #fff; } -.section-nav button span { width: 20px; text-align: center; } - -.runtime { padding: 8px 10px; display: flex; align-items: center; gap: 9px; color: #aaaab6; } -.theme-toggle { display: inline-flex; align-items: center; gap: 8px; white-space: nowrap; color: var(--muted); font-size: 13px; } -.theme-toggle input { width: 18px; height: 18px; accent-color: var(--accent); } -.runtime strong, .runtime small { display: block; } -.runtime strong { font-size: 12px; color: #d8d8df; } -.runtime small { font-size: 11px; margin-top: 2px; } -.dot { width: 8px; height: 8px; border-radius: 50%; background: #777; } -.dot.ok { background: #46cf7a; box-shadow: 0 0 8px #46cf7a66; } - -.content { margin: 0 auto; padding: 28px 38px 50px; max-width: 1800px; } -.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 24px; } -.page-header h1 { font-size: 30px; margin: 0 0 8px; letter-spacing: 0; } -.page-header p { margin: 0; color: #9d9daa; max-width: 820px; line-height: 1.5; } -.count-pill { border: 1px solid #454650; color: #d2d2da; background: #17171c; border-radius: 4px; padding: 8px 12px; white-space: nowrap; } - -.card { - background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 18px; margin-bottom: 18px; - box-shadow: 0 12px 30px #00000018; -} -.card h2 { margin: 0 0 14px; font-size: 17px; font-weight: 650; } -.card h4 { color: #dedee6; margin: 22px 0 10px; } -.muted { color: #92929e; line-height: 1.5; } -.warning { background: #2a2110; border: 1px solid #665023; color: #f5d98b; padding: 13px 15px; border-radius: 10px; margin-bottom: 18px; } - -.metrics { display: grid; grid-template-columns: repeat(auto-fit,minmax(175px,1fr)); gap: 12px; margin: 0 0 18px; } -.metric { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; } -.metric span { display: block; color: #888894; font-size: 12px; margin-bottom: 6px; } -.metric strong { display: block; font-size: 20px; color: #f1f1f5; overflow-wrap: anywhere; } - -.table-wrap { overflow: auto; width: 100%; border: 1px solid var(--border); border-radius: 6px; } -.table-wrap:focus-visible, .json:focus-visible { outline: 2px solid #f4c542; outline-offset: 2px; } -table { width: 100%; border-collapse: collapse; font-size: 12px; white-space: nowrap; } -th { position: sticky; top: 0; z-index: 1; background: #222229; color: #d9d9df; text-align: left; padding: 9px 10px; border-bottom: 1px solid #36363e; } -td { padding: 8px 10px; border-bottom: 1px solid #24242b; color: #bebec8; } -tbody tr:hover { background: #1c1c22; } - -.explorer-grid { display: grid; grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); gap: 18px; align-items: start; } -.filter-card { position: sticky; top: 18px; max-height: calc(100vh - 36px); overflow: auto; } -.search, input, select { - width: 100%; background: #0f0f13; color: #ececf1; border: 1px solid #383842; border-radius: 7px; padding: 9px 10px; -} -.search { margin-bottom: 12px; } -.filter-list { display: grid; gap: 10px; } -.filter-row { padding: 9px; border: 1px solid #292931; border-radius: 8px; } -.filter-row label { display: block; color: #c8c8d0; font-size: 12px; margin-bottom: 7px; overflow-wrap: anywhere; } -.filter-row label input { width: auto; margin-right: 6px; } -.range-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; } -.button-row { display: flex; gap: 8px; margin-top: 14px; } -.button-row.wrap { flex-wrap: wrap; } +.streamlit-main { min-height: 100vh; } +.block-container { width: 100%; max-width: 1200px; margin: 0 auto; padding: 2.7rem 3rem 4rem; } +.streamlit-hero { margin-bottom: .7rem; } +.brand-logo { display: block; width: 450px; max-width: 100%; height: auto; margin: 0 0 1.15rem; } +.streamlit-hero h1 { margin: 0 0 .55rem; font-size: 2.75rem; line-height: 1.15; font-weight: 700; letter-spacing: -.02em; } +.caption { margin: .12rem 0; color: #808495; font-size: .88rem; line-height: 1.35; } + +.streamlit-tabs { + display: flex; align-items: flex-end; gap: 1.35rem; width: 100%; overflow-x: auto; + border-bottom: 1px solid #e6e6e8; margin: 1rem 0 1.8rem; scrollbar-width: thin; +} +.streamlit-tabs button { + appearance: none; border: 0; border-bottom: 2px solid transparent; background: transparent; + color: #586069; padding: .7rem .05rem .62rem; border-radius: 0; white-space: nowrap; + font-size: .96rem; font-weight: 400; +} +.streamlit-tabs button:hover { color: var(--text); } +.streamlit-tabs button.active { color: var(--text); border-bottom-color: var(--accent); font-weight: 600; } + +.page-content { min-height: 28rem; } +.page-header { display: block; margin: 0 0 1.15rem; } +.page-header h1 { margin: 0 0 .45rem; font-size: 2rem; line-height: 1.25; font-weight: 700; letter-spacing: -.01em; } +.page-header p { margin: 0; color: var(--text); line-height: 1.6; max-width: none; } +.count-pill { display: none; } + +.card { background: transparent; border: 0; border-radius: 0; padding: 0; margin: 0 0 1.65rem; box-shadow: none; } +.card h2 { margin: 0 0 .7rem; font-size: 1.45rem; line-height: 1.3; font-weight: 650; } +.card h3, .card h4 { margin: 1.2rem 0 .55rem; color: var(--text); } +.muted { color: var(--muted); line-height: 1.5; } +.warning, .status.error { + padding: .85rem 1rem; margin: .75rem 0 1rem; border-radius: .5rem; border: 1px solid; +} +.warning { background: #fff5e5; border-color: #f3c46f; color: #6b4b12; } +.status.error { background: #ffecec; border-color: #ffb3b3; color: #8b1b1b; } + +.metrics { display: grid; grid-template-columns: repeat(auto-fit,minmax(180px,1fr)); gap: 1rem; margin: .75rem 0 1.5rem; } +.metric { + min-height: 5.8rem; padding: .75rem 1rem; border: 1px solid var(--border); border-radius: .5rem; background: #fff; +} +.metric span { display: block; color: #5e626b; font-size: .88rem; margin-bottom: .35rem; } +.metric strong { display: block; color: var(--text); font-size: 1.65rem; line-height: 1.15; font-weight: 600; overflow-wrap: anywhere; } + +.table-wrap { overflow: auto; width: 100%; border: 1px solid #d7d8da; border-radius: .5rem; background: white; } +table { width: 100%; border-collapse: collapse; font-size: .84rem; white-space: nowrap; } +th { + position: sticky; top: 0; z-index: 1; background: #f7f7f8; color: #31333f; text-align: left; + padding: .55rem .65rem; border-bottom: 1px solid #d7d8da; font-weight: 600; +} +td { padding: .52rem .65rem; border-bottom: 1px solid #ececee; color: #31333f; } +tbody tr:hover { background: #f7f7f8; } +.next-race-row { background: #ffe599 !important; } + +.filter-results-toggle, .dataset-toggle { + display: inline-flex; align-items: center; gap: .5rem; margin: .15rem 0 1rem; font-size: .96rem; +} +.filter-results-toggle input, .dataset-toggle input { width: 1rem; height: 1rem; accent-color: var(--accent); } + +.explorer-grid { + display: grid; grid-template-columns: minmax(280px, 330px) minmax(0, 1fr); + gap: 2rem; align-items: start; margin-left: -1rem; +} +.filter-card { + position: sticky; top: 0; max-height: 85vh; overflow: auto; padding: 1.25rem 1rem; + background: var(--secondary-bg); border-radius: .35rem; +} +.filter-card h2 { font-size: 1.35rem; } +.search, input, select, textarea { + width: 100%; background: white; color: var(--text); border: 1px solid #aeb1b5; border-radius: .45rem; + padding: .48rem .65rem; +} +.search { margin-bottom: .75rem; } +.filter-list { display: grid; gap: .78rem; } +.filter-row { padding: 0; border: 0; } +.filter-row > label { display: block; color: var(--text); font-size: .9rem; margin-bottom: .35rem; } +.range-pair { display: grid; grid-template-columns: 1fr 1fr; gap: .35rem; } + button { - color: #d7d7de; background: #222229; border: 1px solid #3a3a43; border-radius: 7px; padding: 8px 12px; + color: var(--text); background: white; border: 1px solid #aeb1b5; border-radius: .45rem; padding: .48rem .8rem; +} +button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); } +button.primary, .button-link { + background: var(--accent); color: white; border: 1px solid var(--accent); border-radius: .45rem; + font-weight: 600; padding: .5rem .85rem; text-decoration: none; +} +button.primary:hover:not(:disabled), .button-link:hover { background: var(--accent-hover); color: white; } +button:disabled { opacity: .5; cursor: not-allowed; } +.button-row { display: flex; align-items: center; gap: .55rem; margin: .75rem 0; } +.button-row.wrap { flex-wrap: wrap; } + +.chart-grid { display: block; } +.chart-grid > * { margin-bottom: 1.65rem; } +.chart { width: 100%; height: 400px; } +.recharts-cartesian-grid line { stroke: #e6e6e8; } +.recharts-text { fill: #5e626b; font-size: 12px; } + +.subtabs { + display: flex; gap: 1.2rem; flex-wrap: nowrap; overflow-x: auto; border-bottom: 1px solid #e6e6e8; + margin: .25rem 0 1.4rem; +} +.subtabs button { + border: 0; border-bottom: 2px solid transparent; border-radius: 0; padding: .6rem .05rem; background: transparent; + color: #586069; white-space: nowrap; } -button:hover:not(:disabled) { background: #2b2b33; } -button.primary, .button-link { background: #e10600; color: white; border-color: #e10600; font-weight: 600; } -button:disabled { opacity: .45; cursor: not-allowed; } -.button-link { display: inline-block; padding: 8px 12px; border-radius: 7px; text-decoration: none; } +.subtabs button.active { background: transparent; border-color: var(--accent); color: var(--text); font-weight: 600; } -.chart-grid { display: grid; grid-template-columns: repeat(auto-fit,minmax(440px,1fr)); gap: 18px; } -.chart { width: 100%; height: 330px; } -.recharts-cartesian-grid line { stroke: #303038; } -.recharts-text { fill: #9f9faa; font-size: 11px; } +details { margin: .7rem 0 1.2rem; border: 1px solid #d7d8da; border-radius: .5rem; padding: .7rem .85rem; } +details summary { cursor: pointer; font-weight: 600; } +details[open] summary { margin-bottom: .8rem; } -.subtabs { display: flex; gap: 7px; flex-wrap: wrap; margin-bottom: 18px; } -.subtabs button.active { background: #e10600; border-color: #e10600; color: white; } .json { - overflow: auto; max-height: 600px; background: #0d0d11; border: 1px solid #282830; - padding: 13px; border-radius: 8px; color: #b9d7b7; font-size: 11px; line-height: 1.45; + overflow: auto; max-height: 600px; background: #f6f7f9; border: 1px solid #d7d8da; + padding: .8rem; border-radius: .45rem; color: #31333f; font-size: .78rem; line-height: 1.45; } -.status, .empty { padding: 20px; color: #92929e; text-align: center; } -.status.error { background: #301719; border: 1px solid #682d30; color: #ff969a; border-radius: 8px; } -.form-grid { display: grid; grid-template-columns: repeat(auto-fit,minmax(180px,1fr)); gap: 12px; margin-bottom: 14px; } -.form-grid label, .field-label { color: #aaaab5; font-size: 12px; } -.form-grid input, .form-grid select { margin-top: 6px; } +.status, .empty { padding: 1rem 0; color: #6b6f76; } +.status.error { text-align: left; } +.loading-state { padding: .8rem 0; color: #6b6f76; } +.skeleton-line { display: block; width: 100%; height: .85rem; margin-top: .55rem; background: #eceef1; border-radius: .2rem; } +.skeleton-line.short { width: 55%; } + +.form-grid { display: grid; grid-template-columns: repeat(auto-fit,minmax(190px,1fr)); gap: .8rem 1rem; margin: .7rem 0 1rem; } +.form-grid label, .field-label { color: var(--text); font-size: .9rem; } +.form-grid input, .form-grid select { margin-top: .3rem; } -.raw-grid { display: grid; grid-template-columns: minmax(300px, 420px) minmax(0, 1fr); gap: 18px; align-items: start; } -.file-list { max-height: 700px; overflow-y: auto; display: grid; gap: 4px; } +.raw-grid { display: grid; grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); gap: 1.5rem; align-items: start; } +.file-list { max-height: 620px; overflow-y: auto; display: grid; gap: .15rem; } .file { - display: flex; justify-content: space-between; width: 100%; text-align: left; - background: transparent; border: 0; color: #bdbdc8; padding: 8px; gap: 10px; + display: flex; justify-content: space-between; width: 100%; text-align: left; background: transparent; + border: 0; color: var(--text); padding: .4rem .25rem; gap: .75rem; } -.file:hover, .file.active { background: #24242b; } -.file small { color: #777783; white-space: nowrap; } +.file:hover, .file.active { background: #f0f2f6; color: var(--text); } +.file small { color: #7b7f87; white-space: nowrap; } + +.tire-section { margin-top: 2rem; } +.tire-section > h2 { font-size: 1.5rem; margin-bottom: .35rem; } +.tire-controls { max-width: 620px; } + footer.site-footer { - margin-top: 36px; padding-top: 20px; border-top: 1px solid #24242b; color: #8a8a95; - font-size: 12px; display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: 8px 14px; + margin-top: 3rem; padding-top: 1.15rem; border-top: 1px solid #ececee; color: #6b6f76; + font-size: .78rem; display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: .4rem .8rem; } -footer.site-footer small { flex-basis: 100%; text-align: center; font-size: 11px; } +footer.site-footer small { flex-basis: 100%; text-align: center; } @media (max-width: 900px) { - .site-header { padding: 18px 14px 10px; align-items: flex-start; } - .site-brand { align-items: flex-start; flex-direction: column; gap: 10px; } - .site-brand img { width: min(360px, 62vw); } - .site-title { font-size: 22px; } - .section-nav { padding: 0 14px; } - .section-nav button { padding-inline: 11px; } - .runtime { padding: 4px; } - .content { padding: 22px 14px 40px; } - .explorer-grid, .raw-grid { grid-template-columns: 1fr; } + .block-container { padding: 1.6rem 1.2rem 3rem; } + .streamlit-hero h1 { font-size: 2.2rem; } + .streamlit-tabs { gap: 1rem; } + .explorer-grid, .raw-grid { grid-template-columns: 1fr; margin-left: 0; } .filter-card { position: static; max-height: none; } - .chart-grid { grid-template-columns: 1fr; } + .chart { height: 330px; } } - -@media (max-width: 520px) { - .site-header { flex-direction: column; gap: 12px; } - .site-brand img { width: min(320px, 86vw); } - .section-nav button { font-size: 13px; } +@media (max-width: 600px) { + .block-container { padding-inline: .8rem; } + .streamlit-hero h1 { font-size: 1.85rem; } + .page-header h1 { font-size: 1.7rem; } + .streamlit-tabs { gap: .8rem; } + .metrics { grid-template-columns: 1fr 1fr; } } - -.next-race-row { background: #3a3212 !important; box-shadow: inset 3px 0 #f5c842; } - -:root[data-theme="light"] { - color-scheme: light; - --page-bg: #f4f5f7; - --surface: #ffffff; - --surface-raised: #e7e8ec; - --border: #aeb1ba; - --text: #1c1d21; - --muted: #4e5059; - --accent: #a90f0b; -} -:root[data-theme="light"] .section-nav { border-color: var(--border); } -:root[data-theme="light"] .section-nav button { color: #40424a; } -:root[data-theme="light"] .section-nav button.active { color: #17181b; } -:root[data-theme="light"] .section-nav button:hover, -:root[data-theme="light"] tbody tr:hover { background: #e7e8ec; color: #141519; } -:root[data-theme="light"] .card, -:root[data-theme="light"] .metric { background: var(--surface); border-color: var(--border); box-shadow: 0 4px 14px #12141a0c; } -:root[data-theme="light"] .metric strong, -:root[data-theme="light"] .card h4 { color: #1c1d21; } -:root[data-theme="light"] .metric span, -:root[data-theme="light"] .muted, -:root[data-theme="light"] .page-header p, -:root[data-theme="light"] .runtime, -:root[data-theme="light"] .theme-toggle { color: var(--muted); } -:root[data-theme="light"] input, -:root[data-theme="light"] select { background: #fff; color: #1c1d21; border-color: #737680; } -:root[data-theme="light"] button { background: #e7e8ec; color: #1c1d21; border-color: #777a84; } -:root[data-theme="light"] button:hover:not(:disabled) { background: #d9dbe1; } -:root[data-theme="light"] button.primary, -:root[data-theme="light"] .button-link, -:root[data-theme="light"] .subtabs button.active { background: #a90f0b; color: #fff; border-color: #a90f0b; } -:root[data-theme="light"] .table-wrap { border-color: var(--border); } -:root[data-theme="light"] th { background: #e3e5e9; color: #22242a; border-color: #afb2bb; } -:root[data-theme="light"] td { color: #30323a; border-color: #d5d7dc; } -:root[data-theme="light"] footer.site-footer { color: #4e5059; border-color: #d5d7dc; } -:root[data-theme="light"] .status.error { background: #fff0ef; color: #80150f; border-color: #bb5f58; } -:root[data-theme="light"] .warning { background: #fff6dc; color: #503600; border-color: #967526; } -:root[data-theme="light"] .json { background: #f1f2f4; color: #254d2a; border-color: var(--border); } -:root[data-theme="light"] .skeleton-line { background: #d6d8de; } - @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; } } From f9542a817ee6b2ffa8b57f53314aea5712cfd7c7 Mon Sep 17 00:00:00 2001 From: gmalbert Date: Tue, 29 Sep 2026 21:55:26 -0400 Subject: [PATCH 003/131] Expose Streamlit shell metadata to React --- fastapi_react/backend/app/main.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/fastapi_react/backend/app/main.py b/fastapi_react/backend/app/main.py index a9095d85..7679f257 100644 --- a/fastapi_react/backend/app/main.py +++ b/fastapi_react/backend/app/main.py @@ -1,5 +1,6 @@ from __future__ import annotations +import datetime import os from typing import Any @@ -21,6 +22,7 @@ from app.services.betting import backtest, calibration, governance, simulate, value_and_stake from app.services.data import ( filter_schema, + load_main_data, list_data_files, model_manifest, precomputed, @@ -81,6 +83,16 @@ def brand_logo() -> FileResponse: @app.get("/api/meta") def meta() -> dict[str, Any]: + data = load_main_data() + years = data.get("grandPrixYear") + race_start_year = int(years.min()) if years is not None and years.notna().any() else 2016 + current_year = int(years.max()) if years is not None and years.notna().any() else datetime.datetime.now().year + candidates = [path for path in DATA_DIR.iterdir() if path.is_file()] if DATA_DIR.exists() else [] + latest = max(candidates, key=lambda path: path.stat().st_mtime, default=None) + last_updated = ( + datetime.datetime.fromtimestamp(latest.stat().st_mtime).strftime("%Y-%m-%d %I:%M %p") + if latest is not None else "No data files found" + ) return { "tabs": [ "Data Explorer", "Analytics", "Current Season", "Next Race", @@ -89,6 +101,10 @@ def meta() -> dict[str, Any]: "models": MODEL_TYPES, "expensive_tools_enabled": ENABLE_EXPENSIVE_TOOLS, "manual_tools": list(TOOLS), + "race_start_year": race_start_year, + "current_year": current_year, + "last_updated": last_updated, + "code_deployed_at": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC"), } From 6087777cf699ae19ebc3f345e1eb891266d78b4a Mon Sep 17 00:00:00 2001 From: gmalbert Date: Tue, 29 Sep 2026 21:55:40 -0400 Subject: [PATCH 004/131] Support Streamlit-style table labels and checkbox cells --- fastapi_react/frontend/src/components/UI.jsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/fastapi_react/frontend/src/components/UI.jsx b/fastapi_react/frontend/src/components/UI.jsx index aa0d94bb..31e9e017 100644 --- a/fastapi_react/frontend/src/components/UI.jsx +++ b/fastapi_react/frontend/src/components/UI.jsx @@ -38,20 +38,20 @@ export function Status(props = {}) { return children || null; } -/** @param {{ rows?: Array>, columns?: string[], maxHeight?: number, ariaLabel?: string }} props */ +/** @param {{ rows?: Array>, columns?: string[], maxHeight?: number, ariaLabel?: string, headerMap?: Record, checkboxColumns?: string[] }} props */ /* eslint-disable jsx-a11y/no-noninteractive-tabindex */ export function DataTable(props = {}) { - const { rows = [], columns = undefined, maxHeight = 560, ariaLabel = "Data table, scrollable region" } = props; + const { rows = [], columns = undefined, maxHeight = 560, ariaLabel = "Data table, scrollable region", headerMap = {}, checkboxColumns = [] } = props; if (!rows?.length) return
No rows available.
; const cols = columns?.length ? columns : Object.keys(rows[0] || {}); return (
- {cols.map(c => )} + {cols.map(c => )} {rows.map((row, i) => ( - {cols.map(c => )} + {cols.map(c => )} ))} From 3b9ec6e5958e75aad07a0efe72c23b5375c5f01e Mon Sep 17 00:00:00 2001 From: gmalbert Date: Tue, 29 Sep 2026 21:56:10 -0400 Subject: [PATCH 005/131] Match Data Explorer controls and table to Streamlit --- .../frontend/src/pages/DataExplorer.jsx | 231 ++++++++++-------- 1 file changed, 125 insertions(+), 106 deletions(-) diff --git a/fastapi_react/frontend/src/pages/DataExplorer.jsx b/fastapi_react/frontend/src/pages/DataExplorer.jsx index 949c48bd..0db03aed 100644 --- a/fastapi_react/frontend/src/pages/DataExplorer.jsx +++ b/fastapi_react/frontend/src/pages/DataExplorer.jsx @@ -1,81 +1,95 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from "react"; import { api } from "../api"; import { Card, DataTable, Status } from "../components/UI"; -const preferredColumns = [ - "grandPrixYear", "grandPrixName", "constructorName", "resultsDriverName", - "resultsStartingGridPositionNumber", "resultsFinalPositionNumber", "positionsGained", - "DNF", "resultsQualificationPositionNumber", "averagePracticePosition", - "lastFPPositionNumber", "numberOfStops", "averageStopTime", "totalStopTime" +const STREAMLIT_COLUMNS = [ + "grandPrixYear", "grandPrixName", "streetRace", "trackRace", "constructorName", "resultsDriverName", + "resultsPodium", "resultsTop5", "resultsTop10", "resultsStartingGridPositionNumber", + "resultsFinalPositionNumber", "positionsGained", "DNF", "resultsQualificationPositionNumber", + "q1End", "q2End", "q3Top10", "averagePracticePosition", "lastFPPositionNumber", "numberOfStops", + "averageStopTime", "totalStopTime", "driverBestStartingGridPosition", "driverBestRaceResult", + "driverTotalChampionshipWins", "driverTotalPolePositions", "resultsReasonRetired", + "driverTotalRaceEntries", "driverTotalRaceStarts", "driverTotalRaceWins", "driverTotalRaceLaps", + "driverTotalPodiums", "avgLapTime", "finishingTime" ]; +const CHECKBOX_COLUMNS = ["streetRace", "trackRace", "resultsPodium", "resultsTop5", "resultsTop10", "DNF", "q1End", "q2End", "q3Top10"]; + function FilterEditor({ spec, value, onChange }) { if (spec.kind === "boolean") { return ( - + ); } if (spec.kind === "exact" && spec.options) { return ( - + ); } if (spec.kind === "range" || spec.kind === "date_range") { const current = Array.isArray(value) ? value : [spec.min, spec.max]; const type = spec.kind === "date_range" ? "date" : "number"; return ( -
- onChange([e.target.value, current[1]])} /> - onChange([current[0], e.target.value])} /> -
+ ); } - return onChange(e.target.value || null)} placeholder="Exact value" />; + return ( + + ); } export default function DataExplorer() { const [schema, setSchema] = useState([]); const [showFilters, setShowFilters] = useState(false); - const [selected, setSelected] = useState(["grandPrixYear", "grandPrixName", "resultsDriverName", "constructorName"]); const [values, setValues] = useState({}); - const [query, setQuery] = useState(""); const [result, setResult] = useState({ rows: [], columns: [], total: 0 }); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [hasAppliedFilters, setHasAppliedFilters] = useState(false); useEffect(() => { api.get("/api/data-explorer/schema").then(r => { setSchema(r.filters); - let saved = null; - try { saved = JSON.parse(sessionStorage.getItem("f1analysis.filters") || "null"); } catch { /* ignore invalid session state */ } - if (saved?.applied) { - setValues(saved.values || {}); - setHasAppliedFilters(true); - setShowFilters(true); - runQuery(saved.filters, preferredColumns); - } else { - setLoading(false); - } - }).catch(e => { setError(e); setLoading(false); }); - // runQuery intentionally omitted: it is recreated on every render and we - // only want this effect to run once on mount. + try { + const saved = JSON.parse(sessionStorage.getItem("f1analysis.filters") || "null"); + if (saved?.applied) { + setShowFilters(true); + setValues(saved.values || {}); + runQuery(saved.filters || []); + } + } catch { /* ignore invalid session state */ } + }).catch(setError); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const byName = useMemo(() => Object.fromEntries(schema.map(s => [s.column, s])), [schema]); - const available = useMemo(() => schema.filter(s => - `${s.column} ${s.label}`.toLowerCase().includes(query.toLowerCase()) - ), [schema, query]); + const headerMap = useMemo(() => Object.fromEntries(schema.map(s => [s.column, s.label])), [schema]); function activeFilters(nextValues = values) { return Object.entries(nextValues).flatMap(([column, value]) => { - if (value == null || value === "") return []; + if (value == null || value === "" || (Array.isArray(value) && value.some(v => v === ""))) return []; const spec = byName[column]; if (!spec) return []; let normalized = value; @@ -84,96 +98,101 @@ export default function DataExplorer() { }); } - async function runQuery(filters = activeFilters(), columns = preferredColumns) { - setLoading(true); setError(null); + async function runQuery(filters = activeFilters()) { + setLoading(true); + setError(null); try { - const body = { filters, columns, sort: ["grandPrixYear", "resultsFinalPositionNumber"], descending: true, offset: 0, limit: 500 }; - const r = /** @type {{ total: number, columns: string[], rows: Array> }} */ ( - await api.post("/api/data-explorer/query", body) - ); - setResult(r); - } catch (e) { setError(e); } - finally { setLoading(false); } - } - - function toggleColumn(column) { - setSelected(prev => prev.includes(column) ? prev.filter(x => x !== column) : [...prev, column]); - } - - function clear() { - setValues({}); - setHasAppliedFilters(false); - sessionStorage.removeItem("f1analysis.filters"); - runQuery([], preferredColumns); + const body = { + filters, + columns: STREAMLIT_COLUMNS, + sort: ["grandPrixYear", "resultsFinalPositionNumber"], + descending: true, + offset: 0, + limit: 1000, + }; + setResult(await api.post("/api/data-explorer/query", body)); + } catch (e) { + setError(e); + } finally { + setLoading(false); + } } - function apply() { - const filters = activeFilters(); - sessionStorage.setItem("f1analysis.filters", JSON.stringify({ applied: true, filters, values })); - setHasAppliedFilters(true); - runQuery(filters, preferredColumns); + function apply(nextValues = values) { + const filters = activeFilters(nextValues); + sessionStorage.setItem("f1analysis.filters", JSON.stringify({ applied: true, filters, values: nextValues })); + runQuery(filters); } function toggleFilters(enabled) { setShowFilters(enabled); - if (!enabled) { + setError(null); + if (enabled) { + const defaults = {}; + for (const spec of schema) { + if (spec.kind === "range" || spec.kind === "date_range") defaults[spec.column] = [spec.min, spec.max]; + } + setValues(defaults); + apply(defaults); + } else { setValues({}); - setHasAppliedFilters(false); setResult({ rows: [], columns: [], total: 0 }); sessionStorage.removeItem("f1analysis.filters"); - setLoading(false); - return; } - if (schema.length) apply(); } return (
-

Data Explorer

Filter and explore F1 race data from multiple perspectives.

-
{result.total.toLocaleString()} rows
+

Data Explorer

+

Filter and explore F1 race data from multiple perspectives.

- {!showFilters && error && } - {showFilters &&
- - setQuery(e.target.value)} /> -
- {available.map(spec => ( -
- - {selected.includes(spec.column) && ( - setValues(x => ({ ...x, [spec.column]: v }))} /> - )} -
- ))} -
-
- - -
-
+ {showFilters && ( +
+ - - - {result.rows?.length ? ( - - ) : ( -
-

No rows match the current filters.

- {hasAppliedFilters && ( - - )} -
- )} -
-
-
} +
+

Number of filtered results: {result.total.toLocaleString()}

+ + {result.rows?.length ? ( + + ) :
No rows match the current filters.
} +
+
+
+ )} + + {!showFilters && error && }
); } From 45c00f510b38bb05080e881ebbcd33d282d34290 Mon Sep 17 00:00:00 2001 From: gmalbert Date: Tue, 29 Sep 2026 21:56:46 -0400 Subject: [PATCH 006/131] Complete lightweight Analytics parity payload --- .../backend/app/services/analysis.py | 123 +++++++++++++++--- 1 file changed, 103 insertions(+), 20 deletions(-) diff --git a/fastapi_react/backend/app/services/analysis.py b/fastapi_react/backend/app/services/analysis.py index 6ef139da..a53355a6 100644 --- a/fastapi_react/backend/app/services/analysis.py +++ b/fastapi_react/backend/app/services/analysis.py @@ -9,7 +9,7 @@ from scipy.stats import linregress from app.config import DATA_DIR -from app.services.data import apply_filters, load_main_data, load_race_schedule, records +from app.services.data import apply_filters, load_main_data, load_race_schedule, model_manifest, precomputed, records def _regression(df: pd.DataFrame, x_col: str, y_col: str) -> dict[str, Any] | None: @@ -52,8 +52,12 @@ def _regression_series(df: pd.DataFrame, x_col: str, y_col: str) -> dict[str, An def analytics(filters: Any, max_rows: int) -> dict[str, Any]: + """Return every lightweight analysis block rendered by the Streamlit Analytics tab.""" df = apply_filters(load_main_data(), filters).head(max_rows).copy() payload: dict[str, Any] = {"rows_considered": len(df), "charts": {}, "regressions": []} + if df.empty: + return payload + pairs = { "active_years_vs_final": ("resultsFinalPositionNumber", "yearsActive"), "positions_gained_over_time": ("short_date", "positionsGained"), @@ -61,6 +65,7 @@ def analytics(filters: Any, max_rows: int) -> dict[str, Any]: "grid_vs_final": ("resultsStartingGridPositionNumber", "resultsFinalPositionNumber"), "avg_practice_vs_final": ("averagePracticePosition", "resultsFinalPositionNumber"), "pit_stop_vs_final": ("averageStopTime", "resultsFinalPositionNumber"), + "track_turns_vs_final": ("turns", "resultsFinalPositionNumber"), } for name, (x, y) in pairs.items(): if x in df and y in df: @@ -71,8 +76,14 @@ def analytics(filters: Any, max_rows: int) -> dict[str, Any]: if result: payload["regressions"].append(result) regression_titles = { - "averagePracticePosition": ("Linear Regression: Average Practice Position vs Final Position", "Average Practice Position"), - "resultsStartingGridPositionNumber": ("Linear Regression: Starting Position vs Final Position", "Starting Position"), + "averagePracticePosition": ( + "Linear Regression: Average Practice Position vs Final Position", + "Average Practice Position", + ), + "resultsStartingGridPositionNumber": ( + "Linear Regression: Starting Position vs. Final Position", + "Starting Position", + ), } payload["regression_series"] = [] for x, (title, x_label) in regression_titles.items(): @@ -90,19 +101,22 @@ def analytics(filters: Any, max_rows: int) -> dict[str, Any]: "driverBestRaceResult", "driverTotalChampionshipWins", "driverTotalPolePositions", "driverTotalRaceEntries", "driverTotalRaceStarts", "driverTotalRaceWins", "driverTotalRaceLaps", "driverTotalPodiums", "avgLapPace", "finishingTime", + "resultsQualificationPositionNumber", "numberOfStops", ) if c in df] if corr_cols: corr = df[corr_cols].apply(pd.to_numeric, errors="coerce").corr() payload["correlation"] = { "columns": list(corr.columns), "rows": [ - {"feature": idx, **{col: (None if pd.isna(v) else float(v)) for col, v in row.items()}} + {"Feature": idx, **{col: (None if pd.isna(v) else float(v)) for col, v in row.items()}} for idx, row in corr.iterrows() ], } if {"grandPrixYear", "resultsDriverName", "resultsFinalPositionNumber"}.issubset(df.columns): - agg = {"average_final_position": ("resultsFinalPositionNumber", "mean")} + agg: dict[str, tuple[str, Any]] = { + "average_final_position": ("resultsFinalPositionNumber", "mean"), + } if "resultsPodium" in df: agg["total_podiums"] = ("resultsPodium", "sum") driver = df.groupby(["grandPrixYear", "resultsDriverName"]).agg(**agg).reset_index() @@ -112,34 +126,103 @@ def analytics(filters: Any, max_rows: int) -> dict[str, Any]: constructor = ( df.groupby(["grandPrixYear", "constructorName"]) .agg( - total_wins=("resultsFinalPositionNumber", lambda s: int((s == 1).sum())), + total_wins=("resultsFinalPositionNumber", lambda values: int((values == 1).sum())), average_final_position=("resultsFinalPositionNumber", "mean"), ).reset_index() ) if "resultsPodium" in df: - podium = df.groupby(["grandPrixYear", "constructorName"])["resultsPodium"].sum().reset_index(name="total_podiums") + podium = ( + df.groupby(["grandPrixYear", "constructorName"])["resultsPodium"] + .sum().reset_index(name="total_podiums") + ) constructor = constructor.merge(podium, on=["grandPrixYear", "constructorName"], how="left") payload["constructor_performance"] = records(constructor) - if {"DNF", "resultsReasonRetired"}.issubset(df.columns): + if {"constructorName", "resultsDriverName", "positionsGained", "resultsFinalPositionNumber"}.issubset(df.columns): + driver_vs_constructor = ( + df.groupby(["constructorName", "resultsDriverName"]) + .agg( + positionsGained=("positionsGained", "sum"), + average_final_position=("resultsFinalPositionNumber", "mean"), + ) + .reset_index() + .sort_values("average_final_position") + ) + driver_vs_constructor["average_final_position"] = driver_vs_constructor["average_final_position"].round(2) + payload["driver_vs_constructor"] = records(driver_vs_constructor) + + dnf_rows = pd.DataFrame() + if "DNF" in df: + dnf_rows = df[pd.to_numeric(df["DNF"], errors="coerce").fillna(0).eq(1)].copy() + if not dnf_rows.empty and "resultsReasonRetired" in dnf_rows: dnf = ( - df[pd.to_numeric(df["DNF"], errors="coerce").fillna(0).eq(1)] - .groupby("resultsReasonRetired").size().reset_index(name="count") + dnf_rows.groupby("resultsReasonRetired").size().reset_index(name="count") .sort_values("count", ascending=False) ) payload["dnf_reasons"] = records(dnf) - dnf_group_cols = {"DNF", "resultsDriverName", "driverTotalRaceEntries"} - if dnf_group_cols.issubset(df.columns): - dnf_by_driver = ( - df[pd.to_numeric(df["DNF"], errors="coerce").eq(1)] - .groupby(["resultsDriverName", "driverTotalRaceEntries"]) - .size().reset_index(name="dnf_count") + if not dnf_rows.empty and {"resultsDriverName", "driverTotalRaceEntries"}.issubset(dnf_rows.columns): + grouped = ( + dnf_rows.groupby(["resultsDriverName", "driverTotalRaceEntries"]).size() + .reset_index(name="dnf_count") ) - entries = pd.to_numeric(dnf_by_driver["driverTotalRaceEntries"], errors="coerce") - dnf_by_driver["dnf_pct"] = (dnf_by_driver["dnf_count"] / entries * 100).round(1) - payload["dnf_by_driver"] = records(dnf_by_driver.sort_values("dnf_pct", ascending=False)) - return payload + entries = pd.to_numeric(grouped["driverTotalRaceEntries"], errors="coerce") + grouped["dnf_pct"] = (grouped["dnf_count"] / entries * 100).round(1) + payload["dnf_by_driver"] = records(grouped.sort_values("dnf_pct", ascending=False)) + if "grandPrixName" in df: + entries = df.groupby("grandPrixName").size().reset_index(name="race_entry_count") + if not dnf_rows.empty: + dnfs = dnf_rows.groupby("grandPrixName").size().reset_index(name="dnf_count") + entries = entries.merge(dnfs, on="grandPrixName", how="left") + else: + entries["dnf_count"] = 0 + entries["dnf_count"] = entries["dnf_count"].fillna(0).astype(int) + entries["dnf_pct"] = (entries["dnf_count"] / entries["race_entry_count"] * 100).round(1) + payload["dnf_by_race"] = records(entries.sort_values("dnf_pct", ascending=False)) + if "constructorName" in df: + entries = df.groupby("constructorName").size().reset_index(name="constructor_entry_count") + if not dnf_rows.empty: + dnfs = dnf_rows.groupby("constructorName").size().reset_index(name="dnf_count") + entries = entries.merge(dnfs, on="constructorName", how="left") + else: + entries["dnf_count"] = 0 + entries["dnf_count"] = entries["dnf_count"].fillna(0).astype(int) + entries["dnf_pct"] = ( + entries["dnf_count"] / entries["constructor_entry_count"] * 100 + ).round(1) + payload["dnf_by_constructor"] = records(entries.sort_values("dnf_pct", ascending=False)) + + if {"grandPrixYear", "resultsDriverName", "positionsGained", "resultsPodium"}.issubset(df.columns): + year = int(pd.to_numeric(df["grandPrixYear"], errors="coerce").max()) + season = ( + df[pd.to_numeric(df["grandPrixYear"], errors="coerce") == year] + .groupby("resultsDriverName") + .agg(positions_gained=("positionsGained", "sum"), total_podiums=("resultsPodium", "sum")) + .reset_index() + ) + payload["season_year"] = year + payload["season_summary"] = records(season) + + if {"resultsDriverName", "resultsFinalPositionNumber"}.issubset(df.columns): + consistency = ( + df.groupby("resultsDriverName") + .agg(finishing_position_std=("resultsFinalPositionNumber", "std")) + .reset_index() + .sort_values("finishing_position_std") + ) + payload["driver_consistency"] = records(consistency) + + try: + manifest = model_manifest("XGBoost") + except (KeyError, OSError, ValueError): + manifest = None + payload["model_summary"] = manifest + try: + importance = precomputed("permutation") + except (KeyError, OSError, ValueError): + importance = None + payload["feature_importance"] = importance + return payload def current_season() -> dict[str, Any]: schedule = load_race_schedule().copy() From bd3954c0bacec9a152990cd77d4b7998d8ebc2e6 Mon Sep 17 00:00:00 2001 From: gmalbert Date: Tue, 29 Sep 2026 21:57:37 -0400 Subject: [PATCH 007/131] Render complete Streamlit Analytics section order --- .../frontend/src/pages/Analytics.jsx | 159 ++++++++++++------ 1 file changed, 105 insertions(+), 54 deletions(-) diff --git a/fastapi_react/frontend/src/pages/Analytics.jsx b/fastapi_react/frontend/src/pages/Analytics.jsx index 526a2a7f..1e36c7a0 100644 --- a/fastapi_react/frontend/src/pages/Analytics.jsx +++ b/fastapi_react/frontend/src/pages/Analytics.jsx @@ -1,7 +1,15 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from "react"; import { api } from "../api"; -import { Card, DataTable, Metric, Status } from "../components/UI"; import { BarPanel, LinePanel, RegressionPanel, ScatterPanel } from "../components/Charts"; +import { Card, DataTable, Metric, Status } from "../components/UI"; + +function firstObjectArray(value) { + if (!value || typeof value !== "object") return []; + for (const candidate of Object.values(value)) { + if (Array.isArray(candidate) && candidate.length && typeof candidate[0] === "object") return candidate; + } + return []; +} export default function Analytics() { const [data, setData] = useState(null); @@ -10,12 +18,12 @@ export default function Analytics() { const [tireData, setTireData] = useState(null); const [tireError, setTireError] = useState(null); const [tireLoading, setTireLoading] = useState(true); - const [filterState] = useState(() => { + const filterState = useMemo(() => { try { return JSON.parse(sessionStorage.getItem("f1analysis.filters") || "null"); } catch { return null; } - }); + }, []); const filterApplied = Boolean(filterState?.applied); - const analyticsFilters = filterState?.filters; + useEffect(() => { api.get("/api/analytics/tire-strategy") .then(setTireData).catch(setTireError).finally(() => setTireLoading(false)); @@ -23,9 +31,9 @@ export default function Analytics() { setLoading(false); return; } - api.post("/api/analytics", { filters: analyticsFilters || [], max_rows: 5000 }) + api.post("/api/analytics", { filters: filterState?.filters || [], max_rows: 10000 }) .then(setData).catch(setError).finally(() => setLoading(false)); - }, [analyticsFilters, filterApplied]); + }, [filterApplied, filterState]); async function selectTire(year, eventName) { setTireLoading(true); @@ -41,60 +49,103 @@ export default function Analytics() { setTireLoading(false); } } + + const manifestMetrics = data?.model_summary?.metrics || {}; + const importanceRows = firstObjectArray(data?.feature_importance); + return (
-

Analytics & Visualizations

Charts, regressions, correlations, driver trends and constructor trends.

- {filterApplied ? - {data && data.rows_considered === 0 ? ( -
No data for the selected years / drivers.
- ) : data && <> -
-
- - - - - - - - {data.regression_series?.map(regression => ( - +
+

Analytics & Visualizations

+

Comprehensive charts, regressions, and analysis of filtered data.

+
+ + {!filterApplied ? ( +
Please filter results in the Data Explorer tab first to view analytics.
+ ) : ( + + {data?.rows_considered === 0 ?
No data available after filtering. Please adjust your filters.
: data && <> + + + + + + + + + + {(data.regression_series || []).map(regression => ( +
+ +

Regression Equation: y = {Number(data.regressions?.find(item => item.x === regression.x)?.slope ?? 0).toFixed(2)}x + {Number(data.regressions?.find(item => item.x === regression.x)?.intercept ?? 0).toFixed(2)}

+

Regression Statistics:

+

R-squared: {Number(data.regressions?.find(item => item.x === regression.x)?.r_squared ?? 0).toFixed(2)}

+
))} -
- - {data.correlation && } - - - - - } -
:
Please filter results in the Data Explorer tab first to view analytics.
} - -
-

Tire Strategy Analysis

-

Compound usage, stint data, and tire degradation per driver/race (FastF1: 2018–present).

+ + +

Correlation values range from -1 to 1, where -1 indicates a perfect negative correlation, 0 indicates no correlation, and 1 indicates a perfect positive correlation.

+

Feature Correlations with Final Position and Podium

+ +
+ + + + + + + + + + + + + + + + + + +

(Lower is Better)

+

Lower standard deviation indicates more consistent finishing positions.

+ + + +
+ + + + +
+ {importanceRows.length > 0 && <> +

Feature Importance

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

🏎️ Tire Strategy Analysis

+

Compound usage, stint data, and tire degradation per driver/race (FastF1:2018–present).

- - + +
- {tireData && !tireData.race_rows?.length ?

No tire strategy data is available for the selected race.

: tireData && <> - + {tireData && !tireData.race_rows?.length ?

No tire strategy data available for this race.

: tireData && <> + -
+
Historical Tire Management by Driver (all races in selected year)
From bc89c199d28f6e460f64a776a0ba160d4f3b6ea3 Mon Sep 17 00:00:00 2001 From: gmalbert Date: Tue, 29 Sep 2026 21:57:49 -0400 Subject: [PATCH 008/131] Match Schedule page to Streamlit table and highlighting --- .../frontend/src/pages/CurrentSeason.jsx | 50 ++++++++++++++----- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/fastapi_react/frontend/src/pages/CurrentSeason.jsx b/fastapi_react/frontend/src/pages/CurrentSeason.jsx index 0dbd588f..a822740c 100644 --- a/fastapi_react/frontend/src/pages/CurrentSeason.jsx +++ b/fastapi_react/frontend/src/pages/CurrentSeason.jsx @@ -1,25 +1,49 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState } from "react"; import { api } from "../api"; -import { Card, Metric, Status } from "../components/UI"; +import { Status } from "../components/UI"; + +const COLUMNS = ["round", "fullName", "date", "time", "circuitType", "courseLength", "laps", "turns", "distance", "totalRacesHeld"]; +const LABELS = { + round: "Round", + fullName: "Grand Prix", + date: "Date", + time: "Time", + circuitType: "Circuit Type", + courseLength: "Course Length", + laps: "Laps", + turns: "Turns", + distance: "Distance", + totalRacesHeld: "Total Races Held", +}; export default function CurrentSeason() { const [data, setData] = useState(null); const [error, setError] = useState(null); useEffect(() => { api.get("/api/current-season").then(setData).catch(setError); }, []); + return (
-

{data?.year || "Current"} Season

Complete schedule and circuit information for the current Formula 1 season.

+
+

{data?.year || "Current"} Season

+

Complete schedule and information for the {data?.year || "current"} Formula 1 season.

+
- {data && data.rows?.length === 0 ? ( -
No race data for the current year.
- ) : data && <> -
- - {/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- keep wide schedules keyboard-scrollable */} -
{c}
{headerMap[c] || c}
{formatCell(row[c])}{checkboxColumns.includes(c) ? : formatCell(row[c])}
{data.columns.map(c => )} - {data.rows.map((row, i) => {data.columns.map(c => )})} -
{c}
{row[c] == null ? "" : String(row[c])}
- + {data && <> +

Total number of races: {data.rows?.length || 0}

+ {data.rows?.length ? ( +
+ + {COLUMNS.map(column => )} + + {data.rows.map((row, index) => ( + + {COLUMNS.map(column => )} + + ))} + +
{LABELS[column]}
{row[column] == null ? "" : String(row[column])}
+
+ ) :
No race data for the current year.
} } From fd063f08acb6ce4efdb66a2ca4f7feb9fd1baab2 Mon Sep 17 00:00:00 2001 From: gmalbert Date: Tue, 29 Sep 2026 21:58:39 -0400 Subject: [PATCH 009/131] Match Next Race section order and presentation to Streamlit --- fastapi_react/frontend/src/pages/NextRace.jsx | 142 +++++++++++++----- 1 file changed, 103 insertions(+), 39 deletions(-) diff --git a/fastapi_react/frontend/src/pages/NextRace.jsx b/fastapi_react/frontend/src/pages/NextRace.jsx index 0c01c90e..1e273f23 100644 --- a/fastapi_react/frontend/src/pages/NextRace.jsx +++ b/fastapi_react/frontend/src/pages/NextRace.jsx @@ -1,53 +1,117 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from "react"; import { api } from "../api"; -import { Card, DataTable, Metric, Status } from "../components/UI"; +import { Card, DataTable, Status } from "../components/UI"; -function Section({ title, rows }) { - return ; -} +const NEXT_RACE_COLUMNS = ["date", "time", "fullName", "courseLength", "turns", "laps"]; +const NEXT_RACE_LABELS = { + date: "Date", time: "Time", fullName: "Grand Prix", courseLength: "Course Length", + turns: "Turns", laps: "Laps", +}; + +const PIT_LABELS = { + year: "Year", round: "Round", constructorName: "Constructor", lap: "Lap", + pitStopSeconds: "Pit Stop (s)", pit_time_stationary: "Pit Time Stationary (s)", +}; export default function NextRace() { const [data, setData] = useState(null); const [error, setError] = useState(null); - const [modelKey, setModelKey] = useState("xgboost"); + const [show, setShow] = useState(true); useEffect(() => { api.get("/api/next-race").then(setData).catch(setError); }, []); - const modelKeys = useMemo(() => Object.keys(data?.predictions?.predictions_by_model || {}), [data]); - const modelBlock = data?.predictions?.predictions_by_model?.[modelKey] || data?.predictions?.predictions_by_model?.[modelKeys[0]]; + + const predictionRows = useMemo(() => { + const byModel = data?.predictions?.predictions_by_model || {}; + const block = byModel.xgboost || byModel[Object.keys(byModel)[0]]; + return (block?.predictions || []).map(row => ({ + Rank: row.predicted_rank, + resultsDriverName: row.driverName, + constructorName: row.constructor, + PredictedFinalPosition: row.predicted_position, + ModelMAE: row.mae ?? block?.model_mae, + })).sort((a, b) => Number(a.Rank) - Number(b.Rank)); + }, [data]); return (
-

Next Race

Upcoming race details, predictions, historical performance, flags, pit-stop context and weather.

+
+

Next Race

+

Details, predictions, and analysis for the upcoming race.

+
+ + + - {data && !data.next_race &&
No upcoming race found.
} - {data?.next_race && <> -
- - - - -
- - {data.predictions?.format === "json" && -
- -
- {modelBlock?.model_mae != null &&

Model MAE: {Number(modelBlock.model_mae).toFixed(3)}

} - -
} - {data.predictions?.format === "csv" && } - {!data.predictions &&
No precomputed prediction artifact matched the upcoming race.
} -
-
-
-
- - - - {data.fastest_pit_stops?.total ? :
No individual pit stop data available for prior races at this Grand Prix.
} -
-
+ {data && !data.next_race ?
No upcoming race found in the schedule.
: data?.next_race && <> + {show && <> +

Next Race:

+ + } + +

Past Results:

+

Total number of results: {data.past_results?.length || 0}

+ + +

Predictive Results for Active Drivers

+ {predictionRows.length ? ( + + ) :
No precomputed active-driver prediction artifact is available for this race.
} + +

Predictive DNF

+ {data.legacy_predictions?.length ? ( + + ) :
No committed DNF prediction rows are available for the upcoming race.
} + +

Predicted Safety Car

+ {data.safety_car_predictions?.length ? ( + + ) : ( +
No committed safety-car prediction artifact is available for the upcoming race.
+ )} + +

Flags and Safety Cars from {data.race_name}:

+

Race messages, including flags, are only available going back to 2018.

+

Total number of results: {data.race_messages?.length || 0}

+ + +

Driver Performance in {data.race_name}:

+

Total number of results: {data.driver_performance?.length || 0}

+ + +

Constructor Performance in {data.race_name}:

+ + +

Fastest Individual Pit Stop per Constructor

+

Total number of fastest pit stops: {data.fastest_pit_stops?.total ?? 0}

+

Pit Time Constant: {data.fastest_pit_stops?.pit_lane_time_constant ?? "N/A"}

+ {data.fastest_pit_stops?.rows?.length ? ( + + ) :
No individual pit stop data available for prior races at this Grand Prix.
} + +

Weather Data for {data.race_name}:

+

Total number of weather records: {data.weather?.length || 0}

+ }
From 0b385500fad81b268323709d2839fed5fae4a69f Mon Sep 17 00:00:00 2001 From: gmalbert Date: Tue, 29 Sep 2026 21:59:01 -0400 Subject: [PATCH 010/131] Add Streamlit-style next-race artifact fallbacks --- .../backend/app/services/analysis.py | 53 ++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/fastapi_react/backend/app/services/analysis.py b/fastapi_react/backend/app/services/analysis.py index a53355a6..211b0eda 100644 --- a/fastapi_react/backend/app/services/analysis.py +++ b/fastapi_react/backend/app/services/analysis.py @@ -471,6 +471,49 @@ def find_prediction_artifact( } +def _legacy_prediction_rows(race_id: str, year: int | str, race_name: str) -> list[dict[str, Any]]: + """Return the committed Streamlit-style CSV prediction rows when an exact race artifact exists.""" + slugs = { + str(race_id).strip().lower().replace("_", "-").replace(" ", "-"), + str(race_name).lower().replace(" grand prix", "").replace(" ", "-"), + } + candidates: list[Path] = [] + for slug in sorted(slugs): + if slug: + candidates.extend([ + DATA_DIR / f"predictions_{slug}_{year}.csv", + DATA_DIR / f"predictions_{slug.replace('-', '_')}_{year}.csv", + ]) + for candidate in candidates: + if candidate.is_file(): + frame = _read_optional(candidate) + if not frame.empty: + sort_col = "Rank" if "Rank" in frame else ( + "PredictedFinalPosition" if "PredictedFinalPosition" in frame else None + ) + if sort_col: + frame = frame.sort_values(sort_col) + return records(frame) + return [] + + +def _historical_safety_car_rows(race_id: str, year: int | str) -> list[dict[str, Any]]: + """Return historical safety-car rows for the current Grand Prix. + + The Streamlit app computes the next-race probability from a loaded model. + The React service remains artifact-first and therefore only exposes a + prediction when a committed artifact exists; historical rows are still + returned here for visual/context parity. + """ + messages = _read_optional(DATA_DIR / "race_control_messages_grouped_with_dnf.csv") + if messages.empty or "grandPrixId" not in messages: + return [] + rows = messages[messages["grandPrixId"].astype(str) == str(race_id)].copy() + if "Year" in rows: + rows = rows[pd.to_numeric(rows["Year"], errors="coerce") != pd.to_numeric(year, errors="coerce")] + rows = rows.sort_values("Year", ascending=False) + return records(rows.head(100)) + def next_race_bundle() -> dict[str, Any]: schedule = load_race_schedule().copy() date_col = "date" if "date" in schedule else ("short_date" if "short_date" in schedule else None) @@ -534,17 +577,25 @@ def next_race_bundle() -> dict[str, Any]: messages = messages[messages["grandPrixId"].astype(str) == str(race_id)] predictions = find_prediction_artifact(str(race_id), str(year), str(race_name), row[date_col]) + legacy_predictions = _legacy_prediction_rows(str(race_id), int(year), str(race_name)) + safety_car_predictions = _historical_safety_car_rows(str(race_id), int(year)) pit_stops = fastest_pit_stops(str(race_id)) return { "next_race": records(next_frame)[0], "race_id": None if race_id is None else str(race_id), "race_name": str(race_name), "year": int(year) if pd.notna(year) else None, - "past_results": records(past.drop_duplicates().head(1000)), + "past_results": records( + past.drop_duplicates( + subset=[column for column in ("resultsDriverName", "grandPrixYear") if column in past.columns] + ).head(1000) + ), "driver_performance": records(driver_perf), "constructor_performance": records(constructor_perf), "weather": records(weather.head(500)), "race_messages": records(messages.head(500)), "fastest_pit_stops": pit_stops, "predictions": predictions, + "legacy_predictions": legacy_predictions, + "safety_car_predictions": safety_car_predictions, } From 7459e8da7bf58bba7e4451e8bc5c692fc83ea855 Mon Sep 17 00:00:00 2001 From: gmalbert Date: Tue, 29 Sep 2026 21:59:39 -0400 Subject: [PATCH 011/131] Align Predictive Models controls and tabs with Streamlit --- fastapi_react/frontend/src/pages/Models.jsx | 191 +++++++++++++------- 1 file changed, 123 insertions(+), 68 deletions(-) diff --git a/fastapi_react/frontend/src/pages/Models.jsx b/fastapi_react/frontend/src/pages/Models.jsx index 1ab57799..0279b01e 100644 --- a/fastapi_react/frontend/src/pages/Models.jsx +++ b/fastapi_react/frontend/src/pages/Models.jsx @@ -1,28 +1,41 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState } from "react"; import { api } from "../api"; -import { Card, DataTable, JsonBlock, Status, Tabs } from "../components/UI"; +import { Card, DataTable, JsonBlock, Metric, Status, Tabs } from "../components/UI"; const advancedTabs = [ - "Performance", "Feature Importance", "Feature Selection", "Position Analysis", - "Hyperparameters", "Historical Validation", "Debug" + "📊 Model Performance", + "🔍 Feature Analysis", + "🎯 Feature Selection", + "🏎️ Position-Specific Analysis", + "⚙️ Hyperparameters", + "📈 Historical Validation", + "🛠️ Debug & Experiments", ]; const artifactByTab = { - "Feature Importance": ["shap", "permutation"], - "Feature Selection": ["monte_carlo", "monte_carlo_log", "rfe", "boruta"], - "Position Analysis": ["position_mae"], - "Hyperparameters": ["hyperparam_bayesian", "hyperparam_grid"], - "Historical Validation": ["historical_validation"], + "🔍 Feature Analysis": ["shap", "permutation"], + "🎯 Feature Selection": ["monte_carlo", "monte_carlo_log", "rfe", "boruta"], + "🏎️ Position-Specific Analysis": ["position_mae"], + "⚙️ Hyperparameters": ["hyperparam_bayesian", "hyperparam_grid"], + "📈 Historical Validation": ["historical_validation"], }; +function firstObjectArray(payload) { + if (!payload || typeof payload !== "object") return null; + for (const [name, value] of Object.entries(payload)) { + if (Array.isArray(value) && value.length && typeof value[0] === "object") return [name, value]; + } + return null; +} + function Artifact({ name, data }) { const payload = data?.data; - if (payload == null) return
No precomputed artifact found.
; - const firstArray = Object.entries(payload).find(([, value]) => Array.isArray(value) && value.length && typeof value[0] === "object"); + if (payload == null) return
No precomputed artifact found for {name.replaceAll("_", " ")}.
; + const table = firstObjectArray(payload); return ( - {payload.metadata && } - {firstArray ? : } + {payload.metadata &&

Precomputed artifact metadata is shown below.

} + {table ? : }
); } @@ -30,7 +43,7 @@ function Artifact({ name, data }) { export default function Models() { const [models, setModels] = useState([]); const [selectedModel, setSelectedModel] = useState("XGBoost"); - const [tab, setTab] = useState("Performance"); + const [tab, setTab] = useState(advancedTabs[0]); const [artifacts, setArtifacts] = useState({}); const [health, setHealth] = useState(null); const [manifest, setManifest] = useState(null); @@ -39,90 +52,132 @@ export default function Models() { useEffect(() => { Promise.all([api.get("/api/models"), api.get("/api/health")]) - .then(([m, h]) => { setModels(m.models); setHealth(h); }) + .then(([modelResponse, healthResponse]) => { + setModels(modelResponse.models || []); + setHealth(healthResponse); + }) .catch(setError); }, []); useEffect(() => { api.get(`/api/models/manifest?model_type=${encodeURIComponent(selectedModel)}`) - .then(r => setManifest(r.manifest)).catch(() => setManifest(null)); + .then(response => setManifest(response.manifest)) + .catch(() => setManifest(null)); }, [selectedModel]); useEffect(() => { const names = artifactByTab[tab] || []; Promise.all(names.map(name => api.get(`/api/models/precomputed/${name}`).then(data => [name, data]))) - .then(entries => setArtifacts(Object.fromEntries(entries))).catch(setError); + .then(entries => setArtifacts(Object.fromEntries(entries))) + .catch(setError); }, [tab]); async function runTool(name) { setToolOutput({ running: true, name }); try { - const out = await api.post("/api/tools/run", { tool: name, args: [] }); - setToolOutput({ name, ...out }); - } catch (e) { setToolOutput({ name, error: e.message }); } + setToolOutput({ name, ...(await api.post("/api/tools/run", { tool: name, args: [] })) }); + } catch (err) { + setToolOutput({ name, error: err.message }); + } } + const metrics = manifest?.metrics || {}; + const featureNames = manifest?.feature_names || []; + return (
-

Predictive Models & Advanced Options

Pretrained model selection, performance diagnostics, feature selection, HPO and historical validation.

+

Predictive Models & Advanced Options

+

Advanced machine learning models, hyperparameter tuning, and feature selection tools.

+ + + + +
+ ℹ️ Model Information & Recommendations +

Model Descriptions & Use Cases

+

🏆 XGBoost (Recommended Default)

+
  • Excellent performance, handles missing data, and provides built-in feature importance.
  • Best for general-purpose predictions and reliable interpretability.
  • Training speed: Fast · Memory usage: Moderate.
+

🚀 LightGBM

+
  • Very fast training and efficient memory use.
  • Best when training speed is critical or datasets are large.
+

🐱 CatBoost

+
  • Strong categorical-data handling and robustness to overfitting.
  • Training speed: Moderate · Memory usage: Moderate.
+

🎯 Ensemble (XGBoost + LightGBM + CatBoost)

+
  • Combines all three base models for maximum prediction accuracy.
  • Training speed: Slowest · Memory usage: High.
+

🏎️ Position Group

+
  • Separate sub-models for podium, points, and outside-points segments.
+

🗺️ Track-Weighted Ensemble

+
  • Circuit-type-specific blend weights across XGBoost, LightGBM, and CatBoost.
+
+ +

Models are pre-trained by GitHub Actions; training controls are kept out of the live app to protect responsiveness.

+ {!health?.expensive_tools_enabled && ( +
Research controls are disabled in hosted mode. Enable F1_RESEARCH_MODE=1 only for a trusted local/admin session; precomputed analyses remain available below.
+ )} + - - - {models.length === 0 ? ( -
No trained model for the selected type. See the precompute docs for how to generate one.
- ) : ( - - )} -

- Selected: {selectedModel}. The React migration keeps production inference artifact-first; it does not train models on page load. -

-
- - - - {tab === "Performance" && ( - -
-
MAE{manifest?.metrics?.mae?.toFixed?.(3) ?? "—"}
-
MSE{manifest?.metrics?.mse?.toFixed?.(3) ?? "—"}
-
R²{manifest?.metrics?.r2?.toFixed?.(3) ?? "—"}
-
Features{manifest?.feature_names?.length ?? "—"}
-
- -
- )} + {!manifest ?
No trained model artifact is available for {selectedModel}.
: ( +
+ 🔧 Advanced Options + - {tab === "Feature Importance" && manifest?.feature_names?.length > 0 && ( - -

Ordered feature contract recorded in the model manifest.

- ({ rank: i + 1, feature }))} /> -
- )} + {tab === "📊 Model Performance" && <> +

Predictive Data Model Metrics

+
+ + + + +
+ {manifest.best_iteration != null &&

Boosting rounds used: {Number(manifest.best_iteration) + 1}

} + {manifest.position_mae && ({ "Position Group": group, MAE: mae }))} />} +

Predictive Results with Features

+

The deployed React site consumes the same workflow-generated model contract and artifacts as Streamlit.

+ } + + {tab === "🔍 Feature Analysis" && <> +

Feature Importances

+ {featureNames.length > 0 && ({ Feature: feature, Rank: index + 1 })).slice(0, 100)} maxHeight={650} />} + {(artifactByTab[tab] || []).map(name => )} + } + + {tab === "🎯 Feature Selection" && <> +

Feature Selection Tools

+ {(artifactByTab[tab] || []).map(name => )} + } - {tab === "Debug" && ( - <> - - - - -

These controls mirror the Streamlit research tools but are disabled by default. Set ENABLE_EXPENSIVE_TOOLS=1 only on a test host.

+ {tab === "🏎️ Position-Specific Analysis" && <> +

Position Group Analysis

+ {(artifactByTab[tab] || []).map(name => )} + } + + {tab === "⚙️ Hyperparameters" && <> +

Hyperparameter Tuning

+ {manifest.hyperparameters && } + {(artifactByTab[tab] || []).map(name => )} + } + + {tab === "📈 Historical Validation" && <> +

Historical Validation

+ {(artifactByTab[tab] || []).map(name => )} + } + + {tab === "🛠️ Debug & Experiments" && <> +

Debug & Experiments

+

Compare Different Bin Counts (q)

+

Manual experiments are disabled by default on the hosted site, matching the Streamlit research-mode gate.

{["monte_carlo", "rfe", "boruta", "shap", "permutation"].map(name => ( - + ))}
{toolOutput && } -
- + } +
)} - - {(artifactByTab[tab] || []).map(name => )}
); From d7d667194ba695ac3113ce175593444c62b01d62 Mon Sep 17 00:00:00 2001 From: gmalbert Date: Tue, 29 Sep 2026 22:00:02 -0400 Subject: [PATCH 012/131] Match Data and Debug tab structure to Streamlit --- fastapi_react/frontend/src/pages/RawData.jsx | 161 +++++++------------ 1 file changed, 61 insertions(+), 100 deletions(-) diff --git a/fastapi_react/frontend/src/pages/RawData.jsx b/fastapi_react/frontend/src/pages/RawData.jsx index 9c7ca3c6..905b7423 100644 --- a/fastapi_react/frontend/src/pages/RawData.jsx +++ b/fastapi_react/frontend/src/pages/RawData.jsx @@ -1,129 +1,90 @@ -import { useEffect, useMemo, useState } from 'react' -import { api, downloadUrl } from "../api"; -import { Card, DataTable, JsonBlock, Status, Tabs } from "../components/UI"; +import { useEffect, useState } from "react"; +import { api } from "../api"; +import { DataTable, JsonBlock, Status, Tabs } from "../components/UI"; -const RAW_TABS = ["Raw Tables", "Temporal Leakage Audit", "Hyperparameter Tuning"]; +const RAW_TABS = ["Raw Data", "Temporal Leakage Audit", "Hyperparameter Tuning"]; export default function RawData() { - const [tab, setTab] = useState(RAW_TABS[0]); - const [files, setFiles] = useState([]); - const [query, setQuery] = useState(""); - const [selected, setSelected] = useState(null); - const [preview, setPreview] = useState(null); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(true); + const [tab, setTab] = useState("Raw Data"); const [health, setHealth] = useState(null); - const [toolResult, setToolResult] = useState(null); - const [toolBusy, setToolBusy] = useState(false); const [showDataset, setShowDataset] = useState(false); const [dataset, setDataset] = useState(null); const [datasetPage, setDatasetPage] = useState(0); - const datasetPageSize = 50; + const [toolResult, setToolResult] = useState(null); + const [toolBusy, setToolBusy] = useState(false); + const [error, setError] = useState(null); + const pageSize = 1000; - useEffect(() => { - api.get("/api/raw/files").then(r => { setFiles(r.files); setLoading(false); }).catch(e => { setError(e); setLoading(false); }); - api.get("/api/health").then(setHealth).catch(() => {}); - }, []); + useEffect(() => { api.get("/api/health").then(setHealth).catch(() => {}); }, []); useEffect(() => { if (!showDataset) return; - let cancelled = false; - setLoading(true); + setError(null); api.post("/api/data-explorer/query", { - limit: datasetPageSize, - offset: datasetPage * datasetPageSize, - }).then(result => { - if (!cancelled) setDataset(result); - }).catch(setError).finally(() => { - if (!cancelled) setLoading(false); - }); - return () => { cancelled = true; }; + filters: [], offset: datasetPage * pageSize, limit: pageSize, + }).then(setDataset).catch(setError); }, [showDataset, datasetPage]); - const shown = useMemo( - () => files.filter(f => f.path.toLowerCase().includes(query.toLowerCase())).slice(0, 500), - [files, query] - ); - - async function open(path) { - setSelected(path); setPreview(null); setError(null); - try { setPreview(await api.get(`/api/raw/preview?path=${encodeURIComponent(path)}`)); } - catch (e) { setError(e); } - } - - async function runTool(tool, args = []) { - setToolBusy(true); setToolResult(null); setError(null); - try { setToolResult(await api.post("/api/tools/run", { tool, args })); } - catch (e) { setError(e); } + async function runTool(tool) { + setToolBusy(true); + setToolResult(null); + setError(null); + try { setToolResult(await api.post("/api/tools/run", { tool, args: [] })); } + catch (err) { setError(err); } finally { setToolBusy(false); } } - const enabled = !!health?.expensive_tools_enabled; + const enabled = Boolean(health?.expensive_tools_enabled); return (
-

Data & Debug Tools

Raw datasets plus the diagnostic and tuning utilities exposed by the Streamlit application.

+
+

Data & Debug Tools

+
- {tab === "Raw Tables" &&
- - - {showDataset && <> - - {dataset && <> -

Rows {dataset.total ? datasetPage * datasetPageSize + 1 : 0}–{Math.min((datasetPage + 1) * datasetPageSize, dataset.total)} of {dataset.total.toLocaleString()}

-
- - -
- - } -
+ {tab === "Raw Data" && <> +

View the complete unfiltered dataset.

+ + {showDataset && + {dataset && <> +

Total number of results: {dataset.total.toLocaleString()}

+ + {dataset.total > pageSize &&
+ + +
} } - setQuery(e.target.value)} /> -
- {shown.length === 0 ? ( -
No files in data_files/
- ) : shown.map(f => ( - - ))} -
-
- - - {!selected &&
Choose a file to preview.
} - {preview?.kind === "table" && } - {preview?.kind === "json" && } - {preview?.kind === "text" &&
{preview.data}
} - {preview?.kind === "binary" &&
Binary preview unavailable.
} - {selected &&

Download original

} -
-
-
} + } + } - {tab === "Temporal Leakage Audit" && -

Runs the repository's heuristics-based temporal leakage audit. This is intentionally disabled by default on a public host because it can read the full analysis dataset.

- {!enabled &&
Manual analysis tools are disabled. Set ENABLE_EXPENSIVE_TOOLS=1 only on a test deployment.
} - - {error &&
{String(error.message || error)}
} - {toolResult && } -
} + {tab === "Temporal Leakage Audit" && <> +

Run heuristics-based checks for features that may leak future information into models.

+
+ 🔍 Run Temporal Leakage Audit (Admin) +

This audit scans the analysis dataset for features that may leak future or post-event information into training.

+
+ About this Leakage Audit +

It applies name-pattern checks, very high target-correlation checks, per-driver lagged-correlation checks, and safety-car candidate checks.

+

Recommendation: review flagged features and remove or re-engineer any that use post-race or future information before training models.

+
+ {!enabled &&
Research controls are disabled in hosted mode.
} + + {toolResult && } +
+ {error && } + } - {tab === "Hyperparameter Tuning" && -

The live site should normally consume precomputed HPO artifacts. These controls provide test-only parity with the manual tuning area without enabling them on production by default.

- {!enabled &&
Manual tuning is disabled. Set ENABLE_EXPENSIVE_TOOLS=1 on a test host to enable it.
} -
- - -
- {error &&
{String(error.message || error)}
} + {tab === "Hyperparameter Tuning" && <> +

Run basic hyperparameter tuning (GridSearch) on the full dataset.

+ {!enabled &&
Research controls are disabled in hosted mode.
} + {toolResult && } -
} + {error && } + }
); } From 6d103ab2d8e294bf62eae21404a1954795b4639b Mon Sep 17 00:00:00 2001 From: gmalbert Date: Tue, 29 Sep 2026 22:01:04 -0400 Subject: [PATCH 013/131] Fix Streamlit analytics visual capture --- fastapi_react/parity_evidence/capture_streamlit.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/fastapi_react/parity_evidence/capture_streamlit.mjs b/fastapi_react/parity_evidence/capture_streamlit.mjs index 43b76fbc..f28d33e6 100644 --- a/fastapi_react/parity_evidence/capture_streamlit.mjs +++ b/fastapi_react/parity_evidence/capture_streamlit.mjs @@ -59,6 +59,8 @@ async function run() { const filterToggle = page.getByRole('checkbox', { name: 'Filter Results' }); if (!(await filterToggle.isChecked())) await filterToggle.check({ force: true }); await page.waitForTimeout(WAIT_MS); + await page.getByRole('tab', { name: /Analytics & Visualizations/ }).first().click(); + await page.waitForTimeout(WAIT_MS); } else if (section.label) { await page.getByRole('tab', { name: section.label }).first().click(); await page.waitForTimeout(WAIT_MS); From 43ffe372824ac596d7aa523e52a880c3f5b9b7f5 Mon Sep 17 00:00:00 2001 From: gmalbert Date: Tue, 29 Sep 2026 22:01:24 -0400 Subject: [PATCH 014/131] Run deployed Streamlit visual parity capture in CI --- .github/workflows/fastapi-react.yml | 66 +++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/.github/workflows/fastapi-react.yml b/.github/workflows/fastapi-react.yml index 46ee8fe7..c5a7ebda 100644 --- a/.github/workflows/fastapi-react.yml +++ b/.github/workflows/fastapi-react.yml @@ -70,3 +70,69 @@ jobs: - name: Production-deps audit run: npm run audit continue-on-error: true # dev-dep advisories only; see PARITY_REPORT.md + + + visual-parity: + name: Visual parity evidence + runs-on: ubuntu-latest + needs: [backend, frontend] + timeout-minutes: 30 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: fastapi_react/backend/requirements.txt + - uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0 + with: + node-version: "20" + cache: npm + cache-dependency-path: fastapi_react/frontend/package-lock.json + - name: Install backend + working-directory: fastapi_react/backend + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Install frontend and browser + working-directory: fastapi_react/frontend + run: | + npm ci + npx playwright install --with-deps chromium + - name: Start parity stack + env: + F1_REPO_ROOT: ${{ github.workspace }} + ENABLE_EXPENSIVE_TOOLS: "0" + run: | + (cd fastapi_react/backend && uvicorn app.main:app --host 127.0.0.1 --port 8000 > /tmp/f1-backend.log 2>&1 & echo $! > /tmp/f1-backend.pid) + (cd fastapi_react/frontend && npm run dev -- --host 127.0.0.1 --port 5173 > /tmp/f1-frontend.log 2>&1 & echo $! > /tmp/f1-frontend.pid) + for i in {1..60}; do + if curl -fsS http://127.0.0.1:8000/api/health >/dev/null && curl -fsS http://127.0.0.1:5173 >/dev/null; then + exit 0 + fi + sleep 2 + done + cat /tmp/f1-backend.log || true + cat /tmp/f1-frontend.log || true + exit 1 + - name: Capture React reference + working-directory: fastapi_react/frontend + env: + REACT_BASE_URL: http://127.0.0.1:5173 + REACT_WAIT_MS: "4000" + run: npm run capture:react + - name: Capture deployed Streamlit reference + working-directory: fastapi_react/frontend + env: + STREAMLIT_BASE_URL: https://f1analysis-app.streamlit.app + STREAMLIT_WAIT_MS: "8000" + run: npm run capture:streamlit + - name: Diff screenshots + working-directory: fastapi_react/frontend + run: npm run capture:diff + - name: Upload visual parity evidence + uses: actions/upload-artifact@v4 + with: + name: fastapi-react-parity-evidence + path: fastapi_react/parity_evidence/visual + if-no-files-found: error From 4991cac0e227e09bf61bd86d75dae7c9a3c1f288 Mon Sep 17 00:00:00 2001 From: gmalbert Date: Tue, 29 Sep 2026 22:02:26 -0400 Subject: [PATCH 015/131] Update parity tests for Streamlit-matched UI --- fastapi_react/frontend/src/App.test.jsx | 32 ++++++++++++------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/fastapi_react/frontend/src/App.test.jsx b/fastapi_react/frontend/src/App.test.jsx index 2a8586f3..dbfc86af 100644 --- a/fastapi_react/frontend/src/App.test.jsx +++ b/fastapi_react/frontend/src/App.test.jsx @@ -3,7 +3,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import App from "./App"; vi.mock("./api", () => ({ - api: { get: vi.fn().mockResolvedValue({ status: "ok", rss_mb: 120 }) }, + api: { get: vi.fn().mockResolvedValue({ + race_start_year: 2016, + current_year: 2026, + last_updated: "2026-09-29 09:00 PM", + code_deployed_at: "2026-09-30 01:00:00 UTC", + }) }, })); vi.mock("./pages/DataExplorer", () => ({ default: () =>

Explorer page

})); vi.mock("./pages/Analytics", () => ({ default: () =>

Analytics page

})); @@ -20,26 +25,21 @@ describe("application shell", () => { document.title = ""; }); - it("renders the reference brand, section navigation, and API status", async () => { + it("renders the Streamlit reference brand, title, captions, and seven tabs", async () => { render(); expect(screen.getByRole("img", { name: "Gridlocked" })).toHaveAttribute("src", "/api/brand/logo"); - expect(screen.getByText(/F1 Races from 2016 to/)).toBeInTheDocument(); - expect(screen.getByRole("navigation", { name: "Sections" })).toBeInTheDocument(); - expect(await screen.findByText("API connected")).toBeInTheDocument(); + expect(await screen.findByText("F1 Races from 2016 to 2026")).toBeInTheDocument(); + expect(screen.getByRole("navigation", { name: "Main sections" })).toBeInTheDocument(); + expect(screen.getAllByRole("tab")).toHaveLength(7); + expect(screen.getByRole("tab", { name: "📊 Data Explorer" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "💾 Data & Debug" })).toBeInTheDocument(); }); - it("switches sections from the horizontal navigation and updates the title", async () => { + it("switches sections from the Streamlit-style tab row and updates the title", async () => { render(); - fireEvent.click(screen.getByRole("button", { name: "Analytics" })); + fireEvent.click(screen.getByRole("tab", { name: "📈 Analytics & Visualizations" })); expect(await screen.findByRole("heading", { name: "Analytics page" })).toBeInTheDocument(); - await waitFor(() => expect(document.title).toBe("Analytics — F1 Analysis")); + await waitFor(() => expect(document.title).toContain("Analytics & Visualizations")); expect(window.location.hash).toBe("#/Analytics"); }); - - it("persists the light theme toggle", async () => { - render(); - fireEvent.click(screen.getByRole("checkbox", { name: "Use light theme" })); - await waitFor(() => expect(document.documentElement).toHaveAttribute("data-theme", "light")); - expect(localStorage.getItem("f1analysis.theme")).toBe("light"); - }); -}); \ No newline at end of file +}); From 524c08ad83ead32c74534cac55a3e9e9d851474c Mon Sep 17 00:00:00 2001 From: gmalbert Date: Tue, 29 Sep 2026 22:02:28 -0400 Subject: [PATCH 016/131] Update parity tests for Streamlit-matched UI --- .../frontend/src/pages/DataExplorer.test.jsx | 85 ++++++------------- 1 file changed, 28 insertions(+), 57 deletions(-) diff --git a/fastapi_react/frontend/src/pages/DataExplorer.test.jsx b/fastapi_react/frontend/src/pages/DataExplorer.test.jsx index a08efd31..b7a46075 100644 --- a/fastapi_react/frontend/src/pages/DataExplorer.test.jsx +++ b/fastapi_react/frontend/src/pages/DataExplorer.test.jsx @@ -1,13 +1,10 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; const apiMock = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn() })); -vi.mock('../api.js', () => ({ - api: apiMock, - downloadUrl: (p) => `/api/raw/download?path=${encodeURIComponent(p)}`, -})); +vi.mock("../api.js", () => ({ api: apiMock })); -import DataExplorer from './DataExplorer.jsx'; +import DataExplorer from "./DataExplorer.jsx"; beforeEach(() => { apiMock.get.mockReset(); @@ -15,69 +12,43 @@ beforeEach(() => { sessionStorage.clear(); }); -describe('DataExplorer page', () => { - it('matches the reference unchecked filter state until users opt in', async () => { - apiMock.get.mockImplementation((url) => { - if (url === '/api/data-explorer/schema') { - return Promise.resolve({ - filters: [ - { column: 'grandPrixYear', kind: 'range', min: 2015, max: 2025 }, - { column: 'driverDNFCount', kind: 'range', min: 0, max: 50 }, - ], - }); - } - return Promise.resolve({}); - }); - apiMock.post.mockResolvedValue({ - total: 100, - columns: ['grandPrixYear', 'driverDNFCount'], - rows: [], - }); +describe("DataExplorer page", () => { + it("matches the reference unchecked filter state until users opt in", async () => { + apiMock.get.mockResolvedValueOnce({ filters: [{ column: "grandPrixYear", label: "Year", kind: "range", min: 2016, max: 2026 }] }); render(); - expect(screen.getByText(/Data Explorer/i)).toBeInTheDocument(); - expect(screen.getByRole('checkbox', { name: 'Filter Results' })).toBeInTheDocument(); - await waitFor(() => { - expect(apiMock.get).toHaveBeenCalledWith('/api/data-explorer/schema'); - }); + expect(screen.getByText("Data Explorer")).toBeInTheDocument(); + expect(screen.getByRole("checkbox", { name: "Filter Results" })).toBeInTheDocument(); + await waitFor(() => expect(apiMock.get).toHaveBeenCalledWith("/api/data-explorer/schema")); expect(apiMock.post).not.toHaveBeenCalled(); - expect(screen.queryByText('Find one of the dataset fields…')).not.toBeInTheDocument(); }); - it('shows error state when schema fetch fails', async () => { - apiMock.get.mockRejectedValueOnce(new Error('schema down')); + it("enabling Filter Results renders all schema controls and loads the unfiltered table", async () => { + apiMock.get.mockResolvedValueOnce({ filters: [ + { column: "grandPrixYear", label: "Year", kind: "range", min: 2016, max: 2026 }, + { column: "DNF", label: "DNF", kind: "boolean" }, + ] }); + apiMock.post.mockResolvedValue({ total: 12, columns: ["grandPrixYear"], rows: [{ grandPrixYear: 2026 }] }); render(); - await waitFor(() => { - expect(screen.getByText('schema down')).toBeInTheDocument(); - }); + fireEvent.click(await screen.findByRole("checkbox", { name: "Filter Results" })); + expect(await screen.findByText("Select filters to apply:")).toBeInTheDocument(); + expect(screen.getByRole("spinbutton", { name: "Year minimum" })).toBeInTheDocument(); + await waitFor(() => expect(apiMock.post).toHaveBeenCalled()); + expect(await screen.findByText("Number of filtered results: 12")).toBeInTheDocument(); }); - it('applies and shares selected filters with other sections', async () => { + it("updates shared filter state when a Streamlit-style control changes", async () => { apiMock.get.mockResolvedValueOnce({ filters: [ - { column: 'grandPrixYear', label: 'Year', kind: 'range', min: 2015, max: 2025 }, + { column: "grandPrixYear", label: "Year", kind: "range", min: 2016, max: 2026 }, ] }); - apiMock.post.mockResolvedValue({ total: 10, columns: ['grandPrixYear'], rows: [] }); + apiMock.post.mockResolvedValue({ total: 10, columns: ["grandPrixYear"], rows: [] }); render(); - fireEvent.click(await screen.findByRole('checkbox', { name: 'Filter Results' })); - const minimum = await screen.findByRole('spinbutton', { name: 'Year minimum' }); - fireEvent.change(minimum, { target: { value: '2020' } }); - fireEvent.click(screen.getByRole('button', { name: 'Apply filters' })); + fireEvent.click(await screen.findByRole("checkbox", { name: "Filter Results" })); + fireEvent.change(await screen.findByRole("spinbutton", { name: "Year minimum" }), { target: { value: "2020" } }); await waitFor(() => { - expect(JSON.parse(sessionStorage.getItem('f1analysis.filters'))).toMatchObject({ + expect(JSON.parse(sessionStorage.getItem("f1analysis.filters"))).toMatchObject({ applied: true, - filters: [{ column: 'grandPrixYear', kind: 'range', value: [2020, 2025] }], + filters: [{ column: "grandPrixYear", kind: "range", value: [2020, 2026] }], }); }); - expect(apiMock.post).toHaveBeenLastCalledWith(expect.any(String), expect.objectContaining({ - filters: [{ column: 'grandPrixYear', kind: 'range', value: [2020, 2025] }], - })); - }); - - it('loads the unfiltered result set when Filter Results is enabled', async () => { - apiMock.get.mockResolvedValueOnce({ filters: [{ column: 'grandPrixYear', label: 'Year', kind: 'range', min: 2015, max: 2025 }] }); - apiMock.post.mockResolvedValue({ total: 12, columns: ['grandPrixYear'], rows: [{ grandPrixYear: 2025 }] }); - render(); - fireEvent.click(await screen.findByRole('checkbox', { name: 'Filter Results' })); - await waitFor(() => expect(apiMock.post).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ filters: [] }))); - expect(await screen.findByText('2025')).toBeInTheDocument(); }); }); From f7c83fa47d1c5fff39f6b5019be52d6bc72d9a3d Mon Sep 17 00:00:00 2001 From: gmalbert Date: Tue, 29 Sep 2026 22:02:31 -0400 Subject: [PATCH 017/131] Update parity tests for Streamlit-matched UI --- .../frontend/src/pages/Analytics.test.jsx | 47 +++++++------------ 1 file changed, 17 insertions(+), 30 deletions(-) diff --git a/fastapi_react/frontend/src/pages/Analytics.test.jsx b/fastapi_react/frontend/src/pages/Analytics.test.jsx index a3820c08..8368093a 100644 --- a/fastapi_react/frontend/src/pages/Analytics.test.jsx +++ b/fastapi_react/frontend/src/pages/Analytics.test.jsx @@ -1,15 +1,11 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; const apiMock = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn() })); -vi.mock('../api.js', () => ({ - api: apiMock, - downloadUrl: (p) => `/api/raw/download?path=${encodeURIComponent(p)}`, -})); +vi.mock("../api.js", () => ({ api: apiMock })); -// Make chart containers have size so Recharts renders. beforeEach(() => { - Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', { + Object.defineProperty(HTMLElement.prototype, "getBoundingClientRect", { configurable: true, value: () => ({ width: 800, height: 400, top: 0, left: 0, right: 800, bottom: 400, x: 0, y: 0 }), }); @@ -19,42 +15,33 @@ beforeEach(() => { sessionStorage.clear(); }); -import Analytics from './Analytics.jsx'; +import Analytics from "./Analytics.jsx"; -describe('Analytics page', () => { - it('renders headings and metric after data loads', async () => { - sessionStorage.setItem('f1analysis.filters', JSON.stringify({ applied: true, filters: [] })); +describe("Analytics page", () => { + it("renders the Streamlit analytics section sequence after filters are enabled", async () => { + sessionStorage.setItem("f1analysis.filters", JSON.stringify({ applied: true, filters: [] })); apiMock.post.mockResolvedValueOnce({ - rows_considered: 1234, + rows_considered: 10, charts: { active_years_vs_final: [{ resultsFinalPositionNumber: 5, yearsActive: 7 }], - positions_gained_over_time: [{ short_date: '2024-01-01', positionsGained: 1 }], + positions_gained_over_time: [{ short_date: "2024-01-01", positionsGained: 1 }], }, regressions: [], + regression_series: [], driver_performance: [], constructor_performance: [], dnf_reasons: [], + model_summary: { metrics: { mae: 1.7 } }, }); render(); - await waitFor(() => { - expect(screen.getByText(/Rows considered/)).toBeInTheDocument(); - }); - expect(screen.getByText('Analytics & Visualizations')).toBeInTheDocument(); - expect(screen.getByText('Tire Strategy Analysis')).toBeInTheDocument(); - }); - - it('shows error message on failure', async () => { - sessionStorage.setItem('f1analysis.filters', JSON.stringify({ applied: true, filters: [] })); - apiMock.post.mockRejectedValueOnce(new Error('boom')); - render(); - await waitFor(() => { - expect(screen.getByText('boom')).toBeInTheDocument(); - }); + await waitFor(() => expect(screen.getByText("Active Years v. Final Position")).toBeInTheDocument()); + expect(screen.getByText("Analytics & Visualizations")).toBeInTheDocument(); + expect(screen.getByText("🏎️ Tire Strategy Analysis")).toBeInTheDocument(); }); - it('waits for the Data Explorer filter flow before rendering the analytics', () => { + it("waits for Data Explorer filtering before rendering analytics", () => { render(); - expect(screen.getByText('Please filter results in the Data Explorer tab first to view analytics.')).toBeInTheDocument(); + expect(screen.getByText("Please filter results in the Data Explorer tab first to view analytics.")).toBeInTheDocument(); expect(apiMock.post).not.toHaveBeenCalled(); }); }); From d17c5b8d163dd29d1873c42254bf09de2ab15301 Mon Sep 17 00:00:00 2001 From: gmalbert Date: Tue, 29 Sep 2026 22:02:34 -0400 Subject: [PATCH 018/131] Update parity tests for Streamlit-matched UI --- .../frontend/src/pages/CurrentSeason.test.jsx | 44 ++++++------------- 1 file changed, 13 insertions(+), 31 deletions(-) diff --git a/fastapi_react/frontend/src/pages/CurrentSeason.test.jsx b/fastapi_react/frontend/src/pages/CurrentSeason.test.jsx index 84cbabe6..5ffc7458 100644 --- a/fastapi_react/frontend/src/pages/CurrentSeason.test.jsx +++ b/fastapi_react/frontend/src/pages/CurrentSeason.test.jsx @@ -1,46 +1,28 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; -const apiMock = vi.hoisted(() => ({ - get: vi.fn(), - post: vi.fn(), -})); +const apiMock = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn() })); +vi.mock("../api.js", () => ({ api: apiMock })); -vi.mock('../api.js', () => ({ - api: apiMock, - downloadUrl: (p) => `/api/raw/download?path=${encodeURIComponent(p)}`, -})); - -import CurrentSeason from './CurrentSeason.jsx'; +import CurrentSeason from "./CurrentSeason.jsx"; beforeEach(() => { apiMock.get.mockReset(); apiMock.post.mockReset(); }); -describe('CurrentSeason page', () => { - it('shows loading then renders the schedule', async () => { +describe("CurrentSeason page", () => { + it("renders the ten-column Streamlit schedule and next-race highlight", async () => { apiMock.get.mockResolvedValueOnce({ - year: 2025, - columns: ['round', 'grandPrixName', 'date', 'seasonStatus'], + year: 2026, rows: [ - { round: 1, grandPrixName: 'Bahrain', date: '2025-03-02', seasonStatus: 'Completed' }, - { round: 2, grandPrixName: 'Saudi Arabia', date: '2025-03-09', seasonStatus: 'Completed' }, - { round: 3, grandPrixName: 'Australia', date: '2025-03-23', seasonStatus: 'Next Race' }, + { round: 1, fullName: "Australian Grand Prix", date: "2026-03-08", time: "05:00", circuitType: "Race", courseLength: 5.3, laps: 58, turns: 14, distance: 307, totalRacesHeld: 40, seasonStatus: "Completed" }, + { round: 2, fullName: "Chinese Grand Prix", date: "2026-03-15", time: "07:00", circuitType: "Race", courseLength: 5.4, laps: 56, turns: 16, distance: 305, totalRacesHeld: 20, seasonStatus: "Next Race" }, ], }); render(); - await waitFor(() => { - expect(screen.getByText('Bahrain')).toBeInTheDocument(); - }); - expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(/2025/); - }); - - it('shows error state when the API fails', async () => { - apiMock.get.mockRejectedValueOnce(new Error('network down')); - render(); - await waitFor(() => { - expect(screen.getByText('network down')).toBeInTheDocument(); - }); + await waitFor(() => expect(screen.getByText("Australian Grand Prix")).toBeInTheDocument()); + expect(screen.getByText("Total number of races: 2")).toBeInTheDocument(); + expect(screen.getByText("Chinese Grand Prix").closest("tr")).toHaveClass("next-race-row"); }); }); From 4d9584c2362660b4708077e987330e8e0f821bdb Mon Sep 17 00:00:00 2001 From: gmalbert Date: Tue, 29 Sep 2026 22:02:36 -0400 Subject: [PATCH 019/131] Update parity tests for Streamlit-matched UI --- .../frontend/src/pages/RawData.test.jsx | 78 ++++++------------- 1 file changed, 22 insertions(+), 56 deletions(-) diff --git a/fastapi_react/frontend/src/pages/RawData.test.jsx b/fastapi_react/frontend/src/pages/RawData.test.jsx index 40d4fe13..c863d4f4 100644 --- a/fastapi_react/frontend/src/pages/RawData.test.jsx +++ b/fastapi_react/frontend/src/pages/RawData.test.jsx @@ -1,74 +1,40 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; const apiMock = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn() })); -vi.mock('../api.js', () => ({ - api: apiMock, - downloadUrl: (p) => `/api/raw/download?path=${encodeURIComponent(p)}`, -})); +vi.mock("../api.js", () => ({ api: apiMock })); -import RawData from './RawData.jsx'; +import RawData from "./RawData.jsx"; beforeEach(() => { apiMock.get.mockReset(); apiMock.post.mockReset(); }); -describe('RawData page', () => { - it('renders the page heading after data loads', async () => { - apiMock.get.mockImplementation((url) => { - if (url === '/api/raw/files') { - return Promise.resolve({ - files: [ - { path: 'active_drivers.csv', size: 100, suffix: '.csv' }, - { path: 'notes.txt', size: 50, suffix: '.txt' }, - ], - }); - } - if (url === '/api/health') return Promise.resolve({ status: 'ok' }); - return Promise.resolve({}); - }); +describe("RawData page", () => { + it("matches the Streamlit Data & Debug tab structure", async () => { + apiMock.get.mockResolvedValue({ status: "ok", expensive_tools_enabled: false }); render(); - expect(screen.getByText(/Data & Debug Tools/i)).toBeInTheDocument(); - await waitFor(() => { - expect(apiMock.get).toHaveBeenCalledWith('/api/raw/files'); - }); + expect(screen.getByText("Data & Debug Tools")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Raw Data" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Temporal Leakage Audit" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Hyperparameter Tuning" })).toBeInTheDocument(); }); - it('previews and downloads a selected source file', async () => { - apiMock.get.mockImplementation(url => { - if (url === '/api/raw/files') return Promise.resolve({ files: [{ path: 'active_drivers.csv', size: 100, suffix: '.csv' }] }); - if (url === '/api/health') return Promise.resolve({ status: 'ok' }); - if (url.startsWith('/api/raw/preview')) return Promise.resolve({ kind: 'table', columns: ['driver'], rows: [{ driver: 'Max' }] }); - return Promise.resolve({}); - }); + it("shows the complete unfiltered dataset on demand", async () => { + apiMock.get.mockResolvedValue({ status: "ok", expensive_tools_enabled: false }); + apiMock.post.mockResolvedValue({ total: 2, columns: ["grandPrixYear"], rows: [{ grandPrixYear: 2025 }, { grandPrixYear: 2026 }] }); render(); - fireEvent.click(await screen.findByRole('button', { name: /active_drivers.csv/ })); - expect(await screen.findByText('Max')).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'Download original' })).toHaveAttribute( - 'href', '/api/raw/download?path=active_drivers.csv', - ); + fireEvent.click(screen.getByRole("checkbox", { name: "Show Raw Data" })); + await waitFor(() => expect(apiMock.post).toHaveBeenCalledWith("/api/data-explorer/query", expect.objectContaining({ filters: [] }))); + expect(await screen.findByText("Total number of results: 2")).toBeInTheDocument(); }); - it('pages through the full analysis table without requesting all rows at once', async () => { - apiMock.get.mockImplementation(url => url === '/api/raw/files' - ? Promise.resolve({ files: [] }) - : Promise.resolve({ status: 'ok' })); - apiMock.post.mockResolvedValue({ total: 51, columns: ['grandPrixYear'], rows: [{ grandPrixYear: 2025 }] }); + it("keeps the leakage audit gated in hosted mode", async () => { + apiMock.get.mockResolvedValue({ status: "ok", expensive_tools_enabled: false }); render(); - fireEvent.click(await screen.findByRole('checkbox', { name: 'Show complete analysis dataset' })); - await screen.findByText(/Rows 1–50 of 51/); - fireEvent.click(screen.getByRole('button', { name: 'Next' })); - await waitFor(() => expect(apiMock.post).toHaveBeenLastCalledWith('/api/data-explorer/query', expect.objectContaining({ offset: 50, limit: 50 }))); - }); - - it('keeps expensive tools disabled with an actionable environment hint', async () => { - apiMock.get.mockImplementation(url => url === '/api/raw/files' - ? Promise.resolve({ files: [] }) - : Promise.resolve({ status: 'ok', expensive_tools_enabled: false })); - render(); - fireEvent.click(screen.getByRole('button', { name: 'Temporal Leakage Audit' })); - expect(await screen.findByRole('button', { name: 'Run Leakage Audit' })).toBeDisabled(); - expect(screen.getByText('ENABLE_EXPENSIVE_TOOLS=1')).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Temporal Leakage Audit" })); + expect(await screen.findByRole("button", { name: "Run Leakage Audit" })).toBeDisabled(); + expect(screen.getByText("Research controls are disabled in hosted mode.")).toBeInTheDocument(); }); }); From 020c42d21baba16b5c6d7326566366690b033572 Mon Sep 17 00:00:00 2001 From: gmalbert Date: Tue, 29 Sep 2026 22:02:57 -0400 Subject: [PATCH 020/131] Match Betting Oracle footer to Streamlit --- fastapi_react/frontend/src/App.jsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/fastapi_react/frontend/src/App.jsx b/fastapi_react/frontend/src/App.jsx index 8cc338b5..2f46f442 100644 --- a/fastapi_react/frontend/src/App.jsx +++ b/fastapi_react/frontend/src/App.jsx @@ -77,10 +77,11 @@ export default function App() {
- Powered by - Betting Oracle - Sports Prediction Analytics - All content is for informational purposes only and does not constitute betting advice. Wager responsibly. +

Powered by Betting Oracle

+

Sports Prediction Analytics

+ + Betting Oracle Logo +
From 9a07744324c3de7f76d8f2c27972cdee923f028c Mon Sep 17 00:00:00 2001 From: gmalbert Date: Tue, 29 Sep 2026 22:03:06 -0400 Subject: [PATCH 021/131] Match footer spacing and typography to Streamlit --- fastapi_react/frontend/src/styles.css | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/fastapi_react/frontend/src/styles.css b/fastapi_react/frontend/src/styles.css index 62cc355d..d5d66795 100644 --- a/fastapi_react/frontend/src/styles.css +++ b/fastapi_react/frontend/src/styles.css @@ -166,10 +166,17 @@ details[open] summary { margin-bottom: .8rem; } .tire-controls { max-width: 620px; } footer.site-footer { - margin-top: 3rem; padding-top: 1.15rem; border-top: 1px solid #ececee; color: #6b6f76; - font-size: .78rem; display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: .4rem .8rem; -} -footer.site-footer small { flex-basis: 100%; text-align: center; } + text-align: center; + padding: 20px 0; + border-top: 1px solid #e0e0e0; + margin-top: 40px; + color: #666; + font-family: sans-serif; +} +footer.site-footer p { margin: 0 0 10px; font-size: 14px; } +footer.site-footer .footer-subtitle { margin: 0 0 15px; font-size: 12px; color: #888; } +footer.site-footer a { color: #3b82f6; text-decoration: none; } +footer.site-footer img { height: 60px; width: auto; border: 0; } @media (max-width: 900px) { .block-container { padding: 1.6rem 1.2rem 3rem; } From 4dd0c409b66477c62cc64c2a72daf022d15b9e57 Mon Sep 17 00:00:00 2001 From: gmalbert Date: Tue, 29 Sep 2026 22:03:47 -0400 Subject: [PATCH 022/131] Match Betting Research controls and wording to Streamlit --- .../frontend/src/pages/BettingResearch.jsx | 163 ++++++++---------- 1 file changed, 76 insertions(+), 87 deletions(-) diff --git a/fastapi_react/frontend/src/pages/BettingResearch.jsx b/fastapi_react/frontend/src/pages/BettingResearch.jsx index ca1496ab..e4448d69 100644 --- a/fastapi_react/frontend/src/pages/BettingResearch.jsx +++ b/fastapi_react/frontend/src/pages/BettingResearch.jsx @@ -1,15 +1,14 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState } from "react"; import Papa from "papaparse"; import { api } from "../api"; -import { Card, DataTable, JsonBlock, Metric, Tabs } from "../components/UI"; +import { DataTable, JsonBlock, Metric, Tabs } from "../components/UI"; import { LinePanel } from "../components/Charts"; const tabs = ["Value & stake", "Field simulation", "Paper replay", "Calibration"]; - const defaultEntries = [ { driver_id: "driver-a", constructor_id: "team-1", pace_score: 1.0, dnf_probability: 0.05, uncertainty: 0.8, race_sensitivity: 0.8 }, { driver_id: "driver-b", constructor_id: "team-1", pace_score: 1.4, dnf_probability: 0.06, uncertainty: 0.9, race_sensitivity: 1.0 }, - { driver_id: "driver-c", constructor_id: "team-2", pace_score: 2.2, dnf_probability: 0.08, uncertainty: 1.0, race_sensitivity: 1.2 } + { driver_id: "driver-c", constructor_id: "team-2", pace_score: 2.2, dnf_probability: 0.08, uncertainty: 1.0, race_sensitivity: 1.2 }, ]; function csvDataUrl(rows, columns) { @@ -19,43 +18,20 @@ function csvDataUrl(rows, columns) { return `data:text/csv;charset=utf-8,${encodeURIComponent(csv)}`; } -function ReliabilityChart({ rows }) { - // The reliability table from f1bet has a 'reliability'/'reliability_observed' - // bin-mean field. Plot observed rate vs predicted mean with the y=x reference. - const mapped = rows - .map(r => ({ - predicted: Number(r.bin ?? r.predicted ?? r.reliability ?? r.center), - observed: Number(r.observed ?? r.observed_rate ?? r.reliability_observed), - })) - .filter(p => Number.isFinite(p.predicted) && Number.isFinite(p.observed)); - if (mapped.length < 2) return null; - const chartRows = mapped.map(p => ({ ...p, perfect: p.predicted })); - return ( - - ); -} - function CsvInput({ onRows, label }) { - function load(file) { + return ; } export default function BettingResearch() { const [tab, setTab] = useState(tabs[0]); - const [calc, setCalc] = useState({ model_probability: .25, decimal_odds: 2.1, opposing_odds: 1.8, uncertainty: .02, devig_method: "multiplicative", bankroll: 10000 }); + const [calc, setCalc] = useState({ + model_probability: .25, decimal_odds: 2.10, opposing_odds: 1.80, + uncertainty: .02, devig_method: "multiplicative", bankroll: 10000, + }); const [calcOut, setCalcOut] = useState(null); const [simEntries, setSimEntries] = useState(defaultEntries); const [simulations, setSimulations] = useState(10000); @@ -70,97 +46,110 @@ export default function BettingResearch() { useEffect(() => { let cancelled = false; setBusy("value"); - setError(null); api.post("/api/betting/value", calc) .then(result => { if (!cancelled) setCalcOut(result); }) - .catch(e => { if (!cancelled) setError(e.message); }) + .catch(err => { if (!cancelled) setError(err.message); }) .finally(() => { if (!cancelled) setBusy(""); }); return () => { cancelled = true; }; }, [calc]); + useEffect(() => { + if (!calRows.length) { setCalOut(null); return; } + let cancelled = false; + setBusy("calibration"); + api.post("/api/betting/calibration", { rows: calRows }) + .then(result => { if (!cancelled) setCalOut(result); }) + .catch(err => { if (!cancelled) setError(`Calibration input is invalid: ${err.message}`); }) + .finally(() => { if (!cancelled) setBusy(""); }); + return () => { cancelled = true; }; + }, [calRows]); + async function runSimulation() { setBusy("simulation"); - try { setError(null); setSimOut(await api.post("/api/betting/simulate", { entries: simEntries, simulations, seed: 42 })); } catch (e) { setError(e.message); } + setError(null); + try { setSimOut(await api.post("/api/betting/simulate", { entries: simEntries, simulations, seed: 42 })); } + catch (err) { setError(`Simulation input is invalid: ${err.message}`); } finally { setBusy(""); } } + async function runReplay() { setBusy("replay"); - try { setError(null); setReplayOut(await api.post("/api/betting/backtest", { rows: replayRows })); } catch (e) { setError(e.message); } - finally { setBusy(""); } - } - async function runCalibration() { - setBusy("calibration"); - try { setError(null); setCalOut(await api.post("/api/betting/calibration", { rows: calRows })); } catch (e) { setError(e.message); } + setError(null); + try { setReplayOut(await api.post("/api/betting/backtest", { rows: replayRows })); } + catch (err) { setError(`Backtest rejected: ${err.message}`); } finally { setBusy(""); } } + return (
-

Probability & Betting Research

Value, coherent race simulation, replay and calibration.

+

Probability & Betting Research

- {error &&
{error}
} + {error &&
{error}
} - {tab === "Value & stake" && -
- {[ - ["Model probability", "model_probability", .001], - ["Selection decimal odds", "decimal_odds", .01], - ["Opposing decimal odds", "opposing_odds", .01], - ["Probability uncertainty", "uncertainty", .005], - ["Bankroll", "bankroll", 100] - ].map(([label, key, step]) => )} - + {tab === "Value & stake" && <> +
+ + +
- {busy === "value" &&
Calculating value…
} + + {calcOut &&
- + = 0 ? "+" : ""}${(calcOut.raw_ev * 100).toFixed(2)}%`} /> - +
} - {calcOut &&

Decision: {calcOut.reason_code}

} - } + {calcOut &&

Decision: {calcOut.reason_code}.

} + } - {tab === "Field simulation" && -

Upload one row per driver, or use the default three-driver template.

- Download input template + {tab === "Field simulation" && <> +

Upload one row per driver. Pace is an arbitrary lower-is-faster score; drivers sharing a constructor receive correlated shocks and all simulations produce unique finishing positions.

+

Download input template

setSimEntries(rows)} /> - - - + +

{simOut && <>

Download probabilities

} -
} + } - {tab === "Paper replay" && -

Upload the timestamped ledger used by the existing f1bet backtest engine.

- setReplayRows(rows)} /> - + {tab === "Paper replay" && <> +

Replay requires timestamps, real pre-event prices, de-vigged market probability, and settled outcomes. Records using a forecast or quote after event start are rejected.

+ { setReplayRows(rows); setReplayOut(null); }} /> + {!replayRows.length ?
No odds ledger is bundled, so profitability is intentionally not estimated.
: ( +

+ )} {replayOut && <> -

Placed paper bets

-

All decisions and abstentions

-

Staking sensitivity

+

Placed paper bets

+

All decisions and abstentions

+

Required staking sensitivity

} -
} + } - {tab === "Calibration" && -

Required columns: probability and outcome. Optional: market and stage.

+ {tab === "Calibration" && <> +

Upload frozen probabilities and binary outcomes. Diagnostics include Brier score, log loss, adaptive reliability bins, ECE, calibration slope/intercept, and ROC AUC.

setCalRows(rows)} /> - + {!calRows.length &&
Required columns: probability and outcome. Optional columns: market and stage.
} + {busy === "calibration" &&
Analyzing calibration…
} {calOut && <> -

Adaptive reliability

+

Adaptive reliability table

- {calOut.reliability?.length > 1 && ( - - )} + } -
} - + }
); } From 69a6516b700a56e6b4ae41049e653f7d3d7a5fa3 Mon Sep 17 00:00:00 2001 From: gmalbert Date: Tue, 29 Sep 2026 22:06:49 -0400 Subject: [PATCH 023/131] Fix application shell accessibility lint --- fastapi_react/frontend/src/App.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastapi_react/frontend/src/App.jsx b/fastapi_react/frontend/src/App.jsx index 2f46f442..05fe3277 100644 --- a/fastapi_react/frontend/src/App.jsx +++ b/fastapi_react/frontend/src/App.jsx @@ -57,7 +57,7 @@ export default function App() {

Code deployed at: {meta?.code_deployed_at || "Loading…"}

-