Compare commits

..
26 Commits
Author SHA1 Message Date
ksv741 54deae57b3 chore(release): 1.0.0
Frontend image / publish (push) Successful in 7m39s
Frontend verify / commits (pull_request) Successful in 39s
Frontend verify / security (pull_request) Successful in 12s
Frontend verify / verify (pull_request) Successful in 6m39s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Successful in 7m34s
2026-10-07 18:14:21 +03:00
ksv741 74f8a72ed5 perf(tbank-connection): poll screen sync every five seconds
Frontend verify / commits (pull_request) Successful in 48s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 6m57s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Successful in 8m21s
During active screen sync, status checks and observe reads run every 5s
instead of 1s; terminal state observes resources immediately.
OpenSpec change: WealthLedger/specs#21
2026-10-07 14:03:55 +03:00
ksv741 fcb938b5a2 chore: remove unused exports
Frontend verify / commits (pull_request) Successful in 42s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Successful in 7m16s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 14s
Frontend verify / verify (push) Failing after 6m28s
2026-10-07 09:10:03 +03:00
ksv741 f873db7b02 fix(deps): resolve npm audit advisories
Frontend verify / commits (pull_request) Successful in 36s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Failing after 32m34s
2026-10-07 07:27:16 +03:00
ksv741 0ea8d7b62d feat: default journal period to last 7 days
Frontend verify / commits (pull_request) Successful in 50s
Frontend verify / security (pull_request) Failing after 13s
Frontend verify / verify (pull_request) Skipped
2026-10-06 22:05:41 +03:00
ksv741 c25f6bd85b refactor: move PortfolioStructure to widgets layer 2026-10-06 22:05:24 +03:00
ksv741 5b30bb802a feat: add quiet reading with rare confirmations
Part of the quiet-user-feedback change; docs and specs land in their own repositories.
2026-10-06 07:01:58 +03:00
ksv741 c096fc70ef fix(deps): remove npm dependency on Nexus
Frontend verify / commits (pull_request) Successful in 42s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Failing after 36m0s
Nexus hosts release Docker images, not npm dependencies.

Omit registry URLs using npm's native lockfile option.

Versions and integrity hashes remain unchanged.
2026-10-03 22:09:33 +03:00
ksv741 69997075e5 refactor!: simplify query and session ownership
Frontend verify / commits (pull_request) Failing after 27s
Frontend verify / security (pull_request) Skipped
Frontend verify / verify (pull_request) Skipped
Use Query cancellation and native mutation results instead of HTTP
registries, access epochs and copied scope state.
Keep local auth ordering and mutation identity fences.
Allow staged generated files to remain excluded from ESLint.

Backend contract: WealthLedger/backend@d698ed9

