feat(ui): add Finding Severity Over Time chart to overview page (#9405)

This commit is contained in:
Alan Buscaglia authored and GitHub committed 2025-12-04 13:19:15 +01:00
1 parent 9c387d5742
commit 7f12832808
100 files changed
+1267 -948

No files matched your search

+41 -15
View File
@@ -1,29 +1,55 @@
export interface ChartLegendItem {
label: string;
color: string;
dataKey?: string;
}
interface ChartLegendProps {
items: ChartLegendItem[];
selectedItem?: string | null;
onItemClick?: (dataKey: string) => void;
}
export function ChartLegend({ items }: ChartLegendProps) {
export function ChartLegend({
items,
selectedItem,
onItemClick,
}: ChartLegendProps) {
const isInteractive = !!onItemClick;
return (
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary inline-flex items-center gap-2 rounded-full border">
{items.map((item, index) => (
<div
key={`legend-${index}`}
className="flex items-center gap-2 px-4 py-3"
>
<div
className="h-3 w-3 rounded"
style={{ backgroundColor: item.color }}
/>
<span className="text-text-neutral-secondary text-sm font-medium">
{item.label}
</span>
</div>
))}
{items.map((item, index) => {
const dataKey = item.dataKey ?? item.label.toLowerCase();
const isSelected = selectedItem === dataKey;
const isFaded = selectedItem !== null && !isSelected;
return (
<button
key={`legend-${index}`}
type="button"
className={`flex items-center gap-2 px-4 py-3 transition-opacity duration-200 ${
isInteractive
? "cursor-pointer hover:opacity-80"
: "cursor-default"
} ${isFaded ? "opacity-30" : "opacity-100"}`}
onClick={() => onItemClick?.(dataKey)}
disabled={!isInteractive}
>
<div
className={`h-3 w-3 rounded ${isSelected ? "ring-2 ring-offset-1" : ""}`}
style={{
backgroundColor: item.color,
// @ts-expect-error ring-color is a valid Tailwind CSS variable
"--tw-ring-color": item.color,
}}
/>
<span className="text-text-neutral-secondary text-sm font-medium">
{item.label}
</span>
</button>
);
})}
</div>
);
}
@@ -0,0 +1,54 @@
import { Dot } from "recharts";
import { LineDataPoint } from "../types";
export interface PointClickData {
point: LineDataPoint;
dataKey?: string;
}
interface CustomActiveDotProps {
cx?: number;
cy?: number;
payload?: LineDataPoint;
dataKey: string;
color: string;
isFaded: boolean;
onPointClick?: (data: PointClickData) => void;
onMouseEnter: () => void;
onMouseLeave: () => void;
}
export const CustomActiveDot = ({
cx,
cy,
payload,
dataKey,
color,
isFaded,
onPointClick,
onMouseEnter,
onMouseLeave,
}: CustomActiveDotProps) => {
if (cx === undefined || cy === undefined) return null;
// Don't render active dot for faded lines
if (isFaded) return null;
return (
<Dot
cx={cx}
cy={cy}
r={6}
fill={color}
style={{ cursor: onPointClick ? "pointer" : "default" }}
onClick={() => {
if (onPointClick && payload) {
onPointClick({ point: payload, dataKey });
}
}}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
/>
);
};
@@ -1,27 +1,55 @@
export const AXIS_FONT_SIZE = 14;
const TODAY_FONT_SIZE = 12;
const MONTH_FONT_SIZE = 11;
interface CustomXAxisTickProps {
x: number;
y: number;
index?: number;
payload: {
value: string | number;
};
visibleTicksCount?: number;
}
const getTodayFormatted = () => {
const getTodayISO = () => {
const today = new Date();
return today.toLocaleDateString("en-US", {
month: "2-digit",
day: "2-digit",
});
return today.toISOString().split("T")[0];
};
const getMonthName = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString("en-US", { month: "short" });
};
const getDayNumber = (dateStr: string) => {
const date = new Date(dateStr);
return date.getDate();
};
const getMonthFromDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.getMonth();
};
export const CustomXAxisTickWithToday = Object.assign(
function CustomXAxisTickWithToday(props: CustomXAxisTickProps) {
const { x, y, payload } = props;
const todayFormatted = getTodayFormatted();
const isToday = String(payload.value) === todayFormatted;
function CustomXAxisTickWithToday(
props: CustomXAxisTickProps & { data?: Array<{ date: string }> },
) {
const { x, y, payload, index = 0, data = [] } = props;
const dateStr = String(payload.value);
const todayISO = getTodayISO();
const isToday = dateStr === todayISO;
const dayNumber = getDayNumber(dateStr);
const currentMonth = getMonthFromDate(dateStr);
// Show month name if it's the first tick or if the month changed from previous tick
const isFirstTick = index === 0;
const previousDate = index > 0 && data[index - 1]?.date;
const previousMonth = previousDate ? getMonthFromDate(previousDate) : -1;
const monthChanged = currentMonth !== previousMonth;
const showMonth = isFirstTick || monthChanged;
return (
<g transform={`translate(${x},${y})`}>
@@ -33,12 +61,23 @@ export const CustomXAxisTickWithToday = Object.assign(
fill="var(--color-text-neutral-secondary)"
fontSize={AXIS_FONT_SIZE}
>
{payload.value}
{dayNumber}
</text>
{showMonth && (
<text
x={0}
y={42}
textAnchor="middle"
fill="var(--color-text-neutral-tertiary)"
fontSize={MONTH_FONT_SIZE}
>
{getMonthName(dateStr)}
</text>
)}
{isToday && (
<text
x={0}
y={36}
y={showMonth ? 56 : 42}
textAnchor="middle"
fill="var(--color-text-neutral-secondary)"
fontSize={TODAY_FONT_SIZE}
@@ -0,0 +1,14 @@
import { Dot } from "recharts";
interface CustomDotProps {
cx?: number;
cy?: number;
color: string;
isFaded: boolean;
}
export const CustomDot = ({ cx, cy, color, isFaded }: CustomDotProps) => {
if (cx === undefined || cy === undefined) return null;
return <Dot cx={cx} cy={cy} r={4} fill={color} opacity={isFaded ? 0.2 : 1} />;
};