Skip to content

fix(frontend): give the savings charts a value summary and data table - #1381

Merged
IAmJSD merged 6 commits into
LeanerCloud:mainfrom
IAmJSD:ui/issue-744
Oct 24, 2026
Merged

IAmJSD merged 6 commits into
LeanerCloud:mainfrom
IAmJSD:ui/issue-744

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator

The Home and Purchases savings charts are <canvas role="img"> elements with fixed aria-labels, so a screen-reader user heard a generic description and could not get a single value out of them, and the stacked layers of the per-service chart differed only by shade. This PR gives each chart a data-driven summary label and a "View as table" disclosure with every value, and separates the stacked layers with a 2px gap.

Refs #744
Partial: the layers still share one hue per service. Distinct per-layer colors are in the branch for #189, which edits the same datasets; doing both here would conflict.

What changed

  • New frontend/src/lib/chart-a11y.ts: setChartSummary (data-driven aria-label) and renderChartTable/removeChartTable, a collapsed "View as table" <details> with a caption, column headers and row headers. Cells are built with textContent only, the disclosure is inserted after an anchor outside Chart.js's sizing box, and it keeps its open state on refresh.
  • frontend/src/dashboard.ts: the "Potential savings range per service" label states the service count, total and largest service, and its table lists Current / Committed, Lowest option, Upside and Total for every service (including any beyond the 10 bars drawn). The "Savings over time" label states the range, latest cumulative value and point count, with a Date / Cumulative savings table. Both tables are removed in empty and error states. The per-service datasets get a 2px top border in the card color, so adjacent layers are separated by a gap.
  • frontend/src/modules/savings-history.ts: the same summary and table (Date / Period savings / Cumulative savings) for the Purchases chart, placed after its .chart-container.
  • frontend/src/styles/charts.css: disclosure and table styles (scrolls inside a 320px box).
  • Tests: new chart-a11y.test.ts (6), new cases in dashboard.test.ts (7) and savings-history.test.ts (2), and a new tests-e2e/chart-text-alternative.spec.ts.

Commits: edf1c487 (helper), 44678ef8 (charts).

Why this approach

A native <details> disclosure works with the keyboard and screen readers without custom JS, stays out of the way when collapsed, and also helps sighted users who can't separate the colors (the issue's "View as table" option). The ladder timeline already uses the same pattern (dynamic aria-label plus a table), so this follows an existing convention in the repo. Putting the table after the canvas's own box avoids Chart.js resize loops.

Screenshots

Before is main (a14dc8f2), after is this branch. Both are headless Chromium at 1440x900 against the repo's mocked e2e API. The yellow dashed box is injected by the screenshot script to print the canvas's aria-label (what a screen reader announces); it is not part of the app.

Before After
service-chart before service-chart after
A fixed description with no values, no table, and layers in shades of one hue with no separation. The label states service count, total and largest service; the expanded "View as table" lists every layer value per service; 2px gaps separate the stacked layers.

Verification

Run on this branch's head 44678ef8 on top of main a14dc8f2, from frontend/:

  • npm run lint: 0 errors (existing warnings only). Typecheck and build pass.
  • Jest: 110 suites, 3501 passed, 1 skipped. The new tests also pass with --randomize.
  • Playwright (e2e-local.sh): 159 passed, including the new chart-text-alternative.spec.ts (the per-service label names the service count; the table opens with the keyboard and lists every service by row header; the trend chart has a label and table).
  • Secrets scan clean (planted key flagged); scripts/test-git-secrets-allowlist.sh: 137 PASS, 0 FAIL.

Notes for review

  • Copy to check: "View as table", and the label sentences, e.g. "Stacked bar chart of monthly savings for 4 services, $575 in total. Largest: compute at $210. Layers: ... Values are in the table below the chart."
  • Tables can be long (the 7-day trend and the Purchases 7-day chart are hourly, up to about 168 rows). They are collapsed by default and scroll inside a 320px box.
  • The "layers differ only by shade" part is only partly addressed here (2px gaps). The distinct per-layer colors depend on fix(frontend): "Potential savings range per service" legend and bar-color encoding mismatch #189's branch; if that color change is rejected, a pattern or hatching follow-up is needed.
  • The static aria-label in index.html is still the initial value before data loads; it is replaced on every render.

Overlaps

Note for the UI owner

This changes UI code that @cristim owns. IAmJSD reviewed the before/after screenshots and asked for it to be merged; any design point you disagree with can be a follow-up.

🤖 Generated with Claude Code

IAmJSD and others added 2 commits October 23, 2026 23:01
…table)

Chart canvases are role="img", so their aria-label is all a screen
reader can announce, and every Home/Purchases chart uses a fixed label
with no values. lib/chart-a11y.ts adds setChartSummary (a data-driven
aria-label) and renderChartTable / removeChartTable, a collapsed
"View as table" <details> listing every plotted value with column and
row headers. It is inserted after an anchor the caller picks so it never
sits inside a box Chart.js measures, keeps its open state across data
refreshes, and builds cells with textContent only.

Refs LeanerCloud#744

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Home "Savings over time" and "Potential savings range per service"
charts and the Purchases savings-history chart now set an aria-label
that states what is plotted (count, total, largest service or latest
cumulative value) and render a "View as table" disclosure with every
value; the table is removed when the chart switches to its empty or
error state. The per-service table lists every service, including any
beyond the 10 bars drawn.

