← Back to project
● Shipped P2 Size M Vertical app

health-coach — Implementation

Tech stack, module structure, schedule conversion, secrets handling, deployment runbook.

Module layout

~/health-coach/
├── health_coach/
│   ├── loader.py          # Garmin JSON → DataFrame (12 metrics)
│   ├── stats.py           # rolling baseline + anomaly z≥1.5 + recovery score + activity↔sleep r
│   ├── correlation.py     # Tier 1 personal lag-1 priors
│   ├── weighting.py       # 11 Tier 2 literature priors + merge_personal_priors()
│   ├── planner.py         # daily plan: traffic light + smart cap + ranked actions
│   ├── retro.py           # descriptive analysis (weekday pattern, RHR trend, HRV percentiles)
│   ├── digest.py          # Haiku LLM weekly, VN weekday format
│   ├── chart.py           # 7-dot weekly + 4-week multi-metric trend PNG
│   ├── manual_log.py      # Option C: anomaly + Saturday batch + streak-aware
│   ├── calendar_reader.py # Google Calendar API → upcoming stressful events
│   ├── delivery.py        # Telegram + iMessage + macOS notif (channel-agnostic)
│   └── cli.py             # 9 subcommands
├── scripts/
│   ├── run-cli.sh         # wrapper: load env + exec CLI
│   └── google_oauth_setup.py
├── data/                  # nudges.jsonl, manual_log.jsonl, charts/
├── samples/               # sample digest outputs (M1 verification)
└── venv/

CLI subcommands (cli.py)

SubcommandPurpose
digest [--send] [--no-llm]Weekly digest (M1). --send → Telegram
dump --days NPrint loaded DataFrame (debug)
morning-ping [--dry-run]Daily 11:00 VN morning ping (M2)
fire-due [--dry-run]Every 15 min — fire any timed nudge whose slot is due now
weekly-chart [--dry-run]Weekly 7-dot PNG → Telegram attachment
`log-prompt —kind {autoanomaly
parse-log "<text>"Parse a reply via Haiku NLP, append to manual_log.jsonl
anomaly-check [--dry-run]Daily 08:00 — alert if overnight anomaly (M2)

Stats engine (stats.py)

MetricComputation
weekly_summarylast 7 rows = current week; preceding 30 rows = baseline; per-metric avg/min/max/Δ/z
_detect_anomaliesper-day deviation; flag if
_recovery_scorecomposite 0–100; weighted z-score: HRV 40% + RHR 30% (inverted) + sleep 30%
_activity_sleep_correlationlag-1 Pearson r between today’s training_load and tomorrow’s sleep_score (n≥10)

Schedule conversion (UTC ↔ VN local)

VM is UTC. systemd OnCalendar interprets in UTC by default.

TriggerUTCVN localsystemd OnCalendar
morning-ping04:0011:00*-*-* 04:00:00
anomaly-check01:0008:00*-*-* 01:00:00
fire-dueevery 15 minOnUnitActiveSec=15min
weekly-digestSat 14:00T7 21:00Sat *-*-* 14:00:00
weekly-chartSat 14:05T7 21:05Sat *-*-* 14:05:00
log-promptSat 14:30T7 21:30Sat *-*-* 14:30:00
garmin-pullevery 30 minOnUnitActiveSec=30min

Week defined: Sunday → Saturday (week-start anchor matches user preference).

Secrets layout

~/.config/health-coach/        chmod 700
├── anthropic_api_key          chmod 600  (raw token)
├── telegram.json              chmod 600  ({"bot_token": "...", "chat_id": "..."})
├── google_client.json         chmod 600  ({"client_id": "...", "client_secret": "..."})
├── google_token.json          chmod 600  (auto-refreshing OAuth refresh token)
└── gcal_ical_url              chmod 600  (legacy; iCal feed approach was killed by Workspace policy)

The run-cli.sh wrapper loads each into env (ANTHROPIC_API_KEY, TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, etc.) so all CLI subcommands inherit them.

Garmin auto-pull (deploy key)

GitHub deploy key — read-only SSH access to one repo, scoped to the VM:

~/.ssh/garmin_sync_deploy        # ed25519, work-mac-scope removed for VM
~/.ssh/config:                   # alias 'github-garmin-sync' uses this key
   Host github-garmin-sync
       HostName github.com
       IdentityFile ~/.ssh/garmin_sync_deploy
       IdentitiesOnly yes

~/garmin-sync remote:
   git@github-garmin-sync:marcng-study/garmin-sync.git

garmin-pull.timer runs git pull --quiet every 30 minutes. No write access — even if the VM is breached, the data lake repo is read-only.

Apple iMessage discoveries (2026-04-30)

Captured for posterity since the iMessage delivery path is still maintained as a fallback (DELIVERY=imessage):

  1. Dedupe — identical bodies sent within minutes are silently dropped. Fix: _ensure_unique() appends nonce {unix-timestamp}.
  2. Per-recipient rate-limit — >10 messages in 1–2 hours to the same handle silently drop. Fix: rotate handles (3 available: nm.tri@icloud.com, nmtri@live.com, tringuyen113@gmail.com).
  3. Spam-classifier flags template-looking content: multi-line + bullet, decimal-comma patterns (78.0, 34.0, 55), compound number+unit (30min, 10min), special chars (±), short paren codes. Fix: single-line conversational Vietnamese, integer values, full units.

Telegram has none of these — multi-line, bullets, emoji, markdown bold all render natively.

Deploy on a fresh host

  1. Provision (Linux with systemd, Python 3.11+ recommended).
  2. rsync ~/health-coach/ + ~/.config/health-coach/ + ~/.ssh/garmin_sync_deploy.
  3. python3 -m venv venv && ./venv/bin/pip install -r requirements.txt && ./venv/bin/pip install matplotlib google-auth google-auth-oauthlib google-api-python-client.
  4. git init garmin-sync repo with deploy-key remote.
  5. Copy systemd units to ~/.config/systemd/user/, loginctl enable-linger $USER, systemctl --user enable --now <units>.
  6. Smoke test ~/health-coach/scripts/run-cli.sh morning-ping.

Migration from GCP VM (post-credit) to OCI A1 / Hetzner CAX11 follows the same pattern. ~1 hour effort.