From c783e30321a545484d0646d6ce116feda7d3de36 Mon Sep 17 00:00:00 2001 From: pralav-25 <174412353+pralav-25@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:58:45 +0530 Subject: [PATCH] feat: add incident triage filters and visible-report CSV export --- README.md | 2 + public/static/app.js | 206 ++++++++++++++++++++++++------------- public/static/reports.js | 68 ++++++++++++ public/static/styles.css | 16 +++ tests-web/reports.test.mjs | 96 +++++++++++++++++ 5 files changed, 316 insertions(+), 72 deletions(-) create mode 100644 public/static/reports.js create mode 100644 tests-web/reports.test.mjs diff --git a/README.md b/README.md index fd6694d..f59af4a 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ demonstration, not a structural-safety assessment service. - Submit an incident with a manually selected priority and optional photo. - Share a public reporting link without granting dashboard access. - Track a submitted report through an unguessable status link. +- Triage incidents with search across asset names, report IDs, descriptions, and resolution notes; combine status and observed-priority filters, then sort by recency or priority. +- Export just the visible incident reports as spreadsheet-safe CSV, in the displayed order. Tracking codes and photo data are excluded; the file contains the descriptions and notes already visible in your private workspace. - Record maintenance and resolve incidents with notes; keep the complete history. - Enable and clear a reversible flood scenario for road assets. - Use the same application on desktop and mobile. diff --git a/public/static/app.js b/public/static/app.js index f83b84f..0f17e08 100644 --- a/public/static/app.js +++ b/public/static/app.js @@ -7,6 +7,7 @@ import { initials, severityName, } from "./utils.js"; +import { filterReports, reportsCsv } from "./reports.js"; const $ = (selector, parent = document) => parent.querySelector(selector); const root = $("#root"), @@ -20,6 +21,9 @@ const state = { view: "overview", archived: false, reportFilter: "Open", + reportQuery: "", + reportSeverity: "", + reportSort: "newest", }; let map, toastTimer, @@ -329,6 +333,10 @@ function renderShell() { try { await api("/auth/logout", { method: "POST" }); state.account = null; + state.reportQuery = ""; + state.reportSeverity = ""; + state.reportSort = "newest"; + state.reportFilter = "Open"; history.pushState({}, "", "/"); landing(); } catch (e) { @@ -994,12 +1002,7 @@ function reportsView() { "Incident reports", "Track observations from submission through to documented resolution.", [ - button( - "Share reporting link", - shareReporting, - "button secondary", - "share", - ), + button("Share reporting link", shareReporting, "button secondary", "share"), button("Submit report", () => reportForm(), "button", "plus"), ], ), @@ -1012,75 +1015,134 @@ function reportsView() { ["Open", "Open reports"], ["Resolved", "Resolved"], ["", "All history"], - ]) - tabs.append( - button( - label, - () => { - state.reportFilter = value; - renderShell(); - }, - state.reportFilter === value ? "active" : "", - ), + ]) { + const tab = button( + label, + () => { + state.reportFilter = value; + renderShell(); + }, + state.reportFilter === value ? "active" : "", ); - content.append(el("div", { class: "toolbar" }, tabs)); - const items = state.reports.filter( - (r) => !state.reportFilter || r.status === state.reportFilter, - ), - grid = el("div", { class: "report-grid" }); - for (const r of items) { - const card = el("article", { class: "report-card" }, [ - el("div", { class: "row between" }, [ - el( - "span", - { class: "small muted" }, - `REPORT #${r.id} · ${date(r.created_at)}`, - ), - badge( - r.status === "Resolved" ? "Resolved" : severityName(r.severity), - r.status === "Resolved" - ? "" - : r.severity === 15 - ? "emergency" - : r.severity === 10 - ? "high" - : "", - ), - ]), - el("h3", {}, r.asset_name), - el("p", {}, r.description), - r.resolution_note - ? el("div", { class: "resolution" }, [ - el("strong", {}, "Resolution: "), - r.resolution_note, - ]) - : null, - el("div", { class: "report-bottom" }, [ - button( - r.has_image ? "View details & photo" : "View details", - () => reportDetail(r), - "text-button", - ), - r.status === "Open" - ? button( - "Resolve report", - () => resolveDialog(r), - "button secondary slim", - "check", - ) - : el("span", { class: "small muted" }, date(r.resolved_at)), - ]), - ]); - grid.append(card); + tab.setAttribute("aria-pressed", String(state.reportFilter === value)); + tabs.append(tab); } - content.append( - items.length - ? grid - : el("div", { class: "panel empty" }, [ - el("strong", {}, "No reports in this view"), - "Submit an observation to begin the workflow, or choose a different status filter.", - ]), + const visible = () => + filterReports(state.reports, { + query: state.reportQuery, + status: state.reportFilter, + severity: state.reportSeverity, + sort: state.reportSort, + }); + const exportButton = button( + "Export visible CSV", + () => { + const rows = visible(); + if (!rows.length) return; + const url = URL.createObjectURL( + new Blob([reportsCsv(rows)], { type: "text/csv;charset=utf-8" }), + ); + const link = el("a", { href: url, download: "structiq-incident-reports.csv" }); + document.body.append(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + toast(`Exported ${rows.length} visible report${rows.length === 1 ? "" : "s"}.`); + }, + "button secondary slim", + "download", + ); + content.append(el("div", { class: "toolbar" }, [tabs, exportButton])); + const filters = el("div", { class: "report-filters" }); + filters.innerHTML = `
`; + const search = $("input", filters); + const [severity, sort] = filters.querySelectorAll("select"); + search.value = state.reportQuery; + severity.value = state.reportSeverity; + sort.value = state.reportSort; + const count = el("p", { class: "small muted", role: "status", style: "margin:0" }); + const reset = button( + "Clear filters", + () => { + state.reportFilter = ""; + state.reportQuery = ""; + state.reportSeverity = ""; + state.reportSort = "newest"; + renderShell(); + $(".report-filters input").focus(); + }, + "text-button", ); + const area = el("div"); + content.append(filters, el("div", { class: "row between report-results" }, [count, reset]), area); + function renderResults() { + const items = visible(); + count.textContent = `${items.length} of ${state.reports.length} reports shown`; + exportButton.disabled = items.length === 0; + reset.disabled = + !state.reportQuery && + !state.reportSeverity && + !state.reportFilter && + state.reportSort === "newest"; + const grid = el("div", { class: "report-grid" }); + for (const r of items) { + const card = el("article", { class: "report-card" }, [ + el("div", { class: "row between" }, [ + el("span", { class: "small muted" }, `REPORT #${r.id} · ${date(r.created_at)}`), + badge( + r.status === "Resolved" ? "Resolved" : severityName(r.severity), + r.status === "Resolved" + ? "" + : r.severity === 15 + ? "emergency" + : r.severity === 10 + ? "high" + : "", + ), + ]), + el("h3", {}, r.asset_name), + el("p", {}, r.description), + r.resolution_note + ? el("div", { class: "resolution" }, [ + el("strong", {}, "Resolution: "), + r.resolution_note, + ]) + : null, + el("div", { class: "report-bottom" }, [ + button( + r.has_image ? "View details & photo" : "View details", + () => reportDetail(r), + "text-button", + ), + r.status === "Open" + ? button("Resolve report", () => resolveDialog(r), "button secondary slim", "check") + : el("span", { class: "small muted" }, date(r.resolved_at)), + ]), + ]); + grid.append(card); + } + area.replaceChildren( + items.length + ? grid + : el("div", { class: "panel empty" }, [ + el("strong", {}, "No reports in this view"), + "Try another search, priority, or status, or clear the filters to see all reports.", + ]), + ); + } + search.oninput = () => { + state.reportQuery = search.value; + renderResults(); + }; + severity.onchange = () => { + state.reportSeverity = severity.value; + renderResults(); + }; + sort.onchange = () => { + state.reportSort = sort.value; + renderResults(); + }; + renderResults(); } function resolveDialog(report) { noteDialog( diff --git a/public/static/reports.js b/public/static/reports.js new file mode 100644 index 0000000..41169a0 --- /dev/null +++ b/public/static/reports.js @@ -0,0 +1,68 @@ +import { severityName } from "./utils.js"; + +export function filterReports( + reports, + { query = "", status = "", severity = "", sort = "newest" } = {}, +) { + const term = query.trim().toLocaleLowerCase(); + const timestamp = (value) => Date.parse(value) || 0; + return reports + .filter( + (report) => + (!status || report.status === status) && + (!severity || report.severity === Number(severity)) && + (!term || + [report.id, report.asset_name, report.description, report.resolution_note].some((value) => + String(value ?? "") + .toLocaleLowerCase() + .includes(term), + )), + ) + .sort((a, b) => { + const newest = timestamp(b.created_at) - timestamp(a.created_at); + const order = + sort === "priority" + ? b.severity - a.severity || newest + : sort === "oldest" + ? -newest + : newest; + return order || b.id - a.id; + }); +} + +function csvCell(value) { + let text = String(value ?? ""); + // Quoting alone does not stop spreadsheet formulas, including after whitespace. + if (/^[\s\uFEFF]*[=+@-]/u.test(text)) text = "'" + text; + return `"${text.replaceAll('"', '""')}"`; +} + +/** Export only the supplied, authorized rows; keep tracking tokens and photos private. */ +export function reportsCsv(reports) { + const rows = [ + [ + "Report ID", + "Asset", + "Description", + "Observed priority", + "Status", + "Created at", + "Resolved at", + "Resolution note", + "Photo attached", + ], + ]; + for (const report of reports) + rows.push([ + report.id, + report.asset_name, + report.description, + severityName(report.severity), + report.status, + report.created_at, + report.resolved_at, + report.resolution_note, + report.has_image ? "Yes" : "No", + ]); + return "\uFEFF" + rows.map((row) => row.map(csvCell).join(",")).join("\r\n") + "\r\n"; +} diff --git a/public/static/styles.css b/public/static/styles.css index 56374ea..f5c91d1 100644 --- a/public/static/styles.css +++ b/public/static/styles.css @@ -993,6 +993,22 @@ tbody tr:hover { white-space: pre-wrap; overflow-wrap: anywhere; } +.report-filters { + display: grid; + grid-template-columns: minmax(240px, 2fr) repeat(2, minmax(150px, 1fr)); + gap: 16px; + margin-bottom: 16px; +} +.report-filters > div, .report-filters label { display: flex; flex-direction: column; gap: 8px; min-width: 0; font-size: 13px; color: #52625c; } +.report-filters input, .report-filters select { width: 100%; min-height: 42px; padding: 10px 12px; border: 1px solid #cedbd4; border-radius: 7px; background: #fff; color: #253f35; } +.report-results { margin-bottom: 18px; gap: 12px; flex-wrap: wrap; } +@media (max-width: 900px) { + .report-filters { grid-template-columns: 1fr 1fr; } + .report-filters > div:first-child { grid-column: 1 / -1; } +} +@media (max-width: 520px) { + .report-filters { grid-template-columns: 1fr; } +} .report-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/tests-web/reports.test.mjs b/tests-web/reports.test.mjs new file mode 100644 index 0000000..b8847d4 --- /dev/null +++ b/tests-web/reports.test.mjs @@ -0,0 +1,96 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { filterReports, reportsCsv } from "../public/static/reports.js"; + +const reports = [ + { + id: 1, + asset_name: "Adyar Bridge", + description: "Loose railing", + severity: 15, + status: "Open", + created_at: "2026-09-01T12:00:00Z", + resolved_at: null, + resolution_note: null, + has_image: false, + }, + { + id: 2, + asset_name: "Mount Road", + description: "Pothole", + severity: 5, + status: "Resolved", + created_at: "2026-09-03T12:00:00Z", + resolved_at: "2026-09-04T12:00:00Z", + resolution_note: "Resurfaced near junction", + has_image: true, + }, + { + id: 3, + asset_name: "Adyar Bridge", + description: "Faded markings", + severity: 10, + status: "Open", + created_at: "2026-09-02T12:00:00Z", + resolved_at: null, + resolution_note: null, + has_image: false, + }, +]; +const ids = (rows) => rows.map((row) => row.id); + +test("combines incident search, status, and manually selected priority", () => { + assert.deepEqual( + ids(filterReports(reports, { query: " ADYAR ", status: "Open", severity: "15" })), + [1], + ); + assert.deepEqual(ids(filterReports(reports, { query: "resurfaced" })), [2]); + assert.deepEqual(ids(filterReports(reports, { query: "1" })), [1]); + assert.deepEqual(filterReports(reports, { query: "missing" }), []); + assert.deepEqual(filterReports(reports, { status: "Resolved", severity: "15" }), []); +}); + +test("orders the queue by recency, age, or priority without mutating report history", () => { + const original = structuredClone(reports); + assert.deepEqual(ids(filterReports(reports)), [2, 3, 1]); + assert.deepEqual(ids(filterReports(reports, { sort: "oldest" })), [1, 3, 2]); + assert.deepEqual(ids(filterReports(reports, { sort: "priority" })), [1, 3, 2]); + assert.deepEqual(reports, original); + assert.deepEqual(filterReports([]), []); +}); + +test("CSV follows the filtered row order and excludes tracking secrets and photo contents", () => { + const rows = filterReports(reports, { status: "Open", sort: "priority" }); + const csv = reportsCsv( + rows.map((row) => ({ + ...row, + tracking_code: "private-link-token", + image: "private-image-bytes", + })), + ); + assert.ok(csv.startsWith('\uFEFF"Report ID","Asset"')); + assert.ok(csv.indexOf('"1","Adyar Bridge"') < csv.indexOf('"3","Adyar Bridge"')); + assert.ok(!csv.includes("Mount Road")); + assert.ok(!csv.includes("private-link-token")); + assert.ok(!csv.includes("private-image-bytes")); + assert.equal(reportsCsv([]).split("\r\n").length, 2); +}); + +test("CSV quotes commas, quotes, line breaks, and neutralizes spreadsheet formulas", () => { + for (const value of [ + '=HYPERLINK("https://example.test")', + "+SUM(1,2)", + "-1+2", + "@SUM(1,2)", + "\t =1+2", + "\r\n=1+2", + ]) { + const csv = reportsCsv([{ ...reports[0], description: value }]); + assert.ok(csv.includes(`"'${value.replaceAll('"', '""')}"`)); + } + const csv = reportsCsv([ + { ...reports[0], asset_name: 'Bridge, "North"', description: "Line one\nLine two" }, + ]); + assert.ok(csv.includes('"Bridge, ""North"""')); + assert.ok(csv.includes('"Line one\nLine two"')); +});