Skip to content

Answer every budget control in the same frame - #28

Merged
suiramdev merged 1 commit into
devfrom
budget-instant-feedback
Sep 8, 2026
Merged

Answer every budget control in the same frame#28
suiramdev merged 1 commit into
devfrom
budget-instant-feedback

Conversation

@suiramdev

Copy link
Copy Markdown
Owner

Summary

Every control on the Budget pages now answers in the same frame: the pressed control changes at once and the data it affects dims until the fresh answer replaces it (StaleRegionaria-busy immediately, opacity after 100 ms, so a fast answer never flickers). Look first at the new apps/web/src/lib/budget/budget-queries.ts, which owns the transactions query options, the prefetchers and isStaleView, then at how transactions.tsx threads them. The direction tab, sort toggle, period arrows and range toggle prefetch their target after an 80 ms pointer rest, so the click swaps from cache. Server-side filtering is unchanged.

Motivation

  • placeholderData: keepPreviousData held the previous answer on screen, so a filter, sort or period click looked like nothing happened for the whole round trip.
  • Each keystroke in either search box re-rendered the whole page, because the draft lived in the view hook.
  • Every recharts series tweened 1500 ms on each mount and view toggle, which read as slowness rather than motion.
  • getTransactions awaited its three database calls in sequence when none depends on another.

Drawbacks

  • Prefetch is pointer-only: keyboard users get the dim, not the head start. Adding onFocus would fetch every control's target as focus passes through.
  • QueryCache.onError now skips queries with no observer, so a failure whose component already unmounted is silent too.
  • One oxlint-disable-next-line sort-keys in budget-queries.ts: alphabetical order puts getNextPageParam before queryFn, and its lastPage then infers as unknown.
  • Prefetching trades requests for latency; a slow sweep across the toolbar can warm periods nobody opens.

Prior art

  • TanStack Router preloads <Link> targets on intent; these are search-param navigations, which it does not cover, hence useHoverIntent.
  • The repo's loading vocabulary covered first paint (skeleton) and pressed controls (RandomSpinner) but not content about to be replaced; a spinner there was rejected, since spinners stay reserved for pressed controls.
  • Considered client-side filtering of a full period (as /budget/recurring does) and rejected it: the list pages 50 rows at a time and the totals cover the whole filtered range.

Notes

  • Visual change: dimmed regions while a view is being replaced, and charts that no longer animate in.
  • Verified against the OrbStack stack with 144 seeded transactions and 800 ms emulated latency: five prefetches on a period-arrow rest and zero requests plus zero dim on the following click; 0 chart re-renders while typing nine characters; identical chart geometry at first paint and 400 ms later (a mutation back to isAnimationActive={true} makes them differ); getTransactions totals equal a direct SQL aggregate. Evidence in a comment below.
  • Tests: none added; the change is feedback and latency, and the existing suites cover the reshaped modules (392 pass).
  • apps/fumadocs: no page changes — no screen, route, environment variable, API shape or vocabulary moves. getTransactions keeps its response shape, so integrations/procedures.mdx stays as it is.
  • Reviewer gate: changes_requested on the first pass (prefetch failures toasted, the empty list escaped the dim, useSettledText.settled went dead), all three fixed, re-judged approved with no findings.
  • Pre-existing defect found and deliberately left alone: getTransactions sets nextCursor to the extra row it popped and re-queries with skip: 1, so that row is never shown. It predates this branch and deserves its own change with a regression test.

Filtering, sorting, paging the period or clicking a chart rewrote the URL
and refetched, and `keepPreviousData` held the old answer on screen, so a
click looked like nothing happened until the round trip landed.

Now the pressed control changes at once and the data it affects dims
(`StaleRegion`: aria-busy immediately, opacity after 100 ms, so a fast
answer never flickers) under one screen-reader status line. The direction
tab, sort toggle, period arrows and range toggle prefetch their target
after an 80 ms pointer rest, so the click swaps from cache. The search
box owns its draft, so a keystroke re-renders the input alone. Recharts
series paint their final state instead of tweening 1500 ms, and
`getTransactions` runs its three database calls concurrently.

A failure nothing on screen observes no longer toasts: a prefetch has no
observer, and its Retry would invalidate a query no component reads.
@coderabbitai

coderabbitai Bot commented Sep 8, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: b7857152-bae0-40a3-8e31-a559c7bb9ad7

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@suiramdev

Copy link
Copy Markdown
Owner Author

Verification log, for anyone who wants the numbers rather than the claim. Run against the OrbStack dev stack, 144 seeded transactions over June–September 2026, CDP Network.emulateNetworkConditions at 800 ms latency unless noted.

Same-frame feedback. Picking a category chip: the chip and the URL change in the next sampled frame while the previous 8 rows stay; aria-busy is set at once and opacity reaches 0.6 at ~200 ms (100 ms delay + 150 ms fade); at ~900 ms the answer lands, rows drop to 2, aria-busy clears, opacity returns to 1. Clicking the period arrow with the pointer teleported (page.mouse.click at its box centre, so no 80 ms rest elapses): the label changes in the first frame, four regions — KPI strip, both chart bodies, rows — dim together, and one output.sr-only reads Updating the view while dimmed and is absent after. With no throttling the same click shows aria-busy for a single ~100 ms sample and never reaches the fade, which is what the delay is for.

Prefetch. performance.getEntriesByType('resource') filtered to /rpc/budget/:

Action On rest On the click that follows
Previous-period arrow getSpendingBreakdown, getSankeyData, getFixedVsVariable, getBudgetVsActual, getTransactions 0 requests, 0 dimmed regions
Incoming tab getTransactions 0
Amount sort getTransactions 0
Sweep 1M3M1Y at 40 ms each 0
Rest on 1Y the five

Search. Typing carrefour (9 keys, 30 ms apart) re-renders BudgetCharts 0 times, measured with a temporary render counter written to document.documentElement.dataset and since removed; a view switch moved the same counter, so it was live. The input shows every keystroke and the URL gains q=carrefour once, after the settle. Same on /budget/recurring with rq.

Chart motion. The pie's path d at first paint equals its d 400 ms later, likewise .recharts-area-area on the forecast. Mutation check: with isAnimationActive={true} the two reads differ (1,0,170.9794,86.15011,0,47.7469,15.4143), so the probe is meaningful.

Prefetch failures stay silent, observed failures do not. With CDP Network.setBlockedURLs: ["*/rpc/budget/*"], resting on the period arrow attempts all five prefetches and raises 0 toasts over 12 s; clicking the same arrow under the same block raises 5 toasts after the retry backoff and the charts show their unavailable state.

Server. POST /api-reference/budget/getTransactions for September returns incoming 324500, outgoing 134569, 9 rows — equal to sum(amount) FILTER (...) straight from Postgres. With limit: 5 the cursor chain and the totals are stable across pages.

Gates. bun x ultracite check clean; apps/web tsc --noEmit reports only the pre-existing server/plugins/evlog-drain.ts errors; packages/api tsc error count unchanged (23 lines, none in routers/budget.ts); bun test apps/web/src/lib/budget packages/api packages/auth packages/ui scripts → 392 pass, 0 fail; en.json and fr.json hold the same 809 keys.

@suiramdev
suiramdev merged commit 2bb1e0b into dev Sep 8, 2026
3 checks passed
@suiramdev
suiramdev deleted the budget-instant-feedback branch September 8, 2026 22:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant