18 Commits
Author SHA1 Message Date
ksv741 f2f81b6e08 test: cover null resource envelopes for account pages (#70) (#71)
Frontend verify / commits (pull_request) Successful in 41s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 6m20s
2026-10-13 22:43:35 +03:00
codex-bot df05c8c7db build: use native openapi-typescript --check (#60) (#77)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Successful in 7m33s
2026-10-13 21:32:33 +03:00
codex-bot e67c47b102 refactor: derive payment-system options from schema tuple (#58) (#75)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 12s
Frontend verify / verify (push) Successful in 7m56s
2026-10-13 21:05:07 +03:00
codex-bot 43b0f5574a feat: unify income payment cards and remove comparisons (WealthLedger/main#33) (#74)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 7m18s
feat: unify income payment cards and remove comparisons (WealthLedger/main#33)
2026-10-13 18:17:35 +03:00
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 сохраняет
публичный контракт предоставленного snapshot.
Одна UI-модель не требует менять API. `openapi:generate` работает offline от snapshot;
`openapi:check` генерирует во временный каталог и сравнивает, не меняя tracked files.
`openapi:check` запускает native `--check` того же CLI и падает при устаревших
generated types, не меняя tracked files.
## Auth и invalidation
+28 -14
View File
@@ -7,11 +7,19 @@
checkout. Внешний API задаётся предоставленным публичным контрактом;
исходники или инструкции его реализации не являются зависимостью разработки.
При нехватке требования сообщи, какое решение не определено, до его реализации.
При конфликте с общим шаблоном следуй местным правилам и явному запросу
пользователя. Выбирай реализацию самостоятельно, но изменение финансовой
логики, публичного контракта или утверждённой семантики возвращай владельцу
на согласование до зависимой работы.
Начни с результата и affected areas. До edits проследи flow/invariants, найди
owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт.
Работай напрямую; OpenSpec/SDD lifecycle — только по явному запросу.
Перед Node-командами активируй местную `.nvmrc`: `nvm use --silent`.
Выбери lightweight или SDD по [Engineering Workflow](https://git.personal.ksv741.ru/WealthLedger/main/src/commit/03b3b59e6ff2ba391f9dfc42a039a96d0d84c1c9/.ai/conventions/engineering-workflow.md);
OpenSpec lifecycle начинай после явного решения Change Owner.
Перед Node-командами активируй местную `.nvmrc`: если `nvm` не загружен в
non-interactive shell, загрузи установленный `$HOME/.nvm/nvm.sh`; затем
выполни `nvm use --silent` из frontend. Если `nvm.sh` недоступен, остановись,
не используй системный Node.
Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы.
## Change scope
@@ -57,21 +65,27 @@ Peer-конфликт решай совместимыми версиями бе
## Проверки и завершение
Code/dependency change: `npm ci`, полный `npm run verify` и дополнительные
risk checks из местной testing convention. Targeted tests ускоряют итерацию,
но не заменяют финальный gate. `lint:fix`/format — edits, не proof.
Markdown-only: проверь новые/изменённые тексты, локальные ссылки/якоря, маршруты
чтения и `git diff --check`; application build ради текста не нужен.
Повтори routing по staged/unstaged/new files. Запиши краткие команды, результаты
и ограничения. Зелёный lint не заменяет semantic/security review.
| Когда | Что проверить |
| --- | --- |
| Окружение новое, изменились manifest/lockfile или Node, установка сомнительна | `npm ci` до Node-проверок; одна правка кода переустановки не требует. |
| Итерация | Адресные тесты, typecheck и lint по затронутому поведению и риску. При изменении поведения добавь/обнови содержательную регрессию, если существующая проверка не ловит её; для косметики не создавай искусственный тест. |
| Итоговый code/dependency diff | Перед объявлением готовности и передачей к интеграции выполни один полный `npm run verify` на итоговых входах. Дополнительные risk checks из testing convention и явно утверждённые task checks обязательны; CI проверяет независимо. |
| Только Markdown | Проверь тексты, ссылки/якоря, маршруты чтения и `git diff --check`; application build не нужен. При изменении verification-контура проверь его работу. |
Повтори routing по staged/unstaged/new files. В отчёте по задаче/PR запиши
проверенные входы, команду, exit code, результат и ограничения. Повторяй
проверку при изменении значимых входов (включая config, dependencies и
generated artifacts), сомнении в evidence или явном запросе пользователя.
`lint:fix`/format — edits, не proof; lint не заменяет semantic/security review.
## Git и релизы
Работа — в `codex/<task>` от актуального `develop`, затем PR в `develop`.
Работа — в ветке `<type>/<issue-number>-<slug>` от актуального `develop`,
затем PR в `develop`; для cross-repo ссылки укажи owner/repo#number.
Сохраняй чужие изменения; перед commit просмотри staged paths и diff.
Commits и PR titles — Conventional Commits; допустим `spec:`. Каждый commit
содержит одну связанную задачу. При связанной issue добавь её номер в конец
заголовка (`fix: … #12`); без issue номер не нужен. Root другого checkout
не управляет этим repo.
Commits и PR titles — Conventional Commits с parser-recognized Issue reference (`fix: … (#12)`
или `chore: … (WealthLedger/main#27)`); допустим `spec:`. Каждый commit
содержит одну связанную задачу. Исключения release и правила ссылки — в
Engineering Workflow из AGENTS. Root другого checkout не управляет этим repo.
Прямой push в `main`, force-push и перенос опубликованных тегов не входят в
обычную работу. Перед release/hotfix прочитай местный README, раздел «Релизы».
+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:
branches: [develop, main]
pull_request:
types: [opened, synchronize, reopened, edited]
types: [opened, synchronize, reopened]
schedule:
- cron: '17 5 * * *'
+3
View File
@@ -4,6 +4,9 @@
только его файлы и предоставленные публичные контракты. Перед работой прочитай
[порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем
объединяй triggers задачи и путей; читай активированные темы до решения.
До edits найди/создай подходящую Gitea Issue, проверь AC и ветку; чтение
допускается без Issue. Общий маршрут — [Engineering Workflow](https://git.personal.ksv741.ru/WealthLedger/main/src/commit/03b3b59e6ff2ba391f9dfc42a039a96d0d84c1c9/.ai/conventions/engineering-workflow.md)
(main#28; после merge — актуальная версия `develop`).
| Решение / сигнал | Читать |
| --- | --- |
+6 -3
View File
@@ -203,7 +203,8 @@ Checkout frontend содержит все входы сборки и прове
локальную копию и не обращаются к исходникам или CI сервера. Доставка новой
схемы — явное обновление snapshot с review и `npm run openapi:generate`.
Runtime URL API задаётся окружением, а не расположением другого репозитория.
Разработка идёт напрямую по задаче пользователя; OpenSpec/SDD — по явному запросу.
Разработка начинается с Issue; lightweight или SDD выбирается по критериям
Engineering Workflow из AGENTS. OpenSpec — после явного решения Change Owner.
Knip исключает `conventional-changelog-conventionalcommits`: release-it загружает
preset по имени из `.release-it.json`. Версия preset закреплена напрямую.
@@ -211,9 +212,11 @@ preset по имени из `.release-it.json`. Версия preset закреп
## Релизы
Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые
релизы. Работа: `codex/<task>` от актуального `develop`, PR в `develop`.
релизы. Обычная работа: issue-linked ветка от актуального `develop`, PR в `develop`;
общие правила — в Engineering Workflow по ссылке из AGENTS.
Обычные PR допускают squash; release и обратная синхронизация сохраняют историю.
Commits и PR titles — Conventional Commits, включая `spec:`. Версии вручную не повышай.
Commits и PR titles — Conventional Commits со ссылкой на Issue (кроме release),
включая `spec:`. Версии вручную не повышай.
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что
`develop` содержит последний релиз. Создай `release/<name>` от проверенного
+6
View File
@@ -1,6 +1,12 @@
module.exports = {
defaultIgnores: false,
extends: ["@commitlint/config-conventional"],
ignores: [
message => /^chore\(release\): \d+\.\d+\.\d+$/.test(message.trim()),
message => /^Merge pull request ['"].+['"] \(#\d+\) from .+ into .+$/.test(message.trim()),
],
rules: {
"references-empty": [2, "never"],
"type-enum": [
2,
"always",
+1 -1
View File
@@ -20,7 +20,7 @@
"test:coverage": "npm run build:packages && vitest run --coverage",
"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: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",
"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",
-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",
],
[
"scripts/check-openapi.mjs",
"scripts/test-pre-commit.mjs",
"import { exec } from \"node:child_process\"; exec(process.argv[2]);",
"security/detect-child-process",
],
@@ -95,10 +95,6 @@ const analytics: IncomeAnalytics
},
],
period: { from: "2026-08-01", to: "2026-08-31" },
previous: {
period: { from: "2026-07-01", to: "2026-07-31" },
totalsByCurrency: [],
},
totalsByCurrency: [
{
coupon: { currency: "RUB", value: "2" },
@@ -107,7 +103,6 @@ const analytics: IncomeAnalytics
total: { currency: "RUB", value: "2" },
},
],
yearComparison: null,
};
const CurrentLocation: FC = () => {
+3 -1
View File
@@ -6,6 +6,7 @@ import { calendarDate } from "@/shared/lib";
import { bankToday } from "./form-validation";
import { moneyCents } from "./money";
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 MAX_BALANCE_KOPECKS = 9_223_372_036_854_775_807n;
@@ -63,7 +64,7 @@ const bankCardSchema = z.object({
openingBalance: moneySchema,
openingDate: bankDateSchema,
paymentSystem: z
.enum(["", "mir", "visa", "mastercard"], {
.enum(["", ...paymentSystemValues], {
error: validationMessage("bank.validation.paymentSystem"),
})
.optional(),
@@ -75,5 +76,6 @@ export {
bankCardSchema,
type BankCardValues,
bankDateSchema,
paymentSystemValues,
positiveMoneySchema,
};
@@ -1,8 +1,7 @@
import { useController, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { BankCardValues } from "../../model/form.schema";
import { type BankCardValues, paymentSystemValues } from "../../model/form.schema";
import { bankDateField } from "../bankDateField";
function useBankCardFieldsVm() {
@@ -16,7 +15,7 @@ function useBankCardFieldsVm() {
const { field: openingDateField } = useController({ control, name: "openingDate" });
const { field: paymentSystemField } = useController({ control, name: "paymentSystem" });
const paymentSystems = ["mir", "visa", "mastercard"].map(value => ({
const paymentSystems = paymentSystemValues.map(value => ({
label: t(`bank.paymentSystems.${value}`),
value,
}));
@@ -15,6 +15,28 @@
min-width: 0;
}
.totals {
display: flex;
flex-wrap: wrap;
gap: var(--mantine-spacing-xs) var(--mantine-spacing-lg);
margin: 0;
}
.totals > div {
display: grid;
gap: 0;
}
.totals dt {
color: var(--mantine-color-dimmed);
font-size: var(--mantine-font-size-xs);
}
.totals dd {
margin: 0;
font-variant-numeric: tabular-nums;
}
.categories {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -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>
{props.data && (
<Text className={classes.total}>
{formatMoney(props.data.net, { withZeroCents: true })}
</Text>
<dl className={classes.totals}>
<div>
<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>
@@ -36,6 +36,12 @@ function entryLabelKey(operation: BrokerageOperation): string {
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 {
return value === expected;
}
@@ -109,4 +115,4 @@ function paymentPresentation(
};
}
export { operationLabelKey, paymentPresentation };
export { isIncomePayment, operationLabelKey, paymentPresentation };
@@ -11,10 +11,11 @@ import {
formatSnapshotTime,
instrumentLogoUrl,
} from "@/shared/lib";
import { PayoutCard } from "@/shared/ui";
import type { BrokerageOperation } from "../api/useBrokerageOperations";
import { operationLabelKey, paymentPresentation } from "../model/display";
import { isIncomePayment, operationLabelKey, paymentPresentation } from "../model/display";
type LedgerEntryRowProps = {
operation: BrokerageOperation;
@@ -75,6 +76,28 @@ const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
}
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 (
<EntryCard
details={details}
@@ -4,17 +4,22 @@ const brokerageAccountsQueryOptions = () =>
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts");
const brokerageAccountsQueryKey = brokerageAccountsQueryOptions().queryKey;
const BROKERAGE_ACCOUNTS_STALE_TIME_MS = 1_800_000;
type BrokerageAccount = NonNullable<
paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"]["data"]
>[number];
function useBrokerageAccounts() {
return useResourceQuery({
const query = useResourceQuery({
meta: { accessGroup: "brokerage" },
...brokerageAccountsQueryOptions(),
staleTime: 60_000,
staleTime: BROKERAGE_ACCOUNTS_STALE_TIME_MS,
});
return {
...query,
};
}
export {
@@ -205,24 +205,32 @@ export const IndependentFreshness: Story
],
[
{
availability: "complete",
availability: "available",
completeness: "complete",
dataRevision: "1",
freshness: "stale",
lastSuccessfulAt: "2026-09-24T10:30:00Z",
resourceKey: "portfolio/storybook-account",
sync: {
errorCode: "TBANK_UNAVAILABLE",
id: "failed",
lastAttempt: {
finishedAt: "2026-09-24T10:31:00Z",
problems: [],
startedAt: "2026-09-24T10:30:00Z",
state: "failed",
},
lastSuccessfulAt: "2026-09-24T10:30:00Z",
resourceKey: "portfolio/storybook-account",
},
{
availability: "missing",
availability: "initial-loading",
completeness: "incomplete",
dataRevision: "0",
freshness: "unknown",
lastAttempt: {
finishedAt: null,
problems: [],
startedAt: "2026-09-24T10:30:00Z",
state: "running",
},
lastSuccessfulAt: null,
resourceKey: "portfolio/waiting",
sync: { errorCode: null, id: "waiting", state: "running" },
},
],
),
@@ -64,15 +64,16 @@ const BrokerageAccountSourceStatus: FC<BrokerageAccountSourceStatusProps> = (
const { source } = props;
const { t } = useTranslation();
const isSyncing = source?.sync.state === "running";
const isSyncing = source?.lastAttempt.state === "running";
const errorText
= source?.sync.state === "failed" ? t("accounts.refreshError") : null;
= source?.lastAttempt.state === "failed" ? t("accounts.refreshError") : null;
return (
<>
<SourceSyncStatus
errorText={errorText}
lastAttempt={source?.lastAttempt}
lastSuccessfulAt={source?.lastSuccessfulAt}
stale={source?.freshness === "stale"}
/>
@@ -4,6 +4,7 @@ import { EntryCard } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import { formatDate, formatMoney, instrumentLogoUrl } from "@/shared/lib";
import { PayoutCard } from "@/shared/ui";
import type { BrokerageEvent } from "../api/useBrokerageEvents";
@@ -15,6 +16,27 @@ const BrokerageEventCard: FC<BrokerageEventCardProps> = (props) => {
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 = [
date,
event.dateKind === "registry"
@@ -40,7 +62,7 @@ const BrokerageEventCard: FC<BrokerageEventCardProps> = (props) => {
title={[t(`events.type.${event.type}`), event.displayName].join(" · ")}
tone="neutral"
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"]
>;
type IncomeAnalyticsMeta
= paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["meta"];
type IncomeSummary = NonNullable<
paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"]["data"]
>;
@@ -47,7 +44,6 @@ function useIncomeSummary(accountId?: string) {
export {
type IncomeAnalytics,
type IncomeAnalyticsMeta,
type IncomeSummary,
useIncomeAnalytics,
useIncomeSummary,
-1
View File
@@ -1,6 +1,5 @@
export {
type IncomeAnalytics,
type IncomeAnalyticsMeta,
type IncomeSummary,
useIncomeAnalytics,
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 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 StockPeriod = NonNullable<
@@ -18,6 +14,8 @@ type StockPeriod = NonNullable<
>["period"]
>;
type StockPosition = NonNullable<paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
const STOCK_STALE_TIME_MS = 30_000;
function useStock(identifier: string) {
@@ -96,18 +94,7 @@ function useStockQuote(uid: string) {
});
}
function useStockSearch(query: string) {
return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks", {
params: { query: { query } },
}),
enabled: query.length >= MIN_STOCK_SEARCH_LENGTH,
staleTime: STOCK_STALE_TIME_MS,
});
}
export {
MIN_STOCK_SEARCH_LENGTH,
type StockCandles,
type StockDividends,
type StockPosition,
@@ -117,5 +104,4 @@ export {
useStockDividends,
useStockPosition,
useStockQuote,
useStockSearch,
};
-2
View File
@@ -1,5 +1,4 @@
export {
MIN_STOCK_SEARCH_LENGTH,
type StockCandles,
type StockDividends,
type StockPosition,
@@ -9,5 +8,4 @@ export {
useStockDividends,
useStockPosition,
useStockQuote,
useStockSearch,
} from "./api/queries";
@@ -5,20 +5,23 @@ import { resource } from "@/test/resource";
import { section } from "./section";
describe("instrument source and transport presentation", () => {
it.each(["missing", "partial", "complete"] as const)(
it.each([
["initial-loading", "incomplete"],
["available", "complete"],
] as const)(
"keeps %s separate from successful transport",
(availability) => {
const data = availability === "missing" ? undefined : { rows: [] };
(availability, completeness) => {
const data = availability === "initial-loading" ? undefined : { rows: [] };
const state = section({
data,
isError: false,
meta: resource(data, { availability }).meta,
meta: resource(data, { availability, completeness }).meta,
});
expect(state.loading).toBe(availability === "missing");
expect(state.partial).toBe(availability === "partial");
expect(state.hasData).toBe(availability !== "missing");
expect(state.loading).toBe(availability === "initial-loading");
expect(state.partial).toBe(completeness === "incomplete");
expect(state.hasData).toBe(availability !== "initial-loading");
},
);
it("preserves empty successful data instead of treating it as missing", () => {
@@ -37,7 +40,12 @@ describe("instrument source and transport presentation", () => {
it("preserves a full source when force fails", () => {
const meta = resource([], {
sync: { errorCode: "TBANK_UNAVAILABLE", id: "work", state: "failed" },
lastAttempt: {
finishedAt: "2026-08-14T00:01:00Z",
problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed",
},
}).meta;
const state = section({ data: [], isError: false, meta });
@@ -53,7 +61,12 @@ describe("instrument source and transport presentation", () => {
data: undefined,
isError: false,
meta: resource(null, {
sync: { errorCode: "TBANK_UNAVAILABLE", id: "work", state: "failed" },
lastAttempt: {
finishedAt: "2026-08-14T00:01:00Z",
problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed",
},
}).meta,
});
@@ -16,14 +16,14 @@ function section(query: {
isError: boolean;
meta?: InvestmentResourceMeta;
}): SectionState {
const isError = query.isError || query.meta?.sync.state === "failed";
const isError = query.isError || query.meta?.lastAttempt.state === "failed";
const hasData = hasSectionData(query.data);
return {
error: isError,
hasData,
loading: !hasData && !isError,
partial: query.meta?.availability === "partial",
partial: query.meta?.completeness === "incomplete",
};
}
@@ -13,7 +13,6 @@ function isInvestmentPath(path: string) {
"/api/v1/brokerage-accounts",
"/api/v1/stocks",
"/api/v1/bonds",
"/api/v1/tbank-sync",
].some(root => path === root || path.startsWith(`${root}/`));
}
@@ -3,14 +3,12 @@ import { http, HttpResponse } from "msw";
import { describe, expect, it, vi } from "vitest";
import { useBrokerageAccounts } from "@/entities/brokerage-account";
import { useStockQuote } from "@/entities/stock";
import { configureAuthClient, invalidateResourceAccess } from "@/shared/api";
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { useScreenSync } from "../index";
import { commitTbankConnectionBoundary } from "./connection-boundary";
function auth() {
configureAuthClient({
@@ -20,364 +18,23 @@ function auth() {
});
}
describe("page-owned screen synchronization", () => {
it("keeps the current scope when an older sync acceptance arrives last", async () => {
describe("read-only screen synchronization", () => {
it("retries the resource with GET without requesting source observation", async () => {
auth();
const { promise: blocked, resolve: release }
= Promise.withResolvers<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;
let gets = 0;
const syncModes: (null | string)[] = [];
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
gets += 1;
syncModes.push(new URL(request.url).searchParams.get("sync"));
return HttpResponse.json(resource([], meta()));
}),
http.post("*/api/v1/tbank-sync", async ({ request }) => {
bodies.push(await request.json());
isCompleted = true;
return HttpResponse.json(
{
id: "screen",
now: "2026-09-25T00:00:00Z",
scope: { screen: "accounts" },
},
{ status: 202 },
);
}),
http.get("*/api/v1/tbank-sync/:id", () => {
polls++;
return HttpResponse.json(
{
errorCode: null,
id: "screen",
now: "2026-09-25T00:00:00Z",
scope: { screen: "accounts" },
state: "succeeded",
},
);
return gets === 1
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(resource([]));
}),
);
const { result, unmount } = renderHook(
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
@@ -389,382 +46,36 @@ describe("page-owned screen synchronization", () => {
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() => expect(gets).toBe(1));
await act(async () => result.current.sync.retry());
await waitFor(() => expect(result.current.accounts.data).toEqual([]));
await act(async () => {
await Promise.all([
result.current.sync.refresh(),
result.current.sync.refresh(),
]);
});
await waitFor(() =>
expect(result.current.accounts.meta?.dataRevision).toBe("2"),
);
expect(bodies).toEqual([{ screen: "accounts" }]);
expect(modes).toEqual([null, "observe"]);
vi.useFakeTimers();
try {
await act(() => vi.advanceTimersByTimeAsync(120_000));
expect(polls).toBe(1);
expect(modes).toEqual([null, "observe"]);
unmount();
await vi.advanceTimersByTimeAsync(120_000);
expect(polls).toBe(1);
}
finally {
vi.useRealTimers();
}
});
it.each(["leave", "logout"])(
"publishes completed empty coverage while history runs, then stops on %s",
async (boundary) => {
vi.useFakeTimers();
try {
auth();
let isCompleted = false;
let polls = 0;
const modes: (null | string)[] = [];
const snapshot = () =>
resource(isCompleted ? [] : null, {
dataRevision: isCompleted ? "covered" : "initial",
sync: { errorCode: null, id: "history", state: "running" },
unavailableReason: isCompleted
? undefined
: "CALENDAR_OR_ACCOUNT_HISTORY",
});
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return HttpResponse.json(snapshot());
}),
http.get("*/api/v1/tbank-sync/:id", () => {
polls++;
return HttpResponse.json(
{
errorCode: null,
id: "history",
now: "2026-09-25T00:00:00Z",
scope: null,
state: "running",
},
);
}),
);
const { result, unmount } = renderHook(
() => {
const accounts = useBrokerageAccounts();
return {
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts]),
};
},
{ wrapper: createQueryWrapper().wrapper },
);
await vi.waitFor(() => expect(modes).toEqual([null, "observe"]));
expect(polls).toBe(1);
expect(result.current.sync.refreshing).toBe(true);
expect(result.current.accounts.data).toBeUndefined();
isCompleted = true;
await act(() => vi.advanceTimersByTimeAsync(5000));
await vi.waitFor(() =>
expect(result.current.accounts.data).toEqual([]),
);
expect(result.current.sync.refreshing).toBe(true);
expect(modes).toEqual([null, "observe", "observe"]);
if (boundary === "leave") unmount();
else act(() => configureAuthClient());
await act(() => vi.advanceTimersByTimeAsync(60_000));
expect(polls).toBe(2);
expect(modes).toHaveLength(3);
}
finally {
vi.useRealTimers();
}
},
);
it("runs the next status poll and observe read only after five seconds", async () => {
auth();
const modes: (null | string)[] = [];
const pollGaps: number[] = [];
let polls = 0;
let previousPollAt = 0;
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return HttpResponse.json(
resource([], {
sync: { errorCode: null, id: "history", state: "running" },
}),
);
}),
http.get("*/api/v1/tbank-sync/:id", () => {
const now = Date.now();
if (previousPollAt) pollGaps.push(now - previousPollAt);
previousPollAt = now;
polls++;
return HttpResponse.json({
errorCode: null,
id: "history",
now: "2026-09-25T00:00:00Z",
scope: null,
state: "running",
});
}),
);
vi.useFakeTimers();
try {
const { unmount } = renderHook(
() => {
const accounts = useBrokerageAccounts();
return {
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts]),
};
},
{ wrapper: createQueryWrapper().wrapper },
);
await vi.waitFor(() => expect(modes).toEqual([null, "observe"]));
expect(polls).toBe(1);
await act(() => vi.advanceTimersByTimeAsync(2500));
expect(polls).toBe(1);
expect(modes).toEqual([null, "observe"]);
await act(() => vi.advanceTimersByTimeAsync(5000));
await vi.waitFor(() => expect(polls).toBe(2));
expect(modes).toEqual([null, "observe", "observe"]);
expect(pollGaps).toHaveLength(1);
expect(pollGaps[0]).toBeGreaterThanOrEqual(5000);
unmount();
await act(() => vi.advanceTimersByTimeAsync(60_000));
expect(polls).toBe(2);
expect(modes).toHaveLength(3);
}
finally {
vi.useRealTimers();
}
});
it("observes resources immediately when a status response turns terminal", async () => {
auth();
const modes: (null | string)[] = [];
let isCompleted = false;
let polls = 0;
const meta = () =>
resource([], {
dataRevision: isCompleted ? "covered" : "initial",
sync: { errorCode: null, id: "history", state: "running" },
});
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return HttpResponse.json(meta());
}),
http.get("*/api/v1/tbank-sync/:id", () => {
polls++;
return HttpResponse.json({
errorCode: null,
id: "history",
now: "2026-09-25T00:00:00Z",
scope: null,
state: isCompleted ? "succeeded" : "running",
});
}),
);
vi.useFakeTimers();
try {
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
return {
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts]),
};
},
{ wrapper: createQueryWrapper().wrapper },
);
await vi.waitFor(() => expect(modes).toEqual([null, "observe"]));
expect(polls).toBe(1);
expect(result.current.sync.refreshing).toBe(true);
isCompleted = true;
await act(() => vi.advanceTimersByTimeAsync(5000));
await vi.waitFor(() => expect(modes).toEqual([null, "observe", "observe"]));
expect(polls).toBe(2);
expect(result.current.accounts.meta?.dataRevision).toBe("covered");
expect(result.current.sync.refreshing).toBe(false);
await act(() => vi.advanceTimersByTimeAsync(60_000));
expect(polls).toBe(2);
expect(modes).toHaveLength(3);
}
finally {
vi.useRealTimers();
}
expect(gets).toBe(2);
expect(syncModes).toEqual([null, null]);
});
it("clears private snapshots when status confirms access loss", async () => {
it("refreshes the currently open resources together", async () => {
auth();
let gets = 0;
server.use(
http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json(
resource(
http.get("*/api/v1/brokerage-accounts", () => {
gets += 1;
[],
{
sync: { errorCode: null, id: "revoked", state: "running" },
},
),
),
),
http.get("*/api/v1/tbank-sync/:id", () =>
HttpResponse.json({ code: "INVALID_TBANK_TOKEN" }, { status: 400 }),
),
return HttpResponse.json(resource([]));
}),
);
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
return {
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts]),
};
return useScreenSync({ screen: "accounts" }, [accounts]);
},
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() => expect(result.current.accounts.isError).toBe(true));
expect(result.current.accounts.data).toBeUndefined();
});
it("observes a projection when only its dependency reports new coverage", async () => {
auth();
let isObserved = false;
const modes: (null | string)[] = [];
const dependency = resource(
[],
{
dataRevision: isObserved ? "covered" : "initial",
resourceKey: "operations",
sync: {
errorCode: null,
id: "dependency",
state: isObserved ? "idle" : "running",
},
},
).meta;
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
const mode = new URL(request.url).searchParams.get("sync");
modes.push(mode);
if (mode) isObserved = true;
return HttpResponse.json(
resource(
isObserved
? []
: null,
{
dependencies: [
{
...dependency,
dataRevision: isObserved ? "covered" : "initial",
},
],
resourceKey: "projection",
},
),
);
}),
http.get("*/api/v1/tbank-sync/:id", () =>
HttpResponse.json(
{
errorCode: null,
id: "dependency",
now: "2026-09-25T00:00:00Z",
scope: null,
state: "succeeded",
},
),
),
);
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
useScreenSync({ screen: "accounts" }, [accounts]);
return accounts;
},
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() => expect(result.current.data).toEqual([]));
expect(modes).toEqual([null, "observe"]);
});
it("preserves a fresh snapshot and timestamp after a failed force", async () => {
auth();
const saved = resource([], { lastSuccessfulAt: "2026-09-25T01:00:00Z" });
server.use(
http.get("*/api/v1/brokerage-accounts", () => HttpResponse.json(saved)),
http.post("*/api/v1/tbank-sync", () =>
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
),
);
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
return {
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts]),
};
},
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() => expect(result.current.accounts.data).toEqual([]));
await act(() => result.current.sync.refresh());
await waitFor(() => expect(result.current.sync.transportError).toBe(true));
expect(result.current.accounts.meta).toEqual(saved.meta);
await waitFor(() => expect(gets).toBe(1));
await act(async () => result.current.refresh());
await waitFor(() => expect(gets).toBe(2));
});
});
@@ -1,269 +1,52 @@
import {
focusManager,
onlineManager,
type QueryKey,
useQueryClient,
} from "@tanstack/react-query";
import type { QueryKey } from "@tanstack/react-query";
import { useEffect, useRef } from "react";
import {
ApiErrorCode,
apiErrorCode,
apiQueryClient,
invalidateResourceAccess,
type InvestmentResourceMeta,
isApiErrorCode,
type paths,
useAuthenticatedQueries,
} from "@/shared/api";
type InvestmentScreen
= paths["/api/v1/tbank-sync"]["post"]["requestBody"]["content"]["application/json"];
import type { InvestmentResourceMeta } from "@/shared/api";
type ScreenResource = {
isError: boolean;
isFetching: boolean;
meta?: InvestmentResourceMeta;
observe: () => Promise<unknown>;
queryKey: QueryKey;
refetch: () => Promise<unknown>;
};
const STATUS_INTERVAL_MS = 5000;
const FIRST_SOURCE_RECOVERY_DELAY_MS = 5000;
const SECOND_SOURCE_RECOVERY_DELAY_MS = 15_000;
type ScreenScope = {
[key: string]: unknown;
screen: string;
};
function requiresSourceRecovery(meta?: InvestmentResourceMeta): boolean {
if (!meta || meta.unavailableReason) return false;
const READ_INTERVAL_MS = 1_800_000;
return ["cancelled", "failed"].includes(meta.sync.state)
|| meta.availability === "missing"
|| meta.freshness === "stale"
|| (meta.dependencies ?? []).some(dependency =>
requiresSourceRecovery(dependency),
);
}
function sourceRecoveryDelay(attempt: number): number | undefined {
switch (attempt) {
case 0: {
return FIRST_SOURCE_RECOVERY_DELAY_MS;
}
case 1: {
return SECOND_SOURCE_RECOVERY_DELAY_MS;
}
default: {
return undefined;
}
}
}
function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
const mutation = apiQueryClient.useMutation("post", "/api/v1/tbank-sync", {
retry: false,
});
const client = useQueryClient();
const deniedRef = useRef(new Set<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,
})),
);
function useScreenSync(_scope: ScreenScope, resources: ScreenResource[]) {
const resourcesRef = useRef(resources);
useEffect(() => {
resourcesRef.current = resources;
}, [resources]);
useEffect(() => {
for (const status of statuses) {
if (!status.error || deniedRef.current.has(status.error)) {
continue;
}
deniedRef.current.add(status.error);
if (!isApiErrorCode(apiErrorCode(status.error), ApiErrorCode.NotFound))
invalidateResourceAccess(client, "brokerage", status.error);
}
if (statuses.every(status => !status.data)) return;
const observation = JSON.stringify([
scopeKey,
statuses.map(status => [
status.data?.id,
status.data?.state,
status.dataUpdatedAt,
]),
resources.map(resource => resource.queryKey),
]);
if (observedRef.current === observation) return;
observedRef.current = observation;
const isCompleted = statuses.every(
status => status.data && status.data.state !== "running",
);
for (const resource of resources)
if (isCompleted || !resource.isFetching)
void resource.observe().catch(() => {});
}, [client, statuses, resources, scopeKey]);
useEffect(() => {
const timers = sourceTimersRef.current;
const attempts = sourceRecoveryRef.current;
return () => {
for (const timer of timers) clearTimeout(timer);
timers.clear();
attempts.clear();
};
}, [scopeKey]);
useEffect(() => {
for (const resource of resources) {
if (
!requiresSourceRecovery(resource.meta)
|| resource.isFetching
) continue;
const key = JSON.stringify([scopeKey, resource.queryKey]);
const attempt = sourceRecoveryRef.current.get(key) ?? 0;
const delay = sourceRecoveryDelay(attempt);
if (delay === undefined) continue;
sourceRecoveryRef.current.set(key, attempt + 1);
const timer = setTimeout(() => {
sourceTimersRef.current.delete(timer);
const timer = setInterval(() => {
for (const resource of resourcesRef.current)
void resource.refetch().catch(() => {});
}, delay);
}, READ_INTERVAL_MS);
sourceTimersRef.current.add(timer);
}
}, [resources, scopeKey]);
return () => clearInterval(timer);
}, []);
useEffect(() => {
const recover = () => {
for (const resource of resources) {
if (
!requiresSourceRecovery(resource.meta)
|| resource.isFetching
) continue;
const key = JSON.stringify([scopeKey, resource.queryKey]);
if (lifecycleRecoveryRef.current.has(key)) continue;
sourceRecoveryRef.current.delete(key);
lifecycleRecoveryRef.current.add(key);
void resource.refetch()
.catch(() => {})
.finally(() => lifecycleRecoveryRef.current.delete(key));
}
};
const unsubscribeFocus = focusManager.subscribe((focused) => {
if (focused) recover();
});
const unsubscribeOnline = onlineManager.subscribe((online) => {
if (online) recover();
});
return () => {
unsubscribeFocus();
unsubscribeOnline();
};
}, [resources, scopeKey]);
const isRefreshing
= (isCurrentMutation && mutation.isPending)
|| statuses.some(
status => !status.isError && status.data?.state === "running",
)
|| resources.some(({ meta }) => {
const status = statuses[ids.indexOf(meta?.sync.id ?? "")];
return (
!status?.isError
&& (!status?.data || status.data.state === "running")
&& meta?.sync.state === "running"
);
});
const isRefreshing = resources.some(resource => resource.isFetching);
const refresh = async () => {
if (isRefreshing || requestInFlightRef.current === scopeKey) return;
requestInFlightRef.current = scopeKey;
try {
await mutation.mutateAsync({ body: scope });
}
catch {
/*
The mutation retains the transport error for presentation.
*/
}
finally {
if (requestInFlightRef.current === scopeKey)
requestInFlightRef.current
= null;
}
if (isRefreshing) return;
await Promise.allSettled(resources.map(resource => resource.refetch()));
};
// Retry failed HTTP reads without asking the source to import again.
// A source failure (or explicit refresh with no read error) still needs a sync.
// Retry only the HTTP read; source attempts are reported in resource metadata.
const retry = async () => {
const failed = resources.filter(
resource => resource.isError,
);
const failed = resources.filter(resource => resource.isError);
const failedStatuses = statuses.filter(status => status.isError);
if (failed.length > 0 || failedStatuses.length > 0) {
await Promise.allSettled([
...failed.map(resource => resource.observe()),
...failedStatuses.map(status => status.refetch()),
]);
}
if (failed.length > 0)
await Promise.allSettled(failed.map(resource => resource.refetch()));
else await refresh();
};
@@ -271,13 +54,11 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
refresh,
refreshing: isRefreshing,
retry,
sourceError:
statuses.some(status => status.data?.state === "failed")
|| resources.some(({ meta }) => meta?.sync.state === "failed"),
transportError:
(isCurrentMutation && mutation.isError)
|| statuses.some(status => status.isError),
sourceError: resources.some(
({ meta }) => meta?.lastAttempt.state === "failed",
),
transportError: resources.some(resource => resource.isError),
};
}
export { type InvestmentScreen, useScreenSync };
export { useScreenSync };
@@ -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(
null,
{
sync: {
errorCode: "TBANK_UNAVAILABLE",
id: null,
lastAttempt: {
finishedAt: "2026-08-14T00:01:00Z",
problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed",
},
},
@@ -309,7 +310,14 @@ it("retains the previous complete forecast when its source refresh fails", async
const snapshot = resource(
{ events, incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }] },
{ sync: { errorCode: "TBANK_UNAVAILABLE", id: "failed", state: "failed" } },
{
lastAttempt: {
finishedAt: "2026-08-14T00:01:00Z",
problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed",
},
},
);
server.use(
@@ -51,8 +51,9 @@ describe("AccountEventsPage", () => {
isError: false,
isLoading: false,
meta: {
availability: "complete",
sync: { errorCode: null, id: null, state: "idle" },
availability: "available",
completeness: "complete",
lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" },
},
refetch: vi.fn(),
} as never,
@@ -85,8 +86,9 @@ describe("AccountEventsPage", () => {
isError: false,
isLoading: false,
meta: {
availability: "complete",
sync: { errorCode: null, id: null, state: "idle" },
availability: "available",
completeness: "complete",
lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" },
},
refetch: vi.fn(),
} as never,
@@ -138,8 +140,9 @@ describe("AccountEventsPage", () => {
isError: false,
isLoading: false,
meta: {
availability: "complete",
sync: { errorCode: null, id: null, state: "idle" },
availability: "available",
completeness: "complete",
lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" },
},
refetch: vi.fn(),
} as never,
@@ -153,7 +156,7 @@ describe("AccountEventsPage", () => {
expect(screen.getAllByText(/RU000A0JX0J2$/)).toHaveLength(1);
expect(screen.getByText("25.08.2026")).toBeVisible();
expect(screen.queryByText(/Дата события/)).not.toBeInTheDocument();
expect(screen.getAllByText("Ожидаемая сумма")).toHaveLength(2);
expect(screen.getAllByText("Прогноз")).toHaveLength(2);
const logo = screen.queryByAltText("");
expect(logo).toHaveAttribute(
"src",
@@ -194,8 +197,9 @@ describe("AccountEventsPage", () => {
isError: false,
isLoading: true,
meta: {
availability: "complete",
sync: { errorCode: null, id: null, state: "idle" },
availability: "available",
completeness: "complete",
lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" },
},
refetch: vi.fn(),
} as never,
@@ -1,6 +1,8 @@
import { useState } from "react";
import { useParams } from "react-router";
import type { InvestmentResourceMeta } from "@/shared/api";
import {
type BrokerageEvent,
useBrokerageEvents,
@@ -25,6 +27,7 @@ type AccountEventsPageViewModel
onPeriodPopoverOpen: () => void;
onReset: () => void;
partial?: boolean;
source?: InvestmentResourceMeta;
state: "loaded";
}
| { onReset: () => void; state: "error" }
@@ -74,7 +77,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
const availability = useInitialAvailability({
hasContent: !!events.data,
identity: scope,
isError: events.isError || events.meta?.sync.state === "failed",
isError: events.isError || events.meta?.lastAttempt.state === "failed",
isFetching: events.isFetching,
});
@@ -86,7 +89,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
setSearchParameters({});
};
if ((events.isError || events.meta?.sync.state === "failed") && !events.data)
if ((events.isError || events.meta?.lastAttempt.state === "failed") && !events.data)
return {
onReset,
state: "error",
@@ -115,7 +118,8 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
),
onPeriodPopoverOpen: () => setMaxDate(moscowDate(new Date())),
onReset,
partial: events.meta?.availability !== "complete",
partial: events.meta?.completeness === "incomplete",
source: events.meta,
state: "loaded",
};
}
@@ -15,6 +15,7 @@ import {
BrokerageEventCard,
brokerageEventKey,
} from "@/entities/brokerage-event";
import { SourceSyncStatus } from "@/shared/ui";
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
@@ -55,6 +56,12 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
<Stack gap="lg">
<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>}
<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 { resource } from "@/test/resource";
import { toIncomeDisplayModel } from "./display";
const analytics: IncomeAnalytics = {
accountId: "account-1",
bucketGranularity: "day",
buckets: [],
calculatedAt: "2026-08-27T00:00:00Z",
entries: [],
instruments: [],
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,
};
describe("income payments display", () => {
it("keeps repeated IDs, order and logo revisions without reconstructing financial data", () => {
const payment = {
id: "same",
instrumentKey: "bond:RUB",
logoName: null,
} as IncomeAnalytics["entries"][number];
function comparison(currency: string) {
return {
compared: summary(currency, "1", "0"),
currency,
difference: summary(currency, "9", "2"),
selected: summary(currency, "10", "2"),
};
}
const analytics = {
entries: [payment, payment, { ...payment, logoName: "bond.png" }],
} as IncomeAnalytics;
function money(currency: string, value: string) {
return { currency, value };
}
const display = toIncomeDisplayModel(analytics);
function summary(currency: string, coupon: string, dividend: string) {
return {
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.entries.map(({ operation }) => operation)).toEqual(
analytics.entries,
);
expect(display?.comparison?.comparedPeriod.replaceAll(/\s/g, " ")).toBe(
"01.12.2024 – 09.01.2025",
);
expect(display?.comparison?.values.map(value => value.currency)).toEqual([
"RUB",
"USD",
]);
expect(new Set(display.entries.map(({ key }) => key)).size).toBe(3);
});
});
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 { formatDate } from "@/shared/lib";
import type { IncomePeriod } from "./period";
type ComparisonNotice
= | "comparisonFailed"
| "comparisonPending"
| "previousUnavailable"
| "yearUnavailable";
import type { IncomeAnalytics } from "@/entities/income";
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] }[];
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(
analytics: IncomeAnalytics,
meta?: IncomeAnalyticsMeta,
): IncomeDisplayModel {
const entries: IncomeDisplayModel["entries"] = [];
const occurrences = new Map<string, number>();
@@ -116,17 +25,7 @@ function toIncomeDisplayModel(
});
}
const dependencies = meta?.dependencies ?? [];
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"),
};
return { entries };
}
export { type IncomeDisplayModel, toIncomeDisplayModel };
@@ -22,123 +22,6 @@
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) {
.fields {
grid-template-columns: minmax(0, 1fr);
@@ -72,23 +72,9 @@ const analytics
},
],
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")],
yearComparison: null,
} 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] {
return {
accruedInterest: null,
@@ -205,9 +191,10 @@ describe("AccountIncomePage", () => {
resource(
null,
{
sync: {
errorCode: "TBANK_UNAVAILABLE",
id: null,
lastAttempt: {
finishedAt: "2026-08-14T00:01:00Z",
problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed",
},
},
@@ -371,19 +358,11 @@ describe("AccountIncomePage", () => {
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 }));
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(
resource({
...analytics,
yearComparison: {
period: { from: "2025-08-01", to: "2025-08-27" },
totalsByCurrency: [comparison("RUB")],
},
}),
),
HttpResponse.json(resource(analytics)),
),
http.get("*/api/v1/brokerage-accounts/account-1/events", eventsRequest),
);
@@ -398,72 +377,9 @@ describe("AccountIncomePage", () => {
expect(screen.queryByText("Динамика")).not.toBeInTheDocument();
expect(eventsRequest).not.toHaveBeenCalled();
expect(screen.getByRole("region", { name: "Выплаты" })).toBeInTheDocument();
const year = screen.getByRole("region", { name: "Годом ранее" });
const selected = within(year).getByRole("region", {
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();
expect(screen.queryByText("Предыдущий период")).not.toBeInTheDocument();
expect(screen.queryByText("Годом ранее")).not.toBeInTheDocument();
expect(screen.queryByText(/Сравнение с/)).not.toBeInTheDocument();
});
it("shows a quiet realized error", async () => {
@@ -747,119 +663,3 @@ describe("income period and snapshot boundary", () => {
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({
hasContent: !!analytics.data,
identity: `${accountId}:${period.from}:${period.to}`,
isError: analytics.isError || analytics.meta?.sync.state === "failed",
isError: analytics.isError || analytics.meta?.lastAttempt.state === "failed",
isFetching: analytics.isFetching,
});
@@ -91,7 +91,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
? { period, state: "error" }
: { state: "loading" };
const display = toIncomeDisplayModel(analytics.data, analytics.meta);
const display = toIncomeDisplayModel(analytics.data);
const onSubmit = (values: IncomeForm) => {
const from = parseDraftDate(values.from);
@@ -114,7 +114,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
formRevision: `${accountId}:${period.from}:${period.to}:${resetRevision}`,
onReset,
onSubmit,
partial: analytics.meta?.availability !== "complete",
partial: analytics.meta?.completeness === "incomplete",
state: "loaded",
};
};
@@ -12,7 +12,6 @@ import { useTranslation } from "react-i18next";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
import classes from "../AccountIncomePage.module.css";
import { IncomeComparisons } from "./IncomeComparisons.view";
import { IncomePayments } from "./IncomePayments.view";
import { IncomePeriodForm } from "./IncomePeriodForm/IncomePeriodForm.view";
import { IncomeSummary } from "./IncomeSummary.view";
@@ -54,18 +53,6 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
<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} />
</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 { Button, Paper, Stack, Text } from "@mantine/core";
import { Button, Stack } from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import classes from "../../AccountIncomePage.module.css";
@@ -27,15 +27,8 @@ const IncomePeriodForm: FC<IncomePeriodFormProps> = (props) => {
} = useIncomePeriodFormVm();
return (
<Paper
component="form"
onSubmit={props.onSubmit}
p="md"
radius="md"
withBorder
>
<form onSubmit={props.onSubmit}>
<Stack gap="sm">
<Text fw={700}>{t("income.period")}</Text>
<div className={classes.fields}>
<DatePickerInput
@@ -71,7 +64,7 @@ const IncomePeriodForm: FC<IncomePeriodFormProps> = (props) => {
</Button>
</div>
</Stack>
</Paper>
</form>
);
};
@@ -492,12 +492,25 @@ describe("AccountOperationsPage", () => {
}),
).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 () => {
configureTestAuth();
const requested: Record<string, string>[] = [];
const syncRequests: unknown[] = [];
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/operations",
@@ -508,9 +521,10 @@ it("keeps UTC operation boundaries in GET and source sync requests", async () =>
resource(
null,
{
sync: {
errorCode: "TBANK_UNAVAILABLE",
id: null,
lastAttempt: {
finishedAt: "2026-08-14T00:01:00Z",
problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed",
},
},
@@ -518,11 +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");
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",
},
]);
expect(syncRequests).toEqual([]);
});
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: [],
nextCursor: null,
},
{ availability: "partial" },
{ completeness: "incomplete" },
),
),
),
@@ -2,6 +2,8 @@ import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useParams } from "react-router";
import type { InvestmentResourceMeta } from "@/shared/api";
import {
type BrokerageOperation,
brokerageOperationsQueryOptions,
@@ -39,6 +41,7 @@ type AccountOperationsPageViewModel
onPrevious: () => void;
onReset: () => void;
partial?: boolean;
source?: InvestmentResourceMeta;
state: "loaded";
}
| { onReset: () => void; state: "error" }
@@ -98,7 +101,7 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
const availability = useInitialAvailability({
hasContent: !!operations.data,
identity: scope,
isError: operations.isError || operations.meta?.sync.state === "failed",
isError: operations.isError || operations.meta?.lastAttempt.state === "failed",
isFetching: operations.isFetching,
});
@@ -127,7 +130,7 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
if (
(isInvalidCursor || !operations.data)
&& (operations.isError || operations.meta?.sync.state === "failed")
&& (operations.isError || operations.meta?.lastAttempt.state === "failed")
)
return { onReset, state: "error" };
@@ -177,7 +180,8 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
onPeriodPopoverOpen: () => setMaxDate(moscowDate(new Date())),
onPrevious: history.onPrevious,
onReset,
partial: operations.meta?.availability !== "complete",
partial: operations.meta?.completeness === "incomplete",
source: operations.meta,
state: "loaded",
};
};
@@ -13,6 +13,7 @@ import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import { LedgerEntryRow, operationCategories } from "@/entities/broker-operation";
import { SourceSyncStatus } from "@/shared/ui";
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
@@ -62,6 +63,12 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
<Stack gap="lg">
<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.historyLoading && <Alert>{t("investment.history")}</Alert>}
+12 -11
View File
@@ -46,9 +46,10 @@ type PreviewResource<T> = {
isError: boolean;
meta?: Pick<InvestmentResourceMeta,
"availability"
| "completeness"
| "freshness"
| "lastAttempt"
| "lastSuccessfulAt"
| "sync"
>;
};
@@ -57,10 +58,10 @@ function toIncomeSummary(
income: PreviewResource<IncomeSummary>,
): AccountPageIncomeSummary {
const to = `/accounts/${accountId}/income`;
if (!income.data && !income.isError && income.meta?.sync.state !== "failed")
if (!income.data && !income.isError && income.meta?.lastAttempt.state !== "failed")
return { state: "loading", to };
if (income.meta?.availability === "partial") return { state: "partial", to };
if (!income.data && (income.isError || income.meta?.sync.state === "failed"))
if (income.meta?.completeness === "incomplete") return { state: "partial", to };
if (!income.data && (income.isError || income.meta?.lastAttempt.state === "failed"))
return { state: "error", to };
if (!income.data?.totalsByCurrency.length) return { state: "empty", to };
@@ -76,15 +77,15 @@ function toRecentEvents(
events: PreviewResource<{ events: BrokerageEvent[] }>,
): AccountPageRecentEvents {
const to = `/accounts/${accountId}/events`;
if (!events.data && !events.isError && events.meta?.sync.state !== "failed")
if (!events.data && !events.isError && events.meta?.lastAttempt.state !== "failed")
return { state: "loading", to };
if (events.meta?.availability === "partial")
if (events.meta?.completeness === "incomplete")
return {
items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [],
state: "partial",
to,
};
if (!events.data && (events.isError || events.meta?.sync.state === "failed"))
if (!events.data && (events.isError || events.meta?.lastAttempt.state === "failed"))
return { state: "error", to };
const items = events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [];
if (items.length === 0) return { state: "empty", to };
@@ -104,14 +105,14 @@ function toRecentOperations(
if (
!operations.data
&& !operations.isError
&& operations.meta?.sync.state !== "failed"
&& operations.meta?.lastAttempt.state !== "failed"
)
return { state: "loading", to };
if (operations.meta?.availability === "partial")
if (operations.meta?.completeness === "incomplete")
return { items: operations.data?.items ?? [], state: "partial", to };
if (
!operations.data
&& (operations.isError || operations.meta?.sync.state === "failed")
&& (operations.isError || operations.meta?.lastAttempt.state === "failed")
)
return { state: "error", to };
if (!operations.data?.items.length) return { state: "empty", to };
@@ -120,7 +121,7 @@ function toRecentOperations(
items: operations.data.items,
lastSuccessfulAt: operations.meta?.lastSuccessfulAt,
refreshError:
operations.isError || operations.meta?.sync.state === "failed",
operations.isError || operations.meta?.lastAttempt.state === "failed",
stale: operations.meta?.freshness === "stale",
state: "loaded",
to,
@@ -402,6 +402,24 @@ describe("AccountPage states", () => {
).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", () => {
renderPage(<AccountPageLoaded {...loadedProps()} />);
@@ -686,7 +704,7 @@ describe("AccountPage states", () => {
).toBeVisible();
for (const amount of ["850,50 ₽", "0 USD", "1 000 ₽", "Сумма неизвестна"])
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(
@@ -770,28 +788,6 @@ describe("AccountPage states", () => {
let requestCount = 0;
let resolveRetry: ((response: Response) => void) | undefined;
server.use(
http.post("*/api/v1/tbank-sync", () =>
HttpResponse.json(
{
id: "overview",
now: "2026-09-25T00:00:00Z",
scope: { accountId: "account-1", screen: "account" },
},
{ status: 202 },
),
),
http.get("*/api/v1/tbank-sync/:id", () =>
HttpResponse.json(
{
errorCode: null,
id: "overview",
now: "2026-09-25T00:00:00Z",
scope: { accountId: "account-1", screen: "account" },
state: "succeeded",
},
),
),
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
requestCount += 1;
if (requestCount === 1)
@@ -173,14 +173,14 @@ it("keeps overview sections independent when their source data is incomplete", a
mockLoadedAccountPage();
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(resource(portfolio, { availability: "partial" })),
HttpResponse.json(resource(portfolio, { completeness: "incomplete" })),
),
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
HttpResponse.json(
resource(
{ hasNext: false, items: [], nextCursor: null },
{ availability: "partial" },
{ completeness: "incomplete" },
),
),
),
@@ -188,7 +188,7 @@ it("keeps overview sections independent when their source data is incomplete", a
HttpResponse.json(
resource(
{ events: [], incomeTotalsByCurrency: [] },
{ availability: "partial" },
{ completeness: "incomplete" },
),
),
),
@@ -1,6 +1,7 @@
import { useParams } from "react-router";
import type { Portfolio } from "@/entities/portfolio";
import type { InvestmentResourceMeta } from "@/shared/api";
import { useBrokerageOperations } from "@/entities/broker-operation";
import {
@@ -45,6 +46,7 @@ type AccountPageEmptyViewModel = AccountPageContent & {
type AccountPageLoadedViewModel = AccountPageContent & {
portfolio: Portfolio;
positionSections: AccountPagePositionSection[];
source?: InvestmentResourceMeta;
state: "loaded";
};
@@ -87,7 +89,7 @@ function useAccountPageViewModel(): AccountPageViewModel {
const availability = useInitialAvailability({
hasContent: !!portfolio.data,
identity: accountId ?? "account",
isError: portfolio.isError || portfolio.meta?.sync.state === "failed",
isError: portfolio.isError || portfolio.meta?.lastAttempt.state === "failed",
isFetching: portfolio.isFetching,
});
@@ -105,7 +107,7 @@ function useAccountPageViewModel(): AccountPageViewModel {
const incomeSummary = toIncomeSummary(accountId, income);
if (
portfolio.meta?.availability === "complete"
portfolio.meta?.availability === "available"
&& portfolio.data.positions.length === 0
&& portfolio.data.cashBalances.length === 0
)
@@ -118,7 +120,7 @@ function useAccountPageViewModel(): AccountPageViewModel {
state: "empty" as const,
};
const isPartial = portfolio.meta?.availability === "partial";
const isPartial = portfolio.meta?.completeness === "incomplete";
return {
account,
@@ -129,6 +131,7 @@ function useAccountPageViewModel(): AccountPageViewModel {
positionSections: toAccountPagePositionSections(portfolio.data),
recentEvents,
recentOperations,
source: portfolio.meta,
state: "loaded" as const,
};
}
@@ -5,8 +5,10 @@ import { useTranslation } from "react-i18next";
import type { BrokerageAccount } from "@/entities/brokerage-account";
import type { Portfolio } from "@/entities/portfolio";
import type { InvestmentResourceMeta } from "@/shared/api";
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
import { SourceSyncStatus } from "@/shared/ui";
import { PortfolioStructure } from "@/widgets/portfolio-structure";
import type { AccountPagePositionSection } from "../../../model/positions";
@@ -32,6 +34,7 @@ type AccountPageLoadedProps = {
positionSections: AccountPagePositionSection[];
recentEvents: AccountPageRecentEvents;
recentOperations: AccountPageRecentOperations;
source?: InvestmentResourceMeta;
};
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
@@ -49,6 +52,12 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
<Stack gap="lg">
<AccountHeading account={props.account} accountId={props.accountId} />
<SourceSyncStatus
lastAttempt={props.source?.lastAttempt}
lastSuccessfulAt={props.source?.lastSuccessfulAt}
stale={props.source?.freshness === "stale"}
/>
{props.partial
? <Alert role="status">{t("investment.partial")}</Alert>
: <CurrencyCards amounts={props.portfolio.totalsByCurrency} />}
@@ -130,17 +130,6 @@ describe("AccountsPage", () => {
});
it("keeps the cached account list quietly after a refresh error", async () => {
const requests: unknown[] = [];
server.use(
http.post("*/api/v1/tbank-sync", async ({ request }) => {
requests.push(await request.json());
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
}),
);
let requestCount = 0;
server.use(
http.get("*/api/v1/brokerage-accounts", () => {
@@ -169,7 +158,6 @@ describe("AccountsPage", () => {
expect(screen.queryByRole("button", { name: "Повторить" }))
.not.toBeInTheDocument();
expect(screen.getByText(account.name)).toBeInTheDocument();
expect(requests).toEqual([]);
});
it("keeps a cached empty account list visible after a refresh error", async () => {
@@ -201,14 +189,19 @@ describe("AccountsPage", () => {
});
});
it.each(["idle", "failed"] as const)(
"keeps partial empty membership distinct from no accounts (sync=%s)",
it.each(["none", "failed"] as const)(
"keeps incomplete empty membership distinct from no accounts (attempt=%s)",
async (state) => {
server.use(
http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json(resource([], {
availability: "partial",
sync: { errorCode: null, id: null, state },
completeness: "incomplete",
lastAttempt: {
finishedAt: state === "failed" ? "2026-08-14T00:01:00Z" : null,
problems: [],
startedAt: state === "none" ? null : "2026-08-14T00:00:00Z",
state,
},
})),
),
);
@@ -217,7 +210,7 @@ it.each(["idle", "failed"] as const)(
await waitFor(() =>
expect(client.getQueryData(brokerageAccountsQueryKey)).toMatchObject({
data: [],
meta: { availability: "partial" },
meta: { completeness: "incomplete" },
}),
);
expect(screen.queryByText("Нет инвестиционных счетов"))
@@ -15,6 +15,7 @@ const AccountsPage: FC = () => {
return (
<AccountsPageLoaded
accounts={viewModel.accounts}
meta={viewModel.meta}
/>
);
};
@@ -8,7 +8,7 @@ import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
import { useInitialAvailability } from "@/shared/lib";
type AccountsPageViewModel
= | { accounts: BrokerageAccountCard[]; state: "loaded" }
= | { accounts: BrokerageAccountCard[]; meta: NonNullable<ReturnType<typeof useBrokerageAccounts>["meta"]>; state: "loaded" }
| { code?: string; state: "error" }
| { state: "empty" }
| { state: "loading" };
@@ -26,12 +26,13 @@ function useAccountsPageViewModel(): AccountsPageViewModel {
const isAccessFailure = isAccountsAccessFailure(code);
const hasResult = !!accounts.data
&& !(accounts.meta?.availability === "partial" && accounts.data.length === 0);
&& !!accounts.meta
&& !(accounts.meta.completeness === "incomplete" && accounts.data.length === 0);
const availability = useInitialAvailability({
hasContent: hasResult,
identity: "accounts",
isError: accounts.isError || accounts.meta?.sync.state === "failed",
isError: accounts.isError || accounts.meta?.lastAttempt.state === "failed",
isFetching: accounts.isFetching,
});
@@ -47,8 +48,12 @@ function useAccountsPageViewModel(): AccountsPageViewModel {
state: "empty" as const,
};
const meta = accounts.meta;
if (!meta) return { state: "loading" as const };
return {
accounts: brokerageAccountCards(accounts.data, accounts.meta?.dependencies),
accounts: brokerageAccountCards(accounts.data, meta.dependencies),
meta,
state: "loaded" as const,
};
}
@@ -4,13 +4,17 @@ import { Button, Stack, Text, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type { InvestmentResourceMeta } from "@/shared/api";
import {
type BrokerageAccountCard,
BrokerageAccountList,
} from "@/entities/brokerage-account";
import { SourceSyncStatus } from "@/shared/ui";
type AccountsPageLoadedProps = {
accounts: BrokerageAccountCard[];
meta: InvestmentResourceMeta;
};
const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
@@ -24,6 +28,12 @@ const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
<Text c="dimmed" size="sm">
{t("accounts.subtitle")}
</Text>
<SourceSyncStatus
lastAttempt={props.meta.lastAttempt}
lastSuccessfulAt={props.meta.lastSuccessfulAt}
stale={props.meta.freshness === "stale"}
/>
</Stack>
<Button
@@ -191,8 +191,10 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
<BondCoupons
coupons={vm.coupons}
logoName={bond.logoName}
pastCoupons={vm.pastCoupons}
state={vm.couponsState}
ticker={bond.ticker}
upcoming={vm.upcoming}
/>
@@ -1,17 +1,20 @@
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 type { BondCoupons as Coupons } from "@/entities/bond";
import { InstrumentMetric, InstrumentSection, type SectionState } from "@/features/instrument-details";
import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib";
import { InstrumentSection, type SectionState } from "@/features/instrument-details";
import { formatDate, formatSnapshotTime, instrumentLogoUrl } from "@/shared/lib";
import { PayoutCard } from "@/shared/ui";
type BondCouponsProps = {
coupons?: Coupons;
logoName?: null | string;
pastCoupons: Coupons["coupons"];
state: SectionState;
ticker?: null | string;
upcoming: Coupons["coupons"];
};
@@ -39,6 +42,8 @@ const BondCoupons: FC<BondCouponsProps> = (props) => {
<Coupon
coupon={coupon}
key={`${coupon.number}:${coupon.paymentDate}`}
logoName={props.logoName}
ticker={props.ticker}
/>
))
)
@@ -75,6 +80,8 @@ const BondCoupons: FC<BondCouponsProps> = (props) => {
<Coupon
coupon={coupon}
key={`${coupon.number}:${coupon.paymentDate}`}
logoName={props.logoName}
ticker={props.ticker}
/>
))
)
@@ -91,59 +98,48 @@ const BondCoupons: FC<BondCouponsProps> = (props) => {
type CouponProps = {
coupon: Coupons["coupons"][number];
logoName?: null | string;
ticker?: null | string;
};
const Coupon: FC<CouponProps> = (props) => {
const { coupon } = props;
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 (
<Paper p="md" radius="md" withBorder>
<Group align="start" justify="space-between">
<div>
<Text fw={600}>
{coupon.paymentDate
? formatDate(coupon.paymentDate)
: t("stock.dateUnknown")}
</Text>
<Text c="dimmed" size="xs">
{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>
<PayoutCard
conditions={conditions}
estimate={coupon.estimatedAmount}
imageSrc={instrumentLogoUrl(props.logoName)}
initial={(props.ticker?.trim()[0] ?? "?").toUpperCase()}
kind="schedule"
metadata={metadata}
perSecurity={coupon.perBond}
perSecurityLabel={t("bond.perBond")}
title={title}
/>
);
};
+3 -3
View File
@@ -17,7 +17,7 @@ function useBondsPageVm() {
code: apiErrorCode(result.error),
error:
!result.data
&& (result.isError || result.meta?.sync.state === "failed"),
&& (result.isError || result.meta?.lastAttempt.state === "failed"),
items:
result.data?.map(bond => ({
...bond,
@@ -29,9 +29,9 @@ function useBondsPageVm() {
&& (isWaiting
|| (!result.data
&& !result.isError
&& result.meta?.sync.state !== "failed")),
&& result.meta?.lastAttempt.state !== "failed")),
onQueryChange: search.onQueryChange,
partial: result.meta?.availability === "partial",
partial: result.meta?.completeness === "incomplete",
query,
ready:
debounced.length >= MIN_BOND_SEARCH_LENGTH
@@ -337,7 +337,7 @@ describe("stock page", () => {
"/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 () => {
const { requests } = setup({ ambiguousTicker: true, path: "/stocks/SAME" });
@@ -368,18 +368,18 @@ describe("stock page", () => {
screen.getByText("К закрытию предыдущего торгового дня, 09.09.2026"),
).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("P/E · TTM")).toBeVisible();
expect(screen.getByText("Описание компании")).toBeVisible();
expect(
screen.queryByRole("button", { name: "О компании" }),
).not.toBeInTheDocument();
expect(screen.getByText("13.10.2026")).toBeVisible();
expect(screen.getByText(/13\.10\.2026/)).toBeVisible();
fireEvent.click(
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();
fireEvent.click(screen.getByRole("button", { name: /Цены в таблице/ }));
const rows = within(await screen.findByRole("table")).getAllByRole("row");
@@ -63,8 +63,10 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
<StockDividends
dividends={vm.dividends}
logoName={stock.logoName}
pastDividends={vm.pastDividends}
state={vm.dividendsState}
ticker={stock.ticker}
upcoming={vm.upcoming}
/>
@@ -1,198 +1,96 @@
import type { FC } from "react";
import {
Accordion,
Alert,
Badge,
Group,
Paper,
Stack,
Text,
} from "@mantine/core";
import { Accordion, Alert, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import type { StockDividends as Dividends } from "@/entities/stock";
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 = {
dividends?: Dividends;
logoName?: null | string;
pastDividends: Dividends["dividends"];
state: SectionState;
ticker?: null | string;
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 { dividends, pastDividends, state, upcoming } = props;
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 (
<InstrumentSection state={state} title={t("stock.upcoming")}>
{!!dividends && (
<Stack gap="md">
{!!(
dividends.estimateUnavailable || dividends.positionIsStale
) && (
{(dividends.estimateUnavailable || dividends.positionIsStale) && (
<Alert aria-live="polite" color="orange" role="status">
{t("stock.estimateUnavailable")}
</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">
{t("stock.dividendNote")}
</Text>
<Text c="dimmed" size="xs">{t("stock.dividendNote")}</Text>
{!!dividends.positionAt && (
<Text c="dimmed" size="xs">
{t("stock.snapshotAt", {
date: formatSnapshotTime(dividends.positionAt),
})}
{t("stock.snapshotAt", { date: formatSnapshotTime(dividends.positionAt) })}
</Text>
)}
@@ -210,7 +108,24 @@ const StockDividends: FC<StockDividendsProps> = (props) => {
})}
</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.Item>
</Accordion>
@@ -1,16 +1,28 @@
import { MantineProvider } from "@mantine/core";
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 { MemoryRouter } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
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());
@@ -18,27 +30,28 @@ describe("stock search", () => {
it("waits for a meaningful query and uses the server-verified ticker link", async () => {
const query = vi.fn();
server.use(
http.get("*/api/v1/stocks", ({ request }) => {
query(new URL(request.url).searchParams.get("query"));
http.post("*/api/v1/stocks/screener/search", async ({ request }) => {
query(await request.json());
return HttpResponse.json(
resource([
{
apiTradeAvailable: true,
classCode: "TQBR",
instrumentUid: "unique-stock",
isin: "RU0009029540",
logoName: "sber.png",
name: "Сбербанк",
price: { currency: "RUB", value: "306.12" },
priceAt: "2026-09-10T10:00:00Z",
realExchange: "REAL_EXCHANGE_MOEX",
shareType: "SHARE_TYPE_COMMON",
slug: "SBER",
ticker: "SBER",
},
]),
);
return HttpResponse.json({
asOf: "2026-10-10T10:00:00Z",
hasNext: false,
items: [{
countryOfRisk: "RU",
currency: "RUB",
instrumentUid: "e6123145-9665-43e0-8413-cd61b8aa9b13",
isin: "RU0009029540",
logoName: "sber.png",
lot: 10,
name: "Сбербанк",
realExchange: "REAL_EXCHANGE_MOEX",
sector: "financial",
shareType: "SHARE_TYPE_COMMON",
ticker: "SBER",
}],
page: 1,
searchId: "search-id",
});
}),
);
configureAuthClient({
@@ -46,7 +59,6 @@ describe("stock search", () => {
clear: vi.fn(),
refresh: () => Promise.resolve(false),
});
vi.useFakeTimers();
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider>
@@ -56,23 +68,212 @@ describe("stock search", () => {
</MantineProvider>
</QueryClientProvider>,
);
const input = screen.getByLabelText("Поиск акций");
fireEvent.change(input, { target: { value: "S" } });
await act(() => vi.advanceTimersByTimeAsync(300));
expect(query).not.toHaveBeenCalled();
fireEvent.change(input, { target: { value: "SB" } });
await act(() => vi.advanceTimersByTimeAsync(299));
expect(query).not.toHaveBeenCalled();
// The final input uses real timers for network and query notifications.
vi.useRealTimers();
const input = screen.getByLabelText("Название или тикер");
fireEvent.change(input, { target: { value: "SBER" } });
expect(query).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Найти" }));
expect(
await screen.findByRole("link", { name: /Сбербанк/ }),
).toHaveAttribute("href", "/stocks/SBER");
expect(screen.getByText("306,12 ₽")).toBeVisible();
expect(screen.getByText("Режим: TQBR")).toBeVisible();
).toHaveAttribute("href", "/stocks/e6123145-9665-43e0-8413-cd61b8aa9b13");
expect(screen.getByText("Список получен 10.10.2026, 10:00")).toBeVisible();
expect(screen.getByText("Лот: 10 шт.")).toBeVisible();
expect(screen.getByText("ISIN: RU0009029540")).toBeVisible();
expect(screen.getByText("Мосбиржа")).toBeVisible();
await waitFor(() => expect(query).toHaveBeenCalledExactlyOnceWith("SBER"));
expect(screen.getAllByText("Мосбиржа")[0]).toBeVisible();
expect(screen.getByAltText("")).toHaveAttribute(
"src",
"https://invest-brands.cdn-tinkoff.ru/sberx160.png",
);
await waitFor(() => expect(query).toHaveBeenCalledExactlyOnceWith({ query: "SBER" }));
});
it("searches by the selected sector without a text query", async () => {
const query = vi.fn();
server.use(
http.post("*/api/v1/stocks/screener/search", async ({ request }) => {
query(await request.json());
return HttpResponse.json({
asOf: "2026-10-10T10:00:00Z",
hasNext: false,
items: [],
page: 1,
searchId: "sector-search",
});
}),
);
configureAuthClient({
accessToken: () => "synthetic",
clear: vi.fn(),
refresh: () => Promise.resolve(false),
});
render(
<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 { Badge, Group, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import type { ChangeEvent, FC } from "react";
import {
InstrumentSearch,
InstrumentSearchCard,
} from "@/features/instrument-search";
import { formatMoney, formatSnapshotTime } from "@/shared/lib";
Alert,
Anchor,
Avatar,
Badge,
Button,
Card,
Fieldset,
Group,
NumberInput,
Pagination,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type { StocksPageVm } from "./StocksPage.vm";
import { formatSnapshotTime, instrumentLogoUrl } from "@/shared/lib";
import type { Criteria, StocksPageVm } from "./StocksPage.vm";
import { useStocksPagePresentationVm } from "./StocksPagePresentation.vm";
type CriteriaField = {
label: keyof Criteria;
options?: readonly string[];
type?: "number";
};
type StockCardProps = { item: StocksPageVm["items"][number] };
type StockCriteriaFieldProps = {
criteria: Criteria;
field: CriteriaField;
onChange: (name: keyof Criteria, value: string) => void;
};
type StocksPageViewProps = { vm: StocksPageVm };
const criteriaFields: CriteriaField[] = [
{ label: "query" },
{
label: "sector",
options: [
"consumer",
"energy",
"financial",
"health_care",
"industrials",
"it",
"materials",
"real_estate",
"telecom",
"utilities",
],
},
{
label: "shareType",
options: [
"SHARE_TYPE_ADR",
"SHARE_TYPE_COMMON",
"SHARE_TYPE_GDR",
"SHARE_TYPE_PREFERRED",
],
},
{ label: "currency", options: ["RUB", "USD", "EUR", "CNY", "HKD", "TRY"] },
{
label: "realExchange",
options: [
"REAL_EXCHANGE_MOEX",
"REAL_EXCHANGE_RTS",
"REAL_EXCHANGE_OTC",
"REAL_EXCHANGE_DEALER",
],
},
{ label: "countryOfRisk", options: ["RU", "US", "CN", "KZ", "HK", "TR"] },
{ label: "maxLot", type: "number" },
];
function numberChange(
field: keyof Criteria,
onChange: StockCriteriaFieldProps["onChange"],
) {
return (value: number | string) => onChange(field, String(value));
}
function selectChange(
field: keyof Criteria,
onChange: StockCriteriaFieldProps["onChange"],
) {
return (value: null | string) => onChange(field, value ?? "");
}
function selectOptions(field: CriteriaField, translate: ReturnType<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 { vm } = props;
const { t } = useTranslation();
const { getControlProps, getItemProps } = useStocksPagePresentationVm();
return (
<InstrumentSearch kind="stock" vm={vm}>
{vm.items.map((stock) => {
const quote = (
<>
<Text fw={650} size="xl">
{stock.price ? formatMoney(stock.price) : "—"}
</Text>
<Stack gap="lg">
<div>
<Title order={1}>{t("stock.screenerTitle")}</Title>
<Text c="dimmed" size="xs">
{stock.price && stock.priceAt
? t("stock.priceAt", {
date: formatSnapshotTime(stock.priceAt),
})
: t("stock.priceUnavailable")}
</Text>
<Text c="dimmed" mt="xs">{t("stock.screenerSubtitle")}</Text>
</div>
{stock.apiTradeAvailable === true && (
<Text c="green.8" mt="sm" size="xs">
{t("stock.apiAvailable")}
</Text>
)}
</>
);
<Fieldset disabled={vm.loading} variant="unstyled">
<SimpleGrid cols={{ base: 1, sm: 2 }}>
{criteriaFields.map(field => (
<StockCriteriaField
criteria={vm.criteria}
field={field}
key={field.label}
onChange={vm.onCriteriaChange}
/>
))}
</SimpleGrid>
return (
<InstrumentSearchCard
instrument={stock}
key={stock.instrumentUid}
quote={quote}
>
<Text c="dimmed" size="sm">
{stock.ticker} ·{" "}
{t(`stock.shareTypes.${stock.shareType}`, {
defaultValue: t("stock.share"),
})}
</Text>
<Group mt="lg">
<Button loading={vm.loading} onClick={vm.onSearch}>
{t("stock.screener.search")}
</Button>
</Group>
</Fieldset>
<Group gap="xs" mt="sm">
<Badge color="gray" variant="light">
{t(`stock.exchanges.${stock.realExchange}`, {
defaultValue: t("stock.exchangeUnknown"),
})}
</Badge>
{!!vm.error && (
<Alert color="orange" role="status">{t("stock.screener.error")}</Alert>
)}
{!!stock.classCode && (
<Text c="dimmed" size="xs">
{t("stock.classCode", { code: stock.classCode })}
</Text>
)}
</Group>
{vm.resultTime && (
<Text c="dimmed" size="sm">
{t("stock.screener.asOf", {
date: formatSnapshotTime(vm.resultTime),
})}
</Text>
)}
{!!stock.isin && (
<Text c="dimmed" mt={4} size="xs">
{t("stock.isin", { value: stock.isin })}
</Text>
)}
{vm.searched && !vm.loading && !vm.error && vm.items.length === 0 && (
<Text c="dimmed">{t("stock.screener.empty")}</Text>
)}
{stock.apiTradeAvailable === false && (
<Text c="dimmed" mt={4} size="xs">
{t("stock.apiUnavailable")}
</Text>
)}
</InstrumentSearchCard>
);
})}
</InstrumentSearch>
<Stack gap="sm">
{vm.items.map(item => (
<StockCard item={item} key={item.instrumentUid} />
))}
</Stack>
{vm.totalPages > 1 && (
<Group aria-label={t("stock.screener.pagination")} component="nav" justify="center">
<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 { useInstrumentSearch } from "@/features/instrument-search";
import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api";
import { instrumentLogoUrl } from "@/shared/lib";
import { useRef, useState } from "react";
import { apiClient } from "@/shared/api";
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>;
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() {
const search = useInstrumentSearch(MIN_STOCK_SEARCH_LENGTH);
const { canSearch, debounced, isWaiting, query } = search;
const result = useStockSearch(debounced);
useScreenSync({ query: debounced, screen: "stocks" }, [result]);
const [criteria, setCriteria] = useState(emptyCriteria);
const [page, setPage] = useState<null | ScreenerPage>(null);
const [pending, setPending] = useState(false);
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 {
canSearch,
code: apiErrorCode(result.error),
error:
!result.data
&& (result.isError || result.meta?.sync.state === "failed"),
items:
result.data?.map(stock => ({
...stock,
logoSrc: instrumentLogoUrl(stock.logoName),
to: `/stocks/${encodeURIComponent(stock.slug)}`,
})) ?? [],
loading:
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),
criteria,
error,
items: page?.items ?? [],
loading: pending,
onCriteriaChange: (name: keyof Criteria, value: string) =>
setCriteria(current => ({ ...current, [name]: value })),
onPage: (nextPage: number) => void changePage(nextPage),
onSearch: () => void search(),
page: page?.page,
resultTime: page?.asOf,
searched: page !== null,
totalPages: page ? page.page + Number(page.hasNext) : 0,
};
}
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;
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}": {
parameters: {
query?: never;
@@ -676,22 +708,6 @@ export interface paths {
patch?: 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}": {
parameters: {
query?: never;
@@ -725,7 +741,10 @@ export interface components {
* }
* ]
*/
errors?: string[];
errors?: {
pointer: string;
detail: string;
}[];
};
CredentialsDto: {
/** @example user@example.com */
@@ -912,24 +931,35 @@ export interface components {
/** @enum {string} */
historyState: "missing" | "loading" | "partial" | "complete" | "failed";
};
InvestmentSyncDto: {
id: string | null;
InvestmentAttemptProblemDto: {
/** @enum {string} */
state: "idle" | "running" | "failed";
section: "membership" | "portfolio" | "operations" | "events";
accountId: 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: {
dataRevision: string;
unavailableReason?: string;
coverage?: components["schemas"]["InvestmentCoverageDto"];
resourceKey: string;
/** @enum {string} */
availability: "missing" | "partial" | "complete";
availability: "available" | "initial-loading" | "temporarily-unavailable" | "connection-unavailable";
/** @enum {string} */
completeness: "complete" | "incomplete";
/** @enum {string} */
freshness: "unknown" | "fresh" | "stale";
/** Format: date-time */
lastSuccessfulAt: string | null;
sync: components["schemas"]["InvestmentSyncDto"];
lastAttempt: components["schemas"]["InvestmentAttemptDto"];
};
InvestmentResourceMetaDto: {
dataRevision: string;
@@ -937,17 +967,33 @@ export interface components {
coverage?: components["schemas"]["InvestmentCoverageDto"];
resourceKey: string;
/** @enum {string} */
availability: "missing" | "partial" | "complete";
availability: "available" | "initial-loading" | "temporarily-unavailable" | "connection-unavailable";
/** @enum {string} */
completeness: "complete" | "incomplete";
/** @enum {string} */
freshness: "unknown" | "fresh" | "stale";
/** Format: date-time */
lastSuccessfulAt: string | null;
sync: components["schemas"]["InvestmentSyncDto"];
lastAttempt: components["schemas"]["InvestmentAttemptDto"];
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: {
data: components["schemas"]["BrokerageAccountDto"][] | null;
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: {
id: string;
@@ -1061,16 +1107,6 @@ export interface components {
data: components["schemas"]["IncomeSummaryDto"] | null;
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: {
period: components["schemas"]["IncomePeriodDto"];
totalsByCurrency: components["schemas"]["IncomeCurrencySummaryDto"][];
@@ -1114,8 +1150,6 @@ export interface components {
totalsByCurrency: components["schemas"]["IncomeCurrencySummaryDto"][];
/** Format: date-time */
calculatedAt: string;
previous: components["schemas"]["IncomeComparisonDto"] | null;
yearComparison: components["schemas"]["IncomeComparisonDto"] | null;
/** @enum {string} */
bucketGranularity: "day" | "week" | "month";
buckets: components["schemas"]["IncomeBucketDto"][];
@@ -1128,14 +1162,16 @@ export interface components {
coverage?: components["schemas"]["InvestmentCoverageDto"];
resourceKey: string;
/** @enum {string} */
availability: "missing" | "partial" | "complete";
availability: "available" | "initial-loading" | "temporarily-unavailable" | "connection-unavailable";
/** @enum {string} */
completeness: "complete" | "incomplete";
/** @enum {string} */
freshness: "unknown" | "fresh" | "stale";
/** Format: date-time */
lastSuccessfulAt: string | null;
sync: components["schemas"]["InvestmentSyncDto"];
lastAttempt: components["schemas"]["InvestmentAttemptDto"];
/** @enum {string} */
role: "selected" | "previous" | "year";
role: "selected";
};
IncomeAnalyticsMetaDto: {
dataRevision: string;
@@ -1143,12 +1179,14 @@ export interface components {
coverage?: components["schemas"]["InvestmentCoverageDto"];
resourceKey: string;
/** @enum {string} */
availability: "missing" | "partial" | "complete";
availability: "available" | "initial-loading" | "temporarily-unavailable" | "connection-unavailable";
/** @enum {string} */
completeness: "complete" | "incomplete";
/** @enum {string} */
freshness: "unknown" | "fresh" | "stale";
/** Format: date-time */
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. */
dependencies: components["schemas"]["IncomeDependencyDto"][];
};
@@ -1232,6 +1270,37 @@ export interface components {
data: components["schemas"]["StockDto"] | null;
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: {
price: components["schemas"]["MoneyDto"] | null;
/** Format: date-time */
@@ -1543,24 +1612,6 @@ export interface components {
PutTbankConnectionDto: {
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: {
accountId: string;
/**
@@ -1579,6 +1630,24 @@ export interface components {
*/
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: {
/** Format: date-time */
from: string;
@@ -1611,13 +1680,6 @@ export interface components {
*/
to?: string;
};
TbankSyncAcceptedDto: {
/** Format: uuid */
id: string;
scope: components["schemas"]["IncomeInvestmentScreenDto"] | components["schemas"]["EventsInvestmentScreenDto"] | components["schemas"]["TimestampInvestmentScreenDto"];
/** Format: date-time */
now: string;
};
TbankSyncStatusDto: {
/** Format: uuid */
id: string;
@@ -3175,9 +3237,7 @@ export interface operations {
};
BrokerageAccountsController_list: {
parameters: {
query?: {
sync?: "observe";
};
query?: never;
header?: never;
path?: never;
cookie?: never;
@@ -3241,9 +3301,7 @@ export interface operations {
};
PortfolioController_all: {
parameters: {
query?: {
sync?: "observe";
};
query?: never;
header?: never;
path?: never;
cookie?: never;
@@ -3307,9 +3365,7 @@ export interface operations {
};
PortfolioController_get: {
parameters: {
query?: {
sync?: "observe";
};
query?: never;
header?: never;
path: {
accountId: string;
@@ -3376,7 +3432,6 @@ export interface operations {
BrokerageOperationsController_all: {
parameters: {
query?: {
sync?: "observe";
/** @description Instrument UID. Without from, includes history since the earliest available account opening. */
instrumentUid?: string;
from?: string;
@@ -3449,7 +3504,6 @@ export interface operations {
BrokerageOperationsController_get: {
parameters: {
query?: {
sync?: "observe";
/** @description Instrument UID. Without from, includes history since the earliest available account opening. */
instrumentUid?: 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: {
parameters: {
query?: {
@@ -4281,9 +4471,7 @@ export interface operations {
};
StockController_position: {
parameters: {
query?: {
sync?: "observe";
};
query?: never;
header?: never;
path: {
uid: string;
@@ -4893,9 +5081,7 @@ export interface operations {
};
BondController_position: {
parameters: {
query?: {
sync?: "observe";
};
query?: never;
header?: never;
path: {
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: {
parameters: {
query?: never;
-2
View File
@@ -1,5 +1,4 @@
export {
useAuthenticatedQueries,
useAuthenticatedQuery,
} from "./authenticated-query";
export {
@@ -11,7 +10,6 @@ export type { paths } from "./generated/schema";
export { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
export {
invalidateResourceAccess,
type InvestmentResourceMeta,
useResourceQuery,
} from "./resource-query";
+8 -5
View File
@@ -75,9 +75,10 @@ describe("persistent resource query lifecycle", () => {
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(
resource(null, {
sync: {
errorCode: "TBANK_UNAVAILABLE",
id: "work",
lastAttempt: {
finishedAt: "2026-08-14T00:01:00Z",
problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed",
},
}),
@@ -89,14 +90,16 @@ describe("persistent resource query lifecycle", () => {
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.isSuccess).toBe(true);
expect(result.current.status).toBe("success");
expect(result.current.isPending).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 () => {
auth();
+14 -4
View File
@@ -15,8 +15,10 @@ import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
type InvestmentResourceMeta = components["schemas"]["InvestmentResourceMetaDto"];
type Resource = {
[key: string]: unknown;
data: unknown;
meta: InvestmentResourceMeta;
sync?: unknown;
};
type ResourcePath = {
@@ -66,8 +68,12 @@ function invalidateResourceAccess(
// Keep endpoint and parameters intact. The generated queryFn accepts the
// optional sync mode even for a cache key originally created without init.
function observationKey<K extends ResourceQueryKey>(key: K): K {
const [method, path, init] = key;
function observationKey<K extends readonly unknown[]>(key: K): K {
const [method, path, init] = key as unknown as [
string,
string,
{ params?: { path?: object; query?: object } }?,
];
return [method, path, {
...init,
@@ -76,7 +82,11 @@ function observationKey<K extends ResourceQueryKey>(key: K): K {
}
// 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>,
) {
const client = useQueryClient();
@@ -135,11 +145,11 @@ function useResourceQuery<T extends Resource, E, K extends ResourceQueryKey>(
queryKey: options.queryKey,
refetch: query.refetch,
status: query.status,
sync: query.data && "sync" in query.data ? query.data.sync : undefined,
};
}
export {
invalidateResourceAccess,
type InvestmentResourceMeta,
useResourceQuery,
};
File diff suppressed because it is too large Load Diff
+66 -9
View File
@@ -340,13 +340,8 @@ const ru = {
},
income: {
apply: "Применить",
comparisonEmpty: "За эти периоды купоны и дивиденды не найдены.",
comparisonFailed:
"Не удалось загрузить период сравнения. Повторите обновление.",
comparisonPending: "Период сравнения ещё не загружен полностью.",
coupon: "Купоны",
datePlaceholder: "ДД.ММ.ГГГГ",
difference: "Разница",
dividend: "Дивиденды",
emptyText: "За выбранный период подходящих исполненных выплат нет.",
emptyTitle: "Купоны и дивиденды не найдены",
@@ -356,8 +351,6 @@ const ru = {
open: "Открыть пассивный доход",
payments: "Выплаты",
period: "Период",
previousPeriod: "Предыдущий период",
previousUnavailable: "Сравнение с предыдущим периодом недоступно.",
refreshError:
"Не удалось обновить пассивный доход. Показан последний загруженный результат.",
refreshing: "Обновляем пассивный доход",
@@ -378,10 +371,15 @@ const ru = {
period:
"Укажите корректный период без будущих дат: начало не позже окончания.",
},
yearAgo: "Годом ранее",
yearUnavailable: "Сравнение с годом ранее недоступно.",
},
investment: {
attempt: {
cancelled: "Обновление отменено",
failed: "Обновление не удалось",
partial: "Обновлено частично",
running: "Обновление выполняется",
succeeded: "Обновлено",
},
history: "История догружается",
partial: "Период загружен не полностью",
refresh: "Обновить",
@@ -452,6 +450,14 @@ const ru = {
stocks: "Акции",
},
notFound: { action: "К счетам", title: "Страница не найдена" },
payout: {
estimate: "Оценка по позиции",
executedAt: "Исполнено {{date}}",
forecast: "Прогноз",
noAmount: "Сумма неизвестна",
noMoney: "Без движения денег",
registryDate: "Дата реестра",
},
portfolio: {
accountUnavailableText: "Вернитесь к списку и выберите доступный счет.",
accountUnavailableTitle: "Счет недоступен",
@@ -629,6 +635,57 @@ const ru = {
quantity: "Количество акций",
record: "Дата реестра",
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: "Введите хотя бы два символа.",
searching: "Ищем акции",
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("Обновление не удалось");
});
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", () => {
renderStatus({
errorText: "Обновление не удалось",
@@ -3,6 +3,8 @@ import type { FC } from "react";
import { Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import type { InvestmentResourceMeta } from "@/shared/api";
import { formatSnapshotTime } from "@/shared/lib";
// 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.
type SourceSyncStatusProps = {
errorText?: null | string;
lastAttempt?: InvestmentResourceMeta["lastAttempt"];
lastSuccessfulAt?: null | string;
stale?: boolean;
};
@@ -17,6 +20,12 @@ type SourceSyncStatusProps = {
const SourceSyncStatus: FC<SourceSyncStatusProps> = (props) => {
const { t } = useTranslation();
const attempt = props.lastAttempt;
const attemptText = attempt && attempt.state !== "none"
? t(`investment.attempt.${attempt.state}`)
: null;
return (
<>
{!!props.lastSuccessfulAt && (
@@ -29,6 +38,12 @@ const SourceSyncStatus: FC<SourceSyncStatusProps> = (props) => {
{!!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>}
</>
);
+1
View File
@@ -1 +1,2 @@
export { PayoutCard } from "./PayoutCard/PayoutCard";
export { SourceSyncStatus } from "./SourceSyncStatus/SourceSyncStatus";
+8 -2
View File
@@ -7,12 +7,18 @@ function resource<T>(data: T, meta: Partial<Meta> = {}) {
return {
data,
meta: {
availability: data === null ? "missing" : "complete",
availability: data === null ? "initial-loading" : "available",
completeness: "complete",
dataRevision: "1",
freshness: "fresh",
lastAttempt: {
finishedAt: null,
problems: [],
startedAt: null,
state: "none",
},
lastSuccessfulAt: "2026-08-14T00:00:00Z",
resourceKey: "fixture",
sync: { errorCode: null, id: null, state: "idle" },
...meta,
} satisfies Meta,
};