Skip to content

About

Personal health-coaching dashboard over a Gadgetbridge SQLite export. Local-LLM coach pipeline + read-only Next.js dashboard.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

Pulse

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.


Status: Experimental License: Source-Available Local-First


Next.js 16 React 19 TypeScript Tailwind v4 Node 22 Ollama SQLite Docker


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.

Highlights

  • 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.

Verified scope

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

What it does

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.


Architecture

[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.


Prerequisites

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.

Setup

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:3030

For 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.


Daily commands

# 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 run

Repo layout

pulse/
├── 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

Environment variables

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.


How the pipeline works

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.


Operating rules

Hard facts every contributor (human or AI) needs to internalise. These are repeated in CLAUDE.md for tool-side enforcement.

  • Gadgetbridge.db is read-only. Every write goes to pulse.db or to JSON files under insights/ / state/.
  • pulse.db is 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_ROOT and atomic-rename into $INSIGHTS_ROOT. Use the output/live-facts.ts / output/alarms.ts writers; never fs.writeFile directly 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_version on 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.ts helpers, never raw new Date().
  • Huawei encoding quirks:
    • Sleep stage codes: 1 light · 2 REM · 3 deep · 4 awake
    • HR signed-byte overflow: real value = 256 + raw when raw < 0 and raw !== -1
    • HUAWEI_ACTIVITY_SAMPLE.CALORIES is firmware-scaled — divide by 1000 for active kcal; BMR not included
    • HUAWEI_ACTIVITY_SAMPLE.DISTANCE is metres on the GT 5 Pro (verified against workout-summary km). Do not apply a /100 divisor.

Stack

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).


Limitations

  • 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 of runner/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.ts adjusted before insights make sense.
  • Single user. Schemas + paths assume one user. USER_ID = 1 hardcoded 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.

Read next


License

See COPYRIGHT.md. All rights reserved; source-available for reading and study, no use rights granted by default.

About

Personal health-coaching dashboard over a Gadgetbridge SQLite export. Local-LLM coach pipeline + read-only Next.js dashboard.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages