feat(income): offer retry after exhausted income reads (WealthLedger/frontend#55)

This commit is contained in:
2026-11-03 08:38:23 +03:00
parent d3335a7a8d
commit a2e9d63bfe
4 changed files with 116 additions and 20 deletions
@@ -38,6 +38,10 @@ import {
} from "./AccountIncomePage.vm"; } from "./AccountIncomePage.vm";
import { IncomePayments } from "./components/IncomePayments.view"; import { IncomePayments } from "./components/IncomePayments.view";
// Automatic read retries wait 1s + 2s before the failure is final.
const RETRIES_EXHAUSTED_MS = 6000;
const TEST_MS = 10_000;
const analytics const analytics
= { = {
accountId: "account-1", accountId: "account-1",
@@ -433,21 +437,95 @@ describe("AccountIncomePage", () => {
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
}); });
it("shows a quiet realized error", async () => { it(
server.use( "offers retry after an initial failure and recovers in place",
http.get( { timeout: TEST_MS },
"*/api/v1/brokerage-accounts/account-1/income-analytics", async () => {
() => new HttpResponse(null, { status: 503 }), let isFailing = true;
), let requests = 0;
); server.use(
renderPage(); http.get(
expect( "*/api/v1/brokerage-accounts/account-1/income-analytics",
await screen.findByText("Не удалось рассчитать пассивный доход", undefined, { () => {
timeout: 4000, requests += 1;
}),
).toBeInTheDocument(); return isFailing
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); ? new HttpResponse(null, { status: 503 })
}); : HttpResponse.json(resource(analytics));
},
),
);
renderPage();
expect(
await screen.findByText(
"Не удалось рассчитать пассивный доход",
undefined,
{ timeout: RETRIES_EXHAUSTED_MS },
),
).toBeInTheDocument();
const retry = screen.getByRole("button", { name: "Повторить" });
expect(requests).toBe(3);
isFailing = false;
fireEvent.click(retry);
expect(
await screen.findByRole("region", { name: "Выплаты" }),
).toBeInTheDocument();
expect(requests).toBe(4);
},
);
it(
"keeps cached income, warns and retries the same GET",
{ timeout: TEST_MS },
async () => {
let isFailing = false;
let requests = 0;
server.use(
http.get(
"*/api/v1/brokerage-accounts/account-1/income-analytics",
() => {
requests += 1;
return isFailing
? HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
)
: HttpResponse.json(resource(analytics));
},
),
);
const { queryClient } = renderPage();
await screen.findByRole("region", { name: "Выплаты" });
expect(screen.queryByRole("button", { name: "Повторить" }))
.not.toBeInTheDocument();
isFailing = true;
void queryClient.invalidateQueries({
queryKey: ["get", "/api/v1/brokerage-accounts/{accountId}/income-analytics"],
});
const retry = await screen.findByRole(
"button",
{ name: "Повторить" },
{ timeout: RETRIES_EXHAUSTED_MS },
);
expect(screen.getByText("Не удалось обновить данные")).toBeInTheDocument();
expect(screen.getByRole("region", { name: "Выплаты" })).toBeInTheDocument();
expect(requests).toBe(4);
isFailing = false;
fireEvent.click(retry);
await waitFor(() =>
expect(screen.queryByText("Не удалось обновить данные"))
.not.toBeInTheDocument());
expect(requests).toBe(5);
},
);
}); });
const LocationProbe: FC = () => { const LocationProbe: FC = () => {
@@ -3,10 +3,9 @@ import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useParams, useSearchParams } from "react-router"; import { useParams, useSearchParams } from "react-router";
import type { InvestmentResourceMeta } from "@/shared/api";
import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income"; import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
import { useScreenSync } from "@/features/tbank-connection"; import { useScreenSync } from "@/features/tbank-connection";
import { type InvestmentResourceMeta, readRetry } from "@/shared/api";
import { isValidPeriod, moscowDate, useInitialAvailability } from "@/shared/lib"; import { isValidPeriod, moscowDate, useInitialAvailability } from "@/shared/lib";
import { import {
@@ -32,12 +31,14 @@ type AccountIncomePageViewModel
onReset: () => void; onReset: () => void;
onSubmit: (values: IncomeForm) => void; onSubmit: (values: IncomeForm) => void;
partial?: boolean; partial?: boolean;
readRetry: ReturnType<typeof readRetry>;
source?: InvestmentResourceMeta; source?: InvestmentResourceMeta;
state: "loaded"; state: "loaded";
totalVisible: boolean; totalVisible: boolean;
} }
| { | {
period: IncomePeriod; period: IncomePeriod;
readRetry: ReturnType<typeof readRetry>;
state: "error"; state: "error";
} }
| { state: "loading" }; | { state: "loading" };
@@ -87,12 +88,14 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
isFetching: analytics.isFetching, isFetching: analytics.isFetching,
}); });
const retry = readRetry([analytics]);
if (availability === "loading" && analytics.isLoading) if (availability === "loading" && analytics.isLoading)
return { state: "loading" }; return { state: "loading" };
if (!analytics.data) if (!analytics.data)
return availability === "unavailable" return availability === "unavailable"
? { period, state: "error" } ? { period, readRetry: retry, state: "error" }
: { state: "loading" }; : { state: "loading" };
const display = toIncomeDisplayModel(analytics.data); const display = toIncomeDisplayModel(analytics.data);
@@ -119,6 +122,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
onReset, onReset,
onSubmit, onSubmit,
partial: analytics.meta?.completeness === "incomplete", partial: analytics.meta?.completeness === "incomplete",
readRetry: retry,
source: analytics.meta, source: analytics.meta,
state: "loaded", state: "loaded",
totalVisible: analytics.meta?.completeness === "complete", totalVisible: analytics.meta?.completeness === "complete",
@@ -3,15 +3,27 @@ import type { FC } from "react";
import { StateView } from "@wealthledger/ui"; import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ReadRetryButton } from "@/shared/ui";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm"; import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
type ErrorProps = Extract<AccountIncomePageViewModel, { state: "error" }>; type ErrorProps = Extract<AccountIncomePageViewModel, { state: "error" }>;
const AccountIncomePageError: FC<ErrorProps> = () => { const AccountIncomePageError: FC<ErrorProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const retry = props.readRetry.exhausted
? (
<ReadRetryButton
onRetry={props.readRetry.onRetry}
retrying={props.readRetry.retrying}
/>
)
: undefined;
return ( return (
<StateView <StateView
action={retry}
error error
text={t("income.errorText")} text={t("income.errorText")}
title={t("income.errorTitle")} title={t("income.errorTitle")}
@@ -9,7 +9,7 @@ import { StateView } from "@wealthledger/ui";
import { FormProvider } from "react-hook-form"; import { FormProvider } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { SourceSyncStatus } from "@/shared/ui"; import { ReadRetry, SourceSyncStatus } from "@/shared/ui";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm"; import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
@@ -57,6 +57,8 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
stale={props.source?.freshness === "stale"} stale={props.source?.freshness === "stale"}
/> />
<ReadRetry {...props.readRetry} />
<AccountIncomePageLoadedSummary {...props} /> <AccountIncomePageLoadedSummary {...props} />
<Alert color="blue">{t("income.taxBoundary")}</Alert> <Alert color="blue">{t("income.taxBoundary")}</Alert>