Open-source WHOOP / Bevel alternative — a free, local-first health dashboard for your Fitbit data.
A desktop Electron app that connects to the Google Health API (the successor to the deprecated Fitbit Web API) and renders a WHOOP / Bevel‑style dashboard from your Fitbit wearable data: derived scores (Recovery, Sleep, Strain, Readiness, Physio Age) plus recovery vitals, daily activity, sleep stage timeline, and workouts — all computed locally with no subscription fees.
Works with Fitbit Charge 6, Pixel Watch, Fitbit Sense 2, Fitbit Versa 4, and any device that syncs to the Google Health API.
- 🆓 Free — no monthly subscription like WHOOP ($30/mo) or Bevel
- 🔒 Private — all data stays on your machine, no third-party servers
- 📊 Detailed — sleep timeline graph with hover crosshair, stage breakdown, trend charts
- ⚡ Fast — cache-first loading with smart incremental sync
- 🎨 Beautiful — liquid-glass UI with light/dark mode
Fitbit dashboard, WHOOP alternative, Bevel alternative, free WHOOP, open source health dashboard, Fitbit recovery score, Fitbit sleep score, Fitbit strain score, Fitbit readiness score, HRV tracking, sleep stages, Google Health API, Fitbit Charge 6 dashboard, Pixel Watch dashboard, health metrics, wearable analytics, quantified self, biometrics dashboard.
Raw metrics are synced to a local store and turned into baseline‑relative scores.
Follow the official Google Health API setup guide to create your project, then:
- A Google Cloud project with the Google Health API enabled.
- An OAuth 2.0 Client ID of type Desktop app. Download its JSON and save
it as
credentials.jsonin this folder (it has an"installed"key). - On the OAuth Audience page, add your Google account as a test user.
- On the Data Access page, add these scopes (read‑only):
googlehealth.profile.readonlygooglehealth.activity_and_fitness.readonlygooglehealth.sleep.readonlygooglehealth.health_metrics_and_measurements.readonly
npm install
npm startClick Connect with Google. Your browser opens Google's consent screen; after
you approve, the app captures the auth code on a temporary localhost redirect,
stores tokens in token.json, and loads your dashboard. Subsequent launches skip
the login and refresh the token automatically.
To open DevTools: set DEVTOOLS=1 && npm start (Windows) or DEVTOOLS=1 npm start.
The UI uses a modern liquid‑glass (glassmorphism) style — frosted, translucent
cards over a softly animated gradient background — with a custom title bar
(titleBarStyle: 'hidden' + overlay) so the window controls blend into the header.
Toggle light / dark mode with the 🌙 / ☀️ button in the header. The choice is saved (localStorage) and the native title‑bar overlay colors update to match.
| File | Role |
|---|---|
main.js |
Electron main process, window + IPC wiring |
health-client.js |
OAuth flow, token refresh, API calls, dashboard building |
insights.js |
Orchestrator: sync → persist → score → assemble; cache-first reads |
sync.js |
Flattens API data into per‑day records and persists them |
data-store.js |
Local JSON store (metrics, scores, journal, settings) |
baselines.js |
Rolling mean/SD per metric (personal ranges) |
scoring.js |
Pure scoring functions (Recovery, Sleep, Strain, Readiness) |
preload.js |
Secure contextBridge between renderer and main |
index.html / styles.css / renderer.js |
Dashboard UI (gauges, cards) |
tests/ |
Unit tests (npm test, Node's built‑in test runner) |
Local data lives in the OS userData dir under Electron, or ./data/ in tests.
Run unit tests with npm test.
On open, the app paints the cached dashboard + scores instantly from the local
store (no network wait), shows an "Updating…" indicator, then fetches fresh data
in the background and repaints when it arrives. If the live refresh fails, the
cached view stays and the indicator reads "showing cached". The cache is rebuilt
from data-store.js records via insights.getCachedInsights().
The default view is 30 days, and history up to 90 days is retained locally for baselines and trends. Sync is incremental rather than re-fetching everything:
- First run (empty store): a one-time full backfill of ~90 days.
- Subsequent refreshes: only the last 3 days are re-fetched (today + a 2-day overlap that catches late-arriving data such as last night's sleep or an evening workout), then merged into the store. A typical refresh pulls ~3 days instead of 90.
- Gaps: if the app hasn't been opened for a while, it fetches only from the first missing day up to today.
- Timestamp skip: if a sync happened within the last 3 minutes and nothing is missing, the network call is skipped entirely and the view is served from the store.
The minimal window is computed by insights.computeSyncWindow() from the stored
per-date records plus the lastSyncAt timestamp; the returned view is always
assembled from the full local store, so a 30- or 90-day view stays complete even
though only a few days were fetched.
- Recovery (0–100%) — weighted z‑scores of HRV, resting HR, sleep, and respiratory rate vs a trailing 30‑day baseline, mapped through the normal CDF (≈50% at your baseline). Needs ~2 weeks of history before it activates.
- Sleep (0–100%) — duration vs need, efficiency, restorative % (deep+REM), and disturbances.
- Strain (0–21) — logarithmic cardiovascular load from active zone minutes, active energy, and steps (tunable constants).
- Readiness (0–100%) — blend of recovery, sleep, and inverse recent strain.
- Physio Age (years) — WHOOP-inspired estimate from recovery, sleep, readiness, and strain balance (best near moderate-high strain), smoothed across days, with a "pace of aging" multiplier.
All weights/constants live in store settings so they can be tuned without code changes. Tap the ⓘ on any score gauge in the app for an in‑context explanation of what it means, how it’s calculated, and today’s live breakdown.
- Base URL:
https://health.googleapis.com, paths under/v4/users/me/.... - Data type IDs are kebab‑case in URLs (
heart-rate,oxygen-saturation,body-fat), but snake_case infilterparams (heart_rate). camelCase returns400 Invalid data type ID. dailyRollUpranges are capped (heart‑rate fails beyond ~14 days), so long ranges are fetched in ≤14‑day chunks and merged.- Methods on
/v4/users/me/dataTypes/{type}/dataPoints:list—GET(intraday / log data), supports?filter=and?pageSize=.rollUp—POST :rollUp, body{ range:{startTime,endTime}, windowSize:"86400s" }.dailyRollUp—POST :dailyRollUp, body{ range:{start:{date},end:{date}}, windowSizeDays:1 }.- Not every type supports every action; the API error lists allowed actions.
- Singletons:
GET /v4/users/me/profile,/identity,/settings.
| Metric | Data type ID | Method used |
|---|---|---|
| Resting heart rate | daily-resting-heart-rate |
list |
| Heart rate variability | daily-heart-rate-variability |
list |
| SpO₂ | daily-oxygen-saturation |
list |
| Respiratory rate | daily-respiratory-rate |
list |
| Steps | steps |
dailyRollUp |
| Active energy | active-energy-burned |
dailyRollUp |
| Heart rate (avg/min/max) | heart-rate |
dailyRollUp |
| Active Zone Minutes | active-zone-minutes |
dailyRollUp |
| Sleep (stages) | sleep |
list |
| Workouts | exercise |
list |
- In Testing publishing status, refresh tokens expire after 7 days. Move the app to Production for long‑lived refresh tokens.
- Supporting more than 100 users requires a Google third‑party security review.
credentials.json and token.json contain secrets and are git‑ignored. Never
commit them.
