Skip to content

fix(frontend): update savings charts in place, drop stale trend data - #1383

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

IAmJSD merged 8 commits into
LeanerCloud:mainfrom
IAmJSD:ui/issue-747

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator

The Home "Savings over time" and per-service charts and the Purchases savings-history chart were destroyed and recreated on every range, filter, period or unit change, so the grow-from-zero animation replayed each time. The Home trend loader also had no request-generation guard: clicking 7d then 30d could let the slower 7d response paint last, under the highlighted 30d button. This PR updates those charts in place and ignores superseded trend responses.

Closes #747

What changed

  • frontend/src/modules/chart-utils.ts: new upsertChart(existing, canvas, config). If the existing instance still draws on the same canvas, it sets data/options and calls update('none'); otherwise it destroys any stale instance and creates a new one.
  • frontend/src/dashboard.ts: both Home charts use upsertChart (the per-service chart now destroys only in its empty state). loadSavingsTrendChart takes a generation number per call and returns early after the await (and in the catch) when a newer call has started.
  • frontend/src/modules/savings-history.ts: the Purchases chart uses upsertChart.
  • The ladder timeline item listed in the issue was already fixed on main by 6c4be959 (fix(ladder): show cumulative commitment as a stepped stacked chart #823), which updates that chart in place (frontend/src/ladder-timeline.ts:97).
  • Tests: new __tests__/chart-upsert.test.ts and 4 new cases in __tests__/dashboard.test.ts.

Why this approach

Updating in place is what the issue suggests and keeps one Chart instance per canvas; the canvas-identity check keeps it safe if a view re-renders its DOM. update('none') skips the transition entirely; a short tween from old to new values would also stop the replay but isn't what the issue asks for. The generation counter copies the existing riexchange.ts pattern rather than adding AbortController plumbing to the API client.

Screenshots

Before is main (a14dc8f2), after is this branch. Both are headless Chromium at 1440x900 against the repo's mocked e2e API.

Before After
trend-stale-response before trend-stale-response after
trend-range-switch-150ms before trend-range-switch-150ms after
  • Stale response: click 7d (slow response), then 30d. On main the 30d button is highlighted but the chart shows the 7-day data ($0-$150, hourly Oct 16-23) that arrived last. After, the stale 7d response is ignored and the chart shows the 30-day data ($900, Sep 23-Oct 23).
  • 150 ms after clicking 30d: on main the rebuilt chart is still growing from zero (the line ends near $700 of $900). After, it is already fully drawn at $900. This pair depends on animation timing; the stale-response pair is the deterministic one.

Verification

Run on this branch's head a18ba161 on top of main a14dc8f2:

  • Lint: 0 errors (existing warnings). Typecheck and build pass.
  • Jest: 110 suites, 3493 passed, 1 skipped. New: chart-upsert.test.ts (3: first render creates; same canvas updates in place with 'none' and no destroy; replaced canvas recreates) and 4 in dashboard.test.ts (an older trend response landing last is ignored; an older request failing last doesn't show the error state; a second trend load updates in place; a per-service re-render updates in place while the empty state still destroys). New tests pass with --randomize.
  • Playwright via e2e-local.sh: 157 passed. No new spec: the race and the animation are asserted in jest with deferred promises and a Chart mock, and the screenshots reproduce both in the real build.
  • git-secrets scan clean (planted key flagged); scripts/test-git-secrets-allowlist.sh: 137 pass, 0 fail.

Notes for review

Overlaps

Those PRs will be opened alongside this one and merged in sequence, with conflicts resolved on the later branch.

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

…sponses

The Home per-service and "Savings over time" charts and the Purchases
savings-history chart were destroyed and recreated on every filter,
range, period or unit change, so Chart.js replayed its grow-from-zero
entry animation each time. A new upsertChart helper
(modules/chart-utils.ts) swaps the data and options into the existing
instance and redraws with update('none'); it only creates a chart on
first render or when the canvas was replaced, and the empty states
still destroy the chart.

loadSavingsTrendChart had no request-generation guard, so clicking one
range and then another could let the older, slower response paint last
and show its data under the newer range button. It now takes a
generation number per call and ignores responses (and errors) from
superseded calls, the same pattern riexchange.ts uses.

The ladder timeline item from the issue was already fixed in LeanerCloud#823, which
updates that chart in place.

Refs LeanerCloud#747

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/s Hours 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 6 billable files and costs up to $1.50.

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 10f2c8a and 30232cc.

Files selected for processing: 6
  • frontend/src/__tests__/chart-upsert.test.ts
  • frontend/src/__tests__/dashboard.test.ts
  • frontend/src/dashboard.ts
  • frontend/src/modules/chart-utils.ts
  • frontend/src/modules/savings-history.ts
  • frontend/src/trends.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

IAmJSD and others added 7 commits October 24, 2026 17:23
Conflicts:
- frontend/src/dashboard.ts (import): main (LeanerCloud#1323) added
  formatTrendTooltipTitle to the chart-utils import, this branch added
  upsertChart. Import both.
- frontend/src/dashboard.ts (renderSavingsByService): main (LeanerCloud#1335)
  dropped the per-service bgColors/darken/upside shade block in favour of
  the fixed SAVINGS_LAYER_COLORS; this branch replaced new Chart(...) with
  upsertChart(...). Took main's layer colours and kept upsertChart, so the
  per-service chart still updates in place.
- frontend/src/modules/chart-utils.ts: both sides appended a helper after
  formatTrendAxisTick (main's formatTrendTooltipTitle, this branch's
  upsertChart). Kept both, main's first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After merging main, webpack's ts-loader rejected trends.ts (new on main)
with TS2322 assigning Chart<'bar', (number | null)[], string> to the
default Chart type. tsc --noEmit still passed: Chart.js's generic
variance check depends on the order files are checked in, and adding
upsertChart (which takes the default ChartConfiguration) to chart-utils
changed that order for ts-loader. Declaring trendsChart with the exact
type it holds removes the cross-type assignment.

Refs LeanerCloud#747

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts:
- frontend/src/modules/chart-utils.ts: import block only; kept this
  branch's chart.js import (for upsertChart) and main's formatCurrency
  import (LeanerCloud#1376).
- frontend/src/dashboard.ts: chart-utils import line only; import both
  main's formatSparseTrendMessage/MIN_TREND_POINTS (LeanerCloud#1334) and this
  branch's upsertChart. The sparse-data early return from main sits
  before the upsertChart call and destroys the chart like the empty state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No conflicts (picks up LeanerCloud#1387, which main gained after the previous merge).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main's LeanerCloud#1334 (issue LeanerCloud#188) shows a sparse-data message instead of a
chart when the trend has fewer than MIN_TREND_POINTS (2) points. The
stale-response and update-in-place tests used one point, so no chart
was drawn. Use two points so they still exercise the chart path.

Refs LeanerCloud#747

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts:
- frontend/src/dashboard.ts: imports kept both sides (main's buildKpiTile,
  upcoming-scope and chart-a11y imports plus this branch's upsertChart).
  renderSavingsByService and loadSavingsTrendChart keep main's LeanerCloud#744
  setChartSummary/renderChartTable calls before the chart call, and use
  this branch's upsertChart instead of destroy + new Chart. The trend
  loader's LeanerCloud#747 generation guard and main's removeChartTable in the
  empty, sparse and error states both stay.
- frontend/src/modules/savings-history.ts: took main's LeanerCloud#749 single-series
  chart (line for cumulative, bar per period) and LeanerCloud#744 summary/table, and
  replaced its destroy + new Chart with this branch's upsertChart.
- frontend/src/__tests__/dashboard.test.ts: both sides added tests at the
  same spots; kept all of them (LeanerCloud#747 stale-response and in-place cases,
  LeanerCloud#744 text-alternative cases).

Semantic change to combine LeanerCloud#749 with LeanerCloud#747:
- frontend/src/modules/chart-utils.ts: upsertChart now also recreates the
  chart when the configured chart type differs from the existing one,
  since LeanerCloud#749's series toggle switches the Purchases chart between 'line'
  and 'bar' and an in-place update keeps the old type. Same-type period
  and unit changes still update in place. chart-upsert.test.ts gains a
  type-change case, and the in-place mocks in it and dashboard.test.ts
  now expose `config.type` like a real Chart instance.

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 30232cc996a0633c43986238ab50d928aa584cb5

Blocking:

@IAmJSD

IAmJSD commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Local verification at 30232cc9 merged onto current main (d9242008; merge-tree cc063343; 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 ee0a6278, main plus 3 PRs) passes npm ci, lint, typecheck, build, jest (1 skipped, 4400 passed, 4401 total), and the full Playwright suite (264 passed).
  • CI is green on 30232cc9 (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 23:39
@IAmJSD
IAmJSD merged commit c582cfd 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 30232cc9.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours 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.

fix(frontend): charts rebuilt on every change; dashboard trend has no stale guard

1 participant