fix(ui): derive the trend chart's grain from the request window - #282
Conversation
Self-review (author is the only reviewer — own-approve is not available here, so this comment stands in for it)Re-read the diff against the claims in the description, on the pushed commit What I verified
Two things worth flagging, both caught only by CI's exact lint command
Both are the same shape: the fragment pipeline had no lint leg, so lint-only failures A declaration-scope finding, recorded rather than papered over While probing the landed tree I also ran two mutation legs that the declaration table was Not covered here: the deployed instance still runs the old front end, and nothing in |
Summary
The transactions page sends its time window to the backend through
txRangeParams()(
#tx-rangeplus the two custom datetime inputs) and asks for a trend grain throughtxTrendBucket(). Both read the same three inputs, but they disagreed about what theinputs mean:
txRangeParams()txTrendBucket()(before)all(all time)weekcustom, both datetime boxes emptyhourSo the grain was a function of which control produced the window, not of the window
that was actually requested. Two faces, both reachable:
alland "custom with both boxes empty" produce thebyte-identical list request, yet ask for
weekandhourrespectively. The graintherefore does not describe the data the chart was asked to draw.
TX_TREND_MAX_COLS = 40anchorsthe x axis at the right edge, so an hourly unbounded window renders only the most
recent ~40 hours of a multi-month set — under an
MM-DD HH:00ruler that labels those40 hours as if they were the whole window.
Measured against the deployed dev instance (
GET /api/transactions/trend, real credential,2026-09-19) for the same unbounded window:
bucket=weekreturns 5 buckets, andbucket=hour— the grain the UI actually asked for in thecustom+empty state — returns623 (ratio 124.6×), which is 15.6× the chart's own 40-column cap.
The fix makes the grain follow the request:
txTrendBucket()readstxRangeParams()andderives the grain from the window it will actually send. No lower bound ⇒ the span is
unknown ⇒ the coarsest grain (
week), the ruleallalready used. With a lower bound,the existing thresholds are kept (≤ 3.5 days ⇒
hour, ≤ 60 days ⇒day, elseweek).Related Issue
None — the repository has no open issue for this; it was found during a UI audit of the
transactions view. No
Closes #N.Changes
ui/js/app.js—txTrendBucket()now parsestxRangeParams()instead ofre-interpreting the
#tx-rangeoption values. Net −3 lines (the oldcontrol-value table plus its date fallbacks collapse into a window computation).
ui/index.html— cache-bust bump forapp.js(20260922-4→20260922-5).src/state_gate.rs— new gatethe_trend_grain_derives_from_the_query_not_from_the_control, plusthe_r165_roster_is_real,the_r165_rules_have_teeth,the_r165_rules_separate_the_variantsandthe_r165_fixed_body_is_the_edit_sheet_text. Test-only module:#[cfg(test)] mod state_gate, nothing compiles into release artifacts.ui/README.md— documents the single-source rule and the gate's scope.Tests
cargo test— 331 passed / 0 failed (baselinemain326; +5 = the new tests).cargo fmt --check— clean.cargo clippy --all-targets -- -D warnings— clean (CI's exact command).What the gate pins (shape)
state_gate::tests::the_trend_grain_derives_from_the_query_not_from_the_controlreads thetxTrendBucketbody out ofui/js/app.js(and the option values out ofui/index.html,so the two artifacts cannot drift apart) and asserts four rules, each with its own tooth:
#tx-rangeoption-value literalm_control: fixed body plus one control branchtxRangeParamsm_nocall: fixed body with the window source renamed{hour, day, week}m_grain: fixed body plus one extra literaltxRangeParamsis still defined exactly once and the control still existsm_hide/m_hour_startshape legsTwo controls guard the gate itself:
m_hour_start(bound the empty case to 24 h) andm_hide(skip the trend request when the range is incomplete) — both are plausiblecompeting fixes that only satisfy the symptom, and the rule set rejects both.
Scope of the gate (stated, not implied)
The gate is lexical: it proves the body has no control-value literals and delegates to
the window source, and that its return literals are bounded. It does not prove the
thresholds (3.5 / 60 days) are the right ones, nor that the span computed from the parsed
window matches the
nowin the request to the millisecond. Those are covered by thejsdom instrument below, on the tree it is run against.
Independent instruments
Compiler gate — splices the fragment into a throw-away copy of the tree, then
rustfmt --check,rustc --testandclippy-driver -D warnings, twice: leg A againstthe pre-fix tree (the axis test must be the only red one) and leg B against a copy with
the edit applied (everything green). Run here against an explicit baseline tree
(
git archive c448d31) because after landing the working tree no longer carries thepre-image: 18/18 legs, plus a 16/16 rule-1-disarm negative control showing the teeth
tests have teeth. The fragment's fix body is asserted to be a substring of the edit
sheet's output, so what is measured and what ships cannot be two different things.
jsdom instrument — boots the real
index.htmlplus the four real scripts, stubsfetch, and drives the real#tx-rangeselect and the real datetime inputs. Every legdeclares its expected verdict before measuring; the probe also prints the md5 of the
app.jsit evaluated, so a leg can be tied to a byte string. Measured matrix:live × landedall 9 legs green (md57cfadd7ec76e…= the shipped file),baseline × {base, fix, m_day, m_hour_start, m_hide}all as declared.The
fixleg is a negative control on the instrument: it is the author's originallydrafted fix, a different implementation (regex over the query string rather than
URLSearchParams), so the legs are testing the contract and not one spelling of it.8/8 legs as declared, including two refusal controls for tree/variant combinations the
declaration table does not cover.