A personal health data hub for Whoop and Apple Health. The project combines a Next.js web app, a native SwiftUI app, a multi-tenant SQLite store, and an AI Coach that can analyze health data and author recovery-aware workout plans.
The web and iOS clients share the same authenticated /api/* backend. Whoop
data arrives through OAuth syncs and signed webhooks; HealthKit workouts arrive
from the iOS app and enrich or supplement the Whoop workout history.
This is an active, production-oriented personal project. The application is multi-tenant, but the checked-in infrastructure and Apple identifiers are specific to the current deployment rather than a turnkey hosted service.
- Recovery, sleep, strain, workout, trend, personal-record, and all-time analytics
- A persistent AI Coach with Anthropic and Cursor provider support, selectable models, configurable Anthropic reasoning effort, image attachments, and expandable work receipts
- Coach-authored workout plans that are shared between web and iOS
- Sign in with Apple for web cookies and iOS bearer sessions
- Per-user encrypted Whoop OAuth tokens and optional Anthropic/Cursor API keys
- Manual seven-day Whoop syncs, signed real-time webhooks, and a replayable webhook dead-letter queue
- HealthKit workout and heart-rate ingestion from iOS, including duplicate matching against Whoop workouts
- APNs device registration, structured server/client logs, web-vitals capture, and production build identity
flowchart LR
whoop["Whoop API<br/>OAuth, sync, webhooks"] --> api["Next.js web + API"]
web["Web client<br/>SIWA cookie"] --> api
ios["SwiftUI iOS app<br/>SIWA bearer token"] --> api
health["Apple HealthKit<br/>workouts + heart rate"] --> ios
api --> db[("SQLite<br/>shared/whoop_data.db")]
api --> coach["Coach provider layer"]
coach --> anthropic["Anthropic API"]
coach --> cursor["Cursor agent + MCP"]
SQLite is the canonical store. Domain rows, integrations, settings, chat
history, encrypted attachments, plans, logs, and device tokens all live in the
same database and are scoped by user_id where applicable.
| Layer | Technology |
|---|---|
| Web and API | Next.js 16.2.4 App Router · React 19.2 · TypeScript 5 · Tailwind 4 |
| Native app | SwiftUI · iOS 17+ · Swift 5.9 · HealthKit · AuthenticationServices |
| Charts and UI | Recharts 3.8 · Lucide · Geist |
| Coach | Anthropic SDK 0.91 · live Anthropic and Cursor model catalogs · cursor-agent |
| Content and images | marked 18 · DOMPurify · Sharp |
| Data | SQLite WAL via better-sqlite3 12 |
| Auth and encryption | Sign in with Apple · JOSE · TweetNaCl secretbox |
| Operations | GitHub Actions CI · Fleet · user systemd · Cloudflare Tunnel · Optiplex |
apps/
├── web/ Next.js dashboard, API, sync, Coach, and DB layer
└── ios/ SwiftUI app generated from project.yml with xcodegen
shared/ Canonical SQLite database location
scripts/ Deploy, Coach inspection, benchmarks, and migrations
streamlit/whoop/ Retained Python helpers for scripts and tests only
tests/ Python helper tests
docs/ Architecture, decisions, contracts, and operations
infra/terraform/ Retired Oracle Cloud provisioning history
systemd/ Active opti web and Cloudflare Tunnel user units
prompts/ Repository task and build prompts
shared/whoop_data.db and its WAL sidecars are intentionally gitignored.
tokens.json is a legacy, gitignored migration source; encrypted
integrations rows are the runtime source of truth.
- Node.js 20 and npm
- A Whoop developer application for OAuth and live sync
- Sign in with Apple web credentials for an interactive authenticated session
- At least one Coach credential: Anthropic, Cursor, or a per-user key added later in Settings
- Python 3 only if you need the retained migration utilities or Python tests
git clone https://github.com/georgenijo/whoop-dashboard.git
cd whoop-dashboard
# The file is not committed. openWrite() owns schema creation and migrations,
# but intentionally refuses to create an absent database file.
test -e shared/whoop_data.db || touch shared/whoop_data.db
cp .env.example apps/web/.env.local
cd apps/web
npm ci
npm run devOpen http://localhost:3000. Build and test commands do not require production secrets, but using the application does require a valid authenticated user.
For a complete local flow:
- Fill the Sign in with Apple,
JWT_SIGNING_KEY, andPUBLIC_ORIGINvariables inapps/web/.env.local. For browser sign-in during local development, use a public HTTPS origin or tunnel registered with Apple. - Set the web Services ID callback to
${PUBLIC_ORIGIN}/api/auth/apple-web/callback. - Add
WHOOP_CLIENT_ID,WHOOP_CLIENT_SECRET,WHOOP_STATE_SECRET, andVAULT_KEY. - Set the Whoop developer callback to
http://localhost:3000/api/auth/callback. - Configure
ANTHROPIC_API_KEYand/orCURSOR_API_KEY. Cursor turns also requirecursor-agenton the service path orCOACH_CURSOR_AGENT_BINset to its absolute path. - Sign in, complete the onboarding flow, connect Whoop, and run the first sync.
The auth proxy is dormant when APPLE_SERVICES_ID is unset so builds and
limited diagnostics remain possible, but protected pages and API routes still
have no development-user bypass.
See .env.example for generation commands and
the environment and deployment guide
for the full variable inventory and secret boundaries.
Run web commands from apps/web/.
| Command | Purpose |
|---|---|
npm run dev |
Start the development server on port 3000 |
npm test |
Run the Vitest suite once |
npm run test:watch |
Run Vitest in watch mode |
npm run lint |
Run ESLint |
npm run build |
Build the Coach MCP bundle and production Next.js app |
npm run start |
Start a completed production build |
npm run build:mcp |
Build only the Cursor Coach MCP server |
The load-bearing DB test rejects unscoped SQL against the health-domain tables. A production build is required before repository changes are merged.
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
pytest tests/These dependencies support vault/integration tests, migrations, and maintenance scripts. There is no active Python sync service or Streamlit UI.
The native app lives in apps/ios/. Coach.xcodeproj is generated and
gitignored; project.yml is the source of truth.
brew install xcodegen
cd apps/ios
xcodegen generate
open Coach.xcodeprojSelect the Coach scheme in Xcode and run it on an iOS 17+ simulator or signed
device. The app currently targets https://coach-api.georgenijo.com by default;
APIClient.swift and ClientLogger.swift own those endpoints. A Debug-only
COACH_DEBUG_TOKEN environment variable can seed a simulator session.
For the shared development simulator, use the launcher instead of signing in with Apple:
apps/ios/scripts/run-dev-simulator.sh [simulator-udid]The launcher mints a normal 30-day session on Fleet node opti,
validates it against the production API, builds a signed Debug app, and injects
the session only into that simulator process. It never prints the signing key or
session token, and Release builds continue to require Sign in with Apple. Set
COACH_DEV_USER_ID to target a different development user.
The iOS app provides Home, Trends, Stats, Coach, Plans, and Settings tabs. Trends contains the Recovery, Sleep, Strain, and Workouts surfaces. It also registers for push notifications and incrementally ingests HealthKit workouts with anchored background delivery.
Xcode Cloud regenerates the project after cloning and archives changes under
apps/ios/** from main. See the iOS CI guide for the
current workflow and signing identifiers.
| Path | Purpose |
|---|---|
/ |
Recovery hero, KPI strip, AI insight, trends, and personal records |
/recovery |
Recovery, HRV/RHR, illness, rebound, and overtraining signals |
/sleep |
Sleep need, stages, debt, consistency, naps, and recovery relationships |
/strain |
Daily strain, training balance, heart rate, and today’s workouts |
/workouts |
Workout history, distance/zones, and detailed heart-rate analytics |
/stats |
All-time totals, yearly comparisons, sport mix, records, and monthly rollups |
/coach |
Persistent Coach threads, images, model picker, and tool work receipts |
/plans |
Coach-authored, recovery-aware workout plans |
/logs |
Unified sync, chat, route, server, client, and webhook diagnostics |
/perf |
Web performance metrics and recent measurements |
/settings |
Connections, Coach keys/model/prompt, preferences, and account controls |
/welcome |
First-run goals, Whoop connection, and initial sync |
POST /api/chat supports streaming SSE and non-streaming JSON responses.
Conversations persist complete provider content blocks, tool calls/results,
encrypted image attachments, and a bounded user-visible work receipt. New
threads receive deterministic titles with an optional Haiku 4.5 refinement.
The shared tool layer contains:
query_recoveryquery_sleepquery_strainquery_workoutsquery_napsquery_journalquery_daily_snapshotquery_workout_planssave_workout_plantrigger_whoop_sync
The Anthropic loop can use the complete set. Cursor runs in a throwaway
per-turn workspace through a bundled stdio MCP server; it receives the scoped
query and plan tools plus view_chat_image, while manual sync remains outside
the Cursor MCP surface.
All data access is bound to the authenticated user. The Anthropic loop is capped at eight tool iterations and 16,384 output tokens. Personal encrypted keys take precedence over shared server fallbacks; Settings can validate, store, mask, and remove both Anthropic and Cursor keys.
To use a paid Anthropic account, open Settings → Coach → Anthropic access and save your API key. The web model picker and Settings load the models available to that key; reasoning options follow each model's advertised capabilities. Anthropic and Cursor groups collapse independently, and model search spans both. Unavailable models remain visible as saved choices but cannot be selected. Removing a key preserves the chosen provider and model; Coach asks for access instead of silently switching to another paid provider.
- Sign in with Apple creates or resolves a user and issues a signed JWT. Web
clients receive it in the
__Host-coach_sessioncookie; iOS sends it as a bearer token. - Whoop OAuth carries the local user through an HMAC-signed state value.
Access and refresh tokens are encrypted into the user’s
integrationsrow. runWhoopSync({ userId })fetches the latest seven days of cycles, recovery, sleep, workouts, and body measurements in parallel, then upserts tenant-scoped rows and recomputesdaily_summary.- Signed Whoop webhooks map the provider user ID back to a local user and
update or delete individual resources. Failures remain replayable in
webhook_events. - The iOS HealthKit pipeline matches workouts by time and sport, enriches existing Whoop rows with heart-rate streams where possible, and inserts unmatched HealthKit-only workouts.
- Web pages, iOS routes, and Coach tools read through the same user-scoped DB helpers.
The public auth exemptions are deliberately narrow: sign-in/auth callbacks,
the signed Whoop webhook, admin routes with their own bearer gate, static
assets, and build-only /api/health.
- Keep the canonical database at
shared/whoop_data.db; useWHOOP_DB_PATHonly when an environment intentionally stores it elsewhere. - Schema changes live in
apps/web/src/lib/db/connection.tsand run lazily throughopenWrite(). - Domain reads go through
forUser(userId); direct unscoped domain SQL is blocked by tests. - Only scored Whoop recovery, cycle, and sleep records are processed. Workouts do not use the same score-state gate.
- Naps are stored but excluded from nightly sleep queries.
- The database runs in WAL mode. Never back up a live deployment with a raw file copy; use SQLite’s online backup API.
Production lives entirely on the Optiplex Fleet node opti. A user-level
whoop-web.service listens only on 127.0.0.1:8501; a user-level
whoop-cloudflared.service publishes both public hostnames through an
outbound-only Cloudflare Tunnel. Sign in with Apple and the app JWT protect the
application. No inbound port, nginx, or Oracle VM is part of the active path.
GitHub Actions is CI only: it runs install, test, build, and deploy-script
syntax checks. Deployment is an explicit operator action from a trusted
machine with the Fleet CLI. scripts/deploy sends the exact revision to
opti, builds there with Node 20.20.2, backs up the live SQLite database using
the online backup API, switches an immutable release symlink, restarts the user
services, and verifies local and public health. A host-side lock serializes
deployments.
Manual operator commands:
scripts/deploy --check # read-only main/live drift report
scripts/deploy --ref <full-sha> # deploy the CI-validated revisionDo not reproduce the deploy sequence with ad hoc SSH or Git commands. Runtime
secrets, the tunnel token, and the canonical database stay under
/home/george/services/whoop-dashboard on opti; releases and CI never contain
them. The one-time Oracle retirement and rollback procedure is documented in
the operations guide below.
scripts/coach is a read-only inspector for local or production Coach state:
scripts/coach threads --limit 10
scripts/coach thread 49 --tools
scripts/coach logs 49
scripts/coach syncs --status error
scripts/coach why 82
scripts/coach --local threadsProduction queries use one-shot fleet exec opti calls. The compatibility
login and logout subcommands only check Fleet reachability and do not open a
persistent session.
The Cursor latency harness is documented in
scripts/BENCH.md. Production configuration and recovery
procedures live in
docs/operations/environment-and-deploy.md.
The read-only Home Assistant bridge for the frame Morning page is documented
in docs/operations/frame-morning-brief.md.
- The Streamlit application and Python daily sync are retired.
streamlit/whoop/remains only because Python migrations and tests share its vault/integration wire format. tokens.jsonis not read by the active application. It exists only as an input to the one-shot encrypted-token migration.systemd/whoop-web.serviceandsystemd/whoop-cloudflared.serviceare the only active production units. Retired Streamlit, Python sync, nginx, and Oracle service definitions remain available only in Git history.infra/terraform/is the retired Oracle baseline. Do not apply it to create or modify the current production environment.- The rebuild documents under
docs/rebuild/describe historical migration work, not current implementation guidance.
Before changing code, read AGENTS.md, the relevant nested agent
guidance, and the current Next.js documentation bundled under
apps/web/node_modules/next/dist/docs/. Architectural and process decisions
are recorded newest-first in
docs/decisions/DECISIONS.md.
Repository changes must be made on a branch and merged through a pull request. At minimum, web changes must pass:
cd apps/web
npm test
npm run build