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: Two rides compared: speed and elevation against distance travelled -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