From 940f732686f4a1da6faf00258f774d3e43918c83 Mon Sep 17 00:00:00 2001 From: abel Date: Sat, 1 Aug 2026 11:46:23 +0800 Subject: [PATCH] fix(reports): align usage dates with activity cells Render usage activity as one horizontal UTC date grid with shared cell and tick columns, bounded long-range ticks, and chart-local scrolling. Add regression coverage for short, long, empty, and localized cases. The implementation follows docs/specs/2026-08-01-html-usage-activity-date-alignment.md. Validation covered focused activity tests, canonical report rendering, Playwright at 1180/800/320, doc-link checks, and package verification. The full suite reached 1044 passes; four Windows symlink fixtures remain blocked by EPERM. Co-authored-by: Codex (GPT 5.6 Sol) --- ...8-01-html-usage-activity-date-alignment.md | 101 ++++++++++++++++++ scripts/harness-analysis/renderers/html.mjs | 29 ++++- templates/reporting/html-visual.md | 2 +- test/harness-report-render-cli.test.mjs | 87 +++++++++++++++ 4 files changed, 214 insertions(+), 5 deletions(-) create mode 100644 docs/specs/2026-08-01-html-usage-activity-date-alignment.md diff --git a/docs/specs/2026-08-01-html-usage-activity-date-alignment.md b/docs/specs/2026-08-01-html-usage-activity-date-alignment.md new file mode 100644 index 0000000..b9cf979 --- /dev/null +++ b/docs/specs/2026-08-01-html-usage-activity-date-alignment.md @@ -0,0 +1,101 @@ +# Align HTML usage activity dates with their cells + +## Traceability + +- Spec ID: 2026-08-01-html-usage-activity-date-alignment +- Status: Implemented +- Issue: None; focused report-layout maintenance from visual review of the + generated AI coding workflow evidence report. + +## Intent + +Make the self-contained HTML report project-usage activity chart preserve the +one-to-one relationship between each UTC date and its activity cell. Dates must +read from left to right, use the same grid columns as the cells they describe, +and keep any overflow inside the chart instead of expanding the page. + +## Acceptance Scenarios + +- HUA-AC-1: The HTML renderer emits exactly one activity cell per retained UTC + date in source order, with strictly increasing horizontal grid columns and + the existing heat level, tooltip, and accessible label derived from that + date's value. +- HUA-AC-2: The activity axis shares the chart's `--heat-days`, column width, + and gap. Ranges of four days or fewer label every date in its matching + column. Longer ranges label the first and last dates plus every seventh date, + excluding periodic ticks within the last three days to avoid a duplicate + end cluster. +- HUA-AC-3: At 1180 px, 800 px, and 320 px viewport widths, the activity panel + remains vertically centered, the page has no horizontal overflow, and long + date ranges scroll only inside the chart container. +- HUA-AC-4: Empty activity data, English and Chinese output, tooltip and + `aria-label` content, self-contained HTML behavior, artifact names, and the + `summary.usageActivity` schema, UTC ordering, values, scoring, findings, + Markdown, and Canvas behavior remain unchanged. +- HUA-AC-5: The existing findings in + `.codex/better-harness/2026-08-01-ai-coding-workflow-evidence` re-render to + the same canonical artifact set, and renderer plus HTML validation pass. +- HUA-AC-6: Focused renderer tests, the documentation graph and link checks, + the full package test suite, package verification, preview health and Canvas + runtime smoke checks, browser console/page-error inspection, and whitespace + validation are executed and recorded. Feature-related checks pass; unavailable + external runtime or platform privileges remain explicit exceptions. + +## Non-goals + +- Changing activity collection, date normalization, truncation, aggregation, + heat calculation, scoring, finding eligibility, or report conclusions. +- Adding report fields, JavaScript, external resources, or generated artifacts. +- Changing the Markdown or Canvas renderer, the activity schema, or the + generated report's evidence source. +- Hand-editing the generated `report.html` or changing unrelated root CLI, + test, spec, documentation-graph, or diagnostic-output worktree changes. + +## Plan and Tasks + +1. Add renderer-level fixtures for four-day, thirty-day, empty, and localized + activity output, with explicit date-to-column assertions. (HUA-AC-1, + HUA-AC-2, HUA-AC-4) +2. Replace the seven-row column flow with one shared horizontal cell-and-axis + grid, deterministic tick selection, and chart-local overflow. (HUA-AC-1, + HUA-AC-2, HUA-AC-3) +3. Document the portable HTML activity-axis contract without changing report + data ownership or runtime boundaries. (HUA-AC-2, HUA-AC-4) +4. Re-render the current report from its existing `findings.json`, then run + automated, visual, preview, packaging, and traceability checks before + changing this spec to Implemented. (HUA-AC-3, HUA-AC-5, HUA-AC-6) + +## Test and Review Evidence + +- The focused activity-chart regressions passed 3/3: + `node --test --test-name-pattern="HTML activity chart" test/harness-report-render-cli.test.mjs`. + They cover four-day column binding, thirty-day tick selection, empty data, + Chinese output, tooltip/`aria-label` parity, and self-contained HTML. +- Re-rendering the current report from its existing `findings.json` passed all + four canonical checks (`output-location`, `run-directory-artifacts`, + `findings-json`, and `html-report`). The pre-existing fix-result sidecar was + moved outside the run directory only for validation and restored unchanged. + SHA-256 checks confirmed `findings.json`, `report.md`, and the sidecar were + unchanged; only `report.html` changed. +- Playwright geometry checks at 1180 px, 800 px, and 320 px found four strictly + increasing cells, matching source-date/grid-column pairs, zero pixel + cell-to-label center deltas, and zero page-level horizontal overflow. A + thirty-day browser sequence at 320 px produced 256 px of chart-local + overflow with `overflow-x:auto`; the page still had zero horizontal overflow. + The final browser console check reported zero errors and zero warnings. +- Documentation routing regenerated to 35 files and 51 links, and the doc-link + suite passed 6/6: + `node scripts/doc-link-graph/cli.mjs skills/better-harness` and + `node --test test/doc-link-graph.test.mjs`. +- Package verification passed with 369 npm entries and 392 runtime-zip entries: + `npm run pack:verify`. +- The full suite completed with 1044 passes, 6 platform skips, and 4 failures: + `npm test`. Every failure was an existing Windows fixture that could not + create a symbolic link because the current process lacks symlink authority + (`EPERM`); no HTML or activity-chart assertion failed. The direct renderer + suite likewise passed 29/30 with only its symlink fixture blocked. +- `npm run preview` was executed but intentionally failed fast because this + machine has no Canvas SDK runtime and neither `CANVAS_SDK_MEDIA_DIR` nor + `CANVAS_SDK_ROOT` is configured. Consequently `/health` and + `/canvas-module.js` could not be smoke-tested in this environment; no fake + runtime was substituted for that missing external prerequisite. diff --git a/scripts/harness-analysis/renderers/html.mjs b/scripts/harness-analysis/renderers/html.mjs index c909ddd..c74c513 100644 --- a/scripts/harness-analysis/renderers/html.mjs +++ b/scripts/harness-analysis/renderers/html.mjs @@ -218,6 +218,18 @@ function heatLevel(value, max) { return Math.max(1, Math.min(4, Math.ceil((value / max) * 4))); } +function activityTickIndexes(dayCount) { + if (dayCount <= 4) return Array.from({ length: dayCount }, (_, index) => index); + + const lastIndex = dayCount - 1; + const indexes = [0]; + for (let index = 7; index < lastIndex; index += 7) { + if (lastIndex - index > 3) indexes.push(index); + } + indexes.push(lastIndex); + return indexes; +} + function renderActivity(summary, language) { const activity = summary?.usageActivity; const usage = summary?.usageEfficiency; @@ -232,8 +244,15 @@ function renderActivity(summary, language) { const label = activeMinutes.length > 0 ? copy(language, `${date}: ${formatNumber(active, language)} active minutes`, `${date}:${formatNumber(active, language)} 活跃分钟`) : copy(language, `${date}: ${formatNumber(sessions, language)} sessions`, `${date}:${formatNumber(sessions, language)} 个会话`); - return ``; + return ``; + }).join(""); + const ticks = activityTickIndexes(dates.length).map((index) => { + const position = dates.length > 4 + ? index === 0 ? " first" : index === dates.length - 1 ? " last" : "" + : ""; + return `${escapeHtml(dates[index])}`; }).join(""); + const heatWidth = Math.max(13, (dates.length * 17) - 4); const coverage = summary?.atAGlance?.coverage ?? {}; const selection = usage?.selection ?? coverage?.selection ?? {}; const analyzed = selection.analyzedSessionCount ?? selection.analyzedCount ?? 0; @@ -243,8 +262,10 @@ function renderActivity(summary, language) { return `
- - ${dates.length > 0 ? `
${escapeHtml(dates[0])}${escapeHtml(dates.at(-1))}
` : ""} + ${dates.length > 0 ? `
+ + +
` : ``}
${metric(copy(language, "Sessions reviewed", "已分析会话"), `${formatNumber(analyzed, language)} / ${formatNumber(eligible, language)}`, copy(language, "all-eligible usage census", "全量合格用量普查"), language)} @@ -457,7 +478,7 @@ export function renderHtml(reportData, actionContext) { .metric > span,.metric > small { display:block; color:var(--muted); }.metric > strong { display:block; margin:5px 0 2px; font-size:34px; line-height:1.1; }.metric > small { font-size:12px; } .section { margin-top:64px; scroll-margin-top:24px; }.section-heading { display:flex; justify-content:space-between; gap:30px; align-items:end; margin-bottom:20px; }.section-heading h2 { margin:5px 0 0; font-size:30px; letter-spacing:-.025em; }.section-heading > p { max-width:420px; margin:0; color:var(--muted); text-align:right; font-size:14px; } .dimension-grid { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:12px; }.dimension-card { min-height:230px; padding:19px; border:1px solid var(--line); border-radius:19px; background:linear-gradient(180deg,var(--panel-2),var(--panel)); }.dimension-top { display:flex; min-height:52px; justify-content:space-between; gap:8px; align-items:flex-start; font-weight:700; }.score-line { display:flex; align-items:baseline; gap:4px; margin-top:14px; }.score-line strong { font-size:34px; }.score-line span { color:var(--muted); font-size:12px; }.track { height:8px; margin:8px 0 16px; overflow:hidden; border-radius:999px; background:#30353a; }.track i { display:block; height:100%; border-radius:inherit; background:linear-gradient(90deg,var(--blue-2),var(--blue)); }.dimension-card p { margin:0; color:var(--muted); font-size:13px; } - .activity-layout { display:grid; grid-template-columns:minmax(0,1.8fr) minmax(230px,.7fr); gap:14px; }.activity-panel,.subpanel,.finding,.custom-grid article,.evidence-grid,.evidence-note { border:1px solid var(--line); border-radius:19px; background:var(--panel); }.activity-panel { padding:22px; overflow:hidden; }.heatmap { display:grid; grid-template-rows:repeat(7,13px); grid-auto-flow:column; grid-auto-columns:13px; gap:4px; min-height:115px; overflow-x:auto; padding-bottom:8px; }.heat-cell { border-radius:3px; background:#2b3035; }.heat-cell.l1{background:#174d70}.heat-cell.l2{background:#206f9e}.heat-cell.l3{background:#319bd1}.heat-cell.l4{background:#70c9ff}.heat-legend { display:flex; justify-content:space-between; color:var(--muted); font-size:11px; }.stacked-metrics { display:grid; gap:14px; }.stacked-metrics .metric { min-height:0; } + .activity-layout { display:grid; grid-template-columns:minmax(0,1.8fr) minmax(230px,.7fr); gap:14px; }.activity-panel,.subpanel,.finding,.custom-grid article,.evidence-grid,.evidence-note { border:1px solid var(--line); border-radius:19px; background:var(--panel); }.activity-panel { display:flex; min-width:0; flex-direction:column; justify-content:center; padding:22px; overflow:hidden; }.heat-scroll { width:100%; min-width:0; overflow-x:auto; padding-bottom:8px; }.heatmap,.heat-axis { display:grid; grid-template-columns:repeat(var(--heat-days),minmax(13px,1fr)); column-gap:4px; min-width:var(--heat-min-width); }.heatmap { align-items:center; }.heat-cell { width:13px; height:13px; justify-self:center; border-radius:3px; background:#2b3035; }.heat-cell.l1{background:#174d70}.heat-cell.l2{background:#206f9e}.heat-cell.l3{background:#319bd1}.heat-cell.l4{background:#70c9ff}.heat-axis { margin-top:10px; color:var(--muted); font-size:10px; }.heat-tick { justify-self:center; white-space:nowrap; }.heat-tick.first { justify-self:start; }.heat-tick.last { justify-self:end; }.heatmap-empty { color:var(--muted); }.stacked-metrics { display:grid; gap:14px; }.stacked-metrics .metric { min-height:0; } .two-column { display:grid; grid-template-columns:1fr 1fr; gap:14px; margin-top:14px; }.subpanel { padding:22px; }.subpanel h3 { font-size:15px; }.usage-list { display:grid; gap:12px; }.usage-row { display:grid; grid-template-columns:minmax(100px,.75fr) 1fr 52px; gap:12px; align-items:center; font-size:12px; }.usage-row > span { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }.usage-row i { height:7px; overflow:hidden; border-radius:999px; background:#30353a; }.usage-row b { display:block; height:100%; background:linear-gradient(90deg,var(--blue-2),var(--blue)); }.usage-row strong { text-align:right; } .finding-list { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; }.finding-card { min-width:0; min-height:220px; display:flex; flex-direction:column; padding:18px; border:1px solid var(--line); border-radius:19px; background:linear-gradient(180deg,#1c2329,var(--panel)); }.finding-card-main { display:grid; gap:10px; }.finding-meta { display:flex; flex-wrap:wrap; gap:7px; }.finding-card h3 { min-height:2.8em; margin:0; display:-webkit-box; overflow:hidden; font-size:17px; line-height:1.4; -webkit-box-orient:vertical; -webkit-line-clamp:2; }.finding-preview { margin:0; color:#c7ced6; display:-webkit-box; overflow:hidden; font-size:13px; line-height:1.55; -webkit-box-orient:vertical; -webkit-line-clamp:2; }.finding-actions,.dialog-actions { display:flex; flex-wrap:wrap; justify-content:space-between; gap:10px; margin-top:auto; padding-top:16px; }.action-button { min-height:38px; padding:8px 13px; border:1px solid transparent; border-radius:10px; color:var(--text); background:#30363d; font:inherit; font-size:13px; font-weight:700; cursor:pointer; }.action-button:hover { filter:brightness(1.12); }.action-button:focus-visible { outline:2px solid var(--blue); outline-offset:2px; }.action-button.secondary { color:#cceaff; border-color:#286d9b; background:#164e75; }.action-button.ghost { color:#d4dae0; border-color:var(--line); background:transparent; }.finding-dialog,.manual-copy-dialog { width:min(880px,calc(100vw - 32px)); max-height:calc(100vh - 32px); overflow:auto; padding:24px; border:1px solid var(--line); border-radius:20px; color:var(--text); background:var(--panel); box-shadow:0 30px 90px rgba(0,0,0,.55); }.finding-dialog::backdrop,.manual-copy-dialog::backdrop { background:rgba(4,8,12,.72); }.dialog-heading { display:flex; justify-content:space-between; gap:18px; align-items:flex-start; }.dialog-heading h3 { margin:6px 0 0; font-size:24px; }.dialog-section { margin-top:18px; }.dialog-section p { margin:7px 0 0; color:#d1d7de; }.dialog-section ol,.dialog-section ul { margin:8px 0 0; padding-left:22px; color:#d1d7de; }.manual-copy-dialog textarea { width:100%; min-height:260px; margin-top:14px; padding:14px; resize:vertical; border:1px solid var(--line); border-radius:12px; color:var(--text); background:#11161b; font:13px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace; }.no-js .finding-actions,.no-js .manual-copy-dialog { display:none!important; }.no-js .finding-dialog:not([open]) { display:block; position:static; width:auto; max-height:none; margin-top:16px; box-shadow:none; } .suggestion-block { margin-top:28px; padding-top:24px; border-top:1px solid var(--line); }.suggestion-heading { display:flex; justify-content:space-between; gap:20px; align-items:end; margin-bottom:14px; }.suggestion-heading h3 { margin:4px 0 0; font-size:20px; }.suggestion-heading p { margin:0; color:var(--muted); font-size:12px; }.suggestion-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:12px; }.suggestion { padding:18px; border:1px solid var(--line); border-radius:17px; background:linear-gradient(180deg,#1b252d,var(--panel)); }.suggestion-top { display:flex; justify-content:space-between; gap:8px; }.suggestion h3 { margin:13px 0 7px; font-size:16px; }.suggestion > p { color:#c7ced6; font-size:13px; }.suggestion dl { display:grid; gap:9px; margin:14px 0 0; }.suggestion dl div { display:grid; gap:2px; }.suggestion dt { color:var(--muted); font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; }.suggestion dd { margin:0; color:#d7dde4; font-size:12px; } diff --git a/templates/reporting/html-visual.md b/templates/reporting/html-visual.md index d601ac3..2c9c802 100644 --- a/templates/reporting/html-visual.md +++ b/templates/reporting/html-visual.md @@ -59,7 +59,7 @@ Mirror the Markdown report and the parity rules in conclusions. Keep first-screen labels short. Put long paths, command output, screenshots, and -raw evidence anchors in an evidence table or appendix. +raw evidence anchors in an evidence table or appendix. Render retained UTC activity dates left-to-right; use shared cell/tick grid columns and gaps, and keep long-range scrolling inside the chart. HTML cannot assume a host chat API, native prompt injection, or host-specific deep links. For each shared finding/action row, render compact `Copy AI Fix` diff --git a/test/harness-report-render-cli.test.mjs b/test/harness-report-render-cli.test.mjs index 8135f19..16995cf 100644 --- a/test/harness-report-render-cli.test.mjs +++ b/test/harness-report-render-cli.test.mjs @@ -337,6 +337,26 @@ function taskLoopFindingsAtVersion(version) { return findings; } +function htmlReportDataWithActivity(dates, { language = "en", activeMinutes } = {}) { + const source = reviewedTaskLoopSource(); + source.sessionEvents.usageActivity.dates = dates; + source.sessionEvents.usageActivity.sessions.starts = dates.map((_, index) => index + 1); + source.sessionEvents.usageActivity.sessions.activeMinutes = activeMinutes ?? dates.map((_, index) => (index + 1) * 5); + const data = projectTaskLoopFindings(source, { + projectName: "render-source-project", + direct: true, + }); + return { + ...data, + language, + target: { name: "render-source-project", path: "/tmp/render-source-project" }, + }; +} + +function attribute(tag, name) { + return tag.match(new RegExp(`\\b${name}="([^"]*)"`, "u"))?.[1] ?? null; +} + function parseRun(stdout) { const payload = JSON.parse(stdout); assert.equal(payload.kind, "harness-report-render"); @@ -938,6 +958,73 @@ test("HTML mode mirrors the reviewed Agent Work Loop reader sections without Can }); }); +test("HTML activity chart binds every short-range UTC date to its horizontal grid column", () => { + const dates = ["2026-07-11", "2026-07-12", "2026-07-13", "2026-07-14"]; + const reportData = htmlReportDataWithActivity(dates); + + const html = renderHtml(reportData); + const cells = html.match(/]*>/gu) ?? []; + const ticks = html.match(/]*>/gu) ?? []; + + assert.equal(cells.length, 4); + assert.deepEqual(cells.map((tag) => attribute(tag, "data-date")), dates); + assert.deepEqual(cells.map((tag) => attribute(tag, "style")), [ + "grid-column:1", + "grid-column:2", + "grid-column:3", + "grid-column:4", + ]); + assert.deepEqual(ticks.map((tag) => attribute(tag, "data-date")), dates); + assert.deepEqual(ticks.map((tag) => attribute(tag, "style")), cells.map((tag) => attribute(tag, "style"))); + assert.ok(ticks.every((tag) => attribute(tag, "class") === "heat-tick")); + assert.match(html, /class="heat-scroll" style="--heat-days:4;--heat-min-width:64px"/u); + assert.doesNotMatch(html, /grid-template-rows:repeat\(7,13px\)|heat-legend/u); + for (const [index, cell] of cells.entries()) { + assert.equal(attribute(cell, "title"), `${dates[index]}: ${(index + 1) * 5} active minutes`); + assert.equal(attribute(cell, "aria-label"), attribute(cell, "title")); + } + assert.equal(evaluateHtmlReport(html, reportData).status, "pass"); +}); + +test("HTML activity chart keeps sparse long-range ticks bound to their source columns", () => { + const dates = Array.from({ length: 30 }, (_, index) => `2026-07-${String(index + 1).padStart(2, "0")}`); + const reportData = htmlReportDataWithActivity(dates); + + const html = renderHtml(reportData); + const cells = html.match(/]*>/gu) ?? []; + const ticks = html.match(/]*>/gu) ?? []; + + assert.equal(cells.length, 30); + assert.deepEqual(ticks.map((tag) => [attribute(tag, "data-date"), attribute(tag, "style")]), [ + [dates[0], "grid-column:1"], + [dates[7], "grid-column:8"], + [dates[14], "grid-column:15"], + [dates[21], "grid-column:22"], + [dates[29], "grid-column:30"], + ]); + assert.match(html, /class="heat-scroll" style="--heat-days:30;--heat-min-width:506px"/u); + assert.match(html, /\.heat-scroll \{[^}]*overflow-x:auto/u); + assert.equal(evaluateHtmlReport(html, reportData).status, "pass"); +}); + +test("HTML activity chart preserves empty, localized, accessible, and self-contained output", () => { + const emptyData = htmlReportDataWithActivity([], { activeMinutes: [] }); + const emptyHtml = renderHtml(emptyData); + assert.match(emptyHtml, /class="heatmap-empty" role="img"/u); + assert.doesNotMatch(emptyHtml, /class="heat-cell|class="heat-axis/u); + assert.equal(evaluateHtmlReport(emptyHtml, emptyData).status, "pass"); + + const chineseData = htmlReportDataWithActivity(["2026-07-11"], { language: "zh", activeMinutes: [15] }); + const chineseHtml = renderHtml(chineseData); + const chineseCell = chineseHtml.match(/]*>/u)?.[0] ?? ""; + assert.equal(attribute(chineseCell, "data-date"), "2026-07-11"); + assert.match(attribute(chineseCell, "title") ?? "", /^2026-07-11:/u); + assert.match(attribute(chineseCell, "title") ?? "", /15/u); + assert.equal(attribute(chineseCell, "aria-label"), attribute(chineseCell, "title")); + assert.doesNotMatch(chineseHtml, /]+\bsrc=|fetch\s*\(/iu); + assert.equal(evaluateHtmlReport(chineseHtml, chineseData).status, "pass"); +}); + test("HTML dimension progressbar semantics stay complete and score-bound", () => { // Given: a canonical reviewed report with five fluency dimensions. const reportData = {