Skip to content

Date filtering, transaction details, and per-transaction tag classification [3/6] - #100

Open
terryaney wants to merge 5 commits into
davidfowl:mainfrom
terryaney:feature/ui-tweaks
Open

Date filtering, transaction details, and per-transaction tag classification [3/6]#100
terryaney wants to merge 5 commits into
davidfowl:mainfrom
terryaney:feature/ui-tweaks

Conversation

@terryaney

@terryaney terryaney commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Title: Date filtering, transaction details, and per-transaction tag classification [3/6]

[3/6] Based on feature/report-json-determinism (#99).
Merging this PR into main also lands #98 and #99 if not already merged.
This layer only: feature/report-json-determinism...feature/ui-tweaks

This PR fixes a data correctness bug in the HTML report and adds date filtering, transaction details, and layout polish.

Screenshots below are from simulated data — visual reference only.

Data Correctness Fix

Charts, Filtered View totals, and Section View percentages previously classified transactions using merchant.tags (the union across all transactions for that merchant). One income or transfer transaction could misclassify every other transaction at the same merchant. This PR switches to per-transaction txn.tags, matching the Python-side KPI logic.

image

Changes

  • Per-transaction tag classification for charts, Filtered View, and Section View percentages
  • Month / Quarter / Year / Custom date filtering with URL hash persistence
  • Collapsible Transaction Details with sticky headers and pinned totals
  • Loading shell while Vue mounts
  • report_fields config so captured fields like memo appear without per-rule passthroughs
  • Layout preferences persisted in localStorage with a reset control
  • Rule provenance popup improvements
  • Starter title changed to Tally Spending Analysis

Behavior changes

Calling these out explicitly — they change what the report does, not just how it looks, and they aren't obvious from the diff:

  • Chart clicks now toggle visibility instead of applying filters. Clicking a category pie segment or a category-by-month bar no longer adds a category/date filter. This is deliberate, not a dropped handler. (Reimagined charts and KPI experience [4/6] #101 then reworks it further, making Spending by Category an explicit filter surface with tri-state legend chips.)
  • Available months now come from categoryView, so a month is no longer lost just because its merchant was excluded from every configured view.
  • Section View percentages use a new denominatorfilteredViewTotals.value.spending, computed per transaction — instead of whole-merchant include/exclude decisions derived from merchant tag unions, which could drop ordinary spending from the denominator in mixed-tag datasets.
  • Blank captured fields no longer render, so the phantom +1 Memo indicator is gone.
  • Rule field: directives that evaluate to empty now emit no field at all, rather than a field with a blank value. A field: directive runs for every transaction its rule matches, so a merchant whose rule captures a memo previously attached an empty memo to every transaction that didn't have one — which is what produced a +1 popup indicator on transactions with nothing in the popup. 0 and False are retained; only None and empty strings/lists/dicts are dropped. This affects the +N indicator, the dynamic extra_fields columns in CSV export, and report search — it does not affect rule matching, since field.<name> in a match expression reads captured columns from the data file, not values emitted by another rule's field: directive.

Mobile: this is a locally generated HTML file and I expect nearly all use to be desktop/large display. Several pre-existing mobile issues remain and are deliberately out of scope here.

Added while addressing review feedback

  • Section percentages changed value. The denominator moved to a spending-only basis (above) but the numerator stayed a raw sum, so a section holding income, investment or transfers divided one basis by the other and could exceed 100%. Sections and categories now carry a spending-only subtotal computed with the same per-transaction classification, and the section percentage uses it. The per-merchant percentage inside a category is unchanged — both its halves are raw totals, so it was already self-consistent.
  • An excluded date chip is no longer flipped to an inclusion. The date popover edits include-mode filters only: it will not rehydrate an excluded month or range into the pending set, and Apply no longer replaces chips it does not own.
  • Dates the calendar does not have are rejected. 2/31/2026, month 13 and 99/99/2026 previously parsed and produced filter chips that could never match a transaction.
  • A malformed date range in the URL hash no longer hangs the report. #+dr:garbage..garbage walked to the string NaN-NaN, which compares less than the malformed end value forever, growing an array without bound until the tab died.
  • report_fields is validated. A bare report_fields: memo was iterated character by character and treated as four capture names; an empty report_fields: raised TypeError. A bare string is now accepted as the single name it plainly means, an empty value means none, and anything else is rejected by name.
  • The report title is escaped and coerced. It reached both the loading shell's markup and the embedded spendingData script unescaped, and a non-string value such as title: 2025 raised TypeError. The title is now resolved once and stored back onto spendingData, so the static shell and the mounted app can no longer disagree — previously an untitled report visibly renamed itself on mount, because the two fallbacks were different strings.

The </ escaping applied to the embedded JSON covers every string in the payload, not just the title. Merchant descriptions come from user CSVs and had the same exposure, so this is not new to this branch.

Date Filtering

image image

Loading State

image

Transaction Details

image image image image

Stack

# Branch PR Description Diff
1 feature/globbing-documentation #98 Glob pattern docs and CLI tests from main
2 feature/report-json-determinism #99 Deterministic report JSON from PR98 · from main
3 feature/ui-tweaks #100 Date filter, transaction details, per-txn tags from PR99 · from main
4 feature/charts-reimagined #101 Reimagined charts and KPIs from PR100 · from main
5 feature/merchant-composite-keys #102 Composite merchant keys from PR101 · from main
6 feature/categorization #103 Categorization review file from PR102 · from main

Independent: feature/ci-repair#97 — fixes fork PR builds.

- Add glob pattern examples in settings.yaml.example, clarify formats docs for file globs.
- Add CLI tests covering multi-file matching, no-match behavior, diag visibility, and sorted processing order.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds date filtering, transaction-detail controls, and per-transaction classification to the HTML report, alongside stacked glob and deterministic-JSON changes.

Changes:

  • Adds date filters, persistent layout controls, transaction details, and chart updates.
  • Introduces report_fields configuration and rule provenance display.
  • Adds glob coverage and deterministic report serialization.

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
tests/test_report_html.py Covers report UI and date filtering.
tests/test_cli.py Tests glob path support.
tests/test_analyzer.py Tests determinism and report fields.
src/tally/templates.py Updates starter configuration.
src/tally/spending_report.js Implements report behavior and filtering.
src/tally/spending_report.html Adds report controls and loading UI.
src/tally/spending_report.css Styles the updated report.
src/tally/report.py Builds report title and provenance data.
src/tally/parsers.py Promotes configured captured fields.
src/tally/merchant_utils.py Adds rule provenance metadata.
src/tally/merchant_engine.py Suppresses empty computed fields.
src/tally/format_parser.py Stores report-field configuration.
src/tally/config_loader.py Resolves global and source report fields.
src/tally/commands/run.py Deduplicates report source names.
src/tally/commands/reference.py Documents report fields in CLI help.
src/tally/cli_utils.py Updates starter settings generation.
src/tally/analyzer.py Stabilizes tag ordering.
docs/reference.html Documents report fields.
docs/formats.html Documents globs and captured fields.
config/settings.yaml.example Adds glob and report-field examples.
Suppressed comments (2)

src/tally/spending_report.html:90

  • Each month option is a mouse-only div, making the primary date-selection control inaccessible from the keyboard. Render these as buttons and expose their selected state.
                            <div v-for="m in activeYearMonths" :key="m.key" class="month-cell"
                                 :class="{ selected: pendingMonths.has(m.key) }"
                                 :data-testid="'date-month-cell-' + m.key"
                                 @click="toggleDateItem({ type: 'month', key: m.key })">{{ m.label.split(' ')[0] }}</div>

src/tally/report.py:373

  • The configured title is also inserted unescaped in the embedded-output branch. Titles containing < are parsed as markup, and closing tags can inject HTML into the generated report. HTML-escape the resolved title before either placeholder replacement.
        final_html = html_template.replace(
            '__REPORT_TITLE__', resolved_title
        ).replace(

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/tally/spending_report.js
Comment thread src/tally/spending_report.js
Comment thread src/tally/spending_report.js
Comment thread src/tally/spending_report.js
Comment thread src/tally/spending_report.js
Comment thread src/tally/report.py Outdated
Comment thread src/tally/report.py
Comment thread src/tally/merchant_engine.py
Comment thread src/tally/spending_report.js
Comment thread src/tally/spending_report.html Outdated
@terryaney terryaney mentioned this pull request Aug 4, 2026
terryaney and others added 4 commits August 4, 2026 16:18
The data-source table's file row used markdown backticks, which render
literally in an HTML page. Every other cell in that table already uses
<code> elements.
`tally up` writes <output>.json beside the HTML and diffs the next run
against it, so the same config and data must produce the same bytes. It
did not: pattern.tags serialized in arbitrary order.

- analyzer.py: sort pattern.tags unconditionally. The isinstance(set)
  guard was dead - merchant_utils.normalize_merchant already does
  list(result.tags), so the value arrives as a list carrying the set's
  arbitrary iteration order. Mirrors the top-level merchant tags field,
  which was already sorted.
- run.py: extract collect_source_names() and dedupe the report subtitle
  sources while preserving settings.yaml declaration order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…le Info

1. BIGGEST UI GAIN: Revamped date filter to be more functional than simply just a month list
2. IMPORTANT DATA FIX: Two data-correctness bugs - in both, the JS classified transactions using
   merchant.tags — the union of every tag across all of a merchant's transactions — instead of the
   transaction's own txn.tags (see Bug Fix Details below))
3. Removed year from title to support multi-year data/reporting and made page title match content title
4. Data from... subtitle is distinct list of source
5. `Include Negatives` has badge count and hides when 0
6. Hide all app components (and added progress bar) while VUE was mounting/initializing
7. Charts UI Polish/Changes
   - Renamed: "Monthly Trend" -> "Cash Flow Trend", "By Category" -> "Spending
     by Category", "Category Trends by Month" -> "Spending by Category Trend".
   - Cash Flow Trend now includes Credits to match cash-flow definition.
   - Removed the synthetic Income/Investment datasets from the category trend chart;
     it is now purely spending by category.
   - Legends are visibility toggles on all three charts. The category trend
     legend previously added a category filter on click.
   - Removed the data-click filter handlers from both category charts. The
     Cash Flow Trend bar click (month filter) is retained.
   - Both category charts share one ranking: top 10 categories plus an "Other" rollup.
   - Series with no data are no longer emitted, so an all-zero Investment
     series no longer leaves a phantom legend entry.
   - Months with nothing to plot are dropped per chart (a transfer-only month
     drew an empty column).
   - Cash Flow Trend bar clicks index into monthlyChartMonths — the months
     actually plotted — instead of availableMonths. Under a date filter the two
     diverged and a bar click filed the wrong month.
8. Transaction Details - container + collapser polish
   - Introduce **containment** — wrap the button row + all category collapsers in one **Transaction Details**
   - Left-align the view buttons; add collapse/expand controls on the right and polish each collapser
   - Fix view-toggle animation cascade in Transaction Details
     - Unify Merchant/Subcategory category views into one keyed v-for
	 - Vue patches <section> nodes in place instead of remounting on a groupByMode toggle
   - Wrap the toggle on mobile (<=640px) — view modes on line 1, actions on line 2; Collapse becomes icon-only
   - Reworked merchant row action presentation: explicit info and filter links
   - Refined expanded transaction row structure for better presentation
   - Fixed 'report fields' badge in transaction detail rendering
   - Adaptive column sizing architecture
     - Introduced dynamic transaction column sizing via CSS variables for
       date, account, and amount tracks.
     - Added startup-time width profiling and debounced resize recalculation
       for desktop-first responsiveness.
9. Rule Info Fixes
   - Added explicit rule provenance into popup data (ruleName, pattern, source)
     so "Why This Matched" reflects the actual classification path.
   - Improved fallback behavior: when no categorization rule matches, promote
     the first matching tag rule so popup rule details remain meaningful.
   - Switched popup tag display to merchant-level union tags (matching the main grid)
   - Cleaned up tag-source reporting to avoid redundant/misleading "from [rule]"
     lines when the displayed rule already explains the tag.
10. Save Layout Settings to Local Storage
   - Transaction Trends collapsed state
   - Transaction Details collapsed state
   - Details view mode (Merchant, Subcategory, View)
   - Include Negatives toggle
   - Per-section collapse state
   - Per-item expansion state (merchant/subcategory rows)
   - Per-section sort settings
11. Fix merchant filter regression and stale tests from row-UX/date polish
- getFilterDescriptor() used item.id (category-specific) instead of
  item.displayName for merchant-type filters, breaking cross-category
  merchant filtering when the same merchant appears in multiple categories.
- Test suite is in line with two intentional behavior changes
  already on this branch that its own tests hadn't caught:
  - Category sections now default to collapsed on a fresh load
    (regardless of "Save Layout Settings to Local Storage")
  - Transaction dates always show the year now

Bug Fix Details:

Both bugs are the same root cause: the JS classified transactions using
merchant.tags — the union of every tag across all of a merchant's
transactions (analyzer.py:122 builds it that way) — instead of the
transaction's own txn.tags, which report.py already emits per transaction.
categorizeAmount() is a strict income -> investment -> transfer -> sign
chain, so one tagged transaction re-bucketed every other transaction at
that merchant. This changed reported dollar amounts; it is not cosmetic.

Bug 1 — charts and the Filtered View tile (filteredViewTotals,
chartAggregations). Every number in all three charts and in the Filtered
View KPI tile was wrong. A merchant whose union is
[monthly-bill, refund, transfer] had all of its ordinary purchases counted
as transfers and vanish from spending. Now classified per transaction, the
JS reconciles with the Python KPIs to the dollar.

Both KPI tiles now agree line-for-line on unfiltered data.

Bug 2 — Section View percentages (grossSpending). grossSpending was
grandTotal + creditsTotal, and both inputs drop a whole merchant when its
tag union is excluded from spending. It feeds the (X%) badge beside each
section header, which renders whenever typeTotals is absent — that is,
Section View always (Category/Subcategory pass server-computed typeTotals
and were never affected). The denominator ran 15% low: $215,322 against a
true $253,781, hiding $6,910 of Amazon spending, $2,448 of Minnesota
Department of Revenue, and more. grossSpending now derives from the same
per-transaction pass and equals Python's spendingTotal exactly. Monthly
Bills reads 23.8% (was 28.0%), Food & Dining 20.0%.

Documented, not fixed: creditMerchants has the same merchant-union bug. It
is unrendered — the "Credits Applied" section was dropped from the template
in ad9477e while docs/reference.html still documents it — so a comment on
the computed records the bug and the two ways to fix it when the section is
restored.
Report title (report.py, spending_report.js)
- Escape the title before it is interpolated into the loading shell's
  <title> and <h1>; a title carrying markup was executable output.
- Escape '</' in the embedded spendingData JSON, so a value containing
  '</script>' cannot close the script element it is embedded in.
- Coerce a non-string title: 'title: 2025' arrives from YAML as an int
  and raised TypeError inside str.replace().
- Resolve the title once and store it back on spendingData, so the
  static shell and the mounted app cannot disagree. The two fallbacks
  differed, so an untitled report was renamed on mount.

Date handling (spending_report.js)
- parseTypedDate now rejects dates a calendar does not have. 2/31/2026,
  month 13 and 99/99/2026 passed the shape regexes and became chips that
  could never match a transaction.
- expandMonthRange validates both halves are YYYY-MM before iterating.
  A hand-edited '#+dr:garbage..garbage' walked to 'NaN-NaN', which
  compares less than 'garba' forever - the report hung with the array
  growing without bound.
- The date popover now edits include-mode filters only. An excluded
  month or range rehydrated into the pending set and came back out of
  Apply as an inclusion, silently inverting the filter.

Percentages (spending_report.js, spending_report.html)
- Section percentages divided a raw total, which includes income,
  investment and transfers, by grossSpending, which excludes them.
  Carry a spending-only subtotal on the same per-transaction basis and
  use it as the numerator.

Performance (spending_report.js)
- Cache transaction column measurements by string. Every transaction
  forced three synchronous layouts, repeated across all three profiles;
  large reports stalled. Cleared per recompute so a resize re-measures.

Accessibility (spending_report.html, spending_report.css)
- Year tabs and month cells are buttons rather than click-only divs, so
  they are keyboard-reachable and announce their selected state.
- Name the reset control with aria-label rather than leaving the glyph
  as its only accessible name.

Config and docs
- Validate report_fields. A bare 'report_fields: memo' was iterated as
  four capture names and an empty value raised TypeError; accept the
  bare string, treat empty as none, and reject other types by name.
- Correct the reference: a field: directive's output is not an input to
  other rules, but the captured column report_fields surfaces is
  available to every rule as field.<name>.
terryaney added a commit to terryaney/OpenSource.tally that referenced this pull request Aug 4, 2026
21 code issues fixed across rungs 98/100/101/103, 31 review threads
replied to and resolved, behavior changes added to the davidfowl#100, davidfowl#101 and
davidfowl#103 bodies and pushed.

Records two findings that changed the answer (100-1 was wider than
reported, 101-2's stated scenario does not reproduce) and the rebase
failure mode that stranded two fix commits.
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