- TypeScript 85.6%
- CSS 9.8%
- Dockerfile 3.9%
- HTML 0.7%
| .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: Node.js + Express + Prisma (
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 (npm, hot reload) or the full Docker Compose stack (for deploying to Portainer or any other Docker host).
Local development
Prerequisites
- 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
npm install # also runs `prisma generate`
npx prisma migrate deploy # applies backend/prisma/migrations/ (creates the schema)
# 2. Frontend
cd ../frontend
npm install
Schema changes go through Prisma Migrate (backend/prisma/schema.prisma +
backend/prisma/migrations/) instead of a hand-written SQL file — run
npx prisma migrate dev from backend/ after editing schema.prisma to
create and apply a new migration.
Running
Open two terminals:
# Terminal 1 — backend API (http://localhost:3001)
cd backend
npm run dev
# 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 itself: on every container start it runsnpx prisma migrate deploy(seebackend/Dockerfile) before starting the server, which applies any pending migrations frombackend/prisma/migrations/and no-ops if the schema's already up to date.
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.
First deploy against the existing shared instance: that Postgres already
has the hormone_levels/medications/reference_ranges tables from the old
schema.sql-based init, but no _prisma_migrations bookkeeping table yet.
Before the first deploy of this Prisma-based backend, baseline it once so
migrate deploy doesn't try to recreate tables that already exist:
cd backend
DATABASE_URL=<the shared instance's connection string> \
npx prisma migrate resolve --applied 20260814201325_init
After that, npx prisma migrate deploy (including the automatic one in the
container's startup command) is a no-op until a real schema change is added.
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, installs deps,
# runs `prisma migrate deploy` before starting the server
prisma.config.ts # Prisma CLI config (schema path, migrate/studio datasource url)
prisma/
schema.prisma # Data model — source of truth for the DB schema
migrations/ # Prisma Migrate history, applied via `prisma migrate deploy`
src/
index.js # Express app entry point (GET /api/health, GET /api/version)
db.js # Prisma Client singleton (also normalizes DATE columns to/from plain strings)
routes/
levels.js # CRUD for hormone_levels (estradiol/testosterone/progesterone/DHT)
medications.js # CRUD for medications (ongoing regimens: dose, frequency, route, start/end date)
ranges.js # 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.