hae-vault 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/.env.example +7 -0
  2. package/CLAUDE.md +220 -0
  3. package/README.md +206 -0
  4. package/SKILL.md +60 -0
  5. package/dist/cli/dashboard.d.ts +3 -0
  6. package/dist/cli/dashboard.d.ts.map +1 -0
  7. package/dist/cli/dashboard.js +206 -0
  8. package/dist/cli/dashboard.js.map +1 -0
  9. package/dist/cli/import.d.ts +3 -0
  10. package/dist/cli/import.d.ts.map +1 -0
  11. package/dist/cli/import.js +78 -0
  12. package/dist/cli/import.js.map +1 -0
  13. package/dist/cli/index.d.ts +3 -0
  14. package/dist/cli/index.d.ts.map +1 -0
  15. package/dist/cli/index.js +31 -0
  16. package/dist/cli/index.js.map +1 -0
  17. package/dist/cli/info.d.ts +5 -0
  18. package/dist/cli/info.d.ts.map +1 -0
  19. package/dist/cli/info.js +34 -0
  20. package/dist/cli/info.js.map +1 -0
  21. package/dist/cli/metrics.d.ts +3 -0
  22. package/dist/cli/metrics.d.ts.map +1 -0
  23. package/dist/cli/metrics.js +20 -0
  24. package/dist/cli/metrics.js.map +1 -0
  25. package/dist/cli/query.d.ts +3 -0
  26. package/dist/cli/query.d.ts.map +1 -0
  27. package/dist/cli/query.js +18 -0
  28. package/dist/cli/query.js.map +1 -0
  29. package/dist/cli/serve.d.ts +3 -0
  30. package/dist/cli/serve.d.ts.map +1 -0
  31. package/dist/cli/serve.js +19 -0
  32. package/dist/cli/serve.js.map +1 -0
  33. package/dist/cli/sleep.d.ts +3 -0
  34. package/dist/cli/sleep.d.ts.map +1 -0
  35. package/dist/cli/sleep.js +19 -0
  36. package/dist/cli/sleep.js.map +1 -0
  37. package/dist/cli/summary.d.ts +3 -0
  38. package/dist/cli/summary.d.ts.map +1 -0
  39. package/dist/cli/summary.js +53 -0
  40. package/dist/cli/summary.js.map +1 -0
  41. package/dist/cli/trends.d.ts +3 -0
  42. package/dist/cli/trends.d.ts.map +1 -0
  43. package/dist/cli/trends.js +77 -0
  44. package/dist/cli/trends.js.map +1 -0
  45. package/dist/cli/watch.d.ts +12 -0
  46. package/dist/cli/watch.d.ts.map +1 -0
  47. package/dist/cli/watch.js +89 -0
  48. package/dist/cli/watch.js.map +1 -0
  49. package/dist/cli/workouts.d.ts +3 -0
  50. package/dist/cli/workouts.d.ts.map +1 -0
  51. package/dist/cli/workouts.js +19 -0
  52. package/dist/cli/workouts.js.map +1 -0
  53. package/dist/config.d.ts +9 -0
  54. package/dist/config.d.ts.map +1 -0
  55. package/dist/config.js +25 -0
  56. package/dist/config.js.map +1 -0
  57. package/dist/db/importLog.d.ts +5 -0
  58. package/dist/db/importLog.d.ts.map +1 -0
  59. package/dist/db/importLog.js +10 -0
  60. package/dist/db/importLog.js.map +1 -0
  61. package/dist/db/metrics.d.ts +4 -0
  62. package/dist/db/metrics.d.ts.map +1 -0
  63. package/dist/db/metrics.js +14 -0
  64. package/dist/db/metrics.js.map +1 -0
  65. package/dist/db/schema.d.ts +5 -0
  66. package/dist/db/schema.d.ts.map +1 -0
  67. package/dist/db/schema.js +100 -0
  68. package/dist/db/schema.js.map +1 -0
  69. package/dist/db/sleep.d.ts +4 -0
  70. package/dist/db/sleep.d.ts.map +1 -0
  71. package/dist/db/sleep.js +13 -0
  72. package/dist/db/sleep.js.map +1 -0
  73. package/dist/db/workouts.d.ts +4 -0
  74. package/dist/db/workouts.d.ts.map +1 -0
  75. package/dist/db/workouts.js +11 -0
  76. package/dist/db/workouts.js.map +1 -0
  77. package/dist/index.d.ts +3 -0
  78. package/dist/index.d.ts.map +1 -0
  79. package/dist/index.js +5 -0
  80. package/dist/index.js.map +1 -0
  81. package/dist/parse/metrics.d.ts +17 -0
  82. package/dist/parse/metrics.d.ts.map +1 -0
  83. package/dist/parse/metrics.js +33 -0
  84. package/dist/parse/metrics.js.map +1 -0
  85. package/dist/parse/sleep.d.ts +23 -0
  86. package/dist/parse/sleep.d.ts.map +1 -0
  87. package/dist/parse/sleep.js +58 -0
  88. package/dist/parse/sleep.js.map +1 -0
  89. package/dist/parse/time.d.ts +4 -0
  90. package/dist/parse/time.d.ts.map +1 -0
  91. package/dist/parse/time.js +41 -0
  92. package/dist/parse/time.js.map +1 -0
  93. package/dist/parse/workouts.d.ts +17 -0
  94. package/dist/parse/workouts.d.ts.map +1 -0
  95. package/dist/parse/workouts.js +24 -0
  96. package/dist/parse/workouts.js.map +1 -0
  97. package/dist/server/app.d.ts +5 -0
  98. package/dist/server/app.d.ts.map +1 -0
  99. package/dist/server/app.js +39 -0
  100. package/dist/server/app.js.map +1 -0
  101. package/dist/server/ingest.d.ts +15 -0
  102. package/dist/server/ingest.d.ts.map +1 -0
  103. package/dist/server/ingest.js +41 -0
  104. package/dist/server/ingest.js.map +1 -0
  105. package/dist/types/hae.d.ts +103 -0
  106. package/dist/types/hae.d.ts.map +1 -0
  107. package/dist/types/hae.js +2 -0
  108. package/dist/types/hae.js.map +1 -0
  109. package/dist/util/zip.d.ts +3 -0
  110. package/dist/util/zip.d.ts.map +1 -0
  111. package/dist/util/zip.js +24 -0
  112. package/dist/util/zip.js.map +1 -0
  113. package/docs/COMMANDS.md +315 -0
  114. package/docs/plans/2026-02-18-hae-vault-initial-implementation.md +2015 -0
  115. package/docs/plans/2026-02-18-readme-dashboard-design.md +213 -0
  116. package/docs/plans/2026-02-18-readme-dashboard-plan.md +1306 -0
  117. package/docs/plans/2026-02-18-zip-env-watch-design.md +213 -0
  118. package/docs/plans/2026-02-18-zip-env-watch.md +966 -0
  119. package/package.json +57 -0
  120. package/src/cli/dashboard.ts +242 -0
  121. package/src/cli/import.ts +85 -0
  122. package/src/cli/index.ts +32 -0
  123. package/src/cli/info.ts +36 -0
  124. package/src/cli/metrics.ts +20 -0
  125. package/src/cli/query.ts +17 -0
  126. package/src/cli/serve.ts +18 -0
  127. package/src/cli/sleep.ts +19 -0
  128. package/src/cli/summary.ts +58 -0
  129. package/src/cli/trends.ts +103 -0
  130. package/src/cli/watch.ts +111 -0
  131. package/src/cli/workouts.ts +19 -0
  132. package/src/config.ts +28 -0
  133. package/src/db/importLog.ts +18 -0
  134. package/src/db/metrics.ts +15 -0
  135. package/src/db/schema.ts +105 -0
  136. package/src/db/sleep.ts +15 -0
  137. package/src/db/workouts.ts +13 -0
  138. package/src/index.ts +4 -0
  139. package/src/parse/metrics.ts +50 -0
  140. package/src/parse/sleep.ts +82 -0
  141. package/src/parse/time.ts +43 -0
  142. package/src/parse/workouts.ts +42 -0
  143. package/src/server/app.ts +46 -0
  144. package/src/server/ingest.ts +68 -0
  145. package/src/types/hae.ts +94 -0
  146. package/src/util/zip.ts +24 -0
  147. package/tests/cli-watch.test.ts +64 -0
  148. package/tests/db-import-log.test.ts +40 -0
  149. package/tests/db-metrics.test.ts +44 -0
  150. package/tests/db-schema.test.ts +55 -0
  151. package/tests/db-sleep.test.ts +36 -0
  152. package/tests/db-workouts.test.ts +34 -0
  153. package/tests/ingest.test.ts +99 -0
  154. package/tests/parse-metrics.test.ts +55 -0
  155. package/tests/parse-sleep.test.ts +65 -0
  156. package/tests/parse-time.test.ts +48 -0
  157. package/tests/parse-workouts.test.ts +43 -0
  158. package/tests/types.test.ts +27 -0
  159. package/tests/util-zip.test.ts +46 -0
  160. package/tsconfig.json +19 -0
