12 Commits
Author SHA1 Message Date
ksv741 c7d8d2c8f9 ci: validate release PR commits independently (WealthLedger/main#30)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend image / publish (push) Successful in 6m45s
Frontend verify / verify (push) Successful in 6m7s
Frontend verify / commits (pull_request) Successful in 37s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m58s
2026-10-12 23:39:30 +03:00
ksv741 9d900b331a chore(release): 1.1.0
Frontend verify / commits (pull_request) Failing after 42s
Frontend verify / security (pull_request) Skipped
Frontend verify / verify (pull_request) Skipped
2026-10-12 23:19:12 +03:00
codex-bot b357743c10 Merge pull request 'chore: align frontend engineering workflow (WealthLedger/main#27)' (#66) from chore/27-engineering-workflow into develop
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 7m9s
2026-10-12 11:00:35 +03:00
ksv741 0d747cc93e docs: pin frontend workflow reference (WealthLedger/main#27)
Frontend verify / commits (pull_request) Successful in 39s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 6m7s
2026-10-12 10:22:13 +03:00
ksv741 c315f80b1b fix: align frontend workflow rules (WealthLedger/main#27)
Frontend verify / commits (pull_request) Successful in 48s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Canceled after 1m36s
2026-10-12 10:19:36 +03:00
ksv741 617edfd489 chore: align frontend engineering workflow (WealthLedger/main#27)
Frontend verify / commits (pull_request) Successful in 35s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Failing after 3m11s
2026-10-12 09:20:13 +03:00
codex-bot 9ad3245efe Merge pull request 'docs: clarify frontend verification policy' (#65) from codex/issue-24-process into develop
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 6m56s
2026-10-11 23:23:10 +03:00
ksv741 cac4351bc4 docs: clarify frontend verification policy
Frontend verify / commits (pull_request) Successful in 41s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Successful in 6m34s
2026-10-11 23:06:51 +03:00
codex-bot 17f5bae69b feat: add stock screener
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 12s
Frontend verify / verify (push) Successful in 6m39s
2026-10-11 11:50:39 +03:00
ksv741 c8821200ce ci: skip checks on PR metadata edits
Frontend verify / commits (pull_request) Successful in 34s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m37s
2026-10-11 11:36:51 +03:00
ksv741 cec9c6348a fix: preserve latest stock screener result
Frontend verify / commits (pull_request) Successful in 35s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m47s
2026-10-11 11:11:58 +03:00
ksv741 a84411df39 feat: add stock screener 2026-10-11 11:11:58 +03:00
25 changed files with 2895 additions and 1745 deletions
+28 -14
View File
@@ -7,11 +7,19 @@
checkout. Внешний API задаётся предоставленным публичным контрактом; checkout. Внешний API задаётся предоставленным публичным контрактом;
исходники или инструкции его реализации не являются зависимостью разработки. исходники или инструкции его реализации не являются зависимостью разработки.
При нехватке требования сообщи, какое решение не определено, до его реализации. При нехватке требования сообщи, какое решение не определено, до его реализации.
При конфликте с общим шаблоном следуй местным правилам и явному запросу
пользователя. Выбирай реализацию самостоятельно, но изменение финансовой
логики, публичного контракта или утверждённой семантики возвращай владельцу
на согласование до зависимой работы.
Начни с результата и affected areas. До edits проследи flow/invariants, найди Начни с результата и affected areas. До edits проследи flow/invariants, найди
owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт. owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт.
Работай напрямую; OpenSpec/SDD lifecycle — только по явному запросу. Выбери lightweight или SDD по [Engineering Workflow](https://git.personal.ksv741.ru/WealthLedger/main/src/commit/03b3b59e6ff2ba391f9dfc42a039a96d0d84c1c9/.ai/conventions/engineering-workflow.md);
Перед Node-командами активируй местную `.nvmrc`: `nvm use --silent`. OpenSpec lifecycle начинай после явного решения Change Owner.
Перед Node-командами активируй местную `.nvmrc`: если `nvm` не загружен в
non-interactive shell, загрузи установленный `$HOME/.nvm/nvm.sh`; затем
выполни `nvm use --silent` из frontend. Если `nvm.sh` недоступен, остановись,
не используй системный Node.
Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы. Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы.
## Change scope ## Change scope
@@ -57,21 +65,27 @@ Peer-конфликт решай совместимыми версиями бе
## Проверки и завершение ## Проверки и завершение
Code/dependency change: `npm ci`, полный `npm run verify` и дополнительные | Когда | Что проверить |
risk checks из местной testing convention. Targeted tests ускоряют итерацию, | --- | --- |
но не заменяют финальный gate. `lint:fix`/format — edits, не proof. | Окружение новое, изменились manifest/lockfile или Node, установка сомнительна | `npm ci` до Node-проверок; одна правка кода переустановки не требует. |
Markdown-only: проверь новые/изменённые тексты, локальные ссылки/якоря, маршруты | Итерация | Адресные тесты, typecheck и lint по затронутому поведению и риску. При изменении поведения добавь/обнови содержательную регрессию, если существующая проверка не ловит её; для косметики не создавай искусственный тест. |
чтения и `git diff --check`; application build ради текста не нужен. | Итоговый code/dependency diff | Перед объявлением готовности и передачей к интеграции выполни один полный `npm run verify` на итоговых входах. Дополнительные risk checks из testing convention и явно утверждённые task checks обязательны; CI проверяет независимо. |
Повтори routing по staged/unstaged/new files. Запиши краткие команды, результаты | Только Markdown | Проверь тексты, ссылки/якоря, маршруты чтения и `git diff --check`; application build не нужен. При изменении verification-контура проверь его работу. |
и ограничения. Зелёный lint не заменяет semantic/security review.
Повтори routing по staged/unstaged/new files. В отчёте по задаче/PR запиши
проверенные входы, команду, exit code, результат и ограничения. Повторяй
проверку при изменении значимых входов (включая config, dependencies и
generated artifacts), сомнении в evidence или явном запросе пользователя.
`lint:fix`/format — edits, не proof; lint не заменяет semantic/security review.
## Git и релизы ## Git и релизы
Работа — в `codex/<task>` от актуального `develop`, затем PR в `develop`. Работа — в ветке `<type>/<issue-number>-<slug>` от актуального `develop`,
затем PR в `develop`; для cross-repo ссылки укажи owner/repo#number.
Сохраняй чужие изменения; перед commit просмотри staged paths и diff. Сохраняй чужие изменения; перед commit просмотри staged paths и diff.
Commits и PR titles — Conventional Commits; допустим `spec:`. Каждый commit Commits и PR titles — Conventional Commits с parser-recognized Issue reference (`fix: … (#12)`
содержит одну связанную задачу. При связанной issue добавь её номер в конец или `chore: … (WealthLedger/main#27)`); допустим `spec:`. Каждый commit
заголовка (`fix: … #12`); без issue номер не нужен. Root другого checkout содержит одну связанную задачу. Исключения release и правила ссылки — в
не управляет этим repo. Engineering Workflow из AGENTS. Root другого checkout не управляет этим repo.
Прямой push в `main`, force-push и перенос опубликованных тегов не входят в Прямой push в `main`, force-push и перенос опубликованных тегов не входят в
обычную работу. Перед release/hotfix прочитай местный README, раздел «Релизы». обычную работу. Перед release/hotfix прочитай местный README, раздел «Релизы».
+12
View File
@@ -0,0 +1,12 @@
---
name: Bug Report
about: Неверное поведение
---
## Проблема и шаги воспроизведения
## Факт / ожидание
## Acceptance Criteria
- [ ]
## Окружение / ссылки (если применимо)
+10
View File
@@ -0,0 +1,10 @@
---
name: Engineering Task
about: CI, тесты, документация, процесс и прочее
---
## Контекст и результат
## Scope / связанные Issues (если есть)
## Acceptance Criteria
- [ ]
+12
View File
@@ -0,0 +1,12 @@
---
name: Feature Request
about: Новое наблюдаемое поведение
---
## Зачем и какой результат
## Ожидаемое поведение
## Acceptance Criteria
- [ ]
## Зависимости / связанные Issues (если есть)
+12
View File
@@ -0,0 +1,12 @@
---
name: Refactoring / Technical Debt
about: Улучшение без нового поведения
---
## Проблема и результат
## Сохраняемые инварианты / границы
## Acceptance Criteria
- [ ]
## Зависимости / ссылки (если есть)
+11
View File
@@ -0,0 +1,11 @@
## Summary
## Linked Issue
Refs
## Changes
## Verification
- Команда / результат / ограничения:
## Breaking Changes (если есть)
+6 -2
View File
@@ -4,7 +4,7 @@ on:
push: push:
branches: [develop, main] branches: [develop, main]
pull_request: pull_request:
types: [opened, synchronize, reopened, edited] types: [opened, synchronize, reopened]
schedule: schedule:
- cron: '17 5 * * *' - cron: '17 5 * * *'
@@ -34,7 +34,11 @@ jobs:
HEAD_SHA: ${{ github.event.pull_request.head.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: | run: |
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA" if [[ "$PR_TITLE" =~ ^chore\(release\):\ [0-9]+\.[0-9]+\.[0-9]+$ ]]; then
npm run commitlint -- --strict --from "$HEAD_SHA^" --to "$HEAD_SHA"
else
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
fi
security: security:
needs: commits needs: commits
+3
View File
@@ -4,6 +4,9 @@
только его файлы и предоставленные публичные контракты. Перед работой прочитай только его файлы и предоставленные публичные контракты. Перед работой прочитай
[порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем [порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем
объединяй triggers задачи и путей; читай активированные темы до решения. объединяй triggers задачи и путей; читай активированные темы до решения.
До edits найди/создай подходящую Gitea Issue, проверь AC и ветку; чтение
допускается без Issue. Общий маршрут — [Engineering Workflow](https://git.personal.ksv741.ru/WealthLedger/main/src/commit/03b3b59e6ff2ba391f9dfc42a039a96d0d84c1c9/.ai/conventions/engineering-workflow.md)
(main#28; после merge — актуальная версия `develop`).
| Решение / сигнал | Читать | | Решение / сигнал | Читать |
| --- | --- | | --- | --- |
+27
View File
@@ -1,5 +1,32 @@
# Changelog # Changelog
## [1.1.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.0.0...v1.1.0) (2026-10-12)
### Features
* add stock screener ([a84411d](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/a84411df39dc131290e9f5b5d9313fed17cc629a))
* align investment client with hourly sync ([432cca2](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/432cca299f3a9a9e45b2697527369ee95f2d0adc))
* render hourly investment states ([3c226f8](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/3c226f8c29efceccb75af0d6424639a757534f39))
### Bug Fixes
* align frontend workflow rules (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/27)) ([c315f80](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c315f80b1b07ae0888366512ec157b8e29c81d62))
* preserve latest stock screener result ([cec9c63](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/cec9c6348a389f16f272dac5533aebc9d12ceae8))
* show bank summary totals ([20b2cdf](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/20b2cdfd432f8b01e447f68657cf5c685a297f1e))
### Documentation
* clarify frontend verification policy ([cac4351](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/cac4351bc4abf2cec7c5b6611a74d87bf4ec2f70))
* pin frontend workflow reference (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/27)) ([0d747cc](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/0d747cc93ee08c337cf300c0367612708f45dfb9))
### CI
* skip checks on PR metadata edits ([c882120](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c8821200ce699dab1ce65360d1968d5877a2e78b))
### Maintenance
* align frontend engineering workflow (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/27)) ([617edfd](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/617edfd489a6a84bb33b57386eca4eb54661f4f0))
## [1.0.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v0.4.0...v1.0.0) (2026-10-07) ## [1.0.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v0.4.0...v1.0.0) (2026-10-07)
### ⚠ BREAKING CHANGES ### ⚠ BREAKING CHANGES
+1 -1
View File
@@ -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.28.0-alpine AS production
ARG VERSION ARG VERSION
ARG REVISION ARG REVISION
LABEL org.opencontainers.image.version=$VERSION \ LABEL org.opencontainers.image.version=$VERSION \
+6 -3
View File
@@ -203,7 +203,8 @@ Checkout frontend содержит все входы сборки и прове
локальную копию и не обращаются к исходникам или CI сервера. Доставка новой локальную копию и не обращаются к исходникам или CI сервера. Доставка новой
схемы — явное обновление snapshot с review и `npm run openapi:generate`. схемы — явное обновление snapshot с review и `npm run openapi:generate`.
Runtime URL API задаётся окружением, а не расположением другого репозитория. Runtime URL API задаётся окружением, а не расположением другого репозитория.
Разработка идёт напрямую по задаче пользователя; OpenSpec/SDD — по явному запросу. Разработка начинается с Issue; lightweight или SDD выбирается по критериям
Engineering Workflow из AGENTS. OpenSpec — после явного решения Change Owner.
Knip исключает `conventional-changelog-conventionalcommits`: release-it загружает Knip исключает `conventional-changelog-conventionalcommits`: release-it загружает
preset по имени из `.release-it.json`. Версия preset закреплена напрямую. preset по имени из `.release-it.json`. Версия preset закреплена напрямую.
@@ -211,9 +212,11 @@ preset по имени из `.release-it.json`. Версия preset закреп
## Релизы ## Релизы
Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые
релизы. Работа: `codex/<task>` от актуального `develop`, PR в `develop`. релизы. Обычная работа: issue-linked ветка от актуального `develop`, PR в `develop`;
общие правила — в Engineering Workflow по ссылке из AGENTS.
Обычные PR допускают squash; release и обратная синхронизация сохраняют историю. Обычные PR допускают squash; release и обратная синхронизация сохраняют историю.
Commits и PR titles — Conventional Commits, включая `spec:`. Версии вручную не повышай. Commits и PR titles — Conventional Commits со ссылкой на Issue (кроме release),
включая `spec:`. Версии вручную не повышай.
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что 1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что
`develop` содержит последний релиз. Создай `release/<name>` от проверенного `develop` содержит последний релиз. Создай `release/<name>` от проверенного
+6
View File
@@ -1,6 +1,12 @@
module.exports = { module.exports = {
defaultIgnores: false,
extends: ["@commitlint/config-conventional"], extends: ["@commitlint/config-conventional"],
ignores: [
message => /^chore\(release\): \d+\.\d+\.\d+$/.test(message.trim()),
message => /^Merge pull request ['"].+['"] \(#\d+\) from .+ into .+$/.test(message.trim()),
],
rules: { rules: {
"references-empty": [2, "never"],
"type-enum": [ "type-enum": [
2, 2,
"always", "always",
+1527 -1516
View File
File diff suppressed because it is too large Load Diff
+43 -43
View File
@@ -1,7 +1,7 @@
{ {
"name": "wealth-ledger-frontend", "name": "wealth-ledger-frontend",
"private": true, "private": true,
"version": "1.0.0", "version": "1.1.0",
"type": "module", "type": "module",
"engines": { "engines": {
"node": "24.21.0" "node": "24.21.0"
@@ -38,54 +38,54 @@
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "5.9.1", "@hookform/resolvers": "5.9.1",
"@mantine/charts": "9.7.1", "@mantine/charts": "9.6.1",
"@mantine/core": "9.7.1", "@mantine/core": "9.6.1",
"@mantine/dates": "9.7.1", "@mantine/dates": "9.6.1",
"@mantine/hooks": "9.7.1", "@mantine/hooks": "9.6.1",
"@mantine/notifications": "9.7.1", "@mantine/notifications": "9.6.1",
"@tanstack/react-query": "5.104.1", "@tanstack/react-query": "5.102.8",
"@wealthledger/icons": "file:packages/icons", "@wealthledger/icons": "file:packages/icons",
"@wealthledger/tokens": "file:packages/tokens", "@wealthledger/tokens": "file:packages/tokens",
"@wealthledger/ui": "file:packages/ui", "@wealthledger/ui": "file:packages/ui",
"dayjs": "1.11.23", "dayjs": "1.11.23",
"i18next": "26.4.3", "i18next": "26.4.2",
"motion": "13.5.1", "motion": "13.2.0",
"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.0",
"react-dom": "19.3.0", "react-dom": "19.3.0",
"react-hook-form": "7.89.0", "react-hook-form": "7.88.0",
"react-i18next": "17.0.16", "react-i18next": "17.0.14",
"react-is": "19.3.0", "react-is": "19.3.0",
"react-router": "8.4.0", "react-router": "8.3.1",
"recharts": "3.10.1", "recharts": "3.10.1",
"zod": "4.6.5" "zod": "4.6.4"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "8.0.7", "@babel/core": "8.0.5",
"@commitlint/cli": "21.2.3", "@commitlint/cli": "21.2.2",
"@commitlint/config-conventional": "21.2.3", "@commitlint/config-conventional": "21.2.2",
"@eslint-react/eslint-plugin": "5.24.10", "@eslint-react/eslint-plugin": "5.21.0",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@release-it/conventional-changelog": "12.0.2", "@release-it/conventional-changelog": "12.0.0",
"@rolldown/plugin-babel": "0.2.4", "@rolldown/plugin-babel": "0.2.4",
"@storybook/addon-docs": "10.6.1", "@storybook/addon-docs": "10.6.0",
"@storybook/react-vite": "10.6.1", "@storybook/react-vite": "10.6.0",
"@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.0",
"@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.13.4",
"@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.1",
"@vitest/browser-playwright": "5.0.3", "@vitest/browser-playwright": "5.0.0",
"@vitest/coverage-v8": "5.0.3", "@vitest/coverage-v8": "5.0.0",
"@vitest/eslint-plugin": "1.6.27", "@vitest/eslint-plugin": "1.6.27",
"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.0",
"dependency-cruiser": "18.5.0", "dependency-cruiser": "18.2.0",
"eslint": "10.12.0", "eslint": "10.11.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",
@@ -94,31 +94,31 @@
"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.1.0",
"eslint-plugin-sonarjs": "4.2.2", "eslint-plugin-sonarjs": "4.2.1",
"eslint-plugin-storybook": "10.6.1", "eslint-plugin-storybook": "10.6.0",
"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.12.0",
"husky": "9.1.7", "husky": "9.1.7",
"jsdom": "30.1.2", "jsdom": "30.0.1",
"knip": "6.41.0", "knip": "6.35.1",
"lint-staged": "17.6.0", "lint-staged": "17.5.1",
"msw": "2.15.0", "msw": "2.15.0",
"openapi-typescript": "7.13.0", "openapi-typescript": "7.13.0",
"playwright": "1.64.0", "playwright": "1.63.0",
"release-it": "21.1.1", "release-it": "21.0.2",
"storybook": "10.6.1", "storybook": "10.6.0",
"typescript": "5.9.3", "typescript": "5.9.3",
"typescript-eslint": "8.71.1", "typescript-eslint": "8.70.1",
"vite": "8.3.4", "vite": "8.3.0",
"vite-plugin-pwa": "1.3.0", "vite-plugin-pwa": "1.3.0",
"vitest": "5.0.3" "vitest": "5.0.0"
}, },
"overrides": { "overrides": {
"release-it": { "release-it": {
"basic-ftp": "6.2.3", "basic-ftp": "6.2.1",
"undici": "7.30.0" "undici": "7.29.1"
} }
}, },
"allowScripts": { "allowScripts": {
+3 -3
View File
@@ -23,11 +23,11 @@
"react": "19.3.0" "react": "19.3.0"
}, },
"devDependencies": { "devDependencies": {
"@storybook/react-vite": "10.6.1", "@storybook/react-vite": "10.6.0",
"@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.0",
"vitest": "5.0.3" "vitest": "5.0.0"
} }
} }
+6 -6
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.6.1",
"@mantine/hooks": "9.7.1", "@mantine/hooks": "9.6.1",
"react": "19.3.0", "react": "19.3.0",
"react-dom": "19.3.0" "react-dom": "19.3.0"
}, },
"devDependencies": { "devDependencies": {
"@storybook/react-vite": "10.6.1", "@storybook/react-vite": "10.6.0",
"@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.0",
"typescript": "5.9.3", "typescript": "5.9.3",
"vite": "8.3.4", "vite": "8.3.0",
"vitest": "5.0.3" "vitest": "5.0.0"
} }
} }
+2 -16
View File
@@ -6,10 +6,6 @@ import {
type StockCandles = NonNullable<paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"]>; type StockCandles = NonNullable<paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
type StockPosition = NonNullable<paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
const MIN_STOCK_SEARCH_LENGTH = 2;
type StockDividends = NonNullable<paths["/api/v1/stocks/{uid}/dividends"]["get"]["responses"][200]["content"]["application/json"]["data"]>; type StockDividends = NonNullable<paths["/api/v1/stocks/{uid}/dividends"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
type StockPeriod = NonNullable< type StockPeriod = NonNullable<
@@ -18,6 +14,8 @@ type StockPeriod = NonNullable<
>["period"] >["period"]
>; >;
type StockPosition = NonNullable<paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
const STOCK_STALE_TIME_MS = 30_000; const STOCK_STALE_TIME_MS = 30_000;
function useStock(identifier: string) { function useStock(identifier: string) {
@@ -96,18 +94,7 @@ function useStockQuote(uid: string) {
}); });
} }
function useStockSearch(query: string) {
return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks", {
params: { query: { query } },
}),
enabled: query.length >= MIN_STOCK_SEARCH_LENGTH,
staleTime: STOCK_STALE_TIME_MS,
});
}
export { export {
MIN_STOCK_SEARCH_LENGTH,
type StockCandles, type StockCandles,
type StockDividends, type StockDividends,
type StockPosition, type StockPosition,
@@ -117,5 +104,4 @@ export {
useStockDividends, useStockDividends,
useStockPosition, useStockPosition,
useStockQuote, useStockQuote,
useStockSearch,
}; };
-2
View File
@@ -1,5 +1,4 @@
export { export {
MIN_STOCK_SEARCH_LENGTH,
type StockCandles, type StockCandles,
type StockDividends, type StockDividends,
type StockPosition, type StockPosition,
@@ -9,5 +8,4 @@ export {
useStockDividends, useStockDividends,
useStockPosition, useStockPosition,
useStockQuote, useStockQuote,
useStockSearch,
} from "./api/queries"; } from "./api/queries";
@@ -1,16 +1,28 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw"; import { http, HttpResponse } from "msw";
import { MemoryRouter } from "react-router"; import { MemoryRouter } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { StocksPage } from "./StocksPage"; import { StocksPage } from "./StocksPage";
import { useStocksPageVm } from "./StocksPage.vm";
const StocksPageVmProbe = () => {
const vm = useStocksPageVm();
return (
<>
<button onClick={vm.onSearch}>search</button>
<button onClick={() => vm.onPage(2)}>page</button>
<output>{vm.items.map(item => item.name).join(", ")}</output>
</>
);
};
afterEach(() => vi.useRealTimers()); afterEach(() => vi.useRealTimers());
@@ -18,27 +30,28 @@ describe("stock search", () => {
it("waits for a meaningful query and uses the server-verified ticker link", async () => { it("waits for a meaningful query and uses the server-verified ticker link", async () => {
const query = vi.fn(); const query = vi.fn();
server.use( server.use(
http.get("*/api/v1/stocks", ({ request }) => { http.post("*/api/v1/stocks/screener/search", async ({ request }) => {
query(new URL(request.url).searchParams.get("query")); query(await request.json());
return HttpResponse.json( return HttpResponse.json({
resource([ asOf: "2026-10-10T10:00:00Z",
{ hasNext: false,
apiTradeAvailable: true, items: [{
classCode: "TQBR", countryOfRisk: "RU",
instrumentUid: "unique-stock", currency: "RUB",
isin: "RU0009029540", instrumentUid: "e6123145-9665-43e0-8413-cd61b8aa9b13",
logoName: "sber.png", isin: "RU0009029540",
name: "Сбербанк", logoName: "sber.png",
price: { currency: "RUB", value: "306.12" }, lot: 10,
priceAt: "2026-09-10T10:00:00Z", name: "Сбербанк",
realExchange: "REAL_EXCHANGE_MOEX", realExchange: "REAL_EXCHANGE_MOEX",
shareType: "SHARE_TYPE_COMMON", sector: "financial",
slug: "SBER", shareType: "SHARE_TYPE_COMMON",
ticker: "SBER", ticker: "SBER",
}, }],
]), page: 1,
); searchId: "search-id",
});
}), }),
); );
configureAuthClient({ configureAuthClient({
@@ -46,7 +59,6 @@ describe("stock search", () => {
clear: vi.fn(), clear: vi.fn(),
refresh: () => Promise.resolve(false), refresh: () => Promise.resolve(false),
}); });
vi.useFakeTimers();
render( render(
<QueryClientProvider client={createTestQueryClient()}> <QueryClientProvider client={createTestQueryClient()}>
<MantineProvider> <MantineProvider>
@@ -56,23 +68,212 @@ describe("stock search", () => {
</MantineProvider> </MantineProvider>
</QueryClientProvider>, </QueryClientProvider>,
); );
const input = screen.getByLabelText("Поиск акций"); const input = screen.getByLabelText("Название или тикер");
fireEvent.change(input, { target: { value: "S" } });
await act(() => vi.advanceTimersByTimeAsync(300));
expect(query).not.toHaveBeenCalled();
fireEvent.change(input, { target: { value: "SB" } });
await act(() => vi.advanceTimersByTimeAsync(299));
expect(query).not.toHaveBeenCalled();
// The final input uses real timers for network and query notifications.
vi.useRealTimers();
fireEvent.change(input, { target: { value: "SBER" } }); fireEvent.change(input, { target: { value: "SBER" } });
expect(query).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Найти" }));
expect( expect(
await screen.findByRole("link", { name: /Сбербанк/ }), await screen.findByRole("link", { name: /Сбербанк/ }),
).toHaveAttribute("href", "/stocks/SBER"); ).toHaveAttribute("href", "/stocks/e6123145-9665-43e0-8413-cd61b8aa9b13");
expect(screen.getByText("306,12 ₽")).toBeVisible(); expect(screen.getByText("Список получен 10.10.2026, 10:00")).toBeVisible();
expect(screen.getByText("Режим: TQBR")).toBeVisible(); expect(screen.getByText("Лот: 10 шт.")).toBeVisible();
expect(screen.getByText("ISIN: RU0009029540")).toBeVisible(); expect(screen.getByText("ISIN: RU0009029540")).toBeVisible();
expect(screen.getByText("Мосбиржа")).toBeVisible(); expect(screen.getAllByText("Мосбиржа")[0]).toBeVisible();
await waitFor(() => expect(query).toHaveBeenCalledExactlyOnceWith("SBER")); expect(screen.getByAltText("")).toHaveAttribute(
"src",
"https://invest-brands.cdn-tinkoff.ru/sberx160.png",
);
await waitFor(() => expect(query).toHaveBeenCalledExactlyOnceWith({ query: "SBER" }));
});
it("searches by the selected sector without a text query", async () => {
const query = vi.fn();
server.use(
http.post("*/api/v1/stocks/screener/search", async ({ request }) => {
query(await request.json());
return HttpResponse.json({
asOf: "2026-10-10T10:00:00Z",
hasNext: false,
items: [],
page: 1,
searchId: "sector-search",
});
}),
);
configureAuthClient({
accessToken: () => "synthetic",
clear: vi.fn(),
refresh: () => Promise.resolve(false),
});
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider>
<MemoryRouter>
<StocksPage />
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("combobox", { name: "Сектор" }));
fireEvent.click(await screen.findByRole("option", { name: "Энергетика" }));
fireEvent.click(screen.getByRole("button", { name: "Найти" }));
await waitFor(() => expect(query).toHaveBeenCalledExactlyOnceWith({
sector: "energy",
}));
});
it("shows known result pages and reads the selected page", async () => {
const requestedPages = vi.fn();
server.use(
http.post("*/api/v1/stocks/screener/search", () => HttpResponse.json({
asOf: "2026-10-10T10:00:00Z",
hasNext: true,
items: [],
page: 1,
searchId: "paged-search",
})),
http.get("*/api/v1/stocks/screener/search/paged-search", ({ request }) => {
const page = Number(new URL(request.url).searchParams.get("page"));
requestedPages(page);
return HttpResponse.json({
asOf: "2026-10-10T10:00:00Z",
hasNext: false,
items: [],
page,
searchId: "paged-search",
});
}),
);
configureAuthClient({
accessToken: () => "synthetic",
clear: vi.fn(),
refresh: () => Promise.resolve(false),
});
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider>
<MemoryRouter>
<StocksPage />
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "Найти" }));
fireEvent.click(await screen.findByRole(
"button",
{ name: "Страница 2" },
));
await waitFor(() =>
expect(requestedPages).toHaveBeenCalledExactlyOnceWith(2),
);
});
it("keeps a newer search when an earlier page request finishes late", async () => {
const oldPage = Promise.withResolvers<void>();
let searches = 0;
server.use(
http.post("*/api/v1/stocks/screener/search", () => {
searches += 1;
return HttpResponse.json({
asOf: "2026-10-10T10:00:00Z",
hasNext: searches === 1,
items: [{
countryOfRisk: "RU",
currency: "RUB",
instrumentUid: searches === 1 ? "old" : "new",
isin: null,
logoName: null,
lot: 1,
name: searches === 1 ? "Старый" : "Новый",
realExchange: "REAL_EXCHANGE_MOEX",
sector: "financial",
shareType: "SHARE_TYPE_COMMON",
ticker: searches === 1 ? "OLD" : "NEW",
}],
page: 1,
searchId: searches === 1 ? "old-search" : "new-search",
});
}),
http.get("*/api/v1/stocks/screener/search/old-search", async () => {
await oldPage.promise;
return HttpResponse.json({
asOf: "2026-10-10T10:00:00Z",
hasNext: false,
items: [{
countryOfRisk: "RU",
currency: "RUB",
instrumentUid: "old-page",
isin: null,
logoName: null,
lot: 1,
name: "Старый результат",
realExchange: "REAL_EXCHANGE_MOEX",
sector: "financial",
shareType: "SHARE_TYPE_COMMON",
ticker: "OLD",
}],
page: 2,
searchId: "old-search",
});
}),
);
render(<StocksPageVmProbe />);
fireEvent.click(screen.getByRole("button", { name: "search" }));
await screen.findByText("Старый");
fireEvent.click(screen.getByRole("button", { name: "page" }));
fireEvent.click(screen.getByRole("button", { name: "search" }));
await screen.findByText("Новый");
oldPage.resolve();
await waitFor(() => expect(screen.getByText("Новый")).toBeVisible());
expect(screen.queryByText("Старый результат")).not.toBeInTheDocument();
});
it("disables filters and shows loading while searching", async () => {
const { promise, resolve } = Promise.withResolvers<void>();
server.use(
http.post("*/api/v1/stocks/screener/search", async () => {
await promise;
return HttpResponse.json({
asOf: "2026-10-10T10:00:00Z",
hasNext: false,
items: [],
page: 1,
searchId: "pending-search",
});
}),
);
configureAuthClient({
accessToken: () => "synthetic",
clear: vi.fn(),
refresh: () => Promise.resolve(false),
});
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider>
<MemoryRouter>
<StocksPage />
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "Найти" }));
await waitFor(() => expect(
screen.getByRole("combobox", { name: "Сектор" }),
).toBeDisabled());
expect(screen.getByRole("button", { name: "Найти" })).toHaveAttribute(
"data-loading",
"true",
);
resolve();
}); });
}); });
@@ -1,89 +1,274 @@
import type { FC } from "react"; import type { ChangeEvent, FC } from "react";
import { Badge, Group, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { import {
InstrumentSearch, Alert,
InstrumentSearchCard, Anchor,
} from "@/features/instrument-search"; Avatar,
import { formatMoney, formatSnapshotTime } from "@/shared/lib"; Badge,
Button,
Card,
Fieldset,
Group,
NumberInput,
Pagination,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type { StocksPageVm } from "./StocksPage.vm"; import { formatSnapshotTime, instrumentLogoUrl } from "@/shared/lib";
import type { Criteria, StocksPageVm } from "./StocksPage.vm";
import { useStocksPagePresentationVm } from "./StocksPagePresentation.vm";
type CriteriaField = {
label: keyof Criteria;
options?: readonly string[];
type?: "number";
};
type StockCardProps = { item: StocksPageVm["items"][number] };
type StockCriteriaFieldProps = {
criteria: Criteria;
field: CriteriaField;
onChange: (name: keyof Criteria, value: string) => void;
};
type StocksPageViewProps = { vm: StocksPageVm }; type StocksPageViewProps = { vm: StocksPageVm };
const criteriaFields: CriteriaField[] = [
{ label: "query" },
{
label: "sector",
options: [
"consumer",
"energy",
"financial",
"health_care",
"industrials",
"it",
"materials",
"real_estate",
"telecom",
"utilities",
],
},
{
label: "shareType",
options: [
"SHARE_TYPE_ADR",
"SHARE_TYPE_COMMON",
"SHARE_TYPE_GDR",
"SHARE_TYPE_PREFERRED",
],
},
{ label: "currency", options: ["RUB", "USD", "EUR", "CNY", "HKD", "TRY"] },
{
label: "realExchange",
options: [
"REAL_EXCHANGE_MOEX",
"REAL_EXCHANGE_RTS",
"REAL_EXCHANGE_OTC",
"REAL_EXCHANGE_DEALER",
],
},
{ label: "countryOfRisk", options: ["RU", "US", "CN", "KZ", "HK", "TR"] },
{ label: "maxLot", type: "number" },
];
function numberChange(
field: keyof Criteria,
onChange: StockCriteriaFieldProps["onChange"],
) {
return (value: number | string) => onChange(field, String(value));
}
function selectChange(
field: keyof Criteria,
onChange: StockCriteriaFieldProps["onChange"],
) {
return (value: null | string) => onChange(field, value ?? "");
}
function selectOptions(field: CriteriaField, translate: ReturnType<typeof useTranslation>["t"]) {
return field.options?.map(value => ({
label: translate(`stock.screener.options.${field.label}.${value}`, {
defaultValue: value,
}),
value,
}));
}
function textChange(
field: keyof Criteria,
onChange: StockCriteriaFieldProps["onChange"],
) {
return (event: ChangeEvent<HTMLInputElement>) =>
onChange(field, event.currentTarget.value);
}
const StockCriteriaField: FC<StockCriteriaFieldProps> = (props) => {
const { criteria, field, onChange } = props;
const { t } = useTranslation();
const options = selectOptions(field, t);
if (field.type === "number") return (
<NumberInput
label={t(`stock.screener.${field.label}`)}
min={1}
onChange={numberChange(field.label, onChange)}
value={criteria[field.label]}
/>
);
if (options) return (
<Select
clearable
data={options}
label={t(`stock.screener.${field.label}`)}
onChange={selectChange(field.label, onChange)}
value={criteria[field.label] || null}
/>
);
return (
<TextInput
label={t(`stock.screener.${field.label}`)}
onChange={textChange(field.label, onChange)}
value={criteria[field.label]}
/>
);
};
const StockCard: FC<StockCardProps> = (props) => {
const { item } = props;
const { t } = useTranslation();
const exchange = item.realExchange
? t(`stock.exchanges.${item.realExchange}`, {
defaultValue: item.realExchange,
})
: undefined;
const lot = item.lot === null
? undefined
: t("stock.lot", { value: item.lot });
return (
<Card p="md" withBorder>
<Group gap="sm" wrap="nowrap">
<Avatar alt="" radius="md" size={40} src={instrumentLogoUrl(item.logoName)}>
{item.name[0]}
</Avatar>
<Stack gap={2} miw={0}>
<Anchor
c="inherit"
component={Link}
fw={600}
to={`/stocks/${item.instrumentUid}`}
>
{item.name}
</Anchor>
<Text c="dimmed" size="xs">
{item.ticker ?? "-"} · {item.shareType ?? "-"}
</Text>
<Group gap="xs">
{exchange && <Badge color="gray" variant="light">{exchange}</Badge>}
{item.currency && (
<Badge color="gray" variant="light">{item.currency}</Badge>
)}
{lot && <Text c="dimmed" size="xs">{lot}</Text>}
</Group>
{item.isin && (
<Text c="dimmed" size="xs">
{t("stock.isin", { value: item.isin })}
</Text>
)}
</Stack>
</Group>
</Card>
);
};
const StocksPageView: FC<StocksPageViewProps> = (props) => { const StocksPageView: FC<StocksPageViewProps> = (props) => {
const { vm } = props; const { vm } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const { getControlProps, getItemProps } = useStocksPagePresentationVm();
return ( return (
<InstrumentSearch kind="stock" vm={vm}> <Stack gap="lg">
{vm.items.map((stock) => { <div>
const quote = ( <Title order={1}>{t("stock.screenerTitle")}</Title>
<>
<Text fw={650} size="xl">
{stock.price ? formatMoney(stock.price) : "—"}
</Text>
<Text c="dimmed" size="xs"> <Text c="dimmed" mt="xs">{t("stock.screenerSubtitle")}</Text>
{stock.price && stock.priceAt </div>
? t("stock.priceAt", {
date: formatSnapshotTime(stock.priceAt),
})
: t("stock.priceUnavailable")}
</Text>
{stock.apiTradeAvailable === true && ( <Fieldset disabled={vm.loading} variant="unstyled">
<Text c="green.8" mt="sm" size="xs"> <SimpleGrid cols={{ base: 1, sm: 2 }}>
{t("stock.apiAvailable")} {criteriaFields.map(field => (
</Text> <StockCriteriaField
)} criteria={vm.criteria}
</> field={field}
); key={field.label}
onChange={vm.onCriteriaChange}
/>
))}
</SimpleGrid>
return ( <Group mt="lg">
<InstrumentSearchCard <Button loading={vm.loading} onClick={vm.onSearch}>
instrument={stock} {t("stock.screener.search")}
key={stock.instrumentUid} </Button>
quote={quote} </Group>
> </Fieldset>
<Text c="dimmed" size="sm">
{stock.ticker} ·{" "}
{t(`stock.shareTypes.${stock.shareType}`, {
defaultValue: t("stock.share"),
})}
</Text>
<Group gap="xs" mt="sm"> {!!vm.error && (
<Badge color="gray" variant="light"> <Alert color="orange" role="status">{t("stock.screener.error")}</Alert>
{t(`stock.exchanges.${stock.realExchange}`, { )}
defaultValue: t("stock.exchangeUnknown"),
})}
</Badge>
{!!stock.classCode && ( {vm.resultTime && (
<Text c="dimmed" size="xs"> <Text c="dimmed" size="sm">
{t("stock.classCode", { code: stock.classCode })} {t("stock.screener.asOf", {
</Text> date: formatSnapshotTime(vm.resultTime),
)} })}
</Group> </Text>
)}
{!!stock.isin && ( {vm.searched && !vm.loading && !vm.error && vm.items.length === 0 && (
<Text c="dimmed" mt={4} size="xs"> <Text c="dimmed">{t("stock.screener.empty")}</Text>
{t("stock.isin", { value: stock.isin })} )}
</Text>
)}
{stock.apiTradeAvailable === false && ( <Stack gap="sm">
<Text c="dimmed" mt={4} size="xs"> {vm.items.map(item => (
{t("stock.apiUnavailable")} <StockCard item={item} key={item.instrumentUid} />
</Text> ))}
)} </Stack>
</InstrumentSearchCard>
); {vm.totalPages > 1 && (
})} <Group aria-label={t("stock.screener.pagination")} component="nav" justify="center">
</InstrumentSearch> <Pagination
disabled={vm.loading}
getControlProps={getControlProps}
getItemProps={getItemProps}
onChange={vm.onPage}
total={vm.totalPages}
value={vm.page ?? 1}
/>
</Group>
)}
</Stack>
); );
}; };
+110 -34
View File
@@ -1,43 +1,119 @@
import { MIN_STOCK_SEARCH_LENGTH, useStockSearch } from "@/entities/stock"; import { useRef, useState } from "react";
import { useInstrumentSearch } from "@/features/instrument-search";
import { useScreenSync } from "@/features/tbank-connection"; import { apiClient } from "@/shared/api";
import { apiErrorCode } from "@/shared/api";
import { instrumentLogoUrl } from "@/shared/lib"; type Criteria = {
countryOfRisk: string;
currency: string;
maxLot: string;
query: string;
realExchange: string;
sector: string;
shareType: string;
};
type ScreenerPage = Awaited<ReturnType<typeof readPage>>;
type StocksPageVm = ReturnType<typeof useStocksPageVm>; type StocksPageVm = ReturnType<typeof useStocksPageVm>;
const emptyCriteria: Criteria = {
countryOfRisk: "",
currency: "",
maxLot: "",
query: "",
realExchange: "",
sector: "",
shareType: "",
};
async function createSearch(criteria: Criteria) {
const { data } = await apiClient.POST("/api/v1/stocks/screener/search", {
body: {
...(criteria.countryOfRisk && { countryOfRisk: criteria.countryOfRisk }),
...(criteria.currency && { currency: criteria.currency }),
...(criteria.maxLot && { maxLot: Number(criteria.maxLot) }),
...(criteria.query && { query: criteria.query }),
...(criteria.realExchange && { realExchange: criteria.realExchange }),
...(criteria.sector && { sector: criteria.sector }),
...(criteria.shareType && { shareType: criteria.shareType }),
},
});
if (!data) throw new Error("Stock screener search failed");
return data;
}
async function readPage(searchId: string, page: number) {
const { data } = await apiClient.GET(
"/api/v1/stocks/screener/search/{searchId}",
{ params: { path: { searchId }, query: { page } } },
);
if (!data) throw new Error("Stock screener page failed");
return data;
}
function useStocksPageVm() { function useStocksPageVm() {
const search = useInstrumentSearch(MIN_STOCK_SEARCH_LENGTH); const [criteria, setCriteria] = useState(emptyCriteria);
const { canSearch, debounced, isWaiting, query } = search; const [page, setPage] = useState<null | ScreenerPage>(null);
const result = useStockSearch(debounced); const [pending, setPending] = useState(false);
useScreenSync({ query: debounced, screen: "stocks" }, [result]); const [error, setError] = useState(false);
const requestIdRef = useRef(0);
const search = async () => {
const currentRequestId = requestIdRef.current + 1;
requestIdRef.current = currentRequestId;
setPending(true);
setError(false);
try {
const result = await createSearch(criteria);
if (requestIdRef.current === currentRequestId) setPage(result);
}
catch {
if (requestIdRef.current === currentRequestId) setError(true);
}
finally {
if (requestIdRef.current === currentRequestId) setPending(false);
}
};
const changePage = async (nextPage: number) => {
if (!page) return;
const currentRequestId = requestIdRef.current + 1;
requestIdRef.current = currentRequestId;
setPending(true);
setError(false);
try {
const result = await readPage(page.searchId, nextPage);
if (requestIdRef.current === currentRequestId) setPage(result);
}
catch {
if (requestIdRef.current === currentRequestId) setError(true);
}
finally {
if (requestIdRef.current === currentRequestId) setPending(false);
}
};
return { return {
canSearch, criteria,
code: apiErrorCode(result.error), error,
error: items: page?.items ?? [],
!result.data loading: pending,
&& (result.isError || result.meta?.lastAttempt.state === "failed"), onCriteriaChange: (name: keyof Criteria, value: string) =>
items: setCriteria(current => ({ ...current, [name]: value })),
result.data?.map(stock => ({ onPage: (nextPage: number) => void changePage(nextPage),
...stock, onSearch: () => void search(),
logoSrc: instrumentLogoUrl(stock.logoName), page: page?.page,
to: `/stocks/${encodeURIComponent(stock.slug)}`, resultTime: page?.asOf,
})) ?? [], searched: page !== null,
loading: totalPages: page ? page.page + Number(page.hasNext) : 0,
canSearch
&& (isWaiting
|| (!result.data
&& !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 };
+199
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;
@@ -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 */
@@ -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?: {
+309
View File
@@ -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",
@@ -7310,6 +7492,133 @@
"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
}
}
},
"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
}
},
"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": {
+51
View File
@@ -636,6 +636,57 @@ const ru = {
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: "Поиск акций",