Frontend verify / commits (pull_request) Successful in 29s
Frontend verify / security (pull_request) Successful in 8s
Frontend verify / verify (pull_request) Successful in 5m25s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 5m20s
150 lines
4.0 KiB
TypeScript
150 lines
4.0 KiB
TypeScript
const DECIMAL_PART_COUNT = 2;
|
||
const CENTS_PRECISION = 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",
|
||
});
|
||
|
||
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 value = withRoundedCents
|
||
? roundDecimalToCents(money.value)
|
||
: money.value;
|
||
|
||
const [integerPart = "", fractionPart = ""] = value.split(
|
||
".",
|
||
DECIMAL_PART_COUNT,
|
||
);
|
||
|
||
const normalizedFraction = withRoundedCents || withZeroCents
|
||
? fractionPart.padEnd(CENTS_PRECISION, "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 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",
|
||
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,
|
||
};
|