BREAKING CHANGE: drop investment stateVersion/progress/resources
from the client contract and use data/metadata readiness instead of
rewritten query status. Cross-tab auth may require re-login.
2026-10-03 11:45:04 +03:00
ksv741 6a89b08ee5 chore: guard presentation data boundaries
Frontend verify / commits (pull_request) Successful in 31s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 10m18s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 59s
Frontend verify / verify (push) Successful in 10m53s
2026-10-01 20:53:23 +03:00
ksv741 2844b22dba refactor: simplify finance view models 2026-10-01 20:53:13 +03:00
ksv741 ff67a768ad refactor: unify bank form types and actions 2026-10-01 20:53:00 +03:00
ksv741 10baefda79 fix(deps): resolve npm audit advisories
Refresh vulnerable transitive patch versions.
Scope patched undici and basic-ftp to release-it.
Retain the low audit threshold and release tooling version.
2026-10-01 20:51:32 +03:00
ksv741 f5f4447d60 fix: sort stock and bond events by payment date 2026-09-30 07:40:43 +03:00
ksv741 0bc8bc36b7 refactor: share source sync status presentation 2026-09-30 07:40:23 +03:00
ksv741 f9f0f6921e refactor: clarify frontend module ownership
Frontend verify / commits (pull_request) Failing after 1m19s
Frontend verify / security (pull_request) Skipped
Frontend verify / verify (pull_request) Skipped
2026-09-29 07:07:36 +03:00
ksv741 7c28a423dd refactor: tighten types and reuse instrument UI
Frontend verify / commits (pull_request) Successful in 24s
Frontend verify / security (pull_request) Failing after 9s
Frontend verify / verify (pull_request) Skipped
2026-09-29 06:15:31 +03:00
ksv741 b404c1802a refactor: simplify auth and instrument pages
Frontend verify / commits (pull_request) Successful in 24s
Frontend verify / security (pull_request) Failing after 9s
Frontend verify / verify (pull_request) Skipped
2026-09-29 05:45:08 +03:00
ksv741 32a77860c8 refactor: share instrument search presentation
Frontend verify / commits (pull_request) Successful in 24s
Frontend verify / security (pull_request) Failing after 9s
Frontend verify / verify (pull_request) Skipped
2026-09-29 05:14:28 +03:00
ksv741 0865b35f93 refactor: clarify frontend model ownership
Frontend verify / commits (pull_request) Successful in 27s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 8m20s
2026-09-28 21:44:53 +03:00
ksv741 7e976c330c refactor: simplify errors and focus tests
Frontend verify / commits (pull_request) Successful in 30s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Successful in 8m25s
2026-09-28 21:25:28 +03:00
ksv741 320ea5922a test: remove redundant frontend checks
Frontend verify / commits (pull_request) Successful in 28s
Frontend verify / security (pull_request) Successful in 12s
Frontend verify / verify (pull_request) Successful in 8m31s
2026-09-28 21:04:27 +03:00
ksv741 64fe902192 refactor: encapsulate instrument chart data
Frontend verify / commits (pull_request) Successful in 27s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 8m45s
2026-09-28 20:46:06 +03:00
ksv741 fb3a5a6eaa refactor: remove redundant page view models
Frontend verify / commits (pull_request) Successful in 29s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 8m35s
2026-09-28 20:34:33 +03:00
ksv741 94adbafa34 refactor: simplify calendar and form state
Frontend verify / commits (pull_request) Successful in 29s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Successful in 8m43s
2026-09-28 20:23:02 +03:00
ksv741 a7537da344 refactor: simplify frontend state and mutation boundaries
Frontend verify / commits (pull_request) Successful in 31s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Successful in 9m1s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 9m59s
2026-09-28 20:01:37 +03:00
267 changed files with 6554 additions and 8025 deletions
+8 -6
View File
@@ -8,7 +8,7 @@ Vitest — отдельную JSDOM конфигурацию. Точные versi
[package.json](../package.json), реализация — source of truth. [package.json](../package.json), реализация — source of truth.
```text ```text
app → pages → features → entities → shared app → pages → widgets → features → entities → shared
page container → page VM → entity/feature query → shared/api → backend /api/v1 page container → page VM → entity/feature query → shared/api → backend /api/v1
↓ ↓
props → state/section views props → state/section views
@@ -30,13 +30,15 @@ contract; frontend не читает его checkout для генерации
| --- | --- | | --- | --- |
| Bootstrap, глобальные providers, routes | `app` | | Bootstrap, глобальные providers, routes | `app` |
| Композиция маршрута и поведение, нужное только этому экрану | `pages/<page>` | | Композиция маршрута и поведение, нужное только этому экрану | `pages/<page>` |
| Крупная переиспользуемая композиция экрана | `widgets/<widget>` |
| Самостоятельная пользовательская возможность с собственным смыслом | `features/<capability>` | | Самостоятельная пользовательская возможность с собственным смыслом | `features/<capability>` |
| Представление/поведение бизнес-сущности | `entities/<entity>` | | Представление/поведение бизнес-сущности | `entities/<entity>` |
| Переиспользуемый код без WealthLedger domain meaning | `shared` | | Переиспользуемый код без WealthLedger domain meaning | `shared` |
Локальный FSD profile: `app → pages → features → entities → shared`, без `widgets`. Локальный FSD profile: `app → pages → widgets → features → entities → shared`.
Крупный уникальный блок остаётся в своей page. Form, mutation или click handler Widget собирает несколько нижних слоёв в самостоятельный UI-блок; компонент для
сами по себе не требуют feature. Два callers не делают domain helper generic shared. одной page остаётся у page. Form, mutation или click handler сами по себе не
требуют feature. Два callers не делают domain helper generic shared.
Slice именуется по ответственности, а не `helpers`, `hooks` или `types`. Slice именуется по ответственности, а не `helpers`, `hooks` или `types`.
**Безопасный путь:** при сомнении оставь код у ближайшего допустимого owner. **Безопасный путь:** при сомнении оставь код у ближайшего допустимого owner.
@@ -47,12 +49,12 @@ Slice именуется по ответственности, а не `helpers`,
Cross-slice consumer использует `@/` и `index.ts` владельца, например Cross-slice consumer использует `@/` и `index.ts` владельца, например
`@/entities/portfolio`. Alias Vite и TypeScript согласован. Другой slice не `@/entities/portfolio`. Alias Vite и TypeScript согласован. Другой slice не
импортирует внутренний файл напрямую. Slices `pages`, `features`, `entities` не зависят от соседнего slice своего слоя; импортирует внутренний файл напрямую. Slices `pages`, `widgets`, `features`, `entities` не зависят от соседнего slice своего слоя;
композиция принадлежит вышележащему owner. Existing меж-slice исключения композиция принадлежит вышележащему owner. Existing меж-slice исключения
не являются разрешением добавлять новые. не являются разрешением добавлять новые.
Направление зависимостей, public imports и запрет соседних slices в Направление зависимостей, public imports и запрет соседних slices в
`pages`, `features`, `entities` проверяет `npm run deps`. Внутренние imports `pages`, `widgets`, `features`, `entities` проверяет `npm run deps`. Внутренние imports
одного slice и связи shared segments этот запрет не ограничивает. одного slice и связи shared segments этот запрет не ограничивает.
Knip проверяет использование exports, а не смысл placement. Knip проверяет использование exports, а не смысл placement.
+13
View File
@@ -39,6 +39,10 @@ Authorization. Auth endpoints исключены из Bearer/retry middleware. R
координируется single-flight; после `401` с `UNAUTHORIZED` возможен один retry. координируется single-flight; после `401` с `UNAUTHORIZED` возможен один retry.
Не добавляй собственный refresh/retry loop в caller. Не добавляй собственный refresh/retry loop в caller.
Cookie-writing requests упорядочены только в текущей вкладке. Cross-tab auth
synchronization не требуется; допустим reload/re-login. Web Locks и отдельные
token stores не добавляются.
Protected queries используют existing session-aware boundary; logout очищает Protected queries используют existing session-aware boundary; logout очищает
session-scoped cache. Access token хранится только в памяти, refresh передаётся session-scoped cache. Access token хранится только в памяти, refresh передаётся
httpOnly cookie с `credentials: include`. Не сохраняй tokens в localStorage, httpOnly cookie с `credentials: include`. Не сохраняй tokens в localStorage,
@@ -50,6 +54,15 @@ GET subtree; ошибка mutation не меняет generation/cache. Это о
от обычного фонового refetch. Даже повторное сохранение того же token начинает от обычного фонового refetch. Даже повторное сохранение того же token начинает
новую generation; поздний ответ прежней generation не попадает в новый кэш. новую generation; поздний ответ прежней generation не попадает в новый кэш.
Отмена/reset принадлежат TanStack Query и generated queryFn с AbortSignal,
без HTTP request registry, URL-wide latest-read counters и access epochs.
Успешный HTTP с `data: null` остаётся query success; readiness выбирается по
data/metadata, без подмены query status и отдельного transport state.
Status синхронизации содержит только состояние работы. Resources читаются
через `sync=observe` во время работы и после завершения, без hash comparison.
Согласованность income entries/groups обеспечивает backend; frontend не
дублирует её runtime validator.
Mutations используют существующий generated client и query-key owner; Mutations используют существующий generated client и query-key owner;
после успеха обновляй/invalidируй только связанные данные. Optimistic update после успеха обновляй/invalidируй только связанные данные. Optimistic update
нужен конкретному UX и обязан иметь rollback/error behavior. нужен конкретному UX и обязан иметь rollback/error behavior.
+1 -1
View File
@@ -47,7 +47,7 @@ throwing route, безопасная ссылка и reset после navigation
Примеры: [AuthForm test](../../src/features/auth/ui/AuthForm/AuthForm.test.tsx), Примеры: [AuthForm test](../../src/features/auth/ui/AuthForm/AuthForm.test.tsx),
[client test](../../src/shared/api/client.test.ts), [client test](../../src/shared/api/client.test.ts),
[usePortfolio test](../../src/entities/portfolio/api/usePortfolio.test.tsx). [resource lifecycle test](../../src/shared/api/resource-query.test.tsx).
Не копируй fixtures/сценарии целиком, если задача затрагивает один исход. Не копируй fixtures/сценарии целиком, если задача затрагивает один исход.
## Команды ## Команды
+8
View File
@@ -17,6 +17,10 @@
CSF сохраняет default meta и прямые story exports; configuration exceptions CSF сохраняет default meta и прямые story exports; configuration exceptions
заданы в [ESLint](../../eslint.config.mjs). Не распространяй их на application code. заданы в [ESLint](../../eslint.config.mjs). Не распространяй их на application code.
Тип значений формы выводится из её schema, когда input/output совпадают.
Общие props определяются у одного owner; DTO и presentation model сохраняют
собственные типы при смысловом отличии. Type re-export сохраняет public imports.
## Читаемость: ESLint и review ## Читаемость: ESLint и review
ESLint проверяет ограничение 80 символов (tab = 2), пустые строки между ESLint проверяет ограничение 80 символов (tab = 2), пустые строки между
@@ -33,6 +37,10 @@ CSS и Markdown также держи в 80 колонках там, где эт
Review проверяет то, чего синтаксис не доказывает: Review проверяет то, чего синтаксис не доказывает:
Обоснованное config exception сопровождается причиной и явным ожиданием в
existing lint tests, включая разрешённый и запрещённый примеры. Это позволяет
настраивать профиль без случайного ослабления остальных правил.
- Пустая строка отделяет смысловые шаги: inputs, вычисления, действие, результат. - Пустая строка отделяет смысловые шаги: inputs, вычисления, действие, результат.
- Имена derived values описывают значение, например `projectedAmount`. - Имена derived values описывают значение, например `projectedAmount`.
- Тернарник остаётся коротким выбором двух значений. Mapping, несколько веток - Тернарник остаётся коротким выбором двух значений. Mapping, несколько веток
+11 -5
View File
@@ -63,6 +63,12 @@ Presentation VM для form-context/UI-only state может использов
в каталоге: её граф зависимостей не достигает application data/API. в каталоге: её граф зависимостей не достигает application data/API.
`useTranslation` и декларативный вывод коллекции допустимы во view. `useTranslation` и декларативный вывод коллекции допустимы во view.
ESLint ограничивает прямые data/auth/router hook imports в `*.view.tsx`;
type imports, error helpers, router components и form context разрешены.
Транзитивный fetching через local presentation hook проверяется review;
Storybook дополнительно проверяет reachable graph. Новое исключение должно
сохранять presentation-only responsibility.
JSX props получают ссылки на функции, включая `onClick`, render props, JSX props получают ссылки на функции, включая `onClick`, render props,
`dateParser` и callbacks внутри объектов props. Для React Hook Form предпочитай `dateParser` и callbacks внутри объектов props. Для React Hook Form предпочитай
`useController` в VM вместо inline `Controller.render`. Значение prop с условием `useController` в VM вместо inline `Controller.render`. Значение prop с условием
@@ -76,9 +82,9 @@ JSX props получают ссылки на функции, включая `onC
Closure над состоянием, mutations и refs является действием VM, а не shared helper. Closure над состоянием, mutations и refs является действием VM, а не shared helper.
VM собирает связные предметные hooks, когда внутри смешиваются независимые VM собирает связные предметные hooks, когда внутри смешиваются независимые
сценарии. Например, `useOpeningDateField` владеет draft/date parsing и событиями сценарии. Например, `useRefundPurchase` владеет выбором исходной покупки,
даты, `usePaymentSystemField` — выбором платёжной системы. Каждый такой hook её загрузкой и пагинацией. Общий адаптер `bankDateField` обслуживает поля даты;
возвращает данные и действия своей ответственности, VM объединяет их для view. простое подключение одного controller остаётся в VM формы.
Не группируй несвязанные функции только по префиксу `on*` и не создавай Не группируй несвязанные функции только по префиксу `on*` и не создавай
passthrough hook над одним вызовом ради формального дробления. passthrough hook над одним вызовом ради формального дробления.
@@ -110,8 +116,8 @@ Render-helper замени самостоятельным presentation component
При переносе обновляй imports, ссылки документации и проверки, зависящие от пути. При переносе обновляй imports, ссылки документации и проверки, зависящие от пути.
Пример: [BankCardForm](../../src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx) Пример: [BankCardForm](../../src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx)
лежит рядом со своим view, тестом и story; общий `BankForms.module.css` лежит рядом со своим view, тестом и story. Формы используют Mantine
остаётся в `bank-card/ui`. `Fieldset variant="unstyled"` для групповой блокировки полей без локального CSS.
## Формы ## Формы
+67
View File
@@ -1,5 +1,72 @@
# Changelog # Changelog
## [1.0.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v0.4.0...v1.0.0) (2026-10-07)
### ⚠ BREAKING CHANGES
* drop investment stateVersion/progress/resources
from the client contract and use data/metadata readiness instead of
rewritten query status. Cross-tab auth may require re-login.
### Features
* add quiet reading with rare confirmations ([5b30bb8](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/5b30bb802a4ec43c036066f9a7e178f37e747a0b))
* default journal period to last 7 days ([0ea8d7b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/0ea8d7b62d50a4b7c92e9a9934aead6246ac97f9))
### Bug Fixes
* **deps:** remove npm dependency on Nexus ([c096fc7](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c096fc70ef442474c9b7766fdf5772b645166b92))
* **deps:** resolve npm audit advisories ([f873db7](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f873db7b027b6dee0c7382b4bd3df5198c7a4198))
* **deps:** resolve npm audit advisories ([10baefd](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/10baefda790e7fb491c893724ddc4da7992103f0))
* resolve frontend audit findings ([f6db28d](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f6db28defa51bd9d8fe932ceae4c3c720aba7eb3))
* sort stock and bond events by payment date ([f5f4447](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f5f4447d60661aa92c80b50a16e8cb194dfa161f))
### Performance
* **tbank-connection:** poll screen sync every five seconds ([74f8a72](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/74f8a72ed55af2348fa3668f57312f4fdfc1e8a8)), references [WealthLedger/specs#21](https://git.personal.ksv741.ru/WealthLedger/specs/issues/21)
### Refactoring
* address frontend review feedback ([a4046db](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/a4046dbe87bd7dc949df32da3b6aaf174e6f5e8f))
* clarify frontend model ownership ([0865b35](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/0865b35f93865b4052181d918cee80c995312930))
* clarify frontend module ownership ([f9f0f69](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f9f0f6921e86f6c81510373c680b136e4580f386))
* encapsulate instrument chart data ([64fe902](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/64fe902192b60a72440635f5610ebe02cf8543f8))
* enforce enum machine codes [#18](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/18) ([5b69003](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/5b6900351612b6ba34c694fc99612733e7901c79))
* enforce frontend view model boundaries ([ca0ad4f](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/ca0ad4f824a47fa47e32d4717e27b15c8a985f7d))
* move PortfolioStructure to widgets layer ([c25f6bd](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c25f6bd85b88c4c7acb2f5ae6fc013aec77005b2))
* remove redundant page view models ([fb3a5a6](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/fb3a5a6eaa0223a15ea2c9f585a06459371f0a25))
* share instrument search presentation ([32a7786](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/32a77860c8eaea4a59a26e55b2d66976fd8f9484))
* share source sync status presentation ([0bc8bc3](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/0bc8bc36b7263b7f0120a08df840b290b40d8116))
* simplify auth and instrument pages ([b404c18](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/b404c1802a74f5f369173fdfcb086a3d5760e35a))
* simplify calendar and form state ([94adbaf](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/94adbafa344b23d7f95c31db975d99d990bfcb8b))
* simplify errors and focus tests ([7e976c3](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/7e976c330c54715018eda4bff0764c5d4de9c8b0))
* simplify finance view models ([2844b22](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/2844b22dbace41a02c1b5a07b3801e84c66b3528))
* simplify frontend state and mutation boundaries ([a7537da](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/a7537da34452ab1a15138ab0c1cd93d53d06c517))
* simplify query and session ownership ([6999707](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/69997075e569da3edd43e242ef36f09311262412))
* simplify resource queries and layout tests ([c1b0873](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c1b08735e3a45d565c3836ae37ad76186bf48f26))
* tighten types and reuse instrument UI ([7c28a42](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/7c28a423dd8a3cc4a5bdcb49b8de7018979df413))
* unify bank form types and actions ([ff67a76](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/ff67a768addc0c4ba55247f5e2d9d5d5d5aec734))
### CI
* reduce duplicate actions and serialize checks ([95fdc87](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/95fdc8759e20f9baa4ac3d00e703752c79010e01))
### Tests
* remove redundant frontend checks ([320ea59](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/320ea5922a70ab81ce4f05c3c90eb7b6a664fa39))
### Styles
* pin ternary operator formatting [#18](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/18) ([12c3b72](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/12c3b72bfb32dff5f1ffdb36519c90f7ea852488))
### Maintenance
* add typed ESLint checks [#18](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/18) ([5763010](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/57630102e4bcc6b555dd546ba373a14a88256eed))
* finish frontend ESLint migration [#18](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/18) ([72d7835](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/72d7835227340e349d99f57db134a635f276f6e9))
* guard presentation data boundaries ([6a89b08](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/6a89b08ee571562fea349801169c40f1f993e9b9))
* guard unsafe DOM HTML with ESLint [#18](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/18) ([061ec70](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/061ec708c651029cd7d61d22319d11e7ac9f074e))
* remove unused exports ([fcb938b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/fcb938b5a29fc82f00bbaa8b2ee776ca57dad010))
## [0.4.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v0.3.0...v0.4.0) (2026-09-26) ## [0.4.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v0.3.0...v0.4.0) (2026-09-26)
### Features ### Features
+20 -5
View File
@@ -1,5 +1,14 @@
module.exports = { module.exports = {
forbidden: [ forbidden: [
{
from: {
path: "^(src|packages/[^/]+/src)/",
pathNot: "[.](test|stories|fixtures)[.]|^src/test/",
},
name: "production-must-not-depend-on-test-code",
severity: "error",
to: { path: "[.](test|stories|fixtures)[.]|^src/test/" },
},
{ {
from: {}, from: {},
name: "no-unresolved", name: "no-unresolved",
@@ -20,17 +29,17 @@ module.exports = {
}, },
{ {
from: { from: {
path: "^src/(app|pages|features|entities|shared)/([^/]+)/", path: "^src/(app|pages|widgets|features|entities|shared)/([^/]+)/",
}, },
name: "slices-must-use-public-api", name: "slices-must-use-public-api",
severity: "error", severity: "error",
to: { to: {
path: String.raw`^src/(app|pages|features|entities|shared)/([^/]+)/(?!index\.ts$)`, path: String.raw`^src/(app|pages|widgets|features|entities|shared)/([^/]+)/(?!index\.ts$)`,
pathNot: "^src/$1/$2/", pathNot: "^src/$1/$2/",
}, },
}, },
{ {
from: { path: "^src/(pages|features|entities)/([^/]+)/" }, from: { path: "^src/(pages|widgets|features|entities)/([^/]+)/" },
name: "no-same-layer-cross-slice-imports", name: "no-same-layer-cross-slice-imports",
severity: "error", severity: "error",
to: { path: "^src/$1/", pathNot: "^src/$1/$2/" }, to: { path: "^src/$1/", pathNot: "^src/$1/$2/" },
@@ -39,18 +48,24 @@ module.exports = {
from: { path: "^src/shared/" }, from: { path: "^src/shared/" },
name: "shared-must-not-depend-on-layers", name: "shared-must-not-depend-on-layers",
severity: "error", severity: "error",
to: { path: "^src/(entities|features|pages|app)/" }, to: { path: "^src/(entities|features|widgets|pages|app)/" },
}, },
{ {
from: { path: "^src/entities/" }, from: { path: "^src/entities/" },
name: "entities-must-not-depend-on-higher-layers", name: "entities-must-not-depend-on-higher-layers",
severity: "error", severity: "error",
to: { path: "^src/(features|pages|app)/" }, to: { path: "^src/(features|widgets|pages|app)/" },
}, },
{ {
from: { path: "^src/features/" }, from: { path: "^src/features/" },
name: "features-must-not-depend-on-higher-layers", name: "features-must-not-depend-on-higher-layers",
severity: "error", severity: "error",
to: { path: "^src/(widgets|pages|app)/" },
},
{
from: { path: "^src/widgets/" },
name: "widgets-must-not-depend-on-higher-layers",
severity: "error",
to: { path: "^src/(pages|app)/" }, to: { path: "^src/(pages|app)/" },
}, },
{ {
+41
View File
@@ -153,11 +153,13 @@ export default defineConfig(
}, },
}, },
rules: { rules: {
"@typescript-eslint/consistent-type-imports": "error",
// React ignores event handler return values; floating promises are still checked in bodies. // React ignores event handler return values; floating promises are still checked in bodies.
"@typescript-eslint/no-misused-promises": [ "@typescript-eslint/no-misused-promises": [
"error", "error",
{ checksVoidReturn: { attributes: false } }, { checksVoidReturn: { attributes: false } },
], ],
"@typescript-eslint/no-non-null-assertion": "error",
"@typescript-eslint/switch-exhaustiveness-check": "error", "@typescript-eslint/switch-exhaustiveness-check": "error",
}, },
}, },
@@ -183,6 +185,44 @@ export default defineConfig(
files: ["src/**/*.tsx", "packages/*/src/**/*.tsx"], files: ["src/**/*.tsx", "packages/*/src/**/*.tsx"],
ignores: ["**/*.test.tsx", "**/*.stories.tsx", "src/test/**"], ignores: ["**/*.test.tsx", "**/*.stories.tsx", "src/test/**"],
}, },
{
files: ["src/**/*.view.tsx"],
rules: {
"no-restricted-imports": ["error", {
paths: [
{
allowTypeImports: true,
message: "Query lifecycle belongs to the data VM.",
name: "@tanstack/react-query",
},
{
allowTypeImports: true,
message: "Transport belongs to shared/api and the data VM.",
name: "openapi-fetch",
},
{
allowTypeImports: true,
message: "Transport belongs to shared/api and the data VM.",
name: "openapi-react-query",
},
],
patterns: [
{
allowTypeImports: true,
group: ["react-router", "@/entities/*", "@/features/*"],
importNamePattern: "^use",
message: "Pass route, auth and entity data from the data VM.",
},
{
allowTypeImports: true,
group: ["@/shared/api", "@/shared/api/*"],
importNamePattern: "^use|^api(?:Query)?Client$",
message: "Fetching belongs to the data VM; error helpers are allowed.",
},
],
}],
},
},
{ {
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"], files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"], ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
@@ -197,6 +237,7 @@ export default defineConfig(
ignoreTypeIndexes: true, ignoreTypeIndexes: true,
}, },
], ],
"@typescript-eslint/no-unnecessary-condition": ["error", { allowConstantLoopConditions: true }],
}, },
}, },
{ {
+112 -1305
View File
File diff suppressed because it is too large Load Diff
+10 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "wealth-ledger-frontend", "name": "wealth-ledger-frontend",
"private": true, "private": true,
"version": "0.4.0", "version": "1.0.0",
"type": "module", "type": "module",
"engines": { "engines": {
"node": "24.21.0" "node": "24.21.0"
@@ -23,7 +23,7 @@
"openapi:check": "node scripts/check-openapi.mjs", "openapi:check": "node scripts/check-openapi.mjs",
"knip": "npm run build:packages && knip", "knip": "npm run build:packages && knip",
"deps": "npm run build:packages && depcruise src packages --config dependency-cruiser.cjs --output-type err-long", "deps": "npm run build:packages && depcruise src packages --config dependency-cruiser.cjs --output-type err-long",
"verify": "npm run build:packages && npm run openapi:check && tsc -b --noEmit && npm run lint && npm run test:lint && vitest run --coverage && knip && depcruise src packages --config dependency-cruiser.cjs --output-type err-long && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long && tsc -b && vite build && storybook build --output-dir storybook-static --disable-telemetry", "verify": "npm run build:packages && npm run openapi:check && tsc -b --noEmit && eslint . --max-warnings=0 && npm run test:lint && vitest run --coverage && knip && depcruise src packages --config dependency-cruiser.cjs --output-type err-long && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long && tsc -b && vite build && storybook build --output-dir storybook-static --disable-telemetry",
"prepare": "husky", "prepare": "husky",
"preview": "vite preview", "preview": "vite preview",
"deps:storybook": "npm run build:packages && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long", "deps:storybook": "npm run build:packages && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long",
@@ -34,7 +34,7 @@
"lint:fix": "npm run build:packages && eslint . --fix --max-warnings=0" "lint:fix": "npm run build:packages && eslint . --fix --max-warnings=0"
}, },
"lint-staged": { "lint-staged": {
"*.{js,mjs,cjs,ts,tsx}": "eslint --fix --max-warnings=0" "*.{js,mjs,cjs,ts,tsx}": "eslint --fix --max-warnings=0 --no-warn-ignored"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "5.9.1", "@hookform/resolvers": "5.9.1",
@@ -42,6 +42,7 @@
"@mantine/core": "9.6.1", "@mantine/core": "9.6.1",
"@mantine/dates": "9.6.1", "@mantine/dates": "9.6.1",
"@mantine/hooks": "9.6.1", "@mantine/hooks": "9.6.1",
"@mantine/notifications": "9.6.1",
"@tanstack/react-query": "5.102.8", "@tanstack/react-query": "5.102.8",
"@wealthledger/icons": "file:packages/icons", "@wealthledger/icons": "file:packages/icons",
"@wealthledger/tokens": "file:packages/tokens", "@wealthledger/tokens": "file:packages/tokens",
@@ -114,6 +115,12 @@
"vite-plugin-pwa": "1.3.0", "vite-plugin-pwa": "1.3.0",
"vitest": "5.0.0" "vitest": "5.0.0"
}, },
"overrides": {
"release-it": {
"basic-ftp": "6.2.1",
"undici": "7.29.1"
}
},
"allowScripts": { "allowScripts": {
"esbuild@0.28.2": true, "esbuild@0.28.2": true,
"fsevents@2.3.3": true "fsevents@2.3.3": true
+13 -1
View File
@@ -1,7 +1,7 @@
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { AddIcon, DeleteIcon, EditIcon } from "./index"; import { AddIcon, DeleteIcon, EditIcon, OperationIcon } from "./index";
describe("icon accessibility", () => { describe("icon accessibility", () => {
it.each([AddIcon, EditIcon, DeleteIcon])( it.each([AddIcon, EditIcon, DeleteIcon])(
@@ -34,4 +34,16 @@ describe("icon accessibility", () => {
expect(icon).toHaveAttribute("stroke", "currentColor"); expect(icon).toHaveAttribute("stroke", "currentColor");
}, },
); );
it("keeps operation icons decorative", () => {
render(
<button aria-label="Операция" type="button">
<OperationIcon color="orange" variant="home" />
</button>,
);
expect(screen.queryByRole("img")).not.toBeInTheDocument();
// eslint-disable-next-line testing-library/no-node-access -- Decorative SVG has no accessible role.
expect(screen.getByRole("button").querySelector("svg")).toHaveAttribute(
"aria-hidden", "true",
);
});
}); });
+1 -4
View File
@@ -1,15 +1,12 @@
{ {
"extends": "../../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"target": "ES2023",
"lib": [ "lib": [
"ES2023", "ES2023",
"DOM" "DOM"
], ],
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"declaration": true, "declaration": true,
"emitDeclarationOnly": true, "emitDeclarationOnly": true,
"rootDir": "src", "rootDir": "src",
@@ -0,0 +1,38 @@
import "@mantine/core/styles.css";
import "../styles.css";
import { MantineProvider } from "@mantine/core";
// eslint-disable-next-line testing-library/no-manual-cleanup -- Vitest globals are disabled, so RTL cannot register its automatic afterEach cleanup.
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, expect, it } from "vitest";
import { cssVariablesResolver, theme } from "../theme";
import { EntryCard } from "./EntryCard";
afterEach(cleanup);
it("shows the full wrapped title without horizontal overflow", () => {
const title = "Дивидендная выплата с очень длинным описанием, которое должно переноситься и оставаться доступным полностью";
render(
<MantineProvider cssVariablesResolver={cssVariablesResolver} theme={theme}>
<EntryCard
initial="Д"
metadata="29.09.2026"
title={title}
tone="positive"
value="123 456 789,90 ₽"
valueLabel="Дивиденды"
wrap
/>
</MantineProvider>,
);
const description = screen.getByText(title);
expect(description.clientHeight).toBeGreaterThan(0);
expect(description.scrollHeight)
.toBeLessThanOrEqual(description.clientHeight);
expect(document.documentElement.scrollWidth)
.toBeLessThanOrEqual(window.innerWidth);
});
@@ -93,3 +93,13 @@
transition: none; transition: none;
} }
} }
.row[data-wrap] p {
white-space: normal;
overflow-wrap: anywhere;
}
@media (min-width: 36em) {
.row[data-wrap] {
grid-template-columns: auto minmax(0, 1fr) minmax(0, 40%);
}
}
@@ -39,3 +39,5 @@ export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const LongContentAndFallback: Story = {}; export const LongContentAndFallback: Story = {};
export const WrappedOperation: Story = { args: { value: "−123 456 789 012 345 678,90 ₽", wrap: true } };
+10 -2
View File
@@ -18,6 +18,7 @@ type EntryCardProps = {
| "positive"; | "positive";
value: string; value: string;
valueLabel: string; valueLabel: string;
wrap?: boolean;
}; };
const EntryCard: FC<EntryCardProps> = (props) => { const EntryCard: FC<EntryCardProps> = (props) => {
@@ -27,11 +28,18 @@ const EntryCard: FC<EntryCardProps> = (props) => {
} }
: undefined; : undefined;
const titleLineClamp = props.wrap ? undefined : 1;
const valueContainerClass = props.actions ? classes.valueFooter : undefined; const valueContainerClass = props.actions ? classes.valueFooter : undefined;
const valueLabelClass = `${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`; const valueLabelClass = `${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`;
return ( return (
<Paper className={classes.row} style={surfaceStyle} withBorder> <Paper
className={classes.row}
data-wrap={props.wrap || undefined}
style={surfaceStyle}
withBorder
>
<Avatar <Avatar
alt="" alt=""
aria-hidden="true" aria-hidden="true"
@@ -43,7 +51,7 @@ const EntryCard: FC<EntryCardProps> = (props) => {
</Avatar> </Avatar>
<Stack className={classes.identity} gap={2}> <Stack className={classes.identity} gap={2}>
<Text fw={700} lineClamp={1}> <Text fw={700} lineClamp={titleLineClamp}>
{props.title} {props.title}
</Text> </Text>
+1 -4
View File
@@ -1,13 +1,10 @@
{ {
"extends": "../../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"target": "ES2023",
"lib": ["ES2023", "DOM"], "lib": ["ES2023", "DOM"],
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"strict": true,
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"declaration": true, "declaration": true,
"emitDeclarationOnly": true, "emitDeclarationOnly": true,
"rootDir": "src", "rootDir": "src",
-6
View File
@@ -6,12 +6,6 @@ const { version } = require("../package.json");
const git = (...arguments_) => const git = (...arguments_) =>
execFileSync("git", arguments_, { encoding: "utf8" }).trim(); // eslint-disable-line sonarjs/no-os-command-from-path -- Repository checks use the developer/CI Git installation; arguments never invoke a shell. execFileSync("git", arguments_, { encoding: "utf8" }).trim(); // eslint-disable-line sonarjs/no-os-command-from-path -- Repository checks use the developer/CI Git installation; arguments never invoke a shell.
assert.equal(
git("status", "--porcelain"),
"",
"Commit all changes before preparing a release",
);
const tag_ = git( const tag_ = git(
"tag", "tag",
"--merged", "--merged",
+55
View File
@@ -24,6 +24,21 @@ test("ESLint enforces the frontend profile and excludes generated artifacts", as
const eslint = new ESLint(); const eslint = new ESLint();
for (const [filePath, code, rule] of [ for (const [filePath, code, rule] of [
[
"src/shared/api/problem-details.ts",
"export function read(value: string) { return value?.trim(); }",
"@typescript-eslint/no-unnecessary-condition",
],
[
"src/shared/api/problem-details.ts",
"export function read(value?: string) { return value!.trim(); }",
"@typescript-eslint/no-non-null-assertion",
],
[
"src/shared/api/problem-details.ts",
"import { ReactNode } from \"react\"; export type Content = ReactNode;",
"@typescript-eslint/consistent-type-imports",
],
[ [
"src/shared/api/problem-details.ts", "src/shared/api/problem-details.ts",
"export function run() { Promise.resolve(); }", "export function run() { Promise.resolve(); }",
@@ -323,6 +338,46 @@ test("JSX spacing fixes preserve text interpolation and are stable", async () =>
assert.equal(again.errorCount, 0); assert.equal(again.errorCount, 0);
}); });
test("views accept presentation inputs but reject direct data hooks", async () => {
const eslint = new ESLint();
const filePath = "src/features/instrument-details/ui/InstrumentEvents.view.tsx";
for (const code of [
"import { useStock } from \"@/entities/stock\";",
"import { useAuth } from \"@/features/auth\";",
"import { useNavigate } from \"react-router\";",
"import { apiClient } from \"@/shared/api\";",
"import { useQuery } from \"@tanstack/react-query\";",
]) {
const [result] = await eslint.lintText(code, { filePath });
assert.equal(result.fatalErrorCount, 0);
assert.ok(result.messages.some(message =>
message.ruleId === "no-restricted-imports"));
}
for (const code of [
"import { Link } from \"react-router\";",
"import { apiErrorCode } from \"@/shared/api\";",
"import type { StockPosition } from \"@/entities/stock\";",
"import type { QueryKey } from \"@tanstack/react-query\";",
"import { useTranslation } from \"react-i18next\";",
"import { useFormContext } from \"react-hook-form\";",
]) {
const [result] = await eslint.lintText(code, { filePath });
assert.equal(result.fatalErrorCount, 0);
assert.ok(result.messages.every(message =>
message.ruleId !== "no-restricted-imports"));
}
const [vm] = await eslint.lintText(
"import { useStock } from \"@/entities/stock\";",
{ filePath: "src/pages/stock/ui/StockPage/StockPage.vm.ts" },
);
assert.ok(vm.messages.every(message =>
message.ruleId !== "no-restricted-imports"));
});
test("compact JSX and contract nulls need no local suppressions", async () => { test("compact JSX and contract nulls need no local suppressions", async () => {
const eslint = new ESLint({ fix: true }); const eslint = new ESLint({ fix: true });
const filePath = "packages/ui/src/EntryCard/EntryCard.tsx"; const filePath = "packages/ui/src/EntryCard/EntryCard.tsx";
+1
View File
@@ -0,0 +1 @@
export { useCurrentRoute } from "./useCurrentRoute";
+23
View File
@@ -0,0 +1,23 @@
import { useMatch } from "react-router";
function useCurrentRoute() {
const account = useMatch("/accounts/:accountId/:section?");
const section = (["events", "income", "operations"] as const)
.find(value => value === account?.params.section?.toLowerCase());
return {
account,
accounts: useMatch("/accounts"),
bond: useMatch("/bonds/:bondId"),
bonds: useMatch("/bonds"),
card: useMatch("/cards/:cardId"),
cards: useMatch("/cards"),
section,
settings: useMatch("/settings"),
stock: useMatch("/stocks/:stockId"),
stocks: useMatch("/stocks"),
};
}
export { useCurrentRoute };
+43 -6
View File
@@ -238,6 +238,34 @@ describe("All accounts routes", () => {
); );
}); });
it.each(["operations", "events", "income"])(
"restores applied dates when switching accounts in %s",
async (section) => {
const search = "?from=2026-08-01&to=2026-08-31";
renderPage(`/accounts/all/${section}${search}`);
fireEvent.click(await screen.findByLabelText("Период"));
fireEvent.click(
await screen.findByRole("button", { name: /10 August 2026/i }),
);
fireEvent.click(
await screen.findByRole("button", { name: /31 August 2026/i }),
);
expect(screen.getByLabelText("Период")).toHaveTextContent(
"10.08.2026 – 31.08.2026",
);
fireEvent.click(screen.getByRole("combobox", { name: "Счёт" }));
fireEvent.click(await screen.findByRole("option", { name: "ИИС" }));
await waitFor(() =>
expect(screen.getByLabelText("Период")).toHaveTextContent(
"01.08.2026 – 31.08.2026",
),
);
expect(screen.getByLabelText("Текущий путь")).toHaveTextContent(
`/accounts/b/${section}${search}`,
);
},
);
it("groups the same security and links its accounts and overview sections", async () => { it("groups the same security and links its accounts and overview sections", async () => {
renderPage("/accounts/all"); renderPage("/accounts/all");
expect(await screen.findAllByText("Сбербанк")).toHaveLength(1); expect(await screen.findAllByText("Сбербанк")).toHaveLength(1);
@@ -312,6 +340,11 @@ describe("All accounts routes", () => {
expect(screen.getByLabelText("Текущий путь")).toHaveTextContent( expect(screen.getByLabelText("Текущий путь")).toHaveTextContent(
"/accounts/b/operations?from=2026-08-01&to=2026-08-31&categories=income", "/accounts/b/operations?from=2026-08-01&to=2026-08-31&categories=income",
); );
await screen.findByText("Первая страница");
fireEvent.click(screen.getByRole("combobox", { name: "Счёт" }));
fireEvent.click(await screen.findByRole("option", { name: "Все счета" }));
await waitFor(() => expect(screen.getByText("Первая страница")).toBeVisible());
expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled();
}); });
it("restarts an expired aggregate cursor without losing filters", async () => { it("restarts an expired aggregate cursor without losing filters", async () => {
@@ -336,7 +369,9 @@ describe("All accounts routes", () => {
renderPage("/accounts/all/operations?from=2026-08-01&to=2026-08-31"); renderPage("/accounts/all/operations?from=2026-08-01&to=2026-08-31");
await screen.findAllByText("Выплата купона"); await screen.findAllByText("Выплата купона");
fireEvent.click(screen.getByRole("button", { name: "Далее" })); fireEvent.click(screen.getByRole("button", { name: "Далее" }));
fireEvent.click(await screen.findByRole("button", { name: "Повторить" })); await waitFor(() => {
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
});
expect(await screen.findAllByText("Выплата купона")).toHaveLength(2); expect(await screen.findAllByText("Выплата купона")).toHaveLength(2);
expect(screen.getByLabelText("Текущий путь")).toHaveTextContent( expect(screen.getByLabelText("Текущий путь")).toHaveTextContent(
"from=2026-08-01&to=2026-08-31", "from=2026-08-01&to=2026-08-31",
@@ -357,10 +392,12 @@ describe("All accounts routes", () => {
}), }),
); );
expect(screen.getAllByText("Сбербанк")).toHaveLength(1); expect(screen.getAllByText("Сбербанк")).toHaveLength(1);
expect( await waitFor(() => {
await screen.findByRole("button", { expect(
name: "Повторить", screen.queryByRole("button", {
}), name: "Повторить",
).toBeInTheDocument(); }),
).not.toBeInTheDocument();
});
}); });
}); });
+64 -7
View File
@@ -1,9 +1,11 @@
import type { ReactNode } from "react";
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme"; import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
import { http, HttpResponse } from "msw"; import { http, HttpResponse } from "msw";
import { MemoryRouter } from "react-router"; import { type InitialEntry, Link, MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { AuthProvider } from "@/features/auth"; import { AuthProvider } from "@/features/auth";
@@ -12,10 +14,11 @@ import { server } from "@/test/server";
import { AppRoutes } from "./AppRoutes"; import { AppRoutes } from "./AppRoutes";
function renderRoutes(path: string) { function renderRoutes(path: InitialEntry, controls?: ReactNode) {
return render( return render(
<QueryClientProvider client={createTestQueryClient()}> <QueryClientProvider client={createTestQueryClient()}>
<MemoryRouter initialEntries={[path]}> <MemoryRouter initialEntries={[path]}>
{controls}
<MantineProvider <MantineProvider
cssVariablesResolver={cssVariablesResolver} cssVariablesResolver={cssVariablesResolver}
theme={theme} theme={theme}
@@ -57,10 +60,20 @@ describe("AppRoutes", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("logs in through the public form and reaches the protected landing", async () => { it.each([
{ endpoint: "login", path: "/accounts", title: "Вход" },
{ endpoint: "register", path: "/register", title: "Регистрация" },
{
endpoint: "login",
path: { pathname: "/login", state: { returnTo: "//example.com" } },
title: "Вход",
},
{ endpoint: "login", path: { pathname: "/login", state: { returnTo: 42 } }, title: "Вход" },
{ endpoint: "login", path: { pathname: "/login", state: 42 }, title: "Вход" },
])("authenticates via $endpoint and reaches the protected landing from $path", async ({ endpoint, path, title }) => {
let credentials: unknown; let credentials: unknown;
server.use( server.use(
http.post("*/api/v1/auth/login", async ({ request }) => { http.post(`*/api/v1/auth/${endpoint}`, async ({ request }) => {
credentials = await request.json(); credentials = await request.json();
return HttpResponse.json({ return HttpResponse.json({
@@ -69,16 +82,23 @@ describe("AppRoutes", () => {
}); });
}), }),
); );
renderRoutes("/accounts"); renderRoutes(path);
await screen.findByRole("heading", { level: 1, name: "Вход" }); await screen.findByRole("heading", { level: 1, name: title });
fireEvent.change(screen.getByLabelText("Email"), { fireEvent.change(screen.getByLabelText("Email"), {
target: { value: " User@Example.com " }, target: { value: " User@Example.com " },
}); });
fireEvent.change(screen.getByLabelText("Пароль"), { fireEvent.change(screen.getByLabelText("Пароль"), {
target: { value: "a secure password" }, target: { value: "a secure password" },
}); });
fireEvent.click(screen.getByRole("button", { name: "Вход" }));
if (endpoint === "register") {
fireEvent.change(screen.getByLabelText("Подтвердите пароль"), {
target: { value: "a secure password" },
});
}
fireEvent.click(screen.getByRole("button", { name: title }));
expect( expect(
await screen.findByRole("heading", { await screen.findByRole("heading", {
@@ -93,6 +113,43 @@ describe("AppRoutes", () => {
}); });
}); });
it.each([
{ isFailure: false, isNewDestination: false },
{ isFailure: true, isNewDestination: false },
{ isFailure: true, isNewDestination: true },
])("finishes logout safely: failure=$isFailure, new destination=$isNewDestination", async ({ isFailure, isNewDestination }) => {
const { promise: pendingLogout, resolve: finishLogout }
= Promise.withResolvers<void>();
server.use(
http.post("*/api/v1/auth/refresh", () => HttpResponse.json({
accessToken: "test-token", user: { email: "user@example.com", id: "user-1" },
})),
http.post("*/api/v1/auth/logout", async () => {
await pendingLogout;
return isFailure
? HttpResponse.error()
: new HttpResponse(null, { status: 204 });
}),
);
renderRoutes("/settings", <Link to="/settings?source=manual">Открыть настройки</Link>);
fireEvent.click(await screen.findByRole("button", { name: "Выйти" }));
await screen.findByRole("heading", { level: 1, name: "Вход" });
if (isNewDestination) fireEvent.click(screen.getByRole("link", { name: "Открыть настройки" }));
fireEvent.change(screen.getByLabelText("Email"), { target: { value: "user@example.com" } });
fireEvent.change(screen.getByLabelText("Пароль"), { target: { value: "a secure password" } });
fireEvent.click(screen.getByRole("button", { name: "Вход" }));
finishLogout();
const destination = isNewDestination
? { level: 1, name: "Настройки" }
: { level: 2, name: "Нет инвестиционных счетов" };
expect(await screen.findByRole("heading", destination)).toBeInTheDocument();
expect(document.title).toBe(isNewDestination ? "Настройки — WealthLedger" : "Счета — WealthLedger");
});
it("returns after login to cards with the selected month without T-Bank", async () => { it("returns after login to cards with the selected month without T-Bank", async () => {
server.use( server.use(
http.get("*/api/v1/bank-cards/categories", () => HttpResponse.json([])), http.get("*/api/v1/bank-cards/categories", () => HttpResponse.json([])),
-36
View File
@@ -200,42 +200,6 @@ const AppRoutes: FC = () => (
path="/accounts" path="/accounts"
/> />
<Route
element={(
<LazyPage variant="content">
<AccountPage accountId="all" />
</LazyPage>
)}
path="/accounts/all"
/>
<Route
element={(
<LazyPage variant="content">
<AccountOperationsPage accountId="all" />
</LazyPage>
)}
path="/accounts/all/operations"
/>
<Route
element={(
<LazyPage variant="content">
<AccountIncomePage accountId="all" />
</LazyPage>
)}
path="/accounts/all/income"
/>
<Route
element={(
<LazyPage variant="content">
<AccountEventsPage accountId="all" />
</LazyPage>
)}
path="/accounts/all/events"
/>
<Route <Route
element={( element={(
<LazyPage variant="content"> <LazyPage variant="content">
+13 -9
View File
@@ -2,8 +2,8 @@ import type { FC } from "react";
import { useDocumentTitle } from "@mantine/hooks"; import { useDocumentTitle } from "@mantine/hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMatch } from "react-router";
import { useCurrentRoute } from "@/app/model";
import { useBankCard } from "@/entities/bank-card"; import { useBankCard } from "@/entities/bank-card";
import { useBond } from "@/entities/bond"; import { useBond } from "@/entities/bond";
import { useBrokerageAccounts } from "@/entities/brokerage-account"; import { useBrokerageAccounts } from "@/entities/brokerage-account";
@@ -82,14 +82,18 @@ const CardDocumentTitle: FC<InstrumentDocumentTitleProps> = (props) => {
const ProtectedDocumentTitle: FC = () => { const ProtectedDocumentTitle: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const account = useMatch("/accounts/:accountId/:section?");
const bond = useMatch("/bonds/:bondId"); const {
const stock = useMatch("/stocks/:stockId"); account,
const card = useMatch("/cards/:cardId"); bond,
const cards = useMatch("/cards"); bonds,
const bonds = useMatch("/bonds"); card,
const stocks = useMatch("/stocks"); cards,
const settings = useMatch("/settings"); settings,
stock,
stocks,
} = useCurrentRoute();
if (card) return <CardDocumentTitle identifier={card.params.cardId ?? ""} />; if (card) return <CardDocumentTitle identifier={card.params.cardId ?? ""} />;
if (account) if (account)
return ( return (
@@ -24,15 +24,15 @@ class Boundary extends Component<BoundaryProps, State> {
return { hasError: true }; return { hasError: true };
} }
state: State = { hasError: false }; override state: State = { hasError: false };
componentDidUpdate(previousProps: BoundaryProps) { override componentDidUpdate(previousProps: BoundaryProps) {
if (previousProps.routeKey !== this.props.routeKey && this.state.hasError) { if (previousProps.routeKey !== this.props.routeKey && this.state.hasError) {
this.setState({ hasError: false }); this.setState({ hasError: false });
} }
} }
render() { override render() {
return this.state.hasError return this.state.hasError
? <RouteErrorFallback authenticated={this.props.authenticated} /> ? <RouteErrorFallback authenticated={this.props.authenticated} />
: ( : (
+5 -8
View File
@@ -1,6 +1,7 @@
import type { FC, PropsWithChildren } from "react"; import type { FC, PropsWithChildren } from "react";
import { Container, Group, Skeleton, Stack } from "@mantine/core"; import { Container, Group, Skeleton, Stack } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import { Suspense } from "react"; import { Suspense } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -19,11 +20,7 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
if (variant === "auth") if (variant === "auth")
return ( return (
<Container py="xl" size={420}> <Container py="xl" size={420}>
<Stack <LoadingSkeleton label={t("states.loadingTitle")}>
aria-busy="true"
aria-label={t("states.loadingTitle")}
role="status"
>
<Skeleton height={32} width="45%" /> <Skeleton height={32} width="45%" />
<Skeleton height={48} radius="sm" /> <Skeleton height={48} radius="sm" />
@@ -31,7 +28,7 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
<Skeleton height={48} radius="sm" /> <Skeleton height={48} radius="sm" />
<Skeleton height={36} radius="sm" /> <Skeleton height={36} radius="sm" />
</Stack> </LoadingSkeleton>
</Container> </Container>
); );
@@ -62,11 +59,11 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
); );
return ( return (
<Stack aria-busy="true" aria-label={t("states.loadingTitle")} role="status"> <LoadingSkeleton label={t("states.loadingTitle")}>
<Skeleton height={32} width="30%" /> <Skeleton height={32} width="30%" />
<Skeleton height={160} radius="md" /> <Skeleton height={160} radius="md" />
</Stack> </LoadingSkeleton>
); );
}; };
+14 -6
View File
@@ -1,5 +1,4 @@
import type { FC, PropsWithChildren } from "react"; import { type FC, type PropsWithChildren, useState } from "react";
import { Navigate, useLocation } from "react-router"; import { Navigate, useLocation } from "react-router";
import { useAuth } from "@/features/auth"; import { useAuth } from "@/features/auth";
@@ -8,16 +7,22 @@ import { RouteSkeleton } from "./RouteFallback";
const Protected: FC<PropsWithChildren> = (props) => { const Protected: FC<PropsWithChildren> = (props) => {
const { children } = props; const { children } = props;
const { ready, user } = useAuth(); const { ready, signedOut, user } = useAuth();
const location = useLocation(); const location = useLocation();
const [wasAuthenticated, setWasAuthenticated] = useState(false);
if (user && !wasAuthenticated) setWasAuthenticated(true);
if (!ready) return <RouteSkeleton variant="shell" />; if (!ready) return <RouteSkeleton variant="shell" />;
const loginState = signedOut && wasAuthenticated
? undefined
: { returnTo: `${location.pathname}${location.search}` };
return user return user
? <>{children}</> ? <>{children}</>
: ( : (
<Navigate <Navigate
replace replace
state={{ returnTo: `${location.pathname}${location.search}` }} state={loginState}
to="/login" to="/login"
/> />
); );
@@ -30,8 +35,11 @@ const Public: FC<PropsWithChildren> = (props) => {
if (!ready) return <RouteSkeleton variant="shell" />; if (!ready) return <RouteSkeleton variant="shell" />;
const requested = (location.state as null const state: unknown = location.state;
| { returnTo?: string })?.returnTo;
const requested = state && typeof state === "object" && "returnTo" in state
? state.returnTo
: undefined;
const returnTo const returnTo
= typeof requested === "string" = typeof requested === "string"
+8 -1
View File
@@ -2,6 +2,7 @@ import type { FC } from "react";
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { DatesProvider } from "@mantine/dates"; import { DatesProvider } from "@mantine/dates";
import { Notifications } from "@mantine/notifications";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme"; import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
import { BrowserRouter } from "react-router"; import { BrowserRouter } from "react-router";
@@ -11,7 +12,11 @@ import { AuthProvider } from "@/features/auth";
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { refetchOnWindowFocus: false, retry: false }, queries: {
refetchOnReconnect: true,
refetchOnWindowFocus: true,
retry: false,
},
}, },
}); });
@@ -31,6 +36,8 @@ const App: FC = () => {
</RouteErrorBoundary> </RouteErrorBoundary>
</AuthProvider> </AuthProvider>
</BrowserRouter> </BrowserRouter>
<Notifications autoClose={4000} limit={2} position="top-right" zIndex={1000} />
</QueryClientProvider> </QueryClientProvider>
</DatesProvider> </DatesProvider>
</MantineProvider> </MantineProvider>
@@ -54,8 +54,13 @@ describe("AppBreadcrumbs", () => {
expect(screen.queryByRole("link")).not.toBeInTheDocument(); expect(screen.queryByRole("link")).not.toBeInTheDocument();
}); });
it("links to the account hierarchy from operations", () => { it.each([
renderBreadcrumbs("/accounts/account-1/operations", [ ["operations", "Операции"],
["OPERATIONS", "Операции"],
["events", "Будущие события"],
["income", "Пассивный доход"],
])("links to the account hierarchy from %s", (section, title) => {
renderBreadcrumbs(`/accounts/account-1/${section}`, [
{ id: "account-1", name: "ИИС" }, { id: "account-1", name: "ИИС" },
]); ]);
@@ -66,7 +71,7 @@ describe("AppBreadcrumbs", () => {
"href", "href",
"/accounts/account-1", "/accounts/account-1",
); );
expect(screen.getByText("Операции")).toHaveAttribute( expect(screen.getByText(title)).toHaveAttribute(
"aria-current", "aria-current",
"page", "page",
); );
@@ -86,7 +91,11 @@ describe("AppBreadcrumbs", () => {
expect(screen.queryByText("account-1")).not.toBeInTheDocument(); expect(screen.queryByText("account-1")).not.toBeInTheDocument();
}); });
it.each(["/login", "/unknown"])("does not render on %s", (path) => { it.each([
"/login",
"/unknown",
"/accounts/account-1/unknown",
])("does not render on %s", (path) => {
renderBreadcrumbs(path); renderBreadcrumbs(path);
expect( expect(
+17 -56
View File
@@ -2,7 +2,8 @@ import type { FC } from "react";
import { Stack } from "@mantine/core"; import { Stack } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMatch } from "react-router";
import { useCurrentRoute } from "@/app/model";
import { AccountBreadcrumbs } from "./AccountBreadcrumbs"; import { AccountBreadcrumbs } from "./AccountBreadcrumbs";
import { AccountScopeSwitch } from "./AccountScopeSwitch/AccountScopeSwitch"; import { AccountScopeSwitch } from "./AccountScopeSwitch/AccountScopeSwitch";
@@ -14,74 +15,34 @@ import { StockBreadcrumbs } from "./StockBreadcrumbs";
const AppBreadcrumbs: FC = () => { const AppBreadcrumbs: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const operations = useMatch({ const {
end: true, account,
path: "/accounts/:accountId/operations", accounts,
}); bond,
card,
cards,
section,
settings,
stock,
} = useCurrentRoute();
const events = useMatch({ end: true, path: "/accounts/:accountId/events" });
const income = useMatch({ end: true, path: "/accounts/:accountId/income" });
const account = useMatch({ end: true, path: "/accounts/:accountId" });
const card = useMatch("/cards/:cardId");
const cards = useMatch("/cards");
const accounts = useMatch({ end: true, path: "/accounts" });
const settings = useMatch({ end: true, path: "/settings" });
const stock = useMatch({ end: true, path: "/stocks/:stockId" });
const bond = useMatch({ end: true, path: "/bonds/:bondId" });
if (bond) return <BondBreadcrumbs identifier={bond.params.bondId ?? ""} />; if (bond) return <BondBreadcrumbs identifier={bond.params.bondId ?? ""} />;
if (stock) if (stock)
return <StockBreadcrumbs identifier={stock.params.stockId ?? ""} />; return <StockBreadcrumbs identifier={stock.params.stockId ?? ""} />;
if (operations) if (account && (section || !account.params.section))
return ( return (
<Stack gap={0} mb="md"> <Stack gap={0} mb="md">
<AccountBreadcrumbs <AccountBreadcrumbs
accountId={operations.params.accountId ?? ""} accountId={account.params.accountId ?? ""}
section="operations" section={section}
/> />
<AccountScopeSwitch <AccountScopeSwitch
accountId={operations.params.accountId ?? ""} accountId={account.params.accountId ?? ""}
section="operations" section={section}
/> />
</Stack> </Stack>
); );
if (events)
return (
<Stack gap={0} mb="md">
<AccountBreadcrumbs
accountId={events.params.accountId ?? ""}
section="events"
/>
<AccountScopeSwitch
accountId={events.params.accountId ?? ""}
section="events"
/>
</Stack>
);
if (income)
return (
<Stack gap={0} mb="md">
<AccountBreadcrumbs
accountId={income.params.accountId ?? ""}
section="income"
/>
<AccountScopeSwitch
accountId={income.params.accountId ?? ""}
section="income"
/>
</Stack>
);
if (account)
return (
<Stack gap={0} mb="md">
<AccountBreadcrumbs accountId={account.params.accountId ?? ""} />
<AccountScopeSwitch accountId={account.params.accountId ?? ""} />
</Stack>
);
if (card) return <CardBreadcrumbs identifier={card.params.cardId ?? ""} />; if (card) return <CardBreadcrumbs identifier={card.params.cardId ?? ""} />;
if (cards) return <BreadcrumbTrail items={[{ label: t("bank.title") }]} />; if (cards) return <BreadcrumbTrail items={[{ label: t("bank.title") }]} />;
if (accounts) if (accounts)
+51 -39
View File
@@ -6,10 +6,16 @@ import { apiClient, configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query"; import { createQueryWrapper } from "@/test/query";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { useBankMutations } from "./mutations"; import type { BankCard, CreateBankOperation } from "./types";
import {
useCreateBankOperation,
useDeleteBankOperation,
useUpdateBankCard,
useUpdateBankOperation,
useUpdateExpenseCategory,
} from "./mutations";
import { import {
type BankCard,
type CreateBankOperation,
useBankCard, useBankCard,
useBankCards, useBankCards,
useBankCategories, useBankCategories,
@@ -100,18 +106,18 @@ function useReadModels() {
const categories = useBankCategories(); const categories = useBankCategories();
const history = useBankOperations("a", { const history = useBankOperations("a", {
limit: 20, from: "2026-09-01",
month: "2026-09", limit: 20, page: 2,
page: 2, to: "2026-09-30",
}); });
const expenses = useBankExpenses("a", { page: 1 }); const expenses = useBankExpenses("a", { page: 1 });
const september = useBankSummary("2026-09"); const september = useBankSummary({ from: "2026-09-01", to: "2026-09-30" });
const august = useBankSummary("2026-08", "b"); const august = useBankSummary({ from: "2026-08-01", to: "2026-08-31" }, "b");
const original = useBankOperation("original"); const original = useBankOperation("original");
return { return {
mutations: useBankMutations(), createOperation: useCreateBankOperation(),
reads: [ reads: [
cards, cards,
a, a,
@@ -138,9 +144,10 @@ describe("banking API", () => {
const page = Number(url.searchParams.get("page")); const page = Number(url.searchParams.get("page"));
pages.push(page); pages.push(page);
expect(url.searchParams.get("limit")).toBe("100"); expect(url.searchParams.get("limit")).toBe("100");
expect(url.searchParams.get("month")).toBe("2026-09"); expect(url.searchParams.get("from")).toBe("2026-09-01");
expect(url.searchParams.get("to")).toBe("2026-09-30");
if (isFail && page === 2) if (isFail && page === 2)
return HttpResponse.json({ code: "FAILED" }, { status: 500 }); return HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 });
return HttpResponse.json({ return HttpResponse.json({
hasNext: page === 1, hasNext: page === 1,
@@ -152,7 +159,7 @@ describe("banking API", () => {
); );
const { result } = renderHook( const { result } = renderHook(
() => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }), () => useBankOperations("a", { from: "2026-09-01", limit: "all", page: 1, to: "2026-09-30" }),
{ wrapper: createQueryWrapper().wrapper }, { wrapper: createQueryWrapper().wrapper },
); );
@@ -165,7 +172,7 @@ describe("banking API", () => {
isFail = true; isFail = true;
const { result: failedResult } = renderHook( const { result: failedResult } = renderHook(
() => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }), () => useBankOperations("a", { from: "2026-09-01", limit: "all", page: 1, to: "2026-09-30" }),
{ wrapper: createQueryWrapper().wrapper }, { wrapper: createQueryWrapper().wrapper },
); );
@@ -173,7 +180,7 @@ describe("banking API", () => {
expect(failedResult.current.data).toBeUndefined(); expect(failedResult.current.data).toBeUndefined();
}); });
it("uses session-aware banking endpoints and explicit month/page without T-Bank", async () => { it("uses session-aware banking endpoints and explicit period/page without T-Bank", async () => {
const counts = new Map<string, number>(); const counts = new Map<string, number>();
server.use(...readHandlers(counts)); server.use(...readHandlers(counts));
@@ -185,12 +192,15 @@ describe("banking API", () => {
expect(result.current.reads.every(read => read.isSuccess)).toBe(true), expect(result.current.reads.every(read => read.isSuccess)).toBe(true),
); );
expect(counts.keys()).toContain("/api/v1/bank-cards?includeArchived=true"); expect(counts.keys()).toContain("/api/v1/bank-cards?includeArchived=true");
expect(counts.keys()).toContain( const urls = Array.from(counts.keys(), path => new URL(path, "http://localhost"));
"/api/v1/bank-cards/a/operations?limit=20&month=2026-09&page=2", const history = urls.find(url => url.pathname === "/api/v1/bank-cards/a/operations");
); const summary = urls.find(url => url.pathname === "/api/v1/bank-cards/summary" && url.searchParams.get("cardId") === "b");
expect(counts.keys()).toContain( expect(Object.fromEntries(history?.searchParams ?? [])).toEqual({
"/api/v1/bank-cards/summary?cardId=b&month=2026-08", from: "2026-09-01", limit: "20", page: "2", to: "2026-09-30",
); });
expect(Object.fromEntries(summary?.searchParams ?? [])).toEqual({
cardId: "b", from: "2026-08-01", to: "2026-08-31",
});
expect(counts.size).toBe(9); expect(counts.size).toBe(9);
}); });
@@ -217,11 +227,11 @@ describe("banking API", () => {
}), }),
); );
const { result } = renderHook(useBankMutations, { const { result } = renderHook(useCreateBankOperation, {
wrapper: createQueryWrapper().wrapper, wrapper: createQueryWrapper().wrapper,
}); });
await act(() => result.current.createOperation.mutateAsync(transfer)); await act(() => result.current.mutateAsync(transfer));
expect(bodies).toEqual([transfer, transfer]); expect(bodies).toEqual([transfer, transfer]);
expect(headers).toEqual(["Bearer expired", "Bearer renewed"]); expect(headers).toEqual(["Bearer expired", "Bearer renewed"]);
expect(refresh).toHaveBeenCalledTimes(1); expect(refresh).toHaveBeenCalledTimes(1);
@@ -239,17 +249,17 @@ describe("banking API", () => {
}), }),
); );
const { result } = renderHook(useBankMutations, { const { result } = renderHook(useCreateBankOperation, {
wrapper: createQueryWrapper().wrapper, wrapper: createQueryWrapper().wrapper,
}); });
await act(async () => { await act(async () => {
await expect( await expect(
result.current.createOperation.mutateAsync(transfer), result.current.mutateAsync(transfer),
).rejects.toThrow(); ).rejects.toThrow();
}); });
expect(bodies).toEqual([transfer]); expect(bodies).toEqual([transfer]);
await act(() => result.current.createOperation.mutateAsync(transfer)); await act(() => result.current.mutateAsync(transfer));
expect(bodies).toEqual([transfer, transfer]); expect(bodies).toEqual([transfer, transfer]);
}); });
@@ -266,20 +276,20 @@ describe("banking API", () => {
); );
const { result } = renderHook( const { result } = renderHook(
() => ({ mutations: useBankMutations(), query: useBankCard("a") }), () => ({ createOperation: useCreateBankOperation(), query: useBankCard("a") }),
{ wrapper: createQueryWrapper().wrapper }, { wrapper: createQueryWrapper().wrapper },
); );
await waitFor(() => expect(result.current.query.isSuccess).toBe(true)); await waitFor(() => expect(result.current.query.isSuccess).toBe(true));
let saving: Promise<unknown>; let saving: Promise<unknown>;
act(() => { act(() => {
saving = result.current.mutations.createOperation saving = result.current.createOperation
.mutateAsync(transfer) .mutateAsync(transfer)
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering. // eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
.catch(() => {}); .catch(() => {});
}); });
await waitFor(() => await waitFor(() =>
expect(result.current.mutations.createOperation.isPending).toBe(true), expect(result.current.createOperation.isPending).toBe(true),
); );
expect(result.current.query.data?.balance.value).toBe("100"); expect(result.current.query.data?.balance.value).toBe("100");
await act(async () => { await act(async () => {
@@ -289,7 +299,7 @@ describe("banking API", () => {
expect(result.current.query.data?.balance.value).toBe("100"); expect(result.current.query.data?.balance.value).toBe("100");
server.use( server.use(
http.get("*/api/v1/bank-cards/a", () => http.get("*/api/v1/bank-cards/a", () =>
HttpResponse.json({ code: "INTERNAL_ERROR" }, { status: 500 }), HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 }),
), ),
); );
await act(() => result.current.query.refetch()); await act(() => result.current.query.refetch());
@@ -297,7 +307,7 @@ describe("banking API", () => {
expect(result.current.query.data?.balance.value).toBe("100"); expect(result.current.query.data?.balance.value).toBe("100");
}); });
it("refetches both cards, every month, categories, journal and source expenses after a transfer without invalidating brokerage", async () => { it("refetches both cards, every period, categories, journal and source expenses after a transfer without invalidating brokerage", async () => {
const counts = new Map<string, number>(); const counts = new Map<string, number>();
server.use( server.use(
...readHandlers(counts), ...readHandlers(counts),
@@ -313,7 +323,7 @@ describe("banking API", () => {
expect(result.current.reads.every(read => read.isSuccess)).toBe(true), expect(result.current.reads.every(read => read.isSuccess)).toBe(true),
); );
await act(() => await act(() =>
result.current.mutations.createOperation.mutateAsync(transfer), result.current.createOperation.mutateAsync(transfer),
); );
expect(counts.size).toBe(9); expect(counts.size).toBe(9);
for (const count of counts.values()) expect(count).toBe(2); for (const count of counts.values()) expect(count).toBe(2);
@@ -336,14 +346,14 @@ describe("banking API", () => {
); );
const { result } = renderHook( const { result } = renderHook(
() => ({ card: useBankCard("a"), mutations: useBankMutations() }), () => ({ card: useBankCard("a"), updateCard: useUpdateBankCard() }),
{ wrapper: createQueryWrapper().wrapper }, { wrapper: createQueryWrapper().wrapper },
); );
await waitFor(() => expect(result.current.card.data?.version).toBe(1)); await waitFor(() => expect(result.current.card.data?.version).toBe(1));
await act(async () => { await act(async () => {
await expect( await expect(
result.current.mutations.updateCard.mutateAsync({ result.current.updateCard.mutateAsync({
body: { name: "changed", version: 1 }, body: { name: "changed", version: 1 },
id: "a", id: "a",
}), }),
@@ -389,31 +399,33 @@ describe("banking API", () => {
const { result } = renderHook( const { result } = renderHook(
() => ({ () => ({
categories: useBankCategories(), categories: useBankCategories(),
mutations: useBankMutations(), deleteOperation: useDeleteBankOperation(),
updateCategory: useUpdateExpenseCategory(),
updateOperation: useUpdateBankOperation(),
}), }),
{ wrapper: createQueryWrapper().wrapper }, { wrapper: createQueryWrapper().wrapper },
); );
await waitFor(() => await waitFor(() =>
expect(result.current.categories.data?.[0].name).toBe("Food"), expect(result.current.categories.data?.[0]?.name).toBe("Food"),
); );
await act(() => await act(() =>
result.current.mutations.updateCategory.mutateAsync({ result.current.updateCategory.mutateAsync({
body: { name: "Groceries", version: 1 }, body: { name: "Groceries", version: 1 },
id: "food", id: "food",
}), }),
); );
await waitFor(() => await waitFor(() =>
expect(result.current.categories.data?.[0].name).toBe("Groceries"), expect(result.current.categories.data?.[0]?.name).toBe("Groceries"),
); );
await act(() => await act(() =>
result.current.mutations.updateOperation.mutateAsync({ result.current.updateOperation.mutateAsync({
body: { description: "corrected", version: 1 }, body: { description: "corrected", version: 1 },
id: "original", id: "original",
}), }),
); );
await act(() => await act(() =>
result.current.mutations.deleteOperation.mutateAsync({ result.current.deleteOperation.mutateAsync({
id: "original", id: "original",
version: 2, version: 2,
}), }),
+74 -48
View File
@@ -14,7 +14,7 @@ import type {
UpdateBankCard, UpdateBankCard,
UpdateBankOperation, UpdateBankOperation,
UpdateExpenseCategory, UpdateExpenseCategory,
} from "./queries"; } from "./types";
async function mutationData<T>( async function mutationData<T>(
request: Promise<{ data?: T; error?: unknown }>, request: Promise<{ data?: T; error?: unknown }>,
@@ -26,7 +26,7 @@ async function mutationData<T>(
return data; return data;
} }
function useBankMutations() { function useBankMutationOptions() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const invalidate = () => const invalidate = () =>
@@ -43,31 +43,73 @@ function useBankMutations() {
}, },
}); });
const options = { return {
onError: (error: unknown) => { onError: (error: unknown) => {
const errorCode = apiErrorCode(error); const errorCode = apiErrorCode(error);
if (
[ return [
ApiErrorCode.BankDependentRefunds, ApiErrorCode.BankDependentRefunds,
ApiErrorCode.BankIntentConflict, ApiErrorCode.BankIntentConflict,
ApiErrorCode.BankRefundLimit, ApiErrorCode.BankRefundLimit,
ApiErrorCode.BankVersionConflict, ApiErrorCode.BankVersionConflict,
ApiErrorCode.BankWriteConflict, ApiErrorCode.BankWriteConflict,
].some(code => isApiErrorCode(errorCode, code)) ].some(code => isApiErrorCode(errorCode, code))
) ? invalidate()
return invalidate(); : undefined;
}, },
onSuccess: invalidate, onSuccess: invalidate,
retry: false, retry: false,
} as const; } as const;
}
const cardCreation = useMutation({ function useCreateBankCard() {
const options = useBankMutationOptions();
return useMutation({
...options, ...options,
mutationFn: (body: CreateBankCard) => mutationFn: (body: CreateBankCard) =>
mutationData(apiClient.POST("/api/v1/bank-cards", { body })), mutationData(apiClient.POST("/api/v1/bank-cards", { body })),
}); });
}
const updateCard = useMutation({ function useCreateBankOperation() {
const options = useBankMutationOptions();
return useMutation({
...options,
mutationFn: (body: CreateBankOperation) =>
mutationData(apiClient.POST("/api/v1/bank-cards/operations", { body })),
});
}
function useCreateExpenseCategory() {
const options = useBankMutationOptions();
return useMutation({
...options,
mutationFn: (body: CreateExpenseCategory) =>
mutationData(apiClient.POST("/api/v1/bank-cards/categories", { body })),
});
}
function useDeleteBankOperation() {
const options = useBankMutationOptions();
return useMutation({
...options,
mutationFn: ({ id, version }: { id: string; version: number }) =>
mutationData(
apiClient.DELETE("/api/v1/bank-cards/operations/{id}", {
params: { path: { id }, query: { version } },
}),
),
});
}
function useUpdateBankCard() {
const options = useBankMutationOptions();
return useMutation({
...options, ...options,
mutationFn: ({ body, id }: { body: UpdateBankCard; id: string }) => mutationFn: ({ body, id }: { body: UpdateBankCard; id: string }) =>
mutationData( mutationData(
@@ -77,14 +119,12 @@ function useBankMutations() {
}), }),
), ),
}); });
}
const operationCreation = useMutation({ function useUpdateBankOperation() {
...options, const options = useBankMutationOptions();
mutationFn: (body: CreateBankOperation) =>
mutationData(apiClient.POST("/api/v1/bank-cards/operations", { body })),
});
const updateOperation = useMutation({ return useMutation({
...options, ...options,
mutationFn: ({ body, id }: { body: UpdateBankOperation; id: string }) => mutationFn: ({ body, id }: { body: UpdateBankOperation; id: string }) =>
mutationData( mutationData(
@@ -94,24 +134,12 @@ function useBankMutations() {
}), }),
), ),
}); });
}
const operationDeletion = useMutation({ function useUpdateExpenseCategory() {
...options, const options = useBankMutationOptions();
mutationFn: ({ id, version }: { id: string; version: number }) =>
mutationData(
apiClient.DELETE("/api/v1/bank-cards/operations/{id}", {
params: { path: { id }, query: { version } },
}),
),
});
const categoryCreation = useMutation({ return useMutation({
...options,
mutationFn: (body: CreateExpenseCategory) =>
mutationData(apiClient.POST("/api/v1/bank-cards/categories", { body })),
});
const updateCategory = useMutation({
...options, ...options,
mutationFn: ({ body, id }: { body: UpdateExpenseCategory; id: string }) => mutationFn: ({ body, id }: { body: UpdateExpenseCategory; id: string }) =>
mutationData( mutationData(
@@ -121,16 +149,14 @@ function useBankMutations() {
}), }),
), ),
}); });
return {
createCard: cardCreation,
createCategory: categoryCreation,
createOperation: operationCreation,
deleteOperation: operationDeletion,
updateCard,
updateCategory,
updateOperation,
};
} }
export { useBankMutations }; export {
useCreateBankCard,
useCreateBankOperation,
useCreateExpenseCategory,
useDeleteBankOperation,
useUpdateBankCard,
useUpdateBankOperation,
useUpdateExpenseCategory,
};
+7 -57
View File
@@ -1,44 +1,12 @@
import { import {
apiClient, apiClient,
apiQueryClient, apiQueryClient,
type paths,
useAuthenticatedQuery, useAuthenticatedQuery,
} from "@/shared/api"; } from "@/shared/api";
import { moneyCents } from "../model/form-validation"; import type { BankOperation, BankOperations, BankSummary } from "./types";
type BankCard import { moneyCents } from "../model/money";
= paths["/api/v1/bank-cards/{id}"]["get"]["responses"][200]["content"]["application/json"];
type BankOperation
= paths["/api/v1/bank-cards/operations/{id}"]["get"]["responses"][200]["content"]["application/json"];
type BankOperations
= paths["/api/v1/bank-cards/{id}/operations"]["get"]["responses"][200]["content"]["application/json"];
type BankSummary
= paths["/api/v1/bank-cards/summary"]["get"]["responses"][200]["content"]["application/json"];
type CreateBankCard
= paths["/api/v1/bank-cards"]["post"]["requestBody"]["content"]["application/json"];
type CreateBankOperation
= paths["/api/v1/bank-cards/operations"]["post"]["requestBody"]["content"]["application/json"];
type CreateExpenseCategory
= paths["/api/v1/bank-cards/categories"]["post"]["requestBody"]["content"]["application/json"];
type ExpenseCategory
= paths["/api/v1/bank-cards/categories"]["get"]["responses"][200]["content"]["application/json"][number];
type UpdateBankCard
= paths["/api/v1/bank-cards/{id}"]["patch"]["requestBody"]["content"]["application/json"];
type UpdateBankOperation
= paths["/api/v1/bank-cards/operations/{id}"]["patch"]["requestBody"]["content"]["application/json"];
type UpdateExpenseCategory
= paths["/api/v1/bank-cards/categories/{id}"]["patch"]["requestBody"]["content"]["application/json"];
const OPERATIONS_PAGE_SIZE = 100; const OPERATIONS_PAGE_SIZE = 100;
@@ -110,7 +78,6 @@ function useBankOperations(
query: { query: {
from?: string; from?: string;
limit?: "all" | number; limit?: "all" | number;
month?: string;
page: number; page: number;
to?: string; to?: string;
}, },
@@ -134,7 +101,7 @@ function useBankOperations(
// eslint-disable-next-line @tanstack/query/exhaustive-deps -- Generated options.queryKey already contains params; retain the shared cache identity. // eslint-disable-next-line @tanstack/query/exhaustive-deps -- Generated options.queryKey already contains params; retain the shared cache identity.
return useAuthenticatedQuery({ return useAuthenticatedQuery({
enabled: Boolean(id && (query.month || (query.from && query.to))), enabled: Boolean(id && query.from && query.to),
placeholderData: ( placeholderData: (
previousData: BankOperations | undefined, previousData: BankOperations | undefined,
previousQuery, previousQuery,
@@ -143,8 +110,7 @@ function useBankOperations(
return !isAll return !isAll
&& previousQuery?.queryKey.length === options.queryKey.length && previousQuery?.queryKey.length === options.queryKey.length
&& previous?.path?.id === id && previous?.path.id === id
&& previous?.query?.month === query.month
&& previous?.query?.from === query.from && previous?.query?.from === query.from
&& previous?.query?.to === query.to && previous?.query?.to === query.to
&& previous?.query?.limit === query.limit && previous?.query?.limit === query.limit
@@ -165,6 +131,7 @@ function useBankOperations(
// eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers. // eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers.
if (error) throw error; if (error) throw error;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- An empty HTTP body can violate the generated response contract.
if (!data) throw new Error("Missing bank operations response"); if (!data) throw new Error("Missing bank operations response");
if (!isAll) return data; if (!isAll) return data;
items.push(...data.items); items.push(...data.items);
@@ -178,36 +145,19 @@ function useBankOperations(
} }
function useBankSummary( function useBankSummary(
period: string period: { from: string; to: string },
| { from: string; to: string },
cardId?: string, cardId?: string,
) { ) {
const query
= typeof period === "string"
? { cardId, month: period }
: { ...period, cardId };
return useAuthenticatedQuery({ return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/summary", { ...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/summary", {
params: { query }, params: { query: { ...period, cardId } },
}), }),
enabled: Boolean(period),
select: sortBankSummary, select: sortBankSummary,
}); });
} }
export { export {
type BankCard,
type BankOperation,
type BankSummary,
type CreateBankCard,
type CreateBankOperation,
type CreateExpenseCategory,
type ExpenseCategory,
sortBankSummary, sortBankSummary,
type UpdateBankCard,
type UpdateBankOperation,
type UpdateExpenseCategory,
useBankCard, useBankCard,
useBankCards, useBankCards,
useBankCategories, useBankCategories,
+3 -1
View File
@@ -1,6 +1,8 @@
import { expect, it } from "vitest"; import { expect, it } from "vitest";
import { type BankSummary, sortBankSummary } from "./queries"; import type { BankSummary } from "./types";
import { sortBankSummary } from "./queries";
const money = (value: string) => ({ currency: "RUB", value }); const money = (value: string) => ({ currency: "RUB", value });
+48
View File
@@ -0,0 +1,48 @@
import type { paths } from "@/shared/api";
type BankCard
= paths["/api/v1/bank-cards/{id}"]["get"]["responses"][200]["content"]["application/json"];
type BankOperation
= paths["/api/v1/bank-cards/operations/{id}"]["get"]["responses"][200]["content"]["application/json"];
type BankOperations
= paths["/api/v1/bank-cards/{id}/operations"]["get"]["responses"][200]["content"]["application/json"];
type BankSummary
= paths["/api/v1/bank-cards/summary"]["get"]["responses"][200]["content"]["application/json"];
type CreateBankCard
= paths["/api/v1/bank-cards"]["post"]["requestBody"]["content"]["application/json"];
type CreateBankOperation
= paths["/api/v1/bank-cards/operations"]["post"]["requestBody"]["content"]["application/json"];
type CreateExpenseCategory
= paths["/api/v1/bank-cards/categories"]["post"]["requestBody"]["content"]["application/json"];
type ExpenseCategory
= paths["/api/v1/bank-cards/categories"]["get"]["responses"][200]["content"]["application/json"][number];
type UpdateBankCard
= paths["/api/v1/bank-cards/{id}"]["patch"]["requestBody"]["content"]["application/json"];
type UpdateBankOperation
= paths["/api/v1/bank-cards/operations/{id}"]["patch"]["requestBody"]["content"]["application/json"];
type UpdateExpenseCategory
= paths["/api/v1/bank-cards/categories/{id}"]["patch"]["requestBody"]["content"]["application/json"];
export type {
BankCard,
BankOperation,
BankOperations,
BankSummary,
CreateBankCard,
CreateBankOperation,
CreateExpenseCategory,
ExpenseCategory,
UpdateBankCard,
UpdateBankOperation,
UpdateExpenseCategory,
};
+10 -5
View File
@@ -1,7 +1,10 @@
export { useBankMutations } from "./api/mutations";
export { export {
type BankCard, useCreateBankOperation,
type BankOperation, useDeleteBankOperation,
useUpdateBankCard,
useUpdateBankOperation,
} from "./api/mutations";
export {
useBankCard, useBankCard,
useBankCards, useBankCards,
useBankCategories, useBankCategories,
@@ -10,14 +13,16 @@ export {
useBankOperations, useBankOperations,
useBankSummary, useBankSummary,
} from "./api/queries"; } from "./api/queries";
export { bankDateInputValue, bankMoneyValue, bankToday, moneyCents } from "./model/form-validation"; export type { BankCard, BankOperation } from "./api/types";
export { bankToday } from "./model/form-validation";
export { bankDateSchema, positiveMoneySchema } from "./model/form.schema"; export { bankDateSchema, positiveMoneySchema } from "./model/form.schema";
export { bankMoneyValue, moneyCents } from "./model/money";
export { bankOperationAppearance } from "./model/operation-appearance"; export { bankOperationAppearance } from "./model/operation-appearance";
export { bankOperationKinds } from "./model/operation-kinds"; export { bankOperationKinds } from "./model/operation-kinds";
export { bankPeriod } from "./model/period"; export { bankPeriod } from "./model/period";
export { BankCardFace } from "./ui/BankCardFace/BankCardFace"; export { BankCardFace } from "./ui/BankCardFace/BankCardFace";
export { BankCardForm } from "./ui/BankCardForm/BankCardForm"; export { BankCardForm } from "./ui/BankCardForm/BankCardForm";
export { bankDateField } from "./ui/bankDateField";
export { BankFeedback } from "./ui/BankFeedback"; export { BankFeedback } from "./ui/BankFeedback";
export { BankPeriodPicker } from "./ui/BankPeriodPicker/BankPeriodPicker"; export { BankPeriodPicker } from "./ui/BankPeriodPicker/BankPeriodPicker";
export { BankSummaryView } from "./ui/BankSummary/BankSummary.view"; export { BankSummaryView } from "./ui/BankSummary/BankSummary.view";
@@ -2,9 +2,11 @@ import { z } from "zod";
import { validationMessage } from "@/shared/i18n"; import { validationMessage } from "@/shared/i18n";
import type { ExpenseCategory } from "../api/queries"; import type { ExpenseCategory } from "../api/types";
const MAX_CATEGORY_NAME_LENGTH = 80; const MAX_CATEGORY_NAME_LENGTH = 80;
type CategoryValues = z.infer<ReturnType<typeof categorySchema>>;
function categorySchema(categories?: ExpenseCategory[], editingId?: string) { function categorySchema(categories?: ExpenseCategory[], editingId?: string) {
return z.object({ return z.object({
name: z name: z
@@ -27,4 +29,4 @@ function categorySchema(categories?: ExpenseCategory[], editingId?: string) {
}); });
} }
export { categorySchema }; export { categorySchema, type CategoryValues };
@@ -1,7 +1,12 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { bankDateInputValue, bankMoneyValue, bankMonth, bankToday } from "./form-validation"; import { bankDateInputValue, bankMonth, bankToday } from "./form-validation";
import { bankCardSchema, bankDateSchema, positiveMoneySchema } from "./form.schema"; import {
bankCardSchema,
bankDateSchema,
positiveMoneySchema,
} from "./form.schema";
import { bankMoneyValue } from "./money";
afterEach(() => vi.useRealTimers()); afterEach(() => vi.useRealTimers());
describe("bank form input", () => { describe("bank form input", () => {
@@ -51,6 +56,9 @@ describe("bank form input", () => {
expect(bankDateSchema.safeParse("2026-09-01").success).toBe(true); expect(bankDateSchema.safeParse("2026-09-01").success).toBe(true);
expect(bankDateSchema.safeParse("2026-09-02").success).toBe(false); expect(bankDateSchema.safeParse("2026-09-02").success).toBe(false);
expect(bankDateSchema.safeParse("2026-02-30").success).toBe(false); expect(bankDateSchema.safeParse("2026-02-30").success).toBe(false);
expect(bankDateSchema.safeParse("0000-01-01").success).toBe(false);
expect(bankDateSchema.safeParse("0001-01-01").success).toBe(true);
expect(bankDateSchema.safeParse("0099-12-31").success).toBe(true);
}); });
}); });
@@ -1,5 +1,4 @@
const DECIMAL_PART_COUNT = 2; import { moscowDate } from "@/shared/lib";
const CENTS_PRECISION = 2;
const ISO_MONTH_LENGTH = 7; const ISO_MONTH_LENGTH = 7;
@@ -7,13 +6,6 @@ function bankDateInputValue(value: string) {
return value.replace(/^(\d{2})\.(\d{2})\.(\d{4})$/, "$3-$2-$1"); return value.replace(/^(\d{2})\.(\d{2})\.(\d{4})$/, "$3-$2-$1");
} }
function bankMoneyValue(value: string) {
return value
.trim()
.replace(",", ".")
.replace(/^(-?)0+(?=\d)/, "$1");
}
function bankMonth(value: null | string) { function bankMonth(value: null | string) {
return value && value >= "0001-01" && /^\d{4}-(?:0[1-9]|1[0-2])$/.test(value) return value && value >= "0001-01" && /^\d{4}-(?:0[1-9]|1[0-2])$/.test(value)
? value ? value
@@ -21,27 +13,7 @@ function bankMonth(value: null | string) {
} }
function bankToday() { function bankToday() {
return new Intl.DateTimeFormat("en-CA", { return moscowDate(new Date());
day: "2-digit",
month: "2-digit",
timeZone: "Europe/Moscow",
year: "numeric",
}).format(new Date());
} }
function moneyCents(value: string): bigint | null { export { bankDateInputValue, bankMonth, bankToday };
const normalized = value.trim().replace(",", ".");
if (!/^-?\d+$|^-?\d+\.\d{1,2}$/.test(normalized))
return null;
const [whole, fraction = ""] = normalized
.replace("-", "")
.split(".", DECIMAL_PART_COUNT);
return (
BigInt(`${whole}${fraction.padEnd(CENTS_PRECISION, "0")}`)
* (normalized.startsWith("-") ? -1n : 1n)
);
}
export { bankDateInputValue, bankMoneyValue, bankMonth, bankToday, moneyCents };
+4 -6
View File
@@ -1,9 +1,10 @@
import { z } from "zod"; import { z } from "zod";
import { validationMessage } from "@/shared/i18n"; import { validationMessage } from "@/shared/i18n";
import { calendarDate } from "@/shared/lib";
import { bankToday, moneyCents } from "./form-validation"; import { bankToday } from "./form-validation";
const ISO_DATE_LENGTH = 10; import { moneyCents } from "./money";
const MAX_CARD_NAME_LENGTH = 100; const MAX_CARD_NAME_LENGTH = 100;
const MIN_BALANCE_KOPECKS = -9_223_372_036_854_775_808n; const MIN_BALANCE_KOPECKS = -9_223_372_036_854_775_808n;
const MAX_BALANCE_KOPECKS = 9_223_372_036_854_775_807n; const MAX_BALANCE_KOPECKS = 9_223_372_036_854_775_807n;
@@ -34,11 +35,8 @@ const bankDateSchema = z
.string() .string()
.refine( .refine(
value => value =>
/^\d{4}-\d{2}-\d{2}$/.test(value) calendarDate.safeParse(value).success
&& value >= "0001-01-01" && value >= "0001-01-01"
&& !Number.isNaN(Date.parse(`${value}T00:00:00Z`))
&& new Date(`${value}T00:00:00Z`).toISOString().slice(0, ISO_DATE_LENGTH)
=== value
&& value <= bankToday(), && value <= bankToday(),
{ error: validationMessage("bank.validation.date") }, { error: validationMessage("bank.validation.date") },
); );
+26
View File
@@ -0,0 +1,26 @@
const DECIMAL_PART_COUNT = 2;
const CENTS_PRECISION = 2;
function bankMoneyValue(value: string) {
return value
.trim()
.replace(",", ".")
.replace(/^(-?)0+(?=\d)/, "$1");
}
function moneyCents(value: string): bigint | null {
const normalized = value.trim().replace(",", ".");
if (!/^-?\d+$|^-?\d+\.\d{1,2}$/.test(normalized))
return null;
const [whole, fraction = ""] = normalized
.replace("-", "")
.split(".", DECIMAL_PART_COUNT);
return (
BigInt(`${whole}${fraction.padEnd(CENTS_PRECISION, "0")}`)
* (normalized.startsWith("-") ? -1n : 1n)
);
}
export { bankMoneyValue, moneyCents };
@@ -0,0 +1,54 @@
import { expect, it } from "vitest";
import { bankOperationAppearance } from "./operation-appearance";
it.each(["Жильё", " ЖИЛЬЕ "])("normalizes category %s", (name) => {
expect(bankOperationAppearance("expense", name)).toEqual({
color: "orange", variant: "home",
});
});
it.each(["Другое", "Моя категория", "constructor", null, undefined])(
"uses the fallback for category %s",
(name) => {
expect(bankOperationAppearance("expense", name)).toEqual({
color: "gray", variant: "grid",
});
},
);
it.each([
["topup", "green", "topup"],
["transfer", "blue", "transfer"],
["refund", "teal", "refund"],
] as const)("ignores expense category for %s", (kind, color, variant) => {
expect(bankOperationAppearance(kind, "Жильё")).toEqual({ color, variant });
});
it.each([
["Алкоголь", "wine"],
["Вклады", "deposit"],
["Жильё", "home"],
["Здоровье", "medical"],
["Инвестиции и накопления", "growth"],
["Кафе", "coffee"],
["Образование", "education"],
["Переводы", "transfer"],
["Погашение кредита", "repayment"],
["Подписки", "subscriptions"],
["Покупки", "shopping"],
["Продукты", "basket"],
["Путешествия", "travel"],
["Развлечения", "music"],
["Связь и интернет", "wifi"],
["Снятие наличных", "cashWithdrawal"],
["Ставки", "dice"],
["Страхование", "shield"],
["Табак", "smoking"],
["Такси", "taxi"],
["Транспорт", "car"],
["Хостинг и ИИ-сервисы", "server"],
["Цифровые покупки", "download"],
])("maps category %s to its icon", (name, variant) => {
expect(bankOperationAppearance("expense", name).variant).toBe(variant);
});
@@ -1,6 +1,6 @@
import type { OperationIconProps } from "@wealthledger/icons"; import type { OperationIconProps } from "@wealthledger/icons";
import type { BankOperation } from "../api/queries"; import type { BankOperation } from "../api/types";
const categoryIcons: { const categoryIcons: {
appearance: OperationIconProps; appearance: OperationIconProps;
@@ -1,4 +1,4 @@
import type { BankOperation } from "../api/queries"; import type { BankOperation } from "../api/types";
const bankOperationKinds = [ const bankOperationKinds = [
"expense", "expense",
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import { formatMoney } from "@/shared/lib"; import { formatMoney } from "@/shared/lib";
import type { BankCard } from "../../api/queries"; import type { BankCard } from "../../api/types";
import classes from "./BankCardFace.module.css"; import classes from "./BankCardFace.module.css";
@@ -1,10 +1,9 @@
import { useFormContext } from "react-hook-form"; import { useController, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { BankCardValues } from "../../model/form.schema"; import type { BankCardValues } from "../../model/form.schema";
import { useOpeningDateField } from "./useOpeningDateField"; import { bankDateField } from "../bankDateField";
import { usePaymentSystemField } from "./usePaymentSystemField";
function useBankCardFieldsVm() { function useBankCardFieldsVm() {
const { const {
@@ -14,10 +13,26 @@ function useBankCardFieldsVm() {
} = useFormContext<BankCardValues>(); } = useFormContext<BankCardValues>();
const { t } = useTranslation(); const { t } = useTranslation();
const openingDate = useOpeningDateField(control); const { field: openingDateField } = useController({ control, name: "openingDate" });
const paymentSystem = usePaymentSystemField(control); const { field: paymentSystemField } = useController({ control, name: "paymentSystem" });
return { ...openingDate, ...paymentSystem, errors, register, t }; const paymentSystems = ["mir", "visa", "mastercard"].map(value => ({
label: t(`bank.paymentSystems.${value}`),
value,
}));
const onPaymentSystemChange = (value: null | string) =>
paymentSystemField.onChange(value ?? "");
return {
errors,
onPaymentSystemChange,
openingDate: { ...openingDateField, ...bankDateField(openingDateField) },
paymentSystemField,
paymentSystems,
register,
t,
};
} }
export { useBankCardFieldsVm }; export { useBankCardFieldsVm };
@@ -8,7 +8,7 @@ import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server"; import { server } from "@/test/server";
import type { BankCard } from "../../api/queries"; import type { BankCard } from "../../api/types";
import { BankCardForm } from "./BankCardForm"; import { BankCardForm } from "./BankCardForm";
@@ -1,12 +1,12 @@
import { Button, Group, Modal, Stack, Text } from "@mantine/core"; import { Button, Fieldset, Group, Modal, Stack, Text } from "@mantine/core";
import { type FC } from "react"; import { type FC } from "react";
import { FormProvider } from "react-hook-form"; import { FormProvider } from "react-hook-form";
import { formatDate } from "@/shared/lib"; import { formatDate } from "@/shared/lib";
import { type BankCard } from "../../api/queries"; import type { BankCard } from "../../api/types";
import { BankFeedback } from "../BankFeedback"; import { BankFeedback } from "../BankFeedback";
import classes from "../BankForms.module.css";
import { BankCardFields } from "./BankCardForm.view"; import { BankCardFields } from "./BankCardForm.view";
import { useBankCardFormVm } from "./BankCardForm.vm"; import { useBankCardFormVm } from "./BankCardForm.vm";
@@ -40,7 +40,7 @@ const BankCardForm: FC<BankCardFormProps> = (props) => {
> >
<FormProvider {...methods}> <FormProvider {...methods}>
<form onSubmit={onSubmit}> <form onSubmit={onSubmit}>
<fieldset className={classes.fieldset} disabled={isPending}> <Fieldset disabled={isPending} variant="unstyled">
<Stack> <Stack>
<BankCardFields archived={Boolean(props.card?.archivedAt)} /> <BankCardFields archived={Boolean(props.card?.archivedAt)} />
@@ -80,7 +80,7 @@ const BankCardForm: FC<BankCardFormProps> = (props) => {
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</fieldset> </Fieldset>
</form> </form>
</FormProvider> </FormProvider>
</Modal> </Modal>
@@ -10,14 +10,9 @@ type BankCardFieldsProps = { archived: boolean };
const BankCardFields: FC<BankCardFieldsProps> = (props) => { const BankCardFields: FC<BankCardFieldsProps> = (props) => {
const { const {
dateParser,
errors, errors,
onOpeningDateBlur,
onOpeningDateChange,
onOpeningDateInput,
onPaymentSystemChange, onPaymentSystemChange,
openingDateField, openingDate,
openingDateValue,
paymentSystemField, paymentSystemField,
paymentSystems, paymentSystems,
register, register,
@@ -71,22 +66,17 @@ const BankCardFields: FC<BankCardFieldsProps> = (props) => {
/> />
<DateInput <DateInput
{...openingDateField} {...openingDate}
dateParser={dateParser}
error={errors.openingDate?.message} error={errors.openingDate?.message}
fixOnBlur={false} fixOnBlur={false}
label={t("bank.openingDate")} label={t("bank.openingDate")}
maxDate={bankToday()} maxDate={bankToday()}
onBlur={onOpeningDateBlur}
onChange={onOpeningDateChange}
onInput={onOpeningDateInput}
popoverProps={{ popoverProps={{
middlewares: { shift: { crossAxis: true } }, middlewares: { shift: { crossAxis: true } },
withinPortal: false, withinPortal: false,
}} }}
readOnly={props.archived} readOnly={props.archived}
required required
value={openingDateValue}
valueFormat="DD.MM.YYYY" valueFormat="DD.MM.YYYY"
/> />
@@ -1,12 +1,16 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { notifications } from "@mantine/notifications";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useBankMutations } from "../../api/mutations"; import type { BankCard } from "../../api/types";
import { type BankCard, useBankCard } from "../../api/queries";
import { bankMoneyValue, bankToday } from "../../model/form-validation"; import { useCreateBankCard, useUpdateBankCard } from "../../api/mutations";
import { useBankCard } from "../../api/queries";
import { bankToday } from "../../model/form-validation";
import { bankCardSchema, type BankCardValues } from "../../model/form.schema"; import { bankCardSchema, type BankCardValues } from "../../model/form.schema";
import { bankMoneyValue } from "../../model/money";
import { useBankFormFocus } from "../useBankFormFocus"; import { useBankFormFocus } from "../useBankFormFocus";
type BankCardFormProps = { card?: BankCard; onClose: () => void }; type BankCardFormProps = { card?: BankCard; onClose: () => void };
@@ -20,7 +24,8 @@ function useBankCardFormVm(props: BankCardFormProps) {
const [error, setError] = useState<unknown>(); const [error, setError] = useState<unknown>();
const [latest, setLatest] = useState<BankCard>(); const [latest, setLatest] = useState<BankCard>();
const fresh = useBankCard(props.card?.id); const fresh = useBankCard(props.card?.id);
const mutation = useBankMutations(); const cardCreation = useCreateBankCard();
const updateCard = useUpdateBankCard();
const methods = useForm<BankCardValues>({ const methods = useForm<BankCardValues>({
defaultValues: { defaultValues: {
@@ -67,8 +72,8 @@ function useBankCardFormVm(props: BankCardFormProps) {
openingDate: values.openingDate, openingDate: values.openingDate,
}; };
if (props.card) if (props.card) {
await mutation.updateCard.mutateAsync({ await updateCard.mutateAsync({
body: { body: {
...metadata, ...metadata,
...(!props.card.archivedAt && financial), ...(!props.card.archivedAt && financial),
@@ -76,12 +81,19 @@ function useBankCardFormVm(props: BankCardFormProps) {
}, },
id: props.card.id, id: props.card.id,
}); });
else }
await mutation.createCard.mutateAsync({ else {
await cardCreation.mutateAsync({
id: intent, id: intent,
...metadata, ...metadata,
...financial, ...financial,
}); });
notifications.show({
id: "bank-card-created-" + intent,
message: t("bank.cardSaved"),
});
}
setOpened(false); setOpened(false);
} }
catch (error_) { catch (error_) {
@@ -1,49 +0,0 @@
import type { ComponentProps } from "react";
import type { Control } from "react-hook-form";
import { DateInput } from "@mantine/dates";
import { useController } from "react-hook-form";
import { bankDateInputValue } from "../../model/form-validation";
import { type BankCardValues, bankDateSchema } from "../../model/form.schema";
function useOpeningDateField(control: Control<BankCardValues>) {
const { field: openingDateField } = useController({
control,
name: "openingDate",
});
const dateParser: ComponentProps<typeof DateInput>["dateParser"] = value =>
bankDateSchema.safeParse(bankDateInputValue(value)).success
? bankDateInputValue(value)
: null;
const onOpeningDateBlur: ComponentProps<typeof DateInput>["onBlur"] = (
event,
) => {
openingDateField.onChange(bankDateInputValue(event.currentTarget.value));
openingDateField.onBlur();
};
const onOpeningDateChange = (value: null | string) =>
openingDateField.onChange(value ?? "");
const onOpeningDateInput: ComponentProps<typeof DateInput>["onInput"] = event =>
openingDateField.onChange(bankDateInputValue(event.currentTarget.value));
const openingDateValue = bankDateSchema.safeParse(openingDateField.value)
.success
? openingDateField.value
: undefined;
return {
dateParser,
onOpeningDateBlur,
onOpeningDateChange,
onOpeningDateInput,
openingDateField,
openingDateValue,
};
}
export { useOpeningDateField };
@@ -1,27 +0,0 @@
import type { Control } from "react-hook-form";
import { useController } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { BankCardValues } from "../../model/form.schema";
function usePaymentSystemField(control: Control<BankCardValues>) {
const { t } = useTranslation();
const { field: paymentSystemField } = useController({
control,
name: "paymentSystem",
});
const paymentSystems = ["mir", "visa", "mastercard"].map(value => ({
label: t(`bank.paymentSystems.${value}`),
value,
}));
const onPaymentSystemChange = (value: null | string) =>
paymentSystemField.onChange(value ?? "");
return { onPaymentSystemChange, paymentSystemField, paymentSystems };
}
export { usePaymentSystemField };
@@ -1,6 +0,0 @@
.fieldset {
border: 0;
padding: 0;
margin: 0;
min-width: 0;
}
@@ -1,92 +0,0 @@
import { render } from "@testing-library/react";
import { OperationIcon } from "@wealthledger/icons";
import { expect, it } from "vitest";
import { bankOperationAppearance } from "../../model/operation-appearance";
const BankOperationIcon = (props: {
categoryName?: null | string;
kind: "expense" | "refund" | "topup" | "transfer";
}) => (
<OperationIcon
{...bankOperationAppearance(props.kind, props.categoryName)}
/>
);
it("matches normalized category names, falls back after renaming and distinguishes operation kinds", () => {
const { container, rerender } = render(
<BankOperationIcon categoryName="Жильё" kind="expense" />,
);
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
const path = () => container.querySelector("path")?.getAttribute("d");
const housing = path();
rerender(<BankOperationIcon categoryName=" ЖИЛЬЕ " kind="expense" />);
expect(path()).toBe(housing);
rerender(<BankOperationIcon categoryName="Другое" kind="expense" />);
const fallback = path();
expect(fallback).not.toBe(housing);
for (const categoryName of [
"Моя категория",
"constructor",
null,
]) {
rerender(<BankOperationIcon categoryName={categoryName} kind="expense" />);
expect(path()).toBe(fallback);
}
const paths = [fallback, housing];
for (const kind of ["topup", "transfer", "refund"] as const) {
rerender(<BankOperationIcon categoryName="Жильё" kind={kind} />);
expect(path()).toBeTruthy();
paths.push(path());
}
expect(new Set(paths).size).toBe(5);
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
expect(container.querySelector("svg")).toHaveAttribute("aria-hidden", "true");
});
it("provides a distinct icon for every existing named category", () => {
const { container, rerender } = render(
<BankOperationIcon categoryName="Другое" kind="expense" />,
);
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
const paths = new Set([container.querySelector("path")?.getAttribute("d")]);
for (const categoryName of [
"Алкоголь",
"Вклады",
"Жильё",
"Здоровье",
"Инвестиции и накопления",
"Кафе",
"Образование",
"Переводы",
"Погашение кредита",
"Подписки",
"Покупки",
"Продукты",
"Путешествия",
"Развлечения",
"Связь и интернет",
"Снятие наличных",
"Ставки",
"Страхование",
"Табак",
"Такси",
"Транспорт",
"Хостинг и ИИ-сервисы",
"Цифровые покупки",
]) {
rerender(<BankOperationIcon categoryName={categoryName} kind="expense" />);
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
const path = container.querySelector("path")?.getAttribute("d");
expect(path).toBeTruthy();
expect(paths.has(path), categoryName).toBe(false);
paths.add(path);
}
});
@@ -24,7 +24,7 @@ function useBankPeriodPickerVm(props: BankPeriodPickerProps) {
onChange(next); onChange(next);
}; };
return { onChange, onPeriodChange, t, value }; return { onPeriodChange, t, value };
} }
export { useBankPeriodPickerVm }; export { useBankPeriodPickerVm };
@@ -47,7 +47,6 @@ const meta
error: false, error: false,
fetching: false, fetching: false,
loading: false, loading: false,
retry: () => {},
}, },
component: BankSummaryView, component: BankSummaryView,
parameters: { parameters: {
@@ -1,13 +1,13 @@
import type { CSSProperties, FC } from "react"; import type { CSSProperties, FC } from "react";
import { Button, Group, Skeleton, Stack, Text, Title } from "@mantine/core"; import { Skeleton, Stack, Text, Title } from "@mantine/core";
import { OperationIcon } from "@wealthledger/icons"; import { OperationIcon } from "@wealthledger/icons";
import { LoadingSkeleton } from "@wealthledger/ui"; import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatMoney } from "@/shared/lib"; import { formatMoney } from "@/shared/lib";
import type { BankSummary } from "../../api/queries"; import type { BankSummary } from "../../api/types";
import { bankOperationAppearance } from "../../model/operation-appearance"; import { bankOperationAppearance } from "../../model/operation-appearance";
import classes from "./BankSummary.module.css"; import classes from "./BankSummary.module.css";
@@ -20,7 +20,6 @@ type BankSummaryViewProps = {
error: boolean; error: boolean;
fetching: boolean; fetching: boolean;
loading: boolean; loading: boolean;
retry: () => void;
}; };
const BankSummaryViewCategories: FC<BankSummaryViewProps> = (props) => { const BankSummaryViewCategories: FC<BankSummaryViewProps> = (props) => {
@@ -78,6 +77,8 @@ const BankSummaryViewCategories: FC<BankSummaryViewProps> = (props) => {
</LoadingSkeleton> </LoadingSkeleton>
); );
} }
return null;
}; };
const BankSummaryView: FC<BankSummaryViewProps> = (props) => { const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
@@ -99,17 +100,7 @@ const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
<BankSummaryViewCategories {...props} /> <BankSummaryViewCategories {...props} />
{props.error && ( {props.error && !props.data && <Text role="alert">{t("bank.error")}</Text>}
<Group role="alert">
<Text>{t(props.data ? "bank.refreshError" : "bank.error")}</Text>
<Button onClick={props.retry} variant="default">
{t("bank.retry")}
</Button>
</Group>
)}
{props.data && props.fetching && <Text role="status">{t("bank.refreshing")}</Text>}
</Stack> </Stack>
); );
}; };
@@ -8,7 +8,7 @@ import {
waitFor, waitFor,
} from "@testing-library/react"; } from "@testing-library/react";
import { http, HttpResponse } from "msw"; import { http, HttpResponse } from "msw";
import { describe, expect, it } from "vitest"; import { assert, describe, expect, it } from "vitest";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
@@ -78,15 +78,16 @@ describe("CategoryManager", () => {
name: "Переименовать", name: "Переименовать",
}); });
assert.isDefined(renameButtons[0]);
fireEvent.click(renameButtons[0]); fireEvent.click(renameButtons[0]);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
fireEvent.click( fireEvent.click(
await screen.findByRole("button", { name: "Перечитать запись" }), await screen.findByRole("button", { name: "Перечитать запись" }),
); );
await waitFor(() => expect(reads).toBe(3)); await waitFor(() => expect(reads).toBe(3));
fireEvent.click( const secondRename = screen.getAllByRole("button", { name: "Переименовать" })[1];
screen.getAllByRole("button", { name: "Переименовать" })[1], assert.isDefined(secondRename);
); fireEvent.click(secondRename);
fireEvent.change(screen.getByLabelText(/Название категории/), { fireEvent.change(screen.getByLabelText(/Название категории/), {
target: { value: "Новая вторая" }, target: { value: "Новая вторая" },
}); });
@@ -1,6 +1,6 @@
import { import { Badge,
Badge,
Button, Button,
Fieldset,
Group, Group,
Modal, Modal,
Stack, Stack,
@@ -12,17 +12,14 @@ import { type FC } from "react";
import { FormProvider, useController, useFormContext } from "react-hook-form"; import { FormProvider, useController, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { CategoryValues } from "../../model/category.schema";
import { bankOperationAppearance } from "../../model/operation-appearance"; import { bankOperationAppearance } from "../../model/operation-appearance";
import { BankFeedback } from "../BankFeedback"; import { BankFeedback } from "../BankFeedback";
import classes from "../BankForms.module.css"; import {
import { useCategoryManagerVm } from "./CategoryManager.vm"; type CategoryManagerProps,
useCategoryManagerVm,
type CategoryManagerProps = { } from "./CategoryManager.vm";
onClose: () => void;
onCreated?: (id: string) => void;
};
type CategoryValues = { name: string };
const CategoryFields: FC = () => { const CategoryFields: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -76,7 +73,7 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
> >
<FormProvider {...methods}> <FormProvider {...methods}>
<form onSubmit={onSubmit}> <form onSubmit={onSubmit}>
<fieldset className={classes.fieldset} disabled={isPending}> <Fieldset disabled={isPending} variant="unstyled">
<Stack> <Stack>
{categoryRows?.map((category) => { {categoryRows?.map((category) => {
return ( return (
@@ -149,7 +146,7 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</fieldset> </Fieldset>
</form> </form>
</FormProvider> </FormProvider>
</Modal> </Modal>
@@ -5,9 +5,14 @@ import { useRef, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useBankMutations } from "../../api/mutations"; import type { ExpenseCategory } from "../../api/types";
import { type ExpenseCategory, useBankCategories } from "../../api/queries";
import { categorySchema } from "../../model/category.schema"; import {
useCreateExpenseCategory,
useUpdateExpenseCategory,
} from "../../api/mutations";
import { useBankCategories } from "../../api/queries";
import { categorySchema, type CategoryValues } from "../../model/category.schema";
import { useBankFormFocus } from "../useBankFormFocus"; import { useBankFormFocus } from "../useBankFormFocus";
type CategoryManagerProps = { type CategoryManagerProps = {
@@ -15,14 +20,13 @@ type CategoryManagerProps = {
onCreated?: (id: string) => void; onCreated?: (id: string) => void;
}; };
type CategoryValues = { name: string };
function useCategoryManagerVm(props: CategoryManagerProps) { function useCategoryManagerVm(props: CategoryManagerProps) {
useBankFormFocus(); useBankFormFocus();
const [opened, setOpened] = useState(true); const [opened, setOpened] = useState(true);
const { t } = useTranslation(); const { t } = useTranslation();
const categories = useBankCategories(); const categories = useBankCategories();
const mutation = useBankMutations(); const categoryCreation = useCreateExpenseCategory();
const updateCategory = useUpdateExpenseCategory();
const [editing, setEditing] = useState<ExpenseCategory>(); const [editing, setEditing] = useState<ExpenseCategory>();
const editSessionRef = useRef(0); const editSessionRef = useRef(0);
const [error, setError] = useState<unknown>(); const [error, setError] = useState<unknown>();
@@ -37,28 +41,32 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
const isPending = !opened || methods.formState.isSubmitting; const isPending = !opened || methods.formState.isSubmitting;
const onRequestClose = () => !isPending && setOpened(false); const onRequestClose = () => !isPending && setOpened(false);
const finishEditing = () => {
editSessionRef.current++;
setEditing(undefined);
setLatest(undefined);
methods.reset({ name: "" });
};
const onSubmit: ComponentProps<"form">["onSubmit"] = (event) => { const onSubmit: ComponentProps<"form">["onSubmit"] = (event) => {
void methods.handleSubmit(async (values) => { void methods.handleSubmit(async (values) => {
setError(undefined); setError(undefined);
try { try {
if (editing) if (editing)
await mutation.updateCategory.mutateAsync({ await updateCategory.mutateAsync({
body: { name: values.name, version: editing.version }, body: { name: values.name, version: editing.version },
id: editing.id, id: editing.id,
}); });
else { else {
const result = await mutation.createCategory.mutateAsync({ const result = await categoryCreation.mutateAsync({
name: values.name, name: values.name,
}); });
if (result) props.onCreated?.(result.id); if (result) props.onCreated?.(result.id);
} }
editSessionRef.current++; finishEditing();
setEditing(undefined);
setLatest(undefined);
methods.reset({ name: "" });
} }
catch (error_) { catch (error_) {
setError(error_); setError(error_);
@@ -84,10 +92,7 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
}; };
const onCancel = () => { const onCancel = () => {
editSessionRef.current++; finishEditing();
setEditing(undefined);
setLatest(undefined);
methods.reset({ name: "" });
setError(undefined); setError(undefined);
}; };
@@ -125,4 +130,4 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
}; };
} }
export { useCategoryManagerVm }; export { type CategoryManagerProps, useCategoryManagerVm };
@@ -0,0 +1,39 @@
import type { DateInput } from "@mantine/dates";
import type { ComponentProps } from "react";
import { bankDateInputValue } from "../model/form-validation";
import { bankDateSchema } from "../model/form.schema";
type DateField = {
onBlur: () => void;
onChange: (value: string) => void;
value: string;
};
function bankDateField(field: DateField) {
const dateParser: ComponentProps<typeof DateInput>["dateParser"] = (value) => {
const parsed = bankDateInputValue(value);
return bankDateSchema.safeParse(parsed).success ? parsed : null;
};
const onBlur: ComponentProps<typeof DateInput>["onBlur"] = (event) => {
field.onChange(bankDateInputValue(event.currentTarget.value));
field.onBlur();
};
const onInput: ComponentProps<typeof DateInput>["onInput"] = event =>
field.onChange(bankDateInputValue(event.currentTarget.value));
return {
dateParser,
onBlur,
onChange: (value: null | string) => field.onChange(value ?? ""),
onInput,
value: bankDateSchema.safeParse(field.value).success
? field.value
: undefined,
};
}
export { bankDateField };
+25 -21
View File
@@ -1,7 +1,15 @@
import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api"; import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
type BondCandles = NonNullable<paths["/api/v1/bonds/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
type BondPosition = NonNullable<paths["/api/v1/bonds/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
const MIN_BOND_SEARCH_LENGTH = 2; const MIN_BOND_SEARCH_LENGTH = 2;
type BondCoupons = NonNullable<paths["/api/v1/bonds/{uid}/coupons"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
type BondMetrics = NonNullable<paths["/api/v1/bonds/{uid}/metrics"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
type BondPeriod = NonNullable< type BondPeriod = NonNullable<
NonNullable< NonNullable<
paths["/api/v1/bonds/{uid}/candles"]["get"]["parameters"]["query"] paths["/api/v1/bonds/{uid}/candles"]["get"]["parameters"]["query"]
@@ -10,35 +18,28 @@ type BondPeriod = NonNullable<
const BOND_STALE_TIME_MS = 30_000; const BOND_STALE_TIME_MS = 30_000;
function bondQueryOptions(uid: string) {
return {
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}", {
params: { path: { uid } },
}),
staleTime: BOND_STALE_TIME_MS,
};
}
function useBond(identifier: string) { function useBond(identifier: string) {
const isUid const isUid
= /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
identifier, identifier,
); );
const byUid = useResourceQuery({ // eslint-disable-next-line unicorn/prefer-minimal-ternary -- Hoisting params breaks the generated union of endpoint request shapes.
...bondQueryOptions(identifier), const init = isUid
enabled: Boolean(identifier) && isUid, ? { params: { path: { uid: identifier } } }
}); : { params: { path: { ticker: identifier } } };
const byTicker = useResourceQuery({ const options = apiQueryClient.queryOptions(
...apiQueryClient.queryOptions("get", "/api/v1/bonds/by-ticker/{ticker}", { "get",
params: { path: { ticker: identifier } }, isUid ? "/api/v1/bonds/{uid}" : "/api/v1/bonds/by-ticker/{ticker}",
}), init,
enabled: Boolean(identifier) && !isUid, );
return useResourceQuery({
...options,
enabled: Boolean(identifier),
staleTime: BOND_STALE_TIME_MS, staleTime: BOND_STALE_TIME_MS,
}); });
return isUid ? byUid : byTicker;
} }
function useBondCandles(uid: string, period: BondPeriod) { function useBondCandles(uid: string, period: BondPeriod) {
@@ -104,7 +105,10 @@ function useBondSearch(query: string) {
} }
export { export {
type BondPeriod, type BondCandles,
type BondCoupons,
type BondMetrics,
type BondPosition,
MIN_BOND_SEARCH_LENGTH, MIN_BOND_SEARCH_LENGTH,
useBond, useBond,
useBondCandles, useBondCandles,
+4 -1
View File
@@ -1,5 +1,8 @@
export { export {
type BondPeriod, type BondCandles,
type BondCoupons,
type BondMetrics,
type BondPosition,
MIN_BOND_SEARCH_LENGTH, MIN_BOND_SEARCH_LENGTH,
useBond, useBond,
useBondCandles, useBondCandles,
@@ -68,6 +68,7 @@ function useBrokerageOperations(
export { export {
type BrokerageOperation, type BrokerageOperation,
type BrokerageOperationsQuery,
brokerageOperationsQueryOptions, brokerageOperationsQueryOptions,
brokerageOperationsQueryParameters as brokerageOperationsQueryParams, brokerageOperationsQueryParameters as brokerageOperationsQueryParams,
operationCategories, operationCategories,
+1
View File
@@ -5,4 +5,5 @@ export {
type OperationCategory, type OperationCategory,
useBrokerageOperations, useBrokerageOperations,
} from "./api/useBrokerageOperations"; } from "./api/useBrokerageOperations";
export { useBrokerageOperationHistory } from "./model/useBrokerageOperationHistory";
export { LedgerEntryRow } from "./ui/LedgerEntryRow"; export { LedgerEntryRow } from "./ui/LedgerEntryRow";
@@ -0,0 +1,80 @@
import { hashKey, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
import {
type BrokerageOperationsQuery,
brokerageOperationsQueryOptions,
useBrokerageOperations,
} from "../api/useBrokerageOperations";
function useBrokerageOperationHistory(
accountId: string | undefined,
firstPage: Omit<BrokerageOperationsQuery, "cursor">,
) {
const { queryKey } = brokerageOperationsQueryOptions(accountId, firstPage);
const scope = hashKey(queryKey);
const [pagination, setPagination] = useState<{
cursors: string[];
scope: string;
}>({ cursors: [], scope });
const cursors = pagination.scope === scope ? pagination.cursors : [];
const client = useQueryClient();
const operations = useBrokerageOperations(accountId, {
...firstPage,
cursor: cursors.at(-1),
});
const isInvalidCursor = isApiErrorCode(
apiErrorCode(operations.error),
ApiErrorCode.InvalidOperationsCursor,
);
const isRecoveringInvalidCursor = cursors.length > 0 && isInvalidCursor;
const reset = () => setPagination({ cursors: [], scope });
useEffect(() => {
if (!isRecoveringInvalidCursor) return;
const timeout = setTimeout(() => {
setPagination({ cursors: [], scope });
void client.invalidateQueries({ exact: true, queryKey });
}, 0);
return () => clearTimeout(timeout);
}, [client, isRecoveringInvalidCursor, queryKey, scope]);
const retry = (retryScreen: () => unknown) => {
if (isInvalidCursor) {
reset();
void client.invalidateQueries({
exact: true,
queryKey,
});
}
else void retryScreen();
};
return {
hasPrevious: cursors.length > 0,
isRecoveringInvalidCursor,
onNext: () => {
if (operations.data?.nextCursor)
setPagination({
cursors: [...cursors, operations.data.nextCursor],
scope,
});
},
onPrevious: () => setPagination({ cursors: cursors.slice(0, -1), scope }),
operations,
reset,
retry,
};
}
export { useBrokerageOperationHistory };
@@ -19,6 +19,7 @@ import { operationLabelKey, paymentPresentation } from "../model/display";
type LedgerEntryRowProps = { type LedgerEntryRowProps = {
operation: BrokerageOperation; operation: BrokerageOperation;
withDetails?: boolean; withDetails?: boolean;
wrap?: boolean;
}; };
const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => { const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
@@ -84,6 +85,7 @@ const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
tone={payment.tone} tone={payment.tone}
value={payment.amount} value={payment.amount}
valueLabel={t(payment.label)} valueLabel={t(payment.label)}
wrap={props.wrap}
/> />
); );
}; };
@@ -1,85 +0,0 @@
import { renderHook, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { useBrokerageAccounts } from "./useBrokerageAccounts";
describe("useBrokerageAccounts", () => {
it("loads accounts through the authenticated query seam", async () => {
configureAuthClient({
accessToken: () => "token",
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
server.use(
http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json(
resource([
{
id: "account-1",
name: "Основной",
openedOn: "2024-08-13",
type: "BROKERAGE",
},
]),
),
),
);
const { result } = renderHook(() => useBrokerageAccounts(), {
wrapper: createQueryWrapper().wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([
{
id: "account-1",
name: "Основной",
openedOn: "2024-08-13",
type: "BROKERAGE",
},
]);
});
it("keeps the current connection snapshot after a background refetch error", async () => {
configureAuthClient({
accessToken: () => "token",
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
let calls = 0;
server.use(
http.get("*/api/v1/brokerage-accounts", () => {
calls += 1;
return calls === 1
? HttpResponse.json(
resource([
{
id: "account-1",
name: "Основной",
openedOn: "2024-08-13",
type: "BROKERAGE",
},
]),
)
: HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
}),
);
const { result } = renderHook(() => useBrokerageAccounts(), {
wrapper: createQueryWrapper().wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
void result.current.refetch();
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.data?.[0].id).toBe("account-1");
});
});
@@ -1,6 +1,7 @@
import type { InvestmentResourceMeta } from "@/shared/api";
import type { import type {
BrokerageAccount, BrokerageAccount,
useBrokerageAccounts,
} from "../api/useBrokerageAccounts"; } from "../api/useBrokerageAccounts";
const VISIBLE_CURRENCY_LIMIT = 2; const VISIBLE_CURRENCY_LIMIT = 2;
@@ -9,11 +10,7 @@ type BrokerageAccountCard = {
account: BrokerageAccount; account: BrokerageAccount;
positionCounts: null positionCounts: null
| { bonds: number; stocks: number }; | { bonds: number; stocks: number };
source?: NonNullable< source?: NonNullable<InvestmentResourceMeta["dependencies"]>[number];
ReturnType<typeof useBrokerageAccounts>["meta"]
>["dependencies"] extends (infer T)[] | undefined
? T
: never;
summary: summary:
| { remainingCurrencyCount: number; state: "available"; totals: Money[] } | { remainingCurrencyCount: number; state: "available"; totals: Money[] }
| { state: "empty" } | { state: "empty" }
@@ -30,9 +27,7 @@ function accountTypeKey(type: BrokerageAccount["type"]) {
function brokerageAccountCards( function brokerageAccountCards(
accounts: BrokerageAccount[], accounts: BrokerageAccount[],
dependencies: NonNullable< dependencies: InvestmentResourceMeta["dependencies"] = [],
ReturnType<typeof useBrokerageAccounts>["meta"]
>["dependencies"] = [],
): BrokerageAccountCard[] { ): BrokerageAccountCard[] {
return accounts.map((account) => { return accounts.map((account) => {
const source = dependencies.find( const source = dependencies.find(
@@ -210,7 +210,6 @@ export const IndependentFreshness: Story
freshness: "stale", freshness: "stale",
lastSuccessfulAt: "2026-09-24T10:30:00Z", lastSuccessfulAt: "2026-09-24T10:30:00Z",
resourceKey: "portfolio/storybook-account", resourceKey: "portfolio/storybook-account",
stateVersion: "failed",
sync: { sync: {
errorCode: "TBANK_UNAVAILABLE", errorCode: "TBANK_UNAVAILABLE",
id: "failed", id: "failed",
@@ -223,7 +222,6 @@ export const IndependentFreshness: Story
freshness: "unknown", freshness: "unknown",
lastSuccessfulAt: null, lastSuccessfulAt: null,
resourceKey: "portfolio/waiting", resourceKey: "portfolio/waiting",
stateVersion: "waiting",
sync: { errorCode: null, id: "waiting", state: "running" }, sync: { errorCode: null, id: "waiting", state: "running" },
}, },
], ],
@@ -5,7 +5,8 @@ import { WalletIcon } from "@wealthledger/icons";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib"; import { formatDate, formatMoney } from "@/shared/lib";
import { SourceSyncStatus } from "@/shared/ui";
import type { BrokerageAccountCard } from "../../model/display"; import type { BrokerageAccountCard } from "../../model/display";
@@ -53,6 +54,38 @@ const BrokerageAccountSummary: FC<BrokerageAccountSummaryProps> = (props) => {
); );
}; };
type BrokerageAccountSourceStatusProps = {
source: BrokerageAccountCard["source"];
};
const BrokerageAccountSourceStatus: FC<BrokerageAccountSourceStatusProps> = (
props,
) => {
const { source } = props;
const { t } = useTranslation();
const isSyncing = source?.sync.state === "running";
const errorText
= source?.sync.state === "failed" ? t("accounts.refreshError") : null;
return (
<>
<SourceSyncStatus
errorText={errorText}
lastSuccessfulAt={source?.lastSuccessfulAt}
stale={source?.freshness === "stale"}
/>
{isSyncing && (
<Text role="status" size="xs">
{t("accounts.refreshing")}
</Text>
)}
</>
);
};
const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => { const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
const { accounts } = props; const { accounts } = props;
const { t } = useTranslation(); const { t } = useTranslation();
@@ -90,24 +123,7 @@ const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
<BrokerageAccountSummary summary={summary} /> <BrokerageAccountSummary summary={summary} />
{!!source?.lastSuccessfulAt && ( <BrokerageAccountSourceStatus source={source} />
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(source.lastSuccessfulAt),
})}
</Text>
)}
{(source?.sync.state === "running"
|| source?.sync.state === "queued") && (
<Text role="status" size="xs">
{t("accounts.refreshing")}
</Text>
)}
{source?.sync.state === "failed" && <Text size="xs">{t("accounts.refreshError")}</Text>}
{source?.freshness === "stale" && <Text size="xs">{t("investment.stale")}</Text>}
</Stack> </Stack>
{!!positionCounts && ( {!!positionCounts && (
+1
View File
@@ -2,6 +2,7 @@ export {
type BrokerageEvent, type BrokerageEvent,
useBrokerageEvents, useBrokerageEvents,
} from "./api/useBrokerageEvents"; } from "./api/useBrokerageEvents";
export { brokerageEventKey } from "./model/event-key";
export { BrokerageEventCard } from "./ui/BrokerageEventCard"; export { BrokerageEventCard } from "./ui/BrokerageEventCard";
export { export {
BrokerageEventsPreview, BrokerageEventsPreview,
@@ -0,0 +1,10 @@
import type { BrokerageEvent } from "../api/useBrokerageEvents";
function brokerageEventKey(event: BrokerageEvent) {
return JSON.stringify([
event.account?.id, event.type, event.date, event.instrumentUid,
event.figi, event.ticker, event.logoName,
]);
}
export { brokerageEventKey };
@@ -34,7 +34,7 @@ const BrokerageEventCard: FC<BrokerageEventCardProps> = (props) => {
<EntryCard <EntryCard
imageSrc={instrumentLogoUrl(event.logoName)} imageSrc={instrumentLogoUrl(event.logoName)}
initial={( initial={(
(event.ticker ?? event.displayName).trim()[0] ?? "?" event.ticker.trim()[0] ?? "?"
).toUpperCase()} ).toUpperCase()}
metadata={metadata} metadata={metadata}
title={[t(`events.type.${event.type}`), event.displayName].join(" · ")} title={[t(`events.type.${event.type}`), event.displayName].join(" · ")}
@@ -1,14 +1,13 @@
import type { FC } from "react"; import type { FC } from "react";
import { Alert, Button, Skeleton, Stack, Text } from "@mantine/core"; import { Button, Skeleton, Stack, Text } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui"; import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import { formatSnapshotTime } from "@/shared/lib";
import type { BrokerageEvent } from "../../api/useBrokerageEvents"; import type { BrokerageEvent } from "../../api/useBrokerageEvents";
import { brokerageEventKey } from "../../model/event-key";
import { BrokerageEventCard } from "../BrokerageEventCard"; import { BrokerageEventCard } from "../BrokerageEventCard";
type BrokerageEventsPreviewProps = { type BrokerageEventsPreviewProps = {
@@ -18,9 +17,6 @@ type BrokerageEventsPreviewProps = {
type BrokerageEventsPreviewState type BrokerageEventsPreviewState
= | { = | {
items: BrokerageEvent[]; items: BrokerageEvent[];
lastSuccessfulAt?: null | string;
refreshError?: boolean;
stale?: boolean;
state: "loaded" | "partial"; state: "loaded" | "partial";
to: string; to: string;
} }
@@ -34,29 +30,13 @@ const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
return ( return (
<Stack gap="sm"> <Stack gap="sm">
{events.state === "loaded" && (
<>
{!!events.lastSuccessfulAt && (
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(events.lastSuccessfulAt),
})}
</Text>
)}
{!!events.stale && <Text size="xs">{t("investment.stale")}</Text>}
{!!events.refreshError && <Text role="status">{t("accounts.refreshError")}</Text>}
</>
)}
{events.state === "loading" && ( {events.state === "loading" && (
<LoadingSkeleton label={t("events.recentLoading")}> <LoadingSkeleton label={t("events.recentLoading")}>
{["first", "second", "third"].map(placeholder => <Skeleton height={48} key={placeholder} radius="sm" />)} {["first", "second", "third"].map(placeholder => <Skeleton height={48} key={placeholder} radius="sm" />)}
</LoadingSkeleton> </LoadingSkeleton>
)} )}
{events.state === "error" && <Alert color="red">{t("events.recentError")}</Alert>} {events.state === "error" && <Text c="dimmed">{t("states.unavailableText")}</Text>}
{events.state === "empty" && <Text c="dimmed">{t("events.recentEmpty")}</Text>} {events.state === "empty" && <Text c="dimmed">{t("events.recentEmpty")}</Text>}
@@ -66,15 +46,7 @@ const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
&& events.items.map(event => ( && events.items.map(event => (
<BrokerageEventCard <BrokerageEventCard
event={event} event={event}
key={JSON.stringify([ key={brokerageEventKey(event)}
event.account?.id,
event.type,
event.date,
event.instrumentUid,
event.figi,
event.ticker,
event.logoName,
])}
/> />
))} ))}
+4
View File
@@ -4,6 +4,9 @@ type IncomeAnalytics = NonNullable<
paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["data"] paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["data"]
>; >;
type IncomeAnalyticsMeta
= paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["meta"];
type IncomeSummary = NonNullable< type IncomeSummary = NonNullable<
paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"]["data"] paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"]["data"]
>; >;
@@ -44,6 +47,7 @@ function useIncomeSummary(accountId?: string) {
export { export {
type IncomeAnalytics, type IncomeAnalytics,
type IncomeAnalyticsMeta,
type IncomeSummary, type IncomeSummary,
useIncomeAnalytics, useIncomeAnalytics,
useIncomeSummary, useIncomeSummary,
+1
View File
@@ -1,5 +1,6 @@
export { export {
type IncomeAnalytics, type IncomeAnalytics,
type IncomeAnalyticsMeta,
type IncomeSummary, type IncomeSummary,
useIncomeAnalytics, useIncomeAnalytics,
useIncomeSummary, useIncomeSummary,
@@ -1,66 +0,0 @@
import { renderHook, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { usePortfolio } from "./usePortfolio";
describe("usePortfolio", () => {
it("loads the account portfolio", async () => {
configureAuthClient({
accessToken: () => "token",
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
const { result } = renderHook(() => usePortfolio("account-1"), {
wrapper: createQueryWrapper().wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toMatchObject({ positions: [] });
});
it("keeps the current connection snapshot after a background refetch error", async () => {
configureAuthClient({
accessToken: () => "token",
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
let calls = 0;
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
calls += 1;
return calls === 1
? HttpResponse.json(
resource({
cashBalances: [],
isStale: false,
lastSuccessfulAt: "2026-08-14T00:00:00Z",
positions: [],
structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency: [],
}),
)
: HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
}),
);
const { result } = renderHook(() => usePortfolio("account-1"), {
wrapper: createQueryWrapper().wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
void result.current.refetch();
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.data).toMatchObject({
lastSuccessfulAt: "2026-08-14T00:00:00Z",
});
});
});
+22 -40
View File
@@ -1,13 +1,17 @@
import { import {
apiQueryClient, apiQueryClient,
type paths, type paths,
unwrapResourceResult,
useAuthenticatedQueries,
useResourceQuery, useResourceQuery,
} from "@/shared/api"; } from "@/shared/api";
type StockCandles = NonNullable<paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
type StockPosition = NonNullable<paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
const MIN_STOCK_SEARCH_LENGTH = 2; const MIN_STOCK_SEARCH_LENGTH = 2;
type StockDividends = NonNullable<paths["/api/v1/stocks/{uid}/dividends"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
type StockPeriod = NonNullable< type StockPeriod = NonNullable<
NonNullable< NonNullable<
paths["/api/v1/stocks/{uid}/candles"]["get"]["parameters"]["query"] paths["/api/v1/stocks/{uid}/candles"]["get"]["parameters"]["query"]
@@ -16,35 +20,28 @@ type StockPeriod = NonNullable<
const STOCK_STALE_TIME_MS = 30_000; const STOCK_STALE_TIME_MS = 30_000;
function stockQueryOptions(uid: string) {
return {
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}", {
params: { path: { uid } },
}),
staleTime: STOCK_STALE_TIME_MS,
};
}
function useStock(identifier: string) { function useStock(identifier: string) {
const isUid const isUid
= /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
identifier, identifier,
); );
const byUid = useResourceQuery({ // eslint-disable-next-line unicorn/prefer-minimal-ternary -- Hoisting params breaks the generated union of endpoint request shapes.
...stockQueryOptions(identifier), const init = isUid
enabled: Boolean(identifier) && isUid, ? { params: { path: { uid: identifier } } }
}); : { params: { path: { ticker: identifier } } };
const byTicker = useResourceQuery({ const options = apiQueryClient.queryOptions(
...apiQueryClient.queryOptions("get", "/api/v1/stocks/by-ticker/{ticker}", { "get",
params: { path: { ticker: identifier } }, isUid ? "/api/v1/stocks/{uid}" : "/api/v1/stocks/by-ticker/{ticker}",
}), init,
enabled: Boolean(identifier) && !isUid, );
return useResourceQuery({
...options,
enabled: Boolean(identifier),
staleTime: STOCK_STALE_TIME_MS, staleTime: STOCK_STALE_TIME_MS,
}); });
return isUid ? byUid : byTicker;
} }
function useStockCandles(uid: string, period: StockPeriod) { function useStockCandles(uid: string, period: StockPeriod) {
@@ -99,22 +96,6 @@ function useStockQuote(uid: string) {
}); });
} }
function useStocks(uids: string[]) {
const uniqueUids = [...new Set(uids)];
const queries = useAuthenticatedQueries(
uniqueUids.map(uniqueUid => stockQueryOptions(uniqueUid)),
);
return new Map(
queries.map((query, index) => [
// eslint-disable-next-line security/detect-object-injection -- This is a positional index into the local array, not a user-supplied object key.
uniqueUids[index],
unwrapResourceResult(query).data,
]),
);
}
function useStockSearch(query: string) { function useStockSearch(query: string) {
return useResourceQuery({ return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks", { ...apiQueryClient.queryOptions("get", "/api/v1/stocks", {
@@ -127,13 +108,14 @@ function useStockSearch(query: string) {
export { export {
MIN_STOCK_SEARCH_LENGTH, MIN_STOCK_SEARCH_LENGTH,
type StockPeriod, type StockCandles,
type StockDividends,
type StockPosition,
useStock, useStock,
useStockCandles, useStockCandles,
useStockCompany, useStockCompany,
useStockDividends, useStockDividends,
useStockPosition, useStockPosition,
useStockQuote, useStockQuote,
useStocks,
useStockSearch, useStockSearch,
}; };
+3 -2
View File
@@ -1,12 +1,13 @@
export { export {
MIN_STOCK_SEARCH_LENGTH, MIN_STOCK_SEARCH_LENGTH,
type StockPeriod, type StockCandles,
type StockDividends,
type StockPosition,
useStock, useStock,
useStockCandles, useStockCandles,
useStockCompany, useStockCompany,
useStockDividends, useStockDividends,
useStockPosition, useStockPosition,
useStockQuote, useStockQuote,
useStocks,
useStockSearch, useStockSearch,
} from "./api/queries"; } from "./api/queries";
+1 -2
View File
@@ -3,12 +3,11 @@ import { createContext, use } from "react";
import type { paths } from "@/shared/api"; import type { paths } from "@/shared/api";
type AuthContextValue = { type AuthContextValue = {
accessToken: string | undefined;
login: (email: string, password: string) => Promise<void>; login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
ready: boolean; ready: boolean;
refresh: () => Promise<boolean>;
register: (email: string, password: string) => Promise<void>; register: (email: string, password: string) => Promise<void>;
signedOut: boolean;
user: undefined | User; user: undefined | User;
}; };
+36 -10
View File
@@ -27,6 +27,32 @@ function createAuthWrapper(
); );
} }
async function expireSession() {
server.use(http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }),
{ once: true }));
try {
await apiClient.GET("/api/v1/brokerage-accounts");
}
catch (error) {
// A refresh that changes identity cancels the original user's request.
if (!(error instanceof DOMException && error.name === "AbortError")) throw error;
}
}
async function requestAuthorization() {
let authorization: null | string = null;
server.use(http.get("*/api/v1/brokerage-accounts", ({ request }) => {
authorization = request.headers.get("Authorization");
return HttpResponse.json(resource([]));
}, { once: true }));
await apiClient.GET("/api/v1/brokerage-accounts");
return authorization;
}
describe("AuthProvider", () => { describe("AuthProvider", () => {
it("authorizes requests after restoring the initial session", async () => { it("authorizes requests after restoring the initial session", async () => {
let authorization: null | string let authorization: null | string
@@ -77,7 +103,7 @@ describe("AuthProvider", () => {
await waitFor(() => expect(result.current.ready).toBe(true)); await waitFor(() => expect(result.current.ready).toBe(true));
expect(result.current.user).toBeUndefined(); expect(result.current.user).toBeUndefined();
expect(result.current.accessToken).toBeUndefined(); expect(await requestAuthorization()).toBeNull();
expect(queryClient.getQueryData(["cached-query"])).toBeUndefined(); expect(queryClient.getQueryData(["cached-query"])).toBeUndefined();
expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined(); expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined();
}); });
@@ -106,13 +132,13 @@ describe("AuthProvider", () => {
const { result } = renderHook(() => useAuth(), { wrapper }); const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => await waitFor(() =>
expect(result.current.accessToken).toBe("restored-token"), expect(result.current.ready).toBe(true),
); );
const response = await apiClient.GET("/api/v1/brokerage-accounts"); const response = await apiClient.GET("/api/v1/brokerage-accounts");
expect(response.response.status).toBe(401); expect(response.response.status).toBe(401);
await waitFor(() => expect(result.current.user).toBeUndefined()); await waitFor(() => expect(result.current.user).toBeUndefined());
expect(result.current.accessToken).toBeUndefined(); expect(await requestAuthorization()).toBeNull();
expect(queryClient.getQueryData(["cached-query"])).toBeUndefined(); expect(queryClient.getQueryData(["cached-query"])).toBeUndefined();
expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined(); expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined();
}); });
@@ -147,7 +173,7 @@ describe("AuthProvider", () => {
queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]); queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]);
const wrapper: FC<PropsWithChildren> = createAuthWrapper(queryClient); const wrapper: FC<PropsWithChildren> = createAuthWrapper(queryClient);
const { result } = renderHook(() => useAuth(), { wrapper }); const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.accessToken).toBe("test-token")); await waitFor(() => expect(result.current.ready).toBe(true));
expect(queryClient.getQueryData(bankCardsKey)).toEqual([ expect(queryClient.getQueryData(bankCardsKey)).toEqual([
{ id: "bank-card-1" }, { id: "bank-card-1" },
@@ -155,7 +181,7 @@ describe("AuthProvider", () => {
await act(() => result.current.logout()); await act(() => result.current.logout());
expect(result.current.user).toBeUndefined(); expect(result.current.user).toBeUndefined();
expect(result.current.accessToken).toBeUndefined(); expect(await requestAuthorization()).toBeNull();
expect(queryClient.getQueryData(statusKey)).toBeUndefined(); expect(queryClient.getQueryData(statusKey)).toBeUndefined();
expect(queryClient.getQueryData(portfolioKey)).toBeUndefined(); expect(queryClient.getQueryData(portfolioKey)).toBeUndefined();
expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined(); expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined();
@@ -265,7 +291,7 @@ describe("AuthProvider", () => {
const { result } = renderHook(() => useAuth(), { wrapper }); const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.ready).toBe(true)); await waitFor(() => expect(result.current.ready).toBe(true));
queryClient.setQueryData(bankCardsKey, [{ id: "a-card" }]); queryClient.setQueryData(bankCardsKey, [{ id: "a-card" }]);
await act(() => result.current.refresh()); await act(() => expireSession());
expect(queryClient.getQueryData(bankCardsKey)).toEqual([{ id: "a-card" }]); expect(queryClient.getQueryData(bankCardsKey)).toEqual([{ id: "a-card" }]);
let release!: (value: string) => void; let release!: (value: string) => void;
@@ -283,7 +309,7 @@ describe("AuthProvider", () => {
.catch(() => {}); .catch(() => {});
identity = "b"; identity = "b";
await act(() => result.current.refresh()); await act(() => expireSession());
release("a-data"); release("a-data");
await pending; await pending;
expect(result.current.user?.id).toBe("b"); expect(result.current.user?.id).toBe("b");
@@ -319,12 +345,12 @@ describe("AuthProvider", () => {
); );
await waitFor(() => await waitFor(() =>
expect(result.current.cards.data?.[0].id).toBe("a-card"), expect(result.current.cards.data?.[0]?.id).toBe("a-card"),
); );
identity = "b"; identity = "b";
await act(() => result.current.auth.refresh()); await act(() => expireSession());
await waitFor(() => await waitFor(() =>
expect(result.current.cards.data?.[0].id).toBe("b-card"), expect(result.current.cards.data?.[0]?.id).toBe("b-card"),
); );
}); });
}); });
+7 -13
View File
@@ -29,23 +29,19 @@ async function request(
function useAuthProviderVm() { function useAuthProviderVm() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [user, setUser] = useState<undefined | User>(undefined); const [user, setUser] = useState<undefined | User>(undefined);
const [accessToken, setAccessToken] = useState<string | undefined>(undefined);
const accessTokenRef = useRef<string | undefined>(undefined); const accessTokenRef = useRef<string | undefined>(undefined);
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const [signedOut, setSignedOut] = useState(false);
const generationRef = useRef(0); const generationRef = useRef(0);
const userRef = useRef<undefined | User>(undefined); const userRef = useRef<undefined | User>(undefined);
const authQueueRef = useRef<Promise<unknown>>(Promise.resolve()); const authQueueRef = useRef<Promise<unknown>>(Promise.resolve());
const refreshPromiseRef = useRef<Promise<boolean> | undefined>(undefined); const refreshPromiseRef = useRef<Promise<boolean> | undefined>(undefined);
// Cookie-writing requests share ordering, including across tabs with Web Locks. // Order cookie-writing requests within this tab. Other tabs may reauthenticate.
const serialize = useCallback(<T>(run: () => Promise<T>): Promise<T> => { const serialize = useCallback(<T>(run: () => Promise<T>): Promise<T> => {
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering. // eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
const next = authQueueRef.current.then(async () => const next = authQueueRef.current.then(run);
"locks" in navigator
? navigator.locks.request("wealth-ledger-refresh", run)
: run(),
);
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering. // eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
authQueueRef.current = next.catch(() => {}); authQueueRef.current = next.catch(() => {});
@@ -58,6 +54,7 @@ function useAuthProviderVm() {
const changedIdentity const changedIdentity
= userRef.current && userRef.current.id !== authentication.user.id; = userRef.current && userRef.current.id !== authentication.user.id;
setSignedOut(false);
accessTokenRef.current = authentication.accessToken; accessTokenRef.current = authentication.accessToken;
if (changedIdentity) { if (changedIdentity) {
@@ -69,7 +66,6 @@ function useAuthProviderVm() {
userRef.current = authentication.user; userRef.current = authentication.user;
setUser(authentication.user); setUser(authentication.user);
setAccessToken(authentication.accessToken);
}, },
[queryClient], [queryClient],
); );
@@ -78,7 +74,6 @@ function useAuthProviderVm() {
generationRef.current += 1; generationRef.current += 1;
userRef.current = undefined; userRef.current = undefined;
setUser(undefined); setUser(undefined);
setAccessToken(undefined);
accessTokenRef.current = undefined; accessTokenRef.current = undefined;
queryClient.clear(); queryClient.clear();
}, [queryClient]); }, [queryClient]);
@@ -111,8 +106,7 @@ function useAuthProviderVm() {
} }
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering. // eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
}).finally(() => { }).finally(() => {
if (refreshPromiseRef.current === pending) refreshPromiseRef.current = undefined;
refreshPromiseRef.current = undefined;
}); });
refreshPromiseRef.current = pending; refreshPromiseRef.current = pending;
@@ -139,6 +133,7 @@ function useAuthProviderVm() {
} }
async function logout() { async function logout() {
setSignedOut(true);
clearSession(); clearSession();
await serialize(async () => { await serialize(async () => {
await apiClient.POST("/api/v1/auth/logout"); await apiClient.POST("/api/v1/auth/logout");
@@ -156,14 +151,13 @@ function useAuthProviderVm() {
useEffect(() => () => configureAuthClient(), []); useEffect(() => () => configureAuthClient(), []);
const authContextValue = { const authContextValue = {
accessToken,
login: (email: string, password: string) => login: (email: string, password: string) =>
authenticate("/api/v1/auth/login", email, password), authenticate("/api/v1/auth/login", email, password),
logout, logout,
ready, ready,
refresh,
register: (email: string, password: string) => register: (email: string, password: string) =>
authenticate("/api/v1/auth/register", email, password), authenticate("/api/v1/auth/register", email, password),
signedOut,
user, user,
}; };
@@ -1,4 +0,0 @@
.fieldset {
border: 0;
padding: 0;
}
+10 -11
View File
@@ -1,9 +1,9 @@
import type { FC } from "react"; import type { FC } from "react";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core"; import { Alert, Button, Fieldset, PasswordInput, Stack, TextInput } from "@mantine/core";
import { useState } from "react"; import { useState } from "react";
import { FormProvider, type Resolver, useForm } from "react-hook-form"; import { FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
@@ -12,7 +12,6 @@ import {
loginSchema, loginSchema,
registerSchema, registerSchema,
} from "../../model/auth.schema"; } from "../../model/auth.schema";
import classes from "./AuthForm.module.css";
type AuthFormProps = { type AuthFormProps = {
onSubmit: (email: string, password: string) => Promise<void>; onSubmit: (email: string, password: string) => Promise<void>;
@@ -25,7 +24,7 @@ const AuthForm: FC<AuthFormProps> = (props) => {
const schema = register ? registerSchema : loginSchema; const schema = register ? registerSchema : loginSchema;
const methods = useForm<AuthFormValues>({ const methods = useForm<AuthFormValues>({
resolver: zodResolver(schema) as Resolver<AuthFormValues>, resolver: zodResolver(schema),
}); });
const { const {
@@ -34,7 +33,7 @@ const AuthForm: FC<AuthFormProps> = (props) => {
const [submitError, setSubmitError] = useState<null | string>(null); const [submitError, setSubmitError] = useState<null | string>(null);
const onSubmit2 = methods.handleSubmit(async (values) => { const submit = methods.handleSubmit(async (values) => {
setSubmitError(null); setSubmitError(null);
try { try {
@@ -49,26 +48,26 @@ const AuthForm: FC<AuthFormProps> = (props) => {
return ( return (
<FormProvider {...methods}> <FormProvider {...methods}>
<form onSubmit={onSubmit2}> <form onSubmit={submit}>
<fieldset className={classes.fieldset} disabled={isSubmitting}> <Fieldset disabled={isSubmitting} variant="unstyled">
<Stack> <Stack>
<TextInput <TextInput
label={t("auth.email")} label={t("auth.email")}
{...methods.register("email")} {...methods.register("email")}
error={errors.email?.message as string} error={errors.email?.message}
/> />
<PasswordInput <PasswordInput
label={t("auth.password")} label={t("auth.password")}
{...methods.register("password")} {...methods.register("password")}
error={errors.password?.message as string} error={errors.password?.message}
/> />
{register && ( {register && (
<PasswordInput <PasswordInput
label={t("auth.passwordConfirmation")} label={t("auth.passwordConfirmation")}
{...methods.register("passwordConfirmation")} {...methods.register("passwordConfirmation")}
error={errors.passwordConfirmation?.message as string} error={errors.passwordConfirmation?.message}
/> />
)} )}
@@ -82,7 +81,7 @@ const AuthForm: FC<AuthFormProps> = (props) => {
{t(register ? "auth.haveAccount" : "auth.noAccount")} {t(register ? "auth.haveAccount" : "auth.noAccount")}
</Button> </Button>
</Stack> </Stack>
</fieldset> </Fieldset>
</form> </form>
</FormProvider> </FormProvider>
); );
+3 -3
View File
@@ -1,12 +1,12 @@
import type { FC } from "react"; import type { FC } from "react";
import { useAuth } from "../../model/auth-context";
import { AuthForm } from "../AuthForm/AuthForm"; import { AuthForm } from "../AuthForm/AuthForm";
import { useLoginFormVm } from "./LoginForm.vm";
const LoginForm: FC = () => { const LoginForm: FC = () => {
const { onSubmit } = useLoginFormVm(); const { login } = useAuth();
return <AuthForm onSubmit={onSubmit} />; return <AuthForm onSubmit={login} />;
}; };
export { LoginForm }; export { LoginForm };
@@ -1,28 +0,0 @@
import type { ComponentProps } from "react";
import { useLocation, useNavigate } from "react-router";
import { useAuth } from "../../model/auth-context";
import { AuthForm } from "../AuthForm/AuthForm";
function useLoginFormVm() {
const { login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const returnTo
= (location.state as null
| { returnTo?: string })?.returnTo ?? "/accounts";
const onSubmit: ComponentProps<typeof AuthForm>["onSubmit"] = async (
email,
password,
) => {
await login(email, password);
await navigate(returnTo, { replace: true });
};
return { onSubmit };
}
export { useLoginFormVm };
@@ -1,12 +1,12 @@
import type { FC } from "react"; import type { FC } from "react";
import { useAuth } from "../../model/auth-context";
import { AuthForm } from "../AuthForm/AuthForm"; import { AuthForm } from "../AuthForm/AuthForm";
import { useRegisterFormVm } from "./RegisterForm.vm";
const RegisterForm: FC = () => { const RegisterForm: FC = () => {
const { onSubmit } = useRegisterFormVm(); const { register } = useAuth();
return <AuthForm onSubmit={onSubmit} register />; return <AuthForm onSubmit={register} register />;
}; };
export { RegisterForm }; export { RegisterForm };
@@ -1,28 +0,0 @@
import type { ComponentProps } from "react";
import { useLocation, useNavigate } from "react-router";
import { useAuth } from "../../model/auth-context";
import { AuthForm } from "../AuthForm/AuthForm";
function useRegisterFormVm() {
const { register } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const returnTo
= (location.state as null
| { returnTo?: string })?.returnTo ?? "/accounts";
const onSubmit: ComponentProps<typeof AuthForm>["onSubmit"] = async (
email,
password,
) => {
await register(email, password);
await navigate(returnTo, { replace: true });
};
return { onSubmit, register };
}
export { useRegisterFormVm };
+13 -2
View File
@@ -1,6 +1,17 @@
export { section } from "./model/section"; export { toInstrumentEventRows } from "./model/operation-rows";
export { useInstrumentOperations } from "./model/useInstrumentOperations"; export { section, type SectionState } from "./model/section";
export { useInstrumentParameters } from "./model/useInstrumentParameters";
export { InstrumentChart } from "./ui/InstrumentChart/InstrumentChart.view"; export { InstrumentChart } from "./ui/InstrumentChart/InstrumentChart.view";
export { InstrumentEvents } from "./ui/InstrumentEvents.view"; export { InstrumentEvents } from "./ui/InstrumentEvents.view";
export { InstrumentHeader } from "./ui/InstrumentHeader/InstrumentHeader.view";
export {
InstrumentMetrics,
InstrumentPage,
InstrumentPrice,
InstrumentTerms,
} from "./ui/InstrumentLayout.view";
export { InstrumentMetric } from "./ui/InstrumentMetric/InstrumentMetric.view";
export { InstrumentPageState } from "./ui/InstrumentPageState.view";
export { InstrumentPosition } from "./ui/InstrumentPosition.view"; export { InstrumentPosition } from "./ui/InstrumentPosition.view";
export { InstrumentQuoteStatus } from "./ui/InstrumentQuoteStatus.view";
export { InstrumentSection } from "./ui/InstrumentSection.view"; export { InstrumentSection } from "./ui/InstrumentSection.view";
@@ -0,0 +1,44 @@
import { expect, it } from "vitest";
import type { BrokerageOperation } from "@/entities/broker-operation";
import { toInstrumentEventRows } from "./operation-rows";
const operation: BrokerageOperation = {
accruedInterest: null,
category: "trade",
commission: null,
description: "Покупка",
figi: null,
id: "first",
instrumentType: "stock",
instrumentUid: "uid",
logoName: null,
occurredAt: "2026-01-01T00:00:00Z",
payment: null,
price: null,
quantity: null,
ticker: null,
type: "OPERATION_TYPE_BUY",
};
it("fills only missing identity without changing rows or their order", () => {
const items = [operation, {
...operation,
id: "second",
logoName: "",
ticker: "own",
}];
const original = structuredClone(items);
expect(toInstrumentEventRows(items, {
logoName: "fallback.png",
ticker: "fallback",
})).toEqual([
{ ...operation, logoName: "fallback.png", ticker: "fallback" },
items[1],
]);
expect(items).toEqual(original);
expect(toInstrumentEventRows(items)).toEqual(items);
expect(toInstrumentEventRows()).toEqual([]);
});
@@ -0,0 +1,14 @@
import type { BrokerageOperation } from "@/entities/broker-operation";
function toInstrumentEventRows(
items: BrokerageOperation[] = [],
instrument?: Pick<BrokerageOperation, "logoName" | "ticker">,
): BrokerageOperation[] {
return items.map(item => ({
...item,
logoName: item.logoName ?? instrument?.logoName ?? null,
ticker: item.ticker ?? instrument?.ticker ?? null,
}));
}
export { toInstrumentEventRows };
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it } from "vitest";
import { resource } from "@/test/resource"; import { resource } from "@/test/resource";
@@ -13,9 +13,7 @@ describe("instrument source and transport presentation", () => {
const state = section({ const state = section({
data, data,
isError: false, isError: false,
isFetching: false,
meta: resource(data, { availability }).meta, meta: resource(data, { availability }).meta,
refetch: vi.fn(),
}); });
expect(state.loading).toBe(availability === "missing"); expect(state.loading).toBe(availability === "missing");
@@ -23,39 +21,40 @@ describe("instrument source and transport presentation", () => {
expect(state.hasData).toBe(availability !== "missing"); expect(state.hasData).toBe(availability !== "missing");
}, },
); );
it("preserves a full fresh source and timestamp when force fails, while offering scoped retry", () => { it("preserves empty successful data instead of treating it as missing", () => {
const retry = vi.fn(); const state = section({
const refetch = vi.fn(); data: [],
isError: false,
meta: resource([]).meta,
});
expect(state).toMatchObject({
error: false,
hasData: true,
loading: false,
});
});
it("preserves a full source when force fails", () => {
const meta = resource([], { const meta = resource([], {
sync: { errorCode: "TBANK_UNAVAILABLE", id: "work", state: "failed" }, sync: { errorCode: "TBANK_UNAVAILABLE", id: "work", state: "failed" },
}).meta; }).meta;
const state = section( const state = section({ data: [], isError: false, meta });
{ data: [], isError: false, isFetching: false, meta, refetch },
retry,
);
expect(state).toMatchObject({ expect(state).toMatchObject({
error: true, error: true,
hasData: true, hasData: true,
lastSuccessfulAt: meta.lastSuccessfulAt,
loading: false, loading: false,
refreshing: false,
}); });
state.retry();
expect(retry).toHaveBeenCalledOnce();
expect(refetch).not.toHaveBeenCalled();
}); });
it("shows source failure without a false loading or empty result", () => { it("shows source failure without a false loading or empty result", () => {
const state = section({ const state = section({
data: undefined, data: undefined,
isError: false, isError: false,
isFetching: false,
meta: resource(null, { meta: resource(null, {
sync: { errorCode: "TBANK_UNAVAILABLE", id: "work", state: "failed" }, sync: { errorCode: "TBANK_UNAVAILABLE", id: "work", state: "failed" },
}).meta, }).meta,
refetch: vi.fn(),
}); });
expect(state).toMatchObject({ expect(state).toMatchObject({
@@ -1,48 +1,29 @@
import type { paths } from "@/shared/api"; import type { InvestmentResourceMeta } from "@/shared/api";
type Meta
= paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number];
type SectionState = { type SectionState = {
error: boolean; error: boolean;
hasData: boolean; hasData: boolean;
lastSuccessfulAt?: null | string;
loading: boolean; loading: boolean;
partial?: boolean; partial?: boolean;
refreshing: boolean;
retry: () => void;
sourceError?: boolean;
stale?: boolean;
}; };
function section( function hasSectionData(data: unknown) {
query: { return data !== undefined;
data: unknown; }
isError: boolean;
isFetching: boolean; function section(query: {
meta?: Meta; data: unknown;
refetch: () => unknown; isError: boolean;
}, meta?: InvestmentResourceMeta;
retry?: () => unknown, }): SectionState {
): SectionState {
const isError = query.isError || query.meta?.sync.state === "failed"; const isError = query.isError || query.meta?.sync.state === "failed";
const hasData = hasSectionData(query.data);
return { return {
error: isError, error: isError,
hasData: Boolean(query.data), hasData,
lastSuccessfulAt: query.meta?.lastSuccessfulAt, loading: !hasData && !isError,
loading: !query.data && !isError,
partial: query.meta?.availability === "partial", partial: query.meta?.availability === "partial",
refreshing:
Boolean(query.data)
&& (query.isFetching
|| query.meta?.sync.state === "running"
|| query.meta?.sync.state === "queued"),
retry: () => {
void (retry ?? query.refetch)();
},
sourceError: query.meta?.sync.state === "failed",
stale: query.meta?.freshness === "stale",
}; };
} }
+9 -12
View File
@@ -1,23 +1,22 @@
import type { BondCandles, BondPosition } from "@/entities/bond";
import type { BrokerageOperation } from "@/entities/broker-operation"; import type { BrokerageOperation } from "@/entities/broker-operation";
import type { paths } from "@/shared/api"; import type { StockCandles, StockPosition } from "@/entities/stock";
import type { SectionState } from "./section"; import type { SectionState } from "./section";
type Candles = NonNullable< type Candles = Pick<BondCandles | StockCandles,
paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"] "candles"
| "currency"
| "historyFrom"
>; >;
type ChartRow = Candles["candles"][number] & { date: string; price: number }; type InstrumentChartProps = { priceUnit?: "percent"; vm: InstrumentChartVm };
type InstrumentChartVm = { type InstrumentChartVm = {
averageLine: null | number;
candles?: Candles; candles?: Candles;
candlesState: SectionState; candlesState: SectionState;
chartData: ChartRow[];
hasIncompleteCandle: boolean;
period: string; period: string;
position?: Position; position?: Position;
priceRows: ChartRow[];
setPeriod: (value: string) => void; setPeriod: (value: string) => void;
}; };
@@ -37,8 +36,6 @@ type InstrumentPositionVm = {
positionState: SectionState; positionState: SectionState;
}; };
type Position = NonNullable< type Position = BondPosition | StockPosition;
paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]
>;
export type { InstrumentChartVm, InstrumentEventsVm, InstrumentPositionVm }; export type { InstrumentChartProps, InstrumentEventsVm, InstrumentPositionVm };
@@ -1,63 +0,0 @@
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import {
brokerageOperationsQueryOptions,
type OperationCategory,
useBrokerageOperations,
} from "@/entities/broker-operation";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
function useInstrumentOperations(
uid: string,
filter: string,
categories: OperationCategory[],
) {
const scope = `${uid}:${filter}`;
const [pagination, setPagination] = useState<{
cursors: string[];
scope: string;
}>({ cursors: [], scope });
const cursors = pagination.scope === scope ? pagination.cursors : [];
const client = useQueryClient();
const firstPage = { categories, instrumentUid: uid, limit: 20 };
const operations = useBrokerageOperations(uid ? "all" : undefined, {
...firstPage,
cursor: cursors.at(-1),
});
const retry = (retryScreen: () => unknown) => {
if (
isApiErrorCode(
apiErrorCode(operations.error),
ApiErrorCode.InvalidOperationsCursor,
)
) {
setPagination({ cursors: [], scope });
void client.invalidateQueries({
exact: true,
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey,
});
}
else void retryScreen();
};
return {
hasPrevious: cursors.length > 0,
onNext: () => {
if (operations.data?.nextCursor)
setPagination({
cursors: [...cursors, operations.data.nextCursor],
scope,
});
},
onPrevious: () => setPagination({ cursors: cursors.slice(0, -1), scope }),
operations,
retry,
};
}
export { useInstrumentOperations };
@@ -0,0 +1,43 @@
import { useSearchParams } from "react-router";
import type { OperationCategory } from "@/entities/broker-operation";
const instrumentPeriods = ["month", "halfYear", "year", "all"] as const;
function useInstrumentParameters(incomeFilter: "coupons" | "dividends") {
const [parameters, setParameters] = useSearchParams();
const period = instrumentPeriods.find(
value => value === parameters.get("period"),
) ?? "month";
const defaultFilter: { categories: OperationCategory[]; value: string } = { categories: [], value: "all" };
const filters = [
defaultFilter,
{ categories: ["trade"], value: "trades" },
{ categories: ["income"], value: incomeFilter },
{ categories: ["tax", "fee", "transfer", "other"], value: "other" },
] satisfies { categories: OperationCategory[]; value: string }[];
const { categories, value: filter } = filters.find(
item => item.value === parameters.get("events"),
) ?? defaultFilter;
const setParameter = (key: string, value: string) => {
const next = new URLSearchParams(parameters);
next.set(key, value);
setParameters(next, { replace: true });
};
return {
categories,
filter,
period,
search: parameters.size > 0 ? `?${parameters.toString()}` : "",
setFilter: (value: string) => setParameter("events", value),
setPeriod: (value: string) => setParameter("period", value),
};
}
export { instrumentPeriods, useInstrumentParameters };
@@ -10,20 +10,19 @@ import {
Text, Text,
} from "@mantine/core"; } from "@mantine/core";
import { formatMoney } from "@/shared/lib"; import { formatChartValue, formatMoney } from "@/shared/lib";
import type { InstrumentChartVm } from "../../model/types"; import type { InstrumentChartProps } from "../../model/types";
import classes from "../InstrumentDetails.module.css"; import classes from "../InstrumentDetails.module.css";
import { InstrumentSection } from "../InstrumentSection.view"; import { InstrumentSection } from "../InstrumentSection.view";
import { useInstrumentChartVm } from "./InstrumentChart.vm"; import { useInstrumentChartVm } from "./InstrumentChart.vm";
type InstrumentChartProps = { priceUnit?: "percent"; vm: InstrumentChartVm };
const InstrumentChart: FC<InstrumentChartProps> = (props) => { const InstrumentChart: FC<InstrumentChartProps> = (props) => {
const { const {
chartData,
chartLabel, chartLabel,
formatChartValue, hasIncompleteCandle,
periodOptions, periodOptions,
priceRows, priceRows,
referenceLines, referenceLines,
@@ -34,14 +33,14 @@ const InstrumentChart: FC<InstrumentChartProps> = (props) => {
let chartContent; let chartContent;
if (vm.candles) { if (vm.candles) {
if (vm.chartData.length > 0) { if (chartData.length > 0) {
chartContent = ( chartContent = (
<> <>
<AreaChart <AreaChart
areaProps={{ isAnimationActive: false }} areaProps={{ isAnimationActive: false }}
aria-label={chartLabel} aria-label={chartLabel}
curveType="linear" curveType="linear"
data={vm.chartData} data={chartData}
dataKey="date" dataKey="date"
gridAxis="y" gridAxis="y"
h={310} h={310}
@@ -52,7 +51,7 @@ const InstrumentChart: FC<InstrumentChartProps> = (props) => {
tickLine="none" tickLine="none"
tooltipAnimationDuration={0} tooltipAnimationDuration={0}
valueFormatter={formatChartValue} valueFormatter={formatChartValue}
withDots={vm.chartData.length === 1} withDots={chartData.length === 1}
withGradient withGradient
yAxisProps={{ domain: ["auto", "auto"], width: 64 }} yAxisProps={{ domain: ["auto", "auto"], width: 64 }}
/> />
@@ -65,13 +64,13 @@ const InstrumentChart: FC<InstrumentChartProps> = (props) => {
)} )}
</Text> </Text>
{!!vm.hasIncompleteCandle && ( {!!hasIncompleteCandle && (
<Text c="dimmed" size="xs"> <Text c="dimmed" size="xs">
{t("stock.incomplete")} {t("stock.incomplete")}
</Text> </Text>
)} )}
{vm.averageLine !== null && ( {referenceLines.length > 0 && (
<Text mt="xs" size="sm"> <Text mt="xs" size="sm">
{t("stock.averageLine")}:{" "} {t("stock.averageLine")}:{" "}
{formatMoney(vm.position?.averagePurchasePrice)} {formatMoney(vm.position?.averagePurchasePrice)}
@@ -88,7 +87,7 @@ const InstrumentChart: FC<InstrumentChartProps> = (props) => {
<Text fw={600}>{t("stock.priceTable")}</Text> <Text fw={600}>{t("stock.priceTable")}</Text>
<Text c="dimmed" size="xs"> <Text c="dimmed" size="xs">
{t("stock.priceTableHint", { count: vm.chartData.length })} {t("stock.priceTableHint", { count: chartData.length })}
</Text> </Text>
</Accordion.Control> </Accordion.Control>
@@ -1,16 +1,29 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatChartValue, formatDecimal, formatMoney } from "@/shared/lib"; import { formatDate, formatDecimal, formatMoney } from "@/shared/lib";
import type { InstrumentChartVm } from "../../model/types"; import type { InstrumentChartProps } from "../../model/types";
type InstrumentChartProps = { priceUnit?: "percent"; vm: InstrumentChartVm }; import { instrumentPeriods } from "../../model/useInstrumentParameters";
function useInstrumentChartVm(props: InstrumentChartProps) { function useInstrumentChartVm(props: InstrumentChartProps) {
const { vm } = props; const { vm } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const priceRows = vm.priceRows.map((candle) => { const chartData = vm.candles?.candles.map(candle => ({
...candle,
date: formatDate(candle.time),
price: Number(candle.close),
})) ?? [];
const average = vm.position?.averagePurchasePrice;
const averageLine = average && props.priceUnit !== "percent"
&& average.currency === vm.candles?.currency
? Number(average.value)
: null;
const priceRows = chartData.toReversed().map((candle) => {
const currency = vm.candles?.currency; const currency = vm.candles?.currency;
const value = candle.close; const value = candle.close;
@@ -23,7 +36,7 @@ function useInstrumentChartVm(props: InstrumentChartProps) {
return { ...candle, price }; return { ...candle, price };
}); });
const periodOptions = ["month", "halfYear", "year", "all"].map(value => ({ const periodOptions = instrumentPeriods.map(value => ({
label: t(`stock.periods.${value}`), label: t(`stock.periods.${value}`),
value, value,
})); }));
@@ -39,20 +52,21 @@ function useInstrumentChartVm(props: InstrumentChartProps) {
); );
const referenceLines const referenceLines
= vm.averageLine === null = averageLine === null || !Number.isFinite(averageLine)
? [] ? []
: [ : [
{ {
color: "orange.7", color: "orange.7",
ifOverflow: "extendDomain" as const, ifOverflow: "extendDomain" as const,
label: t("stock.averageLine"), label: t("stock.averageLine"),
y: vm.averageLine, y: averageLine,
}, },
]; ];
return { return {
chartData,
chartLabel, chartLabel,
formatChartValue, hasIncompleteCandle: chartData.some(candle => !candle.isComplete),
periodOptions, periodOptions,
priceRows, priceRows,
referenceLines, referenceLines,

Some files were not shown because too many files have changed in this diff Show More