diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetGaugeChart.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetGaugeChart.tsx
deleted file mode 100644
index 1c65dabd163..00000000000
--- a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetGaugeChart.tsx
+++ /dev/null
@@ -1,216 +0,0 @@
-import { useTheme } from '@emotion/react';
-import styled from '@emotion/styled';
-import {
- type RadialBarCustomLayerProps,
- ResponsiveRadialBar,
-} from '@nivo/radial-bar';
-import { useId, useState } from 'react';
-import { isDefined } from 'twenty-shared/utils';
-import { H1Title, H1TitleFontColor } from 'twenty-ui/display';
-import { type GraphColor } from '../types/GraphColor';
-import { createGradientDef } from '../utils/createGradientDef';
-import { createGraphColorRegistry } from '../utils/createGraphColorRegistry';
-import { getColorScheme } from '../utils/getColorScheme';
-import {
- formatGraphValue,
- type GraphValueFormatOptions,
-} from '../utils/graphFormatters';
-import { GraphWidgetChartContainer } from './GraphWidgetChartContainer';
-import { GraphWidgetLegend } from './GraphWidgetLegend';
-import { GraphWidgetTooltip } from './GraphWidgetTooltip';
-
-type GaugeChartData = {
- value: number;
- min: number;
- max: number;
- color?: GraphColor;
- to?: string;
- label?: string;
-};
-
-type GraphWidgetGaugeChartProps = {
- data: GaugeChartData;
- showValue?: boolean;
- showLegend?: boolean;
- id: string;
-} & GraphValueFormatOptions;
-
-const StyledContainer = styled.div`
- align-items: center;
- display: flex;
- flex-direction: column;
- height: 100%;
- justify-content: center;
- width: 100%;
-`;
-
-const StyledH1Title = styled(H1Title)`
- left: 50%;
- position: absolute;
- top: 50%;
- transform: translate(-50%, -150%);
-`;
-
-export const GraphWidgetGaugeChart = ({
- data,
- showValue = true,
- showLegend = true,
- id,
- displayType,
- decimals,
- prefix,
- suffix,
- customFormatter,
-}: GraphWidgetGaugeChartProps) => {
- const { value, min, max, color = 'blue', to, label = 'Value' } = data;
- const theme = useTheme();
- const instanceId = useId();
- const [isHovered, setIsHovered] = useState(false);
-
- const colorRegistry = createGraphColorRegistry(theme);
- const colorScheme = getColorScheme(colorRegistry, color);
-
- const formatOptions: GraphValueFormatOptions = {
- displayType,
- decimals,
- prefix,
- suffix,
- customFormatter,
- };
-
- const formattedValue = formatGraphValue(value, formatOptions);
-
- const normalizedValue = max === min ? 0 : ((value - min) / (max - min)) * 100;
- const clampedNormalizedValue = Math.max(0, Math.min(100, normalizedValue));
-
- const chartData = [
- {
- id: 'gauge',
- data: [
- { x: 'value', y: clampedNormalizedValue },
- { x: 'empty', y: 100 - clampedNormalizedValue },
- ],
- },
- ];
-
- const gradientId = `gaugeGradient-${id}-${instanceId}`;
- const gaugeAngle = -90 + (clampedNormalizedValue / 100) * 90;
- const gradientDef = createGradientDef(
- colorScheme,
- gradientId,
- isHovered,
- gaugeAngle,
- );
- const defs = [gradientDef];
-
- const handleClick = () => {
- if (isDefined(to)) {
- window.location.href = to;
- }
- };
-
- const renderTooltip = () => {
- const formattedWithPercentage = `${formattedValue} (${normalizedValue.toFixed(1)}%)`;
-
- return (
-
- );
- };
-
- const renderValueEndLine = (props: RadialBarCustomLayerProps) => {
- if (clampedNormalizedValue === 0) {
- return null;
- }
-
- const { center, bars } = props;
-
- const valueBar = bars?.find((bar) => bar.data.x === 'value');
- if (!valueBar) {
- return null;
- }
-
- const endAngle = valueBar.arc.endAngle - Math.PI / 2;
- const arcInnerRadius = valueBar.arc.innerRadius;
- const arcOuterRadius = valueBar.arc.outerRadius;
-
- const [centerX, centerY] = center;
- const x1 = centerX + Math.cos(endAngle) * arcInnerRadius;
- const y1 = centerY + Math.sin(endAngle) * arcInnerRadius;
- const x2 = centerX + Math.cos(endAngle) * arcOuterRadius;
- const y2 = centerY + Math.sin(endAngle) * arcOuterRadius;
-
- return (
-
-
-
- );
- };
-
- return (
-
-
- d.x === 'value',
- id: gradientId,
- },
- ]}
- enableTracks={false}
- enableRadialGrid={false}
- enableCircularGrid={false}
- enableLabels={false}
- isInteractive={true}
- tooltip={renderTooltip}
- onClick={handleClick}
- onMouseEnter={() => setIsHovered(true)}
- onMouseLeave={() => setIsHovered(false)}
- layers={['bars', renderValueEndLine]}
- />
- {showValue && (
-
- )}
-
-
-
- );
-};
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetRenderer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetRenderer.tsx
index fafc567dce2..ce3dbdd6b5d 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetRenderer.tsx
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetRenderer.tsx
@@ -1,7 +1,7 @@
import { GraphType } from '@/page-layout/mocks/mockWidgets';
import { getDefaultWidgetData } from '@/page-layout/utils/getDefaultWidgetData';
import { ChartSkeletonLoader } from '@/page-layout/widgets/graph/components/ChartSkeletonLoader';
-import { GraphWidgetNumberChart } from '@/page-layout/widgets/graph/components/GraphWidgetNumberChart';
+import { GraphWidgetNumberChart } from '@/page-layout/widgets/graph/graphWidgetNumberChart/components/GraphWidgetNumberChart';
import { type GraphWidget } from '@/page-layout/widgets/graph/types/GraphWidget';
import { lazy, Suspense } from 'react';
@@ -30,11 +30,11 @@ const GraphWidgetPieChart = lazy(() =>
);
const GraphWidgetGaugeChart = lazy(() =>
- import('@/page-layout/widgets/graph/components/GraphWidgetGaugeChart').then(
- (module) => ({
- default: module.GraphWidgetGaugeChart,
- }),
- ),
+ import(
+ '@/page-layout/widgets/graph/graphWidgetGaugeChart/components/GraphWidgetGaugeChart'
+ ).then((module) => ({
+ default: module.GraphWidgetGaugeChart,
+ })),
);
type GraphWidgetRendererProps = {
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetGaugeChart.stories.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetGaugeChart.stories.tsx
index 7234459b4e3..19c3329eff8 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetGaugeChart.stories.tsx
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetGaugeChart.stories.tsx
@@ -1,7 +1,7 @@
import { type Meta, type StoryObj } from '@storybook/react';
import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing';
-import { GraphWidgetGaugeChart } from '../GraphWidgetGaugeChart';
+import { GraphWidgetGaugeChart } from '@/page-layout/widgets/graph/graphWidgetGaugeChart/components/GraphWidgetGaugeChart';
const meta: Meta = {
title: 'Modules/PageLayout/Widgets/GraphWidgetGaugeChart',
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetNumberChart.stories.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetNumberChart.stories.tsx
index c18694dce96..451c5a11af6 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetNumberChart.stories.tsx
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetNumberChart.stories.tsx
@@ -1,7 +1,7 @@
import { type Meta, type StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui/testing';
-import { GraphWidgetNumberChart } from '../GraphWidgetNumberChart';
+import { GraphWidgetNumberChart } from '@/page-layout/widgets/graph/graphWidgetNumberChart/components/GraphWidgetNumberChart';
const meta: Meta = {
title: 'Modules/PageLayout/Widgets/GraphWidgetNumberChart',
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/components/GaugeChartEndLine.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/components/GaugeChartEndLine.tsx
new file mode 100644
index 00000000000..296f626614b
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/components/GaugeChartEndLine.tsx
@@ -0,0 +1,49 @@
+import { type GraphColorScheme } from '@/page-layout/widgets/graph/types/GraphColorScheme';
+import { calculateGaugeChartEndLineCoordinates } from '@/page-layout/widgets/graph/graphWidgetGaugeChart/utils/calculateGaugeChartEndLineCoordinates';
+import { type RadialBarCustomLayerProps } from '@nivo/radial-bar';
+import { isDefined } from 'twenty-shared/utils';
+
+type GaugeChartEndLineProps = {
+ center: RadialBarCustomLayerProps['center'];
+ bars: RadialBarCustomLayerProps['bars'];
+ clampedNormalizedValue: number;
+ colorScheme: GraphColorScheme;
+};
+
+export const GaugeChartEndLine = ({
+ center,
+ bars,
+ clampedNormalizedValue,
+ colorScheme,
+}: GaugeChartEndLineProps) => {
+ if (clampedNormalizedValue === 0) {
+ return null;
+ }
+
+ const valueBar = bars?.find((bar) => bar.data.x === 'value');
+ if (!isDefined(valueBar)) {
+ return null;
+ }
+
+ const [centerX, centerY] = center;
+ const { x1, y1, x2, y2 } = calculateGaugeChartEndLineCoordinates(
+ valueBar.arc.endAngle,
+ centerX,
+ centerY,
+ valueBar.arc.innerRadius,
+ valueBar.arc.outerRadius,
+ );
+
+ return (
+
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/components/GraphWidgetGaugeChart.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/components/GraphWidgetGaugeChart.tsx
new file mode 100644
index 00000000000..f997a9eae0c
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/components/GraphWidgetGaugeChart.tsx
@@ -0,0 +1,179 @@
+import { GraphWidgetLegend } from '@/page-layout/widgets/graph/components/GraphWidgetLegend';
+import { GraphWidgetTooltip } from '@/page-layout/widgets/graph/components/GraphWidgetTooltip';
+import { GaugeChartEndLine } from '@/page-layout/widgets/graph/graphWidgetGaugeChart/components/GaugeChartEndLine';
+import { useGaugeChartData } from '@/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/useGaugeChartData';
+import { useGaugeChartHandlers } from '@/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/useGaugeChartHandlers';
+import { useGaugeChartTooltip } from '@/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/useGaugeChartTooltip';
+import { type GaugeChartData } from '@/page-layout/widgets/graph/graphWidgetGaugeChart/types/GaugeChartData';
+import { createGraphColorRegistry } from '@/page-layout/widgets/graph/utils/createGraphColorRegistry';
+import {
+ formatGraphValue,
+ type GraphValueFormatOptions,
+} from '@/page-layout/widgets/graph/utils/graphFormatters';
+import { useTheme } from '@emotion/react';
+import styled from '@emotion/styled';
+import { t } from '@lingui/core/macro';
+import {
+ type RadialBarCustomLayerProps,
+ ResponsiveRadialBar,
+} from '@nivo/radial-bar';
+import { useId } from 'react';
+import { H1Title, H1TitleFontColor } from 'twenty-ui/display';
+
+type GraphWidgetGaugeChartProps = {
+ data: GaugeChartData;
+ showValue?: boolean;
+ showLegend?: boolean;
+ id: string;
+} & GraphValueFormatOptions;
+
+const StyledContainer = styled.div`
+ align-items: center;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ justify-content: center;
+ width: 100%;
+`;
+
+const StyledChartContainer = styled.div<{ $isClickable?: boolean }>`
+ flex: 1;
+ position: relative;
+ width: 100%;
+
+ ${({ $isClickable }) =>
+ $isClickable &&
+ `
+ svg g path[fill^="url(#"] {
+ cursor: pointer;
+ }
+ `}
+`;
+
+const StyledH1Title = styled(H1Title)`
+ left: 50%;
+ position: absolute;
+ top: 50%;
+ transform: translate(-50%, -150%);
+`;
+
+export const GraphWidgetGaugeChart = ({
+ data,
+ showValue = true,
+ showLegend = true,
+ id,
+ displayType,
+ decimals,
+ prefix,
+ suffix,
+ customFormatter,
+}: GraphWidgetGaugeChartProps) => {
+ const theme = useTheme();
+ const instanceId = useId();
+ const colorRegistry = createGraphColorRegistry(theme);
+
+ const formatOptions: GraphValueFormatOptions = {
+ displayType,
+ decimals,
+ prefix,
+ suffix,
+ customFormatter,
+ };
+
+ const { isHovered, setIsHovered, handleClick, hasClickableItems } =
+ useGaugeChartHandlers({ data });
+
+ const {
+ colorScheme,
+ normalizedValue,
+ clampedNormalizedValue,
+ chartData,
+ gradientId,
+ defs,
+ } = useGaugeChartData({
+ data,
+ colorRegistry,
+ id,
+ instanceId,
+ isHovered,
+ });
+
+ const { createTooltipData } = useGaugeChartTooltip({
+ value: data.value,
+ normalizedValue,
+ label: data.label || t`Value`,
+ colorScheme,
+ formatOptions,
+ to: data.to,
+ });
+
+ const formattedValue = formatGraphValue(data.value, formatOptions);
+
+ const renderValueEndLine = (props: RadialBarCustomLayerProps) => (
+
+ );
+
+ const renderTooltip = () => {
+ const tooltipData = createTooltipData();
+ return (
+
+ );
+ };
+
+ return (
+
+
+ d.x === 'value',
+ id: gradientId,
+ },
+ ]}
+ enableTracks={false}
+ enableRadialGrid={false}
+ enableCircularGrid={false}
+ enableLabels={false}
+ isInteractive={true}
+ tooltip={renderTooltip}
+ onClick={handleClick}
+ onMouseEnter={() => setIsHovered(true)}
+ onMouseLeave={() => setIsHovered(false)}
+ layers={['bars', renderValueEndLine]}
+ />
+ {showValue && (
+
+ )}
+
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/__tests__/useGaugeChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/__tests__/useGaugeChartData.test.ts
new file mode 100644
index 00000000000..33a384ece5e
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/__tests__/useGaugeChartData.test.ts
@@ -0,0 +1,305 @@
+import { type GaugeChartData } from '@/page-layout/widgets/graph/graphWidgetGaugeChart/types/GaugeChartData';
+import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry';
+import { renderHook } from '@testing-library/react';
+import { useGaugeChartData } from '../useGaugeChartData';
+
+describe('useGaugeChartData', () => {
+ const mockColorRegistry: GraphColorRegistry = {
+ blue: {
+ name: 'blue',
+ gradient: {
+ normal: ['blue1', 'blue2'],
+ hover: ['blue3', 'blue4'],
+ },
+ solid: 'blueSolid',
+ },
+ green: {
+ name: 'green',
+ gradient: {
+ normal: ['green1', 'green2'],
+ hover: ['green3', 'green4'],
+ },
+ solid: 'greenSolid',
+ },
+ };
+
+ it('should calculate normalized value correctly', () => {
+ const data: GaugeChartData = {
+ value: 75,
+ min: 0,
+ max: 100,
+ };
+
+ const { result } = renderHook(() =>
+ useGaugeChartData({
+ data,
+ colorRegistry: mockColorRegistry,
+ id: 'test-gauge',
+ instanceId: 'instance-1',
+ isHovered: false,
+ }),
+ );
+
+ expect(result.current.normalizedValue).toBe(75);
+ expect(result.current.clampedNormalizedValue).toBe(75);
+ });
+
+ it('should handle min and max with non-zero min', () => {
+ const data: GaugeChartData = {
+ value: 150,
+ min: 100,
+ max: 200,
+ };
+
+ const { result } = renderHook(() =>
+ useGaugeChartData({
+ data,
+ colorRegistry: mockColorRegistry,
+ id: 'test-gauge',
+ instanceId: 'instance-1',
+ isHovered: false,
+ }),
+ );
+
+ expect(result.current.normalizedValue).toBe(50);
+ expect(result.current.clampedNormalizedValue).toBe(50);
+ });
+
+ it('should clamp values above 100', () => {
+ const data: GaugeChartData = {
+ value: 150,
+ min: 0,
+ max: 100,
+ };
+
+ const { result } = renderHook(() =>
+ useGaugeChartData({
+ data,
+ colorRegistry: mockColorRegistry,
+ id: 'test-gauge',
+ instanceId: 'instance-1',
+ isHovered: false,
+ }),
+ );
+
+ expect(result.current.normalizedValue).toBe(150);
+ expect(result.current.clampedNormalizedValue).toBe(100);
+ });
+
+ it('should clamp values below 0', () => {
+ const data: GaugeChartData = {
+ value: -25,
+ min: 0,
+ max: 100,
+ };
+
+ const { result } = renderHook(() =>
+ useGaugeChartData({
+ data,
+ colorRegistry: mockColorRegistry,
+ id: 'test-gauge',
+ instanceId: 'instance-1',
+ isHovered: false,
+ }),
+ );
+
+ expect(result.current.normalizedValue).toBe(-25);
+ expect(result.current.clampedNormalizedValue).toBe(0);
+ });
+
+ it('should handle equal min and max', () => {
+ const data: GaugeChartData = {
+ value: 50,
+ min: 100,
+ max: 100,
+ };
+
+ const { result } = renderHook(() =>
+ useGaugeChartData({
+ data,
+ colorRegistry: mockColorRegistry,
+ id: 'test-gauge',
+ instanceId: 'instance-1',
+ isHovered: false,
+ }),
+ );
+
+ expect(result.current.normalizedValue).toBe(0);
+ expect(result.current.clampedNormalizedValue).toBe(0);
+ });
+
+ it('should use custom color when provided', () => {
+ const data: GaugeChartData = {
+ value: 50,
+ min: 0,
+ max: 100,
+ color: 'green',
+ };
+
+ const { result } = renderHook(() =>
+ useGaugeChartData({
+ data,
+ colorRegistry: mockColorRegistry,
+ id: 'test-gauge',
+ instanceId: 'instance-1',
+ isHovered: false,
+ }),
+ );
+
+ expect(result.current.colorScheme).toBe(mockColorRegistry.green);
+ });
+
+ it('should default to blue color when not provided', () => {
+ const data: GaugeChartData = {
+ value: 50,
+ min: 0,
+ max: 100,
+ };
+
+ const { result } = renderHook(() =>
+ useGaugeChartData({
+ data,
+ colorRegistry: mockColorRegistry,
+ id: 'test-gauge',
+ instanceId: 'instance-1',
+ isHovered: false,
+ }),
+ );
+
+ expect(result.current.colorScheme).toBe(mockColorRegistry.blue);
+ });
+
+ it('should generate correct chart data structure', () => {
+ const data: GaugeChartData = {
+ value: 30,
+ min: 0,
+ max: 100,
+ };
+
+ const { result } = renderHook(() =>
+ useGaugeChartData({
+ data,
+ colorRegistry: mockColorRegistry,
+ id: 'test-gauge',
+ instanceId: 'instance-1',
+ isHovered: false,
+ }),
+ );
+
+ expect(result.current.chartData).toEqual([
+ {
+ id: 'gauge',
+ data: [
+ { x: 'value', y: 30 },
+ { x: 'empty', y: 70 },
+ ],
+ },
+ ]);
+ });
+
+ it('should generate gradient with correct angle', () => {
+ const data: GaugeChartData = {
+ value: 50,
+ min: 0,
+ max: 100,
+ };
+
+ const { result } = renderHook(() =>
+ useGaugeChartData({
+ data,
+ colorRegistry: mockColorRegistry,
+ id: 'test-gauge',
+ instanceId: 'instance-1',
+ isHovered: false,
+ }),
+ );
+
+ expect(result.current.defs[0].id).toBe(
+ 'gaugeGradient-test-gauge-instance-1',
+ );
+ const expectedAngle = -45;
+ const expectedRadians = (expectedAngle * Math.PI) / 180 + Math.PI / 2;
+ const expectedSin = Math.sin(expectedRadians);
+ const expectedCos = -Math.cos(expectedRadians);
+ const expectedX1 = 50 - expectedSin * 50;
+ const expectedY1 = 50 - expectedCos * 50;
+
+ const actualX1 = parseFloat(result.current.defs[0].x1);
+ const actualY1 = parseFloat(result.current.defs[0].y1);
+ expect(actualX1).toBeCloseTo(expectedX1, 5);
+ expect(actualY1).toBeCloseTo(expectedY1, 5);
+ });
+
+ it('should handle hover state', () => {
+ const data: GaugeChartData = {
+ value: 50,
+ min: 0,
+ max: 100,
+ };
+
+ const { result } = renderHook(() =>
+ useGaugeChartData({
+ data,
+ colorRegistry: mockColorRegistry,
+ id: 'test-gauge',
+ instanceId: 'instance-1',
+ isHovered: true,
+ }),
+ );
+
+ expect(result.current.defs[0].colors).toEqual([
+ { offset: 0, color: 'blue3' },
+ { offset: 100, color: 'blue4' },
+ ]);
+ });
+
+ it('should generate unique gradient id', () => {
+ const data: GaugeChartData = {
+ value: 50,
+ min: 0,
+ max: 100,
+ };
+
+ const { result } = renderHook(() =>
+ useGaugeChartData({
+ data,
+ colorRegistry: mockColorRegistry,
+ id: 'unique-id',
+ instanceId: 'unique-instance',
+ isHovered: false,
+ }),
+ );
+
+ expect(result.current.gradientId).toBe(
+ 'gaugeGradient-unique-id-unique-instance',
+ );
+ });
+
+ it('should memoize calculations', () => {
+ const data: GaugeChartData = {
+ value: 50,
+ min: 0,
+ max: 100,
+ };
+
+ const { result, rerender } = renderHook(
+ ({ isHovered }) =>
+ useGaugeChartData({
+ data,
+ colorRegistry: mockColorRegistry,
+ id: 'test-gauge',
+ instanceId: 'instance-1',
+ isHovered,
+ }),
+ { initialProps: { isHovered: false } },
+ );
+
+ const firstColorScheme = result.current.colorScheme;
+ const firstChartData = result.current.chartData;
+
+ rerender({ isHovered: false });
+
+ expect(result.current.colorScheme).toBe(firstColorScheme);
+ expect(result.current.chartData).toBe(firstChartData);
+ });
+});
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/useGaugeChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/useGaugeChartData.ts
new file mode 100644
index 00000000000..3e9ad6e03de
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/useGaugeChartData.ts
@@ -0,0 +1,68 @@
+import { type GaugeChartData } from '@/page-layout/widgets/graph/graphWidgetGaugeChart/types/GaugeChartData';
+import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry';
+import { createGradientDef } from '@/page-layout/widgets/graph/utils/createGradientDef';
+import { getColorScheme } from '@/page-layout/widgets/graph/utils/getColorScheme';
+import { useMemo } from 'react';
+
+type UseGaugeChartDataProps = {
+ data: GaugeChartData;
+ colorRegistry: GraphColorRegistry;
+ id: string;
+ instanceId: string;
+ isHovered: boolean;
+};
+
+export const useGaugeChartData = ({
+ data,
+ colorRegistry,
+ id,
+ instanceId,
+ isHovered,
+}: UseGaugeChartDataProps) => {
+ const { value, min, max, color = 'blue' } = data;
+
+ const colorScheme = useMemo(
+ () => getColorScheme(colorRegistry, color),
+ [colorRegistry, color],
+ );
+
+ const normalizedValue = useMemo(
+ () => (max === min ? 0 : ((value - min) / (max - min)) * 100),
+ [value, min, max],
+ );
+
+ const clampedNormalizedValue = useMemo(
+ () => Math.max(0, Math.min(100, normalizedValue)),
+ [normalizedValue],
+ );
+
+ const chartData = useMemo(
+ () => [
+ {
+ id: 'gauge',
+ data: [
+ { x: 'value', y: clampedNormalizedValue },
+ { x: 'empty', y: 100 - clampedNormalizedValue },
+ ],
+ },
+ ],
+ [clampedNormalizedValue],
+ );
+
+ const gradientId = `gaugeGradient-${id}-${instanceId}`;
+ const gaugeAngle = -90 + (clampedNormalizedValue / 100) * 90;
+
+ const defs = useMemo(
+ () => [createGradientDef(colorScheme, gradientId, isHovered, gaugeAngle)],
+ [colorScheme, gradientId, isHovered, gaugeAngle],
+ );
+
+ return {
+ colorScheme,
+ normalizedValue,
+ clampedNormalizedValue,
+ chartData,
+ gradientId,
+ defs,
+ };
+};
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/useGaugeChartHandlers.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/useGaugeChartHandlers.ts
new file mode 100644
index 00000000000..002b06ff5d5
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/useGaugeChartHandlers.ts
@@ -0,0 +1,26 @@
+import { type GaugeChartData } from '@/page-layout/widgets/graph/graphWidgetGaugeChart/types/GaugeChartData';
+import { useState } from 'react';
+import { isDefined } from 'twenty-shared/utils';
+
+type UseGaugeChartHandlersProps = {
+ data: GaugeChartData;
+};
+
+export const useGaugeChartHandlers = ({ data }: UseGaugeChartHandlersProps) => {
+ const [isHovered, setIsHovered] = useState(false);
+
+ const handleClick = () => {
+ if (isDefined(data.to)) {
+ window.location.href = data.to;
+ }
+ };
+
+ const hasClickableItems = isDefined(data.to);
+
+ return {
+ isHovered,
+ setIsHovered,
+ handleClick,
+ hasClickableItems,
+ };
+};
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/useGaugeChartTooltip.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/useGaugeChartTooltip.ts
new file mode 100644
index 00000000000..9b95ddb0499
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/useGaugeChartTooltip.ts
@@ -0,0 +1,45 @@
+import { type GraphColorScheme } from '@/page-layout/widgets/graph/types/GraphColorScheme';
+import {
+ formatGraphValue,
+ type GraphValueFormatOptions,
+} from '@/page-layout/widgets/graph/utils/graphFormatters';
+import { isDefined } from 'twenty-shared/utils';
+
+type UseGaugeChartTooltipProps = {
+ value: number;
+ normalizedValue: number;
+ label: string;
+ colorScheme: GraphColorScheme;
+ formatOptions: GraphValueFormatOptions;
+ to?: string;
+};
+
+export const useGaugeChartTooltip = ({
+ value,
+ normalizedValue,
+ label,
+ colorScheme,
+ formatOptions,
+ to,
+}: UseGaugeChartTooltipProps) => {
+ const createTooltipData = () => {
+ // Format value based on display type to avoid redundant percentage display
+ const formattedValue =
+ formatOptions?.displayType === 'percentage'
+ ? formatGraphValue(normalizedValue / 100, formatOptions)
+ : `${formatGraphValue(value, formatOptions)} (${normalizedValue.toFixed(1)}%)`;
+
+ return {
+ tooltipItem: {
+ label: label,
+ formattedValue,
+ dotColor: colorScheme.solid,
+ },
+ showClickHint: isDefined(to),
+ };
+ };
+
+ return {
+ createTooltipData,
+ };
+};
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/types/GaugeChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/types/GaugeChartData.ts
new file mode 100644
index 00000000000..f6462f3c703
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/types/GaugeChartData.ts
@@ -0,0 +1,10 @@
+import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor';
+
+export type GaugeChartData = {
+ value: number;
+ min: number;
+ max: number;
+ color?: GraphColor;
+ to?: string;
+ label?: string;
+};
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/utils/__tests__/calculateGaugeChartEndLineCoordinates.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/utils/__tests__/calculateGaugeChartEndLineCoordinates.test.ts
new file mode 100644
index 00000000000..b234bc21374
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/utils/__tests__/calculateGaugeChartEndLineCoordinates.test.ts
@@ -0,0 +1,168 @@
+import { calculateGaugeChartEndLineCoordinates } from '../calculateGaugeChartEndLineCoordinates';
+
+describe('calculateGaugeChartEndLineCoordinates', () => {
+ it('should calculate coordinates for angle π/2 (after adjustment points right)', () => {
+ const result = calculateGaugeChartEndLineCoordinates(
+ Math.PI / 2,
+ 100,
+ 100,
+ 50,
+ 80,
+ );
+ expect(result).toEqual({
+ x1: 150,
+ y1: 100,
+ x2: 180,
+ y2: 100,
+ });
+ });
+
+ it('should calculate coordinates for angle π (after adjustment points down)', () => {
+ const result = calculateGaugeChartEndLineCoordinates(
+ Math.PI,
+ 100,
+ 100,
+ 50,
+ 80,
+ );
+ expect(result).toEqual({
+ x1: 100,
+ y1: 150,
+ x2: 100,
+ y2: 180,
+ });
+ });
+
+ it('should calculate coordinates for angle 3π/2 (after adjustment points left)', () => {
+ const result = calculateGaugeChartEndLineCoordinates(
+ (3 * Math.PI) / 2,
+ 100,
+ 100,
+ 50,
+ 80,
+ );
+ expect(result.x1).toBeCloseTo(50, 5);
+ expect(result.y1).toBeCloseTo(100, 5);
+ expect(result.x2).toBeCloseTo(20, 5);
+ expect(result.y2).toBeCloseTo(100, 5);
+ });
+
+ it('should calculate coordinates for angle 0 (after adjustment points up)', () => {
+ const result = calculateGaugeChartEndLineCoordinates(0, 100, 100, 50, 80);
+ expect(result.x1).toBeCloseTo(100, 5);
+ expect(result.y1).toBeCloseTo(50, 5);
+ expect(result.x2).toBeCloseTo(100, 5);
+ expect(result.y2).toBeCloseTo(20, 5);
+ });
+
+ it('should calculate coordinates for 45-degree angle', () => {
+ const angle = Math.PI / 4;
+ const result = calculateGaugeChartEndLineCoordinates(
+ angle,
+ 100,
+ 100,
+ 50,
+ 80,
+ );
+ const adjustedAngle = angle - Math.PI / 2;
+ const expectedCos = Math.cos(adjustedAngle);
+ const expectedSin = Math.sin(adjustedAngle);
+ expect(result.x1).toBeCloseTo(100 + expectedCos * 50, 5);
+ expect(result.y1).toBeCloseTo(100 + expectedSin * 50, 5);
+ expect(result.x2).toBeCloseTo(100 + expectedCos * 80, 5);
+ expect(result.y2).toBeCloseTo(100 + expectedSin * 80, 5);
+ });
+
+ it('should handle different center positions', () => {
+ const result = calculateGaugeChartEndLineCoordinates(
+ Math.PI,
+ 200,
+ 150,
+ 30,
+ 60,
+ );
+ expect(result).toEqual({
+ x1: 200,
+ y1: 180,
+ x2: 200,
+ y2: 210,
+ });
+ });
+
+ it('should handle zero radius', () => {
+ const result = calculateGaugeChartEndLineCoordinates(
+ Math.PI / 4,
+ 100,
+ 100,
+ 0,
+ 0,
+ );
+ expect(result).toEqual({
+ x1: 100,
+ y1: 100,
+ x2: 100,
+ y2: 100,
+ });
+ });
+
+ it('should handle negative angles', () => {
+ const result = calculateGaugeChartEndLineCoordinates(
+ -Math.PI / 2,
+ 100,
+ 100,
+ 50,
+ 80,
+ );
+ expect(result.x1).toBeCloseTo(50, 5);
+ expect(result.y1).toBeCloseTo(100, 5);
+ expect(result.x2).toBeCloseTo(20, 5);
+ expect(result.y2).toBeCloseTo(100, 5);
+ });
+
+ it('should create a line from inner to outer radius', () => {
+ const angle = Math.PI / 6;
+ const centerX = 100;
+ const centerY = 100;
+ const innerRadius = 40;
+ const outerRadius = 70;
+ const result = calculateGaugeChartEndLineCoordinates(
+ angle,
+ centerX,
+ centerY,
+ innerRadius,
+ outerRadius,
+ );
+ const dist1 = Math.sqrt(
+ (result.x1 - centerX) ** 2 + (result.y1 - centerY) ** 2,
+ );
+ const dist2 = Math.sqrt(
+ (result.x2 - centerX) ** 2 + (result.y2 - centerY) ** 2,
+ );
+ expect(dist1).toBeCloseTo(innerRadius, 5);
+ expect(dist2).toBeCloseTo(outerRadius, 5);
+ const angle1 = Math.atan2(result.y1 - centerY, result.x1 - centerX);
+ const angle2 = Math.atan2(result.y2 - centerY, result.x2 - centerX);
+ expect(angle1).toBeCloseTo(angle2, 5);
+ });
+
+ it('should handle angles greater than 2π', () => {
+ const result = calculateGaugeChartEndLineCoordinates(
+ 2.5 * Math.PI,
+ 100,
+ 100,
+ 50,
+ 80,
+ );
+ const equivalentResult = calculateGaugeChartEndLineCoordinates(
+ 0.5 * Math.PI,
+ 100,
+ 100,
+ 50,
+ 80,
+ );
+ expect(result.x1).toBeCloseTo(equivalentResult.x1, 10);
+ expect(result.y1).toBeCloseTo(equivalentResult.y1, 10);
+ expect(result.x2).toBeCloseTo(equivalentResult.x2, 10);
+ expect(result.y2).toBeCloseTo(equivalentResult.y2, 10);
+ });
+});
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/utils/calculateGaugeChartEndLineCoordinates.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/utils/calculateGaugeChartEndLineCoordinates.ts
new file mode 100644
index 00000000000..1f7066d8880
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/utils/calculateGaugeChartEndLineCoordinates.ts
@@ -0,0 +1,16 @@
+export const calculateGaugeChartEndLineCoordinates = (
+ endAngle: number,
+ centerX: number,
+ centerY: number,
+ innerRadius: number,
+ outerRadius: number,
+) => {
+ const adjustedAngle = endAngle - Math.PI / 2;
+
+ const x1 = centerX + Math.cos(adjustedAngle) * innerRadius;
+ const y1 = centerY + Math.sin(adjustedAngle) * innerRadius;
+ const x2 = centerX + Math.cos(adjustedAngle) * outerRadius;
+ const y2 = centerY + Math.sin(adjustedAngle) * outerRadius;
+
+ return { x1, y1, x2, y2 };
+};
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetNumberChart.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetNumberChart/components/GraphWidgetNumberChart.tsx
similarity index 82%
rename from packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetNumberChart.tsx
rename to packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetNumberChart/components/GraphWidgetNumberChart.tsx
index d977b5cd5b0..cf815ff0b16 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetNumberChart.tsx
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetNumberChart/components/GraphWidgetNumberChart.tsx
@@ -1,3 +1,4 @@
+import { formatNumberChartTrend } from '@/page-layout/widgets/graph/graphWidgetNumberChart/utils/formatNumberChartTrend';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import {
@@ -7,7 +8,6 @@ import {
IconTrendingUp,
} from 'twenty-ui/display';
-// props are subjected to change
type GraphWidgetNumberChartProps = {
value: string;
trendPercentage: number;
@@ -43,8 +43,8 @@ export const GraphWidgetNumberChart = ({
trendPercentage,
}: GraphWidgetNumberChartProps) => {
const theme = useTheme();
- const formattedPercentage =
- trendPercentage >= 0 ? `+${trendPercentage}` : `${trendPercentage}`;
+ const formattedPercentage = formatNumberChartTrend(trendPercentage);
+
return (
@@ -58,8 +58,10 @@ export const GraphWidgetNumberChart = ({
size={theme.icon.size.md}
/>
) : (
- // question for product - whats the exact red here? cant see it on figma
-
+
)}
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetNumberChart/utils/formatNumberChartTrend.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetNumberChart/utils/formatNumberChartTrend.ts
new file mode 100644
index 00000000000..d7b432561fb
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetNumberChart/utils/formatNumberChartTrend.ts
@@ -0,0 +1,3 @@
+export const formatNumberChartTrend = (trendPercentage: number): string => {
+ return trendPercentage >= 0 ? `+${trendPercentage}` : `${trendPercentage}`;
+};