Compare commits
42
Commits
fac27bed93
...
v1.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df86b8450f | ||
|
|
b5ceb77208 | ||
|
|
b009101b7b | ||
|
|
7e6fd95cfa | ||
|
|
2cd6b1e72d | ||
|
|
3af9df0ae2 | ||
|
|
7b6f34c776 | ||
|
|
ed811e88fa | ||
|
|
eefdde29f8 | ||
|
|
5134106696 | ||
|
|
ebe92684a7 | ||
|
|
6f1226b668 | ||
|
|
d409a297e5 | ||
|
|
ed63adad94 | ||
|
|
bc28973d1a | ||
|
|
20618fb033 | ||
|
|
612ab62509 | ||
|
|
f295dea6a6 | ||
|
|
f3725c202b | ||
|
|
edc7097bd7 | ||
|
|
233680448f | ||
|
|
e38ecd87d6 | ||
|
|
e5f8bbe33c | ||
|
|
f7f53ff8e4 | ||
|
|
df05c8c7db | ||
|
|
e67c47b102 | ||
|
|
43b0f5574a | ||
|
|
c7d8d2c8f9 | ||
|
|
9d900b331a | ||
|
|
b357743c10 | ||
|
|
0d747cc93e | ||
|
|
c315f80b1b | ||
|
|
617edfd489 | ||
|
|
9ad3245efe | ||
|
|
cac4351bc4 | ||
|
|
17f5bae69b | ||
|
|
c8821200ce | ||
|
|
cec9c6348a | ||
|
|
a84411df39 | ||
|
|
3c226f8c29 | ||
|
|
20b2cdfd43 | ||
|
|
dcd1e3c278 |
@@ -30,7 +30,8 @@ backend checkout и не проверяют backend runtime OpenAPI; измен
|
||||
shape/status/route сохраняет
|
||||
публичный контракт предоставленного snapshot.
|
||||
Одна UI-модель не требует менять API. `openapi:generate` работает offline от snapshot;
|
||||
`openapi:check` генерирует во временный каталог и сравнивает, не меняя tracked files.
|
||||
`openapi:check` запускает native `--check` того же CLI и падает при устаревших
|
||||
generated types, не меняя tracked files.
|
||||
|
||||
## Auth и invalidation
|
||||
|
||||
@@ -39,6 +40,11 @@ Authorization. Auth endpoints исключены из Bearer/retry middleware. R
|
||||
координируется single-flight; после `401` с `UNAUTHORIZED` возможен один retry.
|
||||
Не добавляй собственный refresh/retry loop в caller.
|
||||
|
||||
Refresh, завершившийся `401`, очищает session. Сетевая ошибка и `5xx`
|
||||
(например, деплой backend) не доказывают конец session: она сохраняется,
|
||||
`unavailable` показывает retry-экран вместо login, а следующий `401` снова
|
||||
запускает refresh. Retry/backoff внутри refresh не добавляется.
|
||||
|
||||
Cookie-writing requests упорядочены только в текущей вкладке. Cross-tab auth
|
||||
synchronization не требуется; допустим reload/re-login. Web Locks и отдельные
|
||||
token stores не добавляются.
|
||||
|
||||
@@ -7,11 +7,19 @@
|
||||
checkout. Внешний API задаётся предоставленным публичным контрактом;
|
||||
исходники или инструкции его реализации не являются зависимостью разработки.
|
||||
При нехватке требования сообщи, какое решение не определено, до его реализации.
|
||||
При конфликте с общим шаблоном следуй местным правилам и явному запросу
|
||||
пользователя. Выбирай реализацию самостоятельно, но изменение финансовой
|
||||
логики, публичного контракта или утверждённой семантики возвращай владельцу
|
||||
на согласование до зависимой работы.
|
||||
|
||||
Начни с результата и affected areas. До edits проследи flow/invariants, найди
|
||||
owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт.
|
||||
Работай напрямую; OpenSpec/SDD lifecycle — только по явному запросу.
|
||||
Перед Node-командами активируй местную `.nvmrc`: `nvm use --silent`.
|
||||
Выбери lightweight или SDD по [Engineering Workflow](https://git.personal.ksv741.ru/WealthLedger/main/src/commit/03b3b59e6ff2ba391f9dfc42a039a96d0d84c1c9/.ai/conventions/engineering-workflow.md);
|
||||
OpenSpec lifecycle начинай после явного решения Change Owner.
|
||||
Перед Node-командами активируй местную `.nvmrc`: если `nvm` не загружен в
|
||||
non-interactive shell, загрузи установленный `$HOME/.nvm/nvm.sh`; затем
|
||||
выполни `nvm use --silent` из frontend. Если `nvm.sh` недоступен, остановись,
|
||||
не используй системный Node.
|
||||
Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы.
|
||||
|
||||
## Change scope
|
||||
@@ -57,21 +65,33 @@ Peer-конфликт решай совместимыми версиями бе
|
||||
|
||||
## Проверки и завершение
|
||||
|
||||
Code/dependency change: `npm ci`, полный `npm run verify` и дополнительные
|
||||
risk checks из местной testing convention. Targeted tests ускоряют итерацию,
|
||||
но не заменяют финальный gate. `lint:fix`/format — edits, не proof.
|
||||
Markdown-only: проверь новые/изменённые тексты, локальные ссылки/якоря, маршруты
|
||||
чтения и `git diff --check`; application build ради текста не нужен.
|
||||
Повтори routing по staged/unstaged/new files. Запиши краткие команды, результаты
|
||||
и ограничения. Зелёный lint не заменяет semantic/security review.
|
||||
| Когда | Что проверить |
|
||||
| --- | --- |
|
||||
| Окружение новое, изменились manifest/lockfile или Node, установка сомнительна | `npm ci` до Node-проверок; одна правка кода переустановки не требует. |
|
||||
| Итерация | Адресные тесты, typecheck и lint по затронутому поведению и риску. При изменении поведения добавь/обнови содержательную регрессию, если существующая проверка не ловит её; для косметики не создавай искусственный тест. |
|
||||
| Итоговый code/dependency diff | Перед объявлением готовности и передачей к интеграции выполни один полный `npm run verify` на итоговых входах. Дополнительные risk checks из testing convention и явно утверждённые task checks обязательны; CI проверяет независимо. |
|
||||
| Только Markdown | Проверь тексты, ссылки/якоря, маршруты чтения и `git diff --check`; application build не нужен. При изменении verification-контура проверь его работу. |
|
||||
| Pre-commit | Hook не скачивает tools: вызывай установленные бинарники из `node_modules/.bin` (не `npx`). Full graph не входит в hook; он выполняется в `npm run verify` и CI. |
|
||||
|
||||
Повтори routing по staged/unstaged/new files. В отчёте по задаче/PR запиши
|
||||
проверенные входы, команду, exit code, результат и ограничения. Повторяй
|
||||
проверку при изменении значимых входов (включая config, dependencies и
|
||||
generated artifacts), сомнении в evidence или явном запросе пользователя.
|
||||
`lint:fix`/format — edits, не proof; lint не заменяет semantic/security review.
|
||||
|
||||
## Git и релизы
|
||||
|
||||
Работа — в `codex/<task>` от актуального `develop`, затем PR в `develop`.
|
||||
Работа — в ветке `<type>/<issue-number>-<slug>` от актуального `develop`,
|
||||
затем PR в `develop`; для cross-repo ссылки укажи owner/repo#number.
|
||||
Сохраняй чужие изменения; перед commit просмотри staged paths и diff.
|
||||
Commits и PR titles — Conventional Commits; допустим `spec:`. Каждый commit
|
||||
содержит одну связанную задачу. При связанной issue добавь её номер в конец
|
||||
заголовка (`fix: … #12`); без issue номер не нужен. Root другого checkout
|
||||
не управляет этим repo.
|
||||
Commits и PR titles — Conventional Commits с parser-recognized Issue reference (`fix: … (#12)`
|
||||
или `chore: … (WealthLedger/main#27)`); допустим `spec:`. Каждый commit
|
||||
содержит одну связанную задачу. Исключения release и правила ссылки — в
|
||||
Engineering Workflow из AGENTS. Root другого checkout не управляет этим repo.
|
||||
Прямой push в `main`, force-push и перенос опубликованных тегов не входят в
|
||||
обычную работу. Перед release/hotfix прочитай местный README, раздел «Релизы».
|
||||
обычную работу. PR сливается squash или rebase-merge; merge-коммит не
|
||||
используется. `main` обновляется только fast-forward из `develop`, поэтому
|
||||
`origin/main` всегда предок `origin/develop`:
|
||||
`git merge-base --is-ancestor origin/main origin/develop`. Срочный fix —
|
||||
обычная ветка `fix/*` от `develop`; пути hotfix от `main` нет.
|
||||
Перед релизом прочитай местный README, раздел «Релизы».
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Неверное поведение
|
||||
---
|
||||
## Проблема и шаги воспроизведения
|
||||
|
||||
## Факт / ожидание
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ]
|
||||
|
||||
## Окружение / ссылки (если применимо)
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
name: Engineering Task
|
||||
about: CI, тесты, документация, процесс и прочее
|
||||
---
|
||||
## Контекст и результат
|
||||
|
||||
## Scope / связанные Issues (если есть)
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ]
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Новое наблюдаемое поведение
|
||||
---
|
||||
## Зачем и какой результат
|
||||
|
||||
## Ожидаемое поведение
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ]
|
||||
|
||||
## Зависимости / связанные Issues (если есть)
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: Refactoring / Technical Debt
|
||||
about: Улучшение без нового поведения
|
||||
---
|
||||
## Проблема и результат
|
||||
|
||||
## Сохраняемые инварианты / границы
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ]
|
||||
|
||||
## Зависимости / ссылки (если есть)
|
||||
@@ -0,0 +1,11 @@
|
||||
## Summary
|
||||
|
||||
## Linked Issue
|
||||
Refs
|
||||
|
||||
## Changes
|
||||
|
||||
## Verification
|
||||
- Команда / результат / ограничения:
|
||||
|
||||
## Breaking Changes (если есть)
|
||||
@@ -0,0 +1,34 @@
|
||||
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"
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
push:
|
||||
branches: [develop, main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
types: [opened, synchronize, reopened]
|
||||
schedule:
|
||||
- cron: '17 5 * * *'
|
||||
|
||||
@@ -34,7 +34,11 @@ jobs:
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
|
||||
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
|
||||
if [[ "$PR_TITLE" =~ ^chore\(release\):\ [0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
npm run commitlint -- --strict --from "$HEAD_SHA^" --to "$HEAD_SHA"
|
||||
else
|
||||
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
|
||||
fi
|
||||
|
||||
security:
|
||||
needs: commits
|
||||
|
||||
+5
-2
@@ -1,3 +1,6 @@
|
||||
gitleaks git --pre-commit --staged --redact=100 --no-banner || exit $?
|
||||
npx lint-staged
|
||||
npm run deps
|
||||
if [ ! -x node_modules/.bin/lint-staged ]; then
|
||||
echo "lint-staged is not installed; run npm ci" >&2
|
||||
exit 1
|
||||
fi
|
||||
node_modules/.bin/lint-staged
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
только его файлы и предоставленные публичные контракты. Перед работой прочитай
|
||||
[порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем
|
||||
объединяй triggers задачи и путей; читай активированные темы до решения.
|
||||
До edits найди/создай подходящую Gitea Issue, проверь AC и ветку; чтение
|
||||
допускается без Issue. Общий маршрут — [Engineering Workflow](https://git.personal.ksv741.ru/WealthLedger/main/src/commit/03b3b59e6ff2ba391f9dfc42a039a96d0d84c1c9/.ai/conventions/engineering-workflow.md)
|
||||
(main#28; после merge — актуальная версия `develop`).
|
||||
|
||||
| Решение / сигнал | Читать |
|
||||
| --- | --- |
|
||||
|
||||
@@ -1,5 +1,85 @@
|
||||
# Changelog
|
||||
|
||||
## [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)
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
FROM node:24.21.0-bookworm-slim AS dependencies
|
||||
FROM node:24.22.1-bookworm-slim AS dependencies
|
||||
WORKDIR /app
|
||||
ENV HUSKY=0
|
||||
COPY package.json package-lock.json ./
|
||||
@@ -19,7 +19,7 @@ COPY . .
|
||||
ENV VITE_API_BASE_URL=""
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.28.0-alpine AS production
|
||||
FROM nginx:1.31.8-alpine AS production
|
||||
ARG VERSION
|
||||
ARG REVISION
|
||||
LABEL org.opencontainers.image.version=$VERSION \
|
||||
|
||||
@@ -19,8 +19,10 @@ npm run dev
|
||||
|
||||
Для commits установите [Gitleaks](https://github.com/gitleaks/gitleaks/releases/tag/v8.30.1)
|
||||
8.30.1 в `PATH` (macOS: `brew install gitleaks`). Pre-commit сначала проверяет
|
||||
staged diff с полной редакцией секретов, затем запускает formatting и FSD checks.
|
||||
Находка или отсутствие Gitleaks блокирует commit.
|
||||
staged diff с полной редакцией секретов, затем запускает установленный
|
||||
`node_modules/.bin/lint-staged` только для staged JS/TS. Hook не скачивает tools и
|
||||
не запускает full graph: полный `npm run verify` выполняется в CI и перед передачей.
|
||||
Находка или отсутствие Gitleaks либо lint-staged блокирует commit.
|
||||
Проверка этого поведения: `node --test scripts/test-pre-commit.mjs`.
|
||||
|
||||
## Контейнерный image
|
||||
@@ -116,7 +118,8 @@ Chromium при ширинах 375 и 1280 px. Browser project не исполь
|
||||
Единый `.gitea/workflows/verify.yml` запускает `commits → security → verify`
|
||||
последовательно на PR; на push в `develop`/`main` — `security → verify`.
|
||||
Рабочие ветки проверяются после открытия PR, без дублирующего push-запуска.
|
||||
Изменение заголовка PR тоже повторяет проверки Conventional Commits.
|
||||
Изменение заголовка PR запускает `.gitea/workflows/pr-title.yml` (то же имя workflow,
|
||||
только job `commits`): статусы `security` и `verify` на HEAD не перезаписываются.
|
||||
Новый запуск отменяет устаревший для того же PR/ветки и типа события
|
||||
(требуется Gitea 1.26+). Ежедневно в 05:17 UTC выполняется только `security`.
|
||||
Job `security` проверяет полную доступную историю Gitleaks 8.30.1 и выполняет
|
||||
@@ -203,7 +206,8 @@ Checkout frontend содержит все входы сборки и прове
|
||||
локальную копию и не обращаются к исходникам или CI сервера. Доставка новой
|
||||
схемы — явное обновление snapshot с review и `npm run openapi:generate`.
|
||||
Runtime URL API задаётся окружением, а не расположением другого репозитория.
|
||||
Разработка идёт напрямую по задаче пользователя; OpenSpec/SDD — по явному запросу.
|
||||
Разработка начинается с Issue; lightweight или SDD выбирается по критериям
|
||||
Engineering Workflow из AGENTS. OpenSpec — после явного решения Change Owner.
|
||||
|
||||
Knip исключает `conventional-changelog-conventionalcommits`: release-it загружает
|
||||
preset по имени из `.release-it.json`. Версия preset закреплена напрямую.
|
||||
@@ -211,36 +215,61 @@ preset по имени из `.release-it.json`. Версия preset закреп
|
||||
## Релизы
|
||||
|
||||
Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые
|
||||
релизы. Работа: `codex/<task>` от актуального `develop`, PR в `develop`.
|
||||
Обычные PR допускают squash; release и обратная синхронизация сохраняют историю.
|
||||
Commits и PR titles — Conventional Commits, включая `spec:`. Версии вручную не повышай.
|
||||
релизы. Обычная работа: issue-linked ветка от актуального `develop`, PR в `develop`;
|
||||
общие правила — в Engineering Workflow по ссылке из AGENTS.
|
||||
PR сливается squash или rebase-merge; merge-коммит не используется. `main`
|
||||
обновляется только fast-forward из `develop`, поэтому история линейна:
|
||||
|
||||
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что
|
||||
`develop` содержит последний релиз. Создай `release/<name>` от проверенного
|
||||
`develop`; `nvm use --silent`, `npm ci`. Hotfix начинается от `main` в
|
||||
`release/hotfix-<name>` и проходит тот же процесс.
|
||||
```text
|
||||
<type>/<issue>-<slug> --squash--> develop --ff-only--> main --> tag vX.Y.Z
|
||||
```
|
||||
|
||||
Инвариант: `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,
|
||||
затем `npm run release:prepare`. Команда создаёт `chore(release): X.Y.Z`,
|
||||
обновляет manifest/lockfile/CHANGELOG; тег и push не выполняет. Не обходи
|
||||
guards ветки, чистого дерева, отсутствующих тегов или уже подготовленной версии.
|
||||
3. Проверь diff и штатные gates этого репозитория. Отправь только release-ветку,
|
||||
открой PR в `main` с заголовком `chore(release): X.Y.Z`. После зелёных
|
||||
`verify`, `security`, `commits` выполни fast-forward-only merge. Если `main`
|
||||
изменился, согласуй историю и повтори проверки кандидата.
|
||||
4. Получи удалённый `main`, сравни его HEAD с проверенным release SHA и версией
|
||||
открой PR в **`develop`** с заголовком `chore(release): X.Y.Z`. После зелёных
|
||||
`verify`, `security`, `commits` выполни fast-forward-only merge. Если
|
||||
`develop` изменился, обнови release-ветку и повтори проверки кандидата.
|
||||
4. Открой PR `develop → main` с тем же заголовком и слей fast-forward-only.
|
||||
5. Получи удалённый `main`, сравни его HEAD с проверенным release SHA и версией
|
||||
manifest/lockfile. Создай аннотированный `vX.Y.Z` именно на этом SHA:
|
||||
`git tag -a vX.Y.Z <release-sha> -m "Release X.Y.Z"`, затем отправь только
|
||||
`git push origin refs/tags/vX.Y.Z`. Не запускай release-it повторно на `main`.
|
||||
5. Верни release-коммит через PR `main → develop` без squash/rebase; проверь,
|
||||
что `develop` содержит тег. Опубликованные теги не перемещай. При сбое после
|
||||
merge создай отсутствующий тег без нового bump; до merge исправь и проверь
|
||||
существующий кандидат без повторного повышения версии.
|
||||
|
||||
Обратная синхронизация не нужна: release-коммит попал в `develop` на шаге 3.
|
||||
Опубликованные теги не перемещай. При сбое после 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;
|
||||
`!`/`BREAKING CHANGE:` — major, включая версии до 1.0. `chore(release):`
|
||||
исключён из рекомендации и changelog. Конфигурация — `.release-it.json`.
|
||||
В Gitea для `main`/`develop` должны быть запрещены прямой/force push и обход
|
||||
администратором, обязательны `verify`, `security`, `commits`; теги `v*` защищены.
|
||||
администратором, включена блокировка устаревшей ветки, обязательны `verify`,
|
||||
`security`, `commits`; теги `v*` защищены. Линейность обеспечивают merge styles
|
||||
репозитория: merge-коммиты и rebase `--no-ff` выключены, доступны squash,
|
||||
rebase-merge и fast-forward-only; стиль по умолчанию — squash.
|
||||
Workflow сам не включает эти настройки. Отдельное review approval для одного
|
||||
владельца не требуется. Npm publishing, deploy и Gitea Releases не включены.
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
module.exports = {
|
||||
defaultIgnores: false,
|
||||
extends: ["@commitlint/config-conventional"],
|
||||
ignores: [
|
||||
message => /^chore\(release\): \d+\.\d+\.\d+$/.test(message.trim()),
|
||||
message => /^Merge pull request ['"].+['"] \(#\d+\) from .+ into .+$/.test(message.trim()),
|
||||
],
|
||||
rules: {
|
||||
"references-empty": [2, "never"],
|
||||
"type-enum": [
|
||||
2,
|
||||
"always",
|
||||
|
||||
Generated
+1241
-1291
File diff suppressed because it is too large
Load Diff
+56
-51
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "wealth-ledger-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.1",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "24.21.0"
|
||||
"node": "24.22.1"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "npm run build:packages && vite",
|
||||
@@ -20,7 +20,7 @@
|
||||
"test:coverage": "npm run build:packages && vitest run --coverage",
|
||||
"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:check": "node scripts/check-openapi.mjs",
|
||||
"openapi:check": "openapi-typescript src/shared/api/spec/openapi.json -o src/shared/api/generated/schema.d.ts --check",
|
||||
"knip": "npm run build:packages && knip",
|
||||
"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",
|
||||
@@ -37,89 +37,94 @@
|
||||
"*.{js,mjs,cjs,ts,tsx}": "eslint --fix --max-warnings=0 --no-warn-ignored"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "5.9.1",
|
||||
"@mantine/charts": "9.6.1",
|
||||
"@mantine/core": "9.6.1",
|
||||
"@mantine/dates": "9.6.1",
|
||||
"@mantine/hooks": "9.6.1",
|
||||
"@mantine/notifications": "9.6.1",
|
||||
"@tanstack/react-query": "5.102.8",
|
||||
"@hookform/resolvers": "5.9.2",
|
||||
"@mantine/charts": "9.7.3",
|
||||
"@mantine/core": "9.7.3",
|
||||
"@mantine/dates": "9.7.3",
|
||||
"@mantine/hooks": "9.7.3",
|
||||
"@mantine/notifications": "9.7.3",
|
||||
"@tanstack/react-query": "5.104.5",
|
||||
"@wealthledger/icons": "file:packages/icons",
|
||||
"@wealthledger/tokens": "file:packages/tokens",
|
||||
"@wealthledger/ui": "file:packages/ui",
|
||||
"dayjs": "1.11.23",
|
||||
"i18next": "26.4.2",
|
||||
"motion": "13.2.0",
|
||||
"i18next": "26.5.10",
|
||||
"motion": "13.5.1",
|
||||
"openapi-fetch": "0.17.0",
|
||||
"openapi-react-query": "0.5.4",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
"react-hook-form": "7.88.0",
|
||||
"react-i18next": "17.0.14",
|
||||
"react-is": "19.3.0",
|
||||
"react-router": "8.3.1",
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1",
|
||||
"react-hook-form": "7.91.0",
|
||||
"react-i18next": "17.1.8",
|
||||
"react-is": "19.3.1",
|
||||
"react-router": "8.5.0",
|
||||
"recharts": "3.10.1",
|
||||
"zod": "4.6.4"
|
||||
"zod": "4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "8.0.5",
|
||||
"@commitlint/cli": "21.2.2",
|
||||
"@commitlint/config-conventional": "21.2.2",
|
||||
"@eslint-react/eslint-plugin": "5.21.0",
|
||||
"@babel/core": "8.0.9",
|
||||
"@commitlint/cli": "21.2.4",
|
||||
"@commitlint/config-conventional": "21.2.4",
|
||||
"@eslint-react/eslint-plugin": "5.28.8",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@release-it/conventional-changelog": "12.0.0",
|
||||
"@release-it/conventional-changelog": "12.0.2",
|
||||
"@rolldown/plugin-babel": "0.2.4",
|
||||
"@storybook/addon-docs": "10.6.0",
|
||||
"@storybook/react-vite": "10.6.0",
|
||||
"@storybook/addon-docs": "10.6.3",
|
||||
"@storybook/react-vite": "10.6.3",
|
||||
"@stylistic/eslint-plugin": "5.10.0",
|
||||
"@tanstack/eslint-plugin-query": "5.104.0",
|
||||
"@tanstack/eslint-plugin-query": "5.104.5",
|
||||
"@testing-library/jest-dom": "7.0.1",
|
||||
"@testing-library/react": "16.3.3",
|
||||
"@types/node": "24.13.4",
|
||||
"@types/node": "24.19.2",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@vitejs/plugin-react": "6.1.1",
|
||||
"@vitest/browser-playwright": "5.0.0",
|
||||
"@vitest/coverage-v8": "5.0.0",
|
||||
"@vitejs/plugin-react": "6.1.2",
|
||||
"@vitest/browser": "5.0.5",
|
||||
"@vitest/browser-playwright": "5.0.5",
|
||||
"@vitest/coverage-v8": "5.0.5",
|
||||
"@vitest/eslint-plugin": "1.6.27",
|
||||
"@vitest/ui": "5.0.5",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"conventional-changelog-conventionalcommits": "10.4.0",
|
||||
"dependency-cruiser": "18.2.0",
|
||||
"eslint": "10.11.0",
|
||||
"conventional-changelog-conventionalcommits": "10.4.1",
|
||||
"dependency-cruiser": "18.7.0",
|
||||
"eslint": "10.13.0",
|
||||
"eslint-import-resolver-typescript": "4.4.5",
|
||||
"eslint-plugin-import-x": "4.17.1",
|
||||
"eslint-plugin-jest-dom": "5.10.1",
|
||||
"eslint-plugin-no-unsanitized": "4.1.5",
|
||||
"eslint-plugin-perfectionist": "5.12.1",
|
||||
"eslint-plugin-perfectionist": "5.13.0",
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"eslint-plugin-react-refresh": "0.5.7",
|
||||
"eslint-plugin-regexp": "3.3.1",
|
||||
"eslint-plugin-security": "4.1.0",
|
||||
"eslint-plugin-sonarjs": "4.2.1",
|
||||
"eslint-plugin-storybook": "10.6.0",
|
||||
"eslint-plugin-security": "4.2.0",
|
||||
"eslint-plugin-sonarjs": "4.2.2",
|
||||
"eslint-plugin-storybook": "10.6.3",
|
||||
"eslint-plugin-testing-library": "7.16.2",
|
||||
"eslint-plugin-unicorn": "76.0.0",
|
||||
"globals": "17.12.0",
|
||||
"globals": "17.13.0",
|
||||
"husky": "9.1.7",
|
||||
"jsdom": "30.0.1",
|
||||
"knip": "6.35.1",
|
||||
"lint-staged": "17.5.1",
|
||||
"jsdom": "30.1.3",
|
||||
"knip": "6.43.0",
|
||||
"lint-staged": "17.6.0",
|
||||
"msw": "2.15.0",
|
||||
"openapi-typescript": "7.13.0",
|
||||
"playwright": "1.63.0",
|
||||
"release-it": "21.0.2",
|
||||
"storybook": "10.6.0",
|
||||
"playwright": "1.64.1",
|
||||
"release-it": "21.1.1",
|
||||
"storybook": "10.6.3",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.70.1",
|
||||
"vite": "8.3.0",
|
||||
"typescript-eslint": "8.72.0",
|
||||
"vite": "8.3.5",
|
||||
"vite-plugin-pwa": "1.3.0",
|
||||
"vitest": "5.0.0"
|
||||
"vitest": "5.0.5"
|
||||
},
|
||||
"overrides": {
|
||||
"release-it": {
|
||||
"basic-ftp": "6.2.1",
|
||||
"undici": "7.29.1"
|
||||
}
|
||||
"basic-ftp": "6.2.3",
|
||||
"undici": "8.12.3"
|
||||
},
|
||||
"@vitest/browser": "5.0.5",
|
||||
"@vitest/ui": "5.0.5",
|
||||
"@vitest/mocker": "5.0.5"
|
||||
},
|
||||
"allowScripts": {
|
||||
"esbuild@0.28.2": true,
|
||||
|
||||
@@ -20,14 +20,14 @@
|
||||
"build": "vite build && tsc -p tsconfig.json"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "19.3.0"
|
||||
"react": "19.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@storybook/react-vite": "10.6.0",
|
||||
"@storybook/react-vite": "10.6.3",
|
||||
"@testing-library/react": "16.3.3",
|
||||
"@types/react": "19.3.0",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "8.3.0",
|
||||
"vitest": "5.0.0"
|
||||
"vite": "8.3.5",
|
||||
"vitest": "5.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,18 +27,18 @@
|
||||
"@wealthledger/tokens": "0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mantine/core": "9.6.1",
|
||||
"@mantine/hooks": "9.6.1",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0"
|
||||
"@mantine/core": "9.7.3",
|
||||
"@mantine/hooks": "9.7.3",
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@storybook/react-vite": "10.6.0",
|
||||
"@storybook/react-vite": "10.6.3",
|
||||
"@testing-library/react": "16.3.3",
|
||||
"@types/react": "19.3.0",
|
||||
"storybook": "10.6.0",
|
||||
"storybook": "10.6.3",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "8.3.0",
|
||||
"vitest": "5.0.0"
|
||||
"vite": "8.3.5",
|
||||
"vitest": "5.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
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;
|
||||
@@ -85,7 +85,7 @@ test("ESLint enforces the frontend profile and excludes generated artifacts", as
|
||||
"security/detect-non-literal-regexp",
|
||||
],
|
||||
[
|
||||
"scripts/check-openapi.mjs",
|
||||
"scripts/test-pre-commit.mjs",
|
||||
"import { exec } from \"node:child_process\"; exec(process.argv[2]);",
|
||||
"security/detect-child-process",
|
||||
],
|
||||
|
||||
@@ -47,6 +47,12 @@ test("pre-commit scans the index, redacts findings and fails closed", () => {
|
||||
}
|
||||
|
||||
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.
|
||||
writeFileSync(nodePath.join(cwd, "sample.txt"), "safe staged content\n");
|
||||
git("add", "sample.txt");
|
||||
@@ -77,3 +83,37 @@ test("pre-commit scans the index, redacts findings and fails closed", () => {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -18,12 +18,21 @@ import type { IncomeAnalytics } from "@/entities/income";
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
|
||||
import { AuthProvider } from "@/features/auth";
|
||||
import { defaultPeriod, formatDate } from "@/shared/lib";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
|
||||
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 = [
|
||||
{ id: "a", name: "Основной" },
|
||||
{ id: "b", name: "ИИС" },
|
||||
@@ -95,10 +104,6 @@ const analytics: IncomeAnalytics
|
||||
},
|
||||
],
|
||||
period: { from: "2026-08-01", to: "2026-08-31" },
|
||||
previous: {
|
||||
period: { from: "2026-07-01", to: "2026-07-31" },
|
||||
totalsByCurrency: [],
|
||||
},
|
||||
totalsByCurrency: [
|
||||
{
|
||||
coupon: { currency: "RUB", value: "2" },
|
||||
@@ -107,7 +112,6 @@ const analytics: IncomeAnalytics
|
||||
total: { currency: "RUB", value: "2" },
|
||||
},
|
||||
],
|
||||
yearComparison: null,
|
||||
};
|
||||
|
||||
const CurrentLocation: FC = () => {
|
||||
@@ -238,7 +242,7 @@ describe("All accounts routes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["operations", "events", "income"])(
|
||||
it.each(["operations", "income"])(
|
||||
"restores applied dates when switching accounts in %s",
|
||||
async (section) => {
|
||||
const search = "?from=2026-08-01&to=2026-08-31";
|
||||
@@ -266,6 +270,31 @@ describe("All accounts routes", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("restores applied dates when switching accounts in events", async () => {
|
||||
// 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 () => {
|
||||
renderPage("/accounts/all");
|
||||
expect(await screen.findAllByText("Сбербанк")).toHaveLength(1);
|
||||
|
||||
@@ -190,6 +190,30 @@ describe("AppRoutes", () => {
|
||||
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 () => {
|
||||
server.use(
|
||||
http.post("*/api/v1/auth/refresh", () =>
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useAuth } from "@/features/auth";
|
||||
|
||||
type BoundaryProps = PropsWithChildren<{
|
||||
authenticated: boolean;
|
||||
routeKey: string;
|
||||
}>;
|
||||
|
||||
type RouteErrorFallbackProps = {
|
||||
@@ -26,12 +25,6 @@ class Boundary extends Component<BoundaryProps, State> {
|
||||
|
||||
override state: State = { hasError: false };
|
||||
|
||||
override componentDidUpdate(previousProps: BoundaryProps) {
|
||||
if (previousProps.routeKey !== this.props.routeKey && this.state.hasError) {
|
||||
this.setState({ hasError: false });
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
return this.state.hasError
|
||||
? <RouteErrorFallback authenticated={this.props.authenticated} />
|
||||
@@ -70,10 +63,7 @@ const RouteErrorBoundary: FC<PropsWithChildren> = (props) => {
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<Boundary
|
||||
authenticated={Boolean(user)}
|
||||
routeKey={`${pathname}${search}:${key}`}
|
||||
>
|
||||
<Boundary authenticated={Boolean(user)} key={`${pathname}${search}:${key}`}>
|
||||
{children}
|
||||
</Boundary>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Button, Container } from "@mantine/core";
|
||||
import { StateView } from "@wealthledger/ui";
|
||||
import { type FC, type PropsWithChildren, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Navigate, useLocation } from "react-router";
|
||||
|
||||
import { useAuth } from "@/features/auth";
|
||||
@@ -7,12 +10,26 @@ import { RouteSkeleton } from "./RouteFallback";
|
||||
|
||||
const Protected: FC<PropsWithChildren> = (props) => {
|
||||
const { children } = props;
|
||||
const { ready, signedOut, user } = useAuth();
|
||||
const { ready, retry, signedOut, unavailable, user } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const [wasAuthenticated, setWasAuthenticated] = useState(false);
|
||||
if (user && !wasAuthenticated) setWasAuthenticated(true);
|
||||
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
|
||||
? undefined
|
||||
: { returnTo: `${location.pathname}${location.search}` };
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
function errorCode(error: unknown): string | undefined {
|
||||
return typeof error === "object" && error !== null && "code" in error
|
||||
? String(error.code)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export { errorCode };
|
||||
@@ -6,6 +6,7 @@ import { calendarDate } from "@/shared/lib";
|
||||
import { bankToday } from "./form-validation";
|
||||
import { moneyCents } from "./money";
|
||||
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 MAX_BALANCE_KOPECKS = 9_223_372_036_854_775_807n;
|
||||
|
||||
@@ -63,7 +64,7 @@ const bankCardSchema = z.object({
|
||||
openingBalance: moneySchema,
|
||||
openingDate: bankDateSchema,
|
||||
paymentSystem: z
|
||||
.enum(["", "mir", "visa", "mastercard"], {
|
||||
.enum(["", ...paymentSystemValues], {
|
||||
error: validationMessage("bank.validation.paymentSystem"),
|
||||
})
|
||||
.optional(),
|
||||
@@ -75,5 +76,6 @@ export {
|
||||
bankCardSchema,
|
||||
type BankCardValues,
|
||||
bankDateSchema,
|
||||
paymentSystemValues,
|
||||
positiveMoneySchema,
|
||||
};
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useController, useFormContext } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { BankCardValues } from "../../model/form.schema";
|
||||
|
||||
import { type BankCardValues, paymentSystemValues } from "../../model/form.schema";
|
||||
import { bankDateField } from "../bankDateField";
|
||||
|
||||
function useBankCardFieldsVm() {
|
||||
@@ -16,7 +15,7 @@ function useBankCardFieldsVm() {
|
||||
const { field: openingDateField } = useController({ control, name: "openingDate" });
|
||||
const { field: paymentSystemField } = useController({ control, name: "paymentSystem" });
|
||||
|
||||
const paymentSystems = ["mir", "visa", "mastercard"].map(value => ({
|
||||
const paymentSystems = paymentSystemValues.map(value => ({
|
||||
label: t(`bank.paymentSystems.${value}`),
|
||||
value,
|
||||
}));
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { FC } from "react";
|
||||
import { Alert, Button, Group, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { apiErrorCode } from "@/shared/api";
|
||||
import { errorCode } from "../model/error-code";
|
||||
|
||||
type BankFeedbackProps = {
|
||||
cached?: boolean;
|
||||
@@ -14,7 +14,7 @@ type BankFeedbackProps = {
|
||||
|
||||
const BankFeedback: FC<BankFeedbackProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const code = apiErrorCode(props.error);
|
||||
const code = errorCode(props.error);
|
||||
|
||||
return (
|
||||
<Alert color="orange" role="alert">
|
||||
|
||||
@@ -15,6 +15,28 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.totals {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--mantine-spacing-xs) var(--mantine-spacing-lg);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.totals > div {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.totals dt {
|
||||
color: var(--mantine-color-dimmed);
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
}
|
||||
|
||||
.totals dd {
|
||||
margin: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.categories {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { BankSummaryView } from "./BankSummary.view";
|
||||
|
||||
const money = (value: string) => ({ currency: "RUB", value });
|
||||
|
||||
describe("BankSummaryView", () => {
|
||||
it("shows expenses, refunds, and net monthly totals", () => {
|
||||
render(
|
||||
<MantineProvider env="test">
|
||||
<BankSummaryView
|
||||
data={{
|
||||
cardId: "card-1",
|
||||
categories: [],
|
||||
expenses: money("1000"),
|
||||
net: money("800"),
|
||||
refunds: money("200"),
|
||||
}}
|
||||
error={false}
|
||||
fetching={false}
|
||||
loading={false}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByText("Расходы")).toHaveLength(2);
|
||||
expect(screen.getByText("Возвращено")).toBeInTheDocument();
|
||||
expect(screen.getByText("После возвратов")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 000,00 ₽")).toBeInTheDocument();
|
||||
expect(screen.getByText("200,00 ₽")).toBeInTheDocument();
|
||||
expect(screen.getByText("800,00 ₽")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { formatMoney } from "@/shared/lib";
|
||||
import type { BankSummary } from "../../api/types";
|
||||
|
||||
import { bankOperationAppearance } from "../../model/operation-appearance";
|
||||
import { BankFeedback } from "../BankFeedback";
|
||||
import classes from "./BankSummary.module.css";
|
||||
|
||||
const CATEGORY_COLUMNS = 2;
|
||||
@@ -17,9 +18,10 @@ const CATEGORY_COLUMNS = 2;
|
||||
type BankSummaryViewProps = {
|
||||
all?: boolean;
|
||||
data?: BankSummary;
|
||||
error: boolean;
|
||||
error: unknown;
|
||||
fetching: boolean;
|
||||
loading: boolean;
|
||||
retry?: () => void;
|
||||
};
|
||||
|
||||
const BankSummaryViewCategories: FC<BankSummaryViewProps> = (props) => {
|
||||
@@ -92,15 +94,44 @@ const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
|
||||
</Title>
|
||||
|
||||
{props.data && (
|
||||
<Text className={classes.total}>
|
||||
{formatMoney(props.data.net, { withZeroCents: true })}
|
||||
</Text>
|
||||
<dl className={classes.totals}>
|
||||
<div>
|
||||
<dt>{t("bank.summary")}</dt>
|
||||
|
||||
<dd>
|
||||
{formatMoney(props.data.expenses, { withZeroCents: true })}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt>{t("bank.refunds")}</dt>
|
||||
|
||||
<dd>
|
||||
{formatMoney(props.data.refunds, { withZeroCents: true })}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt>{t("bank.net")}</dt>
|
||||
|
||||
<dd className={classes.total}>
|
||||
{formatMoney(props.data.net, { withZeroCents: true })}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<BankSummaryViewCategories {...props} />
|
||||
|
||||
{props.error && !props.data && <Text role="alert">{t("bank.error")}</Text>}
|
||||
{Boolean(props.error) && (
|
||||
<BankFeedback
|
||||
cached={Boolean(props.data)}
|
||||
error={props.error}
|
||||
loadingError={!props.data}
|
||||
retry={props.retry}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -36,6 +36,12 @@ function entryLabelKey(operation: BrokerageOperation): string {
|
||||
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 {
|
||||
return value === expected;
|
||||
}
|
||||
@@ -109,4 +115,4 @@ function paymentPresentation(
|
||||
};
|
||||
}
|
||||
|
||||
export { operationLabelKey, paymentPresentation };
|
||||
export { isIncomePayment, operationLabelKey, paymentPresentation };
|
||||
|
||||
@@ -11,10 +11,11 @@ import {
|
||||
formatSnapshotTime,
|
||||
instrumentLogoUrl,
|
||||
} from "@/shared/lib";
|
||||
import { PayoutCard } from "@/shared/ui";
|
||||
|
||||
import type { BrokerageOperation } from "../api/useBrokerageOperations";
|
||||
|
||||
import { operationLabelKey, paymentPresentation } from "../model/display";
|
||||
import { isIncomePayment, operationLabelKey, paymentPresentation } from "../model/display";
|
||||
|
||||
type LedgerEntryRowProps = {
|
||||
operation: BrokerageOperation;
|
||||
@@ -75,6 +76,28 @@ const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
|
||||
}
|
||||
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 (
|
||||
<EntryCard
|
||||
details={details}
|
||||
|
||||
@@ -10,8 +10,6 @@ type BrokerageAccount = NonNullable<
|
||||
paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"]["data"]
|
||||
>[number];
|
||||
|
||||
type BrokerageAccountsResponse = paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"];
|
||||
|
||||
function useBrokerageAccounts() {
|
||||
const query = useResourceQuery({
|
||||
meta: { accessGroup: "brokerage" },
|
||||
@@ -21,7 +19,6 @@ function useBrokerageAccounts() {
|
||||
|
||||
return {
|
||||
...query,
|
||||
sync: query.sync as BrokerageAccountsResponse["sync"] | null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+16
-8
@@ -205,24 +205,32 @@ export const IndependentFreshness: Story
|
||||
],
|
||||
[
|
||||
{
|
||||
availability: "complete",
|
||||
availability: "available",
|
||||
completeness: "complete",
|
||||
dataRevision: "1",
|
||||
freshness: "stale",
|
||||
lastSuccessfulAt: "2026-09-24T10:30:00Z",
|
||||
resourceKey: "portfolio/storybook-account",
|
||||
sync: {
|
||||
errorCode: "TBANK_UNAVAILABLE",
|
||||
id: "failed",
|
||||
lastAttempt: {
|
||||
finishedAt: "2026-09-24T10:31:00Z",
|
||||
problems: [],
|
||||
startedAt: "2026-09-24T10:30:00Z",
|
||||
state: "failed",
|
||||
},
|
||||
lastSuccessfulAt: "2026-09-24T10:30:00Z",
|
||||
resourceKey: "portfolio/storybook-account",
|
||||
},
|
||||
{
|
||||
availability: "missing",
|
||||
availability: "initial-loading",
|
||||
completeness: "incomplete",
|
||||
dataRevision: "0",
|
||||
freshness: "unknown",
|
||||
lastAttempt: {
|
||||
finishedAt: null,
|
||||
problems: [],
|
||||
startedAt: "2026-09-24T10:30:00Z",
|
||||
state: "running",
|
||||
},
|
||||
lastSuccessfulAt: null,
|
||||
resourceKey: "portfolio/waiting",
|
||||
sync: { errorCode: null, id: "waiting", state: "running" },
|
||||
},
|
||||
],
|
||||
),
|
||||
|
||||
@@ -64,15 +64,16 @@ const BrokerageAccountSourceStatus: FC<BrokerageAccountSourceStatusProps> = (
|
||||
const { source } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isSyncing = source?.sync.state === "running";
|
||||
const isSyncing = source?.lastAttempt.state === "running";
|
||||
|
||||
const errorText
|
||||
= source?.sync.state === "failed" ? t("accounts.refreshError") : null;
|
||||
= source?.lastAttempt.state === "failed" ? t("accounts.refreshError") : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SourceSyncStatus
|
||||
errorText={errorText}
|
||||
lastAttempt={source?.lastAttempt}
|
||||
lastSuccessfulAt={source?.lastSuccessfulAt}
|
||||
stale={source?.freshness === "stale"}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { EntryCard } from "@wealthledger/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { formatDate, formatMoney, instrumentLogoUrl } from "@/shared/lib";
|
||||
import { PayoutCard } from "@/shared/ui";
|
||||
|
||||
import type { BrokerageEvent } from "../api/useBrokerageEvents";
|
||||
|
||||
@@ -15,6 +16,27 @@ const BrokerageEventCard: FC<BrokerageEventCardProps> = (props) => {
|
||||
|
||||
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 = [
|
||||
date,
|
||||
event.dateKind === "registry"
|
||||
@@ -40,7 +62,7 @@ const BrokerageEventCard: FC<BrokerageEventCardProps> = (props) => {
|
||||
title={[t(`events.type.${event.type}`), event.displayName].join(" · ")}
|
||||
tone="neutral"
|
||||
value={projectedAmount}
|
||||
valueLabel={t("events.projectedAmount")}
|
||||
valueLabel={t("payout.forecast")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,9 +4,6 @@ type IncomeAnalytics = NonNullable<
|
||||
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<
|
||||
paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"]["data"]
|
||||
>;
|
||||
@@ -47,7 +44,6 @@ function useIncomeSummary(accountId?: string) {
|
||||
|
||||
export {
|
||||
type IncomeAnalytics,
|
||||
type IncomeAnalyticsMeta,
|
||||
type IncomeSummary,
|
||||
useIncomeAnalytics,
|
||||
useIncomeSummary,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export {
|
||||
type IncomeAnalytics,
|
||||
type IncomeAnalyticsMeta,
|
||||
type IncomeSummary,
|
||||
useIncomeAnalytics,
|
||||
useIncomeSummary,
|
||||
|
||||
@@ -6,10 +6,6 @@ import {
|
||||
|
||||
type StockCandles = NonNullable<paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
|
||||
|
||||
type StockPosition = NonNullable<paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
|
||||
|
||||
const MIN_STOCK_SEARCH_LENGTH = 2;
|
||||
|
||||
type StockDividends = NonNullable<paths["/api/v1/stocks/{uid}/dividends"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
|
||||
|
||||
type StockPeriod = NonNullable<
|
||||
@@ -18,6 +14,8 @@ type StockPeriod = NonNullable<
|
||||
>["period"]
|
||||
>;
|
||||
|
||||
type StockPosition = NonNullable<paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
|
||||
|
||||
const STOCK_STALE_TIME_MS = 30_000;
|
||||
|
||||
function useStock(identifier: string) {
|
||||
@@ -96,18 +94,7 @@ function useStockQuote(uid: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function useStockSearch(query: string) {
|
||||
return useResourceQuery({
|
||||
...apiQueryClient.queryOptions("get", "/api/v1/stocks", {
|
||||
params: { query: { query } },
|
||||
}),
|
||||
enabled: query.length >= MIN_STOCK_SEARCH_LENGTH,
|
||||
staleTime: STOCK_STALE_TIME_MS,
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
MIN_STOCK_SEARCH_LENGTH,
|
||||
type StockCandles,
|
||||
type StockDividends,
|
||||
type StockPosition,
|
||||
@@ -117,5 +104,4 @@ export {
|
||||
useStockDividends,
|
||||
useStockPosition,
|
||||
useStockQuote,
|
||||
useStockSearch,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export {
|
||||
MIN_STOCK_SEARCH_LENGTH,
|
||||
type StockCandles,
|
||||
type StockDividends,
|
||||
type StockPosition,
|
||||
@@ -9,5 +8,4 @@ export {
|
||||
useStockDividends,
|
||||
useStockPosition,
|
||||
useStockQuote,
|
||||
useStockSearch,
|
||||
} from "./api/queries";
|
||||
|
||||
@@ -7,7 +7,12 @@ type AuthContextValue = {
|
||||
logout: () => Promise<void>;
|
||||
ready: boolean;
|
||||
register: (email: string, password: string) => Promise<void>;
|
||||
retry: () => void;
|
||||
signedOut: boolean;
|
||||
/**
|
||||
Refresh failed with a network error or 5xx; the refresh cookie may still be valid.
|
||||
*/
|
||||
unavailable: boolean;
|
||||
user: undefined | User;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -7,8 +7,10 @@ const EMAIL_PART_COUNT = 2;
|
||||
const ASCII_SPACE = 32;
|
||||
const ASCII_DELETE = 127;
|
||||
const MAX_EMAIL_LENGTH = 254;
|
||||
const MIN_PASSWORD_BYTES = 12;
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
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 {
|
||||
const codePoint = character.codePointAt(0);
|
||||
@@ -20,6 +22,14 @@ 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
|
||||
.string()
|
||||
.trim()
|
||||
@@ -33,28 +43,42 @@ const email = z
|
||||
{ error: validationMessage("auth.validation.invalidEmail") },
|
||||
);
|
||||
|
||||
const password = z
|
||||
// Login stays lenient: accounts created under an older policy must still sign in.
|
||||
const loginPassword = z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(
|
||||
value =>
|
||||
new TextEncoder().encode(value).length >= MIN_PASSWORD_BYTES
|
||||
&& new TextEncoder().encode(value).length <= MAX_PASSWORD_BYTES,
|
||||
{ error: validationMessage("auth.validation.invalidPasswordLength") },
|
||||
value => new TextEncoder().encode(value).length <= MAX_PASSWORD_BYTES,
|
||||
{ error: validationMessage("auth.validation.passwordTooLong") },
|
||||
);
|
||||
|
||||
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({
|
||||
email,
|
||||
password,
|
||||
password: loginPassword,
|
||||
passwordConfirmation: z.string().optional(),
|
||||
});
|
||||
|
||||
const registerSchema = loginSchema.refine(
|
||||
value => value.password === value.passwordConfirmation,
|
||||
{
|
||||
error: validationMessage("auth.validation.passwordsDoNotMatch"),
|
||||
path: ["passwordConfirmation"],
|
||||
},
|
||||
);
|
||||
const registerSchema = loginSchema
|
||||
.extend({ password: registerPassword })
|
||||
.refine(
|
||||
value => value.password === value.passwordConfirmation,
|
||||
{
|
||||
error: validationMessage("auth.validation.passwordsDoNotMatch"),
|
||||
path: ["passwordConfirmation"],
|
||||
},
|
||||
);
|
||||
|
||||
type AuthFormValues = z.input<typeof loginSchema>;
|
||||
|
||||
|
||||
@@ -92,8 +92,10 @@ describe("AuthProvider", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("settles unauthenticated and clears cached data when cold-start refresh has a transport error", async () => {
|
||||
server.use(http.post("*/api/v1/auth/refresh", () => HttpResponse.error()));
|
||||
it("settles unauthenticated and clears cached data when cold-start refresh is rejected with 401", async () => {
|
||||
server.use(http.post("*/api/v1/auth/refresh", () =>
|
||||
HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }),
|
||||
));
|
||||
const queryClient = createTestQueryClient();
|
||||
queryClient.setQueryData(["cached-query"], { value: "stale" });
|
||||
queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]);
|
||||
@@ -103,12 +105,54 @@ describe("AuthProvider", () => {
|
||||
await waitFor(() => expect(result.current.ready).toBe(true));
|
||||
|
||||
expect(result.current.user).toBeUndefined();
|
||||
expect(result.current.unavailable).toBe(false);
|
||||
expect(await requestAuthorization()).toBeNull();
|
||||
expect(queryClient.getQueryData(["cached-query"])).toBeUndefined();
|
||||
expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the original protected 401 when serialized refresh has a transport error", async () => {
|
||||
it.each([
|
||||
["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;
|
||||
server.use(
|
||||
http.post("*/api/v1/auth/refresh", () => {
|
||||
@@ -137,10 +181,11 @@ describe("AuthProvider", () => {
|
||||
const response = await apiClient.GET("/api/v1/brokerage-accounts");
|
||||
|
||||
expect(response.response.status).toBe(401);
|
||||
await waitFor(() => expect(result.current.user).toBeUndefined());
|
||||
expect(await requestAuthorization()).toBeNull();
|
||||
expect(queryClient.getQueryData(["cached-query"])).toBeUndefined();
|
||||
expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined();
|
||||
// An unreachable server does not prove the session ended; the next 401 refreshes again.
|
||||
await waitFor(() => expect(result.current.unavailable).toBe(true));
|
||||
expect(result.current.user?.id).toBe("user-1");
|
||||
expect(queryClient.getQueryData(["cached-query"])).toEqual({ value: "stale" });
|
||||
expect(queryClient.getQueryData(bankCardsKey)).toEqual([{ id: "bank-card-1" }]);
|
||||
});
|
||||
|
||||
it("clears connection status and financial cache independently on logout", async () => {
|
||||
|
||||
@@ -5,6 +5,9 @@ import { apiClient, configureAuthClient, type paths } from "@/shared/api";
|
||||
|
||||
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
|
||||
= paths["/api/v1/auth/login"]["post"]["responses"][200]["content"]["application/json"];
|
||||
|
||||
@@ -32,6 +35,8 @@ function useAuthProviderVm() {
|
||||
const accessTokenRef = useRef<string | undefined>(undefined);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [signedOut, setSignedOut] = useState(false);
|
||||
const [unavailable, setUnavailable] = useState(false);
|
||||
const unavailableRef = useRef(false);
|
||||
|
||||
const generationRef = useRef(0);
|
||||
const userRef = useRef<undefined | User>(undefined);
|
||||
@@ -49,12 +54,18 @@ function useAuthProviderVm() {
|
||||
return next;
|
||||
}, []);
|
||||
|
||||
const markUnavailable = useCallback((isUnavailable: boolean) => {
|
||||
unavailableRef.current = isUnavailable;
|
||||
setUnavailable(isUnavailable);
|
||||
}, []);
|
||||
|
||||
const applyAuthentication = useCallback(
|
||||
(authentication: Authentication) => {
|
||||
const changedIdentity
|
||||
= userRef.current && userRef.current.id !== authentication.user.id;
|
||||
|
||||
setSignedOut(false);
|
||||
markUnavailable(false);
|
||||
accessTokenRef.current = authentication.accessToken;
|
||||
|
||||
if (changedIdentity) {
|
||||
@@ -67,16 +78,17 @@ function useAuthProviderVm() {
|
||||
userRef.current = authentication.user;
|
||||
setUser(authentication.user);
|
||||
},
|
||||
[queryClient],
|
||||
[markUnavailable, queryClient],
|
||||
);
|
||||
|
||||
const clearSession = useCallback(() => {
|
||||
markUnavailable(false);
|
||||
generationRef.current += 1;
|
||||
userRef.current = undefined;
|
||||
setUser(undefined);
|
||||
accessTokenRef.current = undefined;
|
||||
queryClient.clear();
|
||||
}, [queryClient]);
|
||||
}, [markUnavailable, queryClient]);
|
||||
|
||||
const refresh = useCallback((): Promise<boolean> => {
|
||||
if (refreshPromiseRef.current) return refreshPromiseRef.current;
|
||||
@@ -86,11 +98,12 @@ function useAuthProviderVm() {
|
||||
if (started !== generationRef.current) return false;
|
||||
|
||||
try {
|
||||
const { data } = await apiClient.POST("/api/v1/auth/refresh");
|
||||
const { data, response } = await apiClient.POST("/api/v1/auth/refresh");
|
||||
if (started !== generationRef.current) return false;
|
||||
|
||||
if (!data) {
|
||||
clearSession();
|
||||
if (response.status >= HTTP_SERVER_ERROR) markUnavailable(true);
|
||||
else clearSession();
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -100,7 +113,7 @@ function useAuthProviderVm() {
|
||||
return true;
|
||||
}
|
||||
catch {
|
||||
if (started === generationRef.current) clearSession();
|
||||
if (started === generationRef.current) markUnavailable(true);
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -112,7 +125,7 @@ function useAuthProviderVm() {
|
||||
refreshPromiseRef.current = pending;
|
||||
|
||||
return pending;
|
||||
}, [applyAuthentication, clearSession, serialize]);
|
||||
}, [applyAuthentication, clearSession, markUnavailable, serialize]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh().finally(() => setReady(true));
|
||||
@@ -146,6 +159,7 @@ function useAuthProviderVm() {
|
||||
clear: clearSession,
|
||||
generation: () => generationRef.current,
|
||||
refresh,
|
||||
unavailable: () => unavailableRef.current,
|
||||
});
|
||||
});
|
||||
useEffect(() => () => configureAuthClient(), []);
|
||||
@@ -157,7 +171,9 @@ function useAuthProviderVm() {
|
||||
ready,
|
||||
register: (email: string, password: string) =>
|
||||
authenticate("/api/v1/auth/register", email, password),
|
||||
retry: () => void refresh(),
|
||||
signedOut,
|
||||
unavailable,
|
||||
user,
|
||||
};
|
||||
|
||||
|
||||
@@ -30,4 +30,79 @@ describe("AuthForm", () => {
|
||||
await waitFor(() => expect(onSubmit).not.toHaveBeenCalled());
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FC } from "react";
|
||||
import type { FC, ReactNode } from "react";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Alert, Button, Fieldset, PasswordInput, Stack, TextInput } from "@mantine/core";
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
|
||||
type AuthFormProps = {
|
||||
onSubmit: (email: string, password: string) => Promise<void>;
|
||||
register?: true;
|
||||
register?: boolean;
|
||||
};
|
||||
|
||||
const AuthForm: FC<AuthFormProps> = (props) => {
|
||||
@@ -45,6 +45,24 @@ const AuthForm: FC<AuthFormProps> = (props) => {
|
||||
});
|
||||
|
||||
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 (
|
||||
<FormProvider {...methods}>
|
||||
@@ -77,9 +95,7 @@ const AuthForm: FC<AuthFormProps> = (props) => {
|
||||
{t(register ? "auth.register" : "auth.login")}
|
||||
</Button>
|
||||
|
||||
<Button component={Link} to={to} variant="subtle">
|
||||
{t(register ? "auth.haveAccount" : "auth.noAccount")}
|
||||
</Button>
|
||||
{switchLink}
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
</form>
|
||||
|
||||
@@ -5,20 +5,23 @@ import { resource } from "@/test/resource";
|
||||
import { section } from "./section";
|
||||
|
||||
describe("instrument source and transport presentation", () => {
|
||||
it.each(["missing", "partial", "complete"] as const)(
|
||||
it.each([
|
||||
["initial-loading", "incomplete"],
|
||||
["available", "complete"],
|
||||
] as const)(
|
||||
"keeps %s separate from successful transport",
|
||||
(availability) => {
|
||||
const data = availability === "missing" ? undefined : { rows: [] };
|
||||
(availability, completeness) => {
|
||||
const data = availability === "initial-loading" ? undefined : { rows: [] };
|
||||
|
||||
const state = section({
|
||||
data,
|
||||
isError: false,
|
||||
meta: resource(data, { availability }).meta,
|
||||
meta: resource(data, { availability, completeness }).meta,
|
||||
});
|
||||
|
||||
expect(state.loading).toBe(availability === "missing");
|
||||
expect(state.partial).toBe(availability === "partial");
|
||||
expect(state.hasData).toBe(availability !== "missing");
|
||||
expect(state.loading).toBe(availability === "initial-loading");
|
||||
expect(state.partial).toBe(completeness === "incomplete");
|
||||
expect(state.hasData).toBe(availability !== "initial-loading");
|
||||
},
|
||||
);
|
||||
it("preserves empty successful data instead of treating it as missing", () => {
|
||||
@@ -37,7 +40,12 @@ describe("instrument source and transport presentation", () => {
|
||||
|
||||
it("preserves a full source when force fails", () => {
|
||||
const meta = resource([], {
|
||||
sync: { errorCode: "TBANK_UNAVAILABLE", id: "work", state: "failed" },
|
||||
lastAttempt: {
|
||||
finishedAt: "2026-08-14T00:01:00Z",
|
||||
problems: [],
|
||||
startedAt: "2026-08-14T00:00:00Z",
|
||||
state: "failed",
|
||||
},
|
||||
}).meta;
|
||||
|
||||
const state = section({ data: [], isError: false, meta });
|
||||
@@ -53,7 +61,12 @@ describe("instrument source and transport presentation", () => {
|
||||
data: undefined,
|
||||
isError: false,
|
||||
meta: resource(null, {
|
||||
sync: { errorCode: "TBANK_UNAVAILABLE", id: "work", state: "failed" },
|
||||
lastAttempt: {
|
||||
finishedAt: "2026-08-14T00:01:00Z",
|
||||
problems: [],
|
||||
startedAt: "2026-08-14T00:00:00Z",
|
||||
state: "failed",
|
||||
},
|
||||
}).meta,
|
||||
});
|
||||
|
||||
|
||||
@@ -16,14 +16,14 @@ function section(query: {
|
||||
isError: boolean;
|
||||
meta?: InvestmentResourceMeta;
|
||||
}): SectionState {
|
||||
const isError = query.isError || query.meta?.sync.state === "failed";
|
||||
const isError = query.isError || query.meta?.lastAttempt.state === "failed";
|
||||
const hasData = hasSectionData(query.data);
|
||||
|
||||
return {
|
||||
error: isError,
|
||||
hasData,
|
||||
loading: !hasData && !isError,
|
||||
partial: query.meta?.availability === "partial",
|
||||
partial: query.meta?.completeness === "incomplete",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -19,12 +19,14 @@ function auth() {
|
||||
}
|
||||
|
||||
describe("read-only screen synchronization", () => {
|
||||
it("retries the resource with GET and never starts a sync POST", async () => {
|
||||
it("retries the resource with GET without requesting source observation", async () => {
|
||||
auth();
|
||||
let gets = 0;
|
||||
const syncModes: (null | string)[] = [];
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts", () => {
|
||||
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
|
||||
gets += 1;
|
||||
syncModes.push(new URL(request.url).searchParams.get("sync"));
|
||||
|
||||
return gets === 1
|
||||
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
|
||||
@@ -49,6 +51,7 @@ describe("read-only screen synchronization", () => {
|
||||
await waitFor(() => expect(result.current.accounts.data).toEqual([]));
|
||||
|
||||
expect(gets).toBe(2);
|
||||
expect(syncModes).toEqual([null, null]);
|
||||
});
|
||||
|
||||
it("refreshes the currently open resources together", async () => {
|
||||
@@ -75,4 +78,35 @@ describe("read-only screen synchronization", () => {
|
||||
await act(async () => result.current.refresh());
|
||||
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,10 +5,10 @@ import { useEffect, useRef } from "react";
|
||||
import type { InvestmentResourceMeta } from "@/shared/api";
|
||||
|
||||
type ScreenResource = {
|
||||
isEnabled: boolean;
|
||||
isError: boolean;
|
||||
isFetching: boolean;
|
||||
meta?: InvestmentResourceMeta;
|
||||
observe: () => Promise<unknown>;
|
||||
queryKey: QueryKey;
|
||||
refetch: () => Promise<unknown>;
|
||||
};
|
||||
@@ -28,28 +28,34 @@ function useScreenSync(_scope: ScreenScope, resources: ScreenResource[]) {
|
||||
|
||||
useEffect(() => {
|
||||
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(() => {});
|
||||
}
|
||||
}, READ_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const isRefreshing = resources.some(resource => resource.isFetching);
|
||||
const isRefreshing = resources.some(
|
||||
resource => resource.isEnabled && resource.isFetching,
|
||||
);
|
||||
|
||||
const eligible = resources.filter(
|
||||
resource => resource.isEnabled && !resource.isFetching,
|
||||
);
|
||||
|
||||
const refresh = async () => {
|
||||
if (isRefreshing) return;
|
||||
await Promise.allSettled(resources.map(resource => resource.refetch()));
|
||||
await Promise.allSettled(eligible.map(resource => resource.refetch()));
|
||||
};
|
||||
|
||||
// Retry failed HTTP reads without asking the source to import again.
|
||||
// Retry only the HTTP read; source attempts are reported in resource metadata.
|
||||
const retry = async () => {
|
||||
const failed = resources.filter(
|
||||
resource => resource.isError,
|
||||
);
|
||||
const failed = eligible.filter(resource => resource.isError);
|
||||
|
||||
if (failed.length > 0)
|
||||
await Promise.allSettled(failed.map(resource => resource.observe()));
|
||||
await Promise.allSettled(failed.map(resource => resource.refetch()));
|
||||
else await refresh();
|
||||
};
|
||||
|
||||
@@ -57,7 +63,9 @@ function useScreenSync(_scope: ScreenScope, resources: ScreenResource[]) {
|
||||
refresh,
|
||||
refreshing: isRefreshing,
|
||||
retry,
|
||||
sourceError: resources.some(({ meta }) => meta?.sync.state === "failed"),
|
||||
sourceError: resources.some(
|
||||
({ meta }) => meta?.lastAttempt.state === "failed",
|
||||
),
|
||||
transportError: resources.some(resource => resource.isError),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,10 +17,10 @@ import {
|
||||
useLocation,
|
||||
useNavigate,
|
||||
} from "react-router";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { defaultPeriod, formatDate } from "@/shared/lib";
|
||||
import { defaultPeriod, formatDate, moscowDate } from "@/shared/lib";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
@@ -72,7 +72,14 @@ function renderEvents(accountId: string, searches: string[]) {
|
||||
}
|
||||
|
||||
describe("Upcoming event date filters", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
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({
|
||||
accessToken: () => "token",
|
||||
clear: vi.fn(),
|
||||
@@ -80,6 +87,8 @@ describe("Upcoming event date filters", () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("keeps reads and sync disabled without an account scope", async () => {
|
||||
const requests: string[] = [];
|
||||
server.use(
|
||||
@@ -123,9 +132,10 @@ describe("Upcoming event date filters", () => {
|
||||
resource(
|
||||
null,
|
||||
{
|
||||
sync: {
|
||||
errorCode: "TBANK_UNAVAILABLE",
|
||||
id: null,
|
||||
lastAttempt: {
|
||||
finishedAt: "2026-08-14T00:01:00Z",
|
||||
problems: [],
|
||||
startedAt: "2026-08-14T00:00:00Z",
|
||||
state: "failed",
|
||||
},
|
||||
},
|
||||
@@ -228,6 +238,10 @@ describe("Upcoming event date filters", () => {
|
||||
it.each(["account-1", "all"])(
|
||||
"applies dates only on submit, restores URL dates, and resets from empty results for %s",
|
||||
async (accountId) => {
|
||||
// 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 searches: string[] = [];
|
||||
server.use(
|
||||
@@ -252,37 +266,58 @@ describe("Upcoming event date filters", () => {
|
||||
expect(from).toHaveTextContent("01.02.2026 – 28.02.2026");
|
||||
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
|
||||
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(
|
||||
await screen.findByRole("button", { name: /10 February 2026/i }),
|
||||
await screen.findByRole("button", { name: calendarLabel(today) }),
|
||||
);
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: /28 February 2026/i }),
|
||||
await screen.findByRole("button", { name: calendarLabel(future) }),
|
||||
);
|
||||
expect(searches).toHaveLength(1);
|
||||
// The reset itself fetched the default range; picking dates applies only on submit.
|
||||
expect(searches).toEqual([
|
||||
"?from=2026-02-01&to=2026-02-28",
|
||||
`?from=${defaultFrom}&to=${defaultTo}`,
|
||||
]);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
|
||||
await waitFor(() =>
|
||||
expect(searches.at(-1)).toBe("?from=2026-02-10&to=2026-02-28"),
|
||||
expect(searches.at(-1)).toBe(`?from=${today}&to=${future}`),
|
||||
);
|
||||
expect(await screen.findByLabelText("Период")).toHaveTextContent(
|
||||
"10.02.2026 – 28.02.2026",
|
||||
`${formatDate(today)} – ${formatDate(future)}`,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "History back" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("Период")).toHaveTextContent(
|
||||
"01.02.2026 – 28.02.2026",
|
||||
`${formatDate(defaultFrom)} – ${formatDate(defaultTo)}`,
|
||||
),
|
||||
);
|
||||
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.getByLabelText("URL")).toBeEmptyDOMElement();
|
||||
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText("За выбранный период событий не найдено"),
|
||||
@@ -309,7 +344,14 @@ it("retains the previous complete forecast when its source refresh fails", async
|
||||
|
||||
const snapshot = resource(
|
||||
{ events, incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }] },
|
||||
{ sync: { errorCode: "TBANK_UNAVAILABLE", id: "failed", state: "failed" } },
|
||||
{
|
||||
lastAttempt: {
|
||||
finishedAt: "2026-08-14T00:01:00Z",
|
||||
problems: [],
|
||||
startedAt: "2026-08-14T00:00:00Z",
|
||||
state: "failed",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
server.use(
|
||||
|
||||
@@ -51,8 +51,9 @@ describe("AccountEventsPage", () => {
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
meta: {
|
||||
availability: "complete",
|
||||
sync: { errorCode: null, id: null, state: "idle" },
|
||||
availability: "available",
|
||||
completeness: "complete",
|
||||
lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" },
|
||||
},
|
||||
refetch: vi.fn(),
|
||||
} as never,
|
||||
@@ -85,8 +86,9 @@ describe("AccountEventsPage", () => {
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
meta: {
|
||||
availability: "complete",
|
||||
sync: { errorCode: null, id: null, state: "idle" },
|
||||
availability: "available",
|
||||
completeness: "complete",
|
||||
lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" },
|
||||
},
|
||||
refetch: vi.fn(),
|
||||
} as never,
|
||||
@@ -138,8 +140,9 @@ describe("AccountEventsPage", () => {
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
meta: {
|
||||
availability: "complete",
|
||||
sync: { errorCode: null, id: null, state: "idle" },
|
||||
availability: "available",
|
||||
completeness: "complete",
|
||||
lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" },
|
||||
},
|
||||
refetch: vi.fn(),
|
||||
} as never,
|
||||
@@ -153,7 +156,7 @@ describe("AccountEventsPage", () => {
|
||||
expect(screen.getAllByText(/RU000A0JX0J2$/)).toHaveLength(1);
|
||||
expect(screen.getByText("25.08.2026")).toBeVisible();
|
||||
expect(screen.queryByText(/Дата события/)).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText("Ожидаемая сумма")).toHaveLength(2);
|
||||
expect(screen.getAllByText("Прогноз")).toHaveLength(2);
|
||||
const logo = screen.queryByAltText("");
|
||||
expect(logo).toHaveAttribute(
|
||||
"src",
|
||||
@@ -194,8 +197,9 @@ describe("AccountEventsPage", () => {
|
||||
isError: false,
|
||||
isLoading: true,
|
||||
meta: {
|
||||
availability: "complete",
|
||||
sync: { errorCode: null, id: null, state: "idle" },
|
||||
availability: "available",
|
||||
completeness: "complete",
|
||||
lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" },
|
||||
},
|
||||
refetch: vi.fn(),
|
||||
} as never,
|
||||
@@ -207,4 +211,30 @@ describe("AccountEventsPage", () => {
|
||||
expect(status).toHaveAttribute("aria-busy", "true");
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
|
||||
import type { InvestmentResourceMeta } from "@/shared/api";
|
||||
|
||||
import {
|
||||
type BrokerageEvent,
|
||||
useBrokerageEvents,
|
||||
@@ -19,12 +21,13 @@ type AccountEventsPageViewModel
|
||||
canApply: boolean;
|
||||
draftPeriod: [null | string, null | string];
|
||||
events: BrokerageEvent[];
|
||||
maxDate: string;
|
||||
minDate: string;
|
||||
onApply: () => void;
|
||||
onPeriodChange: (value: [null | string, null | string]) => void;
|
||||
onPeriodPopoverOpen: () => void;
|
||||
onReset: () => void;
|
||||
partial?: boolean;
|
||||
source?: InvestmentResourceMeta;
|
||||
state: "loaded";
|
||||
}
|
||||
| { onReset: () => void; state: "error" }
|
||||
@@ -46,7 +49,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
|
||||
|
||||
const [draftFrom, draftTo] = draftPeriod;
|
||||
|
||||
const [maxDate, setMaxDate] = useState(() => moscowDate(new Date()));
|
||||
const [minDate, setMinDate] = useState(() => moscowDate(new Date()));
|
||||
|
||||
const scope = JSON.stringify([accountId, appliedFrom, appliedTo]);
|
||||
const [previousScope, setPreviousScope] = useState(scope);
|
||||
@@ -74,7 +77,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
|
||||
const availability = useInitialAvailability({
|
||||
hasContent: !!events.data,
|
||||
identity: scope,
|
||||
isError: events.isError || events.meta?.sync.state === "failed",
|
||||
isError: events.isError || events.meta?.lastAttempt.state === "failed",
|
||||
isFetching: events.isFetching,
|
||||
});
|
||||
|
||||
@@ -86,7 +89,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
|
||||
setSearchParameters({});
|
||||
};
|
||||
|
||||
if ((events.isError || events.meta?.sync.state === "failed") && !events.data)
|
||||
if ((events.isError || events.meta?.lastAttempt.state === "failed") && !events.data)
|
||||
return {
|
||||
onReset,
|
||||
state: "error",
|
||||
@@ -102,7 +105,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
|
||||
&& isValidPeriod(draftFrom, draftTo),
|
||||
draftPeriod,
|
||||
events: events.data.events,
|
||||
maxDate,
|
||||
minDate,
|
||||
onApply: () => {
|
||||
if (!isValidPeriod(draftFrom, draftTo)) return;
|
||||
setSearchParameters({
|
||||
@@ -113,9 +116,10 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
|
||||
onPeriodChange: value => setDraftPeriod(
|
||||
value[0] === null && value[1] === null ? defaultPeriod() : value,
|
||||
),
|
||||
onPeriodPopoverOpen: () => setMaxDate(moscowDate(new Date())),
|
||||
onPeriodPopoverOpen: () => setMinDate(moscowDate(new Date())),
|
||||
onReset,
|
||||
partial: events.meta?.availability !== "complete",
|
||||
partial: events.meta?.completeness === "incomplete",
|
||||
source: events.meta,
|
||||
state: "loaded",
|
||||
};
|
||||
}
|
||||
|
||||
+8
-1
@@ -15,6 +15,7 @@ import {
|
||||
BrokerageEventCard,
|
||||
brokerageEventKey,
|
||||
} from "@/entities/brokerage-event";
|
||||
import { SourceSyncStatus } from "@/shared/ui";
|
||||
|
||||
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
|
||||
|
||||
@@ -55,6 +56,12 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
|
||||
<Stack gap="lg">
|
||||
<Title order={1}>{t("events.title")}</Title>
|
||||
|
||||
<SourceSyncStatus
|
||||
lastAttempt={props.source?.lastAttempt}
|
||||
lastSuccessfulAt={props.source?.lastSuccessfulAt}
|
||||
stale={props.source?.freshness === "stale"}
|
||||
/>
|
||||
|
||||
{!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>}
|
||||
|
||||
<Text c="dimmed">{t("events.hint")}</Text>
|
||||
@@ -65,7 +72,7 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
|
||||
clearable
|
||||
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
|
||||
label={t("ledger.period")}
|
||||
maxDate={props.maxDate}
|
||||
minDate={props.minDate}
|
||||
onChange={props.onPeriodChange}
|
||||
popoverProps={{
|
||||
middlewares: { shift: { crossAxis: true } },
|
||||
|
||||
@@ -2,167 +2,25 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { IncomeAnalytics } from "@/entities/income";
|
||||
|
||||
import { resource } from "@/test/resource";
|
||||
|
||||
import { toIncomeDisplayModel } from "./display";
|
||||
|
||||
const analytics: IncomeAnalytics = {
|
||||
accountId: "account-1",
|
||||
bucketGranularity: "day",
|
||||
buckets: [],
|
||||
calculatedAt: "2026-08-27T00:00:00Z",
|
||||
entries: [],
|
||||
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,
|
||||
};
|
||||
describe("income payments display", () => {
|
||||
it("keeps repeated IDs, order and logo revisions without reconstructing financial data", () => {
|
||||
const payment = {
|
||||
id: "same",
|
||||
instrumentKey: "bond:RUB",
|
||||
logoName: null,
|
||||
} as IncomeAnalytics["entries"][number];
|
||||
|
||||
function comparison(currency: string) {
|
||||
return {
|
||||
compared: summary(currency, "1", "0"),
|
||||
currency,
|
||||
difference: summary(currency, "9", "2"),
|
||||
selected: summary(currency, "10", "2"),
|
||||
};
|
||||
}
|
||||
const analytics = {
|
||||
entries: [payment, payment, { ...payment, logoName: "bond.png" }],
|
||||
} as IncomeAnalytics;
|
||||
|
||||
function money(currency: string, value: string) {
|
||||
return { currency, value };
|
||||
}
|
||||
const display = toIncomeDisplayModel(analytics);
|
||||
|
||||
function summary(currency: string, coupon: string, dividend: string) {
|
||||
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(display.entries.map(({ operation }) => operation)).toEqual(
|
||||
analytics.entries,
|
||||
);
|
||||
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",
|
||||
]);
|
||||
expect(new Set(display.entries.map(({ key }) => key)).size).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
resourceKey: "opaque/year-looking-key",
|
||||
role: "previous" as const,
|
||||
sync: {
|
||||
errorCode: "TBANK_UNAVAILABLE",
|
||||
id: null,
|
||||
state: "failed" 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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,102 +1,11 @@
|
||||
import type { IncomeAnalytics, IncomeAnalyticsMeta } from "@/entities/income";
|
||||
|
||||
import { formatDate } from "@/shared/lib";
|
||||
|
||||
import type { IncomePeriod } from "./period";
|
||||
|
||||
type ComparisonNotice
|
||||
= | "comparisonFailed"
|
||||
| "comparisonPending"
|
||||
| "previousUnavailable"
|
||||
| "yearUnavailable";
|
||||
import type { IncomeAnalytics } from "@/entities/income";
|
||||
|
||||
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] }[];
|
||||
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.sync.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(
|
||||
analytics: IncomeAnalytics,
|
||||
meta?: IncomeAnalyticsMeta,
|
||||
): IncomeDisplayModel {
|
||||
const entries: IncomeDisplayModel["entries"] = [];
|
||||
const occurrences = new Map<string, number>();
|
||||
@@ -116,17 +25,7 @@ function toIncomeDisplayModel(
|
||||
});
|
||||
}
|
||||
|
||||
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"),
|
||||
};
|
||||
return { entries };
|
||||
}
|
||||
|
||||
export { type IncomeDisplayModel, toIncomeDisplayModel };
|
||||
|
||||
@@ -22,123 +22,6 @@
|
||||
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) {
|
||||
.fields {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
|
||||
@@ -72,23 +72,9 @@ const analytics
|
||||
},
|
||||
],
|
||||
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")],
|
||||
yearComparison: null,
|
||||
} 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] {
|
||||
return {
|
||||
accruedInterest: null,
|
||||
@@ -205,9 +191,10 @@ describe("AccountIncomePage", () => {
|
||||
resource(
|
||||
null,
|
||||
{
|
||||
sync: {
|
||||
errorCode: "TBANK_UNAVAILABLE",
|
||||
id: null,
|
||||
lastAttempt: {
|
||||
finishedAt: "2026-08-14T00:01:00Z",
|
||||
problems: [],
|
||||
startedAt: "2026-08-14T00:00:00Z",
|
||||
state: "failed",
|
||||
},
|
||||
},
|
||||
@@ -327,6 +314,24 @@ describe("AccountIncomePage", () => {
|
||||
).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 () => {
|
||||
const response
|
||||
= {
|
||||
@@ -371,19 +376,11 @@ describe("AccountIncomePage", () => {
|
||||
expect(within(panel).getByText("−2 ₽")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders realized text and comparisons without subscribing to events", async () => {
|
||||
it("renders realized income without comparison blocks or event requests", async () => {
|
||||
const eventsRequest = vi.fn(() => new HttpResponse(null, { status: 503 }));
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||
HttpResponse.json(
|
||||
resource({
|
||||
...analytics,
|
||||
yearComparison: {
|
||||
period: { from: "2025-08-01", to: "2025-08-27" },
|
||||
totalsByCurrency: [comparison("RUB")],
|
||||
},
|
||||
}),
|
||||
),
|
||||
HttpResponse.json(resource(analytics)),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/events", eventsRequest),
|
||||
);
|
||||
@@ -398,72 +395,9 @@ describe("AccountIncomePage", () => {
|
||||
expect(screen.queryByText("Динамика")).not.toBeInTheDocument();
|
||||
expect(eventsRequest).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("region", { name: "Выплаты" })).toBeInTheDocument();
|
||||
const year = screen.getByRole("region", { name: "Годом ранее" });
|
||||
|
||||
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();
|
||||
expect(screen.queryByText("Предыдущий период")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Годом ранее")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Сравнение с/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a quiet realized error", async () => {
|
||||
@@ -747,119 +681,3 @@ describe("income period and snapshot boundary", () => {
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"shows unavailable comparisons for absent roles without positional fallback (legacy=%s)",
|
||||
async (legacy) => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||
HttpResponse.json(
|
||||
|
||||
resource(
|
||||
{ ...analytics, previous: null, yearComparison: null },
|
||||
{
|
||||
dependencies: legacy
|
||||
? [
|
||||
resource(null).meta,
|
||||
resource(null).meta,
|
||||
resource(null).meta,
|
||||
]
|
||||
: [],
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
renderPage();
|
||||
expect(
|
||||
await screen.findByText("Сравнение с предыдущим периодом недоступно."),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getByText("Сравнение с годом ранее недоступно."),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.queryByText("Период сравнения ещё не загружен полностью."),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
it("shows selected income before comparison coverage without inventing a zero baseline", async () => {
|
||||
const dependencies = (["year", "selected", "previous"] as const).map(
|
||||
|
||||
role =>
|
||||
({
|
||||
...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,
|
||||
role: "year" as const,
|
||||
sync: {
|
||||
errorCode: "TBANK_UNAVAILABLE",
|
||||
id: null,
|
||||
state: "failed" 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();
|
||||
});
|
||||
|
||||
@@ -79,7 +79,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
|
||||
const availability = useInitialAvailability({
|
||||
hasContent: !!analytics.data,
|
||||
identity: `${accountId}:${period.from}:${period.to}`,
|
||||
isError: analytics.isError || analytics.meta?.sync.state === "failed",
|
||||
isError: analytics.isError || analytics.meta?.lastAttempt.state === "failed",
|
||||
isFetching: analytics.isFetching,
|
||||
});
|
||||
|
||||
@@ -91,7 +91,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
|
||||
? { period, state: "error" }
|
||||
: { state: "loading" };
|
||||
|
||||
const display = toIncomeDisplayModel(analytics.data, analytics.meta);
|
||||
const display = toIncomeDisplayModel(analytics.data);
|
||||
|
||||
const onSubmit = (values: IncomeForm) => {
|
||||
const from = parseDraftDate(values.from);
|
||||
@@ -114,7 +114,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
|
||||
formRevision: `${accountId}:${period.from}:${period.to}:${resetRevision}`,
|
||||
onReset,
|
||||
onSubmit,
|
||||
partial: analytics.meta?.availability !== "complete",
|
||||
partial: analytics.meta?.completeness === "incomplete",
|
||||
state: "loaded",
|
||||
};
|
||||
};
|
||||
|
||||
-13
@@ -12,7 +12,6 @@ import { useTranslation } from "react-i18next";
|
||||
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
|
||||
|
||||
import classes from "../AccountIncomePage.module.css";
|
||||
import { IncomeComparisons } from "./IncomeComparisons.view";
|
||||
import { IncomePayments } from "./IncomePayments.view";
|
||||
import { IncomePeriodForm } from "./IncomePeriodForm/IncomePeriodForm.view";
|
||||
import { IncomeSummary } from "./IncomeSummary.view";
|
||||
@@ -54,18 +53,6 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
|
||||
|
||||
<Alert color="blue">{t("income.taxBoundary")}</Alert>
|
||||
|
||||
<IncomeComparisons
|
||||
comparison={display.previousComparison}
|
||||
notice={t(`income.${display.previousNotice}`)}
|
||||
title={t("income.previousPeriod")}
|
||||
/>
|
||||
|
||||
<IncomeComparisons
|
||||
comparison={display.comparison}
|
||||
notice={t(`income.${display.yearNotice}`)}
|
||||
title={t("income.yearAgo")}
|
||||
/>
|
||||
|
||||
<IncomePayments entries={display.entries} />
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
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
-10
@@ -1,6 +1,6 @@
|
||||
import type { FC, SubmitEventHandler } from "react";
|
||||
|
||||
import { Button, Paper, Stack, Text } from "@mantine/core";
|
||||
import { Button, Stack } from "@mantine/core";
|
||||
import { DatePickerInput } from "@mantine/dates";
|
||||
|
||||
import classes from "../../AccountIncomePage.module.css";
|
||||
@@ -27,15 +27,8 @@ const IncomePeriodForm: FC<IncomePeriodFormProps> = (props) => {
|
||||
} = useIncomePeriodFormVm();
|
||||
|
||||
return (
|
||||
<Paper
|
||||
component="form"
|
||||
onSubmit={props.onSubmit}
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
>
|
||||
<form onSubmit={props.onSubmit}>
|
||||
<Stack gap="sm">
|
||||
<Text fw={700}>{t("income.period")}</Text>
|
||||
|
||||
<div className={classes.fields}>
|
||||
<DatePickerInput
|
||||
@@ -71,7 +64,7 @@ const IncomePeriodForm: FC<IncomePeriodFormProps> = (props) => {
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+19
-4
@@ -492,6 +492,20 @@ describe("AccountOperationsPage", () => {
|
||||
}),
|
||||
).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 () => {
|
||||
@@ -507,9 +521,10 @@ it("keeps UTC operation boundaries in GET and source sync requests", async () =>
|
||||
resource(
|
||||
null,
|
||||
{
|
||||
sync: {
|
||||
errorCode: "TBANK_UNAVAILABLE",
|
||||
id: null,
|
||||
lastAttempt: {
|
||||
finishedAt: "2026-08-14T00:01:00Z",
|
||||
problems: [],
|
||||
startedAt: "2026-08-14T00:00:00Z",
|
||||
state: "failed",
|
||||
},
|
||||
},
|
||||
@@ -543,7 +558,7 @@ it("does not label incomplete empty operations as a confirmed empty period", asy
|
||||
items: [],
|
||||
nextCursor: null,
|
||||
},
|
||||
{ availability: "partial" },
|
||||
{ completeness: "incomplete" },
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
|
||||
import type { InvestmentResourceMeta } from "@/shared/api";
|
||||
|
||||
import {
|
||||
type BrokerageOperation,
|
||||
brokerageOperationsQueryOptions,
|
||||
@@ -39,6 +41,7 @@ type AccountOperationsPageViewModel
|
||||
onPrevious: () => void;
|
||||
onReset: () => void;
|
||||
partial?: boolean;
|
||||
source?: InvestmentResourceMeta;
|
||||
state: "loaded";
|
||||
}
|
||||
| { onReset: () => void; state: "error" }
|
||||
@@ -98,7 +101,7 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
|
||||
const availability = useInitialAvailability({
|
||||
hasContent: !!operations.data,
|
||||
identity: scope,
|
||||
isError: operations.isError || operations.meta?.sync.state === "failed",
|
||||
isError: operations.isError || operations.meta?.lastAttempt.state === "failed",
|
||||
isFetching: operations.isFetching,
|
||||
});
|
||||
|
||||
@@ -127,7 +130,7 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
|
||||
|
||||
if (
|
||||
(isInvalidCursor || !operations.data)
|
||||
&& (operations.isError || operations.meta?.sync.state === "failed")
|
||||
&& (operations.isError || operations.meta?.lastAttempt.state === "failed")
|
||||
)
|
||||
return { onReset, state: "error" };
|
||||
|
||||
@@ -177,7 +180,8 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
|
||||
onPeriodPopoverOpen: () => setMaxDate(moscowDate(new Date())),
|
||||
onPrevious: history.onPrevious,
|
||||
onReset,
|
||||
partial: operations.meta?.availability !== "complete",
|
||||
partial: operations.meta?.completeness === "incomplete",
|
||||
source: operations.meta,
|
||||
state: "loaded",
|
||||
};
|
||||
};
|
||||
|
||||
+7
@@ -13,6 +13,7 @@ import { StateView } from "@wealthledger/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { LedgerEntryRow, operationCategories } from "@/entities/broker-operation";
|
||||
import { SourceSyncStatus } from "@/shared/ui";
|
||||
|
||||
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
|
||||
|
||||
@@ -62,6 +63,12 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
|
||||
<Stack gap="lg">
|
||||
<Title order={1}>{t("ledger.title")}</Title>
|
||||
|
||||
<SourceSyncStatus
|
||||
lastAttempt={props.source?.lastAttempt}
|
||||
lastSuccessfulAt={props.source?.lastSuccessfulAt}
|
||||
stale={props.source?.freshness === "stale"}
|
||||
/>
|
||||
|
||||
{!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>}
|
||||
|
||||
{!!props.historyLoading && <Alert>{t("investment.history")}</Alert>}
|
||||
|
||||
@@ -46,9 +46,10 @@ type PreviewResource<T> = {
|
||||
isError: boolean;
|
||||
meta?: Pick<InvestmentResourceMeta,
|
||||
"availability"
|
||||
| "completeness"
|
||||
| "freshness"
|
||||
| "lastAttempt"
|
||||
| "lastSuccessfulAt"
|
||||
| "sync"
|
||||
>;
|
||||
};
|
||||
|
||||
@@ -57,10 +58,10 @@ function toIncomeSummary(
|
||||
income: PreviewResource<IncomeSummary>,
|
||||
): AccountPageIncomeSummary {
|
||||
const to = `/accounts/${accountId}/income`;
|
||||
if (!income.data && !income.isError && income.meta?.sync.state !== "failed")
|
||||
if (!income.data && !income.isError && income.meta?.lastAttempt.state !== "failed")
|
||||
return { state: "loading", to };
|
||||
if (income.meta?.availability === "partial") return { state: "partial", to };
|
||||
if (!income.data && (income.isError || income.meta?.sync.state === "failed"))
|
||||
if (income.meta?.completeness === "incomplete") return { state: "partial", to };
|
||||
if (!income.data && (income.isError || income.meta?.lastAttempt.state === "failed"))
|
||||
return { state: "error", to };
|
||||
if (!income.data?.totalsByCurrency.length) return { state: "empty", to };
|
||||
|
||||
@@ -76,15 +77,15 @@ function toRecentEvents(
|
||||
events: PreviewResource<{ events: BrokerageEvent[] }>,
|
||||
): AccountPageRecentEvents {
|
||||
const to = `/accounts/${accountId}/events`;
|
||||
if (!events.data && !events.isError && events.meta?.sync.state !== "failed")
|
||||
if (!events.data && !events.isError && events.meta?.lastAttempt.state !== "failed")
|
||||
return { state: "loading", to };
|
||||
if (events.meta?.availability === "partial")
|
||||
if (events.meta?.completeness === "incomplete")
|
||||
return {
|
||||
items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [],
|
||||
state: "partial",
|
||||
to,
|
||||
};
|
||||
if (!events.data && (events.isError || events.meta?.sync.state === "failed"))
|
||||
if (!events.data && (events.isError || events.meta?.lastAttempt.state === "failed"))
|
||||
return { state: "error", to };
|
||||
const items = events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [];
|
||||
if (items.length === 0) return { state: "empty", to };
|
||||
@@ -104,14 +105,14 @@ function toRecentOperations(
|
||||
if (
|
||||
!operations.data
|
||||
&& !operations.isError
|
||||
&& operations.meta?.sync.state !== "failed"
|
||||
&& operations.meta?.lastAttempt.state !== "failed"
|
||||
)
|
||||
return { state: "loading", to };
|
||||
if (operations.meta?.availability === "partial")
|
||||
if (operations.meta?.completeness === "incomplete")
|
||||
return { items: operations.data?.items ?? [], state: "partial", to };
|
||||
if (
|
||||
!operations.data
|
||||
&& (operations.isError || operations.meta?.sync.state === "failed")
|
||||
&& (operations.isError || operations.meta?.lastAttempt.state === "failed")
|
||||
)
|
||||
return { state: "error", to };
|
||||
if (!operations.data?.items.length) return { state: "empty", to };
|
||||
@@ -120,7 +121,7 @@ function toRecentOperations(
|
||||
items: operations.data.items,
|
||||
lastSuccessfulAt: operations.meta?.lastSuccessfulAt,
|
||||
refreshError:
|
||||
operations.isError || operations.meta?.sync.state === "failed",
|
||||
operations.isError || operations.meta?.lastAttempt.state === "failed",
|
||||
stale: operations.meta?.freshness === "stale",
|
||||
state: "loaded",
|
||||
to,
|
||||
|
||||
@@ -402,6 +402,50 @@ describe("AccountPage states", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps a null portfolio envelope loading instead of an empty portfolio", async () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
HttpResponse.json(resource(null)),
|
||||
),
|
||||
);
|
||||
const queryClient = createTestQueryClient();
|
||||
renderAccountRoute(queryClient);
|
||||
|
||||
expect(
|
||||
await screen.findByRole("status", { name: "Загрузка" }, { timeout: 4000 }),
|
||||
).toBeVisible();
|
||||
expect(screen.queryByText("Портфель пока пуст")).not.toBeInTheDocument();
|
||||
expect(
|
||||
queryClient.getQueryData(portfolioQueryOptions("account-1").queryKey),
|
||||
).toMatchObject({ data: null });
|
||||
});
|
||||
|
||||
it("keeps null events and income envelopes loading in the previews", async () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
|
||||
HttpResponse.json(resource(null)),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/income-summary", () =>
|
||||
HttpResponse.json(resource(null)),
|
||||
),
|
||||
);
|
||||
renderAccountRoute(createTestQueryClient());
|
||||
|
||||
expect(
|
||||
await screen.findByText("Портфель пока пуст", undefined, {
|
||||
timeout: 4000,
|
||||
}),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole("status", { name: "Загрузка будущих событий" }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.queryByText("На ближайшие 30 дней событий не найдено."),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Загрузка дохода текущего месяца")).toBeVisible();
|
||||
expect(screen.queryByText("Нет текущих сумм")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a stale portfolio in upstream order with nullable values hidden", () => {
|
||||
renderPage(<AccountPageLoaded {...loadedProps()} />);
|
||||
|
||||
@@ -686,7 +730,7 @@ describe("AccountPage states", () => {
|
||||
).toBeVisible();
|
||||
for (const amount of ["850,50 ₽", "0 USD", "1 000 ₽", "Сумма неизвестна"])
|
||||
expect(content.getByText(amount)).toBeVisible();
|
||||
expect(content.getAllByText("Ожидаемая сумма")).toHaveLength(4);
|
||||
expect(content.getAllByText("Прогноз")).toHaveLength(4);
|
||||
expect(content.queryByText("Без движения денег")).not.toBeInTheDocument();
|
||||
expect(content.queryByText(/^\+/)).not.toBeInTheDocument();
|
||||
expect(
|
||||
|
||||
@@ -173,14 +173,14 @@ it("keeps overview sections independent when their source data is incomplete", a
|
||||
mockLoadedAccountPage();
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
HttpResponse.json(resource(portfolio, { availability: "partial" })),
|
||||
HttpResponse.json(resource(portfolio, { completeness: "incomplete" })),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
|
||||
HttpResponse.json(
|
||||
|
||||
resource(
|
||||
{ hasNext: false, items: [], nextCursor: null },
|
||||
{ availability: "partial" },
|
||||
{ completeness: "incomplete" },
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -188,7 +188,7 @@ it("keeps overview sections independent when their source data is incomplete", a
|
||||
HttpResponse.json(
|
||||
resource(
|
||||
{ events: [], incomeTotalsByCurrency: [] },
|
||||
{ availability: "partial" },
|
||||
{ completeness: "incomplete" },
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useParams } from "react-router";
|
||||
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
import type { InvestmentResourceMeta } from "@/shared/api";
|
||||
|
||||
import { useBrokerageOperations } from "@/entities/broker-operation";
|
||||
import {
|
||||
@@ -45,6 +46,7 @@ type AccountPageEmptyViewModel = AccountPageContent & {
|
||||
type AccountPageLoadedViewModel = AccountPageContent & {
|
||||
portfolio: Portfolio;
|
||||
positionSections: AccountPagePositionSection[];
|
||||
source?: InvestmentResourceMeta;
|
||||
state: "loaded";
|
||||
};
|
||||
|
||||
@@ -87,7 +89,7 @@ function useAccountPageViewModel(): AccountPageViewModel {
|
||||
const availability = useInitialAvailability({
|
||||
hasContent: !!portfolio.data,
|
||||
identity: accountId ?? "account",
|
||||
isError: portfolio.isError || portfolio.meta?.sync.state === "failed",
|
||||
isError: portfolio.isError || portfolio.meta?.lastAttempt.state === "failed",
|
||||
isFetching: portfolio.isFetching,
|
||||
});
|
||||
|
||||
@@ -105,7 +107,7 @@ function useAccountPageViewModel(): AccountPageViewModel {
|
||||
const incomeSummary = toIncomeSummary(accountId, income);
|
||||
|
||||
if (
|
||||
portfolio.meta?.availability === "complete"
|
||||
portfolio.meta?.availability === "available"
|
||||
&& portfolio.data.positions.length === 0
|
||||
&& portfolio.data.cashBalances.length === 0
|
||||
)
|
||||
@@ -118,7 +120,7 @@ function useAccountPageViewModel(): AccountPageViewModel {
|
||||
state: "empty" as const,
|
||||
};
|
||||
|
||||
const isPartial = portfolio.meta?.availability === "partial";
|
||||
const isPartial = portfolio.meta?.completeness === "incomplete";
|
||||
|
||||
return {
|
||||
account,
|
||||
@@ -129,6 +131,7 @@ function useAccountPageViewModel(): AccountPageViewModel {
|
||||
positionSections: toAccountPagePositionSections(portfolio.data),
|
||||
recentEvents,
|
||||
recentOperations,
|
||||
source: portfolio.meta,
|
||||
state: "loaded" as const,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { BrokerageAccount } from "@/entities/brokerage-account";
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
import type { InvestmentResourceMeta } from "@/shared/api";
|
||||
|
||||
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
|
||||
import { SourceSyncStatus } from "@/shared/ui";
|
||||
import { PortfolioStructure } from "@/widgets/portfolio-structure";
|
||||
|
||||
import type { AccountPagePositionSection } from "../../../model/positions";
|
||||
@@ -32,6 +34,7 @@ type AccountPageLoadedProps = {
|
||||
positionSections: AccountPagePositionSection[];
|
||||
recentEvents: AccountPageRecentEvents;
|
||||
recentOperations: AccountPageRecentOperations;
|
||||
source?: InvestmentResourceMeta;
|
||||
};
|
||||
|
||||
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||
@@ -49,6 +52,12 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||
<Stack gap="lg">
|
||||
<AccountHeading account={props.account} accountId={props.accountId} />
|
||||
|
||||
<SourceSyncStatus
|
||||
lastAttempt={props.source?.lastAttempt}
|
||||
lastSuccessfulAt={props.source?.lastSuccessfulAt}
|
||||
stale={props.source?.freshness === "stale"}
|
||||
/>
|
||||
|
||||
{props.partial
|
||||
? <Alert role="status">{t("investment.partial")}</Alert>
|
||||
: <CurrencyCards amounts={props.portfolio.totalsByCurrency} />}
|
||||
|
||||
@@ -189,14 +189,19 @@ describe("AccountsPage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["idle", "failed"] as const)(
|
||||
"keeps partial empty membership distinct from no accounts (sync=%s)",
|
||||
it.each(["none", "failed"] as const)(
|
||||
"keeps incomplete empty membership distinct from no accounts (attempt=%s)",
|
||||
async (state) => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts", () =>
|
||||
HttpResponse.json(resource([], {
|
||||
availability: "partial",
|
||||
sync: { errorCode: null, id: null, state },
|
||||
completeness: "incomplete",
|
||||
lastAttempt: {
|
||||
finishedAt: state === "failed" ? "2026-08-14T00:01:00Z" : null,
|
||||
problems: [],
|
||||
startedAt: state === "none" ? null : "2026-08-14T00:00:00Z",
|
||||
state,
|
||||
},
|
||||
})),
|
||||
),
|
||||
);
|
||||
@@ -205,7 +210,7 @@ it.each(["idle", "failed"] as const)(
|
||||
await waitFor(() =>
|
||||
expect(client.getQueryData(brokerageAccountsQueryKey)).toMatchObject({
|
||||
data: [],
|
||||
meta: { availability: "partial" },
|
||||
meta: { completeness: "incomplete" },
|
||||
}),
|
||||
);
|
||||
expect(screen.queryByText("Нет инвестиционных счетов"))
|
||||
|
||||
@@ -15,7 +15,7 @@ const AccountsPage: FC = () => {
|
||||
return (
|
||||
<AccountsPageLoaded
|
||||
accounts={viewModel.accounts}
|
||||
sync={viewModel.sync}
|
||||
meta={viewModel.meta}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,17 +4,15 @@ import {
|
||||
useBrokerageAccounts,
|
||||
} from "@/entities/brokerage-account";
|
||||
import { useScreenSync } from "@/features/tbank-connection";
|
||||
import { ApiErrorCode, apiErrorCode, isApiErrorCode, type paths } from "@/shared/api";
|
||||
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
|
||||
import { useInitialAvailability } from "@/shared/lib";
|
||||
|
||||
type AccountsPageViewModel
|
||||
= | { accounts: BrokerageAccountCard[]; state: "loaded"; sync: AccountsSync }
|
||||
= | { accounts: BrokerageAccountCard[]; meta: NonNullable<ReturnType<typeof useBrokerageAccounts>["meta"]>; state: "loaded" }
|
||||
| { code?: string; state: "error" }
|
||||
| { state: "empty" }
|
||||
| { state: "loading" };
|
||||
|
||||
type AccountsSync = paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"]["sync"];
|
||||
|
||||
function isAccountsAccessFailure(code?: string) {
|
||||
return isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)
|
||||
|| isApiErrorCode(code, ApiErrorCode.TbankNotConnected);
|
||||
@@ -28,12 +26,13 @@ function useAccountsPageViewModel(): AccountsPageViewModel {
|
||||
const isAccessFailure = isAccountsAccessFailure(code);
|
||||
|
||||
const hasResult = !!accounts.data
|
||||
&& !(accounts.meta?.availability === "partial" && accounts.data.length === 0);
|
||||
&& !!accounts.meta
|
||||
&& !(accounts.meta.completeness === "incomplete" && accounts.data.length === 0);
|
||||
|
||||
const availability = useInitialAvailability({
|
||||
hasContent: hasResult,
|
||||
identity: "accounts",
|
||||
isError: accounts.isError || accounts.meta?.sync.state === "failed",
|
||||
isError: accounts.isError || accounts.meta?.lastAttempt.state === "failed",
|
||||
isFetching: accounts.isFetching,
|
||||
});
|
||||
|
||||
@@ -49,10 +48,13 @@ function useAccountsPageViewModel(): AccountsPageViewModel {
|
||||
state: "empty" as const,
|
||||
};
|
||||
|
||||
const meta = accounts.meta;
|
||||
if (!meta) return { state: "loading" as const };
|
||||
|
||||
return {
|
||||
accounts: brokerageAccountCards(accounts.data, accounts.meta?.dependencies),
|
||||
accounts: brokerageAccountCards(accounts.data, meta.dependencies),
|
||||
meta,
|
||||
state: "loaded" as const,
|
||||
sync: accounts.sync,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,45 +1,24 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { Badge, Button, Stack, Text, Title } from "@mantine/core";
|
||||
import { Button, Stack, Text, Title } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import type { InvestmentResourceMeta } from "@/shared/api";
|
||||
|
||||
import {
|
||||
type BrokerageAccountCard,
|
||||
BrokerageAccountList,
|
||||
} from "@/entities/brokerage-account";
|
||||
import { SourceSyncStatus } from "@/shared/ui";
|
||||
|
||||
type AccountsPageLoadedProps = {
|
||||
accounts: BrokerageAccountCard[];
|
||||
sync:
|
||||
| null
|
||||
| undefined
|
||||
| {
|
||||
finishedAt: null | string;
|
||||
sections: {
|
||||
accountId?: string;
|
||||
errorCode: null | string;
|
||||
section: string;
|
||||
}[];
|
||||
startedAt: string;
|
||||
state: string;
|
||||
};
|
||||
meta: InvestmentResourceMeta;
|
||||
};
|
||||
|
||||
const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const sync = props.sync;
|
||||
|
||||
const syncLabel = sync
|
||||
? new Intl.DateTimeFormat("ru-RU", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(sync.finishedAt ?? sync.startedAt))
|
||||
: null;
|
||||
|
||||
const syncColor = sync?.state === "failed" || sync?.state === "partial"
|
||||
? "red"
|
||||
: "gray";
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
@@ -50,20 +29,11 @@ const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
|
||||
{t("accounts.subtitle")}
|
||||
</Text>
|
||||
|
||||
{sync && (
|
||||
<Text c="dimmed" size="xs">
|
||||
Последний обход:
|
||||
{" "}
|
||||
{syncLabel}
|
||||
{" "}
|
||||
<Badge
|
||||
color={syncColor}
|
||||
size="xs"
|
||||
>
|
||||
{sync.state}
|
||||
</Badge>
|
||||
</Text>
|
||||
)}
|
||||
<SourceSyncStatus
|
||||
lastAttempt={props.meta.lastAttempt}
|
||||
lastSuccessfulAt={props.meta.lastSuccessfulAt}
|
||||
stale={props.meta.freshness === "stale"}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -88,7 +88,6 @@ const quote
|
||||
function setup(
|
||||
options: {
|
||||
ambiguousTicker?: boolean;
|
||||
failBond?: boolean;
|
||||
failCoupons?: boolean;
|
||||
noPosition?: boolean;
|
||||
path?: string;
|
||||
@@ -100,11 +99,7 @@ function setup(
|
||||
const resolved = { ...bond, slug: options.slug ?? uid };
|
||||
let isFailQuote = false;
|
||||
server.use(
|
||||
http.get("*/api/v1/bonds/:uid", () =>
|
||||
options.failBond
|
||||
? HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 })
|
||||
: HttpResponse.json(resource(resolved)),
|
||||
),
|
||||
http.get("*/api/v1/bonds/:uid", () => HttpResponse.json(resource(resolved))),
|
||||
http.get("*/api/v1/bonds/by-ticker/:ticker", ({ request }) => {
|
||||
requests.push(new URL(request.url));
|
||||
|
||||
|
||||
@@ -191,8 +191,10 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
|
||||
|
||||
<BondCoupons
|
||||
coupons={vm.coupons}
|
||||
logoName={bond.logoName}
|
||||
pastCoupons={vm.pastCoupons}
|
||||
state={vm.couponsState}
|
||||
ticker={bond.ticker}
|
||||
upcoming={vm.upcoming}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { Accordion, Alert, Badge, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
import { Accordion, Alert, Badge, Group, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { BondCoupons as Coupons } from "@/entities/bond";
|
||||
|
||||
import { InstrumentMetric, InstrumentSection, type SectionState } from "@/features/instrument-details";
|
||||
import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib";
|
||||
import { InstrumentSection, type SectionState } from "@/features/instrument-details";
|
||||
import { formatDate, formatSnapshotTime, instrumentLogoUrl } from "@/shared/lib";
|
||||
import { PayoutCard } from "@/shared/ui";
|
||||
|
||||
type BondCouponsProps = {
|
||||
coupons?: Coupons;
|
||||
logoName?: null | string;
|
||||
pastCoupons: Coupons["coupons"];
|
||||
state: SectionState;
|
||||
ticker?: null | string;
|
||||
upcoming: Coupons["coupons"];
|
||||
};
|
||||
|
||||
@@ -39,6 +42,8 @@ const BondCoupons: FC<BondCouponsProps> = (props) => {
|
||||
<Coupon
|
||||
coupon={coupon}
|
||||
key={`${coupon.number}:${coupon.paymentDate}`}
|
||||
logoName={props.logoName}
|
||||
ticker={props.ticker}
|
||||
/>
|
||||
))
|
||||
)
|
||||
@@ -75,6 +80,8 @@ const BondCoupons: FC<BondCouponsProps> = (props) => {
|
||||
<Coupon
|
||||
coupon={coupon}
|
||||
key={`${coupon.number}:${coupon.paymentDate}`}
|
||||
logoName={props.logoName}
|
||||
ticker={props.ticker}
|
||||
/>
|
||||
))
|
||||
)
|
||||
@@ -91,59 +98,48 @@ const BondCoupons: FC<BondCouponsProps> = (props) => {
|
||||
|
||||
type CouponProps = {
|
||||
coupon: Coupons["coupons"][number];
|
||||
logoName?: null | string;
|
||||
ticker?: null | string;
|
||||
};
|
||||
|
||||
const Coupon: FC<CouponProps> = (props) => {
|
||||
const { coupon } = props;
|
||||
const { t } = useTranslation();
|
||||
const title = `${t("events.type.coupon")} · ${props.ticker || t("bond.instrument")}`;
|
||||
|
||||
const metadata = [
|
||||
coupon.paymentDate ? formatDate(coupon.paymentDate) : t("stock.dateUnknown"),
|
||||
t("bond.couponNumber", { number: coupon.number ?? t("bond.unknown") }),
|
||||
].join(" · ");
|
||||
|
||||
const conditions = (
|
||||
<Group gap="md" mt="sm">
|
||||
<Badge color="gray" variant="light">
|
||||
{t(`bond.couponTypes.${coupon.couponType}`, { defaultValue: t("bond.unknown") })}
|
||||
</Badge>
|
||||
|
||||
{coupon.periodDays != undefined && (
|
||||
<Text c="dimmed" size="xs">{t("bond.couponDays", { count: coupon.periodDays })}</Text>
|
||||
)}
|
||||
|
||||
{coupon.recordDate && (
|
||||
<Text c="dimmed" size="xs">{t("stock.record")}: {formatDate(coupon.recordDate)}</Text>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper p="md" radius="md" withBorder>
|
||||
<Group align="start" justify="space-between">
|
||||
<div>
|
||||
<Text fw={600}>
|
||||
{coupon.paymentDate
|
||||
? formatDate(coupon.paymentDate)
|
||||
: t("stock.dateUnknown")}
|
||||
</Text>
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.couponNumber", {
|
||||
number: coupon.number ?? t("bond.unknown"),
|
||||
})}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<InstrumentMetric label={t("bond.perBond")} value={formatMoney(coupon.perBond)} />
|
||||
|
||||
{!!coupon.estimatedAmount && (
|
||||
<InstrumentMetric
|
||||
label={t("stock.yourEstimate")}
|
||||
value={formatMoney(coupon.estimatedAmount)}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Group gap="md" mt="sm">
|
||||
<Badge color="gray" variant="light">
|
||||
{t(`bond.couponTypes.${coupon.couponType}`, {
|
||||
defaultValue: t("bond.unknown"),
|
||||
})}
|
||||
</Badge>
|
||||
|
||||
{coupon.periodDays != undefined && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.couponDays", { count: coupon.periodDays })}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!!coupon.recordDate && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.record")}: {formatDate(coupon.recordDate)}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
<PayoutCard
|
||||
conditions={conditions}
|
||||
estimate={coupon.estimatedAmount}
|
||||
imageSrc={instrumentLogoUrl(props.logoName)}
|
||||
initial={(props.ticker?.trim()[0] ?? "?").toUpperCase()}
|
||||
kind="schedule"
|
||||
metadata={metadata}
|
||||
perSecurity={coupon.perBond}
|
||||
perSecurityLabel={t("bond.perBond")}
|
||||
title={title}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ function useBondsPageVm() {
|
||||
code: apiErrorCode(result.error),
|
||||
error:
|
||||
!result.data
|
||||
&& (result.isError || result.meta?.sync.state === "failed"),
|
||||
&& (result.isError || result.meta?.lastAttempt.state === "failed"),
|
||||
items:
|
||||
result.data?.map(bond => ({
|
||||
...bond,
|
||||
@@ -29,9 +29,9 @@ function useBondsPageVm() {
|
||||
&& (isWaiting
|
||||
|| (!result.data
|
||||
&& !result.isError
|
||||
&& result.meta?.sync.state !== "failed")),
|
||||
&& result.meta?.lastAttempt.state !== "failed")),
|
||||
onQueryChange: search.onQueryChange,
|
||||
partial: result.meta?.availability === "partial",
|
||||
partial: result.meta?.completeness === "incomplete",
|
||||
query,
|
||||
ready:
|
||||
debounced.length >= MIN_BOND_SEARCH_LENGTH
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import {
|
||||
BankFeedback,
|
||||
bankOperationAppearance,
|
||||
} from "@/entities/bank-card";
|
||||
|
||||
@@ -108,6 +109,17 @@ const CardHistory: FC<CardHistoryProps> = (props) => {
|
||||
return <Text>{t("bank.noOperations")}</Text>;
|
||||
}
|
||||
|
||||
if (vm.history.error) {
|
||||
return (
|
||||
<BankFeedback
|
||||
cached={Boolean(vm.rows)}
|
||||
error={vm.history.error}
|
||||
loadingError={!vm.rows}
|
||||
retry={vm.history.refetch}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (vm.history.loading) {
|
||||
return (
|
||||
<LoadingSkeleton label={t("bank.loadingHistory")}>
|
||||
|
||||
@@ -316,12 +316,47 @@ describe("CardPage", () => {
|
||||
setup({ summaryError: true });
|
||||
await screen.findByRole("heading", { name: "Повседневная" });
|
||||
expect(
|
||||
screen.queryByRole("alert"),
|
||||
).not.toBeInTheDocument();
|
||||
await screen.findByText("Перевод на «Запасная»"),
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByRole("alert", {}, { timeout: 5000 })).toHaveTextContent(
|
||||
"Не удалось загрузить данные карт.",
|
||||
);
|
||||
expect(screen.queryByText("0 ₽")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("recovers summary with retry while keeping card and journal", async () => {
|
||||
const { summaryRequests } = setup({ summaryError: true });
|
||||
await screen.findByRole("heading", { name: "Повседневная" });
|
||||
expect(
|
||||
await screen.findByText("Перевод на «Запасная»"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText("0 ₽")).not.toBeInTheDocument();
|
||||
|
||||
const retry = await screen.findByRole(
|
||||
"button",
|
||||
{ name: "Повторить" },
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
|
||||
const requestsBefore = summaryRequests.length;
|
||||
server.use(
|
||||
http.get("*/api/v1/bank-cards/summary", ({ request }) => {
|
||||
summaryRequests.push(new URL(request.url).search);
|
||||
|
||||
return HttpResponse.json({
|
||||
cardId: "a",
|
||||
categories: [],
|
||||
expenses: money("0"),
|
||||
net: money("-50"),
|
||||
refunds: money("50"),
|
||||
});
|
||||
}),
|
||||
);
|
||||
fireEvent.click(retry);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(summaryRequests).toHaveLength(requestsBefore + 1);
|
||||
});
|
||||
it("keeps cached card and history after refetch failure", async () => {
|
||||
const { client } = setup();
|
||||
@@ -331,9 +366,8 @@ describe("CardPage", () => {
|
||||
http.get("*/api/v1/bank-cards/a/operations", () => HttpResponse.error()),
|
||||
);
|
||||
await client.invalidateQueries();
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
const alerts = await screen.findAllByRole("alert", {}, { timeout: 5000 });
|
||||
expect(alerts.length).toBeGreaterThanOrEqual(1);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Повседневная" }),
|
||||
).toBeInTheDocument();
|
||||
@@ -401,11 +435,11 @@ describe("CardPage", () => {
|
||||
]);
|
||||
});
|
||||
it("recovers the prerequisite card list while preserving the visible balance and journal", async () => {
|
||||
setup({ cardsError: true });
|
||||
const { client } = setup({ cardsError: true });
|
||||
await screen.findByText("Перевод на «Запасная»");
|
||||
expect(
|
||||
screen.queryByRole("alert"),
|
||||
).not.toBeInTheDocument();
|
||||
await screen.findByRole("alert", {}, { timeout: 5000 }),
|
||||
).toHaveTextContent("Не удалось загрузить данные карт.");
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Добавить операцию" }),
|
||||
).toBeDisabled();
|
||||
@@ -414,6 +448,7 @@ describe("CardPage", () => {
|
||||
HttpResponse.json([card, { ...card, id: "b", name: "Запасная" }]),
|
||||
),
|
||||
);
|
||||
await client.invalidateQueries();
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Добавить операцию" }),
|
||||
|
||||
@@ -75,6 +75,11 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
|
||||
{t("bank.back")}
|
||||
</Button>
|
||||
|
||||
{!vm.notFound && (
|
||||
<Button onClick={vm.cardRetry} variant="default">
|
||||
{t("bank.retry")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
error
|
||||
@@ -128,18 +133,27 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
|
||||
)}
|
||||
|
||||
{card.archivedAt && <Text>{t("bank.archiveHint")}</Text>}
|
||||
|
||||
{vm.cardError && vm.card && (
|
||||
<BankFeedback
|
||||
cached
|
||||
error={vm.cardError}
|
||||
retry={vm.cardRetry}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{vm.cardsFeedback && (
|
||||
<BankFeedback
|
||||
{...vm.cardsFeedback}
|
||||
loadingError
|
||||
retry={vm.cardsRetry}
|
||||
/>
|
||||
)}
|
||||
|
||||
<BankPeriodPicker onChange={vm.setPeriod} period={vm.period} />
|
||||
|
||||
<BankSummaryView {...vm.summary} />
|
||||
<BankSummaryView {...vm.summary} retry={vm.summary.refetch} />
|
||||
|
||||
<Group align="end" justify="space-between">
|
||||
<Group gap="xs">
|
||||
@@ -183,8 +197,6 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
|
||||
<CardHistory history={vm.history} period={vm.period} rows={vm.rows} />
|
||||
</Stack>
|
||||
|
||||
{vm.history.error && !vm.rows && <Text role="alert">{t("bank.error")}</Text>}
|
||||
|
||||
{vm.history.page !== undefined && vm.pageSize !== "all" && (
|
||||
<Group
|
||||
aria-label={t("bank.pagination")}
|
||||
|
||||
@@ -97,13 +97,16 @@ function useCardPageVm() {
|
||||
archivePending: updateCard.isPending,
|
||||
canAddOperation: !card.data?.archivedAt && Boolean(cards.data),
|
||||
card: card.data,
|
||||
cardError: card.error,
|
||||
cardLoading: card.isLoading,
|
||||
cardRefreshError: card.isError,
|
||||
cardRefreshing: card.isFetching,
|
||||
cardRetry: () => card.refetch(),
|
||||
cards: cards.data,
|
||||
cardsFeedback: cards.isError
|
||||
? { cached: Boolean(cards.data), error: cards.error }
|
||||
: undefined,
|
||||
cardsRetry: () => cards.refetch(),
|
||||
closeDeletion: () => setDeleting(null),
|
||||
closeEditing: () => setEditingCard(false),
|
||||
closeOperation: () => setOperation(null),
|
||||
@@ -112,9 +115,10 @@ function useCardPageVm() {
|
||||
editingCard,
|
||||
history: {
|
||||
changingPage: history.isPlaceholderData,
|
||||
error: history.isError,
|
||||
error: history.error,
|
||||
loading: history.isLoading,
|
||||
page: history.data?.page,
|
||||
refetch: () => history.refetch(),
|
||||
},
|
||||
notFound: isApiErrorCode(
|
||||
apiErrorCode(card.error),
|
||||
@@ -139,9 +143,10 @@ function useCardPageVm() {
|
||||
},
|
||||
summary: {
|
||||
data: summary.data,
|
||||
error: summary.isError,
|
||||
error: summary.error,
|
||||
fetching: summary.isFetching,
|
||||
loading: summary.isLoading,
|
||||
refetch: () => summary.refetch(),
|
||||
},
|
||||
toggleArchive: () => {
|
||||
if (!card.data) return;
|
||||
|
||||
@@ -47,8 +47,9 @@ function renderCards(isFail = false, hasCategoryFailure = false) {
|
||||
),
|
||||
);
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
const client = createTestQueryClient();
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<MantineProvider env="test">
|
||||
<MemoryRouter initialEntries={["/cards?month=2026-09"]}>
|
||||
<CardsPage />
|
||||
@@ -56,6 +57,8 @@ function renderCards(isFail = false, hasCategoryFailure = false) {
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
describe("CardsPage", () => {
|
||||
@@ -232,4 +235,76 @@ describe("CardsPage", () => {
|
||||
});
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a cold cards error with retry", async () => {
|
||||
renderCards(true);
|
||||
|
||||
expect(
|
||||
await screen.findByRole("alert", {}, { timeout: 5000 }),
|
||||
).toHaveTextContent("Не удалось загрузить данные карт.");
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Повторить" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
server.use(
|
||||
http.get("*/api/v1/bank-cards", () =>
|
||||
HttpResponse.json([
|
||||
{
|
||||
archivedAt: null,
|
||||
balance: money("10000"),
|
||||
bankName: null,
|
||||
id: "a",
|
||||
lastFour: null,
|
||||
name: "Повседневная",
|
||||
openingBalance: money("10000"),
|
||||
openingDate: "2026-01-01",
|
||||
version: 1,
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("link", { name: /Повседневная/ }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a cached cards error while keeping the grid", async () => {
|
||||
const view = renderCards();
|
||||
|
||||
await screen.findByRole("heading", { name: "Добавьте первую карту" });
|
||||
server.use(
|
||||
http.get("*/api/v1/bank-cards", () =>
|
||||
HttpResponse.json([
|
||||
{
|
||||
archivedAt: null,
|
||||
balance: money("10000"),
|
||||
bankName: null,
|
||||
id: "a",
|
||||
lastFour: null,
|
||||
name: "Повседневная",
|
||||
openingBalance: money("10000"),
|
||||
openingDate: "2026-01-01",
|
||||
version: 1,
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
await view.invalidateQueries();
|
||||
await screen.findByRole("link", { name: /Повседневная/ });
|
||||
|
||||
server.use(http.get("*/api/v1/bank-cards", () => HttpResponse.error()));
|
||||
await view.invalidateQueries();
|
||||
|
||||
expect(
|
||||
await screen.findByRole("alert", {}, { timeout: 5000 }),
|
||||
).toHaveTextContent("Не удалось обновить данные. Показаны последние полученные.");
|
||||
expect(
|
||||
screen.getByRole("link", { name: /Повседневная/ }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Link } from "react-router";
|
||||
|
||||
import {
|
||||
BankCardFace,
|
||||
BankFeedback,
|
||||
BankPeriodPicker,
|
||||
BankSummaryView,
|
||||
} from "@/entities/bank-card";
|
||||
@@ -51,15 +52,17 @@ const CardsPageViewCards: FC<CardsPageViewProps> = (props) => {
|
||||
);
|
||||
}
|
||||
|
||||
const text = t(vm.archive ? "bank.emptyHint" : "bank.noActive");
|
||||
if (!vm.cards.error) {
|
||||
const text = t(vm.archive ? "bank.emptyHint" : "bank.noActive");
|
||||
|
||||
return (
|
||||
<StateView
|
||||
action={<Button onClick={vm.onAddCard}>{t("bank.addCard")}</Button>}
|
||||
text={text}
|
||||
title={t("bank.empty")}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<StateView
|
||||
action={<Button onClick={vm.onAddCard}>{t("bank.addCard")}</Button>}
|
||||
text={text}
|
||||
title={t("bank.empty")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (vm.cards.isLoading) {
|
||||
@@ -103,6 +106,15 @@ const CardsPageView: FC<CardsPageViewProps> = (props) => {
|
||||
onChange={vm.onArchiveChange}
|
||||
/>
|
||||
|
||||
{vm.cards.error && (
|
||||
<BankFeedback
|
||||
cached={Boolean(vm.cards.data)}
|
||||
error={vm.cards.error}
|
||||
loadingError={!vm.cards.data}
|
||||
retry={vm.retryCards}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CardsPageViewCards {...props} />
|
||||
|
||||
<Group align="flex-end" justify="space-between">
|
||||
@@ -123,9 +135,10 @@ const CardsPageView: FC<CardsPageViewProps> = (props) => {
|
||||
<BankSummaryView
|
||||
all
|
||||
data={vm.summary.data}
|
||||
error={vm.summary.isError}
|
||||
error={vm.summary.error}
|
||||
fetching={vm.summary.isFetching}
|
||||
loading={vm.summary.isLoading}
|
||||
retry={vm.retrySummary}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -39,6 +39,8 @@ function useCardsPageVm() {
|
||||
setArchive(event.currentTarget.checked),
|
||||
onCategories: () => setManagingCategories(true),
|
||||
period,
|
||||
retryCards: () => cards.refetch(),
|
||||
retrySummary: () => summary.refetch(),
|
||||
setPeriod: (value: [null | string, null | string]) => {
|
||||
if (value[0] && value[1]) setParameters({ from: value[0], to: value[1] });
|
||||
},
|
||||
|
||||
@@ -337,7 +337,7 @@ describe("stock page", () => {
|
||||
"/stocks/SBERP?period=year&events=trades",
|
||||
),
|
||||
);
|
||||
expect(await screen.findByText("Зачисление дивидендов")).toBeVisible();
|
||||
expect(await screen.findByText("Дивиденд")).toBeVisible();
|
||||
});
|
||||
it("offers search for ambiguous tickers without requesting personal data", async () => {
|
||||
const { requests } = setup({ ambiguousTicker: true, path: "/stocks/SAME" });
|
||||
@@ -368,18 +368,18 @@ describe("stock page", () => {
|
||||
screen.getByText("К закрытию предыдущего торгового дня, 09.09.2026"),
|
||||
).toBeVisible();
|
||||
expect(await screen.findByText("Зачисление дивидендов")).toBeVisible();
|
||||
expect(screen.getByText("Событие компании")).toBeVisible();
|
||||
expect(screen.getAllByText("на акцию").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("328,8 ₽")).toBeVisible();
|
||||
expect(screen.getByText("P/E · TTM")).toBeVisible();
|
||||
expect(screen.getByText("Описание компании")).toBeVisible();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "О компании" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText("13.10.2026")).toBeVisible();
|
||||
expect(screen.getByText(/13\.10\.2026/)).toBeVisible();
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "История дивидендов компании" }),
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText("13.10.2025")).toBeVisible());
|
||||
await waitFor(() => expect(screen.getByText(/13\.10\.2025/)).toBeVisible());
|
||||
expect(screen.getByText("Текущая средняя покупка: 200 ₽")).toBeVisible();
|
||||
fireEvent.click(screen.getByRole("button", { name: /Цены в таблице/ }));
|
||||
const rows = within(await screen.findByRole("table")).getAllByRole("row");
|
||||
@@ -413,7 +413,9 @@ describe("stock page", () => {
|
||||
expect(await screen.findByText("Продажа")).toBeVisible();
|
||||
expect(screen.getByText("2 шт.")).toBeVisible();
|
||||
expect(screen.getByText("Цена: 100 ₽")).toBeVisible();
|
||||
expect(screen.getByText(/02.01.2022, 10:00 UTC/)).toBeVisible();
|
||||
const snapshotTime = new Intl.DateTimeFormat("ru-RU", { dateStyle: "short", timeStyle: "short" });
|
||||
const operationTime = snapshotTime.format(new Date("2022-01-02T10:00:00Z"));
|
||||
expect(screen.getByText(operationTime, { exact: false })).toBeVisible();
|
||||
expect(
|
||||
requests.some(
|
||||
url =>
|
||||
|
||||
@@ -63,8 +63,10 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
|
||||
|
||||
<StockDividends
|
||||
dividends={vm.dividends}
|
||||
logoName={stock.logoName}
|
||||
pastDividends={vm.pastDividends}
|
||||
state={vm.dividendsState}
|
||||
ticker={stock.ticker}
|
||||
upcoming={vm.upcoming}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,198 +1,96 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
Alert,
|
||||
Badge,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { Accordion, Alert, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { StockDividends as Dividends } from "@/entities/stock";
|
||||
|
||||
import { InstrumentSection, type SectionState } from "@/features/instrument-details";
|
||||
import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib";
|
||||
import { formatDate, formatSnapshotTime, instrumentLogoUrl } from "@/shared/lib";
|
||||
import { PayoutCard } from "@/shared/ui";
|
||||
|
||||
import classes from "../StockPage.module.css";
|
||||
type Dividend = Dividends["dividends"][number];
|
||||
|
||||
type DividendCardProps = {
|
||||
event: Dividend;
|
||||
logoName?: null | string;
|
||||
ticker?: null | string;
|
||||
};
|
||||
|
||||
type StockDividendsProps = {
|
||||
dividends?: Dividends;
|
||||
logoName?: null | string;
|
||||
pastDividends: Dividends["dividends"];
|
||||
state: SectionState;
|
||||
ticker?: null | string;
|
||||
upcoming: Dividends["dividends"];
|
||||
};
|
||||
|
||||
const DividendCard: FC<DividendCardProps> = (props) => {
|
||||
const { event } = props;
|
||||
const { t } = useTranslation();
|
||||
const title = `${t("events.type.dividend")} · ${props.ticker || t("stock.share")}`;
|
||||
|
||||
const metadata = [
|
||||
event.recordDate
|
||||
? `${t("stock.record")}: ${formatDate(event.recordDate)}`
|
||||
: t("stock.dateUnknown"),
|
||||
event.paymentDate ? `${t("stock.payment")}: ${formatDate(event.paymentDate)}` : null,
|
||||
].filter(Boolean).join(" · ");
|
||||
|
||||
const conditions = event.lastBuyDate
|
||||
? <Text c="dimmed" size="xs">{t("stock.lastBuy")}: {formatDate(event.lastBuyDate)}</Text>
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<PayoutCard
|
||||
conditions={conditions}
|
||||
estimate={event.estimatedAmount}
|
||||
imageSrc={instrumentLogoUrl(props.logoName)}
|
||||
initial={(props.ticker?.trim()[0] ?? "?").toUpperCase()}
|
||||
kind="schedule"
|
||||
metadata={metadata}
|
||||
perSecurity={event.perShare}
|
||||
perSecurityLabel={t("stock.perShare")}
|
||||
title={title}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const StockDividends: FC<StockDividendsProps> = (props) => {
|
||||
const { dividends, pastDividends, state, upcoming } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
let upcomingContent;
|
||||
|
||||
if (dividends) {
|
||||
if (upcoming.length > 0) {
|
||||
upcomingContent = (
|
||||
upcoming.map(event => (
|
||||
<div
|
||||
className={classes.dividend}
|
||||
key={JSON.stringify([
|
||||
event.recordDate,
|
||||
event.paymentDate,
|
||||
event.perShare,
|
||||
])}
|
||||
>
|
||||
<Group align="start" justify="space-between">
|
||||
<div>
|
||||
<Badge color="green" variant="light">
|
||||
{t("stock.companyEvent")}
|
||||
</Badge>
|
||||
|
||||
<Text fw={650} mt="xs" size="xl">
|
||||
{formatMoney(event.perShare)}
|
||||
{" "}
|
||||
<Text c="dimmed" component="span" size="sm">
|
||||
{t("stock.perShare")}
|
||||
</Text>
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{!!event.estimatedAmount && (
|
||||
<div>
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.yourEstimate")}
|
||||
</Text>
|
||||
|
||||
<Text fw={600}>
|
||||
{formatMoney(event.estimatedAmount)}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Group gap="xl" mt="md">
|
||||
{!!event.lastBuyDate && (
|
||||
<div>
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.lastBuy")}
|
||||
</Text>
|
||||
|
||||
<Text>{formatDate(event.lastBuyDate)}</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!!event.recordDate && (
|
||||
<div>
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.record")}
|
||||
</Text>
|
||||
|
||||
<Text>{formatDate(event.recordDate)}</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!!event.paymentDate && (
|
||||
<div>
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.payment")}
|
||||
</Text>
|
||||
|
||||
<Text>{formatDate(event.paymentDate)}</Text>
|
||||
</div>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
))
|
||||
);
|
||||
}
|
||||
else {
|
||||
upcomingContent = (
|
||||
<Text c="dimmed">
|
||||
{t("stock.noUpcoming", { to: formatDate(dividends.to) })}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let pastDividendsContent;
|
||||
|
||||
if (pastDividends.length > 0) {
|
||||
pastDividendsContent = (
|
||||
<Stack gap="sm">
|
||||
{pastDividends.map(event => (
|
||||
<Paper
|
||||
key={JSON.stringify([
|
||||
event.recordDate,
|
||||
event.paymentDate,
|
||||
event.perShare,
|
||||
])}
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
>
|
||||
<Group align="start" justify="space-between">
|
||||
<div>
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.record")}
|
||||
</Text>
|
||||
|
||||
<Text fw={600}>
|
||||
{event.recordDate
|
||||
? formatDate(event.recordDate)
|
||||
: t("stock.dateUnknown")}
|
||||
</Text>
|
||||
|
||||
{!!event.paymentDate && (
|
||||
<Text c="dimmed" mt="xs" size="xs">
|
||||
{t("stock.payment")}:{" "}
|
||||
{formatDate(event.paymentDate)}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text fw={650} size="lg">
|
||||
{formatMoney(event.perShare)}
|
||||
</Text>
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.perShare")}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
else {
|
||||
pastDividendsContent = <Text c="dimmed">{t("stock.noDividends")}</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<InstrumentSection state={state} title={t("stock.upcoming")}>
|
||||
{!!dividends && (
|
||||
<Stack gap="md">
|
||||
{!!(
|
||||
dividends.estimateUnavailable || dividends.positionIsStale
|
||||
) && (
|
||||
{(dividends.estimateUnavailable || dividends.positionIsStale) && (
|
||||
<Alert aria-live="polite" color="orange" role="status">
|
||||
{t("stock.estimateUnavailable")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{upcomingContent}
|
||||
{upcoming.length > 0
|
||||
? upcoming.map(event => (
|
||||
<DividendCard
|
||||
event={event}
|
||||
key={JSON.stringify([
|
||||
event.recordDate,
|
||||
event.paymentDate,
|
||||
event.perShare,
|
||||
])}
|
||||
logoName={props.logoName}
|
||||
ticker={props.ticker}
|
||||
/>
|
||||
))
|
||||
: <Text c="dimmed">{t("stock.noUpcoming", { to: formatDate(dividends.to) })}</Text>}
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.dividendNote")}
|
||||
</Text>
|
||||
<Text c="dimmed" size="xs">{t("stock.dividendNote")}</Text>
|
||||
|
||||
{!!dividends.positionAt && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.snapshotAt", {
|
||||
date: formatSnapshotTime(dividends.positionAt),
|
||||
})}
|
||||
{t("stock.snapshotAt", { date: formatSnapshotTime(dividends.positionAt) })}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -210,7 +108,24 @@ const StockDividends: FC<StockDividendsProps> = (props) => {
|
||||
})}
|
||||
</Text>
|
||||
|
||||
{pastDividendsContent}
|
||||
{pastDividends.length > 0
|
||||
? (
|
||||
<Stack gap="sm">
|
||||
{pastDividends.map(event => (
|
||||
<DividendCard
|
||||
event={event}
|
||||
key={JSON.stringify([
|
||||
event.recordDate,
|
||||
event.paymentDate,
|
||||
event.perShare,
|
||||
])}
|
||||
logoName={props.logoName}
|
||||
ticker={props.ticker}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)
|
||||
: <Text c="dimmed">{t("stock.noDividends")}</Text>}
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
|
||||
@@ -7,38 +7,69 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
|
||||
import { StocksPage } from "./StocksPage";
|
||||
import { useStocksPageVm } from "./StocksPage.vm";
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
const pageOperations: Promise<void>[] = [];
|
||||
|
||||
const StocksPageVmProbe = () => {
|
||||
const vm = useStocksPageVm();
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
pageOperations.push(vm.search());
|
||||
}}
|
||||
>
|
||||
search
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
pageOperations.push(vm.changePage(2));
|
||||
}}
|
||||
>
|
||||
page
|
||||
</button>
|
||||
<output>{vm.items.map(item => item.name).join(", ")}</output>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
pageOperations.length = 0;
|
||||
|
||||
return vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("stock search", () => {
|
||||
it("waits for a meaningful query and uses the server-verified ticker link", async () => {
|
||||
const query = vi.fn();
|
||||
server.use(
|
||||
http.get("*/api/v1/stocks", ({ request }) => {
|
||||
query(new URL(request.url).searchParams.get("query"));
|
||||
http.post("*/api/v1/stocks/screener/search", async ({ request }) => {
|
||||
query(await request.json());
|
||||
|
||||
return HttpResponse.json(
|
||||
resource([
|
||||
{
|
||||
apiTradeAvailable: true,
|
||||
classCode: "TQBR",
|
||||
instrumentUid: "unique-stock",
|
||||
isin: "RU0009029540",
|
||||
logoName: "sber.png",
|
||||
name: "Сбербанк",
|
||||
price: { currency: "RUB", value: "306.12" },
|
||||
priceAt: "2026-09-10T10:00:00Z",
|
||||
realExchange: "REAL_EXCHANGE_MOEX",
|
||||
shareType: "SHARE_TYPE_COMMON",
|
||||
slug: "SBER",
|
||||
ticker: "SBER",
|
||||
},
|
||||
]),
|
||||
);
|
||||
return HttpResponse.json({
|
||||
asOf: "2026-10-10T10:00:00Z",
|
||||
hasNext: false,
|
||||
items: [{
|
||||
countryOfRisk: "RU",
|
||||
currency: "RUB",
|
||||
instrumentUid: "e6123145-9665-43e0-8413-cd61b8aa9b13",
|
||||
isin: "RU0009029540",
|
||||
logoName: "sber.png",
|
||||
lot: 10,
|
||||
name: "Сбербанк",
|
||||
realExchange: "REAL_EXCHANGE_MOEX",
|
||||
sector: "financial",
|
||||
shareType: "SHARE_TYPE_COMMON",
|
||||
ticker: "SBER",
|
||||
}],
|
||||
page: 1,
|
||||
searchId: "search-id",
|
||||
});
|
||||
}),
|
||||
);
|
||||
configureAuthClient({
|
||||
@@ -46,7 +77,6 @@ describe("stock search", () => {
|
||||
clear: vi.fn(),
|
||||
refresh: () => Promise.resolve(false),
|
||||
});
|
||||
vi.useFakeTimers();
|
||||
render(
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<MantineProvider>
|
||||
@@ -56,23 +86,217 @@ describe("stock search", () => {
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const input = screen.getByLabelText("Поиск акций");
|
||||
fireEvent.change(input, { target: { value: "S" } });
|
||||
await act(() => vi.advanceTimersByTimeAsync(300));
|
||||
expect(query).not.toHaveBeenCalled();
|
||||
fireEvent.change(input, { target: { value: "SB" } });
|
||||
await act(() => vi.advanceTimersByTimeAsync(299));
|
||||
expect(query).not.toHaveBeenCalled();
|
||||
// The final input uses real timers for network and query notifications.
|
||||
vi.useRealTimers();
|
||||
const input = screen.getByLabelText("Название или тикер");
|
||||
fireEvent.change(input, { target: { value: "SBER" } });
|
||||
expect(query).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Найти" }));
|
||||
expect(
|
||||
await screen.findByRole("link", { name: /Сбербанк/ }),
|
||||
).toHaveAttribute("href", "/stocks/SBER");
|
||||
expect(screen.getByText("306,12 ₽")).toBeVisible();
|
||||
expect(screen.getByText("Режим: TQBR")).toBeVisible();
|
||||
).toHaveAttribute("href", "/stocks/e6123145-9665-43e0-8413-cd61b8aa9b13");
|
||||
const snapshotTime = new Intl.DateTimeFormat("ru-RU", { dateStyle: "short", timeStyle: "short" });
|
||||
const fetchedAt = snapshotTime.format(new Date("2026-10-10T10:00:00Z"));
|
||||
expect(screen.getByText(`Список получен ${fetchedAt}`)).toBeVisible();
|
||||
expect(screen.getByText("Лот: 10 шт.")).toBeVisible();
|
||||
expect(screen.getByText("ISIN: RU0009029540")).toBeVisible();
|
||||
expect(screen.getByText("Мосбиржа")).toBeVisible();
|
||||
await waitFor(() => expect(query).toHaveBeenCalledExactlyOnceWith("SBER"));
|
||||
expect(screen.getAllByText("Мосбиржа")[0]).toBeVisible();
|
||||
expect(screen.getByAltText("")).toHaveAttribute(
|
||||
"src",
|
||||
"https://invest-brands.cdn-tinkoff.ru/sberx160.png",
|
||||
);
|
||||
await waitFor(() => expect(query).toHaveBeenCalledExactlyOnceWith({ query: "SBER" }));
|
||||
});
|
||||
|
||||
it("searches by the selected sector without a text query", async () => {
|
||||
const query = vi.fn();
|
||||
server.use(
|
||||
http.post("*/api/v1/stocks/screener/search", async ({ request }) => {
|
||||
query(await request.json());
|
||||
|
||||
return HttpResponse.json({
|
||||
asOf: "2026-10-10T10:00:00Z",
|
||||
hasNext: false,
|
||||
items: [],
|
||||
page: 1,
|
||||
searchId: "sector-search",
|
||||
});
|
||||
}),
|
||||
);
|
||||
configureAuthClient({
|
||||
accessToken: () => "synthetic",
|
||||
clear: vi.fn(),
|
||||
refresh: () => Promise.resolve(false),
|
||||
});
|
||||
render(
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<MantineProvider>
|
||||
<MemoryRouter>
|
||||
<StocksPage />
|
||||
</MemoryRouter>
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("combobox", { name: "Сектор" }));
|
||||
fireEvent.click(await screen.findByRole("option", { name: "Энергетика" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Найти" }));
|
||||
|
||||
await waitFor(() => expect(query).toHaveBeenCalledExactlyOnceWith({
|
||||
sector: "energy",
|
||||
}));
|
||||
});
|
||||
|
||||
it("shows known result pages and reads the selected page", async () => {
|
||||
const requestedPages = vi.fn();
|
||||
server.use(
|
||||
http.post("*/api/v1/stocks/screener/search", () => HttpResponse.json({
|
||||
asOf: "2026-10-10T10:00:00Z",
|
||||
hasNext: true,
|
||||
items: [],
|
||||
page: 1,
|
||||
searchId: "paged-search",
|
||||
})),
|
||||
http.get("*/api/v1/stocks/screener/search/paged-search", ({ request }) => {
|
||||
const page = Number(new URL(request.url).searchParams.get("page"));
|
||||
requestedPages(page);
|
||||
|
||||
return HttpResponse.json({
|
||||
asOf: "2026-10-10T10:00:00Z",
|
||||
hasNext: false,
|
||||
items: [],
|
||||
page,
|
||||
searchId: "paged-search",
|
||||
});
|
||||
}),
|
||||
);
|
||||
configureAuthClient({
|
||||
accessToken: () => "synthetic",
|
||||
clear: vi.fn(),
|
||||
refresh: () => Promise.resolve(false),
|
||||
});
|
||||
render(
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<MantineProvider>
|
||||
<MemoryRouter>
|
||||
<StocksPage />
|
||||
</MemoryRouter>
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Найти" }));
|
||||
fireEvent.click(await screen.findByRole(
|
||||
"button",
|
||||
{ name: "Страница 2" },
|
||||
));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(requestedPages).toHaveBeenCalledExactlyOnceWith(2),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a newer search when an earlier page request finishes late", async () => {
|
||||
const oldPage = Promise.withResolvers<void>();
|
||||
let searches = 0;
|
||||
|
||||
server.use(
|
||||
http.post("*/api/v1/stocks/screener/search", () => {
|
||||
searches += 1;
|
||||
|
||||
return HttpResponse.json({
|
||||
asOf: "2026-10-10T10:00:00Z",
|
||||
hasNext: searches === 1,
|
||||
items: [{
|
||||
countryOfRisk: "RU",
|
||||
currency: "RUB",
|
||||
instrumentUid: searches === 1 ? "old" : "new",
|
||||
isin: null,
|
||||
logoName: null,
|
||||
lot: 1,
|
||||
name: searches === 1 ? "Старый" : "Новый",
|
||||
realExchange: "REAL_EXCHANGE_MOEX",
|
||||
sector: "financial",
|
||||
shareType: "SHARE_TYPE_COMMON",
|
||||
ticker: searches === 1 ? "OLD" : "NEW",
|
||||
}],
|
||||
page: 1,
|
||||
searchId: searches === 1 ? "old-search" : "new-search",
|
||||
});
|
||||
}),
|
||||
http.get("*/api/v1/stocks/screener/search/old-search", async () => {
|
||||
await oldPage.promise;
|
||||
|
||||
return HttpResponse.json({
|
||||
asOf: "2026-10-10T10:00:00Z",
|
||||
hasNext: false,
|
||||
items: [{
|
||||
countryOfRisk: "RU",
|
||||
currency: "RUB",
|
||||
instrumentUid: "old-page",
|
||||
isin: null,
|
||||
logoName: null,
|
||||
lot: 1,
|
||||
name: "Старый результат",
|
||||
realExchange: "REAL_EXCHANGE_MOEX",
|
||||
sector: "financial",
|
||||
shareType: "SHARE_TYPE_COMMON",
|
||||
ticker: "OLD",
|
||||
}],
|
||||
page: 2,
|
||||
searchId: "old-search",
|
||||
});
|
||||
}),
|
||||
);
|
||||
render(<StocksPageVmProbe />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "search" }));
|
||||
await screen.findByText("Старый");
|
||||
fireEvent.click(screen.getByRole("button", { name: "page" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "search" }));
|
||||
await screen.findByText("Новый");
|
||||
await act(async () => {
|
||||
oldPage.resolve();
|
||||
await pageOperations[0];
|
||||
});
|
||||
|
||||
expect(screen.getByText("Новый")).toBeVisible();
|
||||
expect(screen.queryByText("Старый результат")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables filters and shows loading while searching", async () => {
|
||||
const { promise, resolve } = Promise.withResolvers<void>();
|
||||
server.use(
|
||||
http.post("*/api/v1/stocks/screener/search", async () => {
|
||||
await promise;
|
||||
|
||||
return HttpResponse.json({
|
||||
asOf: "2026-10-10T10:00:00Z",
|
||||
hasNext: false,
|
||||
items: [],
|
||||
page: 1,
|
||||
searchId: "pending-search",
|
||||
});
|
||||
}),
|
||||
);
|
||||
configureAuthClient({
|
||||
accessToken: () => "synthetic",
|
||||
clear: vi.fn(),
|
||||
refresh: () => Promise.resolve(false),
|
||||
});
|
||||
render(
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<MantineProvider>
|
||||
<MemoryRouter>
|
||||
<StocksPage />
|
||||
</MemoryRouter>
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Найти" }));
|
||||
|
||||
await waitFor(() => expect(
|
||||
screen.getByRole("combobox", { name: "Сектор" }),
|
||||
).toBeDisabled());
|
||||
expect(screen.getByRole("button", { name: "Найти" })).toHaveAttribute(
|
||||
"data-loading",
|
||||
"true",
|
||||
);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,89 +1,274 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { Badge, Group, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ChangeEvent, FC } from "react";
|
||||
|
||||
import {
|
||||
InstrumentSearch,
|
||||
InstrumentSearchCard,
|
||||
} from "@/features/instrument-search";
|
||||
import { formatMoney, formatSnapshotTime } from "@/shared/lib";
|
||||
Alert,
|
||||
Anchor,
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Fieldset,
|
||||
Group,
|
||||
NumberInput,
|
||||
Pagination,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import type { StocksPageVm } from "./StocksPage.vm";
|
||||
import { formatSnapshotTime, instrumentLogoUrl } from "@/shared/lib";
|
||||
|
||||
import type { Criteria, StocksPageVm } from "./StocksPage.vm";
|
||||
|
||||
import { useStocksPagePresentationVm } from "./StocksPagePresentation.vm";
|
||||
|
||||
type CriteriaField = {
|
||||
label: keyof Criteria;
|
||||
options?: readonly string[];
|
||||
type?: "number";
|
||||
};
|
||||
|
||||
type StockCardProps = { item: StocksPageVm["items"][number] };
|
||||
|
||||
type StockCriteriaFieldProps = {
|
||||
criteria: Criteria;
|
||||
field: CriteriaField;
|
||||
onChange: (name: keyof Criteria, value: string) => void;
|
||||
};
|
||||
|
||||
type StocksPageViewProps = { vm: StocksPageVm };
|
||||
|
||||
const criteriaFields: CriteriaField[] = [
|
||||
{ label: "query" },
|
||||
{
|
||||
label: "sector",
|
||||
options: [
|
||||
"consumer",
|
||||
"energy",
|
||||
"financial",
|
||||
"health_care",
|
||||
"industrials",
|
||||
"it",
|
||||
"materials",
|
||||
"real_estate",
|
||||
"telecom",
|
||||
"utilities",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "shareType",
|
||||
options: [
|
||||
"SHARE_TYPE_ADR",
|
||||
"SHARE_TYPE_COMMON",
|
||||
"SHARE_TYPE_GDR",
|
||||
"SHARE_TYPE_PREFERRED",
|
||||
],
|
||||
},
|
||||
{ label: "currency", options: ["RUB", "USD", "EUR", "CNY", "HKD", "TRY"] },
|
||||
{
|
||||
label: "realExchange",
|
||||
options: [
|
||||
"REAL_EXCHANGE_MOEX",
|
||||
"REAL_EXCHANGE_RTS",
|
||||
"REAL_EXCHANGE_OTC",
|
||||
"REAL_EXCHANGE_DEALER",
|
||||
],
|
||||
},
|
||||
{ label: "countryOfRisk", options: ["RU", "US", "CN", "KZ", "HK", "TR"] },
|
||||
{ label: "maxLot", type: "number" },
|
||||
];
|
||||
|
||||
function numberChange(
|
||||
field: keyof Criteria,
|
||||
onChange: StockCriteriaFieldProps["onChange"],
|
||||
) {
|
||||
return (value: number | string) => onChange(field, String(value));
|
||||
}
|
||||
|
||||
function selectChange(
|
||||
field: keyof Criteria,
|
||||
onChange: StockCriteriaFieldProps["onChange"],
|
||||
) {
|
||||
return (value: null | string) => onChange(field, value ?? "");
|
||||
}
|
||||
|
||||
function selectOptions(field: CriteriaField, translate: ReturnType<typeof useTranslation>["t"]) {
|
||||
return field.options?.map(value => ({
|
||||
label: translate(`stock.screener.options.${field.label}.${value}`, {
|
||||
defaultValue: value,
|
||||
}),
|
||||
value,
|
||||
}));
|
||||
}
|
||||
|
||||
function textChange(
|
||||
field: keyof Criteria,
|
||||
onChange: StockCriteriaFieldProps["onChange"],
|
||||
) {
|
||||
return (event: ChangeEvent<HTMLInputElement>) =>
|
||||
onChange(field, event.currentTarget.value);
|
||||
}
|
||||
|
||||
const StockCriteriaField: FC<StockCriteriaFieldProps> = (props) => {
|
||||
const { criteria, field, onChange } = props;
|
||||
const { t } = useTranslation();
|
||||
const options = selectOptions(field, t);
|
||||
if (field.type === "number") return (
|
||||
<NumberInput
|
||||
label={t(`stock.screener.${field.label}`)}
|
||||
min={1}
|
||||
onChange={numberChange(field.label, onChange)}
|
||||
value={criteria[field.label]}
|
||||
/>
|
||||
);
|
||||
|
||||
if (options) return (
|
||||
<Select
|
||||
clearable
|
||||
data={options}
|
||||
label={t(`stock.screener.${field.label}`)}
|
||||
onChange={selectChange(field.label, onChange)}
|
||||
value={criteria[field.label] || null}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
label={t(`stock.screener.${field.label}`)}
|
||||
onChange={textChange(field.label, onChange)}
|
||||
value={criteria[field.label]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const StockCard: FC<StockCardProps> = (props) => {
|
||||
const { item } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const exchange = item.realExchange
|
||||
? t(`stock.exchanges.${item.realExchange}`, {
|
||||
defaultValue: item.realExchange,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const lot = item.lot === null
|
||||
? undefined
|
||||
: t("stock.lot", { value: item.lot });
|
||||
|
||||
return (
|
||||
<Card p="md" withBorder>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Avatar alt="" radius="md" size={40} src={instrumentLogoUrl(item.logoName)}>
|
||||
{item.name[0]}
|
||||
</Avatar>
|
||||
|
||||
<Stack gap={2} miw={0}>
|
||||
<Anchor
|
||||
c="inherit"
|
||||
component={Link}
|
||||
fw={600}
|
||||
to={`/stocks/${item.instrumentUid}`}
|
||||
>
|
||||
{item.name}
|
||||
</Anchor>
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{item.ticker ?? "-"} · {item.shareType ?? "-"}
|
||||
</Text>
|
||||
|
||||
<Group gap="xs">
|
||||
{exchange && <Badge color="gray" variant="light">{exchange}</Badge>}
|
||||
|
||||
{item.currency && (
|
||||
<Badge color="gray" variant="light">{item.currency}</Badge>
|
||||
)}
|
||||
|
||||
{lot && <Text c="dimmed" size="xs">{lot}</Text>}
|
||||
</Group>
|
||||
|
||||
{item.isin && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.isin", { value: item.isin })}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const StocksPageView: FC<StocksPageViewProps> = (props) => {
|
||||
const { vm } = props;
|
||||
const { t } = useTranslation();
|
||||
const { getControlProps, getItemProps } = useStocksPagePresentationVm();
|
||||
|
||||
return (
|
||||
<InstrumentSearch kind="stock" vm={vm}>
|
||||
{vm.items.map((stock) => {
|
||||
const quote = (
|
||||
<>
|
||||
<Text fw={650} size="xl">
|
||||
{stock.price ? formatMoney(stock.price) : "—"}
|
||||
</Text>
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title order={1}>{t("stock.screenerTitle")}</Title>
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{stock.price && stock.priceAt
|
||||
? t("stock.priceAt", {
|
||||
date: formatSnapshotTime(stock.priceAt),
|
||||
})
|
||||
: t("stock.priceUnavailable")}
|
||||
</Text>
|
||||
<Text c="dimmed" mt="xs">{t("stock.screenerSubtitle")}</Text>
|
||||
</div>
|
||||
|
||||
{stock.apiTradeAvailable === true && (
|
||||
<Text c="green.8" mt="sm" size="xs">
|
||||
{t("stock.apiAvailable")}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
<Fieldset disabled={vm.loading} variant="unstyled">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }}>
|
||||
{criteriaFields.map(field => (
|
||||
<StockCriteriaField
|
||||
criteria={vm.criteria}
|
||||
field={field}
|
||||
key={field.label}
|
||||
onChange={vm.onCriteriaChange}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
return (
|
||||
<InstrumentSearchCard
|
||||
instrument={stock}
|
||||
key={stock.instrumentUid}
|
||||
quote={quote}
|
||||
>
|
||||
<Text c="dimmed" size="sm">
|
||||
{stock.ticker} ·{" "}
|
||||
{t(`stock.shareTypes.${stock.shareType}`, {
|
||||
defaultValue: t("stock.share"),
|
||||
})}
|
||||
</Text>
|
||||
<Group mt="lg">
|
||||
<Button loading={vm.loading} onClick={vm.onSearch}>
|
||||
{t("stock.screener.search")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Fieldset>
|
||||
|
||||
<Group gap="xs" mt="sm">
|
||||
<Badge color="gray" variant="light">
|
||||
{t(`stock.exchanges.${stock.realExchange}`, {
|
||||
defaultValue: t("stock.exchangeUnknown"),
|
||||
})}
|
||||
</Badge>
|
||||
{!!vm.error && (
|
||||
<Alert color="orange" role="status">{t("stock.screener.error")}</Alert>
|
||||
)}
|
||||
|
||||
{!!stock.classCode && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.classCode", { code: stock.classCode })}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
{vm.resultTime && (
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("stock.screener.asOf", {
|
||||
date: formatSnapshotTime(vm.resultTime),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!!stock.isin && (
|
||||
<Text c="dimmed" mt={4} size="xs">
|
||||
{t("stock.isin", { value: stock.isin })}
|
||||
</Text>
|
||||
)}
|
||||
{vm.searched && !vm.loading && !vm.error && vm.items.length === 0 && (
|
||||
<Text c="dimmed">{t("stock.screener.empty")}</Text>
|
||||
)}
|
||||
|
||||
{stock.apiTradeAvailable === false && (
|
||||
<Text c="dimmed" mt={4} size="xs">
|
||||
{t("stock.apiUnavailable")}
|
||||
</Text>
|
||||
)}
|
||||
</InstrumentSearchCard>
|
||||
);
|
||||
})}
|
||||
</InstrumentSearch>
|
||||
<Stack gap="sm">
|
||||
{vm.items.map(item => (
|
||||
<StockCard item={item} key={item.instrumentUid} />
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{vm.totalPages > 1 && (
|
||||
<Group aria-label={t("stock.screener.pagination")} component="nav" justify="center">
|
||||
<Pagination
|
||||
disabled={vm.loading}
|
||||
getControlProps={getControlProps}
|
||||
getItemProps={getItemProps}
|
||||
onChange={vm.onPage}
|
||||
total={vm.totalPages}
|
||||
value={vm.page ?? 1}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,43 +1,122 @@
|
||||
import { MIN_STOCK_SEARCH_LENGTH, useStockSearch } from "@/entities/stock";
|
||||
import { useInstrumentSearch } from "@/features/instrument-search";
|
||||
import { useScreenSync } from "@/features/tbank-connection";
|
||||
import { apiErrorCode } from "@/shared/api";
|
||||
import { instrumentLogoUrl } from "@/shared/lib";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { apiClient } from "@/shared/api";
|
||||
|
||||
type Criteria = {
|
||||
countryOfRisk: string;
|
||||
currency: string;
|
||||
maxLot: string;
|
||||
query: string;
|
||||
realExchange: string;
|
||||
sector: string;
|
||||
shareType: string;
|
||||
};
|
||||
|
||||
type ScreenerPage = Awaited<ReturnType<typeof readPage>>;
|
||||
|
||||
type StocksPageVm = ReturnType<typeof useStocksPageVm>;
|
||||
|
||||
const emptyCriteria: Criteria = {
|
||||
countryOfRisk: "",
|
||||
currency: "",
|
||||
maxLot: "",
|
||||
query: "",
|
||||
realExchange: "",
|
||||
sector: "",
|
||||
shareType: "",
|
||||
};
|
||||
|
||||
async function createSearch(criteria: Criteria) {
|
||||
const { data } = await apiClient.POST("/api/v1/stocks/screener/search", {
|
||||
body: {
|
||||
...(criteria.countryOfRisk && { countryOfRisk: criteria.countryOfRisk }),
|
||||
...(criteria.currency && { currency: criteria.currency }),
|
||||
...(criteria.maxLot && { maxLot: Number(criteria.maxLot) }),
|
||||
...(criteria.query && { query: criteria.query }),
|
||||
...(criteria.realExchange && { realExchange: criteria.realExchange }),
|
||||
...(criteria.sector && { sector: criteria.sector }),
|
||||
...(criteria.shareType && { shareType: criteria.shareType }),
|
||||
},
|
||||
});
|
||||
|
||||
if (!data) throw new Error("Stock screener search failed");
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async function readPage(searchId: string, page: number) {
|
||||
const { data } = await apiClient.GET(
|
||||
"/api/v1/stocks/screener/search/{searchId}",
|
||||
{ params: { path: { searchId }, query: { page } } },
|
||||
);
|
||||
|
||||
if (!data) throw new Error("Stock screener page failed");
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function useStocksPageVm() {
|
||||
const search = useInstrumentSearch(MIN_STOCK_SEARCH_LENGTH);
|
||||
const { canSearch, debounced, isWaiting, query } = search;
|
||||
const result = useStockSearch(debounced);
|
||||
useScreenSync({ query: debounced, screen: "stocks" }, [result]);
|
||||
const [criteria, setCriteria] = useState(emptyCriteria);
|
||||
const [page, setPage] = useState<null | ScreenerPage>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
const search = async () => {
|
||||
const currentRequestId = requestIdRef.current + 1;
|
||||
requestIdRef.current = currentRequestId;
|
||||
setPending(true);
|
||||
setError(false);
|
||||
|
||||
try {
|
||||
const result = await createSearch(criteria);
|
||||
if (requestIdRef.current === currentRequestId) setPage(result);
|
||||
}
|
||||
catch {
|
||||
if (requestIdRef.current === currentRequestId) setError(true);
|
||||
}
|
||||
finally {
|
||||
if (requestIdRef.current === currentRequestId) setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const changePage = async (nextPage: number) => {
|
||||
if (!page) return;
|
||||
|
||||
const currentRequestId = requestIdRef.current + 1;
|
||||
requestIdRef.current = currentRequestId;
|
||||
setPending(true);
|
||||
setError(false);
|
||||
|
||||
try {
|
||||
const result = await readPage(page.searchId, nextPage);
|
||||
if (requestIdRef.current === currentRequestId) setPage(result);
|
||||
}
|
||||
catch {
|
||||
if (requestIdRef.current === currentRequestId) setError(true);
|
||||
}
|
||||
finally {
|
||||
if (requestIdRef.current === currentRequestId) setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
canSearch,
|
||||
code: apiErrorCode(result.error),
|
||||
error:
|
||||
!result.data
|
||||
&& (result.isError || result.meta?.sync.state === "failed"),
|
||||
items:
|
||||
result.data?.map(stock => ({
|
||||
...stock,
|
||||
logoSrc: instrumentLogoUrl(stock.logoName),
|
||||
to: `/stocks/${encodeURIComponent(stock.slug)}`,
|
||||
})) ?? [],
|
||||
loading:
|
||||
canSearch
|
||||
&& (isWaiting
|
||||
|| (!result.data
|
||||
&& !result.isError
|
||||
&& result.meta?.sync.state !== "failed")),
|
||||
onQueryChange: search.onQueryChange,
|
||||
partial: result.meta?.availability === "partial",
|
||||
query,
|
||||
ready:
|
||||
debounced.length >= MIN_STOCK_SEARCH_LENGTH
|
||||
&& !isWaiting
|
||||
&& Boolean(result.data),
|
||||
changePage,
|
||||
criteria,
|
||||
error,
|
||||
items: page?.items ?? [],
|
||||
loading: pending,
|
||||
onCriteriaChange: (name: keyof Criteria, value: string) =>
|
||||
setCriteria(current => ({ ...current, [name]: value })),
|
||||
onPage: (nextPage: number) => void changePage(nextPage),
|
||||
onSearch: () => void search(),
|
||||
page: page?.page,
|
||||
resultTime: page?.asOf,
|
||||
// Test seam: settle the actual operation instead of the handler release.
|
||||
search,
|
||||
searched: page !== null,
|
||||
totalPages: page ? page.page + Number(page.hasNext) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export { type StocksPageVm, useStocksPageVm };
|
||||
export { type Criteria, type StocksPageVm, useStocksPageVm };
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Pagination } from "@mantine/core";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function useStocksPagePresentationVm() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getControlProps: ComponentProps<typeof Pagination>["getControlProps"] = control => ({
|
||||
"aria-label": t(control === "previous" ? "stock.previous" : "stock.next"),
|
||||
});
|
||||
|
||||
const getItemProps: ComponentProps<typeof Pagination>["getItemProps"] = page => ({
|
||||
"aria-label": t("stock.screener.page", { page }),
|
||||
});
|
||||
|
||||
return { getControlProps, getItemProps };
|
||||
}
|
||||
|
||||
export { useStocksPagePresentationVm };
|
||||
@@ -67,6 +67,25 @@ describe("apiClient authentication", () => {
|
||||
expect(clear).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps the session when refresh failed because the server was unreachable", async () => {
|
||||
const clear = vi.fn();
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts", () =>
|
||||
HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }),
|
||||
),
|
||||
);
|
||||
configureAuthClient({
|
||||
accessToken: () => "expired",
|
||||
clear,
|
||||
refresh: () => Promise.resolve(false),
|
||||
unavailable: () => true,
|
||||
});
|
||||
|
||||
await apiClient.GET("/api/v1/brokerage-accounts");
|
||||
|
||||
expect(clear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not attach Bearer auth to authentication endpoints", async () => {
|
||||
let authorization: null | string
|
||||
|
||||
@@ -281,6 +300,33 @@ describe("apiClient transient read recovery", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("does not retry a read refused with INVALID_TBANK_TOKEN", async () => {
|
||||
let requests = 0;
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts", () => {
|
||||
requests += 1;
|
||||
|
||||
return HttpResponse.json(
|
||||
{ code: "INVALID_TBANK_TOKEN", status: 400 },
|
||||
{ status: 400 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
configureAuthClient({
|
||||
accessToken: () => "token",
|
||||
clear: vi.fn(),
|
||||
refresh: () => Promise.resolve(false),
|
||||
});
|
||||
|
||||
const { error, response } = await apiClient.GET(
|
||||
"/api/v1/brokerage-accounts",
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(error).toMatchObject({ code: "INVALID_TBANK_TOKEN" });
|
||||
expect(requests).toBe(1);
|
||||
});
|
||||
|
||||
it("does not retry mutations", async () => {
|
||||
let requests = 0;
|
||||
server.use(
|
||||
|
||||
@@ -32,6 +32,10 @@ type AuthHandlers = {
|
||||
clear: () => void;
|
||||
generation?: () => number;
|
||||
refresh: () => Promise<boolean>;
|
||||
/**
|
||||
True after a refresh failed transiently (network/5xx): the session is not proven gone.
|
||||
*/
|
||||
unavailable?: () => boolean;
|
||||
};
|
||||
|
||||
type ReadAttempt = {
|
||||
@@ -100,7 +104,8 @@ async function authenticatedFetch(request: Request): Promise<Response> {
|
||||
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
|
||||
|
||||
if (!(await sessionState.handlers.refresh())) {
|
||||
if (isCurrent()) sessionState.handlers.clear();
|
||||
if (isCurrent() && !sessionState.handlers.unavailable?.())
|
||||
sessionState.handlers.clear();
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
Vendored
+606
-398
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user