Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<x>` and `@/shared/lib/<x>`.
- **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/<x>` and `@/shared/lib/<x>`.
- **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.

Expand Down
2 changes: 1 addition & 1 deletion apps/fumadocs/content/docs/next/reference/procedures.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion apps/fumadocs/content/docs/next/use/home.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 10 additions & 9 deletions apps/web/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand All @@ -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`.

Expand All @@ -82,15 +83,15 @@ Code is organized by slice, and within a slice by segment — what a module is _
<slice>/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.

Expand Down Expand Up @@ -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

Expand Down
6 changes: 2 additions & 4 deletions apps/web/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
6 changes: 2 additions & 4 deletions apps/web/messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/app/routes/_auth/accounts/$accountId.tsx
Original file line number Diff line number Diff line change
@@ -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,
});
1 change: 0 additions & 1 deletion apps/web/src/entities/budget/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
76 changes: 76 additions & 0 deletions apps/web/src/features/transaction-list/api/transactions-query.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>[]) => {
await Promise.allSettled(fetching);
};

export const prefetchTransactions = (
queryClient: QueryClient,
input: TransactionsInput
) => {
void warmWithoutRaising([
queryClient.infiniteQuery(transactionsQueryOptions(input)),
]);
};
19 changes: 19 additions & 0 deletions apps/web/src/features/transaction-list/index.ts
Original file line number Diff line number Diff line change
@@ -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";
57 changes: 57 additions & 0 deletions apps/web/src/features/transaction-list/model/list-search.ts
Original file line number Diff line number Diff line change
@@ -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<typeof transactionListSearchSchema>;

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<TransactionListSearch>;

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
),
});
Loading
Loading