No description
  • TypeScript 54.2%
  • Rust 32.8%
  • CSS 8.8%
  • Dockerfile 3.6%
  • HTML 0.6%
Find a file
Freydis Mende ea82cdf6e7
All checks were successful
CI / Frontend – install, lint & build (push) Successful in 17s
CI / Backend – fmt, clippy & build (push) Successful in 1m56s
CI / Docker Compose – build images (push) Successful in 1m35s
CI / deploy (push) Successful in 1s
Replace dtolnay/rust-toolchain action with rustup install in CI
The Forgejo runner resolves third-party actions through data.forgejo.org's
action mirror, which doesn't include dtolnay/rust-toolchain, causing the
backend CI job to fail with a clone 404.
2026-09-18 22:42:10 +02:00
.forgejo/workflows Replace dtolnay/rust-toolchain action with rustup install in CI 2026-09-18 22:42:10 +02:00
backend Rewrite backend in Rust (Axum + sqlx) 2026-09-18 19:56:57 +02:00
frontend Migrate frontend and backend to TypeScript, add zod request validation 2026-08-14 22:57:13 +02:00
.dockerignore Add /api/health and /api/version, bake in git commit hash, add CI 2026-08-14 10:21:05 +02:00
.env.example Make backend's Postgres host/port configurable 2026-09-17 09:24:15 +02:00
.gitignore Rewrite backend in Rust (Axum + sqlx) 2026-09-18 19:56:57 +02:00
docker-compose.yml Make backend's Postgres host/port configurable 2026-09-17 09:24:15 +02:00
README.md Rewrite backend in Rust (Axum + sqlx) 2026-09-18 19:56:57 +02:00

Hormone Tracker

