1 Commits
Author SHA1 Message Date
ksv741 7b2bd8d379 fix: round money to currency minor units without negative zero (WealthLedger/frontend#118)
Frontend verify / commits (pull_request) Successful in 28s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Successful in 5m22s
Frontend verify / commits (pull_request) Successful in 29s
2026-10-28 08:29:18 +03:00
159 changed files with 2834 additions and 7359 deletions
+7 -6
View File
@@ -8,7 +8,7 @@ Vitest — отдельную JSDOM конфигурацию. Точные versi
[package.json](../package.json), реализация — source of truth. [package.json](../package.json), реализация — source of truth.
```text ```text
app → pages → features → entities → shared app → pages → widgets → features → entities → shared
page container → page VM → entity/feature query → shared/api → backend /api/v1 page container → page VM → entity/feature query → shared/api → backend /api/v1
↓ ↓
props → state/section views props → state/section views
@@ -30,13 +30,14 @@ contract; frontend не читает его checkout для генерации
| --- | --- | | --- | --- |
| Bootstrap, глобальные providers, routes | `app` | | Bootstrap, глобальные providers, routes | `app` |
| Композиция маршрута и поведение, нужное только этому экрану | `pages/<page>` | | Композиция маршрута и поведение, нужное только этому экрану | `pages/<page>` |
| Крупная переиспользуемая композиция экрана | `widgets/<widget>` |
| Самостоятельная пользовательская возможность с собственным смыслом | `features/<capability>` | | Самостоятельная пользовательская возможность с собственным смыслом | `features/<capability>` |
| Представление/поведение бизнес-сущности | `entities/<entity>` | | Представление/поведение бизнес-сущности | `entities/<entity>` |
| Переиспользуемый код без WealthLedger domain meaning | `shared` | | Переиспользуемый код без WealthLedger domain meaning | `shared` |
Локальный FSD profile: `app → pages → features → entities → shared`. Локальный FSD profile: `app → pages → widgets → features → entities → shared`.
Слой `widgets` не используется: композиция экрана и компонент для одной page Widget собирает несколько нижних слоёв в самостоятельный UI-блок; компонент для
остаются у page. Form, mutation или click handler сами по себе не одной page остаётся у page. Form, mutation или click handler сами по себе не
требуют feature. Два callers не делают domain helper generic shared. требуют feature. Два callers не делают domain helper generic shared.
Slice именуется по ответственности, а не `helpers`, `hooks` или `types`. Slice именуется по ответственности, а не `helpers`, `hooks` или `types`.
@@ -48,12 +49,12 @@ Slice именуется по ответственности, а не `helpers`,
Cross-slice consumer использует `@/` и `index.ts` владельца, например Cross-slice consumer использует `@/` и `index.ts` владельца, например
`@/entities/portfolio`. Alias Vite и TypeScript согласован. Другой slice не `@/entities/portfolio`. Alias Vite и TypeScript согласован. Другой slice не
импортирует внутренний файл напрямую. Slices `pages`, `features`, `entities` не зависят от соседнего slice своего слоя; импортирует внутренний файл напрямую. Slices `pages`, `widgets`, `features`, `entities` не зависят от соседнего slice своего слоя;
композиция принадлежит вышележащему owner. Existing меж-slice исключения композиция принадлежит вышележащему owner. Existing меж-slice исключения
не являются разрешением добавлять новые. не являются разрешением добавлять новые.
Направление зависимостей, public imports и запрет соседних slices в Направление зависимостей, public imports и запрет соседних slices в
`pages`, `features`, `entities` проверяет `npm run deps`. Внутренние imports `pages`, `widgets`, `features`, `entities` проверяет `npm run deps`. Внутренние imports
одного slice и связи shared segments этот запрет не ограничивает. одного slice и связи shared segments этот запрет не ограничивает.
Knip проверяет использование exports, а не смысл placement. Knip проверяет использование exports, а не смысл placement.
-3
View File
@@ -13,9 +13,6 @@
!public/** !public/**
!docker/ !docker/
!docker/** !docker/**
!scripts/
!scripts/minimatch-compat/
!scripts/minimatch-compat/**
!packages/ !packages/
!packages/tokens/ !packages/tokens/
!packages/tokens/package.json !packages/tokens/package.json
-56
View File
@@ -1,61 +1,5 @@
# Changelog # Changelog
## [1.4.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.3.0...v1.4.0) (2026-11-06)
### Features
* **investments:** warn and offer retry after exhausted read retries (WealthLedger/frontend[#55](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/55)) ([#136](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/136)) ([8ce1e8e](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/8ce1e8e44486dcc23d4c074559ca2bb517880b60))
### Bug Fixes
* **account:** show source metadata on empty account and income (WealthLedger/frontend[#55](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/55)) ([#133](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/133)) ([9619dc9](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/9619dc979172dbc16391206e801ed67862d20116))
* align audit findings for Events, currency and auth (WealthLedger/frontend[#155](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/155)) ([668f2ef](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/668f2ef70e535a1222e7f600b1be4a31fefc6f8b))
### Refactoring
* **api:** consume bank operation oneOf contract (WealthLedger/frontend[#144](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/144)) ([#152](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/152)) ([2d070ce](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/2d070ce2b54bbee466ecf6cc18b437910f0c07f8))
* drop dead query helpers and simplify read deadline (WealthLedger/frontend[#141](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/141)) ([#149](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/149)) ([5077bd4](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/5077bd4f0baa4a59643e875485c2f08253cc198d))
* move portfolio structure into account page, drop widgets (WealthLedger/frontend[#139](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/139)) ([#140](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/140)) ([e2d143d](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/e2d143df46e650139e49b7c80a886008ede86d2a))
* normalize view models and share breadcrumbs and titles (WealthLedger/frontend[#143](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/143)) ([#153](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/153)) ([2db39f2](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/2db39f25d9478350ad56a1b2e2ca6496068257c5))
* replace repeated lazy page wrappers with lazyPage helper ([#131](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/131)) ([#132](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/132)) ([71fbe77](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/71fbe7774d3ed63fcb84bc644da997921bc049b2))
* share path-root query predicate (WealthLedger/frontend[#142](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/142)) ([#154](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/154)) ([cadba8d](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/cadba8d075daf9b20c0c9de97ad5bd8f5a3e74fc))
### Maintenance
* **api:** update OpenAPI snapshot with UpcomingEventsDto.window (WealthLedger/frontend[#134](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/134)) ([#135](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/135)) ([aaee959](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/aaee9591d7880193ee9b0c14339e8477264e4a94))
* **deps:** set Renovate release age, PR limit and update groups (WealthLedger/main[#6](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/6)) ([#137](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/137)) ([ad93725](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/ad93725a84615ec329631b71ed8db33337085740))
## [1.3.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.2.1...v1.3.0) (2026-11-01)
### Features
* **account:** add concentration tab with top-5 slices and chart (WealthLedger/main[#48](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/48)) ([#127](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/127)) ([70a2c33](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/70a2c335fdddb8b6a99e3f609befe06aeb8cc45d))
* group all-account rows by instrument and day (WealthLedger/main[#47](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/47)) ([#126](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/126)) ([71126c9](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/71126c91f0b30f4d65a313ddc7d7f86963340455))
* make brokerage account list compact ([#111](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/111)) ([c24704a](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c24704affacbe7fbc08f5e2b61bb6e7ffa6053b9))
* remove top header and rework mobile bottom navigation ([#109](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/109)) ([1a92f02](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/1a92f023c1c79f646424f72710ab74de63386a11))
* replace account page accordion with tabs and show open position result ([#112](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/112)) ([3075081](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/3075081df188de0630d314ba15a34fe6efd1eea9))
* show return, cash and structure on account cards ([#114](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/114)) ([431559b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/431559be0fca9eb37f6a09d930d502f939884e39))
### Bug Fixes
* round money to currency minor units without negative zero (WealthLedger/frontend[#118](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/118)) ([#119](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/119)) ([1d49904](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/1d4990491f254e1ec6bafad8204872eaae2982d0))
* show account source sync status (WealthLedger/frontend[#120](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/120)) ([#121](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/121)) ([a56edf2](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/a56edf2b3757ef0b0f6c42ecbd422a1a563f6505))
* show history loading alert only while loading (WealthLedger/frontend[#124](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/124)) ([#125](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/125)) ([6792f5b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/6792f5b70aa3ba90b2eb2df7e63d3ae2f669e295))
* stabilize auth client registration (WealthLedger/frontend[#95](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/95)) ([#122](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/122)) ([5796d2d](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/5796d2d789963e66a3a4bcd79eab44700f16bd31))
### Refactoring
* derive api error codes from contract (WealthLedger/backend[#117](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/117)) ([#123](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/123)) ([32e8fe9](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/32e8fe9402665ea0c350f5f247d87e328ffd44c8))
### Tests
* pin date in all accounts events test ([#115](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/115)) ([58b4e12](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/58b4e126330d01c1d6865bd0e9dd066a028fba08))
### Maintenance
* **deps:** resolve minimatch and glob audit advisories (WealthLedger/main[#49](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/49)) ([#128](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/128)) ([7242240](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/7242240b2188e9d1ca1325e65d0e1a0a3655d882))
* sync brokerage accounts contract (WealthLedger/backend[#138](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/138)) ([0e4bbec](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/0e4bbec2a7b814579095ea7ef6db68a70508fa29))
## [1.2.1](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.2.0...v1.2.1) (2026-10-24) ## [1.2.1](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.2.0...v1.2.1) (2026-10-24)
### Bug Fixes ### Bug Fixes
-1
View File
@@ -5,7 +5,6 @@ COPY package.json package-lock.json ./
COPY packages/tokens/package.json ./packages/tokens/ COPY packages/tokens/package.json ./packages/tokens/
COPY packages/ui/package.json ./packages/ui/ COPY packages/ui/package.json ./packages/ui/
COPY packages/icons/package.json ./packages/icons/ COPY packages/icons/package.json ./packages/icons/
COPY scripts/minimatch-compat ./scripts/minimatch-compat
RUN npm ci RUN npm ci
FROM dependencies AS development FROM dependencies AS development
+11 -5
View File
@@ -29,17 +29,17 @@ module.exports = {
}, },
{ {
from: { from: {
path: "^src/(app|pages|features|entities|shared)/([^/]+)/", path: "^src/(app|pages|widgets|features|entities|shared)/([^/]+)/",
}, },
name: "slices-must-use-public-api", name: "slices-must-use-public-api",
severity: "error", severity: "error",
to: { to: {
path: String.raw`^src/(app|pages|features|entities|shared)/([^/]+)/(?!index\.ts$)`, path: String.raw`^src/(app|pages|widgets|features|entities|shared)/([^/]+)/(?!index\.ts$)`,
pathNot: "^src/$1/$2/", pathNot: "^src/$1/$2/",
}, },
}, },
{ {
from: { path: "^src/(pages|features|entities)/([^/]+)/" }, from: { path: "^src/(pages|widgets|features|entities)/([^/]+)/" },
name: "no-same-layer-cross-slice-imports", name: "no-same-layer-cross-slice-imports",
severity: "error", severity: "error",
to: { path: "^src/$1/", pathNot: "^src/$1/$2/" }, to: { path: "^src/$1/", pathNot: "^src/$1/$2/" },
@@ -48,18 +48,24 @@ module.exports = {
from: { path: "^src/shared/" }, from: { path: "^src/shared/" },
name: "shared-must-not-depend-on-layers", name: "shared-must-not-depend-on-layers",
severity: "error", severity: "error",
to: { path: "^src/(entities|features|pages|app)/" }, to: { path: "^src/(entities|features|widgets|pages|app)/" },
}, },
{ {
from: { path: "^src/entities/" }, from: { path: "^src/entities/" },
name: "entities-must-not-depend-on-higher-layers", name: "entities-must-not-depend-on-higher-layers",
severity: "error", severity: "error",
to: { path: "^src/(features|pages|app)/" }, to: { path: "^src/(features|widgets|pages|app)/" },
}, },
{ {
from: { path: "^src/features/" }, from: { path: "^src/features/" },
name: "features-must-not-depend-on-higher-layers", name: "features-must-not-depend-on-higher-layers",
severity: "error", severity: "error",
to: { path: "^src/(widgets|pages|app)/" },
},
{
from: { path: "^src/widgets/" },
name: "widgets-must-not-depend-on-higher-layers",
severity: "error",
to: { path: "^src/(pages|app)/" }, to: { path: "^src/(pages|app)/" },
}, },
{ {
-9
View File
@@ -17,21 +17,12 @@ http {
default $http_x_forwarded_proto; default $http_x_forwarded_proto;
'' $scheme; '' $scheme;
} }
# One shared budget: the external proxy's client-IP forwarding is not verified.
map "$request_method:$uri" $auth_admission_key {
default "";
POST:/api/v1/auth/login auth;
POST:/api/v1/auth/register auth;
}
limit_req_zone $auth_admission_key zone=auth_admission:1m rate=2r/s;
limit_req_status 429;
server { server {
listen 8080; listen 8080;
server_name _; server_name _;
root /usr/share/nginx/html; root /usr/share/nginx/html;
resolver 127.0.0.11 valid=5s ipv6=off; resolver 127.0.0.11 valid=5s ipv6=off;
location /api/ { location /api/ {
limit_req zone=auth_admission burst=5 nodelay;
set $backend http://backend:3000; set $backend http://backend:3000;
proxy_pass $backend$request_uri; proxy_pass $backend$request_uri;
proxy_set_header Host $http_host; proxy_set_header Host $http_host;
+1 -6
View File
@@ -25,11 +25,7 @@ const readability = {
) )
context.report({ messageId: "schema", node }); context.report({ messageId: "schema", node });
}, },
FunctionDeclaration(node) { FunctionDeclaration: checkParameters,
checkParameters(node);
if (node.id?.name?.endsWith?.("ViewModel"))
context.report({ messageId: "vmName", node: node.id });
},
FunctionExpression: checkParameters, FunctionExpression: checkParameters,
ImportDeclaration(node) { ImportDeclaration(node) {
if ( if (
@@ -99,7 +95,6 @@ const readability = {
schema: "Declare Zod schemas in a dedicated *.schema.ts file.", schema: "Declare Zod schemas in a dedicated *.schema.ts file.",
svg: "Own SVG markup in packages/icons, and import its public component.", svg: "Own SVG markup in packages/icons, and import its public component.",
union: "Put complex union members on separate lines.", union: "Put complex union members on separate lines.",
vmName: "Name custom hooks use*Vm, not use*ViewModel.",
}, },
schema: [], schema: [],
type: "suggestion", type: "suggestion",
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"ignoreDependencies": ["conventional-changelog-conventionalcommits", "minimatch"], "ignoreDependencies": ["conventional-changelog-conventionalcommits"],
"workspaces": { "workspaces": {
".": { ".": {
"project": ["src/**/*.{ts,tsx}", "!src/shared/api/generated/**"] "project": ["src/**/*.{ts,tsx}", "!src/shared/api/generated/**"]
+138 -42
View File
@@ -1,12 +1,12 @@
{ {
"name": "wealth-ledger-frontend", "name": "wealth-ledger-frontend",
"version": "1.4.0", "version": "1.2.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "wealth-ledger-frontend", "name": "wealth-ledger-frontend",
"version": "1.4.0", "version": "1.2.1",
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
], ],
@@ -80,7 +80,6 @@
"jsdom": "30.1.3", "jsdom": "30.1.3",
"knip": "6.43.0", "knip": "6.43.0",
"lint-staged": "17.6.0", "lint-staged": "17.6.0",
"minimatch": "file:scripts/minimatch-compat",
"msw": "2.15.0", "msw": "2.15.0",
"openapi-typescript": "7.13.0", "openapi-typescript": "7.13.0",
"playwright": "1.64.1", "playwright": "1.64.1",
@@ -89,7 +88,7 @@
"typescript": "5.9.3", "typescript": "5.9.3",
"typescript-eslint": "8.72.0", "typescript-eslint": "8.72.0",
"vite": "8.3.5", "vite": "8.3.5",
"vite-plugin-pwa": "2.0.0", "vite-plugin-pwa": "1.3.0",
"vitest": "5.0.5" "vitest": "5.0.5"
}, },
"engines": { "engines": {
@@ -2512,12 +2511,14 @@
} }
} }
}, },
"node_modules/@isaacs/nonrecursive-glob-match": { "node_modules/@isaacs/cliui": {
"version": "1.0.3", "version": "9.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/nonrecursive-glob-match/-/nonrecursive-glob-match-1.0.3.tgz", "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==",
"integrity": "sha512-SI7kyKHq4XXDRozw+r9GlRZkia1ZkFrpLW98r2IC2Bpr8YkFGE3fvjzmxCG0x78A++69HmDNrDvSMYBZmHGpKA==",
"dev": true, "dev": true,
"license": "BlueOak-1.0.0" "license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
}, },
"node_modules/@joshwooding/vite-plugin-react-docgen-typescript": { "node_modules/@joshwooding/vite-plugin-react-docgen-typescript": {
"version": "0.7.0", "version": "0.7.0",
@@ -3719,6 +3720,33 @@
"npm": ">=9.5.0" "npm": ">=9.5.0"
} }
}, },
"node_modules/@redocly/openapi-core/node_modules/balanced-match": {
"version": "1.0.2",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/@redocly/openapi-core/node_modules/brace-expansion": {
"version": "2.1.7",
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/@redocly/openapi-core/node_modules/minimatch": {
"version": "5.1.9",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@reduxjs/toolkit": { "node_modules/@reduxjs/toolkit": {
"version": "2.12.0", "version": "2.12.0",
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
@@ -6420,7 +6448,6 @@
}, },
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
@@ -6468,7 +6495,6 @@
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "5.0.12", "version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
@@ -9071,6 +9097,33 @@
"minimatch": "^5.0.1" "minimatch": "^5.0.1"
} }
}, },
"node_modules/filelist/node_modules/balanced-match": {
"version": "1.0.2",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/filelist/node_modules/brace-expansion": {
"version": "2.1.7",
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/filelist/node_modules/minimatch": {
"version": "5.1.9",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/find-up": { "node_modules/find-up": {
"version": "5.0.0", "version": "5.0.0",
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
@@ -9141,6 +9194,22 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/foreground-child": {
"version": "3.3.1",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"dev": true,
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/formatly": { "node_modules/formatly": {
"version": "0.11.4", "version": "0.11.4",
"resolved": "https://registry.npmjs.org/formatly/-/formatly-0.11.4.tgz", "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.11.4.tgz",
@@ -9419,15 +9488,14 @@
} }
}, },
"node_modules/glob": { "node_modules/glob": {
"version": "14.0.3", "version": "13.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-14.0.3.tgz", "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
"integrity": "sha512-A1vjIwC3qpdCso45Iw127gnp7sPhqgY8lUQhDrArIs+GbmfaaVPFuoyy7WyLT9EPZCRZ7j7aLdwUhQmHWesEow==",
"dev": true, "dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"dependencies": { "dependencies": {
"minimatch": "^11.0.2", "minimatch": "^10.2.2",
"minipass": "^7.1.3", "minipass": "^7.1.3",
"path-scurry": "^3.0.0" "path-scurry": "^2.0.2"
}, },
"engines": { "engines": {
"node": "18 || 20 || >=22" "node": "18 || 20 || >=22"
@@ -10602,6 +10670,21 @@
"node": "^18.17 || >=20.6.1" "node": "^18.17 || >=20.6.1"
} }
}, },
"node_modules/jackspeak": {
"version": "4.2.3",
"integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^9.0.0"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/jake": { "node_modules/jake": {
"version": "10.9.4", "version": "10.9.4",
"integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
@@ -11390,19 +11473,12 @@
} }
}, },
"node_modules/minimatch": { "node_modules/minimatch": {
"resolved": "scripts/minimatch-compat", "version": "10.2.6",
"link": true "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
},
"node_modules/minimatch-11": {
"name": "minimatch",
"version": "11.0.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.2.tgz",
"integrity": "sha512-pWTgz5CN3tjqyZNSK2tePlSmu9IwM41q8yn8Usdqqwe9TIHh+9dEpLLjF4uApsfPEAZY71qtSiA2B2APEr+1Yw==",
"dev": true, "dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"dependencies": { "dependencies": {
"@isaacs/nonrecursive-glob-match": "^1.0.2", "brace-expansion": "^5.0.8"
"brace-expansion": "^5.0.12"
}, },
"engines": { "engines": {
"node": "18 || 20 || >=22" "node": "18 || 20 || >=22"
@@ -12136,6 +12212,12 @@
"quickjs-wasi": "^2.2.0" "quickjs-wasi": "^2.2.0"
} }
}, },
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/package-manager-detector": { "node_modules/package-manager-detector": {
"version": "1.9.0", "version": "1.9.0",
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.9.0.tgz", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.9.0.tgz",
@@ -12232,9 +12314,8 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/path-scurry": { "node_modules/path-scurry": {
"version": "3.0.0", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-3.0.0.tgz", "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
"integrity": "sha512-1pTbQqH1wIwMi/eleQ5leFTTCkzFsPcgXNjwOA8YGGW/W4/kHT3dssE7Z6NH+zuqNLOzOB0uaD61yl7cAcVhgg==",
"dev": true, "dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"dependencies": { "dependencies": {
@@ -15848,9 +15929,8 @@
} }
}, },
"node_modules/vite-plugin-pwa": { "node_modules/vite-plugin-pwa": {
"version": "2.0.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-2.0.0.tgz", "integrity": "sha512-c5kMgN+ITrOtHXp8PAtk2uOIEea6XjP/unCGxOWWBzQ6qa65qj/awHg0wf+QF9E/2u9vh86LqxPwzEPNbM2r5A==",
"integrity": "sha512-6G9dsPKT9QKYpeTuTLJM28zZL3HsXUTUM/QUZBEgQk2jbfrPJDq2i8gTewAKqi1c9Mt6MiidrL4SFI+HthiQMg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -15861,13 +15941,13 @@
"workbox-window": "^7.4.1" "workbox-window": "^7.4.1"
}, },
"engines": { "engines": {
"node": ">=20.19.0" "node": ">=16.0.0"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/antfu" "url": "https://github.com/sponsors/antfu"
}, },
"peerDependencies": { "peerDependencies": {
"@vite-pwa/assets-generator": "^1.0.0 || ^2.0.0", "@vite-pwa/assets-generator": "^1.0.0",
"vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
"workbox-build": "^7.4.1", "workbox-build": "^7.4.1",
"workbox-window": "^7.4.1" "workbox-window": "^7.4.1"
@@ -17562,6 +17642,30 @@
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
} }
}, },
"node_modules/workbox-build/node_modules/glob": {
"version": "11.1.0",
"integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"foreground-child": "^3.3.1",
"jackspeak": "^4.1.1",
"minimatch": "^10.1.1",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^2.0.0"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/workbox-build/node_modules/lru-cache": { "node_modules/workbox-build/node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
@@ -17972,14 +18076,6 @@
"react": "19.3.1", "react": "19.3.1",
"react-dom": "19.3.1" "react-dom": "19.3.1"
} }
},
"scripts/minimatch-compat": {
"name": "minimatch",
"version": "11.0.2-compat.0",
"dev": true,
"dependencies": {
"minimatch-11": "npm:minimatch@11.0.2"
}
} }
} }
} }
+3 -6
View File
@@ -1,7 +1,7 @@
{ {
"name": "wealth-ledger-frontend", "name": "wealth-ledger-frontend",
"private": true, "private": true,
"version": "1.4.0", "version": "1.2.1",
"type": "module", "type": "module",
"engines": { "engines": {
"node": "24.22.1" "node": "24.22.1"
@@ -106,7 +106,6 @@
"jsdom": "30.1.3", "jsdom": "30.1.3",
"knip": "6.43.0", "knip": "6.43.0",
"lint-staged": "17.6.0", "lint-staged": "17.6.0",
"minimatch": "file:scripts/minimatch-compat",
"msw": "2.15.0", "msw": "2.15.0",
"openapi-typescript": "7.13.0", "openapi-typescript": "7.13.0",
"playwright": "1.64.1", "playwright": "1.64.1",
@@ -115,7 +114,7 @@
"typescript": "5.9.3", "typescript": "5.9.3",
"typescript-eslint": "8.72.0", "typescript-eslint": "8.72.0",
"vite": "8.3.5", "vite": "8.3.5",
"vite-plugin-pwa": "2.0.0", "vite-plugin-pwa": "1.3.0",
"vitest": "5.0.5" "vitest": "5.0.5"
}, },
"overrides": { "overrides": {
@@ -125,9 +124,7 @@
}, },
"@vitest/browser": "5.0.5", "@vitest/browser": "5.0.5",
"@vitest/ui": "5.0.5", "@vitest/ui": "5.0.5",
"@vitest/mocker": "5.0.5", "@vitest/mocker": "5.0.5"
"minimatch": "$minimatch",
"glob": "14.0.3"
}, },
"allowScripts": { "allowScripts": {
"esbuild@0.28.2": true, "esbuild@0.28.2": true,
+2 -7
View File
@@ -16,7 +16,7 @@ type EntryCardProps = {
tone: "negative" tone: "negative"
| "neutral" | "neutral"
| "positive"; | "positive";
value: ReactNode; value: string;
valueLabel: string; valueLabel: string;
wrap?: boolean; wrap?: boolean;
}; };
@@ -32,7 +32,6 @@ const EntryCard: FC<EntryCardProps> = (props) => {
const valueContainerClass = props.actions ? classes.valueFooter : undefined; const valueContainerClass = props.actions ? classes.valueFooter : undefined;
const valueLabelClass = `${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`; const valueLabelClass = `${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`;
const valueComponent = typeof props.value === "string" ? undefined : "div";
return ( return (
<Paper <Paper
@@ -64,11 +63,7 @@ const EntryCard: FC<EntryCardProps> = (props) => {
</Stack> </Stack>
<Stack className={classes.value} gap={2}> <Stack className={classes.value} gap={2}>
<Text <Text className={classes[props.tone]} fw={700}>
className={classes[props.tone]}
component={valueComponent}
fw={700}
>
{props.value} {props.value}
</Text> </Text>
+1 -16
View File
@@ -1,30 +1,15 @@
{ {
"$schema": "https://docs.renovatebot.com/renovate-schema.json", "$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended", ":semanticCommitTypeAll(chore)", "security:minimumReleaseAgeNpm"], "extends": ["config:recommended", ":semanticCommitTypeAll(chore)"],
"timezone": "Europe/Moscow", "timezone": "Europe/Moscow",
"labels": ["dependencies"], "labels": ["dependencies"],
"dependencyDashboard": true, "dependencyDashboard": true,
"rangeStrategy": "pin", "rangeStrategy": "pin",
"prConcurrentLimit": 5,
"lockFileMaintenance": {
"enabled": true,
"schedule": ["before 4am on the first day of the month"]
},
"packageRules": [ "packageRules": [
{ {
"matchUpdateTypes": ["minor", "patch"], "matchUpdateTypes": ["minor", "patch"],
"groupName": "non-major dependencies" "groupName": "non-major dependencies"
}, },
{
"matchManagers": ["github-actions"],
"matchUpdateTypes": ["minor", "patch"],
"groupName": "non-major github actions"
},
{
"matchManagers": ["docker-compose", "dockerfile"],
"matchUpdateTypes": ["minor", "patch"],
"groupName": "non-major container images"
},
{ {
"matchUpdateTypes": ["major"], "matchUpdateTypes": ["major"],
"dependencyDashboardApproval": true "dependencyDashboardApproval": true
-9
View File
@@ -1,9 +0,0 @@
// ponytail: CommonJS twin of index.js (import-x ships lib/index.cjs too).
const minimatch = require("minimatch-11");
module.exports = {
...minimatch,
makeRe: (pattern, options = {}) => ({
test: path => minimatch.minimatch(path, pattern, options),
}),
};
-11
View File
@@ -1,11 +0,0 @@
// ponytail: shim for eslint-plugin-import-x@4.17.1, which still imports the
// makeRe export removed in minimatch 11. Its only caller (no-internal-modules)
// uses the result via .test(), so a matcher object is enough. Delete this
// package and the override once import-x supports minimatch 11.
import { minimatch } from "minimatch-11";
export * from "minimatch-11";
export const makeRe = (pattern, options = {}) => ({
test: path => minimatch(path, pattern, options),
});
-14
View File
@@ -1,14 +0,0 @@
{
"name": "minimatch",
"version": "11.0.2-compat.0",
"private": true,
"description": "minimatch 11 plus the makeRe export eslint-plugin-import-x 4.17.1 still imports. Remove when import-x supports minimatch 11.",
"type": "module",
"exports": {
"import": "./index.js",
"require": "./index.cjs"
},
"dependencies": {
"minimatch-11": "npm:minimatch@11.0.2"
}
}
+12 -12
View File
@@ -46,21 +46,19 @@ const position: Portfolio["positions"][number]
displayName: "Сбербанк", displayName: "Сбербанк",
figi: "SBER", figi: "SBER",
isin: null, isin: null,
issuer: null,
logoName: null, logoName: null,
quantity: "2", quantity: "2",
sector: null,
ticker: "SBER", ticker: "SBER",
type: "STOCK", type: "STOCK",
}; };
const portfolio: Portfolio = { const portfolio: Portfolio = {
cash: { currency: "RUB", value: "0" }, cashBalances: [],
isStale: false, isStale: false,
lastSuccessfulAt: "2026-08-15T12:00:00Z", lastSuccessfulAt: "2026-08-15T12:00:00Z",
positions: accounts.map(account => ({ ...position, account })), positions: accounts.map(account => ({ ...position, account })),
structure: { categories: [], hasNegativeValues: false, totalValue: "0", unvaluedCategories: [] }, structure: { currencies: [], unvaluedCategories: [] },
totals: { currency: "RUB", value: "1200" }, totalsByCurrency: [{ currency: "RUB", value: "1200" }],
}; };
const receipt: BrokerageOperation const receipt: BrokerageOperation
@@ -106,11 +104,14 @@ const analytics: IncomeAnalytics
}, },
], ],
period: { from: "2026-08-01", to: "2026-08-31" }, period: { from: "2026-08-01", to: "2026-08-31" },
total: { totalsByCurrency: [
{
coupon: { currency: "RUB", value: "2" }, coupon: { currency: "RUB", value: "2" },
currency: "RUB",
dividend: { currency: "RUB", value: "0" }, dividend: { currency: "RUB", value: "0" },
total: { currency: "RUB", value: "2" }, total: { currency: "RUB", value: "2" },
}, },
],
}; };
const CurrentLocation: FC = () => { const CurrentLocation: FC = () => {
@@ -213,8 +214,7 @@ beforeEach(() => {
type: "coupon", type: "coupon",
}), }),
), ),
incomeTotal: { currency: "RUB", value: "0" }, incomeTotalsByCurrency: [],
window: { from: "2026-09-01", to: "2026-10-01" },
}), }),
), ),
), ),
@@ -224,9 +224,9 @@ beforeEach(() => {
describe("All accounts routes", () => { describe("All accounts routes", () => {
it.each([ it.each([
["/accounts/all", "Все счета"], ["/accounts/all", "Все счета"],
["/accounts/all/operations", "История операций"], ["/accounts/all/operations", "Журнал операций"],
["/accounts/all/income?from=2026-08-01&to=2026-08-31", "Пассивный доход"], ["/accounts/all/income?from=2026-08-01&to=2026-08-31", "Пассивный доход"],
["/accounts/all/events", "Предстоящие события"], ["/accounts/all/events", "Будущие события"],
])("opens %s directly", async (path, title) => { ])("opens %s directly", async (path, title) => {
renderPage(path); renderPage(path);
expect( expect(
@@ -308,9 +308,9 @@ describe("All accounts routes", () => {
); );
for (const [suffix, tab] of [ for (const [suffix, tab] of [
["operations", "История операций"], ["operations", "Операции"],
["income", "Доход"], ["income", "Доход"],
["events", "Предстоящие события"], ["events", "События"],
]) { ]) {
fireEvent.click(screen.getByRole("tab", { name: tab })); fireEvent.click(screen.getByRole("tab", { name: tab }));
expect(screen.getByRole("link", { name: "Подробнее" })) expect(screen.getByRole("link", { name: "Подробнее" }))
+94 -28
View File
@@ -1,4 +1,4 @@
import type { ComponentType, FC } from "react"; import type { FC } from "react";
import { lazy } from "react"; import { lazy } from "react";
import { Navigate, Route, Routes } from "react-router"; import { Navigate, Route, Routes } from "react-router";
@@ -6,35 +6,101 @@ import { Navigate, Route, Routes } from "react-router";
import { LazyPage } from "../RouteFallback"; import { LazyPage } from "../RouteFallback";
import { Protected, Public } from "../RouteGuards"; import { Protected, Public } from "../RouteGuards";
// Pages use named exports; React.lazy needs a default export per dynamic import (one chunk each). const CardPage = lazy(async () => {
function lazyPage<Name extends string>( const { CardPage } = await import("@/pages/card");
load: () => Promise<Record<Name, ComponentType>>,
name: Name,
) {
return lazy(async () => {
const page = await load();
// eslint-disable-next-line security/detect-object-injection -- `name` is a compile-time literal checked against the module's exports. return { default: CardPage };
return { default: page[name] }; });
});
}
const CardPage = lazyPage(async () => import("@/pages/card"), "CardPage"); const CardsPage = lazy(async () => {
const CardsPage = lazyPage(async () => import("@/pages/cards"), "CardsPage"); const { CardsPage } = await import("@/pages/cards");
const BondPage = lazyPage(async () => import("@/pages/bond"), "BondPage");
const BondsPage = lazyPage(async () => import("@/pages/bonds"), "BondsPage"); return { default: CardsPage };
const StockPage = lazyPage(async () => import("@/pages/stock"), "StockPage"); });
const StocksPage = lazyPage(async () => import("@/pages/stocks"), "StocksPage");
const AccountPage = lazyPage(async () => import("@/pages/account"), "AccountPage"); const BondPage = lazy(async () => {
const AccountEventsPage = lazyPage(async () => import("@/pages/account-events"), "AccountEventsPage"); const { BondPage } = await import("@/pages/bond");
const AccountOperationsPage = lazyPage(async () => import("@/pages/account-operations"), "AccountOperationsPage");
const AccountIncomePage = lazyPage(async () => import("@/pages/account-income"), "AccountIncomePage"); return { default: BondPage };
const AccountsPage = lazyPage(async () => import("@/pages/accounts"), "AccountsPage"); });
const LoginPage = lazyPage(async () => import("@/pages/login"), "LoginPage");
const NotFoundPage = lazyPage(async () => import("@/pages/not-found"), "NotFoundPage"); const BondsPage = lazy(async () => {
const RegisterPage = lazyPage(async () => import("@/pages/register"), "RegisterPage"); const { BondsPage } = await import("@/pages/bonds");
const SettingsPage = lazyPage(async () => import("@/pages/settings"), "SettingsPage");
const ProtectedLayout = lazyPage(async () => import("../ProtectedLayout"), "ProtectedLayout"); return { default: BondsPage };
});
const StockPage = lazy(async () => {
const { StockPage } = await import("@/pages/stock");
return { default: StockPage };
});
const StocksPage = lazy(async () => {
const { StocksPage } = await import("@/pages/stocks");
return { default: StocksPage };
});
const AccountPage = lazy(async () => {
const { AccountPage } = await import("@/pages/account");
return { default: AccountPage };
});
const AccountEventsPage = lazy(async () => {
const { AccountEventsPage } = await import("@/pages/account-events");
return { default: AccountEventsPage };
});
const AccountOperationsPage = lazy(async () => {
const { AccountOperationsPage } = await import("@/pages/account-operations");
return { default: AccountOperationsPage };
});
const AccountIncomePage = lazy(async () => {
const { AccountIncomePage } = await import("@/pages/account-income");
return { default: AccountIncomePage };
});
const AccountsPage = lazy(async () => {
const { AccountsPage } = await import("@/pages/accounts");
return { default: AccountsPage };
});
const LoginPage = lazy(async () => {
const { LoginPage } = await import("@/pages/login");
return { default: LoginPage };
});
const NotFoundPage = lazy(async () => {
const { NotFoundPage } = await import("@/pages/not-found");
return { default: NotFoundPage };
});
const RegisterPage = lazy(async () => {
const { RegisterPage } = await import("@/pages/register");
return { default: RegisterPage };
});
const SettingsPage = lazy(async () => {
const { SettingsPage } = await import("@/pages/settings");
return { default: SettingsPage };
});
const ProtectedLayout = lazy(async () => {
const { ProtectedLayout } = await import("../ProtectedLayout");
return { default: ProtectedLayout };
});
const AppRoutes: FC = () => ( const AppRoutes: FC = () => (
<Routes> <Routes>
@@ -1,5 +1,6 @@
import type { FC } from "react"; import type { FC } from "react";
import { useDocumentTitle } from "@mantine/hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useCurrentRoute } from "@/app/model"; import { useCurrentRoute } from "@/app/model";
@@ -7,12 +8,12 @@ import { useBankCard } from "@/entities/bank-card";
import { useBond } from "@/entities/bond"; import { useBond } from "@/entities/bond";
import { useBrokerageAccounts } from "@/entities/brokerage-account"; import { useBrokerageAccounts } from "@/entities/brokerage-account";
import { useStock } from "@/entities/stock"; import { useStock } from "@/entities/stock";
import { useAppDocumentTitle } from "@/shared/ui";
type DocumentTitleProps = { title: string }; type DocumentTitleProps = { title: string };
const DocumentTitle: FC<DocumentTitleProps> = (props): undefined => { const DocumentTitle: FC<DocumentTitleProps> = (props): undefined => {
useAppDocumentTitle(props.title); const { t } = useTranslation();
useDocumentTitle(`${props.title} — ${t("appName")}`);
}; };
type AccountDocumentTitleProps = { type AccountDocumentTitleProps = {
@@ -1,12 +1,12 @@
import type { FC, PropsWithChildren } from "react"; import type { FC, PropsWithChildren } from "react";
import { Button, Stack, Text, Title } from "@mantine/core"; import { Button, Stack, Text, Title } from "@mantine/core";
import { useDocumentTitle } from "@mantine/hooks";
import { Component } from "react"; import { Component } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link, useLocation } from "react-router"; import { Link, useLocation } from "react-router";
import { useAuth } from "@/features/auth"; import { useAuth } from "@/features/auth";
import { useAppDocumentTitle } from "@/shared/ui";
type BoundaryProps = PropsWithChildren<{ type BoundaryProps = PropsWithChildren<{
authenticated: boolean; authenticated: boolean;
@@ -37,7 +37,7 @@ class Boundary extends Component<BoundaryProps, State> {
const RouteErrorFallback: FC<RouteErrorFallbackProps> = (props) => { const RouteErrorFallback: FC<RouteErrorFallbackProps> = (props) => {
const { authenticated } = props; const { authenticated } = props;
const { t } = useTranslation(); const { t } = useTranslation();
useAppDocumentTitle(t("routeError.title")); useDocumentTitle(`${t("routeError.title")} — ${t("appName")}`);
const to = authenticated ? "/accounts" : "/login"; const to = authenticated ? "/accounts" : "/login";
return ( return (
@@ -55,9 +55,9 @@ describe("AppBreadcrumbs", () => {
}); });
it.each([ it.each([
["operations", "История операций"], ["operations", "Операции"],
["OPERATIONS", "История операций"], ["OPERATIONS", "Операции"],
["events", "Предстоящие события"], ["events", "Будущие события"],
["income", "Пассивный доход"], ["income", "Пассивный доход"],
])("links to the account hierarchy from %s", (section, title) => { ])("links to the account hierarchy from %s", (section, title) => {
renderBreadcrumbs(`/accounts/account-1/${section}`, [ renderBreadcrumbs(`/accounts/account-1/${section}`, [
+7 -17
View File
@@ -7,8 +7,10 @@ import { useCurrentRoute } from "@/app/model";
import { AccountBreadcrumbs } from "./AccountBreadcrumbs"; import { AccountBreadcrumbs } from "./AccountBreadcrumbs";
import { AccountScopeSwitch } from "./AccountScopeSwitch/AccountScopeSwitch"; import { AccountScopeSwitch } from "./AccountScopeSwitch/AccountScopeSwitch";
import { BondBreadcrumbs } from "./BondBreadcrumbs";
import { BreadcrumbTrail } from "./BreadcrumbTrail"; import { BreadcrumbTrail } from "./BreadcrumbTrail";
import { EntityBreadcrumbs } from "./EntityBreadcrumbs"; import { CardBreadcrumbs } from "./CardBreadcrumbs";
import { StockBreadcrumbs } from "./StockBreadcrumbs";
const AppBreadcrumbs: FC = () => { const AppBreadcrumbs: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -24,16 +26,9 @@ const AppBreadcrumbs: FC = () => {
stock, stock,
} = useCurrentRoute(); } = useCurrentRoute();
if (bond) { if (bond) return <BondBreadcrumbs identifier={bond.params.bondId ?? ""} />;
return <EntityBreadcrumbs identifier={bond.params.bondId ?? ""} kind="bond" />; if (stock)
} return <StockBreadcrumbs identifier={stock.params.stockId ?? ""} />;
if (stock) {
return (
<EntityBreadcrumbs identifier={stock.params.stockId ?? ""} kind="stock" />
);
}
if (account && (section || !account.params.section)) if (account && (section || !account.params.section))
return ( return (
<Stack gap={0} mb="md"> <Stack gap={0} mb="md">
@@ -48,13 +43,8 @@ const AppBreadcrumbs: FC = () => {
/> />
</Stack> </Stack>
); );
if (card) return <CardBreadcrumbs identifier={card.params.cardId ?? ""} />;
if (card) {
return <EntityBreadcrumbs identifier={card.params.cardId ?? ""} kind="card" />;
}
if (cards) return <BreadcrumbTrail items={[{ label: t("bank.title") }]} />; if (cards) return <BreadcrumbTrail items={[{ label: t("bank.title") }]} />;
if (accounts) if (accounts)
return <BreadcrumbTrail items={[{ label: t("breadcrumbs.accounts") }]} />; return <BreadcrumbTrail items={[{ label: t("breadcrumbs.accounts") }]} />;
@@ -0,0 +1,25 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { useBond } from "@/entities/bond";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
type BondBreadcrumbsProps = { identifier: string };
const BondBreadcrumbs: FC<BondBreadcrumbsProps> = (props) => {
const { data } = useBond(props.identifier);
const { t } = useTranslation();
return (
<BreadcrumbTrail
items={[
{ label: t("bond.searchTitle"), to: "/bonds" },
{ label: data?.name ?? t("bond.instrument") },
]}
/>
);
};
export { BondBreadcrumbs };
@@ -39,4 +39,4 @@ const BreadcrumbTrail: FC<BreadcrumbTrailProps> = (props) => {
); );
}; };
export { BreadcrumbTrail, type Crumb }; export { BreadcrumbTrail };
@@ -0,0 +1,25 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { useBankCard } from "@/entities/bank-card";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
type CardBreadcrumbsProps = { identifier: string };
const CardBreadcrumbs: FC<CardBreadcrumbsProps> = (props) => {
const { data } = useBankCard(props.identifier);
const { t } = useTranslation();
return (
<BreadcrumbTrail
items={[
{ label: t("bank.title"), to: "/cards" },
{ label: data?.name ?? t("bank.card") },
]}
/>
);
};
export { CardBreadcrumbs };
@@ -1,48 +0,0 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { useBankCard } from "@/entities/bank-card";
import { useBond } from "@/entities/bond";
import { useStock } from "@/entities/stock";
import type { Crumb } from "./BreadcrumbTrail";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
type EntityBreadcrumbsProps = {
identifier: string;
kind:
| "bond"
| "card"
| "stock";
};
const EntityBreadcrumbs: FC<EntityBreadcrumbsProps> = (props) => {
const { identifier, kind } = props;
const { t } = useTranslation();
const bond = useBond(kind === "bond" ? identifier : "");
const card = useBankCard(kind === "card" ? identifier : "");
const stock = useStock(kind === "stock" ? identifier : "");
let parent: Crumb;
let name: string;
if (kind === "bond") {
parent = { label: t("bond.searchTitle"), to: "/bonds" };
name = bond.data?.name ?? t("bond.instrument");
}
else if (kind === "stock") {
parent = { label: t("stock.searchTitle"), to: "/stocks" };
name = stock.data?.name ?? t("stock.share");
}
else {
parent = { label: t("bank.title"), to: "/cards" };
name = card.data?.name ?? t("bank.card");
}
return <BreadcrumbTrail items={[parent, { label: name }]} />;
};
export { EntityBreadcrumbs };
@@ -0,0 +1,25 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { useStock } from "@/entities/stock";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
type StockBreadcrumbsProps = { identifier: string };
const StockBreadcrumbs: FC<StockBreadcrumbsProps> = (props) => {
const { data } = useStock(props.identifier);
const { t } = useTranslation();
return (
<BreadcrumbTrail
items={[
{ label: t("stock.searchTitle"), to: "/stocks" },
{ label: data?.name ?? t("stock.share") },
]}
/>
);
};
export { StockBreadcrumbs };
+10 -4
View File
@@ -5,7 +5,6 @@ import {
ApiErrorCode, ApiErrorCode,
apiErrorCode, apiErrorCode,
isApiErrorCode, isApiErrorCode,
isGetUnder,
} from "@/shared/api"; } from "@/shared/api";
import type { import type {
@@ -27,14 +26,21 @@ async function mutationData<T>(
return data; return data;
} }
const bankCardGetPredicate = isGetUnder(["/api/v1/bank-cards"]);
function useBankMutationOptions() { function useBankMutationOptions() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const invalidate = () => const invalidate = () =>
queryClient.invalidateQueries({ queryClient.invalidateQueries({
predicate: ({ queryKey }) => bankCardGetPredicate(queryKey), predicate: ({ queryKey }) => {
const [method, path] = queryKey;
return (
method === "get"
&& typeof path === "string"
&& (path === "/api/v1/bank-cards"
|| path.startsWith("/api/v1/bank-cards/"))
);
},
}); });
return { return {
-2
View File
@@ -5,7 +5,5 @@ export {
type OperationCategory, type OperationCategory,
useBrokerageOperations, useBrokerageOperations,
} from "./api/useBrokerageOperations"; } from "./api/useBrokerageOperations";
export { brokerageOperationGroupKey } from "./model/display";
export { useBrokerageOperationHistory } from "./model/useBrokerageOperationHistory"; export { useBrokerageOperationHistory } from "./model/useBrokerageOperationHistory";
export { LedgerEntryRow } from "./ui/LedgerEntryRow"; export { LedgerEntryRow } from "./ui/LedgerEntryRow";
export { OperationGroup } from "./ui/OperationGroup";
@@ -1,12 +1,9 @@
import { describe, expect, it } from "vitest"; import { expect, it } from "vitest";
import type { BrokerageOperation } from "../api/useBrokerageOperations"; import { operationLabelKey } from "./display";
import { brokerageOperationGroupKey, operationLabelKey } from "./display";
it.each([ it.each([
["OPERATION_TYPE_BUY", "buy"], ["OPERATION_TYPE_BUY", "buy"],
["OPERATION_TYPE_COUPON", "other"],
["OPERATION_TYPE_BUY_CARD", "buy"], ["OPERATION_TYPE_BUY_CARD", "buy"],
["OPERATION_TYPE_BUY_MARGIN", "buy"], ["OPERATION_TYPE_BUY_MARGIN", "buy"],
["OPERATION_TYPE_SELL", "sell"], ["OPERATION_TYPE_SELL", "sell"],
@@ -24,71 +21,3 @@ it.each([
])("preserves the wire operation label for %s", (type, label) => { ])("preserves the wire operation label for %s", (type, label) => {
expect(operationLabelKey(type)).toBe(`ledger.operationType.${label}`); expect(operationLabelKey(type)).toBe(`ledger.operationType.${label}`);
}); });
describe("brokerageOperationGroupKey", () => {
const base: BrokerageOperation = {
account: { id: "account-1", name: "Брокерский" },
accruedInterest: null,
category: "income",
commission: null,
description: "Купон",
figi: "FIGI",
id: "operation",
instrumentType: "bond",
instrumentUid: "UID",
logoName: null,
occurredAt: "2026-08-14T10:00:00Z",
payment: null,
price: null,
quantity: null,
ticker: "BOND",
type: "OPERATION_TYPE_COUPON",
};
it("uses screen day, preferred identity and exact type, not account", () => {
const first = brokerageOperationGroupKey(base);
const same = brokerageOperationGroupKey({
...base,
account: { id: "account-2", name: "ИИС" },
figi: "OTHER-FIGI",
occurredAt: "2026-08-14T11:00:00Z",
});
expect(same).toBe(first);
expect(brokerageOperationGroupKey({
...base,
type: "OPERATION_TYPE_DIVIDEND",
})).not.toBe(first);
});
it("keeps buy and sell of one instrument in different groups", () => {
expect(brokerageOperationGroupKey({ ...base, type: "OPERATION_TYPE_BUY" }))
.not.toBe(brokerageOperationGroupKey({
...base,
type: "OPERATION_TYPE_SELL",
}));
});
it("separates local midnight and joins one local day across UTC dates", () => {
const before = new Date(2026, 7, 14, 23, 59).toISOString();
const after = new Date(2026, 7, 15, 0, 1).toISOString();
const morning = new Date(2026, 7, 14, 1).toISOString();
const evening = new Date(2026, 7, 14, 23).toISOString();
expect(brokerageOperationGroupKey({ ...base, occurredAt: before }))
.not.toBe(brokerageOperationGroupKey({ ...base, occurredAt: after }));
expect(brokerageOperationGroupKey({ ...base, occurredAt: morning }))
.toBe(brokerageOperationGroupKey({ ...base, occurredAt: evening }));
});
it("falls back to FIGI and leaves missing identity ungrouped", () => {
expect(brokerageOperationGroupKey({ ...base, instrumentUid: null }))
.toBe(brokerageOperationGroupKey({ ...base, instrumentUid: "" }));
expect(brokerageOperationGroupKey({
...base,
figi: null,
instrumentUid: null,
})).toBeNull();
});
});
+2 -27
View File
@@ -1,4 +1,4 @@
import { formatMoney, formatSnapshotDay } from "@/shared/lib"; import { formatMoney } from "@/shared/lib";
import type { BrokerageOperation } from "../api/useBrokerageOperations"; import type { BrokerageOperation } from "../api/useBrokerageOperations";
@@ -27,19 +27,6 @@ type PaymentPresentation = {
| "positive"; | "positive";
}; };
function brokerageOperationGroupKey(
operation: BrokerageOperation,
): null | string {
const identity = operation.instrumentUid || operation.figi;
if (!identity) return null;
return JSON.stringify([
formatSnapshotDay(operation.occurredAt),
identity,
operation.type,
]);
}
function entryLabelKey(operation: BrokerageOperation): string { function entryLabelKey(operation: BrokerageOperation): string {
if (operation.category === "income") if (operation.category === "income")
return isOperationType(operation.type, OperationType.Coupon) return isOperationType(operation.type, OperationType.Coupon)
@@ -49,10 +36,6 @@ function entryLabelKey(operation: BrokerageOperation): string {
return `ledger.category.${operation.category}`; return `ledger.category.${operation.category}`;
} }
function isCouponPayment(operation: BrokerageOperation): boolean {
return isOperationType(operation.type, OperationType.Coupon);
}
function isIncomePayment(operation: BrokerageOperation): boolean { function isIncomePayment(operation: BrokerageOperation): boolean {
return [OperationType.Coupon, OperationType.Dividend].some(type => return [OperationType.Coupon, OperationType.Dividend].some(type =>
isOperationType(operation.type, type), isOperationType(operation.type, type),
@@ -132,12 +115,4 @@ function paymentPresentation(
}; };
} }
export { export { isIncomePayment, operationLabelKey, paymentPresentation };
brokerageOperationGroupKey,
entryLabelKey,
isCouponPayment,
isIncomePayment,
isZeroDecimal,
operationLabelKey,
paymentPresentation,
};
@@ -1,138 +0,0 @@
import { MantineProvider } from "@mantine/core";
import { render, screen, within } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
import type { BrokerageOperation } from "../api/useBrokerageOperations";
import { OperationGroup } from "./OperationGroup";
function operation(
id: string,
account: { id: string; name: string },
value: null | string,
currency = "RUB",
): BrokerageOperation {
return {
account,
accruedInterest: null,
category: "income",
commission: null,
description: "Купон",
figi: "FIGI",
id,
instrumentType: "bond",
instrumentUid: "UID",
logoName: null,
occurredAt: "2026-08-14T10:00:00Z",
payment: value === null ? null : { currency, value },
price: null,
quantity: null,
ticker: "BOND",
type: "OPERATION_TYPE_COUPON",
};
}
function renderGroup(
operations: BrokerageOperation[],
isTotalVisible = true,
) {
return render(
<MantineProvider>
<MemoryRouter>
<ul>
<OperationGroup
entries={operations.map((operation, index) => ({
key: `${operation.id}-${index}`,
operation,
}))}
totalVisible={isTotalVisible}
/>
</ul>
</MemoryRouter>
</MantineProvider>,
);
}
describe("OperationGroup", () => {
it("shows the received coupon and positive totals in flat account rows", () => {
renderGroup([
{ ...operation("1", { id: "broker", name: "Брокерский" }, "0.1"), description: "Выплата купонов СФО ВТБ РКС Эталон 04" },
operation("2", { id: "iis", name: "ИИС" }, "0.2"),
]);
expect(screen.getByText("Выплата купонов СФО ВТБ РКС Эталон 04")).toBeInTheDocument();
expect(screen.getByText("+0,3 ₽")).toBeInTheDocument();
const accounts = within(screen.getByRole("list", {
name: /Выплата купонов СФО ВТБ РКС Эталон 04, Исполнено 14\.08\.2026.*, \+0,3 ₽/,
}));
expect(accounts.getByRole("listitem", { name: "Брокерский, +0,1 ₽" }))
.toHaveTextContent("Купон");
expect(accounts.getByText("+0,1 ₽")).toHaveClass(/positive/);
expect(accounts.getByRole("listitem", { name: "ИИС, +0,2 ₽" }))
.toHaveTextContent("Купон");
expect(accounts.getByText("+0,2 ₽")).toHaveClass(/positive/);
expect(accounts.getByRole("listitem", { name: "ИИС, +0,2 ₽" }))
.not.toHaveClass("EntryGroup_accountCardRow");
expect(accounts.queryByText(/Исполнено/)).not.toBeInTheDocument();
expect(accounts.queryByRole("link")).not.toBeInTheDocument();
});
it("announces the hidden total as unavailable", () => {
renderGroup([
operation("1", { id: "broker", name: "Брокерский" }, "100"),
operation("2", { id: "iis", name: "ИИС" }, null),
]);
expect(screen.getByRole("list", {
name: /Выплата купонов BOND, Исполнено .*Итог недоступен/,
})).toBeInTheDocument();
expect(screen.getByText("Итог недоступен")).toBeInTheDocument();
expect(screen.getByText("—")).not.toHaveClass(/positive/);
});
it.each(["0", "0.000"])(
"hides the whole total for a zero source amount (%s)",
(value) => {
renderGroup([
operation("1", { id: "broker", name: "Брокерский" }, value),
operation("2", { id: "iis", name: "ИИС" }, "100"),
]);
expect(screen.getAllByText("+100 ₽")).toHaveLength(1);
expect(screen.getByText("Итог недоступен")).toBeInTheDocument();
},
);
it("hides the total for incomplete data but keeps compensated zero", () => {
const { unmount } = renderGroup([
operation("1", { id: "broker", name: "Брокерский" }, "100"),
operation("2", { id: "iis", name: "ИИС" }, "100"),
], false);
expect(screen.queryByText("+200 ₽")).not.toBeInTheDocument();
unmount();
renderGroup([
operation("1", { id: "broker", name: "Брокерский" }, "100"),
operation("2", { id: "iis", name: "ИИС" }, "-100"),
]);
expect(screen.getByText("0 ₽")).toBeInTheDocument();
expect(screen.getByText("−100 ₽")).toHaveClass(/negative/);
});
it("renders repeated ids inside one group as separate rows", () => {
renderGroup([
operation("same", { id: "broker", name: "Брокерский" }, "10"),
operation("same", { id: "iis", name: "ИИС" }, "20"),
]);
expect(screen.getByText("+30 ₽")).toBeInTheDocument();
const accounts = within(screen.getByRole("list", { name: /Выплата купонов/ }));
expect(accounts.getAllByText("Брокерский")).toHaveLength(1);
expect(accounts.getAllByText("ИИС")).toHaveLength(1);
expect(accounts.getAllByRole("listitem")).toHaveLength(2);
});
});
@@ -1,113 +0,0 @@
import type { FC } from "react";
import { EntryCard } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import {
formatSnapshotDay,
instrumentLogoUrl,
sumMoney,
totalText,
} from "@/shared/lib";
import { AccountCardRow, AccountRow, EntryGroup, EntryGroupTotal } from "@/shared/ui";
import type { BrokerageOperation } from "../api/useBrokerageOperations";
import {
entryLabelKey,
isCouponPayment,
isZeroDecimal,
operationLabelKey,
paymentPresentation,
} from "../model/display";
type OperationGroupProps = {
entries: { key: string; operation: BrokerageOperation }[];
totalVisible: boolean;
};
const OperationGroup: FC<OperationGroupProps> = (props) => {
const { t } = useTranslation();
const operations = props.entries.map(entry => entry.operation);
const first = operations.at(0);
// groupItems only emits groups of at least two entries.
if (!first) return null;
const typeLabel = t(first.category === "income"
? entryLabelKey(first)
: operationLabelKey(first.type));
const isCoupon = isCouponPayment(first);
const instrument
= first.ticker ?? first.instrumentType ?? t("ledger.noInstrument");
const amounts = operations.flatMap(operation =>
operation.payment ? [operation.payment] : [],
);
const isAmountsKnown = amounts.length === operations.length
&& amounts.every(amount => !isZeroDecimal(amount.value));
const totals = isAmountsKnown && props.totalVisible
? sumMoney(amounts)
: null;
const date = formatSnapshotDay(first.occurredAt);
const description = first.description.trim();
let title = `${typeLabel} · ${instrument}`;
if (isCoupon) {
title = description && description.toLocaleLowerCase() !== "купон"
? description
: t("ledger.couponPayment", { instrument });
}
const totalsText = totalText(totals, true);
const totalTone = isCoupon && totals && !isZeroDecimal(totals.value) && !totals.value.startsWith("-")
? "positive"
: "neutral";
const accessibleName = [
title,
t("payout.executedAt", { date }),
totalsText || t("entryGroup.totalUnavailable"),
].join(", ");
return (
<EntryGroup
flat={isCoupon}
header={(
<EntryCard
imageSrc={instrumentLogoUrl(first.logoName)}
initial={(first.ticker?.trim()[0] ?? "?").toUpperCase()}
metadata={t("payout.executedAt", { date })}
title={title}
tone={totalTone}
value={<EntryGroupTotal isSigned totals={totals} />}
valueLabel={typeLabel}
wrap
/>
)}
label={accessibleName}
>
{props.entries.map(({ key, operation }) => {
const payment = paymentPresentation(operation);
const accountRowProps = {
accountName: operation.account?.name ?? "—",
value: payment.amount,
valueLabel: t(payment.label),
};
return isCoupon
? <AccountRow key={key} {...accountRowProps} tone={payment.tone} />
: <AccountCardRow key={key} {...accountRowProps} />;
})}
</EntryGroup>
);
};
export { OperationGroup };
@@ -4,6 +4,8 @@ import type {
BrokerageAccount, BrokerageAccount,
} from "../api/useBrokerageAccounts"; } from "../api/useBrokerageAccounts";
const VISIBLE_CURRENCY_LIMIT = 2;
type BrokerageAccountCard = { type BrokerageAccountCard = {
account: BrokerageAccount; account: BrokerageAccount;
positionCounts: null | PositionCounts; positionCounts: null | PositionCounts;
@@ -11,13 +13,18 @@ type BrokerageAccountCard = {
summary: summary:
| { | {
portfolio: NonNullable<BrokerageAccount["portfolioSummary"]>; portfolio: NonNullable<BrokerageAccount["portfolioSummary"]>;
remainingCurrencyCount: number;
state: "available"; state: "available";
totals: NonNullable<BrokerageAccount["portfolioSummary"]>["totals"]; totals: Money[];
} }
| { state: "empty" } | { state: "empty" }
| { state: "unavailable" }; | { state: "unavailable" };
}; };
type Money = NonNullable<
BrokerageAccount["portfolioSummary"]
>["totalsByCurrency"][number];
type PositionCounts = { bonds: number; stocks: number; total: number }; type PositionCounts = { bonds: number; stocks: number; total: number };
function accountTypeKey(type: BrokerageAccount["type"]) { function accountTypeKey(type: BrokerageAccount["type"]) {
@@ -50,7 +57,7 @@ function brokerageAccountCards(
total: summary.totalPositionCount, total: summary.totalPositionCount,
}; };
if (Number(summary.totals.value) === 0) if (summary.totalsByCurrency.length === 0)
return { account, positionCounts, source, summary: { state: "empty" } }; return { account, positionCounts, source, summary: { state: "empty" } };
return { return {
@@ -59,8 +66,12 @@ function brokerageAccountCards(
source, source,
summary: { summary: {
portfolio: summary, portfolio: summary,
remainingCurrencyCount: Math.max(
summary.totalsByCurrency.length - VISIBLE_CURRENCY_LIMIT,
0,
),
state: "available", state: "available",
totals: summary.totals, totals: summary.totalsByCurrency.slice(0, VISIBLE_CURRENCY_LIMIT),
}, },
}; };
}); });
@@ -65,32 +65,18 @@
.summary { .summary {
font-variant-numeric: tabular-nums lining-nums; font-variant-numeric: tabular-nums lining-nums;
align-items: flex-end; white-space: nowrap;
}
.currencySummary {
min-width: 0;
} }
.total { .total {
color: var(--wl-color-text); color: var(--wl-color-text);
font-size: var(--mantine-font-size-lg); font-size: var(--mantine-font-size-lg);
line-height: var(--mantine-line-height-lg); line-height: var(--mantine-line-height-lg);
overflow-wrap: anywhere;
} }
.return, .return,
.cashValue { .cashValue {
font-variant-numeric: tabular-nums lining-nums; font-variant-numeric: tabular-nums lining-nums;
}
.return {
justify-content: flex-end;
min-width: 0;
overflow-wrap: anywhere;
}
.cashValue {
white-space: nowrap; white-space: nowrap;
} }
@@ -152,11 +138,7 @@
text-align: left; text-align: left;
} }
.summary { .balance > div {
align-items: flex-start;
}
.return {
justify-content: flex-start; justify-content: flex-start;
} }
} }
@@ -14,29 +14,36 @@ type PortfolioSummary = NonNullable<BrokerageAccount["portfolioSummary"]>;
const rubReturn = { const rubReturn = {
absolute: "136900", absolute: "136900",
costBasis: "1103600.50", costBasis: "1103600.50",
currency: "RUB",
partial: false, partial: false,
percent: "12.40", percent: "12.40",
}; };
const portfolioSummary = { const portfolioSummary = {
bondPositionCount: 12, bondPositionCount: 12,
cash: { currency: "RUB", value: "82500.50" }, cashBalances: [
{ currency: "RUB", value: "82500.50" },
{ currency: "USD", value: "1500" },
],
fundPositionCount: 2, fundPositionCount: 2,
stockPositionCount: 5, stockPositionCount: 5,
structure: { structure: {
currencies: [{
categories: [ categories: [
{ sharePercent: "55.000000", type: "STOCK", value: "682500" }, { sharePercent: "55.000000", type: "STOCK", value: "682500" },
{ sharePercent: "25.000000", type: "BOND", value: "310500" }, { sharePercent: "25.000000", type: "BOND", value: "310500" },
{ sharePercent: "10.000000", type: "OTHER", value: "124200" }, { sharePercent: "10.000000", type: "OTHER", value: "124200" },
{ sharePercent: "10.000000", type: "CASH", value: "124200" }, { sharePercent: "10.000000", type: "CASH", value: "124200" },
], ],
currency: "RUB",
hasNegativeValues: false, hasNegativeValues: false,
totalValue: "1241400", totalValue: "1241400",
}],
unvaluedCategories: [], unvaluedCategories: [],
}, },
totalPositionCount: 19, totalPositionCount: 19,
totals: { currency: "RUB", value: "1240500.50" }, totalsByCurrency: [{ currency: "RUB", value: "1240500.50" }],
unrealizedReturn: rubReturn, unrealizedReturnByCurrency: [rubReturn],
} satisfies PortfolioSummary; } satisfies PortfolioSummary;
function account( function account(
@@ -56,25 +63,20 @@ function account(
const negativeSummary: PortfolioSummary = { const negativeSummary: PortfolioSummary = {
...portfolioSummary, ...portfolioSummary,
unrealizedReturn: { unrealizedReturnByCurrency: [{
...rubReturn, ...rubReturn,
absolute: "-4200", absolute: "-4200",
percent: "-3.05", percent: "-3.05",
}, }],
}; };
const emptySummary: PortfolioSummary = { const emptySummary: PortfolioSummary = {
...portfolioSummary, ...portfolioSummary,
cash: { currency: "RUB", value: "0" }, cashBalances: [],
structure: { structure: { currencies: [], unvaluedCategories: [] },
categories: [],
hasNegativeValues: false,
totalValue: "0",
unvaluedCategories: [],
},
totalPositionCount: 0, totalPositionCount: 0,
totals: { currency: "RUB", value: "0" }, totalsByCurrency: [],
unrealizedReturn: rubReturn, unrealizedReturnByCurrency: [],
}; };
const meta = { const meta = {
@@ -92,7 +94,7 @@ const meta = {
docs: { docs: {
description: { description: {
component: component:
"Карточки счетов: стоимость и доходность, деньги, структура и свежесть источника. Денежные и процентные значения остаются строками. Все примеры содержат вымышленные данные; ссылки работают только внутри MemoryRouter.", "Карточки счетов: стоимость и доходность по валютам, деньги, структура и свежесть источника. Денежные и процентные значения остаются строками. Все примеры содержат вымышленные данные; ссылки работают только внутри MemoryRouter.",
}, },
}, },
}, },
@@ -106,6 +108,40 @@ type Story = StoryObj<typeof meta>;
export const Normal: Story = {}; export const Normal: Story = {};
export const MultiCurrency: Story = {
args: {
accounts: brokerageAccountCards([account("multi", "Мультивалютный", {
...portfolioSummary,
cashBalances: [
{ currency: "RUB", value: "82500.50" },
{ currency: "USD", value: "1500" },
],
totalsByCurrency: [
{ currency: "RUB", value: "1240500.50" },
{ currency: "USD", value: "3200" },
{ currency: "EUR", value: "900" },
],
unrealizedReturnByCurrency: [
...portfolioSummary.unrealizedReturnByCurrency,
{
absolute: "20",
costBasis: "3180",
currency: "USD",
partial: false,
percent: "2.10",
},
{
absolute: "0",
costBasis: "900",
currency: "EUR",
partial: false,
percent: null,
},
],
})]),
},
};
export const Negative: Story = { export const Negative: Story = {
args: { args: {
accounts: brokerageAccountCards([ accounts: brokerageAccountCards([
@@ -118,10 +154,10 @@ export const Partial: Story = {
args: { args: {
accounts: brokerageAccountCards([account("partial", "Частичные данные", { accounts: brokerageAccountCards([account("partial", "Частичные данные", {
...portfolioSummary, ...portfolioSummary,
unrealizedReturn: { unrealizedReturnByCurrency: [{
...rubReturn, ...rubReturn,
partial: true, partial: true,
}, }],
})]), })]),
}, },
}; };
@@ -14,28 +14,55 @@ type PortfolioSummary = NonNullable<BrokerageAccount["portfolioSummary"]>;
const portfolioSummary = { const portfolioSummary = {
bondPositionCount: 12, bondPositionCount: 12,
cash: { currency: "RUB", value: "82500.5" }, cashBalances: [
{ currency: "RUB", value: "82500.5" },
{ currency: "USD", value: "20" },
],
fundPositionCount: 0, fundPositionCount: 0,
stockPositionCount: 5, stockPositionCount: 5,
structure: { structure: {
currencies: [{
categories: [ categories: [
{ sharePercent: "50.000000", type: "STOCK", value: "100" }, { sharePercent: "50.000000", type: "STOCK", value: "100" },
{ sharePercent: "30.000000", type: "BOND", value: "60" }, { sharePercent: "30.000000", type: "BOND", value: "60" },
{ sharePercent: "0.000000", type: "OTHER", value: "0" }, { sharePercent: "0.000000", type: "OTHER", value: "0" },
{ sharePercent: "20.000000", type: "CASH", value: "40" }, { sharePercent: "20.000000", type: "CASH", value: "40" },
], ],
currency: "RUB",
hasNegativeValues: false, hasNegativeValues: false,
totalValue: "200", totalValue: "200",
}],
unvaluedCategories: [], unvaluedCategories: [],
}, },
totalPositionCount: 17, totalPositionCount: 17,
totals: { currency: "RUB", value: "123456789012345678901.995" }, totalsByCurrency: [
unrealizedReturn: { { currency: "RUB", value: "123456789012345678901.995" },
{ currency: "USD", value: "20" },
{ currency: "EUR", value: "5" },
],
unrealizedReturnByCurrency: [
{
absolute: "136900", absolute: "136900",
costBasis: "1000000", costBasis: "1000000",
currency: "RUB",
partial: true, partial: true,
percent: "12.40", percent: "12.40",
}, },
{
absolute: "-4.2",
costBasis: "24.2",
currency: "USD",
partial: false,
percent: "-3.05",
},
{
absolute: "0",
costBasis: "5",
currency: "EUR",
partial: false,
percent: null,
},
],
} satisfies PortfolioSummary; } satisfies PortfolioSummary;
type BrokerageAccountCardInput = Parameters<typeof BrokerageAccountList>[0][ type BrokerageAccountCardInput = Parameters<typeof BrokerageAccountList>[0][
@@ -70,19 +97,16 @@ describe("BrokerageAccountList", () => {
it("renders returns, cash and structure with precise totals", () => { it("renders returns, cash and structure with precise totals", () => {
const nullReturnSummary: PortfolioSummary = { const nullReturnSummary: PortfolioSummary = {
...portfolioSummary, ...portfolioSummary,
structure: { cashBalances: [],
categories: [], structure: { currencies: [], unvaluedCategories: [] },
hasNegativeValues: false, totalsByCurrency: [{ currency: "EUR", value: "5" }],
totalValue: "0", unrealizedReturnByCurrency: [{
unvaluedCategories: [],
},
totals: { currency: "RUB", value: "5" },
unrealizedReturn: {
absolute: "0", absolute: "0",
costBasis: "5", costBasis: "5",
currency: "EUR",
partial: false, partial: false,
percent: null, percent: null,
}, }],
}; };
renderList(brokerageAccountCards([ renderList(brokerageAccountCards([
@@ -94,38 +118,38 @@ describe("BrokerageAccountList", () => {
expect(link).toHaveAttribute("href", "/accounts/account-1"); expect(link).toHaveAttribute("href", "/accounts/account-1");
expect(within(link).getByText("123 456 789 012 345 678 902,00 ₽")) expect(within(link).getByText("123 456 789 012 345 678 902,00 ₽"))
.toBeInTheDocument(); .toBeInTheDocument();
expect(within(link).getByText("+12,4 %")).toBeInTheDocument(); expect(within(link).getByText("Еще 1 валюта")).toBeInTheDocument();
expect(within(link).getByText("+12,40 %")).toBeInTheDocument();
expect(within(link).getByText("+136 900 ₽")).toBeInTheDocument(); expect(within(link).getByText("+136 900 ₽")).toBeInTheDocument();
expect(within(link).getByText("−3,05 %")).toBeInTheDocument();
expect(within(link).getByText("−4,2 USD")).toBeInTheDocument();
const nullReturnLink = screen.getByRole("link", { name: /Без доходности/ }); const nullReturnLink = screen.getByRole("link", { name: /Без доходности/ });
expect(within(nullReturnLink).getByText("—")).toBeInTheDocument(); expect(within(nullReturnLink).getByText("—")).toBeInTheDocument();
expect(within(link).getByText("частично")).toBeInTheDocument(); expect(within(link).getByText("частично")).toBeInTheDocument();
expect(within(link).getByText("82 500,50 ₽")).toBeInTheDocument(); expect(within(link).getByText("82 500,50 ₽")).toBeInTheDocument();
expect(within(link).getAllByText("20,00 USD")).not.toHaveLength(0);
expect(within(link).getByText("17 позиций")).toBeInTheDocument(); expect(within(link).getByText("17 позиций")).toBeInTheDocument();
expect(screen.getByRole("img", { expect(screen.getByRole("img", {
name: "Структура: Акции 50,0 %; Облигации 30,0 %; " name: "Структура в RUB: Акции 50 %; Облигации 30 %; "
+ "Другие активы 0,0 %; Свободные деньги 20,0 %", + "Другие активы 0 %; Свободные деньги 20 %",
})).toBeInTheDocument(); })).toBeInTheDocument();
const stockLegend = within(link).getByText(/Акции 50,0 %/); const stockLegend = within(link).getByText(/Акции 50 %/);
expect(stockLegend).toHaveTextContent("● Акции 50,0 %"); expect(stockLegend).toHaveTextContent("● Акции 50 %");
// eslint-disable-next-line testing-library/no-node-access -- The legend is aria-hidden, so only DOM traversal reaches its container. // eslint-disable-next-line testing-library/no-node-access -- The legend is aria-hidden, so only DOM traversal reaches its container.
expect(stockLegend.parentElement).toHaveAttribute("aria-hidden", "true"); expect(stockLegend.parentElement).toHaveAttribute("aria-hidden", "true");
expect(within(link).queryByText(/Другие активы 0,0 %/)) expect(within(link).queryByText(/Другие активы 0 %/)).not.toBeInTheDocument();
.not.toBeInTheDocument();
expect(link).toHaveTextContent("По данным брокера"); expect(link).toHaveTextContent("По данным брокера");
expect(link).toHaveTextContent("Структура: Акции 100"); expect(link).toHaveTextContent("Структура RUB: Акции 100");
}); });
it("preserves empty, unavailable and stale states", () => { it("preserves empty, unavailable and stale states", () => {
const emptySummary: PortfolioSummary = { const emptySummary: PortfolioSummary = {
...portfolioSummary, ...portfolioSummary,
structure: { cashBalances: [],
categories: [], structure: { currencies: [], unvaluedCategories: [] },
hasNegativeValues: false,
totalValue: "0",
unvaluedCategories: [],
},
totalPositionCount: 0, totalPositionCount: 0,
totals: { currency: "RUB", value: "0" }, totalsByCurrency: [],
unrealizedReturnByCurrency: [],
}; };
const cards = brokerageAccountCards([ const cards = brokerageAccountCards([
@@ -15,11 +15,6 @@ import classes from "./BrokerageAccountList.module.css";
const DECIMAL_PART_COUNT = 2; const DECIMAL_PART_COUNT = 2;
const STRUCTURE_CATEGORY_ORDER = ["STOCK", "BOND", "OTHER", "CASH"] as const; const STRUCTURE_CATEGORY_ORDER = ["STOCK", "BOND", "OTHER", "CASH"] as const;
const percentFormatter = new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 1,
minimumFractionDigits: 1,
});
type AvailableSummary = Extract< type AvailableSummary = Extract<
BrokerageAccountCard["summary"], BrokerageAccountCard["summary"],
{ state: "available" } { state: "available" }
@@ -34,23 +29,28 @@ type BrokerageAccountSummaryProps = {
type PortfolioSummary = AvailableSummary["portfolio"]; type PortfolioSummary = AvailableSummary["portfolio"];
type ReturnValueProps = { type ReturnValueProps = {
item: PortfolioSummary["unrealizedReturn"]; item: PortfolioSummary["unrealizedReturnByCurrency"][number];
}; };
type StructureCurrency = PortfolioSummary["structure"]; type StructureCurrency = PortfolioSummary["structure"]["currencies"][number];
function formatPercent(value: null | string) { function formatPercent(value: null | string) {
if (value === null) return "—"; if (value === null) return "—";
const rounded = percentFormatter.format(Math.abs(Number(value))); const formatted = `${value.replace(".", ",")}\u{A0}%`;
let sign = ""; if (isZero(value))
if (rounded !== "0,0") sign = value.startsWith("-") ? "−" : "+"; return value.startsWith("-") ? formatted.slice(1) : formatted;
return `${sign}${rounded}\u{A0}%`; return value.startsWith("-") ? `−${formatted.slice(1)}` : `+${formatted}`;
} }
function formatStructurePercent(value: string) { function formatStructurePercent(value: string) {
return percentFormatter.format(Number(value)); let trimmed = value;
while (trimmed.includes(".") && trimmed.endsWith("0"))
trimmed = trimmed.slice(0, -1);
if (trimmed.endsWith(".")) trimmed = trimmed.slice(0, -1);
return trimmed.replace(".", ",");
} }
function isZero(value: string) { function isZero(value: string) {
@@ -65,23 +65,27 @@ const ReturnValue: FC<ReturnValueProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { item } = props; const { item } = props;
const displayedPercent = formatPercent(item.percent); const isNegative = item.percent !== null
const isNegative = displayedPercent.startsWith("−"); && item.percent.startsWith("-")
const isPositive = displayedPercent.startsWith("+"); && !isZero(item.percent);
const isPositive = item.percent !== null
&& !item.percent.startsWith("-")
&& !isZero(item.percent);
let toneClass = classes.neutral; let toneClass = classes.neutral;
if (isNegative) toneClass = classes.negative; if (isNegative) toneClass = classes.negative;
else if (isPositive) toneClass = classes.positive; else if (isPositive) toneClass = classes.positive;
return ( return (
<Group className={classes.return} gap="xs" wrap="wrap"> <Group className={classes.return} gap="xs" wrap="nowrap">
<Text className={toneClass} fw={700} size="sm"> <Text className={toneClass} fw={700} size="sm">
{displayedPercent} {formatPercent(item.percent)}
</Text> </Text>
<Text className={toneClass} size="sm"> <Text className={toneClass} size="sm">
{formatMoney( {formatMoney(
{ currency: "RUB", value: item.absolute }, { currency: item.currency, value: item.absolute },
{ withSign: true }, { withSign: true },
)} )}
</Text> </Text>
@@ -119,6 +123,7 @@ const StructureBar: FC<StructureBarProps> = (props) => {
}).join("; "); }).join("; ");
const label = t("accounts.structure.label", { const label = t("accounts.structure.label", {
currency: props.item.currency,
value: description, value: description,
}); });
@@ -195,25 +200,51 @@ const BrokerageAccountSummary: FC<BrokerageAccountSummaryProps> = (props) => {
return ( return (
<Stack className={classes.balance} gap="xs"> <Stack className={classes.balance} gap="xs">
<Stack className={classes.summary} gap="sm"> <Group align="flex-start" gap="lg" justify="flex-end" wrap="nowrap">
<Stack className={classes.currencySummary} gap={2}> <Stack className={classes.summary} gap={4}>
<Text className={classes.total} fw={700}> {summary.totals.map(total => (
{formatMoney(summary.totals, { withRoundedCents: true })} <Text className={classes.total} fw={700} key={total.currency}>
{formatMoney(total, { withRoundedCents: true })}
</Text> </Text>
))}
<ReturnValue item={summary.portfolio.unrealizedReturn} /> {!!summary.remainingCurrencyCount && (
</Stack> <Text c="dimmed" size="sm">
{t("accounts.remainingCurrencies", {
count: summary.remainingCurrencyCount,
})}
</Text>
)}
</Stack> </Stack>
<Stack gap={4}>
{summary.totals.map((total) => {
const item = summary.portfolio.unrealizedReturnByCurrency.find(
returnItem => returnItem.currency === total.currency,
);
return item
? <ReturnValue item={item} key={total.currency} />
: null;
})}
</Stack>
</Group>
{summary.portfolio.cashBalances.length > 0 && (
<Group className={classes.cash} gap="xs" justify="flex-end"> <Group className={classes.cash} gap="xs" justify="flex-end">
<Text c="dimmed" size="xs"> <Text c="dimmed" size="xs">
{t("accounts.cash")} {t("accounts.cash")}
</Text> </Text>
<Text className={classes.cashValue} size="sm"> <Stack gap={0}>
{formatMoney(summary.portfolio.cash, { withRoundedCents: true })} {summary.portfolio.cashBalances.map(cash => (
<Text className={classes.cashValue} key={cash.currency} size="sm">
{formatMoney(cash, { withRoundedCents: true })}
</Text> </Text>
))}
</Stack>
</Group> </Group>
)}
</Stack> </Stack>
); );
}; };
@@ -260,31 +291,39 @@ const AccountDescription: FC<AccountDescriptionProps> = (props) => {
const { account, summary } = props.account; const { account, summary } = props.account;
if (summary.state !== "available") return null; if (summary.state !== "available") return null;
const returns = [t("accounts.description.return", { const returns = summary.portfolio.unrealizedReturnByCurrency.map(item =>
t("accounts.description.return", {
absolute: formatMoney( absolute: formatMoney(
{ currency: "RUB", value: summary.portfolio.unrealizedReturn.absolute }, { currency: item.currency, value: item.absolute },
{ withSign: true }, { withSign: true },
), ),
percent: formatPercent(summary.portfolio.unrealizedReturn.percent), currency: item.currency,
})]; percent: formatPercent(item.percent),
}),
);
const structures = (() => { const structures = summary.portfolio.structure.currencies.map((item) => {
const categories = summary.portfolio.structure.categories.map(category => const categories = item.categories.map(category =>
t("accounts.description.structureCategory", { t("accounts.description.structureCategory", {
category: t(portfolioCategoryPresentation[category.type].label), category: t(portfolioCategoryPresentation[category.type].label),
value: formatMoney( value: formatMoney(
{ currency: "RUB", value: category.value }, { currency: item.currency, value: category.value },
{ withRoundedCents: true }, { withRoundedCents: true },
), ),
}), }),
).join(", "); ).join(", ");
return t("accounts.description.structure", { value: categories }); return t("accounts.description.structure", {
})(); currency: item.currency,
value: categories,
});
});
const hasPartialReturn = summary.portfolio.unrealizedReturn.partial; const hasPartialReturn = summary.portfolio.unrealizedReturnByCurrency.some(
item => item.partial,
);
const details = [returns, structures].join(". "); const details = [...returns, ...structures].join(". ");
return ( return (
<VisuallyHidden> <VisuallyHidden>
@@ -293,7 +332,8 @@ const AccountDescription: FC<AccountDescriptionProps> = (props) => {
type: t(accountTypeKey(account.type)), type: t(accountTypeKey(account.type)),
})} })}
{t("accounts.description.currentValue", { {t("accounts.description.currentValue", {
value: formatMoney(summary.portfolio.totals), value: summary.portfolio.totalsByCurrency
.map(total => formatMoney(total)).join(", "),
})} })}
{details ? `${details}. ` : ""} {details ? `${details}. ` : ""}
{hasPartialReturn ? t("accounts.brokerDescription") : ""} {hasPartialReturn ? t("accounts.brokerDescription") : ""}
@@ -333,7 +373,9 @@ const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
{summary.state === "available" && ( {summary.state === "available" && (
<Stack className={classes.structure} gap={4}> <Stack className={classes.structure} gap={4}>
<StructureBar item={summary.portfolio.structure} /> {summary.portfolio.structure.currencies.map(item => (
<StructureBar item={item} key={item.currency} />
))}
</Stack> </Stack>
)} )}
+1 -2
View File
@@ -2,9 +2,8 @@ export {
type BrokerageEvent, type BrokerageEvent,
useBrokerageEvents, useBrokerageEvents,
} from "./api/useBrokerageEvents"; } from "./api/useBrokerageEvents";
export { brokerageEventGroupKey, brokerageEventKey } from "./model/event-key"; export { brokerageEventKey } from "./model/event-key";
export { BrokerageEventCard } from "./ui/BrokerageEventCard"; export { BrokerageEventCard } from "./ui/BrokerageEventCard";
export { BrokerageEventGroup } from "./ui/BrokerageEventGroup";
export { export {
BrokerageEventsPreview, BrokerageEventsPreview,
type BrokerageEventsPreviewState, type BrokerageEventsPreviewState,
@@ -1,57 +0,0 @@
import { describe, expect, it } from "vitest";
import type { BrokerageEvent } from "../api/useBrokerageEvents";
import { brokerageEventGroupKey } from "./event-key";
const base: BrokerageEvent = {
account: { id: "account-1", name: "Брокерский" },
date: "2026-08-20",
dateKind: "event",
displayName: "ОФЗ 26238",
figi: "FIGI",
instrumentUid: "UID",
isin: "RU000A0JX0J2",
logoName: null,
projectedAmount: { currency: "RUB", value: "100" },
ticker: "OFZ",
type: "coupon",
};
describe("brokerageEventGroupKey", () => {
it("uses date, preferred identity, exact type and dateKind", () => {
const key = brokerageEventGroupKey(base);
expect(brokerageEventGroupKey({
...base,
account: { id: "account-2", name: "ИИС" },
figi: "OTHER-FIGI",
})).toBe(key);
expect(brokerageEventGroupKey({ ...base, date: "2026-08-21" }))
.not.toBe(key);
expect(brokerageEventGroupKey({ ...base, dateKind: "registry" }))
.not.toBe(key);
expect(brokerageEventGroupKey({ ...base, type: "dividend" }))
.not.toBe(key);
});
it("falls back through FIGI and ISIN and rejects missing identity", () => {
expect(brokerageEventGroupKey({ ...base, instrumentUid: null }))
.toBe(brokerageEventGroupKey({ ...base, instrumentUid: "" }));
expect(brokerageEventGroupKey({
...base,
figi: null,
instrumentUid: null,
})).toBe(brokerageEventGroupKey({
...base,
figi: "",
instrumentUid: null,
}));
expect(brokerageEventGroupKey({
...base,
figi: null,
instrumentUid: null,
isin: null,
})).toBeNull();
});
});
@@ -1,13 +1,5 @@
import type { BrokerageEvent } from "../api/useBrokerageEvents"; import type { BrokerageEvent } from "../api/useBrokerageEvents";
function brokerageEventGroupKey(event: BrokerageEvent): null | string {
const identity = event.instrumentUid || event.figi || event.isin;
return identity
? JSON.stringify([event.date, identity, event.type, event.dateKind])
: null;
}
function brokerageEventKey(event: BrokerageEvent) { function brokerageEventKey(event: BrokerageEvent) {
return JSON.stringify([ return JSON.stringify([
event.account?.id, event.type, event.date, event.instrumentUid, event.account?.id, event.type, event.date, event.instrumentUid,
@@ -15,4 +7,4 @@ function brokerageEventKey(event: BrokerageEvent) {
]); ]);
} }
export { brokerageEventGroupKey, brokerageEventKey }; export { brokerageEventKey };
@@ -1,122 +0,0 @@
import { MantineProvider } from "@mantine/core";
import { render, screen, within } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
import type { BrokerageEvent } from "../api/useBrokerageEvents";
import { BrokerageEventGroup } from "./BrokerageEventGroup";
function event(
account: { id: string; name: string },
value: null | string,
currency = "RUB",
): BrokerageEvent {
return {
account,
date: "2026-08-20",
dateKind: "registry",
displayName: "ОФЗ 26238",
figi: "FIGI",
instrumentUid: "UID",
isin: "RU000A0JX0J2",
logoName: null,
projectedAmount: value === null ? null : { currency, value },
ticker: "OFZ",
type: "coupon",
};
}
function renderGroup(
events: [BrokerageEvent, BrokerageEvent],
isTotalVisible = true,
) {
return render(
<MantineProvider>
<MemoryRouter>
<ul>
<BrokerageEventGroup
events={events}
totalVisible={isTotalVisible}
/>
</ul>
</MemoryRouter>
</MantineProvider>,
);
}
describe("BrokerageEventGroup", () => {
it("shows an unsigned forecast total and every account forecast", () => {
renderGroup([
event({ id: "broker", name: "Брокерский" }, "100.5"),
event({ id: "iis", name: "ИИС" }, "49.5"),
]);
expect(screen.getByText("Купон · ОФЗ 26238")).toBeInTheDocument();
expect(screen.getByText("150 ₽")).toBeInTheDocument();
expect(screen.getAllByText("Прогноз").length).toBeGreaterThanOrEqual(3);
const accounts = within(screen.getByRole("list", {
name: /Купон · ОФЗ 26238, 20\.08\.2026 · Дата реестра, 150 ₽/,
}));
expect(accounts.getByText("Брокерский")).toBeInTheDocument();
expect(accounts.getByText("ИИС")).toBeInTheDocument();
expect(accounts.getByText("100,5 ₽")).toBeInTheDocument();
expect(accounts.getByText("49,5 ₽")).toBeInTheDocument();
expect(accounts.queryByRole("link")).not.toBeInTheDocument();
});
it("announces an unknown total as unavailable", () => {
renderGroup([
event({ id: "broker", name: "Брокерский" }, null),
event({ id: "iis", name: "ИИС" }, "49.5"),
]);
expect(screen.getByRole("list", {
name: /Купон · ОФЗ 26238.*Итог недоступен/,
})).toBeInTheDocument();
expect(screen.getByText("Сумма неизвестна")).toBeInTheDocument();
});
it("hides the total for incomplete data", () => {
renderGroup([
event({ id: "broker", name: "Брокерский" }, "100.5"),
event({ id: "iis", name: "ИИС" }, "49.5"),
], false);
expect(screen.queryByText("150 ₽")).not.toBeInTheDocument();
});
it("allows zero source forecasts to produce a visible zero total", () => {
renderGroup([
event({ id: "broker", name: "Брокерский" }, "0"),
event({ id: "iis", name: "ИИС" }, "0.00"),
]);
expect(screen.getAllByText("0 ₽")).toHaveLength(2);
expect(screen.getByText("0,00 ₽")).toBeInTheDocument();
});
it("sums forecast totals exactly", () => {
renderGroup([
event({ id: "broker", name: "Брокерский" }, "10"),
event({ id: "iis", name: "ИИС" }, "200"),
]);
expect(screen.getByRole("list", { name: /210 ₽/ }))
.toBeInTheDocument();
});
it("shows an offer group without a total", () => {
renderGroup([
{ ...event({ id: "broker", name: "Брокерский" }, null), type: "offer" },
{ ...event({ id: "iis", name: "ИИС" }, null), type: "offer" },
]);
const accounts = within(screen.getByRole("list", {
name: /Итог недоступен/,
}));
expect(accounts.getAllByRole("listitem")).toHaveLength(2);
});
});
@@ -1,87 +0,0 @@
import type { FC } from "react";
import { EntryCard } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import {
formatDate,
formatMoney,
instrumentLogoUrl,
sumMoney,
totalText,
} from "@/shared/lib";
import { AccountRow, EntryGroup, EntryGroupTotal } from "@/shared/ui";
import type { BrokerageEvent } from "../api/useBrokerageEvents";
import { brokerageEventKey } from "../model/event-key";
type BrokerageEventGroupProps = {
events: [BrokerageEvent, BrokerageEvent, ...BrokerageEvent[]];
totalVisible: boolean;
};
const BrokerageEventGroup: FC<BrokerageEventGroupProps> = (props) => {
const { t } = useTranslation();
const [first] = props.events;
const typeLabel = t(`events.type.${first.type}`);
const amounts = props.events.flatMap(event =>
event.projectedAmount ? [event.projectedAmount] : [],
);
const totals = props.totalVisible && amounts.length === props.events.length
? sumMoney(amounts)
: null;
const date = formatDate(first.date);
const metadata = [
date,
first.dateKind === "registry" ? t("events.dateKind.registry") : null,
].filter(Boolean).join(" · ");
const title = `${typeLabel} · ${first.displayName}`;
const totalsText = totalText(totals, false);
const accessibleName = [
title,
metadata,
totalsText || t("entryGroup.totalUnavailable"),
].join(", ");
return (
<EntryGroup
header={(
<EntryCard
imageSrc={instrumentLogoUrl(first.logoName)}
initial={(first.ticker.trim()[0] ?? "?").toUpperCase()}
metadata={metadata}
title={title}
tone="neutral"
value={<EntryGroupTotal totals={totals} />}
valueLabel={t("payout.forecast")}
wrap
/>
)}
label={accessibleName}
>
{props.events.map((event) => {
const value = event.projectedAmount
? formatMoney(event.projectedAmount)
: t("events.noAmount");
return (
<AccountRow
accountName={event.account?.name ?? "—"}
key={brokerageEventKey(event)}
value={value}
valueLabel={t("payout.forecast")}
/>
);
})}
</EntryGroup>
);
};
export { BrokerageEventGroup };
@@ -5,16 +5,12 @@ import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import { groupItems } from "@/shared/lib";
import type { BrokerageEvent } from "../../api/useBrokerageEvents"; import type { BrokerageEvent } from "../../api/useBrokerageEvents";
import { brokerageEventGroupKey, brokerageEventKey } from "../../model/event-key"; import { brokerageEventKey } from "../../model/event-key";
import { BrokerageEventCard } from "../BrokerageEventCard"; import { BrokerageEventCard } from "../BrokerageEventCard";
import { BrokerageEventGroup } from "../BrokerageEventGroup";
type BrokerageEventsPreviewProps = { type BrokerageEventsPreviewProps = {
allAccounts?: boolean;
events: BrokerageEventsPreviewState; events: BrokerageEventsPreviewState;
moreLabel?: string; moreLabel?: string;
}; };
@@ -24,7 +20,6 @@ type BrokerageEventsPreviewState
items: BrokerageEvent[]; items: BrokerageEvent[];
state: "loaded" | "partial"; state: "loaded" | "partial";
to: string; to: string;
totalVisible?: boolean;
} }
| { state: "empty" | { state: "empty"
| "error" | "error"
@@ -48,32 +43,13 @@ const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
{events.state === "partial" && <Text role="status">{t("investment.partial")}</Text>} {events.state === "partial" && <Text role="status">{t("investment.partial")}</Text>}
{(events.state === "loaded" || events.state === "partial") && ( {(events.state === "loaded" || events.state === "partial")
<Stack && events.items.map(event => (
component="ul" <BrokerageEventCard
gap="sm" event={event}
m={0} key={brokerageEventKey(event)}
p={0}
style={{ listStyle: "none" }}
>
{groupItems(
events.items,
props.allAccounts ? brokerageEventGroupKey : () => null,
).map(entry => entry.type === "group"
? (
<BrokerageEventGroup
events={entry.items}
key={entry.key}
totalVisible={events.totalVisible === true}
/> />
)
: (
<li key={brokerageEventKey(entry.item)}>
<BrokerageEventCard event={entry.item} />
</li>
))} ))}
</Stack>
)}
<Button component={Link} to={events.to} variant="subtle"> <Button component={Link} to={events.to} variant="subtle">
{props.moreLabel ?? t("events.open")} {props.moreLabel ?? t("events.open")}
@@ -1,20 +0,0 @@
import { apiQueryClient, type paths, useAuthenticatedQuery } from "@/shared/api";
type Performance = NonNullable<
paths["/api/v1/brokerage-accounts/{accountId}/performance"]["get"]["responses"][200]["content"]["application/json"]["data"]
>;
function usePerformance(accountId: string) {
return useAuthenticatedQuery({
meta: { accessGroup: "brokerage" },
...apiQueryClient.queryOptions(
"get",
"/api/v1/brokerage-accounts/{accountId}/performance",
{ params: { path: { accountId } } },
),
enabled: Boolean(accountId),
staleTime: 30_000,
});
}
export { type Performance, usePerformance };
-1
View File
@@ -1 +0,0 @@
export { type Performance, usePerformance } from "./api/usePerformance";
+1 -1
View File
@@ -2,7 +2,7 @@ import type { Portfolio } from "../api/usePortfolio";
const PERCENT_SCALE = 100; const PERCENT_SCALE = 100;
type Money = Portfolio["totals"]; type Money = Portfolio["totalsByCurrency"][number];
function formatShare( function formatShare(
numerator: Money | null, numerator: Money | null,
+1 -21
View File
@@ -3,9 +3,8 @@ import type { FC, PropsWithChildren } from "react";
import { QueryClientProvider, useQueryClient } from "@tanstack/react-query"; import { QueryClientProvider, useQueryClient } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react"; import { act, renderHook, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw"; import { http, HttpResponse } from "msw";
import { describe, expect, it, onTestFinished, vi } from "vitest"; import { describe, expect, it } from "vitest";
import * as sharedApi from "@/shared/api";
import { apiClient, apiQueryClient, useAuthenticatedQuery } from "@/shared/api"; import { apiClient, apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource"; import { resource } from "@/test/resource";
@@ -399,23 +398,4 @@ describe("AuthProvider", () => {
expect(result.current.cards.data?.[0]?.id).toBe("b-card"), expect(result.current.cards.data?.[0]?.id).toBe("b-card"),
); );
}); });
it("registers auth handlers once until unmount", async () => {
const configure = vi.spyOn(sharedApi, "configureAuthClient");
onTestFinished(() => configure.mockRestore());
const { rerender, result, unmount } = renderHook(useAuth, {
wrapper: createAuthWrapper(createTestQueryClient()),
});
await waitFor(() => expect(result.current.ready).toBe(true));
const registrations = configure.mock.calls.length;
rerender();
expect(configure).toHaveBeenCalledTimes(registrations);
unmount();
expect(configure).toHaveBeenCalledTimes(registrations + 1);
expect(configure).toHaveBeenLastCalledWith();
});
}); });
+1 -2
View File
@@ -161,8 +161,7 @@ function useAuthProviderVm() {
refresh, refresh,
unavailable: () => unavailableRef.current, unavailable: () => unavailableRef.current,
}); });
// `user` tracks access-token changes, which configureAuthClient publishes as session state. });
}, [clearSession, refresh, user]);
useEffect(() => () => configureAuthClient(), []); useEffect(() => () => configureAuthClient(), []);
const authContextValue = { const authContextValue = {
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it } from "vitest";
import { resource } from "@/test/resource"; import { resource } from "@/test/resource";
@@ -76,29 +76,4 @@ describe("instrument source and transport presentation", () => {
loading: false, loading: false,
}); });
}); });
it("carries the retry of an exhausted read and only of that read", () => {
const refetch = vi.fn(() => Promise.resolve());
const state = section({
data: { rows: [] },
error: { code: "TBANK_UNAVAILABLE", status: 503 },
isError: true,
isFetching: false,
refetch,
});
expect(state.readRetry?.exhausted).toBe(true);
state.readRetry?.onRetry();
expect(refetch).toHaveBeenCalledOnce();
});
it("offers no retry for access loss or a healthy read", () => {
const base = { data: undefined, isFetching: false, refetch: vi.fn() };
expect(section({ ...base, error: { code: "NOT_FOUND" }, isError: true })
.readRetry?.exhausted).toBe(false);
expect(section({ ...base, error: null, isError: false })
.readRetry?.exhausted).toBe(false);
});
}); });
@@ -1,11 +1,10 @@
import { type InvestmentResourceMeta, readRetry } from "@/shared/api"; import type { InvestmentResourceMeta } from "@/shared/api";
type SectionState = { type SectionState = {
error: boolean; error: boolean;
hasData: boolean; hasData: boolean;
loading: boolean; loading: boolean;
partial?: boolean; partial?: boolean;
readRetry?: ReturnType<typeof readRetry>;
}; };
function hasSectionData(data: unknown) { function hasSectionData(data: unknown) {
@@ -14,11 +13,8 @@ function hasSectionData(data: unknown) {
function section(query: { function section(query: {
data: unknown; data: unknown;
error?: unknown;
isError: boolean; isError: boolean;
isFetching?: boolean;
meta?: InvestmentResourceMeta; meta?: InvestmentResourceMeta;
refetch?: () => Promise<unknown>;
}): SectionState { }): SectionState {
const isError = query.isError || query.meta?.lastAttempt.state === "failed"; const isError = query.isError || query.meta?.lastAttempt.state === "failed";
const hasData = hasSectionData(query.data); const hasData = hasSectionData(query.data);
@@ -28,17 +24,6 @@ function section(query: {
hasData, hasData,
loading: !hasData && !isError, loading: !hasData && !isError,
partial: query.meta?.completeness === "incomplete", partial: query.meta?.completeness === "incomplete",
// Each section retries only its own GET, so sections recover independently.
readRetry: query.refetch
? readRetry([
{
error: query.error,
isError: query.isError,
isFetching: query.isFetching ?? false,
refetch: query.refetch,
},
])
: undefined,
}; };
} }
@@ -19,6 +19,7 @@ function useInstrumentChartVm(props: InstrumentChartProps) {
const average = vm.position?.averagePurchasePrice; const average = vm.position?.averagePurchasePrice;
const averageLine = average && props.priceUnit !== "percent" const averageLine = average && props.priceUnit !== "percent"
&& average.currency === vm.candles?.currency
? Number(average.value) ? Number(average.value)
: null; : null;
@@ -46,7 +47,7 @@ function useInstrumentChartVm(props: InstrumentChartProps) {
currency: currency:
props.priceUnit === "percent" props.priceUnit === "percent"
? t("bond.percentOfNominal") ? t("bond.percentOfNominal")
: "₽", : (vm.candles?.currency ?? ""),
}, },
); );
@@ -12,8 +12,6 @@ import {
import { LoadingSkeleton } from "@wealthledger/ui"; import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ReadRetry } from "@/shared/ui";
import type { SectionState } from "../model/section"; import type { SectionState } from "../model/section";
import classes from "./InstrumentDetails.module.css"; import classes from "./InstrumentDetails.module.css";
@@ -83,8 +81,6 @@ const InstrumentSection: FC<InstrumentSectionProps> = (props) => {
</Alert> </Alert>
)} )}
{!!props.state.readRetry && <ReadRetry {...props.state.readRetry} />}
{sectionContent} {sectionContent}
</Paper> </Paper>
); );
@@ -2,18 +2,19 @@ import type { QueryClient } from "@tanstack/react-query";
import { import {
apiQueryClient, apiQueryClient,
isGetUnder,
type paths, type paths,
} from "@/shared/api"; } from "@/shared/api";
type TbankConnectionStatus type TbankConnectionStatus
= paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"]; = paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"];
const investmentGetPredicate = isGetUnder([ function isInvestmentPath(path: string) {
return [
"/api/v1/brokerage-accounts", "/api/v1/brokerage-accounts",
"/api/v1/stocks", "/api/v1/stocks",
"/api/v1/bonds", "/api/v1/bonds",
]); ].some(root => path === root || path.startsWith(`${root}/`));
}
const tbankConnectionQueryOptions = () => const tbankConnectionQueryOptions = () =>
apiQueryClient.queryOptions("get", "/api/v1/tbank-connection"); apiQueryClient.queryOptions("get", "/api/v1/tbank-connection");
@@ -26,7 +27,15 @@ function commitTbankConnectionBoundary(
) { ) {
queryClient.setQueryData(tbankConnectionQueryKey, status); queryClient.setQueryData(tbankConnectionQueryKey, status);
void queryClient.resetQueries({ void queryClient.resetQueries({
predicate: ({ queryKey }) => investmentGetPredicate(queryKey), predicate: ({ queryKey }) => {
const [method, path] = queryKey;
return (
method === "get"
&& typeof path === "string"
&& isInvestmentPath(path)
);
},
}); });
} }
@@ -10,9 +10,6 @@ import { server } from "@/test/server";
import { useScreenSync } from "../index"; import { useScreenSync } from "../index";
const TRANSPORT_ATTEMPTS = 3;
const TRANSPORT_RECOVERY_MS = 3000;
function auth() { function auth() {
configureAuthClient({ configureAuthClient({
accessToken: () => "synthetic", accessToken: () => "synthetic",
@@ -31,13 +28,11 @@ describe("read-only screen synchronization", () => {
gets += 1; gets += 1;
syncModes.push(new URL(request.url).searchParams.get("sync")); syncModes.push(new URL(request.url).searchParams.get("sync"));
// The transport retries a transient read twice (1 s, 2 s) on its own. return gets === 1
return gets <= TRANSPORT_ATTEMPTS
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(resource([])); : HttpResponse.json(resource([]));
}), }),
); );
vi.useFakeTimers();
const { result } = renderHook( const { result } = renderHook(
() => { () => {
@@ -51,26 +46,12 @@ describe("read-only screen synchronization", () => {
{ wrapper: createQueryWrapper().wrapper }, { wrapper: createQueryWrapper().wrapper },
); );
try { await waitFor(() => expect(gets).toBe(1));
// Exhaust the transport back-off so the query settles in error.
await vi.waitFor(async () => {
await act(() => vi.advanceTimersByTimeAsync(TRANSPORT_RECOVERY_MS));
expect(result.current.sync.transportError).toBe(true);
});
expect(gets).toBe(TRANSPORT_ATTEMPTS);
await act(async () => result.current.sync.retry()); await act(async () => result.current.sync.retry());
await vi.waitFor(async () => { await waitFor(() => expect(result.current.accounts.data).toEqual([]));
await act(() => vi.advanceTimersByTimeAsync(0));
expect(result.current.accounts.data).toEqual([]);
});
}
finally {
vi.useRealTimers();
}
expect(gets).toBe(TRANSPORT_ATTEMPTS + 1); expect(gets).toBe(2);
expect(syncModes).toEqual(Array.from({ length: gets }, () => null)); expect(syncModes).toEqual([null, null]);
}); });
it("refreshes the currently open resources together", async () => { it("refreshes the currently open resources together", async () => {
@@ -23,7 +23,7 @@ const meta = {
}, },
render: arguments_ => ( render: arguments_ => (
<Stack> <Stack>
<Text>125 000,50 ₽</Text> <Text>125 000,50 ₽ · 320 USD</Text>
<InvestmentRefreshState {...arguments_} /> <InvestmentRefreshState {...arguments_} />
</Stack> </Stack>
@@ -4,16 +4,16 @@ import {
TbankConnectionError, TbankConnectionError,
TbankConnectionLoading, TbankConnectionLoading,
} from "./TbankConnectionSection.view"; } from "./TbankConnectionSection.view";
import { useTbankConnectionSectionVm } from "./TbankConnectionSection.vm"; import { useTbankConnectionViewModel } from "./TbankConnectionSection.vm";
import { TbankConnectionSectionLoaded } from "./TbankConnectionSectionLoaded"; import { TbankConnectionSectionLoaded } from "./TbankConnectionSectionLoaded";
const TbankConnectionSection: FC = () => { const TbankConnectionSection: FC = () => {
const vm = useTbankConnectionSectionVm(); const viewModel = useTbankConnectionViewModel();
if (vm.state === "loading") return <TbankConnectionLoading />; if (viewModel.state === "loading") return <TbankConnectionLoading />;
return vm.state === "error" return viewModel.state === "error"
? <TbankConnectionError retry={vm.retry} /> ? <TbankConnectionError retry={viewModel.retry} />
: <TbankConnectionSectionLoaded {...vm} />; : <TbankConnectionSectionLoaded {...viewModel} />;
}; };
export { TbankConnectionSection }; export { TbankConnectionSection };
@@ -2,7 +2,7 @@ import { useAuthenticatedQuery } from "@/shared/api";
import { tbankConnectionQueryOptions } from "../../model/connection-boundary"; import { tbankConnectionQueryOptions } from "../../model/connection-boundary";
function useTbankConnectionSectionVm() { function useTbankConnectionViewModel() {
const status = useAuthenticatedQuery({ const status = useAuthenticatedQuery({
...tbankConnectionQueryOptions(), ...tbankConnectionQueryOptions(),
}); });
@@ -22,4 +22,4 @@ function useTbankConnectionSectionVm() {
}; };
} }
export { useTbankConnectionSectionVm }; export { useTbankConnectionViewModel };
@@ -26,7 +26,7 @@ import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { AccountEventsPage } from "./AccountEventsPage"; import { AccountEventsPage } from "./AccountEventsPage";
import { useAccountEventsPageVm } from "./AccountEventsPage.vm"; import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm";
const Navigation: FC = () => { const Navigation: FC = () => {
const location = useLocation(); const location = useLocation();
@@ -100,7 +100,7 @@ describe("Upcoming event date filters", () => {
); );
const queryClient = createTestQueryClient(); const queryClient = createTestQueryClient();
const { result } = renderHook(() => useAccountEventsPageVm(), { const { result } = renderHook(() => useAccountEventsPageViewModel(), {
wrapper: ({ children }) => ( wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["?from=2099-02-01"]}> <MemoryRouter initialEntries={["?from=2099-02-01"]}>
@@ -146,7 +146,7 @@ describe("Upcoming event date filters", () => {
const queryClient = createTestQueryClient(); const queryClient = createTestQueryClient();
const { result } = renderHook( const { result } = renderHook(
() => useAccountEventsPageVm(), () => useAccountEventsPageViewModel(),
{ {
wrapper: ({ children }) => ( wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
@@ -176,11 +176,7 @@ describe("Upcoming event date filters", () => {
({ request }) => { ({ request }) => {
searches.push(new URL(request.url).search); searches.push(new URL(request.url).search);
return HttpResponse.json(resource({ return HttpResponse.json(resource({ events: [] }));
calculatedAt: "2026-08-14T00:00:00Z",
events: [],
window: { from: "2026-08-14", to: "2026-09-13" },
}));
}, },
), ),
); );
@@ -227,11 +223,7 @@ describe("Upcoming event date filters", () => {
"*/api/v1/brokerage-accounts/:accountId/events", "*/api/v1/brokerage-accounts/:accountId/events",
({ request }) => ({ request }) =>
new URL(request.url).searchParams.get("from") === defaultFrom new URL(request.url).searchParams.get("from") === defaultFrom
? HttpResponse.json(resource({ ? HttpResponse.json(resource({ events: [] }))
calculatedAt: "2026-08-14T00:00:00Z",
events: [],
window: { from: "2026-08-14", to: "2026-09-13" },
}))
: HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 }), : HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 }),
), ),
); );
@@ -263,8 +255,7 @@ describe("Upcoming event date filters", () => {
accountId, accountId,
calculatedAt: "2099-01-01T00:00:00Z", calculatedAt: "2099-01-01T00:00:00Z",
events: [], events: [],
incomeTotal: { currency: "RUB", value: "0" }, incomeTotalsByCurrency: [],
window: { from: "2099-01-01", to: "2099-01-31" },
}), }),
); );
}, },
@@ -273,7 +264,6 @@ describe("Upcoming event date filters", () => {
renderEvents(accountId, ["?from=2026-02-01&to=2026-02-28"]); renderEvents(accountId, ["?from=2026-02-01&to=2026-02-28"]);
const from = await screen.findByLabelText("Период"); const from = await screen.findByLabelText("Период");
expect(from).toHaveTextContent("01.02.2026 – 28.02.2026"); expect(from).toHaveTextContent("01.02.2026 – 28.02.2026");
expect(screen.getByText(/Прогноз от .* на 01\.01\.2099 – 31\.01\.2099/)).toBeVisible();
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
expect(searches).toEqual(["?from=2026-02-01&to=2026-02-28"]); expect(searches).toEqual(["?from=2026-02-01&to=2026-02-28"]);
@@ -330,7 +320,7 @@ describe("Upcoming event date filters", () => {
expect(screen.getByLabelText("URL")).toBeEmptyDOMElement(); expect(screen.getByLabelText("URL")).toBeEmptyDOMElement();
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
expect( expect(
screen.getByText("На выбранный период предстоящих событий не найдено"), screen.getByText("За выбранный период событий не найдено"),
).toBeVisible(); ).toBeVisible();
}, },
); );
@@ -353,7 +343,7 @@ it("retains the previous complete forecast when its source refresh fails", async
]; ];
const snapshot = resource( const snapshot = resource(
{ events, incomeTotal: { currency: "RUB", value: "123.45" } }, { events, incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }] },
{ {
lastAttempt: { lastAttempt: {
finishedAt: "2026-08-14T00:01:00Z", finishedAt: "2026-08-14T00:01:00Z",
@@ -371,7 +361,7 @@ it("retains the previous complete forecast when its source refresh fails", async
); );
const queryClient = createTestQueryClient(); const queryClient = createTestQueryClient();
const { result } = renderHook(() => useAccountEventsPageVm(), { const { result } = renderHook(() => useAccountEventsPageViewModel(), {
wrapper: ({ children }) => ( wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["/accounts/all/events"]}> <MemoryRouter initialEntries={["/accounts/all/events"]}>
@@ -1,167 +0,0 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { MemoryRouter, Route, Routes } from "react-router";
import { 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 { AccountEventsPage } from "./AccountEventsPage";
// Automatic read retries wait 1s + 2s before the failure is final.
const RETRIES_EXHAUSTED_MS = 6000;
const TEST_MS = 10_000;
const snapshot = resource({
accountId: "account-1",
calculatedAt: "2026-08-14T00:00:00Z",
events: [
{
date: "2026-10-01",
dateKind: "event",
displayName: "Synthetic coupon",
figi: null,
instrumentUid: null,
isin: null,
logoName: null,
projectedAmount: { currency: "RUB", value: "123.45" },
ticker: "SYN",
type: "coupon",
},
],
incomeTotal: { currency: "RUB", value: "123.45" },
window: { from: "2026-08-14", to: "2026-09-13" },
});
function renderEvents() {
configureAuthClient({
accessToken: () => "token",
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
const queryClient = createTestQueryClient();
render(
<QueryClientProvider client={queryClient}>
<MantineProvider env="test">
<MemoryRouter initialEntries={["/accounts/account-1/events"]}>
<Routes>
<Route
element={<AccountEventsPage />}
path="/accounts/:accountId/events"
/>
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
return { queryClient };
}
describe("AccountEventsPage read retry", () => {
it(
"offers retry after an initial failure and recovers in place",
{ timeout: TEST_MS },
async () => {
let isFailing = true;
let requests = 0;
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/events", () => {
requests += 1;
return isFailing
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(snapshot);
}),
);
renderEvents();
const retry = await screen.findByRole(
"button",
{ name: "Повторить" },
{ timeout: RETRIES_EXHAUSTED_MS },
);
expect(requests).toBe(3);
isFailing = false;
fireEvent.click(retry);
expect(await screen.findByText("Купон · Synthetic coupon")).toBeInTheDocument();
expect(requests).toBe(4);
},
);
it(
"keeps cached events, warns and retries the same GET",
{ timeout: TEST_MS },
async () => {
let isFailing = false;
let requests = 0;
let imports = 0;
server.use(
http.post("*/api/v1/tbank-sync", () => {
imports += 1;
return HttpResponse.json({}, { status: 202 });
}),
http.get("*/api/v1/brokerage-accounts/:accountId/events", () => {
requests += 1;
return isFailing
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(snapshot);
}),
);
const { queryClient } = renderEvents();
await screen.findByText("Купон · Synthetic coupon");
expect(screen.getByText(/Прогноз от .* на 14\.08\.2026 – 13\.09\.2026/)).toBeVisible();
expect(screen.queryByRole("button", { name: "Повторить" }))
.not.toBeInTheDocument();
isFailing = true;
void queryClient.invalidateQueries({
queryKey: ["get", "/api/v1/brokerage-accounts/{accountId}/events"],
});
const retry = await screen.findByRole(
"button",
{ name: "Повторить" },
{ timeout: RETRIES_EXHAUSTED_MS },
);
expect(screen.getByText("Не удалось обновить данные")).toBeInTheDocument();
expect(screen.getByText("Купон · Synthetic coupon")).toBeInTheDocument();
expect(screen.getByText(/Прогноз от .* на 14\.08\.2026 – 13\.09\.2026/)).toBeVisible();
expect(requests).toBe(4);
isFailing = false;
fireEvent.click(retry);
await waitFor(() =>
expect(screen.queryByText("Не удалось обновить данные"))
.not.toBeInTheDocument());
expect(requests).toBe(5);
expect(imports).toBe(0);
},
);
it("does not offer a plain retry when the account is gone", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 })),
);
renderEvents();
await waitFor(() =>
expect(screen.queryByRole("heading", { name: "Загрузка" }))
.not.toBeInTheDocument());
expect(screen.queryByRole("button", { name: "Повторить" }))
.not.toBeInTheDocument();
});
});
@@ -47,7 +47,6 @@ describe("AccountEventsPage", () => {
accountId: "account", accountId: "account",
calculatedAt: "2026-08-14T00:00:00Z", calculatedAt: "2026-08-14T00:00:00Z",
events: [], events: [],
window: { from: "2026-08-14", to: "2026-09-13" },
}, },
isError: false, isError: false,
isLoading: false, isLoading: false,
@@ -137,7 +136,6 @@ describe("AccountEventsPage", () => {
type: "offer", type: "offer",
}, },
], ],
window: { from: "2026-08-14", to: "2026-09-13" },
}, },
isError: false, isError: false,
isLoading: false, isLoading: false,
@@ -171,18 +169,15 @@ describe("AccountEventsPage", () => {
expect(screen.getAllByText("O")).toHaveLength(2); expect(screen.getAllByText("O")).toHaveLength(2);
}); });
it("renders empty and error states with retry", async () => { it("renders empty and quiet error states", async () => {
const { rerender } = renderPage(); const { rerender } = renderPage();
expect( expect(
screen.getByText("На выбранный период предстоящих событий не найдено"), screen.getByText("За выбранный период событий не найдено"),
).toBeInTheDocument(); ).toBeInTheDocument();
query.mockReturnValue({ query.mockReturnValue({
data: undefined, data: undefined,
error: { code: "TBANK_UNAVAILABLE", status: 503 },
isError: true, isError: true,
isFetching: false,
isLoading: false, isLoading: false,
refetch: vi.fn(),
} as never); } as never);
rerender( rerender(
<MantineProvider> <MantineProvider>
@@ -192,7 +187,7 @@ describe("AccountEventsPage", () => {
</MantineProvider>, </MantineProvider>,
); );
expect(await screen.findByRole("button", { name: "Сбросить" })).toBeVisible(); expect(await screen.findByRole("button", { name: "Сбросить" })).toBeVisible();
expect(screen.getByRole("button", { name: "Повторить" })).toBeVisible(); expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
}); });
it("shows a structural busy state before events are available", () => { it("shows a structural busy state before events are available", () => {
@@ -239,83 +234,7 @@ describe("AccountEventsPage", () => {
"true", "true",
); );
expect( expect(
screen.queryByText("На выбранный период предстоящих событий не найдено"), screen.queryByText("За выбранный период событий не найдено"),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
it("groups same-day instrument events only in all-account mode", () => {
const events = [
{
account: { id: "broker", name: "Брокерский" },
date: "2026-08-20",
dateKind: "event",
displayName: "ОФЗ 26238",
figi: "FIGI",
instrumentUid: "UID",
isin: "RU000A0JX0J2",
logoName: null,
projectedAmount: { currency: "RUB", value: "200" },
ticker: "OFZ",
type: "coupon",
},
{
account: { id: "iis", name: "ИИС" },
date: "2026-08-20",
dateKind: "event",
displayName: "ОФЗ 26238",
figi: "FIGI",
instrumentUid: "UID",
isin: "RU000A0JX0J2",
logoName: null,
projectedAmount: { currency: "RUB", value: "100" },
ticker: "OFZ",
type: "coupon",
},
] as const;
query.mockReturnValue({
data: {
accountId: "all",
calculatedAt: "2026-08-14T00:00:00Z",
events: [...events],
window: { from: "2026-08-14", to: "2026-09-13" },
},
isError: false,
isLoading: false,
meta: {
availability: "available",
completeness: "complete",
lastAttempt: {
finishedAt: null,
problems: [],
startedAt: null,
state: "none",
},
},
refetch: vi.fn(),
} as never);
render(
<MantineProvider>
<MemoryRouter initialEntries={["/accounts/all/events"]}>
<Routes>
<Route
element={<AccountEventsPage />}
path="/accounts/:accountId/events"
/>
</Routes>
</MemoryRouter>
</MantineProvider>,
);
expect(screen.getByText("Купон · ОФЗ 26238")).toBeInTheDocument();
expect(screen.getByText("300 ₽")).toBeInTheDocument();
const accounts = within(screen.getByRole("list", {
name: /Купон · ОФЗ 26238, 20\.08\.2026, 300 ₽/,
}));
expect(accounts.getByText("Брокерский")).toBeInTheDocument();
expect(accounts.getByText("ИИС")).toBeInTheDocument();
});
}); });
@@ -1,17 +1,17 @@
import type { FC } from "react"; import type { FC } from "react";
import { useAccountEventsPageVm } from "./AccountEventsPage.vm"; import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm";
import { AccountEventsPageError } from "./components/AccountEventsPageError.view"; import { AccountEventsPageError } from "./components/AccountEventsPageError.view";
import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view"; import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view";
import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view"; import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view";
const AccountEventsPage: FC = () => { const AccountEventsPage: FC = () => {
const vm = useAccountEventsPageVm(); const viewModel = useAccountEventsPageViewModel();
if (vm.state === "loading") return <AccountEventsPageLoading />; if (viewModel.state === "loading") return <AccountEventsPageLoading />;
return vm.state === "error" return viewModel.state === "error"
? <AccountEventsPageError {...vm} /> ? <AccountEventsPageError {...viewModel} />
: <AccountEventsPageLoaded {...vm} />; : <AccountEventsPageLoaded {...viewModel} />;
}; };
export { AccountEventsPage }; export { AccountEventsPage };
@@ -1,12 +1,13 @@
import { useState } from "react"; import { useState } from "react";
import { useParams } from "react-router"; import { useParams } from "react-router";
import type { InvestmentResourceMeta } from "@/shared/api";
import { import {
type BrokerageEvent, type BrokerageEvent,
useBrokerageEvents, useBrokerageEvents,
} from "@/entities/brokerage-event"; } from "@/entities/brokerage-event";
import { useScreenSync } from "@/features/tbank-connection"; import { useScreenSync } from "@/features/tbank-connection";
import { type InvestmentResourceMeta, readRetry } from "@/shared/api";
import { import {
defaultPeriod, defaultPeriod,
isValidPeriod, isValidPeriod,
@@ -17,8 +18,6 @@ import {
type AccountEventsPageViewModel type AccountEventsPageViewModel
= | { = | {
allAccounts: boolean;
calculatedAt: string;
canApply: boolean; canApply: boolean;
draftPeriod: [null | string, null | string]; draftPeriod: [null | string, null | string];
events: BrokerageEvent[]; events: BrokerageEvent[];
@@ -28,20 +27,13 @@ type AccountEventsPageViewModel
onPeriodPopoverOpen: () => void; onPeriodPopoverOpen: () => void;
onReset: () => void; onReset: () => void;
partial?: boolean; partial?: boolean;
readRetry: ReturnType<typeof readRetry>;
source?: InvestmentResourceMeta; source?: InvestmentResourceMeta;
state: "loaded"; state: "loaded";
totalVisible: boolean;
window: { from: string; to: string };
}
| {
onReset: () => void;
readRetry: ReturnType<typeof readRetry>;
state: "error";
} }
| { onReset: () => void; state: "error" }
| { state: "loading" }; | { state: "loading" };
function useAccountEventsPageVm(): AccountEventsPageViewModel { function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
const { accountId = "" } = useParams(); const { accountId = "" } = useParams();
const { const {
@@ -89,8 +81,6 @@ function useAccountEventsPageVm(): AccountEventsPageViewModel {
isFetching: events.isFetching, isFetching: events.isFetching,
}); });
const retry = readRetry([events]);
if (availability === "loading" && events.isLoading) if (availability === "loading" && events.isLoading)
return { state: "loading" }; return { state: "loading" };
@@ -102,17 +92,14 @@ function useAccountEventsPageVm(): AccountEventsPageViewModel {
if ((events.isError || events.meta?.lastAttempt.state === "failed") && !events.data) if ((events.isError || events.meta?.lastAttempt.state === "failed") && !events.data)
return { return {
onReset, onReset,
readRetry: retry,
state: "error", state: "error",
}; };
if (!events.data) if (!events.data)
return availability === "unavailable" return availability === "unavailable"
? { onReset, readRetry: retry, state: "error" } ? { onReset, state: "error" }
: { state: "loading" }; : { state: "loading" };
return { return {
allAccounts: accountId === "all",
calculatedAt: events.data.calculatedAt,
canApply: canApply:
(draftFrom !== appliedFrom || draftTo !== appliedTo) (draftFrom !== appliedFrom || draftTo !== appliedTo)
&& isValidPeriod(draftFrom, draftTo), && isValidPeriod(draftFrom, draftTo),
@@ -132,12 +119,9 @@ function useAccountEventsPageVm(): AccountEventsPageViewModel {
onPeriodPopoverOpen: () => setMinDate(moscowDate(new Date())), onPeriodPopoverOpen: () => setMinDate(moscowDate(new Date())),
onReset, onReset,
partial: events.meta?.completeness === "incomplete", partial: events.meta?.completeness === "incomplete",
readRetry: retry,
source: events.meta, source: events.meta,
state: "loaded", state: "loaded",
totalVisible: events.meta?.completeness === "complete",
window: events.data.window,
}; };
} }
export { type AccountEventsPageViewModel, useAccountEventsPageVm }; export { type AccountEventsPageViewModel, useAccountEventsPageViewModel };
@@ -4,12 +4,7 @@ import { Button, Group } from "@mantine/core";
import { StateView } from "@wealthledger/ui"; import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ReadRetryButton, type ReadRetryProps } from "@/shared/ui"; type AccountEventsPageErrorProps = { onReset: () => void };
type AccountEventsPageErrorProps = {
onReset: () => void;
readRetry: ReadRetryProps;
};
const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => { const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -18,13 +13,6 @@ const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
<StateView <StateView
action={( action={(
<Group> <Group>
{props.readRetry.exhausted && (
<ReadRetryButton
onRetry={props.readRetry.onRetry}
retrying={props.readRetry.retrying}
/>
)}
<Button onClick={props.onReset} variant="default"> <Button onClick={props.onReset} variant="default">
{t("ledger.reset")} {t("ledger.reset")}
</Button> </Button>
@@ -13,12 +13,9 @@ import { useTranslation } from "react-i18next";
import { import {
BrokerageEventCard, BrokerageEventCard,
BrokerageEventGroup,
brokerageEventGroupKey,
brokerageEventKey, brokerageEventKey,
} from "@/entities/brokerage-event"; } from "@/entities/brokerage-event";
import { formatDate, formatSnapshotTime, groupItems } from "@/shared/lib"; import { SourceSyncStatus } from "@/shared/ui";
import { ReadRetry, SourceSyncStatus } from "@/shared/ui";
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm"; import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
@@ -33,31 +30,13 @@ const AccountEventsPageLoadedEvents: FC<AccountEventsPageLoadedProps> = (
props, props,
) => { ) => {
if (props.events.length > 0) { if (props.events.length > 0) {
const entries = groupItems(
props.events,
props.allAccounts ? brokerageEventGroupKey : () => null,
);
return ( return (
<Stack <Stack gap="sm">
component="ul" {props.events.map(event => (
gap="sm" <BrokerageEventCard
m={0} event={event}
p={0} key={brokerageEventKey(event)}
style={{ listStyle: "none" }}
>
{entries.map(entry => entry.type === "group"
? (
<BrokerageEventGroup
events={entry.items}
key={entry.key}
totalVisible={props.totalVisible}
/> />
)
: (
<li key={brokerageEventKey(entry.item)}>
<BrokerageEventCard event={entry.item} />
</li>
))} ))}
</Stack> </Stack>
); );
@@ -83,20 +62,10 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
stale={props.source?.freshness === "stale"} stale={props.source?.freshness === "stale"}
/> />
<ReadRetry {...props.readRetry} />
{!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>} {!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>}
<Text c="dimmed">{t("events.hint")}</Text> <Text c="dimmed">{t("events.hint")}</Text>
<Text c="dimmed" size="sm">
{t("events.forecastWindow", {
date: formatSnapshotTime(props.calculatedAt),
from: formatDate(props.window.from),
to: formatDate(props.window.to),
})}
</Text>
<Group align="end"> <Group align="end">
<DatePickerInput <DatePickerInput
allowSingleDateInRange allowSingleDateInRange
@@ -26,7 +26,6 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { IncomeAnalytics } from "@/entities/income"; import type { IncomeAnalytics } from "@/entities/income";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
import { formatSnapshotTime } from "@/shared/lib";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource"; import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
@@ -34,14 +33,10 @@ import { server } from "@/test/server";
import { toIncomeDisplayModel } from "../../model/display"; import { toIncomeDisplayModel } from "../../model/display";
import { AccountIncomePage } from "./AccountIncomePage"; import { AccountIncomePage } from "./AccountIncomePage";
import { import {
useAccountIncomePageVm, useAccountIncomePageViewModel,
} from "./AccountIncomePage.vm"; } from "./AccountIncomePage.vm";
import { IncomePayments } from "./components/IncomePayments.view"; import { IncomePayments } from "./components/IncomePayments.view";
// Automatic read retries wait 1s + 2s before the failure is final.
const RETRIES_EXHAUSTED_MS = 6000;
const TEST_MS = 10_000;
const analytics const analytics
= { = {
accountId: "account-1", accountId: "account-1",
@@ -49,7 +44,7 @@ const analytics
buckets: [ buckets: [
{ {
period: { from: "2026-08-01", to: "2026-08-01" }, period: { from: "2026-08-01", to: "2026-08-01" },
total: summary("10", "2"), totalsByCurrency: [summary("RUB", "10", "2")],
}, },
], ],
calculatedAt: "2026-08-27T00:00:00Z", calculatedAt: "2026-08-27T00:00:00Z",
@@ -77,7 +72,7 @@ const analytics
}, },
], ],
period: { from: "2026-08-01", to: "2026-08-27" }, period: { from: "2026-08-01", to: "2026-08-27" },
total: summary("10", "2"), totalsByCurrency: [summary("RUB", "10", "2")],
} satisfies IncomeAnalytics & { instruments: [unknown] }; } satisfies IncomeAnalytics & { instruments: [unknown] };
function entry(): IncomeAnalytics["entries"][number] { function entry(): IncomeAnalytics["entries"][number] {
@@ -105,10 +100,7 @@ function money(currency: string, value: string) {
return { currency, value }; return { currency, value };
} }
function renderPage( function renderPage(search = "from=2026-08-01&to=2026-08-27") {
search = "from=2026-08-01&to=2026-08-27",
accountId = "account-1",
) {
configureAuthClient({ configureAuthClient({
accessToken: () => "token", accessToken: () => "token",
clear: vi.fn(), clear: vi.fn(),
@@ -121,7 +113,7 @@ function renderPage(
<DatesProvider settings={{ locale: "ru" }}> <DatesProvider settings={{ locale: "ru" }}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<MemoryRouter <MemoryRouter
initialEntries={[`/accounts/${accountId}/income?${search}`]} initialEntries={[`/accounts/account-1/income?${search}`]}
> >
<LocationProbe /> <LocationProbe />
@@ -145,11 +137,12 @@ function renderPage(
return { ...view, queryClient }; return { ...view, queryClient };
} }
function summary(coupon: string, dividend: string) { function summary(currency: string, coupon: string, dividend: string) {
return { return {
coupon: money("RUB", coupon), coupon: money(currency, coupon),
dividend: money("RUB", dividend), currency,
total: money("RUB", String(Number(coupon) + Number(dividend))), dividend: money(currency, dividend),
total: money(currency, String(Number(coupon) + Number(dividend))),
}; };
} }
@@ -170,7 +163,7 @@ describe("AccountIncomePage", () => {
); );
const queryClient = createTestQueryClient(); const queryClient = createTestQueryClient();
const { result } = renderHook(() => useAccountIncomePageVm(), { const { result } = renderHook(() => useAccountIncomePageViewModel(), {
wrapper: ({ children }) => ( wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["?from=2024-02-29&to=2024-03-01"]}> <MemoryRouter initialEntries={["?from=2024-02-29&to=2024-03-01"]}>
@@ -307,7 +300,7 @@ describe("AccountIncomePage", () => {
buckets: [], buckets: [],
entries: [], entries: [],
instruments: [], instruments: [],
total: summary("0", "0"), totalsByCurrency: [],
}), }),
), ),
), ),
@@ -407,124 +400,21 @@ describe("AccountIncomePage", () => {
expect(screen.queryByText(/Сравнение с/)).not.toBeInTheDocument(); expect(screen.queryByText(/Сравнение с/)).not.toBeInTheDocument();
}); });
it.each([ it("shows a quiet realized error", async () => {
["full", analytics],
["empty", { ...analytics, buckets: [], entries: [], instruments: [], total: summary("0", "0") }],
])("shows the server source time and last attempt for a %s result", async (_name, data) => {
server.use( server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => http.get(
HttpResponse.json( "*/api/v1/brokerage-accounts/account-1/income-analytics",
resource(data, { () => new HttpResponse(null, { status: 503 }),
freshness: "stale",
lastAttempt: {
finishedAt: "2026-08-27T01:00:00Z",
problems: [],
startedAt: "2026-08-27T00:59:00Z",
state: "failed",
},
lastSuccessfulAt: "2026-08-26T12:00:00Z",
}),
),
), ),
); );
renderPage(); renderPage();
expect(await screen.findByText(/^Данные на /)).toHaveTextContent( expect(
formatSnapshotTime("2026-08-26T12:00:00Z"), await screen.findByText("Не удалось рассчитать пассивный доход", undefined, {
); timeout: 4000,
expect(screen.getByText("Обновление не удалось")).toBeInTheDocument(); }),
expect(screen.getByText("Данные требуют обновления")).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
}); });
it(
"offers retry after an initial failure and recovers in place",
{ timeout: TEST_MS },
async () => {
let isFailing = true;
let requests = 0;
server.use(
http.get(
"*/api/v1/brokerage-accounts/account-1/income-analytics",
() => {
requests += 1;
return isFailing
? new HttpResponse(null, { status: 503 })
: HttpResponse.json(resource(analytics));
},
),
);
renderPage();
expect(
await screen.findByText(
"Не удалось рассчитать пассивный доход",
undefined,
{ timeout: RETRIES_EXHAUSTED_MS },
),
).toBeInTheDocument();
const retry = screen.getByRole("button", { name: "Повторить" });
expect(requests).toBe(3);
isFailing = false;
fireEvent.click(retry);
expect(
await screen.findByRole("region", { name: "Выплаты" }),
).toBeInTheDocument();
expect(requests).toBe(4);
},
);
it(
"keeps cached income, warns and retries the same GET",
{ timeout: TEST_MS },
async () => {
let isFailing = false;
let requests = 0;
server.use(
http.get(
"*/api/v1/brokerage-accounts/account-1/income-analytics",
() => {
requests += 1;
return isFailing
? HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
)
: HttpResponse.json(resource(analytics));
},
),
);
const { queryClient } = renderPage();
await screen.findByRole("region", { name: "Выплаты" });
expect(screen.queryByRole("button", { name: "Повторить" }))
.not.toBeInTheDocument();
isFailing = true;
void queryClient.invalidateQueries({
queryKey: ["get", "/api/v1/brokerage-accounts/{accountId}/income-analytics"],
});
const retry = await screen.findByRole(
"button",
{ name: "Повторить" },
{ timeout: RETRIES_EXHAUSTED_MS },
);
expect(screen.getByText("Не удалось обновить данные")).toBeInTheDocument();
expect(screen.getByRole("region", { name: "Выплаты" })).toBeInTheDocument();
expect(requests).toBe(4);
isFailing = false;
fireEvent.click(retry);
await waitFor(() =>
expect(screen.queryByText("Не удалось обновить данные"))
.not.toBeInTheDocument());
expect(requests).toBe(5);
},
);
}); });
const LocationProbe: FC = () => { const LocationProbe: FC = () => {
@@ -736,11 +626,11 @@ describe("income period and snapshot boundary", () => {
const instruments = [ const instruments = [
{ {
...analytics.instruments[0], ...analytics.instruments[0],
coupon: money("RUB", "10"), coupon: money("USD", "10"),
dividend: money("RUB", "0"), dividend: money("USD", "0"),
key: "rub-positive", key: "usd",
receiptCount: 1, receiptCount: 1,
total: money("RUB", "10"), total: money("USD", "10"),
}, },
{ {
...analytics.instruments[0], ...analytics.instruments[0],
@@ -769,7 +659,7 @@ describe("income period and snapshot boundary", () => {
payment: instrument.total, payment: instrument.total,
})), })),
instruments, instruments,
total: summary("-5", "0"), totalsByCurrency: [summary("RUB", "-5", "0"), summary("USD", "10", "0")],
}); });
render( render(
@@ -781,118 +671,13 @@ describe("income period and snapshot boundary", () => {
screen.getByRole("heading", { name: "Выплаты" }), screen.getByRole("heading", { name: "Выплаты" }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.queryByText("RUB")).not.toBeInTheDocument(); expect(screen.queryByText("RUB")).not.toBeInTheDocument();
expect(screen.queryByText("USD")).not.toBeInTheDocument();
const cards = screen.getAllByRole("listitem"); const cards = screen.getAllByRole("listitem");
expect(cards).toHaveLength(3); expect(cards).toHaveLength(3);
expect(cards[0]).toHaveTextContent("+10 ₽"); expect(cards[0]).toHaveTextContent("+10 USD");
expect(cards[1]).toHaveTextContent("−2 ₽"); expect(cards[1]).toHaveTextContent("−2 ₽");
expect(cards[2]).toHaveTextContent("−3 ₽"); expect(cards[2]).toHaveTextContent("−3 ₽");
expect(screen.queryByText(/Итого:|Выплат:/)).not.toBeInTheDocument(); expect(screen.queryByText(/Итого:|Выплат:/)).not.toBeInTheDocument();
expect(screen.queryByRole("button")).not.toBeInTheDocument(); expect(screen.queryByRole("button")).not.toBeInTheDocument();
}); });
it("groups all-account payouts by instrument with an exact total", async () => {
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/income-analytics",
() => HttpResponse.json(resource({
...analytics,
accountId: "all",
entries: [
{
...entry(),
account: { id: "broker", name: "Брокерский" },
payment: money("RUB", "10"),
},
{
...entry(),
account: { id: "iis", name: "ИИС" },
id: "coupon-2",
payment: money("RUB", "20"),
},
],
})),
),
);
renderPage(undefined, "all");
expect(await screen.findByText("Выплата купонов BOND")).toBeInTheDocument();
expect(screen.getByText("+30 ₽")).toBeInTheDocument();
const accounts = within(screen.getByRole("list", {
name: /Выплата купонов BOND, Исполнено/,
}));
expect(accounts.getByText("Брокерский")).toBeInTheDocument();
expect(accounts.getByText("ИИС")).toBeInTheDocument();
expect(accounts.queryByRole("link")).not.toBeInTheDocument();
});
it("keeps repeated payout ids inside one group as separate cards", async () => {
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/income-analytics",
() => HttpResponse.json(resource({
...analytics,
accountId: "all",
entries: [
{
...entry(),
account: { id: "broker", name: "Брокерский" },
payment: money("RUB", "10"),
},
{
...entry(),
account: { id: "iis", name: "ИИС" },
payment: money("RUB", "20"),
},
],
})),
),
);
renderPage(undefined, "all");
expect(await screen.findByText("+30 ₽")).toBeInTheDocument();
const accounts = within(screen.getByRole("list", {
name: /Выплата купонов BOND/,
}));
expect(accounts.getAllByRole("listitem")).toHaveLength(2);
});
it("shows the group total after completeness changes to complete", async () => {
let completeness: "complete" | "incomplete" = "incomplete";
const entries = [
{
...entry(),
account: { id: "broker", name: "Брокерский" },
payment: money("RUB", "10"),
},
{
...entry(),
account: { id: "iis", name: "ИИС" },
id: "coupon-2",
payment: money("RUB", "20"),
},
];
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/income-analytics",
() => HttpResponse.json(
resource({ ...analytics, accountId: "all", entries }, { completeness }),
),
),
);
const view = renderPage(undefined, "all");
await screen.findByText("Выплата купонов BOND");
expect(screen.queryByText("+30 ₽")).not.toBeInTheDocument();
view.unmount();
completeness = "complete";
renderPage(undefined, "all");
expect(await screen.findByText("+30 ₽")).toBeInTheDocument();
});
}); });
@@ -1,17 +1,17 @@
import type { FC } from "react"; import type { FC } from "react";
import { useAccountIncomePageVm } from "./AccountIncomePage.vm"; import { useAccountIncomePageViewModel } from "./AccountIncomePage.vm";
import { AccountIncomePageError } from "./components/AccountIncomePageError.view"; import { AccountIncomePageError } from "./components/AccountIncomePageError.view";
import { AccountIncomePageLoaded } from "./components/AccountIncomePageLoaded.view"; import { AccountIncomePageLoaded } from "./components/AccountIncomePageLoaded.view";
import { AccountIncomePageLoading } from "./components/AccountIncomePageLoading.view"; import { AccountIncomePageLoading } from "./components/AccountIncomePageLoading.view";
const AccountIncomePage: FC = () => { const AccountIncomePage: FC = () => {
const vm = useAccountIncomePageVm(); const viewModel = useAccountIncomePageViewModel();
if (vm.state === "loading") return <AccountIncomePageLoading />; if (viewModel.state === "loading") return <AccountIncomePageLoading />;
return vm.state === "error" return viewModel.state === "error"
? <AccountIncomePageError {...vm} /> ? <AccountIncomePageError {...viewModel} />
: <AccountIncomePageLoaded {...vm} />; : <AccountIncomePageLoaded {...viewModel} />;
}; };
export { AccountIncomePage }; export { AccountIncomePage };
@@ -5,7 +5,6 @@ import { useParams, useSearchParams } from "react-router";
import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income"; import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
import { useScreenSync } from "@/features/tbank-connection"; import { useScreenSync } from "@/features/tbank-connection";
import { type InvestmentResourceMeta, readRetry } from "@/shared/api";
import { isValidPeriod, moscowDate, useInitialAvailability } from "@/shared/lib"; import { isValidPeriod, moscowDate, useInitialAvailability } from "@/shared/lib";
import { import {
@@ -31,19 +30,15 @@ type AccountIncomePageViewModel
onReset: () => void; onReset: () => void;
onSubmit: (values: IncomeForm) => void; onSubmit: (values: IncomeForm) => void;
partial?: boolean; partial?: boolean;
readRetry: ReturnType<typeof readRetry>;
source?: InvestmentResourceMeta;
state: "loaded"; state: "loaded";
totalVisible: boolean;
} }
| { | {
period: IncomePeriod; period: IncomePeriod;
readRetry: ReturnType<typeof readRetry>;
state: "error"; state: "error";
} }
| { state: "loading" }; | { state: "loading" };
const useAccountIncomePageVm = (): AccountIncomePageViewModel => { const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
const { accountId = "" } = useParams(); const { accountId = "" } = useParams();
const [searchParameters, setSearchParameters] = useSearchParams(); const [searchParameters, setSearchParameters] = useSearchParams();
@@ -88,14 +83,12 @@ const useAccountIncomePageVm = (): AccountIncomePageViewModel => {
isFetching: analytics.isFetching, isFetching: analytics.isFetching,
}); });
const retry = readRetry([analytics]);
if (availability === "loading" && analytics.isLoading) if (availability === "loading" && analytics.isLoading)
return { state: "loading" }; return { state: "loading" };
if (!analytics.data) if (!analytics.data)
return availability === "unavailable" return availability === "unavailable"
? { period, readRetry: retry, state: "error" } ? { period, state: "error" }
: { state: "loading" }; : { state: "loading" };
const display = toIncomeDisplayModel(analytics.data); const display = toIncomeDisplayModel(analytics.data);
@@ -122,10 +115,7 @@ const useAccountIncomePageVm = (): AccountIncomePageViewModel => {
onReset, onReset,
onSubmit, onSubmit,
partial: analytics.meta?.completeness === "incomplete", partial: analytics.meta?.completeness === "incomplete",
readRetry: retry,
source: analytics.meta,
state: "loaded", state: "loaded",
totalVisible: analytics.meta?.completeness === "complete",
}; };
}; };
@@ -146,5 +136,5 @@ function isValidIncomePeriod(period: IncomePeriod): boolean {
export { export {
type AccountIncomePageViewModel, type AccountIncomePageViewModel,
useAccountIncomePageVm, useAccountIncomePageViewModel,
}; };
@@ -3,27 +3,15 @@ import type { FC } from "react";
import { StateView } from "@wealthledger/ui"; import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ReadRetryButton } from "@/shared/ui";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm"; import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
type ErrorProps = Extract<AccountIncomePageViewModel, { state: "error" }>; type ErrorProps = Extract<AccountIncomePageViewModel, { state: "error" }>;
const AccountIncomePageError: FC<ErrorProps> = (props) => { const AccountIncomePageError: FC<ErrorProps> = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const retry = props.readRetry.exhausted
? (
<ReadRetryButton
onRetry={props.readRetry.onRetry}
retrying={props.readRetry.retrying}
/>
)
: undefined;
return ( return (
<StateView <StateView
action={retry}
error error
text={t("income.errorText")} text={t("income.errorText")}
title={t("income.errorTitle")} title={t("income.errorTitle")}
@@ -5,11 +5,10 @@ import {
Stack, Stack,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import { FormProvider } from "react-hook-form"; import { FormProvider } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ReadRetry, SourceSyncStatus } from "@/shared/ui";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm"; import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
import classes from "../AccountIncomePage.module.css"; import classes from "../AccountIncomePage.module.css";
@@ -24,9 +23,13 @@ const AccountIncomePageLoadedSummary: FC<LoadedProps> = (props) => {
const { analytics } = props; const { analytics } = props;
return props.partial if (props.partial) {
? <Alert role="status">{t("investment.partial")}</Alert> return <Alert role="status">{t("investment.partial")}</Alert>;
: <IncomeSummary totals={analytics.total} />; }
return analytics.totalsByCurrency.length > 0
? <IncomeSummary totals={analytics.totalsByCurrency} />
: <StateView text={t("income.emptyText")} title={t("income.emptyTitle")} />;
}; };
const AccountIncomePageLoaded: FC<LoadedProps> = (props) => { const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
@@ -46,23 +49,11 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
/> />
</FormProvider> </FormProvider>
<SourceSyncStatus
lastAttempt={props.source?.lastAttempt}
lastSuccessfulAt={props.source?.lastSuccessfulAt}
stale={props.source?.freshness === "stale"}
/>
<ReadRetry {...props.readRetry} />
<AccountIncomePageLoadedSummary {...props} /> <AccountIncomePageLoadedSummary {...props} />
<Alert color="blue">{t("income.taxBoundary")}</Alert> <Alert color="blue">{t("income.taxBoundary")}</Alert>
<IncomePayments <IncomePayments entries={display.entries} />
allAccounts={props.analytics.accountId === "all"}
entries={display.entries}
totalVisible={props.totalVisible}
/>
</Stack> </Stack>
); );
}; };
@@ -3,22 +3,13 @@ import type { FC } from "react";
import { Stack, Title } from "@mantine/core"; import { Stack, Title } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { LedgerEntryRow } from "@/entities/broker-operation";
brokerageOperationGroupKey,
LedgerEntryRow,
OperationGroup,
} from "@/entities/broker-operation";
import { groupItems } from "@/shared/lib";
import type { IncomeDisplayModel } from "../../../model/display"; import type { IncomeDisplayModel } from "../../../model/display";
import classes from "../AccountIncomePage.module.css"; import classes from "../AccountIncomePage.module.css";
type IncomePaymentsProps = { type IncomePaymentsProps = { entries: IncomeDisplayModel["entries"] };
allAccounts?: boolean;
entries: IncomeDisplayModel["entries"];
totalVisible?: boolean;
};
const IncomePayments: FC<IncomePaymentsProps> = (props) => { const IncomePayments: FC<IncomePaymentsProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -28,22 +19,9 @@ const IncomePayments: FC<IncomePaymentsProps> = (props) => {
<Title order={2}>{t("income.payments")}</Title> <Title order={2}>{t("income.payments")}</Title>
<Stack className={classes.entries} component="ul" gap="sm"> <Stack className={classes.entries} component="ul" gap="sm">
{groupItems( {props.entries.map(({ key, operation }) => (
props.entries, <li key={key}>
entry => props.allAccounts <LedgerEntryRow operation={operation} wrap />
? brokerageOperationGroupKey(entry.operation)
: null,
).map(entry => entry.type === "group"
? (
<OperationGroup
entries={entry.items}
key={entry.key}
totalVisible={props.totalVisible === true}
/>
)
: (
<li key={entry.item.key}>
<LedgerEntryRow operation={entry.item.operation} wrap />
</li> </li>
))} ))}
</Stack> </Stack>
@@ -7,7 +7,7 @@ import type { IncomeAnalytics } from "@/entities/income";
import { formatMoney } from "@/shared/lib"; import { formatMoney } from "@/shared/lib";
type IncomeSummaryProps = { totals: IncomeAnalytics["total"] }; type IncomeSummaryProps = { totals: IncomeAnalytics["totalsByCurrency"] };
const IncomeSummary: FC<IncomeSummaryProps> = (props) => { const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -16,14 +16,16 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
<Stack> <Stack>
<Title order={2}>{t("income.summaryTitle")}</Title> <Title order={2}>{t("income.summaryTitle")}</Title>
<Paper p="md" radius="md" withBorder> {props.totals.map(total => (
<Text fw={700}>{formatMoney(props.totals.total)}</Text> <Paper key={total.currency} p="md" radius="md" withBorder>
<Text fw={700}>{formatMoney(total.total)}</Text>
<Text c="dimmed" size="sm"> <Text c="dimmed" size="sm">
{t("income.coupon")}: {formatMoney(props.totals.coupon)} ·{" "} {t("income.coupon")}: {formatMoney(total.coupon)} ·{" "}
{t("income.dividend")}: {formatMoney(props.totals.dividend)} {t("income.dividend")}: {formatMoney(total.dividend)}
</Text> </Text>
</Paper> </Paper>
))}
</Stack> </Stack>
); );
}; };
@@ -2,7 +2,7 @@ import type { FC } from "react";
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw"; import { http, HttpResponse } from "msw";
import { import {
MemoryRouter, MemoryRouter,
@@ -44,25 +44,6 @@ const operation: BrokerageOperation
type: "OPERATION_TYPE_BUY", type: "OPERATION_TYPE_BUY",
}; };
function coupon(
id: string,
accountId: string,
accountName: string,
value: string,
): BrokerageOperation {
return {
...operation,
account: { id: accountId, name: accountName },
category: "income",
description: "Купон",
id,
instrumentUid: "UID",
payment: { currency: "RUB", value },
ticker: "BOND",
type: "OPERATION_TYPE_COUPON",
};
}
const Navigation: FC = () => { const Navigation: FC = () => {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -90,16 +71,12 @@ function configureTestAuth() {
}); });
} }
function renderPage( function renderPage(search = "") {
search = "",
accountId = "account-1",
queryClient = createTestQueryClient(),
) {
return render( return render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={createTestQueryClient()}>
<MantineProvider> <MantineProvider>
<MemoryRouter <MemoryRouter
initialEntries={[`/accounts/${accountId}/operations${search}`]} initialEntries={[`/accounts/account-1/operations${search}`]}
> >
<Routes> <Routes>
<Route <Route
@@ -113,18 +90,6 @@ function renderPage(
); );
} }
// Automatic read retries wait 1s + 2s before the failure is final.
const RETRIES_EXHAUSTED_MS = 6000;
const TEST_MS = 10_000;
const loadedOperations = {
accountId: "account-1",
hasNext: false,
items: [operation],
nextCursor: null,
observedAt: "2026-08-14T00:00:00Z",
};
describe("LedgerEntryRow", () => { describe("LedgerEntryRow", () => {
it("marks cashless operations without rendering operation details", () => { it("marks cashless operations without rendering operation details", () => {
render( render(
@@ -252,27 +217,6 @@ describe("LedgerEntryRow", () => {
}); });
}); });
const historyStateResource = (historyState: "loading" | "partial") =>
HttpResponse.json(
resource(
{
hasNext: false,
items: [operation],
nextCursor: null,
},
{
coverage: {
asOf: null,
completeRanges: [],
historyBoundary: "unknown",
historyState,
missingRanges: [],
requested: [],
},
},
),
);
describe("AccountOperationsPage", () => { describe("AccountOperationsPage", () => {
it.each(["account-1", "all"])( it.each(["account-1", "all"])(
"normalizes invalid periods and history, preserves one-sided UTC dates and resets cursors for %s", "normalizes invalid periods and history, preserves one-sided UTC dates and resets cursors for %s",
@@ -440,7 +384,7 @@ describe("AccountOperationsPage", () => {
renderPage(); renderPage();
expect( expect(
await screen.findByRole("heading", { name: "История операций" }), await screen.findByRole("heading", { name: "Журнал операций" }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
screen.queryByRole("link", { name: "К счету" }), screen.queryByRole("link", { name: "К счету" }),
@@ -482,7 +426,7 @@ describe("AccountOperationsPage", () => {
renderPage(); renderPage();
expect( expect(
await screen.findByRole("heading", { name: "История операций" }), await screen.findByRole("heading", { name: "Журнал операций" }),
).toBeInTheDocument(); ).toBeInTheDocument();
await waitFor(() => expect(searches.length).toBeGreaterThan(0)); await waitFor(() => expect(searches.length).toBeGreaterThan(0));
const countBefore = searches.length; const countBefore = searches.length;
@@ -521,7 +465,7 @@ describe("AccountOperationsPage", () => {
renderPage(); renderPage();
expect( expect(
await screen.findByRole("heading", { name: "История операций" }), await screen.findByRole("heading", { name: "Журнал операций" }),
).toBeInTheDocument(); ).toBeInTheDocument();
fireEvent.click(screen.getByRole("combobox", { name: "Категории" })); fireEvent.click(screen.getByRole("combobox", { name: "Категории" }));
fireEvent.click(await screen.findByText("Налог")); fireEvent.click(await screen.findByText("Налог"));
@@ -534,115 +478,21 @@ describe("AccountOperationsPage", () => {
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
}); });
it("does not show the history loading alert when coverage is partial", async () => { it("renders an error state", async () => {
configureTestAuth(); configureTestAuth();
server.use( server.use(
http.get( http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
"*/api/v1/brokerage-accounts/:accountId/operations", HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
() => historyStateResource("partial"),
),
);
renderPage();
await screen.findByText("Покупка акций");
expect(screen.queryByText("История догружается")).not.toBeInTheDocument();
});
it("shows the history loading alert while historyState is loading", async () => {
configureTestAuth();
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/operations",
() => historyStateResource("loading"),
), ),
); );
renderPage(); renderPage();
expect( expect(
await screen.findByText("История догружается"), await screen.findByText("Не удалось загрузить журнал", undefined, {
).toBeInTheDocument(); timeout: 4000,
});
it(
"renders an error state with retry that recovers in place",
{ timeout: TEST_MS },
async () => {
configureTestAuth();
let isFailing = true;
let requests = 0;
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => {
requests += 1;
return isFailing
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(resource(loadedOperations));
}),
);
renderPage();
expect(
await screen.findByText("Не удалось загрузить историю операций", undefined, {
timeout: RETRIES_EXHAUSTED_MS,
}), }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(requests).toBe(3);
isFailing = false;
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
expect(await screen.findByText("Покупка акций")).toBeInTheDocument();
expect(requests).toBe(4);
},
);
it(
"keeps cached operations, warns and retries the same GET",
{ timeout: TEST_MS },
async () => {
configureTestAuth();
let isFailing = false;
let requests = 0;
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => {
requests += 1;
return isFailing
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(resource(loadedOperations));
}),
);
const queryClient = createTestQueryClient();
renderPage("", "account-1", queryClient);
await screen.findByText("Покупка акций");
expect(screen.queryByRole("button", { name: "Повторить" }))
.not.toBeInTheDocument();
isFailing = true;
void queryClient.invalidateQueries({
queryKey: [
"get",
"/api/v1/brokerage-accounts/{accountId}/operations",
],
}); });
const retry = await screen.findByRole(
"button",
{ name: "Повторить" },
{ timeout: RETRIES_EXHAUSTED_MS },
);
expect(screen.getByText("Не удалось обновить данные")).toBeInTheDocument();
expect(screen.getByText("Покупка акций")).toBeInTheDocument();
expect(requests).toBe(4);
isFailing = false;
fireEvent.click(retry);
await waitFor(() =>
expect(screen.queryByText("Не удалось обновить данные"))
.not.toBeInTheDocument());
expect(requests).toBe(5);
},
);
it("keeps a null operations envelope loading instead of a confirmed empty period", async () => { it("keeps a null operations envelope loading instead of a confirmed empty period", async () => {
configureTestAuth(); configureTestAuth();
server.use( server.use(
@@ -752,73 +602,3 @@ it("recovers an invalid cursor at the first page without clearing applied filter
for (const key of ["from", "to", "categories"]) for (const key of ["from", "to", "categories"])
expect(searches[2].get(key)).toBe(searches[0].get(key)); expect(searches[2].get(key)).toBe(searches[0].get(key));
}); });
describe("all-account operation groups", () => {
it("groups only the current page and rebuilds the group at its boundary", async () => {
configureTestAuth();
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/operations",
({ request }) => {
const cursor = new URL(request.url).searchParams.get("cursor");
const items = cursor
? [
coupon("X2", "iis", "ИИС", "150"),
coupon("X3", "broker", "Брокерский", "50"),
operation,
]
: [coupon("X1", "broker", "Брокерский", "100")];
return HttpResponse.json(resource({
accountId: "all",
hasNext: !cursor,
items,
nextCursor: cursor ? null : "page-2",
}));
},
),
);
renderPage("", "all");
await screen.findAllByText("Купон");
expect(screen.queryByRole("list", { name: "Разбивка по счетам" }))
.not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
const accounts = await screen.findByRole("list", {
name: /Выплата купонов BOND, Исполнено/,
});
expect(screen.getByText("Выплата купонов BOND")).toBeInTheDocument();
expect(screen.getByText("+200 ₽")).toBeInTheDocument();
expect(within(accounts).getAllByRole("listitem")).toHaveLength(2);
fireEvent.click(screen.getByRole("button", { name: "Назад" }));
await screen.findAllByText("Купон");
expect(screen.queryByRole("list", { name: "Разбивка по счетам" }))
.not.toBeInTheDocument();
});
it("leaves repeated operations ungrouped for one account", async () => {
configureTestAuth();
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
HttpResponse.json(resource({
accountId: "account-1",
hasNext: false,
items: [
coupon("X1", "account-1", "Брокерский", "100"),
coupon("X2", "account-1", "Брокерский", "150"),
],
nextCursor: null,
})),
),
);
renderPage();
await screen.findAllByText("Купон");
expect(screen.queryByRole("list", { name: /Разбивка|Исполнено/ }))
.not.toBeInTheDocument();
});
});
@@ -6,15 +6,15 @@ import { AccountOperationsPageLoaded } from "./components/AccountOperationsPageL
import { AccountOperationsPageLoading } from "./components/AccountOperationsPageLoading.view"; import { AccountOperationsPageLoading } from "./components/AccountOperationsPageLoading.view";
const AccountOperationsPage: FC = () => { const AccountOperationsPage: FC = () => {
const vm = useAccountOperationsPageVm(); const viewModel = useAccountOperationsPageVm();
if (vm.state === "loading") { if (viewModel.state === "loading") {
return <AccountOperationsPageLoading />; return <AccountOperationsPageLoading />;
} }
return vm.state === "error" return viewModel.state === "error"
? <AccountOperationsPageError {...vm} /> ? <AccountOperationsPageError {...viewModel} />
: <AccountOperationsPageLoaded {...vm} />; : <AccountOperationsPageLoaded {...viewModel} />;
}; };
export { AccountOperationsPage }; export { AccountOperationsPage };
@@ -12,12 +12,7 @@ import {
useBrokerageOperationHistory, useBrokerageOperationHistory,
} from "@/entities/broker-operation"; } from "@/entities/broker-operation";
import { useScreenSync } from "@/features/tbank-connection"; import { useScreenSync } from "@/features/tbank-connection";
import { import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
ApiErrorCode,
apiErrorCode,
isApiErrorCode,
readRetry,
} from "@/shared/api";
import { import {
defaultPeriod, defaultPeriod,
isValidPeriod, isValidPeriod,
@@ -30,7 +25,6 @@ const operationCategorySet = new Set<string>(operationCategories);
type AccountOperationsPageViewModel type AccountOperationsPageViewModel
= | { = | {
allAccounts: boolean;
canApply: boolean; canApply: boolean;
draftCategories: OperationCategory[]; draftCategories: OperationCategory[];
draftPeriod: [null | string, null | string]; draftPeriod: [null | string, null | string];
@@ -47,16 +41,10 @@ type AccountOperationsPageViewModel
onPrevious: () => void; onPrevious: () => void;
onReset: () => void; onReset: () => void;
partial?: boolean; partial?: boolean;
readRetry: ReturnType<typeof readRetry>;
source?: InvestmentResourceMeta; source?: InvestmentResourceMeta;
state: "loaded"; state: "loaded";
totalVisible: boolean;
}
| {
onReset: () => void;
readRetry: ReturnType<typeof readRetry>;
state: "error";
} }
| { onReset: () => void; state: "error" }
| { state: "loading" }; | { state: "loading" };
const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => { const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
@@ -117,8 +105,6 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
isFetching: operations.isFetching, isFetching: operations.isFetching,
}); });
const retry = readRetry([operations]);
if (availability === "loading" && operations.isLoading) if (availability === "loading" && operations.isLoading)
return { state: "loading" }; return { state: "loading" };
@@ -146,11 +132,11 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
(isInvalidCursor || !operations.data) (isInvalidCursor || !operations.data)
&& (operations.isError || operations.meta?.lastAttempt.state === "failed") && (operations.isError || operations.meta?.lastAttempt.state === "failed")
) )
return { onReset, readRetry: retry, state: "error" }; return { onReset, state: "error" };
if (!operations.data) if (!operations.data)
return availability === "unavailable" return availability === "unavailable"
? { onReset, readRetry: retry, state: "error" } ? { onReset, state: "error" }
: { state: "loading" }; : { state: "loading" };
const nextCursor = operations.data.nextCursor; const nextCursor = operations.data.nextCursor;
@@ -161,13 +147,14 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
|| !isSameCategories(draftCategories, appliedCategories); || !isSameCategories(draftCategories, appliedCategories);
return { return {
allAccounts: accountId === "all",
canApply: isDirty && isValidPeriod(draftFrom, draftTo), canApply: isDirty && isValidPeriod(draftFrom, draftTo),
draftCategories, draftCategories,
draftPeriod, draftPeriod,
hasNext: Boolean(nextCursor && operations.data.hasNext), hasNext: Boolean(nextCursor && operations.data.hasNext),
hasPrevious: history.hasPrevious, hasPrevious: history.hasPrevious,
historyLoading: operations.meta?.coverage?.historyState === "loading", historyLoading:
operations.meta?.coverage?.historyState === "loading"
|| operations.meta?.coverage?.historyState === "partial",
items: operations.data.items, items: operations.data.items,
maxDate, maxDate,
onApply: () => { onApply: () => {
@@ -194,10 +181,8 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
onPrevious: history.onPrevious, onPrevious: history.onPrevious,
onReset, onReset,
partial: operations.meta?.completeness === "incomplete", partial: operations.meta?.completeness === "incomplete",
readRetry: retry,
source: operations.meta, source: operations.meta,
state: "loaded", state: "loaded",
totalVisible: operations.meta?.completeness === "complete",
}; };
}; };
@@ -1,11 +1,9 @@
import type { FC } from "react"; import type { FC } from "react";
import { Button, Group } from "@mantine/core"; import { Button } from "@mantine/core";
import { StateView } from "@wealthledger/ui"; import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ReadRetryButton } from "@/shared/ui";
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm"; import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
type AccountOperationsPageErrorProps = Extract< type AccountOperationsPageErrorProps = Extract<
@@ -21,18 +19,9 @@ const AccountOperationsPageError: FC<AccountOperationsPageErrorProps> = (
return ( return (
<StateView <StateView
action={( action={(
<Group gap="sm" justify="center">
{props.readRetry.exhausted && (
<ReadRetryButton
onRetry={props.readRetry.onRetry}
retrying={props.readRetry.retrying}
/>
)}
<Button onClick={props.onReset} variant="default"> <Button onClick={props.onReset} variant="default">
{t("ledger.reset")} {t("ledger.reset")}
</Button> </Button>
</Group>
)} )}
text={t("ledger.errorText")} text={t("ledger.errorText")}
title={t("ledger.errorTitle")} title={t("ledger.errorTitle")}
@@ -12,14 +12,8 @@ import { DatePickerInput } from "@mantine/dates";
import { StateView } from "@wealthledger/ui"; import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { LedgerEntryRow, operationCategories } from "@/entities/broker-operation";
brokerageOperationGroupKey, import { SourceSyncStatus } from "@/shared/ui";
LedgerEntryRow,
operationCategories,
OperationGroup,
} from "@/entities/broker-operation";
import { groupItems } from "@/shared/lib";
import { ReadRetry, SourceSyncStatus } from "@/shared/ui";
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm"; import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
@@ -30,49 +24,22 @@ type AccountOperationsPageLoadedProps = Extract<
const AccountOperationsPageLoadedOperations: FC< const AccountOperationsPageLoadedOperations: FC<
AccountOperationsPageLoadedProps AccountOperationsPageLoadedProps
> = (props) => { > = (props) => { // eslint-disable-line sonarjs/function-return-type -- React renders elements and lists.
const { t } = useTranslation(); const { t } = useTranslation();
const { items } = props; const { items } = props;
if (items.length > 0) { if (items.length > 0) {
const entries = groupItems( return items.map(operation => (
items.map(operation => ({ <LedgerEntryRow
key: JSON.stringify([ key={JSON.stringify([
operation.account?.id, operation.account?.id,
operation.id, operation.id,
operation.logoName, operation.logoName,
]), ])}
operation, operation={operation}
})),
entry => props.allAccounts
? brokerageOperationGroupKey(entry.operation)
: null,
);
return (
<Stack
component="ul"
gap="lg"
m={0}
p={0}
style={{ listStyle: "none" }}
>
{entries.map(entry => entry.type === "group"
? (
<OperationGroup
entries={entry.items}
key={entry.key}
totalVisible={props.totalVisible}
/> />
) ));
: (
<li key={entry.item.key}>
<LedgerEntryRow operation={entry.item.operation} />
</li>
))}
</Stack>
);
} }
if (props.partial) { if (props.partial) {
@@ -102,8 +69,6 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
stale={props.source?.freshness === "stale"} stale={props.source?.freshness === "stale"}
/> />
<ReadRetry {...props.readRetry} />
{!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>} {!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>}
{!!props.historyLoading && <Alert>{t("investment.history")}</Alert>} {!!props.historyLoading && <Alert>{t("investment.history")}</Alert>}
@@ -1,173 +0,0 @@
import { describe, expect, it } from "vitest";
import type { Portfolio } from "@/entities/portfolio";
import { toConcentration } from "./concentration";
import { portfolio, position } from "./positions.fixtures";
function snapshot(positions: Portfolio["positions"]): Portfolio {
return { ...portfolio, positions };
}
function stock(
name: string,
value: null | string,
currency = "RUB",
overrides: Partial<Portfolio["positions"][number]> = {},
): Portfolio["positions"][number] {
return {
...position,
currentValue: value === null ? null : { currency, value },
displayName: name,
...overrides,
};
}
const labels = {
missingIssuer: "Эмитент не указан",
missingSector: "Сектор не указан",
other: "Прочее",
sector: (code: string) => code,
};
describe("account concentration", () => {
it("uses exact positive values and excludes cash, negatives and nulls", () => {
const model = toConcentration(snapshot([
stock("A", "1"), stock("B", "15"),
stock("C", "0"), stock("D", "-50"), stock("E", null),
]), "stocks", labels);
expect(model.total).toBe("16");
expect(model.items.map(item => item.share)).toEqual(["93,8 %", "6,3 %"]);
expect(model.unaccounted).toBe(2);
const negative = snapshot([stock("A", "-3")]);
expect(toConcentration(negative, "stocks", labels))
.toMatchObject({ empty: true, unaccounted: 1 });
});
it("ranks seven positions with exact other and top-five totals", () => {
const model = toConcentration(snapshot([
stock("A", "400"), stock("B", "250"), stock("C", "150"),
stock("D", "100"), stock("E", "50"), stock("F", "30"),
stock("G", "20"),
]), "stocks", labels);
expect(model.items.map(item => item.amount)).toEqual([
"400", "250", "150", "100", "50",
]);
expect(model.other).toMatchObject({
amount: "50", count: 2, share: "5 %",
});
expect(model.top5Share).toBe("95 %");
expect(model.other?.entries.map(item => item.share)).toEqual([
"3 %", "2 %",
]);
});
it("keeps sixth row, tiny share and stable alphabetical ties", () => {
const model = toConcentration(snapshot([
stock("Сбер", "100"), stock("X5", "100"),
stock("VK", "100"), stock("D", "100"),
stock("E", "100"), stock("F", "100"),
]), "stocks", labels);
expect(model.items).toHaveLength(6);
expect(model.other).toBeNull();
expect(model.items.slice(0, 2).map(item => item.name)).toEqual([
"D", "E",
]);
const tiny = snapshot([
stock("Large", "100000000"), stock("Small", "1"),
]);
expect(toConcentration(tiny, "stocks", labels).items[1]?.share)
.toBe("< 0,1 %");
});
it("groups sectors and issuers by codes, not labels", () => {
const positions = [
stock("A", "300", "RUB", {
issuer: { id: "one", name: "Сбербанк" }, sector: "energy",
}),
stock("B", "200", "RUB", {
issuer: { id: "one", name: "Сбер" }, sector: "energy",
type: "BOND",
}),
stock("C", "100", "RUB", {
issuer: { id: "two", name: "Сбер" }, sector: "financial",
type: "FUND",
}),
stock("D", "50", "RUB", { issuer: null, sector: null }),
];
expect(toConcentration(snapshot(positions), "sectors", labels)
.items.map(item => item.amount)).toEqual(["500", "100", "50"]);
expect(toConcentration(snapshot(positions), "issuers", labels)
.items.map(item => [item.name, item.amount])).toEqual([
["Сбербанк", "500"], ["", "50"],
]);
});
it("orders equal sums by displayed name", () => {
const issuers = snapshot([
stock("A", "300", "RUB", {
issuer: { id: "g", name: "Газпром" }, sector: "energy",
}),
stock("B", "300", "RUB", {
issuer: { id: "s", name: "Сбер" }, sector: "energy",
}),
stock("C", "200", "RUB", { issuer: null, sector: "energy" }),
stock("D", "100", "RUB", { issuer: null, sector: "energy" }),
]);
expect(toConcentration(issuers, "issuers", labels)
.items.map(item => [item.label, item.amount])).toEqual([
["Газпром", "300"], ["Сбер", "300"], ["Эмитент не указан", "300"],
]);
const sectors = snapshot([
stock("A", "100", "RUB", { sector: "it" }),
stock("B", "100", "RUB", { sector: "energy" }),
]);
const sectorLabels = {
...labels,
sector: (code: string) => code === "it"
? "Информационные технологии"
: "Энергетика",
};
expect(toConcentration(sectors, "sectors", sectorLabels)
.items.map(item => item.label))
.toEqual(["Информационные технологии", "Энергетика"]);
});
it("chooses issuer name from the largest position, then alphabetically", () => {
const data = snapshot([
stock("A", "100", "RUB", {
issuer: { id: "brand", name: "Ястреб" },
}),
stock("B", "300", "RUB", {
issuer: { id: "brand", name: "Альфа" },
}),
stock("C", "400", "RUB", {
issuer: { id: "other", name: "Бета" },
}),
]);
expect(toConcentration(data, "issuers", labels).items
.map(item => [item.name, item.label, item.amount])).toEqual([
["Альфа", "Альфа", "400"], ["Бета", "Бета", "400"],
]);
});
it("uses ISIN before ticker for bonds", () => {
const data = snapshot([stock("Облигация", "100", "RUB", {
isin: "RU000A0JX0J2", ticker: "BOND", type: "BOND",
})]);
expect(toConcentration(data, "bonds", labels)
.items[0]?.logo?.subtitle).toBe("RU000A0JX0J2");
});
});
-276
View File
@@ -1,276 +0,0 @@
import type { Portfolio } from "@/entities/portfolio";
import {
type Decimal,
decimalFromString,
decimalToString,
instrumentLogoUrl,
sumDecimals,
} from "@/shared/lib";
type Concentration = {
empty: boolean;
items: ConcentrationItem[];
other: ConcentrationOther | null;
top5Share: null | string;
total: null | string;
unaccounted: number;
};
type ConcentrationItem = {
amount: string;
chartValue: number;
key: string;
label: string;
logo: ConcentrationLogo | null;
name: string;
share: string;
unknown: boolean;
};
type ConcentrationLabels = {
missingIssuer: string;
missingSector: string;
other: string;
sector: (code: string) => string;
};
type ConcentrationLogo = {
src: null | string;
subtitle: null | string;
};
type ConcentrationOther = ConcentrationItem & {
count: number;
entries: ConcentrationItem[];
};
type Group = {
best?: { name: string; value: Decimal };
first: number;
key: string;
label: string;
logo: ConcentrationLogo | null;
name: string;
unknown: boolean;
values: Decimal[];
};
type Position = Portfolio["positions"][number];
type Slice = "bonds"
| "issuers"
| "sectors"
| "stocks";
const eligibleTypes = new Set(["BOND", "FUND", "STOCK"]);
const RADIX = 10n;
const CHART_PRECISION = 1_000_000n;
const SHARE_PRECISION = 1000n;
const HALF = 2n;
const TOP = 5;
const FIRST_OTHER = 7;
const compareNames = (a: string, b: string) =>
a.toLowerCase().localeCompare(b.toLowerCase(), "en", {
numeric: false,
});
function addPosition(
groups: Map<string, Group>,
position: Position,
index: number,
slice: Slice,
labels: ConcentrationLabels,
): number {
if (!isIncluded(slice, position.type)) return 0;
if (!position.currentValue) return 1;
const value = decimalFromString(position.currentValue.value);
if (value.value < 0n) return 1;
if (value.value === 0n) return 0;
const identity = groupIdentity(position, slice, index, labels);
const group = groups.get(identity.key) ?? {
...identity, first: index, values: [],
};
group.values.push(value);
if (slice === "issuers" && !identity.unknown) {
const best = group.best;
if (!best || compareDecimal(value, best.value) > 0
|| (compareDecimal(value, best.value) === 0
&& compareNames(identity.name, best.name) < 0)) {
group.best = { name: identity.name, value };
group.name = identity.name;
group.label = identity.label;
}
}
groups.set(identity.key, group);
return 0;
}
function chartValue(part: Decimal, total: Decimal): number {
const scale = Math.max(part.scale, total.scale);
const numerator = part.value * RADIX ** BigInt(scale - part.scale);
const denominator = total.value * RADIX ** BigInt(scale - total.scale);
return Number(numerator * CHART_PRECISION / denominator)
/ Number(CHART_PRECISION);
}
function compareDecimal(a: Decimal, b: Decimal): number {
const scale = Math.max(a.scale, b.scale);
const left = a.value * RADIX ** BigInt(scale - a.scale);
const right = b.value * RADIX ** BigInt(scale - b.scale);
if (left > right) return 1;
return left < right ? -1 : 0;
}
function groupIdentity(
position: Position,
slice: Slice,
index: number,
labels: ConcentrationLabels,
) {
if (slice === "sectors") {
const sector = position.sector;
const isUnknown = !sector;
return {
key: sector ?? "",
label: isUnknown
? labels.missingSector
: labels.sector(sector),
logo: null,
name: sector ?? "",
unknown: isUnknown,
};
}
if (slice === "issuers") {
const isUnknown = !position.issuer;
return {
key: position.issuer?.id ?? "",
label: position.issuer ? position.issuer.name : labels.missingIssuer,
logo: null,
name: position.issuer?.name ?? "",
unknown: isUnknown,
};
}
return {
key: String(index),
label: position.displayName,
logo: {
src: instrumentLogoUrl(position.logoName),
subtitle: slice === "bonds"
? position.isin ?? position.ticker ?? position.figi
: position.ticker ?? position.isin ?? position.figi,
},
name: position.displayName,
unknown: false,
};
}
function isIncluded(slice: Slice, type: string): boolean {
if (slice === "stocks") return type === "STOCK";
if (slice === "bonds") return type === "BOND";
return slice === "issuers" ? type === "STOCK" || type === "BOND" : eligibleTypes.has(type);
}
function share(part: Decimal, total: Decimal): string {
const scale = Math.max(part.scale, total.scale);
const numerator = part.value * RADIX ** BigInt(scale - part.scale);
const denominator = total.value * RADIX ** BigInt(scale - total.scale);
if (numerator * SHARE_PRECISION < denominator) return "< 0,1 %";
const tenths = (numerator * SHARE_PRECISION * HALF + denominator)
/ (HALF * denominator);
return tenths % RADIX === 0n ? `${tenths / RADIX} %` : `${tenths / RADIX},${tenths % RADIX} %`;
}
function toConcentration(
portfolio: Portfolio,
slice: Slice,
labels: ConcentrationLabels,
): Concentration {
const groups = new Map<string, Group>();
let unaccounted = 0;
for (const [index, position] of portfolio.positions.entries()) {
unaccounted += addPosition(groups, position, index, slice, labels);
}
const ranked = Array.from(groups.values(), group => ({
...group, sum: sumDecimals(group.values),
})).toSorted((a, b) => compareDecimal(b.sum, a.sum)
|| compareNames(a.label, b.label) || a.first - b.first);
const total = ranked.length > 0
? sumDecimals(ranked.map(item => item.sum))
: null;
const entries = total
? ranked.map(group => ({
amount: decimalToString(group.sum),
chartValue: chartValue(group.sum, total),
key: group.key,
label: group.label,
logo: group.logo,
name: group.name,
share: share(group.sum, total),
unknown: group.unknown,
}))
: [];
const rest = entries.length >= FIRST_OTHER ? entries.slice(TOP) : [];
const otherSum = rest.length > 0
? sumDecimals(ranked.slice(TOP).map(item => item.sum))
: null;
const other = otherSum && total
? {
amount: decimalToString(otherSum),
chartValue: chartValue(otherSum, total),
count: rest.length,
entries: rest,
key: "__other",
label: labels.other,
logo: null,
name: "", share: share(otherSum, total), unknown: false,
}
: null;
const top5 = ranked.length > TOP
? sumDecimals(ranked.slice(0, TOP).map(item => item.sum))
: null;
return {
empty: !total,
items: other ? entries.slice(0, TOP) : entries,
other,
top5Share: top5 && total ? share(top5, total) : null,
total: total && decimalToString(total),
unaccounted,
};
}
export {
toConcentration,
};
export type {
Concentration,
ConcentrationItem,
ConcentrationLabels,
Slice,
};
-104
View File
@@ -1,104 +0,0 @@
import type { Performance } from "@/entities/performance";
import { formatDate, formatMoney, formatPercentagePoints, formatSignedPercent } from "@/shared/lib";
type TextValue = { tone: Tone; value: string };
type Tone
= | "negative"
| "neutral"
| "positive";
const reasonLabels: Record<string, string> = {
ACCOUNT_VALUATION_DATES_DIFFER: "Оценки счетов сделаны в разные дни",
AMBIGUOUS_RETURN: "У расчёта доходности несколько возможных решений",
HISTORY_START_UNKNOWN: "Начало истории счёта неизвестно",
INDEX_HISTORY_UNAVAILABLE: "История индекса временно недоступна",
NO_SOLUTION: "Расчёт доходности не сходится",
OPERATIONS_HISTORY_INCOMPLETE: "История операций ещё не получена полностью",
PORTFOLIO_VALUATION_INCOMPLETE: "Нет полной оценки портфеля",
UNCONFIRMED_TRANSFER: "Перевод между счетами не удалось подтвердить",
UNVALUED_SECURITIES_TRANSFER: "Бумаги переведены без денежной оценки",
};
const unknownReason = "Причину недоступности не удалось определить";
function performanceVm(data: Performance) {
return {
basis: data.return.basis,
comparison: data.outperformance
? {
money: signedMoney(data.outperformance.money),
points: valueText(
data.outperformance.percentagePoints,
formatPercentagePoints,
),
}
: null,
current: formatMoney(data.currentValue),
currentPoint: data.series.length > 0
? { date: formatDate(`${data.series.at(-1)?.date ?? data.period.to}T00:00:00Z`) }
: null,
days: data.period.days,
excluded: data.excludedAccounts.map(item => ({
id: item.accountId,
name: item.name,
reason: reasonText(item.reason),
})),
from: data.period.from,
indexReason: data.benchmark.unavailableReason
? reasonText(data.benchmark.unavailableReason)
: null,
indexReturn: valueText(data.benchmark.return.value, formatSignedPercent),
indexValue: data.benchmark.value ? formatMoney(data.benchmark.value) : "—",
invested: formatMoney(data.netInvested),
profit: signedMoney(data.profit),
returnReason: data.return.unavailableReason
? reasonText(data.return.unavailableReason)
: null,
returnValue: valueText(data.return.value, formatSignedPercent),
series: data.series.map(item => ({
benchmarkValue: Number(item.benchmarkValue),
date: item.date,
label: formatDate(`${item.date}T00:00:00Z`),
netInvested: Number(item.netInvested),
referenceValue: item.referenceValue === null
? null
: Number(item.referenceValue),
})),
to: data.period.to,
};
}
function reasonText(reason: string): string {
// eslint-disable-next-line security/detect-object-injection -- Lookup accepts only an API reason, with a safe fallback.
return reasonLabels[reason] ?? unknownReason;
}
function signedMoney(value: Performance["profit"]): TextValue {
return {
tone: tone(value.value),
value: formatMoney(value, { withSign: true }),
};
}
function tone(value: string): Tone {
const number = Number(value);
if (number > 0) return "positive";
return number < 0 ? "negative" : "neutral";
}
// Tone follows the displayed text, so "0,0 %" is never coloured.
function valueText(
value: null | string,
format: (value: string) => string,
): TextValue {
if (value === null) return { tone: "neutral", value: "—" };
const text = format(value);
if (text.startsWith("+")) return { tone: "positive", value: text };
return { tone: text.startsWith("−") ? "negative" : "neutral", value: text };
}
export { performanceVm, reasonText };
+3 -14
View File
@@ -2,7 +2,7 @@ import type { Portfolio } from "@/entities/portfolio";
const portfolio const portfolio
= { = {
cash: { currency: "RUB", value: "1" }, cashBalances: [{ currency: "USD", value: "1" }],
isStale: true, isStale: true,
lastSuccessfulAt: "2026-08-13T10:00:00Z", lastSuccessfulAt: "2026-08-13T10:00:00Z",
positions: [ positions: [
@@ -13,10 +13,8 @@ const portfolio
displayName: "Акция", displayName: "Акция",
figi: "FIGI-STOCK", figi: "FIGI-STOCK",
isin: null, isin: null,
issuer: null,
logoName: "akciya.png", logoName: "akciya.png",
quantity: "2", quantity: "2",
sector: null,
ticker: null, ticker: null,
type: "STOCK", type: "STOCK",
}, },
@@ -27,10 +25,8 @@ const portfolio
displayName: "Облигация", displayName: "Облигация",
figi: "FIGI-BOND", figi: "FIGI-BOND",
isin: "RU000A0JX0J2", isin: "RU000A0JX0J2",
issuer: null,
logoName: null, logoName: null,
quantity: "1", quantity: "1",
sector: null,
ticker: null, ticker: null,
type: "BOND", type: "BOND",
}, },
@@ -41,21 +37,14 @@ const portfolio
displayName: "Фонд", displayName: "Фонд",
figi: "FIGI-FUND", figi: "FIGI-FUND",
isin: null, isin: null,
issuer: null,
logoName: "fond.png", logoName: "fond.png",
quantity: "3", quantity: "3",
sector: null,
ticker: null, ticker: null,
type: "FUND", type: "FUND",
}, },
] satisfies [Portfolio["positions"][number], ...Portfolio["positions"]], ] satisfies [Portfolio["positions"][number], ...Portfolio["positions"]],
structure: { structure: { currencies: [], unvaluedCategories: [] },
categories: [], totalsByCurrency: [{ currency: "RUB", value: "150" }],
hasNegativeValues: false,
totalValue: "0",
unvaluedCategories: [],
},
totals: { currency: "RUB", value: "150" },
} satisfies Portfolio; } satisfies Portfolio;
const position: Portfolio["positions"][number] = { const position: Portfolio["positions"][number] = {
+23 -32
View File
@@ -23,7 +23,7 @@ function sections(positions: Portfolio["positions"]) {
} }
describe("account position sections", () => { describe("account position sections", () => {
it("excludes currency mirrors from display and share denominators", () => { it("excludes currency instruments only from display, preserving order and share denominators", () => {
const otherTypes = [ const otherTypes = [
"OPTION", "OPTION",
"OTHER", "OTHER",
@@ -35,7 +35,7 @@ describe("account position sections", () => {
const snapshot: Portfolio = { const snapshot: Portfolio = {
...portfolio, ...portfolio,
cash: { currency: "RUB", value: "1000" }, cashBalances: [{ currency: "RUB", value: "1000" }],
positions: [ positions: [
...portfolio.positions, ...portfolio.positions,
{ {
@@ -56,8 +56,8 @@ describe("account position sections", () => {
}), }),
), ),
], ],
structure: { categories: [], hasNegativeValues: false, totalValue: "0", unvaluedCategories: [] }, structure: { currencies: [], unvaluedCategories: [] },
totals: { currency: "RUB", value: "1250" }, totalsByCurrency: [{ currency: "RUB", value: "1250" }],
}; };
const original = structuredClone(snapshot); const original = structuredClone(snapshot);
@@ -73,30 +73,12 @@ describe("account position sections", () => {
"FIGI-FUND", "FIGI-FUND",
...otherTypes.map(type => `FIGI-${type}-NEXT`), ...otherTypes.map(type => `FIGI-${type}-NEXT`),
]); ]);
expect(sections[0]?.groups[0]?.positions[0].shareText).toBe("50 %"); expect(sections[0]?.groups[0]?.positions[0].shareText).toBe("40 %");
expect(sections[1]?.groups[0]?.positions[0].shareText).toBe("25 %"); expect(sections[1]?.groups[0]?.positions[0].shareText).toBe("20 %");
expect(sections[2]?.groups[1]?.positions[0].shareText).toBe("25 %"); expect(sections[2]?.groups[1]?.positions[0].shareText).toBe("20 %");
expect(snapshot).toEqual(original); expect(snapshot).toEqual(original);
}); });
it("keeps all-account positions from both accounts without counting mirrored cash", () => {
const result = sections([
makePosition({ account: { id: "a", name: "Основной" }, currentValue: { currency: "RUB", value: "100" } }),
makePosition({ account: { id: "a", name: "Основной" }, currentValue: { currency: "RUB", value: "50" }, type: "CURRENCY" }),
makePosition({ account: { id: "b", name: "ИИС" }, currentValue: { currency: "RUB", value: "100" } }),
]);
const visible = result.flatMap(section =>
section.groups.flatMap(group => group.positions));
expect(visible)
.toEqual(expect.arrayContaining([
expect.objectContaining({ account: { id: "a", name: "Основной" }, shareText: "50 %" }),
expect.objectContaining({ account: { id: "b", name: "ИИС" }, shareText: "50 %" }),
]));
expect(visible).toHaveLength(2);
});
it("prepares grouped position sections with derived display data", () => { it("prepares grouped position sections with derived display data", () => {
expect( expect(
toAccountPagePositionSections(portfolio).map(({ groups, kind }) => ({ toAccountPagePositionSections(portfolio).map(({ groups, kind }) => ({
@@ -193,13 +175,13 @@ describe("account position sections", () => {
).toHaveLength(2); ).toHaveLength(2);
}); });
it("keeps values and purchase prices attached to their source positions", () => { it("keeps currencies and purchase prices attached to their source positions", () => {
const source = [ const source = [
position, position,
{ {
...second, ...second,
averagePurchasePrice: { currency: "RUB", value: "0.99" }, averagePurchasePrice: { currency: "USD", value: "0.99" },
currentValue: { currency: "RUB", value: "1.01" }, currentValue: { currency: "USD", value: "1.01" },
}, },
]; ];
@@ -264,6 +246,10 @@ describe("account position returns", () => {
{ name: "zero quantity", overrides: { quantity: "0" } }, { name: "zero quantity", overrides: { quantity: "0" } },
{ name: "negative quantity", overrides: { quantity: "-1" } }, { name: "negative quantity", overrides: { quantity: "-1" } },
{ name: "missing average price", overrides: { averagePurchasePrice: null } }, { name: "missing average price", overrides: { averagePurchasePrice: null } },
{
name: "currency mismatch",
overrides: { averagePurchasePrice: { currency: "USD", value: "280" } },
},
{ name: "zero cost", overrides: { averagePurchasePrice: { currency: "RUB", value: "0" } } }, { name: "zero cost", overrides: { averagePurchasePrice: { currency: "RUB", value: "0" } } },
])("returns null for $name", ({ overrides }) => { ])("returns null for $name", ({ overrides }) => {
const result = sections([makePosition({ const result = sections([makePosition({
@@ -297,24 +283,29 @@ describe("account position returns", () => {
expect(formatPositionReturnPercent(testCase.value)).toBe(testCase.expected); expect(formatPositionReturnPercent(testCase.value)).toBe(testCase.expected);
}); });
it("sums complete unrealized returns", () => { it("sums complete unrealized returns by currency", () => {
const result = toUnrealizedReturns([ const result = toUnrealizedReturns([
makePosition({ absoluteReturn: { currency: "RUB", value: "0.1" } }), makePosition({ absoluteReturn: { currency: "RUB", value: "0.1" } }),
makePosition({ absoluteReturn: { currency: "RUB", value: "0.2" } }), makePosition({ absoluteReturn: { currency: "RUB", value: "0.2" } }),
makePosition({ absoluteReturn: { currency: "USD", value: "-2.5" } }),
]); ]);
expect(result).toEqual({ currency: "RUB", value: "0.3" }); expect(result).toEqual([
{ currency: "RUB", value: "0.3" },
{ currency: "USD", value: "-2.5" },
]);
}); });
it("returns null when any return is incomplete", () => { it("omits only currencies with incomplete returns", () => {
const result = toUnrealizedReturns([ const result = toUnrealizedReturns([
makePosition({ absoluteReturn: { currency: "RUB", value: "10" } }), makePosition({ absoluteReturn: { currency: "RUB", value: "10" } }),
makePosition({ makePosition({
absoluteReturn: null, absoluteReturn: null,
currentValue: { currency: "RUB", value: "100" }, currentValue: { currency: "RUB", value: "100" },
}), }),
makePosition({ absoluteReturn: { currency: "USD", value: "2" } }),
]); ]);
expect(result).toBeNull(); expect(result).toEqual([{ currency: "USD", value: "2" }]);
}); });
}); });
+66 -19
View File
@@ -1,13 +1,5 @@
import { formatShare, type Portfolio } from "@/entities/portfolio"; import { formatShare, type Portfolio } from "@/entities/portfolio";
import { import { formatDecimal, formatMoney, instrumentLogoUrl } from "@/shared/lib";
type Decimal,
decimalFromString,
decimalToString,
formatDecimal,
formatMoney,
instrumentLogoUrl,
sumDecimals,
} from "@/shared/lib";
type AccountPagePosition = Portfolio["positions"][number] & { type AccountPagePosition = Portfolio["positions"][number] & {
instrumentTo?: string; instrumentTo?: string;
@@ -31,6 +23,8 @@ type AccountPagePositionSection = {
type AccountPageReturnTone = "negative" | "positive"; type AccountPageReturnTone = "negative" | "positive";
type Decimal = { scale: number; value: bigint };
type Money = NonNullable<Portfolio["positions"][number]["absoluteReturn"]>; type Money = NonNullable<Portfolio["positions"][number]["absoluteReturn"]>;
type PositionSectionKind = "bonds" type PositionSectionKind = "bonds"
@@ -38,11 +32,38 @@ type PositionSectionKind = "bonds"
| "stocks"; | "stocks";
const RADIX = 10n; const RADIX = 10n;
const DECIMAL_PARTS = 2;
const PERCENT_SCALE = 6; const PERCENT_SCALE = 6;
const DISPLAY_PERCENT_SCALE = 2; const DISPLAY_PERCENT_SCALE = 2;
const PERCENT_FACTOR = 100n; const PERCENT_FACTOR = 100n;
const ROUNDING_FACTOR = 2n; const ROUNDING_FACTOR = 2n;
function decimalFromString(value: string): Decimal {
const [whole = "0", fraction = ""] = value.split(".", DECIMAL_PARTS);
return { scale: fraction.length, value: BigInt(`${whole}${fraction}`) };
}
function decimalToString(decimal: Decimal): string {
const sign = decimal.value < 0n ? "-" : "";
const digits = (decimal.value < 0n ? -decimal.value : decimal.value)
.toString()
.padStart(decimal.scale + 1, "0");
const whole = decimal.scale
? digits.slice(0, -decimal.scale)
: digits;
let fraction = decimal.scale ? digits.slice(-decimal.scale) : "";
while (fraction.endsWith("0")) fraction = fraction.slice(0, -1);
const suffix = fraction ? `.${fraction}` : "";
return `${sign}${whole}${suffix}`;
}
function formatPositionReturnPercent(value: string): string { function formatPositionReturnPercent(value: string): string {
const decimal = decimalFromString(value); const decimal = decimalFromString(value);
let rounded = decimal.value; let rounded = decimal.value;
@@ -146,13 +167,25 @@ function returnPercent(
return decimalToString({ scale: PERCENT_SCALE, value: rounded }); return decimalToString({ scale: PERCENT_SCALE, value: rounded });
} }
function sumDecimals(values: Decimal[]): Decimal {
const scale = Math.max(...values.map(value => value.scale));
const sum = values.reduce(
(total, value) => total
+ value.value * RADIX ** BigInt(scale - value.scale),
0n,
);
return { scale, value: sum };
}
function toAccountPagePositionSections( function toAccountPagePositionSections(
portfolio: Portfolio, portfolio: Portfolio,
): AccountPagePositionSection[] { ): AccountPagePositionSection[] {
const denominators = new Map<string, number>(); const denominators = new Map<string, number>();
for (const position of portfolio.positions) { for (const position of portfolio.positions) {
if (position.type === "CURRENCY" || !position.currentValue) continue; if (!position.currentValue) continue;
const currency = position.currentValue.currency; const currency = position.currentValue.currency;
denominators.set( denominators.set(
currency, currency,
@@ -249,23 +282,37 @@ function toSection(
function toUnrealizedReturns( function toUnrealizedReturns(
positions: Portfolio["positions"], positions: Portfolio["positions"],
): Money | null { ): Money[] {
const values: Decimal[] = []; const totals = new Map<string, {
complete: boolean;
values: Decimal[];
}>();
for (const position of positions) { for (const position of positions) {
if (!position.absoluteReturn) { const currencies = position.absoluteReturn
// A position without any valuation has nothing to contribute. ? [position.absoluteReturn.currency]
: [...new Set([
position.averagePurchasePrice?.currency,
position.currentValue?.currency,
].filter((currency): currency is string => !!currency))];
if (!position.averagePurchasePrice && !position.currentValue) for (const currency of currencies) {
continue; const total = totals.get(currency) ?? { complete: true, values: [] };
return null; if (position.absoluteReturn) {
total.values.push(decimalFromString(position.absoluteReturn.value));
}
else {
total.complete = false;
} }
values.push(decimalFromString(position.absoluteReturn.value)); totals.set(currency, total);
}
} }
return { currency: "RUB", value: decimalToString(sumDecimals(values)) }; return [...totals].flatMap(([currency, total]) => total.complete
? [{ currency, value: decimalToString(sumDecimals(total.values)) }]
: []);
} }
export { export {
+2 -11
View File
@@ -33,7 +33,6 @@ type AccountPageRecentOperations
stale?: boolean; stale?: boolean;
state: "loaded" | "partial"; state: "loaded" | "partial";
to: string; to: string;
totalVisible?: boolean;
} }
| { | {
state: "empty" state: "empty"
@@ -64,7 +63,7 @@ function toIncomeSummary(
if (income.meta?.completeness === "incomplete") return { state: "partial", to }; if (income.meta?.completeness === "incomplete") return { state: "partial", to };
if (!income.data && (income.isError || income.meta?.lastAttempt.state === "failed")) if (!income.data && (income.isError || income.meta?.lastAttempt.state === "failed"))
return { state: "error", to }; return { state: "error", to };
if (!income.data) return { state: "empty", to }; if (!income.data?.totalsByCurrency.length) return { state: "empty", to };
return { return {
state: "loaded", state: "loaded",
@@ -85,7 +84,6 @@ function toRecentEvents(
items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [], items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [],
state: "partial", state: "partial",
to, to,
totalVisible: false,
}; };
if (!events.data && (events.isError || events.meta?.lastAttempt.state === "failed")) if (!events.data && (events.isError || events.meta?.lastAttempt.state === "failed"))
return { state: "error", to }; return { state: "error", to };
@@ -96,7 +94,6 @@ function toRecentEvents(
items, items,
state: "loaded", state: "loaded",
to, to,
totalVisible: events.meta?.completeness === "complete",
}; };
} }
@@ -112,12 +109,7 @@ function toRecentOperations(
) )
return { state: "loading", to }; return { state: "loading", to };
if (operations.meta?.completeness === "incomplete") if (operations.meta?.completeness === "incomplete")
return { return { items: operations.data?.items ?? [], state: "partial", to };
items: operations.data?.items ?? [],
state: "partial",
to,
totalVisible: false,
};
if ( if (
!operations.data !operations.data
&& (operations.isError || operations.meta?.lastAttempt.state === "failed") && (operations.isError || operations.meta?.lastAttempt.state === "failed")
@@ -133,7 +125,6 @@ function toRecentOperations(
stale: operations.meta?.freshness === "stale", stale: operations.meta?.freshness === "stale",
state: "loaded", state: "loaded",
to, to,
totalVisible: operations.meta?.completeness === "complete",
}; };
} }
File diff suppressed because it is too large Load Diff
@@ -1,46 +1,37 @@
import type { FC } from "react"; import type { FC } from "react";
import { useAccountPageVm } from "./AccountPage.vm"; import { useAccountPageViewModel } from "./AccountPage.vm";
import { AccountPageEmpty } from "./components/AccountPageEmpty.view"; import { AccountPageEmpty } from "./components/AccountPageEmpty.view";
import { AccountPageError } from "./components/AccountPageError.view"; import { AccountPageError } from "./components/AccountPageError.view";
import { AccountPageLoaded } from "./components/AccountPageLoaded.view"; import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
import { AccountPageLoading } from "./components/AccountPageLoading/AccountPageLoading.view"; import { AccountPageLoading } from "./components/AccountPageLoading/AccountPageLoading.view";
const AccountPage: FC = () => { const AccountPage: FC = () => {
const vm = useAccountPageVm(); const viewModel = useAccountPageViewModel();
if (vm.state === "loading") return <AccountPageLoading />; if (viewModel.state === "loading") return <AccountPageLoading />;
if (vm.state === "error") return <AccountPageError {...vm} />; if (viewModel.state === "error") return <AccountPageError {...viewModel} />;
if (vm.state === "empty") if (viewModel.state === "empty")
return ( return (
<AccountPageEmpty <AccountPageEmpty
account={vm.account} account={viewModel.account}
accountId={vm.accountId} accountId={viewModel.accountId}
incomeSummary={vm.incomeSummary} incomeSummary={viewModel.incomeSummary}
readRetry={vm.readRetry} recentEvents={viewModel.recentEvents}
recentEvents={vm.recentEvents} recentOperations={viewModel.recentOperations}
recentOperations={vm.recentOperations}
source={vm.source}
/> />
); );
return ( return (
<AccountPageLoaded <AccountPageLoaded
account={vm.account} account={viewModel.account}
accountId={vm.accountId} accountId={viewModel.accountId}
concentration={vm.concentration} incomeSummary={viewModel.incomeSummary}
incomeSummary={vm.incomeSummary} partial={viewModel.partial}
onSliceChange={vm.onSliceChange} portfolio={viewModel.portfolio}
onTabChange={vm.onTabChange} positionSections={viewModel.positionSections}
partial={vm.partial} recentEvents={viewModel.recentEvents}
portfolio={vm.portfolio} recentOperations={viewModel.recentOperations}
positionSections={vm.positionSections} unrealizedReturns={viewModel.unrealizedReturns}
readRetry={vm.readRetry}
recentEvents={vm.recentEvents}
recentOperations={vm.recentOperations}
slice={vm.slice}
source={vm.source}
tab={vm.tab}
unrealizedReturns={vm.unrealizedReturns}
/> />
); );
}; };
@@ -12,7 +12,7 @@ import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { portfolio } from "../../model/positions.fixtures"; import { portfolio } from "../../model/positions.fixtures";
import { useAccountPageVm } from "./AccountPage.vm"; import { useAccountPageViewModel } from "./AccountPage.vm";
function configureTestAuth() { function configureTestAuth() {
configureAuthClient({ configureAuthClient({
@@ -102,12 +102,12 @@ function mockLoadedAccountPage() {
); );
} }
describe("useAccountPageVm", () => { describe("useAccountPageViewModel", () => {
it("prepares recent operations and events state for presentation views", async () => { it("prepares recent operations and events state for presentation views", async () => {
configureTestAuth(); configureTestAuth();
mockLoadedAccountPage(); mockLoadedAccountPage();
const { result } = renderHook(() => useAccountPageVm(), { const { result } = renderHook(() => useAccountPageViewModel(), {
wrapper: createWrapper(), wrapper: createWrapper(),
}); });
@@ -154,7 +154,7 @@ describe("useAccountPageVm", () => {
), ),
); );
const { result } = renderHook(() => useAccountPageVm(), { const { result } = renderHook(() => useAccountPageViewModel(), {
wrapper: createWrapper(), wrapper: createWrapper(),
}); });
@@ -187,14 +187,14 @@ it("keeps overview sections independent when their source data is incomplete", a
http.get("*/api/v1/brokerage-accounts/:accountId/events", () => http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
HttpResponse.json( HttpResponse.json(
resource( resource(
{ events: [], incomeTotal: { currency: "RUB", value: "0" } }, { events: [], incomeTotalsByCurrency: [] },
{ completeness: "incomplete" }, { completeness: "incomplete" },
), ),
), ),
), ),
); );
const { result } = renderHook(() => useAccountPageVm(), { const { result } = renderHook(() => useAccountPageViewModel(), {
wrapper: createWrapper(), wrapper: createWrapper(),
}); });
@@ -213,8 +213,8 @@ it("keeps overview sections loaded without exposing technical retry", async () =
const sections: Record<string, unknown> const sections: Record<string, unknown>
= { = {
"events": { events: [], incomeTotal: { currency: "RUB", value: "0" } }, "events": { events: [], incomeTotalsByCurrency: [] },
"income-summary": { total: { coupon: { currency: "RUB", value: "0" }, dividend: { currency: "RUB", value: "0" }, total: { currency: "RUB", value: "0" } } }, "income-summary": { totalsByCurrency: [] },
"operations": { hasNext: false, items: [], nextCursor: null }, "operations": { hasNext: false, items: [], nextCursor: null },
portfolio, portfolio,
}; };
@@ -232,13 +232,13 @@ it("keeps overview sections loaded without exposing technical retry", async () =
), ),
); );
const { result } = renderHook(() => useAccountPageVm(), { const { result } = renderHook(() => useAccountPageViewModel(), {
wrapper: createWrapper(), wrapper: createWrapper(),
}); });
await waitFor(() => await waitFor(() =>
expect(result.current).toMatchObject({ expect(result.current).toMatchObject({
incomeSummary: { state: "loaded" }, incomeSummary: { state: "empty" },
recentEvents: { state: "empty" }, recentEvents: { state: "empty" },
recentOperations: { state: "empty" }, recentOperations: { state: "empty" },
state: "loaded", state: "loaded",
@@ -1,5 +1,3 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router"; import { useParams } from "react-router";
import type { Portfolio } from "@/entities/portfolio"; import type { Portfolio } from "@/entities/portfolio";
@@ -16,15 +14,9 @@ import {
import { useIncomeSummary } from "@/entities/income"; import { useIncomeSummary } from "@/entities/income";
import { usePortfolio } from "@/entities/portfolio"; import { usePortfolio } from "@/entities/portfolio";
import { useScreenSync } from "@/features/tbank-connection"; import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode, isBrokerageAccessLoss, readRetry } from "@/shared/api"; import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
import { useInitialAvailability } from "@/shared/lib"; import { useInitialAvailability } from "@/shared/lib";
import {
type Concentration,
type ConcentrationLabels,
type Slice,
toConcentration,
} from "../../model/concentration";
import { import {
type AccountPagePositionSection, type AccountPagePositionSection,
toAccountPagePositionSections, toAccountPagePositionSections,
@@ -44,10 +36,8 @@ type AccountPageContent = {
accountId?: string; accountId?: string;
incomeSummary: AccountPageIncomeSummary; incomeSummary: AccountPageIncomeSummary;
partial?: boolean; partial?: boolean;
readRetry: AccountPageReadRetry;
recentEvents: AccountPageRecentEvents; recentEvents: AccountPageRecentEvents;
recentOperations: AccountPageRecentOperations; recentOperations: AccountPageRecentOperations;
source?: InvestmentResourceMeta;
}; };
type AccountPageEmptyViewModel = AccountPageContent & { type AccountPageEmptyViewModel = AccountPageContent & {
@@ -55,46 +45,28 @@ type AccountPageEmptyViewModel = AccountPageContent & {
}; };
type AccountPageLoadedViewModel = AccountPageContent & { type AccountPageLoadedViewModel = AccountPageContent & {
concentration: Concentration | null;
onSliceChange: (slice: Slice) => void;
onTabChange: (tab: null | string) => void;
portfolio: Portfolio; portfolio: Portfolio;
positionSections: AccountPagePositionSection[]; positionSections: AccountPagePositionSection[];
slice: Slice; source?: InvestmentResourceMeta;
state: "loaded"; state: "loaded";
tab: string; unrealizedReturns: Portfolio["totalsByCurrency"];
unrealizedReturns:
| null
| { currency: string; value: string };
}; };
type AccountPageReadRetry = ReturnType<typeof readRetry>;
type AccountPageViewModel type AccountPageViewModel
= | AccountPageEmptyViewModel = | AccountPageEmptyViewModel
| AccountPageLoadedViewModel | AccountPageLoadedViewModel
| { code?: string; readRetry: AccountPageReadRetry; state: "error" } | { code?: string; state: "error" }
| { state: "loading" }; | { state: "loading" };
function useAccountPageVm(): AccountPageViewModel { function isPortfolioAccessFailure(code?: string) {
return isApiErrorCode(code, ApiErrorCode.Forbidden)
|| isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)
|| isApiErrorCode(code, ApiErrorCode.NotFound)
|| isApiErrorCode(code, ApiErrorCode.TbankNotConnected);
}
function useAccountPageViewModel(): AccountPageViewModel {
const { accountId } = useParams(); const { accountId } = useParams();
const { t } = useTranslation();
const [selection, setSelection] = useState<{
accountId?: string;
slice: Slice;
tab: string;
}>({ accountId, slice: "stocks", tab: "positions" });
const current = selection.accountId === accountId
? selection
: {
accountId, slice: "stocks" as const, tab: "positions",
};
const updateSelection = (change: Partial<typeof selection>) =>
setSelection({ ...current, ...change });
const portfolio = usePortfolio(accountId); const portfolio = usePortfolio(accountId);
const operations = useBrokerageOperations(accountId, { const operations = useBrokerageOperations(accountId, {
@@ -114,9 +86,7 @@ function useAccountPageVm(): AccountPageViewModel {
]); ]);
const code = apiErrorCode(portfolio.error); const code = apiErrorCode(portfolio.error);
// The portfolio drives this screen; the retry repeats only its GET. const isAccessFailure = isPortfolioAccessFailure(code);
const retry = readRetry([portfolio]);
const isAccessFailure = isBrokerageAccessLoss(code);
const availability = useInitialAvailability({ const availability = useInitialAvailability({
hasContent: !!portfolio.data, hasContent: !!portfolio.data,
@@ -125,13 +95,12 @@ function useAccountPageVm(): AccountPageViewModel {
isFetching: portfolio.isFetching, isFetching: portfolio.isFetching,
}); });
if (isAccessFailure) if (isAccessFailure) return { code, state: "error" as const };
return { code, readRetry: retry, state: "error" as const };
if (!portfolio.data) if (!portfolio.data)
return availability === "unavailable" return {
? { code, readRetry: retry, state: "error" as const } state: availability === "unavailable" ? "error" : "loading",
: { state: "loading" as const }; } as const;
const account = accounts.data?.find(item => item.id === accountId); const account = accounts.data?.find(item => item.id === accountId);
const recentOperations = toRecentOperations(accountId, operations); const recentOperations = toRecentOperations(accountId, operations);
@@ -142,52 +111,32 @@ function useAccountPageVm(): AccountPageViewModel {
if ( if (
portfolio.meta?.availability === "available" portfolio.meta?.availability === "available"
&& portfolio.data.positions.length === 0 && portfolio.data.positions.length === 0
&& Number(portfolio.data.cash.value) === 0 && portfolio.data.cashBalances.length === 0
) )
return { return {
account, account,
accountId, accountId,
incomeSummary, incomeSummary,
readRetry: retry,
recentEvents, recentEvents,
recentOperations, recentOperations,
source: portfolio.meta,
state: "empty" as const, state: "empty" as const,
}; };
const isPartial = portfolio.meta?.completeness === "incomplete"; const isPartial = portfolio.meta?.completeness === "incomplete";
const labels: ConcentrationLabels = {
missingIssuer: t("portfolio.concentration.missingIssuer"),
missingSector: t("portfolio.concentration.missingSector"),
other: t("portfolio.concentration.other"),
sector: code => t(`stock.sectors.${code}`, { defaultValue: code }),
};
const concentration = accountId !== "all"
&& current.tab === "concentration"
? toConcentration(portfolio.data, current.slice, labels)
: null;
return { return {
account, account,
accountId, accountId,
concentration,
incomeSummary, incomeSummary,
onSliceChange: slice => updateSelection({ slice }),
onTabChange: tab => updateSelection({ tab: tab ?? "positions" }),
partial: isPartial, partial: isPartial,
portfolio: portfolio.data, portfolio: portfolio.data,
positionSections: toAccountPagePositionSections(portfolio.data), positionSections: toAccountPagePositionSections(portfolio.data),
readRetry: retry,
recentEvents, recentEvents,
recentOperations, recentOperations,
slice: current.slice,
source: portfolio.meta, source: portfolio.meta,
state: "loaded" as const, state: "loaded" as const,
tab: current.tab,
unrealizedReturns: toUnrealizedReturns(portfolio.data.positions), unrealizedReturns: toUnrealizedReturns(portfolio.data.positions),
}; };
} }
export { useAccountPageVm }; export { useAccountPageViewModel };
@@ -5,10 +5,9 @@ import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { BrokerageAccount } from "@/entities/brokerage-account"; import type { BrokerageAccount } from "@/entities/brokerage-account";
import type { InvestmentResourceMeta } from "@/shared/api";
import { BrokerageEventsPreview } from "@/entities/brokerage-event"; import { BrokerageEventsPreview } from "@/entities/brokerage-event";
import { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui"; import { PortfolioStructure } from "@/widgets/portfolio-structure";
import type { import type {
AccountPageIncomeSummary, AccountPageIncomeSummary,
@@ -18,17 +17,14 @@ import type {
import { AccountHeading } from "./AccountHeading.view"; import { AccountHeading } from "./AccountHeading.view";
import { IncomeSummary } from "./IncomeSummary.view"; import { IncomeSummary } from "./IncomeSummary.view";
import { PortfolioStructure } from "./PortfolioStructure/PortfolioStructure";
import { RecentOperations } from "./RecentOperations.view"; import { RecentOperations } from "./RecentOperations.view";
type AccountPageEmptyProps = { type AccountPageEmptyProps = {
account?: BrokerageAccount; account?: BrokerageAccount;
accountId?: string; accountId?: string;
incomeSummary?: AccountPageIncomeSummary; incomeSummary?: AccountPageIncomeSummary;
readRetry?: ReadRetryProps;
recentEvents: AccountPageRecentEvents; recentEvents: AccountPageRecentEvents;
recentOperations: AccountPageRecentOperations; recentOperations: AccountPageRecentOperations;
source?: InvestmentResourceMeta;
}; };
const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => { const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
@@ -44,34 +40,15 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
<Stack> <Stack>
<AccountHeading account={props.account} accountId={props.accountId} /> <AccountHeading account={props.account} accountId={props.accountId} />
<SourceSyncStatus
lastAttempt={props.source?.lastAttempt}
lastSuccessfulAt={props.source?.lastSuccessfulAt}
stale={props.source?.freshness === "stale"}
/>
{!!props.readRetry && <ReadRetry {...props.readRetry} />}
<StateView text={text} title={t("portfolio.emptyTitle")} /> <StateView text={text} title={t("portfolio.emptyTitle")} />
<PortfolioStructure <PortfolioStructure
structure={{ structure={{ currencies: [], unvaluedCategories: [] }}
categories: [],
hasNegativeValues: false,
totalValue: "0",
unvaluedCategories: [],
}}
/> />
<RecentOperations <RecentOperations operations={props.recentOperations} />
allAccounts={props.accountId === "all"}
operations={props.recentOperations}
/>
<BrokerageEventsPreview <BrokerageEventsPreview events={props.recentEvents} />
allAccounts={props.accountId === "all"}
events={props.recentEvents}
/>
{!!props.incomeSummary && <IncomeSummary {...props.incomeSummary} />} {!!props.incomeSummary && <IncomeSummary {...props.incomeSummary} />}
</Stack> </Stack>
@@ -6,12 +6,8 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import { ApiErrorCode, isApiErrorCode } from "@/shared/api"; import { ApiErrorCode, isApiErrorCode } from "@/shared/api";
import { ReadRetryButton, type ReadRetryProps } from "@/shared/ui";
type AccountPageErrorProps = { type AccountPageErrorProps = { code?: string };
code?: string;
readRetry?: ReadRetryProps;
};
const AccountPageError: FC<AccountPageErrorProps> = (props) => { const AccountPageError: FC<AccountPageErrorProps> = (props) => {
const { code } = props; const { code } = props;
@@ -47,18 +43,8 @@ const AccountPageError: FC<AccountPageErrorProps> = (props) => {
/> />
); );
const retry = props.readRetry?.exhausted
? (
<ReadRetryButton
onRetry={props.readRetry.onRetry}
retrying={props.readRetry.retrying}
/>
)
: undefined;
return ( return (
<StateView <StateView
action={retry}
text={t("states.unavailableText")} text={t("states.unavailableText")}
title={t("states.unavailableTitle")} title={t("states.unavailableTitle")}
/> />
@@ -15,9 +15,9 @@ import type { InvestmentResourceMeta } from "@/shared/api";
import { BrokerageEventsPreview } from "@/entities/brokerage-event"; import { BrokerageEventsPreview } from "@/entities/brokerage-event";
import { formatMoney } from "@/shared/lib"; import { formatMoney } from "@/shared/lib";
import { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui"; import { SourceSyncStatus } from "@/shared/ui";
import { PortfolioStructure } from "@/widgets/portfolio-structure";
import type { Concentration as ConcentrationModel, Slice } from "../../../model/concentration";
import type { AccountPagePositionSection } from "../../../model/positions"; import type { AccountPagePositionSection } from "../../../model/positions";
import type { import type {
AccountPageIncomeSummary, AccountPageIncomeSummary,
@@ -27,40 +27,27 @@ import type {
import classes from "../AccountPage.module.css"; import classes from "../AccountPage.module.css";
import { AccountHeading } from "./AccountHeading.view"; import { AccountHeading } from "./AccountHeading.view";
import { Concentration } from "./Concentration/Concentration.view"; import { CurrencyCards } from "./CurrencyCards/CurrencyCards.view";
import { IncomeSummary } from "./IncomeSummary.view"; import { IncomeSummary } from "./IncomeSummary.view";
import { PerformanceTab } from "./Performance/PerformanceTab";
import { PortfolioStructure } from "./PortfolioStructure/PortfolioStructure";
import { PositionSection } from "./PositionSection.view"; import { PositionSection } from "./PositionSection.view";
import { RecentOperations } from "./RecentOperations.view"; import { RecentOperations } from "./RecentOperations.view";
type AccountPageLoadedProps = { type AccountPageLoadedProps = {
account?: BrokerageAccount; account?: BrokerageAccount;
accountId?: string; accountId?: string;
concentration: ConcentrationModel | null;
incomeSummary?: AccountPageIncomeSummary; incomeSummary?: AccountPageIncomeSummary;
onSliceChange: (slice: Slice) => void;
onTabChange: (tab: null | string) => void;
partial?: boolean; partial?: boolean;
portfolio: Portfolio; portfolio: Portfolio;
positionSections: AccountPagePositionSection[]; positionSections: AccountPagePositionSection[];
readRetry?: ReadRetryProps;
recentEvents: AccountPageRecentEvents; recentEvents: AccountPageRecentEvents;
recentOperations: AccountPageRecentOperations; recentOperations: AccountPageRecentOperations;
slice: Slice;
source?: InvestmentResourceMeta; source?: InvestmentResourceMeta;
tab: string; unrealizedReturns: Portfolio["totalsByCurrency"];
unrealizedReturns:
| null
| { currency: string; value: string };
}; };
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => { const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [filter, setFilter] = useState<string>(); const [filter, setFilter] = useState<string>();
const tab = props.tab;
const onTabChange = props.onTabChange;
const activeSection = props.positionSections.find( const activeSection = props.positionSections.find(
section => section.kind === filter, section => section.kind === filter,
@@ -87,29 +74,11 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
stale={props.source?.freshness === "stale"} stale={props.source?.freshness === "stale"}
/> />
{!!props.readRetry && <ReadRetry {...props.readRetry} />}
{props.partial {props.partial
? tab !== "concentration" && ( ? <Alert role="status">{t("investment.partial")}</Alert>
<Alert role="status">{t("investment.partial")}</Alert> : <CurrencyCards amounts={props.portfolio.totalsByCurrency} />}
)
: (
<Stack component="dl" gap={4} m={0}>
<dt>
<Text fw={600}>{t("portfolio.total")}</Text>
</dt>
<dd style={{ margin: 0 }}> {!props.partial && props.unrealizedReturns.length > 0 && (
<Text className={classes.totalAmount}>
{formatMoney(props.portfolio.totals, {
withRoundedCents: true,
})}
</Text>
</dd>
</Stack>
)}
{!props.partial && props.unrealizedReturns && (
<Stack <Stack
aria-label={t("portfolio.unrealizedResult")} aria-label={t("portfolio.unrealizedResult")}
component="section" component="section"
@@ -118,7 +87,9 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
<Text fw={600}>{t("portfolio.unrealizedResult")}</Text> <Text fw={600}>{t("portfolio.unrealizedResult")}</Text>
<Group gap="md"> <Group gap="md">
<Text>{formatMoney(props.unrealizedReturns)}</Text> {props.unrealizedReturns.map(total => (
<Text key={total.currency}>{formatMoney(total)}</Text>
))}
</Group> </Group>
<Text c="dimmed" size="xs"> <Text c="dimmed" size="xs">
@@ -136,21 +107,14 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
<Tabs <Tabs
classNames={{ list: classes.tabsList }} classNames={{ list: classes.tabsList }}
defaultValue="positions"
keepMounted={false} keepMounted={false}
onChange={onTabChange}
value={tab}
> >
<Tabs.List aria-label={t("portfolio.sections")} mb="md"> <Tabs.List aria-label={t("portfolio.sections")} mb="md">
<Tabs.Tab value="positions"> <Tabs.Tab value="positions">
{t("portfolio.tabs.positions")} {t("portfolio.tabs.positions")}
</Tabs.Tab> </Tabs.Tab>
{props.accountId !== "all" && (
<Tabs.Tab value="concentration">
{t("portfolio.concentration.title")}
</Tabs.Tab>
)}
<Tabs.Tab value="cash">{t("portfolio.tabs.cash")}</Tabs.Tab> <Tabs.Tab value="cash">{t("portfolio.tabs.cash")}</Tabs.Tab>
<Tabs.Tab value="operations"> <Tabs.Tab value="operations">
@@ -160,8 +124,6 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
<Tabs.Tab value="events">{t("portfolio.tabs.events")}</Tabs.Tab> <Tabs.Tab value="events">{t("portfolio.tabs.events")}</Tabs.Tab>
<Tabs.Tab value="income">{t("portfolio.tabs.income")}</Tabs.Tab> <Tabs.Tab value="income">{t("portfolio.tabs.income")}</Tabs.Tab>
<Tabs.Tab value="performance">{t("portfolio.tabs.performance")}</Tabs.Tab>
</Tabs.List> </Tabs.List>
<Tabs.Panel value="positions"> <Tabs.Panel value="positions">
@@ -181,37 +143,12 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
</Stack> </Stack>
</Tabs.Panel> </Tabs.Panel>
{props.accountId !== "all" && (
<Tabs.Panel value="concentration">
{props.concentration && (
<Concentration
key={props.slice}
model={props.concentration}
onSliceChange={props.onSliceChange}
partial={!!props.partial}
slice={props.slice}
/>
)}
</Tabs.Panel>
)}
<Tabs.Panel value="cash"> <Tabs.Panel value="cash">
<Stack component="dl" gap={4} m={0}> <CurrencyCards amounts={props.portfolio.cashBalances} />
<dt>
<Text fw={600}>{t("portfolio.cashTitle")}</Text>
</dt>
<dd style={{ margin: 0 }}>
<Text>
{formatMoney(props.portfolio.cash, { withRoundedCents: true })}
</Text>
</dd>
</Stack>
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="operations"> <Tabs.Panel value="operations">
<RecentOperations <RecentOperations
allAccounts={props.accountId === "all"}
moreLabel={t("portfolio.more")} moreLabel={t("portfolio.more")}
operations={props.recentOperations} operations={props.recentOperations}
/> />
@@ -219,7 +156,6 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
<Tabs.Panel value="events"> <Tabs.Panel value="events">
<BrokerageEventsPreview <BrokerageEventsPreview
allAccounts={props.accountId === "all"}
events={props.recentEvents} events={props.recentEvents}
moreLabel={t("portfolio.more")} moreLabel={t("portfolio.more")}
/> />
@@ -233,10 +169,6 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
/> />
)} )}
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="performance">
{props.accountId && <PerformanceTab accountId={props.accountId} />}
</Tabs.Panel>
</Tabs> </Tabs>
</Stack> </Stack>
); );
@@ -1,111 +0,0 @@
.layout {
display: grid;
grid-template-columns: minmax(260px, 1fr) minmax(0, 1.4fr);
gap: var(--mantine-spacing-xl);
align-items: center;
}
.chart {
position: relative;
justify-self: center;
}
.pattern {
position: absolute;
width: 0;
height: 0;
}
.center {
position: absolute;
inset: 32%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
font-size: clamp(12px, 1.3vw, 17px);
overflow-wrap: anywhere;
pointer-events: none;
}
.srOnly {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
.list {
list-style: none;
margin: 0;
padding: 0;
}
.row {
display: flex;
align-items: center;
gap: var(--mantine-spacing-sm);
min-height: 58px;
border-bottom: 1px solid var(--mantine-color-gray-2);
}
.rank {
display: grid;
place-items: center;
width: 28px;
height: 28px;
flex: none;
border-radius: 50%;
color: white;
}
.identity {
flex: 1;
min-width: 0;
overflow-wrap: anywhere;
}
.bar,
.bar span {
display: block;
height: 4px;
border-radius: 4px;
}
.bar {
background: var(--mantine-color-gray-2);
}
.value {
text-align: right;
white-space: nowrap;
}
.other {
display: flex;
justify-content: space-between;
gap: var(--mantine-spacing-sm);
width: 100%;
min-height: 44px;
align-items: center;
border: 0;
background: var(--mantine-color-gray-0);
color: inherit;
cursor: pointer;
text-align: left;
}
.other:focus-visible {
outline: 2px solid var(--mantine-color-blue-6);
outline-offset: 2px;
}
@media (max-width: 700px) {
.layout { grid-template-columns: 1fr; }
.slices { max-width: 100%; overflow-x: auto; }
.other { flex-wrap: wrap; }
}
@@ -1,310 +0,0 @@
import { DonutChart } from "@mantine/charts";
import {
Alert, Box, Group, Paper, SegmentedControl, Stack,
Text, Title,
} from "@mantine/core";
import { type FC, useId, useState } from "react";
import { useTranslation } from "react-i18next";
import { formatMoney } from "@/shared/lib";
import type {
ConcentrationItem,
Concentration as ConcentrationModel,
Slice,
} from "../../../../model/concentration";
import { PositionLogo } from "../PositionLogo.view";
import classes from "./Concentration.module.css";
type Props = {
model: ConcentrationModel;
onSliceChange: (slice: Slice) => void;
partial: boolean;
slice: Slice;
};
const sliceLabels = {
bonds: "portfolio.bonds",
issuers: "portfolio.concentration.issuers",
sectors: "portfolio.concentration.sectors",
stocks: "portfolio.stocks",
} as const;
type RowProps = {
color: string;
index: number;
item: ConcentrationItem;
logo?: ConcentrationItem["logo"];
name: string;
};
const colors = [
"#2563eb", "#18a999", "#f59e0b", "#8b5cf6", "#ef6c5b",
"#64748b",
];
const PERCENT = 100;
const OTHER_START = 6;
function amountFor(item: ConcentrationItem): string {
return formatMoney({ currency: "RUB", value: item.amount });
}
const ConcentrationRow: FC<RowProps> = props => (
<li className={classes.row}>
<span
aria-hidden="true"
className={classes.rank}
style={{ backgroundColor: props.color }}
>
{props.index}
</span>
{props.logo && <PositionLogo label={props.name} src={props.logo.src} />}
<span className={classes.identity}>
<span>{props.name}</span>
{props.logo?.subtitle && (
<Text c="dimmed" size="xs">{props.logo.subtitle}</Text>
)}
<span aria-hidden="true" className={classes.bar}>
<span style={{
backgroundColor: props.color,
width: `${props.item.chartValue * PERCENT}%`,
}}
/>
</span>
</span>
<span className={classes.value}>
<strong>{amountFor(props.item)}</strong>
<Text c="dimmed" size="sm">{props.item.share}</Text>
</span>
</li>
);
type TooltipProps = {
active?: boolean;
payload?: { name?: number | string }[];
rows: { amount: string; label: string; share: string }[];
};
function chartCellProps(series: { name: string }, rows: ConcentrationItem[]) {
const index = Number(series.name);
const item = rows.at(index);
return {
stroke: "white",
strokeWidth: 2,
...((item?.unknown || (index === rows.length - 1
&& item?.key === "__other"))
&& { strokeDasharray: "3 2" }),
};
}
function rankLabel({ index }: { index: number; percent?: number }) {
return String(index + 1);
}
const ChartTooltip: FC<TooltipProps> = (props) => {
const row = props.rows[Number(props.payload?.[0]?.name)];
if (!row || !props.active) return;
return (
<Paper p="sm" radius="md" shadow="sm">
<Text fw={600} size="sm">{row.label}</Text>
<Text size="sm">{row.amount} · {row.share}</Text>
</Paper>
);
};
const Concentration: FC<Props> = (props) => {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const otherId = useId();
const chartId = useId();
const rows = props.model.other
? [...props.model.items, props.model.other]
: props.model.items;
const chartData = rows.map((item, index) => ({
color: colors[index % colors.length] ?? colors[0] ?? "blue",
name: String(index),
value: item.chartValue,
}));
const chartText = [
props.model.top5Share && t("portfolio.concentration.top5", {
share: props.model.top5Share,
}),
props.model.total && formatMoney({ currency: "RUB", value: props.model.total }),
...rows.map(item => `${item.label}: ${amountFor(item)}, ${item.share}`),
].filter(Boolean).join("; ");
const slices = [
{ label: t("portfolio.stocks"), value: "stocks" },
{ label: t("portfolio.bonds"), value: "bonds" },
{ label: t("portfolio.concentration.sectors"), value: "sectors" },
{ label: t("portfolio.concentration.issuers"), value: "issuers" },
];
// eslint-disable-next-line frontend/readability -- Mantine callback closes over page action.
const selectSlice = (value: string) => props.onSliceChange(value as Slice);
// eslint-disable-next-line frontend/readability -- Disclosure state belongs to this view.
const toggleOther = () => setExpanded(value => !value);
// eslint-disable-next-line frontend/readability -- Chart callback closes over visible rows.
const cellProps = (series: { name: string }) => chartCellProps(series, rows);
const pieProps = { isAnimationActive: false, label: rankLabel };
const tooltipRows = rows.map(item => ({
amount: amountFor(item),
label: item.label,
share: item.share,
}));
const tooltipProps = { content: <ChartTooltip rows={tooltipRows} /> };
const totalText = props.model.total
? formatMoney({ currency: "RUB", value: props.model.total })
: "";
return (
<Stack gap="lg">
<Group align="end" justify="space-between">
<SegmentedControl
aria-label={t("portfolio.concentration.slice")}
className={classes.slices}
data={slices}
onChange={selectSlice}
value={props.slice}
/>
</Group>
{props.partial && (
<Alert role="status">{t("portfolio.concentration.incomplete")}</Alert>
)}
{props.model.empty
? (
<Stack gap="xs">
<Text fw={600}>{t(sliceLabels[props.slice])}</Text>
<Text>{t("portfolio.concentration.empty")}</Text>
</Stack>
)
: (
<Paper className={classes.layout} p="lg" radius="md" withBorder>
<Box className={classes.chart}>
<DonutChart
aria-describedby={chartId}
aria-label={t("portfolio.concentration.chart")}
cellProps={cellProps}
data={chartData}
pieProps={pieProps}
role="img"
size={260}
strokeWidth={24}
tooltipDataSource="segment"
tooltipProps={tooltipProps}
/>
<div aria-hidden="true" className={classes.center}>
{props.model.top5Share && (
<Text fw={700} size="sm">{t("portfolio.concentration.top5", {
share: props.model.top5Share,
})}
</Text>
)}
<Text fw={700}>{totalText}</Text>
</div>
<Text
className={classes.srOnly}
id={chartId}
>
{chartText}
</Text>
</Box>
<Stack gap="xs">
<Title order={3} size="md">{t("portfolio.concentration.title")}</Title>
<ol className={classes.list}>
{props.model.items.map((item, index) => (
<ConcentrationRow
color={colors[index % colors.length] ?? "blue"}
index={index + 1}
item={item}
key={item.key}
logo={item.logo}
name={item.label}
/>
))}
</ol>
{props.model.other && (
<>
<button
aria-controls={otherId}
aria-expanded={expanded}
className={classes.other}
onClick={toggleOther}
type="button"
>
<span>{t("portfolio.concentration.other")} · {t(
"portfolio.concentration.otherCount",
{ count: props.model.other.count },
)}
</span>
<span>{amountFor(props.model.other)} · {
props.model.other.share
}
</span>
</button>
<ol
className={classes.list}
hidden={!expanded}
id={otherId}
start={OTHER_START}
>
{props.model.other.entries.map((item, index) => (
<ConcentrationRow
color={colors[(index + OTHER_START) % colors.length]
?? "blue"}
index={index + OTHER_START}
item={item}
key={item.key}
logo={item.logo}
name={item.label}
/>
))}
</ol>
</>
)}
</Stack>
</Paper>
)}
{props.model.unaccounted > 0 && (
<Text c="dimmed" size="sm">{t(
"portfolio.concentration.unaccounted",
{ count: props.model.unaccounted },
)}
</Text>
)}
</Stack>
);
};
export { Concentration };
@@ -0,0 +1,77 @@
import { MantineProvider } from "@mantine/core";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { CurrencyCards } from "./CurrencyCards.view";
describe("CurrencyCards currency presentation", () => {
it.each([0, 5])(
"renders only the %i supplied totals in upstream order",
(count) => {
const totals = [
{ currency: "EUR", value: "3.00" },
{ currency: "USD", value: "2.00" },
{ currency: "RUB", value: "1.00" },
{ currency: "CAD", value: "4.00" },
{ currency: "XYZ", value: "5.00" },
].slice(0, count);
const { container } = render(
<MantineProvider>
<CurrencyCards amounts={totals} />
</MantineProvider>,
);
expect(
screen.queryAllByRole("definition").map(node => node.textContent),
).toEqual(
["3,00 €", "2,00 $", "1,00 ₽", "4,00 CAD", "5,00 XYZ"].slice(0, count),
);
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
expect(container.querySelectorAll("dl")).toHaveLength(count);
expect(
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
container.querySelector("[tabindex], button, a, input"),
).toBeNull();
},
);
it.each([
["RUB", "125000.50", "125 000,50 ₽", "Российский рубль", "₽"],
["USD", "320.00", "320,00 $", "Доллар США", "$"],
["EUR", "9876.54321", "9 876,54321 €", "Евро", "€"],
[
"RUB",
"123456789012.345678901",
"123 456 789 012,345678901 ₽",
"Российский рубль",
"₽",
],
["USD", "-1234567.890123", "-1 234 567,890123 $", "Доллар США", "$"],
["EUR", "987654321.000001", "987 654 321,000001 €", "Евро", "€"],
["USD", "-0.5", "-0,5 $", "Доллар США", "$"],
["CAD", "20.00", "20,00 CAD", "CAD", "CAD"],
["XYZ", "1.000", "1,000 XYZ", "XYZ", "XYZ"],
])(
"preserves %s %s and its currency identity",
(currency, value, expected, name, symbol) => {
render(
<MantineProvider>
<CurrencyCards amounts={[{ currency, value }]} />
</MantineProvider>,
);
const term = screen.getByRole("term");
expect(term).toHaveTextContent(name);
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
expect(term.querySelector("[aria-hidden=\"true\"]")).toHaveTextContent(
symbol,
);
expect(screen.getByRole("definition")).toHaveTextContent(expected);
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
expect(term.parentElement).toBe(
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
screen.getByRole("definition").parentElement,
);
},
);
});
@@ -0,0 +1,74 @@
import type { FC } from "react";
import { Paper, SimpleGrid, Text, VisuallyHidden } from "@mantine/core";
import { useTranslation } from "react-i18next";
import type { Portfolio } from "@/entities/portfolio";
import { formatDecimal } from "@/shared/lib";
import classes from "../../AccountPage.module.css";
const SIGN_PREFIX_LENGTH = 2;
type CurrencyCardsProps = {
amounts: Portfolio["totalsByCurrency"];
};
const CurrencyCards: FC<CurrencyCardsProps> = (props) => {
const { t } = useTranslation();
const { amounts } = props;
const currencies: Record<string, { name: string; symbol: string }> = {
EUR: { name: t("portfolio.currencies.EUR"), symbol: "€" },
RUB: { name: t("portfolio.currencies.RUB"), symbol: "₽" },
USD: { name: t("portfolio.currencies.USD"), symbol: "$" },
};
return (
<SimpleGrid cols={{ base: 1, md: 3, sm: 2 }} spacing={10}>
{amounts.map((money) => {
const amount = formatDecimal(money.value);
return (
<Paper
className={classes.total}
component="dl"
key={money.currency}
m={0}
withBorder
>
<Text className={classes.totalBadge} component="dt">
<span aria-hidden="true">
{currencies[money.currency]?.symbol ?? money.currency}
</span>
<VisuallyHidden>
{currencies[money.currency]?.name ?? money.currency}
</VisuallyHidden>
</Text>
<Text className={classes.totalAmount} component="dd" m={0}>
{amount.startsWith("-")
? (
<>
<span className={classes.totalLeadingDigits}>
{amount.slice(0, SIGN_PREFIX_LENGTH)}
</span>
{amount.slice(SIGN_PREFIX_LENGTH)}
</>
)
: (
amount
)}{" "}
{currencies[money.currency]?.symbol ?? money.currency}
</Text>
</Paper>
);
})}
</SimpleGrid>
);
};
export { CurrencyCards };
@@ -64,14 +64,16 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
return ( return (
<Stack gap="xs"> <Stack gap="xs">
<Paper p="sm" withBorder> {props.summary.totalsByCurrency.map(total => (
<Text fw={700}>{formatMoney(props.summary.total.total)}</Text> <Paper key={total.currency} p="sm" withBorder>
<Text fw={700}>{formatMoney(total.total)}</Text>
<Text c="dimmed" size="sm"> <Text c="dimmed" size="sm">
{t("income.coupon")}: {formatMoney(props.summary.total.coupon)} ·{" "} {t("income.coupon")}: {formatMoney(total.coupon)} ·{" "}
{t("income.dividend")}: {formatMoney(props.summary.total.dividend)} {t("income.dividend")}: {formatMoney(total.dividend)}
</Text> </Text>
</Paper> </Paper>
))}
<Button component={Link} to={props.to} variant="subtle"> <Button component={Link} to={props.to} variant="subtle">
{props.moreLabel ?? t("income.open")} {props.moreLabel ?? t("income.open")}
@@ -1,3 +0,0 @@
.positive { color: var(--wl-color-positive); }
.negative { color: var(--wl-color-negative); }
.neutral { color: var(--mantine-color-dark-7); }
@@ -1,106 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { Performance } from "@/entities/performance";
import { PerformanceView } from "./Performance.view";
const rub = (value: string) => ({ currency: "RUB", value });
const series = Array.from({ length: 24 }, (_, month) => {
const date = new Date(Date.UTC(2024, month, 1)).toISOString().slice(0, 10);
const invested = 300_000 + Math.floor(month / 6) * 150_000;
return {
benchmarkValue: String(Math.round(invested * (1 + month * 0.009))),
date,
netInvested: String(invested),
referenceValue: String(Math.round(invested * (1 + month * 0.004))),
};
});
const data: Performance = {
accountId: "account-1",
benchmark: {
asOf: "2025-12-01",
code: "MCFTRR",
return: { basis: "annual", unavailableReason: null, value: "0.108" },
unavailableReason: null,
value: rub("1387973"),
},
calculatedAt: "2025-12-01T00:00:00Z",
completeness: "complete",
currentValue: rub("1536400"),
excludedAccounts: [],
netInvested: rub("1060000"),
outperformance: { money: rub("148427"), percentagePoints: "0.043" },
period: { days: 700, from: "2024-01-01", to: "2025-12-01" },
profit: rub("476400"),
reference: {
asOf: "2025-12-01",
code: "IMOEX",
return: { basis: "annual", unavailableReason: null, value: "0.047" },
unavailableReason: null,
value: rub("1150000"),
},
return: { basis: "annual", unavailableReason: null, value: "0.151" },
series,
};
const meta = {
args: { data },
component: PerformanceView,
parameters: {
docs: {
description: {
component:
"Вкладка «Результат»: доходность, прибыль, те же деньги в индексе Мосбиржи и опережение. Синтетический серверный ответ без расчётов на клиенте.",
},
},
},
tags: ["autodocs"],
title: "Product/Performance",
} satisfies Meta<typeof PerformanceView>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Ahead: Story = {};
export const Behind: Story = {
args: {
data: {
...data,
currentValue: rub("1172400"),
outperformance: { money: rub("-215573"), percentagePoints: "-0.068" },
profit: rub("112400"),
return: { basis: "annual", unavailableReason: null, value: "0.04" },
},
},
};
export const ShortHistory: Story = {
args: {
data: {
...data,
benchmark: {
...data.benchmark,
return: { basis: "period", unavailableReason: null, value: "0.112" },
},
period: { days: 277, from: "2025-02-27", to: "2025-12-01" },
return: { basis: "period", unavailableReason: null, value: "0.074" },
},
},
};
export const Incomplete: Story = {
args: {
data: {
...data,
completeness: "incomplete",
excludedAccounts: [
{ accountId: "usd", name: "Валютный", reason: "PORTFOLIO_VALUATION_INCOMPLETE" },
],
},
},
};
@@ -1,185 +0,0 @@
import { LineChart } from "@mantine/charts";
import {
Accordion,
ActionIcon,
Alert,
Group,
Paper,
SimpleGrid,
Stack,
Text,
Title,
Tooltip,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import type { Performance } from "@/entities/performance";
import { formatChartValue, formatDate } from "@/shared/lib";
import { performanceVm } from "../../../../model/performance";
import classes from "./Performance.module.css";
const formatRub = (value: number) => `${formatChartValue(Math.round(value))} ₽`;
function PerformanceView({ data }: Readonly<{ data: Performance }>) {
const { t } = useTranslation();
const vm = performanceVm(data);
const basis = vm.basis === "annual"
? t("portfolio.performance.yearly")
: t("portfolio.performance.periodLabel", { days: vm.days });
const comparisonTone = vm.comparison?.points.tone ?? "neutral";
let comparisonTitle = t("portfolio.performance.marketEqual");
if (comparisonTone === "positive") comparisonTitle = t("portfolio.performance.marketAhead");
else if (comparisonTone === "negative") comparisonTitle = t("portfolio.performance.marketBehind");
const percentText = vm.returnValue.value;
const indexText = vm.indexReturn.value;
const currentLines = vm.currentPoint
? [{ color: "blue.6", label: t("portfolio.performance.current"), x: vm.currentPoint.date }]
: [];
const profitCaption = t("portfolio.performance.profitCaption", { current: vm.current, invested: vm.invested });
const chartLabel = [
`${t("portfolio.performance.invested")}: ${vm.invested}`,
`${t("portfolio.performance.benchmark")}: ${vm.indexValue}`,
`${t("portfolio.performance.current")}: ${vm.current}`,
`${t("portfolio.performance.returnLabel")}: ${percentText}`,
].join("; ");
return (
<Stack gap="md">
{vm.excluded.length > 0 && (
<Alert role="status" title={t("portfolio.performance.incomplete")}>
{vm.excluded.map(item => (
<Text key={item.id} size="sm">
{t("portfolio.performance.unavailableReason", item)}
</Text>
))}
</Alert>
)}
{vm.basis === "period" && (
<Alert role="status">
{t("portfolio.performance.periodNote", { days: vm.days })}
</Alert>
)}
<SimpleGrid cols={{ base: 1, lg: 4, sm: 2 }}>
<Paper component="dl" m={0} p="md" withBorder>
<Text component="dt" size="sm">{t("portfolio.performance.returnLabel")}</Text>
<Text className={classes[vm.returnValue.tone]} component="dd" fw={700} m={0} size="xl">
{percentText}
</Text>
<Text c="dimmed" size="xs">{vm.returnReason ?? basis}</Text>
</Paper>
<Paper component="dl" m={0} p="md" withBorder>
<Text component="dt" size="sm">{t("portfolio.performance.profit")}</Text>
<Text className={classes[vm.profit.tone]} component="dd" fw={700} m={0} size="xl">
{vm.profit.value}
</Text>
<Text c="dimmed" size="xs">{profitCaption}</Text>
</Paper>
<Paper component="dl" m={0} p="md" withBorder>
<Group gap="xs">
<Text component="dt" size="sm">{t("portfolio.performance.benchmark")}</Text>
<Tooltip
events={{ focus: true, hover: true, touch: true }}
label={t("portfolio.performance.benchmarkHint")}
multiline
w={280}
>
<ActionIcon
aria-label={t("portfolio.performance.benchmarkHint")}
size="xs"
variant="subtle"
>?
</ActionIcon>
</Tooltip>
</Group>
<Text component="dd" fw={700} m={0} size="xl">{indexText}</Text>
<Text c="dimmed" size="xs">
{vm.indexReason ?? `${basis} · ${vm.indexValue}`}
</Text>
</Paper>
<Paper component="dl" m={0} p="md" withBorder>
<Text component="dt" size="sm">
{comparisonTitle}
</Text>
<Text
className={classes[vm.comparison?.points.tone ?? "neutral"]}
component="dd"
fw={700}
m={0}
size="xl"
>
{vm.comparison?.points.value ?? "—"}
</Text>
<Text c="dimmed" size="xs">{vm.comparison?.money.value ?? vm.indexReason ?? vm.returnReason}</Text>
</Paper>
</SimpleGrid>
<Paper component="section" p="md" withBorder>
<Title mb="sm" order={2} size="h4">{t("portfolio.performance.chartTitle")}</Title>
{vm.series.length > 0
? (
<LineChart
aria-label={chartLabel}
curveType="linear"
data={vm.series}
dataKey="label"
gridAxis="y"
h={{ base: 220, sm: 300 }}
lineProps={{ isAnimationActive: false }}
referenceLines={currentLines}
series={[
{ color: "dark.4", label: t("portfolio.performance.invested"), name: "netInvested" },
{ color: "orange.6", label: t("portfolio.performance.benchmark"), name: "benchmarkValue" },
{ color: "gray.5", label: t("portfolio.performance.reference"), name: "referenceValue", strokeDasharray: "4 3" },
]}
tooltipAnimationDuration={0}
valueFormatter={formatRub}
yAxisProps={{ domain: [0, "auto"], width: 70 }}
/>
)
: <Text c="dimmed">{t("portfolio.performance.benchmarkUnavailable")}</Text>}
<Text c="dimmed" mt="sm" size="sm">
{t("portfolio.performance.missingHistory")}
</Text>
</Paper>
<Accordion variant="contained">
<Accordion.Item value="method">
<Accordion.Control>{t("portfolio.performance.calculation")}</Accordion.Control>
<Accordion.Panel>
<Text size="sm">
{t("portfolio.performance.datePeriod", { from: formatDate(`${vm.from}T00:00:00Z`), to: formatDate(`${vm.to}T00:00:00Z`) })}
</Text>
<Text mt="sm" size="sm">{t("portfolio.performance.method")}</Text>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
);
}
export { PerformanceView };
@@ -1,193 +0,0 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen } from "@testing-library/react";
import { http, HttpResponse, type JsonBodyType } from "msw";
import { describe, expect, it, vi } from "vitest";
import type { Performance } from "@/entities/performance";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import { PerformanceTab } from "./PerformanceTab";
const url = "*/api/v1/brokerage-accounts/:accountId/performance";
const rub = (value: string) => ({ currency: "RUB", value });
function performance(overrides: Partial<Performance> = {}): Performance {
return {
accountId: "account-1",
benchmark: {
asOf: "2025-12-31",
code: "MCFTRR",
return: { basis: "annual", unavailableReason: null, value: "0.048809" },
unavailableReason: null,
value: rub("110000"),
},
calculatedAt: "2026-01-01T00:00:00Z",
completeness: "complete",
currentValue: rub("121000"),
excludedAccounts: [],
netInvested: rub("100000"),
outperformance: { money: rub("11000"), percentagePoints: "0.051191" },
period: { days: 730, from: "2024-01-01", to: "2025-12-31" },
profit: rub("21000"),
reference: {
asOf: "2025-12-31",
code: "IMOEX",
return: { basis: "annual", unavailableReason: null, value: "0.02" },
unavailableReason: null,
value: rub("105000"),
},
return: { basis: "annual", unavailableReason: null, value: "0.1" },
series: [
{ benchmarkValue: "100000", date: "2024-01-01", netInvested: "100000", referenceValue: "100000" },
{ benchmarkValue: "110000", date: "2025-12-31", netInvested: "100000", referenceValue: "105000" },
],
...overrides,
};
}
function renderTab(body: JsonBodyType, status = 200) {
configureAuthClient({
accessToken: () => "test-token",
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
server.use(http.get(url, () => HttpResponse.json(body, { status })));
return render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider>
<PerformanceTab accountId="account-1" />
</MantineProvider>
</QueryClientProvider>,
);
}
const meta = { dependencies: [], unavailable: [] };
describe("PerformanceTab", () => {
it("shows return, profit, the same money in the index and outperformance with signs", async () => {
renderTab({ data: performance(), meta });
expect(await screen.findByText("+10,0 %")).toBeInTheDocument();
expect(screen.getByText("+21 000 ₽")).toBeInTheDocument();
expect(screen.getByText("+4,9 %")).toBeInTheDocument();
expect(screen.getByText("Опережение рынка")).toBeInTheDocument();
expect(screen.getByText("+5,1 п.п.")).toBeInTheDocument();
expect(screen.getByText("+11 000 ₽")).toBeInTheDocument();
// Index codes are allowed only inside "Как считали", not as captions.
expect(screen.getByText("Те же деньги в индексе Мосбиржи")).toBeInTheDocument();
expect(screen.queryByText("MCFTRR")).not.toBeInTheDocument();
});
it("labels lagging the market without colour-only meaning", async () => {
renderTab({
data: performance({
outperformance: { money: rub("-5000"), percentagePoints: "-0.02" },
}),
meta,
});
expect(await screen.findByText("Отставание от рынка")).toBeInTheDocument();
expect(screen.getByText("−2,0 п.п.")).toBeInTheDocument();
});
it("never says annual when history is shorter than a year", async () => {
renderTab({
data: performance({
benchmark: { ...performance().benchmark, return: { basis: "period", unavailableReason: null, value: "0.05" } },
period: { days: 200, from: "2025-06-14", to: "2025-12-31" },
return: { basis: "period", unavailableReason: null, value: "0.07" },
}),
meta,
});
expect(await screen.findByText(/проценты показаны за 200 дн\./)).toBeInTheDocument();
expect(screen.queryByText(/годовых/)).not.toBeInTheDocument();
});
it("lists excluded accounts when the all-accounts result is incomplete", async () => {
renderTab({
data: performance({
completeness: "incomplete",
excludedAccounts: [{ accountId: "usd", name: "Валютный", reason: "PORTFOLIO_VALUATION_INCOMPLETE" }],
}),
meta,
});
expect(await screen.findByText("Результат неполный")).toBeInTheDocument();
expect(screen.getByText("Валютный: Нет полной оценки портфеля")).toBeInTheDocument();
});
it("explains unavailability instead of showing zeros, including unknown reasons", async () => {
renderTab({
data: null,
meta: {
dependencies: [],
unavailable: [
{ accountId: "iis", name: "ИИС", reason: "HISTORY_START_UNKNOWN" },
{ accountId: "x", name: "Новый", reason: "SOMETHING_NEW" },
],
},
});
expect(await screen.findByText("Доходность недоступна")).toBeInTheDocument();
expect(screen.getByText("ИИС: Начало истории счёта неизвестно")).toBeInTheDocument();
expect(screen.getByText("Новый: Причину недоступности не удалось определить")).toBeInTheDocument();
expect(screen.queryByText(/0,0 %/)).not.toBeInTheDocument();
});
it("keeps profit and hides the comparison when the index is unavailable", async () => {
renderTab({
data: performance({
benchmark: {
asOf: null,
code: "MCFTRR",
return: { basis: "annual", unavailableReason: "INDEX_HISTORY_UNAVAILABLE", value: null },
unavailableReason: "INDEX_HISTORY_UNAVAILABLE",
value: null,
},
outperformance: null,
series: [],
}),
meta,
});
expect(await screen.findByText("+21 000 ₽")).toBeInTheDocument();
expect(screen.getByText("Сравнение с индексом сейчас недоступно")).toBeInTheDocument();
});
it("does not colour or call a rounded-to-zero difference outperformance", async () => {
renderTab({
data: performance({
outperformance: { money: rub("0"), percentagePoints: "-0.0004" },
}),
meta,
});
expect(await screen.findByText("Сравнение с рынком")).toBeInTheDocument();
expect(screen.getByText("0,0 п.п.")).toBeInTheDocument();
});
it("explains a pending sync when no account reasons are available", async () => {
renderTab({ data: null, meta });
expect(await screen.findByText(/ещё синхронизируются/)).toBeInTheDocument();
});
it("shows a local error with retry", async () => {
renderTab({ code: "INTERNAL_SERVER_ERROR", status: 500 }, 500);
expect(await screen.findByText("Не удалось загрузить результат инвестирования", {}, { timeout: 8000 })).toBeInTheDocument();
server.use(http.get(
url,
() => HttpResponse.json({ data: performance(), meta }),
));
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
expect(await screen.findByText("+10,0 %")).toBeInTheDocument();
}, 10_000);
});
@@ -1,66 +0,0 @@
import { Button, Paper, SimpleGrid, Skeleton, Stack, Text, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { usePerformance } from "@/entities/performance";
import { reasonText } from "../../../../model/performance";
import { PerformanceView } from "./Performance.view";
function PerformanceTab({ accountId }: Readonly<{ accountId: string }>) {
const { t } = useTranslation();
const query = usePerformance(accountId);
if (query.isPending) return (
<Stack aria-label={t("portfolio.performance.returnLabel")} gap="md">
<SimpleGrid cols={{ base: 1, lg: 4, sm: 2 }}>
{["rate", "profit", "benchmark", "difference"].map(key => (
<Paper key={key} p="md" withBorder>
<Skeleton h={14} mb="sm" w="60%" />
<Skeleton h={28} w="80%" />
</Paper>
))}
</SimpleGrid>
<Skeleton h={220} />
</Stack>
);
const onRetry = () => {
void query.refetch();
};
if (query.isError) return (
<Paper p="lg" withBorder>
<Stack align="flex-start" gap="sm">
<Text>{t("portfolio.performance.error")}</Text>
<Button onClick={onRetry} variant="light">
{t("portfolio.performance.retry")}
</Button>
</Stack>
</Paper>
);
if (!query.data.data) return (
<Paper p="xl" withBorder>
<Title order={2} size="h4">{t("portfolio.performance.unavailable")}</Title>
{query.data.meta.unavailable.length === 0 && (
<Text c="dimmed" mt="sm">{t("portfolio.performance.syncing")}</Text>
)}
{query.data.meta.unavailable.map(item => (
<Text c="dimmed" key={item.accountId} mt="sm">
{t("portfolio.performance.unavailableReason", {
name: item.name,
reason: reasonText(item.reason),
})}
</Text>
))}
</Paper>
);
return <PerformanceView data={query.data.data} />;
}
export { PerformanceTab };

Some files were not shown because too many files have changed in this diff Show More