feat: group all-account rows by instrument and day (WealthLedger/main#47) (#126)
This commit was merged in pull request #126.
This commit is contained in:
@@ -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: "Зачисление ценных бумаг",
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -1,2 +1,8 @@
|
||||
export {
|
||||
AccountCardRow,
|
||||
AccountRow,
|
||||
EntryGroup,
|
||||
EntryGroupTotal,
|
||||
} from "./EntryGroup/EntryGroup";
|
||||
export { PayoutCard } from "./PayoutCard/PayoutCard";
|
||||
export { SourceSyncStatus } from "./SourceSyncStatus/SourceSyncStatus";
|
||||
|
||||
Reference in New Issue
Block a user