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:
@@ -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 };
|
||||
Reference in New Issue
Block a user