Skip to content

fix(ui): derive the trend chart's grain from the request window - #282

Merged
argszero merged 1 commit into
mainfrom
fix/tx-trend-grain-follows-window
Sep 21, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/tx-trend-grain-follows-window

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

The transactions page sends its time window to the backend through txRangeParams()
(#tx-range plus the two custom datetime inputs) and asks for a trend grain through
txTrendBucket(). Both read the same three inputs, but they disagreed about what the
inputs mean:

control state request sent by txRangeParams() grain from txTrendBucket() (before)
all (all time) (no bounds) week
custom, both datetime boxes empty (no bounds — identical request) hour

So the grain was a function of which control produced the window, not of the window
that was actually requested. Two faces, both reachable:

  1. Same request, two grains. all and "custom with both boxes empty" produce the
    byte-identical list request, yet ask for week and hour respectively. The grain
    therefore does not describe the data the chart was asked to draw.
  2. The widest window is drawn at the finest grain. TX_TREND_MAX_COLS = 40 anchors
    the 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:00 ruler that labels those
    40 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=week returns 5 buckets, and
bucket=hour — the grain the UI actually asked for in the custom+empty state — returns
623 (ratio 124.6×), which is 15.6× the chart's own 40-column cap.

The fix makes the grain follow the request: txTrendBucket() reads txRangeParams() and
derives the grain from the window it will actually send. No lower bound ⇒ the span is
unknown ⇒ the coarsest grain (week), the rule all already used. With a lower bound,
the existing thresholds are kept (≤ 3.5 days ⇒ hour, ≤ 60 days ⇒ day, else week).

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 parses txRangeParams() instead of
    re-interpreting the #tx-range option values. Net −3 lines (the old
    control-value table plus its date fallbacks collapse into a window computation).
  • ui/index.html — cache-bust bump for app.js (20260922-4 → 20260922-5).
  • src/state_gate.rs — new gate
    the_trend_grain_derives_from_the_query_not_from_the_control, plus
    the_r165_roster_is_real, the_r165_rules_have_teeth,
    the_r165_rules_separate_the_variants and
    the_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.
  • No configuration / data-structure change, so no sample file is involved.

Tests

  • cargo test — 331 passed / 0 failed (baseline main 326; +5 = the new tests).
  • cargo fmt --check — clean.
  • cargo clippy --all-targets -- -D warnings — clean (CI's exact command).
  • New unit tests added.

What the gate pins (shape)

state_gate::tests::the_trend_grain_derives_from_the_query_not_from_the_control reads the
txTrendBucket body out of ui/js/app.js (and the option values out of ui/index.html,
so the two artifacts cannot drift apart) and asserts four rules, each with its own tooth:

rule assertion variant that flips only it
R1 the body carries no #tx-range option-value literal m_control: fixed body plus one control branch
R2 the body mentions txRangeParams m_nocall: fixed body with the window source renamed
R3 the returned grain literals stay within {hour, day, week} m_grain: fixed body plus one extra literal
R4 (reverse) txRangeParams is still defined exactly once and the control still exists m_hide / m_hour_start shape legs

Two controls guard the gate itself: m_hour_start (bound the empty case to 24 h) and
m_hide (skip the trend request when the range is incomplete) — both are plausible
competing 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 now in the request to the millisecond. Those are covered by the
jsdom 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 --test and clippy-driver -D warnings, twice: leg A against
the 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 the
pre-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.html plus the four real scripts, stubs
fetch, and drives the real #tx-range select and the real datetime inputs. Every leg
declares its expected verdict before measuring; the probe also prints the md5 of the
app.js it evaluated, so a leg can be tied to a byte string. Measured matrix:
live × landed all 9 legs green (md5 7cfadd7ec76e… = the shipped file),
baseline × {base, fix, m_day, m_hour_start, m_hide} all as declared.
The fix leg is a negative control on the instrument: it is the author's originally
drafted 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.

@argszero

Copy link
Copy Markdown
Owner Author

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 3d0331d.

What I verified

  • The ui/js/app.js hunk is the whole change on that file: the old control-value table plus
    its two date fallbacks collapse into "parse the request, look at start". Nothing else in
    app.js moved (the file is otherwise byte-identical to main).
  • ui/index.html carries exactly one js/app.js?v= token and it moved by one
    (20260922-4 → 20260922-5).
  • The four files on disk are exactly the edit sheet's output: re-applying E1/E2 to the
    pre-fix tree (git archive c448d31) reproduces the working tree byte for byte, and the
    landed src/state_gate.rs carries the gate fragment's two parts verbatim.
  • cargo test 331 / 0 (baseline 326), cargo fmt --check clean,
    cargo clippy --all-targets -- -D warnings clean.
  • The compiler gate (against the explicit baseline tree) is 18/18, and its rule-1-disarm
    negative control is 16/16; the jsdom matrix is 8/8, including two refusal controls for
    tree/variant combinations the declaration table does not cover.

Two things worth flagging, both caught only by CI's exact lint command

  1. The gate fragment carried a struct field that nothing read. cargo test was perfectly
    happy (the fragment's own pre-flight compiles with rustc --test, which does not read
    lints
    ); clippy -D warnings rejects it as dead_code. The field is gone rather than
    #[allow]-ed — an allowance would have made the lint silent forever.
  2. The fragment's r165_body opened with if function_source(..).is_none() { return None; },
    which clippy::question_mark rejects. Rewritten with ?.

Both are the same shape: the fragment pipeline had no lint leg, so lint-only failures
could not surface before landing. I have added clippy-driver -D warnings as a first-class
leg of the compiler gate (plus a leg asserting clippy-driver exists, so the lint leg
cannot silently vanish) and re-ran everything with it.

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
never written for (they were authored against the pre-fix tree). One of them reported a
mismatch — on the axis leg A3, which the landed body passes legitimately: the mutation
changes the request, and the landed grain now reads only the request, whereas the
pre-fix grain read the control. So the mismatch was about the declaration's scope, not
about the code. Rather than editing the declaration to match the measurement, the probe now
refuses tree/variant combinations outside its declared matrix (exit code 2), which is
what the two refusal controls in the matrix assert.

Not covered here: the deployed instance still runs the old front end, and nothing in
this PR depends on a deployment. No configuration or schema change.

@argszero
argszero merged commit abee8c3 into main Sep 21, 2026
1 check passed
@argszero
argszero deleted the fix/tx-trend-grain-follows-window branch September 21, 2026 23:33
@argszero argszero mentioned this pull request Sep 24, 2026
10 tasks
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