diff --git a/app/react/common/utils/numbers.test.ts b/app/react/common/utils/numbers.test.ts new file mode 100644 index 000000000..0cd6004a5 --- /dev/null +++ b/app/react/common/utils/numbers.test.ts @@ -0,0 +1,75 @@ +/* eslint-disable @typescript-eslint/no-loss-of-precision */ + +import { abbreviateNumber } from './numbers'; + +describe('abbreviateNumber', () => { + test('errors', () => { + expect(() => abbreviateNumber(Number.NaN)).toThrowError(); + expect(() => abbreviateNumber(1, -1)).toThrowError(); + expect(() => abbreviateNumber(1, 21)).toThrowError(); + }); + + test('zero', () => { + expect(abbreviateNumber(0)).toBe('0'); + expect(abbreviateNumber(-0)).toBe('0'); + }); + + test('decimals=0', () => { + const cases: [number, string][] = [ + [123, '123'], + [123_123, '123k'], + [123_123_123, '123M'], + [123_123_123_123, '123G'], + [123_123_123_123_123, '123T'], + [123_123_123_123_123_123, '123P'], + [123_123_123_123_123_123_123, '123E'], + [123_123_123_123_123_123_123_123, '123Z'], + [123_123_123_123_123_123_123_123_123, '123Y'], + [123_123_123_123_123_123_123_123_123_123, '123123Y'], + ]; + cases.forEach(([num, str]) => { + expect(abbreviateNumber(num, 0)).toBe(str); + expect(abbreviateNumber(-num, 0)).toBe(`-${str}`); + }); + }); + + test('decimals=1 (default)', () => { + const cases: [number, string][] = [ + [123, '123'], + [123_123, '123.1k'], + [123_123_123, '123.1M'], + [123_123_123_123, '123.1G'], + [123_123_123_123_123, '123.1T'], + [123_123_123_123_123_123, '123.1P'], + [123_123_123_123_123_123_123, '123.1E'], + [123_123_123_123_123_123_123_123, '123.1Z'], + [123_123_123_123_123_123_123_123_123, '123.1Y'], + [123_123_123_123_123_123_123_123_123_123, '123123.1Y'], + ]; + cases.forEach(([num, str]) => { + expect(abbreviateNumber(num)).toBe(str); + expect(abbreviateNumber(-num)).toBe(`-${str}`); + }); + }); + + test('decimals=10', () => { + const cases: [number, string][] = [ + [123, '123'], + [123_123, '123.123k'], + [123_123_123, '123.123123M'], + [123_123_123_123, '123.123123123G'], + [123_123_123_123_123, '123.1231231231T'], + [123_123_123_123_123_123, '123.1231231231P'], + [123_123_123_123_123_123_123, '123.1231231231E'], + [123_123_123_123_123_123_123_123, '123.1231231231Z'], + [123_123_123_123_123_123_123_123_123, '123.1231231231Y'], + [123_123_123_123_123_123_123_123_123_123, '123123.1231231231Y'], + ]; + cases.forEach(([num, str]) => { + expect(abbreviateNumber(num, 10)).toBe(str); + expect(abbreviateNumber(-num, 10)).toBe(`-${str}`); + }); + }); +}); + +/* eslint-enable @typescript-eslint/no-loss-of-precision */ diff --git a/app/react/common/utils/numbers.ts b/app/react/common/utils/numbers.ts new file mode 100644 index 000000000..617da47ae --- /dev/null +++ b/app/react/common/utils/numbers.ts @@ -0,0 +1,45 @@ +const suffixes = ['', 'k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y']; + +/** + * Converts a number to a human-readable abbreviated format + * Uses base 10 and standard SI prefixes + * + * @param num - The number to abbreviate + * @param decimals - Number of decimal places (default: 1) + * @returns Abbreviated number as string (e.g., "90k", "123M") + */ +export function abbreviateNumber(num: number, decimals: number = 1): string { + if (Number.isNaN(num)) { + throw new Error('Invalid number'); + } + + if (decimals < 0 || decimals > 20) { + throw new Error('Invalid decimals. Must be in [0;20] range'); + } + + const isNegative = num < 0; + const absNum = Math.abs(num); + + if (absNum === 0) { + return '0'; + } + + let exponent = Math.floor(Math.log10(absNum) / 3); + + if (exponent > suffixes.length - 1) { + exponent = suffixes.length - 1; + } + + if (exponent < 0) { + exponent = 0; + } + + const value = absNum / 1000 ** exponent; + + const roundedValue = + exponent > 0 ? Number(value.toFixed(decimals)) : Math.floor(value); + + const finalValue = isNegative ? -roundedValue : roundedValue; + + return `${finalValue}${suffixes[exponent]}`; +}