Skip to content

Repository files navigation

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

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

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

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

Tests

dotnet test backend/GymNotebook.sln

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

Status

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.

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