Skip to content

Repository files navigation

Gym Notebook

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.

Stack

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)

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

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

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.

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

Status

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.

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