diff --git a/package-lock.json b/package-lock.json
index 686ebdc..527dcbf 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,6 +16,7 @@
"@mantine/core": "9.6.1",
"@mantine/dates": "9.6.1",
"@mantine/hooks": "9.6.1",
+ "@mantine/notifications": "9.6.1",
"@tanstack/react-query": "5.102.8",
"@wealthledger/icons": "file:packages/icons",
"@wealthledger/tokens": "file:packages/tokens",
@@ -2841,6 +2842,31 @@
"react": "^19.2.0"
}
},
+ "node_modules/@mantine/notifications": {
+ "version": "9.6.1",
+ "resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-9.6.1.tgz",
+ "integrity": "sha512-6bikkVCAf6wd1KO/oVhBuXGDZ+HcWeNGCEvdgkL0pQ6jmjzd24ShW6UnbGu6aLABhYxQdfLnrE1cbOjC93Tj6A==",
+ "license": "MIT",
+ "dependencies": {
+ "@mantine/store": "9.6.1",
+ "react-transition-group": "4.4.5"
+ },
+ "peerDependencies": {
+ "@mantine/core": "9.6.1",
+ "@mantine/hooks": "9.6.1",
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0"
+ }
+ },
+ "node_modules/@mantine/store": {
+ "version": "9.6.1",
+ "resolved": "https://registry.npmjs.org/@mantine/store/-/store-9.6.1.tgz",
+ "integrity": "sha512-KePeOIAg2n7wkHlxFxF7s6qYeD9EE/GrFIK3cc329nTSDIEIJQ+oFQv1S/t4sZJTIz7YnBlRuvA9Wc8pRg8TRA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^19.2.0"
+ }
+ },
"node_modules/@mdx-js/react": {
"version": "3.1.1",
"integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==",
@@ -7275,7 +7301,6 @@
"node_modules/csstype": {
"version": "3.2.3",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "devOptional": true,
"license": "MIT"
},
"node_modules/d3-array": {
@@ -7749,6 +7774,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/dom-helpers": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
+ "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.8.7",
+ "csstype": "^3.0.2"
+ }
+ },
"node_modules/dotenv": {
"version": "17.4.2",
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
@@ -10715,7 +10750,6 @@
"node_modules/js-tokens": {
"version": "4.0.0",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@@ -11363,6 +11397,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
"node_modules/loupe": {
"version": "3.2.1",
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
@@ -11819,6 +11865,15 @@
"node": ">=18"
}
},
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/object-inspect": {
"version": "1.13.4",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
@@ -12536,6 +12591,23 @@
"node": ">= 6"
}
},
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
+ "node_modules/prop-types/node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "license": "MIT"
+ },
"node_modules/protocols": {
"version": "2.0.2",
"integrity": "sha512-hHVTzba3wboROl0/aWRRG9dMytgH6ow//STBZh43l/wQgmMhYhOFi0EHWAPtoCz9IAUymsyP0TSBHkhgMEGNnQ==",
@@ -13001,6 +13073,22 @@
}
}
},
+ "node_modules/react-transition-group": {
+ "version": "4.4.5",
+ "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
+ "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@babel/runtime": "^7.5.5",
+ "dom-helpers": "^5.0.1",
+ "loose-envify": "^1.4.0",
+ "prop-types": "^15.6.2"
+ },
+ "peerDependencies": {
+ "react": ">=16.6.0",
+ "react-dom": ">=16.6.0"
+ }
+ },
"node_modules/readable-stream": {
"version": "3.6.2",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
diff --git a/package.json b/package.json
index 8c0daa1..a43bf4e 100644
--- a/package.json
+++ b/package.json
@@ -42,6 +42,7 @@
"@mantine/core": "9.6.1",
"@mantine/dates": "9.6.1",
"@mantine/hooks": "9.6.1",
+ "@mantine/notifications": "9.6.1",
"@tanstack/react-query": "5.102.8",
"@wealthledger/icons": "file:packages/icons",
"@wealthledger/tokens": "file:packages/tokens",
diff --git a/src/app/routes/AppRoutes/AllAccounts.test.tsx b/src/app/routes/AppRoutes/AllAccounts.test.tsx
index 023debe..1226035 100644
--- a/src/app/routes/AppRoutes/AllAccounts.test.tsx
+++ b/src/app/routes/AppRoutes/AllAccounts.test.tsx
@@ -369,7 +369,9 @@ describe("All accounts routes", () => {
renderPage("/accounts/all/operations?from=2026-08-01&to=2026-08-31");
await screen.findAllByText("Выплата купона");
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
- fireEvent.click(await screen.findByRole("button", { name: "Повторить" }));
+ await waitFor(() => {
+ expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
+ });
expect(await screen.findAllByText("Выплата купона")).toHaveLength(2);
expect(screen.getByLabelText("Текущий путь")).toHaveTextContent(
"from=2026-08-01&to=2026-08-31",
@@ -390,10 +392,12 @@ describe("All accounts routes", () => {
}),
);
expect(screen.getAllByText("Сбербанк")).toHaveLength(1);
- expect(
- await screen.findByRole("button", {
- name: "Повторить",
- }),
- ).toBeInTheDocument();
+ await waitFor(() => {
+ expect(
+ screen.queryByRole("button", {
+ name: "Повторить",
+ }),
+ ).not.toBeInTheDocument();
+ });
});
});
diff --git a/src/app/ui/App.tsx b/src/app/ui/App.tsx
index db26d02..4a196cc 100644
--- a/src/app/ui/App.tsx
+++ b/src/app/ui/App.tsx
@@ -2,6 +2,7 @@ import type { FC } from "react";
import { MantineProvider } from "@mantine/core";
import { DatesProvider } from "@mantine/dates";
+import { Notifications } from "@mantine/notifications";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
import { BrowserRouter } from "react-router";
@@ -11,7 +12,11 @@ import { AuthProvider } from "@/features/auth";
const queryClient = new QueryClient({
defaultOptions: {
- queries: { refetchOnWindowFocus: false, retry: false },
+ queries: {
+ refetchOnReconnect: true,
+ refetchOnWindowFocus: true,
+ retry: false,
+ },
},
});
@@ -31,6 +36,8 @@ const App: FC = () => {
+
+
diff --git a/src/entities/bank-card/api/banking.test.tsx b/src/entities/bank-card/api/banking.test.tsx
index 2ec2666..86fd5b4 100644
--- a/src/entities/bank-card/api/banking.test.tsx
+++ b/src/entities/bank-card/api/banking.test.tsx
@@ -147,7 +147,7 @@ describe("banking API", () => {
expect(url.searchParams.get("from")).toBe("2026-09-01");
expect(url.searchParams.get("to")).toBe("2026-09-30");
if (isFail && page === 2)
- return HttpResponse.json({ code: "FAILED" }, { status: 500 });
+ return HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 });
return HttpResponse.json({
hasNext: page === 1,
@@ -299,7 +299,7 @@ describe("banking API", () => {
expect(result.current.query.data?.balance.value).toBe("100");
server.use(
http.get("*/api/v1/bank-cards/a", () =>
- HttpResponse.json({ code: "INTERNAL_ERROR" }, { status: 500 }),
+ HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 }),
),
);
await act(() => result.current.query.refetch());
diff --git a/src/entities/bank-card/ui/BankCardForm/BankCardForm.vm.ts b/src/entities/bank-card/ui/BankCardForm/BankCardForm.vm.ts
index 4add66a..20fbbe5 100644
--- a/src/entities/bank-card/ui/BankCardForm/BankCardForm.vm.ts
+++ b/src/entities/bank-card/ui/BankCardForm/BankCardForm.vm.ts
@@ -1,4 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
+import { notifications } from "@mantine/notifications";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
@@ -71,7 +72,7 @@ function useBankCardFormVm(props: BankCardFormProps) {
openingDate: values.openingDate,
};
- if (props.card)
+ if (props.card) {
await updateCard.mutateAsync({
body: {
...metadata,
@@ -80,12 +81,19 @@ function useBankCardFormVm(props: BankCardFormProps) {
},
id: props.card.id,
});
- else
+ }
+ else {
await cardCreation.mutateAsync({
id: intent,
...metadata,
...financial,
});
+ notifications.show({
+ id: "bank-card-created-" + intent,
+ message: t("bank.cardSaved"),
+ });
+ }
+
setOpened(false);
}
catch (error_) {
diff --git a/src/entities/bank-card/ui/BankSummary/BankSummary.stories.tsx b/src/entities/bank-card/ui/BankSummary/BankSummary.stories.tsx
index 897713b..1ed7fd9 100644
--- a/src/entities/bank-card/ui/BankSummary/BankSummary.stories.tsx
+++ b/src/entities/bank-card/ui/BankSummary/BankSummary.stories.tsx
@@ -47,7 +47,6 @@ const meta
error: false,
fetching: false,
loading: false,
- retry: () => {},
},
component: BankSummaryView,
parameters: {
diff --git a/src/entities/bank-card/ui/BankSummary/BankSummary.view.tsx b/src/entities/bank-card/ui/BankSummary/BankSummary.view.tsx
index 4bd575f..52fccc6 100644
--- a/src/entities/bank-card/ui/BankSummary/BankSummary.view.tsx
+++ b/src/entities/bank-card/ui/BankSummary/BankSummary.view.tsx
@@ -1,6 +1,6 @@
import type { CSSProperties, FC } from "react";
-import { Button, Group, Skeleton, Stack, Text, Title } from "@mantine/core";
+import { Skeleton, Stack, Text, Title } from "@mantine/core";
import { OperationIcon } from "@wealthledger/icons";
import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
@@ -20,7 +20,6 @@ type BankSummaryViewProps = {
error: boolean;
fetching: boolean;
loading: boolean;
- retry: () => void;
};
const BankSummaryViewCategories: FC = (props) => {
@@ -101,17 +100,7 @@ const BankSummaryView: FC = (props) => {
- {props.error && (
-
- {t(props.data ? "bank.refreshError" : "bank.error")}
-
-
-
- )}
-
- {props.data && props.fetching && {t("bank.refreshing")}}
+ {props.error && !props.data && {t("bank.error")}}
);
};
diff --git a/src/entities/broker-operation/model/useBrokerageOperationHistory.ts b/src/entities/broker-operation/model/useBrokerageOperationHistory.ts
index 3147b8c..1ab9a25 100644
--- a/src/entities/broker-operation/model/useBrokerageOperationHistory.ts
+++ b/src/entities/broker-operation/model/useBrokerageOperationHistory.ts
@@ -1,5 +1,5 @@
import { hashKey, useQueryClient } from "@tanstack/react-query";
-import { useState } from "react";
+import { useEffect, useState } from "react";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
@@ -29,15 +29,28 @@ function useBrokerageOperationHistory(
cursor: cursors.at(-1),
});
+ const isInvalidCursor = isApiErrorCode(
+ apiErrorCode(operations.error),
+ ApiErrorCode.InvalidOperationsCursor,
+ );
+
+ const isRecoveringInvalidCursor = cursors.length > 0 && isInvalidCursor;
+
const reset = () => setPagination({ cursors: [], scope });
+ useEffect(() => {
+ if (!isRecoveringInvalidCursor) return;
+
+ const timeout = setTimeout(() => {
+ setPagination({ cursors: [], scope });
+ void client.invalidateQueries({ exact: true, queryKey });
+ }, 0);
+
+ return () => clearTimeout(timeout);
+ }, [client, isRecoveringInvalidCursor, queryKey, scope]);
+
const retry = (retryScreen: () => unknown) => {
- if (
- isApiErrorCode(
- apiErrorCode(operations.error),
- ApiErrorCode.InvalidOperationsCursor,
- )
- ) {
+ if (isInvalidCursor) {
reset();
void client.invalidateQueries({
exact: true,
@@ -49,6 +62,7 @@ function useBrokerageOperationHistory(
return {
hasPrevious: cursors.length > 0,
+ isRecoveringInvalidCursor,
onNext: () => {
if (operations.data?.nextCursor)
setPagination({
diff --git a/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx b/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx
index 40e3734..5855d0a 100644
--- a/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx
+++ b/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx
@@ -1,12 +1,10 @@
import type { FC } from "react";
-import { Alert, Button, Skeleton, Stack, Text } from "@mantine/core";
+import { Button, Skeleton, Stack, Text } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
-import { SourceSyncStatus } from "@/shared/ui";
-
import type { BrokerageEvent } from "../../api/useBrokerageEvents";
import { brokerageEventKey } from "../../model/event-key";
@@ -19,9 +17,6 @@ type BrokerageEventsPreviewProps = {
type BrokerageEventsPreviewState
= | {
items: BrokerageEvent[];
- lastSuccessfulAt?: null | string;
- refreshError?: boolean;
- stale?: boolean;
state: "loaded" | "partial";
to: string;
}
@@ -33,28 +28,15 @@ const BrokerageEventsPreview: FC = (props) => {
const { events } = props;
const { t } = useTranslation();
- const errorText
- = events.state === "loaded" && events.refreshError
- ? t("accounts.refreshError")
- : null;
-
return (
- {events.state === "loaded" && (
-
- )}
-
{events.state === "loading" && (
{["first", "second", "third"].map(placeholder => )}
)}
- {events.state === "error" && {t("events.recentError")}}
+ {events.state === "error" && {t("states.unavailableText")}}
{events.state === "empty" && {t("events.recentEmpty")}}
diff --git a/src/features/instrument-details/model/section.test.ts b/src/features/instrument-details/model/section.test.ts
index b2ff22d..40b5527 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, vi } from "vitest";
+import { describe, expect, it } from "vitest";
import { resource } from "@/test/resource";
@@ -13,9 +13,7 @@ describe("instrument source and transport presentation", () => {
const state = section({
data,
isError: false,
- isFetching: false,
meta: resource(data, { availability }).meta,
- refetch: vi.fn(),
});
expect(state.loading).toBe(availability === "missing");
@@ -23,39 +21,40 @@ describe("instrument source and transport presentation", () => {
expect(state.hasData).toBe(availability !== "missing");
},
);
- it("preserves a full fresh source and timestamp when force fails, while offering scoped retry", () => {
- const retry = vi.fn();
- const refetch = vi.fn();
+ it("preserves empty successful data instead of treating it as missing", () => {
+ const state = section({
+ data: [],
+ isError: false,
+ meta: resource([]).meta,
+ });
+ expect(state).toMatchObject({
+ error: false,
+ hasData: true,
+ loading: false,
+ });
+ });
+
+ it("preserves a full source when force fails", () => {
const meta = resource([], {
sync: { errorCode: "TBANK_UNAVAILABLE", id: "work", state: "failed" },
}).meta;
- const state = section(
- { data: [], isError: false, isFetching: false, meta, refetch },
- retry,
- );
+ const state = section({ data: [], isError: false, meta });
expect(state).toMatchObject({
error: true,
hasData: true,
- lastSuccessfulAt: meta.lastSuccessfulAt,
loading: false,
- refreshing: false,
});
- state.retry();
- expect(retry).toHaveBeenCalledOnce();
- expect(refetch).not.toHaveBeenCalled();
});
it("shows source failure without a false loading or empty result", () => {
const state = section({
data: undefined,
isError: false,
- isFetching: false,
meta: resource(null, {
sync: { errorCode: "TBANK_UNAVAILABLE", id: "work", state: "failed" },
}).meta,
- refetch: vi.fn(),
});
expect(state).toMatchObject({
diff --git a/src/features/instrument-details/model/section.ts b/src/features/instrument-details/model/section.ts
index 399d903..898ea88 100644
--- a/src/features/instrument-details/model/section.ts
+++ b/src/features/instrument-details/model/section.ts
@@ -3,43 +3,27 @@ import type { InvestmentResourceMeta } from "@/shared/api";
type SectionState = {
error: boolean;
hasData: boolean;
- lastSuccessfulAt?: null | string;
loading: boolean;
partial?: boolean;
- refreshing: boolean;
- retry: () => void;
- sourceError?: boolean;
- stale?: boolean;
};
-function section(
- query: {
- data: unknown;
- isError: boolean;
- isFetching: boolean;
- meta?: InvestmentResourceMeta;
- refetch: () => unknown;
- },
- retry?: () => unknown,
-): SectionState {
+function hasSectionData(data: unknown) {
+ return data !== undefined;
+}
+
+function section(query: {
+ data: unknown;
+ isError: boolean;
+ meta?: InvestmentResourceMeta;
+}): SectionState {
const isError = query.isError || query.meta?.sync.state === "failed";
+ const hasData = hasSectionData(query.data);
return {
error: isError,
- hasData: Boolean(query.data),
- lastSuccessfulAt: query.meta?.lastSuccessfulAt,
- loading: !query.data && !isError,
+ hasData,
+ loading: !hasData && !isError,
partial: query.meta?.availability === "partial",
- refreshing:
- Boolean(query.data)
- && (query.isFetching
- || query.meta?.sync.state === "running"
- ),
- retry: () => {
- void (retry ?? query.refetch)();
- },
- sourceError: query.meta?.sync.state === "failed",
- stale: query.meta?.freshness === "stale",
};
}
diff --git a/src/features/instrument-details/ui/InstrumentEvents.view.tsx b/src/features/instrument-details/ui/InstrumentEvents.view.tsx
index 6d6b75d..13e3f98 100644
--- a/src/features/instrument-details/ui/InstrumentEvents.view.tsx
+++ b/src/features/instrument-details/ui/InstrumentEvents.view.tsx
@@ -86,7 +86,7 @@ const InstrumentEvents: FC = (props) => {
{children}
diff --git a/src/features/instrument-details/ui/InstrumentPageState.view.tsx b/src/features/instrument-details/ui/InstrumentPageState.view.tsx
index aa6fb61..1f502ee 100644
--- a/src/features/instrument-details/ui/InstrumentPageState.view.tsx
+++ b/src/features/instrument-details/ui/InstrumentPageState.view.tsx
@@ -1,6 +1,6 @@
import type { FC } from "react";
-import { Anchor, Button, Skeleton, Stack, Text, Title } from "@mantine/core";
+import { Anchor, Skeleton, Stack, Text, Title } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
@@ -11,11 +11,10 @@ type InstrumentPageStateProps = {
code?: string;
kind: "bond" | "stock";
loading: boolean;
- retry: () => void;
};
const InstrumentPageState: FC = (props) => {
- const { code, kind, loading, retry } = props;
+ const { code, kind, loading } = props;
const { t } = useTranslation();
if (loading) return (
@@ -55,10 +54,6 @@ const InstrumentPageState: FC = (props) => {
{t("accounts.openSettings")}
-
-
- {t("accounts.retry")}
-
);
};
diff --git a/src/features/instrument-details/ui/InstrumentSection.view.tsx b/src/features/instrument-details/ui/InstrumentSection.view.tsx
index 64694b9..5c37ba1 100644
--- a/src/features/instrument-details/ui/InstrumentSection.view.tsx
+++ b/src/features/instrument-details/ui/InstrumentSection.view.tsx
@@ -2,7 +2,6 @@ import type { FC, PropsWithChildren, ReactNode } from "react";
import {
Alert,
- Button,
Group,
Paper,
Skeleton,
@@ -13,8 +12,6 @@ import {
import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
-import { SourceSyncStatus } from "@/shared/ui";
-
import type { SectionState } from "../model/section";
import classes from "./InstrumentDetails.module.css";
@@ -33,10 +30,6 @@ type InstrumentSectionProps = PropsWithChildren<{
const InstrumentSection: FC = (props) => {
const { t } = useTranslation();
- const readError = t(
- props.state.hasData ? "accounts.refreshError" : "stock.sectionError",
- );
-
const sectionClassName = props.embedded
? classes.embeddedSection
: classes.section;
@@ -80,35 +73,11 @@ const InstrumentSection: FC = (props) => {
{props.controls}
-
-
{!!props.state.partial && {t("investment.partial")}}
- {!!props.state.refreshing && (
-
- {t("accounts.refreshing")}
-
- )}
-
- {!!props.state.error && (
+ {!!(props.state.error && !props.state.hasData) && (
-
-
- {props.state.sourceError ? t("investment.syncFailed") : readError}
-
-
-
- {t("accounts.retry")}
-
-
+ {t("stock.sectionError")}
)}
diff --git a/src/features/instrument-search/ui/InstrumentSearch/InstrumentSearch.test.tsx b/src/features/instrument-search/ui/InstrumentSearch/InstrumentSearch.test.tsx
index 4f16310..1bdeb77 100644
--- a/src/features/instrument-search/ui/InstrumentSearch/InstrumentSearch.test.tsx
+++ b/src/features/instrument-search/ui/InstrumentSearch/InstrumentSearch.test.tsx
@@ -1,7 +1,7 @@
import type { ComponentProps } from "react";
import { MantineProvider } from "@mantine/core";
-import { fireEvent, render, screen } from "@testing-library/react";
+import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { expect, it, vi } from "vitest";
@@ -16,8 +16,6 @@ it("keeps cached results on failure, hides them during a new search and distingu
partial: false,
query: "SBER",
ready: true,
- refreshing: false,
- retry: vi.fn(),
};
const cached = [Сбербанк];
@@ -44,8 +42,7 @@ it("keeps cached results on failure, hides them during a new search and distingu
expect(
screen.getByRole("link", { name: "Открыть настройки T-Bank" }),
).toHaveAttribute("href", "/settings");
- fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
- expect(vm.retry).toHaveBeenCalledOnce();
+ expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
rerender(view({ loading: true, ready: false }));
expect(screen.queryByRole("link", { name: "Сбербанк" })).not.toBeInTheDocument();
diff --git a/src/features/instrument-search/ui/InstrumentSearch/InstrumentSearch.view.tsx b/src/features/instrument-search/ui/InstrumentSearch/InstrumentSearch.view.tsx
index e56ff5f..b99ed45 100644
--- a/src/features/instrument-search/ui/InstrumentSearch/InstrumentSearch.view.tsx
+++ b/src/features/instrument-search/ui/InstrumentSearch/InstrumentSearch.view.tsx
@@ -3,7 +3,6 @@ import type { ChangeEvent, FC, ReactNode } from "react";
import {
Alert,
Anchor,
- Button,
Skeleton,
Stack,
Text,
@@ -28,8 +27,6 @@ type InstrumentSearchProps = {
partial: boolean;
query: string;
ready: boolean;
- refreshing: boolean;
- retry: () => void;
};
};
@@ -77,14 +74,6 @@ const InstrumentSearch: FC = (props) => {
-
- {t("investment.refresh")}
-
-
{!!vm.partial && {t("investment.partial")}}
= (props) => {
{t("accounts.openSettings")}
-
- {t("accounts.retry")}
-
)}
diff --git a/src/features/tbank-connection/model/screen-sync.test.tsx b/src/features/tbank-connection/model/screen-sync.test.tsx
index dc39898..aef200c 100644
--- a/src/features/tbank-connection/model/screen-sync.test.tsx
+++ b/src/features/tbank-connection/model/screen-sync.test.tsx
@@ -78,7 +78,7 @@ describe("page-owned screen synchronization", () => {
expect(posted).toEqual(["old", "new"]);
});
it.each([false, true])(
- "retries a failed GET without forcing the source (cached=%s)",
+ "recovers a failed GET without forcing the source (cached=%s)",
async (cached) => {
auth();
const modes: (null | string)[] = [];
@@ -122,26 +122,13 @@ describe("page-owned screen synchronization", () => {
expect(result.current.accounts.isSuccess).toBe(true),
);
isUnavailable = true;
- await act(() => result.current.accounts.refetch());
+ void result.current.accounts.refetch();
}
- await waitFor(() => expect(result.current.accounts.isError).toBe(true));
isUnavailable = false;
- await act(() => result.current.sync.retry());
await waitFor(() => expect(result.current.accounts.isSuccess).toBe(true));
- expect(result.current.accounts.meta?.dataRevision).toBe(
- cached ? "3" : "2",
- );
- expect(modes).toEqual(
- cached
- ? [
- null,
- null,
- "observe",
- ]
- : [null, "observe"],
- );
expect(posts).toBe(0);
+ expect(modes.every(mode => mode === null)).toBe(true);
expect(result.current.disabled.data).toBeUndefined();
expect(
queryClient
@@ -157,171 +144,193 @@ describe("page-owned screen synchronization", () => {
["connection", false],
["access", false],
] as const)(
- "discards recovery after %s changes (HTTP already pending=%s)",
+ "discards source recovery after %s changes (HTTP already pending=%s)",
async (boundary, pending) => {
auth();
const { promise: blocked, resolve: release }
= Promise.withResolvers();
- let isRecovering = false;
+ let recoveryRequests = 0;
let isChanged = false;
- server.use(
- http.get("*/api/v1/brokerage-accounts", async ({ request }) => {
- if (boundary === "connection" && isChanged && !request.signal.aborted)
- return HttpResponse.json(resource([], { dataRevision: "current" }));
- if (!new URL(request.url).searchParams.has("sync"))
- return isChanged
- ? HttpResponse.json(resource([], { dataRevision: "current" }))
- : HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 });
- isRecovering = true;
- await blocked;
- return HttpResponse.json(resource([], { dataRevision: "old" }));
- }),
- );
- const { queryClient, wrapper } = createQueryWrapper();
- const published: unknown[] = [];
+ try {
+ server.use(
+ http.get("*/api/v1/brokerage-accounts", async () => {
+ if (isChanged)
+ return HttpResponse.json(
+ resource([], { dataRevision: "current" }),
+ );
- const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
- if (event.type === "updated" && event.action.type === "success")
- published.push(event.query.state.data);
- });
+ if (recoveryRequests > 0) {
+ recoveryRequests += 1;
+ await blocked;
- const { result } = renderHook(
- () => {
- const accounts = useBrokerageAccounts();
+ return HttpResponse.json(resource([], { dataRevision: "old" }));
+ }
- return {
- accounts,
- sync: useScreenSync({ screen: "accounts" }, [accounts]),
- };
- },
- { wrapper },
- );
+ recoveryRequests += 1;
- await waitFor(() => expect(result.current.accounts.isError).toBe(true));
- let recovery!: Promise;
- act(() => {
- recovery = result.current.sync.retry();
- });
- // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
- if (pending) await waitFor(() => expect(isRecovering).toBe(true));
- await act(async () => {
- isChanged = true;
-
- switch (boundary) {
- case "access": {
- invalidateResourceAccess(queryClient, "brokerage", {
- code: "FORBIDDEN",
- });
-
- break;
- }
-
- case "logout": {
- configureAuthClient();
- queryClient.clear();
-
- break;
- }
-
- case "session": {
- auth();
- queryClient.clear();
-
- break;
- }
-
- case "connection": {
- commitTbankConnectionBoundary(queryClient, {
- connected: true,
- connectedAt: "2026-09-27T00:00:00Z",
- });
- }
- }
-
- release();
- await recovery;
- });
- if (boundary === "connection")
- await waitFor(() =>
- // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
- expect(result.current.accounts.meta?.dataRevision).toBe("current"),
+ return HttpResponse.json(
+ resource(null, {
+ sync: {
+ errorCode: "TBANK_UNAVAILABLE",
+ id: "failed",
+ state: "failed",
+ },
+ }),
+ );
+ }),
);
- expect(published).not.toContainEqual(
- expect.objectContaining({
- meta: expect.objectContaining({ dataRevision: "old" }) as unknown,
- }),
- );
- unsubscribe();
+ const { queryClient, wrapper } = createQueryWrapper();
+ const published: unknown[] = [];
+
+ const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
+ if (event.type === "updated" && event.action.type === "success")
+ published.push(event.query.state.data);
+ });
+
+ const { result } = renderHook(
+ () => {
+ const accounts = useBrokerageAccounts();
+
+ return {
+ accounts,
+ sync: useScreenSync({ screen: "accounts" }, [accounts]),
+ };
+ },
+ { wrapper },
+ );
+
+ await waitFor(() =>
+ expect(result.current.accounts.meta?.sync.state).toBe("failed"),
+ );
+ vi.useFakeTimers();
+ if (pending)
+ await act(() => vi.advanceTimersByTimeAsync(5000));
+ expect(recoveryRequests).toBeGreaterThanOrEqual(1);
+
+ await act(async () => {
+ isChanged = true;
+
+ switch (boundary) {
+ case "access": {
+ invalidateResourceAccess(queryClient, "brokerage", {
+ code: "FORBIDDEN",
+ });
+
+ break;
+ }
+
+ case "logout": {
+ configureAuthClient();
+ queryClient.clear();
+
+ break;
+ }
+
+ case "session": {
+ auth();
+ queryClient.clear();
+
+ break;
+ }
+
+ case "connection": {
+ commitTbankConnectionBoundary(queryClient, {
+ connected: true,
+ connectedAt: "2026-09-27T00:00:00Z",
+ });
+ }
+ }
+
+ release();
+ if (!pending) await vi.advanceTimersByTimeAsync(5000);
+ });
+ vi.useRealTimers();
+ if (boundary === "connection" && !pending)
+ await waitFor(() =>
+ // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
+ expect(result.current.accounts.meta?.dataRevision).toBe("current"),
+ );
+ expect(published).not.toContainEqual(
+ expect.objectContaining({
+ meta: expect.objectContaining({ dataRevision: "old" }) as unknown,
+ }),
+ );
+ unsubscribe();
+ }
+ finally {
+ vi.useRealTimers();
+ }
},
);
it.each([false, true])(
- "recovers an initial GET failure after forced sync without metadata (status retry=%s)",
+ "recovers an initial GET failure without forced sync (status retry=%s)",
async (retryStatus) => {
auth();
const modes: (null | string)[] = [];
+ let posts = 0;
let polls = 0;
- server.use(
- http.get("*/api/v1/brokerage-accounts", ({ request }) => {
- modes.push(new URL(request.url).searchParams.get("sync"));
- return modes.length === 1
- ? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 })
- : HttpResponse.json(resource([]));
- }),
- http.post("*/api/v1/tbank-sync", () =>
- HttpResponse.json({ id: "recover" }, { status: 202 }),
- ),
- http.get("*/api/v1/tbank-sync/:id", () => {
- polls += 1;
+ try {
+ server.use(
+ http.get("*/api/v1/brokerage-accounts", ({ request }) => {
+ modes.push(new URL(request.url).searchParams.get("sync"));
- return retryStatus && polls === 1
- ? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 })
- : HttpResponse.json({
- id: "recover",
- state: "succeeded",
- });
- }),
- );
- const { queryClient, wrapper } = createQueryWrapper();
+ return modes.length === 1
+ ? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 })
+ : HttpResponse.json(resource([]));
+ }),
+ http.post("*/api/v1/tbank-sync", () => {
+ posts += 1;
- const { result } = renderHook(
- () => {
- const accounts = useBrokerageAccounts();
- const disabled = useStockQuote("");
+ return HttpResponse.json({ id: "recover" }, { status: 202 });
+ }),
+ http.get("*/api/v1/tbank-sync/:id", () => {
+ polls += 1;
- return {
- accounts,
- sync: useScreenSync({ screen: "accounts" }, [accounts, disabled]),
- };
- },
- { wrapper },
- );
-
- await waitFor(() => expect(result.current.accounts.isError).toBe(true));
- expect(result.current.accounts.meta).toBeUndefined();
- await act(() => result.current.sync.refresh());
-
- if (retryStatus) {
- await waitFor(() =>
- // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
- expect(result.current.sync.transportError).toBe(true),
+ return retryStatus && polls === 1
+ ? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 })
+ : HttpResponse.json({
+ id: "recover",
+ state: "succeeded",
+ });
+ }),
);
- await act(() => result.current.sync.retry());
+ const { queryClient, wrapper } = createQueryWrapper();
+
+ const { result } = renderHook(
+ () => {
+ const accounts = useBrokerageAccounts();
+ const disabled = useStockQuote("");
+
+ return {
+ accounts,
+ sync: useScreenSync({ screen: "accounts" }, [accounts, disabled]),
+ };
+ },
+ { wrapper },
+ );
+
+ await waitFor(
+ () => expect(result.current.accounts.isSuccess).toBe(true),
+ { timeout: 3000 },
+ );
+ expect(result.current.accounts.data).toEqual([]);
+
+ expect(modes).toEqual([null, null]);
+ expect(posts).toBe(0);
+ expect(polls).toBe(0);
+ expect(
+ queryClient
+ .getQueryCache()
+ .findAll({ queryKey: ["get", "/api/v1/brokerage-accounts"] }),
+ ).toHaveLength(1);
+ }
+ finally {
+ vi.useRealTimers();
}
-
- await waitFor(() => expect(result.current.accounts.isSuccess).toBe(true));
- expect(result.current.accounts.data).toEqual([]);
-
- expect(modes[0]).toBeNull();
- expect(modes.slice(1).every(mode => mode === "observe")).toBe(true);
- expect(
- queryClient
- .getQueryCache()
- .findAll({ queryKey: ["get", "/api/v1/brokerage-accounts"] }),
- ).toHaveLength(1);
},
);
it("posts only its scope once and observes completion without importing again", async () => {
diff --git a/src/features/tbank-connection/model/screen-sync.ts b/src/features/tbank-connection/model/screen-sync.ts
index 00db54a..0809222 100644
--- a/src/features/tbank-connection/model/screen-sync.ts
+++ b/src/features/tbank-connection/model/screen-sync.ts
@@ -1,4 +1,9 @@
-import { type QueryKey, useQueryClient } from "@tanstack/react-query";
+import {
+ focusManager,
+ onlineManager,
+ type QueryKey,
+ useQueryClient,
+} from "@tanstack/react-query";
import { useEffect, useRef } from "react";
import {
@@ -21,9 +26,39 @@ type ScreenResource = {
meta?: InvestmentResourceMeta;
observe: () => Promise;
queryKey: QueryKey;
+ refetch: () => Promise;
};
const STATUS_INTERVAL_MS = 1000;
+const FIRST_SOURCE_RECOVERY_DELAY_MS = 5000;
+const SECOND_SOURCE_RECOVERY_DELAY_MS = 15_000;
+
+function requiresSourceRecovery(meta?: InvestmentResourceMeta): boolean {
+ if (!meta || meta.unavailableReason) return false;
+
+ return ["cancelled", "failed"].includes(meta.sync.state)
+ || meta.availability === "missing"
+ || meta.freshness === "stale"
+ || (meta.dependencies ?? []).some(dependency =>
+ requiresSourceRecovery(dependency),
+ );
+}
+
+function sourceRecoveryDelay(attempt: number): number | undefined {
+ switch (attempt) {
+ case 0: {
+ return FIRST_SOURCE_RECOVERY_DELAY_MS;
+ }
+
+ case 1: {
+ return SECOND_SOURCE_RECOVERY_DELAY_MS;
+ }
+
+ default: {
+ return undefined;
+ }
+ }
+}
function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
const mutation = apiQueryClient.useMutation("post", "/api/v1/tbank-sync", {
@@ -35,6 +70,9 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
const scopeKey = JSON.stringify(scope);
const requestInFlightRef = useRef(null);
+ const lifecycleRecoveryRef = useRef(new Set());
+ const sourceRecoveryRef = useRef(new Map());
+ const sourceTimersRef = useRef(new Set>());
const isCurrentMutation
= JSON.stringify(mutation.variables?.body) === scopeKey;
@@ -110,6 +148,73 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
void resource.observe().catch(() => {});
}, [client, statuses, resources, scopeKey]);
+ useEffect(() => {
+ const timers = sourceTimersRef.current;
+ const attempts = sourceRecoveryRef.current;
+
+ return () => {
+ for (const timer of timers) clearTimeout(timer);
+ timers.clear();
+ attempts.clear();
+ };
+ }, [scopeKey]);
+
+ useEffect(() => {
+ for (const resource of resources) {
+ if (
+ !requiresSourceRecovery(resource.meta)
+ || resource.isFetching
+ ) continue;
+
+ const key = JSON.stringify([scopeKey, resource.queryKey]);
+ const attempt = sourceRecoveryRef.current.get(key) ?? 0;
+ const delay = sourceRecoveryDelay(attempt);
+ if (delay === undefined) continue;
+
+ sourceRecoveryRef.current.set(key, attempt + 1);
+
+ const timer = setTimeout(() => {
+ sourceTimersRef.current.delete(timer);
+ void resource.refetch().catch(() => {});
+ }, delay);
+
+ sourceTimersRef.current.add(timer);
+ }
+ }, [resources, scopeKey]);
+
+ useEffect(() => {
+ const recover = () => {
+ for (const resource of resources) {
+ if (
+ !requiresSourceRecovery(resource.meta)
+ || resource.isFetching
+ ) continue;
+
+ const key = JSON.stringify([scopeKey, resource.queryKey]);
+ if (lifecycleRecoveryRef.current.has(key)) continue;
+
+ sourceRecoveryRef.current.delete(key);
+ lifecycleRecoveryRef.current.add(key);
+ void resource.refetch()
+ .catch(() => {})
+ .finally(() => lifecycleRecoveryRef.current.delete(key));
+ }
+ };
+
+ const unsubscribeFocus = focusManager.subscribe((focused) => {
+ if (focused) recover();
+ });
+
+ const unsubscribeOnline = onlineManager.subscribe((online) => {
+ if (online) recover();
+ });
+
+ return () => {
+ unsubscribeFocus();
+ unsubscribeOnline();
+ };
+ }, [resources, scopeKey]);
+
const isRefreshing
= (isCurrentMutation && mutation.isPending)
|| statuses.some(
diff --git a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.test.tsx b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.test.tsx
index 6d7d1b6..334d210 100644
--- a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.test.tsx
+++ b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.test.tsx
@@ -127,9 +127,11 @@ describe("TbankConnectionSection", () => {
const token = screen.getByLabelText("Token T-Bank");
fireEvent.change(token, { target: { value: "replacement-draft" } });
finishRefetch?.();
- expect(
- await screen.findByText(/Не удалось обновить статус подключения/),
- ).toBeVisible();
+ await waitFor(() => {
+ expect(
+ screen.queryByText(/Не удалось обновить статус подключения/),
+ ).not.toBeInTheDocument();
+ });
expect(screen.getByLabelText("Token T-Bank")).toBe(token);
expect(token).toHaveValue("replacement-draft");
expect(screen.getByText("Подключено с 14.08.2026")).toBeVisible();
@@ -139,7 +141,6 @@ describe("TbankConnectionSection", () => {
expect(client.getQueryData(portfolioQueryKey)).toEqual({
source: "previous-connection",
});
- fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
await waitFor(() => expect(reads).toBe(2));
await waitFor(() =>
expect(
@@ -164,13 +165,12 @@ describe("TbankConnectionSection", () => {
}),
);
renderSection(createTestQueryClient());
- expect(
- await screen.findByText(/Не удалось загрузить статус подключения/),
- ).toBeVisible();
- expect(screen.queryByLabelText("Token T-Bank")).not.toBeInTheDocument();
- fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
- expect(await screen.findByLabelText("Token T-Bank")).toBeVisible();
- expect(reads).toBe(2);
+ await waitFor(() => {
+ expect(
+ screen.queryByText(/Не удалось загрузить статус подключения/),
+ ).not.toBeInTheDocument();
+ });
+ expect(reads).toBe(1);
});
it("commits connection status and resets the complete brokerage query subtree after connecting", async () => {
const client = seededClient();
diff --git a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.vm.ts b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.vm.ts
index 5ec16f6..ac354e5 100644
--- a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.vm.ts
+++ b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.vm.ts
@@ -1,3 +1,4 @@
+import { notifications } from "@mantine/notifications";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
@@ -32,6 +33,12 @@ function useTbankConnectionSectionLoadedVm(status: TbankConnectionStatus) {
onSuccess: (data) => {
commitTbankConnectionBoundary(queryClient, data);
reset();
+
+ if (status.connected && data.connected)
+ notifications.show({
+ id: "tbank-token-replaced-" + crypto.randomUUID(),
+ message: t("tbank.tokenReplaced"),
+ });
},
});
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 4a75e0c..33015bf 100644
--- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx
+++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx
@@ -3,7 +3,6 @@ import type { FC } from "react";
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import {
- act,
fireEvent,
render,
renderHook,
@@ -115,7 +114,6 @@ describe("Upcoming event date filters", () => {
"keeps event calendar dates in GET and source sync requests: %s",
async (search) => {
const requested: string[] = [];
- const syncRequests: unknown[] = [];
server.use(
http.get("*/api/v1/brokerage-accounts/all/events", ({ request }) => {
requested.push(new URL(request.url).search);
@@ -133,16 +131,7 @@ describe("Upcoming event date filters", () => {
),
);
}),
- http.post("*/api/v1/tbank-sync", async ({ request }) => {
- syncRequests.push(await request.json());
-
- return HttpResponse.json(
- { code: "TBANK_UNAVAILABLE" },
- { status: 503 },
- );
- }),
);
- const period = Object.fromEntries(new URLSearchParams(search));
const queryClient = createTestQueryClient();
const { result } = renderHook(
@@ -162,15 +151,6 @@ describe("Upcoming event date filters", () => {
await waitFor(() => expect(result.current.state).toBe("error"));
expect(requested).toEqual([`?${search}`]);
- expect(syncRequests).toEqual([]);
- act(() => {
- if (result.current.state === "error") result.current.retry();
- });
- await waitFor(() =>
- expect(syncRequests).toEqual([
- { accountId: "all", screen: "events", ...period },
- ]),
- );
},
);
@@ -232,7 +212,7 @@ describe("Upcoming event date filters", () => {
),
);
renderEvents(accountId, ["?from=2099-02-01"]);
- await screen.findByRole("button", { name: "Повторить" });
+ await screen.findByRole("button", { name: "Сбросить" });
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
await screen.findByLabelText("Период");
expect(screen.getByLabelText("URL")).toBeEmptyDOMElement();
@@ -300,7 +280,7 @@ describe("Upcoming event date filters", () => {
);
});
-it("retains the previous complete forecast and timestamp when its source refresh fails", async () => {
+it("retains the previous complete forecast when its source refresh fails", async () => {
configureAuthClient({
accessToken: () => "synthetic",
clear: vi.fn(),
@@ -343,9 +323,6 @@ it("retains the previous complete forecast and timestamp when its source refresh
await waitFor(() =>
expect(result.current).toMatchObject({
events,
- lastSuccessfulAt: snapshot.meta.lastSuccessfulAt,
- refreshError: true,
- sourceError: true,
state: "loaded",
}),
);
diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx
index 9308ae7..4bfa29e 100644
--- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx
+++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx
@@ -159,8 +159,7 @@ describe("AccountEventsPage", () => {
expect(screen.getAllByText("O")).toHaveLength(2);
});
- it("renders empty and retryable error states", () => {
- const refetch = vi.fn();
+ it("renders empty and quiet error states", async () => {
const { rerender } = renderPage();
expect(
screen.getByText("За выбранный период событий не найдено"),
@@ -169,7 +168,6 @@ describe("AccountEventsPage", () => {
data: undefined,
isError: true,
isLoading: false,
- refetch,
} as never);
rerender(
@@ -178,8 +176,8 @@ describe("AccountEventsPage", () => {
,
);
- fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
- expect(refresh).toHaveBeenCalledOnce();
+ expect(await screen.findByRole("button", { name: "Сбросить" })).toBeVisible();
+ expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
});
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 f9707e0..ce2a856 100644
--- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts
+++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts
@@ -6,25 +6,20 @@ import {
useBrokerageEvents,
} from "@/entities/brokerage-event";
import { useScreenSync } from "@/features/tbank-connection";
-import { isValidPeriod, useUrlPeriod } from "@/shared/lib";
+import { isValidPeriod, useInitialAvailability, useUrlPeriod } from "@/shared/lib";
type AccountEventsPageViewModel
= | {
canApply: boolean;
draftPeriod: [null | string, null | string];
events: BrokerageEvent[];
- lastSuccessfulAt?: null | string;
onApply: () => void;
onPeriodChange: (value: [null | string, null | string]) => void;
onReset: () => void;
partial?: boolean;
- refreshError: boolean;
- refreshing: boolean;
- retry: () => void;
- sourceError?: boolean;
state: "loaded";
}
- | { onReset: () => void; retry: () => void; state: "error" }
+ | { onReset: () => void; state: "error" }
| { state: "loading" };
function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
@@ -56,7 +51,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
...(appliedTo && { to: appliedTo }),
});
- const sync = useScreenSync(
+ useScreenSync(
{
accountId,
screen: "events",
@@ -66,7 +61,15 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
[events],
);
- if (events.isLoading) return { state: "loading" };
+ const availability = useInitialAvailability({
+ hasContent: !!events.data,
+ identity: scope,
+ isError: events.isError || events.meta?.sync.state === "failed",
+ isFetching: events.isFetching,
+ });
+
+ if (availability === "loading" && events.isLoading)
+ return { state: "loading" };
const onReset = () => {
setDraftPeriod([null, null]);
@@ -76,12 +79,12 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
if ((events.isError || events.meta?.sync.state === "failed") && !events.data)
return {
onReset,
- retry: () => {
- void sync.retry();
- },
state: "error",
};
- if (!events.data) return { state: "loading" };
+ if (!events.data)
+ return availability === "unavailable"
+ ? { onReset, state: "error" }
+ : { state: "loading" };
return {
canApply:
@@ -89,7 +92,6 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
&& isValidPeriod(draftFrom, draftTo),
draftPeriod,
events: events.data.events,
- lastSuccessfulAt: events.meta?.lastSuccessfulAt,
onApply: () => {
if (!isValidPeriod(draftFrom, draftTo)) return;
setSearchParameters({
@@ -100,12 +102,6 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
onPeriodChange: setDraftPeriod,
onReset,
partial: events.meta?.availability !== "complete",
- refreshError: events.isError || sync.sourceError || sync.transportError,
- refreshing: events.isFetching || sync.refreshing,
- retry: () => {
- void sync.retry();
- },
- sourceError: sync.sourceError,
state: "loaded",
};
}
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 9630812..86666b5 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,7 @@ import { Button, Group } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
-type AccountEventsPageErrorProps = { onReset: () => void; retry: () => void };
+type AccountEventsPageErrorProps = { onReset: () => void };
const AccountEventsPageError: FC = (props) => {
const { t } = useTranslation();
@@ -13,8 +13,6 @@ const AccountEventsPageError: FC = (props) => {
- {t("events.retry")}
-
{t("ledger.reset")}
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 624ab0c..dd0743c 100644
--- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx
+++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx
@@ -4,7 +4,6 @@ import {
Alert,
Button,
Group,
- Progress,
Stack,
Text,
Title,
@@ -16,7 +15,6 @@ import {
BrokerageEventCard,
brokerageEventKey,
} from "@/entities/brokerage-event";
-import { SourceSyncStatus } from "@/shared/ui";
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
@@ -55,32 +53,10 @@ const AccountEventsPageLoaded: FC = (props) => {
return (
-
-
{t("events.title")}
-
- {t("investment.refresh")}
-
-
{!!props.partial && {t("investment.partial")}}
- {!!props.refreshing && }
-
- {!!props.refreshError && (
-
- {t(
- props.sourceError
- ? "investment.syncFailed"
- : "accounts.refreshError",
- )}
-
-
- {t("accounts.retry")}
-
-
- )}
-
{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 106aef7..8d1676d 100644
--- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx
+++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx
@@ -195,7 +195,6 @@ describe("AccountIncomePage", () => {
it("keeps income calendar dates in GET and source sync requests", async () => {
const requested: string[] = [];
- const syncRequests: unknown[] = [];
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/income-analytics",
@@ -216,30 +215,11 @@ describe("AccountIncomePage", () => {
);
},
),
- http.post("*/api/v1/tbank-sync", async ({ request }) => {
- syncRequests.push(await request.json());
-
- return HttpResponse.json(
- { code: "TBANK_UNAVAILABLE" },
- { status: 503 },
- );
- }),
);
renderPage("from=2024-02-29&to=2024-03-01");
- const retry = await screen.findByRole("button", { name: "Повторить" });
+ await screen.findByText("Не удалось рассчитать пассивный доход");
expect(requested).toEqual(["?from=2024-02-29&to=2024-03-01"]);
- expect(syncRequests).toEqual([]);
- fireEvent.click(retry);
- await waitFor(() =>
- expect(syncRequests).toEqual([
- {
- accountId: "account-1",
- from: "2024-02-29",
- screen: "income",
- to: "2024-03-01",
- },
- ]),
- );
+ expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
});
it("requests a multi-year period from the URL without resetting it", async () => {
@@ -486,7 +466,7 @@ describe("AccountIncomePage", () => {
).toBeVisible();
});
- it("shows a recoverable realized error", async () => {
+ it("shows a quiet realized error", async () => {
server.use(
http.get(
"*/api/v1/brokerage-accounts/account-1/income-analytics",
@@ -495,8 +475,11 @@ describe("AccountIncomePage", () => {
);
renderPage();
expect(
- await screen.findByRole("button", { name: "Повторить" }),
+ await screen.findByText("Не удалось рассчитать пассивный доход", undefined, {
+ timeout: 4000,
+ }),
).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
});
});
@@ -631,7 +614,7 @@ describe("income period and snapshot boundary", () => {
expect(requests).toEqual(["?from=2026-09-01&to=2026-09-01"]);
});
- it("keeps valid data and draft during refetch, warns on failure and retries", async () => {
+ it("keeps valid data and draft during refetch failure", async () => {
respondWithRequestedPeriod([]);
const { queryClient } = renderPage();
await screen.findByLabelText("Период");
@@ -653,51 +636,13 @@ describe("income period and snapshot boundary", () => {
act(() => {
void queryClient.invalidateQueries();
});
- expect(
- await screen.findByRole("status", { name: "Обновляем пассивный доход" }),
- ).toBeInTheDocument();
expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible();
expect(screen.getByLabelText("Период")).toHaveTextContent(
"02.08.2026 – 27.08.2026",
);
release();
- expect(
- await screen.findByText(/Показан последний загруженный результат/),
- ).toBeInTheDocument();
expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible();
- server.use(
- http.post("*/api/v1/tbank-sync", () =>
- HttpResponse.json(
- {
- id: "income",
- now: "2026-09-25T00:00:00Z",
- scope: { accountId: "account-1", screen: "income" },
- },
- { status: 202 },
- ),
- ),
- http.get("*/api/v1/tbank-sync/:id", () =>
-
- HttpResponse.json(
- {
- errorCode: null,
- id: "income",
- now: "2026-09-25T00:00:00Z",
- scope: null,
- state: "succeeded",
- },
- ),
- ),
- http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
- HttpResponse.json(resource(analytics, { dataRevision: "2" })),
- ),
- );
- fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
- await waitFor(() =>
- expect(
- screen.queryByText(/Показан последний загруженный результат/),
- ).not.toBeInTheDocument(),
- );
+ expect(screen.queryByText(/Показан последний загруженный результат/)).not.toBeInTheDocument();
expect(screen.getByLabelText("Период")).toHaveTextContent(
"02.08.2026 – 27.08.2026",
);
diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts
index 0d52a74..7d4bc42 100644
--- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts
+++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts
@@ -5,7 +5,7 @@ import { useParams, useSearchParams } from "react-router";
import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
import { useScreenSync } from "@/features/tbank-connection";
-import { isValidPeriod, moscowDate } from "@/shared/lib";
+import { isValidPeriod, moscowDate, useInitialAvailability } from "@/shared/lib";
import {
type IncomeDisplayModel,
@@ -27,19 +27,13 @@ type AccountIncomePageViewModel
display: IncomeDisplayModel;
form: ReturnType>;
formRevision: string;
- lastSuccessfulAt?: null | string;
onReset: () => void;
onSubmit: (values: IncomeForm) => void;
partial?: boolean;
- refreshError: boolean;
- refreshing: boolean;
- retry: () => Promise | undefined;
- sourceError?: boolean;
state: "loaded";
}
| {
period: IncomePeriod;
- retry: () => Promise | undefined;
state: "error";
}
| { state: "loading" };
@@ -69,7 +63,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
const [resetRevision, setResetRevision] = useState(0);
const analytics = useIncomeAnalytics(accountId, period);
- const sync = useScreenSync(
+ useScreenSync(
{ accountId, from: period.from, screen: "income", to: period.to },
[analytics],
);
@@ -82,11 +76,20 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
form.reset(draftPeriod(period));
}, [accountId, form, period]);
- if (analytics.isLoading || !analytics.data) {
- return analytics.isError || analytics.meta?.sync.state === "failed"
- ? { period, retry: sync.retry, state: "error" }
+ const availability = useInitialAvailability({
+ hasContent: !!analytics.data,
+ identity: `${accountId}:${period.from}:${period.to}`,
+ isError: analytics.isError || analytics.meta?.sync.state === "failed",
+ isFetching: analytics.isFetching,
+ });
+
+ if (availability === "loading" && analytics.isLoading)
+ return { state: "loading" };
+
+ if (!analytics.data)
+ return availability === "unavailable"
+ ? { period, state: "error" }
: { state: "loading" };
- }
const display = toIncomeDisplayModel(analytics.data, analytics.meta);
@@ -109,14 +112,9 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
display,
form,
formRevision: `${accountId}:${period.from}:${period.to}:${resetRevision}`,
- lastSuccessfulAt: analytics.meta?.lastSuccessfulAt,
onReset,
onSubmit,
partial: analytics.meta?.availability !== "complete",
- refreshError: analytics.isError || sync.sourceError || sync.transportError,
- refreshing: analytics.isFetching || sync.refreshing,
- retry: sync.retry,
- sourceError: sync.sourceError,
state: "loaded",
};
};
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 e4603ea..bdaf3bd 100644
--- a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageError.view.tsx
+++ b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageError.view.tsx
@@ -1,6 +1,5 @@
import type { FC } from "react";
-import { Button } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
@@ -8,12 +7,11 @@ import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
type ErrorProps = Extract;
-const AccountIncomePageError: FC = (props) => {
+const AccountIncomePageError: FC = () => {
const { t } = useTranslation();
return (
{t("income.retry")}}
error
text={t("income.errorText")}
title={t("income.errorTitle")}
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 219c1f6..50db1bc 100644
--- a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx
+++ b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx
@@ -2,19 +2,13 @@ import type { FC } from "react";
import {
Alert,
- Button,
- Group,
- Loader,
Stack,
- Text,
Title,
} from "@mantine/core";
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";
@@ -40,21 +34,14 @@ const AccountIncomePageLoadedSummary: FC = (props) => {
};
const AccountIncomePageLoaded: FC = (props) => {
- const { display, form, onReset, onSubmit, refreshError, refreshing, retry }
- = props;
+ const { display, form, onReset, onSubmit } = props;
const { t } = useTranslation();
return (
-
-
{t("income.title")}
-
- {t("investment.refresh")}
-
-
= (props) => {
/>
- {!!refreshing && (
-
-
-
- {t("income.refreshing")}
-
- )}
-
- {!!refreshError && (
-
-
-
- {t(
- props.sourceError
- ? "investment.syncFailed"
- : "income.refreshError",
- )}
-
-
-
- {t("income.retry")}
-
-
-
- )}
-
{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 ab4f146..6a0f9fe 100644
--- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx
+++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx
@@ -346,7 +346,7 @@ describe("AccountOperationsPage", () => {
,
);
- await screen.findByRole("button", { name: "Повторить" });
+ await screen.findByRole("button", { name: "Сбросить" });
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
await screen.findByLabelText("Период");
expect(screen.getByLabelText("URL")).toBeEmptyDOMElement();
@@ -484,7 +484,9 @@ describe("AccountOperationsPage", () => {
);
renderPage();
expect(
- await screen.findByText("Не удалось загрузить журнал"),
+ await screen.findByText("Не удалось загрузить журнал", undefined, {
+ timeout: 4000,
+ }),
).toBeInTheDocument();
});
});
@@ -520,7 +522,7 @@ it("keeps UTC operation boundaries in GET and source sync requests", async () =>
}),
);
renderPage("?from=2024-02-29&to=2024-03-01&categories=trade");
- const retry = await screen.findByRole("button", { name: "Повторить" });
+ await screen.findByRole("button", { name: "Сбросить" });
expect(requested).toEqual([
{
categories: "trade",
@@ -530,19 +532,6 @@ it("keeps UTC operation boundaries in GET and source sync requests", async () =>
},
]);
expect(syncRequests).toEqual([]);
- fireEvent.click(retry);
- await waitFor(() =>
- expect(syncRequests).toEqual([
- {
- accountId: "account-1",
- categories: ["trade"],
- from: "2024-02-29T00:00:00.000Z",
- limit: 100,
- screen: "operations",
- to: "2024-03-01T23:59:59.999Z",
- },
- ]),
- );
});
it("does not label incomplete empty operations as a confirmed empty period", async () => {
@@ -595,7 +584,6 @@ it("recovers an invalid cursor at the first page without clearing applied filter
);
renderPage("?from=2026-08-01&to=2026-08-14&categories=trade");
fireEvent.click(await screen.findByRole("button", { name: "Далее" }));
- fireEvent.click(await screen.findByRole("button", { name: "Повторить" }));
await waitFor(() => expect(searches).toHaveLength(3));
assert.isDefined(searches[0]);
assert.isDefined(searches[2]);
diff --git a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts
index 6b9f5fe..db29494 100644
--- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts
+++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts
@@ -11,7 +11,7 @@ import {
} from "@/entities/broker-operation";
import { useScreenSync } from "@/features/tbank-connection";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
-import { isValidPeriod, useUrlPeriod } from "@/shared/lib";
+import { isValidPeriod, useInitialAvailability, useUrlPeriod } from "@/shared/lib";
const operationCategorySet = new Set(operationCategories);
@@ -24,7 +24,6 @@ type AccountOperationsPageViewModel
hasPrevious: boolean;
historyLoading?: boolean;
items: BrokerageOperation[];
- lastSuccessfulAt?: null | string;
onApply: () => void;
onCategoriesChange: (value: string[]) => void;
onNext: () => void;
@@ -32,13 +31,9 @@ type AccountOperationsPageViewModel
onPrevious: () => void;
onReset: () => void;
partial?: boolean;
- refreshError?: boolean;
- refreshing?: boolean;
- retry?: () => void;
- sourceError?: boolean;
state: "loaded";
}
- | { onReset: () => void; retry: () => void; state: "error" }
+ | { onReset: () => void; state: "error" }
| { state: "loading" };
const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
@@ -85,19 +80,28 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
history.reset();
}
- const sync = useScreenSync(
+ useScreenSync(
{ accountId, screen: "operations", ...firstPageQuery },
[operations],
);
- if (operations.isLoading) return { state: "loading" };
+ const availability = useInitialAvailability({
+ hasContent: !!operations.data,
+ identity: scope,
+ isError: operations.isError || operations.meta?.sync.state === "failed",
+ isFetching: operations.isFetching,
+ });
+
+ if (availability === "loading" && operations.isLoading)
+ return { state: "loading" };
+
+ if (history.isRecoveringInvalidCursor)
+ return { state: "loading" };
const resetQueryKey = brokerageOperationsQueryOptions(accountId, {
limit: 100,
}).queryKey;
- const retry = () => history.retry(sync.retry);
-
const onReset = () => {
setDraftPeriod([null, null]);
setDraftCategories([]);
@@ -106,16 +110,22 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
void queryClient.invalidateQueries({ queryKey: resetQueryKey });
};
+ const isInvalidCursor = isApiErrorCode(
+ apiErrorCode(operations.error),
+ ApiErrorCode.InvalidOperationsCursor,
+ );
+
if (
- (operations.isError || operations.meta?.sync.state === "failed")
- && (!operations.data
- || isApiErrorCode(
- apiErrorCode(operations.error),
- ApiErrorCode.InvalidOperationsCursor,
- ))
+ (isInvalidCursor || !operations.data)
+ && (operations.isError || operations.meta?.sync.state === "failed")
)
- return { onReset, retry, state: "error" };
- if (!operations.data) return { state: "loading" };
+ return { onReset, state: "error" };
+
+ if (!operations.data)
+ return availability === "unavailable"
+ ? { onReset, state: "error" }
+ : { state: "loading" };
+
const nextCursor = operations.data.nextCursor;
const isDirty
@@ -133,7 +143,6 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
operations.meta?.coverage?.historyState === "loading"
|| operations.meta?.coverage?.historyState === "partial",
items: operations.data.items,
- lastSuccessfulAt: operations.meta?.lastSuccessfulAt,
onApply: () => {
if (!isValidPeriod(draftFrom, draftTo)) return;
setSearchParameters({
@@ -155,10 +164,6 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
onPrevious: history.onPrevious,
onReset,
partial: operations.meta?.availability !== "complete",
- refreshError: operations.isError || sync.sourceError || sync.transportError,
- refreshing: operations.isFetching || sync.refreshing,
- retry,
- sourceError: sync.sourceError,
state: "loaded",
};
};
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 fd1fdf6..a45e4a0 100644
--- a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageError.view.tsx
+++ b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageError.view.tsx
@@ -1,6 +1,6 @@
import type { FC } from "react";
-import { Button, Group } from "@mantine/core";
+import { Button } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
@@ -14,21 +14,15 @@ type AccountOperationsPageErrorProps = Extract<
const AccountOperationsPageError: FC = (
props,
) => {
- const { retry } = props;
const { t } = useTranslation();
return (
- {t("ledger.retry")}
-
-
- {t("ledger.reset")}
-
-
+
+ {t("ledger.reset")}
+
)}
- error
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 504c644..18a69e7 100644
--- a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx
+++ b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx
@@ -5,7 +5,6 @@ import {
Button,
Group,
MultiSelect,
- Progress,
Stack,
Title,
} from "@mantine/core";
@@ -14,7 +13,6 @@ import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import { LedgerEntryRow, operationCategories } from "@/entities/broker-operation";
-import { SourceSyncStatus } from "@/shared/ui";
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
@@ -62,34 +60,12 @@ const AccountOperationsPageLoaded: FC = (
return (
-
-
{t("ledger.title")}
-
- {t("investment.refresh")}
-
-
{!!props.partial && {t("investment.partial")}}
{!!props.historyLoading && {t("investment.history")}}
- {!!props.refreshing && }
-
- {!!props.refreshError && (
-
- {t(
- props.sourceError
- ? "investment.syncFailed"
- : "accounts.refreshError",
- )}
-
-
- {t("accounts.retry")}
-
-
- )}
-
Promise | undefined; state: "error"; to: string }
- | {
state: "empty"
| "loading"
| "partial";
to: string;
- };
+ }
+ | { state: "error"; to: string }
+ | {
+ state: "loaded";
+ summary: IncomeSummary;
+ to: string;
+ }
+ ;
type AccountPageRecentEvents = BrokerageEventsPreviewState;
@@ -57,20 +55,16 @@ type PreviewResource = {
function toIncomeSummary(
accountId: string | undefined,
income: PreviewResource,
- retry: () => Promise,
): AccountPageIncomeSummary {
const to = `/accounts/${accountId}/income`;
if (!income.data && !income.isError && income.meta?.sync.state !== "failed")
return { state: "loading", to };
if (income.meta?.availability === "partial") return { state: "partial", to };
if (!income.data && (income.isError || income.meta?.sync.state === "failed"))
- return { retry, state: "error", to };
+ return { state: "error", to };
if (!income.data?.totalsByCurrency.length) return { state: "empty", to };
return {
- lastSuccessfulAt: income.meta?.lastSuccessfulAt,
- refreshError: income.isError || income.meta?.sync.state === "failed",
- stale: income.meta?.freshness === "stale",
state: "loaded",
summary: income.data,
to,
@@ -97,9 +91,6 @@ function toRecentEvents(
return {
items,
- lastSuccessfulAt: events.meta?.lastSuccessfulAt,
- refreshError: events.isError || events.meta?.sync.state === "failed",
- stale: events.meta?.freshness === "stale",
state: "loaded",
to,
};
diff --git a/src/pages/account/ui/AccountPage/AccountPage.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.test.tsx
index 94afba2..de60722 100644
--- a/src/pages/account/ui/AccountPage/AccountPage.test.tsx
+++ b/src/pages/account/ui/AccountPage/AccountPage.test.tsx
@@ -1,7 +1,6 @@
import { MantineProvider } from "@mantine/core";
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
- fireEvent,
render,
screen,
waitFor,
@@ -261,7 +260,7 @@ describe("AccountPage states", () => {
it.each(["NOT_FOUND", "TBANK_NOT_CONNECTED", "INVALID_TBANK_TOKEN"])(
"does not offer retry without cached data for %s",
(code) => {
- renderPage();
+ renderPage();
expect(
screen.queryByRole("button", { name: "Повторить" }),
).not.toBeInTheDocument();
@@ -274,12 +273,16 @@ describe("AccountPage states", () => {
},
);
- it.each(["TBANK_UNAVAILABLE", "UNKNOWN"])("allows retry for %s", (code) => {
- const retry = vi.fn();
- renderPage();
- fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
- expect(retry).toHaveBeenCalledOnce();
- });
+ it.each(["TBANK_UNAVAILABLE", "UNKNOWN"])(
+ "shows neutral unavailable without retry for %s",
+ (code) => {
+ renderPage();
+ expect(screen.getByRole("heading", { name: "Пока недоступно" }))
+ .toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Повторить" }))
+ .not.toBeInTheDocument();
+ },
+ );
it("shows supplied cash separately without adding currency instruments or recalculating totals", () => {
renderPage(
@@ -405,11 +408,9 @@ describe("AccountPage states", () => {
expect(
screen.getByRole("heading", { level: 1, name: "Портфель" }),
).toBeInTheDocument();
- expect(
- screen.getByText(
- "T-Bank временно недоступен. Показан последний успешный снимок.",
- ),
- ).toBeInTheDocument();
+ expect(screen.queryByText(/T-Bank временно недоступен/))
+ .not.toBeInTheDocument();
+ expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Акции" })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Облигации" }),
@@ -573,7 +574,7 @@ describe("AccountPage states", () => {
);
expect(
- screen.getByText("Не удалось загрузить последние события."),
+ screen.getByText("Пока недоступно"),
).toBeInTheDocument();
expect(screen.getByText("Вторая")).toBeInTheDocument();
});
@@ -742,13 +743,13 @@ describe("AccountPage states", () => {
);
expect(
- screen.getByText("Не удалось загрузить будущие события."),
+ screen.getByText("Данные появятся здесь, когда чтение восстановится."),
).toBeInTheDocument();
expect(screen.getByText("Вторая")).toBeInTheDocument();
});
it.each([503])(
- "preserves totals and UTC snapshot through refresh error %i, retry and success",
+ "preserves totals quietly through refresh error %i and success",
async (status) => {
const initialPortfolio: Portfolio
= {
@@ -821,40 +822,30 @@ describe("AccountPage states", () => {
});
expect(structure).toHaveTextContent("10 ₽");
- const snapshotText = screen.getByText(/Данные получены/).textContent;
- expect(snapshotText).toContain("10:00");
+ expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
void queryClient.invalidateQueries({
queryKey: portfolioQueryOptions("account-1").queryKey,
});
- expect(
- await screen.findByText(
- "Не удалось обновить портфель. Показан последний полученный снимок.",
- ),
- ).toBeInTheDocument();
- expect(
- screen.getByText(
- "T-Bank временно недоступен. Показан последний успешный снимок.",
- ),
- ).toBeInTheDocument();
+ await waitFor(() => expect(requestCount).toBe(2));
+ expect(screen.queryByText(/Не удалось обновить портфель/))
+ .not.toBeInTheDocument();
+ expect(screen.queryByText(/T-Bank временно недоступен/))
+ .not.toBeInTheDocument();
expect(screen.getByText("Вторая")).toBeInTheDocument();
expect(
screen.getAllByRole("definition").map(node => node.textContent),
).toEqual(["1 234,5 ₽", "0,5 $"]);
- // eslint-disable-next-line jest-dom/prefer-to-have-text-content -- The cached timestamp must remain exactly unchanged.
- expect(screen.getByText(/Данные получены/).textContent).toBe(
- snapshotText,
- );
- fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
+ expect(screen.queryByRole("button", { name: "Повторить" }))
+ .not.toBeInTheDocument();
+ void queryClient.invalidateQueries({
+ queryKey: portfolioQueryOptions("account-1").queryKey,
+ });
await waitFor(() => expect(requestCount).toBe(3));
expect(structure).toHaveTextContent("10 ₽");
expect(
screen.getAllByRole("definition").map(node => node.textContent),
).toEqual(["1 234,5 ₽", "0,5 $"]);
- // eslint-disable-next-line jest-dom/prefer-to-have-text-content -- The cached timestamp must remain exactly unchanged.
- expect(screen.getByText(/Данные получены/).textContent).toBe(
- snapshotText,
- );
resolveRetry?.(
HttpResponse.json(
resource(
@@ -875,7 +866,7 @@ describe("AccountPage states", () => {
screen.getAllByRole("definition").map(node => node.textContent),
).toEqual(["-0,5 $", "2,50 €"]),
);
- expect(screen.getByText(/Данные получены/)).toHaveTextContent("11:30");
+ expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
expect(structure).not.toHaveTextContent("10 ₽");
expect(structure).toHaveTextContent("Нет положительной стоимости");
expect(
@@ -916,13 +907,12 @@ describe("AccountPage states", () => {
expect(
screen.getAllByRole("definition").map(node => node.textContent),
).toEqual(["1 234,5 ₽", "0,5 $"]);
- expect(screen.getByText(/Данные получены/)).toHaveTextContent("10:00");
+ expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
expect(
screen.queryByRole("status", { name: "Загрузка" }),
).not.toBeInTheDocument();
- expect(
- screen.getByRole("status", { name: "Обновляем портфель" }),
- ).toBeInTheDocument();
+ expect(screen.queryByRole("status", { name: "Обновляем портфель" }))
+ .not.toBeInTheDocument();
resolveRefresh?.(HttpResponse.json(resource(portfolio)));
});
diff --git a/src/pages/account/ui/AccountPage/AccountPage.tsx b/src/pages/account/ui/AccountPage/AccountPage.tsx
index edf4835..81b5417 100644
--- a/src/pages/account/ui/AccountPage/AccountPage.tsx
+++ b/src/pages/account/ui/AccountPage/AccountPage.tsx
@@ -18,10 +18,6 @@ const AccountPage: FC = () => {
incomeSummary={viewModel.incomeSummary}
recentEvents={viewModel.recentEvents}
recentOperations={viewModel.recentOperations}
- refreshError={viewModel.refreshError}
- refreshing={viewModel.refreshing}
- retry={viewModel.retry}
- sourceError={viewModel.sourceError}
/>
);
@@ -35,10 +31,6 @@ const AccountPage: FC = () => {
positionSections={viewModel.positionSections}
recentEvents={viewModel.recentEvents}
recentOperations={viewModel.recentOperations}
- refreshError={viewModel.refreshError}
- refreshing={viewModel.refreshing}
- retry={viewModel.retry}
- sourceError={viewModel.sourceError}
/>
);
};
diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx
index e0bea05..b6baf4e 100644
--- a/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx
+++ b/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx
@@ -1,7 +1,7 @@
import type { FC, PropsWithChildren } from "react";
import { MantineProvider } from "@mantine/core";
-import { act, renderHook, waitFor } from "@testing-library/react";
+import { renderHook, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { MemoryRouter, Route, Routes } from "react-router";
import { describe, expect, it, vi } from "vitest";
@@ -147,10 +147,10 @@ describe("useAccountPageViewModel", () => {
HttpResponse.json(resource(portfolio)),
),
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
- HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
+ HttpResponse.json({ code: "DEMO_FAILURE" }, { status: 400 }),
),
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
- HttpResponse.json({ code: "MOEX_UNAVAILABLE" }, { status: 503 }),
+ HttpResponse.json({ code: "DEMO_FAILURE" }, { status: 400 }),
),
);
@@ -208,7 +208,7 @@ it("keeps overview sections independent when their source data is incomplete", a
);
});
-it("refreshes every overview section through one account descriptor and observational reads", async () => {
+it("keeps overview sections loaded without exposing technical retry", async () => {
configureTestAuth();
const sections: Record
@@ -219,48 +219,17 @@ it("refreshes every overview section through one account descriptor and observat
portfolio,
};
- const observed: string[] = [];
- const scopes: unknown[] = [];
- let version = "1";
server.use(
...Object.entries(sections).map(([path, data]) =>
http.get(
`*/api/v1/brokerage-accounts/:accountId/${path}`,
- ({ request }) => {
- if (new URL(request.url).searchParams.get("sync") === "observe")
- observed.push(path);
-
+ () => {
return HttpResponse.json(
- resource(data, { dataRevision: version, resourceKey: path }),
+ resource(data, { dataRevision: "1", resourceKey: path }),
);
},
),
),
- http.post("*/api/v1/tbank-sync", async ({ request }) => {
- scopes.push(await request.json());
- version = "2";
-
- return HttpResponse.json(
- {
- id: "overview",
- now: "2026-09-25T00:00:00Z",
- scope: { accountId: "account-1", screen: "account" },
- },
- { status: 202 },
- );
- }),
- http.get("*/api/v1/tbank-sync/:id", () =>
-
- HttpResponse.json(
- {
- errorCode: null,
- id: "overview",
- now: "2026-09-25T00:00:00Z",
- scope: null,
- state: "succeeded",
- },
- ),
- ),
);
const { result } = renderHook(() => useAccountPageViewModel(), {
@@ -275,11 +244,5 @@ it("refreshes every overview section through one account descriptor and observat
state: "loaded",
}),
);
- await act(async () => {
- if (result.current.state === "loaded") await result.current.retry();
- });
- await waitFor(() =>
- expect(new Set(observed)).toEqual(new Set(Object.keys(sections))),
- );
- expect(scopes).toEqual([{ accountId: "account-1", screen: "account" }]);
+ expect(result.current).not.toHaveProperty("retry");
});
diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.ts b/src/pages/account/ui/AccountPage/AccountPage.vm.ts
index 77c0341..675e83f 100644
--- a/src/pages/account/ui/AccountPage/AccountPage.vm.ts
+++ b/src/pages/account/ui/AccountPage/AccountPage.vm.ts
@@ -13,7 +13,8 @@ import {
import { useIncomeSummary } from "@/entities/income";
import { usePortfolio } from "@/entities/portfolio";
import { useScreenSync } from "@/features/tbank-connection";
-import { apiErrorCode } from "@/shared/api";
+import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
+import { useInitialAvailability } from "@/shared/lib";
import {
type AccountPagePositionSection,
@@ -35,10 +36,6 @@ type AccountPageContent = {
partial?: boolean;
recentEvents: AccountPageRecentEvents;
recentOperations: AccountPageRecentOperations;
- refreshError: boolean;
- refreshing: boolean;
- retry: () => Promise | undefined;
- sourceError?: boolean;
};
type AccountPageEmptyViewModel = AccountPageContent & {
@@ -54,9 +51,16 @@ type AccountPageLoadedViewModel = AccountPageContent & {
type AccountPageViewModel
= | AccountPageEmptyViewModel
| AccountPageLoadedViewModel
- | { code?: string; retry: () => Promise | undefined; state: "error" }
+ | { code?: string; state: "error" }
| { state: "loading" };
+function isPortfolioAccessFailure(code?: string) {
+ return isApiErrorCode(code, ApiErrorCode.Forbidden)
+ || isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)
+ || isApiErrorCode(code, ApiErrorCode.NotFound)
+ || isApiErrorCode(code, ApiErrorCode.TbankNotConnected);
+}
+
function useAccountPageViewModel(): AccountPageViewModel {
const { accountId } = useParams();
const portfolio = usePortfolio(accountId);
@@ -69,7 +73,7 @@ function useAccountPageViewModel(): AccountPageViewModel {
const income = useIncomeSummary(accountId);
const accounts = useBrokerageAccounts();
- const sync = useScreenSync({ accountId, screen: "account" }, [
+ useScreenSync({ accountId, screen: "account" }, [
accounts,
portfolio,
operations,
@@ -77,32 +81,28 @@ function useAccountPageViewModel(): AccountPageViewModel {
income,
]);
- if (!portfolio.data) {
- if (portfolio.isError || portfolio.meta?.sync.state === "failed")
- return {
- code: apiErrorCode(portfolio.error),
- retry: sync.retry,
- state: "error" as const,
- };
+ const code = apiErrorCode(portfolio.error);
+ const isAccessFailure = isPortfolioAccessFailure(code);
- return { state: "loading" as const };
- }
+ const availability = useInitialAvailability({
+ hasContent: !!portfolio.data,
+ identity: accountId ?? "account",
+ isError: portfolio.isError || portfolio.meta?.sync.state === "failed",
+ isFetching: portfolio.isFetching,
+ });
+
+ if (isAccessFailure) return { code, state: "error" as const };
+
+ if (!portfolio.data)
+ return {
+ state: availability === "unavailable" ? "error" : "loading",
+ } as const;
const account = accounts.data?.find(item => item.id === accountId);
const recentOperations = toRecentOperations(accountId, operations);
const recentEvents = toRecentEvents(accountId, events);
- const incomeSummary = toIncomeSummary(
- accountId, income, () => income.refetch(),
- );
-
- const refresh = {
- partial: portfolio.meta?.availability === "partial",
- refreshError: portfolio.isError || sync.transportError || sync.sourceError,
- refreshing: portfolio.isFetching || sync.refreshing,
- retry: sync.retry,
- sourceError: sync.sourceError,
- };
+ const incomeSummary = toIncomeSummary(accountId, income);
if (
portfolio.meta?.availability === "complete"
@@ -116,19 +116,20 @@ function useAccountPageViewModel(): AccountPageViewModel {
recentEvents,
recentOperations,
state: "empty" as const,
- ...refresh,
};
+ const isPartial = portfolio.meta?.availability === "partial";
+
return {
account,
accountId,
incomeSummary,
+ partial: isPartial,
portfolio: portfolio.data,
positionSections: toAccountPagePositionSections(portfolio.data),
recentEvents,
recentOperations,
state: "loaded" as const,
- ...refresh,
};
}
diff --git a/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx
index 1d3a28b..bf9c35d 100644
--- a/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx
+++ b/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx
@@ -7,7 +7,6 @@ import { useTranslation } from "react-i18next";
import type { BrokerageAccount } from "@/entities/brokerage-account";
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
-import { InvestmentRefreshState } from "@/features/tbank-connection";
import type {
AccountPageIncomeSummary,
@@ -26,10 +25,6 @@ type AccountPageEmptyProps = {
incomeSummary?: AccountPageIncomeSummary;
recentEvents: AccountPageRecentEvents;
recentOperations: AccountPageRecentOperations;
- refreshError?: boolean;
- refreshing?: boolean;
- retry?: () => void;
- sourceError?: boolean;
};
const AccountPageEmpty: FC = (props) => {
@@ -45,14 +40,6 @@ const AccountPageEmpty: FC = (props) => {
-
-
void };
+type AccountPageErrorProps = { code?: string };
const AccountPageError: FC = (props) => {
- const { code, retry } = props;
+ const { code } = props;
const { t } = useTranslation();
if (isApiErrorCode(code, ApiErrorCode.NotFound))
return (
@@ -25,6 +25,7 @@ const AccountPageError: FC = (props) => {
title={t("portfolio.accountUnavailableTitle")}
/>
);
+
if (
isApiErrorCode(code, ApiErrorCode.TbankNotConnected)
|| isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)
@@ -41,22 +42,11 @@ const AccountPageError: FC = (props) => {
title={t("portfolio.connectionErrorTitle")}
/>
);
- if (isApiErrorCode(code, ApiErrorCode.TbankUnavailable))
- return (
- {t("accounts.retry")}}
- error
- text={t("accounts.unavailableText")}
- title={t("accounts.unavailableTitle")}
- />
- );
return (
{t("accounts.retry")}}
- error
- text={t("states.errorText")}
- title={t("states.errorTitle")}
+ text={t("states.unavailableText")}
+ title={t("states.unavailableTitle")}
/>
);
};
diff --git a/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx
index c302c24..892b1c9 100644
--- a/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx
+++ b/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx
@@ -1,14 +1,12 @@
import type { FC } from "react";
-import { Accordion, Alert, Stack, Text } from "@mantine/core";
+import { Accordion, Alert, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import type { BrokerageAccount } from "@/entities/brokerage-account";
import type { Portfolio } from "@/entities/portfolio";
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
-import { InvestmentRefreshState } from "@/features/tbank-connection";
-import { formatSnapshotTime } from "@/shared/lib";
import type { AccountPagePositionSection } from "../../../model/positions";
import type {
@@ -34,10 +32,6 @@ type AccountPageLoadedProps = {
positionSections: AccountPagePositionSection[];
recentEvents: AccountPageRecentEvents;
recentOperations: AccountPageRecentOperations;
- refreshError?: boolean;
- refreshing?: boolean;
- retry?: () => void;
- sourceError?: boolean;
};
const AccountPageLoaded: FC = (props) => {
@@ -55,27 +49,6 @@ const AccountPageLoaded: FC = (props) => {
-
-
- {!!props.portfolio.isStale && {t("portfolio.staleWarning")}}
-
-
- {t(
- props.accountId === "all"
- ? "portfolio.allUpdatedAt"
- : "portfolio.updatedAt",
- {
- date: formatSnapshotTime(props.portfolio.lastSuccessfulAt),
- },
- )}
-
-
{props.partial
? {t("investment.partial")}
: }
diff --git a/src/pages/account/ui/AccountPage/components/IncomeSummary.view.tsx b/src/pages/account/ui/AccountPage/components/IncomeSummary.view.tsx
index 7a7afcf..56d91a3 100644
--- a/src/pages/account/ui/AccountPage/components/IncomeSummary.view.tsx
+++ b/src/pages/account/ui/AccountPage/components/IncomeSummary.view.tsx
@@ -8,30 +8,30 @@ import { Link } from "react-router";
import type { IncomeSummary as IncomeSummaryData } from "@/entities/income";
import { formatMoney } from "@/shared/lib";
-import { SourceSyncStatus } from "@/shared/ui";
type IncomeSummaryProps
= | {
- lastSuccessfulAt?: null | string;
- refreshError?: boolean;
- stale?: boolean;
+ state: "empty";
+ to: string;
+ }
+ | { state: "error"; to: string }
+ | {
state: "loaded";
summary: IncomeSummaryData;
to: string;
}
- | { retry: () => void; state: "error"; to: string }
- | { state: "empty"; to: string }
- | { state: "loading"; to: string }
- | { state: "partial"; to: string };
+ | {
+ state: "loading";
+ to: string;
+ }
+ | {
+ state: "partial";
+ to: string;
+ };
const IncomeSummary: FC = (props) => {
const { t } = useTranslation();
- const errorText
- = props.state === "loaded" && props.refreshError
- ? t("income.refreshError")
- : null;
-
if (props.state === "loading" || props.state === "partial")
return (
@@ -45,9 +45,6 @@ const IncomeSummary: FC = (props) => {
if (props.state === "error")
return (
{t("income.summaryRetry")}
- }
error
text={t("income.errorText")}
title={t("income.summaryError")}
@@ -66,12 +63,6 @@ const IncomeSummary: FC = (props) => {
return (
-
-
{props.summary.totalsByCurrency.map(total => (
{formatMoney(total.total)}
diff --git a/src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx b/src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx
index 7e6acf1..84d5918 100644
--- a/src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx
+++ b/src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx
@@ -1,12 +1,11 @@
import type { FC } from "react";
-import { Alert, Button, Skeleton, Stack, Text } from "@mantine/core";
+import { Button, Skeleton, Stack, Text } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { LedgerEntryRow } from "@/entities/broker-operation";
-import { SourceSyncStatus } from "@/shared/ui";
import type { AccountPageRecentOperations } from "../../../model/previews";
@@ -18,28 +17,15 @@ const RecentOperations: FC = (props) => {
const { t } = useTranslation();
const { operations } = props;
- const errorText
- = operations.state === "loaded" && operations.refreshError
- ? t("accounts.refreshError")
- : null;
-
return (
- {operations.state === "loaded" && (
-
- )}
-
{operations.state === "loading" && (
{["first", "second", "third"].map(placeholder => )}
)}
- {operations.state === "error" && {t("ledger.recentError")}}
+ {operations.state === "error" && {t("states.unavailableTitle")}}
{operations.state === "empty" && {t("ledger.recentEmpty")}}
diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx b/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx
index 6e69741..3d9431a 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 { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { render, screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { MemoryRouter } from "react-router";
import { describe, expect, it, vi } from "vitest";
@@ -58,10 +58,11 @@ describe("AccountsPage", () => {
}),
);
renderPage(createTestQueryClient());
- fireEvent.click(await screen.findByRole("button", { name: "Повторить" }));
- expect(await screen.findByText(account.name)).toBeVisible();
+ expect(await screen.findByText(account.name, undefined, {
+ timeout: 4000,
+ })).toBeVisible();
- expect(modes).toEqual([null, "observe"]);
+ expect(modes).toEqual([null, null]);
});
it.each([
["loading", AccountsPageLoading, "status"],
@@ -81,17 +82,16 @@ describe("AccountsPage", () => {
).toBeInTheDocument();
});
- it("routes connection errors to settings and keeps retry available", () => {
- const retry = vi.fn();
+ it("routes connection errors to settings without technical retry", () => {
render(
-
+
,
);
- fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
- expect(retry).toHaveBeenCalledOnce();
+ expect(screen.queryByRole("button", { name: "Повторить" }))
+ .not.toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Открыть настройки T-Bank" }),
).toHaveAttribute("href", "/settings");
@@ -123,14 +123,13 @@ describe("AccountsPage", () => {
await waitFor(() => expect(requestCount).toBe(2));
expect(screen.getByText(account.name)).toBeInTheDocument();
- expect(
- screen.getByRole("status", { name: "Обновляем данные" }),
- ).toBeInTheDocument();
+ expect(screen.queryByRole("status", { name: "Обновляем данные" }))
+ .not.toBeInTheDocument();
resolveRefresh?.(HttpResponse.json(resource([account])));
});
- it("keeps the cached account list and offers retry after a refresh error", async () => {
+ it("keeps the cached account list quietly after a refresh error", async () => {
const requests: unknown[] = [];
server.use(
http.post("*/api/v1/tbank-sync", async ({ request }) => {
@@ -161,17 +160,15 @@ describe("AccountsPage", () => {
queryKey: brokerageAccountsQueryKey,
});
- const warning = await screen.findByText(
- "Не удалось обновить данные. Показаны последние полученные.",
- );
-
- expect(warning).toBeInTheDocument();
+ await waitFor(() => expect(requestCount).toBe(2));
+ expect(
+ screen.queryByText(
+ "Не удалось обновить данные. Показаны последние полученные.",
+ ),
+ ).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Повторить" }))
+ .not.toBeInTheDocument();
expect(screen.getByText(account.name)).toBeInTheDocument();
-
- fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
- await waitFor(() => expect(requestCount).toBe(3));
- await waitFor(() => expect(warning).not.toBeInTheDocument());
- expect(screen.getByText(account.name)).toBeVisible();
expect(requests).toEqual([]);
});
@@ -194,11 +191,12 @@ describe("AccountsPage", () => {
queryKey: brokerageAccountsQueryKey,
});
+ await waitFor(() => expect(requestCount).toBe(2));
expect(
- await screen.findByText(
+ screen.queryByText(
"Не удалось обновить данные. Показаны последние полученные.",
),
- ).toBeInTheDocument();
+ ).not.toBeInTheDocument();
expect(screen.getByText("Нет инвестиционных счетов")).toBeInTheDocument();
});
});
@@ -226,7 +224,7 @@ it.each(["idle", "failed"] as const)(
.not.toBeInTheDocument();
if (state === "failed")
// eslint-disable-next-line vitest/no-conditional-expect -- Each fixed state has a different observable recovery contract.
- expect(await screen.findByRole("button", { name: "Повторить" }))
+ expect(await screen.findByRole("heading", { name: "Пока недоступно" }))
.toBeVisible();
else
// eslint-disable-next-line vitest/no-conditional-expect -- Each fixed state has a different observable recovery contract.
diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx b/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx
index ef6828f..241d6b6 100644
--- a/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx
+++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx
@@ -10,25 +10,11 @@ const AccountsPage: FC = () => {
const viewModel = useAccountsPageViewModel();
if (viewModel.state === "loading") return ;
if (viewModel.state === "error") return ;
- if (viewModel.state === "empty")
- return (
-
- );
+ if (viewModel.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 e7cd89e..9f60141 100644
--- a/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts
+++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts
@@ -1,45 +1,54 @@
import {
+ type BrokerageAccountCard,
brokerageAccountCards,
useBrokerageAccounts,
} from "@/entities/brokerage-account";
import { useScreenSync } from "@/features/tbank-connection";
-import { apiErrorCode } from "@/shared/api";
+import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
+import { useInitialAvailability } from "@/shared/lib";
-function useAccountsPageViewModel() {
+type AccountsPageViewModel
+ = | { accounts: BrokerageAccountCard[]; state: "loaded" }
+ | { code?: string; state: "error" }
+ | { state: "empty" }
+ | { state: "loading" };
+
+function isAccountsAccessFailure(code?: string) {
+ return isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)
+ || isApiErrorCode(code, ApiErrorCode.TbankNotConnected);
+}
+
+function useAccountsPageViewModel(): AccountsPageViewModel {
const accounts = useBrokerageAccounts();
- const sync = useScreenSync({ screen: "accounts" }, [accounts]);
+ useScreenSync({ screen: "accounts" }, [accounts]);
- if (
- !accounts.data
- || (accounts.meta?.availability === "partial" && accounts.data.length === 0)
- ) {
- if (!accounts.isError && accounts.meta?.sync.state !== "failed")
- return { state: "loading" as const };
+ const code = apiErrorCode(accounts.error);
+ const isAccessFailure = isAccountsAccessFailure(code);
+ const hasResult = !!accounts.data
+ && !(accounts.meta?.availability === "partial" && accounts.data.length === 0);
+
+ const availability = useInitialAvailability({
+ hasContent: hasResult,
+ identity: "accounts",
+ isError: accounts.isError || accounts.meta?.sync.state === "failed",
+ isFetching: accounts.isFetching,
+ });
+
+ if (isAccessFailure) return { code, state: "error" as const };
+
+ if (!hasResult || !accounts.data)
return {
- code: apiErrorCode(accounts.error),
- retry: sync.retry,
- state: "error" as const,
- };
- }
-
- const refresh = {
- lastSuccessfulAt: accounts.meta?.lastSuccessfulAt,
- refreshError: accounts.isError || sync.transportError || sync.sourceError,
- refreshing: accounts.isFetching || sync.refreshing,
- retry: sync.retry,
- sourceError: sync.sourceError,
- };
+ state: availability === "unavailable" ? "error" : "loading",
+ } as const;
if (accounts.data.length === 0)
return {
- ...refresh,
state: "empty" as const,
};
return {
accounts: brokerageAccountCards(accounts.data, accounts.meta?.dependencies),
- ...refresh,
state: "loaded" as const,
};
}
diff --git a/src/pages/accounts/ui/AccountsPage/components/AccountsPageEmpty.view.tsx b/src/pages/accounts/ui/AccountsPage/components/AccountsPageEmpty.view.tsx
index a7908cf..3680b6a 100644
--- a/src/pages/accounts/ui/AccountsPage/components/AccountsPageEmpty.view.tsx
+++ b/src/pages/accounts/ui/AccountsPage/components/AccountsPageEmpty.view.tsx
@@ -1,26 +1,16 @@
import type { FC } from "react";
-import { Stack } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
-import {
- InvestmentRefreshState,
- type InvestmentRefreshStateProps,
-} from "@/features/tbank-connection";
-
-const AccountsPageEmpty: FC = (props) => {
+const AccountsPageEmpty: FC = () => {
const { t } = useTranslation();
return (
-
-
-
-
-
+
);
};
diff --git a/src/pages/accounts/ui/AccountsPage/components/AccountsPageError.view.tsx b/src/pages/accounts/ui/AccountsPage/components/AccountsPageError.view.tsx
index 35132d7..7ad5c98 100644
--- a/src/pages/accounts/ui/AccountsPage/components/AccountsPageError.view.tsx
+++ b/src/pages/accounts/ui/AccountsPage/components/AccountsPageError.view.tsx
@@ -1,16 +1,16 @@
import type { FC } from "react";
-import { Button, Stack } from "@mantine/core";
+import { Button } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { ApiErrorCode, isApiErrorCode } from "@/shared/api";
-type AccountsPageErrorProps = { code?: string; retry?: () => void };
+type AccountsPageErrorProps = { code?: string };
const AccountsPageError: FC = (props) => {
- const { code, retry } = props;
+ const { code } = props;
const { t } = useTranslation();
const settingsAction = (
@@ -37,28 +37,11 @@ const AccountsPageError: FC = (props) => {
title={t("accounts.invalidTokenTitle")}
/>
);
- if (isApiErrorCode(code, ApiErrorCode.TbankUnavailable))
- return (
-
- {t("accounts.retry")}
-
- {settingsAction}
-
- )}
- error
- text={t("accounts.unavailableText")}
- title={t("accounts.unavailableTitle")}
- />
- );
return (
{t("accounts.retry")}}
- error
- text={t("states.errorText")}
- title={t("states.errorTitle")}
+ text={t("states.unavailableText")}
+ title={t("states.unavailableTitle")}
/>
);
};
diff --git a/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx b/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx
index b254cb6..4e74c79 100644
--- a/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx
+++ b/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx
@@ -8,17 +8,12 @@ import {
type BrokerageAccountCard,
BrokerageAccountList,
} from "@/entities/brokerage-account";
-import {
- InvestmentRefreshState,
- type InvestmentRefreshStateProps,
-} from "@/features/tbank-connection";
-type AccountsPageLoadedProps = InvestmentRefreshStateProps & {
+type AccountsPageLoadedProps = {
accounts: BrokerageAccountCard[];
};
const AccountsPageLoaded: FC = (props) => {
- const { accounts, ...refresh } = props;
const { t } = useTranslation();
return (
@@ -31,8 +26,6 @@ const AccountsPageLoaded: FC = (props) => {
-
-
= (props) => {
{t("accounts.openAll")}
-
+
);
};
diff --git a/src/pages/bond/ui/BondPage/BondPage.test.tsx b/src/pages/bond/ui/BondPage/BondPage.test.tsx
index aa57007..fada25a 100644
--- a/src/pages/bond/ui/BondPage/BondPage.test.tsx
+++ b/src/pages/bond/ui/BondPage/BondPage.test.tsx
@@ -352,11 +352,7 @@ describe("bond page", () => {
it("keeps market content and sold-position history when coupons fail", async () => {
setup({ failCoupons: true, noPosition: true });
await screen.findByText("98,125 %");
- const message = await screen.findByText(/Не удалось загрузить этот раздел/);
- expect(
- // eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
- message.closest("[role=\"status\"]"),
- ).toHaveAttribute("aria-live", "polite");
+ expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
expect(
screen.queryByRole("heading", { name: "Моя позиция" }),
).not.toBeInTheDocument();
@@ -364,38 +360,12 @@ describe("bond page", () => {
screen.getByRole("heading", { name: "Моя история событий" }),
).toBeVisible();
});
- it("preserves cached quote on refresh failure", async () => {
- const scopes: unknown[] = [];
- server.use(
- http.post("*/api/v1/tbank-sync", async ({ request }) => {
- scopes.push(await request.json());
-
- return HttpResponse.json(
- { code: "TBANK_UNAVAILABLE" },
- { status: 503 },
- );
- }),
- );
+ it("preserves cached quote without manual refresh", async () => {
const controls = setup({ path: `/bonds/${uid}?period=year&events=trades` });
await screen.findByText("98,125 %");
controls.failQuote();
- fireEvent.click(screen.getByRole("button", { name: "Обновить" }));
- const message = await screen.findByText(/последние полученные/);
- expect(
- // eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
- message.closest("[role=\"status\"]"),
- ).toHaveAttribute("aria-live", "polite");
expect(screen.getByText("98,125 %")).toBeVisible();
- expect(scopes).toEqual([
- {
- accountId: "all",
- candlePeriod: "year",
- categories: ["trade"],
- instrumentUid: uid,
- limit: 20,
- screen: "bond",
- },
- ]);
+ expect(screen.queryByRole("button", { name: "Обновить" })).not.toBeInTheDocument();
});
it("offers search for an ambiguous or missing instrument without loading personal data", async () => {
const { requests } = setup({ ambiguousTicker: true, path: "/bonds/SAME" });
diff --git a/src/pages/bond/ui/BondPage/BondPage.tsx b/src/pages/bond/ui/BondPage/BondPage.tsx
index a870ea3..5f28d9f 100644
--- a/src/pages/bond/ui/BondPage/BondPage.tsx
+++ b/src/pages/bond/ui/BondPage/BondPage.tsx
@@ -15,7 +15,6 @@ const BondPage: FC = () => {
code={vm.code}
kind="bond"
loading={vm.bondState.loading}
- retry={vm.bondState.retry}
/>
);
diff --git a/src/pages/bond/ui/BondPage/BondPage.view.tsx b/src/pages/bond/ui/BondPage/BondPage.view.tsx
index e6debda..b727fdf 100644
--- a/src/pages/bond/ui/BondPage/BondPage.view.tsx
+++ b/src/pages/bond/ui/BondPage/BondPage.view.tsx
@@ -1,7 +1,6 @@
import type { FC } from "react";
import {
- Alert,
Badge,
Group,
Skeleton,
@@ -137,8 +136,6 @@ const BondPageView: FC = (props) => {
@@ -156,20 +153,8 @@ const BondPageView: FC = (props) => {
)}
- {!!(vm.refreshError || vm.bondState.error || vm.quoteState.error) && (
-
- {t(vm.quote ? "accounts.refreshError" : "stock.sectionError")}
-
- )}
-
{quoteContent}
- {!!(vm.quoteState.refreshing || vm.bondState.refreshing) && (
-
- {t("accounts.refreshing")}
-
- )}
-
diff --git a/src/pages/bond/ui/BondPage/BondPage.vm.ts b/src/pages/bond/ui/BondPage/BondPage.vm.ts
index 6a0af5f..a3f2d8c 100644
--- a/src/pages/bond/ui/BondPage/BondPage.vm.ts
+++ b/src/pages/bond/ui/BondPage/BondPage.vm.ts
@@ -42,7 +42,7 @@ function useBondPageVm() {
const { operations } = history;
- const sync = useScreenSync(
+ useScreenSync(
{
accountId: "all",
candlePeriod: period,
@@ -56,29 +56,26 @@ function useBondPageVm() {
return {
bond: bond.data,
- bondState: section(bond, uid ? sync.retry : () => bond.refetch()),
+ bondState: section(bond),
candles: candles.data,
- candlesState: section(candles, sync.retry),
+ candlesState: section(candles),
canonicalTo:
bond.data?.slug && bond.data.slug !== bondId
? `/bonds/${encodeURIComponent(bond.data.slug)}${search}`
: null,
code: apiErrorCode(bond.error),
coupons: coupons.data,
- couponsState: section(coupons, sync.retry),
+ couponsState: section(coupons),
eventRows: toInstrumentEventRows(operations.data?.items, bond.data),
filter,
hasPrevious: history.hasPrevious,
logoSrc: instrumentLogoUrl(bond.data?.logoName),
metrics: metrics.data,
- metricsState: section(metrics, sync.retry),
+ metricsState: section(metrics),
onNext: history.onNext,
onPrevious: history.onPrevious,
operations: operations.data,
- operationsState: {
- ...section(operations, sync.retry),
- retry: () => history.retry(sync.retry),
- },
+ operationsState: section(operations),
pastCoupons:
coupons.data?.coupons
.filter(event => event.timing !== "upcoming")
@@ -87,12 +84,9 @@ function useBondPageVm() {
) ?? [],
period,
position: position.data,
- positionState: section(position, sync.retry),
+ positionState: section(position),
quote: quote.data,
- quoteState: section(quote, sync.retry),
- refresh: sync.refresh,
- refreshError: sync.transportError || sync.sourceError,
- refreshing: sync.refreshing,
+ quoteState: section(quote),
setFilter,
setPeriod,
upcoming:
diff --git a/src/pages/bonds/ui/BondsPage/BondsPage.vm.ts b/src/pages/bonds/ui/BondsPage/BondsPage.vm.ts
index db55a65..d3a9297 100644
--- a/src/pages/bonds/ui/BondsPage/BondsPage.vm.ts
+++ b/src/pages/bonds/ui/BondsPage/BondsPage.vm.ts
@@ -10,12 +10,14 @@ function useBondsPageVm() {
const search = useInstrumentSearch(MIN_BOND_SEARCH_LENGTH);
const { canSearch, debounced, isWaiting, query } = search;
const result = useBondSearch(debounced);
- const sync = useScreenSync({ query: debounced, screen: "bonds" }, [result]);
+ useScreenSync({ query: debounced, screen: "bonds" }, [result]);
return {
canSearch,
code: apiErrorCode(result.error),
- error: result.isError || sync.sourceError || sync.transportError,
+ error:
+ !result.data
+ && (result.isError || result.meta?.sync.state === "failed"),
items:
result.data?.map(bond => ({
...bond,
@@ -35,10 +37,6 @@ function useBondsPageVm() {
debounced.length >= MIN_BOND_SEARCH_LENGTH
&& !isWaiting
&& Boolean(result.data),
- refreshing: sync.refreshing,
- retry: () => {
- void sync.retry();
- },
};
}
diff --git a/src/pages/card/ui/CardPage/CardPage.test.tsx b/src/pages/card/ui/CardPage/CardPage.test.tsx
index 8140403..467d35a 100644
--- a/src/pages/card/ui/CardPage/CardPage.test.tsx
+++ b/src/pages/card/ui/CardPage/CardPage.test.tsx
@@ -256,21 +256,19 @@ describe("CardPage", () => {
}),
);
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
- await waitFor(() =>
- expect(screen.getByRole("button", { name: "Далее" })).toBeDisabled(),
- );
+ await waitFor(() => {
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ });
expect(screen.getByText("Перевод на «Запасная»")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Изменить" })).toBeDisabled();
- expect(
- screen.queryByLabelText("Загрузка операций"),
- ).not.toBeInTheDocument();
+ expect(screen.queryByLabelText("Загрузка операций")).not.toBeInTheDocument();
expect(scroll).not.toHaveBeenCalled();
measure.mockReturnValue({ top: 200 } as DOMRect);
release();
await screen.findByText(/Новая страница/);
- // eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
- const list = screen.getByText(/Новая страница/).closest("[aria-busy]");
- expect(list?.parentElement?.style.minHeight).toBe("");
+ const newPageText = screen.getByText(/Новая страница/);
+ expect(newPageText).toBeInTheDocument();
+
expect(scroll).toHaveBeenCalledExactlyOnceWith({
behavior: "instant",
top: -400,
@@ -317,7 +315,9 @@ describe("CardPage", () => {
it("keeps card and journal when summary fails", async () => {
setup({ summaryError: true });
await screen.findByRole("heading", { name: "Повседневная" });
- await screen.findByRole("alert");
+ expect(
+ screen.queryByRole("alert"),
+ ).not.toBeInTheDocument();
expect(
await screen.findByText("Перевод на «Запасная»"),
).toBeInTheDocument();
@@ -331,7 +331,9 @@ describe("CardPage", () => {
http.get("*/api/v1/bank-cards/a/operations", () => HttpResponse.error()),
);
await client.invalidateQueries();
- await screen.findAllByRole("alert");
+ await waitFor(() => {
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ });
expect(
screen.getByRole("heading", { name: "Повседневная" }),
).toBeInTheDocument();
@@ -401,9 +403,9 @@ describe("CardPage", () => {
it("recovers the prerequisite card list while preserving the visible balance and journal", async () => {
setup({ cardsError: true });
await screen.findByText("Перевод на «Запасная»");
- expect(await screen.findByRole("alert")).toHaveTextContent(
- "Не удалось загрузить данные карт.",
- );
+ expect(
+ screen.queryByRole("alert"),
+ ).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Добавить операцию" }),
).toBeDisabled();
@@ -412,11 +414,6 @@ describe("CardPage", () => {
HttpResponse.json([card, { ...card, id: "b", name: "Запасная" }]),
),
);
- fireEvent.click(
- within(screen.getByRole("alert")).getByRole("button", {
- name: "Повторить",
- }),
- );
await waitFor(() =>
expect(
screen.getByRole("button", { name: "Добавить операцию" }),
diff --git a/src/pages/card/ui/CardPage/CardPage.view.tsx b/src/pages/card/ui/CardPage/CardPage.view.tsx
index e839f64..4a499d9 100644
--- a/src/pages/card/ui/CardPage/CardPage.view.tsx
+++ b/src/pages/card/ui/CardPage/CardPage.view.tsx
@@ -75,7 +75,6 @@ const CardPageView: FC = (props) => {
{t("bank.back")}
- {!vm.notFound && {t("bank.retry")}}
)}
error
@@ -125,27 +124,16 @@ const CardPageView: FC = (props) => {
{!!vm.archiveError && (
-
+
)}
{card.archivedAt && {t("bank.archiveHint")}}
- {vm.cardRefreshError && (
-
- {t("bank.refreshError")}
-
- {t("bank.retry")}
-
- )}
-
- {vm.cardRefreshing && {t("bank.refreshing")}}
-
{vm.cardsFeedback && (
)}
@@ -187,7 +175,6 @@ const CardPageView: FC = (props) => {
= (props) => {
- {vm.history.error && (
-
- {t(vm.rows ? "bank.refreshError" : "bank.error")}
-
- {t("bank.retry")}
-
- )}
+ {vm.history.error && !vm.rows && {t("bank.error")}}
{vm.history.page !== undefined && vm.pageSize !== "all" && (
= (props) => {
ref={paginationRef}
>
void history.refetch(),
},
notFound: isApiErrorCode(
apiErrorCode(card.error),
@@ -128,8 +126,6 @@ function useCardPageVm() {
page,
pageSize,
period,
- retryCard: () => void card.refetch(),
- retryCards: () => void cards.refetch(),
rows,
setPage: (value: number) =>
setParameters({ ...period, limit: pageSize, page: String(value) }),
@@ -146,7 +142,6 @@ function useCardPageVm() {
error: summary.isError,
fetching: summary.isFetching,
loading: summary.isLoading,
- retry: () => void summary.refetch(),
},
toggleArchive: () => {
if (!card.data) return;
diff --git a/src/pages/card/ui/DeleteOperation/DeleteOperation.vm.ts b/src/pages/card/ui/DeleteOperation/DeleteOperation.vm.ts
index d456231..dea3507 100644
--- a/src/pages/card/ui/DeleteOperation/DeleteOperation.vm.ts
+++ b/src/pages/card/ui/DeleteOperation/DeleteOperation.vm.ts
@@ -1,3 +1,4 @@
+import { notifications } from "@mantine/notifications";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@@ -44,10 +45,16 @@ function useDeleteOperationVm(props: DeleteOperationProps) {
setError(undefined);
try {
+ const actionId = crypto.randomUUID();
await operationDeletion.mutateAsync({
id: record.id,
version: record.version,
});
+
+ notifications.show({
+ id: "bank-operation-deleted-" + actionId,
+ message: t("bank.operationDeleted"),
+ });
setOpened(false);
}
catch (error_) {
diff --git a/src/pages/card/ui/OperationForm/OperationForm.vm.ts b/src/pages/card/ui/OperationForm/OperationForm.vm.ts
index e6d5ae1..bae39b7 100644
--- a/src/pages/card/ui/OperationForm/OperationForm.vm.ts
+++ b/src/pages/card/ui/OperationForm/OperationForm.vm.ts
@@ -1,4 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
+import { notifications } from "@mantine/notifications";
import { useState } from "react";
import { useForm, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
@@ -112,6 +113,8 @@ const useOperationFormVm = (props: OperationFormProps) => {
description: values.description,
};
+ const actionId = crypto.randomUUID();
+
if (props.operation)
await updateOperation.mutateAsync({
body: { ...body, version },
@@ -122,6 +125,11 @@ const useOperationFormVm = (props: OperationFormProps) => {
id: intent,
...body,
});
+
+ notifications.show({
+ id: "bank-operation-saved-" + actionId,
+ message: t("bank.operationSaved"),
+ });
setOpened(false);
}
catch (error_) {
diff --git a/src/pages/cards/ui/CardsPage/CardsPage.test.tsx b/src/pages/cards/ui/CardsPage/CardsPage.test.tsx
index bc6793d..8856550 100644
--- a/src/pages/cards/ui/CardsPage/CardsPage.test.tsx
+++ b/src/pages/cards/ui/CardsPage/CardsPage.test.tsx
@@ -198,26 +198,26 @@ describe("CardsPage", () => {
it("shows retry after failure instead of presenting an empty list", async () => {
renderCards(true);
// Mount starts asynchronously; this handler owns the request.
- expect(await screen.findByRole("alert")).toHaveTextContent(
- "Не удалось загрузить данные карт.",
- );
+ expect(
+ screen.queryByRole("alert"),
+ ).not.toBeInTheDocument();
expect(
screen.queryByRole("heading", { name: "Добавьте первую карту" }),
).not.toBeInTheDocument();
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
server.use(http.get("*/api/v1/bank-cards", () => HttpResponse.json([])));
- fireEvent.click(
- within(screen.getByRole("alert")).getByRole("button", {
- name: "Повторить",
- }),
- );
- await screen.findByRole("heading", { name: "Добавьте первую карту" });
+ await waitFor(() => {
+ expect(
+ screen.queryByRole("alert"),
+ ).not.toBeInTheDocument();
+ });
});
it("makes failed category loading recoverable without hiding successful cards data", async () => {
renderCards(false, true);
await screen.findByRole("heading", { name: "Добавьте первую карту" });
- expect(await screen.findByRole("alert")).toHaveTextContent(
- "Не удалось загрузить данные карт.",
- );
+ expect(
+ screen.queryByRole("alert"),
+ ).not.toBeInTheDocument();
server.use(
http.get("*/api/v1/bank-cards/categories", () =>
HttpResponse.json([
@@ -225,12 +225,11 @@ describe("CardsPage", () => {
]),
),
);
- fireEvent.click(
- within(screen.getByRole("alert")).getByRole("button", {
- name: "Повторить",
- }),
- );
- await screen.findByRole("button", { name: "Категории" });
+ await waitFor(() => {
+ expect(
+ screen.getByRole("button", { name: "Категории" }),
+ ).toBeInTheDocument();
+ });
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
});
diff --git a/src/pages/cards/ui/CardsPage/CardsPage.view.tsx b/src/pages/cards/ui/CardsPage/CardsPage.view.tsx
index 6072698..1ad94dd 100644
--- a/src/pages/cards/ui/CardsPage/CardsPage.view.tsx
+++ b/src/pages/cards/ui/CardsPage/CardsPage.view.tsx
@@ -9,7 +9,6 @@ import {
Skeleton,
Stack,
Switch,
- Text,
Title,
} from "@mantine/core";
import { AddIcon, CategoriesIcon } from "@wealthledger/icons";
@@ -19,7 +18,6 @@ import { Link } from "react-router";
import {
BankCardFace,
- BankFeedback,
BankPeriodPicker,
BankSummaryView,
} from "@/entities/bank-card";
@@ -107,18 +105,6 @@ const CardsPageView: FC = (props) => {
- {vm.cards.isError && (
-
- {t(vm.items ? "bank.refreshError" : "bank.error")}
-
-
- {t("bank.retry")}
-
-
- )}
-
- {vm.cards.data && vm.cards.isFetching && {t("bank.refreshing")}}
-
@@ -140,17 +126,7 @@ const CardsPageView: FC = (props) => {
error={vm.summary.isError}
fetching={vm.summary.isFetching}
loading={vm.summary.isLoading}
- retry={vm.retrySummary}
/>
-
- {vm.categories.isError && (
-
- )}
);
};
diff --git a/src/pages/cards/ui/CardsPage/CardsPage.vm.ts b/src/pages/cards/ui/CardsPage/CardsPage.vm.ts
index b7d3e05..20b00aa 100644
--- a/src/pages/cards/ui/CardsPage/CardsPage.vm.ts
+++ b/src/pages/cards/ui/CardsPage/CardsPage.vm.ts
@@ -39,9 +39,6 @@ function useCardsPageVm() {
setArchive(event.currentTarget.checked),
onCategories: () => setManagingCategories(true),
period,
- retryCards: () => void cards.refetch(),
- retryCategories: () => void categories.refetch(),
- retrySummary: () => void summary.refetch(),
setPeriod: (value: [null | string, null | string]) => {
if (value[0] && value[1]) setParameters({ from: value[0], to: value[1] });
},
diff --git a/src/pages/stock/ui/StockPage/StockPage.test.tsx b/src/pages/stock/ui/StockPage/StockPage.test.tsx
index 2b90355..e0d5c46 100644
--- a/src/pages/stock/ui/StockPage/StockPage.test.tsx
+++ b/src/pages/stock/ui/StockPage/StockPage.test.tsx
@@ -396,37 +396,15 @@ describe("stock page", () => {
screen.queryByRole("heading", { name: "Моя позиция" }),
).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "График цены" })).toBeVisible();
- const message = await screen.findByText(/Не удалось загрузить этот раздел/);
- expect(
- // eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
- message.closest("[role=\"status\"]"),
- ).toHaveAttribute("aria-live", "polite");
+ expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
});
- it("recovers an initially missing company section without reloading successful sections", async () => {
+ it("keeps successful sections visible when company data is initially missing", async () => {
const { requests } = setup({ failCompany: true });
- const retry = await screen.findByRole("button", { name: "Повторить" });
await screen.findByText("Зачисление дивидендов");
- const modes: (null | string)[] = [];
- server.use(
- http.get("*/api/v1/stocks/:uid/company", ({ request }) => {
- modes.push(new URL(request.url).searchParams.get("sync"));
-
- return HttpResponse.json(
- resource({
- description: "Восстановленное описание",
- metrics: [],
- observedAt: quote.observedAt,
- unavailable: [],
- }),
- );
- }),
- );
const previousRequests = requests.length;
- fireEvent.click(retry);
- expect(await screen.findByText("Восстановленное описание")).toBeVisible();
expect(screen.getByText("Зачисление дивидендов")).toBeVisible();
expect(requests).toHaveLength(previousRequests);
- expect(modes).toEqual(["observe"]);
+ expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
});
it("sends instrument-filtered continuation, restores it on returning to the filter and changes chart period", async () => {
const { requests } = setup();
@@ -469,75 +447,28 @@ describe("stock page", () => {
);
});
it.each([
- [
- "INVALID_OPERATIONS_CURSOR",
- null,
- null,
- ],
- ["TBANK_UNAVAILABLE", "next", "observe"],
+ "INVALID_OPERATIONS_CURSOR",
+ "TBANK_UNAVAILABLE",
])(
- "recovers history after %s with fresh data and the correct cursor",
- async (code, cursor, sync) => {
+ "keeps history quiet after %s without technical retry",
+ async (code) => {
const controls = setup({ cursorError: code });
await screen.findByText("Зачисление дивидендов");
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
- const retry = await screen.findByRole("button", { name: "Повторить" });
controls.recoverOperations();
- const previousRequests = controls.requests.length;
- fireEvent.click(retry);
- expect(await screen.findByText("+201 ₽")).toBeVisible();
- const recovered = controls.requests.slice(previousRequests);
- expect(recovered).toHaveLength(1);
- expect(recovered[0]?.pathname).toBe(
- "/api/v1/brokerage-accounts/all/operations",
- );
- expect(recovered[0]?.searchParams.get("instrumentUid")).toBe(uid);
- expect(recovered[0]?.searchParams.get("limit")).toBe("20");
- expect(recovered[0]?.searchParams.get("cursor")).toBe(cursor);
- expect(recovered[0]?.searchParams.get("sync")).toBe(sync);
- if (cursor)
- // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
- expect(screen.getByRole("button", { name: "Назад" })).toBeEnabled();
- // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
- else expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled();
+ expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
+ expect(screen.getByRole("heading", { name: "Моя история событий" })).toBeVisible();
},
);
- it("keeps the last quote on a refresh failure", async () => {
- const scopes: unknown[] = [];
- server.use(
- http.post("*/api/v1/tbank-sync", async ({ request }) => {
- scopes.push(await request.json());
-
- return HttpResponse.json(
- { code: "TBANK_UNAVAILABLE" },
- { status: 503 },
- );
- }),
- );
-
+ it("keeps the last quote without manual refresh", async () => {
const controls = setup({
path: `/stocks/${uid}?period=year&events=trades`,
});
await screen.findByText("300 ₽");
controls.failQuote();
- fireEvent.click(screen.getByRole("button", { name: "Обновить" }));
- const message = await screen.findByText(/последние полученные/);
- expect(
- // eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
- message.closest("[role=\"status\"]"),
- ).toHaveAttribute("aria-live", "polite");
expect(screen.getByText("300 ₽")).toBeVisible();
- expect(scopes).toEqual([
- {
- accountId: "all",
- candlePeriod: "year",
- categories: ["trade"],
- instrumentUid: uid,
- limit: 20,
- screen: "stock",
- },
- ]);
+ expect(screen.queryByRole("button", { name: "Обновить" })).not.toBeInTheDocument();
});
it("offers recovery for an unavailable direct link", async () => {
setup({ failStock: true });
diff --git a/src/pages/stock/ui/StockPage/StockPage.tsx b/src/pages/stock/ui/StockPage/StockPage.tsx
index a9780a7..baae627 100644
--- a/src/pages/stock/ui/StockPage/StockPage.tsx
+++ b/src/pages/stock/ui/StockPage/StockPage.tsx
@@ -15,7 +15,6 @@ const StockPage: FC = () => {
code={vm.code}
kind="stock"
loading={vm.stockState.loading}
- retry={vm.stockState.retry}
/>
);
diff --git a/src/pages/stock/ui/StockPage/StockPage.view.tsx b/src/pages/stock/ui/StockPage/StockPage.view.tsx
index 42ba5af..28d963b 100644
--- a/src/pages/stock/ui/StockPage/StockPage.view.tsx
+++ b/src/pages/stock/ui/StockPage/StockPage.view.tsx
@@ -1,9 +1,6 @@
import type { FC } from "react";
-import {
- Alert,
- Text,
-} from "@mantine/core";
+import { Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import {
@@ -42,8 +39,6 @@ const StockPageView: FC = (props) => {
@@ -53,12 +48,6 @@ const StockPageView: FC = (props) => {
)}
- {!!(vm.refreshError || vm.stockState.error || vm.quoteState.error) && (
-
- {t(vm.quote ? "accounts.refreshError" : "stock.sectionError")}
-
- )}
-
history.retry(sync.retry),
- },
+ operationsState: section(operations),
pastDividends:
dividends.data?.dividends
.filter(event => event.timing !== "upcoming")
@@ -85,16 +82,13 @@ function useStockPageVm() {
) ?? [],
period,
position: position.data,
- positionState: section(position, sync.retry),
+ positionState: section(position),
quote: quote.data,
- quoteState: section(quote, sync.retry),
- refresh: sync.refresh,
- refreshError: sync.transportError || sync.sourceError,
- refreshing: sync.refreshing,
+ quoteState: section(quote),
setFilter,
setPeriod,
stock: stock.data,
- stockState: section(stock, uid ? sync.retry : () => stock.refetch()),
+ stockState: section(stock),
upcoming:
dividends.data?.dividends
.filter(event => event.timing === "upcoming")
diff --git a/src/pages/stock/ui/StockPage/components/StockCompany.view.tsx b/src/pages/stock/ui/StockPage/components/StockCompany.view.tsx
index df67069..500a725 100644
--- a/src/pages/stock/ui/StockPage/components/StockCompany.view.tsx
+++ b/src/pages/stock/ui/StockPage/components/StockCompany.view.tsx
@@ -1,12 +1,6 @@
import type { FC } from "react";
-import {
- Alert,
- Button,
- Group,
- Stack,
- Text,
-} from "@mantine/core";
+import { Alert, Group, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import {
@@ -80,17 +74,7 @@ const StockCompany: FC = (props) => {
{props.company.unavailable.length > 0 && (
-
- {t("stock.partialCompany")}
-
-
- {t("accounts.retry")}
-
-
+ {t("stock.partialCompany")}
)}
diff --git a/src/pages/stock/ui/StockPage/components/StockQuote.view.tsx b/src/pages/stock/ui/StockPage/components/StockQuote.view.tsx
index 5c9a7b7..0b2b53a 100644
--- a/src/pages/stock/ui/StockPage/components/StockQuote.view.tsx
+++ b/src/pages/stock/ui/StockPage/components/StockQuote.view.tsx
@@ -82,12 +82,6 @@ const StockQuote: FC = (props) => {
{t("stock.partialQuote")}
)}
-
- {!!props.quoteState.refreshing && (
-
- {t("accounts.refreshing")}
-
- )}
);
}
diff --git a/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts b/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts
index a35e22e..957bd83 100644
--- a/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts
+++ b/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts
@@ -10,12 +10,14 @@ function useStocksPageVm() {
const search = useInstrumentSearch(MIN_STOCK_SEARCH_LENGTH);
const { canSearch, debounced, isWaiting, query } = search;
const result = useStockSearch(debounced);
- const sync = useScreenSync({ query: debounced, screen: "stocks" }, [result]);
+ useScreenSync({ query: debounced, screen: "stocks" }, [result]);
return {
canSearch,
code: apiErrorCode(result.error),
- error: result.isError || sync.sourceError || sync.transportError,
+ error:
+ !result.data
+ && (result.isError || result.meta?.sync.state === "failed"),
items:
result.data?.map(stock => ({
...stock,
@@ -35,10 +37,6 @@ function useStocksPageVm() {
debounced.length >= MIN_STOCK_SEARCH_LENGTH
&& !isWaiting
&& Boolean(result.data),
- refreshing: sync.refreshing,
- retry: () => {
- void sync.retry();
- },
};
}
diff --git a/src/shared/api/client.test.ts b/src/shared/api/client.test.ts
index e588b64..db15880 100644
--- a/src/shared/api/client.test.ts
+++ b/src/shared/api/client.test.ts
@@ -1,12 +1,26 @@
import { http, HttpResponse } from "msw";
-import { describe, expect, it, vi } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { apiClient, configureAuthClient } from "./client";
+async function advanceReadDeadlineRecovery() {
+ await vi.advanceTimersByTimeAsync(15_000);
+ await vi.advanceTimersByTimeAsync(1000);
+}
+
+async function advanceTransportRecovery() {
+ await vi.advanceTimersByTimeAsync(1000);
+ await vi.advanceTimersByTimeAsync(2000);
+}
+
describe("apiClient authentication", () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
it("refreshes once and retries an unauthorized protected request", async () => {
let token = "expired";
@@ -109,3 +123,186 @@ describe("apiClient authentication", () => {
await response;
});
});
+
+describe("apiClient transient read recovery", () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("recovers a protected GET after transient server failures", async () => {
+ vi.useFakeTimers();
+ let requests = 0;
+ server.use(
+ http.get("*/api/v1/brokerage-accounts", () => {
+ requests += 1;
+
+ return requests < 3
+ ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
+ : HttpResponse.json(resource([{ id: "account-1" }]));
+ }),
+ );
+ configureAuthClient({
+ accessToken: () => "token",
+ clear: vi.fn(),
+ refresh: () => Promise.resolve(true),
+ });
+
+ const response = apiClient.GET("/api/v1/brokerage-accounts");
+ await advanceTransportRecovery();
+
+ await expect(response).resolves.toMatchObject({
+ data: { data: [{ id: "account-1" }] },
+ });
+ expect(requests).toBe(3);
+ });
+
+ it("recovers a protected GET after network failure", async () => {
+ vi.useFakeTimers();
+ let requests = 0;
+ server.use(
+ http.get("*/api/v1/brokerage-accounts", () => {
+ requests += 1;
+
+ return requests === 1
+ ? HttpResponse.error()
+ : HttpResponse.json(resource([{ id: "account-1" }]));
+ }),
+ );
+ configureAuthClient({
+ accessToken: () => "token",
+ clear: vi.fn(),
+ refresh: () => Promise.resolve(true),
+ });
+
+ const response = apiClient.GET("/api/v1/brokerage-accounts");
+ await advanceTransportRecovery();
+
+ await expect(response).resolves.toMatchObject({
+ data: { data: [{ id: "account-1" }] },
+ });
+ expect(requests).toBe(2);
+ });
+
+ it("times out a stalled response body before retrying", async () => {
+ vi.useFakeTimers();
+ let requests = 0;
+ server.use(
+ http.get("*/api/v1/brokerage-accounts", () => {
+ requests += 1;
+
+ return requests === 1
+ ? new Response(new ReadableStream())
+ : HttpResponse.json(resource([{ id: "account-1" }]));
+ }),
+ );
+ configureAuthClient({
+ accessToken: () => "token",
+ clear: vi.fn(),
+ refresh: () => Promise.resolve(true),
+ });
+
+ const response = apiClient.GET("/api/v1/brokerage-accounts");
+ await advanceReadDeadlineRecovery();
+
+ await expect(response).resolves.toMatchObject({
+ data: { data: [{ id: "account-1" }] },
+ });
+ expect(requests).toBe(2);
+ });
+
+ it("does not retry rate limited reads before the server delay", async () => {
+ let requests = 0;
+ server.use(
+ http.get("*/api/v1/brokerage-accounts", () => {
+ requests += 1;
+
+ return HttpResponse.json(
+ { code: "RATE_LIMITED" },
+ { headers: { "Retry-After": "60" }, status: 429 },
+ );
+ }),
+ );
+ configureAuthClient({
+ accessToken: () => "token",
+ clear: vi.fn(),
+ refresh: () => Promise.resolve(true),
+ });
+
+ await apiClient.GET("/api/v1/brokerage-accounts");
+
+ expect(requests).toBe(1);
+ });
+
+ it("does not retry malformed successful responses as transport failures", async () => {
+ let requests = 0;
+ server.use(
+ http.get("*/api/v1/brokerage-accounts", () => {
+ requests += 1;
+
+ return new Response("not json", {
+ headers: { "Content-Type": "application/json" },
+ status: 200,
+ });
+ }),
+ );
+ configureAuthClient({
+ accessToken: () => "token",
+ clear: vi.fn(),
+ refresh: () => Promise.resolve(true),
+ });
+
+ await expect(
+ apiClient.GET("/api/v1/brokerage-accounts"),
+ ).rejects.toThrow(SyntaxError);
+
+ expect(requests).toBe(1);
+ });
+
+ it.each([401, 403, 404, 409])(
+ "does not retry permanent HTTP %s responses",
+ async (status) => {
+ let requests = 0;
+ server.use(
+ http.get("*/api/v1/brokerage-accounts", () => {
+ requests += 1;
+
+ return HttpResponse.json({ code: "FORBIDDEN" }, { status });
+ }),
+ );
+ configureAuthClient({
+ accessToken: () => "token",
+ clear: vi.fn(),
+ refresh: () => Promise.resolve(false),
+ });
+
+ await apiClient.GET("/api/v1/brokerage-accounts");
+
+ expect(requests).toBe(1);
+ },
+ );
+
+ it("does not retry mutations", async () => {
+ let requests = 0;
+ server.use(
+ http.put("*/api/v1/tbank-connection", () => {
+ requests += 1;
+
+ return HttpResponse.json(
+ { code: "TBANK_UNAVAILABLE" },
+ { status: 503 },
+ );
+ }),
+ );
+ configureAuthClient({
+ accessToken: () => "token",
+ clear: vi.fn(),
+ refresh: () => Promise.resolve(true),
+ });
+
+ await apiClient.PUT("/api/v1/tbank-connection", {
+ body: { token: "token" },
+ });
+
+ expect(requests).toBe(1);
+ });
+});
diff --git a/src/shared/api/client.ts b/src/shared/api/client.ts
index ea8d312..d9dbe75 100644
--- a/src/shared/api/client.ts
+++ b/src/shared/api/client.ts
@@ -6,6 +6,17 @@ import type { paths } from "./generated/schema";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
const HTTP_UNAUTHORIZED = 401;
+const HTTP_REQUEST_TIMEOUT = 408;
+const HTTP_TOO_MANY_REQUESTS = 429;
+const HTTP_INTERNAL_SERVER_ERROR = 500;
+const HTTP_BAD_GATEWAY = 502;
+const HTTP_SERVICE_UNAVAILABLE = 503;
+const HTTP_GATEWAY_TIMEOUT = 504;
+const GET_DEADLINE_MS = 15_000;
+const FIRST_RETRY_DELAY_MS = 1000;
+const SECOND_RETRY_DELAY_MS = 2000;
+const RETRY_DELAYS_MS = [FIRST_RETRY_DELAY_MS, SECOND_RETRY_DELAY_MS] as const;
+const SECONDS_TO_MS = 1000;
const configuredBaseUrl: unknown = import.meta.env.VITE_API_BASE_URL;
@@ -23,12 +34,22 @@ type AuthHandlers = {
refresh: () => Promise;
};
+type ReadAttempt = {
+ problem?: unknown;
+ reason: "abort" | "transient";
+ response?: Response;
+};
+
const sessionState: { handlers?: AuthHandlers; hasSession: boolean } = {
hasSession: false,
};
const authSessionListeners = new Set<() => void>();
+function abortError() {
+ return new DOMException("Request aborted", "AbortError");
+}
+
async function authenticatedFetch(request: Request): Promise {
const isAuthRequest = new URL(request.url).pathname.startsWith(
"/api/v1/auth/",
@@ -56,7 +77,7 @@ async function authenticatedFetch(request: Request): Promise {
= token && !isAuthRequest ? withToken(request, token) : request;
const retryRequest = authorized.clone();
- const response = await fetch(authorized);
+ const response = await fetchAppRead(authorized, isAuthRequest);
if (!isAuthRequest && !isCurrent())
throw new DOMException("Session changed", "AbortError");
if (
@@ -87,7 +108,12 @@ async function authenticatedFetch(request: Request): Promise {
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
const refreshedToken = sessionState.handlers.accessToken();
if (!refreshedToken) return response;
- const retried = await fetch(withToken(retryRequest, refreshedToken));
+
+ const retried = await fetchAppRead(
+ withToken(retryRequest, refreshedToken),
+ isAuthRequest,
+ );
+
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
return retried;
@@ -98,10 +124,138 @@ function configureAuthClient(handlers?: AuthHandlers) {
setAuthenticatedSession(Boolean(sessionState.handlers?.accessToken()));
}
+async function fetchAppRead(
+ request: Request,
+ isAuthRequest: boolean,
+): Promise {
+ if (isAuthRequest || request.method !== "GET") return fetch(request);
+
+ const retryableRequest = request.clone();
+
+ for (const [retryIndex, delayMs] of RETRY_DELAYS_MS.entries()) {
+ const attempt = await readAttempt(
+ retryIndex === 0 ? request : retryableRequest.clone(),
+ );
+
+ if (attempt.reason === "abort") throw abortError();
+ const response = attempt.response;
+ if (response && !isTransientReadFailure(response, attempt.problem))
+ return response;
+ await waitForRetry(delayMs, request.signal);
+ }
+
+ const finalAttempt = await readAttempt(retryableRequest.clone());
+ if (finalAttempt.reason === "abort") throw abortError();
+ if (!finalAttempt.response) throw new TypeError("Network request failed");
+
+ return finalAttempt.response;
+}
+
function hasAuthenticatedSession() {
return sessionState.hasSession;
}
+function isNetworkFailure(error: unknown): boolean {
+ return error instanceof TypeError;
+}
+
+function isPermanentCode(code?: string): boolean {
+ return [
+ ApiErrorCode.Forbidden,
+ ApiErrorCode.InvalidTbankToken,
+ ApiErrorCode.NotFound,
+ ApiErrorCode.TbankNotConnected,
+ ApiErrorCode.Unauthorized,
+ ].some(permanent => isApiErrorCode(code, permanent));
+}
+
+function isTransientReadFailure(
+ response: Response | undefined,
+ problem: unknown,
+): boolean {
+ if (!response) return true;
+ const code = apiErrorCode(problem);
+ if (isApiErrorCode(code, ApiErrorCode.TbankUnavailable)) return true;
+ if (isPermanentCode(code)) return false;
+
+ const transientStatuses = [
+ HTTP_BAD_GATEWAY,
+ HTTP_GATEWAY_TIMEOUT,
+ HTTP_INTERNAL_SERVER_ERROR,
+ HTTP_REQUEST_TIMEOUT,
+ HTTP_SERVICE_UNAVAILABLE,
+ ];
+
+ return transientStatuses.includes(response.status)
+ || (
+ response.status === HTTP_TOO_MANY_REQUESTS
+ && retryAfterMs(response) === 0
+ );
+}
+
+function parseJson(body: string): unknown {
+ try {
+ return JSON.parse(body);
+ }
+ catch {
+ return undefined;
+ }
+}
+
+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 });
+
+ try {
+ const response = await Promise.race([fetch(timedRequest), deadline]);
+ const body = await Promise.race([response.text(), deadline]);
+ const problem = parseJson(body);
+
+ return {
+ problem,
+ reason: "transient",
+ response: new Response(body, response),
+ };
+ }
+ catch (error) {
+ if (failure.reason === "abort") return { reason: "abort" };
+ if (failure.reason === "timeout" || isNetworkFailure(error))
+ return { reason: "transient" };
+
+ throw error;
+ }
+ finally {
+ clearTimeout(timeout);
+ request.signal.removeEventListener("abort", abort);
+ }
+}
+
+function retryAfterMs(response: Response): number | undefined {
+ const header = response.headers.get("Retry-After");
+ if (!header) return undefined;
+ const seconds = Number(header);
+
+ return Number.isFinite(seconds) ? seconds * SECONDS_TO_MS : undefined;
+}
+
function setAuthenticatedSession(hasSession: boolean) {
if (hasSession === sessionState.hasSession) return;
sessionState.hasSession = hasSession;
@@ -114,6 +268,19 @@ function subscribeAuthenticatedSession(listener: () => void) {
return () => authSessionListeners.delete(listener);
}
+function waitForRetry(ms: number, signal: AbortSignal): Promise {
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(resolve, ms);
+
+ const abort = () => {
+ clearTimeout(timeout);
+ reject(abortError());
+ };
+
+ signal.addEventListener("abort", abort, { once: true });
+ });
+}
+
const apiClient = createClient({
baseUrl: apiBaseUrl,
credentials: "include",
diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts
index e9c4d04..0abfd80 100644
--- a/src/shared/i18n/locales/ru.ts
+++ b/src/shared/i18n/locales/ru.ts
@@ -76,9 +76,11 @@ const ru = {
bankName: "Банк (необязательно)",
cancel: "Отмена",
card: "Карта",
+ cardSaved: "Карта сохранена",
categories: "Категории",
category: "Категория",
categoryName: "Название категории",
+ categorySaved: "Категория сохранена",
close: "Закрыть",
date: "Дата операции",
defaultCategory: "По умолчанию",
@@ -153,7 +155,9 @@ const ru = {
openingHint:
"Остаток до операций с выбранной даты. Валюта — рубли (RUB).",
operationCard: "Карта операции",
+ operationDeleted: "Операция удалена",
operations: "Операции",
+ operationSaved: "Операция сохранена",
page: "Страница {{page}}",
pageSize: "Операций на странице",
pagination: "Страницы операций",
@@ -516,6 +520,8 @@ const ru = {
errorTitle: "Что-то пошло не так",
loadingText: "Пожалуйста, подождите.",
loadingTitle: "Загрузка",
+ unavailableText: "Данные появятся здесь, когда чтение восстановится.",
+ unavailableTitle: "Пока недоступно",
},
stock: {
aboutCompany: "О компании",
@@ -696,6 +702,7 @@ const ru = {
"Не удалось загрузить статус подключения. Обновите страницу.",
title: "T-Bank Invest",
token: "Token T-Bank",
+ tokenReplaced: "Подключение обновлено",
tokenRequired: "Введите token",
unavailable: "T-Bank временно недоступен. Повторите попытку позже.",
},
diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts
index 5561391..9b45661 100644
--- a/src/shared/lib/index.ts
+++ b/src/shared/lib/index.ts
@@ -8,4 +8,8 @@ export {
formatSnapshotTime,
instrumentLogoUrl,
} from "./display";
+export {
+ type InitialAvailability,
+ useInitialAvailability,
+} from "./initial-availability";
export { useUrlPeriod } from "./useUrlPeriod";
diff --git a/src/shared/lib/initial-availability.test.tsx b/src/shared/lib/initial-availability.test.tsx
new file mode 100644
index 0000000..d4ca70a
--- /dev/null
+++ b/src/shared/lib/initial-availability.test.tsx
@@ -0,0 +1,107 @@
+import { act, renderHook } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { useInitialAvailability } from "./initial-availability";
+
+describe("useInitialAvailability", () => {
+ it("keeps loading until content, error, or the initial wait budget", async () => {
+ vi.useFakeTimers();
+
+ try {
+ const { rerender, result } = renderHook(
+ props => useInitialAvailability(props),
+ {
+ initialProps: {
+ hasContent: false,
+ identity: "account-a",
+ isError: false,
+ isFetching: true,
+ timeoutMs: 100,
+ },
+ },
+ );
+
+ expect(result.current).toBe("loading");
+ await act(() => vi.advanceTimersByTimeAsync(99));
+ expect(result.current).toBe("loading");
+ await act(() => vi.advanceTimersByTimeAsync(1));
+ expect(result.current).toBe("unavailable");
+
+ rerender({
+ hasContent: true,
+ identity: "account-a",
+ isError: false,
+ isFetching: false,
+ timeoutMs: 100,
+ });
+ expect(result.current).toBe("ready");
+ }
+ finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("does not flash loading during retry for the same identity", async () => {
+ vi.useFakeTimers();
+
+ try {
+ const { rerender, result } = renderHook(
+ props => useInitialAvailability(props),
+ {
+ initialProps: {
+ hasContent: false,
+ identity: "account-a",
+ isError: true,
+ isFetching: false,
+ },
+ },
+ );
+
+ await act(() => vi.runOnlyPendingTimersAsync());
+ expect(result.current).toBe("unavailable");
+ rerender({
+ hasContent: false,
+ identity: "account-a",
+ isError: false,
+ isFetching: true,
+ });
+
+ expect(result.current).toBe("unavailable");
+ }
+ finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("resets loading only when identity changes", async () => {
+ vi.useFakeTimers();
+
+ try {
+ const { rerender, result } = renderHook(
+ props => useInitialAvailability(props),
+ {
+ initialProps: {
+ hasContent: false,
+ identity: "account-a",
+ isError: true,
+ isFetching: false,
+ },
+ },
+ );
+
+ await act(() => vi.runOnlyPendingTimersAsync());
+ expect(result.current).toBe("unavailable");
+ rerender({
+ hasContent: false,
+ identity: "account-b",
+ isError: false,
+ isFetching: true,
+ });
+
+ expect(result.current).toBe("loading");
+ }
+ finally {
+ vi.useRealTimers();
+ }
+ });
+});
diff --git a/src/shared/lib/initial-availability.ts b/src/shared/lib/initial-availability.ts
new file mode 100644
index 0000000..4366b8c
--- /dev/null
+++ b/src/shared/lib/initial-availability.ts
@@ -0,0 +1,49 @@
+import { useEffect, useState } from "react";
+
+const INITIAL_WAIT_BUDGET_MS = 10_000;
+
+type InitialAvailability
+ = | "loading"
+ | "ready"
+ | "unavailable";
+
+type InitialAvailabilityInput = {
+ hasContent: boolean;
+ identity: string;
+ isError: boolean;
+ isFetching: boolean;
+ timeoutMs?: number;
+};
+
+function useInitialAvailability(
+ input: InitialAvailabilityInput,
+): InitialAvailability {
+ const timeoutMs = input.timeoutMs ?? INITIAL_WAIT_BUDGET_MS;
+
+ const [unavailableIdentity, setUnavailableIdentity] = useState(
+ null,
+ );
+
+ useEffect(() => {
+ if (input.hasContent) return;
+
+ if (input.isError) {
+ const timer = setTimeout(() => setUnavailableIdentity(input.identity), 0);
+
+ return () => clearTimeout(timer);
+ }
+
+ const timer = setTimeout(
+ () => setUnavailableIdentity(input.identity),
+ timeoutMs,
+ );
+
+ return () => clearTimeout(timer);
+ }, [input.hasContent, input.identity, input.isError, timeoutMs]);
+
+ if (input.hasContent) return "ready";
+
+ return unavailableIdentity === input.identity ? "unavailable" : "loading";
+}
+
+export { type InitialAvailability, useInitialAvailability };