Compare commits

..
23 Commits
Author SHA1 Message Date
ksv741 c7d8d2c8f9 ci: validate release PR commits independently (WealthLedger/main#30)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend image / publish (push) Successful in 6m45s
Frontend verify / verify (push) Successful in 6m7s
Frontend verify / commits (pull_request) Successful in 37s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m58s
2026-10-12 23:39:30 +03:00
ksv741 9d900b331a chore(release): 1.1.0
Frontend verify / commits (pull_request) Failing after 42s
Frontend verify / security (pull_request) Skipped
Frontend verify / verify (pull_request) Skipped
2026-10-12 23:19:12 +03:00
codex-bot b357743c10 Merge pull request 'chore: align frontend engineering workflow (WealthLedger/main#27)' (#66) from chore/27-engineering-workflow into develop
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 7m9s
2026-10-12 11:00:35 +03:00
ksv741 0d747cc93e docs: pin frontend workflow reference (WealthLedger/main#27)
Frontend verify / commits (pull_request) Successful in 39s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 6m7s
2026-10-12 10:22:13 +03:00
ksv741 c315f80b1b fix: align frontend workflow rules (WealthLedger/main#27)
Frontend verify / commits (pull_request) Successful in 48s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Canceled after 1m36s
2026-10-12 10:19:36 +03:00
ksv741 617edfd489 chore: align frontend engineering workflow (WealthLedger/main#27)
Frontend verify / commits (pull_request) Successful in 35s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Failing after 3m11s
2026-10-12 09:20:13 +03:00
codex-bot 9ad3245efe Merge pull request 'docs: clarify frontend verification policy' (#65) from codex/issue-24-process into develop
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 6m56s
2026-10-11 23:23:10 +03:00
ksv741 cac4351bc4 docs: clarify frontend verification policy
Frontend verify / commits (pull_request) Successful in 41s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Successful in 6m34s
2026-10-11 23:06:51 +03:00
codex-bot 17f5bae69b feat: add stock screener
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 12s
Frontend verify / verify (push) Successful in 6m39s
2026-10-11 11:50:39 +03:00
ksv741 c8821200ce ci: skip checks on PR metadata edits
Frontend verify / commits (pull_request) Successful in 34s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m37s
2026-10-11 11:36:51 +03:00
ksv741 cec9c6348a fix: preserve latest stock screener result
Frontend verify / commits (pull_request) Successful in 35s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m47s
2026-10-11 11:11:58 +03:00
ksv741 a84411df39 feat: add stock screener 2026-10-11 11:11:58 +03:00
ksv741 3c226f8c29 feat: render hourly investment states
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 12s
Frontend verify / verify (push) Successful in 7m41s
2026-10-09 21:13:34 +03:00
ksv741 20b2cdfd43 fix: show bank summary totals 2026-10-09 21:13:34 +03:00
codex-bot dcd1e3c278 Merge pull request 'feat: align investment client with hourly sync' (#50) from release/1.0.0 into develop
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Successful in 7m32s
Merge hourly sync frontend client
2026-10-09 09:19:49 +03:00
ksv741 432cca299f feat: align investment client with hourly sync
Frontend verify / commits (pull_request) Successful in 40s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 6m43s
2026-10-09 09:04:45 +03:00
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
124 changed files with 3260 additions and 2774 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.
+28 -14
View File
@@ -7,11 +7,19 @@
checkout. Внешний API задаётся предоставленным публичным контрактом; checkout. Внешний API задаётся предоставленным публичным контрактом;
исходники или инструкции его реализации не являются зависимостью разработки. исходники или инструкции его реализации не являются зависимостью разработки.
При нехватке требования сообщи, какое решение не определено, до его реализации. При нехватке требования сообщи, какое решение не определено, до его реализации.
При конфликте с общим шаблоном следуй местным правилам и явному запросу
пользователя. Выбирай реализацию самостоятельно, но изменение финансовой
логики, публичного контракта или утверждённой семантики возвращай владельцу
на согласование до зависимой работы.
Начни с результата и affected areas. До edits проследи flow/invariants, найди Начни с результата и affected areas. До edits проследи flow/invariants, найди
owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт. owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт.
Работай напрямую; OpenSpec/SDD lifecycle — только по явному запросу. Выбери lightweight или SDD по [Engineering Workflow](https://git.personal.ksv741.ru/WealthLedger/main/src/commit/03b3b59e6ff2ba391f9dfc42a039a96d0d84c1c9/.ai/conventions/engineering-workflow.md);
Перед Node-командами активируй местную `.nvmrc`: `nvm use --silent`. OpenSpec lifecycle начинай после явного решения Change Owner.
Перед Node-командами активируй местную `.nvmrc`: если `nvm` не загружен в
non-interactive shell, загрузи установленный `$HOME/.nvm/nvm.sh`; затем
выполни `nvm use --silent` из frontend. Если `nvm.sh` недоступен, остановись,
не используй системный Node.
Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы. Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы.
## Change scope ## Change scope
@@ -57,21 +65,27 @@ Peer-конфликт решай совместимыми версиями бе
## Проверки и завершение ## Проверки и завершение
Code/dependency change: `npm ci`, полный `npm run verify` и дополнительные | Когда | Что проверить |
risk checks из местной testing convention. Targeted tests ускоряют итерацию, | --- | --- |
но не заменяют финальный gate. `lint:fix`/format — edits, не proof. | Окружение новое, изменились manifest/lockfile или Node, установка сомнительна | `npm ci` до Node-проверок; одна правка кода переустановки не требует. |
Markdown-only: проверь новые/изменённые тексты, локальные ссылки/якоря, маршруты | Итерация | Адресные тесты, typecheck и lint по затронутому поведению и риску. При изменении поведения добавь/обнови содержательную регрессию, если существующая проверка не ловит её; для косметики не создавай искусственный тест. |
чтения и `git diff --check`; application build ради текста не нужен. | Итоговый code/dependency diff | Перед объявлением готовности и передачей к интеграции выполни один полный `npm run verify` на итоговых входах. Дополнительные risk checks из testing convention и явно утверждённые task checks обязательны; CI проверяет независимо. |
Повтори routing по staged/unstaged/new files. Запиши краткие команды, результаты | Только Markdown | Проверь тексты, ссылки/якоря, маршруты чтения и `git diff --check`; application build не нужен. При изменении verification-контура проверь его работу. |
и ограничения. Зелёный lint не заменяет semantic/security review.
Повтори routing по staged/unstaged/new files. В отчёте по задаче/PR запиши
проверенные входы, команду, exit code, результат и ограничения. Повторяй
проверку при изменении значимых входов (включая config, dependencies и
generated artifacts), сомнении в evidence или явном запросе пользователя.
`lint:fix`/format — edits, не proof; lint не заменяет semantic/security review.
## Git и релизы ## Git и релизы
Работа — в `codex/<task>` от актуального `develop`, затем PR в `develop`. Работа — в ветке `<type>/<issue-number>-<slug>` от актуального `develop`,
затем PR в `develop`; для cross-repo ссылки укажи owner/repo#number.
Сохраняй чужие изменения; перед commit просмотри staged paths и diff. Сохраняй чужие изменения; перед commit просмотри staged paths и diff.
Commits и PR titles — Conventional Commits; допустим `spec:`. Каждый commit Commits и PR titles — Conventional Commits с parser-recognized Issue reference (`fix: … (#12)`
содержит одну связанную задачу. При связанной issue добавь её номер в конец или `chore: … (WealthLedger/main#27)`); допустим `spec:`. Каждый commit
заголовка (`fix: … #12`); без issue номер не нужен. Root другого checkout содержит одну связанную задачу. Исключения release и правила ссылки — в
не управляет этим repo. Engineering Workflow из AGENTS. Root другого checkout не управляет этим repo.
Прямой push в `main`, force-push и перенос опубликованных тегов не входят в Прямой push в `main`, force-push и перенос опубликованных тегов не входят в
обычную работу. Перед release/hotfix прочитай местный README, раздел «Релизы». обычную работу. Перед release/hotfix прочитай местный README, раздел «Релизы».
+12
View File
@@ -0,0 +1,12 @@
---
name: Bug Report
about: Неверное поведение
---
## Проблема и шаги воспроизведения
## Факт / ожидание
## Acceptance Criteria
- [ ]
## Окружение / ссылки (если применимо)
+10
View File
@@ -0,0 +1,10 @@
---
name: Engineering Task
about: CI, тесты, документация, процесс и прочее
---
## Контекст и результат
## Scope / связанные Issues (если есть)
## Acceptance Criteria
- [ ]
+12
View File
@@ -0,0 +1,12 @@
---
name: Feature Request
about: Новое наблюдаемое поведение
---
## Зачем и какой результат
## Ожидаемое поведение
## Acceptance Criteria
- [ ]
## Зависимости / связанные Issues (если есть)
+12
View File
@@ -0,0 +1,12 @@
---
name: Refactoring / Technical Debt
about: Улучшение без нового поведения
---
## Проблема и результат
## Сохраняемые инварианты / границы
## Acceptance Criteria
- [ ]
## Зависимости / ссылки (если есть)
+11
View File
@@ -0,0 +1,11 @@
## Summary
## Linked Issue
Refs
## Changes
## Verification
- Команда / результат / ограничения:
## Breaking Changes (если есть)
+6 -2
View File
@@ -4,7 +4,7 @@ on:
push: push:
branches: [develop, main] branches: [develop, main]
pull_request: pull_request:
types: [opened, synchronize, reopened, edited] types: [opened, synchronize, reopened]
schedule: schedule:
- cron: '17 5 * * *' - cron: '17 5 * * *'
@@ -34,7 +34,11 @@ jobs:
HEAD_SHA: ${{ github.event.pull_request.head.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: | run: |
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA" if [[ "$PR_TITLE" =~ ^chore\(release\):\ [0-9]+\.[0-9]+\.[0-9]+$ ]]; then
npm run commitlint -- --strict --from "$HEAD_SHA^" --to "$HEAD_SHA"
else
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
fi
security: security:
needs: commits needs: commits
+3
View File
@@ -4,6 +4,9 @@
только его файлы и предоставленные публичные контракты. Перед работой прочитай только его файлы и предоставленные публичные контракты. Перед работой прочитай
[порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем [порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем
объединяй triggers задачи и путей; читай активированные темы до решения. объединяй triggers задачи и путей; читай активированные темы до решения.
До edits найди/создай подходящую Gitea Issue, проверь AC и ветку; чтение
допускается без Issue. Общий маршрут — [Engineering Workflow](https://git.personal.ksv741.ru/WealthLedger/main/src/commit/03b3b59e6ff2ba391f9dfc42a039a96d0d84c1c9/.ai/conventions/engineering-workflow.md)
(main#28; после merge — актуальная версия `develop`).
| Решение / сигнал | Читать | | Решение / сигнал | Читать |
| --- | --- | | --- | --- |
+94
View File
@@ -1,5 +1,99 @@
# Changelog # Changelog
## [1.1.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.0.0...v1.1.0) (2026-10-12)
### Features
* add stock screener ([a84411d](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/a84411df39dc131290e9f5b5d9313fed17cc629a))
* align investment client with hourly sync ([432cca2](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/432cca299f3a9a9e45b2697527369ee95f2d0adc))
* render hourly investment states ([3c226f8](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/3c226f8c29efceccb75af0d6424639a757534f39))
### Bug Fixes
* align frontend workflow rules (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/27)) ([c315f80](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c315f80b1b07ae0888366512ec157b8e29c81d62))
* preserve latest stock screener result ([cec9c63](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/cec9c6348a389f16f272dac5533aebc9d12ceae8))
* show bank summary totals ([20b2cdf](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/20b2cdfd432f8b01e447f68657cf5c685a297f1e))
### Documentation
* clarify frontend verification policy ([cac4351](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/cac4351bc4abf2cec7c5b6611a74d87bf4ec2f70))
* pin frontend workflow reference (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/27)) ([0d747cc](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/0d747cc93ee08c337cf300c0367612708f45dfb9))
### CI
* skip checks on PR metadata edits ([c882120](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c8821200ce699dab1ce65360d1968d5877a2e78b))
### Maintenance
* align frontend engineering workflow (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/27)) ([617edfd](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/617edfd489a6a84bb33b57386eca4eb54661f4f0))
## [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
+6 -3
View File
@@ -203,7 +203,8 @@ Checkout frontend содержит все входы сборки и прове
локальную копию и не обращаются к исходникам или CI сервера. Доставка новой локальную копию и не обращаются к исходникам или CI сервера. Доставка новой
схемы — явное обновление snapshot с review и `npm run openapi:generate`. схемы — явное обновление snapshot с review и `npm run openapi:generate`.
Runtime URL API задаётся окружением, а не расположением другого репозитория. Runtime URL API задаётся окружением, а не расположением другого репозитория.
Разработка идёт напрямую по задаче пользователя; OpenSpec/SDD — по явному запросу. Разработка начинается с Issue; lightweight или SDD выбирается по критериям
Engineering Workflow из AGENTS. OpenSpec — после явного решения Change Owner.
Knip исключает `conventional-changelog-conventionalcommits`: release-it загружает Knip исключает `conventional-changelog-conventionalcommits`: release-it загружает
preset по имени из `.release-it.json`. Версия preset закреплена напрямую. preset по имени из `.release-it.json`. Версия preset закреплена напрямую.
@@ -211,9 +212,11 @@ preset по имени из `.release-it.json`. Версия preset закреп
## Релизы ## Релизы
Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые
релизы. Работа: `codex/<task>` от актуального `develop`, PR в `develop`. релизы. Обычная работа: issue-linked ветка от актуального `develop`, PR в `develop`;
общие правила — в Engineering Workflow по ссылке из AGENTS.
Обычные PR допускают squash; release и обратная синхронизация сохраняют историю. Обычные PR допускают squash; release и обратная синхронизация сохраняют историю.
Commits и PR titles — Conventional Commits, включая `spec:`. Версии вручную не повышай. Commits и PR titles — Conventional Commits со ссылкой на Issue (кроме release),
включая `spec:`. Версии вручную не повышай.
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что 1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что
`develop` содержит последний релиз. Создай `release/<name>` от проверенного `develop` содержит последний релиз. Создай `release/<name>` от проверенного
+6
View File
@@ -1,6 +1,12 @@
module.exports = { module.exports = {
defaultIgnores: false,
extends: ["@commitlint/config-conventional"], extends: ["@commitlint/config-conventional"],
ignores: [
message => /^chore\(release\): \d+\.\d+\.\d+$/.test(message.trim()),
message => /^Merge pull request ['"].+['"] \(#\d+\) from .+ into .+$/.test(message.trim()),
],
rules: { rules: {
"references-empty": [2, "never"],
"type-enum": [ "type-enum": [
2, 2,
"always", "always",
+11 -5
View File
@@ -29,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/" },
@@ -48,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)/" },
}, },
{ {
+98 -8
View File
@@ -1,12 +1,12 @@
{ {
"name": "wealth-ledger-frontend", "name": "wealth-ledger-frontend",
"version": "0.4.0", "version": "1.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "wealth-ledger-frontend", "name": "wealth-ledger-frontend",
"version": "0.4.0", "version": "1.1.0",
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
], ],
@@ -16,6 +16,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",
@@ -2841,6 +2842,31 @@
"react": "^19.2.0" "react": "^19.2.0"
} }
}, },
"node_modules/@mantine/notifications": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-9.6.1.tgz",
"integrity": "sha512-6bikkVCAf6wd1KO/oVhBuXGDZ+HcWeNGCEvdgkL0pQ6jmjzd24ShW6UnbGu6aLABhYxQdfLnrE1cbOjC93Tj6A==",
"license": "MIT",
"dependencies": {
"@mantine/store": "9.6.1",
"react-transition-group": "4.4.5"
},
"peerDependencies": {
"@mantine/core": "9.6.1",
"@mantine/hooks": "9.6.1",
"react": "^19.2.0",
"react-dom": "^19.2.0"
}
},
"node_modules/@mantine/store": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@mantine/store/-/store-9.6.1.tgz",
"integrity": "sha512-KePeOIAg2n7wkHlxFxF7s6qYeD9EE/GrFIK3cc329nTSDIEIJQ+oFQv1S/t4sZJTIz7YnBlRuvA9Wc8pRg8TRA==",
"license": "MIT",
"peerDependencies": {
"react": "^19.2.0"
}
},
"node_modules/@mdx-js/react": { "node_modules/@mdx-js/react": {
"version": "3.1.1", "version": "3.1.1",
"integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==",
@@ -7275,7 +7301,6 @@
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-array": { "node_modules/d3-array": {
@@ -7749,6 +7774,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "17.4.2", "version": "17.4.2",
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
@@ -10715,7 +10750,6 @@
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
@@ -11363,6 +11397,18 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/loupe": { "node_modules/loupe": {
"version": "3.2.1", "version": "3.2.1",
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
@@ -11819,6 +11865,15 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
@@ -12536,6 +12591,23 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/prop-types/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/protocols": { "node_modules/protocols": {
"version": "2.0.2", "version": "2.0.2",
"integrity": "sha512-hHVTzba3wboROl0/aWRRG9dMytgH6ow//STBZh43l/wQgmMhYhOFi0EHWAPtoCz9IAUymsyP0TSBHkhgMEGNnQ==", "integrity": "sha512-hHVTzba3wboROl0/aWRRG9dMytgH6ow//STBZh43l/wQgmMhYhOFi0EHWAPtoCz9IAUymsyP0TSBHkhgMEGNnQ==",
@@ -13001,6 +13073,22 @@
} }
} }
}, },
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"license": "BSD-3-Clause",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/readable-stream": { "node_modules/readable-stream": {
"version": "3.6.2", "version": "3.6.2",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
@@ -13892,8 +13980,9 @@
} }
}, },
"node_modules/smol-toml": { "node_modules/smol-toml": {
"version": "1.8.0", "version": "1.9.0",
"integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==", "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.0.tgz",
"integrity": "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==",
"dev": true, "dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
@@ -13950,8 +14039,9 @@
} }
}, },
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.2",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"dev": true, "dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
+2 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "wealth-ledger-frontend", "name": "wealth-ledger-frontend",
"private": true, "private": true,
"version": "0.4.0", "version": "1.1.0",
"type": "module", "type": "module",
"engines": { "engines": {
"node": "24.21.0" "node": "24.21.0"
@@ -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",
+10 -6
View File
@@ -369,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",
@@ -390,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();
});
}); });
}); });
+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>
+2 -2
View File
@@ -147,7 +147,7 @@ describe("banking API", () => {
expect(url.searchParams.get("from")).toBe("2026-09-01"); expect(url.searchParams.get("from")).toBe("2026-09-01");
expect(url.searchParams.get("to")).toBe("2026-09-30"); 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,
@@ -299,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());
@@ -1,4 +1,5 @@
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";
@@ -71,7 +72,7 @@ function useBankCardFormVm(props: BankCardFormProps) {
openingDate: values.openingDate, openingDate: values.openingDate,
}; };
if (props.card) if (props.card) {
await updateCard.mutateAsync({ await updateCard.mutateAsync({
body: { body: {
...metadata, ...metadata,
@@ -80,12 +81,19 @@ function useBankCardFormVm(props: BankCardFormProps) {
}, },
id: props.card.id, id: props.card.id,
}); });
else }
else {
await cardCreation.mutateAsync({ 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_) {
@@ -15,6 +15,28 @@
min-width: 0; min-width: 0;
} }
.totals {
display: flex;
flex-wrap: wrap;
gap: var(--mantine-spacing-xs) var(--mantine-spacing-lg);
margin: 0;
}
.totals > div {
display: grid;
gap: 0;
}
.totals dt {
color: var(--mantine-color-dimmed);
font-size: var(--mantine-font-size-xs);
}
.totals dd {
margin: 0;
font-variant-numeric: tabular-nums;
}
.categories { .categories {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -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: {
@@ -0,0 +1,35 @@
import { MantineProvider } from "@mantine/core";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { BankSummaryView } from "./BankSummary.view";
const money = (value: string) => ({ currency: "RUB", value });
describe("BankSummaryView", () => {
it("shows expenses, refunds, and net monthly totals", () => {
render(
<MantineProvider env="test">
<BankSummaryView
data={{
cardId: "card-1",
categories: [],
expenses: money("1000"),
net: money("800"),
refunds: money("200"),
}}
error={false}
fetching={false}
loading={false}
/>
</MantineProvider>,
);
expect(screen.getAllByText("Расходы")).toHaveLength(2);
expect(screen.getByText("Возвращено")).toBeInTheDocument();
expect(screen.getByText("После возвратов")).toBeInTheDocument();
expect(screen.getByText("1 000,00 ₽")).toBeInTheDocument();
expect(screen.getByText("200,00 ₽")).toBeInTheDocument();
expect(screen.getByText("800,00 ₽")).toBeInTheDocument();
});
});
@@ -1,6 +1,6 @@
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";
@@ -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) => {
@@ -93,25 +92,37 @@ const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
</Title> </Title>
{props.data && ( {props.data && (
<Text className={classes.total}> <dl className={classes.totals}>
{formatMoney(props.data.net, { withZeroCents: true })} <div>
</Text> <dt>{t("bank.summary")}</dt>
<dd>
{formatMoney(props.data.expenses, { withZeroCents: true })}
</dd>
</div>
<div>
<dt>{t("bank.refunds")}</dt>
<dd>
{formatMoney(props.data.refunds, { withZeroCents: true })}
</dd>
</div>
<div>
<dt>{t("bank.net")}</dt>
<dd className={classes.total}>
{formatMoney(props.data.net, { withZeroCents: true })}
</dd>
</div>
</dl>
)} )}
</div> </div>
<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>
); );
}; };
@@ -1,5 +1,5 @@
import { hashKey, useQueryClient } from "@tanstack/react-query"; import { hashKey, useQueryClient } from "@tanstack/react-query";
import { useState } from "react"; import { useEffect, useState } from "react";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
@@ -29,15 +29,28 @@ function useBrokerageOperationHistory(
cursor: cursors.at(-1), cursor: cursors.at(-1),
}); });
const isInvalidCursor = isApiErrorCode(
apiErrorCode(operations.error),
ApiErrorCode.InvalidOperationsCursor,
);
const isRecoveringInvalidCursor = cursors.length > 0 && isInvalidCursor;
const reset = () => setPagination({ cursors: [], scope }); 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) => { const retry = (retryScreen: () => unknown) => {
if ( if (isInvalidCursor) {
isApiErrorCode(
apiErrorCode(operations.error),
ApiErrorCode.InvalidOperationsCursor,
)
) {
reset(); reset();
void client.invalidateQueries({ void client.invalidateQueries({
exact: true, exact: true,
@@ -49,6 +62,7 @@ function useBrokerageOperationHistory(
return { return {
hasPrevious: cursors.length > 0, hasPrevious: cursors.length > 0,
isRecoveringInvalidCursor,
onNext: () => { onNext: () => {
if (operations.data?.nextCursor) if (operations.data?.nextCursor)
setPagination({ setPagination({
@@ -4,17 +4,22 @@ const brokerageAccountsQueryOptions = () =>
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"); apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts");
const brokerageAccountsQueryKey = brokerageAccountsQueryOptions().queryKey; const brokerageAccountsQueryKey = brokerageAccountsQueryOptions().queryKey;
const BROKERAGE_ACCOUNTS_STALE_TIME_MS = 1_800_000;
type BrokerageAccount = NonNullable< type BrokerageAccount = NonNullable<
paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"]["data"] paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"]["data"]
>[number]; >[number];
function useBrokerageAccounts() { function useBrokerageAccounts() {
return useResourceQuery({ const query = useResourceQuery({
meta: { accessGroup: "brokerage" }, meta: { accessGroup: "brokerage" },
...brokerageAccountsQueryOptions(), ...brokerageAccountsQueryOptions(),
staleTime: 60_000, staleTime: BROKERAGE_ACCOUNTS_STALE_TIME_MS,
}); });
return {
...query,
};
} }
export { export {
@@ -205,24 +205,32 @@ export const IndependentFreshness: Story
], ],
[ [
{ {
availability: "complete", availability: "available",
completeness: "complete",
dataRevision: "1", dataRevision: "1",
freshness: "stale", freshness: "stale",
lastSuccessfulAt: "2026-09-24T10:30:00Z", lastAttempt: {
resourceKey: "portfolio/storybook-account", finishedAt: "2026-09-24T10:31:00Z",
sync: { problems: [],
errorCode: "TBANK_UNAVAILABLE", startedAt: "2026-09-24T10:30:00Z",
id: "failed",
state: "failed", state: "failed",
}, },
lastSuccessfulAt: "2026-09-24T10:30:00Z",
resourceKey: "portfolio/storybook-account",
}, },
{ {
availability: "missing", availability: "initial-loading",
completeness: "incomplete",
dataRevision: "0", dataRevision: "0",
freshness: "unknown", freshness: "unknown",
lastAttempt: {
finishedAt: null,
problems: [],
startedAt: "2026-09-24T10:30:00Z",
state: "running",
},
lastSuccessfulAt: null, lastSuccessfulAt: null,
resourceKey: "portfolio/waiting", resourceKey: "portfolio/waiting",
sync: { errorCode: null, id: "waiting", state: "running" },
}, },
], ],
), ),
@@ -64,15 +64,16 @@ const BrokerageAccountSourceStatus: FC<BrokerageAccountSourceStatusProps> = (
const { source } = props; const { source } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const isSyncing = source?.sync.state === "running"; const isSyncing = source?.lastAttempt.state === "running";
const errorText const errorText
= source?.sync.state === "failed" ? t("accounts.refreshError") : null; = source?.lastAttempt.state === "failed" ? t("accounts.refreshError") : null;
return ( return (
<> <>
<SourceSyncStatus <SourceSyncStatus
errorText={errorText} errorText={errorText}
lastAttempt={source?.lastAttempt}
lastSuccessfulAt={source?.lastSuccessfulAt} lastSuccessfulAt={source?.lastSuccessfulAt}
stale={source?.freshness === "stale"} stale={source?.freshness === "stale"}
/> />
@@ -1,12 +1,10 @@
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 { SourceSyncStatus } from "@/shared/ui";
import type { BrokerageEvent } from "../../api/useBrokerageEvents"; import type { BrokerageEvent } from "../../api/useBrokerageEvents";
import { brokerageEventKey } from "../../model/event-key"; import { brokerageEventKey } from "../../model/event-key";
@@ -19,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;
} }
@@ -33,28 +28,15 @@ const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
const { events } = props; const { events } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const errorText
= events.state === "loaded" && events.refreshError
? t("accounts.refreshError")
: null;
return ( return (
<Stack gap="sm"> <Stack gap="sm">
{events.state === "loaded" && (
<SourceSyncStatus
errorText={errorText}
lastSuccessfulAt={events.lastSuccessfulAt}
stale={events.stale}
/>
)}
{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>}
+2 -16
View File
@@ -6,10 +6,6 @@ import {
type StockCandles = NonNullable<paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"]>; 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;
type StockDividends = NonNullable<paths["/api/v1/stocks/{uid}/dividends"]["get"]["responses"][200]["content"]["application/json"]["data"]>; type StockDividends = NonNullable<paths["/api/v1/stocks/{uid}/dividends"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
type StockPeriod = NonNullable< type StockPeriod = NonNullable<
@@ -18,6 +14,8 @@ type StockPeriod = NonNullable<
>["period"] >["period"]
>; >;
type StockPosition = NonNullable<paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
const STOCK_STALE_TIME_MS = 30_000; const STOCK_STALE_TIME_MS = 30_000;
function useStock(identifier: string) { function useStock(identifier: string) {
@@ -96,18 +94,7 @@ function useStockQuote(uid: string) {
}); });
} }
function useStockSearch(query: string) {
return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks", {
params: { query: { query } },
}),
enabled: query.length >= MIN_STOCK_SEARCH_LENGTH,
staleTime: STOCK_STALE_TIME_MS,
});
}
export { export {
MIN_STOCK_SEARCH_LENGTH,
type StockCandles, type StockCandles,
type StockDividends, type StockDividends,
type StockPosition, type StockPosition,
@@ -117,5 +104,4 @@ export {
useStockDividends, useStockDividends,
useStockPosition, useStockPosition,
useStockQuote, useStockQuote,
useStockSearch,
}; };
-2
View File
@@ -1,5 +1,4 @@
export { export {
MIN_STOCK_SEARCH_LENGTH,
type StockCandles, type StockCandles,
type StockDividends, type StockDividends,
type StockPosition, type StockPosition,
@@ -9,5 +8,4 @@ export {
useStockDividends, useStockDividends,
useStockPosition, useStockPosition,
useStockQuote, useStockQuote,
useStockSearch,
} from "./api/queries"; } from "./api/queries";
@@ -1,61 +1,73 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it } from "vitest";
import { resource } from "@/test/resource"; import { resource } from "@/test/resource";
import { section } from "./section"; import { section } from "./section";
describe("instrument source and transport presentation", () => { describe("instrument source and transport presentation", () => {
it.each(["missing", "partial", "complete"] as const)( it.each([
["initial-loading", "incomplete"],
["available", "complete"],
] as const)(
"keeps %s separate from successful transport", "keeps %s separate from successful transport",
(availability) => { (availability, completeness) => {
const data = availability === "missing" ? undefined : { rows: [] }; const data = availability === "initial-loading" ? undefined : { rows: [] };
const state = section({ const state = section({
data, data,
isError: false, isError: false,
isFetching: false, meta: resource(data, { availability, completeness }).meta,
meta: resource(data, { availability }).meta,
refetch: vi.fn(),
}); });
expect(state.loading).toBe(availability === "missing"); expect(state.loading).toBe(availability === "initial-loading");
expect(state.partial).toBe(availability === "partial"); expect(state.partial).toBe(completeness === "incomplete");
expect(state.hasData).toBe(availability !== "missing"); expect(state.hasData).toBe(availability !== "initial-loading");
}, },
); );
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" }, lastAttempt: {
finishedAt: "2026-08-14T00:01:00Z",
problems: [],
startedAt: "2026-08-14T00:00:00Z",
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" }, lastAttempt: {
finishedAt: "2026-08-14T00:01:00Z",
problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed",
},
}).meta, }).meta,
refetch: vi.fn(),
}); });
expect(state).toMatchObject({ expect(state).toMatchObject({
@@ -3,43 +3,27 @@ import type { InvestmentResourceMeta } from "@/shared/api";
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?: InvestmentResourceMeta; data: unknown;
refetch: () => unknown; isError: boolean;
}, meta?: InvestmentResourceMeta;
retry?: () => unknown, }): SectionState {
): SectionState { const isError = query.isError || query.meta?.lastAttempt.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?.completeness === "incomplete",
partial: query.meta?.availability === "partial",
refreshing:
Boolean(query.data)
&& (query.isFetching
|| query.meta?.sync.state === "running"
),
retry: () => {
void (retry ?? query.refetch)();
},
sourceError: query.meta?.sync.state === "failed",
stale: query.meta?.freshness === "stale",
}; };
} }
@@ -86,7 +86,7 @@ const InstrumentEvents: FC<InstrumentEventsProps> = (props) => {
<Group justify="space-between" mt="md"> <Group justify="space-between" mt="md">
<Button <Button
disabled={!vm.hasPrevious || vm.operationsState.refreshing} disabled={!vm.hasPrevious}
onClick={vm.onPrevious} onClick={vm.onPrevious}
variant="default" variant="default"
> >
@@ -94,7 +94,7 @@ const InstrumentEvents: FC<InstrumentEventsProps> = (props) => {
</Button> </Button>
<Button <Button
disabled={!vm.operations.hasNext || vm.operationsState.refreshing} disabled={!vm.operations.hasNext}
onClick={vm.onNext} onClick={vm.onNext}
variant="default" variant="default"
> >
@@ -1,21 +1,17 @@
import type { FC, PropsWithChildren, ReactNode } from "react"; import type { FC, PropsWithChildren, ReactNode } from "react";
import { Avatar, Button, Group, Paper, Text, Title } from "@mantine/core"; import { Avatar, Group, Paper, Text, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import classes from "./InstrumentHeader.module.css"; import classes from "./InstrumentHeader.module.css";
type InstrumentHeaderProps = PropsWithChildren<{ type InstrumentHeaderProps = PropsWithChildren<{
logoSrc: null | string; logoSrc: null | string;
name: string; name: string;
onRefresh: () => void;
refreshing: boolean;
subtitle: ReactNode; subtitle: ReactNode;
}>; }>;
const InstrumentHeader: FC<InstrumentHeaderProps> = (props) => { const InstrumentHeader: FC<InstrumentHeaderProps> = (props) => {
const { children, logoSrc, name, onRefresh, refreshing, subtitle } = props; const { children, logoSrc, name, subtitle } = props;
const { t } = useTranslation();
return ( return (
<Paper className={classes.header} component="header" withBorder> <Paper className={classes.header} component="header" withBorder>
@@ -31,10 +27,6 @@ const InstrumentHeader: FC<InstrumentHeaderProps> = (props) => {
<Text c="dimmed" size="sm">{subtitle}</Text> <Text c="dimmed" size="sm">{subtitle}</Text>
</div> </div>
</div> </div>
<Button disabled={refreshing} onClick={onRefresh} variant="subtle">
{t("investment.refresh")}
</Button>
</Group> </Group>
{children} {children}
@@ -1,6 +1,6 @@
import type { FC } from "react"; import type { FC } from "react";
import { Anchor, Button, Skeleton, Stack, Text, Title } from "@mantine/core"; import { Anchor, Skeleton, Stack, Text, Title } 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";
@@ -11,11 +11,10 @@ type InstrumentPageStateProps = {
code?: string; code?: string;
kind: "bond" | "stock"; kind: "bond" | "stock";
loading: boolean; loading: boolean;
retry: () => void;
}; };
const InstrumentPageState: FC<InstrumentPageStateProps> = (props) => { const InstrumentPageState: FC<InstrumentPageStateProps> = (props) => {
const { code, kind, loading, retry } = props; const { code, kind, loading } = props;
const { t } = useTranslation(); const { t } = useTranslation();
if (loading) return ( if (loading) return (
<LoadingSkeleton label={t(`${kind}.loading`)}> <LoadingSkeleton label={t(`${kind}.loading`)}>
@@ -55,10 +54,6 @@ const InstrumentPageState: FC<InstrumentPageStateProps> = (props) => {
<Anchor component={Link} to="/settings"> <Anchor component={Link} to="/settings">
{t("accounts.openSettings")} {t("accounts.openSettings")}
</Anchor> </Anchor>
<Button onClick={retry} variant="default">
{t("accounts.retry")}
</Button>
</Stack> </Stack>
); );
}; };
@@ -2,7 +2,6 @@ import type { FC, PropsWithChildren, ReactNode } from "react";
import { import {
Alert, Alert,
Button,
Group, Group,
Paper, Paper,
Skeleton, Skeleton,
@@ -13,8 +12,6 @@ import {
import { LoadingSkeleton } from "@wealthledger/ui"; import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { SourceSyncStatus } from "@/shared/ui";
import type { SectionState } from "../model/section"; import type { SectionState } from "../model/section";
import classes from "./InstrumentDetails.module.css"; import classes from "./InstrumentDetails.module.css";
@@ -33,10 +30,6 @@ type InstrumentSectionProps = PropsWithChildren<{
const InstrumentSection: FC<InstrumentSectionProps> = (props) => { const InstrumentSection: FC<InstrumentSectionProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const readError = t(
props.state.hasData ? "accounts.refreshError" : "stock.sectionError",
);
const sectionClassName = props.embedded const sectionClassName = props.embedded
? classes.embeddedSection ? classes.embeddedSection
: classes.section; : classes.section;
@@ -80,35 +73,11 @@ const InstrumentSection: FC<InstrumentSectionProps> = (props) => {
{props.controls} {props.controls}
</Group> </Group>
<SourceSyncStatus
lastSuccessfulAt={props.state.lastSuccessfulAt}
stale={props.state.stale}
/>
{!!props.state.partial && <Text role="status">{t("investment.partial")}</Text>} {!!props.state.partial && <Text role="status">{t("investment.partial")}</Text>}
{!!props.state.refreshing && ( {!!(props.state.error && !props.state.hasData) && (
<Text c="dimmed" role="status" size="xs">
{t("accounts.refreshing")}
</Text>
)}
{!!props.state.error && (
<Alert aria-live="polite" color="orange" mb="sm" role="status"> <Alert aria-live="polite" color="orange" mb="sm" role="status">
<Group justify="space-between"> <Text size="sm">{t("stock.sectionError")}</Text>
<Text size="sm">
{props.state.sourceError ? t("investment.syncFailed") : readError}
</Text>
<Button
disabled={props.state.refreshing}
onClick={props.state.retry}
size="xs"
variant="default"
>
{t("accounts.retry")}
</Button>
</Group>
</Alert> </Alert>
)} )}
@@ -1,7 +1,7 @@
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { fireEvent, render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router"; import { MemoryRouter } from "react-router";
import { expect, it, vi } from "vitest"; import { expect, it, vi } from "vitest";
@@ -16,8 +16,6 @@ it("keeps cached results on failure, hides them during a new search and distingu
partial: false, partial: false,
query: "SBER", query: "SBER",
ready: true, ready: true,
refreshing: false,
retry: vi.fn(),
}; };
const cached = [<a href="/stocks/SBER" key="SBER">Сбербанк</a>]; const cached = [<a href="/stocks/SBER" key="SBER">Сбербанк</a>];
@@ -44,8 +42,7 @@ it("keeps cached results on failure, hides them during a new search and distingu
expect( expect(
screen.getByRole("link", { name: "Открыть настройки T-Bank" }), screen.getByRole("link", { name: "Открыть настройки T-Bank" }),
).toHaveAttribute("href", "/settings"); ).toHaveAttribute("href", "/settings");
fireEvent.click(screen.getByRole("button", { name: "Повторить" })); expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
expect(vm.retry).toHaveBeenCalledOnce();
rerender(view({ loading: true, ready: false })); rerender(view({ loading: true, ready: false }));
expect(screen.queryByRole("link", { name: "Сбербанк" })).not.toBeInTheDocument(); expect(screen.queryByRole("link", { name: "Сбербанк" })).not.toBeInTheDocument();
@@ -3,7 +3,6 @@ import type { ChangeEvent, FC, ReactNode } from "react";
import { import {
Alert, Alert,
Anchor, Anchor,
Button,
Skeleton, Skeleton,
Stack, Stack,
Text, Text,
@@ -28,8 +27,6 @@ type InstrumentSearchProps = {
partial: boolean; partial: boolean;
query: string; query: string;
ready: boolean; ready: boolean;
refreshing: boolean;
retry: () => void;
}; };
}; };
@@ -77,14 +74,6 @@ const InstrumentSearch: FC<InstrumentSearchProps> = (props) => {
</Text> </Text>
</div> </div>
<Button
disabled={vm.refreshing || !vm.canSearch}
onClick={vm.retry}
w="fit-content"
>
{t("investment.refresh")}
</Button>
{!!vm.partial && <Alert role="status">{t("investment.partial")}</Alert>} {!!vm.partial && <Alert role="status">{t("investment.partial")}</Alert>}
<TextInput <TextInput
@@ -114,9 +103,6 @@ const InstrumentSearch: FC<InstrumentSearchProps> = (props) => {
{t("accounts.openSettings")} {t("accounts.openSettings")}
</Anchor> </Anchor>
<Button onClick={vm.retry} variant="default">
{t("accounts.retry")}
</Button>
</Stack> </Stack>
</Alert> </Alert>
)} )}
-4
View File
@@ -1,6 +1,2 @@
export { useScreenSync } from "./model/screen-sync"; export { useScreenSync } from "./model/screen-sync";
export {
InvestmentRefreshState,
type InvestmentRefreshStateProps,
} from "./ui/InvestmentRefreshState/InvestmentRefreshState.view";
export { TbankConnectionSection } from "./ui/TbankConnectionSection/TbankConnectionSection"; export { TbankConnectionSection } from "./ui/TbankConnectionSection/TbankConnectionSection";
@@ -13,7 +13,6 @@ function isInvestmentPath(path: string) {
"/api/v1/brokerage-accounts", "/api/v1/brokerage-accounts",
"/api/v1/stocks", "/api/v1/stocks",
"/api/v1/bonds", "/api/v1/bonds",
"/api/v1/tbank-sync",
].some(root => path === root || path.startsWith(`${root}/`)); ].some(root => path === root || path.startsWith(`${root}/`));
} }
@@ -3,14 +3,12 @@ import { http, HttpResponse } from "msw";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { useBrokerageAccounts } from "@/entities/brokerage-account"; import { useBrokerageAccounts } from "@/entities/brokerage-account";
import { useStockQuote } from "@/entities/stock"; import { configureAuthClient } from "@/shared/api";
import { configureAuthClient, invalidateResourceAccess } from "@/shared/api";
import { createQueryWrapper } from "@/test/query"; import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource"; import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { useScreenSync } from "../index"; import { useScreenSync } from "../index";
import { commitTbankConnectionBoundary } from "./connection-boundary";
function auth() { function auth() {
configureAuthClient({ configureAuthClient({
@@ -20,355 +18,23 @@ function auth() {
}); });
} }
describe("page-owned screen synchronization", () => { describe("read-only screen synchronization", () => {
it("keeps the current scope when an older sync acceptance arrives last", async () => { it("retries the resource with GET without requesting source observation", async () => {
auth(); auth();
let gets = 0;
const { promise: blocked, resolve: release } const syncModes: (null | string)[] = [];
= Promise.withResolvers<void>();
const posted: string[] = [];
const polled: string[] = [];
server.use(
http.post("*/api/v1/tbank-sync", async ({ request }) => {
const scope = (await request.json()) as {
accountId: string;
screen: "account";
};
posted.push(scope.accountId);
if (scope.accountId === "old") await blocked;
return HttpResponse.json(
{ id: scope.accountId, now: "2026-09-25T00:00:00Z", scope },
{ status: 202 },
);
}),
http.get("*/api/v1/tbank-sync/:id", ({ params }) => {
polled.push(String(params.id));
return HttpResponse.json({
id: params.id,
state: "succeeded",
});
}),
);
const { rerender, result } = renderHook(
({ accountId }) => useScreenSync({ accountId, screen: "account" }, []),
{
initialProps: { accountId: "old" },
wrapper: createQueryWrapper().wrapper,
},
);
let pending!: Promise<void>;
act(() => {
pending = result.current.refresh();
});
await waitFor(() => expect(posted).toEqual(["old"]));
rerender({ accountId: "new" });
await act(() => result.current.refresh());
await waitFor(() => expect(polled).toEqual(["new"]));
await act(async () => {
release();
await pending;
});
expect(polled).toEqual(["new"]);
expect(posted).toEqual(["old", "new"]);
});
it.each([false, true])(
"retries a failed GET without forcing the source (cached=%s)",
async (cached) => {
auth();
const modes: (null | string)[] = [];
let isUnavailable = !cached;
let posts = 0;
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return isUnavailable
? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(
resource([], { dataRevision: String(modes.length) }),
);
}),
http.post("*/api/v1/tbank-sync", () => {
posts += 1;
return new HttpResponse(null, { status: 500 });
}),
);
const { queryClient, wrapper } = createQueryWrapper();
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
const disabled = useStockQuote("");
return {
accounts,
disabled,
sync: useScreenSync({ screen: "accounts" }, [accounts, disabled]),
};
},
{ wrapper },
);
if (cached) {
await waitFor(() =>
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
expect(result.current.accounts.isSuccess).toBe(true),
);
isUnavailable = true;
await act(() => result.current.accounts.refetch());
}
await waitFor(() => expect(result.current.accounts.isError).toBe(true));
isUnavailable = false;
await act(() => result.current.sync.retry());
await waitFor(() => expect(result.current.accounts.isSuccess).toBe(true));
expect(result.current.accounts.meta?.dataRevision).toBe(
cached ? "3" : "2",
);
expect(modes).toEqual(
cached
? [
null,
null,
"observe",
]
: [null, "observe"],
);
expect(posts).toBe(0);
expect(result.current.disabled.data).toBeUndefined();
expect(
queryClient
.getQueryCache()
.findAll({ queryKey: ["get", "/api/v1/brokerage-accounts"] }),
).toHaveLength(1);
},
);
it.each([
["logout", true],
["connection", true],
["session", false],
["connection", false],
["access", false],
] as const)(
"discards recovery after %s changes (HTTP already pending=%s)",
async (boundary, pending) => {
auth();
const { promise: blocked, resolve: release }
= Promise.withResolvers<void>();
let isRecovering = false;
let isChanged = false;
server.use(
http.get("*/api/v1/brokerage-accounts", async ({ request }) => {
if (boundary === "connection" && isChanged && !request.signal.aborted)
return HttpResponse.json(resource([], { dataRevision: "current" }));
if (!new URL(request.url).searchParams.has("sync"))
return isChanged
? HttpResponse.json(resource([], { dataRevision: "current" }))
: HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 });
isRecovering = true;
await blocked;
return HttpResponse.json(resource([], { dataRevision: "old" }));
}),
);
const { queryClient, wrapper } = createQueryWrapper();
const published: unknown[] = [];
const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
if (event.type === "updated" && event.action.type === "success")
published.push(event.query.state.data);
});
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
return {
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts]),
};
},
{ wrapper },
);
await waitFor(() => expect(result.current.accounts.isError).toBe(true));
let recovery!: Promise<void>;
act(() => {
recovery = result.current.sync.retry();
});
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
if (pending) await waitFor(() => expect(isRecovering).toBe(true));
await act(async () => {
isChanged = true;
switch (boundary) {
case "access": {
invalidateResourceAccess(queryClient, "brokerage", {
code: "FORBIDDEN",
});
break;
}
case "logout": {
configureAuthClient();
queryClient.clear();
break;
}
case "session": {
auth();
queryClient.clear();
break;
}
case "connection": {
commitTbankConnectionBoundary(queryClient, {
connected: true,
connectedAt: "2026-09-27T00:00:00Z",
});
}
}
release();
await recovery;
});
if (boundary === "connection")
await waitFor(() =>
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
expect(result.current.accounts.meta?.dataRevision).toBe("current"),
);
expect(published).not.toContainEqual(
expect.objectContaining({
meta: expect.objectContaining({ dataRevision: "old" }) as unknown,
}),
);
unsubscribe();
},
);
it.each([false, true])(
"recovers an initial GET failure after forced sync without metadata (status retry=%s)",
async (retryStatus) => {
auth();
const modes: (null | string)[] = [];
let polls = 0;
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return modes.length === 1
? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(resource([]));
}),
http.post("*/api/v1/tbank-sync", () =>
HttpResponse.json({ id: "recover" }, { status: 202 }),
),
http.get("*/api/v1/tbank-sync/:id", () => {
polls += 1;
return retryStatus && polls === 1
? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 })
: HttpResponse.json({
id: "recover",
state: "succeeded",
});
}),
);
const { queryClient, wrapper } = createQueryWrapper();
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
const disabled = useStockQuote("");
return {
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts, disabled]),
};
},
{ wrapper },
);
await waitFor(() => expect(result.current.accounts.isError).toBe(true));
expect(result.current.accounts.meta).toBeUndefined();
await act(() => result.current.sync.refresh());
if (retryStatus) {
await waitFor(() =>
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
expect(result.current.sync.transportError).toBe(true),
);
await act(() => result.current.sync.retry());
}
await waitFor(() => expect(result.current.accounts.isSuccess).toBe(true));
expect(result.current.accounts.data).toEqual([]);
expect(modes[0]).toBeNull();
expect(modes.slice(1).every(mode => mode === "observe")).toBe(true);
expect(
queryClient
.getQueryCache()
.findAll({ queryKey: ["get", "/api/v1/brokerage-accounts"] }),
).toHaveLength(1);
},
);
it("posts only its scope once and observes completion without importing again", async () => {
auth();
const modes: (null | string)[] = [];
const bodies: unknown[] = [];
let isCompleted = false;
let polls = 0;
const meta = () =>
resource([], { dataRevision: isCompleted ? "2" : "1" }).meta;
server.use( server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => { http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync")); gets += 1;
syncModes.push(new URL(request.url).searchParams.get("sync"));
return HttpResponse.json(resource([], meta())); return gets === 1
}), ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
http.post("*/api/v1/tbank-sync", async ({ request }) => { : HttpResponse.json(resource([]));
bodies.push(await request.json());
isCompleted = true;
return HttpResponse.json(
{
id: "screen",
now: "2026-09-25T00:00:00Z",
scope: { screen: "accounts" },
},
{ status: 202 },
);
}),
http.get("*/api/v1/tbank-sync/:id", () => {
polls++;
return HttpResponse.json(
{
errorCode: null,
id: "screen",
now: "2026-09-25T00:00:00Z",
scope: { screen: "accounts" },
state: "succeeded",
},
);
}), }),
); );
const { result, unmount } = renderHook( const { result } = renderHook(
() => { () => {
const accounts = useBrokerageAccounts(); const accounts = useBrokerageAccounts();
@@ -380,251 +46,36 @@ describe("page-owned screen synchronization", () => {
{ wrapper: createQueryWrapper().wrapper }, { wrapper: createQueryWrapper().wrapper },
); );
await waitFor(() => expect(gets).toBe(1));
await act(async () => result.current.sync.retry());
await waitFor(() => expect(result.current.accounts.data).toEqual([])); await waitFor(() => expect(result.current.accounts.data).toEqual([]));
await act(async () => {
await Promise.all([
result.current.sync.refresh(),
result.current.sync.refresh(),
]);
});
await waitFor(() =>
expect(result.current.accounts.meta?.dataRevision).toBe("2"),
);
expect(bodies).toEqual([{ screen: "accounts" }]);
expect(modes).toEqual([null, "observe"]); expect(gets).toBe(2);
vi.useFakeTimers(); expect(syncModes).toEqual([null, null]);
try {
await act(() => vi.advanceTimersByTimeAsync(120_000));
expect(polls).toBe(1);
expect(modes).toEqual([null, "observe"]);
unmount();
await vi.advanceTimersByTimeAsync(120_000);
expect(polls).toBe(1);
}
finally {
vi.useRealTimers();
}
}); });
it.each(["leave", "logout"])(
"publishes completed empty coverage while history runs, then stops on %s",
async (boundary) => {
vi.useFakeTimers({ toFake: ["Date"] });
try { it("refreshes the currently open resources together", async () => {
auth();
let isCompleted = false;
let polls = 0;
const modes: (null | string)[] = [];
const snapshot = () =>
resource(
isCompleted
? []
: null,
{
dataRevision: isCompleted ? "covered" : "initial",
sync: { errorCode: null, id: "history", state: "running" },
},
);
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return HttpResponse.json(snapshot());
}),
http.get("*/api/v1/tbank-sync/:id", () => {
polls++;
return HttpResponse.json(
{
errorCode: null,
id: "history",
now: "2026-09-25T00:00:00Z",
scope: null,
state: "running",
},
);
}),
);
const { result, unmount } = renderHook(
() => {
const accounts = useBrokerageAccounts();
return {
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts]),
};
},
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() =>
expect(result.current.accounts.meta?.sync.id).toBe("history"),
);
await waitFor(() => expect(polls).toBeGreaterThan(0));
expect(result.current.sync.refreshing).toBe(true);
expect(result.current.accounts.data).toBeUndefined();
isCompleted = true;
vi.setSystemTime(Date.now() + 61_000);
await waitFor(() => expect(result.current.accounts.data).toEqual([]), {
timeout: 3000,
});
expect(result.current.sync.refreshing).toBe(true);
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 1100));
});
expect(modes[0]).toBeNull();
expect(modes.slice(1).every(mode => mode === "observe")).toBe(true);
if (boundary === "leave") unmount();
else act(() => configureAuthClient());
const stopped = polls;
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 1100));
});
expect(polls).toBe(stopped);
}
finally {
vi.useRealTimers();
}
},
);
it("clears private snapshots when status confirms access loss", async () => {
auth(); auth();
let gets = 0;
server.use( server.use(
http.get("*/api/v1/brokerage-accounts", () => http.get("*/api/v1/brokerage-accounts", () => {
HttpResponse.json( gets += 1;
resource(
[], return HttpResponse.json(resource([]));
{
sync: { errorCode: null, id: "revoked", state: "running" },
},
),
),
),
http.get("*/api/v1/tbank-sync/:id", () =>
HttpResponse.json({ code: "INVALID_TBANK_TOKEN" }, { status: 400 }),
),
);
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
return {
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts]),
};
},
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() => expect(result.current.accounts.isError).toBe(true));
expect(result.current.accounts.data).toBeUndefined();
});
it("observes a projection when only its dependency reports new coverage", async () => {
auth();
let isObserved = false;
const modes: (null | string)[] = [];
const dependency = resource(
[],
{
dataRevision: isObserved ? "covered" : "initial",
resourceKey: "operations",
sync: {
errorCode: null,
id: "dependency",
state: isObserved ? "idle" : "running",
},
},
).meta;
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
const mode = new URL(request.url).searchParams.get("sync");
modes.push(mode);
if (mode) isObserved = true;
return HttpResponse.json(
resource(
isObserved
? []
: null,
{
dependencies: [
{
...dependency,
dataRevision: isObserved ? "covered" : "initial",
},
],
resourceKey: "projection",
},
),
);
}), }),
http.get("*/api/v1/tbank-sync/:id", () =>
HttpResponse.json(
{
errorCode: null,
id: "dependency",
now: "2026-09-25T00:00:00Z",
scope: null,
state: "succeeded",
},
),
),
);
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
useScreenSync({ screen: "accounts" }, [accounts]);
return accounts;
},
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() => expect(result.current.data).toEqual([]));
expect(modes).toEqual([null, "observe"]);
});
it("preserves a fresh snapshot and timestamp after a failed force", async () => {
auth();
const saved = resource([], { lastSuccessfulAt: "2026-09-25T01:00:00Z" });
server.use(
http.get("*/api/v1/brokerage-accounts", () => HttpResponse.json(saved)),
http.post("*/api/v1/tbank-sync", () =>
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
),
); );
const { result } = renderHook( const { result } = renderHook(
() => { () => {
const accounts = useBrokerageAccounts(); const accounts = useBrokerageAccounts();
return { return useScreenSync({ screen: "accounts" }, [accounts]);
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts]),
};
}, },
{ wrapper: createQueryWrapper().wrapper }, { wrapper: createQueryWrapper().wrapper },
); );
await waitFor(() => expect(result.current.accounts.data).toEqual([])); await waitFor(() => expect(gets).toBe(1));
await act(() => result.current.sync.refresh()); await act(async () => result.current.refresh());
await waitFor(() => expect(result.current.sync.transportError).toBe(true)); await waitFor(() => expect(gets).toBe(2));
expect(result.current.accounts.meta).toEqual(saved.meta);
}); });
}); });
@@ -1,164 +1,52 @@
import { type QueryKey, useQueryClient } from "@tanstack/react-query"; import type { QueryKey } from "@tanstack/react-query";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { import type { InvestmentResourceMeta } from "@/shared/api";
ApiErrorCode,
apiErrorCode,
apiQueryClient,
invalidateResourceAccess,
type InvestmentResourceMeta,
isApiErrorCode,
type paths,
useAuthenticatedQueries,
} from "@/shared/api";
type InvestmentScreen
= paths["/api/v1/tbank-sync"]["post"]["requestBody"]["content"]["application/json"];
type ScreenResource = { type ScreenResource = {
isError: boolean; isError: boolean;
isFetching: boolean; isFetching: boolean;
meta?: InvestmentResourceMeta; meta?: InvestmentResourceMeta;
observe: () => Promise<unknown>;
queryKey: QueryKey; queryKey: QueryKey;
refetch: () => Promise<unknown>;
}; };
const STATUS_INTERVAL_MS = 1000; type ScreenScope = {
[key: string]: unknown;
screen: string;
};
function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) { const READ_INTERVAL_MS = 1_800_000;
const mutation = apiQueryClient.useMutation("post", "/api/v1/tbank-sync", {
retry: false,
});
const client = useQueryClient(); function useScreenSync(_scope: ScreenScope, resources: ScreenResource[]) {
const deniedRef = useRef(new Set<unknown>()); const resourcesRef = useRef(resources);
const scopeKey = JSON.stringify(scope); useEffect(() => {
resourcesRef.current = resources;
const requestInFlightRef = useRef<null | string>(null); }, [resources]);
const isCurrentMutation
= JSON.stringify(mutation.variables?.body) === scopeKey;
const observedRef = useRef("");
const ids = [
...new Set([
...(isCurrentMutation && mutation.data ? [mutation.data.id] : []),
...resources
.flatMap(({ meta }) =>
meta ? [meta, ...(meta.dependencies ?? [])] : [],
)
.flatMap(meta =>
meta.sync.id
&& meta.sync.state === "running"
? [meta.sync.id]
: [],
),
]),
];
const statuses = useAuthenticatedQueries(
ids.map(id => ({
...apiQueryClient.queryOptions("get", "/api/v1/tbank-sync/{id}", {
params: { path: { id } },
}),
refetchInterval: (query: {
state: { data?: { state: string }; error: unknown };
}) =>
!(
query.state.error
|| (query.state.data && query.state.data.state !== "running")
) && STATUS_INTERVAL_MS,
refetchOnMount: false,
refetchOnWindowFocus: false,
retry: false,
})),
);
useEffect(() => { useEffect(() => {
for (const status of statuses) { const timer = setInterval(() => {
if (!status.error || deniedRef.current.has(status.error)) { for (const resource of resourcesRef.current)
continue; void resource.refetch().catch(() => {});
} }, READ_INTERVAL_MS);
deniedRef.current.add(status.error); return () => clearInterval(timer);
if (!isApiErrorCode(apiErrorCode(status.error), ApiErrorCode.NotFound)) }, []);
invalidateResourceAccess(client, "brokerage", status.error);
}
if (statuses.every(status => !status.data)) return; const isRefreshing = resources.some(resource => resource.isFetching);
const observation = JSON.stringify([
scopeKey,
statuses.map(status => [
status.data?.id,
status.data?.state,
status.dataUpdatedAt,
]),
resources.map(resource => resource.queryKey),
]);
if (observedRef.current === observation) return;
observedRef.current = observation;
const isCompleted = statuses.every(
status => status.data && status.data.state !== "running",
);
for (const resource of resources)
if (isCompleted || !resource.isFetching)
void resource.observe().catch(() => {});
}, [client, statuses, resources, scopeKey]);
const isRefreshing
= (isCurrentMutation && mutation.isPending)
|| statuses.some(
status => !status.isError && status.data?.state === "running",
)
|| resources.some(({ meta }) => {
const status = statuses[ids.indexOf(meta?.sync.id ?? "")];
return (
!status?.isError
&& (!status?.data || status.data.state === "running")
&& meta?.sync.state === "running"
);
});
const refresh = async () => { const refresh = async () => {
if (isRefreshing || requestInFlightRef.current === scopeKey) return; if (isRefreshing) return;
requestInFlightRef.current = scopeKey; await Promise.allSettled(resources.map(resource => resource.refetch()));
try {
await mutation.mutateAsync({ body: scope });
}
catch {
/*
The mutation retains the transport error for presentation.
*/
}
finally {
if (requestInFlightRef.current === scopeKey)
requestInFlightRef.current
= null;
}
}; };
// Retry failed HTTP reads without asking the source to import again. // Retry only the HTTP read; source attempts are reported in resource metadata.
// A source failure (or explicit refresh with no read error) still needs a sync.
const retry = async () => { const retry = async () => {
const failed = resources.filter( const failed = resources.filter(resource => resource.isError);
resource => resource.isError,
);
const failedStatuses = statuses.filter(status => status.isError); if (failed.length > 0)
if (failed.length > 0 || failedStatuses.length > 0) { await Promise.allSettled(failed.map(resource => resource.refetch()));
await Promise.allSettled([
...failed.map(resource => resource.observe()),
...failedStatuses.map(status => status.refetch()),
]);
}
else await refresh(); else await refresh();
}; };
@@ -166,13 +54,11 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
refresh, refresh,
refreshing: isRefreshing, refreshing: isRefreshing,
retry, retry,
sourceError: sourceError: resources.some(
statuses.some(status => status.data?.state === "failed") ({ meta }) => meta?.lastAttempt.state === "failed",
|| resources.some(({ meta }) => meta?.sync.state === "failed"), ),
transportError: transportError: resources.some(resource => resource.isError),
(isCurrentMutation && mutation.isError)
|| statuses.some(status => status.isError),
}; };
} }
export { type InvestmentScreen, useScreenSync }; export { useScreenSync };
@@ -1,148 +0,0 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { describe, expect, it, vi } from "vitest";
import { useBrokerageAccounts } from "@/entities/brokerage-account";
import { apiQueryClient, configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import type { InvestmentScreen } from "./screen-sync";
import { useScreenSync } from "../index";
import { commitTbankConnectionBoundary } from "./connection-boundary";
describe("sync connection and session boundaries", () => {
it.each(["connection", "session"])(
"keeps old status out of the cache after %s changes",
async (boundary) => {
let generation = 0;
configureAuthClient({
accessToken: () => "synthetic",
clear: vi.fn(),
generation: () => generation,
refresh: () => Promise.resolve(true),
});
const { promise: blocked, resolve: release }
= Promise.withResolvers<void>();
const accepted = {
id: "work",
now: "2026-09-25T12:00:00Z",
scope: { screen: "accounts" },
};
let started = 0;
let isChanged = false;
server.use(
http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json(resource([], {
sync: { errorCode: null, id: "work", state: "running" },
})),
),
http.post("*/api/v1/tbank-sync", async () => {
started += 1;
await blocked;
return HttpResponse.json(accepted, { status: 202 });
}),
http.get("*/api/v1/tbank-sync/:id", async () => {
const now = isChanged ? "2026-09-25T12:01:00Z" : accepted.now;
started += 1;
await blocked;
return HttpResponse.json(
{
...accepted,
errorCode: null,
now,
state: "succeeded",
},
);
}),
);
const { queryClient, wrapper } = createQueryWrapper();
const published: string[] = [];
const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
if (
(event.query.queryKey as readonly unknown[])[1]
!== "/api/v1/tbank-sync/{id}"
) {
return;
}
const data = event.query.state.data as undefined | { now?: string };
if (data?.now) published.push(data.now);
});
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
useScreenSync({ screen: "accounts" }, [accounts]);
return {
mutation: apiQueryClient.useMutation("post", "/api/v1/tbank-sync", {
retry: false,
}),
};
},
{ wrapper },
);
const screen: InvestmentScreen = { screen: "accounts" };
let mutation!: Promise<unknown>;
act(() => {
mutation = result.current.mutation.mutateAsync({ body: screen });
});
// eslint-disable-next-line unicorn/prefer-await -- Handle rejection before changing the boundary and releasing the pending response.
const settled = mutation.catch(() => {});
await waitFor(() => expect(started).toBe(2));
await act(async () => {
isChanged = true;
if (boundary === "connection")
commitTbankConnectionBoundary(
queryClient,
{
connected: false,
connectedAt: null,
},
);
else {
generation += 1;
queryClient.clear();
}
release();
await blocked;
});
await settled;
const key = apiQueryClient.queryOptions(
"get",
"/api/v1/tbank-sync/{id}",
{ params: { path: { id: "work" } } },
).queryKey;
// A new request may start after a successful connection boundary; an old result cannot win it.
if (boundary === "session")
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
expect(queryClient.getQueryData(key)).toBeUndefined();
else
await waitFor(() =>
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
expect(queryClient.getQueryData(key)).toMatchObject({
now: "2026-09-25T12:01:00Z",
}),
);
expect(published).not.toContain(accepted.now);
unsubscribe();
},
);
});
@@ -58,4 +58,4 @@ const InvestmentRefreshState: FC<InvestmentRefreshStateProps> = (props) => {
); );
}; };
export { InvestmentRefreshState, type InvestmentRefreshStateProps }; export { InvestmentRefreshState };
@@ -127,9 +127,11 @@ describe("TbankConnectionSection", () => {
const token = screen.getByLabelText("Token T-Bank"); const token = screen.getByLabelText("Token T-Bank");
fireEvent.change(token, { target: { value: "replacement-draft" } }); fireEvent.change(token, { target: { value: "replacement-draft" } });
finishRefetch?.(); finishRefetch?.();
expect( await waitFor(() => {
await screen.findByText(/Не удалось обновить статус подключения/), expect(
).toBeVisible(); screen.queryByText(/Не удалось обновить статус подключения/),
).not.toBeInTheDocument();
});
expect(screen.getByLabelText("Token T-Bank")).toBe(token); expect(screen.getByLabelText("Token T-Bank")).toBe(token);
expect(token).toHaveValue("replacement-draft"); expect(token).toHaveValue("replacement-draft");
expect(screen.getByText("Подключено с 14.08.2026")).toBeVisible(); expect(screen.getByText("Подключено с 14.08.2026")).toBeVisible();
@@ -139,7 +141,6 @@ describe("TbankConnectionSection", () => {
expect(client.getQueryData(portfolioQueryKey)).toEqual({ expect(client.getQueryData(portfolioQueryKey)).toEqual({
source: "previous-connection", source: "previous-connection",
}); });
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
await waitFor(() => expect(reads).toBe(2)); await waitFor(() => expect(reads).toBe(2));
await waitFor(() => await waitFor(() =>
expect( expect(
@@ -164,13 +165,12 @@ describe("TbankConnectionSection", () => {
}), }),
); );
renderSection(createTestQueryClient()); renderSection(createTestQueryClient());
expect( await waitFor(() => {
await screen.findByText(/Не удалось загрузить статус подключения/), expect(
).toBeVisible(); screen.queryByText(/Не удалось загрузить статус подключения/),
expect(screen.queryByLabelText("Token T-Bank")).not.toBeInTheDocument(); ).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Повторить" })); });
expect(await screen.findByLabelText("Token T-Bank")).toBeVisible(); expect(reads).toBe(1);
expect(reads).toBe(2);
}); });
it("commits connection status and resets the complete brokerage query subtree after connecting", async () => { it("commits connection status and resets the complete brokerage query subtree after connecting", async () => {
const client = seededClient(); const client = seededClient();
@@ -1,3 +1,4 @@
import { notifications } from "@mantine/notifications";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -32,6 +33,12 @@ function useTbankConnectionSectionLoadedVm(status: TbankConnectionStatus) {
onSuccess: (data) => { onSuccess: (data) => {
commitTbankConnectionBoundary(queryClient, data); commitTbankConnectionBoundary(queryClient, data);
reset(); reset();
if (status.connected && data.connected)
notifications.show({
id: "tbank-token-replaced-" + crypto.randomUUID(),
message: t("tbank.tokenReplaced"),
});
}, },
}); });
@@ -3,7 +3,6 @@ import type { FC } 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 { import {
act,
fireEvent, fireEvent,
render, render,
renderHook, renderHook,
@@ -21,6 +20,7 @@ import {
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
import { defaultPeriod, formatDate } from "@/shared/lib";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource"; import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
@@ -115,7 +115,6 @@ describe("Upcoming event date filters", () => {
"keeps event calendar dates in GET and source sync requests: %s", "keeps event calendar dates in GET and source sync requests: %s",
async (search) => { async (search) => {
const requested: string[] = []; const requested: string[] = [];
const syncRequests: unknown[] = [];
server.use( server.use(
http.get("*/api/v1/brokerage-accounts/all/events", ({ request }) => { http.get("*/api/v1/brokerage-accounts/all/events", ({ request }) => {
requested.push(new URL(request.url).search); requested.push(new URL(request.url).search);
@@ -124,25 +123,17 @@ describe("Upcoming event date filters", () => {
resource( resource(
null, null,
{ {
sync: { lastAttempt: {
errorCode: "TBANK_UNAVAILABLE", finishedAt: "2026-08-14T00:01:00Z",
id: null, problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed", state: "failed",
}, },
}, },
), ),
); );
}), }),
http.post("*/api/v1/tbank-sync", async ({ request }) => {
syncRequests.push(await request.json());
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
}),
); );
const period = Object.fromEntries(new URLSearchParams(search));
const queryClient = createTestQueryClient(); const queryClient = createTestQueryClient();
const { result } = renderHook( const { result } = renderHook(
@@ -162,21 +153,13 @@ describe("Upcoming event date filters", () => {
await waitFor(() => expect(result.current.state).toBe("error")); await waitFor(() => expect(result.current.state).toBe("error"));
expect(requested).toEqual([`?${search}`]); expect(requested).toEqual([`?${search}`]);
expect(syncRequests).toEqual([]);
act(() => {
if (result.current.state === "error") result.current.retry();
});
await waitFor(() =>
expect(syncRequests).toEqual([
{ accountId: "all", screen: "events", ...period },
]),
);
}, },
); );
it.each(["account-1", "all"])( it.each(["account-1", "all"])(
"normalizes invalid links and history while preserving one-sided future dates for %s", "normalizes invalid links and history while preserving one-sided future dates for %s",
async (accountId) => { async (accountId) => {
const [defaultFrom, defaultTo] = defaultPeriod();
const searches: string[] = []; const searches: string[] = [];
server.use( server.use(
http.get( http.get(
@@ -215,24 +198,28 @@ describe("Upcoming event date filters", () => {
), ),
); );
expect(screen.getByLabelText("Период")).toHaveTextContent("01.02.2099"); expect(screen.getByLabelText("Период")).toHaveTextContent("01.02.2099");
expect(searches).toEqual(["", "?from=2099-02-01", "?to=2099-02-28"]); expect(searches[0]).toBe("");
expect(searches).toContain(`?from=${defaultFrom}&to=${defaultTo}`);
expect(searches).toContain("?from=2099-02-01");
expect(searches).toContain("?to=2099-02-28");
}, },
); );
it.each(["account-1", "all"])( it.each(["account-1", "all"])(
"lets users reset filters after a request failure for %s", "lets users reset filters after a request failure for %s",
async (accountId) => { async (accountId) => {
const [defaultFrom] = defaultPeriod();
server.use( server.use(
http.get( http.get(
"*/api/v1/brokerage-accounts/:accountId/events", "*/api/v1/brokerage-accounts/:accountId/events",
({ request }) => ({ request }) =>
new URL(request.url).searchParams.has("from") new URL(request.url).searchParams.get("from") === defaultFrom
? HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 }) ? HttpResponse.json(resource({ events: [] }))
: HttpResponse.json(resource({ events: [] })), : HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 }),
), ),
); );
renderEvents(accountId, ["?from=2099-02-01"]); renderEvents(accountId, ["?from=2099-02-01"]);
await screen.findByRole("button", { name: "Повторить" }); await screen.findByRole("button", { name: "Сбросить" });
fireEvent.click(screen.getByRole("button", { name: "Сбросить" })); fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
await screen.findByLabelText("Период"); await screen.findByLabelText("Период");
expect(screen.getByLabelText("URL")).toBeEmptyDOMElement(); expect(screen.getByLabelText("URL")).toBeEmptyDOMElement();
@@ -242,6 +229,7 @@ describe("Upcoming event date filters", () => {
it.each(["account-1", "all"])( it.each(["account-1", "all"])(
"applies dates only on submit, restores URL dates, and resets from empty results for %s", "applies dates only on submit, restores URL dates, and resets from empty results for %s",
async (accountId) => { async (accountId) => {
const [defaultFrom, defaultTo] = defaultPeriod();
const searches: string[] = []; const searches: string[] = [];
server.use( server.use(
http.get( http.get(
@@ -260,38 +248,42 @@ describe("Upcoming event date filters", () => {
}, },
), ),
); );
renderEvents(accountId, ["?from=2099-02-01&to=2099-02-28"]); renderEvents(accountId, ["?from=2026-02-01&to=2026-02-28"]);
const from = await screen.findByLabelText("Период"); const from = await screen.findByLabelText("Период");
expect(from).toHaveTextContent("01.02.2099 – 28.02.2099"); expect(from).toHaveTextContent("01.02.2026 – 28.02.2026");
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
expect(searches).toEqual(["?from=2099-02-01&to=2099-02-28"]); expect(searches).toEqual(["?from=2026-02-01&to=2026-02-28"]);
fireEvent.click(from); fireEvent.click(from);
fireEvent.click( fireEvent.click(
await screen.findByRole("button", { name: /10 February 2099/i }), await screen.findByRole("button", { name: /10 February 2026/i }),
); );
fireEvent.click( fireEvent.click(
await screen.findByRole("button", { name: /28 February 2099/i }), await screen.findByRole("button", { name: /28 February 2026/i }),
); );
expect(searches).toHaveLength(1); expect(searches).toHaveLength(1);
fireEvent.click(screen.getByRole("button", { name: "Применить" })); fireEvent.click(screen.getByRole("button", { name: "Применить" }));
await waitFor(() => await waitFor(() =>
expect(searches.at(-1)).toBe("?from=2099-02-10&to=2099-02-28"), expect(searches.at(-1)).toBe("?from=2026-02-10&to=2026-02-28"),
); );
expect(await screen.findByLabelText("Период")).toHaveTextContent( expect(await screen.findByLabelText("Период")).toHaveTextContent(
"10.02.2099 – 28.02.2099", "10.02.2026 – 28.02.2026",
); );
fireEvent.click(screen.getByRole("button", { name: "History back" })); fireEvent.click(screen.getByRole("button", { name: "History back" }));
await waitFor(() => await waitFor(() =>
expect(screen.getByLabelText("Период")).toHaveTextContent( expect(screen.getByLabelText("Период")).toHaveTextContent(
"01.02.2099 – 28.02.2099", "01.02.2026 – 28.02.2026",
), ),
); );
expect(screen.getByLabelText("URL")).toHaveTextContent( expect(screen.getByLabelText("URL")).toHaveTextContent(
"?from=2099-02-01&to=2099-02-28", "?from=2026-02-01&to=2026-02-28",
); );
fireEvent.click(screen.getByRole("button", { name: "Сбросить" })); fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
await waitFor(() => expect(searches.at(-1)).toBe("")); await waitFor(() =>
expect(await screen.findByLabelText("Период")).toHaveTextContent(""); expect(searches.at(-1)).toBe(`?from=${defaultFrom}&to=${defaultTo}`),
);
expect(await screen.findByLabelText("Период")).toHaveTextContent(
`${formatDate(defaultFrom)} – ${formatDate(defaultTo)}`,
);
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
expect( expect(
screen.getByText("За выбранный период событий не найдено"), screen.getByText("За выбранный период событий не найдено"),
@@ -300,7 +292,7 @@ describe("Upcoming event date filters", () => {
); );
}); });
it("retains the previous complete forecast and timestamp when its source refresh fails", async () => { it("retains the previous complete forecast when its source refresh fails", async () => {
configureAuthClient({ configureAuthClient({
accessToken: () => "synthetic", accessToken: () => "synthetic",
clear: vi.fn(), clear: vi.fn(),
@@ -318,7 +310,14 @@ it("retains the previous complete forecast and timestamp when its source refresh
const snapshot = resource( const snapshot = resource(
{ events, incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }] }, { events, incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }] },
{ sync: { errorCode: "TBANK_UNAVAILABLE", id: "failed", state: "failed" } }, {
lastAttempt: {
finishedAt: "2026-08-14T00:01:00Z",
problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed",
},
},
); );
server.use( server.use(
@@ -343,9 +342,6 @@ it("retains the previous complete forecast and timestamp when its source refresh
await waitFor(() => await waitFor(() =>
expect(result.current).toMatchObject({ expect(result.current).toMatchObject({
events, events,
lastSuccessfulAt: snapshot.meta.lastSuccessfulAt,
refreshError: true,
sourceError: true,
state: "loaded", state: "loaded",
}), }),
); );
@@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type * as BrokerageEvents from "@/entities/brokerage-event"; import type * as BrokerageEvents from "@/entities/brokerage-event";
import { useBrokerageEvents } from "@/entities/brokerage-event"; import { useBrokerageEvents } from "@/entities/brokerage-event";
import { defaultPeriod } from "@/shared/lib";
import { AccountEventsPage } from "./AccountEventsPage"; import { AccountEventsPage } from "./AccountEventsPage";
@@ -50,8 +51,9 @@ describe("AccountEventsPage", () => {
isError: false, isError: false,
isLoading: false, isLoading: false,
meta: { meta: {
availability: "complete", availability: "available",
sync: { errorCode: null, id: null, state: "idle" }, completeness: "complete",
lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" },
}, },
refetch: vi.fn(), refetch: vi.fn(),
} as never, } as never,
@@ -71,7 +73,10 @@ describe("AccountEventsPage", () => {
</MemoryRouter> </MemoryRouter>
</MantineProvider>, </MantineProvider>,
); );
expect(query).toHaveBeenCalledWith("selected-account", {}); expect(query).toHaveBeenCalledWith("selected-account", {
from: defaultPeriod()[0],
to: defaultPeriod()[1],
});
}); });
it("keeps the loading fallback when no result or account is available", () => { it("keeps the loading fallback when no result or account is available", () => {
@@ -81,14 +86,18 @@ describe("AccountEventsPage", () => {
isError: false, isError: false,
isLoading: false, isLoading: false,
meta: { meta: {
availability: "complete", availability: "available",
sync: { errorCode: null, id: null, state: "idle" }, completeness: "complete",
lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" },
}, },
refetch: vi.fn(), refetch: vi.fn(),
} as never, } as never,
); );
renderPage(); renderPage();
expect(query).toHaveBeenCalledWith("", {}); expect(query).toHaveBeenCalledWith("", {
from: defaultPeriod()[0],
to: defaultPeriod()[1],
});
expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute( expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute(
"aria-busy", "aria-busy",
"true", "true",
@@ -131,8 +140,9 @@ describe("AccountEventsPage", () => {
isError: false, isError: false,
isLoading: false, isLoading: false,
meta: { meta: {
availability: "complete", availability: "available",
sync: { errorCode: null, id: null, state: "idle" }, completeness: "complete",
lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" },
}, },
refetch: vi.fn(), refetch: vi.fn(),
} as never, } as never,
@@ -159,8 +169,7 @@ describe("AccountEventsPage", () => {
expect(screen.getAllByText("O")).toHaveLength(2); expect(screen.getAllByText("O")).toHaveLength(2);
}); });
it("renders empty and retryable error states", () => { it("renders empty and quiet error states", async () => {
const refetch = vi.fn();
const { rerender } = renderPage(); const { rerender } = renderPage();
expect( expect(
screen.getByText("За выбранный период событий не найдено"), screen.getByText("За выбранный период событий не найдено"),
@@ -169,7 +178,6 @@ describe("AccountEventsPage", () => {
data: undefined, data: undefined,
isError: true, isError: true,
isLoading: false, isLoading: false,
refetch,
} as never); } as never);
rerender( rerender(
<MantineProvider> <MantineProvider>
@@ -178,8 +186,8 @@ describe("AccountEventsPage", () => {
</MemoryRouter> </MemoryRouter>
</MantineProvider>, </MantineProvider>,
); );
fireEvent.click(screen.getByRole("button", { name: "Повторить" })); expect(await screen.findByRole("button", { name: "Сбросить" })).toBeVisible();
expect(refresh).toHaveBeenCalledOnce(); expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
}); });
it("shows a structural busy state before events are available", () => { it("shows a structural busy state before events are available", () => {
@@ -189,8 +197,9 @@ describe("AccountEventsPage", () => {
isError: false, isError: false,
isLoading: true, isLoading: true,
meta: { meta: {
availability: "complete", availability: "available",
sync: { errorCode: null, id: null, state: "idle" }, completeness: "complete",
lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" },
}, },
refetch: vi.fn(), refetch: vi.fn(),
} as never, } as never,
@@ -1,30 +1,36 @@
import { useState } from "react"; import { useState } from "react";
import { useParams } from "react-router"; import { useParams } from "react-router";
import type { InvestmentResourceMeta } from "@/shared/api";
import { import {
type BrokerageEvent, type BrokerageEvent,
useBrokerageEvents, useBrokerageEvents,
} from "@/entities/brokerage-event"; } from "@/entities/brokerage-event";
import { useScreenSync } from "@/features/tbank-connection"; import { useScreenSync } from "@/features/tbank-connection";
import { isValidPeriod, useUrlPeriod } from "@/shared/lib"; import {
defaultPeriod,
isValidPeriod,
moscowDate,
useInitialAvailability,
useUrlPeriod,
} from "@/shared/lib";
type AccountEventsPageViewModel type AccountEventsPageViewModel
= | { = | {
canApply: boolean; canApply: boolean;
draftPeriod: [null | string, null | string]; draftPeriod: [null | string, null | string];
events: BrokerageEvent[]; events: BrokerageEvent[];
lastSuccessfulAt?: null | string; maxDate: string;
onApply: () => void; onApply: () => void;
onPeriodChange: (value: [null | string, null | string]) => void; onPeriodChange: (value: [null | string, null | string]) => void;
onPeriodPopoverOpen: () => void;
onReset: () => void; onReset: () => void;
partial?: boolean; partial?: boolean;
refreshError: boolean; source?: InvestmentResourceMeta;
refreshing: boolean;
retry: () => void;
sourceError?: boolean;
state: "loaded"; state: "loaded";
} }
| { onReset: () => void; retry: () => void; state: "error" } | { onReset: () => void; state: "error" }
| { state: "loading" }; | { state: "loading" };
function useAccountEventsPageViewModel(): AccountEventsPageViewModel { function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
@@ -43,6 +49,8 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
const [draftFrom, draftTo] = draftPeriod; const [draftFrom, draftTo] = draftPeriod;
const [maxDate, setMaxDate] = useState(() => moscowDate(new Date()));
const scope = JSON.stringify([accountId, appliedFrom, appliedTo]); const scope = JSON.stringify([accountId, appliedFrom, appliedTo]);
const [previousScope, setPreviousScope] = useState(scope); const [previousScope, setPreviousScope] = useState(scope);
@@ -56,7 +64,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
...(appliedTo && { to: appliedTo }), ...(appliedTo && { to: appliedTo }),
}); });
const sync = useScreenSync( useScreenSync(
{ {
accountId, accountId,
screen: "events", screen: "events",
@@ -66,22 +74,30 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
[events], [events],
); );
if (events.isLoading) return { state: "loading" }; const availability = useInitialAvailability({
hasContent: !!events.data,
identity: scope,
isError: events.isError || events.meta?.lastAttempt.state === "failed",
isFetching: events.isFetching,
});
if (availability === "loading" && events.isLoading)
return { state: "loading" };
const onReset = () => { const onReset = () => {
setDraftPeriod([null, null]); setDraftPeriod([appliedFrom, appliedTo]);
setSearchParameters({}); setSearchParameters({});
}; };
if ((events.isError || events.meta?.sync.state === "failed") && !events.data) if ((events.isError || events.meta?.lastAttempt.state === "failed") && !events.data)
return { return {
onReset, onReset,
retry: () => {
void sync.retry();
},
state: "error", state: "error",
}; };
if (!events.data) return { state: "loading" }; if (!events.data)
return availability === "unavailable"
? { onReset, state: "error" }
: { state: "loading" };
return { return {
canApply: canApply:
@@ -89,7 +105,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
&& isValidPeriod(draftFrom, draftTo), && isValidPeriod(draftFrom, draftTo),
draftPeriod, draftPeriod,
events: events.data.events, events: events.data.events,
lastSuccessfulAt: events.meta?.lastSuccessfulAt, maxDate,
onApply: () => { onApply: () => {
if (!isValidPeriod(draftFrom, draftTo)) return; if (!isValidPeriod(draftFrom, draftTo)) return;
setSearchParameters({ setSearchParameters({
@@ -97,15 +113,13 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
...(draftTo && { to: draftTo }), ...(draftTo && { to: draftTo }),
}); });
}, },
onPeriodChange: setDraftPeriod, onPeriodChange: value => setDraftPeriod(
value[0] === null && value[1] === null ? defaultPeriod() : value,
),
onPeriodPopoverOpen: () => setMaxDate(moscowDate(new Date())),
onReset, onReset,
partial: events.meta?.availability !== "complete", partial: events.meta?.completeness === "incomplete",
refreshError: events.isError || sync.sourceError || sync.transportError, source: events.meta,
refreshing: events.isFetching || sync.refreshing,
retry: () => {
void sync.retry();
},
sourceError: sync.sourceError,
state: "loaded", state: "loaded",
}; };
} }
@@ -4,7 +4,7 @@ import { Button, Group } from "@mantine/core";
import { StateView } from "@wealthledger/ui"; import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
type AccountEventsPageErrorProps = { onReset: () => void; retry: () => void }; type AccountEventsPageErrorProps = { onReset: () => void };
const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => { const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -13,8 +13,6 @@ const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
<StateView <StateView
action={( action={(
<Group> <Group>
<Button onClick={props.retry}>{t("events.retry")}</Button>
<Button onClick={props.onReset} variant="default"> <Button onClick={props.onReset} variant="default">
{t("ledger.reset")} {t("ledger.reset")}
</Button> </Button>
@@ -4,7 +4,6 @@ import {
Alert, Alert,
Button, Button,
Group, Group,
Progress,
Stack, Stack,
Text, Text,
Title, Title,
@@ -55,32 +54,16 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
return ( return (
<Stack gap="lg"> <Stack gap="lg">
<SourceSyncStatus lastSuccessfulAt={props.lastSuccessfulAt} />
<Title order={1}>{t("events.title")}</Title> <Title order={1}>{t("events.title")}</Title>
<Button disabled={props.refreshing} onClick={props.retry} w="fit-content"> <SourceSyncStatus
{t("investment.refresh")} lastAttempt={props.source?.lastAttempt}
</Button> lastSuccessfulAt={props.source?.lastSuccessfulAt}
stale={props.source?.freshness === "stale"}
/>
{!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>} {!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>}
{!!props.refreshing && <Progress animated aria-label={t("accounts.refreshing")} value={100} />}
{!!props.refreshError && (
<Alert aria-live="polite" color="yellow" role="status">
{t(
props.sourceError
? "investment.syncFailed"
: "accounts.refreshError",
)}
<Button onClick={props.retry} variant="subtle">
{t("accounts.retry")}
</Button>
</Alert>
)}
<Text c="dimmed">{t("events.hint")}</Text> <Text c="dimmed">{t("events.hint")}</Text>
<Group align="end"> <Group align="end">
@@ -89,9 +72,11 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
clearable clearable
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }} clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
label={t("ledger.period")} label={t("ledger.period")}
maxDate={props.maxDate}
onChange={props.onPeriodChange} onChange={props.onPeriodChange}
popoverProps={{ popoverProps={{
middlewares: { shift: { crossAxis: true } }, middlewares: { shift: { crossAxis: true } },
onOpen: props.onPeriodPopoverOpen,
returnFocus: true, returnFocus: true,
}} }}
type="range" type="range"
@@ -136,13 +136,14 @@ it("maps comparison notices by role regardless of dependency order or resource k
const previous const previous
= { = {
...resource(null).meta, ...resource(null).meta,
resourceKey: "opaque/year-looking-key", lastAttempt: {
role: "previous" as const, finishedAt: "2026-08-14T00:01:00Z",
sync: { problems: [],
errorCode: "TBANK_UNAVAILABLE", startedAt: "2026-08-14T00:00:00Z",
id: null,
state: "failed" as const, state: "failed" as const,
}, },
resourceKey: "opaque/year-looking-key",
role: "previous" as const,
}; };
const year = { const year = {
+1 -1
View File
@@ -66,7 +66,7 @@ function comparisonNotice(
): ComparisonNotice { ): ComparisonNotice {
if (!dependency || dependency.unavailableReason) return unavailable; if (!dependency || dependency.unavailableReason) return unavailable;
return dependency.sync.state === "failed" return dependency.lastAttempt.state === "failed"
? "comparisonFailed" ? "comparisonFailed"
: "comparisonPending"; : "comparisonPending";
} }
@@ -195,7 +195,6 @@ describe("AccountIncomePage", () => {
it("keeps income calendar dates in GET and source sync requests", async () => { it("keeps income calendar dates in GET and source sync requests", async () => {
const requested: string[] = []; const requested: string[] = [];
const syncRequests: unknown[] = [];
server.use( server.use(
http.get( http.get(
"*/api/v1/brokerage-accounts/:accountId/income-analytics", "*/api/v1/brokerage-accounts/:accountId/income-analytics",
@@ -206,9 +205,10 @@ describe("AccountIncomePage", () => {
resource( resource(
null, null,
{ {
sync: { lastAttempt: {
errorCode: "TBANK_UNAVAILABLE", finishedAt: "2026-08-14T00:01:00Z",
id: null, problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed", state: "failed",
}, },
}, },
@@ -216,30 +216,11 @@ describe("AccountIncomePage", () => {
); );
}, },
), ),
http.post("*/api/v1/tbank-sync", async ({ request }) => {
syncRequests.push(await request.json());
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
}),
); );
renderPage("from=2024-02-29&to=2024-03-01"); renderPage("from=2024-02-29&to=2024-03-01");
const retry = await screen.findByRole("button", { name: "Повторить" }); await screen.findByText("Не удалось рассчитать пассивный доход");
expect(requested).toEqual(["?from=2024-02-29&to=2024-03-01"]); expect(requested).toEqual(["?from=2024-02-29&to=2024-03-01"]);
expect(syncRequests).toEqual([]); expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
fireEvent.click(retry);
await waitFor(() =>
expect(syncRequests).toEqual([
{
accountId: "account-1",
from: "2024-02-29",
screen: "income",
to: "2024-03-01",
},
]),
);
}); });
it("requests a multi-year period from the URL without resetting it", async () => { it("requests a multi-year period from the URL without resetting it", async () => {
@@ -486,7 +467,7 @@ describe("AccountIncomePage", () => {
).toBeVisible(); ).toBeVisible();
}); });
it("shows a recoverable realized error", async () => { it("shows a quiet realized error", async () => {
server.use( server.use(
http.get( http.get(
"*/api/v1/brokerage-accounts/account-1/income-analytics", "*/api/v1/brokerage-accounts/account-1/income-analytics",
@@ -495,8 +476,11 @@ describe("AccountIncomePage", () => {
); );
renderPage(); renderPage();
expect( expect(
await screen.findByRole("button", { name: "Повторить" }), await screen.findByText("Не удалось рассчитать пассивный доход", undefined, {
timeout: 4000,
}),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
}); });
}); });
@@ -631,7 +615,7 @@ describe("income period and snapshot boundary", () => {
expect(requests).toEqual(["?from=2026-09-01&to=2026-09-01"]); expect(requests).toEqual(["?from=2026-09-01&to=2026-09-01"]);
}); });
it("keeps valid data and draft during refetch, warns on failure and retries", async () => { it("keeps valid data and draft during refetch failure", async () => {
respondWithRequestedPeriod([]); respondWithRequestedPeriod([]);
const { queryClient } = renderPage(); const { queryClient } = renderPage();
await screen.findByLabelText("Период"); await screen.findByLabelText("Период");
@@ -653,51 +637,13 @@ describe("income period and snapshot boundary", () => {
act(() => { act(() => {
void queryClient.invalidateQueries(); void queryClient.invalidateQueries();
}); });
expect(
await screen.findByRole("status", { name: "Обновляем пассивный доход" }),
).toBeInTheDocument();
expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible(); expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible();
expect(screen.getByLabelText("Период")).toHaveTextContent( expect(screen.getByLabelText("Период")).toHaveTextContent(
"02.08.2026 – 27.08.2026", "02.08.2026 – 27.08.2026",
); );
release(); release();
expect(
await screen.findByText(/Показан последний загруженный результат/),
).toBeInTheDocument();
expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible(); expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible();
server.use( expect(screen.queryByText(/Показан последний загруженный результат/)).not.toBeInTheDocument();
http.post("*/api/v1/tbank-sync", () =>
HttpResponse.json(
{
id: "income",
now: "2026-09-25T00:00:00Z",
scope: { accountId: "account-1", screen: "income" },
},
{ status: 202 },
),
),
http.get("*/api/v1/tbank-sync/:id", () =>
HttpResponse.json(
{
errorCode: null,
id: "income",
now: "2026-09-25T00:00:00Z",
scope: null,
state: "succeeded",
},
),
),
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(resource(analytics, { dataRevision: "2" })),
),
);
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
await waitFor(() =>
expect(
screen.queryByText(/Показан последний загруженный результат/),
).not.toBeInTheDocument(),
);
expect(screen.getByLabelText("Период")).toHaveTextContent( expect(screen.getByLabelText("Период")).toHaveTextContent(
"02.08.2026 – 27.08.2026", "02.08.2026 – 27.08.2026",
); );
@@ -887,12 +833,13 @@ it("keeps complete previous comparison while year source failed without inventin
}, },
{ {
...resource(null).meta, ...resource(null).meta,
role: "year" as const, lastAttempt: {
sync: { finishedAt: "2026-08-14T00:01:00Z",
errorCode: "TBANK_UNAVAILABLE", problems: [],
id: null, startedAt: "2026-08-14T00:00:00Z",
state: "failed" as const, state: "failed" as const,
}, },
role: "year" as const,
}, },
{ {
...resource(analytics).meta, ...resource(analytics).meta,
@@ -5,7 +5,7 @@ import { useParams, useSearchParams } from "react-router";
import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income"; import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
import { useScreenSync } from "@/features/tbank-connection"; import { useScreenSync } from "@/features/tbank-connection";
import { isValidPeriod, moscowDate } from "@/shared/lib"; import { isValidPeriod, moscowDate, useInitialAvailability } from "@/shared/lib";
import { import {
type IncomeDisplayModel, type IncomeDisplayModel,
@@ -27,19 +27,13 @@ type AccountIncomePageViewModel
display: IncomeDisplayModel; display: IncomeDisplayModel;
form: ReturnType<typeof useForm<IncomeForm>>; form: ReturnType<typeof useForm<IncomeForm>>;
formRevision: string; formRevision: string;
lastSuccessfulAt?: null | string;
onReset: () => void; onReset: () => void;
onSubmit: (values: IncomeForm) => void; onSubmit: (values: IncomeForm) => void;
partial?: boolean; partial?: boolean;
refreshError: boolean;
refreshing: boolean;
retry: () => Promise<unknown> | undefined;
sourceError?: boolean;
state: "loaded"; state: "loaded";
} }
| { | {
period: IncomePeriod; period: IncomePeriod;
retry: () => Promise<unknown> | undefined;
state: "error"; state: "error";
} }
| { state: "loading" }; | { state: "loading" };
@@ -69,7 +63,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
const [resetRevision, setResetRevision] = useState(0); const [resetRevision, setResetRevision] = useState(0);
const analytics = useIncomeAnalytics(accountId, period); const analytics = useIncomeAnalytics(accountId, period);
const sync = useScreenSync( useScreenSync(
{ accountId, from: period.from, screen: "income", to: period.to }, { accountId, from: period.from, screen: "income", to: period.to },
[analytics], [analytics],
); );
@@ -82,11 +76,20 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
form.reset(draftPeriod(period)); form.reset(draftPeriod(period));
}, [accountId, form, period]); }, [accountId, form, period]);
if (analytics.isLoading || !analytics.data) { const availability = useInitialAvailability({
return analytics.isError || analytics.meta?.sync.state === "failed" hasContent: !!analytics.data,
? { period, retry: sync.retry, state: "error" } identity: `${accountId}:${period.from}:${period.to}`,
isError: analytics.isError || analytics.meta?.lastAttempt.state === "failed",
isFetching: analytics.isFetching,
});
if (availability === "loading" && analytics.isLoading)
return { state: "loading" };
if (!analytics.data)
return availability === "unavailable"
? { period, state: "error" }
: { state: "loading" }; : { state: "loading" };
}
const display = toIncomeDisplayModel(analytics.data, analytics.meta); const display = toIncomeDisplayModel(analytics.data, analytics.meta);
@@ -109,14 +112,9 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
display, display,
form, form,
formRevision: `${accountId}:${period.from}:${period.to}:${resetRevision}`, formRevision: `${accountId}:${period.from}:${period.to}:${resetRevision}`,
lastSuccessfulAt: analytics.meta?.lastSuccessfulAt,
onReset, onReset,
onSubmit, onSubmit,
partial: analytics.meta?.availability !== "complete", partial: analytics.meta?.completeness === "incomplete",
refreshError: analytics.isError || sync.sourceError || sync.transportError,
refreshing: analytics.isFetching || sync.refreshing,
retry: sync.retry,
sourceError: sync.sourceError,
state: "loaded", state: "loaded",
}; };
}; };
@@ -1,6 +1,5 @@
import type { FC } from "react"; import type { FC } from "react";
import { Button } from "@mantine/core";
import { StateView } from "@wealthledger/ui"; import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -8,12 +7,11 @@ import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
type ErrorProps = Extract<AccountIncomePageViewModel, { state: "error" }>; type ErrorProps = Extract<AccountIncomePageViewModel, { state: "error" }>;
const AccountIncomePageError: FC<ErrorProps> = (props) => { const AccountIncomePageError: FC<ErrorProps> = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<StateView <StateView
action={<Button onClick={props.retry}>{t("income.retry")}</Button>}
error error
text={t("income.errorText")} text={t("income.errorText")}
title={t("income.errorTitle")} title={t("income.errorTitle")}
@@ -2,19 +2,13 @@ import type { FC } from "react";
import { import {
Alert, Alert,
Button,
Group,
Loader,
Stack, Stack,
Text,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import { StateView } from "@wealthledger/ui"; import { StateView } from "@wealthledger/ui";
import { FormProvider } from "react-hook-form"; import { FormProvider } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { SourceSyncStatus } from "@/shared/ui";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm"; import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
import classes from "../AccountIncomePage.module.css"; import classes from "../AccountIncomePage.module.css";
@@ -40,21 +34,14 @@ const AccountIncomePageLoadedSummary: FC<LoadedProps> = (props) => {
}; };
const AccountIncomePageLoaded: FC<LoadedProps> = (props) => { const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
const { display, form, onReset, onSubmit, refreshError, refreshing, retry } const { display, form, onReset, onSubmit } = props;
= props;
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Stack className={classes.page} gap="lg"> <Stack className={classes.page} gap="lg">
<SourceSyncStatus lastSuccessfulAt={props.lastSuccessfulAt} />
<Title order={1}>{t("income.title")}</Title> <Title order={1}>{t("income.title")}</Title>
<Button disabled={refreshing} onClick={retry} w="fit-content">
{t("investment.refresh")}
</Button>
<FormProvider {...form}> <FormProvider {...form}>
<IncomePeriodForm <IncomePeriodForm
key={props.formRevision} key={props.formRevision}
@@ -63,32 +50,6 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
/> />
</FormProvider> </FormProvider>
{!!refreshing && (
<Group aria-label={t("income.refreshing")} gap="xs" role="status">
<Loader size="sm" />
<Text>{t("income.refreshing")}</Text>
</Group>
)}
{!!refreshError && (
<Alert aria-live="polite" color="orange" role="status">
<Stack gap="sm">
<Text>
{t(
props.sourceError
? "investment.syncFailed"
: "income.refreshError",
)}
</Text>
<Button onClick={retry} variant="default">
{t("income.retry")}
</Button>
</Stack>
</Alert>
)}
<AccountIncomePageLoadedSummary {...props} /> <AccountIncomePageLoadedSummary {...props} />
<Alert color="blue">{t("income.taxBoundary")}</Alert> <Alert color="blue">{t("income.taxBoundary")}</Alert>
@@ -18,6 +18,7 @@ import {
LedgerEntryRow, LedgerEntryRow,
} from "@/entities/broker-operation"; } from "@/entities/broker-operation";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
import { defaultPeriod } from "@/shared/lib";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource"; import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
@@ -312,18 +313,20 @@ describe("AccountOperationsPage", () => {
"allows resetting filters after a request failure for %s", "allows resetting filters after a request failure for %s",
async (accountId) => { async (accountId) => {
configureTestAuth(); configureTestAuth();
const [defaultFrom] = defaultPeriod();
server.use( server.use(
http.get( http.get(
"*/api/v1/brokerage-accounts/:accountId/operations", "*/api/v1/brokerage-accounts/:accountId/operations",
({ request }) => ({ request }) =>
new URL(request.url).searchParams.has("from") new URL(request.url).searchParams.get("from")
? HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 }) === `${defaultFrom}T00:00:00.000Z`
: HttpResponse.json( ? HttpResponse.json(
resource( resource(
{ hasNext: false, items: [], nextCursor: null }, { hasNext: false, items: [], nextCursor: null },
), ),
), )
: HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 }),
), ),
); );
render( render(
@@ -346,7 +349,7 @@ describe("AccountOperationsPage", () => {
</MantineProvider> </MantineProvider>
</QueryClientProvider>, </QueryClientProvider>,
); );
await screen.findByRole("button", { name: "Повторить" }); await screen.findByRole("button", { name: "Сбросить" });
fireEvent.click(screen.getByRole("button", { name: "Сбросить" })); fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
await screen.findByLabelText("Период"); await screen.findByLabelText("Период");
expect(screen.getByLabelText("URL")).toBeEmptyDOMElement(); expect(screen.getByLabelText("URL")).toBeEmptyDOMElement();
@@ -484,7 +487,9 @@ describe("AccountOperationsPage", () => {
); );
renderPage(); renderPage();
expect( expect(
await screen.findByText("Не удалось загрузить журнал"), await screen.findByText("Не удалось загрузить журнал", undefined, {
timeout: 4000,
}),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
}); });
@@ -492,7 +497,6 @@ describe("AccountOperationsPage", () => {
it("keeps UTC operation boundaries in GET and source sync requests", async () => { it("keeps UTC operation boundaries in GET and source sync requests", async () => {
configureTestAuth(); configureTestAuth();
const requested: Record<string, string>[] = []; const requested: Record<string, string>[] = [];
const syncRequests: unknown[] = [];
server.use( server.use(
http.get( http.get(
"*/api/v1/brokerage-accounts/:accountId/operations", "*/api/v1/brokerage-accounts/:accountId/operations",
@@ -503,9 +507,10 @@ it("keeps UTC operation boundaries in GET and source sync requests", async () =>
resource( resource(
null, null,
{ {
sync: { lastAttempt: {
errorCode: "TBANK_UNAVAILABLE", finishedAt: "2026-08-14T00:01:00Z",
id: null, problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed", state: "failed",
}, },
}, },
@@ -513,14 +518,9 @@ it("keeps UTC operation boundaries in GET and source sync requests", async () =>
); );
}, },
), ),
http.post("*/api/v1/tbank-sync", async ({ request }) => {
syncRequests.push(await request.json());
return HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
}),
); );
renderPage("?from=2024-02-29&to=2024-03-01&categories=trade"); renderPage("?from=2024-02-29&to=2024-03-01&categories=trade");
const retry = await screen.findByRole("button", { name: "Повторить" }); await screen.findByRole("button", { name: "Сбросить" });
expect(requested).toEqual([ expect(requested).toEqual([
{ {
categories: "trade", categories: "trade",
@@ -529,20 +529,6 @@ it("keeps UTC operation boundaries in GET and source sync requests", async () =>
to: "2024-03-01T23:59:59.999Z", to: "2024-03-01T23:59:59.999Z",
}, },
]); ]);
expect(syncRequests).toEqual([]);
fireEvent.click(retry);
await waitFor(() =>
expect(syncRequests).toEqual([
{
accountId: "account-1",
categories: ["trade"],
from: "2024-02-29T00:00:00.000Z",
limit: 100,
screen: "operations",
to: "2024-03-01T23:59:59.999Z",
},
]),
);
}); });
it("does not label incomplete empty operations as a confirmed empty period", async () => { it("does not label incomplete empty operations as a confirmed empty period", async () => {
@@ -558,7 +544,7 @@ it("does not label incomplete empty operations as a confirmed empty period", asy
items: [], items: [],
nextCursor: null, nextCursor: null,
}, },
{ availability: "partial" }, { completeness: "incomplete" },
), ),
), ),
), ),
@@ -595,7 +581,6 @@ it("recovers an invalid cursor at the first page without clearing applied filter
); );
renderPage("?from=2026-08-01&to=2026-08-14&categories=trade"); renderPage("?from=2026-08-01&to=2026-08-14&categories=trade");
fireEvent.click(await screen.findByRole("button", { name: "Далее" })); fireEvent.click(await screen.findByRole("button", { name: "Далее" }));
fireEvent.click(await screen.findByRole("button", { name: "Повторить" }));
await waitFor(() => expect(searches).toHaveLength(3)); await waitFor(() => expect(searches).toHaveLength(3));
assert.isDefined(searches[0]); assert.isDefined(searches[0]);
assert.isDefined(searches[2]); assert.isDefined(searches[2]);
@@ -2,6 +2,8 @@ import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import { useParams } from "react-router"; import { useParams } from "react-router";
import type { InvestmentResourceMeta } from "@/shared/api";
import { import {
type BrokerageOperation, type BrokerageOperation,
brokerageOperationsQueryOptions, brokerageOperationsQueryOptions,
@@ -11,7 +13,13 @@ import {
} from "@/entities/broker-operation"; } from "@/entities/broker-operation";
import { useScreenSync } from "@/features/tbank-connection"; import { useScreenSync } from "@/features/tbank-connection";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
import { isValidPeriod, useUrlPeriod } from "@/shared/lib"; import {
defaultPeriod,
isValidPeriod,
moscowDate,
useInitialAvailability,
useUrlPeriod,
} from "@/shared/lib";
const operationCategorySet = new Set<string>(operationCategories); const operationCategorySet = new Set<string>(operationCategories);
@@ -24,21 +32,19 @@ type AccountOperationsPageViewModel
hasPrevious: boolean; hasPrevious: boolean;
historyLoading?: boolean; historyLoading?: boolean;
items: BrokerageOperation[]; items: BrokerageOperation[];
lastSuccessfulAt?: null | string; maxDate: string;
onApply: () => void; onApply: () => void;
onCategoriesChange: (value: string[]) => void; onCategoriesChange: (value: string[]) => void;
onNext: () => void; onNext: () => void;
onPeriodChange: (value: [null | string, null | string]) => void; onPeriodChange: (value: [null | string, null | string]) => void;
onPeriodPopoverOpen: () => void;
onPrevious: () => void; onPrevious: () => void;
onReset: () => void; onReset: () => void;
partial?: boolean; partial?: boolean;
refreshError?: boolean; source?: InvestmentResourceMeta;
refreshing?: boolean;
retry?: () => void;
sourceError?: boolean;
state: "loaded"; state: "loaded";
} }
| { onReset: () => void; retry: () => void; state: "error" } | { onReset: () => void; state: "error" }
| { state: "loading" }; | { state: "loading" };
const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => { const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
@@ -66,6 +72,8 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
const [draftCategories, setDraftCategories] const [draftCategories, setDraftCategories]
= useState<OperationCategory[]>(appliedCategories); = useState<OperationCategory[]>(appliedCategories);
const [maxDate, setMaxDate] = useState(() => moscowDate(new Date()));
const firstPageQuery = { const firstPageQuery = {
from: dateStart(appliedFrom), from: dateStart(appliedFrom),
to: dateEnd(appliedTo), to: dateEnd(appliedTo),
@@ -85,37 +93,52 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
history.reset(); history.reset();
} }
const sync = useScreenSync( useScreenSync(
{ accountId, screen: "operations", ...firstPageQuery }, { accountId, screen: "operations", ...firstPageQuery },
[operations], [operations],
); );
if (operations.isLoading) return { state: "loading" }; const availability = useInitialAvailability({
hasContent: !!operations.data,
identity: scope,
isError: operations.isError || operations.meta?.lastAttempt.state === "failed",
isFetching: operations.isFetching,
});
if (availability === "loading" && operations.isLoading)
return { state: "loading" };
if (history.isRecoveringInvalidCursor)
return { state: "loading" };
const resetQueryKey = brokerageOperationsQueryOptions(accountId, { const resetQueryKey = brokerageOperationsQueryOptions(accountId, {
limit: 100, limit: 100,
}).queryKey; }).queryKey;
const retry = () => history.retry(sync.retry);
const onReset = () => { const onReset = () => {
setDraftPeriod([null, null]); setDraftPeriod([appliedFrom, appliedTo]);
setDraftCategories([]); setDraftCategories([]);
setSearchParameters({}); setSearchParameters({});
history.reset(); history.reset();
void queryClient.invalidateQueries({ queryKey: resetQueryKey }); void queryClient.invalidateQueries({ queryKey: resetQueryKey });
}; };
const isInvalidCursor = isApiErrorCode(
apiErrorCode(operations.error),
ApiErrorCode.InvalidOperationsCursor,
);
if ( if (
(operations.isError || operations.meta?.sync.state === "failed") (isInvalidCursor || !operations.data)
&& (!operations.data && (operations.isError || operations.meta?.lastAttempt.state === "failed")
|| isApiErrorCode(
apiErrorCode(operations.error),
ApiErrorCode.InvalidOperationsCursor,
))
) )
return { onReset, retry, state: "error" }; return { onReset, state: "error" };
if (!operations.data) return { state: "loading" };
if (!operations.data)
return availability === "unavailable"
? { onReset, state: "error" }
: { state: "loading" };
const nextCursor = operations.data.nextCursor; const nextCursor = operations.data.nextCursor;
const isDirty const isDirty
@@ -133,7 +156,7 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
operations.meta?.coverage?.historyState === "loading" operations.meta?.coverage?.historyState === "loading"
|| operations.meta?.coverage?.historyState === "partial", || operations.meta?.coverage?.historyState === "partial",
items: operations.data.items, items: operations.data.items,
lastSuccessfulAt: operations.meta?.lastSuccessfulAt, maxDate,
onApply: () => { onApply: () => {
if (!isValidPeriod(draftFrom, draftTo)) return; if (!isValidPeriod(draftFrom, draftTo)) return;
setSearchParameters({ setSearchParameters({
@@ -151,14 +174,14 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
), ),
), ),
onNext: history.onNext, onNext: history.onNext,
onPeriodChange: setDraftPeriod, onPeriodChange: value => setDraftPeriod(
value[0] === null && value[1] === null ? defaultPeriod() : value,
),
onPeriodPopoverOpen: () => setMaxDate(moscowDate(new Date())),
onPrevious: history.onPrevious, onPrevious: history.onPrevious,
onReset, onReset,
partial: operations.meta?.availability !== "complete", partial: operations.meta?.completeness === "incomplete",
refreshError: operations.isError || sync.sourceError || sync.transportError, source: operations.meta,
refreshing: operations.isFetching || sync.refreshing,
retry,
sourceError: sync.sourceError,
state: "loaded", state: "loaded",
}; };
}; };
@@ -1,6 +1,6 @@
import type { FC } from "react"; import type { FC } from "react";
import { Button, Group } from "@mantine/core"; import { Button } from "@mantine/core";
import { StateView } from "@wealthledger/ui"; import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -14,21 +14,15 @@ type AccountOperationsPageErrorProps = Extract<
const AccountOperationsPageError: FC<AccountOperationsPageErrorProps> = ( const AccountOperationsPageError: FC<AccountOperationsPageErrorProps> = (
props, props,
) => { ) => {
const { retry } = props;
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<StateView <StateView
action={( action={(
<Group> <Button onClick={props.onReset} variant="default">
<Button onClick={retry}>{t("ledger.retry")}</Button> {t("ledger.reset")}
</Button>
<Button onClick={props.onReset} variant="default">
{t("ledger.reset")}
</Button>
</Group>
)} )}
error
text={t("ledger.errorText")} text={t("ledger.errorText")}
title={t("ledger.errorTitle")} title={t("ledger.errorTitle")}
/> />
@@ -5,7 +5,6 @@ import {
Button, Button,
Group, Group,
MultiSelect, MultiSelect,
Progress,
Stack, Stack,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
@@ -62,43 +61,29 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
return ( return (
<Stack gap="lg"> <Stack gap="lg">
<SourceSyncStatus lastSuccessfulAt={props.lastSuccessfulAt} />
<Title order={1}>{t("ledger.title")}</Title> <Title order={1}>{t("ledger.title")}</Title>
<Button disabled={props.refreshing} onClick={props.retry} w="fit-content"> <SourceSyncStatus
{t("investment.refresh")} lastAttempt={props.source?.lastAttempt}
</Button> lastSuccessfulAt={props.source?.lastSuccessfulAt}
stale={props.source?.freshness === "stale"}
/>
{!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>} {!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>}
{!!props.historyLoading && <Alert>{t("investment.history")}</Alert>} {!!props.historyLoading && <Alert>{t("investment.history")}</Alert>}
{!!props.refreshing && <Progress animated aria-label={t("accounts.refreshing")} value={100} />}
{!!props.refreshError && (
<Alert aria-live="polite" color="yellow" role="status">
{t(
props.sourceError
? "investment.syncFailed"
: "accounts.refreshError",
)}
<Button onClick={props.retry} variant="subtle">
{t("accounts.retry")}
</Button>
</Alert>
)}
<Group align="end"> <Group align="end">
<DatePickerInput <DatePickerInput
allowSingleDateInRange allowSingleDateInRange
clearable clearable
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }} clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
label={t("ledger.period")} label={t("ledger.period")}
maxDate={props.maxDate}
onChange={props.onPeriodChange} onChange={props.onPeriodChange}
popoverProps={{ popoverProps={{
middlewares: { shift: { crossAxis: true } }, middlewares: { shift: { crossAxis: true } },
onOpen: props.onPeriodPopoverOpen,
returnFocus: true, returnFocus: true,
}} }}
type="range" type="range"
+21 -29
View File
@@ -10,20 +10,18 @@ const RECENT_EVENTS_LIMIT = 5;
type AccountPageIncomeSummary type AccountPageIncomeSummary
= | { = | {
lastSuccessfulAt?: null | string;
refreshError?: boolean;
stale?: boolean;
state: "loaded";
summary: IncomeSummary;
to: string;
}
| { retry: () => Promise<unknown> | undefined; state: "error"; to: string }
| {
state: "empty" state: "empty"
| "loading" | "loading"
| "partial"; | "partial";
to: string; to: string;
}; }
| { state: "error"; to: string }
| {
state: "loaded";
summary: IncomeSummary;
to: string;
}
;
type AccountPageRecentEvents = BrokerageEventsPreviewState; type AccountPageRecentEvents = BrokerageEventsPreviewState;
@@ -48,29 +46,26 @@ type PreviewResource<T> = {
isError: boolean; isError: boolean;
meta?: Pick<InvestmentResourceMeta, meta?: Pick<InvestmentResourceMeta,
"availability" "availability"
| "completeness"
| "freshness" | "freshness"
| "lastAttempt"
| "lastSuccessfulAt" | "lastSuccessfulAt"
| "sync"
>; >;
}; };
function toIncomeSummary( function toIncomeSummary(
accountId: string | undefined, accountId: string | undefined,
income: PreviewResource<IncomeSummary>, income: PreviewResource<IncomeSummary>,
retry: () => Promise<unknown>,
): AccountPageIncomeSummary { ): AccountPageIncomeSummary {
const to = `/accounts/${accountId}/income`; const to = `/accounts/${accountId}/income`;
if (!income.data && !income.isError && income.meta?.sync.state !== "failed") if (!income.data && !income.isError && income.meta?.lastAttempt.state !== "failed")
return { state: "loading", to }; return { state: "loading", to };
if (income.meta?.availability === "partial") return { state: "partial", to }; if (income.meta?.completeness === "incomplete") return { state: "partial", to };
if (!income.data && (income.isError || income.meta?.sync.state === "failed")) if (!income.data && (income.isError || income.meta?.lastAttempt.state === "failed"))
return { retry, state: "error", to }; return { state: "error", to };
if (!income.data?.totalsByCurrency.length) return { state: "empty", to }; if (!income.data?.totalsByCurrency.length) return { state: "empty", to };
return { return {
lastSuccessfulAt: income.meta?.lastSuccessfulAt,
refreshError: income.isError || income.meta?.sync.state === "failed",
stale: income.meta?.freshness === "stale",
state: "loaded", state: "loaded",
summary: income.data, summary: income.data,
to, to,
@@ -82,24 +77,21 @@ function toRecentEvents(
events: PreviewResource<{ events: BrokerageEvent[] }>, events: PreviewResource<{ events: BrokerageEvent[] }>,
): AccountPageRecentEvents { ): AccountPageRecentEvents {
const to = `/accounts/${accountId}/events`; const to = `/accounts/${accountId}/events`;
if (!events.data && !events.isError && events.meta?.sync.state !== "failed") if (!events.data && !events.isError && events.meta?.lastAttempt.state !== "failed")
return { state: "loading", to }; return { state: "loading", to };
if (events.meta?.availability === "partial") if (events.meta?.completeness === "incomplete")
return { return {
items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [], items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [],
state: "partial", state: "partial",
to, to,
}; };
if (!events.data && (events.isError || events.meta?.sync.state === "failed")) if (!events.data && (events.isError || events.meta?.lastAttempt.state === "failed"))
return { state: "error", to }; return { state: "error", to };
const items = events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? []; const items = events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [];
if (items.length === 0) return { state: "empty", to }; if (items.length === 0) return { state: "empty", to };
return { return {
items, items,
lastSuccessfulAt: events.meta?.lastSuccessfulAt,
refreshError: events.isError || events.meta?.sync.state === "failed",
stale: events.meta?.freshness === "stale",
state: "loaded", state: "loaded",
to, to,
}; };
@@ -113,14 +105,14 @@ function toRecentOperations(
if ( if (
!operations.data !operations.data
&& !operations.isError && !operations.isError
&& operations.meta?.sync.state !== "failed" && operations.meta?.lastAttempt.state !== "failed"
) )
return { state: "loading", to }; return { state: "loading", to };
if (operations.meta?.availability === "partial") if (operations.meta?.completeness === "incomplete")
return { items: operations.data?.items ?? [], state: "partial", to }; return { items: operations.data?.items ?? [], state: "partial", to };
if ( if (
!operations.data !operations.data
&& (operations.isError || operations.meta?.sync.state === "failed") && (operations.isError || operations.meta?.lastAttempt.state === "failed")
) )
return { state: "error", to }; return { state: "error", to };
if (!operations.data?.items.length) return { state: "empty", to }; if (!operations.data?.items.length) return { state: "empty", to };
@@ -129,7 +121,7 @@ function toRecentOperations(
items: operations.data.items, items: operations.data.items,
lastSuccessfulAt: operations.meta?.lastSuccessfulAt, lastSuccessfulAt: operations.meta?.lastSuccessfulAt,
refreshError: refreshError:
operations.isError || operations.meta?.sync.state === "failed", operations.isError || operations.meta?.lastAttempt.state === "failed",
stale: operations.meta?.freshness === "stale", stale: operations.meta?.freshness === "stale",
state: "loaded", state: "loaded",
to, to,
@@ -1,7 +1,6 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { import {
fireEvent,
render, render,
screen, screen,
waitFor, waitFor,
@@ -261,7 +260,7 @@ describe("AccountPage states", () => {
it.each(["NOT_FOUND", "TBANK_NOT_CONNECTED", "INVALID_TBANK_TOKEN"])( it.each(["NOT_FOUND", "TBANK_NOT_CONNECTED", "INVALID_TBANK_TOKEN"])(
"does not offer retry without cached data for %s", "does not offer retry without cached data for %s",
(code) => { (code) => {
renderPage(<AccountPageError code={code} retry={vi.fn()} />); renderPage(<AccountPageError code={code} />);
expect( expect(
screen.queryByRole("button", { name: "Повторить" }), screen.queryByRole("button", { name: "Повторить" }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
@@ -274,12 +273,16 @@ describe("AccountPage states", () => {
}, },
); );
it.each(["TBANK_UNAVAILABLE", "UNKNOWN"])("allows retry for %s", (code) => { it.each(["TBANK_UNAVAILABLE", "UNKNOWN"])(
const retry = vi.fn(); "shows neutral unavailable without retry for %s",
renderPage(<AccountPageError code={code} retry={retry} />); (code) => {
fireEvent.click(screen.getByRole("button", { name: "Повторить" })); renderPage(<AccountPageError code={code} />);
expect(retry).toHaveBeenCalledOnce(); expect(screen.getByRole("heading", { name: "Пока недоступно" }))
}); .toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Повторить" }))
.not.toBeInTheDocument();
},
);
it("shows supplied cash separately without adding currency instruments or recalculating totals", () => { it("shows supplied cash separately without adding currency instruments or recalculating totals", () => {
renderPage( renderPage(
@@ -405,11 +408,9 @@ describe("AccountPage states", () => {
expect( expect(
screen.getByRole("heading", { level: 1, name: "Портфель" }), screen.getByRole("heading", { level: 1, name: "Портфель" }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(screen.queryByText(/T-Bank временно недоступен/))
screen.getByText( .not.toBeInTheDocument();
"T-Bank временно недоступен. Показан последний успешный снимок.", expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Акции" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Акции" })).toBeInTheDocument();
expect( expect(
screen.getByRole("button", { name: "Облигации" }), screen.getByRole("button", { name: "Облигации" }),
@@ -573,7 +574,7 @@ describe("AccountPage states", () => {
); );
expect( expect(
screen.getByText("Не удалось загрузить последние события."), screen.getByText("Пока недоступно"),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText("Вторая")).toBeInTheDocument(); expect(screen.getByText("Вторая")).toBeInTheDocument();
}); });
@@ -742,13 +743,13 @@ describe("AccountPage states", () => {
); );
expect( expect(
screen.getByText("Не удалось загрузить будущие события."), screen.getByText("Данные появятся здесь, когда чтение восстановится."),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText("Вторая")).toBeInTheDocument(); expect(screen.getByText("Вторая")).toBeInTheDocument();
}); });
it.each([503])( it.each([503])(
"preserves totals and UTC snapshot through refresh error %i, retry and success", "preserves totals quietly through refresh error %i and success",
async (status) => { async (status) => {
const initialPortfolio: Portfolio const initialPortfolio: Portfolio
= { = {
@@ -769,28 +770,6 @@ describe("AccountPage states", () => {
let requestCount = 0; let requestCount = 0;
let resolveRetry: ((response: Response) => void) | undefined; let resolveRetry: ((response: Response) => void) | undefined;
server.use( server.use(
http.post("*/api/v1/tbank-sync", () =>
HttpResponse.json(
{
id: "overview",
now: "2026-09-25T00:00:00Z",
scope: { accountId: "account-1", screen: "account" },
},
{ status: 202 },
),
),
http.get("*/api/v1/tbank-sync/:id", () =>
HttpResponse.json(
{
errorCode: null,
id: "overview",
now: "2026-09-25T00:00:00Z",
scope: { accountId: "account-1", screen: "account" },
state: "succeeded",
},
),
),
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => { http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
requestCount += 1; requestCount += 1;
if (requestCount === 1) if (requestCount === 1)
@@ -821,40 +800,30 @@ describe("AccountPage states", () => {
}); });
expect(structure).toHaveTextContent("10 ₽"); expect(structure).toHaveTextContent("10 ₽");
const snapshotText = screen.getByText(/Данные получены/).textContent; expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
expect(snapshotText).toContain("10:00");
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: portfolioQueryOptions("account-1").queryKey, queryKey: portfolioQueryOptions("account-1").queryKey,
}); });
expect( await waitFor(() => expect(requestCount).toBe(2));
await screen.findByText( expect(screen.queryByText(/Не удалось обновить портфель/))
"Не удалось обновить портфель. Показан последний полученный снимок.", .not.toBeInTheDocument();
), expect(screen.queryByText(/T-Bank временно недоступен/))
).toBeInTheDocument(); .not.toBeInTheDocument();
expect(
screen.getByText(
"T-Bank временно недоступен. Показан последний успешный снимок.",
),
).toBeInTheDocument();
expect(screen.getByText("Вторая")).toBeInTheDocument(); expect(screen.getByText("Вторая")).toBeInTheDocument();
expect( expect(
screen.getAllByRole("definition").map(node => node.textContent), screen.getAllByRole("definition").map(node => node.textContent),
).toEqual(["1 234,5 ₽", "0,5 $"]); ).toEqual(["1 234,5 ₽", "0,5 $"]);
// eslint-disable-next-line jest-dom/prefer-to-have-text-content -- The cached timestamp must remain exactly unchanged. expect(screen.queryByRole("button", { name: "Повторить" }))
expect(screen.getByText(/Данные получены/).textContent).toBe( .not.toBeInTheDocument();
snapshotText, void queryClient.invalidateQueries({
); queryKey: portfolioQueryOptions("account-1").queryKey,
fireEvent.click(screen.getByRole("button", { name: "Повторить" })); });
await waitFor(() => expect(requestCount).toBe(3)); await waitFor(() => expect(requestCount).toBe(3));
expect(structure).toHaveTextContent("10 ₽"); expect(structure).toHaveTextContent("10 ₽");
expect( expect(
screen.getAllByRole("definition").map(node => node.textContent), screen.getAllByRole("definition").map(node => node.textContent),
).toEqual(["1 234,5 ₽", "0,5 $"]); ).toEqual(["1 234,5 ₽", "0,5 $"]);
// eslint-disable-next-line jest-dom/prefer-to-have-text-content -- The cached timestamp must remain exactly unchanged.
expect(screen.getByText(/Данные получены/).textContent).toBe(
snapshotText,
);
resolveRetry?.( resolveRetry?.(
HttpResponse.json( HttpResponse.json(
resource( resource(
@@ -875,7 +844,7 @@ describe("AccountPage states", () => {
screen.getAllByRole("definition").map(node => node.textContent), screen.getAllByRole("definition").map(node => node.textContent),
).toEqual(["-0,5 $", "2,50 €"]), ).toEqual(["-0,5 $", "2,50 €"]),
); );
expect(screen.getByText(/Данные получены/)).toHaveTextContent("11:30"); expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
expect(structure).not.toHaveTextContent("10 ₽"); expect(structure).not.toHaveTextContent("10 ₽");
expect(structure).toHaveTextContent("Нет положительной стоимости"); expect(structure).toHaveTextContent("Нет положительной стоимости");
expect( expect(
@@ -916,13 +885,12 @@ describe("AccountPage states", () => {
expect( expect(
screen.getAllByRole("definition").map(node => node.textContent), screen.getAllByRole("definition").map(node => node.textContent),
).toEqual(["1 234,5 ₽", "0,5 $"]); ).toEqual(["1 234,5 ₽", "0,5 $"]);
expect(screen.getByText(/Данные получены/)).toHaveTextContent("10:00"); expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
expect( expect(
screen.queryByRole("status", { name: "Загрузка" }), screen.queryByRole("status", { name: "Загрузка" }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
expect( expect(screen.queryByRole("status", { name: "Обновляем портфель" }))
screen.getByRole("status", { name: "Обновляем портфель" }), .not.toBeInTheDocument();
).toBeInTheDocument();
resolveRefresh?.(HttpResponse.json(resource(portfolio))); resolveRefresh?.(HttpResponse.json(resource(portfolio)));
}); });
@@ -18,10 +18,6 @@ const AccountPage: FC = () => {
incomeSummary={viewModel.incomeSummary} incomeSummary={viewModel.incomeSummary}
recentEvents={viewModel.recentEvents} recentEvents={viewModel.recentEvents}
recentOperations={viewModel.recentOperations} recentOperations={viewModel.recentOperations}
refreshError={viewModel.refreshError}
refreshing={viewModel.refreshing}
retry={viewModel.retry}
sourceError={viewModel.sourceError}
/> />
); );
@@ -35,10 +31,6 @@ const AccountPage: FC = () => {
positionSections={viewModel.positionSections} positionSections={viewModel.positionSections}
recentEvents={viewModel.recentEvents} recentEvents={viewModel.recentEvents}
recentOperations={viewModel.recentOperations} recentOperations={viewModel.recentOperations}
refreshError={viewModel.refreshError}
refreshing={viewModel.refreshing}
retry={viewModel.retry}
sourceError={viewModel.sourceError}
/> />
); );
}; };
@@ -1,7 +1,7 @@
import type { FC, PropsWithChildren } from "react"; import type { FC, PropsWithChildren } from "react";
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { act, renderHook, waitFor } from "@testing-library/react"; import { renderHook, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw"; import { http, HttpResponse } from "msw";
import { MemoryRouter, Route, Routes } from "react-router"; import { MemoryRouter, Route, Routes } from "react-router";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
@@ -147,10 +147,10 @@ describe("useAccountPageViewModel", () => {
HttpResponse.json(resource(portfolio)), HttpResponse.json(resource(portfolio)),
), ),
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }), HttpResponse.json({ code: "DEMO_FAILURE" }, { status: 400 }),
), ),
http.get("*/api/v1/brokerage-accounts/:accountId/events", () => http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
HttpResponse.json({ code: "MOEX_UNAVAILABLE" }, { status: 503 }), HttpResponse.json({ code: "DEMO_FAILURE" }, { status: 400 }),
), ),
); );
@@ -173,14 +173,14 @@ it("keeps overview sections independent when their source data is incomplete", a
mockLoadedAccountPage(); mockLoadedAccountPage();
server.use( server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(resource(portfolio, { availability: "partial" })), HttpResponse.json(resource(portfolio, { completeness: "incomplete" })),
), ),
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
HttpResponse.json( HttpResponse.json(
resource( resource(
{ hasNext: false, items: [], nextCursor: null }, { hasNext: false, items: [], nextCursor: null },
{ availability: "partial" }, { completeness: "incomplete" },
), ),
), ),
), ),
@@ -188,7 +188,7 @@ it("keeps overview sections independent when their source data is incomplete", a
HttpResponse.json( HttpResponse.json(
resource( resource(
{ events: [], incomeTotalsByCurrency: [] }, { events: [], incomeTotalsByCurrency: [] },
{ availability: "partial" }, { completeness: "incomplete" },
), ),
), ),
), ),
@@ -208,7 +208,7 @@ it("keeps overview sections independent when their source data is incomplete", a
); );
}); });
it("refreshes every overview section through one account descriptor and observational reads", async () => { it("keeps overview sections loaded without exposing technical retry", async () => {
configureTestAuth(); configureTestAuth();
const sections: Record<string, unknown> const sections: Record<string, unknown>
@@ -219,48 +219,17 @@ it("refreshes every overview section through one account descriptor and observat
portfolio, portfolio,
}; };
const observed: string[] = [];
const scopes: unknown[] = [];
let version = "1";
server.use( server.use(
...Object.entries(sections).map(([path, data]) => ...Object.entries(sections).map(([path, data]) =>
http.get( http.get(
`*/api/v1/brokerage-accounts/:accountId/${path}`, `*/api/v1/brokerage-accounts/:accountId/${path}`,
({ request }) => { () => {
if (new URL(request.url).searchParams.get("sync") === "observe")
observed.push(path);
return HttpResponse.json( return HttpResponse.json(
resource(data, { dataRevision: version, resourceKey: path }), resource(data, { dataRevision: "1", resourceKey: path }),
); );
}, },
), ),
), ),
http.post("*/api/v1/tbank-sync", async ({ request }) => {
scopes.push(await request.json());
version = "2";
return HttpResponse.json(
{
id: "overview",
now: "2026-09-25T00:00:00Z",
scope: { accountId: "account-1", screen: "account" },
},
{ status: 202 },
);
}),
http.get("*/api/v1/tbank-sync/:id", () =>
HttpResponse.json(
{
errorCode: null,
id: "overview",
now: "2026-09-25T00:00:00Z",
scope: null,
state: "succeeded",
},
),
),
); );
const { result } = renderHook(() => useAccountPageViewModel(), { const { result } = renderHook(() => useAccountPageViewModel(), {
@@ -275,11 +244,5 @@ it("refreshes every overview section through one account descriptor and observat
state: "loaded", state: "loaded",
}), }),
); );
await act(async () => { expect(result.current).not.toHaveProperty("retry");
if (result.current.state === "loaded") await result.current.retry();
});
await waitFor(() =>
expect(new Set(observed)).toEqual(new Set(Object.keys(sections))),
);
expect(scopes).toEqual([{ accountId: "account-1", screen: "account" }]);
}); });
@@ -1,6 +1,7 @@
import { useParams } from "react-router"; import { useParams } from "react-router";
import type { Portfolio } from "@/entities/portfolio"; import type { Portfolio } from "@/entities/portfolio";
import type { InvestmentResourceMeta } from "@/shared/api";
import { useBrokerageOperations } from "@/entities/broker-operation"; import { useBrokerageOperations } from "@/entities/broker-operation";
import { import {
@@ -13,7 +14,8 @@ import {
import { useIncomeSummary } from "@/entities/income"; import { useIncomeSummary } from "@/entities/income";
import { usePortfolio } from "@/entities/portfolio"; import { usePortfolio } from "@/entities/portfolio";
import { useScreenSync } from "@/features/tbank-connection"; import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api"; import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
import { useInitialAvailability } from "@/shared/lib";
import { import {
type AccountPagePositionSection, type AccountPagePositionSection,
@@ -35,10 +37,6 @@ type AccountPageContent = {
partial?: boolean; partial?: boolean;
recentEvents: AccountPageRecentEvents; recentEvents: AccountPageRecentEvents;
recentOperations: AccountPageRecentOperations; recentOperations: AccountPageRecentOperations;
refreshError: boolean;
refreshing: boolean;
retry: () => Promise<unknown> | undefined;
sourceError?: boolean;
}; };
type AccountPageEmptyViewModel = AccountPageContent & { type AccountPageEmptyViewModel = AccountPageContent & {
@@ -48,15 +46,23 @@ type AccountPageEmptyViewModel = AccountPageContent & {
type AccountPageLoadedViewModel = AccountPageContent & { type AccountPageLoadedViewModel = AccountPageContent & {
portfolio: Portfolio; portfolio: Portfolio;
positionSections: AccountPagePositionSection[]; positionSections: AccountPagePositionSection[];
source?: InvestmentResourceMeta;
state: "loaded"; state: "loaded";
}; };
type AccountPageViewModel type AccountPageViewModel
= | AccountPageEmptyViewModel = | AccountPageEmptyViewModel
| AccountPageLoadedViewModel | AccountPageLoadedViewModel
| { code?: string; retry: () => Promise<unknown> | undefined; state: "error" } | { code?: string; state: "error" }
| { state: "loading" }; | { state: "loading" };
function isPortfolioAccessFailure(code?: string) {
return isApiErrorCode(code, ApiErrorCode.Forbidden)
|| isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)
|| isApiErrorCode(code, ApiErrorCode.NotFound)
|| isApiErrorCode(code, ApiErrorCode.TbankNotConnected);
}
function useAccountPageViewModel(): AccountPageViewModel { function useAccountPageViewModel(): AccountPageViewModel {
const { accountId } = useParams(); const { accountId } = useParams();
const portfolio = usePortfolio(accountId); const portfolio = usePortfolio(accountId);
@@ -69,7 +75,7 @@ function useAccountPageViewModel(): AccountPageViewModel {
const income = useIncomeSummary(accountId); const income = useIncomeSummary(accountId);
const accounts = useBrokerageAccounts(); const accounts = useBrokerageAccounts();
const sync = useScreenSync({ accountId, screen: "account" }, [ useScreenSync({ accountId, screen: "account" }, [
accounts, accounts,
portfolio, portfolio,
operations, operations,
@@ -77,35 +83,31 @@ function useAccountPageViewModel(): AccountPageViewModel {
income, income,
]); ]);
if (!portfolio.data) { const code = apiErrorCode(portfolio.error);
if (portfolio.isError || portfolio.meta?.sync.state === "failed") const isAccessFailure = isPortfolioAccessFailure(code);
return {
code: apiErrorCode(portfolio.error),
retry: sync.retry,
state: "error" as const,
};
return { state: "loading" as const }; const availability = useInitialAvailability({
} hasContent: !!portfolio.data,
identity: accountId ?? "account",
isError: portfolio.isError || portfolio.meta?.lastAttempt.state === "failed",
isFetching: portfolio.isFetching,
});
if (isAccessFailure) return { code, state: "error" as const };
if (!portfolio.data)
return {
state: availability === "unavailable" ? "error" : "loading",
} as const;
const account = accounts.data?.find(item => item.id === accountId); const account = accounts.data?.find(item => item.id === accountId);
const recentOperations = toRecentOperations(accountId, operations); const recentOperations = toRecentOperations(accountId, operations);
const recentEvents = toRecentEvents(accountId, events); const recentEvents = toRecentEvents(accountId, events);
const incomeSummary = toIncomeSummary( const incomeSummary = toIncomeSummary(accountId, income);
accountId, income, () => income.refetch(),
);
const refresh = {
partial: portfolio.meta?.availability === "partial",
refreshError: portfolio.isError || sync.transportError || sync.sourceError,
refreshing: portfolio.isFetching || sync.refreshing,
retry: sync.retry,
sourceError: sync.sourceError,
};
if ( if (
portfolio.meta?.availability === "complete" portfolio.meta?.availability === "available"
&& portfolio.data.positions.length === 0 && portfolio.data.positions.length === 0
&& portfolio.data.cashBalances.length === 0 && portfolio.data.cashBalances.length === 0
) )
@@ -116,19 +118,21 @@ function useAccountPageViewModel(): AccountPageViewModel {
recentEvents, recentEvents,
recentOperations, recentOperations,
state: "empty" as const, state: "empty" as const,
...refresh,
}; };
const isPartial = portfolio.meta?.completeness === "incomplete";
return { return {
account, account,
accountId, accountId,
incomeSummary, incomeSummary,
partial: isPartial,
portfolio: portfolio.data, portfolio: portfolio.data,
positionSections: toAccountPagePositionSections(portfolio.data), positionSections: toAccountPagePositionSections(portfolio.data),
recentEvents, recentEvents,
recentOperations, recentOperations,
source: portfolio.meta,
state: "loaded" as const, state: "loaded" as const,
...refresh,
}; };
} }
@@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next";
import type { BrokerageAccount } from "@/entities/brokerage-account"; import type { BrokerageAccount } from "@/entities/brokerage-account";
import { BrokerageEventsPreview } from "@/entities/brokerage-event"; import { BrokerageEventsPreview } from "@/entities/brokerage-event";
import { InvestmentRefreshState } from "@/features/tbank-connection"; import { PortfolioStructure } from "@/widgets/portfolio-structure";
import type { import type {
AccountPageIncomeSummary, AccountPageIncomeSummary,
@@ -17,7 +17,6 @@ import type {
import { AccountHeading } from "./AccountHeading.view"; import { AccountHeading } from "./AccountHeading.view";
import { IncomeSummary } from "./IncomeSummary.view"; import { IncomeSummary } from "./IncomeSummary.view";
import { PortfolioStructure } from "./PortfolioStructure/PortfolioStructure";
import { RecentOperations } from "./RecentOperations.view"; import { RecentOperations } from "./RecentOperations.view";
type AccountPageEmptyProps = { type AccountPageEmptyProps = {
@@ -26,10 +25,6 @@ type AccountPageEmptyProps = {
incomeSummary?: AccountPageIncomeSummary; incomeSummary?: AccountPageIncomeSummary;
recentEvents: AccountPageRecentEvents; recentEvents: AccountPageRecentEvents;
recentOperations: AccountPageRecentOperations; recentOperations: AccountPageRecentOperations;
refreshError?: boolean;
refreshing?: boolean;
retry?: () => void;
sourceError?: boolean;
}; };
const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => { const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
@@ -45,14 +40,6 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
<Stack> <Stack>
<AccountHeading account={props.account} accountId={props.accountId} /> <AccountHeading account={props.account} accountId={props.accountId} />
<InvestmentRefreshState
labelScope="portfolio"
refreshError={props.refreshError ?? false}
refreshing={props.refreshing ?? false}
retry={props.retry}
sourceError={props.sourceError}
/>
<StateView text={text} title={t("portfolio.emptyTitle")} /> <StateView text={text} title={t("portfolio.emptyTitle")} />
<PortfolioStructure <PortfolioStructure
@@ -7,10 +7,10 @@ import { Link } from "react-router";
import { ApiErrorCode, isApiErrorCode } from "@/shared/api"; import { ApiErrorCode, isApiErrorCode } from "@/shared/api";
type AccountPageErrorProps = { code?: string; retry: () => void }; type AccountPageErrorProps = { code?: string };
const AccountPageError: FC<AccountPageErrorProps> = (props) => { const AccountPageError: FC<AccountPageErrorProps> = (props) => {
const { code, retry } = props; const { code } = props;
const { t } = useTranslation(); const { t } = useTranslation();
if (isApiErrorCode(code, ApiErrorCode.NotFound)) if (isApiErrorCode(code, ApiErrorCode.NotFound))
return ( return (
@@ -25,6 +25,7 @@ const AccountPageError: FC<AccountPageErrorProps> = (props) => {
title={t("portfolio.accountUnavailableTitle")} title={t("portfolio.accountUnavailableTitle")}
/> />
); );
if ( if (
isApiErrorCode(code, ApiErrorCode.TbankNotConnected) isApiErrorCode(code, ApiErrorCode.TbankNotConnected)
|| isApiErrorCode(code, ApiErrorCode.InvalidTbankToken) || isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)
@@ -41,22 +42,11 @@ const AccountPageError: FC<AccountPageErrorProps> = (props) => {
title={t("portfolio.connectionErrorTitle")} title={t("portfolio.connectionErrorTitle")}
/> />
); );
if (isApiErrorCode(code, ApiErrorCode.TbankUnavailable))
return (
<StateView
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
error
text={t("accounts.unavailableText")}
title={t("accounts.unavailableTitle")}
/>
);
return ( return (
<StateView <StateView
action={<Button onClick={retry}>{t("accounts.retry")}</Button>} text={t("states.unavailableText")}
error title={t("states.unavailableTitle")}
text={t("states.errorText")}
title={t("states.errorTitle")}
/> />
); );
}; };
@@ -1,14 +1,15 @@
import type { FC } from "react"; import type { FC } from "react";
import { Accordion, Alert, Stack, Text } from "@mantine/core"; import { Accordion, Alert, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { BrokerageAccount } from "@/entities/brokerage-account"; import type { BrokerageAccount } from "@/entities/brokerage-account";
import type { Portfolio } from "@/entities/portfolio"; import type { Portfolio } from "@/entities/portfolio";
import type { InvestmentResourceMeta } from "@/shared/api";
import { BrokerageEventsPreview } from "@/entities/brokerage-event"; import { BrokerageEventsPreview } from "@/entities/brokerage-event";
import { InvestmentRefreshState } from "@/features/tbank-connection"; import { SourceSyncStatus } from "@/shared/ui";
import { formatSnapshotTime } from "@/shared/lib"; import { PortfolioStructure } from "@/widgets/portfolio-structure";
import type { AccountPagePositionSection } from "../../../model/positions"; import type { AccountPagePositionSection } from "../../../model/positions";
import type { import type {
@@ -21,7 +22,6 @@ import classes from "../AccountPage.module.css";
import { AccountHeading } from "./AccountHeading.view"; import { AccountHeading } from "./AccountHeading.view";
import { CurrencyCards } from "./CurrencyCards/CurrencyCards.view"; import { CurrencyCards } from "./CurrencyCards/CurrencyCards.view";
import { IncomeSummary } from "./IncomeSummary.view"; import { IncomeSummary } from "./IncomeSummary.view";
import { PortfolioStructure } from "./PortfolioStructure/PortfolioStructure";
import { PositionSection } from "./PositionSection.view"; import { PositionSection } from "./PositionSection.view";
import { RecentOperations } from "./RecentOperations.view"; import { RecentOperations } from "./RecentOperations.view";
@@ -34,10 +34,7 @@ type AccountPageLoadedProps = {
positionSections: AccountPagePositionSection[]; positionSections: AccountPagePositionSection[];
recentEvents: AccountPageRecentEvents; recentEvents: AccountPageRecentEvents;
recentOperations: AccountPageRecentOperations; recentOperations: AccountPageRecentOperations;
refreshError?: boolean; source?: InvestmentResourceMeta;
refreshing?: boolean;
retry?: () => void;
sourceError?: boolean;
}; };
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => { const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
@@ -55,27 +52,12 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
<Stack gap="lg"> <Stack gap="lg">
<AccountHeading account={props.account} accountId={props.accountId} /> <AccountHeading account={props.account} accountId={props.accountId} />
<InvestmentRefreshState <SourceSyncStatus
labelScope="portfolio" lastAttempt={props.source?.lastAttempt}
refreshError={props.refreshError ?? false} lastSuccessfulAt={props.source?.lastSuccessfulAt}
refreshing={props.refreshing ?? false} stale={props.source?.freshness === "stale"}
retry={props.retry}
sourceError={props.sourceError}
/> />
{!!props.portfolio.isStale && <Alert color="blue">{t("portfolio.staleWarning")}</Alert>}
<Text c="dimmed" size="sm">
{t(
props.accountId === "all"
? "portfolio.allUpdatedAt"
: "portfolio.updatedAt",
{
date: formatSnapshotTime(props.portfolio.lastSuccessfulAt),
},
)}
</Text>
{props.partial {props.partial
? <Alert role="status">{t("investment.partial")}</Alert> ? <Alert role="status">{t("investment.partial")}</Alert>
: <CurrencyCards amounts={props.portfolio.totalsByCurrency} />} : <CurrencyCards amounts={props.portfolio.totalsByCurrency} />}
@@ -8,30 +8,30 @@ import { Link } from "react-router";
import type { IncomeSummary as IncomeSummaryData } from "@/entities/income"; import type { IncomeSummary as IncomeSummaryData } from "@/entities/income";
import { formatMoney } from "@/shared/lib"; import { formatMoney } from "@/shared/lib";
import { SourceSyncStatus } from "@/shared/ui";
type IncomeSummaryProps type IncomeSummaryProps
= | { = | {
lastSuccessfulAt?: null | string; state: "empty";
refreshError?: boolean; to: string;
stale?: boolean; }
| { state: "error"; to: string }
| {
state: "loaded"; state: "loaded";
summary: IncomeSummaryData; summary: IncomeSummaryData;
to: string; to: string;
} }
| { retry: () => void; state: "error"; to: string } | {
| { state: "empty"; to: string } state: "loading";
| { state: "loading"; to: string } to: string;
| { state: "partial"; to: string }; }
| {
state: "partial";
to: string;
};
const IncomeSummary: FC<IncomeSummaryProps> = (props) => { const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const errorText
= props.state === "loaded" && props.refreshError
? t("income.refreshError")
: null;
if (props.state === "loading" || props.state === "partial") if (props.state === "loading" || props.state === "partial")
return ( return (
<Text c="dimmed" role="status"> <Text c="dimmed" role="status">
@@ -45,9 +45,6 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
if (props.state === "error") if (props.state === "error")
return ( return (
<StateView <StateView
action={
<Button onClick={props.retry}>{t("income.summaryRetry")}</Button>
}
error error
text={t("income.errorText")} text={t("income.errorText")}
title={t("income.summaryError")} title={t("income.summaryError")}
@@ -66,12 +63,6 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
return ( return (
<Stack gap="xs"> <Stack gap="xs">
<SourceSyncStatus
errorText={errorText}
lastSuccessfulAt={props.lastSuccessfulAt}
stale={props.stale}
/>
{props.summary.totalsByCurrency.map(total => ( {props.summary.totalsByCurrency.map(total => (
<Paper key={total.currency} p="sm" withBorder> <Paper key={total.currency} p="sm" withBorder>
<Text fw={700}>{formatMoney(total.total)}</Text> <Text fw={700}>{formatMoney(total.total)}</Text>
@@ -1,12 +1,11 @@
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 { LedgerEntryRow } from "@/entities/broker-operation"; import { LedgerEntryRow } from "@/entities/broker-operation";
import { SourceSyncStatus } from "@/shared/ui";
import type { AccountPageRecentOperations } from "../../../model/previews"; import type { AccountPageRecentOperations } from "../../../model/previews";
@@ -18,28 +17,15 @@ const RecentOperations: FC<RecentOperationsProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { operations } = props; const { operations } = props;
const errorText
= operations.state === "loaded" && operations.refreshError
? t("accounts.refreshError")
: null;
return ( return (
<Stack gap="sm"> <Stack gap="sm">
{operations.state === "loaded" && (
<SourceSyncStatus
errorText={errorText}
lastSuccessfulAt={operations.lastSuccessfulAt}
stale={operations.stale}
/>
)}
{operations.state === "loading" && ( {operations.state === "loading" && (
<LoadingSkeleton label={t("ledger.recentLoading")}> <LoadingSkeleton label={t("ledger.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>
)} )}
{operations.state === "error" && <Alert color="red">{t("ledger.recentError")}</Alert>} {operations.state === "error" && <Text c="dimmed">{t("states.unavailableTitle")}</Text>}
{operations.state === "empty" && <Text c="dimmed">{t("ledger.recentEmpty")}</Text>} {operations.state === "empty" && <Text c="dimmed">{t("ledger.recentEmpty")}</Text>}
@@ -1,6 +1,6 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { render, screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw"; import { http, HttpResponse } from "msw";
import { MemoryRouter } from "react-router"; import { MemoryRouter } from "react-router";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
@@ -58,10 +58,11 @@ describe("AccountsPage", () => {
}), }),
); );
renderPage(createTestQueryClient()); renderPage(createTestQueryClient());
fireEvent.click(await screen.findByRole("button", { name: "Повторить" })); expect(await screen.findByText(account.name, undefined, {
expect(await screen.findByText(account.name)).toBeVisible(); timeout: 4000,
})).toBeVisible();
expect(modes).toEqual([null, "observe"]); expect(modes).toEqual([null, null]);
}); });
it.each([ it.each([
["loading", AccountsPageLoading, "status"], ["loading", AccountsPageLoading, "status"],
@@ -81,17 +82,16 @@ describe("AccountsPage", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("routes connection errors to settings and keeps retry available", () => { it("routes connection errors to settings without technical retry", () => {
const retry = vi.fn();
render( render(
<MantineProvider> <MantineProvider>
<MemoryRouter> <MemoryRouter>
<AccountsPageError code="TBANK_UNAVAILABLE" retry={retry} /> <AccountsPageError code="TBANK_NOT_CONNECTED" />
</MemoryRouter> </MemoryRouter>
</MantineProvider>, </MantineProvider>,
); );
fireEvent.click(screen.getByRole("button", { name: "Повторить" })); expect(screen.queryByRole("button", { name: "Повторить" }))
expect(retry).toHaveBeenCalledOnce(); .not.toBeInTheDocument();
expect( expect(
screen.getByRole("link", { name: "Открыть настройки T-Bank" }), screen.getByRole("link", { name: "Открыть настройки T-Bank" }),
).toHaveAttribute("href", "/settings"); ).toHaveAttribute("href", "/settings");
@@ -123,25 +123,13 @@ describe("AccountsPage", () => {
await waitFor(() => expect(requestCount).toBe(2)); await waitFor(() => expect(requestCount).toBe(2));
expect(screen.getByText(account.name)).toBeInTheDocument(); expect(screen.getByText(account.name)).toBeInTheDocument();
expect( expect(screen.queryByRole("status", { name: "Обновляем данные" }))
screen.getByRole("status", { name: "Обновляем данные" }), .not.toBeInTheDocument();
).toBeInTheDocument();
resolveRefresh?.(HttpResponse.json(resource([account]))); resolveRefresh?.(HttpResponse.json(resource([account])));
}); });
it("keeps the cached account list and offers retry after a refresh error", async () => { it("keeps the cached account list quietly after a refresh error", async () => {
const requests: unknown[] = [];
server.use(
http.post("*/api/v1/tbank-sync", async ({ request }) => {
requests.push(await request.json());
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
}),
);
let requestCount = 0; let requestCount = 0;
server.use( server.use(
http.get("*/api/v1/brokerage-accounts", () => { http.get("*/api/v1/brokerage-accounts", () => {
@@ -161,18 +149,15 @@ describe("AccountsPage", () => {
queryKey: brokerageAccountsQueryKey, queryKey: brokerageAccountsQueryKey,
}); });
const warning = await screen.findByText( await waitFor(() => expect(requestCount).toBe(2));
"Не удалось обновить данные. Показаны последние полученные.", expect(
); screen.queryByText(
"Не удалось обновить данные. Показаны последние полученные.",
expect(warning).toBeInTheDocument(); ),
).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Повторить" }))
.not.toBeInTheDocument();
expect(screen.getByText(account.name)).toBeInTheDocument(); expect(screen.getByText(account.name)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
await waitFor(() => expect(requestCount).toBe(3));
await waitFor(() => expect(warning).not.toBeInTheDocument());
expect(screen.getByText(account.name)).toBeVisible();
expect(requests).toEqual([]);
}); });
it("keeps a cached empty account list visible after a refresh error", async () => { it("keeps a cached empty account list visible after a refresh error", async () => {
@@ -194,23 +179,29 @@ describe("AccountsPage", () => {
queryKey: brokerageAccountsQueryKey, queryKey: brokerageAccountsQueryKey,
}); });
await waitFor(() => expect(requestCount).toBe(2));
expect( expect(
await screen.findByText( screen.queryByText(
"Не удалось обновить данные. Показаны последние полученные.", "Не удалось обновить данные. Показаны последние полученные.",
), ),
).toBeInTheDocument(); ).not.toBeInTheDocument();
expect(screen.getByText("Нет инвестиционных счетов")).toBeInTheDocument(); expect(screen.getByText("Нет инвестиционных счетов")).toBeInTheDocument();
}); });
}); });
it.each(["idle", "failed"] as const)( it.each(["none", "failed"] as const)(
"keeps partial empty membership distinct from no accounts (sync=%s)", "keeps incomplete empty membership distinct from no accounts (attempt=%s)",
async (state) => { async (state) => {
server.use( server.use(
http.get("*/api/v1/brokerage-accounts", () => http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json(resource([], { HttpResponse.json(resource([], {
availability: "partial", completeness: "incomplete",
sync: { errorCode: null, id: null, state }, lastAttempt: {
finishedAt: state === "failed" ? "2026-08-14T00:01:00Z" : null,
problems: [],
startedAt: state === "none" ? null : "2026-08-14T00:00:00Z",
state,
},
})), })),
), ),
); );
@@ -219,14 +210,14 @@ it.each(["idle", "failed"] as const)(
await waitFor(() => await waitFor(() =>
expect(client.getQueryData(brokerageAccountsQueryKey)).toMatchObject({ expect(client.getQueryData(brokerageAccountsQueryKey)).toMatchObject({
data: [], data: [],
meta: { availability: "partial" }, meta: { completeness: "incomplete" },
}), }),
); );
expect(screen.queryByText("Нет инвестиционных счетов")) expect(screen.queryByText("Нет инвестиционных счетов"))
.not.toBeInTheDocument(); .not.toBeInTheDocument();
if (state === "failed") if (state === "failed")
// eslint-disable-next-line vitest/no-conditional-expect -- Each fixed state has a different observable recovery contract. // eslint-disable-next-line vitest/no-conditional-expect -- Each fixed state has a different observable recovery contract.
expect(await screen.findByRole("button", { name: "Повторить" })) expect(await screen.findByRole("heading", { name: "Пока недоступно" }))
.toBeVisible(); .toBeVisible();
else else
// eslint-disable-next-line vitest/no-conditional-expect -- Each fixed state has a different observable recovery contract. // eslint-disable-next-line vitest/no-conditional-expect -- Each fixed state has a different observable recovery contract.
@@ -10,25 +10,12 @@ const AccountsPage: FC = () => {
const viewModel = useAccountsPageViewModel(); const viewModel = useAccountsPageViewModel();
if (viewModel.state === "loading") return <AccountsPageLoading />; if (viewModel.state === "loading") return <AccountsPageLoading />;
if (viewModel.state === "error") return <AccountsPageError {...viewModel} />; if (viewModel.state === "error") return <AccountsPageError {...viewModel} />;
if (viewModel.state === "empty") if (viewModel.state === "empty") return <AccountsPageEmpty />;
return (
<AccountsPageEmpty
lastSuccessfulAt={viewModel.lastSuccessfulAt}
refreshError={viewModel.refreshError}
refreshing={viewModel.refreshing}
retry={viewModel.retry}
sourceError={viewModel.sourceError}
/>
);
return ( return (
<AccountsPageLoaded <AccountsPageLoaded
accounts={viewModel.accounts} accounts={viewModel.accounts}
lastSuccessfulAt={viewModel.lastSuccessfulAt} meta={viewModel.meta}
refreshError={viewModel.refreshError}
refreshing={viewModel.refreshing}
retry={viewModel.retry}
sourceError={viewModel.sourceError}
/> />
); );
}; };
@@ -1,45 +1,59 @@
import { import {
type BrokerageAccountCard,
brokerageAccountCards, brokerageAccountCards,
useBrokerageAccounts, useBrokerageAccounts,
} from "@/entities/brokerage-account"; } from "@/entities/brokerage-account";
import { useScreenSync } from "@/features/tbank-connection"; import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api"; import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
import { useInitialAvailability } from "@/shared/lib";
function useAccountsPageViewModel() { type AccountsPageViewModel
= | { accounts: BrokerageAccountCard[]; meta: NonNullable<ReturnType<typeof useBrokerageAccounts>["meta"]>; state: "loaded" }
| { code?: string; state: "error" }
| { state: "empty" }
| { state: "loading" };
function isAccountsAccessFailure(code?: string) {
return isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)
|| isApiErrorCode(code, ApiErrorCode.TbankNotConnected);
}
function useAccountsPageViewModel(): AccountsPageViewModel {
const accounts = useBrokerageAccounts(); const accounts = useBrokerageAccounts();
const sync = useScreenSync({ screen: "accounts" }, [accounts]); useScreenSync({ screen: "accounts" }, [accounts]);
if ( const code = apiErrorCode(accounts.error);
!accounts.data const isAccessFailure = isAccountsAccessFailure(code);
|| (accounts.meta?.availability === "partial" && accounts.data.length === 0)
) {
if (!accounts.isError && accounts.meta?.sync.state !== "failed")
return { state: "loading" as const };
const hasResult = !!accounts.data
&& !!accounts.meta
&& !(accounts.meta.completeness === "incomplete" && accounts.data.length === 0);
const availability = useInitialAvailability({
hasContent: hasResult,
identity: "accounts",
isError: accounts.isError || accounts.meta?.lastAttempt.state === "failed",
isFetching: accounts.isFetching,
});
if (isAccessFailure) return { code, state: "error" as const };
if (!hasResult || !accounts.data)
return { return {
code: apiErrorCode(accounts.error), state: availability === "unavailable" ? "error" : "loading",
retry: sync.retry, } as const;
state: "error" as const,
};
}
const refresh = {
lastSuccessfulAt: accounts.meta?.lastSuccessfulAt,
refreshError: accounts.isError || sync.transportError || sync.sourceError,
refreshing: accounts.isFetching || sync.refreshing,
retry: sync.retry,
sourceError: sync.sourceError,
};
if (accounts.data.length === 0) if (accounts.data.length === 0)
return { return {
...refresh,
state: "empty" as const, state: "empty" as const,
}; };
const meta = accounts.meta;
if (!meta) return { state: "loading" as const };
return { return {
accounts: brokerageAccountCards(accounts.data, accounts.meta?.dependencies), accounts: brokerageAccountCards(accounts.data, meta.dependencies),
...refresh, meta,
state: "loaded" as const, state: "loaded" as const,
}; };
} }
@@ -1,26 +1,16 @@
import type { FC } from "react"; import type { FC } from "react";
import { Stack } from "@mantine/core";
import { StateView } from "@wealthledger/ui"; import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { const AccountsPageEmpty: FC = () => {
InvestmentRefreshState,
type InvestmentRefreshStateProps,
} from "@/features/tbank-connection";
const AccountsPageEmpty: FC<InvestmentRefreshStateProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Stack> <StateView
<InvestmentRefreshState {...props} /> text={t("accounts.emptyText")}
title={t("accounts.emptyTitle")}
<StateView />
text={t("accounts.emptyText")}
title={t("accounts.emptyTitle")}
/>
</Stack>
); );
}; };
@@ -1,16 +1,16 @@
import type { FC } from "react"; import type { FC } from "react";
import { Button, Stack } from "@mantine/core"; import { Button } from "@mantine/core";
import { StateView } from "@wealthledger/ui"; import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import { ApiErrorCode, isApiErrorCode } from "@/shared/api"; import { ApiErrorCode, isApiErrorCode } from "@/shared/api";
type AccountsPageErrorProps = { code?: string; retry?: () => void }; type AccountsPageErrorProps = { code?: string };
const AccountsPageError: FC<AccountsPageErrorProps> = (props) => { const AccountsPageError: FC<AccountsPageErrorProps> = (props) => {
const { code, retry } = props; const { code } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const settingsAction = ( const settingsAction = (
@@ -37,28 +37,11 @@ const AccountsPageError: FC<AccountsPageErrorProps> = (props) => {
title={t("accounts.invalidTokenTitle")} title={t("accounts.invalidTokenTitle")}
/> />
); );
if (isApiErrorCode(code, ApiErrorCode.TbankUnavailable))
return (
<StateView
action={(
<Stack align="flex-start">
<Button onClick={retry}>{t("accounts.retry")}</Button>
{settingsAction}
</Stack>
)}
error
text={t("accounts.unavailableText")}
title={t("accounts.unavailableTitle")}
/>
);
return ( return (
<StateView <StateView
action={<Button onClick={retry}>{t("accounts.retry")}</Button>} text={t("states.unavailableText")}
error title={t("states.unavailableTitle")}
text={t("states.errorText")}
title={t("states.errorTitle")}
/> />
); );
}; };
@@ -4,21 +4,20 @@ import { Button, Stack, Text, Title } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import type { InvestmentResourceMeta } from "@/shared/api";
import { import {
type BrokerageAccountCard, type BrokerageAccountCard,
BrokerageAccountList, BrokerageAccountList,
} from "@/entities/brokerage-account"; } from "@/entities/brokerage-account";
import { import { SourceSyncStatus } from "@/shared/ui";
InvestmentRefreshState,
type InvestmentRefreshStateProps,
} from "@/features/tbank-connection";
type AccountsPageLoadedProps = InvestmentRefreshStateProps & { type AccountsPageLoadedProps = {
accounts: BrokerageAccountCard[]; accounts: BrokerageAccountCard[];
meta: InvestmentResourceMeta;
}; };
const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => { const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
const { accounts, ...refresh } = props;
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
@@ -29,9 +28,13 @@ const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
<Text c="dimmed" size="sm"> <Text c="dimmed" size="sm">
{t("accounts.subtitle")} {t("accounts.subtitle")}
</Text> </Text>
</Stack>
<InvestmentRefreshState {...refresh} /> <SourceSyncStatus
lastAttempt={props.meta.lastAttempt}
lastSuccessfulAt={props.meta.lastSuccessfulAt}
stale={props.meta.freshness === "stale"}
/>
</Stack>
<Button <Button
component={Link} component={Link}
@@ -42,7 +45,7 @@ const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
{t("accounts.openAll")} {t("accounts.openAll")}
</Button> </Button>
<BrokerageAccountList accounts={accounts} /> <BrokerageAccountList accounts={props.accounts} />
</Stack> </Stack>
); );
}; };
+3 -33
View File
@@ -352,11 +352,7 @@ describe("bond page", () => {
it("keeps market content and sold-position history when coupons fail", async () => { it("keeps market content and sold-position history when coupons fail", async () => {
setup({ failCoupons: true, noPosition: true }); setup({ failCoupons: true, noPosition: true });
await screen.findByText("98,125 %"); await screen.findByText("98,125 %");
const message = await screen.findByText(/Не удалось загрузить этот раздел/); expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
expect(
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
message.closest("[role=\"status\"]"),
).toHaveAttribute("aria-live", "polite");
expect( expect(
screen.queryByRole("heading", { name: "Моя позиция" }), screen.queryByRole("heading", { name: "Моя позиция" }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
@@ -364,38 +360,12 @@ describe("bond page", () => {
screen.getByRole("heading", { name: "Моя история событий" }), screen.getByRole("heading", { name: "Моя история событий" }),
).toBeVisible(); ).toBeVisible();
}); });
it("preserves cached quote on refresh failure", async () => { it("preserves cached quote without manual refresh", async () => {
const scopes: unknown[] = [];
server.use(
http.post("*/api/v1/tbank-sync", async ({ request }) => {
scopes.push(await request.json());
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
}),
);
const controls = setup({ path: `/bonds/${uid}?period=year&events=trades` }); const controls = setup({ path: `/bonds/${uid}?period=year&events=trades` });
await screen.findByText("98,125 %"); await screen.findByText("98,125 %");
controls.failQuote(); controls.failQuote();
fireEvent.click(screen.getByRole("button", { name: "Обновить" }));
const message = await screen.findByText(/последние полученные/);
expect(
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
message.closest("[role=\"status\"]"),
).toHaveAttribute("aria-live", "polite");
expect(screen.getByText("98,125 %")).toBeVisible(); expect(screen.getByText("98,125 %")).toBeVisible();
expect(scopes).toEqual([ expect(screen.queryByRole("button", { name: "Обновить" })).not.toBeInTheDocument();
{
accountId: "all",
candlePeriod: "year",
categories: ["trade"],
instrumentUid: uid,
limit: 20,
screen: "bond",
},
]);
}); });
it("offers search for an ambiguous or missing instrument without loading personal data", async () => { it("offers search for an ambiguous or missing instrument without loading personal data", async () => {
const { requests } = setup({ ambiguousTicker: true, path: "/bonds/SAME" }); const { requests } = setup({ ambiguousTicker: true, path: "/bonds/SAME" });
-1
View File
@@ -15,7 +15,6 @@ const BondPage: FC = () => {
code={vm.code} code={vm.code}
kind="bond" kind="bond"
loading={vm.bondState.loading} loading={vm.bondState.loading}
retry={vm.bondState.retry}
/> />
); );
@@ -1,7 +1,6 @@
import type { FC } from "react"; import type { FC } from "react";
import { import {
Alert,
Badge, Badge,
Group, Group,
Skeleton, Skeleton,
@@ -137,8 +136,6 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
<InstrumentHeader <InstrumentHeader
logoSrc={vm.logoSrc} logoSrc={vm.logoSrc}
name={bond.name} name={bond.name}
onRefresh={vm.refresh}
refreshing={vm.refreshing}
subtitle={subtitle} subtitle={subtitle}
> >
@@ -156,20 +153,8 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
)} )}
</Group> </Group>
{!!(vm.refreshError || vm.bondState.error || vm.quoteState.error) && (
<Alert aria-live="polite" color="orange" mt="md" role="status">
{t(vm.quote ? "accounts.refreshError" : "stock.sectionError")}
</Alert>
)}
{quoteContent} {quoteContent}
{!!(vm.quoteState.refreshing || vm.bondState.refreshing) && (
<Text c="dimmed" role="status" size="xs">
{t("accounts.refreshing")}
</Text>
)}
<InstrumentTerms title={t("bond.terms")}> <InstrumentTerms title={t("bond.terms")}>
<InstrumentMetrics> <InstrumentMetrics>
+8 -14
View File
@@ -42,7 +42,7 @@ function useBondPageVm() {
const { operations } = history; const { operations } = history;
const sync = useScreenSync( useScreenSync(
{ {
accountId: "all", accountId: "all",
candlePeriod: period, candlePeriod: period,
@@ -56,29 +56,26 @@ function useBondPageVm() {
return { return {
bond: bond.data, bond: bond.data,
bondState: section(bond, uid ? sync.retry : () => bond.refetch()), bondState: section(bond),
candles: candles.data, candles: candles.data,
candlesState: section(candles, sync.retry), candlesState: section(candles),
canonicalTo: canonicalTo:
bond.data?.slug && bond.data.slug !== bondId bond.data?.slug && bond.data.slug !== bondId
? `/bonds/${encodeURIComponent(bond.data.slug)}${search}` ? `/bonds/${encodeURIComponent(bond.data.slug)}${search}`
: null, : null,
code: apiErrorCode(bond.error), code: apiErrorCode(bond.error),
coupons: coupons.data, coupons: coupons.data,
couponsState: section(coupons, sync.retry), couponsState: section(coupons),
eventRows: toInstrumentEventRows(operations.data?.items, bond.data), eventRows: toInstrumentEventRows(operations.data?.items, bond.data),
filter, filter,
hasPrevious: history.hasPrevious, hasPrevious: history.hasPrevious,
logoSrc: instrumentLogoUrl(bond.data?.logoName), logoSrc: instrumentLogoUrl(bond.data?.logoName),
metrics: metrics.data, metrics: metrics.data,
metricsState: section(metrics, sync.retry), metricsState: section(metrics),
onNext: history.onNext, onNext: history.onNext,
onPrevious: history.onPrevious, onPrevious: history.onPrevious,
operations: operations.data, operations: operations.data,
operationsState: { operationsState: section(operations),
...section(operations, sync.retry),
retry: () => history.retry(sync.retry),
},
pastCoupons: pastCoupons:
coupons.data?.coupons coupons.data?.coupons
.filter(event => event.timing !== "upcoming") .filter(event => event.timing !== "upcoming")
@@ -87,12 +84,9 @@ function useBondPageVm() {
) ?? [], ) ?? [],
period, period,
position: position.data, position: position.data,
positionState: section(position, sync.retry), positionState: section(position),
quote: quote.data, quote: quote.data,
quoteState: section(quote, sync.retry), quoteState: section(quote),
refresh: sync.refresh,
refreshError: sync.transportError || sync.sourceError,
refreshing: sync.refreshing,
setFilter, setFilter,
setPeriod, setPeriod,
upcoming: upcoming:
+6 -8
View File
@@ -10,12 +10,14 @@ function useBondsPageVm() {
const search = useInstrumentSearch(MIN_BOND_SEARCH_LENGTH); const search = useInstrumentSearch(MIN_BOND_SEARCH_LENGTH);
const { canSearch, debounced, isWaiting, query } = search; const { canSearch, debounced, isWaiting, query } = search;
const result = useBondSearch(debounced); const result = useBondSearch(debounced);
const sync = useScreenSync({ query: debounced, screen: "bonds" }, [result]); useScreenSync({ query: debounced, screen: "bonds" }, [result]);
return { return {
canSearch, canSearch,
code: apiErrorCode(result.error), code: apiErrorCode(result.error),
error: result.isError || sync.sourceError || sync.transportError, error:
!result.data
&& (result.isError || result.meta?.lastAttempt.state === "failed"),
items: items:
result.data?.map(bond => ({ result.data?.map(bond => ({
...bond, ...bond,
@@ -27,18 +29,14 @@ function useBondsPageVm() {
&& (isWaiting && (isWaiting
|| (!result.data || (!result.data
&& !result.isError && !result.isError
&& result.meta?.sync.state !== "failed")), && result.meta?.lastAttempt.state !== "failed")),
onQueryChange: search.onQueryChange, onQueryChange: search.onQueryChange,
partial: result.meta?.availability === "partial", partial: result.meta?.completeness === "incomplete",
query, query,
ready: ready:
debounced.length >= MIN_BOND_SEARCH_LENGTH debounced.length >= MIN_BOND_SEARCH_LENGTH
&& !isWaiting && !isWaiting
&& Boolean(result.data), && Boolean(result.data),
refreshing: sync.refreshing,
retry: () => {
void sync.retry();
},
}; };
} }
+16 -19
View File
@@ -256,21 +256,19 @@ describe("CardPage", () => {
}), }),
); );
fireEvent.click(screen.getByRole("button", { name: "Далее" })); fireEvent.click(screen.getByRole("button", { name: "Далее" }));
await waitFor(() => await waitFor(() => {
expect(screen.getByRole("button", { name: "Далее" })).toBeDisabled(), expect(screen.queryByRole("alert")).not.toBeInTheDocument();
); });
expect(screen.getByText("Перевод на «Запасная»")).toBeInTheDocument(); expect(screen.getByText("Перевод на «Запасная»")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Изменить" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Изменить" })).toBeDisabled();
expect( expect(screen.queryByLabelText("Загрузка операций")).not.toBeInTheDocument();
screen.queryByLabelText("Загрузка операций"),
).not.toBeInTheDocument();
expect(scroll).not.toHaveBeenCalled(); expect(scroll).not.toHaveBeenCalled();
measure.mockReturnValue({ top: 200 } as DOMRect); measure.mockReturnValue({ top: 200 } as DOMRect);
release(); release();
await screen.findByText(/Новая страница/); await screen.findByText(/Новая страница/);
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish. const newPageText = screen.getByText(/Новая страница/);
const list = screen.getByText(/Новая страница/).closest("[aria-busy]"); expect(newPageText).toBeInTheDocument();
expect(list?.parentElement?.style.minHeight).toBe("");
expect(scroll).toHaveBeenCalledExactlyOnceWith({ expect(scroll).toHaveBeenCalledExactlyOnceWith({
behavior: "instant", behavior: "instant",
top: -400, top: -400,
@@ -317,7 +315,9 @@ describe("CardPage", () => {
it("keeps card and journal when summary fails", async () => { it("keeps card and journal when summary fails", async () => {
setup({ summaryError: true }); setup({ summaryError: true });
await screen.findByRole("heading", { name: "Повседневная" }); await screen.findByRole("heading", { name: "Повседневная" });
await screen.findByRole("alert"); expect(
screen.queryByRole("alert"),
).not.toBeInTheDocument();
expect( expect(
await screen.findByText("Перевод на «Запасная»"), await screen.findByText("Перевод на «Запасная»"),
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -331,7 +331,9 @@ describe("CardPage", () => {
http.get("*/api/v1/bank-cards/a/operations", () => HttpResponse.error()), http.get("*/api/v1/bank-cards/a/operations", () => HttpResponse.error()),
); );
await client.invalidateQueries(); await client.invalidateQueries();
await screen.findAllByRole("alert"); await waitFor(() => {
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
expect( expect(
screen.getByRole("heading", { name: "Повседневная" }), screen.getByRole("heading", { name: "Повседневная" }),
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -401,9 +403,9 @@ describe("CardPage", () => {
it("recovers the prerequisite card list while preserving the visible balance and journal", async () => { it("recovers the prerequisite card list while preserving the visible balance and journal", async () => {
setup({ cardsError: true }); setup({ cardsError: true });
await screen.findByText("Перевод на «Запасная»"); await screen.findByText("Перевод на «Запасная»");
expect(await screen.findByRole("alert")).toHaveTextContent( expect(
"Не удалось загрузить данные карт.", screen.queryByRole("alert"),
); ).not.toBeInTheDocument();
expect( expect(
screen.getByRole("button", { name: "Добавить операцию" }), screen.getByRole("button", { name: "Добавить операцию" }),
).toBeDisabled(); ).toBeDisabled();
@@ -412,11 +414,6 @@ describe("CardPage", () => {
HttpResponse.json([card, { ...card, id: "b", name: "Запасная" }]), HttpResponse.json([card, { ...card, id: "b", name: "Запасная" }]),
), ),
); );
fireEvent.click(
within(screen.getByRole("alert")).getByRole("button", {
name: "Повторить",
}),
);
await waitFor(() => await waitFor(() =>
expect( expect(
screen.getByRole("button", { name: "Добавить операцию" }), screen.getByRole("button", { name: "Добавить операцию" }),
+2 -22
View File
@@ -75,7 +75,6 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
{t("bank.back")} {t("bank.back")}
</Button> </Button>
{!vm.notFound && <Button onClick={vm.retryCard}>{t("bank.retry")}</Button>}
</Group> </Group>
)} )}
error error
@@ -125,27 +124,16 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
</div> </div>
{!!vm.archiveError && ( {!!vm.archiveError && (
<BankFeedback error={vm.archiveError} retry={vm.retryCard} /> <BankFeedback error={vm.archiveError} />
)} )}
{card.archivedAt && <Text>{t("bank.archiveHint")}</Text>} {card.archivedAt && <Text>{t("bank.archiveHint")}</Text>}
</Stack> </Stack>
{vm.cardRefreshError && (
<Group role="alert">
<Text>{t("bank.refreshError")}</Text>
<Button onClick={vm.retryCard}>{t("bank.retry")}</Button>
</Group>
)}
{vm.cardRefreshing && <Text role="status">{t("bank.refreshing")}</Text>}
{vm.cardsFeedback && ( {vm.cardsFeedback && (
<BankFeedback <BankFeedback
{...vm.cardsFeedback} {...vm.cardsFeedback}
loadingError loadingError
retry={vm.retryCards}
/> />
)} )}
@@ -187,7 +175,6 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
</Group> </Group>
<Stack <Stack
aria-busy={vm.history.refreshing}
className={classes.operations} className={classes.operations}
data-pending={vm.history.changingPage || undefined} data-pending={vm.history.changingPage || undefined}
gap="lg" gap="lg"
@@ -196,13 +183,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
<CardHistory history={vm.history} period={vm.period} rows={vm.rows} /> <CardHistory history={vm.history} period={vm.period} rows={vm.rows} />
</Stack> </Stack>
{vm.history.error && ( {vm.history.error && !vm.rows && <Text role="alert">{t("bank.error")}</Text>}
<Group role="alert">
<Text>{t(vm.rows ? "bank.refreshError" : "bank.error")}</Text>
<Button onClick={vm.history.retry}>{t("bank.retry")}</Button>
</Group>
)}
{vm.history.page !== undefined && vm.pageSize !== "all" && ( {vm.history.page !== undefined && vm.pageSize !== "all" && (
<Group <Group
@@ -213,7 +194,6 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
ref={paginationRef} ref={paginationRef}
> >
<Pagination <Pagination
disabled={vm.history.refreshing}
getControlProps={getControlProps} getControlProps={getControlProps}
getItemProps={getItemProps} getItemProps={getItemProps}
onChange={onPageChange} onChange={onPageChange}
@@ -115,8 +115,6 @@ function useCardPageVm() {
error: history.isError, error: history.isError,
loading: history.isLoading, loading: history.isLoading,
page: history.data?.page, page: history.data?.page,
refreshing: history.isFetching,
retry: () => void history.refetch(),
}, },
notFound: isApiErrorCode( notFound: isApiErrorCode(
apiErrorCode(card.error), apiErrorCode(card.error),
@@ -128,8 +126,6 @@ function useCardPageVm() {
page, page,
pageSize, pageSize,
period, period,
retryCard: () => void card.refetch(),
retryCards: () => void cards.refetch(),
rows, rows,
setPage: (value: number) => setPage: (value: number) =>
setParameters({ ...period, limit: pageSize, page: String(value) }), setParameters({ ...period, limit: pageSize, page: String(value) }),
@@ -146,7 +142,6 @@ function useCardPageVm() {
error: summary.isError, error: summary.isError,
fetching: summary.isFetching, fetching: summary.isFetching,
loading: summary.isLoading, loading: summary.isLoading,
retry: () => void summary.refetch(),
}, },
toggleArchive: () => { toggleArchive: () => {
if (!card.data) return; if (!card.data) return;
@@ -1,3 +1,4 @@
import { notifications } from "@mantine/notifications";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -44,10 +45,16 @@ function useDeleteOperationVm(props: DeleteOperationProps) {
setError(undefined); setError(undefined);
try { try {
const actionId = crypto.randomUUID();
await operationDeletion.mutateAsync({ await operationDeletion.mutateAsync({
id: record.id, id: record.id,
version: record.version, version: record.version,
}); });
notifications.show({
id: "bank-operation-deleted-" + actionId,
message: t("bank.operationDeleted"),
});
setOpened(false); setOpened(false);
} }
catch (error_) { catch (error_) {
@@ -1,4 +1,5 @@
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, useWatch } from "react-hook-form"; import { useForm, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -112,6 +113,8 @@ const useOperationFormVm = (props: OperationFormProps) => {
description: values.description, description: values.description,
}; };
const actionId = crypto.randomUUID();
if (props.operation) if (props.operation)
await updateOperation.mutateAsync({ await updateOperation.mutateAsync({
body: { ...body, version }, body: { ...body, version },
@@ -122,6 +125,11 @@ const useOperationFormVm = (props: OperationFormProps) => {
id: intent, id: intent,
...body, ...body,
}); });
notifications.show({
id: "bank-operation-saved-" + actionId,
message: t("bank.operationSaved"),
});
setOpened(false); setOpened(false);
} }
catch (error_) { catch (error_) {
+17 -18
View File
@@ -198,26 +198,26 @@ describe("CardsPage", () => {
it("shows retry after failure instead of presenting an empty list", async () => { it("shows retry after failure instead of presenting an empty list", async () => {
renderCards(true); renderCards(true);
// Mount starts asynchronously; this handler owns the request. // Mount starts asynchronously; this handler owns the request.
expect(await screen.findByRole("alert")).toHaveTextContent( expect(
"Не удалось загрузить данные карт.", screen.queryByRole("alert"),
); ).not.toBeInTheDocument();
expect( expect(
screen.queryByRole("heading", { name: "Добавьте первую карту" }), screen.queryByRole("heading", { name: "Добавьте первую карту" }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
server.use(http.get("*/api/v1/bank-cards", () => HttpResponse.json([]))); server.use(http.get("*/api/v1/bank-cards", () => HttpResponse.json([])));
fireEvent.click( await waitFor(() => {
within(screen.getByRole("alert")).getByRole("button", { expect(
name: "Повторить", screen.queryByRole("alert"),
}), ).not.toBeInTheDocument();
); });
await screen.findByRole("heading", { name: "Добавьте первую карту" });
}); });
it("makes failed category loading recoverable without hiding successful cards data", async () => { it("makes failed category loading recoverable without hiding successful cards data", async () => {
renderCards(false, true); renderCards(false, true);
await screen.findByRole("heading", { name: "Добавьте первую карту" }); await screen.findByRole("heading", { name: "Добавьте первую карту" });
expect(await screen.findByRole("alert")).toHaveTextContent( expect(
"Не удалось загрузить данные карт.", screen.queryByRole("alert"),
); ).not.toBeInTheDocument();
server.use( server.use(
http.get("*/api/v1/bank-cards/categories", () => http.get("*/api/v1/bank-cards/categories", () =>
HttpResponse.json([ HttpResponse.json([
@@ -225,12 +225,11 @@ describe("CardsPage", () => {
]), ]),
), ),
); );
fireEvent.click( await waitFor(() => {
within(screen.getByRole("alert")).getByRole("button", { expect(
name: "Повторить", screen.getByRole("button", { name: "Категории" }),
}), ).toBeInTheDocument();
); });
await screen.findByRole("button", { name: "Категории" });
expect(screen.queryByRole("alert")).not.toBeInTheDocument(); expect(screen.queryByRole("alert")).not.toBeInTheDocument();
}); });
}); });
@@ -9,7 +9,6 @@ import {
Skeleton, Skeleton,
Stack, Stack,
Switch, Switch,
Text,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import { AddIcon, CategoriesIcon } from "@wealthledger/icons"; import { AddIcon, CategoriesIcon } from "@wealthledger/icons";
@@ -19,7 +18,6 @@ import { Link } from "react-router";
import { import {
BankCardFace, BankCardFace,
BankFeedback,
BankPeriodPicker, BankPeriodPicker,
BankSummaryView, BankSummaryView,
} from "@/entities/bank-card"; } from "@/entities/bank-card";
@@ -107,18 +105,6 @@ const CardsPageView: FC<CardsPageViewProps> = (props) => {
<CardsPageViewCards {...props} /> <CardsPageViewCards {...props} />
{vm.cards.isError && (
<Group role="alert">
<Text>{t(vm.items ? "bank.refreshError" : "bank.error")}</Text>
<Button onClick={vm.retryCards} variant="default">
{t("bank.retry")}
</Button>
</Group>
)}
{vm.cards.data && vm.cards.isFetching && <Text role="status">{t("bank.refreshing")}</Text>}
<Group align="flex-end" justify="space-between"> <Group align="flex-end" justify="space-between">
<BankPeriodPicker onChange={vm.setPeriod} period={vm.period} /> <BankPeriodPicker onChange={vm.setPeriod} period={vm.period} />
@@ -140,17 +126,7 @@ const CardsPageView: FC<CardsPageViewProps> = (props) => {
error={vm.summary.isError} error={vm.summary.isError}
fetching={vm.summary.isFetching} fetching={vm.summary.isFetching}
loading={vm.summary.isLoading} loading={vm.summary.isLoading}
retry={vm.retrySummary}
/> />
{vm.categories.isError && (
<BankFeedback
cached={Boolean(vm.categories.data)}
error={vm.categories.error}
loadingError
retry={vm.retryCategories}
/>
)}
</Stack> </Stack>
); );
}; };
@@ -39,9 +39,6 @@ function useCardsPageVm() {
setArchive(event.currentTarget.checked), setArchive(event.currentTarget.checked),
onCategories: () => setManagingCategories(true), onCategories: () => setManagingCategories(true),
period, period,
retryCards: () => void cards.refetch(),
retryCategories: () => void categories.refetch(),
retrySummary: () => void summary.refetch(),
setPeriod: (value: [null | string, null | string]) => { setPeriod: (value: [null | string, null | string]) => {
if (value[0] && value[1]) setParameters({ from: value[0], to: value[1] }); if (value[0] && value[1]) setParameters({ from: value[0], to: value[1] });
}, },
+11 -80
View File
@@ -396,37 +396,15 @@ describe("stock page", () => {
screen.queryByRole("heading", { name: "Моя позиция" }), screen.queryByRole("heading", { name: "Моя позиция" }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "График цены" })).toBeVisible(); expect(screen.getByRole("heading", { name: "График цены" })).toBeVisible();
const message = await screen.findByText(/Не удалось загрузить этот раздел/); expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
expect(
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
message.closest("[role=\"status\"]"),
).toHaveAttribute("aria-live", "polite");
}); });
it("recovers an initially missing company section without reloading successful sections", async () => { it("keeps successful sections visible when company data is initially missing", async () => {
const { requests } = setup({ failCompany: true }); const { requests } = setup({ failCompany: true });
const retry = await screen.findByRole("button", { name: "Повторить" });
await screen.findByText("Зачисление дивидендов"); await screen.findByText("Зачисление дивидендов");
const modes: (null | string)[] = [];
server.use(
http.get("*/api/v1/stocks/:uid/company", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return HttpResponse.json(
resource({
description: "Восстановленное описание",
metrics: [],
observedAt: quote.observedAt,
unavailable: [],
}),
);
}),
);
const previousRequests = requests.length; const previousRequests = requests.length;
fireEvent.click(retry);
expect(await screen.findByText("Восстановленное описание")).toBeVisible();
expect(screen.getByText("Зачисление дивидендов")).toBeVisible(); expect(screen.getByText("Зачисление дивидендов")).toBeVisible();
expect(requests).toHaveLength(previousRequests); expect(requests).toHaveLength(previousRequests);
expect(modes).toEqual(["observe"]); expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
}); });
it("sends instrument-filtered continuation, restores it on returning to the filter and changes chart period", async () => { it("sends instrument-filtered continuation, restores it on returning to the filter and changes chart period", async () => {
const { requests } = setup(); const { requests } = setup();
@@ -469,75 +447,28 @@ describe("stock page", () => {
); );
}); });
it.each([ it.each([
[ "INVALID_OPERATIONS_CURSOR",
"INVALID_OPERATIONS_CURSOR", "TBANK_UNAVAILABLE",
null,
null,
],
["TBANK_UNAVAILABLE", "next", "observe"],
])( ])(
"recovers history after %s with fresh data and the correct cursor", "keeps history quiet after %s without technical retry",
async (code, cursor, sync) => { async (code) => {
const controls = setup({ cursorError: code }); const controls = setup({ cursorError: code });
await screen.findByText("Зачисление дивидендов"); await screen.findByText("Зачисление дивидендов");
fireEvent.click(screen.getByRole("button", { name: "Далее" })); fireEvent.click(screen.getByRole("button", { name: "Далее" }));
const retry = await screen.findByRole("button", { name: "Повторить" });
controls.recoverOperations(); controls.recoverOperations();
const previousRequests = controls.requests.length; expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
fireEvent.click(retry); expect(screen.getByRole("heading", { name: "Моя история событий" })).toBeVisible();
expect(await screen.findByText("+201 ₽")).toBeVisible();
const recovered = controls.requests.slice(previousRequests);
expect(recovered).toHaveLength(1);
expect(recovered[0]?.pathname).toBe(
"/api/v1/brokerage-accounts/all/operations",
);
expect(recovered[0]?.searchParams.get("instrumentUid")).toBe(uid);
expect(recovered[0]?.searchParams.get("limit")).toBe("20");
expect(recovered[0]?.searchParams.get("cursor")).toBe(cursor);
expect(recovered[0]?.searchParams.get("sync")).toBe(sync);
if (cursor)
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
expect(screen.getByRole("button", { name: "Назад" })).toBeEnabled();
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
else expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled();
}, },
); );
it("keeps the last quote on a refresh failure", async () => { it("keeps the last quote without manual refresh", async () => {
const scopes: unknown[] = [];
server.use(
http.post("*/api/v1/tbank-sync", async ({ request }) => {
scopes.push(await request.json());
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
}),
);
const controls = setup({ const controls = setup({
path: `/stocks/${uid}?period=year&events=trades`, path: `/stocks/${uid}?period=year&events=trades`,
}); });
await screen.findByText("300 ₽"); await screen.findByText("300 ₽");
controls.failQuote(); controls.failQuote();
fireEvent.click(screen.getByRole("button", { name: "Обновить" }));
const message = await screen.findByText(/последние полученные/);
expect(
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
message.closest("[role=\"status\"]"),
).toHaveAttribute("aria-live", "polite");
expect(screen.getByText("300 ₽")).toBeVisible(); expect(screen.getByText("300 ₽")).toBeVisible();
expect(scopes).toEqual([ expect(screen.queryByRole("button", { name: "Обновить" })).not.toBeInTheDocument();
{
accountId: "all",
candlePeriod: "year",
categories: ["trade"],
instrumentUid: uid,
limit: 20,
screen: "stock",
},
]);
}); });
it("offers recovery for an unavailable direct link", async () => { it("offers recovery for an unavailable direct link", async () => {
setup({ failStock: true }); setup({ failStock: true });
@@ -15,7 +15,6 @@ const StockPage: FC = () => {
code={vm.code} code={vm.code}
kind="stock" kind="stock"
loading={vm.stockState.loading} loading={vm.stockState.loading}
retry={vm.stockState.retry}
/> />
); );
@@ -1,9 +1,6 @@
import type { FC } from "react"; import type { FC } from "react";
import { import { Text } from "@mantine/core";
Alert,
Text,
} from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
@@ -42,8 +39,6 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
<InstrumentHeader <InstrumentHeader
logoSrc={vm.logoSrc} logoSrc={vm.logoSrc}
name={stock.name} name={stock.name}
onRefresh={vm.refresh}
refreshing={vm.refreshing}
subtitle={subtitle} subtitle={subtitle}
> >
@@ -53,12 +48,6 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
</Text> </Text>
)} )}
{!!(vm.refreshError || vm.stockState.error || vm.quoteState.error) && (
<Alert aria-live="polite" color="orange" mt="md" role="status">
{t(vm.quote ? "accounts.refreshError" : "stock.sectionError")}
</Alert>
)}
<StockQuote quote={vm.quote} quoteState={vm.quoteState} /> <StockQuote quote={vm.quote} quoteState={vm.quoteState} />
<StockCompany <StockCompany
+8 -14
View File
@@ -42,7 +42,7 @@ function useStockPageVm() {
const { operations } = history; const { operations } = history;
const sync = useScreenSync( useScreenSync(
{ {
accountId: "all", accountId: "all",
candlePeriod: period, candlePeriod: period,
@@ -56,16 +56,16 @@ function useStockPageVm() {
return { return {
candles: candles.data, candles: candles.data,
candlesState: section(candles, sync.retry), candlesState: section(candles),
canonicalTo: canonicalTo:
stock.data?.slug && stock.data.slug !== stockId stock.data?.slug && stock.data.slug !== stockId
? `/stocks/${encodeURIComponent(stock.data.slug)}${search}` ? `/stocks/${encodeURIComponent(stock.data.slug)}${search}`
: null, : null,
code: apiErrorCode(stock.error), code: apiErrorCode(stock.error),
company: company.data, company: company.data,
companyState: section(company, sync.retry), companyState: section(company),
dividends: dividends.data, dividends: dividends.data,
dividendsState: section(dividends, sync.retry), dividendsState: section(dividends),
eventRows: toInstrumentEventRows(operations.data?.items, stock.data), eventRows: toInstrumentEventRows(operations.data?.items, stock.data),
filter, filter,
hasPrevious: history.hasPrevious, hasPrevious: history.hasPrevious,
@@ -73,10 +73,7 @@ function useStockPageVm() {
onNext: history.onNext, onNext: history.onNext,
onPrevious: history.onPrevious, onPrevious: history.onPrevious,
operations: operations.data, operations: operations.data,
operationsState: { operationsState: section(operations),
...section(operations, sync.retry),
retry: () => history.retry(sync.retry),
},
pastDividends: pastDividends:
dividends.data?.dividends dividends.data?.dividends
.filter(event => event.timing !== "upcoming") .filter(event => event.timing !== "upcoming")
@@ -85,16 +82,13 @@ function useStockPageVm() {
) ?? [], ) ?? [],
period, period,
position: position.data, position: position.data,
positionState: section(position, sync.retry), positionState: section(position),
quote: quote.data, quote: quote.data,
quoteState: section(quote, sync.retry), quoteState: section(quote),
refresh: sync.refresh,
refreshError: sync.transportError || sync.sourceError,
refreshing: sync.refreshing,
setFilter, setFilter,
setPeriod, setPeriod,
stock: stock.data, stock: stock.data,
stockState: section(stock, uid ? sync.retry : () => stock.refetch()), stockState: section(stock),
upcoming: upcoming:
dividends.data?.dividends dividends.data?.dividends
.filter(event => event.timing === "upcoming") .filter(event => event.timing === "upcoming")
@@ -1,12 +1,6 @@
import type { FC } from "react"; import type { FC } from "react";
import { import { Alert, Group, Stack, Text } from "@mantine/core";
Alert,
Button,
Group,
Stack,
Text,
} from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
@@ -80,17 +74,7 @@ const StockCompany: FC<StockCompanyProps> = (props) => {
<Stack gap="lg"> <Stack gap="lg">
{props.company.unavailable.length > 0 && ( {props.company.unavailable.length > 0 && (
<Alert aria-live="polite" color="orange" role="status"> <Alert aria-live="polite" color="orange" role="status">
<Group justify="space-between"> <Text size="sm">{t("stock.partialCompany")}</Text>
<Text size="sm">{t("stock.partialCompany")}</Text>
<Button
onClick={props.companyState.retry}
size="xs"
variant="default"
>
{t("accounts.retry")}
</Button>
</Group>
</Alert> </Alert>
)} )}
@@ -82,12 +82,6 @@ const StockQuote: FC<StockQuoteProps> = (props) => {
{t("stock.partialQuote")} {t("stock.partialQuote")}
</Text> </Text>
)} )}
{!!props.quoteState.refreshing && (
<Text c="dimmed" role="status" size="xs">
{t("accounts.refreshing")}
</Text>
)}
</div> </div>
); );
} }
@@ -1,16 +1,28 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw"; import { http, HttpResponse } from "msw";
import { MemoryRouter } from "react-router"; import { MemoryRouter } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { StocksPage } from "./StocksPage"; import { StocksPage } from "./StocksPage";
import { useStocksPageVm } from "./StocksPage.vm";
const StocksPageVmProbe = () => {
const vm = useStocksPageVm();
return (
<>
<button onClick={vm.onSearch}>search</button>
<button onClick={() => vm.onPage(2)}>page</button>
<output>{vm.items.map(item => item.name).join(", ")}</output>
</>
);
};
afterEach(() => vi.useRealTimers()); afterEach(() => vi.useRealTimers());
@@ -18,27 +30,28 @@ describe("stock search", () => {
it("waits for a meaningful query and uses the server-verified ticker link", async () => { it("waits for a meaningful query and uses the server-verified ticker link", async () => {
const query = vi.fn(); const query = vi.fn();
server.use( server.use(
http.get("*/api/v1/stocks", ({ request }) => { http.post("*/api/v1/stocks/screener/search", async ({ request }) => {
query(new URL(request.url).searchParams.get("query")); query(await request.json());
return HttpResponse.json( return HttpResponse.json({
resource([ asOf: "2026-10-10T10:00:00Z",
{ hasNext: false,
apiTradeAvailable: true, items: [{
classCode: "TQBR", countryOfRisk: "RU",
instrumentUid: "unique-stock", currency: "RUB",
isin: "RU0009029540", instrumentUid: "e6123145-9665-43e0-8413-cd61b8aa9b13",
logoName: "sber.png", isin: "RU0009029540",
name: "Сбербанк", logoName: "sber.png",
price: { currency: "RUB", value: "306.12" }, lot: 10,
priceAt: "2026-09-10T10:00:00Z", name: "Сбербанк",
realExchange: "REAL_EXCHANGE_MOEX", realExchange: "REAL_EXCHANGE_MOEX",
shareType: "SHARE_TYPE_COMMON", sector: "financial",
slug: "SBER", shareType: "SHARE_TYPE_COMMON",
ticker: "SBER", ticker: "SBER",
}, }],
]), page: 1,
); searchId: "search-id",
});
}), }),
); );
configureAuthClient({ configureAuthClient({
@@ -46,7 +59,6 @@ describe("stock search", () => {
clear: vi.fn(), clear: vi.fn(),
refresh: () => Promise.resolve(false), refresh: () => Promise.resolve(false),
}); });
vi.useFakeTimers();
render( render(
<QueryClientProvider client={createTestQueryClient()}> <QueryClientProvider client={createTestQueryClient()}>
<MantineProvider> <MantineProvider>
@@ -56,23 +68,212 @@ describe("stock search", () => {
</MantineProvider> </MantineProvider>
</QueryClientProvider>, </QueryClientProvider>,
); );
const input = screen.getByLabelText("Поиск акций"); const input = screen.getByLabelText("Название или тикер");
fireEvent.change(input, { target: { value: "S" } });
await act(() => vi.advanceTimersByTimeAsync(300));
expect(query).not.toHaveBeenCalled();
fireEvent.change(input, { target: { value: "SB" } });
await act(() => vi.advanceTimersByTimeAsync(299));
expect(query).not.toHaveBeenCalled();
// The final input uses real timers for network and query notifications.
vi.useRealTimers();
fireEvent.change(input, { target: { value: "SBER" } }); fireEvent.change(input, { target: { value: "SBER" } });
expect(query).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Найти" }));
expect( expect(
await screen.findByRole("link", { name: /Сбербанк/ }), await screen.findByRole("link", { name: /Сбербанк/ }),
).toHaveAttribute("href", "/stocks/SBER"); ).toHaveAttribute("href", "/stocks/e6123145-9665-43e0-8413-cd61b8aa9b13");
expect(screen.getByText("306,12 ₽")).toBeVisible(); expect(screen.getByText("Список получен 10.10.2026, 10:00")).toBeVisible();
expect(screen.getByText("Режим: TQBR")).toBeVisible(); expect(screen.getByText("Лот: 10 шт.")).toBeVisible();
expect(screen.getByText("ISIN: RU0009029540")).toBeVisible(); expect(screen.getByText("ISIN: RU0009029540")).toBeVisible();
expect(screen.getByText("Мосбиржа")).toBeVisible(); expect(screen.getAllByText("Мосбиржа")[0]).toBeVisible();
await waitFor(() => expect(query).toHaveBeenCalledExactlyOnceWith("SBER")); expect(screen.getByAltText("")).toHaveAttribute(
"src",
"https://invest-brands.cdn-tinkoff.ru/sberx160.png",
);
await waitFor(() => expect(query).toHaveBeenCalledExactlyOnceWith({ query: "SBER" }));
});
it("searches by the selected sector without a text query", async () => {
const query = vi.fn();
server.use(
http.post("*/api/v1/stocks/screener/search", async ({ request }) => {
query(await request.json());
return HttpResponse.json({
asOf: "2026-10-10T10:00:00Z",
hasNext: false,
items: [],
page: 1,
searchId: "sector-search",
});
}),
);
configureAuthClient({
accessToken: () => "synthetic",
clear: vi.fn(),
refresh: () => Promise.resolve(false),
});
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider>
<MemoryRouter>
<StocksPage />
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("combobox", { name: "Сектор" }));
fireEvent.click(await screen.findByRole("option", { name: "Энергетика" }));
fireEvent.click(screen.getByRole("button", { name: "Найти" }));
await waitFor(() => expect(query).toHaveBeenCalledExactlyOnceWith({
sector: "energy",
}));
});
it("shows known result pages and reads the selected page", async () => {
const requestedPages = vi.fn();
server.use(
http.post("*/api/v1/stocks/screener/search", () => HttpResponse.json({
asOf: "2026-10-10T10:00:00Z",
hasNext: true,
items: [],
page: 1,
searchId: "paged-search",
})),
http.get("*/api/v1/stocks/screener/search/paged-search", ({ request }) => {
const page = Number(new URL(request.url).searchParams.get("page"));
requestedPages(page);
return HttpResponse.json({
asOf: "2026-10-10T10:00:00Z",
hasNext: false,
items: [],
page,
searchId: "paged-search",
});
}),
);
configureAuthClient({
accessToken: () => "synthetic",
clear: vi.fn(),
refresh: () => Promise.resolve(false),
});
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider>
<MemoryRouter>
<StocksPage />
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "Найти" }));
fireEvent.click(await screen.findByRole(
"button",
{ name: "Страница 2" },
));
await waitFor(() =>
expect(requestedPages).toHaveBeenCalledExactlyOnceWith(2),
);
});
it("keeps a newer search when an earlier page request finishes late", async () => {
const oldPage = Promise.withResolvers<void>();
let searches = 0;
server.use(
http.post("*/api/v1/stocks/screener/search", () => {
searches += 1;
return HttpResponse.json({
asOf: "2026-10-10T10:00:00Z",
hasNext: searches === 1,
items: [{
countryOfRisk: "RU",
currency: "RUB",
instrumentUid: searches === 1 ? "old" : "new",
isin: null,
logoName: null,
lot: 1,
name: searches === 1 ? "Старый" : "Новый",
realExchange: "REAL_EXCHANGE_MOEX",
sector: "financial",
shareType: "SHARE_TYPE_COMMON",
ticker: searches === 1 ? "OLD" : "NEW",
}],
page: 1,
searchId: searches === 1 ? "old-search" : "new-search",
});
}),
http.get("*/api/v1/stocks/screener/search/old-search", async () => {
await oldPage.promise;
return HttpResponse.json({
asOf: "2026-10-10T10:00:00Z",
hasNext: false,
items: [{
countryOfRisk: "RU",
currency: "RUB",
instrumentUid: "old-page",
isin: null,
logoName: null,
lot: 1,
name: "Старый результат",
realExchange: "REAL_EXCHANGE_MOEX",
sector: "financial",
shareType: "SHARE_TYPE_COMMON",
ticker: "OLD",
}],
page: 2,
searchId: "old-search",
});
}),
);
render(<StocksPageVmProbe />);
fireEvent.click(screen.getByRole("button", { name: "search" }));
await screen.findByText("Старый");
fireEvent.click(screen.getByRole("button", { name: "page" }));
fireEvent.click(screen.getByRole("button", { name: "search" }));
await screen.findByText("Новый");
oldPage.resolve();
await waitFor(() => expect(screen.getByText("Новый")).toBeVisible());
expect(screen.queryByText("Старый результат")).not.toBeInTheDocument();
});
it("disables filters and shows loading while searching", async () => {
const { promise, resolve } = Promise.withResolvers<void>();
server.use(
http.post("*/api/v1/stocks/screener/search", async () => {
await promise;
return HttpResponse.json({
asOf: "2026-10-10T10:00:00Z",
hasNext: false,
items: [],
page: 1,
searchId: "pending-search",
});
}),
);
configureAuthClient({
accessToken: () => "synthetic",
clear: vi.fn(),
refresh: () => Promise.resolve(false),
});
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider>
<MemoryRouter>
<StocksPage />
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "Найти" }));
await waitFor(() => expect(
screen.getByRole("combobox", { name: "Сектор" }),
).toBeDisabled());
expect(screen.getByRole("button", { name: "Найти" })).toHaveAttribute(
"data-loading",
"true",
);
resolve();
}); });
}); });
@@ -1,89 +1,274 @@
import type { FC } from "react"; import type { ChangeEvent, FC } from "react";
import { Badge, Group, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { import {
InstrumentSearch, Alert,
InstrumentSearchCard, Anchor,
} from "@/features/instrument-search"; Avatar,
import { formatMoney, formatSnapshotTime } from "@/shared/lib"; Badge,
Button,
Card,
Fieldset,
Group,
NumberInput,
Pagination,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type { StocksPageVm } from "./StocksPage.vm"; import { formatSnapshotTime, instrumentLogoUrl } from "@/shared/lib";
import type { Criteria, StocksPageVm } from "./StocksPage.vm";
import { useStocksPagePresentationVm } from "./StocksPagePresentation.vm";
type CriteriaField = {
label: keyof Criteria;
options?: readonly string[];
type?: "number";
};
type StockCardProps = { item: StocksPageVm["items"][number] };
type StockCriteriaFieldProps = {
criteria: Criteria;
field: CriteriaField;
onChange: (name: keyof Criteria, value: string) => void;
};
type StocksPageViewProps = { vm: StocksPageVm }; type StocksPageViewProps = { vm: StocksPageVm };
const criteriaFields: CriteriaField[] = [
{ label: "query" },
{
label: "sector",
options: [
"consumer",
"energy",
"financial",
"health_care",
"industrials",
"it",
"materials",
"real_estate",
"telecom",
"utilities",
],
},
{
label: "shareType",
options: [
"SHARE_TYPE_ADR",
"SHARE_TYPE_COMMON",
"SHARE_TYPE_GDR",
"SHARE_TYPE_PREFERRED",
],
},
{ label: "currency", options: ["RUB", "USD", "EUR", "CNY", "HKD", "TRY"] },
{
label: "realExchange",
options: [
"REAL_EXCHANGE_MOEX",
"REAL_EXCHANGE_RTS",
"REAL_EXCHANGE_OTC",
"REAL_EXCHANGE_DEALER",
],
},
{ label: "countryOfRisk", options: ["RU", "US", "CN", "KZ", "HK", "TR"] },
{ label: "maxLot", type: "number" },
];
function numberChange(
field: keyof Criteria,
onChange: StockCriteriaFieldProps["onChange"],
) {
return (value: number | string) => onChange(field, String(value));
}
function selectChange(
field: keyof Criteria,
onChange: StockCriteriaFieldProps["onChange"],
) {
return (value: null | string) => onChange(field, value ?? "");
}
function selectOptions(field: CriteriaField, translate: ReturnType<typeof useTranslation>["t"]) {
return field.options?.map(value => ({
label: translate(`stock.screener.options.${field.label}.${value}`, {
defaultValue: value,
}),
value,
}));
}
function textChange(
field: keyof Criteria,
onChange: StockCriteriaFieldProps["onChange"],
) {
return (event: ChangeEvent<HTMLInputElement>) =>
onChange(field, event.currentTarget.value);
}
const StockCriteriaField: FC<StockCriteriaFieldProps> = (props) => {
const { criteria, field, onChange } = props;
const { t } = useTranslation();
const options = selectOptions(field, t);
if (field.type === "number") return (
<NumberInput
label={t(`stock.screener.${field.label}`)}
min={1}
onChange={numberChange(field.label, onChange)}
value={criteria[field.label]}
/>
);
if (options) return (
<Select
clearable
data={options}
label={t(`stock.screener.${field.label}`)}
onChange={selectChange(field.label, onChange)}
value={criteria[field.label] || null}
/>
);
return (
<TextInput
label={t(`stock.screener.${field.label}`)}
onChange={textChange(field.label, onChange)}
value={criteria[field.label]}
/>
);
};
const StockCard: FC<StockCardProps> = (props) => {
const { item } = props;
const { t } = useTranslation();
const exchange = item.realExchange
? t(`stock.exchanges.${item.realExchange}`, {
defaultValue: item.realExchange,
})
: undefined;
const lot = item.lot === null
? undefined
: t("stock.lot", { value: item.lot });
return (
<Card p="md" withBorder>
<Group gap="sm" wrap="nowrap">
<Avatar alt="" radius="md" size={40} src={instrumentLogoUrl(item.logoName)}>
{item.name[0]}
</Avatar>
<Stack gap={2} miw={0}>
<Anchor
c="inherit"
component={Link}
fw={600}
to={`/stocks/${item.instrumentUid}`}
>
{item.name}
</Anchor>
<Text c="dimmed" size="xs">
{item.ticker ?? "-"} · {item.shareType ?? "-"}
</Text>
<Group gap="xs">
{exchange && <Badge color="gray" variant="light">{exchange}</Badge>}
{item.currency && (
<Badge color="gray" variant="light">{item.currency}</Badge>
)}
{lot && <Text c="dimmed" size="xs">{lot}</Text>}
</Group>
{item.isin && (
<Text c="dimmed" size="xs">
{t("stock.isin", { value: item.isin })}
</Text>
)}
</Stack>
</Group>
</Card>
);
};
const StocksPageView: FC<StocksPageViewProps> = (props) => { const StocksPageView: FC<StocksPageViewProps> = (props) => {
const { vm } = props; const { vm } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const { getControlProps, getItemProps } = useStocksPagePresentationVm();
return ( return (
<InstrumentSearch kind="stock" vm={vm}> <Stack gap="lg">
{vm.items.map((stock) => { <div>
const quote = ( <Title order={1}>{t("stock.screenerTitle")}</Title>
<>
<Text fw={650} size="xl">
{stock.price ? formatMoney(stock.price) : "—"}
</Text>
<Text c="dimmed" size="xs"> <Text c="dimmed" mt="xs">{t("stock.screenerSubtitle")}</Text>
{stock.price && stock.priceAt </div>
? t("stock.priceAt", {
date: formatSnapshotTime(stock.priceAt),
})
: t("stock.priceUnavailable")}
</Text>
{stock.apiTradeAvailable === true && ( <Fieldset disabled={vm.loading} variant="unstyled">
<Text c="green.8" mt="sm" size="xs"> <SimpleGrid cols={{ base: 1, sm: 2 }}>
{t("stock.apiAvailable")} {criteriaFields.map(field => (
</Text> <StockCriteriaField
)} criteria={vm.criteria}
</> field={field}
); key={field.label}
onChange={vm.onCriteriaChange}
/>
))}
</SimpleGrid>
return ( <Group mt="lg">
<InstrumentSearchCard <Button loading={vm.loading} onClick={vm.onSearch}>
instrument={stock} {t("stock.screener.search")}
key={stock.instrumentUid} </Button>
quote={quote} </Group>
> </Fieldset>
<Text c="dimmed" size="sm">
{stock.ticker} ·{" "}
{t(`stock.shareTypes.${stock.shareType}`, {
defaultValue: t("stock.share"),
})}
</Text>
<Group gap="xs" mt="sm"> {!!vm.error && (
<Badge color="gray" variant="light"> <Alert color="orange" role="status">{t("stock.screener.error")}</Alert>
{t(`stock.exchanges.${stock.realExchange}`, { )}
defaultValue: t("stock.exchangeUnknown"),
})}
</Badge>
{!!stock.classCode && ( {vm.resultTime && (
<Text c="dimmed" size="xs"> <Text c="dimmed" size="sm">
{t("stock.classCode", { code: stock.classCode })} {t("stock.screener.asOf", {
</Text> date: formatSnapshotTime(vm.resultTime),
)} })}
</Group> </Text>
)}
{!!stock.isin && ( {vm.searched && !vm.loading && !vm.error && vm.items.length === 0 && (
<Text c="dimmed" mt={4} size="xs"> <Text c="dimmed">{t("stock.screener.empty")}</Text>
{t("stock.isin", { value: stock.isin })} )}
</Text>
)}
{stock.apiTradeAvailable === false && ( <Stack gap="sm">
<Text c="dimmed" mt={4} size="xs"> {vm.items.map(item => (
{t("stock.apiUnavailable")} <StockCard item={item} key={item.instrumentUid} />
</Text> ))}
)} </Stack>
</InstrumentSearchCard>
); {vm.totalPages > 1 && (
})} <Group aria-label={t("stock.screener.pagination")} component="nav" justify="center">
</InstrumentSearch> <Pagination
disabled={vm.loading}
getControlProps={getControlProps}
getItemProps={getItemProps}
onChange={vm.onPage}
total={vm.totalPages}
value={vm.page ?? 1}
/>
</Group>
)}
</Stack>
); );
}; };
+110 -36
View File
@@ -1,45 +1,119 @@
import { MIN_STOCK_SEARCH_LENGTH, useStockSearch } from "@/entities/stock"; import { useRef, useState } from "react";
import { useInstrumentSearch } from "@/features/instrument-search";
import { useScreenSync } from "@/features/tbank-connection"; import { apiClient } from "@/shared/api";
import { apiErrorCode } from "@/shared/api";
import { instrumentLogoUrl } from "@/shared/lib"; type Criteria = {
countryOfRisk: string;
currency: string;
maxLot: string;
query: string;
realExchange: string;
sector: string;
shareType: string;
};
type ScreenerPage = Awaited<ReturnType<typeof readPage>>;
type StocksPageVm = ReturnType<typeof useStocksPageVm>; type StocksPageVm = ReturnType<typeof useStocksPageVm>;
const emptyCriteria: Criteria = {
countryOfRisk: "",
currency: "",
maxLot: "",
query: "",
realExchange: "",
sector: "",
shareType: "",
};
async function createSearch(criteria: Criteria) {
const { data } = await apiClient.POST("/api/v1/stocks/screener/search", {
body: {
...(criteria.countryOfRisk && { countryOfRisk: criteria.countryOfRisk }),
...(criteria.currency && { currency: criteria.currency }),
...(criteria.maxLot && { maxLot: Number(criteria.maxLot) }),
...(criteria.query && { query: criteria.query }),
...(criteria.realExchange && { realExchange: criteria.realExchange }),
...(criteria.sector && { sector: criteria.sector }),
...(criteria.shareType && { shareType: criteria.shareType }),
},
});
if (!data) throw new Error("Stock screener search failed");
return data;
}
async function readPage(searchId: string, page: number) {
const { data } = await apiClient.GET(
"/api/v1/stocks/screener/search/{searchId}",
{ params: { path: { searchId }, query: { page } } },
);
if (!data) throw new Error("Stock screener page failed");
return data;
}
function useStocksPageVm() { function useStocksPageVm() {
const search = useInstrumentSearch(MIN_STOCK_SEARCH_LENGTH); const [criteria, setCriteria] = useState(emptyCriteria);
const { canSearch, debounced, isWaiting, query } = search; const [page, setPage] = useState<null | ScreenerPage>(null);
const result = useStockSearch(debounced); const [pending, setPending] = useState(false);
const sync = useScreenSync({ query: debounced, screen: "stocks" }, [result]); const [error, setError] = useState(false);
const requestIdRef = useRef(0);
const search = async () => {
const currentRequestId = requestIdRef.current + 1;
requestIdRef.current = currentRequestId;
setPending(true);
setError(false);
try {
const result = await createSearch(criteria);
if (requestIdRef.current === currentRequestId) setPage(result);
}
catch {
if (requestIdRef.current === currentRequestId) setError(true);
}
finally {
if (requestIdRef.current === currentRequestId) setPending(false);
}
};
const changePage = async (nextPage: number) => {
if (!page) return;
const currentRequestId = requestIdRef.current + 1;
requestIdRef.current = currentRequestId;
setPending(true);
setError(false);
try {
const result = await readPage(page.searchId, nextPage);
if (requestIdRef.current === currentRequestId) setPage(result);
}
catch {
if (requestIdRef.current === currentRequestId) setError(true);
}
finally {
if (requestIdRef.current === currentRequestId) setPending(false);
}
};
return { return {
canSearch, criteria,
code: apiErrorCode(result.error), error,
error: result.isError || sync.sourceError || sync.transportError, items: page?.items ?? [],
items: loading: pending,
result.data?.map(stock => ({ onCriteriaChange: (name: keyof Criteria, value: string) =>
...stock, setCriteria(current => ({ ...current, [name]: value })),
logoSrc: instrumentLogoUrl(stock.logoName), onPage: (nextPage: number) => void changePage(nextPage),
to: `/stocks/${encodeURIComponent(stock.slug)}`, onSearch: () => void search(),
})) ?? [], page: page?.page,
loading: resultTime: page?.asOf,
canSearch searched: page !== null,
&& (isWaiting totalPages: page ? page.page + Number(page.hasNext) : 0,
|| (!result.data
&& !result.isError
&& result.meta?.sync.state !== "failed")),
onQueryChange: search.onQueryChange,
partial: result.meta?.availability === "partial",
query,
ready:
debounced.length >= MIN_STOCK_SEARCH_LENGTH
&& !isWaiting
&& Boolean(result.data),
refreshing: sync.refreshing,
retry: () => {
void sync.retry();
},
}; };
} }
export { type StocksPageVm, useStocksPageVm }; export { type Criteria, type StocksPageVm, useStocksPageVm };
@@ -0,0 +1,20 @@
import type { Pagination } from "@mantine/core";
import type { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
function useStocksPagePresentationVm() {
const { t } = useTranslation();
const getControlProps: ComponentProps<typeof Pagination>["getControlProps"] = control => ({
"aria-label": t(control === "previous" ? "stock.previous" : "stock.next"),
});
const getItemProps: ComponentProps<typeof Pagination>["getItemProps"] = page => ({
"aria-label": t("stock.screener.page", { page }),
});
return { getControlProps, getItemProps };
}
export { useStocksPagePresentationVm };

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