- Python 55.9%
- JavaScript 35.6%
- CSS 7.9%
- Dockerfile 0.4%
- HTML 0.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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> |
||
| .forgejo/workflows | ||
| backend | ||
| deploy | ||
| frontend | ||
| .gitignore | ||
| docker-compose.yml | ||
| README.md | ||
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.
- The first account you create becomes the admin. That is the whole bootstrap.
- As that admin, go to Admin → Deployment and set the MedSync server URL. "Test connection" checks it before you save.
- Everyone (admin included) then goes to Settings → MedSync and pastes their own API key, generated in MedSync under Settings → API keys.
- 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_atin 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
- In Authentik, create an OAuth2/OpenID confidential provider.
- 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. - Scopes:
openid profile email.openidis required;emailis what lets an existing account be recognised automatically. - 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:
- If that provider identity is already linked to an account, they sign into it.
- Otherwise, if their email matches an account here, that account is adopted and linked. Failing that, their username is tried.
- 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_usernameandnameare taken, and only when itssubmatches 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 INTOso the snapshot is transactionally consistent even while the app is being written to. (Copying the.dbfile 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
--secretplumbing 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 to3010. - 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:
- Portainer → Stacks → Add stack, named exactly
healthtracker. - Paste
deploy/portainer-stack.yml— notdocker-compose.yml. The root compose file hasbuild: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. - Optionally add a
HEALTHTRACKER_PORTenv var on the stack if3010clashes with something. - 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