diff --git a/AGENTS.md b/AGENTS.md index 83bfd5c7..68687740 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,13 +40,13 @@ src/app/ the application: routing, the router, the signed-in shell, globa src/pages/ one slice per screen; a page owns its composition, queries and handlers src/features/ a user interaction two or more pages share (auth-gate, bank-connection) src/entities/ a domain model two or more pages share (category) -src/shared/ infrastructure with no business rule: ui, lib, api, auth, config, i18n +src/shared/ infrastructure with no business rule: ui, lib, api, auth, config, i18n, routing ``` Four rules carry the whole thing, and [Steiger](https://github.com/feature-sliced/steiger) enforces them: - **A module imports only from a layer strictly below it.** `shared < entities < features < pages < app`. Nothing imports from `app`. -- **A slice is entered through its `index.ts`.** `@/pages/budget`, `@/features/bank-connection`, `@/entities/category` — never a path inside one. `shared` is entered per segment: `@/shared/api`, `@/shared/auth`, `@/shared/config`, `@/shared/i18n`, and per module for `@/shared/ui/` and `@/shared/lib/`. +- **A slice is entered through its `index.ts`.** `@/pages/budget`, `@/features/bank-connection`, `@/entities/category` — never a path inside one. `shared` is entered per segment: `@/shared/api`, `@/shared/auth`, `@/shared/config`, `@/shared/i18n`, `@/shared/routing`, and per module for `@/shared/ui/` and `@/shared/lib/`. - **Inside a slice, imports are relative**; across slices they use the alias. Both mistakes fail the gate. - **Extraction is earned, never anticipated.** New code starts in the page that uses it. It moves down a layer when a second consumer exists, the responsibility is focused, and it has a reason to change of its own — which is why `features/` holds two slices and `entities/` one. diff --git a/apps/fumadocs/content/docs/next/reference/procedures.mdx b/apps/fumadocs/content/docs/next/reference/procedures.mdx index 4c32b4b6..6d6cfef8 100644 --- a/apps/fumadocs/content/docs/next/reference/procedures.mdx +++ b/apps/fumadocs/content/docs/next/reference/procedures.mdx @@ -212,7 +212,7 @@ With `average` or `median`, `planned` is the monthly plan. With `total`, it's th ### budget.getMerchants -**Input**: `search` is at most 200 characters and matches the counterparty name, or the descriptor when there is none, as a plain substring, without case. `%` and `_` in it are literal. +**Input**: `search` is at most 200 characters and matches the counterparty name, or the descriptor when there is none, as a plain substring, without case. `%` and `_` in it are literal. `accountId`, when set, keeps the companies of that one account. An account of another user matches nothing. **Output**: `{ merchants }` of `{ count, name, totalMinor }`, highest count first, then by name. `totalMinor` sums absolute amounts. diff --git a/apps/fumadocs/content/docs/next/use/home.mdx b/apps/fumadocs/content/docs/next/use/home.mdx index 87a9c4d4..8c2f91b9 100644 --- a/apps/fumadocs/content/docs/next/use/home.mdx +++ b/apps/fumadocs/content/docs/next/use/home.mdx @@ -16,7 +16,7 @@ The large amount at the top is what your current accounts hold, added up. It tur **Accounts** shows one tile per account with its name, its bank and its balance in the account's own currency. A dash means the bank hasn't reported a balance. On a narrow screen, scroll the row sideways to see every tile. -Press a tile to open the account: its full name, its bank and kind, its balance and its transactions, newest first. `Show older transactions` loads more. The last tile, `Bank connections`, opens the place where you add or remove a bank. +Press a tile to open the account: its full name, its bank and kind, its balance and its transactions. The list works like the one on **Transactions**, with the `Outgoing` and `Incoming` tabs, the search box and its filters, `Saved filters`, and `Newest` or `Largest` first, but it holds every transaction of that one account, whatever its date. [Narrow the list](/docs/use/budget#narrow-the-list) explains the search box. The page address holds the tab, the order, the text and every filter. The last tile, `Bank connections`, opens the place where you add or remove a bank. ## Recent transactions diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index ea09099c..7fd2cfdd 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -24,10 +24,10 @@ src/ pages/ # one slice per screen: bank-account, brand-avatar, budget, callback, home, # login, onboarding, settings, setup; assistant has no route features/ # a reused interaction with its own state: account-sync, auth-gate, bank-connection, - # instance-integration, transaction-details + # instance-integration, transaction-details, transaction-list entities/ # a reused domain object: account, budget, category, recurring shared/ # anything with no domain knowledge, per segment: - # api/ auth/ config/ i18n/ lib/ ui/ + # api/ auth/ config/ i18n/ lib/ routing/ ui/ paraglide/ # generated by the Paraglide plugin; outside every layer routeTree.gen.ts# generated by TanStack Start; outside every layer server.ts # the Nitro entry; wraps the handler in paraglideMiddleware @@ -41,7 +41,7 @@ There is no `main.tsx`: the Start plugin builds both entries from `src/app/route **A slice is reached through its `index.ts` and nowhere else** — `@/pages/budget`, `@/features/bank-connection`, `@/entities/category`. That barrel is the slice's public API, and it is short: `@/pages/budget` exports `BudgetArea`, `RecurringPage`, `TransactionsPage` and `budgetSearchSchema`, and nothing else in the slice is reachable. -**Shared is reached per segment.** `@/shared/api`, `@/shared/auth`, `@/shared/config` and `@/shared/i18n` each expose one `index.ts`. `ui/` and `lib/` are too large for one barrel and are imported per module or per module folder: `@/shared/ui/search-input`, `@/shared/ui/sankey-chart`, `@/shared/lib/format-currency`, `@/shared/lib/sankey`. +**Shared is reached per segment.** `@/shared/api`, `@/shared/auth`, `@/shared/config`, `@/shared/i18n` and `@/shared/routing` each expose one `index.ts`. `ui/` and `lib/` are too large for one barrel and are imported per module or per module folder: `@/shared/ui/search-input`, `@/shared/ui/sankey-chart`, `@/shared/lib/format-currency`, `@/shared/lib/sankey`. **Inside one slice, imports are relative** — `./ui/auth-form`, `../model/use-sign-in-flow`. The same holds inside `app/` and inside `shared/`. Reaching your own slice through its alias, or another slice past its `index.ts`, both fail the gate. @@ -55,9 +55,10 @@ There is no `main.tsx`: the Start plugin builds both entries from `src/app/route - `features/bank-connection` — the bank list, its card, panel and disconnect dialog, used by `pages/onboarding` and `pages/settings`. - `features/account-sync` — the sync mutation and its toasts, used by `app/shell` for the background sync and by `pages/budget` for `Sync transactions`. - `features/instance-integration` — the provider step, its credentials form and outcome notice, and the save, check and complete mutations with the post-restart reconnect, used by `pages/setup` and `pages/settings`. -- `features/transaction-details` — the transaction drawer with its category picker and mutation, and the `TransactionItem` row that opens it, used by `pages/budget`, `pages/home` and `pages/bank-account`. +- `features/transaction-details` — the transaction drawer with its category picker and mutation, used by `pages/budget`, `pages/home` and `pages/bank-account`, and the `TransactionItem` row that opens it on `pages/home`. +- `features/transaction-list` — the filterable, sortable transaction list: `TransactionList` with its search, filter chips, saved filters, sort toggle, direction tabs and virtualised rows, `useTransactionListQuery`, and `transactionListSearchSchema` for the `dir`, `filters`, `q` and `sort` search params, used by `pages/budget` for one period and by `pages/bank-account` for one account. - `entities/account` — the account-type labels, `accountName` and `AccountBalance`, used by `pages/home` and `pages/bank-account`. -- `entities/budget` — `invalidateBudgetData` and the transactions query key, used by `app/shell`, `features/account-sync`, `features/bank-connection`, `pages/budget` and `pages/callback`; the `Transaction` type, `transactionTitle`, `TransactionAmount` and `CATEGORY_MEDIA_BOX`, used by `pages/budget` and `features/transaction-details`; and `latestTransactionsQueryOptions`, used by `pages/home` and `pages/bank-account`. +- `entities/budget` — `invalidateBudgetData` and the transactions query key, used by `app/shell`, `features/account-sync`, `features/bank-connection`, `pages/budget` and `pages/callback`; the `Transaction` type, `transactionTitle`, `TransactionAmount` and `CATEGORY_MEDIA_BOX`, used by `pages/budget`, `features/transaction-details` and `features/transaction-list`. - `entities/category` — the spending taxonomy: labels, selection, search, chart colour and `CategoryIcon`, used across `pages/budget`, `pages/settings`, `pages/home` and `pages/assistant`. - `entities/recurring` — the `getRecurring` item types and `upcomingPayments`, the projection of each recurring payment onto its next due dates, used by `pages/budget` and `pages/home`. @@ -82,15 +83,15 @@ Code is organized by slice, and within a slice by segment — what a module is _ /index.ts # the public API, and the only way in from outside ``` -Not every slice needs every segment: `pages/bank-account` is one `ui/` file and a barrel, `pages/budget` uses all four. Add a segment when you have something to put in it. +Not every slice needs every segment: `pages/bank-account` has only `model/` and `ui/`, `pages/budget` uses all four. Add a segment when you have something to put in it. ### Atomic design (implicit) Components follow atomic design as a **mental model, not a directory structure** — never create `atoms/`, `molecules/`, or `organisms/` folders. Everything a slice renders sits flat in its `ui/`; the level lives in how a component is designed: - **Atom-scale** — the smallest reusable elements: badges, labels, icons, wrappers around `@freenary/ui` primitives (`pages/budget/ui/recurrence-badge.tsx`, `entities/category/ui/category-icon.tsx`). Purely presentational, no business logic, no data fetching. -- **Molecule-scale** — small groups composing a few atoms into one reusable unit: cards, form field groups, list rows (`pages/budget/ui/transaction-row.tsx`, `pages/settings/ui/budget-line-row.tsx`). May hold light local UI state (open/closed, input value), never a full feature workflow. -- **Organism-scale** — distinct sections of the interface, coordinating molecules, atoms, and other organisms (`pages/budget/ui/transaction-list.tsx`, `pages/settings/ui/categories-section.tsx`). Presentation-focused: they receive data as props and own layout, not fetching. +- **Molecule-scale** — small groups composing a few atoms into one reusable unit: cards, form field groups, list rows (`features/transaction-list/ui/transaction-row.tsx`, `pages/settings/ui/budget-line-row.tsx`). May hold light local UI state (open/closed, input value), never a full feature workflow. +- **Organism-scale** — distinct sections of the interface, coordinating molecules, atoms, and other organisms (`features/transaction-list/ui/transaction-list.tsx`, `pages/settings/ui/categories-section.tsx`). Presentation-focused: they receive data as props and own layout, not fetching. The remaining stages map onto the structure rather than onto components: `app/shell/auth-layout.tsx` plays the template role, and a slice's page component (`pages/settings/ui/settings-page.tsx`) is the page stage — it pours in real content by fetching data and passing it down. @@ -156,7 +157,7 @@ login-form.tsx → export const LoginForm = () => { … } dashboard-overview.tsx → export const DashboardOverview = () => { … } ``` -Avoid vague names (`form.tsx`, `card.tsx`, `section.tsx`) and PascalCase filenames. A slice's one `index.ts` is its public API and is required; a barrel inside a segment is not. The only `index.ts` files below a segment belong to a multi-file folder that is one import unit (`shared/ui/sankey-chart/`, `shared/ui/ai-elements/`, `shared/lib/sankey/`) — never one that exists to shorten a relative import. +Avoid vague names (`form.tsx`, `card.tsx`, `section.tsx`) and PascalCase filenames. A slice's one `index.ts` is its public API and is required; a barrel inside a segment is not. The only `index.ts` files below a segment belong to a multi-file folder that is one import unit (`shared/ui/sankey-chart/`, `shared/ui/ai-elements/`, `shared/ui/list-controls/`, `shared/lib/sankey/`, `shared/lib/text/`) — never one that exists to shorten a relative import. ## The avatar diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index a9da9baa..1e914ebd 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -317,10 +317,8 @@ "bank_account_missing_description": "Its bank connection was removed, or the bank stopped reporting it.", "bank_account_missing_home": "Back to Home", "bank_account_missing_title": "This bank account is not connected", - "bank_account_transactions_empty": "No transactions on this account yet. They appear after the bank's next sync.", - "bank_account_transactions_error": "Could not load this account's transactions.", - "bank_account_transactions_loading": "Loading this account's transactions", - "bank_account_transactions_more": "Show older transactions", + "bank_account_transactions_empty_incoming": "No incoming transactions found on this account.", + "bank_account_transactions_empty_outgoing": "No outgoing transactions found on this account.", "bank_callback_declined": "Bank connection cancelled. Nothing was linked.", "bank_callback_failed": "Bank connection failed. You can try again.", "bank_callback_incomplete": "The bank sent back an incomplete response.", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index cb8b1a62..ec6f6e8b 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -326,10 +326,8 @@ "bank_account_missing_description": "Sa connexion bancaire a été supprimée, ou la banque ne le transmet plus.", "bank_account_missing_home": "Revenir à l’accueil", "bank_account_missing_title": "Ce compte bancaire n’est pas connecté", - "bank_account_transactions_empty": "Aucune transaction sur ce compte pour l’instant. Elles apparaissent à la prochaine synchronisation de la banque.", - "bank_account_transactions_error": "Impossible de charger les transactions de ce compte.", - "bank_account_transactions_loading": "Chargement des transactions de ce compte", - "bank_account_transactions_more": "Afficher les transactions plus anciennes", + "bank_account_transactions_empty_incoming": "Aucune entrée d’argent sur ce compte.", + "bank_account_transactions_empty_outgoing": "Aucune sortie d’argent sur ce compte.", "bank_callback_declined": "Connexion bancaire annulée. Rien n’a été lié.", "bank_callback_failed": "La connexion bancaire a échoué. Vous pouvez réessayer.", "bank_callback_incomplete": "La banque a renvoyé une réponse incomplète.", diff --git a/apps/web/src/app/routes/_auth/accounts/$accountId.tsx b/apps/web/src/app/routes/_auth/accounts/$accountId.tsx index 7875eb7e..9c15e894 100644 --- a/apps/web/src/app/routes/_auth/accounts/$accountId.tsx +++ b/apps/web/src/app/routes/_auth/accounts/$accountId.tsx @@ -1,7 +1,9 @@ import { createFileRoute } from "@tanstack/react-router"; +import { transactionListSearchSchema } from "@/features/transaction-list"; import { BankAccountPage } from "@/pages/bank-account"; export const Route = createFileRoute("/_auth/accounts/$accountId")({ component: BankAccountPage, + validateSearch: transactionListSearchSchema, }); diff --git a/apps/web/src/entities/budget/index.ts b/apps/web/src/entities/budget/index.ts index 9e78be2a..b801ccc2 100644 --- a/apps/web/src/entities/budget/index.ts +++ b/apps/web/src/entities/budget/index.ts @@ -3,7 +3,6 @@ export { TRANSACTIONS_QUERY_KEY, } from "./api/budget-cache"; export { SYNC_POLL_INTERVAL_MS, syncAccountsToEnd } from "./api/sync-accounts"; -export { latestTransactionsQueryOptions } from "./api/transactions-query"; export { type Transaction, transactionTitle } from "./model/transaction"; export { CATEGORY_MEDIA_BOX } from "./ui/category-media-box"; export { TransactionAmount } from "./ui/transaction-amount"; diff --git a/apps/web/src/features/transaction-list/api/transactions-query.ts b/apps/web/src/features/transaction-list/api/transactions-query.ts new file mode 100644 index 00000000..7a904a2c --- /dev/null +++ b/apps/web/src/features/transaction-list/api/transactions-query.ts @@ -0,0 +1,76 @@ +import type { TransactionFilter } from "@freenary/api/lib/transaction-filters"; +import type { QueryClient } from "@tanstack/react-query"; +import { infiniteQueryOptions, keepPreviousData } from "@tanstack/react-query"; + +import { TRANSACTIONS_QUERY_KEY } from "@/entities/budget"; +import { client } from "@/shared/api"; + +import type { SortMode, TransactionDirection } from "../model/list-search"; + +export interface TransactionsInput { + accountId?: string; + direction: TransactionDirection; + filters: TransactionFilter[]; + from: Date; + search: string; + sort: SortMode; + to: Date; +} + +const PAGE_SIZE = 50; + +// SAFETY: TanStack Query requires initialPageParam typed to match pageParam; undefined is the valid initial state +const INITIAL_PAGE_PARAM = undefined as string | undefined; + +export const transactionsQueryOptions = ({ + accountId, + direction, + filters, + from, + search, + sort, + to, +}: TransactionsInput) => + // oxlint-disable-next-line sort-keys -- TanStack infers the page type from `queryFn`, so a later `getNextPageParam` reads its `lastPage`; alphabetical order puts it first, where the page is `unknown`. + infiniteQueryOptions({ + queryKey: [ + ...TRANSACTIONS_QUERY_KEY, + { + accountId, + direction, + filters, + from: from.toISOString(), + search, + sort, + to: to.toISOString(), + }, + ], + queryFn: ({ pageParam }) => + client.budget.getTransactions({ + accountId, + cursor: pageParam, + direction, + filters: filters.length > 0 ? filters : undefined, + from, + limit: PAGE_SIZE, + search: search || undefined, + sort, + to, + }), + initialPageParam: INITIAL_PAGE_PARAM, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + placeholderData: keepPreviousData, + }); + +const warmWithoutRaising = async (fetching: Promise[]) => { + await Promise.allSettled(fetching); +}; + +export const prefetchTransactions = ( + queryClient: QueryClient, + input: TransactionsInput +) => { + void warmWithoutRaising([ + queryClient.infiniteQuery(transactionsQueryOptions(input)), + ]); +}; diff --git a/apps/web/src/features/transaction-list/index.ts b/apps/web/src/features/transaction-list/index.ts new file mode 100644 index 00000000..847d7c11 --- /dev/null +++ b/apps/web/src/features/transaction-list/index.ts @@ -0,0 +1,19 @@ +export { transactionsQueryOptions } from "./api/transactions-query"; +export type { TransactionsInput } from "./api/transactions-query"; +export { + highlightedChartCategories, + selectChartCategory, +} from "./model/filter-tokens"; +export { + nextTransactionListSearch, + TRANSACTION_LIST_SEARCH_DEFAULTS, + transactionListSearchSchema, +} from "./model/list-search"; +export type { + SortMode, + TransactionDirection, + TransactionListSearch, +} from "./model/list-search"; +export type { TransactionGrouping } from "./model/transaction-groups"; +export { useTransactionListQuery } from "./model/use-transaction-list-query"; +export { TransactionList } from "./ui/transaction-list"; diff --git a/apps/web/src/pages/budget/model/filter-draft.test.ts b/apps/web/src/features/transaction-list/model/filter-draft.test.ts similarity index 100% rename from apps/web/src/pages/budget/model/filter-draft.test.ts rename to apps/web/src/features/transaction-list/model/filter-draft.test.ts diff --git a/apps/web/src/pages/budget/model/filter-draft.ts b/apps/web/src/features/transaction-list/model/filter-draft.ts similarity index 100% rename from apps/web/src/pages/budget/model/filter-draft.ts rename to apps/web/src/features/transaction-list/model/filter-draft.ts diff --git a/apps/web/src/pages/budget/model/filter-suggestions.ts b/apps/web/src/features/transaction-list/model/filter-suggestions.ts similarity index 100% rename from apps/web/src/pages/budget/model/filter-suggestions.ts rename to apps/web/src/features/transaction-list/model/filter-suggestions.ts diff --git a/apps/web/src/pages/budget/model/filter-tokens.test.ts b/apps/web/src/features/transaction-list/model/filter-tokens.test.ts similarity index 100% rename from apps/web/src/pages/budget/model/filter-tokens.test.ts rename to apps/web/src/features/transaction-list/model/filter-tokens.test.ts diff --git a/apps/web/src/pages/budget/model/filter-tokens.ts b/apps/web/src/features/transaction-list/model/filter-tokens.ts similarity index 100% rename from apps/web/src/pages/budget/model/filter-tokens.ts rename to apps/web/src/features/transaction-list/model/filter-tokens.ts diff --git a/apps/web/src/features/transaction-list/model/list-search.ts b/apps/web/src/features/transaction-list/model/list-search.ts new file mode 100644 index 00000000..78a55e56 --- /dev/null +++ b/apps/web/src/features/transaction-list/model/list-search.ts @@ -0,0 +1,57 @@ +import { TRANSACTION_DIRECTIONS } from "@freenary/api/lib/taxonomy"; +import { + lenientTransactionFiltersSchema, + MAX_SEARCH_TEXT_LENGTH, +} from "@freenary/api/lib/transaction-filters"; +import { z } from "zod"; + +import { + absentUnlessNonEmptyText, + absentUnlessOneOf, + absentWhenDefault, + absentWhenEmpty, +} from "@/shared/routing"; + +export type TransactionDirection = (typeof TRANSACTION_DIRECTIONS)[number]; + +export type SortMode = (typeof SORT_MODES)[number]; + +export type TransactionListSearch = z.infer; + +export const SORT_MODES = ["date", "amount"] as const; + +const absentUnlessFilters = lenientTransactionFiltersSchema + .transform((kept) => (kept.length > 0 ? kept : undefined)) + .optional(); + +export const transactionListSearchSchema = z.object({ + dir: absentUnlessOneOf(TRANSACTION_DIRECTIONS), + filters: absentUnlessFilters, + q: absentUnlessNonEmptyText(MAX_SEARCH_TEXT_LENGTH), + sort: absentUnlessOneOf(SORT_MODES), +}); + +export const TRANSACTION_LIST_SEARCH_DEFAULTS = { + dir: "outgoing", + q: "", + sort: "date", +} as const satisfies Partial; + +export const nextTransactionListSearch = ( + current: TransactionListSearch, + patch: TransactionListSearch +): TransactionListSearch => ({ + dir: absentWhenDefault( + patch.dir ?? current.dir, + TRANSACTION_LIST_SEARCH_DEFAULTS.dir + ), + filters: absentWhenEmpty(patch.filters ?? current.filters), + q: absentWhenDefault( + patch.q ?? current.q, + TRANSACTION_LIST_SEARCH_DEFAULTS.q + ), + sort: absentWhenDefault( + patch.sort ?? current.sort, + TRANSACTION_LIST_SEARCH_DEFAULTS.sort + ), +}); diff --git a/apps/web/src/features/transaction-list/model/transaction-groups.test.ts b/apps/web/src/features/transaction-list/model/transaction-groups.test.ts new file mode 100644 index 00000000..a9c0b306 --- /dev/null +++ b/apps/web/src/features/transaction-list/model/transaction-groups.test.ts @@ -0,0 +1,69 @@ +import { afterAll, afterEach, expect, it, setSystemTime } from "bun:test"; + +import { m } from "@/paraglide/messages.js"; + +import { + buildVirtualItems, + formatGroupLabel, + groupKey, +} from "./transaction-groups"; + +const FIRST_OF_OCTOBER = "2026-10-01T00:00:00.000Z"; +const HOST_TZ = process.env.TZ; + +afterEach(() => { + process.env.TZ = "America/Martinique"; + setSystemTime(); +}); + +afterAll(() => { + if (HOST_TZ === undefined) { + delete process.env.TZ; + } else { + process.env.TZ = HOST_TZ; + } +}); + +process.env.TZ = "America/Martinique"; + +it("groups a bank date of the 1st under the 1st west of UTC", () => { + expect(groupKey(FIRST_OF_OCTOBER, "day")).toBe("2026-10-01"); + expect(groupKey(FIRST_OF_OCTOBER, "month")).toBe("2026-10"); +}); + +it("labels today's bank date as today just after midnight in Paris", () => { + process.env.TZ = "Europe/Paris"; + setSystemTime(new Date("2026-10-25T23:30:00.000Z")); + + const key = groupKey("2026-10-26T00:00:00.000Z", "day"); + + expect(formatGroupLabel(key, "day", "en")).toBe(m.budget_group_today()); +}); + +const transactionOn = (id: string, date: string, amount: number) => ({ + amount, + category: "groceries" as const, + counterpartyName: id, + currency: "EUR", + date, + derivedCategory: "groceries" as const, + description: id, + id, + normalisedDescriptor: id, +}); + +it("keys every header apart when a day heads two groups", () => { + const items = buildVirtualItems( + [ + transactionOn("a", "2026-10-05T00:00:00.000Z", -90), + transactionOn("b", "2026-10-06T00:00:00.000Z", -50), + transactionOn("c", "2026-10-05T00:00:00.000Z", -10), + ], + "day", + "en" + ); + + const keys = items.map((item) => item.key); + + expect(new Set(keys).size).toBe(keys.length); +}); diff --git a/apps/web/src/pages/budget/model/transaction-groups.ts b/apps/web/src/features/transaction-list/model/transaction-groups.ts similarity index 89% rename from apps/web/src/pages/budget/model/transaction-groups.ts rename to apps/web/src/features/transaction-list/model/transaction-groups.ts index d45390ab..504bdb57 100644 --- a/apps/web/src/pages/budget/model/transaction-groups.ts +++ b/apps/web/src/features/transaction-list/model/transaction-groups.ts @@ -3,7 +3,7 @@ import { m } from "@/paraglide/messages.js"; import type { Locale } from "@/paraglide/runtime.js"; import { localDayOfBankDate } from "@/shared/lib/calendar-day"; -import type { TimeRange } from "./period"; +export type TransactionGrouping = "day" | "week" | "month"; interface GroupHeader { type: "header"; @@ -62,14 +62,17 @@ const weekKey = (date: Date) => { return `${monday.getFullYear()}-W${padded(weekOfMonth(monday))}-${padded(monday.getMonth() + 1)}-${padded(monday.getDate())}`; }; -export const groupKey = (dateStr: string, range: TimeRange): string => { +export const groupKey = ( + dateStr: string, + grouping: TransactionGrouping +): string => { const date = localDayOfBankDate(dateStr); - if (range === "1Y") { + if (grouping === "month") { return monthKey(date); } - if (range === "3M") { + if (grouping === "week") { return weekKey(date); } @@ -119,14 +122,14 @@ const formatDayKeyLabel = (key: string, locale: Locale): string => { export const formatGroupLabel = ( key: string, - range: TimeRange, + grouping: TransactionGrouping, locale: Locale ): string => { - if (range === "1Y") { + if (grouping === "month") { return formatMonthKeyLabel(key, locale); } - if (range === "3M") { + if (grouping === "week") { return formatWeekKeyLabel(key, locale); } @@ -135,7 +138,7 @@ export const formatGroupLabel = ( export const buildVirtualItems = ( transactions: Transaction[], - range: TimeRange, + grouping: TransactionGrouping, locale: Locale ): VirtualItem[] => { if (transactions.length === 0) { @@ -148,7 +151,7 @@ export const buildVirtualItems = ( let headerAwaitingItsTotal: GroupHeader | null = null; for (const tx of transactions) { - const key = groupKey(tx.date, range); + const key = groupKey(tx.date, grouping); if (key !== currentKey) { if (headerAwaitingItsTotal) { @@ -159,8 +162,8 @@ export const buildVirtualItems = ( groupTotal = 0; headerAwaitingItsTotal = { currency: tx.currency, - key: `header-${key}`, - label: formatGroupLabel(key, range, locale), + key: `header-${tx.id}`, + label: formatGroupLabel(key, grouping, locale), total: 0, type: "header", }; diff --git a/apps/web/src/pages/budget/model/use-filter-token-search.ts b/apps/web/src/features/transaction-list/model/use-filter-token-search.ts similarity index 97% rename from apps/web/src/pages/budget/model/use-filter-token-search.ts rename to apps/web/src/features/transaction-list/model/use-filter-token-search.ts index f729381b..dc0095e7 100644 --- a/apps/web/src/pages/budget/model/use-filter-token-search.ts +++ b/apps/web/src/features/transaction-list/model/use-filter-token-search.ts @@ -7,9 +7,10 @@ import type { FocusEvent, KeyboardEvent } from "react"; import { getLocale } from "@/paraglide/runtime.js"; import { orpc } from "@/shared/api"; +import { parseAmountBound } from "@/shared/lib/text"; import { useDebouncedValue } from "@/shared/lib/use-debounced-value"; +import { useSettledText } from "@/shared/ui/list-controls"; -import { useSettledText } from "../lib/use-settled-text"; import { appliedAmount, appliedValues, @@ -30,10 +31,10 @@ import { } from "./filter-suggestions"; import type { FilterSuggestion } from "./filter-suggestions"; import { searchKey, withoutFilterAt } from "./filter-tokens"; -import type { TransactionDirection } from "./search"; -import { parseAmountBound } from "./transaction-filters"; +import type { TransactionDirection } from "./list-search"; export interface FilterTokenSearchOptions { + accountId?: string; currency: string; direction: TransactionDirection; filters: TransactionFilter[]; @@ -51,6 +52,7 @@ export const NO_HIGHLIGHT = -1; export const filterKey = (filter: TransactionFilter) => searchKey([filter], ""); export const useFilterTokenSearch = ({ + accountId, currency, direction, filters, @@ -77,6 +79,7 @@ export const useFilterTokenSearch = ({ const merchantsQuery = useQuery({ ...orpc.budget.getMerchants.queryOptions({ input: { + accountId, direction, from, search: settledCompanyQuery.trim() || undefined, diff --git a/apps/web/src/pages/budget/model/use-saved-filters.ts b/apps/web/src/features/transaction-list/model/use-saved-filters.ts similarity index 100% rename from apps/web/src/pages/budget/model/use-saved-filters.ts rename to apps/web/src/features/transaction-list/model/use-saved-filters.ts diff --git a/apps/web/src/features/transaction-list/model/use-transaction-list-query.ts b/apps/web/src/features/transaction-list/model/use-transaction-list-query.ts new file mode 100644 index 00000000..b3340386 --- /dev/null +++ b/apps/web/src/features/transaction-list/model/use-transaction-list-query.ts @@ -0,0 +1,55 @@ +import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; + +import { + prefetchTransactions, + transactionsQueryOptions, +} from "../api/transactions-query"; +import type { TransactionsInput } from "../api/transactions-query"; +import type { SortMode, TransactionDirection } from "./list-search"; + +const NO_TOTALS = { incoming: 0, outgoing: 0 }; + +export const useTransactionListQuery = ( + input: TransactionsInput, + { isAwaitingFirstData = false }: { isAwaitingFirstData?: boolean } = {} +) => { + const query = useInfiniteQuery(transactionsQueryOptions(input)); + const queryClient = useQueryClient(); + + const onDirectionIntent = useCallback( + (direction: TransactionDirection) => + prefetchTransactions(queryClient, { ...input, direction }), + [input, queryClient] + ); + + const onSortIntent = useCallback( + (sort: SortMode) => prefetchTransactions(queryClient, { ...input, sort }), + [input, queryClient] + ); + + const onLoadMore = useCallback(() => { + if (query.hasNextPage && !query.isFetchingNextPage) { + void query.fetchNextPage(); + } + }, [query]); + + const onRetry = useCallback(() => { + void query.refetch(); + }, [query]); + + return { + hasMore: query.hasNextPage, + isError: query.isError && !query.isFetchNextPageError, + isLoading: + query.isLoading || isAwaitingFirstData || query.isFetchingNextPage, + isRetrying: query.isFetching, + isStale: query.isPlaceholderData && query.isFetching, + onDirectionIntent, + onLoadMore, + onRetry, + onSortIntent, + totals: query.data?.pages[0]?.totals ?? NO_TOTALS, + transactions: query.data?.pages.flatMap((page) => page.transactions) ?? [], + }; +}; diff --git a/apps/web/src/pages/budget/ui/filter-token-chip.tsx b/apps/web/src/features/transaction-list/ui/filter-token-chip.tsx similarity index 100% rename from apps/web/src/pages/budget/ui/filter-token-chip.tsx rename to apps/web/src/features/transaction-list/ui/filter-token-chip.tsx diff --git a/apps/web/src/pages/budget/ui/filter-token-search.tsx b/apps/web/src/features/transaction-list/ui/filter-token-search.tsx similarity index 100% rename from apps/web/src/pages/budget/ui/filter-token-search.tsx rename to apps/web/src/features/transaction-list/ui/filter-token-search.tsx diff --git a/apps/web/src/pages/budget/ui/saved-filters-menu.tsx b/apps/web/src/features/transaction-list/ui/saved-filters-menu.tsx similarity index 100% rename from apps/web/src/pages/budget/ui/saved-filters-menu.tsx rename to apps/web/src/features/transaction-list/ui/saved-filters-menu.tsx diff --git a/apps/web/src/pages/budget/ui/transaction-group-header.tsx b/apps/web/src/features/transaction-list/ui/transaction-group-header.tsx similarity index 100% rename from apps/web/src/pages/budget/ui/transaction-group-header.tsx rename to apps/web/src/features/transaction-list/ui/transaction-group-header.tsx diff --git a/apps/web/src/pages/budget/ui/transaction-list.tsx b/apps/web/src/features/transaction-list/ui/transaction-list.tsx similarity index 86% rename from apps/web/src/pages/budget/ui/transaction-list.tsx rename to apps/web/src/features/transaction-list/ui/transaction-list.tsx index 8788c2c5..87b5998b 100644 --- a/apps/web/src/pages/budget/ui/transaction-list.tsx +++ b/apps/web/src/features/transaction-list/ui/transaction-list.tsx @@ -5,12 +5,15 @@ import { useId } from "react"; import type { Transaction } from "@/entities/budget"; import { m } from "@/paraglide/messages.js"; import { formatCurrency } from "@/shared/lib/format-currency"; +import { + useHoverIntent, + ListFilterBar, + ListSortToggle, +} from "@/shared/ui/list-controls"; -import { useHoverIntent } from "../lib/use-hover-intent"; -import type { TimeRange } from "../model/period"; -import type { SortMode, TransactionDirection } from "../model/search"; +import type { SortMode, TransactionDirection } from "../model/list-search"; +import type { TransactionGrouping } from "../model/transaction-groups"; import { FilterTokenSearch } from "./filter-token-search"; -import { ListFilterBar, ListSortToggle } from "./list-controls"; import { SavedFiltersMenu } from "./saved-filters-menu"; import { TransactionRows } from "./transaction-rows"; @@ -19,12 +22,18 @@ const DIRECTIONS = [ "incoming", ] as const satisfies readonly TransactionDirection[]; +const PERIOD_EMPTY_DESCRIPTIONS = { + incoming: m.budget_transactions_empty_incoming, + outgoing: m.budget_transactions_empty_outgoing, +} as const satisfies Record string>; + const SORT_OPTIONS = [ { label: m.budget_sort_date, value: "date" }, { label: m.budget_sort_amount, value: "amount" }, ] as const satisfies readonly { label: () => string; value: SortMode }[]; export const TransactionList = ({ + accountId, transactions, currency, totals, @@ -49,8 +58,11 @@ export const TransactionList = ({ onRetry, isStale, onTransactionClick, - range, + grouping, + emptyDescriptions = PERIOD_EMPTY_DESCRIPTIONS, }: { + accountId?: string; + emptyDescriptions?: Record string>; transactions: Transaction[]; currency: string; totals: { incoming: number; outgoing: number }; @@ -78,7 +90,7 @@ export const TransactionList = ({ onRetry: () => void; isStale: boolean; onTransactionClick: (tx: Transaction) => void; - range: TimeRange; + grouping: TransactionGrouping; }) => { const directionIntent = useHoverIntent(onDirectionIntent); const tabsId = useId(); @@ -108,6 +120,7 @@ export const TransactionList = ({
))} diff --git a/apps/web/src/pages/budget/ui/transaction-row.tsx b/apps/web/src/features/transaction-list/ui/transaction-row.tsx similarity index 100% rename from apps/web/src/pages/budget/ui/transaction-row.tsx rename to apps/web/src/features/transaction-list/ui/transaction-row.tsx diff --git a/apps/web/src/pages/budget/ui/transaction-rows-skeleton.tsx b/apps/web/src/features/transaction-list/ui/transaction-rows-skeleton.tsx similarity index 100% rename from apps/web/src/pages/budget/ui/transaction-rows-skeleton.tsx rename to apps/web/src/features/transaction-list/ui/transaction-rows-skeleton.tsx diff --git a/apps/web/src/pages/budget/ui/transaction-rows.tsx b/apps/web/src/features/transaction-list/ui/transaction-rows.tsx similarity index 91% rename from apps/web/src/pages/budget/ui/transaction-rows.tsx rename to apps/web/src/features/transaction-list/ui/transaction-rows.tsx index 6b5f8226..017ce756 100644 --- a/apps/web/src/pages/budget/ui/transaction-rows.tsx +++ b/apps/web/src/features/transaction-list/ui/transaction-rows.tsx @@ -14,15 +14,15 @@ import { useCallback, useEffect, useMemo, useRef } from "react"; import type { Transaction } from "@/entities/budget"; import { m } from "@/paraglide/messages.js"; import { getLocale } from "@/paraglide/runtime.js"; +import { LoadError } from "@/shared/ui/load-error"; +import { StaleRegion } from "@/shared/ui/stale-region"; -import type { TimeRange } from "../model/period"; import { buildVirtualItems, HEADER_HEIGHT, ROW_HEIGHT, } from "../model/transaction-groups"; -import { LoadError } from "./load-error"; -import { StaleRegion } from "./stale-region"; +import type { TransactionGrouping } from "../model/transaction-groups"; import { TransactionGroupHeader } from "./transaction-group-header"; import { TransactionRow } from "./transaction-row"; import { TransactionRowsSkeleton } from "./transaction-rows-skeleton"; @@ -42,7 +42,8 @@ export const TransactionRows = ({ isIncoming, isStale, onTransactionClick, - range, + grouping, + emptyDescription, }: { transactions: Transaction[]; hasMore: boolean; @@ -54,7 +55,8 @@ export const TransactionRows = ({ isIncoming: boolean; isStale: boolean; onTransactionClick: (tx: Transaction) => void; - range: TimeRange; + grouping: TransactionGrouping; + emptyDescription: string; }) => { "use no memo"; const parentRef = useRef(null); @@ -63,8 +65,8 @@ export const TransactionRows = ({ const locale = getLocale(); const virtualItems = useMemo( - () => buildVirtualItems(transactions, range, locale), - [transactions, range, locale] + () => buildVirtualItems(transactions, grouping, locale), + [transactions, grouping, locale] ); // eslint-disable-next-line react/incompatible-library -- useVirtualizer is inherently incompatible with React Compiler; component opts out via "use no memo" @@ -72,6 +74,7 @@ export const TransactionRows = ({ count: virtualItems.length, estimateSize: (index) => virtualItems[index]?.type === "header" ? HEADER_HEIGHT : ROW_HEIGHT, + getItemKey: (index) => virtualItems[index]?.key ?? index, getScrollElement: () => parentRef.current, overscan: 10, }); @@ -141,11 +144,7 @@ export const TransactionRows = ({ {m.budget_transactions_empty_title()} - - {isIncoming - ? m.budget_transactions_empty_incoming() - : m.budget_transactions_empty_outgoing()} - + {emptyDescription} diff --git a/apps/web/src/pages/bank-account/model/use-account-transactions-view.ts b/apps/web/src/pages/bank-account/model/use-account-transactions-view.ts new file mode 100644 index 00000000..99469add --- /dev/null +++ b/apps/web/src/pages/bank-account/model/use-account-transactions-view.ts @@ -0,0 +1,68 @@ +import { getRouteApi } from "@tanstack/react-router"; +import { useCallback, useMemo } from "react"; + +import { + nextTransactionListSearch, + TRANSACTION_LIST_SEARCH_DEFAULTS, +} from "@/features/transaction-list"; +import type { + SortMode, + TransactionDirection, + TransactionListSearch, + TransactionsInput, +} from "@/features/transaction-list"; + +const route = getRouteApi("/_auth/accounts/$accountId"); + +const EARLIEST_BANK_DATE = new Date(0); +const LAST_HOUR = 23; +const LAST_MINUTE = 59; +const LAST_SECOND = 59; +const LAST_MILLISECOND = 999; + +const endOfToday = () => { + const end = new Date(); + end.setHours(LAST_HOUR, LAST_MINUTE, LAST_SECOND, LAST_MILLISECOND); + + return end; +}; + +export const useAccountTransactionsView = () => { + const { accountId } = route.useParams(); + const search = route.useSearch(); + const navigate = route.useNavigate(); + const to = useMemo(() => endOfToday(), []); + + const applyPatch = useCallback( + (patch: TransactionListSearch) => { + navigate({ + replace: true, + resetScroll: false, + search: (prev) => nextTransactionListSearch(prev, patch), + }); + }, + [navigate] + ); + + const filters = useMemo(() => search.filters ?? [], [search.filters]); + const direction: TransactionDirection = + search.dir ?? TRANSACTION_LIST_SEARCH_DEFAULTS.dir; + + const sort: SortMode = search.sort ?? TRANSACTION_LIST_SEARCH_DEFAULTS.sort; + const searchQuery = search.q ?? TRANSACTION_LIST_SEARCH_DEFAULTS.q; + + const listInput = useMemo( + () => ({ + accountId, + direction, + filters, + from: EARLIEST_BANK_DATE, + search: searchQuery, + sort, + to, + }), + [accountId, direction, filters, searchQuery, sort, to] + ); + + return { accountId, applyPatch, listInput }; +}; diff --git a/apps/web/src/pages/bank-account/ui/bank-account-page.tsx b/apps/web/src/pages/bank-account/ui/bank-account-page.tsx index c0af6d3b..a929893f 100644 --- a/apps/web/src/pages/bank-account/ui/bank-account-page.tsx +++ b/apps/web/src/pages/bank-account/ui/bank-account-page.tsx @@ -7,8 +7,8 @@ import { EmptyTitle, } from "@freenary/ui/components/empty"; import { Skeleton } from "@freenary/ui/components/skeleton"; -import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; -import { getRouteApi, Link } from "@tanstack/react-router"; +import { useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; import { useState } from "react"; import { @@ -17,19 +17,20 @@ import { accountName, } from "@/entities/account"; import type { PortfolioAccount } from "@/entities/account"; -import { latestTransactionsQueryOptions } from "@/entities/budget"; -import type { Transaction } from "@/entities/budget"; +import { TransactionDetailDrawer } from "@/features/transaction-details"; import { - TransactionDetailDrawer, - TransactionItem, -} from "@/features/transaction-details"; + TransactionList, + useTransactionListQuery, +} from "@/features/transaction-list"; import { m } from "@/paraglide/messages.js"; import { orpc } from "@/shared/api"; -const route = getRouteApi("/_auth/accounts/$accountId"); +import { useAccountTransactionsView } from "../model/use-account-transactions-view"; -const PAGE_SIZE = 30; -const SKELETON_ROWS = 6; +const ACCOUNT_EMPTY_DESCRIPTIONS = { + incoming: m.bank_account_transactions_empty_incoming, + outgoing: m.bank_account_transactions_empty_outgoing, +}; const AccountHeader = ({ account }: { account: PortfolioAccount }) => (
@@ -64,97 +65,54 @@ const HeaderSkeleton = () => (
); -const RowsSkeleton = () => ( -
- {m.bank_account_transactions_loading()} - {Array.from({ length: SKELETON_ROWS }, (_, index) => ( - - ))} -
-); - -interface TransactionsListState { - canLoadMore: boolean; - isError: boolean; - isLoaded: boolean; - isLoadingMore: boolean; - isPending: boolean; - isRetrying: boolean; - handleLoadMore: () => void; - handleRetry: () => void; - transactions: Transaction[]; -} - -const AccountTransactions = ({ list }: { list: TransactionsListState }) => { +const AccountTransactions = ({ + currency, + list, + view, +}: { + currency: string; + list: ReturnType; + view: ReturnType; +}) => { const [selectedId, setSelectedId] = useState(null); - const today = new Date(); + const { accountId, applyPatch, listInput } = view; - const { transactions } = list; const selected = - transactions.find((transaction) => transaction.id === selectedId) ?? null; + list.transactions.find((transaction) => transaction.id === selectedId) ?? + null; return (

{m.nav_budget_transactions()}

- {list.isPending ? : null} - {list.isError ? ( -
-

- {m.bank_account_transactions_error()} -

- -
- ) : null} - {list.isLoaded && transactions.length === 0 ? ( -

- {m.bank_account_transactions_empty()} -

- ) : null} - {transactions.length > 0 ? ( -
    - {transactions.map((transaction) => ( - setSelectedId(picked.id)} - today={today} - transaction={transaction} - /> - ))} -
- ) : null} - {list.canLoadMore ? ( -
- -
+ {list.isStale ? ( + {m.budget_view_updating()} ) : null} + applyPatch({ dir })} + onFiltersChange={(filters) => applyPatch({ filters })} + onSearchChange={(q) => applyPatch({ q })} + onSearchReplace={(next) => + applyPatch({ filters: next.filters, q: next.search }) + } + onSortChange={(sort) => applyPatch({ sort })} + onTransactionClick={(transaction) => setSelectedId(transaction.id)} + search={listInput.search} + sort={listInput.sort} + to={listInput.to} + /> { if (!open) { @@ -185,31 +143,11 @@ const AccountMissing = () => ( ); export const BankAccountPage = () => { - const { accountId } = route.useParams(); + const view = useAccountTransactionsView(); + const list = useTransactionListQuery(view.listInput); const overview = useQuery(orpc.portfolio.getOverview.queryOptions()); - const transactionsQuery = useInfiniteQuery( - latestTransactionsQueryOptions({ accountId, pageSize: PAGE_SIZE }) - ); - - const list: TransactionsListState = { - canLoadMore: transactionsQuery.hasNextPage, - handleLoadMore: () => { - void transactionsQuery.fetchNextPage(); - }, - handleRetry: () => { - void transactionsQuery.refetch(); - }, - isError: transactionsQuery.isError && transactionsQuery.data === undefined, - isLoaded: transactionsQuery.data !== undefined, - isLoadingMore: transactionsQuery.isFetchingNextPage, - isPending: transactionsQuery.isPending, - isRetrying: transactionsQuery.isFetching, - transactions: - transactionsQuery.data?.pages.flatMap((page) => page.transactions) ?? [], - }; - const account = overview.data?.accounts.find( - (candidate) => candidate.id === accountId + (candidate) => candidate.id === view.accountId ); if (overview.isPending) { @@ -257,7 +195,12 @@ export const BankAccountPage = () => { return (
- +
); }; diff --git a/apps/web/src/pages/budget/api/budget-queries.ts b/apps/web/src/pages/budget/api/budget-queries.ts index f2b16bc2..3c09d634 100644 --- a/apps/web/src/pages/budget/api/budget-queries.ts +++ b/apps/web/src/pages/budget/api/budget-queries.ts @@ -1,12 +1,11 @@ -import type { TransactionFilter } from "@freenary/api/lib/transaction-filters"; import type { QueryClient } from "@tanstack/react-query"; -import { infiniteQueryOptions, keepPreviousData } from "@tanstack/react-query"; +import { keepPreviousData } from "@tanstack/react-query"; -import { TRANSACTIONS_QUERY_KEY } from "@/entities/budget"; -import { client, orpc } from "@/shared/api"; +import { transactionsQueryOptions } from "@/features/transaction-list"; +import type { TransactionsInput } from "@/features/transaction-list"; +import { orpc } from "@/shared/api"; import type { AggregationMode } from "../model/period"; -import type { SortMode, TransactionDirection } from "../model/search"; export interface PeriodInput { aggregation: AggregationMode; @@ -14,76 +13,21 @@ export interface PeriodInput { to: Date; } -export interface TransactionsInput { - direction: TransactionDirection; - filters: TransactionFilter[]; - from: Date; - search: string; - sort: SortMode; - to: Date; -} - -const PAGE_SIZE = 50; - -// SAFETY: TanStack Query requires initialPageParam typed to match pageParam; undefined is the valid initial state -const INITIAL_PAGE_PARAM = undefined as string | undefined; - -export const transactionsQueryOptions = ({ - direction, - filters, - from, - search, - sort, - to, -}: TransactionsInput) => - // oxlint-disable-next-line sort-keys -- TanStack infers the page type from `queryFn`, so a later `getNextPageParam` reads its `lastPage`; alphabetical order puts it first, where the page is `unknown`. - infiniteQueryOptions({ - queryKey: [ - ...TRANSACTIONS_QUERY_KEY, - { - direction, - filters, - from: from.toISOString(), - search, - sort, - to: to.toISOString(), - }, - ], - queryFn: ({ pageParam }) => - client.budget.getTransactions({ - cursor: pageParam, - direction, - filters: filters.length > 0 ? filters : undefined, - from, - limit: PAGE_SIZE, - search: search || undefined, - sort, - to, - }), - initialPageParam: INITIAL_PAGE_PARAM, - getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, - placeholderData: keepPreviousData, - }); - export const periodOverviewQueryOptions = (period: PeriodInput) => orpc.budget.getPeriodOverview.queryOptions({ input: period, placeholderData: keepPreviousData, }); +export const isStaleView = (query: { + isFetching: boolean; + isPlaceholderData: boolean; +}) => query.isPlaceholderData && query.isFetching; + const warmWithoutRaising = async (fetching: Promise[]) => { await Promise.allSettled(fetching); }; -export const prefetchTransactions = ( - queryClient: QueryClient, - input: TransactionsInput -) => { - void warmWithoutRaising([ - queryClient.infiniteQuery(transactionsQueryOptions(input)), - ]); -}; - export const prefetchPeriod = ( queryClient: QueryClient, period: PeriodInput, @@ -96,8 +40,3 @@ export const prefetchPeriod = ( ), ]); }; - -export const isStaleView = (query: { - isFetching: boolean; - isPlaceholderData: boolean; -}) => query.isPlaceholderData && query.isFetching; diff --git a/apps/web/src/pages/budget/model/transaction-groups.test.ts b/apps/web/src/pages/budget/model/period.test.ts similarity index 60% rename from apps/web/src/pages/budget/model/transaction-groups.test.ts rename to apps/web/src/pages/budget/model/period.test.ts index 6e655422..00373309 100644 --- a/apps/web/src/pages/budget/model/transaction-groups.test.ts +++ b/apps/web/src/pages/budget/model/period.test.ts @@ -1,9 +1,6 @@ import { afterAll, afterEach, expect, it, setSystemTime } from "bun:test"; -import { m } from "@/paraglide/messages.js"; - import { computeDateRange, formatPeriodLabel } from "./period"; -import { formatGroupLabel, groupKey } from "./transaction-groups"; const FIRST_OF_OCTOBER = "2026-10-01T00:00:00.000Z"; const OCTOBER = 9; @@ -36,17 +33,3 @@ it("counts a bank date of the 1st in its own month west of UTC", () => { expect(bankDate >= from && bankDate <= to).toBe(true); expect(formatPeriodLabel(from, to, "1M", "en")).toBe("October 2026"); }); - -it("groups a bank date of the 1st under the 1st west of UTC", () => { - expect(groupKey(FIRST_OF_OCTOBER, "1M")).toBe("2026-10-01"); - expect(groupKey(FIRST_OF_OCTOBER, "1Y")).toBe("2026-10"); -}); - -it("labels today's bank date as today just after midnight in Paris", () => { - process.env.TZ = "Europe/Paris"; - setSystemTime(new Date("2026-10-25T23:30:00.000Z")); - - const key = groupKey("2026-10-26T00:00:00.000Z", "1M"); - - expect(formatGroupLabel(key, "1M", "en")).toBe(m.budget_group_today()); -}); diff --git a/apps/web/src/pages/budget/model/recurring-filters.ts b/apps/web/src/pages/budget/model/recurring-filters.ts index 7d532a2c..31320cd4 100644 --- a/apps/web/src/pages/budget/model/recurring-filters.ts +++ b/apps/web/src/pages/budget/model/recurring-filters.ts @@ -6,7 +6,8 @@ import { EMPTY_CATEGORY_FILTER, filterCount } from "@/entities/category"; import type { CategoryFilter } from "@/entities/category"; import { merchantLabel } from "@/entities/recurring"; import type { RecurringItem } from "@/entities/recurring"; -import { foldForSearch } from "@/shared/lib/text"; +import { EMPTY_AMOUNT_RANGE, foldForSearch } from "@/shared/lib/text"; +import type { AmountRange } from "@/shared/lib/text"; import { monthlyEquivalentMinor, recurringSections } from "./recurring"; import type { @@ -15,8 +16,6 @@ import type { RecurringSection, } from "./recurring"; import type { RecurringSortMode } from "./search"; -import { EMPTY_AMOUNT_RANGE } from "./transaction-filters"; -import type { AmountRange } from "./transaction-filters"; export interface RecurringFilter { amount: AmountRange; diff --git a/apps/web/src/pages/budget/model/search.ts b/apps/web/src/pages/budget/model/search.ts index 988f74dd..71b3e7d9 100644 --- a/apps/web/src/pages/budget/model/search.ts +++ b/apps/web/src/pages/budget/model/search.ts @@ -1,14 +1,22 @@ import { CATEGORY_GROUPS, SPENDING_CATEGORIES, - TRANSACTION_DIRECTIONS, } from "@freenary/api/lib/taxonomy"; -import { - lenientTransactionFiltersSchema, - MAX_SEARCH_TEXT_LENGTH, -} from "@freenary/api/lib/transaction-filters"; +import { MAX_SEARCH_TEXT_LENGTH } from "@freenary/api/lib/transaction-filters"; import { z } from "zod"; +import { + nextTransactionListSearch, + TRANSACTION_LIST_SEARCH_DEFAULTS, + transactionListSearchSchema, +} from "@/features/transaction-list"; +import { + absentUnlessNonEmptyText, + absentUnlessOneOf, + absentWhenDefault, + absentWhenEmpty, +} from "@/shared/routing"; + import { AGGREGATION_MODES, TIME_RANGES } from "./period"; import { RECURRENCE_CONFIDENCES, @@ -24,10 +32,6 @@ export type RecurringView = (typeof RECURRING_VIEWS)[number]; export type RecurringCompanionView = (typeof RECURRING_COMPANION_VIEWS)[number]; -export type TransactionDirection = (typeof TRANSACTION_DIRECTIONS)[number]; - -export type SortMode = (typeof SORT_MODES)[number]; - export type RecurringSortMode = (typeof RECURRING_SORT_MODES)[number]; export type BudgetSearch = z.infer; @@ -44,7 +48,6 @@ export const RECURRING_COMPANION_VIEWS = [ "split", ] as const; -export const SORT_MODES = ["date", "amount"] as const; export const RECURRING_SORT_MODES = ["next", "cost"] as const; const EARLIEST_PLAUSIBLE_YEAR = 1970; @@ -54,16 +57,6 @@ const MAX_PLAUSIBLE_AMOUNT = 1_000_000_000; const LAST_MONTH_INDEX = 11; -const absentUnlessOneOf = (allowed: readonly T[]) => - z - .unknown() - .transform((raw) => { - const parsed = z.enum(allowed).safeParse(raw); - - return parsed.success ? parsed.data : undefined; - }) - .optional(); - const absentUnlessSlugList = (allowed: readonly T[]) => z .unknown() @@ -90,17 +83,6 @@ const absentUnlessBoundedInt = (min: number, max: number) => }) .optional(); -const absentUnlessNonEmptyText = z - .unknown() - .transform((raw) => { - const parsed = z.coerce.string().safeParse(raw); - - return parsed.success && parsed.data.length > 0 - ? parsed.data.slice(0, MAX_SEARCH_TEXT_LENGTH) - : undefined; - }) - .optional(); - const absentUnlessPositiveAmount = z .unknown() .transform((raw) => { @@ -114,17 +96,10 @@ const absentUnlessPositiveAmount = z }) .optional(); -const absentUnlessFilters = lenientTransactionFiltersSchema - .transform((kept) => (kept.length > 0 ? kept : undefined)) - .optional(); - -export const budgetSearchSchema = z.object({ +export const budgetSearchSchema = transactionListSearchSchema.extend({ agg: absentUnlessOneOf(AGGREGATION_MODES), companion: absentUnlessOneOf(COMPANION_VIEWS), - dir: absentUnlessOneOf(TRANSACTION_DIRECTIONS), - filters: absentUnlessFilters, month: absentUnlessBoundedInt(0, LAST_MONTH_INDEX), - q: absentUnlessNonEmptyText, range: absentUnlessOneOf(TIME_RANGES), rcat: absentUnlessSlugList(SPENDING_CATEGORIES), rcomp: absentUnlessOneOf(RECURRING_COMPANION_VIEWS), @@ -134,19 +109,17 @@ export const budgetSearchSchema = z.object({ rkind: absentUnlessOneOf(RECURRENCE_KINDS), rmax: absentUnlessPositiveAmount, rmin: absentUnlessPositiveAmount, - rq: absentUnlessNonEmptyText, + rq: absentUnlessNonEmptyText(MAX_SEARCH_TEXT_LENGTH), rsort: absentUnlessOneOf(RECURRING_SORT_MODES), rview: absentUnlessOneOf(RECURRING_VIEWS), - sort: absentUnlessOneOf(SORT_MODES), view: absentUnlessOneOf(PRIMARY_VIEWS), year: absentUnlessBoundedInt(EARLIEST_PLAUSIBLE_YEAR, LATEST_PLAUSIBLE_YEAR), }); export const BUDGET_SEARCH_DEFAULTS = { + ...TRANSACTION_LIST_SEARCH_DEFAULTS, agg: "total", companion: "fixed", - dir: "outgoing", - q: "", range: "1M", rcomp: "categories", rkind: "fixed", @@ -155,20 +128,10 @@ export const BUDGET_SEARCH_DEFAULTS = { rq: "", rsort: "cost", rview: "trend", - sort: "date", view: "flow", } as const satisfies Partial; -const absentWhenDefault = ( - value: T | undefined, - fallback: T -): T | undefined => - value === undefined || value === fallback ? undefined : value; - -const absentWhenEmpty = (value: T[] | undefined) => - value && value.length > 0 ? value : undefined; - -const nextTransactionsSearch = ( +const nextPeriodAndChartsSearch = ( current: BudgetSearch, patch: BudgetSearchPatch ) => ({ @@ -177,18 +140,11 @@ const nextTransactionsSearch = ( patch.companion ?? current.companion, BUDGET_SEARCH_DEFAULTS.companion ), - dir: absentWhenDefault(patch.dir ?? current.dir, BUDGET_SEARCH_DEFAULTS.dir), - filters: absentWhenEmpty(patch.filters ?? current.filters), month: patch.month ?? current.month, - q: absentWhenDefault(patch.q ?? current.q, BUDGET_SEARCH_DEFAULTS.q), range: absentWhenDefault( patch.range ?? current.range, BUDGET_SEARCH_DEFAULTS.range ), - sort: absentWhenDefault( - patch.sort ?? current.sort, - BUDGET_SEARCH_DEFAULTS.sort - ), view: absentWhenDefault( patch.view ?? current.view, BUDGET_SEARCH_DEFAULTS.view @@ -235,6 +191,7 @@ export const nextBudgetSearch = ( current: BudgetSearch, patch: BudgetSearchPatch ): BudgetSearch => ({ - ...nextTransactionsSearch(current, patch), + ...nextTransactionListSearch(current, patch), + ...nextPeriodAndChartsSearch(current, patch), ...nextRecurringSearch(current, patch), }); diff --git a/apps/web/src/pages/budget/model/use-budget-view.ts b/apps/web/src/pages/budget/model/use-budget-view.ts index 64201b87..1895ed8b 100644 --- a/apps/web/src/pages/budget/model/use-budget-view.ts +++ b/apps/web/src/pages/budget/model/use-budget-view.ts @@ -1,14 +1,13 @@ import { getRouteApi } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; -import { BUDGET_SEARCH_DEFAULTS, nextBudgetSearch } from "./search"; import type { - BudgetSearchPatch, - CompanionView, - PrimaryView, SortMode, TransactionDirection, -} from "./search"; +} from "@/features/transaction-list"; + +import { BUDGET_SEARCH_DEFAULTS, nextBudgetSearch } from "./search"; +import type { BudgetSearchPatch, CompanionView, PrimaryView } from "./search"; import { useBudgetPeriod } from "./use-budget-period"; import type { BudgetPeriodPatch } from "./use-budget-period"; diff --git a/apps/web/src/pages/budget/ui/amount-filter-menu.tsx b/apps/web/src/pages/budget/ui/amount-filter-menu.tsx index 3f530e9e..0df52d21 100644 --- a/apps/web/src/pages/budget/ui/amount-filter-menu.tsx +++ b/apps/web/src/pages/budget/ui/amount-filter-menu.tsx @@ -19,14 +19,10 @@ import { m } from "@/paraglide/messages.js"; import { getLocale } from "@/paraglide/runtime.js"; import { currencyAffix } from "@/shared/lib/format-currency"; import { remixIcon } from "@/shared/lib/remix-icon"; +import { EMPTY_AMOUNT_RANGE, parseAmountBound } from "@/shared/lib/text"; +import type { AmountRange } from "@/shared/lib/text"; import { useDebouncedValue } from "@/shared/lib/use-debounced-value"; -import { - EMPTY_AMOUNT_RANGE, - parseAmountBound, -} from "../model/transaction-filters"; -import type { AmountRange } from "../model/transaction-filters"; - interface AmountFilterMenuProps { currency: string; hint: string; diff --git a/apps/web/src/pages/budget/ui/budget-area.tsx b/apps/web/src/pages/budget/ui/budget-area.tsx index 55b0d583..37d0ff2d 100644 --- a/apps/web/src/pages/budget/ui/budget-area.tsx +++ b/apps/web/src/pages/budget/ui/budget-area.tsx @@ -4,11 +4,10 @@ import { Outlet } from "@tanstack/react-router"; import { useSyncAccounts } from "@/features/account-sync"; import { m } from "@/paraglide/messages.js"; import { orpc } from "@/shared/api"; +import { LoadError } from "@/shared/ui/load-error"; import { NoBankAccount } from "@/shared/ui/no-bank-account"; import { SyncButton } from "@/shared/ui/sync-button"; -import { LoadError } from "./load-error"; - export const BudgetArea = () => { const accountsQuery = useQuery(orpc.budget.getAccounts.queryOptions()); const { isSyncing, sync } = useSyncAccounts(); diff --git a/apps/web/src/pages/budget/ui/budget-charts.tsx b/apps/web/src/pages/budget/ui/budget-charts.tsx index cfb517d9..cde57ac4 100644 --- a/apps/web/src/pages/budget/ui/budget-charts.tsx +++ b/apps/web/src/pages/budget/ui/budget-charts.tsx @@ -24,6 +24,8 @@ import type { ComponentProps } from "react"; import type { CategorySelection } from "@/entities/category"; import { m } from "@/paraglide/messages.js"; +import { PRESS_MOTION } from "@/shared/ui/list-controls"; +import { StaleRegion } from "@/shared/ui/stale-region"; import { aggregationLabel } from "../model/period"; import type { AggregationMode } from "../model/period"; @@ -31,9 +33,7 @@ import type { CompanionView, PrimaryView } from "../model/search"; import { BudgetVsActualChart } from "./budget-vs-actual-chart"; import { CashFlowChart } from "./cash-flow-chart"; import { FixedVsVariableChart } from "./fixed-vs-variable-chart"; -import { PRESS_MOTION } from "./list-controls"; import { SpendingBreakdownChart } from "./spending-breakdown-chart"; -import { StaleRegion } from "./stale-region"; type BreakdownData = Pick< ComponentProps, diff --git a/apps/web/src/pages/budget/ui/budget-kpi-strip.tsx b/apps/web/src/pages/budget/ui/budget-kpi-strip.tsx index ab546047..6785ce20 100644 --- a/apps/web/src/pages/budget/ui/budget-kpi-strip.tsx +++ b/apps/web/src/pages/budget/ui/budget-kpi-strip.tsx @@ -4,9 +4,9 @@ import { cn } from "@freenary/ui/lib/utils"; import { m } from "@/paraglide/messages.js"; import { formatCurrency } from "@/shared/lib/format-currency"; +import { StaleRegion } from "@/shared/ui/stale-region"; import type { AggregationMode } from "../model/period"; -import { StaleRegion } from "./stale-region"; interface SummaryLabels { expenses: () => string; diff --git a/apps/web/src/pages/budget/ui/budget-vs-actual-chart.tsx b/apps/web/src/pages/budget/ui/budget-vs-actual-chart.tsx index 716b6886..a6c8a668 100644 --- a/apps/web/src/pages/budget/ui/budget-vs-actual-chart.tsx +++ b/apps/web/src/pages/budget/ui/budget-vs-actual-chart.tsx @@ -17,8 +17,7 @@ import type { CategorySelection } from "@/entities/category"; import { categoryChartColor, categoryLabel } from "@/entities/category"; import { m } from "@/paraglide/messages.js"; import { formatCurrency } from "@/shared/lib/format-currency"; - -import { PRESS_MOTION } from "./list-controls"; +import { PRESS_MOTION } from "@/shared/ui/list-controls"; interface PlannedCategory { actual: number; diff --git a/apps/web/src/pages/budget/ui/period-navigator.tsx b/apps/web/src/pages/budget/ui/period-navigator.tsx index 66b3afb0..d81b01d0 100644 --- a/apps/web/src/pages/budget/ui/period-navigator.tsx +++ b/apps/web/src/pages/budget/ui/period-navigator.tsx @@ -26,8 +26,8 @@ import { useRef, useState } from "react"; import { m } from "@/paraglide/messages.js"; import { getLocale } from "@/paraglide/runtime.js"; +import { useHoverIntent } from "@/shared/ui/list-controls"; -import { useHoverIntent } from "../lib/use-hover-intent"; import { AGGREGATION_MODES, aggregationLabel, diff --git a/apps/web/src/pages/budget/ui/recurring-charts.tsx b/apps/web/src/pages/budget/ui/recurring-charts.tsx index 7855a0d6..8fa0c542 100644 --- a/apps/web/src/pages/budget/ui/recurring-charts.tsx +++ b/apps/web/src/pages/budget/ui/recurring-charts.tsx @@ -24,6 +24,7 @@ import { useMemo } from "react"; import type { RecurringData } from "@/entities/recurring"; import { m } from "@/paraglide/messages.js"; +import { PRESS_MOTION } from "@/shared/ui/list-controls"; import { forecastSeries, @@ -35,7 +36,6 @@ import { import { RECURRING_COMPANION_VIEWS, RECURRING_VIEWS } from "../model/search"; import type { RecurringCompanionView, RecurringView } from "../model/search"; import { FrequencyCostChart } from "./frequency-cost-chart"; -import { PRESS_MOTION } from "./list-controls"; import { PurchaseFrequencyChart } from "./purchase-frequency-chart"; import { RecurringCategoryChart } from "./recurring-category-chart"; import { RecurringForecastChart } from "./recurring-forecast-chart"; diff --git a/apps/web/src/pages/budget/ui/recurring-list.tsx b/apps/web/src/pages/budget/ui/recurring-list.tsx index 21da163d..f2e9eec8 100644 --- a/apps/web/src/pages/budget/ui/recurring-list.tsx +++ b/apps/web/src/pages/budget/ui/recurring-list.tsx @@ -8,6 +8,16 @@ import { formatCurrency, formatDecimalCurrency, } from "@/shared/lib/format-currency"; +import { EMPTY_AMOUNT_RANGE } from "@/shared/lib/text"; +import type { AmountRange } from "@/shared/lib/text"; +import { + ClearFiltersButton, + ListFilterBar, + ListFilterChip, + ListFilterChips, + ListSearchInput, + ListSortToggle, +} from "@/shared/ui/list-controls"; import { confidenceLabel, @@ -29,18 +39,8 @@ import type { RecurringGroups, } from "../model/recurring-filters"; import type { RecurringSortMode } from "../model/search"; -import { EMPTY_AMOUNT_RANGE } from "../model/transaction-filters"; -import type { AmountRange } from "../model/transaction-filters"; import { AmountFilterMenu } from "./amount-filter-menu"; import { CategoryFilterMenu } from "./category-filter-menu"; -import { - ClearFiltersButton, - ListFilterBar, - ListFilterChip, - ListFilterChips, - ListSearchInput, - ListSortToggle, -} from "./list-controls"; import { RecurrenceFilterMenu } from "./recurrence-filter-menu"; import { RecurringTable } from "./recurring-table"; diff --git a/apps/web/src/pages/budget/ui/recurring-page.tsx b/apps/web/src/pages/budget/ui/recurring-page.tsx index dd34aa71..7a6f139a 100644 --- a/apps/web/src/pages/budget/ui/recurring-page.tsx +++ b/apps/web/src/pages/budget/ui/recurring-page.tsx @@ -12,6 +12,7 @@ import { useCallback, useMemo } from "react"; import type { CategoryFilter } from "@/entities/category"; import { m } from "@/paraglide/messages.js"; import { orpc } from "@/shared/api"; +import type { AmountRange } from "@/shared/lib/text"; import { recurringInsights, recurringSummary } from "../model/recurring"; import type { @@ -20,7 +21,6 @@ import type { } from "../model/recurring"; import { groupRecurringItems } from "../model/recurring-filters"; import { BUDGET_SEARCH_DEFAULTS } from "../model/search"; -import type { AmountRange } from "../model/transaction-filters"; import { useBudgetCurrency } from "../model/use-budget-currency"; import { useRecurringView } from "../model/use-recurring-view"; import { useSyncProgress } from "../model/use-sync-progress"; diff --git a/apps/web/src/pages/budget/ui/spending-breakdown-chart.tsx b/apps/web/src/pages/budget/ui/spending-breakdown-chart.tsx index b16d3ef6..7b0a8db2 100644 --- a/apps/web/src/pages/budget/ui/spending-breakdown-chart.tsx +++ b/apps/web/src/pages/budget/ui/spending-breakdown-chart.tsx @@ -18,6 +18,7 @@ import { categoryChartColor, categoryLabel } from "@/entities/category"; import { m } from "@/paraglide/messages.js"; import { CHART_COLOR_VARS } from "@/shared/lib/chart-colors"; import { formatCurrency } from "@/shared/lib/format-currency"; +import { PRESS_MOTION } from "@/shared/ui/list-controls"; import { breakdownSlices, @@ -28,7 +29,6 @@ import type { BreakdownSlice, CategoryAmount, } from "../model/spending-breakdown"; -import { PRESS_MOTION } from "./list-controls"; interface SpendingBreakdownChartProps { activeCategories: SpendingCategory[]; diff --git a/apps/web/src/pages/budget/ui/transactions-page.tsx b/apps/web/src/pages/budget/ui/transactions-page.tsx index e8daf8da..0f937a7e 100644 --- a/apps/web/src/pages/budget/ui/transactions-page.tsx +++ b/apps/web/src/pages/budget/ui/transactions-page.tsx @@ -1,15 +1,21 @@ import type { TransactionFilter } from "@freenary/api/lib/transaction-filters"; import type { AppRouter } from "@freenary/api/routers/index"; import type { InferRouterOutputs } from "@orpc/server"; -import { - useInfiniteQuery, - useQuery, - useQueryClient, -} from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useId, useMemo, useState } from "react"; import type { CategorySelection } from "@/entities/category"; import { TransactionDetailDrawer } from "@/features/transaction-details"; +import { + highlightedChartCategories, + selectChartCategory, + TransactionList, + useTransactionListQuery, +} from "@/features/transaction-list"; +import type { + TransactionGrouping, + TransactionsInput, +} from "@/features/transaction-list"; import { m } from "@/paraglide/messages.js"; import { orpc } from "@/shared/api"; @@ -17,15 +23,9 @@ import { isStaleView, periodOverviewQueryOptions, prefetchPeriod, - prefetchTransactions, - transactionsQueryOptions, } from "../api/budget-queries"; -import type { PeriodInput, TransactionsInput } from "../api/budget-queries"; -import { - highlightedChartCategories, - selectChartCategory, -} from "../model/filter-tokens"; -import type { SortMode, TransactionDirection } from "../model/search"; +import type { PeriodInput } from "../api/budget-queries"; +import type { TimeRange } from "../model/period"; import { useBudgetCurrency } from "../model/use-budget-currency"; import { useBudgetView } from "../model/use-budget-view"; import { useSyncProgress } from "../model/use-sync-progress"; @@ -33,7 +33,6 @@ import { BudgetCharts } from "./budget-charts"; import { BudgetKpiStrip } from "./budget-kpi-strip"; import { ChartsToggle } from "./charts-toggle"; import { PeriodNavigator } from "./period-navigator"; -import { TransactionList } from "./transaction-list"; type PeriodOverview = InferRouterOutputs["budget"]["getPeriodOverview"]; @@ -47,6 +46,12 @@ const overviewSections = (overview: PeriodOverview | undefined) => ({ totalIncome: overview?.sankey.totalIncome ?? 0, }); +const GROUPING_OF_RANGE = { + "1M": "day", + "1Y": "month", + "3M": "week", +} as const satisfies Record; + export const TransactionsPage = () => { const accountsQuery = useQuery(orpc.budget.getAccounts.queryOptions()); const currency = useBudgetCurrency(); @@ -104,38 +109,22 @@ export const TransactionsPage = () => { [direction, filters, from, searchQuery, sort, to] ); - const transactionsQuery = useInfiniteQuery( - transactionsQueryOptions(listInput) - ); - - const queryClient = useQueryClient(); + const syncProgress = useSyncProgress(); + const hasNoHistoryYet = + accountsQuery.isSuccess && accountsQuery.data.firstTransactionDate === null; - const handleDirectionIntent = useCallback( - (dir: TransactionDirection) => - prefetchTransactions(queryClient, { ...listInput, direction: dir }), - [listInput, queryClient] - ); + const isAwaitingFirstData = syncProgress !== null && hasNoHistoryYet; + const transactionList = useTransactionListQuery(listInput, { + isAwaitingFirstData, + }); - const handleSortIntent = useCallback( - (next: SortMode) => - prefetchTransactions(queryClient, { ...listInput, sort: next }), - [listInput, queryClient] - ); + const queryClient = useQueryClient(); const handlePeriodIntent = useCallback( (target: PeriodInput) => prefetchPeriod(queryClient, target, listInput), [listInput, queryClient] ); - const handleLoadMore = useCallback(() => { - if ( - transactionsQuery.hasNextPage && - !transactionsQuery.isFetchingNextPage - ) { - transactionsQuery.fetchNextPage(); - } - }, [transactionsQuery]); - const handleFiltersChange = useCallback( (next: TransactionFilter[]) => applyPatch({ filters: next }), [applyPatch] @@ -158,32 +147,20 @@ export const TransactionsPage = () => { [filters, handleFiltersChange] ); - const allTransactions = - transactionsQuery.data?.pages.flatMap((p) => p.transactions) ?? []; - - const totals = transactionsQuery.data?.pages[0]?.totals ?? { - incoming: 0, - outgoing: 0, - }; - - const syncProgress = useSyncProgress(); - const hasNoHistoryYet = - accountsQuery.isSuccess && accountsQuery.data.firstTransactionDate === null; - - const isAwaitingFirstData = syncProgress !== null && hasNoHistoryYet; - const isFilling = (isLoading: boolean) => isLoading || isAwaitingFirstData; const overview = overviewSections(overviewQuery.data); const overviewState = { isError: overviewQuery.isError, - isPending: isFilling(overviewQuery.isLoading), + isPending: overviewQuery.isLoading || isAwaitingFirstData, isStale: isStaleView(overviewQuery), }; const selectedTransaction = selectedTransactionId - ? (allTransactions.find((t) => t.id === selectedTransactionId) ?? null) + ? (transactionList.transactions.find( + (t) => t.id === selectedTransactionId + ) ?? null) : null; - const isUpdating = [overviewQuery, transactionsQuery].some(isStaleView); + const isUpdating = isStaleView(overviewQuery) || transactionList.isStale; const chartsId = useId(); return ( @@ -246,12 +223,10 @@ export const TransactionsPage = () => { applyPatch({ dir })} - onDirectionIntent={handleDirectionIntent} from={from} to={to} search={searchQuery} @@ -261,23 +236,8 @@ export const TransactionsPage = () => { onSearchReplace={handleSearchReplace} sort={sort} onSortChange={(next) => applyPatch({ sort: next })} - onSortIntent={handleSortIntent} - hasMore={transactionsQuery.hasNextPage} - onLoadMore={handleLoadMore} - isLoading={ - isFilling(transactionsQuery.isLoading) || - transactionsQuery.isFetchingNextPage - } - isError={ - transactionsQuery.isError && !transactionsQuery.isFetchNextPageError - } - isRetrying={transactionsQuery.isFetching} - onRetry={() => { - void transactionsQuery.refetch(); - }} - isStale={isStaleView(transactionsQuery)} onTransactionClick={(tx) => setSelectedTransactionId(tx.id)} - range={range} + grouping={GROUPING_OF_RANGE[range]} /> // oxlint-disable-next-line sort-keys -- TanStack infers the page type from `queryFn`, so a later `getNextPageParam` reads its `lastPage`; alphabetical order puts it first, where the page is `unknown`. infiniteQueryOptions({ - queryKey: [...TRANSACTIONS_QUERY_KEY, "latest", { accountId, pageSize }], + queryKey: [...TRANSACTIONS_QUERY_KEY, "latest", { pageSize }], queryFn: ({ pageParam }) => client.budget.getTransactions({ - accountId, cursor: pageParam, from: EARLIEST_BANK_DATE, limit: pageSize, diff --git a/apps/web/src/pages/home/ui/home-page.tsx b/apps/web/src/pages/home/ui/home-page.tsx index 1ee710d2..fc5f0b77 100644 --- a/apps/web/src/pages/home/ui/home-page.tsx +++ b/apps/web/src/pages/home/ui/home-page.tsx @@ -1,11 +1,11 @@ import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; -import { latestTransactionsQueryOptions } from "@/entities/budget"; import { useSyncAccounts } from "@/features/account-sync"; import { m } from "@/paraglide/messages.js"; import { orpc } from "@/shared/api"; import { NoBankAccount } from "@/shared/ui/no-bank-account"; +import { latestTransactionsQueryOptions } from "../api/latest-transactions-query"; import { dashboardMonths, RECENT_TRANSACTIONS_SHOWN } from "../model/dashboard"; import { firstPageWidgetQuery, widgetQuery } from "../model/widget-query"; import { AccountStrip } from "./account-strip"; diff --git a/apps/web/src/pages/budget/model/transaction-filters.test.ts b/apps/web/src/shared/lib/text/amount-bound.test.ts similarity index 95% rename from apps/web/src/pages/budget/model/transaction-filters.test.ts rename to apps/web/src/shared/lib/text/amount-bound.test.ts index d3210548..25238c50 100644 --- a/apps/web/src/pages/budget/model/transaction-filters.test.ts +++ b/apps/web/src/shared/lib/text/amount-bound.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { parseAmountBound } from "./transaction-filters"; +import { parseAmountBound } from "./amount-bound"; describe("parseAmountBound", () => { test("reads a plain number in either locale", () => { diff --git a/apps/web/src/pages/budget/model/transaction-filters.ts b/apps/web/src/shared/lib/text/amount-bound.ts similarity index 100% rename from apps/web/src/pages/budget/model/transaction-filters.ts rename to apps/web/src/shared/lib/text/amount-bound.ts diff --git a/apps/web/src/shared/lib/text/index.ts b/apps/web/src/shared/lib/text/index.ts index 88d57811..2ab24666 100644 --- a/apps/web/src/shared/lib/text/index.ts +++ b/apps/web/src/shared/lib/text/index.ts @@ -1,2 +1,7 @@ +export { + type AmountRange, + EMPTY_AMOUNT_RANGE, + parseAmountBound, +} from "./amount-bound"; export { merchantName, readableName } from "./merchant-name"; export { foldForSearch } from "./search-text"; diff --git a/apps/web/src/shared/routing/index.ts b/apps/web/src/shared/routing/index.ts new file mode 100644 index 00000000..aadcdc51 --- /dev/null +++ b/apps/web/src/shared/routing/index.ts @@ -0,0 +1,6 @@ +export { + absentUnlessNonEmptyText, + absentUnlessOneOf, + absentWhenDefault, + absentWhenEmpty, +} from "./search-params"; diff --git a/apps/web/src/shared/routing/search-params.ts b/apps/web/src/shared/routing/search-params.ts new file mode 100644 index 00000000..d0bc672a --- /dev/null +++ b/apps/web/src/shared/routing/search-params.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; + +export const absentUnlessOneOf = (allowed: readonly T[]) => + z + .unknown() + .transform((raw) => { + const parsed = z.enum(allowed).safeParse(raw); + + return parsed.success ? parsed.data : undefined; + }) + .optional(); + +export const absentUnlessNonEmptyText = (maxLength: number) => + z + .unknown() + .transform((raw) => { + const parsed = z.coerce.string().safeParse(raw); + + return parsed.success && parsed.data.length > 0 + ? parsed.data.slice(0, maxLength) + : undefined; + }) + .optional(); + +export const absentWhenDefault = ( + value: T | undefined, + fallback: T +): T | undefined => + value === undefined || value === fallback ? undefined : value; + +export const absentWhenEmpty = (value: T[] | undefined) => + value && value.length > 0 ? value : undefined; diff --git a/apps/web/src/shared/ui/list-controls/index.ts b/apps/web/src/shared/ui/list-controls/index.ts new file mode 100644 index 00000000..986831b9 --- /dev/null +++ b/apps/web/src/shared/ui/list-controls/index.ts @@ -0,0 +1,11 @@ +export { + ClearFiltersButton, + ListFilterBar, + ListFilterChip, + ListFilterChips, + ListSearchInput, + ListSortToggle, + PRESS_MOTION, +} from "./list-controls"; +export { useHoverIntent } from "./use-hover-intent"; +export { useSettledText } from "./use-settled-text"; diff --git a/apps/web/src/pages/budget/ui/list-controls.tsx b/apps/web/src/shared/ui/list-controls/list-controls.tsx similarity index 97% rename from apps/web/src/pages/budget/ui/list-controls.tsx rename to apps/web/src/shared/ui/list-controls/list-controls.tsx index 32261c24..a4341888 100644 --- a/apps/web/src/pages/budget/ui/list-controls.tsx +++ b/apps/web/src/shared/ui/list-controls/list-controls.tsx @@ -23,8 +23,8 @@ import type { ReactNode } from "react"; import { m } from "@/paraglide/messages.js"; -import { useHoverIntent } from "../lib/use-hover-intent"; -import { useSettledText } from "../lib/use-settled-text"; +import { useHoverIntent } from "./use-hover-intent"; +import { useSettledText } from "./use-settled-text"; interface ChipPosition { index: number; diff --git a/apps/web/src/pages/budget/lib/use-hover-intent.ts b/apps/web/src/shared/ui/list-controls/use-hover-intent.ts similarity index 100% rename from apps/web/src/pages/budget/lib/use-hover-intent.ts rename to apps/web/src/shared/ui/list-controls/use-hover-intent.ts diff --git a/apps/web/src/pages/budget/lib/use-settled-text.ts b/apps/web/src/shared/ui/list-controls/use-settled-text.ts similarity index 100% rename from apps/web/src/pages/budget/lib/use-settled-text.ts rename to apps/web/src/shared/ui/list-controls/use-settled-text.ts diff --git a/apps/web/src/pages/budget/ui/load-error.tsx b/apps/web/src/shared/ui/load-error.tsx similarity index 100% rename from apps/web/src/pages/budget/ui/load-error.tsx rename to apps/web/src/shared/ui/load-error.tsx diff --git a/apps/web/src/pages/budget/ui/stale-region.tsx b/apps/web/src/shared/ui/stale-region.tsx similarity index 100% rename from apps/web/src/pages/budget/ui/stale-region.tsx rename to apps/web/src/shared/ui/stale-region.tsx diff --git a/docs/engineering/web-app.md b/docs/engineering/web-app.md index 111cdc3e..cfda4862 100644 --- a/docs/engineering/web-app.md +++ b/docs/engineering/web-app.md @@ -54,7 +54,7 @@ Durable facts that the code cannot carry in a name. Each bullet names the module - `apps/web/src/pages/budget/model/recurring.ts › recurringTrend` — the server sends the trailing 12 calendar months, oldest first and zero-filled. The final entry is the month in progress and is dropped, because comparing a part month against whole ones reports a collapse that never happened; and a month with nothing outgoing at all is a month before the history begins, since the series counts discretionary spend too, so averaging it in fabricates a rise. - `apps/web/src/pages/budget/model/recurring-filters.ts › filterRecurringItems` — detection reads a whole year in one response, so every recurring filter runs in the browser: narrowing the list costs no request and contradicts no chart. The amount bounds read against the monthly equivalent, which is the figure the filter sits beside, not the amount charged. - `apps/web/src/entities/category/model/category-selection.ts › toggleGroup` — a ticked group drops its own categories from the filter because `filterRecurringItems` unions them in anyway; keeping the chips would raise the filter badge without changing a single result. It matters more with three groups than with sixteen: one `Spending` chip now stands for sixteen categories, so the alternative is sixteen chips that say the same thing. -- `apps/web/src/pages/budget/model/transaction-filters.ts › parseAmountBound` — reads a bound in the reader's own notation: `1,234.56` under `en` and `1 234,56` under `fr` are the same number, and both are what the list renders and a reader copies out of it. The locale says which separator groups, whatever is left of a decimal comma becomes a dot, and where several dots survive the last one is the decimal point. Anything else reads as no bound rather than as an error the reader then has to clear. +- `apps/web/src/shared/lib/text/amount-bound.ts › parseAmountBound` — reads a bound in the reader's own notation: `1,234.56` under `en` and `1 234,56` under `fr` are the same number, and both are what the list renders and a reader copies out of it. The locale says which separator groups, whatever is left of a decimal comma becomes a dot, and where several dots survive the last one is the decimal point. Anything else reads as no bound rather than as an error the reader then has to clear. - `apps/web/src/pages/budget/model/period.ts › computeDateRange` — a transaction's `date` is a bank date with no time, stored as UTC midnight, so the period bounds are UTC calendar days built with `Date.UTC` and the period label formats with `timeZone: "UTC"`. Read with local getters, the 1st of a month falls into the month before anywhere west of UTC. `period.ts › localDayOfBankDate` turns a bank date into the same calendar day in local time, so transaction grouping and the "Today" header compare it with the reader's own calendar day, and the Recurring view does the same with an item's `nextExpected` before counting the days to it from `asOf`, which is a real instant and stays in local time; `use-budget-period.ts › startOfMonth` does the same for the data bounds the period navigator and its calendar read. - `apps/web/src/shared/lib/sankey/apportion.ts › apportion` — money is fungible, so no apportionment is more true than another; but linking every source to every target needs N×M ribbons for the same totals and reads as a wash, while filling each target in turn needs at most N+M−1. Sources are consumed in the order given, and when they run short the trailing targets get no ribbon — which is what an overspent period looks like. - `apps/web/src/shared/lib/sankey/apportion.ts › canContribute` — the positive-only test is what rejects `NaN`: its comparisons are false in both directions, so a source that was retried rather than skipped would spin forever. @@ -99,17 +99,17 @@ Durable facts that the code cannot carry in a name. Each bullet names the module - `apps/web/vite.config.ts › paraglideVitePlugin` — locale lives in a cookie, not the URL: every route is behind auth, so localized paths would buy no SEO and cost a router rewrite. The `dev` script in `apps/web/package.json` repeats these same options because Vite caches the first failed resolution of `@/paraglide/server.js`, so `outdir` must exist before Vite starts; `project.inlang` would be the drift-proof home for them, but inlang's `.gitignore` there keeps everything but `settings.json` out of the repo. - `apps/web/src/app/routes/callback/$provider.tsx › Route.ssr` — `ssr: false` is required, because exchanging the provider's callback needs the session cookie, which only the browser holds. - `apps/web/src/app/routes/callback/$provider.tsx › callbackSearchSchema` with `pages/callback/api/exchange-callback.ts › parseCallbackDestination` — providers append their own query parameters and TanStack Router auto-parses JSON-shaped and numeric values, so each value is normalised back to the exact string the provider sent: only the provider's own adapter knows what it means. A failed exchange never got far enough to report where it came from, so the destination is read off the _unverified_ `state`, which is safe only because it picks between two of our own pages and anything unexpected lands on onboarding. An `error` parameter means the reader declined at the bank, and the provider still echoes `state`, so the flow can still return where it started. -- `apps/web/src/pages/budget/model/search.ts › budgetSearchSchema` — a URL carries whatever the sender typed and the router JSON-parses it, so every field reads through a parser that answers "absent" instead of throwing: a mangled link must open the default view, never a route error. Nothing defaults in the schema, so an absent parameter means the default, a clean view keeps a clean URL, and a shared link carries only what its sender changed. The `r`-prefixed fields belong to Recurring and the rest to Transactions. `MAX_PLAUSIBLE_AMOUNT` and `EARLIEST_PLAUSIBLE_YEAR` bound what a hostile link may ask of the database. `filters` reads through `lenientTransactionFiltersSchema` from `@freenary/api/lib/transaction-filters`. It is built on the same `transactionFilterSchema` that `getTransactions` and `saveFilter` check strictly through `transactionFiltersSchema`, so the URL, a saved search and the request share one shape. A link drops a filter that does not parse, drops a repeated one, and keeps the rest, capped at `MAX_TRANSACTION_FILTERS`. +- `apps/web/src/pages/budget/model/search.ts › budgetSearchSchema`, `apps/web/src/features/transaction-list/model/list-search.ts › transactionListSearchSchema` — a URL carries whatever the sender typed and the router JSON-parses it, so every field reads through a parser that answers "absent" instead of throwing: a mangled link must open the default view, never a route error. Nothing defaults in the schema, so an absent parameter means the default, a clean view keeps a clean URL, and a shared link carries only what its sender changed. `transactionListSearchSchema` holds the list fields, `dir`, `filters`, `q` and `sort`, which the account page reads alone and `budgetSearchSchema` extends. The `r`-prefixed fields belong to Recurring and the rest to Transactions. `MAX_PLAUSIBLE_AMOUNT` and `EARLIEST_PLAUSIBLE_YEAR` bound what a hostile link may ask of the database. `filters` reads through `lenientTransactionFiltersSchema` from `@freenary/api/lib/transaction-filters`. It is built on the same `transactionFilterSchema` that `getTransactions` and `saveFilter` check strictly through `transactionFiltersSchema`, so the URL, a saved search and the request share one shape. A link drops a filter that does not parse, drops a repeated one, and keeps the rest, capped at `MAX_TRANSACTION_FILTERS`. - `apps/web/src/pages/budget/ui/budget-area.tsx › Route.search` — a sidebar link carries no search of its own and the router would answer that with an empty one, so the budget area needs `retainSearchParams(true)`: both budget pages read one vocabulary, and stepping between them must keep the view the reader built. - `apps/web/src/pages/budget/ui/recurring-page.tsx › RecurringPage` — `asOf` comes from the server's `data.asOf`, when detection ran, and never from a render-time clock, which would move every projection between two renders. The view also reads a fixed trailing year on the server rather than the period the Transactions view carries, and `m.budget_recurring_scope()` is the string that says so. - `apps/web/src/pages/brand-avatar/ui/brand-avatar-page.tsx` — the whole route is a temporary dev-only state board: not linked from the app, not in the message catalogs, and meant to be deleted. ### Client data -- `apps/web/src/shared/api/orpc.ts › createQueryClient` — the cache reports only failures a mounted component observes, so a prefetch nobody is watching raises no toast. `budget-queries.ts › warmWithoutRaising` depends on that: it settles rather than awaits, so a failed warm-up rejects nothing either. +- `apps/web/src/shared/api/orpc.ts › createQueryClient` — the cache reports only failures a mounted component observes, so a prefetch nobody is watching raises no toast. `budget-queries.ts › warmWithoutRaising` and `features/transaction-list/api/transactions-query.ts › warmWithoutRaising` depend on that: it settles rather than awaits, so a failed warm-up rejects nothing either. - `oxlint.config.ts › no-restricted-imports` — the web runs `@freenary/api` code in the browser, so it may import at runtime only the isomorphic modules the rule lists; anything else, the router included, comes in through `import type`. API shapes are inferred from `InferRouterOutputs` rather than copied, and `packages/api/package.json` exports each subpath by name, so a new one is added there on purpose. - `apps/web/src/entities/budget/api/budget-cache.ts › TRANSACTIONS_QUERY_KEY` — hand-built rather than taken from `orpc.budget.getTransactions.key()`, because the list is an infinite query and the key has to stay matchable by prefix. `invalidateBudgetData` and `pages/budget/model/use-transaction-category.ts` both match it by prefix, and `orpc.budget.key()` cannot — which is why `invalidateBudgetData` issues a second invalidation. -- `apps/web/src/pages/budget/api/budget-queries.ts › prefetchTransactions` — the 60 s `staleTime` that `createQueryClient` sets for every query makes a repeat a no-op, so a pointer resting twice on the same control costs one request. +- `apps/web/src/features/transaction-list/api/transactions-query.ts › prefetchTransactions` — the 60 s `staleTime` that `createQueryClient` sets for every query makes a repeat a no-op, so a pointer resting twice on the same control costs one request. - `apps/web/src/pages/budget/ui/budget-area.tsx › BudgetArea` — the area renders nothing until the accounts query answers: painting the budget or the empty state and then swapping it is worse than the shell standing alone for a beat. - `apps/web/src/app/shell/use-account-sync.ts › hasStartedBackgroundSync` — the quiet sync belongs to the signed-in shell, not to Budget, because Budget and the assistant's tools (`packages/api/src/assistant/tools.ts`) both read the transactions it imports (issue #69). It starts once per mount of the shell, and only once `onboarding.getStatus` answers `completed`, so it runs after a sign-in, a page load, the bank callback returning to Settings (`/callback/$provider` sits outside the shell, so coming back mounts it again) and finishing onboarding (the navigation to `/` mounts it). A callback that returns to `/onboarding` starts nothing: onboarding sits outside the shell too. - `apps/web/src/app/shell/use-account-sync.ts › refetchInterval` — the status poll runs only while a run is known or a `budget.syncAccounts` mutation of this tab is in flight, so an idle page issues no traffic at all. Every sync mutation — `features/account-sync › useSyncAccounts` and the Settings re-sync (`use-bank-connections.ts › resyncMutation`) — shares `orpc.budget.syncAccounts.mutationKey()`, and `useIsMutating` on that key is what lets the shell poll, and so show its progress toast, for a sync started anywhere in the tab. The cost is that a run another tab starts is invisible to an idle page until it fetches for another reason, which is why both mutations invalidate the status query in `onMutate` rather than waiting for their own answer. diff --git a/docs/engineering/web-ui.md b/docs/engineering/web-ui.md index b0391e70..88c9addd 100644 --- a/docs/engineering/web-ui.md +++ b/docs/engineering/web-ui.md @@ -14,7 +14,7 @@ Durable facts that the code cannot carry in a name. Each bullet names the module - `apps/web/src/features/transaction-details/ui/spending-category-picker.tsx › SpendingCategoryPicker` — the rows are `categoriesForDirection(direction)` and not the whole taxonomy: 10 on a credit, 20 on a debit, so `Salary & wages` can never be offered on a card payment. `TransactionDetailDrawer` reads that direction off the sign of the amount, which is why the `useMemo` depends on it — an empty dependency array was harmless while the list was constant and would now freeze whichever direction the drawer opened on first. It stays a `Combobox` rather than a `Select`: 20 rows still want a search field, and each row carries its flow section right-aligned and muted, because two categories of one section read alike without it. The trigger is content-sized, so the popup needs its own width floor (`min-w-56`). - `apps/web/src/features/transaction-details/ui/transaction-detail-drawer.tsx › TransactionDetailDrawer` — Base UI plays the enter transition only when `open` flips on an already-mounted root, so the root stays rendered and `shownDuringExitAnimation` holds the last transaction to give the exit animation something to draw. - `packages/ui › MenuRowLabel` takes the _leading_ string children of a sidebar row as the label it truncates and weight-animates, and renders the rest after it, so `shared/sidebar-first-steps.tsx` writes the visible label first and the `sr-only` done/todo span second. Put the span first and the label is empty, which drops the row into the untruncated fallback branch. The svg sizing rule (`[&_svg]:size-[var(--icon-size)]`) is on `SidebarMenuAction` and `SidebarGroupAction`, not on `SidebarMenuButton`. The Fluid Functionalism button takes no `tooltip`, so `shared/sidebar-brand.tsx › SidebarBrand` puts its greeting handlers on `SidebarMenuItem`, where the whole row answers the pointer rather than the glyph alone. -- `apps/web/src/pages/budget/ui/transaction-row.tsx › TransactionRow`, `bank/bank-list.tsx › BankList` — `packages/ui`'s Item primitives fight their callers twice: `ItemTitle`'s own `flex` beats its `line-clamp-1`, so truncation is re-stated on an inner block box (`block w-full truncate`); and `ItemGroup` renders `div[role=list]`, which cannot legally hold `
  • ` rows, so the bank list is a real `
      `. +- `apps/web/src/features/transaction-list/ui/transaction-row.tsx › TransactionRow`, `bank/bank-list.tsx › BankList` — `packages/ui`'s Item primitives fight their callers twice: `ItemTitle`'s own `flex` beats its `line-clamp-1`, so truncation is re-stated on an inner block box (`block w-full truncate`); and `ItemGroup` renders `div[role=list]`, which cannot legally hold `
    • ` rows, so the bank list is a real `
        `. - `apps/web/src/pages/login/ui/auth-form-field.tsx › AuthFormField` — `InputGroup` is rendered unconditionally and only the addon is conditional: swapping the group in and out remounts the input and drops focus mid-typing. - `apps/web/src/app/shell/sidebar-first-steps.tsx › SidebarGroupLabel` wrapper, `ROW_TRANSITION_CLASS` — the group label owns the icon rail's `opacity-0` collapse rule and must not be the animated element, because motion writes opacity as an inline style that no class can outrank; the wrapping `motion.div` is animated instead. The row transition class names every transitioned property explicitly so class merging keeps the button's own collapse transition rather than replacing it. - `apps/web/src/app/shell/sidebar-first-step-icon.tsx › HIDDEN`, `VISIBLE` — motion resolves a `none` filter target back to `blur(0px)` and re-applies it on every render, leaving a compositing layer that the sidebar's width transition then fails to repaint. Elements that stay mounted animate scale, opacity and translate only; `filter` is for subtrees that unmount. @@ -27,8 +27,8 @@ Durable facts that the code cannot carry in a name. Each bullet names the module - `apps/web/src/pages/assistant/ui/assistant-chat.tsx › AssistantChat` — the page is `h-[calc(100svh-4rem)]` (the shell header is `h-16`) so the transcript scrolls inside itself and the composer holds still; the composer is `InputMessage` from `@freenary/ui`, which caps its own question box. Settled message rows get constant props so the `memo` on `AssistantMessage` holds and a streamed chunk never re-renders a settled answer's chart. - `apps/web/src/pages/assistant/ui/assistant-ui-library.tsx › components`, `budget/recurring-insights.tsx › insightLine` — both shapes are load-bearing typing. The `satisfies Record` means a component added in `packages/api` cannot reach the prompt without a renderer declared here; the chain of `return`s typechecks its trailing `insight.trend` only once every other variant has returned, so a new insight kind becomes a compile error. A `default` branch or a lookup table destroys that. - `apps/web/src/pages/budget/ui/category-filter-menu.tsx › items`, `handleChange` — the Recurring filter unions a picked group into its categories (`categoriesUnionedWithGroups`), so those category rows leave the list while the group is picked and its chip stands for all of them. This menu takes no direction filtering, unlike `SpendingCategoryPicker`: a filter carries no sign, and a reader filtering on `Salary & wages` wants the credits it names. The combobox hands back the whole selection, so the one changed value is recovered by diffing it against the current filter, which keeps `toggleGroup` and `toggleCategory` the single source of what a pick does. The chips box carries its own width floor, wide enough for the placeholder beside the icon, the clear control and the chevron; chips grow it from there. -- `apps/web/src/pages/budget/ui/transaction-rows.tsx › loadMoreCheck` — paging is blocked while the page is stale, because a placeholder page's `hasMore` belongs to the previous view. The empty state is wrapped in `StaleRegion` for the same reason: an unmarked "no transactions" reads as the answer to the control just pressed. -- `apps/web/src/pages/budget/ui/stale-region.tsx › StaleRegion` — the fade is delayed 100 ms so a fast answer never flickers, and the return to full opacity is immediate. The region announces nothing on purpose: several regions go stale on one period change, so the page renders a single status line for all of them. +- `apps/web/src/features/transaction-list/ui/transaction-rows.tsx › loadMoreCheck` — paging is blocked while the page is stale, because a placeholder page's `hasMore` belongs to the previous view. The empty state is wrapped in `StaleRegion` for the same reason: an unmarked "no transactions" reads as the answer to the control just pressed. +- `apps/web/src/shared/ui/stale-region.tsx › StaleRegion` — the fade is delayed 100 ms so a fast answer never flickers, and the return to full opacity is immediate. The region announces nothing on purpose: several regions go stale on one period change, so the page renders a single status line for all of them. - `apps/web/src/pages/budget/ui/recurring-table.tsx › WIDEST_COLUMN`, `budget/recurring-kpi-strip.tsx › STRIP_LAYOUT` — the app shell caps content at `max-w-5xl`, so the `@container/budget` content box tops out just under 62rem: a 64rem breakpoint would never fire and six cells across is unreachable. `CELL_HEIGHT` matches a loaded cell so the strip does not resize when data lands. - `apps/web/src/pages/settings/ui/security-two-factor-dialog.tsx › SecurityTwoFactorDialog` — the backup codes are shown exactly once, so `onOpenChange` refuses to close while the codes stage is on: no backdrop click, no Escape. `showCloseButton` follows the same flag, because a ✕ that the root then refuses reads as a broken control. The `scan` stage keeps a white plate behind the QR code in every colour scheme, because the specification needs a light quiet zone. - Confirmation dialogs in `apps/web/src/pages/settings/ui/` and `features/bank-connection/ui/disconnect-bank-dialog.tsx › DisconnectBankDialog` are deliberately left open on confirm: a success unmounts the row or the action, and a failure keeps the retry available. @@ -36,7 +36,7 @@ Durable facts that the code cannot carry in a name. Each bullet names the module - `apps/web/src/pages/settings/ui/security-passkey-row.tsx › SecurityPasskeyRow` — a passkey registered by another client can arrive with a blank name, so the stored name and the displayed fallback stay separate values: prefilling the rename dialog with the translated "unnamed" label would save that label as the real name. - `apps/web/src/shared/lib/ff-icons.ts › ffIcons`, `lib/remix-icon.tsx › remixIcon`, `budget/category-menu-icon.tsx › categoryMenuIcon` — the Fluid Functionalism primitives draw their own glyphs (chevrons, checks, the search magnifier, the sidebar toggle) through named icon slots that default to Lucide, so this app overrides the shared slots with Remix Icon and lets the names it leaves out keep their default. No shared slot takes a Kubai icon. Each adapter caches per icon, because a slot given a fresh closure on every render remounts its icon. - `apps/web/src/shared/lib/kubai-icon.tsx › kubaiIcon` — adapts a coloured Kubai icon to an `IconComponent` slot. It caches per icon and per colour for the same remount reason, and it drops the slot's `strokeWidth`, because a Kubai icon is a layered fill and a stroke width set on its root `` would cascade into every outline. The colour is a `ChartColorVar`, so a Kubai icon always takes a theme token. -- `apps/web/src/pages/budget/ui/filter-token-search.tsx › FilterTokenSearch`, `model/use-filter-token-search.ts` — the Transactions search is a hand-built combobox rather than `@freenary/ui`'s `Combobox`, because one input carries three things in turn: free text, then a field, an operator and values, and the primitive owns its input value. The input keeps focus throughout and moves `aria-activedescendant` over the rows, and the popup cancels `mousedown` so a pick never blurs it. That is also why its `listbox` and `option` roles carry a `prefer-tag-over-role` exemption: `