Files
frontend/src/shared/lib/display.ts
T
codex-bot 1d4990491f
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 5m26s
fix: round money to currency minor units without negative zero (WealthLedger/frontend#118) (#119)
2026-10-28 08:45:55 +03:00

167 lines
4.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const DECIMAL_PART_COUNT = 2;
const DEFAULT_MINOR_UNITS = 2;
type FormatMoneyOptions = {
withRoundedCents?: boolean;
withSign?: boolean;
withZeroCents?: boolean;
};
const portfolioCategoryPresentation = {
BOND: { color: "grape.6", label: "portfolio.bonds" },
CASH: { color: "green.6", label: "portfolio.structure.cash" },
OTHER: { color: "orange.6", label: "portfolio.structure.other" },
STOCK: { color: "blue.6", label: "portfolio.stocks" },
} as const;
type Money = { currency: string; value: string };
const dateFormatter = new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "2-digit",
timeZone: "UTC",
year: "numeric",
});
// ISO 4217 minor units (JPY 0, KWD 3); unknown codes fall back to 2.
function currencyMinorUnits(currency: string): number {
try {
return new Intl.NumberFormat("en", { currency, style: "currency" })
.resolvedOptions().maximumFractionDigits ?? DEFAULT_MINOR_UNITS;
}
catch {
return DEFAULT_MINOR_UNITS;
}
}
function formatDate(value: string): string {
return dateFormatter.format(new Date(value));
}
function formatDecimal(value: string): string {
const [integer = "", fraction] = value.split(".", DECIMAL_PART_COUNT);
const sign = integer.startsWith("-") ? "-" : "";
const digits = sign ? integer.slice(1) : integer;
const groupSize = 3;
const groups: string[] = [];
for (let end = digits.length; end > 0; end -= groupSize) {
groups.push(digits.slice(Math.max(0, end - groupSize), end));
}
const grouped = groups.toReversed().join(" ");
const suffix = fraction ? `,${fraction}` : "";
return `${sign}${grouped}${suffix}`;
}
function formatMoney(
money: Money
| null
| undefined,
{
withRoundedCents = false,
withSign = false,
withZeroCents = false,
}: FormatMoneyOptions = {},
): string {
if (!money) return "—";
const minorUnits = currencyMinorUnits(money.currency);
const value = withRoundedCents
? roundDecimal(money.value, minorUnits)
: money.value;
const [integerPart = "", fractionPart = ""] = value.split(
".",
DECIMAL_PART_COUNT,
);
const normalizedFraction = withRoundedCents || withZeroCents
? fractionPart.padEnd(minorUnits, "0")
: fractionPart;
const decimal = normalizedFraction
? `${integerPart}.${normalizedFraction}`
: integerPart;
const currency = money.currency === "RUB" ? "₽" : money.currency;
const formatted = `${formatDecimal(decimal)} ${currency}`;
if (!withSign) return formatted;
if (isZeroDecimal(value))
return value.startsWith("-") ? formatted.slice(1) : formatted;
return value.startsWith("-") ? `−${formatted.slice(1)}` : `+${formatted}`;
}
function isZeroDecimal(value: string) {
const unsigned = value.startsWith("-") ? value.slice(1) : value;
const [integer = "", fraction = ""] = unsigned.split(
".",
DECIMAL_PART_COUNT,
);
return integer.replaceAll("0", "") === ""
&& fraction.replaceAll("0", "") === "";
}
function roundDecimal(value: string, places: number): string {
const [integer = "", fraction = ""] = value.split(".", DECIMAL_PART_COUNT);
const digits = integer.startsWith("-") ? integer.slice(1) : integer;
const paddedFraction = fraction.padEnd(places + 1, "0");
const units = `${digits}${paddedFraction.slice(0, places)}`;
const roundingDigit = paddedFraction.at(places) ?? "0";
const rounded = (roundingDigit >= "5" ? BigInt(units) + 1n : BigInt(units))
.toString()
.padStart(places + 1, "0");
// A value rounded to zero has no sign: "-0.004" → "0.00".
const sign = integer.startsWith("-") && /[1-9]/.test(rounded) ? "-" : "";
const roundedInteger = rounded.slice(0, rounded.length - places);
const roundedFraction = rounded.slice(rounded.length - places);
return roundedFraction
? `${sign}${roundedInteger}.${roundedFraction}`
: `${sign}${roundedInteger}`;
}
// Без timeZone Intl берёт часовой пояс браузера пользователя.
const snapshotFormatter = new Intl.DateTimeFormat("ru-RU", {
dateStyle: "short",
timeStyle: "short",
});
function formatSnapshotTime(value: string): string {
return snapshotFormatter.format(new Date(value));
}
const logoSize = 160;
const chartFormatter = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 4 });
function formatChartValue(value: number) {
return chartFormatter.format(value);
}
function instrumentLogoUrl(logoName?: null | string): null | string {
const stem = logoName?.trim().replace(/\.png$/i, "");
return stem
? `https://invest-brands.cdn-tinkoff.ru/${stem}x${logoSize}.png`
: null;
}
export {
formatChartValue,
formatDate,
formatDecimal,
formatMoney,
formatSnapshotTime,
instrumentLogoUrl,
portfolioCategoryPresentation,
};