A full-stack medication tracker focused on fast dose logging, live timers, adherence analytics, secure auth, and exportable history. Built with SvelteKit 2 (Svelte 5 runes), TypeScript, Drizzle ORM, and Postgres.
MedTracker is a personal medication tracking web app. It is a tracking tool, not medical advice — see the disclaimer surfaced across the UI and the medical disclaimer note below. The project is built as a portfolio piece: the goal is to show end-to-end full-stack judgement, not to add yet another feature.
Read the long-form story in docs/case-study.md.
[email protected] / demo-medtracker-2026.
Seeded with five medications and ~30 days of dose history so the
dashboard, log, and analytics pages reflect a populated state.Refresh the demo (deletes and recreates the demo user, idempotent):
DATABASE_URL=... npm run seed:demo
Run locally in 60 seconds (Node 22, free Neon Postgres tier):
git clone https://github.com/JWhite212/medication-tracker.git
cd medication-tracker
npm install
cp .env.example .env # set DATABASE_URL (Neon pooled URL with sslmode=require)
npm run db:migrate # apply Drizzle migrations
npm run seed:demo # optional: seed the demo account
npm run dev # http://localhost:5173
Verify with curl http://localhost:5173/api/health. Full deployment
runbook in docs/DEPLOYMENT.md.
| Medications | Add Medication |
| History | Analytics |
visibilitychange
catch-up.Honest about what's complete vs. what's planned:
| Feature | Status |
|---|---|
| Email/password auth | Complete |
| OAuth (Google, GitHub) | Complete; account-takeover guard in place |
| 2FA (TOTP) | Complete; secrets encrypted at rest with AES-256-GCM |
| Dose logging + edit + skip | Complete; ownership-checked, status-aware |
| Adherence analytics | Complete; cap-at-100 + overuse split |
| Email reminders | Complete; typed EmailResult, per-channel status, retry-after-cooldown via reminder_events |
| Web Push reminders | Complete; per-channel opt-in, claim/complete dispatch |
| Notification preferences | Complete; split into 4 channel-specific toggles (overdue email/push, low-inventory email/push) |
| PDF / CSV export | Complete; formula-injection escape, en-GB time format, audit log CSV |
| Inventory event history | Complete; refill workflow, per-event timeline on /medications/[id] |
| Drug interaction notice | Experimental, behind INTERACTIONS_ENABLED flag |
| Medical disclaimer | Surfaced on landing, register, medication form, analytics, exports |
| Re-auth gate (sensitive actions) | Complete for change-password, enable/disable 2FA, delete account, wipe dose history, wipe archived medications, revoke other sessions |
| Medication scheduling | Interval, fixed-time, and PRN; multi-row schedules with optional day-of-week filters |
| Atomic medication creation | Complete; createMedicationWithSchedules runs medication + schedules + audit in one transaction |
| Privacy & data controls | Complete; /settings/privacy with stored/not-stored copy, scoped wipes, session revocation, audit-log CSV |
| Demo account + seed | Complete; npm run seed:demo (4c) |
| End-to-end tests | Complete; Playwright journeys for auth, medication lifecycle, dose logging, analytics, history filters, exports, axe |
v1:iv:tag:ct) and a one-shot migration script
(scripts/encrypt-totp-secrets.ts).dedupe_key rows in
reminder_events. See ADR 0005.formatUserTime(date, tz, '12h'|'24h')
threaded through dashboard, timeline, log, exports, emails so
everything agrees.escapeCsvCell neutralises formula
injection prefixes (= + - @ \t \r) plus standard CSV escape
rules; CRLF line endings per RFC 4180.buildInsights is a deterministic,
unit-testable predicate over already-computed stats; new rules are
one-line additions and never inject prescriptive medical wording.vite.config.ts, set just below current so legitimate refactor
noise doesn't fail CI but real regressions do.+-------------------+
| Browser / PWA | <-- service worker for offline shell + push
+---------+---------+
|
v
+---------+---------+ +--------------------+
| SvelteKit edge | --> | Resend (email) |
| (Vercel) | +--------------------+
| |
| - Loaders | +--------------------+
| - Form actions | --> | Web Push |
| - API endpoints | +--------------------+
| - Cron handler |
+---------+---------+ +--------------------+
| | OpenFDA labels |
| Drizzle ORM | (feature-flagged) |
v +--------------------+
+---------+---------+
| Postgres (Neon) |
| users · sessions |
| medications |
| dose_logs (status)|
| reminder_events |
| reauth_tokens |
| audit_logs ... |
+-------------------+
For Mermaid diagrams of the system overview, the reminder-dispatch
sequence, and the data model, see
docs/architecture.md. The architectural
decisions are recorded in docs/adr/.
Short, opinionated tour of the eight files I'd start with as a reviewer who has 30 minutes:
src/lib/server/db/schema.ts - the
complete data model in one file. Foreign keys, indexes, and the
reminder/inventory event status fields all live here. Skim to
ground every later trace in concrete tables.src/lib/server/doses.ts - dose
lifecycle (log, skip, edit, delete) with transactional inventory
adjustments. Demonstrates the dbTx.transaction + audit pattern
that the rest of the service layer follows.src/lib/server/reminders.ts - the
cron entrypoints. Shows the claim/complete dispatch flow, the
per-channel split, and how email-not-verified is gated separately
from push subscriptions.src/lib/server/reminders/domain.ts -
pure overdue-slot computation and dedupe-key generation. Decoupled
from DB so it's exhaustively tested.src/lib/server/analytics.ts -
adherence and insight generation. Pure functions over already-
computed stats; new rules are one-line predicates.src/lib/server/email.ts - typed
EmailResult discriminated union with mapResendError. Senders
never throw on provider errors; runtime exceptions fold into
provider_error. Tokens, recipients, and HTML payloads never
appear in error messages..github/workflows/ci.yml - the CI
gate. Type-check, lint, format, unit tests with coverage floors,
secret scan, build, and a gated E2E job that runs Playwright
against a Neon test branch.tests/e2e/ - Playwright journeys covering auth,
medication lifecycle, dose logging, analytics, history filters,
exports, and an axe-core accessibility scan. Deterministic
seed-e2e.ts user; storageState reused via Playwright projects.The corresponding tests live alongside under tests/unit/ (309+ unit
tests) and tests/e2e/ (8 spec files). The test suite has been the
forcing function for most of the architectural choices above - several
P5-P8 bugs were caught only because their unit tests pinned the
contract first.
Each significant choice is captured as a short ADR. They explain not just what, but the alternatives weighed and the consequences accepted:
@node-rs/argon2
(memoryCost 19456, timeCost 2 — OWASP minimum recommendations).reauth_tokens for audit./api/interactions, and /api/export — sliding
window stored in rate_limits.sql.raw(...) reserved
for whitelisted timezone identifiers.secure: !dev.X-Frame-Options: DENY, HSTS in prod
only, expanded Permissions-Policy blocking camera, mic,
geolocation, accelerometer, gyroscope, magnetometer, ambient-light,
payment, USB; X-Content-Type-Options: nosniff;
Referrer-Policy: strict-origin-when-cross-origin.svelte.config.js adapter config:
default-src 'self', scripts/connect/worker 'self',
object-src 'none', frame-src 'none'.audit_logs (user-scoped, append-only).?sslmode=require in DATABASE_URL).SECURITY.md.MedTracker is a personal tracking tool. It does not provide medical advice, dosage recommendations, diagnosis, or emergency guidance. Always follow advice from a qualified healthcare professional.
The target is WCAG 2.2 AA. The Phase 4 accessibility plan
(.claude/PRPs/plans/completed/accessibility-wcag-2-2-aa.plan.md)
shipped these foundations:
#main-content rendered first in app.html and
visible-on-focus.<h1>, a
<main> element, and <nav> for the sidebar with aria-current="page"./ focus search,
n add medication, 1-9 quick-log, ? help) plus Esc closes
modals; focus is trapped inside the dose-edit modal and restored
on close.prefers-reduced-motion: reduce disables
all keyframe animations and shortens transitions; the heatmap's
staggered fade-in zeroes its animation-delay under the same media
query.prefers-contrast: more overrides text
tokens and brightens the accent.9:1 (well past WCAG AAA) in Phase 5.
<label>, plus
aria-invalid, aria-describedby, and aria-required driven
through the shared Input.svelte primitive.aria-live="polite";
TimeSince counters update silently to avoid screen-reader chatter.aria-label="Move {medication} up/down" and disable on no-op moves; arrow glyphs
are wrapped in aria-hidden.+page.server.ts; the client never fetches data on mount.use:enhance cover the surface.buildSparklineShape lives in src/lib/utils/sparkline.ts and
is unit-tested)./static/*
and the manifest with long cache headers.(user_id, name) on
medications, (user_id, taken_at desc) on dose_logs, dedicated
per-user index on oauth_accounts; no full-table scans on the
hot pages./api/health — no DB hit; cheap enough for high-frequency
uptime probes (Cache-Control: no-store).See docs/database.md for the full table-by-table
reference, indexes, and migration workflow. Quick summary:
| Table | Purpose |
|---|---|
users, sessions, oauth_accounts |
Auth core |
email_verification_tokens, password_reset_tokens |
Email flows (hashed tokens) |
medications |
User-owned; colours, pattern, schedule, archived_at |
dose_logs |
One row per logged dose; status taken/skipped/missed |
audit_logs |
Append-only JSONB diff log |
user_preferences |
Per-user UI/format/reminder settings |
rate_limits |
Sliding-window login + reset rate limit |
push_subscriptions |
Web Push endpoints |
reminder_events |
Idempotency key for cron dispatch |
reauth_tokens |
Sensitive-action re-auth audit |
src/lib/** so
routes (which need E2E) don't inflate the denominator. Provider:
v8. Reporters: text, html, lcov, json-summary.scripts/seed-e2e.ts) creates a fixed user, three
medications, and 14 days of synthetic history; tests reuse the
seeded session via Playwright storageState. Teardown removes
any user under the @e2e.medtracker.test domain.npm audit → build → optional E2E job (gated on the RUN_E2E
repo variable and the E2E_DATABASE_URL secret). See
.github/workflows/ci.yml.npm test # unit tests
npm run test:coverage # unit tests with v8 coverage
npm run test:e2e # Playwright (requires dev server + DB)
E2E tests need a real database. Use a separate Neon branch (or local Postgres) so the suite can seed and tear down without touching personal data.
# 1. Point at a test database. Either DATABASE_URL or
# E2E_DATABASE_URL works; the seed script prefers the latter.
export E2E_DATABASE_URL="postgresql://user:pass@host/dbname?sslmode=require"
# 2. Apply the schema once.
npm run db:push
# 3. Install Chromium for Playwright (one-off).
npm run playwright:install
# 4. Run the suite. Global setup re-seeds the user automatically;
# you don't need to run seed:e2e by hand.
npm run test:e2e
# 5. Inspect the HTML report after a failure.
npx playwright show-report
# Single file:
npx playwright test tests/e2e/dose-logging.test.ts
The deterministic seed user is [email protected]
(password e2e-medtracker-2026). Both values are intentionally
fixed; the account only ever exists in test databases under a
domain that cannot resolve to a real mailbox.
git clone https://github.com/JWhite212/medication-tracker.git
cd medication-tracker
npm install
cp .env.example .env # fill in DATABASE_URL at minimum
npm run db:migrate # apply Drizzle migrations
npm run dev # start dev server on :5173
Other handy commands:
| Command | Purpose |
|---|---|
npm run check |
Type-check (svelte-check) |
npm run lint |
ESLint |
npm run format |
Prettier --write |
npm run db:generate |
Diff schema → new migration file |
npm run db:studio |
Open Drizzle Studio |
A husky pre-commit hook runs lint-staged (ESLint + Prettier on
the staged files) before every commit.
The full annotated list lives in .env.example.
Required: DATABASE_URL. Everything else is optional and disables
the corresponding feature when unset (OAuth, email, push,
interactions). See docs/DEPLOYMENT.md for
the production setup runbook.
The long version is in docs/case-study.md §7.
Six honest takeaways:
reminder_events.dedupe_key made the cron handler safe to retry
with no effort. Cheaper than locks.quantity: 0 + notes: "Skipped" for skipped doses
(Phase 1 plan). It worked. It also distorted analytics counts
until I added a real status enum. Schema choices outlive code.RUN_E2E repo variable
plus an E2E_DATABASE_URL secret pointing at a Neon test branch.
Until both are set the suite still runs locally (npm run test:e2e)
but the CI job is a no-op.INTERACTIONS_ENABLED=true
to turn on, and even then the warning panel is labelled
"Experimental" — false positives are expected.Honest list of audit findings deliberately deferred under a conservative-risk policy. Each is captured here so a future pass can pick them up:
MedicationForm.svelte (currently 634 LoC) into
ScheduleModeSelector, ColourPicker, and a leaner form body.style-src — Tailwind v4 may still emit inline
styles in some component paths; verify before removing
'unsafe-inline'.goto() to
<form use:enhance> for progressive enhancement.sharp dependency — currently transitive; audit whether
Vercel image optimization actually needs it.Tracked across implementation phases, with the source plan in
.claude/PRPs/plans/improvements-broad.plan.md:
archived_at,
shared hashToken util, reminders N+1 fix, /api/health, API
rate-limiting, email-verify rate limit, badge contrast, heatmap
a11y, reorder a11y, pending-state on delete, README, CHANGELOG,
CONTRIBUTING, SECURITY, deployment guide, husky, CI hardening.
Done.