Skip to content

Commit fed17b7

Browse files
committed
Merge remote-tracking branch 'origin/main' into resolve-1071-merge
2 parents 603d910 + 199d9ed commit fed17b7

25 files changed

Lines changed: 4577 additions & 157 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
99
## [Unreleased]
1010

1111
### Features
12+
- Add advanced filter and sort to the transaction history table: multi-column sort (up to three keys) with shift-click tiebreakers, a keyboard-reachable sort panel, URL-shareable ordering with legacy single-column links still honoured, relative date presets, removable active-filter chips, and inline reporting of contradictory ranges (#1035)
1213
- Harden the transfer orchestration service to be idempotent and retry-safe: wallet-scoped idempotency keys, request validation and canonicalisation, submission-boundary failure classification that never blindly resubmits a transfer whose outcome is unknown, stored terminal rejections, circuit-breaker fail-fast, a submission timeout, and an operator reconciliation queue with metrics (#1043)
1314
- Harden the vault comparison screen for multi-strategy selection: numeric strategy catalog with locale-aware formatting, URL-synced shareable selection and column ordering, best-in-class marking with non-colour cues, and screen-reader announcements for selection and sort changes (#1036)
1415
- Add journalled partial-failure recovery for multi-step withdrawals: resume-forward retries, reverse-order compensation, an operator escalation queue with admin endpoints, and a background sweeper for crashed or backed-off sagas (#954)

docs/FRONTEND_STATE_MANAGEMENT.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,9 @@ Critical real-time metrics use a custom `useQueryWithPolling` wrapper. For examp
5555

5656
### URL State Synchronization
5757
Complex view state, such as data tables and filters, is synchronized with the URL search parameters.
58-
- **`useDataTableState`**: Syncs pagination (page, pageSize) and sorting state to the URL.
58+
- **`useDataTableState`**: Syncs pagination (page, pageSize) and single-column sorting state to the URL.
5959
- **`useTransactionFilters`**: Syncs multi-filter criteria (date ranges, statuses, types) to the URL.
60+
- **`useTransactionSort`**: Syncs the transaction table's multi-column ordering to the URL (`?sort=status:asc,amount:desc`), mirroring the primary key into `useDataTableState`'s legacy `sortBy`/`direction` params so older links keep working. See [Transaction History — Advanced Filter and Sort](../frontend/docs/TRANSACTION_HISTORY_FILTER_SORT.md).
6061
- **Deep Linking**: Components like `VaultDashboard` listen for URL parameters (e.g., `?action=deposit&amount=100`) to automatically pre-fill local state (active tab, input amount) on mount, then clean up the URL to prevent state trapping.
6162

6263
### Client-Side Data Derivation
@@ -112,8 +113,8 @@ Custom hooks in `frontend/src/hooks/` encapsulate all complex logic.
112113
### `TransactionHistory` (`frontend/src/pages/TransactionHistory.tsx`)
113114
- **State Consumed:** `useTransactionHistory` (raw transaction list).
114115
- **Local State:** Manages `viewMode` ("paginated" | "infinite") and infinite scroll batch counts via `useState` and `useRef`. Persists user preferences for `viewMode` and `pageSize` to `localStorage`.
115-
- **URL Synchronization:** Binds heavily to URL parameters using `useDataTableState` and `useTransactionFilters` to ensure the view is shareable and persists across refreshes.
116-
- **Rendering:** Passes raw data through `useClientDataTable` for in-memory transformations. Renders either a standard paginated `DataTable` or an infinite scroll container based on `viewMode`.
116+
- **URL Synchronization:** Binds heavily to URL parameters using `useDataTableState` (pagination), `useTransactionFilters` (filters) and `useTransactionSort` (multi-column ordering) to ensure the view is shareable and persists across refreshes.
117+
- **Rendering:** Derives the visible rows with the pure `filterTransactions → sortTransactions → paginateRows` pipeline from `lib/transactionQuery`, keeping the matching and ordering rules unit-testable outside React. Renders either a standard paginated `DataTable` or an infinite scroll container based on `viewMode`.
117118

118119
---
119120

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
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

Comments
 (0)