diff --git a/.env.example b/.env.example
index c59e51e..1bdf189 100644
--- a/.env.example
+++ b/.env.example
@@ -4,6 +4,12 @@ POSTGRES_USER=activity
POSTGRES_PASSWORD=activity
POSTGRES_DB=activity_hub
+# Where the API finds the database. Compose builds this itself out of the three
+# values above, so leave it alone for a Docker run — it is needed only when you
+# run the API on your own machine, where nothing else supplies it and startup
+# fails without it.
+# DATABASE_URL=postgresql+psycopg2://activity:activity@localhost:5432/activity_hub
+
# IANA zone used to bucket activities whose file states no UTC offset.
DISPLAY_TIMEZONE=Europe/Rome
@@ -19,3 +25,11 @@ API_PORT=8000
# Only needed if something other than the bundled dashboard calls the API.
CORS_ORIGINS=
+
+# The name `scripts.ensure_user` gives the profile it creates, for a headless
+# setup that never opens the first-run screen.
+# DEFAULT_FIRST_NAME=Athlete
+
+# How close two activities of the same sport must start to count as the same
+# session exported twice. Five minutes.
+# DUPLICATE_WINDOW_SECONDS=300
diff --git a/README.md b/README.md
index 49378ee..87eb8f8 100644
--- a/README.md
+++ b/README.md
@@ -63,8 +63,7 @@ To try it without your own data, load the bundled demo set:
- **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: the activity table keeps the
- columns that fit and earns the rest as the screen widens.
+- **Readable on a phone**, down to a 320px screen.
## What it looks like
@@ -101,8 +100,8 @@ elapsed time, so the same hill lands in the same place on both lines:
-It reads on a phone too, down to a 320px screen — the activity table keeps the
-columns that fit and earns the rest as the screen widens:
+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:
@@ -174,7 +173,7 @@ Interactive docs at when the stack is up.
| 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 `.tcx` or `.gpx` |
+| 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. Returns counts plus a per-file outcome |
| 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 |
@@ -225,15 +224,11 @@ that Monday, not to the Sunday it falls on in UTC.
summaries, counts. The samples live in `track_points`, so nothing is stored
twice.
-Archive imports, duplicate detection and the reasoning behind both are in
-[docs/DESIGN.md](docs/DESIGN.md).
-
-## Why it works the way it does
-
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. They are collected in **[docs/DESIGN.md](docs/DESIGN.md)** —
-worth a read before changing a chart, a colour or a derived figure.
+rendered page — archive imports and duplicate detection among them. They are
+collected in **[docs/DESIGN.md](docs/DESIGN.md)**, worth a read before changing
+a chart, a colour or a derived figure.
## Demo data
@@ -262,7 +257,7 @@ Docker is enough to run it; these are for working on it. You need **Python
cd backend
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
-cp .env.example .env # point DATABASE_URL at a PostgreSQL instance
+cp .env.example .env # then uncomment DATABASE_URL and point it at PostgreSQL
alembic upgrade head
uvicorn app.main:app --reload
```
@@ -274,9 +269,10 @@ npm install
npm run dev
```
-The dev server proxies `/api`, and the nginx image serves the API on the same
-origin, so the browser never makes a cross-origin request and CORS never comes
-into it either way.
+The 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.
@@ -284,7 +280,7 @@ creates the profile from `DEFAULT_FIRST_NAME` instead of the first-run screen.
### Tests
```bash
-cd backend && ruff check . && ruff format --check . && pytest # 311 tests, no database needed
+cd backend && ruff check . && ruff format --check . && pytest # 343 tests, no database needed
cd frontend && npm run lint && npm test # 223 tests, jsdom
```
@@ -326,10 +322,13 @@ 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
+├── schemas/ the request and response shapes
├── routers/ health, users, workouts, upload, analysis, export
└── services/
├── parsers/ base, FIT, TCX, GPX, factory
+ ├── archives.py reading a zip without trusting the zip
├── 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
@@ -339,6 +338,7 @@ backend/app/
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
```
diff --git a/demo/README.md b/demo/README.md
index 4a9d8f9..1b6bc25 100644
--- a/demo/README.md
+++ b/demo/README.md
@@ -3,6 +3,11 @@
Synthetic TCX and GPX activities: five weeks of training plus a set of
deliberately awkward files.
+No FIT file, though the app reads that too. FIT is binary and this generator
+writes text from one end to the other, so producing one would mean teaching it
+a second output mode. The FIT path is covered by the test suite instead, where
+`backend/tests/fit_builder.py` builds whatever bytes a test needs.
+
## Why synthetic
Real GPS traces are personal data. A GPX exported from someone's watch
@@ -37,9 +42,10 @@ python -m scripts.generate_demo_data --out ../demo/activities --clean \
Output is deterministic for a given `--seed`, and `--ending` is pinned so the
committed files stay stable — which also means they age. The committed block
ends **2026-06-28**, so a weekly chart run much later shows empty recent weeks.
-For a dashboard that looks alive, regenerate with `--ending today`. For something closer to real data — a full
-training year sampled every ten seconds, about 15 MB — write it somewhere
-untracked:
+For a dashboard that looks alive, regenerate with `--ending today`.
+
+For something closer to real data — a full training year sampled every ten
+seconds, about 15 MB — write it somewhere untracked:
```bash
python -m scripts.generate_demo_data --out ../demo/generated \
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index 541fe95..dee1eac 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -16,8 +16,8 @@ offers. This is the long half.
- [Fitness, fatigue and form](#fitness-fatigue-and-form)
- [Personal bests](#personal-bests)
- [Comparing two activities](#comparing-two-activities)
-- [Filtering, deleting, exporting](#filtering-deleting-exporting)
- [Narrow screens](#narrow-screens)
+- [Filtering, deleting, exporting](#filtering-deleting-exporting)
## Reading the files