No description
  • Python 55.9%
  • JavaScript 35.6%
  • CSS 7.9%
  • Dockerfile 0.4%
  • HTML 0.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Brian 123de1ce2a
All checks were successful
Build and Deploy / test-backend (push) Successful in 54s
Build and Deploy / build-backend (push) Successful in 8s
Build and Deploy / build-frontend (push) Successful in 9s
Build and Deploy / deploy (push) Successful in 9s
Show today's saved notes on first load
DayPanel seeded the notes textarea from an effect keyed only on `date`.
On first paint the selected day is already today while entries is still
empty, so the box seeded to '' and the fetched note, arriving without a
date change, never reached it. Reselecting the day changed `date` and
made the note appear, which is what made this look intermittent.

Watch `entry?.notes` as well, and adopt a same-day value only while the
textarea still holds what we last seeded and no debounce is pending, so
a load or a save echo can't yank the cursor mid-typing. Clear the
debounce handle when it fires, too: it stayed a stale non-null id after
the first save, which would have blocked re-seeding for that day and
made flushNotes' guard misread.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 21:59:44 -06:00
.forgejo/workflows Report Portainer's error instead of hiding it, and define HEALTHTRACKER_PORT 2026-08-26 15:23:24 -06:00
backend Initial commit: period, migraine and acne tracker 2026-08-26 10:57:06 -06:00
deploy Fix frontend crash loop when the backend is absent 2026-08-26 15:16:37 -06:00
frontend Show today's saved notes on first load 2026-08-26 21:59:44 -06:00
.gitignore Initial commit: period, migraine and acne tracker 2026-08-26 10:57:06 -06:00
docker-compose.yml Initial commit: period, migraine and acne tracker 2026-08-26 10:57:06 -06:00
README.md Fix frontend crash loop when the backend is absent 2026-08-26 15:16:37 -06:00

Health Tracker

A self-hosted tracker for periods, migraines and acne, with the medications you actually took pulled in from MedSync.

One row per day, four things on it — flow, migraine pain, acne stage, and a note — rendered as a calendar you can read at a glance, plus a cycle clock that answers the question the two trackers exist to answer together: where in the cycle do the migraines land?

Stack

Layer Tech
Backend Python 3.12 · FastAPI
Database SQLite (stdlib sqlite3)
Frontend React 18 · Vite
Serving nginx (frontend) · uvicorn (backend)
Infra Docker Compose

Quick start

docker compose up -d --build

Open http://localhost:3010. There is nothing to configure first — no .env, no secrets to generate.

  1. The first account you create becomes the admin. That is the whole bootstrap.
  2. As that admin, go to Admin → Deployment and set the MedSync server URL. "Test connection" checks it before you save.
  3. Everyone (admin included) then goes to Settings → MedSync and pastes their own API key, generated in MedSync under Settings → API keys.
  4. Settings → Medications on the calendar picks which medications show up.

Sign-ups are closed by default: the admin creates accounts under Admin → Accounts. Flip Sign-ups to "Anyone can sign up" if you'd rather people register themselves.

No environment variables

Every setting that matters — the MedSync URL, the app name, whether sign-ups are open — lives in the database and is edited from the admin UI, so changing one never means editing a file and redeploying.

