← back to AbramsOS
health: vitals trends + insights (weight/RHR sparklines, local-LLM 'what changed this month', gentle Stage-2 recheck flag)
8f58fa2dc6d0e1d352ccc1db9c99e5d5ee72a208 · 2026-07-13 01:04:48 -0700 · Steve
- lib/vitals-insights.js: computeTrends (this-30d vs prior-30d avgs), recheckFlag (deterministic rule, gentle), summarize (gemma3:12b narrates ONLY computed deltas — never invents readings; no medical advice)
- routes/vitals.js: fast trends+recheck on /health load; /api/health/insights for async LLM narrative
- views/vitals.ejs: 'This month' panel (trend chips + delta arrows + recheck banner + async narrative) + weight/RHR sparklines
- verified end-to-end on synthetic data then wiped; empty-state safe (no fabricated readings)
Files touched
A lib/vitals-insights.jsM routes/vitals.jsM views/vitals.ejs
Diff
commit 8f58fa2dc6d0e1d352ccc1db9c99e5d5ee72a208
Author: Steve <steve@designerwallcoverings.com>
Date: Mon Jul 13 01:04:48 2026 -0700
health: vitals trends + insights (weight/RHR sparklines, local-LLM 'what changed this month', gentle Stage-2 recheck flag)
- lib/vitals-insights.js: computeTrends (this-30d vs prior-30d avgs), recheckFlag (deterministic rule, gentle), summarize (gemma3:12b narrates ONLY computed deltas — never invents readings; no medical advice)
- routes/vitals.js: fast trends+recheck on /health load; /api/health/insights for async LLM narrative
- views/vitals.ejs: 'This month' panel (trend chips + delta arrows + recheck banner + async narrative) + weight/RHR sparklines
- verified end-to-end on synthetic data then wiped; empty-state safe (no fabricated readings)
---
lib/vitals-insights.js | 112 +++++++++++++++++++++++++++++++++++++++++++++++++
routes/vitals.js | 24 ++++++++++-
views/vitals.ejs | 62 +++++++++++++++++++++++++++
3 files changed, 197 insertions(+), 1 deletion(-)
diff --git a/lib/vitals-insights.js b/lib/vitals-insights.js
new file mode 100644
index 0000000..4c2ef6e
--- /dev/null
+++ b/lib/vitals-insights.js
@@ -0,0 +1,112 @@
+// vitals-insights.js — trends + a plain-English "what changed" summary for Health.
+//
+// HONESTY / SAFETY rails:
+// • The LLM is fed ONLY computed numeric deltas (this-month vs last-month averages it
+// did not invent) and writes a short narrative of THOSE facts — it never makes up a
+// reading or a number. If there's no data, there's no summary.
+// • Output is explicitly informational, NOT medical advice/diagnosis. The Stage-2/crisis
+// recheck flag is a deterministic RULE (not the LLM), phrased gently.
+// • Local Ollama only ($0).
+
+const db = require('./db');
+const ollama = require('./ollama');
+
+const METRICS = [
+ { key: 'blood_pressure', label: 'blood pressure', unit: 'mmHg', bp: true },
+ { key: 'resting_heart_rate', label: 'resting heart rate', unit: 'bpm' },
+ { key: 'heart_rate', label: 'heart rate', unit: 'bpm' },
+ { key: 'weight', label: 'weight', unit: 'lb' },
+ { key: 'spo2', label: 'SpO₂', unit: '%' },
+ { key: 'blood_glucose', label: 'blood glucose', unit: 'mg/dL' },
+];
+
+function avg(nums) {
+ const v = nums.filter((n) => Number.isFinite(n));
+ return v.length ? v.reduce((a, b) => a + b, 0) / v.length : null;
+}
+
+// Compute this-30-days vs prior-30-days averages per metric for one person (or all).
+async function computeTrends(userId, personId) {
+ const params = [userId];
+ let where = 'user_id = $1';
+ if (personId) { params.push(personId); where += ` AND person_id = $${params.length}`; }
+
+ const rows = (await db.query(
+ `SELECT metric, systolic, diastolic, value, category, measured_at
+ FROM health_reading WHERE ${where} AND measured_at > now() - interval '60 days'`,
+ params
+ )).rows;
+
+ const now = Date.now();
+ const DAY = 86400000;
+ const trends = [];
+ for (const m of METRICS) {
+ const mine = rows.filter((r) => r.metric === m.key);
+ if (!mine.length) continue;
+ const recent = mine.filter((r) => now - new Date(r.measured_at).getTime() <= 30 * DAY);
+ const prior = mine.filter((r) => now - new Date(r.measured_at).getTime() > 30 * DAY);
+ const pick = (r) => (m.bp ? Number(r.systolic) : Number(r.value));
+ const rNow = avg(recent.map(pick));
+ const rPrev = avg(prior.map(pick));
+ if (rNow == null) continue;
+ const t = {
+ metric: m.key, label: m.label, unit: m.unit, bp: !!m.bp,
+ recentAvg: round1(rNow), priorAvg: rPrev == null ? null : round1(rPrev),
+ delta: rPrev == null ? null : round1(rNow - rPrev), n: recent.length,
+ };
+ if (m.bp) {
+ t.recentDiaAvg = round1(avg(recent.map((r) => Number(r.diastolic))));
+ t.highReadings = recent.filter((r) => r.category === 'hypertension_2' || r.category === 'crisis').length;
+ }
+ trends.push(t);
+ }
+ return trends;
+}
+
+// Deterministic gentle recheck flag (RULE, not LLM). Returns null or a message.
+function recheckFlag(trends) {
+ const bp = trends.find((t) => t.bp);
+ if (bp && bp.highReadings > 0) {
+ return `${bp.highReadings} recent blood-pressure reading${bp.highReadings > 1 ? 's were' : ' was'} in the elevated range. Consider taking it again when relaxed, and mentioning the trend to your doctor. (Informational only — not medical advice.)`;
+ }
+ return null;
+}
+
+// LLM narrates ONLY the computed deltas. Returns a short string, or null if no data / no model.
+async function summarize(trends, opts = {}) {
+ if (!trends.length) return null;
+ const facts = trends.map((t) =>
+ t.bp
+ ? `blood pressure averaged ${t.recentAvg}/${t.recentDiaAvg} over ${t.n} recent readings` +
+ (t.delta != null ? ` (systolic ${t.delta >= 0 ? 'up' : 'down'} ${Math.abs(t.delta)} vs the prior month)` : '')
+ : `${t.label} averaged ${t.recentAvg} ${t.unit} over ${t.n} recent readings` +
+ (t.delta != null ? ` (${t.delta >= 0 ? 'up' : 'down'} ${Math.abs(t.delta)} vs the prior month)` : '')
+ ).join('; ');
+
+ const system = `You summarize personal health trends in 2-3 short, calm, plain-English sentences.
+Use ONLY the numbers provided — never invent readings. Do NOT diagnose or give medical advice or
+treatment recommendations. It's fine to gently suggest discussing a notable change with a doctor.
+No alarm. Plain text only.`;
+ try {
+ const out = await ollama.generate(`Recent health data: ${facts}.\n\nWrite the summary.`, {
+ system, timeoutMs: opts.timeoutMs || 45_000, model: opts.model,
+ });
+ return (out || '').replace(/^["']|["']$/g, '').trim() || null;
+ } catch (_) {
+ return null;
+ }
+}
+
+async function insights(userId, personId, opts = {}) {
+ const trends = await computeTrends(userId, personId);
+ return {
+ trends,
+ recheck: recheckFlag(trends),
+ summary: trends.length ? await summarize(trends, opts) : null,
+ generatedAt: new Date().toISOString(),
+ };
+}
+
+function round1(n) { return n == null ? null : Math.round(n * 10) / 10; }
+
+module.exports = { insights, computeTrends, recheckFlag, summarize };
diff --git a/routes/vitals.js b/routes/vitals.js
index f246bb5..f1fcc56 100644
--- a/routes/vitals.js
+++ b/routes/vitals.js
@@ -7,6 +7,7 @@ const db = require('../lib/db');
const audit = require('../lib/audit');
const { id } = require('../lib/ids');
const hm = require('../lib/health-metrics');
+const vitalsInsights = require('../lib/vitals-insights');
const router = express.Router();
const DEV_USER_ID = 'user_steve';
@@ -51,7 +52,11 @@ router.get('/health', async (req, res) => {
let where = 'user_id = $1';
if (personId) { params.push(personId); where += ` AND person_id = $${params.length}`; }
- const [bp, latest, counts] = await Promise.all([
+ const series = (metric) => db.query(
+ `SELECT value, measured_at FROM health_reading WHERE ${where} AND metric = '${metric}'
+ ORDER BY measured_at DESC LIMIT 40`, params);
+
+ const [bp, latest, counts, weight, rhr, trends] = await Promise.all([
db.query(
`SELECT id, systolic, diastolic, category, measured_at, device_name, source
FROM health_reading WHERE ${where} AND metric = 'blood_pressure'
@@ -61,6 +66,9 @@ router.get('/health', async (req, res) => {
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),
+ series('weight'),
+ series('resting_heart_rate'),
+ vitalsInsights.computeTrends(DEV_USER_ID, personId), // cheap, DB-only (no LLM)
]);
res.render('vitals', {
@@ -68,9 +76,23 @@ router.get('/health', async (req, res) => {
bp: bp.rows,
latest: latest.rows.filter((r) => r.metric !== 'blood_pressure'),
total: counts.rows[0].n,
+ weightSeries: weight.rows,
+ rhrSeries: rhr.rows,
+ trends,
+ recheck: vitalsInsights.recheckFlag(trends),
});
});
+// LLM narrative ("what changed this month") — fetched async so the page never blocks on it.
+router.get('/api/health/insights', async (req, res) => {
+ try {
+ const out = await vitalsInsights.insights(DEV_USER_ID, req.query.person || null, { model: 'gemma3:12b' });
+ res.json(out);
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
router.get('/api/health/readings', async (req, res) => {
const metric = req.query.metric || 'blood_pressure';
const r = await db.query(
diff --git a/views/vitals.ejs b/views/vitals.ejs
index 5562e2f..8800952 100644
--- a/views/vitals.ejs
+++ b/views/vitals.ejs
@@ -75,6 +75,30 @@
</section>
<% } %>
+<% if (total && typeof trends !== 'undefined' && trends.length) { %>
+ <section class="glass" id="insights" style="padding:1.1rem 1.35rem;margin-bottom:1rem">
+ <h2 style="margin:.1rem 0 .5rem">This month</h2>
+ <% if (typeof recheck !== 'undefined' && recheck) { %>
+ <div style="background:#e08c1a;color:#fff;padding:.55rem .8rem;border-radius:8px;margin:.4rem 0;font-size:.9rem">⚠︎ <%= recheck %></div>
+ <% } %>
+ <div style="display:flex;gap:.5rem;flex-wrap:wrap;margin:.5rem 0">
+ <% trends.forEach(t => {
+ const val = t.bp ? (t.recentAvg + '/' + t.recentDiaAvg) : (t.recentAvg + ' ' + t.unit);
+ const up = t.delta != null && t.delta > 0, dn = t.delta != null && t.delta < 0;
+ %>
+ <span class="chip" style="background:rgba(128,128,128,.15)">
+ <strong><%= t.label %></strong> <%= val %>
+ <% if (t.delta != null && t.delta !== 0) { %>
+ <span style="color:<%= up ? '#e5534b' : '#3fb950' %>"><%= up ? '▲' : '▼' %><%= Math.abs(t.delta) %></span>
+ <% } %>
+ </span>
+ <% }) %>
+ </div>
+ <p id="whatChanged" class="subtle" style="margin:.4rem 0 0">Analyzing your recent readings…</p>
+ <p class="subtle" style="font-size:.72rem;margin:.3rem 0 0">Informational only — not medical advice.</p>
+ </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">
@@ -118,9 +142,47 @@
</section>
<% } %>
+<% const wSeries = (typeof weightSeries !== 'undefined' ? weightSeries : []); const rSeries = (typeof rhrSeries !== 'undefined' ? rhrSeries : []); %>
+<% if (wSeries.length > 1 || rSeries.length > 1) { %>
+ <section class="stat-row">
+ <% if (wSeries.length > 1) { %>
+ <div class="stat glass"><div class="label">Weight trend (lb)</div><svg id="sparkWeight" viewBox="0 0 260 60" preserveAspectRatio="none" style="width:100%;height:60px"></svg></div>
+ <% } %>
+ <% if (rSeries.length > 1) { %>
+ <div class="stat glass"><div class="label">Resting HR trend (bpm)</div><svg id="sparkRhr" viewBox="0 0 260 60" preserveAspectRatio="none" style="width:100%;height:60px"></svg></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 }))) %>;
+ const WEIGHT = <%- JSON.stringify(wSeries.slice().reverse().map(r => Number(r.value))) %>;
+ const RHR = <%- JSON.stringify(rSeries.slice().reverse().map(r => Number(r.value))) %>;
+ const PERSON = <%- JSON.stringify(typeof personId !== 'undefined' ? personId : null) %>;
+
+ // Async "what changed this month" narrative (local LLM; page never blocks on it).
+ (async function narrative(){
+ const el = document.getElementById('whatChanged'); if(!el) return;
+ try {
+ const q = PERSON ? ('?person='+encodeURIComponent(PERSON)) : '';
+ const j = await (await fetch('/api/health/insights'+q)).json();
+ el.textContent = j && j.summary ? j.summary : 'Not enough data yet for a monthly summary.';
+ } catch(_) { el.textContent = ''; }
+ })();
+
+ function sparkline(id, data, color){
+ const svg=document.getElementById(id); if(!svg||data.length<2) return;
+ const W=260,H=60,pad=6, vals=data.filter(Number.isFinite);
+ const min=Math.min(...vals), max=Math.max(...vals), span=(max-min)||1;
+ const x=i=>pad+(W-2*pad)*(i/(data.length-1)), y=v=>H-pad-(H-2*pad)*((v-min)/span);
+ const ns='http://www.w3.org/2000/svg';
+ const d=data.map((v,i)=>(i?'L':'M')+x(i).toFixed(1)+' '+y(v).toFixed(1)).join(' ');
+ const p=document.createElementNS(ns,'path'); p.setAttribute('d',d); p.setAttribute('fill','none'); p.setAttribute('stroke',color); p.setAttribute('stroke-width','2'); svg.appendChild(p);
+ const last=document.createElementNS(ns,'circle'); last.setAttribute('cx',x(data.length-1)); last.setAttribute('cy',y(data[data.length-1])); last.setAttribute('r','3'); last.setAttribute('fill',color); svg.appendChild(last);
+ }
+ sparkline('sparkWeight', WEIGHT, '#539bf5');
+ sparkline('sparkRhr', RHR, '#e5534b');
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
← 4a02cce purchases: collect 38 real Amazon orders from info@ (via Geo
·
back to AbramsOS
·
health: BP-medication context — show active meds that raise/ 355389e →