← back to AbramsOS
views/vitals.ejs
236 lines
<%- 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 (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 (typeof bpMeds !== 'undefined' && bpMeds.length) { %>
<section class="glass" style="padding:.9rem 1.25rem;margin-bottom:1rem">
<h3 style="margin:.1rem 0 .5rem">Medications that affect blood pressure</h3>
<div style="display:flex;gap:.5rem;flex-wrap:wrap">
<% bpMeds.forEach(m => { %>
<span class="chip" style="background:<%= m.effect==='raises' ? 'rgba(229,83,75,.15)' : 'rgba(63,185,80,.15)' %>">
<strong><%= m.name %></strong> · <%= m.klass %> · <%= m.effect==='raises' ? 'may raise BP' : 'lowers BP' %><% if (m.person) { %> · <%= m.person %><% } %>
</span>
<% }) %>
</div>
<p class="subtle" style="font-size:.72rem;margin:.4rem 0 0">Context from your medication list — informational, 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">
<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>
<% } %>
<% 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
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', {}) %>