|
| 1 | +# Transaction History — Advanced Filter and Sort |
| 2 | + |
| 3 | +How the transaction history table decides which rows to show and in what order. |
| 4 | + |
| 5 | +- Engine: [`frontend/src/lib/transactionQuery.ts`](../src/lib/transactionQuery.ts) |
| 6 | +- Filter state: [`frontend/src/hooks/useTransactionFilters.ts`](../src/hooks/useTransactionFilters.ts) |
| 7 | +- Sort state: [`frontend/src/hooks/useTransactionSort.ts`](../src/hooks/useTransactionSort.ts) |
| 8 | +- UI: [`TransactionFilterPanel`](../src/components/TransactionFilterPanel.tsx), |
| 9 | + [`TransactionFilterChips`](../src/components/TransactionFilterChips.tsx), |
| 10 | + [`TransactionSortControl`](../src/components/TransactionSortControl.tsx) |
| 11 | +- Page: [`frontend/src/pages/TransactionHistory.tsx`](../src/pages/TransactionHistory.tsx) |
| 12 | + |
| 13 | +## Architecture |
| 14 | + |
| 15 | +The page holds no filter or sort state of its own. The URL is the single source |
| 16 | +of truth, the hooks parse it, and the pipeline is three pure functions: |
| 17 | + |
| 18 | +``` |
| 19 | +transactions ──filterTransactions──▶ filtered ──sortTransactions──▶ sorted ──paginateRows──▶ page |
| 20 | +``` |
| 21 | + |
| 22 | +All three live in `lib/transactionQuery.ts` and take their inputs as arguments — |
| 23 | +no React, no router, no clock. That is what lets the table's behaviour be |
| 24 | +specified in unit tests (`transactionQuery.test.ts`) instead of only observed |
| 25 | +through the DOM, and it is why a change to ordering or matching rules should be |
| 26 | +made there rather than in the page component. |
| 27 | + |
| 28 | +Because state lives in the URL, every filtered, sorted view is bookmarkable, |
| 29 | +shareable, and restored correctly by browser back/forward. |
| 30 | + |
| 31 | +## URL parameters |
| 32 | + |
| 33 | +| Parameter | Meaning | Example | |
| 34 | +| --- | --- | --- | |
| 35 | +| `search` | Free-text query; space-separated terms are ANDed | `?search=xlm+failed` | |
| 36 | +| `types` | Comma-separated transaction types | `?types=deposit,withdrawal` | |
| 37 | +| `statuses` | Comma-separated statuses | `?statuses=pending,failed` | |
| 38 | +| `asset` | Asset code, matched case-insensitively | `?asset=usdc` | |
| 39 | +| `dateFrom` / `dateTo` | Inclusive `YYYY-MM-DD` bounds | `?dateFrom=2026-01-01&dateTo=2026-03-31` | |
| 40 | +| `amountMin` / `amountMax` | Inclusive numeric bounds | `?amountMin=100&amountMax=5000` | |
| 41 | +| `sort` | Ordering, highest priority first | `?sort=status:asc,amount:desc` | |
| 42 | +| `sortBy` / `direction` | Single-column ordering (legacy) | `?sortBy=amount&direction=asc` | |
| 43 | +| `page` / `pageSize` | Pagination | `?page=2&pageSize=25` | |
| 44 | + |
| 45 | +Every parameter is treated as untrusted input. Unknown types, statuses, sort |
| 46 | +fields and directions are dropped; malformed dates and negative amounts are |
| 47 | +discarded; a repeated sort field keeps only its first occurrence. A bad |
| 48 | +parameter therefore degrades to "filter not applied" rather than an error or an |
| 49 | +empty table. |
| 50 | + |
| 51 | +## Sorting |
| 52 | + |
| 53 | +### Multi-column ordering |
| 54 | + |
| 55 | +Up to **three** columns (`MAX_SORT_KEYS`) can be sorted at once, in priority |
| 56 | +order: later keys only decide rows that tie on every earlier key. Three is the |
| 57 | +point past which the ordering stops being explicable from the header row alone. |
| 58 | + |
| 59 | +Sortable fields are `date`, `amount`, `type` and `status`. `asset` and the |
| 60 | +transaction hash are deliberately not sortable — alphabetising either tells a |
| 61 | +user nothing they came to the page to learn. |
| 62 | + |
| 63 | +Two gestures build the same state: |
| 64 | + |
| 65 | +- **Click a header** — replaces the sort with that column, cycling |
| 66 | + `default direction → opposite → off`. Cycling back to "off" returns to the |
| 67 | + default ordering, so a user can always undo by clicking again. |
| 68 | +- **Shift-click a header** — appends the column as a lower-priority tiebreaker, |
| 69 | + leaving existing keys and their order alone. A third shift-click on the same |
| 70 | + column removes it. |
| 71 | + |
| 72 | +The **Sort** panel in the table toolbar exposes the same capability as ordinary |
| 73 | +buttons: it names each active key, shows its priority, and offers flip, reorder, |
| 74 | +remove and reset. Shift-click is a pointer gesture that has to be known in |
| 75 | +advance; the panel is how the feature stays reachable without it. |
| 76 | + |
| 77 | +### Ordering rules |
| 78 | + |
| 79 | +Three rules matter enough to state, because a plausible-looking refactor can |
| 80 | +silently break each one: |
| 81 | + |
| 82 | +1. **Total ordering.** Comparison always ends with a transaction-id comparison, |
| 83 | + so a given (rows, sort keys) pair yields exactly one ordering regardless of |
| 84 | + input order. Without it, rows that tie on every key reshuffle whenever the |
| 85 | + upstream fetch returns them in a different sequence. |
| 86 | +2. **Absent values sort last in both directions.** A `null` amount or an |
| 87 | + unparseable timestamp goes to the bottom whether the sort is ascending or |
| 88 | + descending. Reversing "unknown" to the top on a descending sort would present |
| 89 | + missing data as the largest data. |
| 90 | +3. **Categories sort by meaning, not alphabet.** Status orders as |
| 91 | + `pending → completed → failed`, so sorting by status surfaces transactions |
| 92 | + that still need attention instead of burying them between `completed` and |
| 93 | + `failed`. Type orders as `deposit → withdrawal → transfer → trade`. |
| 94 | + |
| 95 | +Amounts are compared numerically. Sorting them as strings would place `1000` |
| 96 | +before `9`. |
| 97 | + |
| 98 | +### URL representation |
| 99 | + |
| 100 | +`sort` is written for every explicit ordering and absent when the table is on |
| 101 | +its default (`date:desc`); its presence is what distinguishes "the user chose |
| 102 | +this" from "the default". The primary key is also mirrored into the legacy |
| 103 | +`sortBy`/`direction` params so older links keep working and so `useDataTableState` |
| 104 | +— which rewrites those params on every page change — cannot contradict `sort`. |
| 105 | + |
| 106 | +A legacy pair that merely restates the default is read as "no explicit sort", so |
| 107 | +a page change is not mistaken for a sort choice. |
| 108 | + |
| 109 | +Changing the ordering resets to page 1: a row's page number means nothing under |
| 110 | +a different order. |
| 111 | + |
| 112 | +## Filtering |
| 113 | + |
| 114 | +### Matching rules |
| 115 | + |
| 116 | +- **Search** matches type, status, asset and hash, case-insensitively. Multiple |
| 117 | + terms are ANDed, so adding a term narrows the result rather than widening it. |
| 118 | +- **Types and statuses** are OR within a filter and AND across filters: an empty |
| 119 | + selection means "all", never "none". |
| 120 | +- **Asset** is matched case-insensitively and ignores surrounding whitespace. |
| 121 | +- **Date bounds are inclusive UTC calendar days** — `dateFrom` starts at |
| 122 | + `T00:00:00.000Z` and `dateTo` ends at `T23:59:59.999Z`. Anchoring to UTC rather |
| 123 | + than the viewer's timezone means a shared link selects the same transactions |
| 124 | + for the sender and the recipient. |
| 125 | +- **A bounded amount range excludes rows with no amount.** A row whose amount is |
| 126 | + unknown cannot be shown to satisfy "at least 100"; letting it through would |
| 127 | + overstate what the filtered set contains. With no bound set, such rows appear |
| 128 | + normally. |
| 129 | + |
| 130 | +### Contradictory ranges |
| 131 | + |
| 132 | +A range whose bounds cross over (`dateFrom` after `dateTo`, `amountMin` above |
| 133 | +`amountMax`) can only ever match zero rows. Rather than render an empty table — |
| 134 | +which reads as "you have no transactions", a different and alarming claim — the |
| 135 | +engine **skips that range** and `validateTransactionFilters` reports the issue, |
| 136 | +which the panel renders as an inline message with `aria-invalid` and |
| 137 | +`aria-describedby` on both inputs. Other filters continue to apply. |
| 138 | + |
| 139 | +The date inputs carry no native `min`/`max` bounds. Bounding each by the other |
| 140 | +blocks a range from being shifted earlier or later with no explanation, and |
| 141 | +leaves a contradictory range arriving from a shared URL undiagnosed. |
| 142 | + |
| 143 | +### Quick ranges |
| 144 | + |
| 145 | +The preset buttons (last 7 / 30 / 90 days, year to date) resolve to **absolute** |
| 146 | +dates that are written into the URL, so a shared link keeps the range it was |
| 147 | +shared with instead of drifting with the reader's clock. Rolling windows include |
| 148 | +today, so "last 7 days" spans today and the six days before it. |
| 149 | + |
| 150 | +Which preset is highlighted is *derived* by comparing the current range back |
| 151 | +against "now" (`matchDatePreset`) rather than stored, so there is no second copy |
| 152 | +of the state to disagree — and a range stops being highlighted exactly when it |
| 153 | +stops meaning "the last 7 days". |
| 154 | + |
| 155 | +`resolveDatePreset` takes `now` as an argument rather than reading the clock, so |
| 156 | +the mapping is deterministic and testable. |
| 157 | + |
| 158 | +### Active filter chips |
| 159 | + |
| 160 | +Every applied filter is summarised as one removable chip. |
| 161 | +`describeActiveFilters` returns structured descriptors, not copy — labels are |
| 162 | +translated in the component — and each `type`/`status` selection gets its own |
| 163 | +chip so one value can be dropped without clearing the rest. |
| 164 | + |
| 165 | +The chips sit **outside** the panel's collapsible body: collapsing the panel must |
| 166 | +not hide the fact that rows are being filtered out. |
| 167 | + |
| 168 | +## Accessibility |
| 169 | + |
| 170 | +- Sorted headers carry `aria-sort`; the priority badges and arrow glyphs are |
| 171 | + `aria-hidden`, since the same information reaches assistive technology through |
| 172 | + `aria-sort` and the sort panel. Keeping them out of the accessible name also |
| 173 | + keeps each header's name equal to its label. |
| 174 | +- A polite live region announces the new ordering after every sort change — |
| 175 | + `aria-sort` on an unfocused header is not announced when it changes. |
| 176 | +- A refused sort (the three-key cap) is announced rather than silently dropped. |
| 177 | +- The sort panel states each key's direction in words ("Amount: Descending"), |
| 178 | + not only as an arrow. |
| 179 | +- Range validation messages are wired to their inputs with `aria-describedby` |
| 180 | + and announced politely. |
| 181 | +- Sort priority is never conveyed by colour alone: the badge carries a number. |
| 182 | + |
| 183 | +## Shared table components |
| 184 | + |
| 185 | +`DataTable` and `VirtualizedDataTable` accept multi-sort through two optional |
| 186 | +props, additive to the existing single-column API: |
| 187 | + |
| 188 | +| Prop | Purpose | |
| 189 | +| --- | --- | |
| 190 | +| `sortKeys` | Active `{ field, direction }[]`; supersedes `sortBy`/`sortDirection` | |
| 191 | +| `onSortToggle` | `(columnId, additive) => void`; `additive` mirrors the Shift modifier | |
| 192 | + |
| 193 | +Header presentation is resolved by `getColumnSortState` in |
| 194 | +[`dataTableSort.ts`](../src/components/dataTableSort.ts), shared by both tables. |
| 195 | +Tables that pass only `sortBy`/`sortDirection` — Portfolio, for one — are |
| 196 | +unaffected. |
| 197 | + |
| 198 | +## Tests |
| 199 | + |
| 200 | +| File | Covers | |
| 201 | +| --- | --- | |
| 202 | +| `src/lib/transactionQuery.test.ts` | Filter matching, ordering rules, sort-param parsing, presets, validation, pagination | |
| 203 | +| `src/hooks/useTransactionSort.test.ts` | Multi-sort URL round-trip, legacy params, cap refusal, page reset | |
| 204 | +| `src/hooks/useTransactionFilters.test.ts` | Filter param parsing and setters | |
| 205 | +| `src/components/dataTableSort.test.ts` | Header sort-state resolution for both prop shapes | |
| 206 | +| `src/components/TransactionSortControl.test.tsx` | Sort panel behaviour and labelling | |
| 207 | +| `src/components/TransactionFilterChips.test.tsx` | Chip labelling and per-value removal | |
| 208 | +| `src/components/TransactionFilterPanel.test.tsx` | Presets, validation messaging, chips while collapsed | |
| 209 | +| `src/pages/TransactionHistory.test.tsx` | End-to-end filter/sort behaviour in the rendered table | |
| 210 | + |
| 211 | +## Extending |
| 212 | + |
| 213 | +- **A new sortable column**: add the field to `SORTABLE_FIELDS`, give it a |
| 214 | + default direction in `DEFAULT_SORT_DIRECTION`, add a case to `sortValue`, add |
| 215 | + an i18n key to `SORT_FIELD_LABEL_KEY`, and mark the column `sortable` in the |
| 216 | + page's column definitions. |
| 217 | +- **A new filter**: extend `TransactionFilters`, parse its param in |
| 218 | + `useTransactionFilters`, apply it in `filterTransactions`, and emit a chip from |
| 219 | + `describeActiveFilters` so it appears in the summary. |
| 220 | +- **Server-side filtering**: the engine functions take rows and filters as |
| 221 | + arguments, so moving the work behind the API means calling the endpoint in |
| 222 | + `useTransactionHistory` and dropping the client-side call — the URL contract, |
| 223 | + the panel and the sort control need no changes. |
0 commit comments