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
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hae-vault",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI + HTTP server for Apple Health data from Health Auto Export",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"hvault": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./dist/index.js",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"dev": "tsx src/index.ts",
|
|
12
|
+
"build": "tsc",
|
|
13
|
+
"start": "node dist/index.js",
|
|
14
|
+
"prepare": "npm run build",
|
|
15
|
+
"test": "tsx --test tests/**/*.test.ts"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=22.0.0"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"apple-health",
|
|
22
|
+
"health-auto-export",
|
|
23
|
+
"sqlite",
|
|
24
|
+
"cli",
|
|
25
|
+
"hvault",
|
|
26
|
+
"sleep",
|
|
27
|
+
"hrv",
|
|
28
|
+
"steps",
|
|
29
|
+
"fitness",
|
|
30
|
+
"hae"
|
|
31
|
+
],
|
|
32
|
+
"author": "Ruben Khachaturov <mr.kha4a2rov@protonmail.com>",
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "https://github.com/mrkhachaturov/hae-vault.git"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://github.com/mrkhachaturov/hae-vault#readme",
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/mrkhachaturov/hae-vault/issues"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"adm-zip": "^0.5.16",
|
|
44
|
+
"better-sqlite3": "^12.6.2",
|
|
45
|
+
"commander": "^12.1.0",
|
|
46
|
+
"dotenv": "^17.3.1",
|
|
47
|
+
"express": "^4.21.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/adm-zip": "^0.5.7",
|
|
51
|
+
"@types/better-sqlite3": "^7.6.11",
|
|
52
|
+
"@types/express": "^4.17.21",
|
|
53
|
+
"@types/node": "^22.10.0",
|
|
54
|
+
"tsx": "^4.19.0",
|
|
55
|
+
"typescript": "^5.7.0"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { openDb } from '../db/schema.js';
|
|
3
|
+
|
|
4
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
function pad(s: string, width: number): string {
|
|
7
|
+
return s.padEnd(width);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function fmt1(n: number | null | undefined): string {
|
|
11
|
+
if (n == null) return '—';
|
|
12
|
+
return n.toFixed(1);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function fmtInt(n: number | null | undefined): string {
|
|
16
|
+
if (n == null) return '—';
|
|
17
|
+
return Math.round(n).toLocaleString();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function sec2min(s: number | null | undefined): string {
|
|
21
|
+
if (s == null) return '—';
|
|
22
|
+
return `${Math.round(s / 60)}min`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function kj2kcal(kj: number | null | undefined): string {
|
|
26
|
+
if (kj == null) return '—';
|
|
27
|
+
return `${Math.round(kj / 4.184)} kcal`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function arrow(first: number, last: number): string {
|
|
31
|
+
const delta = last - first;
|
|
32
|
+
if (Math.abs(delta) < 0.01 * Math.abs(first || 1)) return '→';
|
|
33
|
+
return delta > 0 ? '↑' : '↓';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function ruler(label: string, width = 43): string {
|
|
37
|
+
const inner = `── ${label} `;
|
|
38
|
+
return inner + '─'.repeat(Math.max(0, width - inner.length));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function workoutEmoji(name: string): string {
|
|
42
|
+
const n = name.toLowerCase();
|
|
43
|
+
if (n.includes('run')) return '🏃';
|
|
44
|
+
if (n.includes('walk')) return '🚶';
|
|
45
|
+
if (n.includes('cycl') || n.includes('bike')) return '🚴';
|
|
46
|
+
if (n.includes('swim')) return '🏊';
|
|
47
|
+
if (n.includes('yoga')) return '🧘';
|
|
48
|
+
if (n.includes('strength') || n.includes('weight') || n.includes('lift')) return '🏋️';
|
|
49
|
+
if (n.includes('hike')) return '🥾';
|
|
50
|
+
return '🏅';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function latestMetric(
|
|
54
|
+
db: ReturnType<typeof openDb>,
|
|
55
|
+
metricName: string,
|
|
56
|
+
days = 7
|
|
57
|
+
): number | null {
|
|
58
|
+
const since = new Date();
|
|
59
|
+
since.setDate(since.getDate() - days);
|
|
60
|
+
const row = db.prepare(
|
|
61
|
+
`SELECT qty FROM metrics WHERE metric = ? AND date >= ? AND qty IS NOT NULL ORDER BY ts DESC LIMIT 1`
|
|
62
|
+
).get(metricName, since.toISOString().slice(0, 10)) as { qty: number } | undefined;
|
|
63
|
+
return row?.qty ?? null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function dailyAvgs(
|
|
67
|
+
db: ReturnType<typeof openDb>,
|
|
68
|
+
metricName: string,
|
|
69
|
+
days: number
|
|
70
|
+
): number[] {
|
|
71
|
+
const since = new Date();
|
|
72
|
+
since.setDate(since.getDate() - days);
|
|
73
|
+
const rows = db.prepare(
|
|
74
|
+
`SELECT date, AVG(qty) as avg_qty FROM metrics
|
|
75
|
+
WHERE metric = ? AND date >= ? AND qty IS NOT NULL
|
|
76
|
+
GROUP BY date ORDER BY date ASC`
|
|
77
|
+
).all(metricName, since.toISOString().slice(0, 10)) as { date: string; avg_qty: number }[];
|
|
78
|
+
return rows.map(r => r.avg_qty);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sleepDailyAvgs(
|
|
82
|
+
db: ReturnType<typeof openDb>,
|
|
83
|
+
days: number
|
|
84
|
+
): number[] {
|
|
85
|
+
const since = new Date();
|
|
86
|
+
since.setDate(since.getDate() - days);
|
|
87
|
+
const rows = db.prepare(
|
|
88
|
+
`SELECT asleep_h FROM sleep WHERE date >= ? AND asleep_h IS NOT NULL ORDER BY date ASC`
|
|
89
|
+
).all(since.toISOString().slice(0, 10)) as { asleep_h: number }[];
|
|
90
|
+
return rows.map(r => r.asleep_h);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function trendLine(
|
|
94
|
+
values: number[],
|
|
95
|
+
label: string,
|
|
96
|
+
unit: string,
|
|
97
|
+
round = false
|
|
98
|
+
): string {
|
|
99
|
+
if (values.length < 2) return '';
|
|
100
|
+
const avg = values.reduce((a, b) => a + b, 0) / values.length;
|
|
101
|
+
const first = values[0];
|
|
102
|
+
const last = values[values.length - 1];
|
|
103
|
+
const dir = arrow(first, last);
|
|
104
|
+
const fmt = round ? fmtInt : fmt1;
|
|
105
|
+
return ` ${pad(label + ':', 14)} ${fmt(first)} → ${fmt(last)}${unit} ${dir} (avg ${fmt(avg)})`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── command ───────────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
export const dashboardCommand = new Command('dashboard')
|
|
111
|
+
.description('Terminal dashboard: sleep, activity, heart health, workouts, trends')
|
|
112
|
+
.option('--days <n>', 'Trend window in days', '7')
|
|
113
|
+
.option('--json', 'Output raw JSON')
|
|
114
|
+
.action((opts) => {
|
|
115
|
+
const db = openDb();
|
|
116
|
+
const trendDays = parseInt(opts.days, 10);
|
|
117
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
118
|
+
|
|
119
|
+
// ── sleep (last night) ─────────────────────────────────────────────────
|
|
120
|
+
const sleep = db.prepare(
|
|
121
|
+
`SELECT * FROM sleep ORDER BY date DESC LIMIT 1`
|
|
122
|
+
).get() as {
|
|
123
|
+
date: string; asleep_h: number | null; in_bed_h: number | null;
|
|
124
|
+
deep_h: number | null; rem_h: number | null; awake_h: number | null;
|
|
125
|
+
source: string | null;
|
|
126
|
+
} | undefined;
|
|
127
|
+
|
|
128
|
+
// ── activity (today, fallback last 2 days) ─────────────────────────────
|
|
129
|
+
const steps = latestMetric(db, 'step_count', 2);
|
|
130
|
+
const activeCal = latestMetric(db, 'active_energy_burned', 2);
|
|
131
|
+
const standHours = latestMetric(db, 'apple_stand_hour', 2);
|
|
132
|
+
|
|
133
|
+
// ── heart health (last 7 days) ─────────────────────────────────────────
|
|
134
|
+
const restingHR = latestMetric(db, 'resting_heart_rate', 7);
|
|
135
|
+
const hrv = latestMetric(db, 'heart_rate_variability_sdnn', 7);
|
|
136
|
+
|
|
137
|
+
// ── recent workouts ────────────────────────────────────────────────────
|
|
138
|
+
const workouts = db.prepare(
|
|
139
|
+
`SELECT date, name, duration_s, calories_kj, avg_hr FROM workouts ORDER BY ts DESC LIMIT 5`
|
|
140
|
+
).all() as { date: string; name: string; duration_s: number | null; calories_kj: number | null; avg_hr: number | null }[];
|
|
141
|
+
|
|
142
|
+
// ── trends ────────────────────────────────────────────────────────────
|
|
143
|
+
const stepTrend = dailyAvgs(db, 'step_count', trendDays);
|
|
144
|
+
const hrTrend = dailyAvgs(db, 'resting_heart_rate', trendDays);
|
|
145
|
+
const hrvTrend = dailyAvgs(db, 'heart_rate_variability_sdnn', trendDays);
|
|
146
|
+
const sleepTrend = sleepDailyAvgs(db, trendDays);
|
|
147
|
+
|
|
148
|
+
// ── vault stats ───────────────────────────────────────────────────────
|
|
149
|
+
const metricsCount = (db.prepare('SELECT COUNT(*) as c FROM metrics').get() as { c: number }).c;
|
|
150
|
+
const sleepCount = (db.prepare('SELECT COUNT(*) as c FROM sleep').get() as { c: number }).c;
|
|
151
|
+
const workoutsCount = (db.prepare('SELECT COUNT(*) as c FROM workouts').get() as { c: number }).c;
|
|
152
|
+
const lastSync = db.prepare('SELECT received_at FROM sync_log ORDER BY received_at DESC LIMIT 1').get() as { received_at: string } | undefined;
|
|
153
|
+
|
|
154
|
+
if (opts.json) {
|
|
155
|
+
console.log(JSON.stringify({
|
|
156
|
+
date: today,
|
|
157
|
+
sleep: sleep ?? null,
|
|
158
|
+
activity: { steps, activeCal, standHours },
|
|
159
|
+
heartHealth: { restingHR, hrv },
|
|
160
|
+
workouts,
|
|
161
|
+
trends: { steps: stepTrend, restingHR: hrTrend, hrv: hrvTrend, sleep: sleepTrend },
|
|
162
|
+
vault: { metricsCount, sleepCount, workoutsCount, lastSync: lastSync?.received_at ?? null }
|
|
163
|
+
}, null, 2));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const lines: string[] = [];
|
|
168
|
+
lines.push(`📅 ${today} | Apple Health Vault`);
|
|
169
|
+
lines.push('');
|
|
170
|
+
|
|
171
|
+
// sleep
|
|
172
|
+
lines.push(ruler('Sleep (last night)'));
|
|
173
|
+
if (sleep) {
|
|
174
|
+
const eff = sleep.in_bed_h && sleep.asleep_h
|
|
175
|
+
? Math.round((sleep.asleep_h / sleep.in_bed_h) * 100) : null;
|
|
176
|
+
lines.push(`😴 ${fmt1(sleep.asleep_h)}h | Efficiency: ${eff != null ? eff + '%' : '—'}`);
|
|
177
|
+
const light = (sleep.asleep_h ?? 0) - (sleep.deep_h ?? 0) - (sleep.rem_h ?? 0);
|
|
178
|
+
const deepPct = sleep.asleep_h ? Math.round(((sleep.deep_h ?? 0) / sleep.asleep_h) * 100) : 0;
|
|
179
|
+
const remPct = sleep.asleep_h ? Math.round(((sleep.rem_h ?? 0) / sleep.asleep_h) * 100) : 0;
|
|
180
|
+
const lightPct = sleep.asleep_h ? Math.round((light / sleep.asleep_h) * 100) : 0;
|
|
181
|
+
lines.push(` Deep: ${fmt1(sleep.deep_h)}h (${deepPct}%) | REM: ${fmt1(sleep.rem_h)}h (${remPct}%) | Light: ${fmt1(light)}h (${lightPct}%)`);
|
|
182
|
+
lines.push(` Awake: ${fmt1(sleep.awake_h)}h | Source: ${sleep.source ?? '—'}`);
|
|
183
|
+
} else {
|
|
184
|
+
lines.push(' No sleep data');
|
|
185
|
+
}
|
|
186
|
+
lines.push('');
|
|
187
|
+
|
|
188
|
+
// activity
|
|
189
|
+
lines.push(ruler('Activity (recent)'));
|
|
190
|
+
const actParts: string[] = [];
|
|
191
|
+
if (steps != null) actParts.push(`👟 ${fmtInt(steps)} steps`);
|
|
192
|
+
if (activeCal != null) actParts.push(`🔥 ${fmtInt(activeCal)} kcal active`);
|
|
193
|
+
if (actParts.length > 0) {
|
|
194
|
+
lines.push(actParts.join(' | '));
|
|
195
|
+
if (standHours != null) lines.push(` Stand hours: ${Math.round(standHours)}`);
|
|
196
|
+
} else {
|
|
197
|
+
lines.push(' No activity data');
|
|
198
|
+
}
|
|
199
|
+
lines.push('');
|
|
200
|
+
|
|
201
|
+
// heart health
|
|
202
|
+
lines.push(ruler('Heart Health'));
|
|
203
|
+
const hh: string[] = [];
|
|
204
|
+
if (restingHR != null) hh.push(`💓 Resting HR: ${Math.round(restingHR)}bpm`);
|
|
205
|
+
if (hrv != null) hh.push(`HRV: ${Math.round(hrv)}ms`);
|
|
206
|
+
lines.push(hh.length > 0 ? hh.join(' | ') : ' No heart data');
|
|
207
|
+
lines.push('');
|
|
208
|
+
|
|
209
|
+
// workouts
|
|
210
|
+
lines.push(ruler('Recent Workouts'));
|
|
211
|
+
if (workouts.length > 0) {
|
|
212
|
+
for (const w of workouts) {
|
|
213
|
+
const emoji = workoutEmoji(w.name);
|
|
214
|
+
const namePadded = pad(w.name, 18);
|
|
215
|
+
const dur = sec2min(w.duration_s);
|
|
216
|
+
const cal = kj2kcal(w.calories_kj);
|
|
217
|
+
lines.push(`${emoji} ${w.date} ${namePadded} ${dur} ${cal}`);
|
|
218
|
+
}
|
|
219
|
+
} else {
|
|
220
|
+
lines.push(' No workout data');
|
|
221
|
+
}
|
|
222
|
+
lines.push('');
|
|
223
|
+
|
|
224
|
+
// trends
|
|
225
|
+
lines.push(ruler(`${trendDays}-Day Trends`));
|
|
226
|
+
const tl: string[] = [
|
|
227
|
+
trendLine(stepTrend, 'Steps', '', true),
|
|
228
|
+
trendLine(sleepTrend, 'Sleep', 'h'),
|
|
229
|
+
trendLine(hrTrend, 'Resting HR', 'bpm', true),
|
|
230
|
+
trendLine(hrvTrend, 'HRV', 'ms', true),
|
|
231
|
+
].filter(Boolean);
|
|
232
|
+
if (tl.length > 0) lines.push(...tl);
|
|
233
|
+
else lines.push(' Insufficient data for trends');
|
|
234
|
+
lines.push('');
|
|
235
|
+
|
|
236
|
+
// vault stats
|
|
237
|
+
lines.push(ruler('Vault Stats'));
|
|
238
|
+
lines.push(` Metrics: ${metricsCount.toLocaleString()} | Sleep: ${sleepCount} | Workouts: ${workoutsCount}`);
|
|
239
|
+
lines.push(` Last sync: ${lastSync?.received_at ? new Date(lastSync.received_at).toISOString().replace('T', ' ').slice(0, 16) + ' UTC' : 'never'}`);
|
|
240
|
+
|
|
241
|
+
console.log(lines.join('\n'));
|
|
242
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { openDb } from '../db/schema.js';
|
|
5
|
+
import { ingest } from '../server/ingest.js';
|
|
6
|
+
import { hasBeenImported, logImport } from '../db/importLog.js';
|
|
7
|
+
import { extractPayloadFromZip } from '../util/zip.js';
|
|
8
|
+
import { config } from '../config.js';
|
|
9
|
+
import type { HaePayload } from '../types/hae.js';
|
|
10
|
+
|
|
11
|
+
function sha256(buf: Buffer): string {
|
|
12
|
+
return createHash('sha256').update(buf).digest('hex');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function loadFile(file: string): { payload: HaePayload; hash: string } {
|
|
16
|
+
let buf: Buffer;
|
|
17
|
+
try {
|
|
18
|
+
buf = readFileSync(file);
|
|
19
|
+
} catch (err) {
|
|
20
|
+
console.error(JSON.stringify({ error: `Cannot read file: ${String(err)}` }));
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const hash = sha256(buf);
|
|
25
|
+
|
|
26
|
+
let payload: HaePayload | null;
|
|
27
|
+
if (file.toLowerCase().endsWith('.zip')) {
|
|
28
|
+
payload = extractPayloadFromZip(buf);
|
|
29
|
+
if (!payload) {
|
|
30
|
+
console.error(JSON.stringify({ error: 'No valid HealthAutoExport-*.json found in zip' }));
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
} else {
|
|
34
|
+
try {
|
|
35
|
+
payload = JSON.parse(buf.toString('utf-8')) as HaePayload;
|
|
36
|
+
} catch (err) {
|
|
37
|
+
console.error(JSON.stringify({ error: `Invalid JSON: ${String(err)}` }));
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
if (!payload?.data) {
|
|
41
|
+
console.error(JSON.stringify({ error: 'Missing data field — not a valid HAE export' }));
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { payload, hash };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const importCommand = new Command('import')
|
|
50
|
+
.description('Import a Health Auto Export JSON or ZIP file into the database')
|
|
51
|
+
.argument('<file>', 'Path to the HAE JSON or ZIP export file')
|
|
52
|
+
.option('--target <name>', 'Target name (device/person identifier)', config.target)
|
|
53
|
+
.option('--pretty', 'Pretty-print summary JSON', false)
|
|
54
|
+
.action((file: string, opts) => {
|
|
55
|
+
const db = openDb(config.dbPath);
|
|
56
|
+
const { payload, hash } = loadFile(file);
|
|
57
|
+
|
|
58
|
+
if (hasBeenImported(db, hash)) {
|
|
59
|
+
const result = { skipped: true, reason: 'already imported', file, hash };
|
|
60
|
+
console.log(opts.pretty ? JSON.stringify(result, null, 2) : JSON.stringify(result));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const ingestResult = ingest(db, payload, {
|
|
65
|
+
target: opts.target,
|
|
66
|
+
sessionId: null,
|
|
67
|
+
automationName: 'file-import',
|
|
68
|
+
automationPeriod: 'manual',
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
logImport(db, file, hash, ingestResult);
|
|
72
|
+
|
|
73
|
+
const result = {
|
|
74
|
+
ok: true,
|
|
75
|
+
file,
|
|
76
|
+
target: opts.target,
|
|
77
|
+
hash,
|
|
78
|
+
added: {
|
|
79
|
+
metrics: ingestResult.metricsAdded,
|
|
80
|
+
sleep: ingestResult.sleepAdded,
|
|
81
|
+
workouts: ingestResult.workoutsAdded,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
console.log(opts.pretty ? JSON.stringify(result, null, 2) : JSON.stringify(result));
|
|
85
|
+
});
|
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { serveCommand } from './serve.js';
|
|
3
|
+
import { metricsCommand } from './metrics.js';
|
|
4
|
+
import { sleepCommand } from './sleep.js';
|
|
5
|
+
import { workoutsCommand } from './workouts.js';
|
|
6
|
+
import { summaryCommand } from './summary.js';
|
|
7
|
+
import { queryCommand } from './query.js';
|
|
8
|
+
import { sourcesCommand, lastSyncCommand, statsCommand } from './info.js';
|
|
9
|
+
import { importCommand } from './import.js';
|
|
10
|
+
import { watchCommand } from './watch.js';
|
|
11
|
+
import { dashboardCommand } from './dashboard.js';
|
|
12
|
+
import { trendsCommand } from './trends.js';
|
|
13
|
+
|
|
14
|
+
export const program = new Command();
|
|
15
|
+
program
|
|
16
|
+
.name('hvault')
|
|
17
|
+
.description('Apple Health data vault — ingest + query')
|
|
18
|
+
.version('0.1.0');
|
|
19
|
+
|
|
20
|
+
program.addCommand(serveCommand);
|
|
21
|
+
program.addCommand(importCommand);
|
|
22
|
+
program.addCommand(watchCommand);
|
|
23
|
+
program.addCommand(metricsCommand);
|
|
24
|
+
program.addCommand(sleepCommand);
|
|
25
|
+
program.addCommand(workoutsCommand);
|
|
26
|
+
program.addCommand(summaryCommand);
|
|
27
|
+
program.addCommand(queryCommand);
|
|
28
|
+
program.addCommand(dashboardCommand);
|
|
29
|
+
program.addCommand(trendsCommand);
|
|
30
|
+
program.addCommand(sourcesCommand);
|
|
31
|
+
program.addCommand(lastSyncCommand);
|
|
32
|
+
program.addCommand(statsCommand);
|
package/src/cli/info.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { openDb } from '../db/schema.js';
|
|
3
|
+
|
|
4
|
+
export const sourcesCommand = new Command('sources')
|
|
5
|
+
.description('Show what metrics are in the DB and their date coverage')
|
|
6
|
+
.option('--pretty', 'Pretty-print JSON', false)
|
|
7
|
+
.action((opts) => {
|
|
8
|
+
const db = openDb();
|
|
9
|
+
const rows = db.prepare(`
|
|
10
|
+
SELECT metric, units, COUNT(*) as count, MIN(date) as first_date, MAX(date) as last_date
|
|
11
|
+
FROM metrics GROUP BY metric, units ORDER BY metric
|
|
12
|
+
`).all();
|
|
13
|
+
console.log(opts.pretty ? JSON.stringify(rows, null, 2) : JSON.stringify(rows));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export const lastSyncCommand = new Command('last-sync')
|
|
17
|
+
.description('Show when the last HAE push was received')
|
|
18
|
+
.option('--pretty', 'Pretty-print JSON', false)
|
|
19
|
+
.action((opts) => {
|
|
20
|
+
const db = openDb();
|
|
21
|
+
const row = db.prepare(`SELECT * FROM sync_log ORDER BY received_at DESC LIMIT 1`).get() ?? null;
|
|
22
|
+
console.log(opts.pretty ? JSON.stringify(row, null, 2) : JSON.stringify(row));
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const statsCommand = new Command('stats')
|
|
26
|
+
.description('Show row counts per table')
|
|
27
|
+
.option('--pretty', 'Pretty-print JSON', false)
|
|
28
|
+
.action((opts) => {
|
|
29
|
+
const db = openDb();
|
|
30
|
+
const metrics = (db.prepare('SELECT COUNT(*) as count FROM metrics').get() as { count: number }).count;
|
|
31
|
+
const sleep = (db.prepare('SELECT COUNT(*) as count FROM sleep').get() as { count: number }).count;
|
|
32
|
+
const workouts = (db.prepare('SELECT COUNT(*) as count FROM workouts').get() as { count: number }).count;
|
|
33
|
+
const syncs = (db.prepare('SELECT COUNT(*) as count FROM sync_log').get() as { count: number }).count;
|
|
34
|
+
const result = { metrics, sleep, workouts, syncs };
|
|
35
|
+
console.log(opts.pretty ? JSON.stringify(result, null, 2) : JSON.stringify(result));
|
|
36
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { openDb } from '../db/schema.js';
|
|
3
|
+
|
|
4
|
+
export const metricsCommand = new Command('metrics')
|
|
5
|
+
.description('Query health metrics')
|
|
6
|
+
.requiredOption('--metric <name>', 'Metric name (e.g. step_count, heart_rate)')
|
|
7
|
+
.option('--days <n>', 'Last N days', '30')
|
|
8
|
+
.option('--pretty', 'Pretty-print JSON', false)
|
|
9
|
+
.action((opts) => {
|
|
10
|
+
const db = openDb();
|
|
11
|
+
const since = new Date();
|
|
12
|
+
since.setDate(since.getDate() - parseInt(opts.days, 10));
|
|
13
|
+
const rows = db.prepare(`
|
|
14
|
+
SELECT ts, date, qty, min, avg, max, units, source, target
|
|
15
|
+
FROM metrics
|
|
16
|
+
WHERE metric = ? AND date >= ?
|
|
17
|
+
ORDER BY ts ASC
|
|
18
|
+
`).all(opts.metric, since.toISOString().slice(0, 10));
|
|
19
|
+
console.log(opts.pretty ? JSON.stringify(rows, null, 2) : JSON.stringify(rows));
|
|
20
|
+
});
|
package/src/cli/query.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { openDb } from '../db/schema.js';
|
|
3
|
+
|
|
4
|
+
export const queryCommand = new Command('query')
|
|
5
|
+
.description('Run raw SQL against the health database (returns JSON)')
|
|
6
|
+
.argument('<sql>', 'SQL query to run')
|
|
7
|
+
.option('--pretty', 'Pretty-print JSON', false)
|
|
8
|
+
.action((sql: string, opts) => {
|
|
9
|
+
const db = openDb();
|
|
10
|
+
try {
|
|
11
|
+
const rows = db.prepare(sql).all();
|
|
12
|
+
console.log(opts.pretty ? JSON.stringify(rows, null, 2) : JSON.stringify(rows));
|
|
13
|
+
} catch (err) {
|
|
14
|
+
console.error(JSON.stringify({ error: String(err) }));
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
});
|
package/src/cli/serve.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { createApp } from '../server/app.js';
|
|
3
|
+
import { openDb } from '../db/schema.js';
|
|
4
|
+
import { config } from '../config.js';
|
|
5
|
+
|
|
6
|
+
export const serveCommand = new Command('serve')
|
|
7
|
+
.description('Start HTTP server to receive Health Auto Export pushes')
|
|
8
|
+
.option('-p, --port <number>', 'Port to listen on', String(config.port))
|
|
9
|
+
.option('--token <secret>', 'Require Authorization: Bearer <secret>', config.token)
|
|
10
|
+
.action((opts) => {
|
|
11
|
+
const db = openDb(config.dbPath);
|
|
12
|
+
const app = createApp(db, { token: opts.token });
|
|
13
|
+
const port = parseInt(opts.port, 10);
|
|
14
|
+
app.listen(port, () => {
|
|
15
|
+
console.log(`hvault server listening on http://0.0.0.0:${port}/api/ingest`);
|
|
16
|
+
if (opts.token) console.log('Auth: Bearer token required');
|
|
17
|
+
});
|
|
18
|
+
});
|
package/src/cli/sleep.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { openDb } from '../db/schema.js';
|
|
3
|
+
|
|
4
|
+
export const sleepCommand = new Command('sleep')
|
|
5
|
+
.description('Query sleep data')
|
|
6
|
+
.option('--days <n>', 'Last N days', '14')
|
|
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 date, sleep_start, sleep_end, core_h, deep_h, rem_h, awake_h, asleep_h, in_bed_h, schema_ver, source
|
|
14
|
+
FROM sleep
|
|
15
|
+
WHERE date >= ?
|
|
16
|
+
ORDER BY date ASC
|
|
17
|
+
`).all(since.toISOString().slice(0, 10));
|
|
18
|
+
console.log(opts.pretty ? JSON.stringify(rows, null, 2) : JSON.stringify(rows));
|
|
19
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { openDb } from '../db/schema.js';
|
|
3
|
+
|
|
4
|
+
export const summaryCommand = new Command('summary')
|
|
5
|
+
.description('Summarise metrics (averages) over N days')
|
|
6
|
+
.option('--days <n>', 'Last N days', '90')
|
|
7
|
+
.option('--pretty', 'Pretty-print JSON', false)
|
|
8
|
+
.option('-c, --color', 'Pretty terminal output with emoji indicators', false)
|
|
9
|
+
.action((opts) => {
|
|
10
|
+
const db = openDb();
|
|
11
|
+
const since = new Date();
|
|
12
|
+
since.setDate(since.getDate() - parseInt(opts.days, 10));
|
|
13
|
+
const sinceStr = since.toISOString().slice(0, 10);
|
|
14
|
+
|
|
15
|
+
if (opts.color) {
|
|
16
|
+
const days = parseInt(opts.days, 10);
|
|
17
|
+
|
|
18
|
+
function avgMetric(metricName: string): number | null {
|
|
19
|
+
const row = db.prepare(
|
|
20
|
+
`SELECT AVG(qty) as avg FROM metrics WHERE metric = ? AND date >= ? AND qty IS NOT NULL`
|
|
21
|
+
).get(metricName, sinceStr) as { avg: number | null } | undefined;
|
|
22
|
+
return row?.avg ?? null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const steps = avgMetric('step_count');
|
|
26
|
+
const restingHR = avgMetric('resting_heart_rate');
|
|
27
|
+
const hrv = avgMetric('heart_rate_variability_sdnn');
|
|
28
|
+
const activeCal = avgMetric('active_energy_burned');
|
|
29
|
+
|
|
30
|
+
const sleepRow = db.prepare(
|
|
31
|
+
`SELECT AVG(asleep_h) as avg FROM sleep WHERE date >= ? AND asleep_h IS NOT NULL`
|
|
32
|
+
).get(sinceStr) as { avg: number | null } | undefined;
|
|
33
|
+
const sleep = sleepRow?.avg ?? null;
|
|
34
|
+
|
|
35
|
+
const lines: string[] = [`📊 ${days}-Day Summary`, ''];
|
|
36
|
+
if (steps != null) lines.push(`👟 Avg Steps: ${Math.round(steps).toLocaleString()}`);
|
|
37
|
+
if (restingHR != null) lines.push(`💓 Avg Resting HR: ${Math.round(restingHR)}bpm`);
|
|
38
|
+
if (hrv != null) lines.push(`🧠 Avg HRV: ${Math.round(hrv)}ms`);
|
|
39
|
+
if (sleep != null) lines.push(`😴 Avg Sleep: ${sleep.toFixed(1)}h`);
|
|
40
|
+
if (activeCal != null) lines.push(`🔥 Avg Active Cal: ${Math.round(activeCal).toLocaleString()} kcal`);
|
|
41
|
+
|
|
42
|
+
if (lines.length === 2) lines.push(' No summary data available');
|
|
43
|
+
console.log(lines.join('\n'));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const rows = db.prepare(`
|
|
48
|
+
SELECT metric, units,
|
|
49
|
+
AVG(qty) as avg_qty, MIN(qty) as min_qty, MAX(qty) as max_qty,
|
|
50
|
+
COUNT(*) as count,
|
|
51
|
+
MIN(date) as first_date, MAX(date) as last_date
|
|
52
|
+
FROM metrics
|
|
53
|
+
WHERE date >= ? AND qty IS NOT NULL
|
|
54
|
+
GROUP BY metric, units
|
|
55
|
+
ORDER BY metric ASC
|
|
56
|
+
`).all(sinceStr);
|
|
57
|
+
console.log(opts.pretty ? JSON.stringify(rows, null, 2) : JSON.stringify(rows));
|
|
58
|
+
});
|