Files
frontend/src/pages/account/ui/AccountPage/AccountPage.vm.ts
T
codex-bot 8ce1e8e444
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 7m5s
feat(investments): warn and offer retry after exhausted read retries (WealthLedger/frontend#55) (#136)
2026-11-03 09:49:08 +03:00

203 lines
5.7 KiB
TypeScript

import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router";
import type { Portfolio } from "@/entities/portfolio";
import type { InvestmentResourceMeta } from "@/shared/api";
import { useBrokerageOperations } from "@/entities/broker-operation";
import {
type BrokerageAccount,
useBrokerageAccounts,
} from "@/entities/brokerage-account";
import {
useBrokerageEvents,
} from "@/entities/brokerage-event";
import { useIncomeSummary } from "@/entities/income";
import { usePortfolio } from "@/entities/portfolio";
import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode, isBrokerageAccessLoss, readRetry } from "@/shared/api";
import { useInitialAvailability } from "@/shared/lib";
import {
type Concentration,
concentrationCurrencies,
type ConcentrationLabels,
type Slice,
toConcentration,
} from "../../model/concentration";
import {
type AccountPagePositionSection,
toAccountPagePositionSections,
toUnrealizedReturns,
} from "../../model/positions";
import {
type AccountPageIncomeSummary,
type AccountPageRecentEvents,
type AccountPageRecentOperations,
toIncomeSummary,
toRecentEvents,
toRecentOperations,
} from "../../model/previews";
type AccountPageContent = {
account?: BrokerageAccount;
accountId?: string;
incomeSummary: AccountPageIncomeSummary;
partial?: boolean;
readRetry: AccountPageReadRetry;
recentEvents: AccountPageRecentEvents;
recentOperations: AccountPageRecentOperations;
source?: InvestmentResourceMeta;
};
type AccountPageEmptyViewModel = AccountPageContent & {
state: "empty";
};
type AccountPageLoadedViewModel = AccountPageContent & {
concentration: Concentration | null;
currency: null | string;
onCurrencyChange: (currency: null | string) => void;
onSliceChange: (slice: Slice) => void;
onTabChange: (tab: null | string) => void;
portfolio: Portfolio;
positionSections: AccountPagePositionSection[];
slice: Slice;
state: "loaded";
tab: string;
unrealizedReturns: Portfolio["totalsByCurrency"];
};
type AccountPageReadRetry = ReturnType<typeof readRetry>;
type AccountPageViewModel
= | AccountPageEmptyViewModel
| AccountPageLoadedViewModel
| { code?: string; readRetry: AccountPageReadRetry; state: "error" }
| { state: "loading" };
function useAccountPageViewModel(): AccountPageViewModel {
const { accountId } = useParams();
const { t } = useTranslation();
const [selection, setSelection] = useState<{
accountId?: string;
currency: null | string;
slice: Slice;
tab: string;
}>({ accountId, currency: null, slice: "stocks", tab: "positions" });
const current = selection.accountId === accountId
? selection
: {
accountId, currency: null, slice: "stocks" as const, tab: "positions",
};
const updateSelection = (change: Partial<typeof selection>) =>
setSelection({ ...current, ...change });
const portfolio = usePortfolio(accountId);
const operations = useBrokerageOperations(accountId, {
limit: 5,
});
const events = useBrokerageEvents(accountId);
const income = useIncomeSummary(accountId);
const accounts = useBrokerageAccounts();
useScreenSync({ accountId, screen: "account" }, [
accounts,
portfolio,
operations,
events,
income,
]);
const code = apiErrorCode(portfolio.error);
// The portfolio drives this screen; the retry repeats only its GET.
const retry = readRetry([portfolio]);
const isAccessFailure = isBrokerageAccessLoss(code);
const availability = useInitialAvailability({
hasContent: !!portfolio.data,
identity: accountId ?? "account",
isError: portfolio.isError || portfolio.meta?.lastAttempt.state === "failed",
isFetching: portfolio.isFetching,
});
if (isAccessFailure)
return { code, readRetry: retry, state: "error" as const };
if (!portfolio.data)
return availability === "unavailable"
? { code, readRetry: retry, state: "error" as const }
: { state: "loading" as const };
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.meta?.availability === "available"
&& portfolio.data.positions.length === 0
&& portfolio.data.cashBalances.length === 0
)
return {
account,
accountId,
incomeSummary,
readRetry: retry,
recentEvents,
recentOperations,
source: portfolio.meta,
state: "empty" as const,
};
const isPartial = portfolio.meta?.completeness === "incomplete";
const currencies = concentrationCurrencies(portfolio.data);
const currency = current.currency && currencies.includes(current.currency)
? current.currency
: currencies[0] ?? null;
const labels: ConcentrationLabels = {
missingIssuer: t("portfolio.concentration.missingIssuer"),
missingSector: t("portfolio.concentration.missingSector"),
other: t("portfolio.concentration.other"),
sector: code => t(`stock.sectors.${code}`, { defaultValue: code }),
};
const concentration = accountId !== "all"
&& current.tab === "concentration"
? toConcentration(portfolio.data, current.slice, currency, labels)
: null;
return {
account,
accountId,
concentration,
currency,
incomeSummary,
onCurrencyChange: value => updateSelection({ currency: value }),
onSliceChange: slice => updateSelection({ slice }),
onTabChange: tab => updateSelection({ tab: tab ?? "positions" }),
partial: isPartial,
portfolio: portfolio.data,
positionSections: toAccountPagePositionSections(portfolio.data),
readRetry: retry,
recentEvents,
recentOperations,
slice: current.slice,
source: portfolio.meta,
state: "loaded" as const,
tab: current.tab,
unrealizedReturns: toUnrealizedReturns(portfolio.data.positions),
};
}
export { useAccountPageViewModel };