[object Object]

← back to AbramsOS

health: Vitals module — BP history + Apple Watch/device readings + sync

2d8848c923e5ae00314f49e097a99c7765398ebf · 2026-07-13 00:30:52 -0700 · Steve

- 0011_health_readings.sql: health_reading (BP pairs, HR, weight, SpO2, glucose...) w/ dedup index
- lib/health-metrics.js: AHA BP categorization + Apple Health export.xml streaming parser (pairs systolic/diastolic by timestamp; scales SpO2)
- routes/vitals.js + views/vitals.ejs: /health dashboard w/ inline SVG BP chart, AHA color categories, per-person filter, manual add, delete; /api/health/readings JSON ingest for the Health Auto Export app
- scripts/import-apple-health.js: one-command import of an Apple Health export
- nav + wiring. NO fabricated readings — table ships empty w/ 3 real sync paths. Verified end-to-end on synthetic data then wiped.

Files touched

Diff

commit 2d8848c923e5ae00314f49e097a99c7765398ebf
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Jul 13 00:30:52 2026 -0700

    health: Vitals module — BP history + Apple Watch/device readings + sync
    
    - 0011_health_readings.sql: health_reading (BP pairs, HR, weight, SpO2, glucose...) w/ dedup index
    - lib/health-metrics.js: AHA BP categorization + Apple Health export.xml streaming parser (pairs systolic/diastolic by timestamp; scales SpO2)
    - routes/vitals.js + views/vitals.ejs: /health dashboard w/ inline SVG BP chart, AHA color categories, per-person filter, manual add, delete; /api/health/readings JSON ingest for the Health Auto Export app
    - scripts/import-apple-health.js: one-command import of an Apple Health export
    - nav + wiring. NO fabricated readings — table ships empty w/ 3 real sync paths. Verified end-to-end on synthetic data then wiped.
---
 db/migrations/0011_health_readings.sql |  37 ++++++++
 lib/health-metrics.js                  | 121 +++++++++++++++++++++++++
 lib/ids.js                             |   1 +
 routes/vitals.js                       | 112 +++++++++++++++++++++++
 scripts/import-apple-health.js         |  39 ++++++++
 server.js                              |   2 +
 views/partials/header.ejs              |   1 +
 views/vitals.ejs                       | 159 +++++++++++++++++++++++++++++++++
 8 files changed, 472 insertions(+)

