diff --git a/backend/Dockerfile b/backend/Dockerfile index 8a8b9c9..9dfdb59 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -6,7 +6,8 @@ WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ libglib2.0-0 \ libgl1 \ - libgomp1 \ + tesseract-ocr \ + tesseract-ocr-eng \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt . diff --git a/backend/api.py b/backend/api.py index fad4a40..45c039b 100644 --- a/backend/api.py +++ b/backend/api.py @@ -4,14 +4,15 @@ FastAPI REST API for timing-systemet. import asyncio import logging +import uuid from contextlib import asynccontextmanager +from datetime import datetime, timezone from pathlib import Path import aiosqlite from fastapi import Depends, FastAPI, File, HTTPException, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse -from fastapi.staticfiles import StaticFiles from pydantic import BaseModel from typing import Optional @@ -23,7 +24,10 @@ from profile_db import ( get_db, import_startlist_csv, list_athletes, - upsert_athlete, +) +from race_db import ( + create_race, delete_race, get_active_race, get_race, get_station, + list_races, list_stations, set_active_race, update_race, upsert_station, delete_station, ) from results import get_results @@ -32,9 +36,7 @@ logger = logging.getLogger(__name__) @asynccontextmanager async def lifespan(app: FastAPI): - # Behandle bilder som lå i depot fra før oppstart await process_existing() - # Start fil-overvåkning i bakgrunnen asyncio.create_task(watch_depot()) yield @@ -49,8 +51,6 @@ app.add_middleware( ) -# --- DB-avhengighet --- - async def get_connection(): db = await get_db() try: @@ -60,7 +60,101 @@ async def get_connection(): # ===================== -# Startliste-endepunkter +# Løp (races) +# ===================== + +class RaceRequest(BaseModel): + name: str + date: Optional[str] = None + description: Optional[str] = None + + +@app.get("/api/races") +async def list_races_endpoint(db=Depends(get_connection)): + return await list_races(db) + + +@app.get("/api/races/active") +async def active_race(db=Depends(get_connection)): + race = await get_active_race(db) + return race or {} + + +@app.post("/api/races") +async def create_race_endpoint(body: RaceRequest, db=Depends(get_connection)): + return await create_race(db, body.name, body.date, body.description) + + +@app.put("/api/races/{race_id}") +async def update_race_endpoint(race_id: str, body: RaceRequest, db=Depends(get_connection)): + ok = await update_race(db, race_id, body.name, body.date, body.description) + if not ok: + raise HTTPException(404, "Løp ikke funnet") + return await get_race(db, race_id) + + +@app.post("/api/races/{race_id}/activate") +async def activate_race(race_id: str, db=Depends(get_connection)): + ok = await set_active_race(db, race_id) + if not ok: + raise HTTPException(404, "Løp ikke funnet") + return {"ok": True} + + +@app.delete("/api/races/{race_id}") +async def delete_race_endpoint(race_id: str, db=Depends(get_connection)): + ok = await delete_race(db, race_id) + if not ok: + raise HTTPException(404, "Løp ikke funnet") + return {"ok": True} + + +# ===================== +# Stasjoner +# ===================== + +class StationRequest(BaseModel): + name: str + display_name: str + station_order: int + gps_lat: Optional[float] = None + gps_lon: Optional[float] = None + gps_alt: Optional[float] = None + + +@app.get("/api/races/{race_id}/stations") +async def list_stations_endpoint(race_id: str, db=Depends(get_connection)): + return await list_stations(db, race_id) + + +@app.post("/api/races/{race_id}/stations") +async def create_station_endpoint(race_id: str, body: StationRequest, db=Depends(get_connection)): + return await upsert_station( + db, race_id, body.name, body.display_name, body.station_order, + body.gps_lat, body.gps_lon, body.gps_alt, + ) + + +@app.put("/api/races/{race_id}/stations/{station_id}") +async def update_station_endpoint( + race_id: str, station_id: str, body: StationRequest, db=Depends(get_connection) +): + return await upsert_station( + db, race_id, body.name, body.display_name, body.station_order, + body.gps_lat, body.gps_lon, body.gps_alt, station_id=station_id, + ) + + +@app.delete("/api/races/{race_id}/stations/{station_id}") +async def delete_station_endpoint(race_id: str, station_id: str, db=Depends(get_connection)): + ok = await delete_station(db, station_id) + if not ok: + raise HTTPException(400, "Kan ikke slette start eller mål") + return {"ok": True} + + +# ===================== +# Startliste # ===================== @app.get("/api/athletes") @@ -69,15 +163,11 @@ async def list_athletes_endpoint(db=Depends(get_connection)): @app.post("/api/athletes/import", summary="Last opp startliste som CSV") -async def import_csv( - file: UploadFile = File(...), - db=Depends(get_connection), -): +async def import_csv(file: UploadFile = File(...), db=Depends(get_connection)): if not file.filename.endswith(".csv"): raise HTTPException(400, "Kun CSV-filer støttes") - content = (await file.read()).decode("utf-8-sig") # utf-8-sig håndterer BOM - result = await import_startlist_csv(db, content) - return result + content = (await file.read()).decode("utf-8-sig") + return await import_startlist_csv(db, content) @app.delete("/api/athletes/all") @@ -100,17 +190,19 @@ async def remove_athlete(profile_id: str, db=Depends(get_connection)): @app.get("/api/passages") async def list_passages( + race_id: Optional[str] = None, station: Optional[str] = None, needs_review: Optional[bool] = None, profile_id: Optional[str] = None, db=Depends(get_connection), ): - return await get_passages(db, profile_id=profile_id, station=station, needs_review=needs_review) + return await get_passages(db, race_id=race_id, profile_id=profile_id, + station=station, needs_review=needs_review) -@app.get("/api/passages/review", summary="Hent passeringer som trenger manuell gjennomgang") -async def review_queue(db=Depends(get_connection)): - return await get_passages(db, needs_review=True) +@app.get("/api/passages/review") +async def review_queue(race_id: Optional[str] = None, db=Depends(get_connection)): + return await get_passages(db, race_id=race_id, needs_review=True) class ResolveRequest(BaseModel): @@ -121,13 +213,10 @@ class ResolveRequest(BaseModel): @app.post("/api/passages/{passage_id}/resolve") async def resolve(passage_id: str, body: ResolveRequest, db=Depends(get_connection)): - ok = await resolve_passage( - db, - passage_id, - profile_id=body.profile_id, - bib_number=body.bib_number, - review_note=body.review_note, - ) + ok = await resolve_passage(db, passage_id, + profile_id=body.profile_id, + bib_number=body.bib_number, + review_note=body.review_note) if not ok: raise HTTPException(404, "Passering ikke funnet") return {"ok": True} @@ -135,7 +224,6 @@ async def resolve(passage_id: str, body: ResolveRequest, db=Depends(get_connecti @app.get("/api/passages/{passage_id}/images") async def passage_images(passage_id: str, db=Depends(get_connection)): - """Hent alle bilder for en passering, kronologisk sortert.""" return await get_passage_images(db, passage_id) @@ -152,24 +240,35 @@ async def remove_passage(passage_id: str, db=Depends(get_connection)): # ===================== @app.get("/api/results") -async def get_results_endpoint(db=Depends(get_connection)): - return await get_results(db) +async def get_results_endpoint(race_id: Optional[str] = None, db=Depends(get_connection)): + return await get_results(db, race_id=race_id) # ===================== # Bildeopplasting # ===================== -VALID_IMAGE_TYPES = {"image/jpeg", "image/png"} VALID_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png"} -@app.post("/api/upload", summary="Last opp ett eller flere bilder til depot") -async def upload_images(files: list[UploadFile] = File(...)): +@app.post("/api/upload") +async def upload_images( + files: list[UploadFile] = File(...), + race_id: Optional[str] = None, + station_id: Optional[str] = None, + db=Depends(get_connection), +): """ - Lagrer opplastede bilder i /depot/ slik at ingest-prosessen plukker dem opp. - Returnerer status per fil. + Last opp bilder til depot. Med race_id + station_id behandles bildene + direkte (ingen EXIF GPS-krav) og passeringen logges umiddelbart. """ + # Hent stasjon for å få GPS og stasjonsnavn + station = None + if station_id: + station = await get_station(db, station_id) + if not station: + raise HTTPException(404, "Stasjon ikke funnet") + results = [] for file in files: suffix = Path(file.filename).suffix.lower() @@ -178,16 +277,29 @@ async def upload_images(files: list[UploadFile] = File(...)): continue dest = Path("/depot") / file.filename - # Unngå overskrivning ved navnekollisjon if dest.exists(): - import uuid as _uuid - dest = Path("/depot") / f"{_uuid.uuid4().hex}_{file.filename}" + dest = Path("/depot") / f"{uuid.uuid4().hex}_{file.filename}" try: content = await file.read() dest.write_bytes(content) + + if station: + # Direktebehandling — flytt til processed og logg passering + from ingest import process_image_with_override + await process_image_with_override( + dest, + race_id=race_id, + station_name=station["name"], + gps_lat=station["gps_lat"], + gps_lon=station["gps_lon"], + gps_alt=station["gps_alt"], + db=db, + ) + results.append({"filename": file.filename, "ok": True, "saved_as": dest.name}) except Exception as e: + logger.exception("Feil ved opplasting av %s", file.filename) results.append({"filename": file.filename, "ok": False, "error": str(e)}) return {"uploaded": sum(1 for r in results if r["ok"]), "results": results} @@ -199,11 +311,9 @@ async def upload_images(files: list[UploadFile] = File(...)): @app.get("/api/images/{path:path}") async def serve_image(path: str): - """Lever behandlede bilder til frontend.""" full_path = Path("/processed") / path if not full_path.exists() or not full_path.is_file(): raise HTTPException(404, "Bilde ikke funnet") - # Sjekk at stien er innenfor /processed (path traversal) try: full_path.resolve().relative_to(Path("/processed").resolve()) except ValueError: diff --git a/backend/ingest.py b/backend/ingest.py index 2f44f49..c2021ba 100644 --- a/backend/ingest.py +++ b/backend/ingest.py @@ -14,6 +14,7 @@ import logging import shutil import uuid from pathlib import Path +from typing import Optional import aiosqlite from watchdog.events import FileSystemEventHandler, FileCreatedEvent @@ -125,6 +126,69 @@ async def process_image(path: Path) -> None: ) +async def process_image_with_override( + path: Path, + *, + race_id: Optional[str], + station_name: str, + gps_lat: Optional[float], + gps_lon: Optional[float], + gps_alt: Optional[float], + db, +) -> None: + """ + Behandle bilde med manuelt oppgitt stasjon og GPS (fra web-opplasting). + EXIF-tid brukes hvis tilgjengelig, ellers nåværende tidspunkt. + """ + from datetime import datetime, timezone + from profile_db import get_athlete_by_bib + + logger.info("Web-opplasting: %s → stasjon=%s", path.name, station_name) + + # Forsøk EXIF for tidsstempel, fallback til nå + try: + meta = parse_image(path) + timestamp = meta.timestamp_utc + except ExifError: + timestamp = datetime.now(timezone.utc) + + ocr = read_bib(path) + dest = _destination_path(path, timestamp) + shutil.move(str(path), str(dest)) + + confidence = ocr.confidence + needs_review = ocr.digits is None or confidence < MIN_AUTO_CONFIDENCE + id_method = "bib_ocr" if not needs_review else "bib_ocr_uncertain" + review_note = None if not needs_review else ( + "number_unreadable" if ocr.digits is None else "low_confidence" + ) + + profile_id = None + if ocr.digits and not needs_review: + athlete = await get_athlete_by_bib(db, ocr.digits) + if athlete: + profile_id = athlete["profile_id"] + + await log_passage( + db, + race_id=race_id, + profile_id=profile_id, + bib_number=ocr.digits, + station=station_name, + timestamp_utc=timestamp, + gps_lat=gps_lat or 0.0, + gps_lon=gps_lon or 0.0, + gps_alt=gps_alt, + confidence=confidence, + proximity_score=ocr.proximity_score, + id_method=id_method, + source_image=str(dest), + needs_review=needs_review, + review_note=review_note, + ) + logger.info("Passering logget: bib=%s station=%s", ocr.digits, station_name) + + async def process_existing() -> None: """Behandle bilder som allerede ligger i depot/ ved oppstart.""" for path in sorted(DEPOT_DIR.glob("*")): diff --git a/backend/passage_log.py b/backend/passage_log.py index 172502f..91b365d 100644 --- a/backend/passage_log.py +++ b/backend/passage_log.py @@ -50,6 +50,7 @@ async def _find_active_passage( async def log_passage( db: aiosqlite.Connection, *, + race_id: Optional[str] = None, profile_id: Optional[str], bib_number: Optional[str], station: str, @@ -112,14 +113,14 @@ async def log_passage( await db.execute( """ INSERT INTO passages ( - passage_id, profile_id, bib_number, station, + passage_id, race_id, profile_id, bib_number, station, timestamp_utc, gps_lat, gps_lon, gps_alt, confidence, id_method, source_image, needs_review, review_note - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( - passage_id, profile_id, bib_number, station, + passage_id, race_id, profile_id, bib_number, station, timestamp_utc.isoformat(), gps_lat, gps_lon, gps_alt, confidence, id_method, source_image, int(needs_review), review_note, @@ -153,12 +154,16 @@ async def get_passage_images(db: aiosqlite.Connection, passage_id: str) -> list[ async def get_passages( db: aiosqlite.Connection, + race_id: Optional[str] = None, profile_id: Optional[str] = None, station: Optional[str] = None, needs_review: Optional[bool] = None, ) -> list[dict]: clauses = [] params = [] + if race_id is not None: + clauses.append("p.race_id = ?") + params.append(race_id) if profile_id is not None: clauses.append("p.profile_id = ?") params.append(profile_id) diff --git a/backend/profile_db.py b/backend/profile_db.py index d65d7f9..5e6b90c 100644 --- a/backend/profile_db.py +++ b/backend/profile_db.py @@ -25,6 +25,7 @@ async def init_db(db: aiosqlite.Connection) -> None: CREATE TABLE IF NOT EXISTS passages ( passage_id TEXT PRIMARY KEY, + race_id TEXT REFERENCES races(race_id), profile_id TEXT REFERENCES athletes(profile_id), bib_number TEXT, station TEXT NOT NULL, @@ -50,12 +51,16 @@ async def init_db(db: aiosqlite.Connection) -> None: ); CREATE INDEX IF NOT EXISTS idx_passages_profile ON passages(profile_id); + CREATE INDEX IF NOT EXISTS idx_passages_race ON passages(race_id); CREATE INDEX IF NOT EXISTS idx_passages_station ON passages(station); CREATE INDEX IF NOT EXISTS idx_passages_needs_review ON passages(needs_review); CREATE INDEX IF NOT EXISTS idx_passage_images_passage ON passage_images(passage_id); """) await db.commit() + from race_db import init_race_tables + await init_race_tables(db) + async def get_db() -> aiosqlite.Connection: db = await aiosqlite.connect(DB_PATH) diff --git a/backend/race_db.py b/backend/race_db.py new file mode 100644 index 0000000..446cf14 --- /dev/null +++ b/backend/race_db.py @@ -0,0 +1,202 @@ +""" +CRUD for løp (races) og stasjoner (stations). +""" + +import uuid +from typing import Optional + +import aiosqlite + + +async def init_race_tables(db: aiosqlite.Connection) -> None: + await db.executescript(""" + CREATE TABLE IF NOT EXISTS races ( + race_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + date TEXT, + description TEXT, + is_active INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS stations ( + station_id TEXT PRIMARY KEY, + race_id TEXT NOT NULL REFERENCES races(race_id) ON DELETE CASCADE, + name TEXT NOT NULL, + display_name TEXT NOT NULL, + station_order INTEGER NOT NULL DEFAULT 0, + gps_lat REAL, + gps_lon REAL, + gps_alt REAL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(race_id, name) + ); + + CREATE INDEX IF NOT EXISTS idx_stations_race ON stations(race_id, station_order); + """) + await db.commit() + + +# ── Løp ────────────────────────────────────────────────────────────────────── + +async def create_race( + db: aiosqlite.Connection, + name: str, + date: Optional[str] = None, + description: Optional[str] = None, +) -> dict: + race_id = str(uuid.uuid4()) + await db.execute( + "INSERT INTO races (race_id, name, date, description) VALUES (?, ?, ?, ?)", + (race_id, name, date, description), + ) + # Sett som aktivt hvis det er første løpet + async with db.execute("SELECT COUNT(*) FROM races") as cur: + count = (await cur.fetchone())[0] + if count == 1: + await db.execute("UPDATE races SET is_active = 1 WHERE race_id = ?", (race_id,)) + + # Opprett standard start- og målstasjon + for order, (sname, display) in enumerate([("start", "Start"), ("finish", "Mål")]): + await db.execute( + """INSERT INTO stations (station_id, race_id, name, display_name, station_order) + VALUES (?, ?, ?, ?, ?)""", + (str(uuid.uuid4()), race_id, sname, display, order * 1000), + ) + + await db.commit() + return await get_race(db, race_id) + + +async def get_race(db: aiosqlite.Connection, race_id: str) -> Optional[dict]: + async with db.execute("SELECT * FROM races WHERE race_id = ?", (race_id,)) as cur: + row = await cur.fetchone() + return dict(row) if row else None + + +async def list_races(db: aiosqlite.Connection) -> list[dict]: + async with db.execute("SELECT * FROM races ORDER BY date DESC, created_at DESC") as cur: + rows = await cur.fetchall() + return [dict(r) for r in rows] + + +async def update_race( + db: aiosqlite.Connection, + race_id: str, + name: str, + date: Optional[str], + description: Optional[str], +) -> bool: + cur = await db.execute( + "UPDATE races SET name = ?, date = ?, description = ? WHERE race_id = ?", + (name, date, description, race_id), + ) + await db.commit() + return cur.rowcount > 0 + + +async def delete_race(db: aiosqlite.Connection, race_id: str) -> bool: + cur = await db.execute("DELETE FROM races WHERE race_id = ?", (race_id,)) + await db.commit() + return cur.rowcount > 0 + + +async def set_active_race(db: aiosqlite.Connection, race_id: str) -> bool: + await db.execute("UPDATE races SET is_active = 0") + cur = await db.execute( + "UPDATE races SET is_active = 1 WHERE race_id = ?", (race_id,) + ) + await db.commit() + return cur.rowcount > 0 + + +async def get_active_race(db: aiosqlite.Connection) -> Optional[dict]: + async with db.execute("SELECT * FROM races WHERE is_active = 1 LIMIT 1") as cur: + row = await cur.fetchone() + return dict(row) if row else None + + +# ── Stasjoner ───────────────────────────────────────────────────────────────── + +async def list_stations(db: aiosqlite.Connection, race_id: str) -> list[dict]: + async with db.execute( + "SELECT * FROM stations WHERE race_id = ? ORDER BY station_order", + (race_id,), + ) as cur: + rows = await cur.fetchall() + return [dict(r) for r in rows] + + +async def get_station(db: aiosqlite.Connection, station_id: str) -> Optional[dict]: + async with db.execute( + "SELECT * FROM stations WHERE station_id = ?", (station_id,) + ) as cur: + row = await cur.fetchone() + return dict(row) if row else None + + +async def create_station( + db: aiosqlite.Connection, + race_id: str, + name: str, + display_name: str, + station_order: int, + gps_lat: Optional[float] = None, + gps_lon: Optional[float] = None, + gps_alt: Optional[float] = None, +) -> dict: + station_id = str(uuid.uuid4()) + await db.execute( + """INSERT INTO stations + (station_id, race_id, name, display_name, station_order, gps_lat, gps_lon, gps_alt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (station_id, race_id, name, display_name, station_order, gps_lat, gps_lon, gps_alt), + ) + await db.commit() + return dict((await db.execute( + "SELECT * FROM stations WHERE station_id = ?", (station_id,) + )).get_cursor() or {}) + + +async def upsert_station( + db: aiosqlite.Connection, + race_id: str, + name: str, + display_name: str, + station_order: int, + gps_lat: Optional[float] = None, + gps_lon: Optional[float] = None, + gps_alt: Optional[float] = None, + station_id: Optional[str] = None, +) -> dict: + if station_id: + await db.execute( + """UPDATE stations SET name=?, display_name=?, station_order=?, + gps_lat=?, gps_lon=?, gps_alt=? WHERE station_id=?""", + (name, display_name, station_order, gps_lat, gps_lon, gps_alt, station_id), + ) + else: + station_id = str(uuid.uuid4()) + await db.execute( + """INSERT INTO stations + (station_id, race_id, name, display_name, station_order, gps_lat, gps_lon, gps_alt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (station_id, race_id, name, display_name, station_order, gps_lat, gps_lon, gps_alt), + ) + await db.commit() + async with db.execute("SELECT * FROM stations WHERE station_id = ?", (station_id,)) as cur: + row = await cur.fetchone() + return dict(row) + + +async def delete_station(db: aiosqlite.Connection, station_id: str) -> bool: + # Ikke tillat sletting av start/finish + async with db.execute( + "SELECT name FROM stations WHERE station_id = ?", (station_id,) + ) as cur: + row = await cur.fetchone() + if row and row["name"] in ("start", "finish"): + return False + cur = await db.execute("DELETE FROM stations WHERE station_id = ?", (station_id,)) + await db.commit() + return cur.rowcount > 0 diff --git a/backend/requirements.txt b/backend/requirements.txt index ad7804e..c82f817 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -2,7 +2,7 @@ fastapi==0.115.6 uvicorn[standard]==0.34.0 pillow==11.1.0 piexif==1.1.3 -easyocr==1.7.2 +pytesseract==0.3.13 opencv-python-headless==4.11.0.86 python-multipart==0.0.20 watchdog==6.0.0 diff --git a/backend/results.py b/backend/results.py index 72d5edc..90bfcd1 100644 --- a/backend/results.py +++ b/backend/results.py @@ -7,21 +7,22 @@ from typing import Optional import aiosqlite - -async def get_results(db: aiosqlite.Connection) -> list[dict]: +async def get_results(db: aiosqlite.Connection, race_id: Optional[str] = None) -> list[dict]: """ Hent totalresultat for alle utøvere som har passert start og mål. Returnerer sortert liste med split-tider. """ - # Hent alle bekreftede passeringer gruppert per utøver - async with db.execute(""" + race_filter = "AND p.race_id = ?" if race_id else "" + params = [race_id] if race_id else [] + + async with db.execute(f""" SELECT p.profile_id, p.bib_number, a.name, a.club, p.station, p.timestamp_utc FROM passages p LEFT JOIN athletes a ON a.profile_id = p.profile_id - WHERE p.needs_review = 0 AND p.profile_id IS NOT NULL + WHERE p.needs_review = 0 AND p.profile_id IS NOT NULL {race_filter} ORDER BY p.profile_id, p.timestamp_utc - """) as cur: + """, params) as cur: rows = await cur.fetchall() # Grupper per utøver diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1b249b8..4d912b9 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,36 +1,52 @@ +import { useEffect, useState } from 'react' import { NavLink, Route, Routes } from 'react-router-dom' +import { api } from './api.js' import StartlistPage from './pages/StartlistPage.jsx' import ReviewPage from './pages/ReviewPage.jsx' import ResultsPage from './pages/ResultsPage.jsx' import PassagesPage from './pages/PassagesPage.jsx' import UploadPage from './pages/UploadPage.jsx' +import RacePage from './pages/RacePage.jsx' import './App.css' -function Nav() { +function Nav({ activeRace }) { const linkClass = ({ isActive }) => (isActive ? 'nav-link active' : 'nav-link') return ( ) } export default function App() { + const [activeRace, setActiveRace] = useState(null) + + useEffect(() => { + api.getActiveRace().then(r => setActiveRace(r?.race_id ? r : null)) + }, []) + return ( <> -