Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions app/graph_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from __future__ import annotations

import logging
from datetime import datetime, timezone
from typing import Any

from neo4j import GraphDatabase
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion app/routers/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}


Expand Down
18 changes: 17 additions & 1 deletion app/routers/seniors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])

Expand All @@ -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)
Expand Down
13 changes: 11 additions & 2 deletions app/services/alert_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}."}
Expand Down
91 changes: 82 additions & 9 deletions frontend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 = '<tr><td colspan="5" class="empty-state">No seniors. Click "+ Add Senior".</td></tr>'; return; }
if (!seniors.length) { tbody.innerHTML = '<tr><td colspan="7" class="empty-state">No seniors. Click "+ Add Senior".</td></tr>'; 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 = '<span class="status-badge danger"><span class="dot"></span> None yet</span>';
} else if (w.at_risk) {
checkinHtml = `<span class="status-badge warning" title="No check-in in ${w.days_threshold} days"><span class="dot"></span> ${w.days_since_checkin}d</span>`;
} else {
checkinHtml = `<span style="font-size:0.85rem;color:var(--gray-600)">${w.days_since_checkin}d ago</span>`;
}
}
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)
? `<button class="btn-contact-link" onclick="showContactDetails('${safeKey}')">${(fc.name || 'Family contact').replace(/</g, '&lt;')}</button>`
: '<span style="color:var(--gray-400);font-size:0.85rem;">—</span>';
return `<tr>
<td><div class="name-cell"><div class="avatar ${getColor(s.name)}">${getInitials(s.name)}</div><div class="name-info"><div class="name">${s.name}</div><div class="phone">${s.phone}</div></div></div></td>
<td><span class="status-badge ${sc}"><span class="dot"></span> ${st}</span></td>
<td>${score === '—' ? '—' : score+'/10'}</td>
<td style="max-width:200px;font-size:0.85rem;">${s.medications.join(', ') || '—'}</td>
<td><button class="btn btn-small" onclick="showPage('graph');document.getElementById('graph-senior-select').value='${s.phone}';loadGraph()">Graph</button>
<button class="btn btn-small" onclick="showPage('insights');document.getElementById('insight-senior-select').value='${s.phone}'">Insights</button>
<button class="btn btn-small" onclick="showPage('voice');document.getElementById('voice-senior-select').value='${s.phone}'">Call</button></td>
<td style="white-space:nowrap;">${checkinHtml}</td>
<td>${contactHtml}</td>
<td style="max-width:180px;font-size:0.85rem;">${s.medications.join(', ') || '—'}</td>
<td><button class="btn btn-small" onclick="goToPage('graph', '${safeKey}')">Graph</button>
<button class="btn btn-small" onclick="goToPage('insights', '${safeKey}')">Insights</button>
<button class="btn btn-small" onclick="goToPage('voice', '${safeKey}')">Call</button></td>
</tr>`;
}).join('');

Expand All @@ -63,7 +96,44 @@ async function loadSeniors() {
`<div style="display:flex;align-items:center;gap:0.5rem;padding:0.3rem 0;"><span class="severity ${a.severity}">${a.severity}</span><span>${a.message}</span></div>`
).join('');
} else { banner.style.display = 'none'; badge.style.display = 'none'; }
} catch(e) { console.error(e); document.getElementById('seniors-tbody').innerHTML = '<tr><td colspan="5" class="empty-state">Failed to load. Is the server running?</td></tr>'; }
} catch(e) { console.error(e); document.getElementById('seniors-tbody').innerHTML = '<tr><td colspan="7" class="empty-state">Failed to load. Is the server running?</td></tr>'; }
}

// ── 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 = '<option value="">Select a senior...</option>' +
seniors.map(s => `<option value="${s.phone}">${s.name}</option>`).join('');
sel.value = phone;
} catch(e) {}
if (page === 'graph') loadGraph();
}

