Web dashboard step 3: overview, runs and articles pages

Fill in the dashboard read pages of the web dashboard plan (§9): the
overview card set (last run from report_json, budget today per provider,
ratings this week, unrated picks with the rating widget, jobs summary,
config-on-disk warnings), the runs list and run detail (funnel, admission
mix, preference state, timings, provider usage, warnings, top feeds,
config diff against the previous non-dry run, near misses, and the
candidates table with allow-listed filters/sorts and per-row signals),
and the articles list and article detail (assessments including
provider rejections, run history, neighbours and interests, embedding
metadata, rating events, and the text explain).

Dynamic list queries go through AssertSqlSafe with SQL built only from
constants and allow-listed fragments; every user value is bound. Adds
the shared signals-table, candidate-row and pager partials, a CSS block
and the data-filter table behaviour in app.js, plus the step 3 handoff.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
This commit is contained in:
2026-09-03 05:26:52 +00:00
co-authored by Claude Fable 5.1
parent 849231e49a
commit 63eee6bbd3
14 changed files with 3543 additions and 7 deletions
+18
View File
@@ -39,3 +39,21 @@ document.querySelectorAll("details[id]").forEach((details) => {
details.addEventListener("toggle", () => localStorage.setItem(key, details.open ? "open" : "closed"));
} catch (_) {}
});
/* step 3: filter-as-you-type on tables with data-filter (this page's rows only) */
document.querySelectorAll("table[data-filter]").forEach((table) => {
const rows = table.querySelectorAll("tbody tr");
if (rows.length < 2) return;
const input = document.createElement("input");
input.type = "search";
input.className = "table-filter";
input.placeholder = "Filter rows on this page";
input.setAttribute("aria-label", "Filter rows on this page");
const host = table.closest(".scroll-x") || table;
host.parentNode.insertBefore(input, host);
input.addEventListener("input", () => {
const needle = input.value.trim().toLowerCase();
rows.forEach((row) => {
row.hidden = needle !== "" && !row.textContent.toLowerCase().includes(needle);
});
});
});