Skip to content

Commit 85d0a01

Browse files
authored
fix(analytics): dashboard predicate, tab dedup, chart a11y, ISO timestamps (#55)
## Summary Four small fixes to the analytics dashboard surfaced from a 7-day audit of `mcp.copilotkit.ai/analytics`. All independent, no behavior change to data collection. ### 1. Top Queries vs Empty Result Queries — single predicate for "empty" `getTopQueries` previously returned every `(query_text, tool_name)` group ordered by count, with no filter on `result_count`. A query that ONLY ever returned zero results therefore appeared in **both** tables: Top Queries (count > 0) and Empty Result Queries (`result_count = 0`). The two tables disagreed on what "empty" means, which let rows like `count=1, avg_results=0.0, avg_score=—` sit in Top Queries. Fix: add `HAVING bool_or(result_count > 0)` so Top Queries narrows to groups with at least one non-empty event. Pure-empty groups stay exclusively in Empty Result Queries. ### 2. Hide the "All" tool-type pill when only one tool type exists With a single tool type (typical install with only `search`), `All (3,214)` rendered next to `Search (3,214)` — identical counts, looked like a broken filter. Now the `All` pill is suppressed when `toolCounts.length <= 1` and reappears automatically once a second tool type is recorded. ### 3. Chart accessibility / scrape-ability The two `<canvas>` charts ("Queries per Day", "Queries by Source") were opaque to assistive tech and to HTML scrapers — only the tooltip prompt was reachable in the accessibility tree. Added: - `role="img"` + `aria-labelledby` + `aria-describedby` on each canvas. - Sibling `.sr-only` tables (`#dailyChartTable`, `#sourceChartTable`) populated with the same numbers the bars/slices encode, kept off-screen with the standard visually-hidden CSS pattern. Visible UI is unchanged. Screen readers now announce per-day and per-source counts; the dashboard's own MCP indexer can now read them too. ### 4. Empty Queries `Last Seen` → ISO-8601 in UTC `toLocaleString()` rendered `4/29/2026, 6:10:04 AM` — month-first ordering, 12-hour time, no timezone. The dashboard is internal and read across timezones. Now renders `2026-04-29 06:10:04 UTC`: explicit, sortable, unambiguously parseable. ## Test plan - [x] `npm run build` clean. - [x] `npm test` — 3,250 passed (added one regression test asserting the `HAVING bool_or(result_count > 0)` predicate is in the SQL). - [x] Manual: open the dashboard with a single tool type and confirm the `All` pill is hidden; with multiple tool types, confirm it reappears. - [ ] Manual: VoiceOver / NVDA reads the sr-only tables alongside each chart. - [x] Manual: confirm `Last Seen` renders as `YYYY-MM-DD HH:MM:SS UTC`.
2 parents a5d1e03 + 3dbb5d4 commit 85d0a01

3 files changed

Lines changed: 142 additions & 13 deletions

File tree

docs/analytics.html

Lines changed: 121 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,24 @@
370370
text-align: center;
371371
font-style: italic;
372372
}
373+
374+
/* Screen-reader-only / scraper-friendly mirror of canvas chart data.
375+
Canvas elements expose nothing useful to assistive tech or to HTML
376+
scrapes; a sibling <table> kept off-screen lets both consume the
377+
same numbers the visible chart shows. Standard "visually hidden"
378+
pattern: positioned, clipped, and with whitespace preserved so the
379+
screen reader announces the table cells. */
380+
.sr-only {
381+
position: absolute;
382+
width: 1px;
383+
height: 1px;
384+
padding: 0;
385+
margin: -1px;
386+
overflow: hidden;
387+
clip: rect(0, 0, 0, 0);
388+
white-space: nowrap;
389+
border: 0;
390+
}
373391
</style>
374392
<script src="/pixels.js" defer></script>
375393
</head>
@@ -382,12 +400,42 @@ <h1>Pathfinder Analytics</h1>
382400
<div class="chart-row">
383401
<div class="chart-box">
384402
<h2 id="dailyChartTitle">Queries per Day</h2>
385-
<canvas id="dailyChart"></canvas>
403+
<canvas
404+
id="dailyChart"
405+
role="img"
406+
aria-labelledby="dailyChartTitle"
407+
aria-describedby="dailyChartTable"
408+
></canvas>
386409
<span class="chart-caption">Click a bar to filter to that day</span>
410+
<table id="dailyChartTable" class="sr-only">
411+
<caption>Queries per day (data table for screen readers)</caption>
412+
<thead>
413+
<tr>
414+
<th scope="col">Day</th>
415+
<th scope="col">Queries</th>
416+
</tr>
417+
</thead>
418+
<tbody></tbody>
419+
</table>
387420
</div>
388421
<div class="chart-box">
389-
<h2>Queries by Source</h2>
390-
<canvas id="sourceChart"></canvas>
422+
<h2 id="sourceChartTitle">Queries by Source</h2>
423+
<canvas
424+
id="sourceChart"
425+
role="img"
426+
aria-labelledby="sourceChartTitle"
427+
aria-describedby="sourceChartTable"
428+
></canvas>
429+
<table id="sourceChartTable" class="sr-only">
430+
<caption>Queries by source (data table for screen readers)</caption>
431+
<thead>
432+
<tr>
433+
<th scope="col">Source</th>
434+
<th scope="col">Queries</th>
435+
</tr>
436+
</thead>
437+
<tbody></tbody>
438+
</table>
391439
</div>
392440
</div>
393441

