- TypeScript 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| src | ||
| test | ||
| .gitignore | ||
| ISA.md | ||
| package.json | ||
| README.org | ||
garmin-health
Turns GarminDB's local copy of Garmin Connect data into a classified record of low-HRV nights (illness-like, stress-like, short or late sleep, unexplained), a set of quantified findings, and one offline HTML dashboard. Everything runs on this machine; no health data is sent anywhere and none of it is tracked by git.
Run
bun test # unit tests; the real-data tests skip when ~/HealthData is absent
bun src/main.ts # writes ~/HealthData/analysis/{dashboard.html,findings.json,nights.json}
bun src/main.ts --data <dir> --out <dir>
bun src/main.ts --vault <dir> # also writes <dir>/Garmin-Recovery-Live.md, reads <dir>/Health-Context-Log.md
bun src/main.ts --context <file> # a context log somewhere else
Open ~/HealthData/analysis/dashboard.html in a browser. It loads nothing
from the network.
Daily refresh
Two systemd user units, kept outside this repo in ~/.config/systemd/user/:
garmindb.timer– daily at 11:30 (+ up to 10 min),Persistent=trueso a missed run happens at the next boot.garmindb.service–garmindb_cli.py --all --download --import --analyze --latestunderflock, thenExecStartPostrebuilds the dashboard and, with--vault ~/Documents/dDocs/1-areas/my-health, the vault noteGarmin-Recovery-Live.md(last 14 nights, a population reference for his age and sex, recent flags, current findings). Both are rebuilt only when the download succeeds. The job writes only that one note; the dated analysis note in the same folder is never touched.- The service then runs the health agent's daily summary
(
pi-durable-bun/src/health/daily.ts, a local LLM, see its README) and renders again. The agent writes~/HealthData/analysis/summary.json;main.tsputs it at the top of the dashboard and the vault note, marked with its model and date, or as out of date when it covers an older night.
GarminDB's --latest imports files modified in the last 24 hours, not files
missing from the database. The day after a full download every file qualifies and
an import takes about 20 minutes; after that a run imports only new files.
systemctl --user list-timers garmindb.timer
journalctl --user -u garmindb.service -n 50
Dependencies
- The summary comes from pi-durable-bun's health agent (see its README for the model server it needs). Without it the dashboard and note are still written, without a current summary.
- Bun 1.4.0 (
bun:sqlite; no npm packages). The unit calls it through the mise shim~/.local/share/mise/shims/bun. - GarminDB, installed as a uv tool (
~/.local/bin/garmindb_cli.py). Config in~/.GarminDb/GarminConnectConfig.json, login token in~/.GarminDb/garmin_tokens.json. Data lands in~/HealthData. -
Inputs read:
~/HealthData/DBs/garmin.db:hrv,resting_hr,daily_summary,device_info(firmware history),attributes(sex and year of birth, for the population reference)~/HealthData/DBs/garmin_monitoring.db:monitoring_pulse_ox(timestamps are local time)~/HealthData/Sleep/sleep_YYYY-MM-DD.json: sleep window, stages, respiration, sleep HR, SpO2, skin temperature deviation, breathing variations. GarminDB downloads these fields but does not import them into its database.<vault>/Health-Context-Log.md, optional: what he logged about a night (see Context log below).
- Population reference values (
src/norms.ts) are fixed for men aged 45-49 and come from four open-access papers, saved in the vault under2-resources/files/20-health-and-fitness/23-wearable-norms/. The note warns when the Garmin profile leaves that group; then the table needs new values. - Device the numbers were proven on: Garmin Enduro 3, firmware 10.23 to 23.39, 2024-10-02 to 2026-10-19.
How a night is classified
Each metric is scored as a robust z (median and MAD) against the previous 28 nights, never including the night itself. A night is low-HRV when its HRV z is -1.5 or lower. Low-HRV nights are then labelled, in this order:
- Illness-like: two or more of resting or sleep HR up (z >= 1), respiration up (z >= 1), skin temperature up (>= +0.5 degC or z >= 1.5), SpO2 down (z <= -1), and one of them must be respiration or temperature.
- Stress-like: the previous day's average stress z >= 1.
- Short or late sleep: under 6 h, or bedtime z >= 1.5.
- Unexplained.
Low-HRV nights at most one night apart form an episode.
Overnight stress is deliberately not a classifier input: Garmin computes stress from HRV, so it would label nights by their own HRV. Daytime stress is also HR/HRV-derived and rises when ill, which is why the illness rule runs first.
Context log
The watch cannot know about a party or a late meal, and GarminDB does not
import Garmin Connect's lifestyle logging. The vault note
Health-Context-Log.md holds one line per evening, editable from the phone:
- 2026-10-31 alcohol: Halloween party, beers
The date is the evening the night starts; the entry applies to the night dated
by the next morning. Tags: alcohol, late-meal, hot-room, travel,
illness, stress, training, other, comma-separated; the note is
optional. Other lines are ignored, and a dated line that cannot be read is
listed as a warning in the live note.
The log only adds. Measured values, baselines and z-scores stay as Garmin
recorded them; each night gains context and sensorLabel (the reading from
the sensors alone). A low-HRV night with any tag except illness becomes
known-cause and is left out of the episodes; an illness entry keeps the
sensor reading.
Firmware breaks
A watch update can change how a metric is measured. Each firmware install date is a candidate break; if the median of the 14 nights after it differs from the 14 before by 3 robust SD or more, the metric's baseline restarts there and its trend uses only the data after the break. On this watch, firmware 13.31 (2025-02-20) moved sleep respiration from 11 to 15.5 br/min overnight.
Limits
- Wrist HRV is about 10 % off a chest strap; only relative changes are used.
- Wrist pulse oximetry reads low with movement or a loose strap. The SpO2 figures are a reason to ask about a sleep study, not a measurement of apnea.
- A single illness-like night also fits alcohol, a late heavy meal or a hot bedroom. Multi-night episodes are the stronger evidence.
- Mann-Kendall p-values on weekly medians are optimistic because weekly values are still autocorrelated.
- Health Status from the GDPR export is not used yet.