2 Commits
Author SHA1 Message Date
ksv741 3ce67a59a5 fix(account): align concentration labels and chart accessibility (WealthLedger/main#48)
Frontend verify / commits (pull_request) Successful in 2m3s
Frontend verify / security (pull_request) Failing after 1m40s
Frontend verify / verify (pull_request) Skipped
2026-11-01 15:50:18 +03:00
ksv741 ba7f8547bb feat(account): add concentration tab with top-5 slices and chart (WealthLedger/main#48)
Frontend verify / commits (pull_request) Successful in 2m10s
Frontend verify / security (pull_request) Failing after 1m46s
Frontend verify / verify (pull_request) Skipped
2026-11-01 12:24:38 +03:00
97 changed files with 922 additions and 2094 deletions
+7 -6
View File
@@ -8,7 +8,7 @@ Vitest — отдельную JSDOM конфигурацию. Точные versi
[package.json](../package.json), реализация — source of truth. [package.json](../package.json), реализация — source of truth.
```text ```text
app → pages → features → entities → shared app → pages → widgets → features → entities → shared
page container → page VM → entity/feature query → shared/api → backend /api/v1 page container → page VM → entity/feature query → shared/api → backend /api/v1
↓ ↓
props → state/section views props → state/section views
@@ -30,13 +30,14 @@ contract; frontend не читает его checkout для генерации
| --- | --- | | --- | --- |
| Bootstrap, глобальные providers, routes | `app` | | Bootstrap, глобальные providers, routes | `app` |
| Композиция маршрута и поведение, нужное только этому экрану | `pages/<page>` | | Композиция маршрута и поведение, нужное только этому экрану | `pages/<page>` |
| Крупная переиспользуемая композиция экрана | `widgets/<widget>` |
| Самостоятельная пользовательская возможность с собственным смыслом | `features/<capability>` | | Самостоятельная пользовательская возможность с собственным смыслом | `features/<capability>` |
| Представление/поведение бизнес-сущности | `entities/<entity>` | | Представление/поведение бизнес-сущности | `entities/<entity>` |
| Переиспользуемый код без WealthLedger domain meaning | `shared` | | Переиспользуемый код без WealthLedger domain meaning | `shared` |
Локальный FSD profile: `app → pages → features → entities → shared`. Локальный FSD profile: `app → pages → widgets → features → entities → shared`.
Слой `widgets` не используется: композиция экрана и компонент для одной page Widget собирает несколько нижних слоёв в самостоятельный UI-блок; компонент для
остаются у page. Form, mutation или click handler сами по себе не одной page остаётся у page. Form, mutation или click handler сами по себе не
требуют feature. Два callers не делают domain helper generic shared. требуют feature. Два callers не делают domain helper generic shared.
Slice именуется по ответственности, а не `helpers`, `hooks` или `types`. Slice именуется по ответственности, а не `helpers`, `hooks` или `types`.
@@ -48,12 +49,12 @@ Slice именуется по ответственности, а не `helpers`,
Cross-slice consumer использует `@/` и `index.ts` владельца, например Cross-slice consumer использует `@/` и `index.ts` владельца, например
`@/entities/portfolio`. Alias Vite и TypeScript согласован. Другой slice не `@/entities/portfolio`. Alias Vite и TypeScript согласован. Другой slice не
импортирует внутренний файл напрямую. Slices `pages`, `features`, `entities` не зависят от соседнего slice своего слоя; импортирует внутренний файл напрямую. Slices `pages`, `widgets`, `features`, `entities` не зависят от соседнего slice своего слоя;
композиция принадлежит вышележащему owner. Existing меж-slice исключения композиция принадлежит вышележащему owner. Existing меж-slice исключения
не являются разрешением добавлять новые. не являются разрешением добавлять новые.
Направление зависимостей, public imports и запрет соседних slices в Направление зависимостей, public imports и запрет соседних slices в
`pages`, `features`, `entities` проверяет `npm run deps`. Внутренние imports `pages`, `widgets`, `features`, `entities` проверяет `npm run deps`. Внутренние imports
одного slice и связи shared segments этот запрет не ограничивает. одного slice и связи shared segments этот запрет не ограничивает.
Knip проверяет использование exports, а не смысл placement. Knip проверяет использование exports, а не смысл placement.
-3
View File
@@ -13,9 +13,6 @@
!public/** !public/**
!docker/ !docker/
!docker/** !docker/**
!scripts/
!scripts/minimatch-compat/
!scripts/minimatch-compat/**
!packages/ !packages/
!packages/tokens/ !packages/tokens/
!packages/tokens/package.json !packages/tokens/package.json
-56
View File
@@ -1,61 +1,5 @@
# Changelog # Changelog
## [1.4.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.3.0...v1.4.0) (2026-11-06)
### Features
* **investments:** warn and offer retry after exhausted read retries (WealthLedger/frontend[#55](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/55)) ([#136](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/136)) ([8ce1e8e](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/8ce1e8e44486dcc23d4c074559ca2bb517880b60))
### Bug Fixes
* **account:** show source metadata on empty account and income (WealthLedger/frontend[#55](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/55)) ([#133](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/133)) ([9619dc9](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/9619dc979172dbc16391206e801ed67862d20116))
* align audit findings for Events, currency and auth (WealthLedger/frontend[#155](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/155)) ([668f2ef](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/668f2ef70e535a1222e7f600b1be4a31fefc6f8b))
### Refactoring
* **api:** consume bank operation oneOf contract (WealthLedger/frontend[#144](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/144)) ([#152](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/152)) ([2d070ce](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/2d070ce2b54bbee466ecf6cc18b437910f0c07f8))
* drop dead query helpers and simplify read deadline (WealthLedger/frontend[#141](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/141)) ([#149](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/149)) ([5077bd4](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/5077bd4f0baa4a59643e875485c2f08253cc198d))
* move portfolio structure into account page, drop widgets (WealthLedger/frontend[#139](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/139)) ([#140](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/140)) ([e2d143d](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/e2d143df46e650139e49b7c80a886008ede86d2a))
* normalize view models and share breadcrumbs and titles (WealthLedger/frontend[#143](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/143)) ([#153](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/153)) ([2db39f2](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/2db39f25d9478350ad56a1b2e2ca6496068257c5))
* replace repeated lazy page wrappers with lazyPage helper ([#131](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/131)) ([#132](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/132)) ([71fbe77](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/71fbe7774d3ed63fcb84bc644da997921bc049b2))
* share path-root query predicate (WealthLedger/frontend[#142](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/142)) ([#154](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/154)) ([cadba8d](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/cadba8d075daf9b20c0c9de97ad5bd8f5a3e74fc))
### Maintenance
* **api:** update OpenAPI snapshot with UpcomingEventsDto.window (WealthLedger/frontend[#134](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/134)) ([#135](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/135)) ([aaee959](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/aaee9591d7880193ee9b0c14339e8477264e4a94))
* **deps:** set Renovate release age, PR limit and update groups (WealthLedger/main[#6](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/6)) ([#137](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/137)) ([ad93725](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/ad93725a84615ec329631b71ed8db33337085740))
## [1.3.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.2.1...v1.3.0) (2026-11-01)
### Features
* **account:** add concentration tab with top-5 slices and chart (WealthLedger/main[#48](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/48)) ([#127](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/127)) ([70a2c33](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/70a2c335fdddb8b6a99e3f609befe06aeb8cc45d))
* group all-account rows by instrument and day (WealthLedger/main[#47](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/47)) ([#126](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/126)) ([71126c9](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/71126c91f0b30f4d65a313ddc7d7f86963340455))
* make brokerage account list compact ([#111](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/111)) ([c24704a](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c24704affacbe7fbc08f5e2b61bb6e7ffa6053b9))
* remove top header and rework mobile bottom navigation ([#109](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/109)) ([1a92f02](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/1a92f023c1c79f646424f72710ab74de63386a11))
* replace account page accordion with tabs and show open position result ([#112](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/112)) ([3075081](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/3075081df188de0630d314ba15a34fe6efd1eea9))
* show return, cash and structure on account cards ([#114](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/114)) ([431559b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/431559be0fca9eb37f6a09d930d502f939884e39))
### Bug Fixes
* round money to currency minor units without negative zero (WealthLedger/frontend[#118](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/118)) ([#119](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/119)) ([1d49904](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/1d4990491f254e1ec6bafad8204872eaae2982d0))
* show account source sync status (WealthLedger/frontend[#120](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/120)) ([#121](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/121)) ([a56edf2](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/a56edf2b3757ef0b0f6c42ecbd422a1a563f6505))
* show history loading alert only while loading (WealthLedger/frontend[#124](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/124)) ([#125](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/125)) ([6792f5b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/6792f5b70aa3ba90b2eb2df7e63d3ae2f669e295))
* stabilize auth client registration (WealthLedger/frontend[#95](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/95)) ([#122](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/122)) ([5796d2d](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/5796d2d789963e66a3a4bcd79eab44700f16bd31))
### Refactoring
* derive api error codes from contract (WealthLedger/backend[#117](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/117)) ([#123](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/123)) ([32e8fe9](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/32e8fe9402665ea0c350f5f247d87e328ffd44c8))
### Tests
* pin date in all accounts events test ([#115](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/115)) ([58b4e12](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/58b4e126330d01c1d6865bd0e9dd066a028fba08))
### Maintenance
* **deps:** resolve minimatch and glob audit advisories (WealthLedger/main[#49](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/49)) ([#128](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/128)) ([7242240](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/7242240b2188e9d1ca1325e65d0e1a0a3655d882))
* sync brokerage accounts contract (WealthLedger/backend[#138](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/138)) ([0e4bbec](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/0e4bbec2a7b814579095ea7ef6db68a70508fa29))
## [1.2.1](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.2.0...v1.2.1) (2026-10-24) ## [1.2.1](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.2.0...v1.2.1) (2026-10-24)
### Bug Fixes ### Bug Fixes
-1
View File
@@ -5,7 +5,6 @@ COPY package.json package-lock.json ./
COPY packages/tokens/package.json ./packages/tokens/ COPY packages/tokens/package.json ./packages/tokens/
COPY packages/ui/package.json ./packages/ui/ COPY packages/ui/package.json ./packages/ui/
COPY packages/icons/package.json ./packages/icons/ COPY packages/icons/package.json ./packages/icons/
COPY scripts/minimatch-compat ./scripts/minimatch-compat
RUN npm ci RUN npm ci
FROM dependencies AS development FROM dependencies AS development
+11 -5
View File
@@ -29,17 +29,17 @@ module.exports = {
}, },
{ {
from: { from: {
path: "^src/(app|pages|features|entities|shared)/([^/]+)/", path: "^src/(app|pages|widgets|features|entities|shared)/([^/]+)/",
}, },
name: "slices-must-use-public-api", name: "slices-must-use-public-api",
severity: "error", severity: "error",
to: { to: {
path: String.raw`^src/(app|pages|features|entities|shared)/([^/]+)/(?!index\.ts$)`, path: String.raw`^src/(app|pages|widgets|features|entities|shared)/([^/]+)/(?!index\.ts$)`,
pathNot: "^src/$1/$2/", pathNot: "^src/$1/$2/",
}, },
}, },
{ {
from: { path: "^src/(pages|features|entities)/([^/]+)/" }, from: { path: "^src/(pages|widgets|features|entities)/([^/]+)/" },
name: "no-same-layer-cross-slice-imports", name: "no-same-layer-cross-slice-imports",
severity: "error", severity: "error",
to: { path: "^src/$1/", pathNot: "^src/$1/$2/" }, to: { path: "^src/$1/", pathNot: "^src/$1/$2/" },
@@ -48,18 +48,24 @@ module.exports = {
from: { path: "^src/shared/" }, from: { path: "^src/shared/" },
name: "shared-must-not-depend-on-layers", name: "shared-must-not-depend-on-layers",
severity: "error", severity: "error",
to: { path: "^src/(entities|features|pages|app)/" }, to: { path: "^src/(entities|features|widgets|pages|app)/" },
}, },
{ {
from: { path: "^src/entities/" }, from: { path: "^src/entities/" },
name: "entities-must-not-depend-on-higher-layers", name: "entities-must-not-depend-on-higher-layers",
severity: "error", severity: "error",
to: { path: "^src/(features|pages|app)/" }, to: { path: "^src/(features|widgets|pages|app)/" },
}, },
{ {
from: { path: "^src/features/" }, from: { path: "^src/features/" },
name: "features-must-not-depend-on-higher-layers", name: "features-must-not-depend-on-higher-layers",
severity: "error", severity: "error",
to: { path: "^src/(widgets|pages|app)/" },
},
{
from: { path: "^src/widgets/" },
name: "widgets-must-not-depend-on-higher-layers",
severity: "error",
to: { path: "^src/(pages|app)/" }, to: { path: "^src/(pages|app)/" },
}, },
{ {
-9
View File
@@ -17,21 +17,12 @@ http {
default $http_x_forwarded_proto; default $http_x_forwarded_proto;
'' $scheme; '' $scheme;
} }
# One shared budget: the external proxy's client-IP forwarding is not verified.
map "$request_method:$uri" $auth_admission_key {
default "";
POST:/api/v1/auth/login auth;
POST:/api/v1/auth/register auth;
}
limit_req_zone $auth_admission_key zone=auth_admission:1m rate=2r/s;
limit_req_status 429;
server { server {
listen 8080; listen 8080;
server_name _; server_name _;
root /usr/share/nginx/html; root /usr/share/nginx/html;
resolver 127.0.0.11 valid=5s ipv6=off; resolver 127.0.0.11 valid=5s ipv6=off;
location /api/ { location /api/ {
limit_req zone=auth_admission burst=5 nodelay;
set $backend http://backend:3000; set $backend http://backend:3000;
proxy_pass $backend$request_uri; proxy_pass $backend$request_uri;
proxy_set_header Host $http_host; proxy_set_header Host $http_host;
+1 -6
View File
@@ -25,11 +25,7 @@ const readability = {
) )
context.report({ messageId: "schema", node }); context.report({ messageId: "schema", node });
}, },
FunctionDeclaration(node) { FunctionDeclaration: checkParameters,
checkParameters(node);
if (node.id?.name?.endsWith?.("ViewModel"))
context.report({ messageId: "vmName", node: node.id });
},
FunctionExpression: checkParameters, FunctionExpression: checkParameters,
ImportDeclaration(node) { ImportDeclaration(node) {
if ( if (
@@ -99,7 +95,6 @@ const readability = {
schema: "Declare Zod schemas in a dedicated *.schema.ts file.", schema: "Declare Zod schemas in a dedicated *.schema.ts file.",
svg: "Own SVG markup in packages/icons, and import its public component.", svg: "Own SVG markup in packages/icons, and import its public component.",
union: "Put complex union members on separate lines.", union: "Put complex union members on separate lines.",
vmName: "Name custom hooks use*Vm, not use*ViewModel.",
}, },
schema: [], schema: [],
type: "suggestion", type: "suggestion",
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"ignoreDependencies": ["conventional-changelog-conventionalcommits", "minimatch"], "ignoreDependencies": ["conventional-changelog-conventionalcommits"],
"workspaces": { "workspaces": {
".": { ".": {
"project": ["src/**/*.{ts,tsx}", "!src/shared/api/generated/**"] "project": ["src/**/*.{ts,tsx}", "!src/shared/api/generated/**"]
+138 -42
View File
@@ -1,12 +1,12 @@
{ {
"name": "wealth-ledger-frontend", "name": "wealth-ledger-frontend",
"version": "1.4.0", "version": "1.2.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "wealth-ledger-frontend", "name": "wealth-ledger-frontend",
"version": "1.4.0", "version": "1.2.1",
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
], ],
@@ -80,7 +80,6 @@
"jsdom": "30.1.3", "jsdom": "30.1.3",
"knip": "6.43.0", "knip": "6.43.0",
"lint-staged": "17.6.0", "lint-staged": "17.6.0",
"minimatch": "file:scripts/minimatch-compat",
"msw": "2.15.0", "msw": "2.15.0",
"openapi-typescript": "7.13.0", "openapi-typescript": "7.13.0",
"playwright": "1.64.1", "playwright": "1.64.1",
@@ -89,7 +88,7 @@
"typescript": "5.9.3", "typescript": "5.9.3",
"typescript-eslint": "8.72.0", "typescript-eslint": "8.72.0",
"vite": "8.3.5", "vite": "8.3.5",
"vite-plugin-pwa": "2.0.0", "vite-plugin-pwa": "1.3.0",
"vitest": "5.0.5" "vitest": "5.0.5"
}, },
"engines": { "engines": {
@@ -2512,12 +2511,14 @@
} }
} }
}, },
"node_modules/@isaacs/nonrecursive-glob-match": { "node_modules/@isaacs/cliui": {
"version": "1.0.3", "version": "9.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/nonrecursive-glob-match/-/nonrecursive-glob-match-1.0.3.tgz", "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==",
"integrity": "sha512-SI7kyKHq4XXDRozw+r9GlRZkia1ZkFrpLW98r2IC2Bpr8YkFGE3fvjzmxCG0x78A++69HmDNrDvSMYBZmHGpKA==",
"dev": true, "dev": true,
"license": "BlueOak-1.0.0" "license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
}, },
"node_modules/@joshwooding/vite-plugin-react-docgen-typescript": { "node_modules/@joshwooding/vite-plugin-react-docgen-typescript": {
"version": "0.7.0", "version": "0.7.0",
@@ -3719,6 +3720,33 @@
"npm": ">=9.5.0" "npm": ">=9.5.0"
} }
}, },
"node_modules/@redocly/openapi-core/node_modules/balanced-match": {
"version": "1.0.2",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/@redocly/openapi-core/node_modules/brace-expansion": {
"version": "2.1.7",
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/@redocly/openapi-core/node_modules/minimatch": {
"version": "5.1.9",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@reduxjs/toolkit": { "node_modules/@reduxjs/toolkit": {
"version": "2.12.0", "version": "2.12.0",
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
@@ -6420,7 +6448,6 @@
}, },
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
@@ -6468,7 +6495,6 @@
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "5.0.12", "version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
@@ -9071,6 +9097,33 @@
"minimatch": "^5.0.1" "minimatch": "^5.0.1"
} }
}, },
"node_modules/filelist/node_modules/balanced-match": {
"version": "1.0.2",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/filelist/node_modules/brace-expansion": {
"version": "2.1.7",
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/filelist/node_modules/minimatch": {
"version": "5.1.9",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/find-up": { "node_modules/find-up": {
"version": "5.0.0", "version": "5.0.0",
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
@@ -9141,6 +9194,22 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/foreground-child": {
"version": "3.3.1",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"dev": true,
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/formatly": { "node_modules/formatly": {
"version": "0.11.4", "version": "0.11.4",
"resolved": "https://registry.npmjs.org/formatly/-/formatly-0.11.4.tgz", "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.11.4.tgz",
@@ -9419,15 +9488,14 @@
} }
}, },
"node_modules/glob": { "node_modules/glob": {
"version": "14.0.3", "version": "13.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-14.0.3.tgz", "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
"integrity": "sha512-A1vjIwC3qpdCso45Iw127gnp7sPhqgY8lUQhDrArIs+GbmfaaVPFuoyy7WyLT9EPZCRZ7j7aLdwUhQmHWesEow==",
"dev": true, "dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"dependencies": { "dependencies": {
"minimatch": "^11.0.2", "minimatch": "^10.2.2",
"minipass": "^7.1.3", "minipass": "^7.1.3",
"path-scurry": "^3.0.0" "path-scurry": "^2.0.2"
}, },
"engines": { "engines": {
"node": "18 || 20 || >=22" "node": "18 || 20 || >=22"
@@ -10602,6 +10670,21 @@
"node": "^18.17 || >=20.6.1" "node": "^18.17 || >=20.6.1"
} }
}, },
"node_modules/jackspeak": {
"version": "4.2.3",
"integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^9.0.0"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/jake": { "node_modules/jake": {
"version": "10.9.4", "version": "10.9.4",
"integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
@@ -11390,19 +11473,12 @@
} }
}, },
"node_modules/minimatch": { "node_modules/minimatch": {
"resolved": "scripts/minimatch-compat", "version": "10.2.6",
"link": true "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
},
"node_modules/minimatch-11": {
"name": "minimatch",
"version": "11.0.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.2.tgz",
"integrity": "sha512-pWTgz5CN3tjqyZNSK2tePlSmu9IwM41q8yn8Usdqqwe9TIHh+9dEpLLjF4uApsfPEAZY71qtSiA2B2APEr+1Yw==",
"dev": true, "dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"dependencies": { "dependencies": {
"@isaacs/nonrecursive-glob-match": "^1.0.2", "brace-expansion": "^5.0.8"
"brace-expansion": "^5.0.12"
}, },
"engines": { "engines": {
"node": "18 || 20 || >=22" "node": "18 || 20 || >=22"
@@ -12136,6 +12212,12 @@
"quickjs-wasi": "^2.2.0" "quickjs-wasi": "^2.2.0"
} }
}, },
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/package-manager-detector": { "node_modules/package-manager-detector": {
"version": "1.9.0", "version": "1.9.0",
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.9.0.tgz", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.9.0.tgz",
@@ -12232,9 +12314,8 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/path-scurry": { "node_modules/path-scurry": {
"version": "3.0.0", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-3.0.0.tgz", "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
"integrity": "sha512-1pTbQqH1wIwMi/eleQ5leFTTCkzFsPcgXNjwOA8YGGW/W4/kHT3dssE7Z6NH+zuqNLOzOB0uaD61yl7cAcVhgg==",
"dev": true, "dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"dependencies": { "dependencies": {
@@ -15848,9 +15929,8 @@
} }
}, },
"node_modules/vite-plugin-pwa": { "node_modules/vite-plugin-pwa": {
"version": "2.0.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-2.0.0.tgz", "integrity": "sha512-c5kMgN+ITrOtHXp8PAtk2uOIEea6XjP/unCGxOWWBzQ6qa65qj/awHg0wf+QF9E/2u9vh86LqxPwzEPNbM2r5A==",
"integrity": "sha512-6G9dsPKT9QKYpeTuTLJM28zZL3HsXUTUM/QUZBEgQk2jbfrPJDq2i8gTewAKqi1c9Mt6MiidrL4SFI+HthiQMg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -15861,13 +15941,13 @@
"workbox-window": "^7.4.1" "workbox-window": "^7.4.1"
}, },
"engines": { "engines": {
"node": ">=20.19.0" "node": ">=16.0.0"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/antfu" "url": "https://github.com/sponsors/antfu"
}, },
"peerDependencies": { "peerDependencies": {
"@vite-pwa/assets-generator": "^1.0.0 || ^2.0.0", "@vite-pwa/assets-generator": "^1.0.0",
"vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
"workbox-build": "^7.4.1", "workbox-build": "^7.4.1",
"workbox-window": "^7.4.1" "workbox-window": "^7.4.1"
@@ -17562,6 +17642,30 @@
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
} }
}, },
"node_modules/workbox-build/node_modules/glob": {
"version": "11.1.0",
"integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"foreground-child": "^3.3.1",
"jackspeak": "^4.1.1",
"minimatch": "^10.1.1",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^2.0.0"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/workbox-build/node_modules/lru-cache": { "node_modules/workbox-build/node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
@@ -17972,14 +18076,6 @@
"react": "19.3.1", "react": "19.3.1",
"react-dom": "19.3.1" "react-dom": "19.3.1"
} }
},
"scripts/minimatch-compat": {
"name": "minimatch",
"version": "11.0.2-compat.0",
"dev": true,
"dependencies": {
"minimatch-11": "npm:minimatch@11.0.2"
}
} }
} }
} }
+3 -6
View File
@@ -1,7 +1,7 @@
{ {
"name": "wealth-ledger-frontend", "name": "wealth-ledger-frontend",
"private": true, "private": true,
"version": "1.4.0", "version": "1.2.1",
"type": "module", "type": "module",
"engines": { "engines": {
"node": "24.22.1" "node": "24.22.1"
@@ -106,7 +106,6 @@
"jsdom": "30.1.3", "jsdom": "30.1.3",
"knip": "6.43.0", "knip": "6.43.0",
"lint-staged": "17.6.0", "lint-staged": "17.6.0",
"minimatch": "file:scripts/minimatch-compat",
"msw": "2.15.0", "msw": "2.15.0",
"openapi-typescript": "7.13.0", "openapi-typescript": "7.13.0",
"playwright": "1.64.1", "playwright": "1.64.1",
@@ -115,7 +114,7 @@
"typescript": "5.9.3", "typescript": "5.9.3",
"typescript-eslint": "8.72.0", "typescript-eslint": "8.72.0",
"vite": "8.3.5", "vite": "8.3.5",
"vite-plugin-pwa": "2.0.0", "vite-plugin-pwa": "1.3.0",
"vitest": "5.0.5" "vitest": "5.0.5"
}, },
"overrides": { "overrides": {
@@ -125,9 +124,7 @@
}, },
"@vitest/browser": "5.0.5", "@vitest/browser": "5.0.5",
"@vitest/ui": "5.0.5", "@vitest/ui": "5.0.5",
"@vitest/mocker": "5.0.5", "@vitest/mocker": "5.0.5"
"minimatch": "$minimatch",
"glob": "14.0.3"
}, },
"allowScripts": { "allowScripts": {
"esbuild@0.28.2": true, "esbuild@0.28.2": true,
+1 -16
View File
@@ -1,30 +1,15 @@
{ {
"$schema": "https://docs.renovatebot.com/renovate-schema.json", "$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended", ":semanticCommitTypeAll(chore)", "security:minimumReleaseAgeNpm"], "extends": ["config:recommended", ":semanticCommitTypeAll(chore)"],
"timezone": "Europe/Moscow", "timezone": "Europe/Moscow",
"labels": ["dependencies"], "labels": ["dependencies"],
"dependencyDashboard": true, "dependencyDashboard": true,
"rangeStrategy": "pin", "rangeStrategy": "pin",
"prConcurrentLimit": 5,
"lockFileMaintenance": {
"enabled": true,
"schedule": ["before 4am on the first day of the month"]
},
"packageRules": [ "packageRules": [
{ {
"matchUpdateTypes": ["minor", "patch"], "matchUpdateTypes": ["minor", "patch"],
"groupName": "non-major dependencies" "groupName": "non-major dependencies"
}, },
{
"matchManagers": ["github-actions"],
"matchUpdateTypes": ["minor", "patch"],
"groupName": "non-major github actions"
},
{
"matchManagers": ["docker-compose", "dockerfile"],
"matchUpdateTypes": ["minor", "patch"],
"groupName": "non-major container images"
},
{ {
"matchUpdateTypes": ["major"], "matchUpdateTypes": ["major"],
"dependencyDashboardApproval": true "dependencyDashboardApproval": true
-9
View File
@@ -1,9 +0,0 @@
// ponytail: CommonJS twin of index.js (import-x ships lib/index.cjs too).
const minimatch = require("minimatch-11");
module.exports = {
...minimatch,
makeRe: (pattern, options = {}) => ({
test: path => minimatch.minimatch(path, pattern, options),
}),
};
-11
View File
@@ -1,11 +0,0 @@
// ponytail: shim for eslint-plugin-import-x@4.17.1, which still imports the
// makeRe export removed in minimatch 11. Its only caller (no-internal-modules)
// uses the result via .test(), so a matcher object is enough. Delete this
// package and the override once import-x supports minimatch 11.
import { minimatch } from "minimatch-11";
export * from "minimatch-11";
export const makeRe = (pattern, options = {}) => ({
test: path => minimatch(path, pattern, options),
});
-14
View File
@@ -1,14 +0,0 @@
{
"name": "minimatch",
"version": "11.0.2-compat.0",
"private": true,
"description": "minimatch 11 plus the makeRe export eslint-plugin-import-x 4.17.1 still imports. Remove when import-x supports minimatch 11.",
"type": "module",
"exports": {
"import": "./index.js",
"require": "./index.cjs"
},
"dependencies": {
"minimatch-11": "npm:minimatch@11.0.2"
}
}
@@ -217,7 +217,6 @@ beforeEach(() => {
}), }),
), ),
incomeTotalsByCurrency: [], incomeTotalsByCurrency: [],
window: { from: "2026-09-01", to: "2026-10-01" },
}), }),
), ),
), ),
+94 -28
View File
@@ -1,4 +1,4 @@
import type { ComponentType, FC } from "react"; import type { FC } from "react";
import { lazy } from "react"; import { lazy } from "react";
import { Navigate, Route, Routes } from "react-router"; import { Navigate, Route, Routes } from "react-router";
@@ -6,35 +6,101 @@ import { Navigate, Route, Routes } from "react-router";
import { LazyPage } from "../RouteFallback"; import { LazyPage } from "../RouteFallback";
import { Protected, Public } from "../RouteGuards"; import { Protected, Public } from "../RouteGuards";
// Pages use named exports; React.lazy needs a default export per dynamic import (one chunk each). const CardPage = lazy(async () => {
function lazyPage<Name extends string>( const { CardPage } = await import("@/pages/card");
load: () => Promise<Record<Name, ComponentType>>,
name: Name,
) {
return lazy(async () => {
const page = await load();
// eslint-disable-next-line security/detect-object-injection -- `name` is a compile-time literal checked against the module's exports. return { default: CardPage };
return { default: page[name] }; });
});
}
const CardPage = lazyPage(async () => import("@/pages/card"), "CardPage"); const CardsPage = lazy(async () => {
const CardsPage = lazyPage(async () => import("@/pages/cards"), "CardsPage"); const { CardsPage } = await import("@/pages/cards");
const BondPage = lazyPage(async () => import("@/pages/bond"), "BondPage");
const BondsPage = lazyPage(async () => import("@/pages/bonds"), "BondsPage"); return { default: CardsPage };
const StockPage = lazyPage(async () => import("@/pages/stock"), "StockPage"); });
const StocksPage = lazyPage(async () => import("@/pages/stocks"), "StocksPage");
const AccountPage = lazyPage(async () => import("@/pages/account"), "AccountPage"); const BondPage = lazy(async () => {
const AccountEventsPage = lazyPage(async () => import("@/pages/account-events"), "AccountEventsPage"); const { BondPage } = await import("@/pages/bond");
const AccountOperationsPage = lazyPage(async () => import("@/pages/account-operations"), "AccountOperationsPage");
const AccountIncomePage = lazyPage(async () => import("@/pages/account-income"), "AccountIncomePage"); return { default: BondPage };
const AccountsPage = lazyPage(async () => import("@/pages/accounts"), "AccountsPage"); });
const LoginPage = lazyPage(async () => import("@/pages/login"), "LoginPage");
const NotFoundPage = lazyPage(async () => import("@/pages/not-found"), "NotFoundPage"); const BondsPage = lazy(async () => {
const RegisterPage = lazyPage(async () => import("@/pages/register"), "RegisterPage"); const { BondsPage } = await import("@/pages/bonds");
const SettingsPage = lazyPage(async () => import("@/pages/settings"), "SettingsPage");
const ProtectedLayout = lazyPage(async () => import("../ProtectedLayout"), "ProtectedLayout"); return { default: BondsPage };
});
const StockPage = lazy(async () => {
const { StockPage } = await import("@/pages/stock");
return { default: StockPage };
});
const StocksPage = lazy(async () => {
const { StocksPage } = await import("@/pages/stocks");
return { default: StocksPage };
});
const AccountPage = lazy(async () => {
const { AccountPage } = await import("@/pages/account");
return { default: AccountPage };
});
const AccountEventsPage = lazy(async () => {
const { AccountEventsPage } = await import("@/pages/account-events");
return { default: AccountEventsPage };
});
const AccountOperationsPage = lazy(async () => {
const { AccountOperationsPage } = await import("@/pages/account-operations");
return { default: AccountOperationsPage };
});
const AccountIncomePage = lazy(async () => {
const { AccountIncomePage } = await import("@/pages/account-income");
return { default: AccountIncomePage };
});
const AccountsPage = lazy(async () => {
const { AccountsPage } = await import("@/pages/accounts");
return { default: AccountsPage };
});
const LoginPage = lazy(async () => {
const { LoginPage } = await import("@/pages/login");
return { default: LoginPage };
});
const NotFoundPage = lazy(async () => {
const { NotFoundPage } = await import("@/pages/not-found");
return { default: NotFoundPage };
});
const RegisterPage = lazy(async () => {
const { RegisterPage } = await import("@/pages/register");
return { default: RegisterPage };
});
const SettingsPage = lazy(async () => {
const { SettingsPage } = await import("@/pages/settings");
return { default: SettingsPage };
});
const ProtectedLayout = lazy(async () => {
const { ProtectedLayout } = await import("../ProtectedLayout");
return { default: ProtectedLayout };
});
const AppRoutes: FC = () => ( const AppRoutes: FC = () => (
<Routes> <Routes>
@@ -1,5 +1,6 @@
import type { FC } from "react"; import type { FC } from "react";
import { useDocumentTitle } from "@mantine/hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useCurrentRoute } from "@/app/model"; import { useCurrentRoute } from "@/app/model";
@@ -7,12 +8,12 @@ import { useBankCard } from "@/entities/bank-card";
import { useBond } from "@/entities/bond"; import { useBond } from "@/entities/bond";
import { useBrokerageAccounts } from "@/entities/brokerage-account"; import { useBrokerageAccounts } from "@/entities/brokerage-account";
import { useStock } from "@/entities/stock"; import { useStock } from "@/entities/stock";
import { useAppDocumentTitle } from "@/shared/ui";
type DocumentTitleProps = { title: string }; type DocumentTitleProps = { title: string };
const DocumentTitle: FC<DocumentTitleProps> = (props): undefined => { const DocumentTitle: FC<DocumentTitleProps> = (props): undefined => {
useAppDocumentTitle(props.title); const { t } = useTranslation();
useDocumentTitle(`${props.title} — ${t("appName")}`);
}; };
type AccountDocumentTitleProps = { type AccountDocumentTitleProps = {
@@ -1,12 +1,12 @@
import type { FC, PropsWithChildren } from "react"; import type { FC, PropsWithChildren } from "react";
import { Button, Stack, Text, Title } from "@mantine/core"; import { Button, Stack, Text, Title } from "@mantine/core";
import { useDocumentTitle } from "@mantine/hooks";
import { Component } from "react"; import { Component } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link, useLocation } from "react-router"; import { Link, useLocation } from "react-router";
import { useAuth } from "@/features/auth"; import { useAuth } from "@/features/auth";
import { useAppDocumentTitle } from "@/shared/ui";
type BoundaryProps = PropsWithChildren<{ type BoundaryProps = PropsWithChildren<{
authenticated: boolean; authenticated: boolean;
@@ -37,7 +37,7 @@ class Boundary extends Component<BoundaryProps, State> {
const RouteErrorFallback: FC<RouteErrorFallbackProps> = (props) => { const RouteErrorFallback: FC<RouteErrorFallbackProps> = (props) => {
const { authenticated } = props; const { authenticated } = props;
const { t } = useTranslation(); const { t } = useTranslation();
useAppDocumentTitle(t("routeError.title")); useDocumentTitle(`${t("routeError.title")} — ${t("appName")}`);
const to = authenticated ? "/accounts" : "/login"; const to = authenticated ? "/accounts" : "/login";
return ( return (
+7 -17
View File
@@ -7,8 +7,10 @@ import { useCurrentRoute } from "@/app/model";
import { AccountBreadcrumbs } from "./AccountBreadcrumbs"; import { AccountBreadcrumbs } from "./AccountBreadcrumbs";
import { AccountScopeSwitch } from "./AccountScopeSwitch/AccountScopeSwitch"; import { AccountScopeSwitch } from "./AccountScopeSwitch/AccountScopeSwitch";
import { BondBreadcrumbs } from "./BondBreadcrumbs";
import { BreadcrumbTrail } from "./BreadcrumbTrail"; import { BreadcrumbTrail } from "./BreadcrumbTrail";
import { EntityBreadcrumbs } from "./EntityBreadcrumbs"; import { CardBreadcrumbs } from "./CardBreadcrumbs";
import { StockBreadcrumbs } from "./StockBreadcrumbs";
const AppBreadcrumbs: FC = () => { const AppBreadcrumbs: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -24,16 +26,9 @@ const AppBreadcrumbs: FC = () => {
stock, stock,
} = useCurrentRoute(); } = useCurrentRoute();
if (bond) { if (bond) return <BondBreadcrumbs identifier={bond.params.bondId ?? ""} />;
return <EntityBreadcrumbs identifier={bond.params.bondId ?? ""} kind="bond" />; if (stock)
} return <StockBreadcrumbs identifier={stock.params.stockId ?? ""} />;
if (stock) {
return (
<EntityBreadcrumbs identifier={stock.params.stockId ?? ""} kind="stock" />
);
}
if (account && (section || !account.params.section)) if (account && (section || !account.params.section))
return ( return (
<Stack gap={0} mb="md"> <Stack gap={0} mb="md">
@@ -48,13 +43,8 @@ const AppBreadcrumbs: FC = () => {
/> />
</Stack> </Stack>
); );
if (card) return <CardBreadcrumbs identifier={card.params.cardId ?? ""} />;
if (card) {
return <EntityBreadcrumbs identifier={card.params.cardId ?? ""} kind="card" />;
}
if (cards) return <BreadcrumbTrail items={[{ label: t("bank.title") }]} />; if (cards) return <BreadcrumbTrail items={[{ label: t("bank.title") }]} />;
if (accounts) if (accounts)
return <BreadcrumbTrail items={[{ label: t("breadcrumbs.accounts") }]} />; return <BreadcrumbTrail items={[{ label: t("breadcrumbs.accounts") }]} />;
@@ -0,0 +1,25 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { useBond } from "@/entities/bond";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
type BondBreadcrumbsProps = { identifier: string };
const BondBreadcrumbs: FC<BondBreadcrumbsProps> = (props) => {
const { data } = useBond(props.identifier);
const { t } = useTranslation();
return (
<BreadcrumbTrail
items={[
{ label: t("bond.searchTitle"), to: "/bonds" },
{ label: data?.name ?? t("bond.instrument") },
]}
/>
);
};
export { BondBreadcrumbs };
@@ -39,4 +39,4 @@ const BreadcrumbTrail: FC<BreadcrumbTrailProps> = (props) => {
); );
}; };
export { BreadcrumbTrail, type Crumb }; export { BreadcrumbTrail };
@@ -0,0 +1,25 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { useBankCard } from "@/entities/bank-card";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
type CardBreadcrumbsProps = { identifier: string };
const CardBreadcrumbs: FC<CardBreadcrumbsProps> = (props) => {
const { data } = useBankCard(props.identifier);
const { t } = useTranslation();
return (
<BreadcrumbTrail
items={[
{ label: t("bank.title"), to: "/cards" },
{ label: data?.name ?? t("bank.card") },
]}
/>
);
};
export { CardBreadcrumbs };
@@ -1,48 +0,0 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { useBankCard } from "@/entities/bank-card";
import { useBond } from "@/entities/bond";
import { useStock } from "@/entities/stock";
import type { Crumb } from "./BreadcrumbTrail";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
type EntityBreadcrumbsProps = {
identifier: string;
kind:
| "bond"
| "card"
| "stock";
};
const EntityBreadcrumbs: FC<EntityBreadcrumbsProps> = (props) => {
const { identifier, kind } = props;
const { t } = useTranslation();
const bond = useBond(kind === "bond" ? identifier : "");
const card = useBankCard(kind === "card" ? identifier : "");
const stock = useStock(kind === "stock" ? identifier : "");
let parent: Crumb;
let name: string;
if (kind === "bond") {
parent = { label: t("bond.searchTitle"), to: "/bonds" };
name = bond.data?.name ?? t("bond.instrument");
}
else if (kind === "stock") {
parent = { label: t("stock.searchTitle"), to: "/stocks" };
name = stock.data?.name ?? t("stock.share");
}
else {
parent = { label: t("bank.title"), to: "/cards" };
name = card.data?.name ?? t("bank.card");
}
return <BreadcrumbTrail items={[parent, { label: name }]} />;
};
export { EntityBreadcrumbs };
@@ -0,0 +1,25 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { useStock } from "@/entities/stock";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
type StockBreadcrumbsProps = { identifier: string };
const StockBreadcrumbs: FC<StockBreadcrumbsProps> = (props) => {
const { data } = useStock(props.identifier);
const { t } = useTranslation();
return (
<BreadcrumbTrail
items={[
{ label: t("stock.searchTitle"), to: "/stocks" },
{ label: data?.name ?? t("stock.share") },
]}
/>
);
};
export { StockBreadcrumbs };
+10 -4
View File
@@ -5,7 +5,6 @@ import {
ApiErrorCode, ApiErrorCode,
apiErrorCode, apiErrorCode,
isApiErrorCode, isApiErrorCode,
isGetUnder,
} from "@/shared/api"; } from "@/shared/api";
import type { import type {
@@ -27,14 +26,21 @@ async function mutationData<T>(
return data; return data;
} }
const bankCardGetPredicate = isGetUnder(["/api/v1/bank-cards"]);
function useBankMutationOptions() { function useBankMutationOptions() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const invalidate = () => const invalidate = () =>
queryClient.invalidateQueries({ queryClient.invalidateQueries({
predicate: ({ queryKey }) => bankCardGetPredicate(queryKey), predicate: ({ queryKey }) => {
const [method, path] = queryKey;
return (
method === "get"
&& typeof path === "string"
&& (path === "/api/v1/bank-cards"
|| path.startsWith("/api/v1/bank-cards/"))
);
},
}); });
return { return {
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it } from "vitest";
import { resource } from "@/test/resource"; import { resource } from "@/test/resource";
@@ -76,29 +76,4 @@ describe("instrument source and transport presentation", () => {
loading: false, loading: false,
}); });
}); });
it("carries the retry of an exhausted read and only of that read", () => {
const refetch = vi.fn(() => Promise.resolve());
const state = section({
data: { rows: [] },
error: { code: "TBANK_UNAVAILABLE", status: 503 },
isError: true,
isFetching: false,
refetch,
});
expect(state.readRetry?.exhausted).toBe(true);
state.readRetry?.onRetry();
expect(refetch).toHaveBeenCalledOnce();
});
it("offers no retry for access loss or a healthy read", () => {
const base = { data: undefined, isFetching: false, refetch: vi.fn() };
expect(section({ ...base, error: { code: "NOT_FOUND" }, isError: true })
.readRetry?.exhausted).toBe(false);
expect(section({ ...base, error: null, isError: false })
.readRetry?.exhausted).toBe(false);
});
}); });
@@ -1,11 +1,10 @@
import { type InvestmentResourceMeta, readRetry } from "@/shared/api"; import type { InvestmentResourceMeta } from "@/shared/api";
type SectionState = { type SectionState = {
error: boolean; error: boolean;
hasData: boolean; hasData: boolean;
loading: boolean; loading: boolean;
partial?: boolean; partial?: boolean;
readRetry?: ReturnType<typeof readRetry>;
}; };
function hasSectionData(data: unknown) { function hasSectionData(data: unknown) {
@@ -14,11 +13,8 @@ function hasSectionData(data: unknown) {
function section(query: { function section(query: {
data: unknown; data: unknown;
error?: unknown;
isError: boolean; isError: boolean;
isFetching?: boolean;
meta?: InvestmentResourceMeta; meta?: InvestmentResourceMeta;
refetch?: () => Promise<unknown>;
}): SectionState { }): SectionState {
const isError = query.isError || query.meta?.lastAttempt.state === "failed"; const isError = query.isError || query.meta?.lastAttempt.state === "failed";
const hasData = hasSectionData(query.data); const hasData = hasSectionData(query.data);
@@ -28,17 +24,6 @@ function section(query: {
hasData, hasData,
loading: !hasData && !isError, loading: !hasData && !isError,
partial: query.meta?.completeness === "incomplete", partial: query.meta?.completeness === "incomplete",
// Each section retries only its own GET, so sections recover independently.
readRetry: query.refetch
? readRetry([
{
error: query.error,
isError: query.isError,
isFetching: query.isFetching ?? false,
refetch: query.refetch,
},
])
: undefined,
}; };
} }
@@ -12,8 +12,6 @@ import {
import { LoadingSkeleton } from "@wealthledger/ui"; import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ReadRetry } from "@/shared/ui";
import type { SectionState } from "../model/section"; import type { SectionState } from "../model/section";
import classes from "./InstrumentDetails.module.css"; import classes from "./InstrumentDetails.module.css";
@@ -83,8 +81,6 @@ const InstrumentSection: FC<InstrumentSectionProps> = (props) => {
</Alert> </Alert>
)} )}
{!!props.state.readRetry && <ReadRetry {...props.state.readRetry} />}
{sectionContent} {sectionContent}
</Paper> </Paper>
); );
@@ -2,18 +2,19 @@ import type { QueryClient } from "@tanstack/react-query";
import { import {
apiQueryClient, apiQueryClient,
isGetUnder,
type paths, type paths,
} from "@/shared/api"; } from "@/shared/api";
type TbankConnectionStatus type TbankConnectionStatus
= paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"]; = paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"];
const investmentGetPredicate = isGetUnder([ function isInvestmentPath(path: string) {
"/api/v1/brokerage-accounts", return [
"/api/v1/stocks", "/api/v1/brokerage-accounts",
"/api/v1/bonds", "/api/v1/stocks",
]); "/api/v1/bonds",
].some(root => path === root || path.startsWith(`${root}/`));
}
const tbankConnectionQueryOptions = () => const tbankConnectionQueryOptions = () =>
apiQueryClient.queryOptions("get", "/api/v1/tbank-connection"); apiQueryClient.queryOptions("get", "/api/v1/tbank-connection");
@@ -26,7 +27,15 @@ function commitTbankConnectionBoundary(
) { ) {
queryClient.setQueryData(tbankConnectionQueryKey, status); queryClient.setQueryData(tbankConnectionQueryKey, status);
void queryClient.resetQueries({ void queryClient.resetQueries({
predicate: ({ queryKey }) => investmentGetPredicate(queryKey), predicate: ({ queryKey }) => {
const [method, path] = queryKey;
return (
method === "get"
&& typeof path === "string"
&& isInvestmentPath(path)
);
},
}); });
} }
@@ -4,16 +4,16 @@ import {
TbankConnectionError, TbankConnectionError,
TbankConnectionLoading, TbankConnectionLoading,
} from "./TbankConnectionSection.view"; } from "./TbankConnectionSection.view";
import { useTbankConnectionSectionVm } from "./TbankConnectionSection.vm"; import { useTbankConnectionViewModel } from "./TbankConnectionSection.vm";
import { TbankConnectionSectionLoaded } from "./TbankConnectionSectionLoaded"; import { TbankConnectionSectionLoaded } from "./TbankConnectionSectionLoaded";
const TbankConnectionSection: FC = () => { const TbankConnectionSection: FC = () => {
const vm = useTbankConnectionSectionVm(); const viewModel = useTbankConnectionViewModel();
if (vm.state === "loading") return <TbankConnectionLoading />; if (viewModel.state === "loading") return <TbankConnectionLoading />;
return vm.state === "error" return viewModel.state === "error"
? <TbankConnectionError retry={vm.retry} /> ? <TbankConnectionError retry={viewModel.retry} />
: <TbankConnectionSectionLoaded {...vm} />; : <TbankConnectionSectionLoaded {...viewModel} />;
}; };
export { TbankConnectionSection }; export { TbankConnectionSection };
@@ -2,7 +2,7 @@ import { useAuthenticatedQuery } from "@/shared/api";
import { tbankConnectionQueryOptions } from "../../model/connection-boundary"; import { tbankConnectionQueryOptions } from "../../model/connection-boundary";
function useTbankConnectionSectionVm() { function useTbankConnectionViewModel() {
const status = useAuthenticatedQuery({ const status = useAuthenticatedQuery({
...tbankConnectionQueryOptions(), ...tbankConnectionQueryOptions(),
}); });
@@ -22,4 +22,4 @@ function useTbankConnectionSectionVm() {
}; };
} }
export { useTbankConnectionSectionVm }; export { useTbankConnectionViewModel };
@@ -26,7 +26,7 @@ import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { AccountEventsPage } from "./AccountEventsPage"; import { AccountEventsPage } from "./AccountEventsPage";
import { useAccountEventsPageVm } from "./AccountEventsPage.vm"; import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm";
const Navigation: FC = () => { const Navigation: FC = () => {
const location = useLocation(); const location = useLocation();
@@ -100,7 +100,7 @@ describe("Upcoming event date filters", () => {
); );
const queryClient = createTestQueryClient(); const queryClient = createTestQueryClient();
const { result } = renderHook(() => useAccountEventsPageVm(), { const { result } = renderHook(() => useAccountEventsPageViewModel(), {
wrapper: ({ children }) => ( wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["?from=2099-02-01"]}> <MemoryRouter initialEntries={["?from=2099-02-01"]}>
@@ -146,7 +146,7 @@ describe("Upcoming event date filters", () => {
const queryClient = createTestQueryClient(); const queryClient = createTestQueryClient();
const { result } = renderHook( const { result } = renderHook(
() => useAccountEventsPageVm(), () => useAccountEventsPageViewModel(),
{ {
wrapper: ({ children }) => ( wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
@@ -176,11 +176,7 @@ describe("Upcoming event date filters", () => {
({ request }) => { ({ request }) => {
searches.push(new URL(request.url).search); searches.push(new URL(request.url).search);
return HttpResponse.json(resource({ return HttpResponse.json(resource({ events: [] }));
calculatedAt: "2026-08-14T00:00:00Z",
events: [],
window: { from: "2026-08-14", to: "2026-09-13" },
}));
}, },
), ),
); );
@@ -227,11 +223,7 @@ describe("Upcoming event date filters", () => {
"*/api/v1/brokerage-accounts/:accountId/events", "*/api/v1/brokerage-accounts/:accountId/events",
({ request }) => ({ request }) =>
new URL(request.url).searchParams.get("from") === defaultFrom new URL(request.url).searchParams.get("from") === defaultFrom
? HttpResponse.json(resource({ ? HttpResponse.json(resource({ events: [] }))
calculatedAt: "2026-08-14T00:00:00Z",
events: [],
window: { from: "2026-08-14", to: "2026-09-13" },
}))
: HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 }), : HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 }),
), ),
); );
@@ -264,7 +256,6 @@ describe("Upcoming event date filters", () => {
calculatedAt: "2099-01-01T00:00:00Z", calculatedAt: "2099-01-01T00:00:00Z",
events: [], events: [],
incomeTotalsByCurrency: [], incomeTotalsByCurrency: [],
window: { from: "2099-01-01", to: "2099-01-31" },
}), }),
); );
}, },
@@ -273,7 +264,6 @@ describe("Upcoming event date filters", () => {
renderEvents(accountId, ["?from=2026-02-01&to=2026-02-28"]); renderEvents(accountId, ["?from=2026-02-01&to=2026-02-28"]);
const from = await screen.findByLabelText("Период"); const from = await screen.findByLabelText("Период");
expect(from).toHaveTextContent("01.02.2026 – 28.02.2026"); expect(from).toHaveTextContent("01.02.2026 – 28.02.2026");
expect(screen.getByText(/Прогноз от .* на 01\.01\.2099 – 31\.01\.2099/)).toBeVisible();
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
expect(searches).toEqual(["?from=2026-02-01&to=2026-02-28"]); expect(searches).toEqual(["?from=2026-02-01&to=2026-02-28"]);
@@ -371,7 +361,7 @@ it("retains the previous complete forecast when its source refresh fails", async
); );
const queryClient = createTestQueryClient(); const queryClient = createTestQueryClient();
const { result } = renderHook(() => useAccountEventsPageVm(), { const { result } = renderHook(() => useAccountEventsPageViewModel(), {
wrapper: ({ children }) => ( wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["/accounts/all/events"]}> <MemoryRouter initialEntries={["/accounts/all/events"]}>
@@ -1,167 +0,0 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { MemoryRouter, Route, Routes } from "react-router";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { AccountEventsPage } from "./AccountEventsPage";
// Automatic read retries wait 1s + 2s before the failure is final.
const RETRIES_EXHAUSTED_MS = 6000;
const TEST_MS = 10_000;
const snapshot = resource({
accountId: "account-1",
calculatedAt: "2026-08-14T00:00:00Z",
events: [
{
date: "2026-10-01",
dateKind: "event",
displayName: "Synthetic coupon",
figi: null,
instrumentUid: null,
isin: null,
logoName: null,
projectedAmount: { currency: "RUB", value: "123.45" },
ticker: "SYN",
type: "coupon",
},
],
incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }],
window: { from: "2026-08-14", to: "2026-09-13" },
});
function renderEvents() {
configureAuthClient({
accessToken: () => "token",
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
const queryClient = createTestQueryClient();
render(
<QueryClientProvider client={queryClient}>
<MantineProvider env="test">
<MemoryRouter initialEntries={["/accounts/account-1/events"]}>
<Routes>
<Route
element={<AccountEventsPage />}
path="/accounts/:accountId/events"
/>
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
return { queryClient };
}
describe("AccountEventsPage read retry", () => {
it(
"offers retry after an initial failure and recovers in place",
{ timeout: TEST_MS },
async () => {
let isFailing = true;
let requests = 0;
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/events", () => {
requests += 1;
return isFailing
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(snapshot);
}),
);
renderEvents();
const retry = await screen.findByRole(
"button",
{ name: "Повторить" },
{ timeout: RETRIES_EXHAUSTED_MS },
);
expect(requests).toBe(3);
isFailing = false;
fireEvent.click(retry);
expect(await screen.findByText("Купон · Synthetic coupon")).toBeInTheDocument();
expect(requests).toBe(4);
},
);
it(
"keeps cached events, warns and retries the same GET",
{ timeout: TEST_MS },
async () => {
let isFailing = false;
let requests = 0;
let imports = 0;
server.use(
http.post("*/api/v1/tbank-sync", () => {
imports += 1;
return HttpResponse.json({}, { status: 202 });
}),
http.get("*/api/v1/brokerage-accounts/:accountId/events", () => {
requests += 1;
return isFailing
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(snapshot);
}),
);
const { queryClient } = renderEvents();
await screen.findByText("Купон · Synthetic coupon");
expect(screen.getByText(/Прогноз от .* на 14\.08\.2026 – 13\.09\.2026/)).toBeVisible();
expect(screen.queryByRole("button", { name: "Повторить" }))
.not.toBeInTheDocument();
isFailing = true;
void queryClient.invalidateQueries({
queryKey: ["get", "/api/v1/brokerage-accounts/{accountId}/events"],
});
const retry = await screen.findByRole(
"button",
{ name: "Повторить" },
{ timeout: RETRIES_EXHAUSTED_MS },
);
expect(screen.getByText("Не удалось обновить данные")).toBeInTheDocument();
expect(screen.getByText("Купон · Synthetic coupon")).toBeInTheDocument();
expect(screen.getByText(/Прогноз от .* на 14\.08\.2026 – 13\.09\.2026/)).toBeVisible();
expect(requests).toBe(4);
isFailing = false;
fireEvent.click(retry);
await waitFor(() =>
expect(screen.queryByText("Не удалось обновить данные"))
.not.toBeInTheDocument());
expect(requests).toBe(5);
expect(imports).toBe(0);
},
);
it("does not offer a plain retry when the account is gone", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 })),
);
renderEvents();
await waitFor(() =>
expect(screen.queryByRole("heading", { name: "Загрузка" }))
.not.toBeInTheDocument());
expect(screen.queryByRole("button", { name: "Повторить" }))
.not.toBeInTheDocument();
});
});
@@ -47,7 +47,6 @@ describe("AccountEventsPage", () => {
accountId: "account", accountId: "account",
calculatedAt: "2026-08-14T00:00:00Z", calculatedAt: "2026-08-14T00:00:00Z",
events: [], events: [],
window: { from: "2026-08-14", to: "2026-09-13" },
}, },
isError: false, isError: false,
isLoading: false, isLoading: false,
@@ -137,7 +136,6 @@ describe("AccountEventsPage", () => {
type: "offer", type: "offer",
}, },
], ],
window: { from: "2026-08-14", to: "2026-09-13" },
}, },
isError: false, isError: false,
isLoading: false, isLoading: false,
@@ -171,18 +169,15 @@ describe("AccountEventsPage", () => {
expect(screen.getAllByText("O")).toHaveLength(2); expect(screen.getAllByText("O")).toHaveLength(2);
}); });
it("renders empty and error states with retry", async () => { it("renders empty and quiet error states", async () => {
const { rerender } = renderPage(); const { rerender } = renderPage();
expect( expect(
screen.getByText("За выбранный период событий не найдено"), screen.getByText("За выбранный период событий не найдено"),
).toBeInTheDocument(); ).toBeInTheDocument();
query.mockReturnValue({ query.mockReturnValue({
data: undefined, data: undefined,
error: { code: "TBANK_UNAVAILABLE", status: 503 },
isError: true, isError: true,
isFetching: false,
isLoading: false, isLoading: false,
refetch: vi.fn(),
} as never); } as never);
rerender( rerender(
<MantineProvider> <MantineProvider>
@@ -192,7 +187,7 @@ describe("AccountEventsPage", () => {
</MantineProvider>, </MantineProvider>,
); );
expect(await screen.findByRole("button", { name: "Сбросить" })).toBeVisible(); expect(await screen.findByRole("button", { name: "Сбросить" })).toBeVisible();
expect(screen.getByRole("button", { name: "Повторить" })).toBeVisible(); expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
}); });
it("shows a structural busy state before events are available", () => { it("shows a structural busy state before events are available", () => {
@@ -278,7 +273,6 @@ describe("AccountEventsPage", () => {
accountId: "all", accountId: "all",
calculatedAt: "2026-08-14T00:00:00Z", calculatedAt: "2026-08-14T00:00:00Z",
events: [...events], events: [...events],
window: { from: "2026-08-14", to: "2026-09-13" },
}, },
isError: false, isError: false,
isLoading: false, isLoading: false,
@@ -1,17 +1,17 @@
import type { FC } from "react"; import type { FC } from "react";
import { useAccountEventsPageVm } from "./AccountEventsPage.vm"; import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm";
import { AccountEventsPageError } from "./components/AccountEventsPageError.view"; import { AccountEventsPageError } from "./components/AccountEventsPageError.view";
import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view"; import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view";
import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view"; import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view";
const AccountEventsPage: FC = () => { const AccountEventsPage: FC = () => {
const vm = useAccountEventsPageVm(); const viewModel = useAccountEventsPageViewModel();
if (vm.state === "loading") return <AccountEventsPageLoading />; if (viewModel.state === "loading") return <AccountEventsPageLoading />;
return vm.state === "error" return viewModel.state === "error"
? <AccountEventsPageError {...vm} /> ? <AccountEventsPageError {...viewModel} />
: <AccountEventsPageLoaded {...vm} />; : <AccountEventsPageLoaded {...viewModel} />;
}; };
export { AccountEventsPage }; export { AccountEventsPage };
@@ -1,12 +1,13 @@
import { useState } from "react"; import { useState } from "react";
import { useParams } from "react-router"; import { useParams } from "react-router";
import type { InvestmentResourceMeta } from "@/shared/api";
import { import {
type BrokerageEvent, type BrokerageEvent,
useBrokerageEvents, useBrokerageEvents,
} from "@/entities/brokerage-event"; } from "@/entities/brokerage-event";
import { useScreenSync } from "@/features/tbank-connection"; import { useScreenSync } from "@/features/tbank-connection";
import { type InvestmentResourceMeta, readRetry } from "@/shared/api";
import { import {
defaultPeriod, defaultPeriod,
isValidPeriod, isValidPeriod,
@@ -18,7 +19,6 @@ import {
type AccountEventsPageViewModel type AccountEventsPageViewModel
= | { = | {
allAccounts: boolean; allAccounts: boolean;
calculatedAt: string;
canApply: boolean; canApply: boolean;
draftPeriod: [null | string, null | string]; draftPeriod: [null | string, null | string];
events: BrokerageEvent[]; events: BrokerageEvent[];
@@ -28,20 +28,14 @@ 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; totalVisible: boolean;
window: { from: string; to: string };
}
| {
onReset: () => void;
readRetry: ReturnType<typeof readRetry>;
state: "error";
} }
| { onReset: () => void; state: "error" }
| { state: "loading" }; | { state: "loading" };
function useAccountEventsPageVm(): AccountEventsPageViewModel { function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
const { accountId = "" } = useParams(); const { accountId = "" } = useParams();
const { const {
@@ -89,8 +83,6 @@ function useAccountEventsPageVm(): AccountEventsPageViewModel {
isFetching: events.isFetching, isFetching: events.isFetching,
}); });
const retry = readRetry([events]);
if (availability === "loading" && events.isLoading) if (availability === "loading" && events.isLoading)
return { state: "loading" }; return { state: "loading" };
@@ -102,17 +94,15 @@ function useAccountEventsPageVm(): AccountEventsPageViewModel {
if ((events.isError || events.meta?.lastAttempt.state === "failed") && !events.data) if ((events.isError || events.meta?.lastAttempt.state === "failed") && !events.data)
return { return {
onReset, onReset,
readRetry: retry,
state: "error", state: "error",
}; };
if (!events.data) if (!events.data)
return availability === "unavailable" return availability === "unavailable"
? { onReset, readRetry: retry, state: "error" } ? { onReset, state: "error" }
: { state: "loading" }; : { state: "loading" };
return { return {
allAccounts: accountId === "all", allAccounts: accountId === "all",
calculatedAt: events.data.calculatedAt,
canApply: canApply:
(draftFrom !== appliedFrom || draftTo !== appliedTo) (draftFrom !== appliedFrom || draftTo !== appliedTo)
&& isValidPeriod(draftFrom, draftTo), && isValidPeriod(draftFrom, draftTo),
@@ -132,12 +122,10 @@ function useAccountEventsPageVm(): AccountEventsPageViewModel {
onPeriodPopoverOpen: () => setMinDate(moscowDate(new Date())), onPeriodPopoverOpen: () => setMinDate(moscowDate(new Date())),
onReset, onReset,
partial: events.meta?.completeness === "incomplete", partial: events.meta?.completeness === "incomplete",
readRetry: retry,
source: events.meta, source: events.meta,
state: "loaded", state: "loaded",
totalVisible: events.meta?.completeness === "complete", totalVisible: events.meta?.completeness === "complete",
window: events.data.window,
}; };
} }
export { type AccountEventsPageViewModel, useAccountEventsPageVm }; export { type AccountEventsPageViewModel, useAccountEventsPageViewModel };
@@ -4,12 +4,7 @@ import { Button, Group } from "@mantine/core";
import { StateView } from "@wealthledger/ui"; import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ReadRetryButton, type ReadRetryProps } from "@/shared/ui"; type AccountEventsPageErrorProps = { onReset: () => void };
type AccountEventsPageErrorProps = {
onReset: () => void;
readRetry: ReadRetryProps;
};
const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => { const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -18,13 +13,6 @@ const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
<StateView <StateView
action={( action={(
<Group> <Group>
{props.readRetry.exhausted && (
<ReadRetryButton
onRetry={props.readRetry.onRetry}
retrying={props.readRetry.retrying}
/>
)}
<Button onClick={props.onReset} variant="default"> <Button onClick={props.onReset} variant="default">
{t("ledger.reset")} {t("ledger.reset")}
</Button> </Button>
@@ -17,8 +17,8 @@ import {
brokerageEventGroupKey, brokerageEventGroupKey,
brokerageEventKey, brokerageEventKey,
} from "@/entities/brokerage-event"; } from "@/entities/brokerage-event";
import { formatDate, formatSnapshotTime, groupItems } from "@/shared/lib"; import { groupItems } from "@/shared/lib";
import { ReadRetry, SourceSyncStatus } from "@/shared/ui"; import { SourceSyncStatus } from "@/shared/ui";
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm"; import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
@@ -83,20 +83,10 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
stale={props.source?.freshness === "stale"} stale={props.source?.freshness === "stale"}
/> />
<ReadRetry {...props.readRetry} />
{!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>} {!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>}
<Text c="dimmed">{t("events.hint")}</Text> <Text c="dimmed">{t("events.hint")}</Text>
<Text c="dimmed" size="sm">
{t("events.forecastWindow", {
date: formatSnapshotTime(props.calculatedAt),
from: formatDate(props.window.from),
to: formatDate(props.window.to),
})}
</Text>
<Group align="end"> <Group align="end">
<DatePickerInput <DatePickerInput
allowSingleDateInRange allowSingleDateInRange
@@ -26,7 +26,6 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { IncomeAnalytics } from "@/entities/income"; import type { IncomeAnalytics } from "@/entities/income";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
import { formatSnapshotTime } from "@/shared/lib";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource"; import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
@@ -34,14 +33,10 @@ import { server } from "@/test/server";
import { toIncomeDisplayModel } from "../../model/display"; import { toIncomeDisplayModel } from "../../model/display";
import { AccountIncomePage } from "./AccountIncomePage"; import { AccountIncomePage } from "./AccountIncomePage";
import { import {
useAccountIncomePageVm, useAccountIncomePageViewModel,
} from "./AccountIncomePage.vm"; } from "./AccountIncomePage.vm";
import { IncomePayments } from "./components/IncomePayments.view"; import { IncomePayments } from "./components/IncomePayments.view";
// Automatic read retries wait 1s + 2s before the failure is final.
const RETRIES_EXHAUSTED_MS = 6000;
const TEST_MS = 10_000;
const analytics const analytics
= { = {
accountId: "account-1", accountId: "account-1",
@@ -171,7 +166,7 @@ describe("AccountIncomePage", () => {
); );
const queryClient = createTestQueryClient(); const queryClient = createTestQueryClient();
const { result } = renderHook(() => useAccountIncomePageVm(), { const { result } = renderHook(() => useAccountIncomePageViewModel(), {
wrapper: ({ children }) => ( wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["?from=2024-02-29&to=2024-03-01"]}> <MemoryRouter initialEntries={["?from=2024-02-29&to=2024-03-01"]}>
@@ -408,124 +403,21 @@ describe("AccountIncomePage", () => {
expect(screen.queryByText(/Сравнение с/)).not.toBeInTheDocument(); expect(screen.queryByText(/Сравнение с/)).not.toBeInTheDocument();
}); });
it.each([ it("shows a quiet realized error", async () => {
["full", analytics],
["empty", { ...analytics, buckets: [], entries: [], instruments: [], totalsByCurrency: [] }],
])("shows the server source time and last attempt for a %s result", async (_name, data) => {
server.use( server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => http.get(
HttpResponse.json( "*/api/v1/brokerage-accounts/account-1/income-analytics",
resource(data, { () => new HttpResponse(null, { status: 503 }),
freshness: "stale",
lastAttempt: {
finishedAt: "2026-08-27T01:00:00Z",
problems: [],
startedAt: "2026-08-27T00:59:00Z",
state: "failed",
},
lastSuccessfulAt: "2026-08-26T12:00:00Z",
}),
),
), ),
); );
renderPage(); renderPage();
expect(await screen.findByText(/^Данные на /)).toHaveTextContent( expect(
formatSnapshotTime("2026-08-26T12:00:00Z"), await screen.findByText("Не удалось рассчитать пассивный доход", undefined, {
); timeout: 4000,
expect(screen.getByText("Обновление не удалось")).toBeInTheDocument(); }),
expect(screen.getByText("Данные требуют обновления")).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
}); });
it(
"offers retry after an initial failure and recovers in place",
{ timeout: TEST_MS },
async () => {
let isFailing = true;
let requests = 0;
server.use(
http.get(
"*/api/v1/brokerage-accounts/account-1/income-analytics",
() => {
requests += 1;
return isFailing
? new HttpResponse(null, { status: 503 })
: HttpResponse.json(resource(analytics));
},
),
);
renderPage();
expect(
await screen.findByText(
"Не удалось рассчитать пассивный доход",
undefined,
{ timeout: RETRIES_EXHAUSTED_MS },
),
).toBeInTheDocument();
const retry = screen.getByRole("button", { name: "Повторить" });
expect(requests).toBe(3);
isFailing = false;
fireEvent.click(retry);
expect(
await screen.findByRole("region", { name: "Выплаты" }),
).toBeInTheDocument();
expect(requests).toBe(4);
},
);
it(
"keeps cached income, warns and retries the same GET",
{ timeout: TEST_MS },
async () => {
let isFailing = false;
let requests = 0;
server.use(
http.get(
"*/api/v1/brokerage-accounts/account-1/income-analytics",
() => {
requests += 1;
return isFailing
? HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
)
: HttpResponse.json(resource(analytics));
},
),
);
const { queryClient } = renderPage();
await screen.findByRole("region", { name: "Выплаты" });
expect(screen.queryByRole("button", { name: "Повторить" }))
.not.toBeInTheDocument();
isFailing = true;
void queryClient.invalidateQueries({
queryKey: ["get", "/api/v1/brokerage-accounts/{accountId}/income-analytics"],
});
const retry = await screen.findByRole(
"button",
{ name: "Повторить" },
{ timeout: RETRIES_EXHAUSTED_MS },
);
expect(screen.getByText("Не удалось обновить данные")).toBeInTheDocument();
expect(screen.getByRole("region", { name: "Выплаты" })).toBeInTheDocument();
expect(requests).toBe(4);
isFailing = false;
fireEvent.click(retry);
await waitFor(() =>
expect(screen.queryByText("Не удалось обновить данные"))
.not.toBeInTheDocument());
expect(requests).toBe(5);
},
);
}); });
const LocationProbe: FC = () => { const LocationProbe: FC = () => {
@@ -1,17 +1,17 @@
import type { FC } from "react"; import type { FC } from "react";
import { useAccountIncomePageVm } from "./AccountIncomePage.vm"; import { useAccountIncomePageViewModel } from "./AccountIncomePage.vm";
import { AccountIncomePageError } from "./components/AccountIncomePageError.view"; import { AccountIncomePageError } from "./components/AccountIncomePageError.view";
import { AccountIncomePageLoaded } from "./components/AccountIncomePageLoaded.view"; import { AccountIncomePageLoaded } from "./components/AccountIncomePageLoaded.view";
import { AccountIncomePageLoading } from "./components/AccountIncomePageLoading.view"; import { AccountIncomePageLoading } from "./components/AccountIncomePageLoading.view";
const AccountIncomePage: FC = () => { const AccountIncomePage: FC = () => {
const vm = useAccountIncomePageVm(); const viewModel = useAccountIncomePageViewModel();
if (vm.state === "loading") return <AccountIncomePageLoading />; if (viewModel.state === "loading") return <AccountIncomePageLoading />;
return vm.state === "error" return viewModel.state === "error"
? <AccountIncomePageError {...vm} /> ? <AccountIncomePageError {...viewModel} />
: <AccountIncomePageLoaded {...vm} />; : <AccountIncomePageLoaded {...viewModel} />;
}; };
export { AccountIncomePage }; export { AccountIncomePage };
@@ -5,7 +5,6 @@ import { useParams, useSearchParams } from "react-router";
import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income"; import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
import { useScreenSync } from "@/features/tbank-connection"; import { useScreenSync } from "@/features/tbank-connection";
import { type InvestmentResourceMeta, readRetry } from "@/shared/api";
import { isValidPeriod, moscowDate, useInitialAvailability } from "@/shared/lib"; import { isValidPeriod, moscowDate, useInitialAvailability } from "@/shared/lib";
import { import {
@@ -31,19 +30,16 @@ 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; totalVisible: boolean;
} }
| { | {
period: IncomePeriod; period: IncomePeriod;
readRetry: ReturnType<typeof readRetry>;
state: "error"; state: "error";
} }
| { state: "loading" }; | { state: "loading" };
const useAccountIncomePageVm = (): AccountIncomePageViewModel => { const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
const { accountId = "" } = useParams(); const { accountId = "" } = useParams();
const [searchParameters, setSearchParameters] = useSearchParams(); const [searchParameters, setSearchParameters] = useSearchParams();
@@ -88,14 +84,12 @@ const useAccountIncomePageVm = (): AccountIncomePageViewModel => {
isFetching: analytics.isFetching, isFetching: analytics.isFetching,
}); });
const retry = readRetry([analytics]);
if (availability === "loading" && analytics.isLoading) if (availability === "loading" && analytics.isLoading)
return { state: "loading" }; return { state: "loading" };
if (!analytics.data) if (!analytics.data)
return availability === "unavailable" return availability === "unavailable"
? { period, readRetry: retry, state: "error" } ? { period, state: "error" }
: { state: "loading" }; : { state: "loading" };
const display = toIncomeDisplayModel(analytics.data); const display = toIncomeDisplayModel(analytics.data);
@@ -122,8 +116,6 @@ const useAccountIncomePageVm = (): AccountIncomePageViewModel => {
onReset, onReset,
onSubmit, onSubmit,
partial: analytics.meta?.completeness === "incomplete", partial: analytics.meta?.completeness === "incomplete",
readRetry: retry,
source: analytics.meta,
state: "loaded", state: "loaded",
totalVisible: analytics.meta?.completeness === "complete", totalVisible: analytics.meta?.completeness === "complete",
}; };
@@ -146,5 +138,5 @@ function isValidIncomePeriod(period: IncomePeriod): boolean {
export { export {
type AccountIncomePageViewModel, type AccountIncomePageViewModel,
useAccountIncomePageVm, useAccountIncomePageViewModel,
}; };
@@ -3,27 +3,15 @@ import type { FC } from "react";
import { StateView } from "@wealthledger/ui"; import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ReadRetryButton } from "@/shared/ui";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm"; import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
type ErrorProps = Extract<AccountIncomePageViewModel, { state: "error" }>; type ErrorProps = Extract<AccountIncomePageViewModel, { state: "error" }>;
const AccountIncomePageError: FC<ErrorProps> = (props) => { const AccountIncomePageError: FC<ErrorProps> = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const retry = props.readRetry.exhausted
? (
<ReadRetryButton
onRetry={props.readRetry.onRetry}
retrying={props.readRetry.retrying}
/>
)
: undefined;
return ( return (
<StateView <StateView
action={retry}
error error
text={t("income.errorText")} text={t("income.errorText")}
title={t("income.errorTitle")} title={t("income.errorTitle")}
@@ -9,8 +9,6 @@ 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";
@@ -51,14 +49,6 @@ 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>
@@ -90,13 +90,9 @@ function configureTestAuth() {
}); });
} }
function renderPage( function renderPage(search = "", accountId = "account-1") {
search = "",
accountId = "account-1",
queryClient = createTestQueryClient(),
) {
return render( return render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={createTestQueryClient()}>
<MantineProvider> <MantineProvider>
<MemoryRouter <MemoryRouter
initialEntries={[`/accounts/${accountId}/operations${search}`]} initialEntries={[`/accounts/${accountId}/operations${search}`]}
@@ -113,18 +109,6 @@ function renderPage(
); );
} }
// Automatic read retries wait 1s + 2s before the failure is final.
const RETRIES_EXHAUSTED_MS = 6000;
const TEST_MS = 10_000;
const loadedOperations = {
accountId: "account-1",
hasNext: false,
items: [operation],
nextCursor: null,
observedAt: "2026-08-14T00:00:00Z",
};
describe("LedgerEntryRow", () => { describe("LedgerEntryRow", () => {
it("marks cashless operations without rendering operation details", () => { it("marks cashless operations without rendering operation details", () => {
render( render(
@@ -561,87 +545,20 @@ describe("AccountOperationsPage", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it( it("renders an error state", async () => {
"renders an error state with retry that recovers in place", configureTestAuth();
{ timeout: TEST_MS }, server.use(
async () => { http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
configureTestAuth(); HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
let isFailing = true; ),
let requests = 0; );
server.use( renderPage();
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => { expect(
requests += 1; await screen.findByText("Не удалось загрузить журнал", undefined, {
timeout: 4000,
return isFailing }),
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) ).toBeInTheDocument();
: 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();
@@ -6,15 +6,15 @@ import { AccountOperationsPageLoaded } from "./components/AccountOperationsPageL
import { AccountOperationsPageLoading } from "./components/AccountOperationsPageLoading.view"; import { AccountOperationsPageLoading } from "./components/AccountOperationsPageLoading.view";
const AccountOperationsPage: FC = () => { const AccountOperationsPage: FC = () => {
const vm = useAccountOperationsPageVm(); const viewModel = useAccountOperationsPageVm();
if (vm.state === "loading") { if (viewModel.state === "loading") {
return <AccountOperationsPageLoading />; return <AccountOperationsPageLoading />;
} }
return vm.state === "error" return viewModel.state === "error"
? <AccountOperationsPageError {...vm} /> ? <AccountOperationsPageError {...viewModel} />
: <AccountOperationsPageLoaded {...vm} />; : <AccountOperationsPageLoaded {...viewModel} />;
}; };
export { AccountOperationsPage }; export { AccountOperationsPage };
@@ -12,12 +12,7 @@ import {
useBrokerageOperationHistory, useBrokerageOperationHistory,
} from "@/entities/broker-operation"; } from "@/entities/broker-operation";
import { useScreenSync } from "@/features/tbank-connection"; import { useScreenSync } from "@/features/tbank-connection";
import { import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
ApiErrorCode,
apiErrorCode,
isApiErrorCode,
readRetry,
} from "@/shared/api";
import { import {
defaultPeriod, defaultPeriod,
isValidPeriod, isValidPeriod,
@@ -47,16 +42,11 @@ 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; totalVisible: boolean;
} }
| { | { onReset: () => void; state: "error" }
onReset: () => void;
readRetry: ReturnType<typeof readRetry>;
state: "error";
}
| { state: "loading" }; | { state: "loading" };
const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => { const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
@@ -117,8 +107,6 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
isFetching: operations.isFetching, isFetching: operations.isFetching,
}); });
const retry = readRetry([operations]);
if (availability === "loading" && operations.isLoading) if (availability === "loading" && operations.isLoading)
return { state: "loading" }; return { state: "loading" };
@@ -146,11 +134,11 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
(isInvalidCursor || !operations.data) (isInvalidCursor || !operations.data)
&& (operations.isError || operations.meta?.lastAttempt.state === "failed") && (operations.isError || operations.meta?.lastAttempt.state === "failed")
) )
return { onReset, readRetry: retry, state: "error" }; return { onReset, state: "error" };
if (!operations.data) if (!operations.data)
return availability === "unavailable" return availability === "unavailable"
? { onReset, readRetry: retry, state: "error" } ? { onReset, state: "error" }
: { state: "loading" }; : { state: "loading" };
const nextCursor = operations.data.nextCursor; const nextCursor = operations.data.nextCursor;
@@ -194,7 +182,6 @@ 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", totalVisible: operations.meta?.completeness === "complete",
@@ -1,11 +1,9 @@
import type { FC } from "react"; import type { FC } from "react";
import { Button, Group } from "@mantine/core"; import { Button } from "@mantine/core";
import { StateView } from "@wealthledger/ui"; import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ReadRetryButton } from "@/shared/ui";
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm"; import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
type AccountOperationsPageErrorProps = Extract< type AccountOperationsPageErrorProps = Extract<
@@ -21,18 +19,9 @@ const AccountOperationsPageError: FC<AccountOperationsPageErrorProps> = (
return ( return (
<StateView <StateView
action={( action={(
<Group gap="sm" justify="center"> <Button onClick={props.onReset} variant="default">
{props.readRetry.exhausted && ( {t("ledger.reset")}
<ReadRetryButton </Button>
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")}
@@ -19,7 +19,7 @@ import {
OperationGroup, OperationGroup,
} from "@/entities/broker-operation"; } from "@/entities/broker-operation";
import { groupItems } from "@/shared/lib"; import { groupItems } from "@/shared/lib";
import { ReadRetry, SourceSyncStatus } from "@/shared/ui"; import { SourceSyncStatus } from "@/shared/ui";
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm"; import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
@@ -102,8 +102,6 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
stale={props.source?.freshness === "stale"} stale={props.source?.freshness === "stale"}
/> />
<ReadRetry {...props.readRetry} />
{!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>} {!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>}
{!!props.historyLoading && <Alert>{t("investment.history")}</Alert>} {!!props.historyLoading && <Alert>{t("investment.history")}</Alert>}
+4 -22
View File
@@ -23,7 +23,7 @@ function sections(positions: Portfolio["positions"]) {
} }
describe("account position sections", () => { describe("account position sections", () => {
it("excludes currency mirrors from display and share denominators", () => { it("excludes currency instruments only from display, preserving order and share denominators", () => {
const otherTypes = [ const otherTypes = [
"OPTION", "OPTION",
"OTHER", "OTHER",
@@ -73,30 +73,12 @@ describe("account position sections", () => {
"FIGI-FUND", "FIGI-FUND",
...otherTypes.map(type => `FIGI-${type}-NEXT`), ...otherTypes.map(type => `FIGI-${type}-NEXT`),
]); ]);
expect(sections[0]?.groups[0]?.positions[0].shareText).toBe("50 %"); expect(sections[0]?.groups[0]?.positions[0].shareText).toBe("40 %");
expect(sections[1]?.groups[0]?.positions[0].shareText).toBe("25 %"); expect(sections[1]?.groups[0]?.positions[0].shareText).toBe("20 %");
expect(sections[2]?.groups[1]?.positions[0].shareText).toBe("25 %"); expect(sections[2]?.groups[1]?.positions[0].shareText).toBe("20 %");
expect(snapshot).toEqual(original); expect(snapshot).toEqual(original);
}); });
it("keeps all-account positions from both accounts without counting mirrored cash", () => {
const result = sections([
makePosition({ account: { id: "a", name: "Основной" }, currentValue: { currency: "RUB", value: "100" } }),
makePosition({ account: { id: "a", name: "Основной" }, currentValue: { currency: "RUB", value: "50" }, type: "CURRENCY" }),
makePosition({ account: { id: "b", name: "ИИС" }, currentValue: { currency: "RUB", value: "100" } }),
]);
const visible = result.flatMap(section =>
section.groups.flatMap(group => group.positions));
expect(visible)
.toEqual(expect.arrayContaining([
expect.objectContaining({ account: { id: "a", name: "Основной" }, shareText: "50 %" }),
expect.objectContaining({ account: { id: "b", name: "ИИС" }, shareText: "50 %" }),
]));
expect(visible).toHaveLength(2);
});
it("prepares grouped position sections with derived display data", () => { it("prepares grouped position sections with derived display data", () => {
expect( expect(
toAccountPagePositionSections(portfolio).map(({ groups, kind }) => ({ toAccountPagePositionSections(portfolio).map(({ groups, kind }) => ({
+1 -1
View File
@@ -152,7 +152,7 @@ function toAccountPagePositionSections(
const denominators = new Map<string, number>(); const denominators = new Map<string, number>();
for (const position of portfolio.positions) { for (const position of portfolio.positions) {
if (position.type === "CURRENCY" || !position.currentValue) continue; if (!position.currentValue) continue;
const currency = position.currentValue.currency; const currency = position.currentValue.currency;
denominators.set( denominators.set(
currency, currency,
@@ -18,7 +18,6 @@ import type { BrokerageEvent } from "@/entities/brokerage-event";
import { type Portfolio, portfolioQueryOptions } from "@/entities/portfolio"; import { type Portfolio, portfolioQueryOptions } from "@/entities/portfolio";
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";
@@ -60,10 +59,6 @@ function configureTestAuth() {
}); });
} }
// Automatic read retries wait 1s + 2s before the failure is final.
const RETRIES_EXHAUSTED_MS = 6000;
const TEST_MS = 10_000;
function renderAccountRoute(queryClient: QueryClient, accountId = "account-1") { function renderAccountRoute(queryClient: QueryClient, accountId = "account-1") {
configureTestAuth(); configureTestAuth();
@@ -236,37 +231,6 @@ describe("AccountPage states", () => {
.toHaveAccessibleDescription(/Акция: 100 ₽, 100 %/); .toHaveAccessibleDescription(/Акция: 100 ₽, 100 %/);
}); });
it("shows server source time and attempt for an empty account", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(resource(
{
...portfolio,
cashBalances: [],
isStale: false,
positions: [],
totalsByCurrency: [],
},
{
lastAttempt: {
finishedAt: "2026-08-14T01:00:00Z",
problems: [],
startedAt: "2026-08-14T00:59:00Z",
state: "failed",
},
lastSuccessfulAt: "2026-08-13T12:00:00Z",
},
)),
),
);
renderAccountRoute(createTestQueryClient());
expect(await screen.findByText(/^Данные на /)).toHaveTextContent(
formatSnapshotTime("2026-08-13T12:00:00Z"),
);
expect(screen.getByText("Обновление не удалось")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
});
it("expands and resets other when switching slice", () => { it("expands and resets other when switching slice", () => {
const positions = [400, 250, 150, 100, 50, 30, 20].map( const positions = [400, 250, 150, 100, 50, 30, 20].map(
(value, index) => position({ (value, index) => position({
@@ -910,6 +874,7 @@ describe("AccountPage states", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.queryByText(/T-Bank временно недоступен/)) expect(screen.queryByText(/T-Bank временно недоступен/))
.not.toBeInTheDocument(); .not.toBeInTheDocument();
expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
expect(screen.getByRole("radio", { name: "Акции" })).toBeInTheDocument(); expect(screen.getByRole("radio", { name: "Акции" })).toBeInTheDocument();
expect( expect(
screen.getByRole("radio", { name: "Облигации" }), screen.getByRole("radio", { name: "Облигации" }),
@@ -1259,110 +1224,6 @@ describe("AccountPage states", () => {
expect(screen.getByText("Вторая")).toBeInTheDocument(); expect(screen.getByText("Вторая")).toBeInTheDocument();
}); });
it(
"keeps cached totals, then warns and retries the same portfolio GET",
{ timeout: TEST_MS },
async () => {
let isFailing = false;
let requests = 0;
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
requests += 1;
if (isFailing)
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
return HttpResponse.json(resource(
requests === 1
? portfolio
: { ...portfolio, totalsByCurrency: [{ currency: "RUB", value: "999" }] },
{ resourceKey: "portfolio" },
));
}),
);
const queryClient = createTestQueryClient();
renderAccountRoute(queryClient);
await screen.findByText("Вторая");
expect(screen.queryByRole("button", { name: "Повторить" }))
.not.toBeInTheDocument();
isFailing = true;
void queryClient.invalidateQueries({
queryKey: portfolioQueryOptions("account-1").queryKey,
});
const retry = await screen.findByRole(
"button",
{ name: "Повторить" },
{ timeout: RETRIES_EXHAUSTED_MS },
);
expect(screen.getByText("Не удалось обновить данные")).toBeInTheDocument();
expect(screen.getByText("Вторая")).toBeInTheDocument();
expect(screen.getByText("1 234,5 ₽")).toBeInTheDocument();
// One cached read plus the three automatic attempts.
expect(requests).toBe(4);
isFailing = false;
fireEvent.click(retry);
expect(await screen.findByText("999 ₽")).toBeInTheDocument();
expect(requests).toBe(5);
expect(screen.queryByText("Не удалось обновить данные"))
.not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Повторить" }))
.not.toBeInTheDocument();
},
);
it(
"offers retry after an initial temporary failure and recovers in place",
{ timeout: TEST_MS },
async () => {
let isFailing = true;
let requests = 0;
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
requests += 1;
return isFailing
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(resource(portfolio, { resourceKey: "portfolio" }));
}),
);
renderAccountRoute(createTestQueryClient());
const retry = await screen.findByRole(
"button",
{ name: "Повторить" },
{ timeout: RETRIES_EXHAUSTED_MS },
);
expect(requests).toBe(3);
isFailing = false;
fireEvent.click(retry);
expect(await screen.findByText("Вторая")).toBeInTheDocument();
expect(requests).toBe(4);
},
);
it.each(["NOT_FOUND", "UNAUTHORIZED"])(
"does not offer a plain retry for %s after a cached portfolio",
async (code) => {
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json({ code }, { status: code === "NOT_FOUND" ? 404 : 401 })),
);
renderAccountRoute(createTestQueryClient());
await waitFor(() =>
expect(screen.queryByRole("heading", { name: "Загрузка" })).not.toBeInTheDocument());
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
},
);
it.each([503])( it.each([503])(
"preserves totals quietly through refresh error %i and success", "preserves totals quietly through refresh error %i and success",
async (status) => { async (status) => {
@@ -1415,7 +1276,7 @@ describe("AccountPage states", () => {
}); });
expect(structure).toHaveTextContent("10 ₽"); expect(structure).toHaveTextContent("10 ₽");
expect(screen.getByText(/^Данные на /)).toBeInTheDocument(); expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: portfolioQueryOptions("account-1").queryKey, queryKey: portfolioQueryOptions("account-1").queryKey,
}); });
@@ -1459,7 +1320,7 @@ describe("AccountPage states", () => {
screen.getAllByRole("definition").map(node => node.textContent), screen.getAllByRole("definition").map(node => node.textContent),
).toEqual(["-0,5 $"]), ).toEqual(["-0,5 $"]),
); );
expect(screen.getByText(/^Данные на /)).toHaveTextContent("Данные на"); expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
expect(structure).not.toHaveTextContent("10 ₽"); expect(structure).not.toHaveTextContent("10 ₽");
expect(structure).toHaveTextContent("Нет положительной стоимости"); expect(structure).toHaveTextContent("Нет положительной стоимости");
expect( expect(
@@ -1500,7 +1361,7 @@ describe("AccountPage states", () => {
expect( expect(
screen.getAllByRole("definition").map(node => node.textContent), screen.getAllByRole("definition").map(node => node.textContent),
).toEqual(["1 234,5 ₽"]); ).toEqual(["1 234,5 ₽"]);
expect(screen.getByText(/^Данные на /)).toBeInTheDocument(); expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
expect( expect(
screen.queryByRole("status", { name: "Загрузка" }), screen.queryByRole("status", { name: "Загрузка" }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
@@ -1,48 +1,45 @@
import type { FC } from "react"; import type { FC } from "react";
import { useAccountPageVm } from "./AccountPage.vm"; import { useAccountPageViewModel } from "./AccountPage.vm";
import { AccountPageEmpty } from "./components/AccountPageEmpty.view"; import { AccountPageEmpty } from "./components/AccountPageEmpty.view";
import { AccountPageError } from "./components/AccountPageError.view"; import { AccountPageError } from "./components/AccountPageError.view";
import { AccountPageLoaded } from "./components/AccountPageLoaded.view"; import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
import { AccountPageLoading } from "./components/AccountPageLoading/AccountPageLoading.view"; import { AccountPageLoading } from "./components/AccountPageLoading/AccountPageLoading.view";
const AccountPage: FC = () => { const AccountPage: FC = () => {
const vm = useAccountPageVm(); const viewModel = useAccountPageViewModel();
if (vm.state === "loading") return <AccountPageLoading />; if (viewModel.state === "loading") return <AccountPageLoading />;
if (vm.state === "error") return <AccountPageError {...vm} />; if (viewModel.state === "error") return <AccountPageError {...viewModel} />;
if (vm.state === "empty") if (viewModel.state === "empty")
return ( return (
<AccountPageEmpty <AccountPageEmpty
account={vm.account} account={viewModel.account}
accountId={vm.accountId} accountId={viewModel.accountId}
incomeSummary={vm.incomeSummary} incomeSummary={viewModel.incomeSummary}
readRetry={vm.readRetry} recentEvents={viewModel.recentEvents}
recentEvents={vm.recentEvents} recentOperations={viewModel.recentOperations}
recentOperations={vm.recentOperations}
source={vm.source}
/> />
); );
return ( return (
<AccountPageLoaded <AccountPageLoaded
account={vm.account} account={viewModel.account}
accountId={vm.accountId} accountId={viewModel.accountId}
concentration={vm.concentration} concentration={viewModel.concentration}
currency={vm.currency} currency={viewModel.currency}
incomeSummary={vm.incomeSummary} incomeSummary={viewModel.incomeSummary}
onCurrencyChange={vm.onCurrencyChange} onCurrencyChange={viewModel.onCurrencyChange}
onSliceChange={vm.onSliceChange} onSliceChange={viewModel.onSliceChange}
onTabChange={vm.onTabChange} onTabChange={viewModel.onTabChange}
partial={vm.partial} partial={viewModel.partial}
portfolio={vm.portfolio} portfolio={viewModel.portfolio}
positionSections={vm.positionSections} positionSections={viewModel.positionSections}
readRetry={vm.readRetry} recentEvents={viewModel.recentEvents}
recentEvents={vm.recentEvents} recentOperations={viewModel.recentOperations}
recentOperations={vm.recentOperations} slice={viewModel.slice}
slice={vm.slice} source={viewModel.source}
source={vm.source} tab={viewModel.tab}
tab={vm.tab} unrealizedReturns={viewModel.unrealizedReturns}
unrealizedReturns={vm.unrealizedReturns}
/> />
); );
}; };
@@ -12,7 +12,7 @@ import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { portfolio } from "../../model/positions.fixtures"; import { portfolio } from "../../model/positions.fixtures";
import { useAccountPageVm } from "./AccountPage.vm"; import { useAccountPageViewModel } from "./AccountPage.vm";
function configureTestAuth() { function configureTestAuth() {
configureAuthClient({ configureAuthClient({
@@ -102,12 +102,12 @@ function mockLoadedAccountPage() {
); );
} }
describe("useAccountPageVm", () => { describe("useAccountPageViewModel", () => {
it("prepares recent operations and events state for presentation views", async () => { it("prepares recent operations and events state for presentation views", async () => {
configureTestAuth(); configureTestAuth();
mockLoadedAccountPage(); mockLoadedAccountPage();
const { result } = renderHook(() => useAccountPageVm(), { const { result } = renderHook(() => useAccountPageViewModel(), {
wrapper: createWrapper(), wrapper: createWrapper(),
}); });
@@ -154,7 +154,7 @@ describe("useAccountPageVm", () => {
), ),
); );
const { result } = renderHook(() => useAccountPageVm(), { const { result } = renderHook(() => useAccountPageViewModel(), {
wrapper: createWrapper(), wrapper: createWrapper(),
}); });
@@ -194,7 +194,7 @@ it("keeps overview sections independent when their source data is incomplete", a
), ),
); );
const { result } = renderHook(() => useAccountPageVm(), { const { result } = renderHook(() => useAccountPageViewModel(), {
wrapper: createWrapper(), wrapper: createWrapper(),
}); });
@@ -232,7 +232,7 @@ it("keeps overview sections loaded without exposing technical retry", async () =
), ),
); );
const { result } = renderHook(() => useAccountPageVm(), { const { result } = renderHook(() => useAccountPageViewModel(), {
wrapper: createWrapper(), wrapper: createWrapper(),
}); });
@@ -16,7 +16,7 @@ import {
import { useIncomeSummary } from "@/entities/income"; import { useIncomeSummary } from "@/entities/income";
import { usePortfolio } from "@/entities/portfolio"; import { usePortfolio } from "@/entities/portfolio";
import { useScreenSync } from "@/features/tbank-connection"; import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode, isBrokerageAccessLoss, readRetry } from "@/shared/api"; import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
import { useInitialAvailability } from "@/shared/lib"; import { useInitialAvailability } from "@/shared/lib";
import { import {
@@ -45,10 +45,8 @@ type AccountPageContent = {
accountId?: string; accountId?: string;
incomeSummary: AccountPageIncomeSummary; incomeSummary: AccountPageIncomeSummary;
partial?: boolean; partial?: boolean;
readRetry: AccountPageReadRetry;
recentEvents: AccountPageRecentEvents; recentEvents: AccountPageRecentEvents;
recentOperations: AccountPageRecentOperations; recentOperations: AccountPageRecentOperations;
source?: InvestmentResourceMeta;
}; };
type AccountPageEmptyViewModel = AccountPageContent & { type AccountPageEmptyViewModel = AccountPageContent & {
@@ -64,20 +62,25 @@ type AccountPageLoadedViewModel = AccountPageContent & {
portfolio: Portfolio; portfolio: Portfolio;
positionSections: AccountPagePositionSection[]; positionSections: AccountPagePositionSection[];
slice: Slice; slice: Slice;
source?: InvestmentResourceMeta;
state: "loaded"; state: "loaded";
tab: string; tab: string;
unrealizedReturns: Portfolio["totalsByCurrency"]; unrealizedReturns: Portfolio["totalsByCurrency"];
}; };
type AccountPageReadRetry = ReturnType<typeof readRetry>;
type AccountPageViewModel type AccountPageViewModel
= | AccountPageEmptyViewModel = | AccountPageEmptyViewModel
| AccountPageLoadedViewModel | AccountPageLoadedViewModel
| { code?: string; readRetry: AccountPageReadRetry; state: "error" } | { code?: string; state: "error" }
| { state: "loading" }; | { state: "loading" };
function useAccountPageVm(): AccountPageViewModel { function isPortfolioAccessFailure(code?: string) {
return isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)
|| isApiErrorCode(code, ApiErrorCode.NotFound)
|| isApiErrorCode(code, ApiErrorCode.TbankNotConnected);
}
function useAccountPageViewModel(): AccountPageViewModel {
const { accountId } = useParams(); const { accountId } = useParams();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -116,9 +119,7 @@ function useAccountPageVm(): AccountPageViewModel {
]); ]);
const code = apiErrorCode(portfolio.error); const code = apiErrorCode(portfolio.error);
// The portfolio drives this screen; the retry repeats only its GET. const isAccessFailure = isPortfolioAccessFailure(code);
const retry = readRetry([portfolio]);
const isAccessFailure = isBrokerageAccessLoss(code);
const availability = useInitialAvailability({ const availability = useInitialAvailability({
hasContent: !!portfolio.data, hasContent: !!portfolio.data,
@@ -127,13 +128,12 @@ function useAccountPageVm(): AccountPageViewModel {
isFetching: portfolio.isFetching, isFetching: portfolio.isFetching,
}); });
if (isAccessFailure) if (isAccessFailure) return { code, state: "error" as const };
return { code, readRetry: retry, state: "error" as const };
if (!portfolio.data) if (!portfolio.data)
return availability === "unavailable" return {
? { code, readRetry: retry, state: "error" as const } state: availability === "unavailable" ? "error" : "loading",
: { state: "loading" as const }; } as const;
const account = accounts.data?.find(item => item.id === accountId); const account = accounts.data?.find(item => item.id === accountId);
const recentOperations = toRecentOperations(accountId, operations); const recentOperations = toRecentOperations(accountId, operations);
@@ -150,10 +150,8 @@ function useAccountPageVm(): AccountPageViewModel {
account, account,
accountId, accountId,
incomeSummary, incomeSummary,
readRetry: retry,
recentEvents, recentEvents,
recentOperations, recentOperations,
source: portfolio.meta,
state: "empty" as const, state: "empty" as const,
}; };
@@ -188,7 +186,6 @@ function useAccountPageVm(): AccountPageViewModel {
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, slice: current.slice,
@@ -199,4 +196,4 @@ function useAccountPageVm(): AccountPageViewModel {
}; };
} }
export { useAccountPageVm }; export { useAccountPageViewModel };
@@ -5,10 +5,9 @@ import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { BrokerageAccount } from "@/entities/brokerage-account"; import type { BrokerageAccount } from "@/entities/brokerage-account";
import type { InvestmentResourceMeta } from "@/shared/api";
import { BrokerageEventsPreview } from "@/entities/brokerage-event"; import { BrokerageEventsPreview } from "@/entities/brokerage-event";
import { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui"; import { PortfolioStructure } from "@/widgets/portfolio-structure";
import type { import type {
AccountPageIncomeSummary, AccountPageIncomeSummary,
@@ -18,17 +17,14 @@ import type {
import { AccountHeading } from "./AccountHeading.view"; import { AccountHeading } from "./AccountHeading.view";
import { IncomeSummary } from "./IncomeSummary.view"; import { IncomeSummary } from "./IncomeSummary.view";
import { PortfolioStructure } from "./PortfolioStructure/PortfolioStructure";
import { RecentOperations } from "./RecentOperations.view"; import { RecentOperations } from "./RecentOperations.view";
type AccountPageEmptyProps = { type AccountPageEmptyProps = {
account?: BrokerageAccount; account?: BrokerageAccount;
accountId?: string; accountId?: string;
incomeSummary?: AccountPageIncomeSummary; incomeSummary?: AccountPageIncomeSummary;
readRetry?: ReadRetryProps;
recentEvents: AccountPageRecentEvents; recentEvents: AccountPageRecentEvents;
recentOperations: AccountPageRecentOperations; recentOperations: AccountPageRecentOperations;
source?: InvestmentResourceMeta;
}; };
const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => { const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
@@ -44,14 +40,6 @@ 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
@@ -6,12 +6,8 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import { ApiErrorCode, isApiErrorCode } from "@/shared/api"; import { ApiErrorCode, isApiErrorCode } from "@/shared/api";
import { ReadRetryButton, type ReadRetryProps } from "@/shared/ui";
type AccountPageErrorProps = { type AccountPageErrorProps = { code?: string };
code?: string;
readRetry?: ReadRetryProps;
};
const AccountPageError: FC<AccountPageErrorProps> = (props) => { const AccountPageError: FC<AccountPageErrorProps> = (props) => {
const { code } = props; const { code } = props;
@@ -47,18 +43,8 @@ const AccountPageError: FC<AccountPageErrorProps> = (props) => {
/> />
); );
const retry = props.readRetry?.exhausted
? (
<ReadRetryButton
onRetry={props.readRetry.onRetry}
retrying={props.readRetry.retrying}
/>
)
: undefined;
return ( return (
<StateView <StateView
action={retry}
text={t("states.unavailableText")} text={t("states.unavailableText")}
title={t("states.unavailableTitle")} title={t("states.unavailableTitle")}
/> />
@@ -15,7 +15,8 @@ import type { InvestmentResourceMeta } from "@/shared/api";
import { BrokerageEventsPreview } from "@/entities/brokerage-event"; import { BrokerageEventsPreview } from "@/entities/brokerage-event";
import { formatMoney } from "@/shared/lib"; import { formatMoney } from "@/shared/lib";
import { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui"; import { SourceSyncStatus } from "@/shared/ui";
import { PortfolioStructure } from "@/widgets/portfolio-structure";
import type { Concentration as ConcentrationModel, Slice } from "../../../model/concentration"; import type { Concentration as ConcentrationModel, Slice } from "../../../model/concentration";
import type { AccountPagePositionSection } from "../../../model/positions"; import type { AccountPagePositionSection } from "../../../model/positions";
@@ -30,7 +31,6 @@ import { AccountHeading } from "./AccountHeading.view";
import { Concentration } from "./Concentration/Concentration.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 { PortfolioStructure } from "./PortfolioStructure/PortfolioStructure";
import { PositionSection } from "./PositionSection.view"; import { PositionSection } from "./PositionSection.view";
import { RecentOperations } from "./RecentOperations.view"; import { RecentOperations } from "./RecentOperations.view";
@@ -46,7 +46,6 @@ type AccountPageLoadedProps = {
partial?: boolean; partial?: boolean;
portfolio: Portfolio; portfolio: Portfolio;
positionSections: AccountPagePositionSection[]; positionSections: AccountPagePositionSection[];
readRetry?: ReadRetryProps;
recentEvents: AccountPageRecentEvents; recentEvents: AccountPageRecentEvents;
recentOperations: AccountPageRecentOperations; recentOperations: AccountPageRecentOperations;
slice: Slice; slice: Slice;
@@ -87,8 +86,6 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
stale={props.source?.freshness === "stale"} stale={props.source?.freshness === "stale"}
/> />
{!!props.readRetry && <ReadRetry {...props.readRetry} />}
{props.partial {props.partial
? tab !== "concentration" && ( ? tab !== "concentration" && (
<Alert role="status">{t("investment.partial")}</Alert> <Alert role="status">{t("investment.partial")}</Alert>
@@ -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 { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { 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";
@@ -261,101 +261,6 @@ 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) => {
@@ -1,22 +1,21 @@
import type { FC } from "react"; import type { FC } from "react";
import { useAccountsPageVm } from "./AccountsPage.vm"; import { useAccountsPageViewModel } from "./AccountsPage.vm";
import { AccountsPageEmpty } from "./components/AccountsPageEmpty.view"; import { AccountsPageEmpty } from "./components/AccountsPageEmpty.view";
import { AccountsPageError } from "./components/AccountsPageError.view"; import { AccountsPageError } from "./components/AccountsPageError.view";
import { AccountsPageLoaded } from "./components/AccountsPageLoaded.view"; import { AccountsPageLoaded } from "./components/AccountsPageLoaded.view";
import { AccountsPageLoading } from "./components/AccountsPageLoading.view"; import { AccountsPageLoading } from "./components/AccountsPageLoading.view";
const AccountsPage: FC = () => { const AccountsPage: FC = () => {
const vm = useAccountsPageVm(); const viewModel = useAccountsPageViewModel();
if (vm.state === "loading") return <AccountsPageLoading />; if (viewModel.state === "loading") return <AccountsPageLoading />;
if (vm.state === "error") return <AccountsPageError {...vm} />; if (viewModel.state === "error") return <AccountsPageError {...viewModel} />;
if (vm.state === "empty") return <AccountsPageEmpty />; if (viewModel.state === "empty") return <AccountsPageEmpty />;
return ( return (
<AccountsPageLoaded <AccountsPageLoaded
accounts={vm.accounts} accounts={viewModel.accounts}
meta={vm.meta} meta={viewModel.meta}
readRetry={vm.readRetry}
/> />
); );
}; };
@@ -4,38 +4,25 @@ import {
useBrokerageAccounts, useBrokerageAccounts,
} from "@/entities/brokerage-account"; } from "@/entities/brokerage-account";
import { useScreenSync } from "@/features/tbank-connection"; import { useScreenSync } from "@/features/tbank-connection";
import { import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
ApiErrorCode,
apiErrorCode,
isApiErrorCode,
readRetry,
} from "@/shared/api";
import { 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" }
accounts: BrokerageAccountCard[]; | { code?: string; state: "error" }
meta: NonNullable<ReturnType<typeof useBrokerageAccounts>["meta"]>; | { state: "empty" }
readRetry: AccountsPageReadRetry; | { state: "loading" };
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)
|| isApiErrorCode(code, ApiErrorCode.TbankNotConnected); || isApiErrorCode(code, ApiErrorCode.TbankNotConnected);
} }
function useAccountsPageVm(): AccountsPageViewModel { function useAccountsPageViewModel(): AccountsPageViewModel {
const accounts = useBrokerageAccounts(); const accounts = useBrokerageAccounts();
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
@@ -49,13 +36,12 @@ function useAccountsPageVm(): AccountsPageViewModel {
isFetching: accounts.isFetching, isFetching: accounts.isFetching,
}); });
if (isAccessFailure) if (isAccessFailure) return { code, state: "error" as const };
return { code, readRetry: retry, state: "error" as const };
if (!hasResult || !accounts.data) if (!hasResult || !accounts.data)
return availability === "unavailable" return {
? { code, readRetry: retry, state: "error" as const } state: availability === "unavailable" ? "error" : "loading",
: { state: "loading" as const }; } as const;
if (accounts.data.length === 0) if (accounts.data.length === 0)
return { return {
@@ -68,9 +54,8 @@ function useAccountsPageVm(): 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,
}; };
} }
export { useAccountsPageVm }; export { useAccountsPageViewModel };
@@ -6,12 +6,8 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import { ApiErrorCode, isApiErrorCode } from "@/shared/api"; import { ApiErrorCode, isApiErrorCode } from "@/shared/api";
import { ReadRetryButton, type ReadRetryProps } from "@/shared/ui";
type AccountsPageErrorProps = { type AccountsPageErrorProps = { code?: string };
code?: string;
readRetry?: ReadRetryProps;
};
const AccountsPageError: FC<AccountsPageErrorProps> = (props) => { const AccountsPageError: FC<AccountsPageErrorProps> = (props) => {
const { code } = props; const { code } = props;
@@ -42,18 +38,8 @@ 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")}
/> />
@@ -11,7 +11,7 @@ import {
BrokerageAccountList, BrokerageAccountList,
} from "@/entities/brokerage-account"; } from "@/entities/brokerage-account";
import { formatMoney } from "@/shared/lib"; import { formatMoney } from "@/shared/lib";
import { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui"; import { SourceSyncStatus } from "@/shared/ui";
const DECIMAL_BASE = 10n; const DECIMAL_BASE = 10n;
const DECIMAL_PART_COUNT = 2; const DECIMAL_PART_COUNT = 2;
@@ -20,7 +20,6 @@ const ZERO = 0n;
type AccountsPageLoadedProps = { type AccountsPageLoadedProps = {
accounts: BrokerageAccountCard[]; accounts: BrokerageAccountCard[];
meta: InvestmentResourceMeta; meta: InvestmentResourceMeta;
readRetry?: ReadRetryProps;
}; };
type Money = { currency: string; value: string }; type Money = { currency: string; value: string };
@@ -133,8 +132,6 @@ const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
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
+1 -16
View File
@@ -347,6 +347,7 @@ 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();
@@ -354,22 +355,6 @@ 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 %");
@@ -120,52 +120,11 @@ const useOperationFormVm = (props: OperationFormProps) => {
body: { ...body, version }, body: { ...body, version },
id: props.operation.id, id: props.operation.id,
}); });
else { else
const base = { await operationCreation.mutateAsync({
amount: body.amount,
cardId: values.cardId,
date: values.date,
description: values.description,
id: intent, id: intent,
}; ...body,
});
switch (values.kind) {
case "expense": {
await operationCreation.mutateAsync({
...base,
categoryId: values.categoryId || defaultCategoryId,
kind: values.kind,
});
break;
}
case "refund": {
await operationCreation.mutateAsync({
...base,
kind: values.kind,
originalExpenseId: values.originalExpenseId,
});
break;
}
case "topup": {
await operationCreation.mutateAsync({
...base,
kind: values.kind,
});
break;
}
case "transfer": {
await operationCreation.mutateAsync({
...base,
kind: values.kind,
targetCardId: values.targetCardId,
});
break;
}
}
}
notifications.show({ notifications.show({
id: "bank-operation-saved-" + actionId, id: "bank-operation-saved-" + actionId,
+2 -2
View File
@@ -1,13 +1,13 @@
import type { FC } from "react"; import type { FC } from "react";
import { useDocumentTitle } from "@mantine/hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AuthPageLayout, LoginForm } from "@/features/auth"; import { AuthPageLayout, LoginForm } from "@/features/auth";
import { useAppDocumentTitle } from "@/shared/ui";
const LoginPage: FC = () => { const LoginPage: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
useAppDocumentTitle(t("auth.login")); useDocumentTitle(`${t("auth.login")} — ${t("appName")}`);
return ( return (
<AuthPageLayout title={t("auth.login")}> <AuthPageLayout title={t("auth.login")}>
+2 -3
View File
@@ -1,15 +1,14 @@
import type { FC } from "react"; import type { FC } from "react";
import { Button, Stack, Title } from "@mantine/core"; import { Button, Stack, Title } from "@mantine/core";
import { useDocumentTitle } from "@mantine/hooks";
import { StateView } from "@wealthledger/ui"; import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import { useAppDocumentTitle } from "@/shared/ui";
const NotFoundPage: FC = () => { const NotFoundPage: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
useAppDocumentTitle(t("notFound.title")); useDocumentTitle(`${t("notFound.title")} — ${t("appName")}`);
return ( return (
<Stack> <Stack>
+2 -2
View File
@@ -1,13 +1,13 @@
import type { FC } from "react"; import type { FC } from "react";
import { useDocumentTitle } from "@mantine/hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AuthPageLayout, RegisterForm } from "@/features/auth"; import { AuthPageLayout, RegisterForm } from "@/features/auth";
import { useAppDocumentTitle } from "@/shared/ui";
const RegisterPage: FC = () => { const RegisterPage: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
useAppDocumentTitle(t("auth.register")); useDocumentTitle(`${t("auth.register")} — ${t("appName")}`);
return ( return (
<AuthPageLayout title={t("auth.register")}> <AuthPageLayout title={t("auth.register")}>
@@ -1,24 +1,13 @@
import type { FC } from "react"; import type { FC } from "react";
import { Button, Stack, Title } from "@mantine/core"; import { Button, Stack, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useAuth } from "@/features/auth";
import { TbankConnectionSection } from "@/features/tbank-connection"; import { TbankConnectionSection } from "@/features/tbank-connection";
const SettingsPage: FC = () => { import { useSettingsPageVm } from "./SettingsPage.vm";
const { t } = useTranslation();
const { logout } = useAuth();
// eslint-disable-next-line frontend/readability -- the page VM was removed as degenerate (frontend#143); a one-off error-swallowing logout is not a ViewModel. const SettingsPage: FC = () => {
const onLogout = async () => { const { onLogout, t } = useSettingsPageVm();
try {
await logout();
}
catch {
// The session is already cleared; the route guard owns navigation.
}
};
return ( return (
<Stack> <Stack>
@@ -0,0 +1,21 @@
import { useTranslation } from "react-i18next";
import { useAuth } from "@/features/auth";
function useSettingsPageVm() {
const { t } = useTranslation();
const { logout } = useAuth();
const onLogout = async () => {
try {
await logout();
}
catch {
// The session is already cleared; the route guard owns navigation.
}
};
return { onLogout, t };
}
export { useSettingsPageVm };
@@ -81,7 +81,6 @@ 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;
@@ -134,7 +133,7 @@ function setup(
), ),
); );
if (params.section === "company") if (params.section === "company")
return (options.failCompanyAfter?.value ?? options.failCompany) return options.failCompany
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json( : HttpResponse.json(
@@ -397,6 +396,7 @@ 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,39 +404,8 @@ 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("Зачисление дивидендов");
+52 -40
View File
@@ -6,7 +6,10 @@ import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource"; import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { useAuthenticatedQuery } from "./authenticated-query"; import {
useAuthenticatedQueries,
useAuthenticatedQuery,
} from "./authenticated-query";
import { apiQueryClient, configureAuthClient } from "./client"; import { apiQueryClient, configureAuthClient } from "./client";
const useSingleQuery = () => const useSingleQuery = () =>
@@ -14,49 +17,58 @@ const useSingleQuery = () =>
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"), apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"),
); );
const useMultipleQueries = () =>
useAuthenticatedQueries([
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"),
])[0];
describe("useAuthenticatedQuery", () => { describe("useAuthenticatedQuery", () => {
it("keeps protected queries idle without a session and loads after session configuration", async () => { it.each(["single", "multiple"])(
const requests = vi.fn(); "keeps %s protected queries idle without a session and loads after session configuration",
server.use( async (mode) => {
http.get("*/api/v1/brokerage-accounts", () => { const requests = vi.fn();
requests(); server.use(
http.get("*/api/v1/brokerage-accounts", () => {
requests();
return HttpResponse.json( return HttpResponse.json(
resource([ resource([
{ {
id: "account-1", id: "account-1",
name: "Основной", name: "Основной",
openedOn: "2024-08-13", openedOn: "2024-08-13",
type: "BROKERAGE", type: "BROKERAGE",
}, },
]), ]),
); );
}), }),
); );
const { result } = renderHook(useSingleQuery, { const { result } = renderHook(
wrapper: createQueryWrapper().wrapper, mode === "single" ? useSingleQuery : useMultipleQueries,
}); { wrapper: createQueryWrapper().wrapper },
);
await new Promise(resolve => setTimeout(resolve, 0)); await new Promise(resolve => setTimeout(resolve, 0));
expect(requests).not.toHaveBeenCalled(); expect(requests).not.toHaveBeenCalled();
expect(result.current?.fetchStatus).toBe("idle"); expect(result.current?.fetchStatus).toBe("idle");
configureAuthClient({ configureAuthClient({
accessToken: () => "token", accessToken: () => "token",
clear: vi.fn(), clear: vi.fn(),
refresh: () => Promise.resolve(true), refresh: () => Promise.resolve(true),
}); });
await waitFor(() => expect(result.current?.isSuccess).toBe(true)); await waitFor(() => expect(result.current?.isSuccess).toBe(true));
expect(requests).toHaveBeenCalledOnce(); expect(requests).toHaveBeenCalledOnce();
expect(result.current?.data?.data).toEqual([ expect(result.current?.data?.data).toEqual([
{ {
id: "account-1", id: "account-1",
name: "Основной", name: "Основной",
openedOn: "2024-08-13", openedOn: "2024-08-13",
type: "BROKERAGE", type: "BROKERAGE",
}, },
]); ]);
}); },
);
}); });
+21 -1
View File
@@ -1,5 +1,6 @@
import { import {
type QueryKey, type QueryKey,
useQueries,
useQuery, useQuery,
type UseQueryOptions, type UseQueryOptions,
type UseQueryResult, type UseQueryResult,
@@ -11,6 +12,25 @@ import {
subscribeAuthenticatedSession, subscribeAuthenticatedSession,
} from "./client"; } from "./client";
function useAuthenticatedQueries<TData, TError, TQueryKey extends QueryKey>(
queries: UseQueryOptions<TData, TError, TData, TQueryKey>[],
) {
const hasSession = useHasAuthenticatedSession();
return useQueries({
queries: queries.map(
(options): UseQueryOptions<TData, TError, TData, TQueryKey> => ({
...options,
enabled: query =>
hasSession
&& (typeof options.enabled === "function"
? options.enabled(query)
: (options.enabled ?? true)),
}),
),
});
}
function useAuthenticatedQuery< function useAuthenticatedQuery<
TQueryFunctionData = unknown, TQueryFunctionData = unknown,
TError = Error, TError = Error,
@@ -38,4 +58,4 @@ function useHasAuthenticatedSession() {
); );
} }
export { useAuthenticatedQuery }; export { useAuthenticatedQueries, useAuthenticatedQuery };
+35 -35
View File
@@ -3,12 +3,7 @@ import createQueryClient from "openapi-react-query";
import type { paths } from "./generated/schema"; import type { paths } from "./generated/schema";
import { import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
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;
@@ -170,8 +165,12 @@ function isNetworkFailure(error: unknown): boolean {
} }
function isPermanentCode(code?: string): boolean { function isPermanentCode(code?: string): boolean {
return isBrokerageAccessLoss(code) return [
|| isApiErrorCode(code, ApiErrorCode.Unauthorized); ApiErrorCode.InvalidTbankToken,
ApiErrorCode.NotFound,
ApiErrorCode.TbankNotConnected,
ApiErrorCode.Unauthorized,
].some(permanent => isApiErrorCode(code, permanent));
} }
function isTransientReadFailure( function isTransientReadFailure(
@@ -208,50 +207,51 @@ function parseJson(body: string): unknown {
} }
async function readAttempt(request: Request): Promise<ReadAttempt> { async function readAttempt(request: Request): Promise<ReadAttempt> {
// Manual timeout (not AbortSignal.timeout) so fake-timer tests can advance it. const controller = new AbortController();
const timeout = new AbortController(); const failure: { reason?: "abort" | "timeout" } = {};
const timer = setTimeout(() => timeout.abort(), GET_DEADLINE_MS);
const signal = AbortSignal.any([request.signal, timeout.signal]); const { promise: deadline, reject: rejectDeadline }
= Promise.withResolvers<never>();
const timeout = setTimeout(() => {
failure.reason = "timeout";
controller.abort();
rejectDeadline(abortError());
}, GET_DEADLINE_MS);
const abort = () => {
failure.reason = "abort";
controller.abort();
rejectDeadline(abortError());
};
request.signal.addEventListener("abort", abort, { once: true });
const timedRequest = new Request(request, { signal: controller.signal });
try { try {
// MSW keeps a stalled body readable after abort, so keep the race. const response = await Promise.race([fetch(timedRequest), deadline]);
const response = await Promise.race([ const body = await Promise.race([response.text(), deadline]);
fetch(new Request(request, { signal })), const problem = parseJson(body);
rejectOnAbort(signal),
]);
const body = await Promise.race([response.text(), rejectOnAbort(signal)]);
return { return {
problem: parseJson(body), problem,
reason: "transient", reason: "transient",
response: new Response(body, response), response: new Response(body, response),
}; };
} }
catch (error) { catch (error) {
if (request.signal.aborted) return { reason: "abort" }; if (failure.reason === "abort") return { reason: "abort" };
// Timeout or network failure: retryable. if (failure.reason === "timeout" || isNetworkFailure(error))
if (timeout.signal.aborted || isNetworkFailure(error))
return { reason: "transient" }; return { reason: "transient" };
throw error; throw error;
} }
finally { finally {
clearTimeout(timer); clearTimeout(timeout);
request.signal.removeEventListener("abort", abort);
} }
} }
function rejectOnAbort(signal: AbortSignal): Promise<never> {
return new Promise((_resolve, reject) => {
// The signal is either already aborted or one of two parents firing once.
signal.addEventListener(
"abort",
() => reject(abortError()),
{ once: true },
);
});
}
function retryAfterMs(response: Response): number | undefined { function retryAfterMs(response: Response): number | undefined {
const header = response.headers.get("Retry-After"); const header = response.headers.get("Retry-After");
if (!header) return undefined; if (!header) return undefined;
+24 -80
View File
@@ -775,77 +775,6 @@ export interface components {
*/ */
password: string; password: string;
}; };
RubMoneyInputDto: {
/** @enum {string} */
currency: "RUB";
value: string;
};
CreateExpenseOperationDto: {
/** Format: uuid */
id: string;
/** Format: uuid */
cardId: string;
amount: components["schemas"]["RubMoneyInputDto"];
/** Format: date */
date: string;
description?: string;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
kind: "expense";
/** Format: uuid */
categoryId?: string;
};
CreateTopupOperationDto: {
/** Format: uuid */
id: string;
/** Format: uuid */
cardId: string;
amount: components["schemas"]["RubMoneyInputDto"];
/** Format: date */
date: string;
description?: string;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
kind: "topup";
};
CreateTransferOperationDto: {
/** Format: uuid */
id: string;
/** Format: uuid */
cardId: string;
amount: components["schemas"]["RubMoneyInputDto"];
/** Format: date */
date: string;
description?: string;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
kind: "transfer";
/** Format: uuid */
targetCardId: string;
};
CreateRefundOperationDto: {
/** Format: uuid */
id: string;
/** Format: uuid */
cardId: string;
amount: components["schemas"]["RubMoneyInputDto"];
/** Format: date */
date: string;
description?: string;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
kind: "refund";
/** Format: uuid */
originalExpenseId: string;
};
MoneyDto: { MoneyDto: {
currency: string; currency: string;
value: string; value: string;
@@ -863,6 +792,11 @@ export interface components {
archivedAt: string | null; archivedAt: string | null;
version: number; version: number;
}; };
RubMoneyInputDto: {
/** @enum {string} */
currency: "RUB";
value: string;
};
CreateBankCardDto: { CreateBankCardDto: {
/** Format: uuid */ /** Format: uuid */
id: string; id: string;
@@ -907,6 +841,24 @@ export interface components {
net: components["schemas"]["MoneyDto"]; net: components["schemas"]["MoneyDto"];
categories: components["schemas"]["BankSummaryCategoryDto"][]; categories: components["schemas"]["BankSummaryCategoryDto"][];
}; };
CreateBankOperationDto: {
/** Format: uuid */
id: string;
/** @enum {string} */
kind: "expense" | "topup" | "transfer" | "refund";
/** Format: uuid */
cardId: string;
/** Format: uuid */
targetCardId?: string;
/** Format: uuid */
categoryId?: string;
/** Format: uuid */
originalExpenseId?: string;
amount: components["schemas"]["RubMoneyInputDto"];
/** Format: date */
date: string;
description?: string;
};
BankOperationDto: { BankOperationDto: {
id: string; id: string;
/** @enum {string} */ /** @enum {string} */
@@ -1280,12 +1232,6 @@ 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} */
@@ -1306,8 +1252,6 @@ 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"][];
}; };
@@ -2646,7 +2590,7 @@ export interface operations {
}; };
requestBody: { requestBody: {
content: { content: {
"application/json": components["schemas"]["CreateExpenseOperationDto"] | components["schemas"]["CreateTopupOperationDto"] | components["schemas"]["CreateTransferOperationDto"] | components["schemas"]["CreateRefundOperationDto"]; "application/json": components["schemas"]["CreateBankOperationDto"];
}; };
}; };
responses: { responses: {
+1 -8
View File
@@ -7,14 +7,7 @@ export {
configureAuthClient, configureAuthClient,
} from "./client"; } from "./client";
export type { paths } from "./generated/schema"; export type { paths } from "./generated/schema";
export { export { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
ApiErrorCode,
apiErrorCode,
isApiErrorCode,
isBrokerageAccessLoss,
} from "./problem-details";
export { isGetUnder } from "./query-predicate";
export { isReadFailureExhausted, readRetry } from "./read-failure";
export { export {
type InvestmentResourceMeta, type InvestmentResourceMeta,
+1 -13
View File
@@ -1,11 +1,6 @@
import { expect, it } from "vitest"; import { expect, it } from "vitest";
import { import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
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(
@@ -21,10 +16,3 @@ 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);
});
+1 -11
View File
@@ -41,14 +41,4 @@ function isApiErrorCode(
return value === expected; return value === expected;
} }
// Confirmed loss of access to the brokerage data. UNAUTHORIZED is a session export { ApiErrorCode, apiErrorCode, isApiErrorCode };
// 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 };
-26
View File
@@ -1,26 +0,0 @@
import { describe, expect, it } from "vitest";
import { isGetUnder } from "./query-predicate";
describe("isGetUnder", () => {
it("matches the root itself and nested paths for get queries only", () => {
const predicate = isGetUnder(["/api/v1/bank-cards"]);
expect(predicate(["get", "/api/v1/bank-cards"])).toBe(true);
expect(predicate(["get", "/api/v1/bank-cards/{id}"])).toBe(true);
expect(predicate(["get", "/api/v1/bank-cards"])).toBe(true);
expect(predicate(["get", "/api/v1/bank-cardsx"])).toBe(false);
expect(predicate(["post", "/api/v1/bank-cards"])).toBe(false);
expect(predicate(["get", "/api/v1/other"])).toBe(false);
});
it("checks every root and tolerates init and non-string parts", () => {
const predicate = isGetUnder(["/api/v1/stocks", "/api/v1/bonds"]);
expect(predicate(["get", "/api/v1/stocks/{uid}/quote", { params: {} }]))
.toBe(true);
expect(predicate(["get", "/api/v1/bonds"])).toBe(true);
expect(predicate([undefined, "/api/v1/stocks"])).toBe(false);
expect(predicate(["get", undefined])).toBe(false);
});
});
-18
View File
@@ -1,18 +0,0 @@
import type { QueryKey } from "@tanstack/react-query";
// openapi-react-query shapes query keys as [method, path, init].
function isGetUnder(roots: readonly string[]) {
const predicate = (queryKey: QueryKey) => {
const [method, path] = queryKey as readonly [unknown, unknown];
return (
method === "get"
&& typeof path === "string"
&& roots.some(root => path === root || path.startsWith(`${root}/`))
);
};
return predicate;
}
export { isGetUnder };
-80
View File
@@ -1,80 +0,0 @@
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
@@ -1,37 +0,0 @@
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 };
+73
View File
@@ -37,6 +37,38 @@ function auth() {
} }
describe("persistent resource query lifecycle", () => { describe("persistent resource query lifecycle", () => {
it("observes once into the canonical cache without another import", async () => {
auth();
const modes: (null | string)[] = [];
let observations = 0;
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/portfolio",
({ request }) => {
const mode = new URL(request.url).searchParams.get("sync");
modes.push(mode);
if (mode) observations += 1;
return HttpResponse.json(
resource(snapshot, {
dataRevision: observations > 0 ? "new" : "old",
}),
);
},
),
);
const { queryClient, wrapper } = createQueryWrapper();
const { result } = renderHook(() => usePrivateResource("a"), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
await act(() => result.current.observe());
await waitFor(() => expect(result.current.meta?.dataRevision).toBe("new"));
expect(modes).toEqual([null, "observe"]);
expect(queryClient.getQueryCache().getAll()).toHaveLength(1);
expect(
queryClient.getQueryData(resourceOptions("a").queryKey),
).toMatchObject({ data: snapshot, meta: { dataRevision: "new" } });
});
it("keeps successful HTTP state distinct from missing or failed source data", async () => { it("keeps successful HTTP state distinct from missing or failed source data", async () => {
auth(); auth();
server.use( server.use(
@@ -104,6 +136,47 @@ describe("persistent resource query lifecycle", () => {
expect(result.current.isSuccess).toBe(true); expect(result.current.isSuccess).toBe(true);
expect(result.current.isPending).toBe(false); expect(result.current.isPending).toBe(false);
}); });
it("keeps the observed snapshot when an older query finishes later", async () => {
auth();
const { promise: blocked, resolve: release }
= Promise.withResolvers<void>();
let isStarted = false;
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/portfolio",
async ({ request }) => {
if (!new URL(request.url).searchParams.has("sync")) {
isStarted = true;
await blocked;
return HttpResponse.json(resource({ ...snapshot, isStale: true }));
}
return HttpResponse.json(resource(snapshot));
},
),
);
const { result } = renderHook(() => usePrivateResource("a"), {
wrapper: createQueryWrapper().wrapper,
});
await waitFor(() => expect(isStarted).toBe(true));
// Capture the observation promise before releasing the old handler; the
// final assertion must follow its settlement, not the handler release.
let observation: Promise<unknown> = Promise.resolve();
await act(async () => {
observation = result.current.observe() ?? Promise.resolve();
await observation;
});
await act(async () => {
release();
await observation;
});
expect(result.current.data).toEqual(snapshot);
});
it("clears private snapshots on confirmed access loss and fences another pending success", async () => { it("clears private snapshots on confirmed access loss and fences another pending success", async () => {
auth(); auth();
let isDenied = false; let isDenied = false;
+65 -6
View File
@@ -1,5 +1,6 @@
import { import {
type QueryClient, type QueryClient,
type QueryFunctionContext,
useQueryClient, useQueryClient,
type UseQueryOptions, type UseQueryOptions,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
@@ -8,7 +9,8 @@ import { useEffect } from "react";
import type { components, paths } from "./generated/schema"; import type { components, paths } from "./generated/schema";
import { useAuthenticatedQuery } from "./authenticated-query"; import { useAuthenticatedQuery } from "./authenticated-query";
import { apiErrorCode, isBrokerageAccessLoss } from "./problem-details"; import { getAuthenticatedSessionSnapshot } from "./client";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
type InvestmentResourceMeta = components["schemas"]["InvestmentResourceMetaDto"]; type InvestmentResourceMeta = components["schemas"]["InvestmentResourceMetaDto"];
@@ -29,7 +31,7 @@ type ResourcePath = {
type ResourceQueryKey = readonly [ type ResourceQueryKey = readonly [
"get", "get",
ResourcePath, ResourcePath,
{ params?: { path?: object; query?: object } }?, { params?: { path?: object; query?: { sync?: "observe" } } }?,
]; ];
function invalidateResourceAccess( function invalidateResourceAccess(
@@ -37,7 +39,15 @@ function invalidateResourceAccess(
group: string, group: string,
error: unknown, error: unknown,
) { ) {
if (!isBrokerageAccessLoss(apiErrorCode(error))) return; const errorCode = apiErrorCode(error);
if (
[
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> }) =>
@@ -55,6 +65,22 @@ function invalidateResourceAccess(
}); });
} }
// Keep endpoint and parameters intact. The generated queryFn accepts the
// optional sync mode even for a cache key originally created without init.
function observationKey<K extends readonly unknown[]>(key: K): K {
const [method, path, init] = key as unknown as [
string,
string,
{ params?: { path?: object; query?: object } }?,
];
return [method, path, {
...init,
params: { ...init?.params, query: { ...init?.params?.query, sync: "observe" } },
}] as unknown as K;
}
// Observation changes transport mode, never the generated cache identity.
function useResourceQuery< function useResourceQuery<
T extends Resource, T extends Resource,
E = unknown, E = unknown,
@@ -74,17 +100,50 @@ function useResourceQuery<
if (group) invalidateResourceAccess(client, group, query.error); if (group) invalidateResourceAccess(client, group, query.error);
}, [client, group, query.error]); }, [client, group, query.error]);
const observe = async () => {
const queryFunction = options.queryFn;
if (
typeof queryFunction !== "function"
|| !query.isEnabled
|| !getAuthenticatedSessionSnapshot()
)
return;
// Cancellation is synchronous; start the replacement in the same boundary.
void client.cancelQueries({ exact: true, queryKey: options.queryKey });
return client.query({
...options,
queryFn: async (context: QueryFunctionContext<K>) => {
const queryKey = observationKey(context.queryKey);
return queryFunction({ ...context, queryKey });
},
retry: false,
// Keep the cache envelope independent of observer projection.
select: undefined,
staleTime: 0,
});
};
const data: T["data"] | undefined = query.data?.data; const data: T["data"] | undefined = query.data?.data;
const meta: T["meta"] | undefined = query.data?.meta; const meta: T["meta"] | undefined = query.data?.meta;
// Project resource fields; refetch still returns the transport envelope. // Project resource fields; refetch still returns the transport envelope.
// queryKey is kept for screen-sync cache bookkeeping.
return { return {
// eslint-disable-next-line @tanstack/query/no-rest-destructuring -- Single projection point; consumers already depend on the whole query state.
...query,
data: data ?? undefined, data: data ?? undefined,
error: query.error,
fetchStatus: query.fetchStatus,
isEnabled: query.isEnabled,
isError: query.isError,
isFetching: query.isFetching,
isLoading: query.isLoading,
isPending: query.isPending,
isSuccess: query.isSuccess,
meta, meta,
observe,
queryKey: options.queryKey, queryKey: options.queryKey,
refetch: query.refetch,
status: query.status,
sync: query.data && "sync" in query.data ? query.data.sync : undefined, sync: query.data && "sync" in query.data ? query.data.sync : undefined,
}; };
} }
+72 -230
View File
@@ -1053,29 +1053,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"oneOf": [ "$ref": "#/components/schemas/CreateBankOperationDto"
{
"$ref": "#/components/schemas/CreateExpenseOperationDto"
},
{
"$ref": "#/components/schemas/CreateTopupOperationDto"
},
{
"$ref": "#/components/schemas/CreateTransferOperationDto"
},
{
"$ref": "#/components/schemas/CreateRefundOperationDto"
}
],
"discriminator": {
"propertyName": "kind",
"mapping": {
"expense": "#/components/schemas/CreateExpenseOperationDto",
"topup": "#/components/schemas/CreateTopupOperationDto",
"transfer": "#/components/schemas/CreateTransferOperationDto",
"refund": "#/components/schemas/CreateRefundOperationDto"
}
}
} }
} }
} }
@@ -5362,187 +5340,6 @@
"password" "password"
] ]
}, },
"RubMoneyInputDto": {
"type": "object",
"properties": {
"currency": {
"type": "string",
"enum": [
"RUB"
]
},
"value": {
"type": "string",
"pattern": "^-?[0-9]+(\\.[0-9]{1,2})?$"
}
},
"required": [
"currency",
"value"
]
},
"CreateExpenseOperationDto": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"cardId": {
"type": "string",
"format": "uuid"
},
"amount": {
"$ref": "#/components/schemas/RubMoneyInputDto"
},
"date": {
"type": "string",
"format": "date"
},
"description": {
"type": "string",
"maxLength": 500
},
"kind": {
"type": "string",
"enum": [
"expense"
]
},
"categoryId": {
"type": "string",
"format": "uuid"
}
},
"required": [
"id",
"cardId",
"amount",
"date",
"kind"
]
},
"CreateTopupOperationDto": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"cardId": {
"type": "string",
"format": "uuid"
},
"amount": {
"$ref": "#/components/schemas/RubMoneyInputDto"
},
"date": {
"type": "string",
"format": "date"
},
"description": {
"type": "string",
"maxLength": 500
},
"kind": {
"type": "string",
"enum": [
"topup"
]
}
},
"required": [
"id",
"cardId",
"amount",
"date",
"kind"
]
},
"CreateTransferOperationDto": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"cardId": {
"type": "string",
"format": "uuid"
},
"amount": {
"$ref": "#/components/schemas/RubMoneyInputDto"
},
"date": {
"type": "string",
"format": "date"
},
"description": {
"type": "string",
"maxLength": 500
},
"kind": {
"type": "string",
"enum": [
"transfer"
]
},
"targetCardId": {
"type": "string",
"format": "uuid"
}
},
"required": [
"id",
"cardId",
"amount",
"date",
"kind",
"targetCardId"
]
},
"CreateRefundOperationDto": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"cardId": {
"type": "string",
"format": "uuid"
},
"amount": {
"$ref": "#/components/schemas/RubMoneyInputDto"
},
"date": {
"type": "string",
"format": "date"
},
"description": {
"type": "string",
"maxLength": 500
},
"kind": {
"type": "string",
"enum": [
"refund"
]
},
"originalExpenseId": {
"type": "string",
"format": "uuid"
}
},
"required": [
"id",
"cardId",
"amount",
"date",
"kind",
"originalExpenseId"
]
},
"MoneyDto": { "MoneyDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -5614,6 +5411,25 @@
"version" "version"
] ]
}, },
"RubMoneyInputDto": {
"type": "object",
"properties": {
"currency": {
"type": "string",
"enum": [
"RUB"
]
},
"value": {
"type": "string",
"pattern": "^-?[0-9]+(\\.[0-9]{1,2})?$"
}
},
"required": [
"currency",
"value"
]
},
"CreateBankCardDto": { "CreateBankCardDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -5783,6 +5599,58 @@
"categories" "categories"
] ]
}, },
"CreateBankOperationDto": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"kind": {
"type": "string",
"enum": [
"expense",
"topup",
"transfer",
"refund"
]
},
"cardId": {
"type": "string",
"format": "uuid"
},
"targetCardId": {
"type": "string",
"format": "uuid"
},
"categoryId": {
"type": "string",
"format": "uuid"
},
"originalExpenseId": {
"type": "string",
"format": "uuid"
},
"amount": {
"$ref": "#/components/schemas/RubMoneyInputDto"
},
"date": {
"type": "string",
"format": "date"
},
"description": {
"type": "string",
"maxLength": 500
}
},
"required": [
"id",
"kind",
"cardId",
"amount",
"date"
]
},
"BankOperationDto": { "BankOperationDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -7375,23 +7243,6 @@
"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": {
@@ -7473,14 +7324,6 @@
"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": {
@@ -7497,7 +7340,6 @@
"required": [ "required": [
"accountId", "accountId",
"calculatedAt", "calculatedAt",
"window",
"events", "events",
"incomeTotalsByCurrency" "incomeTotalsByCurrency"
] ]
-3
View File
@@ -360,7 +360,6 @@ const ru = {
emptyTitle: "За выбранный период событий не найдено", emptyTitle: "За выбранный период событий не найдено",
errorText: "Попробуйте запросить прогноз еще раз.", errorText: "Попробуйте запросить прогноз еще раз.",
errorTitle: "Не удалось загрузить будущие события", errorTitle: "Не удалось загрузить будущие события",
forecastWindow: "Прогноз от {{date}} на {{from}} – {{to}}",
hint: "Прогноз рассчитан по текущей позиции до налогов. Покрытие зависит от доступных данных MOEX.", hint: "Прогноз рассчитан по текущей позиции до налогов. Покрытие зависит от доступных данных MOEX.",
noAmount: "Сумма неизвестна", noAmount: "Сумма неизвестна",
open: "Открыть будущие события", open: "Открыть будущие события",
@@ -422,9 +421,7 @@ const ru = {
}, },
history: "История догружается", history: "История догружается",
partial: "Период загружен не полностью", partial: "Период загружен не полностью",
readFailed: "Не удалось обновить данные",
refresh: "Обновить", refresh: "Обновить",
retry: "Повторить",
sourceTime: "Данные на {{date}}", sourceTime: "Данные на {{date}}",
stale: "Данные требуют обновления", stale: "Данные требуют обновления",
syncFailed: syncFailed:
@@ -1,37 +0,0 @@
import { MantineProvider } from "@mantine/core";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ReadRetry } from "./ReadRetry";
function renderRetry(props: Parameters<typeof ReadRetry>[0]) {
return render(
<MantineProvider>
<ReadRetry {...props} />
</MantineProvider>,
);
}
describe("ReadRetry", () => {
it("renders nothing until the automatic retries are exhausted", () => {
renderRetry({ exhausted: false, onRetry: vi.fn() });
expect(screen.queryByRole("status")).not.toBeInTheDocument();
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("announces the failure politely and retries on activation", () => {
const onRetry = vi.fn();
renderRetry({ exhausted: true, onRetry });
expect(screen.getByRole("status")).toHaveTextContent("Не удалось обновить данные");
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
expect(onRetry).toHaveBeenCalledOnce();
});
it("disables the action while the retry runs", () => {
renderRetry({ exhausted: true, onRetry: vi.fn(), retrying: true });
expect(screen.getByRole("button", { name: "Повторить" })).toBeDisabled();
});
});
-42
View File
@@ -1,42 +0,0 @@
import type { FC } from "react";
import { Alert, Button, Group, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
// Shown only after the automatic read retries are exhausted. The action repeats
// the failed GET of the affected query; it never starts a source sync.
type ReadRetryProps = {
exhausted: boolean;
onRetry: () => void;
retrying?: boolean;
};
const ReadRetryButton: FC<Pick<ReadRetryProps, "onRetry" | "retrying">> = (
props,
) => {
const { t } = useTranslation();
return (
<Button disabled={props.retrying} onClick={props.onRetry} variant="light">
{t("investment.retry")}
</Button>
);
};
const ReadRetry: FC<ReadRetryProps> = (props) => {
const { t } = useTranslation();
if (!props.exhausted) return null;
return (
<Alert aria-live="polite" color="orange" role="status">
<Group gap="sm" justify="space-between">
<Text>{t("investment.readFailed")}</Text>
<ReadRetryButton onRetry={props.onRetry} retrying={props.retrying} />
</Group>
</Alert>
);
};
export { ReadRetry, ReadRetryButton, type ReadRetryProps };
-6
View File
@@ -5,10 +5,4 @@ export {
EntryGroupTotal, EntryGroupTotal,
} from "./EntryGroup/EntryGroup"; } from "./EntryGroup/EntryGroup";
export { PayoutCard } from "./PayoutCard/PayoutCard"; export { PayoutCard } from "./PayoutCard/PayoutCard";
export {
ReadRetry,
ReadRetryButton,
type ReadRetryProps,
} from "./ReadRetry/ReadRetry";
export { SourceSyncStatus } from "./SourceSyncStatus/SourceSyncStatus"; export { SourceSyncStatus } from "./SourceSyncStatus/SourceSyncStatus";
export { useAppDocumentTitle } from "./useAppDocumentTitle";
-13
View File
@@ -1,13 +0,0 @@
import { useDocumentTitle } from "@mantine/hooks";
import { useTranslation } from "react-i18next";
/**
* Sets `document.title` to `<title> — <appName>`.
* Owner of the "page title + app name suffix" formatting rule.
*/
function useAppDocumentTitle(title: string) {
const { t } = useTranslation();
useDocumentTitle(`${title} — ${t("appName")}`);
}
export { useAppDocumentTitle };
-1
View File
@@ -66,7 +66,6 @@ const handlers = [
accountId: "account-1", accountId: "account-1",
calculatedAt: "2026-08-14T00:00:00Z", calculatedAt: "2026-08-14T00:00:00Z",
events: [], events: [],
window: { from: "2026-08-14", to: "2026-09-13" },
}), }),
), ),
), ),
+1
View File
@@ -0,0 +1 @@
export { PortfolioStructure } from "./ui/PortfolioStructure";
@@ -28,7 +28,7 @@ const meta = {
}, },
}, },
tags: ["autodocs"], tags: ["autodocs"],
title: "Product/PortfolioStructure", title: "Widgets/PortfolioStructure",
} satisfies Meta<typeof PortfolioStructure>; } satisfies Meta<typeof PortfolioStructure>;
export default meta; export default meta;