Skip to content

Live preview of filter decisions for screens beneath the filter modal#76

Merged
tomfunk merged 7 commits into
tomfunk:devfrom
MarkIannucci:filter-live-preview
Jun 15, 2026
Merged

Live preview of filter decisions for screens beneath the filter modal#76
tomfunk merged 7 commits into
tomfunk:devfrom
MarkIannucci:filter-live-preview

Conversation

@MarkIannucci

Copy link
Copy Markdown
Contributor

One thing to call out --- the useLoadGuard helper function is in the tui code. Would this sort of guard be helpful in the GUI code?

Setting this to draft as I haven't completed local testing yet

--- Opus generated PR description follows ---

What & why

The filter panel previously only affected the underlying screens on Enter — you committed a filter blind, then saw the result. This adds live preview: as you toggle categories, accounts, owners, or tags in the panel, the screen behind it re-renders with the draft filter in real time. Esc discards the draft and restores the original view; Enter commits it.

This implements the "Live preview plan" in docs/filter-panel-plan.md.

How it works

The mechanism is deliberately small so consumer screens need zero changes:

  • FilterContext gains a preview: Filter | null state alongside the committed history. useFilter().filter now returns preview ?? committed. Because Dashboard, Transactions, and Trends already read filter, they all gain preview for free. The context also exposes committed separately and a setPreview setter.
  • FilterPanel extracts its serialized draft into a draftFilter memo and publishes it via setPreview (debounced 120ms so bulk ops like a/n/i collapse into one query round-trip). It clears the preview on unmount, so Esc/Enter both fall back to the committed filter. Enter still commits via setFilter — a single history push, so the existing Esc-to-step-back behavior stays clean.

Two subtleties worth calling out for review:

  • The panel hydrates its draft from committed, not filter — reading the live value would re-seed the draft from the panel's own preview, a feedback loop.
  • Preview bypasses pushFilter, so toggling 15 checkboxes while previewing never pollutes filter history; only the final Enter does.

Hardening: out-of-order query results

Live preview turns one-query-per-action into a stream of queries as the draft changes. Since the load paths were getX().then(setState) with no cancellation, a slow earlier query could resolve after a faster later one and paint stale data. This PR adds a shared useLoadGuard hook (a monotonic token; only the newest load applies its result) and wires it into every filter/period-driven async load:

  • Transactions — the transaction list load.
  • Dashboard — summary, drift, search-stats, search-filtered data, and merchant drill.
  • Trends — period totals, live match count, search totals.

Testing

  • Live preview — toggling updates the list before Enter; Esc reverts without touching the committed filter; Enter commits and updates the summary; preview reaches the Dashboard it was opened from; a burst of draft changes collapses into a single preview query; previewing many toggles never pushes history (commit pushes exactly one level).
  • Race guard — a useLoadGuard unit test pins the token semantics; Transactions and Dashboard integration tests force the unfiltered load slow and assert the faster filtered result survives the stale one that follows (both fail without the guard — verified).
  • Full suite: 553/553 pass, tsc --noEmit clean.

Notes

  • Only the active screen is mounted, so preview repaints just the screen the panel was opened from (no redundant hidden-screen queries) — which is exactly the desired "update the numbers behind the panel" behavior.
  • Trends' rendered output (bars/totals) has no clean unique string to assert against, so its guard wiring leans on the shared useLoadGuard unit test plus the two integration tests proving the identical pattern; the wiring is the same three-line shape in all three screens.

MarkIannucci and others added 6 commits June 12, 2026 14:04
Toggling selections in the filter panel now updates the dashboard,
transactions, and trends screens immediately (debounced), via a preview
filter in FilterContext that screens consume transparently. Esc reverts to
the committed filter with no history pollution; Enter commits as before.
- Extract the preview debounce window to a named PREVIEW_DEBOUNCE_MS const.
- Document why the panel hydrates from `committed` (not `filter`): reading the
  live value would re-seed the draft from the panel's own preview — a loop.
- Note the unmount cleanup is distinct from the debounce cleanup: the latter
  only cancels a pending timer, which would otherwise strand an already-
  published non-null preview when closing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Assert the preview reaches the Dashboard it was opened from (via the
  Expenses total, which no panel row renders).
- Assert a burst of draft changes collapses into a single preview query.
- Assert previewing never pollutes filter history — commit pushes exactly one
  level, so one Esc in Transactions returns straight to the original view.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Filter changes — the live preview most of all — can fire queries faster than
they resolve. A slow earlier query could land after a faster later one and
paint stale rows. Stamp each load with a monotonic token and apply only the
newest result; earlier ones are discarded on arrival.

Test forces the unfiltered load slow and the filtered load fast, then asserts
the fast result survives the stale one that follows (fails without the guard).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract the Transactions sequence-token guard into a shared useLoadGuard hook
and apply it to every filter/period-driven async load on Dashboard (summary,
drift, search stats, search-filtered data, merchant drill) and Trends (period
totals, match count, search totals), so the live preview can't leave any screen
showing stale data from a slow earlier query. Transactions now uses the hook
too, so all three screens share one mechanism.

Tests: a useLoadGuard unit test pins the token semantics, and a Dashboard race
test mirrors the Transactions one (forces the unfiltered summary slow, asserts
the faster filtered result survives the stale one that follows).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@tomfunk

tomfunk commented Jun 13, 2026

Copy link
Copy Markdown
Owner

Good question. The GUI is mostly already covered — useQuery (gui/renderer/src/hooks/useQuery.ts) uses the same alive flag cleanup pattern, so all the filter-driven loads in the GUI screens get the equivalent guard for free. That hook does the same work as useLoadGuard at the useEffect level.

The one place it'd be worth adding explicitly is countPatternMatches in the Transactions rule-edit modal (gui/renderer/src/screens/Transactions.tsx:446), which is a raw useEffect + .then() that fires as the user types a pattern. Wiring would look the same as in the TUI: pull useLoadGuard up to gui/renderer/src/hooks/ (or a shared src/hooks/), then:

const matchGuard = useLoadGuard();
useEffect(() => {
  if (pattern.trim()) {
    const token = matchGuard.begin();
    void api.rules.countPatternMatches(pattern, matchType)
      .then((count) => { if (matchGuard.isLatest(token)) setMatchCount(count); });
  } else {
    setMatchCount(0);
  }
}, [pattern, matchType]);

No pressure to take it on in this PR — I'm happy to handle the GUI side in a follow-up.

@tomfunk

tomfunk commented Jun 13, 2026

Copy link
Copy Markdown
Owner

Handled in #78 — pulled useLoadGuard into gui/renderer/src/hooks/ and wired it into the countPatternMatches effect.

@MarkIannucci MarkIannucci marked this pull request as ready for review June 15, 2026 02:03
@MarkIannucci

Copy link
Copy Markdown
Contributor Author

@tomfunk , I was able to test this functionality out and it is working quite nicely.

@tomfunk tomfunk merged commit a66cdf7 into tomfunk:dev Jun 15, 2026
3 checks passed
tomfunk added a commit that referenced this pull request Jun 15, 2026
Mirrors the TUI live preview from #76. useFilter now exposes a
preview slot; filter returns preview ?? committed so the FilterBar
panel can publish a debounced draft as the user toggles, with the
underlying Dashboard/Transactions/Trends repainting before Apply.
Cancel/X clears the preview on unmount; Apply commits via setFilter
as before. The panel hydrates from committed (not the live filter)
to avoid re-seeding itself from its own preview.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
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.

2 participants