feat: add quiet reading with rare confirmations
Part of the quiet-user-feedback change; docs and specs land in their own repositories.
This commit is contained in:
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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<typeof useForm<IncomeForm>>;
|
||||
formRevision: string;
|
||||
lastSuccessfulAt?: null | string;
|
||||
onReset: () => void;
|
||||
onSubmit: (values: IncomeForm) => void;
|
||||
partial?: boolean;
|
||||
refreshError: boolean;
|
||||
refreshing: boolean;
|
||||
retry: () => Promise<unknown> | undefined;
|
||||
sourceError?: boolean;
|
||||
state: "loaded";
|
||||
}
|
||||
| {
|
||||
period: IncomePeriod;
|
||||
retry: () => Promise<unknown> | 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",
|
||||
};
|
||||
};
|
||||
|
||||
+1
-3
@@ -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<AccountIncomePageViewModel, { state: "error" }>;
|
||||
|
||||
const AccountIncomePageError: FC<ErrorProps> = (props) => {
|
||||
const AccountIncomePageError: FC<ErrorProps> = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<StateView
|
||||
action={<Button onClick={props.retry}>{t("income.retry")}</Button>}
|
||||
error
|
||||
text={t("income.errorText")}
|
||||
title={t("income.errorTitle")}
|
||||
|
||||
+1
-40
@@ -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<LoadedProps> = (props) => {
|
||||
};
|
||||
|
||||
const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
|
||||
const { display, form, onReset, onSubmit, refreshError, refreshing, retry }
|
||||
= props;
|
||||
const { display, form, onReset, onSubmit } = props;
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack className={classes.page} gap="lg">
|
||||
<SourceSyncStatus lastSuccessfulAt={props.lastSuccessfulAt} />
|
||||
|
||||
<Title order={1}>{t("income.title")}</Title>
|
||||
|
||||
<Button disabled={refreshing} onClick={retry} w="fit-content">
|
||||
{t("investment.refresh")}
|
||||
</Button>
|
||||
|
||||
<FormProvider {...form}>
|
||||
<IncomePeriodForm
|
||||
key={props.formRevision}
|
||||
@@ -63,32 +50,6 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
|
||||
/>
|
||||
</FormProvider>
|
||||
|
||||
{!!refreshing && (
|
||||
<Group aria-label={t("income.refreshing")} gap="xs" role="status">
|
||||
<Loader size="sm" />
|
||||
|
||||
<Text>{t("income.refreshing")}</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{!!refreshError && (
|
||||
<Alert aria-live="polite" color="orange" role="status">
|
||||
<Stack gap="sm">
|
||||
<Text>
|
||||
{t(
|
||||
props.sourceError
|
||||
? "investment.syncFailed"
|
||||
: "income.refreshError",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Button onClick={retry} variant="default">
|
||||
{t("income.retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<AccountIncomePageLoadedSummary {...props} />
|
||||
|
||||
<Alert color="blue">{t("income.taxBoundary")}</Alert>
|
||||
|
||||
Reference in New Issue
Block a user