feat: redesign year-over-year income comparison

This commit is contained in:
2026-09-09 18:28:15 +03:00
parent 451400b63b
commit 91eaeb5661
6 changed files with 398 additions and 59 deletions
@@ -29,6 +29,123 @@
overflow-wrap: anywhere;
}
.comparison {
container-type: inline-size;
}
.comparisonGrid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--mantine-spacing-sm);
}
.comparisonPanel {
min-width: 0;
padding: var(--mantine-spacing-md);
border-radius: var(--mantine-radius-md);
border-top: 3px solid var(--mantine-color-gray-4);
background: var(--mantine-color-gray-0);
}
.comparisonPanel[data-kind="selected"] {
border-color: var(--mantine-color-blue-6);
background: var(--mantine-color-blue-0);
}
.comparisonPanel[data-tone="positive"] {
border-color: var(--mantine-color-green-8);
background: var(--mantine-color-green-0);
}
.comparisonPanel[data-tone="negative"] {
border-color: var(--mantine-color-red-8);
background: var(--mantine-color-red-0);
}
.comparisonLabel,
.comparisonValues dt {
color: var(--mantine-color-gray-7);
}
.comparisonValues {
margin: var(--mantine-spacing-md) 0 0;
font-size: var(--mantine-font-size-sm);
}
.comparisonValues dd {
min-width: 0;
margin: 0;
font-weight: 600;
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
.comparisonTotal {
margin-bottom: var(--mantine-spacing-md);
padding-bottom: var(--mantine-spacing-md);
border-bottom: 1px solid var(--mantine-color-gray-3);
}
.comparisonTotal dd {
margin-top: 4px;
font-size: var(--mantine-h2-font-size);
line-height: 1.3;
font-weight: 700;
letter-spacing: -0.025em;
}
.comparisonValues dd[data-tone="positive"] {
color: color-mix(
in srgb,
var(--mantine-color-green-9) 85%,
var(--mantine-color-black)
);
}
.comparisonValues dd[data-tone="negative"] {
color: var(--mantine-color-red-9);
}
.comparisonValues dd[data-tone="neutral"] {
color: var(--mantine-color-gray-7);
}
.comparisonPanel[data-kind="selected"] .comparisonTotal dd {
color: var(--mantine-color-blue-8);
}
.comparisonDetail {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 4px var(--mantine-spacing-xs);
margin-top: var(--mantine-spacing-xs);
}
.comparisonPercent {
display: inline-block;
margin-left: var(--mantine-spacing-xs);
padding: 2px 6px;
border-radius: var(--mantine-radius-sm);
background: var(--mantine-color-white);
font-size: var(--mantine-font-size-xs);
font-weight: 600;
line-height: 1.5;
letter-spacing: normal;
vertical-align: middle;
}
.comparisonNotice {
background: var(--mantine-color-gray-0);
border-left: 3px solid var(--mantine-color-gray-4);
}
@container (min-width: 42em) {
.comparisonGrid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 35.999em) {
.fields {
grid-template-columns: minmax(0, 1fr);
@@ -368,7 +368,13 @@ describe("AccountIncomePage", () => {
const eventsRequest = vi.fn(() => new HttpResponse(null, { status: 503 }));
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(analytics),
HttpResponse.json({
...analytics,
yearComparison: {
period: { from: "2025-08-01", to: "2025-08-27" },
totalsByCurrency: [comparison("RUB")],
},
}),
),
http.get("*/api/v1/brokerage-accounts/account-1/events", eventsRequest),
);
@@ -385,14 +391,50 @@ describe("AccountIncomePage", () => {
expect(screen.queryByText("Динамика")).not.toBeInTheDocument();
expect(eventsRequest).not.toHaveBeenCalled();
expect(screen.getByRole("region", { name: "Выплаты" })).toBeInTheDocument();
const selected = screen.getByRole("region", {
name: /^1–27 авг\. 2026\sг\.$/,
});
expect(
screen.getByText(
"Выбранный период: Купоны 10 ₽ · Дивиденды 2 ₽ · Итого 12 ₽",
screen.getByRole("region", { name: /^1–27 авг\. 2025\sг\.$/ }),
).toBeInTheDocument();
expect(screen.queryByText(/Предыдущий период/)).not.toBeInTheDocument();
expect(screen.queryByText("Выбранный период")).not.toBeInTheDocument();
expect(screen.queryByText("Сравниваемый период")).not.toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "Годом ранее", level: 2 }),
).toBeInTheDocument();
expect(screen.queryByText("Сравнения")).not.toBeInTheDocument();
expect(screen.queryByText("RUB", { exact: true })).not.toBeInTheDocument();
for (const amount of ["10 ₽", "2 ₽", "12 ₽"]) {
expect(within(selected).getByText(amount)).toBeInTheDocument();
}
const difference = screen.getByRole("region", { name: "Разница" });
for (const amount of ["+9 ₽", "+2 ₽", "+11 ₽"]) {
expect(within(difference).getByText(amount)).toBeInTheDocument();
}
expect(within(difference).getByText("+900 %")).toBeInTheDocument();
expect(within(difference).getByText("+1 100 %")).toBeInTheDocument();
expect(within(difference).queryByText("нет базы")).not.toBeInTheDocument();
expect(within(difference).getAllByText(/%$/)).toHaveLength(2);
expect(
within(difference).getByText("+2 ₽").closest("dd"),
).toHaveTextContent(/^\+2 ₽$/);
});
it("shows the unavailable year state without falling back to the previous period", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(analytics),
),
);
renderPage();
expect(
await screen.findByText("Сравнение с годом ранее недоступно."),
).toBeInTheDocument();
expect(
screen.getByText("Разница: Купоны 9 ₽ · Дивиденды 2 ₽ · Итого 11 ₽"),
).toBeInTheDocument();
screen.queryByRole("region", { name: "Разница" }),
).not.toBeInTheDocument();
expect(screen.queryByText(/Предыдущий период/)).not.toBeInTheDocument();
});
it("shows a recoverable realized error", async () => {
@@ -411,6 +453,63 @@ describe("AccountIncomePage", () => {
});
});
describe("income comparison display", () => {
it.each([
["25", "100", "+25 %"],
["-25", "100", "-25 %"],
["0", "100", "0 %"],
["-0.000", "100", "0 %"],
["0.0001", "100", "0 %"],
["1", "3", "+33,3 %"],
["0.01", "0.04", "+25 %"],
["123456789012345678.123456789", "123456789012345678.123456789", "+100 %"],
["10", "0", null],
["0", "0", null],
["10", "-0.00", null],
["10", "-100", null],
["9".repeat(400), "1", null],
["1", "9".repeat(400), null],
])("formats change %s against base %s", (difference, base, expected) => {
const value = comparison("RUB");
value.difference.coupon = money("RUB", difference);
value.compared.coupon = money("RUB", base);
const display = toIncomeDisplayModel({
...analytics,
yearComparison: {
period: { from: "2025-08-01", to: "2025-08-27" },
totalsByCurrency: [value],
},
});
const result = display?.comparison?.values[0];
expect(result?.percentages.coupon?.replaceAll("\u00a0", " ") ?? null).toBe(
expected,
);
expect(result?.difference.coupon.value).toBe(difference);
expect(result?.compared.coupon.value).toBe(base);
});
it("formats calendar ranges across years and preserves separate currencies", () => {
const display = toIncomeDisplayModel({
...analytics,
period: { from: "2025-12-01", to: "2026-01-09" },
yearComparison: {
period: { from: "2024-12-01", to: "2025-01-09" },
totalsByCurrency: [comparison("RUB"), comparison("USD")],
},
});
expect(display?.comparison?.selectedPeriod.replaceAll(/\s/g, " ")).toBe(
"1 дек. 2025 г. – 9 янв. 2026 г.",
);
expect(display?.comparison?.comparedPeriod.replaceAll(/\s/g, " ")).toBe(
"1 дек. 2024 г. – 9 янв. 2025 г.",
);
expect(display?.comparison?.values.map((value) => value.currency)).toEqual([
"RUB",
"USD",
]);
});
});
const LocationProbe: FC = () => {
const location = useLocation();
const navigate = useNavigate();
@@ -11,6 +11,15 @@ type IncomeForm = IncomePeriod;
type IncomeDisplayModel = {
entries: { key: string; operation: IncomeAnalytics["entries"][number] }[];
comparison: {
selectedPeriod: string;
comparedPeriod: string;
values: (NonNullable<
IncomeAnalytics["yearComparison"]
>["totalsByCurrency"][number] & {
percentages: Record<"total" | "coupon" | "dividend", string | null>;
})[];
} | null;
};
type AccountIncomePageViewModel =
@@ -209,7 +218,60 @@ function toIncomeDisplayModel(
if (instrument.includedCount !== instrument.receiptCount) return null;
}
return { entries };
return {
entries,
comparison: analytics.yearComparison
? {
selectedPeriod: formatComparisonPeriod(analytics.period),
comparedPeriod: formatComparisonPeriod(
analytics.yearComparison.period,
),
values: analytics.yearComparison.totalsByCurrency.map((value) => ({
...value,
percentages: {
total: formatIncomeChange(
value.difference.total.value,
value.compared.total.value,
),
coupon: formatIncomeChange(
value.difference.coupon.value,
value.compared.coupon.value,
),
dividend: formatIncomeChange(
value.difference.dividend.value,
value.compared.dividend.value,
),
},
})),
}
: null,
};
}
function formatComparisonPeriod(period: IncomePeriod): string {
return new Intl.DateTimeFormat("ru-RU", {
dateStyle: "medium",
timeZone: "UTC",
}).formatRange(
new Date(`${period.from}T00:00:00Z`),
new Date(`${period.to}T00:00:00Z`),
);
}
function formatIncomeChange(
difference: string,
compared: string,
): string | null {
// Display-only ratio; server-provided Money values remain exact decimal strings.
const base = Number(compared);
if (!Number.isFinite(base) || base <= 0) return null;
const ratio = Number(difference) / base;
if (!Number.isFinite(ratio)) return null;
return new Intl.NumberFormat("ru-RU", {
style: "percent",
maximumFractionDigits: 1,
signDisplay: "exceptZero",
}).format(ratio);
}
function moscowDate(value: Date): string {
@@ -227,6 +289,7 @@ function moscowDate(value: Date): string {
export {
type AccountIncomePageViewModel,
formatDraftDate,
type IncomeDisplayModel,
type IncomeForm,
isDate,
moscowDate,
@@ -67,7 +67,7 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
/>
)}
<Alert color="blue">{t("income.taxBoundary")}</Alert>
<IncomeComparisons analytics={analytics} />
<IncomeComparisons comparison={display.comparison} />
<IncomePayments entries={display.entries} />
</Stack>
);
@@ -1,37 +1,32 @@
import { Paper, Stack, Text, Title } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import type { IncomeAnalytics } from "@/entities/income";
import { formatMoney } from "@/shared/lib";
import classes from "../AccountIncomePage.module.css";
import type { IncomeDisplayModel } from "../AccountIncomePage.vm";
type IncomeComparisonsProps = { analytics: IncomeAnalytics };
type ComparisonProps = {
title: string;
values: IncomeAnalytics["previous"]["totalsByCurrency"];
type IncomeComparisonsProps = { comparison: IncomeDisplayModel["comparison"] };
type ComparisonProps = NonNullable<IncomeDisplayModel["comparison"]>;
type ComparisonValuesProps = {
kind: "selected" | "compared" | "difference";
label: string;
values: ComparisonProps["values"][number]["selected"];
percentages?: ComparisonProps["values"][number]["percentages"];
};
const IncomeComparisons: FC<IncomeComparisonsProps> = (props) => {
const { t } = useTranslation();
return (
<Stack>
<Title order={2}>{t("income.comparisons")}</Title>
<Comparison
title={t("income.previous", {
from: props.analytics.previous.period.from,
to: props.analytics.previous.period.to,
})}
values={props.analytics.previous.totalsByCurrency}
/>
{props.analytics.yearComparison ? (
<Comparison
title={t("income.yearAgo", {
from: props.analytics.yearComparison.period.from,
to: props.analytics.yearComparison.period.to,
})}
values={props.analytics.yearComparison.totalsByCurrency}
/>
<Title order={2}>{t("income.yearAgo")}</Title>
{props.comparison ? (
<Comparison {...props.comparison} />
) : (
<Text c="dimmed">{t("income.yearUnavailable")}</Text>
<Paper p="sm" radius="md" className={classes.comparisonNotice}>
<Text size="sm" c="gray.7">
{t("income.yearUnavailable")}
</Text>
</Paper>
)}
</Stack>
);
@@ -40,33 +35,101 @@ const IncomeComparisons: FC<IncomeComparisonsProps> = (props) => {
const Comparison: FC<ComparisonProps> = (props) => {
const { t } = useTranslation();
return (
<Paper p="sm" radius="md" withBorder>
<Text fw={700}>{props.title}</Text>
{props.values.map((value) => (
<Stack gap={2} key={value.currency}>
<Text size="sm">{value.currency}</Text>
<Text size="sm">
{t("income.selected")}: {t("income.coupon")}{" "}
{formatMoney(value.selected.coupon)} · {t("income.dividend")}{" "}
{formatMoney(value.selected.dividend)} · {t("income.total")}{" "}
{formatMoney(value.selected.total)}
<section aria-label={t("income.yearAgo")} className={classes.comparison}>
<Stack gap="md">
{props.values.map((value) => (
<Stack
gap="xs"
key={value.currency}
role="group"
aria-label={value.currency}
>
<div className={classes.comparisonGrid}>
<ComparisonValues
kind="selected"
label={props.selectedPeriod}
values={value.selected}
/>
<ComparisonValues
kind="compared"
label={props.comparedPeriod}
values={value.compared}
/>
<ComparisonValues
kind="difference"
label={t("income.difference")}
values={value.difference}
percentages={value.percentages}
/>
</div>
</Stack>
))}
{props.values.length === 0 ? (
<Text size="sm" c="gray.7">
{t("income.comparisonEmpty")}
</Text>
<Text size="sm">
{t("income.compared")}: {t("income.coupon")}{" "}
{formatMoney(value.compared.coupon)} · {t("income.dividend")}{" "}
{formatMoney(value.compared.dividend)} · {t("income.total")}{" "}
{formatMoney(value.compared.total)}
</Text>
<Text size="sm">
{t("income.difference")}: {t("income.coupon")}{" "}
{formatMoney(value.difference.coupon)} · {t("income.dividend")}{" "}
{formatMoney(value.difference.dividend)} · {t("income.total")}{" "}
{formatMoney(value.difference.total)}
</Text>
</Stack>
))}
</Paper>
) : null}
</Stack>
</section>
);
};
const ComparisonValues: FC<ComparisonValuesProps> = (props) => {
const { t } = useTranslation();
return (
<section
aria-label={props.label}
className={classes.comparisonPanel}
data-kind={props.kind}
data-tone={
props.kind === "difference"
? differenceTone(props.values.total.value)
: undefined
}
>
<Text size="sm" fw={600} className={classes.comparisonLabel}>
{props.label}
</Text>
<dl className={classes.comparisonValues}>
{(["total", "coupon", "dividend"] as const).map((key) => {
const money = props.values[key];
const percentage = props.percentages?.[key];
const tone =
props.kind === "difference"
? differenceTone(money.value)
: undefined;
return (
<div
key={key}
className={
key === "total"
? classes.comparisonTotal
: classes.comparisonDetail
}
>
<dt>{t(`income.${key}`)}</dt>
<dd data-tone={tone}>
<span>
{tone === "positive" ? "+" : ""}
{formatMoney(money)}
</span>
{percentage ? (
<span className={classes.comparisonPercent}>
{percentage}
</span>
) : null}
</dd>
</div>
);
})}
</dl>
</section>
);
};
function differenceTone(value: string): "positive" | "negative" | "neutral" {
if (!/[1-9]/.test(value)) return "neutral";
return value.startsWith("-") ? "negative" : "positive";
}
export { IncomeComparisons };
+2 -5
View File
@@ -168,12 +168,9 @@ const ru = {
coupon: "Купоны",
dividend: "Дивиденды",
taxBoundary: "Налоговые операции не включены в этот результат.",
comparisons: "Сравнения",
previous: "Предыдущий период: {{from}} — {{to}}",
yearAgo: "Годом ранее: {{from}} — {{to}}",
comparisonEmpty: "За эти периоды купоны и дивиденды не найдены.",
yearAgo: "Годом ранее",
yearUnavailable: "Сравнение с годом ранее недоступно.",
selected: "Выбранный период",
compared: "Сравниваемый период",
difference: "Разница",
total: "Итого",
payments: "Выплаты",