[Dashboards] Graph pie chart component + refactor (#14045)

closes https://github.com/twentyhq/core-team-issues/issues/1373
closes https://github.com/twentyhq/core-team-issues/issues/1368

figma
https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=72808-199932&t=c7GEJKFZKI0JhRv0-0

TODO: 
- ~~refactor to have a color registry~~
- ~~refactor legend and tooltips into a separate component for reuse
across the graphs~~
- also refactor the border in between to be a common logic between pie
and gauge graphs - ***open question*** -
How should we code this border in between (renderValueEndLine), which is
a layer - it's a function that returns JSX
I could not figure out if it should be a util/component/hook? None of
them fit into the category -- hence kept them inline in both gauge and
pie charts for simplicity
This commit is contained in:
nitin 2025-08-26 16:53:15 +05:30 committed by GitHub
parent 8cb4058d2d
commit 50448c9b16
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1256 additions and 334 deletions

View file

@ -47,6 +47,7 @@
"@lingui/react": "^5.1.2",
"@nivo/core": "^0.99.0",
"@nivo/line": "^0.99.0",
"@nivo/pie": "^0.99.0",
"@nivo/radial-bar": "^0.99.0",
"@react-pdf/renderer": "^4.1.6",
"@scalar/api-reference-react": "^0.4.36",

View file

@ -1,26 +1,33 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { ResponsiveRadialBar } from '@nivo/radial-bar';
import { useState } from 'react';
import {
AppTooltip,
H1Title,
H1TitleFontColor,
IconArrowUpRight,
TooltipDelay,
} from 'twenty-ui/display';
type RadialBarCustomLayerProps,
ResponsiveRadialBar,
} from '@nivo/radial-bar';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { H1Title, H1TitleFontColor } from 'twenty-ui/display';
import { createGradientDef } from '../utils/createGradientDef';
import { createGraphColorRegistry } from '../utils/createGraphColorRegistry';
import { getColorSchemeByName } from '../utils/getColorSchemeByName';
import {
formatGraphValue,
type GraphValueFormatOptions,
} from '../utils/graphFormatters';
import { GraphWidgetLegend } from './GraphWidgetLegend';
import { GraphWidgetTooltip } from './GraphWidgetTooltip';
type GraphWidgetGaugeChartProps = {
value: number;
min: number;
max: number;
unit: string;
showValue?: boolean;
showLegend?: boolean;
legendLabel: string;
tooltipHref: string;
tooltipHref?: string;
id: string;
};
} & GraphValueFormatOptions;
const StyledContainer = styled.div`
align-items: center;
@ -44,88 +51,37 @@ const StyledH1Title = styled(H1Title)`
transform: translate(-50%, -150%);
`;
const StyledLegendContainer = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(3)};
justify-content: center;
`;
const StyledLegendItem = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
`;
const StyledLegendLabel = styled.span`
color: ${({ theme }) => theme.font.color.light};
`;
const StyledLegendValue = styled.span`
color: ${({ theme }) => theme.font.color.primary};
`;
const StyledTooltipContent = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledTooltipRow = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.extraLight};
display: flex;
font-size: ${({ theme }) => theme.font.size.xs};
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledDot = styled.div`
background: ${({ theme }) => theme.color.blue};
border-radius: 50%;
height: 6px;
width: 6px;
flex-shrink: 0;
`;
const StyledTooltipValue = styled.span`
margin-left: auto;
white-space: nowrap;
`;
const StyledTooltipLink = styled.a`
display: flex;
align-items: center;
color: ${({ theme }) => theme.font.color.light};
cursor: pointer;
text-decoration: none;
&:hover {
color: ${({ theme }) => theme.font.color.secondary};
}
`;
export const GraphWidgetGaugeChart = ({
value,
min,
max,
unit,
showValue = true,
showLegend = true,
legendLabel,
tooltipHref,
id,
displayType,
decimals,
prefix,
suffix,
customFormatter,
}: GraphWidgetGaugeChartProps) => {
const theme = useTheme();
const [isHovered, setIsHovered] = useState(false);
const formatValue = (val: number): string => {
if (val % 1 !== 0) {
return val.toFixed(1);
}
return val.toString();
const colorRegistry = createGraphColorRegistry(theme);
const colorScheme =
getColorSchemeByName(colorRegistry, 'blue') || colorRegistry.blue;
const formatOptions: GraphValueFormatOptions = {
displayType,
decimals,
prefix,
suffix,
customFormatter,
};
const displayValue = formatValue(value);
const formattedValue = formatGraphValue(value, formatOptions);
const normalizedValue = max === min ? 0 : ((value - min) / (max - min)) * 100;
const clampedNormalizedValue = Math.max(0, Math.min(100, normalizedValue));
@ -140,99 +96,125 @@ export const GraphWidgetGaugeChart = ({
},
];
const gradientColors = isHovered
? {
start: theme.adaptiveColors.blue4,
end: theme.adaptiveColors.blue3,
}
: {
start: theme.adaptiveColors.blue2,
end: theme.adaptiveColors.blue1,
};
const gradientId = `gaugeGradient-${id}`;
const defs = [
{
id: gradientId,
type: 'linearGradient',
colors: [
{ offset: 0, color: gradientColors.start },
{ offset: 100, color: gradientColors.end },
],
},
];
const tooltipContent = (
<StyledTooltipContent>
<StyledTooltipRow>
<StyledDot />
<span>{legendLabel}</span>
<StyledTooltipValue>{`${displayValue}${unit}`}</StyledTooltipValue>
</StyledTooltipRow>
<StyledTooltipLink href={tooltipHref}>
<span>{t`Click to see data`}</span>
<IconArrowUpRight size={theme.icon.size.sm} />
</StyledTooltipLink>
</StyledTooltipContent>
const gaugeAngle = -90 + (clampedNormalizedValue / 100) * 90;
const gradientDef = createGradientDef(
colorScheme,
gradientId,
isHovered,
gaugeAngle,
);
const defs = [gradientDef];
const handleClick = () => {
if (isDefined(tooltipHref)) {
window.location.href = tooltipHref;
}
};
const renderTooltip = () => {
const formattedWithPercentage = `${formattedValue} (${normalizedValue.toFixed(1)}%)`;
return (
<GraphWidgetTooltip
items={[
{
label: legendLabel,
formattedValue: formattedWithPercentage,
dotColor: colorScheme.solid,
},
]}
showClickHint={isDefined(tooltipHref)}
/>
);
};
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 (
<g>
<line
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke={colorScheme.solid}
strokeWidth={1}
/>
</g>
);
};
return (
<>
<StyledContainer
id={id}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<StyledChartContainer>
<ResponsiveRadialBar
data={data}
startAngle={-90}
endAngle={90}
innerRadius={0.7}
padding={0.2}
colors={[`url(#${gradientId})`, theme.background.tertiary]}
defs={defs}
fill={[
{
match: (d: { x: string }) => d.x === 'value',
id: gradientId,
},
]}
enableTracks={false}
enableRadialGrid={false}
enableCircularGrid={false}
enableLabels={false}
isInteractive={false}
radialAxisStart={null}
radialAxisEnd={null}
circularAxisInner={null}
circularAxisOuter={null}
<StyledContainer>
<StyledChartContainer>
<ResponsiveRadialBar
data={data}
startAngle={-90}
endAngle={90}
innerRadius={0.7}
padding={0.2}
colors={[`url(#${gradientId})`, theme.background.tertiary]}
defs={defs}
fill={[
{
match: (d: { x: string }) => 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)}
radialAxisStart={null}
radialAxisEnd={null}
circularAxisInner={null}
circularAxisOuter={null}
layers={['bars', renderValueEndLine]}
/>
{showValue && (
<StyledH1Title
title={formattedValue}
fontColor={H1TitleFontColor.Primary}
/>
{showValue && (
<StyledH1Title
title={`${displayValue}${unit}`}
fontColor={H1TitleFontColor.Primary}
/>
)}
</StyledChartContainer>
<StyledLegendContainer>
<StyledLegendItem>
<StyledDot />
<StyledLegendLabel>{legendLabel}</StyledLegendLabel>
<StyledLegendValue>{`${displayValue}${unit}`}</StyledLegendValue>
</StyledLegendItem>
</StyledLegendContainer>
</StyledContainer>
<AppTooltip
anchorSelect={`#${id}`}
place="top-start"
noArrow
delay={TooltipDelay.noDelay}
clickable
>
{tooltipContent}
</AppTooltip>
</>
)}
</StyledChartContainer>
<GraphWidgetLegend
show={showLegend}
items={[
{
id: 'gauge',
label: legendLabel,
formattedValue: formattedValue,
color: colorScheme.solid,
},
]}
/>
</StyledContainer>
);
};

