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
+18 -7
View File
@@ -16,6 +16,7 @@ import type { IncomeAnalytics } from "@/entities/income";
import type { Portfolio } from "@/entities/portfolio"; import type { Portfolio } from "@/entities/portfolio";
import { AuthProvider } from "@/features/auth"; import { AuthProvider } from "@/features/auth";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { AppRoutes } from "./AppRoutes"; import { AppRoutes } from "./AppRoutes";
@@ -132,6 +133,7 @@ beforeEach(() => {
), ),
http.get("*/api/v1/brokerage-accounts", () => http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json( HttpResponse.json(
resource(
accounts.map((account) => ({ accounts.map((account) => ({
...account, ...account,
type: "BROKERAGE", type: "BROKERAGE",
@@ -140,13 +142,15 @@ beforeEach(() => {
})), })),
), ),
), ),
),
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(portfolio), HttpResponse.json(resource(portfolio)),
), ),
http.get( http.get(
"*/api/v1/brokerage-accounts/:accountId/operations", "*/api/v1/brokerage-accounts/:accountId/operations",
({ params }) => ({ params }) =>
HttpResponse.json({ HttpResponse.json(
resource({
accountId: params.accountId, accountId: params.accountId,
items, items,
hasNext: false, hasNext: false,
@@ -154,11 +158,13 @@ beforeEach(() => {
observedAt: receipt.occurredAt, observedAt: receipt.occurredAt,
}), }),
), ),
),
http.get("*/api/v1/brokerage-accounts/:accountId/income-analytics", () => http.get("*/api/v1/brokerage-accounts/:accountId/income-analytics", () =>
HttpResponse.json(analytics), HttpResponse.json(resource(analytics)),
), ),
http.get("*/api/v1/brokerage-accounts/:accountId/events", () => http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
HttpResponse.json({ HttpResponse.json(
resource({
accountId: "all", accountId: "all",
calculatedAt: receipt.occurredAt, calculatedAt: receipt.occurredAt,
incomeTotalsByCurrency: [], incomeTotalsByCurrency: [],
@@ -177,6 +183,7 @@ beforeEach(() => {
})), })),
}), }),
), ),
),
); );
}); });
@@ -224,7 +231,8 @@ describe("All accounts routes", () => {
({ request, params }) => { ({ request, params }) => {
const url = new URL(request.url); const url = new URL(request.url);
requests.push(url); requests.push(url);
return HttpResponse.json({ return HttpResponse.json(
resource({
accountId: params.accountId, accountId: params.accountId,
items: [ items: [
{ {
@@ -237,7 +245,8 @@ describe("All accounts routes", () => {
hasNext: !url.searchParams.has("cursor"), hasNext: !url.searchParams.has("cursor"),
nextCursor: url.searchParams.has("cursor") ? null : "next", nextCursor: url.searchParams.has("cursor") ? null : "next",
observedAt: receipt.occurredAt, observedAt: receipt.occurredAt,
}); }),
);
}, },
), ),
); );
@@ -274,7 +283,8 @@ describe("All accounts routes", () => {
{ code: "INVALID_OPERATIONS_CURSOR" }, { code: "INVALID_OPERATIONS_CURSOR" },
{ status: 400 }, { status: 400 },
) )
: HttpResponse.json({ : HttpResponse.json(
resource({
accountId: "all", accountId: "all",
items, items,
hasNext: true, hasNext: true,
@@ -282,6 +292,7 @@ describe("All accounts routes", () => {
observedAt: receipt.occurredAt, observedAt: receipt.occurredAt,
}), }),
), ),
),
); );
renderPage("/accounts/all/operations?from=2026-08-01&to=2026-08-31"); renderPage("/accounts/all/operations?from=2026-08-01&to=2026-08-31");
await screen.findAllByText("Выплата купона"); await screen.findAllByText("Выплата купона");
@@ -12,6 +12,7 @@ import { MemoryRouter, useNavigate } from "react-router";
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import { AuthProvider } from "@/features/auth"; import { AuthProvider } from "@/features/auth";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { ProtectedDocumentTitle } from "./DocumentTitle"; import { ProtectedDocumentTitle } from "./DocumentTitle";
@@ -62,7 +63,7 @@ describe("document titles", () => {
server.use( server.use(
http.get("*/api/v1/brokerage-accounts", async () => { http.get("*/api/v1/brokerage-accounts", async () => {
await accountsReady; await accountsReady;
return HttpResponse.json([{ id: "a", name: "ИИС" }]); return HttpResponse.json(resource([{ id: "a", name: "ИИС" }]));
}), }),
); );
renderTitle("/accounts/a/income?from=2026-01-01&to=2026-08-31"); renderTitle("/accounts/a/income?from=2026-01-01&to=2026-08-31");
@@ -85,7 +86,7 @@ describe("document titles", () => {
async (kind, id, fallback, name) => { async (kind, id, fallback, name) => {
server.use( server.use(
http.get(`*/api/v1/${kind}/by-ticker/:ticker`, () => http.get(`*/api/v1/${kind}/by-ticker/:ticker`, () =>
HttpResponse.json({ name }), HttpResponse.json(resource({ name })),
), ),
); );
renderTitle(`/${kind}/${id}`); renderTitle(`/${kind}/${id}`);
+11 -13
View File
@@ -1,8 +1,4 @@
import { import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
apiQueryClient,
type paths,
useAuthenticatedQuery,
} from "@/shared/api";
type BondPeriod = NonNullable< type BondPeriod = NonNullable<
NonNullable< NonNullable<
@@ -13,7 +9,7 @@ type BondPeriod = NonNullable<
const BOND_STALE_TIME_MS = 30_000; const BOND_STALE_TIME_MS = 30_000;
function useBondSearch(query: string) { function useBondSearch(query: string) {
return useAuthenticatedQuery({ return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bonds", { ...apiQueryClient.queryOptions("get", "/api/v1/bonds", {
params: { query: { query } }, params: { query: { query } },
}), }),
@@ -36,11 +32,11 @@ function useBond(identifier: string) {
identifier, identifier,
); );
const byUid = useAuthenticatedQuery({ const byUid = useResourceQuery({
...bondQueryOptions(identifier), ...bondQueryOptions(identifier),
enabled: Boolean(identifier) && isUid, enabled: Boolean(identifier) && isUid,
}); });
const byTicker = useAuthenticatedQuery({ const byTicker = useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bonds/by-ticker/{ticker}", { ...apiQueryClient.queryOptions("get", "/api/v1/bonds/by-ticker/{ticker}", {
params: { path: { ticker: identifier } }, params: { path: { ticker: identifier } },
}), }),
@@ -50,7 +46,7 @@ function useBond(identifier: string) {
return isUid ? byUid : byTicker; return isUid ? byUid : byTicker;
} }
function useBondMetrics(uid: string) { function useBondMetrics(uid: string) {
return useAuthenticatedQuery({ return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/metrics", { ...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/metrics", {
params: { path: { uid } }, params: { path: { uid } },
}), }),
@@ -59,7 +55,7 @@ function useBondMetrics(uid: string) {
}); });
} }
function useBondQuote(uid: string) { function useBondQuote(uid: string) {
return useAuthenticatedQuery({ return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/quote", { ...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/quote", {
params: { path: { uid } }, params: { path: { uid } },
}), }),
@@ -68,7 +64,7 @@ function useBondQuote(uid: string) {
}); });
} }
function useBondCandles(uid: string, period: BondPeriod) { function useBondCandles(uid: string, period: BondPeriod) {
return useAuthenticatedQuery({ return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/candles", { ...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/candles", {
params: { path: { uid }, query: { period } }, params: { path: { uid }, query: { period } },
}), }),
@@ -77,7 +73,8 @@ function useBondCandles(uid: string, period: BondPeriod) {
}); });
} }
function useBondPosition(uid: string) { function useBondPosition(uid: string) {
return useAuthenticatedQuery({ return useResourceQuery({
meta: { accessGroup: "brokerage" },
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/position", { ...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/position", {
params: { path: { uid } }, params: { path: { uid } },
}), }),
@@ -86,7 +83,8 @@ function useBondPosition(uid: string) {
}); });
} }
function useBondCoupons(uid: string) { function useBondCoupons(uid: string) {
return useAuthenticatedQuery({ return useResourceQuery({
meta: { accessGroup: "brokerage" },
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/coupons", { ...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/coupons", {
params: { path: { uid } }, params: { path: { uid } },
}), }),
@@ -1,16 +1,14 @@
import { import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
apiQueryClient,
type paths,
useAuthenticatedQuery,
} from "@/shared/api";
type OperationsEndpoint = type OperationsEndpoint =
paths["/api/v1/brokerage-accounts/{accountId}/operations"]["get"]; paths["/api/v1/brokerage-accounts/{accountId}/operations"]["get"];
type BrokerageOperation = type BrokerageOperation = NonNullable<
OperationsEndpoint["responses"][200]["content"]["application/json"]["items"][number]; OperationsEndpoint["responses"][200]["content"]["application/json"]["data"]
type BrokerageOperationsQuery = NonNullable< >["items"][number];
OperationsEndpoint["parameters"]["query"] type BrokerageOperationsQuery = Omit<
NonNullable<OperationsEndpoint["parameters"]["query"]>,
"sync"
>; >;
type OperationCategory = BrokerageOperation["category"]; type OperationCategory = BrokerageOperation["category"];
@@ -56,7 +54,8 @@ function useBrokerageOperations(
accountId: string | undefined, accountId: string | undefined,
query: BrokerageOperationsQuery = {}, query: BrokerageOperationsQuery = {},
) { ) {
return useAuthenticatedQuery({ return useResourceQuery({
meta: { accessGroup: "brokerage" },
...brokerageOperationsQueryOptions(accountId, query), ...brokerageOperationsQueryOptions(accountId, query),
enabled: Boolean(accountId), enabled: Boolean(accountId),
staleTime: 30_000, staleTime: 30_000,
@@ -3,6 +3,7 @@ import { HttpResponse, http } from "msw";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query"; import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { useBrokerageAccounts } from "./useBrokerageAccounts"; import { useBrokerageAccounts } from "./useBrokerageAccounts";
@@ -15,7 +16,8 @@ describe("useBrokerageAccounts", () => {
}); });
server.use( server.use(
http.get("*/api/v1/brokerage-accounts", () => http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json([ HttpResponse.json(
resource([
{ {
id: "account-1", id: "account-1",
name: "Основной", name: "Основной",
@@ -24,6 +26,7 @@ describe("useBrokerageAccounts", () => {
}, },
]), ]),
), ),
),
); );
const { result } = renderHook(() => useBrokerageAccounts(), { const { result } = renderHook(() => useBrokerageAccounts(), {
@@ -52,14 +55,16 @@ describe("useBrokerageAccounts", () => {
http.get("*/api/v1/brokerage-accounts", () => { http.get("*/api/v1/brokerage-accounts", () => {
calls += 1; calls += 1;
return calls === 1 return calls === 1
? HttpResponse.json([ ? HttpResponse.json(
resource([
{ {
id: "account-1", id: "account-1",
name: "Основной", name: "Основной",
type: "BROKERAGE", type: "BROKERAGE",
openedOn: "2024-08-13", openedOn: "2024-08-13",
}, },
]) ]),
)
: HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }); : HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
}), }),
); );
@@ -1,19 +1,17 @@
import { import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
apiQueryClient,
type paths,
useAuthenticatedQuery,
} from "@/shared/api";
const brokerageAccountsQueryOptions = () => const brokerageAccountsQueryOptions = () =>
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"); apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts");
const brokerageAccountsQueryKey = brokerageAccountsQueryOptions().queryKey; const brokerageAccountsQueryKey = brokerageAccountsQueryOptions().queryKey;
type BrokerageAccount = type BrokerageAccount = NonNullable<
paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"][number]; paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"]["data"]
>[number];
function useBrokerageAccounts() { function useBrokerageAccounts() {
return useAuthenticatedQuery({ return useResourceQuery({
meta: { accessGroup: "brokerage" },
...brokerageAccountsQueryOptions(), ...brokerageAccountsQueryOptions(),
staleTime: 60_000, staleTime: 60_000,
}); });
@@ -1,10 +1,18 @@
import type { BrokerageAccount } from "../api/useBrokerageAccounts"; import type {
BrokerageAccount,
useBrokerageAccounts,
} from "../api/useBrokerageAccounts";
type Money = NonNullable< type Money = NonNullable<
BrokerageAccount["portfolioSummary"] BrokerageAccount["portfolioSummary"]
>["totalsByCurrency"][number]; >["totalsByCurrency"][number];
type BrokerageAccountCard = { type BrokerageAccountCard = {
source?: NonNullable<
ReturnType<typeof useBrokerageAccounts>["meta"]
>["dependencies"] extends (infer T)[] | undefined
? T
: never;
account: BrokerageAccount; account: BrokerageAccount;
positionCounts: { stocks: number; bonds: number } | null; positionCounts: { stocks: number; bonds: number } | null;
summary: summary:
@@ -19,11 +27,19 @@ function accountTypeKey(type: BrokerageAccount["type"]) {
function brokerageAccountCards( function brokerageAccountCards(
accounts: BrokerageAccount[], accounts: BrokerageAccount[],
dependencies: NonNullable<
ReturnType<typeof useBrokerageAccounts>["meta"]
>["dependencies"] = [],
): BrokerageAccountCard[] { ): BrokerageAccountCard[] {
return accounts.map((account) => { return accounts.map((account) => {
const source = dependencies.find(
(item) =>
item.resourceKey === `portfolio/${encodeURIComponent(account.id)}`,
);
const summary = account.portfolioSummary; const summary = account.portfolioSummary;
if (!summary) if (!summary)
return { return {
source,
account, account,
positionCounts: null, positionCounts: null,
summary: { state: "unavailable" }, summary: { state: "unavailable" },
@@ -33,8 +49,9 @@ function brokerageAccountCards(
bonds: summary.bondPositionCount, bonds: summary.bondPositionCount,
}; };
if (!summary.totalsByCurrency.length) if (!summary.totalsByCurrency.length)
return { account, positionCounts, summary: { state: "empty" } }; return { source, account, positionCounts, summary: { state: "empty" } };
return { return {
source,
account, account,
positionCounts, positionCounts,
summary: { summary: {
@@ -185,3 +185,44 @@ export const LargeValues: Story = {
]), ]),
}, },
}; };
export const IndependentFreshness: Story = {
name: "Независимая свежесть и ошибка сводок",
args: {
accounts: brokerageAccountCards(
[
syntheticAccount,
{
...syntheticAccount,
id: "waiting",
name: "Новый счёт",
portfolioSummary: null,
},
],
[
{
resourceKey: "portfolio/storybook-account",
dataRevision: "1",
stateVersion: "failed",
availability: "complete",
freshness: "stale",
lastSuccessfulAt: "2026-09-24T10:30:00Z",
sync: {
id: "failed",
state: "failed",
errorCode: "TBANK_UNAVAILABLE",
},
},
{
resourceKey: "portfolio/waiting",
dataRevision: "0",
stateVersion: "waiting",
availability: "missing",
freshness: "unknown",
lastSuccessfulAt: null,
sync: { id: "waiting", state: "running", errorCode: null },
},
],
),
},
};
@@ -2,6 +2,7 @@ import { MantineProvider } from "@mantine/core";
import { render, screen, within } from "@testing-library/react"; import { render, screen, within } from "@testing-library/react";
import { MemoryRouter } from "react-router"; import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { resource } from "@/test/resource";
import { brokerageAccountCards } from "../../model/display"; import { brokerageAccountCards } from "../../model/display";
import { BrokerageAccountList } from "./BrokerageAccountList"; import { BrokerageAccountList } from "./BrokerageAccountList";
@@ -84,3 +85,42 @@ describe("BrokerageAccountList", () => {
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
}); });
it("shows portfolio summary freshness independently for each account", () => {
const accounts = brokerageAccountCards(
["fresh", "stale"].map((id) => ({
id,
name: id,
type: "BROKERAGE" as const,
openedOn: null,
portfolioSummary: {
stockPositionCount: 1,
bondPositionCount: 0,
totalsByCurrency: [{ currency: "RUB", value: "10" }],
},
})),
[
resource(null, { resourceKey: "portfolio/fresh", freshness: "fresh" })
.meta,
resource(null, { resourceKey: "portfolio/stale", freshness: "stale" })
.meta,
],
);
render(
<MantineProvider>
<MemoryRouter>
<BrokerageAccountList accounts={accounts} />
</MemoryRouter>
</MantineProvider>,
);
expect(
within(screen.getByRole("link", { name: /stale/ })).getByText(
"Данные требуют обновления",
),
).toBeVisible();
expect(
within(screen.getByRole("link", { name: /fresh/ })).queryByText(
"Данные требуют обновления",
),
).not.toBeInTheDocument();
});
@@ -2,7 +2,7 @@ import { Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import type { FC } from "react"; import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import { formatDate, formatMoney } from "@/shared/lib"; import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib";
import type { BrokerageAccountCard } from "../../model/display"; import type { BrokerageAccountCard } from "../../model/display";
import { accountTypeKey } from "../../model/display"; import { accountTypeKey } from "../../model/display";
import classes from "./BrokerageAccountList.module.css"; import classes from "./BrokerageAccountList.module.css";
@@ -52,8 +52,8 @@ const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<SimpleGrid className={classes.list} cols={{ base: 1, lg: 2 }} spacing="lg"> <SimpleGrid className={classes.list} cols={1} spacing="lg">
{accounts.map(({ account, summary, positionCounts }) => ( {accounts.map(({ account, summary, positionCounts, source }) => (
<Card <Card
component={Link} component={Link}
to={`/accounts/${account.id}`} to={`/accounts/${account.id}`}
@@ -92,6 +92,25 @@ const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
{t("accounts.currentValue")} {t("accounts.currentValue")}
</Text> </Text>
<BrokerageAccountSummary summary={summary} /> <BrokerageAccountSummary summary={summary} />
{source?.lastSuccessfulAt ? (
<Text size="xs" c="dimmed">
{t("investment.sourceTime", {
date: formatSnapshotTime(source.lastSuccessfulAt),
})}
</Text>
) : null}
{source?.sync.state === "running" ||
source?.sync.state === "queued" ? (
<Text size="xs" role="status">
{t("accounts.refreshing")}
</Text>
) : null}
{source?.sync.state === "failed" ? (
<Text size="xs">{t("accounts.refreshError")}</Text>
) : null}
{source?.freshness === "stale" ? (
<Text size="xs">{t("investment.stale")}</Text>
) : null}
</Stack> </Stack>
{positionCounts ? ( {positionCounts ? (
<dl className={classes.positions}> <dl className={classes.positions}>
@@ -3,6 +3,7 @@ import { HttpResponse, http } from "msw";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query"; import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { useBrokerageEvents } from "./useBrokerageEvents"; import { useBrokerageEvents } from "./useBrokerageEvents";
@@ -31,7 +32,8 @@ describe("useBrokerageEvents", () => {
}); });
server.use( server.use(
http.get("*/api/v1/brokerage-accounts/account-1/events", () => http.get("*/api/v1/brokerage-accounts/account-1/events", () =>
HttpResponse.json({ HttpResponse.json(
resource({
accountId: "account-1", accountId: "account-1",
calculatedAt: "2026-08-14T00:00:00Z", calculatedAt: "2026-08-14T00:00:00Z",
events: [ events: [
@@ -49,6 +51,7 @@ describe("useBrokerageEvents", () => {
], ],
}), }),
), ),
),
); );
const { result } = renderHook(() => useBrokerageEvents("account-1"), { const { result } = renderHook(() => useBrokerageEvents("account-1"), {
@@ -1,19 +1,17 @@
import { import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
apiQueryClient,
type paths,
useAuthenticatedQuery,
} from "@/shared/api";
type EventsEndpoint = type EventsEndpoint =
paths["/api/v1/brokerage-accounts/{accountId}/events"]["get"]; paths["/api/v1/brokerage-accounts/{accountId}/events"]["get"];
type BrokerageEvent = type BrokerageEvent = NonNullable<
EventsEndpoint["responses"][200]["content"]["application/json"]["events"][number]; EventsEndpoint["responses"][200]["content"]["application/json"]["data"]
>["events"][number];
function useBrokerageEvents( function useBrokerageEvents(
accountId: string | undefined, accountId: string | undefined,
query: EventsEndpoint["parameters"]["query"] = {}, query: Omit<NonNullable<EventsEndpoint["parameters"]["query"]>, "sync"> = {},
) { ) {
return useAuthenticatedQuery({ return useResourceQuery({
meta: { accessGroup: "brokerage" },
...apiQueryClient.queryOptions( ...apiQueryClient.queryOptions(
"get", "get",
"/api/v1/brokerage-accounts/{accountId}/events", "/api/v1/brokerage-accounts/{accountId}/events",
@@ -3,6 +3,7 @@ import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react"; import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import { formatSnapshotTime } from "@/shared/lib";
import type { BrokerageEvent } from "../../api/useBrokerageEvents"; import type { BrokerageEvent } from "../../api/useBrokerageEvents";
import { BrokerageEventCard } from "../BrokerageEventCard"; import { BrokerageEventCard } from "../BrokerageEventCard";
@@ -12,7 +13,14 @@ type BrokerageEventsPreviewProps = {
type BrokerageEventsPreviewState = type BrokerageEventsPreviewState =
| { state: "loading" | "error" | "empty"; to: string } | { state: "loading" | "error" | "empty"; to: string }
| { state: "loaded"; to: string; items: BrokerageEvent[] }; | {
state: "loaded" | "partial";
to: string;
lastSuccessfulAt?: string | null;
stale?: boolean;
refreshError?: boolean;
items: BrokerageEvent[];
};
const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => { const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
const { events } = props; const { events } = props;
@@ -20,6 +28,21 @@ const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
return ( return (
<Stack gap="sm"> <Stack gap="sm">
{events.state === "loaded" ? (
<>
{events.lastSuccessfulAt ? (
<Text size="xs" c="dimmed">
{t("investment.sourceTime", {
date: formatSnapshotTime(events.lastSuccessfulAt),
})}
</Text>
) : null}
{events.stale ? <Text size="xs">{t("investment.stale")}</Text> : null}
{events.refreshError ? (
<Text role="status">{t("accounts.refreshError")}</Text>
) : null}
</>
) : null}
{events.state === "loading" ? ( {events.state === "loading" ? (
<LoadingSkeleton label={t("events.recentLoading")}> <LoadingSkeleton label={t("events.recentLoading")}>
{[0, 1, 2].map((index) => ( {[0, 1, 2].map((index) => (
@@ -33,7 +56,10 @@ const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
{events.state === "empty" ? ( {events.state === "empty" ? (
<Text c="dimmed">{t("events.recentEmpty")}</Text> <Text c="dimmed">{t("events.recentEmpty")}</Text>
) : null} ) : null}
{events.state === "loaded" {events.state === "partial" ? (
<Text role="status">{t("investment.partial")}</Text>
) : null}
{events.state === "loaded" || events.state === "partial"
? events.items.map((event) => ( ? events.items.map((event) => (
<BrokerageEventCard <BrokerageEventCard
key={JSON.stringify([ key={JSON.stringify([
+11 -11
View File
@@ -1,16 +1,15 @@
import { import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
apiQueryClient,
type paths,
useAuthenticatedQuery,
} from "@/shared/api";
type IncomeSummary = type IncomeSummary = NonNullable<
paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"]; paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"]["data"]
type IncomeAnalytics = >;
paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]; type IncomeAnalytics = NonNullable<
paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["data"]
>;
function useIncomeSummary(accountId: string | undefined) { function useIncomeSummary(accountId: string | undefined) {
return useAuthenticatedQuery({ return useResourceQuery({
meta: { accessGroup: "brokerage" },
...apiQueryClient.queryOptions( ...apiQueryClient.queryOptions(
"get", "get",
"/api/v1/brokerage-accounts/{accountId}/income-summary", "/api/v1/brokerage-accounts/{accountId}/income-summary",
@@ -25,7 +24,8 @@ function useIncomeAnalytics(
accountId: string | undefined, accountId: string | undefined,
period: { from: string; to: string }, period: { from: string; to: string },
) { ) {
return useAuthenticatedQuery({ return useResourceQuery({
meta: { accessGroup: "brokerage" },
...apiQueryClient.queryOptions( ...apiQueryClient.queryOptions(
"get", "get",
"/api/v1/brokerage-accounts/{accountId}/income-analytics", "/api/v1/brokerage-accounts/{accountId}/income-analytics",
@@ -0,0 +1,144 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { describe, expect, it, vi } from "vitest";
import { apiClient, configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import {
type Portfolio,
portfolioQueryOptions,
usePortfolio,
} from "./usePortfolio";
const snapshot: Portfolio = {
positions: [],
totalsByCurrency: [{ currency: "RUB", value: "123.000000001" }],
cashBalances: [],
structure: { currencies: [], unvaluedCategories: [] },
lastSuccessfulAt: "2026-08-14T00:00:00Z",
isStale: false,
};
function auth() {
configureAuthClient({
accessToken: () => "synthetic",
refresh: async () => true,
clear: vi.fn(),
});
}
describe("persistent resource query lifecycle", () => {
it("observes into the canonical cache and rejects an old notified state before accepting the current one", async () => {
auth();
const modes: (string | null)[] = [];
let observations = 0;
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/portfolio",
({ request }) => {
const mode = new URL(request.url).searchParams.get("sync");
modes.push(mode);
if (mode) observations += 1;
return HttpResponse.json(
resource(snapshot, {
stateVersion: observations > 1 ? "new" : "old",
}),
);
},
),
);
const { wrapper, queryClient } = createQueryWrapper();
const { result } = renderHook(() => usePortfolio("a"), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
await act(() => result.current.observe("new"));
await waitFor(() => expect(result.current.meta?.stateVersion).toBe("new"));
expect(modes).toEqual([null, "observe", "observe"]);
expect(queryClient.getQueryCache().getAll()).toHaveLength(1);
expect(
queryClient.getQueryData(portfolioQueryOptions("a").queryKey),
).toMatchObject({ meta: { stateVersion: "new" }, data: snapshot });
});
it("does not convert missing or failed source data to empty financial success", async () => {
auth();
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(
resource(null, {
sync: {
id: "work",
state: "failed",
errorCode: "TBANK_UNAVAILABLE",
},
}),
),
),
);
const { result } = renderHook(() => usePortfolio("a"), {
wrapper: createQueryWrapper().wrapper,
});
await waitFor(() => expect(result.current.meta?.sync.state).toBe("failed"));
expect(result.current.data).toBeUndefined();
expect(result.current.isSuccess).toBe(false);
expect(result.current.meta?.availability).toBe("missing");
});
it("rejects a late ordinary response when an observe response for that resource arrived first", async () => {
auth();
let release!: () => void;
const blocked = new Promise<void>((resolve) => {
release = resolve;
});
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/portfolio",
async ({ request }) => {
if (!new URL(request.url).searchParams.has("sync")) await blocked;
return HttpResponse.json(resource(snapshot));
},
),
);
const old = apiClient.GET(
"/api/v1/brokerage-accounts/{accountId}/portfolio",
{ params: { path: { accountId: "a" } } },
);
const rejected = expect(old).rejects.toMatchObject({ name: "AbortError" });
await apiClient.GET("/api/v1/brokerage-accounts/{accountId}/portfolio", {
params: { path: { accountId: "a" }, query: { sync: "observe" } },
});
release();
await rejected;
});
it("clears private snapshots on confirmed access loss and fences another pending success", async () => {
auth();
let denied = false;
let release!: () => void;
const blocked = new Promise<void>((resolve) => {
release = resolve;
});
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/portfolio",
async ({ params }) => {
if (params.accountId === "b") await blocked;
if (params.accountId === "a" && denied)
return HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 });
return HttpResponse.json(resource(snapshot));
},
),
);
const { wrapper } = createQueryWrapper();
const { result } = renderHook(
() => ({ a: usePortfolio("a"), b: usePortfolio("b") }),
{ wrapper },
);
await waitFor(() => expect(result.current.a.data).toEqual(snapshot));
denied = true;
await act(() => result.current.a.refetch());
await waitFor(() => expect(result.current.a.data).toBeUndefined());
await act(async () => {
release();
await blocked;
});
expect(result.current.b.data).toBeUndefined();
expect(result.current.a.isError).toBe(true);
});
});
@@ -1,9 +1,9 @@
import { renderHook, waitFor } from "@testing-library/react"; import { renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw"; import { HttpResponse, http } from "msw";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query"; import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { usePortfolio } from "./usePortfolio"; import { usePortfolio } from "./usePortfolio";
@@ -33,14 +33,16 @@ describe("usePortfolio", () => {
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => { http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
calls += 1; calls += 1;
return calls === 1 return calls === 1
? HttpResponse.json({ ? HttpResponse.json(
resource({
lastSuccessfulAt: "2026-08-14T00:00:00Z", lastSuccessfulAt: "2026-08-14T00:00:00Z",
isStale: false, isStale: false,
totalsByCurrency: [], totalsByCurrency: [],
positions: [], positions: [],
structure: { currencies: [], unvaluedCategories: [] }, structure: { currencies: [], unvaluedCategories: [] },
cashBalances: [], cashBalances: [],
}) }),
)
: HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }); : HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
}), }),
); );
+6 -8
View File
@@ -1,15 +1,12 @@
import { import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
apiQueryClient,
type paths,
useAuthenticatedQuery,
} from "@/shared/api";
const portfolioQueryPolicy = { const portfolioQueryPolicy = {
staleTime: 30_000, staleTime: 30_000,
} as const; } as const;
type Portfolio = type Portfolio = NonNullable<
paths["/api/v1/brokerage-accounts/{accountId}/portfolio"]["get"]["responses"][200]["content"]["application/json"]; paths["/api/v1/brokerage-accounts/{accountId}/portfolio"]["get"]["responses"][200]["content"]["application/json"]["data"]
>;
function portfolioQueryOptions(accountId: string | undefined) { function portfolioQueryOptions(accountId: string | undefined) {
return apiQueryClient.queryOptions( return apiQueryClient.queryOptions(
@@ -20,7 +17,8 @@ function portfolioQueryOptions(accountId: string | undefined) {
} }
function usePortfolio(accountId: string | undefined) { function usePortfolio(accountId: string | undefined) {
return useAuthenticatedQuery({ return useResourceQuery({
meta: { accessGroup: "brokerage" },
...portfolioQueryOptions(accountId), ...portfolioQueryOptions(accountId),
enabled: Boolean(accountId), enabled: Boolean(accountId),
...portfolioQueryPolicy, ...portfolioQueryPolicy,
+16 -10
View File
@@ -1,8 +1,9 @@
import { import {
apiQueryClient, apiQueryClient,
type paths, type paths,
unwrapResourceResult,
useAuthenticatedQueries, useAuthenticatedQueries,
useAuthenticatedQuery, useResourceQuery,
} from "@/shared/api"; } from "@/shared/api";
type StockPeriod = NonNullable< type StockPeriod = NonNullable<
@@ -14,7 +15,7 @@ type StockPeriod = NonNullable<
const STOCK_STALE_TIME_MS = 30_000; const STOCK_STALE_TIME_MS = 30_000;
function useStockSearch(query: string) { function useStockSearch(query: string) {
return useAuthenticatedQuery({ return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks", { ...apiQueryClient.queryOptions("get", "/api/v1/stocks", {
params: { query: { query } }, params: { query: { query } },
}), }),
@@ -35,7 +36,10 @@ function useStocks(uids: string[]) {
const uniqueUids = [...new Set(uids)]; const uniqueUids = [...new Set(uids)];
const queries = useAuthenticatedQueries(uniqueUids.map(stockQueryOptions)); const queries = useAuthenticatedQueries(uniqueUids.map(stockQueryOptions));
return new Map( return new Map(
queries.map((query, index) => [uniqueUids[index], query.data]), queries.map((query, index) => [
uniqueUids[index],
unwrapResourceResult(query).data,
]),
); );
} }
@@ -45,11 +49,11 @@ function useStock(identifier: string) {
identifier, identifier,
); );
const byUid = useAuthenticatedQuery({ const byUid = useResourceQuery({
...stockQueryOptions(identifier), ...stockQueryOptions(identifier),
enabled: Boolean(identifier) && isUid, enabled: Boolean(identifier) && isUid,
}); });
const byTicker = useAuthenticatedQuery({ const byTicker = useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks/by-ticker/{ticker}", { ...apiQueryClient.queryOptions("get", "/api/v1/stocks/by-ticker/{ticker}", {
params: { path: { ticker: identifier } }, params: { path: { ticker: identifier } },
}), }),
@@ -59,7 +63,7 @@ function useStock(identifier: string) {
return isUid ? byUid : byTicker; return isUid ? byUid : byTicker;
} }
function useStockQuote(uid: string) { function useStockQuote(uid: string) {
return useAuthenticatedQuery({ return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/quote", { ...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/quote", {
params: { path: { uid } }, params: { path: { uid } },
}), }),
@@ -68,7 +72,7 @@ function useStockQuote(uid: string) {
}); });
} }
function useStockCandles(uid: string, period: StockPeriod) { function useStockCandles(uid: string, period: StockPeriod) {
return useAuthenticatedQuery({ return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/candles", { ...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/candles", {
params: { path: { uid }, query: { period } }, params: { path: { uid }, query: { period } },
}), }),
@@ -77,7 +81,8 @@ function useStockCandles(uid: string, period: StockPeriod) {
}); });
} }
function useStockPosition(uid: string) { function useStockPosition(uid: string) {
return useAuthenticatedQuery({ return useResourceQuery({
meta: { accessGroup: "brokerage" },
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/position", { ...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/position", {
params: { path: { uid } }, params: { path: { uid } },
}), }),
@@ -86,7 +91,7 @@ function useStockPosition(uid: string) {
}); });
} }
function useStockCompany(uid: string) { function useStockCompany(uid: string) {
return useAuthenticatedQuery({ return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/company", { ...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/company", {
params: { path: { uid } }, params: { path: { uid } },
}), }),
@@ -95,7 +100,8 @@ function useStockCompany(uid: string) {
}); });
} }
function useStockDividends(uid: string) { function useStockDividends(uid: string) {
return useAuthenticatedQuery({ return useResourceQuery({
meta: { accessGroup: "brokerage" },
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/dividends", { ...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/dividends", {
params: { path: { uid } }, params: { path: { uid } },
}), }),
+2 -1
View File
@@ -5,6 +5,7 @@ import type { FC, PropsWithChildren } from "react";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { apiClient, apiQueryClient, useAuthenticatedQuery } from "@/shared/api"; import { apiClient, apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { AuthProvider, useAuth } from "./auth"; import { AuthProvider, useAuth } from "./auth";
@@ -24,7 +25,7 @@ describe("AuthProvider", () => {
), ),
http.get("*/api/v1/brokerage-accounts", ({ request }) => { http.get("*/api/v1/brokerage-accounts", ({ request }) => {
authorization = request.headers.get("Authorization"); authorization = request.headers.get("Authorization");
return HttpResponse.json([]); return HttpResponse.json(resource([]));
}), }),
); );
const queryClient = createTestQueryClient(); const queryClient = createTestQueryClient();
+1 -1
View File
@@ -143,8 +143,8 @@ const AuthProvider: FC<PropsWithChildren> = (props) => {
refresh, refresh,
clear: clearSession, clear: clearSession,
}); });
return () => configureAuthClient(null);
}); });
useEffect(() => () => configureAuthClient(null), []);
return ( return (
<AuthContext <AuthContext
@@ -0,0 +1,59 @@
import { describe, expect, it, vi } from "vitest";
import { resource } from "@/test/resource";
import { section } from "./section";
describe("instrument source and transport presentation", () => {
it.each(["missing", "partial", "complete"] as const)(
"keeps %s separate from successful transport",
(availability) => {
const data = availability === "missing" ? undefined : { rows: [] };
const state = section({
data,
meta: resource(data, { availability }).meta,
isError: false,
isFetching: false,
refetch: vi.fn(),
});
expect(state.loading).toBe(availability === "missing");
expect(state.partial).toBe(availability === "partial");
expect(state.hasData).toBe(availability !== "missing");
},
);
it("preserves a full fresh source and timestamp when force fails, while offering scoped retry", () => {
const retry = vi.fn();
const refetch = vi.fn();
const meta = resource([], {
sync: { id: "work", state: "failed", errorCode: "TBANK_UNAVAILABLE" },
}).meta;
const state = section(
{ data: [], meta, isError: false, isFetching: false, refetch },
retry,
);
expect(state).toMatchObject({
hasData: true,
loading: false,
error: true,
refreshing: false,
lastSuccessfulAt: meta.lastSuccessfulAt,
});
state.retry();
expect(retry).toHaveBeenCalledOnce();
expect(refetch).not.toHaveBeenCalled();
});
it("shows source failure without a false loading or empty result", () => {
const state = section({
data: undefined,
meta: resource(null, {
sync: { id: "work", state: "failed", errorCode: "TBANK_UNAVAILABLE" },
}).meta,
isError: false,
isFetching: false,
refetch: vi.fn(),
});
expect(state).toMatchObject({
hasData: false,
loading: false,
error: true,
});
});
});
@@ -1,23 +1,44 @@
import type { paths } from "@/shared/api";
type Meta =
paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number];
type SectionState = { type SectionState = {
stale?: boolean;
sourceError?: boolean;
partial?: boolean;
lastSuccessfulAt?: string | null;
hasData: boolean; hasData: boolean;
loading: boolean; loading: boolean;
error: boolean; error: boolean;
refreshing: boolean; refreshing: boolean;
retry: () => void; retry: () => void;
}; };
function section(query: { function section(
query: {
data: unknown; data: unknown;
meta?: Meta;
isError: boolean; isError: boolean;
isFetching: boolean; isFetching: boolean;
refetch: () => unknown; refetch: () => unknown;
}): SectionState { },
retry?: () => unknown,
): SectionState {
const error = query.isError || query.meta?.sync.state === "failed";
return { return {
stale: query.meta?.freshness === "stale",
sourceError: query.meta?.sync.state === "failed",
partial: query.meta?.availability === "partial",
lastSuccessfulAt: query.meta?.lastSuccessfulAt,
hasData: Boolean(query.data), hasData: Boolean(query.data),
loading: !query.data && !query.isError, loading: !query.data && !error,
error: query.isError, error,
refreshing: Boolean(query.data) && query.isFetching, refreshing:
Boolean(query.data) &&
(query.isFetching ||
query.meta?.sync.state === "running" ||
query.meta?.sync.state === "queued"),
retry: () => { retry: () => {
void query.refetch(); void (retry ?? query.refetch)();
}, },
}; };
} }
@@ -2,10 +2,12 @@ import type { BrokerageOperation } from "@/entities/broker-operation";
import type { paths } from "@/shared/api"; import type { paths } from "@/shared/api";
import type { SectionState } from "./section"; import type { SectionState } from "./section";
type Position = type Position = NonNullable<
paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]; paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]
type Candles = >;
paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]; type Candles = NonNullable<
paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"]
>;
type ChartRow = Candles["candles"][number] & { date: string; price: number }; type ChartRow = Candles["candles"][number] & { date: string; price: number };
type InstrumentPositionVm = { type InstrumentPositionVm = {
position?: Position; position?: Position;
@@ -177,7 +177,13 @@ const InstrumentChart: FC<InstrumentChartProps> = (props) => {
</Accordion> </Accordion>
</> </>
) : ( ) : (
<Text c="dimmed">{t("stock.noCandles")}</Text> <Text c="dimmed">
{t(
vm.candlesState.partial
? "investment.partial"
: "stock.noCandles",
)}
</Text>
) )
) : null} ) : null}
</InstrumentSection> </InstrumentSection>
@@ -60,7 +60,11 @@ const InstrumentEvents: FC<InstrumentEventsProps> = (props) => {
</Stack> </Stack>
) : ( ) : (
<Text c="dimmed" py="lg"> <Text c="dimmed" py="lg">
{t("stock.noOperations")} {t(
vm.operationsState.partial
? "investment.partial"
: "stock.noOperations",
)}
</Text> </Text>
)} )}
<Group justify="space-between" mt="md"> <Group justify="space-between" mt="md">
@@ -11,6 +11,7 @@ import {
import { LoadingSkeleton } from "@wealthledger/ui"; import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC, PropsWithChildren, ReactNode } from "react"; import type { FC, PropsWithChildren, ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatSnapshotTime } from "@/shared/lib";
import type { SectionState } from "../model/section"; import type { SectionState } from "../model/section";
import classes from "./InstrumentDetails.module.css"; import classes from "./InstrumentDetails.module.css";
@@ -38,20 +39,40 @@ const InstrumentSection: FC<InstrumentSectionProps> = (props) => {
</Title> </Title>
{props.controls} {props.controls}
</Group> </Group>
{props.state.stale ? (
<Text size="xs">{t("investment.stale")}</Text>
) : null}
{props.state.partial ? (
<Text role="status">{t("investment.partial")}</Text>
) : null}
{props.state.lastSuccessfulAt ? (
<Text size="xs" c="dimmed">
{t("investment.sourceTime", {
date: formatSnapshotTime(props.state.lastSuccessfulAt),
})}
</Text>
) : null}
{props.state.refreshing ? ( {props.state.refreshing ? (
<Text role="status" size="xs" c="dimmed"> <Text role="status" size="xs" c="dimmed">
{t("accounts.refreshing")} {t("accounts.refreshing")}
</Text> </Text>
) : null} ) : null}
{props.state.error ? ( {props.state.error ? (
<Alert color="orange" mb="sm" role="alert"> <Alert color="orange" mb="sm" role="status" aria-live="polite">
<Group justify="space-between"> <Group justify="space-between">
<Text size="sm"> <Text size="sm">
{props.state.hasData {props.state.sourceError
? t("investment.syncFailed")
: props.state.hasData
? t("accounts.refreshError") ? t("accounts.refreshError")
: t("stock.sectionError")} : t("stock.sectionError")}
</Text> </Text>
<Button size="xs" variant="default" onClick={props.state.retry}> <Button
size="xs"
variant="default"
onClick={props.state.retry}
disabled={props.state.refreshing}
>
{t("accounts.retry")} {t("accounts.retry")}
</Button> </Button>
</Group> </Group>
+6
View File
@@ -1 +1,7 @@
export { useScreenSync } from "./model/screen-sync";
export {
type InvestmentScreen,
useTbankSync,
useTbankSyncStatus,
} from "./model/sync";
export { TbankConnectionSection } from "./ui/TbankConnectionSection/TbankConnectionSection"; export { TbankConnectionSection } from "./ui/TbankConnectionSection/TbankConnectionSection";
@@ -1,5 +1,9 @@
import type { QueryClient } from "@tanstack/react-query"; import type { QueryClient } from "@tanstack/react-query";
import { apiQueryClient, type paths } from "@/shared/api"; import {
apiQueryClient,
invalidateApiRequests,
type paths,
} from "@/shared/api";
type TbankConnectionStatus = type TbankConnectionStatus =
paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"]; paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"];
@@ -15,6 +19,13 @@ function commitTbankConnectionBoundary(
queryClient: QueryClient, queryClient: QueryClient,
status: TbankConnectionStatus, status: TbankConnectionStatus,
) { ) {
invalidateApiRequests(
(path) =>
path.startsWith("/api/v1/brokerage-accounts") ||
path.startsWith("/api/v1/stocks") ||
path.startsWith("/api/v1/bonds") ||
path.startsWith("/api/v1/tbank-sync"),
);
queryClient.setQueryData(tbankConnectionQueryKey, status); queryClient.setQueryData(tbankConnectionQueryKey, status);
void queryClient.resetQueries({ void queryClient.resetQueries({
predicate: ({ queryKey }) => { predicate: ({ queryKey }) => {
@@ -22,7 +33,8 @@ function commitTbankConnectionBoundary(
return ( return (
method === "get" && method === "get" &&
typeof path === "string" && typeof path === "string" &&
(path === "/api/v1/bonds" || (path.startsWith("/api/v1/tbank-sync") ||
path === "/api/v1/bonds" ||
path.startsWith("/api/v1/bonds/") || path.startsWith("/api/v1/bonds/") ||
path === "/api/v1/stocks" || path === "/api/v1/stocks" ||
path.startsWith("/api/v1/stocks/") || path.startsWith("/api/v1/stocks/") ||
@@ -0,0 +1,268 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { describe, expect, it, vi } from "vitest";
import { useBrokerageAccounts } from "@/entities/brokerage-account";
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { useScreenSync } from "../index";
function auth() {
configureAuthClient({
accessToken: () => "synthetic",
refresh: async () => true,
clear: vi.fn(),
});
}
describe("page-owned screen synchronization", () => {
it("posts only its scope once and observes completion without importing again", async () => {
auth();
const modes: (string | null)[] = [];
const bodies: unknown[] = [];
let completed = false;
let polls = 0;
const meta = () =>
resource([], { stateVersion: completed ? "2" : "1" }).meta;
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return HttpResponse.json(resource([], meta()));
}),
http.post("*/api/v1/tbank-sync", async ({ request }) => {
bodies.push(await request.json());
completed = true;
return HttpResponse.json(
{
id: "screen",
scope: { screen: "accounts" },
now: "2026-09-25T00:00:00Z",
},
{ status: 202 },
);
}),
http.get("*/api/v1/tbank-sync/:id", () => {
polls++;
return HttpResponse.json({
id: "screen",
scope: { screen: "accounts" },
now: "2026-09-25T00:00:00Z",
state: "succeeded",
errorCode: null,
progress: { total: 1, completed: 1, discovering: false },
resources: [meta()],
});
}),
);
const { result, unmount } = renderHook(
() => {
const accounts = useBrokerageAccounts();
return {
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts]),
};
},
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() => expect(result.current.accounts.data).toEqual([]));
await act(async () => {
await Promise.all([
result.current.sync.refresh(),
result.current.sync.refresh(),
]);
});
await waitFor(() =>
expect(result.current.accounts.meta?.stateVersion).toBe("2"),
);
expect(bodies).toEqual([{ screen: "accounts" }]);
expect(modes).toEqual([null, "observe"]);
vi.useFakeTimers();
try {
await act(() => vi.advanceTimersByTimeAsync(120000));
expect(polls).toBe(1);
expect(modes).toEqual([null, "observe"]);
unmount();
await vi.advanceTimersByTimeAsync(120000);
expect(polls).toBe(1);
} finally {
vi.useRealTimers();
}
});
it.each(["leave", "logout"])(
"publishes completed empty coverage while history runs, then stops on %s",
async (boundary) => {
vi.useFakeTimers({ toFake: ["Date"] });
try {
auth();
let completed = false;
let polls = 0;
const modes: (string | null)[] = [];
const snapshot = () =>
resource(completed ? [] : null, {
stateVersion: completed ? "covered" : "initial",
sync: { id: "history", state: "running", errorCode: null },
});
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return HttpResponse.json(snapshot());
}),
http.get("*/api/v1/tbank-sync/:id", () => {
polls++;
return HttpResponse.json({
id: "history",
scope: null,
now: "2026-09-25T00:00:00Z",
state: "running",
errorCode: null,
progress: {
total: 2,
completed: completed ? 1 : 0,
discovering: true,
},
resources: [snapshot().meta],
});
}),
);
const { result, unmount } = renderHook(
() => {
const accounts = useBrokerageAccounts();
return {
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts]),
};
},
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() =>
expect(result.current.accounts.meta?.sync.id).toBe("history"),
);
await waitFor(() => expect(polls).toBeGreaterThan(0));
expect(result.current.sync.refreshing).toBe(true);
expect(result.current.accounts.data).toBeUndefined();
completed = true;
vi.setSystemTime(Date.now() + 61000);
await waitFor(() => expect(result.current.accounts.data).toEqual([]), {
timeout: 3000,
});
expect(result.current.sync.refreshing).toBe(true);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 1100));
});
expect(modes).toEqual([null, "observe"]);
if (boundary === "leave") unmount();
else await act(() => configureAuthClient(null));
const stopped = polls;
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 1100));
});
expect(polls).toBe(stopped);
} finally {
vi.useRealTimers();
}
},
);
it("clears private snapshots when status confirms access loss", async () => {
auth();
server.use(
http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json(
resource([], {
sync: { id: "revoked", state: "running", errorCode: null },
}),
),
),
http.get("*/api/v1/tbank-sync/:id", () =>
HttpResponse.json({ code: "INVALID_TBANK_TOKEN" }, { status: 400 }),
),
);
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
return {
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts]),
};
},
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() => expect(result.current.accounts.isError).toBe(true));
expect(result.current.accounts.data).toBeUndefined();
});
it("observes a projection when only its dependency reports new coverage", async () => {
auth();
let observed = false;
const modes: (string | null)[] = [];
const dependency = resource([], {
resourceKey: "operations",
stateVersion: observed ? "covered" : "initial",
sync: {
id: "dependency",
state: observed ? "idle" : "running",
errorCode: null,
},
}).meta;
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
const mode = new URL(request.url).searchParams.get("sync");
modes.push(mode);
if (mode) observed = true;
return HttpResponse.json(
resource(observed ? [] : null, {
resourceKey: "projection",
dependencies: [
{ ...dependency, stateVersion: observed ? "covered" : "initial" },
],
}),
);
}),
http.get("*/api/v1/tbank-sync/:id", () =>
HttpResponse.json({
id: "dependency",
scope: null,
now: "2026-09-25T00:00:00Z",
state: "succeeded",
errorCode: null,
progress: { total: 1, completed: 1, discovering: false },
resources: [{ ...dependency, stateVersion: "covered" }],
}),
),
);
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
useScreenSync({ screen: "accounts" }, [accounts]);
return accounts;
},
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() => expect(result.current.data).toEqual([]));
expect(modes).toEqual([null, "observe"]);
});
it("preserves a fresh snapshot and timestamp after a failed force", async () => {
auth();
const saved = resource([], { lastSuccessfulAt: "2026-09-25T01:00:00Z" });
server.use(
http.get("*/api/v1/brokerage-accounts", () => HttpResponse.json(saved)),
http.post("*/api/v1/tbank-sync", () =>
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
),
);
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
return {
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts]),
};
},
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() => expect(result.current.accounts.data).toEqual([]));
await act(() => result.current.sync.refresh());
await waitFor(() => expect(result.current.sync.transportError).toBe(true));
expect(result.current.accounts.meta).toEqual(saved.meta);
});
});
@@ -0,0 +1,148 @@
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import {
apiErrorCode,
apiQueryClient,
invalidateResourceAccess,
type paths,
useAuthenticatedQueries,
} from "@/shared/api";
import { type InvestmentScreen, useTbankSync } from "./sync";
type ResourceMeta =
paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number];
type ScreenResource = {
meta?: ResourceMeta;
observe: (version?: string) => Promise<unknown>;
};
const STATUS_INTERVAL_MS = 1000;
function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
const mutation = useTbankSync();
const client = useQueryClient();
const denied = useRef(new Set<unknown>());
const scopeKey = JSON.stringify(scope);
const [requested, setRequested] = useState<{
scope: string;
id: string;
} | null>(null);
const requestInFlight = useRef<string | null>(null);
const currentScope = useRef(scopeKey);
currentScope.current = scopeKey;
const currentMutation = JSON.stringify(mutation.variables?.body) === scopeKey;
const observed = useRef(new Map<string, string>());
const ids = [
...new Set([
...(requested?.scope === scopeKey ? [requested.id] : []),
...resources
.flatMap(({ meta }) =>
meta ? [meta, ...(meta.dependencies ?? [])] : [],
)
.flatMap((meta) =>
meta?.sync.id &&
(meta.sync.state === "running" || meta.sync.state === "queued")
? [meta.sync.id]
: [],
),
]),
];
const statuses = useAuthenticatedQueries(
ids.map((id) => ({
...apiQueryClient.queryOptions("get", "/api/v1/tbank-sync/{id}", {
params: { path: { id } },
}),
retry: false,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchInterval: (query: {
state: { error: unknown; data?: { state: string } };
}) =>
query.state.error ||
(query.state.data && query.state.data.state !== "running")
? false
: STATUS_INTERVAL_MS,
})),
);
useEffect(() => {
for (const status of statuses) {
if (status.error && !denied.current.has(status.error)) {
denied.current.add(status.error);
if (apiErrorCode(status.error) !== "NOT_FOUND")
invalidateResourceAccess(client, "brokerage", status.error);
}
}
const latest = new Map<string, ResourceMeta>();
for (const status of [...statuses].sort(
(a, b) => a.dataUpdatedAt - b.dataUpdatedAt,
))
for (const meta of status.data?.resources ?? []) {
latest.set(meta.resourceKey, meta);
for (const dependency of meta.dependencies ?? [])
latest.set(dependency.resourceKey, dependency);
}
for (const resource of resources) {
if (!resource.meta) continue;
const changed = [
resource.meta,
...(resource.meta.dependencies ?? []),
].flatMap((current) => {
const notified = latest.get(current.resourceKey);
return notified && notified.stateVersion !== current.stateVersion
? [notified]
: [];
});
if (!changed.length) continue;
const notification = JSON.stringify(
changed.map((meta) => [meta.resourceKey, meta.stateVersion]),
);
if (observed.current.get(resource.meta.resourceKey) === notification)
continue;
observed.current.set(resource.meta.resourceKey, notification);
const own = changed.find(
(meta) => meta.resourceKey === resource.meta?.resourceKey,
);
void resource.observe(own?.stateVersion).catch(() => undefined);
}
}, [client, statuses, resources]);
const refreshing =
(currentMutation && mutation.isPending) ||
statuses.some(
(status) => !status.isError && status.data?.state === "running",
) ||
resources.some(({ meta }) => {
const status = statuses[ids.indexOf(meta?.sync.id ?? "")];
return (
!status?.isError &&
(!status?.data || status.data.state === "running") &&
(meta?.sync.state === "running" || meta?.sync.state === "queued")
);
});
const refresh = async () => {
if (requestInFlight.current === scopeKey || refreshing) return;
requestInFlight.current = scopeKey;
try {
const accepted = await mutation.mutateAsync({ body: scope });
if (
currentScope.current === scopeKey &&
requestInFlight.current === scopeKey
)
setRequested({ scope: scopeKey, id: accepted.id });
} catch {
/* The mutation retains the transport error for presentation. */
} finally {
if (requestInFlight.current === scopeKey) requestInFlight.current = null;
}
};
return {
refresh,
refreshing,
transportError:
(currentMutation && mutation.isError) ||
statuses.some((status) => status.isError),
sourceError:
statuses.some((status) => status.data?.state === "failed") ||
resources.some(({ meta }) => meta?.sync.state === "failed"),
};
}
export { useScreenSync };
@@ -0,0 +1,114 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { describe, expect, it, vi } from "vitest";
import { apiQueryClient, configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { server } from "@/test/server";
import {
type InvestmentScreen,
useTbankSync,
useTbankSyncStatus,
} from "../index";
import { commitTbankConnectionBoundary } from "./connection-boundary";
describe("sync connection and session boundaries", () => {
it.each(["connection", "session"])(
"discards pending status and mutation after %s changes",
async (boundary) => {
let generation = 0;
configureAuthClient({
generation: () => generation,
accessToken: () => "synthetic",
refresh: async () => true,
clear: vi.fn(),
});
let release!: () => void;
const blocked = new Promise<void>((resolve) => {
release = resolve;
});
const accepted = {
id: "work",
scope: { screen: "accounts" },
now: "2026-09-25T12:00:00Z",
};
let started = 0;
let changed = false;
server.use(
http.post("*/api/v1/tbank-sync", async () => {
started += 1;
await blocked;
return HttpResponse.json(accepted, { status: 202 });
}),
http.get("*/api/v1/tbank-sync/:id", async () => {
const now = changed ? "2026-09-25T12:01:00Z" : accepted.now;
started += 1;
await blocked;
return HttpResponse.json({
...accepted,
now,
state: "succeeded",
errorCode: null,
progress: { total: 1, completed: 1, discovering: false },
resources: [],
});
}),
);
const { wrapper, queryClient } = createQueryWrapper();
const published: string[] = [];
const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
if (event.query.queryKey[1] === "/api/v1/tbank-sync/{id}") {
const data = event.query.state.data as { now?: string } | undefined;
if (data?.now) published.push(data.now);
}
});
const { result } = renderHook(
() => ({
mutation: useTbankSync(),
status: useTbankSyncStatus("work"),
}),
{ wrapper },
);
const screen: InvestmentScreen = { screen: "accounts" };
let mutation!: Promise<unknown>;
act(() => {
mutation = result.current.mutation.mutateAsync({ body: screen });
});
const discarded = expect(mutation).rejects.toMatchObject({
name: "AbortError",
});
await waitFor(() => expect(started).toBe(2));
await act(async () => {
changed = true;
if (boundary === "connection")
commitTbankConnectionBoundary(queryClient, {
connected: false,
connectedAt: null,
});
else {
generation += 1;
queryClient.clear();
}
release();
await blocked;
});
await discarded;
expect(result.current.mutation.data).toBeUndefined();
const key = apiQueryClient.queryOptions(
"get",
"/api/v1/tbank-sync/{id}",
{ params: { path: { id: "work" } } },
).queryKey;
// A new request may start after a successful connection boundary; an old result cannot win it.
if (boundary === "session")
expect(queryClient.getQueryData(key)).toBeUndefined();
else
await waitFor(() =>
expect(queryClient.getQueryData(key)).toMatchObject({
now: "2026-09-25T12:01:00Z",
}),
);
expect(published).not.toContain(accepted.now);
unsubscribe();
},
);
});
@@ -0,0 +1,24 @@
import {
apiQueryClient,
type paths,
useAuthenticatedQuery,
} from "@/shared/api";
type InvestmentScreen =
paths["/api/v1/tbank-sync"]["post"]["requestBody"]["content"]["application/json"];
function useTbankSync() {
return apiQueryClient.useMutation("post", "/api/v1/tbank-sync", {
retry: false,
});
}
function useTbankSyncStatus(id: string | null) {
return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/tbank-sync/{id}", {
params: { path: { id: id ?? "" } },
}),
enabled: Boolean(id),
retry: false,
});
}
export { type InvestmentScreen, useTbankSync, useTbankSyncStatus };
@@ -1,6 +1,12 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query"; 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 { HttpResponse, http } from "msw";
import type { FC } from "react"; import type { FC } from "react";
import { import {
@@ -12,9 +18,11 @@ import {
} from "react-router"; } from "react-router";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api"; 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 { server } from "@/test/server";
import { AccountEventsPage } from "./AccountEventsPage"; import { AccountEventsPage } from "./AccountEventsPage";
import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm";
const Navigation: FC = () => { const Navigation: FC = () => {
const location = useLocation(); const location = useLocation();
@@ -44,12 +52,14 @@ describe("Upcoming event date filters", () => {
"*/api/v1/brokerage-accounts/:accountId/events", "*/api/v1/brokerage-accounts/:accountId/events",
({ request }) => { ({ request }) => {
searches.push(new URL(request.url).search); searches.push(new URL(request.url).search);
return HttpResponse.json({ return HttpResponse.json(
resource({
accountId, accountId,
calculatedAt: "2099-01-01T00:00:00Z", calculatedAt: "2099-01-01T00:00:00Z",
events: [], events: [],
incomeTotalsByCurrency: [], 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 { useBrokerageEvents } from "@/entities/brokerage-event";
import { AccountEventsPage } from "./AccountEventsPage"; 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) => ({ vi.mock("@/entities/brokerage-event", async (importOriginal) => ({
...(await importOriginal<typeof import("@/entities/brokerage-event")>()), ...(await importOriginal<typeof import("@/entities/brokerage-event")>()),
useBrokerageEvents: vi.fn(), useBrokerageEvents: vi.fn(),
@@ -24,12 +33,17 @@ function renderPage() {
describe("AccountEventsPage", () => { describe("AccountEventsPage", () => {
beforeEach(() => { beforeEach(() => {
refresh.mockClear();
query.mockReturnValue({ query.mockReturnValue({
data: { data: {
accountId: "account", accountId: "account",
calculatedAt: "2026-08-14T00:00:00Z", calculatedAt: "2026-08-14T00:00:00Z",
events: [], events: [],
}, },
meta: {
availability: "complete",
sync: { state: "idle", id: null, errorCode: null },
},
isError: false, isError: false,
isLoading: false, isLoading: false,
refetch: vi.fn(), refetch: vi.fn(),
@@ -55,6 +69,10 @@ describe("AccountEventsPage", () => {
it("keeps the loading fallback when no result or account is available", () => { it("keeps the loading fallback when no result or account is available", () => {
query.mockReturnValue({ query.mockReturnValue({
data: undefined, data: undefined,
meta: {
availability: "complete",
sync: { state: "idle", id: null, errorCode: null },
},
isError: false, isError: false,
isLoading: false, isLoading: false,
refetch: vi.fn(), refetch: vi.fn(),
@@ -99,6 +117,10 @@ describe("AccountEventsPage", () => {
}, },
], ],
}, },
meta: {
availability: "complete",
sync: { state: "idle", id: null, errorCode: null },
},
isError: false, isError: false,
isLoading: false, isLoading: false,
refetch: vi.fn(), refetch: vi.fn(),
@@ -145,12 +167,16 @@ describe("AccountEventsPage", () => {
</MantineProvider>, </MantineProvider>,
); );
screen.getByRole("button", { name: "Повторить" }).click(); screen.getByRole("button", { name: "Повторить" }).click();
expect(refetch).toHaveBeenCalledOnce(); expect(refresh).toHaveBeenCalledOnce();
}); });
it("shows a structural busy state before events are available", () => { it("shows a structural busy state before events are available", () => {
query.mockReturnValue({ query.mockReturnValue({
data: undefined, data: undefined,
meta: {
availability: "complete",
sync: { state: "idle", id: null, errorCode: null },
},
isError: false, isError: false,
isLoading: true, isLoading: true,
refetch: vi.fn(), refetch: vi.fn(),
@@ -4,6 +4,7 @@ import {
type BrokerageEvent, type BrokerageEvent,
useBrokerageEvents, useBrokerageEvents,
} from "@/entities/brokerage-event"; } from "@/entities/brokerage-event";
import { useScreenSync } from "@/features/tbank-connection";
type AccountEventsPageViewModel = type AccountEventsPageViewModel =
| { state: "loading" } | { state: "loading" }
@@ -11,6 +12,9 @@ type AccountEventsPageViewModel =
| { | {
state: "loaded"; state: "loaded";
events: BrokerageEvent[]; events: BrokerageEvent[];
sourceError?: boolean;
lastSuccessfulAt?: string | null;
partial?: boolean;
refreshing: boolean; refreshing: boolean;
refreshError: boolean; refreshError: boolean;
retry: () => void; retry: () => void;
@@ -41,22 +45,34 @@ function useAccountEventsPageViewModel(
...(appliedFrom ? { from: appliedFrom } : {}), ...(appliedFrom ? { from: appliedFrom } : {}),
...(appliedTo ? { to: appliedTo } : {}), ...(appliedTo ? { to: appliedTo } : {}),
}); });
const sync = useScreenSync(
{
screen: "events",
accountId,
...(appliedFrom ? { from: appliedFrom } : {}),
...(appliedTo ? { to: appliedTo } : {}),
},
[events],
);
if (events.isLoading) return { state: "loading" }; if (events.isLoading) return { state: "loading" };
if (events.isError && !events.data) if ((events.isError || events.meta?.sync.state === "failed") && !events.data)
return { return {
state: "error", state: "error",
retry: () => { retry: () => {
void events.refetch(); void sync.refresh();
}, },
}; };
if (!events.data) return { state: "loading" }; if (!events.data) return { state: "loading" };
return { return {
state: "loaded", state: "loaded",
events: events.data.events, events: events.data.events,
refreshing: events.isFetching, sourceError: sync.sourceError,
refreshError: events.isError, lastSuccessfulAt: events.meta?.lastSuccessfulAt,
partial: events.meta?.availability !== "complete",
refreshing: events.isFetching || sync.refreshing,
refreshError: events.isError || sync.sourceError || sync.transportError,
retry: () => { retry: () => {
void events.refetch(); void sync.refresh();
}, },
draftFrom, draftFrom,
draftTo, draftTo,
@@ -11,6 +11,7 @@ import { DatePickerInput } from "@mantine/dates";
import type { FC } from "react"; import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { BrokerageEventCard } from "@/entities/brokerage-event"; import { BrokerageEventCard } from "@/entities/brokerage-event";
import { formatSnapshotTime } from "@/shared/lib";
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm"; import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
import { AccountEventsPageEmpty } from "./AccountEventsPageEmpty.view"; import { AccountEventsPageEmpty } from "./AccountEventsPageEmpty.view";
@@ -23,13 +24,30 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Stack gap="lg"> <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> <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 ? ( {props.refreshing ? (
<Progress value={100} animated aria-label={t("accounts.refreshing")} /> <Progress value={100} animated aria-label={t("accounts.refreshing")} />
) : null} ) : null}
{props.refreshError ? ( {props.refreshError ? (
<Alert color="yellow"> <Alert color="yellow" role="status" aria-live="polite">
{t("accounts.refreshError")} {t(
props.sourceError
? "investment.syncFailed"
: "accounts.refreshError",
)}
<Button variant="subtle" onClick={props.retry}> <Button variant="subtle" onClick={props.retry}>
{t("accounts.retry")} {t("accounts.retry")}
</Button> </Button>
@@ -80,9 +98,9 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
/> />
))} ))}
</Stack> </Stack>
) : ( ) : !props.partial ? (
<AccountEventsPageEmpty /> <AccountEventsPageEmpty />
)} ) : null}
</Stack> </Stack>
); );
}; };
@@ -1,3 +1,4 @@
import { resource } from "@/test/resource";
import "dayjs/locale/ru"; import "dayjs/locale/ru";
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { DatesProvider } from "@mantine/dates"; import { DatesProvider } from "@mantine/dates";
@@ -154,7 +155,7 @@ describe("AccountIncomePage", () => {
"*/api/v1/brokerage-accounts/:accountId/income-analytics", "*/api/v1/brokerage-accounts/:accountId/income-analytics",
({ request }) => { ({ request }) => {
requested.push(new URL(request.url).search); requested.push(new URL(request.url).search);
return HttpResponse.json(analytics); return HttpResponse.json(resource(analytics));
}, },
), ),
); );
@@ -236,7 +237,7 @@ describe("AccountIncomePage", () => {
}; };
server.use( server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(response), HttpResponse.json(resource(response)),
), ),
); );
renderPage(); renderPage();
@@ -284,7 +285,7 @@ describe("AccountIncomePage", () => {
])("shows a local error for %s", async (_name, response) => { ])("shows a local error for %s", async (_name, response) => {
server.use( server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(response), HttpResponse.json(resource(response)),
), ),
); );
renderPage(); renderPage();
@@ -299,7 +300,8 @@ describe("AccountIncomePage", () => {
it("accepts an empty analytics response", async () => { it("accepts an empty analytics response", async () => {
server.use( server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json({ HttpResponse.json(
resource({
...analytics, ...analytics,
totalsByCurrency: [], totalsByCurrency: [],
buckets: [], buckets: [],
@@ -307,6 +309,7 @@ describe("AccountIncomePage", () => {
entries: [], entries: [],
}), }),
), ),
),
); );
renderPage(); renderPage();
expect( expect(
@@ -347,7 +350,7 @@ describe("AccountIncomePage", () => {
}; };
server.use( server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(response), HttpResponse.json(resource(response)),
), ),
); );
renderPage(); renderPage();
@@ -369,7 +372,8 @@ describe("AccountIncomePage", () => {
const eventsRequest = vi.fn(() => new HttpResponse(null, { status: 503 })); const eventsRequest = vi.fn(() => new HttpResponse(null, { status: 503 }));
server.use( server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json({ HttpResponse.json(
resource({
...analytics, ...analytics,
yearComparison: { yearComparison: {
period: { from: "2025-08-01", to: "2025-08-27" }, period: { from: "2025-08-01", to: "2025-08-27" },
@@ -377,6 +381,7 @@ describe("AccountIncomePage", () => {
}, },
}), }),
), ),
),
http.get("*/api/v1/brokerage-accounts/account-1/events", eventsRequest), http.get("*/api/v1/brokerage-accounts/account-1/events", eventsRequest),
); );
renderPage(); renderPage();
@@ -392,13 +397,16 @@ describe("AccountIncomePage", () => {
expect(screen.queryByText("Динамика")).not.toBeInTheDocument(); expect(screen.queryByText("Динамика")).not.toBeInTheDocument();
expect(eventsRequest).not.toHaveBeenCalled(); expect(eventsRequest).not.toHaveBeenCalled();
expect(screen.getByRole("region", { name: "Выплаты" })).toBeInTheDocument(); 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", name: "01.08.2026 – 27.08.2026",
}); });
expect( expect(
screen.getByRole("region", { name: "01.08.2025 – 27.08.2025" }), screen.getByRole("region", { name: "01.08.2025 – 27.08.2025" }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.queryByText(/Предыдущий период/)).not.toBeInTheDocument(); expect(
screen.getByRole("heading", { name: "Предыдущий период" }),
).toBeVisible();
expect(screen.queryByText("Выбранный период")).not.toBeInTheDocument(); expect(screen.queryByText("Выбранный период")).not.toBeInTheDocument();
expect(screen.queryByText("Сравниваемый период")).not.toBeInTheDocument(); expect(screen.queryByText("Сравниваемый период")).not.toBeInTheDocument();
expect( expect(
@@ -409,7 +417,7 @@ describe("AccountIncomePage", () => {
for (const amount of ["10 ₽", "2 ₽", "12 ₽"]) { for (const amount of ["10 ₽", "2 ₽", "12 ₽"]) {
expect(within(selected).getByText(amount)).toBeInTheDocument(); 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 ₽"]) { for (const amount of ["+9 ₽", "+2 ₽", "+11 ₽"]) {
expect(within(difference).getByText(amount)).toBeInTheDocument(); 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 () => { it("shows the unavailable year state without falling back to the previous period", async () => {
server.use( server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => 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(); renderPage();
@@ -433,9 +452,11 @@ describe("AccountIncomePage", () => {
await screen.findByText("Сравнение с годом ранее недоступно."), await screen.findByText("Сравнение с годом ранее недоступно."),
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
screen.queryByRole("region", { name: "Разница" }), screen.queryByRole("region", { name: "Годом ранее" }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
expect(screen.queryByText(/Предыдущий период/)).not.toBeInTheDocument(); expect(
screen.getByRole("region", { name: "Предыдущий период" }),
).toBeVisible();
}); });
it("shows a recoverable realized error", async () => { it("shows a recoverable realized error", async () => {
@@ -537,13 +558,15 @@ function respondWithRequestedPeriod(requests: string[]) {
({ request }) => { ({ request }) => {
const url = new URL(request.url); const url = new URL(request.url);
requests.push(url.search); requests.push(url.search);
return HttpResponse.json({ return HttpResponse.json(
resource({
...analytics, ...analytics,
period: { period: {
from: url.searchParams.get("from"), from: url.searchParams.get("from"),
to: url.searchParams.get("to"), to: url.searchParams.get("to"),
}, },
}); }),
);
}, },
), ),
); );
@@ -669,7 +692,32 @@ describe("income period and snapshot boundary", () => {
await screen.findByText(/Показан последний загруженный результат/), await screen.findByText(/Показан последний загруженный результат/),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible(); 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: "Повторить" })); fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
await waitFor(() => await waitFor(() =>
expect( expect(
@@ -694,10 +742,12 @@ describe("income period and snapshot boundary", () => {
"*/api/v1/brokerage-accounts/account-1/income-analytics", "*/api/v1/brokerage-accounts/account-1/income-analytics",
async () => { async () => {
await pending; await pending;
return HttpResponse.json({ return HttpResponse.json(
resource({
...analytics, ...analytics,
period: { from: "2026-08-02", to: "2026-08-27" }, period: { from: "2026-08-02", to: "2026-08-27" },
}); }),
);
}, },
), ),
); );
@@ -778,3 +828,64 @@ describe("income period and snapshot boundary", () => {
expect(screen.getAllByText("Купон")).toHaveLength(6); 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 { useParams, useSearchParams } from "react-router";
import { z } from "zod"; import { z } from "zod";
import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income"; import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
import { useScreenSync } from "@/features/tbank-connection";
import { validationMessage } from "@/shared/i18n"; import { validationMessage } from "@/shared/i18n";
import { formatDate } from "@/shared/lib"; import { formatDate } from "@/shared/lib";
import { import {
@@ -17,7 +18,16 @@ import {
const ISO_MONTH_LENGTH = 7; const ISO_MONTH_LENGTH = 7;
type IncomeMeta = ReturnType<typeof useIncomeAnalytics>["meta"];
type ComparisonNotice =
| "comparisonPending"
| "comparisonFailed"
| "yearUnavailable";
type IncomeDisplayModel = { type IncomeDisplayModel = {
previousComparison: IncomeDisplayModel["comparison"];
previousNotice: ComparisonNotice;
yearNotice: ComparisonNotice;
entries: { key: string; operation: IncomeAnalytics["entries"][number] }[]; entries: { key: string; operation: IncomeAnalytics["entries"][number] }[];
comparison: { comparison: {
selectedPeriod: string; selectedPeriod: string;
@@ -38,6 +48,9 @@ type AccountIncomePageViewModel =
analytics: IncomeAnalytics; analytics: IncomeAnalytics;
display: IncomeDisplayModel; display: IncomeDisplayModel;
formRevision: number; formRevision: number;
sourceError?: boolean;
lastSuccessfulAt?: string | null;
partial?: boolean;
refreshing: boolean; refreshing: boolean;
refreshError: boolean; refreshError: boolean;
retry: () => void; retry: () => void;
@@ -90,6 +103,10 @@ const useAccountIncomePageViewModel = (
}); });
const [formRevision, setFormRevision] = useState(0); const [formRevision, setFormRevision] = useState(0);
const analytics = useIncomeAnalytics(accountId, period); const analytics = useIncomeAnalytics(accountId, period);
const sync = useScreenSync(
{ screen: "income", accountId, from: period.from, to: period.to },
[analytics],
);
useEffect(() => { useEffect(() => {
if (!validPeriod(requested)) setSearchParams(period, { replace: true }); if (!validPeriod(requested)) setSearchParams(period, { replace: true });
@@ -112,21 +129,30 @@ const useAccountIncomePageViewModel = (
setSearchParams(next, { replace: true }); setSearchParams(next, { replace: true });
}; };
if (analytics.isLoading || !analytics.data) { if (analytics.isLoading || !analytics.data) {
if (analytics.isError) if (analytics.isError || analytics.meta?.sync.state === "failed")
return { state: "error", retry: () => analytics.refetch(), period }; return { state: "error", retry: sync.refresh, period };
return { state: "loading" }; return { state: "loading" };
} }
const display = toIncomeDisplayModel(analytics.data); const display = toIncomeDisplayModel(analytics.data, analytics.meta);
if (!display) if (!display)
return { state: "error", retry: () => analytics.refetch(), period }; return {
state: "error",
retry: () => {
void analytics.observe();
},
period,
};
return { return {
state: "loaded", state: "loaded",
analytics: analytics.data, analytics: analytics.data,
display, display,
formRevision, formRevision,
refreshing: analytics.isFetching, sourceError: sync.sourceError,
refreshError: analytics.isError, lastSuccessfulAt: analytics.meta?.lastSuccessfulAt,
retry: () => analytics.refetch(), partial: analytics.meta?.availability !== "complete",
refreshing: analytics.isFetching || sync.refreshing,
refreshError: analytics.isError || sync.sourceError || sync.transportError,
retry: sync.refresh,
form, form,
onSubmit, onSubmit,
onReset, onReset,
@@ -157,6 +183,7 @@ function validPeriod(period: IncomePeriod): period is IncomePeriod {
function toIncomeDisplayModel( function toIncomeDisplayModel(
analytics: IncomeAnalytics, analytics: IncomeAnalytics,
meta?: IncomeMeta,
): IncomeDisplayModel | null { ): IncomeDisplayModel | null {
const instruments = new Map< const instruments = new Map<
string, string,
@@ -209,15 +236,37 @@ function toIncomeDisplayModel(
if (instrument.includedCount !== instrument.receiptCount) return null; if (instrument.includedCount !== instrument.receiptCount) return null;
} }
// The income endpoint returns selected, previous and year dependencies in order.
const [, previous, year] = meta?.dependencies ?? [];
return { return {
entries, entries,
comparison: analytics.yearComparison previousComparison: comparisonDisplay(analytics.period, analytics.previous),
? { comparison: comparisonDisplay(analytics.period, analytics.yearComparison),
selectedPeriod: formatComparisonPeriod(analytics.period), previousNotice: comparisonNotice(previous),
comparedPeriod: formatComparisonPeriod( yearNotice: comparisonNotice(year),
analytics.yearComparison.period, };
), }
values: analytics.yearComparison.totalsByCurrency.map((value) => ({
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, ...value,
percentages: { percentages: {
total: formatIncomeChange( total: formatIncomeChange(
@@ -234,8 +283,6 @@ function toIncomeDisplayModel(
), ),
}, },
})), })),
}
: null,
}; };
} }
@@ -11,6 +11,7 @@ import { StateView } from "@wealthledger/ui";
import type { FC } from "react"; import type { FC } from "react";
import { FormProvider } from "react-hook-form"; import { FormProvider } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatSnapshotTime } from "@/shared/lib";
import classes from "../AccountIncomePage.module.css"; import classes from "../AccountIncomePage.module.css";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm"; import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
import { IncomeComparisons } from "./IncomeComparisons.view"; import { IncomeComparisons } from "./IncomeComparisons.view";
@@ -34,7 +35,17 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Stack gap="lg" className={classes.page}> <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> <Title order={1}>{t("income.title")}</Title>
<Button onClick={retry} disabled={refreshing} w="fit-content">
{t("investment.refresh")}
</Button>
<FormProvider {...form}> <FormProvider {...form}>
<IncomePeriodForm <IncomePeriodForm
key={props.formRevision} key={props.formRevision}
@@ -49,16 +60,24 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
</Group> </Group>
) : null} ) : null}
{refreshError ? ( {refreshError ? (
<Alert color="orange"> <Alert color="orange" role="status" aria-live="polite">
<Stack gap="sm"> <Stack gap="sm">
<Text>{t("income.refreshError")}</Text> <Text>
{t(
props.sourceError
? "investment.syncFailed"
: "income.refreshError",
)}
</Text>
<Button onClick={retry} variant="default"> <Button onClick={retry} variant="default">
{t("income.retry")} {t("income.retry")}
</Button> </Button>
</Stack> </Stack>
</Alert> </Alert>
) : null} ) : null}
{analytics.totalsByCurrency.length ? ( {props.partial ? (
<Alert role="status">{t("investment.partial")}</Alert>
) : analytics.totalsByCurrency.length ? (
<IncomeSummary totals={analytics.totalsByCurrency} /> <IncomeSummary totals={analytics.totalsByCurrency} />
) : ( ) : (
<StateView <StateView
@@ -67,7 +86,16 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
/> />
)} )}
<Alert color="blue">{t("income.taxBoundary")}</Alert> <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} /> <IncomePayments entries={display.entries} />
</Stack> </Stack>
); );
@@ -5,8 +5,14 @@ import { formatMoney } from "@/shared/lib";
import classes from "../AccountIncomePage.module.css"; import classes from "../AccountIncomePage.module.css";
import type { IncomeDisplayModel } from "../AccountIncomePage.vm"; import type { IncomeDisplayModel } from "../AccountIncomePage.vm";
type IncomeComparisonsProps = { comparison: IncomeDisplayModel["comparison"] }; type IncomeComparisonsProps = {
type ComparisonProps = NonNullable<IncomeDisplayModel["comparison"]>; comparison: IncomeDisplayModel["comparison"];
title: string;
notice: string;
};
type ComparisonProps = NonNullable<IncomeDisplayModel["comparison"]> & {
title: string;
};
type ComparisonValuesProps = { type ComparisonValuesProps = {
kind: "selected" | "compared" | "difference"; kind: "selected" | "compared" | "difference";
label: string; label: string;
@@ -15,16 +21,15 @@ type ComparisonValuesProps = {
}; };
const IncomeComparisons: FC<IncomeComparisonsProps> = (props) => { const IncomeComparisons: FC<IncomeComparisonsProps> = (props) => {
const { t } = useTranslation();
return ( return (
<Stack> <Stack>
<Title order={2}>{t("income.yearAgo")}</Title> <Title order={2}>{props.title}</Title>
{props.comparison ? ( {props.comparison ? (
<Comparison {...props.comparison} /> <Comparison {...props.comparison} title={props.title} />
) : ( ) : (
<Paper p="sm" radius="md" className={classes.comparisonNotice}> <Paper p="sm" radius="md" className={classes.comparisonNotice}>
<Text size="sm" c="gray.7"> <Text size="sm" c="gray.7" role="status" aria-live="polite">
{t("income.yearUnavailable")} {props.notice}
</Text> </Text>
</Paper> </Paper>
)} )}
@@ -35,7 +40,7 @@ const IncomeComparisons: FC<IncomeComparisonsProps> = (props) => {
const Comparison: FC<ComparisonProps> = (props) => { const Comparison: FC<ComparisonProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<section aria-label={t("income.yearAgo")} className={classes.comparison}> <section aria-label={props.title} className={classes.comparison}>
<Stack gap="md"> <Stack gap="md">
{props.values.map((value) => ( {props.values.map((value) => (
<Stack <Stack
@@ -10,6 +10,7 @@ import {
} from "@/entities/broker-operation"; } from "@/entities/broker-operation";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { AccountOperationsPage } from "./AccountOperationsPage"; import { AccountOperationsPage } from "./AccountOperationsPage";
@@ -39,11 +40,13 @@ function configureTestAuth() {
}); });
} }
function renderPage() { function renderPage(search = "") {
return render( return render(
<QueryClientProvider client={createTestQueryClient()}> <QueryClientProvider client={createTestQueryClient()}>
<MantineProvider> <MantineProvider>
<MemoryRouter initialEntries={["/accounts/account-1/operations"]}> <MemoryRouter
initialEntries={[`/accounts/account-1/operations${search}`]}
>
<Routes> <Routes>
<Route <Route
path="/accounts/:accountId/operations" path="/accounts/:accountId/operations"
@@ -193,13 +196,15 @@ describe("AccountOperationsPage", () => {
({ request }) => { ({ request }) => {
const url = new URL(request.url); const url = new URL(request.url);
searches.push(url.search); searches.push(url.search);
return HttpResponse.json({ return HttpResponse.json(
resource({
accountId: "account-1", accountId: "account-1",
items: [operation], items: [operation],
nextCursor: url.searchParams.get("cursor") ? null : "next-1", nextCursor: url.searchParams.get("cursor") ? null : "next-1",
hasNext: !url.searchParams.get("cursor"), hasNext: !url.searchParams.get("cursor"),
observedAt: "2026-08-14T00:00:00Z", observedAt: "2026-08-14T00:00:00Z",
}); }),
);
}, },
), ),
); );
@@ -229,13 +234,15 @@ describe("AccountOperationsPage", () => {
({ request }) => { ({ request }) => {
const url = new URL(request.url); const url = new URL(request.url);
searches.push(url.search); searches.push(url.search);
return HttpResponse.json({ return HttpResponse.json(
resource({
accountId: "account-1", accountId: "account-1",
items: [operation], items: [operation],
nextCursor: null, nextCursor: null,
hasNext: false, hasNext: false,
observedAt: "2026-08-14T00:00:00Z", observedAt: "2026-08-14T00:00:00Z",
}); }),
);
}, },
), ),
); );
@@ -262,13 +269,15 @@ describe("AccountOperationsPage", () => {
({ request }) => { ({ request }) => {
const url = new URL(request.url); const url = new URL(request.url);
searches.push(url.search); searches.push(url.search);
return HttpResponse.json({ return HttpResponse.json(
resource({
accountId: "account-1", accountId: "account-1",
items: [operation], items: [operation],
nextCursor: null, nextCursor: null,
hasNext: false, hasNext: false,
observedAt: "2026-08-14T00:00:00Z", observedAt: "2026-08-14T00:00:00Z",
}); }),
);
}, },
), ),
); );
@@ -301,3 +310,58 @@ describe("AccountOperationsPage", () => {
).toBeInTheDocument(); ).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, useBrokerageOperations,
} from "@/entities/broker-operation"; } from "@/entities/broker-operation";
import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api"; import { apiErrorCode } from "@/shared/api";
type AccountOperationsPageViewModel = type AccountOperationsPageViewModel =
@@ -16,6 +17,10 @@ type AccountOperationsPageViewModel =
| { state: "error"; retry: () => void } | { state: "error"; retry: () => void }
| { | {
state: "loaded"; state: "loaded";
sourceError?: boolean;
lastSuccessfulAt?: string | null;
partial?: boolean;
historyLoading?: boolean;
refreshing?: boolean; refreshing?: boolean;
refreshError?: boolean; refreshError?: boolean;
retry?: () => void; retry?: () => void;
@@ -82,6 +87,10 @@ const useAccountOperationsPageVm = (
...firstPageQuery, ...firstPageQuery,
...(cursor ? { cursor } : {}), ...(cursor ? { cursor } : {}),
}); });
const sync = useScreenSync(
{ screen: "operations", accountId, ...firstPageQuery },
[operations],
);
const resetQueryKey = brokerageOperationsQueryOptions(accountId, { const resetQueryKey = brokerageOperationsQueryOptions(accountId, {
limit: 100, limit: 100,
}).queryKey; }).queryKey;
@@ -95,12 +104,12 @@ const useAccountOperationsPageVm = (
exact: true, exact: true,
}); });
resetCursors(); resetCursors();
} else void operations.refetch(); } else void sync.refresh();
}; };
if (operations.isLoading) return { state: "loading" }; if (operations.isLoading) return { state: "loading" };
if ( if (
operations.isError && (operations.isError || operations.meta?.sync.state === "failed") &&
(!operations.data || (!operations.data ||
apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR") apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR")
) )
@@ -115,8 +124,14 @@ const useAccountOperationsPageVm = (
return { return {
state: "loaded", state: "loaded",
refreshing: operations.isFetching, sourceError: sync.sourceError,
refreshError: operations.isError, 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, retry,
draftFrom, draftFrom,
draftTo, draftTo,
@@ -5,6 +5,7 @@ import {
MultiSelect, MultiSelect,
Progress, Progress,
Stack, Stack,
Text,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import { DatePickerInput } from "@mantine/dates"; import { DatePickerInput } from "@mantine/dates";
@@ -16,6 +17,7 @@ import {
type OperationCategory, type OperationCategory,
operationCategories, operationCategories,
} from "@/entities/broker-operation"; } from "@/entities/broker-operation";
import { formatSnapshotTime } from "@/shared/lib";
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm"; import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
type AccountOperationsPageLoadedProps = Extract< type AccountOperationsPageLoadedProps = Extract<
@@ -59,13 +61,31 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
return ( return (
<Stack gap="lg"> <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> <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 ? ( {props.refreshing ? (
<Progress value={100} animated aria-label={t("accounts.refreshing")} /> <Progress value={100} animated aria-label={t("accounts.refreshing")} />
) : null} ) : null}
{props.refreshError ? ( {props.refreshError ? (
<Alert color="yellow"> <Alert color="yellow" role="status" aria-live="polite">
{t("accounts.refreshError")} {t(
props.sourceError
? "investment.syncFailed"
: "accounts.refreshError",
)}
<Button variant="subtle" onClick={props.retry}> <Button variant="subtle" onClick={props.retry}>
{t("accounts.retry")} {t("accounts.retry")}
</Button> </Button>
@@ -120,12 +140,12 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
operation={operation} operation={operation}
/> />
)) ))
) : ( ) : !props.partial ? (
<StateView <StateView
title={t("ledger.emptyTitle")} title={t("ledger.emptyTitle")}
text={t("ledger.emptyText")} text={t("ledger.emptyText")}
/> />
)} ) : null}
{hasNext || hasPrevious ? ( {hasNext || hasPrevious ? (
<Group justify="space-between"> <Group justify="space-between">
<Button disabled={!hasPrevious} onClick={onPrevious}> <Button disabled={!hasPrevious} onClick={onPrevious}>
@@ -14,6 +14,7 @@ import type { BrokerageEvent } from "@/entities/brokerage-event";
import { type Portfolio, portfolioQueryOptions } from "@/entities/portfolio"; import { type Portfolio, portfolioQueryOptions } from "@/entities/portfolio";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { AccountPage } from "./AccountPage"; import { AccountPage } from "./AccountPage";
import type { import type {
@@ -144,7 +145,8 @@ describe("AccountPage states", () => {
}; };
server.use( server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json({ HttpResponse.json(
resource({
...portfolio, ...portfolio,
positions: positions:
accountId === "all" accountId === "all"
@@ -155,16 +157,19 @@ describe("AccountPage states", () => {
: [position], : [position],
}), }),
), ),
),
http.get(`*/api/v1/${collection}/:uid`, () => { http.get(`*/api/v1/${collection}/:uid`, () => {
requests(); requests();
return HttpResponse.json({ return HttpResponse.json(
resource({
instrumentUid: uid, instrumentUid: uid,
slug, slug,
name: "Название из карточки инструмента", name: "Название из карточки инструмента",
ticker: "OTHER-TICKER", ticker: "OTHER-TICKER",
isin: "OTHER-ISIN", isin: "OTHER-ISIN",
logoName: "other.png", logoName: "other.png",
}); }),
);
}), }),
); );
configureTestAuth(); configureTestAuth();
@@ -204,7 +209,8 @@ describe("AccountPage states", () => {
const requests = vi.fn(); const requests = vi.fn();
server.use( server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json({ HttpResponse.json(
resource({
...portfolio, ...portfolio,
positions: [ positions: [
{ {
@@ -215,11 +221,12 @@ describe("AccountPage states", () => {
], ],
}), }),
), ),
),
http.get("*/api/v1/stocks/:uid", () => { http.get("*/api/v1/stocks/:uid", () => {
requests(); requests();
return scenario === "unavailable" return scenario === "unavailable"
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json({ instrumentUid: uid, slug: uid }); : HttpResponse.json(resource({ instrumentUid: uid, slug: uid }));
}), }),
); );
renderAccountRoute(createTestQueryClient()); renderAccountRoute(createTestQueryClient());
@@ -234,7 +241,7 @@ describe("AccountPage states", () => {
it("keeps the account and positions visible for an older HTTP response without structure", async () => { it("keeps the account and positions visible for an older HTTP response without structure", async () => {
server.use( server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json({ ...portfolio, structure: undefined }), HttpResponse.json(resource({ ...portfolio, structure: undefined })),
), ),
); );
renderAccountRoute(createTestQueryClient()); renderAccountRoute(createTestQueryClient());
@@ -322,12 +329,14 @@ describe("AccountPage states", () => {
async (hasPositions) => { async (hasPositions) => {
server.use( server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json({ HttpResponse.json(
resource({
...portfolio, ...portfolio,
totalsByCurrency: [], totalsByCurrency: [],
positions: hasPositions ? portfolio.positions : [], positions: hasPositions ? portfolio.positions : [],
}), }),
), ),
),
); );
renderAccountRoute(createTestQueryClient()); renderAccountRoute(createTestQueryClient());
await screen.findByRole("heading", { name: "Портфель", level: 1 }); await screen.findByRole("heading", { name: "Портфель", level: 1 });
@@ -358,7 +367,7 @@ describe("AccountPage states", () => {
}; };
server.use( server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(snapshot), HttpResponse.json(resource(snapshot)),
), ),
); );
const queryClient = createTestQueryClient(); const queryClient = createTestQueryClient();
@@ -380,7 +389,7 @@ describe("AccountPage states", () => {
).toEqual(totalsByCurrency.length ? ["100 ₽"] : []); ).toEqual(totalsByCurrency.length ? ["100 ₽"] : []);
expect( expect(
queryClient.getQueryData(portfolioQueryOptions("account-1").queryKey), queryClient.getQueryData(portfolioQueryOptions("account-1").queryKey),
).toEqual(snapshot); ).toEqual(resource(snapshot));
}, },
); );
@@ -887,7 +896,7 @@ describe("AccountPage states", () => {
expect(screen.getByText("Вторая")).toBeInTheDocument(); expect(screen.getByText("Вторая")).toBeInTheDocument();
}); });
it.each([503, 404])( it.each([503])(
"preserves totals and UTC snapshot through refresh error %i, retry and success", "preserves totals and UTC snapshot through refresh error %i, retry and success",
async (status) => { async (status) => {
const initialPortfolio: Portfolio = { const initialPortfolio: Portfolio = {
@@ -907,9 +916,36 @@ describe("AccountPage states", () => {
let requestCount = 0; let requestCount = 0;
let resolveRetry: ((response: Response) => void) | undefined; let resolveRetry: ((response: Response) => void) | undefined;
server.use( 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", () => { http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
requestCount += 1; requestCount += 1;
if (requestCount === 1) return HttpResponse.json(initialPortfolio); if (requestCount === 1)
return HttpResponse.json(
resource(initialPortfolio, { resourceKey: "portfolio" }),
);
if (requestCount === 3) if (requestCount === 3)
return new Promise<Response>((resolve) => { return new Promise<Response>((resolve) => {
resolveRetry = resolve; resolveRetry = resolve;
@@ -961,14 +997,19 @@ describe("AccountPage states", () => {
snapshotText, snapshotText,
); );
resolveRetry?.( resolveRetry?.(
HttpResponse.json({ HttpResponse.json(
resource(
{
...portfolio, ...portfolio,
isStale: false, isStale: false,
lastSuccessfulAt: "2026-08-14T11:30:00Z", lastSuccessfulAt: "2026-08-14T11:30:00Z",
totalsByCurrency: [{ currency: "USD", value: "-0.5" }], totalsByCurrency: [{ currency: "USD", value: "-0.5" }],
structure: { currencies: [], unvaluedCategories: [] }, structure: { currencies: [], unvaluedCategories: [] },
cashBalances: [{ currency: "EUR", value: "2.50" }], cashBalances: [{ currency: "EUR", value: "2.50" }],
}), },
{ resourceKey: "portfolio", stateVersion: "2" },
),
),
); );
await waitFor(() => await waitFor(() =>
expect( expect(
@@ -993,7 +1034,7 @@ describe("AccountPage states", () => {
server.use( server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => { http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
requestCount += 1; requestCount += 1;
if (requestCount === 1) return HttpResponse.json(portfolio); if (requestCount === 1) return HttpResponse.json(resource(portfolio));
return new Promise<Response>((resolve) => { return new Promise<Response>((resolve) => {
resolveRefresh = resolve; resolveRefresh = resolve;
}); });
@@ -1020,6 +1061,6 @@ describe("AccountPage states", () => {
screen.getByRole("status", { name: "Обновляем портфель" }), screen.getByRole("status", { name: "Обновляем портфель" }),
).toBeInTheDocument(); ).toBeInTheDocument();
resolveRefresh?.(HttpResponse.json(portfolio)); resolveRefresh?.(HttpResponse.json(resource(portfolio)));
}); });
}); });
@@ -17,6 +17,7 @@ const AccountPage: FC<AccountPageProps> = (props) => {
account={viewModel.account} account={viewModel.account}
accountId={viewModel.accountId} accountId={viewModel.accountId}
refreshing={viewModel.refreshing} refreshing={viewModel.refreshing}
sourceError={viewModel.sourceError}
refreshError={viewModel.refreshError} refreshError={viewModel.refreshError}
retry={viewModel.retry} retry={viewModel.retry}
recentOperations={viewModel.recentOperations} recentOperations={viewModel.recentOperations}
@@ -28,9 +29,11 @@ const AccountPage: FC<AccountPageProps> = (props) => {
<AccountPageLoaded <AccountPageLoaded
account={viewModel.account} account={viewModel.account}
accountId={viewModel.accountId} accountId={viewModel.accountId}
partial={viewModel.partial}
portfolio={viewModel.portfolio} portfolio={viewModel.portfolio}
positionSections={viewModel.positionSections} positionSections={viewModel.positionSections}
refreshing={viewModel.refreshing} refreshing={viewModel.refreshing}
sourceError={viewModel.sourceError}
refreshError={viewModel.refreshError} refreshError={viewModel.refreshError}
retry={viewModel.retry} retry={viewModel.retry}
recentOperations={viewModel.recentOperations} recentOperations={viewModel.recentOperations}
@@ -1,5 +1,5 @@
import { MantineProvider } from "@mantine/core"; 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 { HttpResponse, http } from "msw";
import type { FC, PropsWithChildren } from "react"; import type { FC, PropsWithChildren } from "react";
import { MemoryRouter, Route, Routes } from "react-router"; 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 type { Portfolio } from "@/entities/portfolio";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query"; import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { import {
toAccountPagePositionSections, toAccountPagePositionSections,
@@ -86,10 +87,11 @@ function createWrapper() {
function mockLoadedAccountPage() { function mockLoadedAccountPage() {
server.use( server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(portfolio), HttpResponse.json(resource(portfolio)),
), ),
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
HttpResponse.json({ HttpResponse.json(
resource({
accountId: "account-1", accountId: "account-1",
items: [ items: [
{ {
@@ -114,8 +116,10 @@ function mockLoadedAccountPage() {
observedAt: "2026-08-13T10:00:00Z", observedAt: "2026-08-13T10:00:00Z",
}), }),
), ),
),
http.get("*/api/v1/brokerage-accounts/:accountId/events", () => http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
HttpResponse.json({ HttpResponse.json(
resource({
accountId: "account-1", accountId: "account-1",
calculatedAt: "2026-08-13T10:00:00Z", calculatedAt: "2026-08-13T10:00:00Z",
events: Array.from({ length: 6 }, (_, index) => ({ events: Array.from({ length: 6 }, (_, index) => ({
@@ -130,6 +134,7 @@ function mockLoadedAccountPage() {
})), })),
}), }),
), ),
),
); );
} }
@@ -281,7 +286,7 @@ describe("useAccountPageViewModel", () => {
configureTestAuth(); configureTestAuth();
server.use( server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(portfolio), HttpResponse.json(resource(portfolio)),
), ),
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }), 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 { useParams } from "react-router";
import type { BrokerageOperation } from "@/entities/broker-operation"; import type { BrokerageOperation } from "@/entities/broker-operation";
import { useBrokerageOperations } from "@/entities/broker-operation"; import { useBrokerageOperations } from "@/entities/broker-operation";
import { import {
type BrokerageAccount, type BrokerageAccount,
brokerageAccountsQueryKey, useBrokerageAccounts,
} from "@/entities/brokerage-account"; } from "@/entities/brokerage-account";
import { import {
type BrokerageEventsPreviewState, type BrokerageEventsPreviewState,
@@ -14,6 +13,7 @@ import { type IncomeSummary, useIncomeSummary } from "@/entities/income";
import type { Portfolio } from "@/entities/portfolio"; import type { Portfolio } from "@/entities/portfolio";
import { formatShare, usePortfolio } from "@/entities/portfolio"; import { formatShare, usePortfolio } from "@/entities/portfolio";
import { useStocks } from "@/entities/stock"; import { useStocks } from "@/entities/stock";
import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api"; import { apiErrorCode } from "@/shared/api";
import { formatMoney, instrumentLogoUrl } from "@/shared/lib"; import { formatMoney, instrumentLogoUrl } from "@/shared/lib";
@@ -46,19 +46,31 @@ type AccountPageRecentOperations =
to: string; to: string;
} }
| { | {
state: "loaded"; state: "loaded" | "partial";
to: string; to: string;
items: BrokerageOperation[]; items: BrokerageOperation[];
lastSuccessfulAt?: string | null;
stale?: boolean;
refreshError?: boolean;
}; };
type AccountPageRecentEvents = BrokerageEventsPreviewState; type AccountPageRecentEvents = BrokerageEventsPreviewState;
type AccountPageIncomeSummary = type AccountPageIncomeSummary =
| { state: "loading" | "empty"; to: string } | { state: "loading" | "partial" | "empty"; to: string }
| { state: "error"; to: string; retry: () => void } | { 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 = { type AccountPageContent = {
sourceError?: boolean;
partial?: boolean;
account?: BrokerageAccount; account?: BrokerageAccount;
accountId?: string; accountId?: string;
refreshing: boolean; refreshing: boolean;
@@ -102,27 +114,39 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
}); });
const events = useBrokerageEvents(accountId); const events = useBrokerageEvents(accountId);
const income = useIncomeSummary(accountId); const income = useIncomeSummary(accountId);
const account = useQueryClient() const accounts = useBrokerageAccounts();
.getQueryData<BrokerageAccount[]>(brokerageAccountsQueryKey) const sync = useScreenSync({ screen: "account", accountId }, [
?.find((item) => item.id === accountId); accounts,
portfolio,
operations,
events,
income,
]);
const account = accounts.data?.find((item) => item.id === accountId);
const recentOperations = toRecentOperations(accountId, operations); const recentOperations = toRecentOperations(accountId, operations);
const recentEvents = toRecentEvents(accountId, events); const recentEvents = toRecentEvents(accountId, events);
const incomeSummary = toIncomeSummary(accountId, income); const incomeSummary = toIncomeSummary(accountId, income);
if (!portfolio.data) { if (!portfolio.data) {
if (portfolio.isError) if (portfolio.isError || portfolio.meta?.sync.state === "failed")
return { return {
state: "error" as const, state: "error" as const,
code: apiErrorCode(portfolio.error), code: apiErrorCode(portfolio.error),
retry: () => portfolio.refetch(), retry: sync.refresh,
}; };
return { state: "loading" as const }; return { state: "loading" as const };
} }
const refresh = { const refresh = {
refreshing: portfolio.isFetching, partial: portfolio.meta?.availability === "partial",
refreshError: portfolio.isError, refreshing: portfolio.isFetching || sync.refreshing,
retry: () => portfolio.refetch(), 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 { return {
state: "empty" as const, state: "empty" as const,
account, account,
@@ -153,11 +177,20 @@ function toIncomeSummary(
income: ReturnType<typeof useIncomeSummary>, income: ReturnType<typeof useIncomeSummary>,
): AccountPageIncomeSummary { ): AccountPageIncomeSummary {
const to = `/accounts/${accountId}/income`; const to = `/accounts/${accountId}/income`;
if (income.isLoading) return { state: "loading", to }; if (!income.data && !income.isError && income.meta?.sync.state !== "failed")
if (income.isError) 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() }; return { state: "error", to, retry: () => income.refetch() };
if (!income.data?.totalsByCurrency.length) return { state: "empty", to }; 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( function toRecentOperations(
@@ -165,10 +198,29 @@ function toRecentOperations(
operations: ReturnType<typeof useBrokerageOperations>, operations: ReturnType<typeof useBrokerageOperations>,
): AccountPageRecentOperations { ): AccountPageRecentOperations {
const to = `/accounts/${accountId}/operations`; const to = `/accounts/${accountId}/operations`;
if (operations.isLoading) return { state: "loading", to }; if (
if (operations.isError) return { state: "error", to }; !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 }; 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( function toRecentEvents(
@@ -176,11 +228,26 @@ function toRecentEvents(
events: ReturnType<typeof useBrokerageEvents>, events: ReturnType<typeof useBrokerageEvents>,
): AccountPageRecentEvents { ): AccountPageRecentEvents {
const to = `/accounts/${accountId}/events`; const to = `/accounts/${accountId}/events`;
if (events.isLoading) return { state: "loading", to }; if (!events.data && !events.isError && events.meta?.sync.state !== "failed")
if (events.isError) return { state: "error", to }; 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) ?? []; const items = events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [];
if (!items.length) return { state: "empty", to }; 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( function toAccountPagePositionSections(
@@ -19,6 +19,7 @@ type AccountPageEmptyProps = {
account?: BrokerageAccount; account?: BrokerageAccount;
accountId?: string; accountId?: string;
refreshing?: boolean; refreshing?: boolean;
sourceError?: boolean;
refreshError?: boolean; refreshError?: boolean;
retry?: () => void; retry?: () => void;
recentOperations: AccountPageRecentOperations; recentOperations: AccountPageRecentOperations;
@@ -33,6 +34,7 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
<AccountHeading account={props.account} accountId={props.accountId} /> <AccountHeading account={props.account} accountId={props.accountId} />
<PortfolioRefreshState <PortfolioRefreshState
refreshing={props.refreshing ?? false} refreshing={props.refreshing ?? false}
sourceError={props.sourceError}
refreshError={props.refreshError ?? false} refreshError={props.refreshError ?? false}
retry={props.retry} retry={props.retry}
/> />
@@ -23,9 +23,11 @@ import { RecentOperations } from "./RecentOperations.view";
type AccountPageLoadedProps = { type AccountPageLoadedProps = {
account?: BrokerageAccount; account?: BrokerageAccount;
accountId?: string; accountId?: string;
partial?: boolean;
portfolio: Portfolio; portfolio: Portfolio;
positionSections: AccountPagePositionSection[]; positionSections: AccountPagePositionSection[];
refreshing?: boolean; refreshing?: boolean;
sourceError?: boolean;
refreshError?: boolean; refreshError?: boolean;
retry?: () => void; retry?: () => void;
recentOperations: AccountPageRecentOperations; recentOperations: AccountPageRecentOperations;
@@ -40,6 +42,7 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
<AccountHeading account={props.account} accountId={props.accountId} /> <AccountHeading account={props.account} accountId={props.accountId} />
<PortfolioRefreshState <PortfolioRefreshState
refreshing={props.refreshing ?? false} refreshing={props.refreshing ?? false}
sourceError={props.sourceError}
refreshError={props.refreshError ?? false} refreshError={props.refreshError ?? false}
retry={props.retry} retry={props.retry}
/> />
@@ -56,11 +59,17 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
}, },
)} )}
</Text> </Text>
{props.partial ? (
<Alert role="status">{t("investment.partial")}</Alert>
) : (
<CurrencyCards amounts={props.portfolio.totalsByCurrency} /> <CurrencyCards amounts={props.portfolio.totalsByCurrency} />
)}
{!props.partial ? (
<PortfolioStructure <PortfolioStructure
key={props.accountId} key={props.accountId}
structure={props.portfolio.structure} structure={props.portfolio.structure}
/> />
) : null}
<Accordion <Accordion
multiple multiple
defaultValue={[ defaultValue={[
@@ -4,18 +4,34 @@ import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import type { IncomeSummary as IncomeSummaryData } from "@/entities/income"; import type { IncomeSummary as IncomeSummaryData } from "@/entities/income";
import { formatMoney } from "@/shared/lib"; import { formatMoney, formatSnapshotTime } from "@/shared/lib";
type IncomeSummaryProps = type IncomeSummaryProps =
| { state: "loading"; to: string } | { state: "loading"; to: string }
| { state: "partial"; to: string }
| { state: "error"; to: string; retry: () => void } | { state: "error"; to: string; retry: () => void }
| { state: "empty"; to: string } | { 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 IncomeSummary: FC<IncomeSummaryProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
if (props.state === "loading") if (props.state === "loading" || props.state === "partial")
return <Text c="dimmed">{t("income.summaryLoading")}</Text>; return (
<Text role="status" c="dimmed">
{t(
props.state === "partial"
? "investment.partial"
: "income.summaryLoading",
)}
</Text>
);
if (props.state === "error") if (props.state === "error")
return ( return (
<StateView <StateView
@@ -38,6 +54,17 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
); );
return ( return (
<Stack gap="xs"> <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) => ( {props.summary.totalsByCurrency.map((total) => (
<Paper key={total.currency} p="sm" withBorder> <Paper key={total.currency} p="sm" withBorder>
<Text fw={700}>{formatMoney(total.total)}</Text> <Text fw={700}>{formatMoney(total.total)}</Text>
@@ -47,3 +47,8 @@ export const RefreshError: Story = {
name: "Ошибка обновления с retry", name: "Ошибка обновления с retry",
args: { refreshError: true }, 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"; import { useTranslation } from "react-i18next";
type PortfolioRefreshStateProps = { type PortfolioRefreshStateProps = {
sourceError?: boolean;
refreshing: boolean; refreshing: boolean;
refreshError: boolean; refreshError: boolean;
retry?: () => void; retry?: () => void;
@@ -11,10 +12,17 @@ type PortfolioRefreshStateProps = {
const PortfolioRefreshState: FC<PortfolioRefreshStateProps> = (props) => { const PortfolioRefreshState: FC<PortfolioRefreshStateProps> = (props) => {
const { refreshing, refreshError, retry } = props; const { refreshing, refreshError, retry } = props;
const { t } = useTranslation(); const { t } = useTranslation();
if (!refreshing && !refreshError) return null;
return ( return (
<> <>
<Button
onClick={retry}
disabled={refreshing}
variant="light"
w="fit-content"
>
{t(refreshError ? "accounts.retry" : "investment.refresh")}
</Button>
{refreshing ? ( {refreshing ? (
<Group gap="xs" role="status" aria-label={t("portfolio.refreshing")}> <Group gap="xs" role="status" aria-label={t("portfolio.refreshing")}>
<Loader size="sm" /> <Loader size="sm" />
@@ -24,12 +32,15 @@ const PortfolioRefreshState: FC<PortfolioRefreshStateProps> = (props) => {
</Group> </Group>
) : null} ) : null}
{refreshError ? ( {refreshError ? (
<Alert color="orange"> <Alert color="orange" role="status" aria-live="polite">
<Stack gap="sm"> <Stack gap="sm">
<Text>{t("portfolio.refreshError")}</Text> <Text>
<Button onClick={retry} variant="light"> {t(
{t("accounts.retry")} props.sourceError
</Button> ? "investment.syncFailed"
: "portfolio.refreshError",
)}
</Text>
</Stack> </Stack>
</Alert> </Alert>
) : null} ) : null}
@@ -4,6 +4,7 @@ import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import { LedgerEntryRow } from "@/entities/broker-operation"; import { LedgerEntryRow } from "@/entities/broker-operation";
import { formatSnapshotTime } from "@/shared/lib";
import type { AccountPageRecentOperations } from "../AccountPage.vm"; import type { AccountPageRecentOperations } from "../AccountPage.vm";
type RecentOperationsProps = { type RecentOperationsProps = {
@@ -16,6 +17,23 @@ const RecentOperations: FC<RecentOperationsProps> = (props) => {
return ( return (
<Stack gap="sm"> <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" ? ( {operations.state === "loading" ? (
<LoadingSkeleton label={t("ledger.recentLoading")}> <LoadingSkeleton label={t("ledger.recentLoading")}>
{[0, 1, 2].map((index) => ( {[0, 1, 2].map((index) => (
@@ -29,7 +47,10 @@ const RecentOperations: FC<RecentOperationsProps> = (props) => {
{operations.state === "empty" ? ( {operations.state === "empty" ? (
<Text c="dimmed">{t("ledger.recentEmpty")}</Text> <Text c="dimmed">{t("ledger.recentEmpty")}</Text>
) : null} ) : 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) => ( ? operations.items.map((operation) => (
<LedgerEntryRow <LedgerEntryRow
key={JSON.stringify([ key={JSON.stringify([
@@ -7,6 +7,7 @@ import { describe, expect, it, vi } from "vitest";
import { brokerageAccountsQueryKey } from "@/entities/brokerage-account"; import { brokerageAccountsQueryKey } from "@/entities/brokerage-account";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { AccountsPage } from "./AccountsPage"; import { AccountsPage } from "./AccountsPage";
import { AccountsPageEmpty } from "./components/AccountsPageEmpty.view"; import { AccountsPageEmpty } from "./components/AccountsPageEmpty.view";
@@ -80,7 +81,7 @@ describe("AccountsPage", () => {
server.use( server.use(
http.get("*/api/v1/brokerage-accounts", () => { http.get("*/api/v1/brokerage-accounts", () => {
requestCount += 1; requestCount += 1;
if (requestCount === 1) return HttpResponse.json([account]); if (requestCount === 1) return HttpResponse.json(resource([account]));
return new Promise<Response>((resolve) => { return new Promise<Response>((resolve) => {
resolveRefresh = resolve; resolveRefresh = resolve;
}); });
@@ -100,21 +101,31 @@ describe("AccountsPage", () => {
screen.getByRole("status", { name: "Обновляем данные" }), screen.getByRole("status", { name: "Обновляем данные" }),
).toBeInTheDocument(); ).toBeInTheDocument();
resolveRefresh?.(HttpResponse.json([account])); resolveRefresh?.(HttpResponse.json(resource([account])));
}); });
it("keeps the cached account list and offers retry after a refresh error", async () => { it("keeps the cached account list 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; let requestCount = 0;
server.use( server.use(
http.get("*/api/v1/brokerage-accounts", () => { http.get("*/api/v1/brokerage-accounts", () => {
requestCount += 1; requestCount += 1;
if (requestCount === 1) return HttpResponse.json([account]); if (requestCount === 1) return HttpResponse.json(resource([account]));
if (requestCount === 2) if (requestCount === 2)
return HttpResponse.json( return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" }, { code: "TBANK_UNAVAILABLE" },
{ status: 503 }, { status: 503 },
); );
return HttpResponse.json([account]); return HttpResponse.json(resource([account]));
}), }),
); );
const queryClient = createTestQueryClient(); const queryClient = createTestQueryClient();
@@ -132,7 +143,8 @@ describe("AccountsPage", () => {
expect(screen.getByText(account.name)).toBeInTheDocument(); expect(screen.getByText(account.name)).toBeInTheDocument();
screen.getByRole("button", { name: "Повторить" }).click(); 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 () => { it("keeps a cached empty account list visible after a refresh error", async () => {
@@ -140,7 +152,7 @@ describe("AccountsPage", () => {
server.use( server.use(
http.get("*/api/v1/brokerage-accounts", () => { http.get("*/api/v1/brokerage-accounts", () => {
requestCount += 1; requestCount += 1;
if (requestCount === 1) return HttpResponse.json([]); if (requestCount === 1) return HttpResponse.json(resource([]));
return HttpResponse.json( return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" }, { code: "TBANK_UNAVAILABLE" },
{ status: 503 }, { status: 503 },
@@ -163,3 +175,22 @@ describe("AccountsPage", () => {
expect(screen.getByText("Нет инвестиционных счетов")).toBeInTheDocument(); 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") if (viewModel.state === "empty")
return ( return (
<AccountsPageEmpty <AccountsPageEmpty
lastSuccessfulAt={viewModel.lastSuccessfulAt}
refreshing={viewModel.refreshing} refreshing={viewModel.refreshing}
sourceError={viewModel.sourceError}
refreshError={viewModel.refreshError} refreshError={viewModel.refreshError}
retry={viewModel.retry} retry={viewModel.retry}
/> />
@@ -20,7 +22,9 @@ const AccountsPage: FC = () => {
return ( return (
<AccountsPageLoaded <AccountsPageLoaded
accounts={viewModel.accounts} accounts={viewModel.accounts}
lastSuccessfulAt={viewModel.lastSuccessfulAt}
refreshing={viewModel.refreshing} refreshing={viewModel.refreshing}
sourceError={viewModel.sourceError}
refreshError={viewModel.refreshError} refreshError={viewModel.refreshError}
retry={viewModel.retry} retry={viewModel.retry}
/> />
@@ -2,32 +2,42 @@ import {
brokerageAccountCards, brokerageAccountCards,
useBrokerageAccounts, useBrokerageAccounts,
} from "@/entities/brokerage-account"; } from "@/entities/brokerage-account";
import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api"; import { apiErrorCode } from "@/shared/api";
function useAccountsPageViewModel() { function useAccountsPageViewModel() {
const accounts = useBrokerageAccounts(); const accounts = useBrokerageAccounts();
if (!accounts.data) { const sync = useScreenSync({ screen: "accounts" }, [accounts]);
if (accounts.isFetching) return { state: "loading" as const }; if (
if (accounts.isError) !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 { return {
state: "error" as const, state: "error" as const,
code: apiErrorCode(accounts.error), code: apiErrorCode(accounts.error),
retry: () => accounts.refetch(), retry: sync.refresh,
}; };
} }
if (!accounts.data?.length) if (!accounts.data?.length)
return { return {
state: "empty" as const, state: "empty" as const,
refreshing: accounts.isFetching, lastSuccessfulAt: accounts.meta?.lastSuccessfulAt,
refreshError: accounts.isError, refreshing: accounts.isFetching || sync.refreshing,
retry: () => accounts.refetch(), sourceError: sync.sourceError,
refreshError: accounts.isError || sync.transportError || sync.sourceError,
retry: sync.refresh,
}; };
return { return {
state: "loaded" as const, state: "loaded" as const,
accounts: brokerageAccountCards(accounts.data), accounts: brokerageAccountCards(accounts.data, accounts.meta?.dependencies),
refreshing: accounts.isFetching, lastSuccessfulAt: accounts.meta?.lastSuccessfulAt,
refreshError: accounts.isError, refreshing: accounts.isFetching || sync.refreshing,
retry: () => accounts.refetch(), 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")}> <LoadingSkeleton label={t("states.loadingTitle")}>
<Skeleton height={32} width="30%" /> <Skeleton height={32} width="30%" />
<Skeleton height={20} width="45%" /> <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) => ( {[0, 1].map((index) => (
<Card key={index} padding="xl" radius="lg" withBorder> <Card key={index} padding="xl" radius="lg" withBorder>
<Stack gap="xl"> <Stack gap="xl">
@@ -1,8 +1,11 @@
import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core"; import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core";
import type { FC } from "react"; import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatSnapshotTime } from "@/shared/lib";
type AccountsRefreshStateProps = { type AccountsRefreshStateProps = {
lastSuccessfulAt?: string | null;
sourceError?: boolean;
refreshing?: boolean; refreshing?: boolean;
refreshError?: boolean; refreshError?: boolean;
retry?: () => void; retry?: () => void;
@@ -11,9 +14,24 @@ type AccountsRefreshStateProps = {
const AccountsRefreshState: FC<AccountsRefreshStateProps> = (props) => { const AccountsRefreshState: FC<AccountsRefreshStateProps> = (props) => {
const { refreshing = false, refreshError = false, retry } = props; const { refreshing = false, refreshError = false, retry } = props;
const { t } = useTranslation(); const { t } = useTranslation();
if (!refreshing && !refreshError) return null;
return ( 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 ? ( {refreshing ? (
<Group gap="xs" role="status" aria-label={t("accounts.refreshing")}> <Group gap="xs" role="status" aria-label={t("accounts.refreshing")}>
<Loader size="sm" /> <Loader size="sm" />
@@ -23,12 +41,15 @@ const AccountsRefreshState: FC<AccountsRefreshStateProps> = (props) => {
</Group> </Group>
) : null} ) : null}
{refreshError ? ( {refreshError ? (
<Alert color="orange"> <Alert color="orange" role="status" aria-live="polite">
<Stack gap="sm"> <Stack gap="sm">
<Text>{t("accounts.refreshError")}</Text> <Text>
<Button onClick={retry} variant="light"> {t(
{t("accounts.retry")} props.sourceError
</Button> ? "investment.syncFailed"
: "accounts.refreshError",
)}
</Text>
</Stack> </Stack>
</Alert> </Alert>
) : null} ) : null}
+66 -23
View File
@@ -6,6 +6,7 @@ import { MemoryRouter, Route, Routes, useLocation } from "react-router";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { BondPage } from "./BondPage"; import { BondPage } from "./BondPage";
@@ -92,25 +93,27 @@ function setup(
http.get("*/api/v1/bonds/:uid", () => http.get("*/api/v1/bonds/:uid", () =>
options.failBond options.failBond
? HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 }) ? HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 })
: HttpResponse.json(resolved), : HttpResponse.json(resource(resolved)),
), ),
http.get("*/api/v1/bonds/by-ticker/:ticker", ({ request }) => { http.get("*/api/v1/bonds/by-ticker/:ticker", ({ request }) => {
requests.push(new URL(request.url)); requests.push(new URL(request.url));
return options.ambiguousTicker return options.ambiguousTicker
? HttpResponse.json({ code: "BOND_TICKER_AMBIGUOUS" }, { status: 409 }) ? HttpResponse.json({ code: "BOND_TICKER_AMBIGUOUS" }, { status: 409 })
: HttpResponse.json(resolved); : HttpResponse.json(resource(resolved));
}), }),
http.get("*/api/v1/bonds/:uid/:section", ({ params, request }) => { http.get("*/api/v1/bonds/:uid/:section", ({ params, request }) => {
requests.push(new URL(request.url)); requests.push(new URL(request.url));
if (params.section === "metrics") if (params.section === "metrics")
return HttpResponse.json({ return HttpResponse.json(
resource({
creditRating: creditRating:
options.ratingState === "missing" || options.ratingState === "missing" ||
options.ratingState === "unavailable" options.ratingState === "unavailable"
? null ? null
: { : {
agency: "NCR", agency: "NCR",
value: options.ratingState === "withdrawn" ? null : "AAA.ru", value:
options.ratingState === "withdrawn" ? null : "AAA.ru",
status: status:
options.ratingState === "withdrawn" options.ratingState === "withdrawn"
? "withdrawn" ? "withdrawn"
@@ -132,34 +135,45 @@ function setup(
dateType: "MATDATE", dateType: "MATDATE",
tradeAt: quote.priceAt, tradeAt: quote.priceAt,
}, },
unavailable: options.ratingState === "unavailable" ? ["rating"] : [], unavailable:
options.ratingState === "unavailable" ? ["rating"] : [],
observedAt: quote.observedAt, observedAt: quote.observedAt,
}); }),
);
if (params.section === "quote") if (params.section === "quote")
return failQuote return failQuote
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(quote); : HttpResponse.json(resource(quote));
if (params.section === "position") if (params.section === "position")
return HttpResponse.json( return HttpResponse.json(
resource(
options.noPosition options.noPosition
? { ...position, quantity: "0", accounts: [] } ? { ...position, quantity: "0", accounts: [] }
: position, : position,
),
); );
if (params.section === "candles") if (params.section === "candles")
return HttpResponse.json({ return HttpResponse.json(
resource({
candles: [ candles: [
{ time: "2026-09-09T00:00:00Z", close: "99.25", isComplete: true }, {
time: "2026-09-09T00:00:00Z",
close: "99.25",
isComplete: true,
},
], ],
currency: null, currency: null,
historyFrom: null, historyFrom: null,
observedAt: quote.observedAt, observedAt: quote.observedAt,
}); }),
);
if (options.failCoupons) if (options.failCoupons)
return HttpResponse.json( return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" }, { code: "TBANK_UNAVAILABLE" },
{ status: 503 }, { status: 503 },
); );
return HttpResponse.json({ return HttpResponse.json(
resource({
coupons: [ coupons: [
{ {
perBond: money("32.88"), perBond: money("32.88"),
@@ -188,13 +202,15 @@ function setup(
positionAt: null, positionAt: null,
estimateUnavailable: false, estimateUnavailable: false,
positionIsStale: false, positionIsStale: false,
}); }),
);
}), }),
http.get("*/api/v1/brokerage-accounts/all/operations", ({ request }) => { http.get("*/api/v1/brokerage-accounts/all/operations", ({ request }) => {
const url = new URL(request.url); const url = new URL(request.url);
requests.push(url); requests.push(url);
const next = url.searchParams.has("cursor"); const next = url.searchParams.has("cursor");
return HttpResponse.json({ return HttpResponse.json(
resource({
accountId: "all", accountId: "all",
observedAt: quote.observedAt, observedAt: quote.observedAt,
hasNext: !next, hasNext: !next,
@@ -202,7 +218,9 @@ function setup(
items: [ items: [
{ {
id: next ? "sold" : "dividend", id: next ? "sold" : "dividend",
occurredAt: next ? "2022-01-02T10:00:00Z" : "2025-01-02T10:00:00Z", occurredAt: next
? "2022-01-02T10:00:00Z"
: "2025-01-02T10:00:00Z",
type: next ? "OPERATION_TYPE_SELL" : "OPERATION_TYPE_COUPON", type: next ? "OPERATION_TYPE_SELL" : "OPERATION_TYPE_COUPON",
category: next ? "trade" : "income", category: next ? "trade" : "income",
description: "", description: "",
@@ -212,7 +230,8 @@ function setup(
price: next ? money("100") : null, price: next ? money("100") : null,
}, },
], ],
}); }),
);
}), }),
); );
configureAuthClient({ configureAuthClient({
@@ -300,9 +319,11 @@ describe("bond page", () => {
it("keeps market content and sold-position history when coupons fail", async () => { it("keeps market content and sold-position history when coupons fail", async () => {
setup({ noPosition: true, failCoupons: true }); setup({ noPosition: true, failCoupons: true });
await screen.findByText("98,125 %"); await screen.findByText("98,125 %");
expect(await screen.findByRole("alert")).toHaveTextContent( expect(
"Не удалось загрузить этот раздел", (await screen.findByText(/Не удалось загрузить этот раздел/)).closest(
); '[role="status"]',
),
).toHaveAttribute("aria-live", "polite");
expect( expect(
screen.queryByRole("heading", { name: "Моя позиция" }), screen.queryByRole("heading", { name: "Моя позиция" }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
@@ -311,14 +332,36 @@ describe("bond page", () => {
).toBeVisible(); ).toBeVisible();
}); });
it("preserves cached quote on refresh failure", async () => { 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 %"); await screen.findByText("98,125 %");
controls.failQuote(); controls.failQuote();
fireEvent.click(screen.getByRole("button", { name: "Обновить цену" })); fireEvent.click(screen.getByRole("button", { name: "Обновить" }));
expect(await screen.findByRole("alert")).toHaveTextContent( expect(
"последние полученные", (await screen.findByText(/последние полученные/)).closest(
); '[role="status"]',
),
).toHaveAttribute("aria-live", "polite");
expect(screen.getByText("98,125 %")).toBeVisible(); 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 () => { it("offers search for an ambiguous or missing instrument without loading personal data", async () => {
const { requests } = setup({ path: "/bonds/SAME", ambiguousTicker: true }); const { requests } = setup({ path: "/bonds/SAME", ambiguousTicker: true });
+8 -9
View File
@@ -55,13 +55,10 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
</div> </div>
<Button <Button
variant="subtle" variant="subtle"
onClick={() => { onClick={vm.refresh}
vm.bondState.retry(); disabled={vm.refreshing}
vm.quoteState.retry();
vm.metricsState.retry();
}}
> >
{t("stock.refreshPrice")} {t("investment.refresh")}
</Button> </Button>
</Group> </Group>
<Group mt="md" gap="xs"> <Group mt="md" gap="xs">
@@ -80,8 +77,8 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
</Badge> </Badge>
) : null} ) : null}
</Group> </Group>
{vm.bondState.error || vm.quoteState.error ? ( {vm.refreshError || vm.bondState.error || vm.quoteState.error ? (
<Alert role="alert" color="orange" mt="md"> <Alert role="status" aria-live="polite" color="orange" mt="md">
{t(vm.quote ? "accounts.refreshError" : "stock.sectionError")} {t(vm.quote ? "accounts.refreshError" : "stock.sectionError")}
</Alert> </Alert>
) : null} ) : null}
@@ -322,7 +319,9 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
{vm.coupons ? ( {vm.coupons ? (
<Stack gap="md"> <Stack gap="md">
{vm.coupons.estimateUnavailable || vm.coupons.positionIsStale ? ( {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} ) : null}
{vm.upcoming.length ? ( {vm.upcoming.length ? (
vm.upcoming.map((coupon) => ( vm.upcoming.map((coupon) => (
+26 -8
View File
@@ -16,6 +16,7 @@ import {
useBrokerageOperations, useBrokerageOperations,
} from "@/entities/broker-operation"; } from "@/entities/broker-operation";
import { section } from "@/features/instrument-details"; import { section } from "@/features/instrument-details";
import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api"; import { apiErrorCode } from "@/shared/api";
import { formatDate, instrumentLogoUrl } from "@/shared/lib"; import { formatDate, instrumentLogoUrl } from "@/shared/lib";
@@ -58,6 +59,17 @@ function useBondPageVm() {
...firstPage, ...firstPage,
cursor: cursors.at(-1), 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 setParam = (key: string, value: string) => {
const next = new URLSearchParams(params); const next = new URLSearchParams(params);
next.set(key, value); next.set(key, value);
@@ -70,7 +82,7 @@ function useBondPageVm() {
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey, queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey,
exact: true, exact: true,
}); });
} else void operations.refetch(); } else void sync.refresh();
}; };
const chartData = const chartData =
candles.data?.candles.map((candle) => ({ candles.data?.candles.map((candle) => ({
@@ -79,31 +91,34 @@ function useBondPageVm() {
price: Number(candle.close), price: Number(candle.close),
})) ?? []; })) ?? [];
return { return {
refresh: sync.refresh,
refreshing: sync.refreshing,
refreshError: sync.transportError || sync.sourceError,
canonicalTo: canonicalTo:
bond.data?.slug && bond.data.slug !== bondId bond.data?.slug && bond.data.slug !== bondId
? `/bonds/${encodeURIComponent(bond.data.slug)}${params.size ? `?${params.toString()}` : ""}` ? `/bonds/${encodeURIComponent(bond.data.slug)}${params.size ? `?${params.toString()}` : ""}`
: null, : null,
bond: bond.data, bond: bond.data,
bondState: section(bond), bondState: section(bond, uid ? sync.refresh : () => bond.refetch()),
code: apiErrorCode(bond.error), code: apiErrorCode(bond.error),
logoSrc: instrumentLogoUrl(bond.data?.logoName), logoSrc: instrumentLogoUrl(bond.data?.logoName),
metrics: metrics.data, metrics: metrics.data,
metricsState: section(metrics), metricsState: section(metrics, sync.refresh),
quote: quote.data, quote: quote.data,
quoteState: section(quote), quoteState: section(quote, sync.refresh),
candles: candles.data, candles: candles.data,
chartData, chartData,
priceRows: chartData.toReversed(), priceRows: chartData.toReversed(),
candlesState: section(candles), candlesState: section(candles, sync.refresh),
period, period,
setPeriod: (value: string) => setParam("period", value), setPeriod: (value: string) => setParam("period", value),
averageLine: null, averageLine: null,
hasIncompleteCandle: hasIncompleteCandle:
candles.data?.candles.some((candle) => !candle.isComplete) ?? false, candles.data?.candles.some((candle) => !candle.isComplete) ?? false,
position: position.data, position: position.data,
positionState: section(position), positionState: section(position, sync.refresh),
coupons: coupons.data, coupons: coupons.data,
couponsState: section(coupons), couponsState: section(coupons, sync.refresh),
upcoming: upcoming:
coupons.data?.coupons coupons.data?.coupons
.filter((event) => event.timing === "upcoming") .filter((event) => event.timing === "upcoming")
@@ -121,7 +136,10 @@ function useBondPageVm() {
), ),
) ?? [], ) ?? [],
operations: operations.data, operations: operations.data,
operationsState: { ...section(operations), retry: retryOperations }, operationsState: {
...section(operations, sync.refresh),
retry: retryOperations,
},
eventRows: eventRows:
operations.data?.items.map((item) => ({ operations.data?.items.map((item) => ({
...item, ...item,
@@ -6,6 +6,7 @@ import { MemoryRouter } from "react-router";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { BondsPage } from "./BondsPage"; import { BondsPage } from "./BondsPage";
@@ -15,7 +16,8 @@ describe("bond search", () => {
server.use( server.use(
http.get("*/api/v1/bonds", ({ request }) => { http.get("*/api/v1/bonds", ({ request }) => {
query(new URL(request.url).searchParams.get("query")); query(new URL(request.url).searchParams.get("query"));
return HttpResponse.json([ return HttpResponse.json(
resource([
{ {
name: "ОФЗ", name: "ОФЗ",
ticker: "SU26238RMFS4", ticker: "SU26238RMFS4",
@@ -32,7 +34,8 @@ describe("bond search", () => {
perpetual: false, perpetual: false,
priceAt: "2026-09-10T10:00:00Z", priceAt: "2026-09-10T10:00:00Z",
}, },
]); ]),
);
}), }),
); );
configureAuthClient({ configureAuthClient({
@@ -37,6 +37,16 @@ const BondsPageView: FC<BondsPageViewProps> = (props) => {
{t("bond.searchSubtitle")} {t("bond.searchSubtitle")}
</Text> </Text>
</div> </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 <TextInput
label={t("bond.searchLabel")} label={t("bond.searchLabel")}
placeholder={t("bond.searchPlaceholder")} placeholder={t("bond.searchPlaceholder")}
@@ -49,7 +59,7 @@ const BondsPageView: FC<BondsPageViewProps> = (props) => {
<Text c="dimmed">{t("stock.searchHint")}</Text> <Text c="dimmed">{t("stock.searchHint")}</Text>
) : null} ) : null}
{vm.error ? ( {vm.error ? (
<Alert role="alert" color="orange"> <Alert role="status" aria-live="polite" color="orange">
<Stack gap="xs"> <Stack gap="xs">
<Text> <Text>
{t( {t(
@@ -166,7 +176,9 @@ const BondsPageView: FC<BondsPageViewProps> = (props) => {
))} ))}
</Stack> </Stack>
) : ( ) : (
<Text c="dimmed">{t("bond.noSearchResults")}</Text> <Text c="dimmed">
{t(vm.partial ? "investment.partial" : "bond.noSearchResults")}
</Text>
) )
) : null} ) : null}
</Stack> </Stack>
+12 -3
View File
@@ -1,6 +1,7 @@
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { useSearchParams } from "react-router"; import { useSearchParams } from "react-router";
import { useBondSearch } from "@/entities/bond"; import { useBondSearch } from "@/entities/bond";
import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api"; import { apiErrorCode } from "@/shared/api";
import { instrumentLogoUrl } from "@/shared/lib"; import { instrumentLogoUrl } from "@/shared/lib";
@@ -11,15 +12,23 @@ function useBondsPageVm() {
const query = params.get("q") ?? ""; const query = params.get("q") ?? "";
const [debounced] = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS); const [debounced] = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS);
const result = useBondSearch(debounced); const result = useBondSearch(debounced);
const sync = useScreenSync({ screen: "bonds", query: debounced }, [result]);
const waiting = query.trim() !== debounced; const waiting = query.trim() !== debounced;
return { return {
query, query,
setQuery: (q: string) => setParams(q ? { q } : {}, { replace: true }), setQuery: (q: string) => setParams(q ? { q } : {}, { replace: true }),
loading: query.trim().length >= 2 && (waiting || result.isLoading), loading:
error: result.isError, 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), code: apiErrorCode(result.error),
retry: () => { retry: () => {
void result.refetch(); void sync.refresh();
}, },
ready: debounced.length >= 2 && !waiting && Boolean(result.data), ready: debounced.length >= 2 && !waiting && Boolean(result.data),
items: items:
+59 -20
View File
@@ -6,6 +6,7 @@ import { MemoryRouter, Route, Routes, useLocation } from "react-router";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { StockPage } from "./StockPage"; import { StockPage } from "./StockPage";
@@ -80,37 +81,42 @@ function setup(
http.get("*/api/v1/stocks/:uid", () => http.get("*/api/v1/stocks/:uid", () =>
options.failStock options.failStock
? HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 }) ? HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 })
: HttpResponse.json(resolved), : HttpResponse.json(resource(resolved)),
), ),
http.get("*/api/v1/stocks/by-ticker/:ticker", ({ request }) => { http.get("*/api/v1/stocks/by-ticker/:ticker", ({ request }) => {
requests.push(new URL(request.url)); requests.push(new URL(request.url));
return options.ambiguousTicker return options.ambiguousTicker
? HttpResponse.json({ code: "STOCK_TICKER_AMBIGUOUS" }, { status: 409 }) ? HttpResponse.json({ code: "STOCK_TICKER_AMBIGUOUS" }, { status: 409 })
: HttpResponse.json(resolved); : HttpResponse.json(resource(resolved));
}), }),
http.get("*/api/v1/stocks/:uid/:section", ({ params, request }) => { http.get("*/api/v1/stocks/:uid/:section", ({ params, request }) => {
requests.push(new URL(request.url)); requests.push(new URL(request.url));
if (params.section === "quote") if (params.section === "quote")
return failQuote return failQuote
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(quote); : HttpResponse.json(resource(quote));
if (params.section === "position") if (params.section === "position")
return HttpResponse.json( return HttpResponse.json(
resource(
options.noPosition options.noPosition
? { ...position, quantity: "0", accounts: [] } ? { ...position, quantity: "0", accounts: [] }
: position, : position,
),
); );
if (params.section === "candles") if (params.section === "candles")
return HttpResponse.json({ return HttpResponse.json(
resource({
candles: [], candles: [],
currency: "RUB", currency: "RUB",
historyFrom: null, historyFrom: null,
observedAt: quote.observedAt, observedAt: quote.observedAt,
}); }),
);
if (params.section === "company") if (params.section === "company")
return options.failCompany return options.failCompany
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json({ : HttpResponse.json(
resource({
description: "Описание компании", description: "Описание компании",
metrics: [ metrics: [
{ {
@@ -123,8 +129,10 @@ function setup(
], ],
unavailable: [], unavailable: [],
observedAt: quote.observedAt, observedAt: quote.observedAt,
}); }),
return HttpResponse.json({ );
return HttpResponse.json(
resource({
dividends: [ dividends: [
{ {
perShare: money("32.88"), perShare: money("32.88"),
@@ -149,13 +157,15 @@ function setup(
positionAt: null, positionAt: null,
estimateUnavailable: false, estimateUnavailable: false,
positionIsStale: false, positionIsStale: false,
}); }),
);
}), }),
http.get("*/api/v1/brokerage-accounts/all/operations", ({ request }) => { http.get("*/api/v1/brokerage-accounts/all/operations", ({ request }) => {
const url = new URL(request.url); const url = new URL(request.url);
requests.push(url); requests.push(url);
const next = url.searchParams.has("cursor"); const next = url.searchParams.has("cursor");
return HttpResponse.json({ return HttpResponse.json(
resource({
accountId: "all", accountId: "all",
observedAt: quote.observedAt, observedAt: quote.observedAt,
hasNext: !next, hasNext: !next,
@@ -163,7 +173,9 @@ function setup(
items: [ items: [
{ {
id: next ? "sold" : "dividend", id: next ? "sold" : "dividend",
occurredAt: next ? "2022-01-02T10:00:00Z" : "2025-01-02T10:00:00Z", occurredAt: next
? "2022-01-02T10:00:00Z"
: "2025-01-02T10:00:00Z",
type: next ? "OPERATION_TYPE_SELL" : "OPERATION_TYPE_DIVIDEND", type: next ? "OPERATION_TYPE_SELL" : "OPERATION_TYPE_DIVIDEND",
category: next ? "trade" : "income", category: next ? "trade" : "income",
description: "", description: "",
@@ -173,7 +185,8 @@ function setup(
price: next ? money("100") : null, price: next ? money("100") : null,
}, },
], ],
}); }),
);
}), }),
); );
configureAuthClient({ configureAuthClient({
@@ -271,9 +284,11 @@ describe("stock page", () => {
screen.queryByRole("heading", { name: "Моя позиция" }), screen.queryByRole("heading", { name: "Моя позиция" }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "График цены" })).toBeVisible(); 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 () => { it("sends instrument-filtered continuation, resets it on filter changes and changes chart period", async () => {
const { requests } = setup(); const { requests } = setup();
@@ -308,14 +323,38 @@ describe("stock page", () => {
); );
}); });
it("keeps the last quote on a refresh failure", async () => { 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 ₽"); await screen.findByText("300 ₽");
controls.failQuote(); controls.failQuote();
fireEvent.click(screen.getByRole("button", { name: "Обновить цену" })); fireEvent.click(screen.getByRole("button", { name: "Обновить" }));
expect(await screen.findByRole("alert")).toHaveTextContent( expect(
"последние полученные", (await screen.findByText(/последние полученные/)).closest(
); '[role="status"]',
),
).toHaveAttribute("aria-live", "polite");
expect(screen.getByText("300 ₽")).toBeVisible(); 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 () => { it("offers recovery for an unavailable direct link", async () => {
setup({ failStock: true }); setup({ failStock: true });
@@ -63,12 +63,10 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
</div> </div>
<Button <Button
variant="subtle" variant="subtle"
onClick={() => { onClick={vm.refresh}
vm.stockState.retry(); disabled={vm.refreshing}
vm.quoteState.retry();
}}
> >
{t("stock.refreshPrice")} {t("investment.refresh")}
</Button> </Button>
</Group> </Group>
{vm.company?.description ? ( {vm.company?.description ? (
@@ -76,8 +74,8 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
{vm.company.description} {vm.company.description}
</Text> </Text>
) : null} ) : null}
{vm.stockState.error || vm.quoteState.error ? ( {vm.refreshError || vm.stockState.error || vm.quoteState.error ? (
<Alert role="alert" color="orange" mt="md"> <Alert role="status" aria-live="polite" color="orange" mt="md">
{t(vm.quote ? "accounts.refreshError" : "stock.sectionError")} {t(vm.quote ? "accounts.refreshError" : "stock.sectionError")}
</Alert> </Alert>
) : null} ) : null}
@@ -164,7 +162,7 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
{vm.company ? ( {vm.company ? (
<Stack gap="lg"> <Stack gap="lg">
{vm.company.unavailable.length ? ( {vm.company.unavailable.length ? (
<Alert color="orange"> <Alert color="orange" role="status" aria-live="polite">
<Group justify="space-between"> <Group justify="space-between">
<Text size="sm">{t("stock.partialCompany")}</Text> <Text size="sm">{t("stock.partialCompany")}</Text>
<Button <Button
+26 -8
View File
@@ -16,6 +16,7 @@ import {
useStockQuote, useStockQuote,
} from "@/entities/stock"; } from "@/entities/stock";
import { section } from "@/features/instrument-details"; import { section } from "@/features/instrument-details";
import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api"; import { apiErrorCode } from "@/shared/api";
import { formatDate, instrumentLogoUrl } from "@/shared/lib"; import { formatDate, instrumentLogoUrl } from "@/shared/lib";
@@ -58,6 +59,17 @@ function useStockPageVm() {
...firstPage, ...firstPage,
cursor: cursors.at(-1), 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 setParam = (key: string, value: string) => {
const next = new URLSearchParams(params); const next = new URLSearchParams(params);
next.set(key, value); next.set(key, value);
@@ -70,7 +82,7 @@ function useStockPageVm() {
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey, queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey,
exact: true, exact: true,
}); });
} else void operations.refetch(); } else void sync.refresh();
}; };
const chartData = const chartData =
candles.data?.candles.map((candle) => ({ candles.data?.candles.map((candle) => ({
@@ -85,20 +97,23 @@ function useStockPageVm() {
: null; : null;
const companyData = company.data; const companyData = company.data;
return { return {
refresh: sync.refresh,
refreshing: sync.refreshing,
refreshError: sync.transportError || sync.sourceError,
canonicalTo: canonicalTo:
stock.data?.slug && stock.data.slug !== stockId stock.data?.slug && stock.data.slug !== stockId
? `/stocks/${encodeURIComponent(stock.data.slug)}${params.size ? `?${params.toString()}` : ""}` ? `/stocks/${encodeURIComponent(stock.data.slug)}${params.size ? `?${params.toString()}` : ""}`
: null, : null,
stock: stock.data, stock: stock.data,
stockState: section(stock), stockState: section(stock, uid ? sync.refresh : () => stock.refetch()),
code: apiErrorCode(stock.error), code: apiErrorCode(stock.error),
logoSrc: instrumentLogoUrl(stock.data?.logoName), logoSrc: instrumentLogoUrl(stock.data?.logoName),
quote: quote.data, quote: quote.data,
quoteState: section(quote), quoteState: section(quote, sync.refresh),
candles: candles.data, candles: candles.data,
chartData, chartData,
priceRows: chartData.toReversed(), priceRows: chartData.toReversed(),
candlesState: section(candles), candlesState: section(candles, sync.refresh),
period, period,
setPeriod: (value: string) => setParam("period", value), setPeriod: (value: string) => setParam("period", value),
averageLine: averageLine:
@@ -106,11 +121,11 @@ function useStockPageVm() {
hasIncompleteCandle: hasIncompleteCandle:
candles.data?.candles.some((candle) => !candle.isComplete) ?? false, candles.data?.candles.some((candle) => !candle.isComplete) ?? false,
position: position.data, position: position.data,
positionState: section(position), positionState: section(position, sync.refresh),
company: companyData, company: companyData,
companyState: section(company), companyState: section(company, sync.refresh),
dividends: dividends.data, dividends: dividends.data,
dividendsState: section(dividends), dividendsState: section(dividends, sync.refresh),
upcoming: upcoming:
dividends.data?.dividends dividends.data?.dividends
.filter((event) => event.timing === "upcoming") .filter((event) => event.timing === "upcoming")
@@ -128,7 +143,10 @@ function useStockPageVm() {
), ),
) ?? [], ) ?? [],
operations: operations.data, operations: operations.data,
operationsState: { ...section(operations), retry: retryOperations }, operationsState: {
...section(operations, sync.refresh),
retry: retryOperations,
},
eventRows: eventRows:
operations.data?.items.map((item) => ({ operations.data?.items.map((item) => ({
...item, ...item,
@@ -23,7 +23,9 @@ const StockDividends: FC<StockEventsProps> = (props) => {
{vm.dividends ? ( {vm.dividends ? (
<Stack gap="md"> <Stack gap="md">
{vm.dividends.estimateUnavailable || vm.dividends.positionIsStale ? ( {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} ) : null}
{vm.upcoming.length ? ( {vm.upcoming.length ? (
vm.upcoming.map((event) => ( vm.upcoming.map((event) => (
@@ -6,6 +6,7 @@ import { MemoryRouter } from "react-router";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { StocksPage } from "./StocksPage"; import { StocksPage } from "./StocksPage";
@@ -15,7 +16,8 @@ describe("stock search", () => {
server.use( server.use(
http.get("*/api/v1/stocks", ({ request }) => { http.get("*/api/v1/stocks", ({ request }) => {
query(new URL(request.url).searchParams.get("query")); query(new URL(request.url).searchParams.get("query"));
return HttpResponse.json([ return HttpResponse.json(
resource([
{ {
name: "Сбербанк", name: "Сбербанк",
ticker: "SBER", ticker: "SBER",
@@ -30,7 +32,8 @@ describe("stock search", () => {
price: { currency: "RUB", value: "306.12" }, price: { currency: "RUB", value: "306.12" },
priceAt: "2026-09-10T10:00:00Z", priceAt: "2026-09-10T10:00:00Z",
}, },
]); ]),
);
}), }),
); );
configureAuthClient({ configureAuthClient({
@@ -32,6 +32,16 @@ const StocksPageView: FC<StocksPageViewProps> = (props) => {
{t("stock.searchSubtitle")} {t("stock.searchSubtitle")}
</Text> </Text>
</div> </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 <TextInput
label={t("stock.searchLabel")} label={t("stock.searchLabel")}
placeholder={t("stock.searchPlaceholder")} placeholder={t("stock.searchPlaceholder")}
@@ -44,7 +54,7 @@ const StocksPageView: FC<StocksPageViewProps> = (props) => {
<Text c="dimmed">{t("stock.searchHint")}</Text> <Text c="dimmed">{t("stock.searchHint")}</Text>
) : null} ) : null}
{vm.error ? ( {vm.error ? (
<Alert role="alert" color="orange"> <Alert role="status" aria-live="polite" color="orange">
<Stack gap="xs"> <Stack gap="xs">
<Text> <Text>
{t( {t(
@@ -142,7 +152,9 @@ const StocksPageView: FC<StocksPageViewProps> = (props) => {
))} ))}
</Stack> </Stack>
) : ( ) : (
<Text c="dimmed">{t("stock.noSearchResults")}</Text> <Text c="dimmed">
{t(vm.partial ? "investment.partial" : "stock.noSearchResults")}
</Text>
) )
) : null} ) : null}
</Stack> </Stack>
@@ -1,6 +1,7 @@
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { useSearchParams } from "react-router"; import { useSearchParams } from "react-router";
import { useStockSearch } from "@/entities/stock"; import { useStockSearch } from "@/entities/stock";
import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api"; import { apiErrorCode } from "@/shared/api";
import { instrumentLogoUrl } from "@/shared/lib"; import { instrumentLogoUrl } from "@/shared/lib";
@@ -11,15 +12,23 @@ function useStocksPageVm() {
const query = params.get("q") ?? ""; const query = params.get("q") ?? "";
const [debounced] = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS); const [debounced] = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS);
const result = useStockSearch(debounced); const result = useStockSearch(debounced);
const sync = useScreenSync({ screen: "stocks", query: debounced }, [result]);
const waiting = query.trim() !== debounced; const waiting = query.trim() !== debounced;
return { return {
query, query,
setQuery: (q: string) => setParams(q ? { q } : {}, { replace: true }), setQuery: (q: string) => setParams(q ? { q } : {}, { replace: true }),
loading: query.trim().length >= 2 && (waiting || result.isLoading), loading:
error: result.isError, 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), code: apiErrorCode(result.error),
retry: () => { retry: () => {
void result.refetch(); void sync.refresh();
}, },
ready: debounced.length >= 2 && !waiting && Boolean(result.data), ready: debounced.length >= 2 && !waiting && Boolean(result.data),
items: items:
+6 -3
View File
@@ -2,6 +2,7 @@ import { renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw"; import { HttpResponse, http } from "msw";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { createQueryWrapper } from "@/test/query"; import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { import {
useAuthenticatedQueries, useAuthenticatedQueries,
@@ -17,14 +18,16 @@ describe("useAuthenticatedQuery", () => {
server.use( server.use(
http.get("*/api/v1/brokerage-accounts", () => { http.get("*/api/v1/brokerage-accounts", () => {
requests(); requests();
return HttpResponse.json([ return HttpResponse.json(
resource([
{ {
id: "account-1", id: "account-1",
name: "Основной", name: "Основной",
type: "BROKERAGE", type: "BROKERAGE",
openedOn: "2024-08-13", openedOn: "2024-08-13",
}, },
]); ]),
);
}), }),
); );
@@ -53,7 +56,7 @@ describe("useAuthenticatedQuery", () => {
await waitFor(() => expect(result.current.isSuccess).toBe(true)); await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(requests).toHaveBeenCalledOnce(); expect(requests).toHaveBeenCalledOnce();
expect(result.current.data).toEqual([ expect(result.current.data?.data).toEqual([
{ {
id: "account-1", id: "account-1",
name: "Основной", name: "Основной",
+4 -3
View File
@@ -1,5 +1,6 @@
import { HttpResponse, http } from "msw"; import { HttpResponse, http } from "msw";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { apiClient, configureAuthClient } from "./client"; import { apiClient, configureAuthClient } from "./client";
@@ -16,14 +17,14 @@ describe("apiClient authentication", () => {
authorization.push(request.headers.get("Authorization") ?? ""); authorization.push(request.headers.get("Authorization") ?? "");
return authorization.length === 1 return authorization.length === 1
? HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }) ? HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 })
: HttpResponse.json([]); : HttpResponse.json(resource([]));
}), }),
); );
configureAuthClient({ accessToken: () => token, refresh, clear: vi.fn() }); configureAuthClient({ accessToken: () => token, refresh, clear: vi.fn() });
const { data } = await apiClient.GET("/api/v1/brokerage-accounts"); const { data } = await apiClient.GET("/api/v1/brokerage-accounts");
expect(data).toEqual([]); expect(data?.data).toEqual([]);
expect(refresh).toHaveBeenCalledOnce(); expect(refresh).toHaveBeenCalledOnce();
expect(authorization).toEqual(["Bearer expired", "Bearer fresh"]); expect(authorization).toEqual(["Bearer expired", "Bearer fresh"]);
}); });
@@ -76,7 +77,7 @@ describe("apiClient authentication", () => {
if (++calls === 1) if (++calls === 1)
return HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }); return HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 });
await pending; await pending;
return HttpResponse.json([{ id: "previous-user-account" }]); return HttpResponse.json(resource([{ id: "previous-user-account" }]));
}), }),
); );
configureAuthClient({ configureAuthClient({
+47 -1
View File
@@ -14,6 +14,13 @@ type AuthHandlers = {
clear: () => void; clear: () => void;
}; };
const inFlight = new Set<{ pathname: string; invalidated: boolean }>();
const latestReads = new Map<string, { sequence: number; pending: number }>();
function invalidateApiRequests(matches: (pathname: string) => boolean) {
for (const request of inFlight)
if (matches(request.pathname)) request.invalidated = true;
}
let auth: AuthHandlers | null = null; let auth: AuthHandlers | null = null;
let hasAuthenticatedSession = false; let hasAuthenticatedSession = false;
const authSessionListeners = new Set<() => void>(); const authSessionListeners = new Set<() => void>();
@@ -25,6 +32,7 @@ function setAuthenticatedSession(next: boolean) {
} }
function configureAuthClient(handlers: AuthHandlers | null) { function configureAuthClient(handlers: AuthHandlers | null) {
if (!handlers) invalidateApiRequests(() => true);
auth = handlers; auth = handlers;
setAuthenticatedSession(Boolean(auth?.accessToken())); setAuthenticatedSession(Boolean(auth?.accessToken()));
} }
@@ -39,12 +47,47 @@ function subscribeAuthenticatedSession(listener: () => void) {
} }
async function authenticatedFetch(request: Request): Promise<Response> { async function authenticatedFetch(request: Request): Promise<Response> {
const url = new URL(request.url);
url.searchParams.delete("sync");
url.searchParams.sort();
const key = `${request.method} ${url}`;
const ticket = { pathname: url.pathname, invalidated: false };
const read =
request.method === "GET"
? (latestReads.get(key) ?? { sequence: 0, pending: 0 })
: null;
if (read) {
read.sequence += 1;
read.pending += 1;
latestReads.set(key, read);
}
const sequence = read?.sequence;
inFlight.add(ticket);
try {
return await fetchCurrent(
request,
() => !ticket.invalidated && (!read || sequence === read.sequence),
);
} finally {
inFlight.delete(ticket);
if (read && --read.pending === 0) latestReads.delete(key);
}
}
async function fetchCurrent(
request: Request,
isLatest: () => boolean,
): Promise<Response> {
const isAuthRequest = new URL(request.url).pathname.startsWith( const isAuthRequest = new URL(request.url).pathname.startsWith(
"/api/v1/auth/", "/api/v1/auth/",
); );
const session = auth; const session = auth;
const generation = session?.generation?.(); const generation = session?.generation?.();
const isCurrent = () => generation === auth?.generation?.(); const isCurrent = () =>
(session?.generation
? generation === session.generation() &&
generation === auth?.generation?.()
: session === auth) && isLatest();
const token = session?.accessToken(); const token = session?.accessToken();
const withToken = (source: Request, value: string) => { const withToken = (source: Request, value: string) => {
const headers = new Headers(source.headers); const headers = new Headers(source.headers);
@@ -55,6 +98,7 @@ async function authenticatedFetch(request: Request): Promise<Response> {
token && !isAuthRequest ? withToken(request, token) : request; token && !isAuthRequest ? withToken(request, token) : request;
const retryRequest = authorized.clone(); const retryRequest = authorized.clone();
const response = await fetch(authorized); const response = await fetch(authorized);
await response.clone().arrayBuffer();
if (!isAuthRequest && !isCurrent()) if (!isAuthRequest && !isCurrent())
throw new DOMException("Session changed", "AbortError"); throw new DOMException("Session changed", "AbortError");
if ( if (
@@ -78,6 +122,7 @@ async function authenticatedFetch(request: Request): Promise<Response> {
const refreshedToken = auth.accessToken(); const refreshedToken = auth.accessToken();
if (!refreshedToken) return response; if (!refreshedToken) return response;
const retried = await fetch(withToken(retryRequest, refreshedToken)); const retried = await fetch(withToken(retryRequest, refreshedToken));
await retried.clone().arrayBuffer();
if (!isCurrent()) throw new DOMException("Session changed", "AbortError"); if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
return retried; return retried;
} }
@@ -95,5 +140,6 @@ export {
apiQueryClient, apiQueryClient,
configureAuthClient, configureAuthClient,
getAuthenticatedSessionSnapshot, getAuthenticatedSessionSnapshot,
invalidateApiRequests,
subscribeAuthenticatedSession, subscribeAuthenticatedSession,
}; };
+436 -302
View File
File diff suppressed because it is too large Load Diff
+12 -1
View File
@@ -2,6 +2,17 @@ export {
useAuthenticatedQueries, useAuthenticatedQueries,
useAuthenticatedQuery, useAuthenticatedQuery,
} from "./authenticated-query"; } from "./authenticated-query";
export { apiClient, apiQueryClient, configureAuthClient } from "./client"; export {
apiClient,
apiQueryClient,
configureAuthClient,
invalidateApiRequests,
} from "./client";
export type { paths } from "./generated/schema"; export type { paths } from "./generated/schema";
export { apiErrorCode } from "./problem-details"; export { apiErrorCode } from "./problem-details";
export {
invalidateResourceAccess,
unwrapResourceResult,
useResourceQuery,
} from "./resource-query";
+150
View File
@@ -0,0 +1,150 @@
import type { QueryClient } from "@tanstack/react-query";
import {
type QueryFunctionContext,
type QueryKey,
type UseQueryOptions,
type UseQueryResult,
useQueryClient,
} from "@tanstack/react-query";
import { useEffect } from "react";
import { useAuthenticatedQuery } from "./authenticated-query";
import type { components } from "./generated/schema";
import { apiErrorCode } from "./problem-details";
const accessEpochs = new WeakMap<QueryClient, Map<string, number>>();
type Resource = {
data: unknown;
meta: components["schemas"]["InvestmentResourceMetaDto"];
};
function unwrapResourceResult<T extends Resource, E>(
result: UseQueryResult<T, E>,
) {
const missing = result.isSuccess && result.data.data === null;
return {
...result,
transport: {
status: result.status,
fetchStatus: result.fetchStatus,
error: result.error,
},
data: result.data?.data ?? undefined,
meta: result.data?.meta,
isPending: result.isPending || missing,
isSuccess: result.isSuccess && !missing,
status: missing ? "pending" : result.status,
} as UseQueryResult<NonNullable<T["data"]>, E> & {
meta?: T["meta"];
transport: {
status: UseQueryResult<T, E>["status"];
fetchStatus: UseQueryResult<T, E>["fetchStatus"];
error: E | null;
};
};
}
function invalidateResourceAccess(
client: QueryClient,
group: string,
error: unknown,
) {
if (
![
"NOT_FOUND",
"FORBIDDEN",
"TBANK_NOT_CONNECTED",
"INVALID_TBANK_TOKEN",
].includes(apiErrorCode(error) ?? "")
)
return;
const epochs = accessEpochs.get(client) ?? new Map<string, number>();
accessEpochs.set(client, epochs);
epochs.set(group, (epochs.get(group) ?? 0) + 1);
const affected = {
predicate: (cached: { meta?: Record<string, unknown> }) =>
cached.meta?.accessGroup === group,
};
void client.cancelQueries(affected);
for (const cached of client.getQueryCache().findAll(affected))
cached.setState({
data: undefined,
dataUpdatedAt: 0,
status: "error",
error: error as Error,
fetchStatus: "idle",
});
}
// Observation changes transport mode, never the generated cache identity.
function useResourceQuery<T extends Resource, E, K extends QueryKey>(
options: UseQueryOptions<T, E, T, K>,
) {
const client = useQueryClient();
const group =
typeof options.meta?.accessGroup === "string"
? options.meta.accessGroup
: undefined;
let epochs = accessEpochs.get(client);
if (!epochs) {
epochs = new Map();
accessEpochs.set(client, epochs);
}
const groupEpochs = epochs;
const queryFn = options.queryFn;
const guarded =
typeof queryFn === "function"
? async (context: QueryFunctionContext<K>) => {
const epoch = group ? (groupEpochs.get(group) ?? 0) : 0;
const result = await queryFn(context);
if (group && epoch !== (groupEpochs.get(group) ?? 0))
throw new DOMException("Access changed", "AbortError");
return result;
}
: queryFn;
const query = useAuthenticatedQuery({ ...options, queryFn: guarded });
useEffect(() => {
if (group) invalidateResourceAccess(client, group, query.error);
}, [client, group, query.error]);
const observe = async (expectedStateVersion?: string) => {
const queryFn = guarded;
if (typeof queryFn !== "function") return;
await client.cancelQueries({ queryKey: options.queryKey, exact: true });
return client.fetchQuery({
...options,
staleTime: 0,
retry: false,
queryFn: async (context: QueryFunctionContext<K>) => {
const [method, path, input] = context.queryKey;
const init = input as
| { params?: { query?: Record<string, unknown> } }
| undefined;
const queryKey = [
method,
path,
{
...init,
params: {
...init?.params,
query: { ...init?.params?.query, sync: "observe" },
},
},
] as unknown as K;
const response = await queryFn({ ...context, queryKey });
if (
expectedStateVersion &&
response.meta.stateVersion !== expectedStateVersion
) {
// One more observational read; never turn a stale notification into an import.
const current = await queryFn({ ...context, queryKey });
if (current.meta.stateVersion !== expectedStateVersion)
throw new DOMException("Resource changed", "AbortError");
return current;
}
return response;
},
});
};
return { ...unwrapResourceResult(query), observe };
}
export { invalidateResourceAccess, unwrapResourceResult, useResourceQuery };
File diff suppressed because it is too large Load Diff
+13
View File
@@ -1,5 +1,14 @@
const ru = { const ru = {
translation: { translation: {
investment: {
refresh: "Обновить",
stale: "Данные требуют обновления",
syncFailed:
"Источник не удалось обновить. Сохранённые данные не изменены.",
partial: "Период загружен не полностью",
history: "История догружается",
sourceTime: "Данные на {{date}}",
},
appName: "WealthLedger", appName: "WealthLedger",
navigation: { navigation: {
cards: "Карты", cards: "Карты",
@@ -623,6 +632,10 @@ const ru = {
taxBoundary: "Налоговые операции не включены в этот результат.", taxBoundary: "Налоговые операции не включены в этот результат.",
comparisonEmpty: "За эти периоды купоны и дивиденды не найдены.", comparisonEmpty: "За эти периоды купоны и дивиденды не найдены.",
yearAgo: "Годом ранее", yearAgo: "Годом ранее",
previousPeriod: "Предыдущий период",
comparisonPending: "Период сравнения ещё не загружен полностью.",
comparisonFailed:
"Не удалось загрузить период сравнения. Повторите обновление.",
yearUnavailable: "Сравнение с годом ранее недоступно.", yearUnavailable: "Сравнение с годом ранее недоступно.",
difference: "Разница", difference: "Разница",
total: "Итого", total: "Итого",
+16 -5
View File
@@ -1,4 +1,5 @@
import { HttpResponse, http } from "msw"; import { HttpResponse, http } from "msw";
import { resource } from "@/test/resource";
const authentication = { const authentication = {
accessToken: "test-token", accessToken: "test-token",
@@ -26,9 +27,12 @@ const handlers = [
"*/api/v1/tbank-connection", "*/api/v1/tbank-connection",
() => new HttpResponse(null, { status: 204 }), () => new HttpResponse(null, { status: 204 }),
), ),
http.get("*/api/v1/brokerage-accounts", () => HttpResponse.json([])), http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json(resource([])),
),
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json({ HttpResponse.json(
resource({
lastSuccessfulAt: "2026-08-14T00:00:00Z", lastSuccessfulAt: "2026-08-14T00:00:00Z",
isStale: false, isStale: false,
totalsByCurrency: [], totalsByCurrency: [],
@@ -37,8 +41,10 @@ const handlers = [
cashBalances: [], cashBalances: [],
}), }),
), ),
),
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
HttpResponse.json({ HttpResponse.json(
resource({
accountId: "account-1", accountId: "account-1",
items: [], items: [],
nextCursor: null, nextCursor: null,
@@ -46,21 +52,26 @@ const handlers = [
observedAt: "2026-08-14T00:00:00Z", observedAt: "2026-08-14T00:00:00Z",
}), }),
), ),
),
http.get("*/api/v1/brokerage-accounts/:accountId/events", () => http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
HttpResponse.json({ HttpResponse.json(
resource({
accountId: "account-1", accountId: "account-1",
calculatedAt: "2026-08-14T00:00:00Z", calculatedAt: "2026-08-14T00:00:00Z",
events: [], events: [],
}), }),
), ),
),
http.get("*/api/v1/brokerage-accounts/:accountId/income-summary", () => http.get("*/api/v1/brokerage-accounts/:accountId/income-summary", () =>
HttpResponse.json({ HttpResponse.json(
resource({
accountId: "account-1", accountId: "account-1",
period: { from: "2026-01-01", to: "2026-08-14" }, period: { from: "2026-01-01", to: "2026-08-14" },
totalsByCurrency: [], totalsByCurrency: [],
calculatedAt: "2026-08-14T00:00:00Z", calculatedAt: "2026-08-14T00:00:00Z",
}), }),
), ),
),
]; ];
export { handlers }; export { handlers };
+21
View File
@@ -0,0 +1,21 @@
import type { paths } from "@/shared/api";
type Meta =
paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"]["meta"];
function resource<T>(data: T, meta: Partial<Meta> = {}) {
return {
data,
meta: {
resourceKey: "fixture",
dataRevision: "1",
stateVersion: "1",
availability: data === null ? "missing" : "complete",
freshness: "fresh",
lastSuccessfulAt: "2026-08-14T00:00:00Z",
sync: { id: null, state: "idle", errorCode: null },
...meta,
} satisfies Meta,
};
}
export { resource };