- Python 59.7%
- TypeScript 31%
- CSS 8.2%
- Shell 0.5%
- HTML 0.3%
- Other 0.3%
Live stages now keep the scrubber fresh: a 10s poll re-fetches the telemetry index and, when the viewer is pinned to the live edge, advances onto new samples — refreshing standings, map, and gap chart off the existing positions fetch. Header shows the leader's km-to-go at the current scrub point with a live indicator. Adds a pinned-rider watchlist: pin from the standings, gap and km/h shown in a dedicated card that reloads with the standings data. Pinned bibs persist in localStorage, scoped by year, surviving reloads and stage browsing. Bump frontend to 0.2.0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|---|---|---|
| .woodpecker | ||
| backend | ||
| frontend | ||
| scripts | ||
| .env.example | ||
| .gitignore | ||
| docker-compose.deploy.yml | ||
| docker-compose.yml | ||
| README.md | ||
RaceWatch
A personal companion app for watching bike races. Import a startlist from procyclingstats.com (PCS), search riders by start number, click straight through to PCS for rider/race results, and see live data from the Le Tour Race Center alongside.
API-first FastAPI backend + a small React/Vite frontend + a harvester daemon that continuously mirrors the Race Center data into Postgres, all in Docker.
Quick start
docker compose up --build
# frontend: http://localhost:8080 (it proxies /api/* to the backend)
Upgrading from the SQLite version: the database is now Postgres and the old
racewatch-datavolume is no longer used. Re-import your races once via the UI (orPOST /api/races/import) — startlists re-import from PCS in seconds.
In the UI: enter a PCS race slug (e.g. tour-de-france) and year, click Import
startlist, then type a start number. Bibs are ordered numerically (1, 2, …, 8, 11, 12, …). Typing 19 ranks 19 first, then 190, 191, … Bibs that merely contain
the query are listed after the prefix matches — so 27 also surfaces 127, 227, … in
case you missed a leading digit.
Architecture
- db — Postgres 16. One database for the app tables and the harvested Race Center
data. Connection string via
DATABASE_URL. - backend/ — FastAPI + SQLAlchemy.
services/pcs_scraper.py— fetches & parses the PCSul.startlist_v4layout.services/racecenter_client.py— fetches/normalizes Race CenterallCompetitors-{year},team-{year},stage-{year}JSON and proxies the/live-streamSSE feed. Competitors carry abibfield, used to join to riders.services/search.py— bib-prefix search with lexicographic ordering.api/startlist.py,api/racecenter.py— routers. Competitors/stages are served from the harvested tables when available, falling back to a live upstream fetch.models_rc.py— storage for harvested Race Center data (see below).harvester/— the harvester daemon (same image,python -m app.harvester).
- harvester — mirrors the entire Race Center document platform. Every dataset
upstream is a bind (a named feed of JSON documents): riders, teams, stages,
checkpoints, rankings per checkpoint, road groups, per-rider GPS telemetry, weather,
withdrawals, live-ticker publications in 13 languages, social/video/image metadata.
On startup it snapshots every bind via
GET /api/{bind}, then follows the/live-streamSSE feed, storing every change:rc_document/rc_document_history— lossless current state + full change history of every document (JSONB). This is the source of truth; history rows are deduplicated (only actual changes are appended).rc_team,rc_competitor,rc_stage,rc_checkpoint,rc_ranking(_row),rc_group_snapshot,rc_telemetry,rc_withdrawal— normalized extractions for direct SQL querying. Telemetry and group snapshots keep full time-series history, so a stage can be replayed. Extraction is best-effort and can always be re-run from the raw documents.
- frontend/ — React + Vite, built to static files served by nginx, which proxies
/api/*(incl. SSE) to the backend. Two views (hash-routed): the main rider-lookup page with the Race Center panel (stages + live-ticker timeline), and a stage replay page (#/replay/{year}/{stage}) that scrubs/plays through the harvested GPS telemetry — schematic course strip, standings, a Leaflet/OSM map of rider positions, and a gap-to-leader chart for selected riders.
Harvester configuration
| Env var | Default | Meaning |
|---|---|---|
DATABASE_URL |
— | Postgres connection string |
RACEWATCH_RC_YEAR |
auto | Edition to harvest; auto-detected from the millesime feed |
RACEWATCH_RC_SNAPSHOT_INTERVAL |
3600 |
Seconds between full REST re-snapshots |
RACEWATCH_LOG_LEVEL |
INFO |
Harvester log level |
RACEWATCH_RADIO_URL |
empty (off) | Direct audio stream URL (Icecast/MP3/AAC) to record race radio from |
RACEWATCH_RADIO_DIR |
/audio |
Where recordings are written (racewatch-audio volume) |
When RACEWATCH_RADIO_URL is set, the harvester continuously records the stream to
timestamped files (one per uninterrupted stretch; off-air periods are retried every
minute and sub-64KB fragments discarded). HLS (.m3u8) sources are not supported —
use a direct stream URL.
API
| Method | Path | Description |
|---|---|---|
GET |
/api/health |
Health check |
GET |
/api/races |
List imported races |
POST |
/api/races/import |
Import a PCS startlist {slug, year} |
GET |
/api/races/{id}/riders?q= |
Search riders by bib prefix (or name) |
GET |
/api/racecenter/{year}/competitors?bib= |
Race Center competitors (harvested, live fallback) |
GET |
/api/racecenter/{year}/stages |
Stage list (harvested, live fallback) |
GET |
/api/racecenter/{year}/stages/{n}/timeline?lang= |
Live-ticker timeline (harvested publications) |
GET |
/api/racecenter/{year}/stages/{n}/replay |
Telemetry sample timestamps (replay scrubber) |
GET |
/api/racecenter/{year}/stages/{n}/positions?ts= |
All rider positions at a timestamp |
GET |
/api/racecenter/{year}/stages/{n}/gaps?bibs=&step= |
Gap-to-leader time series per rider |
GET |
/api/racecenter/{year}/live |
SSE live-feed passthrough |
Tests
docker run --rm -v "$PWD/backend":/app -w /app python:3.12-slim \
bash -c "pip install -q -r requirements.txt && python -m pytest -q"
Tests parse saved real fixtures in backend/tests/fixtures/ (no network needed) and cover
the scraper, the Race Center parsers, the 19 → 191, 192, … search ordering, the
import/search API, and the harvester (document store semantics, extractors, snapshot,
SSE parsing). They run against SQLite; the schema is declared with dialect variants so
the same models create on both SQLite and Postgres.
Backups
scripts/backup.sh # local stack -> ./backups/racewatch-<ts>.sql.gz
COMPOSE_FILE=docker-compose.deploy.yml scripts/backup.sh # deploy stack
Gzipped plain pg_dump with --clean --if-exists; restore with
gunzip -c backups/racewatch-<ts>.sql.gz | docker compose exec -T db psql -U racewatch racewatch.
CI/CD
.woodpecker/build.yaml builds and pushes both images to the Forgejo registry at
code.jaduer.dk on pushes to main/master and on tags, using the
registry_user / registry_password Woodpecker secrets.
Notes
- PCS rejects requests without a browser-like
User-Agent; the scraper sets one. - The Race Center has no documented public API; endpoints were reverse-engineered and the client is kept as a thin adapter so parsing can be adjusted if payloads change.