Self-hosted training log. Feed it the FIT, TCX and GPX files your watch or Strava exports, and get your history back as numbers and charts that stay on your own machine.
One user, no accounts, no external services, no telemetry. Units are metric throughout. There is no map tile server either: drawing your routes over a borrowed basemap would mean telling a stranger where you exercise, which is the opposite of the point.
You need Docker with Compose v2. Nothing else — the images build themselves.
git clone https://github.com/Danielededo/activity-hub.git
cd activity-hub
cp .env.example .env # then change POSTGRES_PASSWORD
docker compose up --build # first run takes a few minutesOpen http://localhost:8080. The dashboard asks for your name once, and that is
the whole of setup. Then feed it your activities: drop files on the upload box,
pick a folder, or hand it the entire .zip your watch or Strava exported.
To try it without your own data, load the bundled demo set:
./demo/load.sh # 22 synthetic activities over five weeks- Upload FIT, TCX and GPX — one file, a folder, a drag and drop, or the
whole export as a
.zip. FIT is what a Garmin watch records and what a Garmin bulk export contains; TCX and GPX cover Strava, Komoot and everything else that writes standard files. Re-uploading is safe: files already stored are skipped, and each file is reported separately as it lands. An export is imported in the background, with a running count of how far it has got. - Cycling, running, hiking, walking, swimming and strength. A Garmin strength session is recognised by its sub-sport and shown by time and heart rate, without the distance, pace and route it does not have. A sport the app has no entry for keeps the name its file gave it.
- Lifetime totals — distance, moving time, elevation, heart rate, with per-sport breakdowns, and a weekly trend over 8 to 52 weeks.
- Heart-rate zones and training load — how much time goes into each of five zones, per activity and per week, with Edwards' TRIMP as the load figure.
- Fitness, fatigue and form — the 42-day and 7-day exponential averages of daily load, and the difference between them.
- Personal bests — the fastest 1 km, 5 km, 10 km, half marathon and marathon you have ever covered, per sport, each naming the activity that set it. Plus the furthest, longest and biggest-climbing activity of each sport, and totals by year.
- Every activity in a table, filterable by sport, date range and name, with pace or speed depending on the sport and in the activity's own local time.
- Per-activity detail — the route coloured by speed, with traces for heart rate, cadence and elevation, and any second activity of the same sport beside it on a shared distance axis. Hovering the route reports how far in, how long in, and how fast at that point.
- Duplicate detection that catches the same session exported twice from two different services, not just the same file twice.
- Export — the summary as CSV and every activity as GPX in one zip, both honouring whatever filters are set. The zip this app writes is a zip it can read back.
- Readable on a phone, down to a 320px screen.
Time in each heart-rate zone, lifetime and week by week, with the load each week earned:
The same load averaged over 42 days and over 7, and the difference between them — a build shows as fatigue riding above fitness, a taper as form crossing back over zero:
One activity: the route coloured by its own speed, time in zone, and traces for heart rate, cadence and elevation.
And a second activity of the same sport beside it, against distance rather than elapsed time, so the same hill lands in the same place on both lines:
The activity table keeps the columns that fit on a narrow screen and earns the rest as it widens, so nothing hides behind a sideways swipe:
Everything lives in one PostgreSQL volume, activity-hub_pgdata. Nothing is
sent anywhere.
docker compose down -v deletes it. The -v removes volumes, which means
your entire history. docker compose down without it is safe, and so is
docker compose stop.
Back it up before you need to:
docker compose exec -T postgres \
sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"' > activity-hub-backup.sqlAnd to restore into a fresh stack:
docker compose exec -T postgres \
sh -c 'psql -U "$POSTGRES_USER" "$POSTGRES_DB"' < activity-hub-backup.sqlThe uploaded files themselves are not kept — each one is parsed into a workout plus its samples, and the original is discarded. Keep your exports if you want them.
git pull
docker compose up -d --buildSchema migrations run automatically when the API container starts, so there is no separate step, and the volume is untouched.
Two figures are computed when a file is uploaded, so activities stored before those features existed need filling in once:
docker compose exec backend python -m scripts.backfill_bests
docker compose exec backend python -m scripts.backfill_hr_zonesBoth read one activity at a time, are safe to interrupt and safe to re-run, and
only look at activities that need it. --recompute redoes everything, which is
only needed if the calculation itself changes.
When an update adds a sport — strength, for instance, which used to be stored as "other" — move the activities already stored to it:
docker compose exec backend python -m scripts.backfill_sports --dry-run # see what would move
docker compose exec backend python -m scripts.backfill_sportsIt re-reads what each file declared, kept for this purpose, and renames an activity only if it still has the name the app generated for it.
Interactive docs at http://localhost:8000/docs when the stack is up.
| Method | Path | Notes |
|---|---|---|
| GET | /api/health |
503 when the database is unreachable |
| GET | /api/sports |
The sport catalogue, in display order: label, pace or speed, cadence unit, colour |
| GET | /api/users/me |
The profile; 404 before one exists |
| POST | /api/users/ |
Creates the profile; 409 if one already exists |
| GET | /api/users/{id} |
|
| GET | /api/workouts?user_id=X |
limit, offset, and optional sport_type, date_from, date_to, q; newest first |
| GET | /api/workouts/{id}?user_id=X |
Includes raw_data and track_point_count; 404 if owned by someone else |
| GET | /api/workouts/{id}/track-points?user_id=X |
Samples for a route map or HR trace, downsampled to max_points |
| GET | /api/workouts/{id}/zones?user_id=X |
One activity's time in zone and the load it earned |
| GET | /api/workouts/{id}/export.gpx?user_id=X |
This activity as GPX, rebuilt from its samples |
| DELETE | /api/workouts/{id}?user_id=X |
Cascades to track points; 404 if owned by someone else |
| POST | /api/upload?user_id=X |
Multipart file: one .fit, .tcx or .gpx |
| POST | /api/upload/archive?user_id=X |
Multipart file: a .zip export. 202 with an import that is still running; the files are imported after the answer |
| GET | /api/upload/archive/{id}?user_id=X |
An import's progress (processed of total), counts, a capped per-file outcome, and its status: running, done, failed or interrupted |
| GET | /api/analysis/{user_id} |
Lifetime totals plus a per-sport breakdown |
| GET | /api/analysis/{user_id}/weekly?weeks=12 |
One bucket per local week, quiet weeks zero-filled |
| GET | /api/analysis/{user_id}/records |
Per-sport records and distance bests, plus totals by local year |
| GET | /api/analysis/{user_id}/zones?weeks=12 |
Time in each heart-rate zone, lifetime and by week, with load |
| GET | /api/analysis/{user_id}/form?days=90 |
Fitness, fatigue and form, one entry per calendar day |
| GET | /api/export/activities.csv?user_id=X |
One row per activity; takes the same filters as the list |
| GET | /api/export/activities.zip?user_id=X |
Every matching activity as a GPX file, in one zip |
Upload failures are explicit: 404 unknown user, 400 empty file, 409
already stored, 413 over the size limit, 422 unreadable or unsupported file.
An archive is refused up front — 422 for a zip that is unreadable, holds too
many files or claims too much uncompressed, 409 while another import is still
running — and a file inside it that fails is reported in the import, not as an
error of the upload.
The deployment serves exactly one person: whoever is self-hosting it. On first
run GET /api/users/me answers 404, the dashboard asks for a name, and that is
the whole of setup — no id to configure, and no placeholder profile invented on
your behalf.
The profile is only a name. With one user and no authentication there is nothing
to log in as and nothing to send mail to, so there is no username and no email;
the surname is optional, because plenty of people go by one name.
POST /api/users/ refuses a second profile.
user_id stays in the schema and on every endpoint. It is not a security
boundary — without authentication nothing here is. It scopes data, and the
ownership checks stop accidental cross-reads rather than attacks. Keeping it
costs nothing and leaves the door open to a second person.
flowchart LR
A["a .fit, .tcx or .gpx file,<br/>or a .zip of them"] --> B["parser_factory<br/>by extension, then<br/>by root element<br/>or file signature"]
B --> C["ParsedWorkout<br/>header, stated totals,<br/>one point per sample"]
C --> D["compute_metrics<br/>trusts the file's own totals,<br/>derives what is missing"]
D --> E["workouts + track_points<br/>with the HR histogram and<br/>the distance bests"]
Timestamps are stored as UTC. TCX and GPX normally write Z, which fixes the
instant but says nothing about the local hour, so any offset the file does
state is kept in utc_offset_minutes and DISPLAY_TIMEZONE is the fallback.
FIT is the one format that always states it, by writing each instant twice.
Weekly totals are bucketed by local week: a 00:30 Monday ride in Rome belongs to
that Monday, not to the Sunday it falls on in UTC.
workouts.raw_data holds file-level metadata only — creator, author, lap
summaries, counts. The samples live in track_points, so nothing is stored
twice.
Most of the choices in here have a reason that is not obvious from the code, and several exist because the obvious alternative was tried and looked wrong on the rendered page — archive imports and duplicate detection among them. They are collected in docs/DESIGN.md, worth a read before changing a chart, a colour or a derived figure.
demo/activities/ holds five weeks of synthetic training plus files that
exercise the awkward cases — no heart rate, no per-point timestamps, a stated
UTC offset, a single point, a hike that TCX can only call Other. Synthetic
rather than downloaded because a real GPX track starts at somebody's front door.
./demo/load.sh # into a running stack
cd backend && python -m scripts.generate_demo_data --helpThe committed set is pinned to a fixed end date, so it ages. See
demo/README.md for regenerating it — including
--ending today for a dashboard that looks alive, which is how the screenshots
above were made.
Docker is enough to run it; these are for working on it. You need Python 3.11 and Node 22.
# API — http://localhost:8000, docs at /docs
cd backend
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # then uncomment DATABASE_URL and point it at PostgreSQL
alembic upgrade head
uvicorn app.main:app --reload# Dashboard — http://localhost:5173, proxying /api to :8000
cd frontend
npm install
npm run devThe version lives in backend/.python-version, not in the commands. No Python
3.11, or a system Python without venv and pip — as on a fresh Ubuntu or WSL
— is no obstacle if you have uv: it reads that
file, fetches the version itself, and replaces the first two backend lines.
uv venv .venv && source .venv/bin/activate
uv pip install -r requirements.txtThe dev server proxies /api and the nginx image serves the API on the same
origin, so in both cases the browser makes no cross-origin request at all. The
CORS middleware is there for the other case — something that is not this
dashboard calling the API — and CORS_ORIGINS is what it reads.
For a headless setup that never opens a browser, python -m scripts.ensure_user
creates the profile from DEFAULT_FIRST_NAME instead of the first-run screen.
Every sport is one entry in backend/app/services/sports.py: its key and label,
whether it reads as pace or speed, its cadence unit, which of the four palette
colours it takes, whether it covers a distance at all, and what FIT, TCX and GPX
files call it — for FIT, by sport or by sub-sport. The parsers and the
dashboard both read that entry (the dashboard through GET /api/sports), so
nothing else changes. Then regenerate the frontend tests' copy of the catalogue,
which a backend test keeps in step:
cd backend && python -m scripts.export_sportsA sport a file names that has no entry is still stored, under the file's own
name, and shown by speed in the "other" colour. To move activities stored
before the entry existed, run python -m scripts.backfill_sports.
cd backend && ruff check . && ruff format --check . && pytest # 389 tests, no database needed
cd frontend && npm run lint && npm test # 252 tests, jsdomThe backend suite runs against in-memory SQLite, so there is nothing to
provision: JSONB and BIGSERIAL fall back to portable types outside
PostgreSQL.
.github/workflows/ci.yml runs on every push to main and every pull request:
| Job | What it proves |
|---|---|
| Lint and test | ruff check and ruff format are clean, the backend suite passes, and coverage is at or above 90% — the run fails below it |
| Migrations on PostgreSQL | The schema applies to a real PostgreSQL 16, alembic check finds no drift from the models, and the downgrade path works |
| Frontend lint, test and build | ESLint is clean, the jsdom suite passes, and the bundle builds |
| Images and compose stack | Both images build, the stack comes up health-gated, and a profile plus an upload round-trips through the dashboard's own origin |
The last two exercise what the unit suites cannot: the actual JSONB and
BIGSERIAL DDL, the image's migrate-then-serve entrypoint, and the nginx proxy
that makes same-origin requests possible.
docker compose is the whole deployment story, on purpose. This is a
single-user application that fits on one machine — a NAS, a VPS, a server in a
cupboard — and it builds its own images, so there is nothing to publish to a
registry and no credentials to keep anywhere.
A Helm chart lived here briefly. It was removed rather than kept "just in case": 600 lines guessing at a cluster's storage class, ingress class and secret conventions is a liability if nobody runs it, and a chart written when those things are actually known would be a better chart. It is in the git history if that day comes.
backend/app/
├── main.py FastAPI app, CORS, router wiring
├── config.py pydantic-settings, reads .env
├── database.py engine, session, declarative base
├── models/ User, Workout, TrackPoint, WorkoutBest, ArchiveImport
├── schemas/ the request and response shapes
├── routers/ health, sports, users, workouts, upload, analysis, export
└── services/
├── sports.py the sport catalogue: every per-sport difference
├── parsers/ base, FIT, TCX, GPX, factory
├── archives.py reading a zip without trusting the zip
├── imports.py importing an archive after the upload is answered
├── analyzer.py metric derivation and aggregate reporting
├── records.py the window scan, and records over it
├── zones.py the heart-rate histogram, and zones over it
├── form.py daily load, and the averages over it
└── exports.py CSV, GPX and the zip of them
frontend/src/
├── theme.js the validated palette, both modes
├── api/client.js every call this app makes
├── hooks/ whether the viewer is in dark mode
├── utils/ formatters, geo, track measurement, zone shares
└── components/ one per panel of the dashboard
Plus backend/alembic/ (migrations), backend/scripts/ (demo generator,
profile bootstrap, three backfills, the sport catalogue export), tests beside
each half, demo/ (synthetic activities and a loader), frontend/nginx.conf
(serves the build, proxies /api), docker-compose.yml, .editorconfig and
.github/workflows/ci.yml.
main is the default branch and is never committed to directly. Each change
lands as a pull request, and CI must be green before it merges.
git switch -c my-change main
# ... work ...
cd backend && ruff check . && ruff format --check . && pytest
cd frontend && npm run lint && npm test && npm run buildEnvironment files are the one thing to watch: .env.example is tracked, every
real .env is ignored. Never commit credentials.
Released under the MIT License.