From 93a2bd1157819e520def9cb96e313b52bd43d6f4 Mon Sep 17 00:00:00 2001 From: Brandon Pereira Date: Wed, 21 Oct 2026 14:48:47 -0600 Subject: [PATCH 1/2] fix: stop abbreviating 500-999 as 0.5k/0.8k on chart axes numbro's default lowPrecision switches to the k suffix once a value rounds to 1000, so 750 rendered as "0.8k" next to an unabbreviated "250" on the same axis. Disable it whenever average is applied. Fixes HDX-5781 --- .changeset/axis-no-sub-thousand-abbreviation.md | 6 ++++++ .../__tests__/HDXMultiSeriesTimeChart.test.ts | 12 +++++++++++- packages/app/src/__tests__/utils.test.ts | 16 ++++++++++++++++ packages/app/src/utils.ts | 4 ++++ packages/cli/src/__tests__/formatNumber.test.ts | 10 ++++++++++ packages/cli/src/shared/formatNumber.ts | 4 ++++ 6 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 .changeset/axis-no-sub-thousand-abbreviation.md diff --git a/.changeset/axis-no-sub-thousand-abbreviation.md b/.changeset/axis-no-sub-thousand-abbreviation.md new file mode 100644 index 0000000000..1812158e5c --- /dev/null +++ b/.changeset/axis-no-sub-thousand-abbreviation.md @@ -0,0 +1,6 @@ +--- +'@hyperdx/app': patch +'@hyperdx/cli': patch +--- + +fix: Stop abbreviating values between 500 and 999 as "0.5k"/"0.8k" on chart axes and in large number format diff --git a/packages/app/src/__tests__/HDXMultiSeriesTimeChart.test.ts b/packages/app/src/__tests__/HDXMultiSeriesTimeChart.test.ts index 2ccea9c3db..9a53c84384 100644 --- a/packages/app/src/__tests__/HDXMultiSeriesTimeChart.test.ts +++ b/packages/app/src/__tests__/HDXMultiSeriesTimeChart.test.ts @@ -86,6 +86,16 @@ describe('formatAxisTick', () => { ); }); + it('does not abbreviate values between 500 and 999', () => { + // Regression: numbro's default lowPrecision rendered 750 as "0.8k" next + // to an unabbreviated "250" on the same axis. + const format = { output: 'number' as const, mantissa: 1 }; + expect( + [0, 250, 500, 750, 1000].map(v => formatAxisTick(v, format)), + ).toEqual(['0', '250', '500', '750', '1k']); + expect(formatAxisTick(800, { output: 'number' })).toBe('800'); + }); + it('always renders exactly 0 as a bare integer', () => { // 0 needs no decimal rescue - it's already unambiguous - and the search // naturally produces this by trimming "0.00"/"0.0 B" down to "0"/"0 B". @@ -320,7 +330,7 @@ describe('getNiceYAxisTicks', () => { const result = getExpandableYAxisTicks(0, 2100, 5, { output: 'number' }); expect(result.ticks.map(t => result.tickFormatter!(t))).toEqual([ '0', - '0.5k', + '500', '1k', '1.5k', '2k', diff --git a/packages/app/src/__tests__/utils.test.ts b/packages/app/src/__tests__/utils.test.ts index 864d5ba1b2..d4d2894335 100644 --- a/packages/app/src/__tests__/utils.test.ts +++ b/packages/app/src/__tests__/utils.test.ts @@ -508,6 +508,22 @@ describe('formatNumber', () => { }; expect(formatNumber(1234567, format)).toBe('1m'); }); + + it('only abbreviates from 1000, not from 500', () => { + const format: NumberFormat = { + output: 'number', + average: true, + mantissa: 1, + }; + expect(formatNumber(750, format)).toBe('750.0'); + expect(formatNumber(1500, format)).toBe('1.5k'); + }); + + it('still formats byte output when average is set', () => { + expect( + formatNumber(268435456, { output: 'byte', average: true, mantissa: 1 }), + ).toBe('256.0 MB'); + }); }); describe('numericUnit with data output (byte)', () => { diff --git a/packages/app/src/utils.ts b/packages/app/src/utils.ts index b8e0e91ea3..6790e5a775 100644 --- a/packages/app/src/utils.ts +++ b/packages/app/src/utils.ts @@ -1062,6 +1062,10 @@ export const formatNumber = ( mantissa: mantissa, thousandSeparated: options.thousandSeparated || false, average: options.average || false, + // numbro's default lowPrecision abbreviates anything >= 500 (750 -> "0.8k"). + // numbro rejects lowPrecision without average, which byte forces off below. + ...(options.average && + options.output !== 'byte' && { lowPrecision: false }), ...(options.output === 'byte' && { base: options.decimalBytes ? 'decimal' : 'general', spaceSeparated: true, diff --git a/packages/cli/src/__tests__/formatNumber.test.ts b/packages/cli/src/__tests__/formatNumber.test.ts index 2ffc60dfbd..f7b293179a 100644 --- a/packages/cli/src/__tests__/formatNumber.test.ts +++ b/packages/cli/src/__tests__/formatNumber.test.ts @@ -18,6 +18,16 @@ describe('axisTickFormatter', () => { expect(format?.(1234)).toBe('1k'); }); + it('does not abbreviate values between 500 and 999', () => { + const format = axisTickFormatter({ output: 'number', mantissa: 1 }); + expect([250, 500, 750, 1000].map(v => format?.(v))).toEqual([ + '250', + '500', + '750', + '1k', + ]); + }); + it('honors an explicit mantissa instead of always rounding to 0', () => { const format = axisTickFormatter({ output: 'number', mantissa: 2 }); expect(format?.(0.14)).toBe('0.14'); diff --git a/packages/cli/src/shared/formatNumber.ts b/packages/cli/src/shared/formatNumber.ts index 9d51af73a8..a428899380 100644 --- a/packages/cli/src/shared/formatNumber.ts +++ b/packages/cli/src/shared/formatNumber.ts @@ -226,6 +226,10 @@ export const formatNumber = ( mantissa: mantissa, thousandSeparated: options.thousandSeparated || false, average: options.average || false, + // numbro's default lowPrecision abbreviates anything >= 500 (750 -> "0.8k"). + // numbro rejects lowPrecision without average, which byte forces off below. + ...(options.average && + options.output !== 'byte' && { lowPrecision: false }), ...(options.output === 'byte' && { base: options.decimalBytes ? 'decimal' : 'general', spaceSeparated: true, From f2cf30f79cd492da1d4bf150a2552742ce51f054 Mon Sep 17 00:00:00 2001 From: Brandon Pereira Date: Wed, 21 Oct 2026 16:01:11 -0600 Subject: [PATCH 2/2] fix: pick the average unit from the rounded value Turning off lowPrecision made numbro choose the unit before rounding, so 999,700 rendered as "1000k" instead of "1m". Choose the unit from the value rounded at the configured mantissa and pass it as forceAverage. --- packages/app/src/__tests__/utils.test.ts | 14 +++++++ packages/app/src/utils.ts | 40 ++++++++++++++++--- .../cli/src/__tests__/formatNumber.test.ts | 11 ++++- packages/cli/src/shared/formatNumber.ts | 40 ++++++++++++++++--- 4 files changed, 92 insertions(+), 13 deletions(-) diff --git a/packages/app/src/__tests__/utils.test.ts b/packages/app/src/__tests__/utils.test.ts index d4d2894335..f52c61f7c0 100644 --- a/packages/app/src/__tests__/utils.test.ts +++ b/packages/app/src/__tests__/utils.test.ts @@ -517,6 +517,20 @@ describe('formatNumber', () => { }; expect(formatNumber(750, format)).toBe('750.0'); expect(formatNumber(1500, format)).toBe('1.5k'); + expect(formatNumber(600000, format)).toBe('600.0k'); + }); + + it('moves to the next unit when rounding reaches 1000 of the current one', () => { + const format: NumberFormat = { output: 'number', average: true }; + expect(formatNumber(999700, format)).toBe('1m'); + expect(formatNumber(-999700, format)).toBe('-1m'); + expect(formatNumber(999.6e6, format)).toBe('1b'); + expect(formatNumber(999.6, format)).toBe('1k'); + expect(formatNumber(999960, { ...format, mantissa: 1 })).toBe('1.0m'); + expect(formatNumber(999940, { ...format, mantissa: 1 })).toBe('999.9k'); + expect(formatNumber(9.9999, { output: 'percent', average: true })).toBe( + '1k%', + ); }); it('still formats byte output when average is set', () => { diff --git a/packages/app/src/utils.ts b/packages/app/src/utils.ts index 6790e5a775..a80e9495c3 100644 --- a/packages/app/src/utils.ts +++ b/packages/app/src/utils.ts @@ -1001,6 +1001,32 @@ const formatAutoScaleData = ( return `${scaledValue.toFixed(mantissa)} ${units[i]}${rateSuffix}`; }; +const AVERAGE_UNITS = [ + [1e3, 'thousand'], + [1e6, 'million'], + [1e9, 'billion'], + [1e12, 'trillion'], +] as const; + +// numbro's default lowPrecision abbreviates anything that rounds to the next +// unit (750 -> "0.8k"), while turning it off picks the unit before rounding +// (999,700 -> "1000k"). Pick the unit from the rounded value instead. +function averageUnitOptions( + displayed: number, + mantissa: number, +): Pick { + const abs = Math.abs(displayed); + const round = (v: number) => Math.round(v * 10 ** mantissa) / 10 ** mantissa; + let scaled = abs; + let unit: numbro.Format['forceAverage']; + for (const [power, name] of AVERAGE_UNITS) { + if (round(scaled) < 1000) break; + unit = name; + scaled = abs / power; + } + return unit ? { forceAverage: unit } : { lowPrecision: false }; +} + export const formatNumber = ( value?: string | number, options?: NumberFormat, @@ -1057,15 +1083,20 @@ export const formatNumber = ( return formatDurationMs(ms); } + // Factor is only currently available for the time output + const factor = options.output === 'time' ? (options.factor ?? 1) : 1; + const numbroFormat: numbro.Format = { output: options.output || 'number', mantissa: mantissa, thousandSeparated: options.thousandSeparated || false, average: options.average || false, - // numbro's default lowPrecision abbreviates anything >= 500 (750 -> "0.8k"). - // numbro rejects lowPrecision without average, which byte forces off below. ...(options.average && - options.output !== 'byte' && { lowPrecision: false }), + options.output !== 'byte' && + averageUnitOptions( + value * factor * (options.output === 'percent' ? 100 : 1), + mantissa, + )), ...(options.output === 'byte' && { base: options.decimalBytes ? 'decimal' : 'general', spaceSeparated: true, @@ -1076,9 +1107,6 @@ export const formatNumber = ( }), }; - // Factor is only currently available for the time output - const factor = options.output === 'time' ? (options.factor ?? 1) : 1; - return ( numbro(value * factor).format(numbroFormat) + (options.unit ? ` ${options.unit}` : '') diff --git a/packages/cli/src/__tests__/formatNumber.test.ts b/packages/cli/src/__tests__/formatNumber.test.ts index f7b293179a..a3a77749f5 100644 --- a/packages/cli/src/__tests__/formatNumber.test.ts +++ b/packages/cli/src/__tests__/formatNumber.test.ts @@ -2,7 +2,16 @@ import { describe, expect, it } from '@jest/globals'; import { NumericUnit } from '@hyperdx/common-utils/dist/types'; -import { axisTickFormatter } from '@/shared/formatNumber'; +import { axisTickFormatter, formatNumber } from '@/shared/formatNumber'; + +describe('formatNumber average', () => { + it('moves to the next unit when rounding reaches 1000 of the current one', () => { + const format = { output: 'number' as const, average: true }; + expect(formatNumber(750, format)).toBe('750'); + expect(formatNumber(999700, format)).toBe('1m'); + expect(formatNumber(999.6e6, format)).toBe('1b'); + }); +}); // Mirrors packages/app/src/__tests__/HDXMultiSeriesTimeChart.test.ts's // formatAxisTick suite - this file is a documented @source port of that diff --git a/packages/cli/src/shared/formatNumber.ts b/packages/cli/src/shared/formatNumber.ts index a428899380..d18a71f71f 100644 --- a/packages/cli/src/shared/formatNumber.ts +++ b/packages/cli/src/shared/formatNumber.ts @@ -165,6 +165,32 @@ const formatAutoScaleData = ( return `${scaledValue.toFixed(mantissa)} ${units[i]}${rateSuffix}`; }; +const AVERAGE_UNITS = [ + [1e3, 'thousand'], + [1e6, 'million'], + [1e9, 'billion'], + [1e12, 'trillion'], +] as const; + +// numbro's default lowPrecision abbreviates anything that rounds to the next +// unit (750 -> "0.8k"), while turning it off picks the unit before rounding +// (999,700 -> "1000k"). Pick the unit from the rounded value instead. +function averageUnitOptions( + displayed: number, + mantissa: number, +): Pick { + const abs = Math.abs(displayed); + const round = (v: number) => Math.round(v * 10 ** mantissa) / 10 ** mantissa; + let scaled = abs; + let unit: numbro.Format['forceAverage']; + for (const [power, name] of AVERAGE_UNITS) { + if (round(scaled) < 1000) break; + unit = name; + scaled = abs / power; + } + return unit ? { forceAverage: unit } : { lowPrecision: false }; +} + export const formatNumber = ( value?: string | number, options?: NumberFormat, @@ -221,15 +247,20 @@ export const formatNumber = ( return formatDurationMs(ms); } + // Factor is only currently available for the time output + const factor = options.output === 'time' ? (options.factor ?? 1) : 1; + const numbroFormat: numbro.Format = { output: options.output || 'number', mantissa: mantissa, thousandSeparated: options.thousandSeparated || false, average: options.average || false, - // numbro's default lowPrecision abbreviates anything >= 500 (750 -> "0.8k"). - // numbro rejects lowPrecision without average, which byte forces off below. ...(options.average && - options.output !== 'byte' && { lowPrecision: false }), + options.output !== 'byte' && + averageUnitOptions( + value * factor * (options.output === 'percent' ? 100 : 1), + mantissa, + )), ...(options.output === 'byte' && { base: options.decimalBytes ? 'decimal' : 'general', spaceSeparated: true, @@ -240,9 +271,6 @@ export const formatNumber = ( }), }; - // Factor is only currently available for the time output - const factor = options.output === 'time' ? (options.factor ?? 1) : 1; - return ( numbro(value * factor).format(numbroFormat) + (options.unit ? ` ${options.unit}` : '')