View file

@ -0,0 +1,66 @@
import styled from '@emotion/styled';
export type GraphWidgetLegendItem = {
id: string;
label: string;
formattedValue: string;
color: string;
};
type GraphWidgetLegendProps = {
items: GraphWidgetLegendItem[];
show?: boolean;
};
const StyledLegendContainer = styled.div`
display: flex;
flex-wrap: wrap;
gap: ${({ theme }) => theme.spacing(3)};
justify-content: center;
padding: ${({ theme }) => theme.spacing(2)} 0;
`;
const StyledLegendItem = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
`;
const StyledLegendLabel = styled.span`
color: ${({ theme }) => theme.font.color.light};
`;
const StyledLegendValue = styled.span`
color: ${({ theme }) => theme.font.color.primary};
`;
const StyledDot = styled.div<{ color: string }>`
background: ${({ color }) => color};
border-radius: 50%;
height: 6px;
width: 6px;
flex-shrink: 0;
`;
export const GraphWidgetLegend = ({
items,
show = true,
}: GraphWidgetLegendProps) => {
if (!show || items.length === 0) {
return null;
}
return (
<StyledLegendContainer>
{items.map((item) => (
<StyledLegendItem key={item.id}>
<StyledDot color={item.color} />
<StyledLegendLabel>{item.label}</StyledLegendLabel>
<StyledLegendValue>{item.formattedValue}</StyledLegendValue>
</StyledLegendItem>
))}
</StyledLegendContainer>
);
};

View file

@ -0,0 +1,207 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import {
ResponsivePie,
type ComputedDatum,
type DatumId,
type PieCustomLayerProps,
} from '@nivo/pie';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { createGradientDef } from '../utils/createGradientDef';
import { createGraphColorRegistry } from '../utils/createGraphColorRegistry';
import { getColorSchemeByIndex } from '../utils/getColorSchemeByIndex';
import {
formatGraphValue,
type GraphValueFormatOptions,
} from '../utils/graphFormatters';
import { GraphWidgetLegend } from './GraphWidgetLegend';
import { GraphWidgetTooltip } from './GraphWidgetTooltip';
type GraphWidgetPieChartProps = {
data: Array<{ id: string; value: number; label?: string }>;
showLegend?: boolean;
tooltipHref?: string;
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`
flex: 1;
position: relative;
width: 100%;
`;
export const GraphWidgetPieChart = ({
data,
showLegend = true,
tooltipHref,
id,
displayType,
decimals,
prefix,
suffix,
customFormatter,
}: GraphWidgetPieChartProps) => {
const theme = useTheme();
const [hoveredSliceId, setHoveredSliceId] = useState<DatumId | null>(null);
const colorRegistry = createGraphColorRegistry(theme);
const formatOptions: GraphValueFormatOptions = {
displayType,
decimals,
prefix,
suffix,
customFormatter,
};
const totalValue = data.reduce((sum, item) => sum + item.value, 0);
let cumulativeAngle = 0;
const enrichedData = data.map((item, index) => {
const colorScheme = getColorSchemeByIndex(colorRegistry, index);
const isHovered = hoveredSliceId === item.id;
const gradientId = `${colorScheme.name}Gradient-${id}-${index}`;
const percentage = totalValue > 0 ? (item.value / totalValue) * 100 : 0;
const sliceAngle = (percentage / 100) * 360;
const middleAngle = cumulativeAngle + sliceAngle / 2;
cumulativeAngle += sliceAngle;
return {
...item,
gradientId,
colorScheme,
isHovered,
percentage,
middleAngle,
};
});
const defs = enrichedData.map((item) =>
createGradientDef(
item.colorScheme,
item.gradientId,
item.isHovered,
item.middleAngle,
),
);
const fill = enrichedData.map((item) => ({
match: { id: item.id },
id: item.gradientId,
}));
const handleSliceClick = () => {
if (isDefined(tooltipHref)) {
window.location.href = tooltipHref;
}
};
const renderTooltip = (
datum: ComputedDatum<{ id: string; value: number; label?: string }>,
) => {
const item = enrichedData.find((d) => d.id === datum.id);
if (!item) return null;
const formattedValue = formatGraphValue(item.value, formatOptions);
const formattedWithPercentage = `${formattedValue} (${item.percentage.toFixed(1)}%)`;
return (
<GraphWidgetTooltip
items={[
{
label: item.label || item.id,
formattedValue: formattedWithPercentage,
dotColor: item.colorScheme.solid,
},
]}
showClickHint={isDefined(tooltipHref)}
/>
);
};
const renderSliceEndLines = (
layerProps: PieCustomLayerProps<{
id: string;
value: number;
label?: string;
}>,
) => {
const { dataWithArc, centerX, centerY, innerRadius, radius } = layerProps;
if (!dataWithArc || !Array.isArray(dataWithArc) || dataWithArc.length < 2) {
return null;
}
return (
<g>
{dataWithArc.map((datum) => {
const enrichedItem = enrichedData.find((d) => d.id === datum.id);
const lineColor = enrichedItem
? enrichedItem.colorScheme.solid
: theme.border.color.strong;
const angle = datum.arc.endAngle - Math.PI / 2;
const x1 = centerX + Math.cos(angle) * innerRadius;
const y1 = centerY + Math.sin(angle) * innerRadius;
const x2 = centerX + Math.cos(angle) * radius;
const y2 = centerY + Math.sin(angle) * radius;
return (
<line
key={`${datum.id}-separator`}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke={lineColor}
strokeWidth={1}
/>
);
})}
</g>
);
};
return (
<StyledContainer id={id}>
<StyledChartContainer>
<ResponsivePie
data={data}
innerRadius={0.8}
colors={enrichedData.map((item) => `url(#${item.gradientId})`)}
borderWidth={0}
enableArcLinkLabels={false}
enableArcLabels={false}
tooltip={({ datum }) => renderTooltip(datum)}
onClick={handleSliceClick}
onMouseEnter={(datum) => setHoveredSliceId(datum.id)}
onMouseLeave={() => setHoveredSliceId(null)}
defs={defs}
fill={fill}
layers={['arcs', renderSliceEndLines]}
/>
</StyledChartContainer>
<GraphWidgetLegend
show={showLegend}
items={enrichedData.map((item) => ({
id: item.id,
label: item.label || item.id,
formattedValue: formatGraphValue(item.value, formatOptions),
color: item.colorScheme.solid,
}))}
/>
</StyledContainer>
);
};

