refactor: clarify frontend model ownership
Frontend verify / commits (pull_request) Successful in 27s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 8m20s

This commit is contained in:
2026-09-28 21:44:53 +03:00
parent 7e976c330c
commit 0865b35f93
44 changed files with 685 additions and 710 deletions
@@ -24,8 +24,8 @@ import type {
AccountPageRecentOperations,
} from "./AccountPage.vm";
import { toAccountPagePositionSections } from "../../model/positions";
import { AccountPage } from "./AccountPage";
import { toAccountPagePositionSections } from "./AccountPage.vm";
import { AccountPageEmpty } from "./components/AccountPageEmpty.view";
import { AccountPageError } from "./components/AccountPageError.view";
import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
@@ -39,13 +39,13 @@ function configureTestAuth() {
});
}
function renderAccountRoute(queryClient: QueryClient) {
function renderAccountRoute(queryClient: QueryClient, accountId = "account-1") {
configureTestAuth();
return render(
<QueryClientProvider client={queryClient}>
<MantineProvider>
<MemoryRouter initialEntries={["/accounts/account-1"]}>
<MemoryRouter initialEntries={[`/accounts/${accountId}`]}>
<Routes>
<Route element={<AccountPage />} path="/accounts/:accountId" />
</Routes>
@@ -202,7 +202,7 @@ describe("AccountPage states", () => {
}),
);
configureTestAuth();
renderPage(<AccountPage accountId={accountId} />);
renderAccountRoute(createTestQueryClient(), accountId);
await waitFor(() => {
const links = screen.getAllByRole("link", {
name: position.displayName,
@@ -6,10 +6,8 @@ import { AccountPageError } from "./components/AccountPageError.view";
import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
import { AccountPageLoading } from "./components/AccountPageLoading/AccountPageLoading.view";
type AccountPageProps = { accountId?: string };
const AccountPage: FC<AccountPageProps> = (props) => {
const viewModel = useAccountPageViewModel(props.accountId);
const AccountPage: FC = () => {
const viewModel = useAccountPageViewModel();
if (viewModel.state === "loading") return <AccountPageLoading />;
if (viewModel.state === "error") return <AccountPageError {...viewModel} />;
if (viewModel.state === "empty")
@@ -13,10 +13,8 @@ import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import {
toAccountPagePositionSections,
useAccountPageViewModel,
} from "./AccountPage.vm";
import { toAccountPagePositionSections } from "../../model/positions";
import { useAccountPageViewModel } from "./AccountPage.vm";
const portfolio: Portfolio
= {
@@ -13,11 +13,12 @@ import {
useBrokerageEvents,
} from "@/entities/brokerage-event";
import { type IncomeSummary, useIncomeSummary } from "@/entities/income";
import { formatShare, usePortfolio } from "@/entities/portfolio";
import { 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";
import { type AccountPagePositionSection, toAccountPagePositionSections } from "../../model/positions";
const RECENT_EVENTS_LIMIT = 5;
@@ -58,24 +59,6 @@ type AccountPageLoadedViewModel = AccountPageContent & {
state: "loaded";
};
type AccountPagePosition = Portfolio["positions"][number] & {
instrumentTo?: string;
logoSrc: null | string;
returnText: null | string;
returnTone?: AccountPageReturnTone;
shareText: null | string;
};
type AccountPagePositionGroup = {
key: string;
positions: AccountPagePosition[];
};
type AccountPagePositionSection = {
groups: AccountPagePositionGroup[];
kind: PositionSectionKind;
};
type AccountPageRecentEvents = BrokerageEventsPreviewState;
type AccountPageRecentOperations
@@ -94,89 +77,12 @@ type AccountPageRecentOperations
to: string;
};
type AccountPageReturnTone = "negative" | "positive";
type AccountPageViewModel
= | AccountPageEmptyViewModel
| AccountPageLoadedViewModel
| { code?: string; retry: () => Promise<unknown> | undefined; state: "error" }
| { state: "loading" };
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.push(position);
}
else {
const uniqueKey = group ? rowKey : key;
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.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(),
): AccountPagePositionSection[] {
const denominators = new Map<string, number>();
for (const position of portfolio.positions) {
if (!position.currentValue) continue;
const currency = position.currentValue.currency;
denominators.set(
currency,
(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);
}
function toIncomeSummary(
accountId: string | undefined,
income: ReturnType<typeof useIncomeSummary>,
@@ -199,47 +105,6 @@ function toIncomeSummary(
};
}
function toPosition(
position: Portfolio["positions"][number],
denominators: Map<string, number>,
kind: PositionSectionKind,
instrumentSlug?: string,
): AccountPagePosition {
const isExpanded = kind !== "other";
const returnValue = position.absoluteReturn
? Number(position.absoluteReturn.value)
: null;
let returnTone: AccountPageReturnTone | undefined;
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)
: null,
returnText: position.absoluteReturn
? `${returnPrefix}${formatMoney(position.absoluteReturn)}`
: null,
returnTone,
shareText: position.currentValue?.currency
? formatShare(position.currentValue, {
currency: position.currentValue.currency,
value: String(denominators.get(position.currentValue.currency) ?? 0),
})
: null,
};
}
function toRecentEvents(
accountId: string | undefined,
events: ReturnType<typeof useBrokerageEvents>,
@@ -299,41 +164,8 @@ function toRecentOperations(
};
}
function toSection(
kind: PositionSectionKind,
portfolio: Portfolio,
denominators: Map<string, number>,
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";
})
.map(position =>
toPosition(
position,
denominators,
kind,
instrumentDetails.get(position.instrumentUid ?? "")?.slug,
),
),
),
kind,
};
}
function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId;
function useAccountPageViewModel(): AccountPageViewModel {
const { accountId } = useParams();
const portfolio = usePortfolio(accountId);
const stocks = useStocks(
@@ -344,8 +176,6 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
) ?? [],
);
const instrumentDetails = stocks;
const operations = useBrokerageOperations(accountId, {
limit: 5,
});
@@ -408,7 +238,7 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
portfolio: portfolio.data,
positionSections: toAccountPagePositionSections(
portfolio.data,
instrumentDetails,
stocks,
),
recentEvents,
recentOperations,
@@ -419,11 +249,7 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
export {
type AccountPageIncomeSummary,
type AccountPagePosition,
type AccountPagePositionGroup,
type AccountPagePositionSection,
type AccountPageRecentEvents,
type AccountPageRecentOperations,
toAccountPagePositionSections,
useAccountPageViewModel,
};
@@ -9,12 +9,8 @@ import type { Portfolio } from "@/entities/portfolio";
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
import { formatSnapshotTime } from "@/shared/lib";
import type {
AccountPageIncomeSummary,
AccountPagePositionSection,
AccountPageRecentEvents,
AccountPageRecentOperations,
} from "../AccountPage.vm";
import type { AccountPagePositionSection } from "../../../model/positions";
import type { AccountPageIncomeSummary, AccountPageRecentEvents, AccountPageRecentOperations } from "../AccountPage.vm";
import classes from "../AccountPage.module.css";
import { AccountHeading } from "./AccountHeading.view";
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { AccountPagePosition } from "../../AccountPage.vm";
import type { AccountPagePosition } from "../../../../model/positions";
import { PositionCard } from "./PositionCard.view";
@@ -6,10 +6,7 @@ import { Link } from "react-router";
import { formatMoney } from "@/shared/lib";
import type {
AccountPagePosition,
AccountPagePositionSection,
} from "../../AccountPage.vm";
import type { AccountPagePosition, AccountPagePositionSection } from "../../../../model/positions";
import classes from "../../AccountPage.module.css";
import { PositionLogo } from "../PositionLogo.view";
@@ -6,10 +6,7 @@ import { Link } from "react-router";
import { formatMoney } from "@/shared/lib";
import type {
AccountPagePositionGroup,
AccountPagePositionSection,
} from "../AccountPage.vm";
import type { AccountPagePositionGroup, AccountPagePositionSection } from "../../../model/positions";
import classes from "../AccountPage.module.css";
import { PositionLogo } from "./PositionLogo.view";
@@ -1,10 +1,9 @@
import type { FC } from "react";
import { Anchor, Avatar } from "@mantine/core";
import { useState } from "react";
import { Link } from "react-router";
import { useImageFallback } from "@/shared/lib";
type PositionLogoProps = {
label: string;
src: null | string;
@@ -13,7 +12,9 @@ type PositionLogoProps = {
const PositionLogo: FC<PositionLogoProps> = (props) => {
const { label, src } = props;
const { failed, onError } = useImageFallback();
const [failed, setFailed] = useState(false);
// eslint-disable-next-line frontend/readability -- Rendering fallback belongs to this keyed logo, without page data or actions.
const onError = () => setFailed(true);
const logo
= src && !failed
@@ -2,7 +2,7 @@ import type { FC } from "react";
import { SimpleGrid } from "@mantine/core";
import type { AccountPagePositionSection } from "../AccountPage.vm";
import type { AccountPagePositionSection } from "../../../model/positions";
import { PositionCard } from "./PositionCard/PositionCard.view";
import { PositionGroupCard } from "./PositionGroupCard.view";
@@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest";
import type { Portfolio } from "@/entities/portfolio";
import { toAccountPagePositionSections } from "./AccountPage.vm";
import { toAccountPagePositionSections } from "../../model/positions";
import { PositionSection } from "./components/PositionSection.view";
const position: Portfolio["positions"][number]
@@ -8,10 +8,7 @@ import { cleanup, render, screen } from "@testing-library/react";
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
import { afterEach, expect, it } from "vitest";
import type {
AccountPagePosition,
AccountPagePositionSection,
} from "./AccountPage.vm";
import type { AccountPagePosition, AccountPagePositionSection } from "../../model/positions";
import { PositionSection } from "./components/PositionSection.view";