diff --git a/db/migrations/0011_health_readings.sql b/db/migrations/0011_health_readings.sql
new file mode 100644
index 0000000..47cf6f2
--- /dev/null
+++ b/db/migrations/0011_health_readings.sql
@@ -0,0 +1,37 @@
+-- 0011_health_readings.sql
+-- Health / Vitals tracking: blood pressure history + Apple Watch / device readings.
+--   health_reading — one measurement (BP pair, heart rate, weight, SpO2, glucose, …)
+--                    for a household member, from Apple Health export, the Health Auto
+--                    Export app (JSON POST), a connected device, or manual entry.
+-- Sensitive medical data — same consent/audit posture as the medication tables.
+-- Idempotent. Safe to re-run.
+
+BEGIN;
+
+CREATE TABLE IF NOT EXISTS health_reading (
+  id            text PRIMARY KEY,
+  user_id       text NOT NULL REFERENCES user_account(id) ON DELETE CASCADE,
+  person_id     text REFERENCES person(id) ON DELETE SET NULL,   -- null = account owner
+  metric        text NOT NULL,        -- blood_pressure|heart_rate|resting_heart_rate|weight|
+                                       -- spo2|blood_glucose|steps|hrv|respiratory_rate|body_temperature|vo2max
+  systolic      numeric(6,2),         -- BP only
+  diastolic     numeric(6,2),         -- BP only
+  value         numeric(12,3),        -- single-value metrics (hr, weight, spo2, …)
+  unit          text,                 -- mmHg | bpm | lb | kg | % | mg/dL | count | ms | °F …
+  category      text,                 -- BP: normal|elevated|hypertension_1|hypertension_2|crisis
+  measured_at   timestamptz NOT NULL,
+  source        text NOT NULL DEFAULT 'manual',  -- manual|apple-health-export|health-auto-export|device
+  device_name   text,                 -- e.g. "Apple Watch", "Omron", "Withings"
+  external_id   text,                 -- stable id from the source, for dedup on re-import
+  notes         text,
+  metadata_jsonb jsonb NOT NULL DEFAULT '{}'::jsonb,
+  created_at    timestamptz NOT NULL DEFAULT now()
+);
+
+-- Dedup: the same source reading never lands twice.
+CREATE UNIQUE INDEX IF NOT EXISTS health_reading_ext_idx
+  ON health_reading (user_id, external_id) WHERE external_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS health_reading_lookup_idx
+  ON health_reading (user_id, person_id, metric, measured_at DESC);
+
+COMMIT;
diff --git a/lib/health-metrics.js b/lib/health-metrics.js
new file mode 100644
index 0000000..c3dc021
--- /dev/null
+++ b/lib/health-metrics.js
@@ -0,0 +1,121 @@
+// 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 };
diff --git a/lib/ids.js b/lib/ids.js
index 00c751b..1660a5d 100644
--- a/lib/ids.js
+++ b/lib/ids.js
@@ -18,6 +18,7 @@ const PREFIX = {
   prescription_fill: 'rxfl',
   saving: 'sav',
   coupon: 'coup',
+  reading: 'hr',
 };
 
 function id(kind) {
diff --git a/routes/vitals.js b/routes/vitals.js
new file mode 100644
index 0000000..f246bb5
--- /dev/null
+++ b/routes/vitals.js
@@ -0,0 +1,112 @@
+// Vitals — health readings dashboard: blood-pressure history + Apple Watch / device
+// metrics. Sensitive medical data; every write is audited with medical:true. Readings
+// arrive from: the Apple Health export CLI, the Health Auto Export app (JSON POST to
+// /api/health/readings), a connected device, or manual entry. Nothing is fabricated.
+const express = require('express');
+const db = require('../lib/db');
+const audit = require('../lib/audit');
+const { id } = require('../lib/ids');
+const hm = require('../lib/health-metrics');
+
+const router = express.Router();
+const DEV_USER_ID = 'user_steve';
+
+// Insert normalized readings, dedup on external_id. Returns { inserted, skipped }.
+async function insertReadings(userId, personId, rawReadings) {
+  let inserted = 0, skipped = 0;
+  for (const raw of rawReadings) {
+    const r = hm.normalizeReading(raw);
+    if (!r.metric || !r.measured_at || isNaN(r.measured_at.getTime())) { skipped++; continue; }
+    if (r.metric === 'blood_pressure' && (r.systolic == null || r.diastolic == null)) { skipped++; continue; }
+    if (r.metric !== 'blood_pressure' && r.value == null) { skipped++; continue; }
+    const res = await db.query(
+      `INSERT INTO health_reading
+         (id, user_id, person_id, metric, systolic, diastolic, value, unit, category,
+          measured_at, source, device_name, external_id, notes)
+       VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
+       ON CONFLICT (user_id, external_id) WHERE external_id IS NOT NULL DO NOTHING
+       RETURNING id`,
+      [
+        id('reading'), userId, personId || raw.person_id || null, r.metric,
+        r.systolic, r.diastolic, r.value, r.unit, r.category || null,
+        r.measured_at, r.source, r.device_name, r.external_id, r.notes,
+      ]
+    );
+    if (res.rows.length) inserted++; else skipped++;
+  }
+  return { inserted, skipped };
+}
+
+async function personList(userId) {
+  return (await db.query(
+    `SELECT id, full_name, relation FROM person WHERE user_id = $1 ORDER BY (relation<>'self'), full_name`,
+    [userId]
+  )).rows;
+}
+
+router.get('/health', async (req, res) => {
+  const personId = req.query.person || null;
+  const people = await personList(DEV_USER_ID);
+  const params = [DEV_USER_ID];
+  let where = 'user_id = $1';
+  if (personId) { params.push(personId); where += ` AND person_id = $${params.length}`; }
+
+  const [bp, latest, counts] = await Promise.all([
+    db.query(
+      `SELECT id, systolic, diastolic, category, measured_at, device_name, source
+         FROM health_reading WHERE ${where} AND metric = 'blood_pressure'
+         ORDER BY measured_at DESC LIMIT 60`, params),
+    db.query(
+      `SELECT DISTINCT ON (metric) metric, value, systolic, diastolic, unit, category, measured_at, device_name
+         FROM health_reading WHERE ${where}
+         ORDER BY metric, measured_at DESC`, params),
+    db.query(`SELECT count(*)::int AS n FROM health_reading WHERE ${where}`, params),
+  ]);
+
+  res.render('vitals', {
+    people, personId,
+    bp: bp.rows,
+    latest: latest.rows.filter((r) => r.metric !== 'blood_pressure'),
+    total: counts.rows[0].n,
+  });
+});
+
+router.get('/api/health/readings', async (req, res) => {
+  const metric = req.query.metric || 'blood_pressure';
+  const r = await db.query(
+    `SELECT id, metric, systolic, diastolic, value, unit, category, measured_at, device_name, source
+       FROM health_reading WHERE user_id = $1 AND metric = $2
+       ORDER BY measured_at DESC LIMIT 500`,
+    [DEV_USER_ID, metric]
+  );
+  res.json(r.rows);
+});
+
+// Add one manual reading OR bulk-ingest (Health Auto Export app posts { readings: [...] }).
+router.post('/api/health/readings', async (req, res) => {
+  try {
+    const body = req.body || {};
+    const list = Array.isArray(body.readings) ? body.readings : [body];
+    const personId = body.person_id || null;
+    const out = await insertReadings(DEV_USER_ID, personId, list);
+    await audit.log({
+      actorType: body.readings ? 'system' : 'user', actorId: DEV_USER_ID,
+      objectType: 'health_reading', objectId: null, eventType: 'health_reading_added',
+      metadata: { medical: true, consent: 'user-entered', ...out, source: (list[0] && list[0].source) || 'manual' },
+    }).catch(() => {});
+    res.json({ ok: true, ...out });
+  } catch (err) {
+    res.status(500).json({ ok: false, error: err.message });
+  }
+});
+
+router.post('/api/health/readings/:id/delete', async (req, res) => {
+  await db.query(`DELETE FROM health_reading WHERE id = $1 AND user_id = $2`, [req.params.id, DEV_USER_ID]);
+  await audit.log({
+    actorType: 'user', actorId: DEV_USER_ID, objectType: 'health_reading',
+    objectId: req.params.id, eventType: 'health_reading_deleted', metadata: { medical: true },
+  }).catch(() => {});
+  res.json({ ok: true });
+});
+
+module.exports = { router, insertReadings };
diff --git a/scripts/import-apple-health.js b/scripts/import-apple-health.js
new file mode 100644
index 0000000..4320463
--- /dev/null
+++ b/scripts/import-apple-health.js
@@ -0,0 +1,39 @@
+#!/usr/bin/env node
+// Import an Apple Health export into AbramsOS health_reading.
+//
+// Get the file:  iPhone → Health app → profile photo → "Export All Health Data"
+//   → AirDrop the .zip to this Mac → unzip → apple_health_export/export.xml
+//
+// Usage:
+//   node scripts/import-apple-health.js <path/to/export.xml> [--person <person_id>]
+//
+// Idempotent: every reading carries a stable external_id, so re-importing a newer
+// export only adds what's new (dedup via the unique index).
+
+require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
+const db = require('../lib/db');
+const hm = require('../lib/health-metrics');
+const { insertReadings } = require('../routes/vitals');
+
+const USER_ID = process.env.SAVINGS_USER_ID || 'user_steve';
+
+(async () => {
+  const file = process.argv[2];
+  const pFlag = process.argv.indexOf('--person');
+  let personId = pFlag > -1 ? process.argv[pFlag + 1] : null;
+  if (!file) { console.error('usage: import-apple-health.js <export.xml> [--person <id>]'); process.exit(2); }
+
+  if (!personId) {
+    const self = await db.query(`SELECT id FROM person WHERE user_id=$1 AND relation='self' LIMIT 1`, [USER_ID]);
+    personId = self.rows[0] && self.rows[0].id;
+  }
+
+  console.log(`Parsing ${file} …`);
+  const { readings, counts } = await hm.parseAppleHealthExport(file);
+  console.log('Found:', JSON.stringify(counts));
+  if (!readings.length) { console.log('No supported readings found.'); process.exit(0); }
+
+  const out = await insertReadings(USER_ID, personId, readings);
+  console.log(`Imported: inserted=${out.inserted} skipped(dupe/invalid)=${out.skipped} of ${readings.length}`);
+  process.exit(0);
+})().catch((e) => { console.error('FAILED:', e.message); process.exit(1); });
diff --git a/server.js b/server.js
index 2075403..ae88465 100644
--- a/server.js
+++ b/server.js
@@ -25,6 +25,7 @@ const uploadRouter = require('./routes/upload');
 const billsRouter = require('./routes/bills');
 const reordersRouter = require('./routes/reorders');
 const savingsRouter = require('./routes/savings');
+const vitalsRouter = require('./routes/vitals').router;
 const warrantiesRouter = require('./routes/warranties');
 const peopleRouter = require('./routes/people');
 const medicationsRouter = require('./routes/medications');
@@ -89,6 +90,7 @@ app.use(uploadRouter);              // /api/upload/csv
 app.use(billsRouter);               // /bills, /api/bills*
 app.use(reordersRouter);            // /reorders, /api/reorders*
 app.use(savingsRouter);             // /savings, /api/savings*, /api/coupons
+app.use(vitalsRouter);              // /health, /api/health/readings*
 app.use(warrantiesRouter);          // /warranties, /api/warranties*
 app.use(peopleRouter);              // /household, /api/people*
 app.use(medicationsRouter);         // /medications, /api/medications*
diff --git a/views/partials/header.ejs b/views/partials/header.ejs
index fd673f8..c3fe0d5 100644
--- a/views/partials/header.ejs
+++ b/views/partials/header.ejs
@@ -27,6 +27,7 @@
       <a href="/warranties">Warranties</a>
       <a href="/reorders">Reorders</a>
       <a href="/savings">Savings</a>
+      <a href="/health">Health</a>
       <a href="/medications">Meds</a>
       <a href="/prescriptions">Rx</a>
       <a href="/household">Household</a>
diff --git a/views/vitals.ejs b/views/vitals.ejs
new file mode 100644
index 0000000..5562e2f
--- /dev/null
+++ b/views/vitals.ejs
@@ -0,0 +1,159 @@
+<%- include('partials/header', { title: 'Health' }) %>
+
+<%
+  const catColor = { normal:'#3fb950', elevated:'#d4b106', hypertension_1:'#e08c1a', hypertension_2:'#e5534b', crisis:'#a11' };
+  const catLabel = { normal:'Normal', elevated:'Elevated', hypertension_1:'Stage 1', hypertension_2:'Stage 2', crisis:'Crisis' };
+  const latestBp = bp && bp.length ? bp[0] : null;
+  const fmt = (d) => new Date(d).toLocaleString(undefined, { year:'numeric', month:'short', day:'numeric', hour:'numeric', minute:'2-digit' });
+%>
+
+<section class="page-head">
+  <div>
+    <h1>Health &amp; Vitals</h1>
+    <p class="subtle">
+      <%= total %> readings ·
+      blood pressure, heart rate &amp; device metrics · <span class="subtle">private to you</span>
+    </p>
+  </div>
+  <div class="grid-controls">
+    <label>Person
+      <select id="personFilter" onchange="location.href='/health'+(this.value?('?person='+this.value):'')">
+        <option value="">Everyone</option>
+        <% people.forEach(p => { %>
+          <option value="<%= p.id %>" <%= personId===p.id?'selected':'' %>><%= p.full_name %></option>
+        <% }) %>
+      </select>
+    </label>
+    <button type="button" class="btn primary" id="toggleAdd">+ Add reading</button>
+  </div>
+</section>
+
+<form id="addForm" class="add-form glass" hidden>
+  <div class="row">
+    <label>Person
+      <select name="person_id">
+        <% people.forEach(p => { %><option value="<%= p.id %>" <%= personId===p.id?'selected':'' %>><%= p.full_name %></option><% }) %>
+      </select>
+    </label>
+    <label>Metric
+      <select name="metric" id="metricSel">
+        <option value="blood_pressure">Blood pressure</option>
+        <option value="heart_rate">Heart rate</option>
+        <option value="resting_heart_rate">Resting HR</option>
+        <option value="weight">Weight</option>
+        <option value="spo2">SpO₂</option>
+        <option value="blood_glucose">Blood glucose</option>
+        <option value="body_temperature">Temperature</option>
+      </select>
+    </label>
+    <label class="bp-only">Systolic<input name="systolic" type="number" step="1" placeholder="120"></label>
+    <label class="bp-only">Diastolic<input name="diastolic" type="number" step="1" placeholder="80"></label>
+    <label class="val-only" hidden>Value<input name="value" type="number" step="0.1" placeholder="—"></label>
+  </div>
+  <div class="row">
+    <label>When<input name="measured_at" type="datetime-local"></label>
+    <label>Device<input name="device_name" placeholder="Apple Watch / Omron"></label>
+    <label class="grow">Notes<input name="notes" placeholder="optional"></label>
+    <button type="submit" class="btn primary">Save reading</button>
+  </div>
+</form>
+
+<% if (!total) { %>
+  <section class="empty glass">
+    <h2>No readings yet — three ways to get your Apple Watch &amp; device data in</h2>
+    <ol style="line-height:1.7">
+      <li><strong>Apple Health export</strong> (one-time / periodic): iPhone → Health app → your
+        photo → <em>Export All Health Data</em> → AirDrop the zip to this Mac → unzip →
+        <code>node scripts/import-apple-health.js &lt;path&gt;/export.xml</code>. Pulls every BP,
+        heart-rate, SpO₂ &amp; weight reading the Watch recorded.</li>
+      <li><strong>Ongoing auto-sync</strong>: install the iOS app <em>Health Auto Export</em>,
+        add a REST API automation pointing at
+        <code>POST <%= (typeof baseUrl!=='undefined'?baseUrl:'http://&lt;this-mac&gt;:9774') %>/api/health/readings</code>,
+        and it pushes new readings on a schedule.</li>
+      <li><strong>Manual</strong>: click <strong>+ Add reading</strong> above — good for a cuff at home.</li>
+    </ol>
+  </section>
+<% } %>
+
+<% if (latestBp) { %>
+  <section class="glass" style="padding:1.25rem 1.5rem;margin-bottom:1rem">
+    <div style="display:flex;align-items:baseline;gap:1rem;flex-wrap:wrap">
+      <h2 style="margin:0">Blood pressure</h2>
+      <span style="font-size:2rem;font-weight:700"><%= Math.round(latestBp.systolic) %>/<%= Math.round(latestBp.diastolic) %></span>
+      <span class="badge" style="background:<%= catColor[latestBp.category]||'#888' %>;color:#fff"><%= catLabel[latestBp.category]||'—' %></span>
+      <span class="subtle">latest · <%= fmt(latestBp.measured_at) %><%= latestBp.device_name?(' · '+latestBp.device_name):'' %></span>
+    </div>
+    <svg id="bpChart" viewBox="0 0 720 200" preserveAspectRatio="none" style="width:100%;height:200px;margin-top:1rem"></svg>
+    <div class="subtle" style="font-size:.8rem">systolic ▲ · diastolic ▼ · guide lines at 120 / 80 mmHg</div>
+  </section>
+
+  <section class="glass" style="padding:1rem 1.25rem;margin-bottom:1rem">
+    <table class="reading-table" style="width:100%;border-collapse:collapse">
+      <thead><tr style="text-align:left"><th>Reading</th><th>Category</th><th>When</th><th>Source</th><th></th></tr></thead>
+      <tbody>
+        <% bp.slice(0,20).forEach(r => { %>
+          <tr data-id="<%= r.id %>" style="border-top:1px solid rgba(128,128,128,.2)">
+            <td><strong><%= Math.round(r.systolic) %>/<%= Math.round(r.diastolic) %></strong> <span class="subtle">mmHg</span></td>
+            <td><span class="badge" style="background:<%= catColor[r.category]||'#888' %>;color:#fff"><%= catLabel[r.category]||'—' %></span></td>
+            <td class="subtle" title="<%= new Date(r.measured_at).toISOString() %>">🕓 <%= fmt(r.measured_at) %></td>
+            <td class="subtle"><%= r.device_name || r.source %></td>
+            <td><button class="btn tiny ghost" data-del="<%= r.id %>">✕</button></td>
+          </tr>
+        <% }) %>
+      </tbody>
+    </table>
+  </section>
+<% } %>
+
+<% if (latest && latest.length) { %>
+  <section class="page-head"><div><h2>Latest vitals</h2></div></section>
+  <section class="stat-row">
+    <% latest.forEach(v => { %>
+      <div class="stat glass">
+        <div class="num"><%= v.value != null ? (Math.round(v.value*10)/10) : '—' %> <span class="subtle" style="font-size:.4em"><%= v.unit||'' %></span></div>
+        <div class="label"><%= v.metric.replace(/_/g,' ') %></div>
+        <div class="subtle" style="font-size:.7rem"><%= fmt(v.measured_at) %></div>
+      </div>
+    <% }) %>
+  </section>
+<% } %>
+
+<script>
+  window.__csrf = '<%= csrfToken %>';
+  const BP = <%- JSON.stringify((bp||[]).slice().reverse().map(r => ({ s:Number(r.systolic), d:Number(r.diastolic), t:r.measured_at }))) %>;
+  async function post(url, body){ const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(Object.assign({_csrf:window.__csrf},body||{}))}); return r.json(); }
+
+  // toggle add form + metric-dependent fields
+  document.getElementById('toggleAdd')?.addEventListener('click', () => { const f=document.getElementById('addForm'); f.hidden=!f.hidden; });
+  const metricSel=document.getElementById('metricSel');
+  function syncFields(){ const bp = metricSel.value==='blood_pressure'; document.querySelectorAll('.bp-only').forEach(e=>e.hidden=!bp); document.querySelectorAll('.val-only').forEach(e=>e.hidden=bp); }
+  metricSel?.addEventListener('change', syncFields); if(metricSel) syncFields();
+
+  document.getElementById('addForm')?.addEventListener('submit', async (e) => {
+    e.preventDefault(); const fd=Object.fromEntries(new FormData(e.target).entries());
+    if (!fd.measured_at) fd.measured_at = new Date().toISOString(); else fd.measured_at = new Date(fd.measured_at).toISOString();
+    fd.source='manual';
+    const out=await post('/api/health/readings', fd);
+    if (out.ok) location.reload();
+  });
+  document.querySelectorAll('[data-del]').forEach(b => b.addEventListener('click', async (e) => {
+    if(!confirm('Delete this reading?')) return;
+    await post('/api/health/readings/'+e.currentTarget.dataset.del+'/delete'); e.currentTarget.closest('tr').remove();
+  }));
+
+  // Inline SVG BP chart — systolic + diastolic over time, with 120/80 guides.
+  (function drawBp(){
+    const svg=document.getElementById('bpChart'); if(!svg||!BP.length) return;
+    const W=720,H=200,pad=24; const vals=BP.flatMap(p=>[p.s,p.d]).filter(Number.isFinite);
+    const min=Math.min(70,...vals)-5, max=Math.max(160,...vals)+5;
+    const x=i=>pad+(W-2*pad)*(BP.length<2?0.5:i/(BP.length-1));
+    const y=v=>H-pad-(H-2*pad)*((v-min)/(max-min));
+    const ns='http://www.w3.org/2000/svg';
+    function guide(v,c){ const ln=document.createElementNS(ns,'line'); ln.setAttribute('x1',pad); ln.setAttribute('x2',W-pad); ln.setAttribute('y1',y(v)); ln.setAttribute('y2',y(v)); ln.setAttribute('stroke',c); ln.setAttribute('stroke-dasharray','4 4'); ln.setAttribute('opacity','.4'); svg.appendChild(ln); }
+    guide(120,'#e08c1a'); guide(80,'#3fb950');
+    function line(key,color){ const d=BP.map((p,i)=>(i?'L':'M')+x(i).toFixed(1)+' '+y(p[key]).toFixed(1)).join(' '); const path=document.createElementNS(ns,'path'); path.setAttribute('d',d); path.setAttribute('fill','none'); path.setAttribute('stroke',color); path.setAttribute('stroke-width','2'); svg.appendChild(path); BP.forEach((p,i)=>{ const c=document.createElementNS(ns,'circle'); c.setAttribute('cx',x(i)); c.setAttribute('cy',y(p[key])); c.setAttribute('r','2.5'); c.setAttribute('fill',color); svg.appendChild(c); }); }
+    line('s','#e5534b'); line('d','#539bf5');
+  })();
+</script>
+
+<%- include('partials/footer', {}) %>

← 0256450 savings: nightly advisor runner + launchd plist (draft, Stev  ·  back to AbramsOS  ·  docs: roadmap adds health track (vitals trends, BP↔meds, rem f4e4da6 →