feat: show persisted investment data and sync state

Consume the provided resource-envelope API snapshot. Preserve financial snapshots during
refresh, observe synchronization without importing, and fence late responses across
account and connection changes.
This commit is contained in:
2026-09-26 21:22:49 +03:00
parent b987fd8e78
commit 4e5f564fa1
81 changed files with 4714 additions and 1420 deletions
@@ -1,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(