Files
FHIR-Sandbox/app/templates/dashboard.html

1275 lines
62 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{template "base.html" .}}
{{define "nav"}}
<div class="flex items-center gap-4">
<a href="/patients" class="nav-link">All Patients</a>
<a href="/" class="nav-link">Home</a>
{{if .Practitioner}}
<div class="practitioner-mini-card">
<div class="avatar avatar-sm">
{{if .Practitioner.FirstName}}{{slice .Practitioner.FirstName 0 1}}{{end}}{{if .Practitioner.LastName}}{{slice .Practitioner.LastName 0 1}}{{end}}
</div>
<span class="text-xs font-medium">Dr. {{.Practitioner.LastName}}</span>
</div>
{{end}}
<form action="/logout" method="POST" class="flex items-center">
<button class="btn btn-danger btn-sm" type="submit">Logout</button>
</form>
</div>
{{end}}
{{define "content"}}
{{/* ---- Flash Messages ---- */}}
{{if .Synced}}
<div class="flash-message mb-6">
<div class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
<div>
<strong class="font-medium text-main">Sync complete</strong>
<span class="text-sm text-muted">Data has been refreshed from the EHR.</span>
</div>
</div>
<button onclick="this.parentElement.style.display='none';" class="btn btn-icon text-muted">&times;</button>
</div>
{{end}}
{{if .Patient}}
<div class="flex flex-col gap-6 pt-4">
{{/* ---- Top Header: Patient Banner ---- */}}
<div class="panel panel-padded flex items-center justify-between border-l-brand">
<div class="flex items-center gap-6 w-full">
<div class="avatar avatar-lg">
{{if .Patient.FirstName}}{{slice .Patient.FirstName 0 1}}{{end}}{{if .Patient.LastName}}{{slice .Patient.LastName 0 1}}{{end}}
</div>
<div class="flex flex-col gap-1 w-full">
<div class="flex items-center gap-3">
<h1 class="page-title m-0">
{{.Patient.LastName}}, {{.Patient.FirstName}} {{.Patient.MiddleName}}
</h1>
<span class="mrn-badge">MRN: {{orDash .Patient.MRN}}</span>
</div>
<div class="patient-meta-strip">
<span class="meta-item"><strong>DOB:</strong> {{formatDate .Patient.DOB}} ({{calculateAge .Patient.DOB}} yrs)</span>
<span class="meta-item"><strong>Gender:</strong> {{titleCase .Patient.Gender}}</span>
{{with .Patient.Email}}<span class="meta-item"><strong>Email:</strong> {{.}}</span>{{end}}
{{with primaryPhone .RawPatient}}<span class="meta-item"><strong>Phone:</strong> {{.}}</span>{{end}}
</div>
</div>
<div class="flex flex-col items-end gap-2 shrink-0">
<form action="/dashboard/sync?patient_id={{.Patient.FHIRID}}" method="POST" class="flex items-center">
<button class="btn btn-primary" type="submit" id="syncBtn" onclick="onSyncClick()">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
Refresh Chart
</button>
</form>
{{if .RimidiMatch}}
{{if .RimidiPatientKey}}
<a href="http://localhost:2222/providers/v2/diab/patient/labs/{{.RimidiPatientKey}}/" target="_blank" class="btn" style="background-color:#e91e63;color:white;border:none;" title="Launch patient in Rimidi">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Launch in Rimidi
</a>
{{else}}
<button class="btn" style="background-color:#e91e63;color:white;border:none;opacity:0.6;cursor:not-allowed;" title="Loading patient key..." disabled>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Launch in Rimidi
</button>
{{end}}
<button class="btn btn-outline" type="button" onclick="unlinkPatient('{{.Patient.FHIRID}}')" title="Unlink this patient from Rimidi" style="color:var(--color-danger);border-color:var(--color-danger);">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18"/><path d="M6 6l12 12"/></svg>
Unlink
</button>
{{else}}
<button class="btn btn-outline" type="button" onclick="findInRimidi('{{.Patient.FHIRID}}')" title="Search for this patient in the Rimidi system">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
Find in Rimidi
</button>
{{end}}
<span class="text-xs text-muted">
Last Synced: {{if .LatestSync}}{{formatDateTime .LatestSync.SyncedAt}}{{else}}Never{{end}}
</span>
</div>
</div>
</div>
<div class="dashboard-layout">
{{/* ---- Main Content Column ---- */}}
<div class="dashboard-main">
{{/* Clinical Summary Stats */}}
<div class="grid grid-cols-3 gap-4 lg:grid-cols-1">
<div class="panel panel-padded stat-card">
<div class="stat-value text-blue">{{.Summary.ActiveCondCount}}</div>
<div class="stat-label">Active Conditions</div>
</div>
<div class="panel panel-padded stat-card">
<div class="stat-value text-success">{{.Summary.ActiveMedCount}}</div>
<div class="stat-label">Active Medications</div>
</div>
<div class="panel panel-padded stat-card {{if gt .Summary.AbnormalLabCount 0}}danger{{end}}">
<div class="stat-value {{if gt .Summary.AbnormalLabCount 0}}text-danger{{end}}">{{.Summary.AbnormalLabCount}}</div>
<div class="stat-label">Abnormal Labs (30d)</div>
</div>
</div>
{{/* Clinical Data Tabs */}}
<div class="panel p-0 overflow-hidden">
<div class="bg-panel px-4 pt-4 border-b border-hard">
<div class="tabs">
<button class="tab-btn active" onclick="openTab(event, 'summary')">Summary</button>
{{if .RimidiMatch}}
<button class="tab-btn cgm-tab" onclick="openTab(event, 'cgm')">CGM</button>
{{end}}
<button class="tab-btn" onclick="openTab(event, 'vitals')">Vitals ({{len (latestObPerCode (filterObsByCategory .Observations "vital-signs"))}})</button>
<button class="tab-btn" onclick="openTab(event, 'labs')">Labs ({{len (filterObsByCategory .Observations "laboratory")}})</button>
<button class="tab-btn" onclick="openTab(event, 'conditions')">Conditions ({{len .Conditions}})</button>
<button class="tab-btn" onclick="openTab(event, 'medications')">Meds ({{len .Medications}})</button>
<button class="tab-btn" onclick="openTab(event, 'allergies')">Allergies ({{len .Allergies}})</button>
<button class="tab-btn" onclick="openTab(event, 'notes')">Notes ({{len .DocumentReferences}})</button>
<button class="tab-btn" onclick="openTab(event, 'more')" title="More Data">...</button>
</div>
</div>
<div class="panel-padded min-h-[400px]">
{{/* ---- Summary Tab ---- */}}
<div id="summary" class="tab-content active">
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-4">
<h3 class="text-xs text-muted font-medium uppercase tracking-wider">Latest Vital Signs</h3>
{{if .Summary.LatestVitals}}
<div class="vitals-strip">
{{range .Summary.LatestVitals}}
<div class="vital-item {{if isAbnormal .Interpretation}}vital-abnormal{{end}}">
<span class="vital-label" title="{{.CodeText}}">{{orDash .CodeText}}</span>
<span class="vital-value">
{{if .ValueQuantity}}{{printf "%.4g" (derefFloat64 .ValueQuantity)}} <small>{{.ValueUnit}}</small>
{{else if .ValueString}}{{.ValueString}}
{{else}}—{{end}}
</span>
<span class="vital-date">{{formatDate .EffectiveDate}}</span>
</div>
{{end}}
</div>
{{else}}
<p class="empty-state">No vital signs recorded.</p>
{{end}}
</div>
<div class="flex flex-col gap-4">
<h3 class="text-xs text-muted font-medium uppercase tracking-wider">Recent Abnormal Labs</h3>
{{if .Summary.AbnormalLabsRecent}}
<div class="table-wrapper">
<table>
<thead>
<tr><th>Date</th><th>Test</th><th>Result</th><th>Flag</th></tr>
</thead>
<tbody>
{{range .Summary.AbnormalLabsRecent}}
<tr class="row-abnormal">
<td>{{formatDate .EffectiveDate}}</td>
<td class="font-medium">{{.CodeText}}</td>
<td>{{if .ValueQuantity}}{{printf "%.4g" (derefFloat64 .ValueQuantity)}} {{.ValueUnit}}{{else}}{{.ValueString}}{{end}}</td>
<td><span class="badge badge-danger">{{.Interpretation}}</span></td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="empty-state">No abnormal labs in the last 30 days.</p>
{{end}}
</div>
</div>
</div>
{{/* ---- CGM Tab ---- */}}
{{if .RimidiMatch}}
<div id="cgm" class="tab-content">
<div id="cgmTabContent" style="min-height:400px;"></div>
</div>
{{end}}
{{/* ---- Vitals Tab ---- */}}
<div id="vitals" class="tab-content">
{{$vitals := latestObPerCode (filterObsByCategory .Observations "vital-signs")}}
{{if $vitals}}
<div class="vitals-grid">
{{range $vitals}}
<div class="panel panel-padded p-5 {{if isAbnormal .Interpretation}}vital-abnormal{{end}}">
<div class="flex justify-between items-start mb-2">
<span class="text-xs font-semibold text-muted">{{orDash .CodeText}}</span>
{{if isAbnormal .Interpretation}}<span class="badge badge-danger">{{.Interpretation}}</span>{{end}}
</div>
<div class="text-2xl font-semibold mb-4">
{{if .ValueQuantity}}{{printf "%.4g" (derefFloat64 .ValueQuantity)}} <span class="text-sm font-medium text-muted">{{.ValueUnit}}</span>
{{else if .ValueString}}{{.ValueString}}
{{else}}—{{end}}
</div>
<div class="flex flex-col gap-1 text-xs text-muted">
<span>{{formatDate .EffectiveDate}}</span>
{{if or .ReferenceRangeLow .ReferenceRangeHigh}}
<span>Ref: {{if .ReferenceRangeLow}}{{printf "%.4g" (derefFloat64 .ReferenceRangeLow)}}{{else}}—{{end}}-{{if .ReferenceRangeHigh}}{{printf "%.4g" (derefFloat64 .ReferenceRangeHigh)}}{{else}}—{{end}}</span>
{{end}}
</div>
</div>
{{end}}
</div>
{{else}}
<p class="empty-state">No vital signs found.</p>
{{end}}
</div>
{{/* ---- Labs Tab ---- */}}
<div id="labs" class="tab-content">
{{$labs := filterObsByCategory .Observations "laboratory"}}
{{if $labs}}
<div class="table-wrapper">
<table>
<thead>
<tr><th>Date</th><th>Test</th><th>Result</th><th>Ref Range</th><th>Flag</th></tr>
</thead>
<tbody>
{{range $labs}}
<tr {{if isAbnormal .Interpretation}}class="row-abnormal"{{end}}>
<td>{{formatDate .EffectiveDate}}</td>
<td class="font-medium">{{orDash .CodeText}}</td>
<td>
{{if .ValueQuantity}}<strong>{{printf "%.4g" (derefFloat64 .ValueQuantity)}}</strong> {{.ValueUnit}}
{{else if .ValueString}}{{.ValueString}}
{{else}}—{{end}}
</td>
<td class="text-xs text-muted">
{{if or .ReferenceRangeLow .ReferenceRangeHigh}}
[{{if .ReferenceRangeLow}}{{printf "%.4g" (derefFloat64 .ReferenceRangeLow)}}{{else}}—{{end}} {{if .ReferenceRangeHigh}}{{printf "%.4g" (derefFloat64 .ReferenceRangeHigh)}}{{else}}—{{end}}]
{{else}}—{{end}}
</td>
<td>{{if isAbnormal .Interpretation}}<span class="badge badge-danger">{{.Interpretation}}</span>{{else if .Interpretation}}<span class="badge badge-neutral">{{.Interpretation}}</span>{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="empty-state">No lab results found.</p>
{{end}}
</div>
{{/* ---- Conditions Tab ---- */}}
<div id="conditions" class="tab-content">
{{if .Conditions}}
<div class="table-wrapper">
<table>
<thead>
<tr><th>Onset</th><th>Condition</th><th>Status</th><th>Verification</th></tr>
</thead>
<tbody>
{{range .Conditions}}
<tr>
<td>{{formatDate (or .OnsetDate .RecordedDate)}}</td>
<td class="font-medium">{{orDash .CodeText}}</td>
<td>{{if eq .ClinicalStatus "active"}}<span class="badge badge-success">Active</span>{{else}}<span class="badge badge-neutral">{{.ClinicalStatus}}</span>{{end}}</td>
<td>{{titleCase .VerificationStatus}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="empty-state">No conditions found.</p>
{{end}}
</div>
{{/* ---- Medications Tab ---- */}}
<div id="medications" class="tab-content">
{{if .Medications}}
<div class="table-wrapper">
<table>
<thead>
<tr><th>Authored</th><th>Medication</th><th>Dosage</th><th>Status</th></tr>
</thead>
<tbody>
{{range .Medications}}
<tr>
<td>{{formatDate .AuthoredOn}}</td>
<td class="font-medium">{{orDash .MedCodeText}}</td>
<td>{{orDash .DosageText}}</td>
<td>{{if eq .Status "active"}}<span class="badge badge-success">Active</span>{{else}}<span class="badge badge-neutral">{{.Status}}</span>{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="empty-state">No medications found.</p>
{{end}}
</div>
{{/* ---- Allergies Tab ---- */}}
<div id="allergies" class="tab-content">
{{if .Allergies}}
<div class="table-wrapper">
<table>
<thead>
<tr><th>Allergen</th><th>Reaction</th><th>Severity</th><th>Status</th></tr>
</thead>
<tbody>
{{range .Allergies}}
<tr>
<td class="font-medium">{{orDash .CodeText}}</td>
<td>{{orDash .ReactionManifestation}}</td>
<td>{{if eq .ReactionSeverity "severe"}}<span class="badge badge-danger">Severe</span>{{else if .ReactionSeverity}}<span class="badge badge-warning">{{.ReactionSeverity}}</span>{{else}}—{{end}}</td>
<td>{{titleCase .ClinicalStatus}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="empty-state">No allergies found.</p>
{{end}}
</div>
{{/* ---- Notes Tab ---- */}}
<div id="notes" class="tab-content">
{{if .DocumentReferences}}
<div class="notes-list">
{{range .DocumentReferences}}
<div class="panel panel-padded note-item">
<div class="note-header">
<span class="note-date">{{formatDate .Date}}</span>
<span class="note-type">{{orDash .TypeText}}</span>
{{if .ContentURL}}<a href="{{.ContentURL}}" target="_blank" class="btn btn-outline btn-sm">View Document</a>{{end}}
</div>
<p class="note-desc">{{orDash .Description}}</p>
</div>
{{end}}
</div>
{{else}}
<p class="empty-state">No clinical notes found.</p>
{{end}}
</div>
{{/* ---- More Tab (Procedures, Immunizations, Encounters) ---- */}}
<div id="more" class="tab-content">
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2">
<h4 class="font-semibold text-sm text-main">Recent Procedures</h4>
{{if .Procedures}}
<ul class="flex flex-col gap-1 text-sm text-muted">
{{range (slice .Procedures 0 (min (len .Procedures) 5))}}
<li><strong class="text-main">{{formatDate .PerformedDate}}:</strong> {{.CodeText}}</li>
{{end}}
</ul>
{{else}}<p class="text-muted text-xs">None recorded</p>{{end}}
</div>
<div class="flex flex-col gap-2">
<h4 class="font-semibold text-sm text-main">Recent Immunizations</h4>
{{if .Immunizations}}
<ul class="flex flex-col gap-1 text-sm text-muted">
{{range (slice .Immunizations 0 (min (len .Immunizations) 5))}}
<li><strong class="text-main">{{formatDate .OccurrenceDate}}:</strong> {{.VaccineText}}</li>
{{end}}
</ul>
{{else}}<p class="text-muted text-xs">None recorded</p>{{end}}
</div>
<div class="flex flex-col gap-2">
<h4 class="font-semibold text-sm text-main">Recent Encounters</h4>
{{if .Encounters}}
<ul class="flex flex-col gap-1 text-sm text-muted">
{{range (slice .Encounters 0 (min (len .Encounters) 5))}}
<li><strong class="text-main">{{formatDate .PeriodStart}}:</strong> {{or .TypeText "Visit"}}</li>
{{end}}
</ul>
{{else}}<p class="text-muted text-xs">None recorded</p>{{end}}
</div>
</div>
</div>
</div>
</div>
</div>
{{/* ---- Sidebar Column ---- */}}
<div class="dashboard-sidebar">
{{/* High Priority: Alerts */}}
{{if hasCriticalAllergies .Allergies}}
<div class="panel panel-padded alert-card">
<div class="flex items-center gap-2 mb-4">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-danger"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>
<h3 class="font-semibold text-danger">Critical Alerts</h3>
</div>
<div class="flex flex-col gap-3">
{{range .Allergies}}
{{if eq .Criticality "high"}}
<div class="alert-item">
<span class="alert-code">{{.CodeText}} Allergy</span>
{{with .ReactionManifestation}}<span class="alert-detail">{{.}}{{with $.ReactionSeverity}} ({{.}}){{end}}</span>{{end}}
</div>
{{end}}
{{end}}
</div>
</div>
{{end}}
{{/* Patient Details (Expanded) */}}
<div class="panel panel-padded">
<div class="mb-4">
<h3 class="font-semibold text-main">Patient Information</h3>
</div>
<div class="kv-list">
<div class="kv-row">
<span class="kv-label">Full Name</span>
<span class="kv-value font-medium">{{.Patient.FirstName}} {{.Patient.MiddleName}} {{.Patient.LastName}}</span>
</div>
<div class="kv-row">
<span class="kv-label">FHIR ID</span>
<span class="kv-value text-xs font-mono">{{.Patient.FHIRID}}</span>
</div>
{{with primaryAddress .RawPatient}}
<div class="kv-row">
<span class="kv-label">Address</span>
<span class="kv-value">{{.}}</span>
</div>
{{end}}
{{with usRace .RawPatient}}
<div class="kv-row">
<span class="kv-label">Race</span>
<span class="kv-value">{{.}}</span>
</div>
{{end}}
{{with usEthnicity .RawPatient}}
<div class="kv-row">
<span class="kv-label">Ethnicity</span>
<span class="kv-value">{{.}}</span>
</div>
{{end}}
</div>
</div>
{{/* Low Priority: SMART Inspector */}}
<div class="panel panel-padded inspector-card">
<details>
<summary class="text-xs text-muted font-medium uppercase tracking-wider cursor-pointer outline-none">System Inspector</summary>
<div class="inspector-content mt-4">
<div class="inspect-item">
<span class="label">EHR Server</span>
<code class="text-xs text-muted break-all" title="{{.Session.EHRURL}}">{{.Session.EHRURL}}</code>
</div>
<div class="inspect-item">
<span class="label">Granted Scopes</span>
<div class="scopes-cloud">
{{range (split .Session.Scope " ")}}
<span class="scope-tag">{{.}}</span>
{{end}}
</div>
</div>
</div>
</details>
</div>
</div>
</div>
</div>
{{end}}
{{end}}
{{define "scripts"}}
<script>
function openTab(evt, tabName) {
var tabcontent = document.getElementsByClassName("tab-content");
for (var i = 0; i < tabcontent.length; i++) {
tabcontent[i].classList.remove("active");
}
var tablinks = document.getElementsByClassName("tab-btn");
for (var i = 0; i < tablinks.length; i++) {
tablinks[i].classList.remove("active");
}
var target = document.getElementById(tabName);
setTimeout(() => target.classList.add("active"), 10);
evt.currentTarget.classList.add("active");
// Load CGM data when CGM tab is opened
if (tabName === 'cgm') {
var cgmContent = document.getElementById('cgmTabContent');
if (cgmContent && !cgmContent.dataset.loaded) {
var patientFHIRID = '{{.Patient.FHIRID}}';
loadCGMData(patientFHIRID);
}
}
}
function onSyncClick() {
var btn = document.getElementById("syncBtn");
setTimeout(function() {
btn.disabled = true;
btn.innerHTML = `<svg class="animate-spin" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg> Syncing...`;
}, 50);
}
// Helper for splitting scopes string
function split(s, sep) { return s.split(sep); }
/* ── "Find in Rimidi" match modal ────────────────────────────────── */
function findInRimidi(patientFHIRID) {
var overlay = document.getElementById('rimidiMatchOverlay');
var body = document.getElementById('rimidiMatchBody');
overlay.style.display = 'flex';
body.innerHTML =
'<div style="text-align:center;padding:40px;">' +
'<div class="animate-spin" style="width:24px;height:24px;border:3px solid #ddd;border-top-color:#0d6efd;border-radius:50%;display:inline-block;"></div>' +
'<p style="margin-top:12px;color:var(--color-text-muted);">Searching Rimidi…</p></div>';
var url = '/api/patient-match-proxy';
if (patientFHIRID) {
url += '?patient_id=' + encodeURIComponent(patientFHIRID);
}
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
})
.then(function(resp) {
if (!resp.ok) {
return resp.json().then(function(d) { throw new Error(d.error || 'HTTP ' + resp.status); });
}
return resp.json();
})
.then(function(data) {
renderRimidiMatchResults(body, data, patientFHIRID);
})
.catch(function(err) {
body.innerHTML =
'<div style="padding:20px;color:var(--color-danger);background:var(--color-danger-bg);border-radius:8px;margin:20px;">' +
'<strong>Error:</strong> ' + escapeHtml(err.message) + '</div>';
});
}
function closeRimidiMatch() {
document.getElementById('rimidiMatchOverlay').style.display = 'none';
}
function escapeHtml(s) {
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function renderRimidiMatchResults(container, data, patientFHIRID) {
var matches = data.matches || [];
var local = data.local_patient || {};
patientFHIRID = patientFHIRID || '';
if (matches.length === 0) {
container.innerHTML =
'<div style="padding:40px;text-align:center;color:var(--text-muted);">' +
'<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin:0 auto 16px;opacity:0.5;"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>' +
'<p style="margin:0;font-size:16px;">No matching patients found in the Rimidi system.</p></div>';
return;
}
var html = '<div style="padding:24px;">';
html += '<div style="margin-bottom:24px;padding:16px;background:var(--accent-blue-bg);border-radius:var(--radius-md);border:1px solid #BAE6FD;">';
html += '<p style="margin:0;color:var(--accent-blue);font-size:14px;font-weight:500;">' +
'Found <strong style="font-weight:600;">' + matches.length + '</strong> potential match' +
(matches.length > 1 ? 'es' : '') + ' in Rimidi.</p>';
html += '</div>';
var fieldLabels = {
first_name: 'First Name', last_name: 'Last Name',
email: 'Email', dob: 'Date of Birth', sex: 'Sex'
};
var fieldOrder = ['first_name', 'last_name', 'email', 'dob', 'sex'];
for (var i = 0; i < matches.length; i++) {
var m = matches[i];
var f = m.fields || {};
var borderColor = m.score >= 4 ? 'var(--success)' : (m.score >= 3 ? 'var(--warning)' : 'var(--accent-blue)');
var badgeClass = m.score >= 4 ? 'badge-success' : (m.score >= 3 ? 'badge-warning' : 'badge-blue');
var matchQuality = m.score >= 4 ? 'High' : (m.score >= 3 ? 'Medium' : 'Low');
html += '<div class="panel" style="margin-bottom:20px;border-left:5px solid ' + borderColor + ';padding:0;overflow:hidden;box-shadow:var(--shadow-subtle);">';
html += '<div style="display:flex;justify-content:space-between;align-items:center;padding:16px 20px;border-bottom:1px solid var(--border-hard);background:var(--bg-panel);">';
html += '<div style="display:flex;align-items:center;gap:12px;">';
html += '<strong style="font-size:16px;font-weight:600;color:var(--text-main);">Match #' + (i + 1) + '</strong>';
html += '<span class="badge ' + badgeClass + '" style="font-size:12px;padding:4px 10px;">' + m.score + '/5 fields match</span>';
html += '<span style="font-size:12px;color:var(--text-muted);">(' + matchQuality + ' confidence)</span>';
html += '</div>';
html += '</div>';
html += '<div style="overflow-x:auto;">';
html += '<table style="width:100%;border-collapse:separate;border-spacing:0;">';
html += '<thead><tr style="background:var(--bg-hover);">';
html += '<th style="padding:12px 16px;text-align:left;width:20%;font-size:11px;text-transform:uppercase;color:var(--text-muted);font-weight:600;letter-spacing:0.05em;border-bottom:2px solid var(--border-hard);">Field</th>';
html += '<th style="padding:12px 16px;text-align:left;width:32%;font-size:11px;text-transform:uppercase;color:var(--text-muted);font-weight:600;letter-spacing:0.05em;border-bottom:2px solid var(--border-hard);">HRS (Local)</th>';
html += '<th style="padding:12px 16px;text-align:left;width:32%;font-size:11px;text-transform:uppercase;color:var(--text-muted);font-weight:600;letter-spacing:0.05em;border-bottom:2px solid var(--border-hard);">Rimidi (Remote)</th>';
html += '<th style="padding:12px 16px;text-align:center;width:16%;font-size:11px;text-transform:uppercase;color:var(--text-muted);font-weight:600;letter-spacing:0.05em;border-bottom:2px solid var(--border-hard);">Status</th>';
html += '</tr></thead><tbody>';
for (var j = 0; j < fieldOrder.length; j++) {
var key = fieldOrder[j];
var fld = f[key] || {};
var localVal = local[key] || '—';
var remoteVal = fld.value || '—';
var isMatch = fld.match === true;
// Enhanced visual indicators
var matchIcon = isMatch
? '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--success)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:middle;"><polyline points="20 6 9 17 4 12"/></svg>'
: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--danger)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:middle;"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
var rowStyle = isMatch
? 'background:var(--success-bg);border-left:3px solid var(--success);'
: 'background:var(--warning-bg);border-left:3px solid var(--warning);';
var matchBadge = isMatch
? '<span style="display:inline-flex;align-items:center;gap:4px;padding:4px 8px;background:var(--success-bg);color:var(--success);border:1px solid #A7F3D0;border-radius:var(--radius-sm);font-size:11px;font-weight:600;">Match</span>'
: '<span style="display:inline-flex;align-items:center;gap:4px;padding:4px 8px;background:var(--danger-bg);color:var(--danger);border:1px solid #FECDD3;border-radius:var(--radius-sm);font-size:11px;font-weight:600;">Mismatch</span>';
html += '<tr style="' + rowStyle + 'border-top:1px solid var(--border-soft);transition:background 0.2s;">';
html += '<td style="padding:14px 16px;font-weight:600;color:var(--text-main);font-size:13px;">' + fieldLabels[key] + '</td>';
html += '<td style="padding:14px 16px;color:var(--text-main);font-size:13px;font-family:var(--font-mono);background:rgba(255,255,255,0.5);">' + escapeHtml(localVal) + '</td>';
html += '<td style="padding:14px 16px;color:var(--text-main);font-size:13px;font-family:var(--font-mono);background:rgba(255,255,255,0.5);">' + escapeHtml(remoteVal) + '</td>';
html += '<td style="padding:14px 16px;text-align:center;vertical-align:middle;">' + matchBadge + '</td>';
html += '</tr>';
}
html += '</tbody></table>';
html += '</div>';
// Add Confirm Match button
var patientPK = m.patient_pk || m.patient_ref;
html += '<div style="padding:16px 20px;border-top:1px solid var(--border-hard);text-align:right;background:var(--bg-panel);">';
html += '<button class="btn btn-primary" id="confirm-btn-' + i + '" onclick="confirmMatch(\'' + escapeHtml(patientFHIRID) + '\', \'' + escapeHtml(m.patient_ref) + '\', \'' + escapeHtml(patientPK) + '\', ' + i + ')" style="font-weight:600;">';
html += '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:text-bottom;margin-right:6px;"><polyline points="20 6 9 17 4 12"/></svg>';
html += 'Confirm Match';
html += '</button>';
html += '</div>';
html += '</div>';
}
html += '</div>';
container.innerHTML = html;
}
function confirmMatch(patientFHIRID, rimidiPatientRef, rimidiPatientPK, matchIndex) {
var btn = document.getElementById('confirm-btn-' + matchIndex);
if (!btn) return;
var originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span style="display:inline-block;width:14px;height:14px;border:2px solid #fff;border-top-color:transparent;border-radius:50%;animation:spin 0.6s linear infinite;margin-right:6px;"></span>Confirming...';
fetch('/api/patient-match/confirm?patient_id=' + encodeURIComponent(patientFHIRID), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rimidi_patient_ref: rimidiPatientRef,
rimidi_patient_pk: rimidiPatientPK
})
})
.then(function(resp) {
if (!resp.ok) {
return resp.json().then(function(d) { throw new Error(d.error || 'HTTP ' + resp.status); });
}
return resp.json();
})
.then(function(data) {
if (data.success) {
// Show success message
var body = document.getElementById('rimidiMatchBody');
body.innerHTML =
'<div style="padding:40px;text-align:center;">' +
'<div style="color:var(--color-success);font-size:48px;margin-bottom:16px;">✓</div>' +
'<h3 style="margin:0 0 8px 0;">Match Confirmed!</h3>' +
'<p style="color:var(--color-text-muted);margin-bottom:24px;">The patient match has been successfully confirmed.</p>' +
'<button class="btn btn-primary" onclick="closeRimidiMatch(); window.location.reload();">Close & Refresh</button>' +
'</div>';
} else {
throw new Error(data.message || 'Confirmation failed');
}
})
.catch(function(err) {
btn.disabled = false;
btn.innerHTML = originalText;
alert('Error confirming match: ' + err.message);
});
}
</script>
<!-- Rimidi Patient Match Modal Overlay -->
<div id="rimidiMatchOverlay" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,0.65);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);align-items:center;justify-content:center;">
<div style="background:#FFFFFF;border-radius:var(--radius-md);width:90%;max-width:800px;max-height:85vh;display:flex;flex-direction:column;box-shadow:0 25px 80px rgba(0,0,0,0.4),0 8px 16px rgba(0,0,0,0.2);border:1px solid var(--border-soft);overflow:hidden;">
<div style="display:flex;justify-content:space-between;align-items:center;padding:20px 24px;border-bottom:1px solid var(--border-hard);background:var(--bg-panel);">
<h3 style="margin:0;font-size:18px;font-weight:600;color:var(--text-main);display:flex;align-items:center;gap:8px;">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
Find Patient in Rimidi
</h3>
<button onclick="closeRimidiMatch()" style="background:none;border:none;font-size:28px;cursor:pointer;color:var(--text-muted);padding:0;width:32px;height:32px;display:flex;align-items:center;justify-content:center;border-radius:var(--radius-sm);transition:all 0.2s;" onmouseover="this.style.background='var(--bg-hover)';this.style.color='var(--text-main)';" onmouseout="this.style.background='none';this.style.color='var(--text-muted)';" title="Close">&times;</button>
</div>
<div id="rimidiMatchBody" style="overflow-y:auto;flex:1;background:var(--bg-panel);">
</div>
<div style="padding:16px 24px;border-top:1px solid var(--border-hard);text-align:right;background:var(--bg-panel);">
<button class="btn btn-secondary" onclick="closeRimidiMatch()">Close</button>
</div>
</div>
</div>
<!-- CGM Preview Modal Overlay -->
<div id="cgmPreviewOverlay" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,0.5);align-items:center;justify-content:center;">
<div style="background:var(--color-bg-main);border-radius:12px;width:95%;max-width:1200px;max-height:90vh;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.3);">
<div style="display:flex;justify-content:space-between;align-items:center;padding:16px 20px;border-bottom:1px solid var(--color-border-soft);">
<h3 style="margin:0;font-size:18px;">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:text-bottom;margin-right:6px;"><path d="M3 3v18h18"/><path d="M7 12h10M7 8h10M7 16h4"/></svg>
CGM Data from Rimidi
</h3>
<button onclick="closeCGMPreview()" style="background:none;border:none;font-size:24px;cursor:pointer;color:var(--color-text-muted);padding:0 4px;">&times;</button>
</div>
<div id="cgmPreviewBody" style="overflow-y:auto;flex:1;padding:20px;">
</div>
<div style="padding:12px 20px;border-top:1px solid var(--color-border-soft);text-align:right;">
<button class="btn btn-secondary" onclick="closeCGMPreview()">Close</button>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
<script>
/* ── CGM Preview Functions ──────────────────────────────────────── */
var cgmChart = null;
function loadCGMData(patientFHIRID) {
var container = document.getElementById('cgmTabContent');
if (!container) return;
// Mark as loading to prevent duplicate loads
if (container.dataset.loaded === 'loading') return;
container.dataset.loaded = 'loading';
container.innerHTML =
'<div style="text-align:center;padding:40px;">' +
'<div class="animate-spin" style="width:24px;height:24px;border:3px solid #ddd;border-top-color:#0d6efd;border-radius:50%;display:inline-block;"></div>' +
'<p style="margin-top:12px;color:var(--color-text-muted);">Loading CGM data…</p></div>';
var url = '/api/cgm-preview?patient_id=' + encodeURIComponent(patientFHIRID);
fetch(url, {
method: 'GET',
headers: { 'Content-Type': 'application/json' }
})
.then(function(resp) {
if (!resp.ok) {
return resp.json().then(function(d) { throw new Error(d.error || 'HTTP ' + resp.status); });
}
return resp.json();
})
.then(function(data) {
container.dataset.loaded = 'true';
renderCGMPreview(container, data);
})
.catch(function(err) {
container.dataset.loaded = 'error';
container.innerHTML =
'<div style="padding:20px;color:var(--color-danger);background:var(--color-danger-bg);border-radius:8px;margin:20px;">' +
'<strong>Error:</strong> ' + escapeHtml(err.message) + '</div>';
});
}
// Keep the old function for backward compatibility (in case it's called from elsewhere)
function loadCGMPreview(patientFHIRID) {
loadCGMData(patientFHIRID);
}
function closeCGMPreview() {
document.getElementById('cgmPreviewOverlay').style.display = 'none';
if (cgmChart) {
cgmChart.destroy();
cgmChart = null;
}
}
function unlinkPatient(patientFHIRID) {
if (!confirm('Are you sure you want to unlink this patient from Rimidi? This will remove the connection and you will need to match again to view CGM data.')) {
return;
}
var url = '/api/patient-match/unlink?patient_id=' + encodeURIComponent(patientFHIRID);
fetch(url, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' }
})
.then(function(resp) {
if (!resp.ok) {
return resp.json().then(function(d) { throw new Error(d.error || 'HTTP ' + resp.status); });
}
return resp.json();
})
.then(function(data) {
if (data.success) {
// Reload the page to update the UI
window.location.reload();
} else {
throw new Error(data.message || 'Unlink failed');
}
})
.catch(function(err) {
alert('Error unlinking patient: ' + err.message);
});
}
function renderCGMPreview(container, data) {
var html = '<div style="display:flex;flex-direction:column;gap:32px;">';
// Key Metrics - Modern Grid Layout
if (data.metrics) {
html += '<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(180px, 1fr));gap:20px;margin-bottom:8px;">';
if (data.metrics.average_glucose !== undefined) {
html += '<div style="padding:20px;background:linear-gradient(135deg, #818cf8 0%, #a78bfa 100%);border-radius:12px;color:white;box-shadow:0 4px 12px rgba(129,140,248,0.2);">';
html += '<div style="font-size:12px;opacity:0.9;margin-bottom:8px;text-transform:uppercase;letter-spacing:0.5px;">Average Glucose</div>';
html += '<div style="font-size:32px;font-weight:700;line-height:1.2;">' + escapeHtml(data.metrics.average_glucose.toFixed(1)) + '<span style="font-size:18px;font-weight:400;opacity:0.9;"> mg/dL</span></div>';
html += '</div>';
}
if (data.metrics.gmi !== undefined) {
html += '<div style="padding:20px;background:linear-gradient(135deg, #f9a8d4 0%, #fb7185 100%);border-radius:12px;color:white;box-shadow:0 4px 12px rgba(249,168,212,0.2);">';
html += '<div style="font-size:12px;opacity:0.9;margin-bottom:8px;text-transform:uppercase;letter-spacing:0.5px;">GMI</div>';
html += '<div style="font-size:32px;font-weight:700;line-height:1.2;">' + escapeHtml(data.metrics.gmi.toFixed(1)) + '<span style="font-size:18px;font-weight:400;opacity:0.9;">%</span></div>';
html += '</div>';
}
if (data.metrics.time_in_range) {
var tir = data.metrics.time_in_range;
html += '<div style="padding:20px;background:linear-gradient(135deg, #60a5fa 0%, #38bdf8 100%);border-radius:12px;color:white;box-shadow:0 4px 12px rgba(96,165,250,0.2);">';
html += '<div style="font-size:12px;opacity:0.9;margin-bottom:8px;text-transform:uppercase;letter-spacing:0.5px;">Time in Range</div>';
html += '<div style="font-size:32px;font-weight:700;line-height:1.2;">' + escapeHtml((tir.target || 0).toFixed(1)) + '<span style="font-size:18px;font-weight:400;opacity:0.9;">%</span></div>';
html += '</div>';
}
if (data.gri_score && data.gri_score !== '-' && data.gri_score !== 'NA') {
html += '<div style="padding:20px;background:linear-gradient(135deg, #fb9ab8 0%, #fde68a 100%);border-radius:12px;color:white;box-shadow:0 4px 12px rgba(251,154,184,0.2);">';
html += '<div style="font-size:12px;opacity:0.9;margin-bottom:8px;text-transform:uppercase;letter-spacing:0.5px;">GRI Score</div>';
html += '<div style="font-size:32px;font-weight:700;line-height:1.2;">' + escapeHtml(data.gri_score) + '</div>';
html += '</div>';
}
html += '</div>';
// Device Info - Inline with subtle styling
if (data.device_info) {
html += '<div style="display:flex;align-items:center;gap:16px;padding:16px 20px;background:var(--bg-panel);border-radius:8px;border:1px solid var(--color-border-soft);">';
html += '<div style="flex:1;">';
html += '<div style="font-size:12px;color:var(--color-text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px;">Device</div>';
html += '<div style="font-size:16px;font-weight:600;color:var(--text-main);">' + escapeHtml(data.device_info.type || 'Unknown') + '</div>';
html += '</div>';
html += '<div style="padding-left:20px;border-left:1px solid var(--color-border-soft);">';
html += '<div style="font-size:12px;color:var(--color-text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px;">Status</div>';
html += '<div style="display:flex;align-items:center;gap:6px;">';
var statusColor = data.device_info.enabled ? 'var(--color-success)' : 'var(--color-text-muted)';
html += '<span style="width:8px;height:8px;border-radius:50%;background:' + statusColor + ';"></span>';
html += '<span style="font-size:14px;font-weight:500;color:var(--text-main);">' + (data.device_info.enabled ? 'Active' : 'Inactive') + '</span>';
html += '</div>';
html += '</div>';
html += '</div>';
}
// Time in Range Breakdown - Modern Visual Bars
if (data.metrics.time_in_range) {
var tir = data.metrics.time_in_range;
html += '<div style="padding:24px;background:var(--bg-panel);border-radius:12px;border:1px solid var(--color-border-soft);">';
html += '<h3 style="margin:0 0 20px 0;font-size:16px;font-weight:600;color:var(--text-main);">Time in Range Breakdown</h3>';
html += '<div style="display:flex;flex-direction:column;gap:12px;">';
// Very Low
if (tir.very_low !== undefined && tir.very_low > 0) {
html += '<div>';
html += '<div style="display:flex;justify-content:space-between;margin-bottom:6px;">';
html += '<span style="font-size:13px;font-weight:500;color:#dc2626;">Very Low (&lt;54)</span>';
html += '<span style="font-size:13px;font-weight:600;color:var(--text-main);">' + (tir.very_low || 0).toFixed(1) + '%</span>';
html += '</div>';
html += '<div style="height:8px;background:#fee2e2;border-radius:4px;overflow:hidden;">';
html += '<div style="height:100%;width:' + (tir.very_low || 0) + '%;background:#dc2626;transition:width 0.3s ease;"></div>';
html += '</div>';
html += '</div>';
}
// Low
if (tir.low !== undefined && tir.low > 0) {
html += '<div>';
html += '<div style="display:flex;justify-content:space-between;margin-bottom:6px;">';
html += '<span style="font-size:13px;font-weight:500;color:#f59e0b;">Low (54-70)</span>';
html += '<span style="font-size:13px;font-weight:600;color:var(--text-main);">' + (tir.low || 0).toFixed(1) + '%</span>';
html += '</div>';
html += '<div style="height:8px;background:#fef3c7;border-radius:4px;overflow:hidden;">';
html += '<div style="height:100%;width:' + (tir.low || 0) + '%;background:#f59e0b;transition:width 0.3s ease;"></div>';
html += '</div>';
html += '</div>';
}
// Target
if (tir.target !== undefined && tir.target > 0) {
html += '<div>';
html += '<div style="display:flex;justify-content:space-between;margin-bottom:6px;">';
html += '<span style="font-size:13px;font-weight:500;color:#10b981;">Target (70-180)</span>';
html += '<span style="font-size:13px;font-weight:600;color:var(--text-main);">' + (tir.target || 0).toFixed(1) + '%</span>';
html += '</div>';
html += '<div style="height:8px;background:#d1fae5;border-radius:4px;overflow:hidden;">';
html += '<div style="height:100%;width:' + (tir.target || 0) + '%;background:#10b981;transition:width 0.3s ease;"></div>';
html += '</div>';
html += '</div>';
}
// High
if (tir.high !== undefined && tir.high > 0) {
html += '<div>';
html += '<div style="display:flex;justify-content:space-between;margin-bottom:6px;">';
html += '<span style="font-size:13px;font-weight:500;color:#f59e0b;">High (180-250)</span>';
html += '<span style="font-size:13px;font-weight:600;color:var(--text-main);">' + (tir.high || 0).toFixed(1) + '%</span>';
html += '</div>';
html += '<div style="height:8px;background:#fef3c7;border-radius:4px;overflow:hidden;">';
html += '<div style="height:100%;width:' + (tir.high || 0) + '%;background:#f59e0b;transition:width 0.3s ease;"></div>';
html += '</div>';
html += '</div>';
}
// Very High
if (tir.very_high !== undefined && tir.very_high > 0) {
html += '<div>';
html += '<div style="display:flex;justify-content:space-between;margin-bottom:6px;">';
html += '<span style="font-size:13px;font-weight:500;color:#dc2626;">Very High (&gt;250)</span>';
html += '<span style="font-size:13px;font-weight:600;color:var(--text-main);">' + (tir.very_high || 0).toFixed(1) + '%</span>';
html += '</div>';
html += '<div style="height:8px;background:#fee2e2;border-radius:4px;overflow:hidden;">';
html += '<div style="height:100%;width:' + (tir.very_high || 0) + '%;background:#dc2626;transition:width 0.3s ease;"></div>';
html += '</div>';
html += '</div>';
}
html += '</div>';
html += '</div>';
}
}
// Chart
if (data.chart_data && data.chart_data.length > 0) {
html += '<div style="padding:24px;background:var(--bg-panel);border-radius:12px;border:1px solid var(--color-border-soft);">';
html += '<h3 style="margin:0 0 20px 0;font-size:16px;font-weight:600;color:var(--text-main);">Glucose Trends (Last 14 Days)</h3>';
html += '<div style="position:relative;height:350px;">';
html += '<canvas id="cgmChart"></canvas>';
html += '</div>';
html += '</div>';
}
// Reports - Carousel Format
if (data.reports && data.reports.length > 0) {
html += '<div style="padding:24px;background:var(--bg-panel);border-radius:12px;border:1px solid var(--color-border-soft);">';
html += '<h3 style="margin:0 0 24px 0;font-size:16px;font-weight:600;color:var(--text-main);">Reports (' + data.reports.length + ')</h3>';
// Carousel Container
html += '<div id="cgmReportsCarousel" style="position:relative;width:100%;overflow:hidden;">';
// Carousel Wrapper
html += '<div id="cgmReportsCarouselWrapper" style="display:flex;width:' + (data.reports.length * 100) + '%;transition:transform 0.3s ease-in-out;">';
// Generate report slides
for (var i = 0; i < data.reports.length; i++) {
var report = data.reports[i];
var reportType = report.type || 'Unknown';
var reportTypeLabel = reportType.charAt(0).toUpperCase() + reportType.slice(1);
var dateRange = '';
if (report.start_date && report.end_date) {
dateRange = report.start_date + ' - ' + report.end_date;
} else if (report.start_date) {
dateRange = report.start_date;
} else if (report.end_date) {
dateRange = report.end_date;
}
// Report Slide - each slide is 100% of container width
var slideWidthPercent = 100 / data.reports.length;
html += '<div class="cgm-report-slide" data-slide-index="' + i + '" style="width:' + slideWidthPercent + '%;flex-shrink:0;display:flex;flex-direction:column;padding:24px;box-sizing:border-box;">';
// Report Header
html += '<div style="margin-bottom:16px;">';
html += '<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;">';
html += '<span style="font-weight:600;font-size:18px;">' + escapeHtml(reportTypeLabel) + ' Report</span>';
html += '</div>';
if (dateRange) {
html += '<span style="color:var(--color-text-muted);font-size:14px;">' + escapeHtml(dateRange) + '</span>';
}
html += '</div>';
// Preview Image
if (report.image_url) {
html += '<div style="flex:1;display:flex;align-items:center;justify-content:center;margin-bottom:20px;background:#f8f9fa;border-radius:8px;padding:16px;min-height:400px;">';
html += '<img src="' + escapeHtml(report.image_url) + '" alt="' + escapeHtml(reportTypeLabel) + ' Report Preview" style="max-width:100%;max-height:500px;object-fit:contain;border-radius:4px;box-shadow:0 2px 8px rgba(0,0,0,0.1);">';
html += '</div>';
} else {
html += '<div style="flex:1;display:flex;align-items:center;justify-content:center;margin-bottom:20px;background:#f8f9fa;border-radius:8px;padding:16px;min-height:400px;color:var(--color-text-muted);">';
html += '<p>No preview image available</p>';
html += '</div>';
}
// PDF Download Link
if (report.pdf_url) {
html += '<div style="text-align:center;padding-top:12px;border-top:1px solid var(--color-border-soft);">';
html += '<a href="' + escapeHtml(report.pdf_url) + '" target="_blank" class="btn btn-primary" style="text-decoration:none;display:inline-block;">';
html += '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:text-bottom;margin-right:6px;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
html += 'Download PDF';
html += '</a>';
html += '</div>';
}
html += '</div>'; // End report slide
}
html += '</div>'; // End carousel wrapper
// Navigation Arrows
if (data.reports.length > 1) {
html += '<button id="cgmCarouselPrev" onclick="navigateCGMCarousel(-1)" style="position:absolute;left:10px;top:50%;transform:translateY(-50%);background:rgba(255,255,255,0.9);border:1px solid var(--color-border-soft);border-radius:50%;width:40px;height:40px;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,0.1);z-index:10;transition:all 0.2s;" onmouseover="this.style.background=\'white\';this.style.boxShadow=\'0 4px 12px rgba(0,0,0,0.15)\';" onmouseout="this.style.background=\'rgba(255,255,255,0.9)\';this.style.boxShadow=\'0 2px 8px rgba(0,0,0,0.1)\';">';
html += '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>';
html += '</button>';
html += '<button id="cgmCarouselNext" onclick="navigateCGMCarousel(1)" style="position:absolute;right:10px;top:50%;transform:translateY(-50%);background:rgba(255,255,255,0.9);border:1px solid var(--color-border-soft);border-radius:50%;width:40px;height:40px;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,0.1);z-index:10;transition:all 0.2s;" onmouseover="this.style.background=\'white\';this.style.boxShadow=\'0 4px 12px rgba(0,0,0,0.15)\';" onmouseout="this.style.background=\'rgba(255,255,255,0.9)\';this.style.boxShadow=\'0 2px 8px rgba(0,0,0,0.1)\';">';
html += '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>';
html += '</button>';
}
// Pagination Dots
if (data.reports.length > 1) {
html += '<div style="display:flex;justify-content:center;gap:8px;margin-top:16px;">';
for (var i = 0; i < data.reports.length; i++) {
var activeClass = i === 0 ? 'active' : '';
html += '<button class="cgm-carousel-dot ' + activeClass + '" onclick="goToCGMReport(' + i + ')" data-dot-index="' + i + '" style="width:10px;height:10px;border-radius:50%;border:none;background:' + (i === 0 ? 'var(--color-brand)' : 'var(--color-border-soft)') + ';cursor:pointer;transition:all 0.2s;padding:0;"></button>';
}
html += '</div>';
}
html += '</div>'; // End carousel container
html += '</div>'; // End panel
}
html += '</div>';
container.innerHTML = html;
// Initialize carousel after rendering (with delay to ensure DOM is ready)
setTimeout(function() {
initCGMCarousel();
// Handle window resize to recalculate carousel
var resizeTimeout;
window.addEventListener('resize', function() {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(function() {
updateCGMCarouselPosition();
}, 250);
});
}, 300);
// Render chart if data exists
if (data.chart_data && data.chart_data.length > 0 && typeof Chart !== 'undefined') {
setTimeout(function() {
renderCGMChart(data.chart_data, data.thresholds);
}, 100);
}
}
function renderCGMChart(chartData, thresholds) {
var ctx = document.getElementById('cgmChart');
if (!ctx) return;
// Destroy existing chart
if (cgmChart) {
cgmChart.destroy();
}
// Prepare data
var labels = [];
var values = [];
for (var i = 0; i < chartData.length; i++) {
var point = chartData[i];
labels.push(point.timestamp);
values.push(point.value);
}
// Thresholds
var lowThreshold = (thresholds && thresholds.low) || 70;
var highThreshold = (thresholds && thresholds.high) || 180;
cgmChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Glucose (mg/dL)',
data: values,
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.1,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: false,
suggestedMin: 50,
suggestedMax: 300
},
x: {
type: 'time',
time: {
unit: 'day'
}
}
},
plugins: {
annotation: {
annotations: {
lowLine: {
type: 'line',
yMin: lowThreshold,
yMax: lowThreshold,
borderColor: 'rgb(239, 68, 68)',
borderWidth: 2,
borderDash: [5, 5],
label: {
content: 'Low: ' + lowThreshold + ' mg/dL',
enabled: true
}
},
highLine: {
type: 'line',
yMin: highThreshold,
yMax: highThreshold,
borderColor: 'rgb(239, 68, 68)',
borderWidth: 2,
borderDash: [5, 5],
label: {
content: 'High: ' + highThreshold + ' mg/dL',
enabled: true
}
}
}
}
}
}
});
}
/* ── CGM Reports Carousel Navigation ──────────────────────────────────────── */
var cgmCarouselCurrentIndex = 0;
var cgmCarouselTotalSlides = 0;
function initCGMCarousel() {
var wrapper = document.getElementById('cgmReportsCarouselWrapper');
if (!wrapper) {
console.log('CGM Carousel: Wrapper not found');
return;
}
var slides = wrapper.querySelectorAll('.cgm-report-slide');
cgmCarouselTotalSlides = slides.length;
cgmCarouselCurrentIndex = 0;
console.log('CGM Carousel: Initialized with', cgmCarouselTotalSlides, 'slides');
if (cgmCarouselTotalSlides > 0) {
updateCGMCarouselPosition();
updateCGMCarouselDots();
}
}
function navigateCGMCarousel(direction) {
var wrapper = document.getElementById('cgmReportsCarouselWrapper');
if (!wrapper) return;
var slides = wrapper.querySelectorAll('.cgm-report-slide');
if (slides.length === 0) return;
cgmCarouselCurrentIndex += direction;
// Wrap around
if (cgmCarouselCurrentIndex < 0) {
cgmCarouselCurrentIndex = slides.length - 1;
} else if (cgmCarouselCurrentIndex >= slides.length) {
cgmCarouselCurrentIndex = 0;
}
updateCGMCarouselPosition();
updateCGMCarouselDots();
}
function goToCGMReport(index) {
var wrapper = document.getElementById('cgmReportsCarouselWrapper');
if (!wrapper) return;
var slides = wrapper.querySelectorAll('.cgm-report-slide');
if (index < 0 || index >= slides.length) return;
cgmCarouselCurrentIndex = index;
updateCGMCarouselPosition();
updateCGMCarouselDots();
}
function updateCGMCarouselPosition() {
var wrapper = document.getElementById('cgmReportsCarouselWrapper');
if (!wrapper) {
console.log('CGM Carousel: Wrapper not found in updateCGMCarouselPosition');
return;
}
var slides = wrapper.querySelectorAll('.cgm-report-slide');
if (slides.length === 0) {
console.log('CGM Carousel: No slides found');
return;
}
// Get the container (parent of wrapper) to calculate pixel offset
var container = wrapper.parentElement;
if (!container) {
console.log('CGM Carousel: Container not found');
return;
}
var containerWidth = container.offsetWidth || container.clientWidth;
if (containerWidth === 0) {
console.log('CGM Carousel: Container width is 0, retrying...');
setTimeout(function() {
updateCGMCarouselPosition();
}, 100);
return;
}
// Calculate offset in pixels: move by container width * index
// Since each slide is 100% of container width, we move by container width for each slide
var offsetPixels = -cgmCarouselCurrentIndex * containerWidth;
wrapper.style.transform = 'translateX(' + offsetPixels + 'px)';
console.log('CGM Carousel: Moved to slide', cgmCarouselCurrentIndex, 'offset:', offsetPixels + 'px', 'container width:', containerWidth);
}
function updateCGMCarouselDots() {
var dots = document.querySelectorAll('.cgm-carousel-dot');
for (var i = 0; i < dots.length; i++) {
if (i === cgmCarouselCurrentIndex) {
dots[i].style.background = 'var(--color-brand)';
dots[i].classList.add('active');
} else {
dots[i].style.background = 'var(--color-border-soft)';
dots[i].classList.remove('active');
}
}
}
</script>
{{end}}