No description
  • Python 59.7%
  • TypeScript 31%
  • CSS 8.2%
  • Shell 0.5%
  • HTML 0.3%
  • Other 0.3%
Find a file
rune a3219ca46c
All checks were successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/tag/build Pipeline was successful
feat(replay): live auto-reload, KM-to-go header, pinned riders
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>
2026-07-09 12:28:40 +00:00
.woodpecker feat: RaceWatch bike-race companion (backend + frontend + docker) 2026-06-22 15:25:25 +00:00
backend feat(replay): rider hover infobox with team, jersey, and career facts 2026-07-06 12:59:22 +00:00
frontend feat(replay): live auto-reload, KM-to-go header, pinned riders 2026-07-09 12:28:40 +00:00
scripts feat(ops): gzip database backups 2026-07-05 20:46:13 +00:00
.env.example feat(harvester): enable Radio Tour recording 2026-07-06 10:21:03 +00:00
.gitignore feat(ops): pg_dump backup script 2026-07-05 20:40:23 +00:00
docker-compose.deploy.yml feat(radio): playback — live listen, recordings API, replay sync 2026-07-06 10:29:09 +00:00
docker-compose.yml feat(radio): playback — live listen, recordings API, replay sync 2026-07-06 10:29:09 +00:00
README.md feat(ops): gzip database backups 2026-07-05 20:46:13 +00:00

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-data volume is no longer used. Re-import your races once via the UI (or POST /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 PCS ul.startlist_v4 layout.
    • services/racecenter_client.py — fetches/normalizes Race Center allCompetitors-{year}, team-{year}, stage-{year} JSON and proxies the /live-stream SSE feed. Competitors carry a bib field, 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-stream SSE 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.