- Python 47.1%
- JavaScript 39.8%
- CSS 12.7%
- Dockerfile 0.3%
- HTML 0.1%
Naive taken_at values sent to log_dose/log_group/update_dose_log were
previously stored verbatim with no timezone interpretation, and
doses_today used SQLite's UTC date('now') — both wrong once "today"
and "now" are meant to reflect the app's configured timezone rather
than assumed UTC. The frontend had no timezone awareness at all,
relying on each browser's own local clock for every displayed time,
day grouping, chart bucket, and the "Earlier…" backdating picker.
Backend: settings.py gains get_timezone()/normalize_taken_at() as the
single chokepoint doses are converted to UTC through (reused by
chat.py, replacing its own duplicate conversion); doses_today now uses
a real timezone-aware day boundary; a public /api/auth/timezone
endpoint exposes the configured zone (default America/Boise) to the
frontend.
Frontend: a new TimezoneContext + utils/time.js (date-fns-tz) drive
every clock display, day grouping, chart bucket, and backdating picker
in the app's configured timezone instead of the browser's local one.
Relative-time displays needed no changes, being already
timezone-agnostic instant diffs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
||
|---|---|---|
| .claude | ||
| .forgejo/workflows | ||
| .idea | ||
| backend | ||
| frontend | ||
| .env | ||
| .gitignore | ||
| docker-compose.yml | ||
| README.md | ||
MedSync — Medication Tracker
A self-hosted medication tracker PWA with bottle scanning via Claude AI, push notifications, and optional SMS / ntfy alerts.
Stack
| Layer | Tech |
|---|---|
| Backend | Python 3.12 · FastAPI · APScheduler |
| Database | SQLite (via Python stdlib) |
| Frontend | React 18 · Vite · PWA (service worker) |
| Serving | nginx (frontend) · uvicorn (backend) |
| Infra | Docker Compose |
Quick Start
1. Generate secrets
# JWT secret
openssl rand -hex 32
# VAPID keys for push notifications
pip install pywebpush
python -c "from py_vapid import Vapid; v = Vapid(); v.generate_keys(); print('Public:', v.public_key); print('Private:', v.private_key)"
# OR use the web tool: https://vapidkeys.com
2. Configure environment
cp .env.example .env
# Edit .env and fill in all values
3. Build and run
docker compose up -d --build
Frontend: http://localhost:3000
API docs: http://localhost:3001/docs (FastAPI auto-generated Swagger UI)
Environment Variables
| Variable | Required | Description |
|---|---|---|
JWT_SECRET |
✅ | Random secret for signing tokens |
ANTHROPIC_API_KEY |
✅ | For pill bottle scanning |
VAPID_PUBLIC_KEY |
✅ | For browser push notifications |
VAPID_PRIVATE_KEY |
✅ | For browser push notifications |
VAPID_EMAIL |
✅ | mailto:you@domain.com |
TWILIO_ACCOUNT_SID |
➖ | Optional — enables SMS alerts |
TWILIO_AUTH_TOKEN |
➖ | Optional — enables SMS alerts |
TWILIO_FROM_NUMBER |
➖ | Optional — your Twilio phone number |
NTFY_SERVER_URL |
➖ | Initial ntfy server (admin-editable later; default https://ntfy.sh) |
NTFY_TOKEN |
➖ | Optional — bearer token for a private server |
NTFY_USERNAME |
➖ | Optional — username for a private server (HTTP Basic) |
NTFY_PASSWORD |
➖ | Optional — password for a private server (HTTP Basic) |
NTFY_PRIORITY |
➖ | Optional — message priority (default default) |
OIDC_ISSUER |
➖ | Optional — OIDC issuer URL (enables SSO) |
OIDC_CLIENT_ID |
➖ | OIDC client id |
OIDC_CLIENT_SECRET |
➖ | OIDC client secret |
OIDC_REDIRECT_URI |
➖ | https://<host>/api/auth/oidc/callback |
OIDC_PROVIDER_NAME |
➖ | Button label (default Authentik) |
FRONTEND_URL |
➖ | Only if frontend ≠ API origin |
OIDC Single Sign-On (Authentik)
MedSync can tie accounts to an OIDC provider such as Authentik. When configured, the login page shows a Sign in with Authentik button and existing users can connect their identity from Settings → Connected accounts.
- In Authentik, create an OAuth2/OpenID confidential provider:
- Redirect URI:
https://<your-host>/api/auth/oidc/callback - Scopes:
openid profile email
- Redirect URI:
- Copy the client id/secret and the provider's issuer URL into
.env(OIDC_ISSUER,OIDC_CLIENT_ID,OIDC_CLIENT_SECRET,OIDC_REDIRECT_URI). - Restart the backend. The feature auto-enables once these are set.
Account linking behaviour:
- A first OIDC login auto-links to an existing account when the
emailclaim (or, failing that,preferred_username) matches a MedSync user. - Otherwise a new account is provisioned from the OIDC claims (no local password).
- Logged-in users can explicitly connect/disconnect from Settings.
Username/password login keeps working alongside SSO.
Admin & User Management
The first account created (whether by registration or OIDC sign-in) automatically becomes an admin. On an existing database with no admin, the earliest user is promoted on next startup.
Admins get an Admin tab where they can:
- Create accounts (optionally as admins)
- Promote / demote other users
- Reset a user's password
- Delete accounts
The app refuses to demote or delete the last remaining admin, and you cannot delete your
own account. Admins can also set the ntfy server URL from the Admin tab; this is stored in
the database and overrides the NTFY_SERVER_URL env var (which only seeds the initial value).
Public API (for other apps)
MedSync exposes a small, scoped API so other apps and scripts can view your medications and log doses without your login credentials.
📖 Full reference: https://your-host/api/public/docs — a self-contained page
covering every key-accessible endpoint, its parameters, response fields, and
worked curl examples. It needs no login, so you can hand the URL to whoever is
writing the integration. The summary below is just an orientation.
API keys
Generate a key by calling the management endpoints with a normal logged-in session (JWT). Keys are shown once at creation — store them securely.
# Create a key (returns the plaintext "key" exactly once)
curl -X POST https://your-host/api/auth/api-keys \
-H "Authorization: Bearer <your-jwt>" \
-H "Content-Type: application/json" \
-d '{"name": "Home Assistant"}'
# → {"id": 1, "name": "Home Assistant", "key_prefix": "msk_AbC12345", "key": "msk_…"}
curl https://your-host/api/auth/api-keys -H "Authorization: Bearer <your-jwt>" # list
curl -X DELETE https://your-host/api/auth/api-keys/1 -H "Authorization: Bearer <your-jwt>" # revoke
Only the SHA-256 hash of a key is stored; a lost key can only be replaced, not recovered. Managing keys requires a real login — an API key cannot mint or revoke keys.
Using a key
Pass the key as a bearer token, exactly like a JWT:
Authorization: Bearer msk_…
Scope — an API key can only view and log. It cannot delete medications,
edit your profile, manage caretakers, or reach admin settings; those stay
login-only. A key inherits the owner's caretaker access, so for_user_id works
for patients they care for. Use GET /api/caretaker/patients to discover those
patient ids — it lists who the key's owner cares for, but linking and unlinking
patients remains login-only.
| Capability | Endpoint |
|---|---|
List the patients you're a caretaker for (id, username, display_name) |
GET /api/caretaker/patients |
List medications & supplements (with next_due / overdue) |
GET /api/medications |
| List groups and their members | GET /api/groups |
| Dose history | GET /api/medications/history?days=7 |
Log a dose (now, or backdated via taken_at) |
POST /api/medications/{id}/log |
| Log every med in a group at once | POST /api/groups/{id}/log |
Supplements are simply medications with category: "supplement", so they appear
in the same lists. When a medication is next due is returned as the
next_due (ISO-8601) and overdue (boolean) fields on each medication;
next_due is null for paused meds, as-needed (PRN) meds, and ones never taken.
overdue means "MedSync would chase you for this", not merely "past its time":
it is false for an as_needed med (no schedule at all) and for one with
reminders_enabled: 0 (muted on purpose, so it still reports a past next_due
without ever alerting). Compare next_due to the clock yourself if you want raw
lateness rather than MedSync's judgement.
# View what's due
curl https://your-host/api/medications -H "Authorization: Bearer msk_…"
# Log a dose taken two hours ago
curl -X POST https://your-host/api/medications/42/log \
-H "Authorization: Bearer msk_…" -H "Content-Type: application/json" \
-d '{"taken_at": "2026-07-14T10:00:00+00:00"}'
Interactive docs for all endpoints are at /docs (Swagger UI).
Android PWA Setup
- Open Chrome on Android and navigate to your server URL
- Tap the ⋮ menu → "Add to Home screen"
- The app installs like a native app with push notification support
- First launch: tap Enable Notifications in the app settings
Project Structure
medsync/
├── docker-compose.yml
├── .env.example
├── backend/
│ ├── Dockerfile
│ ├── requirements.txt
│ └── app/
│ ├── main.py # FastAPI app + lifespan
│ ├── db/
│ │ └── schema.py # SQLite init + context manager
│ ├── routes/
│ │ ├── auth.py # Register, login, profile
│ │ ├── admin.py # Admin user management
│ │ ├── medications.py # CRUD + dose logging
│ │ ├── public_docs.py # Public API docs page (/api/public/docs)
│ │ └── scan.py # Claude Vision bottle scan
│ └── services/
│ ├── auth.py # JWT + bcrypt
│ ├── notifications.py # WebPush + Twilio SMS + ntfy
│ └── scheduler.py # APScheduler — checks every 5 min
└── frontend/
├── Dockerfile
├── nginx.conf
├── vite.config.js # Vite + PWA plugin
└── src/
├── services/api.js # All API calls
├── contexts/AuthContext.jsx
└── hooks/usePushNotifications.js
Development (without Docker)
Backend:
cd backend
pip install -r requirements.txt
cp ../.env.example .env # fill in values
uvicorn app.main:app --reload --port 3001
# Swagger UI at http://localhost:3001/docs
Frontend:
cd frontend
npm install
npm run dev # proxies /api to localhost:3001
Reverse Proxy (recommended for production)
Put nginx or Caddy in front to add HTTPS — required for:
- Camera access on Android Chrome
- Push notifications (requires secure context)
Example Caddy snippet:
medsync.yourdomain.com {
reverse_proxy localhost:3000
}