Compare commits

...
25 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
ksv741 c096fc70ef fix(deps): remove npm dependency on Nexus
Frontend verify / commits (pull_request) Successful in 42s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Failing after 36m0s
Nexus hosts release Docker images, not npm dependencies.

Omit registry URLs using npm's native lockfile option.

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

Backend contract: WealthLedger/backend@d698ed9

BREAKING CHANGE: drop investment stateVersion/progress/resources
from the client contract and use data/metadata readiness instead of
rewritten query status. Cross-tab auth may require re-login.
2026-10-03 11:45:04 +03:00
128 changed files with 3338 additions and 4535 deletions
+8 -6
View File
@@ -8,7 +8,7 @@ Vitest — отдельную JSDOM конфигурацию. Точные versi
[package.json](../package.json), реализация — source of truth.
```text
app → pages → features → entities → shared
app → pages → widgets → features → entities → shared
page container → page VM → entity/feature query → shared/api → backend /api/v1
↓
props → state/section views
@@ -30,13 +30,15 @@ contract; frontend не читает его checkout для генерации
| --- | --- |
| Bootstrap, глобальные providers, routes | `app` |
| Композиция маршрута и поведение, нужное только этому экрану | `pages/<page>` |
| Крупная переиспользуемая композиция экрана | `widgets/<widget>` |
| Самостоятельная пользовательская возможность с собственным смыслом | `features/<capability>` |
| Представление/поведение бизнес-сущности | `entities/<entity>` |
| Переиспользуемый код без WealthLedger domain meaning | `shared` |
Локальный FSD profile: `app → pages → features → entities → shared`, без `widgets`.
Крупный уникальный блок остаётся в своей page. Form, mutation или click handler
сами по себе не требуют feature. Два callers не делают domain helper generic shared.
Локальный FSD profile: `app → pages → widgets → features → entities → shared`.
Widget собирает несколько нижних слоёв в самостоятельный UI-блок; компонент для
одной page остаётся у page. Form, mutation или click handler сами по себе не
требуют feature. Два callers не делают domain helper generic shared.
Slice именуется по ответственности, а не `helpers`, `hooks` или `types`.
**Безопасный путь:** при сомнении оставь код у ближайшего допустимого owner.
@@ -47,12 +49,12 @@ Slice именуется по ответственности, а не `helpers`,
Cross-slice consumer использует `@/` и `index.ts` владельца, например
`@/entities/portfolio`. Alias Vite и TypeScript согласован. Другой slice не
импортирует внутренний файл напрямую. Slices `pages`, `features`, `entities` не зависят от соседнего slice своего слоя;
импортирует внутренний файл напрямую. Slices `pages`, `widgets`, `features`, `entities` не зависят от соседнего slice своего слоя;
композиция принадлежит вышележащему owner. Existing меж-slice исключения
не являются разрешением добавлять новые.
Направление зависимостей, public imports и запрет соседних slices в
`pages`, `features`, `entities` проверяет `npm run deps`. Внутренние imports
`pages`, `widgets`, `features`, `entities` проверяет `npm run deps`. Внутренние imports
одного slice и связи shared segments этот запрет не ограничивает.
Knip проверяет использование exports, а не смысл placement.
+13
View File
@@ -39,6 +39,10 @@ Authorization. Auth endpoints исключены из Bearer/retry middleware. R
координируется single-flight; после `401` с `UNAUTHORIZED` возможен один retry.
Не добавляй собственный refresh/retry loop в caller.
Cookie-writing requests упорядочены только в текущей вкладке. Cross-tab auth
synchronization не требуется; допустим reload/re-login. Web Locks и отдельные
token stores не добавляются.
Protected queries используют existing session-aware boundary; logout очищает
session-scoped cache. Access token хранится только в памяти, refresh передаётся
httpOnly cookie с `credentials: include`. Не сохраняй tokens в localStorage,
@@ -50,6 +54,15 @@ GET subtree; ошибка mutation не меняет generation/cache. Это о
от обычного фонового refetch. Даже повторное сохранение того же token начинает
новую generation; поздний ответ прежней generation не попадает в новый кэш.
Отмена/reset принадлежат TanStack Query и generated queryFn с AbortSignal,
без HTTP request registry, URL-wide latest-read counters и access epochs.
Успешный HTTP с `data: null` остаётся query success; readiness выбирается по
data/metadata, без подмены query status и отдельного transport state.
Status синхронизации содержит только состояние работы. Resources читаются
через `sync=observe` во время работы и после завершения, без hash comparison.
Согласованность income entries/groups обеспечивает backend; frontend не
дублирует её runtime validator.
Mutations используют существующий generated client и query-key owner;
после успеха обновляй/invalidируй только связанные данные. Optimistic update
нужен конкретному UX и обязан иметь rollback/error behavior.
+28 -14
View File
@@ -7,11 +7,19 @@
checkout. Внешний API задаётся предоставленным публичным контрактом;
исходники или инструкции его реализации не являются зависимостью разработки.
При нехватке требования сообщи, какое решение не определено, до его реализации.
При конфликте с общим шаблоном следуй местным правилам и явному запросу
пользователя. Выбирай реализацию самостоятельно, но изменение финансовой
логики, публичного контракта или утверждённой семантики возвращай владельцу
на согласование до зависимой работы.
Начни с результата и affected areas. До edits проследи flow/invariants, найди
owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт.
Работай напрямую; OpenSpec/SDD lifecycle — только по явному запросу.
Перед Node-командами активируй местную `.nvmrc`: `nvm use --silent`.
Выбери lightweight или SDD по [Engineering Workflow](https://git.personal.ksv741.ru/WealthLedger/main/src/commit/03b3b59e6ff2ba391f9dfc42a039a96d0d84c1c9/.ai/conventions/engineering-workflow.md);
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 архивы.
## 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.
Markdown-only: проверь новые/изменённые тексты, локальные ссылки/якоря, маршруты
чтения и `git diff --check`; application build ради текста не нужен.
Повтори routing по staged/unstaged/new files. Запиши краткие команды, результаты
и ограничения. Зелёный lint не заменяет semantic/security review.
| Когда | Что проверить |
| --- | --- |
| Окружение новое, изменились manifest/lockfile или Node, установка сомнительна | `npm ci` до Node-проверок; одна правка кода переустановки не требует. |
| Итерация | Адресные тесты, typecheck и lint по затронутому поведению и риску. При изменении поведения добавь/обнови содержательную регрессию, если существующая проверка не ловит её; для косметики не создавай искусственный тест. |
| Итоговый code/dependency diff | Перед объявлением готовности и передачей к интеграции выполни один полный `npm run verify` на итоговых входах. Дополнительные risk checks из testing convention и явно утверждённые task checks обязательны; CI проверяет независимо. |
| Только Markdown | Проверь тексты, ссылки/якоря, маршруты чтения и `git diff --check`; application build не нужен. При изменении verification-контура проверь его работу. |
Повтори routing по staged/unstaged/new files. В отчёте по задаче/PR запиши
проверенные входы, команду, exit code, результат и ограничения. Повторяй
проверку при изменении значимых входов (включая config, dependencies и
generated artifacts), сомнении в evidence или явном запросе пользователя.
`lint:fix`/format — edits, не proof; lint не заменяет semantic/security review.
## Git и релизы
Работа — в `codex/<task>` от актуального `develop`, затем PR в `develop`.
Работа — в ветке `<type>/<issue-number>-<slug>` от актуального `develop`,
затем PR в `develop`; для cross-repo ссылки укажи owner/repo#number.
Сохраняй чужие изменения; перед commit просмотри staged paths и diff.
Commits и PR titles — Conventional Commits; допустим `spec:`. Каждый commit
содержит одну связанную задачу. При связанной issue добавь её номер в конец
заголовка (`fix: … #12`); без issue номер не нужен. Root другого checkout
не управляет этим repo.
Commits и PR titles — Conventional Commits с parser-recognized Issue reference (`fix: … (#12)`
или `chore: … (WealthLedger/main#27)`); допустим `spec:`. Каждый commit
содержит одну связанную задачу. Исключения release и правила ссылки — в
Engineering Workflow из AGENTS. Root другого checkout не управляет этим repo.
Прямой push в `main`, force-push и перенос опубликованных тегов не входят в
обычную работу. Перед 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:
branches: [develop, main]
pull_request:
types: [opened, synchronize, reopened, edited]
types: [opened, synchronize, reopened]
schedule:
- cron: '17 5 * * *'
@@ -34,7 +34,11 @@ jobs:
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
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:
needs: commits
+3
View File
@@ -4,6 +4,9 @@
только его файлы и предоставленные публичные контракты. Перед работой прочитай
[порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем
объединяй 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
## [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)
### Features
+6 -3
View File
@@ -203,7 +203,8 @@ Checkout frontend содержит все входы сборки и прове
локальную копию и не обращаются к исходникам или CI сервера. Доставка новой
схемы — явное обновление snapshot с review и `npm run openapi:generate`.
Runtime URL API задаётся окружением, а не расположением другого репозитория.
Разработка идёт напрямую по задаче пользователя; OpenSpec/SDD — по явному запросу.
Разработка начинается с Issue; lightweight или SDD выбирается по критериям
Engineering Workflow из AGENTS. OpenSpec — после явного решения Change Owner.
Knip исключает `conventional-changelog-conventionalcommits`: release-it загружает
preset по имени из `.release-it.json`. Версия preset закреплена напрямую.
@@ -211,9 +212,11 @@ preset по имени из `.release-it.json`. Версия preset закреп
## Релизы
Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые
релизы. Работа: `codex/<task>` от актуального `develop`, PR в `develop`.
релизы. Обычная работа: issue-linked ветка от актуального `develop`, PR в `develop`;
общие правила — в Engineering Workflow по ссылке из AGENTS.
Обычные PR допускают squash; release и обратная синхронизация сохраняют историю.
Commits и PR titles — Conventional Commits, включая `spec:`. Версии вручную не повышай.
Commits и PR titles — Conventional Commits со ссылкой на Issue (кроме release),
включая `spec:`. Версии вручную не повышай.
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что
`develop` содержит последний релиз. Создай `release/<name>` от проверенного
+6
View File
@@ -1,6 +1,12 @@
module.exports = {
defaultIgnores: false,
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: {
"references-empty": [2, "never"],
"type-enum": [
2,
"always",
+11 -5
View File
@@ -29,17 +29,17 @@ module.exports = {
},
{
from: {
path: "^src/(app|pages|features|entities|shared)/([^/]+)/",
path: "^src/(app|pages|widgets|features|entities|shared)/([^/]+)/",
},
name: "slices-must-use-public-api",
severity: "error",
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/",
},
},
{
from: { path: "^src/(pages|features|entities)/([^/]+)/" },
from: { path: "^src/(pages|widgets|features|entities)/([^/]+)/" },
name: "no-same-layer-cross-slice-imports",
severity: "error",
to: { path: "^src/$1/", pathNot: "^src/$1/$2/" },
@@ -48,18 +48,24 @@ module.exports = {
from: { path: "^src/shared/" },
name: "shared-must-not-depend-on-layers",
severity: "error",
to: { path: "^src/(entities|features|pages|app)/" },
to: { path: "^src/(entities|features|widgets|pages|app)/" },
},
{
from: { path: "^src/entities/" },
name: "entities-must-not-depend-on-higher-layers",
severity: "error",
to: { path: "^src/(features|pages|app)/" },
to: { path: "^src/(features|widgets|pages|app)/" },
},
{
from: { path: "^src/features/" },
name: "features-must-not-depend-on-higher-layers",
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)/" },
},
{
+98 -1291
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -1,7 +1,7 @@
{
"name": "wealth-ledger-frontend",
"private": true,
"version": "0.4.0",
"version": "1.1.0",
"type": "module",
"engines": {
"node": "24.21.0"
@@ -34,7 +34,7 @@
"lint:fix": "npm run build:packages && eslint . --fix --max-warnings=0"
},
"lint-staged": {
"*.{js,mjs,cjs,ts,tsx}": "eslint --fix --max-warnings=0"
"*.{js,mjs,cjs,ts,tsx}": "eslint --fix --max-warnings=0 --no-warn-ignored"
},
"dependencies": {
"@hookform/resolvers": "5.9.1",
@@ -42,6 +42,7 @@
"@mantine/core": "9.6.1",
"@mantine/dates": "9.6.1",
"@mantine/hooks": "9.6.1",
"@mantine/notifications": "9.6.1",
"@tanstack/react-query": "5.102.8",
"@wealthledger/icons": "file:packages/icons",
"@wealthledger/tokens": "file:packages/tokens",
-6
View File
@@ -6,12 +6,6 @@ const { version } = require("../package.json");
const git = (...arguments_) =>
execFileSync("git", arguments_, { encoding: "utf8" }).trim(); // eslint-disable-line sonarjs/no-os-command-from-path -- Repository checks use the developer/CI Git installation; arguments never invoke a shell.
assert.equal(
git("status", "--porcelain"),
"",
"Commit all changes before preparing a release",
);
const tag_ = git(
"tag",
"--merged",
+10 -6
View File
@@ -369,7 +369,9 @@ describe("All accounts routes", () => {
renderPage("/accounts/all/operations?from=2026-08-01&to=2026-08-31");
await screen.findAllByText("Выплата купона");
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(screen.getByLabelText("Текущий путь")).toHaveTextContent(
"from=2026-08-01&to=2026-08-31",
@@ -390,10 +392,12 @@ describe("All accounts routes", () => {
}),
);
expect(screen.getAllByText("Сбербанк")).toHaveLength(1);
expect(
await screen.findByRole("button", {
name: "Повторить",
}),
).toBeInTheDocument();
await waitFor(() => {
expect(
screen.queryByRole("button", {
name: "Повторить",
}),
).not.toBeInTheDocument();
});
});
});
+8 -1
View File
@@ -2,6 +2,7 @@ import type { FC } from "react";
import { MantineProvider } from "@mantine/core";
import { DatesProvider } from "@mantine/dates";
import { Notifications } from "@mantine/notifications";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
import { BrowserRouter } from "react-router";
@@ -11,7 +12,11 @@ import { AuthProvider } from "@/features/auth";
const queryClient = new QueryClient({
defaultOptions: {
queries: { refetchOnWindowFocus: false, retry: false },
queries: {
refetchOnReconnect: true,
refetchOnWindowFocus: true,
retry: false,
},
},
});
@@ -31,6 +36,8 @@ const App: FC = () => {
</RouteErrorBoundary>
</AuthProvider>
</BrowserRouter>
<Notifications autoClose={4000} limit={2} position="top-right" zIndex={1000} />
</QueryClientProvider>
</DatesProvider>
</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("to")).toBe("2026-09-30");
if (isFail && page === 2)
return HttpResponse.json({ code: "FAILED" }, { status: 500 });
return HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 });
return HttpResponse.json({
hasNext: page === 1,
@@ -299,7 +299,7 @@ describe("banking API", () => {
expect(result.current.query.data?.balance.value).toBe("100");
server.use(
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());
@@ -1,4 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { notifications } from "@mantine/notifications";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
@@ -71,7 +72,7 @@ function useBankCardFormVm(props: BankCardFormProps) {
openingDate: values.openingDate,
};
if (props.card)
if (props.card) {
await updateCard.mutateAsync({
body: {
...metadata,
@@ -80,12 +81,19 @@ function useBankCardFormVm(props: BankCardFormProps) {
},
id: props.card.id,
});
else
}
else {
await cardCreation.mutateAsync({
id: intent,
...metadata,
...financial,
});
notifications.show({
id: "bank-card-created-" + intent,
message: t("bank.cardSaved"),
});
}
setOpened(false);
}
catch (error_) {
@@ -15,6 +15,28 @@
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 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -47,7 +47,6 @@ const meta
error: false,
fetching: false,
loading: false,
retry: () => {},
},
component: BankSummaryView,
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 { Button, Group, Skeleton, Stack, Text, Title } from "@mantine/core";
import { Skeleton, Stack, Text, Title } from "@mantine/core";
import { OperationIcon } from "@wealthledger/icons";
import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
@@ -20,7 +20,6 @@ type BankSummaryViewProps = {
error: boolean;
fetching: boolean;
loading: boolean;
retry: () => void;
};
const BankSummaryViewCategories: FC<BankSummaryViewProps> = (props) => {
@@ -93,25 +92,37 @@ const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
</Title>
{props.data && (
<Text className={classes.total}>
{formatMoney(props.data.net, { withZeroCents: true })}
</Text>
<dl className={classes.totals}>
<div>
<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>
<BankSummaryViewCategories {...props} />
{props.error && (
<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>}
{props.error && !props.data && <Text role="alert">{t("bank.error")}</Text>}
</Stack>
);
};
@@ -1,5 +1,5 @@
import { hashKey, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useEffect, useState } from "react";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
@@ -29,15 +29,28 @@ function useBrokerageOperationHistory(
cursor: cursors.at(-1),
});
const isInvalidCursor = isApiErrorCode(
apiErrorCode(operations.error),
ApiErrorCode.InvalidOperationsCursor,
);
const isRecoveringInvalidCursor = cursors.length > 0 && isInvalidCursor;
const reset = () => setPagination({ cursors: [], scope });
useEffect(() => {
if (!isRecoveringInvalidCursor) return;
const timeout = setTimeout(() => {
setPagination({ cursors: [], scope });
void client.invalidateQueries({ exact: true, queryKey });
}, 0);
return () => clearTimeout(timeout);
}, [client, isRecoveringInvalidCursor, queryKey, scope]);
const retry = (retryScreen: () => unknown) => {
if (
isApiErrorCode(
apiErrorCode(operations.error),
ApiErrorCode.InvalidOperationsCursor,
)
) {
if (isInvalidCursor) {
reset();
void client.invalidateQueries({
exact: true,
@@ -49,6 +62,7 @@ function useBrokerageOperationHistory(
return {
hasPrevious: cursors.length > 0,
isRecoveringInvalidCursor,
onNext: () => {
if (operations.data?.nextCursor)
setPagination({
@@ -4,17 +4,22 @@ const brokerageAccountsQueryOptions = () =>
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts");
const brokerageAccountsQueryKey = brokerageAccountsQueryOptions().queryKey;
const BROKERAGE_ACCOUNTS_STALE_TIME_MS = 1_800_000;
type BrokerageAccount = NonNullable<
paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"]["data"]
>[number];
function useBrokerageAccounts() {
return useResourceQuery({
const query = useResourceQuery({
meta: { accessGroup: "brokerage" },
...brokerageAccountsQueryOptions(),
staleTime: 60_000,
staleTime: BROKERAGE_ACCOUNTS_STALE_TIME_MS,
});
return {
...query,
};
}
export {
@@ -205,26 +205,32 @@ export const IndependentFreshness: Story
],
[
{
availability: "complete",
availability: "available",
completeness: "complete",
dataRevision: "1",
freshness: "stale",
lastSuccessfulAt: "2026-09-24T10:30:00Z",
resourceKey: "portfolio/storybook-account",
stateVersion: "failed",
sync: {
errorCode: "TBANK_UNAVAILABLE",
id: "failed",
lastAttempt: {
finishedAt: "2026-09-24T10:31:00Z",
problems: [],
startedAt: "2026-09-24T10:30:00Z",
state: "failed",
},
lastSuccessfulAt: "2026-09-24T10:30:00Z",
resourceKey: "portfolio/storybook-account",
},
{
availability: "missing",
availability: "initial-loading",
completeness: "incomplete",
dataRevision: "0",
freshness: "unknown",
lastAttempt: {
finishedAt: null,
problems: [],
startedAt: "2026-09-24T10:30:00Z",
state: "running",
},
lastSuccessfulAt: null,
resourceKey: "portfolio/waiting",
stateVersion: "waiting",
sync: { errorCode: null, id: "waiting", state: "running" },
},
],
),
@@ -64,16 +64,16 @@ const BrokerageAccountSourceStatus: FC<BrokerageAccountSourceStatusProps> = (
const { source } = props;
const { t } = useTranslation();
const isSyncing
= source?.sync.state === "running" || source?.sync.state === "queued";
const isSyncing = source?.lastAttempt.state === "running";
const errorText
= source?.sync.state === "failed" ? t("accounts.refreshError") : null;
= source?.lastAttempt.state === "failed" ? t("accounts.refreshError") : null;
return (
<>
<SourceSyncStatus
errorText={errorText}
lastAttempt={source?.lastAttempt}
lastSuccessfulAt={source?.lastSuccessfulAt}
stale={source?.freshness === "stale"}
/>
@@ -1,12 +1,10 @@
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 { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { SourceSyncStatus } from "@/shared/ui";
import type { BrokerageEvent } from "../../api/useBrokerageEvents";
import { brokerageEventKey } from "../../model/event-key";
@@ -19,9 +17,6 @@ type BrokerageEventsPreviewProps = {
type BrokerageEventsPreviewState
= | {
items: BrokerageEvent[];
lastSuccessfulAt?: null | string;
refreshError?: boolean;
stale?: boolean;
state: "loaded" | "partial";
to: string;
}
@@ -33,28 +28,15 @@ const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
const { events } = props;
const { t } = useTranslation();
const errorText
= events.state === "loaded" && events.refreshError
? t("accounts.refreshError")
: null;
return (
<Stack gap="sm">
{events.state === "loaded" && (
<SourceSyncStatus
errorText={errorText}
lastSuccessfulAt={events.lastSuccessfulAt}
stale={events.stale}
/>
)}
{events.state === "loading" && (
<LoadingSkeleton label={t("events.recentLoading")}>
{["first", "second", "third"].map(placeholder => <Skeleton height={48} key={placeholder} radius="sm" />)}
</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>}
+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 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 StockPeriod = NonNullable<
@@ -18,6 +14,8 @@ type StockPeriod = NonNullable<
>["period"]
>;
type StockPosition = NonNullable<paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
const STOCK_STALE_TIME_MS = 30_000;
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 {
MIN_STOCK_SEARCH_LENGTH,
type StockCandles,
type StockDividends,
type StockPosition,
@@ -117,5 +104,4 @@ export {
useStockDividends,
useStockPosition,
useStockQuote,
useStockSearch,
};
-2
View File
@@ -1,5 +1,4 @@
export {
MIN_STOCK_SEARCH_LENGTH,
type StockCandles,
type StockDividends,
type StockPosition,
@@ -9,5 +8,4 @@ export {
useStockDividends,
useStockPosition,
useStockQuote,
useStockSearch,
} from "./api/queries";
+3 -8
View File
@@ -38,14 +38,10 @@ function useAuthProviderVm() {
const authQueueRef = useRef<Promise<unknown>>(Promise.resolve());
const refreshPromiseRef = useRef<Promise<boolean> | undefined>(undefined);
// Cookie-writing requests share ordering, including across tabs with Web Locks.
// Order cookie-writing requests within this tab. Other tabs may reauthenticate.
const serialize = useCallback(<T>(run: () => Promise<T>): Promise<T> => {
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
const next = authQueueRef.current.then(async () =>
"locks" in navigator
? navigator.locks.request("wealth-ledger-refresh", run)
: run(),
);
const next = authQueueRef.current.then(run);
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
authQueueRef.current = next.catch(() => {});
@@ -110,8 +106,7 @@ function useAuthProviderVm() {
}
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
}).finally(() => {
if (refreshPromiseRef.current === pending)
refreshPromiseRef.current = undefined;
refreshPromiseRef.current = undefined;
});
refreshPromiseRef.current = pending;
@@ -1,61 +1,73 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { resource } from "@/test/resource";
import { section } from "./section";
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",
(availability) => {
const data = availability === "missing" ? undefined : { rows: [] };
(availability, completeness) => {
const data = availability === "initial-loading" ? undefined : { rows: [] };
const state = section({
data,
isError: false,
isFetching: false,
meta: resource(data, { availability }).meta,
refetch: vi.fn(),
meta: resource(data, { availability, completeness }).meta,
});
expect(state.loading).toBe(availability === "missing");
expect(state.partial).toBe(availability === "partial");
expect(state.hasData).toBe(availability !== "missing");
expect(state.loading).toBe(availability === "initial-loading");
expect(state.partial).toBe(completeness === "incomplete");
expect(state.hasData).toBe(availability !== "initial-loading");
},
);
it("preserves a full fresh source and timestamp when force fails, while offering scoped retry", () => {
const retry = vi.fn();
const refetch = vi.fn();
it("preserves empty successful data instead of treating it as missing", () => {
const state = section({
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([], {
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;
const state = section(
{ data: [], isError: false, isFetching: false, meta, refetch },
retry,
);
const state = section({ data: [], isError: false, meta });
expect(state).toMatchObject({
error: true,
hasData: true,
lastSuccessfulAt: meta.lastSuccessfulAt,
loading: false,
refreshing: false,
});
state.retry();
expect(retry).toHaveBeenCalledOnce();
expect(refetch).not.toHaveBeenCalled();
});
it("shows source failure without a false loading or empty result", () => {
const state = section({
data: undefined,
isError: false,
isFetching: false,
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,
refetch: vi.fn(),
});
expect(state).toMatchObject({
@@ -3,43 +3,27 @@ import type { InvestmentResourceMeta } from "@/shared/api";
type SectionState = {
error: boolean;
hasData: boolean;
lastSuccessfulAt?: null | string;
loading: boolean;
partial?: boolean;
refreshing: boolean;
retry: () => void;
sourceError?: boolean;
stale?: boolean;
};
function section(
query: {
data: unknown;
isError: boolean;
isFetching: boolean;
meta?: InvestmentResourceMeta;
refetch: () => unknown;
},
retry?: () => unknown,
): SectionState {
const isError = query.isError || query.meta?.sync.state === "failed";
function hasSectionData(data: unknown) {
return data !== undefined;
}
function section(query: {
data: unknown;
isError: boolean;
meta?: InvestmentResourceMeta;
}): SectionState {
const isError = query.isError || query.meta?.lastAttempt.state === "failed";
const hasData = hasSectionData(query.data);
return {
error: isError,
hasData: Boolean(query.data),
lastSuccessfulAt: query.meta?.lastSuccessfulAt,
loading: !query.data && !isError,
partial: query.meta?.availability === "partial",
refreshing:
Boolean(query.data)
&& (query.isFetching
|| query.meta?.sync.state === "running"
|| query.meta?.sync.state === "queued"),
retry: () => {
void (retry ?? query.refetch)();
},
sourceError: query.meta?.sync.state === "failed",
stale: query.meta?.freshness === "stale",
hasData,
loading: !hasData && !isError,
partial: query.meta?.completeness === "incomplete",
};
}
@@ -86,7 +86,7 @@ const InstrumentEvents: FC<InstrumentEventsProps> = (props) => {
<Group justify="space-between" mt="md">
<Button
disabled={!vm.hasPrevious || vm.operationsState.refreshing}
disabled={!vm.hasPrevious}
onClick={vm.onPrevious}
variant="default"
>
@@ -94,7 +94,7 @@ const InstrumentEvents: FC<InstrumentEventsProps> = (props) => {
</Button>
<Button
disabled={!vm.operations.hasNext || vm.operationsState.refreshing}
disabled={!vm.operations.hasNext}
onClick={vm.onNext}
variant="default"
>
@@ -1,21 +1,17 @@
import type { FC, PropsWithChildren, ReactNode } from "react";
import { Avatar, Button, Group, Paper, Text, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Avatar, Group, Paper, Text, Title } from "@mantine/core";
import classes from "./InstrumentHeader.module.css";
type InstrumentHeaderProps = PropsWithChildren<{
logoSrc: null | string;
name: string;
onRefresh: () => void;
refreshing: boolean;
subtitle: ReactNode;
}>;
const InstrumentHeader: FC<InstrumentHeaderProps> = (props) => {
const { children, logoSrc, name, onRefresh, refreshing, subtitle } = props;
const { t } = useTranslation();
const { children, logoSrc, name, subtitle } = props;
return (
<Paper className={classes.header} component="header" withBorder>
@@ -31,10 +27,6 @@ const InstrumentHeader: FC<InstrumentHeaderProps> = (props) => {
<Text c="dimmed" size="sm">{subtitle}</Text>
</div>
</div>
<Button disabled={refreshing} onClick={onRefresh} variant="subtle">
{t("investment.refresh")}
</Button>
</Group>
{children}
@@ -1,6 +1,6 @@
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 { useTranslation } from "react-i18next";
import { Link } from "react-router";
@@ -11,11 +11,10 @@ type InstrumentPageStateProps = {
code?: string;
kind: "bond" | "stock";
loading: boolean;
retry: () => void;
};
const InstrumentPageState: FC<InstrumentPageStateProps> = (props) => {
const { code, kind, loading, retry } = props;
const { code, kind, loading } = props;
const { t } = useTranslation();
if (loading) return (
<LoadingSkeleton label={t(`${kind}.loading`)}>
@@ -55,10 +54,6 @@ const InstrumentPageState: FC<InstrumentPageStateProps> = (props) => {
<Anchor component={Link} to="/settings">
{t("accounts.openSettings")}
</Anchor>
<Button onClick={retry} variant="default">
{t("accounts.retry")}
</Button>
</Stack>
);
};
@@ -2,7 +2,6 @@ import type { FC, PropsWithChildren, ReactNode } from "react";
import {
Alert,
Button,
Group,
Paper,
Skeleton,
@@ -13,8 +12,6 @@ import {
import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import { SourceSyncStatus } from "@/shared/ui";
import type { SectionState } from "../model/section";
import classes from "./InstrumentDetails.module.css";
@@ -33,10 +30,6 @@ type InstrumentSectionProps = PropsWithChildren<{
const InstrumentSection: FC<InstrumentSectionProps> = (props) => {
const { t } = useTranslation();
const readError = t(
props.state.hasData ? "accounts.refreshError" : "stock.sectionError",
);
const sectionClassName = props.embedded
? classes.embeddedSection
: classes.section;
@@ -80,35 +73,11 @@ const InstrumentSection: FC<InstrumentSectionProps> = (props) => {
{props.controls}
</Group>
<SourceSyncStatus
lastSuccessfulAt={props.state.lastSuccessfulAt}
stale={props.state.stale}
/>
{!!props.state.partial && <Text role="status">{t("investment.partial")}</Text>}
{!!props.state.refreshing && (
<Text c="dimmed" role="status" size="xs">
{t("accounts.refreshing")}
</Text>
)}
{!!props.state.error && (
{!!(props.state.error && !props.state.hasData) && (
<Alert aria-live="polite" color="orange" mb="sm" role="status">
<Group justify="space-between">
<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>
<Text size="sm">{t("stock.sectionError")}</Text>
</Alert>
)}
@@ -1,7 +1,7 @@
import type { ComponentProps } from "react";
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 { 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,
query: "SBER",
ready: true,
refreshing: false,
retry: vi.fn(),
};
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(
screen.getByRole("link", { name: "Открыть настройки T-Bank" }),
).toHaveAttribute("href", "/settings");
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
expect(vm.retry).toHaveBeenCalledOnce();
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
rerender(view({ loading: true, ready: false }));
expect(screen.queryByRole("link", { name: "Сбербанк" })).not.toBeInTheDocument();
@@ -3,7 +3,6 @@ import type { ChangeEvent, FC, ReactNode } from "react";
import {
Alert,
Anchor,
Button,
Skeleton,
Stack,
Text,
@@ -28,8 +27,6 @@ type InstrumentSearchProps = {
partial: boolean;
query: string;
ready: boolean;
refreshing: boolean;
retry: () => void;
};
};
@@ -77,14 +74,6 @@ const InstrumentSearch: FC<InstrumentSearchProps> = (props) => {
</Text>
</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>}
<TextInput
@@ -114,9 +103,6 @@ const InstrumentSearch: FC<InstrumentSearchProps> = (props) => {
{t("accounts.openSettings")}
</Anchor>
<Button onClick={vm.retry} variant="default">
{t("accounts.retry")}
</Button>
</Stack>
</Alert>
)}
-4
View File
@@ -1,6 +1,2 @@
export { useScreenSync } from "./model/screen-sync";
export {
InvestmentRefreshState,
type InvestmentRefreshStateProps,
} from "./ui/InvestmentRefreshState/InvestmentRefreshState.view";
export { TbankConnectionSection } from "./ui/TbankConnectionSection/TbankConnectionSection";
@@ -2,7 +2,6 @@ import type { QueryClient } from "@tanstack/react-query";
import {
apiQueryClient,
invalidateApiRequests,
type paths,
} from "@/shared/api";
@@ -14,7 +13,6 @@ function isInvestmentPath(path: string) {
"/api/v1/brokerage-accounts",
"/api/v1/stocks",
"/api/v1/bonds",
"/api/v1/tbank-sync",
].some(root => path === root || path.startsWith(`${root}/`));
}
@@ -27,7 +25,6 @@ function commitTbankConnectionBoundary(
queryClient: QueryClient,
status: TbankConnectionStatus,
) {
invalidateApiRequests(isInvestmentPath);
queryClient.setQueryData(tbankConnectionQueryKey, status);
void queryClient.resetQueries({
predicate: ({ queryKey }) => {
@@ -3,14 +3,12 @@ import { http, HttpResponse } from "msw";
import { describe, expect, it, vi } from "vitest";
import { useBrokerageAccounts } from "@/entities/brokerage-account";
import { useStockQuote } from "@/entities/stock";
import { configureAuthClient, invalidateResourceAccess } from "@/shared/api";
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { useScreenSync } from "../index";
import { commitTbankConnectionBoundary } from "./connection-boundary";
function auth() {
configureAuthClient({
@@ -20,357 +18,23 @@ function auth() {
});
}
describe("page-owned screen synchronization", () => {
it("ignores a late sync acceptance after the account scope changes", async () => {
describe("read-only screen synchronization", () => {
it("retries the resource with GET without requesting source observation", async () => {
auth();
const { promise: blocked, resolve: release }
= 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);
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,
resources: [],
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(async () => {
release();
await pending;
});
await act(() => result.current.refresh());
await waitFor(() => 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([], { stateVersion: 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?.stateVersion).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([], { stateVersion: "current" }));
if (!new URL(request.url).searchParams.has("sync"))
return isChanged
? HttpResponse.json(resource([], { stateVersion: "current" }))
: HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 });
isRecovering = true;
await blocked;
return HttpResponse.json(resource([], { stateVersion: "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?.stateVersion).toBe("current"),
);
expect(published).not.toContainEqual(
expect.objectContaining({
meta: expect.objectContaining({ stateVersion: "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",
resources: [resource([]).meta],
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).toEqual([null, "observe"]);
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([], { stateVersion: isCompleted ? "2" : "1" }).meta;
let gets = 0;
const syncModes: (null | string)[] = [];
server.use(
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()));
}),
http.post("*/api/v1/tbank-sync", async ({ request }) => {
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",
progress: { completed: 1, discovering: false, total: 1 },
resources: [meta()],
scope: { screen: "accounts" },
state: "succeeded",
},
);
return gets === 1
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(resource([]));
}),
);
const { result, unmount } = renderHook(
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
@@ -382,258 +46,36 @@ describe("page-owned screen synchronization", () => {
{ 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 act(async () => {
await Promise.all([
result.current.sync.refresh(),
result.current.sync.refresh(),
]);
});
await waitFor(() =>
expect(result.current.accounts.meta?.stateVersion).toBe("2"),
);
expect(bodies).toEqual([{ screen: "accounts" }]);
expect(modes).toEqual([null, "observe"]);
vi.useFakeTimers();
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();
}
expect(gets).toBe(2);
expect(syncModes).toEqual([null, null]);
});
it.each(["leave", "logout"])(
"publishes completed empty coverage while history runs, then stops on %s",
async (boundary) => {
vi.useFakeTimers({ toFake: ["Date"] });
try {
auth();
let isCompleted = false;
let polls = 0;
const modes: (null | string)[] = [];
const snapshot = () =>
resource(
isCompleted
? []
: null,
{
stateVersion: 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",
progress: {
completed: isCompleted ? 1 : 0,
discovering: true,
total: 2,
},
resources: [snapshot().meta],
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).toEqual([null, "observe"]);
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 () => {
it("refreshes the currently open resources together", async () => {
auth();
let gets = 0;
server.use(
http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json(
resource(
http.get("*/api/v1/brokerage-accounts", () => {
gets += 1;
[],
{
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(
[],
{
resourceKey: "operations",
stateVersion: isObserved ? "covered" : "initial",
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,
stateVersion: isObserved ? "covered" : "initial",
},
],
resourceKey: "projection",
},
),
);
return HttpResponse.json(resource([]));
}),
http.get("*/api/v1/tbank-sync/:id", () =>
HttpResponse.json(
{
errorCode: null,
id: "dependency",
now: "2026-09-25T00:00:00Z",
progress: { completed: 1, discovering: false, total: 1 },
resources: [{ ...dependency, stateVersion: "covered" }],
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 accounts = useBrokerageAccounts();
return {
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts]),
};
return useScreenSync({ screen: "accounts" }, [accounts]);
},
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() => expect(result.current.accounts.data).toEqual([]));
await act(() => result.current.sync.refresh());
await waitFor(() => expect(result.current.sync.transportError).toBe(true));
expect(result.current.accounts.meta).toEqual(saved.meta);
await waitFor(() => expect(gets).toBe(1));
await act(async () => result.current.refresh());
await waitFor(() => expect(gets).toBe(2));
});
});
@@ -1,227 +1,52 @@
import { type QueryKey, useQueryClient } from "@tanstack/react-query";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import type { QueryKey } from "@tanstack/react-query";
import {
ApiErrorCode,
apiErrorCode,
apiQueryClient,
invalidateResourceAccess,
type InvestmentResourceMeta,
isApiErrorCode,
useAuthenticatedQueries,
} from "@/shared/api";
import { useEffect, useRef } from "react";
import { type InvestmentScreen, useTbankSync } from "./sync";
import type { InvestmentResourceMeta } from "@/shared/api";
type ScreenResource = {
isError: boolean;
isFetching: boolean;
meta?: InvestmentResourceMeta;
observe: (version?: string) => Promise<unknown>;
queryKey: QueryKey;
transport: {
fetchStatus: "fetching"
| "idle"
| "paused";
status: "error"
| "pending"
| "success";
};
refetch: () => Promise<unknown>;
};
const STATUS_INTERVAL_MS = 1000;
type ScreenScope = {
[key: string]: unknown;
screen: string;
};
function observeResource(
resource: ScreenResource,
latest: Map<string, InvestmentResourceMeta>,
completedId: string | undefined,
observed: Map<string, string>,
) {
if (!resource.meta) {
if (!completedId || resource.transport.fetchStatus !== "idle") return;
const key = `query:${JSON.stringify(resource.queryKey)}`;
if (observed.get(key) === completedId) return;
observed.set(key, completedId);
void resource.observe().catch(() => {});
const READ_INTERVAL_MS = 1_800_000;
return;
}
const changed = [
resource.meta,
...(resource.meta.dependencies ?? []),
].flatMap((current) => {
const notified = latest.get(current.resourceKey);
return notified && notified.stateVersion !== current.stateVersion
? [notified]
: [];
});
if (changed.length === 0) return;
const notification = JSON.stringify(
changed.map(meta => [meta.resourceKey, meta.stateVersion]),
);
if (observed.get(resource.meta.resourceKey) === notification) return;
observed.set(resource.meta.resourceKey, notification);
const own = changed.find(
meta => meta.resourceKey === resource.meta?.resourceKey,
);
void resource.observe(own?.stateVersion).catch(() => {});
}
function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
const mutation = useTbankSync();
const client = useQueryClient();
const deniedRef = useRef(new Set<unknown>());
const scopeKey = JSON.stringify(scope);
const [requested, setRequested] = useState<null
| {
id: string;
scope: string;
}>(null);
const requestInFlightRef = useRef<null | string>(null);
const currentScopeRef = useRef(scopeKey);
useLayoutEffect(() => {
currentScopeRef.current = scopeKey;
}, [scopeKey]);
const isCurrentMutation
= JSON.stringify(mutation.variables?.body) === scopeKey;
const observedRef = useRef(new Map<string, string>());
const ids = [
...new Set([
...(requested?.scope === scopeKey ? [requested.id] : []),
...resources
.flatMap(({ meta }) =>
meta ? [meta, ...(meta.dependencies ?? [])] : [],
)
.flatMap(meta =>
meta.sync.id
&& (meta.sync.state === "running" || meta.sync.state === "queued")
? [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,
})),
);
function useScreenSync(_scope: ScreenScope, resources: ScreenResource[]) {
const resourcesRef = useRef(resources);
useEffect(() => {
resourcesRef.current = resources;
}, [resources]);
useEffect(() => {
for (const status of statuses) {
if (!status.error || deniedRef.current.has(status.error)) {
continue;
}
const timer = setInterval(() => {
for (const resource of resourcesRef.current)
void resource.refetch().catch(() => {});
}, READ_INTERVAL_MS);
deniedRef.current.add(status.error);
if (!isApiErrorCode(apiErrorCode(status.error), ApiErrorCode.NotFound))
invalidateResourceAccess(client, "brokerage", status.error);
}
return () => clearInterval(timer);
}, []);
const latest = new Map<string, InvestmentResourceMeta>();
const orderedStatuses = statuses.toSorted(
(a, b) => a.dataUpdatedAt - b.dataUpdatedAt,
);
for (const status of orderedStatuses) {
const statusResources = status.data?.resources ?? [];
for (const meta of statusResources) {
latest.set(meta.resourceKey, meta);
const dependencies = meta.dependencies ?? [];
for (const dependency of dependencies)
latest.set(dependency.resourceKey, dependency);
}
}
const completed = statuses.find(
status =>
requested?.scope === scopeKey
&& status.data?.id === requested.id
&& status.data.state !== "running",
)?.data;
for (const resource of resources)
observeResource(resource, latest, completed?.id, observedRef.current);
}, [client, statuses, resources, requested, 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" || meta?.sync.state === "queued")
);
});
const isRefreshing = resources.some(resource => resource.isFetching);
const refresh = async () => {
if (isRefreshing || requestInFlightRef.current === scopeKey) return;
requestInFlightRef.current = scopeKey;
try {
const accepted = await mutation.mutateAsync({ body: scope });
if (
currentScopeRef.current === scopeKey
&& requestInFlightRef.current === scopeKey
)
setRequested({ id: accepted.id, scope: scopeKey });
}
catch {
/*
The mutation retains the transport error for presentation.
*/
}
finally {
if (requestInFlightRef.current === scopeKey)
requestInFlightRef.current
= null;
}
if (isRefreshing) return;
await Promise.allSettled(resources.map(resource => resource.refetch()));
};
// Retry failed HTTP reads without asking the source to import again.
// A source failure (or explicit refresh with no read error) still needs a sync.
// Retry only the HTTP read; source attempts are reported in resource metadata.
const retry = async () => {
const failed = resources.filter(
resource => resource.transport.status === "error",
);
const failed = resources.filter(resource => resource.isError);
const failedStatuses = statuses.filter(status => status.isError);
if (failed.length > 0 || failedStatuses.length > 0) {
await Promise.allSettled([
...failed.map(resource => resource.observe()),
...failedStatuses.map(status => status.refetch()),
]);
}
if (failed.length > 0)
await Promise.allSettled(failed.map(resource => resource.refetch()));
else await refresh();
};
@@ -229,12 +54,10 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
refresh,
refreshing: isRefreshing,
retry,
sourceError:
statuses.some(status => status.data?.state === "failed")
|| resources.some(({ meta }) => meta?.sync.state === "failed"),
transportError:
(isCurrentMutation && mutation.isError)
|| statuses.some(status => status.isError),
sourceError: resources.some(
({ meta }) => meta?.lastAttempt.state === "failed",
),
transportError: resources.some(resource => resource.isError),
};
}
@@ -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 { useScreenSync } from "../index";
import { commitTbankConnectionBoundary } from "./connection-boundary";
import { type InvestmentScreen, useTbankSync } from "./sync";
describe("sync connection and session boundaries", () => {
it.each(["connection", "session"])(
"discards pending status and mutation 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,
progress: { completed: 1, discovering: false, total: 1 },
resources: [],
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: useTbankSync() };
},
{ wrapper },
);
const screen: InvestmentScreen = { screen: "accounts" };
let mutation!: Promise<unknown>;
act(() => {
mutation = result.current.mutation.mutateAsync({ body: screen });
});
// eslint-disable-next-line vitest/valid-expect -- Attach rejection handling before releasing the request; the assertion is awaited below.
const discarded = expect(mutation).rejects.toMatchObject({
name: "AbortError",
});
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 discarded;
expect(result.current.mutation.data).toBeUndefined();
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();
},
);
});
@@ -1,12 +0,0 @@
import { apiQueryClient, type paths } from "@/shared/api";
type InvestmentScreen
= paths["/api/v1/tbank-sync"]["post"]["requestBody"]["content"]["application/json"];
function useTbankSync() {
return apiQueryClient.useMutation("post", "/api/v1/tbank-sync", {
retry: false,
});
}
export { type InvestmentScreen, useTbankSync };
@@ -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");
fireEvent.change(token, { target: { value: "replacement-draft" } });
finishRefetch?.();
expect(
await screen.findByText(/Не удалось обновить статус подключения/),
).toBeVisible();
await waitFor(() => {
expect(
screen.queryByText(/Не удалось обновить статус подключения/),
).not.toBeInTheDocument();
});
expect(screen.getByLabelText("Token T-Bank")).toBe(token);
expect(token).toHaveValue("replacement-draft");
expect(screen.getByText("Подключено с 14.08.2026")).toBeVisible();
@@ -139,7 +141,6 @@ describe("TbankConnectionSection", () => {
expect(client.getQueryData(portfolioQueryKey)).toEqual({
source: "previous-connection",
});
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
await waitFor(() => expect(reads).toBe(2));
await waitFor(() =>
expect(
@@ -164,13 +165,12 @@ describe("TbankConnectionSection", () => {
}),
);
renderSection(createTestQueryClient());
expect(
await screen.findByText(/Не удалось загрузить статус подключения/),
).toBeVisible();
expect(screen.queryByLabelText("Token T-Bank")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
expect(await screen.findByLabelText("Token T-Bank")).toBeVisible();
expect(reads).toBe(2);
await waitFor(() => {
expect(
screen.queryByText(/Не удалось загрузить статус подключения/),
).not.toBeInTheDocument();
});
expect(reads).toBe(1);
});
it("commits connection status and resets the complete brokerage query subtree after connecting", async () => {
const client = seededClient();
@@ -1,3 +1,4 @@
import { notifications } from "@mantine/notifications";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
@@ -32,6 +33,12 @@ function useTbankConnectionSectionLoadedVm(status: TbankConnectionStatus) {
onSuccess: (data) => {
commitTbankConnectionBoundary(queryClient, data);
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 { QueryClientProvider } from "@tanstack/react-query";
import {
act,
fireEvent,
render,
renderHook,
@@ -21,6 +20,7 @@ import {
import { beforeEach, describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { defaultPeriod, formatDate } from "@/shared/lib";
import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
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",
async (search) => {
const requested: string[] = [];
const syncRequests: unknown[] = [];
server.use(
http.get("*/api/v1/brokerage-accounts/all/events", ({ request }) => {
requested.push(new URL(request.url).search);
@@ -124,25 +123,17 @@ describe("Upcoming event date filters", () => {
resource(
null,
{
sync: {
errorCode: "TBANK_UNAVAILABLE",
id: null,
lastAttempt: {
finishedAt: "2026-08-14T00:01:00Z",
problems: [],
startedAt: "2026-08-14T00:00:00Z",
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 { result } = renderHook(
@@ -162,21 +153,13 @@ describe("Upcoming event date filters", () => {
await waitFor(() => expect(result.current.state).toBe("error"));
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"])(
"normalizes invalid links and history while preserving one-sided future dates for %s",
async (accountId) => {
const [defaultFrom, defaultTo] = defaultPeriod();
const searches: string[] = [];
server.use(
http.get(
@@ -215,24 +198,28 @@ describe("Upcoming event date filters", () => {
),
);
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"])(
"lets users reset filters after a request failure for %s",
async (accountId) => {
const [defaultFrom] = defaultPeriod();
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/events",
({ request }) =>
new URL(request.url).searchParams.has("from")
? HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 })
: HttpResponse.json(resource({ events: [] })),
new URL(request.url).searchParams.get("from") === defaultFrom
? HttpResponse.json(resource({ events: [] }))
: HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 }),
),
);
renderEvents(accountId, ["?from=2099-02-01"]);
await screen.findByRole("button", { name: "Повторить" });
await screen.findByRole("button", { name: "Сбросить" });
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
await screen.findByLabelText("Период");
expect(screen.getByLabelText("URL")).toBeEmptyDOMElement();
@@ -242,6 +229,7 @@ describe("Upcoming event date filters", () => {
it.each(["account-1", "all"])(
"applies dates only on submit, restores URL dates, and resets from empty results for %s",
async (accountId) => {
const [defaultFrom, defaultTo] = defaultPeriod();
const searches: string[] = [];
server.use(
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("Период");
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(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(
await screen.findByRole("button", { name: /10 February 2099/i }),
await screen.findByRole("button", { name: /10 February 2026/i }),
);
fireEvent.click(
await screen.findByRole("button", { name: /28 February 2099/i }),
await screen.findByRole("button", { name: /28 February 2026/i }),
);
expect(searches).toHaveLength(1);
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
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(
"10.02.2099 – 28.02.2099",
"10.02.2026 – 28.02.2026",
);
fireEvent.click(screen.getByRole("button", { name: "History back" }));
await waitFor(() =>
expect(screen.getByLabelText("Период")).toHaveTextContent(
"01.02.2099 – 28.02.2099",
"01.02.2026 – 28.02.2026",
),
);
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: "Сбросить" }));
await waitFor(() => expect(searches.at(-1)).toBe(""));
expect(await screen.findByLabelText("Период")).toHaveTextContent("");
await waitFor(() =>
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.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({
accessToken: () => "synthetic",
clear: vi.fn(),
@@ -318,7 +310,14 @@ it("retains the previous complete forecast and timestamp when its source refresh
const snapshot = resource(
{ 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(
@@ -343,9 +342,6 @@ it("retains the previous complete forecast and timestamp when its source refresh
await waitFor(() =>
expect(result.current).toMatchObject({
events,
lastSuccessfulAt: snapshot.meta.lastSuccessfulAt,
refreshError: true,
sourceError: true,
state: "loaded",
}),
);
@@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type * as BrokerageEvents from "@/entities/brokerage-event";
import { useBrokerageEvents } from "@/entities/brokerage-event";
import { defaultPeriod } from "@/shared/lib";
import { AccountEventsPage } from "./AccountEventsPage";
@@ -50,8 +51,9 @@ describe("AccountEventsPage", () => {
isError: false,
isLoading: false,
meta: {
availability: "complete",
sync: { errorCode: null, id: null, state: "idle" },
availability: "available",
completeness: "complete",
lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" },
},
refetch: vi.fn(),
} as never,
@@ -71,7 +73,10 @@ describe("AccountEventsPage", () => {
</MemoryRouter>
</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", () => {
@@ -81,14 +86,18 @@ describe("AccountEventsPage", () => {
isError: false,
isLoading: false,
meta: {
availability: "complete",
sync: { errorCode: null, id: null, state: "idle" },
availability: "available",
completeness: "complete",
lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" },
},
refetch: vi.fn(),
} as never,
);
renderPage();
expect(query).toHaveBeenCalledWith("", {});
expect(query).toHaveBeenCalledWith("", {
from: defaultPeriod()[0],
to: defaultPeriod()[1],
});
expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute(
"aria-busy",
"true",
@@ -131,8 +140,9 @@ describe("AccountEventsPage", () => {
isError: false,
isLoading: false,
meta: {
availability: "complete",
sync: { errorCode: null, id: null, state: "idle" },
availability: "available",
completeness: "complete",
lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" },
},
refetch: vi.fn(),
} as never,
@@ -159,8 +169,7 @@ describe("AccountEventsPage", () => {
expect(screen.getAllByText("O")).toHaveLength(2);
});
it("renders empty and retryable error states", () => {
const refetch = vi.fn();
it("renders empty and quiet error states", async () => {
const { rerender } = renderPage();
expect(
screen.getByText("За выбранный период событий не найдено"),
@@ -169,7 +178,6 @@ describe("AccountEventsPage", () => {
data: undefined,
isError: true,
isLoading: false,
refetch,
} as never);
rerender(
<MantineProvider>
@@ -178,8 +186,8 @@ describe("AccountEventsPage", () => {
</MemoryRouter>
</MantineProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
expect(refresh).toHaveBeenCalledOnce();
expect(await screen.findByRole("button", { name: "Сбросить" })).toBeVisible();
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
});
it("shows a structural busy state before events are available", () => {
@@ -189,8 +197,9 @@ describe("AccountEventsPage", () => {
isError: false,
isLoading: true,
meta: {
availability: "complete",
sync: { errorCode: null, id: null, state: "idle" },
availability: "available",
completeness: "complete",
lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" },
},
refetch: vi.fn(),
} as never,
@@ -1,30 +1,36 @@
import { useState } from "react";
import { useParams } from "react-router";
import type { InvestmentResourceMeta } from "@/shared/api";
import {
type BrokerageEvent,
useBrokerageEvents,
} from "@/entities/brokerage-event";
import { useScreenSync } from "@/features/tbank-connection";
import { isValidPeriod, useUrlPeriod } from "@/shared/lib";
import {
defaultPeriod,
isValidPeriod,
moscowDate,
useInitialAvailability,
useUrlPeriod,
} from "@/shared/lib";
type AccountEventsPageViewModel
= | {
canApply: boolean;
draftPeriod: [null | string, null | string];
events: BrokerageEvent[];
lastSuccessfulAt?: null | string;
maxDate: string;
onApply: () => void;
onPeriodChange: (value: [null | string, null | string]) => void;
onPeriodPopoverOpen: () => void;
onReset: () => void;
partial?: boolean;
refreshError: boolean;
refreshing: boolean;
retry: () => void;
sourceError?: boolean;
source?: InvestmentResourceMeta;
state: "loaded";
}
| { onReset: () => void; retry: () => void; state: "error" }
| { onReset: () => void; state: "error" }
| { state: "loading" };
function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
@@ -43,6 +49,8 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
const [draftFrom, draftTo] = draftPeriod;
const [maxDate, setMaxDate] = useState(() => moscowDate(new Date()));
const scope = JSON.stringify([accountId, appliedFrom, appliedTo]);
const [previousScope, setPreviousScope] = useState(scope);
@@ -56,7 +64,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
...(appliedTo && { to: appliedTo }),
});
const sync = useScreenSync(
useScreenSync(
{
accountId,
screen: "events",
@@ -66,22 +74,30 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
[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 = () => {
setDraftPeriod([null, null]);
setDraftPeriod([appliedFrom, appliedTo]);
setSearchParameters({});
};
if ((events.isError || events.meta?.sync.state === "failed") && !events.data)
if ((events.isError || events.meta?.lastAttempt.state === "failed") && !events.data)
return {
onReset,
retry: () => {
void sync.retry();
},
state: "error",
};
if (!events.data) return { state: "loading" };
if (!events.data)
return availability === "unavailable"
? { onReset, state: "error" }
: { state: "loading" };
return {
canApply:
@@ -89,7 +105,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
&& isValidPeriod(draftFrom, draftTo),
draftPeriod,
events: events.data.events,
lastSuccessfulAt: events.meta?.lastSuccessfulAt,
maxDate,
onApply: () => {
if (!isValidPeriod(draftFrom, draftTo)) return;
setSearchParameters({
@@ -97,15 +113,13 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
...(draftTo && { to: draftTo }),
});
},
onPeriodChange: setDraftPeriod,
onPeriodChange: value => setDraftPeriod(
value[0] === null && value[1] === null ? defaultPeriod() : value,
),
onPeriodPopoverOpen: () => setMaxDate(moscowDate(new Date())),
onReset,
partial: events.meta?.availability !== "complete",
refreshError: events.isError || sync.sourceError || sync.transportError,
refreshing: events.isFetching || sync.refreshing,
retry: () => {
void sync.retry();
},
sourceError: sync.sourceError,
partial: events.meta?.completeness === "incomplete",
source: events.meta,
state: "loaded",
};
}
@@ -4,7 +4,7 @@ import { Button, Group } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
type AccountEventsPageErrorProps = { onReset: () => void; retry: () => void };
type AccountEventsPageErrorProps = { onReset: () => void };
const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
const { t } = useTranslation();
@@ -13,8 +13,6 @@ const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
<StateView
action={(
<Group>
<Button onClick={props.retry}>{t("events.retry")}</Button>
<Button onClick={props.onReset} variant="default">
{t("ledger.reset")}
</Button>
@@ -4,7 +4,6 @@ import {
Alert,
Button,
Group,
Progress,
Stack,
Text,
Title,
@@ -55,32 +54,16 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
return (
<Stack gap="lg">
<SourceSyncStatus lastSuccessfulAt={props.lastSuccessfulAt} />
<Title order={1}>{t("events.title")}</Title>
<Button disabled={props.refreshing} onClick={props.retry} w="fit-content">
{t("investment.refresh")}
</Button>
<SourceSyncStatus
lastAttempt={props.source?.lastAttempt}
lastSuccessfulAt={props.source?.lastSuccessfulAt}
stale={props.source?.freshness === "stale"}
/>
{!!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>
<Group align="end">
@@ -89,9 +72,11 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
clearable
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
label={t("ledger.period")}
maxDate={props.maxDate}
onChange={props.onPeriodChange}
popoverProps={{
middlewares: { shift: { crossAxis: true } },
onOpen: props.onPeriodPopoverOpen,
returnFocus: true,
}}
type="range"
+6 -70
View File
@@ -44,71 +44,6 @@ function summary(currency: string, coupon: string, dividend: string) {
};
}
describe("income membership validation", () => {
const entry: IncomeAnalytics["entries"][number] = {
accruedInterest: null,
category: "income",
commission: null,
description: "Купон",
figi: "FIGI",
id: "coupon-1",
instrumentKey: "income-group",
instrumentType: "bond",
instrumentUid: null,
logoName: null,
occurredAt: "2026-08-01T12:00:00Z",
payment: money("RUB", "10"),
price: null,
quantity: null,
ticker: "BOND",
type: "OPERATION_TYPE_COUPON",
};
const instrument: IncomeAnalytics["instruments"][number] = {
...summary("RUB", "10", "0"),
displayName: "BOND",
figi: "FIGI",
instrumentType: "bond",
instrumentUid: null,
key: entry.instrumentKey,
receiptCount: 1,
ticker: "BOND",
};
const valid: IncomeAnalytics = {
...analytics,
entries: [entry],
instruments: [instrument],
totalsByCurrency: [summary("RUB", "10", "0")],
};
it.each([
["missing key", undefined],
["empty key", ""],
["whitespace key", " "],
["non-string key", 12],
["unknown key", "not-in-breakdown"],
])("rejects an entry with %s", (_name, instrumentKey) => {
// Deliberately malformed API data exercises the runtime validation boundary.
const response = {
...valid,
entries: [{ ...entry, instrumentKey }],
} as unknown as IncomeAnalytics;
expect(toIncomeDisplayModel(response)).toBeNull();
});
it.each([
["duplicate instrument keys", { instruments: [instrument, instrument] }],
["currency mismatch", { entries: [{ ...entry, payment: money("USD", "10") }] }],
["receipt count mismatch", { instruments: [{ ...instrument, receiptCount: 2 }] }],
["missing entries", { entries: [] }],
["empty instrument key", { instruments: [{ ...instrument, key: "" }] }],
])("rejects %s", (_name, invalid) => {
expect(toIncomeDisplayModel({ ...valid, ...invalid })).toBeNull();
});
});
describe("income comparison display", () => {
it.each([
["25", "100", "+25 %"],
@@ -201,13 +136,14 @@ it("maps comparison notices by role regardless of dependency order or resource k
const previous
= {
...resource(null).meta,
resourceKey: "opaque/year-looking-key",
role: "previous" as const,
sync: {
errorCode: "TBANK_UNAVAILABLE",
id: null,
lastAttempt: {
finishedAt: "2026-08-14T00:01:00Z",
problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed" as const,
},
resourceKey: "opaque/year-looking-key",
role: "previous" as const,
};
const year = {
+2 -42
View File
@@ -66,7 +66,7 @@ function comparisonNotice(
): ComparisonNotice {
if (!dependency || dependency.unavailableReason) return unavailable;
return dependency.sync.state === "failed"
return dependency.lastAttempt.state === "failed"
? "comparisonFailed"
: "comparisonPending";
}
@@ -97,49 +97,14 @@ function formatIncomeChange(
function toIncomeDisplayModel(
analytics: IncomeAnalytics,
meta?: IncomeAnalyticsMeta,
): IncomeDisplayModel | null {
const instruments = new Map<
string,
{
currency: string;
includedCount: number;
receiptCount: number;
}
>();
const currencies = new Set(
analytics.totalsByCurrency.map(({ currency }) => currency),
);
for (const instrument of analytics.instruments) {
if (
typeof instrument.key !== "string"
|| !instrument.key.trim()
|| instruments.has(instrument.key)
|| !currencies.has(instrument.total.currency)
)
return null;
instruments.set(instrument.key, {
currency: instrument.total.currency,
includedCount: 0,
receiptCount: instrument.receiptCount,
});
}
): IncomeDisplayModel {
const entries: IncomeDisplayModel["entries"] = [];
const occurrences = new Map<string, number>();
for (const entry of analytics.entries) {
if (typeof entry.instrumentKey !== "string" || !entry.instrumentKey.trim())
return null;
const instrument = instruments.get(entry.instrumentKey);
if (!instrument || entry.payment?.currency !== instrument.currency)
return null;
const occurrenceKey = JSON.stringify([entry.instrumentKey, entry.id]);
const occurrence = occurrences.get(occurrenceKey) ?? 0;
occurrences.set(occurrenceKey, occurrence + 1);
instrument.includedCount += 1;
entries.push({
key: JSON.stringify([
entry.instrumentKey,
@@ -151,11 +116,6 @@ function toIncomeDisplayModel(
});
}
for (const instrument of instruments.values()) {
if (instrument.includedCount !== instrument.receiptCount)
return null;
}
const dependencies = meta?.dependencies ?? [];
const previous = dependencies.find(({ role }) => role === "previous");
const year = dependencies.find(({ role }) => role === "year");
@@ -195,7 +195,6 @@ describe("AccountIncomePage", () => {
it("keeps income calendar dates in GET and source sync requests", async () => {
const requested: string[] = [];
const syncRequests: unknown[] = [];
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/income-analytics",
@@ -206,9 +205,10 @@ describe("AccountIncomePage", () => {
resource(
null,
{
sync: {
errorCode: "TBANK_UNAVAILABLE",
id: null,
lastAttempt: {
finishedAt: "2026-08-14T00:01:00Z",
problems: [],
startedAt: "2026-08-14T00:00:00Z",
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");
const retry = await screen.findByRole("button", { name: "Повторить" });
await screen.findByText("Не удалось рассчитать пассивный доход");
expect(requested).toEqual(["?from=2024-02-29&to=2024-03-01"]);
expect(syncRequests).toEqual([]);
fireEvent.click(retry);
await waitFor(() =>
expect(syncRequests).toEqual([
{
accountId: "account-1",
from: "2024-02-29",
screen: "income",
to: "2024-03-01",
},
]),
);
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
});
it("requests a multi-year period from the URL without resetting it", async () => {
@@ -324,32 +305,6 @@ describe("AccountIncomePage", () => {
expect(within(panel).getAllByRole("listitem")).toHaveLength(2);
});
it("rejects inconsistent analytics and recovers through retry", async () => {
let response = {
...analytics,
entries: [
{ ...analytics.entries[0], instrumentKey: "not-in-breakdown" },
analytics.entries[1],
],
};
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(resource(response)),
),
);
renderPage();
const retry = await screen.findByRole("button", { name: "Повторить" });
expect(
screen.queryByRole("region", { name: "Выплаты" }),
).not.toBeInTheDocument();
response = analytics;
fireEvent.click(retry);
expect(
await screen.findByRole("region", { name: "Выплаты" }),
).toBeInTheDocument();
});
it("accepts an empty analytics response", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
@@ -512,7 +467,7 @@ describe("AccountIncomePage", () => {
).toBeVisible();
});
it("shows a recoverable realized error", async () => {
it("shows a quiet realized error", async () => {
server.use(
http.get(
"*/api/v1/brokerage-accounts/account-1/income-analytics",
@@ -521,8 +476,11 @@ describe("AccountIncomePage", () => {
);
renderPage();
expect(
await screen.findByRole("button", { name: "Повторить" }),
await screen.findByText("Не удалось рассчитать пассивный доход", undefined, {
timeout: 4000,
}),
).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
});
});
@@ -657,7 +615,7 @@ describe("income period and snapshot boundary", () => {
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([]);
const { queryClient } = renderPage();
await screen.findByLabelText("Период");
@@ -679,53 +637,13 @@ describe("income period and snapshot boundary", () => {
act(() => {
void queryClient.invalidateQueries();
});
expect(
await screen.findByRole("status", { name: "Обновляем пассивный доход" }),
).toBeInTheDocument();
expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible();
expect(screen.getByLabelText("Период")).toHaveTextContent(
"02.08.2026 – 27.08.2026",
);
release();
expect(
await screen.findByText(/Показан последний загруженный результат/),
).toBeInTheDocument();
expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible();
server.use(
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",
progress: { completed: 1, discovering: false, total: 1 },
resources: [resource(null, { stateVersion: "2" }).meta],
scope: null,
state: "succeeded",
},
),
),
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(resource(analytics, { stateVersion: "2" })),
),
);
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
await waitFor(() =>
expect(
screen.queryByText(/Показан последний загруженный результат/),
).not.toBeInTheDocument(),
);
expect(screen.queryByText(/Показан последний загруженный результат/)).not.toBeInTheDocument();
expect(screen.getByLabelText("Период")).toHaveTextContent(
"02.08.2026 – 27.08.2026",
);
@@ -915,12 +833,13 @@ it("keeps complete previous comparison while year source failed without inventin
},
{
...resource(null).meta,
role: "year" as const,
sync: {
errorCode: "TBANK_UNAVAILABLE",
id: null,
lastAttempt: {
finishedAt: "2026-08-14T00:01:00Z",
problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed" as const,
},
role: "year" as const,
},
{
...resource(analytics).meta,
@@ -5,7 +5,7 @@ import { useParams, useSearchParams } from "react-router";
import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
import { useScreenSync } from "@/features/tbank-connection";
import { isValidPeriod, moscowDate } from "@/shared/lib";
import { isValidPeriod, moscowDate, useInitialAvailability } from "@/shared/lib";
import {
type IncomeDisplayModel,
@@ -27,19 +27,13 @@ type AccountIncomePageViewModel
display: IncomeDisplayModel;
form: ReturnType<typeof useForm<IncomeForm>>;
formRevision: string;
lastSuccessfulAt?: null | string;
onReset: () => void;
onSubmit: (values: IncomeForm) => void;
partial?: boolean;
refreshError: boolean;
refreshing: boolean;
retry: () => Promise<unknown> | undefined;
sourceError?: boolean;
state: "loaded";
}
| {
period: IncomePeriod;
retry: () => Promise<unknown> | undefined;
state: "error";
}
| { state: "loading" };
@@ -69,7 +63,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
const [resetRevision, setResetRevision] = useState(0);
const analytics = useIncomeAnalytics(accountId, period);
const sync = useScreenSync(
useScreenSync(
{ accountId, from: period.from, screen: "income", to: period.to },
[analytics],
);
@@ -82,19 +76,22 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
form.reset(draftPeriod(period));
}, [accountId, form, period]);
if (analytics.isLoading || !analytics.data) {
return analytics.isError || analytics.meta?.sync.state === "failed"
? { period, retry: sync.retry, state: "error" }
const availability = useInitialAvailability({
hasContent: !!analytics.data,
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" };
}
const display = toIncomeDisplayModel(analytics.data, analytics.meta);
if (!display)
return {
period,
retry: () => analytics.observe(),
state: "error",
};
const onSubmit = (values: IncomeForm) => {
const from = parseDraftDate(values.from);
@@ -115,14 +112,9 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
display,
form,
formRevision: `${accountId}:${period.from}:${period.to}:${resetRevision}`,
lastSuccessfulAt: analytics.meta?.lastSuccessfulAt,
onReset,
onSubmit,
partial: analytics.meta?.availability !== "complete",
refreshError: analytics.isError || sync.sourceError || sync.transportError,
refreshing: analytics.isFetching || sync.refreshing,
retry: sync.retry,
sourceError: sync.sourceError,
partial: analytics.meta?.completeness === "incomplete",
state: "loaded",
};
};
@@ -1,6 +1,5 @@
import type { FC } from "react";
import { Button } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
@@ -8,12 +7,11 @@ import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
type ErrorProps = Extract<AccountIncomePageViewModel, { state: "error" }>;
const AccountIncomePageError: FC<ErrorProps> = (props) => {
const AccountIncomePageError: FC<ErrorProps> = () => {
const { t } = useTranslation();
return (
<StateView
action={<Button onClick={props.retry}>{t("income.retry")}</Button>}
error
text={t("income.errorText")}
title={t("income.errorTitle")}
@@ -2,19 +2,13 @@ import type { FC } from "react";
import {
Alert,
Button,
Group,
Loader,
Stack,
Text,
Title,
} from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import { FormProvider } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { SourceSyncStatus } from "@/shared/ui";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
import classes from "../AccountIncomePage.module.css";
@@ -40,21 +34,14 @@ const AccountIncomePageLoadedSummary: FC<LoadedProps> = (props) => {
};
const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
const { display, form, onReset, onSubmit, refreshError, refreshing, retry }
= props;
const { display, form, onReset, onSubmit } = props;
const { t } = useTranslation();
return (
<Stack className={classes.page} gap="lg">
<SourceSyncStatus lastSuccessfulAt={props.lastSuccessfulAt} />
<Title order={1}>{t("income.title")}</Title>
<Button disabled={refreshing} onClick={retry} w="fit-content">
{t("investment.refresh")}
</Button>
<FormProvider {...form}>
<IncomePeriodForm
key={props.formRevision}
@@ -63,32 +50,6 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
/>
</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} />
<Alert color="blue">{t("income.taxBoundary")}</Alert>
@@ -18,6 +18,7 @@ import {
LedgerEntryRow,
} from "@/entities/broker-operation";
import { configureAuthClient } from "@/shared/api";
import { defaultPeriod } from "@/shared/lib";
import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
@@ -312,18 +313,20 @@ describe("AccountOperationsPage", () => {
"allows resetting filters after a request failure for %s",
async (accountId) => {
configureTestAuth();
const [defaultFrom] = defaultPeriod();
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/operations",
({ request }) =>
new URL(request.url).searchParams.has("from")
? HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 })
: HttpResponse.json(
new URL(request.url).searchParams.get("from")
=== `${defaultFrom}T00:00:00.000Z`
? HttpResponse.json(
resource(
{ hasNext: false, items: [], nextCursor: null },
),
),
)
: HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 }),
),
);
render(
@@ -346,7 +349,7 @@ describe("AccountOperationsPage", () => {
</MantineProvider>
</QueryClientProvider>,
);
await screen.findByRole("button", { name: "Повторить" });
await screen.findByRole("button", { name: "Сбросить" });
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
await screen.findByLabelText("Период");
expect(screen.getByLabelText("URL")).toBeEmptyDOMElement();
@@ -484,7 +487,9 @@ describe("AccountOperationsPage", () => {
);
renderPage();
expect(
await screen.findByText("Не удалось загрузить журнал"),
await screen.findByText("Не удалось загрузить журнал", undefined, {
timeout: 4000,
}),
).toBeInTheDocument();
});
});
@@ -492,7 +497,6 @@ describe("AccountOperationsPage", () => {
it("keeps UTC operation boundaries in GET and source sync requests", async () => {
configureTestAuth();
const requested: Record<string, string>[] = [];
const syncRequests: unknown[] = [];
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/operations",
@@ -503,9 +507,10 @@ it("keeps UTC operation boundaries in GET and source sync requests", async () =>
resource(
null,
{
sync: {
errorCode: "TBANK_UNAVAILABLE",
id: null,
lastAttempt: {
finishedAt: "2026-08-14T00:01:00Z",
problems: [],
startedAt: "2026-08-14T00:00:00Z",
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");
const retry = await screen.findByRole("button", { name: "Повторить" });
await screen.findByRole("button", { name: "Сбросить" });
expect(requested).toEqual([
{
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",
},
]);
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 () => {
@@ -558,7 +544,7 @@ it("does not label incomplete empty operations as a confirmed empty period", asy
items: [],
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");
fireEvent.click(await screen.findByRole("button", { name: "Далее" }));
fireEvent.click(await screen.findByRole("button", { name: "Повторить" }));
await waitFor(() => expect(searches).toHaveLength(3));
assert.isDefined(searches[0]);
assert.isDefined(searches[2]);
@@ -2,6 +2,8 @@ import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useParams } from "react-router";
import type { InvestmentResourceMeta } from "@/shared/api";
import {
type BrokerageOperation,
brokerageOperationsQueryOptions,
@@ -11,7 +13,13 @@ import {
} from "@/entities/broker-operation";
import { useScreenSync } from "@/features/tbank-connection";
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);
@@ -24,21 +32,19 @@ type AccountOperationsPageViewModel
hasPrevious: boolean;
historyLoading?: boolean;
items: BrokerageOperation[];
lastSuccessfulAt?: null | string;
maxDate: string;
onApply: () => void;
onCategoriesChange: (value: string[]) => void;
onNext: () => void;
onPeriodChange: (value: [null | string, null | string]) => void;
onPeriodPopoverOpen: () => void;
onPrevious: () => void;
onReset: () => void;
partial?: boolean;
refreshError?: boolean;
refreshing?: boolean;
retry?: () => void;
sourceError?: boolean;
source?: InvestmentResourceMeta;
state: "loaded";
}
| { onReset: () => void; retry: () => void; state: "error" }
| { onReset: () => void; state: "error" }
| { state: "loading" };
const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
@@ -66,6 +72,8 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
const [draftCategories, setDraftCategories]
= useState<OperationCategory[]>(appliedCategories);
const [maxDate, setMaxDate] = useState(() => moscowDate(new Date()));
const firstPageQuery = {
from: dateStart(appliedFrom),
to: dateEnd(appliedTo),
@@ -85,37 +93,52 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
history.reset();
}
const sync = useScreenSync(
useScreenSync(
{ accountId, screen: "operations", ...firstPageQuery },
[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, {
limit: 100,
}).queryKey;
const retry = () => history.retry(sync.retry);
const onReset = () => {
setDraftPeriod([null, null]);
setDraftPeriod([appliedFrom, appliedTo]);
setDraftCategories([]);
setSearchParameters({});
history.reset();
void queryClient.invalidateQueries({ queryKey: resetQueryKey });
};
const isInvalidCursor = isApiErrorCode(
apiErrorCode(operations.error),
ApiErrorCode.InvalidOperationsCursor,
);
if (
(operations.isError || operations.meta?.sync.state === "failed")
&& (!operations.data
|| isApiErrorCode(
apiErrorCode(operations.error),
ApiErrorCode.InvalidOperationsCursor,
))
(isInvalidCursor || !operations.data)
&& (operations.isError || operations.meta?.lastAttempt.state === "failed")
)
return { onReset, retry, state: "error" };
if (!operations.data) return { state: "loading" };
return { onReset, state: "error" };
if (!operations.data)
return availability === "unavailable"
? { onReset, state: "error" }
: { state: "loading" };
const nextCursor = operations.data.nextCursor;
const isDirty
@@ -133,7 +156,7 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
operations.meta?.coverage?.historyState === "loading"
|| operations.meta?.coverage?.historyState === "partial",
items: operations.data.items,
lastSuccessfulAt: operations.meta?.lastSuccessfulAt,
maxDate,
onApply: () => {
if (!isValidPeriod(draftFrom, draftTo)) return;
setSearchParameters({
@@ -151,14 +174,14 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
),
),
onNext: history.onNext,
onPeriodChange: setDraftPeriod,
onPeriodChange: value => setDraftPeriod(
value[0] === null && value[1] === null ? defaultPeriod() : value,
),
onPeriodPopoverOpen: () => setMaxDate(moscowDate(new Date())),
onPrevious: history.onPrevious,
onReset,
partial: operations.meta?.availability !== "complete",
refreshError: operations.isError || sync.sourceError || sync.transportError,
refreshing: operations.isFetching || sync.refreshing,
retry,
sourceError: sync.sourceError,
partial: operations.meta?.completeness === "incomplete",
source: operations.meta,
state: "loaded",
};
};
@@ -1,6 +1,6 @@
import type { FC } from "react";
import { Button, Group } from "@mantine/core";
import { Button } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
@@ -14,21 +14,15 @@ type AccountOperationsPageErrorProps = Extract<
const AccountOperationsPageError: FC<AccountOperationsPageErrorProps> = (
props,
) => {
const { retry } = props;
const { t } = useTranslation();
return (
<StateView
action={(
<Group>
<Button onClick={retry}>{t("ledger.retry")}</Button>
<Button onClick={props.onReset} variant="default">
{t("ledger.reset")}
</Button>
</Group>
<Button onClick={props.onReset} variant="default">
{t("ledger.reset")}
</Button>
)}
error
text={t("ledger.errorText")}
title={t("ledger.errorTitle")}
/>
@@ -5,7 +5,6 @@ import {
Button,
Group,
MultiSelect,
Progress,
Stack,
Title,
} from "@mantine/core";
@@ -62,43 +61,29 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
return (
<Stack gap="lg">
<SourceSyncStatus lastSuccessfulAt={props.lastSuccessfulAt} />
<Title order={1}>{t("ledger.title")}</Title>
<Button disabled={props.refreshing} onClick={props.retry} w="fit-content">
{t("investment.refresh")}
</Button>
<SourceSyncStatus
lastAttempt={props.source?.lastAttempt}
lastSuccessfulAt={props.source?.lastSuccessfulAt}
stale={props.source?.freshness === "stale"}
/>
{!!props.partial && <Alert role="status">{t("investment.partial")}</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">
<DatePickerInput
allowSingleDateInRange
clearable
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
label={t("ledger.period")}
maxDate={props.maxDate}
onChange={props.onPeriodChange}
popoverProps={{
middlewares: { shift: { crossAxis: true } },
onOpen: props.onPeriodPopoverOpen,
returnFocus: true,
}}
type="range"
+21 -29
View File
@@ -10,20 +10,18 @@ const RECENT_EVENTS_LIMIT = 5;
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"
| "loading"
| "partial";
to: string;
};
}
| { state: "error"; to: string }
| {
state: "loaded";
summary: IncomeSummary;
to: string;
}
;
type AccountPageRecentEvents = BrokerageEventsPreviewState;
@@ -48,29 +46,26 @@ type PreviewResource<T> = {
isError: boolean;
meta?: Pick<InvestmentResourceMeta,
"availability"
| "completeness"
| "freshness"
| "lastAttempt"
| "lastSuccessfulAt"
| "sync"
>;
};
function toIncomeSummary(
accountId: string | undefined,
income: PreviewResource<IncomeSummary>,
retry: () => Promise<unknown>,
): AccountPageIncomeSummary {
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 };
if (income.meta?.availability === "partial") return { state: "partial", to };
if (!income.data && (income.isError || income.meta?.sync.state === "failed"))
return { retry, state: "error", to };
if (income.meta?.completeness === "incomplete") return { state: "partial", to };
if (!income.data && (income.isError || income.meta?.lastAttempt.state === "failed"))
return { state: "error", to };
if (!income.data?.totalsByCurrency.length) return { state: "empty", to };
return {
lastSuccessfulAt: income.meta?.lastSuccessfulAt,
refreshError: income.isError || income.meta?.sync.state === "failed",
stale: income.meta?.freshness === "stale",
state: "loaded",
summary: income.data,
to,
@@ -82,24 +77,21 @@ function toRecentEvents(
events: PreviewResource<{ events: BrokerageEvent[] }>,
): AccountPageRecentEvents {
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 };
if (events.meta?.availability === "partial")
if (events.meta?.completeness === "incomplete")
return {
items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [],
state: "partial",
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 };
const items = events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [];
if (items.length === 0) return { state: "empty", to };
return {
items,
lastSuccessfulAt: events.meta?.lastSuccessfulAt,
refreshError: events.isError || events.meta?.sync.state === "failed",
stale: events.meta?.freshness === "stale",
state: "loaded",
to,
};
@@ -113,14 +105,14 @@ function toRecentOperations(
if (
!operations.data
&& !operations.isError
&& operations.meta?.sync.state !== "failed"
&& operations.meta?.lastAttempt.state !== "failed"
)
return { state: "loading", to };
if (operations.meta?.availability === "partial")
if (operations.meta?.completeness === "incomplete")
return { items: operations.data?.items ?? [], state: "partial", to };
if (
!operations.data
&& (operations.isError || operations.meta?.sync.state === "failed")
&& (operations.isError || operations.meta?.lastAttempt.state === "failed")
)
return { state: "error", to };
if (!operations.data?.items.length) return { state: "empty", to };
@@ -129,7 +121,7 @@ function toRecentOperations(
items: operations.data.items,
lastSuccessfulAt: operations.meta?.lastSuccessfulAt,
refreshError:
operations.isError || operations.meta?.sync.state === "failed",
operations.isError || operations.meta?.lastAttempt.state === "failed",
stale: operations.meta?.freshness === "stale",
state: "loaded",
to,
@@ -1,7 +1,6 @@
import { MantineProvider } from "@mantine/core";
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
fireEvent,
render,
screen,
waitFor,
@@ -261,7 +260,7 @@ describe("AccountPage states", () => {
it.each(["NOT_FOUND", "TBANK_NOT_CONNECTED", "INVALID_TBANK_TOKEN"])(
"does not offer retry without cached data for %s",
(code) => {
renderPage(<AccountPageError code={code} retry={vi.fn()} />);
renderPage(<AccountPageError code={code} />);
expect(
screen.queryByRole("button", { name: "Повторить" }),
).not.toBeInTheDocument();
@@ -274,12 +273,16 @@ describe("AccountPage states", () => {
},
);
it.each(["TBANK_UNAVAILABLE", "UNKNOWN"])("allows retry for %s", (code) => {
const retry = vi.fn();
renderPage(<AccountPageError code={code} retry={retry} />);
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
expect(retry).toHaveBeenCalledOnce();
});
it.each(["TBANK_UNAVAILABLE", "UNKNOWN"])(
"shows neutral unavailable without retry for %s",
(code) => {
renderPage(<AccountPageError code={code} />);
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", () => {
renderPage(
@@ -405,11 +408,9 @@ describe("AccountPage states", () => {
expect(
screen.getByRole("heading", { level: 1, name: "Портфель" }),
).toBeInTheDocument();
expect(
screen.getByText(
"T-Bank временно недоступен. Показан последний успешный снимок.",
),
).toBeInTheDocument();
expect(screen.queryByText(/T-Bank временно недоступен/))
.not.toBeInTheDocument();
expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Акции" })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Облигации" }),
@@ -573,7 +574,7 @@ describe("AccountPage states", () => {
);
expect(
screen.getByText("Не удалось загрузить последние события."),
screen.getByText("Пока недоступно"),
).toBeInTheDocument();
expect(screen.getByText("Вторая")).toBeInTheDocument();
});
@@ -742,13 +743,13 @@ describe("AccountPage states", () => {
);
expect(
screen.getByText("Не удалось загрузить будущие события."),
screen.getByText("Данные появятся здесь, когда чтение восстановится."),
).toBeInTheDocument();
expect(screen.getByText("Вторая")).toBeInTheDocument();
});
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) => {
const initialPortfolio: Portfolio
= {
@@ -769,33 +770,6 @@ describe("AccountPage states", () => {
let requestCount = 0;
let resolveRetry: ((response: Response) => void) | undefined;
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",
progress: { completed: 1, discovering: false, total: 1 },
resources: [
resource(null, { resourceKey: "portfolio", stateVersion: "2" })
.meta,
],
scope: { accountId: "account-1", screen: "account" },
state: "succeeded",
},
),
),
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
requestCount += 1;
if (requestCount === 1)
@@ -826,40 +800,30 @@ describe("AccountPage states", () => {
});
expect(structure).toHaveTextContent("10 ₽");
const snapshotText = screen.getByText(/Данные получены/).textContent;
expect(snapshotText).toContain("10:00");
expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
void queryClient.invalidateQueries({
queryKey: portfolioQueryOptions("account-1").queryKey,
});
expect(
await screen.findByText(
"Не удалось обновить портфель. Показан последний полученный снимок.",
),
).toBeInTheDocument();
expect(
screen.getByText(
"T-Bank временно недоступен. Показан последний успешный снимок.",
),
).toBeInTheDocument();
await waitFor(() => expect(requestCount).toBe(2));
expect(screen.queryByText(/Не удалось обновить портфель/))
.not.toBeInTheDocument();
expect(screen.queryByText(/T-Bank временно недоступен/))
.not.toBeInTheDocument();
expect(screen.getByText("Вторая")).toBeInTheDocument();
expect(
screen.getAllByRole("definition").map(node => node.textContent),
).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,
);
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
expect(screen.queryByRole("button", { name: "Повторить" }))
.not.toBeInTheDocument();
void queryClient.invalidateQueries({
queryKey: portfolioQueryOptions("account-1").queryKey,
});
await waitFor(() => expect(requestCount).toBe(3));
expect(structure).toHaveTextContent("10 ₽");
expect(
screen.getAllByRole("definition").map(node => node.textContent),
).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?.(
HttpResponse.json(
resource(
@@ -871,7 +835,7 @@ describe("AccountPage states", () => {
structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency: [{ currency: "USD", value: "-0.5" }],
},
{ resourceKey: "portfolio", stateVersion: "2" },
{ dataRevision: "2", resourceKey: "portfolio" },
),
),
);
@@ -880,7 +844,7 @@ describe("AccountPage states", () => {
screen.getAllByRole("definition").map(node => node.textContent),
).toEqual(["-0,5 $", "2,50 €"]),
);
expect(screen.getByText(/Данные получены/)).toHaveTextContent("11:30");
expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
expect(structure).not.toHaveTextContent("10 ₽");
expect(structure).toHaveTextContent("Нет положительной стоимости");
expect(
@@ -921,13 +885,12 @@ describe("AccountPage states", () => {
expect(
screen.getAllByRole("definition").map(node => node.textContent),
).toEqual(["1 234,5 ₽", "0,5 $"]);
expect(screen.getByText(/Данные получены/)).toHaveTextContent("10:00");
expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
expect(
screen.queryByRole("status", { name: "Загрузка" }),
).not.toBeInTheDocument();
expect(
screen.getByRole("status", { name: "Обновляем портфель" }),
).toBeInTheDocument();
expect(screen.queryByRole("status", { name: "Обновляем портфель" }))
.not.toBeInTheDocument();
resolveRefresh?.(HttpResponse.json(resource(portfolio)));
});
@@ -18,10 +18,6 @@ const AccountPage: FC = () => {
incomeSummary={viewModel.incomeSummary}
recentEvents={viewModel.recentEvents}
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}
recentEvents={viewModel.recentEvents}
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 { 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 { MemoryRouter, Route, Routes } from "react-router";
import { describe, expect, it, vi } from "vitest";
@@ -147,10 +147,10 @@ describe("useAccountPageViewModel", () => {
HttpResponse.json(resource(portfolio)),
),
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", () =>
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();
server.use(
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", () =>
HttpResponse.json(
resource(
{ 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(
resource(
{ 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();
const sections: Record<string, unknown>
@@ -219,53 +219,17 @@ it("refreshes every overview section through one account descriptor and observat
portfolio,
};
const observed: string[] = [];
const scopes: unknown[] = [];
let version = "1";
server.use(
...Object.entries(sections).map(([path, data]) =>
http.get(
`*/api/v1/brokerage-accounts/:accountId/${path}`,
({ request }) => {
if (new URL(request.url).searchParams.get("sync") === "observe")
observed.push(path);
() => {
return HttpResponse.json(
resource(data, { resourceKey: path, stateVersion: version }),
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",
progress: { completed: 4, discovering: false, total: 4 },
resources: Object.keys(sections).map(
resourceKey =>
resource(null, { resourceKey, stateVersion: version }).meta,
),
scope: null,
state: "succeeded",
},
),
),
);
const { result } = renderHook(() => useAccountPageViewModel(), {
@@ -280,11 +244,5 @@ it("refreshes every overview section through one account descriptor and observat
state: "loaded",
}),
);
await act(async () => {
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" }]);
expect(result.current).not.toHaveProperty("retry");
});
@@ -1,6 +1,7 @@
import { useParams } from "react-router";
import type { Portfolio } from "@/entities/portfolio";
import type { InvestmentResourceMeta } from "@/shared/api";
import { useBrokerageOperations } from "@/entities/broker-operation";
import {
@@ -13,7 +14,8 @@ import {
import { useIncomeSummary } from "@/entities/income";
import { usePortfolio } from "@/entities/portfolio";
import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
import { useInitialAvailability } from "@/shared/lib";
import {
type AccountPagePositionSection,
@@ -35,10 +37,6 @@ type AccountPageContent = {
partial?: boolean;
recentEvents: AccountPageRecentEvents;
recentOperations: AccountPageRecentOperations;
refreshError: boolean;
refreshing: boolean;
retry: () => Promise<unknown> | undefined;
sourceError?: boolean;
};
type AccountPageEmptyViewModel = AccountPageContent & {
@@ -48,15 +46,23 @@ type AccountPageEmptyViewModel = AccountPageContent & {
type AccountPageLoadedViewModel = AccountPageContent & {
portfolio: Portfolio;
positionSections: AccountPagePositionSection[];
source?: InvestmentResourceMeta;
state: "loaded";
};
type AccountPageViewModel
= | AccountPageEmptyViewModel
| AccountPageLoadedViewModel
| { code?: string; retry: () => Promise<unknown> | undefined; state: "error" }
| { code?: string; state: "error" }
| { 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 {
const { accountId } = useParams();
const portfolio = usePortfolio(accountId);
@@ -69,7 +75,7 @@ function useAccountPageViewModel(): AccountPageViewModel {
const income = useIncomeSummary(accountId);
const accounts = useBrokerageAccounts();
const sync = useScreenSync({ accountId, screen: "account" }, [
useScreenSync({ accountId, screen: "account" }, [
accounts,
portfolio,
operations,
@@ -77,35 +83,31 @@ function useAccountPageViewModel(): AccountPageViewModel {
income,
]);
if (!portfolio.data) {
if (portfolio.isError || portfolio.meta?.sync.state === "failed")
return {
code: apiErrorCode(portfolio.error),
retry: sync.retry,
state: "error" as const,
};
const code = apiErrorCode(portfolio.error);
const isAccessFailure = isPortfolioAccessFailure(code);
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 recentOperations = toRecentOperations(accountId, operations);
const recentEvents = toRecentEvents(accountId, events);
const incomeSummary = toIncomeSummary(
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,
};
const incomeSummary = toIncomeSummary(accountId, income);
if (
portfolio.meta?.availability === "complete"
portfolio.meta?.availability === "available"
&& portfolio.data.positions.length === 0
&& portfolio.data.cashBalances.length === 0
)
@@ -116,19 +118,21 @@ function useAccountPageViewModel(): AccountPageViewModel {
recentEvents,
recentOperations,
state: "empty" as const,
...refresh,
};
const isPartial = portfolio.meta?.completeness === "incomplete";
return {
account,
accountId,
incomeSummary,
partial: isPartial,
portfolio: portfolio.data,
positionSections: toAccountPagePositionSections(portfolio.data),
recentEvents,
recentOperations,
source: portfolio.meta,
state: "loaded" as const,
...refresh,
};
}
@@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next";
import type { BrokerageAccount } from "@/entities/brokerage-account";
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
import { InvestmentRefreshState } from "@/features/tbank-connection";
import { PortfolioStructure } from "@/widgets/portfolio-structure";
import type {
AccountPageIncomeSummary,
@@ -17,7 +17,6 @@ import type {
import { AccountHeading } from "./AccountHeading.view";
import { IncomeSummary } from "./IncomeSummary.view";
import { PortfolioStructure } from "./PortfolioStructure/PortfolioStructure";
import { RecentOperations } from "./RecentOperations.view";
type AccountPageEmptyProps = {
@@ -26,10 +25,6 @@ type AccountPageEmptyProps = {
incomeSummary?: AccountPageIncomeSummary;
recentEvents: AccountPageRecentEvents;
recentOperations: AccountPageRecentOperations;
refreshError?: boolean;
refreshing?: boolean;
retry?: () => void;
sourceError?: boolean;
};
const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
@@ -45,14 +40,6 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
<Stack>
<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")} />
<PortfolioStructure
@@ -7,10 +7,10 @@ import { Link } from "react-router";
import { ApiErrorCode, isApiErrorCode } from "@/shared/api";
type AccountPageErrorProps = { code?: string; retry: () => void };
type AccountPageErrorProps = { code?: string };
const AccountPageError: FC<AccountPageErrorProps> = (props) => {
const { code, retry } = props;
const { code } = props;
const { t } = useTranslation();
if (isApiErrorCode(code, ApiErrorCode.NotFound))
return (
@@ -25,6 +25,7 @@ const AccountPageError: FC<AccountPageErrorProps> = (props) => {
title={t("portfolio.accountUnavailableTitle")}
/>
);
if (
isApiErrorCode(code, ApiErrorCode.TbankNotConnected)
|| isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)
@@ -41,22 +42,11 @@ const AccountPageError: FC<AccountPageErrorProps> = (props) => {
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 (
<StateView
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
error
text={t("states.errorText")}
title={t("states.errorTitle")}
text={t("states.unavailableText")}
title={t("states.unavailableTitle")}
/>
);
};
@@ -1,14 +1,15 @@
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 type { BrokerageAccount } from "@/entities/brokerage-account";
import type { Portfolio } from "@/entities/portfolio";
import type { InvestmentResourceMeta } from "@/shared/api";
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
import { InvestmentRefreshState } from "@/features/tbank-connection";
import { formatSnapshotTime } from "@/shared/lib";
import { SourceSyncStatus } from "@/shared/ui";
import { PortfolioStructure } from "@/widgets/portfolio-structure";
import type { AccountPagePositionSection } from "../../../model/positions";
import type {
@@ -21,7 +22,6 @@ import classes from "../AccountPage.module.css";
import { AccountHeading } from "./AccountHeading.view";
import { CurrencyCards } from "./CurrencyCards/CurrencyCards.view";
import { IncomeSummary } from "./IncomeSummary.view";
import { PortfolioStructure } from "./PortfolioStructure/PortfolioStructure";
import { PositionSection } from "./PositionSection.view";
import { RecentOperations } from "./RecentOperations.view";
@@ -34,10 +34,7 @@ type AccountPageLoadedProps = {
positionSections: AccountPagePositionSection[];
recentEvents: AccountPageRecentEvents;
recentOperations: AccountPageRecentOperations;
refreshError?: boolean;
refreshing?: boolean;
retry?: () => void;
sourceError?: boolean;
source?: InvestmentResourceMeta;
};
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
@@ -55,27 +52,12 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
<Stack gap="lg">
<AccountHeading account={props.account} accountId={props.accountId} />
<InvestmentRefreshState
labelScope="portfolio"
refreshError={props.refreshError ?? false}
refreshing={props.refreshing ?? false}
retry={props.retry}
sourceError={props.sourceError}
<SourceSyncStatus
lastAttempt={props.source?.lastAttempt}
lastSuccessfulAt={props.source?.lastSuccessfulAt}
stale={props.source?.freshness === "stale"}
/>
{!!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
? <Alert role="status">{t("investment.partial")}</Alert>
: <CurrencyCards amounts={props.portfolio.totalsByCurrency} />}
@@ -8,30 +8,30 @@ import { Link } from "react-router";
import type { IncomeSummary as IncomeSummaryData } from "@/entities/income";
import { formatMoney } from "@/shared/lib";
import { SourceSyncStatus } from "@/shared/ui";
type IncomeSummaryProps
= | {
lastSuccessfulAt?: null | string;
refreshError?: boolean;
stale?: boolean;
state: "empty";
to: string;
}
| { state: "error"; to: string }
| {
state: "loaded";
summary: IncomeSummaryData;
to: string;
}
| { retry: () => void; state: "error"; to: string }
| { state: "empty"; to: string }
| { state: "loading"; to: string }
| { state: "partial"; to: string };
| {
state: "loading";
to: string;
}
| {
state: "partial";
to: string;
};
const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
const { t } = useTranslation();
const errorText
= props.state === "loaded" && props.refreshError
? t("income.refreshError")
: null;
if (props.state === "loading" || props.state === "partial")
return (
<Text c="dimmed" role="status">
@@ -45,9 +45,6 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
if (props.state === "error")
return (
<StateView
action={
<Button onClick={props.retry}>{t("income.summaryRetry")}</Button>
}
error
text={t("income.errorText")}
title={t("income.summaryError")}
@@ -66,12 +63,6 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
return (
<Stack gap="xs">
<SourceSyncStatus
errorText={errorText}
lastSuccessfulAt={props.lastSuccessfulAt}
stale={props.stale}
/>
{props.summary.totalsByCurrency.map(total => (
<Paper key={total.currency} p="sm" withBorder>
<Text fw={700}>{formatMoney(total.total)}</Text>
@@ -1,12 +1,11 @@
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 { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { LedgerEntryRow } from "@/entities/broker-operation";
import { SourceSyncStatus } from "@/shared/ui";
import type { AccountPageRecentOperations } from "../../../model/previews";
@@ -18,28 +17,15 @@ const RecentOperations: FC<RecentOperationsProps> = (props) => {
const { t } = useTranslation();
const { operations } = props;
const errorText
= operations.state === "loaded" && operations.refreshError
? t("accounts.refreshError")
: null;
return (
<Stack gap="sm">
{operations.state === "loaded" && (
<SourceSyncStatus
errorText={errorText}
lastSuccessfulAt={operations.lastSuccessfulAt}
stale={operations.stale}
/>
)}
{operations.state === "loading" && (
<LoadingSkeleton label={t("ledger.recentLoading")}>
{["first", "second", "third"].map(placeholder => <Skeleton height={48} key={placeholder} radius="sm" />)}
</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>}
@@ -1,6 +1,6 @@
import { MantineProvider } from "@mantine/core";
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 { MemoryRouter } from "react-router";
import { describe, expect, it, vi } from "vitest";
@@ -58,10 +58,11 @@ describe("AccountsPage", () => {
}),
);
renderPage(createTestQueryClient());
fireEvent.click(await screen.findByRole("button", { name: "Повторить" }));
expect(await screen.findByText(account.name)).toBeVisible();
expect(await screen.findByText(account.name, undefined, {
timeout: 4000,
})).toBeVisible();
expect(modes).toEqual([null, "observe"]);
expect(modes).toEqual([null, null]);
});
it.each([
["loading", AccountsPageLoading, "status"],
@@ -81,17 +82,16 @@ describe("AccountsPage", () => {
).toBeInTheDocument();
});
it("routes connection errors to settings and keeps retry available", () => {
const retry = vi.fn();
it("routes connection errors to settings without technical retry", () => {
render(
<MantineProvider>
<MemoryRouter>
<AccountsPageError code="TBANK_UNAVAILABLE" retry={retry} />
<AccountsPageError code="TBANK_NOT_CONNECTED" />
</MemoryRouter>
</MantineProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
expect(retry).toHaveBeenCalledOnce();
expect(screen.queryByRole("button", { name: "Повторить" }))
.not.toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Открыть настройки T-Bank" }),
).toHaveAttribute("href", "/settings");
@@ -123,25 +123,13 @@ describe("AccountsPage", () => {
await waitFor(() => expect(requestCount).toBe(2));
expect(screen.getByText(account.name)).toBeInTheDocument();
expect(
screen.getByRole("status", { name: "Обновляем данные" }),
).toBeInTheDocument();
expect(screen.queryByRole("status", { name: "Обновляем данные" }))
.not.toBeInTheDocument();
resolveRefresh?.(HttpResponse.json(resource([account])));
});
it("keeps the cached account list and offers retry 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 },
);
}),
);
it("keeps the cached account list quietly after a refresh error", async () => {
let requestCount = 0;
server.use(
http.get("*/api/v1/brokerage-accounts", () => {
@@ -161,18 +149,15 @@ describe("AccountsPage", () => {
queryKey: brokerageAccountsQueryKey,
});
const warning = await screen.findByText(
"Не удалось обновить данные. Показаны последние полученные.",
);
expect(warning).toBeInTheDocument();
await waitFor(() => expect(requestCount).toBe(2));
expect(
screen.queryByText(
"Не удалось обновить данные. Показаны последние полученные.",
),
).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Повторить" }))
.not.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 () => {
@@ -194,23 +179,29 @@ describe("AccountsPage", () => {
queryKey: brokerageAccountsQueryKey,
});
await waitFor(() => expect(requestCount).toBe(2));
expect(
await screen.findByText(
screen.queryByText(
"Не удалось обновить данные. Показаны последние полученные.",
),
).toBeInTheDocument();
).not.toBeInTheDocument();
expect(screen.getByText("Нет инвестиционных счетов")).toBeInTheDocument();
});
});
it.each(["idle", "failed"] as const)(
"keeps partial empty membership distinct from no accounts (sync=%s)",
it.each(["none", "failed"] as const)(
"keeps incomplete empty membership distinct from no accounts (attempt=%s)",
async (state) => {
server.use(
http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json(resource([], {
availability: "partial",
sync: { errorCode: null, id: null, state },
completeness: "incomplete",
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(() =>
expect(client.getQueryData(brokerageAccountsQueryKey)).toMatchObject({
data: [],
meta: { availability: "partial" },
meta: { completeness: "incomplete" },
}),
);
expect(screen.queryByText("Нет инвестиционных счетов"))
.not.toBeInTheDocument();
if (state === "failed")
// 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();
else
// 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();
if (viewModel.state === "loading") return <AccountsPageLoading />;
if (viewModel.state === "error") return <AccountsPageError {...viewModel} />;
if (viewModel.state === "empty")
return (
<AccountsPageEmpty
lastSuccessfulAt={viewModel.lastSuccessfulAt}
refreshError={viewModel.refreshError}
refreshing={viewModel.refreshing}
retry={viewModel.retry}
sourceError={viewModel.sourceError}
/>
);
if (viewModel.state === "empty") return <AccountsPageEmpty />;
return (
<AccountsPageLoaded
accounts={viewModel.accounts}
lastSuccessfulAt={viewModel.lastSuccessfulAt}
refreshError={viewModel.refreshError}
refreshing={viewModel.refreshing}
retry={viewModel.retry}
sourceError={viewModel.sourceError}
meta={viewModel.meta}
/>
);
};
@@ -1,45 +1,59 @@
import {
type BrokerageAccountCard,
brokerageAccountCards,
useBrokerageAccounts,
} from "@/entities/brokerage-account";
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 sync = useScreenSync({ screen: "accounts" }, [accounts]);
useScreenSync({ screen: "accounts" }, [accounts]);
if (
!accounts.data
|| (accounts.meta?.availability === "partial" && accounts.data.length === 0)
) {
if (!accounts.isError && accounts.meta?.sync.state !== "failed")
return { state: "loading" as const };
const code = apiErrorCode(accounts.error);
const isAccessFailure = isAccountsAccessFailure(code);
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 {
code: apiErrorCode(accounts.error),
retry: sync.retry,
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,
};
state: availability === "unavailable" ? "error" : "loading",
} as const;
if (accounts.data.length === 0)
return {
...refresh,
state: "empty" as const,
};
const meta = accounts.meta;
if (!meta) return { state: "loading" as const };
return {
accounts: brokerageAccountCards(accounts.data, accounts.meta?.dependencies),
...refresh,
accounts: brokerageAccountCards(accounts.data, meta.dependencies),
meta,
state: "loaded" as const,
};
}
@@ -1,26 +1,16 @@
import type { FC } from "react";
import { Stack } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import {
InvestmentRefreshState,
type InvestmentRefreshStateProps,
} from "@/features/tbank-connection";
const AccountsPageEmpty: FC<InvestmentRefreshStateProps> = (props) => {
const AccountsPageEmpty: FC = () => {
const { t } = useTranslation();
return (
<Stack>
<InvestmentRefreshState {...props} />
<StateView
text={t("accounts.emptyText")}
title={t("accounts.emptyTitle")}
/>
</Stack>
<StateView
text={t("accounts.emptyText")}
title={t("accounts.emptyTitle")}
/>
);
};
@@ -1,16 +1,16 @@
import type { FC } from "react";
import { Button, Stack } from "@mantine/core";
import { Button } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { ApiErrorCode, isApiErrorCode } from "@/shared/api";
type AccountsPageErrorProps = { code?: string; retry?: () => void };
type AccountsPageErrorProps = { code?: string };
const AccountsPageError: FC<AccountsPageErrorProps> = (props) => {
const { code, retry } = props;
const { code } = props;
const { t } = useTranslation();
const settingsAction = (
@@ -37,28 +37,11 @@ const AccountsPageError: FC<AccountsPageErrorProps> = (props) => {
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 (
<StateView
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
error
text={t("states.errorText")}
title={t("states.errorTitle")}
text={t("states.unavailableText")}
title={t("states.unavailableTitle")}
/>
);
};
@@ -4,21 +4,20 @@ import { Button, Stack, Text, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type { InvestmentResourceMeta } from "@/shared/api";
import {
type BrokerageAccountCard,
BrokerageAccountList,
} from "@/entities/brokerage-account";
import {
InvestmentRefreshState,
type InvestmentRefreshStateProps,
} from "@/features/tbank-connection";
import { SourceSyncStatus } from "@/shared/ui";
type AccountsPageLoadedProps = InvestmentRefreshStateProps & {
type AccountsPageLoadedProps = {
accounts: BrokerageAccountCard[];
meta: InvestmentResourceMeta;
};
const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
const { accounts, ...refresh } = props;
const { t } = useTranslation();
return (
@@ -29,9 +28,13 @@ const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
<Text c="dimmed" size="sm">
{t("accounts.subtitle")}
</Text>
</Stack>
<InvestmentRefreshState {...refresh} />
<SourceSyncStatus
lastAttempt={props.meta.lastAttempt}
lastSuccessfulAt={props.meta.lastSuccessfulAt}
stale={props.meta.freshness === "stale"}
/>
</Stack>
<Button
component={Link}
@@ -42,7 +45,7 @@ const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
{t("accounts.openAll")}
</Button>
<BrokerageAccountList accounts={accounts} />
<BrokerageAccountList accounts={props.accounts} />
</Stack>
);
};
+3 -33
View File
@@ -352,11 +352,7 @@ describe("bond page", () => {
it("keeps market content and sold-position history when coupons fail", async () => {
setup({ failCoupons: true, noPosition: true });
await screen.findByText("98,125 %");
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.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
expect(
screen.queryByRole("heading", { name: "Моя позиция" }),
).not.toBeInTheDocument();
@@ -364,38 +360,12 @@ describe("bond page", () => {
screen.getByRole("heading", { name: "Моя история событий" }),
).toBeVisible();
});
it("preserves cached quote on refresh failure", 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 },
);
}),
);
it("preserves cached quote without manual refresh", async () => {
const controls = setup({ path: `/bonds/${uid}?period=year&events=trades` });
await screen.findByText("98,125 %");
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(scopes).toEqual([
{
accountId: "all",
candlePeriod: "year",
categories: ["trade"],
instrumentUid: uid,
limit: 20,
screen: "bond",
},
]);
expect(screen.queryByRole("button", { name: "Обновить" })).not.toBeInTheDocument();
});
it("offers search for an ambiguous or missing instrument without loading personal data", async () => {
const { requests } = setup({ ambiguousTicker: true, path: "/bonds/SAME" });
-1
View File
@@ -15,7 +15,6 @@ const BondPage: FC = () => {
code={vm.code}
kind="bond"
loading={vm.bondState.loading}
retry={vm.bondState.retry}
/>
);
@@ -1,7 +1,6 @@
import type { FC } from "react";
import {
Alert,
Badge,
Group,
Skeleton,
@@ -137,8 +136,6 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
<InstrumentHeader
logoSrc={vm.logoSrc}
name={bond.name}
onRefresh={vm.refresh}
refreshing={vm.refreshing}
subtitle={subtitle}
>
@@ -156,20 +153,8 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
)}
</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}
{!!(vm.quoteState.refreshing || vm.bondState.refreshing) && (
<Text c="dimmed" role="status" size="xs">
{t("accounts.refreshing")}
</Text>
)}
<InstrumentTerms title={t("bond.terms")}>
<InstrumentMetrics>
+8 -14
View File
@@ -42,7 +42,7 @@ function useBondPageVm() {
const { operations } = history;
const sync = useScreenSync(
useScreenSync(
{
accountId: "all",
candlePeriod: period,
@@ -56,29 +56,26 @@ function useBondPageVm() {
return {
bond: bond.data,
bondState: section(bond, uid ? sync.retry : () => bond.refetch()),
bondState: section(bond),
candles: candles.data,
candlesState: section(candles, sync.retry),
candlesState: section(candles),
canonicalTo:
bond.data?.slug && bond.data.slug !== bondId
? `/bonds/${encodeURIComponent(bond.data.slug)}${search}`
: null,
code: apiErrorCode(bond.error),
coupons: coupons.data,
couponsState: section(coupons, sync.retry),
couponsState: section(coupons),
eventRows: toInstrumentEventRows(operations.data?.items, bond.data),
filter,
hasPrevious: history.hasPrevious,
logoSrc: instrumentLogoUrl(bond.data?.logoName),
metrics: metrics.data,
metricsState: section(metrics, sync.retry),
metricsState: section(metrics),
onNext: history.onNext,
onPrevious: history.onPrevious,
operations: operations.data,
operationsState: {
...section(operations, sync.retry),
retry: () => history.retry(sync.retry),
},
operationsState: section(operations),
pastCoupons:
coupons.data?.coupons
.filter(event => event.timing !== "upcoming")
@@ -87,12 +84,9 @@ function useBondPageVm() {
) ?? [],
period,
position: position.data,
positionState: section(position, sync.retry),
positionState: section(position),
quote: quote.data,
quoteState: section(quote, sync.retry),
refresh: sync.refresh,
refreshError: sync.transportError || sync.sourceError,
refreshing: sync.refreshing,
quoteState: section(quote),
setFilter,
setPeriod,
upcoming:
+6 -8
View File
@@ -10,12 +10,14 @@ function useBondsPageVm() {
const search = useInstrumentSearch(MIN_BOND_SEARCH_LENGTH);
const { canSearch, debounced, isWaiting, query } = search;
const result = useBondSearch(debounced);
const sync = useScreenSync({ query: debounced, screen: "bonds" }, [result]);
useScreenSync({ query: debounced, screen: "bonds" }, [result]);
return {
canSearch,
code: apiErrorCode(result.error),
error: result.isError || sync.sourceError || sync.transportError,
error:
!result.data
&& (result.isError || result.meta?.lastAttempt.state === "failed"),
items:
result.data?.map(bond => ({
...bond,
@@ -27,18 +29,14 @@ function useBondsPageVm() {
&& (isWaiting
|| (!result.data
&& !result.isError
&& result.meta?.sync.state !== "failed")),
&& result.meta?.lastAttempt.state !== "failed")),
onQueryChange: search.onQueryChange,
partial: result.meta?.availability === "partial",
partial: result.meta?.completeness === "incomplete",
query,
ready:
debounced.length >= MIN_BOND_SEARCH_LENGTH
&& !isWaiting
&& 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: "Далее" }));
await waitFor(() =>
expect(screen.getByRole("button", { name: "Далее" })).toBeDisabled(),
);
await waitFor(() => {
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
expect(screen.getByText("Перевод на «Запасная»")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Изменить" })).toBeDisabled();
expect(
screen.queryByLabelText("Загрузка операций"),
).not.toBeInTheDocument();
expect(screen.queryByLabelText("Загрузка операций")).not.toBeInTheDocument();
expect(scroll).not.toHaveBeenCalled();
measure.mockReturnValue({ top: 200 } as DOMRect);
release();
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 list = screen.getByText(/Новая страница/).closest("[aria-busy]");
expect(list?.parentElement?.style.minHeight).toBe("");
const newPageText = screen.getByText(/Новая страница/);
expect(newPageText).toBeInTheDocument();
expect(scroll).toHaveBeenCalledExactlyOnceWith({
behavior: "instant",
top: -400,
@@ -317,7 +315,9 @@ describe("CardPage", () => {
it("keeps card and journal when summary fails", async () => {
setup({ summaryError: true });
await screen.findByRole("heading", { name: "Повседневная" });
await screen.findByRole("alert");
expect(
screen.queryByRole("alert"),
).not.toBeInTheDocument();
expect(
await screen.findByText("Перевод на «Запасная»"),
).toBeInTheDocument();
@@ -331,7 +331,9 @@ describe("CardPage", () => {
http.get("*/api/v1/bank-cards/a/operations", () => HttpResponse.error()),
);
await client.invalidateQueries();
await screen.findAllByRole("alert");
await waitFor(() => {
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
expect(
screen.getByRole("heading", { name: "Повседневная" }),
).toBeInTheDocument();
@@ -401,9 +403,9 @@ describe("CardPage", () => {
it("recovers the prerequisite card list while preserving the visible balance and journal", async () => {
setup({ cardsError: true });
await screen.findByText("Перевод на «Запасная»");
expect(await screen.findByRole("alert")).toHaveTextContent(
"Не удалось загрузить данные карт.",
);
expect(
screen.queryByRole("alert"),
).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Добавить операцию" }),
).toBeDisabled();
@@ -412,11 +414,6 @@ describe("CardPage", () => {
HttpResponse.json([card, { ...card, id: "b", name: "Запасная" }]),
),
);
fireEvent.click(
within(screen.getByRole("alert")).getByRole("button", {
name: "Повторить",
}),
);
await waitFor(() =>
expect(
screen.getByRole("button", { name: "Добавить операцию" }),
+2 -22
View File
@@ -75,7 +75,6 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
{t("bank.back")}
</Button>
{!vm.notFound && <Button onClick={vm.retryCard}>{t("bank.retry")}</Button>}
</Group>
)}
error
@@ -125,27 +124,16 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
</div>
{!!vm.archiveError && (
<BankFeedback error={vm.archiveError} retry={vm.retryCard} />
<BankFeedback error={vm.archiveError} />
)}
{card.archivedAt && <Text>{t("bank.archiveHint")}</Text>}
</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 && (
<BankFeedback
{...vm.cardsFeedback}
loadingError
retry={vm.retryCards}
/>
)}
@@ -187,7 +175,6 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
</Group>
<Stack
aria-busy={vm.history.refreshing}
className={classes.operations}
data-pending={vm.history.changingPage || undefined}
gap="lg"
@@ -196,13 +183,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
<CardHistory history={vm.history} period={vm.period} rows={vm.rows} />
</Stack>
{vm.history.error && (
<Group role="alert">
<Text>{t(vm.rows ? "bank.refreshError" : "bank.error")}</Text>
<Button onClick={vm.history.retry}>{t("bank.retry")}</Button>
</Group>
)}
{vm.history.error && !vm.rows && <Text role="alert">{t("bank.error")}</Text>}
{vm.history.page !== undefined && vm.pageSize !== "all" && (
<Group
@@ -213,7 +194,6 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
ref={paginationRef}
>
<Pagination
disabled={vm.history.refreshing}
getControlProps={getControlProps}
getItemProps={getItemProps}
onChange={onPageChange}
@@ -115,8 +115,6 @@ function useCardPageVm() {
error: history.isError,
loading: history.isLoading,
page: history.data?.page,
refreshing: history.isFetching,
retry: () => void history.refetch(),
},
notFound: isApiErrorCode(
apiErrorCode(card.error),
@@ -128,8 +126,6 @@ function useCardPageVm() {
page,
pageSize,
period,
retryCard: () => void card.refetch(),
retryCards: () => void cards.refetch(),
rows,
setPage: (value: number) =>
setParameters({ ...period, limit: pageSize, page: String(value) }),
@@ -146,7 +142,6 @@ function useCardPageVm() {
error: summary.isError,
fetching: summary.isFetching,
loading: summary.isLoading,
retry: () => void summary.refetch(),
},
toggleArchive: () => {
if (!card.data) return;
@@ -1,3 +1,4 @@
import { notifications } from "@mantine/notifications";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@@ -44,10 +45,16 @@ function useDeleteOperationVm(props: DeleteOperationProps) {
setError(undefined);
try {
const actionId = crypto.randomUUID();
await operationDeletion.mutateAsync({
id: record.id,
version: record.version,
});
notifications.show({
id: "bank-operation-deleted-" + actionId,
message: t("bank.operationDeleted"),
});
setOpened(false);
}
catch (error_) {
@@ -1,4 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { notifications } from "@mantine/notifications";
import { useState } from "react";
import { useForm, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
@@ -112,6 +113,8 @@ const useOperationFormVm = (props: OperationFormProps) => {
description: values.description,
};
const actionId = crypto.randomUUID();
if (props.operation)
await updateOperation.mutateAsync({
body: { ...body, version },
@@ -122,6 +125,11 @@ const useOperationFormVm = (props: OperationFormProps) => {
id: intent,
...body,
});
notifications.show({
id: "bank-operation-saved-" + actionId,
message: t("bank.operationSaved"),
});
setOpened(false);
}
catch (error_) {
+17 -18
View File
@@ -198,26 +198,26 @@ describe("CardsPage", () => {
it("shows retry after failure instead of presenting an empty list", async () => {
renderCards(true);
// Mount starts asynchronously; this handler owns the request.
expect(await screen.findByRole("alert")).toHaveTextContent(
"Не удалось загрузить данные карт.",
);
expect(
screen.queryByRole("alert"),
).not.toBeInTheDocument();
expect(
screen.queryByRole("heading", { name: "Добавьте первую карту" }),
).not.toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
server.use(http.get("*/api/v1/bank-cards", () => HttpResponse.json([])));
fireEvent.click(
within(screen.getByRole("alert")).getByRole("button", {
name: "Повторить",
}),
);
await screen.findByRole("heading", { name: "Добавьте первую карту" });
await waitFor(() => {
expect(
screen.queryByRole("alert"),
).not.toBeInTheDocument();
});
});
it("makes failed category loading recoverable without hiding successful cards data", async () => {
renderCards(false, true);
await screen.findByRole("heading", { name: "Добавьте первую карту" });
expect(await screen.findByRole("alert")).toHaveTextContent(
"Не удалось загрузить данные карт.",
);
expect(
screen.queryByRole("alert"),
).not.toBeInTheDocument();
server.use(
http.get("*/api/v1/bank-cards/categories", () =>
HttpResponse.json([
@@ -225,12 +225,11 @@ describe("CardsPage", () => {
]),
),
);
fireEvent.click(
within(screen.getByRole("alert")).getByRole("button", {
name: "Повторить",
}),
);
await screen.findByRole("button", { name: "Категории" });
await waitFor(() => {
expect(
screen.getByRole("button", { name: "Категории" }),
).toBeInTheDocument();
});
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
});
@@ -9,7 +9,6 @@ import {
Skeleton,
Stack,
Switch,
Text,
Title,
} from "@mantine/core";
import { AddIcon, CategoriesIcon } from "@wealthledger/icons";
@@ -19,7 +18,6 @@ import { Link } from "react-router";
import {
BankCardFace,
BankFeedback,
BankPeriodPicker,
BankSummaryView,
} from "@/entities/bank-card";
@@ -107,18 +105,6 @@ const CardsPageView: FC<CardsPageViewProps> = (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">
<BankPeriodPicker onChange={vm.setPeriod} period={vm.period} />
@@ -140,17 +126,7 @@ const CardsPageView: FC<CardsPageViewProps> = (props) => {
error={vm.summary.isError}
fetching={vm.summary.isFetching}
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>
);
};
@@ -39,9 +39,6 @@ function useCardsPageVm() {
setArchive(event.currentTarget.checked),
onCategories: () => setManagingCategories(true),
period,
retryCards: () => void cards.refetch(),
retryCategories: () => void categories.refetch(),
retrySummary: () => void summary.refetch(),
setPeriod: (value: [null | string, null | string]) => {
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: "Моя позиция" }),
).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "График цены" })).toBeVisible();
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.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
});
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 retry = await screen.findByRole("button", { name: "Повторить" });
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;
fireEvent.click(retry);
expect(await screen.findByText("Восстановленное описание")).toBeVisible();
expect(screen.getByText("Зачисление дивидендов")).toBeVisible();
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 () => {
const { requests } = setup();
@@ -469,75 +447,28 @@ describe("stock page", () => {
);
});
it.each([
[
"INVALID_OPERATIONS_CURSOR",
null,
null,
],
["TBANK_UNAVAILABLE", "next", "observe"],
"INVALID_OPERATIONS_CURSOR",
"TBANK_UNAVAILABLE",
])(
"recovers history after %s with fresh data and the correct cursor",
async (code, cursor, sync) => {
"keeps history quiet after %s without technical retry",
async (code) => {
const controls = setup({ cursorError: code });
await screen.findByText("Зачисление дивидендов");
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
const retry = await screen.findByRole("button", { name: "Повторить" });
controls.recoverOperations();
const previousRequests = controls.requests.length;
fireEvent.click(retry);
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();
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Моя история событий" })).toBeVisible();
},
);
it("keeps the last quote on a refresh failure", 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 },
);
}),
);
it("keeps the last quote without manual refresh", async () => {
const controls = setup({
path: `/stocks/${uid}?period=year&events=trades`,
});
await screen.findByText("300 ₽");
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(scopes).toEqual([
{
accountId: "all",
candlePeriod: "year",
categories: ["trade"],
instrumentUid: uid,
limit: 20,
screen: "stock",
},
]);
expect(screen.queryByRole("button", { name: "Обновить" })).not.toBeInTheDocument();
});
it("offers recovery for an unavailable direct link", async () => {
setup({ failStock: true });
@@ -15,7 +15,6 @@ const StockPage: FC = () => {
code={vm.code}
kind="stock"
loading={vm.stockState.loading}
retry={vm.stockState.retry}
/>
);
@@ -1,9 +1,6 @@
import type { FC } from "react";
import {
Alert,
Text,
} from "@mantine/core";
import { Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import {
@@ -42,8 +39,6 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
<InstrumentHeader
logoSrc={vm.logoSrc}
name={stock.name}
onRefresh={vm.refresh}
refreshing={vm.refreshing}
subtitle={subtitle}
>
@@ -53,12 +48,6 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
</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} />
<StockCompany
+8 -14
View File
@@ -42,7 +42,7 @@ function useStockPageVm() {
const { operations } = history;
const sync = useScreenSync(
useScreenSync(
{
accountId: "all",
candlePeriod: period,
@@ -56,16 +56,16 @@ function useStockPageVm() {
return {
candles: candles.data,
candlesState: section(candles, sync.retry),
candlesState: section(candles),
canonicalTo:
stock.data?.slug && stock.data.slug !== stockId
? `/stocks/${encodeURIComponent(stock.data.slug)}${search}`
: null,
code: apiErrorCode(stock.error),
company: company.data,
companyState: section(company, sync.retry),
companyState: section(company),
dividends: dividends.data,
dividendsState: section(dividends, sync.retry),
dividendsState: section(dividends),
eventRows: toInstrumentEventRows(operations.data?.items, stock.data),
filter,
hasPrevious: history.hasPrevious,
@@ -73,10 +73,7 @@ function useStockPageVm() {
onNext: history.onNext,
onPrevious: history.onPrevious,
operations: operations.data,
operationsState: {
...section(operations, sync.retry),
retry: () => history.retry(sync.retry),
},
operationsState: section(operations),
pastDividends:
dividends.data?.dividends
.filter(event => event.timing !== "upcoming")
@@ -85,16 +82,13 @@ function useStockPageVm() {
) ?? [],
period,
position: position.data,
positionState: section(position, sync.retry),
positionState: section(position),
quote: quote.data,
quoteState: section(quote, sync.retry),
refresh: sync.refresh,
refreshError: sync.transportError || sync.sourceError,
refreshing: sync.refreshing,
quoteState: section(quote),
setFilter,
setPeriod,
stock: stock.data,
stockState: section(stock, uid ? sync.retry : () => stock.refetch()),
stockState: section(stock),
upcoming:
dividends.data?.dividends
.filter(event => event.timing === "upcoming")
@@ -1,12 +1,6 @@
import type { FC } from "react";
import {
Alert,
Button,
Group,
Stack,
Text,
} from "@mantine/core";
import { Alert, Group, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import {
@@ -80,17 +74,7 @@ const StockCompany: FC<StockCompanyProps> = (props) => {
<Stack gap="lg">
{props.company.unavailable.length > 0 && (
<Alert aria-live="polite" color="orange" role="status">
<Group justify="space-between">
<Text size="sm">{t("stock.partialCompany")}</Text>
<Button
onClick={props.companyState.retry}
size="xs"
variant="default"
>
{t("accounts.retry")}
</Button>
</Group>
<Text size="sm">{t("stock.partialCompany")}</Text>
</Alert>
)}
@@ -82,12 +82,6 @@ const StockQuote: FC<StockQuoteProps> = (props) => {
{t("stock.partialQuote")}
</Text>
)}
{!!props.quoteState.refreshing && (
<Text c="dimmed" role="status" size="xs">
{t("accounts.refreshing")}
</Text>
)}
</div>
);
}

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