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.
- package/.env.example +7 -0
- package/CLAUDE.md +220 -0
- package/README.md +206 -0
- package/SKILL.md +60 -0
- package/dist/cli/dashboard.d.ts +3 -0
- package/dist/cli/dashboard.d.ts.map +1 -0
- package/dist/cli/dashboard.js +206 -0
- package/dist/cli/dashboard.js.map +1 -0
- package/dist/cli/import.d.ts +3 -0
- package/dist/cli/import.d.ts.map +1 -0
- package/dist/cli/import.js +78 -0
- package/dist/cli/import.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +31 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/info.d.ts +5 -0
- package/dist/cli/info.d.ts.map +1 -0
- package/dist/cli/info.js +34 -0
- package/dist/cli/info.js.map +1 -0
- package/dist/cli/metrics.d.ts +3 -0
- package/dist/cli/metrics.d.ts.map +1 -0
- package/dist/cli/metrics.js +20 -0
- package/dist/cli/metrics.js.map +1 -0
- package/dist/cli/query.d.ts +3 -0
- package/dist/cli/query.d.ts.map +1 -0
- package/dist/cli/query.js +18 -0
- package/dist/cli/query.js.map +1 -0
- package/dist/cli/serve.d.ts +3 -0
- package/dist/cli/serve.d.ts.map +1 -0
- package/dist/cli/serve.js +19 -0
- package/dist/cli/serve.js.map +1 -0
- package/dist/cli/sleep.d.ts +3 -0
- package/dist/cli/sleep.d.ts.map +1 -0
- package/dist/cli/sleep.js +19 -0
- package/dist/cli/sleep.js.map +1 -0
- package/dist/cli/summary.d.ts +3 -0
- package/dist/cli/summary.d.ts.map +1 -0
- package/dist/cli/summary.js +53 -0
- package/dist/cli/summary.js.map +1 -0
- package/dist/cli/trends.d.ts +3 -0
- package/dist/cli/trends.d.ts.map +1 -0
- package/dist/cli/trends.js +77 -0
- package/dist/cli/trends.js.map +1 -0
- package/dist/cli/watch.d.ts +12 -0
- package/dist/cli/watch.d.ts.map +1 -0
- package/dist/cli/watch.js +89 -0
- package/dist/cli/watch.js.map +1 -0
- package/dist/cli/workouts.d.ts +3 -0
- package/dist/cli/workouts.d.ts.map +1 -0
- package/dist/cli/workouts.js +19 -0
- package/dist/cli/workouts.js.map +1 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +25 -0
- package/dist/config.js.map +1 -0
- package/dist/db/importLog.d.ts +5 -0
- package/dist/db/importLog.d.ts.map +1 -0
- package/dist/db/importLog.js +10 -0
- package/dist/db/importLog.js.map +1 -0
- package/dist/db/metrics.d.ts +4 -0
- package/dist/db/metrics.d.ts.map +1 -0
- package/dist/db/metrics.js +14 -0
- package/dist/db/metrics.js.map +1 -0
- package/dist/db/schema.d.ts +5 -0
- package/dist/db/schema.d.ts.map +1 -0
- package/dist/db/schema.js +100 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/db/sleep.d.ts +4 -0
- package/dist/db/sleep.d.ts.map +1 -0
- package/dist/db/sleep.js +13 -0
- package/dist/db/sleep.js.map +1 -0
- package/dist/db/workouts.d.ts +4 -0
- package/dist/db/workouts.d.ts.map +1 -0
- package/dist/db/workouts.js +11 -0
- package/dist/db/workouts.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/parse/metrics.d.ts +17 -0
- package/dist/parse/metrics.d.ts.map +1 -0
- package/dist/parse/metrics.js +33 -0
- package/dist/parse/metrics.js.map +1 -0
- package/dist/parse/sleep.d.ts +23 -0
- package/dist/parse/sleep.d.ts.map +1 -0
- package/dist/parse/sleep.js +58 -0
- package/dist/parse/sleep.js.map +1 -0
- package/dist/parse/time.d.ts +4 -0
- package/dist/parse/time.d.ts.map +1 -0
- package/dist/parse/time.js +41 -0
- package/dist/parse/time.js.map +1 -0
- package/dist/parse/workouts.d.ts +17 -0
- package/dist/parse/workouts.d.ts.map +1 -0
- package/dist/parse/workouts.js +24 -0
- package/dist/parse/workouts.js.map +1 -0
- package/dist/server/app.d.ts +5 -0
- package/dist/server/app.d.ts.map +1 -0
- package/dist/server/app.js +39 -0
- package/dist/server/app.js.map +1 -0
- package/dist/server/ingest.d.ts +15 -0
- package/dist/server/ingest.d.ts.map +1 -0
- package/dist/server/ingest.js +41 -0
- package/dist/server/ingest.js.map +1 -0
- package/dist/types/hae.d.ts +103 -0
- package/dist/types/hae.d.ts.map +1 -0
- package/dist/types/hae.js +2 -0
- package/dist/types/hae.js.map +1 -0
- package/dist/util/zip.d.ts +3 -0
- package/dist/util/zip.d.ts.map +1 -0
- package/dist/util/zip.js +24 -0
- package/dist/util/zip.js.map +1 -0
- package/docs/COMMANDS.md +315 -0
- package/docs/plans/2026-02-18-hae-vault-initial-implementation.md +2015 -0
- package/docs/plans/2026-02-18-readme-dashboard-design.md +213 -0
- package/docs/plans/2026-02-18-readme-dashboard-plan.md +1306 -0
- package/docs/plans/2026-02-18-zip-env-watch-design.md +213 -0
- package/docs/plans/2026-02-18-zip-env-watch.md +966 -0
- package/package.json +57 -0
- package/src/cli/dashboard.ts +242 -0
- package/src/cli/import.ts +85 -0
- package/src/cli/index.ts +32 -0
- package/src/cli/info.ts +36 -0
- package/src/cli/metrics.ts +20 -0
- package/src/cli/query.ts +17 -0
- package/src/cli/serve.ts +18 -0
- package/src/cli/sleep.ts +19 -0
- package/src/cli/summary.ts +58 -0
- package/src/cli/trends.ts +103 -0
- package/src/cli/watch.ts +111 -0
- package/src/cli/workouts.ts +19 -0
- package/src/config.ts +28 -0
- package/src/db/importLog.ts +18 -0
- package/src/db/metrics.ts +15 -0
- package/src/db/schema.ts +105 -0
- package/src/db/sleep.ts +15 -0
- package/src/db/workouts.ts +13 -0
- package/src/index.ts +4 -0
- package/src/parse/metrics.ts +50 -0
- package/src/parse/sleep.ts +82 -0
- package/src/parse/time.ts +43 -0
- package/src/parse/workouts.ts +42 -0
- package/src/server/app.ts +46 -0
- package/src/server/ingest.ts +68 -0
- package/src/types/hae.ts +94 -0
- package/src/util/zip.ts +24 -0
- package/tests/cli-watch.test.ts +64 -0
- package/tests/db-import-log.test.ts +40 -0
- package/tests/db-metrics.test.ts +44 -0
- package/tests/db-schema.test.ts +55 -0
- package/tests/db-sleep.test.ts +36 -0
- package/tests/db-workouts.test.ts +34 -0
- package/tests/ingest.test.ts +99 -0
- package/tests/parse-metrics.test.ts +55 -0
- package/tests/parse-sleep.test.ts +65 -0
- package/tests/parse-time.test.ts +48 -0
- package/tests/parse-workouts.test.ts +43 -0
- package/tests/types.test.ts +27 -0
- package/tests/util-zip.test.ts +46 -0
- 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.
|