// ── Interactive Graph View (vis.js) ──
Expand Down Expand Up @@ -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 = '<p class="empty-state">No alerts.</p>'; return; }
el.innerHTML = alerts.map(a => `<div class="alert-card ${a.severity}"><div style="flex:1;"><span class="severity ${a.severity}">${a.severity}</span> <strong>${a.alert_type.replace(/_/g,' ')}</strong><div style="margin-top:0.25rem;">${a.message}</div><div style="font-size:0.75rem;color:var(--gray-500);margin-top:0.25rem;">${new Date(a.timestamp).toLocaleString()}</div></div>${!a.acknowledged ? `<button class="btn btn-small" onclick="ackAlert('${a.id}')">Acknowledge</button>` : ''}</div>`).join('');
const hint = history ? '' : '<p style="color:var(--gray-600);font-size:0.9rem;margin-bottom:1rem;">Showing active alerts only. Enable <strong>Show acknowledged history</strong> to see past items.</p>';
if (!alerts.length) { el.innerHTML = hint + '<p class="empty-state">No alerts.</p>'; return; }
el.innerHTML = hint + alerts.map(a => `<div class="alert-card ${a.severity}"><div style="flex:1;"><span class="severity ${a.severity}">${a.severity}</span> <strong>${a.alert_type.replace(/_/g,' ')}</strong>${a.acknowledged ? ' <span style="font-size:0.75rem;color:var(--gray-500);">(acknowledged)</span>' : ''}<div style="margin-top:0.25rem;">${a.message}</div><div style="font-size:0.75rem;color:var(--gray-500);margin-top:0.25rem;">${new Date(a.timestamp).toLocaleString()}</div></div>${!a.acknowledged ? `<button class="btn btn-small" onclick="ackAlert(${JSON.stringify(a.id)})">Acknowledge</button>` : ''}</div>`).join('');
} catch(e) { console.error(e); }
}
async function ackAlert(id) { await fetchJSON(`/api/alerts/${encodeURIComponent(id)}/acknowledge`, { method: 'PUT' }); loadAlertsPage(); loadSeniors(); }
Expand Down
26 changes: 24 additions & 2 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,13 @@ <h1>Seniors</h1>
<div class="stats-row">
<div class="stat-card"><div class="stat-label">Total seniors</div><div class="stat-value" id="stat-total">0</div></div>
<div class="stat-card"><div class="stat-label">Active alerts</div><div class="stat-value stat-orange" id="stat-alerts">0</div></div>
<div class="stat-card"><div class="stat-label">Needs outreach</div><div class="stat-value stat-orange" id="stat-at-risk" title="No check-in within 7 days">0</div></div>
<div class="stat-card"><div class="stat-label">Avg. wellness</div><div class="stat-value" id="stat-avg">—/10</div></div>
<div class="stat-card"><div class="stat-label">Graph nodes</div><div class="stat-value stat-green" id="stat-nodes">—</div></div>
</div>
<div class="table-container">
<table class="data-table"><thead><tr><th>NAME</th><th>STATUS</th><th>SCORE</th><th>MEDICATIONS</th><th>ACTIONS</th></tr></thead>
<tbody id="seniors-tbody"><tr><td colspan="5" class="empty-state">Loading...</td></tr></tbody></table>
<table class="data-table"><thead><tr><th>NAME</th><th>STATUS</th><th>SCORE</th><th>CHECK-IN</th><th>FAMILY</th><th>MEDICATIONS</th><th>ACTIONS</th></tr></thead>
<tbody id="seniors-tbody"><tr><td colspan="7" class="empty-state">Loading...</td></tr></tbody></table>
</div>
</div>

Expand Down Expand Up @@ -126,6 +127,12 @@ <h1>Seniors</h1>
<!-- Page: Alerts -->
<div id="page-alerts" class="page">
<div class="page-header"><h1>Alerts</h1></div>
<div class="graph-controls" style="margin-bottom:1rem;">
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;">
<input type="checkbox" id="alerts-show-history" onchange="loadAlertsPage()">
Show acknowledged history
</label>
</div>
<div id="alerts-full-list"></div>
</div>

Expand All @@ -141,6 +148,21 @@ <h1>Seniors</h1>
</div>
</div>

<!-- Contact Details Modal -->
<div id="contact-modal" class="modal" style="display:none;" onclick="if(event.target===this)closeContactModal()">
<div class="modal-content" style="max-width:360px;">
<h2 style="margin-bottom:0.25rem;">Family Contact</h2>
<p style="margin:0 0 1.25rem;font-size:0.85rem;color:var(--gray-500);">Contact for <span id="contact-modal-senior-name"></span></p>
<div class="contact-detail-row"><span class="contact-detail-label">Name</span><span id="contact-modal-name" class="contact-detail-value"></span></div>
<div class="contact-detail-row"><span class="contact-detail-label">Phone</span><span id="contact-modal-phone" class="contact-detail-value"></span></div>
<div class="contact-detail-row"><span class="contact-detail-label">Relation</span><span id="contact-modal-relation" class="contact-detail-value"></span></div>
<div class="modal-actions" style="margin-top:1.5rem;">
<button type="button" class="btn" onclick="closeContactModal()">Close</button>
<a id="contact-modal-call" href="#" class="btn btn-primary" style="display:inline-flex;align-items:center;gap:0.4rem;text-decoration:none;">📞 Call</a>
</div>
</div>
</div>

<!-- Add Senior Modal -->
<div id="add-modal" class="modal" style="display:none;">
<div class="modal-content">
Expand Down
9 changes: 8 additions & 1 deletion frontend/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,14 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; b
.btn-primary:hover { opacity: 0.9; }
.btn-small { padding: 0.3rem 0.6rem; font-size: 0.8rem; }

.stats-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; margin-bottom: 1.5rem; }
.stats-row { display: grid; grid-template-columns: repeat(5, 1fr); gap: 1rem; margin-bottom: 1.5rem; }
.phone-link { color: var(--primary); font-weight: 500; text-decoration: none; font-size: 0.85rem; }
.phone-link:hover { text-decoration: underline; }
.btn-contact-link { background: none; border: none; color: var(--primary); font-weight: 500; font-size: 0.85rem; cursor: pointer; padding: 0; text-align: left; }
.btn-contact-link:hover { text-decoration: underline; }
.contact-detail-row { display: flex; gap: 1rem; padding: 0.5rem 0; border-bottom: 1px solid var(--gray-100); align-items: baseline; }
.contact-detail-label { font-size: 0.8rem; color: var(--gray-500); min-width: 70px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.03em; }
.contact-detail-value { font-size: 0.95rem; font-weight: 500; color: var(--gray-900); }
.stat-card { background: var(--gray-50); border: 1px solid var(--gray-200); border-radius: var(--radius); padding: 1rem 1.25rem; }
.stat-label { font-size: 0.8rem; color: var(--gray-500); margin-bottom: 0.25rem; }
.stat-value { font-size: 1.5rem; font-weight: 700; }
Expand Down
Loading