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", () => {
it("uses exact positive values and excludes cash, negatives and nulls", () => {
const model = toConcentration(snapshot([
stock("A", "1"), stock("B", "15"),
stock("C", "0"), stock("D", "-50"), stock("E", null),
stock("F", null, "USD"), stock("G", "-3", "USD"),
]), "stocks", "RUB");
]), "stocks", "RUB", labels);
expect(model.currencies).toEqual(["RUB", "USD"]);
expect(model.total).toBe("16");
expect(model.items.map(item => item.share)).toEqual(["93,8 %", "6,3 %"]);
expect(model.unaccounted).toBe(3);
const negative = snapshot([stock("A", "-3", "USD")]);
expect(toConcentration(negative, "stocks", "USD"))
expect(toConcentration(negative, "stocks", "USD", labels))
.toMatchObject({ empty: true, unaccounted: 1 });
});
@@ -45,7 +52,7 @@ describe("account concentration", () => {
stock("A", "400"), stock("B", "250"), stock("C", "150"),
stock("D", "100"), stock("E", "50"), stock("F", "30"),
stock("G", "20"),
]), "stocks", "RUB");
]), "stocks", "RUB", labels);
expect(model.items.map(item => item.amount)).toEqual([
"400", "250", "150", "100", "50",
@@ -64,7 +71,7 @@ describe("account concentration", () => {
stock("Сбер", "100"), stock("X5", "100"),
stock("VK", "100"), stock("D", "100"),
stock("E", "100"), stock("F", "100"),
]), "stocks", "RUB");
]), "stocks", "RUB", labels);
expect(model.items).toHaveLength(6);
expect(model.other).toBeNull();
@@ -76,7 +83,7 @@ describe("account concentration", () => {
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 %");
});
@@ -96,28 +103,73 @@ describe("account concentration", () => {
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"]);
expect(toConcentration(snapshot(positions), "issuers", "RUB")
expect(toConcentration(snapshot(positions), "issuers", "RUB", labels)
.items.map(item => [item.name, item.amount])).toEqual([
["Сбербанк", "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", () => {
const data = snapshot([
stock("A", "200", "RUB", {
issuer: { id: "brand", name: "Сбербанк" },
stock("A", "100", "RUB", {
issuer: { id: "brand", name: "Ястреб" },
}),
stock("B", "200", "RUB", {
issuer: { id: "brand", name: "Сбер" },
stock("B", "300", "RUB", {
issuer: { id: "brand", name: "Альфа" },
}),
stock("C", "300", "RUB", {
issuer: { id: "other", name: "Альфа" },
stock("C", "400", "RUB", {
issuer: { id: "other", name: "Бета" },
}),
]);
expect(toConcentration(data, "issuers", "RUB").items[0]?.name)
.toBe("Сбер");
expect(toConcentration(data, "issuers", "RUB", labels).items
.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,
decimalFromString,
decimalToString,
instrumentLogoUrl,
sumDecimals,
} from "@/shared/lib";
@@ -21,11 +22,25 @@ type ConcentrationItem = {
amount: string;
chartValue: number;
key: string;
label: string;
logo: ConcentrationLogo | null;
name: string;
share: string;
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 & {
count: number;
entries: ConcentrationItem[];
@@ -35,6 +50,8 @@ type Group = {
best?: { name: string; value: Decimal };
first: number;
key: string;
label: string;
logo: ConcentrationLogo | null;
name: string;
unknown: boolean;
values: Decimal[];
@@ -65,6 +82,7 @@ function addPosition(
position: Position,
index: number,
slice: Slice,
labels: ConcentrationLabels,
selected: null | string,
): number {
if (!isIncluded(slice, position.type)) return 0;
@@ -73,7 +91,7 @@ function addPosition(
const value = decimalFromString(position.currentValue.value);
if (value.value < 0n) return 1;
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) ?? {
...identity, first: index, values: [],
@@ -89,6 +107,7 @@ function addPosition(
&& compareNames(identity.name, best.name) < 0)) {
group.best = { name: identity.name, value };
group.name = identity.name;
group.label = identity.label;
}
}
@@ -116,23 +135,58 @@ function compareDecimal(a: Decimal, b: Decimal): number {
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") {
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") {
const isUnknown = !position.issuer;
return {
key: position.issuer?.id ?? "",
label: position.issuer ? position.issuer.name : labels.missingIssuer,
logo: null,
name: position.issuer?.name ?? "",
unknown: !position.issuer,
unknown: isUnknown,
};
}
return {
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,
unknown: false,
};
@@ -161,14 +215,9 @@ function toConcentration(
portfolio: Portfolio,
slice: Slice,
currency: null | string,
labels: ConcentrationLabels,
): Concentration {
const currencies = [...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));
const currencies = concentrationCurrencies(portfolio);
const selected = currency && currencies.includes(currency)
? currency
@@ -178,13 +227,15 @@ function toConcentration(
let unaccounted = 0;
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 => ({
...group, sum: sumDecimals(group.values),
})).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
? sumDecimals(ranked.map(item => item.sum))
@@ -195,6 +246,8 @@ function toConcentration(
amount: decimalToString(group.sum),
chartValue: chartValue(group.sum, total),
key: group.key,
label: group.label,
logo: group.logo,
name: group.name,
share: share(group.sum, total),
unknown: group.unknown,
@@ -214,6 +267,8 @@ function toConcentration(
count: rest.length,
entries: rest,
key: "__other",
label: labels.other,
logo: null,
name: "", share: share(otherSum, total), unknown: false,
}
: null;
@@ -233,5 +288,13 @@ function toConcentration(
};
}
export { toConcentration };
export type { Concentration, ConcentrationItem, Slice };
export {
concentrationCurrencies,
toConcentration,
};
export type {
Concentration,
ConcentrationItem,
ConcentrationLabels,
Slice,
};
@@ -8,7 +8,9 @@ import {
within,
} from "@testing-library/react";
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 type { BrokerageOperation } from "@/entities/broker-operation";
@@ -36,6 +38,19 @@ import { AccountPageError } from "./components/AccountPageError.view";
import { AccountPageLoaded } from "./components/AccountPageLoaded.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() {
configureAuthClient({
accessToken: () => "test-token",
@@ -51,6 +66,8 @@ function renderAccountRoute(queryClient: QueryClient, accountId = "account-1") {
<QueryClientProvider client={queryClient}>
<MantineProvider>
<MemoryRouter initialEntries={[`/accounts/${accountId}`]}>
<AccountSwitcher />
<Routes>
<Route element={<AccountPage />} path="/accounts/:accountId" />
</Routes>
@@ -141,6 +158,19 @@ const emptyRecentEvents: AccountPageRecentEvents = {
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() {
return {
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) {
return {
concentration: null,
@@ -182,6 +227,8 @@ describe("AccountPage states", () => {
expect(tab).toHaveAttribute("aria-selected", "false");
fireEvent.click(tab);
expect(screen.getAllByText("100 ₽")).toHaveLength(2);
expect(screen.getByRole("img", { name: "Распределение позиций" }))
.toHaveAccessibleDescription(/Акция: 100 ₽, 100 %/);
});
it("expands and resets other when switching slice", () => {
@@ -193,16 +240,18 @@ describe("AccountPage states", () => {
);
const data = { ...portfolio, positions };
const model = toConcentration(data, "stocks", "RUB");
const model = toConcentration(
data, "stocks", "RUB", concentrationLabels(),
);
const props = loadedProps(data);
const onTabChange = vi.fn();
renderPage(
<AccountPageLoaded
<LoadedHarness
{...props}
accountId="a"
concentration={model}
currency="RUB"
onTabChange={onTabChange}
tab="concentration"
/>,
);
@@ -214,6 +263,272 @@ describe("AccountPage states", () => {
fireEvent.click(screen.getByRole("radio", { name: "Секторы" }));
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 () => {
server.use(
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", () => {
renderPage(
<AccountPageLoaded
<LoadedHarness
{...loadedProps({
...portfolio,
cashBalances: [
@@ -552,7 +867,7 @@ describe("AccountPage states", () => {
});
it("renders a stale portfolio in upstream order with nullable values hidden", () => {
renderPage(<AccountPageLoaded {...loadedProps()} />);
renderPage(<LoadedHarness {...loadedProps()} />);
expect(
screen.getByRole("heading", { level: 1, name: "Портфель" }),
@@ -590,7 +905,7 @@ describe("AccountPage states", () => {
"renders %s logos, fallbacks and signed returns including zero",
(type) => {
renderPage(
<AccountPageLoaded
<LoadedHarness
{...loadedProps({
...portfolio,
positions: [
@@ -633,7 +948,7 @@ describe("AccountPage states", () => {
"groups %s supporting metrics into quiet single lines",
(type) => {
renderPage(
<AccountPageLoaded
<LoadedHarness
{...loadedProps({
...portfolio,
positions: [position({
@@ -655,7 +970,7 @@ describe("AccountPage states", () => {
it("uses the ticker only for stock cards and otherwise keeps the ISIN", () => {
renderPage(
<AccountPageLoaded
<LoadedHarness
{...loadedProps({
...portfolio,
positions: [
@@ -680,7 +995,7 @@ describe("AccountPage states", () => {
it("keeps other-position cards compact with labeled return and no logo", () => {
renderPage(
<AccountPageLoaded
<LoadedHarness
{...loadedProps({
...portfolio,
positions: [position({
@@ -698,7 +1013,7 @@ describe("AccountPage states", () => {
it("uses the cached account title when available", () => {
renderPage(
<AccountPageLoaded
<LoadedHarness
{...loadedProps()}
account={{
@@ -717,7 +1032,7 @@ describe("AccountPage states", () => {
it("keeps the portfolio visible when recent operations fail", () => {
renderPage(
<AccountPageLoaded
<LoadedHarness
{...loadedProps()}
recentOperations={{
state: "error",
@@ -736,7 +1051,7 @@ describe("AccountPage states", () => {
it("uses the ledger row for a recent outgoing trade operation", () => {
renderPage(
<AccountPageLoaded
<LoadedHarness
{...loadedProps()}
recentOperations={{
@@ -786,7 +1101,7 @@ describe("AccountPage states", () => {
};
renderPage(
<AccountPageLoaded
<LoadedHarness
{...loadedProps()}
recentEvents={{
@@ -855,7 +1170,7 @@ describe("AccountPage states", () => {
"keeps the upcoming %s state and calendar action",
(state) => {
renderPage(
<AccountPageLoaded
<LoadedHarness
{...loadedProps()}
recentEvents={{ state, to: "/accounts/account-1/events" }}
/>,
@@ -892,7 +1207,7 @@ describe("AccountPage states", () => {
it("keeps the portfolio visible when upcoming events fail", () => {
renderPage(
<AccountPageLoaded
<LoadedHarness
{...loadedProps()}
recentEvents={{
state: "error",
@@ -1057,7 +1372,7 @@ describe("AccountPage states", () => {
});
it("opens positions by default and switches between local tabs", () => {
renderPage(<AccountPageLoaded {...loadedProps()} />);
renderPage(<LoadedHarness {...loadedProps()} />);
expect(screen.getByRole("tab", { name: "Позиции" }))
.toHaveAttribute("aria-selected", "true");
@@ -1074,7 +1389,7 @@ describe("AccountPage states", () => {
it("links operation, event and income previews to their detail pages", () => {
renderPage(
<AccountPageLoaded
<LoadedHarness
{...loadedProps()}
incomeSummary={{
state: "empty",
@@ -1095,7 +1410,7 @@ describe("AccountPage states", () => {
});
it("filters positions by the kinds present in the portfolio", () => {
renderPage(<AccountPageLoaded {...loadedProps()} />);
renderPage(<LoadedHarness {...loadedProps()} />);
expect(
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", () => {
renderPage(
<AccountPageLoaded
<LoadedHarness
{...loadedProps({
...portfolio,
positions: [portfolio.positions[0]],
@@ -1122,7 +1437,7 @@ describe("AccountPage states", () => {
});
it("hides unrealized returns for a partial portfolio", () => {
renderPage(<AccountPageLoaded {...loadedProps()} partial />);
renderPage(<LoadedHarness {...loadedProps()} partial />);
expect(screen.queryByRole("region", { name: "Нереализованный результат" }))
.not.toBeInTheDocument();
@@ -1130,7 +1445,7 @@ describe("AccountPage states", () => {
it("omits an unrealized currency total when any return is incomplete", () => {
renderPage(
<AccountPageLoaded
<LoadedHarness
{...loadedProps({
...portfolio,
positions: [
@@ -1,4 +1,5 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router";
import type { Portfolio } from "@/entities/portfolio";
@@ -20,6 +21,8 @@ import { useInitialAvailability } from "@/shared/lib";
import {
type Concentration,
concentrationCurrencies,
type ConcentrationLabels,
type Slice,
toConcentration,
} from "../../model/concentration";
@@ -79,6 +82,7 @@ function isPortfolioAccessFailure(code?: string) {
function useAccountPageViewModel(): AccountPageViewModel {
const { accountId } = useParams();
const { t } = useTranslation();
const [selection, setSelection] = useState<{
accountId?: string;
@@ -152,15 +156,22 @@ function useAccountPageViewModel(): AccountPageViewModel {
};
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)
? 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)
? toConcentration(portfolio.data, current.slice, currency, labels)
: null;
return {
@@ -42,7 +42,7 @@ type AccountPageLoadedProps = {
incomeSummary?: AccountPageIncomeSummary;
onCurrencyChange: (currency: null | string) => void;
onSliceChange: (slice: Slice) => void;
onTabChange?: (tab: null | string) => void;
onTabChange: (tab: null | string) => void;
partial?: boolean;
portfolio: Portfolio;
positionSections: AccountPagePositionSection[];
@@ -50,18 +50,16 @@ type AccountPageLoadedProps = {
recentOperations: AccountPageRecentOperations;
slice: Slice;
source?: InvestmentResourceMeta;
tab?: string;
tab: string;
unrealizedReturns: Portfolio["totalsByCurrency"];
};
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
const { t } = useTranslation();
const [filter, setFilter] = useState<string>();
const [fallbackTab, setFallbackTab] = useState("positions");
const tab = props.tab ?? fallbackTab;
const tab = props.tab;
const onTabChange = props.onTabChange
?? ((value: null | string) => setFallbackTab(value ?? "positions"));
const onTabChange = props.onTabChange;
const activeSection = props.positionSections.find(
section => section.kind === filter,
@@ -14,6 +14,7 @@ import type {
Slice,
} from "../../../../model/concentration";
import { PositionLogo } from "../PositionLogo.view";
import classes from "./Concentration.module.css";
type Props = {
@@ -37,6 +38,7 @@ type RowProps = {
currency: string;
index: number;
item: ConcentrationItem;
logo?: ConcentrationItem["logo"];
name: string;
};
@@ -46,29 +48,12 @@ const colors = [
];
const PERCENT = 100;
const RANK_THRESHOLD = 0.04;
const OTHER_START = 6;
function amountFor(item: ConcentrationItem, currency: string): string {
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 => (
<li className={classes.row}>
<span
@@ -79,9 +64,15 @@ const ConcentrationRow: FC<RowProps> = props => (
{props.index}
</span>
{props.logo && <PositionLogo label={props.name} src={props.logo.src} />}
<span className={classes.identity}>
<span>{props.name}</span>
{props.logo?.subtitle && (
<Text c="dimmed" size="xs">{props.logo.subtitle}</Text>
)}
<span aria-hidden="true" className={classes.bar}>
<span style={{
backgroundColor: props.color,
@@ -99,20 +90,42 @@ const ConcentrationRow: FC<RowProps> = props => (
</li>
);
type TooltipProps = {
active?: boolean;
payload?: { name?: number | string }[];
rows: { amount: string; label: string; share: string }[];
};
function chartCellProps(series: { name: string }, rows: ConcentrationItem[]) {
const index = Number(series.name);
const item = rows.at(index);
return {
stroke: "white",
strokeWidth: 2,
...((series.name === "__other"
|| rows.some(item => item.key === series.name && item.unknown))
...((item?.unknown || (index === rows.length - 1
&& item?.key === "__other"))
&& { strokeDasharray: "3 2" }),
};
}
function rankLabel({ index, percent }: { index: number; percent?: number }) {
return (percent ?? 0) >= RANK_THRESHOLD ? String(index + 1) : "";
function rankLabel({ index }: { index: number; percent?: number }) {
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 { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
@@ -126,7 +139,7 @@ const Concentration: FC<Props> = (props) => {
const chartData = rows.map((item, index) => ({
color: colors[index % colors.length] ?? colors[0] ?? "blue",
name: item.key,
name: String(index),
value: item.chartValue,
}));
@@ -137,8 +150,7 @@ const Concentration: FC<Props> = (props) => {
props.model.total && formatMoney({
currency, value: props.model.total,
}),
...rows.map(item =>
`${nameFor(item, props.slice, t)} · ${amountFor(item, currency)} · ${item.share}`),
...rows.map(item => `${item.label}: ${amountFor(item, currency)}, ${item.share}`),
].filter(Boolean).join("; ");
const slices = [
@@ -157,6 +169,14 @@ const Concentration: FC<Props> = (props) => {
const cellProps = (series: { name: string }) => chartCellProps(series, rows);
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
? formatMoney({ currency, value: props.model.total })
: "";
@@ -174,6 +194,7 @@ const Concentration: FC<Props> = (props) => {
{props.model.currencies.length > 1 && (
<Select
allowDeselect={false}
aria-label={t("portfolio.concentration.currency")}
data={props.model.currencies}
onChange={props.onCurrencyChange}
@@ -209,6 +230,8 @@ const Concentration: FC<Props> = (props) => {
role="img"
size={260}
strokeWidth={24}
tooltipDataSource="segment"
tooltipProps={tooltipProps}
/>
<div aria-hidden="true" className={classes.center}>
@@ -222,7 +245,10 @@ const Concentration: FC<Props> = (props) => {
<Text fw={700}>{totalText}</Text>
</div>
<Text className={classes.srOnly} id={chartId}>
<Text
className={classes.srOnly}
id={chartId}
>
{chartText}
</Text>
</Box>
@@ -238,7 +264,8 @@ const Concentration: FC<Props> = (props) => {
index={index + 1}
item={item}
key={item.key}
name={nameFor(item, props.slice, t)}
logo={item.logo}
name={item.label}
/>
))}
</ol>
@@ -278,7 +305,8 @@ const Concentration: FC<Props> = (props) => {
index={index + OTHER_START}
item={item}
key={item.key}
name={nameFor(item, props.slice, t)}
logo={item.logo}
name={item.label}
/>
))}
</ol>
+4
View File
@@ -519,6 +519,10 @@ const ru = {
missingSector: "Сектор не указан",
other: "Прочее",
otherCount: "{{count}} элементов",
otherCount_few: "{{count}} элемента",
otherCount_many: "{{count}} элементов",
otherCount_one: "{{count}} элемент",
otherCount_other: "{{count}} элемента",
sectors: "Секторы",
slice: "Разрез концентрации",
title: "Концентрация",