Skip to content

About

Digital replacement for a paper gym notebook: log workouts (exercises, sets, reps, weight) and visualize strength progress over time. Learning project — C#/.NET 10 Minimal API + EF Core, React/TypeScript (Vite), PostgreSQL, Docker Compose.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Gym Notebook app icon

Gym Notebook

build test deploy

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.

Stack

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

Prerequisites

  • .NET 10 SDK
  • Node.js 24 (for the frontend; CI pins the same major)
  • Docker (for Compose, and for the Testcontainers-based test suite)

Running it

With Docker Compose (frontend + backend + Postgres)

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:3000

The 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.

From the SDK (for development and the API docs)

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.Api

The app listens on http://localhost:5217 (the http profile in launchSettings.json).

Frontend (Vite dev server)

cd frontend
npm install
npm run dev

Vite 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.

On a phone

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.

API documentation

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:

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.

Configuration

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.

Tests

dotnet test backend/GymNotebook.sln

Docker 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 tests

Code style

C# 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 .githooks

CI 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/.

Repository layout

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.

Status

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.

License

MIT

About

Digital replacement for a paper gym notebook: log workouts (exercises, sets, reps, weight) and visualize strength progress over time. Learning project — C#/.NET 10 Minimal API + EF Core, React/TypeScript (Vite), PostgreSQL, Docker Compose.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages