No description
  • Python 47.1%
  • JavaScript 39.8%
  • CSS 12.7%
  • Dockerfile 0.3%
  • HTML 0.1%
Find a file
Brian d083be7495
All checks were successful
Build and Deploy / build-frontend (push) Successful in 20s
Build and Deploy / test-backend (push) Successful in 1m22s
Build and Deploy / build-backend (push) Successful in 6s
Build and Deploy / deploy (push) Successful in 5s
feat: store all times as UTC, display in a configurable app timezone
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>
2026-07-21 12:44:23 -06:00
.claude Add tests 2026-05-29 23:05:33 -06:00
.forgejo/workflows Add an in-app chat assistant, built on the shared Claude packages 2026-07-21 00:18:12 -06:00
.idea initial working medsync 2026-05-27 09:05:25 -06:00
backend feat: store all times as UTC, display in a configurable app timezone 2026-07-21 12:44:23 -06:00
frontend feat: store all times as UTC, display in a configurable app timezone 2026-07-21 12:44:23 -06:00
.env ntfy add 2026-06-09 20:16:33 -06:00
.gitignore chore: add package-lock, ignore aider files and .env 2026-07-06 10:08:56 -06:00
docker-compose.yml ntfy add 2026-06-09 20:16:33 -06:00
README.md feat: as-needed (PRN) medications; reminders-off meds are never overdue 2026-07-16 16:15:13 -06:00

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.

  1. In Authentik, create an OAuth2/OpenID confidential provider:
    • Redirect URI: https://<your-host>/api/auth/oidc/callback
    • Scopes: openid profile email
  2. Copy the client id/secret and the provider's issuer URL into .env (OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI).
  3. 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 email claim (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

  1. Open Chrome on Android and navigate to your server URL
  2. Tap the ⋮ menu → "Add to Home screen"
  3. The app installs like a native app with push notification support
  4. 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

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
}