The two secrets the app needs (a JWT signing key and the key that encrypts users' MedSync API keys) are generated on first boot into backend/data/secret.key, mode 0600. You never see or set them.


The trackers

Tracker Values
Flow spotting · light · medium · heavy
Migraine pain 1–10
Acne soreness begins · acne visible · completely gone
Notes free text

All four are optional and independent. Clicking the value that's already set clears it. A day with nothing set stores no row at all, so "untracked" and "tracked as nothing" never get confused.

Reading the calendar. Flow is a wash across the whole day cell, getting stronger with the level. Migraine is a chip in the corner showing the number. Acne is a mark whose shape carries the stage — a ring is under the skin, a filled dot has surfaced, a faded ring has cleared — so it reads without relying on colour. Medications are small pills along the bottom, one per dose, in each medication's own colour. A hairline at the base means there's a note.

Dim mode. The moon in the top bar drops the whole interface a stop. Photophobia is a migraine symptom, and a migraine tracker you can't look at during a migraine isn't much use.


Dates, times and timezones

Two different kinds of time are in play here, and they're stored differently on purpose.

  • The trackers are calendar dates. "The day I had a migraine" is a date, not an instant — it must not shift when you travel. Day entries are stored as a plain local YYYY-MM-DD.
  • Doses are instants. MedSync stores taken_at in UTC, and that's how it reaches the browser. Bucketing a dose into the right day is done in the frontend, in the timezone on your profile.

That's why Settings → Timezone matters: it decides which calendar day a 10pm dose belongs to. The backend uses it to widen the window it asks MedSync for, so a dose logged at 02:30 UTC still lands on the previous evening in New York.


MedSync integration

The admin sets the server URL once; each user brings their own API key. The two are separate on purpose — a key is personal, and only shows that person's medications.

Only two MedSync endpoints are ever called, both read-only:

Purpose MedSync endpoint
The list you pick tracked meds from GET /api/medications
Doses for the visible days GET /api/medications/history?start=&end=

Keys are verified against MedSync at the moment you enter one, so a typo fails immediately rather than showing up weeks later as an empty column. They are stored as Fernet ciphertext, never in the clear.

If MedSync is unreachable, misconfigured, or your key has been revoked, the calendar still works — it just says so instead of showing doses.


Single sign-on (Authentik)

Admin only, under Admin → Single sign-on. Once configured, the sign-in screen leads with a Sign in with Authentik button and passwords keep working alongside it.

Setting it up

  1. In Authentik, create an OAuth2/OpenID confidential provider.
  2. Give it the redirect URI the admin panel shows you — it is https://<your-host>/api/auth/oidc/callback, and it has to match character for character.
  3. Scopes: openid profile email. openid is required; email is what lets an existing account be recognised automatically.
  4. Paste the issuer URL, client ID and client secret into Admin → Single sign-on and press Test connection, which fetches the provider's discovery document and reports what it found.

The issuer is the URL Authentik shows on the provider page, usually https://auth.example.com/application/o/<slug>/. Pasting the .well-known/openid-configuration URL instead works — it gets trimmed back.

The client secret is stored encrypted and never sent back to a browser.

Who gets an account

When someone signs in through the provider for the first time:

  1. If that provider identity is already linked to an account, they sign into it.
  2. Otherwise, if their email matches an account here, that account is adopted and linked. Failing that, their username is tried.
  3. Otherwise a new account is created — unless you set People who have never signed in here to Are turned away, which limits sign-in to accounts that already exist.

Identities are keyed on issuer + subject, not on email, because that pair is what the provider guarantees is stable — people change their email address.

An account created this way has no password, so single sign-on is its only way in and it cannot disconnect itself into a lockout. An admin setting a password for it (Admin → Accounts → Reset password) fixes that and unlocks disconnecting.

Anyone with a password account can connect their identity from Settings → Single sign-on, and disconnect it again.

How the flow is secured

  • PKCE (S256). The code verifier is held in a signed, httpOnly cookie and never sent to the provider, so an intercepted authorization code can't be redeemed by anyone else.
  • State is an opaque id compared against that cookie, which stops a cross-site request from completing someone else's sign-in.
  • Nonce is checked against the ID token, so a token captured from an earlier sign-in can't be replayed.
  • The ID token's signature is verified against the provider's JWKS, with issuer and audience checked. Key rotation heals itself.
  • UserInfo can fill in a profile but never change identity. Its response is unsigned, so it is only consulted when the ID token carries no email or username, only its email, preferred_username and name are taken, and only when its sub matches the ID token's.
  • The token comes back in the URL fragment, which browsers never send to a server and which doesn't leak through Referer. The app clears it from the address bar immediately.

Backup

Backup in the nav. Everyone sees their own data there; everything deployment-wide is admin-only.

  • Download my data — your days and medication selection as readable JSON. No passwords, no API keys; safe to keep anywhere. Restores with either merge (days in the file win, everything else survives) or replace.
  • Export everything (JSON) — admin only. Every account and every day, including password hashes, so a restore brings accounts back exactly as they were. Treat the file as a secret.
  • Download database — admin only. The SQLite file itself, copied with VACUUM INTO so the snapshot is transactionally consistent even while the app is being written to. (Copying the .db file by hand is not safe.)

Scheduled backups to Nextcloud

Admin only, under Backup → Scheduled backups. Daily or weekly, at a time in a timezone you pick, uploaded to Nextcloud over WebDAV.

Each run produces one .tar.gz:

Inside What it is
healthtracker.sqlite the database, via VACUUM INTO
healthtracker-full.json the same data readable, for restores without SQLite
manifest.json what's in the archive, and what deliberately isn't
secret.key only if you switch it on — see below

Setting up the destination. In Nextcloud, go to Settings → Security → Devices & sessions and create an app password. Use that, never your login password: it can be revoked on its own and it works with two-factor enabled. Then fill in Backup → Destination and press Test connection, which reaches the server, signs in, creates the folder and lists it — so you find out the path works before trusting a schedule to it. Back up now runs the whole thing immediately.

Keep sets how many archives stay on Nextcloud; older ones are deleted after each successful run. 0 keeps everything.

Recent runs lists every attempt, scheduled or manual, with what went wrong when something did. A failed scheduled run is recorded rather than raised, so the history is the one place to look.

About secret.key. It decrypts the MedSync API keys held in the database. Including it in an archive puts both halves in the same place — anyone who reaches your backup folder gets both — so it is off by default. Left out, everything else restores fine and each person re-enters their MedSync key once. Turn it on only if the backup folder is somewhere you'd be comfortable storing the keys themselves.

The Nextcloud app password is never included in any export, and never sent back to the browser: putting a working credential for the backup store inside the backup would hand over the whole store to anyone who opened one archive.

The simplest complete local backup is still the whole backend/data/ directory, which holds both the database and the key.


Development

Backend

cd backend
python -m venv .venv && .venv/bin/pip install -r requirements-test.txt
HEALTHTRACKER_DATA_DIR="$PWD/data" .venv/bin/uvicorn app.main:app --reload --port 3011

Swagger UI at http://localhost:3011/docs.

Frontend

cd frontend
npm install
npm run dev     # http://localhost:5183, proxies /api to :3011

Tests

cd backend && .venv/bin/python -m pytest tests/ -q

tests/test_medsync_contract.py runs the real HTTP client against a stub whose response shapes are copied from MedSync's own SQL, and tests/test_scheduled_backup.py runs the real uploader against a WebDAV stub, and tests/test_oidc.py runs the real sign-in flow against a stub provider that signs genuine RS256 ID tokens and enforces PKCE — all over real sockets, rather than with the clients mocked out.

Demo data. python backend/scripts/seed_demo.py fills a running instance with a year of plausible cycles, migraines and acne (account geo / trackerdemo1) so the calendar and the cycle clock have something to show. To wipe everything and start clean, stop the app and delete backend/data/ — the database and secret key are both regenerated on next boot.

Fonts are vendored into frontend/public/fonts rather than loaded from Google — a health app has no business making third-party requests on every page load. Regenerate with python frontend/scripts/fetch-fonts.py from the frontend directory.


Deployment (Forgejo → Portainer)

.forgejo/workflows/build-deploy.yaml mirrors medsync's pipeline: push to master, and CI runs the tests, builds both images, pushes them to git.thenymans.com, and tells Portainer to redeploy.

Job What it does
test-backend pytest on python:3.12-slim
build-backend buildx → git.thenymans.com/brian/healthtracker-backend:{latest,sha}
build-frontend buildx → git.thenymans.com/brian/healthtracker-frontend:{latest,sha}
deploy pulls both images on the Portainer host, then PUTs the stack

It uses the same secrets the medsync repo already has — REGISTRY_USER, REGISTRY_PASSWORD and PORTAINER_TOKEN — so there is nothing new to create if they are set at the user or organisation level.

Differences from the medsync pipeline

  • No private package index. Every dependency is on public PyPI and npm, so the builds need no --secret plumbing and the test job needs no credentials.
  • No environment variables. Settings live in the database behind the admin login, so the compose file has nothing to interpolate. The one exception is HEALTHTRACKER_PORT, which sets the published port and defaults to 3010.
  • The backend port is not published. nginx reaches it over the compose network, so the API has no second unprotected door on the host. Medsync publishes 3001; this publishes only the frontend.
  • The stack is found by name, not id. No stack id to look up and paste in, and a stack renamed in Portainer fails loudly instead of redeploying whatever now sits at a hardcoded id.

First deploy

The workflow updates an existing stack but will not invent one, so create it once:

  1. Portainer → Stacks → Add stack, named exactly healthtracker.
  2. Paste deploy/portainer-stack.yml — not docker-compose.yml. The root compose file has build: keys for local development, and Portainer's web editor has no build context, so those services never come up. The deploy job ships this same file on every push, so the two cannot drift.
  3. Optionally add a HEALTHTRACKER_PORT env var on the stack if 3010 clashes with something.
  4. Deploy, then push to master. Every push after that updates it.

Then open the app and create the first account — that one becomes the admin.

If the frontend crash-loops

host not found in upstream "backend" means no container named backend is on the stack's network — almost always because the backend service never started. The usual cause is deploying the root docker-compose.yml into Portainer, whose build: keys it cannot honour; use deploy/portainer-stack.yml.

nginx itself no longer dies from this: it resolves the backend at request time through a variable, so a missing or restarting backend produces a 502 while the app keeps loading, and a recreated backend container's new IP is picked up automatically. The resolver address is read from the container's own /etc/resolv.conf at startup rather than hardcoded to Docker's 127.0.0.11, so the same image behaves under Podman too.

The volume is the backup

healthtracker-data holds healthtracker.db and secret.key, and that key is what decrypts the stored MedSync API keys and the Nextcloud app password. Back up the whole volume. Restoring the database alone works, but everyone re-enters their MedSync key afterwards.


Reverse proxy

Put Caddy or nginx in front for HTTPS:

health.yourdomain.com {
    reverse_proxy localhost:3010
}

Project structure

healthtracker/
├── docker-compose.yml            # local development (builds from source)
├── deploy/portainer-stack.yml    # what Portainer runs (images only)
├── .forgejo/workflows/build-deploy.yaml
├── backend/
│   ├── Dockerfile
│   ├── requirements.txt
│   ├── app/
│   │   ├── main.py
│   │   ├── db/schema.py          # tables + connection helper
│   │   ├── routes/
│   │   │   ├── auth.py           # register (first user = admin), login, profile, API key
│   │   │   ├── entries.py        # the day trackers
│   │   │   ├── meds.py           # tracked medications + dose lookup
│   │   │   ├── insights.py       # cycles, period lengths, cycle-day breakdown
│   │   │   ├── backup.py         # export / restore, user and deployment level
│   │   │   └── admin.py          # deployment settings + accounts
│   │   └── services/
│   │       ├── auth.py           # bcrypt + JWT
│   │       ├── settings.py       # DB-backed settings, secret.key, encryption
│   │       └── medsync.py        # the MedSync client
│   └── tests/
└── frontend/
    ├── Dockerfile · nginx.conf.template
    ├── public/fonts/             # vendored, no third-party requests
    └── src/
        ├── App.jsx
        ├── index.css             # design tokens
        ├── lib/                  # api, dates, trackers, prefs
        └── components/
            ├── CalendarView.jsx · MonthGrid.jsx · DayPanel.jsx
            ├── InsightsView.jsx · CycleClock.jsx
            ├── SettingsView.jsx · AdminView.jsx
            ├── BackupView.jsx · BackupSchedule.jsx
            └── SsoSettings.jsx · SsoAccount.jsx