39 Commits
Author SHA1 Message Date
ksv741 df86b8450f chore(release): 1.2.1
Frontend verify / commits (pull_request) Successful in 40s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Successful in 7m15s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 13s
Frontend verify / verify (push) Successful in 7m48s
Frontend image / publish (push) Successful in 9m17s
2026-10-24 19:15:04 +03:00
ksv741 b5ceb77208 chore(deps): update non-major dependencies #9
Frontend verify / commits (pull_request) Successful in 35s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 6m37s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Failing after 6m29s
2026-10-24 18:20:20 +03:00
codex-bot b009101b7b fix: show snapshot time in browser time zone (#101)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 13s
Frontend verify / verify (push) Failing after 6m33s
2026-10-24 11:04:50 +03:00
codex-bot 7e6fd95cfa chore: bump test stack and fix npm audit findings (#105)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Failing after 5m38s
2026-10-24 10:32:11 +03:00
codex-bot 2cd6b1e72d chore: bump Node to 24.22.1 (WealthLedger/backend#122)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 13s
Frontend verify / verify (push) Failing after 7m25s
2026-10-23 16:10:45 +03:00
codex-bot 3af9df0ae2 fix: keep session when refresh fails on network error or 5xx (WealthLedger/frontend#97)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 1m3s
Frontend verify / verify (push) Failing after 7m11s
2026-10-23 15:06:20 +03:00
codex-bot 7b6f34c776 chore: resolve dev dependency audit findings (WealthLedger/frontend#103)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Failing after 7m33s
2026-10-23 14:13:08 +03:00
ksv741 ed811e88fa chore(release): 1.2.0
Frontend verify / commits (pull_request) Successful in 39s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 6m48s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 6m45s
Frontend image / publish (push) Successful in 7m55s
2026-10-18 16:24:32 +03:00
codex-bot eefdde29f8 chore: adopt linear git model in frontend docs (#91)
Frontend verify / commits (push) Skipped
Frontend verify / commits (pull_request) Failing after 39s
Frontend verify / security (pull_request) Skipped
Frontend verify / verify (pull_request) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Failing after 7m24s
2026-10-17 14:25:51 +03:00
codex-bot 5134106696 test: cover non-retry of INVALID_TBANK_TOKEN reads (WealthLedger/backend#59) (#90)
Frontend verify / commits (push) Skipped
Frontend verify / commits (pull_request) Failing after 44s
Frontend verify / security (pull_request) Skipped
Frontend verify / verify (pull_request) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Failing after 7m45s
2026-10-16 23:23:12 +03:00
codex-bot ebe92684a7 fix: align password validation with backend ASCII policy (WealthLedger/backend#60) (#89)
Frontend verify / commits (push) Skipped
Frontend verify / commits (pull_request) Failing after 40s
Frontend verify / security (pull_request) Skipped
Frontend verify / verify (pull_request) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Failing after 7m35s
2026-10-16 10:00:54 +03:00
codex-bot 6f1226b668 Merge pull request 'chore: synchronize release 1.1.0 into develop (WealthLedger/main#30)' (#68) from main into develop
Frontend verify / commits (push) Skipped
Frontend verify / commits (pull_request) Failing after 35s
Frontend verify / security (pull_request) Skipped
Frontend verify / verify (pull_request) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Successful in 6m57s
2026-10-16 08:04:34 +03:00
codex-bot d409a297e5 ci: keep pre-commit proportionate and avoid tool downloads (WealthLedger/frontend#62) (#86)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Successful in 6m21s
Frontend verify / commits (pull_request) Failing after 34s
Frontend verify / security (pull_request) Skipped
Frontend verify / verify (pull_request) Skipped
2026-10-15 06:34:36 +03:00
codex-bot ed63adad94 fix: deliver screener OpenAPI constraints (WealthLedger/backend#70) (#85)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Successful in 6m25s
2026-10-15 06:15:53 +03:00
codex-bot bc28973d1a ci: recheck PR title on edited without the full pipeline (WealthLedger/frontend#64)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Successful in 8m12s
2026-10-14 22:46:51 +03:00
codex-bot 20618fb033 Merge pull request 'fix(events): seven-date default period and picker from today (WealthLedger/frontend#63)' (#83) from fix/64-upcoming-events-date-filter into develop
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 13s
Frontend verify / verify (push) Successful in 8m18s
2026-10-14 21:34:59 +03:00
ksv741 612ab62509 fix(events): seven-date default period and picker from today (WealthLedger/frontend#63)
Frontend verify / commits (pull_request) Successful in 50s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 7m7s
2026-10-14 21:22:09 +03:00
codex-bot f295dea6a6 fix: respect query eligibility in screen sync (WealthLedger/frontend#54)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 13s
Frontend verify / verify (push) Successful in 8m22s
2026-10-14 17:01:53 +03:00
codex-bot f3725c202b test: await client settlement in late-response cases (WealthLedger/frontend#61) (#81)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 7m35s
2026-10-14 14:13:41 +03:00
ksv741 edc7097bd7 Merge pull request 'fix(auth,bank): guard switch-auth nav and surface banking retry (#56, #57)' (#80) from feature/56-57-auth-pending-banking-feedback into develop
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 7m50s
Reviewed-on: #80
2026-10-14 12:01:28 +03:00
ksv741 233680448f fix(bank): break BankFeedback's shared/api edge for stories (#80)
Frontend verify / commits (pull_request) Successful in 42s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 6m36s
BankFeedback lived under entities/bank-card/ui but pulled apiErrorCode
from @/shared/api, which dragged the entire api layer (openapi-fetch,
openapi-react-query, @tanstack/react-query, client, problem-details,
resource-query) into any Storybook story that imported a sibling
view, breaking stories-must-not-reach-application-data.

Move the local 'extract code from unknown' helper into the entity
and consume it from BankFeedback. The shared helper stays in
shared/api for the rest of the app.
2026-10-14 11:25:20 +03:00
ksv741 e38ecd87d6 fix(auth,bank): guard switch-auth nav and surface banking retry
Frontend verify / commits (pull_request) Successful in 39s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Failing after 5m50s
AuthForm disables the switch-auth Link while submit is pending so pointer
click and Enter can no longer leave the auth route and lose the draft/error
context. The form restores a true Link after the request settles.

CardsPage/CardPage distinguish cold vs cached banking failures:
- cold card/section errors expose accessible alert + retry
- cached failures keep the previous data and offer a section GET retry
- summary-only/history-only outages do not hide successful sections
- notFound still skips the retry CTA

Tests cover the pending link guard, cold/cached cards error with retry,
summary failure with retry, and the prerequisite card list recovery.

Closes #56, #57
2026-10-14 10:43:34 +03:00
codex-bot e5f8bbe33c test: cover null events and income envelopes on account pages (#72) (#79)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 12s
Frontend verify / verify (push) Successful in 7m11s
2026-10-13 23:15:56 +03:00
codex-bot f7f53ff8e4 test: cover null resource envelopes for account pages (#70) (#71) (#78)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Successful in 7m21s
2026-10-13 22:53:28 +03:00
codex-bot df05c8c7db build: use native openapi-typescript --check (#60) (#77)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Successful in 7m33s
2026-10-13 21:32:33 +03:00
codex-bot e67c47b102 refactor: derive payment-system options from schema tuple (#58) (#75)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 12s
Frontend verify / verify (push) Successful in 7m56s
2026-10-13 21:05:07 +03:00
codex-bot 43b0f5574a feat: unify income payment cards and remove comparisons (WealthLedger/main#33) (#74)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 7m18s
feat: unify income payment cards and remove comparisons (WealthLedger/main#33)
2026-10-13 18:17:35 +03:00
ksv741 c7d8d2c8f9 ci: validate release PR commits independently (WealthLedger/main#30)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend image / publish (push) Successful in 6m45s
Frontend verify / verify (push) Successful in 6m7s
Frontend verify / commits (pull_request) Successful in 37s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m58s
2026-10-12 23:39:30 +03:00
ksv741 9d900b331a chore(release): 1.1.0
Frontend verify / commits (pull_request) Failing after 42s
Frontend verify / security (pull_request) Skipped
Frontend verify / verify (pull_request) Skipped
2026-10-12 23:19:12 +03:00
codex-bot b357743c10 Merge pull request 'chore: align frontend engineering workflow (WealthLedger/main#27)' (#66) from chore/27-engineering-workflow into develop
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 7m9s
2026-10-12 11:00:35 +03:00
ksv741 0d747cc93e docs: pin frontend workflow reference (WealthLedger/main#27)
Frontend verify / commits (pull_request) Successful in 39s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 6m7s
2026-10-12 10:22:13 +03:00
ksv741 c315f80b1b fix: align frontend workflow rules (WealthLedger/main#27)
Frontend verify / commits (pull_request) Successful in 48s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Canceled after 1m36s
2026-10-12 10:19:36 +03:00
ksv741 617edfd489 chore: align frontend engineering workflow (WealthLedger/main#27)
Frontend verify / commits (pull_request) Successful in 35s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Failing after 3m11s
2026-10-12 09:20:13 +03:00
codex-bot 9ad3245efe Merge pull request 'docs: clarify frontend verification policy' (#65) from codex/issue-24-process into develop
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 6m56s
2026-10-11 23:23:10 +03:00
ksv741 cac4351bc4 docs: clarify frontend verification policy
Frontend verify / commits (pull_request) Successful in 41s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Successful in 6m34s
2026-10-11 23:06:51 +03:00
codex-bot 17f5bae69b feat: add stock screener
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 12s
Frontend verify / verify (push) Successful in 6m39s
2026-10-11 11:50:39 +03:00
ksv741 c8821200ce ci: skip checks on PR metadata edits
Frontend verify / commits (pull_request) Successful in 34s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m37s
2026-10-11 11:36:51 +03:00
ksv741 cec9c6348a fix: preserve latest stock screener result
Frontend verify / commits (pull_request) Successful in 35s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m47s
2026-10-11 11:11:58 +03:00
ksv741 a84411df39 feat: add stock screener 2026-10-11 11:11:58 +03:00
92 changed files with 4210 additions and 2825 deletions
+7 -1
View File
@@ -30,7 +30,8 @@ backend checkout и не проверяют backend runtime OpenAPI; измен
shape/status/route сохраняет shape/status/route сохраняет
публичный контракт предоставленного snapshot. публичный контракт предоставленного snapshot.
Одна UI-модель не требует менять API. `openapi:generate` работает offline от snapshot; Одна UI-модель не требует менять API. `openapi:generate` работает offline от snapshot;
`openapi:check` генерирует во временный каталог и сравнивает, не меняя tracked files. `openapi:check` запускает native `--check` того же CLI и падает при устаревших
generated types, не меняя tracked files.
## Auth и invalidation ## Auth и invalidation
@@ -39,6 +40,11 @@ Authorization. Auth endpoints исключены из Bearer/retry middleware. R
координируется single-flight; после `401` с `UNAUTHORIZED` возможен один retry. координируется single-flight; после `401` с `UNAUTHORIZED` возможен один retry.
Не добавляй собственный refresh/retry loop в caller. Не добавляй собственный refresh/retry loop в caller.
Refresh, завершившийся `401`, очищает session. Сетевая ошибка и `5xx`
(например, деплой backend) не доказывают конец session: она сохраняется,
`unavailable` показывает retry-экран вместо login, а следующий `401` снова
запускает refresh. Retry/backoff внутри refresh не добавляется.
Cookie-writing requests упорядочены только в текущей вкладке. Cross-tab auth Cookie-writing requests упорядочены только в текущей вкладке. Cross-tab auth
synchronization не требуется; допустим reload/re-login. Web Locks и отдельные synchronization не требуется; допустим reload/re-login. Web Locks и отдельные
token stores не добавляются. token stores не добавляются.
+35 -15
View File
@@ -7,11 +7,19 @@
checkout. Внешний API задаётся предоставленным публичным контрактом; checkout. Внешний API задаётся предоставленным публичным контрактом;
исходники или инструкции его реализации не являются зависимостью разработки. исходники или инструкции его реализации не являются зависимостью разработки.
При нехватке требования сообщи, какое решение не определено, до его реализации. При нехватке требования сообщи, какое решение не определено, до его реализации.
При конфликте с общим шаблоном следуй местным правилам и явному запросу
пользователя. Выбирай реализацию самостоятельно, но изменение финансовой
логики, публичного контракта или утверждённой семантики возвращай владельцу
на согласование до зависимой работы.
Начни с результата и affected areas. До edits проследи flow/invariants, найди Начни с результата и affected areas. До edits проследи flow/invariants, найди
owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт. owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт.
Работай напрямую; OpenSpec/SDD lifecycle — только по явному запросу. Выбери lightweight или SDD по [Engineering Workflow](https://git.personal.ksv741.ru/WealthLedger/main/src/commit/03b3b59e6ff2ba391f9dfc42a039a96d0d84c1c9/.ai/conventions/engineering-workflow.md);
Перед Node-командами активируй местную `.nvmrc`: `nvm use --silent`. OpenSpec lifecycle начинай после явного решения Change Owner.
Перед Node-командами активируй местную `.nvmrc`: если `nvm` не загружен в
non-interactive shell, загрузи установленный `$HOME/.nvm/nvm.sh`; затем
выполни `nvm use --silent` из frontend. Если `nvm.sh` недоступен, остановись,
не используй системный Node.
Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы. Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы.
## Change scope ## Change scope
@@ -57,21 +65,33 @@ Peer-конфликт решай совместимыми версиями бе
## Проверки и завершение ## Проверки и завершение
Code/dependency change: `npm ci`, полный `npm run verify` и дополнительные | Когда | Что проверить |
risk checks из местной testing convention. Targeted tests ускоряют итерацию, | --- | --- |
но не заменяют финальный gate. `lint:fix`/format — edits, не proof. | Окружение новое, изменились manifest/lockfile или Node, установка сомнительна | `npm ci` до Node-проверок; одна правка кода переустановки не требует. |
Markdown-only: проверь новые/изменённые тексты, локальные ссылки/якоря, маршруты | Итерация | Адресные тесты, typecheck и lint по затронутому поведению и риску. При изменении поведения добавь/обнови содержательную регрессию, если существующая проверка не ловит её; для косметики не создавай искусственный тест. |
чтения и `git diff --check`; application build ради текста не нужен. | Итоговый code/dependency diff | Перед объявлением готовности и передачей к интеграции выполни один полный `npm run verify` на итоговых входах. Дополнительные risk checks из testing convention и явно утверждённые task checks обязательны; CI проверяет независимо. |
Повтори routing по staged/unstaged/new files. Запиши краткие команды, результаты | Только Markdown | Проверь тексты, ссылки/якоря, маршруты чтения и `git diff --check`; application build не нужен. При изменении verification-контура проверь его работу. |
и ограничения. Зелёный lint не заменяет semantic/security review. | 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 и релизы ## Git и релизы
Работа — в `codex/<task>` от актуального `develop`, затем PR в `develop`. Работа — в ветке `<type>/<issue-number>-<slug>` от актуального `develop`,
затем PR в `develop`; для cross-repo ссылки укажи owner/repo#number.
Сохраняй чужие изменения; перед commit просмотри staged paths и diff. Сохраняй чужие изменения; перед commit просмотри staged paths и diff.
Commits и PR titles — Conventional Commits; допустим `spec:`. Каждый commit Commits и PR titles — Conventional Commits с parser-recognized Issue reference (`fix: … (#12)`
содержит одну связанную задачу. При связанной issue добавь её номер в конец или `chore: … (WealthLedger/main#27)`); допустим `spec:`. Каждый commit
заголовка (`fix: … #12`); без issue номер не нужен. Root другого checkout содержит одну связанную задачу. Исключения release и правила ссылки — в
не управляет этим repo. Engineering Workflow из AGENTS. Root другого checkout не управляет этим repo.
Прямой push в `main`, force-push и перенос опубликованных тегов не входят в Прямой push в `main`, force-push и перенос опубликованных тегов не входят в
обычную работу. Перед release/hotfix прочитай местный README, раздел «Релизы». обычную работу. 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, раздел «Релизы».
+12
View File
@@ -0,0 +1,12 @@
---
name: Bug Report
about: Неверное поведение
---
## Проблема и шаги воспроизведения
## Факт / ожидание
## Acceptance Criteria
- [ ]
## Окружение / ссылки (если применимо)
+10
View File
@@ -0,0 +1,10 @@
---
name: Engineering Task
about: CI, тесты, документация, процесс и прочее
---
## Контекст и результат
## Scope / связанные Issues (если есть)
## Acceptance Criteria
- [ ]
+12
View File
@@ -0,0 +1,12 @@
---
name: Feature Request
about: Новое наблюдаемое поведение
---
## Зачем и какой результат
## Ожидаемое поведение
## Acceptance Criteria
- [ ]
## Зависимости / связанные Issues (если есть)
+12
View File
@@ -0,0 +1,12 @@
---
name: Refactoring / Technical Debt
about: Улучшение без нового поведения
---
## Проблема и результат
## Сохраняемые инварианты / границы
## Acceptance Criteria
- [ ]
## Зависимости / ссылки (если есть)
+11
View File
@@ -0,0 +1,11 @@
## Summary
## Linked Issue
Refs
## Changes
## Verification
- Команда / результат / ограничения:
## Breaking Changes (если есть)
+34
View File
@@ -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"
+6 -2
View File
@@ -4,7 +4,7 @@ on:
push: push:
branches: [develop, main] branches: [develop, main]
pull_request: pull_request:
types: [opened, synchronize, reopened, edited] types: [opened, synchronize, reopened]
schedule: schedule:
- cron: '17 5 * * *' - cron: '17 5 * * *'
@@ -34,7 +34,11 @@ jobs:
HEAD_SHA: ${{ github.event.pull_request.head.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: | run: |
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA" if [[ "$PR_TITLE" =~ ^chore\(release\):\ [0-9]+\.[0-9]+\.[0-9]+$ ]]; then
npm run commitlint -- --strict --from "$HEAD_SHA^" --to "$HEAD_SHA"
else
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
fi
security: security:
needs: commits needs: commits
+5 -2
View File
@@ -1,3 +1,6 @@
gitleaks git --pre-commit --staged --redact=100 --no-banner || exit $? gitleaks git --pre-commit --staged --redact=100 --no-banner || exit $?
npx lint-staged if [ ! -x node_modules/.bin/lint-staged ]; then
npm run deps echo "lint-staged is not installed; run npm ci" >&2
exit 1
fi
node_modules/.bin/lint-staged
+1 -1
View File
@@ -1 +1 @@
24.21.0 24.22.1
+3
View File
@@ -4,6 +4,9 @@
только его файлы и предоставленные публичные контракты. Перед работой прочитай только его файлы и предоставленные публичные контракты. Перед работой прочитай
[порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем [порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем
объединяй triggers задачи и путей; читай активированные темы до решения. объединяй triggers задачи и путей; читай активированные темы до решения.
До edits найди/создай подходящую Gitea Issue, проверь AC и ветку; чтение
допускается без Issue. Общий маршрут — [Engineering Workflow](https://git.personal.ksv741.ru/WealthLedger/main/src/commit/03b3b59e6ff2ba391f9dfc42a039a96d0d84c1c9/.ai/conventions/engineering-workflow.md)
(main#28; после merge — актуальная версия `develop`).
| Решение / сигнал | Читать | | Решение / сигнал | Читать |
| --- | --- | | --- | --- |
+80
View File
@@ -1,5 +1,85 @@
# Changelog # 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) ## [1.0.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v0.4.0...v1.0.0) (2026-10-07)
### ⚠ BREAKING CHANGES ### ⚠ BREAKING CHANGES
+2 -2
View File
@@ -1,4 +1,4 @@
FROM node:24.21.0-bookworm-slim AS dependencies FROM node:24.22.1-bookworm-slim AS dependencies
WORKDIR /app WORKDIR /app
ENV HUSKY=0 ENV HUSKY=0
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
@@ -19,7 +19,7 @@ COPY . .
ENV VITE_API_BASE_URL="" ENV VITE_API_BASE_URL=""
RUN npm run build RUN npm run build
FROM nginx:1.31.6-alpine AS production FROM nginx:1.31.8-alpine AS production
ARG VERSION ARG VERSION
ARG REVISION ARG REVISION
LABEL org.opencontainers.image.version=$VERSION \ LABEL org.opencontainers.image.version=$VERSION \
+49 -20
View File
@@ -19,8 +19,10 @@ npm run dev
Для commits установите [Gitleaks](https://github.com/gitleaks/gitleaks/releases/tag/v8.30.1) Для commits установите [Gitleaks](https://github.com/gitleaks/gitleaks/releases/tag/v8.30.1)
8.30.1 в `PATH` (macOS: `brew install gitleaks`). Pre-commit сначала проверяет 8.30.1 в `PATH` (macOS: `brew install gitleaks`). Pre-commit сначала проверяет
staged diff с полной редакцией секретов, затем запускает formatting и FSD checks. staged diff с полной редакцией секретов, затем запускает установленный
Находка или отсутствие Gitleaks блокирует commit. `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`. Проверка этого поведения: `node --test scripts/test-pre-commit.mjs`.
## Контейнерный image ## Контейнерный image
@@ -116,7 +118,8 @@ Chromium при ширинах 375 и 1280 px. Browser project не исполь
Единый `.gitea/workflows/verify.yml` запускает `commits → security → verify` Единый `.gitea/workflows/verify.yml` запускает `commits → security → verify`
последовательно на PR; на push в `develop`/`main` — `security → verify`. последовательно на PR; на push в `develop`/`main` — `security → verify`.
Рабочие ветки проверяются после открытия PR, без дублирующего push-запуска. Рабочие ветки проверяются после открытия PR, без дублирующего push-запуска.
Изменение заголовка PR тоже повторяет проверки Conventional Commits. Изменение заголовка PR запускает `.gitea/workflows/pr-title.yml` (то же имя workflow,
только job `commits`): статусы `security` и `verify` на HEAD не перезаписываются.
Новый запуск отменяет устаревший для того же PR/ветки и типа события Новый запуск отменяет устаревший для того же PR/ветки и типа события
(требуется Gitea 1.26+). Ежедневно в 05:17 UTC выполняется только `security`. (требуется Gitea 1.26+). Ежедневно в 05:17 UTC выполняется только `security`.
Job `security` проверяет полную доступную историю Gitleaks 8.30.1 и выполняет Job `security` проверяет полную доступную историю Gitleaks 8.30.1 и выполняет
@@ -203,7 +206,8 @@ Checkout frontend содержит все входы сборки и прове
локальную копию и не обращаются к исходникам или CI сервера. Доставка новой локальную копию и не обращаются к исходникам или CI сервера. Доставка новой
схемы — явное обновление snapshot с review и `npm run openapi:generate`. схемы — явное обновление snapshot с review и `npm run openapi:generate`.
Runtime URL API задаётся окружением, а не расположением другого репозитория. Runtime URL API задаётся окружением, а не расположением другого репозитория.
Разработка идёт напрямую по задаче пользователя; OpenSpec/SDD — по явному запросу. Разработка начинается с Issue; lightweight или SDD выбирается по критериям
Engineering Workflow из AGENTS. OpenSpec — после явного решения Change Owner.
Knip исключает `conventional-changelog-conventionalcommits`: release-it загружает Knip исключает `conventional-changelog-conventionalcommits`: release-it загружает
preset по имени из `.release-it.json`. Версия preset закреплена напрямую. preset по имени из `.release-it.json`. Версия preset закреплена напрямую.
@@ -211,36 +215,61 @@ preset по имени из `.release-it.json`. Версия preset закреп
## Релизы ## Релизы
Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые
релизы. Работа: `codex/<task>` от актуального `develop`, PR в `develop`. релизы. Обычная работа: issue-linked ветка от актуального `develop`, PR в `develop`;
Обычные PR допускают squash; release и обратная синхронизация сохраняют историю. общие правила — в Engineering Workflow по ссылке из AGENTS.
Commits и PR titles — Conventional Commits, включая `spec:`. Версии вручную не повышай. PR сливается squash или rebase-merge; merge-коммит не используется. `main`
обновляется только fast-forward из `develop`, поэтому история линейна:
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что ```text
`develop` содержит последний релиз. Создай `release/<name>` от проверенного <type>/<issue>-<slug> --squash--> develop --ff-only--> main --> tag vX.Y.Z
`develop`; `nvm use --silent`, `npm ci`. Hotfix начинается от `main` в ```
`release/hotfix-<name>` и проходит тот же процесс.
Инвариант: `origin/main` всегда предок `origin/develop`. Проверка:
```bash
git fetch -q origin
git merge-base --is-ancestor origin/main origin/develop && echo ok || echo BROKEN
```
Commits и PR titles — Conventional Commits со ссылкой на Issue (кроме release),
включая `spec:`. Версии вручную не повышай.
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Проверь
инвариант. Создай `release/X.Y.Z` от проверенного `develop`;
`nvm use --silent`, `npm ci`.
2. На чистой ветке выполни `npm run release:preview`, просмотри версию/changelog, 2. На чистой ветке выполни `npm run release:preview`, просмотри версию/changelog,
затем `npm run release:prepare`. Команда создаёт `chore(release): X.Y.Z`, затем `npm run release:prepare`. Команда создаёт `chore(release): X.Y.Z`,
обновляет manifest/lockfile/CHANGELOG; тег и push не выполняет. Не обходи обновляет manifest/lockfile/CHANGELOG; тег и push не выполняет. Не обходи
guards ветки, чистого дерева, отсутствующих тегов или уже подготовленной версии. guards ветки, чистого дерева, отсутствующих тегов или уже подготовленной версии.
3. Проверь diff и штатные gates этого репозитория. Отправь только release-ветку, 3. Проверь diff и штатные gates этого репозитория. Отправь только release-ветку,
открой PR в `main` с заголовком `chore(release): X.Y.Z`. После зелёных открой PR в **`develop`** с заголовком `chore(release): X.Y.Z`. После зелёных
`verify`, `security`, `commits` выполни fast-forward-only merge. Если `main` `verify`, `security`, `commits` выполни fast-forward-only merge. Если
изменился, согласуй историю и повтори проверки кандидата. `develop` изменился, обнови release-ветку и повтори проверки кандидата.
4. Получи удалённый `main`, сравни его HEAD с проверенным release SHA и версией 4. Открой PR `develop → main` с тем же заголовком и слей fast-forward-only.
5. Получи удалённый `main`, сравни его HEAD с проверенным release SHA и версией
manifest/lockfile. Создай аннотированный `vX.Y.Z` именно на этом SHA: manifest/lockfile. Создай аннотированный `vX.Y.Z` именно на этом SHA:
`git tag -a vX.Y.Z <release-sha> -m "Release X.Y.Z"`, затем отправь только `git tag -a vX.Y.Z <release-sha> -m "Release X.Y.Z"`, затем отправь только
`git push origin refs/tags/vX.Y.Z`. Не запускай release-it повторно на `main`. `git push origin refs/tags/vX.Y.Z`. Не запускай release-it повторно на `main`.
5. Верни release-коммит через PR `main → develop` без squash/rebase; проверь,
что `develop` содержит тег. Опубликованные теги не перемещай. При сбое после Обратная синхронизация не нужна: release-коммит попал в `develop` на шаге 3.
merge создай отсутствующий тег без нового bump; до merge исправь и проверь Опубликованные теги не перемещай. При сбое после merge создай отсутствующий тег
существующий кандидат без повторного повышения версии. без нового bump; до merge исправь и проверь существующий кандидат без
повторного повышения версии.
Freeze релиза не поддерживается: пока открыт релизный PR, `develop` не двигают.
Срочный fix — обычная ветка `fix/*` от `develop` с последующим релизом;
пути от `main` нет, он сделал бы `main` не-предком `develop`. Исключение
требует явного решения: `release/hotfix-<name>` от `main`, PR в `main`, тег,
затем merge `main → develop` для восстановления инварианта.
`feat:` повышает minor, `fix:`/`perf:` и прочие разрешённые типы — patch; `feat:` повышает minor, `fix:`/`perf:` и прочие разрешённые типы — patch;
`!`/`BREAKING CHANGE:` — major, включая версии до 1.0. `chore(release):` `!`/`BREAKING CHANGE:` — major, включая версии до 1.0. `chore(release):`
исключён из рекомендации и changelog. Конфигурация — `.release-it.json`. исключён из рекомендации и changelog. Конфигурация — `.release-it.json`.
В Gitea для `main`/`develop` должны быть запрещены прямой/force push и обход В Gitea для `main`/`develop` должны быть запрещены прямой/force push и обход
администратором, обязательны `verify`, `security`, `commits`; теги `v*` защищены. администратором, включена блокировка устаревшей ветки, обязательны `verify`,
`security`, `commits`; теги `v*` защищены. Линейность обеспечивают merge styles
репозитория: merge-коммиты и rebase `--no-ff` выключены, доступны squash,
rebase-merge и fast-forward-only; стиль по умолчанию — squash.
Workflow сам не включает эти настройки. Отдельное review approval для одного Workflow сам не включает эти настройки. Отдельное review approval для одного
владельца не требуется. Npm publishing, deploy и Gitea Releases не включены. владельца не требуется. Npm publishing, deploy и Gitea Releases не включены.
+6
View File
@@ -1,6 +1,12 @@
module.exports = { module.exports = {
defaultIgnores: false,
extends: ["@commitlint/config-conventional"], extends: ["@commitlint/config-conventional"],
ignores: [
message => /^chore\(release\): \d+\.\d+\.\d+$/.test(message.trim()),
message => /^Merge pull request ['"].+['"] \(#\d+\) from .+ into .+$/.test(message.trim()),
],
rules: { rules: {
"references-empty": [2, "never"],
"type-enum": [ "type-enum": [
2, 2,
"always", "always",
+1255 -1294
View File
File diff suppressed because it is too large Load Diff
+45 -40
View File
@@ -1,10 +1,10 @@
{ {
"name": "wealth-ledger-frontend", "name": "wealth-ledger-frontend",
"private": true, "private": true,
"version": "1.0.0", "version": "1.2.1",
"type": "module", "type": "module",
"engines": { "engines": {
"node": "24.21.0" "node": "24.22.1"
}, },
"scripts": { "scripts": {
"dev": "npm run build:packages && vite", "dev": "npm run build:packages && vite",
@@ -20,7 +20,7 @@
"test:coverage": "npm run build:packages && vitest run --coverage", "test:coverage": "npm run build:packages && vitest run --coverage",
"test:lint": "node --test scripts/test-eslint.mjs", "test:lint": "node --test scripts/test-eslint.mjs",
"openapi:generate": "openapi-typescript src/shared/api/spec/openapi.json -o src/shared/api/generated/schema.d.ts", "openapi:generate": "openapi-typescript src/shared/api/spec/openapi.json -o src/shared/api/generated/schema.d.ts",
"openapi:check": "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", "knip": "npm run build:packages && knip",
"deps": "npm run build:packages && depcruise src packages --config dependency-cruiser.cjs --output-type err-long", "deps": "npm run build:packages && depcruise src packages --config dependency-cruiser.cjs --output-type err-long",
"verify": "npm run build:packages && npm run openapi:check && tsc -b --noEmit && eslint . --max-warnings=0 && npm run test:lint && vitest run --coverage && knip && depcruise src packages --config dependency-cruiser.cjs --output-type err-long && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long && tsc -b && vite build && storybook build --output-dir storybook-static --disable-telemetry", "verify": "npm run build:packages && npm run openapi:check && tsc -b --noEmit && eslint . --max-warnings=0 && npm run test:lint && vitest run --coverage && knip && depcruise src packages --config dependency-cruiser.cjs --output-type err-long && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long && tsc -b && vite build && storybook build --output-dir storybook-static --disable-telemetry",
@@ -37,89 +37,94 @@
"*.{js,mjs,cjs,ts,tsx}": "eslint --fix --max-warnings=0 --no-warn-ignored" "*.{js,mjs,cjs,ts,tsx}": "eslint --fix --max-warnings=0 --no-warn-ignored"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "5.9.1", "@hookform/resolvers": "5.9.2",
"@mantine/charts": "9.7.1", "@mantine/charts": "9.7.3",
"@mantine/core": "9.7.1", "@mantine/core": "9.7.3",
"@mantine/dates": "9.7.1", "@mantine/dates": "9.7.3",
"@mantine/hooks": "9.7.1", "@mantine/hooks": "9.7.3",
"@mantine/notifications": "9.7.1", "@mantine/notifications": "9.7.3",
"@tanstack/react-query": "5.104.1", "@tanstack/react-query": "5.104.5",
"@wealthledger/icons": "file:packages/icons", "@wealthledger/icons": "file:packages/icons",
"@wealthledger/tokens": "file:packages/tokens", "@wealthledger/tokens": "file:packages/tokens",
"@wealthledger/ui": "file:packages/ui", "@wealthledger/ui": "file:packages/ui",
"dayjs": "1.11.23", "dayjs": "1.11.23",
"i18next": "26.4.3", "i18next": "26.5.10",
"motion": "13.5.1", "motion": "13.5.1",
"openapi-fetch": "0.17.0", "openapi-fetch": "0.17.0",
"openapi-react-query": "0.5.4", "openapi-react-query": "0.5.4",
"react": "19.3.0", "react": "19.3.1",
"react-dom": "19.3.0", "react-dom": "19.3.1",
"react-hook-form": "7.89.0", "react-hook-form": "7.91.0",
"react-i18next": "17.0.16", "react-i18next": "17.1.8",
"react-is": "19.3.0", "react-is": "19.3.1",
"react-router": "8.4.0", "react-router": "8.5.0",
"recharts": "3.10.1", "recharts": "3.10.1",
"zod": "4.6.5" "zod": "4.6.7"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "8.0.7", "@babel/core": "8.0.9",
"@commitlint/cli": "21.2.3", "@commitlint/cli": "21.2.4",
"@commitlint/config-conventional": "21.2.3", "@commitlint/config-conventional": "21.2.4",
"@eslint-react/eslint-plugin": "5.24.10", "@eslint-react/eslint-plugin": "5.28.8",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@release-it/conventional-changelog": "12.0.2", "@release-it/conventional-changelog": "12.0.2",
"@rolldown/plugin-babel": "0.2.4", "@rolldown/plugin-babel": "0.2.4",
"@storybook/addon-docs": "10.6.1", "@storybook/addon-docs": "10.6.3",
"@storybook/react-vite": "10.6.1", "@storybook/react-vite": "10.6.3",
"@stylistic/eslint-plugin": "5.10.0", "@stylistic/eslint-plugin": "5.10.0",
"@tanstack/eslint-plugin-query": "5.104.1", "@tanstack/eslint-plugin-query": "5.104.5",
"@testing-library/jest-dom": "7.0.1", "@testing-library/jest-dom": "7.0.1",
"@testing-library/react": "16.3.3", "@testing-library/react": "16.3.3",
"@types/node": "24.19.2", "@types/node": "24.19.2",
"@types/react": "19.3.0", "@types/react": "19.3.0",
"@types/react-dom": "19.3.0", "@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "6.1.2", "@vitejs/plugin-react": "6.1.2",
"@vitest/browser-playwright": "5.0.3", "@vitest/browser": "5.0.5",
"@vitest/coverage-v8": "5.0.3", "@vitest/browser-playwright": "5.0.5",
"@vitest/coverage-v8": "5.0.5",
"@vitest/eslint-plugin": "1.6.27", "@vitest/eslint-plugin": "1.6.27",
"@vitest/ui": "5.0.5",
"babel-plugin-react-compiler": "1.0.0", "babel-plugin-react-compiler": "1.0.0",
"conventional-changelog-conventionalcommits": "10.4.1", "conventional-changelog-conventionalcommits": "10.4.1",
"dependency-cruiser": "18.5.0", "dependency-cruiser": "18.7.0",
"eslint": "10.12.0", "eslint": "10.13.0",
"eslint-import-resolver-typescript": "4.4.5", "eslint-import-resolver-typescript": "4.4.5",
"eslint-plugin-import-x": "4.17.1", "eslint-plugin-import-x": "4.17.1",
"eslint-plugin-jest-dom": "5.10.1", "eslint-plugin-jest-dom": "5.10.1",
"eslint-plugin-no-unsanitized": "4.1.5", "eslint-plugin-no-unsanitized": "4.1.5",
"eslint-plugin-perfectionist": "5.12.1", "eslint-plugin-perfectionist": "5.13.0",
"eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.5.7", "eslint-plugin-react-refresh": "0.5.7",
"eslint-plugin-regexp": "3.3.1", "eslint-plugin-regexp": "3.3.1",
"eslint-plugin-security": "4.2.0", "eslint-plugin-security": "4.2.0",
"eslint-plugin-sonarjs": "4.2.2", "eslint-plugin-sonarjs": "4.2.2",
"eslint-plugin-storybook": "10.6.1", "eslint-plugin-storybook": "10.6.3",
"eslint-plugin-testing-library": "7.16.2", "eslint-plugin-testing-library": "7.16.2",
"eslint-plugin-unicorn": "76.0.0", "eslint-plugin-unicorn": "76.0.0",
"globals": "17.13.0", "globals": "17.13.0",
"husky": "9.1.7", "husky": "9.1.7",
"jsdom": "30.1.2", "jsdom": "30.1.3",
"knip": "6.41.0", "knip": "6.43.0",
"lint-staged": "17.6.0", "lint-staged": "17.6.0",
"msw": "2.15.0", "msw": "2.15.0",
"openapi-typescript": "7.13.0", "openapi-typescript": "7.13.0",
"playwright": "1.64.0", "playwright": "1.64.1",
"release-it": "21.1.1", "release-it": "21.1.1",
"storybook": "10.6.1", "storybook": "10.6.3",
"typescript": "5.9.3", "typescript": "5.9.3",
"typescript-eslint": "8.71.1", "typescript-eslint": "8.72.0",
"vite": "8.3.4", "vite": "8.3.5",
"vite-plugin-pwa": "1.3.0", "vite-plugin-pwa": "1.3.0",
"vitest": "5.0.3" "vitest": "5.0.5"
}, },
"overrides": { "overrides": {
"release-it": { "release-it": {
"basic-ftp": "6.2.3", "basic-ftp": "6.2.3",
"undici": "7.30.0" "undici": "8.12.3"
} },
"@vitest/browser": "5.0.5",
"@vitest/ui": "5.0.5",
"@vitest/mocker": "5.0.5"
}, },
"allowScripts": { "allowScripts": {
"esbuild@0.28.2": true, "esbuild@0.28.2": true,
+4 -4
View File
@@ -20,14 +20,14 @@
"build": "vite build && tsc -p tsconfig.json" "build": "vite build && tsc -p tsconfig.json"
}, },
"peerDependencies": { "peerDependencies": {
"react": "19.3.0" "react": "19.3.1"
}, },
"devDependencies": { "devDependencies": {
"@storybook/react-vite": "10.6.1", "@storybook/react-vite": "10.6.3",
"@testing-library/react": "16.3.3", "@testing-library/react": "16.3.3",
"@types/react": "19.3.0", "@types/react": "19.3.0",
"typescript": "5.9.3", "typescript": "5.9.3",
"vite": "8.3.4", "vite": "8.3.5",
"vitest": "5.0.3" "vitest": "5.0.5"
} }
} }
+8 -8
View File
@@ -27,18 +27,18 @@
"@wealthledger/tokens": "0.3.0" "@wealthledger/tokens": "0.3.0"
}, },
"peerDependencies": { "peerDependencies": {
"@mantine/core": "9.7.1", "@mantine/core": "9.7.3",
"@mantine/hooks": "9.7.1", "@mantine/hooks": "9.7.3",
"react": "19.3.0", "react": "19.3.1",
"react-dom": "19.3.0" "react-dom": "19.3.1"
}, },
"devDependencies": { "devDependencies": {
"@storybook/react-vite": "10.6.1", "@storybook/react-vite": "10.6.3",
"@testing-library/react": "16.3.3", "@testing-library/react": "16.3.3",
"@types/react": "19.3.0", "@types/react": "19.3.0",
"storybook": "10.6.1", "storybook": "10.6.3",
"typescript": "5.9.3", "typescript": "5.9.3",
"vite": "8.3.4", "vite": "8.3.5",
"vitest": "5.0.3" "vitest": "5.0.5"
} }
} }
-45
View File
@@ -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;
+1 -1
View File
@@ -85,7 +85,7 @@ test("ESLint enforces the frontend profile and excludes generated artifacts", as
"security/detect-non-literal-regexp", "security/detect-non-literal-regexp",
], ],
[ [
"scripts/check-openapi.mjs", "scripts/test-pre-commit.mjs",
"import { exec } from \"node:child_process\"; exec(process.argv[2]);", "import { exec } from \"node:child_process\"; exec(process.argv[2]);",
"security/detect-child-process", "security/detect-child-process",
], ],
+40
View File
@@ -47,6 +47,12 @@ test("pre-commit scans the index, redacts findings and fails closed", () => {
} }
git("init", "--quiet"); git("init", "--quiet");
// The hook must run the installed lint-staged binary, never download one.
const localBin = nodePath.join(cwd, "node_modules", ".bin");
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
mkdirSync(localBin, { recursive: true });
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
writeFileSync(nodePath.join(localBin, "lint-staged"), "#!/bin/sh\necho ran >> downstream\n", { mode: 0o755 });
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
writeFileSync(nodePath.join(cwd, "sample.txt"), "safe staged content\n"); writeFileSync(nodePath.join(cwd, "sample.txt"), "safe staged content\n");
git("add", "sample.txt"); git("add", "sample.txt");
@@ -77,3 +83,37 @@ test("pre-commit scans the index, redacts findings and fails closed", () => {
rmSync(cwd, { force: true, recursive: true }); rmSync(cwd, { force: true, recursive: true });
} }
}); });
test("pre-commit never downloads tools and blocks when lint-staged is missing", () => {
const hook = nodePath.resolve(".husky/pre-commit");
const cwd = mkdtempSync(nodePath.join(tmpdir(), "frontend-hook-"));
const bin = nodePath.join(cwd, "bin");
const marker = nodePath.join(cwd, "downstream");
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
mkdirSync(bin);
// Gitleaks passes; npx/npm must never be reached, even if present on PATH.
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
writeFileSync(nodePath.join(bin, "gitleaks"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
for (const command of ["npx", "npm"]) {
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
writeFileSync(nodePath.join(bin, command), "#!/bin/sh\necho ran >> downstream\n", { mode: 0o755 });
}
const result = spawnSync("/bin/sh", ["-e", hook], {
cwd,
encoding: "utf8",
env: { ...process.env, PATH: `${bin}${nodePath.delimiter}${process.env.PATH}` },
});
assert.notEqual(result.status, 0, "missing lint-staged must block commit");
assert.match(result.stderr, /lint-staged is not installed/);
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
assert.ok(!existsSync(marker), "hook must not invoke npx or npm");
}
finally {
rmSync(cwd, { force: true, recursive: true });
}
});
+35 -6
View File
@@ -18,12 +18,21 @@ import type { IncomeAnalytics } from "@/entities/income";
import type { Portfolio } from "@/entities/portfolio"; import type { Portfolio } from "@/entities/portfolio";
import { AuthProvider } from "@/features/auth"; import { AuthProvider } from "@/features/auth";
import { defaultPeriod, formatDate } from "@/shared/lib";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource"; import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { AppRoutes } from "./AppRoutes"; import { AppRoutes } from "./AppRoutes";
// Mantine calendar labels follow dayjs default (English) locale.
function dayLabel(date: string) {
const monthName = new Intl.DateTimeFormat("en", { month: "long" })
.format(new Date(`${date}T12:00:00Z`));
return `${Number(date.slice(8))} ${monthName} ${date.slice(0, 4)}`;
}
const accounts = [ const accounts = [
{ id: "a", name: "Основной" }, { id: "a", name: "Основной" },
{ id: "b", name: "ИИС" }, { id: "b", name: "ИИС" },
@@ -95,10 +104,6 @@ const analytics: IncomeAnalytics
}, },
], ],
period: { from: "2026-08-01", to: "2026-08-31" }, period: { from: "2026-08-01", to: "2026-08-31" },
previous: {
period: { from: "2026-07-01", to: "2026-07-31" },
totalsByCurrency: [],
},
totalsByCurrency: [ totalsByCurrency: [
{ {
coupon: { currency: "RUB", value: "2" }, coupon: { currency: "RUB", value: "2" },
@@ -107,7 +112,6 @@ const analytics: IncomeAnalytics
total: { currency: "RUB", value: "2" }, total: { currency: "RUB", value: "2" },
}, },
], ],
yearComparison: null,
}; };
const CurrentLocation: FC = () => { 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", "restores applied dates when switching accounts in %s",
async (section) => { async (section) => {
const search = "?from=2026-08-01&to=2026-08-31"; const search = "?from=2026-08-01&to=2026-08-31";
@@ -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 () => { it("groups the same security and links its accounts and overview sections", async () => {
renderPage("/accounts/all"); renderPage("/accounts/all");
expect(await screen.findAllByText("Сбербанк")).toHaveLength(1); expect(await screen.findAllByText("Сбербанк")).toHaveLength(1);
@@ -190,6 +190,30 @@ describe("AppRoutes", () => {
expect(document.title).toBe("Карты — WealthLedger"); expect(document.title).toBe("Карты — WealthLedger");
}); });
it("offers a retry instead of the login form while the server is unreachable, then restores the route", async () => {
let isOutage = true;
server.use(
http.post("*/api/v1/auth/refresh", () =>
isOutage
? new HttpResponse(null, { status: 502 })
: HttpResponse.json({
accessToken: "test-token",
user: { email: "user@example.com", id: "user-1" },
}),
),
);
renderRoutes("/accounts");
expect(await screen.findByRole("alert")).toHaveTextContent("Не удалось подключиться");
expect(screen.queryByRole("heading", { name: "Вход" })).not.toBeInTheDocument();
isOutage = false;
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
expect(
await screen.findByRole("heading", { level: 2, name: "Нет инвестиционных счетов" }),
).toBeVisible();
});
it("lazy-loads the protected shell for an authenticated accounts route", async () => { it("lazy-loads the protected shell for an authenticated accounts route", async () => {
server.use( server.use(
http.post("*/api/v1/auth/refresh", () => http.post("*/api/v1/auth/refresh", () =>
@@ -10,7 +10,6 @@ import { useAuth } from "@/features/auth";
type BoundaryProps = PropsWithChildren<{ type BoundaryProps = PropsWithChildren<{
authenticated: boolean; authenticated: boolean;
routeKey: string;
}>; }>;
type RouteErrorFallbackProps = { type RouteErrorFallbackProps = {
@@ -26,12 +25,6 @@ class Boundary extends Component<BoundaryProps, State> {
override state: State = { hasError: false }; override state: State = { hasError: false };
override componentDidUpdate(previousProps: BoundaryProps) {
if (previousProps.routeKey !== this.props.routeKey && this.state.hasError) {
this.setState({ hasError: false });
}
}
override render() { override render() {
return this.state.hasError return this.state.hasError
? <RouteErrorFallback authenticated={this.props.authenticated} /> ? <RouteErrorFallback authenticated={this.props.authenticated} />
@@ -70,10 +63,7 @@ const RouteErrorBoundary: FC<PropsWithChildren> = (props) => {
const { user } = useAuth(); const { user } = useAuth();
return ( return (
<Boundary <Boundary authenticated={Boolean(user)} key={`${pathname}${search}:${key}`}>
authenticated={Boolean(user)}
routeKey={`${pathname}${search}:${key}`}
>
{children} {children}
</Boundary> </Boundary>
); );
+18 -1
View File
@@ -1,4 +1,7 @@
import { Button, Container } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import { type FC, type PropsWithChildren, useState } from "react"; import { type FC, type PropsWithChildren, useState } from "react";
import { useTranslation } from "react-i18next";
import { Navigate, useLocation } from "react-router"; import { Navigate, useLocation } from "react-router";
import { useAuth } from "@/features/auth"; import { useAuth } from "@/features/auth";
@@ -7,12 +10,26 @@ import { RouteSkeleton } from "./RouteFallback";
const Protected: FC<PropsWithChildren> = (props) => { const Protected: FC<PropsWithChildren> = (props) => {
const { children } = props; const { children } = props;
const { ready, signedOut, user } = useAuth(); const { ready, retry, signedOut, unavailable, user } = useAuth();
const { t } = useTranslation();
const location = useLocation(); const location = useLocation();
const [wasAuthenticated, setWasAuthenticated] = useState(false); const [wasAuthenticated, setWasAuthenticated] = useState(false);
if (user && !wasAuthenticated) setWasAuthenticated(true); if (user && !wasAuthenticated) setWasAuthenticated(true);
if (!ready) return <RouteSkeleton variant="shell" />; if (!ready) return <RouteSkeleton variant="shell" />;
// The server was unreachable (e.g. mid-deploy), so the session is unproven, not gone.
if (!user && unavailable)
return (
<Container py="xl" size={560}>
<StateView
action={<Button onClick={retry}>{t("auth.retry")}</Button>}
error
text={t("auth.unavailableText")}
title={t("auth.unavailableTitle")}
/>
</Container>
);
const loginState = signedOut && wasAuthenticated const loginState = signedOut && wasAuthenticated
? undefined ? undefined
: { returnTo: `${location.pathname}${location.search}` }; : { returnTo: `${location.pathname}${location.search}` };
@@ -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 };
+3 -1
View File
@@ -6,6 +6,7 @@ import { calendarDate } from "@/shared/lib";
import { bankToday } from "./form-validation"; import { bankToday } from "./form-validation";
import { moneyCents } from "./money"; import { moneyCents } from "./money";
const MAX_CARD_NAME_LENGTH = 100; const MAX_CARD_NAME_LENGTH = 100;
const paymentSystemValues = ["mir", "visa", "mastercard"] as const;
const MIN_BALANCE_KOPECKS = -9_223_372_036_854_775_808n; const MIN_BALANCE_KOPECKS = -9_223_372_036_854_775_808n;
const MAX_BALANCE_KOPECKS = 9_223_372_036_854_775_807n; const MAX_BALANCE_KOPECKS = 9_223_372_036_854_775_807n;
@@ -63,7 +64,7 @@ const bankCardSchema = z.object({
openingBalance: moneySchema, openingBalance: moneySchema,
openingDate: bankDateSchema, openingDate: bankDateSchema,
paymentSystem: z paymentSystem: z
.enum(["", "mir", "visa", "mastercard"], { .enum(["", ...paymentSystemValues], {
error: validationMessage("bank.validation.paymentSystem"), error: validationMessage("bank.validation.paymentSystem"),
}) })
.optional(), .optional(),
@@ -75,5 +76,6 @@ export {
bankCardSchema, bankCardSchema,
type BankCardValues, type BankCardValues,
bankDateSchema, bankDateSchema,
paymentSystemValues,
positiveMoneySchema, positiveMoneySchema,
}; };
@@ -1,8 +1,7 @@
import { useController, useFormContext } from "react-hook-form"; import { useController, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { BankCardValues } from "../../model/form.schema"; import { type BankCardValues, paymentSystemValues } from "../../model/form.schema";
import { bankDateField } from "../bankDateField"; import { bankDateField } from "../bankDateField";
function useBankCardFieldsVm() { function useBankCardFieldsVm() {
@@ -16,7 +15,7 @@ function useBankCardFieldsVm() {
const { field: openingDateField } = useController({ control, name: "openingDate" }); const { field: openingDateField } = useController({ control, name: "openingDate" });
const { field: paymentSystemField } = useController({ control, name: "paymentSystem" }); const { field: paymentSystemField } = useController({ control, name: "paymentSystem" });
const paymentSystems = ["mir", "visa", "mastercard"].map(value => ({ const paymentSystems = paymentSystemValues.map(value => ({
label: t(`bank.paymentSystems.${value}`), label: t(`bank.paymentSystems.${value}`),
value, value,
})); }));
+2 -2
View File
@@ -3,7 +3,7 @@ import type { FC } from "react";
import { Alert, Button, Group, Text } from "@mantine/core"; import { Alert, Button, Group, Text } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { apiErrorCode } from "@/shared/api"; import { errorCode } from "../model/error-code";
type BankFeedbackProps = { type BankFeedbackProps = {
cached?: boolean; cached?: boolean;
@@ -14,7 +14,7 @@ type BankFeedbackProps = {
const BankFeedback: FC<BankFeedbackProps> = (props) => { const BankFeedback: FC<BankFeedbackProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const code = apiErrorCode(props.error); const code = errorCode(props.error);
return ( return (
<Alert color="orange" role="alert"> <Alert color="orange" role="alert">
@@ -10,6 +10,7 @@ import { formatMoney } from "@/shared/lib";
import type { BankSummary } from "../../api/types"; import type { BankSummary } from "../../api/types";
import { bankOperationAppearance } from "../../model/operation-appearance"; import { bankOperationAppearance } from "../../model/operation-appearance";
import { BankFeedback } from "../BankFeedback";
import classes from "./BankSummary.module.css"; import classes from "./BankSummary.module.css";
const CATEGORY_COLUMNS = 2; const CATEGORY_COLUMNS = 2;
@@ -17,9 +18,10 @@ const CATEGORY_COLUMNS = 2;
type BankSummaryViewProps = { type BankSummaryViewProps = {
all?: boolean; all?: boolean;
data?: BankSummary; data?: BankSummary;
error: boolean; error: unknown;
fetching: boolean; fetching: boolean;
loading: boolean; loading: boolean;
retry?: () => void;
}; };
const BankSummaryViewCategories: FC<BankSummaryViewProps> = (props) => { const BankSummaryViewCategories: FC<BankSummaryViewProps> = (props) => {
@@ -122,7 +124,14 @@ const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
<BankSummaryViewCategories {...props} /> <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> </Stack>
); );
}; };
@@ -36,6 +36,12 @@ function entryLabelKey(operation: BrokerageOperation): string {
return `ledger.category.${operation.category}`; return `ledger.category.${operation.category}`;
} }
function isIncomePayment(operation: BrokerageOperation): boolean {
return [OperationType.Coupon, OperationType.Dividend].some(type =>
isOperationType(operation.type, type),
);
}
function isOperationType(value: unknown, expected: OperationType): boolean { function isOperationType(value: unknown, expected: OperationType): boolean {
return value === expected; return value === expected;
} }
@@ -109,4 +115,4 @@ function paymentPresentation(
}; };
} }
export { operationLabelKey, paymentPresentation }; export { isIncomePayment, operationLabelKey, paymentPresentation };
@@ -11,10 +11,11 @@ import {
formatSnapshotTime, formatSnapshotTime,
instrumentLogoUrl, instrumentLogoUrl,
} from "@/shared/lib"; } from "@/shared/lib";
import { PayoutCard } from "@/shared/ui";
import type { BrokerageOperation } from "../api/useBrokerageOperations"; import type { BrokerageOperation } from "../api/useBrokerageOperations";
import { operationLabelKey, paymentPresentation } from "../model/display"; import { isIncomePayment, operationLabelKey, paymentPresentation } from "../model/display";
type LedgerEntryRowProps = { type LedgerEntryRowProps = {
operation: BrokerageOperation; operation: BrokerageOperation;
@@ -75,6 +76,28 @@ const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
} }
else details = undefined; else details = undefined;
if (isIncomePayment(operation)) {
const extraMetadata = [
instrument,
props.withDetails ? null : operation.account?.name,
].filter(Boolean).join(" · ");
return (
<PayoutCard
details={details}
extraMetadata={extraMetadata}
imageSrc={instrumentLogoUrl(operation.logoName)}
initial={(instrument.trim()[0] ?? "?").toUpperCase()}
kind="executed"
occurredAt={formatSnapshotTime(operation.occurredAt)}
payment={operation.payment}
title={operation.description || t(operationLabelKey(operation.type))}
typeLabel={t(paymentPresentation(operation).label)}
wrap={props.wrap}
/>
);
}
return ( return (
<EntryCard <EntryCard
details={details} details={details}
@@ -4,6 +4,7 @@ import { EntryCard } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatDate, formatMoney, instrumentLogoUrl } from "@/shared/lib"; import { formatDate, formatMoney, instrumentLogoUrl } from "@/shared/lib";
import { PayoutCard } from "@/shared/ui";
import type { BrokerageEvent } from "../api/useBrokerageEvents"; import type { BrokerageEvent } from "../api/useBrokerageEvents";
@@ -15,6 +16,27 @@ const BrokerageEventCard: FC<BrokerageEventCardProps> = (props) => {
const date = formatDate(event.date); const date = formatDate(event.date);
if (event.type === "coupon" || event.type === "dividend") {
const title = [t(`events.type.${event.type}`), event.displayName].join(" · ");
const extraMetadata = [event.isin, event.account?.name]
.filter(Boolean)
.join(" · ");
return (
<PayoutCard
date={date}
dateKind={event.dateKind}
extraMetadata={extraMetadata}
imageSrc={instrumentLogoUrl(event.logoName)}
initial={(event.ticker.trim()[0] ?? "?").toUpperCase()}
kind="forecast"
projectedAmount={event.projectedAmount}
title={title}
/>
);
}
const metadata = [ const metadata = [
date, date,
event.dateKind === "registry" event.dateKind === "registry"
@@ -40,7 +62,7 @@ const BrokerageEventCard: FC<BrokerageEventCardProps> = (props) => {
title={[t(`events.type.${event.type}`), event.displayName].join(" · ")} title={[t(`events.type.${event.type}`), event.displayName].join(" · ")}
tone="neutral" tone="neutral"
value={projectedAmount} value={projectedAmount}
valueLabel={t("events.projectedAmount")} valueLabel={t("payout.forecast")}
/> />
); );
}; };
-4
View File
@@ -4,9 +4,6 @@ type IncomeAnalytics = NonNullable<
paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["data"] paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["data"]
>; >;
type IncomeAnalyticsMeta
= paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["meta"];
type IncomeSummary = NonNullable< type IncomeSummary = NonNullable<
paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"]["data"] paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"]["data"]
>; >;
@@ -47,7 +44,6 @@ function useIncomeSummary(accountId?: string) {
export { export {
type IncomeAnalytics, type IncomeAnalytics,
type IncomeAnalyticsMeta,
type IncomeSummary, type IncomeSummary,
useIncomeAnalytics, useIncomeAnalytics,
useIncomeSummary, useIncomeSummary,
-1
View File
@@ -1,6 +1,5 @@
export { export {
type IncomeAnalytics, type IncomeAnalytics,
type IncomeAnalyticsMeta,
type IncomeSummary, type IncomeSummary,
useIncomeAnalytics, useIncomeAnalytics,
useIncomeSummary, useIncomeSummary,
+2 -16
View File
@@ -6,10 +6,6 @@ import {
type StockCandles = NonNullable<paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"]>; type StockCandles = NonNullable<paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
type StockPosition = NonNullable<paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
const MIN_STOCK_SEARCH_LENGTH = 2;
type StockDividends = NonNullable<paths["/api/v1/stocks/{uid}/dividends"]["get"]["responses"][200]["content"]["application/json"]["data"]>; type StockDividends = NonNullable<paths["/api/v1/stocks/{uid}/dividends"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
type StockPeriod = NonNullable< type StockPeriod = NonNullable<
@@ -18,6 +14,8 @@ type StockPeriod = NonNullable<
>["period"] >["period"]
>; >;
type StockPosition = NonNullable<paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
const STOCK_STALE_TIME_MS = 30_000; const STOCK_STALE_TIME_MS = 30_000;
function useStock(identifier: string) { function useStock(identifier: string) {
@@ -96,18 +94,7 @@ function useStockQuote(uid: string) {
}); });
} }
function useStockSearch(query: string) {
return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks", {
params: { query: { query } },
}),
enabled: query.length >= MIN_STOCK_SEARCH_LENGTH,
staleTime: STOCK_STALE_TIME_MS,
});
}
export { export {
MIN_STOCK_SEARCH_LENGTH,
type StockCandles, type StockCandles,
type StockDividends, type StockDividends,
type StockPosition, type StockPosition,
@@ -117,5 +104,4 @@ export {
useStockDividends, useStockDividends,
useStockPosition, useStockPosition,
useStockQuote, useStockQuote,
useStockSearch,
}; };
-2
View File
@@ -1,5 +1,4 @@
export { export {
MIN_STOCK_SEARCH_LENGTH,
type StockCandles, type StockCandles,
type StockDividends, type StockDividends,
type StockPosition, type StockPosition,
@@ -9,5 +8,4 @@ export {
useStockDividends, useStockDividends,
useStockPosition, useStockPosition,
useStockQuote, useStockQuote,
useStockSearch,
} from "./api/queries"; } from "./api/queries";
+5
View File
@@ -7,7 +7,12 @@ type AuthContextValue = {
logout: () => Promise<void>; logout: () => Promise<void>;
ready: boolean; ready: boolean;
register: (email: string, password: string) => Promise<void>; register: (email: string, password: string) => Promise<void>;
retry: () => void;
signedOut: boolean; signedOut: boolean;
/**
Refresh failed with a network error or 5xx; the refresh cookie may still be valid.
*/
unavailable: boolean;
user: undefined | User; user: undefined | User;
}; };
@@ -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);
});
});
+38 -14
View File
@@ -7,8 +7,10 @@ const EMAIL_PART_COUNT = 2;
const ASCII_SPACE = 32; const ASCII_SPACE = 32;
const ASCII_DELETE = 127; const ASCII_DELETE = 127;
const MAX_EMAIL_LENGTH = 254; const MAX_EMAIL_LENGTH = 254;
const MIN_PASSWORD_BYTES = 12; const MIN_PASSWORD_LENGTH = 8;
const MAX_PASSWORD_BYTES = 72; const MAX_PASSWORD_BYTES = 72;
// Printable ASCII keeps characters == bytes, within the bcrypt 72-byte limit.
const ASCII_TILDE = 126;
function isEmailCharacterInvalid(character: string): boolean { function isEmailCharacterInvalid(character: string): boolean {
const codePoint = character.codePointAt(0); const codePoint = character.codePointAt(0);
@@ -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 const email = z
.string() .string()
.trim() .trim()
@@ -33,28 +43,42 @@ const email = z
{ error: validationMessage("auth.validation.invalidEmail") }, { 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() .string()
.min(1)
.refine( .refine(
value => value => new TextEncoder().encode(value).length <= MAX_PASSWORD_BYTES,
new TextEncoder().encode(value).length >= MIN_PASSWORD_BYTES { error: validationMessage("auth.validation.passwordTooLong") },
&& new TextEncoder().encode(value).length <= MAX_PASSWORD_BYTES,
{ error: validationMessage("auth.validation.invalidPasswordLength") },
); );
const registerPassword = z
.string()
.min(MIN_PASSWORD_LENGTH, {
error: validationMessage("auth.validation.invalidPasswordLength"),
})
.max(MAX_PASSWORD_BYTES, {
error: validationMessage("auth.validation.invalidPasswordLength"),
})
.refine(isPrintableAscii, {
error: validationMessage("auth.validation.invalidPasswordLength"),
});
const loginSchema = z.object({ const loginSchema = z.object({
email, email,
password, password: loginPassword,
passwordConfirmation: z.string().optional(), passwordConfirmation: z.string().optional(),
}); });
const registerSchema = loginSchema.refine( const registerSchema = loginSchema
value => value.password === value.passwordConfirmation, .extend({ password: registerPassword })
{ .refine(
error: validationMessage("auth.validation.passwordsDoNotMatch"), value => value.password === value.passwordConfirmation,
path: ["passwordConfirmation"], {
}, error: validationMessage("auth.validation.passwordsDoNotMatch"),
); path: ["passwordConfirmation"],
},
);
type AuthFormValues = z.input<typeof loginSchema>; type AuthFormValues = z.input<typeof loginSchema>;
+52 -7
View File
@@ -92,8 +92,10 @@ describe("AuthProvider", () => {
); );
}); });
it("settles unauthenticated and clears cached data when cold-start refresh has a transport error", async () => { 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.error())); server.use(http.post("*/api/v1/auth/refresh", () =>
HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }),
));
const queryClient = createTestQueryClient(); const queryClient = createTestQueryClient();
queryClient.setQueryData(["cached-query"], { value: "stale" }); queryClient.setQueryData(["cached-query"], { value: "stale" });
queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]); queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]);
@@ -103,12 +105,54 @@ describe("AuthProvider", () => {
await waitFor(() => expect(result.current.ready).toBe(true)); await waitFor(() => expect(result.current.ready).toBe(true));
expect(result.current.user).toBeUndefined(); expect(result.current.user).toBeUndefined();
expect(result.current.unavailable).toBe(false);
expect(await requestAuthorization()).toBeNull(); expect(await requestAuthorization()).toBeNull();
expect(queryClient.getQueryData(["cached-query"])).toBeUndefined(); expect(queryClient.getQueryData(["cached-query"])).toBeUndefined();
expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined(); expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined();
}); });
it("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; let refreshCalls = 0;
server.use( server.use(
http.post("*/api/v1/auth/refresh", () => { http.post("*/api/v1/auth/refresh", () => {
@@ -137,10 +181,11 @@ describe("AuthProvider", () => {
const response = await apiClient.GET("/api/v1/brokerage-accounts"); const response = await apiClient.GET("/api/v1/brokerage-accounts");
expect(response.response.status).toBe(401); expect(response.response.status).toBe(401);
await waitFor(() => expect(result.current.user).toBeUndefined()); // An unreachable server does not prove the session ended; the next 401 refreshes again.
expect(await requestAuthorization()).toBeNull(); await waitFor(() => expect(result.current.unavailable).toBe(true));
expect(queryClient.getQueryData(["cached-query"])).toBeUndefined(); expect(result.current.user?.id).toBe("user-1");
expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined(); 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 () => { it("clears connection status and financial cache independently on logout", async () => {
+22 -6
View File
@@ -5,6 +5,9 @@ import { apiClient, configureAuthClient, type paths } from "@/shared/api";
import { type User } from "./auth-context"; import { type User } from "./auth-context";
// A 5xx or network error means the server is unreachable, not that the session ended.
const HTTP_SERVER_ERROR = 500;
type Authentication type Authentication
= paths["/api/v1/auth/login"]["post"]["responses"][200]["content"]["application/json"]; = paths["/api/v1/auth/login"]["post"]["responses"][200]["content"]["application/json"];
@@ -32,6 +35,8 @@ function useAuthProviderVm() {
const accessTokenRef = useRef<string | undefined>(undefined); const accessTokenRef = useRef<string | undefined>(undefined);
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const [signedOut, setSignedOut] = useState(false); const [signedOut, setSignedOut] = useState(false);
const [unavailable, setUnavailable] = useState(false);
const unavailableRef = useRef(false);
const generationRef = useRef(0); const generationRef = useRef(0);
const userRef = useRef<undefined | User>(undefined); const userRef = useRef<undefined | User>(undefined);
@@ -49,12 +54,18 @@ function useAuthProviderVm() {
return next; return next;
}, []); }, []);
const markUnavailable = useCallback((isUnavailable: boolean) => {
unavailableRef.current = isUnavailable;
setUnavailable(isUnavailable);
}, []);
const applyAuthentication = useCallback( const applyAuthentication = useCallback(
(authentication: Authentication) => { (authentication: Authentication) => {
const changedIdentity const changedIdentity
= userRef.current && userRef.current.id !== authentication.user.id; = userRef.current && userRef.current.id !== authentication.user.id;
setSignedOut(false); setSignedOut(false);
markUnavailable(false);
accessTokenRef.current = authentication.accessToken; accessTokenRef.current = authentication.accessToken;
if (changedIdentity) { if (changedIdentity) {
@@ -67,16 +78,17 @@ function useAuthProviderVm() {
userRef.current = authentication.user; userRef.current = authentication.user;
setUser(authentication.user); setUser(authentication.user);
}, },
[queryClient], [markUnavailable, queryClient],
); );
const clearSession = useCallback(() => { const clearSession = useCallback(() => {
markUnavailable(false);
generationRef.current += 1; generationRef.current += 1;
userRef.current = undefined; userRef.current = undefined;
setUser(undefined); setUser(undefined);
accessTokenRef.current = undefined; accessTokenRef.current = undefined;
queryClient.clear(); queryClient.clear();
}, [queryClient]); }, [markUnavailable, queryClient]);
const refresh = useCallback((): Promise<boolean> => { const refresh = useCallback((): Promise<boolean> => {
if (refreshPromiseRef.current) return refreshPromiseRef.current; if (refreshPromiseRef.current) return refreshPromiseRef.current;
@@ -86,11 +98,12 @@ function useAuthProviderVm() {
if (started !== generationRef.current) return false; if (started !== generationRef.current) return false;
try { 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 (started !== generationRef.current) return false;
if (!data) { if (!data) {
clearSession(); if (response.status >= HTTP_SERVER_ERROR) markUnavailable(true);
else clearSession();
return false; return false;
} }
@@ -100,7 +113,7 @@ function useAuthProviderVm() {
return true; return true;
} }
catch { catch {
if (started === generationRef.current) clearSession(); if (started === generationRef.current) markUnavailable(true);
return false; return false;
} }
@@ -112,7 +125,7 @@ function useAuthProviderVm() {
refreshPromiseRef.current = pending; refreshPromiseRef.current = pending;
return pending; return pending;
}, [applyAuthentication, clearSession, serialize]); }, [applyAuthentication, clearSession, markUnavailable, serialize]);
useEffect(() => { useEffect(() => {
void refresh().finally(() => setReady(true)); void refresh().finally(() => setReady(true));
@@ -146,6 +159,7 @@ function useAuthProviderVm() {
clear: clearSession, clear: clearSession,
generation: () => generationRef.current, generation: () => generationRef.current,
refresh, refresh,
unavailable: () => unavailableRef.current,
}); });
}); });
useEffect(() => () => configureAuthClient(), []); useEffect(() => () => configureAuthClient(), []);
@@ -157,7 +171,9 @@ function useAuthProviderVm() {
ready, ready,
register: (email: string, password: string) => register: (email: string, password: string) =>
authenticate("/api/v1/auth/register", email, password), authenticate("/api/v1/auth/register", email, password),
retry: () => void refresh(),
signedOut, signedOut,
unavailable,
user, user,
}; };
@@ -30,4 +30,79 @@ describe("AuthForm", () => {
await waitFor(() => expect(onSubmit).not.toHaveBeenCalled()); await waitFor(() => expect(onSubmit).not.toHaveBeenCalled());
expect(await screen.findByText("Пароли не совпадают")).toBeInTheDocument(); expect(await screen.findByText("Пароли не совпадают")).toBeInTheDocument();
}); });
it.each([
["register", "Регистрация", "Уже есть аккаунт? Войти"],
["login", "Вход", "Нет аккаунта? Зарегистрироваться"],
])("blocks the switch-auth link while %s is submitting", async (_mode, submitLabel, linkLabel) => {
const { promise, resolve } = Promise.withResolvers<void>();
const onSubmit = vi.fn(() => promise);
render(
<MantineProvider>
<MemoryRouter>
<AuthForm
onSubmit={onSubmit}
register={_mode === "register"}
/>
</MemoryRouter>
</MantineProvider>,
);
fireEvent.change(screen.getByLabelText("Email"), {
target: { value: "user@example.com" },
});
fireEvent.change(screen.getByLabelText("Пароль"), {
target: { value: "a secure password" },
});
if (_mode === "register") {
fireEvent.change(screen.getByLabelText("Подтвердите пароль"), {
target: { value: "a secure password" },
});
}
fireEvent.click(screen.getByRole("button", { name: submitLabel }));
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
expect(screen.queryByRole("link", { name: linkLabel })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: linkLabel })).toBeDisabled();
resolve();
await screen.findByRole("link", { name: linkLabel });
});
// Register policy is 8..72 printable ASCII; login accepts legacy passwords.
it.each([
["register", "Регистрация", "пароль-пароль", false],
["register", "Регистрация", "passw😀rd", false],
["register", "Регистрация", "a".repeat(7), false],
["register", "Регистрация", "a".repeat(8), true],
["register", "Регистрация", "a".repeat(72), true],
["register", "Регистрация", "a".repeat(73), false],
["login", "Вход", "пароль-пароль", true],
])("%s submits password %j: %s", async (mode, submitLabel, password, submitted) => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(
<MantineProvider>
<MemoryRouter>
<AuthForm onSubmit={onSubmit} register={mode === "register"} />
</MemoryRouter>
</MantineProvider>,
);
fireEvent.change(screen.getByLabelText("Email"), { target: { value: "user@example.com" } });
fireEvent.change(screen.getByLabelText("Пароль"), { target: { value: password } });
if (mode === "register") {
fireEvent.change(screen.getByLabelText("Подтвердите пароль"), { target: { value: password } });
}
fireEvent.click(screen.getByRole("button", { name: submitLabel }));
await waitFor(() => expect(
screen.queryByText(/Пароль: от 8 до 72 символов/) === null,
).toBe(submitted));
expect(onSubmit).toHaveBeenCalledTimes(submitted ? 1 : 0);
});
}); });
+21 -5
View File
@@ -1,4 +1,4 @@
import type { FC } from "react"; import type { FC, ReactNode } from "react";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Alert, Button, Fieldset, PasswordInput, Stack, TextInput } from "@mantine/core"; import { Alert, Button, Fieldset, PasswordInput, Stack, TextInput } from "@mantine/core";
@@ -15,7 +15,7 @@ import {
type AuthFormProps = { type AuthFormProps = {
onSubmit: (email: string, password: string) => Promise<void>; onSubmit: (email: string, password: string) => Promise<void>;
register?: true; register?: boolean;
}; };
const AuthForm: FC<AuthFormProps> = (props) => { const AuthForm: FC<AuthFormProps> = (props) => {
@@ -45,6 +45,24 @@ const AuthForm: FC<AuthFormProps> = (props) => {
}); });
const to = register ? "/login" : "/register"; const to = register ? "/login" : "/register";
const switchLabel = t(register ? "auth.haveAccount" : "auth.noAccount");
let switchLink: ReactNode;
if (isSubmitting) {
switchLink = (
<Button aria-disabled disabled variant="subtle">
{switchLabel}
</Button>
);
}
else {
switchLink = (
<Button component={Link} to={to} variant="subtle">
{switchLabel}
</Button>
);
}
return ( return (
<FormProvider {...methods}> <FormProvider {...methods}>
@@ -77,9 +95,7 @@ const AuthForm: FC<AuthFormProps> = (props) => {
{t(register ? "auth.register" : "auth.login")} {t(register ? "auth.register" : "auth.login")}
</Button> </Button>
<Button component={Link} to={to} variant="subtle"> {switchLink}
{t(register ? "auth.haveAccount" : "auth.noAccount")}
</Button>
</Stack> </Stack>
</Fieldset> </Fieldset>
</form> </form>
@@ -78,4 +78,35 @@ describe("read-only screen synchronization", () => {
await act(async () => result.current.refresh()); await act(async () => result.current.refresh());
await waitFor(() => expect(gets).toBe(2)); await waitFor(() => expect(gets).toBe(2));
}); });
it("does not manually refetch disabled resources", async () => {
vi.useFakeTimers();
const refetch = vi.fn(() => Promise.resolve());
const resource = {
isEnabled: false,
isError: true,
isFetching: false,
queryKey: [],
refetch,
};
try {
const { result, unmount } = renderHook(() =>
useScreenSync({ screen: "accounts" }, [resource]),
);
await act(async () => {
await result.current.refresh();
await result.current.retry();
});
await act(() => vi.advanceTimersByTime(1_800_000));
expect(refetch).not.toHaveBeenCalled();
unmount();
}
finally {
vi.useRealTimers();
}
});
}); });
@@ -5,6 +5,7 @@ import { useEffect, useRef } from "react";
import type { InvestmentResourceMeta } from "@/shared/api"; import type { InvestmentResourceMeta } from "@/shared/api";
type ScreenResource = { type ScreenResource = {
isEnabled: boolean;
isError: boolean; isError: boolean;
isFetching: boolean; isFetching: boolean;
meta?: InvestmentResourceMeta; meta?: InvestmentResourceMeta;
@@ -27,23 +28,31 @@ function useScreenSync(_scope: ScreenScope, resources: ScreenResource[]) {
useEffect(() => { useEffect(() => {
const timer = setInterval(() => { const timer = setInterval(() => {
for (const resource of resourcesRef.current) for (const resource of resourcesRef.current) {
if (!resource.isEnabled || resource.isFetching) continue;
void resource.refetch().catch(() => {}); void resource.refetch().catch(() => {});
}
}, READ_INTERVAL_MS); }, READ_INTERVAL_MS);
return () => clearInterval(timer); return () => clearInterval(timer);
}, []); }, []);
const isRefreshing = resources.some(resource => resource.isFetching); const isRefreshing = resources.some(
resource => resource.isEnabled && resource.isFetching,
);
const eligible = resources.filter(
resource => resource.isEnabled && !resource.isFetching,
);
const refresh = async () => { const refresh = async () => {
if (isRefreshing) return; if (isRefreshing) return;
await Promise.allSettled(resources.map(resource => resource.refetch())); await Promise.allSettled(eligible.map(resource => resource.refetch()));
}; };
// Retry only the HTTP read; source attempts are reported in resource metadata. // Retry only the HTTP read; source attempts are reported in resource metadata.
const retry = async () => { const retry = async () => {
const failed = resources.filter(resource => resource.isError); const failed = eligible.filter(resource => resource.isError);
if (failed.length > 0) if (failed.length > 0)
await Promise.allSettled(failed.map(resource => resource.refetch())); await Promise.allSettled(failed.map(resource => resource.refetch()));
@@ -17,10 +17,10 @@ import {
useLocation, useLocation,
useNavigate, useNavigate,
} from "react-router"; } from "react-router";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
import { defaultPeriod, formatDate } from "@/shared/lib"; import { defaultPeriod, formatDate, moscowDate } from "@/shared/lib";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource"; import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
@@ -72,7 +72,14 @@ function renderEvents(accountId: string, searches: string[]) {
} }
describe("Upcoming event date filters", () => { describe("Upcoming event date filters", () => {
afterEach(() => {
vi.useRealTimers();
});
beforeEach(() => { beforeEach(() => {
// Pin "today": the test picks today + 10 days, which must be inside the visible calendar grid.
// Real dates can push it beyond the grid (e.g. 2026-10-23 -> 2 November).
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-08-10T09:00:00Z"));
configureAuthClient({ configureAuthClient({
accessToken: () => "token", accessToken: () => "token",
clear: vi.fn(), clear: vi.fn(),
@@ -80,6 +87,8 @@ describe("Upcoming event date filters", () => {
}); });
}); });
afterEach(() => vi.useRealTimers());
it("keeps reads and sync disabled without an account scope", async () => { it("keeps reads and sync disabled without an account scope", async () => {
const requests: string[] = []; const requests: string[] = [];
server.use( server.use(
@@ -229,6 +238,10 @@ describe("Upcoming event date filters", () => {
it.each(["account-1", "all"])( it.each(["account-1", "all"])(
"applies dates only on submit, restores URL dates, and resets from empty results for %s", "applies dates only on submit, restores URL dates, and resets from empty results for %s",
async (accountId) => { async (accountId) => {
// Pin "today" mid-month so "today + 10 days" stays in the calendar's
// visible month regardless of the real date.
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-10-15T12:00:00+03:00"));
const [defaultFrom, defaultTo] = defaultPeriod(); const [defaultFrom, defaultTo] = defaultPeriod();
const searches: string[] = []; const searches: string[] = [];
server.use( server.use(
@@ -253,37 +266,58 @@ describe("Upcoming event date filters", () => {
expect(from).toHaveTextContent("01.02.2026 – 28.02.2026"); expect(from).toHaveTextContent("01.02.2026 – 28.02.2026");
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
expect(searches).toEqual(["?from=2026-02-01&to=2026-02-28"]); expect(searches).toEqual(["?from=2026-02-01&to=2026-02-28"]);
fireEvent.click(from);
// The calendar opens on the applied month, so reset to the default
// range first, then pick today and a future date.
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
await waitFor(() => expect(screen.getByLabelText("URL")).toBeEmptyDOMElement());
const picker = await screen.findByLabelText("Период");
const [today] = defaultPeriod();
const [year = 1970, month = 0, day = 1] = today.split("-").map(Number);
const future = moscowDate(new Date(
Date.UTC(year, month - 1, day + 10, 12),
));
// Mantine calendar labels follow dayjs default (English) locale.
const calendarLabel = (date: string) => {
const monthName = new Intl.DateTimeFormat("en", { month: "long" })
.format(new Date(`${date}T12:00:00Z`));
const pattern = `${Number(date.slice(8))} ${monthName} ${date.slice(0, 4)}`;
// Dates are constructed above from validated defaultPeriod output.
// eslint-disable-next-line security/detect-non-literal-regexp -- see comment above
return new RegExp(pattern, "i");
};
fireEvent.click(picker);
fireEvent.click( fireEvent.click(
await screen.findByRole("button", { name: /10 February 2026/i }), await screen.findByRole("button", { name: calendarLabel(today) }),
); );
fireEvent.click( 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: "Применить" })); fireEvent.click(screen.getByRole("button", { name: "Применить" }));
await waitFor(() => 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( expect(await screen.findByLabelText("Период")).toHaveTextContent(
"10.02.2026 – 28.02.2026", `${formatDate(today)} – ${formatDate(future)}`,
); );
fireEvent.click(screen.getByRole("button", { name: "History back" })); fireEvent.click(screen.getByRole("button", { name: "History back" }));
await waitFor(() => await waitFor(() =>
expect(screen.getByLabelText("Период")).toHaveTextContent( expect(screen.getByLabelText("Период")).toHaveTextContent(
"01.02.2026 – 28.02.2026", `${formatDate(defaultFrom)} – ${formatDate(defaultTo)}`,
), ),
); );
expect(screen.getByLabelText("URL")).toHaveTextContent( expect(screen.getByLabelText("URL")).toBeEmptyDOMElement();
"?from=2026-02-01&to=2026-02-28",
);
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
await waitFor(() =>
expect(searches.at(-1)).toBe(`?from=${defaultFrom}&to=${defaultTo}`),
);
expect(await screen.findByLabelText("Период")).toHaveTextContent(
`${formatDate(defaultFrom)} – ${formatDate(defaultTo)}`,
);
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
expect( expect(
screen.getByText("За выбранный период событий не найдено"), screen.getByText("За выбранный период событий не найдено"),
@@ -156,7 +156,7 @@ describe("AccountEventsPage", () => {
expect(screen.getAllByText(/RU000A0JX0J2$/)).toHaveLength(1); expect(screen.getAllByText(/RU000A0JX0J2$/)).toHaveLength(1);
expect(screen.getByText("25.08.2026")).toBeVisible(); expect(screen.getByText("25.08.2026")).toBeVisible();
expect(screen.queryByText(/Дата события/)).not.toBeInTheDocument(); expect(screen.queryByText(/Дата события/)).not.toBeInTheDocument();
expect(screen.getAllByText("Ожидаемая сумма")).toHaveLength(2); expect(screen.getAllByText("Прогноз")).toHaveLength(2);
const logo = screen.queryByAltText(""); const logo = screen.queryByAltText("");
expect(logo).toHaveAttribute( expect(logo).toHaveAttribute(
"src", "src",
@@ -211,4 +211,30 @@ describe("AccountEventsPage", () => {
expect(status).toHaveAttribute("aria-busy", "true"); expect(status).toHaveAttribute("aria-busy", "true");
expect(within(status).queryByRole("button")).not.toBeInTheDocument(); expect(within(status).queryByRole("button")).not.toBeInTheDocument();
}); });
it("keeps a null events envelope loading instead of an empty period", () => {
query.mockReturnValue(
{
data: null,
isError: false,
isLoading: false,
meta: {
availability: "available",
completeness: "complete",
lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" },
},
refetch: vi.fn(),
} as never,
);
renderPage();
expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute(
"aria-busy",
"true",
);
expect(
screen.queryByText("За выбранный период событий не найдено"),
).not.toBeInTheDocument();
});
}); });
@@ -21,7 +21,7 @@ type AccountEventsPageViewModel
canApply: boolean; canApply: boolean;
draftPeriod: [null | string, null | string]; draftPeriod: [null | string, null | string];
events: BrokerageEvent[]; events: BrokerageEvent[];
maxDate: string; minDate: string;
onApply: () => void; onApply: () => void;
onPeriodChange: (value: [null | string, null | string]) => void; onPeriodChange: (value: [null | string, null | string]) => void;
onPeriodPopoverOpen: () => void; onPeriodPopoverOpen: () => void;
@@ -49,7 +49,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
const [draftFrom, draftTo] = draftPeriod; 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 scope = JSON.stringify([accountId, appliedFrom, appliedTo]);
const [previousScope, setPreviousScope] = useState(scope); const [previousScope, setPreviousScope] = useState(scope);
@@ -105,7 +105,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
&& isValidPeriod(draftFrom, draftTo), && isValidPeriod(draftFrom, draftTo),
draftPeriod, draftPeriod,
events: events.data.events, events: events.data.events,
maxDate, minDate,
onApply: () => { onApply: () => {
if (!isValidPeriod(draftFrom, draftTo)) return; if (!isValidPeriod(draftFrom, draftTo)) return;
setSearchParameters({ setSearchParameters({
@@ -116,7 +116,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
onPeriodChange: value => setDraftPeriod( onPeriodChange: value => setDraftPeriod(
value[0] === null && value[1] === null ? defaultPeriod() : value, value[0] === null && value[1] === null ? defaultPeriod() : value,
), ),
onPeriodPopoverOpen: () => setMaxDate(moscowDate(new Date())), onPeriodPopoverOpen: () => setMinDate(moscowDate(new Date())),
onReset, onReset,
partial: events.meta?.completeness === "incomplete", partial: events.meta?.completeness === "incomplete",
source: events.meta, source: events.meta,
@@ -72,7 +72,7 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
clearable clearable
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }} clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
label={t("ledger.period")} label={t("ledger.period")}
maxDate={props.maxDate} minDate={props.minDate}
onChange={props.onPeriodChange} onChange={props.onPeriodChange}
popoverProps={{ popoverProps={{
middlewares: { shift: { crossAxis: true } }, middlewares: { shift: { crossAxis: true } },
+14 -157
View File
@@ -2,168 +2,25 @@ import { describe, expect, it } from "vitest";
import type { IncomeAnalytics } from "@/entities/income"; import type { IncomeAnalytics } from "@/entities/income";
import { resource } from "@/test/resource";
import { toIncomeDisplayModel } from "./display"; import { toIncomeDisplayModel } from "./display";
const analytics: IncomeAnalytics = { describe("income payments display", () => {
accountId: "account-1", it("keeps repeated IDs, order and logo revisions without reconstructing financial data", () => {
bucketGranularity: "day", const payment = {
buckets: [], id: "same",
calculatedAt: "2026-08-27T00:00:00Z", instrumentKey: "bond:RUB",
entries: [], logoName: null,
instruments: [], } as IncomeAnalytics["entries"][number];
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,
};
function comparison(currency: string) { const analytics = {
return { entries: [payment, payment, { ...payment, logoName: "bond.png" }],
compared: summary(currency, "1", "0"), } as IncomeAnalytics;
currency,
difference: summary(currency, "9", "2"),
selected: summary(currency, "10", "2"),
};
}
function money(currency: string, value: string) { const display = toIncomeDisplayModel(analytics);
return { currency, value };
}
function summary(currency: string, coupon: string, dividend: string) { expect(display.entries.map(({ operation }) => operation)).toEqual(
return { analytics.entries,
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?.comparison?.comparedPeriod.replaceAll(/\s/g, " ")).toBe( expect(new Set(display.entries.map(({ key }) => key)).size).toBe(3);
"01.12.2024 – 09.01.2025",
);
expect(display?.comparison?.values.map(value => value.currency)).toEqual([
"RUB",
"USD",
]);
}); });
}); });
it("maps comparison notices by role regardless of dependency order or resource key", () => {
const selected = { ...resource(analytics).meta, role: "selected" as const };
const previous
= {
...resource(null).meta,
lastAttempt: {
finishedAt: "2026-08-14T00:01:00Z",
problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed" as const,
},
resourceKey: "opaque/year-looking-key",
role: "previous" as const,
};
const year = {
...resource(null).meta,
resourceKey: "opaque/previous-looking-key",
role: "year" as const,
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
};
for (const dependencies of [
[selected, previous, year],
[year, selected, previous],
]) {
const display = toIncomeDisplayModel(analytics, {
...resource(analytics).meta,
dependencies,
});
expect(display?.previousNotice).toBe("comparisonFailed");
expect(display?.yearNotice).toBe("yearUnavailable");
expect(display?.previousComparison).not.toBeNull();
}
});
+2 -103
View File
@@ -1,102 +1,11 @@
import type { IncomeAnalytics, IncomeAnalyticsMeta } from "@/entities/income"; import type { IncomeAnalytics } from "@/entities/income";
import { formatDate } from "@/shared/lib";
import type { IncomePeriod } from "./period";
type ComparisonNotice
= | "comparisonFailed"
| "comparisonPending"
| "previousUnavailable"
| "yearUnavailable";
type IncomeDisplayModel = { type IncomeDisplayModel = {
comparison: null
| {
comparedPeriod: string;
selectedPeriod: string;
values: (NonNullable<
IncomeAnalytics["yearComparison"]
>["totalsByCurrency"][number] & {
percentages: Record<"coupon"
| "dividend"
| "total", null | string>;
})[];
};
entries: { key: string; operation: IncomeAnalytics["entries"][number] }[]; entries: { key: string; operation: IncomeAnalytics["entries"][number] }[];
previousComparison: IncomeDisplayModel["comparison"];
previousNotice: ComparisonNotice;
yearNotice: ComparisonNotice;
}; };
function comparisonDisplay(
period: IncomePeriod,
comparison: IncomeAnalytics["yearComparison"],
): IncomeDisplayModel["comparison"] {
if (!comparison)
return null;
return {
comparedPeriod: formatComparisonPeriod(comparison.period),
selectedPeriod: formatComparisonPeriod(period),
values: comparison.totalsByCurrency.map(value => ({
...value,
percentages: {
coupon: formatIncomeChange(
value.difference.coupon.value,
value.compared.coupon.value,
),
dividend: formatIncomeChange(
value.difference.dividend.value,
value.compared.dividend.value,
),
total: formatIncomeChange(
value.difference.total.value,
value.compared.total.value,
),
},
})),
};
}
function comparisonNotice(
dependency:
IncomeAnalyticsMeta["dependencies"][number] | undefined,
unavailable: "previousUnavailable" | "yearUnavailable",
): ComparisonNotice {
if (!dependency || dependency.unavailableReason) return unavailable;
return dependency.lastAttempt.state === "failed"
? "comparisonFailed"
: "comparisonPending";
}
function formatComparisonPeriod(period: IncomePeriod): string {
return `${formatDate(period.from)} – ${formatDate(period.to)}`;
}
function formatIncomeChange(
difference: string,
compared: string,
): null | string {
// Display-only ratio; server-provided Money values remain exact decimal strings.
const base = Number(compared);
if (!Number.isFinite(base) || base <= 0)
return null;
const ratio = Number(difference) / base;
if (!Number.isFinite(ratio))
return null;
return new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 1,
signDisplay: "exceptZero",
style: "percent",
}).format(ratio);
}
function toIncomeDisplayModel( function toIncomeDisplayModel(
analytics: IncomeAnalytics, analytics: IncomeAnalytics,
meta?: IncomeAnalyticsMeta,
): IncomeDisplayModel { ): IncomeDisplayModel {
const entries: IncomeDisplayModel["entries"] = []; const entries: IncomeDisplayModel["entries"] = [];
const occurrences = new Map<string, number>(); const occurrences = new Map<string, number>();
@@ -116,17 +25,7 @@ function toIncomeDisplayModel(
}); });
} }
const dependencies = meta?.dependencies ?? []; return { entries };
const previous = dependencies.find(({ role }) => role === "previous");
const year = dependencies.find(({ role }) => role === "year");
return {
comparison: comparisonDisplay(analytics.period, analytics.yearComparison),
entries,
previousComparison: comparisonDisplay(analytics.period, analytics.previous),
previousNotice: comparisonNotice(previous, "previousUnavailable"),
yearNotice: comparisonNotice(year, "yearUnavailable"),
};
} }
export { type IncomeDisplayModel, toIncomeDisplayModel }; export { type IncomeDisplayModel, toIncomeDisplayModel };
@@ -22,123 +22,6 @@
min-width: 0; min-width: 0;
} }
.comparison {
container-type: inline-size;
}
.comparisonGrid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--mantine-spacing-sm);
}
.comparisonPanel {
min-width: 0;
padding: var(--mantine-spacing-md);
border-radius: var(--mantine-radius-md);
border-top: 3px solid var(--mantine-color-gray-4);
background: var(--mantine-color-gray-0);
}
.comparisonPanel[data-kind="selected"] {
border-color: var(--mantine-color-blue-6);
background: var(--mantine-color-blue-0);
}
.comparisonPanel[data-tone="positive"] {
border-color: var(--mantine-color-green-8);
background: var(--mantine-color-green-0);
}
.comparisonPanel[data-tone="negative"] {
border-color: var(--mantine-color-red-8);
background: var(--mantine-color-red-0);
}
.comparisonLabel,
.comparisonValues dt {
color: var(--mantine-color-gray-7);
}
.comparisonValues {
margin: var(--mantine-spacing-md) 0 0;
font-size: var(--mantine-font-size-sm);
}
.comparisonValues dd {
min-width: 0;
margin: 0;
font-weight: 600;
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
.comparisonTotal {
margin-bottom: var(--mantine-spacing-md);
padding-bottom: var(--mantine-spacing-md);
border-bottom: 1px solid var(--mantine-color-gray-3);
}
.comparisonTotal dd {
margin-top: 4px;
font-size: var(--mantine-h2-font-size);
line-height: 1.3;
font-weight: 700;
letter-spacing: -0.025em;
}
.comparisonValues dd[data-tone="positive"] {
color: color-mix(
in srgb,
var(--mantine-color-green-9) 85%,
var(--mantine-color-black)
);
}
.comparisonValues dd[data-tone="negative"] {
color: var(--mantine-color-red-9);
}
.comparisonValues dd[data-tone="neutral"] {
color: var(--mantine-color-gray-7);
}
.comparisonPanel[data-kind="selected"] .comparisonTotal dd {
color: var(--mantine-color-blue-8);
}
.comparisonDetail {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 4px var(--mantine-spacing-xs);
margin-top: var(--mantine-spacing-xs);
}
.comparisonPercent {
display: inline-block;
margin-left: var(--mantine-spacing-xs);
padding: 2px 6px;
border-radius: var(--mantine-radius-sm);
background: var(--mantine-color-white);
font-size: var(--mantine-font-size-xs);
font-weight: 600;
line-height: 1.5;
letter-spacing: normal;
vertical-align: middle;
}
.comparisonNotice {
background: var(--mantine-color-gray-0);
border-left: 3px solid var(--mantine-color-gray-4);
}
@container (min-width: 42em) {
.comparisonGrid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 35.999em) { @media (max-width: 35.999em) {
.fields { .fields {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
@@ -72,23 +72,9 @@ const analytics
}, },
], ],
period: { from: "2026-08-01", to: "2026-08-27" }, period: { from: "2026-08-01", to: "2026-08-27" },
previous: {
period: { from: "2026-07-01", to: "2026-07-31" },
totalsByCurrency: [comparison("RUB")],
},
totalsByCurrency: [summary("RUB", "10", "2")], totalsByCurrency: [summary("RUB", "10", "2")],
yearComparison: null,
} satisfies IncomeAnalytics & { instruments: [unknown] }; } satisfies IncomeAnalytics & { instruments: [unknown] };
function comparison(currency: string) {
return {
compared: summary(currency, "1", "0"),
currency,
difference: summary(currency, "9", "2"),
selected: summary(currency, "10", "2"),
};
}
function entry(): IncomeAnalytics["entries"][number] { function entry(): IncomeAnalytics["entries"][number] {
return { return {
accruedInterest: null, accruedInterest: null,
@@ -328,6 +314,24 @@ describe("AccountIncomePage", () => {
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
it("keeps a null analytics envelope loading instead of an empty page", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(resource(null)),
),
);
renderPage();
expect(
await screen.findByRole("status", { name: "Загрузка" }, { timeout: 4000 }),
).toBeVisible();
expect(
screen.queryByRole("heading", { name: "Пассивный доход" }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Повторить" }),
).not.toBeInTheDocument();
});
it("renders the signed drill-down using opaque membership rather than instrument identity", async () => { it("renders the signed drill-down using opaque membership rather than instrument identity", async () => {
const response const response
= { = {
@@ -372,19 +376,11 @@ describe("AccountIncomePage", () => {
expect(within(panel).getByText("−2 ₽")).toBeInTheDocument(); 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 })); const eventsRequest = vi.fn(() => new HttpResponse(null, { status: 503 }));
server.use( server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json( HttpResponse.json(resource(analytics)),
resource({
...analytics,
yearComparison: {
period: { from: "2025-08-01", to: "2025-08-27" },
totalsByCurrency: [comparison("RUB")],
},
}),
),
), ),
http.get("*/api/v1/brokerage-accounts/account-1/events", eventsRequest), http.get("*/api/v1/brokerage-accounts/account-1/events", eventsRequest),
); );
@@ -399,72 +395,9 @@ describe("AccountIncomePage", () => {
expect(screen.queryByText("Динамика")).not.toBeInTheDocument(); expect(screen.queryByText("Динамика")).not.toBeInTheDocument();
expect(eventsRequest).not.toHaveBeenCalled(); expect(eventsRequest).not.toHaveBeenCalled();
expect(screen.getByRole("region", { name: "Выплаты" })).toBeInTheDocument(); expect(screen.getByRole("region", { name: "Выплаты" })).toBeInTheDocument();
const year = screen.getByRole("region", { name: "Годом ранее" }); expect(screen.queryByText("Предыдущий период")).not.toBeInTheDocument();
expect(screen.queryByText("Годом ранее")).not.toBeInTheDocument();
const selected = within(year).getByRole("region", { expect(screen.queryByText(/Сравнение с/)).not.toBeInTheDocument();
name: "01.08.2026 – 27.08.2026",
});
expect(
screen.getByRole("region", { name: "01.08.2025 – 27.08.2025" }),
).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "Предыдущий период" }),
).toBeVisible();
expect(screen.queryByText("Выбранный период")).not.toBeInTheDocument();
expect(screen.queryByText("Сравниваемый период")).not.toBeInTheDocument();
expect(
screen.getByRole("heading", { level: 2, name: "Годом ранее" }),
).toBeInTheDocument();
expect(screen.queryByText("Сравнения")).not.toBeInTheDocument();
expect(screen.queryByText("RUB", { exact: true })).not.toBeInTheDocument();
for (const amount of ["10 ₽", "2 ₽", "12 ₽"]) {
expect(within(selected).getByText(amount)).toBeInTheDocument();
}
const difference = within(year).getByRole("region", { name: "Разница" });
for (const amount of ["+9 ₽", "+2 ₽", "+11 ₽"]) {
expect(within(difference).getByText(amount)).toBeInTheDocument();
}
expect(within(difference).getByText("+900 %")).toBeInTheDocument();
expect(within(difference).getByText("+1 100 %")).toBeInTheDocument();
expect(within(difference).queryByText("нет базы")).not.toBeInTheDocument();
expect(within(difference).getAllByText(/%$/)).toHaveLength(2);
expect(
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
within(difference).getByText("+2 ₽").closest("dd"),
).toHaveTextContent(/^\+2 ₽$/);
});
it("shows the unavailable year state without falling back to the previous period", async () => {
const dependencies = [
{ ...resource(analytics).meta, role: "selected" as const },
{ ...resource(analytics).meta, role: "previous" as const },
{
...resource(null).meta,
role: "year" as const,
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
},
];
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(resource(analytics, { dependencies })),
),
);
renderPage();
expect(
await screen.findByText("Сравнение с годом ранее недоступно."),
).toBeInTheDocument();
expect(
screen.queryByRole("region", { name: "Годом ранее" }),
).not.toBeInTheDocument();
expect(
screen.getByRole("region", { name: "Предыдущий период" }),
).toBeVisible();
}); });
it("shows a quiet realized error", async () => { it("shows a quiet realized error", async () => {
@@ -748,120 +681,3 @@ describe("income period and snapshot boundary", () => {
expect(screen.queryByRole("button")).not.toBeInTheDocument(); 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,
lastAttempt: {
finishedAt: "2026-08-14T00:01:00Z",
problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed" as const,
},
role: "year" as const,
},
{
...resource(analytics).meta,
role: "selected" as const,
},
];
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(resource(analytics, { dependencies })),
),
);
renderPage();
expect(
await screen.findByRole("region", { name: "Предыдущий период" }),
).toBeVisible();
expect(
screen.getByText(
"Не удалось загрузить период сравнения. Повторите обновление.",
),
).toBeVisible();
expect(
screen.queryByRole("region", { name: "Годом ранее" }),
).not.toBeInTheDocument();
});
@@ -91,7 +91,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
? { period, state: "error" } ? { period, state: "error" }
: { state: "loading" }; : { state: "loading" };
const display = toIncomeDisplayModel(analytics.data, analytics.meta); const display = toIncomeDisplayModel(analytics.data);
const onSubmit = (values: IncomeForm) => { const onSubmit = (values: IncomeForm) => {
const from = parseDraftDate(values.from); const from = parseDraftDate(values.from);
@@ -12,7 +12,6 @@ import { useTranslation } from "react-i18next";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm"; import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
import classes from "../AccountIncomePage.module.css"; import classes from "../AccountIncomePage.module.css";
import { IncomeComparisons } from "./IncomeComparisons.view";
import { IncomePayments } from "./IncomePayments.view"; import { IncomePayments } from "./IncomePayments.view";
import { IncomePeriodForm } from "./IncomePeriodForm/IncomePeriodForm.view"; import { IncomePeriodForm } from "./IncomePeriodForm/IncomePeriodForm.view";
import { IncomeSummary } from "./IncomeSummary.view"; import { IncomeSummary } from "./IncomeSummary.view";
@@ -54,18 +53,6 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
<Alert color="blue">{t("income.taxBoundary")}</Alert> <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} /> <IncomePayments entries={display.entries} />
</Stack> </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 };
@@ -1,6 +1,6 @@
import type { FC, SubmitEventHandler } from "react"; 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 { DatePickerInput } from "@mantine/dates";
import classes from "../../AccountIncomePage.module.css"; import classes from "../../AccountIncomePage.module.css";
@@ -27,15 +27,8 @@ const IncomePeriodForm: FC<IncomePeriodFormProps> = (props) => {
} = useIncomePeriodFormVm(); } = useIncomePeriodFormVm();
return ( return (
<Paper <form onSubmit={props.onSubmit}>
component="form"
onSubmit={props.onSubmit}
p="md"
radius="md"
withBorder
>
<Stack gap="sm"> <Stack gap="sm">
<Text fw={700}>{t("income.period")}</Text>
<div className={classes.fields}> <div className={classes.fields}>
<DatePickerInput <DatePickerInput
@@ -71,7 +64,7 @@ const IncomePeriodForm: FC<IncomePeriodFormProps> = (props) => {
</Button> </Button>
</div> </div>
</Stack> </Stack>
</Paper> </form>
); );
}; };
@@ -492,6 +492,20 @@ describe("AccountOperationsPage", () => {
}), }),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("keeps a null operations envelope loading instead of a confirmed empty period", async () => {
configureTestAuth();
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
HttpResponse.json(resource(null)),
),
);
renderPage();
expect(
await screen.findByRole("status", { name: "Загрузка" }, { timeout: 4000 }),
).toBeVisible();
expect(screen.queryByText("Операций нет")).not.toBeInTheDocument();
});
}); });
it("keeps UTC operation boundaries in GET and source sync requests", async () => { it("keeps UTC operation boundaries in GET and source sync requests", async () => {
@@ -402,6 +402,50 @@ describe("AccountPage states", () => {
).toBeInTheDocument(); ).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", () => { it("renders a stale portfolio in upstream order with nullable values hidden", () => {
renderPage(<AccountPageLoaded {...loadedProps()} />); renderPage(<AccountPageLoaded {...loadedProps()} />);
@@ -686,7 +730,7 @@ describe("AccountPage states", () => {
).toBeVisible(); ).toBeVisible();
for (const amount of ["850,50 ₽", "0 USD", "1 000 ₽", "Сумма неизвестна"]) for (const amount of ["850,50 ₽", "0 USD", "1 000 ₽", "Сумма неизвестна"])
expect(content.getByText(amount)).toBeVisible(); 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(content.queryByText(/^\+/)).not.toBeInTheDocument(); expect(content.queryByText(/^\+/)).not.toBeInTheDocument();
expect( expect(
+1 -6
View File
@@ -88,7 +88,6 @@ const quote
function setup( function setup(
options: { options: {
ambiguousTicker?: boolean; ambiguousTicker?: boolean;
failBond?: boolean;
failCoupons?: boolean; failCoupons?: boolean;
noPosition?: boolean; noPosition?: boolean;
path?: string; path?: string;
@@ -100,11 +99,7 @@ function setup(
const resolved = { ...bond, slug: options.slug ?? uid }; const resolved = { ...bond, slug: options.slug ?? uid };
let isFailQuote = false; let isFailQuote = false;
server.use( server.use(
http.get("*/api/v1/bonds/:uid", () => http.get("*/api/v1/bonds/:uid", () => HttpResponse.json(resource(resolved))),
options.failBond
? HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 })
: HttpResponse.json(resource(resolved)),
),
http.get("*/api/v1/bonds/by-ticker/:ticker", ({ request }) => { http.get("*/api/v1/bonds/by-ticker/:ticker", ({ request }) => {
requests.push(new URL(request.url)); requests.push(new URL(request.url));
@@ -191,8 +191,10 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
<BondCoupons <BondCoupons
coupons={vm.coupons} coupons={vm.coupons}
logoName={bond.logoName}
pastCoupons={vm.pastCoupons} pastCoupons={vm.pastCoupons}
state={vm.couponsState} state={vm.couponsState}
ticker={bond.ticker}
upcoming={vm.upcoming} upcoming={vm.upcoming}
/> />
@@ -1,17 +1,20 @@
import type { FC } from "react"; 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 { useTranslation } from "react-i18next";
import type { BondCoupons as Coupons } from "@/entities/bond"; import type { BondCoupons as Coupons } from "@/entities/bond";
import { InstrumentMetric, InstrumentSection, type SectionState } from "@/features/instrument-details"; 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";
type BondCouponsProps = { type BondCouponsProps = {
coupons?: Coupons; coupons?: Coupons;
logoName?: null | string;
pastCoupons: Coupons["coupons"]; pastCoupons: Coupons["coupons"];
state: SectionState; state: SectionState;
ticker?: null | string;
upcoming: Coupons["coupons"]; upcoming: Coupons["coupons"];
}; };
@@ -39,6 +42,8 @@ const BondCoupons: FC<BondCouponsProps> = (props) => {
<Coupon <Coupon
coupon={coupon} coupon={coupon}
key={`${coupon.number}:${coupon.paymentDate}`} key={`${coupon.number}:${coupon.paymentDate}`}
logoName={props.logoName}
ticker={props.ticker}
/> />
)) ))
) )
@@ -75,6 +80,8 @@ const BondCoupons: FC<BondCouponsProps> = (props) => {
<Coupon <Coupon
coupon={coupon} coupon={coupon}
key={`${coupon.number}:${coupon.paymentDate}`} key={`${coupon.number}:${coupon.paymentDate}`}
logoName={props.logoName}
ticker={props.ticker}
/> />
)) ))
) )
@@ -91,59 +98,48 @@ const BondCoupons: FC<BondCouponsProps> = (props) => {
type CouponProps = { type CouponProps = {
coupon: Coupons["coupons"][number]; coupon: Coupons["coupons"][number];
logoName?: null | string;
ticker?: null | string;
}; };
const Coupon: FC<CouponProps> = (props) => { const Coupon: FC<CouponProps> = (props) => {
const { coupon } = props; const { coupon } = props;
const { t } = useTranslation(); 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 ( return (
<Paper p="md" radius="md" withBorder> <PayoutCard
<Group align="start" justify="space-between"> conditions={conditions}
<div> estimate={coupon.estimatedAmount}
<Text fw={600}> imageSrc={instrumentLogoUrl(props.logoName)}
{coupon.paymentDate initial={(props.ticker?.trim()[0] ?? "?").toUpperCase()}
? formatDate(coupon.paymentDate) kind="schedule"
: t("stock.dateUnknown")} metadata={metadata}
</Text> perSecurity={coupon.perBond}
perSecurityLabel={t("bond.perBond")}
<Text c="dimmed" size="xs"> title={title}
{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>
); );
}; };
@@ -17,6 +17,7 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import { import {
BankFeedback,
bankOperationAppearance, bankOperationAppearance,
} from "@/entities/bank-card"; } from "@/entities/bank-card";
@@ -108,6 +109,17 @@ const CardHistory: FC<CardHistoryProps> = (props) => {
return <Text>{t("bank.noOperations")}</Text>; 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) { if (vm.history.loading) {
return ( return (
<LoadingSkeleton label={t("bank.loadingHistory")}> <LoadingSkeleton label={t("bank.loadingHistory")}>
+44 -9
View File
@@ -316,12 +316,47 @@ describe("CardPage", () => {
setup({ summaryError: true }); setup({ summaryError: true });
await screen.findByRole("heading", { name: "Повседневная" }); await screen.findByRole("heading", { name: "Повседневная" });
expect( expect(
screen.queryByRole("alert"), await screen.findByText("Перевод на «Запасная»"),
).not.toBeInTheDocument(); ).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( expect(
await screen.findByText("Перевод на «Запасная»"), await screen.findByText("Перевод на «Запасная»"),
).toBeInTheDocument(); ).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 () => { it("keeps cached card and history after refetch failure", async () => {
const { client } = setup(); const { client } = setup();
@@ -331,9 +366,8 @@ describe("CardPage", () => {
http.get("*/api/v1/bank-cards/a/operations", () => HttpResponse.error()), http.get("*/api/v1/bank-cards/a/operations", () => HttpResponse.error()),
); );
await client.invalidateQueries(); await client.invalidateQueries();
await waitFor(() => { const alerts = await screen.findAllByRole("alert", {}, { timeout: 5000 });
expect(screen.queryByRole("alert")).not.toBeInTheDocument(); expect(alerts.length).toBeGreaterThanOrEqual(1);
});
expect( expect(
screen.getByRole("heading", { name: "Повседневная" }), screen.getByRole("heading", { name: "Повседневная" }),
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -401,11 +435,11 @@ describe("CardPage", () => {
]); ]);
}); });
it("recovers the prerequisite card list while preserving the visible balance and journal", async () => { it("recovers the prerequisite card list while preserving the visible balance and journal", async () => {
setup({ cardsError: true }); const { client } = setup({ cardsError: true });
await screen.findByText("Перевод на «Запасная»"); await screen.findByText("Перевод на «Запасная»");
expect( expect(
screen.queryByRole("alert"), await screen.findByRole("alert", {}, { timeout: 5000 }),
).not.toBeInTheDocument(); ).toHaveTextContent("Не удалось загрузить данные карт.");
expect( expect(
screen.getByRole("button", { name: "Добавить операцию" }), screen.getByRole("button", { name: "Добавить операцию" }),
).toBeDisabled(); ).toBeDisabled();
@@ -414,6 +448,7 @@ describe("CardPage", () => {
HttpResponse.json([card, { ...card, id: "b", name: "Запасная" }]), HttpResponse.json([card, { ...card, id: "b", name: "Запасная" }]),
), ),
); );
await client.invalidateQueries();
await waitFor(() => await waitFor(() =>
expect( expect(
screen.getByRole("button", { name: "Добавить операцию" }), screen.getByRole("button", { name: "Добавить операцию" }),
+15 -3
View File
@@ -75,6 +75,11 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
{t("bank.back")} {t("bank.back")}
</Button> </Button>
{!vm.notFound && (
<Button onClick={vm.cardRetry} variant="default">
{t("bank.retry")}
</Button>
)}
</Group> </Group>
)} )}
error error
@@ -128,18 +133,27 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
)} )}
{card.archivedAt && <Text>{t("bank.archiveHint")}</Text>} {card.archivedAt && <Text>{t("bank.archiveHint")}</Text>}
{vm.cardError && vm.card && (
<BankFeedback
cached
error={vm.cardError}
retry={vm.cardRetry}
/>
)}
</Stack> </Stack>
{vm.cardsFeedback && ( {vm.cardsFeedback && (
<BankFeedback <BankFeedback
{...vm.cardsFeedback} {...vm.cardsFeedback}
loadingError loadingError
retry={vm.cardsRetry}
/> />
)} )}
<BankPeriodPicker onChange={vm.setPeriod} period={vm.period} /> <BankPeriodPicker onChange={vm.setPeriod} period={vm.period} />
<BankSummaryView {...vm.summary} /> <BankSummaryView {...vm.summary} retry={vm.summary.refetch} />
<Group align="end" justify="space-between"> <Group align="end" justify="space-between">
<Group gap="xs"> <Group gap="xs">
@@ -183,8 +197,6 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
<CardHistory history={vm.history} period={vm.period} rows={vm.rows} /> <CardHistory history={vm.history} period={vm.period} rows={vm.rows} />
</Stack> </Stack>
{vm.history.error && !vm.rows && <Text role="alert">{t("bank.error")}</Text>}
{vm.history.page !== undefined && vm.pageSize !== "all" && ( {vm.history.page !== undefined && vm.pageSize !== "all" && (
<Group <Group
aria-label={t("bank.pagination")} aria-label={t("bank.pagination")}
+7 -2
View File
@@ -97,13 +97,16 @@ function useCardPageVm() {
archivePending: updateCard.isPending, archivePending: updateCard.isPending,
canAddOperation: !card.data?.archivedAt && Boolean(cards.data), canAddOperation: !card.data?.archivedAt && Boolean(cards.data),
card: card.data, card: card.data,
cardError: card.error,
cardLoading: card.isLoading, cardLoading: card.isLoading,
cardRefreshError: card.isError, cardRefreshError: card.isError,
cardRefreshing: card.isFetching, cardRefreshing: card.isFetching,
cardRetry: () => card.refetch(),
cards: cards.data, cards: cards.data,
cardsFeedback: cards.isError cardsFeedback: cards.isError
? { cached: Boolean(cards.data), error: cards.error } ? { cached: Boolean(cards.data), error: cards.error }
: undefined, : undefined,
cardsRetry: () => cards.refetch(),
closeDeletion: () => setDeleting(null), closeDeletion: () => setDeleting(null),
closeEditing: () => setEditingCard(false), closeEditing: () => setEditingCard(false),
closeOperation: () => setOperation(null), closeOperation: () => setOperation(null),
@@ -112,9 +115,10 @@ function useCardPageVm() {
editingCard, editingCard,
history: { history: {
changingPage: history.isPlaceholderData, changingPage: history.isPlaceholderData,
error: history.isError, error: history.error,
loading: history.isLoading, loading: history.isLoading,
page: history.data?.page, page: history.data?.page,
refetch: () => history.refetch(),
}, },
notFound: isApiErrorCode( notFound: isApiErrorCode(
apiErrorCode(card.error), apiErrorCode(card.error),
@@ -139,9 +143,10 @@ function useCardPageVm() {
}, },
summary: { summary: {
data: summary.data, data: summary.data,
error: summary.isError, error: summary.error,
fetching: summary.isFetching, fetching: summary.isFetching,
loading: summary.isLoading, loading: summary.isLoading,
refetch: () => summary.refetch(),
}, },
toggleArchive: () => { toggleArchive: () => {
if (!card.data) return; if (!card.data) return;
@@ -47,8 +47,9 @@ function renderCards(isFail = false, hasCategoryFailure = false) {
), ),
); );
return render( const client = createTestQueryClient();
<QueryClientProvider client={createTestQueryClient()}> render(
<QueryClientProvider client={client}>
<MantineProvider env="test"> <MantineProvider env="test">
<MemoryRouter initialEntries={["/cards?month=2026-09"]}> <MemoryRouter initialEntries={["/cards?month=2026-09"]}>
<CardsPage /> <CardsPage />
@@ -56,6 +57,8 @@ function renderCards(isFail = false, hasCategoryFailure = false) {
</MantineProvider> </MantineProvider>
</QueryClientProvider>, </QueryClientProvider>,
); );
return client;
} }
describe("CardsPage", () => { describe("CardsPage", () => {
@@ -232,4 +235,76 @@ describe("CardsPage", () => {
}); });
expect(screen.queryByRole("alert")).not.toBeInTheDocument(); 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 { import {
BankCardFace, BankCardFace,
BankFeedback,
BankPeriodPicker, BankPeriodPicker,
BankSummaryView, BankSummaryView,
} from "@/entities/bank-card"; } 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 ( return (
<StateView <StateView
action={<Button onClick={vm.onAddCard}>{t("bank.addCard")}</Button>} action={<Button onClick={vm.onAddCard}>{t("bank.addCard")}</Button>}
text={text} text={text}
title={t("bank.empty")} title={t("bank.empty")}
/> />
); );
}
} }
if (vm.cards.isLoading) { if (vm.cards.isLoading) {
@@ -103,6 +106,15 @@ const CardsPageView: FC<CardsPageViewProps> = (props) => {
onChange={vm.onArchiveChange} 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} /> <CardsPageViewCards {...props} />
<Group align="flex-end" justify="space-between"> <Group align="flex-end" justify="space-between">
@@ -123,9 +135,10 @@ const CardsPageView: FC<CardsPageViewProps> = (props) => {
<BankSummaryView <BankSummaryView
all all
data={vm.summary.data} data={vm.summary.data}
error={vm.summary.isError} error={vm.summary.error}
fetching={vm.summary.isFetching} fetching={vm.summary.isFetching}
loading={vm.summary.isLoading} loading={vm.summary.isLoading}
retry={vm.retrySummary}
/> />
</Stack> </Stack>
); );
@@ -39,6 +39,8 @@ function useCardsPageVm() {
setArchive(event.currentTarget.checked), setArchive(event.currentTarget.checked),
onCategories: () => setManagingCategories(true), onCategories: () => setManagingCategories(true),
period, period,
retryCards: () => cards.refetch(),
retrySummary: () => summary.refetch(),
setPeriod: (value: [null | string, null | string]) => { setPeriod: (value: [null | string, null | string]) => {
if (value[0] && value[1]) setParameters({ from: value[0], to: value[1] }); if (value[0] && value[1]) setParameters({ from: value[0], to: value[1] });
}, },
@@ -337,7 +337,7 @@ describe("stock page", () => {
"/stocks/SBERP?period=year&events=trades", "/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 () => { it("offers search for ambiguous tickers without requesting personal data", async () => {
const { requests } = setup({ ambiguousTicker: true, path: "/stocks/SAME" }); const { requests } = setup({ ambiguousTicker: true, path: "/stocks/SAME" });
@@ -368,18 +368,18 @@ describe("stock page", () => {
screen.getByText("К закрытию предыдущего торгового дня, 09.09.2026"), screen.getByText("К закрытию предыдущего торгового дня, 09.09.2026"),
).toBeVisible(); ).toBeVisible();
expect(await screen.findByText("Зачисление дивидендов")).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("328,8 ₽")).toBeVisible();
expect(screen.getByText("P/E · TTM")).toBeVisible(); expect(screen.getByText("P/E · TTM")).toBeVisible();
expect(screen.getByText("Описание компании")).toBeVisible(); expect(screen.getByText("Описание компании")).toBeVisible();
expect( expect(
screen.queryByRole("button", { name: "О компании" }), screen.queryByRole("button", { name: "О компании" }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
expect(screen.getByText("13.10.2026")).toBeVisible(); expect(screen.getByText(/13\.10\.2026/)).toBeVisible();
fireEvent.click( fireEvent.click(
screen.getByRole("button", { name: "История дивидендов компании" }), 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(); expect(screen.getByText("Текущая средняя покупка: 200 ₽")).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: /Цены в таблице/ })); fireEvent.click(screen.getByRole("button", { name: /Цены в таблице/ }));
const rows = within(await screen.findByRole("table")).getAllByRole("row"); const rows = within(await screen.findByRole("table")).getAllByRole("row");
@@ -413,7 +413,9 @@ describe("stock page", () => {
expect(await screen.findByText("Продажа")).toBeVisible(); expect(await screen.findByText("Продажа")).toBeVisible();
expect(screen.getByText("2 шт.")).toBeVisible(); expect(screen.getByText("2 шт.")).toBeVisible();
expect(screen.getByText("Цена: 100 ₽")).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( expect(
requests.some( requests.some(
url => url =>
@@ -63,8 +63,10 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
<StockDividends <StockDividends
dividends={vm.dividends} dividends={vm.dividends}
logoName={stock.logoName}
pastDividends={vm.pastDividends} pastDividends={vm.pastDividends}
state={vm.dividendsState} state={vm.dividendsState}
ticker={stock.ticker}
upcoming={vm.upcoming} upcoming={vm.upcoming}
/> />
@@ -1,198 +1,96 @@
import type { FC } from "react"; import type { FC } from "react";
import { import { Accordion, Alert, Stack, Text } from "@mantine/core";
Accordion,
Alert,
Badge,
Group,
Paper,
Stack,
Text,
} from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { StockDividends as Dividends } from "@/entities/stock"; import type { StockDividends as Dividends } from "@/entities/stock";
import { InstrumentSection, type SectionState } from "@/features/instrument-details"; 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 = { type StockDividendsProps = {
dividends?: Dividends; dividends?: Dividends;
logoName?: null | string;
pastDividends: Dividends["dividends"]; pastDividends: Dividends["dividends"];
state: SectionState; state: SectionState;
ticker?: null | string;
upcoming: Dividends["dividends"]; 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 StockDividends: FC<StockDividendsProps> = (props) => {
const { dividends, pastDividends, state, upcoming } = props; const { dividends, pastDividends, state, upcoming } = props;
const { t } = useTranslation(); 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 ( return (
<InstrumentSection state={state} title={t("stock.upcoming")}> <InstrumentSection state={state} title={t("stock.upcoming")}>
{!!dividends && ( {!!dividends && (
<Stack gap="md"> <Stack gap="md">
{!!( {(dividends.estimateUnavailable || dividends.positionIsStale) && (
dividends.estimateUnavailable || dividends.positionIsStale
) && (
<Alert aria-live="polite" color="orange" role="status"> <Alert aria-live="polite" color="orange" role="status">
{t("stock.estimateUnavailable")} {t("stock.estimateUnavailable")}
</Alert> </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"> <Text c="dimmed" size="xs">{t("stock.dividendNote")}</Text>
{t("stock.dividendNote")}
</Text>
{!!dividends.positionAt && ( {!!dividends.positionAt && (
<Text c="dimmed" size="xs"> <Text c="dimmed" size="xs">
{t("stock.snapshotAt", { {t("stock.snapshotAt", { date: formatSnapshotTime(dividends.positionAt) })}
date: formatSnapshotTime(dividends.positionAt),
})}
</Text> </Text>
)} )}
@@ -210,7 +108,24 @@ const StockDividends: FC<StockDividendsProps> = (props) => {
})} })}
</Text> </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.Panel>
</Accordion.Item> </Accordion.Item>
</Accordion> </Accordion>
@@ -7,38 +7,69 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { StocksPage } from "./StocksPage"; import { StocksPage } from "./StocksPage";
import { useStocksPageVm } from "./StocksPage.vm";
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", () => { describe("stock search", () => {
it("waits for a meaningful query and uses the server-verified ticker link", async () => { it("waits for a meaningful query and uses the server-verified ticker link", async () => {
const query = vi.fn(); const query = vi.fn();
server.use( server.use(
http.get("*/api/v1/stocks", ({ request }) => { http.post("*/api/v1/stocks/screener/search", async ({ request }) => {
query(new URL(request.url).searchParams.get("query")); query(await request.json());
return HttpResponse.json( return HttpResponse.json({
resource([ asOf: "2026-10-10T10:00:00Z",
{ hasNext: false,
apiTradeAvailable: true, items: [{
classCode: "TQBR", countryOfRisk: "RU",
instrumentUid: "unique-stock", currency: "RUB",
isin: "RU0009029540", instrumentUid: "e6123145-9665-43e0-8413-cd61b8aa9b13",
logoName: "sber.png", isin: "RU0009029540",
name: "Сбербанк", logoName: "sber.png",
price: { currency: "RUB", value: "306.12" }, lot: 10,
priceAt: "2026-09-10T10:00:00Z", name: "Сбербанк",
realExchange: "REAL_EXCHANGE_MOEX", realExchange: "REAL_EXCHANGE_MOEX",
shareType: "SHARE_TYPE_COMMON", sector: "financial",
slug: "SBER", shareType: "SHARE_TYPE_COMMON",
ticker: "SBER", ticker: "SBER",
}, }],
]), page: 1,
); searchId: "search-id",
});
}), }),
); );
configureAuthClient({ configureAuthClient({
@@ -46,7 +77,6 @@ describe("stock search", () => {
clear: vi.fn(), clear: vi.fn(),
refresh: () => Promise.resolve(false), refresh: () => Promise.resolve(false),
}); });
vi.useFakeTimers();
render( render(
<QueryClientProvider client={createTestQueryClient()}> <QueryClientProvider client={createTestQueryClient()}>
<MantineProvider> <MantineProvider>
@@ -56,23 +86,217 @@ describe("stock search", () => {
</MantineProvider> </MantineProvider>
</QueryClientProvider>, </QueryClientProvider>,
); );
const input = screen.getByLabelText("Поиск акций"); const input = screen.getByLabelText("Название или тикер");
fireEvent.change(input, { target: { value: "S" } });
await act(() => vi.advanceTimersByTimeAsync(300));
expect(query).not.toHaveBeenCalled();
fireEvent.change(input, { target: { value: "SB" } });
await act(() => vi.advanceTimersByTimeAsync(299));
expect(query).not.toHaveBeenCalled();
// The final input uses real timers for network and query notifications.
vi.useRealTimers();
fireEvent.change(input, { target: { value: "SBER" } }); fireEvent.change(input, { target: { value: "SBER" } });
expect(query).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Найти" }));
expect( expect(
await screen.findByRole("link", { name: /Сбербанк/ }), await screen.findByRole("link", { name: /Сбербанк/ }),
).toHaveAttribute("href", "/stocks/SBER"); ).toHaveAttribute("href", "/stocks/e6123145-9665-43e0-8413-cd61b8aa9b13");
expect(screen.getByText("306,12 ₽")).toBeVisible(); const snapshotTime = new Intl.DateTimeFormat("ru-RU", { dateStyle: "short", timeStyle: "short" });
expect(screen.getByText("Режим: TQBR")).toBeVisible(); 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("ISIN: RU0009029540")).toBeVisible();
expect(screen.getByText("Мосбиржа")).toBeVisible(); expect(screen.getAllByText("Мосбиржа")[0]).toBeVisible();
await waitFor(() => expect(query).toHaveBeenCalledExactlyOnceWith("SBER")); expect(screen.getByAltText("")).toHaveAttribute(
"src",
"https://invest-brands.cdn-tinkoff.ru/sberx160.png",
);
await waitFor(() => expect(query).toHaveBeenCalledExactlyOnceWith({ query: "SBER" }));
});
it("searches by the selected sector without a text query", async () => {
const query = vi.fn();
server.use(
http.post("*/api/v1/stocks/screener/search", async ({ request }) => {
query(await request.json());
return HttpResponse.json({
asOf: "2026-10-10T10:00:00Z",
hasNext: false,
items: [],
page: 1,
searchId: "sector-search",
});
}),
);
configureAuthClient({
accessToken: () => "synthetic",
clear: vi.fn(),
refresh: () => Promise.resolve(false),
});
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider>
<MemoryRouter>
<StocksPage />
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("combobox", { name: "Сектор" }));
fireEvent.click(await screen.findByRole("option", { name: "Энергетика" }));
fireEvent.click(screen.getByRole("button", { name: "Найти" }));
await waitFor(() => expect(query).toHaveBeenCalledExactlyOnceWith({
sector: "energy",
}));
});
it("shows known result pages and reads the selected page", async () => {
const requestedPages = vi.fn();
server.use(
http.post("*/api/v1/stocks/screener/search", () => HttpResponse.json({
asOf: "2026-10-10T10:00:00Z",
hasNext: true,
items: [],
page: 1,
searchId: "paged-search",
})),
http.get("*/api/v1/stocks/screener/search/paged-search", ({ request }) => {
const page = Number(new URL(request.url).searchParams.get("page"));
requestedPages(page);
return HttpResponse.json({
asOf: "2026-10-10T10:00:00Z",
hasNext: false,
items: [],
page,
searchId: "paged-search",
});
}),
);
configureAuthClient({
accessToken: () => "synthetic",
clear: vi.fn(),
refresh: () => Promise.resolve(false),
});
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider>
<MemoryRouter>
<StocksPage />
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "Найти" }));
fireEvent.click(await screen.findByRole(
"button",
{ name: "Страница 2" },
));
await waitFor(() =>
expect(requestedPages).toHaveBeenCalledExactlyOnceWith(2),
);
});
it("keeps a newer search when an earlier page request finishes late", async () => {
const oldPage = Promise.withResolvers<void>();
let searches = 0;
server.use(
http.post("*/api/v1/stocks/screener/search", () => {
searches += 1;
return HttpResponse.json({
asOf: "2026-10-10T10:00:00Z",
hasNext: searches === 1,
items: [{
countryOfRisk: "RU",
currency: "RUB",
instrumentUid: searches === 1 ? "old" : "new",
isin: null,
logoName: null,
lot: 1,
name: searches === 1 ? "Старый" : "Новый",
realExchange: "REAL_EXCHANGE_MOEX",
sector: "financial",
shareType: "SHARE_TYPE_COMMON",
ticker: searches === 1 ? "OLD" : "NEW",
}],
page: 1,
searchId: searches === 1 ? "old-search" : "new-search",
});
}),
http.get("*/api/v1/stocks/screener/search/old-search", async () => {
await oldPage.promise;
return HttpResponse.json({
asOf: "2026-10-10T10:00:00Z",
hasNext: false,
items: [{
countryOfRisk: "RU",
currency: "RUB",
instrumentUid: "old-page",
isin: null,
logoName: null,
lot: 1,
name: "Старый результат",
realExchange: "REAL_EXCHANGE_MOEX",
sector: "financial",
shareType: "SHARE_TYPE_COMMON",
ticker: "OLD",
}],
page: 2,
searchId: "old-search",
});
}),
);
render(<StocksPageVmProbe />);
fireEvent.click(screen.getByRole("button", { name: "search" }));
await screen.findByText("Старый");
fireEvent.click(screen.getByRole("button", { name: "page" }));
fireEvent.click(screen.getByRole("button", { name: "search" }));
await screen.findByText("Новый");
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 type { ChangeEvent, FC } from "react";
import { Badge, Group, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { import {
InstrumentSearch, Alert,
InstrumentSearchCard, Anchor,
} from "@/features/instrument-search"; Avatar,
import { formatMoney, formatSnapshotTime } from "@/shared/lib"; Badge,
Button,
Card,
Fieldset,
Group,
NumberInput,
Pagination,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type { StocksPageVm } from "./StocksPage.vm"; import { formatSnapshotTime, instrumentLogoUrl } from "@/shared/lib";
import type { Criteria, StocksPageVm } from "./StocksPage.vm";
import { useStocksPagePresentationVm } from "./StocksPagePresentation.vm";
type CriteriaField = {
label: keyof Criteria;
options?: readonly string[];
type?: "number";
};
type StockCardProps = { item: StocksPageVm["items"][number] };
type StockCriteriaFieldProps = {
criteria: Criteria;
field: CriteriaField;
onChange: (name: keyof Criteria, value: string) => void;
};
type StocksPageViewProps = { vm: StocksPageVm }; type StocksPageViewProps = { vm: StocksPageVm };
const criteriaFields: CriteriaField[] = [
{ label: "query" },
{
label: "sector",
options: [
"consumer",
"energy",
"financial",
"health_care",
"industrials",
"it",
"materials",
"real_estate",
"telecom",
"utilities",
],
},
{
label: "shareType",
options: [
"SHARE_TYPE_ADR",
"SHARE_TYPE_COMMON",
"SHARE_TYPE_GDR",
"SHARE_TYPE_PREFERRED",
],
},
{ label: "currency", options: ["RUB", "USD", "EUR", "CNY", "HKD", "TRY"] },
{
label: "realExchange",
options: [
"REAL_EXCHANGE_MOEX",
"REAL_EXCHANGE_RTS",
"REAL_EXCHANGE_OTC",
"REAL_EXCHANGE_DEALER",
],
},
{ label: "countryOfRisk", options: ["RU", "US", "CN", "KZ", "HK", "TR"] },
{ label: "maxLot", type: "number" },
];
function numberChange(
field: keyof Criteria,
onChange: StockCriteriaFieldProps["onChange"],
) {
return (value: number | string) => onChange(field, String(value));
}
function selectChange(
field: keyof Criteria,
onChange: StockCriteriaFieldProps["onChange"],
) {
return (value: null | string) => onChange(field, value ?? "");
}
function selectOptions(field: CriteriaField, translate: ReturnType<typeof useTranslation>["t"]) {
return field.options?.map(value => ({
label: translate(`stock.screener.options.${field.label}.${value}`, {
defaultValue: value,
}),
value,
}));
}
function textChange(
field: keyof Criteria,
onChange: StockCriteriaFieldProps["onChange"],
) {
return (event: ChangeEvent<HTMLInputElement>) =>
onChange(field, event.currentTarget.value);
}
const StockCriteriaField: FC<StockCriteriaFieldProps> = (props) => {
const { criteria, field, onChange } = props;
const { t } = useTranslation();
const options = selectOptions(field, t);
if (field.type === "number") return (
<NumberInput
label={t(`stock.screener.${field.label}`)}
min={1}
onChange={numberChange(field.label, onChange)}
value={criteria[field.label]}
/>
);
if (options) return (
<Select
clearable
data={options}
label={t(`stock.screener.${field.label}`)}
onChange={selectChange(field.label, onChange)}
value={criteria[field.label] || null}
/>
);
return (
<TextInput
label={t(`stock.screener.${field.label}`)}
onChange={textChange(field.label, onChange)}
value={criteria[field.label]}
/>
);
};
const StockCard: FC<StockCardProps> = (props) => {
const { item } = props;
const { t } = useTranslation();
const exchange = item.realExchange
? t(`stock.exchanges.${item.realExchange}`, {
defaultValue: item.realExchange,
})
: undefined;
const lot = item.lot === null
? undefined
: t("stock.lot", { value: item.lot });
return (
<Card p="md" withBorder>
<Group gap="sm" wrap="nowrap">
<Avatar alt="" radius="md" size={40} src={instrumentLogoUrl(item.logoName)}>
{item.name[0]}
</Avatar>
<Stack gap={2} miw={0}>
<Anchor
c="inherit"
component={Link}
fw={600}
to={`/stocks/${item.instrumentUid}`}
>
{item.name}
</Anchor>
<Text c="dimmed" size="xs">
{item.ticker ?? "-"} · {item.shareType ?? "-"}
</Text>
<Group gap="xs">
{exchange && <Badge color="gray" variant="light">{exchange}</Badge>}
{item.currency && (
<Badge color="gray" variant="light">{item.currency}</Badge>
)}
{lot && <Text c="dimmed" size="xs">{lot}</Text>}
</Group>
{item.isin && (
<Text c="dimmed" size="xs">
{t("stock.isin", { value: item.isin })}
</Text>
)}
</Stack>
</Group>
</Card>
);
};
const StocksPageView: FC<StocksPageViewProps> = (props) => { const StocksPageView: FC<StocksPageViewProps> = (props) => {
const { vm } = props; const { vm } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const { getControlProps, getItemProps } = useStocksPagePresentationVm();
return ( return (
<InstrumentSearch kind="stock" vm={vm}> <Stack gap="lg">
{vm.items.map((stock) => { <div>
const quote = ( <Title order={1}>{t("stock.screenerTitle")}</Title>
<>
<Text fw={650} size="xl">
{stock.price ? formatMoney(stock.price) : "—"}
</Text>
<Text c="dimmed" size="xs"> <Text c="dimmed" mt="xs">{t("stock.screenerSubtitle")}</Text>
{stock.price && stock.priceAt </div>
? t("stock.priceAt", {
date: formatSnapshotTime(stock.priceAt),
})
: t("stock.priceUnavailable")}
</Text>
{stock.apiTradeAvailable === true && ( <Fieldset disabled={vm.loading} variant="unstyled">
<Text c="green.8" mt="sm" size="xs"> <SimpleGrid cols={{ base: 1, sm: 2 }}>
{t("stock.apiAvailable")} {criteriaFields.map(field => (
</Text> <StockCriteriaField
)} criteria={vm.criteria}
</> field={field}
); key={field.label}
onChange={vm.onCriteriaChange}
/>
))}
</SimpleGrid>
return ( <Group mt="lg">
<InstrumentSearchCard <Button loading={vm.loading} onClick={vm.onSearch}>
instrument={stock} {t("stock.screener.search")}
key={stock.instrumentUid} </Button>
quote={quote} </Group>
> </Fieldset>
<Text c="dimmed" size="sm">
{stock.ticker} ·{" "}
{t(`stock.shareTypes.${stock.shareType}`, {
defaultValue: t("stock.share"),
})}
</Text>
<Group gap="xs" mt="sm"> {!!vm.error && (
<Badge color="gray" variant="light"> <Alert color="orange" role="status">{t("stock.screener.error")}</Alert>
{t(`stock.exchanges.${stock.realExchange}`, { )}
defaultValue: t("stock.exchangeUnknown"),
})}
</Badge>
{!!stock.classCode && ( {vm.resultTime && (
<Text c="dimmed" size="xs"> <Text c="dimmed" size="sm">
{t("stock.classCode", { code: stock.classCode })} {t("stock.screener.asOf", {
</Text> date: formatSnapshotTime(vm.resultTime),
)} })}
</Group> </Text>
)}
{!!stock.isin && ( {vm.searched && !vm.loading && !vm.error && vm.items.length === 0 && (
<Text c="dimmed" mt={4} size="xs"> <Text c="dimmed">{t("stock.screener.empty")}</Text>
{t("stock.isin", { value: stock.isin })} )}
</Text>
)}
{stock.apiTradeAvailable === false && ( <Stack gap="sm">
<Text c="dimmed" mt={4} size="xs"> {vm.items.map(item => (
{t("stock.apiUnavailable")} <StockCard item={item} key={item.instrumentUid} />
</Text> ))}
)} </Stack>
</InstrumentSearchCard>
); {vm.totalPages > 1 && (
})} <Group aria-label={t("stock.screener.pagination")} component="nav" justify="center">
</InstrumentSearch> <Pagination
disabled={vm.loading}
getControlProps={getControlProps}
getItemProps={getItemProps}
onChange={vm.onPage}
total={vm.totalPages}
value={vm.page ?? 1}
/>
</Group>
)}
</Stack>
); );
}; };
+113 -34
View File
@@ -1,43 +1,122 @@
import { MIN_STOCK_SEARCH_LENGTH, useStockSearch } from "@/entities/stock"; import { useRef, useState } from "react";
import { useInstrumentSearch } from "@/features/instrument-search";
import { useScreenSync } from "@/features/tbank-connection"; import { apiClient } from "@/shared/api";
import { apiErrorCode } from "@/shared/api";
import { instrumentLogoUrl } from "@/shared/lib"; type Criteria = {
countryOfRisk: string;
currency: string;
maxLot: string;
query: string;
realExchange: string;
sector: string;
shareType: string;
};
type ScreenerPage = Awaited<ReturnType<typeof readPage>>;
type StocksPageVm = ReturnType<typeof useStocksPageVm>; type StocksPageVm = ReturnType<typeof useStocksPageVm>;
const emptyCriteria: Criteria = {
countryOfRisk: "",
currency: "",
maxLot: "",
query: "",
realExchange: "",
sector: "",
shareType: "",
};
async function createSearch(criteria: Criteria) {
const { data } = await apiClient.POST("/api/v1/stocks/screener/search", {
body: {
...(criteria.countryOfRisk && { countryOfRisk: criteria.countryOfRisk }),
...(criteria.currency && { currency: criteria.currency }),
...(criteria.maxLot && { maxLot: Number(criteria.maxLot) }),
...(criteria.query && { query: criteria.query }),
...(criteria.realExchange && { realExchange: criteria.realExchange }),
...(criteria.sector && { sector: criteria.sector }),
...(criteria.shareType && { shareType: criteria.shareType }),
},
});
if (!data) throw new Error("Stock screener search failed");
return data;
}
async function readPage(searchId: string, page: number) {
const { data } = await apiClient.GET(
"/api/v1/stocks/screener/search/{searchId}",
{ params: { path: { searchId }, query: { page } } },
);
if (!data) throw new Error("Stock screener page failed");
return data;
}
function useStocksPageVm() { function useStocksPageVm() {
const search = useInstrumentSearch(MIN_STOCK_SEARCH_LENGTH); const [criteria, setCriteria] = useState(emptyCriteria);
const { canSearch, debounced, isWaiting, query } = search; const [page, setPage] = useState<null | ScreenerPage>(null);
const result = useStockSearch(debounced); const [pending, setPending] = useState(false);
useScreenSync({ query: debounced, screen: "stocks" }, [result]); const [error, setError] = useState(false);
const requestIdRef = useRef(0);
const search = async () => {
const currentRequestId = requestIdRef.current + 1;
requestIdRef.current = currentRequestId;
setPending(true);
setError(false);
try {
const result = await createSearch(criteria);
if (requestIdRef.current === currentRequestId) setPage(result);
}
catch {
if (requestIdRef.current === currentRequestId) setError(true);
}
finally {
if (requestIdRef.current === currentRequestId) setPending(false);
}
};
const changePage = async (nextPage: number) => {
if (!page) return;
const currentRequestId = requestIdRef.current + 1;
requestIdRef.current = currentRequestId;
setPending(true);
setError(false);
try {
const result = await readPage(page.searchId, nextPage);
if (requestIdRef.current === currentRequestId) setPage(result);
}
catch {
if (requestIdRef.current === currentRequestId) setError(true);
}
finally {
if (requestIdRef.current === currentRequestId) setPending(false);
}
};
return { return {
canSearch, changePage,
code: apiErrorCode(result.error), criteria,
error: error,
!result.data items: page?.items ?? [],
&& (result.isError || result.meta?.lastAttempt.state === "failed"), loading: pending,
items: onCriteriaChange: (name: keyof Criteria, value: string) =>
result.data?.map(stock => ({ setCriteria(current => ({ ...current, [name]: value })),
...stock, onPage: (nextPage: number) => void changePage(nextPage),
logoSrc: instrumentLogoUrl(stock.logoName), onSearch: () => void search(),
to: `/stocks/${encodeURIComponent(stock.slug)}`, page: page?.page,
})) ?? [], resultTime: page?.asOf,
loading: // Test seam: settle the actual operation instead of the handler release.
canSearch search,
&& (isWaiting searched: page !== null,
|| (!result.data totalPages: page ? page.page + Number(page.hasNext) : 0,
&& !result.isError
&& result.meta?.lastAttempt.state !== "failed")),
onQueryChange: search.onQueryChange,
partial: result.meta?.completeness === "incomplete",
query,
ready:
debounced.length >= MIN_STOCK_SEARCH_LENGTH
&& !isWaiting
&& Boolean(result.data),
}; };
} }
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 };
+46
View File
@@ -67,6 +67,25 @@ describe("apiClient authentication", () => {
expect(clear).toHaveBeenCalledOnce(); 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 () => { it("does not attach Bearer auth to authentication endpoints", async () => {
let authorization: null | string 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 () => { it("does not retry mutations", async () => {
let requests = 0; let requests = 0;
server.use( server.use(
+6 -1
View File
@@ -32,6 +32,10 @@ type AuthHandlers = {
clear: () => void; clear: () => void;
generation?: () => number; generation?: () => number;
refresh: () => Promise<boolean>; refresh: () => Promise<boolean>;
/**
True after a refresh failed transiently (network/5xx): the session is not proven gone.
*/
unavailable?: () => boolean;
}; };
type ReadAttempt = { type ReadAttempt = {
@@ -100,7 +104,8 @@ async function authenticatedFetch(request: Request): Promise<Response> {
if (!isCurrent()) throw new DOMException("Session changed", "AbortError"); if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
if (!(await sessionState.handlers.refresh())) { if (!(await sessionState.handlers.refresh())) {
if (isCurrent()) sessionState.handlers.clear(); if (isCurrent() && !sessionState.handlers.unavailable?.())
sessionState.handlers.clear();
return response; return response;
} }
+216 -17
View File
@@ -436,6 +436,38 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/stocks/screener/search": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["createStockScreenerSearch"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/stocks/screener/search/{searchId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["readStockScreenerPage"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/stocks/{uid}": { "/api/v1/stocks/{uid}": {
parameters: { parameters: {
query?: never; query?: never;
@@ -714,10 +746,13 @@ export interface components {
detail: string; detail: string;
}[]; }[];
}; };
CredentialsDto: { RegisterDto: {
/** @example user@example.com */ /** @example user@example.com */
email: string; email: string;
/** @example a secure password */ /**
* @description Raw password: 8-72 printable ASCII characters (letters, digits, symbols, space). Not trimmed or normalized.
* @example a secure password
*/
password: string; password: string;
}; };
UserDto: { UserDto: {
@@ -728,6 +763,15 @@ export interface components {
accessToken: string; accessToken: string;
user: components["schemas"]["UserDto"]; user: components["schemas"]["UserDto"];
}; };
LoginDto: {
/** @example user@example.com */
email: string;
/**
* @description Raw password, at most 72 UTF-8 bytes. Not trimmed or normalized.
* @example a secure password
*/
password: string;
};
MoneyDto: { MoneyDto: {
currency: string; currency: string;
value: string; value: string;
@@ -1075,16 +1119,6 @@ export interface components {
data: components["schemas"]["IncomeSummaryDto"] | null; data: components["schemas"]["IncomeSummaryDto"] | null;
meta: components["schemas"]["InvestmentResourceMetaDto"]; meta: components["schemas"]["InvestmentResourceMetaDto"];
}; };
IncomeComparisonCurrencyDto: {
currency: string;
selected: components["schemas"]["IncomeCurrencySummaryDto"];
compared: components["schemas"]["IncomeCurrencySummaryDto"];
difference: components["schemas"]["IncomeCurrencySummaryDto"];
};
IncomeComparisonDto: {
period: components["schemas"]["IncomePeriodDto"];
totalsByCurrency: components["schemas"]["IncomeComparisonCurrencyDto"][];
};
IncomeBucketDto: { IncomeBucketDto: {
period: components["schemas"]["IncomePeriodDto"]; period: components["schemas"]["IncomePeriodDto"];
totalsByCurrency: components["schemas"]["IncomeCurrencySummaryDto"][]; totalsByCurrency: components["schemas"]["IncomeCurrencySummaryDto"][];
@@ -1128,8 +1162,6 @@ export interface components {
totalsByCurrency: components["schemas"]["IncomeCurrencySummaryDto"][]; totalsByCurrency: components["schemas"]["IncomeCurrencySummaryDto"][];
/** Format: date-time */ /** Format: date-time */
calculatedAt: string; calculatedAt: string;
previous: components["schemas"]["IncomeComparisonDto"] | null;
yearComparison: components["schemas"]["IncomeComparisonDto"] | null;
/** @enum {string} */ /** @enum {string} */
bucketGranularity: "day" | "week" | "month"; bucketGranularity: "day" | "week" | "month";
buckets: components["schemas"]["IncomeBucketDto"][]; buckets: components["schemas"]["IncomeBucketDto"][];
@@ -1151,7 +1183,7 @@ export interface components {
lastSuccessfulAt: string | null; lastSuccessfulAt: string | null;
lastAttempt: components["schemas"]["InvestmentAttemptDto"]; lastAttempt: components["schemas"]["InvestmentAttemptDto"];
/** @enum {string} */ /** @enum {string} */
role: "selected" | "previous" | "year"; role: "selected";
}; };
IncomeAnalyticsMetaDto: { IncomeAnalyticsMetaDto: {
dataRevision: string; dataRevision: string;
@@ -1250,6 +1282,37 @@ export interface components {
data: components["schemas"]["StockDto"] | null; data: components["schemas"]["StockDto"] | null;
meta: components["schemas"]["InvestmentResourceMetaDto"]; meta: components["schemas"]["InvestmentResourceMetaDto"];
}; };
StockScreenerCriteriaDto: {
query?: string;
sector?: string;
shareType?: string;
currency?: string;
realExchange?: string;
countryOfRisk?: string;
maxLot?: number;
};
StockScreenerItemDto: {
/** Format: uuid */
instrumentUid: string;
name: string;
ticker: string | null;
isin: string | null;
logoName: string | null;
shareType: string | null;
sector: string | null;
currency: string | null;
realExchange: string | null;
countryOfRisk: string | null;
lot: number | null;
};
StockScreenerPageDto: {
searchId: string;
/** Format: date-time */
asOf: string;
page: number;
hasNext: boolean;
items: components["schemas"]["StockScreenerItemDto"][];
};
StockQuoteDto: { StockQuoteDto: {
price: components["schemas"]["MoneyDto"] | null; price: components["schemas"]["MoneyDto"] | null;
/** Format: date-time */ /** Format: date-time */
@@ -1657,7 +1720,7 @@ export interface operations {
}; };
requestBody: { requestBody: {
content: { content: {
"application/json": components["schemas"]["CredentialsDto"]; "application/json": components["schemas"]["RegisterDto"];
}; };
}; };
responses: { responses: {
@@ -1725,7 +1788,7 @@ export interface operations {
}; };
requestBody: { requestBody: {
content: { content: {
"application/json": components["schemas"]["CredentialsDto"]; "application/json": components["schemas"]["LoginDto"];
}; };
}; };
responses: { responses: {
@@ -4077,6 +4140,142 @@ export interface operations {
}; };
}; };
}; };
createStockScreenerSearch: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["StockScreenerCriteriaDto"];
};
};
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["StockScreenerPageDto"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Upstream service unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
};
};
readStockScreenerPage: {
parameters: {
query: {
page: number;
};
header?: never;
path: {
searchId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["StockScreenerPageDto"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Upstream service unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
};
};
StockController_get: { StockController_get: {
parameters: { parameters: {
query?: { query?: {
+8 -2
View File
@@ -164,10 +164,16 @@ describe("persistent resource query lifecycle", () => {
}); });
await waitFor(() => expect(isStarted).toBe(true)); await waitFor(() => expect(isStarted).toBe(true));
await act(() => result.current.observe()); // Capture the observation promise before releasing the old handler; the
// final assertion must follow its settlement, not the handler release.
let observation: Promise<unknown> = Promise.resolve();
await act(async () => {
observation = result.current.observe() ?? Promise.resolve();
await observation;
});
await act(async () => { await act(async () => {
release(); release();
await blocked; await observation;
}); });
expect(result.current.data).toEqual(snapshot); expect(result.current.data).toEqual(snapshot);
}); });
+335 -69
View File
@@ -10,7 +10,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/CredentialsDto" "$ref": "#/components/schemas/RegisterDto"
} }
} }
} }
@@ -91,7 +91,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/CredentialsDto" "$ref": "#/components/schemas/LoginDto"
} }
} }
} }
@@ -3333,6 +3333,188 @@
] ]
} }
}, },
"/api/v1/stocks/screener/search": {
"post": {
"operationId": "createStockScreenerSearch",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StockScreenerCriteriaDto"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StockScreenerPageDto"
}
}
}
},
"400": {
"description": "Invalid request",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetailsDto"
}
}
}
},
"401": {
"description": "Authentication required",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetailsDto"
}
}
}
},
"404": {
"description": "Resource not found",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetailsDto"
}
}
}
},
"500": {
"description": "Internal server error",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetailsDto"
}
}
}
},
"503": {
"description": "Upstream service unavailable",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetailsDto"
}
}
}
}
},
"security": [
{
"bearer": []
}
],
"summary": "",
"tags": [
"stocks"
]
}
},
"/api/v1/stocks/screener/search/{searchId}": {
"get": {
"operationId": "readStockScreenerPage",
"parameters": [
{
"name": "searchId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "page",
"required": true,
"in": "query",
"schema": {
"minimum": 1,
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StockScreenerPageDto"
}
}
}
},
"400": {
"description": "Invalid request",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetailsDto"
}
}
}
},
"401": {
"description": "Authentication required",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetailsDto"
}
}
}
},
"404": {
"description": "Resource not found",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetailsDto"
}
}
}
},
"500": {
"description": "Internal server error",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetailsDto"
}
}
}
},
"503": {
"description": "Upstream service unavailable",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetailsDto"
}
}
}
}
},
"security": [
{
"bearer": []
}
],
"summary": "",
"tags": [
"stocks"
]
}
},
"/api/v1/stocks/{uid}": { "/api/v1/stocks/{uid}": {
"get": { "get": {
"operationId": "StockController_get", "operationId": "StockController_get",
@@ -5054,7 +5236,7 @@
"code" "code"
] ]
}, },
"CredentialsDto": { "RegisterDto": {
"type": "object", "type": "object",
"properties": { "properties": {
"email": { "email": {
@@ -5063,8 +5245,8 @@
}, },
"password": { "password": {
"type": "string", "type": "string",
"minLength": 12, "description": "Raw password: 8-72 printable ASCII characters (letters, digits, symbols, space). Not trimmed or normalized.",
"maxLength": 72, "pattern": "^[\\x20-\\x7E]{8,72}$",
"example": "a secure password" "example": "a secure password"
} }
}, },
@@ -5103,6 +5285,24 @@
"user" "user"
] ]
}, },
"LoginDto": {
"type": "object",
"properties": {
"email": {
"type": "string",
"example": "user@example.com"
},
"password": {
"type": "string",
"description": "Raw password, at most 72 UTF-8 bytes. Not trimmed or normalized.",
"example": "a secure password"
}
},
"required": [
"email",
"password"
]
},
"MoneyDto": { "MoneyDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -6524,47 +6724,6 @@
"meta" "meta"
] ]
}, },
"IncomeComparisonCurrencyDto": {
"type": "object",
"properties": {
"currency": {
"type": "string"
},
"selected": {
"$ref": "#/components/schemas/IncomeCurrencySummaryDto"
},
"compared": {
"$ref": "#/components/schemas/IncomeCurrencySummaryDto"
},
"difference": {
"$ref": "#/components/schemas/IncomeCurrencySummaryDto"
}
},
"required": [
"currency",
"selected",
"compared",
"difference"
]
},
"IncomeComparisonDto": {
"type": "object",
"properties": {
"period": {
"$ref": "#/components/schemas/IncomePeriodDto"
},
"totalsByCurrency": {
"type": "array",
"items": {
"$ref": "#/components/schemas/IncomeComparisonCurrencyDto"
}
}
},
"required": [
"period",
"totalsByCurrency"
]
},
"IncomeBucketDto": { "IncomeBucketDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -6766,24 +6925,6 @@
"type": "string", "type": "string",
"format": "date-time" "format": "date-time"
}, },
"previous": {
"nullable": true,
"type": "object",
"allOf": [
{
"$ref": "#/components/schemas/IncomeComparisonDto"
}
]
},
"yearComparison": {
"nullable": true,
"type": "object",
"allOf": [
{
"$ref": "#/components/schemas/IncomeComparisonDto"
}
]
},
"bucketGranularity": { "bucketGranularity": {
"type": "string", "type": "string",
"enum": [ "enum": [
@@ -6816,8 +6957,6 @@
"period", "period",
"totalsByCurrency", "totalsByCurrency",
"calculatedAt", "calculatedAt",
"previous",
"yearComparison",
"bucketGranularity", "bucketGranularity",
"buckets", "buckets",
"instruments", "instruments",
@@ -6874,9 +7013,7 @@
"role": { "role": {
"type": "string", "type": "string",
"enum": [ "enum": [
"selected", "selected"
"previous",
"year"
] ]
} }
}, },
@@ -7310,6 +7447,135 @@
"meta" "meta"
] ]
}, },
"StockScreenerCriteriaDto": {
"type": "object",
"properties": {
"query": {
"type": "string",
"maxLength": 80
},
"sector": {
"type": "string",
"minLength": 1
},
"shareType": {
"type": "string",
"minLength": 1
},
"currency": {
"type": "string",
"minLength": 1
},
"realExchange": {
"type": "string",
"minLength": 1
},
"countryOfRisk": {
"type": "string",
"minLength": 1
},
"maxLot": {
"type": "integer",
"minimum": 1
}
},
"additionalProperties": false
},
"StockScreenerItemDto": {
"type": "object",
"properties": {
"instrumentUid": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"ticker": {
"type": "string",
"nullable": true
},
"isin": {
"type": "string",
"nullable": true
},
"logoName": {
"type": "string",
"nullable": true
},
"shareType": {
"type": "string",
"nullable": true
},
"sector": {
"type": "string",
"nullable": true
},
"currency": {
"type": "string",
"nullable": true
},
"realExchange": {
"type": "string",
"nullable": true
},
"countryOfRisk": {
"type": "string",
"nullable": true
},
"lot": {
"type": "integer",
"nullable": true,
"minimum": 1
}
},
"required": [
"instrumentUid",
"name",
"ticker",
"isin",
"logoName",
"shareType",
"sector",
"currency",
"realExchange",
"countryOfRisk",
"lot"
]
},
"StockScreenerPageDto": {
"type": "object",
"properties": {
"searchId": {
"type": "string"
},
"asOf": {
"type": "string",
"format": "date-time"
},
"page": {
"type": "integer",
"minimum": 1
},
"hasNext": {
"type": "boolean"
},
"items": {
"maxItems": 30,
"type": "array",
"items": {
"$ref": "#/components/schemas/StockScreenerItemDto"
}
}
},
"required": [
"searchId",
"asOf",
"page",
"hasNext",
"items"
]
},
"StockQuoteDto": { "StockQuoteDto": {
"type": "object", "type": "object",
"properties": { "properties": {
+67 -13
View File
@@ -53,10 +53,14 @@ const ru = {
passwordConfirmation: "Подтвердите пароль", passwordConfirmation: "Подтвердите пароль",
register: "Регистрация", register: "Регистрация",
requestError: "Не удалось выполнить запрос. Попробуйте ещё раз.", requestError: "Не удалось выполнить запрос. Попробуйте ещё раз.",
retry: "Повторить",
unavailableText: "Сервер временно недоступен. Ваш вход сохранён, попробуйте через минуту.",
unavailableTitle: "Не удалось подключиться",
validation: { validation: {
invalidEmail: "Некорректный email", invalidEmail: "Некорректный email",
invalidPasswordLength: "Пароль должен содержать от 12 до 72 байт", invalidPasswordLength: "Пароль: от 8 до 72 символов, только латинские буквы, цифры, спецсимволы и пробел",
passwordsDoNotMatch: "Пароли не совпадают", passwordsDoNotMatch: "Пароли не совпадают",
passwordTooLong: "Пароль не должен превышать 72 байта",
}, },
}, },
bank: { bank: {
@@ -340,13 +344,8 @@ const ru = {
}, },
income: { income: {
apply: "Применить", apply: "Применить",
comparisonEmpty: "За эти периоды купоны и дивиденды не найдены.",
comparisonFailed:
"Не удалось загрузить период сравнения. Повторите обновление.",
comparisonPending: "Период сравнения ещё не загружен полностью.",
coupon: "Купоны", coupon: "Купоны",
datePlaceholder: "ДД.ММ.ГГГГ", datePlaceholder: "ДД.ММ.ГГГГ",
difference: "Разница",
dividend: "Дивиденды", dividend: "Дивиденды",
emptyText: "За выбранный период подходящих исполненных выплат нет.", emptyText: "За выбранный период подходящих исполненных выплат нет.",
emptyTitle: "Купоны и дивиденды не найдены", emptyTitle: "Купоны и дивиденды не найдены",
@@ -356,8 +355,6 @@ const ru = {
open: "Открыть пассивный доход", open: "Открыть пассивный доход",
payments: "Выплаты", payments: "Выплаты",
period: "Период", period: "Период",
previousPeriod: "Предыдущий период",
previousUnavailable: "Сравнение с предыдущим периодом недоступно.",
refreshError: refreshError:
"Не удалось обновить пассивный доход. Показан последний загруженный результат.", "Не удалось обновить пассивный доход. Показан последний загруженный результат.",
refreshing: "Обновляем пассивный доход", refreshing: "Обновляем пассивный доход",
@@ -378,8 +375,6 @@ const ru = {
period: period:
"Укажите корректный период без будущих дат: начало не позже окончания.", "Укажите корректный период без будущих дат: начало не позже окончания.",
}, },
yearAgo: "Годом ранее",
yearUnavailable: "Сравнение с годом ранее недоступно.",
}, },
investment: { investment: {
attempt: { attempt: {
@@ -425,7 +420,7 @@ const ru = {
next: "Далее", next: "Далее",
noInstrument: "Без инструмента", noInstrument: "Без инструмента",
noMoney: "Без движения денег", noMoney: "Без движения денег",
occurredAt: "{{date}} UTC", occurredAt: "{{date}}",
open: "Открыть журнал", open: "Открыть журнал",
operationType: { operationType: {
buy: "Покупка", buy: "Покупка",
@@ -459,6 +454,14 @@ const ru = {
stocks: "Акции", stocks: "Акции",
}, },
notFound: { action: "К счетам", title: "Страница не найдена" }, notFound: { action: "К счетам", title: "Страница не найдена" },
payout: {
estimate: "Оценка по позиции",
executedAt: "Исполнено {{date}}",
forecast: "Прогноз",
noAmount: "Сумма неизвестна",
noMoney: "Без движения денег",
registryDate: "Дата реестра",
},
portfolio: { portfolio: {
accountUnavailableText: "Вернитесь к списку и выберите доступный счет.", accountUnavailableText: "Вернитесь к списку и выберите доступный счет.",
accountUnavailableTitle: "Счет недоступен", accountUnavailableTitle: "Счет недоступен",
@@ -629,13 +632,64 @@ const ru = {
position: "Моя позиция", position: "Моя позиция",
previous: "Назад", previous: "Назад",
price: "Цена", price: "Цена",
priceAt: "Цена на {{date}} UTC", priceAt: "Цена на {{date}}",
priceTable: "Цены в таблице", priceTable: "Цены в таблице",
priceTableHint: "Дневные закрытия · записей: {{count}} · сначала новые", priceTableHint: "Дневные закрытия · записей: {{count}} · сначала новые",
priceUnavailable: "Цена недоступна", priceUnavailable: "Цена недоступна",
quantity: "Количество акций", quantity: "Количество акций",
record: "Дата реестра", record: "Дата реестра",
refreshPrice: "Обновить цену", refreshPrice: "Обновить цену",
screener: {
asOf: "Список получен {{date}}",
countryOfRisk: "Страна риска",
currency: "Валюта расчётов",
empty: "Подходящие акции не найдены.",
error: "Не удалось получить результат скринера. Попробуйте найти снова.",
maxLot: "Максимальный размер лота",
options: {
countryOfRisk: {
CN: "Китай",
HK: "Гонконг",
KZ: "Казахстан",
RU: "Россия",
TR: "Турция",
US: "США",
},
realExchange: {
REAL_EXCHANGE_DEALER: "Площадка брокера",
REAL_EXCHANGE_MOEX: "Мосбиржа",
REAL_EXCHANGE_OTC: "Внебиржевой рынок",
REAL_EXCHANGE_RTS: "СПБ Биржа",
},
sector: {
consumer: "Потребительский сектор",
energy: "Энергетика",
financial: "Финансы",
health_care: "Здравоохранение",
industrials: "Промышленность",
it: "Информационные технологии",
materials: "Материалы",
real_estate: "Недвижимость",
telecom: "Телекоммуникации",
utilities: "Коммунальные услуги",
},
shareType: {
SHARE_TYPE_ADR: "Депозитарная расписка",
SHARE_TYPE_COMMON: "Обыкновенная акция",
SHARE_TYPE_GDR: "Глобальная депозитарная расписка",
SHARE_TYPE_PREFERRED: "Привилегированная акция",
},
},
page: "Страница {{page}}",
pagination: "Страницы результатов",
query: "Название или тикер",
realExchange: "Площадка",
search: "Найти",
sector: "Сектор",
shareType: "Тип акции",
},
screenerSubtitle: "Подберите акции по данным каталога T-Bank. Доступность не гарантирует исполнение сделки.",
screenerTitle: "Скринер акций",
searchHint: "Введите хотя бы два символа.", searchHint: "Введите хотя бы два символа.",
searching: "Ищем акции", searching: "Ищем акции",
searchLabel: "Поиск акций", searchLabel: "Поиск акций",
@@ -667,7 +721,7 @@ const ru = {
SHARE_TYPE_GDR: "Депозитарная расписка", SHARE_TYPE_GDR: "Депозитарная расписка",
SHARE_TYPE_PREFERRED: "Привилегированная акция", SHARE_TYPE_PREFERRED: "Привилегированная акция",
}, },
snapshotAt: "Позиция на {{date}} UTC", snapshotAt: "Позиция на {{date}}",
trading: { trading: {
SECURITY_TRADING_STATUS_BREAK_IN_TRADING: "Перерыв в торгах", SECURITY_TRADING_STATUS_BREAK_IN_TRADING: "Перерыв в торгах",
SECURITY_TRADING_STATUS_CLOSING_AUCTION: "Аукцион закрытия", SECURITY_TRADING_STATUS_CLOSING_AUCTION: "Аукцион закрытия",
+2 -2
View File
@@ -38,11 +38,11 @@ describe("calendar dates", () => {
expect(moscowDate(new Date("2026-12-31T21:00:00Z"))).toBe("2027-01-01"); expect(moscowDate(new Date("2026-12-31T21:00:00Z"))).toBe("2027-01-01");
}); });
it("defaults to the last 7 days including today", () => { it("defaults to 7 calendar dates starting with today", () => {
const [from, to] = defaultPeriod(); const [from, to] = defaultPeriod();
expect(calendarDate.safeParse(from).success).toBe(true); expect(calendarDate.safeParse(from).success).toBe(true);
expect(to).toBe(moscowDate(new Date())); expect(from).toBe(moscowDate(new Date()));
expect((Date.parse(to) - Date.parse(from)) / 86_400_000).toBe(6); expect((Date.parse(to) - Date.parse(from)) / 86_400_000).toBe(6);
expect(from <= to).toBe(true); expect(from <= to).toBe(true);
}); });
+10 -6
View File
@@ -9,8 +9,7 @@ function isValidPeriod(from: null | string, to: null | string) {
); );
} }
const DAY_MS = 86_400_000; const DEFAULT_PERIOD_LAST_INDEX = 6;
const DEFAULT_PERIOD_DAYS = 6;
const moscowFormatter = new Intl.DateTimeFormat("en-CA", { const moscowFormatter = new Intl.DateTimeFormat("en-CA", {
day: "2-digit", day: "2-digit",
@@ -25,12 +24,17 @@ function compareDateDesc(a: null | string, b: null | string) {
return (b ?? "").localeCompare(a ?? ""); return (b ?? "").localeCompare(a ?? "");
} }
// Last 7 days including today. An empty period always means this range. // 7 calendar dates starting with today (Moscow calendar arithmetic).
function defaultPeriod(): [string, string] { function defaultPeriod(): [string, string] {
const to = new Date(); const from = moscowDate(new Date());
const from = new Date(to.getTime() - DEFAULT_PERIOD_DAYS * DAY_MS);
return [moscowDate(from), moscowDate(to)]; const [year = 1970, month = 0, day = 1] = from.split("-").map(Number);
const noon = 12;
const lastDay = day + DEFAULT_PERIOD_LAST_INDEX;
const end = new Date(Date.UTC(year, month - 1, lastDay, noon));
const to = moscowDate(end);
return [from, to];
} }
function moscowDate(value: Date): string { function moscowDate(value: Date): string {
+10 -3
View File
@@ -12,9 +12,16 @@ describe("shared display helpers", () => {
expect(formatDate("2027-09-10")).toBe("10.09.2027"); expect(formatDate("2027-09-10")).toBe("10.09.2027");
expect(formatDate("2024-02-29")).toBe("29.02.2024"); expect(formatDate("2024-02-29")).toBe("29.02.2024");
expect(formatDate("2027-09-10T00:30:00+03:00")).toBe("09.09.2027"); expect(formatDate("2027-09-10T00:30:00+03:00")).toBe("09.09.2027");
expect(formatSnapshotTime("2027-09-10T00:30:00+03:00")).toBe( });
"09.09.2027, 21:30", it("formats snapshot time in the browser time zone", () => {
); const instant = "2027-09-10T00:30:00+03:00";
const expected = new Intl.DateTimeFormat("ru-RU", {
dateStyle: "short",
timeStyle: "short",
}).format(new Date(instant));
expect(formatSnapshotTime(instant)).toBe(expected);
}); });
it("formats decimal strings without financial arithmetic", () => { it("formats decimal strings without financial arithmetic", () => {
expect(formatMoney({ currency: "RUB", value: "-1234567.89" })).toBe( expect(formatMoney({ currency: "RUB", value: "-1234567.89" })).toBe(
+1 -1
View File
@@ -61,10 +61,10 @@ function formatMoney(
return `${formatDecimal(decimal)} ${currency}`; return `${formatDecimal(decimal)} ${currency}`;
} }
// Без timeZone Intl берёт часовой пояс браузера пользователя.
const snapshotFormatter = new Intl.DateTimeFormat("ru-RU", { const snapshotFormatter = new Intl.DateTimeFormat("ru-RU", {
dateStyle: "short", dateStyle: "short",
timeStyle: "short", timeStyle: "short",
timeZone: "UTC",
}); });
function formatSnapshotTime(value: string): string { function formatSnapshotTime(value: string): string {
@@ -0,0 +1,85 @@
import type { FC } from "react";
import { MantineProvider } from "@mantine/core";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { PayoutCard } from "./PayoutCard";
const Wrapper: FC<{ children: React.ReactNode }> = props => (
<MantineProvider env="test">{props.children}</MantineProvider>
);
describe("PayoutCard", () => {
it("renders an executed payout with signed source money", () => {
render(
<Wrapper>
<PayoutCard
initial="К"
kind="executed"
occurredAt="01.08.2026 12:00"
payment={{ currency: "RUB", value: "-10.25" }}
title="Купон"
typeLabel="Купон"
/>
</Wrapper>,
);
expect(screen.getByText("−10,25 ₽")).toBeInTheDocument();
expect(screen.getByText(/Исполнено 01.08.2026 12:00/)).toBeInTheDocument();
});
it("keeps zero distinct from an unknown executed amount", () => {
render(
<Wrapper>
<PayoutCard
initial="Д"
kind="executed"
occurredAt="01.08.2026"
payment={{ currency: "RUB", value: "0.000" }}
title="Дивиденд"
typeLabel="Дивиденд"
/>
</Wrapper>,
);
expect(screen.getByText("0,000 ₽")).toBeInTheDocument();
expect(screen.getByText("Без движения денег")).toBeInTheDocument();
});
it("marks a forecast payout and a dividend registry date without inventing an amount", () => {
render(
<Wrapper>
<PayoutCard
date="15.08.2026"
dateKind="registry"
initial="S"
kind="forecast"
projectedAmount={null}
title="Дивиденд · SBER"
/>
</Wrapper>,
);
expect(screen.getByText("Прогноз")).toBeInTheDocument();
expect(screen.getByText(/15.08.2026 · Дата реестра/)).toBeInTheDocument();
expect(screen.getByText("Сумма неизвестна")).toBeInTheDocument();
});
it("shows a schedule payout with per-security label and separate estimate", () => {
render(
<Wrapper>
<PayoutCard
estimate={{ currency: "RUB", value: "125" }}
initial="S"
kind="schedule"
metadata="Дата выплаты 04.10.2026"
perSecurity={{ currency: "RUB", value: "12.5" }}
perSecurityLabel="На облигацию"
title="Купон · SU26238RMFS4"
/>
</Wrapper>,
);
expect(screen.getByText("12,5 ₽")).toBeInTheDocument();
expect(screen.getByText("На облигацию")).toBeInTheDocument();
expect(screen.getByText("Оценка по позиции")).toBeInTheDocument();
expect(screen.getByText("125 ₽")).toBeInTheDocument();
});
});
+166
View File
@@ -0,0 +1,166 @@
import type { FC, ReactNode } from "react";
import { Group, Text } from "@mantine/core";
import { EntryCard } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import { formatMoney } from "@/shared/lib";
type ExecutedPayout = {
details?: ReactNode;
extraMetadata?: string;
imageSrc?: null | string;
initial: string;
kind: "executed";
occurredAt: string;
payment: null | PayoutMoney;
title: string;
typeLabel: string;
wrap?: boolean;
};
type ForecastPayout = {
date: string;
dateKind?: "event" | "registry";
extraMetadata?: string;
imageSrc?: null | string;
initial: string;
kind: "forecast";
projectedAmount: null | PayoutMoney;
title: string;
};
type PayoutCardProps
= | ExecutedPayout
| ForecastPayout
| SchedulePayout;
type PayoutMoney = { currency: string; value: string };
type PayoutTone
= | "negative"
| "neutral"
| "positive";
type SchedulePayout = {
conditions?: ReactNode;
estimate: null | PayoutMoney;
imageSrc?: null | string;
initial: string;
kind: "schedule";
metadata: string;
perSecurity: null | PayoutMoney;
perSecurityLabel: string;
title: string;
};
const PayoutCard: FC<PayoutCardProps> = (props) => {
const { t } = useTranslation();
const common = {
imageSrc: props.imageSrc,
initial: props.initial,
title: props.title,
wrap: true,
};
if (props.kind === "executed") {
const payment = props.payment;
const presentation = executedPresentation(payment);
const isKnown = payment !== null && !isZeroDecimal(payment.value);
const metadata = [
t("payout.executedAt", { date: props.occurredAt }),
props.extraMetadata,
].filter(Boolean).join(" · ");
const valueLabel = isKnown ? props.typeLabel : t("payout.noMoney");
return (
<EntryCard
{...common}
details={props.details}
metadata={metadata}
tone={presentation.tone}
value={presentation.amount}
valueLabel={valueLabel}
wrap={props.wrap}
/>
);
}
if (props.kind === "forecast") {
const metadata = [
props.date,
props.dateKind === "registry" ? t("payout.registryDate") : null,
props.extraMetadata,
].filter(Boolean).join(" · ");
const value = props.projectedAmount
? formatMoney(props.projectedAmount)
: t("payout.noAmount");
return (
<EntryCard
{...common}
metadata={metadata}
tone="neutral"
value={value}
valueLabel={t("payout.forecast")}
/>
);
}
const details = (
<>
{props.estimate
? (
<Group gap="sm">
<Text c="dimmed" size="xs">{t("payout.estimate")}</Text>
<Text fw={600} size="xs">{formatMoney(props.estimate)}</Text>
</Group>
)
: null}
{props.conditions}
</>
);
const value = props.perSecurity
? formatMoney(props.perSecurity)
: t("payout.noAmount");
return (
<EntryCard
{...common}
details={details}
metadata={props.metadata}
tone="neutral"
value={value}
valueLabel={props.perSecurityLabel}
/>
);
};
function executedPresentation(payment: null | PayoutMoney): {
amount: string;
tone: PayoutTone;
} {
if (!payment) return { amount: "—", tone: "neutral" };
if (isZeroDecimal(payment.value))
return { amount: formatMoney(payment), tone: "neutral" };
if (payment.value.startsWith("-"))
return {
amount: `−${formatMoney({ ...payment, value: payment.value.slice(1) })}`,
tone: "negative",
};
return { amount: `+${formatMoney(payment)}`, tone: "positive" };
}
function isZeroDecimal(value: string): boolean {
// eslint-disable-next-line security/detect-unsafe-regex -- Anchored decimal check over a bounded Money string, with no nested repetition.
return /^-?0(?:\.0+)?$/.test(value);
}
export { PayoutCard };
+1
View File
@@ -1 +1,2 @@
export { PayoutCard } from "./PayoutCard/PayoutCard";
export { SourceSyncStatus } from "./SourceSyncStatus/SourceSyncStatus"; export { SourceSyncStatus } from "./SourceSyncStatus/SourceSyncStatus";