@@ -0,0 +1,1306 @@
1
+ # hae-vault README, Dashboard & CLI Polish Implementation Plan
2
+
3
+ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+ **Goal:** Add a rich terminal dashboard, trends, colored summary to hae-vault CLI; write full README + COMMANDS docs; configure package.json and push to GitHub.
6
+
7
+ **Architecture:** No new dependencies. Pure string formatting in TypeScript. Three new/modified CLI files (`dashboard.ts`, `trends.ts`, `summary.ts`). All queries use existing `openDb()` + better-sqlite3. Display logic is self-contained — no shared formatter module (YAGNI).
8
+
9
+ **Tech Stack:** TypeScript, better-sqlite3, Commander.js, Node.js 22+
10
+
11
+ ---
12
+
13
+ ## Context
14
+
15
+ - **CLI binary:** `hvault` (package `hae-vault`)
16
+ - **DB path:** `~/.hae-vault/health.db` (via `openDb()` from `src/db/schema.ts`)
17
+ - **Build:** `npm run build` (tsc → dist/); dev run: `npm run dev -- <cmd>`
18
+ - **Test:** `npm test` (tsx --test tests/**/*.test.ts)
19
+ - **Schema key facts:**
20
+ - `metrics(ts, date, metric, qty, min, avg, max, units, source, target)`
21
+ - `sleep(date, sleep_start, sleep_end, core_h, deep_h, rem_h, awake_h, asleep_h, in_bed_h, source)`
22
+ - `workouts(ts, date, name, duration_s, calories_kj, distance, distance_unit, avg_hr, max_hr, target)`
23
+ - `sync_log(received_at, target, metrics_count, workouts_count)`
24
+ - `import_log(filename, file_hash, imported_at, metrics_added, sleep_added, workouts_added)`
25
+ - Sleep light hours = `asleep_h - deep_h - rem_h` (no separate column)
26
+ - `duration_s` is seconds; `calories_kj` is kilojoules (÷ 4.184 = kcal)
27
+ - **Existing CLI files:** `src/cli/{index,serve,import,watch,metrics,sleep,workouts,summary,query,info}.ts`
28
+
29
+ ---
30
+
31
+ ## Task 1: Update package.json
32
+
33
+ **Files:**
34
+ - Modify: `package.json`
35
+
36
+ **Step 1: Add metadata fields**
37
+
38
+ Edit `package.json` — add these fields after `"license": "MIT"`:
39
+
40
+ ```json
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "https://github.com/mrkhachaturov/hae-vault.git"
44
+ },
45
+ "homepage": "https://github.com/mrkhachaturov/hae-vault#readme",
46
+ "bugs": {
47
+ "url": "https://github.com/mrkhachaturov/hae-vault/issues"
48
+ },
49
+ "keywords": [
50
+ "apple-health",
51
+ "health-auto-export",
52
+ "sqlite",
53
+ "cli",
54
+ "hvault",
55
+ "sleep",
56
+ "hrv",
57
+ "steps",
58
+ "fitness",
59
+ "hae"
60
+ ],
61
+ ```
62
+
63
+ Final `package.json` (complete):
64
+
65
+ ```json
66
+ {
67
+ "name": "hae-vault",
68
+ "version": "0.1.0",
69
+ "description": "CLI + HTTP server for Apple Health data from Health Auto Export",
70
+ "type": "module",
71
+ "bin": {
72
+ "hvault": "./dist/index.js"
73
+ },
74
+ "main": "./dist/index.js",
75
+ "scripts": {
76
+ "dev": "tsx src/index.ts",
77
+ "build": "tsc",
78
+ "start": "node dist/index.js",
79
+ "prepare": "npm run build",
80
+ "test": "tsx --test tests/**/*.test.ts"
81
+ },
82
+ "engines": {
83
+ "node": ">=22.0.0"
84
+ },
85
+ "keywords": [
86
+ "apple-health",
87
+ "health-auto-export",
88
+ "sqlite",
89
+ "cli",
90
+ "hvault",
91
+ "sleep",
92
+ "hrv",
93
+ "steps",
94
+ "fitness",
95
+ "hae"
96
+ ],
97
+ "author": "Ruben Khachaturov <mr.kha4a2rov@protonmail.com>",
98
+ "license": "MIT",
99
+ "repository": {
100
+ "type": "git",
101
+ "url": "https://github.com/mrkhachaturov/hae-vault.git"
102
+ },
103
+ "homepage": "https://github.com/mrkhachaturov/hae-vault#readme",
104
+ "bugs": {
105
+ "url": "https://github.com/mrkhachaturov/hae-vault/issues"
106
+ },
107
+ "dependencies": {
108
+ "adm-zip": "^0.5.16",
109
+ "better-sqlite3": "^12.6.2",
110
+ "commander": "^12.1.0",
111
+ "dotenv": "^17.3.1",
112
+ "express": "^4.21.0"
113
+ },
114
+ "devDependencies": {
115
+ "@types/adm-zip": "^0.5.7",
116
+ "@types/better-sqlite3": "^7.6.11",
117
+ "@types/express": "^4.17.21",
118
+ "@types/node": "^22.10.0",
119
+ "tsx": "^4.19.0",
120
+ "typescript": "^5.7.0"
121
+ }
122
+ }
123
+ ```
124
+
125
+ **Step 2: Verify JSON is valid**
126
+
127
+ ```bash
128
+ node -e "JSON.parse(require('fs').readFileSync('package.json','utf8')); console.log('valid')"
129
+ ```
130
+
131
+ Expected: `valid`
132
+
133
+ **Step 3: Commit**
134
+
135
+ ```bash
136
+ git add package.json
137
+ git commit -m "chore: add repository/homepage/bugs/keywords to package.json"
138
+ ```
139
+
140
+ ---
141
+
142
+ ## Task 2: Create `src/cli/dashboard.ts`
143
+
144
+ **Files:**
145
+ - Create: `src/cli/dashboard.ts`
146
+ - Modify: `src/cli/index.ts`
147
+
148
+ **Step 1: Create the file**
149
+
150
+ ```typescript
151
+ // src/cli/dashboard.ts
152
+ import { Command } from 'commander';
153
+ import { openDb } from '../db/schema.js';
154
+
155
+ // ── helpers ──────────────────────────────────────────────────────────────────
156
+
157
+ function pad(s: string, width: number): string {
158
+ return s.padEnd(width);
159
+ }
160
+
161
+ function fmt1(n: number | null | undefined): string {
162
+ if (n == null) return '—';
163
+ return n.toFixed(1);
164
+ }
165
+
166
+ function fmtInt(n: number | null | undefined): string {
167
+ if (n == null) return '—';
168
+ return Math.round(n).toLocaleString();
169
+ }
170
+
171
+ function sec2min(s: number | null | undefined): string {
172
+ if (s == null) return '—';
173
+ return `${Math.round(s / 60)}min`;
174
+ }
175
+
176
+ function kj2kcal(kj: number | null | undefined): string {
177
+ if (kj == null) return '—';
178
+ return `${Math.round(kj / 4.184)} kcal`;
179
+ }
180
+
181
+ function arrow(first: number, last: number): string {
182
+ const delta = last - first;
183
+ if (Math.abs(delta) < 0.01 * Math.abs(first || 1)) return '→';
184
+ return delta > 0 ? '↑' : '↓';
185
+ }
186
+
187
+ function ruler(label: string, width = 43): string {
188
+ const inner = `── ${label} `;
189
+ return inner + '─'.repeat(Math.max(0, width - inner.length));
190
+ }
191
+
192
+ function workoutEmoji(name: string): string {
193
+ const n = name.toLowerCase();
194
+ if (n.includes('run')) return '🏃';
195
+ if (n.includes('walk')) return '🚶';
196
+ if (n.includes('cycl') || n.includes('bike')) return '🚴';
197
+ if (n.includes('swim')) return '🏊';
198
+ if (n.includes('yoga')) return '🧘';
199
+ if (n.includes('strength') || n.includes('weight') || n.includes('lift')) return '🏋️';
200
+ if (n.includes('hike')) return '🥾';
201
+ return '🏅';
202
+ }
203
+
204
+ function latestMetric(
205
+ db: ReturnType<typeof openDb>,
206
+ metricName: string,
207
+ days = 7
208
+ ): number | null {
209
+ const since = new Date();
210
+ since.setDate(since.getDate() - days);
211
+ const row = db.prepare(
212
+ `SELECT qty FROM metrics WHERE metric = ? AND date >= ? AND qty IS NOT NULL ORDER BY ts DESC LIMIT 1`
213
+ ).get(metricName, since.toISOString().slice(0, 10)) as { qty: number } | undefined;
214
+ return row?.qty ?? null;
215
+ }
216
+
217
+ function dailyAvgs(
218
+ db: ReturnType<typeof openDb>,
219
+ metricName: string,
220
+ days: number
221
+ ): number[] {
222
+ const since = new Date();
223
+ since.setDate(since.getDate() - days);
224
+ const rows = db.prepare(
225
+ `SELECT date, AVG(qty) as avg_qty FROM metrics
226
+ WHERE metric = ? AND date >= ? AND qty IS NOT NULL
227
+ GROUP BY date ORDER BY date ASC`
228
+ ).all(metricName, since.toISOString().slice(0, 10)) as { date: string; avg_qty: number }[];
229
+ return rows.map(r => r.avg_qty);
230
+ }
231
+
232
+ function sleepDailyAvgs(
233
+ db: ReturnType<typeof openDb>,
234
+ days: number
235
+ ): number[] {
236
+ const since = new Date();
237
+ since.setDate(since.getDate() - days);
238
+ const rows = db.prepare(
239
+ `SELECT asleep_h FROM sleep WHERE date >= ? AND asleep_h IS NOT NULL ORDER BY date ASC`
240
+ ).all(since.toISOString().slice(0, 10)) as { asleep_h: number }[];
241
+ return rows.map(r => r.asleep_h);
242
+ }
243
+
244
+ function trendLine(
245
+ values: number[],
246
+ label: string,
247
+ unit: string,
248
+ round = false
249
+ ): string {
250
+ if (values.length < 2) return '';
251
+ const avg = values.reduce((a, b) => a + b, 0) / values.length;
252
+ const first = values[0];
253
+ const last = values[values.length - 1];
254
+ const dir = arrow(first, last);
255
+ const fmt = round ? fmtInt : fmt1;
256
+ return ` ${pad(label + ':', 14)} ${fmt(first)} → ${fmt(last)}${unit} ${dir} (avg ${fmt(avg)})`;
257
+ }
258
+
259
+ // ── command ───────────────────────────────────────────────────────────────────
260
+
261
+ export const dashboardCommand = new Command('dashboard')
262
+ .description('Terminal dashboard: sleep, activity, heart health, workouts, trends')
263
+ .option('--days <n>', 'Trend window in days', '7')
264
+ .option('--json', 'Output raw JSON')
265
+ .action((opts) => {
266
+ const db = openDb();
267
+ const trendDays = parseInt(opts.days, 10);
268
+ const today = new Date().toISOString().slice(0, 10);
269
+
270
+ // ── sleep (last night) ─────────────────────────────────────────────────
271
+ const sleep = db.prepare(
272
+ `SELECT * FROM sleep ORDER BY date DESC LIMIT 1`
273
+ ).get() as {
274
+ date: string; asleep_h: number | null; in_bed_h: number | null;
275
+ deep_h: number | null; rem_h: number | null; awake_h: number | null;
276
+ source: string | null;
277
+ } | undefined;
278
+
279
+ // ── activity (today, fallback last 2 days) ─────────────────────────────
280
+ const steps = latestMetric(db, 'step_count', 2);
281
+ const activeCal = latestMetric(db, 'active_energy_burned', 2);
282
+ const standHours = latestMetric(db, 'apple_stand_hour', 2);
283
+
284
+ // ── heart health (last 7 days) ─────────────────────────────────────────
285
+ const restingHR = latestMetric(db, 'resting_heart_rate', 7);
286
+ const hrv = latestMetric(db, 'heart_rate_variability_sdnn', 7);
287
+
288
+ // ── recent workouts ────────────────────────────────────────────────────
289
+ const workouts = db.prepare(
290
+ `SELECT date, name, duration_s, calories_kj, avg_hr FROM workouts ORDER BY ts DESC LIMIT 5`
291
+ ).all() as { date: string; name: string; duration_s: number | null; calories_kj: number | null; avg_hr: number | null }[];
292
+
293
+ // ── trends ────────────────────────────────────────────────────────────
294
+ const stepTrend = dailyAvgs(db, 'step_count', trendDays);
295
+ const hrTrend = dailyAvgs(db, 'resting_heart_rate', trendDays);
296
+ const hrvTrend = dailyAvgs(db, 'heart_rate_variability_sdnn', trendDays);
297
+ const sleepTrend = sleepDailyAvgs(db, trendDays);
298
+
299
+ // ── vault stats ───────────────────────────────────────────────────────
300
+ const metricsCount = (db.prepare('SELECT COUNT(*) as c FROM metrics').get() as { c: number }).c;
301
+ const sleepCount = (db.prepare('SELECT COUNT(*) as c FROM sleep').get() as { c: number }).c;
302
+ const workoutsCount = (db.prepare('SELECT COUNT(*) as c FROM workouts').get() as { c: number }).c;
303
+ const lastSync = db.prepare('SELECT received_at FROM sync_log ORDER BY received_at DESC LIMIT 1').get() as { received_at: string } | undefined;
304
+
305
+ if (opts.json) {
306
+ console.log(JSON.stringify({
307
+ date: today,
308
+ sleep: sleep ?? null,
309
+ activity: { steps, activeCal, standHours },
310
+ heartHealth: { restingHR, hrv },
311
+ workouts,
312
+ trends: { steps: stepTrend, restingHR: hrTrend, hrv: hrvTrend, sleep: sleepTrend },
313
+ vault: { metricsCount, sleepCount, workoutsCount, lastSync: lastSync?.received_at ?? null }
314
+ }, null, 2));
315
+ return;
316
+ }
317
+
318
+ const lines: string[] = [];
319
+ lines.push(`📅 ${today} | Apple Health Vault`);
320
+ lines.push('');
321
+
322
+ // sleep
323
+ lines.push(ruler('Sleep (last night)'));
324
+ if (sleep) {
325
+ const eff = sleep.in_bed_h && sleep.asleep_h
326
+ ? Math.round((sleep.asleep_h / sleep.in_bed_h) * 100) : null;
327
+ lines.push(`😴 ${fmt1(sleep.asleep_h)}h | Efficiency: ${eff != null ? eff + '%' : '—'}`);
328
+ const light = (sleep.asleep_h ?? 0) - (sleep.deep_h ?? 0) - (sleep.rem_h ?? 0);
329
+ const deepPct = sleep.asleep_h ? Math.round(((sleep.deep_h ?? 0) / sleep.asleep_h) * 100) : 0;
330
+ const remPct = sleep.asleep_h ? Math.round(((sleep.rem_h ?? 0) / sleep.asleep_h) * 100) : 0;
331
+ const lightPct = sleep.asleep_h ? Math.round((light / sleep.asleep_h) * 100) : 0;
332
+ lines.push(` Deep: ${fmt1(sleep.deep_h)}h (${deepPct}%) | REM: ${fmt1(sleep.rem_h)}h (${remPct}%) | Light: ${fmt1(light)}h (${lightPct}%)`);
333
+ lines.push(` Awake: ${fmt1(sleep.awake_h)}h | Source: ${sleep.source ?? '—'}`);
334
+ } else {
335
+ lines.push(' No sleep data');
336
+ }
337
+ lines.push('');
338
+
339
+ // activity
340
+ lines.push(ruler('Activity (recent)'));
341
+ const actParts: string[] = [];
342
+ if (steps != null) actParts.push(`👟 ${fmtInt(steps)} steps`);
343
+ if (activeCal != null) actParts.push(`🔥 ${fmtInt(activeCal)} kcal active`);
344
+ if (actParts.length > 0) {
345
+ lines.push(actParts.join(' | '));
346
+ if (standHours != null) lines.push(` Stand hours: ${Math.round(standHours)}`);
347
+ } else {
348
+ lines.push(' No activity data');
349
+ }
350
+ lines.push('');
351
+
352
+ // heart health
353
+ lines.push(ruler('Heart Health'));
354
+ const hh: string[] = [];
355
+ if (restingHR != null) hh.push(`💓 Resting HR: ${Math.round(restingHR)}bpm`);
356
+ if (hrv != null) hh.push(`HRV: ${Math.round(hrv)}ms`);
357
+ lines.push(hh.length > 0 ? hh.join(' | ') : ' No heart data');
358
+ lines.push('');
359
+
360
+ // workouts
361
+ lines.push(ruler('Recent Workouts'));
362
+ if (workouts.length > 0) {
363
+ for (const w of workouts) {
364
+ const emoji = workoutEmoji(w.name);
365
+ const namePadded = pad(w.name, 18);
366
+ const dur = sec2min(w.duration_s);
367
+ const cal = kj2kcal(w.calories_kj);
368
+ lines.push(`${emoji} ${w.date} ${namePadded} ${dur} ${cal}`);
369
+ }
370
+ } else {
371
+ lines.push(' No workout data');
372
+ }
373
+ lines.push('');
374
+
375
+ // trends
376
+ lines.push(ruler(`${trendDays}-Day Trends`));
377
+ const tl: string[] = [
378
+ trendLine(stepTrend, 'Steps', '', true),
379
+ trendLine(sleepTrend, 'Sleep', 'h'),
380
+ trendLine(hrTrend, 'Resting HR', 'bpm', true),
381
+ trendLine(hrvTrend, 'HRV', 'ms', true),
382
+ ].filter(Boolean);
383
+ if (tl.length > 0) lines.push(...tl);
384
+ else lines.push(' Insufficient data for trends');
385
+ lines.push('');
386
+
387
+ // vault stats
388
+ lines.push(ruler('Vault Stats'));
389
+ lines.push(` Metrics: ${metricsCount.toLocaleString()} | Sleep: ${sleepCount} | Workouts: ${workoutsCount}`);
390
+ lines.push(` Last sync: ${lastSync?.received_at ? new Date(lastSync.received_at).toISOString().replace('T', ' ').slice(0, 16) + ' UTC' : 'never'}`);
391
+
392
+ console.log(lines.join('\n'));
393
+ });
394
+ ```
395
+
396
+ **Step 2: Register in `src/cli/index.ts`**
397
+
398
+ Add at top (after existing imports):
399
+
400
+ ```typescript
401
+ import { dashboardCommand } from './dashboard.js';
402
+ ```
403
+
404
+ Add before `program.addCommand(sourcesCommand)`:
405
+
406
+ ```typescript
407
+ program.addCommand(dashboardCommand);
408
+ ```
409
+
410
+ **Step 3: Build**
411
+
412
+ ```bash
413
+ npm run build
414
+ ```
415
+
416
+ Expected: zero TypeScript errors.
417
+
418
+ **Step 4: Smoke test**
419
+
420
+ ```bash
421
+ npm run dev -- dashboard
422
+ ```
423
+
424
+ Expected: renders the dashboard with real data from `~/.hae-vault/health.db`. Sections show data or graceful `—` for missing metrics.
425
+
426
+ ```bash
427
+ npm run dev -- dashboard --json
428
+ ```
429
+
430
+ Expected: valid JSON object.
431
+
432
+ **Step 5: Commit**
433
+
434
+ ```bash
435
+ git add src/cli/dashboard.ts src/cli/index.ts
436
+ git commit -m "feat: add hvault dashboard command"
437
+ ```
438
+
439
+ ---
440
+
441
+ ## Task 3: Create `src/cli/trends.ts`
442
+
443
+ **Files:**
444
+ - Create: `src/cli/trends.ts`
445
+ - Modify: `src/cli/index.ts`
446
+
447
+ **Step 1: Create the file**
448
+
449
+ ```typescript
450
+ // src/cli/trends.ts
451
+ import { Command } from 'commander';
452
+ import { openDb } from '../db/schema.js';
453
+
454
+ function fmt1(n: number): string {
455
+ return n.toFixed(1);
456
+ }
457
+
458
+ function fmtInt(n: number): string {
459
+ return Math.round(n).toLocaleString();
460
+ }
461
+
462
+ function arrow(values: number[]): string {
463
+ if (values.length < 2) return '→';
464
+ const half = Math.floor(values.length / 2);
465
+ const firstHalfAvg = values.slice(0, half).reduce((a, b) => a + b, 0) / half;
466
+ const secondHalfAvg = values.slice(half).reduce((a, b) => a + b, 0) / (values.length - half);
467
+ const delta = secondHalfAvg - firstHalfAvg;
468
+ if (Math.abs(delta) < 0.01 * Math.abs(firstHalfAvg || 1)) return '→';
469
+ return delta > 0 ? '↑' : '↓';
470
+ }
471
+
472
+ interface MetricTrendRow { date: string; avg_qty: number }
473
+
474
+ function metricTrend(
475
+ db: ReturnType<typeof openDb>,
476
+ metricName: string,
477
+ since: string
478
+ ): { values: number[]; avg: number; min: number; max: number } | null {
479
+ const rows = db.prepare(
480
+ `SELECT date, AVG(qty) as avg_qty FROM metrics
481
+ WHERE metric = ? AND date >= ? AND qty IS NOT NULL
482
+ GROUP BY date ORDER BY date ASC`
483
+ ).all(metricName, since) as MetricTrendRow[];
484
+ if (rows.length === 0) return null;
485
+ const values = rows.map(r => r.avg_qty);
486
+ const avg = values.reduce((a, b) => a + b, 0) / values.length;
487
+ return { values, avg, min: Math.min(...values), max: Math.max(...values) };
488
+ }
489
+
490
+ function sleepTrend(
491
+ db: ReturnType<typeof openDb>,
492
+ since: string
493
+ ): { values: number[]; avg: number; min: number; max: number } | null {
494
+ const rows = db.prepare(
495
+ `SELECT asleep_h FROM sleep WHERE date >= ? AND asleep_h IS NOT NULL ORDER BY date ASC`
496
+ ).all(since) as { asleep_h: number }[];
497
+ if (rows.length === 0) return null;
498
+ const values = rows.map(r => r.asleep_h);
499
+ const avg = values.reduce((a, b) => a + b, 0) / values.length;
500
+ return { values, avg, min: Math.min(...values), max: Math.max(...values) };
501
+ }
502
+
503
+ export const trendsCommand = new Command('trends')
504
+ .description('Multi-metric trend analysis with averages, ranges, and direction arrows')
505
+ .option('--days <n>', 'Days of history', '7')
506
+ .option('--json', 'Output raw JSON')
507
+ .action((opts) => {
508
+ const db = openDb();
509
+ const days = parseInt(opts.days, 10);
510
+ const since = new Date();
511
+ since.setDate(since.getDate() - days);
512
+ const sinceStr = since.toISOString().slice(0, 10);
513
+
514
+ const steps = metricTrend(db, 'step_count', sinceStr);
515
+ const restingHR = metricTrend(db, 'resting_heart_rate', sinceStr);
516
+ const hrv = metricTrend(db, 'heart_rate_variability_sdnn', sinceStr);
517
+ const activeCal = metricTrend(db, 'active_energy_burned', sinceStr);
518
+ const sleep = sleepTrend(db, sinceStr);
519
+
520
+ if (opts.json) {
521
+ console.log(JSON.stringify({ days, steps, restingHR, hrv, activeCal, sleep }, null, 2));
522
+ return;
523
+ }
524
+
525
+ const lines: string[] = [];
526
+ lines.push(`📊 ${days}-Day Trends`);
527
+ lines.push('');
528
+
529
+ function row(
530
+ emoji: string,
531
+ label: string,
532
+ data: { values: number[]; avg: number; min: number; max: number } | null,
533
+ unit: string,
534
+ round: boolean
535
+ ): void {
536
+ if (!data) return;
537
+ const dir = arrow(data.values);
538
+ const fmt = round ? fmtInt : fmt1;
539
+ lines.push(`${emoji} ${label}: ${fmt(data.avg)} avg (${fmt(data.min)}–${fmt(data.max)}) ${dir}`);
540
+ }
541
+
542
+ row('👟', 'Steps', steps, '', true);
543
+ row('💓', 'Resting HR', restingHR, 'bpm', true);
544
+ row('🧠', 'HRV', hrv, 'ms', true);
545
+ row('😴', 'Sleep', sleep, 'h', false);
546
+ row('🔥', 'Active Cal', activeCal, 'kcal', true);
547
+
548
+ if (lines.length === 2) {
549
+ lines.push(' No trend data available');
550
+ }
551
+
552
+ console.log(lines.join('\n'));
553
+ });
554
+ ```
555
+
556
+ **Step 2: Register in `src/cli/index.ts`**
557
+
558
+ Add import:
559
+
560
+ ```typescript
561
+ import { trendsCommand } from './trends.js';
562
+ ```
563
+
564
+ Add command:
565
+
566
+ ```typescript
567
+ program.addCommand(trendsCommand);
568
+ ```
569
+
570
+ **Step 3: Build**
571
+
572
+ ```bash
573
+ npm run build
574
+ ```
575
+
576
+ Expected: zero TypeScript errors.
577
+
578
+ **Step 4: Smoke test**
579
+
580
+ ```bash
581
+ npm run dev -- trends
582
+ npm run dev -- trends --days 30
583
+ npm run dev -- trends --json
584
+ ```
585
+
586
+ **Step 5: Commit**
587
+
588
+ ```bash
589
+ git add src/cli/trends.ts src/cli/index.ts
590
+ git commit -m "feat: add hvault trends command"
591
+ ```
592
+
593
+ ---
594
+
595
+ ## Task 4: Enhance `src/cli/summary.ts` with `--color`
596
+
597
+ **Files:**
598
+ - Modify: `src/cli/summary.ts`
599
+
600
+ **Step 1: Rewrite the file**
601
+
602
+ Replace entirely with:
603
+
604
+ ```typescript
605
+ // src/cli/summary.ts
606
+ import { Command } from 'commander';
607
+ import { openDb } from '../db/schema.js';
608
+
609
+ export const summaryCommand = new Command('summary')
610
+ .description('Summarise metrics (averages) over N days')
611
+ .option('--days <n>', 'Last N days', '90')
612
+ .option('--pretty', 'Pretty-print JSON', false)
613
+ .option('-c, --color', 'Pretty terminal output with emoji indicators', false)
614
+ .option('--json', 'Raw JSON (alias for default behavior)', false)
615
+ .action((opts) => {
616
+ const db = openDb();
617
+ const since = new Date();
618
+ since.setDate(since.getDate() - parseInt(opts.days, 10));
619
+ const sinceStr = since.toISOString().slice(0, 10);
620
+
621
+ if (opts.color) {
622
+ // ── colored terminal output ──────────────────────────────────────────
623
+ const days = parseInt(opts.days, 10);
624
+
625
+ function avgMetric(metricName: string): number | null {
626
+ const row = db.prepare(
627
+ `SELECT AVG(qty) as avg FROM metrics WHERE metric = ? AND date >= ? AND qty IS NOT NULL`
628
+ ).get(metricName, sinceStr) as { avg: number | null } | undefined;
629
+ return row?.avg ?? null;
630
+ }
631
+
632
+ const steps = avgMetric('step_count');
633
+ const restingHR = avgMetric('resting_heart_rate');
634
+ const hrv = avgMetric('heart_rate_variability_sdnn');
635
+ const activeCal = avgMetric('active_energy_burned');
636
+
637
+ const sleepRow = db.prepare(
638
+ `SELECT AVG(asleep_h) as avg FROM sleep WHERE date >= ? AND asleep_h IS NOT NULL`
639
+ ).get(sinceStr) as { avg: number | null } | undefined;
640
+ const sleep = sleepRow?.avg ?? null;
641
+
642
+ const lines: string[] = [`📊 ${days}-Day Summary`, ''];
643
+ if (steps != null) lines.push(`👟 Avg Steps: ${Math.round(steps).toLocaleString()}`);
644
+ if (restingHR != null) lines.push(`💓 Avg Resting HR: ${Math.round(restingHR)}bpm`);
645
+ if (hrv != null) lines.push(`🧠 Avg HRV: ${Math.round(hrv)}ms`);
646
+ if (sleep != null) lines.push(`😴 Avg Sleep: ${sleep.toFixed(1)}h`);
647
+ if (activeCal != null) lines.push(`🔥 Avg Active Cal: ${Math.round(activeCal).toLocaleString()} kcal`);
648
+
649
+ if (lines.length === 2) lines.push(' No summary data available');
650
+ console.log(lines.join('\n'));
651
+ return;
652
+ }
653
+
654
+ // ── JSON output (default + --pretty) ────────────────────────────────────
655
+ const rows = db.prepare(`
656
+ SELECT metric, units,
657
+ AVG(qty) as avg_qty, MIN(qty) as min_qty, MAX(qty) as max_qty,
658
+ COUNT(*) as count,
659
+ MIN(date) as first_date, MAX(date) as last_date
660
+ FROM metrics
661
+ WHERE date >= ? AND qty IS NOT NULL
662
+ GROUP BY metric, units
663
+ ORDER BY metric ASC
664
+ `).all(sinceStr);
665
+ console.log(opts.pretty ? JSON.stringify(rows, null, 2) : JSON.stringify(rows));
666
+ });
667
+ ```
668
+
669
+ **Step 2: Build**
670
+
671
+ ```bash
672
+ npm run build
673
+ ```
674
+
675
+ Expected: zero TypeScript errors.
676
+
677
+ **Step 3: Smoke test**
678
+
679
+ ```bash
680
+ npm run dev -- summary --color
681
+ npm run dev -- summary --color --days 30
682
+ npm run dev -- summary --pretty
683
+ ```
684
+
685
+ **Step 4: Commit**
686
+
687
+ ```bash
688
+ git add src/cli/summary.ts
689
+ git commit -m "feat: add --color flag to hvault summary"
690
+ ```
691
+
692
+ ---
693
+
694
+ ## Task 5: Create `README.md`
695
+
696
+ **Files:**
697
+ - Create: `README.md`
698
+
699
+ **Step 1: Write the file**
700
+
701
+ ```markdown
702
+ # hae-vault
703
+
704
+ [![npm version](https://img.shields.io/npm/v/hae-vault.svg)](https://www.npmjs.com/package/hae-vault)
705
+
706
+ CLI + HTTP server for Apple Health data from the [Health Auto Export](https://www.healthexportapp.com) iOS app. Ingests via REST API or ZIP file, stores in local SQLite.
707
+
708
+ ```bash
709
+ npm install -g hae-vault
710
+ ```
711
+
712
+ ## Quick start
713
+
714
+ ```bash
715
+ hvault serve # start ingest server (port 4242)
716
+ hvault import export.zip # bulk import from HAE export file
717
+ hvault dashboard # full terminal dashboard
718
+ hvault summary --color # N-day averages with emoji indicators
719
+ ```
720
+
721
+ ## Setup
722
+
723
+ 1. Install the [Health Auto Export](https://www.healthexportapp.com) iOS app
724
+ 2. In HAE: Settings → REST API → set server URL to `http://your-server:4242/api/ingest`
725
+ 3. Or: export a ZIP from HAE and run `hvault import export.zip`
726
+
727
+ **Optional:** create `.env` in your working directory to override defaults:
728
+
729
+ ```env
730
+ HVAULT_DB_PATH=~/.hae-vault/health.db # SQLite DB location
731
+ HVAULT_PORT=4242 # ingest server port
732
+ HVAULT_TOKEN=secret # bearer token for serve (optional)
733
+ HVAULT_WATCH_DIR=~/Downloads # directory to watch for exports
734
+ ```
735
+
736
+ ## Commands
737
+
738
+ ### Ingest
739
+
740
+ | Command | Description |
741
+ | --- | --- |
742
+ | `hvault serve` | Start HTTP server, receive HAE REST API pushes |
743
+ | `hvault import <file>` | Import HAE JSON or ZIP export (idempotent) |
744
+ | `hvault watch` | Watch directory and auto-import new HAE exports |
745
+
746
+ ### Query
747
+
748
+ Output is JSON by default. Add `--pretty` for formatted JSON.
749
+
750
+ | Command | Description |
751
+ | --- | --- |
752
+ | `hvault metrics --metric <name>` | Time series for a specific metric |
753
+ | `hvault sleep` | Sleep records with stage breakdown |
754
+ | `hvault workouts` | Workout sessions |
755
+ | `hvault summary` | Per-metric averages over N days (JSON) |
756
+ | `hvault query "<sql>"` | Raw SQL query |
757
+
758
+ ### Analysis
759
+
760
+ Output is pretty-printed by default. Add `--json` for raw JSON.
761
+
762
+ | Command | Description |
763
+ | --- | --- |
764
+ | `hvault summary --color` | N-day averages with emoji indicators |
765
+ | `hvault dashboard` | Full terminal dashboard with trends |
766
+ | `hvault trends` | Multi-metric trend analysis with direction arrows |
767
+
768
+ ### Info
769
+
770
+ | Command | Description |
771
+ | --- | --- |
772
+ | `hvault sources` | Metric coverage in DB (name, count, date range) |
773
+ | `hvault last-sync` | Last HAE REST API push received |
774
+ | `hvault stats` | Row counts per table |
775
+
776
+ ## Example output
777
+
778
+ `hvault dashboard`:
779
+ ```
780
+ 📅 2026-02-18 | Apple Health Vault
781
+
782
+ ── Sleep (last night) ────────────────
783
+ 😴 7.2h | Efficiency: 94%
784
+ Deep: 1.5h (21%) | REM: 2.1h (29%) | Light: 3.6h (50%)
785
+ Awake: 0.3h | Source: Apple Watch
786
+
787
+ ── Activity (recent) ─────────────────
788
+ 👟 8,432 steps | 🔥 420 kcal active
789
+ Stand hours: 10
790
+
791
+ ── Heart Health ──────────────────────
792
+ 💓 Resting HR: 58bpm | HRV: 44ms
793
+
794
+ ── Recent Workouts ───────────────────
795
+ 🚶 2026-02-17 Walking 45min 280 kcal
796
+ 🚴 2026-02-15 Cycling 62min 420 kcal
797
+
798
+ ── 7-Day Trends ──────────────────────
799
+ Steps: 7,200 → 8,432 ↑ (avg 7,840)
800
+ Sleep: 6.8h → 7.2h ↑ (avg 7.1h)
801
+ Resting HR: 60 → 58bpm ↓ (avg 59)
802
+ HRV: 42 → 44ms ↑ (avg 43)
803
+
804
+ ── Vault Stats ───────────────────────
805
+ Metrics: 570,432 | Sleep: 365 | Workouts: 248
806
+ Last sync: 2026-02-18 09:23 UTC
807
+ ```
808
+
809
+ `hvault summary --color --days 30`:
810
+ ```
811
+ 📊 30-Day Summary
812
+
813
+ 👟 Avg Steps: 8,432
814
+ 💓 Avg Resting HR: 58bpm
815
+ 🧠 Avg HRV: 44ms
816
+ 😴 Avg Sleep: 7.2h
817
+ 🔥 Avg Active Cal: 420 kcal
818
+ ```
819
+
820
+ `hvault trends --days 7`:
821
+ ```
822
+ 📊 7-Day Trends
823
+
824
+ 👟 Steps: 8,432 avg (6,100–11,200) ↑
825
+ 💓 Resting HR: 58bpm avg (55–62) ↓
826
+ 🧠 HRV: 44ms avg (38–52) ↑
827
+ 😴 Sleep: 7.2h avg (5.5–8.9h) ↑
828
+ 🔥 Active Cal: 420 kcal avg (280–620) →
829
+ ```
830
+
831
+ `hvault stats`:
832
+ ```json
833
+ {"metrics":570432,"sleep":365,"workouts":248,"syncs":12}
834
+ ```
835
+
836
+ ## Flags
837
+
838
+ ### Ingest flags
839
+
840
+ | Flag | Applies to | Description |
841
+ | --- | --- | --- |
842
+ | `--port <n>` | serve | HTTP port (default: 4242) |
843
+ | `--token <secret>` | serve | Require `Authorization: Bearer` header |
844
+ | `--target <name>` | import, watch | Tag data with device/person name |
845
+ | `--dir <path>` | watch | Directory to watch |
846
+ | `--interval <s>` | watch | Poll interval in seconds (default: 60) |
847
+
848
+ ### Query flags
849
+
850
+ | Flag | Description |
851
+ | --- | --- |
852
+ | `--days <n>` | Days of history (default varies per command) |
853
+ | `--metric <name>` | Metric name, e.g. `step_count`, `heart_rate` |
854
+ | `--pretty` | Pretty-print JSON output |
855
+
856
+ ### Analysis flags
857
+
858
+ | Flag | Applies to | Description |
859
+ | --- | --- | --- |
860
+ | `--days <n>` | all analysis | Days of history (default: 7 or 90) |
861
+ | `-c, --color` | summary | Pretty terminal output |
862
+ | `--json` | dashboard, trends | Raw JSON output |
863
+
864
+ ## Environment variables
865
+
866
+ Load order: CLI flag > env var > `.env` file > default.
867
+
868
+ ```bash
869
+ HVAULT_DB_PATH=~/.hae-vault/health.db # SQLite DB location
870
+ HVAULT_PORT=4242 # serve port
871
+ HVAULT_TOKEN=secret # bearer token for serve
872
+ HVAULT_WATCH_DIR=~/Downloads # directory to watch
873
+ HVAULT_WATCH_INTERVAL=60 # watch poll interval (seconds)
874
+ HVAULT_TARGET=default # default target name
875
+ HVAULT_ENV_FILE=/path/to/.env # override .env file location
876
+ ```
877
+
878
+ ## Exit codes
879
+
880
+ | Code | Meaning |
881
+ | --- | --- |
882
+ | 0 | Success |
883
+ | 1 | General error |
884
+
885
+ ## Development
886
+
887
+ ```bash
888
+ git clone https://github.com/mrkhachaturov/hae-vault.git
889
+ cd hae-vault
890
+ npm install
891
+
892
+ npm run dev -- serve # run server without building
893
+ npm run dev -- dashboard # run dashboard without building
894
+ npm run build # compile TypeScript → dist/
895
+ npm test # run test suite
896
+ npm install -g . # install globally as hvault
897
+ ```
898
+
899
+ Node.js 22+ required.
900
+
901
+ ## Full command reference
902
+
903
+ → [docs/COMMANDS.md](docs/COMMANDS.md)
904
+
905
+ ## License
906
+
907
+ MIT
908
+ ```
909
+
910
+ **Step 2: Commit**
911
+
912
+ ```bash
913
+ git add README.md
914
+ git commit -m "docs: add README.md"
915
+ ```
916
+
917
+ ---
918
+
919
+ ## Task 6: Create `docs/COMMANDS.md`
920
+
921
+ **Files:**
922
+ - Create: `docs/COMMANDS.md`
923
+
924
+ **Step 1: Write the file**
925
+
926
+ Write a detailed reference following the whoop-sync/docs/COMMANDS.md pattern. Groups: Ingest, Query, Analysis, Info. Each command gets: description, flags table, example invocation, example output.
927
+
928
+ Full content:
929
+
930
+ ```markdown
931
+ # hae-vault — Full Command Reference
932
+
933
+ ← [Back to README](../README.md)
934
+
935
+ ---
936
+
937
+ ## Ingest commands
938
+
939
+ ### `serve`
940
+
941
+ Start an HTTP server that receives Health Auto Export REST API pushes.
942
+
943
+ ```bash
944
+ hvault serve
945
+ hvault serve --port 4242 --token mysecret
946
+ ```
947
+
948
+ | Flag | Description |
949
+ | --- | --- |
950
+ | `--port <n>` | HTTP port (default: 4242) |
951
+ | `--token <secret>` | Require `Authorization: Bearer <secret>` header |
952
+
953
+ Configure HAE: Settings → REST API → URL: `http://your-server:4242/api/ingest`
954
+
955
+ ---
956
+
957
+ ### `import`
958
+
959
+ Bulk import from a HAE JSON or ZIP export file. Idempotent — skips already-imported files via SHA-256 hash.
960
+
961
+ ```bash
962
+ hvault import export.json
963
+ hvault import export.zip
964
+ hvault import export.zip --target me
965
+ ```
966
+
967
+ | Flag | Description |
968
+ | --- | --- |
969
+ | `--target <name>` | Tag imported data with device/person name |
970
+
971
+ Output:
972
+ ```json
973
+ {"metrics":1234,"sleep":7,"workouts":3,"skipped":false}
974
+ ```
975
+
976
+ ---
977
+
978
+ ### `watch`
979
+
980
+ Poll a directory for new HAE export files and auto-import on schedule.
981
+
982
+ ```bash
983
+ hvault watch
984
+ hvault watch --dir ~/Downloads
985
+ hvault watch --dir ~/Downloads --interval 60
986
+ ```
987
+
988
+ | Flag | Description |
989
+ | --- | --- |
990
+ | `--dir <path>` | Directory to watch (default: `HVAULT_WATCH_DIR`) |
991
+ | `--interval <s>` | Poll interval in seconds (default: 60) |
992
+ | `--target <name>` | Tag imported data with device/person name |
993
+
994
+ ---
995
+
996
+ ## Query commands
997
+
998
+ All query commands output JSON by default. Add `--pretty` for formatted JSON.
999
+
1000
+ ### Shared flags
1001
+
1002
+ | Flag | Description |
1003
+ | --- | --- |
1004
+ | `--days <n>` | Days of history |
1005
+ | `--pretty` | Pretty-print JSON output |
1006
+
1007
+ ---
1008
+
1009
+ ### `metrics`
1010
+
1011
+ Time series for a specific health metric.
1012
+
1013
+ ```bash
1014
+ hvault metrics --metric step_count --days 30
1015
+ hvault metrics --metric heart_rate --days 7 --pretty
1016
+ ```
1017
+
1018
+ | Flag | Description |
1019
+ | --- | --- |
1020
+ | `--metric <name>` | **Required.** Metric name (e.g. `step_count`, `resting_heart_rate`) |
1021
+ | `--days <n>` | Last N days (default: 30) |
1022
+
1023
+ Output (default JSON):
1024
+ ```json
1025
+ [{"ts":"2026-02-18T00:00:00Z","date":"2026-02-18","qty":8432,"min":null,"avg":null,"max":null,"units":"count","source":"iPhone","target":"default"}]
1026
+ ```
1027
+
1028
+ To discover available metric names: `hvault sources`
1029
+
1030
+ ---
1031
+
1032
+ ### `sleep`
1033
+
1034
+ Sleep records with stage breakdown.
1035
+
1036
+ ```bash
1037
+ hvault sleep --days 14
1038
+ hvault sleep --days 7 --pretty
1039
+ ```
1040
+
1041
+ Output:
1042
+ ```json
1043
+ [{"date":"2026-02-17","sleep_start":"2026-02-17T22:45:00Z","sleep_end":"2026-02-18T06:00:00Z","core_h":null,"deep_h":1.5,"rem_h":2.1,"awake_h":0.3,"asleep_h":7.2,"in_bed_h":7.5,"schema_ver":"aggregated_v2","source":"Apple Watch"}]
1044
+ ```
1045
+
1046
+ ---
1047
+
1048
+ ### `workouts`
1049
+
1050
+ Workout sessions with duration, calories, and heart rate.
1051
+
1052
+ ```bash
1053
+ hvault workouts --days 30
1054
+ hvault workouts --days 7 --pretty
1055
+ ```
1056
+
1057
+ Output:
1058
+ ```json
1059
+ [{"ts":"2026-02-17T18:30:00Z","date":"2026-02-17","name":"Walking","duration_s":2700,"calories_kj":1172,"distance":3.2,"distance_unit":"km","avg_hr":98,"max_hr":121,"target":"default"}]
1060
+ ```
1061
+
1062
+ Note: `calories_kj` is kilojoules. Divide by 4.184 for kcal.
1063
+
1064
+ ---
1065
+
1066
+ ### `summary`
1067
+
1068
+ Per-metric averages over N days. JSON output by default.
1069
+
1070
+ ```bash
1071
+ hvault summary --days 90
1072
+ hvault summary --days 30 --pretty
1073
+ hvault summary --color --days 30 # pretty terminal output
1074
+ ```
1075
+
1076
+ JSON output:
1077
+ ```json
1078
+ [{"metric":"step_count","units":"count","avg_qty":8432,"min_qty":3200,"max_qty":14100,"count":87,"first_date":"2025-11-20","last_date":"2026-02-18"}]
1079
+ ```
1080
+
1081
+ With `--color`:
1082
+ ```
1083
+ 📊 30-Day Summary
1084
+
1085
+ 👟 Avg Steps: 8,432
1086
+ 💓 Avg Resting HR: 58bpm
1087
+ 🧠 Avg HRV: 44ms
1088
+ 😴 Avg Sleep: 7.2h
1089
+ 🔥 Avg Active Cal: 420 kcal
1090
+ ```
1091
+
1092
+ ---
1093
+
1094
+ ### `query`
1095
+
1096
+ Raw SQL query against the SQLite database.
1097
+
1098
+ ```bash
1099
+ hvault query "SELECT date, AVG(qty) FROM metrics WHERE metric='step_count' GROUP BY date ORDER BY date DESC LIMIT 7"
1100
+ ```
1101
+
1102
+ Returns JSON array of row objects. Use `--pretty` for formatted output.
1103
+
1104
+ **Tables:** `metrics`, `sleep`, `workouts`, `sync_log`, `import_log`
1105
+
1106
+ ---
1107
+
1108
+ ## Analysis commands
1109
+
1110
+ ### `dashboard`
1111
+
1112
+ Full terminal dashboard: sleep, activity, heart health, recent workouts, N-day trends, vault stats.
1113
+
1114
+ ```bash
1115
+ hvault dashboard
1116
+ hvault dashboard --days 14
1117
+ hvault dashboard --json
1118
+ ```
1119
+
1120
+ | Flag | Description |
1121
+ | --- | --- |
1122
+ | `--days <n>` | Trend window in days (default: 7) |
1123
+ | `--json` | Output raw JSON (for AI agent use) |
1124
+
1125
+ Output:
1126
+ ```
1127
+ 📅 2026-02-18 | Apple Health Vault
1128
+
1129
+ ── Sleep (last night) ────────────────
1130
+ 😴 7.2h | Efficiency: 94%
1131
+ Deep: 1.5h (21%) | REM: 2.1h (29%) | Light: 3.6h (50%)
1132
+ Awake: 0.3h | Source: Apple Watch
1133
+
1134
+ ── Activity (recent) ─────────────────
1135
+ 👟 8,432 steps | 🔥 420 kcal active
1136
+ Stand hours: 10
1137
+
1138
+ ── Heart Health ──────────────────────
1139
+ 💓 Resting HR: 58bpm | HRV: 44ms
1140
+
1141
+ ── Recent Workouts ───────────────────
1142
+ 🚶 2026-02-17 Walking 45min 280 kcal
1143
+ 🚴 2026-02-15 Cycling 62min 420 kcal
1144
+
1145
+ ── 7-Day Trends ──────────────────────
1146
+ Steps: 7,200 → 8,432 ↑ (avg 7,840)
1147
+ Sleep: 6.8h → 7.2h ↑ (avg 7.1h)
1148
+ Resting HR: 60 → 58bpm ↓ (avg 59)
1149
+ HRV: 42 → 44ms ↑ (avg 43)
1150
+
1151
+ ── Vault Stats ───────────────────────
1152
+ Metrics: 570,432 | Sleep: 365 | Workouts: 248
1153
+ Last sync: 2026-02-18 09:23 UTC
1154
+ ```
1155
+
1156
+ ---
1157
+
1158
+ ### `trends`
1159
+
1160
+ Multi-metric trend analysis with averages, ranges, and direction arrows.
1161
+
1162
+ ```bash
1163
+ hvault trends
1164
+ hvault trends --days 30
1165
+ hvault trends --json
1166
+ ```
1167
+
1168
+ | Flag | Description |
1169
+ | --- | --- |
1170
+ | `--days <n>` | Days of history (default: 7) |
1171
+ | `--json` | Raw JSON output |
1172
+
1173
+ Output:
1174
+ ```
1175
+ 📊 7-Day Trends
1176
+
1177
+ 👟 Steps: 8,432 avg (6,100–11,200) ↑
1178
+ 💓 Resting HR: 58bpm avg (55–62) ↓
1179
+ 🧠 HRV: 44ms avg (38–52) ↑
1180
+ 😴 Sleep: 7.2h avg (5.5–8.9h) ↑
1181
+ 🔥 Active Cal: 420 kcal avg (280–620) →
1182
+ ```
1183
+
1184
+ Direction logic: compares first-half average vs second-half average of the period.
1185
+
1186
+ ---
1187
+
1188
+ ## Info commands
1189
+
1190
+ ### `sources`
1191
+
1192
+ Show which metrics are in the DB and their date coverage.
1193
+
1194
+ ```bash
1195
+ hvault sources
1196
+ hvault sources --pretty
1197
+ ```
1198
+
1199
+ Output:
1200
+ ```json
1201
+ [{"metric":"active_energy_burned","units":"kcal","count":365,"first_date":"2025-02-18","last_date":"2026-02-18"}]
1202
+ ```
1203
+
1204
+ ---
1205
+
1206
+ ### `last-sync`
1207
+
1208
+ Show when the last HAE REST API push was received.
1209
+
1210
+ ```bash
1211
+ hvault last-sync
1212
+ ```
1213
+
1214
+ Output:
1215
+ ```json
1216
+ {"id":42,"received_at":"2026-02-18T09:23:11.000Z","target":"default","metrics_count":247,"workouts_count":0}
1217
+ ```
1218
+
1219
+ Returns `null` if no pushes have been received.
1220
+
1221
+ ---
1222
+
1223
+ ### `stats`
1224
+
1225
+ Row counts per table.
1226
+
1227
+ ```bash
1228
+ hvault stats
1229
+ hvault stats --pretty
1230
+ ```
1231
+
1232
+ Output:
1233
+ ```json
1234
+ {"metrics":570432,"sleep":365,"workouts":248,"syncs":12}
1235
+ ```
1236
+
1237
+ ---
1238
+
1239
+ ← [Back to README](../README.md)
1240
+ ```
1241
+
1242
+ **Step 2: Commit**
1243
+
1244
+ ```bash
1245
+ git add docs/COMMANDS.md
1246
+ git commit -m "docs: add docs/COMMANDS.md full command reference"
1247
+ ```
1248
+
1249
+ ---
1250
+
1251
+ ## Task 7: Git init and push to GitHub
1252
+
1253
+ **Files:** none
1254
+
1255
+ **Step 1: Initialize git and set up**
1256
+
1257
+ > Note: The repo already has a `.git` directory. Skip `git init` if `git status` works.
1258
+
1259
+ ```bash
1260
+ cd /Volumes/storage/01_Projects/whoop/hae-vault && git status
1261
+ ```
1262
+
1263
+ If "not a git repository": run `git init && git branch -M main`
1264
+
1265
+ **Step 2: Check current git state**
1266
+
1267
+ ```bash
1268
+ git log --oneline -5
1269
+ git status
1270
+ ```
1271
+
1272
+ **Step 3: Add remote (if not already set)**
1273
+
1274
+ ```bash
1275
+ git remote -v
1276
+ ```
1277
+
1278
+ If no remote: `git remote add origin git@github.com:mrkhachaturov/hae-vault.git`
1279
+
1280
+ **Step 4: Push to main**
1281
+
1282
+ ```bash
1283
+ git push -u origin main
1284
+ ```
1285
+
1286
+ Expected: branch pushed, tracking set.
1287
+
1288
+ ---
1289
+
1290
+ ## Final verification
1291
+
1292
+ ```bash
1293
+ # Build is clean
1294
+ npm run build
1295
+
1296
+ # All tests pass
1297
+ npm test
1298
+
1299
+ # CLI help works
1300
+ npm run dev -- --help
1301
+ npm run dev -- dashboard --help
1302
+ npm run dev -- trends --help
1303
+ npm run dev -- summary --help
1304
+ ```
1305
+
1306
+ Expected: zero build errors, all tests pass, help text shows new commands.