feat: group all-account rows by instrument and day (WealthLedger/main#47) (#126)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 6m1s

This commit was merged in pull request #126.
This commit is contained in:
2026-10-30 07:28:19 +03:00
parent 6792f5b70a
commit 71126c91f0
40 changed files with 1564 additions and 102 deletions
+4
View File
@@ -350,6 +350,9 @@ const ru = {
operations: "Операции",
settings: "Настройки",
},
entryGroup: {
totalUnavailable: "Итог недоступен",
},
events: {
dateKind: { event: "Дата события", registry: "Дата реестра" },
emptyText:
@@ -456,6 +459,7 @@ const ru = {
open: "Открыть журнал",
operationType: {
buy: "Покупка",
coupon: "Купон",
dividend: "Зачисление дивидендов",
fee: "Комиссия",
input: "Зачисление ценных бумаг",
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { sumMoney } from "./decimal";
describe("sumMoney", () => {
it("adds exact signed decimals independently per currency", () => {
expect(sumMoney([
{ currency: "RUB", value: "0.1" },
{ currency: "USD", value: "0.01" },
{ currency: "RUB", value: "0.2" },
{ currency: "RUB", value: "-1500000.45" },
{ currency: "USD", value: "-0.02" },
{ currency: "RUB", value: "200" },
])).toEqual([
{ currency: "RUB", value: "-1499800.15" },
{ currency: "USD", value: "-0.01" },
]);
});
it("preserves significant fractional digits and a compensated zero", () => {
expect(sumMoney([
{ currency: "RUB", value: "100.5" },
{ currency: "RUB", value: "49.50" },
{ currency: "RUB", value: "0.005" },
])).toEqual([{ currency: "RUB", value: "150.005" }]);
expect(sumMoney([
{ currency: "RUB", value: "100" },
{ currency: "RUB", value: "-100" },
])).toEqual([{ currency: "RUB", value: "0" }]);
});
it.each(["", ".", "1.2.3", "not-a-number"])(
"rejects malformed amount %s instead of treating it as zero",
(value) => {
expect(sumMoney([
{ currency: "RUB", value: "100" },
{ currency: "RUB", value },
])).toBeNull();
},
);
});
+75
View File
@@ -0,0 +1,75 @@
type Decimal = { scale: number; value: bigint };
type Money = { currency: string; value: string };
const DECIMAL_PARTS = 2;
const RADIX = 10n;
// Decimal grammar is bounded to digits and one optional dot.
// eslint-disable-next-line security/detect-unsafe-regex
const decimalPattern = /^-?\d+(?:\.\d+)?$/;
function decimalFromString(value: string): Decimal {
if (!decimalPattern.test(value)) throw new SyntaxError("Invalid decimal");
const [whole = "0", fraction = ""] = value.split(".", DECIMAL_PARTS);
return { scale: fraction.length, value: BigInt(`${whole}${fraction}`) };
}
function decimalToString(decimal: Decimal): string {
const sign = decimal.value < 0n ? "-" : "";
const digits = (decimal.value < 0n ? -decimal.value : decimal.value)
.toString()
.padStart(decimal.scale + 1, "0");
const whole = decimal.scale
? digits.slice(0, -decimal.scale)
: digits;
let fraction = decimal.scale ? digits.slice(-decimal.scale) : "";
while (fraction.endsWith("0")) fraction = fraction.slice(0, -1);
const suffix = fraction ? `.${fraction}` : "";
return `${sign}${whole}${suffix}`;
}
function sumDecimals(decimals: readonly Decimal[]): Decimal {
const scale = Math.max(...decimals.map(decimal => decimal.scale));
const value = decimals.reduce(
(sum, decimal) =>
sum + decimal.value * RADIX ** BigInt(scale - decimal.scale),
0n,
);
return { scale, value };
}
function sumMoney(amounts: readonly Money[]): Money[] | null {
const decimals = new Map<string, Decimal[]>();
for (const amount of amounts) {
try {
const values = decimals.get(amount.currency) ?? [];
values.push(decimalFromString(amount.value));
decimals.set(amount.currency, values);
}
catch {
return null;
}
}
return Array.from(decimals, ([currency, values]) => ({
currency,
value: decimalToString(sumDecimals(values)),
}));
}
export {
type Decimal,
decimalFromString,
decimalToString,
sumDecimals,
sumMoney,
};
+5
View File
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
formatDate,
formatMoney,
formatSnapshotDay,
formatSnapshotTime,
instrumentLogoUrl,
} from "./display";
@@ -22,6 +23,10 @@ describe("shared display helpers", () => {
}).format(new Date(instant));
expect(formatSnapshotTime(instant)).toBe(expected);
expect(formatSnapshotDay(instant)).toBe(
new Intl.DateTimeFormat("ru-RU", { dateStyle: "short" })
.format(new Date(instant)),
);
});
it("formats decimal strings without financial arithmetic", () => {
expect(formatMoney({ currency: "RUB", value: "-1234567.89" })).toBe(
+9
View File
@@ -135,6 +135,14 @@ const snapshotFormatter = new Intl.DateTimeFormat("ru-RU", {
timeStyle: "short",
});
const snapshotDayFormatter = new Intl.DateTimeFormat("ru-RU", {
dateStyle: "short",
});
function formatSnapshotDay(value: string): string {
return snapshotDayFormatter.format(new Date(value));
}
function formatSnapshotTime(value: string): string {
return snapshotFormatter.format(new Date(value));
}
@@ -160,6 +168,7 @@ export {
formatDate,
formatDecimal,
formatMoney,
formatSnapshotDay,
formatSnapshotTime,
instrumentLogoUrl,
portfolioCategoryPresentation,
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { groupItems } from "./group-items";
describe("groupItems", () => {
it("keeps the first key position and source order through interleaving", () => {
const a1 = { id: "A1", key: "A" };
const b = { id: "B", key: null };
const a2 = { id: "A2", key: "A" };
const c = { id: "C", key: "C" };
const a3 = { id: "A3", key: "A" };
const input = [a1, b, a2, c, a3];
const result = groupItems(input, item => item.key);
expect(result).toEqual([
{ items: [a1, a2, a3], key: "A", type: "group" },
{ item: b, type: "single" },
{ item: c, type: "single" },
]);
expect(input.map(item => item.id)).toEqual(["A1", "B", "A2", "C", "A3"]);
});
it("keeps two interleaved groups independent and singleton rows single", () => {
const result = groupItems(
[
{ id: "X1", key: "X" },
{ id: "Y1", key: "Y" },
{ id: "X2", key: "X" },
{ id: "Y2", key: "Y" },
{ id: "X3", key: "X" },
{ id: "Z1", key: "Z" },
],
item => item.key,
);
expect(result.map(entry => entry.type === "group"
? [entry.key, entry.items.map(item => item.id)]
: [null, [entry.item.id]])).toEqual([
["X", ["X1", "X2", "X3"]],
["Y", ["Y1", "Y2"]],
[null, ["Z1"]],
]);
});
});
+47
View File
@@ -0,0 +1,47 @@
type GroupedItem<T>
= | { item: T; type: "single" }
| { items: [T, T, ...T[]]; key: string; type: "group" };
function groupItems<T>(
items: readonly T[],
keyOf: (item: T) => null | string,
): GroupedItem<T>[] {
const result: GroupedItem<T>[] = [];
const indices = new Map<string, number>();
for (const item of items) {
const key = keyOf(item);
if (key === null) {
result.push({ item, type: "single" });
continue;
}
const index = indices.get(key);
if (index === undefined) {
indices.set(key, result.length);
result.push({ item, type: "single" });
continue;
}
// eslint-disable-next-line security/detect-object-injection -- Map-owned index.
const existing = result[index];
if (existing?.type === "single") {
// eslint-disable-next-line security/detect-object-injection -- Map-owned index.
result[index] = {
items: [existing.item, item],
key,
type: "group",
};
}
else if (existing) {
existing.items.push(item);
}
}
return result;
}
export { groupItems };
+10
View File
@@ -1,13 +1,23 @@
export { compareDateDesc, defaultPeriod, isValidPeriod, moscowDate } from "./calendar";
export { calendarDate } from "./calendar.schema";
export {
type Decimal,
decimalFromString,
decimalToString,
sumDecimals,
sumMoney,
} from "./decimal";
export {
formatChartValue,
formatDate,
formatDecimal,
formatMoney,
formatSnapshotDay,
formatSnapshotTime,
instrumentLogoUrl,
portfolioCategoryPresentation,
} from "./display";
export { groupItems } from "./group-items";
export { useInitialAvailability } from "./initial-availability";
export { totalText } from "./total-text";
export { useUrlPeriod } from "./useUrlPeriod";
+13
View File
@@ -0,0 +1,13 @@
import { formatMoney } from "./display";
type Money = { currency: string; value: string };
function totalText(totals: Money[] | null, isSigned: boolean): string {
return totals
? totals.map(total =>
formatMoney(total, { withSign: isSigned || undefined }),
).join(", ")
: "";
}
export { totalText };
@@ -0,0 +1,41 @@
.group {
overflow: hidden;
border-color: var(--wl-color-border);
background: var(--wl-color-subtle);
}
.accounts {
padding: 0 var(--mantine-spacing-md) var(--mantine-spacing-sm);
list-style: none;
}
.accountRow {
display: flex;
gap: var(--mantine-spacing-sm);
align-items: center;
justify-content: space-between;
padding: var(--mantine-spacing-sm) 0;
border-top: 1px solid var(--wl-color-border);
}
.accountValue {
flex-shrink: 0;
text-align: right;
white-space: nowrap;
}
.accountCardRow {
display: flex;
gap: var(--mantine-spacing-sm);
align-items: center;
justify-content: space-between;
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
border-color: var(--wl-color-border);
background: var(--wl-color-surface);
}
@media (max-width: 36em) {
.accountRow {
align-items: flex-start;
}
}
+96
View File
@@ -0,0 +1,96 @@
import type { FC, PropsWithChildren, ReactNode } from "react";
import { Paper, Stack, Text, VisuallyHidden } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { formatMoney } from "@/shared/lib";
import classes from "./EntryGroup.module.css";
type AccountRowProps = {
accountName: string;
value: string;
valueLabel: string;
};
type EntryGroupProps = PropsWithChildren<{
header: ReactNode;
label: string;
}>;
type EntryGroupTotalProps = {
isSigned?: boolean;
totals: null | { currency: string; value: string }[];
};
const AccountRow: FC<AccountRowProps> = (props) => {
const accessibleName = `${props.accountName}, ${props.value}`;
return (
<li aria-label={accessibleName} className={classes.accountRow}>
<Text>{props.accountName}</Text>
<Stack className={classes.accountValue} gap={2}>
<Text fw={700}>{props.value}</Text>
<Text c="dimmed" size="xs">{props.valueLabel}</Text>
</Stack>
</li>
);
};
const AccountCardRow: FC<AccountRowProps> = props => (
<li aria-label={`${props.accountName}, ${props.value}`}>
<Paper className={classes.accountCardRow} withBorder>
<Text fw={700}>{props.accountName}</Text>
<Stack className={classes.accountValue} gap={2}>
<Text fw={700}>{props.value}</Text>
<Text c="dimmed" size="xs">{props.valueLabel}</Text>
</Stack>
</Paper>
</li>
);
const EntryGroup: FC<EntryGroupProps> = props => (
<Paper className={classes.group} component="li" withBorder>
{props.header}
<Stack
aria-label={props.label}
className={classes.accounts}
component="ul"
gap={0}
>
{props.children}
</Stack>
</Paper>
);
const EntryGroupTotal: FC<EntryGroupTotalProps> = (props) => {
const { t } = useTranslation();
if (!props.totals) {
return <VisuallyHidden>{t("entryGroup.totalUnavailable")}</VisuallyHidden>;
}
const isSigned = props.isSigned === true;
return (
<Stack gap={2}>
{props.totals.map(total => (
<span key={`${total.currency}-${total.value}`}>
{formatMoney(total, { withSign: isSigned || undefined })}
</span>
))}
</Stack>
);
};
export {
AccountCardRow,
AccountRow,
EntryGroup,
EntryGroupTotal,
};
+6
View File
@@ -1,2 +1,8 @@
export {
AccountCardRow,
AccountRow,
EntryGroup,
EntryGroupTotal,
} from "./EntryGroup/EntryGroup";
export { PayoutCard } from "./PayoutCard/PayoutCard";
export { SourceSyncStatus } from "./SourceSyncStatus/SourceSyncStatus";