|
193 | 193 | // ─── Fetch & Render ──────────────────────────────────────────── |
194 | 194 | let manifestData = null; |
195 | 195 | let fallbackInpatientPatternByPatient = null; |
| 196 | + let patientSortDirection = 'asc'; |
| 197 | +
|
| 198 | + function comparePatientIds(left, right) { |
| 199 | + return String(left).localeCompare(String(right), undefined, { |
| 200 | + numeric: true, |
| 201 | + sensitivity: 'base' |
| 202 | + }); |
| 203 | + } |
| 204 | +
|
| 205 | + function sortPatientEntries(entries) { |
| 206 | + const direction = patientSortDirection === 'desc' ? -1 : 1; |
| 207 | +
|
| 208 | + return [...entries].sort(([leftId], [rightId]) => |
| 209 | + direction * comparePatientIds(leftId, rightId) |
| 210 | + ); |
| 211 | + } |
| 212 | +
|
| 213 | + function patientSortIcon() { |
| 214 | + return patientSortDirection === 'asc' |
| 215 | + ? '<i class="bi bi-sort-up ms-1"></i>' |
| 216 | + : '<i class="bi bi-sort-down ms-1"></i>'; |
| 217 | + } |
| 218 | +
|
| 219 | + function togglePatientSort() { |
| 220 | + patientSortDirection = |
| 221 | + patientSortDirection === 'asc' ? 'desc' : 'asc'; |
| 222 | +
|
| 223 | + if (!manifestData) return; |
| 224 | +
|
| 225 | + renderEligibility(manifestData); |
| 226 | + if (allPatientEntries.length > 0) { |
| 227 | + renderPatientBreakdown(); |
| 228 | + } |
| 229 | + } |
196 | 230 |
|
197 | 231 | fetch(`${manifestDataUrl}?id=${runId}`) |
198 | 232 | .then(r => r.ok && r.status !== 204 ? r.json() : null) |
|
257 | 291 | <div><div class="small text-muted mb-1">CQL Referenced</div>${renderBadgeSet(m.cqlReferencedResourceTypes)}</div>`; |
258 | 292 |
|
259 | 293 | // Eligibility |
260 | | - const el = document.getElementById('eligibilitySection'); |
261 | | - const eb = document.getElementById('eligibilityBadge'); |
262 | | - if (m.patientEligibility && Object.keys(m.patientEligibility).length) { |
263 | | - const entries = Object.entries(m.patientEligibility); |
264 | | - const q = entries.filter(([, ms]) => ms.length > 0).length; |
265 | | - eb.textContent = `${q}/${entries.length} qualifying`; |
266 | | - let html = '<div style="max-height:300px;overflow-y:auto;"><table class="au-table table table-sm small mb-0"><thead><tr><th>Patient</th><th>Qualifying Measures</th></tr></thead><tbody>'; |
267 | | - entries.forEach(([pid, measures]) => { |
268 | | - const b = measures.length > 0 |
269 | | - ? `<span class="badge au-badge-success">${measures.length} measure(s)</span>` |
270 | | - : `<span class="badge au-badge-muted">None</span>`; |
271 | | - html += `<tr><td><code>${escapeHtml(pid)}</code></td><td>${b}</td></tr>`; |
272 | | - }); |
273 | | - html += '</tbody></table></div>'; |
274 | | - el.innerHTML = html; |
275 | | - } else { |
276 | | - el.innerHTML = '<span class="text-muted small">No eligibility data</span>'; |
277 | | - } |
| 294 | + renderEligibility(m); |
278 | 295 |
|
279 | 296 | // Resource counts table |
280 | 297 | const allCounts = { ...(m.totalCountsByType || {}) }; |
|
320 | 337 | } |
321 | 338 | } |
322 | 339 |
|
| 340 | + function renderEligibility(m) { |
| 341 | + const el = document.getElementById('eligibilitySection'); |
| 342 | + const eb = document.getElementById('eligibilityBadge'); |
| 343 | +
|
| 344 | + if (!m.patientEligibility || |
| 345 | + !Object.keys(m.patientEligibility).length) { |
| 346 | + eb.textContent = ''; |
| 347 | + el.innerHTML = |
| 348 | + '<span class="text-muted small">No eligibility data</span>'; |
| 349 | + return; |
| 350 | + } |
| 351 | +
|
| 352 | + const entries = sortPatientEntries( |
| 353 | + Object.entries(m.patientEligibility) |
| 354 | + ); |
| 355 | +
|
| 356 | + const qualifyingCount = entries.filter( |
| 357 | + ([, measures]) => measures.length > 0 |
| 358 | + ).length; |
| 359 | +
|
| 360 | + eb.textContent = |
| 361 | + `${qualifyingCount}/${entries.length} qualifying`; |
| 362 | +
|
| 363 | + let html = ` |
| 364 | + <div style="max-height:300px;overflow-y:auto;"> |
| 365 | + <table class="au-table table table-sm small mb-0"> |
| 366 | + <thead> |
| 367 | + <tr> |
| 368 | + <th> |
| 369 | + <button type="button" |
| 370 | + class="btn btn-link btn-sm p-0 text-white text-decoration-none patient-sort-button" |
| 371 | + aria-label="Sort patient IDs ${patientSortDirection === 'asc' ? 'descending' : 'ascending'}"> |
| 372 | + Patient |
| 373 | + ${patientSortIcon()} |
| 374 | + </button> |
| 375 | + </th> |
| 376 | + <th>Qualifying Measures</th> |
| 377 | + </tr> |
| 378 | + </thead> |
| 379 | + <tbody>`; |
| 380 | +
|
| 381 | + entries.forEach(([pid, measures]) => { |
| 382 | + const badge = measures.length > 0 |
| 383 | + ? `<span class="badge au-badge-success">${measures.length} measure(s)</span>` |
| 384 | + : '<span class="badge au-badge-muted">None</span>'; |
| 385 | +
|
| 386 | + html += ` |
| 387 | + <tr> |
| 388 | + <td><code>${escapeHtml(pid)}</code></td> |
| 389 | + <td>${badge}</td> |
| 390 | + </tr>`; |
| 391 | + }); |
| 392 | +
|
| 393 | + html += '</tbody></table></div>'; |
| 394 | + el.innerHTML = html; |
| 395 | +
|
| 396 | + el.querySelector('.patient-sort-button') |
| 397 | + ?.addEventListener('click', togglePatientSort); |
| 398 | + } |
| 399 | +
|
323 | 400 | // ─── PATIENTS TAB ────────────────────────────────────────────── |
| 401 | + let allPatientEntries = []; |
| 402 | + let patientBarChart = null; |
| 403 | +
|
324 | 404 | function renderPatients(m) { |
325 | | - const container = document.getElementById('patientBreakdownContainer'); |
326 | | - const patients = Object.entries(m.resourceCountsByPatient || {}); |
327 | | - if (!patients.length) { |
328 | | - container.innerHTML = '<span class="text-muted small">No per-patient data</span>'; |
| 405 | + allPatientEntries = Object.entries( |
| 406 | + m.resourceCountsByPatient || {} |
| 407 | + ); |
| 408 | +
|
| 409 | + const container = |
| 410 | + document.getElementById('patientBreakdownContainer'); |
| 411 | +
|
| 412 | + if (!allPatientEntries.length) { |
| 413 | + container.innerHTML = |
| 414 | + '<span class="text-muted small">No per-patient data</span>'; |
329 | 415 | return; |
330 | 416 | } |
331 | 417 |
|
332 | | - fallbackInpatientPatternByPatient = buildFallbackInpatientPatternMap(m); |
| 418 | + fallbackInpatientPatternByPatient = |
| 419 | + buildFallbackInpatientPatternMap(m); |
| 420 | +
|
| 421 | + renderPatientBreakdown(); |
| 422 | +
|
| 423 | + const searchInput = document.getElementById('patientSearch'); |
| 424 | +
|
| 425 | + searchInput.addEventListener('input', function () { |
| 426 | + renderPatientBreakdown(); |
| 427 | + }); |
| 428 | + } |
| 429 | +
|
| 430 | + function getVisiblePatientEntries() { |
| 431 | + const searchTerm = |
| 432 | + document.getElementById('patientSearch') |
| 433 | + ?.value |
| 434 | + ?.trim() |
| 435 | + ?.toLowerCase() || ''; |
| 436 | +
|
| 437 | + const filtered = allPatientEntries.filter(([pid]) => |
| 438 | + !searchTerm || pid.toLowerCase().includes(searchTerm) |
| 439 | + ); |
| 440 | +
|
| 441 | + return sortPatientEntries(filtered); |
| 442 | + } |
| 443 | +
|
| 444 | + function renderPatientBreakdown() { |
| 445 | + const patients = getVisiblePatientEntries(); |
333 | 446 |
|
334 | | - // Bar chart: total resources per patient (top 50) |
335 | | - const patientTotals = patients.map(([pid, counts]) => ({ |
336 | | - pid, |
337 | | - total: Object.values(counts).reduce((s, c) => s + c, 0) |
338 | | - })).sort((a, b) => b.total - a.total); |
| 447 | + renderPatientBarChart(patients); |
| 448 | + renderPatientCards(patients); |
| 449 | + } |
| 450 | +
|
| 451 | + function renderPatientBarChart(patients) { |
| 452 | + const patientTotals = patients |
| 453 | + .map(([pid, counts]) => ({ |
| 454 | + pid, |
| 455 | + total: Object.values(counts) |
| 456 | + .reduce((sum, count) => sum + count, 0) |
| 457 | + })) |
| 458 | + .slice(0, 50); |
| 459 | +
|
| 460 | + const chartCanvas = |
| 461 | + document.getElementById('patientBarChart'); |
339 | 462 |
|
340 | | - const chartPatients = patientTotals.slice(0, 50); |
341 | | - new Chart(document.getElementById('patientBarChart'), { |
| 463 | + if (patientBarChart) { |
| 464 | + patientBarChart.destroy(); |
| 465 | + } |
| 466 | +
|
| 467 | + patientBarChart = new Chart(chartCanvas, { |
342 | 468 | type: 'bar', |
343 | 469 | data: { |
344 | | - labels: chartPatients.map(p => p.pid), |
| 470 | + labels: patientTotals.map(patient => patient.pid), |
345 | 471 | datasets: [{ |
346 | 472 | label: 'Total Resources', |
347 | | - data: chartPatients.map(p => p.total), |
| 473 | + data: patientTotals.map(patient => patient.total), |
348 | 474 | backgroundColor: 'var(--au-accent)', |
349 | 475 | borderRadius: 3 |
350 | 476 | }] |
|
353 | 479 | responsive: true, |
354 | 480 | maintainAspectRatio: true, |
355 | 481 | plugins: { |
356 | | - legend: { display: false }, |
357 | | - tooltip: { callbacks: { label: ctx => `${ctx.parsed.y.toLocaleString()} resources` } } |
| 482 | + legend: { |
| 483 | + display: false |
| 484 | + }, |
| 485 | + tooltip: { |
| 486 | + callbacks: { |
| 487 | + label: context => |
| 488 | + `${context.parsed.y.toLocaleString()} resources` |
| 489 | + } |
| 490 | + } |
358 | 491 | }, |
359 | 492 | scales: { |
360 | | - x: { ticks: { font: { size: 10 }, maxRotation: 45 } }, |
361 | | - y: { beginAtZero: true, ticks: { callback: v => v.toLocaleString() } } |
| 493 | + x: { |
| 494 | + ticks: { |
| 495 | + font: { size: 10 }, |
| 496 | + maxRotation: 45 |
| 497 | + } |
| 498 | + }, |
| 499 | + y: { |
| 500 | + beginAtZero: true, |
| 501 | + ticks: { |
| 502 | + callback: value => value.toLocaleString() |
| 503 | + } |
| 504 | + } |
362 | 505 | } |
363 | 506 | } |
364 | 507 | }); |
365 | | -
|
366 | | - // Patient cards |
367 | | - renderPatientCards(patients); |
368 | | -
|
369 | | - // Search |
370 | | - document.getElementById('patientSearch').addEventListener('input', function () { |
371 | | - const q = this.value.toLowerCase(); |
372 | | - const filtered = patients.filter(([pid]) => pid.toLowerCase().includes(q)); |
373 | | - renderPatientCards(filtered); |
374 | | - }); |
375 | 508 | } |
376 | 509 |
|
377 | 510 | function renderPatientCards(patients) { |
378 | 511 | const container = document.getElementById('patientBreakdownContainer'); |
| 512 | + disposeTooltips(container); |
379 | 513 | if (!patients.length) { |
380 | 514 | container.innerHTML = '<span class="text-muted small">No matching patients</span>'; |
381 | 515 | return; |
382 | 516 | } |
383 | 517 |
|
384 | 518 | // Compact table view for many patients |
385 | | - let html = '<table class="au-table table table-sm small mb-0"><thead><tr><th>Patient</th><th>Inpatient Pattern</th><th class="text-end">Total</th><th>Resource Types</th></tr></thead><tbody>'; |
| 519 | + let html = ` |
| 520 | + <table class="au-table table table-sm small mb-0"> |
| 521 | + <thead> |
| 522 | + <tr> |
| 523 | + <th> |
| 524 | + <button type="button" |
| 525 | + class="btn btn-link btn-sm p-0 text-white text-decoration-none patient-sort-button" |
| 526 | + aria-label="Sort patient IDs ${patientSortDirection === 'asc' ? 'descending' : 'ascending'}"> |
| 527 | + Patient |
| 528 | + ${patientSortIcon()} |
| 529 | + </button> |
| 530 | + </th> |
| 531 | + <th>Inpatient Pattern</th> |
| 532 | + <th class="text-end">Total</th> |
| 533 | + <th>Resource Types</th> |
| 534 | + </tr> |
| 535 | + </thead> |
| 536 | + <tbody>`; |
386 | 537 | patients.forEach(([pid, counts]) => { |
387 | 538 | const total = Object.values(counts).reduce((s, c) => s + c, 0); |
388 | 539 | const inpatientPattern = manifestData?.patientInpatientPatterns?.[pid] |
|
400 | 551 | }); |
401 | 552 | html += '</tbody></table>'; |
402 | 553 | container.innerHTML = html; |
| 554 | + container.querySelector('.patient-sort-button') |
| 555 | + ?.addEventListener('click', togglePatientSort); |
403 | 556 | // Re-init tooltips for new elements |
404 | 557 | container.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => new bootstrap.Tooltip(el)); |
405 | 558 | } |
406 | 559 |
|
| 560 | + function disposeTooltips(container) { |
| 561 | + container |
| 562 | + .querySelectorAll('[data-bs-toggle="tooltip"]') |
| 563 | + .forEach(el => { |
| 564 | + bootstrap.Tooltip.getInstance(el)?.dispose(); |
| 565 | + }); |
| 566 | + } |
| 567 | +
|
407 | 568 | function normalizePatternForDisplay(value) { |
408 | 569 | if (value === null || value === undefined || value === '') return defaultScheduledPattern; |
409 | 570 | const key = String(value).trim(); |
|
0 commit comments