[object Object]

← 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

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 →