Compare commits

..
Author SHA1 Message Date
jaredandClaude Sonnet 5 6e0863449f Trim comment text before persisting, not just for validation
Security / PHP Security (semgrep) (push) Successful in 2m6s
Lint / Deploy (push) Successful in 3s
Lint / PHP (phpcs PSR-12) (push) Successful in 26s
Lint / JS (eslint) (push) Successful in 11s
Lint / PHP requirements (version + extensions) (push) Successful in 29s
Lint / Notify on failure (push) Skipped
add_comment.php computed a trimmed copy of comment_text only to check
for empty input, then passed the original untrimmed $data through to
CommentModel::addComment(), so any leading/trailing whitespace the
user typed (or pasted) was written to ticket_comments.comment_text as-is.
update_comment.php already trims before saving edits, so a comment
could pass through this endpoint once with untrimmed text (creation)
and be silently corrected the moment it was next edited — inconsistent
storage that, combined with the markdown parser's line-anchored regexes
(headings, tables, lists all match on ^), could make a markdown-enabled
comment mis-render after a reload depending on whether its first line
carried leading whitespace.

Also trims in the "Load more comments" pagination re-render path in
TicketView.php, matching the two on-load renderers in markdown.js so
all three code paths that call parseMarkdown() on stored comment text
treat leading whitespace consistently.

Closes #18

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 21:03:00 -04:00
jared 1fb984e352 Merge #22 chart click-to-filter into main
Lint / JS (eslint) (push) Successful in 19s
Lint / PHP requirements (version + extensions) (push) Successful in 56s
Lint / PHP (phpcs PSR-12) (push) Successful in 30s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 2m3s
Lint / Deploy (push) Successful in 19s
2026-08-07 23:15:59 -04:00
jared ce0ea66994 Charts: click a segment to filter the dashboard (#22)
Lint / PHP (phpcs PSR-12) (push) Successful in 31s
Lint / JS (eslint) (push) Successful in 21s
Lint / PHP requirements (version + extensions) (push) Successful in 1m7s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Successful in 3s
Security / PHP Security (semgrep) (push) Successful in 2m0s
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.
2026-08-07 23:15:51 -04:00
3 changed files with 71 additions and 3 deletions
+5
View File
@@ -99,6 +99,11 @@ try {
exit;
}
// Persist the trimmed text (not the raw client value) — matches update_comment.php
// and keeps stored comment_text free of leading whitespace that could shift a
// markdown-enabled comment's first line out of column 0 on reload.
$data['comment_text'] = $commentTextRaw;
// Never trust a client-supplied display name — always attribute the comment to
// the authenticated session user.
$data['user_name'] = $currentUser['display_name'] ?? $currentUser['username'] ?? 'User';
+65 -2
View File
@@ -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 } }
+1 -1
View File
@@ -1219,7 +1219,7 @@ document.addEventListener('DOMContentLoaded', function () {
if (typeof parseMarkdown === 'function') {
list.querySelectorAll('.comment-text[data-markdown]').forEach(function (el) {
if (!el.dataset.rendered) {
el.innerHTML = parseMarkdown(el.textContent);
el.innerHTML = parseMarkdown(el.textContent.trim());
el.dataset.rendered = '1';
}
});