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 18 (an UpCloud managed database in production) |
| Auth | Hand-rolled: email + password sign-in with emailed confirmation, BCrypt password hashes, HMAC-SHA256 JWTs (also for the links in emails), open signup behind a Cloudflare Turnstile bot check, per-IP rate limiting on the auth routes |
| 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 over SSH, through a key that can only run deploy/bin/deploy.sh |
| Hosting | UpCloud in Helsinki, Finland: the two app containers on a server shared with the owner's Subscription Tracker, behind its Caddy (TLS), and a managed PostgreSQL 18 service; runbook in deploy/README.md; 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 a Local environment (ASPNETCORE_ENVIRONMENT=Local in docker-compose.yml): not Development, so the interactive API docs stay switched off as in a deployed app, and not Production, so the console email backend is allowed. 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:18
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 deployed app can be installed to the home screen. On iOS, open it in Safari, then Share → Add to Home Screen. On Android, open it in Chrome, then ⋮ → Install app. It then opens full-screen like an app. On iOS the home-screen app keeps its storage separate from Safari, so you sign in once more there. See docs/ui/README.md → Home screen for how it's set up.
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 |
– | Email, password, display name; always 202 with no body, and emails the address (confirmation link, or a note that the account exists). Needs a passing turnstileToken when TURNSTILE_SECRET_KEY is set (400 captcha otherwise); rate limited |
POST |
/auth/login |
– | Email + password, returns a JWT; 403 email_not_verified until the address is confirmed; rate limited |
POST |
/auth/verification |
– | Sends the confirmation link again (email + password); always 204; 5/hour per IP |
POST |
/auth/verify-email |
– | The token from a confirmation link; returns the confirmed address, or 400 expired/invalid; 10/hour per IP |
POST |
/auth/password-reset |
– | Email; always 202, and emails a one-hour reset link only if the address has an account; turnstileToken as on register; 5/hour per IP |
POST |
/auth/password-reset/confirm |
– | Reset link token + new password; returns a JWT and revokes every other session; 400 expired/invalid, 403 account_suspended; 10/hour per IP |
GET |
/auth/me |
Bearer | The caller's id, display name and email; proves a token is valid and not revoked |
POST |
/auth/change-password |
Bearer | Invalidates all previously issued tokens, returns a fresh one |
POST |
/auth/token |
Bearer | Renews a valid session token within 12 hours of sign-in or password change; 403 renewal_refused after the cap |
GET |
/exercises?search=&excludeWorkoutId= |
Bearer | Index/autocomplete, scoped to the caller; results carry sessionCount, lastSet and firstSet (excludeWorkoutId leaves the page being edited out of both) |
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 with revision: 1 |
GET |
/workouts/{id} |
Bearer | One page: heading + blocks (with isBodyweight) + sets and revision, 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 |
GET |
/account/backup |
Bearer | Latest completed full-backup time, or null; independent of the privacy flag |
POST |
/account/export |
Bearer | Needs currentPassword; optional format defaults to json (csv also streams JSON for browser conversion); records the last completed full backup |
POST |
/account/restore |
Bearer | Adds missing pages from a version 1 JSON backup in one transaction; returns added/skipped counts |
POST |
/account/delete |
Bearer | Needs currentPassword and confirmDeletion: true; deletes the account and notebook |
Workout heading and set writes accept an optional expectedRevision in the JSON body; set DELETE takes it as a query parameter. A stale revision returns 409 page_changed with the current revision, while omitted revisions remain accepted. Successful page and set responses include the new revision; set DELETE keeps its 204 response. See the durable logging API contract.
Export, backup status and restore are always mapped. The remaining 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. The progress screen describes e1RM as an estimated strength trend, explains how to compare similar sets, flags estimates from sets above 12 reps as less certain, and links research on repetitions and maximum strength.
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 — is in PLAN.md → Configuration. In production the values live in /etc/gymnotebook/.env on the server (deploy/README.md → Environment); GitHub holds only the deploy key, the server's address and its host key.
| Setting | Default | Purpose |
|---|---|---|
Jwt__ExpiryMinutes |
30 locally and in production | Lifetime of each access token, including a renewed one |
Jwt__RenewalCapHours |
12 | Maximum time since sign-in, password change or reset during which a valid token may renew; plain setting, not a secret |
Restore__MaxBytes optionally overrides the JSON restore upload limit (default 26214400, 25 MB). RestoreRateLimit__PermitLimit and RestoreRateLimit__WindowSeconds default to five attempts per ten minutes per signed-in account. Over-limit uploads return 413.
PRIVACY_LIFECYCLE_ENABLED switches on the privacy and account lifecycle feature (notice, optional-details consent 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 production, by deploy/compose.prod.yml. It makes the per-IP login/register limit key on the client address Caddy puts in X-Forwarded-For, instead of on Caddy 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.
Outgoing email (milestone 12, specs/002-email-login/) is configured by Email__Backend: console writes each message, link included, to the API's log (docker compose logs backend); memory keeps messages for the tests; resend sends through Resend and needs RESEND_API_KEY, EMAIL_FROM and APP_URL (the frontend origin links point at). The app refuses to start with console or memory in Production, or with resend missing any of its three settings. EMAIL_DAILY_CAP (default 90) bounds what the whole app sends in 24 hours; one address gets at most five. Signup, "send the link again" and "Forgot your password?" send email. To confirm an account locally with the console backend, copy the verify-email#token=… link from docker compose logs backend | grep verify-email (or the SDK run's console) into the browser; a reset link is found the same way with grep reset-password.
Signup and "Forgot your password?" are behind a Cloudflare Turnstile bot check when TURNSTILE_SECRET_KEY is set on the API. Empty (the default locally and in the tests) switches it off. With a secret, TURNSTILE_HOSTNAMES (comma-separated frontend hostnames a token may come from) is required, or the API refuses to start. If Cloudflare can't be reached, those two forms are refused (400 captcha) rather than let through. The frontend shows the widget only when it has the public site key: TURNSTILE_SITE_KEY in the container (rendered into config.js), or VITE_TURNSTILE_SITE_KEY for the Vite dev server. Set the API and frontend halves together. To try it locally, use Cloudflare's always-pass test pair: site key 1x00000000000000000000AA, secret 1x0000000000000000000000000000000AA, with TURNSTILE_HOSTNAMES=example.com (the hostname those test answers carry).
Upgrading a local database to email sign-in: the migration that adds email refuses to run while users has rows, since existing username accounts have no address to give them. Start from an empty database: docker compose down -v (this deletes the local notebook data too).
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. Production caps the Npgsql pool at 10 connections (Maximum Pool Size=10, so the app stays within its share of the database service it shares with the Subscription Tracker; deploy/README.md → Environment); the default elsewhere is 100. Ten requests in flight at once is well above this app's traffic, and the 100,000-set restore and export ran within it, 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:18 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, backup; download.ts saves the backup 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 Backup — plus the tested helpers they share (formatting, chart geometry, drafts,
the backup CSV writer and file pre-check)
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
deploy/ production on UpCloud beside the Subscription Tracker (spec 005): Compose file, Caddy site,
deploy.sh, the log trim job and its systemd units; runbook in deploy/README.md
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/memory/ Spec Kit project constitution (the rest of .specify/ is regenerated locally)
.github/workflows/ test.yml (format checks + tests), build-and-push.yml (images to GHCR), deploy.yml (to UpCloud over
SSH, after the images are built)
.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
AI-tooling folders (.agents/, and everything in .specify/ except memory/) are git-ignored and installed per machine. To restore them, reinstall the skills listed in skills-lock.json and run specify init --here for Spec Kit.
Milestones 1–10, 12, 14 and 15 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 UpCloud in Helsinki at https://gymnotebook.fit (moved from Azure and Neon on 2026-10-12, specs/005-upcloud-migration/). Signup is open: accounts sign in by email, confirm their address by an emailed link, can reset a forgotten password, and a Cloudflare Turnstile check guards the two forms that send email (specs/002-email-login/).
Milestone 11, privacy and account lifecycle, is in progress through Spec Kit (specs/001-privacy-account-lifecycle/). The notice, deletion and optional-details consent are implemented and remain switched off in production behind PRIVACY_LIFECYCLE_ENABLED. Provider, database-restore, deployed performance and owner walkthrough evidence is still needed before the production switch; track it in the release checklist. Spec 004 makes the export, backup-status and user-upload restore APIs independent of that flag; its owner-signed processing-decision amendment is required before deployment. The backup/restore screen follows in later PRs. The full milestone log is in PLAN.md → Milestones.