From ce0ea66994fe1a7f8749b5f52fa8b78a1ed38620 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 7 Aug 2026 23:15:51 -0400 Subject: [PATCH] Charts: click a segment to filter the dashboard (#22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three charts (priority donut, status donut, category bar) now navigate to the same URL filters the stat cards already use, with a pointer cursor on hover, a title hint, and "click to filter" in the tooltip. The status each click applies is explicit rather than left to the default. With no `status` param the controller falls back to the viewer's default_status_filters preference, which can be anything, so the list would not necessarily match what the chart counted. StatsModel builds by_priority and by_category with `status != 'Closed'` while by_status spans every status, so only the priority and category charts pin the open set; the status chart filters on the clicked status alone (which is how clicking "Closed" works at all). Verified two ways: - 17/17 in headless chromium, driving the real chart script from this view with the Chart constructor stubbed, asserting the exact query each click produces and that a click hitting no segment navigates nowhere. - Against the live database, every segment's count equals the number of tickets its filter returns — 12/12 across all three charts — so the list you land on matches the number you clicked. --- views/DashboardView.php | 67 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/views/DashboardView.php b/views/DashboardView.php index af6895d..d210aa2 100644 --- a/views/DashboardView.php +++ b/views/DashboardView.php @@ -277,9 +277,64 @@ include __DIR__ . '/layout_header.php'; array_values($stats['by_category'] ?? []) ))) ?>; + // ── Click-to-filter ──────────────────────────────────────────────────────── + // Charts navigate to the same URL filters the stat cards use. + // + // The status the click filters on has to be explicit rather than left to the + // default: with no `status` param the controller falls back to the viewer's + // default_status_filters preference, which can be anything, so the resulting + // list would not necessarily match what the chart counted. StatsModel builds + // by_priority and by_category with `status != 'Closed'`, while by_status spans + // every status — so only the priority and category charts pin the open set. + function openStatuses() { + var all = window.TICKET_STATUSES || ['Open', 'Pending', 'In Progress', 'Closed']; + return all.filter(function(s) { return s !== 'Closed'; }).join(','); + } + + function gotoFilter(params) { + var qs = new URLSearchParams(); + Object.keys(params).forEach(function(k) { + if (params[k] !== null && params[k] !== undefined && params[k] !== '') qs.set(k, params[k]); + }); + window.location.href = '/?' + qs.toString(); + } + + // Each chart maps a clicked label to a filter. Returns null when the label + // can't be mapped, so the click is simply ignored. + var CHART_FILTERS = { + chartPriority: function(label) { + var m = /^P(\d+)$/.exec(label); + return m ? { priority: m[1], status: openStatuses() } : null; + }, + chartStatus: function(label) { + return label ? { status: label } : null; + }, + chartCategory: function(label) { + return label ? { category: label, status: openStatuses() } : null; + } + }; + + function filterOnClick(canvasId) { + return function(evt, elements, chart) { + if (!elements || !elements.length) return; + var label = chart.data.labels[elements[0].index]; + var mapper = CHART_FILTERS[canvasId]; + var params = mapper && mapper(label); + if (params) gotoFilter(params); + }; + } + + // Pointer cursor over clickable segments so the affordance is visible. + function filterOnHover(evt, elements) { + if (evt && evt.native && evt.native.target) { + evt.native.target.style.cursor = (elements && elements.length) ? 'pointer' : 'default'; + } + } + function makeDonut(canvasId, data, colorMap) { var ctx = document.getElementById(canvasId); if (!ctx || !data.length) return; + ctx.title = 'Click a segment to filter the ticket list'; return new Chart(ctx, { type: 'doughnut', data: { @@ -295,12 +350,14 @@ include __DIR__ . '/layout_header.php'; }, options: { responsive: true, maintainAspectRatio: false, + onClick: filterOnClick(canvasId), + onHover: filterOnHover, plugins: { legend: { position: 'bottom', labels: { color: '#8fa3b1', font: { family: 'monospace', size: 10 }, padding: 8, boxWidth: 10 } }, - tooltip: { callbacks: { label: function(ctx) { return ' ' + ctx.label + ': ' + ctx.parsed; } } } + tooltip: { callbacks: { label: function(ctx) { return ' ' + ctx.label + ': ' + ctx.parsed + ' — click to filter'; } } } }, cutout: '68%' } @@ -310,6 +367,7 @@ include __DIR__ . '/layout_header.php'; function makeBar(canvasId, data) { var ctx = document.getElementById(canvasId); if (!ctx || !data.length) return; + ctx.title = 'Click a bar to filter the ticket list'; return new Chart(ctx, { type: 'bar', data: { @@ -323,7 +381,12 @@ include __DIR__ . '/layout_header.php'; }, options: { indexAxis: 'y', responsive: true, maintainAspectRatio: false, - plugins: { legend: { display: false } }, + onClick: filterOnClick(canvasId), + onHover: filterOnHover, + plugins: { + legend: { display: false }, + tooltip: { callbacks: { label: function(ctx) { return ' ' + ctx.parsed.x + ' — click to filter'; } } } + }, scales: { x: { ticks: { color: '#8fa3b1', font: { size: 10 } }, grid: { color: 'rgba(0,255,65,0.06)' } }, y: { ticks: { color: '#8fa3b1', font: { family: 'monospace', size: 10 } }, grid: { display: false } }