1 Commits
Author SHA1 Message Date
renovate-bot 3782b249c3 chore(deps): update non-major dependencies
Frontend verify / commits (pull_request) Failing after 6s
Frontend verify / security (pull_request) Skipped
Frontend verify / verify (pull_request) Skipped
2026-10-10 21:29:32 +00:00
25 changed files with 1745 additions and 2895 deletions
+14 -28
View File
@@ -7,19 +7,11 @@
checkout. Внешний API задаётся предоставленным публичным контрактом; checkout. Внешний API задаётся предоставленным публичным контрактом;
исходники или инструкции его реализации не являются зависимостью разработки. исходники или инструкции его реализации не являются зависимостью разработки.
При нехватке требования сообщи, какое решение не определено, до его реализации. При нехватке требования сообщи, какое решение не определено, до его реализации.
При конфликте с общим шаблоном следуй местным правилам и явному запросу
пользователя. Выбирай реализацию самостоятельно, но изменение финансовой
логики, публичного контракта или утверждённой семантики возвращай владельцу
на согласование до зависимой работы.
Начни с результата и affected areas. До edits проследи flow/invariants, найди Начни с результата и affected areas. До edits проследи flow/invariants, найди
owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт. owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт.
Выбери lightweight или SDD по [Engineering Workflow](https://git.personal.ksv741.ru/WealthLedger/main/src/commit/03b3b59e6ff2ba391f9dfc42a039a96d0d84c1c9/.ai/conventions/engineering-workflow.md); Работай напрямую; OpenSpec/SDD lifecycle — только по явному запросу.
OpenSpec lifecycle начинай после явного решения Change Owner. Перед Node-командами активируй местную `.nvmrc`: `nvm use --silent`.
Перед Node-командами активируй местную `.nvmrc`: если `nvm` не загружен в
non-interactive shell, загрузи установленный `$HOME/.nvm/nvm.sh`; затем
выполни `nvm use --silent` из frontend. Если `nvm.sh` недоступен, остановись,
не используй системный Node.
Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы. Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы.
## Change scope ## Change scope
@@ -65,27 +57,21 @@ Peer-конфликт решай совместимыми версиями бе
## Проверки и завершение ## Проверки и завершение
| Когда | Что проверить | Code/dependency change: `npm ci`, полный `npm run verify` и дополнительные
| --- | --- | risk checks из местной testing convention. Targeted tests ускоряют итерацию,
| Окружение новое, изменились manifest/lockfile или Node, установка сомнительна | `npm ci` до Node-проверок; одна правка кода переустановки не требует. | но не заменяют финальный gate. `lint:fix`/format — edits, не proof.
| Итерация | Адресные тесты, typecheck и lint по затронутому поведению и риску. При изменении поведения добавь/обнови содержательную регрессию, если существующая проверка не ловит её; для косметики не создавай искусственный тест. | Markdown-only: проверь новые/изменённые тексты, локальные ссылки/якоря, маршруты
| Итоговый code/dependency diff | Перед объявлением готовности и передачей к интеграции выполни один полный `npm run verify` на итоговых входах. Дополнительные risk checks из testing convention и явно утверждённые task checks обязательны; CI проверяет независимо. | чтения и `git diff --check`; application build ради текста не нужен.
| Только Markdown | Проверь тексты, ссылки/якоря, маршруты чтения и `git diff --check`; application build не нужен. При изменении verification-контура проверь его работу. | Повтори routing по staged/unstaged/new files. Запиши краткие команды, результаты
и ограничения. Зелёный lint не заменяет semantic/security review.
Повтори routing по staged/unstaged/new files. В отчёте по задаче/PR запиши
проверенные входы, команду, exit code, результат и ограничения. Повторяй
проверку при изменении значимых входов (включая config, dependencies и
generated artifacts), сомнении в evidence или явном запросе пользователя.
`lint:fix`/format — edits, не proof; lint не заменяет semantic/security review.
## Git и релизы ## Git и релизы
Работа — в ветке `<type>/<issue-number>-<slug>` от актуального `develop`, Работа — в `codex/<task>` от актуального `develop`, затем PR в `develop`.
затем PR в `develop`; для cross-repo ссылки укажи owner/repo#number.
Сохраняй чужие изменения; перед commit просмотри staged paths и diff. Сохраняй чужие изменения; перед commit просмотри staged paths и diff.
Commits и PR titles — Conventional Commits с parser-recognized Issue reference (`fix: … (#12)` Commits и PR titles — Conventional Commits; допустим `spec:`. Каждый commit
или `chore: … (WealthLedger/main#27)`); допустим `spec:`. Каждый commit содержит одну связанную задачу. При связанной issue добавь её номер в конец
содержит одну связанную задачу. Исключения release и правила ссылки — в заголовка (`fix: … #12`); без issue номер не нужен. Root другого checkout
Engineering Workflow из AGENTS. Root другого checkout не управляет этим repo. не управляет этим repo.
Прямой push в `main`, force-push и перенос опубликованных тегов не входят в Прямой push в `main`, force-push и перенос опубликованных тегов не входят в
обычную работу. Перед release/hotfix прочитай местный README, раздел «Релизы». обычную работу. Перед release/hotfix прочитай местный README, раздел «Релизы».
-12
View File
@@ -1,12 +0,0 @@
---
name: Bug Report
about: Неверное поведение
---
## Проблема и шаги воспроизведения
## Факт / ожидание
## Acceptance Criteria
- [ ]
## Окружение / ссылки (если применимо)
-10
View File
@@ -1,10 +0,0 @@
---
name: Engineering Task
about: CI, тесты, документация, процесс и прочее
---
## Контекст и результат
## Scope / связанные Issues (если есть)
## Acceptance Criteria
- [ ]
-12
View File
@@ -1,12 +0,0 @@
---
name: Feature Request
about: Новое наблюдаемое поведение
---
## Зачем и какой результат
## Ожидаемое поведение
## Acceptance Criteria
- [ ]
## Зависимости / связанные Issues (если есть)
-12
View File
@@ -1,12 +0,0 @@
---
name: Refactoring / Technical Debt
about: Улучшение без нового поведения
---
## Проблема и результат
## Сохраняемые инварианты / границы
## Acceptance Criteria
- [ ]
## Зависимости / ссылки (если есть)
-11
View File
@@ -1,11 +0,0 @@
## Summary
## Linked Issue
Refs
## Changes
## Verification
- Команда / результат / ограничения:
## Breaking Changes (если есть)
+2 -6
View File
@@ -4,7 +4,7 @@ on:
push: push:
branches: [develop, main] branches: [develop, main]
pull_request: pull_request:
types: [opened, synchronize, reopened] types: [opened, synchronize, reopened, edited]
schedule: schedule:
- cron: '17 5 * * *' - cron: '17 5 * * *'
@@ -34,11 +34,7 @@ jobs:
HEAD_SHA: ${{ github.event.pull_request.head.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: | run: |
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
if [[ "$PR_TITLE" =~ ^chore\(release\):\ [0-9]+\.[0-9]+\.[0-9]+$ ]]; then npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
npm run commitlint -- --strict --from "$HEAD_SHA^" --to "$HEAD_SHA"
else
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
fi
security: security:
needs: commits needs: commits
-3
View File
@@ -4,9 +4,6 @@
только его файлы и предоставленные публичные контракты. Перед работой прочитай только его файлы и предоставленные публичные контракты. Перед работой прочитай
[порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем [порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем
объединяй triggers задачи и путей; читай активированные темы до решения. объединяй triggers задачи и путей; читай активированные темы до решения.
До edits найди/создай подходящую Gitea Issue, проверь AC и ветку; чтение
допускается без Issue. Общий маршрут — [Engineering Workflow](https://git.personal.ksv741.ru/WealthLedger/main/src/commit/03b3b59e6ff2ba391f9dfc42a039a96d0d84c1c9/.ai/conventions/engineering-workflow.md)
(main#28; после merge — актуальная версия `develop`).
| Решение / сигнал | Читать | | Решение / сигнал | Читать |
| --- | --- | | --- | --- |
-27
View File
@@ -1,32 +1,5 @@
# 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.28.0-alpine AS production FROM nginx:1.31.6-alpine AS production
ARG VERSION ARG VERSION
ARG REVISION ARG REVISION
LABEL org.opencontainers.image.version=$VERSION \ LABEL org.opencontainers.image.version=$VERSION \
+3 -6
View File
@@ -203,8 +203,7 @@ Checkout frontend содержит все входы сборки и прове
локальную копию и не обращаются к исходникам или CI сервера. Доставка новой локальную копию и не обращаются к исходникам или CI сервера. Доставка новой
схемы — явное обновление snapshot с review и `npm run openapi:generate`. схемы — явное обновление snapshot с review и `npm run openapi:generate`.
Runtime URL API задаётся окружением, а не расположением другого репозитория. Runtime URL API задаётся окружением, а не расположением другого репозитория.
Разработка начинается с Issue; lightweight или SDD выбирается по критериям Разработка идёт напрямую по задаче пользователя; OpenSpec/SDD — по явному запросу.
Engineering Workflow из AGENTS. OpenSpec — после явного решения Change Owner.
Knip исключает `conventional-changelog-conventionalcommits`: release-it загружает Knip исключает `conventional-changelog-conventionalcommits`: release-it загружает
preset по имени из `.release-it.json`. Версия preset закреплена напрямую. preset по имени из `.release-it.json`. Версия preset закреплена напрямую.
@@ -212,11 +211,9 @@ preset по имени из `.release-it.json`. Версия preset закреп
## Релизы ## Релизы
Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые
релизы. Обычная работа: issue-linked ветка от актуального `develop`, PR в `develop`; релизы. Работа: `codex/<task>` от актуального `develop`, PR в `develop`.
общие правила — в Engineering Workflow по ссылке из AGENTS.
Обычные PR допускают squash; release и обратная синхронизация сохраняют историю. Обычные PR допускают squash; release и обратная синхронизация сохраняют историю.
Commits и PR titles — Conventional Commits со ссылкой на Issue (кроме release), Commits и PR titles — Conventional Commits, включая `spec:`. Версии вручную не повышай.
включая `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,12 +1,6 @@
module.exports = { module.exports = {
defaultIgnores: false,
extends: ["@commitlint/config-conventional"], extends: ["@commitlint/config-conventional"],
ignores: [
message => /^chore\(release\): \d+\.\d+\.\d+$/.test(message.trim()),
message => /^Merge pull request ['"].+['"] \(#\d+\) from .+ into .+$/.test(message.trim()),
],
rules: { rules: {
"references-empty": [2, "never"],
"type-enum": [ "type-enum": [
2, 2,
"always", "always",
+1516 -1527
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.1.0", "version": "1.0.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.6.1", "@mantine/charts": "9.7.1",
"@mantine/core": "9.6.1", "@mantine/core": "9.7.1",
"@mantine/dates": "9.6.1", "@mantine/dates": "9.7.1",
"@mantine/hooks": "9.6.1", "@mantine/hooks": "9.7.1",
"@mantine/notifications": "9.6.1", "@mantine/notifications": "9.7.1",
"@tanstack/react-query": "5.102.8", "@tanstack/react-query": "5.104.1",
"@wealthledger/icons": "file:packages/icons", "@wealthledger/icons": "file:packages/icons",
"@wealthledger/tokens": "file:packages/tokens", "@wealthledger/tokens": "file:packages/tokens",
"@wealthledger/ui": "file:packages/ui", "@wealthledger/ui": "file:packages/ui",
"dayjs": "1.11.23", "dayjs": "1.11.23",
"i18next": "26.4.2", "i18next": "26.4.3",
"motion": "13.2.0", "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.0",
"react-dom": "19.3.0", "react-dom": "19.3.0",
"react-hook-form": "7.88.0", "react-hook-form": "7.89.0",
"react-i18next": "17.0.14", "react-i18next": "17.0.16",
"react-is": "19.3.0", "react-is": "19.3.0",
"react-router": "8.3.1", "react-router": "8.4.0",
"recharts": "3.10.1", "recharts": "3.10.1",
"zod": "4.6.4" "zod": "4.6.5"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "8.0.5", "@babel/core": "8.0.7",
"@commitlint/cli": "21.2.2", "@commitlint/cli": "21.2.3",
"@commitlint/config-conventional": "21.2.2", "@commitlint/config-conventional": "21.2.3",
"@eslint-react/eslint-plugin": "5.21.0", "@eslint-react/eslint-plugin": "5.24.10",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@release-it/conventional-changelog": "12.0.0", "@release-it/conventional-changelog": "12.0.2",
"@rolldown/plugin-babel": "0.2.4", "@rolldown/plugin-babel": "0.2.4",
"@storybook/addon-docs": "10.6.0", "@storybook/addon-docs": "10.6.1",
"@storybook/react-vite": "10.6.0", "@storybook/react-vite": "10.6.1",
"@stylistic/eslint-plugin": "5.10.0", "@stylistic/eslint-plugin": "5.10.0",
"@tanstack/eslint-plugin-query": "5.104.0", "@tanstack/eslint-plugin-query": "5.104.1",
"@testing-library/jest-dom": "7.0.1", "@testing-library/jest-dom": "7.0.1",
"@testing-library/react": "16.3.3", "@testing-library/react": "16.3.3",
"@types/node": "24.13.4", "@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.1", "@vitejs/plugin-react": "6.1.2",
"@vitest/browser-playwright": "5.0.0", "@vitest/browser-playwright": "5.0.3",
"@vitest/coverage-v8": "5.0.0", "@vitest/coverage-v8": "5.0.3",
"@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.0", "conventional-changelog-conventionalcommits": "10.4.1",
"dependency-cruiser": "18.2.0", "dependency-cruiser": "18.5.0",
"eslint": "10.11.0", "eslint": "10.12.0",
"eslint-import-resolver-typescript": "4.4.5", "eslint-import-resolver-typescript": "4.4.5",
"eslint-plugin-import-x": "4.17.1", "eslint-plugin-import-x": "4.17.1",
"eslint-plugin-jest-dom": "5.10.1", "eslint-plugin-jest-dom": "5.10.1",
@@ -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.1.0", "eslint-plugin-security": "4.2.0",
"eslint-plugin-sonarjs": "4.2.1", "eslint-plugin-sonarjs": "4.2.2",
"eslint-plugin-storybook": "10.6.0", "eslint-plugin-storybook": "10.6.1",
"eslint-plugin-testing-library": "7.16.2", "eslint-plugin-testing-library": "7.16.2",
"eslint-plugin-unicorn": "76.0.0", "eslint-plugin-unicorn": "76.0.0",
"globals": "17.12.0", "globals": "17.13.0",
"husky": "9.1.7", "husky": "9.1.7",
"jsdom": "30.0.1", "jsdom": "30.1.2",
"knip": "6.35.1", "knip": "6.41.0",
"lint-staged": "17.5.1", "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.63.0", "playwright": "1.64.0",
"release-it": "21.0.2", "release-it": "21.1.1",
"storybook": "10.6.0", "storybook": "10.6.1",
"typescript": "5.9.3", "typescript": "5.9.3",
"typescript-eslint": "8.70.1", "typescript-eslint": "8.71.1",
"vite": "8.3.0", "vite": "8.3.4",
"vite-plugin-pwa": "1.3.0", "vite-plugin-pwa": "1.3.0",
"vitest": "5.0.0" "vitest": "5.0.3"
}, },
"overrides": { "overrides": {
"release-it": { "release-it": {
"basic-ftp": "6.2.1", "basic-ftp": "6.2.3",
"undici": "7.29.1" "undici": "7.30.0"
} }
}, },
"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.0", "@storybook/react-vite": "10.6.1",
"@testing-library/react": "16.3.3", "@testing-library/react": "16.3.3",
"@types/react": "19.3.0", "@types/react": "19.3.0",
"typescript": "5.9.3", "typescript": "5.9.3",
"vite": "8.3.0", "vite": "8.3.4",
"vitest": "5.0.0" "vitest": "5.0.3"
} }
} }
+6 -6
View File
@@ -27,18 +27,18 @@
"@wealthledger/tokens": "0.3.0" "@wealthledger/tokens": "0.3.0"
}, },
"peerDependencies": { "peerDependencies": {
"@mantine/core": "9.6.1", "@mantine/core": "9.7.1",
"@mantine/hooks": "9.6.1", "@mantine/hooks": "9.7.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.0", "@storybook/react-vite": "10.6.1",
"@testing-library/react": "16.3.3", "@testing-library/react": "16.3.3",
"@types/react": "19.3.0", "@types/react": "19.3.0",
"storybook": "10.6.0", "storybook": "10.6.1",
"typescript": "5.9.3", "typescript": "5.9.3",
"vite": "8.3.0", "vite": "8.3.4",
"vitest": "5.0.0" "vitest": "5.0.3"
} }
} }
+16 -2
View File
@@ -6,6 +6,10 @@ import {
type StockCandles = NonNullable<paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"]>; type StockCandles = NonNullable<paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
type StockPosition = NonNullable<paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
const MIN_STOCK_SEARCH_LENGTH = 2;
type StockDividends = NonNullable<paths["/api/v1/stocks/{uid}/dividends"]["get"]["responses"][200]["content"]["application/json"]["data"]>; type StockDividends = NonNullable<paths["/api/v1/stocks/{uid}/dividends"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
type StockPeriod = NonNullable< type StockPeriod = NonNullable<
@@ -14,8 +18,6 @@ type StockPeriod = NonNullable<
>["period"] >["period"]
>; >;
type StockPosition = NonNullable<paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
const STOCK_STALE_TIME_MS = 30_000; const STOCK_STALE_TIME_MS = 30_000;
function useStock(identifier: string) { function useStock(identifier: string) {
@@ -94,7 +96,18 @@ function useStockQuote(uid: string) {
}); });
} }
function useStockSearch(query: string) {
return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks", {
params: { query: { query } },
}),
enabled: query.length >= MIN_STOCK_SEARCH_LENGTH,
staleTime: STOCK_STALE_TIME_MS,
});
}
export { export {
MIN_STOCK_SEARCH_LENGTH,
type StockCandles, type StockCandles,
type StockDividends, type StockDividends,
type StockPosition, type StockPosition,
@@ -104,4 +117,5 @@ export {
useStockDividends, useStockDividends,
useStockPosition, useStockPosition,
useStockQuote, useStockQuote,
useStockSearch,
}; };
+2
View File
@@ -1,4 +1,5 @@
export { export {
MIN_STOCK_SEARCH_LENGTH,
type StockCandles, type StockCandles,
type StockDividends, type StockDividends,
type StockPosition, type StockPosition,
@@ -8,4 +9,5 @@ export {
useStockDividends, useStockDividends,
useStockPosition, useStockPosition,
useStockQuote, useStockQuote,
useStockSearch,
} from "./api/queries"; } from "./api/queries";
@@ -1,28 +1,16 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { act, 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());
@@ -30,28 +18,27 @@ 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.post("*/api/v1/stocks/screener/search", async ({ request }) => { http.get("*/api/v1/stocks", ({ request }) => {
query(await request.json()); query(new URL(request.url).searchParams.get("query"));
return HttpResponse.json({ return HttpResponse.json(
asOf: "2026-10-10T10:00:00Z", resource([
hasNext: false, {
items: [{ apiTradeAvailable: true,
countryOfRisk: "RU", classCode: "TQBR",
currency: "RUB", instrumentUid: "unique-stock",
instrumentUid: "e6123145-9665-43e0-8413-cd61b8aa9b13", isin: "RU0009029540",
isin: "RU0009029540", logoName: "sber.png",
logoName: "sber.png", name: "Сбербанк",
lot: 10, price: { currency: "RUB", value: "306.12" },
name: "Сбербанк", priceAt: "2026-09-10T10:00:00Z",
realExchange: "REAL_EXCHANGE_MOEX", realExchange: "REAL_EXCHANGE_MOEX",
sector: "financial", shareType: "SHARE_TYPE_COMMON",
shareType: "SHARE_TYPE_COMMON", slug: "SBER",
ticker: "SBER", ticker: "SBER",
}], },
page: 1, ]),
searchId: "search-id", );
});
}), }),
); );
configureAuthClient({ configureAuthClient({
@@ -59,6 +46,7 @@ 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>
@@ -68,212 +56,23 @@ describe("stock search", () => {
</MantineProvider> </MantineProvider>
</QueryClientProvider>, </QueryClientProvider>,
); );
const input = screen.getByLabelText("Название или тикер"); const input = screen.getByLabelText("Поиск акций");
fireEvent.change(input, { target: { value: "SBER" } }); fireEvent.change(input, { target: { value: "S" } });
await act(() => vi.advanceTimersByTimeAsync(300));
expect(query).not.toHaveBeenCalled(); expect(query).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Найти" })); 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" } });
expect( expect(
await screen.findByRole("link", { name: /Сбербанк/ }), await screen.findByRole("link", { name: /Сбербанк/ }),
).toHaveAttribute("href", "/stocks/e6123145-9665-43e0-8413-cd61b8aa9b13"); ).toHaveAttribute("href", "/stocks/SBER");
expect(screen.getByText("Список получен 10.10.2026, 10:00")).toBeVisible(); expect(screen.getByText("306,12 ₽")).toBeVisible();
expect(screen.getByText("Лот: 10 шт.")).toBeVisible(); expect(screen.getByText("Режим: TQBR")).toBeVisible();
expect(screen.getByText("ISIN: RU0009029540")).toBeVisible(); expect(screen.getByText("ISIN: RU0009029540")).toBeVisible();
expect(screen.getAllByText("Мосбиржа")[0]).toBeVisible(); expect(screen.getByText("Мосбиржа")).toBeVisible();
expect(screen.getByAltText("")).toHaveAttribute( await waitFor(() => expect(query).toHaveBeenCalledExactlyOnceWith("SBER"));
"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,274 +1,89 @@
import type { ChangeEvent, FC } from "react"; import type { FC } from "react";
import { Badge, Group, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { import {
Alert, InstrumentSearch,
Anchor, InstrumentSearchCard,
Avatar, } from "@/features/instrument-search";
Badge, import { formatMoney, formatSnapshotTime } from "@/shared/lib";
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 { formatSnapshotTime, instrumentLogoUrl } from "@/shared/lib"; import type { StocksPageVm } from "./StocksPage.vm";
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 (
<Stack gap="lg"> <InstrumentSearch kind="stock" vm={vm}>
<div> {vm.items.map((stock) => {
<Title order={1}>{t("stock.screenerTitle")}</Title> const quote = (
<>
<Text fw={650} size="xl">
{stock.price ? formatMoney(stock.price) : "—"}
</Text>
<Text c="dimmed" mt="xs">{t("stock.screenerSubtitle")}</Text> <Text c="dimmed" size="xs">
</div> {stock.price && stock.priceAt
? t("stock.priceAt", {
date: formatSnapshotTime(stock.priceAt),
})
: t("stock.priceUnavailable")}
</Text>
<Fieldset disabled={vm.loading} variant="unstyled"> {stock.apiTradeAvailable === true && (
<SimpleGrid cols={{ base: 1, sm: 2 }}> <Text c="green.8" mt="sm" size="xs">
{criteriaFields.map(field => ( {t("stock.apiAvailable")}
<StockCriteriaField </Text>
criteria={vm.criteria} )}
field={field} </>
key={field.label} );
onChange={vm.onCriteriaChange}
/>
))}
</SimpleGrid>
<Group mt="lg"> return (
<Button loading={vm.loading} onClick={vm.onSearch}> <InstrumentSearchCard
{t("stock.screener.search")} instrument={stock}
</Button> key={stock.instrumentUid}
</Group> quote={quote}
</Fieldset> >
<Text c="dimmed" size="sm">
{stock.ticker} ·{" "}
{t(`stock.shareTypes.${stock.shareType}`, {
defaultValue: t("stock.share"),
})}
</Text>
{!!vm.error && ( <Group gap="xs" mt="sm">
<Alert color="orange" role="status">{t("stock.screener.error")}</Alert> <Badge color="gray" variant="light">
)} {t(`stock.exchanges.${stock.realExchange}`, {
defaultValue: t("stock.exchangeUnknown"),
})}
</Badge>
{vm.resultTime && ( {!!stock.classCode && (
<Text c="dimmed" size="sm"> <Text c="dimmed" size="xs">
{t("stock.screener.asOf", { {t("stock.classCode", { code: stock.classCode })}
date: formatSnapshotTime(vm.resultTime), </Text>
})} )}
</Text> </Group>
)}
{vm.searched && !vm.loading && !vm.error && vm.items.length === 0 && ( {!!stock.isin && (
<Text c="dimmed">{t("stock.screener.empty")}</Text> <Text c="dimmed" mt={4} size="xs">
)} {t("stock.isin", { value: stock.isin })}
</Text>
)}
<Stack gap="sm"> {stock.apiTradeAvailable === false && (
{vm.items.map(item => ( <Text c="dimmed" mt={4} size="xs">
<StockCard item={item} key={item.instrumentUid} /> {t("stock.apiUnavailable")}
))} </Text>
</Stack> )}
</InstrumentSearchCard>
{vm.totalPages > 1 && ( );
<Group aria-label={t("stock.screener.pagination")} component="nav" justify="center"> })}
<Pagination </InstrumentSearch>
disabled={vm.loading}
getControlProps={getControlProps}
getItemProps={getItemProps}
onChange={vm.onPage}
total={vm.totalPages}
value={vm.page ?? 1}
/>
</Group>
)}
</Stack>
); );
}; };
+34 -110
View File
@@ -1,119 +1,43 @@
import { useRef, useState } from "react"; import { MIN_STOCK_SEARCH_LENGTH, useStockSearch } from "@/entities/stock";
import { useInstrumentSearch } from "@/features/instrument-search";
import { apiClient } from "@/shared/api"; import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api";
type Criteria = { import { instrumentLogoUrl } from "@/shared/lib";
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 [criteria, setCriteria] = useState(emptyCriteria); const search = useInstrumentSearch(MIN_STOCK_SEARCH_LENGTH);
const [page, setPage] = useState<null | ScreenerPage>(null); const { canSearch, debounced, isWaiting, query } = search;
const [pending, setPending] = useState(false); const result = useStockSearch(debounced);
const [error, setError] = useState(false); useScreenSync({ query: debounced, screen: "stocks" }, [result]);
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 {
criteria, canSearch,
error, code: apiErrorCode(result.error),
items: page?.items ?? [], error:
loading: pending, !result.data
onCriteriaChange: (name: keyof Criteria, value: string) => && (result.isError || result.meta?.lastAttempt.state === "failed"),
setCriteria(current => ({ ...current, [name]: value })), items:
onPage: (nextPage: number) => void changePage(nextPage), result.data?.map(stock => ({
onSearch: () => void search(), ...stock,
page: page?.page, logoSrc: instrumentLogoUrl(stock.logoName),
resultTime: page?.asOf, to: `/stocks/${encodeURIComponent(stock.slug)}`,
searched: page !== null, })) ?? [],
totalPages: page ? page.page + Number(page.hasNext) : 0, loading:
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 Criteria, type StocksPageVm, useStocksPageVm }; export { type StocksPageVm, useStocksPageVm };
@@ -1,20 +0,0 @@
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,38 +436,6 @@ 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;
@@ -1282,37 +1250,6 @@ 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 */
@@ -4140,142 +4077,6 @@ 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,188 +3333,6 @@
] ]
} }
}, },
"/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",
@@ -7492,133 +7310,6 @@
"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,57 +636,6 @@ 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: "Поиск акций",