feat: show persisted investment data and sync state

Consume the provided resource-envelope API snapshot. Preserve financial snapshots during
refresh, observe synchronization without importing, and fence late responses across
account and connection changes.
This commit is contained in:
2026-09-26 21:22:49 +03:00
parent b987fd8e78
commit 4e5f564fa1
81 changed files with 4714 additions and 1420 deletions
@@ -1,6 +1,12 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import {
fireEvent,
render,
renderHook,
screen,
waitFor,
} from "@testing-library/react";
import { HttpResponse, http } from "msw";
import type { FC } from "react";
import {
@@ -12,9 +18,11 @@ import {
} from "react-router";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { createQueryWrapper, createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { AccountEventsPage } from "./AccountEventsPage";
import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm";
const Navigation: FC = () => {
const location = useLocation();
@@ -44,12 +52,14 @@ describe("Upcoming event date filters", () => {
"*/api/v1/brokerage-accounts/:accountId/events",
({ request }) => {
searches.push(new URL(request.url).search);
return HttpResponse.json({
accountId,
calculatedAt: "2099-01-01T00:00:00Z",
events: [],
incomeTotalsByCurrency: [],
});
return HttpResponse.json(
resource({
accountId,
calculatedAt: "2099-01-01T00:00:00Z",
events: [],
incomeTotalsByCurrency: [],
}),
);
},
),
);
@@ -110,3 +120,45 @@ describe("Upcoming event date filters", () => {
},
);
});
it("retains the previous complete forecast and timestamp when its source refresh fails", async () => {
configureAuthClient({
accessToken: () => "synthetic",
refresh: async () => true,
clear: vi.fn(),
});
const events = [
{
date: "2026-10-01",
displayName: "Synthetic coupon",
type: "coupon",
projectedAmount: { currency: "RUB", value: "123.45" },
},
];
const snapshot = resource(
{ events, incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }] },
{ sync: { id: "failed", state: "failed", errorCode: "TBANK_UNAVAILABLE" } },
);
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
HttpResponse.json(snapshot),
),
);
const { wrapper: QueryWrapper } = createQueryWrapper();
const { result } = renderHook(() => useAccountEventsPageViewModel("all"), {
wrapper: ({ children }) => (
<QueryWrapper>
<MemoryRouter>{children}</MemoryRouter>
</QueryWrapper>
),
});
await waitFor(() =>
expect(result.current).toMatchObject({
state: "loaded",
events,
sourceError: true,
refreshError: true,
lastSuccessfulAt: snapshot.meta.lastSuccessfulAt,
}),
);
});
@@ -5,6 +5,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { useBrokerageEvents } from "@/entities/brokerage-event";
import { AccountEventsPage } from "./AccountEventsPage";
const { refresh } = vi.hoisted(() => ({ refresh: vi.fn() }));
vi.mock("@/features/tbank-connection", () => ({
useScreenSync: () => ({
refresh,
refreshing: false,
sourceError: false,
transportError: false,
}),
}));
vi.mock("@/entities/brokerage-event", async (importOriginal) => ({
...(await importOriginal<typeof import("@/entities/brokerage-event")>()),
useBrokerageEvents: vi.fn(),
@@ -24,12 +33,17 @@ function renderPage() {
describe("AccountEventsPage", () => {
beforeEach(() => {
refresh.mockClear();
query.mockReturnValue({
data: {
accountId: "account",
calculatedAt: "2026-08-14T00:00:00Z",
events: [],
},
meta: {
availability: "complete",
sync: { state: "idle", id: null, errorCode: null },
},
isError: false,
isLoading: false,
refetch: vi.fn(),
@@ -55,6 +69,10 @@ describe("AccountEventsPage", () => {
it("keeps the loading fallback when no result or account is available", () => {
query.mockReturnValue({
data: undefined,
meta: {
availability: "complete",
sync: { state: "idle", id: null, errorCode: null },
},
isError: false,
isLoading: false,
refetch: vi.fn(),
@@ -99,6 +117,10 @@ describe("AccountEventsPage", () => {
},
],
},
meta: {
availability: "complete",
sync: { state: "idle", id: null, errorCode: null },
},
isError: false,
isLoading: false,
refetch: vi.fn(),
@@ -145,12 +167,16 @@ describe("AccountEventsPage", () => {
</MantineProvider>,
);
screen.getByRole("button", { name: "Повторить" }).click();
expect(refetch).toHaveBeenCalledOnce();
expect(refresh).toHaveBeenCalledOnce();
});
it("shows a structural busy state before events are available", () => {
query.mockReturnValue({
data: undefined,
meta: {
availability: "complete",
sync: { state: "idle", id: null, errorCode: null },
},
isError: false,
isLoading: true,
refetch: vi.fn(),
@@ -4,6 +4,7 @@ import {
type BrokerageEvent,
useBrokerageEvents,
} from "@/entities/brokerage-event";
import { useScreenSync } from "@/features/tbank-connection";
type AccountEventsPageViewModel =
| { state: "loading" }
@@ -11,6 +12,9 @@ type AccountEventsPageViewModel =
| {
state: "loaded";
events: BrokerageEvent[];
sourceError?: boolean;
lastSuccessfulAt?: string | null;
partial?: boolean;
refreshing: boolean;
refreshError: boolean;
retry: () => void;
@@ -41,22 +45,34 @@ function useAccountEventsPageViewModel(
...(appliedFrom ? { from: appliedFrom } : {}),
...(appliedTo ? { to: appliedTo } : {}),
});
const sync = useScreenSync(
{
screen: "events",
accountId,
...(appliedFrom ? { from: appliedFrom } : {}),
...(appliedTo ? { to: appliedTo } : {}),
},
[events],
);
if (events.isLoading) return { state: "loading" };
if (events.isError && !events.data)
if ((events.isError || events.meta?.sync.state === "failed") && !events.data)
return {
state: "error",
retry: () => {
void events.refetch();
void sync.refresh();
},
};
if (!events.data) return { state: "loading" };
return {
state: "loaded",
events: events.data.events,
refreshing: events.isFetching,
refreshError: events.isError,
sourceError: sync.sourceError,
lastSuccessfulAt: events.meta?.lastSuccessfulAt,
partial: events.meta?.availability !== "complete",
refreshing: events.isFetching || sync.refreshing,
refreshError: events.isError || sync.sourceError || sync.transportError,
retry: () => {
void events.refetch();
void sync.refresh();
},
draftFrom,
draftTo,
@@ -11,6 +11,7 @@ import { DatePickerInput } from "@mantine/dates";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { BrokerageEventCard } from "@/entities/brokerage-event";
import { formatSnapshotTime } from "@/shared/lib";
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
import { AccountEventsPageEmpty } from "./AccountEventsPageEmpty.view";
@@ -23,13 +24,30 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
const { t } = useTranslation();
return (
<Stack gap="lg">
{props.lastSuccessfulAt ? (
<Text size="xs" c="dimmed">
{t("investment.sourceTime", {
date: formatSnapshotTime(props.lastSuccessfulAt),
})}
</Text>
) : null}
<Title order={1}>{t("events.title")}</Title>
<Button onClick={props.retry} disabled={props.refreshing} w="fit-content">
{t("investment.refresh")}
</Button>
{props.partial ? (
<Alert role="status">{t("investment.partial")}</Alert>
) : null}
{props.refreshing ? (
<Progress value={100} animated aria-label={t("accounts.refreshing")} />
) : null}
{props.refreshError ? (
<Alert color="yellow">
{t("accounts.refreshError")}
<Alert color="yellow" role="status" aria-live="polite">
{t(
props.sourceError
? "investment.syncFailed"
: "accounts.refreshError",
)}
<Button variant="subtle" onClick={props.retry}>
{t("accounts.retry")}
</Button>
@@ -80,9 +98,9 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
/>
))}
</Stack>
) : (
) : !props.partial ? (
<AccountEventsPageEmpty />
)}
) : null}
</Stack>
);
};
@@ -1,3 +1,4 @@
import { resource } from "@/test/resource";
import "dayjs/locale/ru";
import { MantineProvider } from "@mantine/core";
import { DatesProvider } from "@mantine/dates";
@@ -154,7 +155,7 @@ describe("AccountIncomePage", () => {
"*/api/v1/brokerage-accounts/:accountId/income-analytics",
({ request }) => {
requested.push(new URL(request.url).search);
return HttpResponse.json(analytics);
return HttpResponse.json(resource(analytics));
},
),
);
@@ -236,7 +237,7 @@ describe("AccountIncomePage", () => {
};
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(response),
HttpResponse.json(resource(response)),
),
);
renderPage();
@@ -284,7 +285,7 @@ describe("AccountIncomePage", () => {
])("shows a local error for %s", async (_name, response) => {
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(response),
HttpResponse.json(resource(response)),
),
);
renderPage();
@@ -299,13 +300,15 @@ describe("AccountIncomePage", () => {
it("accepts an empty analytics response", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json({
...analytics,
totalsByCurrency: [],
buckets: [],
instruments: [],
entries: [],
}),
HttpResponse.json(
resource({
...analytics,
totalsByCurrency: [],
buckets: [],
instruments: [],
entries: [],
}),
),
),
);
renderPage();
@@ -347,7 +350,7 @@ describe("AccountIncomePage", () => {
};
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(response),
HttpResponse.json(resource(response)),
),
);
renderPage();
@@ -369,13 +372,15 @@ describe("AccountIncomePage", () => {
const eventsRequest = vi.fn(() => new HttpResponse(null, { status: 503 }));
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json({
...analytics,
yearComparison: {
period: { from: "2025-08-01", to: "2025-08-27" },
totalsByCurrency: [comparison("RUB")],
},
}),
HttpResponse.json(
resource({
...analytics,
yearComparison: {
period: { from: "2025-08-01", to: "2025-08-27" },
totalsByCurrency: [comparison("RUB")],
},
}),
),
),
http.get("*/api/v1/brokerage-accounts/account-1/events", eventsRequest),
);
@@ -392,13 +397,16 @@ describe("AccountIncomePage", () => {
expect(screen.queryByText("Динамика")).not.toBeInTheDocument();
expect(eventsRequest).not.toHaveBeenCalled();
expect(screen.getByRole("region", { name: "Выплаты" })).toBeInTheDocument();
const selected = screen.getByRole("region", {
const year = screen.getByRole("region", { name: "Годом ранее" });
const selected = within(year).getByRole("region", {
name: "01.08.2026 – 27.08.2026",
});
expect(
screen.getByRole("region", { name: "01.08.2025 – 27.08.2025" }),
).toBeInTheDocument();
expect(screen.queryByText(/Предыдущий период/)).not.toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "Предыдущий период" }),
).toBeVisible();
expect(screen.queryByText("Выбранный период")).not.toBeInTheDocument();
expect(screen.queryByText("Сравниваемый период")).not.toBeInTheDocument();
expect(
@@ -409,7 +417,7 @@ describe("AccountIncomePage", () => {
for (const amount of ["10 ₽", "2 ₽", "12 ₽"]) {
expect(within(selected).getByText(amount)).toBeInTheDocument();
}
const difference = screen.getByRole("region", { name: "Разница" });
const difference = within(year).getByRole("region", { name: "Разница" });
for (const amount of ["+9 ₽", "+2 ₽", "+11 ₽"]) {
expect(within(difference).getByText(amount)).toBeInTheDocument();
}
@@ -425,7 +433,18 @@ describe("AccountIncomePage", () => {
it("shows the unavailable year state without falling back to the previous period", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(analytics),
HttpResponse.json(
resource(analytics, {
dependencies: [
resource(analytics).meta,
resource(analytics).meta,
{
...resource(null).meta,
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
},
],
}),
),
),
);
renderPage();
@@ -433,9 +452,11 @@ describe("AccountIncomePage", () => {
await screen.findByText("Сравнение с годом ранее недоступно."),
).toBeInTheDocument();
expect(
screen.queryByRole("region", { name: "Разница" }),
screen.queryByRole("region", { name: "Годом ранее" }),
).not.toBeInTheDocument();
expect(screen.queryByText(/Предыдущий период/)).not.toBeInTheDocument();
expect(
screen.getByRole("region", { name: "Предыдущий период" }),
).toBeVisible();
});
it("shows a recoverable realized error", async () => {
@@ -537,13 +558,15 @@ function respondWithRequestedPeriod(requests: string[]) {
({ request }) => {
const url = new URL(request.url);
requests.push(url.search);
return HttpResponse.json({
...analytics,
period: {
from: url.searchParams.get("from"),
to: url.searchParams.get("to"),
},
});
return HttpResponse.json(
resource({
...analytics,
period: {
from: url.searchParams.get("from"),
to: url.searchParams.get("to"),
},
}),
);
},
),
);
@@ -669,7 +692,32 @@ describe("income period and snapshot boundary", () => {
await screen.findByText(/Показан последний загруженный результат/),
).toBeInTheDocument();
expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible();
respondWithRequestedPeriod([]);
server.use(
http.post("*/api/v1/tbank-sync", () =>
HttpResponse.json(
{
id: "income",
scope: { screen: "income", accountId: "account-1" },
now: "2026-09-25T00:00:00Z",
},
{ status: 202 },
),
),
http.get("*/api/v1/tbank-sync/:id", () =>
HttpResponse.json({
id: "income",
scope: null,
now: "2026-09-25T00:00:00Z",
state: "succeeded",
errorCode: null,
progress: { total: 1, completed: 1, discovering: false },
resources: [resource(null, { stateVersion: "2" }).meta],
}),
),
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(resource(analytics, { stateVersion: "2" })),
),
);
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
await waitFor(() =>
expect(
@@ -694,10 +742,12 @@ describe("income period and snapshot boundary", () => {
"*/api/v1/brokerage-accounts/account-1/income-analytics",
async () => {
await pending;
return HttpResponse.json({
...analytics,
period: { from: "2026-08-02", to: "2026-08-27" },
});
return HttpResponse.json(
resource({
...analytics,
period: { from: "2026-08-02", to: "2026-08-27" },
}),
);
},
),
);
@@ -778,3 +828,64 @@ describe("income period and snapshot boundary", () => {
expect(screen.getAllByText("Купон")).toHaveLength(6);
});
});
it("shows selected income before comparison coverage without inventing a zero baseline", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(
resource({ ...analytics, previous: null, yearComparison: null }),
),
),
);
renderPage();
expect(await screen.findByRole("region", { name: "Выплаты" })).toBeVisible();
expect(
screen.queryByRole("region", { name: "Год назад" }),
).not.toBeInTheDocument();
expect(screen.getAllByText("12 ₽").length).toBeGreaterThan(0);
expect(
screen.getAllByText("Период сравнения ещё не загружен полностью."),
).toHaveLength(2);
expect(
screen.queryByText("Сравнение с годом ранее недоступно."),
).not.toBeInTheDocument();
expect(
screen.queryByRole("region", { name: "Разница" }),
).not.toBeInTheDocument();
expect(screen.getByLabelText("Период")).toBeVisible();
});
it("keeps complete previous comparison while year source failed without inventing its amounts", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(
resource(analytics, {
dependencies: [
resource(analytics).meta,
resource(analytics).meta,
{
...resource(null).meta,
sync: {
id: null,
state: "failed",
errorCode: "TBANK_UNAVAILABLE",
},
},
],
}),
),
),
);
renderPage();
expect(
await screen.findByRole("region", { name: "Предыдущий период" }),
).toBeVisible();
expect(
screen.getByText(
"Не удалось загрузить период сравнения. Повторите обновление.",
),
).toBeVisible();
expect(
screen.queryByRole("region", { name: "Годом ранее" }),
).not.toBeInTheDocument();
});
@@ -4,6 +4,7 @@ import { useForm } from "react-hook-form";
import { useParams, useSearchParams } from "react-router";
import { z } from "zod";
import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
import { useScreenSync } from "@/features/tbank-connection";
import { validationMessage } from "@/shared/i18n";
import { formatDate } from "@/shared/lib";
import {
@@ -17,7 +18,16 @@ import {
const ISO_MONTH_LENGTH = 7;
type IncomeMeta = ReturnType<typeof useIncomeAnalytics>["meta"];
type ComparisonNotice =
| "comparisonPending"
| "comparisonFailed"
| "yearUnavailable";
type IncomeDisplayModel = {
previousComparison: IncomeDisplayModel["comparison"];
previousNotice: ComparisonNotice;
yearNotice: ComparisonNotice;
entries: { key: string; operation: IncomeAnalytics["entries"][number] }[];
comparison: {
selectedPeriod: string;
@@ -38,6 +48,9 @@ type AccountIncomePageViewModel =
analytics: IncomeAnalytics;
display: IncomeDisplayModel;
formRevision: number;
sourceError?: boolean;
lastSuccessfulAt?: string | null;
partial?: boolean;
refreshing: boolean;
refreshError: boolean;
retry: () => void;
@@ -90,6 +103,10 @@ const useAccountIncomePageViewModel = (
});
const [formRevision, setFormRevision] = useState(0);
const analytics = useIncomeAnalytics(accountId, period);
const sync = useScreenSync(
{ screen: "income", accountId, from: period.from, to: period.to },
[analytics],
);
useEffect(() => {
if (!validPeriod(requested)) setSearchParams(period, { replace: true });
@@ -112,21 +129,30 @@ const useAccountIncomePageViewModel = (
setSearchParams(next, { replace: true });
};
if (analytics.isLoading || !analytics.data) {
if (analytics.isError)
return { state: "error", retry: () => analytics.refetch(), period };
if (analytics.isError || analytics.meta?.sync.state === "failed")
return { state: "error", retry: sync.refresh, period };
return { state: "loading" };
}
const display = toIncomeDisplayModel(analytics.data);
const display = toIncomeDisplayModel(analytics.data, analytics.meta);
if (!display)
return { state: "error", retry: () => analytics.refetch(), period };
return {
state: "error",
retry: () => {
void analytics.observe();
},
period,
};
return {
state: "loaded",
analytics: analytics.data,
display,
formRevision,
refreshing: analytics.isFetching,
refreshError: analytics.isError,
retry: () => analytics.refetch(),
sourceError: sync.sourceError,
lastSuccessfulAt: analytics.meta?.lastSuccessfulAt,
partial: analytics.meta?.availability !== "complete",
refreshing: analytics.isFetching || sync.refreshing,
refreshError: analytics.isError || sync.sourceError || sync.transportError,
retry: sync.refresh,
form,
onSubmit,
onReset,
@@ -157,6 +183,7 @@ function validPeriod(period: IncomePeriod): period is IncomePeriod {
function toIncomeDisplayModel(
analytics: IncomeAnalytics,
meta?: IncomeMeta,
): IncomeDisplayModel | null {
const instruments = new Map<
string,
@@ -209,33 +236,53 @@ function toIncomeDisplayModel(
if (instrument.includedCount !== instrument.receiptCount) return null;
}
// The income endpoint returns selected, previous and year dependencies in order.
const [, previous, year] = meta?.dependencies ?? [];
return {
entries,
comparison: analytics.yearComparison
? {
selectedPeriod: formatComparisonPeriod(analytics.period),
comparedPeriod: formatComparisonPeriod(
analytics.yearComparison.period,
),
values: analytics.yearComparison.totalsByCurrency.map((value) => ({
...value,
percentages: {
total: formatIncomeChange(
value.difference.total.value,
value.compared.total.value,
),
coupon: formatIncomeChange(
value.difference.coupon.value,
value.compared.coupon.value,
),
dividend: formatIncomeChange(
value.difference.dividend.value,
value.compared.dividend.value,
),
},
})),
}
: null,
previousComparison: comparisonDisplay(analytics.period, analytics.previous),
comparison: comparisonDisplay(analytics.period, analytics.yearComparison),
previousNotice: comparisonNotice(previous),
yearNotice: comparisonNotice(year),
};
}
function comparisonNotice(
dependency:
| NonNullable<NonNullable<IncomeMeta>["dependencies"]>[number]
| undefined,
): ComparisonNotice {
if (dependency?.unavailableReason) return "yearUnavailable";
return dependency?.sync.state === "failed"
? "comparisonFailed"
: "comparisonPending";
}
function comparisonDisplay(
period: IncomePeriod,
comparison: IncomeAnalytics["yearComparison"],
): IncomeDisplayModel["comparison"] {
if (!comparison) return null;
return {
selectedPeriod: formatComparisonPeriod(period),
comparedPeriod: formatComparisonPeriod(comparison.period),
values: comparison.totalsByCurrency.map((value) => ({
...value,
percentages: {
total: formatIncomeChange(
value.difference.total.value,
value.compared.total.value,
),
coupon: formatIncomeChange(
value.difference.coupon.value,
value.compared.coupon.value,
),
dividend: formatIncomeChange(
value.difference.dividend.value,
value.compared.dividend.value,
),
},
})),
};
}
@@ -11,6 +11,7 @@ import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { FormProvider } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { formatSnapshotTime } from "@/shared/lib";
import classes from "../AccountIncomePage.module.css";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
import { IncomeComparisons } from "./IncomeComparisons.view";
@@ -34,7 +35,17 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
const { t } = useTranslation();
return (
<Stack gap="lg" className={classes.page}>
{props.lastSuccessfulAt ? (
<Text size="xs" c="dimmed">
{t("investment.sourceTime", {
date: formatSnapshotTime(props.lastSuccessfulAt),
})}
</Text>
) : null}
<Title order={1}>{t("income.title")}</Title>
<Button onClick={retry} disabled={refreshing} w="fit-content">
{t("investment.refresh")}
</Button>
<FormProvider {...form}>
<IncomePeriodForm
key={props.formRevision}
@@ -49,16 +60,24 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
</Group>
) : null}
{refreshError ? (
<Alert color="orange">
<Alert color="orange" role="status" aria-live="polite">
<Stack gap="sm">
<Text>{t("income.refreshError")}</Text>
<Text>
{t(
props.sourceError
? "investment.syncFailed"
: "income.refreshError",
)}
</Text>
<Button onClick={retry} variant="default">
{t("income.retry")}
</Button>
</Stack>
</Alert>
) : null}
{analytics.totalsByCurrency.length ? (
{props.partial ? (
<Alert role="status">{t("investment.partial")}</Alert>
) : analytics.totalsByCurrency.length ? (
<IncomeSummary totals={analytics.totalsByCurrency} />
) : (
<StateView
@@ -67,7 +86,16 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
/>
)}
<Alert color="blue">{t("income.taxBoundary")}</Alert>
<IncomeComparisons comparison={display.comparison} />
<IncomeComparisons
title={t("income.previousPeriod")}
comparison={display.previousComparison}
notice={t(`income.${display.previousNotice}`)}
/>
<IncomeComparisons
title={t("income.yearAgo")}
comparison={display.comparison}
notice={t(`income.${display.yearNotice}`)}
/>
<IncomePayments entries={display.entries} />
</Stack>
);
@@ -5,8 +5,14 @@ import { formatMoney } from "@/shared/lib";
import classes from "../AccountIncomePage.module.css";
import type { IncomeDisplayModel } from "../AccountIncomePage.vm";
type IncomeComparisonsProps = { comparison: IncomeDisplayModel["comparison"] };
type ComparisonProps = NonNullable<IncomeDisplayModel["comparison"]>;
type IncomeComparisonsProps = {
comparison: IncomeDisplayModel["comparison"];
title: string;
notice: string;
};
type ComparisonProps = NonNullable<IncomeDisplayModel["comparison"]> & {
title: string;
};
type ComparisonValuesProps = {
kind: "selected" | "compared" | "difference";
label: string;
@@ -15,16 +21,15 @@ type ComparisonValuesProps = {
};
const IncomeComparisons: FC<IncomeComparisonsProps> = (props) => {
const { t } = useTranslation();
return (
<Stack>
<Title order={2}>{t("income.yearAgo")}</Title>
<Title order={2}>{props.title}</Title>
{props.comparison ? (
<Comparison {...props.comparison} />
<Comparison {...props.comparison} title={props.title} />
) : (
<Paper p="sm" radius="md" className={classes.comparisonNotice}>
<Text size="sm" c="gray.7">
{t("income.yearUnavailable")}
<Text size="sm" c="gray.7" role="status" aria-live="polite">
{props.notice}
</Text>
</Paper>
)}
@@ -35,7 +40,7 @@ const IncomeComparisons: FC<IncomeComparisonsProps> = (props) => {
const Comparison: FC<ComparisonProps> = (props) => {
const { t } = useTranslation();
return (
<section aria-label={t("income.yearAgo")} className={classes.comparison}>
<section aria-label={props.title} className={classes.comparison}>
<Stack gap="md">
{props.values.map((value) => (
<Stack
@@ -10,6 +10,7 @@ import {
} from "@/entities/broker-operation";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { AccountOperationsPage } from "./AccountOperationsPage";
@@ -39,11 +40,13 @@ function configureTestAuth() {
});
}
function renderPage() {
function renderPage(search = "") {
return render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider>
<MemoryRouter initialEntries={["/accounts/account-1/operations"]}>
<MemoryRouter
initialEntries={[`/accounts/account-1/operations${search}`]}
>
<Routes>
<Route
path="/accounts/:accountId/operations"
@@ -193,13 +196,15 @@ describe("AccountOperationsPage", () => {
({ request }) => {
const url = new URL(request.url);
searches.push(url.search);
return HttpResponse.json({
accountId: "account-1",
items: [operation],
nextCursor: url.searchParams.get("cursor") ? null : "next-1",
hasNext: !url.searchParams.get("cursor"),
observedAt: "2026-08-14T00:00:00Z",
});
return HttpResponse.json(
resource({
accountId: "account-1",
items: [operation],
nextCursor: url.searchParams.get("cursor") ? null : "next-1",
hasNext: !url.searchParams.get("cursor"),
observedAt: "2026-08-14T00:00:00Z",
}),
);
},
),
);
@@ -229,13 +234,15 @@ describe("AccountOperationsPage", () => {
({ request }) => {
const url = new URL(request.url);
searches.push(url.search);
return HttpResponse.json({
accountId: "account-1",
items: [operation],
nextCursor: null,
hasNext: false,
observedAt: "2026-08-14T00:00:00Z",
});
return HttpResponse.json(
resource({
accountId: "account-1",
items: [operation],
nextCursor: null,
hasNext: false,
observedAt: "2026-08-14T00:00:00Z",
}),
);
},
),
);
@@ -262,13 +269,15 @@ describe("AccountOperationsPage", () => {
({ request }) => {
const url = new URL(request.url);
searches.push(url.search);
return HttpResponse.json({
accountId: "account-1",
items: [operation],
nextCursor: null,
hasNext: false,
observedAt: "2026-08-14T00:00:00Z",
});
return HttpResponse.json(
resource({
accountId: "account-1",
items: [operation],
nextCursor: null,
hasNext: false,
observedAt: "2026-08-14T00:00:00Z",
}),
);
},
),
);
@@ -301,3 +310,58 @@ describe("AccountOperationsPage", () => {
).toBeInTheDocument();
});
});
it("does not label incomplete empty operations as a confirmed empty period", async () => {
configureTestAuth();
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
HttpResponse.json(
resource(
{
accountId: "account-1",
items: [],
hasNext: false,
nextCursor: null,
},
{ availability: "partial" },
),
),
),
);
renderPage();
expect(await screen.findByText("Период загружен не полностью")).toBeVisible();
expect(screen.queryByText("Операций нет")).not.toBeInTheDocument();
});
it("recovers an invalid cursor at the first page without clearing applied filters", async () => {
configureTestAuth();
const searches: URLSearchParams[] = [];
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/operations",
({ request }) => {
const params = new URL(request.url).searchParams;
searches.push(params);
if (params.has("cursor"))
return HttpResponse.json(
{ code: "INVALID_OPERATIONS_CURSOR" },
{ status: 400 },
);
return HttpResponse.json(
resource({
items: [operation],
hasNext: true,
nextCursor: "old-cursor",
}),
);
},
),
);
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.length).toBe(3));
expect(searches[2].get("cursor")).toBeNull();
for (const key of ["from", "to", "categories"])
expect(searches[2].get(key)).toBe(searches[0].get(key));
});
@@ -9,6 +9,7 @@ import {
useBrokerageOperations,
} from "@/entities/broker-operation";
import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api";
type AccountOperationsPageViewModel =
@@ -16,6 +17,10 @@ type AccountOperationsPageViewModel =
| { state: "error"; retry: () => void }
| {
state: "loaded";
sourceError?: boolean;
lastSuccessfulAt?: string | null;
partial?: boolean;
historyLoading?: boolean;
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
@@ -82,6 +87,10 @@ const useAccountOperationsPageVm = (
...firstPageQuery,
...(cursor ? { cursor } : {}),
});
const sync = useScreenSync(
{ screen: "operations", accountId, ...firstPageQuery },
[operations],
);
const resetQueryKey = brokerageOperationsQueryOptions(accountId, {
limit: 100,
}).queryKey;
@@ -95,12 +104,12 @@ const useAccountOperationsPageVm = (
exact: true,
});
resetCursors();
} else void operations.refetch();
} else void sync.refresh();
};
if (operations.isLoading) return { state: "loading" };
if (
operations.isError &&
(operations.isError || operations.meta?.sync.state === "failed") &&
(!operations.data ||
apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR")
)
@@ -115,8 +124,14 @@ const useAccountOperationsPageVm = (
return {
state: "loaded",
refreshing: operations.isFetching,
refreshError: operations.isError,
sourceError: sync.sourceError,
lastSuccessfulAt: operations.meta?.lastSuccessfulAt,
partial: operations.meta?.availability !== "complete",
historyLoading:
operations.meta?.coverage?.historyState === "loading" ||
operations.meta?.coverage?.historyState === "partial",
refreshing: operations.isFetching || sync.refreshing,
refreshError: operations.isError || sync.sourceError || sync.transportError,
retry,
draftFrom,
draftTo,
@@ -5,6 +5,7 @@ import {
MultiSelect,
Progress,
Stack,
Text,
Title,
} from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
@@ -16,6 +17,7 @@ import {
type OperationCategory,
operationCategories,
} from "@/entities/broker-operation";
import { formatSnapshotTime } from "@/shared/lib";
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
type AccountOperationsPageLoadedProps = Extract<
@@ -59,13 +61,31 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
return (
<Stack gap="lg">
{props.lastSuccessfulAt ? (
<Text size="xs" c="dimmed">
{t("investment.sourceTime", {
date: formatSnapshotTime(props.lastSuccessfulAt),
})}
</Text>
) : null}
<Title order={1}>{t("ledger.title")}</Title>
<Button onClick={props.retry} disabled={props.refreshing} w="fit-content">
{t("investment.refresh")}
</Button>
{props.partial ? (
<Alert role="status">{t("investment.partial")}</Alert>
) : null}
{props.historyLoading ? <Alert>{t("investment.history")}</Alert> : null}
{props.refreshing ? (
<Progress value={100} animated aria-label={t("accounts.refreshing")} />
) : null}
{props.refreshError ? (
<Alert color="yellow">
{t("accounts.refreshError")}
<Alert color="yellow" role="status" aria-live="polite">
{t(
props.sourceError
? "investment.syncFailed"
: "accounts.refreshError",
)}
<Button variant="subtle" onClick={props.retry}>
{t("accounts.retry")}
</Button>
@@ -120,12 +140,12 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
operation={operation}
/>
))
) : (
) : !props.partial ? (
<StateView
title={t("ledger.emptyTitle")}
text={t("ledger.emptyText")}
/>
)}
) : null}
{hasNext || hasPrevious ? (
<Group justify="space-between">
<Button disabled={!hasPrevious} onClick={onPrevious}>
@@ -14,6 +14,7 @@ import type { BrokerageEvent } from "@/entities/brokerage-event";
import { type Portfolio, portfolioQueryOptions } from "@/entities/portfolio";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { AccountPage } from "./AccountPage";
import type {
@@ -144,27 +145,31 @@ describe("AccountPage states", () => {
};
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json({
...portfolio,
positions:
accountId === "all"
? ["account-1", "account-2"].map((id) => ({
...position,
account: { id, name: id },
}))
: [position],
}),
HttpResponse.json(
resource({
...portfolio,
positions:
accountId === "all"
? ["account-1", "account-2"].map((id) => ({
...position,
account: { id, name: id },
}))
: [position],
}),
),
),
http.get(`*/api/v1/${collection}/:uid`, () => {
requests();
return HttpResponse.json({
instrumentUid: uid,
slug,
name: "Название из карточки инструмента",
ticker: "OTHER-TICKER",
isin: "OTHER-ISIN",
logoName: "other.png",
});
return HttpResponse.json(
resource({
instrumentUid: uid,
slug,
name: "Название из карточки инструмента",
ticker: "OTHER-TICKER",
isin: "OTHER-ISIN",
logoName: "other.png",
}),
);
}),
);
configureTestAuth();
@@ -204,22 +209,24 @@ describe("AccountPage states", () => {
const requests = vi.fn();
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json({
...portfolio,
positions: [
{
...portfolio.positions[0],
instrumentUid: uid,
ticker: "SBERP",
},
],
}),
HttpResponse.json(
resource({
...portfolio,
positions: [
{
...portfolio.positions[0],
instrumentUid: uid,
ticker: "SBERP",
},
],
}),
),
),
http.get("*/api/v1/stocks/:uid", () => {
requests();
return scenario === "unavailable"
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json({ instrumentUid: uid, slug: uid });
: HttpResponse.json(resource({ instrumentUid: uid, slug: uid }));
}),
);
renderAccountRoute(createTestQueryClient());
@@ -234,7 +241,7 @@ describe("AccountPage states", () => {
it("keeps the account and positions visible for an older HTTP response without structure", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json({ ...portfolio, structure: undefined }),
HttpResponse.json(resource({ ...portfolio, structure: undefined })),
),
);
renderAccountRoute(createTestQueryClient());
@@ -322,11 +329,13 @@ describe("AccountPage states", () => {
async (hasPositions) => {
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json({
...portfolio,
totalsByCurrency: [],
positions: hasPositions ? portfolio.positions : [],
}),
HttpResponse.json(
resource({
...portfolio,
totalsByCurrency: [],
positions: hasPositions ? portfolio.positions : [],
}),
),
),
);
renderAccountRoute(createTestQueryClient());
@@ -358,7 +367,7 @@ describe("AccountPage states", () => {
};
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(snapshot),
HttpResponse.json(resource(snapshot)),
),
);
const queryClient = createTestQueryClient();
@@ -380,7 +389,7 @@ describe("AccountPage states", () => {
).toEqual(totalsByCurrency.length ? ["100 ₽"] : []);
expect(
queryClient.getQueryData(portfolioQueryOptions("account-1").queryKey),
).toEqual(snapshot);
).toEqual(resource(snapshot));
},
);
@@ -887,7 +896,7 @@ describe("AccountPage states", () => {
expect(screen.getByText("Вторая")).toBeInTheDocument();
});
it.each([503, 404])(
it.each([503])(
"preserves totals and UTC snapshot through refresh error %i, retry and success",
async (status) => {
const initialPortfolio: Portfolio = {
@@ -907,9 +916,36 @@ describe("AccountPage states", () => {
let requestCount = 0;
let resolveRetry: ((response: Response) => void) | undefined;
server.use(
http.post("*/api/v1/tbank-sync", () =>
HttpResponse.json(
{
id: "overview",
scope: { screen: "account", accountId: "account-1" },
now: "2026-09-25T00:00:00Z",
},
{ status: 202 },
),
),
http.get("*/api/v1/tbank-sync/:id", () =>
HttpResponse.json({
id: "overview",
scope: { screen: "account", accountId: "account-1" },
now: "2026-09-25T00:00:00Z",
state: "succeeded",
errorCode: null,
progress: { total: 1, completed: 1, discovering: false },
resources: [
resource(null, { resourceKey: "portfolio", stateVersion: "2" })
.meta,
],
}),
),
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
requestCount += 1;
if (requestCount === 1) return HttpResponse.json(initialPortfolio);
if (requestCount === 1)
return HttpResponse.json(
resource(initialPortfolio, { resourceKey: "portfolio" }),
);
if (requestCount === 3)
return new Promise<Response>((resolve) => {
resolveRetry = resolve;
@@ -961,14 +997,19 @@ describe("AccountPage states", () => {
snapshotText,
);
resolveRetry?.(
HttpResponse.json({
...portfolio,
isStale: false,
lastSuccessfulAt: "2026-08-14T11:30:00Z",
totalsByCurrency: [{ currency: "USD", value: "-0.5" }],
structure: { currencies: [], unvaluedCategories: [] },
cashBalances: [{ currency: "EUR", value: "2.50" }],
}),
HttpResponse.json(
resource(
{
...portfolio,
isStale: false,
lastSuccessfulAt: "2026-08-14T11:30:00Z",
totalsByCurrency: [{ currency: "USD", value: "-0.5" }],
structure: { currencies: [], unvaluedCategories: [] },
cashBalances: [{ currency: "EUR", value: "2.50" }],
},
{ resourceKey: "portfolio", stateVersion: "2" },
),
),
);
await waitFor(() =>
expect(
@@ -993,7 +1034,7 @@ describe("AccountPage states", () => {
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
requestCount += 1;
if (requestCount === 1) return HttpResponse.json(portfolio);
if (requestCount === 1) return HttpResponse.json(resource(portfolio));
return new Promise<Response>((resolve) => {
resolveRefresh = resolve;
});
@@ -1020,6 +1061,6 @@ describe("AccountPage states", () => {
screen.getByRole("status", { name: "Обновляем портфель" }),
).toBeInTheDocument();
resolveRefresh?.(HttpResponse.json(portfolio));
resolveRefresh?.(HttpResponse.json(resource(portfolio)));
});
});
@@ -17,6 +17,7 @@ const AccountPage: FC<AccountPageProps> = (props) => {
account={viewModel.account}
accountId={viewModel.accountId}
refreshing={viewModel.refreshing}
sourceError={viewModel.sourceError}
refreshError={viewModel.refreshError}
retry={viewModel.retry}
recentOperations={viewModel.recentOperations}
@@ -28,9 +29,11 @@ const AccountPage: FC<AccountPageProps> = (props) => {
<AccountPageLoaded
account={viewModel.account}
accountId={viewModel.accountId}
partial={viewModel.partial}
portfolio={viewModel.portfolio}
positionSections={viewModel.positionSections}
refreshing={viewModel.refreshing}
sourceError={viewModel.sourceError}
refreshError={viewModel.refreshError}
retry={viewModel.retry}
recentOperations={viewModel.recentOperations}
@@ -1,5 +1,5 @@
import { MantineProvider } from "@mantine/core";
import { renderHook, waitFor } from "@testing-library/react";
import { act, renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import type { FC, PropsWithChildren } from "react";
import { MemoryRouter, Route, Routes } from "react-router";
@@ -7,6 +7,7 @@ import { describe, expect, it, vi } from "vitest";
import type { Portfolio } from "@/entities/portfolio";
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import {
toAccountPagePositionSections,
@@ -86,49 +87,53 @@ function createWrapper() {
function mockLoadedAccountPage() {
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(portfolio),
HttpResponse.json(resource(portfolio)),
),
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
HttpResponse.json({
accountId: "account-1",
items: [
{
id: "operation-1",
occurredAt: "2026-08-13T10:00:00Z",
type: "Buy",
category: "trade",
description: "Покупка акции",
figi: "FIGI-STOCK",
instrumentUid: null,
ticker: "STK",
instrumentType: "share",
payment: { currency: "RUB", value: "-100" },
quantity: "1",
price: null,
accruedInterest: null,
commission: null,
},
],
nextCursor: null,
hasNext: false,
observedAt: "2026-08-13T10:00:00Z",
}),
HttpResponse.json(
resource({
accountId: "account-1",
items: [
{
id: "operation-1",
occurredAt: "2026-08-13T10:00:00Z",
type: "Buy",
category: "trade",
description: "Покупка акции",
figi: "FIGI-STOCK",
instrumentUid: null,
ticker: "STK",
instrumentType: "share",
payment: { currency: "RUB", value: "-100" },
quantity: "1",
price: null,
accruedInterest: null,
commission: null,
},
],
nextCursor: null,
hasNext: false,
observedAt: "2026-08-13T10:00:00Z",
}),
),
),
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
HttpResponse.json({
accountId: "account-1",
calculatedAt: "2026-08-13T10:00:00Z",
events: Array.from({ length: 6 }, (_, index) => ({
type: "coupon",
date: `2026-09-${String(index + 1).padStart(2, "0")}`,
dateKind: "event",
figi: `FIGI-EVENT-${index}`,
instrumentUid: null,
ticker: `BND${index}`,
displayName: `Купон ${index}`,
projectedAmount: { currency: "RUB", value: String(index + 1) },
})),
}),
HttpResponse.json(
resource({
accountId: "account-1",
calculatedAt: "2026-08-13T10:00:00Z",
events: Array.from({ length: 6 }, (_, index) => ({
type: "coupon",
date: `2026-09-${String(index + 1).padStart(2, "0")}`,
dateKind: "event",
figi: `FIGI-EVENT-${index}`,
instrumentUid: null,
ticker: `BND${index}`,
displayName: `Купон ${index}`,
projectedAmount: { currency: "RUB", value: String(index + 1) },
})),
}),
),
),
);
}
@@ -281,7 +286,7 @@ describe("useAccountPageViewModel", () => {
configureTestAuth();
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(portfolio),
HttpResponse.json(resource(portfolio)),
),
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
@@ -304,3 +309,111 @@ describe("useAccountPageViewModel", () => {
);
});
});
it("keeps overview sections independent when their source data is incomplete", async () => {
configureTestAuth();
mockLoadedAccountPage();
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(resource(portfolio, { availability: "partial" })),
),
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
HttpResponse.json(
resource(
{ items: [], hasNext: false, nextCursor: null },
{ availability: "partial" },
),
),
),
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
HttpResponse.json(
resource(
{ events: [], incomeTotalsByCurrency: [] },
{ availability: "partial" },
),
),
),
);
const { result } = renderHook(() => useAccountPageViewModel(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.state).toBe("loaded"));
await waitFor(() =>
expect(result.current).toMatchObject({
partial: true,
recentOperations: { state: "partial", items: [] },
recentEvents: { state: "partial", items: [] },
}),
);
});
it("refreshes every overview section through one account descriptor and observational reads", async () => {
configureTestAuth();
const sections: Record<string, unknown> = {
portfolio,
operations: { items: [], hasNext: false, nextCursor: null },
events: { events: [], incomeTotalsByCurrency: [] },
"income-summary": { totalsByCurrency: [] },
};
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, { resourceKey: path, stateVersion: version }),
);
},
),
),
http.post("*/api/v1/tbank-sync", async ({ request }) => {
scopes.push(await request.json());
version = "2";
return HttpResponse.json(
{
id: "overview",
scope: { screen: "account", accountId: "account-1" },
now: "2026-09-25T00:00:00Z",
},
{ status: 202 },
);
}),
http.get("*/api/v1/tbank-sync/:id", () =>
HttpResponse.json({
id: "overview",
scope: null,
now: "2026-09-25T00:00:00Z",
state: "succeeded",
errorCode: null,
progress: { total: 4, completed: 4, discovering: false },
resources: Object.keys(sections).map(
(resourceKey) =>
resource(null, { resourceKey, stateVersion: version }).meta,
),
}),
),
);
const { result } = renderHook(() => useAccountPageViewModel(), {
wrapper: createWrapper(),
});
await waitFor(() =>
expect(result.current).toMatchObject({
state: "loaded",
recentOperations: { state: "empty" },
recentEvents: { state: "empty" },
incomeSummary: { state: "empty" },
}),
);
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([{ screen: "account", accountId: "account-1" }]);
});
@@ -1,10 +1,9 @@
import { useQueryClient } from "@tanstack/react-query";
import { useParams } from "react-router";
import type { BrokerageOperation } from "@/entities/broker-operation";
import { useBrokerageOperations } from "@/entities/broker-operation";
import {
type BrokerageAccount,
brokerageAccountsQueryKey,
useBrokerageAccounts,
} from "@/entities/brokerage-account";
import {
type BrokerageEventsPreviewState,
@@ -14,6 +13,7 @@ import { type IncomeSummary, useIncomeSummary } from "@/entities/income";
import type { Portfolio } from "@/entities/portfolio";
import { formatShare, usePortfolio } from "@/entities/portfolio";
import { useStocks } from "@/entities/stock";
import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api";
import { formatMoney, instrumentLogoUrl } from "@/shared/lib";
@@ -46,19 +46,31 @@ type AccountPageRecentOperations =
to: string;
}
| {
state: "loaded";
state: "loaded" | "partial";
to: string;
items: BrokerageOperation[];
lastSuccessfulAt?: string | null;
stale?: boolean;
refreshError?: boolean;
};
type AccountPageRecentEvents = BrokerageEventsPreviewState;
type AccountPageIncomeSummary =
| { state: "loading" | "empty"; to: string }
| { state: "loading" | "partial" | "empty"; to: string }
| { state: "error"; to: string; retry: () => void }
| { state: "loaded"; to: string; summary: IncomeSummary };
| {
state: "loaded";
to: string;
summary: IncomeSummary;
lastSuccessfulAt?: string | null;
stale?: boolean;
refreshError?: boolean;
};
type AccountPageContent = {
sourceError?: boolean;
partial?: boolean;
account?: BrokerageAccount;
accountId?: string;
refreshing: boolean;
@@ -102,27 +114,39 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
});
const events = useBrokerageEvents(accountId);
const income = useIncomeSummary(accountId);
const account = useQueryClient()
.getQueryData<BrokerageAccount[]>(brokerageAccountsQueryKey)
?.find((item) => item.id === accountId);
const accounts = useBrokerageAccounts();
const sync = useScreenSync({ screen: "account", accountId }, [
accounts,
portfolio,
operations,
events,
income,
]);
const account = accounts.data?.find((item) => item.id === accountId);
const recentOperations = toRecentOperations(accountId, operations);
const recentEvents = toRecentEvents(accountId, events);
const incomeSummary = toIncomeSummary(accountId, income);
if (!portfolio.data) {
if (portfolio.isError)
if (portfolio.isError || portfolio.meta?.sync.state === "failed")
return {
state: "error" as const,
code: apiErrorCode(portfolio.error),
retry: () => portfolio.refetch(),
retry: sync.refresh,
};
return { state: "loading" as const };
}
const refresh = {
refreshing: portfolio.isFetching,
refreshError: portfolio.isError,
retry: () => portfolio.refetch(),
partial: portfolio.meta?.availability === "partial",
refreshing: portfolio.isFetching || sync.refreshing,
sourceError: sync.sourceError,
refreshError: portfolio.isError || sync.transportError || sync.sourceError,
retry: sync.refresh,
};
if (!portfolio.data.positions.length && !portfolio.data.cashBalances.length)
if (
portfolio.meta?.availability === "complete" &&
!portfolio.data.positions.length &&
!portfolio.data.cashBalances.length
)
return {
state: "empty" as const,
account,
@@ -153,11 +177,20 @@ function toIncomeSummary(
income: ReturnType<typeof useIncomeSummary>,
): AccountPageIncomeSummary {
const to = `/accounts/${accountId}/income`;
if (income.isLoading) return { state: "loading", to };
if (income.isError)
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 { state: "error", to, retry: () => income.refetch() };
if (!income.data?.totalsByCurrency.length) return { state: "empty", to };
return { state: "loaded", to, summary: income.data };
return {
state: "loaded",
to,
summary: income.data,
lastSuccessfulAt: income.meta?.lastSuccessfulAt,
stale: income.meta?.freshness === "stale",
refreshError: income.isError || income.meta?.sync.state === "failed",
};
}
function toRecentOperations(
@@ -165,10 +198,29 @@ function toRecentOperations(
operations: ReturnType<typeof useBrokerageOperations>,
): AccountPageRecentOperations {
const to = `/accounts/${accountId}/operations`;
if (operations.isLoading) return { state: "loading", to };
if (operations.isError) return { state: "error", to };
if (
!operations.data &&
!operations.isError &&
operations.meta?.sync.state !== "failed"
)
return { state: "loading", to };
if (operations.meta?.availability === "partial")
return { state: "partial", to, items: operations.data?.items ?? [] };
if (
!operations.data &&
(operations.isError || operations.meta?.sync.state === "failed")
)
return { state: "error", to };
if (!operations.data?.items.length) return { state: "empty", to };
return { state: "loaded", to, items: operations.data.items };
return {
state: "loaded",
to,
items: operations.data.items,
lastSuccessfulAt: operations.meta?.lastSuccessfulAt,
stale: operations.meta?.freshness === "stale",
refreshError:
operations.isError || operations.meta?.sync.state === "failed",
};
}
function toRecentEvents(
@@ -176,11 +228,26 @@ function toRecentEvents(
events: ReturnType<typeof useBrokerageEvents>,
): AccountPageRecentEvents {
const to = `/accounts/${accountId}/events`;
if (events.isLoading) return { state: "loading", to };
if (events.isError) return { state: "error", to };
if (!events.data && !events.isError && events.meta?.sync.state !== "failed")
return { state: "loading", to };
if (events.meta?.availability === "partial")
return {
state: "partial",
to,
items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [],
};
if (!events.data && (events.isError || events.meta?.sync.state === "failed"))
return { state: "error", to };
const items = events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [];
if (!items.length) return { state: "empty", to };
return { state: "loaded", to, items };
return {
state: "loaded",
to,
items,
lastSuccessfulAt: events.meta?.lastSuccessfulAt,
stale: events.meta?.freshness === "stale",
refreshError: events.isError || events.meta?.sync.state === "failed",
};
}
function toAccountPagePositionSections(
@@ -19,6 +19,7 @@ type AccountPageEmptyProps = {
account?: BrokerageAccount;
accountId?: string;
refreshing?: boolean;
sourceError?: boolean;
refreshError?: boolean;
retry?: () => void;
recentOperations: AccountPageRecentOperations;
@@ -33,6 +34,7 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
<AccountHeading account={props.account} accountId={props.accountId} />
<PortfolioRefreshState
refreshing={props.refreshing ?? false}
sourceError={props.sourceError}
refreshError={props.refreshError ?? false}
retry={props.retry}
/>
@@ -23,9 +23,11 @@ import { RecentOperations } from "./RecentOperations.view";
type AccountPageLoadedProps = {
account?: BrokerageAccount;
accountId?: string;
partial?: boolean;
portfolio: Portfolio;
positionSections: AccountPagePositionSection[];
refreshing?: boolean;
sourceError?: boolean;
refreshError?: boolean;
retry?: () => void;
recentOperations: AccountPageRecentOperations;
@@ -40,6 +42,7 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
<AccountHeading account={props.account} accountId={props.accountId} />
<PortfolioRefreshState
refreshing={props.refreshing ?? false}
sourceError={props.sourceError}
refreshError={props.refreshError ?? false}
retry={props.retry}
/>
@@ -56,11 +59,17 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
},
)}
</Text>
<CurrencyCards amounts={props.portfolio.totalsByCurrency} />
<PortfolioStructure
key={props.accountId}
structure={props.portfolio.structure}
/>
{props.partial ? (
<Alert role="status">{t("investment.partial")}</Alert>
) : (
<CurrencyCards amounts={props.portfolio.totalsByCurrency} />
)}
{!props.partial ? (
<PortfolioStructure
key={props.accountId}
structure={props.portfolio.structure}
/>
) : null}
<Accordion
multiple
defaultValue={[
@@ -4,18 +4,34 @@ import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type { IncomeSummary as IncomeSummaryData } from "@/entities/income";
import { formatMoney } from "@/shared/lib";
import { formatMoney, formatSnapshotTime } from "@/shared/lib";
type IncomeSummaryProps =
| { state: "loading"; to: string }
| { state: "partial"; to: string }
| { state: "error"; to: string; retry: () => void }
| { state: "empty"; to: string }
| { state: "loaded"; to: string; summary: IncomeSummaryData };
| {
state: "loaded";
to: string;
summary: IncomeSummaryData;
lastSuccessfulAt?: string | null;
stale?: boolean;
refreshError?: boolean;
};
const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
const { t } = useTranslation();
if (props.state === "loading")
return <Text c="dimmed">{t("income.summaryLoading")}</Text>;
if (props.state === "loading" || props.state === "partial")
return (
<Text role="status" c="dimmed">
{t(
props.state === "partial"
? "investment.partial"
: "income.summaryLoading",
)}
</Text>
);
if (props.state === "error")
return (
<StateView
@@ -38,6 +54,17 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
);
return (
<Stack gap="xs">
{props.lastSuccessfulAt ? (
<Text size="xs" c="dimmed">
{t("investment.sourceTime", {
date: formatSnapshotTime(props.lastSuccessfulAt),
})}
</Text>
) : null}
{props.stale ? <Text size="xs">{t("investment.stale")}</Text> : null}
{props.refreshError ? (
<Text role="status">{t("income.refreshError")}</Text>
) : null}
{props.summary.totalsByCurrency.map((total) => (
<Paper key={total.currency} p="sm" withBorder>
<Text fw={700}>{formatMoney(total.total)}</Text>
@@ -47,3 +47,8 @@ export const RefreshError: Story = {
name: "Ошибка обновления с retry",
args: { refreshError: true },
};
export const SourceFailure: Story = {
name: "Ошибка синхронизации сохраняет суммы",
args: { refreshError: true, sourceError: true },
};
@@ -3,6 +3,7 @@ import type { FC } from "react";
import { useTranslation } from "react-i18next";
type PortfolioRefreshStateProps = {
sourceError?: boolean;
refreshing: boolean;
refreshError: boolean;
retry?: () => void;
@@ -11,10 +12,17 @@ type PortfolioRefreshStateProps = {
const PortfolioRefreshState: FC<PortfolioRefreshStateProps> = (props) => {
const { refreshing, refreshError, retry } = props;
const { t } = useTranslation();
if (!refreshing && !refreshError) return null;
return (
<>
<Button
onClick={retry}
disabled={refreshing}
variant="light"
w="fit-content"
>
{t(refreshError ? "accounts.retry" : "investment.refresh")}
</Button>
{refreshing ? (
<Group gap="xs" role="status" aria-label={t("portfolio.refreshing")}>
<Loader size="sm" />
@@ -24,12 +32,15 @@ const PortfolioRefreshState: FC<PortfolioRefreshStateProps> = (props) => {
</Group>
) : null}
{refreshError ? (
<Alert color="orange">
<Alert color="orange" role="status" aria-live="polite">
<Stack gap="sm">
<Text>{t("portfolio.refreshError")}</Text>
<Button onClick={retry} variant="light">
{t("accounts.retry")}
</Button>
<Text>
{t(
props.sourceError
? "investment.syncFailed"
: "portfolio.refreshError",
)}
</Text>
</Stack>
</Alert>
) : null}
@@ -4,6 +4,7 @@ import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { LedgerEntryRow } from "@/entities/broker-operation";
import { formatSnapshotTime } from "@/shared/lib";
import type { AccountPageRecentOperations } from "../AccountPage.vm";
type RecentOperationsProps = {
@@ -16,6 +17,23 @@ const RecentOperations: FC<RecentOperationsProps> = (props) => {
return (
<Stack gap="sm">
{operations.state === "loaded" ? (
<>
{operations.lastSuccessfulAt ? (
<Text size="xs" c="dimmed">
{t("investment.sourceTime", {
date: formatSnapshotTime(operations.lastSuccessfulAt),
})}
</Text>
) : null}
{operations.stale ? (
<Text size="xs">{t("investment.stale")}</Text>
) : null}
{operations.refreshError ? (
<Text role="status">{t("accounts.refreshError")}</Text>
) : null}
</>
) : null}
{operations.state === "loading" ? (
<LoadingSkeleton label={t("ledger.recentLoading")}>
{[0, 1, 2].map((index) => (
@@ -29,7 +47,10 @@ const RecentOperations: FC<RecentOperationsProps> = (props) => {
{operations.state === "empty" ? (
<Text c="dimmed">{t("ledger.recentEmpty")}</Text>
) : null}
{operations.state === "loaded"
{operations.state === "partial" ? (
<Text role="status">{t("investment.partial")}</Text>
) : null}
{operations.state === "loaded" || operations.state === "partial"
? operations.items.map((operation) => (
<LedgerEntryRow
key={JSON.stringify([
@@ -7,6 +7,7 @@ import { describe, expect, it, vi } from "vitest";
import { brokerageAccountsQueryKey } from "@/entities/brokerage-account";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { AccountsPage } from "./AccountsPage";
import { AccountsPageEmpty } from "./components/AccountsPageEmpty.view";
@@ -80,7 +81,7 @@ describe("AccountsPage", () => {
server.use(
http.get("*/api/v1/brokerage-accounts", () => {
requestCount += 1;
if (requestCount === 1) return HttpResponse.json([account]);
if (requestCount === 1) return HttpResponse.json(resource([account]));
return new Promise<Response>((resolve) => {
resolveRefresh = resolve;
});
@@ -100,21 +101,31 @@ describe("AccountsPage", () => {
screen.getByRole("status", { name: "Обновляем данные" }),
).toBeInTheDocument();
resolveRefresh?.(HttpResponse.json([account]));
resolveRefresh?.(HttpResponse.json(resource([account])));
});
it("keeps the cached account list and offers retry after a refresh error", async () => {
const requests: unknown[] = [];
server.use(
http.post("*/api/v1/tbank-sync", async ({ request }) => {
requests.push(await request.json());
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
}),
);
let requestCount = 0;
server.use(
http.get("*/api/v1/brokerage-accounts", () => {
requestCount += 1;
if (requestCount === 1) return HttpResponse.json([account]);
if (requestCount === 1) return HttpResponse.json(resource([account]));
if (requestCount === 2)
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
return HttpResponse.json([account]);
return HttpResponse.json(resource([account]));
}),
);
const queryClient = createTestQueryClient();
@@ -132,7 +143,8 @@ describe("AccountsPage", () => {
expect(screen.getByText(account.name)).toBeInTheDocument();
screen.getByRole("button", { name: "Повторить" }).click();
await waitFor(() => expect(requestCount).toBe(3));
await waitFor(() => expect(requests).toEqual([{ screen: "accounts" }]));
expect(requestCount).toBe(2);
});
it("keeps a cached empty account list visible after a refresh error", async () => {
@@ -140,7 +152,7 @@ describe("AccountsPage", () => {
server.use(
http.get("*/api/v1/brokerage-accounts", () => {
requestCount += 1;
if (requestCount === 1) return HttpResponse.json([]);
if (requestCount === 1) return HttpResponse.json(resource([]));
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
@@ -163,3 +175,22 @@ describe("AccountsPage", () => {
expect(screen.getByText("Нет инвестиционных счетов")).toBeInTheDocument();
});
});
it("keeps unknown membership loading instead of declaring no accounts", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json(resource(null)),
),
);
const client = createTestQueryClient();
renderPage(client);
await waitFor(() =>
expect(client.getQueryData(brokerageAccountsQueryKey)).toMatchObject({
data: null,
}),
);
expect(
screen.queryByText("Нет инвестиционных счетов"),
).not.toBeInTheDocument();
expect(screen.getByRole("status")).toBeVisible();
});
@@ -12,7 +12,9 @@ const AccountsPage: FC = () => {
if (viewModel.state === "empty")
return (
<AccountsPageEmpty
lastSuccessfulAt={viewModel.lastSuccessfulAt}
refreshing={viewModel.refreshing}
sourceError={viewModel.sourceError}
refreshError={viewModel.refreshError}
retry={viewModel.retry}
/>
@@ -20,7 +22,9 @@ const AccountsPage: FC = () => {
return (
<AccountsPageLoaded
accounts={viewModel.accounts}
lastSuccessfulAt={viewModel.lastSuccessfulAt}
refreshing={viewModel.refreshing}
sourceError={viewModel.sourceError}
refreshError={viewModel.refreshError}
retry={viewModel.retry}
/>
@@ -2,32 +2,42 @@ import {
brokerageAccountCards,
useBrokerageAccounts,
} from "@/entities/brokerage-account";
import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api";
function useAccountsPageViewModel() {
const accounts = useBrokerageAccounts();
if (!accounts.data) {
if (accounts.isFetching) return { state: "loading" as const };
if (accounts.isError)
const sync = useScreenSync({ screen: "accounts" }, [accounts]);
if (
!accounts.data ||
(accounts.meta?.availability === "partial" && !accounts.data.length)
) {
if (!accounts.isError && accounts.meta?.sync.state !== "failed")
return { state: "loading" as const };
if (accounts.isError || accounts.meta?.sync.state === "failed")
return {
state: "error" as const,
code: apiErrorCode(accounts.error),
retry: () => accounts.refetch(),
retry: sync.refresh,
};
}
if (!accounts.data?.length)
return {
state: "empty" as const,
refreshing: accounts.isFetching,
refreshError: accounts.isError,
retry: () => accounts.refetch(),
lastSuccessfulAt: accounts.meta?.lastSuccessfulAt,
refreshing: accounts.isFetching || sync.refreshing,
sourceError: sync.sourceError,
refreshError: accounts.isError || sync.transportError || sync.sourceError,
retry: sync.refresh,
};
return {
state: "loaded" as const,
accounts: brokerageAccountCards(accounts.data),
refreshing: accounts.isFetching,
refreshError: accounts.isError,
retry: () => accounts.refetch(),
accounts: brokerageAccountCards(accounts.data, accounts.meta?.dependencies),
lastSuccessfulAt: accounts.meta?.lastSuccessfulAt,
refreshing: accounts.isFetching || sync.refreshing,
sourceError: sync.sourceError,
refreshError: accounts.isError || sync.transportError || sync.sourceError,
retry: sync.refresh,
};
}
@@ -9,7 +9,7 @@ const AccountsPageLoading: FC = () => {
<LoadingSkeleton label={t("states.loadingTitle")}>
<Skeleton height={32} width="30%" />
<Skeleton height={20} width="45%" />
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="lg" aria-hidden="true">
<SimpleGrid cols={1} spacing="lg" aria-hidden="true">
{[0, 1].map((index) => (
<Card key={index} padding="xl" radius="lg" withBorder>
<Stack gap="xl">
@@ -1,8 +1,11 @@
import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { formatSnapshotTime } from "@/shared/lib";
type AccountsRefreshStateProps = {
lastSuccessfulAt?: string | null;
sourceError?: boolean;
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
@@ -11,9 +14,24 @@ type AccountsRefreshStateProps = {
const AccountsRefreshState: FC<AccountsRefreshStateProps> = (props) => {
const { refreshing = false, refreshError = false, retry } = props;
const { t } = useTranslation();
if (!refreshing && !refreshError) return null;
return (
<>
{props.lastSuccessfulAt ? (
<Text size="xs" c="dimmed">
{t("investment.sourceTime", {
date: formatSnapshotTime(props.lastSuccessfulAt),
})}
</Text>
) : null}
<Button
onClick={retry}
disabled={refreshing}
variant="light"
w="fit-content"
>
{t(refreshError ? "accounts.retry" : "investment.refresh")}
</Button>
{refreshing ? (
<Group gap="xs" role="status" aria-label={t("accounts.refreshing")}>
<Loader size="sm" />
@@ -23,12 +41,15 @@ const AccountsRefreshState: FC<AccountsRefreshStateProps> = (props) => {
</Group>
) : null}
{refreshError ? (
<Alert color="orange">
<Alert color="orange" role="status" aria-live="polite">
<Stack gap="sm">
<Text>{t("accounts.refreshError")}</Text>
<Button onClick={retry} variant="light">
{t("accounts.retry")}
</Button>
<Text>
{t(
props.sourceError
? "investment.syncFailed"
: "accounts.refreshError",
)}
</Text>
</Stack>
</Alert>
) : null}
+146 -103
View File
@@ -6,6 +6,7 @@ import { MemoryRouter, Route, Routes, useLocation } 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 { BondPage } from "./BondPage";
@@ -92,127 +93,145 @@ function setup(
http.get("*/api/v1/bonds/:uid", () =>
options.failBond
? HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 })
: HttpResponse.json(resolved),
: HttpResponse.json(resource(resolved)),
),
http.get("*/api/v1/bonds/by-ticker/:ticker", ({ request }) => {
requests.push(new URL(request.url));
return options.ambiguousTicker
? HttpResponse.json({ code: "BOND_TICKER_AMBIGUOUS" }, { status: 409 })
: HttpResponse.json(resolved);
: HttpResponse.json(resource(resolved));
}),
http.get("*/api/v1/bonds/:uid/:section", ({ params, request }) => {
requests.push(new URL(request.url));
if (params.section === "metrics")
return HttpResponse.json({
creditRating:
options.ratingState === "missing" ||
options.ratingState === "unavailable"
? null
: {
agency: "NCR",
value: options.ratingState === "withdrawn" ? null : "AAA.ru",
status:
options.ratingState === "withdrawn"
? "withdrawn"
: "active",
date: "2026-04-17",
sourceUrl: "https://ratings.ru/ratings/issues/123/",
observedAt: quote.observedAt,
},
coupon: money("32.88"),
couponDate: "2026-10-27",
couponType: "COUPON_TYPE_FLOATING",
couponPeriodDays: 90,
couponYieldPercent: "27.18",
couponYieldPriceAt: quote.priceAt,
effectiveYield: {
value: "15.6262",
pricePercent: "53.384",
date: "2030-05-15",
dateType: "MATDATE",
tradeAt: quote.priceAt,
},
unavailable: options.ratingState === "unavailable" ? ["rating"] : [],
observedAt: quote.observedAt,
});
return HttpResponse.json(
resource({
creditRating:
options.ratingState === "missing" ||
options.ratingState === "unavailable"
? null
: {
agency: "NCR",
value:
options.ratingState === "withdrawn" ? null : "AAA.ru",
status:
options.ratingState === "withdrawn"
? "withdrawn"
: "active",
date: "2026-04-17",
sourceUrl: "https://ratings.ru/ratings/issues/123/",
observedAt: quote.observedAt,
},
coupon: money("32.88"),
couponDate: "2026-10-27",
couponType: "COUPON_TYPE_FLOATING",
couponPeriodDays: 90,
couponYieldPercent: "27.18",
couponYieldPriceAt: quote.priceAt,
effectiveYield: {
value: "15.6262",
pricePercent: "53.384",
date: "2030-05-15",
dateType: "MATDATE",
tradeAt: quote.priceAt,
},
unavailable:
options.ratingState === "unavailable" ? ["rating"] : [],
observedAt: quote.observedAt,
}),
);
if (params.section === "quote")
return failQuote
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(quote);
: HttpResponse.json(resource(quote));
if (params.section === "position")
return HttpResponse.json(
options.noPosition
? { ...position, quantity: "0", accounts: [] }
: position,
resource(
options.noPosition
? { ...position, quantity: "0", accounts: [] }
: position,
),
);
if (params.section === "candles")
return HttpResponse.json({
candles: [
{ time: "2026-09-09T00:00:00Z", close: "99.25", isComplete: true },
],
currency: null,
historyFrom: null,
observedAt: quote.observedAt,
});
return HttpResponse.json(
resource({
candles: [
{
time: "2026-09-09T00:00:00Z",
close: "99.25",
isComplete: true,
},
],
currency: null,
historyFrom: null,
observedAt: quote.observedAt,
}),
);
if (options.failCoupons)
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
return HttpResponse.json({
coupons: [
{
perBond: money("32.88"),
recordDate: "2026-10-13",
number: "1",
couponType: "COUPON_TYPE_FLOATING",
periodDays: 90,
paymentDate: "2026-10-27",
timing: "upcoming",
estimatedAmount: options.noPosition ? null : money("328.8"),
},
{
perBond: money("20"),
recordDate: "2025-10-13",
paymentDate: null,
number: "2",
couponType: null,
periodDays: null,
timing: "past",
estimatedAmount: null,
},
],
from: "2021-09-10",
to: "2027-09-10",
observedAt: quote.observedAt,
positionAt: null,
estimateUnavailable: false,
positionIsStale: false,
});
return HttpResponse.json(
resource({
coupons: [
{
perBond: money("32.88"),
recordDate: "2026-10-13",
number: "1",
couponType: "COUPON_TYPE_FLOATING",
periodDays: 90,
paymentDate: "2026-10-27",
timing: "upcoming",
estimatedAmount: options.noPosition ? null : money("328.8"),
},
{
perBond: money("20"),
recordDate: "2025-10-13",
paymentDate: null,
number: "2",
couponType: null,
periodDays: null,
timing: "past",
estimatedAmount: null,
},
],
from: "2021-09-10",
to: "2027-09-10",
observedAt: quote.observedAt,
positionAt: null,
estimateUnavailable: false,
positionIsStale: false,
}),
);
}),
http.get("*/api/v1/brokerage-accounts/all/operations", ({ request }) => {
const url = new URL(request.url);
requests.push(url);
const next = url.searchParams.has("cursor");
return HttpResponse.json({
accountId: "all",
observedAt: quote.observedAt,
hasNext: !next,
nextCursor: next ? null : "next",
items: [
{
id: next ? "sold" : "dividend",
occurredAt: next ? "2022-01-02T10:00:00Z" : "2025-01-02T10:00:00Z",
type: next ? "OPERATION_TYPE_SELL" : "OPERATION_TYPE_COUPON",
category: next ? "trade" : "income",
description: "",
account: { id: "main", name: "Основной" },
payment: money("200"),
quantity: next ? "2" : null,
price: next ? money("100") : null,
},
],
});
return HttpResponse.json(
resource({
accountId: "all",
observedAt: quote.observedAt,
hasNext: !next,
nextCursor: next ? null : "next",
items: [
{
id: next ? "sold" : "dividend",
occurredAt: next
? "2022-01-02T10:00:00Z"
: "2025-01-02T10:00:00Z",
type: next ? "OPERATION_TYPE_SELL" : "OPERATION_TYPE_COUPON",
category: next ? "trade" : "income",
description: "",
account: { id: "main", name: "Основной" },
payment: money("200"),
quantity: next ? "2" : null,
price: next ? money("100") : null,
},
],
}),
);
}),
);
configureAuthClient({
@@ -300,9 +319,11 @@ describe("bond page", () => {
it("keeps market content and sold-position history when coupons fail", async () => {
setup({ noPosition: true, failCoupons: true });
await screen.findByText("98,125 %");
expect(await screen.findByRole("alert")).toHaveTextContent(
"Не удалось загрузить этот раздел",
);
expect(
(await screen.findByText(/Не удалось загрузить этот раздел/)).closest(
'[role="status"]',
),
).toHaveAttribute("aria-live", "polite");
expect(
screen.queryByRole("heading", { name: "Моя позиция" }),
).not.toBeInTheDocument();
@@ -311,14 +332,36 @@ describe("bond page", () => {
).toBeVisible();
});
it("preserves cached quote on refresh failure", async () => {
const controls = setup();
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 },
);
}),
);
const controls = setup({ path: `/bonds/${uid}?period=year&events=trades` });
await screen.findByText("98,125 %");
controls.failQuote();
fireEvent.click(screen.getByRole("button", { name: "Обновить цену" }));
expect(await screen.findByRole("alert")).toHaveTextContent(
"последние полученные",
);
fireEvent.click(screen.getByRole("button", { name: "Обновить" }));
expect(
(await screen.findByText(/последние полученные/)).closest(
'[role="status"]',
),
).toHaveAttribute("aria-live", "polite");
expect(screen.getByText("98,125 %")).toBeVisible();
expect(scopes).toEqual([
{
screen: "bond",
instrumentUid: uid,
accountId: "all",
candlePeriod: "year",
categories: ["trade"],
limit: 20,
},
]);
});
it("offers search for an ambiguous or missing instrument without loading personal data", async () => {
const { requests } = setup({ path: "/bonds/SAME", ambiguousTicker: true });
+8 -9
View File
@@ -55,13 +55,10 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
</div>
<Button
variant="subtle"
onClick={() => {
vm.bondState.retry();
vm.quoteState.retry();
vm.metricsState.retry();
}}
onClick={vm.refresh}
disabled={vm.refreshing}
>
{t("stock.refreshPrice")}
{t("investment.refresh")}
</Button>
</Group>
<Group mt="md" gap="xs">
@@ -80,8 +77,8 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
</Badge>
) : null}
</Group>
{vm.bondState.error || vm.quoteState.error ? (
<Alert role="alert" color="orange" mt="md">
{vm.refreshError || vm.bondState.error || vm.quoteState.error ? (
<Alert role="status" aria-live="polite" color="orange" mt="md">
{t(vm.quote ? "accounts.refreshError" : "stock.sectionError")}
</Alert>
) : null}
@@ -322,7 +319,9 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
{vm.coupons ? (
<Stack gap="md">
{vm.coupons.estimateUnavailable || vm.coupons.positionIsStale ? (
<Alert color="orange">{t("stock.estimateUnavailable")}</Alert>
<Alert color="orange" role="status" aria-live="polite">
{t("stock.estimateUnavailable")}
</Alert>
) : null}
{vm.upcoming.length ? (
vm.upcoming.map((coupon) => (
+26 -8
View File
@@ -16,6 +16,7 @@ import {
useBrokerageOperations,
} from "@/entities/broker-operation";
import { section } from "@/features/instrument-details";
import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api";
import { formatDate, instrumentLogoUrl } from "@/shared/lib";
@@ -58,6 +59,17 @@ function useBondPageVm() {
...firstPage,
cursor: cursors.at(-1),
});
const sync = useScreenSync(
{
screen: "bond",
instrumentUid: uid,
candlePeriod: period,
accountId: "all",
categories: categories[filter],
limit: 20,
},
[bond, quote, candles, position, metrics, coupons, operations],
);
const setParam = (key: string, value: string) => {
const next = new URLSearchParams(params);
next.set(key, value);
@@ -70,7 +82,7 @@ function useBondPageVm() {
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey,
exact: true,
});
} else void operations.refetch();
} else void sync.refresh();
};
const chartData =
candles.data?.candles.map((candle) => ({
@@ -79,31 +91,34 @@ function useBondPageVm() {
price: Number(candle.close),
})) ?? [];
return {
refresh: sync.refresh,
refreshing: sync.refreshing,
refreshError: sync.transportError || sync.sourceError,
canonicalTo:
bond.data?.slug && bond.data.slug !== bondId
? `/bonds/${encodeURIComponent(bond.data.slug)}${params.size ? `?${params.toString()}` : ""}`
: null,
bond: bond.data,
bondState: section(bond),
bondState: section(bond, uid ? sync.refresh : () => bond.refetch()),
code: apiErrorCode(bond.error),
logoSrc: instrumentLogoUrl(bond.data?.logoName),
metrics: metrics.data,
metricsState: section(metrics),
metricsState: section(metrics, sync.refresh),
quote: quote.data,
quoteState: section(quote),
quoteState: section(quote, sync.refresh),
candles: candles.data,
chartData,
priceRows: chartData.toReversed(),
candlesState: section(candles),
candlesState: section(candles, sync.refresh),
period,
setPeriod: (value: string) => setParam("period", value),
averageLine: null,
hasIncompleteCandle:
candles.data?.candles.some((candle) => !candle.isComplete) ?? false,
position: position.data,
positionState: section(position),
positionState: section(position, sync.refresh),
coupons: coupons.data,
couponsState: section(coupons),
couponsState: section(coupons, sync.refresh),
upcoming:
coupons.data?.coupons
.filter((event) => event.timing === "upcoming")
@@ -121,7 +136,10 @@ function useBondPageVm() {
),
) ?? [],
operations: operations.data,
operationsState: { ...section(operations), retry: retryOperations },
operationsState: {
...section(operations, sync.refresh),
retry: retryOperations,
},
eventRows:
operations.data?.items.map((item) => ({
...item,
+21 -18
View File
@@ -6,6 +6,7 @@ import { MemoryRouter } 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 { BondsPage } from "./BondsPage";
@@ -15,24 +16,26 @@ describe("bond search", () => {
server.use(
http.get("*/api/v1/bonds", ({ request }) => {
query(new URL(request.url).searchParams.get("query"));
return HttpResponse.json([
{
name: "ОФЗ",
ticker: "SU26238RMFS4",
instrumentUid: "unique-bond",
slug: "SU26238RMFS4",
logoName: "sber.png",
classCode: "TQOB",
isin: "RU0009029540",
realExchange: "REAL_EXCHANGE_MOEX",
shareType: "SHARE_TYPE_COMMON",
apiTradeAvailable: true,
pricePercent: "98.125",
maturityDate: "2030-05-15",
perpetual: false,
priceAt: "2026-09-10T10:00:00Z",
},
]);
return HttpResponse.json(
resource([
{
name: "ОФЗ",
ticker: "SU26238RMFS4",
instrumentUid: "unique-bond",
slug: "SU26238RMFS4",
logoName: "sber.png",
classCode: "TQOB",
isin: "RU0009029540",
realExchange: "REAL_EXCHANGE_MOEX",
shareType: "SHARE_TYPE_COMMON",
apiTradeAvailable: true,
pricePercent: "98.125",
maturityDate: "2030-05-15",
perpetual: false,
priceAt: "2026-09-10T10:00:00Z",
},
]),
);
}),
);
configureAuthClient({
@@ -37,6 +37,16 @@ const BondsPageView: FC<BondsPageViewProps> = (props) => {
{t("bond.searchSubtitle")}
</Text>
</div>
<Button
onClick={vm.retry}
disabled={vm.refreshing || vm.query.trim().length < 2}
w="fit-content"
>
{t("investment.refresh")}
</Button>
{vm.partial ? (
<Alert role="status">{t("investment.partial")}</Alert>
) : null}
<TextInput
label={t("bond.searchLabel")}
placeholder={t("bond.searchPlaceholder")}
@@ -49,7 +59,7 @@ const BondsPageView: FC<BondsPageViewProps> = (props) => {
<Text c="dimmed">{t("stock.searchHint")}</Text>
) : null}
{vm.error ? (
<Alert role="alert" color="orange">
<Alert role="status" aria-live="polite" color="orange">
<Stack gap="xs">
<Text>
{t(
@@ -166,7 +176,9 @@ const BondsPageView: FC<BondsPageViewProps> = (props) => {
))}
</Stack>
) : (
<Text c="dimmed">{t("bond.noSearchResults")}</Text>
<Text c="dimmed">
{t(vm.partial ? "investment.partial" : "bond.noSearchResults")}
</Text>
)
) : null}
</Stack>
+12 -3
View File
@@ -1,6 +1,7 @@
import { useDebouncedValue } from "@mantine/hooks";
import { useSearchParams } from "react-router";
import { useBondSearch } from "@/entities/bond";
import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api";
import { instrumentLogoUrl } from "@/shared/lib";
@@ -11,15 +12,23 @@ function useBondsPageVm() {
const query = params.get("q") ?? "";
const [debounced] = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS);
const result = useBondSearch(debounced);
const sync = useScreenSync({ screen: "bonds", query: debounced }, [result]);
const waiting = query.trim() !== debounced;
return {
query,
setQuery: (q: string) => setParams(q ? { q } : {}, { replace: true }),
loading: query.trim().length >= 2 && (waiting || result.isLoading),
error: result.isError,
loading:
query.trim().length >= 2 &&
(waiting ||
(!result.data &&
!result.isError &&
result.meta?.sync.state !== "failed")),
error: result.isError || sync.sourceError || sync.transportError,
refreshing: sync.refreshing,
partial: result.meta?.availability === "partial",
code: apiErrorCode(result.error),
retry: () => {
void result.refetch();
void sync.refresh();
},
ready: debounced.length >= 2 && !waiting && Boolean(result.data),
items:
+118 -79
View File
@@ -6,6 +6,7 @@ import { MemoryRouter, Route, Routes, useLocation } 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 { StockPage } from "./StockPage";
@@ -80,100 +81,112 @@ function setup(
http.get("*/api/v1/stocks/:uid", () =>
options.failStock
? HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 })
: HttpResponse.json(resolved),
: HttpResponse.json(resource(resolved)),
),
http.get("*/api/v1/stocks/by-ticker/:ticker", ({ request }) => {
requests.push(new URL(request.url));
return options.ambiguousTicker
? HttpResponse.json({ code: "STOCK_TICKER_AMBIGUOUS" }, { status: 409 })
: HttpResponse.json(resolved);
: HttpResponse.json(resource(resolved));
}),
http.get("*/api/v1/stocks/:uid/:section", ({ params, request }) => {
requests.push(new URL(request.url));
if (params.section === "quote")
return failQuote
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(quote);
: HttpResponse.json(resource(quote));
if (params.section === "position")
return HttpResponse.json(
options.noPosition
? { ...position, quantity: "0", accounts: [] }
: position,
resource(
options.noPosition
? { ...position, quantity: "0", accounts: [] }
: position,
),
);
if (params.section === "candles")
return HttpResponse.json({
candles: [],
currency: "RUB",
historyFrom: null,
observedAt: quote.observedAt,
});
return HttpResponse.json(
resource({
candles: [],
currency: "RUB",
historyFrom: null,
observedAt: quote.observedAt,
}),
);
if (params.section === "company")
return options.failCompany
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json({
description: "Описание компании",
metrics: [
{
key: "peRatioTtm",
value: 5.6,
unit: "ratio",
currency: null,
period: "TTM",
},
],
unavailable: [],
observedAt: quote.observedAt,
});
return HttpResponse.json({
dividends: [
{
perShare: money("32.88"),
recordDate: "2026-10-13",
lastBuyDate: "2026-10-12",
paymentDate: "2026-10-27",
timing: "upcoming",
estimatedAmount: options.noPosition ? null : money("328.8"),
},
{
perShare: money("20"),
recordDate: "2025-10-13",
paymentDate: null,
lastBuyDate: null,
timing: "past",
estimatedAmount: null,
},
],
from: "2021-09-10",
to: "2027-09-10",
observedAt: quote.observedAt,
positionAt: null,
estimateUnavailable: false,
positionIsStale: false,
});
: HttpResponse.json(
resource({
description: "Описание компании",
metrics: [
{
key: "peRatioTtm",
value: 5.6,
unit: "ratio",
currency: null,
period: "TTM",
},
],
unavailable: [],
observedAt: quote.observedAt,
}),
);
return HttpResponse.json(
resource({
dividends: [
{
perShare: money("32.88"),
recordDate: "2026-10-13",
lastBuyDate: "2026-10-12",
paymentDate: "2026-10-27",
timing: "upcoming",
estimatedAmount: options.noPosition ? null : money("328.8"),
},
{
perShare: money("20"),
recordDate: "2025-10-13",
paymentDate: null,
lastBuyDate: null,
timing: "past",
estimatedAmount: null,
},
],
from: "2021-09-10",
to: "2027-09-10",
observedAt: quote.observedAt,
positionAt: null,
estimateUnavailable: false,
positionIsStale: false,
}),
);
}),
http.get("*/api/v1/brokerage-accounts/all/operations", ({ request }) => {
const url = new URL(request.url);
requests.push(url);
const next = url.searchParams.has("cursor");
return HttpResponse.json({
accountId: "all",
observedAt: quote.observedAt,
hasNext: !next,
nextCursor: next ? null : "next",
items: [
{
id: next ? "sold" : "dividend",
occurredAt: next ? "2022-01-02T10:00:00Z" : "2025-01-02T10:00:00Z",
type: next ? "OPERATION_TYPE_SELL" : "OPERATION_TYPE_DIVIDEND",
category: next ? "trade" : "income",
description: "",
account: { id: "main", name: "Основной" },
payment: money("200"),
quantity: next ? "2" : null,
price: next ? money("100") : null,
},
],
});
return HttpResponse.json(
resource({
accountId: "all",
observedAt: quote.observedAt,
hasNext: !next,
nextCursor: next ? null : "next",
items: [
{
id: next ? "sold" : "dividend",
occurredAt: next
? "2022-01-02T10:00:00Z"
: "2025-01-02T10:00:00Z",
type: next ? "OPERATION_TYPE_SELL" : "OPERATION_TYPE_DIVIDEND",
category: next ? "trade" : "income",
description: "",
account: { id: "main", name: "Основной" },
payment: money("200"),
quantity: next ? "2" : null,
price: next ? money("100") : null,
},
],
}),
);
}),
);
configureAuthClient({
@@ -271,9 +284,11 @@ describe("stock page", () => {
screen.queryByRole("heading", { name: "Моя позиция" }),
).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "График цены" })).toBeVisible();
expect(await screen.findByRole("alert")).toHaveTextContent(
"Не удалось загрузить этот раздел",
);
expect(
(await screen.findByText(/Не удалось загрузить этот раздел/)).closest(
'[role="status"]',
),
).toHaveAttribute("aria-live", "polite");
});
it("sends instrument-filtered continuation, resets it on filter changes and changes chart period", async () => {
const { requests } = setup();
@@ -308,14 +323,38 @@ describe("stock page", () => {
);
});
it("keeps the last quote on a refresh failure", async () => {
const controls = setup();
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 },
);
}),
);
const controls = setup({
path: `/stocks/${uid}?period=year&events=trades`,
});
await screen.findByText("300 ₽");
controls.failQuote();
fireEvent.click(screen.getByRole("button", { name: "Обновить цену" }));
expect(await screen.findByRole("alert")).toHaveTextContent(
"последние полученные",
);
fireEvent.click(screen.getByRole("button", { name: "Обновить" }));
expect(
(await screen.findByText(/последние полученные/)).closest(
'[role="status"]',
),
).toHaveAttribute("aria-live", "polite");
expect(screen.getByText("300 ₽")).toBeVisible();
expect(scopes).toEqual([
{
screen: "stock",
instrumentUid: uid,
accountId: "all",
candlePeriod: "year",
categories: ["trade"],
limit: 20,
},
]);
});
it("offers recovery for an unavailable direct link", async () => {
setup({ failStock: true });
@@ -63,12 +63,10 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
</div>
<Button
variant="subtle"
onClick={() => {
vm.stockState.retry();
vm.quoteState.retry();
}}
onClick={vm.refresh}
disabled={vm.refreshing}
>
{t("stock.refreshPrice")}
{t("investment.refresh")}
</Button>
</Group>
{vm.company?.description ? (
@@ -76,8 +74,8 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
{vm.company.description}
</Text>
) : null}
{vm.stockState.error || vm.quoteState.error ? (
<Alert role="alert" color="orange" mt="md">
{vm.refreshError || vm.stockState.error || vm.quoteState.error ? (
<Alert role="status" aria-live="polite" color="orange" mt="md">
{t(vm.quote ? "accounts.refreshError" : "stock.sectionError")}
</Alert>
) : null}
@@ -164,7 +162,7 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
{vm.company ? (
<Stack gap="lg">
{vm.company.unavailable.length ? (
<Alert color="orange">
<Alert color="orange" role="status" aria-live="polite">
<Group justify="space-between">
<Text size="sm">{t("stock.partialCompany")}</Text>
<Button
+26 -8
View File
@@ -16,6 +16,7 @@ import {
useStockQuote,
} from "@/entities/stock";
import { section } from "@/features/instrument-details";
import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api";
import { formatDate, instrumentLogoUrl } from "@/shared/lib";
@@ -58,6 +59,17 @@ function useStockPageVm() {
...firstPage,
cursor: cursors.at(-1),
});
const sync = useScreenSync(
{
screen: "stock",
instrumentUid: uid,
candlePeriod: period,
accountId: "all",
categories: categories[filter],
limit: 20,
},
[stock, quote, candles, position, company, dividends, operations],
);
const setParam = (key: string, value: string) => {
const next = new URLSearchParams(params);
next.set(key, value);
@@ -70,7 +82,7 @@ function useStockPageVm() {
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey,
exact: true,
});
} else void operations.refetch();
} else void sync.refresh();
};
const chartData =
candles.data?.candles.map((candle) => ({
@@ -85,20 +97,23 @@ function useStockPageVm() {
: null;
const companyData = company.data;
return {
refresh: sync.refresh,
refreshing: sync.refreshing,
refreshError: sync.transportError || sync.sourceError,
canonicalTo:
stock.data?.slug && stock.data.slug !== stockId
? `/stocks/${encodeURIComponent(stock.data.slug)}${params.size ? `?${params.toString()}` : ""}`
: null,
stock: stock.data,
stockState: section(stock),
stockState: section(stock, uid ? sync.refresh : () => stock.refetch()),
code: apiErrorCode(stock.error),
logoSrc: instrumentLogoUrl(stock.data?.logoName),
quote: quote.data,
quoteState: section(quote),
quoteState: section(quote, sync.refresh),
candles: candles.data,
chartData,
priceRows: chartData.toReversed(),
candlesState: section(candles),
candlesState: section(candles, sync.refresh),
period,
setPeriod: (value: string) => setParam("period", value),
averageLine:
@@ -106,11 +121,11 @@ function useStockPageVm() {
hasIncompleteCandle:
candles.data?.candles.some((candle) => !candle.isComplete) ?? false,
position: position.data,
positionState: section(position),
positionState: section(position, sync.refresh),
company: companyData,
companyState: section(company),
companyState: section(company, sync.refresh),
dividends: dividends.data,
dividendsState: section(dividends),
dividendsState: section(dividends, sync.refresh),
upcoming:
dividends.data?.dividends
.filter((event) => event.timing === "upcoming")
@@ -128,7 +143,10 @@ function useStockPageVm() {
),
) ?? [],
operations: operations.data,
operationsState: { ...section(operations), retry: retryOperations },
operationsState: {
...section(operations, sync.refresh),
retry: retryOperations,
},
eventRows:
operations.data?.items.map((item) => ({
...item,
@@ -23,7 +23,9 @@ const StockDividends: FC<StockEventsProps> = (props) => {
{vm.dividends ? (
<Stack gap="md">
{vm.dividends.estimateUnavailable || vm.dividends.positionIsStale ? (
<Alert color="orange">{t("stock.estimateUnavailable")}</Alert>
<Alert color="orange" role="status" aria-live="polite">
{t("stock.estimateUnavailable")}
</Alert>
) : null}
{vm.upcoming.length ? (
vm.upcoming.map((event) => (
@@ -6,6 +6,7 @@ import { MemoryRouter } 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 { StocksPage } from "./StocksPage";
@@ -15,22 +16,24 @@ describe("stock search", () => {
server.use(
http.get("*/api/v1/stocks", ({ request }) => {
query(new URL(request.url).searchParams.get("query"));
return HttpResponse.json([
{
name: "Сбербанк",
ticker: "SBER",
instrumentUid: "unique-stock",
slug: "SBER",
logoName: "sber.png",
classCode: "TQBR",
isin: "RU0009029540",
realExchange: "REAL_EXCHANGE_MOEX",
shareType: "SHARE_TYPE_COMMON",
apiTradeAvailable: true,
price: { currency: "RUB", value: "306.12" },
priceAt: "2026-09-10T10:00:00Z",
},
]);
return HttpResponse.json(
resource([
{
name: "Сбербанк",
ticker: "SBER",
instrumentUid: "unique-stock",
slug: "SBER",
logoName: "sber.png",
classCode: "TQBR",
isin: "RU0009029540",
realExchange: "REAL_EXCHANGE_MOEX",
shareType: "SHARE_TYPE_COMMON",
apiTradeAvailable: true,
price: { currency: "RUB", value: "306.12" },
priceAt: "2026-09-10T10:00:00Z",
},
]),
);
}),
);
configureAuthClient({
@@ -32,6 +32,16 @@ const StocksPageView: FC<StocksPageViewProps> = (props) => {
{t("stock.searchSubtitle")}
</Text>
</div>
<Button
onClick={vm.retry}
disabled={vm.refreshing || vm.query.trim().length < 2}
w="fit-content"
>
{t("investment.refresh")}
</Button>
{vm.partial ? (
<Alert role="status">{t("investment.partial")}</Alert>
) : null}
<TextInput
label={t("stock.searchLabel")}
placeholder={t("stock.searchPlaceholder")}
@@ -44,7 +54,7 @@ const StocksPageView: FC<StocksPageViewProps> = (props) => {
<Text c="dimmed">{t("stock.searchHint")}</Text>
) : null}
{vm.error ? (
<Alert role="alert" color="orange">
<Alert role="status" aria-live="polite" color="orange">
<Stack gap="xs">
<Text>
{t(
@@ -142,7 +152,9 @@ const StocksPageView: FC<StocksPageViewProps> = (props) => {
))}
</Stack>
) : (
<Text c="dimmed">{t("stock.noSearchResults")}</Text>
<Text c="dimmed">
{t(vm.partial ? "investment.partial" : "stock.noSearchResults")}
</Text>
)
) : null}
</Stack>
@@ -1,6 +1,7 @@
import { useDebouncedValue } from "@mantine/hooks";
import { useSearchParams } from "react-router";
import { useStockSearch } from "@/entities/stock";
import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api";
import { instrumentLogoUrl } from "@/shared/lib";
@@ -11,15 +12,23 @@ function useStocksPageVm() {
const query = params.get("q") ?? "";
const [debounced] = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS);
const result = useStockSearch(debounced);
const sync = useScreenSync({ screen: "stocks", query: debounced }, [result]);
const waiting = query.trim() !== debounced;
return {
query,
setQuery: (q: string) => setParams(q ? { q } : {}, { replace: true }),
loading: query.trim().length >= 2 && (waiting || result.isLoading),
error: result.isError,
loading:
query.trim().length >= 2 &&
(waiting ||
(!result.data &&
!result.isError &&
result.meta?.sync.state !== "failed")),
error: result.isError || sync.sourceError || sync.transportError,
refreshing: sync.refreshing,
partial: result.meta?.availability === "partial",
code: apiErrorCode(result.error),
retry: () => {
void result.refetch();
void sync.refresh();
},
ready: debounced.length >= 2 && !waiting && Boolean(result.data),
items: