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 write a full-stack app in C# by hand, 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 for the eventual Azure deploy) |
| 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 | GitHub Actions: format check + tests for both backend and frontend on every push and PR, required on main |
| Hosting | Azure Container Apps + Neon (later milestone) |
- .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; VITE_API_URL is baked in at image build time (Compose passes it as a build arg from .env), which is why --build is needed after changing it — milestone 6 replaces that with a runtime setting.
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 | Autocomplete, scoped to the caller; each result carries its lastSet |
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 |
Every route under /exercises and /workouts answers 404 for anything the caller doesn't own. The progress endpoint (GET /exercises/{id}/history) is specified in PLAN.md → REST API and arrives with milestone 8.
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.
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 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
entrypoint.sh applies migrations, then starts the app
frontend/
src/api/ client.ts (the one fetch wrapper) + auth.ts (wire types and calls)
src/auth/ token.ts (where the JWT lives) + requireAuth.ts (route-guard loader)
src/screens/ Login, Cover, ChangePassword — one .tsx (+ .css) per screen
src/styles/ tokens.css (design tokens lifted from the prototype) + base.css
src/routes.tsx route table; main.tsx mounts the RouterProvider
Dockerfile multi-stage: node builds, nginx serves dist/ (nginx.conf: SPA fallback)
eslint.config.js typescript-eslint (type-checked) + react-hooks + prettier
docs/ui/ UI specification and a clickable HTML prototype
.github/workflows/ test.yml — backend and frontend jobs: format checks + tests
.githooks/ pre-commit formatter (dotnet format + prettier)
docker-compose.yml frontend + backend + postgres for local dev
PLAN.md design, decisions and milestone log
Milestones 1–6 are done. The backend and frontend are containerized and the CI/CD pipeline publishes images to GHCR on every push to main. Next is milestone 7: log a workout end-to-end — the "new workout" page with exercise autocomplete, sets, and session save. The full list with what each milestone turned out to involve is in PLAN.md → Milestones.