← 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 };