← 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
A db/migrations/0011_health_readings.sqlA lib/health-metrics.jsM lib/ids.jsA routes/vitals.jsA scripts/import-apple-health.jsM server.jsM views/partials/header.ejsA views/vitals.ejs
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 & Vitals</h1>
+ <p class="subtle">
+ <%= total %> readings ·
+ blood pressure, heart rate & 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 & 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 <path>/export.xml</code>. Pulls every BP,
+ heart-rate, SpO₂ & 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://<this-mac>: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 →