fix: polish accounts portfolio summary (#159)
Frontend verify / commits (pull_request) Successful in 35s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 6m12s

This commit is contained in:
2026-11-06 15:09:13 +03:00
parent 2fd7d59e65
commit fcca784fc6
13 changed files with 146 additions and 101 deletions
@@ -65,18 +65,32 @@
.summary { .summary {
font-variant-numeric: tabular-nums lining-nums; font-variant-numeric: tabular-nums lining-nums;
white-space: nowrap; align-items: flex-end;
}
.currencySummary {
min-width: 0;
} }
.total { .total {
color: var(--wl-color-text); color: var(--wl-color-text);
font-size: var(--mantine-font-size-lg); font-size: var(--mantine-font-size-lg);
line-height: var(--mantine-line-height-lg); line-height: var(--mantine-line-height-lg);
overflow-wrap: anywhere;
} }
.return, .return,
.cashValue { .cashValue {
font-variant-numeric: tabular-nums lining-nums; font-variant-numeric: tabular-nums lining-nums;
}
.return {
justify-content: flex-end;
min-width: 0;
overflow-wrap: anywhere;
}
.cashValue {
white-space: nowrap; white-space: nowrap;
} }
@@ -138,7 +152,11 @@
text-align: left; text-align: left;
} }
.balance > div { .summary {
align-items: flex-start;
}
.return {
justify-content: flex-start; justify-content: flex-start;
} }
} }
@@ -119,25 +119,26 @@ describe("BrokerageAccountList", () => {
expect(within(link).getByText("123 456 789 012 345 678 902,00 ₽")) expect(within(link).getByText("123 456 789 012 345 678 902,00 ₽"))
.toBeInTheDocument(); .toBeInTheDocument();
expect(within(link).getByText("Еще 1 валюта")).toBeInTheDocument(); expect(within(link).getByText("Еще 1 валюта")).toBeInTheDocument();
expect(within(link).getByText("+12,40 %")).toBeInTheDocument(); expect(within(link).getByText("+12,4 %")).toBeInTheDocument();
expect(within(link).getByText("+136 900 ₽")).toBeInTheDocument(); expect(within(link).getByText("+136 900 ₽")).toBeInTheDocument();
expect(within(link).getByText("−3,05 %")).toBeInTheDocument(); expect(within(link).getByText("−3,1 %")).toBeInTheDocument();
expect(within(link).getByText("−4,2 USD")).toBeInTheDocument(); expect(within(link).getByText("−4,2 $")).toBeInTheDocument();
const nullReturnLink = screen.getByRole("link", { name: /Без доходности/ }); const nullReturnLink = screen.getByRole("link", { name: /Без доходности/ });
expect(within(nullReturnLink).getByText("—")).toBeInTheDocument(); expect(within(nullReturnLink).getByText("—")).toBeInTheDocument();
expect(within(link).getByText("частично")).toBeInTheDocument(); expect(within(link).getByText("частично")).toBeInTheDocument();
expect(within(link).getByText("82 500,50 ₽")).toBeInTheDocument(); expect(within(link).getByText("82 500,50 ₽")).toBeInTheDocument();
expect(within(link).getAllByText("20,00 USD")).not.toHaveLength(0); expect(within(link).getAllByText("20,00 $")).not.toHaveLength(0);
expect(within(link).getByText("17 позиций")).toBeInTheDocument(); expect(within(link).getByText("17 позиций")).toBeInTheDocument();
expect(screen.getByRole("img", { expect(screen.getByRole("img", {
name: "Структура в RUB: Акции 50 %; Облигации 30 %; " name: "Структура в RUB: Акции 50,0 %; Облигации 30,0 %; "
+ "Другие активы 0 %; Свободные деньги 20 %", + "Другие активы 0,0 %; Свободные деньги 20,0 %",
})).toBeInTheDocument(); })).toBeInTheDocument();
const stockLegend = within(link).getByText(/Акции 50 %/); const stockLegend = within(link).getByText(/Акции 50,0 %/);
expect(stockLegend).toHaveTextContent("● Акции 50 %"); expect(stockLegend).toHaveTextContent("● Акции 50,0 %");
// eslint-disable-next-line testing-library/no-node-access -- The legend is aria-hidden, so only DOM traversal reaches its container. // eslint-disable-next-line testing-library/no-node-access -- The legend is aria-hidden, so only DOM traversal reaches its container.
expect(stockLegend.parentElement).toHaveAttribute("aria-hidden", "true"); expect(stockLegend.parentElement).toHaveAttribute("aria-hidden", "true");
expect(within(link).queryByText(/Другие активы 0 %/)).not.toBeInTheDocument(); expect(within(link).queryByText(/Другие активы 0,0 %/))
.not.toBeInTheDocument();
expect(link).toHaveTextContent("По данным брокера"); expect(link).toHaveTextContent("По данным брокера");
expect(link).toHaveTextContent("Структура RUB: Акции 100"); expect(link).toHaveTextContent("Структура RUB: Акции 100");
}); });
@@ -15,6 +15,11 @@ import classes from "./BrokerageAccountList.module.css";
const DECIMAL_PART_COUNT = 2; const DECIMAL_PART_COUNT = 2;
const STRUCTURE_CATEGORY_ORDER = ["STOCK", "BOND", "OTHER", "CASH"] as const; const STRUCTURE_CATEGORY_ORDER = ["STOCK", "BOND", "OTHER", "CASH"] as const;
const percentFormatter = new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 1,
minimumFractionDigits: 1,
});
type AvailableSummary = Extract< type AvailableSummary = Extract<
BrokerageAccountCard["summary"], BrokerageAccountCard["summary"],
{ state: "available" } { state: "available" }
@@ -37,20 +42,15 @@ type StructureCurrency = PortfolioSummary["structure"]["currencies"][number];
function formatPercent(value: null | string) { function formatPercent(value: null | string) {
if (value === null) return "—"; if (value === null) return "—";
const formatted = `${value.replace(".", ",")}\u{A0}%`; const rounded = percentFormatter.format(Math.abs(Number(value)));
if (isZero(value)) let sign = "";
return value.startsWith("-") ? formatted.slice(1) : formatted; if (rounded !== "0,0") sign = value.startsWith("-") ? "−" : "+";
return value.startsWith("-") ? `−${formatted.slice(1)}` : `+${formatted}`; return `${sign}${rounded}\u{A0}%`;
} }
function formatStructurePercent(value: string) { function formatStructurePercent(value: string) {
let trimmed = value; return percentFormatter.format(Number(value));
while (trimmed.includes(".") && trimmed.endsWith("0"))
trimmed = trimmed.slice(0, -1);
if (trimmed.endsWith(".")) trimmed = trimmed.slice(0, -1);
return trimmed.replace(".", ",");
} }
function isZero(value: string) { function isZero(value: string) {
@@ -65,22 +65,18 @@ const ReturnValue: FC<ReturnValueProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { item } = props; const { item } = props;
const isNegative = item.percent !== null const displayedPercent = formatPercent(item.percent);
&& item.percent.startsWith("-") const isNegative = displayedPercent.startsWith("−");
&& !isZero(item.percent); const isPositive = displayedPercent.startsWith("+");
const isPositive = item.percent !== null
&& !item.percent.startsWith("-")
&& !isZero(item.percent);
let toneClass = classes.neutral; let toneClass = classes.neutral;
if (isNegative) toneClass = classes.negative; if (isNegative) toneClass = classes.negative;
else if (isPositive) toneClass = classes.positive; else if (isPositive) toneClass = classes.positive;
return ( return (
<Group className={classes.return} gap="xs" wrap="nowrap"> <Group className={classes.return} gap="xs" wrap="wrap">
<Text className={toneClass} fw={700} size="sm"> <Text className={toneClass} fw={700} size="sm">
{formatPercent(item.percent)} {displayedPercent}
</Text> </Text>
<Text className={toneClass} size="sm"> <Text className={toneClass} size="sm">
@@ -200,35 +196,35 @@ const BrokerageAccountSummary: FC<BrokerageAccountSummaryProps> = (props) => {
return ( return (
<Stack className={classes.balance} gap="xs"> <Stack className={classes.balance} gap="xs">
<Group align="flex-start" gap="lg" justify="flex-end" wrap="nowrap"> <Stack className={classes.summary} gap="sm">
<Stack className={classes.summary} gap={4}> {summary.totals.map((total) => {
{summary.totals.map(total => ( const item = summary.portfolio.unrealizedReturnByCurrency.find(
<Text className={classes.total} fw={700} key={total.currency}> returnItem => returnItem.currency === total.currency,
{formatMoney(total, { withRoundedCents: true })} );
</Text>
))}
{!!summary.remainingCurrencyCount && ( return (
<Text c="dimmed" size="sm"> <Stack
{t("accounts.remainingCurrencies", { className={classes.currencySummary}
count: summary.remainingCurrencyCount, gap={2}
})} key={total.currency}
</Text> >
)} <Text className={classes.total} fw={700}>
</Stack> {formatMoney(total, { withRoundedCents: true })}
</Text>
<Stack gap={4}> {item && <ReturnValue item={item} />}
{summary.totals.map((total) => { </Stack>
const item = summary.portfolio.unrealizedReturnByCurrency.find( );
returnItem => returnItem.currency === total.currency, })}
);
return item {!!summary.remainingCurrencyCount && (
? <ReturnValue item={item} key={total.currency} /> <Text c="dimmed" size="sm">
: null; {t("accounts.remainingCurrencies", {
})} count: summary.remainingCurrencyCount,
</Stack> })}
</Group> </Text>
)}
</Stack>
{summary.portfolio.cashBalances.length > 0 && ( {summary.portfolio.cashBalances.length > 0 && (
<Group className={classes.cash} gap="xs" justify="flex-end"> <Group className={classes.cash} gap="xs" justify="flex-end">
@@ -103,7 +103,7 @@ describe("BrokerageEventGroup", () => {
event({ id: "iis", name: "ИИС" }, "200"), event({ id: "iis", name: "ИИС" }, "200"),
]); ]);
expect(screen.getByRole("list", { name: /10 USD.*200 ₽/ })) expect(screen.getByRole("list", { name: /10 \$.*200 ₽/ }))
.toBeInTheDocument(); .toBeInTheDocument();
}); });
@@ -785,7 +785,7 @@ describe("income period and snapshot boundary", () => {
expect(screen.queryByText("USD")).not.toBeInTheDocument(); expect(screen.queryByText("USD")).not.toBeInTheDocument();
const cards = screen.getAllByRole("listitem"); const cards = screen.getAllByRole("listitem");
expect(cards).toHaveLength(3); expect(cards).toHaveLength(3);
expect(cards[0]).toHaveTextContent("+10 USD"); expect(cards[0]).toHaveTextContent("+10 $");
expect(cards[1]).toHaveTextContent("−2 ₽"); expect(cards[1]).toHaveTextContent("−2 ₽");
expect(cards[2]).toHaveTextContent("−3 ₽"); expect(cards[2]).toHaveTextContent("−3 ₽");
expect(screen.queryByText(/Итого:|Выплат:/)).not.toBeInTheDocument(); expect(screen.queryByText(/Итого:|Выплат:/)).not.toBeInTheDocument();
@@ -1191,7 +1191,7 @@ describe("AccountPage states", () => {
expect( expect(
content.getByText("11.09.2026 · Дата реестра · RU000A0JX0J2"), content.getByText("11.09.2026 · Дата реестра · RU000A0JX0J2"),
).toBeVisible(); ).toBeVisible();
for (const amount of ["850,50 ₽", "0 USD", "1 000 ₽", "Сумма неизвестна"]) for (const amount of ["850,50 ₽", "0 $", "1 000 ₽", "Сумма неизвестна"])
expect(content.getByText(amount)).toBeVisible(); expect(content.getByText(amount)).toBeVisible();
expect(content.getAllByText("Прогноз")).toHaveLength(4); expect(content.getAllByText("Прогноз")).toHaveLength(4);
expect(content.queryByText("Без движения денег")).not.toBeInTheDocument(); expect(content.queryByText("Без движения денег")).not.toBeInTheDocument();
@@ -60,7 +60,7 @@ describe("portfolio structure", () => {
screen.getByRole("combobox", { name: "Валюта структуры портфеля" }), screen.getByRole("combobox", { name: "Валюта структуры портфеля" }),
); );
fireEvent.click(screen.getByRole("option", { name: "USD" })); fireEvent.click(screen.getByRole("option", { name: "USD" }));
expect(screen.getByRole("list")).toHaveTextContent("60 USD"); expect(screen.getByRole("list")).toHaveTextContent("60 $");
expect(screen.getByRole("list")).not.toHaveTextContent("₽"); expect(screen.getByRole("list")).not.toHaveTextContent("₽");
view.rerender(ui({ currencies: [rub], unvaluedCategories: [] })); view.rerender(ui({ currencies: [rub], unvaluedCategories: [] }));
expect(screen.getByRole("list")).toHaveTextContent("60 ₽"); expect(screen.getByRole("list")).toHaveTextContent("60 ₽");
@@ -110,7 +110,9 @@ describe("AccountsPage", () => {
renderPage(client); renderPage(client);
expect(await screen.findByText("175,30 ₽")).toBeInTheDocument(); expect(await screen.findByText("175,30 ₽")).toBeInTheDocument();
expect(screen.getAllByText("20,00 USD")).not.toHaveLength(0); expect(screen.getAllByText("20,00 $")).not.toHaveLength(0);
expect(screen.queryByText("Ваши инвестиции по счетам"))
.not.toBeInTheDocument();
expect(screen.getByText("5 счетов")).toBeInTheDocument(); expect(screen.getByText("5 счетов")).toBeInTheDocument();
expect(screen.getByText("Нет текущих сумм")).toBeInTheDocument(); expect(screen.getByText("Нет текущих сумм")).toBeInTheDocument();
expect(screen.getByText( expect(screen.getByText(
@@ -0,0 +1,21 @@
.summary {
min-width: 0;
padding-bottom: var(--mantine-spacing-lg);
border-bottom: 1px solid var(--wl-color-border);
}
.primaryTotal {
font-size: clamp(1.75rem, 4vw, 2.25rem);
font-variant-numeric: tabular-nums;
line-height: 1.2;
overflow-wrap: anywhere;
}
.otherTotal {
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
.meta {
padding-top: var(--mantine-spacing-xs);
}
@@ -1,6 +1,6 @@
import type { FC } from "react"; import type { FC } from "react";
import { Button, SimpleGrid, Stack, Text, Title } from "@mantine/core"; import { Button, Group, Stack, Text, Title } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
@@ -13,6 +13,8 @@ import {
import { formatMoney } from "@/shared/lib"; import { formatMoney } from "@/shared/lib";
import { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui"; import { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui";
import classes from "./AccountsPageLoaded.module.css";
const DECIMAL_BASE = 10n; const DECIMAL_BASE = 10n;
const DECIMAL_PART_COUNT = 2; const DECIMAL_PART_COUNT = 2;
const ZERO = 0n; const ZERO = 0n;
@@ -87,52 +89,55 @@ function sumDecimals(values: string[]) {
const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => { const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { excludedCount, totals } = accountTotals(props.accounts); const { excludedCount, totals } = accountTotals(props.accounts);
const primaryTotal = totals.find(total => total.currency === "RUB") ?? totals[0];
const otherTotals = totals.filter(total => total !== primaryTotal);
return ( return (
<Stack gap="lg"> <Stack gap="lg">
<Stack gap={4}> <Title order={1}>{t("accounts.title")}</Title>
<Title order={1}>{t("accounts.title")}</Title>
<Stack className={classes.summary} gap="xs">
<Text c="dimmed" size="sm"> <Text c="dimmed" size="sm">
{t("accounts.subtitle")} {t("accounts.currentValue")}
</Text> </Text>
</Stack>
<Stack gap="xs"> {primaryTotal
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="xs"> ? (
<Stack gap={4}> <Text className={classes.primaryTotal} fw={700}>
<Text c="dimmed" size="sm"> {formatMoney(primaryTotal, { withRoundedCents: true })}
{t("accounts.currentValue")}
</Text>
{totals.length > 0
? totals.map(total => (
<Text fw={700} key={total.currency} size="lg">
{formatMoney(total, { withRoundedCents: true })}
</Text>
))
: <Text c="dimmed">{t("accounts.summaryNoTotals")}</Text>}
</Stack>
<Stack gap={4}>
<Text fw={600}>{t("accounts.count", {
count: props.accounts.length,
})}
</Text>
{excludedCount > 0 && (
<Text c="dimmed" size="sm">
{t("accounts.summaryExcluded", { count: excludedCount })}
</Text> </Text>
)
: (
<Text c="dimmed">{t("accounts.summaryNoTotals")}</Text>
)} )}
</Stack>
</SimpleGrid>
<SourceSyncStatus {otherTotals.length > 0 && (
lastAttempt={props.meta.lastAttempt} <Group gap="md" wrap="wrap">
lastSuccessfulAt={props.meta.lastSuccessfulAt} {otherTotals.map(total => (
stale={props.meta.freshness === "stale"} <Text className={classes.otherTotal} key={total.currency}>
/> {formatMoney(total, { withRoundedCents: true })}
</Text>
))}
</Group>
)}
<Group className={classes.meta} gap="xs" wrap="wrap">
<Text c="dimmed" size="xs">
{t("accounts.count", { count: props.accounts.length })}
</Text>
<SourceSyncStatus
lastAttempt={props.meta.lastAttempt}
lastSuccessfulAt={props.meta.lastSuccessfulAt}
stale={props.meta.freshness === "stale"}
/>
</Group>
{excludedCount > 0 && (
<Text c="dimmed" size="sm">
{t("accounts.summaryExcluded", { count: excludedCount })}
</Text>
)}
{!!props.readRetry && <ReadRetry {...props.readRetry} />} {!!props.readRetry && <ReadRetry {...props.readRetry} />}
</Stack> </Stack>
-1
View File
@@ -51,7 +51,6 @@ const ru = {
incomplete: "{{category}}: доля неизвестна", incomplete: "{{category}}: доля неизвестна",
label: "Структура в {{currency}}: {{value}}", label: "Структура в {{currency}}: {{value}}",
}, },
subtitle: "Ваши инвестиции по счетам",
summaryEmpty: "Нет текущих сумм", summaryEmpty: "Нет текущих сумм",
summaryExcluded_few: summaryExcluded_few:
"Ещё {{count}} счёта не включены: нет актуального портфеля", "Ещё {{count}} счёта не включены: нет актуального портфеля",
+1 -1
View File
@@ -33,7 +33,7 @@ describe("shared display helpers", () => {
"-1 234 567,89 ₽", "-1 234 567,89 ₽",
); );
expect(formatMoney({ currency: "RUB", value: "10" })).toBe("10 ₽"); expect(formatMoney({ currency: "RUB", value: "10" })).toBe("10 ₽");
expect(formatMoney({ currency: "USD", value: "0.5" })).toBe("0,5 USD"); expect(formatMoney({ currency: "USD", value: "0.5" })).toBe("0,5 $");
expect(formatMoney({ currency: "RUB", value: "0001234.50" })).toBe( expect(formatMoney({ currency: "RUB", value: "0001234.50" })).toBe(
"0 001 234,50 ₽", "0 001 234,50 ₽",
); );
+4 -1
View File
@@ -86,7 +86,10 @@ function formatMoney(
? `${integerPart}.${normalizedFraction}` ? `${integerPart}.${normalizedFraction}`
: integerPart; : integerPart;
const currency = money.currency === "RUB" ? "₽" : money.currency; let currency = money.currency;
if (currency === "RUB") currency = "₽";
else if (currency === "USD") currency = "$";
const formatted = `${formatDecimal(decimal)} ${currency}`; const formatted = `${formatDecimal(decimal)} ${currency}`;
if (!withSign) return formatted; if (!withSign) return formatted;