@@ -1106,15 +1154,21 @@ <h2 style="margin-bottom: 16px; font-size: 1.2rem">Analytics Token</h2>
11061154
return sum + t.count;
11071155
}, 0);
11081156

1109-
// Tool-type group (left)
1157+
// Tool-type group (left). Skip the "All" pill when only one tool type
1158+
// exists in the window: it would render with the same count as the
1159+
// single per-tool pill (e.g. `All (3,214)` next to `Search (3,214)`)
1160+
// and look like a broken filter. Re-appears once a second tool type
1161+
// is recorded.
11101162
var toolHtml = '<div class="filter-group tool-group">';
1111-
toolHtml +=
1112-
'<span class="pill' +
1113-
(activeToolType === null ? " active" : "") +
1114-
'" data-tool-all="1">' +
1115-
'All <span class="count">(' +
1116-
total.toLocaleString() +
1117-
")</span></span>";
1163+
if (toolCounts.length > 1) {
1164+
toolHtml +=
1165+
'<span class="pill' +
1166+
(activeToolType === null ? " active" : "") +
1167+
'" data-tool-all="1">' +
1168+
'All <span class="count">(' +
1169+
total.toLocaleString() +
1170+
")</span></span>";
1171+
}
11181172
toolCounts.forEach(function (t) {
11191173
var isActive = activeToolType === t.tool_type;
11201174
var label =
@@ -1834,6 +1888,28 @@ <h2 style="margin-bottom: 16px; font-size: 1.2rem">Analytics Token</h2>
18341888
},
18351889
);
18361890

1891+
// Mirror the daily chart's data into a screen-reader-only table
1892+
// (#dailyChartTable). Canvas elements are opaque to assistive tech
1893+
// and HTML scrapers; the sr-only table gives both consumers the
1894+
// same numbers the visible bars encode.
1895+
var dailyTableBody = document.querySelector(
1896+
"#dailyChartTable tbody",
1897+
);
1898+
if (dailyTableBody) {
1899+
dailyTableBody.innerHTML =
1900+
perDayArr
1901+
.map(function (d) {
1902+
return (
1903+
"<tr><td>" +
1904+
esc(String(d.day)) +
1905+
"</td><td>" +
1906+
esc(safeNumber(d.count).toLocaleString()) +
1907+
"</td></tr>"
1908+
);
1909+
})
1910+
.join("") || "<tr><td colspan=\"2\">No data</td></tr>";
1911+
}
1912+
18371913
// Source chart - destroy previous instance
18381914
if (sourceChartInstance) {
18391915
sourceChartInstance.destroy();
@@ -1870,6 +1946,28 @@ <h2 style="margin-bottom: 16px; font-size: 1.2rem">Analytics Token</h2>
18701946
);
18711947
}
18721948

