refactor: enforce frontend view model boundaries
Frontend verify / commits (pull_request) Successful in 28s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 8m19s

This commit is contained in:
2026-09-28 18:48:23 +03:00
parent 5b69003516
commit ca0ad4f824
287 changed files with 8558 additions and 4538 deletions
@@ -33,6 +33,7 @@ type AccountPageContent = {
retry: () => Promise<unknown> | undefined;
sourceError?: boolean;
};
type AccountPageEmptyViewModel = AccountPageContent & {
state: "empty";
};
@@ -47,7 +48,9 @@ type AccountPageIncomeSummary
to: string;
}
| { retry: () => Promise<unknown> | undefined; state: "error"; to: string }
| { state: "empty" | "loading" | "partial"; to: string };
| { state: "empty"
| "loading"
| "partial"; to: string; };
type AccountPageLoadedViewModel = AccountPageContent & {
portfolio: Portfolio;
@@ -85,7 +88,9 @@ type AccountPageRecentOperations
to: string;
}
| {
state: "empty" | "error" | "loading";
state: "empty"
| "error"
| "loading";
to: string;
};
@@ -97,23 +102,31 @@ type AccountPageViewModel
| { code?: string; retry: () => Promise<unknown> | undefined; state: "error" }
| { state: "loading" };
type PositionSectionKind = "bonds" | "other" | "stocks";
type PositionSectionKind = "bonds"
| "other"
| "stocks";
function groupPositions(
positions: AccountPagePosition[],
): AccountPagePositionGroup[] {
const groups = new Map<string, AccountPagePositionGroup>();
for (const [index, position] of positions.entries()) {
const identity = positionIdentity(position);
const rowKey = JSON.stringify(["row", position.account?.id, index]);
const key
= identity && position.account
? JSON.stringify([position.type, ...identity])
: rowKey;
const group = groups.get(key);
if (
group
&& group.positions.every(item => item.account?.id !== position.account?.id)
&& group.positions.every(
item => item.account?.id !== position.account?.id,
)
) {
group.positions.push(position);
}
@@ -122,21 +135,26 @@ function groupPositions(
groups.set(uniqueKey, { key: uniqueKey, positions: [position] });
}
}
// eslint-disable-next-line unicorn/prefer-iterator-to-array -- Production targets ES2023; Iterator helpers require a newer browser baseline.
return [...groups.values()];
}
function positionIdentity(position: AccountPagePosition) {
if (position.instrumentUid?.trim()) return ["uid", position.instrumentUid.trim()];
if (position.instrumentUid?.trim())
return ["uid", position.instrumentUid.trim()];
if (position.figi?.trim()) return ["figi", position.figi.trim()];
return position.isin?.trim() ? ["isin", position.isin.trim()] : undefined;
}
function toAccountPagePositionSections(
portfolio: Portfolio,
instrumentDetails: Map<string, undefined | { slug: string }> = new Map(),
instrumentDetails: Map<string, undefined
| { slug: string }> = new Map(),
): AccountPagePositionSection[] {
const denominators = new Map<string, number>();
for (const position of portfolio.positions) {
if (!position.currentValue) continue;
const currency = position.currentValue.currency;
@@ -145,14 +163,17 @@ function toAccountPagePositionSections(
(denominators.get(currency) ?? 0) + Number(position.currentValue.value),
);
}
const stocks = toSection(
"stocks",
portfolio,
denominators,
instrumentDetails,
);
const bonds = toSection("bonds", portfolio, denominators, instrumentDetails);
const other = toSection("other", portfolio, denominators, instrumentDetails);
return [stocks, bonds, other].filter(section => section.groups.length > 0);
}
@@ -167,6 +188,7 @@ function toIncomeSummary(
if (!income.data && (income.isError || income.meta?.sync.state === "failed"))
return { retry: () => income.refetch(), state: "error", to };
if (!income.data?.totalsByCurrency.length) return { state: "empty", to };
return {
lastSuccessfulAt: income.meta?.lastSuccessfulAt,
refreshError: income.isError || income.meta?.sync.state === "failed",
@@ -181,32 +203,40 @@ function toPosition(
position: Portfolio["positions"][number],
denominators: Map<string, number>,
kind: PositionSectionKind,
instrumentSlug: string | undefined,
instrumentSlug?: string,
): AccountPagePosition {
const isExpanded = kind !== "other";
const returnValue = position.absoluteReturn
? Number(position.absoluteReturn.value)
: /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
: null; // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
let returnTone: AccountPageReturnTone | undefined;
if (returnValue !== null && returnValue !== 0) returnTone = returnValue > 0 ? "positive" : "negative";
const returnPrefix = isExpanded && returnValue !== null && returnValue > 0 ? "+" : "";
if (returnValue !== null && returnValue !== 0)
returnTone = returnValue > 0 ? "positive" : "negative";
const returnPrefix
= isExpanded && returnValue !== null && returnValue > 0 ? "+" : "";
return {
...position,
instrumentTo:
(kind === "stocks" || kind === "bonds") && position.instrumentUid
? `/${kind}/${encodeURIComponent(instrumentSlug ?? position.instrumentUid)}`
: undefined,
logoSrc: isExpanded ? instrumentLogoUrl(position.logoName) : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
logoSrc: isExpanded
? instrumentLogoUrl(position.logoName)
: null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
returnText: position.absoluteReturn
? `${returnPrefix}${formatMoney(position.absoluteReturn)}`
: /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
: null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
returnTone,
shareText: position.currentValue?.currency
? formatShare(position.currentValue, {
currency: position.currentValue.currency,
value: String(denominators.get(position.currentValue.currency) ?? 0),
})
: /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
: null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
};
}
@@ -227,6 +257,7 @@ function toRecentEvents(
return { state: "error", to };
const items = events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [];
if (items.length === 0) return { state: "empty", to };
return {
items,
lastSuccessfulAt: events.meta?.lastSuccessfulAt,
@@ -256,6 +287,7 @@ function toRecentOperations(
)
return { state: "error", to };
if (!operations.data?.items.length) return { state: "empty", to };
return {
items: operations.data.items,
lastSuccessfulAt: operations.meta?.lastSuccessfulAt,
@@ -271,20 +303,20 @@ function toSection(
kind: PositionSectionKind,
portfolio: Portfolio,
denominators: Map<string, number>,
instrumentDetails: Map<string, undefined | { slug: string }>,
instrumentDetails: Map<string, undefined
| { slug: string }>,
): AccountPagePositionSection {
return {
groups: groupPositions(
portfolio.positions
.filter((position) => {
if (kind === "stocks") return position.type === "STOCK";
return kind === "bonds"
? position.type === "BOND"
: (
position.type !== "STOCK"
&& position.type !== "BOND"
&& position.type !== "CURRENCY"
);
: position.type !== "STOCK"
&& position.type !== "BOND"
&& position.type !== "CURRENCY";
})
.map(position =>
toPosition(
@@ -303,6 +335,7 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId;
const portfolio = usePortfolio(accountId);
const stocks = useStocks(
portfolio.data?.positions.flatMap(position =>
position.type === "STOCK" && position.instrumentUid
@@ -310,13 +343,17 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
: [],
) ?? [],
);
const instrumentDetails = stocks;
const operations = useBrokerageOperations(accountId, {
limit: 5,
});
const events = useBrokerageEvents(accountId);
const income = useIncomeSummary(accountId);
const accounts = useBrokerageAccounts();
const sync = useScreenSync({ accountId, screen: "account" }, [
accounts,
portfolio,
@@ -332,12 +369,15 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
retry: sync.retry,
state: "error" as const,
};
return { 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);
const refresh = {
partial: portfolio.meta?.availability === "partial",
refreshError: portfolio.isError || sync.transportError || sync.sourceError,
@@ -345,6 +385,7 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
retry: sync.retry,
sourceError: sync.sourceError,
};
if (
portfolio.meta?.availability === "complete"
&& portfolio.data.positions.length === 0
@@ -359,6 +400,7 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
state: "empty" as const,
...refresh,
};
return {
account,
accountId,