Skip to content

fix(wallet): fill the dashboard's 7-day series window so the sparkline's x axis is time - #218

Merged
argszero merged 1 commit into
mainfrom
fix/dashboard-sparkline-window-fill
Sep 13, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/dashboard-sparkline-window-fill

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

GET /api/dashboard's series is a sparse day-bucket list: GROUP BY date(time) emits a row only for days that actually have transactions.
ui/js/app.js (renderMonthChanges) hands that list straight to
sparkline(), which places point i at an index-based x
(pad + i*(w - 2*pad)/(len - 1)) instead of at its date. Days without
transactions were therefore collapsed rather than shown as zero.

Measured with jsdom (the real ui/index.html plus the four real scripts, only
fetch stubbed), driving the real card:

series fed to the card stroke path line segments
1 active day — what the handler returns today M60.0 2.0 0
the same week, window-filled M2.0 32.0 L21.3 32.0 ... L118.0 2.0 6
2 active days, first and last M2.0 2.0 L118.0 32.0 1

With a single active day the fill path has zero area and the stroke path is a
lone M command (per the SVG spec a subpath holding only a moveto is not
stroked), so there is no line at all while the number above it still
renders. With two active days the two points are stretched across the full
width, drawing a week-long trend out of a one-day spike.

The convention this breaks is written down four times in this tree, each time
with its reason:

The sparse series was wired up in f525242 (#79); every sibling was filled
afterwards. This is the one consumer that was left out — an omission, not a
choice. sparkline() has exactly two call sites (the guest branch's fixed
7-point array and this one), and the tree's three time-bucketed aggregations
(ops.rs::runtime, this endpoint, transactions_trend) now all fill their
window.

Related Issue

None (no issue exists for this; none was fabricated).

Changes

  • src/routes/wallet.rsseries now builds its window with a
    recursive CTE (today plus the previous 6 days, UTC) and a LEFT JOIN
    whose ON clause carries the user filter, so every day of the window
    gets exactly one row and days with no transactions get 0.
    The user filter has to stay in the JOIN condition: in WHERE it
    would discard precisely the empty days this change exists to add
    (verified by mutation — see below).
  • src/routes/wallet.rs — the endpoint's doc comment now states the fill
    (the doc already contrasted the two windows after C2049).
  • src/routes/wallet.rs — the old assertion !v["series"].is_empty()
    is trivially true once the window is filled, so it is rewritten into the
    shape assertion it meant (7 rows, ascending, ending today).
  • New tests dashboard_series_is_a_zero_filled_seven_day_window and
    dashboard_series_zero_fills_inside_the_user_and_the_window.

Not touched, on purpose: sparkline()'s per-point title structure (that
needs a browser-capable probe, recorded separately), the guest branch's dead
code, and the front-end. Zero-filling on the client was rejected on purpose:
it would re-derive a window the server already owns — a second copy of
date('now','-6 days'), which is exactly what C2094 judged a defect when the
week bucket was defined twice.

The window semantics are unchanged: still date(time) day keys, still
anchored on date('now', '-6 days'), still UTC. month / net (the
C2049 windows) are untouched.

Tests

  • cargo test222 passed (was 220).
  • cargo fmt --check — clean.
  • cargo clippy --all-targets -- -D warnings — clean.
  • dashboard_series_is_a_zero_filled_seven_day_window — asserts 7 rows in
    ascending order whose date set equals the window computed from
    chrono::Utc::now() (an authority independent of the CTE under
    test).
  • dashboard_series_zero_fills_inside_the_user_and_the_window — asserts
    the per-day values, each compared against a plain per-day SUM for the
    same user; another user's row inside the window and this user's row
    outside it must not appear; then a positive control writes a new
    transaction onto a day that was zero-filled and re-reads the endpoint, so
    the fill cannot be hiding data.
  • A/B — each mutation applied alone, reverted with the file's md5
    restored
    (baseline e6251ed2..., unmutated tree
    222 passed / 0 failed):
    - M1 the pre-change sparse query reddens 3:
    dashboard_series_is_a_zero_filled_seven_day_window,
    dashboard_series_zero_fills_inside_the_user_and_the_window,
    wallet_summary_and_dashboard — i.e. the new tests reject the
    pre-change tree
    .
    - M2 window off by one (-5 days) reddens 2 — a different set — and
    includes the shape test but not the value test.
    - M3 user filter moved into WHERE reddens the same 3 as M1 (moving
    it back into the join is the whole point of the LEFT JOIN).
    - M4 joining a datetime column against a date key reddens exactly 1:
    the value test, which is what makes it the guard for per-day values.
  • End-to-end: jsdom running the real ui/index.html with the unmodified
    api.js / data.js / i18n.js / app.js, fed the exact
    series the patched handler produces (captured from the router test): 7
    points, 6 line segments, 7 tooltips — against 1 point, 0 segments for the
    pre-change shape.

