No description
  • TypeScript 85.6%
  • CSS 9.8%
  • Dockerfile 3.9%
  • HTML 0.7%
Find a file
Freydis Mende 151210b45e
All checks were successful
CI / Backend – install & build (push) Successful in 19s
CI / Frontend – install, lint & build (push) Successful in 16s
CI / Docker Compose – build images (push) Successful in 3s
CI / deploy (push) Successful in 1s
Exclude backend from Watchtower auto-updates
2026-08-15 15:37:25 +02:00
.forgejo/workflows Migrate frontend and backend to TypeScript, add zod request validation 2026-08-14 22:57:13 +02:00
backend Migrate frontend and backend to TypeScript, add zod request validation 2026-08-14 22:57:13 +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 Initial commit: Hormone Tracker 2026-07-24 17:41:43 +02:00
.gitignore Initial commit: Hormone Tracker 2026-07-24 17:41:43 +02:00
docker-compose.yml Exclude backend from Watchtower auto-updates 2026-08-15 15:37:25 +02:00
README.md Integrate Prisma, replacing raw pg queries in the backend 2026-08-14 22:32:51 +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: 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 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 itself: on every container start it runs npx prisma migrate deploy (see backend/Dockerfile) before starting the server, which applies any pending migrations from backend/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_PASSWORD from the .env.example default before deploying anywhere beyond your own machine.