diff --git a/.ai/architecture.md b/.ai/architecture.md index 0a9788f..4ca0420 100644 --- a/.ai/architecture.md +++ b/.ai/architecture.md @@ -8,7 +8,7 @@ Vitest — отдельную JSDOM конфигурацию. Точные versi [package.json](../package.json), реализация — source of truth. ```text -app → pages → widgets → features → entities → shared +app → pages → features → entities → shared page container → page VM → entity/feature query → shared/api → backend /api/v1 ↓ props → state/section views @@ -30,14 +30,13 @@ contract; frontend не читает его checkout для генерации | --- | --- | | Bootstrap, глобальные providers, routes | `app` | | Композиция маршрута и поведение, нужное только этому экрану | `pages/` | -| Крупная переиспользуемая композиция экрана | `widgets/` | | Самостоятельная пользовательская возможность с собственным смыслом | `features/` | | Представление/поведение бизнес-сущности | `entities/` | | Переиспользуемый код без WealthLedger domain meaning | `shared` | -Локальный FSD profile: `app → pages → widgets → features → entities → shared`. -Widget собирает несколько нижних слоёв в самостоятельный UI-блок; компонент для -одной page остаётся у page. Form, mutation или click handler сами по себе не +Локальный FSD profile: `app → pages → features → entities → shared`. +Слой `widgets` не используется: композиция экрана и компонент для одной page +остаются у page. Form, mutation или click handler сами по себе не требуют feature. Два callers не делают domain helper generic shared. Slice именуется по ответственности, а не `helpers`, `hooks` или `types`. @@ -49,12 +48,12 @@ Slice именуется по ответственности, а не `helpers`, Cross-slice consumer использует `@/` и `index.ts` владельца, например `@/entities/portfolio`. Alias Vite и TypeScript согласован. Другой slice не -импортирует внутренний файл напрямую. Slices `pages`, `widgets`, `features`, `entities` не зависят от соседнего slice своего слоя; +импортирует внутренний файл напрямую. Slices `pages`, `features`, `entities` не зависят от соседнего slice своего слоя; композиция принадлежит вышележащему owner. Existing меж-slice исключения не являются разрешением добавлять новые. Направление зависимостей, public imports и запрет соседних slices в -`pages`, `widgets`, `features`, `entities` проверяет `npm run deps`. Внутренние imports +`pages`, `features`, `entities` проверяет `npm run deps`. Внутренние imports одного slice и связи shared segments этот запрет не ограничивает. Knip проверяет использование exports, а не смысл placement. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dd80e3..5df8b13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # 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 diff --git a/dependency-cruiser.cjs b/dependency-cruiser.cjs index 7aae82c..39beab8 100644 --- a/dependency-cruiser.cjs +++ b/dependency-cruiser.cjs @@ -29,17 +29,17 @@ module.exports = { }, { from: { - path: "^src/(app|pages|widgets|features|entities|shared)/([^/]+)/", + path: "^src/(app|pages|features|entities|shared)/([^/]+)/", }, name: "slices-must-use-public-api", severity: "error", to: { - path: String.raw`^src/(app|pages|widgets|features|entities|shared)/([^/]+)/(?!index\.ts$)`, + path: String.raw`^src/(app|pages|features|entities|shared)/([^/]+)/(?!index\.ts$)`, pathNot: "^src/$1/$2/", }, }, { - from: { path: "^src/(pages|widgets|features|entities)/([^/]+)/" }, + from: { path: "^src/(pages|features|entities)/([^/]+)/" }, name: "no-same-layer-cross-slice-imports", severity: "error", to: { path: "^src/$1/", pathNot: "^src/$1/$2/" }, @@ -48,24 +48,18 @@ module.exports = { from: { path: "^src/shared/" }, name: "shared-must-not-depend-on-layers", severity: "error", - to: { path: "^src/(entities|features|widgets|pages|app)/" }, + to: { path: "^src/(entities|features|pages|app)/" }, }, { from: { path: "^src/entities/" }, name: "entities-must-not-depend-on-higher-layers", severity: "error", - to: { path: "^src/(features|widgets|pages|app)/" }, + to: { path: "^src/(features|pages|app)/" }, }, { from: { path: "^src/features/" }, name: "features-must-not-depend-on-higher-layers", 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)/" }, }, { diff --git a/docker/nginx.conf b/docker/nginx.conf index ef37b03..946f737 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -17,12 +17,21 @@ http { default $http_x_forwarded_proto; '' $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 { listen 8080; server_name _; root /usr/share/nginx/html; resolver 127.0.0.11 valid=5s ipv6=off; location /api/ { + limit_req zone=auth_admission burst=5 nodelay; set $backend http://backend:3000; proxy_pass $backend$request_uri; proxy_set_header Host $http_host; diff --git a/eslint-rules.mjs b/eslint-rules.mjs index bbb543c..5eaa6f9 100644 --- a/eslint-rules.mjs +++ b/eslint-rules.mjs @@ -25,7 +25,11 @@ const readability = { ) context.report({ messageId: "schema", node }); }, - FunctionDeclaration: checkParameters, + FunctionDeclaration(node) { + checkParameters(node); + if (node.id?.name?.endsWith?.("ViewModel")) + context.report({ messageId: "vmName", node: node.id }); + }, FunctionExpression: checkParameters, ImportDeclaration(node) { if ( @@ -95,6 +99,7 @@ const readability = { schema: "Declare Zod schemas in a dedicated *.schema.ts file.", svg: "Own SVG markup in packages/icons, and import its public component.", union: "Put complex union members on separate lines.", + vmName: "Name custom hooks use*Vm, not use*ViewModel.", }, schema: [], type: "suggestion", diff --git a/package-lock.json b/package-lock.json index d9dd952..557f0e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wealth-ledger-frontend", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wealth-ledger-frontend", - "version": "1.3.0", + "version": "1.4.0", "workspaces": [ "packages/*" ], diff --git a/package.json b/package.json index 434f528..ae29119 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "wealth-ledger-frontend", "private": true, - "version": "1.3.0", + "version": "1.4.0", "type": "module", "engines": { "node": "24.22.1" diff --git a/renovate.json b/renovate.json index 37938de..a215cb4 100644 --- a/renovate.json +++ b/renovate.json @@ -1,15 +1,30 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": ["config:recommended", ":semanticCommitTypeAll(chore)"], + "extends": ["config:recommended", ":semanticCommitTypeAll(chore)", "security:minimumReleaseAgeNpm"], "timezone": "Europe/Moscow", "labels": ["dependencies"], "dependencyDashboard": true, "rangeStrategy": "pin", + "prConcurrentLimit": 5, + "lockFileMaintenance": { + "enabled": true, + "schedule": ["before 4am on the first day of the month"] + }, "packageRules": [ { "matchUpdateTypes": ["minor", "patch"], "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"], "dependencyDashboardApproval": true diff --git a/src/app/routes/AppRoutes/AllAccounts.test.tsx b/src/app/routes/AppRoutes/AllAccounts.test.tsx index 0363249..c2e8452 100644 --- a/src/app/routes/AppRoutes/AllAccounts.test.tsx +++ b/src/app/routes/AppRoutes/AllAccounts.test.tsx @@ -217,6 +217,7 @@ beforeEach(() => { }), ), incomeTotalsByCurrency: [], + window: { from: "2026-09-01", to: "2026-10-01" }, }), ), ), diff --git a/src/app/routes/AppRoutes/AppRoutes.tsx b/src/app/routes/AppRoutes/AppRoutes.tsx index fea6244..ec75090 100644 --- a/src/app/routes/AppRoutes/AppRoutes.tsx +++ b/src/app/routes/AppRoutes/AppRoutes.tsx @@ -1,4 +1,4 @@ -import type { FC } from "react"; +import type { ComponentType, FC } from "react"; import { lazy } from "react"; import { Navigate, Route, Routes } from "react-router"; @@ -6,101 +6,35 @@ import { Navigate, Route, Routes } from "react-router"; import { LazyPage } from "../RouteFallback"; import { Protected, Public } from "../RouteGuards"; -const CardPage = lazy(async () => { - const { CardPage } = await import("@/pages/card"); +// Pages use named exports; React.lazy needs a default export per dynamic import (one chunk each). +function lazyPage( + load: () => Promise>, + name: Name, +) { + return lazy(async () => { + const page = await load(); - return { default: CardPage }; -}); + // eslint-disable-next-line security/detect-object-injection -- `name` is a compile-time literal checked against the module's exports. + return { default: page[name] }; + }); +} -const CardsPage = lazy(async () => { - const { CardsPage } = await import("@/pages/cards"); - - return { default: CardsPage }; -}); - -const BondPage = lazy(async () => { - const { BondPage } = await import("@/pages/bond"); - - return { default: BondPage }; -}); - -const BondsPage = lazy(async () => { - const { BondsPage } = await import("@/pages/bonds"); - - 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 CardPage = lazyPage(async () => import("@/pages/card"), "CardPage"); +const CardsPage = lazyPage(async () => import("@/pages/cards"), "CardsPage"); +const BondPage = lazyPage(async () => import("@/pages/bond"), "BondPage"); +const BondsPage = lazyPage(async () => import("@/pages/bonds"), "BondsPage"); +const StockPage = lazyPage(async () => import("@/pages/stock"), "StockPage"); +const StocksPage = lazyPage(async () => import("@/pages/stocks"), "StocksPage"); +const AccountPage = lazyPage(async () => import("@/pages/account"), "AccountPage"); +const AccountEventsPage = lazyPage(async () => import("@/pages/account-events"), "AccountEventsPage"); +const AccountOperationsPage = lazyPage(async () => import("@/pages/account-operations"), "AccountOperationsPage"); +const AccountIncomePage = lazyPage(async () => import("@/pages/account-income"), "AccountIncomePage"); +const AccountsPage = lazyPage(async () => import("@/pages/accounts"), "AccountsPage"); +const LoginPage = lazyPage(async () => import("@/pages/login"), "LoginPage"); +const NotFoundPage = lazyPage(async () => import("@/pages/not-found"), "NotFoundPage"); +const RegisterPage = lazyPage(async () => import("@/pages/register"), "RegisterPage"); +const SettingsPage = lazyPage(async () => import("@/pages/settings"), "SettingsPage"); +const ProtectedLayout = lazyPage(async () => import("../ProtectedLayout"), "ProtectedLayout"); const AppRoutes: FC = () => ( diff --git a/src/app/routes/DocumentTitle/DocumentTitle.tsx b/src/app/routes/DocumentTitle/DocumentTitle.tsx index 61d25f0..2db4f17 100644 --- a/src/app/routes/DocumentTitle/DocumentTitle.tsx +++ b/src/app/routes/DocumentTitle/DocumentTitle.tsx @@ -1,6 +1,5 @@ import type { FC } from "react"; -import { useDocumentTitle } from "@mantine/hooks"; import { useTranslation } from "react-i18next"; import { useCurrentRoute } from "@/app/model"; @@ -8,12 +7,12 @@ import { useBankCard } from "@/entities/bank-card"; import { useBond } from "@/entities/bond"; import { useBrokerageAccounts } from "@/entities/brokerage-account"; import { useStock } from "@/entities/stock"; +import { useAppDocumentTitle } from "@/shared/ui"; type DocumentTitleProps = { title: string }; const DocumentTitle: FC = (props): undefined => { - const { t } = useTranslation(); - useDocumentTitle(`${props.title} — ${t("appName")}`); + useAppDocumentTitle(props.title); }; type AccountDocumentTitleProps = { diff --git a/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.tsx b/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.tsx index cd06c6f..35b6e38 100644 --- a/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.tsx +++ b/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.tsx @@ -1,12 +1,12 @@ import type { FC, PropsWithChildren } from "react"; import { Button, Stack, Text, Title } from "@mantine/core"; -import { useDocumentTitle } from "@mantine/hooks"; import { Component } from "react"; import { useTranslation } from "react-i18next"; import { Link, useLocation } from "react-router"; import { useAuth } from "@/features/auth"; +import { useAppDocumentTitle } from "@/shared/ui"; type BoundaryProps = PropsWithChildren<{ authenticated: boolean; @@ -37,7 +37,7 @@ class Boundary extends Component { const RouteErrorFallback: FC = (props) => { const { authenticated } = props; const { t } = useTranslation(); - useDocumentTitle(`${t("routeError.title")} — ${t("appName")}`); + useAppDocumentTitle(t("routeError.title")); const to = authenticated ? "/accounts" : "/login"; return ( diff --git a/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.tsx b/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.tsx index b21d084..9241a4f 100644 --- a/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.tsx +++ b/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.tsx @@ -7,10 +7,8 @@ import { useCurrentRoute } from "@/app/model"; import { AccountBreadcrumbs } from "./AccountBreadcrumbs"; import { AccountScopeSwitch } from "./AccountScopeSwitch/AccountScopeSwitch"; -import { BondBreadcrumbs } from "./BondBreadcrumbs"; import { BreadcrumbTrail } from "./BreadcrumbTrail"; -import { CardBreadcrumbs } from "./CardBreadcrumbs"; -import { StockBreadcrumbs } from "./StockBreadcrumbs"; +import { EntityBreadcrumbs } from "./EntityBreadcrumbs"; const AppBreadcrumbs: FC = () => { const { t } = useTranslation(); @@ -26,9 +24,16 @@ const AppBreadcrumbs: FC = () => { stock, } = useCurrentRoute(); - if (bond) return ; - if (stock) - return ; + if (bond) { + return ; + } + + if (stock) { + return ( + + ); + } + if (account && (section || !account.params.section)) return ( @@ -43,8 +48,13 @@ const AppBreadcrumbs: FC = () => { /> ); - if (card) return ; + + if (card) { + return ; + } + if (cards) return ; + if (accounts) return ; diff --git a/src/app/ui/AppBreadcrumbs/BondBreadcrumbs.tsx b/src/app/ui/AppBreadcrumbs/BondBreadcrumbs.tsx deleted file mode 100644 index 08cb1d2..0000000 --- a/src/app/ui/AppBreadcrumbs/BondBreadcrumbs.tsx +++ /dev/null @@ -1,25 +0,0 @@ -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 = (props) => { - const { data } = useBond(props.identifier); - const { t } = useTranslation(); - - return ( - - ); -}; - -export { BondBreadcrumbs }; diff --git a/src/app/ui/AppBreadcrumbs/BreadcrumbTrail.tsx b/src/app/ui/AppBreadcrumbs/BreadcrumbTrail.tsx index 52cf37c..30b60e0 100644 --- a/src/app/ui/AppBreadcrumbs/BreadcrumbTrail.tsx +++ b/src/app/ui/AppBreadcrumbs/BreadcrumbTrail.tsx @@ -39,4 +39,4 @@ const BreadcrumbTrail: FC = (props) => { ); }; -export { BreadcrumbTrail }; +export { BreadcrumbTrail, type Crumb }; diff --git a/src/app/ui/AppBreadcrumbs/CardBreadcrumbs.tsx b/src/app/ui/AppBreadcrumbs/CardBreadcrumbs.tsx deleted file mode 100644 index bf4dfb6..0000000 --- a/src/app/ui/AppBreadcrumbs/CardBreadcrumbs.tsx +++ /dev/null @@ -1,25 +0,0 @@ -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 = (props) => { - const { data } = useBankCard(props.identifier); - const { t } = useTranslation(); - - return ( - - ); -}; - -export { CardBreadcrumbs }; diff --git a/src/app/ui/AppBreadcrumbs/EntityBreadcrumbs.tsx b/src/app/ui/AppBreadcrumbs/EntityBreadcrumbs.tsx new file mode 100644 index 0000000..cfcef84 --- /dev/null +++ b/src/app/ui/AppBreadcrumbs/EntityBreadcrumbs.tsx @@ -0,0 +1,48 @@ +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 = (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 ; +}; + +export { EntityBreadcrumbs }; diff --git a/src/app/ui/AppBreadcrumbs/StockBreadcrumbs.tsx b/src/app/ui/AppBreadcrumbs/StockBreadcrumbs.tsx deleted file mode 100644 index afdb04d..0000000 --- a/src/app/ui/AppBreadcrumbs/StockBreadcrumbs.tsx +++ /dev/null @@ -1,25 +0,0 @@ -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 = (props) => { - const { data } = useStock(props.identifier); - const { t } = useTranslation(); - - return ( - - ); -}; - -export { StockBreadcrumbs }; diff --git a/src/entities/bank-card/api/mutations.ts b/src/entities/bank-card/api/mutations.ts index 2d46cc6..14d6c63 100644 --- a/src/entities/bank-card/api/mutations.ts +++ b/src/entities/bank-card/api/mutations.ts @@ -5,6 +5,7 @@ import { ApiErrorCode, apiErrorCode, isApiErrorCode, + isGetUnder, } from "@/shared/api"; import type { @@ -26,21 +27,14 @@ async function mutationData( return data; } +const bankCardGetPredicate = isGetUnder(["/api/v1/bank-cards"]); + function useBankMutationOptions() { const queryClient = useQueryClient(); const invalidate = () => queryClient.invalidateQueries({ - predicate: ({ queryKey }) => { - const [method, path] = queryKey; - - return ( - method === "get" - && typeof path === "string" - && (path === "/api/v1/bank-cards" - || path.startsWith("/api/v1/bank-cards/")) - ); - }, + predicate: ({ queryKey }) => bankCardGetPredicate(queryKey), }); return { diff --git a/src/features/instrument-details/model/section.test.ts b/src/features/instrument-details/model/section.test.ts index c4642c2..eb44779 100644 --- a/src/features/instrument-details/model/section.test.ts +++ b/src/features/instrument-details/model/section.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { resource } from "@/test/resource"; @@ -76,4 +76,29 @@ describe("instrument source and transport presentation", () => { 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); + }); }); diff --git a/src/features/instrument-details/model/section.ts b/src/features/instrument-details/model/section.ts index a18b264..7c62f68 100644 --- a/src/features/instrument-details/model/section.ts +++ b/src/features/instrument-details/model/section.ts @@ -1,10 +1,11 @@ -import type { InvestmentResourceMeta } from "@/shared/api"; +import { type InvestmentResourceMeta, readRetry } from "@/shared/api"; type SectionState = { error: boolean; hasData: boolean; loading: boolean; partial?: boolean; + readRetry?: ReturnType; }; function hasSectionData(data: unknown) { @@ -13,8 +14,11 @@ function hasSectionData(data: unknown) { function section(query: { data: unknown; + error?: unknown; isError: boolean; + isFetching?: boolean; meta?: InvestmentResourceMeta; + refetch?: () => Promise; }): SectionState { const isError = query.isError || query.meta?.lastAttempt.state === "failed"; const hasData = hasSectionData(query.data); @@ -24,6 +28,17 @@ function section(query: { hasData, loading: !hasData && !isError, 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, }; } diff --git a/src/features/instrument-details/ui/InstrumentSection.view.tsx b/src/features/instrument-details/ui/InstrumentSection.view.tsx index 5c37ba1..f9c32c6 100644 --- a/src/features/instrument-details/ui/InstrumentSection.view.tsx +++ b/src/features/instrument-details/ui/InstrumentSection.view.tsx @@ -12,6 +12,8 @@ import { import { LoadingSkeleton } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; +import { ReadRetry } from "@/shared/ui"; + import type { SectionState } from "../model/section"; import classes from "./InstrumentDetails.module.css"; @@ -81,6 +83,8 @@ const InstrumentSection: FC = (props) => { )} + {!!props.state.readRetry && } + {sectionContent} ); diff --git a/src/features/tbank-connection/model/connection-boundary.ts b/src/features/tbank-connection/model/connection-boundary.ts index 61c9caa..3c1cf4b 100644 --- a/src/features/tbank-connection/model/connection-boundary.ts +++ b/src/features/tbank-connection/model/connection-boundary.ts @@ -2,19 +2,18 @@ import type { QueryClient } from "@tanstack/react-query"; import { apiQueryClient, + isGetUnder, type paths, } from "@/shared/api"; type TbankConnectionStatus = paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"]; -function isInvestmentPath(path: string) { - return [ - "/api/v1/brokerage-accounts", - "/api/v1/stocks", - "/api/v1/bonds", - ].some(root => path === root || path.startsWith(`${root}/`)); -} +const investmentGetPredicate = isGetUnder([ + "/api/v1/brokerage-accounts", + "/api/v1/stocks", + "/api/v1/bonds", +]); const tbankConnectionQueryOptions = () => apiQueryClient.queryOptions("get", "/api/v1/tbank-connection"); @@ -27,15 +26,7 @@ function commitTbankConnectionBoundary( ) { queryClient.setQueryData(tbankConnectionQueryKey, status); void queryClient.resetQueries({ - predicate: ({ queryKey }) => { - const [method, path] = queryKey; - - return ( - method === "get" - && typeof path === "string" - && isInvestmentPath(path) - ); - }, + predicate: ({ queryKey }) => investmentGetPredicate(queryKey), }); } diff --git a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.tsx b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.tsx index a0a7eac..3dab9ca 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.tsx +++ b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.tsx @@ -4,16 +4,16 @@ import { TbankConnectionError, TbankConnectionLoading, } from "./TbankConnectionSection.view"; -import { useTbankConnectionViewModel } from "./TbankConnectionSection.vm"; +import { useTbankConnectionSectionVm } from "./TbankConnectionSection.vm"; import { TbankConnectionSectionLoaded } from "./TbankConnectionSectionLoaded"; const TbankConnectionSection: FC = () => { - const viewModel = useTbankConnectionViewModel(); - if (viewModel.state === "loading") return ; + const vm = useTbankConnectionSectionVm(); + if (vm.state === "loading") return ; - return viewModel.state === "error" - ? - : ; + return vm.state === "error" + ? + : ; }; export { TbankConnectionSection }; diff --git a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.vm.ts b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.vm.ts index 9507c0e..fbb860f 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.vm.ts +++ b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.vm.ts @@ -2,7 +2,7 @@ import { useAuthenticatedQuery } from "@/shared/api"; import { tbankConnectionQueryOptions } from "../../model/connection-boundary"; -function useTbankConnectionViewModel() { +function useTbankConnectionSectionVm() { const status = useAuthenticatedQuery({ ...tbankConnectionQueryOptions(), }); @@ -22,4 +22,4 @@ function useTbankConnectionViewModel() { }; } -export { useTbankConnectionViewModel }; +export { useTbankConnectionSectionVm }; diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx index ba652d0..33cd2d6 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx @@ -26,7 +26,7 @@ import { resource } from "@/test/resource"; import { server } from "@/test/server"; import { AccountEventsPage } from "./AccountEventsPage"; -import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm"; +import { useAccountEventsPageVm } from "./AccountEventsPage.vm"; const Navigation: FC = () => { const location = useLocation(); @@ -100,7 +100,7 @@ describe("Upcoming event date filters", () => { ); const queryClient = createTestQueryClient(); - const { result } = renderHook(() => useAccountEventsPageViewModel(), { + const { result } = renderHook(() => useAccountEventsPageVm(), { wrapper: ({ children }) => ( @@ -146,7 +146,7 @@ describe("Upcoming event date filters", () => { const queryClient = createTestQueryClient(); const { result } = renderHook( - () => useAccountEventsPageViewModel(), + () => useAccountEventsPageVm(), { wrapper: ({ children }) => ( @@ -176,7 +176,11 @@ describe("Upcoming event date filters", () => { ({ request }) => { searches.push(new URL(request.url).search); - return HttpResponse.json(resource({ events: [] })); + return HttpResponse.json(resource({ + calculatedAt: "2026-08-14T00:00:00Z", + events: [], + window: { from: "2026-08-14", to: "2026-09-13" }, + })); }, ), ); @@ -223,7 +227,11 @@ describe("Upcoming event date filters", () => { "*/api/v1/brokerage-accounts/:accountId/events", ({ request }) => new URL(request.url).searchParams.get("from") === defaultFrom - ? HttpResponse.json(resource({ events: [] })) + ? HttpResponse.json(resource({ + calculatedAt: "2026-08-14T00:00:00Z", + events: [], + window: { from: "2026-08-14", to: "2026-09-13" }, + })) : HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 }), ), ); @@ -256,6 +264,7 @@ describe("Upcoming event date filters", () => { calculatedAt: "2099-01-01T00:00:00Z", events: [], incomeTotalsByCurrency: [], + window: { from: "2099-01-01", to: "2099-01-31" }, }), ); }, @@ -264,6 +273,7 @@ describe("Upcoming event date filters", () => { renderEvents(accountId, ["?from=2026-02-01&to=2026-02-28"]); const from = await screen.findByLabelText("Период"); 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(searches).toEqual(["?from=2026-02-01&to=2026-02-28"]); @@ -361,7 +371,7 @@ it("retains the previous complete forecast when its source refresh fails", async ); const queryClient = createTestQueryClient(); - const { result } = renderHook(() => useAccountEventsPageViewModel(), { + const { result } = renderHook(() => useAccountEventsPageVm(), { wrapper: ({ children }) => ( diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.retry.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.retry.test.tsx new file mode 100644 index 0000000..cb39af1 --- /dev/null +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.retry.test.tsx @@ -0,0 +1,167 @@ +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( + + + + + } + path="/accounts/:accountId/events" + /> + + + + , + ); + + 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(); + }); +}); diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx index 15994eb..3d32729 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx @@ -47,6 +47,7 @@ describe("AccountEventsPage", () => { accountId: "account", calculatedAt: "2026-08-14T00:00:00Z", events: [], + window: { from: "2026-08-14", to: "2026-09-13" }, }, isError: false, isLoading: false, @@ -136,6 +137,7 @@ describe("AccountEventsPage", () => { type: "offer", }, ], + window: { from: "2026-08-14", to: "2026-09-13" }, }, isError: false, isLoading: false, @@ -169,15 +171,18 @@ describe("AccountEventsPage", () => { expect(screen.getAllByText("O")).toHaveLength(2); }); - it("renders empty and quiet error states", async () => { + it("renders empty and error states with retry", async () => { const { rerender } = renderPage(); expect( screen.getByText("За выбранный период событий не найдено"), ).toBeInTheDocument(); query.mockReturnValue({ data: undefined, + error: { code: "TBANK_UNAVAILABLE", status: 503 }, isError: true, + isFetching: false, isLoading: false, + refetch: vi.fn(), } as never); rerender( @@ -187,7 +192,7 @@ describe("AccountEventsPage", () => { , ); expect(await screen.findByRole("button", { name: "Сбросить" })).toBeVisible(); - expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Повторить" })).toBeVisible(); }); it("shows a structural busy state before events are available", () => { @@ -273,6 +278,7 @@ describe("AccountEventsPage", () => { accountId: "all", calculatedAt: "2026-08-14T00:00:00Z", events: [...events], + window: { from: "2026-08-14", to: "2026-09-13" }, }, isError: false, isLoading: false, diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.tsx index 682dd4a..a898a75 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.tsx @@ -1,17 +1,17 @@ import type { FC } from "react"; -import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm"; +import { useAccountEventsPageVm } from "./AccountEventsPage.vm"; import { AccountEventsPageError } from "./components/AccountEventsPageError.view"; import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view"; import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view"; const AccountEventsPage: FC = () => { - const viewModel = useAccountEventsPageViewModel(); - if (viewModel.state === "loading") return ; + const vm = useAccountEventsPageVm(); + if (vm.state === "loading") return ; - return viewModel.state === "error" - ? - : ; + return vm.state === "error" + ? + : ; }; export { AccountEventsPage }; diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts index 92ef995..fe6c4d1 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts @@ -1,13 +1,12 @@ import { useState } from "react"; import { useParams } from "react-router"; -import type { InvestmentResourceMeta } from "@/shared/api"; - import { type BrokerageEvent, useBrokerageEvents, } from "@/entities/brokerage-event"; import { useScreenSync } from "@/features/tbank-connection"; +import { type InvestmentResourceMeta, readRetry } from "@/shared/api"; import { defaultPeriod, isValidPeriod, @@ -19,6 +18,7 @@ import { type AccountEventsPageViewModel = | { allAccounts: boolean; + calculatedAt: string; canApply: boolean; draftPeriod: [null | string, null | string]; events: BrokerageEvent[]; @@ -28,14 +28,20 @@ type AccountEventsPageViewModel onPeriodPopoverOpen: () => void; onReset: () => void; partial?: boolean; + readRetry: ReturnType; source?: InvestmentResourceMeta; state: "loaded"; totalVisible: boolean; + window: { from: string; to: string }; + } + | { + onReset: () => void; + readRetry: ReturnType; + state: "error"; } - | { onReset: () => void; state: "error" } | { state: "loading" }; -function useAccountEventsPageViewModel(): AccountEventsPageViewModel { +function useAccountEventsPageVm(): AccountEventsPageViewModel { const { accountId = "" } = useParams(); const { @@ -83,6 +89,8 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel { isFetching: events.isFetching, }); + const retry = readRetry([events]); + if (availability === "loading" && events.isLoading) return { state: "loading" }; @@ -94,15 +102,17 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel { if ((events.isError || events.meta?.lastAttempt.state === "failed") && !events.data) return { onReset, + readRetry: retry, state: "error", }; if (!events.data) return availability === "unavailable" - ? { onReset, state: "error" } + ? { onReset, readRetry: retry, state: "error" } : { state: "loading" }; return { allAccounts: accountId === "all", + calculatedAt: events.data.calculatedAt, canApply: (draftFrom !== appliedFrom || draftTo !== appliedTo) && isValidPeriod(draftFrom, draftTo), @@ -122,10 +132,12 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel { onPeriodPopoverOpen: () => setMinDate(moscowDate(new Date())), onReset, partial: events.meta?.completeness === "incomplete", + readRetry: retry, source: events.meta, state: "loaded", totalVisible: events.meta?.completeness === "complete", + window: events.data.window, }; } -export { type AccountEventsPageViewModel, useAccountEventsPageViewModel }; +export { type AccountEventsPageViewModel, useAccountEventsPageVm }; diff --git a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageError.view.tsx b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageError.view.tsx index 86666b5..b36b3d2 100644 --- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageError.view.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageError.view.tsx @@ -4,7 +4,12 @@ import { Button, Group } from "@mantine/core"; import { StateView } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; -type AccountEventsPageErrorProps = { onReset: () => void }; +import { ReadRetryButton, type ReadRetryProps } from "@/shared/ui"; + +type AccountEventsPageErrorProps = { + onReset: () => void; + readRetry: ReadRetryProps; +}; const AccountEventsPageError: FC = (props) => { const { t } = useTranslation(); @@ -13,6 +18,13 @@ const AccountEventsPageError: FC = (props) => { + {props.readRetry.exhausted && ( + + )} + diff --git a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx index 4fbd2f7..5e79c56 100644 --- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx @@ -17,8 +17,8 @@ import { brokerageEventGroupKey, brokerageEventKey, } from "@/entities/brokerage-event"; -import { groupItems } from "@/shared/lib"; -import { SourceSyncStatus } from "@/shared/ui"; +import { formatDate, formatSnapshotTime, groupItems } from "@/shared/lib"; +import { ReadRetry, SourceSyncStatus } from "@/shared/ui"; import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm"; @@ -83,10 +83,20 @@ const AccountEventsPageLoaded: FC = (props) => { stale={props.source?.freshness === "stale"} /> + + {!!props.partial && {t("investment.partial")}} {t("events.hint")} + + {t("events.forecastWindow", { + date: formatSnapshotTime(props.calculatedAt), + from: formatDate(props.window.from), + to: formatDate(props.window.to), + })} + + { ); const queryClient = createTestQueryClient(); - const { result } = renderHook(() => useAccountIncomePageViewModel(), { + const { result } = renderHook(() => useAccountIncomePageVm(), { wrapper: ({ children }) => ( @@ -403,21 +408,124 @@ describe("AccountIncomePage", () => { expect(screen.queryByText(/Сравнение с/)).not.toBeInTheDocument(); }); - it("shows a quiet realized error", async () => { + it.each([ + ["full", analytics], + ["empty", { ...analytics, buckets: [], entries: [], instruments: [], totalsByCurrency: [] }], + ])("shows the server source time and last attempt for a %s result", async (_name, data) => { server.use( - http.get( - "*/api/v1/brokerage-accounts/account-1/income-analytics", - () => new HttpResponse(null, { status: 503 }), + http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => + HttpResponse.json( + resource(data, { + freshness: "stale", + lastAttempt: { + finishedAt: "2026-08-27T01:00:00Z", + problems: [], + startedAt: "2026-08-27T00:59:00Z", + state: "failed", + }, + lastSuccessfulAt: "2026-08-26T12:00:00Z", + }), + ), ), ); renderPage(); - expect( - await screen.findByText("Не удалось рассчитать пассивный доход", undefined, { - timeout: 4000, - }), - ).toBeInTheDocument(); + expect(await screen.findByText(/^Данные на /)).toHaveTextContent( + formatSnapshotTime("2026-08-26T12:00:00Z"), + ); + expect(screen.getByText("Обновление не удалось")).toBeInTheDocument(); + expect(screen.getByText("Данные требуют обновления")).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 = () => { diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.tsx b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.tsx index f272f09..6133d53 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.tsx @@ -1,17 +1,17 @@ import type { FC } from "react"; -import { useAccountIncomePageViewModel } from "./AccountIncomePage.vm"; +import { useAccountIncomePageVm } from "./AccountIncomePage.vm"; import { AccountIncomePageError } from "./components/AccountIncomePageError.view"; import { AccountIncomePageLoaded } from "./components/AccountIncomePageLoaded.view"; import { AccountIncomePageLoading } from "./components/AccountIncomePageLoading.view"; const AccountIncomePage: FC = () => { - const viewModel = useAccountIncomePageViewModel(); - if (viewModel.state === "loading") return ; + const vm = useAccountIncomePageVm(); + if (vm.state === "loading") return ; - return viewModel.state === "error" - ? - : ; + return vm.state === "error" + ? + : ; }; export { AccountIncomePage }; diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts index 4c1d679..96ad523 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts @@ -5,6 +5,7 @@ import { useParams, useSearchParams } from "react-router"; import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income"; import { useScreenSync } from "@/features/tbank-connection"; +import { type InvestmentResourceMeta, readRetry } from "@/shared/api"; import { isValidPeriod, moscowDate, useInitialAvailability } from "@/shared/lib"; import { @@ -30,16 +31,19 @@ type AccountIncomePageViewModel onReset: () => void; onSubmit: (values: IncomeForm) => void; partial?: boolean; + readRetry: ReturnType; + source?: InvestmentResourceMeta; state: "loaded"; totalVisible: boolean; } | { period: IncomePeriod; + readRetry: ReturnType; state: "error"; } | { state: "loading" }; -const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => { +const useAccountIncomePageVm = (): AccountIncomePageViewModel => { const { accountId = "" } = useParams(); const [searchParameters, setSearchParameters] = useSearchParams(); @@ -84,12 +88,14 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => { isFetching: analytics.isFetching, }); + const retry = readRetry([analytics]); + if (availability === "loading" && analytics.isLoading) return { state: "loading" }; if (!analytics.data) return availability === "unavailable" - ? { period, state: "error" } + ? { period, readRetry: retry, state: "error" } : { state: "loading" }; const display = toIncomeDisplayModel(analytics.data); @@ -116,6 +122,8 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => { onReset, onSubmit, partial: analytics.meta?.completeness === "incomplete", + readRetry: retry, + source: analytics.meta, state: "loaded", totalVisible: analytics.meta?.completeness === "complete", }; @@ -138,5 +146,5 @@ function isValidIncomePeriod(period: IncomePeriod): boolean { export { type AccountIncomePageViewModel, - useAccountIncomePageViewModel, + useAccountIncomePageVm, }; diff --git a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageError.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageError.view.tsx index bdaf3bd..742ab88 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageError.view.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageError.view.tsx @@ -3,15 +3,27 @@ import type { FC } from "react"; import { StateView } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; +import { ReadRetryButton } from "@/shared/ui"; + import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm"; type ErrorProps = Extract; -const AccountIncomePageError: FC = () => { +const AccountIncomePageError: FC = (props) => { const { t } = useTranslation(); + const retry = props.readRetry.exhausted + ? ( + + ) + : undefined; + return ( = (props) => { /> + + + + {t("income.taxBoundary")} diff --git a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx index b0ef764..d43def2 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx @@ -90,9 +90,13 @@ function configureTestAuth() { }); } -function renderPage(search = "", accountId = "account-1") { +function renderPage( + search = "", + accountId = "account-1", + queryClient = createTestQueryClient(), +) { return render( - + { it("marks cashless operations without rendering operation details", () => { render( @@ -545,20 +561,87 @@ describe("AccountOperationsPage", () => { ).toBeInTheDocument(); }); - it("renders an error state", async () => { - configureTestAuth(); - server.use( - http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => - HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }), - ), - ); - renderPage(); - expect( - await screen.findByText("Не удалось загрузить журнал", undefined, { - timeout: 4000, - }), - ).toBeInTheDocument(); - }); + it( + "renders an error state with retry that recovers in place", + { timeout: TEST_MS }, + async () => { + configureTestAuth(); + let isFailing = true; + let requests = 0; + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => { + requests += 1; + + return isFailing + ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) + : HttpResponse.json(resource(loadedOperations)); + }), + ); + renderPage(); + expect( + await screen.findByText("Не удалось загрузить журнал", undefined, { + timeout: RETRIES_EXHAUSTED_MS, + }), + ).toBeInTheDocument(); + expect(requests).toBe(3); + + isFailing = false; + fireEvent.click(screen.getByRole("button", { name: "Повторить" })); + + expect(await screen.findByText("Покупка акций")).toBeInTheDocument(); + expect(requests).toBe(4); + }, + ); + + it( + "keeps cached operations, warns and retries the same GET", + { timeout: TEST_MS }, + async () => { + configureTestAuth(); + let isFailing = false; + let requests = 0; + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => { + requests += 1; + + return isFailing + ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) + : HttpResponse.json(resource(loadedOperations)); + }), + ); + const queryClient = createTestQueryClient(); + renderPage("", "account-1", queryClient); + await screen.findByText("Покупка акций"); + expect(screen.queryByRole("button", { name: "Повторить" })) + .not.toBeInTheDocument(); + + isFailing = true; + void queryClient.invalidateQueries({ + queryKey: [ + "get", + "/api/v1/brokerage-accounts/{accountId}/operations", + ], + }); + + const retry = await screen.findByRole( + "button", + { name: "Повторить" }, + { timeout: RETRIES_EXHAUSTED_MS }, + ); + + expect(screen.getByText("Не удалось обновить данные")).toBeInTheDocument(); + expect(screen.getByText("Покупка акций")).toBeInTheDocument(); + expect(requests).toBe(4); + + isFailing = false; + fireEvent.click(retry); + + await waitFor(() => + expect(screen.queryByText("Не удалось обновить данные")) + .not.toBeInTheDocument()); + expect(requests).toBe(5); + }, + ); it("keeps a null operations envelope loading instead of a confirmed empty period", async () => { configureTestAuth(); diff --git a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.tsx b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.tsx index de98d16..3b12363 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.tsx @@ -6,15 +6,15 @@ import { AccountOperationsPageLoaded } from "./components/AccountOperationsPageL import { AccountOperationsPageLoading } from "./components/AccountOperationsPageLoading.view"; const AccountOperationsPage: FC = () => { - const viewModel = useAccountOperationsPageVm(); + const vm = useAccountOperationsPageVm(); - if (viewModel.state === "loading") { + if (vm.state === "loading") { return ; } - return viewModel.state === "error" - ? - : ; + return vm.state === "error" + ? + : ; }; export { AccountOperationsPage }; diff --git a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts index a585843..0e53a4f 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts +++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts @@ -12,7 +12,12 @@ import { useBrokerageOperationHistory, } from "@/entities/broker-operation"; import { useScreenSync } from "@/features/tbank-connection"; -import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; +import { + ApiErrorCode, + apiErrorCode, + isApiErrorCode, + readRetry, +} from "@/shared/api"; import { defaultPeriod, isValidPeriod, @@ -42,11 +47,16 @@ type AccountOperationsPageViewModel onPrevious: () => void; onReset: () => void; partial?: boolean; + readRetry: ReturnType; source?: InvestmentResourceMeta; state: "loaded"; totalVisible: boolean; } - | { onReset: () => void; state: "error" } + | { + onReset: () => void; + readRetry: ReturnType; + state: "error"; + } | { state: "loading" }; const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => { @@ -107,6 +117,8 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => { isFetching: operations.isFetching, }); + const retry = readRetry([operations]); + if (availability === "loading" && operations.isLoading) return { state: "loading" }; @@ -134,11 +146,11 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => { (isInvalidCursor || !operations.data) && (operations.isError || operations.meta?.lastAttempt.state === "failed") ) - return { onReset, state: "error" }; + return { onReset, readRetry: retry, state: "error" }; if (!operations.data) return availability === "unavailable" - ? { onReset, state: "error" } + ? { onReset, readRetry: retry, state: "error" } : { state: "loading" }; const nextCursor = operations.data.nextCursor; @@ -182,6 +194,7 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => { onPrevious: history.onPrevious, onReset, partial: operations.meta?.completeness === "incomplete", + readRetry: retry, source: operations.meta, state: "loaded", totalVisible: operations.meta?.completeness === "complete", diff --git a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageError.view.tsx b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageError.view.tsx index a45e4a0..01aa336 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageError.view.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageError.view.tsx @@ -1,9 +1,11 @@ import type { FC } from "react"; -import { Button } from "@mantine/core"; +import { Button, Group } from "@mantine/core"; import { StateView } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; +import { ReadRetryButton } from "@/shared/ui"; + import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm"; type AccountOperationsPageErrorProps = Extract< @@ -19,9 +21,18 @@ const AccountOperationsPageError: FC = ( return ( - {t("ledger.reset")} - + + {props.readRetry.exhausted && ( + + )} + + + )} text={t("ledger.errorText")} title={t("ledger.errorTitle")} diff --git a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx index afe7301..447205a 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx @@ -19,7 +19,7 @@ import { OperationGroup, } from "@/entities/broker-operation"; import { groupItems } from "@/shared/lib"; -import { SourceSyncStatus } from "@/shared/ui"; +import { ReadRetry, SourceSyncStatus } from "@/shared/ui"; import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm"; @@ -102,6 +102,8 @@ const AccountOperationsPageLoaded: FC = ( stale={props.source?.freshness === "stale"} /> + + {!!props.partial && {t("investment.partial")}} {!!props.historyLoading && {t("investment.history")}} diff --git a/src/pages/account/model/positions.test.ts b/src/pages/account/model/positions.test.ts index e70943c..e1986d8 100644 --- a/src/pages/account/model/positions.test.ts +++ b/src/pages/account/model/positions.test.ts @@ -23,7 +23,7 @@ function sections(positions: Portfolio["positions"]) { } describe("account position sections", () => { - it("excludes currency instruments only from display, preserving order and share denominators", () => { + it("excludes currency mirrors from display and share denominators", () => { const otherTypes = [ "OPTION", "OTHER", @@ -73,12 +73,30 @@ describe("account position sections", () => { "FIGI-FUND", ...otherTypes.map(type => `FIGI-${type}-NEXT`), ]); - expect(sections[0]?.groups[0]?.positions[0].shareText).toBe("40 %"); - expect(sections[1]?.groups[0]?.positions[0].shareText).toBe("20 %"); - expect(sections[2]?.groups[1]?.positions[0].shareText).toBe("20 %"); + expect(sections[0]?.groups[0]?.positions[0].shareText).toBe("50 %"); + expect(sections[1]?.groups[0]?.positions[0].shareText).toBe("25 %"); + expect(sections[2]?.groups[1]?.positions[0].shareText).toBe("25 %"); 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", () => { expect( toAccountPagePositionSections(portfolio).map(({ groups, kind }) => ({ diff --git a/src/pages/account/model/positions.ts b/src/pages/account/model/positions.ts index 8fd431f..5019923 100644 --- a/src/pages/account/model/positions.ts +++ b/src/pages/account/model/positions.ts @@ -152,7 +152,7 @@ function toAccountPagePositionSections( const denominators = new Map(); for (const position of portfolio.positions) { - if (!position.currentValue) continue; + if (position.type === "CURRENCY" || !position.currentValue) continue; const currency = position.currentValue.currency; denominators.set( currency, diff --git a/src/pages/account/ui/AccountPage/AccountPage.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.test.tsx index 296a08a..9ab30b9 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.test.tsx @@ -18,6 +18,7 @@ import type { BrokerageEvent } from "@/entities/brokerage-event"; import { type Portfolio, portfolioQueryOptions } from "@/entities/portfolio"; import { configureAuthClient } from "@/shared/api"; +import { formatSnapshotTime } from "@/shared/lib"; import { createTestQueryClient } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; @@ -59,6 +60,10 @@ 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") { configureTestAuth(); @@ -231,6 +236,37 @@ describe("AccountPage states", () => { .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", () => { const positions = [400, 250, 150, 100, 50, 30, 20].map( (value, index) => position({ @@ -874,7 +910,6 @@ describe("AccountPage states", () => { ).toBeInTheDocument(); expect(screen.queryByText(/T-Bank временно недоступен/)) .not.toBeInTheDocument(); - expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument(); expect(screen.getByRole("radio", { name: "Акции" })).toBeInTheDocument(); expect( screen.getByRole("radio", { name: "Облигации" }), @@ -1224,6 +1259,110 @@ describe("AccountPage states", () => { 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])( "preserves totals quietly through refresh error %i and success", async (status) => { @@ -1276,7 +1415,7 @@ describe("AccountPage states", () => { }); expect(structure).toHaveTextContent("10 ₽"); - expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument(); + expect(screen.getByText(/^Данные на /)).toBeInTheDocument(); void queryClient.invalidateQueries({ queryKey: portfolioQueryOptions("account-1").queryKey, }); @@ -1320,7 +1459,7 @@ describe("AccountPage states", () => { screen.getAllByRole("definition").map(node => node.textContent), ).toEqual(["-0,5 $"]), ); - expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument(); + expect(screen.getByText(/^Данные на /)).toHaveTextContent("Данные на"); expect(structure).not.toHaveTextContent("10 ₽"); expect(structure).toHaveTextContent("Нет положительной стоимости"); expect( @@ -1361,7 +1500,7 @@ describe("AccountPage states", () => { expect( screen.getAllByRole("definition").map(node => node.textContent), ).toEqual(["1 234,5 ₽"]); - expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument(); + expect(screen.getByText(/^Данные на /)).toBeInTheDocument(); expect( screen.queryByRole("status", { name: "Загрузка" }), ).not.toBeInTheDocument(); diff --git a/src/pages/account/ui/AccountPage/AccountPage.tsx b/src/pages/account/ui/AccountPage/AccountPage.tsx index e742f7f..0b74da4 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.tsx @@ -1,45 +1,48 @@ import type { FC } from "react"; -import { useAccountPageViewModel } from "./AccountPage.vm"; +import { useAccountPageVm } from "./AccountPage.vm"; import { AccountPageEmpty } from "./components/AccountPageEmpty.view"; import { AccountPageError } from "./components/AccountPageError.view"; import { AccountPageLoaded } from "./components/AccountPageLoaded.view"; import { AccountPageLoading } from "./components/AccountPageLoading/AccountPageLoading.view"; const AccountPage: FC = () => { - const viewModel = useAccountPageViewModel(); - if (viewModel.state === "loading") return ; - if (viewModel.state === "error") return ; - if (viewModel.state === "empty") + const vm = useAccountPageVm(); + if (vm.state === "loading") return ; + if (vm.state === "error") return ; + if (vm.state === "empty") return ( ); return ( ); }; diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx index 62d0b39..9915fae 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx @@ -12,7 +12,7 @@ import { resource } from "@/test/resource"; import { server } from "@/test/server"; import { portfolio } from "../../model/positions.fixtures"; -import { useAccountPageViewModel } from "./AccountPage.vm"; +import { useAccountPageVm } from "./AccountPage.vm"; function configureTestAuth() { configureAuthClient({ @@ -102,12 +102,12 @@ function mockLoadedAccountPage() { ); } -describe("useAccountPageViewModel", () => { +describe("useAccountPageVm", () => { it("prepares recent operations and events state for presentation views", async () => { configureTestAuth(); mockLoadedAccountPage(); - const { result } = renderHook(() => useAccountPageViewModel(), { + const { result } = renderHook(() => useAccountPageVm(), { wrapper: createWrapper(), }); @@ -154,7 +154,7 @@ describe("useAccountPageViewModel", () => { ), ); - const { result } = renderHook(() => useAccountPageViewModel(), { + const { result } = renderHook(() => useAccountPageVm(), { wrapper: createWrapper(), }); @@ -194,7 +194,7 @@ it("keeps overview sections independent when their source data is incomplete", a ), ); - const { result } = renderHook(() => useAccountPageViewModel(), { + const { result } = renderHook(() => useAccountPageVm(), { wrapper: createWrapper(), }); @@ -232,7 +232,7 @@ it("keeps overview sections loaded without exposing technical retry", async () = ), ); - const { result } = renderHook(() => useAccountPageViewModel(), { + const { result } = renderHook(() => useAccountPageVm(), { wrapper: createWrapper(), }); diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.ts b/src/pages/account/ui/AccountPage/AccountPage.vm.ts index 5ecf856..8f0106e 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.vm.ts +++ b/src/pages/account/ui/AccountPage/AccountPage.vm.ts @@ -16,7 +16,7 @@ import { import { useIncomeSummary } from "@/entities/income"; import { usePortfolio } from "@/entities/portfolio"; import { useScreenSync } from "@/features/tbank-connection"; -import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; +import { apiErrorCode, isBrokerageAccessLoss, readRetry } from "@/shared/api"; import { useInitialAvailability } from "@/shared/lib"; import { @@ -45,8 +45,10 @@ type AccountPageContent = { accountId?: string; incomeSummary: AccountPageIncomeSummary; partial?: boolean; + readRetry: AccountPageReadRetry; recentEvents: AccountPageRecentEvents; recentOperations: AccountPageRecentOperations; + source?: InvestmentResourceMeta; }; type AccountPageEmptyViewModel = AccountPageContent & { @@ -62,25 +64,20 @@ type AccountPageLoadedViewModel = AccountPageContent & { portfolio: Portfolio; positionSections: AccountPagePositionSection[]; slice: Slice; - source?: InvestmentResourceMeta; state: "loaded"; tab: string; unrealizedReturns: Portfolio["totalsByCurrency"]; }; +type AccountPageReadRetry = ReturnType; + type AccountPageViewModel = | AccountPageEmptyViewModel | AccountPageLoadedViewModel - | { code?: string; state: "error" } + | { code?: string; readRetry: AccountPageReadRetry; state: "error" } | { state: "loading" }; -function isPortfolioAccessFailure(code?: string) { - return isApiErrorCode(code, ApiErrorCode.InvalidTbankToken) - || isApiErrorCode(code, ApiErrorCode.NotFound) - || isApiErrorCode(code, ApiErrorCode.TbankNotConnected); -} - -function useAccountPageViewModel(): AccountPageViewModel { +function useAccountPageVm(): AccountPageViewModel { const { accountId } = useParams(); const { t } = useTranslation(); @@ -119,7 +116,9 @@ function useAccountPageViewModel(): AccountPageViewModel { ]); const code = apiErrorCode(portfolio.error); - const isAccessFailure = isPortfolioAccessFailure(code); + // The portfolio drives this screen; the retry repeats only its GET. + const retry = readRetry([portfolio]); + const isAccessFailure = isBrokerageAccessLoss(code); const availability = useInitialAvailability({ hasContent: !!portfolio.data, @@ -128,12 +127,13 @@ function useAccountPageViewModel(): AccountPageViewModel { isFetching: portfolio.isFetching, }); - if (isAccessFailure) return { code, state: "error" as const }; + if (isAccessFailure) + return { code, readRetry: retry, state: "error" as const }; if (!portfolio.data) - return { - state: availability === "unavailable" ? "error" : "loading", - } as const; + return availability === "unavailable" + ? { code, readRetry: retry, state: "error" as const } + : { state: "loading" as const }; const account = accounts.data?.find(item => item.id === accountId); const recentOperations = toRecentOperations(accountId, operations); @@ -150,8 +150,10 @@ function useAccountPageViewModel(): AccountPageViewModel { account, accountId, incomeSummary, + readRetry: retry, recentEvents, recentOperations, + source: portfolio.meta, state: "empty" as const, }; @@ -186,6 +188,7 @@ function useAccountPageViewModel(): AccountPageViewModel { partial: isPartial, portfolio: portfolio.data, positionSections: toAccountPagePositionSections(portfolio.data), + readRetry: retry, recentEvents, recentOperations, slice: current.slice, @@ -196,4 +199,4 @@ function useAccountPageViewModel(): AccountPageViewModel { }; } -export { useAccountPageViewModel }; +export { useAccountPageVm }; diff --git a/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx index 5e336a9..41a688c 100644 --- a/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx @@ -5,9 +5,10 @@ import { StateView } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; import type { BrokerageAccount } from "@/entities/brokerage-account"; +import type { InvestmentResourceMeta } from "@/shared/api"; import { BrokerageEventsPreview } from "@/entities/brokerage-event"; -import { PortfolioStructure } from "@/widgets/portfolio-structure"; +import { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui"; import type { AccountPageIncomeSummary, @@ -17,14 +18,17 @@ import type { import { AccountHeading } from "./AccountHeading.view"; import { IncomeSummary } from "./IncomeSummary.view"; +import { PortfolioStructure } from "./PortfolioStructure/PortfolioStructure"; import { RecentOperations } from "./RecentOperations.view"; type AccountPageEmptyProps = { account?: BrokerageAccount; accountId?: string; incomeSummary?: AccountPageIncomeSummary; + readRetry?: ReadRetryProps; recentEvents: AccountPageRecentEvents; recentOperations: AccountPageRecentOperations; + source?: InvestmentResourceMeta; }; const AccountPageEmpty: FC = (props) => { @@ -40,6 +44,14 @@ const AccountPageEmpty: FC = (props) => { + + + {!!props.readRetry && } + = (props) => { const { code } = props; @@ -43,8 +47,18 @@ const AccountPageError: FC = (props) => { /> ); + const retry = props.readRetry?.exhausted + ? ( + + ) + : undefined; + return ( diff --git a/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx index 8348501..3611ae5 100644 --- a/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx @@ -15,8 +15,7 @@ import type { InvestmentResourceMeta } from "@/shared/api"; import { BrokerageEventsPreview } from "@/entities/brokerage-event"; import { formatMoney } from "@/shared/lib"; -import { SourceSyncStatus } from "@/shared/ui"; -import { PortfolioStructure } from "@/widgets/portfolio-structure"; +import { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui"; import type { Concentration as ConcentrationModel, Slice } from "../../../model/concentration"; import type { AccountPagePositionSection } from "../../../model/positions"; @@ -31,6 +30,7 @@ import { AccountHeading } from "./AccountHeading.view"; import { Concentration } from "./Concentration/Concentration.view"; import { CurrencyCards } from "./CurrencyCards/CurrencyCards.view"; import { IncomeSummary } from "./IncomeSummary.view"; +import { PortfolioStructure } from "./PortfolioStructure/PortfolioStructure"; import { PositionSection } from "./PositionSection.view"; import { RecentOperations } from "./RecentOperations.view"; @@ -46,6 +46,7 @@ type AccountPageLoadedProps = { partial?: boolean; portfolio: Portfolio; positionSections: AccountPagePositionSection[]; + readRetry?: ReadRetryProps; recentEvents: AccountPageRecentEvents; recentOperations: AccountPageRecentOperations; slice: Slice; @@ -86,6 +87,8 @@ const AccountPageLoaded: FC = (props) => { stale={props.source?.freshness === "stale"} /> + {!!props.readRetry && } + {props.partial ? tab !== "concentration" && ( {t("investment.partial")} diff --git a/src/widgets/portfolio-structure/ui/PortfolioStructure.module.css b/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.module.css similarity index 100% rename from src/widgets/portfolio-structure/ui/PortfolioStructure.module.css rename to src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.module.css diff --git a/src/widgets/portfolio-structure/ui/PortfolioStructure.stories.tsx b/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.stories.tsx similarity index 98% rename from src/widgets/portfolio-structure/ui/PortfolioStructure.stories.tsx rename to src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.stories.tsx index aecd386..38900e5 100644 --- a/src/widgets/portfolio-structure/ui/PortfolioStructure.stories.tsx +++ b/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.stories.tsx @@ -28,7 +28,7 @@ const meta = { }, }, tags: ["autodocs"], - title: "Widgets/PortfolioStructure", + title: "Product/PortfolioStructure", } satisfies Meta; export default meta; diff --git a/src/widgets/portfolio-structure/ui/PortfolioStructure.test.tsx b/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.test.tsx similarity index 100% rename from src/widgets/portfolio-structure/ui/PortfolioStructure.test.tsx rename to src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.test.tsx diff --git a/src/widgets/portfolio-structure/ui/PortfolioStructure.tsx b/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.tsx similarity index 100% rename from src/widgets/portfolio-structure/ui/PortfolioStructure.tsx rename to src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.tsx diff --git a/src/widgets/portfolio-structure/ui/PortfolioStructure.view.tsx b/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.view.tsx similarity index 100% rename from src/widgets/portfolio-structure/ui/PortfolioStructure.view.tsx rename to src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.view.tsx diff --git a/src/widgets/portfolio-structure/ui/PortfolioStructure.vm.ts b/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.vm.ts similarity index 100% rename from src/widgets/portfolio-structure/ui/PortfolioStructure.vm.ts rename to src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.vm.ts diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx b/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx index 6da65ee..9fc2416 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx @@ -1,6 +1,6 @@ import { MantineProvider } from "@mantine/core"; import { type QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { MemoryRouter } from "react-router"; import { describe, expect, it, vi } from "vitest"; @@ -261,6 +261,101 @@ describe("AccountsPage", () => { }); }); +// Automatic read retries wait 1s + 2s before the failure is final. +const RETRIES_EXHAUSTED_MS = 6000; +const TEST_MS = 10_000; + +describe("exhausted read retries", () => { + it( + "keeps the cached list, warns and retries the same accounts GET", + { timeout: TEST_MS }, + async () => { + let isFailing = false; + let requests = 0; + server.use( + http.get("*/api/v1/brokerage-accounts", () => { + requests += 1; + if (isFailing) + return HttpResponse.json( + { code: "TBANK_UNAVAILABLE" }, + { status: 503 }, + ); + + return HttpResponse.json(resource([account])); + }), + ); + const queryClient = createTestQueryClient(); + renderPage(queryClient); + await screen.findByText("Основной"); + expect(screen.queryByRole("button", { name: "Повторить" })) + .not.toBeInTheDocument(); + + isFailing = true; + void queryClient.invalidateQueries({ + queryKey: brokerageAccountsQueryKey, + }); + + const retry = await screen.findByRole( + "button", + { name: "Повторить" }, + { timeout: RETRIES_EXHAUSTED_MS }, + ); + + expect(screen.getByText("Не удалось обновить данные")).toBeInTheDocument(); + expect(screen.getByText("Основной")).toBeInTheDocument(); + expect(requests).toBe(4); + + isFailing = false; + fireEvent.click(retry); + + await waitFor(() => + expect(screen.queryByText("Не удалось обновить данные")) + .not.toBeInTheDocument()); + expect(requests).toBe(5); + expect(screen.getByText("Основной")).toBeInTheDocument(); + }, + ); + + it( + "offers retry on the unavailable state after an initial failure", + { timeout: TEST_MS }, + async () => { + let isFailing = true; + server.use( + http.get("*/api/v1/brokerage-accounts", () => + isFailing + ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) + : HttpResponse.json(resource([account]))), + ); + renderPage(createTestQueryClient()); + + const retry = await screen.findByRole( + "button", + { name: "Повторить" }, + { timeout: RETRIES_EXHAUSTED_MS }, + ); + + isFailing = false; + fireEvent.click(retry); + + expect(await screen.findByText("Основной")).toBeInTheDocument(); + }, + ); + + it("does not offer a plain retry when the connection is lost", async () => { + server.use( + http.get("*/api/v1/brokerage-accounts", () => + HttpResponse.json({ code: "TBANK_NOT_CONNECTED" }, { status: 409 })), + ); + renderPage(createTestQueryClient()); + + expect(await screen.findByRole("link", { name: "Открыть настройки T-Bank" })) + .toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Повторить" })) + .not.toBeInTheDocument(); + }); +}); + it.each(["none", "failed"] as const)( "keeps incomplete empty membership distinct from no accounts (attempt=%s)", async (state) => { diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx b/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx index 8e02cdc..0ea59ec 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx @@ -1,21 +1,22 @@ import type { FC } from "react"; -import { useAccountsPageViewModel } from "./AccountsPage.vm"; +import { useAccountsPageVm } from "./AccountsPage.vm"; import { AccountsPageEmpty } from "./components/AccountsPageEmpty.view"; import { AccountsPageError } from "./components/AccountsPageError.view"; import { AccountsPageLoaded } from "./components/AccountsPageLoaded.view"; import { AccountsPageLoading } from "./components/AccountsPageLoading.view"; const AccountsPage: FC = () => { - const viewModel = useAccountsPageViewModel(); - if (viewModel.state === "loading") return ; - if (viewModel.state === "error") return ; - if (viewModel.state === "empty") return ; + const vm = useAccountsPageVm(); + if (vm.state === "loading") return ; + if (vm.state === "error") return ; + if (vm.state === "empty") return ; return ( ); }; diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts b/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts index 4f3252c..838f22f 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts @@ -4,25 +4,38 @@ import { useBrokerageAccounts, } from "@/entities/brokerage-account"; import { useScreenSync } from "@/features/tbank-connection"; -import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; +import { + ApiErrorCode, + apiErrorCode, + isApiErrorCode, + readRetry, +} from "@/shared/api"; import { useInitialAvailability } from "@/shared/lib"; +type AccountsPageReadRetry = ReturnType; + type AccountsPageViewModel - = | { accounts: BrokerageAccountCard[]; meta: NonNullable["meta"]>; state: "loaded" } - | { code?: string; state: "error" } - | { state: "empty" } - | { state: "loading" }; + = | { + accounts: BrokerageAccountCard[]; + meta: NonNullable["meta"]>; + readRetry: AccountsPageReadRetry; + state: "loaded"; + } + | { code?: string; readRetry: AccountsPageReadRetry; state: "error" } + | { state: "empty" } + | { state: "loading" }; function isAccountsAccessFailure(code?: string) { return isApiErrorCode(code, ApiErrorCode.InvalidTbankToken) || isApiErrorCode(code, ApiErrorCode.TbankNotConnected); } -function useAccountsPageViewModel(): AccountsPageViewModel { +function useAccountsPageVm(): AccountsPageViewModel { const accounts = useBrokerageAccounts(); useScreenSync({ screen: "accounts" }, [accounts]); const code = apiErrorCode(accounts.error); + const retry = readRetry([accounts]); const isAccessFailure = isAccountsAccessFailure(code); const hasResult = !!accounts.data @@ -36,12 +49,13 @@ function useAccountsPageViewModel(): AccountsPageViewModel { isFetching: accounts.isFetching, }); - if (isAccessFailure) return { code, state: "error" as const }; + if (isAccessFailure) + return { code, readRetry: retry, state: "error" as const }; if (!hasResult || !accounts.data) - return { - state: availability === "unavailable" ? "error" : "loading", - } as const; + return availability === "unavailable" + ? { code, readRetry: retry, state: "error" as const } + : { state: "loading" as const }; if (accounts.data.length === 0) return { @@ -54,8 +68,9 @@ function useAccountsPageViewModel(): AccountsPageViewModel { return { accounts: brokerageAccountCards(accounts.data, meta.dependencies), meta, + readRetry: retry, state: "loaded" as const, }; } -export { useAccountsPageViewModel }; +export { useAccountsPageVm }; diff --git a/src/pages/accounts/ui/AccountsPage/components/AccountsPageError.view.tsx b/src/pages/accounts/ui/AccountsPage/components/AccountsPageError.view.tsx index 7ad5c98..9d4cf6e 100644 --- a/src/pages/accounts/ui/AccountsPage/components/AccountsPageError.view.tsx +++ b/src/pages/accounts/ui/AccountsPage/components/AccountsPageError.view.tsx @@ -6,8 +6,12 @@ import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import { ApiErrorCode, isApiErrorCode } from "@/shared/api"; +import { ReadRetryButton, type ReadRetryProps } from "@/shared/ui"; -type AccountsPageErrorProps = { code?: string }; +type AccountsPageErrorProps = { + code?: string; + readRetry?: ReadRetryProps; +}; const AccountsPageError: FC = (props) => { const { code } = props; @@ -38,8 +42,18 @@ const AccountsPageError: FC = (props) => { /> ); + const retry = props.readRetry?.exhausted + ? ( + + ) + : undefined; + return ( diff --git a/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx b/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx index 96d5d88..ef5e51e 100644 --- a/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx +++ b/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx @@ -11,7 +11,7 @@ import { BrokerageAccountList, } from "@/entities/brokerage-account"; import { formatMoney } from "@/shared/lib"; -import { SourceSyncStatus } from "@/shared/ui"; +import { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui"; const DECIMAL_BASE = 10n; const DECIMAL_PART_COUNT = 2; @@ -20,6 +20,7 @@ const ZERO = 0n; type AccountsPageLoadedProps = { accounts: BrokerageAccountCard[]; meta: InvestmentResourceMeta; + readRetry?: ReadRetryProps; }; type Money = { currency: string; value: string }; @@ -132,6 +133,8 @@ const AccountsPageLoaded: FC = (props) => { lastSuccessfulAt={props.meta.lastSuccessfulAt} stale={props.meta.freshness === "stale"} /> + + {!!props.readRetry && } + ); +}; + +const ReadRetry: FC = (props) => { + const { t } = useTranslation(); + + if (!props.exhausted) return null; + + return ( + + + {t("investment.readFailed")} + + + + + ); +}; + +export { ReadRetry, ReadRetryButton, type ReadRetryProps }; diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 88a1681..99f0483 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -5,4 +5,10 @@ export { EntryGroupTotal, } from "./EntryGroup/EntryGroup"; export { PayoutCard } from "./PayoutCard/PayoutCard"; +export { + ReadRetry, + ReadRetryButton, + type ReadRetryProps, +} from "./ReadRetry/ReadRetry"; export { SourceSyncStatus } from "./SourceSyncStatus/SourceSyncStatus"; +export { useAppDocumentTitle } from "./useAppDocumentTitle"; diff --git a/src/shared/ui/useAppDocumentTitle.ts b/src/shared/ui/useAppDocumentTitle.ts new file mode 100644 index 0000000..944d636 --- /dev/null +++ b/src/shared/ui/useAppDocumentTitle.ts @@ -0,0 +1,13 @@ +import { useDocumentTitle } from "@mantine/hooks"; +import { useTranslation } from "react-i18next"; + +/** + * Sets `document.title` to ` — <appName>`. + * Owner of the "page title + app name suffix" formatting rule. + */ +function useAppDocumentTitle(title: string) { + const { t } = useTranslation(); + useDocumentTitle(`${title} — ${t("appName")}`); +} + +export { useAppDocumentTitle }; diff --git a/src/test/handlers.ts b/src/test/handlers.ts index a3e1595..0b4606c 100644 --- a/src/test/handlers.ts +++ b/src/test/handlers.ts @@ -66,6 +66,7 @@ const handlers = [ accountId: "account-1", calculatedAt: "2026-08-14T00:00:00Z", events: [], + window: { from: "2026-08-14", to: "2026-09-13" }, }), ), ), diff --git a/src/widgets/portfolio-structure/index.ts b/src/widgets/portfolio-structure/index.ts deleted file mode 100644 index b89160e..0000000 --- a/src/widgets/portfolio-structure/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PortfolioStructure } from "./ui/PortfolioStructure";