fix(account): align concentration labels and chart accessibility (WealthLedger/main#48)
Frontend verify / commits (pull_request) Successful in 2m3s
Frontend verify / security (pull_request) Failing after 1m40s
Frontend verify / verify (pull_request) Skipped

This commit is contained in:
2026-11-01 15:50:18 +03:00
parent ba7f8547bb
commit 3ce67a59a5
7 changed files with 559 additions and 88 deletions
+67 -15
View File
@@ -23,20 +23,27 @@ function stock(
}; };
} }
const labels = {
missingIssuer: "Эмитент не указан",
missingSector: "Сектор не указан",
other: "Прочее",
sector: (code: string) => code,
};
describe("account concentration", () => { describe("account concentration", () => {
it("uses exact positive values and excludes cash, negatives and nulls", () => { it("uses exact positive values and excludes cash, negatives and nulls", () => {
const model = toConcentration(snapshot([ const model = toConcentration(snapshot([
stock("A", "1"), stock("B", "15"), stock("A", "1"), stock("B", "15"),
stock("C", "0"), stock("D", "-50"), stock("E", null), stock("C", "0"), stock("D", "-50"), stock("E", null),
stock("F", null, "USD"), stock("G", "-3", "USD"), stock("F", null, "USD"), stock("G", "-3", "USD"),
]), "stocks", "RUB"); ]), "stocks", "RUB", labels);
expect(model.currencies).toEqual(["RUB", "USD"]); expect(model.currencies).toEqual(["RUB", "USD"]);
expect(model.total).toBe("16"); expect(model.total).toBe("16");
expect(model.items.map(item => item.share)).toEqual(["93,8 %", "6,3 %"]); expect(model.items.map(item => item.share)).toEqual(["93,8 %", "6,3 %"]);
expect(model.unaccounted).toBe(3); expect(model.unaccounted).toBe(3);
const negative = snapshot([stock("A", "-3", "USD")]); const negative = snapshot([stock("A", "-3", "USD")]);
expect(toConcentration(negative, "stocks", "USD")) expect(toConcentration(negative, "stocks", "USD", labels))
.toMatchObject({ empty: true, unaccounted: 1 }); .toMatchObject({ empty: true, unaccounted: 1 });
}); });
@@ -45,7 +52,7 @@ describe("account concentration", () => {
stock("A", "400"), stock("B", "250"), stock("C", "150"), stock("A", "400"), stock("B", "250"), stock("C", "150"),
stock("D", "100"), stock("E", "50"), stock("F", "30"), stock("D", "100"), stock("E", "50"), stock("F", "30"),
stock("G", "20"), stock("G", "20"),
]), "stocks", "RUB"); ]), "stocks", "RUB", labels);
expect(model.items.map(item => item.amount)).toEqual([ expect(model.items.map(item => item.amount)).toEqual([
"400", "250", "150", "100", "50", "400", "250", "150", "100", "50",
@@ -64,7 +71,7 @@ describe("account concentration", () => {
stock("Сбер", "100"), stock("X5", "100"), stock("Сбер", "100"), stock("X5", "100"),
stock("VK", "100"), stock("D", "100"), stock("VK", "100"), stock("D", "100"),
stock("E", "100"), stock("F", "100"), stock("E", "100"), stock("F", "100"),
]), "stocks", "RUB"); ]), "stocks", "RUB", labels);
expect(model.items).toHaveLength(6); expect(model.items).toHaveLength(6);
expect(model.other).toBeNull(); expect(model.other).toBeNull();
@@ -76,7 +83,7 @@ describe("account concentration", () => {
stock("Large", "100000000"), stock("Small", "1"), stock("Large", "100000000"), stock("Small", "1"),
]); ]);
expect(toConcentration(tiny, "stocks", "RUB").items[1]?.share) expect(toConcentration(tiny, "stocks", "RUB", labels).items[1]?.share)
.toBe("< 0,1 %"); .toBe("< 0,1 %");
}); });
@@ -96,28 +103,73 @@ describe("account concentration", () => {
stock("D", "50", "RUB", { issuer: null, sector: null }), stock("D", "50", "RUB", { issuer: null, sector: null }),
]; ];
expect(toConcentration(snapshot(positions), "sectors", "RUB") expect(toConcentration(snapshot(positions), "sectors", "RUB", labels)
.items.map(item => item.amount)).toEqual(["500", "100", "50"]); .items.map(item => item.amount)).toEqual(["500", "100", "50"]);
expect(toConcentration(snapshot(positions), "issuers", "RUB") expect(toConcentration(snapshot(positions), "issuers", "RUB", labels)
.items.map(item => [item.name, item.amount])).toEqual([ .items.map(item => [item.name, item.amount])).toEqual([
["Сбербанк", "500"], ["", "50"], ["Сбербанк", "500"], ["", "50"],
]); ]);
}); });
it("orders equal sums by displayed name", () => {
const issuers = snapshot([
stock("A", "300", "RUB", {
issuer: { id: "g", name: "Газпром" }, sector: "energy",
}),
stock("B", "300", "RUB", {
issuer: { id: "s", name: "Сбер" }, sector: "energy",
}),
stock("C", "200", "RUB", { issuer: null, sector: "energy" }),
stock("D", "100", "RUB", { issuer: null, sector: "energy" }),
]);
expect(toConcentration(issuers, "issuers", "RUB", labels)
.items.map(item => [item.label, item.amount])).toEqual([
["Газпром", "300"], ["Сбер", "300"], ["Эмитент не указан", "300"],
]);
const sectors = snapshot([
stock("A", "100", "RUB", { sector: "it" }),
stock("B", "100", "RUB", { sector: "energy" }),
]);
const sectorLabels = {
...labels,
sector: (code: string) => code === "it"
? "Информационные технологии"
: "Энергетика",
};
expect(toConcentration(sectors, "sectors", "RUB", sectorLabels)
.items.map(item => item.label))
.toEqual(["Информационные технологии", "Энергетика"]);
});
it("chooses issuer name from the largest position, then alphabetically", () => { it("chooses issuer name from the largest position, then alphabetically", () => {
const data = snapshot([ const data = snapshot([
stock("A", "200", "RUB", { stock("A", "100", "RUB", {
issuer: { id: "brand", name: "Сбербанк" }, issuer: { id: "brand", name: "Ястреб" },
}), }),
stock("B", "200", "RUB", { stock("B", "300", "RUB", {
issuer: { id: "brand", name: "Сбер" }, issuer: { id: "brand", name: "Альфа" },
}), }),
stock("C", "300", "RUB", { stock("C", "400", "RUB", {
issuer: { id: "other", name: "Альфа" }, issuer: { id: "other", name: "Бета" },
}), }),
]); ]);
expect(toConcentration(data, "issuers", "RUB").items[0]?.name) expect(toConcentration(data, "issuers", "RUB", labels).items
.toBe("Сбер"); .map(item => [item.name, item.label, item.amount])).toEqual([
["Альфа", "Альфа", "400"], ["Бета", "Бета", "400"],
]);
});
it("uses ISIN before ticker for bonds", () => {
const data = snapshot([stock("Облигация", "100", "RUB", {
isin: "RU000A0JX0J2", ticker: "BOND", type: "BOND",
})]);
expect(toConcentration(data, "bonds", "RUB", labels)
.items[0]?.logo?.subtitle).toBe("RU000A0JX0J2");
}); });
}); });
+78 -15
View File
@@ -4,6 +4,7 @@ import {
type Decimal, type Decimal,
decimalFromString, decimalFromString,
decimalToString, decimalToString,
instrumentLogoUrl,
sumDecimals, sumDecimals,
} from "@/shared/lib"; } from "@/shared/lib";
@@ -21,11 +22,25 @@ type ConcentrationItem = {
amount: string; amount: string;
chartValue: number; chartValue: number;
key: string; key: string;
label: string;
logo: ConcentrationLogo | null;
name: string; name: string;
share: string; share: string;
unknown: boolean; unknown: boolean;
}; };
type ConcentrationLabels = {
missingIssuer: string;
missingSector: string;
other: string;
sector: (code: string) => string;
};
type ConcentrationLogo = {
src: null | string;
subtitle: null | string;
};
type ConcentrationOther = ConcentrationItem & { type ConcentrationOther = ConcentrationItem & {
count: number; count: number;
entries: ConcentrationItem[]; entries: ConcentrationItem[];
@@ -35,6 +50,8 @@ type Group = {
best?: { name: string; value: Decimal }; best?: { name: string; value: Decimal };
first: number; first: number;
key: string; key: string;
label: string;
logo: ConcentrationLogo | null;
name: string; name: string;
unknown: boolean; unknown: boolean;
values: Decimal[]; values: Decimal[];
@@ -65,6 +82,7 @@ function addPosition(
position: Position, position: Position,
index: number, index: number,
slice: Slice, slice: Slice,
labels: ConcentrationLabels,
selected: null | string, selected: null | string,
): number { ): number {
if (!isIncluded(slice, position.type)) return 0; if (!isIncluded(slice, position.type)) return 0;
@@ -73,7 +91,7 @@ function addPosition(
const value = decimalFromString(position.currentValue.value); const value = decimalFromString(position.currentValue.value);
if (value.value < 0n) return 1; if (value.value < 0n) return 1;
if (value.value === 0n) return 0; if (value.value === 0n) return 0;
const identity = groupIdentity(position, slice, index); const identity = groupIdentity(position, slice, index, labels);
const group = groups.get(identity.key) ?? { const group = groups.get(identity.key) ?? {
...identity, first: index, values: [], ...identity, first: index, values: [],
@@ -89,6 +107,7 @@ function addPosition(
&& compareNames(identity.name, best.name) < 0)) { && compareNames(identity.name, best.name) < 0)) {
group.best = { name: identity.name, value }; group.best = { name: identity.name, value };
group.name = identity.name; group.name = identity.name;
group.label = identity.label;
} }
} }
@@ -116,23 +135,58 @@ function compareDecimal(a: Decimal, b: Decimal): number {
return left < right ? -1 : 0; return left < right ? -1 : 0;
} }
function groupIdentity(position: Position, slice: Slice, index: number) { function concentrationCurrencies(portfolio: Portfolio): string[] {
return [...new Set(portfolio.positions
.filter(position => eligibleTypes.has(position.type))
.filter(position => position.currentValue
&& decimalFromString(position.currentValue.value).value !== 0n)
.map(position => position.currentValue?.currency)
.filter((value): value is string => !!value))]
.toSorted((a, b) => a.localeCompare(b));
}
function groupIdentity(
position: Position,
slice: Slice,
index: number,
labels: ConcentrationLabels,
) {
if (slice === "sectors") { if (slice === "sectors") {
const sector = position.sector; const sector = position.sector;
const isUnknown = !sector;
return { key: sector ?? "", name: sector ?? "", unknown: !sector }; return {
key: sector ?? "",
label: isUnknown
? labels.missingSector
: labels.sector(sector),
logo: null,
name: sector ?? "",
unknown: isUnknown,
};
} }
if (slice === "issuers") { if (slice === "issuers") {
const isUnknown = !position.issuer;
return { return {
key: position.issuer?.id ?? "", key: position.issuer?.id ?? "",
label: position.issuer ? position.issuer.name : labels.missingIssuer,
logo: null,
name: position.issuer?.name ?? "", name: position.issuer?.name ?? "",
unknown: !position.issuer, unknown: isUnknown,
}; };
} }
return { return {
key: String(index), key: String(index),
label: position.displayName,
logo: {
src: instrumentLogoUrl(position.logoName),
subtitle: slice === "bonds"
? position.isin ?? position.ticker ?? position.figi
: position.ticker ?? position.isin ?? position.figi,
},
name: position.displayName, name: position.displayName,
unknown: false, unknown: false,
}; };
@@ -161,14 +215,9 @@ function toConcentration(
portfolio: Portfolio, portfolio: Portfolio,
slice: Slice, slice: Slice,
currency: null | string, currency: null | string,
labels: ConcentrationLabels,
): Concentration { ): Concentration {
const currencies = [...new Set(portfolio.positions const currencies = concentrationCurrencies(portfolio);
.filter(position => eligibleTypes.has(position.type))
.filter(position => position.currentValue
&& decimalFromString(position.currentValue.value).value !== 0n)
.map(position => position.currentValue?.currency)
.filter((value): value is string => !!value))]
.toSorted((a, b) => a.localeCompare(b));
const selected = currency && currencies.includes(currency) const selected = currency && currencies.includes(currency)
? currency ? currency
@@ -178,13 +227,15 @@ function toConcentration(
let unaccounted = 0; let unaccounted = 0;
for (const [index, position] of portfolio.positions.entries()) { for (const [index, position] of portfolio.positions.entries()) {
unaccounted += addPosition(groups, position, index, slice, selected); unaccounted += addPosition(
groups, position, index, slice, labels, selected,
);
} }
const ranked = Array.from(groups.values(), group => ({ const ranked = Array.from(groups.values(), group => ({
...group, sum: sumDecimals(group.values), ...group, sum: sumDecimals(group.values),
})).toSorted((a, b) => compareDecimal(b.sum, a.sum) })).toSorted((a, b) => compareDecimal(b.sum, a.sum)
|| compareNames(a.name, b.name) || a.first - b.first); || compareNames(a.label, b.label) || a.first - b.first);
const total = ranked.length > 0 const total = ranked.length > 0
? sumDecimals(ranked.map(item => item.sum)) ? sumDecimals(ranked.map(item => item.sum))
@@ -195,6 +246,8 @@ function toConcentration(
amount: decimalToString(group.sum), amount: decimalToString(group.sum),
chartValue: chartValue(group.sum, total), chartValue: chartValue(group.sum, total),
key: group.key, key: group.key,
label: group.label,
logo: group.logo,
name: group.name, name: group.name,
share: share(group.sum, total), share: share(group.sum, total),
unknown: group.unknown, unknown: group.unknown,
@@ -214,6 +267,8 @@ function toConcentration(
count: rest.length, count: rest.length,
entries: rest, entries: rest,
key: "__other", key: "__other",
label: labels.other,
logo: null,
name: "", share: share(otherSum, total), unknown: false, name: "", share: share(otherSum, total), unknown: false,
} }
: null; : null;
@@ -233,5 +288,13 @@ function toConcentration(
}; };
} }
export { toConcentration }; export {
export type { Concentration, ConcentrationItem, Slice }; concentrationCurrencies,
toConcentration,
};
export type {
Concentration,
ConcentrationItem,
ConcentrationLabels,
Slice,
};
@@ -8,7 +8,9 @@ import {
within, within,
} from "@testing-library/react"; } from "@testing-library/react";
import { http, HttpResponse } from "msw"; import { http, HttpResponse } from "msw";
import { MemoryRouter, Route, Routes } from "react-router"; import { useState } from "react";
import { getI18n } from "react-i18next";
import { MemoryRouter, Route, Routes, useNavigate } from "react-router";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { BrokerageOperation } from "@/entities/broker-operation"; import type { BrokerageOperation } from "@/entities/broker-operation";
@@ -36,6 +38,19 @@ import { AccountPageError } from "./components/AccountPageError.view";
import { AccountPageLoaded } from "./components/AccountPageLoaded.view"; import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
import { AccountPageLoading } from "./components/AccountPageLoading/AccountPageLoading.view"; import { AccountPageLoading } from "./components/AccountPageLoading/AccountPageLoading.view";
function AccountSwitcher() {
const navigate = useNavigate();
return (
<button
onClick={() => navigate("/accounts/account-2")}
type="button"
>
Другой счёт
</button>
);
}
function configureTestAuth() { function configureTestAuth() {
configureAuthClient({ configureAuthClient({
accessToken: () => "test-token", accessToken: () => "test-token",
@@ -51,6 +66,8 @@ function renderAccountRoute(queryClient: QueryClient, accountId = "account-1") {
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<MantineProvider> <MantineProvider>
<MemoryRouter initialEntries={[`/accounts/${accountId}`]}> <MemoryRouter initialEntries={[`/accounts/${accountId}`]}>
<AccountSwitcher />
<Routes> <Routes>
<Route element={<AccountPage />} path="/accounts/:accountId" /> <Route element={<AccountPage />} path="/accounts/:accountId" />
</Routes> </Routes>
@@ -141,6 +158,19 @@ const emptyRecentEvents: AccountPageRecentEvents = {
to: "/accounts/account-1/events", to: "/accounts/account-1/events",
}; };
function concentrationLabels() {
const i18n = getI18n();
return {
missingIssuer: i18n.t("portfolio.concentration.missingIssuer"),
missingSector: i18n.t("portfolio.concentration.missingSector"),
other: i18n.t("portfolio.concentration.other"),
sector: (code: string) => i18n.t(`stock.sectors.${code}`, {
defaultValue: code,
}),
};
}
function emptyProps() { function emptyProps() {
return { return {
recentEvents: emptyRecentEvents, recentEvents: emptyRecentEvents,
@@ -148,6 +178,21 @@ function emptyProps() {
}; };
} }
const LoadedHarness = (
props: Omit<Partial<Parameters<typeof AccountPageLoaded>[0]>, "onTabChange">,
) => {
const [tab, setTab] = useState(props.tab ?? "positions");
return (
<AccountPageLoaded
{...loadedProps()}
{...props}
onTabChange={value => setTab(value ?? "positions")}
tab={tab}
/>
);
};
function loadedProps(nextPortfolio: Portfolio = portfolio) { function loadedProps(nextPortfolio: Portfolio = portfolio) {
return { return {
concentration: null, concentration: null,
@@ -182,6 +227,8 @@ describe("AccountPage states", () => {
expect(tab).toHaveAttribute("aria-selected", "false"); expect(tab).toHaveAttribute("aria-selected", "false");
fireEvent.click(tab); fireEvent.click(tab);
expect(screen.getAllByText("100 ₽")).toHaveLength(2); expect(screen.getAllByText("100 ₽")).toHaveLength(2);
expect(screen.getByRole("img", { name: "Распределение позиций" }))
.toHaveAccessibleDescription(/Акция: 100 ₽, 100 %/);
}); });
it("expands and resets other when switching slice", () => { it("expands and resets other when switching slice", () => {
@@ -193,16 +240,18 @@ describe("AccountPage states", () => {
); );
const data = { ...portfolio, positions }; const data = { ...portfolio, positions };
const model = toConcentration(data, "stocks", "RUB");
const model = toConcentration(
data, "stocks", "RUB", concentrationLabels(),
);
const props = loadedProps(data); const props = loadedProps(data);
const onTabChange = vi.fn();
renderPage( renderPage(
<AccountPageLoaded <LoadedHarness
{...props} {...props}
accountId="a" accountId="a"
concentration={model} concentration={model}
currency="RUB" currency="RUB"
onTabChange={onTabChange}
tab="concentration" tab="concentration"
/>, />,
); );
@@ -214,6 +263,272 @@ describe("AccountPage states", () => {
fireEvent.click(screen.getByRole("radio", { name: "Секторы" })); fireEvent.click(screen.getByRole("radio", { name: "Секторы" }));
expect(props.onSliceChange).toHaveBeenCalledWith("sectors"); expect(props.onSliceChange).toHaveBeenCalledWith("sectors");
}); });
it.each([
[2, "2 элемента"],
[5, "5 элементов"],
[21, "21 элемент"],
])("pluralizes the other count %d as %s", (count, expected) => {
const values = [400, 250, 150, 100, 50];
const rest = Array.from(
{ length: count },
(_, index) => 20 + index,
);
const positions = [...values, ...rest].map(
(value, index) => position({
currentValue: { currency: "RUB", value: String(value) },
displayName: `Акция ${index + 1}`,
}),
);
renderPage(
<LoadedHarness
{...loadedProps({ ...portfolio, positions })}
accountId="a"
concentration={toConcentration(
{ ...portfolio, positions },
"stocks",
"RUB",
concentrationLabels(),
)}
currency="RUB"
tab="concentration"
/>,
);
const other = screen.getByRole("button", { name: /Прочее/ });
expect(other).toHaveTextContent(expected);
});
it("shows ticker subtitles and stocks rows with logos, issuers without", () => {
const positions = [
position({
currentValue: { currency: "RUB", value: "300" },
displayName: "Сбербанк",
ticker: "SBER",
}),
];
const data = { ...portfolio, positions };
const view = renderPage(
<LoadedHarness
{...loadedProps(data)}
accountId="a"
concentration={toConcentration(
data, "stocks", "RUB", concentrationLabels(),
)}
currency="RUB"
tab="concentration"
/>,
);
expect(screen.getByText("SBER")).toBeInTheDocument();
view.unmount();
renderPage(
<LoadedHarness
{...loadedProps(data)}
accountId="a"
concentration={toConcentration(
data, "issuers", "RUB", concentrationLabels(),
)}
currency="RUB"
slice="issuers"
tab="concentration"
/>,
);
expect(screen.queryByText("SBER")).not.toBeInTheDocument();
});
it("hides the currency select when only one currency is available", () => {
const data = { ...portfolio, positions: [position({
currentValue: { currency: "RUB", value: "100" },
displayName: "Акция",
})] };
renderPage(
<LoadedHarness
{...loadedProps(data)}
accountId="a"
concentration={toConcentration(
data, "stocks", "RUB", concentrationLabels(),
)}
currency="RUB"
tab="concentration"
/>,
);
expect(screen.queryByRole("combobox", { name: "Валюта концентрации" }))
.not.toBeInTheDocument();
});
it("shows sector labels including government and unknown codes", () => {
const positions = [
position({
currentValue: { currency: "RUB", value: "300" },
displayName: "А",
sector: "government",
}),
position({
currentValue: { currency: "RUB", value: "100" },
displayName: "Б",
sector: "unknown_code",
}),
];
const data = { ...portfolio, positions };
renderPage(
<LoadedHarness
{...loadedProps(data)}
accountId="a"
concentration={toConcentration(
data, "sectors", "RUB", concentrationLabels(),
)}
currency="RUB"
slice="sectors"
tab="concentration"
/>,
);
expect(screen.getByText("Государственный сектор")).toBeInTheDocument();
expect(screen.getByText("unknown_code")).toBeInTheDocument();
});
it("describes ranked segments with amounts and shares", () => {
const data = { ...portfolio, positions: [
position({
currentValue: { currency: "RUB", value: "300" },
displayName: "Первая акция",
}),
position({
currentValue: { currency: "RUB", value: "100" },
displayName: "Вторая акция",
}),
...[50, 40, 30, 20, 10].map((value, index) => position({
currentValue: { currency: "RUB", value: String(value) },
displayName: `Остаток ${index + 1}`,
})),
] };
renderPage(
<LoadedHarness
{...loadedProps(data)}
accountId="a"
concentration={toConcentration(
data, "stocks", "RUB", concentrationLabels(),
)}
currency="RUB"
tab="concentration"
/>,
);
const chart = screen.getByRole("img", { name: "Распределение позиций" });
expect(chart).toHaveAccessibleDescription(
/Первая акция: 300 ₽, 54,5 %; Вторая акция: 100 ₽, 18,2 %; Остаток 1: 50 ₽, 9,1 %; Остаток 2: 40 ₽, 7,3 %; Остаток 3: 30 ₽, 5,5 %; Прочее: 30 ₽, 5,5 %/,
);
expect(screen.getByRole("button", { name: /Прочее/ }))
.toBeInTheDocument();
});
it("does not duplicate the partial warning on the concentration tab", () => {
const data = { ...portfolio, positions: [position({
currentValue: { currency: "RUB", value: "100" },
displayName: "Акция",
})] };
renderPage(
<LoadedHarness
{...loadedProps(data)}
accountId="a"
concentration={toConcentration(
data, "stocks", "RUB", concentrationLabels(),
)}
currency="RUB"
partial
tab="concentration"
/>,
);
expect(screen.getAllByRole("status"))
.toHaveLength(1);
});
it("keeps slice and currency while switching page tabs and resets on account change", async () => {
const multiCurrency = { ...portfolio, positions:
["RUB", "USD"].flatMap(currency =>
[400, 250, 150, 100, 50, 30, 20].map((value, index) =>
position({
currentValue: { currency, value: String(value) },
displayName: `${currency} акция ${index + 1}`,
sector: "energy", type: "STOCK",
}),
),
) };
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(resource(multiCurrency)),
),
);
const client = createTestQueryClient();
renderAccountRoute(client);
fireEvent.click(await screen.findByRole("tab", { name: "Концентрация" }));
fireEvent.click(screen.getByRole("button", { name: /Прочее/ }));
expect(screen.getByRole("button", { name: /Прочее/ }))
.toHaveAttribute("aria-expanded", "true");
fireEvent.click(screen.getByRole("combobox", { name: "Валюта концентрации" }));
fireEvent.click(screen.getByRole("option", { name: "USD" }));
expect(screen.getByRole("button", { name: /Прочее/ }))
.toHaveAttribute("aria-expanded", "false");
fireEvent.click(screen.getByRole("button", { name: /Прочее/ }));
fireEvent.click(screen.getByRole("radio", { name: "Секторы" }));
fireEvent.click(screen.getByRole("radio", { name: "Акции" }));
expect(screen.getByRole("button", { name: /Прочее/ }))
.toHaveAttribute("aria-expanded", "false");
fireEvent.click(screen.getByRole("radio", { name: "Секторы" }));
expect(screen.getByRole("combobox", { name: "Валюта концентрации" }))
.toHaveValue("USD");
fireEvent.click(screen.getByRole("tab", { name: "Позиции" }));
fireEvent.click(screen.getByRole("tab", { name: "Концентрация" }));
expect(screen.getByRole("radio", { name: "Секторы" }))
.toBeChecked();
expect(screen.getByRole("combobox", { name: "Валюта концентрации" }))
.toHaveValue("USD");
fireEvent.click(screen.getByRole("button", { name: "Другой счёт" }));
fireEvent.click(await screen.findByRole("tab", { name: "Концентрация" }));
expect(screen.getByRole("radio", { name: "Акции" })).toBeChecked();
expect(screen.getByRole("combobox", { name: "Валюта концентрации" }))
.toHaveValue("RUB");
});
it("falls back to the first currency when the selected one disappears", () => {
const data = { ...portfolio, positions: [
position({
currentValue: { currency: "USD", value: "200" },
}),
position({
currentValue: { currency: "RUB", value: "100" },
}),
] };
const model = toConcentration(
data, "stocks", "EUR", concentrationLabels(),
);
expect(model.currencies).toEqual(["RUB", "USD"]);
expect(model.total).toBe("100");
expect(model.items.map(item => item.amount)).toEqual(["100"]);
});
it("shows the loaded portfolio source sync status", async () => { it("shows the loaded portfolio source sync status", async () => {
server.use( server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
@@ -368,7 +683,7 @@ describe("AccountPage states", () => {
it("shows supplied cash separately without adding currency instruments or recalculating totals", () => { it("shows supplied cash separately without adding currency instruments or recalculating totals", () => {
renderPage( renderPage(
<AccountPageLoaded <LoadedHarness
{...loadedProps({ {...loadedProps({
...portfolio, ...portfolio,
cashBalances: [ cashBalances: [
@@ -552,7 +867,7 @@ describe("AccountPage states", () => {
}); });
it("renders a stale portfolio in upstream order with nullable values hidden", () => { it("renders a stale portfolio in upstream order with nullable values hidden", () => {
renderPage(<AccountPageLoaded {...loadedProps()} />); renderPage(<LoadedHarness {...loadedProps()} />);
expect( expect(
screen.getByRole("heading", { level: 1, name: "Портфель" }), screen.getByRole("heading", { level: 1, name: "Портфель" }),
@@ -590,7 +905,7 @@ describe("AccountPage states", () => {
"renders %s logos, fallbacks and signed returns including zero", "renders %s logos, fallbacks and signed returns including zero",
(type) => { (type) => {
renderPage( renderPage(
<AccountPageLoaded <LoadedHarness
{...loadedProps({ {...loadedProps({
...portfolio, ...portfolio,
positions: [ positions: [
@@ -633,7 +948,7 @@ describe("AccountPage states", () => {
"groups %s supporting metrics into quiet single lines", "groups %s supporting metrics into quiet single lines",
(type) => { (type) => {
renderPage( renderPage(
<AccountPageLoaded <LoadedHarness
{...loadedProps({ {...loadedProps({
...portfolio, ...portfolio,
positions: [position({ positions: [position({
@@ -655,7 +970,7 @@ describe("AccountPage states", () => {
it("uses the ticker only for stock cards and otherwise keeps the ISIN", () => { it("uses the ticker only for stock cards and otherwise keeps the ISIN", () => {
renderPage( renderPage(
<AccountPageLoaded <LoadedHarness
{...loadedProps({ {...loadedProps({
...portfolio, ...portfolio,
positions: [ positions: [
@@ -680,7 +995,7 @@ describe("AccountPage states", () => {
it("keeps other-position cards compact with labeled return and no logo", () => { it("keeps other-position cards compact with labeled return and no logo", () => {
renderPage( renderPage(
<AccountPageLoaded <LoadedHarness
{...loadedProps({ {...loadedProps({
...portfolio, ...portfolio,
positions: [position({ positions: [position({
@@ -698,7 +1013,7 @@ describe("AccountPage states", () => {
it("uses the cached account title when available", () => { it("uses the cached account title when available", () => {
renderPage( renderPage(
<AccountPageLoaded <LoadedHarness
{...loadedProps()} {...loadedProps()}
account={{ account={{
@@ -717,7 +1032,7 @@ describe("AccountPage states", () => {
it("keeps the portfolio visible when recent operations fail", () => { it("keeps the portfolio visible when recent operations fail", () => {
renderPage( renderPage(
<AccountPageLoaded <LoadedHarness
{...loadedProps()} {...loadedProps()}
recentOperations={{ recentOperations={{
state: "error", state: "error",
@@ -736,7 +1051,7 @@ describe("AccountPage states", () => {
it("uses the ledger row for a recent outgoing trade operation", () => { it("uses the ledger row for a recent outgoing trade operation", () => {
renderPage( renderPage(
<AccountPageLoaded <LoadedHarness
{...loadedProps()} {...loadedProps()}
recentOperations={{ recentOperations={{
@@ -786,7 +1101,7 @@ describe("AccountPage states", () => {
}; };
renderPage( renderPage(
<AccountPageLoaded <LoadedHarness
{...loadedProps()} {...loadedProps()}
recentEvents={{ recentEvents={{
@@ -855,7 +1170,7 @@ describe("AccountPage states", () => {
"keeps the upcoming %s state and calendar action", "keeps the upcoming %s state and calendar action",
(state) => { (state) => {
renderPage( renderPage(
<AccountPageLoaded <LoadedHarness
{...loadedProps()} {...loadedProps()}
recentEvents={{ state, to: "/accounts/account-1/events" }} recentEvents={{ state, to: "/accounts/account-1/events" }}
/>, />,
@@ -892,7 +1207,7 @@ describe("AccountPage states", () => {
it("keeps the portfolio visible when upcoming events fail", () => { it("keeps the portfolio visible when upcoming events fail", () => {
renderPage( renderPage(
<AccountPageLoaded <LoadedHarness
{...loadedProps()} {...loadedProps()}
recentEvents={{ recentEvents={{
state: "error", state: "error",
@@ -1057,7 +1372,7 @@ describe("AccountPage states", () => {
}); });
it("opens positions by default and switches between local tabs", () => { it("opens positions by default and switches between local tabs", () => {
renderPage(<AccountPageLoaded {...loadedProps()} />); renderPage(<LoadedHarness {...loadedProps()} />);
expect(screen.getByRole("tab", { name: "Позиции" })) expect(screen.getByRole("tab", { name: "Позиции" }))
.toHaveAttribute("aria-selected", "true"); .toHaveAttribute("aria-selected", "true");
@@ -1074,7 +1389,7 @@ describe("AccountPage states", () => {
it("links operation, event and income previews to their detail pages", () => { it("links operation, event and income previews to their detail pages", () => {
renderPage( renderPage(
<AccountPageLoaded <LoadedHarness
{...loadedProps()} {...loadedProps()}
incomeSummary={{ incomeSummary={{
state: "empty", state: "empty",
@@ -1095,7 +1410,7 @@ describe("AccountPage states", () => {
}); });
it("filters positions by the kinds present in the portfolio", () => { it("filters positions by the kinds present in the portfolio", () => {
renderPage(<AccountPageLoaded {...loadedProps()} />); renderPage(<LoadedHarness {...loadedProps()} />);
expect( expect(
screen.getAllByRole<HTMLInputElement>("radio").map(radio => radio.value), screen.getAllByRole<HTMLInputElement>("radio").map(radio => radio.value),
@@ -1109,7 +1424,7 @@ describe("AccountPage states", () => {
it("hides the position filter when only one kind exists", () => { it("hides the position filter when only one kind exists", () => {
renderPage( renderPage(
<AccountPageLoaded <LoadedHarness
{...loadedProps({ {...loadedProps({
...portfolio, ...portfolio,
positions: [portfolio.positions[0]], positions: [portfolio.positions[0]],
@@ -1122,7 +1437,7 @@ describe("AccountPage states", () => {
}); });
it("hides unrealized returns for a partial portfolio", () => { it("hides unrealized returns for a partial portfolio", () => {
renderPage(<AccountPageLoaded {...loadedProps()} partial />); renderPage(<LoadedHarness {...loadedProps()} partial />);
expect(screen.queryByRole("region", { name: "Нереализованный результат" })) expect(screen.queryByRole("region", { name: "Нереализованный результат" }))
.not.toBeInTheDocument(); .not.toBeInTheDocument();
@@ -1130,7 +1445,7 @@ describe("AccountPage states", () => {
it("omits an unrealized currency total when any return is incomplete", () => { it("omits an unrealized currency total when any return is incomplete", () => {
renderPage( renderPage(
<AccountPageLoaded <LoadedHarness
{...loadedProps({ {...loadedProps({
...portfolio, ...portfolio,
positions: [ positions: [
@@ -1,4 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router"; import { useParams } from "react-router";
import type { Portfolio } from "@/entities/portfolio"; import type { Portfolio } from "@/entities/portfolio";
@@ -20,6 +21,8 @@ import { useInitialAvailability } from "@/shared/lib";
import { import {
type Concentration, type Concentration,
concentrationCurrencies,
type ConcentrationLabels,
type Slice, type Slice,
toConcentration, toConcentration,
} from "../../model/concentration"; } from "../../model/concentration";
@@ -79,6 +82,7 @@ function isPortfolioAccessFailure(code?: string) {
function useAccountPageViewModel(): AccountPageViewModel { function useAccountPageViewModel(): AccountPageViewModel {
const { accountId } = useParams(); const { accountId } = useParams();
const { t } = useTranslation();
const [selection, setSelection] = useState<{ const [selection, setSelection] = useState<{
accountId?: string; accountId?: string;
@@ -152,15 +156,22 @@ function useAccountPageViewModel(): AccountPageViewModel {
}; };
const isPartial = portfolio.meta?.completeness === "incomplete"; const isPartial = portfolio.meta?.completeness === "incomplete";
const currencies = toConcentration(portfolio.data, "stocks", null).currencies; const currencies = concentrationCurrencies(portfolio.data);
const currency = current.currency && currencies.includes(current.currency) const currency = current.currency && currencies.includes(current.currency)
? current.currency ? current.currency
: currencies[0] ?? null; : 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" const concentration = accountId !== "all"
&& current.tab === "concentration" && current.tab === "concentration"
? toConcentration(portfolio.data, current.slice, currency) ? toConcentration(portfolio.data, current.slice, currency, labels)
: null; : null;
return { return {
@@ -42,7 +42,7 @@ type AccountPageLoadedProps = {
incomeSummary?: AccountPageIncomeSummary; incomeSummary?: AccountPageIncomeSummary;
onCurrencyChange: (currency: null | string) => void; onCurrencyChange: (currency: null | string) => void;
onSliceChange: (slice: Slice) => void; onSliceChange: (slice: Slice) => void;
onTabChange?: (tab: null | string) => void; onTabChange: (tab: null | string) => void;
partial?: boolean; partial?: boolean;
portfolio: Portfolio; portfolio: Portfolio;
positionSections: AccountPagePositionSection[]; positionSections: AccountPagePositionSection[];
@@ -50,18 +50,16 @@ type AccountPageLoadedProps = {
recentOperations: AccountPageRecentOperations; recentOperations: AccountPageRecentOperations;
slice: Slice; slice: Slice;
source?: InvestmentResourceMeta; source?: InvestmentResourceMeta;
tab?: string; tab: string;
unrealizedReturns: Portfolio["totalsByCurrency"]; unrealizedReturns: Portfolio["totalsByCurrency"];
}; };
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => { const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [filter, setFilter] = useState<string>(); const [filter, setFilter] = useState<string>();
const [fallbackTab, setFallbackTab] = useState("positions"); const tab = props.tab;
const tab = props.tab ?? fallbackTab;
const onTabChange = props.onTabChange const onTabChange = props.onTabChange;
?? ((value: null | string) => setFallbackTab(value ?? "positions"));
const activeSection = props.positionSections.find( const activeSection = props.positionSections.find(
section => section.kind === filter, section => section.kind === filter,
@@ -14,6 +14,7 @@ import type {
Slice, Slice,
} from "../../../../model/concentration"; } from "../../../../model/concentration";
import { PositionLogo } from "../PositionLogo.view";
import classes from "./Concentration.module.css"; import classes from "./Concentration.module.css";
type Props = { type Props = {
@@ -37,6 +38,7 @@ type RowProps = {
currency: string; currency: string;
index: number; index: number;
item: ConcentrationItem; item: ConcentrationItem;
logo?: ConcentrationItem["logo"];
name: string; name: string;
}; };
@@ -46,29 +48,12 @@ const colors = [
]; ];
const PERCENT = 100; const PERCENT = 100;
const RANK_THRESHOLD = 0.04;
const OTHER_START = 6; const OTHER_START = 6;
function amountFor(item: ConcentrationItem, currency: string): string { function amountFor(item: ConcentrationItem, currency: string): string {
return formatMoney({ currency, value: item.amount }); return formatMoney({ currency, value: item.amount });
} }
function nameFor(
item: ConcentrationItem,
slice: Slice,
t: ReturnType<typeof useTranslation>["t"],
): string {
if (item.key === "__other") return t("portfolio.concentration.other");
if (item.unknown) return t(slice === "sectors"
? "portfolio.concentration.missingSector"
: "portfolio.concentration.missingIssuer");
if (slice === "sectors") return t(`stock.sectors.${item.name}`, {
defaultValue: item.name,
});
return item.name;
}
const ConcentrationRow: FC<RowProps> = props => ( const ConcentrationRow: FC<RowProps> = props => (
<li className={classes.row}> <li className={classes.row}>
<span <span
@@ -79,9 +64,15 @@ const ConcentrationRow: FC<RowProps> = props => (
{props.index} {props.index}
</span> </span>
{props.logo && <PositionLogo label={props.name} src={props.logo.src} />}
<span className={classes.identity}> <span className={classes.identity}>
<span>{props.name}</span> <span>{props.name}</span>
{props.logo?.subtitle && (
<Text c="dimmed" size="xs">{props.logo.subtitle}</Text>
)}
<span aria-hidden="true" className={classes.bar}> <span aria-hidden="true" className={classes.bar}>
<span style={{ <span style={{
backgroundColor: props.color, backgroundColor: props.color,
@@ -99,20 +90,42 @@ const ConcentrationRow: FC<RowProps> = props => (
</li> </li>
); );
type TooltipProps = {
active?: boolean;
payload?: { name?: number | string }[];
rows: { amount: string; label: string; share: string }[];
};
function chartCellProps(series: { name: string }, rows: ConcentrationItem[]) { function chartCellProps(series: { name: string }, rows: ConcentrationItem[]) {
const index = Number(series.name);
const item = rows.at(index);
return { return {
stroke: "white", stroke: "white",
strokeWidth: 2, strokeWidth: 2,
...((series.name === "__other" ...((item?.unknown || (index === rows.length - 1
|| rows.some(item => item.key === series.name && item.unknown)) && item?.key === "__other"))
&& { strokeDasharray: "3 2" }), && { strokeDasharray: "3 2" }),
}; };
} }
function rankLabel({ index, percent }: { index: number; percent?: number }) { function rankLabel({ index }: { index: number; percent?: number }) {
return (percent ?? 0) >= RANK_THRESHOLD ? String(index + 1) : ""; return String(index + 1);
} }
const ChartTooltip: FC<TooltipProps> = (props) => {
const row = props.rows[Number(props.payload?.[0]?.name)];
if (!row || !props.active) return;
return (
<Paper p="sm" radius="md" shadow="sm">
<Text fw={600} size="sm">{row.label}</Text>
<Text size="sm">{row.amount} · {row.share}</Text>
</Paper>
);
};
const Concentration: FC<Props> = (props) => { const Concentration: FC<Props> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
@@ -126,7 +139,7 @@ const Concentration: FC<Props> = (props) => {
const chartData = rows.map((item, index) => ({ const chartData = rows.map((item, index) => ({
color: colors[index % colors.length] ?? colors[0] ?? "blue", color: colors[index % colors.length] ?? colors[0] ?? "blue",
name: item.key, name: String(index),
value: item.chartValue, value: item.chartValue,
})); }));
@@ -137,8 +150,7 @@ const Concentration: FC<Props> = (props) => {
props.model.total && formatMoney({ props.model.total && formatMoney({
currency, value: props.model.total, currency, value: props.model.total,
}), }),
...rows.map(item => ...rows.map(item => `${item.label}: ${amountFor(item, currency)}, ${item.share}`),
`${nameFor(item, props.slice, t)} · ${amountFor(item, currency)} · ${item.share}`),
].filter(Boolean).join("; "); ].filter(Boolean).join("; ");
const slices = [ const slices = [
@@ -157,6 +169,14 @@ const Concentration: FC<Props> = (props) => {
const cellProps = (series: { name: string }) => chartCellProps(series, rows); const cellProps = (series: { name: string }) => chartCellProps(series, rows);
const pieProps = { isAnimationActive: false, label: rankLabel }; const pieProps = { isAnimationActive: false, label: rankLabel };
const tooltipRows = rows.map(item => ({
amount: amountFor(item, currency),
label: item.label,
share: item.share,
}));
const tooltipProps = { content: <ChartTooltip rows={tooltipRows} /> };
const totalText = props.model.total const totalText = props.model.total
? formatMoney({ currency, value: props.model.total }) ? formatMoney({ currency, value: props.model.total })
: ""; : "";
@@ -174,6 +194,7 @@ const Concentration: FC<Props> = (props) => {
{props.model.currencies.length > 1 && ( {props.model.currencies.length > 1 && (
<Select <Select
allowDeselect={false}
aria-label={t("portfolio.concentration.currency")} aria-label={t("portfolio.concentration.currency")}
data={props.model.currencies} data={props.model.currencies}
onChange={props.onCurrencyChange} onChange={props.onCurrencyChange}
@@ -209,6 +230,8 @@ const Concentration: FC<Props> = (props) => {
role="img" role="img"
size={260} size={260}
strokeWidth={24} strokeWidth={24}
tooltipDataSource="segment"
tooltipProps={tooltipProps}
/> />
<div aria-hidden="true" className={classes.center}> <div aria-hidden="true" className={classes.center}>
@@ -222,7 +245,10 @@ const Concentration: FC<Props> = (props) => {
<Text fw={700}>{totalText}</Text> <Text fw={700}>{totalText}</Text>
</div> </div>
<Text className={classes.srOnly} id={chartId}> <Text
className={classes.srOnly}
id={chartId}
>
{chartText} {chartText}
</Text> </Text>
</Box> </Box>
@@ -238,7 +264,8 @@ const Concentration: FC<Props> = (props) => {
index={index + 1} index={index + 1}
item={item} item={item}
key={item.key} key={item.key}
name={nameFor(item, props.slice, t)} logo={item.logo}
name={item.label}
/> />
))} ))}
</ol> </ol>
@@ -278,7 +305,8 @@ const Concentration: FC<Props> = (props) => {
index={index + OTHER_START} index={index + OTHER_START}
item={item} item={item}
key={item.key} key={item.key}
name={nameFor(item, props.slice, t)} logo={item.logo}
name={item.label}
/> />
))} ))}
</ol> </ol>
+4
View File
@@ -519,6 +519,10 @@ const ru = {
missingSector: "Сектор не указан", missingSector: "Сектор не указан",
other: "Прочее", other: "Прочее",
otherCount: "{{count}} элементов", otherCount: "{{count}} элементов",
otherCount_few: "{{count}} элемента",
otherCount_many: "{{count}} элементов",
otherCount_one: "{{count}} элемент",
otherCount_other: "{{count}} элемента",
sectors: "Секторы", sectors: "Секторы",
slice: "Разрез концентрации", slice: "Разрез концентрации",
title: "Концентрация", title: "Концентрация",