View file

@ -0,0 +1,80 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { IconArrowUpRight } from 'twenty-ui/display';
const StyledTooltipContent = styled.div`
background: ${({ theme }) => theme.background.primary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
box-shadow: ${({ theme }) => theme.boxShadow.strong};
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(2)};
pointer-events: none;
`;
const StyledTooltipRow = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.extraLight};
display: flex;
font-size: ${({ theme }) => theme.font.size.xs};
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledDot = styled.div<{ $color: string }>`
background: ${({ $color }) => $color};
border-radius: 50%;
height: 6px;
width: 6px;
flex-shrink: 0;
`;
const StyledTooltipValue = styled.span`
margin-left: auto;
white-space: nowrap;
`;
const StyledTooltipLink = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.light};
cursor: default;
display: flex;
`;
export type GraphWidgetTooltipItem = {
label: string;
formattedValue: string;
dotColor: string;
};
type GraphWidgetTooltipProps = {
items: GraphWidgetTooltipItem[];
showClickHint?: boolean;
};
export const GraphWidgetTooltip = ({
items,
showClickHint = false,
}: GraphWidgetTooltipProps) => {
const theme = useTheme();
return (
<StyledTooltipContent>
{items.map((item, index) => (
<StyledTooltipRow key={index}>
<StyledDot $color={item.dotColor} />
<span>{item.label}</span>
<StyledTooltipValue>{item.formattedValue}</StyledTooltipValue>
</StyledTooltipRow>
))}
{showClickHint && (
<StyledTooltipLink>
<span>{t`Click to see data`}</span>
<IconArrowUpRight size={theme.icon.size.sm} />
</StyledTooltipLink>
)}
</StyledTooltipContent>
);
};

View file

@ -20,12 +20,25 @@ const meta: Meta<typeof GraphWidgetGaugeChart> = {
max: {
control: { type: 'number' },
},
unit: {
displayType: {
control: 'select',
options: ['percentage', 'number', 'shortNumber', 'currency', 'custom'],
},
prefix: {
control: 'text',
},
suffix: {
control: 'text',
},
decimals: {
control: 'number',
},
showValue: {
control: 'boolean',
},
showLegend: {
control: 'boolean',
},
legendLabel: {
control: 'text',
},
@ -47,10 +60,10 @@ const Container = ({ children }: { children: React.ReactNode }) => (
export const Default: Story = {
args: {
value: 50,
value: 0.5,
min: 0,
max: 100,
unit: '%',
max: 1,
displayType: 'percentage',
showValue: true,
legendLabel: 'Conversion rate',
tooltipHref: 'https://example.com',
@ -62,8 +75,12 @@ export const Default: Story = {
value={args.value}
min={args.min}
max={args.max}
unit={args.unit}
displayType={args.displayType}
decimals={args.decimals}
prefix={args.prefix}
suffix={args.suffix}
showValue={args.showValue}
showLegend={args.showLegend}
legendLabel={args.legendLabel}
tooltipHref={args.tooltipHref}
id={args.id}
@ -81,10 +98,10 @@ export const Catalog: Story = {
name: 'value',
values: [0, 25, 50, 75, 100],
props: (value: number) => ({
value,
value: value / 100,
min: 0,
max: 100,
unit: '%',
max: 1,
displayType: 'percentage' as const,
id: `gauge-chart-catalog-${value}`,
}),
labels: (value: number) => {
@ -107,11 +124,11 @@ export const Catalog: Story = {
value={args.value}
min={args.min}
max={args.max}
unit={args.unit}
displayType={args.displayType}
showValue={true}
legendLabel="Percentage"
tooltipHref="https://example.com"
id={args.id}
id="gauge-chart-catalog"
/>
</Container>
),
@ -122,7 +139,7 @@ export const WithoutValue: Story = {
value: 65,
min: 0,
max: 100,
unit: '%',
displayType: 'percentage',
showValue: false,
legendLabel: 'Conversion rate',
tooltipHref: 'https://example.com',
@ -134,8 +151,12 @@ export const WithoutValue: Story = {
value={args.value}
min={args.min}
max={args.max}
unit={args.unit}
displayType={args.displayType}
decimals={args.decimals}
prefix={args.prefix}
suffix={args.suffix}
showValue={args.showValue}
showLegend={args.showLegend}
legendLabel={args.legendLabel}
tooltipHref={args.tooltipHref}
id={args.id}
@ -149,7 +170,7 @@ export const Revenue: Story = {
value: 750,
min: 0,
max: 1000,
unit: 'K',
displayType: 'shortNumber',
showValue: true,
legendLabel: 'Revenue',
tooltipHref: 'https://example.com',
@ -161,8 +182,12 @@ export const Revenue: Story = {
value={args.value}
min={args.min}
max={args.max}
unit={args.unit}
displayType={args.displayType}
decimals={args.decimals}
prefix={args.prefix}
suffix={args.suffix}
showValue={args.showValue}
showLegend={args.showLegend}
legendLabel={args.legendLabel}
tooltipHref={args.tooltipHref}
id={args.id}
@ -176,7 +201,7 @@ export const Temperature: Story = {
value: 22,
min: -10,
max: 40,
unit: '°C',
suffix: '°C',
showValue: true,
legendLabel: 'Temperature',
tooltipHref: 'https://example.com',
@ -188,8 +213,12 @@ export const Temperature: Story = {
value={args.value}
min={args.min}
max={args.max}
unit={args.unit}
displayType={args.displayType}
decimals={args.decimals}
prefix={args.prefix}
suffix={args.suffix}
showValue={args.showValue}
showLegend={args.showLegend}
legendLabel={args.legendLabel}
tooltipHref={args.tooltipHref}
id={args.id}
@ -203,7 +232,7 @@ export const Storage: Story = {
value: 384,
min: 0,
max: 512,
unit: 'GB',
suffix: ' GB',
showValue: true,
legendLabel: 'Storage Used',
tooltipHref: 'https://example.com',
@ -215,8 +244,12 @@ export const Storage: Story = {
value={args.value}
min={args.min}
max={args.max}
unit={args.unit}
displayType={args.displayType}
decimals={args.decimals}
prefix={args.prefix}
suffix={args.suffix}
showValue={args.showValue}
showLegend={args.showLegend}
legendLabel={args.legendLabel}
tooltipHref={args.tooltipHref}
id={args.id}
@ -230,7 +263,8 @@ export const Rating: Story = {
value: 4.2,
min: 0,
max: 5,
unit: ' ⭐',
suffix: ' ⭐',
decimals: 1,
showValue: true,
legendLabel: 'Average Rating',
tooltipHref: 'https://example.com',
@ -242,8 +276,43 @@ export const Rating: Story = {
value={args.value}
min={args.min}
max={args.max}
unit={args.unit}
displayType={args.displayType}
decimals={args.decimals}
prefix={args.prefix}
suffix={args.suffix}
showValue={args.showValue}
showLegend={args.showLegend}
legendLabel={args.legendLabel}
tooltipHref={args.tooltipHref}
id={args.id}
/>
</Container>
),
};
export const WithoutLegend: Story = {
args: {
value: 65,
min: 0,
max: 100,
displayType: 'percentage',
showValue: true,
legendLabel: 'Conversion rate',
tooltipHref: 'https://example.com',
id: 'gauge-chart-without-legend',
},
render: (args) => (
<Container>
<GraphWidgetGaugeChart
value={args.value}
min={args.min}
max={args.max}
displayType={args.displayType}
decimals={args.decimals}
prefix={args.prefix}
suffix={args.suffix}
showValue={args.showValue}
showLegend={false}
legendLabel={args.legendLabel}
tooltipHref={args.tooltipHref}
id={args.id}

View file

@ -0,0 +1,64 @@
import { type Meta, type StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui/testing';
import { GraphWidgetLegend } from '../GraphWidgetLegend';
const meta: Meta<typeof GraphWidgetLegend> = {
title: 'Modules/Dashboards/Graphs/GraphWidgetLegend',
component: GraphWidgetLegend,
decorators: [ComponentDecorator],
parameters: {
layout: 'centered',
},
};
export default meta;
type Story = StoryObj<typeof GraphWidgetLegend>;
export const Default: Story = {
render: () => {
return (
<GraphWidgetLegend
show={true}
items={[
{
id: 'sales',
label: 'Sales',
formattedValue: '$45,231',
color: 'blue',
},
{
id: 'marketing',
label: 'Marketing',
formattedValue: '$12,543',
color: 'green',
},
{
id: 'operations',
label: 'Operations',
formattedValue: '$8,765',
color: 'red',
},
]}
/>
);
},
};
export const SingleItem: Story = {
render: () => {
return (
<GraphWidgetLegend
show={true}
items={[
{
id: 'revenue',
label: 'Revenue',
formattedValue: '750',
color: 'blue',
},
]}
/>
);
},
};

View file

@ -0,0 +1,332 @@
import { type Meta, type StoryObj } from '@storybook/react';
import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing';
import { GraphWidgetPieChart } from '../GraphWidgetPieChart';
const meta: Meta<typeof GraphWidgetPieChart> = {
title: 'Modules/Dashboards/Graphs/GraphWidgetPieChart',
component: GraphWidgetPieChart,
decorators: [ComponentDecorator],
parameters: {
layout: 'centered',
},
argTypes: {
data: {
control: 'object',
},
displayType: {
control: 'select',
options: ['percentage', 'number', 'shortNumber', 'currency', 'custom'],
},
prefix: {
control: 'text',
},
suffix: {
control: 'text',
},
decimals: {
control: 'number',
},
showLegend: {
control: 'boolean',
},
tooltipHref: {
control: 'text',
},
id: {
control: 'text',
},
},
};
export default meta;
type Story = StoryObj<typeof GraphWidgetPieChart>;
const Container = ({ children }: { children: React.ReactNode }) => (
<div style={{ width: '300px', height: '300px' }}>{children}</div>
);
export const Default: Story = {
args: {
data: [
{ id: 'qualified', value: 35, label: 'Qualified' },
{ id: 'contacted', value: 25, label: 'Contacted' },
{ id: 'unqualified', value: 20, label: 'Unqualified' },
{ id: 'proposal', value: 15, label: 'Proposal' },
{ id: 'negotiation', value: 5, label: 'Negotiation' },
],
showLegend: true,
tooltipHref: 'https://example.com/leads',
id: 'pie-chart-default',
},
render: (args) => (
<Container>
<GraphWidgetPieChart
data={args.data}
displayType={args.displayType}
prefix={args.prefix}
suffix={args.suffix}
decimals={args.decimals}
showLegend={args.showLegend}
tooltipHref={args.tooltipHref}
id={args.id}
/>
</Container>
),
};
export const Revenue: Story = {
args: {
data: [
{ id: 'product-a', value: 420000, label: 'Product A' },
{ id: 'product-b', value: 380000, label: 'Product B' },
{ id: 'product-c', value: 250000, label: 'Product C' },
{ id: 'product-d', value: 180000, label: 'Product D' },
],
prefix: '$',
displayType: 'shortNumber',
showLegend: true,
tooltipHref: 'https://example.com/revenue',
id: 'pie-chart-revenue',
},
render: (args) => (
<Container>
<GraphWidgetPieChart
data={args.data}
displayType={args.displayType}
prefix={args.prefix}
suffix={args.suffix}
decimals={args.decimals}
showLegend={args.showLegend}
tooltipHref={args.tooltipHref}
id={args.id}
/>
</Container>
),
};
export const TaskStatus: Story = {
args: {
data: [
{ id: 'completed', value: 45, label: 'Completed' },
{ id: 'in-progress', value: 30, label: 'In Progress' },
{ id: 'todo', value: 25, label: 'To Do' },
],
displayType: 'percentage',
showLegend: true,
tooltipHref: 'https://example.com/tasks',
id: 'pie-chart-task-status',
},
render: (args) => (
<Container>
<GraphWidgetPieChart
data={args.data}
displayType={args.displayType}
prefix={args.prefix}
suffix={args.suffix}
decimals={args.decimals}
showLegend={args.showLegend}
tooltipHref={args.tooltipHref}
id={args.id}
/>
</Container>
),
};
export const TwoSlices: Story = {
args: {
data: [
{ id: 'active', value: 75, label: 'Active' },
{ id: 'inactive', value: 25, label: 'Inactive' },
],
displayType: 'percentage',
showLegend: true,
tooltipHref: 'https://example.com/status',
id: 'pie-chart-two-slices',
},
render: (args) => (
<Container>
<GraphWidgetPieChart
data={args.data}
displayType={args.displayType}
prefix={args.prefix}
suffix={args.suffix}
decimals={args.decimals}
showLegend={args.showLegend}
tooltipHref={args.tooltipHref}
id={args.id}
/>
</Container>
),
};
export const ManySlices: Story = {
args: {
data: [
{ id: 'category-1', value: 20, label: 'Category 1' },
{ id: 'category-2', value: 18, label: 'Category 2' },
{ id: 'category-3', value: 16, label: 'Category 3' },
{ id: 'category-4', value: 14, label: 'Category 4' },
{ id: 'category-5', value: 12, label: 'Category 5' },
{ id: 'category-6', value: 10, label: 'Category 6' },
{ id: 'category-7', value: 6, label: 'Category 7' },
{ id: 'category-8', value: 4, label: 'Category 8' },
],
showLegend: true,
tooltipHref: 'https://example.com/categories',
id: 'pie-chart-many-slices',
},
render: (args) => (
<Container>
<GraphWidgetPieChart
data={args.data}
displayType={args.displayType}
prefix={args.prefix}
suffix={args.suffix}
decimals={args.decimals}
showLegend={args.showLegend}
tooltipHref={args.tooltipHref}
id={args.id}
/>
</Container>
),
};
export const WithoutLegend: Story = {
args: {
data: [
{ id: 'web', value: 45, label: 'Web' },
{ id: 'mobile', value: 35, label: 'Mobile' },
{ id: 'desktop', value: 20, label: 'Desktop' },
],
displayType: 'percentage',
showLegend: false,
tooltipHref: 'https://example.com/platforms',
id: 'pie-chart-without-legend',
},
render: (args) => (
<Container>
<GraphWidgetPieChart
data={args.data}
displayType={args.displayType}
prefix={args.prefix}
suffix={args.suffix}
decimals={args.decimals}
showLegend={args.showLegend}
tooltipHref={args.tooltipHref}
id={args.id}
/>
</Container>
),
};
export const MarketShare: Story = {
args: {
data: [
{ id: 'brand-a', value: 35.5, label: 'Brand A' },
{ id: 'brand-b', value: 28.2, label: 'Brand B' },
{ id: 'brand-c', value: 18.7, label: 'Brand C' },
{ id: 'others', value: 17.6, label: 'Others' },
],
displayType: 'percentage',
showLegend: true,
tooltipHref: 'https://example.com/market',
id: 'pie-chart-market-share',
},
render: (args) => (
<Container>
<GraphWidgetPieChart
data={args.data}
displayType={args.displayType}
prefix={args.prefix}
suffix={args.suffix}
decimals={args.decimals}
showLegend={args.showLegend}
tooltipHref={args.tooltipHref}
id={args.id}
/>
</Container>
),
};
export const Storage: Story = {
args: {
data: [
{ id: 'documents', value: 125, label: 'Documents' },
{ id: 'media', value: 280, label: 'Media' },
{ id: 'applications', value: 95, label: 'Applications' },
{ id: 'system', value: 50, label: 'System' },
],
suffix: ' GB',
showLegend: true,
tooltipHref: 'https://example.com/storage',
id: 'pie-chart-storage',
},
render: (args) => (
<Container>
<GraphWidgetPieChart
data={args.data}
displayType={args.displayType}
prefix={args.prefix}
suffix={args.suffix}
decimals={args.decimals}
showLegend={args.showLegend}
tooltipHref={args.tooltipHref}
id={args.id}
/>
</Container>
),
};
export const Catalog: Story = {
decorators: [CatalogDecorator],
parameters: {
catalog: {
dimensions: [
{
name: 'slices',
values: [2, 3, 5],
props: (sliceCount: number) => {
const dataMap: Record<
number,
Array<{ id: string; value: number; label?: string }>
> = {
2: [
{ id: 'yes', value: 65, label: 'Yes' },
{ id: 'no', value: 35, label: 'No' },
],
3: [
{ id: 'gold', value: 45, label: 'Gold' },
{ id: 'silver', value: 35, label: 'Silver' },
{ id: 'bronze', value: 20, label: 'Bronze' },
],
5: [
{ id: 'item-1', value: 30, label: 'Item 1' },
{ id: 'item-2', value: 25, label: 'Item 2' },
{ id: 'item-3', value: 20, label: 'Item 3' },
{ id: 'item-4', value: 15, label: 'Item 4' },
{ id: 'item-5', value: 10, label: 'Item 5' },
],
};
return {
data: dataMap[sliceCount] || dataMap[3],
id: `pie-chart-catalog-${sliceCount}`,
};
},
labels: (sliceCount: number) => `${sliceCount} slices`,
},
],
},
},
render: (args) => (
<Container>
<GraphWidgetPieChart
data={args.data}
displayType="percentage"
showLegend={true}
id={args.id}
/>
</Container>
),
};

View file

@ -0,0 +1,65 @@
import { type Meta, type StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui/testing';
import { GraphWidgetTooltip } from '../GraphWidgetTooltip';
const meta: Meta<typeof GraphWidgetTooltip> = {
title: 'Modules/Dashboards/Graphs/GraphWidgetTooltip',
component: GraphWidgetTooltip,
decorators: [ComponentDecorator],
parameters: {
layout: 'centered',
},
};
export default meta;
type Story = StoryObj<typeof GraphWidgetTooltip>;
export const Default: Story = {
args: {
items: [
{
label: 'Revenue',
formattedValue: '$45,231',
dotColor: 'blue',
},
],
showClickHint: false,
},
};
export const WithClickHint: Story = {
args: {
items: [
{
label: 'Sales',
formattedValue: '1,234 units',
dotColor: 'green',
},
],
showClickHint: true,
},
};
export const MultipleItems: Story = {
args: {
items: [
{
label: 'Q1',
formattedValue: '$12,345',
dotColor: 'blue',
},
{
label: 'Q2',
formattedValue: '$23,456',
dotColor: 'green',
},
{
label: 'Q3',
formattedValue: '$34,567',
dotColor: 'red',
},
],
showClickHint: false,
},
};

View file

@ -0,0 +1,5 @@
import { type GraphColorScheme } from './GraphColorScheme';
export type GraphColorRegistry = {
[key: string]: GraphColorScheme;
};

View file

@ -0,0 +1,8 @@
export type GraphColorScheme = {
name: string;
gradient: {
normal: [string, string];
hover: [string, string];
};
solid: string;
};

View file

@ -0,0 +1,13 @@
export const calculateAngularGradient = (angle: number) => {
const gradientAngle = (angle * Math.PI) / 180 + Math.PI / 2;
const dx = Math.sin(gradientAngle);
const dy = -Math.cos(gradientAngle);
return {
x1: `${50 - dx * 50}%`,
y1: `${50 - dy * 50}%`,
x2: `${50 + dx * 50}%`,
y2: `${50 + dy * 50}%`,
};
};

View file

@ -0,0 +1,28 @@
import { type GraphColorScheme } from '../types/GraphColorScheme';
import { calculateAngularGradient } from './calculateAngularGradient';
export const createGradientDef = (
colorScheme: GraphColorScheme,
id: string,
isHovered: boolean = false,
angle?: number,
) => {
const colors = isHovered
? colorScheme.gradient.hover
: colorScheme.gradient.normal;
const coords =
angle !== undefined
? calculateAngularGradient(angle)
: { x1: '0%', y1: '0%', x2: '0%', y2: '100%' };
return {
id,
type: 'linearGradient' as const,
...coords,
colors: [
{ offset: 0, color: colors[0] },
{ offset: 100, color: colors[1] },
],
};
};

View file

@ -0,0 +1,51 @@
import { type ThemeType } from 'twenty-ui/theme';
import { type GraphColorRegistry } from '../types/GraphColorRegistry';
export const createGraphColorRegistry = (
theme: ThemeType,
): GraphColorRegistry => ({
blue: {
name: 'blue',
gradient: {
normal: [theme.adaptiveColors.blue1, theme.adaptiveColors.blue2],
hover: [theme.adaptiveColors.blue3, theme.adaptiveColors.blue4],
},
solid: theme.color.blue,
},
purple: {
name: 'purple',
gradient: {
normal: [theme.adaptiveColors.purple1, theme.adaptiveColors.purple2],
hover: [theme.adaptiveColors.purple3, theme.adaptiveColors.purple4],
},
solid: theme.color.purple,
},
turquoise: {
name: 'turquoise',
gradient: {
normal: [
theme.adaptiveColors.turquoise1,
theme.adaptiveColors.turquoise2,
],
hover: [theme.adaptiveColors.turquoise3, theme.adaptiveColors.turquoise4],
},
solid: theme.color.turquoise,
},
orange: {
name: 'orange',
gradient: {
normal: [theme.adaptiveColors.orange1, theme.adaptiveColors.orange2],
hover: [theme.adaptiveColors.orange3, theme.adaptiveColors.orange4],
},
solid: theme.color.orange,
},
pink: {
name: 'pink',
gradient: {
normal: [theme.adaptiveColors.pink1, theme.adaptiveColors.pink2],
hover: [theme.adaptiveColors.pink3, theme.adaptiveColors.pink4],
},
solid: theme.color.pink,
},
});

View file

@ -0,0 +1,10 @@
import { type GraphColorRegistry } from '../types/GraphColorRegistry';
import { type GraphColorScheme } from '../types/GraphColorScheme';
export const getColorSchemeByIndex = (
registry: GraphColorRegistry,
index: number,
): GraphColorScheme => {
const schemes = Object.values(registry);
return schemes[index % schemes.length];
};

View file

@ -0,0 +1,9 @@
import { type GraphColorRegistry } from '../types/GraphColorRegistry';
import { type GraphColorScheme } from '../types/GraphColorScheme';
export const getColorSchemeByName = (
registry: GraphColorRegistry,
name: string,
): GraphColorScheme | undefined => {
return registry[name];
};

View file

@ -0,0 +1,45 @@
import { isDefined } from 'twenty-shared/utils';
import { formatAmount } from '~/utils/format/formatAmount';
import { formatNumber } from '~/utils/format/number';
export type GraphValueFormatOptions = {
displayType?: 'percentage' | 'number' | 'shortNumber' | 'currency' | 'custom';
decimals?: number;
prefix?: string;
suffix?: string;
customFormatter?: (value: number) => string;
};
export const formatGraphValue = (
value: number,
options?: GraphValueFormatOptions,
): string => {
const {
displayType = 'number',
decimals,
prefix = '',
suffix = '',
customFormatter,
} = options || {};
if (displayType === 'custom' && isDefined(customFormatter)) {
return customFormatter(value);
}
switch (displayType) {
case 'percentage':
return `${formatNumber(value * 100, decimals)}%`;
case 'shortNumber':
return `${prefix}${formatAmount(value)}${suffix}`;
case 'currency': {
const currencyPrefix = prefix || '$';
return `${currencyPrefix}${formatNumber(value, decimals)}${suffix}`;
}
case 'number':
default:
return `${prefix}${formatNumber(value, decimals)}${suffix}`;
}
};

View file

@ -18,7 +18,7 @@
"@keystatic/core": "^0.5.45",
"@keystatic/next": "^5.0.3",
"@markdoc/markdoc": "^0.5.1",
"@nivo/calendar": "^0.87.0",
"@nivo/calendar": "^0.99.0",
"date-fns": "^2.30.0",
"drizzle-kit": "^0.20.14",
"facepaint": "^1.2.1",

175
yarn.lock
View file

@ -9718,13 +9718,15 @@ __metadata:
languageName: node
linkType: hard
"@nivo/calendar@npm:^0.87.0":
version: 0.87.0
resolution: "@nivo/calendar@npm:0.87.0"
"@nivo/calendar@npm:^0.99.0":
version: 0.99.0
resolution: "@nivo/calendar@npm:0.99.0"
dependencies:
"@nivo/core": "npm:0.87.0"
"@nivo/legends": "npm:0.87.0"
"@nivo/tooltip": "npm:0.87.0"
"@nivo/core": "npm:0.99.0"
"@nivo/legends": "npm:0.99.0"
"@nivo/text": "npm:0.99.0"
"@nivo/theming": "npm:0.99.0"
"@nivo/tooltip": "npm:0.99.0"
"@types/d3-scale": "npm:^4.0.8"
"@types/d3-time": "npm:^1.0.10"
"@types/d3-time-format": "npm:^3.0.0"
@ -9733,28 +9735,8 @@ __metadata:
d3-time-format: "npm:^3.0.0"
lodash: "npm:^4.17.21"
peerDependencies:
react: ">= 16.14.0 < 19.0.0"
checksum: 10c0/5cb0f4ceb45109695f1b86e4215ef464b7c33449c50d45a436f71a30029bcfca6b91956d6be9862d17cd0d8bf4dd80e2ce281cb85ace83ee07159a0e9f603242
languageName: node
linkType: hard
"@nivo/colors@npm:0.87.0":
version: 0.87.0
resolution: "@nivo/colors@npm:0.87.0"
dependencies:
"@nivo/core": "npm:0.87.0"
"@types/d3-color": "npm:^3.0.0"
"@types/d3-scale": "npm:^4.0.8"
"@types/d3-scale-chromatic": "npm:^3.0.0"
"@types/prop-types": "npm:^15.7.2"
d3-color: "npm:^3.1.0"
d3-scale: "npm:^4.0.2"
d3-scale-chromatic: "npm:^3.0.0"
lodash: "npm:^4.17.21"
prop-types: "npm:^15.7.2"
peerDependencies:
react: ">= 16.14.0 < 19.0.0"
checksum: 10c0/872f4e2d8392f89633531250e0474a365e0456dff146d2e8ba8576226effd1eda7e721ce1dd15a792b05d06caa28a11510a5ca8e55fb84aedb8ba3d964f357f5
react: ^16.14 || ^17.0 || ^18.0 || ^19.0
checksum: 10c0/b647566eb890e7fc839bc36de99c5426f62c11b35841fcb54afca167c45150ea159ef4a7a1706c3b1ca2ca12da99d6b8b26fe5499656d037deea92e35a4cfa9a
languageName: node
linkType: hard
@ -9777,28 +9759,6 @@ __metadata:
languageName: node
linkType: hard
"@nivo/core@npm:0.87.0":
version: 0.87.0
resolution: "@nivo/core@npm:0.87.0"
dependencies:
"@nivo/tooltip": "npm:0.87.0"
"@react-spring/web": "npm:9.4.5 || ^9.7.2"
"@types/d3-shape": "npm:^3.1.6"
d3-color: "npm:^3.1.0"
d3-format: "npm:^1.4.4"
d3-interpolate: "npm:^3.0.1"
d3-scale: "npm:^4.0.2"
d3-scale-chromatic: "npm:^3.0.0"
d3-shape: "npm:^3.2.0"
d3-time-format: "npm:^3.0.0"
lodash: "npm:^4.17.21"
prop-types: "npm:^15.7.2"
peerDependencies:
react: ">= 16.14.0 < 19.0.0"
checksum: 10c0/75bb5e48cf57c8e31fbd9b9f33121fb4bffb98d47a967e5135527f8fde51537b68da3d894dc7231bf3c969523985eac6a5fc6d50827b958d51e6907a6391ed84
languageName: node
linkType: hard
"@nivo/core@npm:0.99.0, @nivo/core@npm:^0.99.0":
version: 0.99.0
resolution: "@nivo/core@npm:0.99.0"
@ -9823,20 +9783,6 @@ __metadata:
languageName: node
linkType: hard
"@nivo/legends@npm:0.87.0":
version: 0.87.0
resolution: "@nivo/legends@npm:0.87.0"
dependencies:
"@nivo/colors": "npm:0.87.0"
"@nivo/core": "npm:0.87.0"
"@types/d3-scale": "npm:^4.0.8"
d3-scale: "npm:^4.0.2"
peerDependencies:
react: ">= 16.14.0 < 19.0.0"
checksum: 10c0/1501bf698cefa2695d1124c38da5fc7712a5ce9911683c6694cbea1a481b9daef8ca7b0fc49eb85530c50347c571ea5b686caff3d6a81174b66bc38318bd317d
languageName: node
linkType: hard
"@nivo/legends@npm:0.99.0":
version: 0.99.0
resolution: "@nivo/legends@npm:0.99.0"
@ -9875,6 +9821,24 @@ __metadata:
languageName: node
linkType: hard
"@nivo/pie@npm:^0.99.0":
version: 0.99.0
resolution: "@nivo/pie@npm:0.99.0"
dependencies:
"@nivo/arcs": "npm:0.99.0"
"@nivo/colors": "npm:0.99.0"
"@nivo/core": "npm:0.99.0"
"@nivo/legends": "npm:0.99.0"
"@nivo/theming": "npm:0.99.0"
"@nivo/tooltip": "npm:0.99.0"
"@types/d3-shape": "npm:^3.1.6"
d3-shape: "npm:^3.2.0"
peerDependencies:
react: ^16.14 || ^17.0 || ^18.0 || ^19.0
checksum: 10c0/402a791f101337c5c1254ad68c46efeee41956372e75dcf93f2cf9b4e79c03f75a464c817782c65741000ddcf2c7ca6423dcce646072991a97b4e46d344520ec
languageName: node
linkType: hard
"@nivo/polar-axes@npm:0.99.0":
version: 0.99.0
resolution: "@nivo/polar-axes@npm:0.99.0"
@ -9955,18 +9919,6 @@ __metadata:
languageName: node
linkType: hard
"@nivo/tooltip@npm:0.87.0":
version: 0.87.0
resolution: "@nivo/tooltip@npm:0.87.0"
dependencies:
"@nivo/core": "npm:0.87.0"
"@react-spring/web": "npm:9.4.5 || ^9.7.2"
peerDependencies:
react: ">= 16.14.0 < 19.0.0"
checksum: 10c0/ac6b1b0bb0a09017c0e5055432e4c5ee771301615db3ee3d34abb55c900f765af78830eb2b18c8d94ff49ddeaa19895e907c793fb431943ade11cc2d7369b7e4
languageName: node
linkType: hard
"@nivo/tooltip@npm:0.99.0":
version: 0.99.0
resolution: "@nivo/tooltip@npm:0.99.0"
@ -15249,18 +15201,6 @@ __metadata:
languageName: node
linkType: hard
"@react-spring/animated@npm:~9.7.4":
version: 9.7.4
resolution: "@react-spring/animated@npm:9.7.4"
dependencies:
"@react-spring/shared": "npm:~9.7.4"
"@react-spring/types": "npm:~9.7.4"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
checksum: 10c0/7620fe7f058ca81d321710dd391062eda8bd298d2abdf63c5e230348aecd1b80730e5b96bdf052807838a20c8961efe1f9e2840e6cce3cacae5b74a28622eff8
languageName: node
linkType: hard
"@react-spring/core@npm:9.4.5 || ^9.7.2 || ^10.0, @react-spring/core@npm:~10.0.1":
version: 10.0.1
resolution: "@react-spring/core@npm:10.0.1"
@ -15274,19 +15214,6 @@ __metadata:
languageName: node
linkType: hard
"@react-spring/core@npm:~9.7.4":
version: 9.7.4
resolution: "@react-spring/core@npm:9.7.4"
dependencies:
"@react-spring/animated": "npm:~9.7.4"
"@react-spring/shared": "npm:~9.7.4"
"@react-spring/types": "npm:~9.7.4"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
checksum: 10c0/cdbc5e222edc5f25746cad36086acf20563157543557a3436b55bf0527460f68e5eb98e2927e186937eba37abe3067c227b685c07b9dc393f987a8ff7abe2455
languageName: node
linkType: hard
"@react-spring/rafz@npm:~10.0.1":
version: 10.0.1
resolution: "@react-spring/rafz@npm:10.0.1"
@ -15294,13 +15221,6 @@ __metadata:
languageName: node
linkType: hard
"@react-spring/rafz@npm:~9.7.4":
version: 9.7.4
resolution: "@react-spring/rafz@npm:9.7.4"
checksum: 10c0/975e27d6c19ed055dea91e97e473831a862aad2dc882f5402e98ac1f3f636f4ec05ee5cff623e5f6ece25388bb50a024bbc6dcdb6b750e43d53f0321140c5e52
languageName: node
linkType: hard
"@react-spring/shared@npm:~10.0.1":
version: 10.0.1
resolution: "@react-spring/shared@npm:10.0.1"
@ -15313,18 +15233,6 @@ __metadata:
languageName: node
linkType: hard
"@react-spring/shared@npm:~9.7.4":
version: 9.7.4
resolution: "@react-spring/shared@npm:9.7.4"
dependencies:
"@react-spring/rafz": "npm:~9.7.4"
"@react-spring/types": "npm:~9.7.4"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
checksum: 10c0/ae5cbb41f4876ebf365e0b2293466678d421eb68e736bfb4a0836a550518eb916fee763274f81c5af8a6a25f776df1480031e39f2981639aa79ac6cb6d290a15
languageName: node
linkType: hard
"@react-spring/types@npm:~10.0.1":
version: 10.0.1
resolution: "@react-spring/types@npm:10.0.1"
@ -15332,28 +15240,6 @@ __metadata:
languageName: node
linkType: hard
"@react-spring/types@npm:~9.7.4":
version: 9.7.4
resolution: "@react-spring/types@npm:9.7.4"
checksum: 10c0/2ad43c0463dadb8caa58a7eeafc9ba447fa66cd7d9b9ea2241d58c619353155ea94d7e6a89fc6da0f071d500216c654b35a64a12a3a586d85e304ed6c93546fd
languageName: node
linkType: hard
"@react-spring/web@npm:9.4.5 || ^9.7.2":
version: 9.7.4
resolution: "@react-spring/web@npm:9.7.4"
dependencies:
"@react-spring/animated": "npm:~9.7.4"
"@react-spring/core": "npm:~9.7.4"
"@react-spring/shared": "npm:~9.7.4"
"@react-spring/types": "npm:~9.7.4"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
checksum: 10c0/6312b612e53ae7a7cd0ab5fe16aaccbc6fba5d71248e30373a1f46e6aec997723095eff3c1a47f59aa8d5788b7092a898dee2120f841814f1d1295e5a4fcf5fb
languageName: node
linkType: hard
"@react-spring/web@npm:9.4.5 || ^9.7.2 || ^10.0":
version: 10.0.1
resolution: "@react-spring/web@npm:10.0.1"
@ -20655,7 +20541,7 @@ __metadata:
languageName: node
linkType: hard
"@types/prop-types@npm:*, @types/prop-types@npm:^15.7.2":
"@types/prop-types@npm:*":
version: 15.7.12
resolution: "@types/prop-types@npm:15.7.12"
checksum: 10c0/1babcc7db6a1177779f8fde0ccc78d64d459906e6ef69a4ed4dd6339c920c2e05b074ee5a92120fe4e9d9f1a01c952f843ebd550bee2332fc2ef81d1706878f8
@ -50963,6 +50849,7 @@ __metadata:
"@lingui/vite-plugin": "npm:^5.1.2"
"@nivo/core": "npm:^0.99.0"
"@nivo/line": "npm:^0.99.0"
"@nivo/pie": "npm:^0.99.0"
"@nivo/radial-bar": "npm:^0.99.0"
"@react-pdf/renderer": "npm:^4.1.6"
"@scalar/api-reference-react": "npm:^0.4.36"
@ -51333,7 +51220,7 @@ __metadata:
"@keystatic/next": "npm:^5.0.3"
"@markdoc/markdoc": "npm:^0.5.1"
"@next/eslint-plugin-next": "npm:^14.1.4"
"@nivo/calendar": "npm:^0.87.0"
"@nivo/calendar": "npm:^0.99.0"
"@types/facepaint": "npm:^1.2.5"
date-fns: "npm:^2.30.0"
drizzle-kit: "npm:^0.20.14"