1949+
// Mirror source chart data into the sr-only table. Renders even
1950+
// when the chart itself doesn't (sourcesArr.length === 0) so
1951+
// screen readers always see "No data" rather than a stale row
1952+
// from a prior load.
1953+
var sourceTableBody = document.querySelector(
1954+
"#sourceChartTable tbody",
1955+
);
1956+
if (sourceTableBody) {
1957+
sourceTableBody.innerHTML =
1958+
sourcesArr
1959+
.map(function (s) {
1960+
return (
1961+
"<tr><td>" +
1962+
esc(String(s.source_name)) +
1963+
"</td><td>" +
1964+
esc(safeNumber(s.count).toLocaleString()) +
1965+
"</td></tr>"
1966+
);
1967+
})
1968+
.join("") || "<tr><td colspan=\"2\">No data</td></tr>";
1969+
}
1970+
18731971
// Top queries table (now with Tool column)
18741972
var topBody = document.querySelector("#topQueriesTable tbody");
18751973
topBody.innerHTML =
@@ -1911,10 +2009,21 @@ <h2 style="margin-bottom: 16px; font-size: 1.2rem">Analytics Token</h2>
19112009
// string "Invalid Date", which would be rendered raw and
19122010
// looks like a bug. Fall back to an em dash instead, and
19132011
// route the result through esc() on interpolation.
2012+
//
2013+
// Render as ISO-8601 in UTC (e.g. "2026-04-29 06:10:04 UTC")
2014+
// rather than the browser locale's mixed month-first / 12h
2015+
// format. The dashboard is internal and read across
2016+
// timezones; an explicit, sortable, timezone-labelled
2017+
// timestamp avoids ambiguity. Trim ISO milliseconds and the
2018+
// "T"/"Z" separators for readability while keeping it
2019+
// unambiguously parseable.
19142020
var lastSeenRaw = q.last_seen ? new Date(q.last_seen) : null;
19152021
var lastSeenStr =
19162022
lastSeenRaw && Number.isFinite(lastSeenRaw.getTime())
1917-
? lastSeenRaw.toLocaleString()
2023+
? lastSeenRaw
2024+
.toISOString()
2025+
.replace("T", " ")
2026+
.replace(/\.\d{3}Z$/, " UTC")
19182027
: "\u2014";
19192028
return (
19202029
'<tr class="empty"><td>' +

src/__tests__/analytics.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,20 @@ describe("getTopQueries", () => {
419419
expect(sql).not.toContain("'<redacted>'");
420420
expect(params).toContain(REDACTED_QUERY_TEXT);
421421
});
422+
423+
it("excludes pure-empty (query_text, tool_name) groups via HAVING bool_or(result_count > 0)", async () => {
424+
// Without this predicate, a query that ONLY ever returned zero results
425+
// shows up in BOTH the Top Queries table (count > 0) and the Empty
426+
// Result Queries table (result_count = 0), and the dashboard's two
427+
// tables disagree on the predicate for "empty." The HAVING clause
428+
// narrows Top Queries to groups where at least one event returned a
429+
// non-empty result.
430+
mockQuery.mockResolvedValueOnce({ rows: [] });
431+
await getTopQueries();
432+
433+
const [sql] = mockQuery.mock.calls[0];
434+
expect(sql).toContain("HAVING bool_or(result_count > 0)");
435+
});
422436
});
423437

424438
// ---------------------------------------------------------------------------

src/db/analytics.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -596,7 +596,12 @@ export async function getAnalyticsSummary(
596596
/**
597597
* Get top queries by frequency over the last N days, or over the explicit
598598
* `filter.from`/`filter.to` range when provided. Groups by (query_text,
599-
* tool_name) and orders by count desc.
599+
* tool_name) and orders by count desc. Excludes (query_text, tool_name)
600+
* pairs whose every event returned zero results — those belong exclusively
601+
* in `getEmptyQueries`. Without this filter, a query that only ever returns
602+
* empty would render in BOTH the Top Queries and Empty Result Queries
603+
* tables with the same count, and the dashboard's two tables disagree on
604+
* the predicate for "empty."
600605
*/
601606
export async function getTopQueries(
602607
days: number = 7,
@@ -628,6 +633,7 @@ export async function getTopQueries(
628633
FROM query_log
629634
${where}
630635
GROUP BY query_text, tool_name
636+
HAVING bool_or(result_count > 0)
631637
ORDER BY count DESC
632638
LIMIT $${redactedIdx + 1}`,
633639
[...fp, ...dw.params, REDACTED_QUERY_TEXT, limit],

0 commit comments

Comments
 (0)