← back to AbramsOS
lib/health-metrics.js
122 lines
// health-metrics.js — shared helpers for the Vitals module.
//
// • bpCategory() — American Heart Association blood-pressure classification.
// • normalizeReading() — coerce an inbound reading (manual form OR the Health Auto
// Export app's JSON) into our health_reading shape.
// • HK_MAP / parseAppleHealthExport() — stream-parse an Apple Health `export.xml`
// (Watch → iPhone Health → Export All Health Data → export.xml) and yield readings.
// Streams line-by-line so a multi-hundred-MB export never blows memory.
const fs = require('fs');
const readline = require('readline');
// AHA categories (systolic/diastolic in mmHg). Order matters — most severe first.
function bpCategory(sys, dia) {
const s = Number(sys), d = Number(dia);
if (!Number.isFinite(s) || !Number.isFinite(d)) return null;
if (s > 180 || d > 120) return 'crisis';
if (s >= 140 || d >= 90) return 'hypertension_2';
if (s >= 130 || d >= 80) return 'hypertension_1';
if (s >= 120 && d < 80) return 'elevated';
return 'normal';
}
// Apple HealthKit type identifier -> our metric + unit handling.
const HK_MAP = {
HKQuantityTypeIdentifierBloodPressureSystolic: { metric: 'blood_pressure', part: 'systolic', unit: 'mmHg' },
HKQuantityTypeIdentifierBloodPressureDiastolic: { metric: 'blood_pressure', part: 'diastolic', unit: 'mmHg' },
HKQuantityTypeIdentifierHeartRate: { metric: 'heart_rate', unit: 'bpm' },
HKQuantityTypeIdentifierRestingHeartRate: { metric: 'resting_heart_rate', unit: 'bpm' },
HKQuantityTypeIdentifierBodyMass: { metric: 'weight', unit: 'lb' },
HKQuantityTypeIdentifierOxygenSaturation: { metric: 'spo2', unit: '%', scale: 100 }, // stored 0..1
HKQuantityTypeIdentifierBloodGlucose: { metric: 'blood_glucose', unit: 'mg/dL' },
HKQuantityTypeIdentifierStepCount: { metric: 'steps', unit: 'count' },
HKQuantityTypeIdentifierHeartRateVariabilitySDNN: { metric: 'hrv', unit: 'ms' },
HKQuantityTypeIdentifierRespiratoryRate: { metric: 'respiratory_rate', unit: 'count/min' },
HKQuantityTypeIdentifierBodyTemperature: { metric: 'body_temperature', unit: '°F' },
HKQuantityTypeIdentifierVO2Max: { metric: 'vo2max', unit: 'mL/kg·min' },
};
const attr = (line, name) => {
const m = line.match(new RegExp(`${name}="([^"]*)"`));
return m ? m[1] : null;
};
// Normalize a single inbound reading object (manual form / auto-export JSON).
function normalizeReading(r) {
const out = {
metric: String(r.metric || '').trim(),
systolic: num(r.systolic),
diastolic: num(r.diastolic),
value: num(r.value),
unit: r.unit || null,
measured_at: r.measured_at ? new Date(r.measured_at) : new Date(),
source: r.source || 'manual',
device_name: r.device_name || null,
external_id: r.external_id || null,
notes: r.notes || null,
};
if (out.metric === 'blood_pressure') out.category = bpCategory(out.systolic, out.diastolic);
return out;
}
// Stream-parse export.xml. Returns { readings, counts }. BP systolic/diastolic Records
// are emitted separately by Apple and paired here by exact timestamp.
async function parseAppleHealthExport(filePath, opts = {}) {
const wanted = opts.metrics ? new Set(opts.metrics) : null; // filter by our metric names
const sys = new Map(); // startDate -> {value, device}
const dia = new Map();
const readings = [];
const counts = {};
const rl = readline.createInterface({ input: fs.createReadStream(filePath), crlfDelay: Infinity });
for await (const line of rl) {
if (line.indexOf('<Record ') === -1) continue;
const type = attr(line, 'type');
const map = type && HK_MAP[type];
if (!map) continue;
if (wanted && !wanted.has(map.metric)) continue;
const rawVal = Number(attr(line, 'value'));
if (!Number.isFinite(rawVal)) continue;
const when = attr(line, 'startDate') || attr(line, 'creationDate');
if (!when) continue;
const device = attr(line, 'sourceName');
const val = map.scale ? rawVal * map.scale : rawVal;
if (map.metric === 'blood_pressure') {
(map.part === 'systolic' ? sys : dia).set(when, { value: val, device });
continue;
}
counts[map.metric] = (counts[map.metric] || 0) + 1;
readings.push({
metric: map.metric, value: val, unit: map.unit,
measured_at: new Date(when), source: 'apple-health-export', device_name: device,
external_id: `ahe:${type}:${when}`,
});
}
// Pair BP by matching timestamp.
for (const [when, s] of sys) {
const d = dia.get(when);
if (!d) continue;
counts.blood_pressure = (counts.blood_pressure || 0) + 1;
readings.push({
metric: 'blood_pressure', systolic: s.value, diastolic: d.value, unit: 'mmHg',
category: bpCategory(s.value, d.value),
measured_at: new Date(when), source: 'apple-health-export', device_name: s.device || d.device,
external_id: `ahe:bp:${when}`,
});
}
return { readings, counts };
}
function num(v) {
if (v == null || v === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
module.exports = { bpCategory, normalizeReading, parseAppleHealthExport, HK_MAP };