feat: render hourly investment states
Frontend verify / commits (pull_request) Successful in 45s
Frontend verify / security (pull_request) Successful in 14s
Frontend verify / verify (pull_request) Successful in 6m45s

This commit is contained in:
2026-10-09 19:56:20 +03:00
parent 734c5bdc2e
commit 6b1b26aa34
36 changed files with 1388 additions and 1238 deletions
@@ -189,14 +189,19 @@ describe("AccountsPage", () => {
});
});
it.each(["idle", "failed"] as const)(
"keeps partial empty membership distinct from no accounts (sync=%s)",
it.each(["none", "failed"] as const)(
"keeps incomplete empty membership distinct from no accounts (attempt=%s)",
async (state) => {
server.use(
http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json(resource([], {
availability: "partial",
sync: { errorCode: null, id: null, state },
completeness: "incomplete",
lastAttempt: {
finishedAt: state === "failed" ? "2026-08-14T00:01:00Z" : null,
problems: [],
startedAt: state === "none" ? null : "2026-08-14T00:00:00Z",
state,
},
})),
),
);
@@ -205,7 +210,7 @@ it.each(["idle", "failed"] as const)(
await waitFor(() =>
expect(client.getQueryData(brokerageAccountsQueryKey)).toMatchObject({
data: [],
meta: { availability: "partial" },
meta: { completeness: "incomplete" },
}),
);
expect(screen.queryByText("Нет инвестиционных счетов"))
@@ -15,7 +15,7 @@ const AccountsPage: FC = () => {
return (
<AccountsPageLoaded
accounts={viewModel.accounts}
sync={viewModel.sync}
meta={viewModel.meta}
/>
);
};
@@ -4,17 +4,15 @@ import {
useBrokerageAccounts,
} from "@/entities/brokerage-account";
import { useScreenSync } from "@/features/tbank-connection";
import { ApiErrorCode, apiErrorCode, isApiErrorCode, type paths } from "@/shared/api";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
import { useInitialAvailability } from "@/shared/lib";
type AccountsPageViewModel
= | { accounts: BrokerageAccountCard[]; state: "loaded"; sync: AccountsSync }
= | { accounts: BrokerageAccountCard[]; meta: NonNullable<ReturnType<typeof useBrokerageAccounts>["meta"]>; state: "loaded" }
| { code?: string; state: "error" }
| { state: "empty" }
| { state: "loading" };
type AccountsSync = paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"]["sync"];
function isAccountsAccessFailure(code?: string) {
return isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)
|| isApiErrorCode(code, ApiErrorCode.TbankNotConnected);
@@ -28,12 +26,13 @@ function useAccountsPageViewModel(): AccountsPageViewModel {
const isAccessFailure = isAccountsAccessFailure(code);
const hasResult = !!accounts.data
&& !(accounts.meta?.availability === "partial" && accounts.data.length === 0);
&& !!accounts.meta
&& !(accounts.meta.completeness === "incomplete" && accounts.data.length === 0);
const availability = useInitialAvailability({
hasContent: hasResult,
identity: "accounts",
isError: accounts.isError || accounts.meta?.sync.state === "failed",
isError: accounts.isError || accounts.meta?.lastAttempt.state === "failed",
isFetching: accounts.isFetching,
});
@@ -49,10 +48,13 @@ function useAccountsPageViewModel(): AccountsPageViewModel {
state: "empty" as const,
};
const meta = accounts.meta;
if (!meta) return { state: "loading" as const };
return {
accounts: brokerageAccountCards(accounts.data, accounts.meta?.dependencies),
accounts: brokerageAccountCards(accounts.data, meta.dependencies),
meta,
state: "loaded" as const,
sync: accounts.sync,
};
}
@@ -1,45 +1,24 @@
import type { FC } from "react";
import { Badge, Button, Stack, Text, Title } from "@mantine/core";
import { Button, Stack, Text, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type { InvestmentResourceMeta } from "@/shared/api";
import {
type BrokerageAccountCard,
BrokerageAccountList,
} from "@/entities/brokerage-account";
import { SourceSyncStatus } from "@/shared/ui";
type AccountsPageLoadedProps = {
accounts: BrokerageAccountCard[];
sync:
| null
| undefined
| {
finishedAt: null | string;
sections: {
accountId?: string;
errorCode: null | string;
section: string;
}[];
startedAt: string;
state: string;
};
meta: InvestmentResourceMeta;
};
const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
const { t } = useTranslation();
const sync = props.sync;
const syncLabel = sync
? new Intl.DateTimeFormat("ru-RU", {
dateStyle: "short",
timeStyle: "short",
}).format(new Date(sync.finishedAt ?? sync.startedAt))
: null;
const syncColor = sync?.state === "failed" || sync?.state === "partial"
? "red"
: "gray";
return (
<Stack gap="lg">
@@ -50,20 +29,11 @@ const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
{t("accounts.subtitle")}
</Text>
{sync && (
<Text c="dimmed" size="xs">
Последний обход:
{" "}
{syncLabel}
{" "}
<Badge
color={syncColor}
size="xs"
>
{sync.state}
</Badge>
</Text>
)}
<SourceSyncStatus
lastAttempt={props.meta.lastAttempt}
lastSuccessfulAt={props.meta.lastSuccessfulAt}
stale={props.meta.freshness === "stale"}
/>
</Stack>
<Button