diff --git a/.ai/conventions/engineering.md b/.ai/conventions/engineering.md index da167e5..d9d197b 100644 --- a/.ai/conventions/engineering.md +++ b/.ai/conventions/engineering.md @@ -7,11 +7,19 @@ checkout. Внешний API задаётся предоставленным публичным контрактом; исходники или инструкции его реализации не являются зависимостью разработки. При нехватке требования сообщи, какое решение не определено, до его реализации. +При конфликте с общим шаблоном следуй местным правилам и явному запросу +пользователя. Выбирай реализацию самостоятельно, но изменение финансовой +логики, публичного контракта или утверждённой семантики возвращай владельцу +на согласование до зависимой работы. Начни с результата и affected areas. До edits проследи flow/invariants, найди owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт. -Работай напрямую; OpenSpec/SDD lifecycle — только по явному запросу. -Перед Node-командами активируй местную `.nvmrc`: `nvm use --silent`. +Выбери lightweight или SDD по [Engineering Workflow](https://git.personal.ksv741.ru/WealthLedger/main/src/commit/03b3b59e6ff2ba391f9dfc42a039a96d0d84c1c9/.ai/conventions/engineering-workflow.md); +OpenSpec lifecycle начинай после явного решения Change Owner. +Перед Node-командами активируй местную `.nvmrc`: если `nvm` не загружен в +non-interactive shell, загрузи установленный `$HOME/.nvm/nvm.sh`; затем +выполни `nvm use --silent` из frontend. Если `nvm.sh` недоступен, остановись, +не используй системный Node. Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы. ## Change scope @@ -57,21 +65,27 @@ Peer-конфликт решай совместимыми версиями бе ## Проверки и завершение -Code/dependency change: `npm ci`, полный `npm run verify` и дополнительные -risk checks из местной testing convention. Targeted tests ускоряют итерацию, -но не заменяют финальный gate. `lint:fix`/format — edits, не proof. -Markdown-only: проверь новые/изменённые тексты, локальные ссылки/якоря, маршруты -чтения и `git diff --check`; application build ради текста не нужен. -Повтори routing по staged/unstaged/new files. Запиши краткие команды, результаты -и ограничения. Зелёный lint не заменяет semantic/security review. +| Когда | Что проверить | +| --- | --- | +| Окружение новое, изменились manifest/lockfile или Node, установка сомнительна | `npm ci` до Node-проверок; одна правка кода переустановки не требует. | +| Итерация | Адресные тесты, typecheck и lint по затронутому поведению и риску. При изменении поведения добавь/обнови содержательную регрессию, если существующая проверка не ловит её; для косметики не создавай искусственный тест. | +| Итоговый code/dependency diff | Перед объявлением готовности и передачей к интеграции выполни один полный `npm run verify` на итоговых входах. Дополнительные risk checks из testing convention и явно утверждённые task checks обязательны; CI проверяет независимо. | +| Только Markdown | Проверь тексты, ссылки/якоря, маршруты чтения и `git diff --check`; application build не нужен. При изменении verification-контура проверь его работу. | + +Повтори routing по staged/unstaged/new files. В отчёте по задаче/PR запиши +проверенные входы, команду, exit code, результат и ограничения. Повторяй +проверку при изменении значимых входов (включая config, dependencies и +generated artifacts), сомнении в evidence или явном запросе пользователя. +`lint:fix`/format — edits, не proof; lint не заменяет semantic/security review. ## Git и релизы -Работа — в `codex/` от актуального `develop`, затем PR в `develop`. +Работа — в ветке `/-` от актуального `develop`, +затем PR в `develop`; для cross-repo ссылки укажи owner/repo#number. Сохраняй чужие изменения; перед commit просмотри staged paths и diff. -Commits и PR titles — Conventional Commits; допустим `spec:`. Каждый commit -содержит одну связанную задачу. При связанной issue добавь её номер в конец -заголовка (`fix: … #12`); без issue номер не нужен. Root другого checkout -не управляет этим repo. +Commits и PR titles — Conventional Commits с parser-recognized Issue reference (`fix: … (#12)` +или `chore: … (WealthLedger/main#27)`); допустим `spec:`. Каждый commit +содержит одну связанную задачу. Исключения release и правила ссылки — в +Engineering Workflow из AGENTS. Root другого checkout не управляет этим repo. Прямой push в `main`, force-push и перенос опубликованных тегов не входят в обычную работу. Перед release/hotfix прочитай местный README, раздел «Релизы». diff --git a/.gitea/ISSUE_TEMPLATE/bug.md b/.gitea/ISSUE_TEMPLATE/bug.md new file mode 100644 index 0000000..c7edcc9 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/bug.md @@ -0,0 +1,12 @@ +--- +name: Bug Report +about: Неверное поведение +--- +## Проблема и шаги воспроизведения + +## Факт / ожидание + +## Acceptance Criteria +- [ ] + +## Окружение / ссылки (если применимо) diff --git a/.gitea/ISSUE_TEMPLATE/engineering.md b/.gitea/ISSUE_TEMPLATE/engineering.md new file mode 100644 index 0000000..96b826e --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/engineering.md @@ -0,0 +1,10 @@ +--- +name: Engineering Task +about: CI, тесты, документация, процесс и прочее +--- +## Контекст и результат + +## Scope / связанные Issues (если есть) + +## Acceptance Criteria +- [ ] diff --git a/.gitea/ISSUE_TEMPLATE/feature.md b/.gitea/ISSUE_TEMPLATE/feature.md new file mode 100644 index 0000000..b10e51f --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/feature.md @@ -0,0 +1,12 @@ +--- +name: Feature Request +about: Новое наблюдаемое поведение +--- +## Зачем и какой результат + +## Ожидаемое поведение + +## Acceptance Criteria +- [ ] + +## Зависимости / связанные Issues (если есть) diff --git a/.gitea/ISSUE_TEMPLATE/refactor.md b/.gitea/ISSUE_TEMPLATE/refactor.md new file mode 100644 index 0000000..f67cf63 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/refactor.md @@ -0,0 +1,12 @@ +--- +name: Refactoring / Technical Debt +about: Улучшение без нового поведения +--- +## Проблема и результат + +## Сохраняемые инварианты / границы + +## Acceptance Criteria +- [ ] + +## Зависимости / ссылки (если есть) diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..f31e344 --- /dev/null +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,11 @@ +## Summary + +## Linked Issue +Refs + +## Changes + +## Verification +- Команда / результат / ограничения: + +## Breaking Changes (если есть) diff --git a/.gitea/workflows/verify.yml b/.gitea/workflows/verify.yml index de41d41..bbef2a1 100644 --- a/.gitea/workflows/verify.yml +++ b/.gitea/workflows/verify.yml @@ -4,7 +4,7 @@ on: push: branches: [develop, main] pull_request: - types: [opened, synchronize, reopened, edited] + types: [opened, synchronize, reopened] schedule: - cron: '17 5 * * *' @@ -34,7 +34,11 @@ jobs: HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict - npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA" + if [[ "$PR_TITLE" =~ ^chore\(release\):\ [0-9]+\.[0-9]+\.[0-9]+$ ]]; then + npm run commitlint -- --strict --from "$HEAD_SHA^" --to "$HEAD_SHA" + else + npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA" + fi security: needs: commits diff --git a/AGENTS.md b/AGENTS.md index 91bf699..014dc54 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,9 @@ только его файлы и предоставленные публичные контракты. Перед работой прочитай [порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем объединяй triggers задачи и путей; читай активированные темы до решения. +До edits найди/создай подходящую Gitea Issue, проверь AC и ветку; чтение +допускается без Issue. Общий маршрут — [Engineering Workflow](https://git.personal.ksv741.ru/WealthLedger/main/src/commit/03b3b59e6ff2ba391f9dfc42a039a96d0d84c1c9/.ai/conventions/engineering-workflow.md) +(main#28; после merge — актуальная версия `develop`). | Решение / сигнал | Читать | | --- | --- | diff --git a/CHANGELOG.md b/CHANGELOG.md index fa874a9..fcb6468 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # 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) ### ⚠ BREAKING CHANGES diff --git a/README.md b/README.md index 0ebf4da..d65a396 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,8 @@ Checkout frontend содержит все входы сборки и прове локальную копию и не обращаются к исходникам или CI сервера. Доставка новой схемы — явное обновление snapshot с review и `npm run openapi:generate`. Runtime URL API задаётся окружением, а не расположением другого репозитория. -Разработка идёт напрямую по задаче пользователя; OpenSpec/SDD — по явному запросу. +Разработка начинается с Issue; lightweight или SDD выбирается по критериям +Engineering Workflow из AGENTS. OpenSpec — после явного решения Change Owner. Knip исключает `conventional-changelog-conventionalcommits`: release-it загружает preset по имени из `.release-it.json`. Версия preset закреплена напрямую. @@ -211,9 +212,11 @@ preset по имени из `.release-it.json`. Версия preset закреп ## Релизы Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые -релизы. Работа: `codex/` от актуального `develop`, PR в `develop`. +релизы. Обычная работа: issue-linked ветка от актуального `develop`, PR в `develop`; +общие правила — в Engineering Workflow по ссылке из AGENTS. Обычные PR допускают squash; release и обратная синхронизация сохраняют историю. -Commits и PR titles — Conventional Commits, включая `spec:`. Версии вручную не повышай. +Commits и PR titles — Conventional Commits со ссылкой на Issue (кроме release), +включая `spec:`. Версии вручную не повышай. 1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что `develop` содержит последний релиз. Создай `release/` от проверенного diff --git a/commitlint.config.cjs b/commitlint.config.cjs index bdca20c..ae358bc 100644 --- a/commitlint.config.cjs +++ b/commitlint.config.cjs @@ -1,6 +1,12 @@ module.exports = { + defaultIgnores: false, extends: ["@commitlint/config-conventional"], + ignores: [ + message => /^chore\(release\): \d+\.\d+\.\d+$/.test(message.trim()), + message => /^Merge pull request ['"].+['"] \(#\d+\) from .+ into .+$/.test(message.trim()), + ], rules: { + "references-empty": [2, "never"], "type-enum": [ 2, "always", diff --git a/package-lock.json b/package-lock.json index aa31b80..ecc5c23 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wealth-ledger-frontend", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wealth-ledger-frontend", - "version": "1.0.0", + "version": "1.1.0", "workspaces": [ "packages/*" ], diff --git a/package.json b/package.json index e1b8e0a..9ebff02 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "wealth-ledger-frontend", "private": true, - "version": "1.0.0", + "version": "1.1.0", "type": "module", "engines": { "node": "24.21.0" diff --git a/src/entities/bank-card/ui/BankSummary/BankSummary.module.css b/src/entities/bank-card/ui/BankSummary/BankSummary.module.css index 0944833..8aea161 100644 --- a/src/entities/bank-card/ui/BankSummary/BankSummary.module.css +++ b/src/entities/bank-card/ui/BankSummary/BankSummary.module.css @@ -15,6 +15,28 @@ min-width: 0; } +.totals { + display: flex; + flex-wrap: wrap; + gap: var(--mantine-spacing-xs) var(--mantine-spacing-lg); + margin: 0; +} + +.totals > div { + display: grid; + gap: 0; +} + +.totals dt { + color: var(--mantine-color-dimmed); + font-size: var(--mantine-font-size-xs); +} + +.totals dd { + margin: 0; + font-variant-numeric: tabular-nums; +} + .categories { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/src/entities/bank-card/ui/BankSummary/BankSummary.view.test.tsx b/src/entities/bank-card/ui/BankSummary/BankSummary.view.test.tsx new file mode 100644 index 0000000..9594f96 --- /dev/null +++ b/src/entities/bank-card/ui/BankSummary/BankSummary.view.test.tsx @@ -0,0 +1,35 @@ +import { MantineProvider } from "@mantine/core"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { BankSummaryView } from "./BankSummary.view"; + +const money = (value: string) => ({ currency: "RUB", value }); + +describe("BankSummaryView", () => { + it("shows expenses, refunds, and net monthly totals", () => { + render( + + + , + ); + + expect(screen.getAllByText("Расходы")).toHaveLength(2); + expect(screen.getByText("Возвращено")).toBeInTheDocument(); + expect(screen.getByText("После возвратов")).toBeInTheDocument(); + expect(screen.getByText("1 000,00 ₽")).toBeInTheDocument(); + expect(screen.getByText("200,00 ₽")).toBeInTheDocument(); + expect(screen.getByText("800,00 ₽")).toBeInTheDocument(); + }); +}); diff --git a/src/entities/bank-card/ui/BankSummary/BankSummary.view.tsx b/src/entities/bank-card/ui/BankSummary/BankSummary.view.tsx index 52fccc6..ffd3e4f 100644 --- a/src/entities/bank-card/ui/BankSummary/BankSummary.view.tsx +++ b/src/entities/bank-card/ui/BankSummary/BankSummary.view.tsx @@ -92,9 +92,31 @@ const BankSummaryView: FC = (props) => { {props.data && ( - - {formatMoney(props.data.net, { withZeroCents: true })} - +
+
+
{t("bank.summary")}
+ +
+ {formatMoney(props.data.expenses, { withZeroCents: true })} +
+
+ +
+
{t("bank.refunds")}
+ +
+ {formatMoney(props.data.refunds, { withZeroCents: true })} +
+
+ +
+
{t("bank.net")}
+ +
+ {formatMoney(props.data.net, { withZeroCents: true })} +
+
+
)} diff --git a/src/entities/brokerage-account/api/useBrokerageAccounts.ts b/src/entities/brokerage-account/api/useBrokerageAccounts.ts index 7ae6cec..d54ea76 100644 --- a/src/entities/brokerage-account/api/useBrokerageAccounts.ts +++ b/src/entities/brokerage-account/api/useBrokerageAccounts.ts @@ -4,17 +4,22 @@ const brokerageAccountsQueryOptions = () => apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"); const brokerageAccountsQueryKey = brokerageAccountsQueryOptions().queryKey; +const BROKERAGE_ACCOUNTS_STALE_TIME_MS = 1_800_000; type BrokerageAccount = NonNullable< paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"]["data"] >[number]; function useBrokerageAccounts() { - return useResourceQuery({ + const query = useResourceQuery({ meta: { accessGroup: "brokerage" }, ...brokerageAccountsQueryOptions(), - staleTime: 60_000, + staleTime: BROKERAGE_ACCOUNTS_STALE_TIME_MS, }); + + return { + ...query, + }; } export { diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx index 3e7dc7e..255e46c 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx +++ b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx @@ -205,24 +205,32 @@ export const IndependentFreshness: Story ], [ { - availability: "complete", + availability: "available", + completeness: "complete", dataRevision: "1", freshness: "stale", - lastSuccessfulAt: "2026-09-24T10:30:00Z", - resourceKey: "portfolio/storybook-account", - sync: { - errorCode: "TBANK_UNAVAILABLE", - id: "failed", + lastAttempt: { + finishedAt: "2026-09-24T10:31:00Z", + problems: [], + startedAt: "2026-09-24T10:30:00Z", state: "failed", }, + lastSuccessfulAt: "2026-09-24T10:30:00Z", + resourceKey: "portfolio/storybook-account", }, { - availability: "missing", + availability: "initial-loading", + completeness: "incomplete", dataRevision: "0", freshness: "unknown", + lastAttempt: { + finishedAt: null, + problems: [], + startedAt: "2026-09-24T10:30:00Z", + state: "running", + }, lastSuccessfulAt: null, resourceKey: "portfolio/waiting", - sync: { errorCode: null, id: "waiting", state: "running" }, }, ], ), diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx index a706ea1..dd8dd3b 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx +++ b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx @@ -64,15 +64,16 @@ const BrokerageAccountSourceStatus: FC = ( const { source } = props; const { t } = useTranslation(); - const isSyncing = source?.sync.state === "running"; + const isSyncing = source?.lastAttempt.state === "running"; const errorText - = source?.sync.state === "failed" ? t("accounts.refreshError") : null; + = source?.lastAttempt.state === "failed" ? t("accounts.refreshError") : null; return ( <> diff --git a/src/entities/stock/api/queries.ts b/src/entities/stock/api/queries.ts index 8afef1e..b6cfef1 100644 --- a/src/entities/stock/api/queries.ts +++ b/src/entities/stock/api/queries.ts @@ -6,10 +6,6 @@ import { type StockCandles = NonNullable; -type StockPosition = NonNullable; - -const MIN_STOCK_SEARCH_LENGTH = 2; - type StockDividends = NonNullable; type StockPeriod = NonNullable< @@ -18,6 +14,8 @@ type StockPeriod = NonNullable< >["period"] >; +type StockPosition = NonNullable; + const STOCK_STALE_TIME_MS = 30_000; function useStock(identifier: string) { @@ -96,18 +94,7 @@ function useStockQuote(uid: string) { }); } -function useStockSearch(query: string) { - return useResourceQuery({ - ...apiQueryClient.queryOptions("get", "/api/v1/stocks", { - params: { query: { query } }, - }), - enabled: query.length >= MIN_STOCK_SEARCH_LENGTH, - staleTime: STOCK_STALE_TIME_MS, - }); -} - export { - MIN_STOCK_SEARCH_LENGTH, type StockCandles, type StockDividends, type StockPosition, @@ -117,5 +104,4 @@ export { useStockDividends, useStockPosition, useStockQuote, - useStockSearch, }; diff --git a/src/entities/stock/index.ts b/src/entities/stock/index.ts index b349d42..9277fad 100644 --- a/src/entities/stock/index.ts +++ b/src/entities/stock/index.ts @@ -1,5 +1,4 @@ export { - MIN_STOCK_SEARCH_LENGTH, type StockCandles, type StockDividends, type StockPosition, @@ -9,5 +8,4 @@ export { useStockDividends, useStockPosition, useStockQuote, - useStockSearch, } from "./api/queries"; diff --git a/src/features/instrument-details/model/section.test.ts b/src/features/instrument-details/model/section.test.ts index 40b5527..c4642c2 100644 --- a/src/features/instrument-details/model/section.test.ts +++ b/src/features/instrument-details/model/section.test.ts @@ -5,20 +5,23 @@ import { resource } from "@/test/resource"; import { section } from "./section"; describe("instrument source and transport presentation", () => { - it.each(["missing", "partial", "complete"] as const)( + it.each([ + ["initial-loading", "incomplete"], + ["available", "complete"], + ] as const)( "keeps %s separate from successful transport", - (availability) => { - const data = availability === "missing" ? undefined : { rows: [] }; + (availability, completeness) => { + const data = availability === "initial-loading" ? undefined : { rows: [] }; const state = section({ data, isError: false, - meta: resource(data, { availability }).meta, + meta: resource(data, { availability, completeness }).meta, }); - expect(state.loading).toBe(availability === "missing"); - expect(state.partial).toBe(availability === "partial"); - expect(state.hasData).toBe(availability !== "missing"); + expect(state.loading).toBe(availability === "initial-loading"); + expect(state.partial).toBe(completeness === "incomplete"); + expect(state.hasData).toBe(availability !== "initial-loading"); }, ); it("preserves empty successful data instead of treating it as missing", () => { @@ -37,7 +40,12 @@ describe("instrument source and transport presentation", () => { it("preserves a full source when force fails", () => { const meta = resource([], { - sync: { errorCode: "TBANK_UNAVAILABLE", id: "work", state: "failed" }, + lastAttempt: { + finishedAt: "2026-08-14T00:01:00Z", + problems: [], + startedAt: "2026-08-14T00:00:00Z", + state: "failed", + }, }).meta; const state = section({ data: [], isError: false, meta }); @@ -53,7 +61,12 @@ describe("instrument source and transport presentation", () => { data: undefined, isError: false, meta: resource(null, { - sync: { errorCode: "TBANK_UNAVAILABLE", id: "work", state: "failed" }, + lastAttempt: { + finishedAt: "2026-08-14T00:01:00Z", + problems: [], + startedAt: "2026-08-14T00:00:00Z", + state: "failed", + }, }).meta, }); diff --git a/src/features/instrument-details/model/section.ts b/src/features/instrument-details/model/section.ts index 898ea88..a18b264 100644 --- a/src/features/instrument-details/model/section.ts +++ b/src/features/instrument-details/model/section.ts @@ -16,14 +16,14 @@ function section(query: { isError: boolean; meta?: InvestmentResourceMeta; }): SectionState { - const isError = query.isError || query.meta?.sync.state === "failed"; + const isError = query.isError || query.meta?.lastAttempt.state === "failed"; const hasData = hasSectionData(query.data); return { error: isError, hasData, loading: !hasData && !isError, - partial: query.meta?.availability === "partial", + partial: query.meta?.completeness === "incomplete", }; } diff --git a/src/features/tbank-connection/model/connection-boundary.ts b/src/features/tbank-connection/model/connection-boundary.ts index 22b86e8..61c9caa 100644 --- a/src/features/tbank-connection/model/connection-boundary.ts +++ b/src/features/tbank-connection/model/connection-boundary.ts @@ -13,7 +13,6 @@ function isInvestmentPath(path: string) { "/api/v1/brokerage-accounts", "/api/v1/stocks", "/api/v1/bonds", - "/api/v1/tbank-sync", ].some(root => path === root || path.startsWith(`${root}/`)); } diff --git a/src/features/tbank-connection/model/screen-sync.test.tsx b/src/features/tbank-connection/model/screen-sync.test.tsx index be495f5..28b35d0 100644 --- a/src/features/tbank-connection/model/screen-sync.test.tsx +++ b/src/features/tbank-connection/model/screen-sync.test.tsx @@ -3,14 +3,12 @@ import { http, HttpResponse } from "msw"; import { describe, expect, it, vi } from "vitest"; import { useBrokerageAccounts } from "@/entities/brokerage-account"; -import { useStockQuote } from "@/entities/stock"; -import { configureAuthClient, invalidateResourceAccess } from "@/shared/api"; +import { configureAuthClient } from "@/shared/api"; import { createQueryWrapper } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; import { useScreenSync } from "../index"; -import { commitTbankConnectionBoundary } from "./connection-boundary"; function auth() { configureAuthClient({ @@ -20,364 +18,23 @@ function auth() { }); } -describe("page-owned screen synchronization", () => { - it("keeps the current scope when an older sync acceptance arrives last", async () => { +describe("read-only screen synchronization", () => { + it("retries the resource with GET without requesting source observation", async () => { auth(); - - const { promise: blocked, resolve: release } - = Promise.withResolvers(); - - const posted: string[] = []; - const polled: string[] = []; - server.use( - http.post("*/api/v1/tbank-sync", async ({ request }) => { - const scope = (await request.json()) as { - accountId: string; - screen: "account"; - }; - - posted.push(scope.accountId); - if (scope.accountId === "old") await blocked; - - return HttpResponse.json( - { id: scope.accountId, now: "2026-09-25T00:00:00Z", scope }, - { status: 202 }, - ); - }), - http.get("*/api/v1/tbank-sync/:id", ({ params }) => { - polled.push(String(params.id)); - - return HttpResponse.json({ - id: params.id, - state: "succeeded", - }); - }), - ); - - const { rerender, result } = renderHook( - ({ accountId }) => useScreenSync({ accountId, screen: "account" }, []), - { - initialProps: { accountId: "old" }, - wrapper: createQueryWrapper().wrapper, - }, - ); - - let pending!: Promise; - act(() => { - pending = result.current.refresh(); - }); - await waitFor(() => expect(posted).toEqual(["old"])); - rerender({ accountId: "new" }); - await act(() => result.current.refresh()); - await waitFor(() => expect(polled).toEqual(["new"])); - await act(async () => { - release(); - await pending; - }); - expect(polled).toEqual(["new"]); - expect(posted).toEqual(["old", "new"]); - }); - it.each([false, true])( - "recovers a failed GET without forcing the source (cached=%s)", - async (cached) => { - auth(); - const modes: (null | string)[] = []; - let isUnavailable = !cached; - let posts = 0; - server.use( - http.get("*/api/v1/brokerage-accounts", ({ request }) => { - modes.push(new URL(request.url).searchParams.get("sync")); - - return isUnavailable - ? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 }) - : HttpResponse.json( - resource([], { dataRevision: String(modes.length) }), - ); - }), - http.post("*/api/v1/tbank-sync", () => { - posts += 1; - - return new HttpResponse(null, { status: 500 }); - }), - ); - const { queryClient, wrapper } = createQueryWrapper(); - - const { result } = renderHook( - () => { - const accounts = useBrokerageAccounts(); - const disabled = useStockQuote(""); - - return { - accounts, - disabled, - sync: useScreenSync({ screen: "accounts" }, [accounts, disabled]), - }; - }, - { wrapper }, - ); - - if (cached) { - await waitFor(() => - // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch. - expect(result.current.accounts.isSuccess).toBe(true), - ); - isUnavailable = true; - void result.current.accounts.refetch(); - } - - isUnavailable = false; - await waitFor(() => expect(result.current.accounts.isSuccess).toBe(true)); - expect(posts).toBe(0); - expect(modes.every(mode => mode === null)).toBe(true); - expect(result.current.disabled.data).toBeUndefined(); - expect( - queryClient - .getQueryCache() - .findAll({ queryKey: ["get", "/api/v1/brokerage-accounts"] }), - ).toHaveLength(1); - }, - ); - it.each([ - ["logout", true], - ["connection", true], - ["session", false], - ["connection", false], - ["access", false], - ] as const)( - "discards source recovery after %s changes (HTTP already pending=%s)", - async (boundary, pending) => { - auth(); - - const { promise: blocked, resolve: release } - = Promise.withResolvers(); - - let recoveryRequests = 0; - let isChanged = false; - - try { - server.use( - http.get("*/api/v1/brokerage-accounts", async () => { - if (isChanged) - return HttpResponse.json( - resource([], { dataRevision: "current" }), - ); - - if (recoveryRequests > 0) { - recoveryRequests += 1; - await blocked; - - return HttpResponse.json(resource([], { dataRevision: "old" })); - } - - recoveryRequests += 1; - - return HttpResponse.json( - resource(null, { - sync: { - errorCode: "TBANK_UNAVAILABLE", - id: "failed", - state: "failed", - }, - }), - ); - }), - ); - const { queryClient, wrapper } = createQueryWrapper(); - const published: unknown[] = []; - - const unsubscribe = queryClient.getQueryCache().subscribe((event) => { - if (event.type === "updated" && event.action.type === "success") - published.push(event.query.state.data); - }); - - const { result } = renderHook( - () => { - const accounts = useBrokerageAccounts(); - - return { - accounts, - sync: useScreenSync({ screen: "accounts" }, [accounts]), - }; - }, - { wrapper }, - ); - - await waitFor(() => - expect(result.current.accounts.meta?.sync.state).toBe("failed"), - ); - vi.useFakeTimers(); - if (pending) - await act(() => vi.advanceTimersByTimeAsync(5000)); - expect(recoveryRequests).toBeGreaterThanOrEqual(1); - - await act(async () => { - isChanged = true; - - switch (boundary) { - case "access": { - invalidateResourceAccess(queryClient, "brokerage", { - code: "FORBIDDEN", - }); - - break; - } - - case "logout": { - configureAuthClient(); - queryClient.clear(); - - break; - } - - case "session": { - auth(); - queryClient.clear(); - - break; - } - - case "connection": { - commitTbankConnectionBoundary(queryClient, { - connected: true, - connectedAt: "2026-09-27T00:00:00Z", - }); - } - } - - release(); - if (!pending) await vi.advanceTimersByTimeAsync(5000); - }); - vi.useRealTimers(); - if (boundary === "connection" && !pending) - await waitFor(() => - // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch. - expect(result.current.accounts.meta?.dataRevision).toBe("current"), - ); - expect(published).not.toContainEqual( - expect.objectContaining({ - meta: expect.objectContaining({ dataRevision: "old" }) as unknown, - }), - ); - unsubscribe(); - } - finally { - vi.useRealTimers(); - } - }, - ); - it.each([false, true])( - "recovers an initial GET failure without forced sync (status retry=%s)", - async (retryStatus) => { - auth(); - const modes: (null | string)[] = []; - let posts = 0; - let polls = 0; - - try { - server.use( - http.get("*/api/v1/brokerage-accounts", ({ request }) => { - modes.push(new URL(request.url).searchParams.get("sync")); - - return modes.length === 1 - ? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 }) - : HttpResponse.json(resource([])); - }), - http.post("*/api/v1/tbank-sync", () => { - posts += 1; - - return HttpResponse.json({ id: "recover" }, { status: 202 }); - }), - http.get("*/api/v1/tbank-sync/:id", () => { - polls += 1; - - return retryStatus && polls === 1 - ? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 }) - : HttpResponse.json({ - id: "recover", - state: "succeeded", - }); - }), - ); - const { queryClient, wrapper } = createQueryWrapper(); - - const { result } = renderHook( - () => { - const accounts = useBrokerageAccounts(); - const disabled = useStockQuote(""); - - return { - accounts, - sync: useScreenSync({ screen: "accounts" }, [accounts, disabled]), - }; - }, - { wrapper }, - ); - - await waitFor( - () => expect(result.current.accounts.isSuccess).toBe(true), - { timeout: 3000 }, - ); - expect(result.current.accounts.data).toEqual([]); - - expect(modes).toEqual([null, null]); - expect(posts).toBe(0); - expect(polls).toBe(0); - expect( - queryClient - .getQueryCache() - .findAll({ queryKey: ["get", "/api/v1/brokerage-accounts"] }), - ).toHaveLength(1); - } - finally { - vi.useRealTimers(); - } - }, - ); - it("posts only its scope once and observes completion without importing again", async () => { - auth(); - const modes: (null | string)[] = []; - const bodies: unknown[] = []; - let isCompleted = false; - let polls = 0; - - const meta = () => - resource([], { dataRevision: isCompleted ? "2" : "1" }).meta; - + let gets = 0; + const syncModes: (null | string)[] = []; server.use( http.get("*/api/v1/brokerage-accounts", ({ request }) => { - modes.push(new URL(request.url).searchParams.get("sync")); + gets += 1; + syncModes.push(new URL(request.url).searchParams.get("sync")); - return HttpResponse.json(resource([], meta())); - }), - http.post("*/api/v1/tbank-sync", async ({ request }) => { - bodies.push(await request.json()); - isCompleted = true; - - return HttpResponse.json( - { - id: "screen", - now: "2026-09-25T00:00:00Z", - scope: { screen: "accounts" }, - }, - { status: 202 }, - ); - }), - http.get("*/api/v1/tbank-sync/:id", () => { - polls++; - - return HttpResponse.json( - { - errorCode: null, - id: "screen", - now: "2026-09-25T00:00:00Z", - scope: { screen: "accounts" }, - state: "succeeded", - }, - ); + return gets === 1 + ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) + : HttpResponse.json(resource([])); }), ); - const { result, unmount } = renderHook( + const { result } = renderHook( () => { const accounts = useBrokerageAccounts(); @@ -389,382 +46,36 @@ describe("page-owned screen synchronization", () => { { wrapper: createQueryWrapper().wrapper }, ); + await waitFor(() => expect(gets).toBe(1)); + await act(async () => result.current.sync.retry()); await waitFor(() => expect(result.current.accounts.data).toEqual([])); - await act(async () => { - await Promise.all([ - result.current.sync.refresh(), - result.current.sync.refresh(), - ]); - }); - await waitFor(() => - expect(result.current.accounts.meta?.dataRevision).toBe("2"), - ); - expect(bodies).toEqual([{ screen: "accounts" }]); - expect(modes).toEqual([null, "observe"]); - vi.useFakeTimers(); - - try { - await act(() => vi.advanceTimersByTimeAsync(120_000)); - expect(polls).toBe(1); - - expect(modes).toEqual([null, "observe"]); - unmount(); - await vi.advanceTimersByTimeAsync(120_000); - expect(polls).toBe(1); - } - finally { - vi.useRealTimers(); - } - }); - it.each(["leave", "logout"])( - "publishes completed empty coverage while history runs, then stops on %s", - async (boundary) => { - vi.useFakeTimers(); - - try { - auth(); - let isCompleted = false; - let polls = 0; - const modes: (null | string)[] = []; - - const snapshot = () => - resource(isCompleted ? [] : null, { - dataRevision: isCompleted ? "covered" : "initial", - sync: { errorCode: null, id: "history", state: "running" }, - unavailableReason: isCompleted - ? undefined - : "CALENDAR_OR_ACCOUNT_HISTORY", - }); - - server.use( - http.get("*/api/v1/brokerage-accounts", ({ request }) => { - modes.push(new URL(request.url).searchParams.get("sync")); - - return HttpResponse.json(snapshot()); - }), - http.get("*/api/v1/tbank-sync/:id", () => { - polls++; - - return HttpResponse.json( - { - errorCode: null, - id: "history", - now: "2026-09-25T00:00:00Z", - scope: null, - state: "running", - }, - ); - }), - ); - - const { result, unmount } = renderHook( - () => { - const accounts = useBrokerageAccounts(); - - return { - accounts, - sync: useScreenSync({ screen: "accounts" }, [accounts]), - }; - }, - { wrapper: createQueryWrapper().wrapper }, - ); - - await vi.waitFor(() => expect(modes).toEqual([null, "observe"])); - expect(polls).toBe(1); - expect(result.current.sync.refreshing).toBe(true); - expect(result.current.accounts.data).toBeUndefined(); - - isCompleted = true; - await act(() => vi.advanceTimersByTimeAsync(5000)); - await vi.waitFor(() => - expect(result.current.accounts.data).toEqual([]), - ); - - expect(result.current.sync.refreshing).toBe(true); - expect(modes).toEqual([null, "observe", "observe"]); - - if (boundary === "leave") unmount(); - else act(() => configureAuthClient()); - - await act(() => vi.advanceTimersByTimeAsync(60_000)); - expect(polls).toBe(2); - expect(modes).toHaveLength(3); - } - finally { - vi.useRealTimers(); - } - }, - ); - - it("runs the next status poll and observe read only after five seconds", async () => { - auth(); - const modes: (null | string)[] = []; - const pollGaps: number[] = []; - let polls = 0; - let previousPollAt = 0; - - server.use( - http.get("*/api/v1/brokerage-accounts", ({ request }) => { - modes.push(new URL(request.url).searchParams.get("sync")); - - return HttpResponse.json( - resource([], { - sync: { errorCode: null, id: "history", state: "running" }, - }), - ); - }), - http.get("*/api/v1/tbank-sync/:id", () => { - const now = Date.now(); - if (previousPollAt) pollGaps.push(now - previousPollAt); - previousPollAt = now; - polls++; - - return HttpResponse.json({ - errorCode: null, - id: "history", - now: "2026-09-25T00:00:00Z", - scope: null, - state: "running", - }); - }), - ); - - vi.useFakeTimers(); - - try { - const { unmount } = renderHook( - () => { - const accounts = useBrokerageAccounts(); - - return { - accounts, - sync: useScreenSync({ screen: "accounts" }, [accounts]), - }; - }, - { wrapper: createQueryWrapper().wrapper }, - ); - - await vi.waitFor(() => expect(modes).toEqual([null, "observe"])); - expect(polls).toBe(1); - - await act(() => vi.advanceTimersByTimeAsync(2500)); - expect(polls).toBe(1); - expect(modes).toEqual([null, "observe"]); - - await act(() => vi.advanceTimersByTimeAsync(5000)); - await vi.waitFor(() => expect(polls).toBe(2)); - - expect(modes).toEqual([null, "observe", "observe"]); - expect(pollGaps).toHaveLength(1); - expect(pollGaps[0]).toBeGreaterThanOrEqual(5000); - - unmount(); - await act(() => vi.advanceTimersByTimeAsync(60_000)); - expect(polls).toBe(2); - expect(modes).toHaveLength(3); - } - finally { - vi.useRealTimers(); - } - }); - it("observes resources immediately when a status response turns terminal", async () => { - auth(); - const modes: (null | string)[] = []; - let isCompleted = false; - let polls = 0; - - const meta = () => - resource([], { - dataRevision: isCompleted ? "covered" : "initial", - sync: { errorCode: null, id: "history", state: "running" }, - }); - - server.use( - http.get("*/api/v1/brokerage-accounts", ({ request }) => { - modes.push(new URL(request.url).searchParams.get("sync")); - - return HttpResponse.json(meta()); - }), - http.get("*/api/v1/tbank-sync/:id", () => { - polls++; - - return HttpResponse.json({ - errorCode: null, - id: "history", - now: "2026-09-25T00:00:00Z", - scope: null, - state: isCompleted ? "succeeded" : "running", - }); - }), - ); - - vi.useFakeTimers(); - - try { - const { result } = renderHook( - () => { - const accounts = useBrokerageAccounts(); - - return { - accounts, - sync: useScreenSync({ screen: "accounts" }, [accounts]), - }; - }, - { wrapper: createQueryWrapper().wrapper }, - ); - - await vi.waitFor(() => expect(modes).toEqual([null, "observe"])); - expect(polls).toBe(1); - expect(result.current.sync.refreshing).toBe(true); - - isCompleted = true; - await act(() => vi.advanceTimersByTimeAsync(5000)); - await vi.waitFor(() => expect(modes).toEqual([null, "observe", "observe"])); - - expect(polls).toBe(2); - expect(result.current.accounts.meta?.dataRevision).toBe("covered"); - expect(result.current.sync.refreshing).toBe(false); - - await act(() => vi.advanceTimersByTimeAsync(60_000)); - expect(polls).toBe(2); - expect(modes).toHaveLength(3); - } - finally { - vi.useRealTimers(); - } + expect(gets).toBe(2); + expect(syncModes).toEqual([null, null]); }); - it("clears private snapshots when status confirms access loss", async () => { + it("refreshes the currently open resources together", async () => { auth(); + let gets = 0; server.use( - http.get("*/api/v1/brokerage-accounts", () => - HttpResponse.json( - resource( + http.get("*/api/v1/brokerage-accounts", () => { + gets += 1; - [], - { - sync: { errorCode: null, id: "revoked", state: "running" }, - }, - ), - ), - ), - http.get("*/api/v1/tbank-sync/:id", () => - HttpResponse.json({ code: "INVALID_TBANK_TOKEN" }, { status: 400 }), - ), + return HttpResponse.json(resource([])); + }), ); const { result } = renderHook( () => { const accounts = useBrokerageAccounts(); - return { - accounts, - sync: useScreenSync({ screen: "accounts" }, [accounts]), - }; + return useScreenSync({ screen: "accounts" }, [accounts]); }, { wrapper: createQueryWrapper().wrapper }, ); - await waitFor(() => expect(result.current.accounts.isError).toBe(true)); - expect(result.current.accounts.data).toBeUndefined(); - }); - it("observes a projection when only its dependency reports new coverage", async () => { - auth(); - let isObserved = false; - const modes: (null | string)[] = []; - - const dependency = resource( - - [], - { - dataRevision: isObserved ? "covered" : "initial", - resourceKey: "operations", - sync: { - errorCode: null, - id: "dependency", - state: isObserved ? "idle" : "running", - }, - }, - ).meta; - - server.use( - http.get("*/api/v1/brokerage-accounts", ({ request }) => { - const mode = new URL(request.url).searchParams.get("sync"); - modes.push(mode); - if (mode) isObserved = true; - - return HttpResponse.json( - resource( - isObserved - ? [] - : null, - { - dependencies: [ - { - ...dependency, - dataRevision: isObserved ? "covered" : "initial", - }, - ], - resourceKey: "projection", - }, - ), - ); - }), - http.get("*/api/v1/tbank-sync/:id", () => - - HttpResponse.json( - { - errorCode: null, - id: "dependency", - now: "2026-09-25T00:00:00Z", - scope: null, - state: "succeeded", - }, - ), - ), - ); - - const { result } = renderHook( - () => { - const accounts = useBrokerageAccounts(); - useScreenSync({ screen: "accounts" }, [accounts]); - - return accounts; - }, - { wrapper: createQueryWrapper().wrapper }, - ); - - await waitFor(() => expect(result.current.data).toEqual([])); - - expect(modes).toEqual([null, "observe"]); - }); - - it("preserves a fresh snapshot and timestamp after a failed force", async () => { - auth(); - const saved = resource([], { lastSuccessfulAt: "2026-09-25T01:00:00Z" }); - server.use( - http.get("*/api/v1/brokerage-accounts", () => HttpResponse.json(saved)), - http.post("*/api/v1/tbank-sync", () => - HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }), - ), - ); - - const { result } = renderHook( - () => { - const accounts = useBrokerageAccounts(); - - return { - accounts, - sync: useScreenSync({ screen: "accounts" }, [accounts]), - }; - }, - { wrapper: createQueryWrapper().wrapper }, - ); - - await waitFor(() => expect(result.current.accounts.data).toEqual([])); - await act(() => result.current.sync.refresh()); - await waitFor(() => expect(result.current.sync.transportError).toBe(true)); - expect(result.current.accounts.meta).toEqual(saved.meta); + await waitFor(() => expect(gets).toBe(1)); + await act(async () => result.current.refresh()); + await waitFor(() => expect(gets).toBe(2)); }); }); diff --git a/src/features/tbank-connection/model/screen-sync.ts b/src/features/tbank-connection/model/screen-sync.ts index 56b9893..fda4122 100644 --- a/src/features/tbank-connection/model/screen-sync.ts +++ b/src/features/tbank-connection/model/screen-sync.ts @@ -1,269 +1,52 @@ -import { - focusManager, - onlineManager, - type QueryKey, - useQueryClient, -} from "@tanstack/react-query"; +import type { QueryKey } from "@tanstack/react-query"; + import { useEffect, useRef } from "react"; -import { - ApiErrorCode, - apiErrorCode, - apiQueryClient, - invalidateResourceAccess, - type InvestmentResourceMeta, - isApiErrorCode, - type paths, - useAuthenticatedQueries, -} from "@/shared/api"; - -type InvestmentScreen - = paths["/api/v1/tbank-sync"]["post"]["requestBody"]["content"]["application/json"]; +import type { InvestmentResourceMeta } from "@/shared/api"; type ScreenResource = { isError: boolean; isFetching: boolean; meta?: InvestmentResourceMeta; - observe: () => Promise; queryKey: QueryKey; refetch: () => Promise; }; -const STATUS_INTERVAL_MS = 5000; -const FIRST_SOURCE_RECOVERY_DELAY_MS = 5000; -const SECOND_SOURCE_RECOVERY_DELAY_MS = 15_000; +type ScreenScope = { + [key: string]: unknown; + screen: string; +}; -function requiresSourceRecovery(meta?: InvestmentResourceMeta): boolean { - if (!meta || meta.unavailableReason) return false; +const READ_INTERVAL_MS = 1_800_000; - return ["cancelled", "failed"].includes(meta.sync.state) - || meta.availability === "missing" - || meta.freshness === "stale" - || (meta.dependencies ?? []).some(dependency => - requiresSourceRecovery(dependency), - ); -} - -function sourceRecoveryDelay(attempt: number): number | undefined { - switch (attempt) { - case 0: { - return FIRST_SOURCE_RECOVERY_DELAY_MS; - } - - case 1: { - return SECOND_SOURCE_RECOVERY_DELAY_MS; - } - - default: { - return undefined; - } - } -} - -function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) { - const mutation = apiQueryClient.useMutation("post", "/api/v1/tbank-sync", { - retry: false, - }); - - const client = useQueryClient(); - const deniedRef = useRef(new Set()); - const scopeKey = JSON.stringify(scope); - - const requestInFlightRef = useRef(null); - const lifecycleRecoveryRef = useRef(new Set()); - const sourceRecoveryRef = useRef(new Map()); - const sourceTimersRef = useRef(new Set>()); - - const isCurrentMutation - = JSON.stringify(mutation.variables?.body) === scopeKey; - - const observedRef = useRef(""); - - const ids = [ - ...new Set([ - ...(isCurrentMutation && mutation.data ? [mutation.data.id] : []), - ...resources - .flatMap(({ meta }) => - meta ? [meta, ...(meta.dependencies ?? [])] : [], - ) - .flatMap(meta => - meta.sync.id - && meta.sync.state === "running" - ? [meta.sync.id] - : [], - ), - ]), - ]; - - const statuses = useAuthenticatedQueries( - ids.map(id => ({ - ...apiQueryClient.queryOptions("get", "/api/v1/tbank-sync/{id}", { - params: { path: { id } }, - }), - refetchInterval: (query: { - state: { data?: { state: string }; error: unknown }; - }) => - !( - query.state.error - || (query.state.data && query.state.data.state !== "running") - ) && STATUS_INTERVAL_MS, - refetchOnMount: false, - refetchOnWindowFocus: false, - retry: false, - })), - ); +function useScreenSync(_scope: ScreenScope, resources: ScreenResource[]) { + const resourcesRef = useRef(resources); + useEffect(() => { + resourcesRef.current = resources; + }, [resources]); useEffect(() => { - for (const status of statuses) { - if (!status.error || deniedRef.current.has(status.error)) { - continue; - } - - deniedRef.current.add(status.error); - if (!isApiErrorCode(apiErrorCode(status.error), ApiErrorCode.NotFound)) - invalidateResourceAccess(client, "brokerage", status.error); - } - - if (statuses.every(status => !status.data)) return; - - const observation = JSON.stringify([ - scopeKey, - statuses.map(status => [ - status.data?.id, - status.data?.state, - status.dataUpdatedAt, - ]), - resources.map(resource => resource.queryKey), - ]); - - if (observedRef.current === observation) return; - observedRef.current = observation; - - const isCompleted = statuses.every( - status => status.data && status.data.state !== "running", - ); - - for (const resource of resources) - if (isCompleted || !resource.isFetching) - void resource.observe().catch(() => {}); - }, [client, statuses, resources, scopeKey]); - - useEffect(() => { - const timers = sourceTimersRef.current; - const attempts = sourceRecoveryRef.current; - - return () => { - for (const timer of timers) clearTimeout(timer); - timers.clear(); - attempts.clear(); - }; - }, [scopeKey]); - - useEffect(() => { - for (const resource of resources) { - if ( - !requiresSourceRecovery(resource.meta) - || resource.isFetching - ) continue; - - const key = JSON.stringify([scopeKey, resource.queryKey]); - const attempt = sourceRecoveryRef.current.get(key) ?? 0; - const delay = sourceRecoveryDelay(attempt); - if (delay === undefined) continue; - - sourceRecoveryRef.current.set(key, attempt + 1); - - const timer = setTimeout(() => { - sourceTimersRef.current.delete(timer); + const timer = setInterval(() => { + for (const resource of resourcesRef.current) void resource.refetch().catch(() => {}); - }, delay); + }, READ_INTERVAL_MS); - sourceTimersRef.current.add(timer); - } - }, [resources, scopeKey]); + return () => clearInterval(timer); + }, []); - useEffect(() => { - const recover = () => { - for (const resource of resources) { - if ( - !requiresSourceRecovery(resource.meta) - || resource.isFetching - ) continue; - - const key = JSON.stringify([scopeKey, resource.queryKey]); - if (lifecycleRecoveryRef.current.has(key)) continue; - - sourceRecoveryRef.current.delete(key); - lifecycleRecoveryRef.current.add(key); - void resource.refetch() - .catch(() => {}) - .finally(() => lifecycleRecoveryRef.current.delete(key)); - } - }; - - const unsubscribeFocus = focusManager.subscribe((focused) => { - if (focused) recover(); - }); - - const unsubscribeOnline = onlineManager.subscribe((online) => { - if (online) recover(); - }); - - return () => { - unsubscribeFocus(); - unsubscribeOnline(); - }; - }, [resources, scopeKey]); - - const isRefreshing - = (isCurrentMutation && mutation.isPending) - || statuses.some( - status => !status.isError && status.data?.state === "running", - ) - || resources.some(({ meta }) => { - const status = statuses[ids.indexOf(meta?.sync.id ?? "")]; - - return ( - !status?.isError - && (!status?.data || status.data.state === "running") - && meta?.sync.state === "running" - ); - }); + const isRefreshing = resources.some(resource => resource.isFetching); const refresh = async () => { - if (isRefreshing || requestInFlightRef.current === scopeKey) return; - requestInFlightRef.current = scopeKey; - - try { - await mutation.mutateAsync({ body: scope }); - } - catch { - /* - The mutation retains the transport error for presentation. - */ - } - finally { - if (requestInFlightRef.current === scopeKey) - requestInFlightRef.current - - = null; - } + if (isRefreshing) return; + await Promise.allSettled(resources.map(resource => resource.refetch())); }; - // Retry failed HTTP reads without asking the source to import again. - // A source failure (or explicit refresh with no read error) still needs a sync. + // Retry only the HTTP read; source attempts are reported in resource metadata. const retry = async () => { - const failed = resources.filter( - resource => resource.isError, - ); + const failed = resources.filter(resource => resource.isError); - const failedStatuses = statuses.filter(status => status.isError); - if (failed.length > 0 || failedStatuses.length > 0) { - await Promise.allSettled([ - ...failed.map(resource => resource.observe()), - ...failedStatuses.map(status => status.refetch()), - ]); - } + if (failed.length > 0) + await Promise.allSettled(failed.map(resource => resource.refetch())); else await refresh(); }; @@ -271,13 +54,11 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) { refresh, refreshing: isRefreshing, retry, - sourceError: - statuses.some(status => status.data?.state === "failed") - || resources.some(({ meta }) => meta?.sync.state === "failed"), - transportError: - (isCurrentMutation && mutation.isError) - || statuses.some(status => status.isError), + sourceError: resources.some( + ({ meta }) => meta?.lastAttempt.state === "failed", + ), + transportError: resources.some(resource => resource.isError), }; } -export { type InvestmentScreen, useScreenSync }; +export { useScreenSync }; diff --git a/src/features/tbank-connection/model/sync.test.tsx b/src/features/tbank-connection/model/sync.test.tsx deleted file mode 100644 index 61ad44a..0000000 --- a/src/features/tbank-connection/model/sync.test.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { act, renderHook, waitFor } from "@testing-library/react"; -import { http, HttpResponse } from "msw"; -import { describe, expect, it, vi } from "vitest"; - -import { useBrokerageAccounts } from "@/entities/brokerage-account"; -import { apiQueryClient, configureAuthClient } from "@/shared/api"; -import { createQueryWrapper } from "@/test/query"; -import { resource } from "@/test/resource"; -import { server } from "@/test/server"; - -import type { InvestmentScreen } from "./screen-sync"; - -import { useScreenSync } from "../index"; -import { commitTbankConnectionBoundary } from "./connection-boundary"; - -describe("sync connection and session boundaries", () => { - it.each(["connection", "session"])( - "keeps old status out of the cache after %s changes", - async (boundary) => { - let generation = 0; - configureAuthClient({ - accessToken: () => "synthetic", - clear: vi.fn(), - generation: () => generation, - refresh: () => Promise.resolve(true), - }); - - const { promise: blocked, resolve: release } - = Promise.withResolvers(); - - const accepted = { - id: "work", - now: "2026-09-25T12:00:00Z", - scope: { screen: "accounts" }, - }; - - let started = 0; - let isChanged = false; - server.use( - http.get("*/api/v1/brokerage-accounts", () => - HttpResponse.json(resource([], { - sync: { errorCode: null, id: "work", state: "running" }, - })), - ), - http.post("*/api/v1/tbank-sync", async () => { - started += 1; - await blocked; - - return HttpResponse.json(accepted, { status: 202 }); - }), - http.get("*/api/v1/tbank-sync/:id", async () => { - const now = isChanged ? "2026-09-25T12:01:00Z" : accepted.now; - started += 1; - await blocked; - - return HttpResponse.json( - { - ...accepted, - errorCode: null, - now, - state: "succeeded", - }, - ); - }), - ); - const { queryClient, wrapper } = createQueryWrapper(); - const published: string[] = []; - - const unsubscribe = queryClient.getQueryCache().subscribe((event) => { - if ( - (event.query.queryKey as readonly unknown[])[1] - !== "/api/v1/tbank-sync/{id}" - ) { - return; - } - - const data = event.query.state.data as undefined | { now?: string }; - if (data?.now) published.push(data.now); - }); - - const { result } = renderHook( - () => { - const accounts = useBrokerageAccounts(); - useScreenSync({ screen: "accounts" }, [accounts]); - - return { - mutation: apiQueryClient.useMutation("post", "/api/v1/tbank-sync", { - retry: false, - }), - }; - }, - { wrapper }, - ); - - const screen: InvestmentScreen = { screen: "accounts" }; - let mutation!: Promise; - act(() => { - mutation = result.current.mutation.mutateAsync({ body: screen }); - }); - - // eslint-disable-next-line unicorn/prefer-await -- Handle rejection before changing the boundary and releasing the pending response. - const settled = mutation.catch(() => {}); - - await waitFor(() => expect(started).toBe(2)); - await act(async () => { - isChanged = true; - - if (boundary === "connection") - commitTbankConnectionBoundary( - - queryClient, - { - connected: false, - connectedAt: null, - }, - ); - else { - generation += 1; - queryClient.clear(); - } - - release(); - await blocked; - }); - await settled; - - const key = apiQueryClient.queryOptions( - "get", - "/api/v1/tbank-sync/{id}", - { params: { path: { id: "work" } } }, - ).queryKey; - - // A new request may start after a successful connection boundary; an old result cannot win it. - if (boundary === "session") - // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch. - expect(queryClient.getQueryData(key)).toBeUndefined(); - else - await waitFor(() => - // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch. - expect(queryClient.getQueryData(key)).toMatchObject({ - now: "2026-09-25T12:01:00Z", - }), - ); - expect(published).not.toContain(accepted.now); - unsubscribe(); - }, - ); -}); diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx index f2694f5..d9752ab 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx @@ -123,9 +123,10 @@ describe("Upcoming event date filters", () => { resource( null, { - sync: { - errorCode: "TBANK_UNAVAILABLE", - id: null, + lastAttempt: { + finishedAt: "2026-08-14T00:01:00Z", + problems: [], + startedAt: "2026-08-14T00:00:00Z", state: "failed", }, }, @@ -309,7 +310,14 @@ it("retains the previous complete forecast when its source refresh fails", async const snapshot = resource( { events, incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }] }, - { sync: { errorCode: "TBANK_UNAVAILABLE", id: "failed", state: "failed" } }, + { + lastAttempt: { + finishedAt: "2026-08-14T00:01:00Z", + problems: [], + startedAt: "2026-08-14T00:00:00Z", + state: "failed", + }, + }, ); server.use( diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx index bde07ed..0f3b0e4 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx @@ -51,8 +51,9 @@ describe("AccountEventsPage", () => { isError: false, isLoading: false, meta: { - availability: "complete", - sync: { errorCode: null, id: null, state: "idle" }, + availability: "available", + completeness: "complete", + lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" }, }, refetch: vi.fn(), } as never, @@ -85,8 +86,9 @@ describe("AccountEventsPage", () => { isError: false, isLoading: false, meta: { - availability: "complete", - sync: { errorCode: null, id: null, state: "idle" }, + availability: "available", + completeness: "complete", + lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" }, }, refetch: vi.fn(), } as never, @@ -138,8 +140,9 @@ describe("AccountEventsPage", () => { isError: false, isLoading: false, meta: { - availability: "complete", - sync: { errorCode: null, id: null, state: "idle" }, + availability: "available", + completeness: "complete", + lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" }, }, refetch: vi.fn(), } as never, @@ -194,8 +197,9 @@ describe("AccountEventsPage", () => { isError: false, isLoading: true, meta: { - availability: "complete", - sync: { errorCode: null, id: null, state: "idle" }, + availability: "available", + completeness: "complete", + lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" }, }, refetch: vi.fn(), } as never, diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts index f947b85..41be198 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts @@ -1,6 +1,8 @@ import { useState } from "react"; import { useParams } from "react-router"; +import type { InvestmentResourceMeta } from "@/shared/api"; + import { type BrokerageEvent, useBrokerageEvents, @@ -25,6 +27,7 @@ type AccountEventsPageViewModel onPeriodPopoverOpen: () => void; onReset: () => void; partial?: boolean; + source?: InvestmentResourceMeta; state: "loaded"; } | { onReset: () => void; state: "error" } @@ -74,7 +77,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel { const availability = useInitialAvailability({ hasContent: !!events.data, identity: scope, - isError: events.isError || events.meta?.sync.state === "failed", + isError: events.isError || events.meta?.lastAttempt.state === "failed", isFetching: events.isFetching, }); @@ -86,7 +89,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel { setSearchParameters({}); }; - if ((events.isError || events.meta?.sync.state === "failed") && !events.data) + if ((events.isError || events.meta?.lastAttempt.state === "failed") && !events.data) return { onReset, state: "error", @@ -115,7 +118,8 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel { ), onPeriodPopoverOpen: () => setMaxDate(moscowDate(new Date())), onReset, - partial: events.meta?.availability !== "complete", + partial: events.meta?.completeness === "incomplete", + source: events.meta, state: "loaded", }; } diff --git a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx index 1b6b2bc..ee44db1 100644 --- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx @@ -15,6 +15,7 @@ import { BrokerageEventCard, brokerageEventKey, } from "@/entities/brokerage-event"; +import { SourceSyncStatus } from "@/shared/ui"; import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm"; @@ -55,6 +56,12 @@ const AccountEventsPageLoaded: FC = (props) => { {t("events.title")} + + {!!props.partial && {t("investment.partial")}} {t("events.hint")} diff --git a/src/pages/account-income/model/display.test.ts b/src/pages/account-income/model/display.test.ts index 01ce9e7..ce68f74 100644 --- a/src/pages/account-income/model/display.test.ts +++ b/src/pages/account-income/model/display.test.ts @@ -136,13 +136,14 @@ it("maps comparison notices by role regardless of dependency order or resource k const previous = { ...resource(null).meta, - resourceKey: "opaque/year-looking-key", - role: "previous" as const, - sync: { - errorCode: "TBANK_UNAVAILABLE", - id: null, + lastAttempt: { + finishedAt: "2026-08-14T00:01:00Z", + problems: [], + startedAt: "2026-08-14T00:00:00Z", state: "failed" as const, }, + resourceKey: "opaque/year-looking-key", + role: "previous" as const, }; const year = { diff --git a/src/pages/account-income/model/display.ts b/src/pages/account-income/model/display.ts index 59a2679..bcd1b7c 100644 --- a/src/pages/account-income/model/display.ts +++ b/src/pages/account-income/model/display.ts @@ -66,7 +66,7 @@ function comparisonNotice( ): ComparisonNotice { if (!dependency || dependency.unavailableReason) return unavailable; - return dependency.sync.state === "failed" + return dependency.lastAttempt.state === "failed" ? "comparisonFailed" : "comparisonPending"; } diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx index 8d1676d..063be7e 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx @@ -205,9 +205,10 @@ describe("AccountIncomePage", () => { resource( null, { - sync: { - errorCode: "TBANK_UNAVAILABLE", - id: null, + lastAttempt: { + finishedAt: "2026-08-14T00:01:00Z", + problems: [], + startedAt: "2026-08-14T00:00:00Z", state: "failed", }, }, @@ -832,12 +833,13 @@ it("keeps complete previous comparison while year source failed without inventin }, { ...resource(null).meta, - role: "year" as const, - sync: { - errorCode: "TBANK_UNAVAILABLE", - id: null, + lastAttempt: { + finishedAt: "2026-08-14T00:01:00Z", + problems: [], + startedAt: "2026-08-14T00:00:00Z", state: "failed" as const, }, + role: "year" as const, }, { ...resource(analytics).meta, diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts index 7d4bc42..853fcae 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts @@ -79,7 +79,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => { const availability = useInitialAvailability({ hasContent: !!analytics.data, identity: `${accountId}:${period.from}:${period.to}`, - isError: analytics.isError || analytics.meta?.sync.state === "failed", + isError: analytics.isError || analytics.meta?.lastAttempt.state === "failed", isFetching: analytics.isFetching, }); @@ -114,7 +114,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => { formRevision: `${accountId}:${period.from}:${period.to}:${resetRevision}`, onReset, onSubmit, - partial: analytics.meta?.availability !== "complete", + partial: analytics.meta?.completeness === "incomplete", state: "loaded", }; }; diff --git a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx index 7f443e9..bf09049 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx @@ -497,7 +497,6 @@ describe("AccountOperationsPage", () => { it("keeps UTC operation boundaries in GET and source sync requests", async () => { configureTestAuth(); const requested: Record[] = []; - const syncRequests: unknown[] = []; server.use( http.get( "*/api/v1/brokerage-accounts/:accountId/operations", @@ -508,9 +507,10 @@ it("keeps UTC operation boundaries in GET and source sync requests", async () => resource( null, { - sync: { - errorCode: "TBANK_UNAVAILABLE", - id: null, + lastAttempt: { + finishedAt: "2026-08-14T00:01:00Z", + problems: [], + startedAt: "2026-08-14T00:00:00Z", state: "failed", }, }, @@ -518,11 +518,6 @@ it("keeps UTC operation boundaries in GET and source sync requests", async () => ); }, ), - http.post("*/api/v1/tbank-sync", async ({ request }) => { - syncRequests.push(await request.json()); - - return HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }); - }), ); renderPage("?from=2024-02-29&to=2024-03-01&categories=trade"); await screen.findByRole("button", { name: "Сбросить" }); @@ -534,7 +529,6 @@ it("keeps UTC operation boundaries in GET and source sync requests", async () => to: "2024-03-01T23:59:59.999Z", }, ]); - expect(syncRequests).toEqual([]); }); it("does not label incomplete empty operations as a confirmed empty period", async () => { @@ -550,7 +544,7 @@ it("does not label incomplete empty operations as a confirmed empty period", asy items: [], nextCursor: null, }, - { availability: "partial" }, + { completeness: "incomplete" }, ), ), ), diff --git a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts index 7449ac9..55eb5f2 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts +++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts @@ -2,6 +2,8 @@ import { useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import { useParams } from "react-router"; +import type { InvestmentResourceMeta } from "@/shared/api"; + import { type BrokerageOperation, brokerageOperationsQueryOptions, @@ -39,6 +41,7 @@ type AccountOperationsPageViewModel onPrevious: () => void; onReset: () => void; partial?: boolean; + source?: InvestmentResourceMeta; state: "loaded"; } | { onReset: () => void; state: "error" } @@ -98,7 +101,7 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => { const availability = useInitialAvailability({ hasContent: !!operations.data, identity: scope, - isError: operations.isError || operations.meta?.sync.state === "failed", + isError: operations.isError || operations.meta?.lastAttempt.state === "failed", isFetching: operations.isFetching, }); @@ -127,7 +130,7 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => { if ( (isInvalidCursor || !operations.data) - && (operations.isError || operations.meta?.sync.state === "failed") + && (operations.isError || operations.meta?.lastAttempt.state === "failed") ) return { onReset, state: "error" }; @@ -177,7 +180,8 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => { onPeriodPopoverOpen: () => setMaxDate(moscowDate(new Date())), onPrevious: history.onPrevious, onReset, - partial: operations.meta?.availability !== "complete", + partial: operations.meta?.completeness === "incomplete", + source: operations.meta, state: "loaded", }; }; diff --git a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx index 208ff5f..40d513a 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx @@ -13,6 +13,7 @@ import { StateView } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; import { LedgerEntryRow, operationCategories } from "@/entities/broker-operation"; +import { SourceSyncStatus } from "@/shared/ui"; import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm"; @@ -62,6 +63,12 @@ const AccountOperationsPageLoaded: FC = ( {t("ledger.title")} + + {!!props.partial && {t("investment.partial")}} {!!props.historyLoading && {t("investment.history")}} diff --git a/src/pages/account/model/previews.ts b/src/pages/account/model/previews.ts index b7bffa8..55e255e 100644 --- a/src/pages/account/model/previews.ts +++ b/src/pages/account/model/previews.ts @@ -46,9 +46,10 @@ type PreviewResource = { isError: boolean; meta?: Pick; }; @@ -57,10 +58,10 @@ function toIncomeSummary( income: PreviewResource, ): AccountPageIncomeSummary { const to = `/accounts/${accountId}/income`; - if (!income.data && !income.isError && income.meta?.sync.state !== "failed") + if (!income.data && !income.isError && income.meta?.lastAttempt.state !== "failed") return { state: "loading", to }; - if (income.meta?.availability === "partial") return { state: "partial", to }; - if (!income.data && (income.isError || income.meta?.sync.state === "failed")) + if (income.meta?.completeness === "incomplete") return { state: "partial", to }; + if (!income.data && (income.isError || income.meta?.lastAttempt.state === "failed")) return { state: "error", to }; if (!income.data?.totalsByCurrency.length) return { state: "empty", to }; @@ -76,15 +77,15 @@ function toRecentEvents( events: PreviewResource<{ events: BrokerageEvent[] }>, ): AccountPageRecentEvents { const to = `/accounts/${accountId}/events`; - if (!events.data && !events.isError && events.meta?.sync.state !== "failed") + if (!events.data && !events.isError && events.meta?.lastAttempt.state !== "failed") return { state: "loading", to }; - if (events.meta?.availability === "partial") + if (events.meta?.completeness === "incomplete") return { items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [], state: "partial", to, }; - if (!events.data && (events.isError || events.meta?.sync.state === "failed")) + if (!events.data && (events.isError || events.meta?.lastAttempt.state === "failed")) return { state: "error", to }; const items = events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? []; if (items.length === 0) return { state: "empty", to }; @@ -104,14 +105,14 @@ function toRecentOperations( if ( !operations.data && !operations.isError - && operations.meta?.sync.state !== "failed" + && operations.meta?.lastAttempt.state !== "failed" ) return { state: "loading", to }; - if (operations.meta?.availability === "partial") + if (operations.meta?.completeness === "incomplete") return { items: operations.data?.items ?? [], state: "partial", to }; if ( !operations.data - && (operations.isError || operations.meta?.sync.state === "failed") + && (operations.isError || operations.meta?.lastAttempt.state === "failed") ) return { state: "error", to }; if (!operations.data?.items.length) return { state: "empty", to }; @@ -120,7 +121,7 @@ function toRecentOperations( items: operations.data.items, lastSuccessfulAt: operations.meta?.lastSuccessfulAt, refreshError: - operations.isError || operations.meta?.sync.state === "failed", + operations.isError || operations.meta?.lastAttempt.state === "failed", stale: operations.meta?.freshness === "stale", state: "loaded", to, diff --git a/src/pages/account/ui/AccountPage/AccountPage.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.test.tsx index de60722..ea1bdcc 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.test.tsx @@ -770,28 +770,6 @@ describe("AccountPage states", () => { let requestCount = 0; let resolveRetry: ((response: Response) => void) | undefined; server.use( - http.post("*/api/v1/tbank-sync", () => - HttpResponse.json( - { - id: "overview", - now: "2026-09-25T00:00:00Z", - scope: { accountId: "account-1", screen: "account" }, - }, - { status: 202 }, - ), - ), - http.get("*/api/v1/tbank-sync/:id", () => - - HttpResponse.json( - { - errorCode: null, - id: "overview", - now: "2026-09-25T00:00:00Z", - scope: { accountId: "account-1", screen: "account" }, - state: "succeeded", - }, - ), - ), http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => { requestCount += 1; if (requestCount === 1) diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx index b6baf4e..62d0b39 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx @@ -173,14 +173,14 @@ it("keeps overview sections independent when their source data is incomplete", a mockLoadedAccountPage(); server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => - HttpResponse.json(resource(portfolio, { availability: "partial" })), + HttpResponse.json(resource(portfolio, { completeness: "incomplete" })), ), http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => HttpResponse.json( resource( { hasNext: false, items: [], nextCursor: null }, - { availability: "partial" }, + { completeness: "incomplete" }, ), ), ), @@ -188,7 +188,7 @@ it("keeps overview sections independent when their source data is incomplete", a HttpResponse.json( resource( { events: [], incomeTotalsByCurrency: [] }, - { availability: "partial" }, + { completeness: "incomplete" }, ), ), ), diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.ts b/src/pages/account/ui/AccountPage/AccountPage.vm.ts index 675e83f..fe1a61b 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.vm.ts +++ b/src/pages/account/ui/AccountPage/AccountPage.vm.ts @@ -1,6 +1,7 @@ import { useParams } from "react-router"; import type { Portfolio } from "@/entities/portfolio"; +import type { InvestmentResourceMeta } from "@/shared/api"; import { useBrokerageOperations } from "@/entities/broker-operation"; import { @@ -45,6 +46,7 @@ type AccountPageEmptyViewModel = AccountPageContent & { type AccountPageLoadedViewModel = AccountPageContent & { portfolio: Portfolio; positionSections: AccountPagePositionSection[]; + source?: InvestmentResourceMeta; state: "loaded"; }; @@ -87,7 +89,7 @@ function useAccountPageViewModel(): AccountPageViewModel { const availability = useInitialAvailability({ hasContent: !!portfolio.data, identity: accountId ?? "account", - isError: portfolio.isError || portfolio.meta?.sync.state === "failed", + isError: portfolio.isError || portfolio.meta?.lastAttempt.state === "failed", isFetching: portfolio.isFetching, }); @@ -105,7 +107,7 @@ function useAccountPageViewModel(): AccountPageViewModel { const incomeSummary = toIncomeSummary(accountId, income); if ( - portfolio.meta?.availability === "complete" + portfolio.meta?.availability === "available" && portfolio.data.positions.length === 0 && portfolio.data.cashBalances.length === 0 ) @@ -118,7 +120,7 @@ function useAccountPageViewModel(): AccountPageViewModel { state: "empty" as const, }; - const isPartial = portfolio.meta?.availability === "partial"; + const isPartial = portfolio.meta?.completeness === "incomplete"; return { account, @@ -129,6 +131,7 @@ function useAccountPageViewModel(): AccountPageViewModel { positionSections: toAccountPagePositionSections(portfolio.data), recentEvents, recentOperations, + source: portfolio.meta, state: "loaded" as const, }; } diff --git a/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx index 01aa1f6..1dde06a 100644 --- a/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx @@ -5,8 +5,10 @@ import { useTranslation } from "react-i18next"; import type { BrokerageAccount } from "@/entities/brokerage-account"; import type { Portfolio } from "@/entities/portfolio"; +import type { InvestmentResourceMeta } from "@/shared/api"; import { BrokerageEventsPreview } from "@/entities/brokerage-event"; +import { SourceSyncStatus } from "@/shared/ui"; import { PortfolioStructure } from "@/widgets/portfolio-structure"; import type { AccountPagePositionSection } from "../../../model/positions"; @@ -32,6 +34,7 @@ type AccountPageLoadedProps = { positionSections: AccountPagePositionSection[]; recentEvents: AccountPageRecentEvents; recentOperations: AccountPageRecentOperations; + source?: InvestmentResourceMeta; }; const AccountPageLoaded: FC = (props) => { @@ -49,6 +52,12 @@ const AccountPageLoaded: FC = (props) => { + + {props.partial ? {t("investment.partial")} : } diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx b/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx index 3d9431a..851d087 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx @@ -130,17 +130,6 @@ describe("AccountsPage", () => { }); it("keeps the cached account list quietly after a refresh error", async () => { - const requests: unknown[] = []; - server.use( - http.post("*/api/v1/tbank-sync", async ({ request }) => { - requests.push(await request.json()); - - return HttpResponse.json( - { code: "TBANK_UNAVAILABLE" }, - { status: 503 }, - ); - }), - ); let requestCount = 0; server.use( http.get("*/api/v1/brokerage-accounts", () => { @@ -169,7 +158,6 @@ describe("AccountsPage", () => { expect(screen.queryByRole("button", { name: "Повторить" })) .not.toBeInTheDocument(); expect(screen.getByText(account.name)).toBeInTheDocument(); - expect(requests).toEqual([]); }); it("keeps a cached empty account list visible after a refresh error", async () => { @@ -201,14 +189,19 @@ describe("AccountsPage", () => { }); }); -it.each(["idle", "failed"] as const)( - "keeps partial empty membership distinct from no accounts (sync=%s)", +it.each(["none", "failed"] as const)( + "keeps incomplete empty membership distinct from no accounts (attempt=%s)", async (state) => { server.use( http.get("*/api/v1/brokerage-accounts", () => HttpResponse.json(resource([], { - availability: "partial", - sync: { errorCode: null, id: null, state }, + completeness: "incomplete", + lastAttempt: { + finishedAt: state === "failed" ? "2026-08-14T00:01:00Z" : null, + problems: [], + startedAt: state === "none" ? null : "2026-08-14T00:00:00Z", + state, + }, })), ), ); @@ -217,7 +210,7 @@ it.each(["idle", "failed"] as const)( await waitFor(() => expect(client.getQueryData(brokerageAccountsQueryKey)).toMatchObject({ data: [], - meta: { availability: "partial" }, + meta: { completeness: "incomplete" }, }), ); expect(screen.queryByText("Нет инвестиционных счетов")) diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx b/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx index 241d6b6..8e02cdc 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx @@ -15,6 +15,7 @@ const AccountsPage: FC = () => { return ( ); }; diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts b/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts index 9f60141..4f3252c 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts @@ -8,7 +8,7 @@ import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; import { useInitialAvailability } from "@/shared/lib"; type AccountsPageViewModel - = | { accounts: BrokerageAccountCard[]; state: "loaded" } + = | { accounts: BrokerageAccountCard[]; meta: NonNullable["meta"]>; state: "loaded" } | { code?: string; state: "error" } | { state: "empty" } | { state: "loading" }; @@ -26,12 +26,13 @@ function useAccountsPageViewModel(): AccountsPageViewModel { const isAccessFailure = isAccountsAccessFailure(code); const hasResult = !!accounts.data - && !(accounts.meta?.availability === "partial" && accounts.data.length === 0); + && !!accounts.meta + && !(accounts.meta.completeness === "incomplete" && accounts.data.length === 0); const availability = useInitialAvailability({ hasContent: hasResult, identity: "accounts", - isError: accounts.isError || accounts.meta?.sync.state === "failed", + isError: accounts.isError || accounts.meta?.lastAttempt.state === "failed", isFetching: accounts.isFetching, }); @@ -47,8 +48,12 @@ function useAccountsPageViewModel(): AccountsPageViewModel { state: "empty" as const, }; + const meta = accounts.meta; + if (!meta) return { state: "loading" as const }; + return { - accounts: brokerageAccountCards(accounts.data, accounts.meta?.dependencies), + accounts: brokerageAccountCards(accounts.data, meta.dependencies), + meta, state: "loaded" as const, }; } diff --git a/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx b/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx index 4e74c79..ec58f96 100644 --- a/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx +++ b/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx @@ -4,13 +4,17 @@ import { Button, Stack, Text, Title } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; +import type { InvestmentResourceMeta } from "@/shared/api"; + import { type BrokerageAccountCard, BrokerageAccountList, } from "@/entities/brokerage-account"; +import { SourceSyncStatus } from "@/shared/ui"; type AccountsPageLoadedProps = { accounts: BrokerageAccountCard[]; + meta: InvestmentResourceMeta; }; const AccountsPageLoaded: FC = (props) => { @@ -24,6 +28,12 @@ const AccountsPageLoaded: FC = (props) => { {t("accounts.subtitle")} + + + + {vm.items.map(item => item.name).join(", ")} + + ); +}; afterEach(() => vi.useRealTimers()); @@ -18,27 +30,28 @@ describe("stock search", () => { it("waits for a meaningful query and uses the server-verified ticker link", async () => { const query = vi.fn(); server.use( - http.get("*/api/v1/stocks", ({ request }) => { - query(new URL(request.url).searchParams.get("query")); + http.post("*/api/v1/stocks/screener/search", async ({ request }) => { + query(await request.json()); - return HttpResponse.json( - resource([ - { - apiTradeAvailable: true, - classCode: "TQBR", - instrumentUid: "unique-stock", - isin: "RU0009029540", - logoName: "sber.png", - name: "Сбербанк", - price: { currency: "RUB", value: "306.12" }, - priceAt: "2026-09-10T10:00:00Z", - realExchange: "REAL_EXCHANGE_MOEX", - shareType: "SHARE_TYPE_COMMON", - slug: "SBER", - ticker: "SBER", - }, - ]), - ); + return HttpResponse.json({ + asOf: "2026-10-10T10:00:00Z", + hasNext: false, + items: [{ + countryOfRisk: "RU", + currency: "RUB", + instrumentUid: "e6123145-9665-43e0-8413-cd61b8aa9b13", + isin: "RU0009029540", + logoName: "sber.png", + lot: 10, + name: "Сбербанк", + realExchange: "REAL_EXCHANGE_MOEX", + sector: "financial", + shareType: "SHARE_TYPE_COMMON", + ticker: "SBER", + }], + page: 1, + searchId: "search-id", + }); }), ); configureAuthClient({ @@ -46,7 +59,6 @@ describe("stock search", () => { clear: vi.fn(), refresh: () => Promise.resolve(false), }); - vi.useFakeTimers(); render( @@ -56,23 +68,212 @@ describe("stock search", () => { , ); - const input = screen.getByLabelText("Поиск акций"); - fireEvent.change(input, { target: { value: "S" } }); - await act(() => vi.advanceTimersByTimeAsync(300)); - expect(query).not.toHaveBeenCalled(); - fireEvent.change(input, { target: { value: "SB" } }); - await act(() => vi.advanceTimersByTimeAsync(299)); - expect(query).not.toHaveBeenCalled(); - // The final input uses real timers for network and query notifications. - vi.useRealTimers(); + const input = screen.getByLabelText("Название или тикер"); fireEvent.change(input, { target: { value: "SBER" } }); + expect(query).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Найти" })); expect( await screen.findByRole("link", { name: /Сбербанк/ }), - ).toHaveAttribute("href", "/stocks/SBER"); - expect(screen.getByText("306,12 ₽")).toBeVisible(); - expect(screen.getByText("Режим: TQBR")).toBeVisible(); + ).toHaveAttribute("href", "/stocks/e6123145-9665-43e0-8413-cd61b8aa9b13"); + expect(screen.getByText("Список получен 10.10.2026, 10:00")).toBeVisible(); + expect(screen.getByText("Лот: 10 шт.")).toBeVisible(); expect(screen.getByText("ISIN: RU0009029540")).toBeVisible(); - expect(screen.getByText("Мосбиржа")).toBeVisible(); - await waitFor(() => expect(query).toHaveBeenCalledExactlyOnceWith("SBER")); + expect(screen.getAllByText("Мосбиржа")[0]).toBeVisible(); + expect(screen.getByAltText("")).toHaveAttribute( + "src", + "https://invest-brands.cdn-tinkoff.ru/sberx160.png", + ); + await waitFor(() => expect(query).toHaveBeenCalledExactlyOnceWith({ query: "SBER" })); + }); + + it("searches by the selected sector without a text query", async () => { + const query = vi.fn(); + server.use( + http.post("*/api/v1/stocks/screener/search", async ({ request }) => { + query(await request.json()); + + return HttpResponse.json({ + asOf: "2026-10-10T10:00:00Z", + hasNext: false, + items: [], + page: 1, + searchId: "sector-search", + }); + }), + ); + configureAuthClient({ + accessToken: () => "synthetic", + clear: vi.fn(), + refresh: () => Promise.resolve(false), + }); + render( + + + + + + + , + ); + 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( + + + + + + + , + ); + 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(); + 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(); + 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(); + 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( + + + + + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Найти" })); + + await waitFor(() => expect( + screen.getByRole("combobox", { name: "Сектор" }), + ).toBeDisabled()); + expect(screen.getByRole("button", { name: "Найти" })).toHaveAttribute( + "data-loading", + "true", + ); + resolve(); }); }); diff --git a/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx b/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx index 6056e71..d380ff9 100644 --- a/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx +++ b/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx @@ -1,89 +1,274 @@ -import type { FC } from "react"; - -import { Badge, Group, Text } from "@mantine/core"; -import { useTranslation } from "react-i18next"; +import type { ChangeEvent, FC } from "react"; import { - InstrumentSearch, - InstrumentSearchCard, -} from "@/features/instrument-search"; -import { formatMoney, formatSnapshotTime } from "@/shared/lib"; + Alert, + Anchor, + Avatar, + Badge, + Button, + Card, + Fieldset, + Group, + NumberInput, + Pagination, + Select, + SimpleGrid, + Stack, + Text, + TextInput, + Title, +} from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; -import type { StocksPageVm } from "./StocksPage.vm"; +import { formatSnapshotTime, instrumentLogoUrl } from "@/shared/lib"; + +import type { Criteria, StocksPageVm } from "./StocksPage.vm"; + +import { useStocksPagePresentationVm } from "./StocksPagePresentation.vm"; + +type CriteriaField = { + label: keyof Criteria; + options?: readonly string[]; + type?: "number"; +}; + +type StockCardProps = { item: StocksPageVm["items"][number] }; + +type StockCriteriaFieldProps = { + criteria: Criteria; + field: CriteriaField; + onChange: (name: keyof Criteria, value: string) => void; +}; type StocksPageViewProps = { vm: StocksPageVm }; +const criteriaFields: CriteriaField[] = [ + { label: "query" }, + { + label: "sector", + options: [ + "consumer", + "energy", + "financial", + "health_care", + "industrials", + "it", + "materials", + "real_estate", + "telecom", + "utilities", + ], + }, + { + label: "shareType", + options: [ + "SHARE_TYPE_ADR", + "SHARE_TYPE_COMMON", + "SHARE_TYPE_GDR", + "SHARE_TYPE_PREFERRED", + ], + }, + { label: "currency", options: ["RUB", "USD", "EUR", "CNY", "HKD", "TRY"] }, + { + label: "realExchange", + options: [ + "REAL_EXCHANGE_MOEX", + "REAL_EXCHANGE_RTS", + "REAL_EXCHANGE_OTC", + "REAL_EXCHANGE_DEALER", + ], + }, + { label: "countryOfRisk", options: ["RU", "US", "CN", "KZ", "HK", "TR"] }, + { label: "maxLot", type: "number" }, +]; + +function numberChange( + field: keyof Criteria, + onChange: StockCriteriaFieldProps["onChange"], +) { + return (value: number | string) => onChange(field, String(value)); +} + +function selectChange( + field: keyof Criteria, + onChange: StockCriteriaFieldProps["onChange"], +) { + return (value: null | string) => onChange(field, value ?? ""); +} + +function selectOptions(field: CriteriaField, translate: ReturnType["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) => + onChange(field, event.currentTarget.value); +} + +const StockCriteriaField: FC = (props) => { + const { criteria, field, onChange } = props; + const { t } = useTranslation(); + const options = selectOptions(field, t); + if (field.type === "number") return ( + + ); + + if (options) return ( +