feat: render hourly investment states #51

Merged
ksv741 merged 3 commits from codex/reconcile-hourly-investment-sync into develop 2026-10-09 21:13:35 +03:00
39 changed files with 1470 additions and 1241 deletions
@@ -15,6 +15,28 @@
min-width: 0;
}
.totals {
display: flex;
flex-wrap: wrap;
gap: var(--mantine-spacing-xs) var(--mantine-spacing-lg);
margin: 0;
}
.totals > div {
display: grid;
gap: 0;
}
.totals dt {
color: var(--mantine-color-dimmed);
font-size: var(--mantine-font-size-xs);
}
.totals dd {
margin: 0;
font-variant-numeric: tabular-nums;
}
.categories {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -0,0 +1,35 @@
import { MantineProvider } from "@mantine/core";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { BankSummaryView } from "./BankSummary.view";
const money = (value: string) => ({ currency: "RUB", value });
describe("BankSummaryView", () => {
it("shows expenses, refunds, and net monthly totals", () => {
render(
<MantineProvider env="test">
<BankSummaryView
data={{
cardId: "card-1",
categories: [],
expenses: money("1000"),
net: money("800"),
refunds: money("200"),
}}
error={false}
fetching={false}
loading={false}
/>
</MantineProvider>,
);
expect(screen.getAllByText("Расходы")).toHaveLength(2);
expect(screen.getByText("Возвращено")).toBeInTheDocument();
expect(screen.getByText("После возвратов")).toBeInTheDocument();
expect(screen.getByText("1 000,00 ₽")).toBeInTheDocument();
expect(screen.getByText("200,00 ₽")).toBeInTheDocument();
expect(screen.getByText("800,00 ₽")).toBeInTheDocument();
});
});
@@ -92,9 +92,31 @@ const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
</Title>
{props.data && (
<Text className={classes.total}>
{formatMoney(props.data.net, { withZeroCents: true })}
</Text>
<dl className={classes.totals}>
<div>
<dt>{t("bank.summary")}</dt>
<dd>
{formatMoney(props.data.expenses, { withZeroCents: true })}
</dd>
</div>
<div>
<dt>{t("bank.refunds")}</dt>
<dd>
{formatMoney(props.data.refunds, { withZeroCents: true })}
</dd>
</div>
<div>
<dt>{t("bank.net")}</dt>
<dd className={classes.total}>
{formatMoney(props.data.net, { withZeroCents: true })}
</dd>
</div>
</dl>
)}
</div>
@@ -10,8 +10,6 @@ type BrokerageAccount = NonNullable<
paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"]["data"]
>[number];
type BrokerageAccountsResponse = paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"];
function useBrokerageAccounts() {
const query = useResourceQuery({
meta: { accessGroup: "brokerage" },
@@ -21,7 +19,6 @@ function useBrokerageAccounts() {
return {
...query,
sync: query.sync as BrokerageAccountsResponse["sync"] | null,
};
}
@@ -205,24 +205,32 @@ export const IndependentFreshness: Story
],
[
{
availability: "complete",
availability: "available",
completeness: "complete",
dataRevision: "1",
freshness: "stale",
lastSuccessfulAt: "2026-09-24T10:30:00Z",
resourceKey: "portfolio/storybook-account",
sync: {
errorCode: "TBANK_UNAVAILABLE",
id: "failed",
lastAttempt: {
finishedAt: "2026-09-24T10:31:00Z",
problems: [],
startedAt: "2026-09-24T10:30:00Z",
state: "failed",
},
lastSuccessfulAt: "2026-09-24T10:30:00Z",
resourceKey: "portfolio/storybook-account",
},
{
availability: "missing",
availability: "initial-loading",
completeness: "incomplete",
dataRevision: "0",
freshness: "unknown",
lastAttempt: {
finishedAt: null,
problems: [],
startedAt: "2026-09-24T10:30:00Z",
state: "running",
},
lastSuccessfulAt: null,
resourceKey: "portfolio/waiting",
sync: { errorCode: null, id: "waiting", state: "running" },
},
],
),
@@ -64,15 +64,16 @@ const BrokerageAccountSourceStatus: FC<BrokerageAccountSourceStatusProps> = (
const { source } = props;
const { t } = useTranslation();
const isSyncing = source?.sync.state === "running";
const isSyncing = source?.lastAttempt.state === "running";
const errorText
= source?.sync.state === "failed" ? t("accounts.refreshError") : null;
= source?.lastAttempt.state === "failed" ? t("accounts.refreshError") : null;
return (
<>
<SourceSyncStatus
errorText={errorText}
lastAttempt={source?.lastAttempt}
lastSuccessfulAt={source?.lastSuccessfulAt}
stale={source?.freshness === "stale"}
/>
@@ -5,20 +5,23 @@ import { resource } from "@/test/resource";
import { section } from "./section";
describe("instrument source and transport presentation", () => {
it.each(["missing", "partial", "complete"] as const)(
it.each([
["initial-loading", "incomplete"],
["available", "complete"],
] as const)(
"keeps %s separate from successful transport",
(availability) => {
const data = availability === "missing" ? undefined : { rows: [] };
(availability, completeness) => {
const data = availability === "initial-loading" ? undefined : { rows: [] };
const state = section({
data,
isError: false,
meta: resource(data, { availability }).meta,
meta: resource(data, { availability, completeness }).meta,
});
expect(state.loading).toBe(availability === "missing");
expect(state.partial).toBe(availability === "partial");
expect(state.hasData).toBe(availability !== "missing");
expect(state.loading).toBe(availability === "initial-loading");
expect(state.partial).toBe(completeness === "incomplete");
expect(state.hasData).toBe(availability !== "initial-loading");
},
);
it("preserves empty successful data instead of treating it as missing", () => {
@@ -37,7 +40,12 @@ describe("instrument source and transport presentation", () => {
it("preserves a full source when force fails", () => {
const meta = resource([], {
sync: { errorCode: "TBANK_UNAVAILABLE", id: "work", state: "failed" },
lastAttempt: {
finishedAt: "2026-08-14T00:01:00Z",
problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed",
},
}).meta;
const state = section({ data: [], isError: false, meta });
@@ -53,7 +61,12 @@ describe("instrument source and transport presentation", () => {
data: undefined,
isError: false,
meta: resource(null, {
sync: { errorCode: "TBANK_UNAVAILABLE", id: "work", state: "failed" },
lastAttempt: {
finishedAt: "2026-08-14T00:01:00Z",
problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed",
},
}).meta,
});
@@ -16,14 +16,14 @@ function section(query: {
isError: boolean;
meta?: InvestmentResourceMeta;
}): SectionState {
const isError = query.isError || query.meta?.sync.state === "failed";
const isError = query.isError || query.meta?.lastAttempt.state === "failed";
const hasData = hasSectionData(query.data);
return {
error: isError,
hasData,
loading: !hasData && !isError,
partial: query.meta?.availability === "partial",
partial: query.meta?.completeness === "incomplete",
};
}
@@ -19,12 +19,14 @@ function auth() {
}
describe("read-only screen synchronization", () => {
it("retries the resource with GET and never starts a sync POST", async () => {
it("retries the resource with GET without requesting source observation", async () => {
auth();
let gets = 0;
const syncModes: (null | string)[] = [];
server.use(
http.get("*/api/v1/brokerage-accounts", () => {
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
gets += 1;
syncModes.push(new URL(request.url).searchParams.get("sync"));
return gets === 1
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
@@ -49,6 +51,7 @@ describe("read-only screen synchronization", () => {
await waitFor(() => expect(result.current.accounts.data).toEqual([]));
expect(gets).toBe(2);
expect(syncModes).toEqual([null, null]);
});
it("refreshes the currently open resources together", async () => {
@@ -8,7 +8,6 @@ type ScreenResource = {
isError: boolean;
isFetching: boolean;
meta?: InvestmentResourceMeta;
observe: () => Promise<unknown>;
queryKey: QueryKey;
refetch: () => Promise<unknown>;
};
@@ -42,14 +41,12 @@ function useScreenSync(_scope: ScreenScope, resources: ScreenResource[]) {
await Promise.allSettled(resources.map(resource => resource.refetch()));
};
// Retry failed HTTP reads without asking the source to import again.
// Retry only the HTTP read; source attempts are reported in resource metadata.
const retry = async () => {
const failed = resources.filter(
resource => resource.isError,
);
const failed = resources.filter(resource => resource.isError);
if (failed.length > 0)
await Promise.allSettled(failed.map(resource => resource.observe()));
await Promise.allSettled(failed.map(resource => resource.refetch()));
else await refresh();
};
@@ -57,7 +54,9 @@ function useScreenSync(_scope: ScreenScope, resources: ScreenResource[]) {
refresh,
refreshing: isRefreshing,
retry,
sourceError: resources.some(({ meta }) => meta?.sync.state === "failed"),
sourceError: resources.some(
({ meta }) => meta?.lastAttempt.state === "failed",
),
transportError: resources.some(resource => resource.isError),
};
}
@@ -123,9 +123,10 @@ describe("Upcoming event date filters", () => {
resource(
null,
{
sync: {
errorCode: "TBANK_UNAVAILABLE",
id: null,
lastAttempt: {
finishedAt: "2026-08-14T00:01:00Z",
problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed",
},
},
@@ -309,7 +310,14 @@ it("retains the previous complete forecast when its source refresh fails", async
const snapshot = resource(
{ events, incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }] },
{ sync: { errorCode: "TBANK_UNAVAILABLE", id: "failed", state: "failed" } },
{
lastAttempt: {
finishedAt: "2026-08-14T00:01:00Z",
problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed",
},
},
);
server.use(
@@ -51,8 +51,9 @@ describe("AccountEventsPage", () => {
isError: false,
isLoading: false,
meta: {
availability: "complete",
sync: { errorCode: null, id: null, state: "idle" },
availability: "available",
completeness: "complete",
lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" },
},
refetch: vi.fn(),
} as never,
@@ -85,8 +86,9 @@ describe("AccountEventsPage", () => {
isError: false,
isLoading: false,
meta: {
availability: "complete",
sync: { errorCode: null, id: null, state: "idle" },
availability: "available",
completeness: "complete",
lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" },
},
refetch: vi.fn(),
} as never,
@@ -138,8 +140,9 @@ describe("AccountEventsPage", () => {
isError: false,
isLoading: false,
meta: {
availability: "complete",
sync: { errorCode: null, id: null, state: "idle" },
availability: "available",
completeness: "complete",
lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" },
},
refetch: vi.fn(),
} as never,
@@ -194,8 +197,9 @@ describe("AccountEventsPage", () => {
isError: false,
isLoading: true,
meta: {
availability: "complete",
sync: { errorCode: null, id: null, state: "idle" },
availability: "available",
completeness: "complete",
lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" },
},
refetch: vi.fn(),
} as never,
@@ -1,6 +1,8 @@
import { useState } from "react";
import { useParams } from "react-router";
import type { InvestmentResourceMeta } from "@/shared/api";
import {
type BrokerageEvent,
useBrokerageEvents,
@@ -25,6 +27,7 @@ type AccountEventsPageViewModel
onPeriodPopoverOpen: () => void;
onReset: () => void;
partial?: boolean;
source?: InvestmentResourceMeta;
state: "loaded";
}
| { onReset: () => void; state: "error" }
@@ -74,7 +77,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
const availability = useInitialAvailability({
hasContent: !!events.data,
identity: scope,
isError: events.isError || events.meta?.sync.state === "failed",
isError: events.isError || events.meta?.lastAttempt.state === "failed",
isFetching: events.isFetching,
});
@@ -86,7 +89,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
setSearchParameters({});
};
if ((events.isError || events.meta?.sync.state === "failed") && !events.data)
if ((events.isError || events.meta?.lastAttempt.state === "failed") && !events.data)
return {
onReset,
state: "error",
@@ -115,7 +118,8 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
),
onPeriodPopoverOpen: () => setMaxDate(moscowDate(new Date())),
onReset,
partial: events.meta?.availability !== "complete",
partial: events.meta?.completeness === "incomplete",
source: events.meta,
state: "loaded",
};
}
@@ -15,6 +15,7 @@ import {
BrokerageEventCard,
brokerageEventKey,
} from "@/entities/brokerage-event";
import { SourceSyncStatus } from "@/shared/ui";
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
@@ -55,6 +56,12 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
<Stack gap="lg">
<Title order={1}>{t("events.title")}</Title>
<SourceSyncStatus
lastAttempt={props.source?.lastAttempt}
lastSuccessfulAt={props.source?.lastSuccessfulAt}
stale={props.source?.freshness === "stale"}
/>
{!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>}
<Text c="dimmed">{t("events.hint")}</Text>
@@ -136,13 +136,14 @@ it("maps comparison notices by role regardless of dependency order or resource k
const previous
= {
...resource(null).meta,
resourceKey: "opaque/year-looking-key",
role: "previous" as const,
sync: {
errorCode: "TBANK_UNAVAILABLE",
id: null,
lastAttempt: {
finishedAt: "2026-08-14T00:01:00Z",
problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed" as const,
},
resourceKey: "opaque/year-looking-key",
role: "previous" as const,
};
const year = {
+1 -1
View File
@@ -66,7 +66,7 @@ function comparisonNotice(
): ComparisonNotice {
if (!dependency || dependency.unavailableReason) return unavailable;
return dependency.sync.state === "failed"
return dependency.lastAttempt.state === "failed"
? "comparisonFailed"
: "comparisonPending";
}
@@ -205,9 +205,10 @@ describe("AccountIncomePage", () => {
resource(
null,
{
sync: {
errorCode: "TBANK_UNAVAILABLE",
id: null,
lastAttempt: {
finishedAt: "2026-08-14T00:01:00Z",
problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed",
},
},
@@ -832,12 +833,13 @@ it("keeps complete previous comparison while year source failed without inventin
},
{
...resource(null).meta,
role: "year" as const,
sync: {
errorCode: "TBANK_UNAVAILABLE",
id: null,
lastAttempt: {
finishedAt: "2026-08-14T00:01:00Z",
problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed" as const,
},
role: "year" as const,
},
{
...resource(analytics).meta,
@@ -79,7 +79,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
const availability = useInitialAvailability({
hasContent: !!analytics.data,
identity: `${accountId}:${period.from}:${period.to}`,
isError: analytics.isError || analytics.meta?.sync.state === "failed",
isError: analytics.isError || analytics.meta?.lastAttempt.state === "failed",
isFetching: analytics.isFetching,
});
@@ -114,7 +114,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
formRevision: `${accountId}:${period.from}:${period.to}:${resetRevision}`,
onReset,
onSubmit,
partial: analytics.meta?.availability !== "complete",
partial: analytics.meta?.completeness === "incomplete",
state: "loaded",
};
};
@@ -507,9 +507,10 @@ it("keeps UTC operation boundaries in GET and source sync requests", async () =>
resource(
null,
{
sync: {
errorCode: "TBANK_UNAVAILABLE",
id: null,
lastAttempt: {
finishedAt: "2026-08-14T00:01:00Z",
problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed",
},
},
@@ -543,7 +544,7 @@ it("does not label incomplete empty operations as a confirmed empty period", asy
items: [],
nextCursor: null,
},
{ availability: "partial" },
{ completeness: "incomplete" },
),
),
),
@@ -2,6 +2,8 @@ import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useParams } from "react-router";
import type { InvestmentResourceMeta } from "@/shared/api";
import {
type BrokerageOperation,
brokerageOperationsQueryOptions,
@@ -39,6 +41,7 @@ type AccountOperationsPageViewModel
onPrevious: () => void;
onReset: () => void;
partial?: boolean;
source?: InvestmentResourceMeta;
state: "loaded";
}
| { onReset: () => void; state: "error" }
@@ -98,7 +101,7 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
const availability = useInitialAvailability({
hasContent: !!operations.data,
identity: scope,
isError: operations.isError || operations.meta?.sync.state === "failed",
isError: operations.isError || operations.meta?.lastAttempt.state === "failed",
isFetching: operations.isFetching,
});
@@ -127,7 +130,7 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
if (
(isInvalidCursor || !operations.data)
&& (operations.isError || operations.meta?.sync.state === "failed")
&& (operations.isError || operations.meta?.lastAttempt.state === "failed")
)
return { onReset, state: "error" };
@@ -177,7 +180,8 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
onPeriodPopoverOpen: () => setMaxDate(moscowDate(new Date())),
onPrevious: history.onPrevious,
onReset,
partial: operations.meta?.availability !== "complete",
partial: operations.meta?.completeness === "incomplete",
source: operations.meta,
state: "loaded",
};
};
@@ -13,6 +13,7 @@ import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import { LedgerEntryRow, operationCategories } from "@/entities/broker-operation";
import { SourceSyncStatus } from "@/shared/ui";
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
@@ -62,6 +63,12 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
<Stack gap="lg">
<Title order={1}>{t("ledger.title")}</Title>
<SourceSyncStatus
lastAttempt={props.source?.lastAttempt}
lastSuccessfulAt={props.source?.lastSuccessfulAt}
stale={props.source?.freshness === "stale"}
/>
{!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>}
{!!props.historyLoading && <Alert>{t("investment.history")}</Alert>}
+12 -11
View File
@@ -46,9 +46,10 @@ type PreviewResource<T> = {
isError: boolean;
meta?: Pick<InvestmentResourceMeta,
"availability"
| "completeness"
| "freshness"
| "lastAttempt"
| "lastSuccessfulAt"
| "sync"
>;
};
@@ -57,10 +58,10 @@ function toIncomeSummary(
income: PreviewResource<IncomeSummary>,
): AccountPageIncomeSummary {
const to = `/accounts/${accountId}/income`;
if (!income.data && !income.isError && income.meta?.sync.state !== "failed")
if (!income.data && !income.isError && income.meta?.lastAttempt.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"))
if (income.meta?.completeness === "incomplete") return { state: "partial", to };
if (!income.data && (income.isError || income.meta?.lastAttempt.state === "failed"))
return { state: "error", to };
if (!income.data?.totalsByCurrency.length) return { state: "empty", to };
@@ -76,15 +77,15 @@ function toRecentEvents(
events: PreviewResource<{ events: BrokerageEvent[] }>,
): AccountPageRecentEvents {
const to = `/accounts/${accountId}/events`;
if (!events.data && !events.isError && events.meta?.sync.state !== "failed")
if (!events.data && !events.isError && events.meta?.lastAttempt.state !== "failed")
return { state: "loading", to };
if (events.meta?.availability === "partial")
if (events.meta?.completeness === "incomplete")
return {
items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [],
state: "partial",
to,
};
if (!events.data && (events.isError || events.meta?.sync.state === "failed"))
if (!events.data && (events.isError || events.meta?.lastAttempt.state === "failed"))
return { state: "error", to };
const items = events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [];
if (items.length === 0) return { state: "empty", to };
@@ -104,14 +105,14 @@ function toRecentOperations(
if (
!operations.data
&& !operations.isError
&& operations.meta?.sync.state !== "failed"
&& operations.meta?.lastAttempt.state !== "failed"
)
return { state: "loading", to };
if (operations.meta?.availability === "partial")
if (operations.meta?.completeness === "incomplete")
return { items: operations.data?.items ?? [], state: "partial", to };
if (
!operations.data
&& (operations.isError || operations.meta?.sync.state === "failed")
&& (operations.isError || operations.meta?.lastAttempt.state === "failed")
)
return { state: "error", to };
if (!operations.data?.items.length) return { state: "empty", to };
@@ -120,7 +121,7 @@ function toRecentOperations(
items: operations.data.items,
lastSuccessfulAt: operations.meta?.lastSuccessfulAt,
refreshError:
operations.isError || operations.meta?.sync.state === "failed",
operations.isError || operations.meta?.lastAttempt.state === "failed",
stale: operations.meta?.freshness === "stale",
state: "loaded",
to,
@@ -173,14 +173,14 @@ it("keeps overview sections independent when their source data is incomplete", a
mockLoadedAccountPage();
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(resource(portfolio, { availability: "partial" })),
HttpResponse.json(resource(portfolio, { completeness: "incomplete" })),
),
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
HttpResponse.json(
resource(
{ hasNext: false, items: [], nextCursor: null },
{ availability: "partial" },
{ completeness: "incomplete" },
),
),
),
@@ -188,7 +188,7 @@ it("keeps overview sections independent when their source data is incomplete", a
HttpResponse.json(
resource(
{ events: [], incomeTotalsByCurrency: [] },
{ availability: "partial" },
{ completeness: "incomplete" },
),
),
),
@@ -1,6 +1,7 @@
import { useParams } from "react-router";
import type { Portfolio } from "@/entities/portfolio";
import type { InvestmentResourceMeta } from "@/shared/api";
import { useBrokerageOperations } from "@/entities/broker-operation";
import {
@@ -45,6 +46,7 @@ type AccountPageEmptyViewModel = AccountPageContent & {
type AccountPageLoadedViewModel = AccountPageContent & {
portfolio: Portfolio;
positionSections: AccountPagePositionSection[];
source?: InvestmentResourceMeta;
state: "loaded";
};
@@ -87,7 +89,7 @@ function useAccountPageViewModel(): AccountPageViewModel {
const availability = useInitialAvailability({
hasContent: !!portfolio.data,
identity: accountId ?? "account",
isError: portfolio.isError || portfolio.meta?.sync.state === "failed",
isError: portfolio.isError || portfolio.meta?.lastAttempt.state === "failed",
isFetching: portfolio.isFetching,
});
@@ -105,7 +107,7 @@ function useAccountPageViewModel(): AccountPageViewModel {
const incomeSummary = toIncomeSummary(accountId, income);
if (
portfolio.meta?.availability === "complete"
portfolio.meta?.availability === "available"
&& portfolio.data.positions.length === 0
&& portfolio.data.cashBalances.length === 0
)
@@ -118,7 +120,7 @@ function useAccountPageViewModel(): AccountPageViewModel {
state: "empty" as const,
};
const isPartial = portfolio.meta?.availability === "partial";
const isPartial = portfolio.meta?.completeness === "incomplete";
return {
account,
@@ -129,6 +131,7 @@ function useAccountPageViewModel(): AccountPageViewModel {
positionSections: toAccountPagePositionSections(portfolio.data),
recentEvents,
recentOperations,
source: portfolio.meta,
state: "loaded" as const,
};
}
@@ -5,8 +5,10 @@ import { useTranslation } from "react-i18next";
import type { BrokerageAccount } from "@/entities/brokerage-account";
import type { Portfolio } from "@/entities/portfolio";
import type { InvestmentResourceMeta } from "@/shared/api";
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
import { SourceSyncStatus } from "@/shared/ui";
import { PortfolioStructure } from "@/widgets/portfolio-structure";
import type { AccountPagePositionSection } from "../../../model/positions";
@@ -32,6 +34,7 @@ type AccountPageLoadedProps = {
positionSections: AccountPagePositionSection[];
recentEvents: AccountPageRecentEvents;
recentOperations: AccountPageRecentOperations;
source?: InvestmentResourceMeta;
};
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
@@ -49,6 +52,12 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
<Stack gap="lg">
<AccountHeading account={props.account} accountId={props.accountId} />
<SourceSyncStatus
lastAttempt={props.source?.lastAttempt}
lastSuccessfulAt={props.source?.lastSuccessfulAt}
stale={props.source?.freshness === "stale"}
/>
{props.partial
? <Alert role="status">{t("investment.partial")}</Alert>
: <CurrencyCards amounts={props.portfolio.totalsByCurrency} />}
@@ -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
+3 -3
View File
@@ -17,7 +17,7 @@ function useBondsPageVm() {
code: apiErrorCode(result.error),
error:
!result.data
&& (result.isError || result.meta?.sync.state === "failed"),
&& (result.isError || result.meta?.lastAttempt.state === "failed"),
items:
result.data?.map(bond => ({
...bond,
@@ -29,9 +29,9 @@ function useBondsPageVm() {
&& (isWaiting
|| (!result.data
&& !result.isError
&& result.meta?.sync.state !== "failed")),
&& result.meta?.lastAttempt.state !== "failed")),
onQueryChange: search.onQueryChange,
partial: result.meta?.availability === "partial",
partial: result.meta?.completeness === "incomplete",
query,
ready:
debounced.length >= MIN_BOND_SEARCH_LENGTH
@@ -17,7 +17,7 @@ function useStocksPageVm() {
code: apiErrorCode(result.error),
error:
!result.data
&& (result.isError || result.meta?.sync.state === "failed"),
&& (result.isError || result.meta?.lastAttempt.state === "failed"),
items:
result.data?.map(stock => ({
...stock,
@@ -29,9 +29,9 @@ function useStocksPageVm() {
&& (isWaiting
|| (!result.data
&& !result.isError
&& result.meta?.sync.state !== "failed")),
&& result.meta?.lastAttempt.state !== "failed")),
onQueryChange: search.onQueryChange,
partial: result.meta?.availability === "partial",
partial: result.meta?.completeness === "incomplete",
query,
ready:
debounced.length >= MIN_STOCK_SEARCH_LENGTH
+390 -381
View File
@@ -276,22 +276,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/tbank-sync/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["TbankSyncController_status"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/brokerage-accounts/all/operations": {
parameters: {
query?: never;
@@ -324,38 +308,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/brokerage-accounts/all/events": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["UpcomingEventsController_all"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/brokerage-accounts/{accountId}/events": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["UpcomingEventsController_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/brokerage-accounts/all/income-summary": {
parameters: {
query?: never;
@@ -420,6 +372,38 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/brokerage-accounts/all/events": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["UpcomingEventsController_all"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/brokerage-accounts/{accountId}/events": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["UpcomingEventsController_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/stocks": {
parameters: {
query?: never;
@@ -692,6 +676,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/tbank-sync/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["TbankSyncController_status"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
@@ -899,24 +899,35 @@ export interface components {
/** @enum {string} */
historyState: "missing" | "loading" | "partial" | "complete" | "failed";
};
InvestmentSyncDto: {
id: string | null;
InvestmentAttemptProblemDto: {
/** @enum {string} */
state: "idle" | "running" | "failed";
section: "membership" | "portfolio" | "operations" | "events";
accountId: string | null;
errorCode: string | null;
};
InvestmentAttemptDto: {
/** @enum {string} */
state: "none" | "running" | "succeeded" | "partial" | "failed";
/** Format: date-time */
startedAt: string | null;
/** Format: date-time */
finishedAt: string | null;
problems: components["schemas"]["InvestmentAttemptProblemDto"][];
};
InvestmentDependencyDto: {
dataRevision: string;
unavailableReason?: string;
coverage?: components["schemas"]["InvestmentCoverageDto"];
resourceKey: string;
/** @enum {string} */
availability: "missing" | "partial" | "complete";
availability: "available" | "initial-loading" | "temporarily-unavailable" | "connection-unavailable";
/** @enum {string} */
completeness: "complete" | "incomplete";
/** @enum {string} */
freshness: "unknown" | "fresh" | "stale";
/** Format: date-time */
lastSuccessfulAt: string | null;
sync: components["schemas"]["InvestmentSyncDto"];
lastAttempt: components["schemas"]["InvestmentAttemptDto"];
};
InvestmentResourceMetaDto: {
dataRevision: string;
@@ -924,22 +935,16 @@ export interface components {
coverage?: components["schemas"]["InvestmentCoverageDto"];
resourceKey: string;
/** @enum {string} */
availability: "missing" | "partial" | "complete";
availability: "available" | "initial-loading" | "temporarily-unavailable" | "connection-unavailable";
/** @enum {string} */
completeness: "complete" | "incomplete";
/** @enum {string} */
freshness: "unknown" | "fresh" | "stale";
/** Format: date-time */
lastSuccessfulAt: string | null;
sync: components["schemas"]["InvestmentSyncDto"];
lastAttempt: components["schemas"]["InvestmentAttemptDto"];
dependencies?: components["schemas"]["InvestmentDependencyDto"][];
};
HourlySyncSectionDto: {
/** @enum {string} */
section: "membership" | "portfolio" | "operations" | "events";
/** @description Account identifier of the problematic section, when scoped. */
accountId?: string;
/** @description Safe error code of the failed section. No tokens or upstream payloads. */
errorCode: string | null;
};
HourlySyncStatusDto: {
/**
* @description Last hourly walk state. Cancelled means an interrupted attempt; its start still delays the next term.
@@ -950,7 +955,7 @@ export interface components {
startedAt: string;
/** Format: date-time */
finishedAt: string | null;
sections: components["schemas"]["HourlySyncSectionDto"][];
problems: components["schemas"]["InvestmentAttemptProblemDto"][];
};
BrokerageAccountsResourceDto: {
data: components["schemas"]["BrokerageAccountDto"][] | null;
@@ -1015,84 +1020,6 @@ export interface components {
data: components["schemas"]["PortfolioDto"] | null;
meta: components["schemas"]["InvestmentResourceMetaDto"];
};
EventsInvestmentScreenDto: {
accountId: string;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
screen: "events";
/**
* Format: date
* @description Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC.
*/
from?: string;
/**
* Format: date
* @description Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end.
*/
to?: string;
};
IncomeInvestmentScreenDto: {
accountId: string;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
screen: "income";
/**
* Format: date
* @description Inclusive start calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither for the current Moscow month.
*/
from?: string;
/**
* Format: date
* @description Inclusive end calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither. Timestamp queries use the start of the following Moscow day as the exclusive end, capped at now.
*/
to?: string;
};
SyncCandleRangeDto: {
/** Format: date-time */
from: string;
/** Format: date-time */
to: string;
};
TimestampInvestmentScreenDto: {
accountId?: string;
/** Format: uuid */
instrumentUid?: string;
query?: string;
/** @enum {string} */
candlePeriod?: "month" | "halfYear" | "year" | "all";
candleRange?: components["schemas"]["SyncCandleRangeDto"];
categories?: ("trade" | "income" | "tax" | "fee" | "transfer" | "other")[];
limit?: number;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
screen: "accounts" | "account" | "operations" | "stocks" | "bonds" | "stock" | "bond";
/**
* Format: date-time
* @description Start instant for operations.
*/
from?: string;
/**
* Format: date-time
* @description End instant for operations.
*/
to?: string;
};
TbankSyncStatusDto: {
/** Format: uuid */
id: string;
scope: (components["schemas"]["IncomeInvestmentScreenDto"] | components["schemas"]["EventsInvestmentScreenDto"] | components["schemas"]["TimestampInvestmentScreenDto"]) | null;
/** Format: date-time */
now: string;
/** @enum {string} */
state: "running" | "succeeded" | "failed" | "cancelled";
errorCode: string | null;
};
BrokerageOperationDto: {
account?: components["schemas"]["AccountReferenceDto"];
id: string;
@@ -1125,33 +1052,6 @@ export interface components {
data: components["schemas"]["BrokerageOperationsPageDto"] | null;
meta: components["schemas"]["InvestmentResourceMetaDto"];
};
UpcomingEventDto: {
account?: components["schemas"]["AccountReferenceDto"];
/** @enum {string} */
type: "coupon" | "dividend" | "maturity" | "offer";
/** Format: date */
date: string;
/** @enum {string} */
dateKind: "event" | "registry";
figi: string | null;
instrumentUid: string | null;
ticker: string;
displayName: string;
isin: string | null;
logoName: string | null;
projectedAmount: components["schemas"]["MoneyDto"] | null;
};
UpcomingEventsDto: {
accountId: string;
/** Format: date-time */
calculatedAt: string;
events: components["schemas"]["UpcomingEventDto"][];
incomeTotalsByCurrency: components["schemas"]["MoneyDto"][];
};
UpcomingEventsResourceDto: {
data: components["schemas"]["UpcomingEventsDto"] | null;
meta: components["schemas"]["InvestmentResourceMetaDto"];
};
IncomePeriodDto: {
/** Format: date */
from: string;
@@ -1242,12 +1142,14 @@ export interface components {
coverage?: components["schemas"]["InvestmentCoverageDto"];
resourceKey: string;
/** @enum {string} */
availability: "missing" | "partial" | "complete";
availability: "available" | "initial-loading" | "temporarily-unavailable" | "connection-unavailable";
/** @enum {string} */
completeness: "complete" | "incomplete";
/** @enum {string} */
freshness: "unknown" | "fresh" | "stale";
/** Format: date-time */
lastSuccessfulAt: string | null;
sync: components["schemas"]["InvestmentSyncDto"];
lastAttempt: components["schemas"]["InvestmentAttemptDto"];
/** @enum {string} */
role: "selected" | "previous" | "year";
};
@@ -1257,12 +1159,14 @@ export interface components {
coverage?: components["schemas"]["InvestmentCoverageDto"];
resourceKey: string;
/** @enum {string} */
availability: "missing" | "partial" | "complete";
availability: "available" | "initial-loading" | "temporarily-unavailable" | "connection-unavailable";
/** @enum {string} */
completeness: "complete" | "incomplete";
/** @enum {string} */
freshness: "unknown" | "fresh" | "stale";
/** Format: date-time */
lastSuccessfulAt: string | null;
sync: components["schemas"]["InvestmentSyncDto"];
lastAttempt: components["schemas"]["InvestmentAttemptDto"];
/** @description Dependencies identified by role, not array order or resourceKey. A missing role is unavailable. */
dependencies: components["schemas"]["IncomeDependencyDto"][];
};
@@ -1270,6 +1174,33 @@ export interface components {
data: components["schemas"]["IncomeAnalyticsDto"] | null;
meta: components["schemas"]["IncomeAnalyticsMetaDto"];
};
UpcomingEventDto: {
account?: components["schemas"]["AccountReferenceDto"];
/** @enum {string} */
type: "coupon" | "dividend" | "maturity" | "offer";
/** Format: date */
date: string;
/** @enum {string} */
dateKind: "event" | "registry";
figi: string | null;
instrumentUid: string | null;
ticker: string;
displayName: string;
isin: string | null;
logoName: string | null;
projectedAmount: components["schemas"]["MoneyDto"] | null;
};
UpcomingEventsDto: {
accountId: string;
/** Format: date-time */
calculatedAt: string;
events: components["schemas"]["UpcomingEventDto"][];
incomeTotalsByCurrency: components["schemas"]["MoneyDto"][];
};
UpcomingEventsResourceDto: {
data: components["schemas"]["UpcomingEventsDto"] | null;
meta: components["schemas"]["InvestmentResourceMetaDto"];
};
StockSearchResultDto: {
/** Format: uuid */
instrumentUid: string;
@@ -1630,6 +1561,84 @@ export interface components {
PutTbankConnectionDto: {
token: string;
};
EventsInvestmentScreenDto: {
accountId: string;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
screen: "events";
/**
* Format: date
* @description Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC.
*/
from?: string;
/**
* Format: date
* @description Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end.
*/
to?: string;
};
IncomeInvestmentScreenDto: {
accountId: string;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
screen: "income";
/**
* Format: date
* @description Inclusive start calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither for the current Moscow month.
*/
from?: string;
/**
* Format: date
* @description Inclusive end calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither. Timestamp queries use the start of the following Moscow day as the exclusive end, capped at now.
*/
to?: string;
};
SyncCandleRangeDto: {
/** Format: date-time */
from: string;
/** Format: date-time */
to: string;
};
TimestampInvestmentScreenDto: {
accountId?: string;
/** Format: uuid */
instrumentUid?: string;
query?: string;
/** @enum {string} */
candlePeriod?: "month" | "halfYear" | "year" | "all";
candleRange?: components["schemas"]["SyncCandleRangeDto"];
categories?: ("trade" | "income" | "tax" | "fee" | "transfer" | "other")[];
limit?: number;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
screen: "accounts" | "account" | "operations" | "stocks" | "bonds" | "stock" | "bond";
/**
* Format: date-time
* @description Start instant for operations.
*/
from?: string;
/**
* Format: date-time
* @description End instant for operations.
*/
to?: string;
};
TbankSyncStatusDto: {
/** Format: uuid */
id: string;
scope: (components["schemas"]["IncomeInvestmentScreenDto"] | components["schemas"]["EventsInvestmentScreenDto"] | components["schemas"]["TimestampInvestmentScreenDto"]) | null;
/** Format: date-time */
now: string;
/** @enum {string} */
state: "running" | "succeeded" | "failed" | "cancelled";
errorCode: string | null;
};
};
responses: never;
parameters: never;
@@ -3369,72 +3378,6 @@ export interface operations {
};
};
};
TbankSyncController_status: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TbankSyncStatusDto"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Upstream service unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
};
};
BrokerageOperationsController_all: {
parameters: {
query?: {
@@ -3581,148 +3524,6 @@ export interface operations {
};
};
};
UpcomingEventsController_all: {
parameters: {
query?: {
sync?: "observe";
/** @description Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC. */
from?: string;
/** @description Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end. */
to?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UpcomingEventsResourceDto"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Upstream service unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
};
};
UpcomingEventsController_get: {
parameters: {
query?: {
sync?: "observe";
/** @description Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC. */
from?: string;
/** @description Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end. */
to?: string;
};
header?: never;
path: {
accountId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UpcomingEventsResourceDto"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Upstream service unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
};
};
IncomeAnalyticsController_allSummary: {
parameters: {
query?: {
@@ -3999,6 +3800,148 @@ export interface operations {
};
};
};
UpcomingEventsController_all: {
parameters: {
query?: {
sync?: "observe";
/** @description Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC. */
from?: string;
/** @description Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end. */
to?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UpcomingEventsResourceDto"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Upstream service unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
};
};
UpcomingEventsController_get: {
parameters: {
query?: {
sync?: "observe";
/** @description Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC. */
from?: string;
/** @description Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end. */
to?: string;
};
header?: never;
path: {
accountId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UpcomingEventsResourceDto"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Upstream service unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
};
};
StockController_search: {
parameters: {
query: {
@@ -5277,4 +5220,70 @@ export interface operations {
};
};
};
TbankSyncController_status: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TbankSyncStatusDto"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Upstream service unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
};
};
}
+8 -5
View File
@@ -75,9 +75,10 @@ describe("persistent resource query lifecycle", () => {
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(
resource(null, {
sync: {
errorCode: "TBANK_UNAVAILABLE",
id: "work",
lastAttempt: {
finishedAt: "2026-08-14T00:01:00Z",
problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed",
},
}),
@@ -89,14 +90,16 @@ describe("persistent resource query lifecycle", () => {
wrapper: createQueryWrapper().wrapper,
});
await waitFor(() => expect(result.current.meta?.sync.state).toBe("failed"));
await waitFor(() =>
expect(result.current.meta?.lastAttempt.state).toBe("failed"),
);
expect(result.current.data).toBeUndefined();
expect(result.current.isSuccess).toBe(true);
expect(result.current.status).toBe("success");
expect(result.current.isPending).toBe(false);
expect(result.current.isLoading).toBe(false);
expect(result.current.meta?.availability).toBe("missing");
expect(result.current.meta?.availability).toBe("initial-loading");
});
it("keeps the refetch envelope while exposing the refreshed resource payload", async () => {
auth();
+1 -1
View File
@@ -145,7 +145,7 @@ function useResourceQuery<
queryKey: options.queryKey,
refetch: query.refetch,
status: query.status,
sync: query.data?.sync,
sync: query.data && "sync" in query.data ? query.data.sync : undefined,
};
}
File diff suppressed because it is too large Load Diff
+7
View File
@@ -382,6 +382,13 @@ const ru = {
yearUnavailable: "Сравнение с годом ранее недоступно.",
},
investment: {
attempt: {
cancelled: "Обновление отменено",
failed: "Обновление не удалось",
partial: "Обновлено частично",
running: "Обновление выполняется",
succeeded: "Обновлено",
},
history: "История догружается",
partial: "Период загружен не полностью",
refresh: "Обновить",
@@ -32,6 +32,21 @@ describe("SourceSyncStatus", () => {
expect(screen.getByRole("status")).toHaveTextContent("Обновление не удалось");
});
it("announces an active or failed source attempt with text", () => {
renderStatus({
lastAttempt: {
finishedAt: null,
problems: [],
startedAt: "2026-02-03T10:15:00.000Z",
state: "running",
},
});
expect(screen.getByRole("status")).toHaveTextContent(
"Обновление выполняется",
);
});
it("keeps the notices next to the timestamp they belong to", () => {
renderStatus({
errorText: "Обновление не удалось",
@@ -3,6 +3,8 @@ import type { FC } from "react";
import { Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import type { InvestmentResourceMeta } from "@/shared/api";
import { formatSnapshotTime } from "@/shared/lib";
// Owns the "when did the source last answer" line and the two notices that
@@ -10,6 +12,7 @@ import { formatSnapshotTime } from "@/shared/lib";
// shows a muted line, a page header a progress bar — that is a product call.
type SourceSyncStatusProps = {
errorText?: null | string;
lastAttempt?: InvestmentResourceMeta["lastAttempt"];
lastSuccessfulAt?: null | string;
stale?: boolean;
};
@@ -17,6 +20,12 @@ type SourceSyncStatusProps = {
const SourceSyncStatus: FC<SourceSyncStatusProps> = (props) => {
const { t } = useTranslation();
const attempt = props.lastAttempt;
const attemptText = attempt && attempt.state !== "none"
? t(`investment.attempt.${attempt.state}`)
: null;
return (
<>
{!!props.lastSuccessfulAt && (
@@ -29,6 +38,12 @@ const SourceSyncStatus: FC<SourceSyncStatusProps> = (props) => {
{!!props.stale && <Text size="xs">{t("investment.stale")}</Text>}
{!!attemptText && (
<Text role="status" size="xs">
{attemptText}
</Text>
)}
{!!props.errorText && <Text role="status">{props.errorText}</Text>}
</>
);
+8 -2
View File
@@ -7,12 +7,18 @@ function resource<T>(data: T, meta: Partial<Meta> = {}) {
return {
data,
meta: {
availability: data === null ? "missing" : "complete",
availability: data === null ? "initial-loading" : "available",
completeness: "complete",
dataRevision: "1",
freshness: "fresh",
lastAttempt: {
finishedAt: null,
problems: [],
startedAt: null,
state: "none",
},
lastSuccessfulAt: "2026-08-14T00:00:00Z",
resourceKey: "fixture",
sync: { errorCode: null, id: null, state: "idle" },
...meta,
} satisfies Meta,
};