From 71fbe7774d3ed63fcb84bc644da997921bc049b2 Mon Sep 17 00:00:00 2001 From: codex-bot Date: Mon, 2 Nov 2026 07:31:25 +0300 Subject: [PATCH 01/12] refactor: replace repeated lazy page wrappers with lazyPage helper (#131) (#132) --- src/app/routes/AppRoutes/AppRoutes.tsx | 122 ++++++------------------- 1 file changed, 28 insertions(+), 94 deletions(-) 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 = () => ( -- 2.54.0 From 9619dc979172dbc16391206e801ed67862d20116 Mon Sep 17 00:00:00 2001 From: codex-bot Date: Tue, 3 Nov 2026 06:44:13 +0300 Subject: [PATCH 02/12] fix(account): show source metadata on empty account and income (WealthLedger/frontend#55) (#133) --- .../AccountIncomePage.test.tsx | 30 ++++++++++++++ .../AccountIncomePage/AccountIncomePage.vm.ts | 4 ++ .../AccountIncomePageLoaded.view.tsx | 8 ++++ .../ui/AccountPage/AccountPage.test.tsx | 39 +++++++++++++++++-- .../account/ui/AccountPage/AccountPage.tsx | 1 + .../account/ui/AccountPage/AccountPage.vm.ts | 13 ++----- .../components/AccountPageEmpty.view.tsx | 9 +++++ src/shared/api/client.ts | 15 +++---- src/shared/api/index.ts | 7 +++- src/shared/api/problem-details.test.ts | 14 ++++++- src/shared/api/problem-details.ts | 12 +++++- src/shared/api/resource-query.ts | 12 +----- 12 files changed, 131 insertions(+), 33 deletions(-) diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx index c1e8038..bcd6ce8 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx @@ -26,6 +26,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { IncomeAnalytics } from "@/entities/income"; 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"; @@ -403,6 +404,35 @@ describe("AccountIncomePage", () => { expect(screen.queryByText(/Сравнение с/)).not.toBeInTheDocument(); }); + 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", () => + 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(/^Данные на /)).toHaveTextContent( + formatSnapshotTime("2026-08-26T12:00:00Z"), + ); + expect(screen.getByText("Обновление не удалось")).toBeInTheDocument(); + expect(screen.getByText("Данные требуют обновления")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); + }); + it("shows a quiet realized error", async () => { server.use( http.get( diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts index 4c1d679..f22540d 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts @@ -3,6 +3,8 @@ import { useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { useParams, useSearchParams } from "react-router"; +import type { InvestmentResourceMeta } from "@/shared/api"; + import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income"; import { useScreenSync } from "@/features/tbank-connection"; import { isValidPeriod, moscowDate, useInitialAvailability } from "@/shared/lib"; @@ -30,6 +32,7 @@ type AccountIncomePageViewModel onReset: () => void; onSubmit: (values: IncomeForm) => void; partial?: boolean; + source?: InvestmentResourceMeta; state: "loaded"; totalVisible: boolean; } @@ -116,6 +119,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => { onReset, onSubmit, partial: analytics.meta?.completeness === "incomplete", + source: analytics.meta, state: "loaded", totalVisible: analytics.meta?.completeness === "complete", }; diff --git a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx index 33fbcb8..b2996e7 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx @@ -9,6 +9,8 @@ import { StateView } from "@wealthledger/ui"; import { FormProvider } from "react-hook-form"; import { useTranslation } from "react-i18next"; +import { SourceSyncStatus } from "@/shared/ui"; + import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm"; import classes from "../AccountIncomePage.module.css"; @@ -49,6 +51,12 @@ const AccountIncomePageLoaded: FC = (props) => { /> + + {t("income.taxBoundary")} diff --git a/src/pages/account/ui/AccountPage/AccountPage.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.test.tsx index 296a08a..37cbacf 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"; @@ -231,6 +232,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 +906,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: "Облигации" }), @@ -1276,7 +1307,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 +1351,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 +1392,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..f9809bc 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.tsx @@ -18,6 +18,7 @@ const AccountPage: FC = () => { incomeSummary={viewModel.incomeSummary} recentEvents={viewModel.recentEvents} recentOperations={viewModel.recentOperations} + source={viewModel.source} /> ); diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.ts b/src/pages/account/ui/AccountPage/AccountPage.vm.ts index 5ecf856..622071a 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 } from "@/shared/api"; import { useInitialAvailability } from "@/shared/lib"; import { @@ -47,6 +47,7 @@ type AccountPageContent = { partial?: boolean; recentEvents: AccountPageRecentEvents; recentOperations: AccountPageRecentOperations; + source?: InvestmentResourceMeta; }; type AccountPageEmptyViewModel = AccountPageContent & { @@ -62,7 +63,6 @@ type AccountPageLoadedViewModel = AccountPageContent & { portfolio: Portfolio; positionSections: AccountPagePositionSection[]; slice: Slice; - source?: InvestmentResourceMeta; state: "loaded"; tab: string; unrealizedReturns: Portfolio["totalsByCurrency"]; @@ -74,12 +74,6 @@ type AccountPageViewModel | { code?: string; state: "error" } | { state: "loading" }; -function isPortfolioAccessFailure(code?: string) { - return isApiErrorCode(code, ApiErrorCode.InvalidTbankToken) - || isApiErrorCode(code, ApiErrorCode.NotFound) - || isApiErrorCode(code, ApiErrorCode.TbankNotConnected); -} - function useAccountPageViewModel(): AccountPageViewModel { const { accountId } = useParams(); const { t } = useTranslation(); @@ -119,7 +113,7 @@ function useAccountPageViewModel(): AccountPageViewModel { ]); const code = apiErrorCode(portfolio.error); - const isAccessFailure = isPortfolioAccessFailure(code); + const isAccessFailure = isBrokerageAccessLoss(code); const availability = useInitialAvailability({ hasContent: !!portfolio.data, @@ -152,6 +146,7 @@ function useAccountPageViewModel(): AccountPageViewModel { incomeSummary, recentEvents, recentOperations, + source: portfolio.meta, state: "empty" as const, }; diff --git a/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx index 5e336a9..510c88c 100644 --- a/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx @@ -5,8 +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 { SourceSyncStatus } from "@/shared/ui"; import { PortfolioStructure } from "@/widgets/portfolio-structure"; import type { @@ -25,6 +27,7 @@ type AccountPageEmptyProps = { incomeSummary?: AccountPageIncomeSummary; recentEvents: AccountPageRecentEvents; recentOperations: AccountPageRecentOperations; + source?: InvestmentResourceMeta; }; const AccountPageEmpty: FC = (props) => { @@ -40,6 +43,12 @@ const AccountPageEmpty: FC = (props) => { + + isApiErrorCode(code, permanent)); + return isBrokerageAccessLoss(code) + || isApiErrorCode(code, ApiErrorCode.Unauthorized); } function isTransientReadFailure( diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts index faea056..bbd54da 100644 --- a/src/shared/api/index.ts +++ b/src/shared/api/index.ts @@ -7,7 +7,12 @@ export { configureAuthClient, } from "./client"; export type { paths } from "./generated/schema"; -export { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details"; +export { + ApiErrorCode, + apiErrorCode, + isApiErrorCode, + isBrokerageAccessLoss, +} from "./problem-details"; export { type InvestmentResourceMeta, diff --git a/src/shared/api/problem-details.test.ts b/src/shared/api/problem-details.test.ts index 0c594e3..afc9c8e 100644 --- a/src/shared/api/problem-details.test.ts +++ b/src/shared/api/problem-details.test.ts @@ -1,6 +1,11 @@ import { expect, it } from "vitest"; -import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details"; +import { + ApiErrorCode, + apiErrorCode, + isApiErrorCode, + isBrokerageAccessLoss, +} from "./problem-details"; it("matches known codes and preserves unknown wire codes for fallback handling", () => { expect( @@ -16,3 +21,10 @@ it("matches known codes and preserves unknown wire codes for fallback handling", expect(apiErrorCode(null)).toBeUndefined(); expect(apiErrorCode({})).toBeUndefined(); }); + +it("separates brokerage access loss from UNAUTHORIZED, temporary and unknown codes", () => { + for (const code of ["INVALID_TBANK_TOKEN", "NOT_FOUND", "TBANK_NOT_CONNECTED"]) + expect(isBrokerageAccessLoss(code)).toBe(true); + for (const code of ["UNAUTHORIZED", "TBANK_UNAVAILABLE", "FUTURE_PROVIDER_ERROR", undefined]) + expect(isBrokerageAccessLoss(code)).toBe(false); +}); diff --git a/src/shared/api/problem-details.ts b/src/shared/api/problem-details.ts index bcea7d5..8d10712 100644 --- a/src/shared/api/problem-details.ts +++ b/src/shared/api/problem-details.ts @@ -41,4 +41,14 @@ function isApiErrorCode( return value === expected; } -export { ApiErrorCode, apiErrorCode, isApiErrorCode }; +// Confirmed loss of access to the brokerage data. UNAUTHORIZED is a session +// failure with its own refresh/sign-out path, so it is deliberately excluded. +function isBrokerageAccessLoss(code?: string): boolean { + return [ + ApiErrorCode.InvalidTbankToken, + ApiErrorCode.NotFound, + ApiErrorCode.TbankNotConnected, + ].some(loss => isApiErrorCode(code, loss)); +} + +export { ApiErrorCode, apiErrorCode, isApiErrorCode, isBrokerageAccessLoss }; diff --git a/src/shared/api/resource-query.ts b/src/shared/api/resource-query.ts index ca6ad06..46e77e7 100644 --- a/src/shared/api/resource-query.ts +++ b/src/shared/api/resource-query.ts @@ -10,7 +10,7 @@ import type { components, paths } from "./generated/schema"; import { useAuthenticatedQuery } from "./authenticated-query"; import { getAuthenticatedSessionSnapshot } from "./client"; -import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details"; +import { apiErrorCode, isBrokerageAccessLoss } from "./problem-details"; type InvestmentResourceMeta = components["schemas"]["InvestmentResourceMetaDto"]; @@ -39,15 +39,7 @@ function invalidateResourceAccess( group: string, error: unknown, ) { - const errorCode = apiErrorCode(error); - if ( - [ - ApiErrorCode.InvalidTbankToken, - ApiErrorCode.NotFound, - ApiErrorCode.TbankNotConnected, - ].every(code => !isApiErrorCode(errorCode, code)) - ) - return; + if (!isBrokerageAccessLoss(apiErrorCode(error))) return; const affected = { predicate: (cached: { meta?: Record }) => -- 2.54.0 From aaee9591d7880193ee9b0c14339e8477264e4a94 Mon Sep 17 00:00:00 2001 From: codex-bot Date: Tue, 3 Nov 2026 07:21:35 +0300 Subject: [PATCH 03/12] chore(api): update OpenAPI snapshot with UpcomingEventsDto.window (WealthLedger/frontend#134) (#135) --- src/shared/api/generated/schema.d.ts | 8 ++++++++ src/shared/api/spec/openapi.json | 26 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index a9a7864..897caf0 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -1232,6 +1232,12 @@ export interface components { data: components["schemas"]["IncomeAnalyticsDto"] | null; meta: components["schemas"]["IncomeAnalyticsMetaDto"]; }; + UpcomingEventsWindowDto: { + /** Format: date */ + from: string; + /** Format: date */ + to: string; + }; UpcomingEventDto: { account?: components["schemas"]["AccountReferenceDto"]; /** @enum {string} */ @@ -1252,6 +1258,8 @@ export interface components { accountId: string; /** Format: date-time */ calculatedAt: string; + /** @description Inclusive UTC window the saved forecast was calculated for. Unchanged by the requested from/to filter. */ + window: components["schemas"]["UpcomingEventsWindowDto"]; events: components["schemas"]["UpcomingEventDto"][]; incomeTotalsByCurrency: components["schemas"]["MoneyDto"][]; }; diff --git a/src/shared/api/spec/openapi.json b/src/shared/api/spec/openapi.json index d3881bd..b5b5e6c 100644 --- a/src/shared/api/spec/openapi.json +++ b/src/shared/api/spec/openapi.json @@ -7243,6 +7243,23 @@ "meta" ] }, + "UpcomingEventsWindowDto": { + "type": "object", + "properties": { + "from": { + "type": "string", + "format": "date" + }, + "to": { + "type": "string", + "format": "date" + } + }, + "required": [ + "from", + "to" + ] + }, "UpcomingEventDto": { "type": "object", "properties": { @@ -7324,6 +7341,14 @@ "type": "string", "format": "date-time" }, + "window": { + "description": "Inclusive UTC window the saved forecast was calculated for. Unchanged by the requested from/to filter.", + "allOf": [ + { + "$ref": "#/components/schemas/UpcomingEventsWindowDto" + } + ] + }, "events": { "type": "array", "items": { @@ -7340,6 +7365,7 @@ "required": [ "accountId", "calculatedAt", + "window", "events", "incomeTotalsByCurrency" ] -- 2.54.0 From 8ce1e8e44486dcc23d4c074559ca2bb517880b60 Mon Sep 17 00:00:00 2001 From: codex-bot Date: Tue, 3 Nov 2026 09:49:08 +0300 Subject: [PATCH 04/12] feat(investments): warn and offer retry after exhausted read retries (WealthLedger/frontend#55) (#136) --- .../instrument-details/model/section.test.ts | 27 ++- .../instrument-details/model/section.ts | 17 +- .../ui/InstrumentSection.view.tsx | 4 + .../AccountEventsPage.retry.test.tsx | 155 ++++++++++++++++++ .../AccountEventsPage.test.tsx | 7 +- .../AccountEventsPage/AccountEventsPage.vm.ts | 16 +- .../AccountEventsPageError.view.tsx | 14 +- .../AccountEventsPageLoaded.view.tsx | 4 +- .../AccountIncomePage.test.tsx | 108 ++++++++++-- .../AccountIncomePage/AccountIncomePage.vm.ts | 10 +- .../AccountIncomePageError.view.tsx | 14 +- .../AccountIncomePageLoaded.view.tsx | 4 +- .../AccountOperationsPage.test.tsx | 115 +++++++++++-- .../AccountOperationsPage.vm.ts | 21 ++- .../AccountOperationsPageError.view.tsx | 19 ++- .../AccountOperationsPageLoaded.view.tsx | 4 +- .../ui/AccountPage/AccountPage.test.tsx | 108 ++++++++++++ .../account/ui/AccountPage/AccountPage.tsx | 2 + .../account/ui/AccountPage/AccountPage.vm.ts | 20 ++- .../components/AccountPageEmpty.view.tsx | 5 +- .../components/AccountPageError.view.tsx | 16 +- .../components/AccountPageLoaded.view.tsx | 5 +- .../ui/AccountsPage/AccountsPage.test.tsx | 97 ++++++++++- .../accounts/ui/AccountsPage/AccountsPage.tsx | 1 + .../ui/AccountsPage/AccountsPage.vm.ts | 33 +++- .../components/AccountsPageError.view.tsx | 16 +- .../components/AccountsPageLoaded.view.tsx | 5 +- src/pages/bond/ui/BondPage/BondPage.test.tsx | 17 +- .../stock/ui/StockPage/StockPage.test.tsx | 37 ++++- src/shared/api/index.ts | 1 + src/shared/api/read-failure.test.ts | 80 +++++++++ src/shared/api/read-failure.ts | 37 +++++ src/shared/i18n/locales/ru.ts | 2 + src/shared/ui/ReadRetry/ReadRetry.test.tsx | 37 +++++ src/shared/ui/ReadRetry/ReadRetry.tsx | 42 +++++ src/shared/ui/index.ts | 5 + 36 files changed, 1025 insertions(+), 80 deletions(-) create mode 100644 src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.retry.test.tsx create mode 100644 src/shared/api/read-failure.test.ts create mode 100644 src/shared/api/read-failure.ts create mode 100644 src/shared/ui/ReadRetry/ReadRetry.test.tsx create mode 100644 src/shared/ui/ReadRetry/ReadRetry.tsx 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/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..db2644f --- /dev/null +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.retry.test.tsx @@ -0,0 +1,155 @@ +import { MantineProvider } from "@mantine/core"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { MemoryRouter, Route, Routes } from "react-router"; +import { describe, expect, it, vi } from "vitest"; + +import { configureAuthClient } from "@/shared/api"; +import { createTestQueryClient } from "@/test/query"; +import { resource } from "@/test/resource"; +import { server } from "@/test/server"; + +import { AccountEventsPage } from "./AccountEventsPage"; + +// Automatic read retries wait 1s + 2s before the failure is final. +const RETRIES_EXHAUSTED_MS = 6000; +const TEST_MS = 10_000; + +const snapshot = resource({ + events: [ + { + date: "2026-10-01", + dateKind: "event", + displayName: "Synthetic coupon", + figi: null, + instrumentUid: null, + isin: null, + logoName: null, + projectedAmount: { currency: "RUB", value: "123.45" }, + ticker: "SYN", + type: "coupon", + }, + ], + incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }], +}); + +function renderEvents() { + configureAuthClient({ + accessToken: () => "token", + clear: vi.fn(), + refresh: () => Promise.resolve(true), + }); + const queryClient = createTestQueryClient(); + + render( + + + + + } + 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; + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/events", () => { + requests += 1; + + return isFailing + ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) + : HttpResponse.json(snapshot); + }), + ); + const { queryClient } = renderEvents(); + await screen.findByText("Купон · Synthetic coupon"); + expect(screen.queryByRole("button", { name: "Повторить" })) + .not.toBeInTheDocument(); + + isFailing = true; + void queryClient.invalidateQueries({ + queryKey: ["get", "/api/v1/brokerage-accounts/{accountId}/events"], + }); + + const retry = await screen.findByRole( + "button", + { name: "Повторить" }, + { timeout: RETRIES_EXHAUSTED_MS }, + ); + + expect(screen.getByText("Не удалось обновить данные")).toBeInTheDocument(); + expect(screen.getByText("Купон · Synthetic coupon")).toBeInTheDocument(); + expect(requests).toBe(4); + + isFailing = false; + fireEvent.click(retry); + + await waitFor(() => + expect(screen.queryByText("Не удалось обновить данные")) + .not.toBeInTheDocument()); + expect(requests).toBe(5); + }, + ); + + it("does not offer a plain retry when the account is gone", async () => { + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/events", () => + HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 })), + ); + renderEvents(); + + await waitFor(() => + expect(screen.queryByRole("heading", { name: "Загрузка" })) + .not.toBeInTheDocument()); + expect(screen.queryByRole("button", { name: "Повторить" })) + .not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx index 15994eb..7a3db60 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx @@ -169,15 +169,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 +190,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", () => { diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts index 92ef995..3190415 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, @@ -28,11 +27,16 @@ type AccountEventsPageViewModel onPeriodPopoverOpen: () => 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" }; function useAccountEventsPageViewModel(): AccountEventsPageViewModel { @@ -83,6 +87,8 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel { isFetching: events.isFetching, }); + const retry = readRetry([events]); + if (availability === "loading" && events.isLoading) return { state: "loading" }; @@ -94,11 +100,12 @@ 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 { @@ -122,6 +129,7 @@ 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", 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..514d839 100644 --- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx @@ -18,7 +18,7 @@ import { brokerageEventKey, } from "@/entities/brokerage-event"; import { groupItems } from "@/shared/lib"; -import { SourceSyncStatus } from "@/shared/ui"; +import { ReadRetry, SourceSyncStatus } from "@/shared/ui"; import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm"; @@ -83,6 +83,8 @@ const AccountEventsPageLoaded: FC = (props) => { stale={props.source?.freshness === "stale"} /> + + {!!props.partial && {t("investment.partial")}} {t("events.hint")} diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx index bcd6ce8..2e6def7 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx @@ -38,6 +38,10 @@ import { } from "./AccountIncomePage.vm"; import { IncomePayments } from "./components/IncomePayments.view"; +// Automatic read retries wait 1s + 2s before the failure is final. +const RETRIES_EXHAUSTED_MS = 6000; +const TEST_MS = 10_000; + const analytics = { accountId: "account-1", @@ -433,21 +437,95 @@ describe("AccountIncomePage", () => { expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); }); - it("shows a quiet realized error", async () => { - server.use( - http.get( - "*/api/v1/brokerage-accounts/account-1/income-analytics", - () => new HttpResponse(null, { status: 503 }), - ), - ); - renderPage(); - expect( - await screen.findByText("Не удалось рассчитать пассивный доход", undefined, { - timeout: 4000, - }), - ).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.vm.ts b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts index f22540d..069c915 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts @@ -3,10 +3,9 @@ import { useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { useParams, useSearchParams } from "react-router"; -import type { InvestmentResourceMeta } from "@/shared/api"; - 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 { @@ -32,12 +31,14 @@ 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" }; @@ -87,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); @@ -119,6 +122,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => { onReset, onSubmit, partial: analytics.meta?.completeness === "incomplete", + readRetry: retry, source: analytics.meta, state: "loaded", totalVisible: analytics.meta?.completeness === "complete", 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) => { stale={props.source?.freshness === "stale"} /> + + {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.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/ui/AccountPage/AccountPage.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.test.tsx index 37cbacf..9ab30b9 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.test.tsx @@ -60,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(); @@ -1255,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) => { diff --git a/src/pages/account/ui/AccountPage/AccountPage.tsx b/src/pages/account/ui/AccountPage/AccountPage.tsx index f9809bc..db138bc 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.tsx @@ -16,6 +16,7 @@ const AccountPage: FC = () => { account={viewModel.account} accountId={viewModel.accountId} incomeSummary={viewModel.incomeSummary} + readRetry={viewModel.readRetry} recentEvents={viewModel.recentEvents} recentOperations={viewModel.recentOperations} source={viewModel.source} @@ -35,6 +36,7 @@ const AccountPage: FC = () => { partial={viewModel.partial} portfolio={viewModel.portfolio} positionSections={viewModel.positionSections} + readRetry={viewModel.readRetry} recentEvents={viewModel.recentEvents} recentOperations={viewModel.recentOperations} slice={viewModel.slice} diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.ts b/src/pages/account/ui/AccountPage/AccountPage.vm.ts index 622071a..77814d0 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, isBrokerageAccessLoss } from "@/shared/api"; +import { apiErrorCode, isBrokerageAccessLoss, readRetry } from "@/shared/api"; import { useInitialAvailability } from "@/shared/lib"; import { @@ -45,6 +45,7 @@ type AccountPageContent = { accountId?: string; incomeSummary: AccountPageIncomeSummary; partial?: boolean; + readRetry: AccountPageReadRetry; recentEvents: AccountPageRecentEvents; recentOperations: AccountPageRecentOperations; source?: InvestmentResourceMeta; @@ -68,10 +69,12 @@ type AccountPageLoadedViewModel = AccountPageContent & { unrealizedReturns: Portfolio["totalsByCurrency"]; }; +type AccountPageReadRetry = ReturnType; + type AccountPageViewModel = | AccountPageEmptyViewModel | AccountPageLoadedViewModel - | { code?: string; state: "error" } + | { code?: string; readRetry: AccountPageReadRetry; state: "error" } | { state: "loading" }; function useAccountPageViewModel(): AccountPageViewModel { @@ -113,6 +116,8 @@ function useAccountPageViewModel(): AccountPageViewModel { ]); const code = apiErrorCode(portfolio.error); + // The portfolio drives this screen; the retry repeats only its GET. + const retry = readRetry([portfolio]); const isAccessFailure = isBrokerageAccessLoss(code); const availability = useInitialAvailability({ @@ -122,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); @@ -144,6 +150,7 @@ function useAccountPageViewModel(): AccountPageViewModel { account, accountId, incomeSummary, + readRetry: retry, recentEvents, recentOperations, source: portfolio.meta, @@ -181,6 +188,7 @@ function useAccountPageViewModel(): AccountPageViewModel { partial: isPartial, portfolio: portfolio.data, positionSections: toAccountPagePositionSections(portfolio.data), + readRetry: retry, recentEvents, recentOperations, slice: current.slice, diff --git a/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx index 510c88c..8fe13e3 100644 --- a/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx @@ -8,7 +8,7 @@ import type { BrokerageAccount } from "@/entities/brokerage-account"; import type { InvestmentResourceMeta } from "@/shared/api"; import { BrokerageEventsPreview } from "@/entities/brokerage-event"; -import { SourceSyncStatus } from "@/shared/ui"; +import { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui"; import { PortfolioStructure } from "@/widgets/portfolio-structure"; import type { @@ -25,6 +25,7 @@ type AccountPageEmptyProps = { account?: BrokerageAccount; accountId?: string; incomeSummary?: AccountPageIncomeSummary; + readRetry?: ReadRetryProps; recentEvents: AccountPageRecentEvents; recentOperations: AccountPageRecentOperations; source?: InvestmentResourceMeta; @@ -49,6 +50,8 @@ const AccountPageEmpty: FC = (props) => { stale={props.source?.freshness === "stale"} /> + {!!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..86bbe73 100644 --- a/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx @@ -15,7 +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 { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui"; import { PortfolioStructure } from "@/widgets/portfolio-structure"; import type { Concentration as ConcentrationModel, Slice } from "../../../model/concentration"; @@ -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/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..e141610 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx @@ -16,6 +16,7 @@ const AccountsPage: FC = () => { ); }; diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts b/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts index 4f3252c..e9c1fef 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts @@ -4,14 +4,26 @@ 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) @@ -23,6 +35,7 @@ function useAccountsPageViewModel(): AccountsPageViewModel { 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,6 +68,7 @@ function useAccountsPageViewModel(): AccountsPageViewModel { return { accounts: brokerageAccountCards(accounts.data, meta.dependencies), meta, + readRetry: retry, state: "loaded" as const, }; } 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..598d3ed 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -5,4 +5,9 @@ export { EntryGroupTotal, } from "./EntryGroup/EntryGroup"; export { PayoutCard } from "./PayoutCard/PayoutCard"; +export { + ReadRetry, + ReadRetryButton, + type ReadRetryProps, +} from "./ReadRetry/ReadRetry"; export { SourceSyncStatus } from "./SourceSyncStatus/SourceSyncStatus"; -- 2.54.0 From ad93725a84615ec329631b71ed8db33337085740 Mon Sep 17 00:00:00 2001 From: codex-bot Date: Tue, 3 Nov 2026 14:07:40 +0300 Subject: [PATCH 05/12] chore(deps): set Renovate release age, PR limit and update groups (WealthLedger/main#6) (#137) --- renovate.json | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) 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 -- 2.54.0 From e2d143df46e650139e49b7c80a886008ede86d2a Mon Sep 17 00:00:00 2001 From: codex-bot Date: Wed, 4 Nov 2026 18:01:00 +0300 Subject: [PATCH 06/12] refactor: move portfolio structure into account page, drop widgets (WealthLedger/frontend#139) (#140) --- .ai/architecture.md | 13 ++++++------- dependency-cruiser.cjs | 16 +++++----------- .../components/AccountPageEmpty.view.tsx | 2 +- .../components/AccountPageLoaded.view.tsx | 2 +- .../PortfolioStructure.module.css | 0 .../PortfolioStructure.stories.tsx | 2 +- .../PortfolioStructure.test.tsx | 0 .../PortfolioStructure}/PortfolioStructure.tsx | 0 .../PortfolioStructure.view.tsx | 0 .../PortfolioStructure}/PortfolioStructure.vm.ts | 0 src/widgets/portfolio-structure/index.ts | 1 - 11 files changed, 14 insertions(+), 22 deletions(-) rename src/{widgets/portfolio-structure/ui => pages/account/ui/AccountPage/components/PortfolioStructure}/PortfolioStructure.module.css (100%) rename src/{widgets/portfolio-structure/ui => pages/account/ui/AccountPage/components/PortfolioStructure}/PortfolioStructure.stories.tsx (98%) rename src/{widgets/portfolio-structure/ui => pages/account/ui/AccountPage/components/PortfolioStructure}/PortfolioStructure.test.tsx (100%) rename src/{widgets/portfolio-structure/ui => pages/account/ui/AccountPage/components/PortfolioStructure}/PortfolioStructure.tsx (100%) rename src/{widgets/portfolio-structure/ui => pages/account/ui/AccountPage/components/PortfolioStructure}/PortfolioStructure.view.tsx (100%) rename src/{widgets/portfolio-structure/ui => pages/account/ui/AccountPage/components/PortfolioStructure}/PortfolioStructure.vm.ts (100%) delete mode 100644 src/widgets/portfolio-structure/index.ts 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/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/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx index 8fe13e3..41a688c 100644 --- a/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx @@ -9,7 +9,6 @@ import type { InvestmentResourceMeta } from "@/shared/api"; import { BrokerageEventsPreview } from "@/entities/brokerage-event"; import { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui"; -import { PortfolioStructure } from "@/widgets/portfolio-structure"; import type { AccountPageIncomeSummary, @@ -19,6 +18,7 @@ import type { import { AccountHeading } from "./AccountHeading.view"; import { IncomeSummary } from "./IncomeSummary.view"; +import { PortfolioStructure } from "./PortfolioStructure/PortfolioStructure"; import { RecentOperations } from "./RecentOperations.view"; type AccountPageEmptyProps = { diff --git a/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx index 86bbe73..3611ae5 100644 --- a/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx @@ -16,7 +16,6 @@ import type { InvestmentResourceMeta } from "@/shared/api"; import { BrokerageEventsPreview } from "@/entities/brokerage-event"; import { formatMoney } from "@/shared/lib"; import { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui"; -import { PortfolioStructure } from "@/widgets/portfolio-structure"; import type { Concentration as ConcentrationModel, Slice } from "../../../model/concentration"; import type { AccountPagePositionSection } from "../../../model/positions"; @@ -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"; 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/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"; -- 2.54.0 From 5077bd4f0baa4a59643e875485c2f08253cc198d Mon Sep 17 00:00:00 2001 From: codex-bot Date: Wed, 4 Nov 2026 18:50:27 +0300 Subject: [PATCH 07/12] refactor: drop dead query helpers and simplify read deadline (WealthLedger/frontend#141) (#149) --- src/shared/api/authenticated-query.test.tsx | 92 +++++++++------------ src/shared/api/authenticated-query.ts | 22 +---- src/shared/api/client.ts | 55 ++++++------ src/shared/api/resource-query.test.tsx | 73 ---------------- src/shared/api/resource-query.ts | 59 +------------ 5 files changed, 72 insertions(+), 229 deletions(-) diff --git a/src/shared/api/authenticated-query.test.tsx b/src/shared/api/authenticated-query.test.tsx index 4699a91..b72b1fa 100644 --- a/src/shared/api/authenticated-query.test.tsx +++ b/src/shared/api/authenticated-query.test.tsx @@ -6,10 +6,7 @@ import { createQueryWrapper } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; -import { - useAuthenticatedQueries, - useAuthenticatedQuery, -} from "./authenticated-query"; +import { useAuthenticatedQuery } from "./authenticated-query"; import { apiQueryClient, configureAuthClient } from "./client"; const useSingleQuery = () => @@ -17,58 +14,49 @@ const useSingleQuery = () => apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"), ); -const useMultipleQueries = () => - useAuthenticatedQueries([ - apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"), - ])[0]; - describe("useAuthenticatedQuery", () => { - it.each(["single", "multiple"])( - "keeps %s protected queries idle without a session and loads after session configuration", - async (mode) => { - const requests = vi.fn(); - server.use( - http.get("*/api/v1/brokerage-accounts", () => { - requests(); + it("keeps protected queries idle without a session and loads after session configuration", async () => { + const requests = vi.fn(); + server.use( + http.get("*/api/v1/brokerage-accounts", () => { + requests(); - return HttpResponse.json( - resource([ - { - id: "account-1", - name: "Основной", - openedOn: "2024-08-13", - type: "BROKERAGE", - }, - ]), - ); - }), - ); + return HttpResponse.json( + resource([ + { + id: "account-1", + name: "Основной", + openedOn: "2024-08-13", + type: "BROKERAGE", + }, + ]), + ); + }), + ); - const { result } = renderHook( - mode === "single" ? useSingleQuery : useMultipleQueries, - { wrapper: createQueryWrapper().wrapper }, - ); + const { result } = renderHook(useSingleQuery, { + wrapper: createQueryWrapper().wrapper, + }); - await new Promise(resolve => setTimeout(resolve, 0)); - expect(requests).not.toHaveBeenCalled(); - expect(result.current?.fetchStatus).toBe("idle"); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(requests).not.toHaveBeenCalled(); + expect(result.current?.fetchStatus).toBe("idle"); - configureAuthClient({ - accessToken: () => "token", - clear: vi.fn(), - refresh: () => Promise.resolve(true), - }); + configureAuthClient({ + accessToken: () => "token", + clear: vi.fn(), + refresh: () => Promise.resolve(true), + }); - await waitFor(() => expect(result.current?.isSuccess).toBe(true)); - expect(requests).toHaveBeenCalledOnce(); - expect(result.current?.data?.data).toEqual([ - { - id: "account-1", - name: "Основной", - openedOn: "2024-08-13", - type: "BROKERAGE", - }, - ]); - }, - ); + await waitFor(() => expect(result.current?.isSuccess).toBe(true)); + expect(requests).toHaveBeenCalledOnce(); + expect(result.current?.data?.data).toEqual([ + { + id: "account-1", + name: "Основной", + openedOn: "2024-08-13", + type: "BROKERAGE", + }, + ]); + }); }); diff --git a/src/shared/api/authenticated-query.ts b/src/shared/api/authenticated-query.ts index bbe0a09..06be1e8 100644 --- a/src/shared/api/authenticated-query.ts +++ b/src/shared/api/authenticated-query.ts @@ -1,6 +1,5 @@ import { type QueryKey, - useQueries, useQuery, type UseQueryOptions, type UseQueryResult, @@ -12,25 +11,6 @@ import { subscribeAuthenticatedSession, } from "./client"; -function useAuthenticatedQueries( - queries: UseQueryOptions[], -) { - const hasSession = useHasAuthenticatedSession(); - - return useQueries({ - queries: queries.map( - (options): UseQueryOptions => ({ - ...options, - enabled: query => - hasSession - && (typeof options.enabled === "function" - ? options.enabled(query) - : (options.enabled ?? true)), - }), - ), - }); -} - function useAuthenticatedQuery< TQueryFunctionData = unknown, TError = Error, @@ -58,4 +38,4 @@ function useHasAuthenticatedSession() { ); } -export { useAuthenticatedQueries, useAuthenticatedQuery }; +export { useAuthenticatedQuery }; diff --git a/src/shared/api/client.ts b/src/shared/api/client.ts index c95f2b3..e04a4a8 100644 --- a/src/shared/api/client.ts +++ b/src/shared/api/client.ts @@ -208,51 +208,50 @@ function parseJson(body: string): unknown { } async function readAttempt(request: Request): Promise { - const controller = new AbortController(); - const failure: { reason?: "abort" | "timeout" } = {}; - - const { promise: deadline, reject: rejectDeadline } - = Promise.withResolvers(); - - const timeout = setTimeout(() => { - failure.reason = "timeout"; - controller.abort(); - rejectDeadline(abortError()); - }, GET_DEADLINE_MS); - - const abort = () => { - failure.reason = "abort"; - controller.abort(); - rejectDeadline(abortError()); - }; - - request.signal.addEventListener("abort", abort, { once: true }); - const timedRequest = new Request(request, { signal: controller.signal }); + // Manual timeout (not AbortSignal.timeout) so fake-timer tests can advance it. + const timeout = new AbortController(); + const timer = setTimeout(() => timeout.abort(), GET_DEADLINE_MS); + const signal = AbortSignal.any([request.signal, timeout.signal]); try { - const response = await Promise.race([fetch(timedRequest), deadline]); - const body = await Promise.race([response.text(), deadline]); - const problem = parseJson(body); + // MSW keeps a stalled body readable after abort, so keep the race. + const response = await Promise.race([ + fetch(new Request(request, { signal })), + rejectOnAbort(signal), + ]); + + const body = await Promise.race([response.text(), rejectOnAbort(signal)]); return { - problem, + problem: parseJson(body), reason: "transient", response: new Response(body, response), }; } catch (error) { - if (failure.reason === "abort") return { reason: "abort" }; - if (failure.reason === "timeout" || isNetworkFailure(error)) + if (request.signal.aborted) return { reason: "abort" }; + // Timeout or network failure: retryable. + if (timeout.signal.aborted || isNetworkFailure(error)) return { reason: "transient" }; throw error; } finally { - clearTimeout(timeout); - request.signal.removeEventListener("abort", abort); + clearTimeout(timer); } } +function rejectOnAbort(signal: AbortSignal): Promise { + return new Promise((_resolve, reject) => { + // The signal is either already aborted or one of two parents firing once. + signal.addEventListener( + "abort", + () => reject(abortError()), + { once: true }, + ); + }); +} + function retryAfterMs(response: Response): number | undefined { const header = response.headers.get("Retry-After"); if (!header) return undefined; diff --git a/src/shared/api/resource-query.test.tsx b/src/shared/api/resource-query.test.tsx index a6ddf93..fa2743b 100644 --- a/src/shared/api/resource-query.test.tsx +++ b/src/shared/api/resource-query.test.tsx @@ -37,38 +37,6 @@ function auth() { } describe("persistent resource query lifecycle", () => { - it("observes once into the canonical cache without another import", async () => { - auth(); - const modes: (null | string)[] = []; - let observations = 0; - server.use( - http.get( - "*/api/v1/brokerage-accounts/:accountId/portfolio", - ({ request }) => { - const mode = new URL(request.url).searchParams.get("sync"); - modes.push(mode); - if (mode) observations += 1; - - return HttpResponse.json( - resource(snapshot, { - dataRevision: observations > 0 ? "new" : "old", - }), - ); - }, - ), - ); - const { queryClient, wrapper } = createQueryWrapper(); - const { result } = renderHook(() => usePrivateResource("a"), { wrapper }); - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - await act(() => result.current.observe()); - await waitFor(() => expect(result.current.meta?.dataRevision).toBe("new")); - - expect(modes).toEqual([null, "observe"]); - expect(queryClient.getQueryCache().getAll()).toHaveLength(1); - expect( - queryClient.getQueryData(resourceOptions("a").queryKey), - ).toMatchObject({ data: snapshot, meta: { dataRevision: "new" } }); - }); it("keeps successful HTTP state distinct from missing or failed source data", async () => { auth(); server.use( @@ -136,47 +104,6 @@ describe("persistent resource query lifecycle", () => { expect(result.current.isSuccess).toBe(true); expect(result.current.isPending).toBe(false); }); - it("keeps the observed snapshot when an older query finishes later", async () => { - auth(); - - const { promise: blocked, resolve: release } - = Promise.withResolvers(); - - let isStarted = false; - server.use( - http.get( - "*/api/v1/brokerage-accounts/:accountId/portfolio", - async ({ request }) => { - if (!new URL(request.url).searchParams.has("sync")) { - isStarted = true; - await blocked; - - return HttpResponse.json(resource({ ...snapshot, isStale: true })); - } - - return HttpResponse.json(resource(snapshot)); - }, - ), - ); - - const { result } = renderHook(() => usePrivateResource("a"), { - wrapper: createQueryWrapper().wrapper, - }); - - await waitFor(() => expect(isStarted).toBe(true)); - // Capture the observation promise before releasing the old handler; the - // final assertion must follow its settlement, not the handler release. - let observation: Promise = Promise.resolve(); - await act(async () => { - observation = result.current.observe() ?? Promise.resolve(); - await observation; - }); - await act(async () => { - release(); - await observation; - }); - expect(result.current.data).toEqual(snapshot); - }); it("clears private snapshots on confirmed access loss and fences another pending success", async () => { auth(); let isDenied = false; diff --git a/src/shared/api/resource-query.ts b/src/shared/api/resource-query.ts index 46e77e7..d71833e 100644 --- a/src/shared/api/resource-query.ts +++ b/src/shared/api/resource-query.ts @@ -1,6 +1,5 @@ import { type QueryClient, - type QueryFunctionContext, useQueryClient, type UseQueryOptions, } from "@tanstack/react-query"; @@ -9,7 +8,6 @@ import { useEffect } from "react"; import type { components, paths } from "./generated/schema"; import { useAuthenticatedQuery } from "./authenticated-query"; -import { getAuthenticatedSessionSnapshot } from "./client"; import { apiErrorCode, isBrokerageAccessLoss } from "./problem-details"; type InvestmentResourceMeta = components["schemas"]["InvestmentResourceMetaDto"]; @@ -31,7 +29,7 @@ type ResourcePath = { type ResourceQueryKey = readonly [ "get", ResourcePath, - { params?: { path?: object; query?: { sync?: "observe" } } }?, + { params?: { path?: object; query?: object } }?, ]; function invalidateResourceAccess( @@ -57,22 +55,6 @@ function invalidateResourceAccess( }); } -// Keep endpoint and parameters intact. The generated queryFn accepts the -// optional sync mode even for a cache key originally created without init. -function observationKey(key: K): K { - const [method, path, init] = key as unknown as [ - string, - string, - { params?: { path?: object; query?: object } }?, - ]; - - return [method, path, { - ...init, - params: { ...init?.params, query: { ...init?.params?.query, sync: "observe" } }, - }] as unknown as K; -} - -// Observation changes transport mode, never the generated cache identity. function useResourceQuery< T extends Resource, E = unknown, @@ -92,50 +74,17 @@ function useResourceQuery< if (group) invalidateResourceAccess(client, group, query.error); }, [client, group, query.error]); - const observe = async () => { - const queryFunction = options.queryFn; - if ( - typeof queryFunction !== "function" - || !query.isEnabled - || !getAuthenticatedSessionSnapshot() - ) - return; - // Cancellation is synchronous; start the replacement in the same boundary. - void client.cancelQueries({ exact: true, queryKey: options.queryKey }); - - return client.query({ - ...options, - queryFn: async (context: QueryFunctionContext) => { - const queryKey = observationKey(context.queryKey); - - return queryFunction({ ...context, queryKey }); - }, - retry: false, - // Keep the cache envelope independent of observer projection. - select: undefined, - staleTime: 0, - }); - }; - const data: T["data"] | undefined = query.data?.data; const meta: T["meta"] | undefined = query.data?.meta; // Project resource fields; refetch still returns the transport envelope. + // queryKey is kept for screen-sync cache bookkeeping. return { + // eslint-disable-next-line @tanstack/query/no-rest-destructuring -- Single projection point; consumers already depend on the whole query state. + ...query, data: data ?? undefined, - error: query.error, - fetchStatus: query.fetchStatus, - isEnabled: query.isEnabled, - isError: query.isError, - isFetching: query.isFetching, - isLoading: query.isLoading, - isPending: query.isPending, - isSuccess: query.isSuccess, meta, - observe, queryKey: options.queryKey, - refetch: query.refetch, - status: query.status, sync: query.data && "sync" in query.data ? query.data.sync : undefined, }; } -- 2.54.0 From 2d070ce2b54bbee466ecf6cc18b437910f0c07f8 Mon Sep 17 00:00:00 2001 From: codex-bot Date: Wed, 4 Nov 2026 20:47:51 +0300 Subject: [PATCH 08/12] refactor(api): consume bank operation oneOf contract (WealthLedger/frontend#144) (#152) --- .../card/ui/OperationForm/OperationForm.vm.ts | 49 +++- src/shared/api/generated/schema.d.ts | 96 ++++-- src/shared/api/spec/openapi.json | 276 +++++++++++++----- 3 files changed, 321 insertions(+), 100 deletions(-) diff --git a/src/pages/card/ui/OperationForm/OperationForm.vm.ts b/src/pages/card/ui/OperationForm/OperationForm.vm.ts index bae39b7..e91139d 100644 --- a/src/pages/card/ui/OperationForm/OperationForm.vm.ts +++ b/src/pages/card/ui/OperationForm/OperationForm.vm.ts @@ -120,11 +120,52 @@ const useOperationFormVm = (props: OperationFormProps) => { body: { ...body, version }, id: props.operation.id, }); - else - await operationCreation.mutateAsync({ + else { + const base = { + amount: body.amount, + cardId: values.cardId, + date: values.date, + description: values.description, id: intent, - ...body, - }); + }; + + switch (values.kind) { + case "expense": { + await operationCreation.mutateAsync({ + ...base, + categoryId: values.categoryId || defaultCategoryId, + kind: values.kind, + }); + break; + } + + case "refund": { + await operationCreation.mutateAsync({ + ...base, + kind: values.kind, + originalExpenseId: values.originalExpenseId, + }); + break; + } + + case "topup": { + await operationCreation.mutateAsync({ + ...base, + kind: values.kind, + }); + break; + } + + case "transfer": { + await operationCreation.mutateAsync({ + ...base, + kind: values.kind, + targetCardId: values.targetCardId, + }); + break; + } + } + } notifications.show({ id: "bank-operation-saved-" + actionId, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 897caf0..05ca2e8 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -775,6 +775,77 @@ export interface components { */ password: string; }; + RubMoneyInputDto: { + /** @enum {string} */ + currency: "RUB"; + value: string; + }; + CreateExpenseOperationDto: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + cardId: string; + amount: components["schemas"]["RubMoneyInputDto"]; + /** Format: date */ + date: string; + description?: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + kind: "expense"; + /** Format: uuid */ + categoryId?: string; + }; + CreateTopupOperationDto: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + cardId: string; + amount: components["schemas"]["RubMoneyInputDto"]; + /** Format: date */ + date: string; + description?: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + kind: "topup"; + }; + CreateTransferOperationDto: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + cardId: string; + amount: components["schemas"]["RubMoneyInputDto"]; + /** Format: date */ + date: string; + description?: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + kind: "transfer"; + /** Format: uuid */ + targetCardId: string; + }; + CreateRefundOperationDto: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + cardId: string; + amount: components["schemas"]["RubMoneyInputDto"]; + /** Format: date */ + date: string; + description?: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + kind: "refund"; + /** Format: uuid */ + originalExpenseId: string; + }; MoneyDto: { currency: string; value: string; @@ -792,11 +863,6 @@ export interface components { archivedAt: string | null; version: number; }; - RubMoneyInputDto: { - /** @enum {string} */ - currency: "RUB"; - value: string; - }; CreateBankCardDto: { /** Format: uuid */ id: string; @@ -841,24 +907,6 @@ export interface components { net: components["schemas"]["MoneyDto"]; categories: components["schemas"]["BankSummaryCategoryDto"][]; }; - CreateBankOperationDto: { - /** Format: uuid */ - id: string; - /** @enum {string} */ - kind: "expense" | "topup" | "transfer" | "refund"; - /** Format: uuid */ - cardId: string; - /** Format: uuid */ - targetCardId?: string; - /** Format: uuid */ - categoryId?: string; - /** Format: uuid */ - originalExpenseId?: string; - amount: components["schemas"]["RubMoneyInputDto"]; - /** Format: date */ - date: string; - description?: string; - }; BankOperationDto: { id: string; /** @enum {string} */ @@ -2598,7 +2646,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["CreateBankOperationDto"]; + "application/json": components["schemas"]["CreateExpenseOperationDto"] | components["schemas"]["CreateTopupOperationDto"] | components["schemas"]["CreateTransferOperationDto"] | components["schemas"]["CreateRefundOperationDto"]; }; }; responses: { diff --git a/src/shared/api/spec/openapi.json b/src/shared/api/spec/openapi.json index b5b5e6c..128b737 100644 --- a/src/shared/api/spec/openapi.json +++ b/src/shared/api/spec/openapi.json @@ -1053,7 +1053,29 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateBankOperationDto" + "oneOf": [ + { + "$ref": "#/components/schemas/CreateExpenseOperationDto" + }, + { + "$ref": "#/components/schemas/CreateTopupOperationDto" + }, + { + "$ref": "#/components/schemas/CreateTransferOperationDto" + }, + { + "$ref": "#/components/schemas/CreateRefundOperationDto" + } + ], + "discriminator": { + "propertyName": "kind", + "mapping": { + "expense": "#/components/schemas/CreateExpenseOperationDto", + "topup": "#/components/schemas/CreateTopupOperationDto", + "transfer": "#/components/schemas/CreateTransferOperationDto", + "refund": "#/components/schemas/CreateRefundOperationDto" + } + } } } } @@ -5340,6 +5362,187 @@ "password" ] }, + "RubMoneyInputDto": { + "type": "object", + "properties": { + "currency": { + "type": "string", + "enum": [ + "RUB" + ] + }, + "value": { + "type": "string", + "pattern": "^-?[0-9]+(\\.[0-9]{1,2})?$" + } + }, + "required": [ + "currency", + "value" + ] + }, + "CreateExpenseOperationDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "cardId": { + "type": "string", + "format": "uuid" + }, + "amount": { + "$ref": "#/components/schemas/RubMoneyInputDto" + }, + "date": { + "type": "string", + "format": "date" + }, + "description": { + "type": "string", + "maxLength": 500 + }, + "kind": { + "type": "string", + "enum": [ + "expense" + ] + }, + "categoryId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "id", + "cardId", + "amount", + "date", + "kind" + ] + }, + "CreateTopupOperationDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "cardId": { + "type": "string", + "format": "uuid" + }, + "amount": { + "$ref": "#/components/schemas/RubMoneyInputDto" + }, + "date": { + "type": "string", + "format": "date" + }, + "description": { + "type": "string", + "maxLength": 500 + }, + "kind": { + "type": "string", + "enum": [ + "topup" + ] + } + }, + "required": [ + "id", + "cardId", + "amount", + "date", + "kind" + ] + }, + "CreateTransferOperationDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "cardId": { + "type": "string", + "format": "uuid" + }, + "amount": { + "$ref": "#/components/schemas/RubMoneyInputDto" + }, + "date": { + "type": "string", + "format": "date" + }, + "description": { + "type": "string", + "maxLength": 500 + }, + "kind": { + "type": "string", + "enum": [ + "transfer" + ] + }, + "targetCardId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "id", + "cardId", + "amount", + "date", + "kind", + "targetCardId" + ] + }, + "CreateRefundOperationDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "cardId": { + "type": "string", + "format": "uuid" + }, + "amount": { + "$ref": "#/components/schemas/RubMoneyInputDto" + }, + "date": { + "type": "string", + "format": "date" + }, + "description": { + "type": "string", + "maxLength": 500 + }, + "kind": { + "type": "string", + "enum": [ + "refund" + ] + }, + "originalExpenseId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "id", + "cardId", + "amount", + "date", + "kind", + "originalExpenseId" + ] + }, "MoneyDto": { "type": "object", "properties": { @@ -5411,25 +5614,6 @@ "version" ] }, - "RubMoneyInputDto": { - "type": "object", - "properties": { - "currency": { - "type": "string", - "enum": [ - "RUB" - ] - }, - "value": { - "type": "string", - "pattern": "^-?[0-9]+(\\.[0-9]{1,2})?$" - } - }, - "required": [ - "currency", - "value" - ] - }, "CreateBankCardDto": { "type": "object", "properties": { @@ -5599,58 +5783,6 @@ "categories" ] }, - "CreateBankOperationDto": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "kind": { - "type": "string", - "enum": [ - "expense", - "topup", - "transfer", - "refund" - ] - }, - "cardId": { - "type": "string", - "format": "uuid" - }, - "targetCardId": { - "type": "string", - "format": "uuid" - }, - "categoryId": { - "type": "string", - "format": "uuid" - }, - "originalExpenseId": { - "type": "string", - "format": "uuid" - }, - "amount": { - "$ref": "#/components/schemas/RubMoneyInputDto" - }, - "date": { - "type": "string", - "format": "date" - }, - "description": { - "type": "string", - "maxLength": 500 - } - }, - "required": [ - "id", - "kind", - "cardId", - "amount", - "date" - ] - }, "BankOperationDto": { "type": "object", "properties": { -- 2.54.0 From 2db39f25d9478350ad56a1b2e2ca6496068257c5 Mon Sep 17 00:00:00 2001 From: codex-bot Date: Wed, 4 Nov 2026 21:14:29 +0300 Subject: [PATCH 09/12] refactor: normalize view models and share breadcrumbs and titles (WealthLedger/frontend#143) (#153) --- eslint-rules.mjs | 7 ++- .../routes/DocumentTitle/DocumentTitle.tsx | 5 +- .../RouteErrorBoundary/RouteErrorBoundary.tsx | 4 +- src/app/ui/AppBreadcrumbs/AppBreadcrumbs.tsx | 24 +++++--- src/app/ui/AppBreadcrumbs/BondBreadcrumbs.tsx | 25 -------- src/app/ui/AppBreadcrumbs/BreadcrumbTrail.tsx | 2 +- src/app/ui/AppBreadcrumbs/CardBreadcrumbs.tsx | 25 -------- .../ui/AppBreadcrumbs/EntityBreadcrumbs.tsx | 48 +++++++++++++++ .../ui/AppBreadcrumbs/StockBreadcrumbs.tsx | 25 -------- .../TbankConnectionSection.tsx | 12 ++-- .../TbankConnectionSection.vm.ts | 4 +- .../AccountEventsPage.filters.test.tsx | 8 +-- .../AccountEventsPage/AccountEventsPage.tsx | 12 ++-- .../AccountEventsPage/AccountEventsPage.vm.ts | 4 +- .../AccountIncomePage.test.tsx | 4 +- .../AccountIncomePage/AccountIncomePage.tsx | 12 ++-- .../AccountIncomePage/AccountIncomePage.vm.ts | 4 +- .../AccountOperationsPage.tsx | 10 ++-- .../account/ui/AccountPage/AccountPage.tsx | 60 +++++++++---------- .../ui/AccountPage/AccountPage.vm.test.tsx | 12 ++-- .../account/ui/AccountPage/AccountPage.vm.ts | 4 +- .../accounts/ui/AccountsPage/AccountsPage.tsx | 16 ++--- .../ui/AccountsPage/AccountsPage.vm.ts | 4 +- src/pages/login/ui/LoginPage.tsx | 4 +- src/pages/not-found/ui/NotFoundPage.tsx | 5 +- src/pages/register/ui/RegisterPage.tsx | 4 +- .../settings/ui/SettingsPage/SettingsPage.tsx | 17 +++++- .../ui/SettingsPage/SettingsPage.vm.ts | 21 ------- src/shared/ui/index.ts | 1 + src/shared/ui/useAppDocumentTitle.ts | 13 ++++ 30 files changed, 194 insertions(+), 202 deletions(-) delete mode 100644 src/app/ui/AppBreadcrumbs/BondBreadcrumbs.tsx delete mode 100644 src/app/ui/AppBreadcrumbs/CardBreadcrumbs.tsx create mode 100644 src/app/ui/AppBreadcrumbs/EntityBreadcrumbs.tsx delete mode 100644 src/app/ui/AppBreadcrumbs/StockBreadcrumbs.tsx delete mode 100644 src/pages/settings/ui/SettingsPage/SettingsPage.vm.ts create mode 100644 src/shared/ui/useAppDocumentTitle.ts 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/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/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..71c8821 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 }) => ( @@ -361,7 +361,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.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 3190415..010c865 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts @@ -39,7 +39,7 @@ type AccountEventsPageViewModel } | { state: "loading" }; -function useAccountEventsPageViewModel(): AccountEventsPageViewModel { +function useAccountEventsPageVm(): AccountEventsPageViewModel { const { accountId = "" } = useParams(); const { @@ -136,4 +136,4 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel { }; } -export { type AccountEventsPageViewModel, useAccountEventsPageViewModel }; +export { type AccountEventsPageViewModel, useAccountEventsPageVm }; diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx index 2e6def7..586caa0 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx @@ -34,7 +34,7 @@ import { server } from "@/test/server"; import { toIncomeDisplayModel } from "../../model/display"; import { AccountIncomePage } from "./AccountIncomePage"; import { - useAccountIncomePageViewModel, + useAccountIncomePageVm, } from "./AccountIncomePage.vm"; import { IncomePayments } from "./components/IncomePayments.view"; @@ -171,7 +171,7 @@ describe("AccountIncomePage", () => { ); const queryClient = createTestQueryClient(); - const { result } = renderHook(() => useAccountIncomePageViewModel(), { + const { result } = renderHook(() => useAccountIncomePageVm(), { wrapper: ({ children }) => ( 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 069c915..96ad523 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts @@ -43,7 +43,7 @@ type AccountIncomePageViewModel } | { state: "loading" }; -const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => { +const useAccountIncomePageVm = (): AccountIncomePageViewModel => { const { accountId = "" } = useParams(); const [searchParameters, setSearchParameters] = useSearchParams(); @@ -146,5 +146,5 @@ function isValidIncomePeriod(period: IncomePeriod): boolean { export { type AccountIncomePageViewModel, - useAccountIncomePageViewModel, + useAccountIncomePageVm, }; 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/ui/AccountPage/AccountPage.tsx b/src/pages/account/ui/AccountPage/AccountPage.tsx index db138bc..0b74da4 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.tsx @@ -1,48 +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 77814d0..8f0106e 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.vm.ts +++ b/src/pages/account/ui/AccountPage/AccountPage.vm.ts @@ -77,7 +77,7 @@ type AccountPageViewModel | { code?: string; readRetry: AccountPageReadRetry; state: "error" } | { state: "loading" }; -function useAccountPageViewModel(): AccountPageViewModel { +function useAccountPageVm(): AccountPageViewModel { const { accountId } = useParams(); const { t } = useTranslation(); @@ -199,4 +199,4 @@ function useAccountPageViewModel(): AccountPageViewModel { }; } -export { useAccountPageViewModel }; +export { useAccountPageVm }; diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx b/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx index e141610..0ea59ec 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx @@ -1,22 +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 e9c1fef..838f22f 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts @@ -30,7 +30,7 @@ function isAccountsAccessFailure(code?: string) { || isApiErrorCode(code, ApiErrorCode.TbankNotConnected); } -function useAccountsPageViewModel(): AccountsPageViewModel { +function useAccountsPageVm(): AccountsPageViewModel { const accounts = useBrokerageAccounts(); useScreenSync({ screen: "accounts" }, [accounts]); @@ -73,4 +73,4 @@ function useAccountsPageViewModel(): AccountsPageViewModel { }; } -export { useAccountsPageViewModel }; +export { useAccountsPageVm }; diff --git a/src/pages/login/ui/LoginPage.tsx b/src/pages/login/ui/LoginPage.tsx index a0b593f..9b40c5d 100644 --- a/src/pages/login/ui/LoginPage.tsx +++ b/src/pages/login/ui/LoginPage.tsx @@ -1,13 +1,13 @@ import type { FC } from "react"; -import { useDocumentTitle } from "@mantine/hooks"; import { useTranslation } from "react-i18next"; import { AuthPageLayout, LoginForm } from "@/features/auth"; +import { useAppDocumentTitle } from "@/shared/ui"; const LoginPage: FC = () => { const { t } = useTranslation(); - useDocumentTitle(`${t("auth.login")} — ${t("appName")}`); + useAppDocumentTitle(t("auth.login")); return ( diff --git a/src/pages/not-found/ui/NotFoundPage.tsx b/src/pages/not-found/ui/NotFoundPage.tsx index 1a2e4e5..36f0046 100644 --- a/src/pages/not-found/ui/NotFoundPage.tsx +++ b/src/pages/not-found/ui/NotFoundPage.tsx @@ -1,14 +1,15 @@ import type { FC } from "react"; import { Button, Stack, Title } from "@mantine/core"; -import { useDocumentTitle } from "@mantine/hooks"; import { StateView } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; +import { useAppDocumentTitle } from "@/shared/ui"; + const NotFoundPage: FC = () => { const { t } = useTranslation(); - useDocumentTitle(`${t("notFound.title")} — ${t("appName")}`); + useAppDocumentTitle(t("notFound.title")); return ( diff --git a/src/pages/register/ui/RegisterPage.tsx b/src/pages/register/ui/RegisterPage.tsx index c29276b..1da17c0 100644 --- a/src/pages/register/ui/RegisterPage.tsx +++ b/src/pages/register/ui/RegisterPage.tsx @@ -1,13 +1,13 @@ import type { FC } from "react"; -import { useDocumentTitle } from "@mantine/hooks"; import { useTranslation } from "react-i18next"; import { AuthPageLayout, RegisterForm } from "@/features/auth"; +import { useAppDocumentTitle } from "@/shared/ui"; const RegisterPage: FC = () => { const { t } = useTranslation(); - useDocumentTitle(`${t("auth.register")} — ${t("appName")}`); + useAppDocumentTitle(t("auth.register")); return ( diff --git a/src/pages/settings/ui/SettingsPage/SettingsPage.tsx b/src/pages/settings/ui/SettingsPage/SettingsPage.tsx index 30c3929..6baef1c 100644 --- a/src/pages/settings/ui/SettingsPage/SettingsPage.tsx +++ b/src/pages/settings/ui/SettingsPage/SettingsPage.tsx @@ -1,13 +1,24 @@ import type { FC } from "react"; import { Button, Stack, Title } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { useAuth } from "@/features/auth"; import { TbankConnectionSection } from "@/features/tbank-connection"; -import { useSettingsPageVm } from "./SettingsPage.vm"; - const SettingsPage: FC = () => { - const { onLogout, t } = useSettingsPageVm(); + const { t } = useTranslation(); + const { logout } = useAuth(); + + // eslint-disable-next-line frontend/readability -- the page VM was removed as degenerate (frontend#143); a one-off error-swallowing logout is not a ViewModel. + const onLogout = async () => { + try { + await logout(); + } + catch { + // The session is already cleared; the route guard owns navigation. + } + }; return ( diff --git a/src/pages/settings/ui/SettingsPage/SettingsPage.vm.ts b/src/pages/settings/ui/SettingsPage/SettingsPage.vm.ts deleted file mode 100644 index c048de0..0000000 --- a/src/pages/settings/ui/SettingsPage/SettingsPage.vm.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useTranslation } from "react-i18next"; - -import { useAuth } from "@/features/auth"; - -function useSettingsPageVm() { - const { t } = useTranslation(); - const { logout } = useAuth(); - - const onLogout = async () => { - try { - await logout(); - } - catch { - // The session is already cleared; the route guard owns navigation. - } - }; - - return { onLogout, t }; -} - -export { useSettingsPageVm }; diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 598d3ed..99f0483 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -11,3 +11,4 @@ export { 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 }; -- 2.54.0 From cadba8d075daf9b20c0c9de97ad5bd8f5a3e74fc Mon Sep 17 00:00:00 2001 From: codex-bot <codex-bot@ksv741.ru> Date: Wed, 4 Nov 2026 22:58:58 +0300 Subject: [PATCH 10/12] refactor: share path-root query predicate (WealthLedger/frontend#142) (#154) --- src/entities/bank-card/api/mutations.ts | 14 +++------- .../model/connection-boundary.ts | 23 +++++----------- src/shared/api/index.ts | 1 + src/shared/api/query-predicate.test.ts | 26 +++++++++++++++++++ src/shared/api/query-predicate.ts | 18 +++++++++++++ 5 files changed, 56 insertions(+), 26 deletions(-) create mode 100644 src/shared/api/query-predicate.test.ts create mode 100644 src/shared/api/query-predicate.ts 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<T>( 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/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/shared/api/index.ts b/src/shared/api/index.ts index 60bc969..b27dc60 100644 --- a/src/shared/api/index.ts +++ b/src/shared/api/index.ts @@ -13,6 +13,7 @@ export { isApiErrorCode, isBrokerageAccessLoss, } from "./problem-details"; +export { isGetUnder } from "./query-predicate"; export { isReadFailureExhausted, readRetry } from "./read-failure"; export { diff --git a/src/shared/api/query-predicate.test.ts b/src/shared/api/query-predicate.test.ts new file mode 100644 index 0000000..b5357c2 --- /dev/null +++ b/src/shared/api/query-predicate.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { isGetUnder } from "./query-predicate"; + +describe("isGetUnder", () => { + it("matches the root itself and nested paths for get queries only", () => { + const predicate = isGetUnder(["/api/v1/bank-cards"]); + + expect(predicate(["get", "/api/v1/bank-cards"])).toBe(true); + expect(predicate(["get", "/api/v1/bank-cards/{id}"])).toBe(true); + expect(predicate(["get", "/api/v1/bank-cards"])).toBe(true); + expect(predicate(["get", "/api/v1/bank-cardsx"])).toBe(false); + expect(predicate(["post", "/api/v1/bank-cards"])).toBe(false); + expect(predicate(["get", "/api/v1/other"])).toBe(false); + }); + + it("checks every root and tolerates init and non-string parts", () => { + const predicate = isGetUnder(["/api/v1/stocks", "/api/v1/bonds"]); + + expect(predicate(["get", "/api/v1/stocks/{uid}/quote", { params: {} }])) + .toBe(true); + expect(predicate(["get", "/api/v1/bonds"])).toBe(true); + expect(predicate([undefined, "/api/v1/stocks"])).toBe(false); + expect(predicate(["get", undefined])).toBe(false); + }); +}); diff --git a/src/shared/api/query-predicate.ts b/src/shared/api/query-predicate.ts new file mode 100644 index 0000000..62c450b --- /dev/null +++ b/src/shared/api/query-predicate.ts @@ -0,0 +1,18 @@ +import type { QueryKey } from "@tanstack/react-query"; + +// openapi-react-query shapes query keys as [method, path, init]. +function isGetUnder(roots: readonly string[]) { + const predicate = (queryKey: QueryKey) => { + const [method, path] = queryKey as readonly [unknown, unknown]; + + return ( + method === "get" + && typeof path === "string" + && roots.some(root => path === root || path.startsWith(`${root}/`)) + ); + }; + + return predicate; +} + +export { isGetUnder }; -- 2.54.0 From 668f2ef70e535a1222e7f600b1be4a31fefc6f8b Mon Sep 17 00:00:00 2001 From: codex-bot <codex-bot@ksv741.ru> Date: Thu, 5 Nov 2026 07:23:07 +0300 Subject: [PATCH 11/12] fix: align audit findings for Events, currency and auth (WealthLedger/frontend#155) --- docker/nginx.conf | 9 +++++++ src/app/routes/AppRoutes/AllAccounts.test.tsx | 1 + .../AccountEventsPage.filters.test.tsx | 14 ++++++++-- .../AccountEventsPage.retry.test.tsx | 12 +++++++++ .../AccountEventsPage.test.tsx | 3 +++ .../AccountEventsPage/AccountEventsPage.vm.ts | 4 +++ .../AccountEventsPageLoaded.view.tsx | 10 ++++++- src/pages/account/model/positions.test.ts | 26 ++++++++++++++++--- src/pages/account/model/positions.ts | 2 +- src/shared/i18n/locales/ru.ts | 1 + src/test/handlers.ts | 1 + 11 files changed, 75 insertions(+), 8 deletions(-) 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/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/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx index 71c8821..33cd2d6 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx @@ -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"]); diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.retry.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.retry.test.tsx index db2644f..cb39af1 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.retry.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.retry.test.tsx @@ -17,6 +17,8 @@ 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", @@ -32,6 +34,7 @@ const snapshot = resource({ }, ], incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }], + window: { from: "2026-08-14", to: "2026-09-13" }, }); function renderEvents() { @@ -100,7 +103,13 @@ describe("AccountEventsPage read retry", () => { 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; @@ -111,6 +120,7 @@ describe("AccountEventsPage read retry", () => { ); 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(); @@ -127,6 +137,7 @@ describe("AccountEventsPage read retry", () => { 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; @@ -136,6 +147,7 @@ describe("AccountEventsPage read retry", () => { expect(screen.queryByText("Не удалось обновить данные")) .not.toBeInTheDocument()); expect(requests).toBe(5); + expect(imports).toBe(0); }, ); diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx index 7a3db60..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, @@ -276,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.vm.ts b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts index 010c865..fe6c4d1 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts @@ -18,6 +18,7 @@ import { type AccountEventsPageViewModel = | { allAccounts: boolean; + calculatedAt: string; canApply: boolean; draftPeriod: [null | string, null | string]; events: BrokerageEvent[]; @@ -31,6 +32,7 @@ type AccountEventsPageViewModel source?: InvestmentResourceMeta; state: "loaded"; totalVisible: boolean; + window: { from: string; to: string }; } | { onReset: () => void; @@ -110,6 +112,7 @@ function useAccountEventsPageVm(): AccountEventsPageViewModel { return { allAccounts: accountId === "all", + calculatedAt: events.data.calculatedAt, canApply: (draftFrom !== appliedFrom || draftTo !== appliedTo) && isValidPeriod(draftFrom, draftTo), @@ -133,6 +136,7 @@ function useAccountEventsPageVm(): AccountEventsPageViewModel { source: events.meta, state: "loaded", totalVisible: events.meta?.completeness === "complete", + window: events.data.window, }; } 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 514d839..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,7 +17,7 @@ import { brokerageEventGroupKey, brokerageEventKey, } from "@/entities/brokerage-event"; -import { groupItems } from "@/shared/lib"; +import { formatDate, formatSnapshotTime, groupItems } from "@/shared/lib"; import { ReadRetry, SourceSyncStatus } from "@/shared/ui"; import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm"; @@ -89,6 +89,14 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => { <Text c="dimmed">{t("events.hint")}</Text> + <Text c="dimmed" size="sm"> + {t("events.forecastWindow", { + date: formatSnapshotTime(props.calculatedAt), + from: formatDate(props.window.from), + to: formatDate(props.window.to), + })} + </Text> + <Group align="end"> <DatePickerInput allowSingleDateInRange 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<string, number>(); 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/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts index d6fcc35..ffb76a4 100644 --- a/src/shared/i18n/locales/ru.ts +++ b/src/shared/i18n/locales/ru.ts @@ -360,6 +360,7 @@ const ru = { emptyTitle: "За выбранный период событий не найдено", errorText: "Попробуйте запросить прогноз еще раз.", errorTitle: "Не удалось загрузить будущие события", + forecastWindow: "Прогноз от {{date}} на {{from}} – {{to}}", hint: "Прогноз рассчитан по текущей позиции до налогов. Покрытие зависит от доступных данных MOEX.", noAmount: "Сумма неизвестна", open: "Открыть будущие события", 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" }, }), ), ), -- 2.54.0 From 2fd7d59e65b995141d67dd448cbe664d7ec8bb16 Mon Sep 17 00:00:00 2001 From: Sergey Krylov <ksv741@mail.ru> Date: Fri, 6 Nov 2026 07:45:29 +0300 Subject: [PATCH 12/12] chore(release): 1.4.0 --- CHANGELOG.md | 25 +++++++++++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 28 insertions(+), 3 deletions(-) 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/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" -- 2.54.0