Skip to content

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

Draft
IAmJSD wants to merge 7 commits into
LeanerCloud:mainfrom
IAmJSD:ui/issue-747
Draft

IAmJSD wants to merge 7 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

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • 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 6 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>

This branch has not been deployed

No deployments
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