1 Commits
Author SHA1 Message Date
renovate-bot 3782b249c3 chore(deps): update non-major dependencies
Frontend verify / commits (pull_request) Failing after 6s
Frontend verify / security (pull_request) Skipped
Frontend verify / verify (pull_request) Skipped
2026-10-10 21:29:32 +00:00
154 changed files with 3878 additions and 9573 deletions
+1 -7
View File
@@ -30,8 +30,7 @@ backend checkout и не проверяют backend runtime OpenAPI; измен
shape/status/route сохраняет shape/status/route сохраняет
публичный контракт предоставленного snapshot. публичный контракт предоставленного snapshot.
Одна UI-модель не требует менять API. `openapi:generate` работает offline от snapshot; Одна UI-модель не требует менять API. `openapi:generate` работает offline от snapshot;
`openapi:check` запускает native `--check` того же CLI и падает при устаревших `openapi:check` генерирует во временный каталог и сравнивает, не меняя tracked files.
generated types, не меняя tracked files.
## Auth и invalidation ## Auth и invalidation
@@ -40,11 +39,6 @@ Authorization. Auth endpoints исключены из Bearer/retry middleware. R
координируется single-flight; после `401` с `UNAUTHORIZED` возможен один retry. координируется single-flight; после `401` с `UNAUTHORIZED` возможен один retry.
Не добавляй собственный refresh/retry loop в caller. Не добавляй собственный refresh/retry loop в caller.
Refresh, завершившийся `401`, очищает session. Сетевая ошибка и `5xx`
(например, деплой backend) не доказывают конец session: она сохраняется,
`unavailable` показывает retry-экран вместо login, а следующий `401` снова
запускает refresh. Retry/backoff внутри refresh не добавляется.
Cookie-writing requests упорядочены только в текущей вкладке. Cross-tab auth Cookie-writing requests упорядочены только в текущей вкладке. Cross-tab auth
synchronization не требуется; допустим reload/re-login. Web Locks и отдельные synchronization не требуется; допустим reload/re-login. Web Locks и отдельные
token stores не добавляются. token stores не добавляются.
+15 -35
View File
@@ -7,19 +7,11 @@
checkout. Внешний API задаётся предоставленным публичным контрактом; checkout. Внешний API задаётся предоставленным публичным контрактом;
исходники или инструкции его реализации не являются зависимостью разработки. исходники или инструкции его реализации не являются зависимостью разработки.
При нехватке требования сообщи, какое решение не определено, до его реализации. При нехватке требования сообщи, какое решение не определено, до его реализации.
При конфликте с общим шаблоном следуй местным правилам и явному запросу
пользователя. Выбирай реализацию самостоятельно, но изменение финансовой
логики, публичного контракта или утверждённой семантики возвращай владельцу
на согласование до зависимой работы.
Начни с результата и affected areas. До edits проследи flow/invariants, найди Начни с результата и affected areas. До edits проследи flow/invariants, найди
owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт. owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт.
Выбери lightweight или SDD по [Engineering Workflow](https://git.personal.ksv741.ru/WealthLedger/main/src/commit/03b3b59e6ff2ba391f9dfc42a039a96d0d84c1c9/.ai/conventions/engineering-workflow.md); Работай напрямую; OpenSpec/SDD lifecycle — только по явному запросу.
OpenSpec lifecycle начинай после явного решения Change Owner. Перед Node-командами активируй местную `.nvmrc`: `nvm use --silent`.
Перед Node-командами активируй местную `.nvmrc`: если `nvm` не загружен в
non-interactive shell, загрузи установленный `$HOME/.nvm/nvm.sh`; затем
выполни `nvm use --silent` из frontend. Если `nvm.sh` недоступен, остановись,
не используй системный Node.
Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы. Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы.
## Change scope ## Change scope
@@ -65,33 +57,21 @@ Peer-конфликт решай совместимыми версиями бе
## Проверки и завершение ## Проверки и завершение
| Когда | Что проверить | Code/dependency change: `npm ci`, полный `npm run verify` и дополнительные
| --- | --- | risk checks из местной testing convention. Targeted tests ускоряют итерацию,
| Окружение новое, изменились manifest/lockfile или Node, установка сомнительна | `npm ci` до Node-проверок; одна правка кода переустановки не требует. | но не заменяют финальный gate. `lint:fix`/format — edits, не proof.
| Итерация | Адресные тесты, typecheck и lint по затронутому поведению и риску. При изменении поведения добавь/обнови содержательную регрессию, если существующая проверка не ловит её; для косметики не создавай искусственный тест. | Markdown-only: проверь новые/изменённые тексты, локальные ссылки/якоря, маршруты
| Итоговый code/dependency diff | Перед объявлением готовности и передачей к интеграции выполни один полный `npm run verify` на итоговых входах. Дополнительные risk checks из testing convention и явно утверждённые task checks обязательны; CI проверяет независимо. | чтения и `git diff --check`; application build ради текста не нужен.
| Только Markdown | Проверь тексты, ссылки/якоря, маршруты чтения и `git diff --check`; application build не нужен. При изменении verification-контура проверь его работу. | Повтори routing по staged/unstaged/new files. Запиши краткие команды, результаты
| Pre-commit | Hook не скачивает tools: вызывай установленные бинарники из `node_modules/.bin` (не `npx`). Full graph не входит в hook; он выполняется в `npm run verify` и CI. | и ограничения. Зелёный lint не заменяет semantic/security review.
Повтори routing по staged/unstaged/new files. В отчёте по задаче/PR запиши
проверенные входы, команду, exit code, результат и ограничения. Повторяй
проверку при изменении значимых входов (включая config, dependencies и
generated artifacts), сомнении в evidence или явном запросе пользователя.
`lint:fix`/format — edits, не proof; lint не заменяет semantic/security review.
## Git и релизы ## Git и релизы
Работа — в ветке `<type>/<issue-number>-<slug>` от актуального `develop`, Работа — в `codex/<task>` от актуального `develop`, затем PR в `develop`.
затем PR в `develop`; для cross-repo ссылки укажи owner/repo#number.
Сохраняй чужие изменения; перед commit просмотри staged paths и diff. Сохраняй чужие изменения; перед commit просмотри staged paths и diff.
Commits и PR titles — Conventional Commits с parser-recognized Issue reference (`fix: … (#12)` Commits и PR titles — Conventional Commits; допустим `spec:`. Каждый commit
или `chore: … (WealthLedger/main#27)`); допустим `spec:`. Каждый commit содержит одну связанную задачу. При связанной issue добавь её номер в конец
содержит одну связанную задачу. Исключения release и правила ссылки — в заголовка (`fix: … #12`); без issue номер не нужен. Root другого checkout
Engineering Workflow из AGENTS. Root другого checkout не управляет этим repo. не управляет этим repo.
Прямой push в `main`, force-push и перенос опубликованных тегов не входят в Прямой push в `main`, force-push и перенос опубликованных тегов не входят в
обычную работу. PR сливается squash или rebase-merge; merge-коммит не обычную работу. Перед release/hotfix прочитай местный README, раздел «Релизы».
используется. `main` обновляется только fast-forward из `develop`, поэтому
`origin/main` всегда предок `origin/develop`:
`git merge-base --is-ancestor origin/main origin/develop`. Срочный fix —
обычная ветка `fix/*` от `develop`; пути hotfix от `main` нет.
Перед релизом прочитай местный README, раздел «Релизы».
-3
View File
@@ -13,9 +13,6 @@
!public/** !public/**
!docker/ !docker/
!docker/** !docker/**
!scripts/
!scripts/minimatch-compat/
!scripts/minimatch-compat/**
!packages/ !packages/
!packages/tokens/ !packages/tokens/
!packages/tokens/package.json !packages/tokens/package.json
-12
View File
@@ -1,12 +0,0 @@
---
name: Bug Report
about: Неверное поведение
---
## Проблема и шаги воспроизведения
## Факт / ожидание
## Acceptance Criteria
- [ ]
## Окружение / ссылки (если применимо)
-10
View File
@@ -1,10 +0,0 @@
---
name: Engineering Task
about: CI, тесты, документация, процесс и прочее
---
## Контекст и результат
## Scope / связанные Issues (если есть)
## Acceptance Criteria
- [ ]
-12
View File
@@ -1,12 +0,0 @@
---
name: Feature Request
about: Новое наблюдаемое поведение
---
## Зачем и какой результат
## Ожидаемое поведение
## Acceptance Criteria
- [ ]
## Зависимости / связанные Issues (если есть)
-12
View File
@@ -1,12 +0,0 @@
---
name: Refactoring / Technical Debt
about: Улучшение без нового поведения
---
## Проблема и результат
## Сохраняемые инварианты / границы
## Acceptance Criteria
- [ ]
## Зависимости / ссылки (если есть)
-11
View File
@@ -1,11 +0,0 @@
## Summary
## Linked Issue
Refs
## Changes
## Verification
- Команда / результат / ограничения:
## Breaking Changes (если есть)
-34
View File
@@ -1,34 +0,0 @@
name: Frontend verify
# Metadata-only: refreshes only the `commits` context after a PR title edit.
# Same workflow name keeps the required context; `security` and `verify`
# statuses of the head revision stay untouched (no skipped overwrite).
on:
pull_request:
types: [edited]
concurrency:
group: frontend-pr-title-${{ gitea.event.pull_request.number }}
cancel-in-progress: true
jobs:
commits:
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- run: npm ci --ignore-scripts
- name: Check PR title and commits
env:
PR_TITLE: ${{ github.event.pull_request.title }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
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"
+3 -7
View File
@@ -4,7 +4,7 @@ on:
push: push:
branches: [develop, main] branches: [develop, main]
pull_request: pull_request:
types: [opened, synchronize, reopened] types: [opened, synchronize, reopened, edited]
schedule: schedule:
- cron: '17 5 * * *' - cron: '17 5 * * *'
@@ -34,11 +34,7 @@ jobs:
HEAD_SHA: ${{ github.event.pull_request.head.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: | run: |
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
if [[ "$PR_TITLE" =~ ^chore\(release\):\ [0-9]+\.[0-9]+\.[0-9]+$ ]]; then npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
npm run commitlint -- --strict --from "$HEAD_SHA^" --to "$HEAD_SHA"
else
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
fi
security: security:
needs: commits needs: commits
@@ -66,7 +62,7 @@ jobs:
- name: Verify pre-commit secret blocking and redaction - name: Verify pre-commit secret blocking and redaction
run: PATH=/tmp/gitleaks-bin:$PATH node --test scripts/test-pre-commit.mjs run: PATH=/tmp/gitleaks-bin:$PATH node --test scripts/test-pre-commit.mjs
- name: Audit runtime and development dependencies - name: Audit runtime and development dependencies
run: npm audit --include=dev --audit-level=moderate run: npm audit --include=dev --audit-level=low
verify: verify:
needs: [commits, security] needs: [commits, security]
+2 -5
View File
@@ -1,6 +1,3 @@
gitleaks git --pre-commit --staged --redact=100 --no-banner || exit $? gitleaks git --pre-commit --staged --redact=100 --no-banner || exit $?
if [ ! -x node_modules/.bin/lint-staged ]; then npx lint-staged
echo "lint-staged is not installed; run npm ci" >&2 npm run deps
exit 1
fi
node_modules/.bin/lint-staged
+1 -1
View File
@@ -1 +1 @@
24.22.1 24.21.0
-3
View File
@@ -4,9 +4,6 @@
только его файлы и предоставленные публичные контракты. Перед работой прочитай только его файлы и предоставленные публичные контракты. Перед работой прочитай
[порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем [порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем
объединяй triggers задачи и путей; читай активированные темы до решения. объединяй triggers задачи и путей; читай активированные темы до решения.
До edits найди/создай подходящую Gitea Issue, проверь AC и ветку; чтение
допускается без Issue. Общий маршрут — [Engineering Workflow](https://git.personal.ksv741.ru/WealthLedger/main/src/commit/03b3b59e6ff2ba391f9dfc42a039a96d0d84c1c9/.ai/conventions/engineering-workflow.md)
(main#28; после merge — актуальная версия `develop`).
| Решение / сигнал | Читать | | Решение / сигнал | Читать |
| --- | --- | | --- | --- |
-111
View File
@@ -1,116 +1,5 @@
# Changelog # Changelog
## [1.3.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.2.1...v1.3.0) (2026-11-01)
### Features
* **account:** add concentration tab with top-5 slices and chart (WealthLedger/main[#48](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/48)) ([#127](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/127)) ([70a2c33](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/70a2c335fdddb8b6a99e3f609befe06aeb8cc45d))
* group all-account rows by instrument and day (WealthLedger/main[#47](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/47)) ([#126](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/126)) ([71126c9](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/71126c91f0b30f4d65a313ddc7d7f86963340455))
* make brokerage account list compact ([#111](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/111)) ([c24704a](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c24704affacbe7fbc08f5e2b61bb6e7ffa6053b9))
* remove top header and rework mobile bottom navigation ([#109](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/109)) ([1a92f02](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/1a92f023c1c79f646424f72710ab74de63386a11))
* replace account page accordion with tabs and show open position result ([#112](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/112)) ([3075081](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/3075081df188de0630d314ba15a34fe6efd1eea9))
* show return, cash and structure on account cards ([#114](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/114)) ([431559b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/431559be0fca9eb37f6a09d930d502f939884e39))
### Bug Fixes
* round money to currency minor units without negative zero (WealthLedger/frontend[#118](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/118)) ([#119](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/119)) ([1d49904](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/1d4990491f254e1ec6bafad8204872eaae2982d0))
* show account source sync status (WealthLedger/frontend[#120](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/120)) ([#121](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/121)) ([a56edf2](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/a56edf2b3757ef0b0f6c42ecbd422a1a563f6505))
* show history loading alert only while loading (WealthLedger/frontend[#124](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/124)) ([#125](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/125)) ([6792f5b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/6792f5b70aa3ba90b2eb2df7e63d3ae2f669e295))
* stabilize auth client registration (WealthLedger/frontend[#95](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/95)) ([#122](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/122)) ([5796d2d](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/5796d2d789963e66a3a4bcd79eab44700f16bd31))
### Refactoring
* derive api error codes from contract (WealthLedger/backend[#117](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/117)) ([#123](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/123)) ([32e8fe9](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/32e8fe9402665ea0c350f5f247d87e328ffd44c8))
### Tests
* pin date in all accounts events test ([#115](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/115)) ([58b4e12](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/58b4e126330d01c1d6865bd0e9dd066a028fba08))
### Maintenance
* **deps:** resolve minimatch and glob audit advisories (WealthLedger/main[#49](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/49)) ([#128](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/128)) ([7242240](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/7242240b2188e9d1ca1325e65d0e1a0a3655d882))
* sync brokerage accounts contract (WealthLedger/backend[#138](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/138)) ([0e4bbec](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/0e4bbec2a7b814579095ea7ef6db68a70508fa29))
## [1.2.1](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.2.0...v1.2.1) (2026-10-24)
### Bug Fixes
* keep session when refresh fails on network error or 5xx (WealthLedger/frontend[#97](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/97)) ([3af9df0](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/3af9df0ae2f0fb314c276504ef7d50a7472de680))
* show snapshot time in browser time zone ([#101](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/101)) ([b009101](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/b009101b7b954e8462672a9da859267ebb71edc4))
### Maintenance
* bump Node to 24.22.1 (WealthLedger/backend[#122](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/122)) ([2cd6b1e](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/2cd6b1e72d97117b0c37465aac3d989b0469c1c3))
* bump test stack and fix npm audit findings ([#105](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/105)) ([7e6fd95](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/7e6fd95cfaf7d288242fb46ec7091f561e6f3de5))
* **deps:** update non-major dependencies [#9](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/9) ([b5ceb77](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/b5ceb77208e35a0e389258f3d3c4b85852980042))
* resolve dev dependency audit findings (WealthLedger/frontend[#103](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/103)) ([7b6f34c](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/7b6f34c776fa3517c93b5c3468e3b277aaa6273f))
## [1.2.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.1.0...v1.2.0) (2026-10-18)
### Features
* unify income payment cards and remove comparisons (WealthLedger/main[#33](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/33)) ([#74](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/74)) ([43b0f55](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/43b0f5574a79e26de0abae35de16d8aa8700f718))
### Bug Fixes
* align password validation with backend ASCII policy (WealthLedger/backend[#60](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/60)) ([#89](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/89)) ([ebe9268](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/ebe92684a74b4e2617b04ef00d9406b8a0f674ea))
* **auth,bank:** guard switch-auth nav and surface banking retry ([e38ecd8](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/e38ecd87d6f69968bb56e6035ceeb6cfda208a3f)), closes [#56](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/56) [#57](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/57)
* **bank:** break BankFeedback's shared/api edge for stories ([#80](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/80)) ([2336804](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/233680448f97dc3ec57d7665ec14c68ac92410e5))
* deliver screener OpenAPI constraints (WealthLedger/backend[#70](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/70)) ([#85](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/85)) ([ed63ada](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/ed63adad94f5d0e20f7d0d1446a235f953d10ec2))
* **events:** seven-date default period and picker from today (WealthLedger/frontend[#63](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/63)) ([612ab62](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/612ab6250979bf8d9b57f4feb6e1476ed3d759e4))
* respect query eligibility in screen sync (WealthLedger/frontend[#54](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/54)) ([f295dea](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f295dea6a6a33c597c753b52aff629d5382c963a))
### Refactoring
* derive payment-system options from schema tuple ([#58](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/58)) ([#75](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/75)) ([e67c47b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/e67c47b102329877eb5d9c9d8bb4ea7c0bc70883))
### Build
* use native openapi-typescript --check ([#60](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/60)) ([#77](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/77)) ([df05c8c](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/df05c8c7dbb5d6816fb95d54ca43cd0d83eecd48))
### CI
* keep pre-commit proportionate and avoid tool downloads (WealthLedger/frontend[#62](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/62)) ([#86](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/86)) ([d409a29](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/d409a297e5f9ae5658d4efa958bda9a012a859a1))
* recheck PR title on edited without the full pipeline (WealthLedger/frontend[#64](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/64)) ([bc28973](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/bc28973d1ad5e15bfbc2e5b73b00ba63449933db))
### Tests
* await client settlement in late-response cases (WealthLedger/frontend[#61](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/61)) ([#81](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/81)) ([f3725c2](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f3725c202bd8cef4ba711085df82a921a2da6b7d))
* cover non-retry of INVALID_TBANK_TOKEN reads (WealthLedger/backend[#59](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/59)) ([#90](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/90)) ([5134106](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/513410669680480a1f68f5720744cc85e43c8286))
* cover null events and income envelopes on account pages ([#72](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/72)) ([#79](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/79)) ([e5f8bbe](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/e5f8bbe33cd438f432281818c5276abe9a425646))
* cover null resource envelopes for account pages ([#70](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/70)) ([#71](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/71)) ([#78](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/78)) ([f7f53ff](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f7f53ff8e4de3c1d06fac6063974ff9667145ed6))
### Maintenance
* adopt linear git model in frontend docs ([#91](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/91)) ([eefdde2](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/eefdde29f8da269581e90bd797078e10a39e472f))
## [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) ## [1.0.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v0.4.0...v1.0.0) (2026-10-07)
### ⚠ BREAKING CHANGES ### ⚠ BREAKING CHANGES
+2 -3
View File
@@ -1,11 +1,10 @@
FROM node:24.22.1-bookworm-slim AS dependencies FROM node:24.21.0-bookworm-slim AS dependencies
WORKDIR /app WORKDIR /app
ENV HUSKY=0 ENV HUSKY=0
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
COPY packages/tokens/package.json ./packages/tokens/ COPY packages/tokens/package.json ./packages/tokens/
COPY packages/ui/package.json ./packages/ui/ COPY packages/ui/package.json ./packages/ui/
COPY packages/icons/package.json ./packages/icons/ COPY packages/icons/package.json ./packages/icons/
COPY scripts/minimatch-compat ./scripts/minimatch-compat
RUN npm ci RUN npm ci
FROM dependencies AS development FROM dependencies AS development
@@ -20,7 +19,7 @@ COPY . .
ENV VITE_API_BASE_URL="" ENV VITE_API_BASE_URL=""
RUN npm run build RUN npm run build
FROM nginx:1.31.8-alpine AS production FROM nginx:1.31.6-alpine AS production
ARG VERSION ARG VERSION
ARG REVISION ARG REVISION
LABEL org.opencontainers.image.version=$VERSION \ LABEL org.opencontainers.image.version=$VERSION \
+23 -54
View File
@@ -19,10 +19,8 @@ npm run dev
Для commits установите [Gitleaks](https://github.com/gitleaks/gitleaks/releases/tag/v8.30.1) Для commits установите [Gitleaks](https://github.com/gitleaks/gitleaks/releases/tag/v8.30.1)
8.30.1 в `PATH` (macOS: `brew install gitleaks`). Pre-commit сначала проверяет 8.30.1 в `PATH` (macOS: `brew install gitleaks`). Pre-commit сначала проверяет
staged diff с полной редакцией секретов, затем запускает установленный staged diff с полной редакцией секретов, затем запускает formatting и FSD checks.
`node_modules/.bin/lint-staged` только для staged JS/TS. Hook не скачивает tools и Находка или отсутствие Gitleaks блокирует commit.
не запускает full graph: полный `npm run verify` выполняется в CI и перед передачей.
Находка или отсутствие Gitleaks либо lint-staged блокирует commit.
Проверка этого поведения: `node --test scripts/test-pre-commit.mjs`. Проверка этого поведения: `node --test scripts/test-pre-commit.mjs`.
## Контейнерный image ## Контейнерный image
@@ -118,17 +116,14 @@ Chromium при ширинах 375 и 1280 px. Browser project не исполь
Единый `.gitea/workflows/verify.yml` запускает `commits → security → verify` Единый `.gitea/workflows/verify.yml` запускает `commits → security → verify`
последовательно на PR; на push в `develop`/`main` — `security → verify`. последовательно на PR; на push в `develop`/`main` — `security → verify`.
Рабочие ветки проверяются после открытия PR, без дублирующего push-запуска. Рабочие ветки проверяются после открытия PR, без дублирующего push-запуска.
Изменение заголовка PR запускает `.gitea/workflows/pr-title.yml` (то же имя workflow, Изменение заголовка PR тоже повторяет проверки Conventional Commits.
только job `commits`): статусы `security` и `verify` на HEAD не перезаписываются.
Новый запуск отменяет устаревший для того же PR/ветки и типа события Новый запуск отменяет устаревший для того же PR/ветки и типа события
(требуется Gitea 1.26+). Ежедневно в 05:17 UTC выполняется только `security`. (требуется Gitea 1.26+). Ежедневно в 05:17 UTC выполняется только `security`.
Job `security` проверяет полную доступную историю Gitleaks 8.30.1 и выполняет Job `security` проверяет полную доступную историю Gitleaks 8.30.1 и выполняет
`npm audit --include=dev --audit-level=moderate`. Advisory уровня moderate и выше `npm audit --include=dev --audit-level=low`. Любой advisory блокирует security job;
блокирует security job; low не блокирует, т.к. для них часто нет исправленной версии исключений frontend нет. Локально можно выполнить те же команды:
(например, GHSA-gcqx-w48m-w2h4 в build-only deepmerge). Других исключений нет.
Локально можно выполнить те же команды:
`gitleaks git --log-opts=--all --redact=100 --no-banner` и `gitleaks git --log-opts=--all --redact=100 --no-banner` и
`npm audit --include=dev --audit-level=moderate`. Audit требует доступа к npm registry; `npm audit --include=dev --audit-level=low`. Audit требует доступа к npm registry;
проверка секретов выполняется локально и не выводит их значения. проверка секретов выполняется локально и не выводит их значения.
Имена jobs `commits`, `security`, `verify` сохранены, но Gitea включает имя Имена jobs `commits`, `security`, `verify` сохранены, но Gitea включает имя
@@ -208,8 +203,7 @@ Checkout frontend содержит все входы сборки и прове
локальную копию и не обращаются к исходникам или CI сервера. Доставка новой локальную копию и не обращаются к исходникам или CI сервера. Доставка новой
схемы — явное обновление snapshot с review и `npm run openapi:generate`. схемы — явное обновление snapshot с review и `npm run openapi:generate`.
Runtime URL API задаётся окружением, а не расположением другого репозитория. Runtime URL API задаётся окружением, а не расположением другого репозитория.
Разработка начинается с Issue; lightweight или SDD выбирается по критериям Разработка идёт напрямую по задаче пользователя; OpenSpec/SDD — по явному запросу.
Engineering Workflow из AGENTS. OpenSpec — после явного решения Change Owner.
Knip исключает `conventional-changelog-conventionalcommits`: release-it загружает Knip исключает `conventional-changelog-conventionalcommits`: release-it загружает
preset по имени из `.release-it.json`. Версия preset закреплена напрямую. preset по имени из `.release-it.json`. Версия preset закреплена напрямую.
@@ -217,61 +211,36 @@ preset по имени из `.release-it.json`. Версия preset закреп
## Релизы ## Релизы
Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые
релизы. Обычная работа: issue-linked ветка от актуального `develop`, PR в `develop`; релизы. Работа: `codex/<task>` от актуального `develop`, PR в `develop`.
общие правила — в Engineering Workflow по ссылке из AGENTS. Обычные PR допускают squash; release и обратная синхронизация сохраняют историю.
PR сливается squash или rebase-merge; merge-коммит не используется. `main` Commits и PR titles — Conventional Commits, включая `spec:`. Версии вручную не повышай.
обновляется только fast-forward из `develop`, поэтому история линейна:
```text 1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что
<type>/<issue>-<slug> --squash--> develop --ff-only--> main --> tag vX.Y.Z `develop` содержит последний релиз. Создай `release/<name>` от проверенного
``` `develop`; `nvm use --silent`, `npm ci`. Hotfix начинается от `main` в
`release/hotfix-<name>` и проходит тот же процесс.
Инвариант: `origin/main` всегда предок `origin/develop`. Проверка:
```bash
git fetch -q origin
git merge-base --is-ancestor origin/main origin/develop && echo ok || echo BROKEN
```
Commits и PR titles — Conventional Commits со ссылкой на Issue (кроме release),
включая `spec:`. Версии вручную не повышай.
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Проверь
инвариант. Создай `release/X.Y.Z` от проверенного `develop`;
`nvm use --silent`, `npm ci`.
2. На чистой ветке выполни `npm run release:preview`, просмотри версию/changelog, 2. На чистой ветке выполни `npm run release:preview`, просмотри версию/changelog,
затем `npm run release:prepare`. Команда создаёт `chore(release): X.Y.Z`, затем `npm run release:prepare`. Команда создаёт `chore(release): X.Y.Z`,
обновляет manifest/lockfile/CHANGELOG; тег и push не выполняет. Не обходи обновляет manifest/lockfile/CHANGELOG; тег и push не выполняет. Не обходи
guards ветки, чистого дерева, отсутствующих тегов или уже подготовленной версии. guards ветки, чистого дерева, отсутствующих тегов или уже подготовленной версии.
3. Проверь diff и штатные gates этого репозитория. Отправь только release-ветку, 3. Проверь diff и штатные gates этого репозитория. Отправь только release-ветку,
открой PR в **`develop`** с заголовком `chore(release): X.Y.Z`. После зелёных открой PR в `main` с заголовком `chore(release): X.Y.Z`. После зелёных
`verify`, `security`, `commits` выполни fast-forward-only merge. Если `verify`, `security`, `commits` выполни fast-forward-only merge. Если `main`
`develop` изменился, обнови release-ветку и повтори проверки кандидата. изменился, согласуй историю и повтори проверки кандидата.
4. Открой PR `develop → main` с тем же заголовком и слей fast-forward-only. 4. Получи удалённый `main`, сравни его HEAD с проверенным release SHA и версией
5. Получи удалённый `main`, сравни его HEAD с проверенным release SHA и версией
manifest/lockfile. Создай аннотированный `vX.Y.Z` именно на этом SHA: manifest/lockfile. Создай аннотированный `vX.Y.Z` именно на этом SHA:
`git tag -a vX.Y.Z <release-sha> -m "Release X.Y.Z"`, затем отправь только `git tag -a vX.Y.Z <release-sha> -m "Release X.Y.Z"`, затем отправь только
`git push origin refs/tags/vX.Y.Z`. Не запускай release-it повторно на `main`. `git push origin refs/tags/vX.Y.Z`. Не запускай release-it повторно на `main`.
5. Верни release-коммит через PR `main → develop` без squash/rebase; проверь,
Обратная синхронизация не нужна: release-коммит попал в `develop` на шаге 3. что `develop` содержит тег. Опубликованные теги не перемещай. При сбое после
Опубликованные теги не перемещай. При сбое после merge создай отсутствующий тег merge создай отсутствующий тег без нового bump; до merge исправь и проверь
без нового bump; до merge исправь и проверь существующий кандидат без существующий кандидат без повторного повышения версии.
повторного повышения версии.
Freeze релиза не поддерживается: пока открыт релизный PR, `develop` не двигают.
Срочный fix — обычная ветка `fix/*` от `develop` с последующим релизом;
пути от `main` нет, он сделал бы `main` не-предком `develop`. Исключение
требует явного решения: `release/hotfix-<name>` от `main`, PR в `main`, тег,
затем merge `main → develop` для восстановления инварианта.
`feat:` повышает minor, `fix:`/`perf:` и прочие разрешённые типы — patch; `feat:` повышает minor, `fix:`/`perf:` и прочие разрешённые типы — patch;
`!`/`BREAKING CHANGE:` — major, включая версии до 1.0. `chore(release):` `!`/`BREAKING CHANGE:` — major, включая версии до 1.0. `chore(release):`
исключён из рекомендации и changelog. Конфигурация — `.release-it.json`. исключён из рекомендации и changelog. Конфигурация — `.release-it.json`.
В Gitea для `main`/`develop` должны быть запрещены прямой/force push и обход В Gitea для `main`/`develop` должны быть запрещены прямой/force push и обход
администратором, включена блокировка устаревшей ветки, обязательны `verify`, администратором, обязательны `verify`, `security`, `commits`; теги `v*` защищены.
`security`, `commits`; теги `v*` защищены. Линейность обеспечивают merge styles
репозитория: merge-коммиты и rebase `--no-ff` выключены, доступны squash,
rebase-merge и fast-forward-only; стиль по умолчанию — squash.
Workflow сам не включает эти настройки. Отдельное review approval для одного Workflow сам не включает эти настройки. Отдельное review approval для одного
владельца не требуется. Npm publishing, deploy и Gitea Releases не включены. владельца не требуется. Npm publishing, deploy и Gitea Releases не включены.
-6
View File
@@ -1,12 +1,6 @@
module.exports = { module.exports = {
defaultIgnores: false,
extends: ["@commitlint/config-conventional"], extends: ["@commitlint/config-conventional"],
ignores: [
message => /^chore\(release\): \d+\.\d+\.\d+$/.test(message.trim()),
message => /^Merge pull request ['"].+['"] \(#\d+\) from .+ into .+$/.test(message.trim()),
],
rules: { rules: {
"references-empty": [2, "never"],
"type-enum": [ "type-enum": [
2, 2,
"always", "always",
+1 -1
View File
@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="theme-color" content="#ffffff" /> <meta name="theme-color" content="#ffffff" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WealthLedger</title> <title>WealthLedger</title>
</head> </head>
<body> <body>
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"ignoreDependencies": ["conventional-changelog-conventionalcommits", "minimatch"], "ignoreDependencies": ["conventional-changelog-conventionalcommits"],
"workspaces": { "workspaces": {
".": { ".": {
"project": ["src/**/*.{ts,tsx}", "!src/shared/api/generated/**"] "project": ["src/**/*.{ts,tsx}", "!src/shared/api/generated/**"]
+1429 -1294
View File
File diff suppressed because it is too large Load Diff
+41 -49
View File
@@ -1,10 +1,10 @@
{ {
"name": "wealth-ledger-frontend", "name": "wealth-ledger-frontend",
"private": true, "private": true,
"version": "1.3.0", "version": "1.0.0",
"type": "module", "type": "module",
"engines": { "engines": {
"node": "24.22.1" "node": "24.21.0"
}, },
"scripts": { "scripts": {
"dev": "npm run build:packages && vite", "dev": "npm run build:packages && vite",
@@ -20,7 +20,7 @@
"test:coverage": "npm run build:packages && vitest run --coverage", "test:coverage": "npm run build:packages && vitest run --coverage",
"test:lint": "node --test scripts/test-eslint.mjs", "test:lint": "node --test scripts/test-eslint.mjs",
"openapi:generate": "openapi-typescript src/shared/api/spec/openapi.json -o src/shared/api/generated/schema.d.ts", "openapi:generate": "openapi-typescript src/shared/api/spec/openapi.json -o src/shared/api/generated/schema.d.ts",
"openapi:check": "openapi-typescript src/shared/api/spec/openapi.json -o src/shared/api/generated/schema.d.ts --check", "openapi:check": "node scripts/check-openapi.mjs",
"knip": "npm run build:packages && knip", "knip": "npm run build:packages && knip",
"deps": "npm run build:packages && depcruise src packages --config dependency-cruiser.cjs --output-type err-long", "deps": "npm run build:packages && depcruise src packages --config dependency-cruiser.cjs --output-type err-long",
"verify": "npm run build:packages && npm run openapi:check && tsc -b --noEmit && eslint . --max-warnings=0 && npm run test:lint && vitest run --coverage && knip && depcruise src packages --config dependency-cruiser.cjs --output-type err-long && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long && tsc -b && vite build && storybook build --output-dir storybook-static --disable-telemetry", "verify": "npm run build:packages && npm run openapi:check && tsc -b --noEmit && eslint . --max-warnings=0 && npm run test:lint && vitest run --coverage && knip && depcruise src packages --config dependency-cruiser.cjs --output-type err-long && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long && tsc -b && vite build && storybook build --output-dir storybook-static --disable-telemetry",
@@ -37,97 +37,89 @@
"*.{js,mjs,cjs,ts,tsx}": "eslint --fix --max-warnings=0 --no-warn-ignored" "*.{js,mjs,cjs,ts,tsx}": "eslint --fix --max-warnings=0 --no-warn-ignored"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "5.9.2", "@hookform/resolvers": "5.9.1",
"@mantine/charts": "9.7.3", "@mantine/charts": "9.7.1",
"@mantine/core": "9.7.3", "@mantine/core": "9.7.1",
"@mantine/dates": "9.7.3", "@mantine/dates": "9.7.1",
"@mantine/hooks": "9.7.3", "@mantine/hooks": "9.7.1",
"@mantine/notifications": "9.7.3", "@mantine/notifications": "9.7.1",
"@tanstack/react-query": "5.104.5", "@tanstack/react-query": "5.104.1",
"@wealthledger/icons": "file:packages/icons", "@wealthledger/icons": "file:packages/icons",
"@wealthledger/tokens": "file:packages/tokens", "@wealthledger/tokens": "file:packages/tokens",
"@wealthledger/ui": "file:packages/ui", "@wealthledger/ui": "file:packages/ui",
"dayjs": "1.11.23", "dayjs": "1.11.23",
"i18next": "26.5.10", "i18next": "26.4.3",
"motion": "13.5.1", "motion": "13.5.1",
"openapi-fetch": "0.17.0", "openapi-fetch": "0.17.0",
"openapi-react-query": "0.5.4", "openapi-react-query": "0.5.4",
"react": "19.3.1", "react": "19.3.0",
"react-dom": "19.3.1", "react-dom": "19.3.0",
"react-hook-form": "7.91.0", "react-hook-form": "7.89.0",
"react-i18next": "17.1.8", "react-i18next": "17.0.16",
"react-is": "19.3.1", "react-is": "19.3.0",
"react-router": "8.5.0", "react-router": "8.4.0",
"recharts": "3.10.1", "recharts": "3.10.1",
"zod": "4.6.7" "zod": "4.6.5"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "8.0.9", "@babel/core": "8.0.7",
"@commitlint/cli": "21.2.4", "@commitlint/cli": "21.2.3",
"@commitlint/config-conventional": "21.2.4", "@commitlint/config-conventional": "21.2.3",
"@eslint-react/eslint-plugin": "5.28.8", "@eslint-react/eslint-plugin": "5.24.10",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@release-it/conventional-changelog": "12.0.2", "@release-it/conventional-changelog": "12.0.2",
"@rolldown/plugin-babel": "0.2.4", "@rolldown/plugin-babel": "0.2.4",
"@storybook/addon-docs": "10.6.3", "@storybook/addon-docs": "10.6.1",
"@storybook/react-vite": "10.6.3", "@storybook/react-vite": "10.6.1",
"@stylistic/eslint-plugin": "5.10.0", "@stylistic/eslint-plugin": "5.10.0",
"@tanstack/eslint-plugin-query": "5.104.5", "@tanstack/eslint-plugin-query": "5.104.1",
"@testing-library/jest-dom": "7.0.1", "@testing-library/jest-dom": "7.0.1",
"@testing-library/react": "16.3.3", "@testing-library/react": "16.3.3",
"@types/node": "24.19.2", "@types/node": "24.19.2",
"@types/react": "19.3.0", "@types/react": "19.3.0",
"@types/react-dom": "19.3.0", "@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "6.1.2", "@vitejs/plugin-react": "6.1.2",
"@vitest/browser": "5.0.5", "@vitest/browser-playwright": "5.0.3",
"@vitest/browser-playwright": "5.0.5", "@vitest/coverage-v8": "5.0.3",
"@vitest/coverage-v8": "5.0.5",
"@vitest/eslint-plugin": "1.6.27", "@vitest/eslint-plugin": "1.6.27",
"@vitest/ui": "5.0.5",
"babel-plugin-react-compiler": "1.0.0", "babel-plugin-react-compiler": "1.0.0",
"conventional-changelog-conventionalcommits": "10.4.1", "conventional-changelog-conventionalcommits": "10.4.1",
"dependency-cruiser": "18.7.0", "dependency-cruiser": "18.5.0",
"eslint": "10.13.0", "eslint": "10.12.0",
"eslint-import-resolver-typescript": "4.4.5", "eslint-import-resolver-typescript": "4.4.5",
"eslint-plugin-import-x": "4.17.1", "eslint-plugin-import-x": "4.17.1",
"eslint-plugin-jest-dom": "5.10.1", "eslint-plugin-jest-dom": "5.10.1",
"eslint-plugin-no-unsanitized": "4.1.5", "eslint-plugin-no-unsanitized": "4.1.5",
"eslint-plugin-perfectionist": "5.13.0", "eslint-plugin-perfectionist": "5.12.1",
"eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.5.7", "eslint-plugin-react-refresh": "0.5.7",
"eslint-plugin-regexp": "3.3.1", "eslint-plugin-regexp": "3.3.1",
"eslint-plugin-security": "4.2.0", "eslint-plugin-security": "4.2.0",
"eslint-plugin-sonarjs": "4.2.2", "eslint-plugin-sonarjs": "4.2.2",
"eslint-plugin-storybook": "10.6.3", "eslint-plugin-storybook": "10.6.1",
"eslint-plugin-testing-library": "7.16.2", "eslint-plugin-testing-library": "7.16.2",
"eslint-plugin-unicorn": "76.0.0", "eslint-plugin-unicorn": "76.0.0",
"globals": "17.13.0", "globals": "17.13.0",
"husky": "9.1.7", "husky": "9.1.7",
"jsdom": "30.1.3", "jsdom": "30.1.2",
"knip": "6.43.0", "knip": "6.41.0",
"lint-staged": "17.6.0", "lint-staged": "17.6.0",
"minimatch": "file:scripts/minimatch-compat",
"msw": "2.15.0", "msw": "2.15.0",
"openapi-typescript": "7.13.0", "openapi-typescript": "7.13.0",
"playwright": "1.64.1", "playwright": "1.64.0",
"release-it": "21.1.1", "release-it": "21.1.1",
"storybook": "10.6.3", "storybook": "10.6.1",
"typescript": "5.9.3", "typescript": "5.9.3",
"typescript-eslint": "8.72.0", "typescript-eslint": "8.71.1",
"vite": "8.3.5", "vite": "8.3.4",
"vite-plugin-pwa": "2.0.0", "vite-plugin-pwa": "1.3.0",
"vitest": "5.0.5" "vitest": "5.0.3"
}, },
"overrides": { "overrides": {
"release-it": { "release-it": {
"basic-ftp": "6.2.3", "basic-ftp": "6.2.3",
"undici": "8.12.3" "undici": "7.30.0"
}, }
"@vitest/browser": "5.0.5",
"@vitest/ui": "5.0.5",
"@vitest/mocker": "5.0.5",
"minimatch": "$minimatch",
"glob": "14.0.3"
}, },
"allowScripts": { "allowScripts": {
"esbuild@0.28.2": true, "esbuild@0.28.2": true,
+4 -4
View File
@@ -20,14 +20,14 @@
"build": "vite build && tsc -p tsconfig.json" "build": "vite build && tsc -p tsconfig.json"
}, },
"peerDependencies": { "peerDependencies": {
"react": "19.3.1" "react": "19.3.0"
}, },
"devDependencies": { "devDependencies": {
"@storybook/react-vite": "10.6.3", "@storybook/react-vite": "10.6.1",
"@testing-library/react": "16.3.3", "@testing-library/react": "16.3.3",
"@types/react": "19.3.0", "@types/react": "19.3.0",
"typescript": "5.9.3", "typescript": "5.9.3",
"vite": "8.3.5", "vite": "8.3.4",
"vitest": "5.0.5" "vitest": "5.0.3"
} }
} }
+8 -8
View File
@@ -27,18 +27,18 @@
"@wealthledger/tokens": "0.3.0" "@wealthledger/tokens": "0.3.0"
}, },
"peerDependencies": { "peerDependencies": {
"@mantine/core": "9.7.3", "@mantine/core": "9.7.1",
"@mantine/hooks": "9.7.3", "@mantine/hooks": "9.7.1",
"react": "19.3.1", "react": "19.3.0",
"react-dom": "19.3.1" "react-dom": "19.3.0"
}, },
"devDependencies": { "devDependencies": {
"@storybook/react-vite": "10.6.3", "@storybook/react-vite": "10.6.1",
"@testing-library/react": "16.3.3", "@testing-library/react": "16.3.3",
"@types/react": "19.3.0", "@types/react": "19.3.0",
"storybook": "10.6.3", "storybook": "10.6.1",
"typescript": "5.9.3", "typescript": "5.9.3",
"vite": "8.3.5", "vite": "8.3.4",
"vitest": "5.0.5" "vitest": "5.0.3"
} }
} }
+2 -7
View File
@@ -16,7 +16,7 @@ type EntryCardProps = {
tone: "negative" tone: "negative"
| "neutral" | "neutral"
| "positive"; | "positive";
value: ReactNode; value: string;
valueLabel: string; valueLabel: string;
wrap?: boolean; wrap?: boolean;
}; };
@@ -32,7 +32,6 @@ const EntryCard: FC<EntryCardProps> = (props) => {
const valueContainerClass = props.actions ? classes.valueFooter : undefined; const valueContainerClass = props.actions ? classes.valueFooter : undefined;
const valueLabelClass = `${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`; const valueLabelClass = `${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`;
const valueComponent = typeof props.value === "string" ? undefined : "div";
return ( return (
<Paper <Paper
@@ -64,11 +63,7 @@ const EntryCard: FC<EntryCardProps> = (props) => {
</Stack> </Stack>
<Stack className={classes.value} gap={2}> <Stack className={classes.value} gap={2}>
<Text <Text className={classes[props.tone]} fw={700}>
className={classes[props.tone]}
component={valueComponent}
fw={700}
>
{props.value} {props.value}
</Text> </Text>
+45
View File
@@ -0,0 +1,45 @@
import { spawnSync } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import nodePath from "node:path";
const temporaryDirectory = await mkdtemp(
nodePath.join(tmpdir(), "wealth-ledger-openapi-"),
);
const generatedPath = nodePath.join(temporaryDirectory, "schema.d.ts");
const committedPath = nodePath.resolve("src/shared/api/generated/schema.d.ts");
const cliPath = nodePath.resolve("node_modules/openapi-typescript/bin/cli.js");
let exitCode = 0;
try {
const generated = spawnSync(
process.execPath,
[cliPath, "src/shared/api/spec/openapi.json", "-o", generatedPath],
{ encoding: "utf8" },
);
if (generated.status === 0) {
const [committed, current] = await Promise.all([
readFile(committedPath, "utf8"),
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
readFile(generatedPath, "utf8"),
]);
if (committed !== current) {
console.error(
"Generated OpenAPI types are stale. Run `npm run openapi:generate` and commit the result.",
);
exitCode = 1;
}
}
else {
process.stderr.write(generated.stderr || generated.stdout);
exitCode = generated.status ?? 1;
}
}
finally {
await rm(temporaryDirectory, { force: true, recursive: true });
}
process.exitCode = exitCode;
-9
View File
@@ -1,9 +0,0 @@
// ponytail: CommonJS twin of index.js (import-x ships lib/index.cjs too).
const minimatch = require("minimatch-11");
module.exports = {
...minimatch,
makeRe: (pattern, options = {}) => ({
test: path => minimatch.minimatch(path, pattern, options),
}),
};
-11
View File
@@ -1,11 +0,0 @@
// ponytail: shim for eslint-plugin-import-x@4.17.1, which still imports the
// makeRe export removed in minimatch 11. Its only caller (no-internal-modules)
// uses the result via .test(), so a matcher object is enough. Delete this
// package and the override once import-x supports minimatch 11.
import { minimatch } from "minimatch-11";
export * from "minimatch-11";
export const makeRe = (pattern, options = {}) => ({
test: path => minimatch(path, pattern, options),
});
-14
View File
@@ -1,14 +0,0 @@
{
"name": "minimatch",
"version": "11.0.2-compat.0",
"private": true,
"description": "minimatch 11 plus the makeRe export eslint-plugin-import-x 4.17.1 still imports. Remove when import-x supports minimatch 11.",
"type": "module",
"exports": {
"import": "./index.js",
"require": "./index.cjs"
},
"dependencies": {
"minimatch-11": "npm:minimatch@11.0.2"
}
}
+1 -1
View File
@@ -85,7 +85,7 @@ test("ESLint enforces the frontend profile and excludes generated artifacts", as
"security/detect-non-literal-regexp", "security/detect-non-literal-regexp",
], ],
[ [
"scripts/test-pre-commit.mjs", "scripts/check-openapi.mjs",
"import { exec } from \"node:child_process\"; exec(process.argv[2]);", "import { exec } from \"node:child_process\"; exec(process.argv[2]);",
"security/detect-child-process", "security/detect-child-process",
], ],
-40
View File
@@ -47,12 +47,6 @@ test("pre-commit scans the index, redacts findings and fails closed", () => {
} }
git("init", "--quiet"); git("init", "--quiet");
// The hook must run the installed lint-staged binary, never download one.
const localBin = nodePath.join(cwd, "node_modules", ".bin");
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
mkdirSync(localBin, { recursive: true });
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
writeFileSync(nodePath.join(localBin, "lint-staged"), "#!/bin/sh\necho ran >> downstream\n", { mode: 0o755 });
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
writeFileSync(nodePath.join(cwd, "sample.txt"), "safe staged content\n"); writeFileSync(nodePath.join(cwd, "sample.txt"), "safe staged content\n");
git("add", "sample.txt"); git("add", "sample.txt");
@@ -83,37 +77,3 @@ test("pre-commit scans the index, redacts findings and fails closed", () => {
rmSync(cwd, { force: true, recursive: true }); rmSync(cwd, { force: true, recursive: true });
} }
}); });
test("pre-commit never downloads tools and blocks when lint-staged is missing", () => {
const hook = nodePath.resolve(".husky/pre-commit");
const cwd = mkdtempSync(nodePath.join(tmpdir(), "frontend-hook-"));
const bin = nodePath.join(cwd, "bin");
const marker = nodePath.join(cwd, "downstream");
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
mkdirSync(bin);
// Gitleaks passes; npx/npm must never be reached, even if present on PATH.
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
writeFileSync(nodePath.join(bin, "gitleaks"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
for (const command of ["npx", "npm"]) {
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
writeFileSync(nodePath.join(bin, command), "#!/bin/sh\necho ran >> downstream\n", { mode: 0o755 });
}
const result = spawnSync("/bin/sh", ["-e", hook], {
cwd,
encoding: "utf8",
env: { ...process.env, PATH: `${bin}${nodePath.delimiter}${process.env.PATH}` },
});
assert.notEqual(result.status, 0, "missing lint-staged must block commit");
assert.match(result.stderr, /lint-staged is not installed/);
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
assert.ok(!existsSync(marker), "hook must not invoke npx or npm");
}
finally {
rmSync(cwd, { force: true, recursive: true });
}
});
+15 -51
View File
@@ -11,28 +11,19 @@ import {
} from "@testing-library/react"; } from "@testing-library/react";
import { http, HttpResponse } from "msw"; import { http, HttpResponse } from "msw";
import { MemoryRouter, useLocation } from "react-router"; import { MemoryRouter, useLocation } from "react-router";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import type { BrokerageOperation } from "@/entities/broker-operation"; import type { BrokerageOperation } from "@/entities/broker-operation";
import type { IncomeAnalytics } from "@/entities/income"; import type { IncomeAnalytics } from "@/entities/income";
import type { Portfolio } from "@/entities/portfolio"; import type { Portfolio } from "@/entities/portfolio";
import { AuthProvider } from "@/features/auth"; import { AuthProvider } from "@/features/auth";
import { defaultPeriod, formatDate } from "@/shared/lib";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource"; import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { AppRoutes } from "./AppRoutes"; import { AppRoutes } from "./AppRoutes";
// Mantine calendar labels follow dayjs default (English) locale.
function dayLabel(date: string) {
const monthName = new Intl.DateTimeFormat("en", { month: "long" })
.format(new Date(`${date}T12:00:00Z`));
return `${Number(date.slice(8))} ${monthName} ${date.slice(0, 4)}`;
}
const accounts = [ const accounts = [
{ id: "a", name: "Основной" }, { id: "a", name: "Основной" },
{ id: "b", name: "ИИС" }, { id: "b", name: "ИИС" },
@@ -46,10 +37,8 @@ const position: Portfolio["positions"][number]
displayName: "Сбербанк", displayName: "Сбербанк",
figi: "SBER", figi: "SBER",
isin: null, isin: null,
issuer: null,
logoName: null, logoName: null,
quantity: "2", quantity: "2",
sector: null,
ticker: "SBER", ticker: "SBER",
type: "STOCK", type: "STOCK",
}; };
@@ -106,6 +95,10 @@ const analytics: IncomeAnalytics
}, },
], ],
period: { from: "2026-08-01", to: "2026-08-31" }, period: { from: "2026-08-01", to: "2026-08-31" },
previous: {
period: { from: "2026-07-01", to: "2026-07-31" },
totalsByCurrency: [],
},
totalsByCurrency: [ totalsByCurrency: [
{ {
coupon: { currency: "RUB", value: "2" }, coupon: { currency: "RUB", value: "2" },
@@ -114,6 +107,7 @@ const analytics: IncomeAnalytics
total: { currency: "RUB", value: "2" }, total: { currency: "RUB", value: "2" },
}, },
], ],
yearComparison: null,
}; };
const CurrentLocation: FC = () => { const CurrentLocation: FC = () => {
@@ -147,7 +141,6 @@ function renderPage(path: string) {
return client; return client;
} }
afterEach(() => vi.useRealTimers());
beforeEach(() => { beforeEach(() => {
server.use( server.use(
http.post("*/api/v1/auth/refresh", () => http.post("*/api/v1/auth/refresh", () =>
@@ -245,7 +238,7 @@ describe("All accounts routes", () => {
); );
}); });
it.each(["operations", "income"])( it.each(["operations", "events", "income"])(
"restores applied dates when switching accounts in %s", "restores applied dates when switching accounts in %s",
async (section) => { async (section) => {
const search = "?from=2026-08-01&to=2026-08-31"; const search = "?from=2026-08-01&to=2026-08-31";
@@ -273,33 +266,6 @@ describe("All accounts routes", () => {
}, },
); );
it("restores applied dates when switching accounts in events", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-10-15T12:00:00Z"));
// The events calendar blocks past dates, so pick within selectable dates.
const search = `?from=${defaultPeriod()[0]}&to=${defaultPeriod()[1]}`;
renderPage(`/accounts/all/events${search}`);
const picker = await screen.findByLabelText("Период");
fireEvent.click(picker);
const [fromDate, toDate] = defaultPeriod();
fireEvent.click(
await screen.findByRole("button", { name: dayLabel(fromDate) }),
);
fireEvent.click(
await screen.findByRole("button", { name: dayLabel(toDate) }),
);
expect(screen.getByLabelText("Период")).toHaveTextContent(
`${formatDate(fromDate)} – ${formatDate(toDate)}`,
);
fireEvent.click(screen.getByRole("combobox", { name: "Счёт" }));
fireEvent.click(await screen.findByRole("option", { name: "ИИС" }));
await waitFor(() =>
expect(screen.getByLabelText("Текущий путь")).toHaveTextContent(
`/accounts/b/events${search}`,
),
);
});
it("groups the same security and links its accounts and overview sections", async () => { it("groups the same security and links its accounts and overview sections", async () => {
renderPage("/accounts/all"); renderPage("/accounts/all");
expect(await screen.findAllByText("Сбербанк")).toHaveLength(1); expect(await screen.findAllByText("Сбербанк")).toHaveLength(1);
@@ -308,16 +274,14 @@ describe("All accounts routes", () => {
"href", "href",
`/accounts/${account.id}`, `/accounts/${account.id}`,
); );
for (const suffix of ["operations", "income", "events"])
for (const [suffix, tab] of [ expect(
["operations", "Операции"], screen
["income", "Доход"], .getAllByRole("link")
["events", "События"], .find(
]) { link => link.getAttribute("href") === `/accounts/all/${suffix}`,
fireEvent.click(screen.getByRole("tab", { name: tab })); ),
expect(screen.getByRole("link", { name: "Подробнее" })) ).toBeInTheDocument();
.toHaveAttribute("href", `/accounts/all/${suffix}`);
}
}); });
it("switches accounts within operations with applied filters and a fresh cursor", async () => { it("switches accounts within operations with applied filters and a fresh cursor", async () => {
+2 -27
View File
@@ -190,30 +190,6 @@ describe("AppRoutes", () => {
expect(document.title).toBe("Карты — WealthLedger"); expect(document.title).toBe("Карты — WealthLedger");
}); });
it("offers a retry instead of the login form while the server is unreachable, then restores the route", async () => {
let isOutage = true;
server.use(
http.post("*/api/v1/auth/refresh", () =>
isOutage
? new HttpResponse(null, { status: 502 })
: HttpResponse.json({
accessToken: "test-token",
user: { email: "user@example.com", id: "user-1" },
}),
),
);
renderRoutes("/accounts");
expect(await screen.findByRole("alert")).toHaveTextContent("Не удалось подключиться");
expect(screen.queryByRole("heading", { name: "Вход" })).not.toBeInTheDocument();
isOutage = false;
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
expect(
await screen.findByRole("heading", { level: 2, name: "Нет инвестиционных счетов" }),
).toBeVisible();
});
it("lazy-loads the protected shell for an authenticated accounts route", async () => { it("lazy-loads the protected shell for an authenticated accounts route", async () => {
server.use( server.use(
http.post("*/api/v1/auth/refresh", () => http.post("*/api/v1/auth/refresh", () =>
@@ -232,8 +208,7 @@ describe("AppRoutes", () => {
}), }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
screen.getAllByRole("navigation", { name: "Основная навигация" }), screen.getByRole("navigation", { name: "WealthLedger" }),
).toHaveLength(2); ).toBeInTheDocument();
expect(screen.queryByRole("banner")).not.toBeInTheDocument();
}); });
}); });
@@ -10,6 +10,7 @@ import { useAuth } from "@/features/auth";
type BoundaryProps = PropsWithChildren<{ type BoundaryProps = PropsWithChildren<{
authenticated: boolean; authenticated: boolean;
routeKey: string;
}>; }>;
type RouteErrorFallbackProps = { type RouteErrorFallbackProps = {
@@ -25,6 +26,12 @@ class Boundary extends Component<BoundaryProps, State> {
override state: State = { hasError: false }; override state: State = { hasError: false };
override componentDidUpdate(previousProps: BoundaryProps) {
if (previousProps.routeKey !== this.props.routeKey && this.state.hasError) {
this.setState({ hasError: false });
}
}
override render() { override render() {
return this.state.hasError return this.state.hasError
? <RouteErrorFallback authenticated={this.props.authenticated} /> ? <RouteErrorFallback authenticated={this.props.authenticated} />
@@ -63,7 +70,10 @@ const RouteErrorBoundary: FC<PropsWithChildren> = (props) => {
const { user } = useAuth(); const { user } = useAuth();
return ( return (
<Boundary authenticated={Boolean(user)} key={`${pathname}${search}:${key}`}> <Boundary
authenticated={Boolean(user)}
routeKey={`${pathname}${search}:${key}`}
>
{children} {children}
</Boundary> </Boundary>
); );
+1 -18
View File
@@ -1,7 +1,4 @@
import { Button, Container } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import { type FC, type PropsWithChildren, useState } from "react"; import { type FC, type PropsWithChildren, useState } from "react";
import { useTranslation } from "react-i18next";
import { Navigate, useLocation } from "react-router"; import { Navigate, useLocation } from "react-router";
import { useAuth } from "@/features/auth"; import { useAuth } from "@/features/auth";
@@ -10,26 +7,12 @@ import { RouteSkeleton } from "./RouteFallback";
const Protected: FC<PropsWithChildren> = (props) => { const Protected: FC<PropsWithChildren> = (props) => {
const { children } = props; const { children } = props;
const { ready, retry, signedOut, unavailable, user } = useAuth(); const { ready, signedOut, user } = useAuth();
const { t } = useTranslation();
const location = useLocation(); const location = useLocation();
const [wasAuthenticated, setWasAuthenticated] = useState(false); const [wasAuthenticated, setWasAuthenticated] = useState(false);
if (user && !wasAuthenticated) setWasAuthenticated(true); if (user && !wasAuthenticated) setWasAuthenticated(true);
if (!ready) return <RouteSkeleton variant="shell" />; if (!ready) return <RouteSkeleton variant="shell" />;
// The server was unreachable (e.g. mid-deploy), so the session is unproven, not gone.
if (!user && unavailable)
return (
<Container py="xl" size={560}>
<StateView
action={<Button onClick={retry}>{t("auth.retry")}</Button>}
error
text={t("auth.unavailableText")}
title={t("auth.unavailableTitle")}
/>
</Container>
);
const loginState = signedOut && wasAuthenticated const loginState = signedOut && wasAuthenticated
? undefined ? undefined
: { returnTo: `${location.pathname}${location.search}` }; : { returnTo: `${location.pathname}${location.search}` };
+16 -82
View File
@@ -1,22 +1,12 @@
.root {
--bottom-nav-height: 56px;
}
.content { .content {
max-width: 1120px; max-width: 1120px;
margin-inline: auto; margin-inline: auto;
} }
.brand { .header {
align-items: center; align-items: center;
border-bottom: 1px solid var(--mantine-color-gray-3);
display: flex; display: flex;
min-height: 60px; padding: 0 var(--mantine-spacing-md);
padding-inline: var(--mantine-spacing-sm);
}
.navList {
padding: var(--mantine-spacing-sm);
} }
.bottomNav { .bottomNav {
@@ -62,85 +52,29 @@
.bottomNav { .bottomNav {
background: var(--mantine-color-white); background: var(--mantine-color-white);
border-top: 1px solid var(--mantine-color-gray-3); border-top: 1px solid var(--mantine-color-gray-3);
display: grid; bottom: 0;
grid-template-columns: repeat(5, minmax(0, 1fr));
inset: auto 0 0;
min-height: calc(var(--bottom-nav-height) + env(safe-area-inset-bottom, 0px));
padding-bottom: env(safe-area-inset-bottom, 0px);
padding-inline:
max(4px, env(safe-area-inset-left, 0px))
max(4px, env(safe-area-inset-right, 0px));
position: fixed;
z-index: var(--mantine-z-index-app);
}
.bottomNavItem {
align-items: center;
color: var(--mantine-color-gray-7);
display: flex; display: flex;
flex-direction: column; justify-content: space-around;
left: 0;
padding: var(--mantine-spacing-xs) 2px;
gap: 2px; gap: 2px;
justify-content: center; font-size: 11px;
min-height: var(--bottom-nav-height); & a {
min-width: 0; min-width: 0;
text-decoration: none; padding: 8px 2px;
text-align: center;
&:active .iconPill {
background-color: var(--mantine-color-gray-1);
}
&:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: -2px;
}
}
@media (hover: hover) {
.bottomNavItem:hover .iconPill {
background-color: var(--mantine-color-gray-1);
}
}
.iconPill {
align-items: center;
border-radius: 14px;
display: inline-flex;
height: 28px;
justify-content: center;
width: 48px;
}
.bottomNavLabel {
font-size: 12px;
font-weight: 500;
line-height: 16px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (prefers-reduced-motion: no-preference) {
.bottomNavItem,
.iconPill {
transition:
background-color 150ms ease-in-out,
color 150ms ease-in-out;
} }
position: fixed;
right: 0;
z-index: 200;
} }
.active { .active {
color: var(--mantine-primary-color-filled); color: var(--mantine-primary-color-filled);
font-weight: 600;
& .iconPill {
background-color: var(--mantine-primary-color-light);
}
} }
.main { .main {
padding-bottom: calc( padding-bottom: 5rem;
var(--bottom-nav-height) + env(safe-area-inset-bottom, 0px) +
var(--mantine-spacing-md)
);
} }
} }
-47
View File
@@ -1,47 +0,0 @@
import { MantineProvider } from "@mantine/core";
import { render, screen, within } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
import { AppShell } from "./AppShell";
function renderShell(path: string) {
return render(
<MantineProvider>
<MemoryRouter initialEntries={[path]}>
<AppShell />
</MemoryRouter>
</MantineProvider>,
);
}
describe("AppShell", () => {
it("has no header banner", () => {
renderShell("/cards");
expect(screen.queryByRole("banner")).not.toBeInTheDocument();
});
it("marks only the current bottom navigation item", () => {
renderShell("/cards");
const bottomNav = screen
.getAllByRole("navigation", { name: "Основная навигация" })
.at(-1);
if (!bottomNav) {
throw new Error("Bottom navigation is not rendered");
}
const links = within(bottomNav).getAllByRole("link");
expect(links).toHaveLength(5);
expect(within(bottomNav).getByRole("link", { name: "Карты" })).toHaveAttribute(
"aria-current",
"page",
);
expect(
within(bottomNav).getByRole("link", { name: "Счета" }),
).not.toHaveAttribute("aria-current");
});
});
+42 -49
View File
@@ -17,12 +17,6 @@ type AppShellProps = {
contentAboveOutlet?: ReactNode; contentAboveOutlet?: ReactNode;
}; };
function getBottomNavItemClass({ isActive }: { isActive: boolean }) {
return isActive
? `${classes.bottomNavItem} ${classes.active}`
: classes.bottomNavItem;
}
const AppShell: FC<AppShellProps> = (props) => { const AppShell: FC<AppShellProps> = (props) => {
const { contentAboveOutlet } = props; const { contentAboveOutlet } = props;
const { t } = useTranslation(); const { t } = useTranslation();
@@ -30,31 +24,31 @@ const AppShell: FC<AppShellProps> = (props) => {
const items = [ const items = [
{ {
Icon: PortfolioIcon, icon: <PortfolioIcon size={20} />,
iconClassName: classes.portfolioIcon, iconClassName: classes.portfolioIcon,
label: t("navigation.accounts"), label: t("navigation.accounts"),
to: "/accounts", to: "/accounts",
}, },
{ {
Icon: CardsIcon, icon: <CardsIcon size={20} />,
iconClassName: classes.cardsIcon, iconClassName: classes.cardsIcon,
label: t("navigation.cards"), label: t("navigation.cards"),
to: "/cards", to: "/cards",
}, },
{ {
Icon: StocksIcon, icon: <StocksIcon size={20} />,
iconClassName: classes.stocksIcon, iconClassName: classes.stocksIcon,
label: t("navigation.stocks"), label: t("navigation.stocks"),
to: "/stocks", to: "/stocks",
}, },
{ {
Icon: BondsIcon, icon: <BondsIcon size={20} />,
iconClassName: classes.bondsIcon, iconClassName: classes.bondsIcon,
label: t("navigation.bonds"), label: t("navigation.bonds"),
to: "/bonds", to: "/bonds",
}, },
{ {
Icon: SettingsIcon, icon: <SettingsIcon size={20} />,
iconClassName: classes.settingsIcon, iconClassName: classes.settingsIcon,
label: t("navigation.settings"), label: t("navigation.settings"),
to: "/settings", to: "/settings",
@@ -63,32 +57,32 @@ const AppShell: FC<AppShellProps> = (props) => {
return ( return (
<MantineAppShell <MantineAppShell
className={classes.root} header={{ height: 60 }}
navbar={{ breakpoint: "sm", collapsed: { mobile: true }, width: 220 }} navbar={{ breakpoint: "sm", collapsed: { mobile: true }, width: 220 }}
padding="md" padding="md"
> >
<MantineAppShell.Navbar aria-label={t("navigation.primary")}> <MantineAppShell.Header className={classes.header}>
<div className={classes.brand}> <Text fw={700}>{t("appName")}</Text>
<Text fw={700}>{t("appName")}</Text> </MantineAppShell.Header>
</div>
<div className={classes.navList}> <MantineAppShell.Navbar p="sm">
{items.map(item => ( {items.map(item => (
<NavLink <NavLink
active={pathname.startsWith(item.to)} active={pathname.startsWith(item.to)}
className={classes.navLink} className={classes.navLink}
component={RouterNavLink} component={RouterNavLink}
key={item.to} key={item.to}
label={item.label} label={item.label}
leftSection={( leftSection={(
<span className={`${classes.navIcon} ${item.iconClassName}`}> <span
<item.Icon size={20} /> className={`${classes.navIcon} ${item.iconClassName ?? ""}`}
</span> >
)} {item.icon}
to={item.to} </span>
/> )}
))} to={item.to}
</div> />
))}
</MantineAppShell.Navbar> </MantineAppShell.Navbar>
<MantineAppShell.Main className={classes.main}> <MantineAppShell.Main className={classes.main}>
@@ -99,23 +93,22 @@ const AppShell: FC<AppShellProps> = (props) => {
</div> </div>
</MantineAppShell.Main> </MantineAppShell.Main>
<nav <nav aria-label={t("appName")} className={classes.bottomNav}>
aria-label={t("navigation.primary")} {items.map((item) => {
className={classes.bottomNav} const appearanceClass = pathname.startsWith(item.to)
> ? classes.active
{items.map(item => ( : undefined;
<RouterNavLink
className={getBottomNavItemClass}
key={item.to}
to={item.to}
>
<span className={classes.iconPill}>
<item.Icon size={24} />
</span>
<span className={classes.bottomNavLabel}>{item.label}</span> return (
</RouterNavLink> <RouterNavLink
))} className={appearanceClass}
key={item.to}
to={item.to}
>
{item.label}
</RouterNavLink>
);
})}
</nav> </nav>
</MantineAppShell> </MantineAppShell>
); );
@@ -1,7 +0,0 @@
function errorCode(error: unknown): string | undefined {
return typeof error === "object" && error !== null && "code" in error
? String(error.code)
: undefined;
}
export { errorCode };
+1 -3
View File
@@ -6,7 +6,6 @@ import { calendarDate } from "@/shared/lib";
import { bankToday } from "./form-validation"; import { bankToday } from "./form-validation";
import { moneyCents } from "./money"; import { moneyCents } from "./money";
const MAX_CARD_NAME_LENGTH = 100; const MAX_CARD_NAME_LENGTH = 100;
const paymentSystemValues = ["mir", "visa", "mastercard"] as const;
const MIN_BALANCE_KOPECKS = -9_223_372_036_854_775_808n; const MIN_BALANCE_KOPECKS = -9_223_372_036_854_775_808n;
const MAX_BALANCE_KOPECKS = 9_223_372_036_854_775_807n; const MAX_BALANCE_KOPECKS = 9_223_372_036_854_775_807n;
@@ -64,7 +63,7 @@ const bankCardSchema = z.object({
openingBalance: moneySchema, openingBalance: moneySchema,
openingDate: bankDateSchema, openingDate: bankDateSchema,
paymentSystem: z paymentSystem: z
.enum(["", ...paymentSystemValues], { .enum(["", "mir", "visa", "mastercard"], {
error: validationMessage("bank.validation.paymentSystem"), error: validationMessage("bank.validation.paymentSystem"),
}) })
.optional(), .optional(),
@@ -76,6 +75,5 @@ export {
bankCardSchema, bankCardSchema,
type BankCardValues, type BankCardValues,
bankDateSchema, bankDateSchema,
paymentSystemValues,
positiveMoneySchema, positiveMoneySchema,
}; };
@@ -1,7 +1,8 @@
import { useController, useFormContext } from "react-hook-form"; import { useController, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { type BankCardValues, paymentSystemValues } from "../../model/form.schema"; import type { BankCardValues } from "../../model/form.schema";
import { bankDateField } from "../bankDateField"; import { bankDateField } from "../bankDateField";
function useBankCardFieldsVm() { function useBankCardFieldsVm() {
@@ -15,7 +16,7 @@ function useBankCardFieldsVm() {
const { field: openingDateField } = useController({ control, name: "openingDate" }); const { field: openingDateField } = useController({ control, name: "openingDate" });
const { field: paymentSystemField } = useController({ control, name: "paymentSystem" }); const { field: paymentSystemField } = useController({ control, name: "paymentSystem" });
const paymentSystems = paymentSystemValues.map(value => ({ const paymentSystems = ["mir", "visa", "mastercard"].map(value => ({
label: t(`bank.paymentSystems.${value}`), label: t(`bank.paymentSystems.${value}`),
value, value,
})); }));
+2 -2
View File
@@ -3,7 +3,7 @@ import type { FC } from "react";
import { Alert, Button, Group, Text } from "@mantine/core"; import { Alert, Button, Group, Text } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { errorCode } from "../model/error-code"; import { apiErrorCode } from "@/shared/api";
type BankFeedbackProps = { type BankFeedbackProps = {
cached?: boolean; cached?: boolean;
@@ -14,7 +14,7 @@ type BankFeedbackProps = {
const BankFeedback: FC<BankFeedbackProps> = (props) => { const BankFeedback: FC<BankFeedbackProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const code = errorCode(props.error); const code = apiErrorCode(props.error);
return ( return (
<Alert color="orange" role="alert"> <Alert color="orange" role="alert">
@@ -10,7 +10,6 @@ import { formatMoney } from "@/shared/lib";
import type { BankSummary } from "../../api/types"; import type { BankSummary } from "../../api/types";
import { bankOperationAppearance } from "../../model/operation-appearance"; import { bankOperationAppearance } from "../../model/operation-appearance";
import { BankFeedback } from "../BankFeedback";
import classes from "./BankSummary.module.css"; import classes from "./BankSummary.module.css";
const CATEGORY_COLUMNS = 2; const CATEGORY_COLUMNS = 2;
@@ -18,10 +17,9 @@ const CATEGORY_COLUMNS = 2;
type BankSummaryViewProps = { type BankSummaryViewProps = {
all?: boolean; all?: boolean;
data?: BankSummary; data?: BankSummary;
error: unknown; error: boolean;
fetching: boolean; fetching: boolean;
loading: boolean; loading: boolean;
retry?: () => void;
}; };
const BankSummaryViewCategories: FC<BankSummaryViewProps> = (props) => { const BankSummaryViewCategories: FC<BankSummaryViewProps> = (props) => {
@@ -124,14 +122,7 @@ const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
<BankSummaryViewCategories {...props} /> <BankSummaryViewCategories {...props} />
{Boolean(props.error) && ( {props.error && !props.data && <Text role="alert">{t("bank.error")}</Text>}
<BankFeedback
cached={Boolean(props.data)}
error={props.error}
loadingError={!props.data}
retry={props.retry}
/>
)}
</Stack> </Stack>
); );
}; };
-2
View File
@@ -5,7 +5,5 @@ export {
type OperationCategory, type OperationCategory,
useBrokerageOperations, useBrokerageOperations,
} from "./api/useBrokerageOperations"; } from "./api/useBrokerageOperations";
export { brokerageOperationGroupKey } from "./model/display";
export { useBrokerageOperationHistory } from "./model/useBrokerageOperationHistory"; export { useBrokerageOperationHistory } from "./model/useBrokerageOperationHistory";
export { LedgerEntryRow } from "./ui/LedgerEntryRow"; export { LedgerEntryRow } from "./ui/LedgerEntryRow";
export { OperationGroup } from "./ui/OperationGroup";
@@ -1,12 +1,9 @@
import { describe, expect, it } from "vitest"; import { expect, it } from "vitest";
import type { BrokerageOperation } from "../api/useBrokerageOperations"; import { operationLabelKey } from "./display";
import { brokerageOperationGroupKey, operationLabelKey } from "./display";
it.each([ it.each([
["OPERATION_TYPE_BUY", "buy"], ["OPERATION_TYPE_BUY", "buy"],
["OPERATION_TYPE_COUPON", "other"],
["OPERATION_TYPE_BUY_CARD", "buy"], ["OPERATION_TYPE_BUY_CARD", "buy"],
["OPERATION_TYPE_BUY_MARGIN", "buy"], ["OPERATION_TYPE_BUY_MARGIN", "buy"],
["OPERATION_TYPE_SELL", "sell"], ["OPERATION_TYPE_SELL", "sell"],
@@ -24,71 +21,3 @@ it.each([
])("preserves the wire operation label for %s", (type, label) => { ])("preserves the wire operation label for %s", (type, label) => {
expect(operationLabelKey(type)).toBe(`ledger.operationType.${label}`); expect(operationLabelKey(type)).toBe(`ledger.operationType.${label}`);
}); });
describe("brokerageOperationGroupKey", () => {
const base: BrokerageOperation = {
account: { id: "account-1", name: "Брокерский" },
accruedInterest: null,
category: "income",
commission: null,
description: "Купон",
figi: "FIGI",
id: "operation",
instrumentType: "bond",
instrumentUid: "UID",
logoName: null,
occurredAt: "2026-08-14T10:00:00Z",
payment: null,
price: null,
quantity: null,
ticker: "BOND",
type: "OPERATION_TYPE_COUPON",
};
it("uses screen day, preferred identity and exact type, not account", () => {
const first = brokerageOperationGroupKey(base);
const same = brokerageOperationGroupKey({
...base,
account: { id: "account-2", name: "ИИС" },
figi: "OTHER-FIGI",
occurredAt: "2026-08-14T11:00:00Z",
});
expect(same).toBe(first);
expect(brokerageOperationGroupKey({
...base,
type: "OPERATION_TYPE_DIVIDEND",
})).not.toBe(first);
});
it("keeps buy and sell of one instrument in different groups", () => {
expect(brokerageOperationGroupKey({ ...base, type: "OPERATION_TYPE_BUY" }))
.not.toBe(brokerageOperationGroupKey({
...base,
type: "OPERATION_TYPE_SELL",
}));
});
it("separates local midnight and joins one local day across UTC dates", () => {
const before = new Date(2026, 7, 14, 23, 59).toISOString();
const after = new Date(2026, 7, 15, 0, 1).toISOString();
const morning = new Date(2026, 7, 14, 1).toISOString();
const evening = new Date(2026, 7, 14, 23).toISOString();
expect(brokerageOperationGroupKey({ ...base, occurredAt: before }))
.not.toBe(brokerageOperationGroupKey({ ...base, occurredAt: after }));
expect(brokerageOperationGroupKey({ ...base, occurredAt: morning }))
.toBe(brokerageOperationGroupKey({ ...base, occurredAt: evening }));
});
it("falls back to FIGI and leaves missing identity ungrouped", () => {
expect(brokerageOperationGroupKey({ ...base, instrumentUid: null }))
.toBe(brokerageOperationGroupKey({ ...base, instrumentUid: "" }));
expect(brokerageOperationGroupKey({
...base,
figi: null,
instrumentUid: null,
})).toBeNull();
});
});
+2 -28
View File
@@ -1,4 +1,4 @@
import { formatMoney, formatSnapshotDay } from "@/shared/lib"; import { formatMoney } from "@/shared/lib";
import type { BrokerageOperation } from "../api/useBrokerageOperations"; import type { BrokerageOperation } from "../api/useBrokerageOperations";
@@ -27,19 +27,6 @@ type PaymentPresentation = {
| "positive"; | "positive";
}; };
function brokerageOperationGroupKey(
operation: BrokerageOperation,
): null | string {
const identity = operation.instrumentUid || operation.figi;
if (!identity) return null;
return JSON.stringify([
formatSnapshotDay(operation.occurredAt),
identity,
operation.type,
]);
}
function entryLabelKey(operation: BrokerageOperation): string { function entryLabelKey(operation: BrokerageOperation): string {
if (operation.category === "income") if (operation.category === "income")
return isOperationType(operation.type, OperationType.Coupon) return isOperationType(operation.type, OperationType.Coupon)
@@ -49,12 +36,6 @@ function entryLabelKey(operation: BrokerageOperation): string {
return `ledger.category.${operation.category}`; return `ledger.category.${operation.category}`;
} }
function isIncomePayment(operation: BrokerageOperation): boolean {
return [OperationType.Coupon, OperationType.Dividend].some(type =>
isOperationType(operation.type, type),
);
}
function isOperationType(value: unknown, expected: OperationType): boolean { function isOperationType(value: unknown, expected: OperationType): boolean {
return value === expected; return value === expected;
} }
@@ -128,11 +109,4 @@ function paymentPresentation(
}; };
} }
export { export { operationLabelKey, paymentPresentation };
brokerageOperationGroupKey,
entryLabelKey,
isIncomePayment,
isZeroDecimal,
operationLabelKey,
paymentPresentation,
};
@@ -11,11 +11,10 @@ import {
formatSnapshotTime, formatSnapshotTime,
instrumentLogoUrl, instrumentLogoUrl,
} from "@/shared/lib"; } from "@/shared/lib";
import { PayoutCard } from "@/shared/ui";
import type { BrokerageOperation } from "../api/useBrokerageOperations"; import type { BrokerageOperation } from "../api/useBrokerageOperations";
import { isIncomePayment, operationLabelKey, paymentPresentation } from "../model/display"; import { operationLabelKey, paymentPresentation } from "../model/display";
type LedgerEntryRowProps = { type LedgerEntryRowProps = {
operation: BrokerageOperation; operation: BrokerageOperation;
@@ -76,28 +75,6 @@ const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
} }
else details = undefined; else details = undefined;
if (isIncomePayment(operation)) {
const extraMetadata = [
instrument,
props.withDetails ? null : operation.account?.name,
].filter(Boolean).join(" · ");
return (
<PayoutCard
details={details}
extraMetadata={extraMetadata}
imageSrc={instrumentLogoUrl(operation.logoName)}
initial={(instrument.trim()[0] ?? "?").toUpperCase()}
kind="executed"
occurredAt={formatSnapshotTime(operation.occurredAt)}
payment={operation.payment}
title={operation.description || t(operationLabelKey(operation.type))}
typeLabel={t(paymentPresentation(operation).label)}
wrap={props.wrap}
/>
);
}
return ( return (
<EntryCard <EntryCard
details={details} details={details}
@@ -1,131 +0,0 @@
import { MantineProvider } from "@mantine/core";
import { render, screen, within } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
import type { BrokerageOperation } from "../api/useBrokerageOperations";
import { OperationGroup } from "./OperationGroup";
function operation(
id: string,
account: { id: string; name: string },
value: null | string,
currency = "RUB",
): BrokerageOperation {
return {
account,
accruedInterest: null,
category: "income",
commission: null,
description: "Купон",
figi: "FIGI",
id,
instrumentType: "bond",
instrumentUid: "UID",
logoName: null,
occurredAt: "2026-08-14T10:00:00Z",
payment: value === null ? null : { currency, value },
price: null,
quantity: null,
ticker: "BOND",
type: "OPERATION_TYPE_COUPON",
};
}
function renderGroup(
operations: BrokerageOperation[],
isTotalVisible = true,
) {
return render(
<MantineProvider>
<MemoryRouter>
<ul>
<OperationGroup
entries={operations.map((operation, index) => ({
key: `${operation.id}-${index}`,
operation,
}))}
totalVisible={isTotalVisible}
/>
</ul>
</MemoryRouter>
</MantineProvider>,
);
}
describe("OperationGroup", () => {
it("shows the header, exact total and separate card rows per account", () => {
renderGroup([
operation("1", { id: "broker", name: "Брокерский" }, "0.1"),
operation("2", { id: "iis", name: "ИИС" }, "0.2"),
]);
expect(screen.getByText("Купон · BOND")).toBeInTheDocument();
expect(screen.getByText("+0,3 ₽")).toBeInTheDocument();
const accounts = within(screen.getByRole("list", {
name: /Купон · BOND, Исполнено 14\.08\.2026.*, \+0,3 ₽/,
}));
expect(accounts.getByRole("listitem", { name: "Брокерский, +0,1 ₽" }))
.toHaveTextContent("Купон");
expect(accounts.getByRole("listitem", { name: "ИИС, +0,2 ₽" }))
.toHaveTextContent("Купон");
expect(accounts.queryByText(/Исполнено/)).not.toBeInTheDocument();
expect(accounts.queryByRole("link")).not.toBeInTheDocument();
});
it("announces the hidden total as unavailable", () => {
renderGroup([
operation("1", { id: "broker", name: "Брокерский" }, "100"),
operation("2", { id: "iis", name: "ИИС" }, null),
]);
expect(screen.getByRole("list", {
name: /Купон · BOND, Исполнено .*Итог недоступен/,
})).toBeInTheDocument();
});
it.each(["0", "0.000"])(
"hides the whole total for a zero source amount (%s)",
(value) => {
renderGroup([
operation("1", { id: "broker", name: "Брокерский" }, value),
operation("2", { id: "iis", name: "ИИС" }, "100"),
]);
expect(screen.getAllByText("+100 ₽")).toHaveLength(1);
expect(screen.getByText("Итог недоступен")).toBeInTheDocument();
},
);
it("hides the total for incomplete data but keeps compensated zero", () => {
const { unmount } = renderGroup([
operation("1", { id: "broker", name: "Брокерский" }, "100"),
operation("2", { id: "iis", name: "ИИС" }, "100"),
], false);
expect(screen.queryByText("+200 ₽")).not.toBeInTheDocument();
unmount();
renderGroup([
operation("1", { id: "broker", name: "Брокерский" }, "100"),
operation("2", { id: "iis", name: "ИИС" }, "-100"),
]);
expect(screen.getByText("0 ₽")).toBeInTheDocument();
});
it("renders repeated ids inside one group as separate rows", () => {
renderGroup([
operation("same", { id: "broker", name: "Брокерский" }, "10"),
operation("same", { id: "iis", name: "ИИС" }, "20"),
]);
expect(screen.getByText("+30 ₽")).toBeInTheDocument();
const accounts = within(screen.getByRole("list", { name: /Купон/ }));
expect(accounts.getAllByText("Брокерский")).toHaveLength(1);
expect(accounts.getAllByText("ИИС")).toHaveLength(1);
expect(accounts.getAllByRole("listitem")).toHaveLength(2);
});
});
@@ -1,96 +0,0 @@
import type { FC } from "react";
import { EntryCard } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import {
formatSnapshotDay,
instrumentLogoUrl,
sumMoney,
totalText,
} from "@/shared/lib";
import { AccountCardRow, EntryGroup, EntryGroupTotal } from "@/shared/ui";
import type { BrokerageOperation } from "../api/useBrokerageOperations";
import {
entryLabelKey,
isZeroDecimal,
operationLabelKey,
paymentPresentation,
} from "../model/display";
type OperationGroupProps = {
entries: { key: string; operation: BrokerageOperation }[];
totalVisible: boolean;
};
const OperationGroup: FC<OperationGroupProps> = (props) => {
const { t } = useTranslation();
const operations = props.entries.map(entry => entry.operation);
const first = operations.at(0);
// groupItems only emits groups of at least two entries.
if (!first) return null;
const typeLabel = t(first.category === "income"
? entryLabelKey(first)
: operationLabelKey(first.type));
const instrument
= first.ticker ?? first.instrumentType ?? t("ledger.noInstrument");
const amounts = operations.flatMap(operation =>
operation.payment ? [operation.payment] : [],
);
const isAmountsKnown = amounts.length === operations.length
&& amounts.every(amount => !isZeroDecimal(amount.value));
const totals = isAmountsKnown && props.totalVisible
? sumMoney(amounts)
: null;
const date = formatSnapshotDay(first.occurredAt);
const title = `${typeLabel} · ${instrument}`;
const totalsText = totalText(totals, true);
const accessibleName = [
title,
t("payout.executedAt", { date }),
totalsText || t("entryGroup.totalUnavailable"),
].join(", ");
return (
<EntryGroup
header={(
<EntryCard
imageSrc={instrumentLogoUrl(first.logoName)}
initial={(first.ticker?.trim()[0] ?? "?").toUpperCase()}
metadata={t("payout.executedAt", { date })}
title={title}
tone="neutral"
value={<EntryGroupTotal isSigned totals={totals} />}
valueLabel={typeLabel}
wrap
/>
)}
label={accessibleName}
>
{props.entries.map(({ key, operation }) => {
const payment = paymentPresentation(operation);
return (
<AccountCardRow
accountName={operation.account?.name ?? "—"}
key={key}
value={payment.amount}
valueLabel={t(payment.label)}
/>
);
})}
</EntryGroup>
);
};
export { OperationGroup };
@@ -8,15 +8,11 @@ const VISIBLE_CURRENCY_LIMIT = 2;
type BrokerageAccountCard = { type BrokerageAccountCard = {
account: BrokerageAccount; account: BrokerageAccount;
positionCounts: null | PositionCounts; positionCounts: null
| { bonds: number; stocks: number };
source?: NonNullable<InvestmentResourceMeta["dependencies"]>[number]; source?: NonNullable<InvestmentResourceMeta["dependencies"]>[number];
summary: summary:
| { | { remainingCurrencyCount: number; state: "available"; totals: Money[] }
portfolio: NonNullable<BrokerageAccount["portfolioSummary"]>;
remainingCurrencyCount: number;
state: "available";
totals: Money[];
}
| { state: "empty" } | { state: "empty" }
| { state: "unavailable" }; | { state: "unavailable" };
}; };
@@ -25,8 +21,6 @@ type Money = NonNullable<
BrokerageAccount["portfolioSummary"] BrokerageAccount["portfolioSummary"]
>["totalsByCurrency"][number]; >["totalsByCurrency"][number];
type PositionCounts = { bonds: number; stocks: number; total: number };
function accountTypeKey(type: BrokerageAccount["type"]) { function accountTypeKey(type: BrokerageAccount["type"]) {
return `accounts.types.${type}` as const; return `accounts.types.${type}` as const;
} }
@@ -54,7 +48,6 @@ function brokerageAccountCards(
const positionCounts = { const positionCounts = {
bonds: summary.bondPositionCount, bonds: summary.bondPositionCount,
stocks: summary.stockPositionCount, stocks: summary.stockPositionCount,
total: summary.totalPositionCount,
}; };
if (summary.totalsByCurrency.length === 0) if (summary.totalsByCurrency.length === 0)
@@ -65,7 +58,6 @@ function brokerageAccountCards(
positionCounts, positionCounts,
source, source,
summary: { summary: {
portfolio: summary,
remainingCurrencyCount: Math.max( remainingCurrencyCount: Math.max(
summary.totalsByCurrency.length - VISIBLE_CURRENCY_LIMIT, summary.totalsByCurrency.length - VISIBLE_CURRENCY_LIMIT,
0, 0,
@@ -1,144 +1,166 @@
.list { .list {
display: grid;
width: 100%; width: 100%;
margin: 0;
padding: 0;
gap: var(--mantine-spacing-sm);
list-style: none;
} }
.row { .card {
display: grid; min-width: 0;
grid-template-areas: gap: var(--mantine-spacing-xl);
"identity balance" background: var(--mantine-color-white);
"details balance" border-color: var(--mantine-color-gray-3);
"structure structure"; box-shadow: 0 4px 16px rgb(15 23 42 / 3%);
grid-template-columns: minmax(0, 1fr) minmax(0, auto);
gap: var(--mantine-spacing-sm) var(--mantine-spacing-xl);
align-items: center;
padding: var(--mantine-spacing-lg);
border: 1px solid var(--wl-color-border);
border-left: 3px solid var(--wl-color-primary);
border-radius: var(--mantine-radius-md);
background: var(--wl-color-surface);
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
transition: background-color 160ms ease, border-color 160ms ease; transition:
transform 160ms ease,
box-shadow 160ms ease,
border-color 160ms ease;
} }
.row:hover, .card:hover,
.row:focus-visible { .card:focus-visible {
border-color: var(--wl-color-primary); border-color: var(--mantine-color-blue-3);
background: var(--wl-color-subtle); box-shadow: 0 12px 28px rgb(15 23 42 / 8%);
transform: translateY(-3px);
} }
.row:focus-visible { .card:focus-visible {
outline: 2px solid var(--wl-color-primary); outline: 2px solid var(--mantine-color-blue-6);
outline-offset: -2px; outline-offset: 3px;
} }
.identity { .identity {
grid-area: identity;
min-width: 0; min-width: 0;
} }
.kicker {
padding: 5px 10px;
border-radius: var(--mantine-radius-sm);
background: var(--mantine-color-blue-0);
color: var(--mantine-color-blue-8);
letter-spacing: 0.02em;
}
.accountIcon {
width: 28px;
height: 28px;
flex-shrink: 0;
color: var(--mantine-color-blue-5);
}
.name { .name {
margin: 0; margin: 0;
color: var(--wl-color-text); font-size: var(--mantine-h3-font-size);
font-size: var(--mantine-font-size-md); line-height: 1.3;
line-height: var(--mantine-line-height-md);
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.accountType {
flex-shrink: 0;
align-self: flex-start;
padding: 2px var(--mantine-spacing-xs);
border-radius: var(--mantine-radius-sm);
}
.balance { .balance {
grid-area: balance; flex: 1;
min-width: 0;
text-align: right;
} }
.summary { .summary {
font-variant-numeric: tabular-nums lining-nums; font-variant-numeric: tabular-nums;
white-space: nowrap;
} }
.total { .total {
color: var(--wl-color-text); font-size: clamp(1.5rem, 2.8vw, 2.25rem);
letter-spacing: -0.035em;
line-height: 1.2;
overflow-wrap: anywhere;
}
.total + .total {
font-size: var(--mantine-font-size-lg); font-size: var(--mantine-font-size-lg);
line-height: var(--mantine-line-height-lg); font-weight: 500;
letter-spacing: 0;
color: var(--mantine-color-gray-7);
} }
.return, .positions {
.cashValue { display: grid;
font-variant-numeric: tabular-nums lining-nums; grid-template-columns: repeat(2, minmax(0, 1fr));
white-space: nowrap; gap: var(--mantine-spacing-sm);
margin: 0;
} }
.positive { .positions > div {
color: var(--wl-color-positive);
}
.negative {
color: var(--wl-color-negative);
}
.neutral {
color: var(--wl-color-muted);
}
.cash {
font-variant-numeric: tabular-nums lining-nums;
}
.details {
grid-area: details;
min-width: 0;
font-variant-numeric: tabular-nums lining-nums;
}
.structure {
grid-area: structure;
gap: var(--mantine-spacing-xs);
}
.structureBar {
display: flex; display: flex;
width: 100%; flex-wrap: wrap;
height: 6px; align-items: center;
overflow: hidden; justify-content: space-between;
border-radius: var(--mantine-radius-xs); gap: 4px var(--mantine-spacing-sm);
background: var(--wl-color-subtle); padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
border-radius: var(--mantine-radius-md);
} }
.structureSection { .stocks {
flex: none; background: var(--mantine-color-blue-0);
height: 100%; color: var(--mantine-color-blue-8);
background: var(--category-color);
} }
@media (max-width: 47.9375em) { .bonds {
.row { background: var(--mantine-color-grape-0);
grid-template-areas: color: var(--mantine-color-grape-8);
"identity" }
"balance"
"structure" .positions dt {
"details"; font-size: var(--mantine-font-size-sm);
grid-template-columns: minmax(0, 1fr); font-weight: 500;
gap: var(--mantine-spacing-sm); }
.positions dd {
margin: 0;
font-size: 1.75rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1.25;
overflow-wrap: anywhere;
}
.footer {
border-top: 1px solid var(--mantine-color-gray-2);
padding-top: var(--mantine-spacing-md);
}
.open {
display: inline-flex;
align-items: center;
gap: var(--mantine-spacing-xs);
margin-left: auto;
color: var(--mantine-color-blue-7);
}
.arrow {
font-size: var(--mantine-font-size-lg);
transition: transform 160ms ease;
}
.card:hover .arrow {
transform: translateX(3px);
}
@media (max-width: 36em) {
.card {
padding: var(--mantine-spacing-md); padding: var(--mantine-spacing-md);
gap: var(--mantine-spacing-lg);
} }
.balance { .positions > div {
text-align: left; flex-direction: column;
align-items: flex-start;
padding: var(--mantine-spacing-sm);
} }
}
.balance > div { @media (prefers-reduced-motion: reduce) {
justify-content: flex-start; .card,
.arrow {
transition: none;
}
.card:hover,
.card:focus-visible,
.card:hover .arrow {
transform: none;
} }
} }
@@ -2,86 +2,32 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import { MemoryRouter } from "react-router"; import { MemoryRouter } from "react-router";
import { resource } from "@/test/resource";
import type { BrokerageAccount } from "../../api/useBrokerageAccounts"; import type { BrokerageAccount } from "../../api/useBrokerageAccounts";
import { brokerageAccountCards } from "../../model/display"; import { brokerageAccountCards } from "../../model/display";
import { BrokerageAccountList } from "./BrokerageAccountList"; import { BrokerageAccountList } from "./BrokerageAccountList";
type PortfolioSummary = NonNullable<BrokerageAccount["portfolioSummary"]>; const syntheticAccount = {
id: "storybook-account",
const rubReturn = { name: "Учебный брокерский счёт",
absolute: "136900", openedOn: "2025-03-15",
costBasis: "1103600.50", portfolioSummary: {
currency: "RUB", bondPositionCount: 12,
partial: false, stockPositionCount: 5,
percent: "12.40", totalsByCurrency: [
}; { currency: "RUB", value: "125000.50" },
{ currency: "USD", value: "320" },
const portfolioSummary = { { currency: "EUR", value: "210" },
bondPositionCount: 12, ],
cashBalances: [
{ currency: "RUB", value: "82500.50" },
{ currency: "USD", value: "1500" },
],
fundPositionCount: 2,
stockPositionCount: 5,
structure: {
currencies: [{
categories: [
{ sharePercent: "55.000000", type: "STOCK", value: "682500" },
{ sharePercent: "25.000000", type: "BOND", value: "310500" },
{ sharePercent: "10.000000", type: "OTHER", value: "124200" },
{ sharePercent: "10.000000", type: "CASH", value: "124200" },
],
currency: "RUB",
hasNegativeValues: false,
totalValue: "1241400",
}],
unvaluedCategories: [],
}, },
totalPositionCount: 19, type: "BROKERAGE",
totalsByCurrency: [{ currency: "RUB", value: "1240500.50" }], } satisfies BrokerageAccount;
unrealizedReturnByCurrency: [rubReturn],
} satisfies PortfolioSummary;
function account(
id: string,
name: string,
summary: null | PortfolioSummary = portfolioSummary,
type: BrokerageAccount["type"] = "BROKERAGE",
): BrokerageAccount {
return {
id,
name,
openedOn: "2025-03-15",
portfolioSummary: summary,
type,
};
}
const negativeSummary: PortfolioSummary = {
...portfolioSummary,
unrealizedReturnByCurrency: [{
...rubReturn,
absolute: "-4200",
percent: "-3.05",
}],
};
const emptySummary: PortfolioSummary = {
...portfolioSummary,
cashBalances: [],
structure: { currencies: [], unvaluedCategories: [] },
totalPositionCount: 0,
totalsByCurrency: [],
unrealizedReturnByCurrency: [],
};
const meta = { const meta = {
args: { accounts: brokerageAccountCards([account("normal", "Основной ИИС")]) }, args: { accounts: brokerageAccountCards([syntheticAccount]) },
argTypes: { accounts: { control: false } }, argTypes: {
accounts: { control: false },
},
component: BrokerageAccountList, component: BrokerageAccountList,
decorators: [ decorators: [
Story => ( Story => (
@@ -94,7 +40,7 @@ const meta = {
docs: { docs: {
description: { description: {
component: component:
"Карточки счетов: стоимость и доходность по валютам, деньги, структура и свежесть источника. Денежные и процентные значения остаются строками. Все примеры содержат вымышленные данные; ссылки работают только внутри MemoryRouter.", "Карточки счетов WealthLedger со сводкой по валютам и числом наименований акций и облигаций. Используйте на экране выбора счёта; отсутствие сводки и отсутствие сумм имеют разные сообщения. Примеры содержат вымышленные данные. Ссылки меняют только локальный маршрут MemoryRouter; страница назначения в этой истории не отображается.",
}, },
}, },
}, },
@@ -106,112 +52,188 @@ export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Normal: Story = {}; export const Available: Story = {
name: "Сводка по нескольким валютам",
parameters: {
docs: {
description: {
story:
"Показываются первые две валюты и количество остальных. Суммы разных валют не складываются в общий итог.",
},
},
},
};
export const MultiCurrency: Story = { export const Unavailable: Story
args: { = {
accounts: brokerageAccountCards([account("multi", "Мультивалютный", { args: {
...portfolioSummary, accounts: brokerageAccountCards([
cashBalances: [ { ...syntheticAccount, openedOn: null, portfolioSummary: null },
{ currency: "RUB", value: "82500.50" }, ]),
{ currency: "USD", value: "1500" }, },
], name: "Сводка недоступна",
totalsByCurrency: [ parameters: {
{ currency: "RUB", value: "1240500.50" }, docs: {
{ currency: "USD", value: "3200" }, description: {
{ currency: "EUR", value: "900" }, story:
], "Счёт остаётся доступен для открытия, даже если сводка временно недоступна. Неизвестные суммы не заменяются нулём; отсутствующая дата открытия не отображается.",
unrealizedReturnByCurrency: [
...portfolioSummary.unrealizedReturnByCurrency,
{
absolute: "20",
costBasis: "3180",
currency: "USD",
partial: false,
percent: "2.10",
}, },
{ },
absolute: "0", },
costBasis: "900", };
currency: "EUR",
partial: false,
percent: null,
},
],
})]),
},
};
export const Negative: Story = {
args: {
accounts: brokerageAccountCards([
account("negative", "Просевший портфель", negativeSummary),
]),
},
};
export const Partial: Story = {
args: {
accounts: brokerageAccountCards([account("partial", "Частичные данные", {
...portfolioSummary,
unrealizedReturnByCurrency: [{
...rubReturn,
partial: true,
}],
})]),
},
};
export const Empty: Story = { export const Empty: Story = {
args: { args: {
accounts: brokerageAccountCards([ accounts: brokerageAccountCards([
account("empty", "Новый пустой счёт", emptySummary), {
]), ...syntheticAccount,
}, portfolioSummary: {
}; bondPositionCount: 0,
stockPositionCount: 0,
export const Unavailable: Story = { totalsByCurrency: [],
args: {
accounts: brokerageAccountCards([
account("unavailable", "Счёт без сводки", null),
]),
},
};
export const Stale: Story = {
args: {
accounts: brokerageAccountCards([
account("stale", "Устаревший портфель"),
], resource([], {
dependencies: [{
availability: "available",
completeness: "complete",
dataRevision: "stale-revision",
freshness: "stale",
lastAttempt: {
finishedAt: null,
problems: [],
startedAt: null,
state: "none",
}, },
lastSuccessfulAt: "2026-08-13T00:00:00Z", },
resourceKey: "portfolio/stale", ]),
}], },
freshness: "stale", name: "Нет текущих сумм",
lastSuccessfulAt: "2026-08-14T00:00:00Z", parameters: {
}).meta.dependencies), docs: {
description: {
story:
"Успешно полученная пустая сводка отличается от ошибки получения данных. Карточка сохраняет название, тип счёта и переход.",
},
},
}, },
}; };
export const LongName: Story = { export const LongName: Story = {
args: { args: {
accounts: brokerageAccountCards([ accounts: brokerageAccountCards([
account( {
"long", ...syntheticAccount,
"Очень длинное название индивидуального инвестиционного счёта, которое должно корректно переноситься на узком экране", name: "Учебный индивидуальный инвестиционный счёт для долгосрочных накоплений",
portfolioSummary, type: "INDIVIDUAL_INVESTMENT",
"INDIVIDUAL_INVESTMENT", },
),
]), ]),
}, },
name: "Длинное название счёта",
parameters: {
docs: {
description: {
story:
"Проверьте перенос длинного названия на узком экране и доступность всей карточки с клавиатуры.",
},
},
},
}; };
export const AccountGrid: Story = {
args: {
accounts: brokerageAccountCards([
{
...syntheticAccount,
name: "Основной",
portfolioSummary: {
bondPositionCount: 14,
stockPositionCount: 8,
totalsByCurrency: [{ currency: "RUB", value: "1256430.50" }],
},
},
{
...syntheticAccount,
id: "storybook-iis",
name: "Долгосрочные инвестиции",
portfolioSummary: {
bondPositionCount: 6,
stockPositionCount: 0,
totalsByCurrency: [{ currency: "RUB", value: "480250" }],
},
type: "INDIVIDUAL_INVESTMENT",
},
]),
},
name: "Несколько счетов",
};
export const UnvaluedPositions: Story = {
args: {
accounts: brokerageAccountCards([
{
...syntheticAccount,
portfolioSummary: {
bondPositionCount: 1,
stockPositionCount: 2,
totalsByCurrency: [],
},
},
]),
},
name: "Бумаги без текущей оценки",
};
export const LargeValues: Story = {
args: {
accounts: brokerageAccountCards([
{
...syntheticAccount,
portfolioSummary: {
bondPositionCount: 105,
stockPositionCount: 21,
totalsByCurrency: [
{ currency: "RUB", value: "1234567890123456789.50" },
{ currency: "USD", value: "-9876543210987654321.50" },
],
},
},
]),
},
name: "Длинные суммы",
};
export const IndependentFreshness: Story
= {
args: {
accounts: brokerageAccountCards(
[
syntheticAccount,
{
...syntheticAccount,
id: "waiting",
name: "Новый счёт",
portfolioSummary: null,
},
],
[
{
availability: "available",
completeness: "complete",
dataRevision: "1",
freshness: "stale",
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: "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",
},
],
),
},
name: "Независимая свежесть и ошибка сводок",
};
@@ -5,202 +5,122 @@ import { describe, expect, it } from "vitest";
import { resource } from "@/test/resource"; import { resource } from "@/test/resource";
import type { BrokerageAccount } from "../../api/useBrokerageAccounts";
import { brokerageAccountCards } from "../../model/display"; import { brokerageAccountCards } from "../../model/display";
import { BrokerageAccountList } from "./BrokerageAccountList"; import { BrokerageAccountList } from "./BrokerageAccountList";
type PortfolioSummary = NonNullable<BrokerageAccount["portfolioSummary"]>; describe("BrokerageAccountList", () => {
it("renders account details and an account link", () => {
render(
<MantineProvider>
<MemoryRouter>
<BrokerageAccountList
accounts={brokerageAccountCards([
{
id: "account-1",
name: "Основной",
openedOn: "2024-08-13",
portfolioSummary: {
bondPositionCount: 12,
stockPositionCount: 5,
totalsByCurrency: [
{ currency: "RUB", value: "1500" },
{ currency: "USD", value: "20" },
{ currency: "EUR", value: "5" },
],
},
type: "BROKERAGE",
const portfolioSummary = { },
bondPositionCount: 12, {
cashBalances: [ id: "account-2",
{ currency: "RUB", value: "82500.5" }, name: "Недоступный",
{ currency: "USD", value: "20" }, openedOn: null,
], portfolioSummary: null,
fundPositionCount: 0, type: "BROKERAGE",
stockPositionCount: 5, },
structure: { {
currencies: [{ id: "account-3",
categories: [ name: "Пустой",
{ sharePercent: "50.000000", type: "STOCK", value: "100" }, openedOn: null,
{ sharePercent: "30.000000", type: "BOND", value: "60" }, portfolioSummary: {
{ sharePercent: "0.000000", type: "OTHER", value: "0" }, bondPositionCount: 0,
{ sharePercent: "20.000000", type: "CASH", value: "40" }, stockPositionCount: 2,
], totalsByCurrency: [],
currency: "RUB", },
hasNegativeValues: false, type: "BROKERAGE",
totalValue: "200", },
}], ])}
unvaluedCategories: [], />
}, </MemoryRouter>
totalPositionCount: 17, </MantineProvider>,
totalsByCurrency: [ );
{ currency: "RUB", value: "123456789012345678901.995" },
{ currency: "USD", value: "20" },
{ currency: "EUR", value: "5" },
],
unrealizedReturnByCurrency: [
{
absolute: "136900",
costBasis: "1000000",
currency: "RUB",
partial: true,
percent: "12.40",
},
{
absolute: "-4.2",
costBasis: "24.2",
currency: "USD",
partial: false,
percent: "-3.05",
},
{
absolute: "0",
costBasis: "5",
currency: "EUR",
partial: false,
percent: null,
},
],
} satisfies PortfolioSummary;
type BrokerageAccountCardInput = Parameters<typeof BrokerageAccountList>[0][ expect(screen.getByRole("link", { name: /Основной/ })).toHaveAttribute(
"accounts" "href",
]; "/accounts/account-1",
);
expect(screen.getAllByText("Брокерский счет")).toHaveLength(3);
expect(screen.getByText(/^Открыт /)).toBeInTheDocument();
expect(screen.getByText("1 500 ₽")).toBeInTheDocument();
expect(screen.getByText("20 USD")).toBeInTheDocument();
expect(screen.getByText("Еще 1 валюта")).toBeInTheDocument();
expect(screen.getByText("Сводка временно недоступна")).toBeInTheDocument();
expect(screen.getByText("Нет текущих сумм")).toBeInTheDocument();
expect(screen.getAllByText("Открыть счет")).toHaveLength(3);
const available = within(screen.getByRole("link", { name: /Основной/ }));
expect(available.getAllByRole("definition")[0]).toHaveTextContent("5");
expect(available.getAllByRole("definition")[1]).toHaveTextContent("12");
const unvalued = within(screen.getByRole("link", { name: /Пустой/ }));
expect(unvalued.getAllByRole("definition")[0]).toHaveTextContent("2");
expect(unvalued.getAllByRole("definition")[1]).toHaveTextContent("0");
expect(
within(screen.getByRole("link", { name: /Недоступный/ })).queryByText(
"Акции",
),
).not.toBeInTheDocument();
});
});
function account( it("shows portfolio summary freshness independently for each account", () => {
id: string, const accounts = brokerageAccountCards(
name: string, ["fresh", "stale"].map(
summary: null | PortfolioSummary,
): BrokerageAccount {
return {
id,
name,
openedOn: null,
portfolioSummary: summary,
type: "BROKERAGE",
};
}
function renderList(accounts: BrokerageAccountCardInput) { id =>
return render( ({
id,
name: id,
openedOn: null,
portfolioSummary: {
bondPositionCount: 0,
stockPositionCount: 1,
totalsByCurrency: [{ currency: "RUB", value: "10" }],
},
type: "BROKERAGE" as const,
}),
),
[
resource(null, { freshness: "fresh", resourceKey: "portfolio/fresh" })
.meta,
resource(null, { freshness: "stale", resourceKey: "portfolio/stale" })
.meta,
],
);
render(
<MantineProvider> <MantineProvider>
<MemoryRouter> <MemoryRouter>
<BrokerageAccountList accounts={accounts} /> <BrokerageAccountList accounts={accounts} />
</MemoryRouter> </MemoryRouter>
</MantineProvider>, </MantineProvider>,
); );
} expect(
within(screen.getByRole("link", { name: /stale/ })).getByText(
describe("BrokerageAccountList", () => { "Данные требуют обновления",
it("renders returns, cash and structure with precise totals", () => { ),
const nullReturnSummary: PortfolioSummary = { ).toBeVisible();
...portfolioSummary, expect(
cashBalances: [], within(screen.getByRole("link", { name: /fresh/ })).queryByText(
structure: { currencies: [], unvaluedCategories: [] }, "Данные требуют обновления",
totalsByCurrency: [{ currency: "EUR", value: "5" }], ),
unrealizedReturnByCurrency: [{ ).not.toBeInTheDocument();
absolute: "0",
costBasis: "5",
currency: "EUR",
partial: false,
percent: null,
}],
};
renderList(brokerageAccountCards([
account("account-1", "Основной", portfolioSummary),
account("account-2", "Без доходности", nullReturnSummary),
]));
const link = screen.getByRole("link", { name: /Основной/ });
expect(link).toHaveAttribute("href", "/accounts/account-1");
expect(within(link).getByText("123 456 789 012 345 678 902,00 ₽"))
.toBeInTheDocument();
expect(within(link).getByText("Еще 1 валюта")).toBeInTheDocument();
expect(within(link).getByText("+12,40 %")).toBeInTheDocument();
expect(within(link).getByText("+136 900 ₽")).toBeInTheDocument();
expect(within(link).getByText("−3,05 %")).toBeInTheDocument();
expect(within(link).getByText("−4,2 USD")).toBeInTheDocument();
const nullReturnLink = screen.getByRole("link", { name: /Без доходности/ });
expect(within(nullReturnLink).getByText("—")).toBeInTheDocument();
expect(within(link).getByText("частично")).toBeInTheDocument();
expect(within(link).getByText("82 500,50 ₽")).toBeInTheDocument();
expect(within(link).getAllByText("20,00 USD")).not.toHaveLength(0);
expect(within(link).getByText("17 позиций")).toBeInTheDocument();
expect(screen.getByRole("img", {
name: "Структура в RUB: Акции 50 %; Облигации 30 %; "
+ "Другие активы 0 %; Свободные деньги 20 %",
})).toBeInTheDocument();
const stockLegend = within(link).getByText(/Акции 50 %/);
expect(stockLegend).toHaveTextContent("● Акции 50 %");
// eslint-disable-next-line testing-library/no-node-access -- The legend is aria-hidden, so only DOM traversal reaches its container.
expect(stockLegend.parentElement).toHaveAttribute("aria-hidden", "true");
expect(within(link).queryByText(/Другие активы 0 %/)).not.toBeInTheDocument();
expect(link).toHaveTextContent("По данным брокера");
expect(link).toHaveTextContent("Структура RUB: Акции 100");
});
it("preserves empty, unavailable and stale states", () => {
const emptySummary: PortfolioSummary = {
...portfolioSummary,
cashBalances: [],
structure: { currencies: [], unvaluedCategories: [] },
totalPositionCount: 0,
totalsByCurrency: [],
unrealizedReturnByCurrency: [],
};
const cards = brokerageAccountCards([
account("empty", "Пустой", emptySummary),
account("unavailable", "Недоступный", null),
account("stale", "Устаревший", portfolioSummary),
account("failed", "Ошибка обновления", null),
], resource([], {
dependencies: [{
availability: "available",
completeness: "complete",
dataRevision: "stale-revision",
freshness: "stale",
lastAttempt: {
finishedAt: null,
problems: [],
startedAt: null,
state: "none",
},
lastSuccessfulAt: "2026-08-13T00:00:00Z",
resourceKey: "portfolio/stale",
}, {
availability: "available",
completeness: "complete",
dataRevision: "failed-revision",
freshness: "stale",
lastAttempt: {
finishedAt: "2026-08-14T00:01:00Z",
problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed",
},
lastSuccessfulAt: "2026-08-13T00:00:00Z",
resourceKey: "portfolio/failed",
}],
freshness: "stale",
lastSuccessfulAt: "2026-08-14T00:00:00Z",
}).meta.dependencies);
renderList(cards);
expect(screen.getByText("Нет текущих сумм")).toBeInTheDocument();
expect(screen.getAllByText("Сводка временно недоступна"))
.toHaveLength(2);
const staleLink = screen.getByRole("link", { name: /Устаревший/ });
expect(within(staleLink).getByText("Данные требуют обновления"))
.toBeInTheDocument();
expect(within(staleLink).getByText(/Данные на/)).toBeInTheDocument();
const failedLink = screen.getByRole("link", { name: /Ошибка обновления/ });
expect(within(failedLink).getByText(
"Не удалось обновить данные. Показаны последние полученные.",
)).toBeInTheDocument();
});
}); });
@@ -1,10 +1,11 @@
import type { CSSProperties, FC } from "react"; import type { FC } from "react";
import { Badge, Group, Stack, Text, VisuallyHidden } from "@mantine/core"; import { Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { WalletIcon } from "@wealthledger/icons";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import { formatMoney, portfolioCategoryPresentation } from "@/shared/lib"; import { formatDate, formatMoney } from "@/shared/lib";
import { SourceSyncStatus } from "@/shared/ui"; import { SourceSyncStatus } from "@/shared/ui";
import type { BrokerageAccountCard } from "../../model/display"; import type { BrokerageAccountCard } from "../../model/display";
@@ -12,176 +13,12 @@ import type { BrokerageAccountCard } from "../../model/display";
import { accountTypeKey } from "../../model/display"; import { accountTypeKey } from "../../model/display";
import classes from "./BrokerageAccountList.module.css"; import classes from "./BrokerageAccountList.module.css";
const DECIMAL_PART_COUNT = 2;
const STRUCTURE_CATEGORY_ORDER = ["STOCK", "BOND", "OTHER", "CASH"] as const;
type AvailableSummary = Extract<
BrokerageAccountCard["summary"],
{ state: "available" }
>;
type BrokerageAccountListProps = { accounts: BrokerageAccountCard[] }; type BrokerageAccountListProps = { accounts: BrokerageAccountCard[] };
type BrokerageAccountSummaryProps = { type BrokerageAccountSummaryProps = {
summary: BrokerageAccountCard["summary"]; summary: BrokerageAccountCard["summary"];
}; };
type PortfolioSummary = AvailableSummary["portfolio"];
type ReturnValueProps = {
item: PortfolioSummary["unrealizedReturnByCurrency"][number];
};
type StructureCurrency = PortfolioSummary["structure"]["currencies"][number];
function formatPercent(value: null | string) {
if (value === null) return "—";
const formatted = `${value.replace(".", ",")}\u{A0}%`;
if (isZero(value))
return value.startsWith("-") ? formatted.slice(1) : formatted;
return value.startsWith("-") ? `−${formatted.slice(1)}` : `+${formatted}`;
}
function formatStructurePercent(value: string) {
let trimmed = value;
while (trimmed.includes(".") && trimmed.endsWith("0"))
trimmed = trimmed.slice(0, -1);
if (trimmed.endsWith(".")) trimmed = trimmed.slice(0, -1);
return trimmed.replace(".", ",");
}
function isZero(value: string) {
const unsigned = value.startsWith("-") ? value.slice(1) : value;
const [integer = "", fraction = ""] = unsigned.split(".", DECIMAL_PART_COUNT);
return integer.replaceAll("0", "") === ""
&& fraction.replaceAll("0", "") === "";
}
const ReturnValue: FC<ReturnValueProps> = (props) => {
const { t } = useTranslation();
const { item } = props;
const isNegative = item.percent !== null
&& item.percent.startsWith("-")
&& !isZero(item.percent);
const isPositive = item.percent !== null
&& !item.percent.startsWith("-")
&& !isZero(item.percent);
let toneClass = classes.neutral;
if (isNegative) toneClass = classes.negative;
else if (isPositive) toneClass = classes.positive;
return (
<Group className={classes.return} gap="xs" wrap="nowrap">
<Text className={toneClass} fw={700} size="sm">
{formatPercent(item.percent)}
</Text>
<Text className={toneClass} size="sm">
{formatMoney(
{ currency: item.currency, value: item.absolute },
{ withSign: true },
)}
</Text>
{item.partial && (
<Text c="dimmed" size="xs">
{t("accounts.partial")}
</Text>
)}
</Group>
);
};
type StructureBarProps = { item: StructureCurrency };
const StructureBar: FC<StructureBarProps> = (props) => {
const { t } = useTranslation();
const categories = STRUCTURE_CATEGORY_ORDER.flatMap((type) => {
const category = props.item.categories.find(item => item.type === type);
return category ? [category] : [];
});
const description = categories.map((category) => {
const label = t(portfolioCategoryPresentation[category.type].label);
const percent = category.sharePercent === null
? null
: formatStructurePercent(category.sharePercent);
return percent === null
? t("accounts.structure.incomplete", { category: label })
: t("accounts.structure.category", { category: label, percent });
}).join("; ");
const label = t("accounts.structure.label", {
currency: props.item.currency,
value: description,
});
const legendCategories = categories.filter(category =>
category.sharePercent === null || !isZero(category.sharePercent));
return (
<Stack gap={4}>
<div
aria-label={label}
className={classes.structureBar}
role="img"
title={label}
>
{categories.map((category) => {
const presentation = portfolioCategoryPresentation[category.type];
const style = {
"--category-color": `var(--mantine-color-${presentation.color.replace(".", "-")})`,
"width": `${category.sharePercent ?? 0}%`,
} as CSSProperties;
return (
<div
aria-hidden
className={classes.structureSection}
key={category.type}
style={style}
/>
);
})}
</div>
<Group aria-hidden gap="xs" wrap="wrap">
{legendCategories.map((category) => {
const presentation = portfolioCategoryPresentation[category.type];
const categoryLabel = t(presentation.label);
const text = category.sharePercent === null
? categoryLabel
: t("accounts.structure.category", {
category: categoryLabel,
percent: formatStructurePercent(category.sharePercent),
});
return (
<Text c="dimmed" key={category.type} size="xs">
<Text c={presentation.color} component="span">●</Text>
{" "}
{text}
</Text>
);
})}
</Group>
</Stack>
);
};
const BrokerageAccountSummary: FC<BrokerageAccountSummaryProps> = (props) => { const BrokerageAccountSummary: FC<BrokerageAccountSummaryProps> = (props) => {
const { summary } = props; const { summary } = props;
const { t } = useTranslation(); const { t } = useTranslation();
@@ -199,51 +36,19 @@ const BrokerageAccountSummary: FC<BrokerageAccountSummaryProps> = (props) => {
); );
return ( return (
<Stack className={classes.balance} gap="xs"> <Stack className={classes.summary} gap={4}>
<Group align="flex-start" gap="lg" justify="flex-end" wrap="nowrap"> {summary.totals.map(total => (
<Stack className={classes.summary} gap={4}> <Text className={classes.total} fw={700} key={total.currency}>
{summary.totals.map(total => ( {formatMoney(total)}
<Text className={classes.total} fw={700} key={total.currency}> </Text>
{formatMoney(total, { withRoundedCents: true })} ))}
</Text>
))}
{!!summary.remainingCurrencyCount && ( {!!summary.remainingCurrencyCount && (
<Text c="dimmed" size="sm"> <Text c="dimmed" size="sm">
{t("accounts.remainingCurrencies", { {t("accounts.remainingCurrencies", {
count: summary.remainingCurrencyCount, count: summary.remainingCurrencyCount,
})}
</Text>
)}
</Stack>
<Stack gap={4}>
{summary.totals.map((total) => {
const item = summary.portfolio.unrealizedReturnByCurrency.find(
returnItem => returnItem.currency === total.currency,
);
return item
? <ReturnValue item={item} key={total.currency} />
: null;
})} })}
</Stack> </Text>
</Group>
{summary.portfolio.cashBalances.length > 0 && (
<Group className={classes.cash} gap="xs" justify="flex-end">
<Text c="dimmed" size="xs">
{t("accounts.cash")}
</Text>
<Stack gap={0}>
{summary.portfolio.cashBalances.map(cash => (
<Text className={classes.cashValue} key={cash.currency} size="sm">
{formatMoney(cash, { withRoundedCents: true })}
</Text>
))}
</Stack>
</Group>
)} )}
</Stack> </Stack>
); );
@@ -256,13 +61,13 @@ type BrokerageAccountSourceStatusProps = {
const BrokerageAccountSourceStatus: FC<BrokerageAccountSourceStatusProps> = ( const BrokerageAccountSourceStatus: FC<BrokerageAccountSourceStatusProps> = (
props, props,
) => { ) => {
const { t } = useTranslation();
const { source } = props; const { source } = props;
const { t } = useTranslation();
const isSyncing = source?.lastAttempt.state === "running"; const isSyncing = source?.lastAttempt.state === "running";
const errorText = source?.lastAttempt.state === "failed" const errorText
? t("accounts.refreshError") = source?.lastAttempt.state === "failed" ? t("accounts.refreshError") : null;
: null;
return ( return (
<> <>
@@ -282,110 +87,86 @@ const BrokerageAccountSourceStatus: FC<BrokerageAccountSourceStatusProps> = (
); );
}; };
type AccountDescriptionProps = {
account: BrokerageAccountCard;
};
const AccountDescription: FC<AccountDescriptionProps> = (props) => {
const { t } = useTranslation();
const { account, summary } = props.account;
if (summary.state !== "available") return null;
const returns = summary.portfolio.unrealizedReturnByCurrency.map(item =>
t("accounts.description.return", {
absolute: formatMoney(
{ currency: item.currency, value: item.absolute },
{ withSign: true },
),
currency: item.currency,
percent: formatPercent(item.percent),
}),
);
const structures = summary.portfolio.structure.currencies.map((item) => {
const categories = item.categories.map(category =>
t("accounts.description.structureCategory", {
category: t(portfolioCategoryPresentation[category.type].label),
value: formatMoney(
{ currency: item.currency, value: category.value },
{ withRoundedCents: true },
),
}),
).join(", ");
return t("accounts.description.structure", {
currency: item.currency,
value: categories,
});
});
const hasPartialReturn = summary.portfolio.unrealizedReturnByCurrency.some(
item => item.partial,
);
const details = [...returns, ...structures].join(". ");
return (
<VisuallyHidden>
{t("accounts.description.start", {
name: account.name,
type: t(accountTypeKey(account.type)),
})}
{t("accounts.description.currentValue", {
value: summary.portfolio.totalsByCurrency
.map(total => formatMoney(total)).join(", "),
})}
{details ? `${details}. ` : ""}
{hasPartialReturn ? t("accounts.brokerDescription") : ""}
</VisuallyHidden>
);
};
const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => { const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
const { accounts } = props;
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<ul className={classes.list}> <SimpleGrid className={classes.list} cols={1} spacing="lg">
{props.accounts.map(({ account, positionCounts, source, summary }) => ( {accounts.map(({ account, positionCounts, source, summary }) => (
<li key={account.id}> <Card
<Link className={classes.row} to={`/accounts/${account.id}`}> className={classes.card}
<Group className={classes.identity} wrap="nowrap"> component={Link}
<h2 className={classes.name}>{account.name}</h2> key={account.id}
padding="xl"
<Badge className={classes.accountType} variant="light"> radius="lg"
to={`/accounts/${account.id}`}
withBorder
>
<Stack className={classes.identity} gap="sm">
<Group gap="sm" justify="space-between" wrap="nowrap">
<Text className={classes.kicker} fw={700} size="xs">
{t(accountTypeKey(account.type))} {t(accountTypeKey(account.type))}
</Badge> </Text>
<WalletIcon className={classes.accountIcon} />
</Group> </Group>
<Text className={classes.name} component="h2" fw={700}>
{account.name}
</Text>
</Stack>
<Stack className={classes.balance} gap="xs">
<Text c="dimmed" size="sm">
{t("accounts.currentValue")}
</Text>
<BrokerageAccountSummary summary={summary} /> <BrokerageAccountSummary summary={summary} />
<Stack className={classes.details} gap={2}> <BrokerageAccountSourceStatus source={source} />
{positionCounts && ( </Stack>
<Text c="dimmed" size="xs">
{t("accounts.positions", {
count: positionCounts.total,
})}
</Text>
)}
<BrokerageAccountSourceStatus source={source} /> {!!positionCounts && (
</Stack> <dl className={classes.positions}>
<div className={classes.stocks}>
<dt>{t("accounts.stocks")}</dt>
{summary.state === "available" && ( <dd>
<Stack className={classes.structure} gap={4}> {new Intl.NumberFormat("ru-RU").format(positionCounts.stocks)}
{summary.portfolio.structure.currencies.map(item => ( </dd>
<StructureBar item={item} key={item.currency} /> </div>
))}
</Stack> <div className={classes.bonds}>
<dt>{t("accounts.bonds")}</dt>
<dd>
{new Intl.NumberFormat("ru-RU").format(positionCounts.bonds)}
</dd>
</div>
</dl>
)}
<Group className={classes.footer} gap="sm" justify="space-between">
{!!account.openedOn && (
<Text c="dimmed" size="xs">
{t("accounts.openedOn", {
date: formatDate(account.openedOn),
})}
</Text>
)} )}
<AccountDescription <Text className={classes.open} component="span" fw={600} size="sm">
account={{ account, positionCounts, source, summary }} {t("accounts.openAccount")}
/>
</Link> <span aria-hidden="true" className={classes.arrow}>
</li> →
</span>
</Text>
</Group>
</Card>
))} ))}
</ul> </SimpleGrid>
); );
}; };
+1 -2
View File
@@ -2,9 +2,8 @@ export {
type BrokerageEvent, type BrokerageEvent,
useBrokerageEvents, useBrokerageEvents,
} from "./api/useBrokerageEvents"; } from "./api/useBrokerageEvents";
export { brokerageEventGroupKey, brokerageEventKey } from "./model/event-key"; export { brokerageEventKey } from "./model/event-key";
export { BrokerageEventCard } from "./ui/BrokerageEventCard"; export { BrokerageEventCard } from "./ui/BrokerageEventCard";
export { BrokerageEventGroup } from "./ui/BrokerageEventGroup";
export { export {
BrokerageEventsPreview, BrokerageEventsPreview,
type BrokerageEventsPreviewState, type BrokerageEventsPreviewState,
@@ -1,57 +0,0 @@
import { describe, expect, it } from "vitest";
import type { BrokerageEvent } from "../api/useBrokerageEvents";
import { brokerageEventGroupKey } from "./event-key";
const base: BrokerageEvent = {
account: { id: "account-1", name: "Брокерский" },
date: "2026-08-20",
dateKind: "event",
displayName: "ОФЗ 26238",
figi: "FIGI",
instrumentUid: "UID",
isin: "RU000A0JX0J2",
logoName: null,
projectedAmount: { currency: "RUB", value: "100" },
ticker: "OFZ",
type: "coupon",
};
describe("brokerageEventGroupKey", () => {
it("uses date, preferred identity, exact type and dateKind", () => {
const key = brokerageEventGroupKey(base);
expect(brokerageEventGroupKey({
...base,
account: { id: "account-2", name: "ИИС" },
figi: "OTHER-FIGI",
})).toBe(key);
expect(brokerageEventGroupKey({ ...base, date: "2026-08-21" }))
.not.toBe(key);
expect(brokerageEventGroupKey({ ...base, dateKind: "registry" }))
.not.toBe(key);
expect(brokerageEventGroupKey({ ...base, type: "dividend" }))
.not.toBe(key);
});
it("falls back through FIGI and ISIN and rejects missing identity", () => {
expect(brokerageEventGroupKey({ ...base, instrumentUid: null }))
.toBe(brokerageEventGroupKey({ ...base, instrumentUid: "" }));
expect(brokerageEventGroupKey({
...base,
figi: null,
instrumentUid: null,
})).toBe(brokerageEventGroupKey({
...base,
figi: "",
instrumentUid: null,
}));
expect(brokerageEventGroupKey({
...base,
figi: null,
instrumentUid: null,
isin: null,
})).toBeNull();
});
});
@@ -1,13 +1,5 @@
import type { BrokerageEvent } from "../api/useBrokerageEvents"; import type { BrokerageEvent } from "../api/useBrokerageEvents";
function brokerageEventGroupKey(event: BrokerageEvent): null | string {
const identity = event.instrumentUid || event.figi || event.isin;
return identity
? JSON.stringify([event.date, identity, event.type, event.dateKind])
: null;
}
function brokerageEventKey(event: BrokerageEvent) { function brokerageEventKey(event: BrokerageEvent) {
return JSON.stringify([ return JSON.stringify([
event.account?.id, event.type, event.date, event.instrumentUid, event.account?.id, event.type, event.date, event.instrumentUid,
@@ -15,4 +7,4 @@ function brokerageEventKey(event: BrokerageEvent) {
]); ]);
} }
export { brokerageEventGroupKey, brokerageEventKey }; export { brokerageEventKey };
@@ -4,7 +4,6 @@ import { EntryCard } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatDate, formatMoney, instrumentLogoUrl } from "@/shared/lib"; import { formatDate, formatMoney, instrumentLogoUrl } from "@/shared/lib";
import { PayoutCard } from "@/shared/ui";
import type { BrokerageEvent } from "../api/useBrokerageEvents"; import type { BrokerageEvent } from "../api/useBrokerageEvents";
@@ -16,27 +15,6 @@ const BrokerageEventCard: FC<BrokerageEventCardProps> = (props) => {
const date = formatDate(event.date); const date = formatDate(event.date);
if (event.type === "coupon" || event.type === "dividend") {
const title = [t(`events.type.${event.type}`), event.displayName].join(" · ");
const extraMetadata = [event.isin, event.account?.name]
.filter(Boolean)
.join(" · ");
return (
<PayoutCard
date={date}
dateKind={event.dateKind}
extraMetadata={extraMetadata}
imageSrc={instrumentLogoUrl(event.logoName)}
initial={(event.ticker.trim()[0] ?? "?").toUpperCase()}
kind="forecast"
projectedAmount={event.projectedAmount}
title={title}
/>
);
}
const metadata = [ const metadata = [
date, date,
event.dateKind === "registry" event.dateKind === "registry"
@@ -62,7 +40,7 @@ const BrokerageEventCard: FC<BrokerageEventCardProps> = (props) => {
title={[t(`events.type.${event.type}`), event.displayName].join(" · ")} title={[t(`events.type.${event.type}`), event.displayName].join(" · ")}
tone="neutral" tone="neutral"
value={projectedAmount} value={projectedAmount}
valueLabel={t("payout.forecast")} valueLabel={t("events.projectedAmount")}
/> />
); );
}; };
@@ -1,122 +0,0 @@
import { MantineProvider } from "@mantine/core";
import { render, screen, within } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
import type { BrokerageEvent } from "../api/useBrokerageEvents";
import { BrokerageEventGroup } from "./BrokerageEventGroup";
function event(
account: { id: string; name: string },
value: null | string,
currency = "RUB",
): BrokerageEvent {
return {
account,
date: "2026-08-20",
dateKind: "registry",
displayName: "ОФЗ 26238",
figi: "FIGI",
instrumentUid: "UID",
isin: "RU000A0JX0J2",
logoName: null,
projectedAmount: value === null ? null : { currency, value },
ticker: "OFZ",
type: "coupon",
};
}
function renderGroup(
events: [BrokerageEvent, BrokerageEvent],
isTotalVisible = true,
) {
return render(
<MantineProvider>
<MemoryRouter>
<ul>
<BrokerageEventGroup
events={events}
totalVisible={isTotalVisible}
/>
</ul>
</MemoryRouter>
</MantineProvider>,
);
}
describe("BrokerageEventGroup", () => {
it("shows an unsigned forecast total and every account forecast", () => {
renderGroup([
event({ id: "broker", name: "Брокерский" }, "100.5"),
event({ id: "iis", name: "ИИС" }, "49.5"),
]);
expect(screen.getByText("Купон · ОФЗ 26238")).toBeInTheDocument();
expect(screen.getByText("150 ₽")).toBeInTheDocument();
expect(screen.getAllByText("Прогноз").length).toBeGreaterThanOrEqual(3);
const accounts = within(screen.getByRole("list", {
name: /Купон · ОФЗ 26238, 20\.08\.2026 · Дата реестра, 150 ₽/,
}));
expect(accounts.getByText("Брокерский")).toBeInTheDocument();
expect(accounts.getByText("ИИС")).toBeInTheDocument();
expect(accounts.getByText("100,5 ₽")).toBeInTheDocument();
expect(accounts.getByText("49,5 ₽")).toBeInTheDocument();
expect(accounts.queryByRole("link")).not.toBeInTheDocument();
});
it("announces an unknown total as unavailable", () => {
renderGroup([
event({ id: "broker", name: "Брокерский" }, null),
event({ id: "iis", name: "ИИС" }, "49.5"),
]);
expect(screen.getByRole("list", {
name: /Купон · ОФЗ 26238.*Итог недоступен/,
})).toBeInTheDocument();
expect(screen.getByText("Сумма неизвестна")).toBeInTheDocument();
});
it("hides the total for incomplete data", () => {
renderGroup([
event({ id: "broker", name: "Брокерский" }, "100.5"),
event({ id: "iis", name: "ИИС" }, "49.5"),
], false);
expect(screen.queryByText("150 ₽")).not.toBeInTheDocument();
});
it("allows zero source forecasts to produce a visible zero total", () => {
renderGroup([
event({ id: "broker", name: "Брокерский" }, "0"),
event({ id: "iis", name: "ИИС" }, "0.00"),
]);
expect(screen.getAllByText("0 ₽")).toHaveLength(2);
expect(screen.getByText("0,00 ₽")).toBeInTheDocument();
});
it("shows separate forecast totals per currency without conversion", () => {
renderGroup([
event({ id: "broker", name: "Брокерский" }, "10", "USD"),
event({ id: "iis", name: "ИИС" }, "200"),
]);
expect(screen.getByRole("list", { name: /10 USD.*200 ₽/ }))
.toBeInTheDocument();
});
it("shows an offer group without a total", () => {
renderGroup([
{ ...event({ id: "broker", name: "Брокерский" }, null), type: "offer" },
{ ...event({ id: "iis", name: "ИИС" }, null), type: "offer" },
]);
const accounts = within(screen.getByRole("list", {
name: /Итог недоступен/,
}));
expect(accounts.getAllByRole("listitem")).toHaveLength(2);
});
});
@@ -1,87 +0,0 @@
import type { FC } from "react";
import { EntryCard } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import {
formatDate,
formatMoney,
instrumentLogoUrl,
sumMoney,
totalText,
} from "@/shared/lib";
import { AccountRow, EntryGroup, EntryGroupTotal } from "@/shared/ui";
import type { BrokerageEvent } from "../api/useBrokerageEvents";
import { brokerageEventKey } from "../model/event-key";
type BrokerageEventGroupProps = {
events: [BrokerageEvent, BrokerageEvent, ...BrokerageEvent[]];
totalVisible: boolean;
};
const BrokerageEventGroup: FC<BrokerageEventGroupProps> = (props) => {
const { t } = useTranslation();
const [first] = props.events;
const typeLabel = t(`events.type.${first.type}`);
const amounts = props.events.flatMap(event =>
event.projectedAmount ? [event.projectedAmount] : [],
);
const totals = props.totalVisible && amounts.length === props.events.length
? sumMoney(amounts)
: null;
const date = formatDate(first.date);
const metadata = [
date,
first.dateKind === "registry" ? t("events.dateKind.registry") : null,
].filter(Boolean).join(" · ");
const title = `${typeLabel} · ${first.displayName}`;
const totalsText = totalText(totals, false);
const accessibleName = [
title,
metadata,
totalsText || t("entryGroup.totalUnavailable"),
].join(", ");
return (
<EntryGroup
header={(
<EntryCard
imageSrc={instrumentLogoUrl(first.logoName)}
initial={(first.ticker.trim()[0] ?? "?").toUpperCase()}
metadata={metadata}
title={title}
tone="neutral"
value={<EntryGroupTotal totals={totals} />}
valueLabel={t("payout.forecast")}
wrap
/>
)}
label={accessibleName}
>
{props.events.map((event) => {
const value = event.projectedAmount
? formatMoney(event.projectedAmount)
: t("events.noAmount");
return (
<AccountRow
accountName={event.account?.name ?? "—"}
key={brokerageEventKey(event)}
value={value}
valueLabel={t("payout.forecast")}
/>
);
})}
</EntryGroup>
);
};
export { BrokerageEventGroup };
@@ -5,18 +5,13 @@ import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import { groupItems } from "@/shared/lib";
import type { BrokerageEvent } from "../../api/useBrokerageEvents"; import type { BrokerageEvent } from "../../api/useBrokerageEvents";
import { brokerageEventGroupKey, brokerageEventKey } from "../../model/event-key"; import { brokerageEventKey } from "../../model/event-key";
import { BrokerageEventCard } from "../BrokerageEventCard"; import { BrokerageEventCard } from "../BrokerageEventCard";
import { BrokerageEventGroup } from "../BrokerageEventGroup";
type BrokerageEventsPreviewProps = { type BrokerageEventsPreviewProps = {
allAccounts?: boolean;
events: BrokerageEventsPreviewState; events: BrokerageEventsPreviewState;
moreLabel?: string;
}; };
type BrokerageEventsPreviewState type BrokerageEventsPreviewState
@@ -24,7 +19,6 @@ type BrokerageEventsPreviewState
items: BrokerageEvent[]; items: BrokerageEvent[];
state: "loaded" | "partial"; state: "loaded" | "partial";
to: string; to: string;
totalVisible?: boolean;
} }
| { state: "empty" | { state: "empty"
| "error" | "error"
@@ -48,35 +42,16 @@ const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
{events.state === "partial" && <Text role="status">{t("investment.partial")}</Text>} {events.state === "partial" && <Text role="status">{t("investment.partial")}</Text>}
{(events.state === "loaded" || events.state === "partial") && ( {(events.state === "loaded" || events.state === "partial")
<Stack && events.items.map(event => (
component="ul" <BrokerageEventCard
gap="sm" event={event}
m={0} key={brokerageEventKey(event)}
p={0} />
style={{ listStyle: "none" }} ))}
>
{groupItems(
events.items,
props.allAccounts ? brokerageEventGroupKey : () => null,
).map(entry => entry.type === "group"
? (
<BrokerageEventGroup
events={entry.items}
key={entry.key}
totalVisible={events.totalVisible === true}
/>
)
: (
<li key={brokerageEventKey(entry.item)}>
<BrokerageEventCard event={entry.item} />
</li>
))}
</Stack>
)}
<Button component={Link} to={events.to} variant="subtle"> <Button component={Link} to={events.to} variant="subtle">
{props.moreLabel ?? t("events.open")} {t("events.open")}
</Button> </Button>
</Stack> </Stack>
); );
+4
View File
@@ -4,6 +4,9 @@ type IncomeAnalytics = NonNullable<
paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["data"] paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["data"]
>; >;
type IncomeAnalyticsMeta
= paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["meta"];
type IncomeSummary = NonNullable< type IncomeSummary = NonNullable<
paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"]["data"] paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"]["data"]
>; >;
@@ -44,6 +47,7 @@ function useIncomeSummary(accountId?: string) {
export { export {
type IncomeAnalytics, type IncomeAnalytics,
type IncomeAnalyticsMeta,
type IncomeSummary, type IncomeSummary,
useIncomeAnalytics, useIncomeAnalytics,
useIncomeSummary, useIncomeSummary,
+1
View File
@@ -1,5 +1,6 @@
export { export {
type IncomeAnalytics, type IncomeAnalytics,
type IncomeAnalyticsMeta,
type IncomeSummary, type IncomeSummary,
useIncomeAnalytics, useIncomeAnalytics,
useIncomeSummary, useIncomeSummary,
+16 -2
View File
@@ -6,6 +6,10 @@ import {
type StockCandles = NonNullable<paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"]>; type StockCandles = NonNullable<paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
type StockPosition = NonNullable<paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
const MIN_STOCK_SEARCH_LENGTH = 2;
type StockDividends = NonNullable<paths["/api/v1/stocks/{uid}/dividends"]["get"]["responses"][200]["content"]["application/json"]["data"]>; type StockDividends = NonNullable<paths["/api/v1/stocks/{uid}/dividends"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
type StockPeriod = NonNullable< type StockPeriod = NonNullable<
@@ -14,8 +18,6 @@ type StockPeriod = NonNullable<
>["period"] >["period"]
>; >;
type StockPosition = NonNullable<paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
const STOCK_STALE_TIME_MS = 30_000; const STOCK_STALE_TIME_MS = 30_000;
function useStock(identifier: string) { function useStock(identifier: string) {
@@ -94,7 +96,18 @@ function useStockQuote(uid: string) {
}); });
} }
function useStockSearch(query: string) {
return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks", {
params: { query: { query } },
}),
enabled: query.length >= MIN_STOCK_SEARCH_LENGTH,
staleTime: STOCK_STALE_TIME_MS,
});
}
export { export {
MIN_STOCK_SEARCH_LENGTH,
type StockCandles, type StockCandles,
type StockDividends, type StockDividends,
type StockPosition, type StockPosition,
@@ -104,4 +117,5 @@ export {
useStockDividends, useStockDividends,
useStockPosition, useStockPosition,
useStockQuote, useStockQuote,
useStockSearch,
}; };
+2
View File
@@ -1,4 +1,5 @@
export { export {
MIN_STOCK_SEARCH_LENGTH,
type StockCandles, type StockCandles,
type StockDividends, type StockDividends,
type StockPosition, type StockPosition,
@@ -8,4 +9,5 @@ export {
useStockDividends, useStockDividends,
useStockPosition, useStockPosition,
useStockQuote, useStockQuote,
useStockSearch,
} from "./api/queries"; } from "./api/queries";
-5
View File
@@ -7,12 +7,7 @@ type AuthContextValue = {
logout: () => Promise<void>; logout: () => Promise<void>;
ready: boolean; ready: boolean;
register: (email: string, password: string) => Promise<void>; register: (email: string, password: string) => Promise<void>;
retry: () => void;
signedOut: boolean; signedOut: boolean;
/**
Refresh failed with a network error or 5xx; the refresh cookie may still be valid.
*/
unavailable: boolean;
user: undefined | User; user: undefined | User;
}; };
@@ -1,55 +0,0 @@
import { describe, expect, it } from "vitest";
import { loginSchema, registerSchema } from "./auth.schema";
const email = "user@example.com";
function canLogIn(password: string) {
return loginSchema.safeParse({ email, password }).success;
}
function canRegister(password: string, confirmation = password) {
return registerSchema.safeParse({
email,
password,
passwordConfirmation: confirmation,
}).success;
}
// Register policy: 8..72 printable ASCII (bcrypt reads 72 bytes, so non-ASCII could be
// truncated). Login stays lenient so accounts created under the old policy still sign in.
describe("auth password policy", () => {
it.each([
["7 chars", "a".repeat(7), false],
["8 chars", "a".repeat(8), true],
["72 chars", "a".repeat(72), true],
["73 chars", "a".repeat(73), false],
["printable symbols and space", "Aa1 !~\"#$%&'()*+,-./:;<=>?@", true],
["Cyrillic, 8+ chars", "пароль-пароль", false],
["emoji", "passw😀rd", false],
["tab", "pass\tword1", false],
["newline", "password1\n", false],
["DEL", "password\u{7F}1", false],
])("register: %s", (_, password, accepted) => {
expect(canRegister(password)).toBe(accepted);
});
it("register compares the raw password with its confirmation", () => {
const padded = " spaced pass ";
expect(canRegister(padded)).toBe(true);
expect(canRegister(padded, padded.trim())).toBe(false);
});
it.each([
["legacy Cyrillic password", "пароль-пароль", true],
["short legacy password", "short", true],
["72 bytes", "a".repeat(72), true],
["73 bytes", "a".repeat(73), false],
["36 Cyrillic letters = 72 bytes", "я".repeat(36), true],
["37 Cyrillic letters = 74 bytes", "я".repeat(37), false],
["empty", "", false],
])("login: %s", (_, password, accepted) => {
expect(canLogIn(password)).toBe(accepted);
});
});
+14 -38
View File
@@ -7,10 +7,8 @@ const EMAIL_PART_COUNT = 2;
const ASCII_SPACE = 32; const ASCII_SPACE = 32;
const ASCII_DELETE = 127; const ASCII_DELETE = 127;
const MAX_EMAIL_LENGTH = 254; const MAX_EMAIL_LENGTH = 254;
const MIN_PASSWORD_LENGTH = 8; const MIN_PASSWORD_BYTES = 12;
const MAX_PASSWORD_BYTES = 72; const MAX_PASSWORD_BYTES = 72;
// Printable ASCII keeps characters == bytes, within the bcrypt 72-byte limit.
const ASCII_TILDE = 126;
function isEmailCharacterInvalid(character: string): boolean { function isEmailCharacterInvalid(character: string): boolean {
const codePoint = character.codePointAt(0); const codePoint = character.codePointAt(0);
@@ -22,14 +20,6 @@ function isEmailCharacterInvalid(character: string): boolean {
); );
} }
function isPrintableAscii(value: string): boolean {
return value.split("").every((character) => {
const code = character.codePointAt(0) ?? 0;
return code >= ASCII_SPACE && code <= ASCII_TILDE;
});
}
const email = z const email = z
.string() .string()
.trim() .trim()
@@ -43,42 +33,28 @@ const email = z
{ error: validationMessage("auth.validation.invalidEmail") }, { error: validationMessage("auth.validation.invalidEmail") },
); );
// Login stays lenient: accounts created under an older policy must still sign in. const password = z
const loginPassword = z
.string() .string()
.min(1)
.refine( .refine(
value => new TextEncoder().encode(value).length <= MAX_PASSWORD_BYTES, value =>
{ error: validationMessage("auth.validation.passwordTooLong") }, new TextEncoder().encode(value).length >= MIN_PASSWORD_BYTES
&& new TextEncoder().encode(value).length <= MAX_PASSWORD_BYTES,
{ error: validationMessage("auth.validation.invalidPasswordLength") },
); );
const registerPassword = z
.string()
.min(MIN_PASSWORD_LENGTH, {
error: validationMessage("auth.validation.invalidPasswordLength"),
})
.max(MAX_PASSWORD_BYTES, {
error: validationMessage("auth.validation.invalidPasswordLength"),
})
.refine(isPrintableAscii, {
error: validationMessage("auth.validation.invalidPasswordLength"),
});
const loginSchema = z.object({ const loginSchema = z.object({
email, email,
password: loginPassword, password,
passwordConfirmation: z.string().optional(), passwordConfirmation: z.string().optional(),
}); });
const registerSchema = loginSchema const registerSchema = loginSchema.refine(
.extend({ password: registerPassword }) value => value.password === value.passwordConfirmation,
.refine( {
value => value.password === value.passwordConfirmation, error: validationMessage("auth.validation.passwordsDoNotMatch"),
{ path: ["passwordConfirmation"],
error: validationMessage("auth.validation.passwordsDoNotMatch"), },
path: ["passwordConfirmation"], );
},
);
type AuthFormValues = z.input<typeof loginSchema>; type AuthFormValues = z.input<typeof loginSchema>;
+8 -73
View File
@@ -3,9 +3,8 @@ import type { FC, PropsWithChildren } from "react";
import { QueryClientProvider, useQueryClient } from "@tanstack/react-query"; import { QueryClientProvider, useQueryClient } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react"; import { act, renderHook, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw"; import { http, HttpResponse } from "msw";
import { describe, expect, it, onTestFinished, vi } from "vitest"; import { describe, expect, it } from "vitest";
import * as sharedApi from "@/shared/api";
import { apiClient, apiQueryClient, useAuthenticatedQuery } from "@/shared/api"; import { apiClient, apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource"; import { resource } from "@/test/resource";
@@ -93,10 +92,8 @@ describe("AuthProvider", () => {
); );
}); });
it("settles unauthenticated and clears cached data when cold-start refresh is rejected with 401", async () => { it("settles unauthenticated and clears cached data when cold-start refresh has a transport error", async () => {
server.use(http.post("*/api/v1/auth/refresh", () => server.use(http.post("*/api/v1/auth/refresh", () => HttpResponse.error()));
HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }),
));
const queryClient = createTestQueryClient(); const queryClient = createTestQueryClient();
queryClient.setQueryData(["cached-query"], { value: "stale" }); queryClient.setQueryData(["cached-query"], { value: "stale" });
queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]); queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]);
@@ -106,54 +103,12 @@ describe("AuthProvider", () => {
await waitFor(() => expect(result.current.ready).toBe(true)); await waitFor(() => expect(result.current.ready).toBe(true));
expect(result.current.user).toBeUndefined(); expect(result.current.user).toBeUndefined();
expect(result.current.unavailable).toBe(false);
expect(await requestAuthorization()).toBeNull(); expect(await requestAuthorization()).toBeNull();
expect(queryClient.getQueryData(["cached-query"])).toBeUndefined(); expect(queryClient.getQueryData(["cached-query"])).toBeUndefined();
expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined(); expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined();
}); });
it.each([ it("keeps the original protected 401 when serialized refresh has a transport error", async () => {
["a transport error", () => HttpResponse.error()],
["a 502 from the proxy", () => new HttpResponse(null, { status: 502 })],
["a 503 problem", () => HttpResponse.json({ code: "INTERNAL_SERVER_ERROR" }, { status: 503 })],
])("settles as unavailable and keeps cached data when cold-start refresh gets %s", async (_name, failure) => {
server.use(http.post("*/api/v1/auth/refresh", failure));
const queryClient = createTestQueryClient();
queryClient.setQueryData(["cached-query"], { value: "kept" });
const wrapper: FC<PropsWithChildren> = createAuthWrapper(queryClient);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.ready).toBe(true));
expect(result.current.user).toBeUndefined();
expect(result.current.unavailable).toBe(true);
expect(queryClient.getQueryData(["cached-query"])).toEqual({ value: "kept" });
});
it("restores the session when retry succeeds after an outage", async () => {
let isOutage = true;
server.use(http.post("*/api/v1/auth/refresh", () =>
isOutage
? new HttpResponse(null, { status: 502 })
: HttpResponse.json({
accessToken: "restored-token",
user: { email: "user@example.com", id: "user-1" },
}),
));
const queryClient = createTestQueryClient();
const wrapper: FC<PropsWithChildren> = createAuthWrapper(queryClient);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.unavailable).toBe(true));
isOutage = false;
act(() => result.current.retry());
await waitFor(() => expect(result.current.user?.id).toBe("user-1"));
expect(result.current.unavailable).toBe(false);
expect(await requestAuthorization()).toBe("Bearer restored-token");
});
it("keeps the original protected 401 and the session when serialized refresh has a transport error", async () => {
let refreshCalls = 0; let refreshCalls = 0;
server.use( server.use(
http.post("*/api/v1/auth/refresh", () => { http.post("*/api/v1/auth/refresh", () => {
@@ -182,11 +137,10 @@ describe("AuthProvider", () => {
const response = await apiClient.GET("/api/v1/brokerage-accounts"); const response = await apiClient.GET("/api/v1/brokerage-accounts");
expect(response.response.status).toBe(401); expect(response.response.status).toBe(401);
// An unreachable server does not prove the session ended; the next 401 refreshes again. await waitFor(() => expect(result.current.user).toBeUndefined());
await waitFor(() => expect(result.current.unavailable).toBe(true)); expect(await requestAuthorization()).toBeNull();
expect(result.current.user?.id).toBe("user-1"); expect(queryClient.getQueryData(["cached-query"])).toBeUndefined();
expect(queryClient.getQueryData(["cached-query"])).toEqual({ value: "stale" }); expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined();
expect(queryClient.getQueryData(bankCardsKey)).toEqual([{ id: "bank-card-1" }]);
}); });
it("clears connection status and financial cache independently on logout", async () => { it("clears connection status and financial cache independently on logout", async () => {
@@ -399,23 +353,4 @@ describe("AuthProvider", () => {
expect(result.current.cards.data?.[0]?.id).toBe("b-card"), expect(result.current.cards.data?.[0]?.id).toBe("b-card"),
); );
}); });
it("registers auth handlers once until unmount", async () => {
const configure = vi.spyOn(sharedApi, "configureAuthClient");
onTestFinished(() => configure.mockRestore());
const { rerender, result, unmount } = renderHook(useAuth, {
wrapper: createAuthWrapper(createTestQueryClient()),
});
await waitFor(() => expect(result.current.ready).toBe(true));
const registrations = configure.mock.calls.length;
rerender();
expect(configure).toHaveBeenCalledTimes(registrations);
unmount();
expect(configure).toHaveBeenCalledTimes(registrations + 1);
expect(configure).toHaveBeenLastCalledWith();
});
}); });
+7 -24
View File
@@ -5,9 +5,6 @@ import { apiClient, configureAuthClient, type paths } from "@/shared/api";
import { type User } from "./auth-context"; import { type User } from "./auth-context";
// A 5xx or network error means the server is unreachable, not that the session ended.
const HTTP_SERVER_ERROR = 500;
type Authentication type Authentication
= paths["/api/v1/auth/login"]["post"]["responses"][200]["content"]["application/json"]; = paths["/api/v1/auth/login"]["post"]["responses"][200]["content"]["application/json"];
@@ -35,8 +32,6 @@ function useAuthProviderVm() {
const accessTokenRef = useRef<string | undefined>(undefined); const accessTokenRef = useRef<string | undefined>(undefined);
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const [signedOut, setSignedOut] = useState(false); const [signedOut, setSignedOut] = useState(false);
const [unavailable, setUnavailable] = useState(false);
const unavailableRef = useRef(false);
const generationRef = useRef(0); const generationRef = useRef(0);
const userRef = useRef<undefined | User>(undefined); const userRef = useRef<undefined | User>(undefined);
@@ -54,18 +49,12 @@ function useAuthProviderVm() {
return next; return next;
}, []); }, []);
const markUnavailable = useCallback((isUnavailable: boolean) => {
unavailableRef.current = isUnavailable;
setUnavailable(isUnavailable);
}, []);
const applyAuthentication = useCallback( const applyAuthentication = useCallback(
(authentication: Authentication) => { (authentication: Authentication) => {
const changedIdentity const changedIdentity
= userRef.current && userRef.current.id !== authentication.user.id; = userRef.current && userRef.current.id !== authentication.user.id;
setSignedOut(false); setSignedOut(false);
markUnavailable(false);
accessTokenRef.current = authentication.accessToken; accessTokenRef.current = authentication.accessToken;
if (changedIdentity) { if (changedIdentity) {
@@ -78,17 +67,16 @@ function useAuthProviderVm() {
userRef.current = authentication.user; userRef.current = authentication.user;
setUser(authentication.user); setUser(authentication.user);
}, },
[markUnavailable, queryClient], [queryClient],
); );
const clearSession = useCallback(() => { const clearSession = useCallback(() => {
markUnavailable(false);
generationRef.current += 1; generationRef.current += 1;
userRef.current = undefined; userRef.current = undefined;
setUser(undefined); setUser(undefined);
accessTokenRef.current = undefined; accessTokenRef.current = undefined;
queryClient.clear(); queryClient.clear();
}, [markUnavailable, queryClient]); }, [queryClient]);
const refresh = useCallback((): Promise<boolean> => { const refresh = useCallback((): Promise<boolean> => {
if (refreshPromiseRef.current) return refreshPromiseRef.current; if (refreshPromiseRef.current) return refreshPromiseRef.current;
@@ -98,12 +86,11 @@ function useAuthProviderVm() {
if (started !== generationRef.current) return false; if (started !== generationRef.current) return false;
try { try {
const { data, response } = await apiClient.POST("/api/v1/auth/refresh"); const { data } = await apiClient.POST("/api/v1/auth/refresh");
if (started !== generationRef.current) return false; if (started !== generationRef.current) return false;
if (!data) { if (!data) {
if (response.status >= HTTP_SERVER_ERROR) markUnavailable(true); clearSession();
else clearSession();
return false; return false;
} }
@@ -113,7 +100,7 @@ function useAuthProviderVm() {
return true; return true;
} }
catch { catch {
if (started === generationRef.current) markUnavailable(true); if (started === generationRef.current) clearSession();
return false; return false;
} }
@@ -125,7 +112,7 @@ function useAuthProviderVm() {
refreshPromiseRef.current = pending; refreshPromiseRef.current = pending;
return pending; return pending;
}, [applyAuthentication, clearSession, markUnavailable, serialize]); }, [applyAuthentication, clearSession, serialize]);
useEffect(() => { useEffect(() => {
void refresh().finally(() => setReady(true)); void refresh().finally(() => setReady(true));
@@ -159,10 +146,8 @@ function useAuthProviderVm() {
clear: clearSession, clear: clearSession,
generation: () => generationRef.current, generation: () => generationRef.current,
refresh, refresh,
unavailable: () => unavailableRef.current,
}); });
// `user` tracks access-token changes, which configureAuthClient publishes as session state. });
}, [clearSession, refresh, user]);
useEffect(() => () => configureAuthClient(), []); useEffect(() => () => configureAuthClient(), []);
const authContextValue = { const authContextValue = {
@@ -172,9 +157,7 @@ function useAuthProviderVm() {
ready, ready,
register: (email: string, password: string) => register: (email: string, password: string) =>
authenticate("/api/v1/auth/register", email, password), authenticate("/api/v1/auth/register", email, password),
retry: () => void refresh(),
signedOut, signedOut,
unavailable,
user, user,
}; };
@@ -30,79 +30,4 @@ describe("AuthForm", () => {
await waitFor(() => expect(onSubmit).not.toHaveBeenCalled()); await waitFor(() => expect(onSubmit).not.toHaveBeenCalled());
expect(await screen.findByText("Пароли не совпадают")).toBeInTheDocument(); expect(await screen.findByText("Пароли не совпадают")).toBeInTheDocument();
}); });
it.each([
["register", "Регистрация", "Уже есть аккаунт? Войти"],
["login", "Вход", "Нет аккаунта? Зарегистрироваться"],
])("blocks the switch-auth link while %s is submitting", async (_mode, submitLabel, linkLabel) => {
const { promise, resolve } = Promise.withResolvers<void>();
const onSubmit = vi.fn(() => promise);
render(
<MantineProvider>
<MemoryRouter>
<AuthForm
onSubmit={onSubmit}
register={_mode === "register"}
/>
</MemoryRouter>
</MantineProvider>,
);
fireEvent.change(screen.getByLabelText("Email"), {
target: { value: "user@example.com" },
});
fireEvent.change(screen.getByLabelText("Пароль"), {
target: { value: "a secure password" },
});
if (_mode === "register") {
fireEvent.change(screen.getByLabelText("Подтвердите пароль"), {
target: { value: "a secure password" },
});
}
fireEvent.click(screen.getByRole("button", { name: submitLabel }));
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
expect(screen.queryByRole("link", { name: linkLabel })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: linkLabel })).toBeDisabled();
resolve();
await screen.findByRole("link", { name: linkLabel });
});
// Register policy is 8..72 printable ASCII; login accepts legacy passwords.
it.each([
["register", "Регистрация", "пароль-пароль", false],
["register", "Регистрация", "passw😀rd", false],
["register", "Регистрация", "a".repeat(7), false],
["register", "Регистрация", "a".repeat(8), true],
["register", "Регистрация", "a".repeat(72), true],
["register", "Регистрация", "a".repeat(73), false],
["login", "Вход", "пароль-пароль", true],
])("%s submits password %j: %s", async (mode, submitLabel, password, submitted) => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(
<MantineProvider>
<MemoryRouter>
<AuthForm onSubmit={onSubmit} register={mode === "register"} />
</MemoryRouter>
</MantineProvider>,
);
fireEvent.change(screen.getByLabelText("Email"), { target: { value: "user@example.com" } });
fireEvent.change(screen.getByLabelText("Пароль"), { target: { value: password } });
if (mode === "register") {
fireEvent.change(screen.getByLabelText("Подтвердите пароль"), { target: { value: password } });
}
fireEvent.click(screen.getByRole("button", { name: submitLabel }));
await waitFor(() => expect(
screen.queryByText(/Пароль: от 8 до 72 символов/) === null,
).toBe(submitted));
expect(onSubmit).toHaveBeenCalledTimes(submitted ? 1 : 0);
});
}); });
+5 -21
View File
@@ -1,4 +1,4 @@
import type { FC, ReactNode } from "react"; import type { FC } from "react";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Alert, Button, Fieldset, PasswordInput, Stack, TextInput } from "@mantine/core"; import { Alert, Button, Fieldset, PasswordInput, Stack, TextInput } from "@mantine/core";
@@ -15,7 +15,7 @@ import {
type AuthFormProps = { type AuthFormProps = {
onSubmit: (email: string, password: string) => Promise<void>; onSubmit: (email: string, password: string) => Promise<void>;
register?: boolean; register?: true;
}; };
const AuthForm: FC<AuthFormProps> = (props) => { const AuthForm: FC<AuthFormProps> = (props) => {
@@ -45,24 +45,6 @@ const AuthForm: FC<AuthFormProps> = (props) => {
}); });
const to = register ? "/login" : "/register"; const to = register ? "/login" : "/register";
const switchLabel = t(register ? "auth.haveAccount" : "auth.noAccount");
let switchLink: ReactNode;
if (isSubmitting) {
switchLink = (
<Button aria-disabled disabled variant="subtle">
{switchLabel}
</Button>
);
}
else {
switchLink = (
<Button component={Link} to={to} variant="subtle">
{switchLabel}
</Button>
);
}
return ( return (
<FormProvider {...methods}> <FormProvider {...methods}>
@@ -95,7 +77,9 @@ const AuthForm: FC<AuthFormProps> = (props) => {
{t(register ? "auth.register" : "auth.login")} {t(register ? "auth.register" : "auth.login")}
</Button> </Button>
{switchLink} <Button component={Link} to={to} variant="subtle">
{t(register ? "auth.haveAccount" : "auth.noAccount")}
</Button>
</Stack> </Stack>
</Fieldset> </Fieldset>
</form> </form>
@@ -78,35 +78,4 @@ describe("read-only screen synchronization", () => {
await act(async () => result.current.refresh()); await act(async () => result.current.refresh());
await waitFor(() => expect(gets).toBe(2)); await waitFor(() => expect(gets).toBe(2));
}); });
it("does not manually refetch disabled resources", async () => {
vi.useFakeTimers();
const refetch = vi.fn(() => Promise.resolve());
const resource = {
isEnabled: false,
isError: true,
isFetching: false,
queryKey: [],
refetch,
};
try {
const { result, unmount } = renderHook(() =>
useScreenSync({ screen: "accounts" }, [resource]),
);
await act(async () => {
await result.current.refresh();
await result.current.retry();
});
await act(() => vi.advanceTimersByTime(1_800_000));
expect(refetch).not.toHaveBeenCalled();
unmount();
}
finally {
vi.useRealTimers();
}
});
}); });
@@ -5,7 +5,6 @@ import { useEffect, useRef } from "react";
import type { InvestmentResourceMeta } from "@/shared/api"; import type { InvestmentResourceMeta } from "@/shared/api";
type ScreenResource = { type ScreenResource = {
isEnabled: boolean;
isError: boolean; isError: boolean;
isFetching: boolean; isFetching: boolean;
meta?: InvestmentResourceMeta; meta?: InvestmentResourceMeta;
@@ -28,31 +27,23 @@ function useScreenSync(_scope: ScreenScope, resources: ScreenResource[]) {
useEffect(() => { useEffect(() => {
const timer = setInterval(() => { const timer = setInterval(() => {
for (const resource of resourcesRef.current) { for (const resource of resourcesRef.current)
if (!resource.isEnabled || resource.isFetching) continue;
void resource.refetch().catch(() => {}); void resource.refetch().catch(() => {});
}
}, READ_INTERVAL_MS); }, READ_INTERVAL_MS);
return () => clearInterval(timer); return () => clearInterval(timer);
}, []); }, []);
const isRefreshing = resources.some( const isRefreshing = resources.some(resource => resource.isFetching);
resource => resource.isEnabled && resource.isFetching,
);
const eligible = resources.filter(
resource => resource.isEnabled && !resource.isFetching,
);
const refresh = async () => { const refresh = async () => {
if (isRefreshing) return; if (isRefreshing) return;
await Promise.allSettled(eligible.map(resource => resource.refetch())); await Promise.allSettled(resources.map(resource => resource.refetch()));
}; };
// Retry only the HTTP read; source attempts are reported in resource metadata. // Retry only the HTTP read; source attempts are reported in resource metadata.
const retry = async () => { const retry = async () => {
const failed = eligible.filter(resource => resource.isError); const failed = resources.filter(resource => resource.isError);
if (failed.length > 0) if (failed.length > 0)
await Promise.allSettled(failed.map(resource => resource.refetch())); await Promise.allSettled(failed.map(resource => resource.refetch()));
@@ -17,10 +17,10 @@ import {
useLocation, useLocation,
useNavigate, useNavigate,
} from "react-router"; } from "react-router";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
import { defaultPeriod, formatDate, moscowDate } from "@/shared/lib"; import { defaultPeriod, formatDate } from "@/shared/lib";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource"; import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
@@ -72,14 +72,7 @@ function renderEvents(accountId: string, searches: string[]) {
} }
describe("Upcoming event date filters", () => { describe("Upcoming event date filters", () => {
afterEach(() => {
vi.useRealTimers();
});
beforeEach(() => { beforeEach(() => {
// Pin "today": the test picks today + 10 days, which must be inside the visible calendar grid.
// Real dates can push it beyond the grid (e.g. 2026-10-23 -> 2 November).
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-08-10T09:00:00Z"));
configureAuthClient({ configureAuthClient({
accessToken: () => "token", accessToken: () => "token",
clear: vi.fn(), clear: vi.fn(),
@@ -87,8 +80,6 @@ describe("Upcoming event date filters", () => {
}); });
}); });
afterEach(() => vi.useRealTimers());
it("keeps reads and sync disabled without an account scope", async () => { it("keeps reads and sync disabled without an account scope", async () => {
const requests: string[] = []; const requests: string[] = [];
server.use( server.use(
@@ -238,10 +229,6 @@ describe("Upcoming event date filters", () => {
it.each(["account-1", "all"])( it.each(["account-1", "all"])(
"applies dates only on submit, restores URL dates, and resets from empty results for %s", "applies dates only on submit, restores URL dates, and resets from empty results for %s",
async (accountId) => { async (accountId) => {
// Pin "today" mid-month so "today + 10 days" stays in the calendar's
// visible month regardless of the real date.
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-10-15T12:00:00+03:00"));
const [defaultFrom, defaultTo] = defaultPeriod(); const [defaultFrom, defaultTo] = defaultPeriod();
const searches: string[] = []; const searches: string[] = [];
server.use( server.use(
@@ -266,58 +253,37 @@ describe("Upcoming event date filters", () => {
expect(from).toHaveTextContent("01.02.2026 – 28.02.2026"); expect(from).toHaveTextContent("01.02.2026 – 28.02.2026");
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
expect(searches).toEqual(["?from=2026-02-01&to=2026-02-28"]); expect(searches).toEqual(["?from=2026-02-01&to=2026-02-28"]);
fireEvent.click(from);
// The calendar opens on the applied month, so reset to the default
// range first, then pick today and a future date.
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
await waitFor(() => expect(screen.getByLabelText("URL")).toBeEmptyDOMElement());
const picker = await screen.findByLabelText("Период");
const [today] = defaultPeriod();
const [year = 1970, month = 0, day = 1] = today.split("-").map(Number);
const future = moscowDate(new Date(
Date.UTC(year, month - 1, day + 10, 12),
));
// Mantine calendar labels follow dayjs default (English) locale.
const calendarLabel = (date: string) => {
const monthName = new Intl.DateTimeFormat("en", { month: "long" })
.format(new Date(`${date}T12:00:00Z`));
const pattern = `${Number(date.slice(8))} ${monthName} ${date.slice(0, 4)}`;
// Dates are constructed above from validated defaultPeriod output.
// eslint-disable-next-line security/detect-non-literal-regexp -- see comment above
return new RegExp(pattern, "i");
};
fireEvent.click(picker);
fireEvent.click( fireEvent.click(
await screen.findByRole("button", { name: calendarLabel(today) }), await screen.findByRole("button", { name: /10 February 2026/i }),
); );
fireEvent.click( fireEvent.click(
await screen.findByRole("button", { name: calendarLabel(future) }), await screen.findByRole("button", { name: /28 February 2026/i }),
); );
// The reset itself fetched the default range; picking dates applies only on submit. expect(searches).toHaveLength(1);
expect(searches).toEqual([
"?from=2026-02-01&to=2026-02-28",
`?from=${defaultFrom}&to=${defaultTo}`,
]);
fireEvent.click(screen.getByRole("button", { name: "Применить" })); fireEvent.click(screen.getByRole("button", { name: "Применить" }));
await waitFor(() => await waitFor(() =>
expect(searches.at(-1)).toBe(`?from=${today}&to=${future}`), expect(searches.at(-1)).toBe("?from=2026-02-10&to=2026-02-28"),
); );
expect(await screen.findByLabelText("Период")).toHaveTextContent( expect(await screen.findByLabelText("Период")).toHaveTextContent(
`${formatDate(today)} – ${formatDate(future)}`, "10.02.2026 – 28.02.2026",
); );
fireEvent.click(screen.getByRole("button", { name: "History back" })); fireEvent.click(screen.getByRole("button", { name: "History back" }));
await waitFor(() => await waitFor(() =>
expect(screen.getByLabelText("Период")).toHaveTextContent( expect(screen.getByLabelText("Период")).toHaveTextContent(
`${formatDate(defaultFrom)} – ${formatDate(defaultTo)}`, "01.02.2026 – 28.02.2026",
), ),
); );
expect(screen.getByLabelText("URL")).toBeEmptyDOMElement(); expect(screen.getByLabelText("URL")).toHaveTextContent(
"?from=2026-02-01&to=2026-02-28",
);
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
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.getByRole("button", { name: "Применить" })).toBeDisabled();
expect( expect(
screen.getByText("За выбранный период событий не найдено"), screen.getByText("За выбранный период событий не найдено"),
@@ -156,7 +156,7 @@ describe("AccountEventsPage", () => {
expect(screen.getAllByText(/RU000A0JX0J2$/)).toHaveLength(1); expect(screen.getAllByText(/RU000A0JX0J2$/)).toHaveLength(1);
expect(screen.getByText("25.08.2026")).toBeVisible(); expect(screen.getByText("25.08.2026")).toBeVisible();
expect(screen.queryByText(/Дата события/)).not.toBeInTheDocument(); expect(screen.queryByText(/Дата события/)).not.toBeInTheDocument();
expect(screen.getAllByText("Прогноз")).toHaveLength(2); expect(screen.getAllByText("Ожидаемая сумма")).toHaveLength(2);
const logo = screen.queryByAltText(""); const logo = screen.queryByAltText("");
expect(logo).toHaveAttribute( expect(logo).toHaveAttribute(
"src", "src",
@@ -211,105 +211,4 @@ describe("AccountEventsPage", () => {
expect(status).toHaveAttribute("aria-busy", "true"); expect(status).toHaveAttribute("aria-busy", "true");
expect(within(status).queryByRole("button")).not.toBeInTheDocument(); expect(within(status).queryByRole("button")).not.toBeInTheDocument();
}); });
it("keeps a null events envelope loading instead of an empty period", () => {
query.mockReturnValue(
{
data: null,
isError: false,
isLoading: false,
meta: {
availability: "available",
completeness: "complete",
lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" },
},
refetch: vi.fn(),
} as never,
);
renderPage();
expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute(
"aria-busy",
"true",
);
expect(
screen.queryByText("За выбранный период событий не найдено"),
).not.toBeInTheDocument();
});
it("groups same-day instrument events only in all-account mode", () => {
const events = [
{
account: { id: "broker", name: "Брокерский" },
date: "2026-08-20",
dateKind: "event",
displayName: "ОФЗ 26238",
figi: "FIGI",
instrumentUid: "UID",
isin: "RU000A0JX0J2",
logoName: null,
projectedAmount: { currency: "RUB", value: "200" },
ticker: "OFZ",
type: "coupon",
},
{
account: { id: "iis", name: "ИИС" },
date: "2026-08-20",
dateKind: "event",
displayName: "ОФЗ 26238",
figi: "FIGI",
instrumentUid: "UID",
isin: "RU000A0JX0J2",
logoName: null,
projectedAmount: { currency: "RUB", value: "100" },
ticker: "OFZ",
type: "coupon",
},
] as const;
query.mockReturnValue({
data: {
accountId: "all",
calculatedAt: "2026-08-14T00:00:00Z",
events: [...events],
},
isError: false,
isLoading: false,
meta: {
availability: "available",
completeness: "complete",
lastAttempt: {
finishedAt: null,
problems: [],
startedAt: null,
state: "none",
},
},
refetch: vi.fn(),
} as never);
render(
<MantineProvider>
<MemoryRouter initialEntries={["/accounts/all/events"]}>
<Routes>
<Route
element={<AccountEventsPage />}
path="/accounts/:accountId/events"
/>
</Routes>
</MemoryRouter>
</MantineProvider>,
);
expect(screen.getByText("Купон · ОФЗ 26238")).toBeInTheDocument();
expect(screen.getByText("300 ₽")).toBeInTheDocument();
const accounts = within(screen.getByRole("list", {
name: /Купон · ОФЗ 26238, 20\.08\.2026, 300 ₽/,
}));
expect(accounts.getByText("Брокерский")).toBeInTheDocument();
expect(accounts.getByText("ИИС")).toBeInTheDocument();
});
}); });
@@ -18,11 +18,10 @@ import {
type AccountEventsPageViewModel type AccountEventsPageViewModel
= | { = | {
allAccounts: boolean;
canApply: boolean; canApply: boolean;
draftPeriod: [null | string, null | string]; draftPeriod: [null | string, null | string];
events: BrokerageEvent[]; events: BrokerageEvent[];
minDate: string; maxDate: string;
onApply: () => void; onApply: () => void;
onPeriodChange: (value: [null | string, null | string]) => void; onPeriodChange: (value: [null | string, null | string]) => void;
onPeriodPopoverOpen: () => void; onPeriodPopoverOpen: () => void;
@@ -30,7 +29,6 @@ type AccountEventsPageViewModel
partial?: boolean; partial?: boolean;
source?: InvestmentResourceMeta; source?: InvestmentResourceMeta;
state: "loaded"; state: "loaded";
totalVisible: boolean;
} }
| { onReset: () => void; state: "error" } | { onReset: () => void; state: "error" }
| { state: "loading" }; | { state: "loading" };
@@ -51,7 +49,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
const [draftFrom, draftTo] = draftPeriod; const [draftFrom, draftTo] = draftPeriod;
const [minDate, setMinDate] = useState(() => moscowDate(new Date())); const [maxDate, setMaxDate] = useState(() => moscowDate(new Date()));
const scope = JSON.stringify([accountId, appliedFrom, appliedTo]); const scope = JSON.stringify([accountId, appliedFrom, appliedTo]);
const [previousScope, setPreviousScope] = useState(scope); const [previousScope, setPreviousScope] = useState(scope);
@@ -102,13 +100,12 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
: { state: "loading" }; : { state: "loading" };
return { return {
allAccounts: accountId === "all",
canApply: canApply:
(draftFrom !== appliedFrom || draftTo !== appliedTo) (draftFrom !== appliedFrom || draftTo !== appliedTo)
&& isValidPeriod(draftFrom, draftTo), && isValidPeriod(draftFrom, draftTo),
draftPeriod, draftPeriod,
events: events.data.events, events: events.data.events,
minDate, maxDate,
onApply: () => { onApply: () => {
if (!isValidPeriod(draftFrom, draftTo)) return; if (!isValidPeriod(draftFrom, draftTo)) return;
setSearchParameters({ setSearchParameters({
@@ -119,12 +116,11 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
onPeriodChange: value => setDraftPeriod( onPeriodChange: value => setDraftPeriod(
value[0] === null && value[1] === null ? defaultPeriod() : value, value[0] === null && value[1] === null ? defaultPeriod() : value,
), ),
onPeriodPopoverOpen: () => setMinDate(moscowDate(new Date())), onPeriodPopoverOpen: () => setMaxDate(moscowDate(new Date())),
onReset, onReset,
partial: events.meta?.completeness === "incomplete", partial: events.meta?.completeness === "incomplete",
source: events.meta, source: events.meta,
state: "loaded", state: "loaded",
totalVisible: events.meta?.completeness === "complete",
}; };
} }
@@ -13,11 +13,8 @@ import { useTranslation } from "react-i18next";
import { import {
BrokerageEventCard, BrokerageEventCard,
BrokerageEventGroup,
brokerageEventGroupKey,
brokerageEventKey, brokerageEventKey,
} from "@/entities/brokerage-event"; } from "@/entities/brokerage-event";
import { groupItems } from "@/shared/lib";
import { SourceSyncStatus } from "@/shared/ui"; import { SourceSyncStatus } from "@/shared/ui";
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm"; import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
@@ -33,32 +30,14 @@ const AccountEventsPageLoadedEvents: FC<AccountEventsPageLoadedProps> = (
props, props,
) => { ) => {
if (props.events.length > 0) { if (props.events.length > 0) {
const entries = groupItems(
props.events,
props.allAccounts ? brokerageEventGroupKey : () => null,
);
return ( return (
<Stack <Stack gap="sm">
component="ul" {props.events.map(event => (
gap="sm" <BrokerageEventCard
m={0} event={event}
p={0} key={brokerageEventKey(event)}
style={{ listStyle: "none" }} />
> ))}
{entries.map(entry => entry.type === "group"
? (
<BrokerageEventGroup
events={entry.items}
key={entry.key}
totalVisible={props.totalVisible}
/>
)
: (
<li key={brokerageEventKey(entry.item)}>
<BrokerageEventCard event={entry.item} />
</li>
))}
</Stack> </Stack>
); );
} }
@@ -93,7 +72,7 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
clearable clearable
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }} clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
label={t("ledger.period")} label={t("ledger.period")}
minDate={props.minDate} maxDate={props.maxDate}
onChange={props.onPeriodChange} onChange={props.onPeriodChange}
popoverProps={{ popoverProps={{
middlewares: { shift: { crossAxis: true } }, middlewares: { shift: { crossAxis: true } },
+157 -14
View File
@@ -2,25 +2,168 @@ import { describe, expect, it } from "vitest";
import type { IncomeAnalytics } from "@/entities/income"; import type { IncomeAnalytics } from "@/entities/income";
import { resource } from "@/test/resource";
import { toIncomeDisplayModel } from "./display"; import { toIncomeDisplayModel } from "./display";
describe("income payments display", () => { const analytics: IncomeAnalytics = {
it("keeps repeated IDs, order and logo revisions without reconstructing financial data", () => { accountId: "account-1",
const payment = { bucketGranularity: "day",
id: "same", buckets: [],
instrumentKey: "bond:RUB", calculatedAt: "2026-08-27T00:00:00Z",
logoName: null, entries: [],
} as IncomeAnalytics["entries"][number]; instruments: [],
period: { from: "2026-08-01", to: "2026-08-27" },
previous: {
period: { from: "2026-07-01", to: "2026-07-31" },
totalsByCurrency: [comparison("RUB")],
},
totalsByCurrency: [],
yearComparison: null,
};
const analytics = { function comparison(currency: string) {
entries: [payment, payment, { ...payment, logoName: "bond.png" }], return {
} as IncomeAnalytics; compared: summary(currency, "1", "0"),
currency,
difference: summary(currency, "9", "2"),
selected: summary(currency, "10", "2"),
};
}
const display = toIncomeDisplayModel(analytics); function money(currency: string, value: string) {
return { currency, value };
}
expect(display.entries.map(({ operation }) => operation)).toEqual( function summary(currency: string, coupon: string, dividend: string) {
analytics.entries, return {
coupon: money(currency, coupon),
currency,
dividend: money(currency, dividend),
total: money(currency, String(Number(coupon) + Number(dividend))),
};
}
describe("income comparison display", () => {
it.each([
["25", "100", "+25 %"],
["-25", "100", "-25 %"],
["0", "100", "0 %"],
["-0.000", "100", "0 %"],
["0.0001", "100", "0 %"],
["1", "3", "+33,3 %"],
["0.01", "0.04", "+25 %"],
["123456789012345678.123456789", "123456789012345678.123456789", "+100 %"],
[
"10",
"0",
null,
],
[
"0",
"0",
null,
],
[
"10",
"-0.00",
null,
],
[
"10",
"-100",
null,
],
[
"9".repeat(400),
"1",
null,
],
[
"1",
"9".repeat(400),
null,
],
])("formats change %s against base %s", (difference, base, expected) => {
const value = comparison("RUB");
value.difference.coupon = money("RUB", difference);
value.compared.coupon = money("RUB", base);
const display = toIncomeDisplayModel({
...analytics,
yearComparison: {
period: { from: "2025-08-01", to: "2025-08-27" },
totalsByCurrency: [value],
},
});
const result = display?.comparison?.values[0];
expect(
result?.percentages.coupon?.replaceAll("\u{A0}", " ")
?? null,
).toBe(expected);
expect(result?.difference.coupon.value).toBe(difference);
expect(result?.compared.coupon.value).toBe(base);
});
it("formats calendar ranges across years and preserves separate currencies", () => {
const display = toIncomeDisplayModel({
...analytics,
period: { from: "2025-12-01", to: "2026-01-09" },
yearComparison: {
period: { from: "2024-12-01", to: "2025-01-09" },
totalsByCurrency: [comparison("RUB"), comparison("USD")],
},
});
expect(display?.comparison?.selectedPeriod.replaceAll(/\s/g, " ")).toBe(
"01.12.2025 – 09.01.2026",
); );
expect(new Set(display.entries.map(({ key }) => key)).size).toBe(3); expect(display?.comparison?.comparedPeriod.replaceAll(/\s/g, " ")).toBe(
"01.12.2024 – 09.01.2025",
);
expect(display?.comparison?.values.map(value => value.currency)).toEqual([
"RUB",
"USD",
]);
}); });
}); });
it("maps comparison notices by role regardless of dependency order or resource key", () => {
const selected = { ...resource(analytics).meta, role: "selected" as const };
const previous
= {
...resource(null).meta,
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 = {
...resource(null).meta,
resourceKey: "opaque/previous-looking-key",
role: "year" as const,
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
};
for (const dependencies of [
[selected, previous, year],
[year, selected, previous],
]) {
const display = toIncomeDisplayModel(analytics, {
...resource(analytics).meta,
dependencies,
});
expect(display?.previousNotice).toBe("comparisonFailed");
expect(display?.yearNotice).toBe("yearUnavailable");
expect(display?.previousComparison).not.toBeNull();
}
});
+103 -2
View File
@@ -1,11 +1,102 @@
import type { IncomeAnalytics } from "@/entities/income"; import type { IncomeAnalytics, IncomeAnalyticsMeta } from "@/entities/income";
import { formatDate } from "@/shared/lib";
import type { IncomePeriod } from "./period";
type ComparisonNotice
= | "comparisonFailed"
| "comparisonPending"
| "previousUnavailable"
| "yearUnavailable";
type IncomeDisplayModel = { type IncomeDisplayModel = {
comparison: null
| {
comparedPeriod: string;
selectedPeriod: string;
values: (NonNullable<
IncomeAnalytics["yearComparison"]
>["totalsByCurrency"][number] & {
percentages: Record<"coupon"
| "dividend"
| "total", null | string>;
})[];
};
entries: { key: string; operation: IncomeAnalytics["entries"][number] }[]; entries: { key: string; operation: IncomeAnalytics["entries"][number] }[];
previousComparison: IncomeDisplayModel["comparison"];
previousNotice: ComparisonNotice;
yearNotice: ComparisonNotice;
}; };
function comparisonDisplay(
period: IncomePeriod,
comparison: IncomeAnalytics["yearComparison"],
): IncomeDisplayModel["comparison"] {
if (!comparison)
return null;
return {
comparedPeriod: formatComparisonPeriod(comparison.period),
selectedPeriod: formatComparisonPeriod(period),
values: comparison.totalsByCurrency.map(value => ({
...value,
percentages: {
coupon: formatIncomeChange(
value.difference.coupon.value,
value.compared.coupon.value,
),
dividend: formatIncomeChange(
value.difference.dividend.value,
value.compared.dividend.value,
),
total: formatIncomeChange(
value.difference.total.value,
value.compared.total.value,
),
},
})),
};
}
function comparisonNotice(
dependency:
IncomeAnalyticsMeta["dependencies"][number] | undefined,
unavailable: "previousUnavailable" | "yearUnavailable",
): ComparisonNotice {
if (!dependency || dependency.unavailableReason) return unavailable;
return dependency.lastAttempt.state === "failed"
? "comparisonFailed"
: "comparisonPending";
}
function formatComparisonPeriod(period: IncomePeriod): string {
return `${formatDate(period.from)} – ${formatDate(period.to)}`;
}
function formatIncomeChange(
difference: string,
compared: string,
): null | string {
// Display-only ratio; server-provided Money values remain exact decimal strings.
const base = Number(compared);
if (!Number.isFinite(base) || base <= 0)
return null;
const ratio = Number(difference) / base;
if (!Number.isFinite(ratio))
return null;
return new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 1,
signDisplay: "exceptZero",
style: "percent",
}).format(ratio);
}
function toIncomeDisplayModel( function toIncomeDisplayModel(
analytics: IncomeAnalytics, analytics: IncomeAnalytics,
meta?: IncomeAnalyticsMeta,
): IncomeDisplayModel { ): IncomeDisplayModel {
const entries: IncomeDisplayModel["entries"] = []; const entries: IncomeDisplayModel["entries"] = [];
const occurrences = new Map<string, number>(); const occurrences = new Map<string, number>();
@@ -25,7 +116,17 @@ function toIncomeDisplayModel(
}); });
} }
return { entries }; const dependencies = meta?.dependencies ?? [];
const previous = dependencies.find(({ role }) => role === "previous");
const year = dependencies.find(({ role }) => role === "year");
return {
comparison: comparisonDisplay(analytics.period, analytics.yearComparison),
entries,
previousComparison: comparisonDisplay(analytics.period, analytics.previous),
previousNotice: comparisonNotice(previous, "previousUnavailable"),
yearNotice: comparisonNotice(year, "yearUnavailable"),
};
} }
export { type IncomeDisplayModel, toIncomeDisplayModel }; export { type IncomeDisplayModel, toIncomeDisplayModel };
@@ -22,6 +22,123 @@
min-width: 0; min-width: 0;
} }
.comparison {
container-type: inline-size;
}
.comparisonGrid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--mantine-spacing-sm);
}
.comparisonPanel {
min-width: 0;
padding: var(--mantine-spacing-md);
border-radius: var(--mantine-radius-md);
border-top: 3px solid var(--mantine-color-gray-4);
background: var(--mantine-color-gray-0);
}
.comparisonPanel[data-kind="selected"] {
border-color: var(--mantine-color-blue-6);
background: var(--mantine-color-blue-0);
}
.comparisonPanel[data-tone="positive"] {
border-color: var(--mantine-color-green-8);
background: var(--mantine-color-green-0);
}
.comparisonPanel[data-tone="negative"] {
border-color: var(--mantine-color-red-8);
background: var(--mantine-color-red-0);
}
.comparisonLabel,
.comparisonValues dt {
color: var(--mantine-color-gray-7);
}
.comparisonValues {
margin: var(--mantine-spacing-md) 0 0;
font-size: var(--mantine-font-size-sm);
}
.comparisonValues dd {
min-width: 0;
margin: 0;
font-weight: 600;
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
.comparisonTotal {
margin-bottom: var(--mantine-spacing-md);
padding-bottom: var(--mantine-spacing-md);
border-bottom: 1px solid var(--mantine-color-gray-3);
}
.comparisonTotal dd {
margin-top: 4px;
font-size: var(--mantine-h2-font-size);
line-height: 1.3;
font-weight: 700;
letter-spacing: -0.025em;
}
.comparisonValues dd[data-tone="positive"] {
color: color-mix(
in srgb,
var(--mantine-color-green-9) 85%,
var(--mantine-color-black)
);
}
.comparisonValues dd[data-tone="negative"] {
color: var(--mantine-color-red-9);
}
.comparisonValues dd[data-tone="neutral"] {
color: var(--mantine-color-gray-7);
}
.comparisonPanel[data-kind="selected"] .comparisonTotal dd {
color: var(--mantine-color-blue-8);
}
.comparisonDetail {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 4px var(--mantine-spacing-xs);
margin-top: var(--mantine-spacing-xs);
}
.comparisonPercent {
display: inline-block;
margin-left: var(--mantine-spacing-xs);
padding: 2px 6px;
border-radius: var(--mantine-radius-sm);
background: var(--mantine-color-white);
font-size: var(--mantine-font-size-xs);
font-weight: 600;
line-height: 1.5;
letter-spacing: normal;
vertical-align: middle;
}
.comparisonNotice {
background: var(--mantine-color-gray-0);
border-left: 3px solid var(--mantine-color-gray-4);
}
@container (min-width: 42em) {
.comparisonGrid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 35.999em) { @media (max-width: 35.999em) {
.fields { .fields {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
@@ -72,9 +72,23 @@ const analytics
}, },
], ],
period: { from: "2026-08-01", to: "2026-08-27" }, period: { from: "2026-08-01", to: "2026-08-27" },
previous: {
period: { from: "2026-07-01", to: "2026-07-31" },
totalsByCurrency: [comparison("RUB")],
},
totalsByCurrency: [summary("RUB", "10", "2")], totalsByCurrency: [summary("RUB", "10", "2")],
yearComparison: null,
} satisfies IncomeAnalytics & { instruments: [unknown] }; } satisfies IncomeAnalytics & { instruments: [unknown] };
function comparison(currency: string) {
return {
compared: summary(currency, "1", "0"),
currency,
difference: summary(currency, "9", "2"),
selected: summary(currency, "10", "2"),
};
}
function entry(): IncomeAnalytics["entries"][number] { function entry(): IncomeAnalytics["entries"][number] {
return { return {
accruedInterest: null, accruedInterest: null,
@@ -100,10 +114,7 @@ function money(currency: string, value: string) {
return { currency, value }; return { currency, value };
} }
function renderPage( function renderPage(search = "from=2026-08-01&to=2026-08-27") {
search = "from=2026-08-01&to=2026-08-27",
accountId = "account-1",
) {
configureAuthClient({ configureAuthClient({
accessToken: () => "token", accessToken: () => "token",
clear: vi.fn(), clear: vi.fn(),
@@ -116,7 +127,7 @@ function renderPage(
<DatesProvider settings={{ locale: "ru" }}> <DatesProvider settings={{ locale: "ru" }}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<MemoryRouter <MemoryRouter
initialEntries={[`/accounts/${accountId}/income?${search}`]} initialEntries={[`/accounts/account-1/income?${search}`]}
> >
<LocationProbe /> <LocationProbe />
@@ -317,24 +328,6 @@ describe("AccountIncomePage", () => {
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
it("keeps a null analytics envelope loading instead of an empty page", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(resource(null)),
),
);
renderPage();
expect(
await screen.findByRole("status", { name: "Загрузка" }, { timeout: 4000 }),
).toBeVisible();
expect(
screen.queryByRole("heading", { name: "Пассивный доход" }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Повторить" }),
).not.toBeInTheDocument();
});
it("renders the signed drill-down using opaque membership rather than instrument identity", async () => { it("renders the signed drill-down using opaque membership rather than instrument identity", async () => {
const response const response
= { = {
@@ -379,11 +372,19 @@ describe("AccountIncomePage", () => {
expect(within(panel).getByText("−2 ₽")).toBeInTheDocument(); expect(within(panel).getByText("−2 ₽")).toBeInTheDocument();
}); });
it("renders realized income without comparison blocks or event requests", async () => { it("renders realized text and comparisons without subscribing to events", async () => {
const eventsRequest = vi.fn(() => new HttpResponse(null, { status: 503 })); const eventsRequest = vi.fn(() => new HttpResponse(null, { status: 503 }));
server.use( server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(resource(analytics)), HttpResponse.json(
resource({
...analytics,
yearComparison: {
period: { from: "2025-08-01", to: "2025-08-27" },
totalsByCurrency: [comparison("RUB")],
},
}),
),
), ),
http.get("*/api/v1/brokerage-accounts/account-1/events", eventsRequest), http.get("*/api/v1/brokerage-accounts/account-1/events", eventsRequest),
); );
@@ -398,9 +399,72 @@ describe("AccountIncomePage", () => {
expect(screen.queryByText("Динамика")).not.toBeInTheDocument(); expect(screen.queryByText("Динамика")).not.toBeInTheDocument();
expect(eventsRequest).not.toHaveBeenCalled(); expect(eventsRequest).not.toHaveBeenCalled();
expect(screen.getByRole("region", { name: "Выплаты" })).toBeInTheDocument(); expect(screen.getByRole("region", { name: "Выплаты" })).toBeInTheDocument();
expect(screen.queryByText("Предыдущий период")).not.toBeInTheDocument(); const year = screen.getByRole("region", { name: "Годом ранее" });
expect(screen.queryByText("Годом ранее")).not.toBeInTheDocument();
expect(screen.queryByText(/Сравнение с/)).not.toBeInTheDocument(); const selected = within(year).getByRole("region", {
name: "01.08.2026 – 27.08.2026",
});
expect(
screen.getByRole("region", { name: "01.08.2025 – 27.08.2025" }),
).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "Предыдущий период" }),
).toBeVisible();
expect(screen.queryByText("Выбранный период")).not.toBeInTheDocument();
expect(screen.queryByText("Сравниваемый период")).not.toBeInTheDocument();
expect(
screen.getByRole("heading", { level: 2, name: "Годом ранее" }),
).toBeInTheDocument();
expect(screen.queryByText("Сравнения")).not.toBeInTheDocument();
expect(screen.queryByText("RUB", { exact: true })).not.toBeInTheDocument();
for (const amount of ["10 ₽", "2 ₽", "12 ₽"]) {
expect(within(selected).getByText(amount)).toBeInTheDocument();
}
const difference = within(year).getByRole("region", { name: "Разница" });
for (const amount of ["+9 ₽", "+2 ₽", "+11 ₽"]) {
expect(within(difference).getByText(amount)).toBeInTheDocument();
}
expect(within(difference).getByText("+900 %")).toBeInTheDocument();
expect(within(difference).getByText("+1 100 %")).toBeInTheDocument();
expect(within(difference).queryByText("нет базы")).not.toBeInTheDocument();
expect(within(difference).getAllByText(/%$/)).toHaveLength(2);
expect(
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
within(difference).getByText("+2 ₽").closest("dd"),
).toHaveTextContent(/^\+2 ₽$/);
});
it("shows the unavailable year state without falling back to the previous period", async () => {
const dependencies = [
{ ...resource(analytics).meta, role: "selected" as const },
{ ...resource(analytics).meta, role: "previous" as const },
{
...resource(null).meta,
role: "year" as const,
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
},
];
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(resource(analytics, { dependencies })),
),
);
renderPage();
expect(
await screen.findByText("Сравнение с годом ранее недоступно."),
).toBeInTheDocument();
expect(
screen.queryByRole("region", { name: "Годом ранее" }),
).not.toBeInTheDocument();
expect(
screen.getByRole("region", { name: "Предыдущий период" }),
).toBeVisible();
}); });
it("shows a quiet realized error", async () => { it("shows a quiet realized error", async () => {
@@ -683,110 +747,121 @@ describe("income period and snapshot boundary", () => {
expect(screen.queryByText(/Итого:|Выплат:/)).not.toBeInTheDocument(); expect(screen.queryByText(/Итого:|Выплат:/)).not.toBeInTheDocument();
expect(screen.queryByRole("button")).not.toBeInTheDocument(); expect(screen.queryByRole("button")).not.toBeInTheDocument();
}); });
});
it("groups all-account payouts by instrument with an exact total", async () => { it.each([false, true])(
"shows unavailable comparisons for absent roles without positional fallback (legacy=%s)",
async (legacy) => {
server.use( server.use(
http.get( http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
"*/api/v1/brokerage-accounts/:accountId/income-analytics", HttpResponse.json(
() => HttpResponse.json(resource({
...analytics, resource(
accountId: "all", { ...analytics, previous: null, yearComparison: null },
entries: [
{ {
...entry(), dependencies: legacy
account: { id: "broker", name: "Брокерский" }, ? [
payment: money("RUB", "10"), resource(null).meta,
resource(null).meta,
resource(null).meta,
]
: [],
}, },
{ ),
...entry(),
account: { id: "iis", name: "ИИС" },
id: "coupon-2",
payment: money("RUB", "20"),
},
],
})),
),
);
renderPage(undefined, "all");
expect(await screen.findByText("Купон · BOND")).toBeInTheDocument();
expect(screen.getByText("+30 ₽")).toBeInTheDocument();
const accounts = within(screen.getByRole("list", {
name: /Купон · BOND, Исполнено/,
}));
expect(accounts.getByText("Брокерский")).toBeInTheDocument();
expect(accounts.getByText("ИИС")).toBeInTheDocument();
expect(accounts.queryByRole("link")).not.toBeInTheDocument();
});
it("keeps repeated payout ids inside one group as separate cards", async () => {
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/income-analytics",
() => HttpResponse.json(resource({
...analytics,
accountId: "all",
entries: [
{
...entry(),
account: { id: "broker", name: "Брокерский" },
payment: money("RUB", "10"),
},
{
...entry(),
account: { id: "iis", name: "ИИС" },
payment: money("RUB", "20"),
},
],
})),
),
);
renderPage(undefined, "all");
expect(await screen.findByText("+30 ₽")).toBeInTheDocument();
const accounts = within(screen.getByRole("list", {
name: /Купон · BOND/,
}));
expect(accounts.getAllByRole("listitem")).toHaveLength(2);
});
it("shows the group total after completeness changes to complete", async () => {
let completeness: "complete" | "incomplete" = "incomplete";
const entries = [
{
...entry(),
account: { id: "broker", name: "Брокерский" },
payment: money("RUB", "10"),
},
{
...entry(),
account: { id: "iis", name: "ИИС" },
id: "coupon-2",
payment: money("RUB", "20"),
},
];
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/income-analytics",
() => HttpResponse.json(
resource({ ...analytics, accountId: "all", entries }, { completeness }),
), ),
), ),
); );
const view = renderPage(undefined, "all"); renderPage();
expect(
await screen.findByText("Сравнение с предыдущим периодом недоступно."),
).toBeVisible();
expect(
screen.getByText("Сравнение с годом ранее недоступно."),
).toBeVisible();
expect(
screen.queryByText("Период сравнения ещё не загружен полностью."),
).not.toBeInTheDocument();
expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible();
},
);
await screen.findByText("Купон · BOND"); it("shows selected income before comparison coverage without inventing a zero baseline", async () => {
expect(screen.queryByText("+30 ₽")).not.toBeInTheDocument(); const dependencies = (["year", "selected", "previous"] as const).map(
view.unmount();
completeness = "complete"; role =>
renderPage(undefined, "all"); ({
expect(await screen.findByText("+30 ₽")).toBeInTheDocument(); ...resource(role === "selected" ? analytics : null).meta,
}); role,
}),
);
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(
resource(
{ ...analytics, previous: null, yearComparison: null },
{ dependencies },
),
),
),
);
renderPage();
expect(await screen.findByRole("region", { name: "Выплаты" })).toBeVisible();
expect(
screen.queryByRole("region", { name: "Год назад" }),
).not.toBeInTheDocument();
expect(screen.getAllByText("12 ₽").length).toBeGreaterThan(0);
expect(
screen.getAllByText("Период сравнения ещё не загружен полностью."),
).toHaveLength(2);
expect(
screen.queryByText("Сравнение с годом ранее недоступно."),
).not.toBeInTheDocument();
expect(
screen.queryByRole("region", { name: "Разница" }),
).not.toBeInTheDocument();
expect(screen.getByLabelText("Период")).toBeVisible();
});
it("keeps complete previous comparison while year source failed without inventing its amounts", async () => {
const dependencies = [
{
...resource(analytics).meta,
role: "previous" as const,
},
{
...resource(null).meta,
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,
role: "selected" as const,
},
];
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(resource(analytics, { dependencies })),
),
);
renderPage();
expect(
await screen.findByRole("region", { name: "Предыдущий период" }),
).toBeVisible();
expect(
screen.getByText(
"Не удалось загрузить период сравнения. Повторите обновление.",
),
).toBeVisible();
expect(
screen.queryByRole("region", { name: "Годом ранее" }),
).not.toBeInTheDocument();
}); });
@@ -31,7 +31,6 @@ type AccountIncomePageViewModel
onSubmit: (values: IncomeForm) => void; onSubmit: (values: IncomeForm) => void;
partial?: boolean; partial?: boolean;
state: "loaded"; state: "loaded";
totalVisible: boolean;
} }
| { | {
period: IncomePeriod; period: IncomePeriod;
@@ -92,7 +91,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
? { period, state: "error" } ? { period, state: "error" }
: { state: "loading" }; : { state: "loading" };
const display = toIncomeDisplayModel(analytics.data); const display = toIncomeDisplayModel(analytics.data, analytics.meta);
const onSubmit = (values: IncomeForm) => { const onSubmit = (values: IncomeForm) => {
const from = parseDraftDate(values.from); const from = parseDraftDate(values.from);
@@ -117,7 +116,6 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
onSubmit, onSubmit,
partial: analytics.meta?.completeness === "incomplete", partial: analytics.meta?.completeness === "incomplete",
state: "loaded", state: "loaded",
totalVisible: analytics.meta?.completeness === "complete",
}; };
}; };
@@ -12,6 +12,7 @@ import { useTranslation } from "react-i18next";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm"; import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
import classes from "../AccountIncomePage.module.css"; import classes from "../AccountIncomePage.module.css";
import { IncomeComparisons } from "./IncomeComparisons.view";
import { IncomePayments } from "./IncomePayments.view"; import { IncomePayments } from "./IncomePayments.view";
import { IncomePeriodForm } from "./IncomePeriodForm/IncomePeriodForm.view"; import { IncomePeriodForm } from "./IncomePeriodForm/IncomePeriodForm.view";
import { IncomeSummary } from "./IncomeSummary.view"; import { IncomeSummary } from "./IncomeSummary.view";
@@ -53,11 +54,19 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
<Alert color="blue">{t("income.taxBoundary")}</Alert> <Alert color="blue">{t("income.taxBoundary")}</Alert>
<IncomePayments <IncomeComparisons
allAccounts={props.analytics.accountId === "all"} comparison={display.previousComparison}
entries={display.entries} notice={t(`income.${display.previousNotice}`)}
totalVisible={props.totalVisible} title={t("income.previousPeriod")}
/> />
<IncomeComparisons
comparison={display.comparison}
notice={t(`income.${display.yearNotice}`)}
title={t("income.yearAgo")}
/>
<IncomePayments entries={display.entries} />
</Stack> </Stack>
); );
}; };
@@ -0,0 +1,167 @@
import type { FC } from "react";
import { Paper, Stack, Text, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { formatMoney } from "@/shared/lib";
import type { IncomeDisplayModel } from "../../../model/display";
import classes from "../AccountIncomePage.module.css";
type ComparisonProps = NonNullable<IncomeDisplayModel["comparison"]> & {
title: string;
};
type ComparisonValuesProps = {
kind: "compared"
| "difference"
| "selected";
label: string;
percentages?: ComparisonProps["values"][number]["percentages"];
values: ComparisonProps["values"][number]["selected"];
};
type IncomeComparisonsProps = {
comparison: IncomeDisplayModel["comparison"];
notice: string;
title: string;
};
const IncomeComparisons: FC<IncomeComparisonsProps> = (props) => {
return (
<Stack>
<Title order={2}>{props.title}</Title>
{props.comparison
? <Comparison {...props.comparison} title={props.title} />
: (
<Paper className={classes.comparisonNotice} p="sm" radius="md">
<Text aria-live="polite" c="gray.7" role="status" size="sm">
{props.notice}
</Text>
</Paper>
)}
</Stack>
);
};
const Comparison: FC<ComparisonProps> = (props) => {
const { t } = useTranslation();
return (
<section aria-label={props.title} className={classes.comparison}>
<Stack gap="md">
{props.values.map(value => (
<Stack
aria-label={value.currency}
gap="xs"
key={value.currency}
role="group"
>
<div className={classes.comparisonGrid}>
<ComparisonValues
kind="selected"
label={props.selectedPeriod}
values={value.selected}
/>
<ComparisonValues
kind="compared"
label={props.comparedPeriod}
values={value.compared}
/>
<ComparisonValues
kind="difference"
label={t("income.difference")}
percentages={value.percentages}
values={value.difference}
/>
</div>
</Stack>
))}
{props.values.length === 0 && (
<Text c="gray.7" size="sm">
{t("income.comparisonEmpty")}
</Text>
)}
</Stack>
</section>
);
};
const ComparisonValues: FC<ComparisonValuesProps> = (props) => {
const { t } = useTranslation();
const data_tone
= props.kind === "difference"
? differenceTone(props.values.total.value)
: undefined;
return (
<section
aria-label={props.label}
className={classes.comparisonPanel}
data-kind={props.kind}
data-tone={data_tone}
>
<Text className={classes.comparisonLabel} fw={600} size="sm">
{props.label}
</Text>
<dl className={classes.comparisonValues}>
{(["total", "coupon", "dividend"] as const).map((key) => {
const money
// eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name.
= props.values[key];
const percentage
// eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name.
= props.percentages?.[key];
const tone
= props.kind === "difference"
? differenceTone(money.value)
: undefined;
const appearanceClass
= key === "total"
? classes.comparisonTotal
: classes.comparisonDetail;
return (
<div className={appearanceClass} key={key}>
<dt>{t(`income.${key}`)}</dt>
<dd data-tone={tone}>
<span>
{tone === "positive" ? "+" : ""}
{formatMoney(money)}
</span>
{!!percentage && (
<span className={classes.comparisonPercent}>
{percentage}
</span>
)}
</dd>
</div>
);
})}
</dl>
</section>
);
};
function differenceTone(value: string): "negative"
| "neutral"
| "positive" {
if (!/[1-9]/.test(value)) return "neutral";
return value.startsWith("-") ? "negative" : "positive";
}
export { IncomeComparisons };
@@ -3,22 +3,13 @@ import type { FC } from "react";
import { Stack, Title } from "@mantine/core"; import { Stack, Title } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { LedgerEntryRow } from "@/entities/broker-operation";
brokerageOperationGroupKey,
LedgerEntryRow,
OperationGroup,
} from "@/entities/broker-operation";
import { groupItems } from "@/shared/lib";
import type { IncomeDisplayModel } from "../../../model/display"; import type { IncomeDisplayModel } from "../../../model/display";
import classes from "../AccountIncomePage.module.css"; import classes from "../AccountIncomePage.module.css";
type IncomePaymentsProps = { type IncomePaymentsProps = { entries: IncomeDisplayModel["entries"] };
allAccounts?: boolean;
entries: IncomeDisplayModel["entries"];
totalVisible?: boolean;
};
const IncomePayments: FC<IncomePaymentsProps> = (props) => { const IncomePayments: FC<IncomePaymentsProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -28,24 +19,11 @@ const IncomePayments: FC<IncomePaymentsProps> = (props) => {
<Title order={2}>{t("income.payments")}</Title> <Title order={2}>{t("income.payments")}</Title>
<Stack className={classes.entries} component="ul" gap="sm"> <Stack className={classes.entries} component="ul" gap="sm">
{groupItems( {props.entries.map(({ key, operation }) => (
props.entries, <li key={key}>
entry => props.allAccounts <LedgerEntryRow operation={operation} wrap />
? brokerageOperationGroupKey(entry.operation) </li>
: null, ))}
).map(entry => entry.type === "group"
? (
<OperationGroup
entries={entry.items}
key={entry.key}
totalVisible={props.totalVisible === true}
/>
)
: (
<li key={entry.item.key}>
<LedgerEntryRow operation={entry.item.operation} wrap />
</li>
))}
</Stack> </Stack>
</Stack> </Stack>
); );
@@ -1,6 +1,6 @@
import type { FC, SubmitEventHandler } from "react"; import type { FC, SubmitEventHandler } from "react";
import { Button, Stack } from "@mantine/core"; import { Button, Paper, Stack, Text } from "@mantine/core";
import { DatePickerInput } from "@mantine/dates"; import { DatePickerInput } from "@mantine/dates";
import classes from "../../AccountIncomePage.module.css"; import classes from "../../AccountIncomePage.module.css";
@@ -27,8 +27,15 @@ const IncomePeriodForm: FC<IncomePeriodFormProps> = (props) => {
} = useIncomePeriodFormVm(); } = useIncomePeriodFormVm();
return ( return (
<form onSubmit={props.onSubmit}> <Paper
component="form"
onSubmit={props.onSubmit}
p="md"
radius="md"
withBorder
>
<Stack gap="sm"> <Stack gap="sm">
<Text fw={700}>{t("income.period")}</Text>
<div className={classes.fields}> <div className={classes.fields}>
<DatePickerInput <DatePickerInput
@@ -64,7 +71,7 @@ const IncomePeriodForm: FC<IncomePeriodFormProps> = (props) => {
</Button> </Button>
</div> </div>
</Stack> </Stack>
</form> </Paper>
); );
}; };
@@ -2,7 +2,7 @@ import type { FC } from "react";
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw"; import { http, HttpResponse } from "msw";
import { import {
MemoryRouter, MemoryRouter,
@@ -44,25 +44,6 @@ const operation: BrokerageOperation
type: "OPERATION_TYPE_BUY", type: "OPERATION_TYPE_BUY",
}; };
function coupon(
id: string,
accountId: string,
accountName: string,
value: string,
): BrokerageOperation {
return {
...operation,
account: { id: accountId, name: accountName },
category: "income",
description: "Купон",
id,
instrumentUid: "UID",
payment: { currency: "RUB", value },
ticker: "BOND",
type: "OPERATION_TYPE_COUPON",
};
}
const Navigation: FC = () => { const Navigation: FC = () => {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -90,12 +71,12 @@ function configureTestAuth() {
}); });
} }
function renderPage(search = "", accountId = "account-1") { function renderPage(search = "") {
return render( return render(
<QueryClientProvider client={createTestQueryClient()}> <QueryClientProvider client={createTestQueryClient()}>
<MantineProvider> <MantineProvider>
<MemoryRouter <MemoryRouter
initialEntries={[`/accounts/${accountId}/operations${search}`]} initialEntries={[`/accounts/account-1/operations${search}`]}
> >
<Routes> <Routes>
<Route <Route
@@ -236,27 +217,6 @@ describe("LedgerEntryRow", () => {
}); });
}); });
const historyStateResource = (historyState: "loading" | "partial") =>
HttpResponse.json(
resource(
{
hasNext: false,
items: [operation],
nextCursor: null,
},
{
coverage: {
asOf: null,
completeRanges: [],
historyBoundary: "unknown",
historyState,
missingRanges: [],
requested: [],
},
},
),
);
describe("AccountOperationsPage", () => { describe("AccountOperationsPage", () => {
it.each(["account-1", "all"])( it.each(["account-1", "all"])(
"normalizes invalid periods and history, preserves one-sided UTC dates and resets cursors for %s", "normalizes invalid periods and history, preserves one-sided UTC dates and resets cursors for %s",
@@ -518,33 +478,6 @@ describe("AccountOperationsPage", () => {
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
}); });
it("does not show the history loading alert when coverage is partial", async () => {
configureTestAuth();
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/operations",
() => historyStateResource("partial"),
),
);
renderPage();
await screen.findByText("Покупка акций");
expect(screen.queryByText("История догружается")).not.toBeInTheDocument();
});
it("shows the history loading alert while historyState is loading", async () => {
configureTestAuth();
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/operations",
() => historyStateResource("loading"),
),
);
renderPage();
expect(
await screen.findByText("История догружается"),
).toBeInTheDocument();
});
it("renders an error state", async () => { it("renders an error state", async () => {
configureTestAuth(); configureTestAuth();
server.use( server.use(
@@ -559,20 +492,6 @@ describe("AccountOperationsPage", () => {
}), }),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("keeps a null operations envelope loading instead of a confirmed empty period", async () => {
configureTestAuth();
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
HttpResponse.json(resource(null)),
),
);
renderPage();
expect(
await screen.findByRole("status", { name: "Загрузка" }, { timeout: 4000 }),
).toBeVisible();
expect(screen.queryByText("Операций нет")).not.toBeInTheDocument();
});
}); });
it("keeps UTC operation boundaries in GET and source sync requests", async () => { it("keeps UTC operation boundaries in GET and source sync requests", async () => {
@@ -669,73 +588,3 @@ it("recovers an invalid cursor at the first page without clearing applied filter
for (const key of ["from", "to", "categories"]) for (const key of ["from", "to", "categories"])
expect(searches[2].get(key)).toBe(searches[0].get(key)); expect(searches[2].get(key)).toBe(searches[0].get(key));
}); });
describe("all-account operation groups", () => {
it("groups only the current page and rebuilds the group at its boundary", async () => {
configureTestAuth();
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/operations",
({ request }) => {
const cursor = new URL(request.url).searchParams.get("cursor");
const items = cursor
? [
coupon("X2", "iis", "ИИС", "150"),
coupon("X3", "broker", "Брокерский", "50"),
operation,
]
: [coupon("X1", "broker", "Брокерский", "100")];
return HttpResponse.json(resource({
accountId: "all",
hasNext: !cursor,
items,
nextCursor: cursor ? null : "page-2",
}));
},
),
);
renderPage("", "all");
await screen.findAllByText("Купон");
expect(screen.queryByRole("list", { name: "Разбивка по счетам" }))
.not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
const accounts = await screen.findByRole("list", {
name: /Купон · BOND, Исполнено/,
});
expect(screen.getByText("Купон · BOND")).toBeInTheDocument();
expect(screen.getByText("+200 ₽")).toBeInTheDocument();
expect(within(accounts).getAllByRole("listitem")).toHaveLength(2);
fireEvent.click(screen.getByRole("button", { name: "Назад" }));
await screen.findAllByText("Купон");
expect(screen.queryByRole("list", { name: "Разбивка по счетам" }))
.not.toBeInTheDocument();
});
it("leaves repeated operations ungrouped for one account", async () => {
configureTestAuth();
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
HttpResponse.json(resource({
accountId: "account-1",
hasNext: false,
items: [
coupon("X1", "account-1", "Брокерский", "100"),
coupon("X2", "account-1", "Брокерский", "150"),
],
nextCursor: null,
})),
),
);
renderPage();
await screen.findAllByText("Купон");
expect(screen.queryByRole("list", { name: /Разбивка|Исполнено/ }))
.not.toBeInTheDocument();
});
});
@@ -25,7 +25,6 @@ const operationCategorySet = new Set<string>(operationCategories);
type AccountOperationsPageViewModel type AccountOperationsPageViewModel
= | { = | {
allAccounts: boolean;
canApply: boolean; canApply: boolean;
draftCategories: OperationCategory[]; draftCategories: OperationCategory[];
draftPeriod: [null | string, null | string]; draftPeriod: [null | string, null | string];
@@ -44,7 +43,6 @@ type AccountOperationsPageViewModel
partial?: boolean; partial?: boolean;
source?: InvestmentResourceMeta; source?: InvestmentResourceMeta;
state: "loaded"; state: "loaded";
totalVisible: boolean;
} }
| { onReset: () => void; state: "error" } | { onReset: () => void; state: "error" }
| { state: "loading" }; | { state: "loading" };
@@ -149,13 +147,14 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
|| !isSameCategories(draftCategories, appliedCategories); || !isSameCategories(draftCategories, appliedCategories);
return { return {
allAccounts: accountId === "all",
canApply: isDirty && isValidPeriod(draftFrom, draftTo), canApply: isDirty && isValidPeriod(draftFrom, draftTo),
draftCategories, draftCategories,
draftPeriod, draftPeriod,
hasNext: Boolean(nextCursor && operations.data.hasNext), hasNext: Boolean(nextCursor && operations.data.hasNext),
hasPrevious: history.hasPrevious, hasPrevious: history.hasPrevious,
historyLoading: operations.meta?.coverage?.historyState === "loading", historyLoading:
operations.meta?.coverage?.historyState === "loading"
|| operations.meta?.coverage?.historyState === "partial",
items: operations.data.items, items: operations.data.items,
maxDate, maxDate,
onApply: () => { onApply: () => {
@@ -184,7 +183,6 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
partial: operations.meta?.completeness === "incomplete", partial: operations.meta?.completeness === "incomplete",
source: operations.meta, source: operations.meta,
state: "loaded", state: "loaded",
totalVisible: operations.meta?.completeness === "complete",
}; };
}; };
@@ -12,13 +12,7 @@ import { DatePickerInput } from "@mantine/dates";
import { StateView } from "@wealthledger/ui"; import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { LedgerEntryRow, operationCategories } from "@/entities/broker-operation";
brokerageOperationGroupKey,
LedgerEntryRow,
operationCategories,
OperationGroup,
} from "@/entities/broker-operation";
import { groupItems } from "@/shared/lib";
import { SourceSyncStatus } from "@/shared/ui"; import { SourceSyncStatus } from "@/shared/ui";
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm"; import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
@@ -30,49 +24,22 @@ type AccountOperationsPageLoadedProps = Extract<
const AccountOperationsPageLoadedOperations: FC< const AccountOperationsPageLoadedOperations: FC<
AccountOperationsPageLoadedProps AccountOperationsPageLoadedProps
> = (props) => { > = (props) => { // eslint-disable-line sonarjs/function-return-type -- React renders elements and lists.
const { t } = useTranslation(); const { t } = useTranslation();
const { items } = props; const { items } = props;
if (items.length > 0) { if (items.length > 0) {
const entries = groupItems( return items.map(operation => (
items.map(operation => ({ <LedgerEntryRow
key: JSON.stringify([ key={JSON.stringify([
operation.account?.id, operation.account?.id,
operation.id, operation.id,
operation.logoName, operation.logoName,
]), ])}
operation, operation={operation}
})), />
entry => props.allAccounts ));
? brokerageOperationGroupKey(entry.operation)
: null,
);
return (
<Stack
component="ul"
gap="lg"
m={0}
p={0}
style={{ listStyle: "none" }}
>
{entries.map(entry => entry.type === "group"
? (
<OperationGroup
entries={entry.items}
key={entry.key}
totalVisible={props.totalVisible}
/>
)
: (
<li key={entry.item.key}>
<LedgerEntryRow operation={entry.item.operation} />
</li>
))}
</Stack>
);
} }
if (props.partial) { if (props.partial) {
@@ -1,175 +0,0 @@
import { describe, expect, it } from "vitest";
import type { Portfolio } from "@/entities/portfolio";
import { toConcentration } from "./concentration";
import { portfolio, position } from "./positions.fixtures";
function snapshot(positions: Portfolio["positions"]): Portfolio {
return { ...portfolio, positions };
}
function stock(
name: string,
value: null | string,
currency = "RUB",
overrides: Partial<Portfolio["positions"][number]> = {},
): Portfolio["positions"][number] {
return {
...position,
currentValue: value === null ? null : { currency, value },
displayName: name,
...overrides,
};
}
const labels = {
missingIssuer: "Эмитент не указан",
missingSector: "Сектор не указан",
other: "Прочее",
sector: (code: string) => code,
};
describe("account concentration", () => {
it("uses exact positive values and excludes cash, negatives and nulls", () => {
const model = toConcentration(snapshot([
stock("A", "1"), stock("B", "15"),
stock("C", "0"), stock("D", "-50"), stock("E", null),
stock("F", null, "USD"), stock("G", "-3", "USD"),
]), "stocks", "RUB", labels);
expect(model.currencies).toEqual(["RUB", "USD"]);
expect(model.total).toBe("16");
expect(model.items.map(item => item.share)).toEqual(["93,8 %", "6,3 %"]);
expect(model.unaccounted).toBe(3);
const negative = snapshot([stock("A", "-3", "USD")]);
expect(toConcentration(negative, "stocks", "USD", labels))
.toMatchObject({ empty: true, unaccounted: 1 });
});
it("ranks seven positions with exact other and top-five totals", () => {
const model = toConcentration(snapshot([
stock("A", "400"), stock("B", "250"), stock("C", "150"),
stock("D", "100"), stock("E", "50"), stock("F", "30"),
stock("G", "20"),
]), "stocks", "RUB", labels);
expect(model.items.map(item => item.amount)).toEqual([
"400", "250", "150", "100", "50",
]);
expect(model.other).toMatchObject({
amount: "50", count: 2, share: "5 %",
});
expect(model.top5Share).toBe("95 %");
expect(model.other?.entries.map(item => item.share)).toEqual([
"3 %", "2 %",
]);
});
it("keeps sixth row, tiny share and stable alphabetical ties", () => {
const model = toConcentration(snapshot([
stock("Сбер", "100"), stock("X5", "100"),
stock("VK", "100"), stock("D", "100"),
stock("E", "100"), stock("F", "100"),
]), "stocks", "RUB", labels);
expect(model.items).toHaveLength(6);
expect(model.other).toBeNull();
expect(model.items.slice(0, 2).map(item => item.name)).toEqual([
"D", "E",
]);
const tiny = snapshot([
stock("Large", "100000000"), stock("Small", "1"),
]);
expect(toConcentration(tiny, "stocks", "RUB", labels).items[1]?.share)
.toBe("< 0,1 %");
});
it("groups sectors and issuers by codes, not labels", () => {
const positions = [
stock("A", "300", "RUB", {
issuer: { id: "one", name: "Сбербанк" }, sector: "energy",
}),
stock("B", "200", "RUB", {
issuer: { id: "one", name: "Сбер" }, sector: "energy",
type: "BOND",
}),
stock("C", "100", "RUB", {
issuer: { id: "two", name: "Сбер" }, sector: "financial",
type: "FUND",
}),
stock("D", "50", "RUB", { issuer: null, sector: null }),
];
expect(toConcentration(snapshot(positions), "sectors", "RUB", labels)
.items.map(item => item.amount)).toEqual(["500", "100", "50"]);
expect(toConcentration(snapshot(positions), "issuers", "RUB", labels)
.items.map(item => [item.name, item.amount])).toEqual([
["Сбербанк", "500"], ["", "50"],
]);
});
it("orders equal sums by displayed name", () => {
const issuers = snapshot([
stock("A", "300", "RUB", {
issuer: { id: "g", name: "Газпром" }, sector: "energy",
}),
stock("B", "300", "RUB", {
issuer: { id: "s", name: "Сбер" }, sector: "energy",
}),
stock("C", "200", "RUB", { issuer: null, sector: "energy" }),
stock("D", "100", "RUB", { issuer: null, sector: "energy" }),
]);
expect(toConcentration(issuers, "issuers", "RUB", labels)
.items.map(item => [item.label, item.amount])).toEqual([
["Газпром", "300"], ["Сбер", "300"], ["Эмитент не указан", "300"],
]);
const sectors = snapshot([
stock("A", "100", "RUB", { sector: "it" }),
stock("B", "100", "RUB", { sector: "energy" }),
]);
const sectorLabels = {
...labels,
sector: (code: string) => code === "it"
? "Информационные технологии"
: "Энергетика",
};
expect(toConcentration(sectors, "sectors", "RUB", sectorLabels)
.items.map(item => item.label))
.toEqual(["Информационные технологии", "Энергетика"]);
});
it("chooses issuer name from the largest position, then alphabetically", () => {
const data = snapshot([
stock("A", "100", "RUB", {
issuer: { id: "brand", name: "Ястреб" },
}),
stock("B", "300", "RUB", {
issuer: { id: "brand", name: "Альфа" },
}),
stock("C", "400", "RUB", {
issuer: { id: "other", name: "Бета" },
}),
]);
expect(toConcentration(data, "issuers", "RUB", labels).items
.map(item => [item.name, item.label, item.amount])).toEqual([
["Альфа", "Альфа", "400"], ["Бета", "Бета", "400"],
]);
});
it("uses ISIN before ticker for bonds", () => {
const data = snapshot([stock("Облигация", "100", "RUB", {
isin: "RU000A0JX0J2", ticker: "BOND", type: "BOND",
})]);
expect(toConcentration(data, "bonds", "RUB", labels)
.items[0]?.logo?.subtitle).toBe("RU000A0JX0J2");
});
});
-300
View File
@@ -1,300 +0,0 @@
import type { Portfolio } from "@/entities/portfolio";
import {
type Decimal,
decimalFromString,
decimalToString,
instrumentLogoUrl,
sumDecimals,
} from "@/shared/lib";
type Concentration = {
currencies: string[];
empty: boolean;
items: ConcentrationItem[];
other: ConcentrationOther | null;
top5Share: null | string;
total: null | string;
unaccounted: number;
};
type ConcentrationItem = {
amount: string;
chartValue: number;
key: string;
label: string;
logo: ConcentrationLogo | null;
name: string;
share: string;
unknown: boolean;
};
type ConcentrationLabels = {
missingIssuer: string;
missingSector: string;
other: string;
sector: (code: string) => string;
};
type ConcentrationLogo = {
src: null | string;
subtitle: null | string;
};
type ConcentrationOther = ConcentrationItem & {
count: number;
entries: ConcentrationItem[];
};
type Group = {
best?: { name: string; value: Decimal };
first: number;
key: string;
label: string;
logo: ConcentrationLogo | null;
name: string;
unknown: boolean;
values: Decimal[];
};
type Position = Portfolio["positions"][number];
type Slice = "bonds"
| "issuers"
| "sectors"
| "stocks";
const eligibleTypes = new Set(["BOND", "FUND", "STOCK"]);
const RADIX = 10n;
const CHART_PRECISION = 1_000_000n;
const SHARE_PRECISION = 1000n;
const HALF = 2n;
const TOP = 5;
const FIRST_OTHER = 7;
const compareNames = (a: string, b: string) =>
a.toLowerCase().localeCompare(b.toLowerCase(), "en", {
numeric: false,
});
function addPosition(
groups: Map<string, Group>,
position: Position,
index: number,
slice: Slice,
labels: ConcentrationLabels,
selected: null | string,
): number {
if (!isIncluded(slice, position.type)) return 0;
if (!position.currentValue) return 1;
if (position.currentValue.currency !== selected) return 0;
const value = decimalFromString(position.currentValue.value);
if (value.value < 0n) return 1;
if (value.value === 0n) return 0;
const identity = groupIdentity(position, slice, index, labels);
const group = groups.get(identity.key) ?? {
...identity, first: index, values: [],
};
group.values.push(value);
if (slice === "issuers" && !identity.unknown) {
const best = group.best;
if (!best || compareDecimal(value, best.value) > 0
|| (compareDecimal(value, best.value) === 0
&& compareNames(identity.name, best.name) < 0)) {
group.best = { name: identity.name, value };
group.name = identity.name;
group.label = identity.label;
}
}
groups.set(identity.key, group);
return 0;
}
function chartValue(part: Decimal, total: Decimal): number {
const scale = Math.max(part.scale, total.scale);
const numerator = part.value * RADIX ** BigInt(scale - part.scale);
const denominator = total.value * RADIX ** BigInt(scale - total.scale);
return Number(numerator * CHART_PRECISION / denominator)
/ Number(CHART_PRECISION);
}
function compareDecimal(a: Decimal, b: Decimal): number {
const scale = Math.max(a.scale, b.scale);
const left = a.value * RADIX ** BigInt(scale - a.scale);
const right = b.value * RADIX ** BigInt(scale - b.scale);
if (left > right) return 1;
return left < right ? -1 : 0;
}
function concentrationCurrencies(portfolio: Portfolio): string[] {
return [...new Set(portfolio.positions
.filter(position => eligibleTypes.has(position.type))
.filter(position => position.currentValue
&& decimalFromString(position.currentValue.value).value !== 0n)
.map(position => position.currentValue?.currency)
.filter((value): value is string => !!value))]
.toSorted((a, b) => a.localeCompare(b));
}
function groupIdentity(
position: Position,
slice: Slice,
index: number,
labels: ConcentrationLabels,
) {
if (slice === "sectors") {
const sector = position.sector;
const isUnknown = !sector;
return {
key: sector ?? "",
label: isUnknown
? labels.missingSector
: labels.sector(sector),
logo: null,
name: sector ?? "",
unknown: isUnknown,
};
}
if (slice === "issuers") {
const isUnknown = !position.issuer;
return {
key: position.issuer?.id ?? "",
label: position.issuer ? position.issuer.name : labels.missingIssuer,
logo: null,
name: position.issuer?.name ?? "",
unknown: isUnknown,
};
}
return {
key: String(index),
label: position.displayName,
logo: {
src: instrumentLogoUrl(position.logoName),
subtitle: slice === "bonds"
? position.isin ?? position.ticker ?? position.figi
: position.ticker ?? position.isin ?? position.figi,
},
name: position.displayName,
unknown: false,
};
}
function isIncluded(slice: Slice, type: string): boolean {
if (slice === "stocks") return type === "STOCK";
if (slice === "bonds") return type === "BOND";
return slice === "issuers" ? type === "STOCK" || type === "BOND" : eligibleTypes.has(type);
}
function share(part: Decimal, total: Decimal): string {
const scale = Math.max(part.scale, total.scale);
const numerator = part.value * RADIX ** BigInt(scale - part.scale);
const denominator = total.value * RADIX ** BigInt(scale - total.scale);
if (numerator * SHARE_PRECISION < denominator) return "< 0,1 %";
const tenths = (numerator * SHARE_PRECISION * HALF + denominator)
/ (HALF * denominator);
return tenths % RADIX === 0n ? `${tenths / RADIX} %` : `${tenths / RADIX},${tenths % RADIX} %`;
}
function toConcentration(
portfolio: Portfolio,
slice: Slice,
currency: null | string,
labels: ConcentrationLabels,
): Concentration {
const currencies = concentrationCurrencies(portfolio);
const selected = currency && currencies.includes(currency)
? currency
: currencies[0] ?? null;
const groups = new Map<string, Group>();
let unaccounted = 0;
for (const [index, position] of portfolio.positions.entries()) {
unaccounted += addPosition(
groups, position, index, slice, labels, selected,
);
}
const ranked = Array.from(groups.values(), group => ({
...group, sum: sumDecimals(group.values),
})).toSorted((a, b) => compareDecimal(b.sum, a.sum)
|| compareNames(a.label, b.label) || a.first - b.first);
const total = ranked.length > 0
? sumDecimals(ranked.map(item => item.sum))
: null;
const entries = total
? ranked.map(group => ({
amount: decimalToString(group.sum),
chartValue: chartValue(group.sum, total),
key: group.key,
label: group.label,
logo: group.logo,
name: group.name,
share: share(group.sum, total),
unknown: group.unknown,
}))
: [];
const rest = entries.length >= FIRST_OTHER ? entries.slice(TOP) : [];
const otherSum = rest.length > 0
? sumDecimals(ranked.slice(TOP).map(item => item.sum))
: null;
const other = otherSum && total
? {
amount: decimalToString(otherSum),
chartValue: chartValue(otherSum, total),
count: rest.length,
entries: rest,
key: "__other",
label: labels.other,
logo: null,
name: "", share: share(otherSum, total), unknown: false,
}
: null;
const top5 = ranked.length > TOP
? sumDecimals(ranked.slice(0, TOP).map(item => item.sum))
: null;
return {
currencies,
empty: !total,
items: other ? entries.slice(0, TOP) : entries,
other,
top5Share: top5 && total ? share(top5, total) : null,
total: total && decimalToString(total),
unaccounted,
};
}
export {
concentrationCurrencies,
toConcentration,
};
export type {
Concentration,
ConcentrationItem,
ConcentrationLabels,
Slice,
};
@@ -13,10 +13,8 @@ const portfolio
displayName: "Акция", displayName: "Акция",
figi: "FIGI-STOCK", figi: "FIGI-STOCK",
isin: null, isin: null,
issuer: null,
logoName: "akciya.png", logoName: "akciya.png",
quantity: "2", quantity: "2",
sector: null,
ticker: null, ticker: null,
type: "STOCK", type: "STOCK",
}, },
@@ -27,10 +25,8 @@ const portfolio
displayName: "Облигация", displayName: "Облигация",
figi: "FIGI-BOND", figi: "FIGI-BOND",
isin: "RU000A0JX0J2", isin: "RU000A0JX0J2",
issuer: null,
logoName: null, logoName: null,
quantity: "1", quantity: "1",
sector: null,
ticker: null, ticker: null,
type: "BOND", type: "BOND",
}, },
@@ -41,10 +37,8 @@ const portfolio
displayName: "Фонд", displayName: "Фонд",
figi: "FIGI-FUND", figi: "FIGI-FUND",
isin: null, isin: null,
issuer: null,
logoName: "fond.png", logoName: "fond.png",
quantity: "3", quantity: "3",
sector: null,
ticker: null, ticker: null,
type: "FUND", type: "FUND",
}, },
+1 -123
View File
@@ -2,19 +2,9 @@ import { describe, expect, it } from "vitest";
import type { Portfolio } from "@/entities/portfolio"; import type { Portfolio } from "@/entities/portfolio";
import { import { toAccountPagePositionSections } from "./positions";
formatPositionReturnPercent,
toAccountPagePositionSections,
toUnrealizedReturns,
} from "./positions";
import { portfolio, position, second } from "./positions.fixtures"; import { portfolio, position, second } from "./positions.fixtures";
function makePosition(
overrides: Partial<Portfolio["positions"][number]>,
): Portfolio["positions"][number] {
return { ...position, ...overrides };
}
function sections(positions: Portfolio["positions"]) { function sections(positions: Portfolio["positions"]) {
return toAccountPagePositionSections({ return toAccountPagePositionSections({
...portfolio, ...portfolio,
@@ -92,8 +82,6 @@ describe("account position sections", () => {
{ {
displayName: "Акция", displayName: "Акция",
logoSrc: "https://invest-brands.cdn-tinkoff.ru/akciyax160.png", logoSrc: "https://invest-brands.cdn-tinkoff.ru/akciyax160.png",
returnPercent: null,
returnPercentText: null,
returnText: "+10 ₽", returnText: "+10 ₽",
returnTone: "positive", returnTone: "positive",
shareText: "66,7 %", shareText: "66,7 %",
@@ -107,8 +95,6 @@ describe("account position sections", () => {
{ {
displayName: "Облигация", displayName: "Облигация",
logoSrc: null, logoSrc: null,
returnPercent: "-11.111111",
returnPercentText: "-11,11 %",
returnText: "-5 ₽", returnText: "-5 ₽",
returnTone: "negative", returnTone: "negative",
shareText: "33,3 %", shareText: "33,3 %",
@@ -121,8 +107,6 @@ describe("account position sections", () => {
{ {
displayName: "Фонд", displayName: "Фонд",
logoSrc: null, logoSrc: null,
returnPercent: null,
returnPercentText: null,
returnText: null, returnText: null,
returnTone: undefined, returnTone: undefined,
shareText: null, shareText: null,
@@ -203,109 +187,3 @@ describe("account position sections", () => {
expect(source).toEqual(before); expect(source).toEqual(before);
}); });
}); });
describe("account position returns", () => {
it.each([
{
absoluteReturn: { currency: "RUB", value: "25" },
averagePurchasePrice: { currency: "RUB", value: "280" },
expected: "7.142857",
name: "gain",
quantity: "1.25",
type: "STOCK",
},
{
absoluteReturn: { currency: "RUB", value: "-25" },
averagePurchasePrice: { currency: "RUB", value: "280" },
expected: "-7.142857",
name: "loss",
quantity: "1.25",
type: "STOCK",
},
{
absoluteReturn: { currency: "RUB", value: "-5" },
averagePurchasePrice: { currency: "RUB", value: "45" },
expected: "-11.111111",
name: "bond without accrued interest",
quantity: "1",
type: "BOND",
},
] as const)("calculates $name from the clean purchase cost", (testCase) => {
const result = sections([makePosition({
absoluteReturn: testCase.absoluteReturn,
averagePurchasePrice: testCase.averagePurchasePrice,
quantity: testCase.quantity,
type: testCase.type,
})]);
expect(result[0]?.groups[0]?.positions[0].returnPercent)
.toBe(testCase.expected);
});
it.each([
{ name: "zero quantity", overrides: { quantity: "0" } },
{ name: "negative quantity", overrides: { quantity: "-1" } },
{ name: "missing average price", overrides: { averagePurchasePrice: null } },
{
name: "currency mismatch",
overrides: { averagePurchasePrice: { currency: "USD", value: "280" } },
},
{ name: "zero cost", overrides: { averagePurchasePrice: { currency: "RUB", value: "0" } } },
])("returns null for $name", ({ overrides }) => {
const result = sections([makePosition({
absoluteReturn: { currency: "RUB", value: "25" },
averagePurchasePrice: { currency: "RUB", value: "280" },
quantity: "1.25",
...overrides,
})]);
expect(result[0]?.groups[0]?.positions[0].returnPercent).toBeNull();
});
it.each([
{ absoluteReturn: "1", expected: "0.000001" },
{ absoluteReturn: "-1", expected: "-0.000001" },
])("rounds an exact half away from zero for $absoluteReturn", (testCase) => {
const result = sections([makePosition({
absoluteReturn: { currency: "RUB", value: testCase.absoluteReturn },
averagePurchasePrice: { currency: "RUB", value: "200000000" },
quantity: "1",
})]);
expect(result[0]?.groups[0]?.positions[0].returnPercent)
.toBe(testCase.expected);
});
it.each([
{ expected: "+1.01", value: "1.005" },
{ expected: "-1.01", value: "-1.005" },
])("formats $value for display with a sign and two decimals", (testCase) => {
expect(formatPositionReturnPercent(testCase.value)).toBe(testCase.expected);
});
it("sums complete unrealized returns by currency", () => {
const result = toUnrealizedReturns([
makePosition({ absoluteReturn: { currency: "RUB", value: "0.1" } }),
makePosition({ absoluteReturn: { currency: "RUB", value: "0.2" } }),
makePosition({ absoluteReturn: { currency: "USD", value: "-2.5" } }),
]);
expect(result).toEqual([
{ currency: "RUB", value: "0.3" },
{ currency: "USD", value: "-2.5" },
]);
});
it("omits only currencies with incomplete returns", () => {
const result = toUnrealizedReturns([
makePosition({ absoluteReturn: { currency: "RUB", value: "10" } }),
makePosition({
absoluteReturn: null,
currentValue: { currency: "RUB", value: "100" },
}),
makePosition({ absoluteReturn: { currency: "USD", value: "2" } }),
]);
expect(result).toEqual([{ currency: "USD", value: "2" }]);
});
});
+1 -123
View File
@@ -1,19 +1,9 @@
import { formatShare, type Portfolio } from "@/entities/portfolio"; import { formatShare, type Portfolio } from "@/entities/portfolio";
import { import { formatMoney, instrumentLogoUrl } from "@/shared/lib";
type Decimal,
decimalFromString,
decimalToString,
formatDecimal,
formatMoney,
instrumentLogoUrl,
sumDecimals,
} from "@/shared/lib";
type AccountPagePosition = Portfolio["positions"][number] & { type AccountPagePosition = Portfolio["positions"][number] & {
instrumentTo?: string; instrumentTo?: string;
logoSrc: null | string; logoSrc: null | string;
returnPercent: null | string;
returnPercentText: null | string;
returnText: null | string; returnText: null | string;
returnTone?: AccountPageReturnTone; returnTone?: AccountPageReturnTone;
shareText: null | string; shareText: null | string;
@@ -31,44 +21,10 @@ type AccountPagePositionSection = {
type AccountPageReturnTone = "negative" | "positive"; type AccountPageReturnTone = "negative" | "positive";
type Money = NonNullable<Portfolio["positions"][number]["absoluteReturn"]>;
type PositionSectionKind = "bonds" type PositionSectionKind = "bonds"
| "other" | "other"
| "stocks"; | "stocks";
const RADIX = 10n;
const PERCENT_SCALE = 6;
const DISPLAY_PERCENT_SCALE = 2;
const PERCENT_FACTOR = 100n;
const ROUNDING_FACTOR = 2n;
function formatPositionReturnPercent(value: string): string {
const decimal = decimalFromString(value);
let rounded = decimal.value;
if (decimal.scale > DISPLAY_PERCENT_SCALE) {
const divisor = RADIX ** BigInt(decimal.scale - DISPLAY_PERCENT_SCALE);
const remainder = rounded % divisor;
const absoluteRemainder = remainder < 0n ? -remainder : remainder;
rounded /= divisor;
if (absoluteRemainder * ROUNDING_FACTOR >= divisor) {
rounded += decimal.value < 0n ? -1n : 1n;
}
}
else {
rounded *= RADIX ** BigInt(DISPLAY_PERCENT_SCALE - decimal.scale);
}
const sign = rounded > 0n ? "+" : "";
return `${sign}${decimalToString({
scale: DISPLAY_PERCENT_SCALE,
value: rounded,
})}`;
}
function groupPositions( function groupPositions(
positions: AccountPagePosition[], positions: AccountPagePosition[],
): AccountPagePositionGroup[] { ): AccountPagePositionGroup[] {
@@ -111,41 +67,6 @@ function positionIdentity(position: AccountPagePosition) {
return position.isin?.trim() ? ["isin", position.isin.trim()] : undefined; return position.isin?.trim() ? ["isin", position.isin.trim()] : undefined;
} }
function returnPercent(
position: Portfolio["positions"][number],
): null | string {
const result = position.absoluteReturn;
const price = position.averagePurchasePrice;
if (!result || !price || result.currency !== price.currency) return null;
const quantity = decimalFromString(position.quantity);
const purchasePrice = decimalFromString(price.value);
const cost = {
scale: quantity.scale + purchasePrice.scale,
value: quantity.value * purchasePrice.value,
};
if (quantity.value <= 0n || cost.value <= 0n) return null;
const absoluteReturn = decimalFromString(result.value);
const numerator = absoluteReturn.value * PERCENT_FACTOR
* RADIX ** BigInt(cost.scale + PERCENT_SCALE);
const denominator = cost.value * RADIX ** BigInt(absoluteReturn.scale);
const remainder = numerator % denominator;
const isRoundsUp = (remainder < 0n ? -remainder : remainder) * ROUNDING_FACTOR
>= denominator;
const rounding = isRoundsUp ? 1n : 0n;
const signedRounding = numerator < 0n ? -rounding : rounding;
const rounded = numerator / denominator + signedRounding;
return decimalToString({ scale: PERCENT_SCALE, value: rounded });
}
function toAccountPagePositionSections( function toAccountPagePositionSections(
portfolio: Portfolio, portfolio: Portfolio,
): AccountPagePositionSection[] { ): AccountPagePositionSection[] {
@@ -190,8 +111,6 @@ function toPosition(
const returnPrefix const returnPrefix
= isExpanded && returnValue !== null && returnValue > 0 ? "+" : ""; = isExpanded && returnValue !== null && returnValue > 0 ? "+" : "";
const percent = returnPercent(position);
return { return {
...position, ...position,
instrumentTo: instrumentTo:
@@ -201,10 +120,6 @@ function toPosition(
logoSrc: isExpanded logoSrc: isExpanded
? instrumentLogoUrl(position.logoName) ? instrumentLogoUrl(position.logoName)
: null, : null,
returnPercent: percent,
returnPercentText: percent
? `${formatDecimal(formatPositionReturnPercent(percent))} %`
: null,
returnText: position.absoluteReturn returnText: position.absoluteReturn
? `${returnPrefix}${formatMoney(position.absoluteReturn)}` ? `${returnPrefix}${formatMoney(position.absoluteReturn)}`
: null, : null,
@@ -247,46 +162,9 @@ function toSection(
}; };
} }
function toUnrealizedReturns(
positions: Portfolio["positions"],
): Money[] {
const totals = new Map<string, {
complete: boolean;
values: Decimal[];
}>();
for (const position of positions) {
const currencies = position.absoluteReturn
? [position.absoluteReturn.currency]
: [...new Set([
position.averagePurchasePrice?.currency,
position.currentValue?.currency,
].filter((currency): currency is string => !!currency))];
for (const currency of currencies) {
const total = totals.get(currency) ?? { complete: true, values: [] };
if (position.absoluteReturn) {
total.values.push(decimalFromString(position.absoluteReturn.value));
}
else {
total.complete = false;
}
totals.set(currency, total);
}
}
return [...totals].flatMap(([currency, total]) => total.complete
? [{ currency, value: decimalToString(sumDecimals(total.values)) }]
: []);
}
export { export {
type AccountPagePosition, type AccountPagePosition,
type AccountPagePositionGroup, type AccountPagePositionGroup,
type AccountPagePositionSection, type AccountPagePositionSection,
formatPositionReturnPercent,
toAccountPagePositionSections, toAccountPagePositionSections,
toUnrealizedReturns,
}; };
+1 -10
View File
@@ -33,7 +33,6 @@ type AccountPageRecentOperations
stale?: boolean; stale?: boolean;
state: "loaded" | "partial"; state: "loaded" | "partial";
to: string; to: string;
totalVisible?: boolean;
} }
| { | {
state: "empty" state: "empty"
@@ -85,7 +84,6 @@ function toRecentEvents(
items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [], items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [],
state: "partial", state: "partial",
to, to,
totalVisible: false,
}; };
if (!events.data && (events.isError || events.meta?.lastAttempt.state === "failed")) if (!events.data && (events.isError || events.meta?.lastAttempt.state === "failed"))
return { state: "error", to }; return { state: "error", to };
@@ -96,7 +94,6 @@ function toRecentEvents(
items, items,
state: "loaded", state: "loaded",
to, to,
totalVisible: events.meta?.completeness === "complete",
}; };
} }
@@ -112,12 +109,7 @@ function toRecentOperations(
) )
return { state: "loading", to }; return { state: "loading", to };
if (operations.meta?.completeness === "incomplete") if (operations.meta?.completeness === "incomplete")
return { return { items: operations.data?.items ?? [], state: "partial", to };
items: operations.data?.items ?? [],
state: "partial",
to,
totalVisible: false,
};
if ( if (
!operations.data !operations.data
&& (operations.isError || operations.meta?.lastAttempt.state === "failed") && (operations.isError || operations.meta?.lastAttempt.state === "failed")
@@ -133,7 +125,6 @@ function toRecentOperations(
stale: operations.meta?.freshness === "stale", stale: operations.meta?.freshness === "stale",
state: "loaded", state: "loaded",
to, to,
totalVisible: operations.meta?.completeness === "complete",
}; };
} }
@@ -98,13 +98,21 @@
font-family: var(--mantine-font-family-monospace); font-family: var(--mantine-font-family-monospace);
} }
.tabsList { .accordionControl {
flex-wrap: nowrap; transition:
overflow-x: auto; background-color 150ms ease,
color 150ms ease;
} }
.positionFilter { .accordionLabel {
overflow-x: auto; font-size: var(--mantine-h2-font-size);
font-weight: var(--mantine-h2-font-weight);
line-height: var(--mantine-h2-line-height);
}
.accordionPanel {
padding-left: 0;
padding-right: 0;
} }
.accountPositions { .accountPositions {
File diff suppressed because it is too large Load Diff
@@ -25,21 +25,12 @@ const AccountPage: FC = () => {
<AccountPageLoaded <AccountPageLoaded
account={viewModel.account} account={viewModel.account}
accountId={viewModel.accountId} accountId={viewModel.accountId}
concentration={viewModel.concentration}
currency={viewModel.currency}
incomeSummary={viewModel.incomeSummary} incomeSummary={viewModel.incomeSummary}
onCurrencyChange={viewModel.onCurrencyChange}
onSliceChange={viewModel.onSliceChange}
onTabChange={viewModel.onTabChange}
partial={viewModel.partial} partial={viewModel.partial}
portfolio={viewModel.portfolio} portfolio={viewModel.portfolio}
positionSections={viewModel.positionSections} positionSections={viewModel.positionSections}
recentEvents={viewModel.recentEvents} recentEvents={viewModel.recentEvents}
recentOperations={viewModel.recentOperations} recentOperations={viewModel.recentOperations}
slice={viewModel.slice}
source={viewModel.source}
tab={viewModel.tab}
unrealizedReturns={viewModel.unrealizedReturns}
/> />
); );
}; };
@@ -1,5 +1,3 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router"; import { useParams } from "react-router";
import type { Portfolio } from "@/entities/portfolio"; import type { Portfolio } from "@/entities/portfolio";
@@ -19,17 +17,9 @@ import { useScreenSync } from "@/features/tbank-connection";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
import { useInitialAvailability } from "@/shared/lib"; import { useInitialAvailability } from "@/shared/lib";
import {
type Concentration,
concentrationCurrencies,
type ConcentrationLabels,
type Slice,
toConcentration,
} from "../../model/concentration";
import { import {
type AccountPagePositionSection, type AccountPagePositionSection,
toAccountPagePositionSections, toAccountPagePositionSections,
toUnrealizedReturns,
} from "../../model/positions"; } from "../../model/positions";
import { import {
type AccountPageIncomeSummary, type AccountPageIncomeSummary,
@@ -54,18 +44,10 @@ type AccountPageEmptyViewModel = AccountPageContent & {
}; };
type AccountPageLoadedViewModel = AccountPageContent & { type AccountPageLoadedViewModel = AccountPageContent & {
concentration: Concentration | null;
currency: null | string;
onCurrencyChange: (currency: null | string) => void;
onSliceChange: (slice: Slice) => void;
onTabChange: (tab: null | string) => void;
portfolio: Portfolio; portfolio: Portfolio;
positionSections: AccountPagePositionSection[]; positionSections: AccountPagePositionSection[];
slice: Slice;
source?: InvestmentResourceMeta; source?: InvestmentResourceMeta;
state: "loaded"; state: "loaded";
tab: string;
unrealizedReturns: Portfolio["totalsByCurrency"];
}; };
type AccountPageViewModel type AccountPageViewModel
@@ -75,31 +57,14 @@ type AccountPageViewModel
| { state: "loading" }; | { state: "loading" };
function isPortfolioAccessFailure(code?: string) { function isPortfolioAccessFailure(code?: string) {
return isApiErrorCode(code, ApiErrorCode.InvalidTbankToken) return isApiErrorCode(code, ApiErrorCode.Forbidden)
|| isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)
|| isApiErrorCode(code, ApiErrorCode.NotFound) || isApiErrorCode(code, ApiErrorCode.NotFound)
|| isApiErrorCode(code, ApiErrorCode.TbankNotConnected); || isApiErrorCode(code, ApiErrorCode.TbankNotConnected);
} }
function useAccountPageViewModel(): AccountPageViewModel { function useAccountPageViewModel(): AccountPageViewModel {
const { accountId } = useParams(); const { accountId } = useParams();
const { t } = useTranslation();
const [selection, setSelection] = useState<{
accountId?: string;
currency: null | string;
slice: Slice;
tab: string;
}>({ accountId, currency: null, slice: "stocks", tab: "positions" });
const current = selection.accountId === accountId
? selection
: {
accountId, currency: null, slice: "stocks" as const, tab: "positions",
};
const updateSelection = (change: Partial<typeof selection>) =>
setSelection({ ...current, ...change });
const portfolio = usePortfolio(accountId); const portfolio = usePortfolio(accountId);
const operations = useBrokerageOperations(accountId, { const operations = useBrokerageOperations(accountId, {
@@ -156,43 +121,18 @@ function useAccountPageViewModel(): AccountPageViewModel {
}; };
const isPartial = portfolio.meta?.completeness === "incomplete"; const isPartial = portfolio.meta?.completeness === "incomplete";
const currencies = concentrationCurrencies(portfolio.data);
const currency = current.currency && currencies.includes(current.currency)
? current.currency
: currencies[0] ?? null;
const labels: ConcentrationLabels = {
missingIssuer: t("portfolio.concentration.missingIssuer"),
missingSector: t("portfolio.concentration.missingSector"),
other: t("portfolio.concentration.other"),
sector: code => t(`stock.sectors.${code}`, { defaultValue: code }),
};
const concentration = accountId !== "all"
&& current.tab === "concentration"
? toConcentration(portfolio.data, current.slice, currency, labels)
: null;
return { return {
account, account,
accountId, accountId,
concentration,
currency,
incomeSummary, incomeSummary,
onCurrencyChange: value => updateSelection({ currency: value }),
onSliceChange: slice => updateSelection({ slice }),
onTabChange: tab => updateSelection({ tab: tab ?? "positions" }),
partial: isPartial, partial: isPartial,
portfolio: portfolio.data, portfolio: portfolio.data,
positionSections: toAccountPagePositionSections(portfolio.data), positionSections: toAccountPagePositionSections(portfolio.data),
recentEvents, recentEvents,
recentOperations, recentOperations,
slice: current.slice,
source: portfolio.meta, source: portfolio.meta,
state: "loaded" as const, state: "loaded" as const,
tab: current.tab,
unrealizedReturns: toUnrealizedReturns(portfolio.data.positions),
}; };
} }
@@ -46,15 +46,9 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
structure={{ currencies: [], unvaluedCategories: [] }} structure={{ currencies: [], unvaluedCategories: [] }}
/> />
<RecentOperations <RecentOperations operations={props.recentOperations} />
allAccounts={props.accountId === "all"}
operations={props.recentOperations}
/>
<BrokerageEventsPreview <BrokerageEventsPreview events={props.recentEvents} />
allAccounts={props.accountId === "all"}
events={props.recentEvents}
/>
{!!props.incomeSummary && <IncomeSummary {...props.incomeSummary} />} {!!props.incomeSummary && <IncomeSummary {...props.incomeSummary} />}
</Stack> </Stack>

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