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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user