diff --git a/app/graph_db.py b/app/graph_db.py index 488c5eb..7f34029 100644 --- a/app/graph_db.py +++ b/app/graph_db.py @@ -17,6 +17,7 @@ from __future__ import annotations import logging +from datetime import datetime, timezone from typing import Any from neo4j import GraphDatabase @@ -292,6 +293,65 @@ def get_latest_checkins() -> list[dict]: return [dict(r) for r in results] +def _parse_graph_datetime(value) -> datetime | None: + """Normalize Neo4j / Python datetime or ISO string to a timezone-aware UTC datetime.""" + if value is None: + return None + if isinstance(value, datetime): + return value if value.tzinfo else value.replace(tzinfo=timezone.utc) + if hasattr(value, "to_native"): + try: + native = value.to_native() + if isinstance(native, datetime): + return native if native.tzinfo else native.replace(tzinfo=timezone.utc) + except Exception: + pass + s = str(value).strip() + if not s: + return None + try: + if s.endswith("Z"): + s = s[:-1] + "+00:00" + dt = datetime.fromisoformat(s) + return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc) + except ValueError: + return None + + +def get_senior_checkin_wellness(days_threshold: int = 7) -> list[dict]: + """Per senior: last check-in time, days since, and at_risk if no check-in within threshold days.""" + results = run_query(""" + MATCH (s:Senior) + OPTIONAL MATCH (s)-[:CHECKED_IN]->(ci:CheckIn) + WITH s, max(ci.timestamp) AS last_ts + RETURN s.phone AS phone, s.name AS name, s.checkin_schedule AS checkin_schedule, + last_ts AS last_checkin_timestamp + ORDER BY name + """) + now = datetime.now(timezone.utc) + out: list[dict] = [] + for r in results: + last_raw = r.get("last_checkin_timestamp") + last_dt = _parse_graph_datetime(last_raw) + if last_dt is None: + days_since = None + at_risk = True + else: + delta = now - last_dt + days_since = max(0, int(delta.total_seconds() // 86400)) + at_risk = days_since >= days_threshold + out.append({ + "phone": r["phone"], + "name": r["name"], + "checkin_schedule": r.get("checkin_schedule") or "09:00", + "last_checkin_timestamp": last_dt.isoformat() if last_dt else None, + "days_since_checkin": days_since, + "at_risk": at_risk, + "days_threshold": days_threshold, + }) + return out + + # ── Alerts ── def store_alert(alert_id: str, senior_phone: str, senior_name: str, diff --git a/app/routers/graph.py b/app/routers/graph.py index 95a0bbc..212225a 100644 --- a/app/routers/graph.py +++ b/app/routers/graph.py @@ -39,7 +39,7 @@ async def drug_interactions(phone: str): explanation = await explain_drug_interaction( interaction["drug1"], interaction["drug2"] ) - interaction["ai_explanation"] = explanation + interaction["ai_explanation"] = explanation or "No AI explanation available." return {"phone": phone, "interactions": interactions} diff --git a/app/routers/seniors.py b/app/routers/seniors.py index 102bbe9..5fd5d15 100644 --- a/app/routers/seniors.py +++ b/app/routers/seniors.py @@ -3,7 +3,13 @@ from fastapi import APIRouter, HTTPException from app.models.senior import Senior -from app.graph_db import create_senior, get_senior, list_seniors, delete_senior +from app.graph_db import ( + create_senior, + get_senior, + list_seniors, + delete_senior, + get_senior_checkin_wellness, +) router = APIRouter(prefix="/api/seniors", tags=["seniors"]) @@ -23,6 +29,16 @@ async def get_all_seniors(): return list_seniors() +@router.get("/wellness-overview") +async def wellness_overview(days_threshold: int = 7): + """Check-in cadence per senior: who may need outreach (no recent check-in).""" + if days_threshold < 1 or days_threshold > 90: + raise HTTPException(status_code=400, detail="days_threshold must be between 1 and 90") + rows = get_senior_checkin_wellness(days_threshold=days_threshold) + at_risk_count = sum(1 for r in rows if r["at_risk"]) + return {"days_threshold": days_threshold, "at_risk_count": at_risk_count, "seniors": rows} + + @router.get("/{phone}") async def get_one_senior(phone: str): s = get_senior(phone) diff --git a/app/services/alert_engine.py b/app/services/alert_engine.py index a337796..b74af57 100644 --- a/app/services/alert_engine.py +++ b/app/services/alert_engine.py @@ -17,9 +17,18 @@ def evaluate_checkin(checkin: dict, senior_name: str = "") -> list[dict]: who = senior_name or checkin.get("senior_phone", "") phone = checkin.get("senior_phone", "") - emergency_words = {"fall", "fell", "fallen", "chest pain", "can't breathe", "emergency", "stroke", "bleeding"} + # Substring match: concerns are free text from NLP; match elder-relevant crises. + emergency_phrases = ( + "fall", "fell", "fallen", "on the floor", "can't get up", "help me up", + "chest pain", "heart attack", "can't breathe", "breathless", "choking", + "stroke", "bleeding", "blood", "unconscious", "passed out", + "emergency", "call 911", "ambulance", + "confusion", "confused", "disoriented", "slurred", "slurring", + "weakness on one side", "face drooping", "severe headache", "sudden numbness", + ) for concern in checkin.get("concerns", []): - if concern.lower() in emergency_words: + cl = concern.lower() + if any(p in cl for p in emergency_phrases): alert = {"id": f"{phone}:{now}:emergency", "senior_phone": phone, "senior_name": senior_name, "timestamp": now, "alert_type": "emergency", "severity": "critical", "message": f"Emergency: {concern}. Immediate attention needed for {who}."} diff --git a/frontend/app.js b/frontend/app.js index 446bdcb..f20e5ac 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -20,6 +20,9 @@ function showPage(id) { if (id === 'voice') loadRecentCalls(); } +// ── Contact lookup map (keyed by senior phone) ── +const _contactMap = {}; + // ── Seniors ── async function loadSeniors() { try { @@ -30,27 +33,57 @@ async function loadSeniors() { latest.forEach(c => { latestMap[c.senior_phone] = c; }); const alerts = await fetchJSON('/api/alerts'); + let wellnessMap = {}; + try { + const w = await fetchJSON('/api/seniors/wellness-overview?days_threshold=7'); + document.getElementById('stat-at-risk').textContent = w.at_risk_count ?? '—'; + (w.seniors || []).forEach(x => { wellnessMap[x.phone] = x; }); + } catch (e) { + document.getElementById('stat-at-risk').textContent = '—'; + } + document.getElementById('stat-total').textContent = seniors.length; document.getElementById('stat-alerts').textContent = alerts.length; const scores = latest.filter(c => c.wellness_score > 0).map(c => c.wellness_score); document.getElementById('stat-avg').textContent = scores.length ? (scores.reduce((a,b)=>a+b,0)/scores.length).toFixed(1)+'/10' : '—'; const tbody = document.getElementById('seniors-tbody'); - if (!seniors.length) { tbody.innerHTML = 'No seniors. Click "+ Add Senior".'; return; } + if (!seniors.length) { tbody.innerHTML = 'No seniors. Click "+ Add Senior".'; return; } tbody.innerHTML = seniors.map(s => { const l = latestMap[s.phone]; const mood = l?.mood || 'unknown'; let sc = mood === 'concerning' ? 'danger' : mood === 'sad' ? 'warning' : l ? 'good' : 'neutral'; let st = mood === 'concerning' ? 'Concerning' : mood === 'sad' ? 'Needs attention' : l ? 'OK' : 'Pending'; + const w = wellnessMap[s.phone]; + if (w && w.at_risk) { sc = 'warning'; st = 'Needs outreach'; } const score = l?.wellness_score || '—'; + let checkinHtml = '—'; + if (w) { + if (w.last_checkin_timestamp == null) { + checkinHtml = ' None yet'; + } else if (w.at_risk) { + checkinHtml = ` ${w.days_since_checkin}d`; + } else { + checkinHtml = `${w.days_since_checkin}d ago`; + } + } + const fc = (s.emergency_contacts && s.emergency_contacts[0]) ? s.emergency_contacts[0] : null; + if (fc) _contactMap[s.phone] = { ...fc, seniorName: s.name }; + const safeKey = s.phone.replace(/'/g, "\\'"); + const safeName = s.name.replace(/'/g, "\\'").replace(/\\/g, '\\\\'); + const contactHtml = (fc && fc.phone) + ? ` - - + ${checkinHtml} + ${contactHtml} + ${s.medications.join(', ') || '—'} + + + `; }).join(''); @@ -63,7 +96,44 @@ async function loadSeniors() { `
${a.severity}${a.message}
` ).join(''); } else { banner.style.display = 'none'; badge.style.display = 'none'; } - } catch(e) { console.error(e); document.getElementById('seniors-tbody').innerHTML = 'Failed to load. Is the server running?'; } + } catch(e) { console.error(e); document.getElementById('seniors-tbody').innerHTML = 'Failed to load. Is the server running?'; } +} + +// ── Contact Details Modal ── +function showContactDetails(seniorPhone) { + const fc = _contactMap[seniorPhone] || {}; + const seniorNameEl = document.getElementById('contact-modal-senior-name'); + if (seniorNameEl) seniorNameEl.textContent = fc.seniorName || 'this senior'; + document.getElementById('contact-modal-name').textContent = fc.name || 'Family Contact'; + document.getElementById('contact-modal-phone').textContent = fc.phone || '—'; + document.getElementById('contact-modal-relation').textContent = fc.relation || fc.relationship || '—'; + const callBtn = document.getElementById('contact-modal-call'); + callBtn.href = fc.phone ? `tel:${String(fc.phone).replace(/\s/g, '')}` : '#'; + callBtn.style.display = fc.phone ? 'inline-flex' : 'none'; + document.getElementById('contact-modal').style.display = 'flex'; +} +function closeContactModal() { document.getElementById('contact-modal').style.display = 'none'; } + +// ── Navigate to page and pre-select a senior ── +async function goToPage(page, phone) { + showPage(page); + const selectMap = { + graph: 'graph-senior-select', + insights: 'insight-senior-select', + voice: 'voice-senior-select', + }; + const selId = selectMap[page]; + if (!selId) return; + // Populate options fresh and set value — avoids async race in showPage + try { + const seniors = await fetchJSON('/api/seniors'); + const sel = document.getElementById(selId); + if (!sel) return; + sel.innerHTML = '' + + seniors.map(s => ``).join(''); + sel.value = phone; + } catch(e) {} + if (page === 'graph') loadGraph(); } // ── Interactive Graph View (vis.js) ── @@ -318,10 +388,13 @@ async function addSenior(e) { // ── Alerts Page ── async function loadAlertsPage() { try { - const alerts = await fetchJSON('/api/alerts?acknowledged=true'); + const history = document.getElementById('alerts-show-history')?.checked; + const url = history ? '/api/alerts?acknowledged=true' : '/api/alerts'; + const alerts = await fetchJSON(url); const el = document.getElementById('alerts-full-list'); - if (!alerts.length) { el.innerHTML = '

No alerts.

'; return; } - el.innerHTML = alerts.map(a => `
${a.severity} ${a.alert_type.replace(/_/g,' ')}
${a.message}
${new Date(a.timestamp).toLocaleString()}
${!a.acknowledged ? `` : ''}
`).join(''); + const hint = history ? '' : '

Showing active alerts only. Enable Show acknowledged history to see past items.

'; + if (!alerts.length) { el.innerHTML = hint + '

No alerts.

'; return; } + el.innerHTML = hint + alerts.map(a => `
${a.severity} ${a.alert_type.replace(/_/g,' ')}${a.acknowledged ? ' (acknowledged)' : ''}
${a.message}
${new Date(a.timestamp).toLocaleString()}
${!a.acknowledged ? `` : ''}
`).join(''); } catch(e) { console.error(e); } } async function ackAlert(id) { await fetchJSON(`/api/alerts/${encodeURIComponent(id)}/acknowledge`, { method: 'PUT' }); loadAlertsPage(); loadSeniors(); } diff --git a/frontend/index.html b/frontend/index.html index f58bef7..c204ab2 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -34,12 +34,13 @@

Seniors

Total seniors
0
Active alerts
0
+
Needs outreach
0
Avg. wellness
—/10
Graph nodes
- -
NAMESTATUSSCOREMEDICATIONSACTIONS
Loading...
+ +
NAMESTATUSSCORECHECK-INFAMILYMEDICATIONSACTIONS
Loading...
@@ -126,6 +127,12 @@

Seniors

+
+ +
@@ -141,6 +148,21 @@

Seniors

+ + +