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'
+ );
};