chore(release): 1.3.0 #130

Merged
codex-bot merged 15 commits from develop into main 2026-11-01 21:01:52 +03:00
5 changed files with 165 additions and 195 deletions
Showing only changes of commit c24704affa - Show all commits
@@ -1,61 +1,68 @@
.list {
width: 100%;
margin: 0;
padding: 0;
list-style: none;
}
.card {
min-width: 0;
gap: var(--mantine-spacing-xl);
.row {
display: grid;
grid-template-areas:
"identity balance"
"details balance";
grid-template-columns: minmax(0, 1fr) minmax(0, auto);
gap: var(--mantine-spacing-xs) var(--mantine-spacing-xl);
align-items: center;
padding: var(--mantine-spacing-md);
border: 1px solid var(--wl-color-border);
border-left: 3px solid var(--mantine-color-blue-5);
border-radius: var(--mantine-radius-md);
background: var(--mantine-color-white);
border-color: var(--mantine-color-gray-3);
box-shadow: 0 4px 16px rgb(15 23 42 / 3%);
color: inherit;
text-decoration: none;
transition:
transform 160ms ease,
box-shadow 160ms ease,
border-color 160ms ease;
transition: background-color 160ms ease, border-color 160ms ease;
}
.card:hover,
.card:focus-visible {
.row:hover,
.row:focus-visible {
border-color: var(--mantine-color-blue-3);
box-shadow: 0 12px 28px rgb(15 23 42 / 8%);
transform: translateY(-3px);
border-left-color: var(--mantine-color-blue-6);
background: var(--mantine-color-blue-0);
}
.card:focus-visible {
.row:focus-visible {
outline: 2px solid var(--mantine-color-blue-6);
outline-offset: 3px;
outline-offset: -2px;
}
.identity {
grid-area: identity;
min-width: 0;
}
.kicker {
padding: 5px 10px;
border-radius: var(--mantine-radius-sm);
background: var(--mantine-color-blue-0);
color: var(--mantine-color-blue-8);
letter-spacing: 0.02em;
}
.accountIcon {
width: 28px;
height: 28px;
flex-shrink: 0;
color: var(--mantine-color-blue-5);
}
.name {
margin: 0;
font-size: var(--mantine-h3-font-size);
line-height: 1.3;
color: var(--mantine-color-blue-9);
font-size: var(--mantine-font-size-md);
line-height: var(--mantine-line-height-md);
overflow-wrap: anywhere;
}
.accountType {
align-self: flex-start;
padding: 2px var(--mantine-spacing-xs);
border-radius: var(--mantine-radius-sm);
background: var(--mantine-color-blue-0);
color: var(--mantine-color-blue-8);
}
.balance {
flex: 1;
grid-area: balance;
min-width: 0;
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
border-radius: var(--mantine-radius-sm);
background: var(--wl-color-subtle);
text-align: right;
}
.summary {
@@ -63,104 +70,46 @@
}
.total {
font-size: clamp(1.5rem, 2.8vw, 2.25rem);
letter-spacing: -0.035em;
line-height: 1.2;
color: var(--mantine-color-dark-7);
font-size: var(--mantine-font-size-lg);
line-height: var(--mantine-line-height-md);
overflow-wrap: anywhere;
}
.total + .total {
font-size: var(--mantine-font-size-lg);
font-weight: 500;
letter-spacing: 0;
color: var(--mantine-color-gray-7);
}
.details {
grid-area: details;
min-width: 0;
}
.positions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--mantine-spacing-sm);
margin: 0;
}
.positions > div {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 4px var(--mantine-spacing-sm);
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
border-radius: var(--mantine-radius-md);
}
.stocks {
background: var(--mantine-color-blue-0);
color: var(--mantine-color-blue-8);
}
.bonds {
background: var(--mantine-color-grape-0);
color: var(--mantine-color-grape-8);
}
.positions dt {
font-size: var(--mantine-font-size-sm);
font-weight: 500;
gap: var(--mantine-spacing-xs);
align-items: baseline;
}
.positions dd {
margin: 0;
font-size: 1.75rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1.25;
}
.status {
min-width: 0;
overflow-wrap: anywhere;
}
.footer {
border-top: 1px solid var(--mantine-color-gray-2);
padding-top: var(--mantine-spacing-md);
}
.open {
display: inline-flex;
align-items: center;
gap: var(--mantine-spacing-xs);
margin-left: auto;
color: var(--mantine-color-blue-7);
}
.arrow {
font-size: var(--mantine-font-size-lg);
transition: transform 160ms ease;
}
.card:hover .arrow {
transform: translateX(3px);
}
@media (max-width: 36em) {
.card {
padding: var(--mantine-spacing-md);
gap: var(--mantine-spacing-lg);
@media (max-width: 48em) {
.row {
column-gap: var(--mantine-spacing-sm);
}
.positions > div {
flex-direction: column;
align-items: flex-start;
padding: var(--mantine-spacing-sm);
}
}
@media (prefers-reduced-motion: reduce) {
.card,
.arrow {
transition: none;
}
.card:hover,
.card:focus-visible,
.card:hover .arrow {
transform: none;
.balance {
max-width: 50vw;
}
}
@@ -40,7 +40,7 @@ const meta = {
docs: {
description: {
component:
"Карточки счетов WealthLedger со сводкой по валютам и числом наименований акций и облигаций. Используйте на экране выбора счёта; отсутствие сводки и отсутствие сумм имеют разные сообщения. Примеры содержат вымышленные данные. Ссылки меняют только локальный маршрут MemoryRouter; страница назначения в этой истории не отображается.",
"Компактные строки счетов WealthLedger: название и отличающийся тип слева, текущая стоимость с двумя знаками справа на desktop и под названием на mobile. Видимы не более двух валют и счётчик остальных, позиции и локальный статус источника. Отсутствие сводки и отсутствие сумм имеют разные сообщения. Примеры содержат вымышленные данные. Ссылки меняют только локальный маршрут MemoryRouter; страница назначения в этой истории не отображается.",
},
},
},
@@ -23,7 +23,10 @@ describe("BrokerageAccountList", () => {
bondPositionCount: 12,
stockPositionCount: 5,
totalsByCurrency: [
{ currency: "RUB", value: "1500" },
{
currency: "RUB",
value: "123456789012345678901.995",
},
{ currency: "USD", value: "20" },
{ currency: "EUR", value: "5" },
],
@@ -33,7 +36,7 @@ describe("BrokerageAccountList", () => {
},
{
id: "account-2",
name: "Недоступный",
name: "Брокерский счет",
openedOn: null,
portfolioSummary: null,
type: "BROKERAGE",
@@ -60,24 +63,27 @@ describe("BrokerageAccountList", () => {
"/accounts/account-1",
);
expect(screen.getAllByText("Брокерский счет")).toHaveLength(3);
expect(screen.getByText(/^Открыт /)).toBeInTheDocument();
expect(screen.getByText("1 500 ₽")).toBeInTheDocument();
expect(screen.getByText("20 USD")).toBeInTheDocument();
expect(screen.getByText("123 456 789 012 345 678 902,00 ₽"))
.toBeInTheDocument();
expect(screen.getByText("20,00 USD")).toBeInTheDocument();
expect(screen.getByText("Еще 1 валюта")).toBeInTheDocument();
expect(screen.getByText("Сводка временно недоступна")).toBeInTheDocument();
expect(screen.getByText("Нет текущих сумм")).toBeInTheDocument();
expect(screen.getAllByText("Открыть счет")).toHaveLength(3);
const available = within(screen.getByRole("link", { name: /Основной/ }));
expect(available.getAllByRole("definition")[0]).toHaveTextContent("5");
expect(available.getAllByRole("definition")[1]).toHaveTextContent("12");
const unvalued = within(screen.getByRole("link", { name: /Пустой/ }));
expect(unvalued.getAllByRole("definition")[0]).toHaveTextContent("2");
expect(unvalued.getAllByRole("definition")[1]).toHaveTextContent("0");
expect(
within(screen.getByRole("link", { name: /Недоступный/ })).queryByText(
"Акции",
),
).not.toBeInTheDocument();
const duplicatedName = within(screen.getByRole("link", {
name: /Сводка временно недоступна/,
}));
expect(duplicatedName.getAllByText("Брокерский счет")).toHaveLength(1);
expect(duplicatedName.queryByText("Акции")).not.toBeInTheDocument();
});
});
@@ -1,11 +1,10 @@
import type { FC } from "react";
import { Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { WalletIcon } from "@wealthledger/icons";
import { Group, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { formatDate, formatMoney } from "@/shared/lib";
import { formatMoney } from "@/shared/lib";
import { SourceSyncStatus } from "@/shared/ui";
import type { BrokerageAccountCard } from "../../model/display";
@@ -39,7 +38,7 @@ const BrokerageAccountSummary: FC<BrokerageAccountSummaryProps> = (props) => {
<Stack className={classes.summary} gap={4}>
{summary.totals.map(total => (
<Text className={classes.total} fw={700} key={total.currency}>
{formatMoney(total)}
{formatMoney(total, { withRoundedCents: true })}
</Text>
))}
@@ -92,81 +91,75 @@ const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
const { t } = useTranslation();
return (
<SimpleGrid className={classes.list} cols={1} spacing="lg">
{accounts.map(({ account, positionCounts, source, summary }) => (
<Card
className={classes.card}
component={Link}
key={account.id}
padding="xl"
radius="lg"
to={`/accounts/${account.id}`}
withBorder
>
<Stack className={classes.identity} gap="sm">
<Group gap="sm" justify="space-between" wrap="nowrap">
<Text className={classes.kicker} fw={700} size="xs">
{t(accountTypeKey(account.type))}
</Text>
<Stack className={classes.list} component="ul" gap="sm">
{accounts.map(({ account, positionCounts, source, summary }) => {
const accountType = t(accountTypeKey(account.type));
<WalletIcon className={classes.accountIcon} />
</Group>
return (
<li key={account.id}>
<Link className={classes.row} to={`/accounts/${account.id}`}>
<Stack className={classes.identity} gap={2}>
<Text className={classes.name} component="h2" fw={600}>
{account.name}
</Text>
<Text className={classes.name} component="h2" fw={700}>
{account.name}
</Text>
</Stack>
{account.name !== accountType && (
<Text className={classes.accountType} size="xs">
{accountType}
</Text>
)}
</Stack>
<Stack className={classes.balance} gap="xs">
<Text c="dimmed" size="sm">
{t("accounts.currentValue")}
</Text>
<Stack className={classes.balance} gap={2}>
<Text c="dimmed" size="xs">
{t("accounts.currentValue")}
</Text>
<BrokerageAccountSummary summary={summary} />
<BrokerageAccountSummary summary={summary} />
</Stack>
<BrokerageAccountSourceStatus source={source} />
</Stack>
<Group className={classes.details} gap="md">
{!!positionCounts && (
<Group
className={classes.positions}
component="dl"
gap="md"
>
<div>
<Text c="dimmed" component="dt" size="sm">
{t("accounts.stocks")}
</Text>
{!!positionCounts && (
<dl className={classes.positions}>
<div className={classes.stocks}>
<dt>{t("accounts.stocks")}</dt>
<Text component="dd" fw={600} size="sm">
{new Intl.NumberFormat("ru-RU").format(
positionCounts.stocks,
)}
</Text>
</div>
<dd>
{new Intl.NumberFormat("ru-RU").format(positionCounts.stocks)}
</dd>
</div>
<div>
<Text c="dimmed" component="dt" size="sm">
{t("accounts.bonds")}
</Text>
<div className={classes.bonds}>
<dt>{t("accounts.bonds")}</dt>
<Text component="dd" fw={600} size="sm">
{new Intl.NumberFormat("ru-RU").format(
positionCounts.bonds,
)}
</Text>
</div>
</Group>
)}
<dd>
{new Intl.NumberFormat("ru-RU").format(positionCounts.bonds)}
</dd>
</div>
</dl>
)}
<Group className={classes.footer} gap="sm" justify="space-between">
{!!account.openedOn && (
<Text c="dimmed" size="xs">
{t("accounts.openedOn", {
date: formatDate(account.openedOn),
})}
</Text>
)}
<Text className={classes.open} component="span" fw={600} size="sm">
{t("accounts.openAccount")}
<span aria-hidden="true" className={classes.arrow}>
→
</span>
</Text>
</Group>
</Card>
))}
</SimpleGrid>
<Stack className={classes.status} gap={2}>
<BrokerageAccountSourceStatus source={source} />
</Stack>
</Group>
</Link>
</li>
);
})}
</Stack>
);
};
+25 -3
View File
@@ -2,6 +2,7 @@ const DECIMAL_PART_COUNT = 2;
const CENTS_PRECISION = 2;
type FormatMoneyOptions = {
withRoundedCents?: boolean;
withZeroCents?: boolean;
};
@@ -39,16 +40,20 @@ function formatMoney(
money: Money
| null
| undefined,
{ withZeroCents = false }: FormatMoneyOptions = {},
{ withRoundedCents = false, withZeroCents = false }: FormatMoneyOptions = {},
): string {
if (!money) return "—";
const [integerPart = "", fractionPart = ""] = money.value.split(
const value = withRoundedCents
? roundDecimalToCents(money.value)
: money.value;
const [integerPart = "", fractionPart = ""] = value.split(
".",
DECIMAL_PART_COUNT,
);
const normalizedFraction = withZeroCents
const normalizedFraction = withRoundedCents || withZeroCents
? fractionPart.padEnd(CENTS_PRECISION, "0")
: fractionPart;
@@ -61,6 +66,23 @@ function formatMoney(
return `${formatDecimal(decimal)} ${currency}`;
}
function roundDecimalToCents(value: string): string {
const [integer = "", fraction = ""] = value.split(".", DECIMAL_PART_COUNT);
const sign = integer.startsWith("-") ? "-" : "";
const digits = sign ? integer.slice(1) : integer;
const roundedFraction = fraction.padEnd(CENTS_PRECISION + 1, "0");
const cents = `${digits}${roundedFraction.slice(0, CENTS_PRECISION)}`;
const roundingDigit = roundedFraction.at(CENTS_PRECISION) ?? "0";
const rounded = roundingDigit >= "5"
? (BigInt(cents) + 1n).toString().padStart(cents.length, "0")
: cents;
const roundedInteger = rounded.slice(0, -CENTS_PRECISION) || "0";
return `${sign}${roundedInteger}.${rounded.slice(-CENTS_PRECISION)}`;
}
// Без timeZone Intl берёт часовой пояс браузера пользователя.
const snapshotFormatter = new Intl.DateTimeFormat("ru-RU", {
dateStyle: "short",