25 Commits
Author SHA1 Message Date
ksv741 c4d0b7edb0 feat(instruments): offer per-section retry after exhausted reads (WealthLedger/frontend#55)
Frontend verify / commits (pull_request) Successful in 31s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 5m39s
2026-11-03 08:44:47 +03:00
ksv741 c84b633855 feat(events): offer retry after exhausted events reads (WealthLedger/frontend#55) 2026-11-03 08:42:28 +03:00
ksv741 7ecc57c547 feat(operations): offer retry after exhausted operations reads (WealthLedger/frontend#55) 2026-11-03 08:40:20 +03:00
ksv741 a2e9d63bfe feat(income): offer retry after exhausted income reads (WealthLedger/frontend#55) 2026-11-03 08:38:23 +03:00
ksv741 d3335a7a8d feat(accounts): offer retry after exhausted accounts reads (WealthLedger/frontend#55) 2026-11-03 08:36:56 +03:00
ksv741 3a01a3a5bc feat(account): offer retry after exhausted portfolio reads (WealthLedger/frontend#55) 2026-11-03 08:35:13 +03:00
ksv741 1a9199a92b feat(shared): add read-failure classifier and ReadRetry (WealthLedger/frontend#55) 2026-11-03 08:31:01 +03:00
codex-bot aaee9591d7 chore(api): update OpenAPI snapshot with UpcomingEventsDto.window (WealthLedger/frontend#134) (#135)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 6m24s
2026-11-03 07:21:35 +03:00
codex-bot 9619dc9791 fix(account): show source metadata on empty account and income (WealthLedger/frontend#55) (#133)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Failing after 6m34s
2026-11-03 06:44:13 +03:00
codex-bot 71fbe7774d refactor: replace repeated lazy page wrappers with lazyPage helper (#131) (#132)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 8s
Frontend verify / verify (push) Failing after 6m34s
2026-11-02 07:31:25 +03:00
ksv741 870b7e6327 chore(release): 1.3.0
Frontend verify / commits (pull_request) Successful in 32s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 5m56s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 5m22s
Frontend image / publish (push) Successful in 6m22s
2026-11-01 19:33:21 +03:00
codex-bot 70a2c335fd feat(account): add concentration tab with top-5 slices and chart (WealthLedger/main#48) (#127)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Failing after 9m31s
2026-11-01 19:28:52 +03:00
codex-bot 7242240b21 chore(deps): resolve minimatch and glob audit advisories (WealthLedger/main#49) (#128)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 7m6s
2026-11-01 19:13:02 +03:00
codex-bot 71126c91f0 feat: group all-account rows by instrument and day (WealthLedger/main#47) (#126)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 6m1s
2026-10-30 07:28:19 +03:00
codex-bot 6792f5b70a fix: show history loading alert only while loading (WealthLedger/frontend#124) (#125)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Failing after 6m57s
2026-10-28 21:00:45 +03:00
codex-bot 32e8fe9402 refactor: derive api error codes from contract (WealthLedger/backend#117) (#123)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 6m30s
2026-10-28 17:31:22 +03:00
codex-bot 5796d2d789 fix: stabilize auth client registration (WealthLedger/frontend#95) (#122)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Failing after 6m58s
2026-10-28 16:59:09 +03:00
codex-bot a56edf2b37 fix: show account source sync status (WealthLedger/frontend#120) (#121)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 7m0s
2026-10-28 16:34:19 +03:00
codex-bot 1d4990491f fix: round money to currency minor units without negative zero (WealthLedger/frontend#118) (#119)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 5m26s
2026-10-28 08:45:55 +03:00
ksv741 431559be0f feat: show return, cash and structure on account cards (#114)
Frontend verify / commits (pull_request) Successful in 29s
Frontend verify / security (pull_request) Successful in 8s
Frontend verify / verify (pull_request) Successful in 5m25s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 5m20s
2026-10-27 05:29:11 +03:00
ksv741 0e4bbec2a7 chore: sync brokerage accounts contract (WealthLedger/backend#138) 2026-10-27 05:29:11 +03:00
ksv741 c24704affa feat: make brokerage account list compact (#111) 2026-10-27 05:29:11 +03:00
codex-bot 58b4e12633 test: pin date in all accounts events test (#115)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 5m7s
2026-10-27 05:27:14 +03:00
codex-bot 3075081df1 feat: replace account page accordion with tabs and show open position result (#112)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 20s
Frontend verify / verify (push) Failing after 6m36s
2026-10-26 20:30:35 +03:00
codex-bot 1a92f023c1 feat: remove top header and rework mobile bottom navigation (#109)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 12s
Frontend verify / verify (push) Failing after 7m7s
2026-10-25 18:25:10 +03:00
109 changed files with 6568 additions and 1247 deletions
+3
View File
@@ -13,6 +13,9 @@
!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
+1 -1
View File
@@ -66,7 +66,7 @@ jobs:
- name: Verify pre-commit secret blocking and redaction - name: Verify pre-commit secret blocking and redaction
run: PATH=/tmp/gitleaks-bin:$PATH node --test scripts/test-pre-commit.mjs run: PATH=/tmp/gitleaks-bin:$PATH node --test scripts/test-pre-commit.mjs
- name: Audit runtime and development dependencies - name: Audit runtime and development dependencies
run: npm audit --include=dev --audit-level=low run: npm audit --include=dev --audit-level=moderate
verify: verify:
needs: [commits, security] needs: [commits, security]
+31
View File
@@ -1,5 +1,36 @@
# Changelog # Changelog
## [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,6 +5,7 @@ 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
+5 -3
View File
@@ -123,10 +123,12 @@ Chromium при ширинах 375 и 1280 px. Browser project не исполь
Новый запуск отменяет устаревший для того же PR/ветки и типа события Новый запуск отменяет устаревший для того же PR/ветки и типа события
(требуется Gitea 1.26+). Ежедневно в 05:17 UTC выполняется только `security`. (требуется Gitea 1.26+). Ежедневно в 05:17 UTC выполняется только `security`.
Job `security` проверяет полную доступную историю Gitleaks 8.30.1 и выполняет Job `security` проверяет полную доступную историю Gitleaks 8.30.1 и выполняет
`npm audit --include=dev --audit-level=low`. Любой advisory блокирует security job; `npm audit --include=dev --audit-level=moderate`. Advisory уровня moderate и выше
исключений frontend нет. Локально можно выполнить те же команды: блокирует security job; low не блокирует, т.к. для них часто нет исправленной версии
(например, GHSA-gcqx-w48m-w2h4 в build-only deepmerge). Других исключений нет.
Локально можно выполнить те же команды:
`gitleaks git --log-opts=--all --redact=100 --no-banner` и `gitleaks git --log-opts=--all --redact=100 --no-banner` и
`npm audit --include=dev --audit-level=low`. Audit требует доступа к npm registry; `npm audit --include=dev --audit-level=moderate`. Audit требует доступа к npm registry;
проверка секретов выполняется локально и не выводит их значения. проверка секретов выполняется локально и не выводит их значения.
Имена jobs `commits`, `security`, `verify` сохранены, но Gitea включает имя Имена jobs `commits`, `security`, `verify` сохранены, но Gitea включает имя
+1 -1
View File
@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="theme-color" content="#ffffff" /> <meta name="theme-color" content="#ffffff" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>WealthLedger</title> <title>WealthLedger</title>
</head> </head>
<body> <body>
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"ignoreDependencies": ["conventional-changelog-conventionalcommits"], "ignoreDependencies": ["conventional-changelog-conventionalcommits", "minimatch"],
"workspaces": { "workspaces": {
".": { ".": {
"project": ["src/**/*.{ts,tsx}", "!src/shared/api/generated/**"] "project": ["src/**/*.{ts,tsx}", "!src/shared/api/generated/**"]
+42 -138
View File
@@ -1,12 +1,12 @@
{ {
"name": "wealth-ledger-frontend", "name": "wealth-ledger-frontend",
"version": "1.2.1", "version": "1.3.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "wealth-ledger-frontend", "name": "wealth-ledger-frontend",
"version": "1.2.1", "version": "1.3.0",
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
], ],
@@ -80,6 +80,7 @@
"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",
@@ -88,7 +89,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": "1.3.0", "vite-plugin-pwa": "2.0.0",
"vitest": "5.0.5" "vitest": "5.0.5"
}, },
"engines": { "engines": {
@@ -2511,14 +2512,12 @@
} }
} }
}, },
"node_modules/@isaacs/cliui": { "node_modules/@isaacs/nonrecursive-glob-match": {
"version": "9.0.0", "version": "1.0.3",
"integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", "resolved": "https://registry.npmjs.org/@isaacs/nonrecursive-glob-match/-/nonrecursive-glob-match-1.0.3.tgz",
"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",
@@ -3720,33 +3719,6 @@
"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==",
@@ -6448,6 +6420,7 @@
}, },
"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",
@@ -6495,6 +6468,7 @@
}, },
"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",
@@ -9097,33 +9071,6 @@
"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==",
@@ -9194,22 +9141,6 @@
"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",
@@ -9488,14 +9419,15 @@
} }
}, },
"node_modules/glob": { "node_modules/glob": {
"version": "13.0.6", "version": "14.0.3",
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "resolved": "https://registry.npmjs.org/glob/-/glob-14.0.3.tgz",
"integrity": "sha512-A1vjIwC3qpdCso45Iw127gnp7sPhqgY8lUQhDrArIs+GbmfaaVPFuoyy7WyLT9EPZCRZ7j7aLdwUhQmHWesEow==",
"dev": true, "dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"dependencies": { "dependencies": {
"minimatch": "^10.2.2", "minimatch": "^11.0.2",
"minipass": "^7.1.3", "minipass": "^7.1.3",
"path-scurry": "^2.0.2" "path-scurry": "^3.0.0"
}, },
"engines": { "engines": {
"node": "18 || 20 || >=22" "node": "18 || 20 || >=22"
@@ -10670,21 +10602,6 @@
"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==",
@@ -11473,12 +11390,19 @@
} }
}, },
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "10.2.6", "resolved": "scripts/minimatch-compat",
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "link": true
},
"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": {
"brace-expansion": "^5.0.8" "@isaacs/nonrecursive-glob-match": "^1.0.2",
"brace-expansion": "^5.0.12"
}, },
"engines": { "engines": {
"node": "18 || 20 || >=22" "node": "18 || 20 || >=22"
@@ -12212,12 +12136,6 @@
"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",
@@ -12314,8 +12232,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/path-scurry": { "node_modules/path-scurry": {
"version": "2.0.2", "version": "3.0.0",
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-3.0.0.tgz",
"integrity": "sha512-1pTbQqH1wIwMi/eleQ5leFTTCkzFsPcgXNjwOA8YGGW/W4/kHT3dssE7Z6NH+zuqNLOzOB0uaD61yl7cAcVhgg==",
"dev": true, "dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"dependencies": { "dependencies": {
@@ -15929,8 +15848,9 @@
} }
}, },
"node_modules/vite-plugin-pwa": { "node_modules/vite-plugin-pwa": {
"version": "1.3.0", "version": "2.0.0",
"integrity": "sha512-c5kMgN+ITrOtHXp8PAtk2uOIEea6XjP/unCGxOWWBzQ6qa65qj/awHg0wf+QF9E/2u9vh86LqxPwzEPNbM2r5A==", "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-2.0.0.tgz",
"integrity": "sha512-6G9dsPKT9QKYpeTuTLJM28zZL3HsXUTUM/QUZBEgQk2jbfrPJDq2i8gTewAKqi1c9Mt6MiidrL4SFI+HthiQMg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -15941,13 +15861,13 @@
"workbox-window": "^7.4.1" "workbox-window": "^7.4.1"
}, },
"engines": { "engines": {
"node": ">=16.0.0" "node": ">=20.19.0"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/antfu" "url": "https://github.com/sponsors/antfu"
}, },
"peerDependencies": { "peerDependencies": {
"@vite-pwa/assets-generator": "^1.0.0", "@vite-pwa/assets-generator": "^1.0.0 || ^2.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"
@@ -17642,30 +17562,6 @@
"@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==",
@@ -18076,6 +17972,14 @@
"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"
}
} }
} }
} }
+6 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "wealth-ledger-frontend", "name": "wealth-ledger-frontend",
"private": true, "private": true,
"version": "1.2.1", "version": "1.3.0",
"type": "module", "type": "module",
"engines": { "engines": {
"node": "24.22.1" "node": "24.22.1"
@@ -106,6 +106,7 @@
"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",
@@ -114,7 +115,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": "1.3.0", "vite-plugin-pwa": "2.0.0",
"vitest": "5.0.5" "vitest": "5.0.5"
}, },
"overrides": { "overrides": {
@@ -124,7 +125,9 @@
}, },
"@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,
+7 -2
View File
@@ -16,7 +16,7 @@ type EntryCardProps = {
tone: "negative" tone: "negative"
| "neutral" | "neutral"
| "positive"; | "positive";
value: string; value: ReactNode;
valueLabel: string; valueLabel: string;
wrap?: boolean; wrap?: boolean;
}; };
@@ -32,6 +32,7 @@ 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
@@ -63,7 +64,11 @@ const EntryCard: FC<EntryCardProps> = (props) => {
</Stack> </Stack>
<Stack className={classes.value} gap={2}> <Stack className={classes.value} gap={2}>
<Text className={classes[props.tone]} fw={700}> <Text
className={classes[props.tone]}
component={valueComponent}
fw={700}
>
{props.value} {props.value}
</Text> </Text>
+9
View File
@@ -0,0 +1,9 @@
// 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
@@ -0,0 +1,11 @@
// 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
@@ -0,0 +1,14 @@
{
"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"
}
}
+16 -9
View File
@@ -11,7 +11,7 @@ import {
} from "@testing-library/react"; } from "@testing-library/react";
import { http, HttpResponse } from "msw"; import { http, HttpResponse } from "msw";
import { MemoryRouter, useLocation } from "react-router"; import { MemoryRouter, useLocation } from "react-router";
import { beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { BrokerageOperation } from "@/entities/broker-operation"; import type { BrokerageOperation } from "@/entities/broker-operation";
import type { IncomeAnalytics } from "@/entities/income"; import type { IncomeAnalytics } from "@/entities/income";
@@ -46,8 +46,10 @@ 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",
}; };
@@ -145,6 +147,7 @@ function renderPage(path: string) {
return client; return client;
} }
afterEach(() => vi.useRealTimers());
beforeEach(() => { beforeEach(() => {
server.use( server.use(
http.post("*/api/v1/auth/refresh", () => http.post("*/api/v1/auth/refresh", () =>
@@ -271,6 +274,8 @@ describe("All accounts routes", () => {
); );
it("restores applied dates when switching accounts in events", async () => { it("restores applied dates when switching accounts in events", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-10-15T12:00:00Z"));
// The events calendar blocks past dates, so pick within selectable dates. // The events calendar blocks past dates, so pick within selectable dates.
const search = `?from=${defaultPeriod()[0]}&to=${defaultPeriod()[1]}`; const search = `?from=${defaultPeriod()[0]}&to=${defaultPeriod()[1]}`;
renderPage(`/accounts/all/events${search}`); renderPage(`/accounts/all/events${search}`);
@@ -303,14 +308,16 @@ describe("All accounts routes", () => {
"href", "href",
`/accounts/${account.id}`, `/accounts/${account.id}`,
); );
for (const suffix of ["operations", "income", "events"])
expect( for (const [suffix, tab] of [
screen ["operations", "Операции"],
.getAllByRole("link") ["income", "Доход"],
.find( ["events", "События"],
link => link.getAttribute("href") === `/accounts/all/${suffix}`, ]) {
), fireEvent.click(screen.getByRole("tab", { name: tab }));
).toBeInTheDocument(); expect(screen.getByRole("link", { name: "Подробнее" }))
.toHaveAttribute("href", `/accounts/all/${suffix}`);
}
}); });
it("switches accounts within operations with applied filters and a fresh cursor", async () => { it("switches accounts within operations with applied filters and a fresh cursor", async () => {
+3 -2
View File
@@ -232,7 +232,8 @@ describe("AppRoutes", () => {
}), }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
screen.getByRole("navigation", { name: "WealthLedger" }), screen.getAllByRole("navigation", { name: "Основная навигация" }),
).toBeInTheDocument(); ).toHaveLength(2);
expect(screen.queryByRole("banner")).not.toBeInTheDocument();
}); });
}); });
+28 -94
View File
@@ -1,4 +1,4 @@
import type { FC } from "react"; import type { ComponentType, 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,101 +6,35 @@ 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";
const CardPage = lazy(async () => { // Pages use named exports; React.lazy needs a default export per dynamic import (one chunk each).
const { CardPage } = await import("@/pages/card"); function lazyPage<Name extends string>(
load: () => Promise<Record<Name, ComponentType>>,
name: Name,
) {
return lazy(async () => {
const page = await load();
return { default: CardPage }; // eslint-disable-next-line security/detect-object-injection -- `name` is a compile-time literal checked against the module's exports.
}); return { default: page[name] };
});
}
const CardsPage = lazy(async () => { const CardPage = lazyPage(async () => import("@/pages/card"), "CardPage");
const { CardsPage } = await import("@/pages/cards"); const CardsPage = lazyPage(async () => import("@/pages/cards"), "CardsPage");
const BondPage = lazyPage(async () => import("@/pages/bond"), "BondPage");
return { default: CardsPage }; const BondsPage = lazyPage(async () => import("@/pages/bonds"), "BondsPage");
}); const StockPage = lazyPage(async () => import("@/pages/stock"), "StockPage");
const StocksPage = lazyPage(async () => import("@/pages/stocks"), "StocksPage");
const BondPage = lazy(async () => { const AccountPage = lazyPage(async () => import("@/pages/account"), "AccountPage");
const { BondPage } = await import("@/pages/bond"); const AccountEventsPage = lazyPage(async () => import("@/pages/account-events"), "AccountEventsPage");
const AccountOperationsPage = lazyPage(async () => import("@/pages/account-operations"), "AccountOperationsPage");
return { default: BondPage }; const AccountIncomePage = lazyPage(async () => import("@/pages/account-income"), "AccountIncomePage");
}); const AccountsPage = lazyPage(async () => import("@/pages/accounts"), "AccountsPage");
const LoginPage = lazyPage(async () => import("@/pages/login"), "LoginPage");
const BondsPage = lazy(async () => { const NotFoundPage = lazyPage(async () => import("@/pages/not-found"), "NotFoundPage");
const { BondsPage } = await import("@/pages/bonds"); const RegisterPage = lazyPage(async () => import("@/pages/register"), "RegisterPage");
const SettingsPage = lazyPage(async () => import("@/pages/settings"), "SettingsPage");
return { default: BondsPage }; const ProtectedLayout = lazyPage(async () => import("../ProtectedLayout"), "ProtectedLayout");
});
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>
+84 -18
View File
@@ -1,12 +1,22 @@
.root {
--bottom-nav-height: 56px;
}
.content { .content {
max-width: 1120px; max-width: 1120px;
margin-inline: auto; margin-inline: auto;
} }
.header { .brand {
align-items: center; align-items: center;
border-bottom: 1px solid var(--mantine-color-gray-3);
display: flex; display: flex;
padding: 0 var(--mantine-spacing-md); min-height: 60px;
padding-inline: var(--mantine-spacing-sm);
}
.navList {
padding: var(--mantine-spacing-sm);
} }
.bottomNav { .bottomNav {
@@ -52,29 +62,85 @@
.bottomNav { .bottomNav {
background: var(--mantine-color-white); background: var(--mantine-color-white);
border-top: 1px solid var(--mantine-color-gray-3); border-top: 1px solid var(--mantine-color-gray-3);
bottom: 0; display: grid;
display: flex; grid-template-columns: repeat(5, minmax(0, 1fr));
justify-content: space-around; inset: auto 0 0;
left: 0; min-height: calc(var(--bottom-nav-height) + env(safe-area-inset-bottom, 0px));
padding: var(--mantine-spacing-xs) 2px; padding-bottom: env(safe-area-inset-bottom, 0px);
gap: 2px; padding-inline:
font-size: 11px; max(4px, env(safe-area-inset-left, 0px))
& a { max(4px, env(safe-area-inset-right, 0px));
min-width: 0;
padding: 8px 2px;
text-align: center;
}
position: fixed; position: fixed;
right: 0; z-index: var(--mantine-z-index-app);
z-index: 200; }
.bottomNavItem {
align-items: center;
color: var(--mantine-color-gray-7);
display: flex;
flex-direction: column;
gap: 2px;
justify-content: center;
min-height: var(--bottom-nav-height);
min-width: 0;
text-decoration: none;
&:active .iconPill {
background-color: var(--mantine-color-gray-1);
}
&:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: -2px;
}
}
@media (hover: hover) {
.bottomNavItem:hover .iconPill {
background-color: var(--mantine-color-gray-1);
}
}
.iconPill {
align-items: center;
border-radius: 14px;
display: inline-flex;
height: 28px;
justify-content: center;
width: 48px;
}
.bottomNavLabel {
font-size: 12px;
font-weight: 500;
line-height: 16px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (prefers-reduced-motion: no-preference) {
.bottomNavItem,
.iconPill {
transition:
background-color 150ms ease-in-out,
color 150ms ease-in-out;
}
} }
.active { .active {
color: var(--mantine-primary-color-filled); color: var(--mantine-primary-color-filled);
font-weight: 600;
& .iconPill {
background-color: var(--mantine-primary-color-light);
}
} }
.main { .main {
padding-bottom: 5rem; padding-bottom: calc(
var(--bottom-nav-height) + env(safe-area-inset-bottom, 0px) +
var(--mantine-spacing-md)
);
} }
} }
+47
View File
@@ -0,0 +1,47 @@
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 { AppShell } from "./AppShell";
function renderShell(path: string) {
return render(
<MantineProvider>
<MemoryRouter initialEntries={[path]}>
<AppShell />
</MemoryRouter>
</MantineProvider>,
);
}
describe("AppShell", () => {
it("has no header banner", () => {
renderShell("/cards");
expect(screen.queryByRole("banner")).not.toBeInTheDocument();
});
it("marks only the current bottom navigation item", () => {
renderShell("/cards");
const bottomNav = screen
.getAllByRole("navigation", { name: "Основная навигация" })
.at(-1);
if (!bottomNav) {
throw new Error("Bottom navigation is not rendered");
}
const links = within(bottomNav).getAllByRole("link");
expect(links).toHaveLength(5);
expect(within(bottomNav).getByRole("link", { name: "Карты" })).toHaveAttribute(
"aria-current",
"page",
);
expect(
within(bottomNav).getByRole("link", { name: "Счета" }),
).not.toHaveAttribute("aria-current");
});
});
+49 -42
View File
@@ -17,6 +17,12 @@ type AppShellProps = {
contentAboveOutlet?: ReactNode; contentAboveOutlet?: ReactNode;
}; };
function getBottomNavItemClass({ isActive }: { isActive: boolean }) {
return isActive
? `${classes.bottomNavItem} ${classes.active}`
: classes.bottomNavItem;
}
const AppShell: FC<AppShellProps> = (props) => { const AppShell: FC<AppShellProps> = (props) => {
const { contentAboveOutlet } = props; const { contentAboveOutlet } = props;
const { t } = useTranslation(); const { t } = useTranslation();
@@ -24,31 +30,31 @@ const AppShell: FC<AppShellProps> = (props) => {
const items = [ const items = [
{ {
icon: <PortfolioIcon size={20} />, Icon: PortfolioIcon,
iconClassName: classes.portfolioIcon, iconClassName: classes.portfolioIcon,
label: t("navigation.accounts"), label: t("navigation.accounts"),
to: "/accounts", to: "/accounts",
}, },
{ {
icon: <CardsIcon size={20} />, Icon: CardsIcon,
iconClassName: classes.cardsIcon, iconClassName: classes.cardsIcon,
label: t("navigation.cards"), label: t("navigation.cards"),
to: "/cards", to: "/cards",
}, },
{ {
icon: <StocksIcon size={20} />, Icon: StocksIcon,
iconClassName: classes.stocksIcon, iconClassName: classes.stocksIcon,
label: t("navigation.stocks"), label: t("navigation.stocks"),
to: "/stocks", to: "/stocks",
}, },
{ {
icon: <BondsIcon size={20} />, Icon: BondsIcon,
iconClassName: classes.bondsIcon, iconClassName: classes.bondsIcon,
label: t("navigation.bonds"), label: t("navigation.bonds"),
to: "/bonds", to: "/bonds",
}, },
{ {
icon: <SettingsIcon size={20} />, Icon: SettingsIcon,
iconClassName: classes.settingsIcon, iconClassName: classes.settingsIcon,
label: t("navigation.settings"), label: t("navigation.settings"),
to: "/settings", to: "/settings",
@@ -57,32 +63,32 @@ const AppShell: FC<AppShellProps> = (props) => {
return ( return (
<MantineAppShell <MantineAppShell
header={{ height: 60 }} className={classes.root}
navbar={{ breakpoint: "sm", collapsed: { mobile: true }, width: 220 }} navbar={{ breakpoint: "sm", collapsed: { mobile: true }, width: 220 }}
padding="md" padding="md"
> >
<MantineAppShell.Header className={classes.header}> <MantineAppShell.Navbar aria-label={t("navigation.primary")}>
<Text fw={700}>{t("appName")}</Text> <div className={classes.brand}>
</MantineAppShell.Header> <Text fw={700}>{t("appName")}</Text>
</div>
<MantineAppShell.Navbar p="sm"> <div className={classes.navList}>
{items.map(item => ( {items.map(item => (
<NavLink <NavLink
active={pathname.startsWith(item.to)} active={pathname.startsWith(item.to)}
className={classes.navLink} className={classes.navLink}
component={RouterNavLink} component={RouterNavLink}
key={item.to} key={item.to}
label={item.label} label={item.label}
leftSection={( leftSection={(
<span <span className={`${classes.navIcon} ${item.iconClassName}`}>
className={`${classes.navIcon} ${item.iconClassName ?? ""}`} <item.Icon size={20} />
> </span>
{item.icon} )}
</span> to={item.to}
)} />
to={item.to} ))}
/> </div>
))}
</MantineAppShell.Navbar> </MantineAppShell.Navbar>
<MantineAppShell.Main className={classes.main}> <MantineAppShell.Main className={classes.main}>
@@ -93,22 +99,23 @@ const AppShell: FC<AppShellProps> = (props) => {
</div> </div>
</MantineAppShell.Main> </MantineAppShell.Main>
<nav aria-label={t("appName")} className={classes.bottomNav}> <nav
{items.map((item) => { aria-label={t("navigation.primary")}
const appearanceClass = pathname.startsWith(item.to) className={classes.bottomNav}
? classes.active >
: undefined; {items.map(item => (
<RouterNavLink
className={getBottomNavItemClass}
key={item.to}
to={item.to}
>
<span className={classes.iconPill}>
<item.Icon size={24} />
</span>
return ( <span className={classes.bottomNavLabel}>{item.label}</span>
<RouterNavLink </RouterNavLink>
className={appearanceClass} ))}
key={item.to}
to={item.to}
>
{item.label}
</RouterNavLink>
);
})}
</nav> </nav>
</MantineAppShell> </MantineAppShell>
); );
+2
View File
@@ -5,5 +5,7 @@ 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,9 +1,12 @@
import { expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { operationLabelKey } from "./display"; import type { BrokerageOperation } from "../api/useBrokerageOperations";
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"],
@@ -21,3 +24,71 @@ 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();
});
});
+22 -2
View File
@@ -1,4 +1,4 @@
import { formatMoney } from "@/shared/lib"; import { formatMoney, formatSnapshotDay } from "@/shared/lib";
import type { BrokerageOperation } from "../api/useBrokerageOperations"; import type { BrokerageOperation } from "../api/useBrokerageOperations";
@@ -27,6 +27,19 @@ 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)
@@ -115,4 +128,11 @@ function paymentPresentation(
}; };
} }
export { isIncomePayment, operationLabelKey, paymentPresentation }; export {
brokerageOperationGroupKey,
entryLabelKey,
isIncomePayment,
isZeroDecimal,
operationLabelKey,
paymentPresentation,
};
@@ -0,0 +1,131 @@
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 header, exact total and separate card rows per account", () => {
renderGroup([
operation("1", { id: "broker", name: "Брокерский" }, "0.1"),
operation("2", { id: "iis", name: "ИИС" }, "0.2"),
]);
expect(screen.getByText("Купон · BOND")).toBeInTheDocument();
expect(screen.getByText("+0,3 ₽")).toBeInTheDocument();
const accounts = within(screen.getByRole("list", {
name: /Купон · BOND, Исполнено 14\.08\.2026.*, \+0,3 ₽/,
}));
expect(accounts.getByRole("listitem", { name: "Брокерский, +0,1 ₽" }))
.toHaveTextContent("Купон");
expect(accounts.getByRole("listitem", { name: "ИИС, +0,2 ₽" }))
.toHaveTextContent("Купон");
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();
});
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();
});
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);
});
});
@@ -0,0 +1,96 @@
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, EntryGroup, EntryGroupTotal } from "@/shared/ui";
import type { BrokerageOperation } from "../api/useBrokerageOperations";
import {
entryLabelKey,
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 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 title = `${typeLabel} · ${instrument}`;
const totalsText = totalText(totals, true);
const accessibleName = [
title,
t("payout.executedAt", { date }),
totalsText || t("entryGroup.totalUnavailable"),
].join(", ");
return (
<EntryGroup
header={(
<EntryCard
imageSrc={instrumentLogoUrl(first.logoName)}
initial={(first.ticker?.trim()[0] ?? "?").toUpperCase()}
metadata={t("payout.executedAt", { date })}
title={title}
tone="neutral"
value={<EntryGroupTotal isSigned totals={totals} />}
valueLabel={typeLabel}
wrap
/>
)}
label={accessibleName}
>
{props.entries.map(({ key, operation }) => {
const payment = paymentPresentation(operation);
return (
<AccountCardRow
accountName={operation.account?.name ?? "—"}
key={key}
value={payment.amount}
valueLabel={t(payment.label)}
/>
);
})}
</EntryGroup>
);
};
export { OperationGroup };
@@ -8,11 +8,15 @@ const VISIBLE_CURRENCY_LIMIT = 2;
type BrokerageAccountCard = { type BrokerageAccountCard = {
account: BrokerageAccount; account: BrokerageAccount;
positionCounts: null positionCounts: null | PositionCounts;
| { bonds: number; stocks: number };
source?: NonNullable<InvestmentResourceMeta["dependencies"]>[number]; source?: NonNullable<InvestmentResourceMeta["dependencies"]>[number];
summary: summary:
| { remainingCurrencyCount: number; state: "available"; totals: Money[] } | {
portfolio: NonNullable<BrokerageAccount["portfolioSummary"]>;
remainingCurrencyCount: number;
state: "available";
totals: Money[];
}
| { state: "empty" } | { state: "empty" }
| { state: "unavailable" }; | { state: "unavailable" };
}; };
@@ -21,6 +25,8 @@ type Money = NonNullable<
BrokerageAccount["portfolioSummary"] BrokerageAccount["portfolioSummary"]
>["totalsByCurrency"][number]; >["totalsByCurrency"][number];
type PositionCounts = { bonds: number; stocks: number; total: number };
function accountTypeKey(type: BrokerageAccount["type"]) { function accountTypeKey(type: BrokerageAccount["type"]) {
return `accounts.types.${type}` as const; return `accounts.types.${type}` as const;
} }
@@ -48,6 +54,7 @@ function brokerageAccountCards(
const positionCounts = { const positionCounts = {
bonds: summary.bondPositionCount, bonds: summary.bondPositionCount,
stocks: summary.stockPositionCount, stocks: summary.stockPositionCount,
total: summary.totalPositionCount,
}; };
if (summary.totalsByCurrency.length === 0) if (summary.totalsByCurrency.length === 0)
@@ -58,6 +65,7 @@ function brokerageAccountCards(
positionCounts, positionCounts,
source, source,
summary: { summary: {
portfolio: summary,
remainingCurrencyCount: Math.max( remainingCurrencyCount: Math.max(
summary.totalsByCurrency.length - VISIBLE_CURRENCY_LIMIT, summary.totalsByCurrency.length - VISIBLE_CURRENCY_LIMIT,
0, 0,
@@ -1,166 +1,144 @@
.list { .list {
display: grid;
width: 100%; width: 100%;
margin: 0;
padding: 0;
gap: var(--mantine-spacing-sm);
list-style: none;
} }
.card { .row {
min-width: 0; display: grid;
gap: var(--mantine-spacing-xl); grid-template-areas:
background: var(--mantine-color-white); "identity balance"
border-color: var(--mantine-color-gray-3); "details balance"
box-shadow: 0 4px 16px rgb(15 23 42 / 3%); "structure structure";
grid-template-columns: minmax(0, 1fr) minmax(0, auto);
gap: var(--mantine-spacing-sm) var(--mantine-spacing-xl);
align-items: center;
padding: var(--mantine-spacing-lg);
border: 1px solid var(--wl-color-border);
border-left: 3px solid var(--wl-color-primary);
border-radius: var(--mantine-radius-md);
background: var(--wl-color-surface);
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
transition: transition: background-color 160ms ease, border-color 160ms ease;
transform 160ms ease,
box-shadow 160ms ease,
border-color 160ms ease;
} }
.card:hover, .row:hover,
.card:focus-visible { .row:focus-visible {
border-color: var(--mantine-color-blue-3); border-color: var(--wl-color-primary);
box-shadow: 0 12px 28px rgb(15 23 42 / 8%); background: var(--wl-color-subtle);
transform: translateY(-3px);
} }
.card:focus-visible { .row:focus-visible {
outline: 2px solid var(--mantine-color-blue-6); outline: 2px solid var(--wl-color-primary);
outline-offset: 3px; outline-offset: -2px;
} }
.identity { .identity {
grid-area: identity;
min-width: 0; min-width: 0;
} }
.kicker {
padding: 5px 10px;
border-radius: var(--mantine-radius-sm);
background: var(--mantine-color-blue-0);
color: var(--mantine-color-blue-8);
letter-spacing: 0.02em;
}
.accountIcon {
width: 28px;
height: 28px;
flex-shrink: 0;
color: var(--mantine-color-blue-5);
}
.name { .name {
margin: 0; margin: 0;
font-size: var(--mantine-h3-font-size); color: var(--wl-color-text);
line-height: 1.3; font-size: var(--mantine-font-size-md);
line-height: var(--mantine-line-height-md);
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.accountType {
flex-shrink: 0;
align-self: flex-start;
padding: 2px var(--mantine-spacing-xs);
border-radius: var(--mantine-radius-sm);
}
.balance { .balance {
flex: 1; grid-area: balance;
min-width: 0;
text-align: right;
} }
.summary { .summary {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums lining-nums;
white-space: nowrap;
} }
.total { .total {
font-size: clamp(1.5rem, 2.8vw, 2.25rem); color: var(--wl-color-text);
letter-spacing: -0.035em;
line-height: 1.2;
overflow-wrap: anywhere;
}
.total + .total {
font-size: var(--mantine-font-size-lg); font-size: var(--mantine-font-size-lg);
font-weight: 500; line-height: var(--mantine-line-height-lg);
letter-spacing: 0;
color: var(--mantine-color-gray-7);
} }
.positions { .return,
display: grid; .cashValue {
grid-template-columns: repeat(2, minmax(0, 1fr)); font-variant-numeric: tabular-nums lining-nums;
gap: var(--mantine-spacing-sm); white-space: nowrap;
margin: 0;
} }
.positions > div { .positive {
display: flex; color: var(--wl-color-positive);
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 4px var(--mantine-spacing-sm);
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
border-radius: var(--mantine-radius-md);
} }
.stocks { .negative {
background: var(--mantine-color-blue-0); color: var(--wl-color-negative);
color: var(--mantine-color-blue-8);
} }
.bonds { .neutral {
background: var(--mantine-color-grape-0); color: var(--wl-color-muted);
color: var(--mantine-color-grape-8);
} }
.positions dt { .cash {
font-size: var(--mantine-font-size-sm); font-variant-numeric: tabular-nums lining-nums;
font-weight: 500;
} }
.positions dd { .details {
margin: 0; grid-area: details;
font-size: 1.75rem; min-width: 0;
font-weight: 700; font-variant-numeric: tabular-nums lining-nums;
font-variant-numeric: tabular-nums;
line-height: 1.25;
overflow-wrap: anywhere;
} }
.footer { .structure {
border-top: 1px solid var(--mantine-color-gray-2); grid-area: structure;
padding-top: var(--mantine-spacing-md);
}
.open {
display: inline-flex;
align-items: center;
gap: var(--mantine-spacing-xs); gap: var(--mantine-spacing-xs);
margin-left: auto;
color: var(--mantine-color-blue-7);
} }
.arrow { .structureBar {
font-size: var(--mantine-font-size-lg); display: flex;
transition: transform 160ms ease; width: 100%;
height: 6px;
overflow: hidden;
border-radius: var(--mantine-radius-xs);
background: var(--wl-color-subtle);
} }
.card:hover .arrow { .structureSection {
transform: translateX(3px); flex: none;
height: 100%;
background: var(--category-color);
} }
@media (max-width: 36em) { @media (max-width: 47.9375em) {
.card { .row {
grid-template-areas:
"identity"
"balance"
"structure"
"details";
grid-template-columns: minmax(0, 1fr);
gap: var(--mantine-spacing-sm);
padding: var(--mantine-spacing-md); padding: var(--mantine-spacing-md);
gap: var(--mantine-spacing-lg);
} }
.positions > div { .balance {
flex-direction: column; text-align: left;
align-items: flex-start;
padding: var(--mantine-spacing-sm);
} }
}
@media (prefers-reduced-motion: reduce) { .balance > div {
.card, justify-content: flex-start;
.arrow {
transition: none;
}
.card:hover,
.card:focus-visible,
.card:hover .arrow {
transform: none;
} }
} }
@@ -2,32 +2,86 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import { MemoryRouter } from "react-router"; import { MemoryRouter } from "react-router";
import { resource } from "@/test/resource";
import type { BrokerageAccount } from "../../api/useBrokerageAccounts"; import type { BrokerageAccount } from "../../api/useBrokerageAccounts";
import { brokerageAccountCards } from "../../model/display"; import { brokerageAccountCards } from "../../model/display";
import { BrokerageAccountList } from "./BrokerageAccountList"; import { BrokerageAccountList } from "./BrokerageAccountList";
const syntheticAccount = { type PortfolioSummary = NonNullable<BrokerageAccount["portfolioSummary"]>;
id: "storybook-account",
name: "Учебный брокерский счёт", const rubReturn = {
openedOn: "2025-03-15", absolute: "136900",
portfolioSummary: { costBasis: "1103600.50",
bondPositionCount: 12, currency: "RUB",
stockPositionCount: 5, partial: false,
totalsByCurrency: [ percent: "12.40",
{ currency: "RUB", value: "125000.50" }, };
{ currency: "USD", value: "320" },
{ currency: "EUR", value: "210" }, const portfolioSummary = {
], bondPositionCount: 12,
cashBalances: [
{ currency: "RUB", value: "82500.50" },
{ currency: "USD", value: "1500" },
],
fundPositionCount: 2,
stockPositionCount: 5,
structure: {
currencies: [{
categories: [
{ sharePercent: "55.000000", type: "STOCK", value: "682500" },
{ sharePercent: "25.000000", type: "BOND", value: "310500" },
{ sharePercent: "10.000000", type: "OTHER", value: "124200" },
{ sharePercent: "10.000000", type: "CASH", value: "124200" },
],
currency: "RUB",
hasNegativeValues: false,
totalValue: "1241400",
}],
unvaluedCategories: [],
}, },
type: "BROKERAGE", totalPositionCount: 19,
} satisfies BrokerageAccount; totalsByCurrency: [{ currency: "RUB", value: "1240500.50" }],
unrealizedReturnByCurrency: [rubReturn],
} satisfies PortfolioSummary;
function account(
id: string,
name: string,
summary: null | PortfolioSummary = portfolioSummary,
type: BrokerageAccount["type"] = "BROKERAGE",
): BrokerageAccount {
return {
id,
name,
openedOn: "2025-03-15",
portfolioSummary: summary,
type,
};
}
const negativeSummary: PortfolioSummary = {
...portfolioSummary,
unrealizedReturnByCurrency: [{
...rubReturn,
absolute: "-4200",
percent: "-3.05",
}],
};
const emptySummary: PortfolioSummary = {
...portfolioSummary,
cashBalances: [],
structure: { currencies: [], unvaluedCategories: [] },
totalPositionCount: 0,
totalsByCurrency: [],
unrealizedReturnByCurrency: [],
};
const meta = { const meta = {
args: { accounts: brokerageAccountCards([syntheticAccount]) }, args: { accounts: brokerageAccountCards([account("normal", "Основной ИИС")]) },
argTypes: { argTypes: { accounts: { control: false } },
accounts: { control: false },
},
component: BrokerageAccountList, component: BrokerageAccountList,
decorators: [ decorators: [
Story => ( Story => (
@@ -40,7 +94,7 @@ const meta = {
docs: { docs: {
description: { description: {
component: component:
"Карточки счетов WealthLedger со сводкой по валютам и числом наименований акций и облигаций. Используйте на экране выбора счёта; отсутствие сводки и отсутствие сумм имеют разные сообщения. Примеры содержат вымышленные данные. Ссылки меняют только локальный маршрут MemoryRouter; страница назначения в этой истории не отображается.", "Карточки счетов: стоимость и доходность по валютам, деньги, структура и свежесть источника. Денежные и процентные значения остаются строками. Все примеры содержат вымышленные данные; ссылки работают только внутри MemoryRouter.",
}, },
}, },
}, },
@@ -52,188 +106,112 @@ export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Available: Story = { export const Normal: Story = {};
name: "Сводка по нескольким валютам",
parameters: { export const MultiCurrency: Story = {
docs: { args: {
description: { accounts: brokerageAccountCards([account("multi", "Мультивалютный", {
story: ...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 Unavailable: Story export const Negative: Story = {
= { args: {
args: { accounts: brokerageAccountCards([
accounts: brokerageAccountCards([ account("negative", "Просевший портфель", negativeSummary),
{ ...syntheticAccount, openedOn: null, portfolioSummary: null }, ]),
]), },
}, };
name: "Сводка недоступна",
parameters: { export const Partial: Story = {
docs: { args: {
description: { accounts: brokerageAccountCards([account("partial", "Частичные данные", {
story: ...portfolioSummary,
"Счёт остаётся доступен для открытия, даже если сводка временно недоступна. Неизвестные суммы не заменяются нулём; отсутствующая дата открытия не отображается.", unrealizedReturnByCurrency: [{
}, ...rubReturn,
}, partial: true,
}, }],
}; })]),
},
};
export const Empty: Story = { export const Empty: Story = {
args: { args: {
accounts: brokerageAccountCards([ accounts: brokerageAccountCards([
{ account("empty", "Новый пустой счёт", emptySummary),
...syntheticAccount,
portfolioSummary: {
bondPositionCount: 0,
stockPositionCount: 0,
totalsByCurrency: [],
},
},
]), ]),
}, },
name: "Нет текущих сумм", };
parameters: {
docs: { export const Unavailable: Story = {
description: { args: {
story: accounts: brokerageAccountCards([
"Успешно полученная пустая сводка отличается от ошибки получения данных. Карточка сохраняет название, тип счёта и переход.", account("unavailable", "Счёт без сводки", null),
}, ]),
}, },
};
export const Stale: Story = {
args: {
accounts: brokerageAccountCards([
account("stale", "Устаревший портфель"),
], resource([], {
dependencies: [{
availability: "available",
completeness: "complete",
dataRevision: "stale-revision",
freshness: "stale",
lastAttempt: {
finishedAt: null,
problems: [],
startedAt: null,
state: "none",
},
lastSuccessfulAt: "2026-08-13T00:00:00Z",
resourceKey: "portfolio/stale",
}],
freshness: "stale",
lastSuccessfulAt: "2026-08-14T00:00:00Z",
}).meta.dependencies),
}, },
}; };
export const LongName: Story = { export const LongName: Story = {
args: { args: {
accounts: brokerageAccountCards([ accounts: brokerageAccountCards([
{ account(
...syntheticAccount, "long",
name: "Учебный индивидуальный инвестиционный счёт для долгосрочных накоплений", "Очень длинное название индивидуального инвестиционного счёта, которое должно корректно переноситься на узком экране",
type: "INDIVIDUAL_INVESTMENT", portfolioSummary,
}, "INDIVIDUAL_INVESTMENT",
]),
},
name: "Длинное название счёта",
parameters: {
docs: {
description: {
story:
"Проверьте перенос длинного названия на узком экране и доступность всей карточки с клавиатуры.",
},
},
},
};
export const AccountGrid: Story = {
args: {
accounts: brokerageAccountCards([
{
...syntheticAccount,
name: "Основной",
portfolioSummary: {
bondPositionCount: 14,
stockPositionCount: 8,
totalsByCurrency: [{ currency: "RUB", value: "1256430.50" }],
},
},
{
...syntheticAccount,
id: "storybook-iis",
name: "Долгосрочные инвестиции",
portfolioSummary: {
bondPositionCount: 6,
stockPositionCount: 0,
totalsByCurrency: [{ currency: "RUB", value: "480250" }],
},
type: "INDIVIDUAL_INVESTMENT",
},
]),
},
name: "Несколько счетов",
};
export const UnvaluedPositions: Story = {
args: {
accounts: brokerageAccountCards([
{
...syntheticAccount,
portfolioSummary: {
bondPositionCount: 1,
stockPositionCount: 2,
totalsByCurrency: [],
},
},
]),
},
name: "Бумаги без текущей оценки",
};
export const LargeValues: Story = {
args: {
accounts: brokerageAccountCards([
{
...syntheticAccount,
portfolioSummary: {
bondPositionCount: 105,
stockPositionCount: 21,
totalsByCurrency: [
{ currency: "RUB", value: "1234567890123456789.50" },
{ currency: "USD", value: "-9876543210987654321.50" },
],
},
},
]),
},
name: "Длинные суммы",
};
export const IndependentFreshness: Story
= {
args: {
accounts: brokerageAccountCards(
[
syntheticAccount,
{
...syntheticAccount,
id: "waiting",
name: "Новый счёт",
portfolioSummary: null,
},
],
[
{
availability: "available",
completeness: "complete",
dataRevision: "1",
freshness: "stale",
lastAttempt: {
finishedAt: "2026-09-24T10:31:00Z",
problems: [],
startedAt: "2026-09-24T10:30:00Z",
state: "failed",
},
lastSuccessfulAt: "2026-09-24T10:30:00Z",
resourceKey: "portfolio/storybook-account",
},
{
availability: "initial-loading",
completeness: "incomplete",
dataRevision: "0",
freshness: "unknown",
lastAttempt: {
finishedAt: null,
problems: [],
startedAt: "2026-09-24T10:30:00Z",
state: "running",
},
lastSuccessfulAt: null,
resourceKey: "portfolio/waiting",
},
],
), ),
}, ]),
name: "Независимая свежесть и ошибка сводок", },
}; };
@@ -5,122 +5,202 @@ import { describe, expect, it } from "vitest";
import { resource } from "@/test/resource"; import { resource } from "@/test/resource";
import type { BrokerageAccount } from "../../api/useBrokerageAccounts";
import { brokerageAccountCards } from "../../model/display"; import { brokerageAccountCards } from "../../model/display";
import { BrokerageAccountList } from "./BrokerageAccountList"; import { BrokerageAccountList } from "./BrokerageAccountList";
describe("BrokerageAccountList", () => { type PortfolioSummary = NonNullable<BrokerageAccount["portfolioSummary"]>;
it("renders account details and an account link", () => {
render(
<MantineProvider>
<MemoryRouter>
<BrokerageAccountList
accounts={brokerageAccountCards([
{
id: "account-1",
name: "Основной",
openedOn: "2024-08-13",
portfolioSummary: {
bondPositionCount: 12,
stockPositionCount: 5,
totalsByCurrency: [
{ currency: "RUB", value: "1500" },
{ currency: "USD", value: "20" },
{ currency: "EUR", value: "5" },
],
},
type: "BROKERAGE",
}, const portfolioSummary = {
{ bondPositionCount: 12,
id: "account-2", cashBalances: [
name: "Недоступный", { currency: "RUB", value: "82500.5" },
openedOn: null, { currency: "USD", value: "20" },
portfolioSummary: null, ],
type: "BROKERAGE", fundPositionCount: 0,
}, stockPositionCount: 5,
{ structure: {
id: "account-3", currencies: [{
name: "Пустой", categories: [
openedOn: null, { sharePercent: "50.000000", type: "STOCK", value: "100" },
portfolioSummary: { { sharePercent: "30.000000", type: "BOND", value: "60" },
bondPositionCount: 0, { sharePercent: "0.000000", type: "OTHER", value: "0" },
stockPositionCount: 2, { sharePercent: "20.000000", type: "CASH", value: "40" },
totalsByCurrency: [], ],
}, currency: "RUB",
type: "BROKERAGE", hasNegativeValues: false,
}, totalValue: "200",
])} }],
/> unvaluedCategories: [],
</MemoryRouter> },
</MantineProvider>, totalPositionCount: 17,
); totalsByCurrency: [
{ currency: "RUB", value: "123456789012345678901.995" },
{ currency: "USD", value: "20" },
{ currency: "EUR", value: "5" },
],
unrealizedReturnByCurrency: [
{
absolute: "136900",
costBasis: "1000000",
currency: "RUB",
partial: true,
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;
expect(screen.getByRole("link", { name: /Основной/ })).toHaveAttribute( type BrokerageAccountCardInput = Parameters<typeof BrokerageAccountList>[0][
"href", "accounts"
"/accounts/account-1", ];
);
expect(screen.getAllByText("Брокерский счет")).toHaveLength(3);
expect(screen.getByText(/^Открыт /)).toBeInTheDocument();
expect(screen.getByText("1 500 ₽")).toBeInTheDocument();
expect(screen.getByText("20 USD")).toBeInTheDocument();
expect(screen.getByText("Еще 1 валюта")).toBeInTheDocument();
expect(screen.getByText("Сводка временно недоступна")).toBeInTheDocument();
expect(screen.getByText("Нет текущих сумм")).toBeInTheDocument();
expect(screen.getAllByText("Открыть счет")).toHaveLength(3);
const available = within(screen.getByRole("link", { name: /Основной/ }));
expect(available.getAllByRole("definition")[0]).toHaveTextContent("5");
expect(available.getAllByRole("definition")[1]).toHaveTextContent("12");
const unvalued = within(screen.getByRole("link", { name: /Пустой/ }));
expect(unvalued.getAllByRole("definition")[0]).toHaveTextContent("2");
expect(unvalued.getAllByRole("definition")[1]).toHaveTextContent("0");
expect(
within(screen.getByRole("link", { name: /Недоступный/ })).queryByText(
"Акции",
),
).not.toBeInTheDocument();
});
});
it("shows portfolio summary freshness independently for each account", () => { function account(
const accounts = brokerageAccountCards( id: string,
["fresh", "stale"].map( name: string,
summary: null | PortfolioSummary,
): BrokerageAccount {
return {
id,
name,
openedOn: null,
portfolioSummary: summary,
type: "BROKERAGE",
};
}
id => function renderList(accounts: BrokerageAccountCardInput) {
({ return render(
id,
name: id,
openedOn: null,
portfolioSummary: {
bondPositionCount: 0,
stockPositionCount: 1,
totalsByCurrency: [{ currency: "RUB", value: "10" }],
},
type: "BROKERAGE" as const,
}),
),
[
resource(null, { freshness: "fresh", resourceKey: "portfolio/fresh" })
.meta,
resource(null, { freshness: "stale", resourceKey: "portfolio/stale" })
.meta,
],
);
render(
<MantineProvider> <MantineProvider>
<MemoryRouter> <MemoryRouter>
<BrokerageAccountList accounts={accounts} /> <BrokerageAccountList accounts={accounts} />
</MemoryRouter> </MemoryRouter>
</MantineProvider>, </MantineProvider>,
); );
expect( }
within(screen.getByRole("link", { name: /stale/ })).getByText(
"Данные требуют обновления", describe("BrokerageAccountList", () => {
), it("renders returns, cash and structure with precise totals", () => {
).toBeVisible(); const nullReturnSummary: PortfolioSummary = {
expect( ...portfolioSummary,
within(screen.getByRole("link", { name: /fresh/ })).queryByText( cashBalances: [],
"Данные требуют обновления", structure: { currencies: [], unvaluedCategories: [] },
), totalsByCurrency: [{ currency: "EUR", value: "5" }],
).not.toBeInTheDocument(); unrealizedReturnByCurrency: [{
absolute: "0",
costBasis: "5",
currency: "EUR",
partial: false,
percent: null,
}],
};
renderList(brokerageAccountCards([
account("account-1", "Основной", portfolioSummary),
account("account-2", "Без доходности", nullReturnSummary),
]));
const link = screen.getByRole("link", { name: /Основной/ });
expect(link).toHaveAttribute("href", "/accounts/account-1");
expect(within(link).getByText("123 456 789 012 345 678 902,00 ₽"))
.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("−3,05 %")).toBeInTheDocument();
expect(within(link).getByText("−4,2 USD")).toBeInTheDocument();
const nullReturnLink = screen.getByRole("link", { name: /Без доходности/ });
expect(within(nullReturnLink).getByText("—")).toBeInTheDocument();
expect(within(link).getByText("частично")).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(screen.getByRole("img", {
name: "Структура в RUB: Акции 50 %; Облигации 30 %; "
+ "Другие активы 0 %; Свободные деньги 20 %",
})).toBeInTheDocument();
const stockLegend = within(link).getByText(/Акции 50 %/);
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.
expect(stockLegend.parentElement).toHaveAttribute("aria-hidden", "true");
expect(within(link).queryByText(/Другие активы 0 %/)).not.toBeInTheDocument();
expect(link).toHaveTextContent("По данным брокера");
expect(link).toHaveTextContent("Структура RUB: Акции 100");
});
it("preserves empty, unavailable and stale states", () => {
const emptySummary: PortfolioSummary = {
...portfolioSummary,
cashBalances: [],
structure: { currencies: [], unvaluedCategories: [] },
totalPositionCount: 0,
totalsByCurrency: [],
unrealizedReturnByCurrency: [],
};
const cards = brokerageAccountCards([
account("empty", "Пустой", emptySummary),
account("unavailable", "Недоступный", null),
account("stale", "Устаревший", portfolioSummary),
account("failed", "Ошибка обновления", null),
], resource([], {
dependencies: [{
availability: "available",
completeness: "complete",
dataRevision: "stale-revision",
freshness: "stale",
lastAttempt: {
finishedAt: null,
problems: [],
startedAt: null,
state: "none",
},
lastSuccessfulAt: "2026-08-13T00:00:00Z",
resourceKey: "portfolio/stale",
}, {
availability: "available",
completeness: "complete",
dataRevision: "failed-revision",
freshness: "stale",
lastAttempt: {
finishedAt: "2026-08-14T00:01:00Z",
problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed",
},
lastSuccessfulAt: "2026-08-13T00:00:00Z",
resourceKey: "portfolio/failed",
}],
freshness: "stale",
lastSuccessfulAt: "2026-08-14T00:00:00Z",
}).meta.dependencies);
renderList(cards);
expect(screen.getByText("Нет текущих сумм")).toBeInTheDocument();
expect(screen.getAllByText("Сводка временно недоступна"))
.toHaveLength(2);
const staleLink = screen.getByRole("link", { name: /Устаревший/ });
expect(within(staleLink).getByText("Данные требуют обновления"))
.toBeInTheDocument();
expect(within(staleLink).getByText(/Данные на/)).toBeInTheDocument();
const failedLink = screen.getByRole("link", { name: /Ошибка обновления/ });
expect(within(failedLink).getByText(
"Не удалось обновить данные. Показаны последние полученные.",
)).toBeInTheDocument();
});
}); });
@@ -1,11 +1,10 @@
import type { FC } from "react"; import type { CSSProperties, FC } from "react";
import { Card, Group, SimpleGrid, Stack, Text } from "@mantine/core"; import { Badge, Group, Stack, Text, VisuallyHidden } from "@mantine/core";
import { WalletIcon } from "@wealthledger/icons";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import { formatDate, formatMoney } from "@/shared/lib"; import { formatMoney, portfolioCategoryPresentation } from "@/shared/lib";
import { SourceSyncStatus } from "@/shared/ui"; import { SourceSyncStatus } from "@/shared/ui";
import type { BrokerageAccountCard } from "../../model/display"; import type { BrokerageAccountCard } from "../../model/display";
@@ -13,12 +12,176 @@ import type { BrokerageAccountCard } from "../../model/display";
import { accountTypeKey } from "../../model/display"; import { accountTypeKey } from "../../model/display";
import classes from "./BrokerageAccountList.module.css"; import classes from "./BrokerageAccountList.module.css";
const DECIMAL_PART_COUNT = 2;
const STRUCTURE_CATEGORY_ORDER = ["STOCK", "BOND", "OTHER", "CASH"] as const;
type AvailableSummary = Extract<
BrokerageAccountCard["summary"],
{ state: "available" }
>;
type BrokerageAccountListProps = { accounts: BrokerageAccountCard[] }; type BrokerageAccountListProps = { accounts: BrokerageAccountCard[] };
type BrokerageAccountSummaryProps = { type BrokerageAccountSummaryProps = {
summary: BrokerageAccountCard["summary"]; summary: BrokerageAccountCard["summary"];
}; };
type PortfolioSummary = AvailableSummary["portfolio"];
type ReturnValueProps = {
item: PortfolioSummary["unrealizedReturnByCurrency"][number];
};
type StructureCurrency = PortfolioSummary["structure"]["currencies"][number];
function formatPercent(value: null | string) {
if (value === null) return "—";
const formatted = `${value.replace(".", ",")}\u{A0}%`;
if (isZero(value))
return value.startsWith("-") ? formatted.slice(1) : formatted;
return value.startsWith("-") ? `−${formatted.slice(1)}` : `+${formatted}`;
}
function formatStructurePercent(value: string) {
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) {
const unsigned = value.startsWith("-") ? value.slice(1) : value;
const [integer = "", fraction = ""] = unsigned.split(".", DECIMAL_PART_COUNT);
return integer.replaceAll("0", "") === ""
&& fraction.replaceAll("0", "") === "";
}
const ReturnValue: FC<ReturnValueProps> = (props) => {
const { t } = useTranslation();
const { item } = props;
const isNegative = item.percent !== null
&& item.percent.startsWith("-")
&& !isZero(item.percent);
const isPositive = item.percent !== null
&& !item.percent.startsWith("-")
&& !isZero(item.percent);
let toneClass = classes.neutral;
if (isNegative) toneClass = classes.negative;
else if (isPositive) toneClass = classes.positive;
return (
<Group className={classes.return} gap="xs" wrap="nowrap">
<Text className={toneClass} fw={700} size="sm">
{formatPercent(item.percent)}
</Text>
<Text className={toneClass} size="sm">
{formatMoney(
{ currency: item.currency, value: item.absolute },
{ withSign: true },
)}
</Text>
{item.partial && (
<Text c="dimmed" size="xs">
{t("accounts.partial")}
</Text>
)}
</Group>
);
};
type StructureBarProps = { item: StructureCurrency };
const StructureBar: FC<StructureBarProps> = (props) => {
const { t } = useTranslation();
const categories = STRUCTURE_CATEGORY_ORDER.flatMap((type) => {
const category = props.item.categories.find(item => item.type === type);
return category ? [category] : [];
});
const description = categories.map((category) => {
const label = t(portfolioCategoryPresentation[category.type].label);
const percent = category.sharePercent === null
? null
: formatStructurePercent(category.sharePercent);
return percent === null
? t("accounts.structure.incomplete", { category: label })
: t("accounts.structure.category", { category: label, percent });
}).join("; ");
const label = t("accounts.structure.label", {
currency: props.item.currency,
value: description,
});
const legendCategories = categories.filter(category =>
category.sharePercent === null || !isZero(category.sharePercent));
return (
<Stack gap={4}>
<div
aria-label={label}
className={classes.structureBar}
role="img"
title={label}
>
{categories.map((category) => {
const presentation = portfolioCategoryPresentation[category.type];
const style = {
"--category-color": `var(--mantine-color-${presentation.color.replace(".", "-")})`,
"width": `${category.sharePercent ?? 0}%`,
} as CSSProperties;
return (
<div
aria-hidden
className={classes.structureSection}
key={category.type}
style={style}
/>
);
})}
</div>
<Group aria-hidden gap="xs" wrap="wrap">
{legendCategories.map((category) => {
const presentation = portfolioCategoryPresentation[category.type];
const categoryLabel = t(presentation.label);
const text = category.sharePercent === null
? categoryLabel
: t("accounts.structure.category", {
category: categoryLabel,
percent: formatStructurePercent(category.sharePercent),
});
return (
<Text c="dimmed" key={category.type} size="xs">
<Text c={presentation.color} component="span">●</Text>
{" "}
{text}
</Text>
);
})}
</Group>
</Stack>
);
};
const BrokerageAccountSummary: FC<BrokerageAccountSummaryProps> = (props) => { const BrokerageAccountSummary: FC<BrokerageAccountSummaryProps> = (props) => {
const { summary } = props; const { summary } = props;
const { t } = useTranslation(); const { t } = useTranslation();
@@ -36,19 +199,51 @@ const BrokerageAccountSummary: FC<BrokerageAccountSummaryProps> = (props) => {
); );
return ( return (
<Stack className={classes.summary} gap={4}> <Stack className={classes.balance} gap="xs">
{summary.totals.map(total => ( <Group align="flex-start" gap="lg" justify="flex-end" wrap="nowrap">
<Text className={classes.total} fw={700} key={total.currency}> <Stack className={classes.summary} gap={4}>
{formatMoney(total)} {summary.totals.map(total => (
</Text> <Text className={classes.total} fw={700} key={total.currency}>
))} {formatMoney(total, { withRoundedCents: true })}
</Text>
))}
{!!summary.remainingCurrencyCount && ( {!!summary.remainingCurrencyCount && (
<Text c="dimmed" size="sm"> <Text c="dimmed" size="sm">
{t("accounts.remainingCurrencies", { {t("accounts.remainingCurrencies", {
count: summary.remainingCurrencyCount, count: summary.remainingCurrencyCount,
})}
</Text>
)}
</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;
})} })}
</Text> </Stack>
</Group>
{summary.portfolio.cashBalances.length > 0 && (
<Group className={classes.cash} gap="xs" justify="flex-end">
<Text c="dimmed" size="xs">
{t("accounts.cash")}
</Text>
<Stack gap={0}>
{summary.portfolio.cashBalances.map(cash => (
<Text className={classes.cashValue} key={cash.currency} size="sm">
{formatMoney(cash, { withRoundedCents: true })}
</Text>
))}
</Stack>
</Group>
)} )}
</Stack> </Stack>
); );
@@ -61,13 +256,13 @@ type BrokerageAccountSourceStatusProps = {
const BrokerageAccountSourceStatus: FC<BrokerageAccountSourceStatusProps> = ( const BrokerageAccountSourceStatus: FC<BrokerageAccountSourceStatusProps> = (
props, props,
) => { ) => {
const { source } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const { source } = props;
const isSyncing = source?.lastAttempt.state === "running"; const isSyncing = source?.lastAttempt.state === "running";
const errorText const errorText = source?.lastAttempt.state === "failed"
= source?.lastAttempt.state === "failed" ? t("accounts.refreshError") : null; ? t("accounts.refreshError")
: null;
return ( return (
<> <>
@@ -87,86 +282,110 @@ const BrokerageAccountSourceStatus: FC<BrokerageAccountSourceStatusProps> = (
); );
}; };
type AccountDescriptionProps = {
account: BrokerageAccountCard;
};
const AccountDescription: FC<AccountDescriptionProps> = (props) => {
const { t } = useTranslation();
const { account, summary } = props.account;
if (summary.state !== "available") return null;
const returns = summary.portfolio.unrealizedReturnByCurrency.map(item =>
t("accounts.description.return", {
absolute: formatMoney(
{ currency: item.currency, value: item.absolute },
{ withSign: true },
),
currency: item.currency,
percent: formatPercent(item.percent),
}),
);
const structures = summary.portfolio.structure.currencies.map((item) => {
const categories = item.categories.map(category =>
t("accounts.description.structureCategory", {
category: t(portfolioCategoryPresentation[category.type].label),
value: formatMoney(
{ currency: item.currency, value: category.value },
{ withRoundedCents: true },
),
}),
).join(", ");
return t("accounts.description.structure", {
currency: item.currency,
value: categories,
});
});
const hasPartialReturn = summary.portfolio.unrealizedReturnByCurrency.some(
item => item.partial,
);
const details = [...returns, ...structures].join(". ");
return (
<VisuallyHidden>
{t("accounts.description.start", {
name: account.name,
type: t(accountTypeKey(account.type)),
})}
{t("accounts.description.currentValue", {
value: summary.portfolio.totalsByCurrency
.map(total => formatMoney(total)).join(", "),
})}
{details ? `${details}. ` : ""}
{hasPartialReturn ? t("accounts.brokerDescription") : ""}
</VisuallyHidden>
);
};
const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => { const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
const { accounts } = props;
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<SimpleGrid className={classes.list} cols={1} spacing="lg"> <ul className={classes.list}>
{accounts.map(({ account, positionCounts, source, summary }) => ( {props.accounts.map(({ account, positionCounts, source, summary }) => (
<Card <li key={account.id}>
className={classes.card} <Link className={classes.row} to={`/accounts/${account.id}`}>
component={Link} <Group className={classes.identity} wrap="nowrap">
key={account.id} <h2 className={classes.name}>{account.name}</h2>
padding="xl"
radius="lg" <Badge className={classes.accountType} variant="light">
to={`/accounts/${account.id}`}
withBorder
>
<Stack className={classes.identity} gap="sm">
<Group gap="sm" justify="space-between" wrap="nowrap">
<Text className={classes.kicker} fw={700} size="xs">
{t(accountTypeKey(account.type))} {t(accountTypeKey(account.type))}
</Text> </Badge>
<WalletIcon className={classes.accountIcon} />
</Group> </Group>
<Text className={classes.name} component="h2" fw={700}>
{account.name}
</Text>
</Stack>
<Stack className={classes.balance} gap="xs">
<Text c="dimmed" size="sm">
{t("accounts.currentValue")}
</Text>
<BrokerageAccountSummary summary={summary} /> <BrokerageAccountSummary summary={summary} />
<BrokerageAccountSourceStatus source={source} /> <Stack className={classes.details} gap={2}>
</Stack> {positionCounts && (
<Text c="dimmed" size="xs">
{t("accounts.positions", {
count: positionCounts.total,
})}
</Text>
)}
{!!positionCounts && ( <BrokerageAccountSourceStatus source={source} />
<dl className={classes.positions}> </Stack>
<div className={classes.stocks}>
<dt>{t("accounts.stocks")}</dt>
<dd> {summary.state === "available" && (
{new Intl.NumberFormat("ru-RU").format(positionCounts.stocks)} <Stack className={classes.structure} gap={4}>
</dd> {summary.portfolio.structure.currencies.map(item => (
</div> <StructureBar item={item} key={item.currency} />
))}
<div className={classes.bonds}> </Stack>
<dt>{t("accounts.bonds")}</dt>
<dd>
{new Intl.NumberFormat("ru-RU").format(positionCounts.bonds)}
</dd>
</div>
</dl>
)}
<Group className={classes.footer} gap="sm" justify="space-between">
{!!account.openedOn && (
<Text c="dimmed" size="xs">
{t("accounts.openedOn", {
date: formatDate(account.openedOn),
})}
</Text>
)} )}
<Text className={classes.open} component="span" fw={600} size="sm"> <AccountDescription
{t("accounts.openAccount")} account={{ account, positionCounts, source, summary }}
/>
<span aria-hidden="true" className={classes.arrow}> </Link>
→ </li>
</span>
</Text>
</Group>
</Card>
))} ))}
</SimpleGrid> </ul>
); );
}; };
+2 -1
View File
@@ -2,8 +2,9 @@ export {
type BrokerageEvent, type BrokerageEvent,
useBrokerageEvents, useBrokerageEvents,
} from "./api/useBrokerageEvents"; } from "./api/useBrokerageEvents";
export { brokerageEventKey } from "./model/event-key"; export { brokerageEventGroupKey, 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,
@@ -0,0 +1,57 @@
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,5 +1,13 @@
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,
@@ -7,4 +15,4 @@ function brokerageEventKey(event: BrokerageEvent) {
]); ]);
} }
export { brokerageEventKey }; export { brokerageEventGroupKey, brokerageEventKey };
@@ -0,0 +1,122 @@
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("shows separate forecast totals per currency without conversion", () => {
renderGroup([
event({ id: "broker", name: "Брокерский" }, "10", "USD"),
event({ id: "iis", name: "ИИС" }, "200"),
]);
expect(screen.getByRole("list", { name: /10 USD.*200 ₽/ }))
.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);
});
});
@@ -0,0 +1,87 @@
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,13 +5,18 @@ 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 { brokerageEventKey } from "../../model/event-key"; import { brokerageEventGroupKey, 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;
}; };
type BrokerageEventsPreviewState type BrokerageEventsPreviewState
@@ -19,6 +24,7 @@ type BrokerageEventsPreviewState
items: BrokerageEvent[]; items: BrokerageEvent[];
state: "loaded" | "partial"; state: "loaded" | "partial";
to: string; to: string;
totalVisible?: boolean;
} }
| { state: "empty" | { state: "empty"
| "error" | "error"
@@ -42,16 +48,35 @@ 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") && (
&& events.items.map(event => ( <Stack
<BrokerageEventCard component="ul"
event={event} gap="sm"
key={brokerageEventKey(event)} m={0}
/> 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">
{t("events.open")} {props.moreLabel ?? t("events.open")}
</Button> </Button>
</Stack> </Stack>
); );
+21 -1
View File
@@ -3,8 +3,9 @@ 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 } from "vitest"; import { describe, expect, it, onTestFinished, vi } 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";
@@ -398,4 +399,23 @@ 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();
});
}); });
+2 -1
View File
@@ -161,7 +161,8 @@ 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 } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { resource } from "@/test/resource"; import { resource } from "@/test/resource";
@@ -76,4 +76,29 @@ 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,10 +1,11 @@
import type { InvestmentResourceMeta } from "@/shared/api"; import { type InvestmentResourceMeta, readRetry } 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) {
@@ -13,8 +14,11 @@ 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);
@@ -24,6 +28,17 @@ 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,
}; };
} }
@@ -12,6 +12,8 @@ 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";
@@ -81,6 +83,8 @@ const InstrumentSection: FC<InstrumentSectionProps> = (props) => {
</Alert> </Alert>
)} )}
{!!props.state.readRetry && <ReadRetry {...props.state.readRetry} />}
{sectionContent} {sectionContent}
</Paper> </Paper>
); );
@@ -0,0 +1,155 @@
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({
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",
},
],
incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }],
});
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;
server.use(
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.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(requests).toBe(4);
isFailing = false;
fireEvent.click(retry);
await waitFor(() =>
expect(screen.queryByText("Не удалось обновить данные"))
.not.toBeInTheDocument());
expect(requests).toBe(5);
},
);
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();
});
});
@@ -169,15 +169,18 @@ describe("AccountEventsPage", () => {
expect(screen.getAllByText("O")).toHaveLength(2); expect(screen.getAllByText("O")).toHaveLength(2);
}); });
it("renders empty and quiet error states", async () => { it("renders empty and error states with retry", 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>
@@ -187,7 +190,7 @@ describe("AccountEventsPage", () => {
</MantineProvider>, </MantineProvider>,
); );
expect(await screen.findByRole("button", { name: "Сбросить" })).toBeVisible(); expect(await screen.findByRole("button", { name: "Сбросить" })).toBeVisible();
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "Повторить" })).toBeVisible();
}); });
it("shows a structural busy state before events are available", () => { it("shows a structural busy state before events are available", () => {
@@ -237,4 +240,79 @@ describe("AccountEventsPage", () => {
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],
},
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,13 +1,12 @@
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,
@@ -18,6 +17,7 @@ import {
type AccountEventsPageViewModel type AccountEventsPageViewModel
= | { = | {
allAccounts: boolean;
canApply: boolean; canApply: boolean;
draftPeriod: [null | string, null | string]; draftPeriod: [null | string, null | string];
events: BrokerageEvent[]; events: BrokerageEvent[];
@@ -27,10 +27,16 @@ 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;
}
| {
onReset: () => void;
readRetry: ReturnType<typeof readRetry>;
state: "error";
} }
| { onReset: () => void; state: "error" }
| { state: "loading" }; | { state: "loading" };
function useAccountEventsPageViewModel(): AccountEventsPageViewModel { function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
@@ -81,6 +87,8 @@ function useAccountEventsPageViewModel(): 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" };
@@ -92,14 +100,16 @@ function useAccountEventsPageViewModel(): 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, state: "error" } ? { onReset, readRetry: retry, state: "error" }
: { state: "loading" }; : { state: "loading" };
return { return {
allAccounts: accountId === "all",
canApply: canApply:
(draftFrom !== appliedFrom || draftTo !== appliedTo) (draftFrom !== appliedFrom || draftTo !== appliedTo)
&& isValidPeriod(draftFrom, draftTo), && isValidPeriod(draftFrom, draftTo),
@@ -119,8 +129,10 @@ function useAccountEventsPageViewModel(): 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",
}; };
} }
@@ -4,7 +4,12 @@ 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";
type AccountEventsPageErrorProps = { onReset: () => void }; import { ReadRetryButton, type ReadRetryProps } from "@/shared/ui";
type AccountEventsPageErrorProps = {
onReset: () => void;
readRetry: ReadRetryProps;
};
const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => { const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -13,6 +18,13 @@ 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,9 +13,12 @@ import { useTranslation } from "react-i18next";
import { import {
BrokerageEventCard, BrokerageEventCard,
BrokerageEventGroup,
brokerageEventGroupKey,
brokerageEventKey, brokerageEventKey,
} from "@/entities/brokerage-event"; } from "@/entities/brokerage-event";
import { SourceSyncStatus } from "@/shared/ui"; import { groupItems } from "@/shared/lib";
import { ReadRetry, SourceSyncStatus } from "@/shared/ui";
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm"; import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
@@ -30,14 +33,32 @@ 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 gap="sm"> <Stack
{props.events.map(event => ( component="ul"
<BrokerageEventCard gap="sm"
event={event} m={0}
key={brokerageEventKey(event)} p={0}
/> 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>
); );
} }
@@ -62,6 +83,8 @@ 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>
@@ -26,6 +26,7 @@ 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";
@@ -37,6 +38,10 @@ import {
} 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",
@@ -100,7 +105,10 @@ function money(currency: string, value: string) {
return { currency, value }; return { currency, value };
} }
function renderPage(search = "from=2026-08-01&to=2026-08-27") { function renderPage(
search = "from=2026-08-01&to=2026-08-27",
accountId = "account-1",
) {
configureAuthClient({ configureAuthClient({
accessToken: () => "token", accessToken: () => "token",
clear: vi.fn(), clear: vi.fn(),
@@ -113,7 +121,7 @@ function renderPage(search = "from=2026-08-01&to=2026-08-27") {
<DatesProvider settings={{ locale: "ru" }}> <DatesProvider settings={{ locale: "ru" }}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<MemoryRouter <MemoryRouter
initialEntries={[`/accounts/account-1/income?${search}`]} initialEntries={[`/accounts/${accountId}/income?${search}`]}
> >
<LocationProbe /> <LocationProbe />
@@ -400,21 +408,124 @@ describe("AccountIncomePage", () => {
expect(screen.queryByText(/Сравнение с/)).not.toBeInTheDocument(); expect(screen.queryByText(/Сравнение с/)).not.toBeInTheDocument();
}); });
it("shows a quiet realized error", async () => { it.each([
["full", analytics],
["empty", { ...analytics, buckets: [], entries: [], instruments: [], totalsByCurrency: [] }],
])("shows the server source time and last attempt for a %s result", async (_name, data) => {
server.use( server.use(
http.get( http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
"*/api/v1/brokerage-accounts/account-1/income-analytics", HttpResponse.json(
() => new HttpResponse(null, { status: 503 }), resource(data, {
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( expect(await screen.findByText(/^Данные на /)).toHaveTextContent(
await screen.findByText("Не удалось рассчитать пассивный доход", undefined, { formatSnapshotTime("2026-08-26T12:00:00Z"),
timeout: 4000, );
}), expect(screen.getByText("Обновление не удалось")).toBeInTheDocument();
).toBeInTheDocument(); expect(screen.getByText("Данные требуют обновления")).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 = () => {
@@ -680,4 +791,110 @@ describe("income period and snapshot boundary", () => {
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();
});
}); });
@@ -5,6 +5,7 @@ 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 {
@@ -30,10 +31,14 @@ 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" };
@@ -83,12 +88,14 @@ const useAccountIncomePageViewModel = (): 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, state: "error" } ? { period, readRetry: retry, state: "error" }
: { state: "loading" }; : { state: "loading" };
const display = toIncomeDisplayModel(analytics.data); const display = toIncomeDisplayModel(analytics.data);
@@ -115,7 +122,10 @@ const useAccountIncomePageViewModel = (): 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",
}; };
}; };
@@ -3,15 +3,27 @@ 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> = () => { const AccountIncomePageError: FC<ErrorProps> = (props) => {
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")}
@@ -9,6 +9,8 @@ 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";
@@ -49,11 +51,23 @@ 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 entries={display.entries} /> <IncomePayments
allAccounts={props.analytics.accountId === "all"}
entries={display.entries}
totalVisible={props.totalVisible}
/>
</Stack> </Stack>
); );
}; };
@@ -3,13 +3,22 @@ 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 { LedgerEntryRow } from "@/entities/broker-operation"; import {
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 = { entries: IncomeDisplayModel["entries"] }; type IncomePaymentsProps = {
allAccounts?: boolean;
entries: IncomeDisplayModel["entries"];
totalVisible?: boolean;
};
const IncomePayments: FC<IncomePaymentsProps> = (props) => { const IncomePayments: FC<IncomePaymentsProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -19,11 +28,24 @@ 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">
{props.entries.map(({ key, operation }) => ( {groupItems(
<li key={key}> props.entries,
<LedgerEntryRow operation={operation} wrap /> entry => props.allAccounts
</li> ? 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>
))}
</Stack> </Stack>
</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 } from "@testing-library/react"; import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { http, HttpResponse } from "msw"; import { http, HttpResponse } from "msw";
import { import {
MemoryRouter, MemoryRouter,
@@ -44,6 +44,25 @@ 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();
@@ -71,12 +90,16 @@ function configureTestAuth() {
}); });
} }
function renderPage(search = "") { function renderPage(
search = "",
accountId = "account-1",
queryClient = createTestQueryClient(),
) {
return render( return render(
<QueryClientProvider client={createTestQueryClient()}> <QueryClientProvider client={queryClient}>
<MantineProvider> <MantineProvider>
<MemoryRouter <MemoryRouter
initialEntries={[`/accounts/account-1/operations${search}`]} initialEntries={[`/accounts/${accountId}/operations${search}`]}
> >
<Routes> <Routes>
<Route <Route
@@ -90,6 +113,18 @@ function renderPage(search = "") {
); );
} }
// 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(
@@ -217,6 +252,27 @@ 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",
@@ -478,21 +534,115 @@ describe("AccountOperationsPage", () => {
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
}); });
it("renders an error state", async () => { it("does not show the history loading alert when coverage is partial", async () => {
configureTestAuth(); configureTestAuth();
server.use( server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => http.get(
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }), "*/api/v1/brokerage-accounts/:accountId/operations",
() => 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("Не удалось загрузить журнал", undefined, { await screen.findByText("История догружается"),
timeout: 4000,
}),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
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();
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(
@@ -602,3 +752,73 @@ 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();
});
});
@@ -12,7 +12,12 @@ import {
useBrokerageOperationHistory, useBrokerageOperationHistory,
} from "@/entities/broker-operation"; } from "@/entities/broker-operation";
import { useScreenSync } from "@/features/tbank-connection"; import { useScreenSync } from "@/features/tbank-connection";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; import {
ApiErrorCode,
apiErrorCode,
isApiErrorCode,
readRetry,
} from "@/shared/api";
import { import {
defaultPeriod, defaultPeriod,
isValidPeriod, isValidPeriod,
@@ -25,6 +30,7 @@ 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];
@@ -41,10 +47,16 @@ 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 => {
@@ -105,6 +117,8 @@ 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" };
@@ -132,11 +146,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, state: "error" }; return { onReset, readRetry: retry, state: "error" };
if (!operations.data) if (!operations.data)
return availability === "unavailable" return availability === "unavailable"
? { onReset, state: "error" } ? { onReset, readRetry: retry, state: "error" }
: { state: "loading" }; : { state: "loading" };
const nextCursor = operations.data.nextCursor; const nextCursor = operations.data.nextCursor;
@@ -147,14 +161,13 @@ 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: historyLoading: operations.meta?.coverage?.historyState === "loading",
operations.meta?.coverage?.historyState === "loading"
|| operations.meta?.coverage?.historyState === "partial",
items: operations.data.items, items: operations.data.items,
maxDate, maxDate,
onApply: () => { onApply: () => {
@@ -181,8 +194,10 @@ 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,9 +1,11 @@
import type { FC } from "react"; import type { FC } from "react";
import { Button } from "@mantine/core"; 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 } from "@/shared/ui";
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm"; import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
type AccountOperationsPageErrorProps = Extract< type AccountOperationsPageErrorProps = Extract<
@@ -19,9 +21,18 @@ const AccountOperationsPageError: FC<AccountOperationsPageErrorProps> = (
return ( return (
<StateView <StateView
action={( action={(
<Button onClick={props.onReset} variant="default"> <Group gap="sm" justify="center">
{t("ledger.reset")} {props.readRetry.exhausted && (
</Button> <ReadRetryButton
onRetry={props.readRetry.onRetry}
retrying={props.readRetry.retrying}
/>
)}
<Button onClick={props.onReset} variant="default">
{t("ledger.reset")}
</Button>
</Group>
)} )}
text={t("ledger.errorText")} text={t("ledger.errorText")}
title={t("ledger.errorTitle")} title={t("ledger.errorTitle")}
@@ -12,8 +12,14 @@ 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 { LedgerEntryRow, operationCategories } from "@/entities/broker-operation"; import {
import { SourceSyncStatus } from "@/shared/ui"; brokerageOperationGroupKey,
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";
@@ -24,22 +30,49 @@ type AccountOperationsPageLoadedProps = Extract<
const AccountOperationsPageLoadedOperations: FC< const AccountOperationsPageLoadedOperations: FC<
AccountOperationsPageLoadedProps AccountOperationsPageLoadedProps
> = (props) => { // eslint-disable-line sonarjs/function-return-type -- React renders elements and lists. > = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { items } = props; const { items } = props;
if (items.length > 0) { if (items.length > 0) {
return items.map(operation => ( const entries = groupItems(
<LedgerEntryRow items.map(operation => ({
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) {
@@ -69,6 +102,8 @@ 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>}
@@ -0,0 +1,175 @@
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),
stock("F", null, "USD"), stock("G", "-3", "USD"),
]), "stocks", "RUB", labels);
expect(model.currencies).toEqual(["RUB", "USD"]);
expect(model.total).toBe("16");
expect(model.items.map(item => item.share)).toEqual(["93,8 %", "6,3 %"]);
expect(model.unaccounted).toBe(3);
const negative = snapshot([stock("A", "-3", "USD")]);
expect(toConcentration(negative, "stocks", "USD", 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", "RUB", 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", "RUB", 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", "RUB", 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", "RUB", labels)
.items.map(item => item.amount)).toEqual(["500", "100", "50"]);
expect(toConcentration(snapshot(positions), "issuers", "RUB", 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", "RUB", 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", "RUB", 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", "RUB", 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", "RUB", labels)
.items[0]?.logo?.subtitle).toBe("RU000A0JX0J2");
});
});
+300
View File
@@ -0,0 +1,300 @@
import type { Portfolio } from "@/entities/portfolio";
import {
type Decimal,
decimalFromString,
decimalToString,
instrumentLogoUrl,
sumDecimals,
} from "@/shared/lib";
type Concentration = {
currencies: string[];
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,
selected: null | string,
): number {
if (!isIncluded(slice, position.type)) return 0;
if (!position.currentValue) return 1;
if (position.currentValue.currency !== selected) return 0;
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 concentrationCurrencies(portfolio: Portfolio): string[] {
return [...new Set(portfolio.positions
.filter(position => eligibleTypes.has(position.type))
.filter(position => position.currentValue
&& decimalFromString(position.currentValue.value).value !== 0n)
.map(position => position.currentValue?.currency)
.filter((value): value is string => !!value))]
.toSorted((a, b) => a.localeCompare(b));
}
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,
currency: null | string,
labels: ConcentrationLabels,
): Concentration {
const currencies = concentrationCurrencies(portfolio);
const selected = currency && currencies.includes(currency)
? currency
: currencies[0] ?? null;
const groups = new Map<string, Group>();
let unaccounted = 0;
for (const [index, position] of portfolio.positions.entries()) {
unaccounted += addPosition(
groups, position, index, slice, labels, selected,
);
}
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 {
currencies,
empty: !total,
items: other ? entries.slice(0, TOP) : entries,
other,
top5Share: top5 && total ? share(top5, total) : null,
total: total && decimalToString(total),
unaccounted,
};
}
export {
concentrationCurrencies,
toConcentration,
};
export type {
Concentration,
ConcentrationItem,
ConcentrationLabels,
Slice,
};
@@ -13,8 +13,10 @@ 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",
}, },
@@ -25,8 +27,10 @@ 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",
}, },
@@ -37,8 +41,10 @@ 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",
}, },
+123 -1
View File
@@ -2,9 +2,19 @@ import { describe, expect, it } from "vitest";
import type { Portfolio } from "@/entities/portfolio"; import type { Portfolio } from "@/entities/portfolio";
import { toAccountPagePositionSections } from "./positions"; import {
formatPositionReturnPercent,
toAccountPagePositionSections,
toUnrealizedReturns,
} from "./positions";
import { portfolio, position, second } from "./positions.fixtures"; import { portfolio, position, second } from "./positions.fixtures";
function makePosition(
overrides: Partial<Portfolio["positions"][number]>,
): Portfolio["positions"][number] {
return { ...position, ...overrides };
}
function sections(positions: Portfolio["positions"]) { function sections(positions: Portfolio["positions"]) {
return toAccountPagePositionSections({ return toAccountPagePositionSections({
...portfolio, ...portfolio,
@@ -82,6 +92,8 @@ describe("account position sections", () => {
{ {
displayName: "Акция", displayName: "Акция",
logoSrc: "https://invest-brands.cdn-tinkoff.ru/akciyax160.png", logoSrc: "https://invest-brands.cdn-tinkoff.ru/akciyax160.png",
returnPercent: null,
returnPercentText: null,
returnText: "+10 ₽", returnText: "+10 ₽",
returnTone: "positive", returnTone: "positive",
shareText: "66,7 %", shareText: "66,7 %",
@@ -95,6 +107,8 @@ describe("account position sections", () => {
{ {
displayName: "Облигация", displayName: "Облигация",
logoSrc: null, logoSrc: null,
returnPercent: "-11.111111",
returnPercentText: "-11,11 %",
returnText: "-5 ₽", returnText: "-5 ₽",
returnTone: "negative", returnTone: "negative",
shareText: "33,3 %", shareText: "33,3 %",
@@ -107,6 +121,8 @@ describe("account position sections", () => {
{ {
displayName: "Фонд", displayName: "Фонд",
logoSrc: null, logoSrc: null,
returnPercent: null,
returnPercentText: null,
returnText: null, returnText: null,
returnTone: undefined, returnTone: undefined,
shareText: null, shareText: null,
@@ -187,3 +203,109 @@ describe("account position sections", () => {
expect(source).toEqual(before); expect(source).toEqual(before);
}); });
}); });
describe("account position returns", () => {
it.each([
{
absoluteReturn: { currency: "RUB", value: "25" },
averagePurchasePrice: { currency: "RUB", value: "280" },
expected: "7.142857",
name: "gain",
quantity: "1.25",
type: "STOCK",
},
{
absoluteReturn: { currency: "RUB", value: "-25" },
averagePurchasePrice: { currency: "RUB", value: "280" },
expected: "-7.142857",
name: "loss",
quantity: "1.25",
type: "STOCK",
},
{
absoluteReturn: { currency: "RUB", value: "-5" },
averagePurchasePrice: { currency: "RUB", value: "45" },
expected: "-11.111111",
name: "bond without accrued interest",
quantity: "1",
type: "BOND",
},
] as const)("calculates $name from the clean purchase cost", (testCase) => {
const result = sections([makePosition({
absoluteReturn: testCase.absoluteReturn,
averagePurchasePrice: testCase.averagePurchasePrice,
quantity: testCase.quantity,
type: testCase.type,
})]);
expect(result[0]?.groups[0]?.positions[0].returnPercent)
.toBe(testCase.expected);
});
it.each([
{ name: "zero quantity", overrides: { quantity: "0" } },
{ name: "negative quantity", overrides: { quantity: "-1" } },
{ 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" } } },
])("returns null for $name", ({ overrides }) => {
const result = sections([makePosition({
absoluteReturn: { currency: "RUB", value: "25" },
averagePurchasePrice: { currency: "RUB", value: "280" },
quantity: "1.25",
...overrides,
})]);
expect(result[0]?.groups[0]?.positions[0].returnPercent).toBeNull();
});
it.each([
{ absoluteReturn: "1", expected: "0.000001" },
{ absoluteReturn: "-1", expected: "-0.000001" },
])("rounds an exact half away from zero for $absoluteReturn", (testCase) => {
const result = sections([makePosition({
absoluteReturn: { currency: "RUB", value: testCase.absoluteReturn },
averagePurchasePrice: { currency: "RUB", value: "200000000" },
quantity: "1",
})]);
expect(result[0]?.groups[0]?.positions[0].returnPercent)
.toBe(testCase.expected);
});
it.each([
{ expected: "+1.01", value: "1.005" },
{ expected: "-1.01", value: "-1.005" },
])("formats $value for display with a sign and two decimals", (testCase) => {
expect(formatPositionReturnPercent(testCase.value)).toBe(testCase.expected);
});
it("sums complete unrealized returns by currency", () => {
const result = toUnrealizedReturns([
makePosition({ absoluteReturn: { currency: "RUB", value: "0.1" } }),
makePosition({ absoluteReturn: { currency: "RUB", value: "0.2" } }),
makePosition({ absoluteReturn: { currency: "USD", value: "-2.5" } }),
]);
expect(result).toEqual([
{ currency: "RUB", value: "0.3" },
{ currency: "USD", value: "-2.5" },
]);
});
it("omits only currencies with incomplete returns", () => {
const result = toUnrealizedReturns([
makePosition({ absoluteReturn: { currency: "RUB", value: "10" } }),
makePosition({
absoluteReturn: null,
currentValue: { currency: "RUB", value: "100" },
}),
makePosition({ absoluteReturn: { currency: "USD", value: "2" } }),
]);
expect(result).toEqual([{ currency: "USD", value: "2" }]);
});
});
+123 -1
View File
@@ -1,9 +1,19 @@
import { formatShare, type Portfolio } from "@/entities/portfolio"; import { formatShare, type Portfolio } from "@/entities/portfolio";
import { formatMoney, instrumentLogoUrl } from "@/shared/lib"; import {
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;
logoSrc: null | string; logoSrc: null | string;
returnPercent: null | string;
returnPercentText: null | string;
returnText: null | string; returnText: null | string;
returnTone?: AccountPageReturnTone; returnTone?: AccountPageReturnTone;
shareText: null | string; shareText: null | string;
@@ -21,10 +31,44 @@ type AccountPagePositionSection = {
type AccountPageReturnTone = "negative" | "positive"; type AccountPageReturnTone = "negative" | "positive";
type Money = NonNullable<Portfolio["positions"][number]["absoluteReturn"]>;
type PositionSectionKind = "bonds" type PositionSectionKind = "bonds"
| "other" | "other"
| "stocks"; | "stocks";
const RADIX = 10n;
const PERCENT_SCALE = 6;
const DISPLAY_PERCENT_SCALE = 2;
const PERCENT_FACTOR = 100n;
const ROUNDING_FACTOR = 2n;
function formatPositionReturnPercent(value: string): string {
const decimal = decimalFromString(value);
let rounded = decimal.value;
if (decimal.scale > DISPLAY_PERCENT_SCALE) {
const divisor = RADIX ** BigInt(decimal.scale - DISPLAY_PERCENT_SCALE);
const remainder = rounded % divisor;
const absoluteRemainder = remainder < 0n ? -remainder : remainder;
rounded /= divisor;
if (absoluteRemainder * ROUNDING_FACTOR >= divisor) {
rounded += decimal.value < 0n ? -1n : 1n;
}
}
else {
rounded *= RADIX ** BigInt(DISPLAY_PERCENT_SCALE - decimal.scale);
}
const sign = rounded > 0n ? "+" : "";
return `${sign}${decimalToString({
scale: DISPLAY_PERCENT_SCALE,
value: rounded,
})}`;
}
function groupPositions( function groupPositions(
positions: AccountPagePosition[], positions: AccountPagePosition[],
): AccountPagePositionGroup[] { ): AccountPagePositionGroup[] {
@@ -67,6 +111,41 @@ function positionIdentity(position: AccountPagePosition) {
return position.isin?.trim() ? ["isin", position.isin.trim()] : undefined; return position.isin?.trim() ? ["isin", position.isin.trim()] : undefined;
} }
function returnPercent(
position: Portfolio["positions"][number],
): null | string {
const result = position.absoluteReturn;
const price = position.averagePurchasePrice;
if (!result || !price || result.currency !== price.currency) return null;
const quantity = decimalFromString(position.quantity);
const purchasePrice = decimalFromString(price.value);
const cost = {
scale: quantity.scale + purchasePrice.scale,
value: quantity.value * purchasePrice.value,
};
if (quantity.value <= 0n || cost.value <= 0n) return null;
const absoluteReturn = decimalFromString(result.value);
const numerator = absoluteReturn.value * PERCENT_FACTOR
* RADIX ** BigInt(cost.scale + PERCENT_SCALE);
const denominator = cost.value * RADIX ** BigInt(absoluteReturn.scale);
const remainder = numerator % denominator;
const isRoundsUp = (remainder < 0n ? -remainder : remainder) * ROUNDING_FACTOR
>= denominator;
const rounding = isRoundsUp ? 1n : 0n;
const signedRounding = numerator < 0n ? -rounding : rounding;
const rounded = numerator / denominator + signedRounding;
return decimalToString({ scale: PERCENT_SCALE, value: rounded });
}
function toAccountPagePositionSections( function toAccountPagePositionSections(
portfolio: Portfolio, portfolio: Portfolio,
): AccountPagePositionSection[] { ): AccountPagePositionSection[] {
@@ -111,6 +190,8 @@ function toPosition(
const returnPrefix const returnPrefix
= isExpanded && returnValue !== null && returnValue > 0 ? "+" : ""; = isExpanded && returnValue !== null && returnValue > 0 ? "+" : "";
const percent = returnPercent(position);
return { return {
...position, ...position,
instrumentTo: instrumentTo:
@@ -120,6 +201,10 @@ function toPosition(
logoSrc: isExpanded logoSrc: isExpanded
? instrumentLogoUrl(position.logoName) ? instrumentLogoUrl(position.logoName)
: null, : null,
returnPercent: percent,
returnPercentText: percent
? `${formatDecimal(formatPositionReturnPercent(percent))} %`
: null,
returnText: position.absoluteReturn returnText: position.absoluteReturn
? `${returnPrefix}${formatMoney(position.absoluteReturn)}` ? `${returnPrefix}${formatMoney(position.absoluteReturn)}`
: null, : null,
@@ -162,9 +247,46 @@ function toSection(
}; };
} }
function toUnrealizedReturns(
positions: Portfolio["positions"],
): Money[] {
const totals = new Map<string, {
complete: boolean;
values: Decimal[];
}>();
for (const position of positions) {
const currencies = position.absoluteReturn
? [position.absoluteReturn.currency]
: [...new Set([
position.averagePurchasePrice?.currency,
position.currentValue?.currency,
].filter((currency): currency is string => !!currency))];
for (const currency of currencies) {
const total = totals.get(currency) ?? { complete: true, values: [] };
if (position.absoluteReturn) {
total.values.push(decimalFromString(position.absoluteReturn.value));
}
else {
total.complete = false;
}
totals.set(currency, total);
}
}
return [...totals].flatMap(([currency, total]) => total.complete
? [{ currency, value: decimalToString(sumDecimals(total.values)) }]
: []);
}
export { export {
type AccountPagePosition, type AccountPagePosition,
type AccountPagePositionGroup, type AccountPagePositionGroup,
type AccountPagePositionSection, type AccountPagePositionSection,
formatPositionReturnPercent,
toAccountPagePositionSections, toAccountPagePositionSections,
toUnrealizedReturns,
}; };
+10 -1
View File
@@ -33,6 +33,7 @@ type AccountPageRecentOperations
stale?: boolean; stale?: boolean;
state: "loaded" | "partial"; state: "loaded" | "partial";
to: string; to: string;
totalVisible?: boolean;
} }
| { | {
state: "empty" state: "empty"
@@ -84,6 +85,7 @@ 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 };
@@ -94,6 +96,7 @@ function toRecentEvents(
items, items,
state: "loaded", state: "loaded",
to, to,
totalVisible: events.meta?.completeness === "complete",
}; };
} }
@@ -109,7 +112,12 @@ function toRecentOperations(
) )
return { state: "loading", to }; return { state: "loading", to };
if (operations.meta?.completeness === "incomplete") if (operations.meta?.completeness === "incomplete")
return { items: operations.data?.items ?? [], state: "partial", to }; return {
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")
@@ -125,6 +133,7 @@ function toRecentOperations(
stale: operations.meta?.freshness === "stale", stale: operations.meta?.freshness === "stale",
state: "loaded", state: "loaded",
to, to,
totalVisible: operations.meta?.completeness === "complete",
}; };
} }
@@ -98,21 +98,13 @@
font-family: var(--mantine-font-family-monospace); font-family: var(--mantine-font-family-monospace);
} }
.accordionControl { .tabsList {
transition: flex-wrap: nowrap;
background-color 150ms ease, overflow-x: auto;
color 150ms ease;
} }
.accordionLabel { .positionFilter {
font-size: var(--mantine-h2-font-size); overflow-x: auto;
font-weight: var(--mantine-h2-font-weight);
line-height: var(--mantine-h2-line-height);
}
.accordionPanel {
padding-left: 0;
padding-right: 0;
} }
.accountPositions { .accountPositions {
File diff suppressed because it is too large Load Diff
@@ -16,8 +16,10 @@ const AccountPage: FC = () => {
account={viewModel.account} account={viewModel.account}
accountId={viewModel.accountId} accountId={viewModel.accountId}
incomeSummary={viewModel.incomeSummary} incomeSummary={viewModel.incomeSummary}
readRetry={viewModel.readRetry}
recentEvents={viewModel.recentEvents} recentEvents={viewModel.recentEvents}
recentOperations={viewModel.recentOperations} recentOperations={viewModel.recentOperations}
source={viewModel.source}
/> />
); );
@@ -25,12 +27,22 @@ const AccountPage: FC = () => {
<AccountPageLoaded <AccountPageLoaded
account={viewModel.account} account={viewModel.account}
accountId={viewModel.accountId} accountId={viewModel.accountId}
concentration={viewModel.concentration}
currency={viewModel.currency}
incomeSummary={viewModel.incomeSummary} incomeSummary={viewModel.incomeSummary}
onCurrencyChange={viewModel.onCurrencyChange}
onSliceChange={viewModel.onSliceChange}
onTabChange={viewModel.onTabChange}
partial={viewModel.partial} partial={viewModel.partial}
portfolio={viewModel.portfolio} portfolio={viewModel.portfolio}
positionSections={viewModel.positionSections} positionSections={viewModel.positionSections}
readRetry={viewModel.readRetry}
recentEvents={viewModel.recentEvents} recentEvents={viewModel.recentEvents}
recentOperations={viewModel.recentOperations} recentOperations={viewModel.recentOperations}
slice={viewModel.slice}
source={viewModel.source}
tab={viewModel.tab}
unrealizedReturns={viewModel.unrealizedReturns}
/> />
); );
}; };
@@ -1,3 +1,5 @@
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";
@@ -14,12 +16,20 @@ 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, apiErrorCode, isApiErrorCode } from "@/shared/api"; import { apiErrorCode, isBrokerageAccessLoss, readRetry } from "@/shared/api";
import { useInitialAvailability } from "@/shared/lib"; import { useInitialAvailability } from "@/shared/lib";
import {
type Concentration,
concentrationCurrencies,
type ConcentrationLabels,
type Slice,
toConcentration,
} from "../../model/concentration";
import { import {
type AccountPagePositionSection, type AccountPagePositionSection,
toAccountPagePositionSections, toAccountPagePositionSections,
toUnrealizedReturns,
} from "../../model/positions"; } from "../../model/positions";
import { import {
type AccountPageIncomeSummary, type AccountPageIncomeSummary,
@@ -35,8 +45,10 @@ 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 & {
@@ -44,27 +56,47 @@ type AccountPageEmptyViewModel = AccountPageContent & {
}; };
type AccountPageLoadedViewModel = AccountPageContent & { type AccountPageLoadedViewModel = AccountPageContent & {
concentration: Concentration | null;
currency: null | string;
onCurrencyChange: (currency: null | string) => void;
onSliceChange: (slice: Slice) => void;
onTabChange: (tab: null | string) => void;
portfolio: Portfolio; portfolio: Portfolio;
positionSections: AccountPagePositionSection[]; positionSections: AccountPagePositionSection[];
source?: InvestmentResourceMeta; slice: Slice;
state: "loaded"; state: "loaded";
tab: string;
unrealizedReturns: Portfolio["totalsByCurrency"];
}; };
type AccountPageReadRetry = ReturnType<typeof readRetry>;
type AccountPageViewModel type AccountPageViewModel
= | AccountPageEmptyViewModel = | AccountPageEmptyViewModel
| AccountPageLoadedViewModel | AccountPageLoadedViewModel
| { code?: string; state: "error" } | { code?: string; readRetry: AccountPageReadRetry; state: "error" }
| { state: "loading" }; | { state: "loading" };
function isPortfolioAccessFailure(code?: string) {
return isApiErrorCode(code, ApiErrorCode.Forbidden)
|| isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)
|| isApiErrorCode(code, ApiErrorCode.NotFound)
|| isApiErrorCode(code, ApiErrorCode.TbankNotConnected);
}
function useAccountPageViewModel(): AccountPageViewModel { function useAccountPageViewModel(): AccountPageViewModel {
const { accountId } = useParams(); const { accountId } = useParams();
const { t } = useTranslation();
const [selection, setSelection] = useState<{
accountId?: string;
currency: null | string;
slice: Slice;
tab: string;
}>({ accountId, currency: null, slice: "stocks", tab: "positions" });
const current = selection.accountId === accountId
? selection
: {
accountId, currency: null, 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, {
@@ -84,7 +116,9 @@ function useAccountPageViewModel(): AccountPageViewModel {
]); ]);
const code = apiErrorCode(portfolio.error); const code = apiErrorCode(portfolio.error);
const isAccessFailure = isPortfolioAccessFailure(code); // The portfolio drives this screen; the retry repeats only its GET.
const retry = readRetry([portfolio]);
const isAccessFailure = isBrokerageAccessLoss(code);
const availability = useInitialAvailability({ const availability = useInitialAvailability({
hasContent: !!portfolio.data, hasContent: !!portfolio.data,
@@ -93,12 +127,13 @@ function useAccountPageViewModel(): AccountPageViewModel {
isFetching: portfolio.isFetching, isFetching: portfolio.isFetching,
}); });
if (isAccessFailure) return { code, state: "error" as const }; if (isAccessFailure)
return { code, readRetry: retry, state: "error" as const };
if (!portfolio.data) if (!portfolio.data)
return { return availability === "unavailable"
state: availability === "unavailable" ? "error" : "loading", ? { code, readRetry: retry, state: "error" as const }
} as const; : { state: "loading" 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);
@@ -115,24 +150,52 @@ function useAccountPageViewModel(): AccountPageViewModel {
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 currencies = concentrationCurrencies(portfolio.data);
const currency = current.currency && currencies.includes(current.currency)
? current.currency
: currencies[0] ?? null;
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, currency, labels)
: null;
return { return {
account, account,
accountId, accountId,
concentration,
currency,
incomeSummary, incomeSummary,
onCurrencyChange: value => updateSelection({ currency: value }),
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),
}; };
} }
@@ -5,8 +5,10 @@ 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 { PortfolioStructure } from "@/widgets/portfolio-structure";
import type { import type {
@@ -23,8 +25,10 @@ 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) => {
@@ -40,15 +44,29 @@ 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={{ currencies: [], unvaluedCategories: [] }} structure={{ currencies: [], unvaluedCategories: [] }}
/> />
<RecentOperations operations={props.recentOperations} /> <RecentOperations
allAccounts={props.accountId === "all"}
operations={props.recentOperations}
/>
<BrokerageEventsPreview events={props.recentEvents} /> <BrokerageEventsPreview
allAccounts={props.accountId === "all"}
events={props.recentEvents}
/>
{!!props.incomeSummary && <IncomeSummary {...props.incomeSummary} />} {!!props.incomeSummary && <IncomeSummary {...props.incomeSummary} />}
</Stack> </Stack>
@@ -6,8 +6,12 @@ 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 = { code?: string }; type AccountPageErrorProps = {
code?: string;
readRetry?: ReadRetryProps;
};
const AccountPageError: FC<AccountPageErrorProps> = (props) => { const AccountPageError: FC<AccountPageErrorProps> = (props) => {
const { code } = props; const { code } = props;
@@ -43,8 +47,18 @@ 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")}
/> />
@@ -1,6 +1,12 @@
import type { FC } from "react"; import {
Alert,
import { Accordion, Alert, Stack } from "@mantine/core"; Group,
SegmentedControl,
Stack,
Tabs,
Text,
} from "@mantine/core";
import { type FC, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { BrokerageAccount } from "@/entities/brokerage-account"; import type { BrokerageAccount } from "@/entities/brokerage-account";
@@ -8,9 +14,11 @@ import type { Portfolio } from "@/entities/portfolio";
import type { InvestmentResourceMeta } from "@/shared/api"; import type { InvestmentResourceMeta } from "@/shared/api";
import { BrokerageEventsPreview } from "@/entities/brokerage-event"; import { BrokerageEventsPreview } from "@/entities/brokerage-event";
import { SourceSyncStatus } from "@/shared/ui"; import { formatMoney } from "@/shared/lib";
import { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui";
import { PortfolioStructure } from "@/widgets/portfolio-structure"; 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,
@@ -20,6 +28,7 @@ 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 { CurrencyCards } from "./CurrencyCards/CurrencyCards.view";
import { IncomeSummary } from "./IncomeSummary.view"; import { IncomeSummary } from "./IncomeSummary.view";
import { PositionSection } from "./PositionSection.view"; import { PositionSection } from "./PositionSection.view";
@@ -28,25 +37,45 @@ import { RecentOperations } from "./RecentOperations.view";
type AccountPageLoadedProps = { type AccountPageLoadedProps = {
account?: BrokerageAccount; account?: BrokerageAccount;
accountId?: string; accountId?: string;
concentration: ConcentrationModel | null;
currency: null | string;
incomeSummary?: AccountPageIncomeSummary; incomeSummary?: AccountPageIncomeSummary;
onCurrencyChange: (currency: null | string) => void;
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"];
}; };
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => { const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [filter, setFilter] = useState<string>();
const tab = props.tab;
const defaultValue = [ const onTabChange = props.onTabChange;
...props.positionSections.map(section => section.kind),
"cash", const activeSection = props.positionSections.find(
"recentOperations", section => section.kind === filter,
"recentEvents", ) ?? props.positionSections[0];
"incomeSummary",
]; const positionKindLabels: Record<AccountPagePositionSection["kind"], string> = {
bonds: t("portfolio.positionKinds.bonds"),
other: t("portfolio.positionKinds.other"),
stocks: t("portfolio.positionKinds.stocks"),
};
const filterOptions = props.positionSections.map(section => ({
label: positionKindLabels[section.kind],
value: section.kind,
}));
return ( return (
<Stack gap="lg"> <Stack gap="lg">
@@ -58,10 +87,34 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
stale={props.source?.freshness === "stale"} stale={props.source?.freshness === "stale"}
/> />
{!!props.readRetry && <ReadRetry {...props.readRetry} />}
{props.partial {props.partial
? <Alert role="status">{t("investment.partial")}</Alert> ? tab !== "concentration" && (
<Alert role="status">{t("investment.partial")}</Alert>
)
: <CurrencyCards amounts={props.portfolio.totalsByCurrency} />} : <CurrencyCards amounts={props.portfolio.totalsByCurrency} />}
{!props.partial && props.unrealizedReturns.length > 0 && (
<Stack
aria-label={t("portfolio.unrealizedResult")}
component="section"
gap={4}
>
<Text fw={600}>{t("portfolio.unrealizedResult")}</Text>
<Group gap="md">
{props.unrealizedReturns.map(total => (
<Text key={total.currency}>{formatMoney(total)}</Text>
))}
</Group>
<Text c="dimmed" size="xs">
{t("portfolio.unrealizedResultCaption")}
</Text>
</Stack>
)}
{!props.partial && ( {!props.partial && (
<PortfolioStructure <PortfolioStructure
key={props.accountId} key={props.accountId}
@@ -69,70 +122,98 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
/> />
)} )}
<Accordion <Tabs
classNames={{ classNames={{ list: classes.tabsList }}
chevron: classes.accordionChevron, keepMounted={false}
control: classes.accordionControl, onChange={onTabChange}
label: classes.accordionLabel, value={tab}
panel: classes.accordionPanel,
}}
defaultValue={defaultValue}
multiple
> >
{props.positionSections.map(section => ( <Tabs.List aria-label={t("portfolio.sections")} mb="md">
<Accordion.Item key={section.kind} value={section.kind}> <Tabs.Tab value="positions">
<Accordion.Control> {t("portfolio.tabs.positions")}
{t(positionSectionTitleKey(section.kind))} </Tabs.Tab>
</Accordion.Control>
<Accordion.Panel> {props.accountId !== "all" && (
<PositionSection section={section} /> <Tabs.Tab value="concentration">
</Accordion.Panel> {t("portfolio.concentration.title")}
</Accordion.Item> </Tabs.Tab>
))} )}
<Accordion.Item value="cash"> <Tabs.Tab value="cash">{t("portfolio.tabs.cash")}</Tabs.Tab>
<Accordion.Control>{t("portfolio.cashBalances")}</Accordion.Control>
<Accordion.Panel> <Tabs.Tab value="operations">
<CurrencyCards amounts={props.portfolio.cashBalances} /> {t("portfolio.tabs.operations")}
</Accordion.Panel> </Tabs.Tab>
</Accordion.Item>
<Accordion.Item value="recentOperations"> <Tabs.Tab value="events">{t("portfolio.tabs.events")}</Tabs.Tab>
<Accordion.Control>{t("ledger.recentTitle")}</Accordion.Control>
<Accordion.Panel> <Tabs.Tab value="income">{t("portfolio.tabs.income")}</Tabs.Tab>
<RecentOperations operations={props.recentOperations} /> </Tabs.List>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="recentEvents"> <Tabs.Panel value="positions">
<Accordion.Control>{t("events.recentTitle")}</Accordion.Control> <Stack gap="md">
{props.positionSections.length > 1 && (
<Accordion.Panel> <div className={classes.positionFilter}>
<BrokerageEventsPreview events={props.recentEvents} /> <SegmentedControl
</Accordion.Panel> aria-label={t("portfolio.positionFilter")}
</Accordion.Item> data={filterOptions}
onChange={setFilter}
<Accordion.Item value="incomeSummary"> value={activeSection?.kind}
<Accordion.Control>{t("income.summaryTitle")}</Accordion.Control> />
</div>
<Accordion.Panel>
{!!props.incomeSummary && (
<IncomeSummary {...props.incomeSummary} />
)} )}
</Accordion.Panel>
</Accordion.Item> {activeSection && <PositionSection section={activeSection} />}
</Accordion> </Stack>
</Tabs.Panel>
{props.accountId !== "all" && (
<Tabs.Panel value="concentration">
{props.concentration && (
<Concentration
currency={props.currency}
key={`${props.slice}-${props.currency}`}
model={props.concentration}
onCurrencyChange={props.onCurrencyChange}
onSliceChange={props.onSliceChange}
partial={!!props.partial}
slice={props.slice}
/>
)}
</Tabs.Panel>
)}
<Tabs.Panel value="cash">
<CurrencyCards amounts={props.portfolio.cashBalances} />
</Tabs.Panel>
<Tabs.Panel value="operations">
<RecentOperations
allAccounts={props.accountId === "all"}
moreLabel={t("portfolio.more")}
operations={props.recentOperations}
/>
</Tabs.Panel>
<Tabs.Panel value="events">
<BrokerageEventsPreview
allAccounts={props.accountId === "all"}
events={props.recentEvents}
moreLabel={t("portfolio.more")}
/>
</Tabs.Panel>
<Tabs.Panel value="income">
{!!props.incomeSummary && (
<IncomeSummary
{...props.incomeSummary}
moreLabel={t("portfolio.more")}
/>
)}
</Tabs.Panel>
</Tabs>
</Stack> </Stack>
); );
}; };
function positionSectionTitleKey(kind: AccountPagePositionSection["kind"]) {
if (kind === "stocks") return "portfolio.stocks";
return kind === "bonds" ? "portfolio.bonds" : "portfolio.otherPositions";
}
export { AccountPageLoaded }; export { AccountPageLoaded };
@@ -34,7 +34,9 @@ const AccountPageLoading: FC = () => {
<Skeleton height={260} radius="sm" /> <Skeleton height={260} radius="sm" />
{["first", "second", "third"].map(placeholder => <Skeleton height={52} key={placeholder} radius="sm" />)} <Skeleton height={40} radius="sm" />
<Skeleton height={220} radius="sm" />
</LoadingSkeleton> </LoadingSkeleton>
); );
}; };
@@ -0,0 +1,111 @@
.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; }
}
@@ -0,0 +1,330 @@
import { DonutChart } from "@mantine/charts";
import {
Alert, Box, Group, Paper, SegmentedControl, Select, 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 = {
currency: null | string;
model: ConcentrationModel;
onCurrencyChange: (currency: null | string) => void;
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;
currency: 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, currency: string): string {
return formatMoney({ currency, 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, props.currency)}</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 currency = props.currency ?? "";
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, value: props.model.total,
}),
...rows.map(item => `${item.label}: ${amountFor(item, currency)}, ${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, currency),
label: item.label,
share: item.share,
}));
const tooltipProps = { content: <ChartTooltip rows={tooltipRows} /> };
const totalText = props.model.total
? formatMoney({ currency, 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}
/>
{props.model.currencies.length > 1 && (
<Select
allowDeselect={false}
aria-label={t("portfolio.concentration.currency")}
data={props.model.currencies}
onChange={props.onCurrencyChange}
value={props.currency}
w={130}
/>
)}
</Group>
{props.partial && (
<Alert role="status">{t("portfolio.concentration.incomplete")}</Alert>
)}
{props.model.empty
? (
<Stack gap="xs">
<Text fw={600}>{t(sliceLabels[props.slice])}
{props.currency && ` · ${props.currency}`}
</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"}
currency={currency}
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, currency)} · {
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"}
currency={currency}
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 };
@@ -9,8 +9,8 @@ import type { IncomeSummary as IncomeSummaryData } from "@/entities/income";
import { formatMoney } from "@/shared/lib"; import { formatMoney } from "@/shared/lib";
type IncomeSummaryProps type IncomeSummaryProps = { moreLabel?: string } & (
= | { | {
state: "empty"; state: "empty";
to: string; to: string;
} }
@@ -27,7 +27,8 @@ type IncomeSummaryProps
| { | {
state: "partial"; state: "partial";
to: string; to: string;
}; }
);
const IncomeSummary: FC<IncomeSummaryProps> = (props) => { const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -56,7 +57,7 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
<Text c="dimmed">{t("income.summaryEmpty")}</Text> <Text c="dimmed">{t("income.summaryEmpty")}</Text>
<Button component={Link} to={props.to} variant="subtle"> <Button component={Link} to={props.to} variant="subtle">
{t("income.open")} {props.moreLabel ?? t("income.open")}
</Button> </Button>
</Stack> </Stack>
); );
@@ -75,7 +76,7 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
))} ))}
<Button component={Link} to={props.to} variant="subtle"> <Button component={Link} to={props.to} variant="subtle">
{t("income.open")} {props.moreLabel ?? t("income.open")}
</Button> </Button>
</Stack> </Stack>
); );
@@ -12,11 +12,15 @@ const syntheticPosition
displayName: "Учебные технологии", displayName: "Учебные технологии",
figi: null, figi: null,
isin: null, isin: null,
issuer: null,
logoName: null, logoName: null,
logoSrc: null, logoSrc: null,
quantity: "100", quantity: "100",
returnPercent: "25",
returnPercentText: "+25,00 %",
returnText: "+5 000 ₽", returnText: "+5 000 ₽",
returnTone: "positive", returnTone: "positive",
sector: null,
shareText: "20%", shareText: "20%",
ticker: "DEMO", ticker: "DEMO",
type: "STOCK", type: "STOCK",
@@ -73,6 +77,8 @@ export const BondLoss: Story
"Учебные облигации для долгосрочных накоплений, выпуск 2030", "Учебные облигации для долгосрочных накоплений, выпуск 2030",
isin: "DEMO-BOND-2030", isin: "DEMO-BOND-2030",
quantity: "10", quantity: "10",
returnPercent: "-5",
returnPercentText: "-5,00 %",
returnText: "−500 ₽", returnText: "−500 ₽",
returnTone: "negative", returnTone: "negative",
shareText: "7,6%", shareText: "7,6%",
@@ -123,6 +129,8 @@ export const MissingValuation: Story
averagePurchasePrice: null, averagePurchasePrice: null,
currentValue: null, currentValue: null,
displayName: "Учебная позиция без текущей оценки", displayName: "Учебная позиция без текущей оценки",
returnPercent: null,
returnPercentText: null,
returnText: null, returnText: null,
returnTone: undefined, returnTone: undefined,
shareText: null, shareText: null,
@@ -6,7 +6,10 @@ import { Link } from "react-router";
import { formatMoney } from "@/shared/lib"; import { formatMoney } from "@/shared/lib";
import type { AccountPagePosition, AccountPagePositionSection } from "../../../../model/positions"; import type {
AccountPagePosition,
AccountPagePositionSection,
} from "../../../../model/positions";
import classes from "../../AccountPage.module.css"; import classes from "../../AccountPage.module.css";
import { PositionLogo } from "../PositionLogo.view"; import { PositionLogo } from "../PositionLogo.view";
@@ -26,8 +29,15 @@ const PositionCard: FC<PositionCardProps> = (props) => {
: undefined; : undefined;
let returnText = position.returnText; let returnText = position.returnText;
if (returnText && !isExpanded)
if (returnText && position.returnPercentText) {
returnText += ` · ${position.returnPercentText}`;
}
if (returnText && !isExpanded) {
returnText = t("portfolio.return", { value: returnText }); returnText = t("portfolio.return", { value: returnText });
}
const instrumentTo = position.instrumentTo; const instrumentTo = position.instrumentTo;
const identifier const identifier
@@ -6,7 +6,10 @@ import { Link } from "react-router";
import { formatMoney } from "@/shared/lib"; import { formatMoney } from "@/shared/lib";
import type { AccountPagePositionGroup, AccountPagePositionSection } from "../../../model/positions"; import type {
AccountPagePositionGroup,
AccountPagePositionSection,
} from "../../../model/positions";
import classes from "../AccountPage.module.css"; import classes from "../AccountPage.module.css";
import { PositionLogo } from "./PositionLogo.view"; import { PositionLogo } from "./PositionLogo.view";
@@ -60,6 +63,16 @@ const PositionGroupCard: FC<PositionGroupCardProps> = (props) => {
? classes[position.returnTone] ? classes[position.returnTone]
: undefined; : undefined;
const returnPercent = position.returnPercentText
? ` · ${position.returnPercentText}`
: "";
const returnValue = `${position.returnText ?? ""}${returnPercent}`;
const returnText = kind === "other"
? t("portfolio.return", { value: returnValue })
: returnValue;
return ( return (
<Stack <Stack
className={classes.accountPosition} className={classes.accountPosition}
@@ -89,9 +102,7 @@ const PositionGroupCard: FC<PositionGroupCardProps> = (props) => {
{!!position.returnText && ( {!!position.returnText && (
<Text className={appearanceClass} size="sm"> <Text className={appearanceClass} size="sm">
{kind === "other" {returnText}
? t("portfolio.return", { value: position.returnText })
: position.returnText}
</Text> </Text>
)} )}
</Group> </Group>
@@ -5,11 +5,18 @@ 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 { LedgerEntryRow } from "@/entities/broker-operation"; import {
brokerageOperationGroupKey,
LedgerEntryRow,
OperationGroup,
} from "@/entities/broker-operation";
import { groupItems } from "@/shared/lib";
import type { AccountPageRecentOperations } from "../../../model/previews"; import type { AccountPageRecentOperations } from "../../../model/previews";
type RecentOperationsProps = { type RecentOperationsProps = {
allAccounts?: boolean;
moreLabel?: string;
operations: AccountPageRecentOperations; operations: AccountPageRecentOperations;
}; };
@@ -31,20 +38,44 @@ const RecentOperations: FC<RecentOperationsProps> = (props) => {
{operations.state === "partial" && <Text role="status">{t("investment.partial")}</Text>} {operations.state === "partial" && <Text role="status">{t("investment.partial")}</Text>}
{(operations.state === "loaded" || operations.state === "partial") {(operations.state === "loaded" || operations.state === "partial") && (
&& operations.items.map(operation => ( <Stack
<LedgerEntryRow component="ul"
key={JSON.stringify([ gap="sm"
operation.account?.id, m={0}
operation.id, p={0}
operation.logoName, style={{ listStyle: "none" }}
])} >
operation={operation} {groupItems(
/> operations.items.map(operation => ({
))} key: JSON.stringify([
operation.account?.id,
operation.id,
operation.logoName,
]),
operation,
})),
entry => props.allAccounts
? brokerageOperationGroupKey(entry.operation)
: null,
).map(entry => entry.type === "group"
? (
<OperationGroup
entries={entry.items}
key={entry.key}
totalVisible={operations.totalVisible === true}
/>
)
: (
<li key={entry.item.key}>
<LedgerEntryRow operation={entry.item.operation} />
</li>
))}
</Stack>
)}
<Button component={Link} to={operations.to} variant="subtle"> <Button component={Link} to={operations.to} variant="subtle">
{t("ledger.open")} {props.moreLabel ?? t("ledger.open")}
</Button> </Button>
</Stack> </Stack>
); );
@@ -36,6 +36,8 @@ describe("Positions grouped by security", () => {
).toHaveAttribute("href", "/accounts/a"); ).toHaveAttribute("href", "/accounts/a");
expect(within(rows[0]).getByText("Количество: 1.25")).toBeInTheDocument(); expect(within(rows[0]).getByText("Количество: 1.25")).toBeInTheDocument();
expect(within(rows[0]).getByText("375 ₽")).toBeInTheDocument(); expect(within(rows[0]).getByText("375 ₽")).toBeInTheDocument();
expect(within(rows[0]).getByText("+25 ₽ · +7,14 %"))
.toBeInTheDocument();
expect( expect(
within(rows[0]).getByText("Средняя цена: 280 ₽"), within(rows[0]).getByText("Средняя цена: 280 ₽"),
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -37,10 +37,14 @@ it("keeps expanded positions vertical while other positions adapt to the viewpor
displayName: `Инструмент ${index}`, displayName: `Инструмент ${index}`,
figi: null, figi: null,
isin: null, isin: null,
issuer: null,
logoName: null, logoName: null,
logoSrc: null, logoSrc: null,
quantity: "10", quantity: "10",
returnPercent: null,
returnPercentText: null,
returnText: null, returnText: null,
sector: null,
shareText: null, shareText: null,
ticker: null, ticker: null,
type: kind === "stocks" ? "STOCK" : otherType, type: kind === "stocks" ? "STOCK" : otherType,
@@ -1,6 +1,6 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw"; import { http, HttpResponse } from "msw";
import { MemoryRouter } from "react-router"; import { MemoryRouter } from "react-router";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
@@ -31,6 +31,19 @@ function configureTestAuth() {
}); });
} }
function portfolioSummary(totals: { currency: string; value: string }[]) {
return {
bondPositionCount: 1,
cashBalances: [],
fundPositionCount: 0,
stockPositionCount: 0,
structure: { currencies: [], unvaluedCategories: [] },
totalPositionCount: 1,
totalsByCurrency: totals,
unrealizedReturnByCurrency: [],
};
}
function renderPage(queryClient: QueryClient) { function renderPage(queryClient: QueryClient) {
configureTestAuth(); configureTestAuth();
@@ -46,6 +59,65 @@ function renderPage(queryClient: QueryClient) {
} }
describe("AccountsPage", () => { describe("AccountsPage", () => {
it("summarizes currencies separately and reports excluded accounts", async () => {
server.use(http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json(resource([
{
...account,
portfolioSummary: portfolioSummary([
{ currency: "RUB", value: "100.10" },
{ currency: "USD", value: "20" },
]),
},
{
...account,
id: "account-2",
name: "Второй",
portfolioSummary: portfolioSummary([
{ currency: "RUB", value: "50.20" },
]),
},
{
...account,
id: "account-3",
name: "Без сводки",
portfolioSummary: null,
},
{
...account,
id: "account-4",
name: "Только деньги",
portfolioSummary: {
...portfolioSummary([{ currency: "RUB", value: "25" }]),
bondPositionCount: 0,
cashBalances: [{ currency: "RUB", value: "25" }],
totalPositionCount: 0,
},
},
{
...account,
id: "account-5",
name: "Без сумм",
portfolioSummary: {
...portfolioSummary([]),
totalPositionCount: 0,
},
},
])),
));
const client = createTestQueryClient();
renderPage(client);
expect(await screen.findByText("175,30 ₽")).toBeInTheDocument();
expect(screen.getAllByText("20,00 USD")).not.toHaveLength(0);
expect(screen.getByText("5 счетов")).toBeInTheDocument();
expect(screen.getByText("Нет текущих сумм")).toBeInTheDocument();
expect(screen.getByText(
"Ещё 1 счёт не включён: нет актуального портфеля",
)).toBeInTheDocument();
});
it("loads accounts after retrying an initial HTTP failure", async () => { it("loads accounts after retrying an initial HTTP failure", async () => {
const modes: (null | string)[] = []; const modes: (null | string)[] = [];
server.use( server.use(
@@ -189,6 +261,101 @@ describe("AccountsPage", () => {
}); });
}); });
// Automatic read retries wait 1s + 2s before the failure is final.
const RETRIES_EXHAUSTED_MS = 6000;
const TEST_MS = 10_000;
describe("exhausted read retries", () => {
it(
"keeps the cached list, warns and retries the same accounts GET",
{ timeout: TEST_MS },
async () => {
let isFailing = false;
let requests = 0;
server.use(
http.get("*/api/v1/brokerage-accounts", () => {
requests += 1;
if (isFailing)
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
return HttpResponse.json(resource([account]));
}),
);
const queryClient = createTestQueryClient();
renderPage(queryClient);
await screen.findByText("Основной");
expect(screen.queryByRole("button", { name: "Повторить" }))
.not.toBeInTheDocument();
isFailing = true;
void queryClient.invalidateQueries({
queryKey: brokerageAccountsQueryKey,
});
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);
expect(screen.getByText("Основной")).toBeInTheDocument();
},
);
it(
"offers retry on the unavailable state after an initial failure",
{ timeout: TEST_MS },
async () => {
let isFailing = true;
server.use(
http.get("*/api/v1/brokerage-accounts", () =>
isFailing
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(resource([account]))),
);
renderPage(createTestQueryClient());
const retry = await screen.findByRole(
"button",
{ name: "Повторить" },
{ timeout: RETRIES_EXHAUSTED_MS },
);
isFailing = false;
fireEvent.click(retry);
expect(await screen.findByText("Основной")).toBeInTheDocument();
},
);
it("does not offer a plain retry when the connection is lost", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json({ code: "TBANK_NOT_CONNECTED" }, { status: 409 })),
);
renderPage(createTestQueryClient());
expect(await screen.findByRole("link", { name: "Открыть настройки T-Bank" }))
.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Повторить" }))
.not.toBeInTheDocument();
});
});
it.each(["none", "failed"] as const)( it.each(["none", "failed"] as const)(
"keeps incomplete empty membership distinct from no accounts (attempt=%s)", "keeps incomplete empty membership distinct from no accounts (attempt=%s)",
async (state) => { async (state) => {
@@ -16,6 +16,7 @@ const AccountsPage: FC = () => {
<AccountsPageLoaded <AccountsPageLoaded
accounts={viewModel.accounts} accounts={viewModel.accounts}
meta={viewModel.meta} meta={viewModel.meta}
readRetry={viewModel.readRetry}
/> />
); );
}; };
@@ -4,14 +4,26 @@ import {
useBrokerageAccounts, useBrokerageAccounts,
} from "@/entities/brokerage-account"; } from "@/entities/brokerage-account";
import { useScreenSync } from "@/features/tbank-connection"; import { useScreenSync } from "@/features/tbank-connection";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; import {
ApiErrorCode,
apiErrorCode,
isApiErrorCode,
readRetry,
} from "@/shared/api";
import { useInitialAvailability } from "@/shared/lib"; import { useInitialAvailability } from "@/shared/lib";
type AccountsPageReadRetry = ReturnType<typeof readRetry>;
type AccountsPageViewModel type AccountsPageViewModel
= | { accounts: BrokerageAccountCard[]; meta: NonNullable<ReturnType<typeof useBrokerageAccounts>["meta"]>; state: "loaded" } = | {
| { code?: string; state: "error" } accounts: BrokerageAccountCard[];
| { state: "empty" } meta: NonNullable<ReturnType<typeof useBrokerageAccounts>["meta"]>;
| { state: "loading" }; readRetry: AccountsPageReadRetry;
state: "loaded";
}
| { code?: string; readRetry: AccountsPageReadRetry; state: "error" }
| { state: "empty" }
| { state: "loading" };
function isAccountsAccessFailure(code?: string) { function isAccountsAccessFailure(code?: string) {
return isApiErrorCode(code, ApiErrorCode.InvalidTbankToken) return isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)
@@ -23,6 +35,7 @@ function useAccountsPageViewModel(): AccountsPageViewModel {
useScreenSync({ screen: "accounts" }, [accounts]); useScreenSync({ screen: "accounts" }, [accounts]);
const code = apiErrorCode(accounts.error); const code = apiErrorCode(accounts.error);
const retry = readRetry([accounts]);
const isAccessFailure = isAccountsAccessFailure(code); const isAccessFailure = isAccountsAccessFailure(code);
const hasResult = !!accounts.data const hasResult = !!accounts.data
@@ -36,12 +49,13 @@ function useAccountsPageViewModel(): AccountsPageViewModel {
isFetching: accounts.isFetching, isFetching: accounts.isFetching,
}); });
if (isAccessFailure) return { code, state: "error" as const }; if (isAccessFailure)
return { code, readRetry: retry, state: "error" as const };
if (!hasResult || !accounts.data) if (!hasResult || !accounts.data)
return { return availability === "unavailable"
state: availability === "unavailable" ? "error" : "loading", ? { code, readRetry: retry, state: "error" as const }
} as const; : { state: "loading" as const };
if (accounts.data.length === 0) if (accounts.data.length === 0)
return { return {
@@ -54,6 +68,7 @@ function useAccountsPageViewModel(): AccountsPageViewModel {
return { return {
accounts: brokerageAccountCards(accounts.data, meta.dependencies), accounts: brokerageAccountCards(accounts.data, meta.dependencies),
meta, meta,
readRetry: retry,
state: "loaded" as const, state: "loaded" as const,
}; };
} }
@@ -6,8 +6,12 @@ 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 AccountsPageErrorProps = { code?: string }; type AccountsPageErrorProps = {
code?: string;
readRetry?: ReadRetryProps;
};
const AccountsPageError: FC<AccountsPageErrorProps> = (props) => { const AccountsPageError: FC<AccountsPageErrorProps> = (props) => {
const { code } = props; const { code } = props;
@@ -38,8 +42,18 @@ const AccountsPageError: FC<AccountsPageErrorProps> = (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")}
/> />
@@ -1,6 +1,6 @@
import type { FC } from "react"; import type { FC } from "react";
import { Button, Stack, Text, Title } from "@mantine/core"; import { Button, SimpleGrid, Stack, Text, Title } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
@@ -10,15 +10,83 @@ import {
type BrokerageAccountCard, type BrokerageAccountCard,
BrokerageAccountList, BrokerageAccountList,
} from "@/entities/brokerage-account"; } from "@/entities/brokerage-account";
import { SourceSyncStatus } from "@/shared/ui"; import { formatMoney } from "@/shared/lib";
import { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui";
const DECIMAL_BASE = 10n;
const DECIMAL_PART_COUNT = 2;
const ZERO = 0n;
type AccountsPageLoadedProps = { type AccountsPageLoadedProps = {
accounts: BrokerageAccountCard[]; accounts: BrokerageAccountCard[];
meta: InvestmentResourceMeta; meta: InvestmentResourceMeta;
readRetry?: ReadRetryProps;
}; };
type Money = { currency: string; value: string };
function accountTotals(accounts: BrokerageAccountCard[]) {
const values = new Map<string, string[]>();
let excludedCount = 0;
for (const { summary } of accounts) {
if (summary.state === "empty") continue;
if (summary.state === "unavailable") {
excludedCount += 1;
continue;
}
for (const total of summary.portfolio.totalsByCurrency) {
values.set(total.currency, [
...values.get(total.currency) ?? [],
total.value,
]);
}
}
const totals: Money[] = Array.from(values, ([currency, amounts]) => ({
currency,
value: sumDecimals(amounts),
}));
return { excludedCount, totals };
}
function sumDecimals(values: string[]) {
const scale = Math.max(
0,
...values.map(value =>
value.split(".", DECIMAL_PART_COUNT)[1]?.length ?? 0),
);
const factor = DECIMAL_BASE ** BigInt(scale);
let sum = ZERO;
for (const value of values) {
const isNegative = value.startsWith("-");
const [integer = "0", fraction = ""] = (isNegative
? value.slice(1)
: value).split(".", DECIMAL_PART_COUNT);
const units = BigInt(integer) * factor
+ BigInt(fraction.padEnd(scale, "0") || "0");
sum += isNegative ? -units : units;
}
const isNegative = sum < ZERO;
const digits = (isNegative ? -sum : sum).toString().padStart(scale + 1, "0");
const integer = scale ? digits.slice(0, -scale) : digits;
const fraction = scale ? `.${digits.slice(-scale)}` : "";
return `${isNegative ? "-" : ""}${integer}${fraction}`;
}
const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => { const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { excludedCount, totals } = accountTotals(props.accounts);
return ( return (
<Stack gap="lg"> <Stack gap="lg">
@@ -28,12 +96,45 @@ const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
<Text c="dimmed" size="sm"> <Text c="dimmed" size="sm">
{t("accounts.subtitle")} {t("accounts.subtitle")}
</Text> </Text>
</Stack>
<Stack gap="xs">
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="xs">
<Stack gap={4}>
<Text c="dimmed" size="sm">
{t("accounts.currentValue")}
</Text>
{totals.length > 0
? totals.map(total => (
<Text fw={700} key={total.currency} size="lg">
{formatMoney(total, { withRoundedCents: true })}
</Text>
))
: <Text c="dimmed">{t("accounts.summaryNoTotals")}</Text>}
</Stack>
<Stack gap={4}>
<Text fw={600}>{t("accounts.count", {
count: props.accounts.length,
})}
</Text>
{excludedCount > 0 && (
<Text c="dimmed" size="sm">
{t("accounts.summaryExcluded", { count: excludedCount })}
</Text>
)}
</Stack>
</SimpleGrid>
<SourceSyncStatus <SourceSyncStatus
lastAttempt={props.meta.lastAttempt} lastAttempt={props.meta.lastAttempt}
lastSuccessfulAt={props.meta.lastSuccessfulAt} lastSuccessfulAt={props.meta.lastSuccessfulAt}
stale={props.meta.freshness === "stale"} stale={props.meta.freshness === "stale"}
/> />
{!!props.readRetry && <ReadRetry {...props.readRetry} />}
</Stack> </Stack>
<Button <Button
+16 -1
View File
@@ -347,7 +347,6 @@ describe("bond page", () => {
it("keeps market content and sold-position history when coupons fail", async () => { it("keeps market content and sold-position history when coupons fail", async () => {
setup({ failCoupons: true, noPosition: true }); setup({ failCoupons: true, noPosition: true });
await screen.findByText("98,125 %"); await screen.findByText("98,125 %");
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
expect( expect(
screen.queryByRole("heading", { name: "Моя позиция" }), screen.queryByRole("heading", { name: "Моя позиция" }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
@@ -355,6 +354,22 @@ describe("bond page", () => {
screen.getByRole("heading", { name: "Моя история событий" }), screen.getByRole("heading", { name: "Моя история событий" }),
).toBeVisible(); ).toBeVisible();
}); });
it(
"offers a local retry for the failed coupons section only",
{ timeout: 10_000 },
async () => {
setup({ failCoupons: true });
await screen.findByText("98,125 %");
// Automatic read retries wait 1s + 2s before the failure is final.
await screen.findByRole(
"button",
{ name: "Повторить" },
{ timeout: 6000 },
);
expect(screen.getAllByRole("button", { name: "Повторить" })).toHaveLength(1);
expect(screen.getByText("98,125 %")).toBeVisible();
},
);
it("preserves cached quote without manual refresh", async () => { it("preserves cached quote without manual refresh", async () => {
const controls = setup({ path: `/bonds/${uid}?period=year&events=trades` }); const controls = setup({ path: `/bonds/${uid}?period=year&events=trades` });
await screen.findByText("98,125 %"); await screen.findByText("98,125 %");
@@ -81,6 +81,7 @@ function setup(
candles?: NonNullable<StockPageVm["candles"]>["candles"]; candles?: NonNullable<StockPageVm["candles"]>["candles"];
cursorError?: string; cursorError?: string;
failCompany?: boolean; failCompany?: boolean;
failCompanyAfter?: { value: boolean };
failStock?: boolean; failStock?: boolean;
metrics?: NonNullable<StockPageVm["company"]>["metrics"]; metrics?: NonNullable<StockPageVm["company"]>["metrics"];
noPosition?: boolean; noPosition?: boolean;
@@ -133,7 +134,7 @@ function setup(
), ),
); );
if (params.section === "company") if (params.section === "company")
return options.failCompany return (options.failCompanyAfter?.value ?? options.failCompany)
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json( : HttpResponse.json(
@@ -396,7 +397,6 @@ describe("stock page", () => {
screen.queryByRole("heading", { name: "Моя позиция" }), screen.queryByRole("heading", { name: "Моя позиция" }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "График цены" })).toBeVisible(); expect(screen.getByRole("heading", { name: "График цены" })).toBeVisible();
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
}); });
it("keeps successful sections visible when company data is initially missing", async () => { it("keeps successful sections visible when company data is initially missing", async () => {
const { requests } = setup({ failCompany: true }); const { requests } = setup({ failCompany: true });
@@ -404,8 +404,39 @@ describe("stock page", () => {
const previousRequests = requests.length; const previousRequests = requests.length;
expect(screen.getByText("Зачисление дивидендов")).toBeVisible(); expect(screen.getByText("Зачисление дивидендов")).toBeVisible();
expect(requests).toHaveLength(previousRequests); expect(requests).toHaveLength(previousRequests);
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
}); });
it(
"retries only the failed company section and recovers it in place",
{ timeout: 10_000 },
async () => {
const failing = { value: true };
const { requests } = setup({ failCompanyAfter: failing });
await screen.findByText("Зачисление дивидендов");
// Automatic read retries wait 1s + 2s before the failure is final.
const retry = await screen.findByRole(
"button",
{ name: "Повторить" },
{ timeout: 6000 },
);
expect(screen.getByRole("heading", { name: "График цены" })).toBeVisible();
expect(screen.getAllByRole("button", { name: "Повторить" })).toHaveLength(1);
const before = requests.length;
const companyBefore = requests.filter(url => url.pathname.endsWith("/company")).length;
failing.value = false;
fireEvent.click(retry);
await waitFor(() =>
expect(screen.queryByRole("button", { name: "Повторить" }))
.not.toBeInTheDocument());
expect(requests.filter(url => url.pathname.endsWith("/company")))
.toHaveLength(companyBefore + 1);
expect(requests.length - before).toBe(1);
expect(screen.getByText("Описание компании")).toBeVisible();
},
);
it("sends instrument-filtered continuation, restores it on returning to the filter and changes chart period", async () => { it("sends instrument-filtered continuation, restores it on returning to the filter and changes chart period", async () => {
const { requests } = setup(); const { requests } = setup();
await screen.findByText("Зачисление дивидендов"); await screen.findByText("Зачисление дивидендов");
+5 -1
View File
@@ -285,7 +285,11 @@ describe("apiClient transient read recovery", () => {
http.get("*/api/v1/brokerage-accounts", () => { http.get("*/api/v1/brokerage-accounts", () => {
requests += 1; requests += 1;
return HttpResponse.json({ code: "FORBIDDEN" }, { status }); // Any non-transient code; status alone must prevent the retry.
return HttpResponse.json(
{ code: "INTERNAL_SERVER_ERROR", status },
{ status },
);
}), }),
); );
configureAuthClient({ configureAuthClient({
+8 -8
View File
@@ -3,7 +3,12 @@ import createQueryClient from "openapi-react-query";
import type { paths } from "./generated/schema"; import type { paths } from "./generated/schema";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details"; import {
ApiErrorCode,
apiErrorCode,
isApiErrorCode,
isBrokerageAccessLoss,
} from "./problem-details";
const HTTP_UNAUTHORIZED = 401; const HTTP_UNAUTHORIZED = 401;
const HTTP_REQUEST_TIMEOUT = 408; const HTTP_REQUEST_TIMEOUT = 408;
@@ -165,13 +170,8 @@ function isNetworkFailure(error: unknown): boolean {
} }
function isPermanentCode(code?: string): boolean { function isPermanentCode(code?: string): boolean {
return [ return isBrokerageAccessLoss(code)
ApiErrorCode.Forbidden, || isApiErrorCode(code, ApiErrorCode.Unauthorized);
ApiErrorCode.InvalidTbankToken,
ApiErrorCode.NotFound,
ApiErrorCode.TbankNotConnected,
ApiErrorCode.Unauthorized,
].some(permanent => isApiErrorCode(code, permanent));
} }
function isTransientReadFailure( function isTransientReadFailure(
+63 -27
View File
@@ -731,8 +731,11 @@ export interface components {
ProblemDetailsDto: { ProblemDetailsDto: {
/** @example 401 */ /** @example 401 */
status: number; status: number;
/** @example UNAUTHORIZED */ /**
code: string; * @example UNAUTHORIZED
* @enum {string}
*/
code: "BAD_REQUEST" | "INTERNAL_SERVER_ERROR" | "NOT_FOUND" | "UNAUTHORIZED" | "UNSUPPORTED_MEDIA_TYPE" | "VALIDATION_ERROR" | "EMAIL_ALREADY_REGISTERED" | "INVALID_CREDENTIALS" | "INVALID_OPERATIONS_CURSOR" | "BOND_HISTORY_START_UNAVAILABLE" | "BOND_TICKER_AMBIGUOUS" | "STOCK_HISTORY_START_UNAVAILABLE" | "STOCK_TICKER_AMBIGUOUS" | "INVALID_TBANK_TOKEN" | "TBANK_NOT_CONNECTED" | "TBANK_UNAVAILABLE" | "BANK_CARD_ARCHIVED" | "BANK_DEPENDENT_REFUNDS" | "BANK_DUPLICATE_CATEGORY" | "BANK_IMMUTABLE_OPERATION" | "BANK_INTENT_CONFLICT" | "BANK_INVALID_DATE" | "BANK_INVALID_INPUT" | "BANK_INVALID_MONEY" | "BANK_INVALID_REFUND" | "BANK_NOT_FOUND" | "BANK_OPENING_DATE_CONFLICT" | "BANK_OPERATION_DELETED" | "BANK_REFUND_LIMIT" | "BANK_VERSION_CONFLICT" | "BANK_WRITE_CONFLICT" | "MOEX_UNAVAILABLE" | "NCR_UNAVAILABLE" | "INVALID_EVENTS_PERIOD" | "INVALID_INCOME_PERIOD";
/** /**
* @example [ * @example [
* { * {
@@ -909,12 +912,54 @@ export interface components {
version: number; version: number;
archived?: boolean; archived?: boolean;
}; };
PortfolioStructureCategoryDto: {
/** @enum {string} */
type: "STOCK" | "BOND" | "OTHER" | "CASH";
/** @description Exact known value as a decimal string. */
value: string;
/** @description Percentage including cash, rounded to six decimal places. Null for incomplete, negative or zero-total compositions. */
sharePercent: string | null;
};
PortfolioStructureCurrencyDto: {
currency: string;
totalValue: string;
/** @description Any negative constituent, even if netted positive. */
hasNegativeValues: boolean;
categories: components["schemas"]["PortfolioStructureCategoryDto"][];
};
PortfolioUnvaluedCategoryDto: {
/** @enum {string} */
type: "STOCK" | "BOND" | "OTHER";
count: number;
};
PortfolioStructureDto: {
currencies: components["schemas"]["PortfolioStructureCurrencyDto"][];
/** @description Positions without current value and known valuation currency. Shares are unavailable across all currencies when nonempty. */
unvaluedCategories: components["schemas"]["PortfolioUnvaluedCategoryDto"][];
};
UnrealizedReturnDto: {
currency: string;
/** @example 12.345678901 */
absolute: string;
/** @example 100.00 */
costBasis: string;
/** @example 12.34 */
percent: string | null;
partial: boolean;
};
PortfolioSummaryDto: { PortfolioSummaryDto: {
totalsByCurrency: components["schemas"]["MoneyDto"][]; totalsByCurrency: components["schemas"]["MoneyDto"][];
cashBalances: components["schemas"]["MoneyDto"][];
structure: components["schemas"]["PortfolioStructureDto"];
unrealizedReturnByCurrency: components["schemas"]["UnrealizedReturnDto"][];
/** @description Number of non-zero stock positions, not the number of units. */ /** @description Number of non-zero stock positions, not the number of units. */
stockPositionCount: number; stockPositionCount: number;
/** @description Number of non-zero bond positions, not the number of units. */ /** @description Number of non-zero bond positions, not the number of units. */
bondPositionCount: number; bondPositionCount: number;
/** @description Number of non-zero fund positions, not the number of units. */
fundPositionCount: number;
/** @description Number of all non-zero positions, not the number of units. */
totalPositionCount: number;
}; };
BrokerageAccountDto: { BrokerageAccountDto: {
id: string; id: string;
@@ -1011,6 +1056,10 @@ export interface components {
id: string; id: string;
name: string; name: string;
}; };
PortfolioIssuerDto: {
id: string;
name: string;
};
PortfolioPositionDto: { PortfolioPositionDto: {
instrumentUid?: string | null; instrumentUid?: string | null;
account?: components["schemas"]["AccountReferenceDto"]; account?: components["schemas"]["AccountReferenceDto"];
@@ -1025,31 +1074,8 @@ export interface components {
isin: string | null; isin: string | null;
ticker: string | null; ticker: string | null;
logoName: string | null; logoName: string | null;
}; sector: string | null;
PortfolioStructureCategoryDto: { issuer: components["schemas"]["PortfolioIssuerDto"] | null;
/** @enum {string} */
type: "STOCK" | "BOND" | "OTHER" | "CASH";
/** @description Exact known value as a decimal string. */
value: string;
/** @description Percentage including cash, rounded to six decimal places. Null for incomplete, negative or zero-total compositions. */
sharePercent: string | null;
};
PortfolioStructureCurrencyDto: {
currency: string;
totalValue: string;
/** @description Any negative constituent, even if netted positive. */
hasNegativeValues: boolean;
categories: components["schemas"]["PortfolioStructureCategoryDto"][];
};
PortfolioUnvaluedCategoryDto: {
/** @enum {string} */
type: "STOCK" | "BOND" | "OTHER";
count: number;
};
PortfolioStructureDto: {
currencies: components["schemas"]["PortfolioStructureCurrencyDto"][];
/** @description Positions without current value and known valuation currency. Shares are unavailable across all currencies when nonempty. */
unvaluedCategories: components["schemas"]["PortfolioUnvaluedCategoryDto"][];
}; };
PortfolioDto: { PortfolioDto: {
/** Format: date-time */ /** Format: date-time */
@@ -1206,6 +1232,12 @@ export interface components {
data: components["schemas"]["IncomeAnalyticsDto"] | null; data: components["schemas"]["IncomeAnalyticsDto"] | null;
meta: components["schemas"]["IncomeAnalyticsMetaDto"]; meta: components["schemas"]["IncomeAnalyticsMetaDto"];
}; };
UpcomingEventsWindowDto: {
/** Format: date */
from: string;
/** Format: date */
to: string;
};
UpcomingEventDto: { UpcomingEventDto: {
account?: components["schemas"]["AccountReferenceDto"]; account?: components["schemas"]["AccountReferenceDto"];
/** @enum {string} */ /** @enum {string} */
@@ -1226,6 +1258,8 @@ export interface components {
accountId: string; accountId: string;
/** Format: date-time */ /** Format: date-time */
calculatedAt: string; calculatedAt: string;
/** @description Inclusive UTC window the saved forecast was calculated for. Unchanged by the requested from/to filter. */
window: components["schemas"]["UpcomingEventsWindowDto"];
events: components["schemas"]["UpcomingEventDto"][]; events: components["schemas"]["UpcomingEventDto"][];
incomeTotalsByCurrency: components["schemas"]["MoneyDto"][]; incomeTotalsByCurrency: components["schemas"]["MoneyDto"][];
}; };
@@ -1363,6 +1397,8 @@ export interface components {
isin: string | null; isin: string | null;
ticker: string | null; ticker: string | null;
logoName: string | null; logoName: string | null;
sector: string | null;
issuer: components["schemas"]["PortfolioIssuerDto"] | null;
returnPercent: string | null; returnPercent: string | null;
}; };
StockPositionDto: { StockPositionDto: {
+7 -1
View File
@@ -7,7 +7,13 @@ export {
configureAuthClient, configureAuthClient,
} from "./client"; } from "./client";
export type { paths } from "./generated/schema"; export type { paths } from "./generated/schema";
export { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details"; export {
ApiErrorCode,
apiErrorCode,
isApiErrorCode,
isBrokerageAccessLoss,
} from "./problem-details";
export { isReadFailureExhausted, readRetry } from "./read-failure";
export { export {
type InvestmentResourceMeta, type InvestmentResourceMeta,
+13 -1
View File
@@ -1,6 +1,11 @@
import { expect, it } from "vitest"; import { expect, it } from "vitest";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details"; import {
ApiErrorCode,
apiErrorCode,
isApiErrorCode,
isBrokerageAccessLoss,
} from "./problem-details";
it("matches known codes and preserves unknown wire codes for fallback handling", () => { it("matches known codes and preserves unknown wire codes for fallback handling", () => {
expect( expect(
@@ -16,3 +21,10 @@ it("matches known codes and preserves unknown wire codes for fallback handling",
expect(apiErrorCode(null)).toBeUndefined(); expect(apiErrorCode(null)).toBeUndefined();
expect(apiErrorCode({})).toBeUndefined(); expect(apiErrorCode({})).toBeUndefined();
}); });
it("separates brokerage access loss from UNAUTHORIZED, temporary and unknown codes", () => {
for (const code of ["INVALID_TBANK_TOKEN", "NOT_FOUND", "TBANK_NOT_CONNECTED"])
expect(isBrokerageAccessLoss(code)).toBe(true);
for (const code of ["UNAUTHORIZED", "TBANK_UNAVAILABLE", "FUTURE_PROVIDER_ERROR", undefined])
expect(isBrokerageAccessLoss(code)).toBe(false);
});
+27 -3
View File
@@ -1,3 +1,5 @@
import type { components } from "./generated/schema";
enum ApiErrorCode { enum ApiErrorCode {
BankDependentRefunds = "BANK_DEPENDENT_REFUNDS", BankDependentRefunds = "BANK_DEPENDENT_REFUNDS",
BankIntentConflict = "BANK_INTENT_CONFLICT", BankIntentConflict = "BANK_INTENT_CONFLICT",
@@ -6,7 +8,6 @@ enum ApiErrorCode {
BankVersionConflict = "BANK_VERSION_CONFLICT", BankVersionConflict = "BANK_VERSION_CONFLICT",
BankWriteConflict = "BANK_WRITE_CONFLICT", BankWriteConflict = "BANK_WRITE_CONFLICT",
BondTickerAmbiguous = "BOND_TICKER_AMBIGUOUS", BondTickerAmbiguous = "BOND_TICKER_AMBIGUOUS",
Forbidden = "FORBIDDEN",
InvalidOperationsCursor = "INVALID_OPERATIONS_CURSOR", InvalidOperationsCursor = "INVALID_OPERATIONS_CURSOR",
InvalidTbankToken = "INVALID_TBANK_TOKEN", InvalidTbankToken = "INVALID_TBANK_TOKEN",
NotFound = "NOT_FOUND", NotFound = "NOT_FOUND",
@@ -16,6 +17,16 @@ enum ApiErrorCode {
Unauthorized = "UNAUTHORIZED", Unauthorized = "UNAUTHORIZED",
} }
/**
Compile-time drift guard: collapses to `never` when any enum member is absent
from the generated ProblemDetails contract, so every call site fails to build.
*/
type ContractApiErrorCode
= Exclude<
`${ApiErrorCode}`,
components["schemas"]["ProblemDetailsDto"]["code"]
> extends never ? ApiErrorCode : never;
function apiErrorCode(error: unknown): string | undefined { function apiErrorCode(error: unknown): string | undefined {
return typeof error === "object" && error !== null && "code" in error return typeof error === "object" && error !== null && "code" in error
? String(error.code) ? String(error.code)
@@ -23,8 +34,21 @@ function apiErrorCode(error: unknown): string | undefined {
} }
// Incoming codes are an open wire string; unknown values retain their fallback. // Incoming codes are an open wire string; unknown values retain their fallback.
function isApiErrorCode(value: unknown, expected: ApiErrorCode): boolean { function isApiErrorCode(
value: unknown,
expected: ContractApiErrorCode,
): boolean {
return value === expected; return value === expected;
} }
export { ApiErrorCode, apiErrorCode, isApiErrorCode }; // Confirmed loss of access to the brokerage data. UNAUTHORIZED is a session
// failure with its own refresh/sign-out path, so it is deliberately excluded.
function isBrokerageAccessLoss(code?: string): boolean {
return [
ApiErrorCode.InvalidTbankToken,
ApiErrorCode.NotFound,
ApiErrorCode.TbankNotConnected,
].some(loss => isApiErrorCode(code, loss));
}
export { ApiErrorCode, apiErrorCode, isApiErrorCode, isBrokerageAccessLoss };
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, it, vi } from "vitest";
import { isReadFailureExhausted, readRetry } from "./read-failure";
const transient = { code: "TBANK_UNAVAILABLE", status: 503 };
const settled = { isError: true, isFetching: false };
describe("isReadFailureExhausted", () => {
it("is true for a settled transient failure, even without a code", () => {
expect(isReadFailureExhausted({ ...settled, error: transient })).toBe(true);
expect(
isReadFailureExhausted({
...settled,
error: new TypeError("Network request failed"),
}),
).toBe(true);
});
it("stays true while a manual retry runs and false without a failure", () => {
expect(
isReadFailureExhausted({
error: transient,
isError: true,
isFetching: true,
}),
).toBe(true);
expect(
isReadFailureExhausted({
error: null,
isError: false,
isFetching: false,
}),
).toBe(false);
});
it.each([
"INVALID_TBANK_TOKEN",
"NOT_FOUND",
"TBANK_NOT_CONNECTED",
"UNAUTHORIZED",
])("is false for %s, which has its own action", (code) => {
expect(isReadFailureExhausted({ ...settled, error: { code } })).toBe(false);
});
});
describe("readRetry", () => {
it("refetches only the failed reads and reports a running retry", () => {
const failedRefetch = vi.fn(() => Promise.resolve());
const healthyRefetch = vi.fn(() => Promise.resolve());
const retry = readRetry([
{ ...settled, error: transient, refetch: failedRefetch },
{
error: null,
isError: false,
isFetching: true,
refetch: healthyRefetch,
},
{ ...settled, error: { code: "NOT_FOUND" }, refetch: healthyRefetch },
]);
expect(retry).toMatchObject({ exhausted: true, retrying: false });
retry.onRetry();
expect(failedRefetch).toHaveBeenCalledOnce();
expect(healthyRefetch).not.toHaveBeenCalled();
});
it("is not exhausted while every read is healthy", () => {
expect(
readRetry([
{
error: null,
isError: false,
isFetching: false,
refetch: vi.fn(() => Promise.resolve()),
},
]).exhausted,
).toBe(false);
});
});
+37
View File
@@ -0,0 +1,37 @@
import {
ApiErrorCode,
apiErrorCode,
isApiErrorCode,
isBrokerageAccessLoss,
} from "./problem-details";
type ReadState = { error: unknown; isError: boolean; isFetching: boolean };
type RetryableRead = ReadState & { refetch: () => Promise<unknown> };
// Automatic retries run inside one fetchAppRead call and Query's own retry is
// off, so a query in the error state has exhausted them. The state stays
// "error" while a manual retry runs, which keeps the warning in place. Access
// loss and a lost session keep their own actions and never offer a plain retry.
function isReadFailureExhausted(state: ReadState): boolean {
if (!state.isError) return false;
const code = apiErrorCode(state.error);
return !isBrokerageAccessLoss(code)
&& !isApiErrorCode(code, ApiErrorCode.Unauthorized);
}
// Retry repeats only the GET of the failed queries, never a source sync.
function readRetry(reads: RetryableRead[]) {
const failed = reads.filter(read => isReadFailureExhausted(read));
return {
exhausted: failed.length > 0,
onRetry: () => {
void Promise.allSettled(failed.map(read => read.refetch()));
},
retrying: failed.some(read => read.isFetching),
};
}
export { isReadFailureExhausted, readRetry };
+2 -11
View File
@@ -10,7 +10,7 @@ import type { components, paths } from "./generated/schema";
import { useAuthenticatedQuery } from "./authenticated-query"; import { useAuthenticatedQuery } from "./authenticated-query";
import { getAuthenticatedSessionSnapshot } from "./client"; import { getAuthenticatedSessionSnapshot } from "./client";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details"; import { apiErrorCode, isBrokerageAccessLoss } from "./problem-details";
type InvestmentResourceMeta = components["schemas"]["InvestmentResourceMetaDto"]; type InvestmentResourceMeta = components["schemas"]["InvestmentResourceMetaDto"];
@@ -39,16 +39,7 @@ function invalidateResourceAccess(
group: string, group: string,
error: unknown, error: unknown,
) { ) {
const errorCode = apiErrorCode(error); if (!isBrokerageAccessLoss(apiErrorCode(error))) return;
if (
[
ApiErrorCode.Forbidden,
ApiErrorCode.InvalidTbankToken,
ApiErrorCode.NotFound,
ApiErrorCode.TbankNotConnected,
].every(code => !isApiErrorCode(errorCode, code))
)
return;
const affected = { const affected = {
predicate: (cached: { meta?: Record<string, unknown> }) => predicate: (cached: { meta?: Record<string, unknown> }) =>
+270 -101
View File
@@ -5204,7 +5204,44 @@
}, },
"code": { "code": {
"type": "string", "type": "string",
"example": "UNAUTHORIZED" "example": "UNAUTHORIZED",
"enum": [
"BAD_REQUEST",
"INTERNAL_SERVER_ERROR",
"NOT_FOUND",
"UNAUTHORIZED",
"UNSUPPORTED_MEDIA_TYPE",
"VALIDATION_ERROR",
"EMAIL_ALREADY_REGISTERED",
"INVALID_CREDENTIALS",
"INVALID_OPERATIONS_CURSOR",
"BOND_HISTORY_START_UNAVAILABLE",
"BOND_TICKER_AMBIGUOUS",
"STOCK_HISTORY_START_UNAVAILABLE",
"STOCK_TICKER_AMBIGUOUS",
"INVALID_TBANK_TOKEN",
"TBANK_NOT_CONNECTED",
"TBANK_UNAVAILABLE",
"BANK_CARD_ARCHIVED",
"BANK_DEPENDENT_REFUNDS",
"BANK_DUPLICATE_CATEGORY",
"BANK_IMMUTABLE_OPERATION",
"BANK_INTENT_CONFLICT",
"BANK_INVALID_DATE",
"BANK_INVALID_INPUT",
"BANK_INVALID_MONEY",
"BANK_INVALID_REFUND",
"BANK_NOT_FOUND",
"BANK_OPENING_DATE_CONFLICT",
"BANK_OPERATION_DELETED",
"BANK_REFUND_LIMIT",
"BANK_VERSION_CONFLICT",
"BANK_WRITE_CONFLICT",
"MOEX_UNAVAILABLE",
"NCR_UNAVAILABLE",
"INVALID_EVENTS_PERIOD",
"INVALID_INCOME_PERIOD"
]
}, },
"errors": { "errors": {
"type": "array", "type": "array",
@@ -5819,6 +5856,135 @@
"version" "version"
] ]
}, },
"PortfolioStructureCategoryDto": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"STOCK",
"BOND",
"OTHER",
"CASH"
]
},
"value": {
"type": "string",
"description": "Exact known value as a decimal string."
},
"sharePercent": {
"type": "string",
"nullable": true,
"description": "Percentage including cash, rounded to six decimal places. Null for incomplete, negative or zero-total compositions."
}
},
"required": [
"type",
"value",
"sharePercent"
]
},
"PortfolioStructureCurrencyDto": {
"type": "object",
"properties": {
"currency": {
"type": "string"
},
"totalValue": {
"type": "string"
},
"hasNegativeValues": {
"type": "boolean",
"description": "Any negative constituent, even if netted positive."
},
"categories": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PortfolioStructureCategoryDto"
}
}
},
"required": [
"currency",
"totalValue",
"hasNegativeValues",
"categories"
]
},
"PortfolioUnvaluedCategoryDto": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"STOCK",
"BOND",
"OTHER"
]
},
"count": {
"type": "number",
"minimum": 1
}
},
"required": [
"type",
"count"
]
},
"PortfolioStructureDto": {
"type": "object",
"properties": {
"currencies": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PortfolioStructureCurrencyDto"
}
},
"unvaluedCategories": {
"description": "Positions without current value and known valuation currency. Shares are unavailable across all currencies when nonempty.",
"type": "array",
"items": {
"$ref": "#/components/schemas/PortfolioUnvaluedCategoryDto"
}
}
},
"required": [
"currencies",
"unvaluedCategories"
]
},
"UnrealizedReturnDto": {
"type": "object",
"properties": {
"currency": {
"type": "string"
},
"absolute": {
"type": "string",
"example": "12.345678901"
},
"costBasis": {
"type": "string",
"example": "100.00"
},
"percent": {
"type": "string",
"nullable": true,
"example": "12.34"
},
"partial": {
"type": "boolean"
}
},
"required": [
"currency",
"absolute",
"costBasis",
"percent",
"partial"
]
},
"PortfolioSummaryDto": { "PortfolioSummaryDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -5828,6 +5994,21 @@
"$ref": "#/components/schemas/MoneyDto" "$ref": "#/components/schemas/MoneyDto"
} }
}, },
"cashBalances": {
"type": "array",
"items": {
"$ref": "#/components/schemas/MoneyDto"
}
},
"structure": {
"$ref": "#/components/schemas/PortfolioStructureDto"
},
"unrealizedReturnByCurrency": {
"type": "array",
"items": {
"$ref": "#/components/schemas/UnrealizedReturnDto"
}
},
"stockPositionCount": { "stockPositionCount": {
"type": "integer", "type": "integer",
"minimum": 0, "minimum": 0,
@@ -5837,12 +6018,27 @@
"type": "integer", "type": "integer",
"minimum": 0, "minimum": 0,
"description": "Number of non-zero bond positions, not the number of units." "description": "Number of non-zero bond positions, not the number of units."
},
"fundPositionCount": {
"type": "integer",
"minimum": 0,
"description": "Number of non-zero fund positions, not the number of units."
},
"totalPositionCount": {
"type": "integer",
"minimum": 0,
"description": "Number of all non-zero positions, not the number of units."
} }
}, },
"required": [ "required": [
"totalsByCurrency", "totalsByCurrency",
"cashBalances",
"structure",
"unrealizedReturnByCurrency",
"stockPositionCount", "stockPositionCount",
"bondPositionCount" "bondPositionCount",
"fundPositionCount",
"totalPositionCount"
] ]
}, },
"BrokerageAccountDto": { "BrokerageAccountDto": {
@@ -6227,6 +6423,21 @@
"name" "name"
] ]
}, },
"PortfolioIssuerDto": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": [
"id",
"name"
]
},
"PortfolioPositionDto": { "PortfolioPositionDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -6299,6 +6510,19 @@
"logoName": { "logoName": {
"type": "string", "type": "string",
"nullable": true "nullable": true
},
"sector": {
"type": "string",
"nullable": true
},
"issuer": {
"nullable": true,
"type": "object",
"allOf": [
{
"$ref": "#/components/schemas/PortfolioIssuerDto"
}
]
} }
}, },
"required": [ "required": [
@@ -6311,105 +6535,9 @@
"absoluteReturn", "absoluteReturn",
"isin", "isin",
"ticker", "ticker",
"logoName" "logoName",
] "sector",
}, "issuer"
"PortfolioStructureCategoryDto": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"STOCK",
"BOND",
"OTHER",
"CASH"
]
},
"value": {
"type": "string",
"description": "Exact known value as a decimal string."
},
"sharePercent": {
"type": "string",
"nullable": true,
"description": "Percentage including cash, rounded to six decimal places. Null for incomplete, negative or zero-total compositions."
}
},
"required": [
"type",
"value",
"sharePercent"
]
},
"PortfolioStructureCurrencyDto": {
"type": "object",
"properties": {
"currency": {
"type": "string"
},
"totalValue": {
"type": "string"
},
"hasNegativeValues": {
"type": "boolean",
"description": "Any negative constituent, even if netted positive."
},
"categories": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PortfolioStructureCategoryDto"
}
}
},
"required": [
"currency",
"totalValue",
"hasNegativeValues",
"categories"
]
},
"PortfolioUnvaluedCategoryDto": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"STOCK",
"BOND",
"OTHER"
]
},
"count": {
"type": "number",
"minimum": 1
}
},
"required": [
"type",
"count"
]
},
"PortfolioStructureDto": {
"type": "object",
"properties": {
"currencies": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PortfolioStructureCurrencyDto"
}
},
"unvaluedCategories": {
"description": "Positions without current value and known valuation currency. Shares are unavailable across all currencies when nonempty.",
"type": "array",
"items": {
"$ref": "#/components/schemas/PortfolioUnvaluedCategoryDto"
}
}
},
"required": [
"currencies",
"unvaluedCategories"
] ]
}, },
"PortfolioDto": { "PortfolioDto": {
@@ -7115,6 +7243,23 @@
"meta" "meta"
] ]
}, },
"UpcomingEventsWindowDto": {
"type": "object",
"properties": {
"from": {
"type": "string",
"format": "date"
},
"to": {
"type": "string",
"format": "date"
}
},
"required": [
"from",
"to"
]
},
"UpcomingEventDto": { "UpcomingEventDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -7196,6 +7341,14 @@
"type": "string", "type": "string",
"format": "date-time" "format": "date-time"
}, },
"window": {
"description": "Inclusive UTC window the saved forecast was calculated for. Unchanged by the requested from/to filter.",
"allOf": [
{
"$ref": "#/components/schemas/UpcomingEventsWindowDto"
}
]
},
"events": { "events": {
"type": "array", "type": "array",
"items": { "items": {
@@ -7212,6 +7365,7 @@
"required": [ "required": [
"accountId", "accountId",
"calculatedAt", "calculatedAt",
"window",
"events", "events",
"incomeTotalsByCurrency" "incomeTotalsByCurrency"
] ]
@@ -7812,6 +7966,19 @@
"type": "string", "type": "string",
"nullable": true "nullable": true
}, },
"sector": {
"type": "string",
"nullable": true
},
"issuer": {
"nullable": true,
"type": "object",
"allOf": [
{
"$ref": "#/components/schemas/PortfolioIssuerDto"
}
]
},
"returnPercent": { "returnPercent": {
"type": "string", "type": "string",
"nullable": true "nullable": true
@@ -7828,6 +7995,8 @@
"isin", "isin",
"ticker", "ticker",
"logoName", "logoName",
"sector",
"issuer",
"returnPercent" "returnPercent"
] ]
}, },
+78 -1
View File
@@ -5,7 +5,20 @@ const ru = {
all: "Все счета", all: "Все счета",
allDescription: "Портфель по всем доступным инвестиционным счетам", allDescription: "Портфель по всем доступным инвестиционным счетам",
bonds: "Облигации", bonds: "Облигации",
brokerDescription: "По данным брокера",
cash: "Деньги на счёте",
count_few: "{{count}} счёта",
count_many: "{{count}} счетов",
count_one: "{{count}} счёт",
count_other: "{{count}} счёта",
currentValue: "Текущая стоимость", currentValue: "Текущая стоимость",
description: {
currentValue: "Текущая стоимость: {{value}}. ",
return: "Доходность {{currency}}: {{percent}}, {{absolute}}",
start: "Счёт {{name}}, {{type}}. ",
structure: "Структура {{currency}}: {{value}}",
structureCategory: "{{category}} {{value}}",
},
emptyText: emptyText:
"Откройте инвестиционный счет в T-Bank, чтобы он появился здесь.", "Откройте инвестиционный счет в T-Bank, чтобы он появился здесь.",
emptyTitle: "Нет инвестиционных счетов", emptyTitle: "Нет инвестиционных счетов",
@@ -18,6 +31,11 @@ const ru = {
openAll: "Портфель по всем счетам", openAll: "Портфель по всем счетам",
openedOn: "Открыт {{date}}", openedOn: "Открыт {{date}}",
openSettings: "Открыть настройки T-Bank", openSettings: "Открыть настройки T-Bank",
partial: "частично",
positions_few: "{{count}} позиции",
positions_many: "{{count}} позиций",
positions_one: "{{count}} позиция",
positions_other: "{{count}} позиции",
refreshError: refreshError:
"Не удалось обновить данные. Показаны последние полученные.", "Не удалось обновить данные. Показаны последние полученные.",
refreshing: "Обновляем данные", refreshing: "Обновляем данные",
@@ -28,8 +46,22 @@ const ru = {
retry: "Повторить", retry: "Повторить",
scope: "Счёт", scope: "Счёт",
stocks: "Акции", stocks: "Акции",
structure: {
category: "{{category}} {{percent}} %",
incomplete: "{{category}}: доля неизвестна",
label: "Структура в {{currency}}: {{value}}",
},
subtitle: "Ваши инвестиции по счетам", subtitle: "Ваши инвестиции по счетам",
summaryEmpty: "Нет текущих сумм", summaryEmpty: "Нет текущих сумм",
summaryExcluded_few:
"Ещё {{count}} счёта не включены: нет актуального портфеля",
summaryExcluded_many:
"Ещё {{count}} счетов не включены: нет актуального портфеля",
summaryExcluded_one:
"Ещё {{count}} счёт не включён: нет актуального портфеля",
summaryExcluded_other:
"Ещё {{count}} счёта не включены: нет актуального портфеля",
summaryNoTotals: "Нет данных для сводки",
summaryUnavailable: "Сводка временно недоступна", summaryUnavailable: "Сводка временно недоступна",
title: "Счета", title: "Счета",
types: { types: {
@@ -318,6 +350,9 @@ const ru = {
operations: "Операции", operations: "Операции",
settings: "Настройки", settings: "Настройки",
}, },
entryGroup: {
totalUnavailable: "Итог недоступен",
},
events: { events: {
dateKind: { event: "Дата события", registry: "Дата реестра" }, dateKind: { event: "Дата события", registry: "Дата реестра" },
emptyText: emptyText:
@@ -386,7 +421,9 @@ const ru = {
}, },
history: "История догружается", history: "История догружается",
partial: "Период загружен не полностью", partial: "Период загружен не полностью",
readFailed: "Не удалось обновить данные",
refresh: "Обновить", refresh: "Обновить",
retry: "Повторить",
sourceTime: "Данные на {{date}}", sourceTime: "Данные на {{date}}",
stale: "Данные требуют обновления", stale: "Данные требуют обновления",
syncFailed: syncFailed:
@@ -420,10 +457,11 @@ const ru = {
next: "Далее", next: "Далее",
noInstrument: "Без инструмента", noInstrument: "Без инструмента",
noMoney: "Без движения денег", noMoney: "Без движения денег",
occurredAt: "{{date}}", occurredAt: "Исполнено {{date}}",
open: "Открыть журнал", open: "Открыть журнал",
operationType: { operationType: {
buy: "Покупка", buy: "Покупка",
coupon: "Купон",
dividend: "Зачисление дивидендов", dividend: "Зачисление дивидендов",
fee: "Комиссия", fee: "Комиссия",
input: "Зачисление ценных бумаг", input: "Зачисление ценных бумаг",
@@ -450,6 +488,7 @@ const ru = {
accounts: "Счета", accounts: "Счета",
bonds: "Облигации", bonds: "Облигации",
cards: "Карты", cards: "Карты",
primary: "Основная навигация",
settings: "Настройки", settings: "Настройки",
stocks: "Акции", stocks: "Акции",
}, },
@@ -472,6 +511,26 @@ const ru = {
bonds: "Облигации", bonds: "Облигации",
byAccount: "Позиции по счетам", byAccount: "Позиции по счетам",
cashBalances: "Денежные остатки", cashBalances: "Денежные остатки",
concentration: {
chart: "Распределение позиций",
currency: "Валюта концентрации",
empty: "В этом разрезе нет позиций с положительной оценкой.",
incomplete: "Данные неполные",
issuers: "Эмитенты",
missingIssuer: "Эмитент не указан",
missingSector: "Сектор не указан",
other: "Прочее",
otherCount: "{{count}} элементов",
otherCount_few: "{{count}} элемента",
otherCount_many: "{{count}} элементов",
otherCount_one: "{{count}} элемент",
otherCount_other: "{{count}} элемента",
sectors: "Секторы",
slice: "Разрез концентрации",
title: "Концентрация",
top5: "Топ-5 · {{share}}",
unaccounted: "Не учтено позиций: {{count}}",
},
connectionErrorText: "Настройте подключение, чтобы посмотреть портфель.", connectionErrorText: "Настройте подключение, чтобы посмотреть портфель.",
connectionErrorTitle: "Проверьте подключение T-Bank", connectionErrorTitle: "Проверьте подключение T-Bank",
currencies: { currencies: {
@@ -481,13 +540,21 @@ const ru = {
}, },
emptyText: "На счете нет позиций и доступных денежных остатков.", emptyText: "На счете нет позиций и доступных денежных остатков.",
emptyTitle: "Портфель пока пуст", emptyTitle: "Портфель пока пуст",
more: "Подробнее",
otherPositions: "Другие позиции", otherPositions: "Другие позиции",
positionFilter: "Тип позиции",
positionKinds: {
bonds: "Облигации",
other: "Прочее",
stocks: "Акции",
},
positions: "Позиции", positions: "Позиции",
quantity: "Количество: {{value}}", quantity: "Количество: {{value}}",
refreshError: refreshError:
"Не удалось обновить портфель. Показан последний полученный снимок.", "Не удалось обновить портфель. Показан последний полученный снимок.",
refreshing: "Обновляем портфель", refreshing: "Обновляем портфель",
return: "Доход: {{value}}", return: "Доход: {{value}}",
sections: "Разделы счёта",
share: "Доля: {{value}}", share: "Доля: {{value}}",
staleWarning: staleWarning:
"T-Bank временно недоступен. Показан последний успешный снимок.", "T-Bank временно недоступен. Показан последний успешный снимок.",
@@ -513,7 +580,16 @@ const ru = {
unvalued: "Нет оценки", unvalued: "Нет оценки",
unvaluedCategory: "{{category}}: без оценки — {{count}}", unvaluedCategory: "{{category}}: без оценки — {{count}}",
}, },
tabs: {
cash: "Деньги",
events: "События",
income: "Доход",
operations: "Операции",
positions: "Позиции",
},
title: "Портфель", title: "Портфель",
unrealizedResult: "Нереализованный результат",
unrealizedResultCaption: "по цене, без НКД и купонов",
updatedAt: "Данные получены {{date}}", updatedAt: "Данные получены {{date}}",
}, },
routeError: { routeError: {
@@ -706,6 +782,7 @@ const ru = {
consumer: "Потребительский сектор", consumer: "Потребительский сектор",
energy: "Энергетика", energy: "Энергетика",
financial: "Финансы", financial: "Финансы",
government: "Государственный сектор",
health_care: "Здравоохранение", health_care: "Здравоохранение",
industrials: "Промышленность", industrials: "Промышленность",
it: "Информационные технологии", it: "Информационные технологии",
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { sumMoney } from "./decimal";
describe("sumMoney", () => {
it("adds exact signed decimals independently per currency", () => {
expect(sumMoney([
{ currency: "RUB", value: "0.1" },
{ currency: "USD", value: "0.01" },
{ currency: "RUB", value: "0.2" },
{ currency: "RUB", value: "-1500000.45" },
{ currency: "USD", value: "-0.02" },
{ currency: "RUB", value: "200" },
])).toEqual([
{ currency: "RUB", value: "-1499800.15" },
{ currency: "USD", value: "-0.01" },
]);
});
it("preserves significant fractional digits and a compensated zero", () => {
expect(sumMoney([
{ currency: "RUB", value: "100.5" },
{ currency: "RUB", value: "49.50" },
{ currency: "RUB", value: "0.005" },
])).toEqual([{ currency: "RUB", value: "150.005" }]);
expect(sumMoney([
{ currency: "RUB", value: "100" },
{ currency: "RUB", value: "-100" },
])).toEqual([{ currency: "RUB", value: "0" }]);
});
it.each(["", ".", "1.2.3", "not-a-number"])(
"rejects malformed amount %s instead of treating it as zero",
(value) => {
expect(sumMoney([
{ currency: "RUB", value: "100" },
{ currency: "RUB", value },
])).toBeNull();
},
);
});
+75
View File
@@ -0,0 +1,75 @@
type Decimal = { scale: number; value: bigint };
type Money = { currency: string; value: string };
const DECIMAL_PARTS = 2;
const RADIX = 10n;
// Decimal grammar is bounded to digits and one optional dot.
// eslint-disable-next-line security/detect-unsafe-regex
const decimalPattern = /^-?\d+(?:\.\d+)?$/;
function decimalFromString(value: string): Decimal {
if (!decimalPattern.test(value)) throw new SyntaxError("Invalid 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 sumDecimals(decimals: readonly Decimal[]): Decimal {
const scale = Math.max(...decimals.map(decimal => decimal.scale));
const value = decimals.reduce(
(sum, decimal) =>
sum + decimal.value * RADIX ** BigInt(scale - decimal.scale),
0n,
);
return { scale, value };
}
function sumMoney(amounts: readonly Money[]): Money[] | null {
const decimals = new Map<string, Decimal[]>();
for (const amount of amounts) {
try {
const values = decimals.get(amount.currency) ?? [];
values.push(decimalFromString(amount.value));
decimals.set(amount.currency, values);
}
catch {
return null;
}
}
return Array.from(decimals, ([currency, values]) => ({
currency,
value: decimalToString(sumDecimals(values)),
}));
}
export {
type Decimal,
decimalFromString,
decimalToString,
sumDecimals,
sumMoney,
};
+27
View File
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import { import {
formatDate, formatDate,
formatMoney, formatMoney,
formatSnapshotDay,
formatSnapshotTime, formatSnapshotTime,
instrumentLogoUrl, instrumentLogoUrl,
} from "./display"; } from "./display";
@@ -22,6 +23,10 @@ describe("shared display helpers", () => {
}).format(new Date(instant)); }).format(new Date(instant));
expect(formatSnapshotTime(instant)).toBe(expected); expect(formatSnapshotTime(instant)).toBe(expected);
expect(formatSnapshotDay(instant)).toBe(
new Intl.DateTimeFormat("ru-RU", { dateStyle: "short" })
.format(new Date(instant)),
);
}); });
it("formats decimal strings without financial arithmetic", () => { it("formats decimal strings without financial arithmetic", () => {
expect(formatMoney({ currency: "RUB", value: "-1234567.89" })).toBe( expect(formatMoney({ currency: "RUB", value: "-1234567.89" })).toBe(
@@ -42,6 +47,28 @@ describe("shared display helpers", () => {
formatMoney({ currency: "RUB", value: "1.5" }, { withZeroCents: true }), formatMoney({ currency: "RUB", value: "1.5" }, { withZeroCents: true }),
).toBe("1,50 ₽"); ).toBe("1,50 ₽");
expect(formatMoney(null)).toBe("—"); expect(formatMoney(null)).toBe("—");
expect(formatMoney(
{ currency: "RUB", value: "136900" },
{ withSign: true },
)).toBe("+136 900 ₽");
expect(formatMoney(
{ currency: "RUB", value: "-4200" },
{ withSign: true },
)).toBe("−4 200 ₽");
expect(formatMoney(
{ currency: "RUB", value: "-0.00" },
{ withSign: true },
)).toBe("0,00 ₽");
});
it("rounds to the currency's minor units and drops the sign of a rounded zero", () => {
const rounded = { withRoundedCents: true };
expect(formatMoney({ currency: "RUB", value: "-0.004" }, rounded)).toBe("0,00 ₽");
expect(formatMoney({ currency: "RUB", value: "-0.005" }, rounded)).toBe("-0,01 ₽");
expect(formatMoney({ currency: "RUB", value: "999.995" }, rounded)).toBe("1 000,00 ₽");
expect(formatMoney({ currency: "JPY", value: "1500.5" }, rounded)).toBe("1 501 JPY");
expect(formatMoney({ currency: "KWD", value: "1.2345" }, rounded)).toBe("1,235 KWD");
expect(formatMoney({ currency: "ZZZ", value: "1.234" }, rounded)).toBe("1,23 ZZZ");
}); });
it("builds the brand logo url from an upstream file name", () => { it("builds the brand logo url from an upstream file name", () => {
+84 -6
View File
@@ -1,10 +1,19 @@
const DECIMAL_PART_COUNT = 2; const DECIMAL_PART_COUNT = 2;
const CENTS_PRECISION = 2; const DEFAULT_MINOR_UNITS = 2;
type FormatMoneyOptions = { type FormatMoneyOptions = {
withRoundedCents?: boolean;
withSign?: boolean;
withZeroCents?: boolean; withZeroCents?: boolean;
}; };
const portfolioCategoryPresentation = {
BOND: { color: "grape.6", label: "portfolio.bonds" },
CASH: { color: "green.6", label: "portfolio.structure.cash" },
OTHER: { color: "orange.6", label: "portfolio.structure.other" },
STOCK: { color: "blue.6", label: "portfolio.stocks" },
} as const;
type Money = { currency: string; value: string }; type Money = { currency: string; value: string };
const dateFormatter = new Intl.DateTimeFormat("ru-RU", { const dateFormatter = new Intl.DateTimeFormat("ru-RU", {
@@ -14,6 +23,17 @@ const dateFormatter = new Intl.DateTimeFormat("ru-RU", {
year: "numeric", year: "numeric",
}); });
// ISO 4217 minor units (JPY 0, KWD 3); unknown codes fall back to 2.
function currencyMinorUnits(currency: string): number {
try {
return new Intl.NumberFormat("en", { currency, style: "currency" })
.resolvedOptions().maximumFractionDigits ?? DEFAULT_MINOR_UNITS;
}
catch {
return DEFAULT_MINOR_UNITS;
}
}
function formatDate(value: string): string { function formatDate(value: string): string {
return dateFormatter.format(new Date(value)); return dateFormatter.format(new Date(value));
} }
@@ -39,17 +59,27 @@ function formatMoney(
money: Money money: Money
| null | null
| undefined, | undefined,
{ withZeroCents = false }: FormatMoneyOptions = {}, {
withRoundedCents = false,
withSign = false,
withZeroCents = false,
}: FormatMoneyOptions = {},
): string { ): string {
if (!money) return "—"; if (!money) return "—";
const [integerPart = "", fractionPart = ""] = money.value.split( const minorUnits = currencyMinorUnits(money.currency);
const value = withRoundedCents
? roundDecimal(money.value, minorUnits)
: money.value;
const [integerPart = "", fractionPart = ""] = value.split(
".", ".",
DECIMAL_PART_COUNT, DECIMAL_PART_COUNT,
); );
const normalizedFraction = withZeroCents const normalizedFraction = withRoundedCents || withZeroCents
? fractionPart.padEnd(CENTS_PRECISION, "0") ? fractionPart.padEnd(minorUnits, "0")
: fractionPart; : fractionPart;
const decimal = normalizedFraction const decimal = normalizedFraction
@@ -57,8 +87,46 @@ function formatMoney(
: integerPart; : integerPart;
const currency = money.currency === "RUB" ? "₽" : money.currency; const currency = money.currency === "RUB" ? "₽" : money.currency;
const formatted = `${formatDecimal(decimal)} ${currency}`;
return `${formatDecimal(decimal)} ${currency}`; if (!withSign) return formatted;
if (isZeroDecimal(value))
return value.startsWith("-") ? formatted.slice(1) : formatted;
return value.startsWith("-") ? `−${formatted.slice(1)}` : `+${formatted}`;
}
function isZeroDecimal(value: string) {
const unsigned = value.startsWith("-") ? value.slice(1) : value;
const [integer = "", fraction = ""] = unsigned.split(
".",
DECIMAL_PART_COUNT,
);
return integer.replaceAll("0", "") === ""
&& fraction.replaceAll("0", "") === "";
}
function roundDecimal(value: string, places: number): string {
const [integer = "", fraction = ""] = value.split(".", DECIMAL_PART_COUNT);
const digits = integer.startsWith("-") ? integer.slice(1) : integer;
const paddedFraction = fraction.padEnd(places + 1, "0");
const units = `${digits}${paddedFraction.slice(0, places)}`;
const roundingDigit = paddedFraction.at(places) ?? "0";
const rounded = (roundingDigit >= "5" ? BigInt(units) + 1n : BigInt(units))
.toString()
.padStart(places + 1, "0");
// A value rounded to zero has no sign: "-0.004" → "0.00".
const sign = integer.startsWith("-") && /[1-9]/.test(rounded) ? "-" : "";
const roundedInteger = rounded.slice(0, rounded.length - places);
const roundedFraction = rounded.slice(rounded.length - places);
return roundedFraction
? `${sign}${roundedInteger}.${roundedFraction}`
: `${sign}${roundedInteger}`;
} }
// Без timeZone Intl берёт часовой пояс браузера пользователя. // Без timeZone Intl берёт часовой пояс браузера пользователя.
@@ -67,6 +135,14 @@ const snapshotFormatter = new Intl.DateTimeFormat("ru-RU", {
timeStyle: "short", timeStyle: "short",
}); });
const snapshotDayFormatter = new Intl.DateTimeFormat("ru-RU", {
dateStyle: "short",
});
function formatSnapshotDay(value: string): string {
return snapshotDayFormatter.format(new Date(value));
}
function formatSnapshotTime(value: string): string { function formatSnapshotTime(value: string): string {
return snapshotFormatter.format(new Date(value)); return snapshotFormatter.format(new Date(value));
} }
@@ -92,6 +168,8 @@ export {
formatDate, formatDate,
formatDecimal, formatDecimal,
formatMoney, formatMoney,
formatSnapshotDay,
formatSnapshotTime, formatSnapshotTime,
instrumentLogoUrl, instrumentLogoUrl,
portfolioCategoryPresentation,
}; };
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { groupItems } from "./group-items";
describe("groupItems", () => {
it("keeps the first key position and source order through interleaving", () => {
const a1 = { id: "A1", key: "A" };
const b = { id: "B", key: null };
const a2 = { id: "A2", key: "A" };
const c = { id: "C", key: "C" };
const a3 = { id: "A3", key: "A" };
const input = [a1, b, a2, c, a3];
const result = groupItems(input, item => item.key);
expect(result).toEqual([
{ items: [a1, a2, a3], key: "A", type: "group" },
{ item: b, type: "single" },
{ item: c, type: "single" },
]);
expect(input.map(item => item.id)).toEqual(["A1", "B", "A2", "C", "A3"]);
});
it("keeps two interleaved groups independent and singleton rows single", () => {
const result = groupItems(
[
{ id: "X1", key: "X" },
{ id: "Y1", key: "Y" },
{ id: "X2", key: "X" },
{ id: "Y2", key: "Y" },
{ id: "X3", key: "X" },
{ id: "Z1", key: "Z" },
],
item => item.key,
);
expect(result.map(entry => entry.type === "group"
? [entry.key, entry.items.map(item => item.id)]
: [null, [entry.item.id]])).toEqual([
["X", ["X1", "X2", "X3"]],
["Y", ["Y1", "Y2"]],
[null, ["Z1"]],
]);
});
});

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