From 4c35cf305f8f546b0ffed4b473aeae47ba3f1465 Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Thu, 30 Oct 2025 22:10:34 +0530 Subject: [PATCH] Add support for negative bars on bar chart (#15418) in app: Screenshot 2025-10-29 at 01 47 51 Screenshot 2025-10-29 at 02 30 15 new stories: Screenshot 2025-10-29 at 01 46 50 Screenshot 2025-10-29 at 01 46 45 Screenshot 2025-10-29 at 01 46 40 Screenshot 2025-10-29 at 01 46 34 Screenshot 2025-10-29 at 01 46 30 --- .../GraphWidgetBarChart.stories.tsx | 246 +++++++++++++++++- .../components/CustomBarItem.tsx | 77 ++++-- .../components/GraphWidgetBarChart.tsx | 33 ++- .../hooks/useBarChartTheme.ts | 6 - .../calculateBarChartValueRange.test.ts | 68 +++++ ...calculateStackedBarChartValueRange.test.ts | 69 +++++ .../utils/calculateBarChartValueRange.ts | 37 +++ .../calculateStackedBarChartValueRange.ts | 46 ++++ .../widgets/graph/utils/graphFormatters.ts | 9 +- .../__tests__/formatToShortNumber.test.ts | 26 ++ .../src/utils/format/formatToShortNumber.ts | 31 ++- 11 files changed, 603 insertions(+), 45 deletions(-) create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/calculateBarChartValueRange.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/calculateStackedBarChartValueRange.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateBarChartValueRange.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateStackedBarChartValueRange.ts diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetBarChart.stories.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetBarChart.stories.tsx index 7ed4ef1447..b46d9818a1 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetBarChart.stories.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetBarChart.stories.tsx @@ -494,6 +494,248 @@ export const Currency: Story = { ), }; +export const NegativeValues: Story = { + args: { + data: [ + { quarter: 'Q1', profit: -50000, to: '/financials/q1' }, + { quarter: 'Q2', profit: -20000, to: '/financials/q2' }, + { quarter: 'Q3', profit: 10000, to: '/financials/q3' }, + { quarter: 'Q4', profit: 80000, to: '/financials/q4' }, + ], + indexBy: 'quarter', + keys: ['profit'], + seriesLabels: { + profit: 'Net Profit', + }, + displayType: 'shortNumber', + prefix: '$', + showLegend: false, + showGrid: true, + xAxisLabel: 'Quarter', + yAxisLabel: 'Profit/Loss ($)', + id: 'bar-chart-negative', + }, + render: (args) => ( + + + + ), +}; + +export const MixedPositiveNegative: Story = { + args: { + data: [ + { + month: 'Jan', + revenue: 120000, + expenses: -80000, + net: 40000, + to: '/reports/jan', + }, + { + month: 'Feb', + revenue: 150000, + expenses: -95000, + net: 55000, + to: '/reports/feb', + }, + { + month: 'Mar', + revenue: 130000, + expenses: -140000, + net: -10000, + to: '/reports/mar', + }, + { + month: 'Apr', + revenue: 180000, + expenses: -110000, + net: 70000, + to: '/reports/apr', + }, + ], + indexBy: 'month', + keys: ['revenue', 'expenses', 'net'], + seriesLabels: { + revenue: 'Revenue', + expenses: 'Expenses', + net: 'Net Income', + }, + displayType: 'shortNumber', + prefix: '$', + showLegend: true, + showGrid: true, + xAxisLabel: 'Month', + yAxisLabel: 'Amount ($)', + id: 'bar-chart-mixed-values', + }, + render: (args) => ( + + + + ), +}; + +export const AllNegative: Story = { + args: { + data: [ + { category: 'Loss A', value: -45, to: '/losses/a' }, + { category: 'Loss B', value: -30, to: '/losses/b' }, + { category: 'Loss C', value: -60, to: '/losses/c' }, + { category: 'Loss D', value: -25, to: '/losses/d' }, + ], + indexBy: 'category', + keys: ['value'], + showLegend: false, + showGrid: true, + xAxisLabel: 'Category', + yAxisLabel: 'Value', + id: 'bar-chart-all-negative', + }, + render: (args) => ( + + + + ), +}; + +export const TemperatureData: Story = { + args: { + data: [ + { month: 'Jan', temp: -5, to: '/weather/jan' }, + { month: 'Feb', temp: -2, to: '/weather/feb' }, + { month: 'Mar', temp: 5, to: '/weather/mar' }, + { month: 'Apr', temp: 15, to: '/weather/apr' }, + { month: 'May', temp: 22, to: '/weather/may' }, + { month: 'Jun', temp: 28, to: '/weather/jun' }, + ], + indexBy: 'month', + keys: ['temp'], + showLegend: false, + showGrid: true, + xAxisLabel: 'Month', + yAxisLabel: 'Temperature', + suffix: '°C', + id: 'bar-chart-temperature', + }, + render: (args) => ( + + + + ), +}; + +export const StackedNegative: Story = { + args: { + data: [ + { + period: 'Q1', + profit: 50000, + loss: -30000, + adjustment: -5000, + to: '/periods/q1', + }, + { + period: 'Q2', + profit: 70000, + loss: -40000, + adjustment: 10000, + to: '/periods/q2', + }, + { + period: 'Q3', + profit: 60000, + loss: -25000, + adjustment: -8000, + to: '/periods/q3', + }, + { + period: 'Q4', + profit: 90000, + loss: -35000, + adjustment: 15000, + to: '/periods/q4', + }, + ], + indexBy: 'period', + keys: ['profit', 'loss', 'adjustment'], + seriesLabels: { + profit: 'Profit', + loss: 'Loss', + adjustment: 'Adjustment', + }, + groupMode: 'stacked', + displayType: 'shortNumber', + prefix: '$', + showLegend: true, + showGrid: true, + xAxisLabel: 'Period', + yAxisLabel: 'Amount ($)', + id: 'bar-chart-stacked-negative', + }, + render: (args) => ( + + + + ), +}; + export const GroupedWithAllBarsTooltip: Story = { args: { data: [ @@ -659,7 +901,9 @@ export const Catalog: Story = { groupMode={args.groupMode} showLegend={true} showGrid={true} - id={args.id} + id={`bar-chart-catalog-${args.keys?.length ?? 0}-${ + args.groupMode ?? 'grouped' + }`} /> ), diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/CustomBarItem.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/CustomBarItem.tsx index 100057370a..2142b3a8ba 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/CustomBarItem.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/CustomBarItem.tsx @@ -15,6 +15,7 @@ type CustomBarItemProps = BarItemProps & { data?: readonly D[]; indexBy?: string; layout?: 'vertical' | 'horizontal'; + chartId?: string; }; const StyledBarRect = styled(animated.rect)<{ $isInteractive?: boolean }>` @@ -62,6 +63,7 @@ export const CustomBarItem = ({ data: chartData, indexBy, layout = 'vertical', + chartId, }: CustomBarItemProps) => { const theme = useTheme(); const { showTooltipFromEvent, showTooltipAt, hideTooltip } = useTooltip(); @@ -105,7 +107,17 @@ export const CustomBarItem = ({ hideTooltip(); }, [hideTooltip]); - const isTopBar = useMemo(() => { + const isNegativeValue = useMemo( + () => isNumber(barData.value) && barData.value < 0, + [barData.value], + ); + + const seriesIndex = useMemo( + () => (isDefined(keys) ? keys.findIndex((k) => k === barData.id) : -1), + [keys, barData.id], + ); + + const shouldRoundFreeEnd = useMemo(() => { const isStackedAndValid = groupMode === 'stacked' && isDefined(keys) && @@ -125,39 +137,58 @@ export const CustomBarItem = ({ return true; } - const currentKeyIndex = keys.findIndex((key) => key === barData.id); - - if (currentKeyIndex === -1) { + if (seriesIndex === -1) { return true; } - const keysAboveCurrentKey = keys.slice(currentKeyIndex + 1); - const hasBarAbove = keysAboveCurrentKey.some((key) => { + const keysAfterCurrentKey = keys.slice(seriesIndex + 1); + const hasSameSignBarAfter = keysAfterCurrentKey.some((key) => { const value = dataPoint[key]; - return isNumber(value) && value > 0; + return isNumber(value) && (isNegativeValue ? value < 0 : value > 0); }); - - return !hasBarAbove; - }, [groupMode, keys, barData, chartData, indexBy]); + return !hasSameSignBarAfter; + }, [ + groupMode, + keys, + chartData, + indexBy, + isNegativeValue, + seriesIndex, + barData.indexValue, + ]); const isHorizontal = layout === 'horizontal'; + const clipPathId = `round-corner-${chartId ?? 'chart'}-${barData.index}-${ + seriesIndex >= 0 ? seriesIndex : 'x' + }`; + + const clipPathX = !isHorizontal ? 0 : isNegativeValue ? 0 : -borderRadius; + + const clipPathY = isHorizontal ? 0 : isNegativeValue ? -borderRadius : 0; + + const widthWithOffset = (v: number) => + Math.max(v + (isHorizontal ? borderRadius : 0), 0); + const heightWithOffset = (v: number) => + Math.max(v + (isHorizontal ? 0 : borderRadius), 0); + const clampRadius = (v: number) => Math.min(borderRadius, v / 2); + + const clipRectWidth = to(width, (v) => widthWithOffset(v)); + const clipRectHeight = to(height, (v) => heightWithOffset(v)); + const clipRx = to(width, (v) => clampRadius(widthWithOffset(v))); + const clipRy = to(height, (v) => clampRadius(heightWithOffset(v))); return ( - {isTopBar && ( + {shouldRoundFreeEnd && ( - + - Math.max(value + (isHorizontal ? borderRadius : 0), 0), - )} - height={to(height, (value) => - Math.max(value + (isHorizontal ? 0 : borderRadius), 0), - )} + x={clipPathX} + y={clipPathY} + rx={clipRx} + ry={clipRy} + width={clipRectWidth} + height={clipRectHeight} /> @@ -165,7 +196,7 @@ export const CustomBarItem = ({ Math.max(value, 0))} height={to(height, (value) => Math.max(value, 0))} fill={color} diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart.tsx index e3de749058..2ef6259206 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart.tsx @@ -9,6 +9,8 @@ import { useBarChartTheme } from '@/page-layout/widgets/graph/graphWidgetBarChar import { useBarChartTooltip } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartTooltip'; import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem'; import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries'; +import { calculateBarChartValueRange } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateBarChartValueRange'; +import { calculateStackedBarChartValueRange } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateStackedBarChartValueRange'; import { getBarChartAxisConfigs } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartAxisConfigs'; import { getBarChartColor } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartColor'; import { createGraphColorRegistry } from '@/page-layout/widgets/graph/utils/createGraphColorRegistry'; @@ -163,11 +165,33 @@ export const GraphWidgetBarChart = ({ data={data} indexBy={indexBy} layout={layout} + chartId={id} /> ), - [keys, groupMode, data, indexBy, layout], + [keys, groupMode, data, indexBy, layout, id], ); + const calculatedRange = + groupMode === 'stacked' + ? calculateStackedBarChartValueRange(data, keys) + : calculateBarChartValueRange(data, keys); + const effectiveMin = rangeMin ?? calculatedRange.min; + const effectiveMax = rangeMax ?? calculatedRange.max; + + const hasNegativeValues = calculatedRange.min < 0; + const zeroMarker = hasNegativeValues + ? [ + { + axis: (layout === 'vertical' ? 'y' : 'x') as 'y' | 'x', + value: 0, + lineStyle: { + stroke: theme.border.color.medium, + strokeWidth: 1, + }, + }, + ] + : undefined; + return ( getBarChartColor(datum, barConfigs, theme)} - layers={['grid', 'axes', 'bars', 'markers', 'legends']} + layers={['grid', 'markers', 'axes', 'bars', 'legends']} + markers={zeroMarker} axisTop={null} axisRight={null} axisBottom={axisBottomConfig} diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartTheme.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartTheme.ts index c13178ddf7..df49df2d92 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartTheme.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartTheme.ts @@ -5,12 +5,6 @@ export const useBarChartTheme = () => { return { axis: { - domain: { - line: { - stroke: theme.border.color.light, - strokeWidth: 1, - }, - }, ticks: { line: { stroke: theme.border.color.light, diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/calculateBarChartValueRange.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/calculateBarChartValueRange.test.ts new file mode 100644 index 0000000000..b2e4bfeacb --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/calculateBarChartValueRange.test.ts @@ -0,0 +1,68 @@ +import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem'; +import { calculateBarChartValueRange } from '../calculateBarChartValueRange'; + +describe('calculateBarChartValueRange (essential cases)', () => { + it('returns min=0 and max=highest value for all positive values', () => { + const data: BarChartDataItem[] = [ + { category: 'A', v1: 10, v2: 20 }, + { category: 'B', v1: 30, v2: 15 }, + { category: 'C', v1: 25, v2: 40 }, + ]; + const keys = ['v1', 'v2']; + + expect(calculateBarChartValueRange(data, keys)).toEqual({ + min: 0, + max: 40, + }); + }); + + it('returns min=lowest and max=0 for all negative values', () => { + const data: BarChartDataItem[] = [ + { category: 'A', v1: -10, v2: -20 }, + { category: 'B', v1: -30, v2: -15 }, + { category: 'C', v1: -25, v2: -40 }, + ]; + const keys = ['v1', 'v2']; + + expect(calculateBarChartValueRange(data, keys)).toEqual({ + min: -40, + max: 0, + }); + }); + + it('includes zero and spans min/max when values cross zero', () => { + const data: BarChartDataItem[] = [ + { category: 'A', v1: -20, v2: 30 }, + { category: 'B', v1: 15, v2: -10 }, + { category: 'C', v1: -5, v2: 25 }, + ]; + const keys = ['v1', 'v2']; + + expect(calculateBarChartValueRange(data, keys)).toEqual({ + min: -20, + max: 30, + }); + }); + + it('handles empty data and empty keys', () => { + expect(calculateBarChartValueRange([], ['v'])).toEqual({ min: 0, max: 0 }); + expect(calculateBarChartValueRange([{ cat: 'A', v: 10 }], [])).toEqual({ + min: 0, + max: 0, + }); + }); + + it('ignores NaN/missing values', () => { + const data: BarChartDataItem[] = [ + { category: 'A', v1: 10, v2: NaN }, + { category: 'B', v1: 20, v2: 30 }, + { category: 'C', v1: undefined as unknown as number }, + ]; + const keys = ['v1', 'v2']; + + expect(calculateBarChartValueRange(data, keys)).toEqual({ + min: 0, + max: 30, + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/calculateStackedBarChartValueRange.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/calculateStackedBarChartValueRange.test.ts new file mode 100644 index 0000000000..e7ca93231d --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/calculateStackedBarChartValueRange.test.ts @@ -0,0 +1,69 @@ +import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem'; +import { calculateStackedBarChartValueRange } from '../calculateStackedBarChartValueRange'; + +describe('calculateStackedBarChartValueRange (essential cases)', () => { + it('returns min=0 and max=largest positive stack', () => { + const data: BarChartDataItem[] = [ + { cat: 'A', v1: 100, v2: 200, v3: 50 }, + { cat: 'B', v1: 150, v2: 25, v3: 75 }, + { cat: 'C', v1: 300, v2: 10, v3: 0 }, + ]; + const keys = ['v1', 'v2', 'v3']; + + expect(calculateStackedBarChartValueRange(data, keys)).toEqual({ + min: 0, + max: 350, + }); + }); + + it('returns min=most negative stack and max=0 for all negative values', () => { + const data: BarChartDataItem[] = [ + { cat: 'A', v1: -100, v2: -200, v3: 0 }, + { cat: 'B', v1: -50, v2: -25, v3: -75 }, + ]; + const keys = ['v1', 'v2', 'v3']; + + expect(calculateStackedBarChartValueRange(data, keys)).toEqual({ + min: -300, + max: 0, + }); + }); + + it('sums positives and negatives per index to compute range when values mix', () => { + const data: BarChartDataItem[] = [ + { cat: 'A', v1: 100, v2: -60, v3: 20 }, + { cat: 'B', v1: 50, v2: -80, v3: -30 }, + { cat: 'C', v1: 10, v2: 0, v3: 0 }, + ]; + const keys = ['v1', 'v2', 'v3']; + + expect(calculateStackedBarChartValueRange(data, keys)).toEqual({ + min: -110, + max: 120, + }); + }); + + it('handles empty data and empty keys', () => { + expect(calculateStackedBarChartValueRange([], ['v1'])).toEqual({ + min: 0, + max: 0, + }); + expect( + calculateStackedBarChartValueRange([{ cat: 'A', v1: 10 }], []), + ).toEqual({ min: 0, max: 0 }); + }); + + it('ignores missing keys and NaN values', () => { + const data: BarChartDataItem[] = [ + { cat: 'A', v1: 10 }, + { cat: 'B', v2: 30 }, + { cat: 'C', v1: NaN as number }, + ]; + const keys = ['v1', 'v2']; + + expect(calculateStackedBarChartValueRange(data, keys)).toEqual({ + min: 0, + max: 30, + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateBarChartValueRange.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateBarChartValueRange.ts new file mode 100644 index 0000000000..e4d575156a --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateBarChartValueRange.ts @@ -0,0 +1,37 @@ +import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem'; + +type ValueRange = { + min: number; + max: number; +}; + +export const calculateBarChartValueRange = ( + data: BarChartDataItem[], + keys: string[], +): ValueRange => { + let min = 0; + let max = 0; + + for (const item of data) { + for (const key of keys) { + const value = Number(item[key] ?? 0); + if (!isNaN(value)) { + if (value < min) { + min = value; + } + if (value > max) { + max = value; + } + } + } + } + + if (min > 0) { + min = 0; + } + if (max < 0) { + max = 0; + } + + return { min, max }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateStackedBarChartValueRange.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateStackedBarChartValueRange.ts new file mode 100644 index 0000000000..c0790a9613 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateStackedBarChartValueRange.ts @@ -0,0 +1,46 @@ +import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem'; + +type ValueRange = { + min: number; + max: number; +}; + +export const calculateStackedBarChartValueRange = ( + data: BarChartDataItem[], + keys: string[], +): ValueRange => { + let min = 0; + let max = 0; + + for (const item of data) { + let positiveSum = 0; + let negativeSum = 0; + + for (const key of keys) { + const value = Number(item[key] ?? 0); + if (!Number.isNaN(value)) { + if (value >= 0) { + positiveSum += value; + } else { + negativeSum += value; + } + } + } + + if (positiveSum > max) { + max = positiveSum; + } + if (negativeSum < min) { + min = negativeSum; + } + } + + if (min > 0) { + min = 0; + } + if (max < 0) { + max = 0; + } + + return { min, max }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/graphFormatters.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/graphFormatters.ts index 163dc2e72e..a53af4b5e8 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/graphFormatters.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/graphFormatters.ts @@ -39,20 +39,23 @@ export const formatGraphValue = ( return customFormatter(value); } + const sign = value < 0 ? '-' : ''; + const absoluteValue = Math.abs(value); + switch (displayType) { case 'percentage': return `${formatNumber(value * 100, { decimals })}%`; case 'shortNumber': - return `${prefix}${formatToShortNumber(value)}${suffix}`; + return `${sign}${prefix}${formatToShortNumber(absoluteValue)}${suffix}`; case 'currency': { const currencyPrefix = prefix || '$'; - return `${currencyPrefix}${formatNumber(value, { decimals })}${suffix}`; + return `${sign}${currencyPrefix}${formatNumber(absoluteValue, { decimals })}${suffix}`; } case 'number': default: - return `${prefix}${formatNumber(value, { decimals })}${suffix}`; + return `${sign}${prefix}${formatNumber(absoluteValue, { decimals })}${suffix}`; } }; diff --git a/packages/twenty-front/src/utils/format/__tests__/formatToShortNumber.test.ts b/packages/twenty-front/src/utils/format/__tests__/formatToShortNumber.test.ts index 501171dcc6..97dc6f6168 100644 --- a/packages/twenty-front/src/utils/format/__tests__/formatToShortNumber.test.ts +++ b/packages/twenty-front/src/utils/format/__tests__/formatToShortNumber.test.ts @@ -25,4 +25,30 @@ describe('formatToShortNumber', () => { expect(formatToShortNumber(123.456)).toBe('123.5'); expect(formatToShortNumber(789.0123)).toBe('789'); }); + + it('handles zero correctly', () => { + expect(formatToShortNumber(0)).toBe('0'); + }); + + describe('negative numbers', () => { + it('formats negative numbers less than 1000 correctly', () => { + expect(formatToShortNumber(-500)).toBe('-500'); + expect(formatToShortNumber(-123.456)).toBe('-123.5'); + }); + + it('formats negative thousands correctly', () => { + expect(formatToShortNumber(-1500)).toBe('-1.5k'); + expect(formatToShortNumber(-789456)).toBe('-789.5k'); + }); + + it('formats negative millions correctly', () => { + expect(formatToShortNumber(-2000000)).toBe('-2m'); + expect(formatToShortNumber(-654987654)).toBe('-655m'); + }); + + it('formats negative billions correctly', () => { + expect(formatToShortNumber(-1200000000)).toBe('-1.2b'); + expect(formatToShortNumber(-987654321987)).toBe('-987.7b'); + }); + }); }); diff --git a/packages/twenty-front/src/utils/format/formatToShortNumber.ts b/packages/twenty-front/src/utils/format/formatToShortNumber.ts index b39d9dd2cd..43359643f4 100644 --- a/packages/twenty-front/src/utils/format/formatToShortNumber.ts +++ b/packages/twenty-front/src/utils/format/formatToShortNumber.ts @@ -1,11 +1,26 @@ export const formatToShortNumber = (amount: number) => { - if (amount < 1000) { - return amount.toFixed(1).replace(/\.?0+$/, ''); - } else if (amount < 1000000) { - return (amount / 1000).toFixed(1).replace(/\.?0+$/, '') + 'k'; - } else if (amount < 1000000000) { - return (amount / 1000000).toFixed(1).replace(/\.?0+$/, '') + 'm'; - } else { - return (amount / 1000000000).toFixed(1).replace(/\.?0+$/, '') + 'b'; + const sign = amount < 0 ? '-' : ''; + const absoluteAmount = Math.abs(amount); + + if (absoluteAmount < 1000) { + return sign + absoluteAmount.toFixed(1).replace(/\.?0+$/, ''); } + + if (absoluteAmount < 1_000_000) { + return ( + sign + (absoluteAmount / 1000).toFixed(1).replace(/\.?0+$/, '') + 'k' + ); + } + + if (absoluteAmount < 1_000_000_000) { + return ( + sign + (absoluteAmount / 1_000_000).toFixed(1).replace(/\.?0+$/, '') + 'm' + ); + } + + return ( + sign + + (absoluteAmount / 1_000_000_000).toFixed(1).replace(/\.?0+$/, '') + + 'b' + ); };