A simple app for logging HRT lab results (estradiol, testosterone, progesterone, DHT) and medications (e.g. "10mg CPA daily since April"), with a dashboard of levels over time and configurable reference ranges to flag out-of-range results.

  • Frontend: React + Vite, served by nginx in production (frontend/)
  • Backend: Rust + Axum + sqlx (backend/)
  • Database: PostgreSQL (a shared instance outside this repo's compose stack — see below)
  • Single user, no login.

There are two ways to run this: local development (hot reload) or the full Docker Compose stack (for deploying to Portainer or any other Docker host).

Local development

Prerequisites

  • Rust (stable toolchain — see rustup.rs) and Node.js 18+
  • A reachable PostgreSQL instance (this repo doesn't run one for you — Postgres lives on a shared instance outside this compose stack, see below)

Setup (first time)

# 1. Backend
cd backend
cp .env.example .env   # point DATABASE_URL at your Postgres instance
cargo build             # fetches and compiles dependencies

# 2. Frontend
cd ../frontend
npm install

Migrations (backend/migrations/) run automatically on startup via sqlx::migrate!() (see backend/src/main.rs) — no separate command needed. Schema changes go through a new migration file: install sqlx-cli (cargo install sqlx-cli --no-default-features --features postgres,rustls) and run sqlx migrate add <name> from backend/ to scaffold one.

Running

Open two terminals:

# Terminal 1 — backend API (http://localhost:3001)
cd backend
cargo run

# Terminal 2 — frontend (http://localhost:5173)
cd frontend
npm run dev

Then open http://localhost:5173.

Deploying with Docker Compose / Portainer

docker-compose.yml builds and runs the frontend and backend as containers — this is what you'd deploy on Portainer. Postgres itself is not part of this stack; both services expect the shared pg-shared instance to already be reachable over the external postgres-shared network (see below).

cp .env.example .env   # adjust POSTGRES_PASSWORD, FRONTEND_PORT, etc.
docker compose up -d --build

Then open http://<host>:8080 (or whatever FRONTEND_PORT you set).

In Portainer: create a Stack using the Repository method (git URL to this repo), not "paste the compose file" — the backend/frontend images both build from source in this repo, so Portainer needs the actual files checked out, not just the YAML text. Set the variables from .env.example as stack environment variables, and make sure the pg-shared network already exists on the host.

How it's wired

  • frontend — nginx serves the built React app and reverse-proxies /api/* to the backend service. This is why the frontend image is built with VITE_API_URL=/api (a relative path) instead of a hardcoded host — it works behind any domain/IP without a rebuild. Only this service's port is published to the host.
  • backend — connects to Postgres via DATABASE_URL=postgres://...@db:5432/... (the db hostname resolves over the external postgres-shared network to the shared instance). Not published — only frontend's nginx needs to reach it, at backend:3001.
  • postgres — not part of this repo at all. The backend connects to the shared pg-shared Postgres instance over the external postgres-shared network (DATABASE_URL=postgres://...@db:5432/...). Schema is applied by the backend binary itself on every startup (sqlx::migrate!() in backend/src/main.rs, before it starts serving), which applies any pending migrations from backend/migrations/ and no-ops if the schema's already up to date — including against the existing shared instance's hormone_levels/medications/reference_ranges tables, since the init migration's CREATE TABLE IF NOT EXISTS guards make it safe to run there without any manual baselining step.

Since backend isn't published to the host, docker compose exec is how you'd get a shell into it directly if needed. Publishing its port (see the commented-out lines in docker-compose.yml) is only useful for host-level debugging — the app itself works without it because containers on the same compose network reach each other by service name regardless of what's published.

Stopping

docker compose down

Project structure

docker-compose.yml
.env.example                  # POSTGRES_*, FRONTEND_PORT, VITE_API_URL

backend/
  Dockerfile         # multi-stage: bakes in the git commit hash, builds the
                      # release binary, runs it directly (migrations happen
                      # on startup, see src/main.rs)
  Cargo.toml         # Crate manifest (axum, sqlx, chrono, rust_decimal, ...)
  migrations/        # sqlx migration history, applied via `sqlx::migrate!()` on startup
  src/
    main.rs          # Axum app entry point (GET /api/health, GET /api/version, migrations)
    db.rs            # Postgres connection pool setup
    error.rs         # Shared error type -> JSON error responses
    validation.rs    # Date-only parsing, the falsy-value-means-null helpers
    routes/
      levels.rs      # CRUD for hormone_levels (estradiol/testosterone/progesterone/DHT)
      medications.rs # CRUD for medications (ongoing regimens: dose, frequency, route, start/end date)
      ranges.rs      # GET/PUT reference ranges (lower/upper bound per hormone)

frontend/
  Dockerfile         # multi-stage: vite build -> nginx
  nginx.conf         # serves the SPA, proxies /api/* to the backend service
  src/
    lib/
      hormones.js    # Shared hormone metadata (field name, label, unit, color)
      datetime.js    # Date-only formatting helpers
    components/
      Dashboard.jsx      # Charts + current (active) medications
      LogEntry.jsx       # Forms for lab results and medications
      History.jsx        # Tables of past entries: delete, out-of-range flags, end a medication
      RangesSettings.jsx # Editable lower/upper bound per hormone
      HormoneChart.jsx   # Line chart with shaded reference band
    api.js           # API client

Data model

hormone_levels: entry_date (date, no time), estradiol_pg_ml, testosterone_ng_ml, progesterone_ng_ml, dht_ng_l, notes

medications: name, dose_amount, dose_unit, frequency (free text, e.g. "daily", "every 2 weeks"), method (route), start_date, end_date (null = ongoing), notes

reference_ranges: hormone (estradiol | testosterone | progesterone | dht), lower_bound, upper_bound

Reference ranges are set on the "Reference Ranges" tab. They're optional per hormone — leave a bound blank to skip it. When set, they show as a shaded band on the dashboard charts and flag lab results outside the range (↑/↓) in History and in chart tooltips.

Medications are regimens, not one-off events — log "10mg CPA daily" once with a start date, and it shows up under "Current medications" on the dashboard until you end it (History → Medications → End, or set an end date directly).

Notes

  • No authentication — intended for single-user use on a private/trusted network. If Portainer will expose this beyond your LAN, add auth first.
  • Reference ranges are whatever you enter — the app ships with none configured by default since normal ranges vary by lab and individual context.
  • Change POSTGRES_PASSWORD from the .env.example default before deploying anywhere beyond your own machine.