diff --git a/lib/format.test.ts b/lib/format.test.ts new file mode 100644 index 0000000..049ccfc --- /dev/null +++ b/lib/format.test.ts @@ -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'); + }); +}); diff --git a/lib/format.ts b/lib/format.ts index eac8faa..bd7d28d 100644 --- a/lib/format.ts +++ b/lib/format.ts @@ -7,6 +7,63 @@ export function asNumber(value: string | number | null | undefined) { 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) { return new Intl.NumberFormat('en-US', { style: 'currency',