diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.retry.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.retry.test.tsx
new file mode 100644
index 0000000..db2644f
--- /dev/null
+++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.retry.test.tsx
@@ -0,0 +1,155 @@
+import { MantineProvider } from "@mantine/core";
+import { QueryClientProvider } from "@tanstack/react-query";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { http, HttpResponse } from "msw";
+import { MemoryRouter, Route, Routes } from "react-router";
+import { describe, expect, it, vi } from "vitest";
+
+import { configureAuthClient } from "@/shared/api";
+import { createTestQueryClient } from "@/test/query";
+import { resource } from "@/test/resource";
+import { server } from "@/test/server";
+
+import { AccountEventsPage } from "./AccountEventsPage";
+
+// Automatic read retries wait 1s + 2s before the failure is final.
+const RETRIES_EXHAUSTED_MS = 6000;
+const TEST_MS = 10_000;
+
+const snapshot = resource({
+ events: [
+ {
+ date: "2026-10-01",
+ dateKind: "event",
+ displayName: "Synthetic coupon",
+ figi: null,
+ instrumentUid: null,
+ isin: null,
+ logoName: null,
+ projectedAmount: { currency: "RUB", value: "123.45" },
+ ticker: "SYN",
+ type: "coupon",
+ },
+ ],
+ incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }],
+});
+
+function renderEvents() {
+ configureAuthClient({
+ accessToken: () => "token",
+ clear: vi.fn(),
+ refresh: () => Promise.resolve(true),
+ });
+ const queryClient = createTestQueryClient();
+
+ render(
+
+
+
+
+ }
+ path="/accounts/:accountId/events"
+ />
+
+
+
+ ,
+ );
+
+ return { queryClient };
+}
+
+describe("AccountEventsPage read retry", () => {
+ it(
+ "offers retry after an initial failure and recovers in place",
+ { timeout: TEST_MS },
+ async () => {
+ let isFailing = true;
+ let requests = 0;
+ server.use(
+ http.get("*/api/v1/brokerage-accounts/:accountId/events", () => {
+ requests += 1;
+
+ return isFailing
+ ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
+ : HttpResponse.json(snapshot);
+ }),
+ );
+ renderEvents();
+
+ const retry = await screen.findByRole(
+ "button",
+ { name: "Повторить" },
+ { timeout: RETRIES_EXHAUSTED_MS },
+ );
+
+ expect(requests).toBe(3);
+
+ isFailing = false;
+ fireEvent.click(retry);
+
+ expect(await screen.findByText("Купон · Synthetic coupon")).toBeInTheDocument();
+ expect(requests).toBe(4);
+ },
+ );
+
+ it(
+ "keeps cached events, warns and retries the same GET",
+ { timeout: TEST_MS },
+ async () => {
+ let isFailing = false;
+ let requests = 0;
+ server.use(
+ http.get("*/api/v1/brokerage-accounts/:accountId/events", () => {
+ requests += 1;
+
+ return isFailing
+ ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
+ : HttpResponse.json(snapshot);
+ }),
+ );
+ const { queryClient } = renderEvents();
+ await screen.findByText("Купон · Synthetic coupon");
+ expect(screen.queryByRole("button", { name: "Повторить" }))
+ .not.toBeInTheDocument();
+
+ isFailing = true;
+ void queryClient.invalidateQueries({
+ queryKey: ["get", "/api/v1/brokerage-accounts/{accountId}/events"],
+ });
+
+ const retry = await screen.findByRole(
+ "button",
+ { name: "Повторить" },
+ { timeout: RETRIES_EXHAUSTED_MS },
+ );
+
+ expect(screen.getByText("Не удалось обновить данные")).toBeInTheDocument();
+ expect(screen.getByText("Купон · Synthetic coupon")).toBeInTheDocument();
+ expect(requests).toBe(4);
+
+ isFailing = false;
+ fireEvent.click(retry);
+
+ await waitFor(() =>
+ expect(screen.queryByText("Не удалось обновить данные"))
+ .not.toBeInTheDocument());
+ expect(requests).toBe(5);
+ },
+ );
+
+ it("does not offer a plain retry when the account is gone", async () => {
+ server.use(
+ http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
+ HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 })),
+ );
+ renderEvents();
+
+ await waitFor(() =>
+ expect(screen.queryByRole("heading", { name: "Загрузка" }))
+ .not.toBeInTheDocument());
+ expect(screen.queryByRole("button", { name: "Повторить" }))
+ .not.toBeInTheDocument();
+ });
+});
diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx
index 15994eb..7a3db60 100644
--- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx
+++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx
@@ -169,15 +169,18 @@ describe("AccountEventsPage", () => {
expect(screen.getAllByText("O")).toHaveLength(2);
});
- it("renders empty and quiet error states", async () => {
+ it("renders empty and error states with retry", async () => {
const { rerender } = renderPage();
expect(
screen.getByText("За выбранный период событий не найдено"),
).toBeInTheDocument();
query.mockReturnValue({
data: undefined,
+ error: { code: "TBANK_UNAVAILABLE", status: 503 },
isError: true,
+ isFetching: false,
isLoading: false,
+ refetch: vi.fn(),
} as never);
rerender(
@@ -187,7 +190,7 @@ describe("AccountEventsPage", () => {
,
);
expect(await screen.findByRole("button", { name: "Сбросить" })).toBeVisible();
- expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Повторить" })).toBeVisible();
});
it("shows a structural busy state before events are available", () => {
diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts
index 92ef995..3190415 100644
--- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts
+++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts
@@ -1,13 +1,12 @@
import { useState } from "react";
import { useParams } from "react-router";
-import type { InvestmentResourceMeta } from "@/shared/api";
-
import {
type BrokerageEvent,
useBrokerageEvents,
} from "@/entities/brokerage-event";
import { useScreenSync } from "@/features/tbank-connection";
+import { type InvestmentResourceMeta, readRetry } from "@/shared/api";
import {
defaultPeriod,
isValidPeriod,
@@ -28,11 +27,16 @@ type AccountEventsPageViewModel
onPeriodPopoverOpen: () => void;
onReset: () => void;
partial?: boolean;
+ readRetry: ReturnType;
source?: InvestmentResourceMeta;
state: "loaded";
totalVisible: boolean;
}
- | { onReset: () => void; state: "error" }
+ | {
+ onReset: () => void;
+ readRetry: ReturnType;
+ state: "error";
+ }
| { state: "loading" };
function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
@@ -83,6 +87,8 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
isFetching: events.isFetching,
});
+ const retry = readRetry([events]);
+
if (availability === "loading" && events.isLoading)
return { state: "loading" };
@@ -94,11 +100,12 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
if ((events.isError || events.meta?.lastAttempt.state === "failed") && !events.data)
return {
onReset,
+ readRetry: retry,
state: "error",
};
if (!events.data)
return availability === "unavailable"
- ? { onReset, state: "error" }
+ ? { onReset, readRetry: retry, state: "error" }
: { state: "loading" };
return {
@@ -122,6 +129,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
onPeriodPopoverOpen: () => setMinDate(moscowDate(new Date())),
onReset,
partial: events.meta?.completeness === "incomplete",
+ readRetry: retry,
source: events.meta,
state: "loaded",
totalVisible: events.meta?.completeness === "complete",
diff --git a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageError.view.tsx b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageError.view.tsx
index 86666b5..b36b3d2 100644
--- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageError.view.tsx
+++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageError.view.tsx
@@ -4,7 +4,12 @@ import { Button, Group } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
-type AccountEventsPageErrorProps = { onReset: () => void };
+import { ReadRetryButton, type ReadRetryProps } from "@/shared/ui";
+
+type AccountEventsPageErrorProps = {
+ onReset: () => void;
+ readRetry: ReadRetryProps;
+};
const AccountEventsPageError: FC = (props) => {
const { t } = useTranslation();
@@ -13,6 +18,13 @@ const AccountEventsPageError: FC = (props) => {
+ {props.readRetry.exhausted && (
+
+ )}
+
diff --git a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx
index 4fbd2f7..514d839 100644
--- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx
+++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx
@@ -18,7 +18,7 @@ import {
brokerageEventKey,
} from "@/entities/brokerage-event";
import { groupItems } from "@/shared/lib";
-import { SourceSyncStatus } from "@/shared/ui";
+import { ReadRetry, SourceSyncStatus } from "@/shared/ui";
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
@@ -83,6 +83,8 @@ const AccountEventsPageLoaded: FC = (props) => {
stale={props.source?.freshness === "stale"}
/>
+
+
{!!props.partial && {t("investment.partial")}}
{t("events.hint")}