[WEB-3781] Analytics page enhancements (#7005)

* chore: analytics endpoint

* added anlytics v2

* updated status icons

* added area chart in workitems and en translations

* active projects

* chore: created analytics chart

* chore: validation errors

* improved radar-chart , added empty states , added projects summary

* chore: added a new graph in advance analytics

* integrated priority chart

* chore: added csv exporter

* added priority dropdown

* integrated created vs resolved chart

* custom x and y axis label in bar and area chart

* added wrapper styles to legends

* added filter components

* fixed temp data imports

* integrated filters in priority charts

* added label to priority chart and updated duration filter

* refactor

* reverted to void onchange

* fixed some contant exports

* fixed type issues

* fixed some type and build issues

* chore: updated the filtering logic for analytics

* updated default value to last_30_days

* percentage value whole number and added some rules for axis options

* fixed some translations

* added - custom tick for radar, calc of insight cards, filter labels

* chore: opitmised the analytics endpoint

* replace old analytics path with new , updated labels of insight card, done some store fixes

* chore: updated the export request

* Enhanced ProjectSelect to support multi-select, improved state management, and optimized data fetching and component structure.

* fix: round completion percentage calculation in ActiveProjectItem

* added empty states in project insights

* Added loader and empty state in created/resolved chart

* added loaders

* added icons in filters

* added custom colors in customised charts

* cleaned up some code

* added some responsiveness

* updated translations

* updated serrchbar for the table

* added work item modal in project analytics

* fixed some of the layput issues in the peek view

* chore: updated the base function for viewsets

* synced tab to url

* code cleanup

* chore: updated the export logic

* fixed project_ids filter

* added icon in projectdropdown

* updated export button position

* export csv and emptystates icons

* refactor

* code refactor

* updated loaders, moved color pallete to contants, added nullish collasece operator in neccessary places

* removed uneccessary cn

* fixed formatting issues

* fixed empty project_ids in payload

* improved null checks

* optimized charts

* modified relevant variables to observable.ref

* fixed the duration type

* optimized some code

* updated query key in project-insight

* updated query key in project-insight

* updated formatting

* chore: replaced analytics route with new one and done some optimizations

* removed the old analytics

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
This commit is contained in:
JayashTripathy 2025-05-12 20:50:33 +05:30 committed by GitHub
parent 0d5c7c6653
commit 75d81f9e95
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
103 changed files with 3919 additions and 162 deletions

View file

@ -29,13 +29,21 @@ export const AreaChart = React.memo(<K extends string, T extends string>(props:
// states
const [activeArea, setActiveArea] = useState<string | null>(null);
const [activeLegend, setActiveLegend] = useState<string | null>(null);
// derived values
const itemKeys = useMemo(() => areas.map((area) => area.key), [areas]);
const itemLabels: Record<string, string> = useMemo(
() => areas.reduce((acc, area) => ({ ...acc, [area.key]: area.label }), {}),
[areas]
);
const itemDotColors = useMemo(() => areas.reduce((acc, area) => ({ ...acc, [area.key]: area.fill }), {}), [areas]);
const { itemKeys, itemLabels, itemDotColors } = useMemo(() => {
const keys: string[] = [];
const labels: Record<string, string> = {};
const colors: Record<string, string> = {};
for (const area of areas) {
keys.push(area.key);
labels[area.key] = area.label;
colors[area.key] = area.fill;
}
return { itemKeys: keys, itemLabels: labels, itemDotColors: colors };
}, [areas]);
const renderAreas = useMemo(
() =>
@ -77,7 +85,7 @@ export const AreaChart = React.memo(<K extends string, T extends string>(props:
// get the last data point
const lastPoint = data[data.length - 1];
// for the y-value in the last point, use its yAxis key value
const lastYValue = lastPoint[yAxis.key] || 0;
const lastYValue = lastPoint[yAxis.key] ?? 0;
// create data for a straight line that has points at each x-axis position
return data.map((item, index) => {
// calculate the y value for this point on the straight line
@ -91,7 +99,6 @@ export const AreaChart = React.memo(<K extends string, T extends string>(props:
};
});
}, [data, xAxis.key]);
return (
<div className={className}>
<ResponsiveContainer width="100%" height="100%">
@ -128,8 +135,8 @@ export const AreaChart = React.memo(<K extends string, T extends string>(props:
value: yAxis.label,
angle: -90,
position: "bottom",
offset: -24,
dx: -16,
offset: yAxis.offset ?? -24,
dx: yAxis.dx ?? -16,
className: AXIS_LABEL_CLASSNAME,
}
}

View file

@ -40,13 +40,22 @@ export const BarChart = React.memo(<K extends string, T extends string>(props: T
// states
const [activeBar, setActiveBar] = useState<string | null>(null);
const [activeLegend, setActiveLegend] = useState<string | null>(null);
// derived values
const stackKeys = useMemo(() => bars.map((bar) => bar.key), [bars]);
const stackLabels: Record<string, string> = useMemo(
() => bars.reduce((acc, bar) => ({ ...acc, [bar.key]: bar.label }), {}),
[bars]
);
const stackDotColors = useMemo(() => bars.reduce((acc, bar) => ({ ...acc, [bar.key]: bar.fill }), {}), [bars]);
const { stackKeys, stackLabels, stackDotColors } = useMemo(() => {
const keys: string[] = [];
const labels: Record<string, string> = {};
const colors: Record<string, string> = {};
for (const bar of bars) {
keys.push(bar.key);
labels[bar.key] = bar.label;
// For tooltip, we need a string color. If fill is a function, use a default color
colors[bar.key] = typeof bar.fill === "function" ? "#000000" : bar.fill;
}
return { stackKeys: keys, stackLabels: labels, stackDotColors: colors };
}, [bars]);
const renderBars = useMemo(
() =>
@ -102,7 +111,7 @@ export const BarChart = React.memo(<K extends string, T extends string>(props: T
axisLine={false}
label={{
value: xAxis.label,
dy: 28,
dy: xAxis.dy ?? 28,
className: AXIS_LABEL_CLASSNAME,
}}
tickCount={tickCount.x}

View file

@ -15,16 +15,17 @@ export const getLegendProps = (args: TChartLegend): LegendProps => {
overflow: "hidden",
...(layout === "vertical"
? {
top: 0,
alignItems: "center",
height: "100%",
}
top: 0,
alignItems: "center",
height: "100%",
}
: {
left: 0,
bottom: 0,
width: "100%",
justifyContent: "center",
}),
left: 0,
bottom: 0,
width: "100%",
justifyContent: "center",
}),
...args.wrapperStyles,
},
content: <CustomLegend {...args} />,
};
@ -33,8 +34,8 @@ export const getLegendProps = (args: TChartLegend): LegendProps => {
const CustomLegend = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<LegendProps, "payload" | "formatter" | "onClick" | "onMouseEnter" | "onMouseLeave"> &
TChartLegend
Pick<LegendProps, "payload" | "formatter" | "onClick" | "onMouseEnter" | "onMouseLeave"> &
TChartLegend
>((props, ref) => {
const { formatter, layout, onClick, onMouseEnter, onMouseLeave, payload } = props;

View file

@ -4,10 +4,10 @@ import React from "react";
// Common classnames
const AXIS_TICK_CLASSNAME = "fill-custom-text-300 text-sm";
export const CustomXAxisTick = React.memo<any>(({ x, y, payload }: any) => (
export const CustomXAxisTick = React.memo<any>(({ x, y, payload, getLabel }: any) => (
<g transform={`translate(${x},${y})`}>
<text y={0} dy={16} textAnchor="middle" className={AXIS_TICK_CLASSNAME}>
{payload.value}
{getLabel ? getLabel(payload.value) : payload.value}
</text>
</g>
));
@ -20,4 +20,28 @@ export const CustomYAxisTick = React.memo<any>(({ x, y, payload }: any) => (
</text>
</g>
));
CustomYAxisTick.displayName = "CustomYAxisTick";
export const CustomRadarAxisTick = React.memo<any>(
({ x, y, payload, getLabel, cx, cy, offset = 16 }: any) => {
// Calculate direction vector from center to tick
const dx = x - cx;
const dy = y - cy;
// Normalize and apply offset
const length = Math.sqrt(dx * dx + dy * dy);
const normX = dx / length;
const normY = dy / length;
const labelX = x + normX * offset;
const labelY = y + normY * offset;
return (
<g transform={`translate(${labelX},${labelY})`}>
<text y={0} textAnchor="middle" className={AXIS_TICK_CLASSNAME}>
{getLabel ? getLabel(payload.value) : payload.value}
</text>
</g>
);
}
);
CustomRadarAxisTick.displayName = "CustomRadarAxisTick";

View file

@ -38,13 +38,21 @@ export const LineChart = React.memo(<K extends string, T extends string>(props:
// states
const [activeLine, setActiveLine] = useState<string | null>(null);
const [activeLegend, setActiveLegend] = useState<string | null>(null);
// derived values
const itemKeys = useMemo(() => lines.map((line) => line.key), [lines]);
const itemLabels: Record<string, string> = useMemo(
() => lines.reduce((acc, line) => ({ ...acc, [line.key]: line.label }), {}),
[lines]
);
const itemDotColors = useMemo(() => lines.reduce((acc, line) => ({ ...acc, [line.key]: line.stroke }), {}), [lines]);
const { itemKeys, itemLabels, itemDotColors } = useMemo(() => {
const keys: string[] = [];
const labels: Record<string, string> = {};
const colors: Record<string, string> = {};
for (const line of lines) {
keys.push(line.key);
labels[line.key] = line.label;
colors[line.key] = line.stroke;
}
return { itemKeys: keys, itemLabels: labels, itemDotColors: colors };
}, [lines]);
const renderLines = useMemo(
() =>

View file

@ -0,0 +1 @@
export * from "./root";

View file

@ -0,0 +1,95 @@
import { useMemo, useState } from "react";
import {
PolarGrid,
Radar,
RadarChart as CoreRadarChart,
ResponsiveContainer,
PolarAngleAxis,
Tooltip,
Legend,
} from "recharts";
import { TRadarChartProps } from "@plane/types";
import { getLegendProps } from "../components/legend";
import { CustomRadarAxisTick } from "../components/tick";
import { CustomTooltip } from "../components/tooltip";
const RadarChart = <T extends string, K extends string>(props: TRadarChartProps<T, K>) => {
const { data, radars, margin, showTooltip, legend, className, angleAxis } = props;
// states
const [, setActiveIndex] = useState<number | null>(null);
const [activeLegend, setActiveLegend] = useState<string | null>(null);
const { itemKeys, itemLabels, itemDotColors } = useMemo(() => {
const keys: string[] = [];
const labels: Record<string, string> = {};
const colors: Record<string, string> = {};
for (const radar of radars) {
keys.push(radar.key);
labels[radar.key] = radar.name;
colors[radar.key] = radar.stroke ?? radar.fill ?? "#000000";
}
return { itemKeys: keys, itemLabels: labels, itemDotColors: colors };
}, [radars]);
return (
<div className={className}>
<ResponsiveContainer width="100%" height="100%">
<CoreRadarChart cx="50%" cy="50%" outerRadius="80%" data={data} margin={margin}>
<PolarGrid stroke="rgba(var(--color-border-100), 0.9)" />
<PolarAngleAxis dataKey={angleAxis.key} tick={(props) => <CustomRadarAxisTick {...props} />} />
{showTooltip && (
<Tooltip
cursor={{
stroke: "rgba(var(--color-text-300))",
strokeDasharray: "4 4",
}}
wrapperStyle={{
pointerEvents: "auto",
}}
content={({ active, label, payload }) => (
<CustomTooltip
active={active}
activeKey={activeLegend}
label={label}
payload={payload}
itemKeys={itemKeys}
itemLabels={itemLabels}
itemDotColors={itemDotColors}
/>
)}
/>
)}
{legend && (
// @ts-expect-error recharts types are not up to date
<Legend
onMouseEnter={(payload) => {
// @ts-expect-error recharts types are not up to date
const key: string | undefined = payload.payload?.key;
if (!key) return;
setActiveLegend(key);
setActiveIndex(null);
}}
onMouseLeave={() => setActiveLegend(null)}
{...getLegendProps(legend)}
/>
)}
{radars.map((radar) => (
<Radar
key={radar.key}
name={radar.name}
dataKey={radar.key}
stroke={radar.stroke}
fill={radar.fill}
fillOpacity={radar.fillOpacity}
dot={radar.dot}
/>
))}
</CoreRadarChart>
</ResponsiveContainer>
</div>
);
};
export { RadarChart };

View file

@ -0,0 +1 @@
export * from "./root";

View file

@ -0,0 +1,155 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import React, { useMemo, useState } from "react";
import {
CartesianGrid,
ScatterChart as CoreScatterChart,
Legend,
Scatter,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
// plane imports
import { AXIS_LABEL_CLASSNAME } from "@plane/constants";
import { TScatterChartProps } from "@plane/types";
// local components
import { getLegendProps } from "../components/legend";
import { CustomXAxisTick, CustomYAxisTick } from "../components/tick";
import { CustomTooltip } from "../components/tooltip";
export const ScatterChart = React.memo(<K extends string, T extends string>(props: TScatterChartProps<K, T>) => {
const {
data,
scatterPoints,
margin,
xAxis,
yAxis,
className,
tickCount = {
x: undefined,
y: 10,
},
legend,
showTooltip = true,
} = props;
// states
const [activePoint, setActivePoint] = useState<string | null>(null);
const [activeLegend, setActiveLegend] = useState<string | null>(null);
//derived values
const { itemKeys, itemLabels, itemDotColors } = useMemo(() => {
const keys: string[] = [];
const labels: Record<string, string> = {};
const colors: Record<string, string> = {};
for (const point of scatterPoints) {
keys.push(point.key);
labels[point.key] = point.label;
colors[point.key] = point.fill;
}
return { itemKeys: keys, itemLabels: labels, itemDotColors: colors };
}, [scatterPoints]);
const renderPoints = useMemo(
() =>
scatterPoints.map((point) => (
<Scatter
key={point.key}
dataKey={point.key}
fill={point.fill}
stroke={point.stroke}
opacity={!!activeLegend && activeLegend !== point.key ? 0.1 : 1}
onMouseEnter={() => setActivePoint(point.key)}
onMouseLeave={() => setActivePoint(null)}
/>
)),
[activeLegend, scatterPoints]
);
return (
<div className={className}>
<ResponsiveContainer width="100%" height="100%">
<CoreScatterChart
data={data}
margin={{
top: margin?.top === undefined ? 5 : margin.top,
right: margin?.right === undefined ? 30 : margin.right,
bottom: margin?.bottom === undefined ? 5 : margin.bottom,
left: margin?.left === undefined ? 20 : margin.left,
}}
>
<CartesianGrid stroke="rgba(var(--color-border-100), 0.8)" vertical={false} />
<XAxis
dataKey={xAxis.key}
tick={(props) => <CustomXAxisTick {...props} />}
tickLine={false}
axisLine={false}
label={
xAxis.label && {
value: xAxis.label,
dy: 28,
className: AXIS_LABEL_CLASSNAME,
}
}
tickCount={tickCount.x}
/>
<YAxis
domain={yAxis.domain}
tickLine={false}
axisLine={false}
label={
yAxis.label && {
value: yAxis.label,
angle: -90,
position: "bottom",
offset: -24,
dx: -16,
className: AXIS_LABEL_CLASSNAME,
}
}
tick={(props) => <CustomYAxisTick {...props} />}
tickCount={tickCount.y}
allowDecimals={!!yAxis.allowDecimals}
/>
{legend && (
// @ts-expect-error recharts types are not up to date
<Legend
onMouseEnter={(payload) => setActiveLegend(payload.value)}
onMouseLeave={() => setActiveLegend(null)}
formatter={(value) => itemLabels[value]}
{...getLegendProps(legend)}
/>
)}
{showTooltip && (
<Tooltip
cursor={{
stroke: "rgba(var(--color-text-300))",
strokeDasharray: "4 4",
}}
wrapperStyle={{
pointerEvents: "auto",
}}
content={({ active, label, payload }) => (
<CustomTooltip
active={active}
activeKey={activePoint}
label={label}
payload={payload}
itemKeys={itemKeys}
itemLabels={itemLabels}
itemDotColors={itemDotColors}
/>
)}
/>
)}
{renderPoints}
</CoreScatterChart>
</ResponsiveContainer>
</div>
);
});
ScatterChart.displayName = "ScatterChart";

View file

@ -0,0 +1,120 @@
import * as React from "react"
import { cn } from "@plane/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("bg-custom-background-80 py-4 border-y border-custom-border-200", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"bg-custom-background-300 font-medium",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"transition-colors data-[state=selected]:bg-custom-background-100",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableHeaderCellElement,
React.ThHTMLAttributes<HTMLTableHeaderCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-custom-text-300 [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableDataCellElement,
React.TdHTMLAttributes<HTMLTableDataCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableDataCellElement,
React.HTMLAttributes<HTMLTableDataCellElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-custom-text-300", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View file

@ -0,0 +1 @@
export * from "./core";