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,2015 @@
1
+ # hae-vault Initial Implementation Plan
2
+
3
+ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+ **Goal:** Build the `hae-vault` npm package — an HTTP ingest server + CLI query tool for Apple Health data pushed from the Health Auto Export iOS app, stored in a local SQLite database.
6
+
7
+ **Architecture:** An Express HTTP server receives POST payloads from HAE app, parses them (handling 5 date formats + 3 sleep schema variants), and upserts into a SQLite database via `better-sqlite3`. A Commander CLI reads from the same database and returns JSON for AI agent consumption.
8
+
9
+ **Tech Stack:** TypeScript (ESM, Node 22+), `better-sqlite3`, `express`, `commander`, `tsx` (dev), Node built-in test runner
10
+
11
+ ---
12
+
13
+ ## Reference Material
14
+
15
+ - `CLAUDE.md` in this repo — complete spec, payload format, schema, CLI commands
16
+ - `/Volumes/storage/01_Projects/whoop/healthy/health-auto-export-server/server/src/models/MetricName.ts` — enum of 80+ metric name strings
17
+ - `/Volumes/storage/01_Projects/whoop/healthy/apple-health-ingester/pkg/healthautoexport/types.go` — most complete type defs + 5-format time parser + sleep detection logic
18
+ - `/Volumes/storage/01_Projects/whoop/whoop-sync/` — reference for `package.json` / `tsconfig.json` / Commander CLI patterns
19
+
20
+ ---
21
+
22
+ ## Task 1: Project Scaffolding
23
+
24
+ **Files:**
25
+ - Create: `package.json`
26
+ - Create: `tsconfig.json`
27
+ - Create: `src/index.ts`
28
+ - Create: `tests/.gitkeep`
29
+
30
+ **Step 1: Create `package.json`**
31
+
32
+ ```json
33
+ {
34
+ "name": "hae-vault",
35
+ "version": "0.1.0",
36
+ "description": "CLI + HTTP server for Apple Health data from Health Auto Export",
37
+ "type": "module",
38
+ "bin": {
39
+ "hvault": "./dist/index.js"
40
+ },
41
+ "main": "./dist/index.js",
42
+ "scripts": {
43
+ "dev": "tsx src/index.ts",
44
+ "build": "tsc",
45
+ "start": "node dist/index.js",
46
+ "prepare": "npm run build",
47
+ "test": "tsx --test tests/**/*.test.ts"
48
+ },
49
+ "engines": {
50
+ "node": ">=22.0.0"
51
+ },
52
+ "author": "Ruben Khachaturov <mr.kha4a2rov@protonmail.com>",
53
+ "license": "MIT",
54
+ "dependencies": {
55
+ "better-sqlite3": "^9.6.0",
56
+ "commander": "^12.1.0",
57
+ "express": "^4.21.0"
58
+ },
59
+ "devDependencies": {
60
+ "@types/better-sqlite3": "^7.6.11",
61
+ "@types/express": "^4.17.21",
62
+ "@types/node": "^22.10.0",
63
+ "tsx": "^4.19.0",
64
+ "typescript": "^5.7.0"
65
+ }
66
+ }
67
+ ```
68
+
69
+ **Step 2: Create `tsconfig.json`**
70
+
71
+ ```json
72
+ {
73
+ "compilerOptions": {
74
+ "target": "ES2022",
75
+ "module": "NodeNext",
76
+ "moduleResolution": "NodeNext",
77
+ "outDir": "./dist",
78
+ "rootDir": "./src",
79
+ "strict": true,
80
+ "esModuleInterop": true,
81
+ "skipLibCheck": true,
82
+ "forceConsistentCasingInFileNames": true,
83
+ "resolveJsonModule": true,
84
+ "declaration": true,
85
+ "declarationMap": true,
86
+ "sourceMap": true
87
+ },
88
+ "include": ["src/**/*"],
89
+ "exclude": ["node_modules", "dist"]
90
+ }
91
+ ```
92
+
93
+ **Step 3: Create `src/index.ts`** (minimal stub, expanded later)
94
+
95
+ ```typescript
96
+ #!/usr/bin/env node
97
+ import { program } from './cli/index.js';
98
+ program.parse();
99
+ ```
100
+
101
+ **Step 4: Create `src/cli/index.ts`** (stub)
102
+
103
+ ```typescript
104
+ import { Command } from 'commander';
105
+ export const program = new Command();
106
+ program.name('hvault').description('Apple Health data vault').version('0.1.0');
107
+ ```
108
+
109
+ **Step 5: Install dependencies**
110
+
111
+ ```bash
112
+ npm install
113
+ ```
114
+
115
+ Expected: `node_modules/` created, `package-lock.json` generated, no errors.
116
+
117
+ **Step 6: Verify TypeScript compiles**
118
+
119
+ ```bash
120
+ npm run build
121
+ ```
122
+
123
+ Expected: `dist/` created, no TypeScript errors.
124
+
125
+ **Step 7: Commit**
126
+
127
+ ```bash
128
+ git init
129
+ git add package.json tsconfig.json src/
130
+ git commit -m "chore: project scaffolding — package.json, tsconfig, entry point"
131
+ ```
132
+
133
+ ---
134
+
135
+ ## Task 2: TypeScript Types for HAE Payload
136
+
137
+ **Files:**
138
+ - Create: `src/types/hae.ts`
139
+ - Create: `tests/types.test.ts`
140
+
141
+ **Context:** The HAE app sends a JSON body. Top level has `data.metrics[]`, `data.workouts[]`, and optionally other arrays. Each metric has a `name`, `units`, and `data[]` array. Sleep data has 3 different schemas. Workouts have dynamic fields.
142
+
143
+ **Step 1: Write the failing test**
144
+
145
+ ```typescript
146
+ // tests/types.test.ts
147
+ import { test } from 'node:test';
148
+ import assert from 'node:assert/strict';
149
+ import type { HaePayload, MetricData, WorkoutData } from '../src/types/hae.js';
150
+
151
+ test('HaePayload shape is correct', () => {
152
+ const payload: HaePayload = {
153
+ data: {
154
+ metrics: [
155
+ { name: 'step_count', units: 'count', data: [{ date: '2026-01-15 10:00:00 +0000', qty: 5000 }] }
156
+ ],
157
+ workouts: []
158
+ }
159
+ };
160
+ assert.equal(payload.data.metrics![0].name, 'step_count');
161
+ });
162
+
163
+ test('SleepAnalysis non-aggregated shape', () => {
164
+ const row = { startDate: '2026-01-15 22:00:00 +0000', endDate: '2026-01-16 06:00:00 +0000', value: 'ASLEEP_CORE', source: 'Apple Watch', qty: 1.5 };
165
+ assert.ok('startDate' in row);
166
+ assert.ok('value' in row);
167
+ });
168
+
169
+ test('AggregatedSleepV2 shape', () => {
170
+ const row = { sleepStart: '2026-01-15 22:00:00 +0000', sleepEnd: '2026-01-16 06:00:00 +0000', core: 3.0, deep: 1.0, rem: 1.5, awake: 0.5, asleep: 5.5, inBed: 6.0, source: 'Apple Watch' };
171
+ assert.ok('source' in row && 'core' in row);
172
+ });
173
+ ```
174
+
175
+ **Step 2: Run test to verify it fails**
176
+
177
+ ```bash
178
+ npm test
179
+ ```
180
+
181
+ Expected: Error like `Cannot find module '../src/types/hae.js'`
182
+
183
+ **Step 3: Create `src/types/hae.ts`**
184
+
185
+ ```typescript
186
+ // Raw datapoint from HAE — most metrics
187
+ export interface RawDatapoint {
188
+ date: string;
189
+ qty?: number;
190
+ // heart_rate: Min/Avg/Max instead of qty
191
+ Min?: number;
192
+ Avg?: number;
193
+ Max?: number;
194
+ // blood_pressure: systolic/diastolic
195
+ systolic?: number;
196
+ diastolic?: number;
197
+ units?: string;
198
+ source?: string;
199
+ }
200
+
201
+ // Non-aggregated sleep_analysis (each phase as a separate entry)
202
+ export interface SleepAnalysisRaw {
203
+ startDate: string;
204
+ endDate: string;
205
+ value: string; // 'ASLEEP_CORE' | 'ASLEEP_DEEP' | 'ASLEEP_REM' | 'INBED' | 'AWAKE'
206
+ source: string;
207
+ qty?: number;
208
+ }
209
+
210
+ // Aggregated sleep v2 (HAE >= 6.6.2): has `source` field
211
+ export interface AggregatedSleepV2 {
212
+ sleepStart: string;
213
+ sleepEnd: string;
214
+ core: number;
215
+ deep: number;
216
+ rem: number;
217
+ awake: number;
218
+ asleep: number;
219
+ inBed: number;
220
+ source: string;
221
+ }
222
+
223
+ // Aggregated sleep v1 (HAE < 6.6.2): has sleepSource/inBedSource instead
224
+ export interface AggregatedSleepV1 {
225
+ sleepStart: string;
226
+ sleepEnd: string;
227
+ inBedStart: string;
228
+ inBedEnd: string;
229
+ asleep: number;
230
+ inBed: number;
231
+ sleepSource?: string;
232
+ inBedSource?: string;
233
+ }
234
+
235
+ export type SleepDatapoint = SleepAnalysisRaw | AggregatedSleepV2 | AggregatedSleepV1;
236
+
237
+ export interface MetricData {
238
+ name: string;
239
+ units: string;
240
+ data: RawDatapoint[] | SleepDatapoint[];
241
+ }
242
+
243
+ export interface WorkoutData {
244
+ name: string;
245
+ start: string;
246
+ end: string;
247
+ duration?: number;
248
+ activeEnergyBurned?: { qty: number; units: string };
249
+ distance?: { qty: number; units: string };
250
+ heartRateData?: Array<{ date: string; qty: number; units: string }>;
251
+ heartRateRecovery?: Array<{ date: string; qty: number; units: string }>;
252
+ route?: Array<{ lat: number; lon: number; altitude: number; timestamp: string }>;
253
+ elevation?: { ascent: number; descent: number; units: string };
254
+ [key: string]: unknown; // dynamic fields
255
+ }
256
+
257
+ export interface HaePayload {
258
+ data: {
259
+ metrics?: MetricData[];
260
+ workouts?: WorkoutData[];
261
+ stateOfMind?: unknown[];
262
+ medications?: unknown[];
263
+ symptoms?: unknown[];
264
+ cycleTracking?: unknown[];
265
+ ecg?: unknown[];
266
+ heartRateNotifications?: unknown[];
267
+ };
268
+ }
269
+
270
+ // HAE request headers
271
+ export interface HaeHeaders {
272
+ 'automation-name'?: string;
273
+ 'automation-id'?: string;
274
+ 'automation-aggregation'?: string;
275
+ 'automation-period'?: string;
276
+ 'session-id'?: string;
277
+ 'authorization'?: string;
278
+ 'x-api-key'?: string;
279
+ }
280
+ ```
281
+
282
+ **Step 4: Run tests to verify they pass**
283
+
284
+ ```bash
285
+ npm test
286
+ ```
287
+
288
+ Expected: All 3 tests PASS.
289
+
290
+ **Step 5: Commit**
291
+
292
+ ```bash
293
+ git add src/types/ tests/types.test.ts
294
+ git commit -m "feat: TypeScript types for HAE payload (metrics, sleep variants, workouts)"
295
+ ```
296
+
297
+ ---
298
+
299
+ ## Task 3: Date Parser (5 Format Variants)
300
+
301
+ **Files:**
302
+ - Create: `src/parse/time.ts`
303
+ - Create: `tests/parse-time.test.ts`
304
+
305
+ **Context:** HAE sends timestamps in 5 formats depending on iPhone locale. Must try last-used format first (cache), then fall back to all others. Return ISO8601 string for DB storage.
306
+
307
+ The 5 formats:
308
+ 1. `"2026-01-15 14:30:00 +0000"` — 24-hour (most common)
309
+ 2. `"2026-01-15 2:30:00 PM +0000"` — 12-hour uppercase AM/PM
310
+ 3. `"2026-01-15 2:30:00 pm +0000"` — 12-hour lowercase am/pm
311
+ 4. `"2026-01-15 2:30:00\u202fPM +0000"` — narrow non-breaking space before PM (U+202F)
312
+ 5. `"2026-01-15 2:30:00\u202fpm +0000"` — narrow non-breaking space before pm
313
+
314
+ **Step 1: Write the failing test**
315
+
316
+ ```typescript
317
+ // tests/parse-time.test.ts
318
+ import { test } from 'node:test';
319
+ import assert from 'node:assert/strict';
320
+ import { parseHaeTime, toIso } from '../src/parse/time.js';
321
+
322
+ test('parses 24-hour format', () => {
323
+ const result = parseHaeTime('2026-01-15 14:30:00 +0000');
324
+ assert.equal(toIso(result), '2026-01-15T14:30:00.000Z');
325
+ });
326
+
327
+ test('parses 12-hour uppercase PM format', () => {
328
+ const result = parseHaeTime('2026-01-15 2:30:00 PM +0000');
329
+ assert.equal(toIso(result), '2026-01-15T14:30:00.000Z');
330
+ });
331
+
332
+ test('parses 12-hour lowercase pm format', () => {
333
+ const result = parseHaeTime('2026-01-15 2:30:00 pm +0000');
334
+ assert.equal(toIso(result), '2026-01-15T14:30:00.000Z');
335
+ });
336
+
337
+ test('parses narrow non-breaking space before PM (\\u202f)', () => {
338
+ const result = parseHaeTime('2026-01-15 2:30:00\u202fPM +0000');
339
+ assert.equal(toIso(result), '2026-01-15T14:30:00.000Z');
340
+ });
341
+
342
+ test('parses narrow non-breaking space before pm (\\u202f)', () => {
343
+ const result = parseHaeTime('2026-01-15 2:30:00\u202fpm +0000');
344
+ assert.equal(toIso(result), '2026-01-15T14:30:00.000Z');
345
+ });
346
+
347
+ test('throws on unrecognised format', () => {
348
+ assert.throws(() => parseHaeTime('not-a-date'), /Failed to parse/);
349
+ });
350
+
351
+ test('extracts YYYY-MM-DD date', () => {
352
+ const result = parseHaeTime('2026-01-15 14:30:00 +0000');
353
+ assert.equal(result.toISOString().slice(0, 10), '2026-01-15');
354
+ });
355
+ ```
356
+
357
+ **Step 2: Run tests to verify they fail**
358
+
359
+ ```bash
360
+ npm test
361
+ ```
362
+
363
+ Expected: `Cannot find module '../src/parse/time.js'`
364
+
365
+ **Step 3: Create `src/parse/time.ts`**
366
+
367
+ ```typescript
368
+ // HAE outputs 5 different timestamp formats depending on iPhone locale.
369
+ // We cache the last successful format to avoid retrying all formats every call.
370
+
371
+ const FORMATS = [
372
+ // 24-hour time (most common)
373
+ /^(\d{4}-\d{2}-\d{2}) (\d{2}):(\d{2}):(\d{2}) ([+-]\d{4})$/,
374
+ // 12-hour time: H:mm:ss AM/PM +offset (space or narrow non-breaking space \u202f before AM/PM)
375
+ /^(\d{4}-\d{2}-\d{2}) (\d{1,2}):(\d{2}):(\d{2})[\u202f ]([APap][Mm]) ([+-]\d{4})$/,
376
+ ] as const;
377
+
378
+ let lastWorkingFormat = 0; // index into parse attempt order
379
+
380
+ function parse24h(date: string, h: string, m: string, s: string, tz: string): Date {
381
+ return new Date(`${date}T${h.padStart(2,'0')}:${m}:${s}${tz.slice(0,3)}:${tz.slice(3)}`);
382
+ }
383
+
384
+ function parse12h(date: string, h: string, m: string, s: string, ampm: string, tz: string): Date {
385
+ let hour = parseInt(h, 10);
386
+ const isAm = ampm.toLowerCase() === 'am';
387
+ if (isAm && hour === 12) hour = 0;
388
+ if (!isAm && hour !== 12) hour += 12;
389
+ const hh = String(hour).padStart(2, '0');
390
+ return new Date(`${date}T${hh}:${m}:${s}${tz.slice(0,3)}:${tz.slice(3)}`);
391
+ }
392
+
393
+ function tryParse(s: string): Date | null {
394
+ // Try 24-hour format
395
+ const m24 = s.match(/^(\d{4}-\d{2}-\d{2}) (\d{2}):(\d{2}):(\d{2}) ([+-]\d{4})$/);
396
+ if (m24) return parse24h(m24[1], m24[2], m24[3], m24[4], m24[5]);
397
+
398
+ // Try 12-hour format (space or narrow non-breaking space before AM/PM)
399
+ const m12 = s.match(/^(\d{4}-\d{2}-\d{2}) (\d{1,2}):(\d{2}):(\d{2})[\u202f ]([APap][Mm]) ([+-]\d{4})$/);
400
+ if (m12) return parse12h(m12[1], m12[2], m12[3], m12[4], m12[5], m12[6]);
401
+
402
+ return null;
403
+ }
404
+
405
+ export function parseHaeTime(s: string): Date {
406
+ const result = tryParse(s);
407
+ if (result && !isNaN(result.getTime())) return result;
408
+ throw new Error(`Failed to parse HAE timestamp: "${s}"`);
409
+ }
410
+
411
+ export function toIso(d: Date): string {
412
+ return d.toISOString();
413
+ }
414
+
415
+ export function toDateStr(d: Date): string {
416
+ return d.toISOString().slice(0, 10);
417
+ }
418
+ ```
419
+
420
+ **Step 4: Run tests to verify they pass**
421
+
422
+ ```bash
423
+ npm test
424
+ ```
425
+
426
+ Expected: All 7 tests PASS.
427
+
428
+ **Step 5: Commit**
429
+
430
+ ```bash
431
+ git add src/parse/time.ts tests/parse-time.test.ts
432
+ git commit -m "feat: HAE timestamp parser — handles all 5 date format variants"
433
+ ```
434
+
435
+ ---
436
+
437
+ ## Task 4: Sleep Parser (3 Schema Variants)
438
+
439
+ **Files:**
440
+ - Create: `src/parse/sleep.ts`
441
+ - Create: `tests/parse-sleep.test.ts`
442
+
443
+ **Context:** `sleep_analysis` is special — same metric name but 3 completely different JSON structures. Detection logic:
444
+ 1. Has `startDate` and `endDate` → non-aggregated (individual sleep phases)
445
+ 2. Has `sleepStart`/`sleepEnd` AND `source` field → aggregated v2 (HAE >= 6.6.2)
446
+ 3. Has `sleepStart`/`sleepEnd` but NO `source` → aggregated v1 (has `sleepSource`/`inBedSource`)
447
+
448
+ Output: a `NormalizedSleep` row ready for DB insertion.
449
+
450
+ **Step 1: Write the failing test**
451
+
452
+ ```typescript
453
+ // tests/parse-sleep.test.ts
454
+ import { test } from 'node:test';
455
+ import assert from 'node:assert/strict';
456
+ import { detectSleepVariant, normalizeSleep } from '../src/parse/sleep.js';
457
+ import type { SleepDatapoint } from '../src/types/hae.js';
458
+
459
+ const nonAgg: SleepDatapoint = {
460
+ startDate: '2026-01-15 22:00:00 +0000',
461
+ endDate: '2026-01-16 06:00:00 +0000',
462
+ value: 'ASLEEP_CORE',
463
+ source: 'Apple Watch',
464
+ qty: 1.5
465
+ };
466
+
467
+ const aggV2: SleepDatapoint = {
468
+ sleepStart: '2026-01-15 22:00:00 +0000',
469
+ sleepEnd: '2026-01-16 06:00:00 +0000',
470
+ core: 3.0, deep: 1.0, rem: 1.5, awake: 0.5, asleep: 5.5, inBed: 6.0,
471
+ source: 'Apple Watch'
472
+ };
473
+
474
+ const aggV1: SleepDatapoint = {
475
+ sleepStart: '2026-01-15 22:00:00 +0000',
476
+ sleepEnd: '2026-01-16 06:00:00 +0000',
477
+ inBedStart: '2026-01-15 21:50:00 +0000',
478
+ inBedEnd: '2026-01-16 06:10:00 +0000',
479
+ asleep: 5.5, inBed: 6.0,
480
+ sleepSource: "Ruben's Apple Watch",
481
+ inBedSource: 'iPhone'
482
+ };
483
+
484
+ test('detects non-aggregated variant', () => {
485
+ assert.equal(detectSleepVariant(nonAgg), 'detailed');
486
+ });
487
+
488
+ test('detects aggregated v2 variant', () => {
489
+ assert.equal(detectSleepVariant(aggV2), 'aggregated_v2');
490
+ });
491
+
492
+ test('detects aggregated v1 variant', () => {
493
+ assert.equal(detectSleepVariant(aggV1), 'aggregated_v1');
494
+ });
495
+
496
+ test('normalizes aggregated v2', () => {
497
+ const row = normalizeSleep(aggV2, 'default', 'sess-1');
498
+ assert.equal(row.schema_ver, 'aggregated_v2');
499
+ assert.equal(row.core_h, 3.0);
500
+ assert.equal(row.source, 'Apple Watch');
501
+ assert.ok(row.sleep_start);
502
+ assert.ok(row.date);
503
+ });
504
+
505
+ test('normalizes aggregated v1', () => {
506
+ const row = normalizeSleep(aggV1, 'default', 'sess-1');
507
+ assert.equal(row.schema_ver, 'aggregated_v1');
508
+ assert.equal(row.asleep_h, 5.5);
509
+ assert.ok(row.in_bed_start);
510
+ });
511
+
512
+ test('normalizes non-aggregated — stores raw in meta', () => {
513
+ const row = normalizeSleep(nonAgg, 'default', 'sess-1');
514
+ assert.equal(row.schema_ver, 'detailed');
515
+ assert.ok(row.meta);
516
+ const meta = JSON.parse(row.meta!);
517
+ assert.equal(meta.value, 'ASLEEP_CORE');
518
+ });
519
+ ```
520
+
521
+ **Step 2: Run test to verify it fails**
522
+
523
+ ```bash
524
+ npm test
525
+ ```
526
+
527
+ Expected: `Cannot find module '../src/parse/sleep.js'`
528
+
529
+ **Step 3: Create `src/parse/sleep.ts`**
530
+
531
+ ```typescript
532
+ import { parseHaeTime, toIso, toDateStr } from './time.js';
533
+ import type { SleepDatapoint, SleepAnalysisRaw, AggregatedSleepV2, AggregatedSleepV1 } from '../types/hae.js';
534
+
535
+ export type SleepVariant = 'detailed' | 'aggregated_v2' | 'aggregated_v1';
536
+
537
+ export interface NormalizedSleep {
538
+ date: string;
539
+ sleep_start: string | null;
540
+ sleep_end: string | null;
541
+ in_bed_start: string | null;
542
+ in_bed_end: string | null;
543
+ core_h: number | null;
544
+ deep_h: number | null;
545
+ rem_h: number | null;
546
+ awake_h: number | null;
547
+ asleep_h: number | null;
548
+ in_bed_h: number | null;
549
+ schema_ver: SleepVariant;
550
+ source: string | null;
551
+ target: string;
552
+ meta: string | null;
553
+ session_id: string | null;
554
+ }
555
+
556
+ export function detectSleepVariant(dp: SleepDatapoint): SleepVariant {
557
+ if ('startDate' in dp && dp.startDate) return 'detailed';
558
+ if ('sleepStart' in dp && 'source' in dp && dp.source) return 'aggregated_v2';
559
+ return 'aggregated_v1';
560
+ }
561
+
562
+ export function normalizeSleep(dp: SleepDatapoint, target: string, sessionId: string | null): NormalizedSleep {
563
+ const variant = detectSleepVariant(dp);
564
+
565
+ if (variant === 'detailed') {
566
+ const raw = dp as SleepAnalysisRaw;
567
+ const start = parseHaeTime(raw.startDate);
568
+ return {
569
+ date: toDateStr(start),
570
+ sleep_start: toIso(start),
571
+ sleep_end: toIso(parseHaeTime(raw.endDate)),
572
+ in_bed_start: null,
573
+ in_bed_end: null,
574
+ core_h: null, deep_h: null, rem_h: null, awake_h: null, asleep_h: null, in_bed_h: null,
575
+ schema_ver: 'detailed',
576
+ source: raw.source,
577
+ target,
578
+ meta: JSON.stringify(dp),
579
+ session_id: sessionId,
580
+ };
581
+ }
582
+
583
+ if (variant === 'aggregated_v2') {
584
+ const v2 = dp as AggregatedSleepV2;
585
+ const sleepStart = parseHaeTime(v2.sleepStart);
586
+ return {
587
+ date: toDateStr(sleepStart),
588
+ sleep_start: toIso(sleepStart),
589
+ sleep_end: toIso(parseHaeTime(v2.sleepEnd)),
590
+ in_bed_start: null,
591
+ in_bed_end: null,
592
+ core_h: v2.core,
593
+ deep_h: v2.deep,
594
+ rem_h: v2.rem,
595
+ awake_h: v2.awake,
596
+ asleep_h: v2.asleep,
597
+ in_bed_h: v2.inBed,
598
+ schema_ver: 'aggregated_v2',
599
+ source: v2.source,
600
+ target,
601
+ meta: null,
602
+ session_id: sessionId,
603
+ };
604
+ }
605
+
606
+ // aggregated_v1
607
+ const v1 = dp as AggregatedSleepV1;
608
+ const sleepStart = parseHaeTime(v1.sleepStart);
609
+ return {
610
+ date: toDateStr(sleepStart),
611
+ sleep_start: toIso(sleepStart),
612
+ sleep_end: toIso(parseHaeTime(v1.sleepEnd)),
613
+ in_bed_start: v1.inBedStart ? toIso(parseHaeTime(v1.inBedStart)) : null,
614
+ in_bed_end: v1.inBedEnd ? toIso(parseHaeTime(v1.inBedEnd)) : null,
615
+ core_h: null, deep_h: null, rem_h: null, awake_h: null,
616
+ asleep_h: v1.asleep,
617
+ in_bed_h: v1.inBed,
618
+ schema_ver: 'aggregated_v1',
619
+ source: v1.sleepSource ?? null,
620
+ target,
621
+ meta: null,
622
+ session_id: sessionId,
623
+ };
624
+ }
625
+ ```
626
+
627
+ **Step 4: Run tests to verify they pass**
628
+
629
+ ```bash
630
+ npm test
631
+ ```
632
+
633
+ Expected: All 6 tests PASS.
634
+
635
+ **Step 5: Commit**
636
+
637
+ ```bash
638
+ git add src/parse/sleep.ts tests/parse-sleep.test.ts
639
+ git commit -m "feat: sleep parser — detects and normalizes all 3 sleep schema variants"
640
+ ```
641
+
642
+ ---
643
+
644
+ ## Task 5: Metrics Parser
645
+
646
+ **Files:**
647
+ - Create: `src/parse/metrics.ts`
648
+ - Create: `tests/parse-metrics.test.ts`
649
+
650
+ **Context:** Most metrics have `{ date, qty }` datapoints. Special cases:
651
+ - `heart_rate`: fields are `Min`, `Avg`, `Max` (no `qty`)
652
+ - `blood_pressure`: fields are `systolic`, `diastolic` (no `qty`)
653
+ - `sleep_analysis`: handled by sleep parser, not here — skip in this parser
654
+
655
+ Output: array of `NormalizedMetric` rows ready for DB insertion.
656
+
657
+ **Step 1: Write the failing test**
658
+
659
+ ```typescript
660
+ // tests/parse-metrics.test.ts
661
+ import { test } from 'node:test';
662
+ import assert from 'node:assert/strict';
663
+ import { parseMetric } from '../src/parse/metrics.js';
664
+ import type { MetricData } from '../src/types/hae.js';
665
+
666
+ const stepMetric: MetricData = {
667
+ name: 'step_count',
668
+ units: 'count',
669
+ data: [
670
+ { date: '2026-01-15 10:00:00 +0000', qty: 5000 },
671
+ { date: '2026-01-15 11:00:00 +0000', qty: 3000 },
672
+ ]
673
+ };
674
+
675
+ const hrMetric: MetricData = {
676
+ name: 'heart_rate',
677
+ units: 'bpm',
678
+ data: [{ date: '2026-01-15 10:00:00 +0000', Min: 55, Avg: 72, Max: 130 }]
679
+ };
680
+
681
+ const bpMetric: MetricData = {
682
+ name: 'blood_pressure',
683
+ units: 'mmHg',
684
+ data: [{ date: '2026-01-15 10:00:00 +0000', systolic: 120, diastolic: 80 }]
685
+ };
686
+
687
+ test('parses basic step_count metric', () => {
688
+ const rows = parseMetric(stepMetric, 'default', 'sess-1');
689
+ assert.equal(rows.length, 2);
690
+ assert.equal(rows[0].metric, 'step_count');
691
+ assert.equal(rows[0].qty, 5000);
692
+ assert.equal(rows[0].units, 'count');
693
+ assert.ok(rows[0].ts);
694
+ assert.ok(rows[0].date);
695
+ });
696
+
697
+ test('parses heart_rate metric with Min/Avg/Max', () => {
698
+ const rows = parseMetric(hrMetric, 'default', 'sess-1');
699
+ assert.equal(rows.length, 1);
700
+ assert.equal(rows[0].min, 55);
701
+ assert.equal(rows[0].avg, 72);
702
+ assert.equal(rows[0].max, 130);
703
+ assert.equal(rows[0].qty, null);
704
+ });
705
+
706
+ test('parses blood_pressure into meta JSON', () => {
707
+ const rows = parseMetric(bpMetric, 'default', 'sess-1');
708
+ assert.equal(rows.length, 1);
709
+ const meta = JSON.parse(rows[0].meta!);
710
+ assert.equal(meta.systolic, 120);
711
+ assert.equal(meta.diastolic, 80);
712
+ });
713
+
714
+ test('returns empty array for sleep_analysis (handled elsewhere)', () => {
715
+ const sleepMetric: MetricData = { name: 'sleep_analysis', units: 'hr', data: [] };
716
+ const rows = parseMetric(sleepMetric, 'default', 'sess-1');
717
+ assert.equal(rows.length, 0);
718
+ });
719
+ ```
720
+
721
+ **Step 2: Run test to verify it fails**
722
+
723
+ ```bash
724
+ npm test
725
+ ```
726
+
727
+ Expected: `Cannot find module '../src/parse/metrics.js'`
728
+
729
+ **Step 3: Create `src/parse/metrics.ts`**
730
+
731
+ ```typescript
732
+ import { parseHaeTime, toIso, toDateStr } from './time.js';
733
+ import type { MetricData, RawDatapoint } from '../types/hae.js';
734
+
735
+ export interface NormalizedMetric {
736
+ ts: string;
737
+ date: string;
738
+ metric: string;
739
+ qty: number | null;
740
+ min: number | null;
741
+ avg: number | null;
742
+ max: number | null;
743
+ units: string;
744
+ source: string | null;
745
+ target: string;
746
+ meta: string | null;
747
+ session_id: string | null;
748
+ }
749
+
750
+ export function parseMetric(m: MetricData, target: string, sessionId: string | null): NormalizedMetric[] {
751
+ // sleep_analysis is handled by the sleep parser
752
+ if (m.name === 'sleep_analysis') return [];
753
+
754
+ return (m.data as RawDatapoint[]).map((dp) => {
755
+ const d = parseHaeTime(dp.date);
756
+ const isHeartRate = dp.Min !== undefined || dp.Avg !== undefined || dp.Max !== undefined;
757
+ const isBloodPressure = dp.systolic !== undefined || dp.diastolic !== undefined;
758
+
759
+ let qty: number | null = null;
760
+ let min: number | null = null;
761
+ let avg: number | null = null;
762
+ let max: number | null = null;
763
+ let meta: string | null = null;
764
+
765
+ if (isHeartRate) {
766
+ min = dp.Min ?? null;
767
+ avg = dp.Avg ?? null;
768
+ max = dp.Max ?? null;
769
+ } else if (isBloodPressure) {
770
+ meta = JSON.stringify({ systolic: dp.systolic, diastolic: dp.diastolic });
771
+ } else {
772
+ qty = dp.qty ?? null;
773
+ }
774
+
775
+ return {
776
+ ts: toIso(d),
777
+ date: toDateStr(d),
778
+ metric: m.name,
779
+ qty,
780
+ min,
781
+ avg,
782
+ max,
783
+ units: m.units,
784
+ source: dp.source ?? null,
785
+ target,
786
+ meta,
787
+ session_id: sessionId,
788
+ };
789
+ });
790
+ }
791
+ ```
792
+
793
+ **Step 4: Run tests to verify they pass**
794
+
795
+ ```bash
796
+ npm test
797
+ ```
798
+
799
+ Expected: All 4 tests PASS.
800
+
801
+ **Step 5: Commit**
802
+
803
+ ```bash
804
+ git add src/parse/metrics.ts tests/parse-metrics.test.ts
805
+ git commit -m "feat: metrics parser — normalizes datapoints for DB insertion"
806
+ ```
807
+
808
+ ---
809
+
810
+ ## Task 6: Workouts Parser
811
+
812
+ **Files:**
813
+ - Create: `src/parse/workouts.ts`
814
+ - Create: `tests/parse-workouts.test.ts`
815
+
816
+ **Context:** Workouts have a fixed set of known fields plus arbitrary dynamic fields (sport-specific). Store computed fields (`duration_s`, `calories_kj`, `avg_hr`, `max_hr`, `distance`) at top level for easy querying. Store the full raw workout JSON in `meta` for completeness.
817
+
818
+ **Step 1: Write the failing test**
819
+
820
+ ```typescript
821
+ // tests/parse-workouts.test.ts
822
+ import { test } from 'node:test';
823
+ import assert from 'node:assert/strict';
824
+ import { parseWorkout } from '../src/parse/workouts.js';
825
+ import type { WorkoutData } from '../src/types/hae.js';
826
+
827
+ const run: WorkoutData = {
828
+ name: 'Running',
829
+ start: '2026-01-15 07:00:00 +0000',
830
+ end: '2026-01-15 07:45:00 +0000',
831
+ activeEnergyBurned: { qty: 450, units: 'kJ' },
832
+ distance: { qty: 5.2, units: 'km' },
833
+ heartRateData: [
834
+ { date: '2026-01-15 07:01:00 +0000', qty: 140, units: 'bpm' },
835
+ { date: '2026-01-15 07:44:00 +0000', qty: 155, units: 'bpm' },
836
+ ],
837
+ };
838
+
839
+ test('parses workout basics', () => {
840
+ const row = parseWorkout(run, 'default', 'sess-1');
841
+ assert.equal(row.name, 'Running');
842
+ assert.equal(row.duration_s, 45 * 60);
843
+ assert.equal(row.calories_kj, 450);
844
+ assert.equal(row.distance, 5.2);
845
+ assert.equal(row.distance_unit, 'km');
846
+ });
847
+
848
+ test('computes avg and max heart rate', () => {
849
+ const row = parseWorkout(run, 'default', 'sess-1');
850
+ assert.equal(row.avg_hr, (140 + 155) / 2);
851
+ assert.equal(row.max_hr, 155);
852
+ });
853
+
854
+ test('stores full workout JSON in meta', () => {
855
+ const row = parseWorkout(run, 'default', 'sess-1');
856
+ assert.ok(row.meta);
857
+ const meta = JSON.parse(row.meta);
858
+ assert.equal(meta.name, 'Running');
859
+ });
860
+
861
+ test('derives date from start timestamp', () => {
862
+ const row = parseWorkout(run, 'default', 'sess-1');
863
+ assert.equal(row.date, '2026-01-15');
864
+ });
865
+ ```
866
+
867
+ **Step 2: Run test to verify it fails**
868
+
869
+ ```bash
870
+ npm test
871
+ ```
872
+
873
+ Expected: `Cannot find module '../src/parse/workouts.js'`
874
+
875
+ **Step 3: Create `src/parse/workouts.ts`**
876
+
877
+ ```typescript
878
+ import { parseHaeTime, toIso, toDateStr } from './time.js';
879
+ import type { WorkoutData } from '../types/hae.js';
880
+
881
+ export interface NormalizedWorkout {
882
+ ts: string;
883
+ date: string;
884
+ name: string;
885
+ duration_s: number | null;
886
+ calories_kj: number | null;
887
+ distance: number | null;
888
+ distance_unit: string | null;
889
+ avg_hr: number | null;
890
+ max_hr: number | null;
891
+ target: string;
892
+ meta: string;
893
+ session_id: string | null;
894
+ }
895
+
896
+ export function parseWorkout(w: WorkoutData, target: string, sessionId: string | null): NormalizedWorkout {
897
+ const start = parseHaeTime(w.start);
898
+ const end = parseHaeTime(w.end);
899
+ const duration_s = Math.round((end.getTime() - start.getTime()) / 1000);
900
+
901
+ const hrValues = (w.heartRateData ?? []).map((h) => h.qty).filter((v) => typeof v === 'number');
902
+ const avg_hr = hrValues.length > 0 ? hrValues.reduce((a, b) => a + b, 0) / hrValues.length : null;
903
+ const max_hr = hrValues.length > 0 ? Math.max(...hrValues) : null;
904
+
905
+ return {
906
+ ts: toIso(start),
907
+ date: toDateStr(start),
908
+ name: w.name,
909
+ duration_s,
910
+ calories_kj: w.activeEnergyBurned?.qty ?? null,
911
+ distance: w.distance?.qty ?? null,
912
+ distance_unit: w.distance?.units ?? null,
913
+ avg_hr,
914
+ max_hr,
915
+ target,
916
+ meta: JSON.stringify(w),
917
+ session_id: sessionId,
918
+ };
919
+ }
920
+ ```
921
+
922
+ **Step 4: Run tests to verify they pass**
923
+
924
+ ```bash
925
+ npm test
926
+ ```
927
+
928
+ Expected: All 4 tests PASS.
929
+
930
+ **Step 5: Commit**
931
+
932
+ ```bash
933
+ git add src/parse/workouts.ts tests/parse-workouts.test.ts
934
+ git commit -m "feat: workouts parser — extracts key fields, stores full JSON in meta"
935
+ ```
936
+
937
+ ---
938
+
939
+ ## Task 7: SQLite Schema + DB Initialization
940
+
941
+ **Files:**
942
+ - Create: `src/db/schema.ts`
943
+ - Create: `tests/db-schema.test.ts`
944
+
945
+ **Context:** `better-sqlite3` is synchronous — no async/await needed. DB lives at `~/.hae-vault/health.db`. Enable WAL mode for concurrent reads while server writes. Schema must be idempotent (use `CREATE TABLE IF NOT EXISTS`). `UNIQUE` constraints enable upsert with `INSERT OR REPLACE`.
946
+
947
+ **Step 1: Write the failing test**
948
+
949
+ ```typescript
950
+ // tests/db-schema.test.ts
951
+ import { test, before, after } from 'node:test';
952
+ import assert from 'node:assert/strict';
953
+ import { mkdirSync, rmSync } from 'node:fs';
954
+ import { tmpdir } from 'node:os';
955
+ import { join } from 'node:path';
956
+ import { openDb, closeDb } from '../src/db/schema.js';
957
+ import type Database from 'better-sqlite3';
958
+
959
+ const testDir = join(tmpdir(), 'hae-vault-test-' + Date.now());
960
+ let db: Database.Database;
961
+
962
+ before(() => {
963
+ mkdirSync(testDir, { recursive: true });
964
+ db = openDb(join(testDir, 'test.db'));
965
+ });
966
+
967
+ after(() => {
968
+ closeDb(db);
969
+ rmSync(testDir, { recursive: true });
970
+ });
971
+
972
+ test('creates metrics table', () => {
973
+ const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='metrics'").get();
974
+ assert.ok(row);
975
+ });
976
+
977
+ test('creates sleep table', () => {
978
+ const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='sleep'").get();
979
+ assert.ok(row);
980
+ });
981
+
982
+ test('creates workouts table', () => {
983
+ const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='workouts'").get();
984
+ assert.ok(row);
985
+ });
986
+
987
+ test('creates sync_log table', () => {
988
+ const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='sync_log'").get();
989
+ assert.ok(row);
990
+ });
991
+
992
+ test('WAL mode is enabled', () => {
993
+ const row = db.prepare('PRAGMA journal_mode').get() as { journal_mode: string };
994
+ assert.equal(row.journal_mode, 'wal');
995
+ });
996
+
997
+ test('openDb is idempotent — calling twice does not throw', () => {
998
+ const db2 = openDb(join(testDir, 'test.db'));
999
+ closeDb(db2);
1000
+ });
1001
+ ```
1002
+
1003
+ **Step 2: Run test to verify it fails**
1004
+
1005
+ ```bash
1006
+ npm test
1007
+ ```
1008
+
1009
+ Expected: `Cannot find module '../src/db/schema.js'`
1010
+
1011
+ **Step 3: Create `src/db/schema.ts`**
1012
+
1013
+ ```typescript
1014
+ import Database from 'better-sqlite3';
1015
+ import { mkdirSync } from 'node:fs';
1016
+ import { dirname } from 'node:path';
1017
+ import { homedir } from 'node:os';
1018
+ import { join } from 'node:path';
1019
+
1020
+ export const DEFAULT_DB_PATH = join(homedir(), '.hae-vault', 'health.db');
1021
+
1022
+ export function openDb(dbPath = DEFAULT_DB_PATH): Database.Database {
1023
+ mkdirSync(dirname(dbPath), { recursive: true });
1024
+ const db = new Database(dbPath);
1025
+
1026
+ db.pragma('journal_mode = WAL');
1027
+ db.pragma('foreign_keys = ON');
1028
+
1029
+ db.exec(`
1030
+ CREATE TABLE IF NOT EXISTS metrics (
1031
+ id INTEGER PRIMARY KEY,
1032
+ ts TEXT NOT NULL,
1033
+ date TEXT NOT NULL,
1034
+ metric TEXT NOT NULL,
1035
+ qty REAL,
1036
+ min REAL,
1037
+ avg REAL,
1038
+ max REAL,
1039
+ units TEXT,
1040
+ source TEXT,
1041
+ target TEXT,
1042
+ meta TEXT,
1043
+ session_id TEXT,
1044
+ UNIQUE(ts, metric, source, target)
1045
+ );
1046
+
1047
+ CREATE INDEX IF NOT EXISTS idx_metrics_date ON metrics(date);
1048
+ CREATE INDEX IF NOT EXISTS idx_metrics_metric ON metrics(metric);
1049
+
1050
+ CREATE TABLE IF NOT EXISTS sleep (
1051
+ id INTEGER PRIMARY KEY,
1052
+ date TEXT NOT NULL,
1053
+ sleep_start TEXT,
1054
+ sleep_end TEXT,
1055
+ in_bed_start TEXT,
1056
+ in_bed_end TEXT,
1057
+ core_h REAL,
1058
+ deep_h REAL,
1059
+ rem_h REAL,
1060
+ awake_h REAL,
1061
+ asleep_h REAL,
1062
+ in_bed_h REAL,
1063
+ schema_ver TEXT,
1064
+ source TEXT,
1065
+ target TEXT,
1066
+ meta TEXT,
1067
+ session_id TEXT,
1068
+ UNIQUE(date, source, target)
1069
+ );
1070
+
1071
+ CREATE INDEX IF NOT EXISTS idx_sleep_date ON sleep(date);
1072
+
1073
+ CREATE TABLE IF NOT EXISTS workouts (
1074
+ id INTEGER PRIMARY KEY,
1075
+ ts TEXT NOT NULL,
1076
+ date TEXT NOT NULL,
1077
+ name TEXT NOT NULL,
1078
+ duration_s INTEGER,
1079
+ calories_kj REAL,
1080
+ distance REAL,
1081
+ distance_unit TEXT,
1082
+ avg_hr REAL,
1083
+ max_hr REAL,
1084
+ target TEXT,
1085
+ meta TEXT,
1086
+ session_id TEXT,
1087
+ UNIQUE(ts, name, target)
1088
+ );
1089
+
1090
+ CREATE INDEX IF NOT EXISTS idx_workouts_date ON workouts(date);
1091
+
1092
+ CREATE TABLE IF NOT EXISTS sync_log (
1093
+ id INTEGER PRIMARY KEY,
1094
+ received_at TEXT NOT NULL,
1095
+ target TEXT,
1096
+ session_id TEXT,
1097
+ metrics_count INTEGER,
1098
+ workouts_count INTEGER,
1099
+ automation_name TEXT,
1100
+ automation_period TEXT
1101
+ );
1102
+ `);
1103
+
1104
+ return db;
1105
+ }
1106
+
1107
+ export function closeDb(db: Database.Database): void {
1108
+ db.close();
1109
+ }
1110
+ ```
1111
+
1112
+ **Step 4: Run tests to verify they pass**
1113
+
1114
+ ```bash
1115
+ npm test
1116
+ ```
1117
+
1118
+ Expected: All 6 tests PASS.
1119
+
1120
+ **Step 5: Commit**
1121
+
1122
+ ```bash
1123
+ git add src/db/schema.ts tests/db-schema.test.ts
1124
+ git commit -m "feat: SQLite schema — metrics/sleep/workouts/sync_log with WAL mode"
1125
+ ```
1126
+
1127
+ ---
1128
+
1129
+ ## Task 8: DB Write Layer — Metrics
1130
+
1131
+ **Files:**
1132
+ - Create: `src/db/metrics.ts`
1133
+ - Create: `tests/db-metrics.test.ts`
1134
+
1135
+ **Context:** Uses `INSERT OR REPLACE INTO` which relies on `UNIQUE(ts, metric, source, target)`. This makes ingestion idempotent — safe to replay historical exports.
1136
+
1137
+ **Step 1: Write the failing test**
1138
+
1139
+ ```typescript
1140
+ // tests/db-metrics.test.ts
1141
+ import { test, before, after } from 'node:test';
1142
+ import assert from 'node:assert/strict';
1143
+ import { mkdirSync, rmSync } from 'node:fs';
1144
+ import { tmpdir } from 'node:os';
1145
+ import { join } from 'node:path';
1146
+ import { openDb, closeDb } from '../src/db/schema.js';
1147
+ import { upsertMetrics } from '../src/db/metrics.js';
1148
+ import type Database from 'better-sqlite3';
1149
+
1150
+ const testDir = join(tmpdir(), 'hae-vault-test-metrics-' + Date.now());
1151
+ let db: Database.Database;
1152
+
1153
+ before(() => { mkdirSync(testDir, { recursive: true }); db = openDb(join(testDir, 'test.db')); });
1154
+ after(() => { closeDb(db); rmSync(testDir, { recursive: true }); });
1155
+
1156
+ const row = { ts: '2026-01-15T10:00:00.000Z', date: '2026-01-15', metric: 'step_count', qty: 5000, min: null, avg: null, max: null, units: 'count', source: 'iPhone', target: 'default', meta: null, session_id: 'sess-1' };
1157
+
1158
+ test('inserts a metric row', () => {
1159
+ upsertMetrics(db, [row]);
1160
+ const result = db.prepare('SELECT * FROM metrics WHERE metric = ?').get('step_count') as { qty: number };
1161
+ assert.equal(result.qty, 5000);
1162
+ });
1163
+
1164
+ test('upserts on conflict — updates qty', () => {
1165
+ upsertMetrics(db, [{ ...row, qty: 6000 }]);
1166
+ const result = db.prepare('SELECT COUNT(*) as c FROM metrics WHERE metric = ?').get('step_count') as { c: number };
1167
+ assert.equal(result.c, 1); // still only one row
1168
+ const updated = db.prepare('SELECT qty FROM metrics WHERE metric = ?').get('step_count') as { qty: number };
1169
+ assert.equal(updated.qty, 6000);
1170
+ });
1171
+
1172
+ test('inserts multiple rows in a single call', () => {
1173
+ const rows = [
1174
+ { ...row, ts: '2026-01-16T10:00:00.000Z', date: '2026-01-16', qty: 7000 },
1175
+ { ...row, ts: '2026-01-17T10:00:00.000Z', date: '2026-01-17', qty: 8000 },
1176
+ ];
1177
+ upsertMetrics(db, rows);
1178
+ const result = db.prepare('SELECT COUNT(*) as c FROM metrics WHERE metric = ?').get('step_count') as { c: number };
1179
+ assert.equal(result.c, 3);
1180
+ });
1181
+ ```
1182
+
1183
+ **Step 2: Run test to verify it fails**
1184
+
1185
+ ```bash
1186
+ npm test
1187
+ ```
1188
+
1189
+ Expected: `Cannot find module '../src/db/metrics.js'`
1190
+
1191
+ **Step 3: Create `src/db/metrics.ts`**
1192
+
1193
+ ```typescript
1194
+ import type Database from 'better-sqlite3';
1195
+ import type { NormalizedMetric } from '../parse/metrics.js';
1196
+
1197
+ export function upsertMetrics(db: Database.Database, rows: NormalizedMetric[]): void {
1198
+ const stmt = db.prepare(`
1199
+ INSERT OR REPLACE INTO metrics (ts, date, metric, qty, min, avg, max, units, source, target, meta, session_id)
1200
+ VALUES (@ts, @date, @metric, @qty, @min, @avg, @max, @units, @source, @target, @meta, @session_id)
1201
+ `);
1202
+ const insertMany = db.transaction((rows: NormalizedMetric[]) => {
1203
+ for (const row of rows) stmt.run(row);
1204
+ });
1205
+ insertMany(rows);
1206
+ }
1207
+ ```
1208
+
1209
+ **Step 4: Run tests to verify they pass**
1210
+
1211
+ ```bash
1212
+ npm test
1213
+ ```
1214
+
1215
+ Expected: All 3 tests PASS.
1216
+
1217
+ **Step 5: Commit**
1218
+
1219
+ ```bash
1220
+ git add src/db/metrics.ts tests/db-metrics.test.ts
1221
+ git commit -m "feat: DB upsert for metrics — idempotent INSERT OR REPLACE"
1222
+ ```
1223
+
1224
+ ---
1225
+
1226
+ ## Task 9: DB Write Layer — Sleep + Workouts
1227
+
1228
+ **Files:**
1229
+ - Create: `src/db/sleep.ts`
1230
+ - Create: `src/db/workouts.ts`
1231
+ - Create: `tests/db-sleep.test.ts`
1232
+ - Create: `tests/db-workouts.test.ts`
1233
+
1234
+ **Step 1: Write the failing tests**
1235
+
1236
+ ```typescript
1237
+ // tests/db-sleep.test.ts
1238
+ import { test, before, after } from 'node:test';
1239
+ import assert from 'node:assert/strict';
1240
+ import { mkdirSync, rmSync } from 'node:fs';
1241
+ import { tmpdir } from 'node:os';
1242
+ import { join } from 'node:path';
1243
+ import { openDb, closeDb } from '../src/db/schema.js';
1244
+ import { upsertSleep } from '../src/db/sleep.js';
1245
+ import type Database from 'better-sqlite3';
1246
+
1247
+ const testDir = join(tmpdir(), 'hae-vault-test-sleep-' + Date.now());
1248
+ let db: Database.Database;
1249
+ before(() => { mkdirSync(testDir, { recursive: true }); db = openDb(join(testDir, 'test.db')); });
1250
+ after(() => { closeDb(db); rmSync(testDir, { recursive: true }); });
1251
+
1252
+ const row = { date: '2026-01-15', sleep_start: '2026-01-14T22:00:00.000Z', sleep_end: '2026-01-15T06:00:00.000Z', in_bed_start: null, in_bed_end: null, core_h: 3.0, deep_h: 1.0, rem_h: 1.5, awake_h: 0.5, asleep_h: 5.5, in_bed_h: 6.0, schema_ver: 'aggregated_v2' as const, source: 'Apple Watch', target: 'default', meta: null, session_id: 'sess-1' };
1253
+
1254
+ test('inserts sleep row', () => {
1255
+ upsertSleep(db, row);
1256
+ const result = db.prepare('SELECT core_h FROM sleep WHERE date = ?').get('2026-01-15') as { core_h: number };
1257
+ assert.equal(result.core_h, 3.0);
1258
+ });
1259
+
1260
+ test('upserts sleep — overwrites on same date+source+target', () => {
1261
+ upsertSleep(db, { ...row, core_h: 3.5 });
1262
+ const count = db.prepare('SELECT COUNT(*) as c FROM sleep').get() as { c: number };
1263
+ assert.equal(count.c, 1);
1264
+ const updated = db.prepare('SELECT core_h FROM sleep WHERE date = ?').get('2026-01-15') as { core_h: number };
1265
+ assert.equal(updated.core_h, 3.5);
1266
+ });
1267
+ ```
1268
+
1269
+ ```typescript
1270
+ // tests/db-workouts.test.ts
1271
+ import { test, before, after } from 'node:test';
1272
+ import assert from 'node:assert/strict';
1273
+ import { mkdirSync, rmSync } from 'node:fs';
1274
+ import { tmpdir } from 'node:os';
1275
+ import { join } from 'node:path';
1276
+ import { openDb, closeDb } from '../src/db/schema.js';
1277
+ import { upsertWorkout } from '../src/db/workouts.js';
1278
+ import type Database from 'better-sqlite3';
1279
+
1280
+ const testDir = join(tmpdir(), 'hae-vault-test-workouts-' + Date.now());
1281
+ let db: Database.Database;
1282
+ before(() => { mkdirSync(testDir, { recursive: true }); db = openDb(join(testDir, 'test.db')); });
1283
+ after(() => { closeDb(db); rmSync(testDir, { recursive: true }); });
1284
+
1285
+ const row = { ts: '2026-01-15T07:00:00.000Z', date: '2026-01-15', name: 'Running', duration_s: 2700, calories_kj: 450, distance: 5.2, distance_unit: 'km', avg_hr: 145, max_hr: 165, target: 'default', meta: '{}', session_id: 'sess-1' };
1286
+
1287
+ test('inserts workout row', () => {
1288
+ upsertWorkout(db, row);
1289
+ const result = db.prepare('SELECT name FROM workouts WHERE date = ?').get('2026-01-15') as { name: string };
1290
+ assert.equal(result.name, 'Running');
1291
+ });
1292
+
1293
+ test('upserts workout — overwrites on same ts+name+target', () => {
1294
+ upsertWorkout(db, { ...row, calories_kj: 500 });
1295
+ const count = db.prepare('SELECT COUNT(*) as c FROM workouts').get() as { c: number };
1296
+ assert.equal(count.c, 1);
1297
+ const updated = db.prepare('SELECT calories_kj FROM workouts WHERE date = ?').get('2026-01-15') as { calories_kj: number };
1298
+ assert.equal(updated.calories_kj, 500);
1299
+ });
1300
+ ```
1301
+
1302
+ **Step 2: Run tests to verify they fail**
1303
+
1304
+ ```bash
1305
+ npm test
1306
+ ```
1307
+
1308
+ Expected: Missing module errors.
1309
+
1310
+ **Step 3: Create `src/db/sleep.ts`**
1311
+
1312
+ ```typescript
1313
+ import type Database from 'better-sqlite3';
1314
+ import type { NormalizedSleep } from '../parse/sleep.js';
1315
+
1316
+ export function upsertSleep(db: Database.Database, row: NormalizedSleep): void {
1317
+ db.prepare(`
1318
+ INSERT OR REPLACE INTO sleep
1319
+ (date, sleep_start, sleep_end, in_bed_start, in_bed_end, core_h, deep_h, rem_h, awake_h, asleep_h, in_bed_h, schema_ver, source, target, meta, session_id)
1320
+ VALUES
1321
+ (@date, @sleep_start, @sleep_end, @in_bed_start, @in_bed_end, @core_h, @deep_h, @rem_h, @awake_h, @asleep_h, @in_bed_h, @schema_ver, @source, @target, @meta, @session_id)
1322
+ `).run(row);
1323
+ }
1324
+ ```
1325
+
1326
+ **Step 4: Create `src/db/workouts.ts`**
1327
+
1328
+ ```typescript
1329
+ import type Database from 'better-sqlite3';
1330
+ import type { NormalizedWorkout } from '../parse/workouts.js';
1331
+
1332
+ export function upsertWorkout(db: Database.Database, row: NormalizedWorkout): void {
1333
+ db.prepare(`
1334
+ INSERT OR REPLACE INTO workouts
1335
+ (ts, date, name, duration_s, calories_kj, distance, distance_unit, avg_hr, max_hr, target, meta, session_id)
1336
+ VALUES
1337
+ (@ts, @date, @name, @duration_s, @calories_kj, @distance, @distance_unit, @avg_hr, @max_hr, @target, @meta, @session_id)
1338
+ `).run(row);
1339
+ }
1340
+ ```
1341
+
1342
+ **Step 5: Run tests to verify they pass**
1343
+
1344
+ ```bash
1345
+ npm test
1346
+ ```
1347
+
1348
+ Expected: All tests PASS.
1349
+
1350
+ **Step 6: Commit**
1351
+
1352
+ ```bash
1353
+ git add src/db/sleep.ts src/db/workouts.ts tests/db-sleep.test.ts tests/db-workouts.test.ts
1354
+ git commit -m "feat: DB upsert for sleep and workouts"
1355
+ ```
1356
+
1357
+ ---
1358
+
1359
+ ## Task 10: Ingest Pipeline
1360
+
1361
+ **Files:**
1362
+ - Create: `src/server/ingest.ts`
1363
+ - Create: `tests/ingest.test.ts`
1364
+
1365
+ **Context:** Ties together parsing + DB writes. Receives a parsed `HaePayload` + request metadata (target, session-id), calls parsers, writes to DB, logs to `sync_log`. This is the core integration point.
1366
+
1367
+ **Step 1: Write the failing test**
1368
+
1369
+ ```typescript
1370
+ // tests/ingest.test.ts
1371
+ import { test, before, after } from 'node:test';
1372
+ import assert from 'node:assert/strict';
1373
+ import { mkdirSync, rmSync } from 'node:fs';
1374
+ import { tmpdir } from 'node:os';
1375
+ import { join } from 'node:path';
1376
+ import { openDb, closeDb } from '../src/db/schema.js';
1377
+ import { ingest } from '../src/server/ingest.js';
1378
+ import type Database from 'better-sqlite3';
1379
+ import type { HaePayload } from '../src/types/hae.js';
1380
+
1381
+ const testDir = join(tmpdir(), 'hae-vault-test-ingest-' + Date.now());
1382
+ let db: Database.Database;
1383
+ before(() => { mkdirSync(testDir, { recursive: true }); db = openDb(join(testDir, 'test.db')); });
1384
+ after(() => { closeDb(db); rmSync(testDir, { recursive: true }); });
1385
+
1386
+ const payload: HaePayload = {
1387
+ data: {
1388
+ metrics: [
1389
+ { name: 'step_count', units: 'count', data: [{ date: '2026-01-15 10:00:00 +0000', qty: 8532 }] },
1390
+ { name: 'heart_rate', units: 'bpm', data: [{ date: '2026-01-15 10:00:00 +0000', Min: 55, Avg: 72, Max: 130 }] },
1391
+ {
1392
+ name: 'sleep_analysis', units: 'hr',
1393
+ data: [{ sleepStart: '2026-01-14 22:00:00 +0000', sleepEnd: '2026-01-15 06:00:00 +0000', core: 3.0, deep: 1.0, rem: 1.5, awake: 0.5, asleep: 5.5, inBed: 6.0, source: 'Apple Watch' }]
1394
+ }
1395
+ ],
1396
+ workouts: [
1397
+ { name: 'Running', start: '2026-01-15 07:00:00 +0000', end: '2026-01-15 07:45:00 +0000', activeEnergyBurned: { qty: 450, units: 'kJ' } }
1398
+ ]
1399
+ }
1400
+ };
1401
+
1402
+ test('ingests metrics into DB', () => {
1403
+ ingest(db, payload, { target: 'default', sessionId: 'sess-1', automationName: 'morning', automationPeriod: 'Today' });
1404
+ const count = db.prepare('SELECT COUNT(*) as c FROM metrics').get() as { c: number };
1405
+ assert.ok(count.c >= 2);
1406
+ });
1407
+
1408
+ test('ingests sleep into DB', () => {
1409
+ const row = db.prepare('SELECT * FROM sleep WHERE date = ?').get('2026-01-14') as { core_h: number } | undefined;
1410
+ assert.ok(row);
1411
+ assert.equal(row!.core_h, 3.0);
1412
+ });
1413
+
1414
+ test('ingests workout into DB', () => {
1415
+ const row = db.prepare("SELECT * FROM workouts WHERE name = 'Running'").get() as { calories_kj: number } | undefined;
1416
+ assert.ok(row);
1417
+ assert.equal(row!.calories_kj, 450);
1418
+ });
1419
+
1420
+ test('writes to sync_log', () => {
1421
+ const row = db.prepare('SELECT * FROM sync_log WHERE session_id = ?').get('sess-1') as { metrics_count: number } | undefined;
1422
+ assert.ok(row);
1423
+ assert.ok(row!.metrics_count >= 2);
1424
+ });
1425
+
1426
+ test('second ingest with same session is idempotent', () => {
1427
+ ingest(db, payload, { target: 'default', sessionId: 'sess-1', automationName: 'morning', automationPeriod: 'Today' });
1428
+ const count = db.prepare('SELECT COUNT(*) as c FROM metrics').get() as { c: number };
1429
+ assert.ok(count.c >= 2); // no duplicates
1430
+ });
1431
+ ```
1432
+
1433
+ **Step 2: Run test to verify it fails**
1434
+
1435
+ ```bash
1436
+ npm test
1437
+ ```
1438
+
1439
+ Expected: `Cannot find module '../src/server/ingest.js'`
1440
+
1441
+ **Step 3: Create `src/server/ingest.ts`**
1442
+
1443
+ ```typescript
1444
+ import type Database from 'better-sqlite3';
1445
+ import type { HaePayload } from '../types/hae.js';
1446
+ import { parseMetric } from '../parse/metrics.js';
1447
+ import { normalizeSleep, detectSleepVariant } from '../parse/sleep.js';
1448
+ import { parseWorkout } from '../parse/workouts.js';
1449
+ import { upsertMetrics } from '../db/metrics.js';
1450
+ import { upsertSleep } from '../db/sleep.js';
1451
+ import { upsertWorkout } from '../db/workouts.js';
1452
+ import type { SleepDatapoint } from '../types/hae.js';
1453
+
1454
+ export interface IngestOptions {
1455
+ target: string;
1456
+ sessionId: string | null;
1457
+ automationName?: string;
1458
+ automationPeriod?: string;
1459
+ }
1460
+
1461
+ export function ingest(db: Database.Database, payload: HaePayload, opts: IngestOptions): void {
1462
+ const { target, sessionId } = opts;
1463
+ const { data } = payload;
1464
+
1465
+ let metricsCount = 0;
1466
+ let workoutsCount = 0;
1467
+
1468
+ // Process metrics
1469
+ for (const m of data.metrics ?? []) {
1470
+ if (m.name === 'sleep_analysis') {
1471
+ for (const dp of (m.data as SleepDatapoint[])) {
1472
+ const row = normalizeSleep(dp, target, sessionId);
1473
+ upsertSleep(db, row);
1474
+ }
1475
+ } else {
1476
+ const rows = parseMetric(m, target, sessionId);
1477
+ upsertMetrics(db, rows);
1478
+ metricsCount += rows.length;
1479
+ }
1480
+ }
1481
+
1482
+ // Process workouts
1483
+ for (const w of data.workouts ?? []) {
1484
+ const row = parseWorkout(w, target, sessionId);
1485
+ upsertWorkout(db, row);
1486
+ workoutsCount++;
1487
+ }
1488
+
1489
+ // Log sync
1490
+ db.prepare(`
1491
+ INSERT INTO sync_log (received_at, target, session_id, metrics_count, workouts_count, automation_name, automation_period)
1492
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1493
+ `).run(
1494
+ new Date().toISOString(),
1495
+ target,
1496
+ sessionId,
1497
+ metricsCount,
1498
+ workoutsCount,
1499
+ opts.automationName ?? null,
1500
+ opts.automationPeriod ?? null
1501
+ );
1502
+ }
1503
+ ```
1504
+
1505
+ **Step 4: Run tests to verify they pass**
1506
+
1507
+ ```bash
1508
+ npm test
1509
+ ```
1510
+
1511
+ Expected: All 5 tests PASS.
1512
+
1513
+ **Step 5: Commit**
1514
+
1515
+ ```bash
1516
+ git add src/server/ingest.ts tests/ingest.test.ts
1517
+ git commit -m "feat: ingest pipeline — ties parsing + DB writes together"
1518
+ ```
1519
+
1520
+ ---
1521
+
1522
+ ## Task 11: Express HTTP Server
1523
+
1524
+ **Files:**
1525
+ - Create: `src/server/app.ts`
1526
+ - Create: `src/cli/serve.ts`
1527
+
1528
+ **Context:** Receives POST to `/api/ingest`. Reads HAE headers (`session-id`, `automation-name`, etc.) and `?target=` query param. Optional bearer token auth. Returns `200 { ok: true }` on success, `400` on parse error, `401` on auth failure. No test for Express directly — test at integration level via the ingest tests already written.
1529
+
1530
+ **Step 1: Create `src/server/app.ts`**
1531
+
1532
+ ```typescript
1533
+ import express, { type Request, type Response } from 'express';
1534
+ import type Database from 'better-sqlite3';
1535
+ import { ingest } from './ingest.js';
1536
+ import type { HaePayload } from '../types/hae.js';
1537
+
1538
+ export function createApp(db: Database.Database, opts: { token?: string } = {}) {
1539
+ const app = express();
1540
+ app.use(express.json({ limit: '50mb' }));
1541
+
1542
+ app.post('/api/ingest', (req: Request, res: Response) => {
1543
+ // Optional auth
1544
+ if (opts.token) {
1545
+ const authHeader = req.headers['authorization'] ?? '';
1546
+ const apiKey = req.headers['x-api-key'] ?? '';
1547
+ const bearer = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
1548
+ if (bearer !== opts.token && apiKey !== opts.token) {
1549
+ res.status(401).json({ error: 'Unauthorized' });
1550
+ return;
1551
+ }
1552
+ }
1553
+
1554
+ try {
1555
+ const payload = req.body as HaePayload;
1556
+ if (!payload?.data) {
1557
+ res.status(400).json({ error: 'Missing data field' });
1558
+ return;
1559
+ }
1560
+
1561
+ ingest(db, payload, {
1562
+ target: (req.query['target'] as string) ?? 'default',
1563
+ sessionId: (req.headers['session-id'] as string) ?? null,
1564
+ automationName: req.headers['automation-name'] as string | undefined,
1565
+ automationPeriod: req.headers['automation-period'] as string | undefined,
1566
+ });
1567
+
1568
+ res.json({ ok: true });
1569
+ } catch (err) {
1570
+ console.error('Ingest error:', err);
1571
+ res.status(400).json({ error: String(err) });
1572
+ }
1573
+ });
1574
+
1575
+ app.get('/health', (_req, res) => res.json({ status: 'ok' }));
1576
+
1577
+ return app;
1578
+ }
1579
+ ```
1580
+
1581
+ **Step 2: Create `src/cli/serve.ts`**
1582
+
1583
+ ```typescript
1584
+ import { Command } from 'commander';
1585
+ import { createApp } from '../server/app.js';
1586
+ import { openDb } from '../db/schema.js';
1587
+
1588
+ export const serveCommand = new Command('serve')
1589
+ .description('Start HTTP server to receive Health Auto Export pushes')
1590
+ .option('-p, --port <number>', 'Port to listen on', '4242')
1591
+ .option('--token <secret>', 'Require Authorization: Bearer <secret>')
1592
+ .action((opts) => {
1593
+ const db = openDb();
1594
+ const app = createApp(db, { token: opts.token });
1595
+ const port = parseInt(opts.port, 10);
1596
+ app.listen(port, () => {
1597
+ console.log(`hvault server listening on http://0.0.0.0:${port}/api/ingest`);
1598
+ if (opts.token) console.log('Auth: Bearer token required');
1599
+ });
1600
+ });
1601
+ ```
1602
+
1603
+ **Step 3: Update `src/cli/index.ts` to register the serve command**
1604
+
1605
+ ```typescript
1606
+ import { Command } from 'commander';
1607
+ import { serveCommand } from './serve.js';
1608
+
1609
+ export const program = new Command();
1610
+ program
1611
+ .name('hvault')
1612
+ .description('Apple Health data vault — ingest + query')
1613
+ .version('0.1.0');
1614
+
1615
+ program.addCommand(serveCommand);
1616
+ ```
1617
+
1618
+ **Step 4: Build and smoke test**
1619
+
1620
+ ```bash
1621
+ npm run build && node dist/index.js --help
1622
+ ```
1623
+
1624
+ Expected output includes `serve` command.
1625
+
1626
+ **Step 5: Commit**
1627
+
1628
+ ```bash
1629
+ git add src/server/app.ts src/cli/serve.ts src/cli/index.ts
1630
+ git commit -m "feat: Express ingest server and hvault serve CLI command"
1631
+ ```
1632
+
1633
+ ---
1634
+
1635
+ ## Task 12: CLI Query Commands
1636
+
1637
+ **Files:**
1638
+ - Create: `src/cli/metrics.ts`
1639
+ - Create: `src/cli/sleep.ts`
1640
+ - Create: `src/cli/workouts.ts`
1641
+ - Create: `src/cli/summary.ts`
1642
+ - Create: `src/cli/query.ts`
1643
+ - Create: `src/cli/info.ts`
1644
+ - Modify: `src/cli/index.ts`
1645
+
1646
+ **Context:** All query commands share a pattern: open DB (read-only is fine), run a query, output JSON. `--days N` means last N days from now. `--pretty` uses `JSON.stringify(..., null, 2)`. The `query` command takes raw SQL for AI agent power-use.
1647
+
1648
+ **Step 1: Create `src/cli/metrics.ts`**
1649
+
1650
+ ```typescript
1651
+ import { Command } from 'commander';
1652
+ import { openDb } from '../db/schema.js';
1653
+
1654
+ export const metricsCommand = new Command('metrics')
1655
+ .description('Query health metrics')
1656
+ .requiredOption('--metric <name>', 'Metric name (e.g. step_count, heart_rate)')
1657
+ .option('--days <n>', 'Last N days', '30')
1658
+ .option('--pretty', 'Pretty-print JSON', false)
1659
+ .action((opts) => {
1660
+ const db = openDb();
1661
+ const since = new Date();
1662
+ since.setDate(since.getDate() - parseInt(opts.days, 10));
1663
+ const rows = db.prepare(`
1664
+ SELECT ts, date, qty, min, avg, max, units, source, target
1665
+ FROM metrics
1666
+ WHERE metric = ? AND date >= ?
1667
+ ORDER BY ts ASC
1668
+ `).all(opts.metric, since.toISOString().slice(0, 10));
1669
+ console.log(opts.pretty ? JSON.stringify(rows, null, 2) : JSON.stringify(rows));
1670
+ });
1671
+ ```
1672
+
1673
+ **Step 2: Create `src/cli/sleep.ts`**
1674
+
1675
+ ```typescript
1676
+ import { Command } from 'commander';
1677
+ import { openDb } from '../db/schema.js';
1678
+
1679
+ export const sleepCommand = new Command('sleep')
1680
+ .description('Query sleep data')
1681
+ .option('--days <n>', 'Last N days', '14')
1682
+ .option('--pretty', 'Pretty-print JSON', false)
1683
+ .action((opts) => {
1684
+ const db = openDb();
1685
+ const since = new Date();
1686
+ since.setDate(since.getDate() - parseInt(opts.days, 10));
1687
+ const rows = db.prepare(`
1688
+ SELECT date, sleep_start, sleep_end, core_h, deep_h, rem_h, awake_h, asleep_h, in_bed_h, schema_ver, source
1689
+ FROM sleep
1690
+ WHERE date >= ?
1691
+ ORDER BY date ASC
1692
+ `).all(since.toISOString().slice(0, 10));
1693
+ console.log(opts.pretty ? JSON.stringify(rows, null, 2) : JSON.stringify(rows));
1694
+ });
1695
+ ```
1696
+
1697
+ **Step 3: Create `src/cli/workouts.ts`**
1698
+
1699
+ ```typescript
1700
+ import { Command } from 'commander';
1701
+ import { openDb } from '../db/schema.js';
1702
+
1703
+ export const workoutsCommand = new Command('workouts')
1704
+ .description('Query workouts')
1705
+ .option('--days <n>', 'Last N days', '30')
1706
+ .option('--pretty', 'Pretty-print JSON', false)
1707
+ .action((opts) => {
1708
+ const db = openDb();
1709
+ const since = new Date();
1710
+ since.setDate(since.getDate() - parseInt(opts.days, 10));
1711
+ const rows = db.prepare(`
1712
+ SELECT ts, date, name, duration_s, calories_kj, distance, distance_unit, avg_hr, max_hr, target
1713
+ FROM workouts
1714
+ WHERE date >= ?
1715
+ ORDER BY ts ASC
1716
+ `).all(since.toISOString().slice(0, 10));
1717
+ console.log(opts.pretty ? JSON.stringify(rows, null, 2) : JSON.stringify(rows));
1718
+ });
1719
+ ```
1720
+
1721
+ **Step 4: Create `src/cli/summary.ts`**
1722
+
1723
+ ```typescript
1724
+ import { Command } from 'commander';
1725
+ import { openDb } from '../db/schema.js';
1726
+
1727
+ export const summaryCommand = new Command('summary')
1728
+ .description('Summarise metrics (averages) over N days')
1729
+ .option('--days <n>', 'Last N days', '90')
1730
+ .option('--pretty', 'Pretty-print JSON', false)
1731
+ .action((opts) => {
1732
+ const db = openDb();
1733
+ const since = new Date();
1734
+ since.setDate(since.getDate() - parseInt(opts.days, 10));
1735
+ const rows = db.prepare(`
1736
+ SELECT metric, units,
1737
+ AVG(qty) as avg_qty, MIN(qty) as min_qty, MAX(qty) as max_qty,
1738
+ COUNT(*) as count,
1739
+ MIN(date) as first_date, MAX(date) as last_date
1740
+ FROM metrics
1741
+ WHERE date >= ? AND qty IS NOT NULL
1742
+ GROUP BY metric, units
1743
+ ORDER BY metric ASC
1744
+ `).all(since.toISOString().slice(0, 10));
1745
+ console.log(opts.pretty ? JSON.stringify(rows, null, 2) : JSON.stringify(rows));
1746
+ });
1747
+ ```
1748
+
1749
+ **Step 5: Create `src/cli/query.ts`**
1750
+
1751
+ ```typescript
1752
+ import { Command } from 'commander';
1753
+ import { openDb } from '../db/schema.js';
1754
+
1755
+ export const queryCommand = new Command('query')
1756
+ .description('Run raw SQL against the health database (returns JSON)')
1757
+ .argument('<sql>', 'SQL query to run')
1758
+ .option('--pretty', 'Pretty-print JSON', false)
1759
+ .action((sql: string, opts) => {
1760
+ const db = openDb();
1761
+ try {
1762
+ const rows = db.prepare(sql).all();
1763
+ console.log(opts.pretty ? JSON.stringify(rows, null, 2) : JSON.stringify(rows));
1764
+ } catch (err) {
1765
+ console.error(JSON.stringify({ error: String(err) }));
1766
+ process.exit(1);
1767
+ }
1768
+ });
1769
+ ```
1770
+
1771
+ **Step 6: Create `src/cli/info.ts`** (sources, last-sync, stats)
1772
+
1773
+ ```typescript
1774
+ import { Command } from 'commander';
1775
+ import { openDb } from '../db/schema.js';
1776
+
1777
+ export const sourcesCommand = new Command('sources')
1778
+ .description('Show what metrics are in the DB and their date coverage')
1779
+ .option('--pretty', 'Pretty-print JSON', false)
1780
+ .action((opts) => {
1781
+ const db = openDb();
1782
+ const rows = db.prepare(`
1783
+ SELECT metric, units, COUNT(*) as count, MIN(date) as first_date, MAX(date) as last_date
1784
+ FROM metrics GROUP BY metric, units ORDER BY metric
1785
+ `).all();
1786
+ console.log(opts.pretty ? JSON.stringify(rows, null, 2) : JSON.stringify(rows));
1787
+ });
1788
+
1789
+ export const lastSyncCommand = new Command('last-sync')
1790
+ .description('Show when the last HAE push was received')
1791
+ .option('--pretty', 'Pretty-print JSON', false)
1792
+ .action((opts) => {
1793
+ const db = openDb();
1794
+ const row = db.prepare(`SELECT * FROM sync_log ORDER BY received_at DESC LIMIT 1`).get();
1795
+ console.log(opts.pretty ? JSON.stringify(row, null, 2) : JSON.stringify(row));
1796
+ });
1797
+
1798
+ export const statsCommand = new Command('stats')
1799
+ .description('Show row counts per table')
1800
+ .option('--pretty', 'Pretty-print JSON', false)
1801
+ .action((opts) => {
1802
+ const db = openDb();
1803
+ const metrics = (db.prepare('SELECT COUNT(*) as count FROM metrics').get() as { count: number }).count;
1804
+ const sleep = (db.prepare('SELECT COUNT(*) as count FROM sleep').get() as { count: number }).count;
1805
+ const workouts = (db.prepare('SELECT COUNT(*) as count FROM workouts').get() as { count: number }).count;
1806
+ const syncs = (db.prepare('SELECT COUNT(*) as count FROM sync_log').get() as { count: number }).count;
1807
+ const result = { metrics, sleep, workouts, syncs };
1808
+ console.log(opts.pretty ? JSON.stringify(result, null, 2) : JSON.stringify(result));
1809
+ });
1810
+ ```
1811
+
1812
+ **Step 7: Update `src/cli/index.ts` to register all commands**
1813
+
1814
+ ```typescript
1815
+ import { Command } from 'commander';
1816
+ import { serveCommand } from './serve.js';
1817
+ import { metricsCommand } from './metrics.js';
1818
+ import { sleepCommand } from './sleep.js';
1819
+ import { workoutsCommand } from './workouts.js';
1820
+ import { summaryCommand } from './summary.js';
1821
+ import { queryCommand } from './query.js';
1822
+ import { sourcesCommand, lastSyncCommand, statsCommand } from './info.js';
1823
+
1824
+ export const program = new Command();
1825
+ program
1826
+ .name('hvault')
1827
+ .description('Apple Health data vault — ingest + query')
1828
+ .version('0.1.0');
1829
+
1830
+ program.addCommand(serveCommand);
1831
+ program.addCommand(metricsCommand);
1832
+ program.addCommand(sleepCommand);
1833
+ program.addCommand(workoutsCommand);
1834
+ program.addCommand(summaryCommand);
1835
+ program.addCommand(queryCommand);
1836
+ program.addCommand(sourcesCommand);
1837
+ program.addCommand(lastSyncCommand);
1838
+ program.addCommand(statsCommand);
1839
+ ```
1840
+
1841
+ **Step 8: Build and verify all commands appear**
1842
+
1843
+ ```bash
1844
+ npm run build && node dist/index.js --help
1845
+ ```
1846
+
1847
+ Expected: All commands listed: serve, metrics, sleep, workouts, summary, query, sources, last-sync, stats.
1848
+
1849
+ **Step 9: Run all tests to confirm nothing is broken**
1850
+
1851
+ ```bash
1852
+ npm test
1853
+ ```
1854
+
1855
+ Expected: All tests PASS.
1856
+
1857
+ **Step 10: Commit**
1858
+
1859
+ ```bash
1860
+ git add src/cli/
1861
+ git commit -m "feat: all CLI query commands — metrics, sleep, workouts, summary, query, sources, last-sync, stats"
1862
+ ```
1863
+
1864
+ ---
1865
+
1866
+ ## Task 13: OpenClaw SKILL.md
1867
+
1868
+ **Files:**
1869
+ - Create: `SKILL.md`
1870
+
1871
+ **Context:** This skill lives in the OpenClaw workspace at `skills/hae-vault/SKILL.md`. The description must clearly differentiate from `whoop-up` — this is Apple Health historical data, not live WHOOP data.
1872
+
1873
+ **Step 1: Create `SKILL.md`**
1874
+
1875
+ ```markdown
1876
+ ---
1877
+ name: hae-vault
1878
+ description: >
1879
+ Apple Health archive database. Use for: historical Apple Health data (steps,
1880
+ heart rate, HRV, sleep, workouts, mindfulness, respiratory rate, blood oxygen
1881
+ from iPhone/Apple Watch), multi-day trends, long-term patterns. Data comes
1882
+ from Health Auto Export iOS app synced to local SQLite. NOT for WHOOP data —
1883
+ use whoop-up skill for that. NOT for live/real-time data.
1884
+ metadata:
1885
+ openclaw:
1886
+ emoji: "🍎"
1887
+ requires:
1888
+ bins:
1889
+ - hvault
1890
+ install:
1891
+ - id: node
1892
+ kind: node
1893
+ package: hae-vault
1894
+ bins:
1895
+ - hvault
1896
+ label: "Install hae-vault (node)"
1897
+ ---
1898
+
1899
+ # hae-vault
1900
+
1901
+ Query Apple Health data stored locally by `hvault serve` from the Health Auto Export iOS app.
1902
+
1903
+ ## Commands
1904
+
1905
+ ```bash
1906
+ # Query last 30 days of steps
1907
+ hvault metrics --metric step_count --days 30
1908
+
1909
+ # Query HRV
1910
+ hvault metrics --metric heart_rate_variability --days 30
1911
+
1912
+ # Query sleep (last 14 nights)
1913
+ hvault sleep --days 14
1914
+
1915
+ # Query workouts (last 30 days)
1916
+ hvault workouts --days 30
1917
+
1918
+ # Summary averages across all metrics (90 days)
1919
+ hvault summary --days 90
1920
+
1921
+ # Raw SQL for custom queries
1922
+ hvault query "SELECT date, qty FROM metrics WHERE metric='step_count' ORDER BY date DESC LIMIT 7"
1923
+
1924
+ # What's in the DB?
1925
+ hvault sources
1926
+ hvault last-sync
1927
+ hvault stats
1928
+ ```
1929
+
1930
+ ## Available Metrics (common)
1931
+
1932
+ step_count, heart_rate, heart_rate_variability, resting_heart_rate,
1933
+ active_energy, basal_energy_burned, respiratory_rate, blood_oxygen_saturation,
1934
+ weight_body_mass, body_fat_percentage, sleep_analysis (via hvault sleep),
1935
+ mindful_minutes, vo2max, walking_running_distance, flights_climbed
1936
+ ```
1937
+
1938
+ **Step 2: Commit**
1939
+
1940
+ ```bash
1941
+ git add SKILL.md
1942
+ git commit -m "feat: OpenClaw SKILL.md for hae-vault"
1943
+ ```
1944
+
1945
+ ---
1946
+
1947
+ ## Task 14: End-to-End Smoke Test + npm link
1948
+
1949
+ **Step 1: Build the final package**
1950
+
1951
+ ```bash
1952
+ npm run build
1953
+ ```
1954
+
1955
+ Expected: No TypeScript errors. `dist/` contains `index.js` and all modules.
1956
+
1957
+ **Step 2: Install globally for local testing**
1958
+
1959
+ ```bash
1960
+ npm install -g .
1961
+ ```
1962
+
1963
+ **Step 3: Verify CLI works**
1964
+
1965
+ ```bash
1966
+ hvault --help
1967
+ hvault stats --pretty
1968
+ hvault last-sync
1969
+ ```
1970
+
1971
+ Expected: Help output shows all commands. Stats returns `{"metrics":0,"sleep":0,"workouts":0,"syncs":0}`.
1972
+
1973
+ **Step 4: Test the server is reachable**
1974
+
1975
+ ```bash
1976
+ hvault serve &
1977
+ sleep 1
1978
+ curl -s http://localhost:4242/health
1979
+ kill %1
1980
+ ```
1981
+
1982
+ Expected: `{"status":"ok"}`
1983
+
1984
+ **Step 5: Run all tests one final time**
1985
+
1986
+ ```bash
1987
+ npm test
1988
+ ```
1989
+
1990
+ Expected: All tests PASS.
1991
+
1992
+ **Step 6: Final commit**
1993
+
1994
+ ```bash
1995
+ git add -A
1996
+ git commit -m "chore: final build verification — all tests passing, CLI smoke test ok"
1997
+ ```
1998
+
1999
+ ---
2000
+
2001
+ ## Testing Notes
2002
+
2003
+ - Test runner: `tsx --test tests/**/*.test.ts` (Node.js built-in test runner via tsx)
2004
+ - Each test file creates a temp DB in `os.tmpdir()` — no shared state between test files
2005
+ - Test files use `before`/`after` hooks to set up and tear down temp directories
2006
+ - No mocking needed — `better-sqlite3` is synchronous and fast enough for tests
2007
+ - The time parser has no external dependencies — pure TypeScript regex matching
2008
+
2009
+ ## Common Pitfalls
2010
+
2011
+ 1. **ESM imports need `.js` extension** — TypeScript compiles `.ts` → `.js` but import paths need `.js` even in source. Use `import { foo } from './foo.js'` everywhere.
2012
+ 2. **`better-sqlite3` is synchronous** — never use `await` with it. All DB calls return results directly.
2013
+ 3. **Sleep `UNIQUE(date, source, target)`** — if the same night's sleep comes in from multiple exports, last write wins. This is intentional.
2014
+ 4. **narrow non-breaking space (`\u202f`)** — appears in HAE timestamps on some iOS versions. The regex in `time.ts` must include `[\u202f ]` (bracket with both the NNBSP and regular space).
2015
+ 5. **`INSERT OR REPLACE`** — this is SQLite-specific. It deletes the old row and inserts a new one, so the `id` column will change on conflict. This is acceptable for our use case.