- TypeScript 54.2%
- Rust 32.8%
- CSS 8.8%
- Dockerfile 3.6%
- HTML 0.6%
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. |
||
|---|---|---|
| .forgejo/workflows | ||
| backend | ||
| frontend | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| docker-compose.yml | ||
| README.md | ||
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 thebackendservice. This is why the frontend image is built withVITE_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/...(thedbhostname resolves over the externalpostgres-sharednetwork to the shared instance). Not published — onlyfrontend's nginx needs to reach it, atbackend:3001. - postgres — not part of this repo at all. The backend connects to the
shared
pg-sharedPostgres instance over the externalpostgres-sharednetwork (DATABASE_URL=postgres://...@db:5432/...). Schema is applied by the backend binary itself on every startup (sqlx::migrate!()inbackend/src/main.rs, before it starts serving), which applies any pending migrations frombackend/migrations/and no-ops if the schema's already up to date — including against the existing shared instance'shormone_levels/medications/reference_rangestables, since the init migration'sCREATE TABLE IF NOT EXISTSguards 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_PASSWORDfrom the.env.exampledefault before deploying anywhere beyond your own machine.