The per-service chart's stacked layers also get a 2px card-colored gap
on top of each segment, so adjacent layers are separated by more than
their shade difference.

Refs LeanerCloud#744

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD IAmJSD added priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged urgency/this-quarter Within the quarter impact/many Affects most users effort/m Days type/bug Defect labels Oct 24, 2026
@coderabbitai

coderabbitai Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

This review includes 8 billable files and costs up to $2.00.

Or wait 59 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration
Commits

Reviewing files that changed from the base of the PR and between 19534fc and 21ea7a8.

Files selected for processing: 8
  • frontend/src/__tests__/chart-a11y.test.ts
  • frontend/src/__tests__/dashboard.test.ts
  • frontend/src/__tests__/savings-history.test.ts
  • frontend/src/dashboard.ts
  • frontend/src/lib/chart-a11y.ts
  • frontend/src/modules/savings-history.ts
  • frontend/src/styles/charts.css
  • frontend/tests-e2e/chart-text-alternative.spec.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

Conflicts:
- frontend/src/dashboard.ts (imports): main's formatTrendTooltipTitle
  import (LeanerCloud#1323) plus LeanerCloud#744's chart-a11y import.
- frontend/src/dashboard.ts (chart constants): kept LeanerCloud#744's SEGMENT_GAP and
  table ids and main's SAVINGS_LAYER_COLORS (LeanerCloud#1335). Took main's removal
  of the per-service palette helpers (serviceBarColor, parseHexColor,
  darkenHexColor, legend swatch constants). SEGMENT_GAP's comment now
  says the gap separates layers by an edge as well as by layer colour,
  since main made the layers differ by hue.
- frontend/src/dashboard.ts (renderSavingsByService): kept LeanerCloud#744's summary
  label and "View as table" table; took main's removal of the per-service
  shade derivation. Each dataset keeps main's layer colour plus LeanerCloud#744's
  ...SEGMENT_GAP (auto-merged).
- frontend/src/styles/charts.css: both appended blocks kept, LeanerCloud#744's
  .chart-table rules then main's Trends tab rules (LeanerCloud#1317).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
IAmJSD and others added 3 commits October 24, 2026 19:43
Conflicts:
- frontend/src/dashboard.ts (loadSavingsTrendChart empty and error
  paths): kept this branch's removeChartTable(SAVINGS_TREND_TABLE_ID) and
  took main's removal of attachSparkline('ytd', []) there; since LeanerCloud#1344 the
  YTD sparkline has its own series loaded by loadYtdSparkline(), so the
  trend chart's empty/error state no longer clears it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts:
- frontend/src/dashboard.ts (imports): took main's widened chart-utils
  import (formatSparseTrendMessage, MIN_TREND_POINTS from LeanerCloud#1334) and kept
  this branch's lib/chart-a11y import.
- frontend/src/dashboard.ts (loadSavingsTrendChart): kept this branch's
  removeChartTable in the no-data branch and main's new sparse-data branch
  (LeanerCloud#1334). The sparse branch also hides the canvas, so it now calls
  removeChartTable too, so a table from an earlier render does not
  outlive its chart (the empty and error states already do this).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e state

Main's LeanerCloud#1334 shows a "not enough history yet" message instead of a chart
when the savings trend has fewer than MIN_TREND_POINTS (2) points. The
LeanerCloud#744 test that checks the trend table is removed on empty data started
from a single point, which is now the sparse state with no chart and no
table. It now starts from two points, and a new case checks the table is
also removed when the data drops to the sparse state.

Refs LeanerCloud#744

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD

IAmJSD commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): APPROVE 21ea7a89e86665959d11ad30d768aff9f0dadd2e

Blocking:

@IAmJSD

IAmJSD commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Local verification at 21ea7a89 merged onto current main (030c5d44; merge-tree df2f1058; Linux, Node, headless Chromium):

  • This branch's own checks passed at this head: lint, typecheck, build, the full jest suite, the full Playwright e2e suite, and the git-secrets checks (see Verification in the PR body).
  • The combined tree of every UI PR in this merge wave (tree ab77aa6e, main plus 5 PRs) passes npm ci, lint, typecheck, build, jest (1 skipped, 4248 passed, 4249 total), and the full Playwright suite (247 passed).
  • CI is green on 21ea7a89 (CI Success, Run pre-commit hooks).
  • The independent review above approves this exact head.
  • Nothing was applied to any cloud.

@IAmJSD
IAmJSD marked this pull request as ready for review October 24, 2026 22:06
@IAmJSD
IAmJSD merged commit 2a3110e into LeanerCloud:main Oct 24, 2026
29 checks passed
@IAmJSD

IAmJSD commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Marked ready and merged by IAmJSD's agent, at IAmJSD's request (IAmJSD reviewed the before/after screenshots; cristim is traveling), after the independent review and local verification above, all at 21ea7a89.

IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 24, 2026
Brings in LeanerCloud#1365, LeanerCloud#1381, LeanerCloud#1378 and LeanerCloud#1389. No conflicts; index.html
auto-merged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/many Affects most users priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/bug Defect urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant