A health-coaching dashboard built around a Gadgetbridge SQLite export.
A read-only Next.js dashboard reads insights produced by a local-LLM coach pipeline over phone-synced wearable data.
Setup · Pipeline · Deployment · Schema · License
Important
Personal reference implementation, source-available. Published for reading and study, not as a turnkey product. See COPYRIGHT.md for usage terms. Building this for yourself today requires porting the SQL layer, the LLM prompts, and the timezone code.
- Local-first by design. Every byte of biometric data stays on your
hardware. The runner calls Ollama on
localhost; nothing reaches an external API. - Deterministic where it counts, LLM where it pays. Facts, rules, alarms, and the composite scores are pure code. Only the slot prose + evidence selection are LLM-driven, each a narrow structured-output call; numbers always come from the data layer, never the model.
- Split deployment baked in. Mac runs the full stack (runner + Ollama + dashboard). Raspberry Pi serves the dashboard read-only over Syncthing-replicated JSON insights. The Pi never calls the model.
- Atomic writes only. Insights stream through a staging dir then atomic-rename into place, so Syncthing never picks half-files even mid-write.
| What | Tested with |
|---|---|
| Watch | Huawei Watch GT 5 Pro (firmware 6.0.0.23(SP10C00M06)) |
| LLM | Ollama · gpt-oss:20b (v4 slots) + qwen3.6:latest (v3 clusters) + ministral-3:3b (interactive) |
| Language | German prompts + UI (output, abstain text, S1 safety strings) |
| Timezone | Europe/Berlin (wake-window math is timezone-coupled) |
| Mac | Apple Silicon, ≥16 GB RAM (gpt-oss:20b inference needs ample free memory) |
| Pi | Raspberry Pi 4/5 running Raspbian 12, Node 22 |
| Surface | What you see |
|---|---|
/ (home) |
The v4 "editorial cockpit": a masthead (hero verdict + first-class KPI rail + switchable 14-day instrument) over LLM-led German chapters, each with one inline evidence chart. Built from pre-computed slots (the day's five fixed slots + event slots) read out of the Pi-written view_state. |
/day/[date] |
Per-day view: the slot chapters + per-domain telemetry charts. |
/week/[weekKey] |
Weekly recap (the week_synthesis slot). |
/sleep, /recovery, /activity, /heart, /body, /stress |
Per-domain drill-downs with the runner's narrative, metric tiles, and chart layer. |
/coach |
Per-lever coaching trajectory cards: trend, 90-day projection, single tiny step per week. |
/workouts, /activities |
Workout / activity log with stitched HR + zones + GPX route if present. |
/explore |
Chart playground driven by a typed metric registry; the same chart-spec runtime the LLM emits into. |
/nutrition, /training |
Per-meal entry + macro/micro tracking; versioned training-plan execution with chat panel. |
/log, /labs, /profile, /settings |
Manual entry (journal, feel, weight), experimental toggles, profile/device. |
The runner pipeline runs locally — no insights ever leave the host. The
Pi dashboard serves the resulting view_state + facts; only the on-demand
chart panel (/api/chart) calls back to Ollama.
[Phone] Gadgetbridge auto-exports Gadgetbridge.db
│
Syncthing
│
▼
[Mac] coach runner (Docker) — two services
• events-loop — chokidar on DB mtime derives events; runs the
v3 training cluster + nutrition packager,
the JobCell worker, and PWA push.
• v4-daemon — 60s tick: builds Tier1, writes _facts.json +
alarms (deterministic, no LLM), schedules +
dispatches the LLM slots (gpt-oss:20b on the
Mac host), and POSTs diffs to the Pi.
│
HTTP diffs (Mac → Pi) + Syncthing (facts/alarms/state)
│
▼
[Pi] Next.js standalone (systemd or docker) — single-writer of view_state
Serves /, /day, /week, /sleep, /activity, /heart, /body, /stress,
/coach, /workouts, /alarms, /labs, /log, /profile,
/explore, /nutrition, /training.
Ingests diffs at /api/ingest/view; reads view_state for the UI.
Never calls the batch model.
Single-machine variant (no Pi) collapses both rows onto the Mac — see docs/SETUP.md.
Hardware:
- A wearable supported by Gadgetbridge (tested: Huawei Watch GT 5 Pro).
- Android phone running Gadgetbridge.
- Mac (Apple Silicon recommended). ≥16 GB RAM, ≥30 GB free disk for the runner container + Ollama model.
- (Optional) Raspberry Pi 4/5 or any always-on Linux box for the read-only dashboard split.
Software, on the Mac:
- Node 22+
- Docker Desktop (Apple Silicon image)
- Ollama, with these models pulled:
ollama pull gpt-oss:20b # v4 slot pipeline ollama pull qwen3.6:latest # v3 clusters (default $COACH_MODEL) ollama pull ministral-3:3b # on-demand chart / chat panel
- Syncthing (if using the Pi split)
Software, on the phone:
- Gadgetbridge paired with your watch.
- Enable Settings → Data management → Auto-export to a Syncthing-watched folder.
See docs/SETUP.md for the full linear walkthrough covering single-machine + two-machine variants, env-var configuration, first-run + backfill, and deployment as launchd / systemd.
Minimal cheat-sheet:
git clone <this-repo> pulse
cd pulse
# 1. install dashboard deps
npm install
# 2. install runner deps
cd runner && npm install && cd ..
# 3. point at your data root
export PULSE_ROOT="$HOME/pulse" # wherever your Gadgetbridge.db lives
mkdir -p "$PULSE_ROOT"
cp /path/to/Gadgetbridge.db "$PULSE_ROOT/Gadgetbridge.db"
# 4. start Ollama (on the Mac)
ollama serve &
# 5. first run — backfill the last 7 days of v4 slots
cd runner
npx tsx src/index.ts v4-backfill --days=7
# 6. start the dashboard
cd ..
npm run dev
# → http://localhost:3030For production: the runner runs as a Docker service via launchd on the
Mac; the dashboard runs as a systemd service on the Pi. Templates under
deploy/*.template.
# Dashboard
npm run dev # localhost:3030, hot reload
npm run build && npm start # standalone production build
npm run typecheck
npm run gen:types # regenerate lib/types/generated.d.ts
# Runner (from runner/)
npx tsx src/index.ts v4-daemon --tick=60 # v4 daemon: Tier1 + _facts.json + alarms + slot scheduler
npx tsx src/index.ts v4-backfill --days=7 # rerun the last N days of v4 slots
npx tsx src/index.ts v4-backfill --date=2026-01-15 # a specific day
npx tsx src/index.ts events-loop --lookback=7 # v3 training cluster + nutrition + JobCell worker
npx tsx src/index.ts backfill-alarms --days=30 # rerun alarm persistence over past days
npm run test # vitest runpulse/
├── README.md this file
├── COPYRIGHT.md usage terms
├── CLAUDE.md hard rules for anyone (human or AI) editing here
├── docs/ design + reference docs (see "Read next")
├── deploy/ systemd + launchd templates + Caddy + build scripts
├── public/ PWA shell, icons, service worker
│
├── app/ Next.js App Router
│ ├── (app)/ authenticated dashboard shell
│ │ ├── (home)/ landing
│ │ ├── day/[date]/ day detail
│ │ ├── week/[weekKey]/ weekly recap
│ │ ├── sleep|activity|heart|body|stress/[date]/ domain pages
│ │ ├── coach/ trajectory cards
│ │ ├── workouts|activities/ session log
│ │ ├── alarms|labs|log|profile|settings/
│ │ ├── explore/[metric]/ chart playground
│ │ └── nutrition|training/ newer feature surfaces
│ ├── api/ server-only endpoints
│ └── globals.css Tailwind v4 tokens
├── components/ ui, charts, coach, domain, log, nav, view, slots, …
├── lib/ db, queries, types, formats, view-state, state-io
│
└── runner/ coach runner (Mac-only)
├── docker-compose.yml two services: events-loop + v4-daemon
├── Dockerfile node:22-alpine + sqlite-dev + python build deps
└── src/
├── index.ts CLI entry (events-loop / v4-daemon / backfills)
├── config.ts / period.ts / db.ts / ollama.ts
├── facts/ deterministic per-day fact builder
├── rules/ rules engine — S1/S2/S3 observations (run-stage1.ts)
├── output/ live-facts.ts (_facts.json) + alarms.ts
├── analyzer/ coaching levers + anomaly-explanation (shared)
├── ingest/ Mac → Pi outbox client
├── events/ bus + cluster-aware subscribers (v3)
├── jobs/ JobCell worker + queue
├── clusters/ JobCell cluster registry
├── v3/ partially-live use-case prompt pipeline (training + nutrition)
├── v4/ slot pipeline + Tier1 + scheduler + view-state writer
└── test/ probe + drift fixtures
Source DB + generated insights live outside the repo so Syncthing can sync data independently of code:
$PULSE_ROOT/
├── Gadgetbridge.db source, written by phone (read-only here)
├── pulse.db sidecar (journal, feel, manual log,
│ training plan/sessions, pattern library)
├── insights/
│ ├── daily/<YYYY-MM-DD>/
│ │ ├── _facts.json deterministic facts; rewritten by the v4 daemon
│ │ └── _bundle.json facts/rules run records + timings
│ ├── view/<scope>/<key>.json canonical v4 view_state (Pi single-writer)
│ └── alarms/<YYYY-MM>/alarms.json
└── state/
├── pause.json
├── labs.json
├── alarm_state.json
└── v4-event-cursor.json
All resolution is env-driven, no hardcoded paths:
| Variable | Default | Purpose |
|---|---|---|
PULSE_ROOT |
./pulse |
Root for Gadgetbridge.db, pulse.db, insights/, state/ |
GADGETBRIDGE_DB_PATH |
$PULSE_ROOT/Gadgetbridge.db |
Override the read-only source DB |
PULSE_DB_PATH |
$PULSE_ROOT/pulse.db |
Override the writable sidecar |
INSIGHTS_ROOT |
$PULSE_ROOT/insights |
Override insight output tree |
STATE_ROOT |
$PULSE_ROOT/state |
Override state JSON dir |
ALARMS_ROOT |
$PULSE_ROOT/insights/alarms |
Override append-only alarm log dir |
PULSE_STAGING_ROOT |
/tmp/pulse-staging |
Atomic-write staging directory |
OLLAMA_URL |
http://localhost:11434 |
Where the runner reaches Ollama |
COACH_MODEL |
qwen3.6:latest |
v3 cluster model (v4 slots are pinned to gpt-oss:20b) |
PULSE_PI_BASE_URL / PULSE_INGEST_BASE_URL |
(required for Mac→Pi) | Pi ingest base URL for the v4 outbox / legacy ingest |
PULSE_INGEST_TOKEN |
(required for Mac→Pi) | Bearer token guarding /api/ingest/* |
VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY / VAPID_SUBJECT |
(required for push) | Web Push keypair |
Web Push keys are generated once with npx web-push generate-vapid-keys --json.
The v4 daemon runs a deterministic layer (facts + rules + alarms, no
LLM) and a slot layer: five fixed daily slots, one weekly, two event
slots, each a narrow structured-output LLM call against a pre-computed
package. Slots are scheduled by clock + dependency order, dispatched to
Ollama on the Mac, and their diffs POST to the Pi, which is the single
writer of view_state. The home reads view_state and renders the
editorial cockpit.
The earlier daily v2 stage pipeline (daily.json/weekly.json, the
_complete sentinel) and the legacy snapshot pipeline are retired;
docs/COACH_PLAN.md is kept only as a historical
record. The deterministic facts/rules/alarm engine it once carried lives
on (relocated) and feeds v4.
Hard facts every contributor (human or AI) needs to internalise. These are repeated in CLAUDE.md for tool-side enforcement.
Gadgetbridge.dbis read-only. Every write goes topulse.dbor to JSON files underinsights//state/.pulse.dbis single-writer (Pi in the current topology). Mac pushes through/api/ingest/*over HTTP. Never co-write from both hosts — Syncthing silently corrupts SQLite under bidirectional writes.- Atomic writes only. Facts/alarms stream through
$PULSE_STAGING_ROOTand atomic-rename into$INSIGHTS_ROOT. Use theoutput/live-facts.ts/output/alarms.tswriters; neverfs.writeFiledirectly into the insight tree. - view_state is Pi single-writer. The Mac never writes
view/<scope>/<key>.json; it POSTs diffs to the Pi ingest, applied under CAS (expected_versionon every diff). - S1 safety observations have locked language. The rules engine emits S1 observations with fixed wording; consumers may paraphrase but never relativise the safety claim.
- Anomaly threshold ladder: singleton suppressed → ≥2 info → ≥3 warn → ≥10 critical.
- "Today" = wake-date local (Europe/Berlin, prev-day 18:00 →
this-day 12:00 sleep window). Use
period.tshelpers, never rawnew Date(). - Huawei encoding quirks:
- Sleep stage codes:
1light ·2REM ·3deep ·4awake - HR signed-byte overflow: real value =
256 + rawwhenraw < 0andraw !== -1 HUAWEI_ACTIVITY_SAMPLE.CALORIESis firmware-scaled — divide by 1000 for active kcal; BMR not includedHUAWEI_ACTIVITY_SAMPLE.DISTANCEis metres on the GT 5 Pro (verified against workout-summary km). Do not apply a/100divisor.
- Sleep stage codes:
Next.js 16 · React 19 · TypeScript · Tailwind v4 · motion · Recharts · react-leaflet · better-sqlite3 · undici · ajv · chokidar · zod · Radix primitives · next-themes · web-push · vitest · Ollama (gpt-oss:20b, qwen3.6, ministral-3).
- Watch lock-in. The runner's facts/rules layer assumes the
HUAWEI_*table family. Other watches (Mi Band, Fitbit, Polar, Garmin) export different Gadgetbridge schemas and would need a re-implementation ofrunner/src/facts/queries/*. - German output only. Every slot prompt + the grounding checks + the
abstain text is German. Switching languages is a prompt-suite rewrite
- grounding re-validation, not a config flag.
- Europe/Berlin assumed. Wake-window logic + ~26 files use the
fixed timezone. A user in a different timezone needs
runner/src/ period.tsadjusted before insights make sense. - Single user. Schemas + paths assume one user.
USER_ID = 1hardcoded in several queries. - Mac-only runner today. The runner image targets
linux/arm64(Apple Silicon). Linux/AMD64 builds work but haven't been validated for Ollama throughput.
- docs/SETUP.md — linear setup walkthrough
- docs/wip/V4_PERSPECTIVE_REWORK.md — current v4 architecture tracker
- docs/COACH_PLAN.md — legacy/historical: the retired daily v2 pipeline
- deploy/README.md — launchd / systemd / Caddy
- docs/gadgetbridge-schema/ — DB schema reference (per-domain)
- docs/PLAN.md — UI / IA design language
- CLAUDE.md — repository constraints + invariants
See COPYRIGHT.md. All rights reserved; source-available for reading and study, no use rights granted by default.