feat: redesign year-over-year income comparison
This commit is contained in:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user