Checklist

  • Branch name follows the convention (fix/).
  • Commit message uses Conventional Commits (fix(wallet): ...).
  • Single responsibility, minimal change.
  • No config / schema change, so no example-file sync is needed.
  • No ui/ or i18n key change, so the i18n gates are unaffected.

…e's x axis is time

`GET /api/dashboard`'s `series` was a sparse day-bucket list: `GROUP BY
date(time)` emits a row only for days that actually have transactions, and
`ui/js/app.js::renderMonthChanges` feeds that list straight into
`sparkline()`, which places point i at an INDEX-based x
(`pad + i*(w - 2*pad)/(len - 1)`) rather than at its date. Days without
transactions were therefore collapsed instead of being shown as zero:

  * a week with a single active day degenerated to a lone `M` command, i.e.
    no line at all, while the number above it still rendered;
  * a week with two active days was drawn as one straight line across the
    full 7-day width, claiming a trend the data does not support.

Every sibling time series in this tree is zero-filled to its own window and
says why: `routes/ops.rs::runtime` fills hours 0..23 ('若不补零前端柱状图会
整体左移'), `app.js::dashTrendDays` and `app.js::txTrendDays` fill their day
buckets, and the guest branch of this very function always hands over 7
points. This was the only consumer that did not, so treat it as the omission
it is: `f525242` (#79) wired the sparse series up and the siblings were fixed
afterwards (#155/#156 and the ops PR6).

Fix: build the window with a recursive CTE (today plus the previous 6 days,
UTC) and a LEFT JOIN whose ON clause carries the user filter. The filter has
to stay in the JOIN condition: in WHERE it would drop exactly the empty days
this change exists to add. Only `src/routes/wallet.rs` changes; the window
semantics (a 7-day day-key window anchored on date('now', '-6 days'), UTC)
and the `month` / `net` windows of C2049 are untouched.

Evidence, all driven through the real router:
- `dashboard_series_is_a_zero_filled_seven_day_window`: 7 rows, ascending,
  the date set compared against chrono (`Utc::now()`) as an independent
  authority rather than derived from the CTE.
- `dashboard_series_zero_fills_inside_the_user_and_the_window`: per-day
  values equal a plain per-day SUM for the same user; another user's row
  inside the window and this user's row outside it never appear; a positive
  control re-checks a zero-filled day after a later transaction lands on it.
- `wallet_summary_and_dashboard`'s `!series.is_empty()` (trivially true once
  the window is filled) is rewritten into the shape assertion it meant.
- A/B, each mutation applied alone and reverted with the file's md5 restored:
  the pre-change sparse query reddens 3 tests (including both new ones); an
  off-by-one window (`-5 days`) reddens 2, a different set; carrying the user
  filter in WHERE reddens those same 3; comparing a datetime column against a
  date key reddens exactly 1 (the value test). Unmutated tree: 222 passed,
  0 failed.
- jsdom end-to-end (real `ui/index.html` plus the four real scripts, only
  `fetch` stubbed), fed the exact series the patched handler returns: 7
  points / 6 line segments / 7 tooltips, against 1 point / 0 segments for the
  pre-change shape. The 14-day bar chart on the same page keeps rendering 14
  columns in every leg.

Display / API shape only: no ledger, balance or settlement change. No `ui/`
or i18n key touched, so the i18n gate counts are unchanged.
`cargo fmt --check` and `cargo clippy --all-targets -- -D warnings` are clean;
`cargo test` goes from 220 to 222.
@argszero
argszero merged commit a9596f9 into main Sep 13, 2026
1 check passed
@argszero
argszero deleted the fix/dashboard-sparkline-window-fill branch September 13, 2026 19:20
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