Add scaled number formatter for K/M/B values
This commit is contained in:
29
lib/format.test.ts
Normal file
29
lib/format.test.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import { formatScaledNumber } from './format';
|
||||||
|
|
||||||
|
describe('formatScaledNumber', () => {
|
||||||
|
it('keeps values below one thousand unscaled', () => {
|
||||||
|
expect(formatScaledNumber(950)).toBe('950');
|
||||||
|
expect(formatScaledNumber(950.44, { maximumFractionDigits: 2 })).toBe('950.44');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scales thousands with K suffix', () => {
|
||||||
|
expect(formatScaledNumber(12_345)).toBe('12.3K');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scales millions with M suffix', () => {
|
||||||
|
expect(formatScaledNumber(3_250_000)).toBe('3.3M');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scales billions with B suffix', () => {
|
||||||
|
expect(formatScaledNumber(7_500_000_000)).toBe('7.5B');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves negative sign when scaled', () => {
|
||||||
|
expect(formatScaledNumber(-2_750_000)).toBe('-2.8M');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('promotes rounded values to the next scale when needed', () => {
|
||||||
|
expect(formatScaledNumber(999_950)).toBe('1M');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,63 @@ export function asNumber(value: string | number | null | undefined) {
|
|||||||
return Number.isFinite(parsed) ? parsed : 0;
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NumberScale = {
|
||||||
|
divisor: number;
|
||||||
|
suffix: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NUMBER_SCALES: NumberScale[] = [
|
||||||
|
{ divisor: 1, suffix: '' },
|
||||||
|
{ divisor: 1_000, suffix: 'K' },
|
||||||
|
{ divisor: 1_000_000, suffix: 'M' },
|
||||||
|
{ divisor: 1_000_000_000, suffix: 'B' }
|
||||||
|
];
|
||||||
|
|
||||||
|
type FormatScaledNumberOptions = {
|
||||||
|
minimumFractionDigits?: number;
|
||||||
|
maximumFractionDigits?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatScaledNumber(
|
||||||
|
value: string | number | null | undefined,
|
||||||
|
options: FormatScaledNumberOptions = {}
|
||||||
|
) {
|
||||||
|
const {
|
||||||
|
minimumFractionDigits = 0,
|
||||||
|
maximumFractionDigits = 1
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const numeric = asNumber(value);
|
||||||
|
const absolute = Math.abs(numeric);
|
||||||
|
|
||||||
|
let scaleIndex = 0;
|
||||||
|
if (absolute >= 1_000_000_000) {
|
||||||
|
scaleIndex = 3;
|
||||||
|
} else if (absolute >= 1_000_000) {
|
||||||
|
scaleIndex = 2;
|
||||||
|
} else if (absolute >= 1_000) {
|
||||||
|
scaleIndex = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let scaledAbsolute = absolute / NUMBER_SCALES[scaleIndex].divisor;
|
||||||
|
|
||||||
|
if (
|
||||||
|
Number(scaledAbsolute.toFixed(maximumFractionDigits)) >= 1_000
|
||||||
|
&& scaleIndex < NUMBER_SCALES.length - 1
|
||||||
|
) {
|
||||||
|
scaleIndex += 1;
|
||||||
|
scaledAbsolute = absolute / NUMBER_SCALES[scaleIndex].divisor;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scaled = numeric < 0 ? -scaledAbsolute : scaledAbsolute;
|
||||||
|
const formatted = new Intl.NumberFormat('en-US', {
|
||||||
|
minimumFractionDigits,
|
||||||
|
maximumFractionDigits
|
||||||
|
}).format(scaled);
|
||||||
|
|
||||||
|
return `${formatted}${NUMBER_SCALES[scaleIndex].suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatCurrency(value: string | number | null | undefined) {
|
export function formatCurrency(value: string | number | null | undefined) {
|
||||||
return new Intl.NumberFormat('en-US', {
|
return new Intl.NumberFormat('en-US', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
|
|||||||
Reference in New Issue
Block a user