18 Commits
Author SHA1 Message Date
ksv741 871f798c93 test: cover null resource envelopes for account pages (#70) (#71)
Frontend verify / commits (pull_request) Successful in 42s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Canceled after 5s
2026-10-13 22:41:25 +03:00
ksv741 b75ca72ea7 build: use native openapi-typescript --check (#60)
Frontend verify / commits (pull_request) Successful in 44s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Successful in 6m44s
2026-10-13 21:24:24 +03:00
codex-bot e67c47b102 refactor: derive payment-system options from schema tuple (#58) (#75)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 12s
Frontend verify / verify (push) Successful in 7m56s
2026-10-13 21:05:07 +03:00
codex-bot 43b0f5574a feat: unify income payment cards and remove comparisons (WealthLedger/main#33) (#74)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 7m18s
feat: unify income payment cards and remove comparisons (WealthLedger/main#33)
2026-10-13 18:17:35 +03:00
codex-bot b357743c10 Merge pull request 'chore: align frontend engineering workflow (WealthLedger/main#27)' (#66) from chore/27-engineering-workflow into develop
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 7m9s
2026-10-12 11:00:35 +03:00
ksv741 0d747cc93e docs: pin frontend workflow reference (WealthLedger/main#27)
Frontend verify / commits (pull_request) Successful in 39s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 6m7s
2026-10-12 10:22:13 +03:00
ksv741 c315f80b1b fix: align frontend workflow rules (WealthLedger/main#27)
Frontend verify / commits (pull_request) Successful in 48s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Canceled after 1m36s
2026-10-12 10:19:36 +03:00
ksv741 617edfd489 chore: align frontend engineering workflow (WealthLedger/main#27)
Frontend verify / commits (pull_request) Successful in 35s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Failing after 3m11s
2026-10-12 09:20:13 +03:00
codex-bot 9ad3245efe Merge pull request 'docs: clarify frontend verification policy' (#65) from codex/issue-24-process into develop
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 6m56s
2026-10-11 23:23:10 +03:00
ksv741 cac4351bc4 docs: clarify frontend verification policy
Frontend verify / commits (pull_request) Successful in 41s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Successful in 6m34s
2026-10-11 23:06:51 +03:00
codex-bot 17f5bae69b feat: add stock screener
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 12s
Frontend verify / verify (push) Successful in 6m39s
2026-10-11 11:50:39 +03:00
ksv741 c8821200ce ci: skip checks on PR metadata edits
Frontend verify / commits (pull_request) Successful in 34s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m37s
2026-10-11 11:36:51 +03:00
ksv741 cec9c6348a fix: preserve latest stock screener result
Frontend verify / commits (pull_request) Successful in 35s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m47s
2026-10-11 11:11:58 +03:00
ksv741 a84411df39 feat: add stock screener 2026-10-11 11:11:58 +03:00
ksv741 3c226f8c29 feat: render hourly investment states
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 12s
Frontend verify / verify (push) Successful in 7m41s
2026-10-09 21:13:34 +03:00
ksv741 20b2cdfd43 fix: show bank summary totals 2026-10-09 21:13:34 +03:00
codex-bot dcd1e3c278 Merge pull request 'feat: align investment client with hourly sync' (#50) from release/1.0.0 into develop
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Successful in 7m32s
Merge hourly sync frontend client
2026-10-09 09:19:49 +03:00
ksv741 432cca299f feat: align investment client with hourly sync
Frontend verify / commits (pull_request) Successful in 40s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 6m43s
2026-10-09 09:04:45 +03:00
82 changed files with 2452 additions and 3008 deletions
+2 -1
View File
@@ -30,7 +30,8 @@ backend checkout и не проверяют backend runtime OpenAPI; измен
shape/status/route сохраняет shape/status/route сохраняет
публичный контракт предоставленного snapshot. публичный контракт предоставленного snapshot.
Одна UI-модель не требует менять API. `openapi:generate` работает offline от snapshot; Одна UI-модель не требует менять API. `openapi:generate` работает offline от snapshot;
`openapi:check` генерирует во временный каталог и сравнивает, не меняя tracked files. `openapi:check` запускает native `--check` того же CLI и падает при устаревших
generated types, не меняя tracked files.
## Auth и invalidation ## Auth и invalidation
+28 -14
View File
@@ -7,11 +7,19 @@
checkout. Внешний API задаётся предоставленным публичным контрактом; checkout. Внешний API задаётся предоставленным публичным контрактом;
исходники или инструкции его реализации не являются зависимостью разработки. исходники или инструкции его реализации не являются зависимостью разработки.
При нехватке требования сообщи, какое решение не определено, до его реализации. При нехватке требования сообщи, какое решение не определено, до его реализации.
При конфликте с общим шаблоном следуй местным правилам и явному запросу
пользователя. Выбирай реализацию самостоятельно, но изменение финансовой
логики, публичного контракта или утверждённой семантики возвращай владельцу
на согласование до зависимой работы.
Начни с результата и affected areas. До edits проследи flow/invariants, найди Начни с результата и affected areas. До edits проследи flow/invariants, найди
owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт. owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт.
Работай напрямую; OpenSpec/SDD lifecycle — только по явному запросу. Выбери lightweight или SDD по [Engineering Workflow](https://git.personal.ksv741.ru/WealthLedger/main/src/commit/03b3b59e6ff2ba391f9dfc42a039a96d0d84c1c9/.ai/conventions/engineering-workflow.md);
Перед Node-командами активируй местную `.nvmrc`: `nvm use --silent`. OpenSpec lifecycle начинай после явного решения Change Owner.
Перед Node-командами активируй местную `.nvmrc`: если `nvm` не загружен в
non-interactive shell, загрузи установленный `$HOME/.nvm/nvm.sh`; затем
выполни `nvm use --silent` из frontend. Если `nvm.sh` недоступен, остановись,
не используй системный Node.
Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы. Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы.
## Change scope ## Change scope
@@ -57,21 +65,27 @@ Peer-конфликт решай совместимыми версиями бе
## Проверки и завершение ## Проверки и завершение
Code/dependency change: `npm ci`, полный `npm run verify` и дополнительные | Когда | Что проверить |
risk checks из местной testing convention. Targeted tests ускоряют итерацию, | --- | --- |
но не заменяют финальный gate. `lint:fix`/format — edits, не proof. | Окружение новое, изменились manifest/lockfile или Node, установка сомнительна | `npm ci` до Node-проверок; одна правка кода переустановки не требует. |
Markdown-only: проверь новые/изменённые тексты, локальные ссылки/якоря, маршруты | Итерация | Адресные тесты, typecheck и lint по затронутому поведению и риску. При изменении поведения добавь/обнови содержательную регрессию, если существующая проверка не ловит её; для косметики не создавай искусственный тест. |
чтения и `git diff --check`; application build ради текста не нужен. | Итоговый code/dependency diff | Перед объявлением готовности и передачей к интеграции выполни один полный `npm run verify` на итоговых входах. Дополнительные risk checks из testing convention и явно утверждённые task checks обязательны; CI проверяет независимо. |
Повтори routing по staged/unstaged/new files. Запиши краткие команды, результаты | Только Markdown | Проверь тексты, ссылки/якоря, маршруты чтения и `git diff --check`; application build не нужен. При изменении verification-контура проверь его работу. |
и ограничения. Зелёный lint не заменяет semantic/security review.
Повтори routing по staged/unstaged/new files. В отчёте по задаче/PR запиши
проверенные входы, команду, exit code, результат и ограничения. Повторяй
проверку при изменении значимых входов (включая config, dependencies и
generated artifacts), сомнении в evidence или явном запросе пользователя.
`lint:fix`/format — edits, не proof; lint не заменяет semantic/security review.
## Git и релизы ## Git и релизы
Работа — в `codex/<task>` от актуального `develop`, затем PR в `develop`. Работа — в ветке `<type>/<issue-number>-<slug>` от актуального `develop`,
затем PR в `develop`; для cross-repo ссылки укажи owner/repo#number.
Сохраняй чужие изменения; перед commit просмотри staged paths и diff. Сохраняй чужие изменения; перед commit просмотри staged paths и diff.
Commits и PR titles — Conventional Commits; допустим `spec:`. Каждый commit Commits и PR titles — Conventional Commits с parser-recognized Issue reference (`fix: … (#12)`
содержит одну связанную задачу. При связанной issue добавь её номер в конец или `chore: … (WealthLedger/main#27)`); допустим `spec:`. Каждый commit
заголовка (`fix: … #12`); без issue номер не нужен. Root другого checkout содержит одну связанную задачу. Исключения release и правила ссылки — в
не управляет этим repo. Engineering Workflow из AGENTS. Root другого checkout не управляет этим repo.
Прямой push в `main`, force-push и перенос опубликованных тегов не входят в Прямой push в `main`, force-push и перенос опубликованных тегов не входят в
обычную работу. Перед release/hotfix прочитай местный README, раздел «Релизы». обычную работу. Перед release/hotfix прочитай местный README, раздел «Релизы».
+12
View File
@@ -0,0 +1,12 @@
---
name: Bug Report
about: Неверное поведение
---
## Проблема и шаги воспроизведения
## Факт / ожидание
## Acceptance Criteria
- [ ]
## Окружение / ссылки (если применимо)
+10
View File
@@ -0,0 +1,10 @@
---
name: Engineering Task
about: CI, тесты, документация, процесс и прочее
---
## Контекст и результат
## Scope / связанные Issues (если есть)
## Acceptance Criteria
- [ ]
+12
View File
@@ -0,0 +1,12 @@
---
name: Feature Request
about: Новое наблюдаемое поведение
---
## Зачем и какой результат
## Ожидаемое поведение
## Acceptance Criteria
- [ ]
## Зависимости / связанные Issues (если есть)
+12
View File
@@ -0,0 +1,12 @@
---
name: Refactoring / Technical Debt
about: Улучшение без нового поведения
---
## Проблема и результат
## Сохраняемые инварианты / границы
## Acceptance Criteria
- [ ]
## Зависимости / ссылки (если есть)
+11
View File
@@ -0,0 +1,11 @@
## Summary
## Linked Issue
Refs
## Changes
## Verification
- Команда / результат / ограничения:
## Breaking Changes (если есть)
+1 -1
View File
@@ -4,7 +4,7 @@ on:
push: push:
branches: [develop, main] branches: [develop, main]
pull_request: pull_request:
types: [opened, synchronize, reopened, edited] types: [opened, synchronize, reopened]
schedule: schedule:
- cron: '17 5 * * *' - cron: '17 5 * * *'
+3
View File
@@ -4,6 +4,9 @@
только его файлы и предоставленные публичные контракты. Перед работой прочитай только его файлы и предоставленные публичные контракты. Перед работой прочитай
[порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем [порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем
объединяй triggers задачи и путей; читай активированные темы до решения. объединяй triggers задачи и путей; читай активированные темы до решения.
До edits найди/создай подходящую Gitea Issue, проверь AC и ветку; чтение
допускается без Issue. Общий маршрут — [Engineering Workflow](https://git.personal.ksv741.ru/WealthLedger/main/src/commit/03b3b59e6ff2ba391f9dfc42a039a96d0d84c1c9/.ai/conventions/engineering-workflow.md)
(main#28; после merge — актуальная версия `develop`).
| Решение / сигнал | Читать | | Решение / сигнал | Читать |
| --- | --- | | --- | --- |
+6 -3
View File
@@ -203,7 +203,8 @@ Checkout frontend содержит все входы сборки и прове
локальную копию и не обращаются к исходникам или CI сервера. Доставка новой локальную копию и не обращаются к исходникам или CI сервера. Доставка новой
схемы — явное обновление snapshot с review и `npm run openapi:generate`. схемы — явное обновление snapshot с review и `npm run openapi:generate`.
Runtime URL API задаётся окружением, а не расположением другого репозитория. Runtime URL API задаётся окружением, а не расположением другого репозитория.
Разработка идёт напрямую по задаче пользователя; OpenSpec/SDD — по явному запросу. Разработка начинается с Issue; lightweight или SDD выбирается по критериям
Engineering Workflow из AGENTS. OpenSpec — после явного решения Change Owner.
Knip исключает `conventional-changelog-conventionalcommits`: release-it загружает Knip исключает `conventional-changelog-conventionalcommits`: release-it загружает
preset по имени из `.release-it.json`. Версия preset закреплена напрямую. preset по имени из `.release-it.json`. Версия preset закреплена напрямую.
@@ -211,9 +212,11 @@ preset по имени из `.release-it.json`. Версия preset закреп
## Релизы ## Релизы
Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые
релизы. Работа: `codex/<task>` от актуального `develop`, PR в `develop`. релизы. Обычная работа: issue-linked ветка от актуального `develop`, PR в `develop`;
общие правила — в Engineering Workflow по ссылке из AGENTS.
Обычные PR допускают squash; release и обратная синхронизация сохраняют историю. Обычные PR допускают squash; release и обратная синхронизация сохраняют историю.
Commits и PR titles — Conventional Commits, включая `spec:`. Версии вручную не повышай. Commits и PR titles — Conventional Commits со ссылкой на Issue (кроме release),
включая `spec:`. Версии вручную не повышай.
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что 1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что
`develop` содержит последний релиз. Создай `release/<name>` от проверенного `develop` содержит последний релиз. Создай `release/<name>` от проверенного
+6
View File
@@ -1,6 +1,12 @@
module.exports = { module.exports = {
defaultIgnores: false,
extends: ["@commitlint/config-conventional"], extends: ["@commitlint/config-conventional"],
ignores: [
message => /^chore\(release\): \d+\.\d+\.\d+$/.test(message.trim()),
message => /^Merge pull request ['"].+['"] \(#\d+\) from .+ into .+$/.test(message.trim()),
],
rules: { rules: {
"references-empty": [2, "never"],
"type-enum": [ "type-enum": [
2, 2,
"always", "always",
+1 -1
View File
@@ -20,7 +20,7 @@
"test:coverage": "npm run build:packages && vitest run --coverage", "test:coverage": "npm run build:packages && vitest run --coverage",
"test:lint": "node --test scripts/test-eslint.mjs", "test:lint": "node --test scripts/test-eslint.mjs",
"openapi:generate": "openapi-typescript src/shared/api/spec/openapi.json -o src/shared/api/generated/schema.d.ts", "openapi:generate": "openapi-typescript src/shared/api/spec/openapi.json -o src/shared/api/generated/schema.d.ts",
"openapi:check": "node scripts/check-openapi.mjs", "openapi:check": "openapi-typescript src/shared/api/spec/openapi.json -o src/shared/api/generated/schema.d.ts --check",
"knip": "npm run build:packages && knip", "knip": "npm run build:packages && knip",
"deps": "npm run build:packages && depcruise src packages --config dependency-cruiser.cjs --output-type err-long", "deps": "npm run build:packages && depcruise src packages --config dependency-cruiser.cjs --output-type err-long",
"verify": "npm run build:packages && npm run openapi:check && tsc -b --noEmit && eslint . --max-warnings=0 && npm run test:lint && vitest run --coverage && knip && depcruise src packages --config dependency-cruiser.cjs --output-type err-long && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long && tsc -b && vite build && storybook build --output-dir storybook-static --disable-telemetry", "verify": "npm run build:packages && npm run openapi:check && tsc -b --noEmit && eslint . --max-warnings=0 && npm run test:lint && vitest run --coverage && knip && depcruise src packages --config dependency-cruiser.cjs --output-type err-long && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long && tsc -b && vite build && storybook build --output-dir storybook-static --disable-telemetry",
-45
View File
@@ -1,45 +0,0 @@
import { spawnSync } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import nodePath from "node:path";
const temporaryDirectory = await mkdtemp(
nodePath.join(tmpdir(), "wealth-ledger-openapi-"),
);
const generatedPath = nodePath.join(temporaryDirectory, "schema.d.ts");
const committedPath = nodePath.resolve("src/shared/api/generated/schema.d.ts");
const cliPath = nodePath.resolve("node_modules/openapi-typescript/bin/cli.js");
let exitCode = 0;
try {
const generated = spawnSync(
process.execPath,
[cliPath, "src/shared/api/spec/openapi.json", "-o", generatedPath],
{ encoding: "utf8" },
);
if (generated.status === 0) {
const [committed, current] = await Promise.all([
readFile(committedPath, "utf8"),
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
readFile(generatedPath, "utf8"),
]);
if (committed !== current) {
console.error(
"Generated OpenAPI types are stale. Run `npm run openapi:generate` and commit the result.",
);
exitCode = 1;
}
}
else {
process.stderr.write(generated.stderr || generated.stdout);
exitCode = generated.status ?? 1;
}
}
finally {
await rm(temporaryDirectory, { force: true, recursive: true });
}
process.exitCode = exitCode;
+1 -1
View File
@@ -85,7 +85,7 @@ test("ESLint enforces the frontend profile and excludes generated artifacts", as
"security/detect-non-literal-regexp", "security/detect-non-literal-regexp",
], ],
[ [
"scripts/check-openapi.mjs", "scripts/test-pre-commit.mjs",
"import { exec } from \"node:child_process\"; exec(process.argv[2]);", "import { exec } from \"node:child_process\"; exec(process.argv[2]);",
"security/detect-child-process", "security/detect-child-process",
], ],
@@ -95,10 +95,6 @@ const analytics: IncomeAnalytics
}, },
], ],
period: { from: "2026-08-01", to: "2026-08-31" }, period: { from: "2026-08-01", to: "2026-08-31" },
previous: {
period: { from: "2026-07-01", to: "2026-07-31" },
totalsByCurrency: [],
},
totalsByCurrency: [ totalsByCurrency: [
{ {
coupon: { currency: "RUB", value: "2" }, coupon: { currency: "RUB", value: "2" },
@@ -107,7 +103,6 @@ const analytics: IncomeAnalytics
total: { currency: "RUB", value: "2" }, total: { currency: "RUB", value: "2" },
}, },
], ],
yearComparison: null,
}; };
const CurrentLocation: FC = () => { const CurrentLocation: FC = () => {
+3 -1
View File
@@ -6,6 +6,7 @@ import { calendarDate } from "@/shared/lib";
import { bankToday } from "./form-validation"; import { bankToday } from "./form-validation";
import { moneyCents } from "./money"; import { moneyCents } from "./money";
const MAX_CARD_NAME_LENGTH = 100; const MAX_CARD_NAME_LENGTH = 100;
const paymentSystemValues = ["mir", "visa", "mastercard"] as const;
const MIN_BALANCE_KOPECKS = -9_223_372_036_854_775_808n; const MIN_BALANCE_KOPECKS = -9_223_372_036_854_775_808n;
const MAX_BALANCE_KOPECKS = 9_223_372_036_854_775_807n; const MAX_BALANCE_KOPECKS = 9_223_372_036_854_775_807n;
@@ -63,7 +64,7 @@ const bankCardSchema = z.object({
openingBalance: moneySchema, openingBalance: moneySchema,
openingDate: bankDateSchema, openingDate: bankDateSchema,
paymentSystem: z paymentSystem: z
.enum(["", "mir", "visa", "mastercard"], { .enum(["", ...paymentSystemValues], {
error: validationMessage("bank.validation.paymentSystem"), error: validationMessage("bank.validation.paymentSystem"),
}) })
.optional(), .optional(),
@@ -75,5 +76,6 @@ export {
bankCardSchema, bankCardSchema,
type BankCardValues, type BankCardValues,
bankDateSchema, bankDateSchema,
paymentSystemValues,
positiveMoneySchema, positiveMoneySchema,
}; };
@@ -1,8 +1,7 @@
import { useController, useFormContext } from "react-hook-form"; import { useController, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { BankCardValues } from "../../model/form.schema"; import { type BankCardValues, paymentSystemValues } from "../../model/form.schema";
import { bankDateField } from "../bankDateField"; import { bankDateField } from "../bankDateField";
function useBankCardFieldsVm() { function useBankCardFieldsVm() {
@@ -16,7 +15,7 @@ function useBankCardFieldsVm() {
const { field: openingDateField } = useController({ control, name: "openingDate" }); const { field: openingDateField } = useController({ control, name: "openingDate" });
const { field: paymentSystemField } = useController({ control, name: "paymentSystem" }); const { field: paymentSystemField } = useController({ control, name: "paymentSystem" });
const paymentSystems = ["mir", "visa", "mastercard"].map(value => ({ const paymentSystems = paymentSystemValues.map(value => ({
label: t(`bank.paymentSystems.${value}`), label: t(`bank.paymentSystems.${value}`),
value, value,
})); }));
@@ -15,6 +15,28 @@
min-width: 0; 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 { .categories {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -0,0 +1,35 @@
import { MantineProvider } from "@mantine/core";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { BankSummaryView } from "./BankSummary.view";
const money = (value: string) => ({ currency: "RUB", value });
describe("BankSummaryView", () => {
it("shows expenses, refunds, and net monthly totals", () => {
render(
<MantineProvider env="test">
<BankSummaryView
data={{
cardId: "card-1",
categories: [],
expenses: money("1000"),
net: money("800"),
refunds: money("200"),
}}
error={false}
fetching={false}
loading={false}
/>
</MantineProvider>,
);
expect(screen.getAllByText("Расходы")).toHaveLength(2);
expect(screen.getByText("Возвращено")).toBeInTheDocument();
expect(screen.getByText("После возвратов")).toBeInTheDocument();
expect(screen.getByText("1 000,00 ₽")).toBeInTheDocument();
expect(screen.getByText("200,00 ₽")).toBeInTheDocument();
expect(screen.getByText("800,00 ₽")).toBeInTheDocument();
});
});
@@ -92,9 +92,31 @@ const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
</Title> </Title>
{props.data && ( {props.data && (
<Text className={classes.total}> <dl className={classes.totals}>
{formatMoney(props.data.net, { withZeroCents: true })} <div>
</Text> <dt>{t("bank.summary")}</dt>
<dd>
{formatMoney(props.data.expenses, { withZeroCents: true })}
</dd>
</div>
<div>
<dt>{t("bank.refunds")}</dt>
<dd>
{formatMoney(props.data.refunds, { withZeroCents: true })}
</dd>
</div>
<div>
<dt>{t("bank.net")}</dt>
<dd className={classes.total}>
{formatMoney(props.data.net, { withZeroCents: true })}
</dd>
</div>
</dl>
)} )}
</div> </div>
@@ -36,6 +36,12 @@ function entryLabelKey(operation: BrokerageOperation): string {
return `ledger.category.${operation.category}`; return `ledger.category.${operation.category}`;
} }
function isIncomePayment(operation: BrokerageOperation): boolean {
return [OperationType.Coupon, OperationType.Dividend].some(type =>
isOperationType(operation.type, type),
);
}
function isOperationType(value: unknown, expected: OperationType): boolean { function isOperationType(value: unknown, expected: OperationType): boolean {
return value === expected; return value === expected;
} }
@@ -109,4 +115,4 @@ function paymentPresentation(
}; };
} }
export { operationLabelKey, paymentPresentation }; export { isIncomePayment, operationLabelKey, paymentPresentation };
@@ -11,10 +11,11 @@ import {
formatSnapshotTime, formatSnapshotTime,
instrumentLogoUrl, instrumentLogoUrl,
} from "@/shared/lib"; } from "@/shared/lib";
import { PayoutCard } from "@/shared/ui";
import type { BrokerageOperation } from "../api/useBrokerageOperations"; import type { BrokerageOperation } from "../api/useBrokerageOperations";
import { operationLabelKey, paymentPresentation } from "../model/display"; import { isIncomePayment, operationLabelKey, paymentPresentation } from "../model/display";
type LedgerEntryRowProps = { type LedgerEntryRowProps = {
operation: BrokerageOperation; operation: BrokerageOperation;
@@ -75,6 +76,28 @@ const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
} }
else details = undefined; else details = undefined;
if (isIncomePayment(operation)) {
const extraMetadata = [
instrument,
props.withDetails ? null : operation.account?.name,
].filter(Boolean).join(" · ");
return (
<PayoutCard
details={details}
extraMetadata={extraMetadata}
imageSrc={instrumentLogoUrl(operation.logoName)}
initial={(instrument.trim()[0] ?? "?").toUpperCase()}
kind="executed"
occurredAt={formatSnapshotTime(operation.occurredAt)}
payment={operation.payment}
title={operation.description || t(operationLabelKey(operation.type))}
typeLabel={t(paymentPresentation(operation).label)}
wrap={props.wrap}
/>
);
}
return ( return (
<EntryCard <EntryCard
details={details} details={details}
@@ -4,17 +4,22 @@ const brokerageAccountsQueryOptions = () =>
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"); apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts");
const brokerageAccountsQueryKey = brokerageAccountsQueryOptions().queryKey; const brokerageAccountsQueryKey = brokerageAccountsQueryOptions().queryKey;
const BROKERAGE_ACCOUNTS_STALE_TIME_MS = 1_800_000;
type BrokerageAccount = NonNullable< type BrokerageAccount = NonNullable<
paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"]["data"] paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"]["data"]
>[number]; >[number];
function useBrokerageAccounts() { function useBrokerageAccounts() {
return useResourceQuery({ const query = useResourceQuery({
meta: { accessGroup: "brokerage" }, meta: { accessGroup: "brokerage" },
...brokerageAccountsQueryOptions(), ...brokerageAccountsQueryOptions(),
staleTime: 60_000, staleTime: BROKERAGE_ACCOUNTS_STALE_TIME_MS,
}); });
return {
...query,
};
} }
export { export {
@@ -205,24 +205,32 @@ export const IndependentFreshness: Story
], ],
[ [
{ {
availability: "complete", availability: "available",
completeness: "complete",
dataRevision: "1", dataRevision: "1",
freshness: "stale", freshness: "stale",
lastSuccessfulAt: "2026-09-24T10:30:00Z", lastAttempt: {
resourceKey: "portfolio/storybook-account", finishedAt: "2026-09-24T10:31:00Z",
sync: { problems: [],
errorCode: "TBANK_UNAVAILABLE", startedAt: "2026-09-24T10:30:00Z",
id: "failed",
state: "failed", state: "failed",
}, },
lastSuccessfulAt: "2026-09-24T10:30:00Z",
resourceKey: "portfolio/storybook-account",
}, },
{ {
availability: "missing", availability: "initial-loading",
completeness: "incomplete",
dataRevision: "0", dataRevision: "0",
freshness: "unknown", freshness: "unknown",
lastAttempt: {
finishedAt: null,
problems: [],
startedAt: "2026-09-24T10:30:00Z",
state: "running",
},
lastSuccessfulAt: null, lastSuccessfulAt: null,
resourceKey: "portfolio/waiting", resourceKey: "portfolio/waiting",
sync: { errorCode: null, id: "waiting", state: "running" },
}, },
], ],
), ),
@@ -64,15 +64,16 @@ const BrokerageAccountSourceStatus: FC<BrokerageAccountSourceStatusProps> = (
const { source } = props; const { source } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const isSyncing = source?.sync.state === "running"; const isSyncing = source?.lastAttempt.state === "running";
const errorText const errorText
= source?.sync.state === "failed" ? t("accounts.refreshError") : null; = source?.lastAttempt.state === "failed" ? t("accounts.refreshError") : null;
return ( return (
<> <>
<SourceSyncStatus <SourceSyncStatus
errorText={errorText} errorText={errorText}
lastAttempt={source?.lastAttempt}
lastSuccessfulAt={source?.lastSuccessfulAt} lastSuccessfulAt={source?.lastSuccessfulAt}
stale={source?.freshness === "stale"} stale={source?.freshness === "stale"}
/> />
@@ -4,6 +4,7 @@ import { EntryCard } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatDate, formatMoney, instrumentLogoUrl } from "@/shared/lib"; import { formatDate, formatMoney, instrumentLogoUrl } from "@/shared/lib";
import { PayoutCard } from "@/shared/ui";
import type { BrokerageEvent } from "../api/useBrokerageEvents"; import type { BrokerageEvent } from "../api/useBrokerageEvents";
@@ -15,6 +16,27 @@ const BrokerageEventCard: FC<BrokerageEventCardProps> = (props) => {
const date = formatDate(event.date); const date = formatDate(event.date);
if (event.type === "coupon" || event.type === "dividend") {
const title = [t(`events.type.${event.type}`), event.displayName].join(" · ");
const extraMetadata = [event.isin, event.account?.name]
.filter(Boolean)
.join(" · ");
return (
<PayoutCard
date={date}
dateKind={event.dateKind}
extraMetadata={extraMetadata}
imageSrc={instrumentLogoUrl(event.logoName)}
initial={(event.ticker.trim()[0] ?? "?").toUpperCase()}
kind="forecast"
projectedAmount={event.projectedAmount}
title={title}
/>
);
}
const metadata = [ const metadata = [
date, date,
event.dateKind === "registry" event.dateKind === "registry"
@@ -40,7 +62,7 @@ const BrokerageEventCard: FC<BrokerageEventCardProps> = (props) => {
title={[t(`events.type.${event.type}`), event.displayName].join(" · ")} title={[t(`events.type.${event.type}`), event.displayName].join(" · ")}
tone="neutral" tone="neutral"
value={projectedAmount} value={projectedAmount}
valueLabel={t("events.projectedAmount")} valueLabel={t("payout.forecast")}
/> />
); );
}; };
-4
View File
@@ -4,9 +4,6 @@ type IncomeAnalytics = NonNullable<
paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["data"] paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["data"]
>; >;
type IncomeAnalyticsMeta
= paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["meta"];
type IncomeSummary = NonNullable< type IncomeSummary = NonNullable<
paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"]["data"] paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"]["data"]
>; >;
@@ -47,7 +44,6 @@ function useIncomeSummary(accountId?: string) {
export { export {
type IncomeAnalytics, type IncomeAnalytics,
type IncomeAnalyticsMeta,
type IncomeSummary, type IncomeSummary,
useIncomeAnalytics, useIncomeAnalytics,
useIncomeSummary, useIncomeSummary,
-1
View File
@@ -1,6 +1,5 @@
export { export {
type IncomeAnalytics, type IncomeAnalytics,
type IncomeAnalyticsMeta,
type IncomeSummary, type IncomeSummary,
useIncomeAnalytics, useIncomeAnalytics,
useIncomeSummary, useIncomeSummary,
+2 -16
View File
@@ -6,10 +6,6 @@ import {
type StockCandles = NonNullable<paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"]>; type StockCandles = NonNullable<paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
type StockPosition = NonNullable<paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
const MIN_STOCK_SEARCH_LENGTH = 2;
type StockDividends = NonNullable<paths["/api/v1/stocks/{uid}/dividends"]["get"]["responses"][200]["content"]["application/json"]["data"]>; type StockDividends = NonNullable<paths["/api/v1/stocks/{uid}/dividends"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
type StockPeriod = NonNullable< type StockPeriod = NonNullable<
@@ -18,6 +14,8 @@ type StockPeriod = NonNullable<
>["period"] >["period"]
>; >;
type StockPosition = NonNullable<paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
const STOCK_STALE_TIME_MS = 30_000; const STOCK_STALE_TIME_MS = 30_000;
function useStock(identifier: string) { function useStock(identifier: string) {
@@ -96,18 +94,7 @@ function useStockQuote(uid: string) {
}); });
} }
function useStockSearch(query: string) {
return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks", {
params: { query: { query } },
}),
enabled: query.length >= MIN_STOCK_SEARCH_LENGTH,
staleTime: STOCK_STALE_TIME_MS,
});
}
export { export {
MIN_STOCK_SEARCH_LENGTH,
type StockCandles, type StockCandles,
type StockDividends, type StockDividends,
type StockPosition, type StockPosition,
@@ -117,5 +104,4 @@ export {
useStockDividends, useStockDividends,
useStockPosition, useStockPosition,
useStockQuote, useStockQuote,
useStockSearch,
}; };
-2
View File
@@ -1,5 +1,4 @@
export { export {
MIN_STOCK_SEARCH_LENGTH,
type StockCandles, type StockCandles,
type StockDividends, type StockDividends,
type StockPosition, type StockPosition,
@@ -9,5 +8,4 @@ export {
useStockDividends, useStockDividends,
useStockPosition, useStockPosition,
useStockQuote, useStockQuote,
useStockSearch,
} from "./api/queries"; } from "./api/queries";
@@ -5,20 +5,23 @@ import { resource } from "@/test/resource";
import { section } from "./section"; import { section } from "./section";
describe("instrument source and transport presentation", () => { 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", "keeps %s separate from successful transport",
(availability) => { (availability, completeness) => {
const data = availability === "missing" ? undefined : { rows: [] }; const data = availability === "initial-loading" ? undefined : { rows: [] };
const state = section({ const state = section({
data, data,
isError: false, isError: false,
meta: resource(data, { availability }).meta, meta: resource(data, { availability, completeness }).meta,
}); });
expect(state.loading).toBe(availability === "missing"); expect(state.loading).toBe(availability === "initial-loading");
expect(state.partial).toBe(availability === "partial"); expect(state.partial).toBe(completeness === "incomplete");
expect(state.hasData).toBe(availability !== "missing"); expect(state.hasData).toBe(availability !== "initial-loading");
}, },
); );
it("preserves empty successful data instead of treating it as missing", () => { 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", () => { it("preserves a full source when force fails", () => {
const meta = resource([], { 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; }).meta;
const state = section({ data: [], isError: false, meta }); const state = section({ data: [], isError: false, meta });
@@ -53,7 +61,12 @@ describe("instrument source and transport presentation", () => {
data: undefined, data: undefined,
isError: false, isError: false,
meta: resource(null, { 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, }).meta,
}); });
@@ -16,14 +16,14 @@ function section(query: {
isError: boolean; isError: boolean;
meta?: InvestmentResourceMeta; meta?: InvestmentResourceMeta;
}): SectionState { }): SectionState {
const isError = query.isError || query.meta?.sync.state === "failed"; const isError = query.isError || query.meta?.lastAttempt.state === "failed";
const hasData = hasSectionData(query.data); const hasData = hasSectionData(query.data);
return { return {
error: isError, error: isError,
hasData, hasData,
loading: !hasData && !isError, loading: !hasData && !isError,
partial: query.meta?.availability === "partial", partial: query.meta?.completeness === "incomplete",
}; };
} }
@@ -13,7 +13,6 @@ function isInvestmentPath(path: string) {
"/api/v1/brokerage-accounts", "/api/v1/brokerage-accounts",
"/api/v1/stocks", "/api/v1/stocks",
"/api/v1/bonds", "/api/v1/bonds",
"/api/v1/tbank-sync",
].some(root => path === root || path.startsWith(`${root}/`)); ].some(root => path === root || path.startsWith(`${root}/`));
} }
@@ -3,14 +3,12 @@ import { http, HttpResponse } from "msw";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { useBrokerageAccounts } from "@/entities/brokerage-account"; import { useBrokerageAccounts } from "@/entities/brokerage-account";
import { useStockQuote } from "@/entities/stock"; import { configureAuthClient } from "@/shared/api";
import { configureAuthClient, invalidateResourceAccess } from "@/shared/api";
import { createQueryWrapper } from "@/test/query"; import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource"; import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { useScreenSync } from "../index"; import { useScreenSync } from "../index";
import { commitTbankConnectionBoundary } from "./connection-boundary";
function auth() { function auth() {
configureAuthClient({ configureAuthClient({
@@ -20,364 +18,23 @@ function auth() {
}); });
} }
describe("page-owned screen synchronization", () => { describe("read-only screen synchronization", () => {
it("keeps the current scope when an older sync acceptance arrives last", async () => { it("retries the resource with GET without requesting source observation", async () => {
auth(); auth();
let gets = 0;
const { promise: blocked, resolve: release } const syncModes: (null | string)[] = [];
= Promise.withResolvers<void>();
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<void>;
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<void>();
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;
server.use( server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => { 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())); return gets === 1
}), ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
http.post("*/api/v1/tbank-sync", async ({ request }) => { : HttpResponse.json(resource([]));
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",
},
);
}), }),
); );
const { result, unmount } = renderHook( const { result } = renderHook(
() => { () => {
const accounts = useBrokerageAccounts(); const accounts = useBrokerageAccounts();
@@ -389,382 +46,36 @@ describe("page-owned screen synchronization", () => {
{ wrapper: createQueryWrapper().wrapper }, { 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 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"]); expect(gets).toBe(2);
vi.useFakeTimers(); expect(syncModes).toEqual([null, null]);
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();
}
}); });
it("clears private snapshots when status confirms access loss", async () => { it("refreshes the currently open resources together", async () => {
auth(); auth();
let gets = 0;
server.use( server.use(
http.get("*/api/v1/brokerage-accounts", () => http.get("*/api/v1/brokerage-accounts", () => {
HttpResponse.json( gets += 1;
resource(
[], return HttpResponse.json(resource([]));
{ }),
sync: { errorCode: null, id: "revoked", state: "running" },
},
),
),
),
http.get("*/api/v1/tbank-sync/:id", () =>
HttpResponse.json({ code: "INVALID_TBANK_TOKEN" }, { status: 400 }),
),
); );
const { result } = renderHook( const { result } = renderHook(
() => { () => {
const accounts = useBrokerageAccounts(); const accounts = useBrokerageAccounts();
return { return useScreenSync({ screen: "accounts" }, [accounts]);
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts]),
};
}, },
{ wrapper: createQueryWrapper().wrapper }, { wrapper: createQueryWrapper().wrapper },
); );
await waitFor(() => expect(result.current.accounts.isError).toBe(true)); await waitFor(() => expect(gets).toBe(1));
expect(result.current.accounts.data).toBeUndefined(); await act(async () => result.current.refresh());
}); await waitFor(() => expect(gets).toBe(2));
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);
}); });
}); });
@@ -1,269 +1,52 @@
import { import type { QueryKey } from "@tanstack/react-query";
focusManager,
onlineManager,
type QueryKey,
useQueryClient,
} from "@tanstack/react-query";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { import type { InvestmentResourceMeta } from "@/shared/api";
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"];
type ScreenResource = { type ScreenResource = {
isError: boolean; isError: boolean;
isFetching: boolean; isFetching: boolean;
meta?: InvestmentResourceMeta; meta?: InvestmentResourceMeta;
observe: () => Promise<unknown>;
queryKey: QueryKey; queryKey: QueryKey;
refetch: () => Promise<unknown>; refetch: () => Promise<unknown>;
}; };
const STATUS_INTERVAL_MS = 5000; type ScreenScope = {
const FIRST_SOURCE_RECOVERY_DELAY_MS = 5000; [key: string]: unknown;
const SECOND_SOURCE_RECOVERY_DELAY_MS = 15_000; screen: string;
};
function requiresSourceRecovery(meta?: InvestmentResourceMeta): boolean { const READ_INTERVAL_MS = 1_800_000;
if (!meta || meta.unavailableReason) return false;
return ["cancelled", "failed"].includes(meta.sync.state) function useScreenSync(_scope: ScreenScope, resources: ScreenResource[]) {
|| meta.availability === "missing" const resourcesRef = useRef(resources);
|| meta.freshness === "stale" useEffect(() => {
|| (meta.dependencies ?? []).some(dependency => resourcesRef.current = resources;
requiresSourceRecovery(dependency), }, [resources]);
);
}
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<unknown>());
const scopeKey = JSON.stringify(scope);
const requestInFlightRef = useRef<null | string>(null);
const lifecycleRecoveryRef = useRef(new Set<string>());
const sourceRecoveryRef = useRef(new Map<string, number>());
const sourceTimersRef = useRef(new Set<ReturnType<typeof setTimeout>>());
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,
})),
);
useEffect(() => { useEffect(() => {
for (const status of statuses) { const timer = setInterval(() => {
if (!status.error || deniedRef.current.has(status.error)) { for (const resource of resourcesRef.current)
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);
void resource.refetch().catch(() => {}); void resource.refetch().catch(() => {});
}, delay); }, READ_INTERVAL_MS);
sourceTimersRef.current.add(timer); return () => clearInterval(timer);
} }, []);
}, [resources, scopeKey]);
useEffect(() => { const isRefreshing = resources.some(resource => resource.isFetching);
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 refresh = async () => { const refresh = async () => {
if (isRefreshing || requestInFlightRef.current === scopeKey) return; if (isRefreshing) return;
requestInFlightRef.current = scopeKey; await Promise.allSettled(resources.map(resource => resource.refetch()));
try {
await mutation.mutateAsync({ body: scope });
}
catch {
/*
The mutation retains the transport error for presentation.
*/
}
finally {
if (requestInFlightRef.current === scopeKey)
requestInFlightRef.current
= null;
}
}; };
// Retry failed HTTP reads without asking the source to import again. // Retry only the HTTP read; source attempts are reported in resource metadata.
// A source failure (or explicit refresh with no read error) still needs a sync.
const retry = async () => { const retry = async () => {
const failed = resources.filter( const failed = resources.filter(resource => resource.isError);
resource => resource.isError,
);
const failedStatuses = statuses.filter(status => status.isError); if (failed.length > 0)
if (failed.length > 0 || failedStatuses.length > 0) { await Promise.allSettled(failed.map(resource => resource.refetch()));
await Promise.allSettled([
...failed.map(resource => resource.observe()),
...failedStatuses.map(status => status.refetch()),
]);
}
else await refresh(); else await refresh();
}; };
@@ -271,13 +54,11 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
refresh, refresh,
refreshing: isRefreshing, refreshing: isRefreshing,
retry, retry,
sourceError: sourceError: resources.some(
statuses.some(status => status.data?.state === "failed") ({ meta }) => meta?.lastAttempt.state === "failed",
|| resources.some(({ meta }) => meta?.sync.state === "failed"), ),
transportError: transportError: resources.some(resource => resource.isError),
(isCurrentMutation && mutation.isError)
|| statuses.some(status => status.isError),
}; };
} }
export { type InvestmentScreen, useScreenSync }; export { useScreenSync };
@@ -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<void>();
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<unknown>;
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();
},
);
});
@@ -123,9 +123,10 @@ describe("Upcoming event date filters", () => {
resource( resource(
null, null,
{ {
sync: { lastAttempt: {
errorCode: "TBANK_UNAVAILABLE", finishedAt: "2026-08-14T00:01:00Z",
id: null, problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed", state: "failed",
}, },
}, },
@@ -309,7 +310,14 @@ it("retains the previous complete forecast when its source refresh fails", async
const snapshot = resource( const snapshot = resource(
{ events, incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }] }, { 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( server.use(
@@ -51,8 +51,9 @@ describe("AccountEventsPage", () => {
isError: false, isError: false,
isLoading: false, isLoading: false,
meta: { meta: {
availability: "complete", availability: "available",
sync: { errorCode: null, id: null, state: "idle" }, completeness: "complete",
lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" },
}, },
refetch: vi.fn(), refetch: vi.fn(),
} as never, } as never,
@@ -85,8 +86,9 @@ describe("AccountEventsPage", () => {
isError: false, isError: false,
isLoading: false, isLoading: false,
meta: { meta: {
availability: "complete", availability: "available",
sync: { errorCode: null, id: null, state: "idle" }, completeness: "complete",
lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" },
}, },
refetch: vi.fn(), refetch: vi.fn(),
} as never, } as never,
@@ -138,8 +140,9 @@ describe("AccountEventsPage", () => {
isError: false, isError: false,
isLoading: false, isLoading: false,
meta: { meta: {
availability: "complete", availability: "available",
sync: { errorCode: null, id: null, state: "idle" }, completeness: "complete",
lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" },
}, },
refetch: vi.fn(), refetch: vi.fn(),
} as never, } as never,
@@ -153,7 +156,7 @@ describe("AccountEventsPage", () => {
expect(screen.getAllByText(/RU000A0JX0J2$/)).toHaveLength(1); expect(screen.getAllByText(/RU000A0JX0J2$/)).toHaveLength(1);
expect(screen.getByText("25.08.2026")).toBeVisible(); expect(screen.getByText("25.08.2026")).toBeVisible();
expect(screen.queryByText(/Дата события/)).not.toBeInTheDocument(); expect(screen.queryByText(/Дата события/)).not.toBeInTheDocument();
expect(screen.getAllByText("Ожидаемая сумма")).toHaveLength(2); expect(screen.getAllByText("Прогноз")).toHaveLength(2);
const logo = screen.queryByAltText(""); const logo = screen.queryByAltText("");
expect(logo).toHaveAttribute( expect(logo).toHaveAttribute(
"src", "src",
@@ -194,8 +197,9 @@ describe("AccountEventsPage", () => {
isError: false, isError: false,
isLoading: true, isLoading: true,
meta: { meta: {
availability: "complete", availability: "available",
sync: { errorCode: null, id: null, state: "idle" }, completeness: "complete",
lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" },
}, },
refetch: vi.fn(), refetch: vi.fn(),
} as never, } as never,
@@ -1,6 +1,8 @@
import { useState } from "react"; import { useState } from "react";
import { useParams } from "react-router"; import { useParams } from "react-router";
import type { InvestmentResourceMeta } from "@/shared/api";
import { import {
type BrokerageEvent, type BrokerageEvent,
useBrokerageEvents, useBrokerageEvents,
@@ -25,6 +27,7 @@ type AccountEventsPageViewModel
onPeriodPopoverOpen: () => void; onPeriodPopoverOpen: () => void;
onReset: () => void; onReset: () => void;
partial?: boolean; partial?: boolean;
source?: InvestmentResourceMeta;
state: "loaded"; state: "loaded";
} }
| { onReset: () => void; state: "error" } | { onReset: () => void; state: "error" }
@@ -74,7 +77,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
const availability = useInitialAvailability({ const availability = useInitialAvailability({
hasContent: !!events.data, hasContent: !!events.data,
identity: scope, identity: scope,
isError: events.isError || events.meta?.sync.state === "failed", isError: events.isError || events.meta?.lastAttempt.state === "failed",
isFetching: events.isFetching, isFetching: events.isFetching,
}); });
@@ -86,7 +89,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
setSearchParameters({}); setSearchParameters({});
}; };
if ((events.isError || events.meta?.sync.state === "failed") && !events.data) if ((events.isError || events.meta?.lastAttempt.state === "failed") && !events.data)
return { return {
onReset, onReset,
state: "error", state: "error",
@@ -115,7 +118,8 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
), ),
onPeriodPopoverOpen: () => setMaxDate(moscowDate(new Date())), onPeriodPopoverOpen: () => setMaxDate(moscowDate(new Date())),
onReset, onReset,
partial: events.meta?.availability !== "complete", partial: events.meta?.completeness === "incomplete",
source: events.meta,
state: "loaded", state: "loaded",
}; };
} }
@@ -15,6 +15,7 @@ import {
BrokerageEventCard, BrokerageEventCard,
brokerageEventKey, brokerageEventKey,
} from "@/entities/brokerage-event"; } from "@/entities/brokerage-event";
import { SourceSyncStatus } from "@/shared/ui";
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm"; import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
@@ -55,6 +56,12 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
<Stack gap="lg"> <Stack gap="lg">
<Title order={1}>{t("events.title")}</Title> <Title order={1}>{t("events.title")}</Title>
<SourceSyncStatus
lastAttempt={props.source?.lastAttempt}
lastSuccessfulAt={props.source?.lastSuccessfulAt}
stale={props.source?.freshness === "stale"}
/>
{!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>} {!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>}
<Text c="dimmed">{t("events.hint")}</Text> <Text c="dimmed">{t("events.hint")}</Text>
+14 -156
View File
@@ -2,167 +2,25 @@ import { describe, expect, it } from "vitest";
import type { IncomeAnalytics } from "@/entities/income"; import type { IncomeAnalytics } from "@/entities/income";
import { resource } from "@/test/resource";
import { toIncomeDisplayModel } from "./display"; import { toIncomeDisplayModel } from "./display";
const analytics: IncomeAnalytics = { describe("income payments display", () => {
accountId: "account-1", it("keeps repeated IDs, order and logo revisions without reconstructing financial data", () => {
bucketGranularity: "day", const payment = {
buckets: [], id: "same",
calculatedAt: "2026-08-27T00:00:00Z", instrumentKey: "bond:RUB",
entries: [], logoName: null,
instruments: [], } as IncomeAnalytics["entries"][number];
period: { from: "2026-08-01", to: "2026-08-27" },
previous: {
period: { from: "2026-07-01", to: "2026-07-31" },
totalsByCurrency: [comparison("RUB")],
},
totalsByCurrency: [],
yearComparison: null,
};
function comparison(currency: string) { const analytics = {
return { entries: [payment, payment, { ...payment, logoName: "bond.png" }],
compared: summary(currency, "1", "0"), } as IncomeAnalytics;
currency,
difference: summary(currency, "9", "2"),
selected: summary(currency, "10", "2"),
};
}
function money(currency: string, value: string) { const display = toIncomeDisplayModel(analytics);
return { currency, value };
}
function summary(currency: string, coupon: string, dividend: string) { expect(display.entries.map(({ operation }) => operation)).toEqual(
return { analytics.entries,
coupon: money(currency, coupon),
currency,
dividend: money(currency, dividend),
total: money(currency, String(Number(coupon) + Number(dividend))),
};
}
describe("income comparison display", () => {
it.each([
["25", "100", "+25 %"],
["-25", "100", "-25 %"],
["0", "100", "0 %"],
["-0.000", "100", "0 %"],
["0.0001", "100", "0 %"],
["1", "3", "+33,3 %"],
["0.01", "0.04", "+25 %"],
["123456789012345678.123456789", "123456789012345678.123456789", "+100 %"],
[
"10",
"0",
null,
],
[
"0",
"0",
null,
],
[
"10",
"-0.00",
null,
],
[
"10",
"-100",
null,
],
[
"9".repeat(400),
"1",
null,
],
[
"1",
"9".repeat(400),
null,
],
])("formats change %s against base %s", (difference, base, expected) => {
const value = comparison("RUB");
value.difference.coupon = money("RUB", difference);
value.compared.coupon = money("RUB", base);
const display = toIncomeDisplayModel({
...analytics,
yearComparison: {
period: { from: "2025-08-01", to: "2025-08-27" },
totalsByCurrency: [value],
},
});
const result = display?.comparison?.values[0];
expect(
result?.percentages.coupon?.replaceAll("\u{A0}", " ")
?? null,
).toBe(expected);
expect(result?.difference.coupon.value).toBe(difference);
expect(result?.compared.coupon.value).toBe(base);
});
it("formats calendar ranges across years and preserves separate currencies", () => {
const display = toIncomeDisplayModel({
...analytics,
period: { from: "2025-12-01", to: "2026-01-09" },
yearComparison: {
period: { from: "2024-12-01", to: "2025-01-09" },
totalsByCurrency: [comparison("RUB"), comparison("USD")],
},
});
expect(display?.comparison?.selectedPeriod.replaceAll(/\s/g, " ")).toBe(
"01.12.2025 – 09.01.2026",
); );
expect(display?.comparison?.comparedPeriod.replaceAll(/\s/g, " ")).toBe( expect(new Set(display.entries.map(({ key }) => key)).size).toBe(3);
"01.12.2024 – 09.01.2025",
);
expect(display?.comparison?.values.map(value => value.currency)).toEqual([
"RUB",
"USD",
]);
}); });
}); });
it("maps comparison notices by role regardless of dependency order or resource key", () => {
const selected = { ...resource(analytics).meta, role: "selected" as const };
const previous
= {
...resource(null).meta,
resourceKey: "opaque/year-looking-key",
role: "previous" as const,
sync: {
errorCode: "TBANK_UNAVAILABLE",
id: null,
state: "failed" as const,
},
};
const year = {
...resource(null).meta,
resourceKey: "opaque/previous-looking-key",
role: "year" as const,
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
};
for (const dependencies of [
[selected, previous, year],
[year, selected, previous],
]) {
const display = toIncomeDisplayModel(analytics, {
...resource(analytics).meta,
dependencies,
});
expect(display?.previousNotice).toBe("comparisonFailed");
expect(display?.yearNotice).toBe("yearUnavailable");
expect(display?.previousComparison).not.toBeNull();
}
});
+2 -103
View File
@@ -1,102 +1,11 @@
import type { IncomeAnalytics, IncomeAnalyticsMeta } from "@/entities/income"; import type { IncomeAnalytics } from "@/entities/income";
import { formatDate } from "@/shared/lib";
import type { IncomePeriod } from "./period";
type ComparisonNotice
= | "comparisonFailed"
| "comparisonPending"
| "previousUnavailable"
| "yearUnavailable";
type IncomeDisplayModel = { type IncomeDisplayModel = {
comparison: null
| {
comparedPeriod: string;
selectedPeriod: string;
values: (NonNullable<
IncomeAnalytics["yearComparison"]
>["totalsByCurrency"][number] & {
percentages: Record<"coupon"
| "dividend"
| "total", null | string>;
})[];
};
entries: { key: string; operation: IncomeAnalytics["entries"][number] }[]; entries: { key: string; operation: IncomeAnalytics["entries"][number] }[];
previousComparison: IncomeDisplayModel["comparison"];
previousNotice: ComparisonNotice;
yearNotice: ComparisonNotice;
}; };
function comparisonDisplay(
period: IncomePeriod,
comparison: IncomeAnalytics["yearComparison"],
): IncomeDisplayModel["comparison"] {
if (!comparison)
return null;
return {
comparedPeriod: formatComparisonPeriod(comparison.period),
selectedPeriod: formatComparisonPeriod(period),
values: comparison.totalsByCurrency.map(value => ({
...value,
percentages: {
coupon: formatIncomeChange(
value.difference.coupon.value,
value.compared.coupon.value,
),
dividend: formatIncomeChange(
value.difference.dividend.value,
value.compared.dividend.value,
),
total: formatIncomeChange(
value.difference.total.value,
value.compared.total.value,
),
},
})),
};
}
function comparisonNotice(
dependency:
IncomeAnalyticsMeta["dependencies"][number] | undefined,
unavailable: "previousUnavailable" | "yearUnavailable",
): ComparisonNotice {
if (!dependency || dependency.unavailableReason) return unavailable;
return dependency.sync.state === "failed"
? "comparisonFailed"
: "comparisonPending";
}
function formatComparisonPeriod(period: IncomePeriod): string {
return `${formatDate(period.from)} – ${formatDate(period.to)}`;
}
function formatIncomeChange(
difference: string,
compared: string,
): null | string {
// Display-only ratio; server-provided Money values remain exact decimal strings.
const base = Number(compared);
if (!Number.isFinite(base) || base <= 0)
return null;
const ratio = Number(difference) / base;
if (!Number.isFinite(ratio))
return null;
return new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 1,
signDisplay: "exceptZero",
style: "percent",
}).format(ratio);
}
function toIncomeDisplayModel( function toIncomeDisplayModel(
analytics: IncomeAnalytics, analytics: IncomeAnalytics,
meta?: IncomeAnalyticsMeta,
): IncomeDisplayModel { ): IncomeDisplayModel {
const entries: IncomeDisplayModel["entries"] = []; const entries: IncomeDisplayModel["entries"] = [];
const occurrences = new Map<string, number>(); const occurrences = new Map<string, number>();
@@ -116,17 +25,7 @@ function toIncomeDisplayModel(
}); });
} }
const dependencies = meta?.dependencies ?? []; return { entries };
const previous = dependencies.find(({ role }) => role === "previous");
const year = dependencies.find(({ role }) => role === "year");
return {
comparison: comparisonDisplay(analytics.period, analytics.yearComparison),
entries,
previousComparison: comparisonDisplay(analytics.period, analytics.previous),
previousNotice: comparisonNotice(previous, "previousUnavailable"),
yearNotice: comparisonNotice(year, "yearUnavailable"),
};
} }
export { type IncomeDisplayModel, toIncomeDisplayModel }; export { type IncomeDisplayModel, toIncomeDisplayModel };
@@ -22,123 +22,6 @@
min-width: 0; min-width: 0;
} }
.comparison {
container-type: inline-size;
}
.comparisonGrid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--mantine-spacing-sm);
}
.comparisonPanel {
min-width: 0;
padding: var(--mantine-spacing-md);
border-radius: var(--mantine-radius-md);
border-top: 3px solid var(--mantine-color-gray-4);
background: var(--mantine-color-gray-0);
}
.comparisonPanel[data-kind="selected"] {
border-color: var(--mantine-color-blue-6);
background: var(--mantine-color-blue-0);
}
.comparisonPanel[data-tone="positive"] {
border-color: var(--mantine-color-green-8);
background: var(--mantine-color-green-0);
}
.comparisonPanel[data-tone="negative"] {
border-color: var(--mantine-color-red-8);
background: var(--mantine-color-red-0);
}
.comparisonLabel,
.comparisonValues dt {
color: var(--mantine-color-gray-7);
}
.comparisonValues {
margin: var(--mantine-spacing-md) 0 0;
font-size: var(--mantine-font-size-sm);
}
.comparisonValues dd {
min-width: 0;
margin: 0;
font-weight: 600;
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
.comparisonTotal {
margin-bottom: var(--mantine-spacing-md);
padding-bottom: var(--mantine-spacing-md);
border-bottom: 1px solid var(--mantine-color-gray-3);
}
.comparisonTotal dd {
margin-top: 4px;
font-size: var(--mantine-h2-font-size);
line-height: 1.3;
font-weight: 700;
letter-spacing: -0.025em;
}
.comparisonValues dd[data-tone="positive"] {
color: color-mix(
in srgb,
var(--mantine-color-green-9) 85%,
var(--mantine-color-black)
);
}
.comparisonValues dd[data-tone="negative"] {
color: var(--mantine-color-red-9);
}
.comparisonValues dd[data-tone="neutral"] {
color: var(--mantine-color-gray-7);
}
.comparisonPanel[data-kind="selected"] .comparisonTotal dd {
color: var(--mantine-color-blue-8);
}
.comparisonDetail {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 4px var(--mantine-spacing-xs);
margin-top: var(--mantine-spacing-xs);
}
.comparisonPercent {
display: inline-block;
margin-left: var(--mantine-spacing-xs);
padding: 2px 6px;
border-radius: var(--mantine-radius-sm);
background: var(--mantine-color-white);
font-size: var(--mantine-font-size-xs);
font-weight: 600;
line-height: 1.5;
letter-spacing: normal;
vertical-align: middle;
}
.comparisonNotice {
background: var(--mantine-color-gray-0);
border-left: 3px solid var(--mantine-color-gray-4);
}
@container (min-width: 42em) {
.comparisonGrid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 35.999em) { @media (max-width: 35.999em) {
.fields { .fields {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
@@ -72,23 +72,9 @@ const analytics
}, },
], ],
period: { from: "2026-08-01", to: "2026-08-27" }, period: { from: "2026-08-01", to: "2026-08-27" },
previous: {
period: { from: "2026-07-01", to: "2026-07-31" },
totalsByCurrency: [comparison("RUB")],
},
totalsByCurrency: [summary("RUB", "10", "2")], totalsByCurrency: [summary("RUB", "10", "2")],
yearComparison: null,
} satisfies IncomeAnalytics & { instruments: [unknown] }; } satisfies IncomeAnalytics & { instruments: [unknown] };
function comparison(currency: string) {
return {
compared: summary(currency, "1", "0"),
currency,
difference: summary(currency, "9", "2"),
selected: summary(currency, "10", "2"),
};
}
function entry(): IncomeAnalytics["entries"][number] { function entry(): IncomeAnalytics["entries"][number] {
return { return {
accruedInterest: null, accruedInterest: null,
@@ -205,9 +191,10 @@ describe("AccountIncomePage", () => {
resource( resource(
null, null,
{ {
sync: { lastAttempt: {
errorCode: "TBANK_UNAVAILABLE", finishedAt: "2026-08-14T00:01:00Z",
id: null, problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed", state: "failed",
}, },
}, },
@@ -371,19 +358,11 @@ describe("AccountIncomePage", () => {
expect(within(panel).getByText("−2 ₽")).toBeInTheDocument(); expect(within(panel).getByText("−2 ₽")).toBeInTheDocument();
}); });
it("renders realized text and comparisons without subscribing to events", async () => { it("renders realized income without comparison blocks or event requests", async () => {
const eventsRequest = vi.fn(() => new HttpResponse(null, { status: 503 })); const eventsRequest = vi.fn(() => new HttpResponse(null, { status: 503 }));
server.use( server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json( HttpResponse.json(resource(analytics)),
resource({
...analytics,
yearComparison: {
period: { from: "2025-08-01", to: "2025-08-27" },
totalsByCurrency: [comparison("RUB")],
},
}),
),
), ),
http.get("*/api/v1/brokerage-accounts/account-1/events", eventsRequest), http.get("*/api/v1/brokerage-accounts/account-1/events", eventsRequest),
); );
@@ -398,72 +377,9 @@ describe("AccountIncomePage", () => {
expect(screen.queryByText("Динамика")).not.toBeInTheDocument(); expect(screen.queryByText("Динамика")).not.toBeInTheDocument();
expect(eventsRequest).not.toHaveBeenCalled(); expect(eventsRequest).not.toHaveBeenCalled();
expect(screen.getByRole("region", { name: "Выплаты" })).toBeInTheDocument(); expect(screen.getByRole("region", { name: "Выплаты" })).toBeInTheDocument();
const year = screen.getByRole("region", { name: "Годом ранее" }); expect(screen.queryByText("Предыдущий период")).not.toBeInTheDocument();
expect(screen.queryByText("Годом ранее")).not.toBeInTheDocument();
const selected = within(year).getByRole("region", { expect(screen.queryByText(/Сравнение с/)).not.toBeInTheDocument();
name: "01.08.2026 – 27.08.2026",
});
expect(
screen.getByRole("region", { name: "01.08.2025 – 27.08.2025" }),
).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "Предыдущий период" }),
).toBeVisible();
expect(screen.queryByText("Выбранный период")).not.toBeInTheDocument();
expect(screen.queryByText("Сравниваемый период")).not.toBeInTheDocument();
expect(
screen.getByRole("heading", { level: 2, name: "Годом ранее" }),
).toBeInTheDocument();
expect(screen.queryByText("Сравнения")).not.toBeInTheDocument();
expect(screen.queryByText("RUB", { exact: true })).not.toBeInTheDocument();
for (const amount of ["10 ₽", "2 ₽", "12 ₽"]) {
expect(within(selected).getByText(amount)).toBeInTheDocument();
}
const difference = within(year).getByRole("region", { name: "Разница" });
for (const amount of ["+9 ₽", "+2 ₽", "+11 ₽"]) {
expect(within(difference).getByText(amount)).toBeInTheDocument();
}
expect(within(difference).getByText("+900 %")).toBeInTheDocument();
expect(within(difference).getByText("+1 100 %")).toBeInTheDocument();
expect(within(difference).queryByText("нет базы")).not.toBeInTheDocument();
expect(within(difference).getAllByText(/%$/)).toHaveLength(2);
expect(
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
within(difference).getByText("+2 ₽").closest("dd"),
).toHaveTextContent(/^\+2 ₽$/);
});
it("shows the unavailable year state without falling back to the previous period", async () => {
const dependencies = [
{ ...resource(analytics).meta, role: "selected" as const },
{ ...resource(analytics).meta, role: "previous" as const },
{
...resource(null).meta,
role: "year" as const,
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
},
];
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(resource(analytics, { dependencies })),
),
);
renderPage();
expect(
await screen.findByText("Сравнение с годом ранее недоступно."),
).toBeInTheDocument();
expect(
screen.queryByRole("region", { name: "Годом ранее" }),
).not.toBeInTheDocument();
expect(
screen.getByRole("region", { name: "Предыдущий период" }),
).toBeVisible();
}); });
it("shows a quiet realized error", async () => { it("shows a quiet realized error", async () => {
@@ -747,119 +663,3 @@ describe("income period and snapshot boundary", () => {
expect(screen.queryByRole("button")).not.toBeInTheDocument(); expect(screen.queryByRole("button")).not.toBeInTheDocument();
}); });
}); });
it.each([false, true])(
"shows unavailable comparisons for absent roles without positional fallback (legacy=%s)",
async (legacy) => {
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(
resource(
{ ...analytics, previous: null, yearComparison: null },
{
dependencies: legacy
? [
resource(null).meta,
resource(null).meta,
resource(null).meta,
]
: [],
},
),
),
),
);
renderPage();
expect(
await screen.findByText("Сравнение с предыдущим периодом недоступно."),
).toBeVisible();
expect(
screen.getByText("Сравнение с годом ранее недоступно."),
).toBeVisible();
expect(
screen.queryByText("Период сравнения ещё не загружен полностью."),
).not.toBeInTheDocument();
expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible();
},
);
it("shows selected income before comparison coverage without inventing a zero baseline", async () => {
const dependencies = (["year", "selected", "previous"] as const).map(
role =>
({
...resource(role === "selected" ? analytics : null).meta,
role,
}),
);
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(
resource(
{ ...analytics, previous: null, yearComparison: null },
{ dependencies },
),
),
),
);
renderPage();
expect(await screen.findByRole("region", { name: "Выплаты" })).toBeVisible();
expect(
screen.queryByRole("region", { name: "Год назад" }),
).not.toBeInTheDocument();
expect(screen.getAllByText("12 ₽").length).toBeGreaterThan(0);
expect(
screen.getAllByText("Период сравнения ещё не загружен полностью."),
).toHaveLength(2);
expect(
screen.queryByText("Сравнение с годом ранее недоступно."),
).not.toBeInTheDocument();
expect(
screen.queryByRole("region", { name: "Разница" }),
).not.toBeInTheDocument();
expect(screen.getByLabelText("Период")).toBeVisible();
});
it("keeps complete previous comparison while year source failed without inventing its amounts", async () => {
const dependencies = [
{
...resource(analytics).meta,
role: "previous" as const,
},
{
...resource(null).meta,
role: "year" as const,
sync: {
errorCode: "TBANK_UNAVAILABLE",
id: null,
state: "failed" as const,
},
},
{
...resource(analytics).meta,
role: "selected" as const,
},
];
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(resource(analytics, { dependencies })),
),
);
renderPage();
expect(
await screen.findByRole("region", { name: "Предыдущий период" }),
).toBeVisible();
expect(
screen.getByText(
"Не удалось загрузить период сравнения. Повторите обновление.",
),
).toBeVisible();
expect(
screen.queryByRole("region", { name: "Годом ранее" }),
).not.toBeInTheDocument();
});
@@ -79,7 +79,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
const availability = useInitialAvailability({ const availability = useInitialAvailability({
hasContent: !!analytics.data, hasContent: !!analytics.data,
identity: `${accountId}:${period.from}:${period.to}`, 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, isFetching: analytics.isFetching,
}); });
@@ -91,7 +91,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
? { period, state: "error" } ? { period, state: "error" }
: { state: "loading" }; : { state: "loading" };
const display = toIncomeDisplayModel(analytics.data, analytics.meta); const display = toIncomeDisplayModel(analytics.data);
const onSubmit = (values: IncomeForm) => { const onSubmit = (values: IncomeForm) => {
const from = parseDraftDate(values.from); const from = parseDraftDate(values.from);
@@ -114,7 +114,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
formRevision: `${accountId}:${period.from}:${period.to}:${resetRevision}`, formRevision: `${accountId}:${period.from}:${period.to}:${resetRevision}`,
onReset, onReset,
onSubmit, onSubmit,
partial: analytics.meta?.availability !== "complete", partial: analytics.meta?.completeness === "incomplete",
state: "loaded", state: "loaded",
}; };
}; };
@@ -12,7 +12,6 @@ import { useTranslation } from "react-i18next";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm"; import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
import classes from "../AccountIncomePage.module.css"; import classes from "../AccountIncomePage.module.css";
import { IncomeComparisons } from "./IncomeComparisons.view";
import { IncomePayments } from "./IncomePayments.view"; import { IncomePayments } from "./IncomePayments.view";
import { IncomePeriodForm } from "./IncomePeriodForm/IncomePeriodForm.view"; import { IncomePeriodForm } from "./IncomePeriodForm/IncomePeriodForm.view";
import { IncomeSummary } from "./IncomeSummary.view"; import { IncomeSummary } from "./IncomeSummary.view";
@@ -54,18 +53,6 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
<Alert color="blue">{t("income.taxBoundary")}</Alert> <Alert color="blue">{t("income.taxBoundary")}</Alert>
<IncomeComparisons
comparison={display.previousComparison}
notice={t(`income.${display.previousNotice}`)}
title={t("income.previousPeriod")}
/>
<IncomeComparisons
comparison={display.comparison}
notice={t(`income.${display.yearNotice}`)}
title={t("income.yearAgo")}
/>
<IncomePayments entries={display.entries} /> <IncomePayments entries={display.entries} />
</Stack> </Stack>
); );
@@ -1,167 +0,0 @@
import type { FC } from "react";
import { Paper, Stack, Text, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { formatMoney } from "@/shared/lib";
import type { IncomeDisplayModel } from "../../../model/display";
import classes from "../AccountIncomePage.module.css";
type ComparisonProps = NonNullable<IncomeDisplayModel["comparison"]> & {
title: string;
};
type ComparisonValuesProps = {
kind: "compared"
| "difference"
| "selected";
label: string;
percentages?: ComparisonProps["values"][number]["percentages"];
values: ComparisonProps["values"][number]["selected"];
};
type IncomeComparisonsProps = {
comparison: IncomeDisplayModel["comparison"];
notice: string;
title: string;
};
const IncomeComparisons: FC<IncomeComparisonsProps> = (props) => {
return (
<Stack>
<Title order={2}>{props.title}</Title>
{props.comparison
? <Comparison {...props.comparison} title={props.title} />
: (
<Paper className={classes.comparisonNotice} p="sm" radius="md">
<Text aria-live="polite" c="gray.7" role="status" size="sm">
{props.notice}
</Text>
</Paper>
)}
</Stack>
);
};
const Comparison: FC<ComparisonProps> = (props) => {
const { t } = useTranslation();
return (
<section aria-label={props.title} className={classes.comparison}>
<Stack gap="md">
{props.values.map(value => (
<Stack
aria-label={value.currency}
gap="xs"
key={value.currency}
role="group"
>
<div className={classes.comparisonGrid}>
<ComparisonValues
kind="selected"
label={props.selectedPeriod}
values={value.selected}
/>
<ComparisonValues
kind="compared"
label={props.comparedPeriod}
values={value.compared}
/>
<ComparisonValues
kind="difference"
label={t("income.difference")}
percentages={value.percentages}
values={value.difference}
/>
</div>
</Stack>
))}
{props.values.length === 0 && (
<Text c="gray.7" size="sm">
{t("income.comparisonEmpty")}
</Text>
)}
</Stack>
</section>
);
};
const ComparisonValues: FC<ComparisonValuesProps> = (props) => {
const { t } = useTranslation();
const data_tone
= props.kind === "difference"
? differenceTone(props.values.total.value)
: undefined;
return (
<section
aria-label={props.label}
className={classes.comparisonPanel}
data-kind={props.kind}
data-tone={data_tone}
>
<Text className={classes.comparisonLabel} fw={600} size="sm">
{props.label}
</Text>
<dl className={classes.comparisonValues}>
{(["total", "coupon", "dividend"] as const).map((key) => {
const money
// eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name.
= props.values[key];
const percentage
// eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name.
= props.percentages?.[key];
const tone
= props.kind === "difference"
? differenceTone(money.value)
: undefined;
const appearanceClass
= key === "total"
? classes.comparisonTotal
: classes.comparisonDetail;
return (
<div className={appearanceClass} key={key}>
<dt>{t(`income.${key}`)}</dt>
<dd data-tone={tone}>
<span>
{tone === "positive" ? "+" : ""}
{formatMoney(money)}
</span>
{!!percentage && (
<span className={classes.comparisonPercent}>
{percentage}
</span>
)}
</dd>
</div>
);
})}
</dl>
</section>
);
};
function differenceTone(value: string): "negative"
| "neutral"
| "positive" {
if (!/[1-9]/.test(value)) return "neutral";
return value.startsWith("-") ? "negative" : "positive";
}
export { IncomeComparisons };
@@ -1,6 +1,6 @@
import type { FC, SubmitEventHandler } from "react"; import type { FC, SubmitEventHandler } from "react";
import { Button, Paper, Stack, Text } from "@mantine/core"; import { Button, Stack } from "@mantine/core";
import { DatePickerInput } from "@mantine/dates"; import { DatePickerInput } from "@mantine/dates";
import classes from "../../AccountIncomePage.module.css"; import classes from "../../AccountIncomePage.module.css";
@@ -27,15 +27,8 @@ const IncomePeriodForm: FC<IncomePeriodFormProps> = (props) => {
} = useIncomePeriodFormVm(); } = useIncomePeriodFormVm();
return ( return (
<Paper <form onSubmit={props.onSubmit}>
component="form"
onSubmit={props.onSubmit}
p="md"
radius="md"
withBorder
>
<Stack gap="sm"> <Stack gap="sm">
<Text fw={700}>{t("income.period")}</Text>
<div className={classes.fields}> <div className={classes.fields}>
<DatePickerInput <DatePickerInput
@@ -71,7 +64,7 @@ const IncomePeriodForm: FC<IncomePeriodFormProps> = (props) => {
</Button> </Button>
</div> </div>
</Stack> </Stack>
</Paper> </form>
); );
}; };
@@ -492,12 +492,25 @@ describe("AccountOperationsPage", () => {
}), }),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("keeps a null operations envelope loading instead of a confirmed empty period", async () => {
configureTestAuth();
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
HttpResponse.json(resource(null)),
),
);
renderPage();
expect(
await screen.findByRole("status", { name: "Загрузка" }, { timeout: 4000 }),
).toBeVisible();
expect(screen.queryByText("Операций нет")).not.toBeInTheDocument();
});
}); });
it("keeps UTC operation boundaries in GET and source sync requests", async () => { it("keeps UTC operation boundaries in GET and source sync requests", async () => {
configureTestAuth(); configureTestAuth();
const requested: Record<string, string>[] = []; const requested: Record<string, string>[] = [];
const syncRequests: unknown[] = [];
server.use( server.use(
http.get( http.get(
"*/api/v1/brokerage-accounts/:accountId/operations", "*/api/v1/brokerage-accounts/:accountId/operations",
@@ -508,9 +521,10 @@ it("keeps UTC operation boundaries in GET and source sync requests", async () =>
resource( resource(
null, null,
{ {
sync: { lastAttempt: {
errorCode: "TBANK_UNAVAILABLE", finishedAt: "2026-08-14T00:01:00Z",
id: null, problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed", state: "failed",
}, },
}, },
@@ -518,11 +532,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"); renderPage("?from=2024-02-29&to=2024-03-01&categories=trade");
await screen.findByRole("button", { name: "Сбросить" }); await screen.findByRole("button", { name: "Сбросить" });
@@ -534,7 +543,6 @@ it("keeps UTC operation boundaries in GET and source sync requests", async () =>
to: "2024-03-01T23:59:59.999Z", to: "2024-03-01T23:59:59.999Z",
}, },
]); ]);
expect(syncRequests).toEqual([]);
}); });
it("does not label incomplete empty operations as a confirmed empty period", async () => { it("does not label incomplete empty operations as a confirmed empty period", async () => {
@@ -550,7 +558,7 @@ it("does not label incomplete empty operations as a confirmed empty period", asy
items: [], items: [],
nextCursor: null, nextCursor: null,
}, },
{ availability: "partial" }, { completeness: "incomplete" },
), ),
), ),
), ),
@@ -2,6 +2,8 @@ import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import { useParams } from "react-router"; import { useParams } from "react-router";
import type { InvestmentResourceMeta } from "@/shared/api";
import { import {
type BrokerageOperation, type BrokerageOperation,
brokerageOperationsQueryOptions, brokerageOperationsQueryOptions,
@@ -39,6 +41,7 @@ type AccountOperationsPageViewModel
onPrevious: () => void; onPrevious: () => void;
onReset: () => void; onReset: () => void;
partial?: boolean; partial?: boolean;
source?: InvestmentResourceMeta;
state: "loaded"; state: "loaded";
} }
| { onReset: () => void; state: "error" } | { onReset: () => void; state: "error" }
@@ -98,7 +101,7 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
const availability = useInitialAvailability({ const availability = useInitialAvailability({
hasContent: !!operations.data, hasContent: !!operations.data,
identity: scope, identity: scope,
isError: operations.isError || operations.meta?.sync.state === "failed", isError: operations.isError || operations.meta?.lastAttempt.state === "failed",
isFetching: operations.isFetching, isFetching: operations.isFetching,
}); });
@@ -127,7 +130,7 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
if ( if (
(isInvalidCursor || !operations.data) (isInvalidCursor || !operations.data)
&& (operations.isError || operations.meta?.sync.state === "failed") && (operations.isError || operations.meta?.lastAttempt.state === "failed")
) )
return { onReset, state: "error" }; return { onReset, state: "error" };
@@ -177,7 +180,8 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
onPeriodPopoverOpen: () => setMaxDate(moscowDate(new Date())), onPeriodPopoverOpen: () => setMaxDate(moscowDate(new Date())),
onPrevious: history.onPrevious, onPrevious: history.onPrevious,
onReset, onReset,
partial: operations.meta?.availability !== "complete", partial: operations.meta?.completeness === "incomplete",
source: operations.meta,
state: "loaded", state: "loaded",
}; };
}; };
@@ -13,6 +13,7 @@ import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { LedgerEntryRow, operationCategories } from "@/entities/broker-operation"; import { LedgerEntryRow, operationCategories } from "@/entities/broker-operation";
import { SourceSyncStatus } from "@/shared/ui";
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm"; import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
@@ -62,6 +63,12 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
<Stack gap="lg"> <Stack gap="lg">
<Title order={1}>{t("ledger.title")}</Title> <Title order={1}>{t("ledger.title")}</Title>
<SourceSyncStatus
lastAttempt={props.source?.lastAttempt}
lastSuccessfulAt={props.source?.lastSuccessfulAt}
stale={props.source?.freshness === "stale"}
/>
{!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>} {!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>}
{!!props.historyLoading && <Alert>{t("investment.history")}</Alert>} {!!props.historyLoading && <Alert>{t("investment.history")}</Alert>}
+12 -11
View File
@@ -46,9 +46,10 @@ type PreviewResource<T> = {
isError: boolean; isError: boolean;
meta?: Pick<InvestmentResourceMeta, meta?: Pick<InvestmentResourceMeta,
"availability" "availability"
| "completeness"
| "freshness" | "freshness"
| "lastAttempt"
| "lastSuccessfulAt" | "lastSuccessfulAt"
| "sync"
>; >;
}; };
@@ -57,10 +58,10 @@ function toIncomeSummary(
income: PreviewResource<IncomeSummary>, income: PreviewResource<IncomeSummary>,
): AccountPageIncomeSummary { ): AccountPageIncomeSummary {
const to = `/accounts/${accountId}/income`; 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 }; return { state: "loading", to };
if (income.meta?.availability === "partial") return { state: "partial", to }; if (income.meta?.completeness === "incomplete") return { state: "partial", to };
if (!income.data && (income.isError || income.meta?.sync.state === "failed")) if (!income.data && (income.isError || income.meta?.lastAttempt.state === "failed"))
return { state: "error", to }; return { state: "error", to };
if (!income.data?.totalsByCurrency.length) return { state: "empty", to }; if (!income.data?.totalsByCurrency.length) return { state: "empty", to };
@@ -76,15 +77,15 @@ function toRecentEvents(
events: PreviewResource<{ events: BrokerageEvent[] }>, events: PreviewResource<{ events: BrokerageEvent[] }>,
): AccountPageRecentEvents { ): AccountPageRecentEvents {
const to = `/accounts/${accountId}/events`; 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 }; return { state: "loading", to };
if (events.meta?.availability === "partial") if (events.meta?.completeness === "incomplete")
return { return {
items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [], items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [],
state: "partial", state: "partial",
to, 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 }; return { state: "error", to };
const items = events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? []; const items = events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [];
if (items.length === 0) return { state: "empty", to }; if (items.length === 0) return { state: "empty", to };
@@ -104,14 +105,14 @@ function toRecentOperations(
if ( if (
!operations.data !operations.data
&& !operations.isError && !operations.isError
&& operations.meta?.sync.state !== "failed" && operations.meta?.lastAttempt.state !== "failed"
) )
return { state: "loading", to }; return { state: "loading", to };
if (operations.meta?.availability === "partial") if (operations.meta?.completeness === "incomplete")
return { items: operations.data?.items ?? [], state: "partial", to }; return { items: operations.data?.items ?? [], state: "partial", to };
if ( if (
!operations.data !operations.data
&& (operations.isError || operations.meta?.sync.state === "failed") && (operations.isError || operations.meta?.lastAttempt.state === "failed")
) )
return { state: "error", to }; return { state: "error", to };
if (!operations.data?.items.length) return { state: "empty", to }; if (!operations.data?.items.length) return { state: "empty", to };
@@ -120,7 +121,7 @@ function toRecentOperations(
items: operations.data.items, items: operations.data.items,
lastSuccessfulAt: operations.meta?.lastSuccessfulAt, lastSuccessfulAt: operations.meta?.lastSuccessfulAt,
refreshError: refreshError:
operations.isError || operations.meta?.sync.state === "failed", operations.isError || operations.meta?.lastAttempt.state === "failed",
stale: operations.meta?.freshness === "stale", stale: operations.meta?.freshness === "stale",
state: "loaded", state: "loaded",
to, to,
@@ -402,6 +402,24 @@ describe("AccountPage states", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("keeps a null portfolio envelope loading instead of an empty portfolio", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(resource(null)),
),
);
const queryClient = createTestQueryClient();
renderAccountRoute(queryClient);
expect(
await screen.findByRole("status", { name: "Загрузка" }, { timeout: 4000 }),
).toBeVisible();
expect(screen.queryByText("Портфель пока пуст")).not.toBeInTheDocument();
expect(
queryClient.getQueryData(portfolioQueryOptions("account-1").queryKey),
).toMatchObject({ data: null });
});
it("renders a stale portfolio in upstream order with nullable values hidden", () => { it("renders a stale portfolio in upstream order with nullable values hidden", () => {
renderPage(<AccountPageLoaded {...loadedProps()} />); renderPage(<AccountPageLoaded {...loadedProps()} />);
@@ -686,7 +704,7 @@ describe("AccountPage states", () => {
).toBeVisible(); ).toBeVisible();
for (const amount of ["850,50 ₽", "0 USD", "1 000 ₽", "Сумма неизвестна"]) for (const amount of ["850,50 ₽", "0 USD", "1 000 ₽", "Сумма неизвестна"])
expect(content.getByText(amount)).toBeVisible(); expect(content.getByText(amount)).toBeVisible();
expect(content.getAllByText("Ожидаемая сумма")).toHaveLength(4); expect(content.getAllByText("Прогноз")).toHaveLength(4);
expect(content.queryByText("Без движения денег")).not.toBeInTheDocument(); expect(content.queryByText("Без движения денег")).not.toBeInTheDocument();
expect(content.queryByText(/^\+/)).not.toBeInTheDocument(); expect(content.queryByText(/^\+/)).not.toBeInTheDocument();
expect( expect(
@@ -770,28 +788,6 @@ describe("AccountPage states", () => {
let requestCount = 0; let requestCount = 0;
let resolveRetry: ((response: Response) => void) | undefined; let resolveRetry: ((response: Response) => void) | undefined;
server.use( 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", () => { http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
requestCount += 1; requestCount += 1;
if (requestCount === 1) if (requestCount === 1)
@@ -173,14 +173,14 @@ it("keeps overview sections independent when their source data is incomplete", a
mockLoadedAccountPage(); mockLoadedAccountPage();
server.use( server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => 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", () => http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
HttpResponse.json( HttpResponse.json(
resource( resource(
{ hasNext: false, items: [], nextCursor: null }, { 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( HttpResponse.json(
resource( resource(
{ events: [], incomeTotalsByCurrency: [] }, { events: [], incomeTotalsByCurrency: [] },
{ availability: "partial" }, { completeness: "incomplete" },
), ),
), ),
), ),
@@ -1,6 +1,7 @@
import { useParams } from "react-router"; import { useParams } from "react-router";
import type { Portfolio } from "@/entities/portfolio"; import type { Portfolio } from "@/entities/portfolio";
import type { InvestmentResourceMeta } from "@/shared/api";
import { useBrokerageOperations } from "@/entities/broker-operation"; import { useBrokerageOperations } from "@/entities/broker-operation";
import { import {
@@ -45,6 +46,7 @@ type AccountPageEmptyViewModel = AccountPageContent & {
type AccountPageLoadedViewModel = AccountPageContent & { type AccountPageLoadedViewModel = AccountPageContent & {
portfolio: Portfolio; portfolio: Portfolio;
positionSections: AccountPagePositionSection[]; positionSections: AccountPagePositionSection[];
source?: InvestmentResourceMeta;
state: "loaded"; state: "loaded";
}; };
@@ -87,7 +89,7 @@ function useAccountPageViewModel(): AccountPageViewModel {
const availability = useInitialAvailability({ const availability = useInitialAvailability({
hasContent: !!portfolio.data, hasContent: !!portfolio.data,
identity: accountId ?? "account", identity: accountId ?? "account",
isError: portfolio.isError || portfolio.meta?.sync.state === "failed", isError: portfolio.isError || portfolio.meta?.lastAttempt.state === "failed",
isFetching: portfolio.isFetching, isFetching: portfolio.isFetching,
}); });
@@ -105,7 +107,7 @@ function useAccountPageViewModel(): AccountPageViewModel {
const incomeSummary = toIncomeSummary(accountId, income); const incomeSummary = toIncomeSummary(accountId, income);
if ( if (
portfolio.meta?.availability === "complete" portfolio.meta?.availability === "available"
&& portfolio.data.positions.length === 0 && portfolio.data.positions.length === 0
&& portfolio.data.cashBalances.length === 0 && portfolio.data.cashBalances.length === 0
) )
@@ -118,7 +120,7 @@ function useAccountPageViewModel(): AccountPageViewModel {
state: "empty" as const, state: "empty" as const,
}; };
const isPartial = portfolio.meta?.availability === "partial"; const isPartial = portfolio.meta?.completeness === "incomplete";
return { return {
account, account,
@@ -129,6 +131,7 @@ function useAccountPageViewModel(): AccountPageViewModel {
positionSections: toAccountPagePositionSections(portfolio.data), positionSections: toAccountPagePositionSections(portfolio.data),
recentEvents, recentEvents,
recentOperations, recentOperations,
source: portfolio.meta,
state: "loaded" as const, state: "loaded" as const,
}; };
} }
@@ -5,8 +5,10 @@ import { useTranslation } from "react-i18next";
import type { BrokerageAccount } from "@/entities/brokerage-account"; import type { BrokerageAccount } from "@/entities/brokerage-account";
import type { Portfolio } from "@/entities/portfolio"; import type { Portfolio } from "@/entities/portfolio";
import type { InvestmentResourceMeta } from "@/shared/api";
import { BrokerageEventsPreview } from "@/entities/brokerage-event"; import { BrokerageEventsPreview } from "@/entities/brokerage-event";
import { SourceSyncStatus } from "@/shared/ui";
import { PortfolioStructure } from "@/widgets/portfolio-structure"; import { PortfolioStructure } from "@/widgets/portfolio-structure";
import type { AccountPagePositionSection } from "../../../model/positions"; import type { AccountPagePositionSection } from "../../../model/positions";
@@ -32,6 +34,7 @@ type AccountPageLoadedProps = {
positionSections: AccountPagePositionSection[]; positionSections: AccountPagePositionSection[];
recentEvents: AccountPageRecentEvents; recentEvents: AccountPageRecentEvents;
recentOperations: AccountPageRecentOperations; recentOperations: AccountPageRecentOperations;
source?: InvestmentResourceMeta;
}; };
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => { const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
@@ -49,6 +52,12 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
<Stack gap="lg"> <Stack gap="lg">
<AccountHeading account={props.account} accountId={props.accountId} /> <AccountHeading account={props.account} accountId={props.accountId} />
<SourceSyncStatus
lastAttempt={props.source?.lastAttempt}
lastSuccessfulAt={props.source?.lastSuccessfulAt}
stale={props.source?.freshness === "stale"}
/>
{props.partial {props.partial
? <Alert role="status">{t("investment.partial")}</Alert> ? <Alert role="status">{t("investment.partial")}</Alert>
: <CurrencyCards amounts={props.portfolio.totalsByCurrency} />} : <CurrencyCards amounts={props.portfolio.totalsByCurrency} />}
@@ -130,17 +130,6 @@ describe("AccountsPage", () => {
}); });
it("keeps the cached account list quietly after a refresh error", async () => { 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; let requestCount = 0;
server.use( server.use(
http.get("*/api/v1/brokerage-accounts", () => { http.get("*/api/v1/brokerage-accounts", () => {
@@ -169,7 +158,6 @@ describe("AccountsPage", () => {
expect(screen.queryByRole("button", { name: "Повторить" })) expect(screen.queryByRole("button", { name: "Повторить" }))
.not.toBeInTheDocument(); .not.toBeInTheDocument();
expect(screen.getByText(account.name)).toBeInTheDocument(); expect(screen.getByText(account.name)).toBeInTheDocument();
expect(requests).toEqual([]);
}); });
it("keeps a cached empty account list visible after a refresh error", async () => { 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)( it.each(["none", "failed"] as const)(
"keeps partial empty membership distinct from no accounts (sync=%s)", "keeps incomplete empty membership distinct from no accounts (attempt=%s)",
async (state) => { async (state) => {
server.use( server.use(
http.get("*/api/v1/brokerage-accounts", () => http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json(resource([], { HttpResponse.json(resource([], {
availability: "partial", completeness: "incomplete",
sync: { errorCode: null, id: null, state }, 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(() => await waitFor(() =>
expect(client.getQueryData(brokerageAccountsQueryKey)).toMatchObject({ expect(client.getQueryData(brokerageAccountsQueryKey)).toMatchObject({
data: [], data: [],
meta: { availability: "partial" }, meta: { completeness: "incomplete" },
}), }),
); );
expect(screen.queryByText("Нет инвестиционных счетов")) expect(screen.queryByText("Нет инвестиционных счетов"))
@@ -15,6 +15,7 @@ const AccountsPage: FC = () => {
return ( return (
<AccountsPageLoaded <AccountsPageLoaded
accounts={viewModel.accounts} accounts={viewModel.accounts}
meta={viewModel.meta}
/> />
); );
}; };
@@ -8,7 +8,7 @@ import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
import { useInitialAvailability } from "@/shared/lib"; import { useInitialAvailability } from "@/shared/lib";
type AccountsPageViewModel type AccountsPageViewModel
= | { accounts: BrokerageAccountCard[]; state: "loaded" } = | { accounts: BrokerageAccountCard[]; meta: NonNullable<ReturnType<typeof useBrokerageAccounts>["meta"]>; state: "loaded" }
| { code?: string; state: "error" } | { code?: string; state: "error" }
| { state: "empty" } | { state: "empty" }
| { state: "loading" }; | { state: "loading" };
@@ -26,12 +26,13 @@ function useAccountsPageViewModel(): AccountsPageViewModel {
const isAccessFailure = isAccountsAccessFailure(code); const isAccessFailure = isAccountsAccessFailure(code);
const hasResult = !!accounts.data 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({ const availability = useInitialAvailability({
hasContent: hasResult, hasContent: hasResult,
identity: "accounts", identity: "accounts",
isError: accounts.isError || accounts.meta?.sync.state === "failed", isError: accounts.isError || accounts.meta?.lastAttempt.state === "failed",
isFetching: accounts.isFetching, isFetching: accounts.isFetching,
}); });
@@ -47,8 +48,12 @@ function useAccountsPageViewModel(): AccountsPageViewModel {
state: "empty" as const, state: "empty" as const,
}; };
const meta = accounts.meta;
if (!meta) return { state: "loading" as const };
return { return {
accounts: brokerageAccountCards(accounts.data, accounts.meta?.dependencies), accounts: brokerageAccountCards(accounts.data, meta.dependencies),
meta,
state: "loaded" as const, state: "loaded" as const,
}; };
} }
@@ -4,13 +4,17 @@ import { Button, Stack, Text, Title } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import type { InvestmentResourceMeta } from "@/shared/api";
import { import {
type BrokerageAccountCard, type BrokerageAccountCard,
BrokerageAccountList, BrokerageAccountList,
} from "@/entities/brokerage-account"; } from "@/entities/brokerage-account";
import { SourceSyncStatus } from "@/shared/ui";
type AccountsPageLoadedProps = { type AccountsPageLoadedProps = {
accounts: BrokerageAccountCard[]; accounts: BrokerageAccountCard[];
meta: InvestmentResourceMeta;
}; };
const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => { const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
@@ -24,6 +28,12 @@ const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
<Text c="dimmed" size="sm"> <Text c="dimmed" size="sm">
{t("accounts.subtitle")} {t("accounts.subtitle")}
</Text> </Text>
<SourceSyncStatus
lastAttempt={props.meta.lastAttempt}
lastSuccessfulAt={props.meta.lastSuccessfulAt}
stale={props.meta.freshness === "stale"}
/>
</Stack> </Stack>
<Button <Button
@@ -191,8 +191,10 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
<BondCoupons <BondCoupons
coupons={vm.coupons} coupons={vm.coupons}
logoName={bond.logoName}
pastCoupons={vm.pastCoupons} pastCoupons={vm.pastCoupons}
state={vm.couponsState} state={vm.couponsState}
ticker={bond.ticker}
upcoming={vm.upcoming} upcoming={vm.upcoming}
/> />
@@ -1,17 +1,20 @@
import type { FC } from "react"; import type { FC } from "react";
import { Accordion, Alert, Badge, Group, Paper, Stack, Text } from "@mantine/core"; import { Accordion, Alert, Badge, Group, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { BondCoupons as Coupons } from "@/entities/bond"; import type { BondCoupons as Coupons } from "@/entities/bond";
import { InstrumentMetric, InstrumentSection, type SectionState } from "@/features/instrument-details"; import { InstrumentSection, type SectionState } from "@/features/instrument-details";
import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib"; import { formatDate, formatSnapshotTime, instrumentLogoUrl } from "@/shared/lib";
import { PayoutCard } from "@/shared/ui";
type BondCouponsProps = { type BondCouponsProps = {
coupons?: Coupons; coupons?: Coupons;
logoName?: null | string;
pastCoupons: Coupons["coupons"]; pastCoupons: Coupons["coupons"];
state: SectionState; state: SectionState;
ticker?: null | string;
upcoming: Coupons["coupons"]; upcoming: Coupons["coupons"];
}; };
@@ -39,6 +42,8 @@ const BondCoupons: FC<BondCouponsProps> = (props) => {
<Coupon <Coupon
coupon={coupon} coupon={coupon}
key={`${coupon.number}:${coupon.paymentDate}`} key={`${coupon.number}:${coupon.paymentDate}`}
logoName={props.logoName}
ticker={props.ticker}
/> />
)) ))
) )
@@ -75,6 +80,8 @@ const BondCoupons: FC<BondCouponsProps> = (props) => {
<Coupon <Coupon
coupon={coupon} coupon={coupon}
key={`${coupon.number}:${coupon.paymentDate}`} key={`${coupon.number}:${coupon.paymentDate}`}
logoName={props.logoName}
ticker={props.ticker}
/> />
)) ))
) )
@@ -91,59 +98,48 @@ const BondCoupons: FC<BondCouponsProps> = (props) => {
type CouponProps = { type CouponProps = {
coupon: Coupons["coupons"][number]; coupon: Coupons["coupons"][number];
logoName?: null | string;
ticker?: null | string;
}; };
const Coupon: FC<CouponProps> = (props) => { const Coupon: FC<CouponProps> = (props) => {
const { coupon } = props; const { coupon } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const title = `${t("events.type.coupon")} · ${props.ticker || t("bond.instrument")}`;
const metadata = [
coupon.paymentDate ? formatDate(coupon.paymentDate) : t("stock.dateUnknown"),
t("bond.couponNumber", { number: coupon.number ?? t("bond.unknown") }),
].join(" · ");
const conditions = (
<Group gap="md" mt="sm">
<Badge color="gray" variant="light">
{t(`bond.couponTypes.${coupon.couponType}`, { defaultValue: t("bond.unknown") })}
</Badge>
{coupon.periodDays != undefined && (
<Text c="dimmed" size="xs">{t("bond.couponDays", { count: coupon.periodDays })}</Text>
)}
{coupon.recordDate && (
<Text c="dimmed" size="xs">{t("stock.record")}: {formatDate(coupon.recordDate)}</Text>
)}
</Group>
);
return ( return (
<Paper p="md" radius="md" withBorder> <PayoutCard
<Group align="start" justify="space-between"> conditions={conditions}
<div> estimate={coupon.estimatedAmount}
<Text fw={600}> imageSrc={instrumentLogoUrl(props.logoName)}
{coupon.paymentDate initial={(props.ticker?.trim()[0] ?? "?").toUpperCase()}
? formatDate(coupon.paymentDate) kind="schedule"
: t("stock.dateUnknown")} metadata={metadata}
</Text> perSecurity={coupon.perBond}
perSecurityLabel={t("bond.perBond")}
<Text c="dimmed" size="xs"> title={title}
{t("bond.couponNumber", { />
number: coupon.number ?? t("bond.unknown"),
})}
</Text>
</div>
<InstrumentMetric label={t("bond.perBond")} value={formatMoney(coupon.perBond)} />
{!!coupon.estimatedAmount && (
<InstrumentMetric
label={t("stock.yourEstimate")}
value={formatMoney(coupon.estimatedAmount)}
/>
)}
</Group>
<Group gap="md" mt="sm">
<Badge color="gray" variant="light">
{t(`bond.couponTypes.${coupon.couponType}`, {
defaultValue: t("bond.unknown"),
})}
</Badge>
{coupon.periodDays != undefined && (
<Text c="dimmed" size="xs">
{t("bond.couponDays", { count: coupon.periodDays })}
</Text>
)}
{!!coupon.recordDate && (
<Text c="dimmed" size="xs">
{t("stock.record")}: {formatDate(coupon.recordDate)}
</Text>
)}
</Group>
</Paper>
); );
}; };
+3 -3
View File
@@ -17,7 +17,7 @@ function useBondsPageVm() {
code: apiErrorCode(result.error), code: apiErrorCode(result.error),
error: error:
!result.data !result.data
&& (result.isError || result.meta?.sync.state === "failed"), && (result.isError || result.meta?.lastAttempt.state === "failed"),
items: items:
result.data?.map(bond => ({ result.data?.map(bond => ({
...bond, ...bond,
@@ -29,9 +29,9 @@ function useBondsPageVm() {
&& (isWaiting && (isWaiting
|| (!result.data || (!result.data
&& !result.isError && !result.isError
&& result.meta?.sync.state !== "failed")), && result.meta?.lastAttempt.state !== "failed")),
onQueryChange: search.onQueryChange, onQueryChange: search.onQueryChange,
partial: result.meta?.availability === "partial", partial: result.meta?.completeness === "incomplete",
query, query,
ready: ready:
debounced.length >= MIN_BOND_SEARCH_LENGTH debounced.length >= MIN_BOND_SEARCH_LENGTH
@@ -337,7 +337,7 @@ describe("stock page", () => {
"/stocks/SBERP?period=year&events=trades", "/stocks/SBERP?period=year&events=trades",
), ),
); );
expect(await screen.findByText("Зачисление дивидендов")).toBeVisible(); expect(await screen.findByText("Дивиденд")).toBeVisible();
}); });
it("offers search for ambiguous tickers without requesting personal data", async () => { it("offers search for ambiguous tickers without requesting personal data", async () => {
const { requests } = setup({ ambiguousTicker: true, path: "/stocks/SAME" }); const { requests } = setup({ ambiguousTicker: true, path: "/stocks/SAME" });
@@ -368,18 +368,18 @@ describe("stock page", () => {
screen.getByText("К закрытию предыдущего торгового дня, 09.09.2026"), screen.getByText("К закрытию предыдущего торгового дня, 09.09.2026"),
).toBeVisible(); ).toBeVisible();
expect(await screen.findByText("Зачисление дивидендов")).toBeVisible(); expect(await screen.findByText("Зачисление дивидендов")).toBeVisible();
expect(screen.getByText("Событие компании")).toBeVisible(); expect(screen.getAllByText("на акцию").length).toBeGreaterThan(0);
expect(screen.getByText("328,8 ₽")).toBeVisible(); expect(screen.getByText("328,8 ₽")).toBeVisible();
expect(screen.getByText("P/E · TTM")).toBeVisible(); expect(screen.getByText("P/E · TTM")).toBeVisible();
expect(screen.getByText("Описание компании")).toBeVisible(); expect(screen.getByText("Описание компании")).toBeVisible();
expect( expect(
screen.queryByRole("button", { name: "О компании" }), screen.queryByRole("button", { name: "О компании" }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
expect(screen.getByText("13.10.2026")).toBeVisible(); expect(screen.getByText(/13\.10\.2026/)).toBeVisible();
fireEvent.click( fireEvent.click(
screen.getByRole("button", { name: "История дивидендов компании" }), screen.getByRole("button", { name: "История дивидендов компании" }),
); );
await waitFor(() => expect(screen.getByText("13.10.2025")).toBeVisible()); await waitFor(() => expect(screen.getByText(/13\.10\.2025/)).toBeVisible());
expect(screen.getByText("Текущая средняя покупка: 200 ₽")).toBeVisible(); expect(screen.getByText("Текущая средняя покупка: 200 ₽")).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: /Цены в таблице/ })); fireEvent.click(screen.getByRole("button", { name: /Цены в таблице/ }));
const rows = within(await screen.findByRole("table")).getAllByRole("row"); const rows = within(await screen.findByRole("table")).getAllByRole("row");
@@ -63,8 +63,10 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
<StockDividends <StockDividends
dividends={vm.dividends} dividends={vm.dividends}
logoName={stock.logoName}
pastDividends={vm.pastDividends} pastDividends={vm.pastDividends}
state={vm.dividendsState} state={vm.dividendsState}
ticker={stock.ticker}
upcoming={vm.upcoming} upcoming={vm.upcoming}
/> />
@@ -1,198 +1,96 @@
import type { FC } from "react"; import type { FC } from "react";
import { import { Accordion, Alert, Stack, Text } from "@mantine/core";
Accordion,
Alert,
Badge,
Group,
Paper,
Stack,
Text,
} from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { StockDividends as Dividends } from "@/entities/stock"; import type { StockDividends as Dividends } from "@/entities/stock";
import { InstrumentSection, type SectionState } from "@/features/instrument-details"; import { InstrumentSection, type SectionState } from "@/features/instrument-details";
import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib"; import { formatDate, formatSnapshotTime, instrumentLogoUrl } from "@/shared/lib";
import { PayoutCard } from "@/shared/ui";
import classes from "../StockPage.module.css"; type Dividend = Dividends["dividends"][number];
type DividendCardProps = {
event: Dividend;
logoName?: null | string;
ticker?: null | string;
};
type StockDividendsProps = { type StockDividendsProps = {
dividends?: Dividends; dividends?: Dividends;
logoName?: null | string;
pastDividends: Dividends["dividends"]; pastDividends: Dividends["dividends"];
state: SectionState; state: SectionState;
ticker?: null | string;
upcoming: Dividends["dividends"]; upcoming: Dividends["dividends"];
}; };
const DividendCard: FC<DividendCardProps> = (props) => {
const { event } = props;
const { t } = useTranslation();
const title = `${t("events.type.dividend")} · ${props.ticker || t("stock.share")}`;
const metadata = [
event.recordDate
? `${t("stock.record")}: ${formatDate(event.recordDate)}`
: t("stock.dateUnknown"),
event.paymentDate ? `${t("stock.payment")}: ${formatDate(event.paymentDate)}` : null,
].filter(Boolean).join(" · ");
const conditions = event.lastBuyDate
? <Text c="dimmed" size="xs">{t("stock.lastBuy")}: {formatDate(event.lastBuyDate)}</Text>
: undefined;
return (
<PayoutCard
conditions={conditions}
estimate={event.estimatedAmount}
imageSrc={instrumentLogoUrl(props.logoName)}
initial={(props.ticker?.trim()[0] ?? "?").toUpperCase()}
kind="schedule"
metadata={metadata}
perSecurity={event.perShare}
perSecurityLabel={t("stock.perShare")}
title={title}
/>
);
};
const StockDividends: FC<StockDividendsProps> = (props) => { const StockDividends: FC<StockDividendsProps> = (props) => {
const { dividends, pastDividends, state, upcoming } = props; const { dividends, pastDividends, state, upcoming } = props;
const { t } = useTranslation(); const { t } = useTranslation();
let upcomingContent;
if (dividends) {
if (upcoming.length > 0) {
upcomingContent = (
upcoming.map(event => (
<div
className={classes.dividend}
key={JSON.stringify([
event.recordDate,
event.paymentDate,
event.perShare,
])}
>
<Group align="start" justify="space-between">
<div>
<Badge color="green" variant="light">
{t("stock.companyEvent")}
</Badge>
<Text fw={650} mt="xs" size="xl">
{formatMoney(event.perShare)}
{" "}
<Text c="dimmed" component="span" size="sm">
{t("stock.perShare")}
</Text>
</Text>
</div>
{!!event.estimatedAmount && (
<div>
<Text c="dimmed" size="xs">
{t("stock.yourEstimate")}
</Text>
<Text fw={600}>
{formatMoney(event.estimatedAmount)}
</Text>
</div>
)}
</Group>
<Group gap="xl" mt="md">
{!!event.lastBuyDate && (
<div>
<Text c="dimmed" size="xs">
{t("stock.lastBuy")}
</Text>
<Text>{formatDate(event.lastBuyDate)}</Text>
</div>
)}
{!!event.recordDate && (
<div>
<Text c="dimmed" size="xs">
{t("stock.record")}
</Text>
<Text>{formatDate(event.recordDate)}</Text>
</div>
)}
{!!event.paymentDate && (
<div>
<Text c="dimmed" size="xs">
{t("stock.payment")}
</Text>
<Text>{formatDate(event.paymentDate)}</Text>
</div>
)}
</Group>
</div>
))
);
}
else {
upcomingContent = (
<Text c="dimmed">
{t("stock.noUpcoming", { to: formatDate(dividends.to) })}
</Text>
);
}
}
let pastDividendsContent;
if (pastDividends.length > 0) {
pastDividendsContent = (
<Stack gap="sm">
{pastDividends.map(event => (
<Paper
key={JSON.stringify([
event.recordDate,
event.paymentDate,
event.perShare,
])}
p="md"
radius="md"
withBorder
>
<Group align="start" justify="space-between">
<div>
<Text c="dimmed" size="xs">
{t("stock.record")}
</Text>
<Text fw={600}>
{event.recordDate
? formatDate(event.recordDate)
: t("stock.dateUnknown")}
</Text>
{!!event.paymentDate && (
<Text c="dimmed" mt="xs" size="xs">
{t("stock.payment")}:{" "}
{formatDate(event.paymentDate)}
</Text>
)}
</div>
<div>
<Text fw={650} size="lg">
{formatMoney(event.perShare)}
</Text>
<Text c="dimmed" size="xs">
{t("stock.perShare")}
</Text>
</div>
</Group>
</Paper>
))}
</Stack>
);
}
else {
pastDividendsContent = <Text c="dimmed">{t("stock.noDividends")}</Text>;
}
return ( return (
<InstrumentSection state={state} title={t("stock.upcoming")}> <InstrumentSection state={state} title={t("stock.upcoming")}>
{!!dividends && ( {!!dividends && (
<Stack gap="md"> <Stack gap="md">
{!!( {(dividends.estimateUnavailable || dividends.positionIsStale) && (
dividends.estimateUnavailable || dividends.positionIsStale
) && (
<Alert aria-live="polite" color="orange" role="status"> <Alert aria-live="polite" color="orange" role="status">
{t("stock.estimateUnavailable")} {t("stock.estimateUnavailable")}
</Alert> </Alert>
)} )}
{upcomingContent} {upcoming.length > 0
? upcoming.map(event => (
<DividendCard
event={event}
key={JSON.stringify([
event.recordDate,
event.paymentDate,
event.perShare,
])}
logoName={props.logoName}
ticker={props.ticker}
/>
))
: <Text c="dimmed">{t("stock.noUpcoming", { to: formatDate(dividends.to) })}</Text>}
<Text c="dimmed" size="xs"> <Text c="dimmed" size="xs">{t("stock.dividendNote")}</Text>
{t("stock.dividendNote")}
</Text>
{!!dividends.positionAt && ( {!!dividends.positionAt && (
<Text c="dimmed" size="xs"> <Text c="dimmed" size="xs">
{t("stock.snapshotAt", { {t("stock.snapshotAt", { date: formatSnapshotTime(dividends.positionAt) })}
date: formatSnapshotTime(dividends.positionAt),
})}
</Text> </Text>
)} )}
@@ -210,7 +108,24 @@ const StockDividends: FC<StockDividendsProps> = (props) => {
})} })}
</Text> </Text>
{pastDividendsContent} {pastDividends.length > 0
? (
<Stack gap="sm">
{pastDividends.map(event => (
<DividendCard
event={event}
key={JSON.stringify([
event.recordDate,
event.paymentDate,
event.perShare,
])}
logoName={props.logoName}
ticker={props.ticker}
/>
))}
</Stack>
)
: <Text c="dimmed">{t("stock.noDividends")}</Text>}
</Accordion.Panel> </Accordion.Panel>
</Accordion.Item> </Accordion.Item>
</Accordion> </Accordion>
@@ -1,16 +1,28 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw"; import { http, HttpResponse } from "msw";
import { MemoryRouter } from "react-router"; import { MemoryRouter } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { StocksPage } from "./StocksPage"; import { StocksPage } from "./StocksPage";
import { useStocksPageVm } from "./StocksPage.vm";
const StocksPageVmProbe = () => {
const vm = useStocksPageVm();
return (
<>
<button onClick={vm.onSearch}>search</button>
<button onClick={() => vm.onPage(2)}>page</button>
<output>{vm.items.map(item => item.name).join(", ")}</output>
</>
);
};
afterEach(() => vi.useRealTimers()); afterEach(() => vi.useRealTimers());
@@ -18,27 +30,28 @@ describe("stock search", () => {
it("waits for a meaningful query and uses the server-verified ticker link", async () => { it("waits for a meaningful query and uses the server-verified ticker link", async () => {
const query = vi.fn(); const query = vi.fn();
server.use( server.use(
http.get("*/api/v1/stocks", ({ request }) => { http.post("*/api/v1/stocks/screener/search", async ({ request }) => {
query(new URL(request.url).searchParams.get("query")); query(await request.json());
return HttpResponse.json( return HttpResponse.json({
resource([ asOf: "2026-10-10T10:00:00Z",
{ hasNext: false,
apiTradeAvailable: true, items: [{
classCode: "TQBR", countryOfRisk: "RU",
instrumentUid: "unique-stock", currency: "RUB",
isin: "RU0009029540", instrumentUid: "e6123145-9665-43e0-8413-cd61b8aa9b13",
logoName: "sber.png", isin: "RU0009029540",
name: "Сбербанк", logoName: "sber.png",
price: { currency: "RUB", value: "306.12" }, lot: 10,
priceAt: "2026-09-10T10:00:00Z", name: "Сбербанк",
realExchange: "REAL_EXCHANGE_MOEX", realExchange: "REAL_EXCHANGE_MOEX",
shareType: "SHARE_TYPE_COMMON", sector: "financial",
slug: "SBER", shareType: "SHARE_TYPE_COMMON",
ticker: "SBER", ticker: "SBER",
}, }],
]), page: 1,
); searchId: "search-id",
});
}), }),
); );
configureAuthClient({ configureAuthClient({
@@ -46,7 +59,6 @@ describe("stock search", () => {
clear: vi.fn(), clear: vi.fn(),
refresh: () => Promise.resolve(false), refresh: () => Promise.resolve(false),
}); });
vi.useFakeTimers();
render( render(
<QueryClientProvider client={createTestQueryClient()}> <QueryClientProvider client={createTestQueryClient()}>
<MantineProvider> <MantineProvider>
@@ -56,23 +68,212 @@ describe("stock search", () => {
</MantineProvider> </MantineProvider>
</QueryClientProvider>, </QueryClientProvider>,
); );
const input = screen.getByLabelText("Поиск акций"); const input = screen.getByLabelText("Название или тикер");
fireEvent.change(input, { target: { value: "S" } });
await act(() => vi.advanceTimersByTimeAsync(300));
expect(query).not.toHaveBeenCalled();
fireEvent.change(input, { target: { value: "SB" } });
await act(() => vi.advanceTimersByTimeAsync(299));
expect(query).not.toHaveBeenCalled();
// The final input uses real timers for network and query notifications.
vi.useRealTimers();
fireEvent.change(input, { target: { value: "SBER" } }); fireEvent.change(input, { target: { value: "SBER" } });
expect(query).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Найти" }));
expect( expect(
await screen.findByRole("link", { name: /Сбербанк/ }), await screen.findByRole("link", { name: /Сбербанк/ }),
).toHaveAttribute("href", "/stocks/SBER"); ).toHaveAttribute("href", "/stocks/e6123145-9665-43e0-8413-cd61b8aa9b13");
expect(screen.getByText("306,12 ₽")).toBeVisible(); expect(screen.getByText("Список получен 10.10.2026, 10:00")).toBeVisible();
expect(screen.getByText("Режим: TQBR")).toBeVisible(); expect(screen.getByText("Лот: 10 шт.")).toBeVisible();
expect(screen.getByText("ISIN: RU0009029540")).toBeVisible(); expect(screen.getByText("ISIN: RU0009029540")).toBeVisible();
expect(screen.getByText("Мосбиржа")).toBeVisible(); expect(screen.getAllByText("Мосбиржа")[0]).toBeVisible();
await waitFor(() => expect(query).toHaveBeenCalledExactlyOnceWith("SBER")); expect(screen.getByAltText("")).toHaveAttribute(
"src",
"https://invest-brands.cdn-tinkoff.ru/sberx160.png",
);
await waitFor(() => expect(query).toHaveBeenCalledExactlyOnceWith({ query: "SBER" }));
});
it("searches by the selected sector without a text query", async () => {
const query = vi.fn();
server.use(
http.post("*/api/v1/stocks/screener/search", async ({ request }) => {
query(await request.json());
return HttpResponse.json({
asOf: "2026-10-10T10:00:00Z",
hasNext: false,
items: [],
page: 1,
searchId: "sector-search",
});
}),
);
configureAuthClient({
accessToken: () => "synthetic",
clear: vi.fn(),
refresh: () => Promise.resolve(false),
});
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider>
<MemoryRouter>
<StocksPage />
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("combobox", { name: "Сектор" }));
fireEvent.click(await screen.findByRole("option", { name: "Энергетика" }));
fireEvent.click(screen.getByRole("button", { name: "Найти" }));
await waitFor(() => expect(query).toHaveBeenCalledExactlyOnceWith({
sector: "energy",
}));
});
it("shows known result pages and reads the selected page", async () => {
const requestedPages = vi.fn();
server.use(
http.post("*/api/v1/stocks/screener/search", () => HttpResponse.json({
asOf: "2026-10-10T10:00:00Z",
hasNext: true,
items: [],
page: 1,
searchId: "paged-search",
})),
http.get("*/api/v1/stocks/screener/search/paged-search", ({ request }) => {
const page = Number(new URL(request.url).searchParams.get("page"));
requestedPages(page);
return HttpResponse.json({
asOf: "2026-10-10T10:00:00Z",
hasNext: false,
items: [],
page,
searchId: "paged-search",
});
}),
);
configureAuthClient({
accessToken: () => "synthetic",
clear: vi.fn(),
refresh: () => Promise.resolve(false),
});
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider>
<MemoryRouter>
<StocksPage />
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "Найти" }));
fireEvent.click(await screen.findByRole(
"button",
{ name: "Страница 2" },
));
await waitFor(() =>
expect(requestedPages).toHaveBeenCalledExactlyOnceWith(2),
);
});
it("keeps a newer search when an earlier page request finishes late", async () => {
const oldPage = Promise.withResolvers<void>();
let searches = 0;
server.use(
http.post("*/api/v1/stocks/screener/search", () => {
searches += 1;
return HttpResponse.json({
asOf: "2026-10-10T10:00:00Z",
hasNext: searches === 1,
items: [{
countryOfRisk: "RU",
currency: "RUB",
instrumentUid: searches === 1 ? "old" : "new",
isin: null,
logoName: null,
lot: 1,
name: searches === 1 ? "Старый" : "Новый",
realExchange: "REAL_EXCHANGE_MOEX",
sector: "financial",
shareType: "SHARE_TYPE_COMMON",
ticker: searches === 1 ? "OLD" : "NEW",
}],
page: 1,
searchId: searches === 1 ? "old-search" : "new-search",
});
}),
http.get("*/api/v1/stocks/screener/search/old-search", async () => {
await oldPage.promise;
return HttpResponse.json({
asOf: "2026-10-10T10:00:00Z",
hasNext: false,
items: [{
countryOfRisk: "RU",
currency: "RUB",
instrumentUid: "old-page",
isin: null,
logoName: null,
lot: 1,
name: "Старый результат",
realExchange: "REAL_EXCHANGE_MOEX",
sector: "financial",
shareType: "SHARE_TYPE_COMMON",
ticker: "OLD",
}],
page: 2,
searchId: "old-search",
});
}),
);
render(<StocksPageVmProbe />);
fireEvent.click(screen.getByRole("button", { name: "search" }));
await screen.findByText("Старый");
fireEvent.click(screen.getByRole("button", { name: "page" }));
fireEvent.click(screen.getByRole("button", { name: "search" }));
await screen.findByText("Новый");
oldPage.resolve();
await waitFor(() => expect(screen.getByText("Новый")).toBeVisible());
expect(screen.queryByText("Старый результат")).not.toBeInTheDocument();
});
it("disables filters and shows loading while searching", async () => {
const { promise, resolve } = Promise.withResolvers<void>();
server.use(
http.post("*/api/v1/stocks/screener/search", async () => {
await promise;
return HttpResponse.json({
asOf: "2026-10-10T10:00:00Z",
hasNext: false,
items: [],
page: 1,
searchId: "pending-search",
});
}),
);
configureAuthClient({
accessToken: () => "synthetic",
clear: vi.fn(),
refresh: () => Promise.resolve(false),
});
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider>
<MemoryRouter>
<StocksPage />
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "Найти" }));
await waitFor(() => expect(
screen.getByRole("combobox", { name: "Сектор" }),
).toBeDisabled());
expect(screen.getByRole("button", { name: "Найти" })).toHaveAttribute(
"data-loading",
"true",
);
resolve();
}); });
}); });
@@ -1,89 +1,274 @@
import type { FC } from "react"; import type { ChangeEvent, FC } from "react";
import { Badge, Group, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { import {
InstrumentSearch, Alert,
InstrumentSearchCard, Anchor,
} from "@/features/instrument-search"; Avatar,
import { formatMoney, formatSnapshotTime } from "@/shared/lib"; Badge,
Button,
Card,
Fieldset,
Group,
NumberInput,
Pagination,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type { StocksPageVm } from "./StocksPage.vm"; import { formatSnapshotTime, instrumentLogoUrl } from "@/shared/lib";
import type { Criteria, StocksPageVm } from "./StocksPage.vm";
import { useStocksPagePresentationVm } from "./StocksPagePresentation.vm";
type CriteriaField = {
label: keyof Criteria;
options?: readonly string[];
type?: "number";
};
type StockCardProps = { item: StocksPageVm["items"][number] };
type StockCriteriaFieldProps = {
criteria: Criteria;
field: CriteriaField;
onChange: (name: keyof Criteria, value: string) => void;
};
type StocksPageViewProps = { vm: StocksPageVm }; type StocksPageViewProps = { vm: StocksPageVm };
const criteriaFields: CriteriaField[] = [
{ label: "query" },
{
label: "sector",
options: [
"consumer",
"energy",
"financial",
"health_care",
"industrials",
"it",
"materials",
"real_estate",
"telecom",
"utilities",
],
},
{
label: "shareType",
options: [
"SHARE_TYPE_ADR",
"SHARE_TYPE_COMMON",
"SHARE_TYPE_GDR",
"SHARE_TYPE_PREFERRED",
],
},
{ label: "currency", options: ["RUB", "USD", "EUR", "CNY", "HKD", "TRY"] },
{
label: "realExchange",
options: [
"REAL_EXCHANGE_MOEX",
"REAL_EXCHANGE_RTS",
"REAL_EXCHANGE_OTC",
"REAL_EXCHANGE_DEALER",
],
},
{ label: "countryOfRisk", options: ["RU", "US", "CN", "KZ", "HK", "TR"] },
{ label: "maxLot", type: "number" },
];
function numberChange(
field: keyof Criteria,
onChange: StockCriteriaFieldProps["onChange"],
) {
return (value: number | string) => onChange(field, String(value));
}
function selectChange(
field: keyof Criteria,
onChange: StockCriteriaFieldProps["onChange"],
) {
return (value: null | string) => onChange(field, value ?? "");
}
function selectOptions(field: CriteriaField, translate: ReturnType<typeof useTranslation>["t"]) {
return field.options?.map(value => ({
label: translate(`stock.screener.options.${field.label}.${value}`, {
defaultValue: value,
}),
value,
}));
}
function textChange(
field: keyof Criteria,
onChange: StockCriteriaFieldProps["onChange"],
) {
return (event: ChangeEvent<HTMLInputElement>) =>
onChange(field, event.currentTarget.value);
}
const StockCriteriaField: FC<StockCriteriaFieldProps> = (props) => {
const { criteria, field, onChange } = props;
const { t } = useTranslation();
const options = selectOptions(field, t);
if (field.type === "number") return (
<NumberInput
label={t(`stock.screener.${field.label}`)}
min={1}
onChange={numberChange(field.label, onChange)}
value={criteria[field.label]}
/>
);
if (options) return (
<Select
clearable
data={options}
label={t(`stock.screener.${field.label}`)}
onChange={selectChange(field.label, onChange)}
value={criteria[field.label] || null}
/>
);
return (
<TextInput
label={t(`stock.screener.${field.label}`)}
onChange={textChange(field.label, onChange)}
value={criteria[field.label]}
/>
);
};
const StockCard: FC<StockCardProps> = (props) => {
const { item } = props;
const { t } = useTranslation();
const exchange = item.realExchange
? t(`stock.exchanges.${item.realExchange}`, {
defaultValue: item.realExchange,
})
: undefined;
const lot = item.lot === null
? undefined
: t("stock.lot", { value: item.lot });
return (
<Card p="md" withBorder>
<Group gap="sm" wrap="nowrap">
<Avatar alt="" radius="md" size={40} src={instrumentLogoUrl(item.logoName)}>
{item.name[0]}
</Avatar>
<Stack gap={2} miw={0}>
<Anchor
c="inherit"
component={Link}
fw={600}
to={`/stocks/${item.instrumentUid}`}
>
{item.name}
</Anchor>
<Text c="dimmed" size="xs">
{item.ticker ?? "-"} · {item.shareType ?? "-"}
</Text>
<Group gap="xs">
{exchange && <Badge color="gray" variant="light">{exchange}</Badge>}
{item.currency && (
<Badge color="gray" variant="light">{item.currency}</Badge>
)}
{lot && <Text c="dimmed" size="xs">{lot}</Text>}
</Group>
{item.isin && (
<Text c="dimmed" size="xs">
{t("stock.isin", { value: item.isin })}
</Text>
)}
</Stack>
</Group>
</Card>
);
};
const StocksPageView: FC<StocksPageViewProps> = (props) => { const StocksPageView: FC<StocksPageViewProps> = (props) => {
const { vm } = props; const { vm } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const { getControlProps, getItemProps } = useStocksPagePresentationVm();
return ( return (
<InstrumentSearch kind="stock" vm={vm}> <Stack gap="lg">
{vm.items.map((stock) => { <div>
const quote = ( <Title order={1}>{t("stock.screenerTitle")}</Title>
<>
<Text fw={650} size="xl">
{stock.price ? formatMoney(stock.price) : "—"}
</Text>
<Text c="dimmed" size="xs"> <Text c="dimmed" mt="xs">{t("stock.screenerSubtitle")}</Text>
{stock.price && stock.priceAt </div>
? t("stock.priceAt", {
date: formatSnapshotTime(stock.priceAt),
})
: t("stock.priceUnavailable")}
</Text>
{stock.apiTradeAvailable === true && ( <Fieldset disabled={vm.loading} variant="unstyled">
<Text c="green.8" mt="sm" size="xs"> <SimpleGrid cols={{ base: 1, sm: 2 }}>
{t("stock.apiAvailable")} {criteriaFields.map(field => (
</Text> <StockCriteriaField
)} criteria={vm.criteria}
</> field={field}
); key={field.label}
onChange={vm.onCriteriaChange}
/>
))}
</SimpleGrid>
return ( <Group mt="lg">
<InstrumentSearchCard <Button loading={vm.loading} onClick={vm.onSearch}>
instrument={stock} {t("stock.screener.search")}
key={stock.instrumentUid} </Button>
quote={quote} </Group>
> </Fieldset>
<Text c="dimmed" size="sm">
{stock.ticker} ·{" "}
{t(`stock.shareTypes.${stock.shareType}`, {
defaultValue: t("stock.share"),
})}
</Text>
<Group gap="xs" mt="sm"> {!!vm.error && (
<Badge color="gray" variant="light"> <Alert color="orange" role="status">{t("stock.screener.error")}</Alert>
{t(`stock.exchanges.${stock.realExchange}`, { )}
defaultValue: t("stock.exchangeUnknown"),
})}
</Badge>
{!!stock.classCode && ( {vm.resultTime && (
<Text c="dimmed" size="xs"> <Text c="dimmed" size="sm">
{t("stock.classCode", { code: stock.classCode })} {t("stock.screener.asOf", {
</Text> date: formatSnapshotTime(vm.resultTime),
)} })}
</Group> </Text>
)}
{!!stock.isin && ( {vm.searched && !vm.loading && !vm.error && vm.items.length === 0 && (
<Text c="dimmed" mt={4} size="xs"> <Text c="dimmed">{t("stock.screener.empty")}</Text>
{t("stock.isin", { value: stock.isin })} )}
</Text>
)}
{stock.apiTradeAvailable === false && ( <Stack gap="sm">
<Text c="dimmed" mt={4} size="xs"> {vm.items.map(item => (
{t("stock.apiUnavailable")} <StockCard item={item} key={item.instrumentUid} />
</Text> ))}
)} </Stack>
</InstrumentSearchCard>
); {vm.totalPages > 1 && (
})} <Group aria-label={t("stock.screener.pagination")} component="nav" justify="center">
</InstrumentSearch> <Pagination
disabled={vm.loading}
getControlProps={getControlProps}
getItemProps={getItemProps}
onChange={vm.onPage}
total={vm.totalPages}
value={vm.page ?? 1}
/>
</Group>
)}
</Stack>
); );
}; };
+110 -34
View File
@@ -1,43 +1,119 @@
import { MIN_STOCK_SEARCH_LENGTH, useStockSearch } from "@/entities/stock"; import { useRef, useState } from "react";
import { useInstrumentSearch } from "@/features/instrument-search";
import { useScreenSync } from "@/features/tbank-connection"; import { apiClient } from "@/shared/api";
import { apiErrorCode } from "@/shared/api";
import { instrumentLogoUrl } from "@/shared/lib"; type Criteria = {
countryOfRisk: string;
currency: string;
maxLot: string;
query: string;
realExchange: string;
sector: string;
shareType: string;
};
type ScreenerPage = Awaited<ReturnType<typeof readPage>>;
type StocksPageVm = ReturnType<typeof useStocksPageVm>; type StocksPageVm = ReturnType<typeof useStocksPageVm>;
const emptyCriteria: Criteria = {
countryOfRisk: "",
currency: "",
maxLot: "",
query: "",
realExchange: "",
sector: "",
shareType: "",
};
async function createSearch(criteria: Criteria) {
const { data } = await apiClient.POST("/api/v1/stocks/screener/search", {
body: {
...(criteria.countryOfRisk && { countryOfRisk: criteria.countryOfRisk }),
...(criteria.currency && { currency: criteria.currency }),
...(criteria.maxLot && { maxLot: Number(criteria.maxLot) }),
...(criteria.query && { query: criteria.query }),
...(criteria.realExchange && { realExchange: criteria.realExchange }),
...(criteria.sector && { sector: criteria.sector }),
...(criteria.shareType && { shareType: criteria.shareType }),
},
});
if (!data) throw new Error("Stock screener search failed");
return data;
}
async function readPage(searchId: string, page: number) {
const { data } = await apiClient.GET(
"/api/v1/stocks/screener/search/{searchId}",
{ params: { path: { searchId }, query: { page } } },
);
if (!data) throw new Error("Stock screener page failed");
return data;
}
function useStocksPageVm() { function useStocksPageVm() {
const search = useInstrumentSearch(MIN_STOCK_SEARCH_LENGTH); const [criteria, setCriteria] = useState(emptyCriteria);
const { canSearch, debounced, isWaiting, query } = search; const [page, setPage] = useState<null | ScreenerPage>(null);
const result = useStockSearch(debounced); const [pending, setPending] = useState(false);
useScreenSync({ query: debounced, screen: "stocks" }, [result]); const [error, setError] = useState(false);
const requestIdRef = useRef(0);
const search = async () => {
const currentRequestId = requestIdRef.current + 1;
requestIdRef.current = currentRequestId;
setPending(true);
setError(false);
try {
const result = await createSearch(criteria);
if (requestIdRef.current === currentRequestId) setPage(result);
}
catch {
if (requestIdRef.current === currentRequestId) setError(true);
}
finally {
if (requestIdRef.current === currentRequestId) setPending(false);
}
};
const changePage = async (nextPage: number) => {
if (!page) return;
const currentRequestId = requestIdRef.current + 1;
requestIdRef.current = currentRequestId;
setPending(true);
setError(false);
try {
const result = await readPage(page.searchId, nextPage);
if (requestIdRef.current === currentRequestId) setPage(result);
}
catch {
if (requestIdRef.current === currentRequestId) setError(true);
}
finally {
if (requestIdRef.current === currentRequestId) setPending(false);
}
};
return { return {
canSearch, criteria,
code: apiErrorCode(result.error), error,
error: items: page?.items ?? [],
!result.data loading: pending,
&& (result.isError || result.meta?.sync.state === "failed"), onCriteriaChange: (name: keyof Criteria, value: string) =>
items: setCriteria(current => ({ ...current, [name]: value })),
result.data?.map(stock => ({ onPage: (nextPage: number) => void changePage(nextPage),
...stock, onSearch: () => void search(),
logoSrc: instrumentLogoUrl(stock.logoName), page: page?.page,
to: `/stocks/${encodeURIComponent(stock.slug)}`, resultTime: page?.asOf,
})) ?? [], searched: page !== null,
loading: totalPages: page ? page.page + Number(page.hasNext) : 0,
canSearch
&& (isWaiting
|| (!result.data
&& !result.isError
&& result.meta?.sync.state !== "failed")),
onQueryChange: search.onQueryChange,
partial: result.meta?.availability === "partial",
query,
ready:
debounced.length >= MIN_STOCK_SEARCH_LENGTH
&& !isWaiting
&& Boolean(result.data),
}; };
} }
export { type StocksPageVm, useStocksPageVm }; export { type Criteria, type StocksPageVm, useStocksPageVm };
@@ -0,0 +1,20 @@
import type { Pagination } from "@mantine/core";
import type { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
function useStocksPagePresentationVm() {
const { t } = useTranslation();
const getControlProps: ComponentProps<typeof Pagination>["getControlProps"] = control => ({
"aria-label": t(control === "previous" ? "stock.previous" : "stock.next"),
});
const getItemProps: ComponentProps<typeof Pagination>["getItemProps"] = page => ({
"aria-label": t("stock.screener.page", { page }),
});
return { getControlProps, getItemProps };
}
export { useStocksPagePresentationVm };
+269 -151
View File
@@ -436,6 +436,38 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/stocks/screener/search": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["createStockScreenerSearch"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/stocks/screener/search/{searchId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["readStockScreenerPage"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/stocks/{uid}": { "/api/v1/stocks/{uid}": {
parameters: { parameters: {
query?: never; query?: never;
@@ -676,22 +708,6 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/tbank-sync": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["TbankSyncController_request"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/tbank-sync/{id}": { "/api/v1/tbank-sync/{id}": {
parameters: { parameters: {
query?: never; query?: never;
@@ -725,7 +741,10 @@ export interface components {
* } * }
* ] * ]
*/ */
errors?: string[]; errors?: {
pointer: string;
detail: string;
}[];
}; };
CredentialsDto: { CredentialsDto: {
/** @example user@example.com */ /** @example user@example.com */
@@ -912,24 +931,35 @@ export interface components {
/** @enum {string} */ /** @enum {string} */
historyState: "missing" | "loading" | "partial" | "complete" | "failed"; historyState: "missing" | "loading" | "partial" | "complete" | "failed";
}; };
InvestmentSyncDto: { InvestmentAttemptProblemDto: {
id: string | null;
/** @enum {string} */ /** @enum {string} */
state: "idle" | "running" | "failed"; section: "membership" | "portfolio" | "operations" | "events";
accountId: string | null;
errorCode: string | null; errorCode: string | null;
}; };
InvestmentAttemptDto: {
/** @enum {string} */
state: "none" | "running" | "succeeded" | "partial" | "failed";
/** Format: date-time */
startedAt: string | null;
/** Format: date-time */
finishedAt: string | null;
problems: components["schemas"]["InvestmentAttemptProblemDto"][];
};
InvestmentDependencyDto: { InvestmentDependencyDto: {
dataRevision: string; dataRevision: string;
unavailableReason?: string; unavailableReason?: string;
coverage?: components["schemas"]["InvestmentCoverageDto"]; coverage?: components["schemas"]["InvestmentCoverageDto"];
resourceKey: string; resourceKey: string;
/** @enum {string} */ /** @enum {string} */
availability: "missing" | "partial" | "complete"; availability: "available" | "initial-loading" | "temporarily-unavailable" | "connection-unavailable";
/** @enum {string} */
completeness: "complete" | "incomplete";
/** @enum {string} */ /** @enum {string} */
freshness: "unknown" | "fresh" | "stale"; freshness: "unknown" | "fresh" | "stale";
/** Format: date-time */ /** Format: date-time */
lastSuccessfulAt: string | null; lastSuccessfulAt: string | null;
sync: components["schemas"]["InvestmentSyncDto"]; lastAttempt: components["schemas"]["InvestmentAttemptDto"];
}; };
InvestmentResourceMetaDto: { InvestmentResourceMetaDto: {
dataRevision: string; dataRevision: string;
@@ -937,17 +967,33 @@ export interface components {
coverage?: components["schemas"]["InvestmentCoverageDto"]; coverage?: components["schemas"]["InvestmentCoverageDto"];
resourceKey: string; resourceKey: string;
/** @enum {string} */ /** @enum {string} */
availability: "missing" | "partial" | "complete"; availability: "available" | "initial-loading" | "temporarily-unavailable" | "connection-unavailable";
/** @enum {string} */
completeness: "complete" | "incomplete";
/** @enum {string} */ /** @enum {string} */
freshness: "unknown" | "fresh" | "stale"; freshness: "unknown" | "fresh" | "stale";
/** Format: date-time */ /** Format: date-time */
lastSuccessfulAt: string | null; lastSuccessfulAt: string | null;
sync: components["schemas"]["InvestmentSyncDto"]; lastAttempt: components["schemas"]["InvestmentAttemptDto"];
dependencies?: components["schemas"]["InvestmentDependencyDto"][]; dependencies?: components["schemas"]["InvestmentDependencyDto"][];
}; };
HourlySyncStatusDto: {
/**
* @description Last hourly walk state. Cancelled means an interrupted attempt; its start still delays the next term.
* @enum {string}
*/
state: "running" | "succeeded" | "partial" | "failed" | "cancelled";
/** Format: date-time */
startedAt: string;
/** Format: date-time */
finishedAt: string | null;
problems: components["schemas"]["InvestmentAttemptProblemDto"][];
};
BrokerageAccountsResourceDto: { BrokerageAccountsResourceDto: {
data: components["schemas"]["BrokerageAccountDto"][] | null; data: components["schemas"]["BrokerageAccountDto"][] | null;
meta: components["schemas"]["InvestmentResourceMetaDto"]; meta: components["schemas"]["InvestmentResourceMetaDto"];
/** @description Read-only status of the last hourly walk for the current connection. Null when no walk attempt exists yet. */
sync: components["schemas"]["HourlySyncStatusDto"] | null;
}; };
AccountReferenceDto: { AccountReferenceDto: {
id: string; id: string;
@@ -1061,16 +1107,6 @@ export interface components {
data: components["schemas"]["IncomeSummaryDto"] | null; data: components["schemas"]["IncomeSummaryDto"] | null;
meta: components["schemas"]["InvestmentResourceMetaDto"]; meta: components["schemas"]["InvestmentResourceMetaDto"];
}; };
IncomeComparisonCurrencyDto: {
currency: string;
selected: components["schemas"]["IncomeCurrencySummaryDto"];
compared: components["schemas"]["IncomeCurrencySummaryDto"];
difference: components["schemas"]["IncomeCurrencySummaryDto"];
};
IncomeComparisonDto: {
period: components["schemas"]["IncomePeriodDto"];
totalsByCurrency: components["schemas"]["IncomeComparisonCurrencyDto"][];
};
IncomeBucketDto: { IncomeBucketDto: {
period: components["schemas"]["IncomePeriodDto"]; period: components["schemas"]["IncomePeriodDto"];
totalsByCurrency: components["schemas"]["IncomeCurrencySummaryDto"][]; totalsByCurrency: components["schemas"]["IncomeCurrencySummaryDto"][];
@@ -1114,8 +1150,6 @@ export interface components {
totalsByCurrency: components["schemas"]["IncomeCurrencySummaryDto"][]; totalsByCurrency: components["schemas"]["IncomeCurrencySummaryDto"][];
/** Format: date-time */ /** Format: date-time */
calculatedAt: string; calculatedAt: string;
previous: components["schemas"]["IncomeComparisonDto"] | null;
yearComparison: components["schemas"]["IncomeComparisonDto"] | null;
/** @enum {string} */ /** @enum {string} */
bucketGranularity: "day" | "week" | "month"; bucketGranularity: "day" | "week" | "month";
buckets: components["schemas"]["IncomeBucketDto"][]; buckets: components["schemas"]["IncomeBucketDto"][];
@@ -1128,14 +1162,16 @@ export interface components {
coverage?: components["schemas"]["InvestmentCoverageDto"]; coverage?: components["schemas"]["InvestmentCoverageDto"];
resourceKey: string; resourceKey: string;
/** @enum {string} */ /** @enum {string} */
availability: "missing" | "partial" | "complete"; availability: "available" | "initial-loading" | "temporarily-unavailable" | "connection-unavailable";
/** @enum {string} */
completeness: "complete" | "incomplete";
/** @enum {string} */ /** @enum {string} */
freshness: "unknown" | "fresh" | "stale"; freshness: "unknown" | "fresh" | "stale";
/** Format: date-time */ /** Format: date-time */
lastSuccessfulAt: string | null; lastSuccessfulAt: string | null;
sync: components["schemas"]["InvestmentSyncDto"]; lastAttempt: components["schemas"]["InvestmentAttemptDto"];
/** @enum {string} */ /** @enum {string} */
role: "selected" | "previous" | "year"; role: "selected";
}; };
IncomeAnalyticsMetaDto: { IncomeAnalyticsMetaDto: {
dataRevision: string; dataRevision: string;
@@ -1143,12 +1179,14 @@ export interface components {
coverage?: components["schemas"]["InvestmentCoverageDto"]; coverage?: components["schemas"]["InvestmentCoverageDto"];
resourceKey: string; resourceKey: string;
/** @enum {string} */ /** @enum {string} */
availability: "missing" | "partial" | "complete"; availability: "available" | "initial-loading" | "temporarily-unavailable" | "connection-unavailable";
/** @enum {string} */
completeness: "complete" | "incomplete";
/** @enum {string} */ /** @enum {string} */
freshness: "unknown" | "fresh" | "stale"; freshness: "unknown" | "fresh" | "stale";
/** Format: date-time */ /** Format: date-time */
lastSuccessfulAt: string | null; lastSuccessfulAt: string | null;
sync: components["schemas"]["InvestmentSyncDto"]; lastAttempt: components["schemas"]["InvestmentAttemptDto"];
/** @description Dependencies identified by role, not array order or resourceKey. A missing role is unavailable. */ /** @description Dependencies identified by role, not array order or resourceKey. A missing role is unavailable. */
dependencies: components["schemas"]["IncomeDependencyDto"][]; dependencies: components["schemas"]["IncomeDependencyDto"][];
}; };
@@ -1232,6 +1270,37 @@ export interface components {
data: components["schemas"]["StockDto"] | null; data: components["schemas"]["StockDto"] | null;
meta: components["schemas"]["InvestmentResourceMetaDto"]; meta: components["schemas"]["InvestmentResourceMetaDto"];
}; };
StockScreenerCriteriaDto: {
query?: string;
sector?: string;
shareType?: string;
currency?: string;
realExchange?: string;
countryOfRisk?: string;
maxLot?: number;
};
StockScreenerItemDto: {
/** Format: uuid */
instrumentUid: string;
name: string;
ticker: string | null;
isin: string | null;
logoName: string | null;
shareType: string | null;
sector: string | null;
currency: string | null;
realExchange: string | null;
countryOfRisk: string | null;
lot: number | null;
};
StockScreenerPageDto: {
searchId: string;
/** Format: date-time */
asOf: string;
page: number;
hasNext: boolean;
items: components["schemas"]["StockScreenerItemDto"][];
};
StockQuoteDto: { StockQuoteDto: {
price: components["schemas"]["MoneyDto"] | null; price: components["schemas"]["MoneyDto"] | null;
/** Format: date-time */ /** Format: date-time */
@@ -1543,24 +1612,6 @@ export interface components {
PutTbankConnectionDto: { PutTbankConnectionDto: {
token: string; token: string;
}; };
IncomeInvestmentScreenDto: {
accountId: string;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
screen: "income";
/**
* Format: date
* @description Inclusive start calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither for the current Moscow month.
*/
from?: string;
/**
* Format: date
* @description Inclusive end calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither. Timestamp queries use the start of the following Moscow day as the exclusive end, capped at now.
*/
to?: string;
};
EventsInvestmentScreenDto: { EventsInvestmentScreenDto: {
accountId: string; accountId: string;
/** /**
@@ -1579,6 +1630,24 @@ export interface components {
*/ */
to?: string; to?: string;
}; };
IncomeInvestmentScreenDto: {
accountId: string;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
screen: "income";
/**
* Format: date
* @description Inclusive start calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither for the current Moscow month.
*/
from?: string;
/**
* Format: date
* @description Inclusive end calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither. Timestamp queries use the start of the following Moscow day as the exclusive end, capped at now.
*/
to?: string;
};
SyncCandleRangeDto: { SyncCandleRangeDto: {
/** Format: date-time */ /** Format: date-time */
from: string; from: string;
@@ -1611,13 +1680,6 @@ export interface components {
*/ */
to?: string; to?: string;
}; };
TbankSyncAcceptedDto: {
/** Format: uuid */
id: string;
scope: components["schemas"]["IncomeInvestmentScreenDto"] | components["schemas"]["EventsInvestmentScreenDto"] | components["schemas"]["TimestampInvestmentScreenDto"];
/** Format: date-time */
now: string;
};
TbankSyncStatusDto: { TbankSyncStatusDto: {
/** Format: uuid */ /** Format: uuid */
id: string; id: string;
@@ -3175,9 +3237,7 @@ export interface operations {
}; };
BrokerageAccountsController_list: { BrokerageAccountsController_list: {
parameters: { parameters: {
query?: { query?: never;
sync?: "observe";
};
header?: never; header?: never;
path?: never; path?: never;
cookie?: never; cookie?: never;
@@ -3241,9 +3301,7 @@ export interface operations {
}; };
PortfolioController_all: { PortfolioController_all: {
parameters: { parameters: {
query?: { query?: never;
sync?: "observe";
};
header?: never; header?: never;
path?: never; path?: never;
cookie?: never; cookie?: never;
@@ -3307,9 +3365,7 @@ export interface operations {
}; };
PortfolioController_get: { PortfolioController_get: {
parameters: { parameters: {
query?: { query?: never;
sync?: "observe";
};
header?: never; header?: never;
path: { path: {
accountId: string; accountId: string;
@@ -3376,7 +3432,6 @@ export interface operations {
BrokerageOperationsController_all: { BrokerageOperationsController_all: {
parameters: { parameters: {
query?: { query?: {
sync?: "observe";
/** @description Instrument UID. Without from, includes history since the earliest available account opening. */ /** @description Instrument UID. Without from, includes history since the earliest available account opening. */
instrumentUid?: string; instrumentUid?: string;
from?: string; from?: string;
@@ -3449,7 +3504,6 @@ export interface operations {
BrokerageOperationsController_get: { BrokerageOperationsController_get: {
parameters: { parameters: {
query?: { query?: {
sync?: "observe";
/** @description Instrument UID. Without from, includes history since the earliest available account opening. */ /** @description Instrument UID. Without from, includes history since the earliest available account opening. */
instrumentUid?: string; instrumentUid?: string;
from?: string; from?: string;
@@ -4074,6 +4128,142 @@ export interface operations {
}; };
}; };
}; };
createStockScreenerSearch: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["StockScreenerCriteriaDto"];
};
};
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["StockScreenerPageDto"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Upstream service unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
};
};
readStockScreenerPage: {
parameters: {
query: {
page: number;
};
header?: never;
path: {
searchId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["StockScreenerPageDto"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Upstream service unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
};
};
StockController_get: { StockController_get: {
parameters: { parameters: {
query?: { query?: {
@@ -4281,9 +4471,7 @@ export interface operations {
}; };
StockController_position: { StockController_position: {
parameters: { parameters: {
query?: { query?: never;
sync?: "observe";
};
header?: never; header?: never;
path: { path: {
uid: string; uid: string;
@@ -4893,9 +5081,7 @@ export interface operations {
}; };
BondController_position: { BondController_position: {
parameters: { parameters: {
query?: { query?: never;
sync?: "observe";
};
header?: never; header?: never;
path: { path: {
uid: string; uid: string;
@@ -5221,74 +5407,6 @@ export interface operations {
}; };
}; };
}; };
TbankSyncController_request: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["IncomeInvestmentScreenDto"] | components["schemas"]["EventsInvestmentScreenDto"] | components["schemas"]["TimestampInvestmentScreenDto"];
};
};
responses: {
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TbankSyncAcceptedDto"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Upstream service unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
};
};
TbankSyncController_status: { TbankSyncController_status: {
parameters: { parameters: {
query?: never; query?: never;
-2
View File
@@ -1,5 +1,4 @@
export { export {
useAuthenticatedQueries,
useAuthenticatedQuery, useAuthenticatedQuery,
} from "./authenticated-query"; } from "./authenticated-query";
export { export {
@@ -11,7 +10,6 @@ export type { paths } from "./generated/schema";
export { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details"; export { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
export { export {
invalidateResourceAccess,
type InvestmentResourceMeta, type InvestmentResourceMeta,
useResourceQuery, useResourceQuery,
} from "./resource-query"; } from "./resource-query";
+8 -5
View File
@@ -75,9 +75,10 @@ describe("persistent resource query lifecycle", () => {
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json( HttpResponse.json(
resource(null, { resource(null, {
sync: { lastAttempt: {
errorCode: "TBANK_UNAVAILABLE", finishedAt: "2026-08-14T00:01:00Z",
id: "work", problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed", state: "failed",
}, },
}), }),
@@ -89,14 +90,16 @@ describe("persistent resource query lifecycle", () => {
wrapper: createQueryWrapper().wrapper, wrapper: createQueryWrapper().wrapper,
}); });
await waitFor(() => expect(result.current.meta?.sync.state).toBe("failed")); await waitFor(() =>
expect(result.current.meta?.lastAttempt.state).toBe("failed"),
);
expect(result.current.data).toBeUndefined(); expect(result.current.data).toBeUndefined();
expect(result.current.isSuccess).toBe(true); expect(result.current.isSuccess).toBe(true);
expect(result.current.status).toBe("success"); expect(result.current.status).toBe("success");
expect(result.current.isPending).toBe(false); expect(result.current.isPending).toBe(false);
expect(result.current.isLoading).toBe(false); expect(result.current.isLoading).toBe(false);
expect(result.current.meta?.availability).toBe("missing"); expect(result.current.meta?.availability).toBe("initial-loading");
}); });
it("keeps the refetch envelope while exposing the refreshed resource payload", async () => { it("keeps the refetch envelope while exposing the refreshed resource payload", async () => {
auth(); auth();
+14 -4
View File
@@ -15,8 +15,10 @@ import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
type InvestmentResourceMeta = components["schemas"]["InvestmentResourceMetaDto"]; type InvestmentResourceMeta = components["schemas"]["InvestmentResourceMetaDto"];
type Resource = { type Resource = {
[key: string]: unknown;
data: unknown; data: unknown;
meta: InvestmentResourceMeta; meta: InvestmentResourceMeta;
sync?: unknown;
}; };
type ResourcePath = { type ResourcePath = {
@@ -66,8 +68,12 @@ function invalidateResourceAccess(
// Keep endpoint and parameters intact. The generated queryFn accepts the // Keep endpoint and parameters intact. The generated queryFn accepts the
// optional sync mode even for a cache key originally created without init. // optional sync mode even for a cache key originally created without init.
function observationKey<K extends ResourceQueryKey>(key: K): K { function observationKey<K extends readonly unknown[]>(key: K): K {
const [method, path, init] = key; const [method, path, init] = key as unknown as [
string,
string,
{ params?: { path?: object; query?: object } }?,
];
return [method, path, { return [method, path, {
...init, ...init,
@@ -76,7 +82,11 @@ function observationKey<K extends ResourceQueryKey>(key: K): K {
} }
// Observation changes transport mode, never the generated cache identity. // Observation changes transport mode, never the generated cache identity.
function useResourceQuery<T extends Resource, E, K extends ResourceQueryKey>( function useResourceQuery<
T extends Resource,
E = unknown,
K extends readonly unknown[] = ResourceQueryKey,
>(
options: UseQueryOptions<T, E, T, K>, options: UseQueryOptions<T, E, T, K>,
) { ) {
const client = useQueryClient(); const client = useQueryClient();
@@ -135,11 +145,11 @@ function useResourceQuery<T extends Resource, E, K extends ResourceQueryKey>(
queryKey: options.queryKey, queryKey: options.queryKey,
refetch: query.refetch, refetch: query.refetch,
status: query.status, status: query.status,
sync: query.data && "sync" in query.data ? query.data.sync : undefined,
}; };
} }
export { export {
invalidateResourceAccess,
type InvestmentResourceMeta, type InvestmentResourceMeta,
useResourceQuery, useResourceQuery,
}; };
File diff suppressed because it is too large Load Diff
+66 -9
View File
@@ -340,13 +340,8 @@ const ru = {
}, },
income: { income: {
apply: "Применить", apply: "Применить",
comparisonEmpty: "За эти периоды купоны и дивиденды не найдены.",
comparisonFailed:
"Не удалось загрузить период сравнения. Повторите обновление.",
comparisonPending: "Период сравнения ещё не загружен полностью.",
coupon: "Купоны", coupon: "Купоны",
datePlaceholder: "ДД.ММ.ГГГГ", datePlaceholder: "ДД.ММ.ГГГГ",
difference: "Разница",
dividend: "Дивиденды", dividend: "Дивиденды",
emptyText: "За выбранный период подходящих исполненных выплат нет.", emptyText: "За выбранный период подходящих исполненных выплат нет.",
emptyTitle: "Купоны и дивиденды не найдены", emptyTitle: "Купоны и дивиденды не найдены",
@@ -356,8 +351,6 @@ const ru = {
open: "Открыть пассивный доход", open: "Открыть пассивный доход",
payments: "Выплаты", payments: "Выплаты",
period: "Период", period: "Период",
previousPeriod: "Предыдущий период",
previousUnavailable: "Сравнение с предыдущим периодом недоступно.",
refreshError: refreshError:
"Не удалось обновить пассивный доход. Показан последний загруженный результат.", "Не удалось обновить пассивный доход. Показан последний загруженный результат.",
refreshing: "Обновляем пассивный доход", refreshing: "Обновляем пассивный доход",
@@ -378,10 +371,15 @@ const ru = {
period: period:
"Укажите корректный период без будущих дат: начало не позже окончания.", "Укажите корректный период без будущих дат: начало не позже окончания.",
}, },
yearAgo: "Годом ранее",
yearUnavailable: "Сравнение с годом ранее недоступно.",
}, },
investment: { investment: {
attempt: {
cancelled: "Обновление отменено",
failed: "Обновление не удалось",
partial: "Обновлено частично",
running: "Обновление выполняется",
succeeded: "Обновлено",
},
history: "История догружается", history: "История догружается",
partial: "Период загружен не полностью", partial: "Период загружен не полностью",
refresh: "Обновить", refresh: "Обновить",
@@ -452,6 +450,14 @@ const ru = {
stocks: "Акции", stocks: "Акции",
}, },
notFound: { action: "К счетам", title: "Страница не найдена" }, notFound: { action: "К счетам", title: "Страница не найдена" },
payout: {
estimate: "Оценка по позиции",
executedAt: "Исполнено {{date}}",
forecast: "Прогноз",
noAmount: "Сумма неизвестна",
noMoney: "Без движения денег",
registryDate: "Дата реестра",
},
portfolio: { portfolio: {
accountUnavailableText: "Вернитесь к списку и выберите доступный счет.", accountUnavailableText: "Вернитесь к списку и выберите доступный счет.",
accountUnavailableTitle: "Счет недоступен", accountUnavailableTitle: "Счет недоступен",
@@ -629,6 +635,57 @@ const ru = {
quantity: "Количество акций", quantity: "Количество акций",
record: "Дата реестра", record: "Дата реестра",
refreshPrice: "Обновить цену", refreshPrice: "Обновить цену",
screener: {
asOf: "Список получен {{date}}",
countryOfRisk: "Страна риска",
currency: "Валюта расчётов",
empty: "Подходящие акции не найдены.",
error: "Не удалось получить результат скринера. Попробуйте найти снова.",
maxLot: "Максимальный размер лота",
options: {
countryOfRisk: {
CN: "Китай",
HK: "Гонконг",
KZ: "Казахстан",
RU: "Россия",
TR: "Турция",
US: "США",
},
realExchange: {
REAL_EXCHANGE_DEALER: "Площадка брокера",
REAL_EXCHANGE_MOEX: "Мосбиржа",
REAL_EXCHANGE_OTC: "Внебиржевой рынок",
REAL_EXCHANGE_RTS: "СПБ Биржа",
},
sector: {
consumer: "Потребительский сектор",
energy: "Энергетика",
financial: "Финансы",
health_care: "Здравоохранение",
industrials: "Промышленность",
it: "Информационные технологии",
materials: "Материалы",
real_estate: "Недвижимость",
telecom: "Телекоммуникации",
utilities: "Коммунальные услуги",
},
shareType: {
SHARE_TYPE_ADR: "Депозитарная расписка",
SHARE_TYPE_COMMON: "Обыкновенная акция",
SHARE_TYPE_GDR: "Глобальная депозитарная расписка",
SHARE_TYPE_PREFERRED: "Привилегированная акция",
},
},
page: "Страница {{page}}",
pagination: "Страницы результатов",
query: "Название или тикер",
realExchange: "Площадка",
search: "Найти",
sector: "Сектор",
shareType: "Тип акции",
},
screenerSubtitle: "Подберите акции по данным каталога T-Bank. Доступность не гарантирует исполнение сделки.",
screenerTitle: "Скринер акций",
searchHint: "Введите хотя бы два символа.", searchHint: "Введите хотя бы два символа.",
searching: "Ищем акции", searching: "Ищем акции",
searchLabel: "Поиск акций", searchLabel: "Поиск акций",
@@ -0,0 +1,85 @@
import type { FC } from "react";
import { MantineProvider } from "@mantine/core";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { PayoutCard } from "./PayoutCard";
const Wrapper: FC<{ children: React.ReactNode }> = props => (
<MantineProvider env="test">{props.children}</MantineProvider>
);
describe("PayoutCard", () => {
it("renders an executed payout with signed source money", () => {
render(
<Wrapper>
<PayoutCard
initial="К"
kind="executed"
occurredAt="01.08.2026 12:00"
payment={{ currency: "RUB", value: "-10.25" }}
title="Купон"
typeLabel="Купон"
/>
</Wrapper>,
);
expect(screen.getByText("−10,25 ₽")).toBeInTheDocument();
expect(screen.getByText(/Исполнено 01.08.2026 12:00/)).toBeInTheDocument();
});
it("keeps zero distinct from an unknown executed amount", () => {
render(
<Wrapper>
<PayoutCard
initial="Д"
kind="executed"
occurredAt="01.08.2026"
payment={{ currency: "RUB", value: "0.000" }}
title="Дивиденд"
typeLabel="Дивиденд"
/>
</Wrapper>,
);
expect(screen.getByText("0,000 ₽")).toBeInTheDocument();
expect(screen.getByText("Без движения денег")).toBeInTheDocument();
});
it("marks a forecast payout and a dividend registry date without inventing an amount", () => {
render(
<Wrapper>
<PayoutCard
date="15.08.2026"
dateKind="registry"
initial="S"
kind="forecast"
projectedAmount={null}
title="Дивиденд · SBER"
/>
</Wrapper>,
);
expect(screen.getByText("Прогноз")).toBeInTheDocument();
expect(screen.getByText(/15.08.2026 · Дата реестра/)).toBeInTheDocument();
expect(screen.getByText("Сумма неизвестна")).toBeInTheDocument();
});
it("shows a schedule payout with per-security label and separate estimate", () => {
render(
<Wrapper>
<PayoutCard
estimate={{ currency: "RUB", value: "125" }}
initial="S"
kind="schedule"
metadata="Дата выплаты 04.10.2026"
perSecurity={{ currency: "RUB", value: "12.5" }}
perSecurityLabel="На облигацию"
title="Купон · SU26238RMFS4"
/>
</Wrapper>,
);
expect(screen.getByText("12,5 ₽")).toBeInTheDocument();
expect(screen.getByText("На облигацию")).toBeInTheDocument();
expect(screen.getByText("Оценка по позиции")).toBeInTheDocument();
expect(screen.getByText("125 ₽")).toBeInTheDocument();
});
});
+166
View File
@@ -0,0 +1,166 @@
import type { FC, ReactNode } from "react";
import { Group, Text } from "@mantine/core";
import { EntryCard } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import { formatMoney } from "@/shared/lib";
type ExecutedPayout = {
details?: ReactNode;
extraMetadata?: string;
imageSrc?: null | string;
initial: string;
kind: "executed";
occurredAt: string;
payment: null | PayoutMoney;
title: string;
typeLabel: string;
wrap?: boolean;
};
type ForecastPayout = {
date: string;
dateKind?: "event" | "registry";
extraMetadata?: string;
imageSrc?: null | string;
initial: string;
kind: "forecast";
projectedAmount: null | PayoutMoney;
title: string;
};
type PayoutCardProps
= | ExecutedPayout
| ForecastPayout
| SchedulePayout;
type PayoutMoney = { currency: string; value: string };
type PayoutTone
= | "negative"
| "neutral"
| "positive";
type SchedulePayout = {
conditions?: ReactNode;
estimate: null | PayoutMoney;
imageSrc?: null | string;
initial: string;
kind: "schedule";
metadata: string;
perSecurity: null | PayoutMoney;
perSecurityLabel: string;
title: string;
};
const PayoutCard: FC<PayoutCardProps> = (props) => {
const { t } = useTranslation();
const common = {
imageSrc: props.imageSrc,
initial: props.initial,
title: props.title,
wrap: true,
};
if (props.kind === "executed") {
const payment = props.payment;
const presentation = executedPresentation(payment);
const isKnown = payment !== null && !isZeroDecimal(payment.value);
const metadata = [
t("payout.executedAt", { date: props.occurredAt }),
props.extraMetadata,
].filter(Boolean).join(" · ");
const valueLabel = isKnown ? props.typeLabel : t("payout.noMoney");
return (
<EntryCard
{...common}
details={props.details}
metadata={metadata}
tone={presentation.tone}
value={presentation.amount}
valueLabel={valueLabel}
wrap={props.wrap}
/>
);
}
if (props.kind === "forecast") {
const metadata = [
props.date,
props.dateKind === "registry" ? t("payout.registryDate") : null,
props.extraMetadata,
].filter(Boolean).join(" · ");
const value = props.projectedAmount
? formatMoney(props.projectedAmount)
: t("payout.noAmount");
return (
<EntryCard
{...common}
metadata={metadata}
tone="neutral"
value={value}
valueLabel={t("payout.forecast")}
/>
);
}
const details = (
<>
{props.estimate
? (
<Group gap="sm">
<Text c="dimmed" size="xs">{t("payout.estimate")}</Text>
<Text fw={600} size="xs">{formatMoney(props.estimate)}</Text>
</Group>
)
: null}
{props.conditions}
</>
);
const value = props.perSecurity
? formatMoney(props.perSecurity)
: t("payout.noAmount");
return (
<EntryCard
{...common}
details={details}
metadata={props.metadata}
tone="neutral"
value={value}
valueLabel={props.perSecurityLabel}
/>
);
};
function executedPresentation(payment: null | PayoutMoney): {
amount: string;
tone: PayoutTone;
} {
if (!payment) return { amount: "—", tone: "neutral" };
if (isZeroDecimal(payment.value))
return { amount: formatMoney(payment), tone: "neutral" };
if (payment.value.startsWith("-"))
return {
amount: `−${formatMoney({ ...payment, value: payment.value.slice(1) })}`,
tone: "negative",
};
return { amount: `+${formatMoney(payment)}`, tone: "positive" };
}
function isZeroDecimal(value: string): boolean {
// eslint-disable-next-line security/detect-unsafe-regex -- Anchored decimal check over a bounded Money string, with no nested repetition.
return /^-?0(?:\.0+)?$/.test(value);
}
export { PayoutCard };
@@ -32,6 +32,21 @@ describe("SourceSyncStatus", () => {
expect(screen.getByRole("status")).toHaveTextContent("Обновление не удалось"); expect(screen.getByRole("status")).toHaveTextContent("Обновление не удалось");
}); });
it("announces an active or failed source attempt with text", () => {
renderStatus({
lastAttempt: {
finishedAt: null,
problems: [],
startedAt: "2026-02-03T10:15:00.000Z",
state: "running",
},
});
expect(screen.getByRole("status")).toHaveTextContent(
"Обновление выполняется",
);
});
it("keeps the notices next to the timestamp they belong to", () => { it("keeps the notices next to the timestamp they belong to", () => {
renderStatus({ renderStatus({
errorText: "Обновление не удалось", errorText: "Обновление не удалось",
@@ -3,6 +3,8 @@ import type { FC } from "react";
import { Text } from "@mantine/core"; import { Text } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { InvestmentResourceMeta } from "@/shared/api";
import { formatSnapshotTime } from "@/shared/lib"; import { formatSnapshotTime } from "@/shared/lib";
// Owns the "when did the source last answer" line and the two notices that // Owns the "when did the source last answer" line and the two notices that
@@ -10,6 +12,7 @@ import { formatSnapshotTime } from "@/shared/lib";
// shows a muted line, a page header a progress bar — that is a product call. // shows a muted line, a page header a progress bar — that is a product call.
type SourceSyncStatusProps = { type SourceSyncStatusProps = {
errorText?: null | string; errorText?: null | string;
lastAttempt?: InvestmentResourceMeta["lastAttempt"];
lastSuccessfulAt?: null | string; lastSuccessfulAt?: null | string;
stale?: boolean; stale?: boolean;
}; };
@@ -17,6 +20,12 @@ type SourceSyncStatusProps = {
const SourceSyncStatus: FC<SourceSyncStatusProps> = (props) => { const SourceSyncStatus: FC<SourceSyncStatusProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const attempt = props.lastAttempt;
const attemptText = attempt && attempt.state !== "none"
? t(`investment.attempt.${attempt.state}`)
: null;
return ( return (
<> <>
{!!props.lastSuccessfulAt && ( {!!props.lastSuccessfulAt && (
@@ -29,6 +38,12 @@ const SourceSyncStatus: FC<SourceSyncStatusProps> = (props) => {
{!!props.stale && <Text size="xs">{t("investment.stale")}</Text>} {!!props.stale && <Text size="xs">{t("investment.stale")}</Text>}
{!!attemptText && (
<Text role="status" size="xs">
{attemptText}
</Text>
)}
{!!props.errorText && <Text role="status">{props.errorText}</Text>} {!!props.errorText && <Text role="status">{props.errorText}</Text>}
</> </>
); );
+1
View File
@@ -1 +1,2 @@
export { PayoutCard } from "./PayoutCard/PayoutCard";
export { SourceSyncStatus } from "./SourceSyncStatus/SourceSyncStatus"; export { SourceSyncStatus } from "./SourceSyncStatus/SourceSyncStatus";
+8 -2
View File
@@ -7,12 +7,18 @@ function resource<T>(data: T, meta: Partial<Meta> = {}) {
return { return {
data, data,
meta: { meta: {
availability: data === null ? "missing" : "complete", availability: data === null ? "initial-loading" : "available",
completeness: "complete",
dataRevision: "1", dataRevision: "1",
freshness: "fresh", freshness: "fresh",
lastAttempt: {
finishedAt: null,
problems: [],
startedAt: null,
state: "none",
},
lastSuccessfulAt: "2026-08-14T00:00:00Z", lastSuccessfulAt: "2026-08-14T00:00:00Z",
resourceKey: "fixture", resourceKey: "fixture",
sync: { errorCode: null, id: null, state: "idle" },
...meta, ...meta,
} satisfies Meta, } satisfies Meta,
}; };