A digital replacement for a paper gym log: record workouts (exercises, sets, reps, weight) and see strength progress over time.
This is a learning project — the goal is to learn full-stack C# by building this app (by hand through milestone 10, AI-implemented and owner-reviewed since), mirroring the container/PR/CI patterns of an earlier Python project. The design rationale, data model, API contract and milestone log all live in PLAN.md; this file is the "how do I run it" companion.
| Layer | Choice |
|---|---|
| Backend | .NET 10 — ASP.NET Core Minimal APIs, EF Core 10 + Npgsql |
| Database | PostgreSQL 17 (Neon in production) |
| Auth | Hand-rolled: BCrypt password hashes, HMAC-SHA256 JWTs, invite-code-gated registration, per-IP rate limiting on login/register |
| API docs | OpenAPI document generated by Microsoft.AspNetCore.OpenApi, rendered by Scalar |
| Tests | xUnit + WebApplicationFactory, against a real Postgres via Testcontainers; Vitest for frontend helpers |
| Frontend | React + TypeScript (Vite), hand-written CSS against the design tokens in docs/ui/ — no component library |
| Containers | One Dockerfile per service, Docker Compose for local dev |
| CI/CD | GitHub Actions: format check + tests for both backend and frontend on every push and PR, required on main; every push to main publishes both images to GHCR and deploys them |
| Hosting | Azure Container Apps + Neon, defined in Bicep (infra/), deployed over OIDC; live at https://gymnotebook.fit |
- .NET 10 SDK
- Node.js 24 (for the frontend; CI pins the same major)
- Docker (for Compose, and for the Testcontainers-based test suite)
cp .env.example .env # then set Jwt__Secret to something generated, e.g. `openssl rand -base64 48`
docker compose up --build
curl http://localhost:8080/health
open http://localhost:3000The backend container applies pending migrations on start (entrypoint.sh), so a fresh database is ready without any manual step. Data lives in the db_data named volume and survives docker compose down. The frontend container is the production build served by nginx on http://localhost:3000, with try_files sending every route to index.html so a direct load of /login works. The backend address is not baked into that build: at container start runtime-config.sh writes it into config.js from API_URL, which Compose fills from VITE_API_URL in .env (see PLAN.md → The VITE_API_URL trap). Changing it needs docker compose up to recreate the container, not a rebuild. --build is still needed after code changes, or Compose reuses the stale images.
To use the app from a phone on the same network, point CORS_ORIGINS and VITE_API_URL in .env at the machine's LAN address instead of localhost and rebuild. Note that such an origin is not a secure context, which puts some web APIs out of reach — see that section before reaching for one.
Compose runs the app in Production mode, which is where the interactive API docs are deliberately switched off. For day-to-day development run the app directly, with only the database in a container:
docker run -d --name gymnotebook-db -p 5432:5432 \
-e POSTGRES_PASSWORD=devpassword -e POSTGRES_DB=gymnotebook postgres:17
cd backend
dotnet user-secrets set "ConnectionStrings:Default" "Host=localhost;Database=gymnotebook;Username=postgres;Password=devpassword" --project GymNotebook.Api
dotnet user-secrets set "Jwt:Secret" "$(openssl rand -base64 48)" --project GymNotebook.Api
dotnet tool restore # installs the pinned dotnet-ef
dotnet ef database update --project GymNotebook.Api # apply migrations
dotnet run --project GymNotebook.ApiThe app listens on http://localhost:5217 (the http profile in launchSettings.json).
cd frontend
npm install
npm run devVite serves the app on http://localhost:5173 with hot reload. It reads VITE_API_URL from the root .env (the same file Compose uses — vite.config.ts points envDir there) and calls the backend at that address, so start the backend first, by either route above. Both origins — :5173 for this and :3000 for the container — are in CORS_ORIGINS, so the two can run side by side.
The API documents itself: every endpoint is annotated and the OpenAPI document is built from the code at startup. With the app running from the SDK as above:
- Interactive reference (Scalar): http://localhost:5217/scalar
- Raw OpenAPI document: http://localhost:5217/openapi/v1.json
Both are mapped only in the Development environment, so they are not available from the Compose stack or a deployed instance — the app has no business publishing its own surface on a public URL. To call the protected routes from the Scalar page, POST /auth/login first and paste the returned token into the page's Authorize field.
Current endpoints:
| Method | Path | Auth | Notes |
|---|---|---|---|
GET |
/health |
– | 200 when the database answers, 503 otherwise |
POST |
/auth/register |
– | Needs inviteCode when INVITE_CODE is set; rate limited |
POST |
/auth/login |
– | Returns a JWT; rate limited |
GET |
/auth/me |
Bearer | The caller's id and username; proves a token is valid and not revoked |
POST |
/auth/change-password |
Bearer | Invalidates all previously issued tokens, returns a fresh one |
GET |
/exercises?search= |
Bearer | Index/autocomplete, scoped to the caller; results carry sessionCount and lastSet |
GET |
/exercises/{id}/history?from=&to= |
Bearer | Best loaded e1RM or bodyweight reps per session, oldest first |
PATCH |
/exercises/{id} |
Bearer | Rename (merges onto an existing name) and set/clear isBodyweight |
GET |
/workouts?limit=&before= |
Bearer | Pages newest first; rows carry exercise names, counts and end time |
POST |
/workouts |
Bearer | Creates a page from its heading fields |
GET |
/workouts/{id} |
Bearer | One page: heading + blocks (with isBodyweight) + sets, in order |
PATCH |
/workouts/{id} |
Bearer | Heading fields, including endedAt to finish a session |
DELETE |
/workouts/{id} |
Bearer | Cascades to blocks and sets |
PUT |
/workouts/{id}/exercises |
Bearer | Replaces the whole session atomically — the "new page" save |
POST |
/workouts/{id}/sets |
Bearer | Appends one set; exercise and block are get-or-create by name |
PATCH |
/workouts/{id}/sets/{setId} |
Bearer | Replaces weight, reps and warm-up flag |
DELETE |
/workouts/{id}/sets/{setId} |
Bearer | Removes one set |
GET |
/privacy/notice |
– | The current privacy notice + any announced successor; records nothing |
GET |
/privacy/optional-details-statement |
– | Current statement for optional workout details |
GET |
/account/privacy |
Bearer | Notice acknowledgement and optional-details consent state, including a pending transition count |
PUT |
/account/privacy/acknowledgement |
Bearer | Acknowledges the current notice version only (409 otherwise) |
PUT |
/account/privacy/optional-details-consent |
Bearer | Grants consent for the current statement version |
DELETE |
/account/privacy/optional-details-consent |
Bearer | Withdraws consent and clears stored optional details |
POST |
/account/export |
Bearer | Needs currentPassword; streams the whole notebook as one JSON attachment |
POST |
/account/delete |
Bearer | Needs currentPassword and confirmDeletion: true; deletes the account and notebook |
These privacy routes exist only when PRIVACY_LIFECYCLE_ENABLED is true (see Configuration); otherwise they answer 404. Every route under /exercises and /workouts answers 404 for anything the caller doesn't own. The progress endpoint (GET /exercises/{id}/history?from=&to=) returns the best qualifying working set per session for the chart: Epley e1RM for loaded exercises (with tested singles unchanged), or reps for unloaded bodyweight sets. Its optional calendar-date bounds are inclusive.
Everything comes from environment variables; .env.example lists them and the gitignored .env holds real values. The full story — including why ConnectionStrings__Default has a double underscore and why INVITE_CODE empty means open registration — is in PLAN.md → Configuration.
PRIVACY_LIFECYCLE_ENABLED switches on the privacy and account lifecycle feature (notice, optional-details consent, export and deletion; see specs/001-privacy-account-lifecycle/). Because main deploys automatically, the feature remains switched off in production until every release gate has evidence. A missing or empty value fails closed: only the exact value true enables it, and True, 1 or a typo leave it off. For a local Compose run, put PRIVACY_LIFECYCLE_ENABLED=true in the gitignored root .env, then run docker compose up --build from the repository root. For an SDK run, set dotnet user-secrets set PRIVACY_LIFECYCLE_ENABLED true --project backend/GymNotebook.Api from the repository root before starting the API. Check GET /privacy/notice returns 200; with the flag off it returns 404. The test fixtures set the flag themselves, so it does not need to be enabled for dotnet test. The served notices in docs/privacy/notices/ are synthetic development content, not a publishable notice.
FORWARDED_HEADERS_ENABLED is set to true only in Azure, by the API's Bicep module. It makes the per-IP login/register limit key on the client address that Container Apps ingress reports in X-Forwarded-For, instead of on the ingress itself. Leave it unset locally: with no proxy in front, trusting that header would let any caller dodge the limit. See PLAN.md → Rate limiting.
Lifecycle:SharedLockTimeoutMs, Lifecycle:WriteTimeoutMs, Lifecycle:ExclusiveLockTimeoutMs and Lifecycle:DeletionStatementTimeoutMs (defaults 5000, 10000, 15000 and 30000) bound the account-lifecycle locks described in PLAN.md → Account lifecycle coordination. The defaults are what production runs with; the app refuses to start if the write timeout isn't below the exclusive wait. One consequence for capacity: every authenticated request holds one pooled database connection for its whole duration, including writing the response (bounded by the write timeout), because the lock lives in that connection's transaction. The Npgsql pool (100 connections by default) and Neon's pooler are far above what this app needs, but it is the first thing to check if requests ever queue for a connection.
dotnet test backend/GymNotebook.slnDocker must be running: each test class gets a throwaway postgres:17 container via Testcontainers, and the migrations are applied to it before the first test. There is no in-memory database mode on purpose — the constraints in the schema are among the things worth testing. The account-lifecycle tests (the Lifecycle collection) share one container and start several app hosts against it, some on real Kestrel over loopback; they take a few seconds longer than the rest.
The full backend command above includes the privacy notice, consent, export, deletion, lifecycle concurrency and transition-clearing tests. To run the 100,000-set export regression fixture alone from the repository root and see its timing and payload measurements:
dotnet test backend/GymNotebook.sln --filter FullyQualifiedName~ExportPerformanceTests --logger 'console;verbosity=detailed'ExportPerformanceTests seeds 1,000 workouts × 10 exercise blocks × 10 sets (100,000 sets), downloads and parses the complete JSON over loopback Kestrel, and checks the 60-second local bound. Its test output reports duration, payload size and test-process memory approximations. DeletionPerformanceTests uses the same fixture for the local deletion bound. These checks cannot establish the deployed cross-region and proxy results; those require the separate release checklist run.
The frontend has the same four checks CI runs, as npm scripts:
cd frontend
npm run typecheck # tsc -b
npm run lint # eslint, with the type-aware rules on
npm run format:check # prettier
npm test # vitest — helper tests only, no component testsC# style is defined in .editorconfig and enforced by dotnet format, which ships with the SDK. Frontend style is Prettier (frontend/.prettierrc) for formatting and ESLint (frontend/eslint.config.js) for everything else — the Vite template's config with typescript-eslint's type-checked rules switched on, so an unawaited promise is a lint error. A pre-commit hook formats the staged files of both kinds for you; enable it once per clone:
git config core.hooksPath .githooksCI runs dotnet format --verify-no-changes and prettier --check, so an unformatted commit fails the required check even if the hook was skipped. To format everything by hand: dotnet format backend/GymNotebook.sln and npm run format in frontend/.
backend/
GymNotebook.Api/ Minimal API endpoints, EF Core models, Data/AppDbContext, Migrations/
GymNotebook.Tests/ xUnit; GymNotebookFactory = WebApplicationFactory + Testcontainers Postgres
GymNotebook.sln
Dockerfile multi-stage: SDK image publishes, slim aspnet image runs; built from the repo root
Dockerfile.dockerignore allow-list for that root context (API, entrypoint, privacy notices)
entrypoint.sh applies migrations, then starts the app
frontend/
src/api/ client.ts (the one fetch wrapper) + one module of wire types and calls per area:
auth, workouts, exercises, privacy; download.ts saves the export file
src/auth/ token.ts (where the JWT lives), requireAuth.ts (route-guard loader), noticeGate.ts + requireNoticeAcknowledged.ts (privacy notice gate)
src/screens/ one .tsx (+ .css) per screen — Login, Cover, ChangePassword, Sessions, NewWorkout
(new and edit), WorkoutDetail, Progress, Exercises, EditExercise, the privacy screens
and ExportData — plus the tested helpers they share (formatting, chart geometry, drafts)
src/styles/ tokens.css (design tokens lifted from the prototype, @font-face rules) + base.css
src/routes.tsx route table; main.tsx mounts the RouterProvider
public/fonts/ self-hosted woff2 files (Cormorant Garamond, Lora) + their OFL licenses
Dockerfile multi-stage: node builds, nginx serves dist/ (nginx.conf: SPA fallback, access log off)
runtime-config.sh writes config.js from API_URL at container start
eslint.config.js typescript-eslint (type-checked) + react-hooks + prettier
infra/ Bicep for the Azure deployment: Container Apps environment, the two apps, Log Analytics
docs/privacy/ versioned privacy notices (notices/, embedded into the API image) + the processing decision
docs/ui/ UI specification and a clickable HTML prototype
specs/ Spec Kit feature specs, plans and task lists (001: privacy and account lifecycle)
.specify/ Spec Kit templates, scripts and the project constitution
.github/workflows/ test.yml (format checks + tests), build-and-push.yml (images to GHCR), deploy.yml (to Azure)
.githooks/ pre-commit formatter (dotnet format + prettier)
docker-compose.yml frontend + backend + postgres for local dev
PLAN.md design, decisions and milestone log
AGENTS.md working guidelines for AI assistants in this repo
Milestones 1–10 are done. Workouts can be logged, edited and deleted end-to-end, exercises have both an e1RM/reps progress chart and rename/merge management, and every push to main publishes both images to GHCR and deploys them to Azure Container Apps at https://gymnotebook.fit.
Milestone 11, privacy and account lifecycle, is in progress through Spec Kit (specs/001-privacy-account-lifecycle/). The notice, export, deletion and optional-details consent are implemented and remain switched off in production behind PRIVACY_LIFECYCLE_ENABLED. Provider, restore, deployed performance and owner walkthrough evidence is still needed before the production switch; track it in the release checklist. The full milestone log is in PLAN.md → Milestones.