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,103 @@
1
+ import { Command } from 'commander';
2
+ import { openDb } from '../db/schema.js';
3
+
4
+ function fmt1(n: number): string {
5
+ return n.toFixed(1);
6
+ }
7
+
8
+ function fmtInt(n: number): string {
9
+ return Math.round(n).toLocaleString();
10
+ }
11
+
12
+ function arrow(values: number[]): string {
13
+ if (values.length < 2) return '→';
14
+ const half = Math.floor(values.length / 2);
15
+ const firstHalfAvg = values.slice(0, half).reduce((a, b) => a + b, 0) / half;
16
+ const secondHalfAvg = values.slice(half).reduce((a, b) => a + b, 0) / (values.length - half);
17
+ const delta = secondHalfAvg - firstHalfAvg;
18
+ if (Math.abs(delta) < 0.01 * Math.abs(firstHalfAvg || 1)) return '→';
19
+ return delta > 0 ? '↑' : '↓';
20
+ }
21
+
22
+ interface MetricTrendRow { date: string; avg_qty: number }
23
+
24
+ function metricTrend(
25
+ db: ReturnType<typeof openDb>,
26
+ metricName: string,
27
+ since: string
28
+ ): { values: number[]; avg: number; min: number; max: number } | null {
29
+ const rows = db.prepare(
30
+ `SELECT date, AVG(qty) as avg_qty FROM metrics
31
+ WHERE metric = ? AND date >= ? AND qty IS NOT NULL
32
+ GROUP BY date ORDER BY date ASC`
33
+ ).all(metricName, since) as MetricTrendRow[];
34
+ if (rows.length === 0) return null;
35
+ const values = rows.map(r => r.avg_qty);
36
+ const avg = values.reduce((a, b) => a + b, 0) / values.length;
37
+ return { values, avg, min: Math.min(...values), max: Math.max(...values) };
38
+ }
39
+
40
+ function sleepTrend(
41
+ db: ReturnType<typeof openDb>,
42
+ since: string
43
+ ): { values: number[]; avg: number; min: number; max: number } | null {
44
+ const rows = db.prepare(
45
+ `SELECT asleep_h FROM sleep WHERE date >= ? AND asleep_h IS NOT NULL ORDER BY date ASC`
46
+ ).all(since) as { asleep_h: number }[];
47
+ if (rows.length === 0) return null;
48
+ const values = rows.map(r => r.asleep_h);
49
+ const avg = values.reduce((a, b) => a + b, 0) / values.length;
50
+ return { values, avg, min: Math.min(...values), max: Math.max(...values) };
51
+ }
52
+
53
+ export const trendsCommand = new Command('trends')
54
+ .description('Multi-metric trend analysis with averages, ranges, and direction arrows')
55
+ .option('--days <n>', 'Days of history', '7')
56
+ .option('--json', 'Output raw JSON')
57
+ .action((opts) => {
58
+ const db = openDb();
59
+ const days = parseInt(opts.days, 10);
60
+ const since = new Date();
61
+ since.setDate(since.getDate() - days);
62
+ const sinceStr = since.toISOString().slice(0, 10);
63
+
64
+ const steps = metricTrend(db, 'step_count', sinceStr);
65
+ const restingHR = metricTrend(db, 'resting_heart_rate', sinceStr);
66
+ const hrv = metricTrend(db, 'heart_rate_variability_sdnn', sinceStr);
67
+ const activeCal = metricTrend(db, 'active_energy_burned', sinceStr);
68
+ const sleep = sleepTrend(db, sinceStr);
69
+
70
+ if (opts.json) {
71
+ console.log(JSON.stringify({ days, steps, restingHR, hrv, activeCal, sleep }, null, 2));
72
+ return;
73
+ }
74
+
75
+ const lines: string[] = [];
76
+ lines.push(`📊 ${days}-Day Trends`);
77
+ lines.push('');
78
+
79
+ function row(
80
+ emoji: string,
81
+ label: string,
82
+ data: { values: number[]; avg: number; min: number; max: number } | null,
83
+ unit: string,
84
+ round: boolean
85
+ ): void {
86
+ if (!data) return;
87
+ const dir = arrow(data.values);
88
+ const fmt = round ? fmtInt : fmt1;
89
+ lines.push(`${emoji} ${label}: ${fmt(data.avg)} avg (${fmt(data.min)}–${fmt(data.max)}) ${dir}`);
90
+ }
91
+
92
+ row('👟', 'Steps', steps, '', true);
93
+ row('💓', 'Resting HR', restingHR, 'bpm', true);
94
+ row('🧠', 'HRV', hrv, 'ms', true);
95
+ row('😴', 'Sleep', sleep, 'h', false);
96
+ row('🔥', 'Active Cal', activeCal, 'kcal', true);
97
+
98
+ if (lines.length === 2) {
99
+ lines.push(' No trend data available');
100
+ }
101
+
102
+ console.log(lines.join('\n'));
103
+ });
@@ -0,0 +1,111 @@
1
+ import { Command } from 'commander';
2
+ import { readdirSync, readFileSync } from 'node:fs';
3
+ import { createHash } from 'node:crypto';
4
+ import { join } from 'node:path';
5
+ import type Database from 'better-sqlite3';
6
+ import { openDb } from '../db/schema.js';
7
+ import { ingest } from '../server/ingest.js';
8
+ import { hasBeenImported, logImport } from '../db/importLog.js';
9
+ import { extractPayloadFromZip } from '../util/zip.js';
10
+ import { config } from '../config.js';
11
+ import type { HaePayload } from '../types/hae.js';
12
+
13
+ const HAE_PATTERN = /^HealthAutoExport.*\.(zip|json)$/i;
14
+
15
+ function sha256(buf: Buffer): string {
16
+ return createHash('sha256').update(buf).digest('hex');
17
+ }
18
+
19
+ function loadBuf(buf: Buffer, filename: string): HaePayload | null {
20
+ if (filename.toLowerCase().endsWith('.zip')) {
21
+ return extractPayloadFromZip(buf);
22
+ }
23
+ try {
24
+ const p = JSON.parse(buf.toString('utf-8')) as HaePayload;
25
+ return p?.data ? p : null;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ export interface TickResult {
32
+ tick: string;
33
+ dir: string;
34
+ found: number;
35
+ imported: number;
36
+ skipped: number;
37
+ }
38
+
39
+ export function tick(db: Database.Database, watchDir: string, target: string): TickResult {
40
+ const now = new Date().toISOString();
41
+ let found = 0, imported = 0, skipped = 0;
42
+
43
+ let files: string[];
44
+ try {
45
+ files = readdirSync(watchDir).filter(f => HAE_PATTERN.test(f));
46
+ } catch (err) {
47
+ console.error(JSON.stringify({ error: `Cannot read watch dir: ${String(err)}` }));
48
+ return { tick: now, dir: watchDir, found, imported, skipped };
49
+ }
50
+
51
+ found = files.length;
52
+
53
+ for (const filename of files) {
54
+ const filepath = join(watchDir, filename);
55
+ let buf: Buffer;
56
+ try {
57
+ buf = readFileSync(filepath);
58
+ } catch {
59
+ skipped++;
60
+ continue;
61
+ }
62
+
63
+ const hash = sha256(buf);
64
+ if (hasBeenImported(db, hash)) {
65
+ skipped++;
66
+ continue;
67
+ }
68
+
69
+ const payload = loadBuf(buf, filename);
70
+ if (!payload) {
71
+ skipped++;
72
+ continue;
73
+ }
74
+
75
+ const result = ingest(db, payload, {
76
+ target,
77
+ sessionId: null,
78
+ automationName: 'watch',
79
+ automationPeriod: 'manual',
80
+ });
81
+
82
+ logImport(db, filepath, hash, result);
83
+ console.log(JSON.stringify({ imported: filename, target, ...result }));
84
+ imported++;
85
+ }
86
+
87
+ const summary: TickResult = { tick: now, dir: watchDir, found, imported, skipped };
88
+ console.log(JSON.stringify(summary));
89
+ return summary;
90
+ }
91
+
92
+ export const watchCommand = new Command('watch')
93
+ .description('Poll a directory for new HAE exports and auto-import them')
94
+ .option('--dir <path>', 'Directory to watch', config.watchDir)
95
+ .option('--interval <seconds>', 'Poll interval in seconds', String(config.watchInterval))
96
+ .option('--target <name>', 'Target name', config.target)
97
+ .action((opts) => {
98
+ const watchDir: string | undefined = opts.dir;
99
+ if (!watchDir) {
100
+ console.error(JSON.stringify({ error: 'Watch directory required: use --dir or set HVAULT_WATCH_DIR' }));
101
+ process.exit(1);
102
+ }
103
+
104
+ const intervalMs = Number(opts.interval) * 1000;
105
+ const db = openDb(config.dbPath);
106
+
107
+ console.log(JSON.stringify({ watching: watchDir, intervalSeconds: Number(opts.interval), target: opts.target }));
108
+
109
+ tick(db, watchDir, opts.target);
110
+ setInterval(() => tick(db, watchDir, opts.target), intervalMs);
111
+ });
@@ -0,0 +1,19 @@
1
+ import { Command } from 'commander';
2
+ import { openDb } from '../db/schema.js';
3
+
4
+ export const workoutsCommand = new Command('workouts')
5
+ .description('Query workouts')
6
+ .option('--days <n>', 'Last N days', '30')
7
+ .option('--pretty', 'Pretty-print JSON', false)
8
+ .action((opts) => {
9
+ const db = openDb();
10
+ const since = new Date();
11
+ since.setDate(since.getDate() - parseInt(opts.days, 10));
12
+ const rows = db.prepare(`
13
+ SELECT ts, date, name, duration_s, calories_kj, distance, distance_unit, avg_hr, max_hr, target
14
+ FROM workouts
15
+ WHERE date >= ?
16
+ ORDER BY ts ASC
17
+ `).all(since.toISOString().slice(0, 10));
18
+ console.log(opts.pretty ? JSON.stringify(rows, null, 2) : JSON.stringify(rows));
19
+ });
package/src/config.ts ADDED
@@ -0,0 +1,28 @@
1
+ import { config as dotenvLoad } from 'dotenv';
2
+ import { existsSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ function expandTilde(p: string): string {
7
+ if (p === '~' || p.startsWith('~/')) {
8
+ return homedir() + p.slice(1);
9
+ }
10
+ return p;
11
+ }
12
+
13
+ // Load .env: HVAULT_ENV_FILE env var overrides; fallback to CWD .env
14
+ const envFile = process.env.HVAULT_ENV_FILE ?? join(process.cwd(), '.env');
15
+ if (existsSync(envFile)) {
16
+ dotenvLoad({ path: envFile });
17
+ }
18
+
19
+ const DEFAULT_DB_PATH = join(homedir(), '.hae-vault', 'health.db');
20
+
21
+ export const config = {
22
+ dbPath: expandTilde(process.env.HVAULT_DB_PATH ?? DEFAULT_DB_PATH),
23
+ port: Number(process.env.HVAULT_PORT ?? 4242),
24
+ token: process.env.HVAULT_TOKEN,
25
+ watchDir: process.env.HVAULT_WATCH_DIR ? expandTilde(process.env.HVAULT_WATCH_DIR) : undefined,
26
+ watchInterval: Number(process.env.HVAULT_WATCH_INTERVAL ?? 60),
27
+ target: process.env.HVAULT_TARGET ?? 'default',
28
+ } as const;
@@ -0,0 +1,18 @@
1
+ import type Database from 'better-sqlite3';
2
+ import type { IngestResult } from '../server/ingest.js';
3
+
4
+ export function hasBeenImported(db: Database.Database, hash: string): boolean {
5
+ return db.prepare('SELECT id FROM import_log WHERE file_hash = ?').get(hash) !== undefined;
6
+ }
7
+
8
+ export function logImport(
9
+ db: Database.Database,
10
+ filename: string,
11
+ hash: string,
12
+ result: IngestResult,
13
+ ): void {
14
+ db.prepare(`
15
+ INSERT OR IGNORE INTO import_log (filename, file_hash, imported_at, metrics_added, sleep_added, workouts_added)
16
+ VALUES (?, ?, ?, ?, ?, ?)
17
+ `).run(filename, hash, new Date().toISOString(), result.metricsAdded, result.sleepAdded, result.workoutsAdded);
18
+ }
@@ -0,0 +1,15 @@
1
+ import type Database from 'better-sqlite3';
2
+ import type { NormalizedMetric } from '../parse/metrics.js';
3
+
4
+ export function upsertMetrics(db: Database.Database, rows: NormalizedMetric[]): void {
5
+ const stmt = db.prepare(`
6
+ INSERT OR REPLACE INTO metrics
7
+ (ts, date, metric, qty, min, avg, max, units, source, target, meta, session_id)
8
+ VALUES
9
+ (@ts, @date, @metric, @qty, @min, @avg, @max, @units, @source, @target, @meta, @session_id)
10
+ `);
11
+ const insertMany = db.transaction((rows: NormalizedMetric[]) => {
12
+ for (const row of rows) stmt.run(row);
13
+ });
14
+ insertMany(rows);
15
+ }
@@ -0,0 +1,105 @@
1
+ import Database from 'better-sqlite3';
2
+ import { mkdirSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+
6
+ export const DEFAULT_DB_PATH = join(homedir(), '.hae-vault', 'health.db');
7
+
8
+ export function openDb(dbPath = DEFAULT_DB_PATH): Database.Database {
9
+ mkdirSync(dirname(dbPath), { recursive: true });
10
+ const db = new Database(dbPath);
11
+
12
+ db.pragma('journal_mode = WAL');
13
+ db.pragma('foreign_keys = ON');
14
+
15
+ db.exec(`
16
+ CREATE TABLE IF NOT EXISTS metrics (
17
+ id INTEGER PRIMARY KEY,
18
+ ts TEXT NOT NULL,
19
+ date TEXT NOT NULL,
20
+ metric TEXT NOT NULL,
21
+ qty REAL,
22
+ min REAL,
23
+ avg REAL,
24
+ max REAL,
25
+ units TEXT,
26
+ source TEXT,
27
+ target TEXT,
28
+ meta TEXT,
29
+ session_id TEXT,
30
+ UNIQUE(ts, metric, source, target)
31
+ );
32
+
33
+ CREATE INDEX IF NOT EXISTS idx_metrics_date ON metrics(date);
34
+ CREATE INDEX IF NOT EXISTS idx_metrics_metric ON metrics(metric);
35
+
36
+ CREATE TABLE IF NOT EXISTS sleep (
37
+ id INTEGER PRIMARY KEY,
38
+ date TEXT NOT NULL,
39
+ sleep_start TEXT,
40
+ sleep_end TEXT,
41
+ in_bed_start TEXT,
42
+ in_bed_end TEXT,
43
+ core_h REAL,
44
+ deep_h REAL,
45
+ rem_h REAL,
46
+ awake_h REAL,
47
+ asleep_h REAL,
48
+ in_bed_h REAL,
49
+ schema_ver TEXT,
50
+ source TEXT,
51
+ target TEXT,
52
+ meta TEXT,
53
+ session_id TEXT,
54
+ UNIQUE(date, source, target)
55
+ );
56
+
57
+ CREATE INDEX IF NOT EXISTS idx_sleep_date ON sleep(date);
58
+
59
+ CREATE TABLE IF NOT EXISTS workouts (
60
+ id INTEGER PRIMARY KEY,
61
+ ts TEXT NOT NULL,
62
+ date TEXT NOT NULL,
63
+ name TEXT NOT NULL,
64
+ duration_s INTEGER,
65
+ calories_kj REAL,
66
+ distance REAL,
67
+ distance_unit TEXT,
68
+ avg_hr REAL,
69
+ max_hr REAL,
70
+ target TEXT,
71
+ meta TEXT,
72
+ session_id TEXT,
73
+ UNIQUE(ts, name, target)
74
+ );
75
+
76
+ CREATE INDEX IF NOT EXISTS idx_workouts_date ON workouts(date);
77
+
78
+ CREATE TABLE IF NOT EXISTS sync_log (
79
+ id INTEGER PRIMARY KEY,
80
+ received_at TEXT NOT NULL,
81
+ target TEXT,
82
+ session_id TEXT,
83
+ metrics_count INTEGER,
84
+ workouts_count INTEGER,
85
+ automation_name TEXT,
86
+ automation_period TEXT
87
+ );
88
+
89
+ CREATE TABLE IF NOT EXISTS import_log (
90
+ id INTEGER PRIMARY KEY,
91
+ filename TEXT NOT NULL,
92
+ file_hash TEXT NOT NULL UNIQUE,
93
+ imported_at TEXT NOT NULL,
94
+ metrics_added INTEGER,
95
+ sleep_added INTEGER,
96
+ workouts_added INTEGER
97
+ );
98
+ `);
99
+
100
+ return db;
101
+ }
102
+
103
+ export function closeDb(db: Database.Database): void {
104
+ db.close();
105
+ }
@@ -0,0 +1,15 @@
1
+ import type Database from 'better-sqlite3';
2
+ import type { NormalizedSleep } from '../parse/sleep.js';
3
+
4
+ export function upsertSleep(db: Database.Database, row: NormalizedSleep): void {
5
+ db.prepare(`
6
+ INSERT OR REPLACE INTO sleep
7
+ (date, sleep_start, sleep_end, in_bed_start, in_bed_end,
8
+ core_h, deep_h, rem_h, awake_h, asleep_h, in_bed_h,
9
+ schema_ver, source, target, meta, session_id)
10
+ VALUES
11
+ (@date, @sleep_start, @sleep_end, @in_bed_start, @in_bed_end,
12
+ @core_h, @deep_h, @rem_h, @awake_h, @asleep_h, @in_bed_h,
13
+ @schema_ver, @source, @target, @meta, @session_id)
14
+ `).run(row);
15
+ }
@@ -0,0 +1,13 @@
1
+ import type Database from 'better-sqlite3';
2
+ import type { NormalizedWorkout } from '../parse/workouts.js';
3
+
4
+ export function upsertWorkout(db: Database.Database, row: NormalizedWorkout): void {
5
+ db.prepare(`
6
+ INSERT OR REPLACE INTO workouts
7
+ (ts, date, name, duration_s, calories_kj, distance, distance_unit,
8
+ avg_hr, max_hr, target, meta, session_id)
9
+ VALUES
10
+ (@ts, @date, @name, @duration_s, @calories_kj, @distance, @distance_unit,
11
+ @avg_hr, @max_hr, @target, @meta, @session_id)
12
+ `).run(row);
13
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import './config.js'; // load dotenv before any command runs
3
+ import { program } from './cli/index.js';
4
+ program.parse();
@@ -0,0 +1,50 @@
1
+ import { parseHaeTime, toIso, toDateStr } from './time.js';
2
+ import type { MetricData, RawDatapoint } from '../types/hae.js';
3
+
4
+ export interface NormalizedMetric {
5
+ ts: string;
6
+ date: string;
7
+ metric: string;
8
+ qty: number | null;
9
+ min: number | null;
10
+ avg: number | null;
11
+ max: number | null;
12
+ units: string;
13
+ source: string | null;
14
+ target: string;
15
+ meta: string | null;
16
+ session_id: string | null;
17
+ }
18
+
19
+ export function parseMetric(m: MetricData, target: string, sessionId: string | null): NormalizedMetric[] {
20
+ if (m.name === 'sleep_analysis') return [];
21
+
22
+ return (m.data as RawDatapoint[]).map((dp) => {
23
+ const d = parseHaeTime(dp.date);
24
+ const isHeartRate = dp.Min !== undefined || dp.Avg !== undefined || dp.Max !== undefined;
25
+ const isBloodPressure = dp.systolic !== undefined || dp.diastolic !== undefined;
26
+
27
+ let qty: number | null = null;
28
+ let min: number | null = null;
29
+ let avg: number | null = null;
30
+ let max: number | null = null;
31
+ let meta: string | null = null;
32
+
33
+ if (isHeartRate) {
34
+ min = dp.Min ?? null;
35
+ avg = dp.Avg ?? null;
36
+ max = dp.Max ?? null;
37
+ } else if (isBloodPressure) {
38
+ meta = JSON.stringify({ systolic: dp.systolic, diastolic: dp.diastolic });
39
+ } else {
40
+ qty = dp.qty ?? null;
41
+ }
42
+
43
+ return {
44
+ ts: toIso(d), date: toDateStr(d),
45
+ metric: m.name, qty, min, avg, max,
46
+ units: m.units, source: dp.source ?? null,
47
+ target, meta, session_id: sessionId,
48
+ };
49
+ });
50
+ }
@@ -0,0 +1,82 @@
1
+ import { parseHaeTime, toIso, toDateStr } from './time.js';
2
+ import type { SleepDatapoint, SleepAnalysisRaw, AggregatedSleepV2, AggregatedSleepV1 } from '../types/hae.js';
3
+
4
+ export type SleepVariant = 'detailed' | 'aggregated_v2' | 'aggregated_v1';
5
+
6
+ export interface NormalizedSleep {
7
+ date: string;
8
+ sleep_start: string | null;
9
+ sleep_end: string | null;
10
+ in_bed_start: string | null;
11
+ in_bed_end: string | null;
12
+ core_h: number | null;
13
+ deep_h: number | null;
14
+ rem_h: number | null;
15
+ awake_h: number | null;
16
+ asleep_h: number | null;
17
+ in_bed_h: number | null;
18
+ schema_ver: SleepVariant;
19
+ source: string | null;
20
+ target: string;
21
+ meta: string | null;
22
+ session_id: string | null;
23
+ }
24
+
25
+ export function detectSleepVariant(dp: SleepDatapoint): SleepVariant {
26
+ if ('startDate' in dp) return 'detailed';
27
+ if ('core' in dp) return 'aggregated_v2';
28
+ return 'aggregated_v1';
29
+ }
30
+
31
+ export function normalizeSleep(dp: SleepDatapoint, target: string, sessionId: string | null): NormalizedSleep {
32
+ const variant = detectSleepVariant(dp);
33
+
34
+ if (variant === 'detailed') {
35
+ const raw = dp as SleepAnalysisRaw;
36
+ const start = parseHaeTime(raw.startDate);
37
+ return {
38
+ date: toDateStr(start),
39
+ sleep_start: toIso(start),
40
+ sleep_end: toIso(parseHaeTime(raw.endDate)),
41
+ in_bed_start: null, in_bed_end: null,
42
+ core_h: null, deep_h: null, rem_h: null, awake_h: null, asleep_h: null, in_bed_h: null,
43
+ schema_ver: 'detailed',
44
+ source: raw.source,
45
+ target,
46
+ meta: JSON.stringify(dp),
47
+ session_id: sessionId,
48
+ };
49
+ }
50
+
51
+ if (variant === 'aggregated_v2') {
52
+ const v2 = dp as AggregatedSleepV2;
53
+ const sleepStart = parseHaeTime(v2.sleepStart);
54
+ return {
55
+ date: toDateStr(sleepStart),
56
+ sleep_start: toIso(sleepStart),
57
+ sleep_end: toIso(parseHaeTime(v2.sleepEnd)),
58
+ in_bed_start: null, in_bed_end: null,
59
+ core_h: v2.core, deep_h: v2.deep, rem_h: v2.rem, awake_h: v2.awake,
60
+ asleep_h: v2.asleep, in_bed_h: v2.inBed,
61
+ schema_ver: 'aggregated_v2',
62
+ source: v2.source,
63
+ target, meta: null, session_id: sessionId,
64
+ };
65
+ }
66
+
67
+ // aggregated_v1
68
+ const v1 = dp as AggregatedSleepV1;
69
+ const sleepStart = parseHaeTime(v1.sleepStart);
70
+ return {
71
+ date: toDateStr(sleepStart),
72
+ sleep_start: toIso(sleepStart),
73
+ sleep_end: toIso(parseHaeTime(v1.sleepEnd)),
74
+ in_bed_start: v1.inBedStart ? toIso(parseHaeTime(v1.inBedStart)) : null,
75
+ in_bed_end: v1.inBedEnd ? toIso(parseHaeTime(v1.inBedEnd)) : null,
76
+ core_h: null, deep_h: null, rem_h: null, awake_h: null,
77
+ asleep_h: v1.asleep, in_bed_h: v1.inBed,
78
+ schema_ver: 'aggregated_v1',
79
+ source: v1.sleepSource ?? null,
80
+ target, meta: null, session_id: sessionId,
81
+ };
82
+ }
@@ -0,0 +1,43 @@
1
+ // HAE outputs 5 different timestamp formats depending on iPhone locale.
2
+
3
+ function parse24h(date: string, h: string, m: string, s: string, tz: string): Date {
4
+ const tzFormatted = `${tz.slice(0, 3)}:${tz.slice(3)}`;
5
+ return new Date(`${date}T${h}:${m}:${s}${tzFormatted}`);
6
+ }
7
+
8
+ function parse12h(date: string, h: string, m: string, s: string, ampm: string, tz: string): Date {
9
+ let hour = parseInt(h, 10);
10
+ const isAm = ampm.toLowerCase() === 'am';
11
+ if (isAm && hour === 12) hour = 0;
12
+ if (!isAm && hour !== 12) hour += 12;
13
+ const hh = String(hour).padStart(2, '0');
14
+ const tzFormatted = `${tz.slice(0, 3)}:${tz.slice(3)}`;
15
+ return new Date(`${date}T${hh}:${m}:${s}${tzFormatted}`);
16
+ }
17
+
18
+ export function parseHaeTime(s: string): Date {
19
+ // Try 24-hour format: "2026-01-15 14:30:00 +0000"
20
+ const m24 = s.match(/^(\d{4}-\d{2}-\d{2}) (\d{2}):(\d{2}):(\d{2}) ([+-]\d{4})$/);
21
+ if (m24) {
22
+ const d = parse24h(m24[1], m24[2], m24[3], m24[4], m24[5]);
23
+ if (!isNaN(d.getTime())) return d;
24
+ }
25
+
26
+ // Try 12-hour format (space or narrow non-breaking space \u202f before AM/PM)
27
+ // "2026-01-15 2:30:00 PM +0000" or "2026-01-15 2:30:00\u202fPM +0000"
28
+ const m12 = s.match(/^(\d{4}-\d{2}-\d{2}) (\d{1,2}):(\d{2}):(\d{2})[\u202f ]([APap][Mm]) ([+-]\d{4})$/);
29
+ if (m12) {
30
+ const d = parse12h(m12[1], m12[2], m12[3], m12[4], m12[5], m12[6]);
31
+ if (!isNaN(d.getTime())) return d;
32
+ }
33
+
34
+ throw new Error(`Failed to parse HAE timestamp: "${s}"`);
35
+ }
36
+
37
+ export function toIso(d: Date): string {
38
+ return d.toISOString();
39
+ }
40
+
41
+ export function toDateStr(d: Date): string {
42
+ return d.toISOString().slice(0, 10);
43
+ }
@@ -0,0 +1,42 @@
1
+ import { parseHaeTime, toIso, toDateStr } from './time.js';
2
+ import type { WorkoutData } from '../types/hae.js';
3
+
4
+ export interface NormalizedWorkout {
5
+ ts: string;
6
+ date: string;
7
+ name: string;
8
+ duration_s: number | null;
9
+ calories_kj: number | null;
10
+ distance: number | null;
11
+ distance_unit: string | null;
12
+ avg_hr: number | null;
13
+ max_hr: number | null;
14
+ target: string;
15
+ meta: string;
16
+ session_id: string | null;
17
+ }
18
+
19
+ export function parseWorkout(w: WorkoutData, target: string, sessionId: string | null): NormalizedWorkout {
20
+ const start = parseHaeTime(w.start);
21
+ const end = parseHaeTime(w.end);
22
+ const duration_s = Math.round((end.getTime() - start.getTime()) / 1000);
23
+
24
+ const hrValues = (w.heartRateData ?? []).map((h) => h.qty).filter((v): v is number => typeof v === 'number');
25
+ const avg_hr = hrValues.length > 0 ? hrValues.reduce((a, b) => a + b, 0) / hrValues.length : null;
26
+ const max_hr = hrValues.length > 0 ? Math.max(...hrValues) : null;
27
+
28
+ return {
29
+ ts: toIso(start),
30
+ date: toDateStr(start),
31
+ name: w.name,
32
+ duration_s,
33
+ calories_kj: w.activeEnergyBurned?.qty ?? null,
34
+ distance: w.distance?.qty ?? null,
35
+ distance_unit: w.distance?.units ?? null,
36
+ avg_hr,
37
+ max_hr,
38
+ target,
39
+ meta: JSON.stringify(w),
40
+ session_id: sessionId,
41
+ };
42
+ }