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
@@ -14,6 +14,7 @@ import type { BrokerageEvent } from "@/entities/brokerage-event";
import { type Portfolio, portfolioQueryOptions } from "@/entities/portfolio";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { AccountPage } from "./AccountPage";
import type {
@@ -144,27 +145,31 @@ describe("AccountPage states", () => {
};
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json({
...portfolio,
positions:
accountId === "all"
? ["account-1", "account-2"].map((id) => ({
...position,
account: { id, name: id },
}))
: [position],
}),
HttpResponse.json(
resource({
...portfolio,
positions:
accountId === "all"
? ["account-1", "account-2"].map((id) => ({
...position,
account: { id, name: id },
}))
: [position],
}),
),
),
http.get(`*/api/v1/${collection}/:uid`, () => {
requests();
return HttpResponse.json({
instrumentUid: uid,
slug,
name: "Название из карточки инструмента",
ticker: "OTHER-TICKER",
isin: "OTHER-ISIN",
logoName: "other.png",
});
return HttpResponse.json(
resource({
instrumentUid: uid,
slug,
name: "Название из карточки инструмента",
ticker: "OTHER-TICKER",
isin: "OTHER-ISIN",
logoName: "other.png",
}),
);
}),
);
configureTestAuth();
@@ -204,22 +209,24 @@ describe("AccountPage states", () => {
const requests = vi.fn();
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json({
...portfolio,
positions: [
{
...portfolio.positions[0],
instrumentUid: uid,
ticker: "SBERP",
},
],
}),
HttpResponse.json(
resource({
...portfolio,
positions: [
{
...portfolio.positions[0],
instrumentUid: uid,
ticker: "SBERP",
},
],
}),
),
),
http.get("*/api/v1/stocks/:uid", () => {
requests();
return scenario === "unavailable"
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json({ instrumentUid: uid, slug: uid });
: HttpResponse.json(resource({ instrumentUid: uid, slug: uid }));
}),
);
renderAccountRoute(createTestQueryClient());
@@ -234,7 +241,7 @@ describe("AccountPage states", () => {
it("keeps the account and positions visible for an older HTTP response without structure", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json({ ...portfolio, structure: undefined }),
HttpResponse.json(resource({ ...portfolio, structure: undefined })),
),
);
renderAccountRoute(createTestQueryClient());
@@ -322,11 +329,13 @@ describe("AccountPage states", () => {
async (hasPositions) => {
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json({
...portfolio,
totalsByCurrency: [],
positions: hasPositions ? portfolio.positions : [],
}),
HttpResponse.json(
resource({
...portfolio,
totalsByCurrency: [],
positions: hasPositions ? portfolio.positions : [],
}),
),
),
);
renderAccountRoute(createTestQueryClient());
@@ -358,7 +367,7 @@ describe("AccountPage states", () => {
};
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(snapshot),
HttpResponse.json(resource(snapshot)),
),
);
const queryClient = createTestQueryClient();
@@ -380,7 +389,7 @@ describe("AccountPage states", () => {
).toEqual(totalsByCurrency.length ? ["100 ₽"] : []);
expect(
queryClient.getQueryData(portfolioQueryOptions("account-1").queryKey),
).toEqual(snapshot);
).toEqual(resource(snapshot));
},
);
@@ -887,7 +896,7 @@ describe("AccountPage states", () => {
expect(screen.getByText("Вторая")).toBeInTheDocument();
});
it.each([503, 404])(
it.each([503])(
"preserves totals and UTC snapshot through refresh error %i, retry and success",
async (status) => {
const initialPortfolio: Portfolio = {
@@ -907,9 +916,36 @@ describe("AccountPage states", () => {
let requestCount = 0;
let resolveRetry: ((response: Response) => void) | undefined;
server.use(
http.post("*/api/v1/tbank-sync", () =>
HttpResponse.json(
{
id: "overview",
scope: { screen: "account", accountId: "account-1" },
now: "2026-09-25T00:00:00Z",
},
{ status: 202 },
),
),
http.get("*/api/v1/tbank-sync/:id", () =>
HttpResponse.json({
id: "overview",
scope: { screen: "account", accountId: "account-1" },
now: "2026-09-25T00:00:00Z",
state: "succeeded",
errorCode: null,
progress: { total: 1, completed: 1, discovering: false },
resources: [
resource(null, { resourceKey: "portfolio", stateVersion: "2" })
.meta,
],
}),
),
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
requestCount += 1;
if (requestCount === 1) return HttpResponse.json(initialPortfolio);
if (requestCount === 1)
return HttpResponse.json(
resource(initialPortfolio, { resourceKey: "portfolio" }),
);
if (requestCount === 3)
return new Promise<Response>((resolve) => {
resolveRetry = resolve;
@@ -961,14 +997,19 @@ describe("AccountPage states", () => {
snapshotText,
);
resolveRetry?.(
HttpResponse.json({
...portfolio,
isStale: false,
lastSuccessfulAt: "2026-08-14T11:30:00Z",
totalsByCurrency: [{ currency: "USD", value: "-0.5" }],
structure: { currencies: [], unvaluedCategories: [] },
cashBalances: [{ currency: "EUR", value: "2.50" }],
}),
HttpResponse.json(
resource(
{
...portfolio,
isStale: false,
lastSuccessfulAt: "2026-08-14T11:30:00Z",
totalsByCurrency: [{ currency: "USD", value: "-0.5" }],
structure: { currencies: [], unvaluedCategories: [] },
cashBalances: [{ currency: "EUR", value: "2.50" }],
},
{ resourceKey: "portfolio", stateVersion: "2" },
),
),
);
await waitFor(() =>
expect(
@@ -993,7 +1034,7 @@ describe("AccountPage states", () => {
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
requestCount += 1;
if (requestCount === 1) return HttpResponse.json(portfolio);
if (requestCount === 1) return HttpResponse.json(resource(portfolio));
return new Promise<Response>((resolve) => {
resolveRefresh = resolve;
});
@@ -1020,6 +1061,6 @@ describe("AccountPage states", () => {
screen.getByRole("status", { name: "Обновляем портфель" }),
).toBeInTheDocument();
resolveRefresh?.(HttpResponse.json(portfolio));
resolveRefresh?.(HttpResponse.json(resource(portfolio)));
});
});
@@ -17,6 +17,7 @@ const AccountPage: FC<AccountPageProps> = (props) => {
account={viewModel.account}
accountId={viewModel.accountId}
refreshing={viewModel.refreshing}
sourceError={viewModel.sourceError}
refreshError={viewModel.refreshError}
retry={viewModel.retry}
recentOperations={viewModel.recentOperations}
@@ -28,9 +29,11 @@ const AccountPage: FC<AccountPageProps> = (props) => {
<AccountPageLoaded
account={viewModel.account}
accountId={viewModel.accountId}
partial={viewModel.partial}
portfolio={viewModel.portfolio}
positionSections={viewModel.positionSections}
refreshing={viewModel.refreshing}
sourceError={viewModel.sourceError}
refreshError={viewModel.refreshError}
retry={viewModel.retry}
recentOperations={viewModel.recentOperations}
@@ -1,5 +1,5 @@
import { MantineProvider } from "@mantine/core";
import { renderHook, waitFor } from "@testing-library/react";
import { act, renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import type { FC, PropsWithChildren } from "react";
import { MemoryRouter, Route, Routes } from "react-router";
@@ -7,6 +7,7 @@ import { describe, expect, it, vi } from "vitest";
import type { Portfolio } from "@/entities/portfolio";
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import {
toAccountPagePositionSections,
@@ -86,49 +87,53 @@ function createWrapper() {
function mockLoadedAccountPage() {
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(portfolio),
HttpResponse.json(resource(portfolio)),
),
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
HttpResponse.json({
accountId: "account-1",
items: [
{
id: "operation-1",
occurredAt: "2026-08-13T10:00:00Z",
type: "Buy",
category: "trade",
description: "Покупка акции",
figi: "FIGI-STOCK",
instrumentUid: null,
ticker: "STK",
instrumentType: "share",
payment: { currency: "RUB", value: "-100" },
quantity: "1",
price: null,
accruedInterest: null,
commission: null,
},
],
nextCursor: null,
hasNext: false,
observedAt: "2026-08-13T10:00:00Z",
}),
HttpResponse.json(
resource({
accountId: "account-1",
items: [
{
id: "operation-1",
occurredAt: "2026-08-13T10:00:00Z",
type: "Buy",
category: "trade",
description: "Покупка акции",
figi: "FIGI-STOCK",
instrumentUid: null,
ticker: "STK",
instrumentType: "share",
payment: { currency: "RUB", value: "-100" },
quantity: "1",
price: null,
accruedInterest: null,
commission: null,
},
],
nextCursor: null,
hasNext: false,
observedAt: "2026-08-13T10:00:00Z",
}),
),
),
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
HttpResponse.json({
accountId: "account-1",
calculatedAt: "2026-08-13T10:00:00Z",
events: Array.from({ length: 6 }, (_, index) => ({
type: "coupon",
date: `2026-09-${String(index + 1).padStart(2, "0")}`,
dateKind: "event",
figi: `FIGI-EVENT-${index}`,
instrumentUid: null,
ticker: `BND${index}`,
displayName: `Купон ${index}`,
projectedAmount: { currency: "RUB", value: String(index + 1) },
})),
}),
HttpResponse.json(
resource({
accountId: "account-1",
calculatedAt: "2026-08-13T10:00:00Z",
events: Array.from({ length: 6 }, (_, index) => ({
type: "coupon",
date: `2026-09-${String(index + 1).padStart(2, "0")}`,
dateKind: "event",
figi: `FIGI-EVENT-${index}`,
instrumentUid: null,
ticker: `BND${index}`,
displayName: `Купон ${index}`,
projectedAmount: { currency: "RUB", value: String(index + 1) },
})),
}),
),
),
);
}
@@ -281,7 +286,7 @@ describe("useAccountPageViewModel", () => {
configureTestAuth();
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(portfolio),
HttpResponse.json(resource(portfolio)),
),
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
@@ -304,3 +309,111 @@ describe("useAccountPageViewModel", () => {
);
});
});
it("keeps overview sections independent when their source data is incomplete", async () => {
configureTestAuth();
mockLoadedAccountPage();
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(resource(portfolio, { availability: "partial" })),
),
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
HttpResponse.json(
resource(
{ items: [], hasNext: false, nextCursor: null },
{ availability: "partial" },
),
),
),
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
HttpResponse.json(
resource(
{ events: [], incomeTotalsByCurrency: [] },
{ availability: "partial" },
),
),
),
);
const { result } = renderHook(() => useAccountPageViewModel(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.state).toBe("loaded"));
await waitFor(() =>
expect(result.current).toMatchObject({
partial: true,
recentOperations: { state: "partial", items: [] },
recentEvents: { state: "partial", items: [] },
}),
);
});
it("refreshes every overview section through one account descriptor and observational reads", async () => {
configureTestAuth();
const sections: Record<string, unknown> = {
portfolio,
operations: { items: [], hasNext: false, nextCursor: null },
events: { events: [], incomeTotalsByCurrency: [] },
"income-summary": { totalsByCurrency: [] },
};
const observed: string[] = [];
const scopes: unknown[] = [];
let version = "1";
server.use(
...Object.entries(sections).map(([path, data]) =>
http.get(
`*/api/v1/brokerage-accounts/:accountId/${path}`,
({ request }) => {
if (new URL(request.url).searchParams.get("sync") === "observe")
observed.push(path);
return HttpResponse.json(
resource(data, { resourceKey: path, stateVersion: version }),
);
},
),
),
http.post("*/api/v1/tbank-sync", async ({ request }) => {
scopes.push(await request.json());
version = "2";
return HttpResponse.json(
{
id: "overview",
scope: { screen: "account", accountId: "account-1" },
now: "2026-09-25T00:00:00Z",
},
{ status: 202 },
);
}),
http.get("*/api/v1/tbank-sync/:id", () =>
HttpResponse.json({
id: "overview",
scope: null,
now: "2026-09-25T00:00:00Z",
state: "succeeded",
errorCode: null,
progress: { total: 4, completed: 4, discovering: false },
resources: Object.keys(sections).map(
(resourceKey) =>
resource(null, { resourceKey, stateVersion: version }).meta,
),
}),
),
);
const { result } = renderHook(() => useAccountPageViewModel(), {
wrapper: createWrapper(),
});
await waitFor(() =>
expect(result.current).toMatchObject({
state: "loaded",
recentOperations: { state: "empty" },
recentEvents: { state: "empty" },
incomeSummary: { state: "empty" },
}),
);
await act(async () => {
if (result.current.state === "loaded") await result.current.retry();
});
await waitFor(() =>
expect(new Set(observed)).toEqual(new Set(Object.keys(sections))),
);
expect(scopes).toEqual([{ screen: "account", accountId: "account-1" }]);
});
@@ -1,10 +1,9 @@
import { useQueryClient } from "@tanstack/react-query";
import { useParams } from "react-router";
import type { BrokerageOperation } from "@/entities/broker-operation";
import { useBrokerageOperations } from "@/entities/broker-operation";
import {
type BrokerageAccount,
brokerageAccountsQueryKey,
useBrokerageAccounts,
} from "@/entities/brokerage-account";
import {
type BrokerageEventsPreviewState,
@@ -14,6 +13,7 @@ import { type IncomeSummary, useIncomeSummary } from "@/entities/income";
import type { Portfolio } from "@/entities/portfolio";
import { formatShare, usePortfolio } from "@/entities/portfolio";
import { useStocks } from "@/entities/stock";
import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api";
import { formatMoney, instrumentLogoUrl } from "@/shared/lib";
@@ -46,19 +46,31 @@ type AccountPageRecentOperations =
to: string;
}
| {
state: "loaded";
state: "loaded" | "partial";
to: string;
items: BrokerageOperation[];
lastSuccessfulAt?: string | null;
stale?: boolean;
refreshError?: boolean;
};
type AccountPageRecentEvents = BrokerageEventsPreviewState;
type AccountPageIncomeSummary =
| { state: "loading" | "empty"; to: string }
| { state: "loading" | "partial" | "empty"; to: string }
| { state: "error"; to: string; retry: () => void }
| { state: "loaded"; to: string; summary: IncomeSummary };
| {
state: "loaded";
to: string;
summary: IncomeSummary;
lastSuccessfulAt?: string | null;
stale?: boolean;
refreshError?: boolean;
};
type AccountPageContent = {
sourceError?: boolean;
partial?: boolean;
account?: BrokerageAccount;
accountId?: string;
refreshing: boolean;
@@ -102,27 +114,39 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
});
const events = useBrokerageEvents(accountId);
const income = useIncomeSummary(accountId);
const account = useQueryClient()
.getQueryData<BrokerageAccount[]>(brokerageAccountsQueryKey)
?.find((item) => item.id === accountId);
const accounts = useBrokerageAccounts();
const sync = useScreenSync({ screen: "account", accountId }, [
accounts,
portfolio,
operations,
events,
income,
]);
const account = accounts.data?.find((item) => item.id === accountId);
const recentOperations = toRecentOperations(accountId, operations);
const recentEvents = toRecentEvents(accountId, events);
const incomeSummary = toIncomeSummary(accountId, income);
if (!portfolio.data) {
if (portfolio.isError)
if (portfolio.isError || portfolio.meta?.sync.state === "failed")
return {
state: "error" as const,
code: apiErrorCode(portfolio.error),
retry: () => portfolio.refetch(),
retry: sync.refresh,
};
return { state: "loading" as const };
}
const refresh = {
refreshing: portfolio.isFetching,
refreshError: portfolio.isError,
retry: () => portfolio.refetch(),
partial: portfolio.meta?.availability === "partial",
refreshing: portfolio.isFetching || sync.refreshing,
sourceError: sync.sourceError,
refreshError: portfolio.isError || sync.transportError || sync.sourceError,
retry: sync.refresh,
};
if (!portfolio.data.positions.length && !portfolio.data.cashBalances.length)
if (
portfolio.meta?.availability === "complete" &&
!portfolio.data.positions.length &&
!portfolio.data.cashBalances.length
)
return {
state: "empty" as const,
account,
@@ -153,11 +177,20 @@ function toIncomeSummary(
income: ReturnType<typeof useIncomeSummary>,
): AccountPageIncomeSummary {
const to = `/accounts/${accountId}/income`;
if (income.isLoading) return { state: "loading", to };
if (income.isError)
if (!income.data && !income.isError && income.meta?.sync.state !== "failed")
return { state: "loading", to };
if (income.meta?.availability === "partial") return { state: "partial", to };
if (!income.data && (income.isError || income.meta?.sync.state === "failed"))
return { state: "error", to, retry: () => income.refetch() };
if (!income.data?.totalsByCurrency.length) return { state: "empty", to };
return { state: "loaded", to, summary: income.data };
return {
state: "loaded",
to,
summary: income.data,
lastSuccessfulAt: income.meta?.lastSuccessfulAt,
stale: income.meta?.freshness === "stale",
refreshError: income.isError || income.meta?.sync.state === "failed",
};
}
function toRecentOperations(
@@ -165,10 +198,29 @@ function toRecentOperations(
operations: ReturnType<typeof useBrokerageOperations>,
): AccountPageRecentOperations {
const to = `/accounts/${accountId}/operations`;
if (operations.isLoading) return { state: "loading", to };
if (operations.isError) return { state: "error", to };
if (
!operations.data &&
!operations.isError &&
operations.meta?.sync.state !== "failed"
)
return { state: "loading", to };
if (operations.meta?.availability === "partial")
return { state: "partial", to, items: operations.data?.items ?? [] };
if (
!operations.data &&
(operations.isError || operations.meta?.sync.state === "failed")
)
return { state: "error", to };
if (!operations.data?.items.length) return { state: "empty", to };
return { state: "loaded", to, items: operations.data.items };
return {
state: "loaded",
to,
items: operations.data.items,
lastSuccessfulAt: operations.meta?.lastSuccessfulAt,
stale: operations.meta?.freshness === "stale",
refreshError:
operations.isError || operations.meta?.sync.state === "failed",
};
}
function toRecentEvents(
@@ -176,11 +228,26 @@ function toRecentEvents(
events: ReturnType<typeof useBrokerageEvents>,
): AccountPageRecentEvents {
const to = `/accounts/${accountId}/events`;
if (events.isLoading) return { state: "loading", to };
if (events.isError) return { state: "error", to };
if (!events.data && !events.isError && events.meta?.sync.state !== "failed")
return { state: "loading", to };
if (events.meta?.availability === "partial")
return {
state: "partial",
to,
items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [],
};
if (!events.data && (events.isError || events.meta?.sync.state === "failed"))
return { state: "error", to };
const items = events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [];
if (!items.length) return { state: "empty", to };
return { state: "loaded", to, items };
return {
state: "loaded",
to,
items,
lastSuccessfulAt: events.meta?.lastSuccessfulAt,
stale: events.meta?.freshness === "stale",
refreshError: events.isError || events.meta?.sync.state === "failed",
};
}
function toAccountPagePositionSections(
@@ -19,6 +19,7 @@ type AccountPageEmptyProps = {
account?: BrokerageAccount;
accountId?: string;
refreshing?: boolean;
sourceError?: boolean;
refreshError?: boolean;
retry?: () => void;
recentOperations: AccountPageRecentOperations;
@@ -33,6 +34,7 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
<AccountHeading account={props.account} accountId={props.accountId} />
<PortfolioRefreshState
refreshing={props.refreshing ?? false}
sourceError={props.sourceError}
refreshError={props.refreshError ?? false}
retry={props.retry}
/>
@@ -23,9 +23,11 @@ import { RecentOperations } from "./RecentOperations.view";
type AccountPageLoadedProps = {
account?: BrokerageAccount;
accountId?: string;
partial?: boolean;
portfolio: Portfolio;
positionSections: AccountPagePositionSection[];
refreshing?: boolean;
sourceError?: boolean;
refreshError?: boolean;
retry?: () => void;
recentOperations: AccountPageRecentOperations;
@@ -40,6 +42,7 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
<AccountHeading account={props.account} accountId={props.accountId} />
<PortfolioRefreshState
refreshing={props.refreshing ?? false}
sourceError={props.sourceError}
refreshError={props.refreshError ?? false}
retry={props.retry}
/>
@@ -56,11 +59,17 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
},
)}
</Text>
<CurrencyCards amounts={props.portfolio.totalsByCurrency} />
<PortfolioStructure
key={props.accountId}
structure={props.portfolio.structure}
/>
{props.partial ? (
<Alert role="status">{t("investment.partial")}</Alert>
) : (
<CurrencyCards amounts={props.portfolio.totalsByCurrency} />
)}
{!props.partial ? (
<PortfolioStructure
key={props.accountId}
structure={props.portfolio.structure}
/>
) : null}
<Accordion
multiple
defaultValue={[
@@ -4,18 +4,34 @@ import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type { IncomeSummary as IncomeSummaryData } from "@/entities/income";
import { formatMoney } from "@/shared/lib";
import { formatMoney, formatSnapshotTime } from "@/shared/lib";
type IncomeSummaryProps =
| { state: "loading"; to: string }
| { state: "partial"; to: string }
| { state: "error"; to: string; retry: () => void }
| { state: "empty"; to: string }
| { state: "loaded"; to: string; summary: IncomeSummaryData };
| {
state: "loaded";
to: string;
summary: IncomeSummaryData;
lastSuccessfulAt?: string | null;
stale?: boolean;
refreshError?: boolean;
};
const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
const { t } = useTranslation();
if (props.state === "loading")
return <Text c="dimmed">{t("income.summaryLoading")}</Text>;
if (props.state === "loading" || props.state === "partial")
return (
<Text role="status" c="dimmed">
{t(
props.state === "partial"
? "investment.partial"
: "income.summaryLoading",
)}
</Text>
);
if (props.state === "error")
return (
<StateView
@@ -38,6 +54,17 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
);
return (
<Stack gap="xs">
{props.lastSuccessfulAt ? (
<Text size="xs" c="dimmed">
{t("investment.sourceTime", {
date: formatSnapshotTime(props.lastSuccessfulAt),
})}
</Text>
) : null}
{props.stale ? <Text size="xs">{t("investment.stale")}</Text> : null}
{props.refreshError ? (
<Text role="status">{t("income.refreshError")}</Text>
) : null}
{props.summary.totalsByCurrency.map((total) => (
<Paper key={total.currency} p="sm" withBorder>
<Text fw={700}>{formatMoney(total.total)}</Text>
@@ -47,3 +47,8 @@ export const RefreshError: Story = {
name: "Ошибка обновления с retry",
args: { refreshError: true },
};
export const SourceFailure: Story = {
name: "Ошибка синхронизации сохраняет суммы",
args: { refreshError: true, sourceError: true },
};
@@ -3,6 +3,7 @@ import type { FC } from "react";
import { useTranslation } from "react-i18next";
type PortfolioRefreshStateProps = {
sourceError?: boolean;
refreshing: boolean;
refreshError: boolean;
retry?: () => void;
@@ -11,10 +12,17 @@ type PortfolioRefreshStateProps = {
const PortfolioRefreshState: FC<PortfolioRefreshStateProps> = (props) => {
const { refreshing, refreshError, retry } = props;
const { t } = useTranslation();
if (!refreshing && !refreshError) return null;
return (
<>
<Button
onClick={retry}
disabled={refreshing}
variant="light"
w="fit-content"
>
{t(refreshError ? "accounts.retry" : "investment.refresh")}
</Button>
{refreshing ? (
<Group gap="xs" role="status" aria-label={t("portfolio.refreshing")}>
<Loader size="sm" />
@@ -24,12 +32,15 @@ const PortfolioRefreshState: FC<PortfolioRefreshStateProps> = (props) => {
</Group>
) : null}
{refreshError ? (
<Alert color="orange">
<Alert color="orange" role="status" aria-live="polite">
<Stack gap="sm">
<Text>{t("portfolio.refreshError")}</Text>
<Button onClick={retry} variant="light">
{t("accounts.retry")}
</Button>
<Text>
{t(
props.sourceError
? "investment.syncFailed"
: "portfolio.refreshError",
)}
</Text>
</Stack>
</Alert>
) : null}
@@ -4,6 +4,7 @@ import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { LedgerEntryRow } from "@/entities/broker-operation";
import { formatSnapshotTime } from "@/shared/lib";
import type { AccountPageRecentOperations } from "../AccountPage.vm";
type RecentOperationsProps = {
@@ -16,6 +17,23 @@ const RecentOperations: FC<RecentOperationsProps> = (props) => {
return (
<Stack gap="sm">
{operations.state === "loaded" ? (
<>
{operations.lastSuccessfulAt ? (
<Text size="xs" c="dimmed">
{t("investment.sourceTime", {
date: formatSnapshotTime(operations.lastSuccessfulAt),
})}
</Text>
) : null}
{operations.stale ? (
<Text size="xs">{t("investment.stale")}</Text>
) : null}
{operations.refreshError ? (
<Text role="status">{t("accounts.refreshError")}</Text>
) : null}
</>
) : null}
{operations.state === "loading" ? (
<LoadingSkeleton label={t("ledger.recentLoading")}>
{[0, 1, 2].map((index) => (
@@ -29,7 +47,10 @@ const RecentOperations: FC<RecentOperationsProps> = (props) => {
{operations.state === "empty" ? (
<Text c="dimmed">{t("ledger.recentEmpty")}</Text>
) : null}
{operations.state === "loaded"
{operations.state === "partial" ? (
<Text role="status">{t("investment.partial")}</Text>
) : null}
{operations.state === "loaded" || operations.state === "partial"
? operations.items.map((operation) => (
<LedgerEntryRow
key={JSON.stringify([