diff --git a/src/components/ExperienceDonutChart/ExperienceDonutChart.jsx b/src/components/ExperienceDonutChart/ExperienceDonutChart.jsx
index 8f342c7f02..45a8aa0b01 100644
--- a/src/components/ExperienceDonutChart/ExperienceDonutChart.jsx
+++ b/src/components/ExperienceDonutChart/ExperienceDonutChart.jsx
@@ -1,7 +1,7 @@
-import { useEffect, useMemo, useRef, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import axios from 'axios'; // Added axios import to fix network request errors
-import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts';
+import { PieChart, Pie, Cell, ResponsiveContainer, Sector } from 'recharts';
import styles from './ExperienceDonutChart.module.css';
const SEGMENT_COLORS = [
@@ -16,11 +16,14 @@ const SEGMENT_COLORS = [
const EXPERIENCE_LABELS = ['0-1 years', '1-3 years', '3-5 years', '5+ years'];
-// ✅ Crypto-based RNG (safer than Math.random)
-function secureRandomInt(min, max) {
- const array = new Uint32Array(1);
- crypto.getRandomValues(array);
- return min + (array[0] % (max - min + 1));
+function getContrastColor(hexColor) {
+ const hex = hexColor.replace('#', '');
+ const bigint = parseInt(hex, 16);
+ const r = (bigint >> 16) & 255;
+ const g = (bigint >> 8) & 255;
+ const b = bigint & 255;
+ const brightness = (r * 299 + g * 587 + b * 114) / 1000;
+ return brightness > 150 ? '#111827' : '#ffffff';
}
function Spinner() {
@@ -32,6 +35,13 @@ function Spinner() {
);
}
+const TODAY = new Date().toISOString().split('T')[0];
+
+const PREFERS_REDUCED_MOTION =
+ typeof window !== 'undefined' && typeof window.matchMedia === 'function'
+ ? window.matchMedia('(prefers-reduced-motion: reduce)').matches
+ : false;
+
export default function ExperienceDonutChart() {
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
@@ -44,7 +54,6 @@ export default function ExperienceDonutChart() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
- const [activeIndex, setActiveIndex] = useState(null);
const darkMode = useSelector(state => state.theme.darkMode);
const hasFilters = useMemo(
@@ -52,15 +61,17 @@ export default function ExperienceDonutChart() {
Boolean(
appliedFilters.startDate ||
appliedFilters.endDate ||
- (appliedFilters.roles?.length ?? 0) > 0,
+ (appliedFilters.roles?.length ?? 0) > 0 ||
+ startDate ||
+ endDate ||
+ selectedRoles.length > 0,
),
- [appliedFilters],
+ [appliedFilters, startDate, endDate, selectedRoles],
);
const fetchData = async () => {
setLoading(true);
setError(null);
- setActiveIndex(null);
try {
const token = localStorage.getItem('token');
@@ -88,7 +99,7 @@ export default function ExperienceDonutChart() {
if (!data || data.length === 0) {
setChartData(null);
- setLoading(false);
+ setTotal(0);
return;
}
@@ -120,18 +131,139 @@ export default function ExperienceDonutChart() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appliedFilters]);
- const onRolesChange = e => {
- setSelectedRoles(Array.from(e.target.selectedOptions, o => o.value));
+ const visibleChartData = useMemo(() => chartData?.filter(d => d.value > 0) ?? [], [chartData]);
+
+ // Hide counts until the sweep animation finishes so they don't bleed through
+ const [animationDone, setAnimationDone] = useState(false);
+ useEffect(() => {
+ setAnimationDone(PREFERS_REDUCED_MOTION);
+ }, [chartData]);
+
+ const [hoveredIndex, setHoveredIndex] = useState(null);
+
+ const [isMobile, setIsMobile] = useState(
+ typeof window !== 'undefined' && window.innerWidth < 450,
+ );
+
+ useEffect(() => {
+ const handleResize = () => setIsMobile(window.innerWidth < 450);
+ window.addEventListener('resize', handleResize);
+ return () => window.removeEventListener('resize', handleResize);
+ }, []);
+ const pieMargin = isMobile
+ ? { top: 5, right: 5, bottom: 5, left: 5 }
+ : { top: 20, right: 115, bottom: 20, left: 115 };
+
+ // Renders the hovered segment with a slightly larger outer radius
+ const renderActiveShape = props => {
+ const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill } = props;
+ return (
+
+ );
+ };
+
+ // Draws the count at the visual center of each segment — only after animation completes
+ const renderInsideCount = ({ cx, cy, midAngle, innerRadius, outerRadius, value, index }) => {
+ if (!value || !animationDone) return null;
+ const isHovered = index === hoveredIndex;
+ const RADIAN = Math.PI / 180;
+ // Push centroid outward slightly when hovered to stay centered in the expanded segment
+ const expandedOuter = isHovered ? outerRadius + 10 : outerRadius;
+ const radius = (innerRadius - (isHovered ? 3 : 0) + expandedOuter) * 0.5;
+ const x = cx + radius * Math.cos(-midAngle * RADIAN);
+ const y = cy + radius * Math.sin(-midAngle * RADIAN);
+ return (
+
+ {value.toLocaleString()}
+
+ );
+ };
+
+ // Draws name on top line, percentage below — outside the segment, only after animation completes
+ const renderOutsideLabel = ({ cx, cy, midAngle, outerRadius, name, percent, index }) => {
+ if (!animationDone) return null;
+ // On very small screens hide outside labels — inside counts are still visible
+ if (isMobile) return null;
+ const isHovered = index === hoveredIndex;
+ const RADIAN = Math.PI / 180;
+ const expandedOuter = isHovered ? outerRadius + 10 : outerRadius;
+ const lineStart = expandedOuter + 8;
+ const lineEnd = expandedOuter + (isMobile ? 30 : 50);
+ const sx = cx + lineStart * Math.cos(-midAngle * RADIAN);
+ const sy = cy + lineStart * Math.sin(-midAngle * RADIAN);
+ const ex = cx + lineEnd * Math.cos(-midAngle * RADIAN);
+ const ey = cy + lineEnd * Math.sin(-midAngle * RADIAN);
+ const isRight = ex > cx;
+ const elbowX = ex + (isRight ? 18 : -18);
+ const textX = elbowX + (isRight ? 4 : -4);
+ const textAnchor = isRight ? 'start' : 'end';
+ const pct = `${(percent * 100).toFixed(1)}%`;
+ const labelColor = darkMode ? '#f8fafc' : '#0f172a';
+ const lineColor = darkMode ? '#94a3b8' : '#64748b';
+ const nameFontSize = isHovered ? '1.2rem' : '1.05rem';
+ const pctFontSize = isHovered ? '1.05rem' : '0.95rem';
+ const strokeWidth = isHovered ? 2.5 : 1.5;
+
+ return (
+
+
+
+
+ {name}
+
+
+ {pct}
+
+
+
+ );
};
const applyFilters = () => {
+ if (startDate && startDate > TODAY) {
+ setError('Start date cannot be in the future.');
+ return;
+ }
+ if (endDate && endDate > TODAY) {
+ setError('End date cannot be in the future.');
+ return;
+ }
if (startDate && endDate && new Date(startDate) > new Date(endDate)) {
- setError(null);
- setChartData(null);
- setTotal(0);
- setLoading(false);
+ setError('Start date must be before end date.');
return;
}
+ setError(null);
setAppliedFilters({ startDate, endDate, roles: selectedRoles });
};
@@ -139,51 +271,10 @@ export default function ExperienceDonutChart() {
setStartDate('');
setEndDate('');
setSelectedRoles([]);
+ setError(null);
setAppliedFilters({ startDate: '', endDate: '', roles: [] });
};
- const DetailsPanel = () => {
- if (!chartData || total === 0) return null;
-
- return (
-
- {chartData.map((d, idx) => {
- const pct = total > 0 ? ((d.value / total) * 100).toFixed(1) : 0;
- return (
-
setActiveIndex(idx)}
- onMouseLeave={() => setActiveIndex(null)}
- >
-
- {d.name}
- {d.value.toLocaleString()}
- {pct}%
-
- );
- })}
-
- );
- };
-
- const CustomTooltip = ({ active, payload }) => {
- if (!active || !payload?.length) return null;
- const d = payload[0]?.payload;
- const pct = total > 0 ? ((d.value / total) * 100).toFixed(1) : 0;
-
- return (
-
- {/* Corrected tooltip to use name and value from payload for visibility */}
- {d.name}
-
- Count: {d.value}
-
- {pct}% of applicants
-
- );
- };
-
return (
setStartDate(e.target.value)}
/>
@@ -218,27 +310,38 @@ export default function ExperienceDonutChart() {
type="date"
className={styles['filter-input']}
value={endDate}
+ max={TODAY}
onChange={e => setEndDate(e.target.value)}
/>
-
-
+
@@ -247,7 +350,9 @@ export default function ExperienceDonutChart() {
Apply