Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
274 changes: 185 additions & 89 deletions src/components/ExperienceDonutChart/ExperienceDonutChart.jsx
Original file line number Diff line number Diff line change
@@ -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 = [
Expand All @@ -16,11 +16,14 @@

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);

Check warning on line 21 in src/components/ExperienceDonutChart/ExperienceDonutChart.jsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `Number.parseInt` over `parseInt`.

See more on https://sonarcloud.io/project/issues?id=OneCommunityGlobal_HighestGoodNetworkApp&issues=AZ41R3E_BO_6kV5u09SQ&open=AZ41R3E_BO_6kV5u09SQ&pullRequest=5281
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() {
Expand All @@ -32,6 +35,13 @@
);
}

const TODAY = new Date().toISOString().split('T')[0];

const PREFERS_REDUCED_MOTION =
typeof window !== 'undefined' && typeof window.matchMedia === 'function'

Check warning on line 41 in src/components/ExperienceDonutChart/ExperienceDonutChart.jsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis.window` over `window`.

See more on https://sonarcloud.io/project/issues?id=OneCommunityGlobal_HighestGoodNetworkApp&issues=AZ41R3E_BO_6kV5u09SR&open=AZ41R3E_BO_6kV5u09SR&pullRequest=5281

Check warning on line 41 in src/components/ExperienceDonutChart/ExperienceDonutChart.jsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=OneCommunityGlobal_HighestGoodNetworkApp&issues=AZ41R3E_BO_6kV5u09SS&open=AZ41R3E_BO_6kV5u09SS&pullRequest=5281
? window.matchMedia('(prefers-reduced-motion: reduce)').matches

Check warning on line 42 in src/components/ExperienceDonutChart/ExperienceDonutChart.jsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=OneCommunityGlobal_HighestGoodNetworkApp&issues=AZ41R3E_BO_6kV5u09ST&open=AZ41R3E_BO_6kV5u09ST&pullRequest=5281
: false;

export default function ExperienceDonutChart() {
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
Expand All @@ -44,7 +54,6 @@
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(
Expand All @@ -60,7 +69,6 @@
const fetchData = async () => {
setLoading(true);
setError(null);
setActiveIndex(null);

try {
const token = localStorage.getItem('token');
Expand Down Expand Up @@ -88,7 +96,7 @@

if (!data || data.length === 0) {
setChartData(null);
setLoading(false);
setTotal(0);
return;
}

Expand Down Expand Up @@ -120,70 +128,139 @@
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appliedFilters]);

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);

// Renders the hovered segment with a slightly larger outer radius
const renderActiveShape = props => {
const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill } = props;

Check warning on line 143 in src/components/ExperienceDonutChart/ExperienceDonutChart.jsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'cy' is missing in props validation

See more on https://sonarcloud.io/project/issues?id=OneCommunityGlobal_HighestGoodNetworkApp&issues=AZ41R3E_BO_6kV5u09SV&open=AZ41R3E_BO_6kV5u09SV&pullRequest=5281

Check warning on line 143 in src/components/ExperienceDonutChart/ExperienceDonutChart.jsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'innerRadius' is missing in props validation

See more on https://sonarcloud.io/project/issues?id=OneCommunityGlobal_HighestGoodNetworkApp&issues=AZ41R3E_BO_6kV5u09SW&open=AZ41R3E_BO_6kV5u09SW&pullRequest=5281

Check warning on line 143 in src/components/ExperienceDonutChart/ExperienceDonutChart.jsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'endAngle' is missing in props validation

See more on https://sonarcloud.io/project/issues?id=OneCommunityGlobal_HighestGoodNetworkApp&issues=AZ41R3E_BO_6kV5u09SZ&open=AZ41R3E_BO_6kV5u09SZ&pullRequest=5281

Check warning on line 143 in src/components/ExperienceDonutChart/ExperienceDonutChart.jsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'fill' is missing in props validation

See more on https://sonarcloud.io/project/issues?id=OneCommunityGlobal_HighestGoodNetworkApp&issues=AZ41R3E_BO_6kV5u09Sa&open=AZ41R3E_BO_6kV5u09Sa&pullRequest=5281

Check warning on line 143 in src/components/ExperienceDonutChart/ExperienceDonutChart.jsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'cx' is missing in props validation

See more on https://sonarcloud.io/project/issues?id=OneCommunityGlobal_HighestGoodNetworkApp&issues=AZ41R3E_BO_6kV5u09SU&open=AZ41R3E_BO_6kV5u09SU&pullRequest=5281

Check warning on line 143 in src/components/ExperienceDonutChart/ExperienceDonutChart.jsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'startAngle' is missing in props validation

See more on https://sonarcloud.io/project/issues?id=OneCommunityGlobal_HighestGoodNetworkApp&issues=AZ41R3E_BO_6kV5u09SY&open=AZ41R3E_BO_6kV5u09SY&pullRequest=5281

Check warning on line 143 in src/components/ExperienceDonutChart/ExperienceDonutChart.jsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'outerRadius' is missing in props validation

See more on https://sonarcloud.io/project/issues?id=OneCommunityGlobal_HighestGoodNetworkApp&issues=AZ41R3E_BO_6kV5u09SX&open=AZ41R3E_BO_6kV5u09SX&pullRequest=5281
return (
<Sector
cx={cx}
cy={cy}
innerRadius={innerRadius - 3}
outerRadius={outerRadius + 10}
startAngle={startAngle}
endAngle={endAngle}
fill={fill}
/>
);
};

// 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 (
<text
x={x}
y={y}
textAnchor="middle"
dominantBaseline="central"
fill={getContrastColor(visibleChartData[index]?.color ?? '#000')}
style={{
fontSize: isHovered ? '1.2rem' : '1.05rem',
fontWeight: 800,
pointerEvents: 'none',
transition: 'font-size 0.15s ease',
}}
>
{value.toLocaleString()}
</text>
);
};

// 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;
const isHovered = index === hoveredIndex;
const RADIAN = Math.PI / 180;
const expandedOuter = isHovered ? outerRadius + 10 : outerRadius;
const lineStart = expandedOuter + 8;
const lineEnd = expandedOuter + 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.05rem' : '0.95rem';
const pctFontSize = isHovered ? '0.95rem' : '0.85rem';
const strokeWidth = isHovered ? 2.5 : 1.5;

return (
<g style={{ transition: 'all 0.15s ease' }}>
<path
d={`M${sx},${sy} L${ex},${ey} L${elbowX},${ey}`}
fill="none"
stroke={lineColor}
strokeWidth={strokeWidth}
/>
<text
x={textX}
y={ey}
textAnchor={textAnchor}
fill={labelColor}
style={{ fontWeight: 700 }}
>
<tspan x={textX} dy="-0.55em" style={{ fontSize: nameFontSize }}>
{name}
</tspan>
<tspan x={textX} dy="1.2em" style={{ fontSize: pctFontSize, opacity: 0.75 }}>
{pct}
</tspan>
</text>
</g>
);
};

const onRolesChange = e => {
setSelectedRoles(Array.from(e.target.selectedOptions, o => o.value));
};

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 });
};

const resetFilters = () => {
setStartDate('');
setEndDate('');
setSelectedRoles([]);
setError(null);
setAppliedFilters({ startDate: '', endDate: '', roles: [] });
};

const DetailsPanel = () => {
if (!chartData || total === 0) return null;

return (
<div className={styles['chart-details']}>
{chartData.map((d, idx) => {
const pct = total > 0 ? ((d.value / total) * 100).toFixed(1) : 0;
return (
<div
key={d.name}
className={`${styles['detail-item']} ${activeIndex === idx ? styles.active : ''}`}
onMouseEnter={() => setActiveIndex(idx)}
onMouseLeave={() => setActiveIndex(null)}
>
<span className={styles['detail-dot']} style={{ backgroundColor: d.color }} />
<span className={styles['detail-name']}>{d.name}</span>
<span className={styles['detail-count']}>{d.value.toLocaleString()}</span>
<span className={styles['detail-pct']}>{pct}%</span>
</div>
);
})}
</div>
);
};

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 (
<div className={styles['custom-tooltip']}>
{/* Corrected tooltip to use name and value from payload for visibility */}
<strong>{d.name}</strong>
<br />
Count: {d.value}
<br />
{pct}% of applicants
</div>
);
};

return (
<div
className={`${styles['experience-donut-chart']} ${darkMode &&
Expand All @@ -205,6 +282,7 @@
type="date"
className={styles['filter-input']}
value={startDate}
max={TODAY}
onChange={e => setStartDate(e.target.value)}
/>
</div>
Expand All @@ -218,6 +296,7 @@
type="date"
className={styles['filter-input']}
value={endDate}
max={TODAY}
onChange={e => setEndDate(e.target.value)}
/>
</div>
Expand Down Expand Up @@ -261,51 +340,68 @@
{loading && <Spinner />}

{!loading && !error && chartData && total > 0 && (
<>
<div className={styles['chart-canvas']}>
<ResponsiveContainer width="100%" aspect={1}>
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
dataKey="value"
innerRadius="55%"
outerRadius="82%"
stroke={darkMode ? '#1c2441' : '#fff'}
strokeWidth={3}
onMouseEnter={(_, i) => setActiveIndex(i)}
onMouseLeave={() => setActiveIndex(null)}
>
{chartData.map((d, i) => (
<Cell
key={d.name}
fill={d.color}
className={styles['pie-cell']}
opacity={activeIndex == null || activeIndex === i ? 1 : 0.45}
/>
))}
</Pie>
<Tooltip content={<CustomTooltip />} />
<div className={styles['chart-canvas']}>
<ResponsiveContainer width="100%" aspect={1}>
<PieChart margin={{ top: 50, right: 100, bottom: 50, left: 100 }}>
<Pie
data={visibleChartData}
cx="50%"
cy="50%"
dataKey="value"
innerRadius="42%"
outerRadius="78%"
stroke={darkMode ? '#1c2441' : '#fff'}
strokeWidth={3}
labelLine={false}
label={renderOutsideLabel}
isAnimationActive={!PREFERS_REDUCED_MOTION}
onAnimationEnd={() => setAnimationDone(true)}
activeIndex={hoveredIndex}
activeShape={renderActiveShape}
onMouseEnter={(_, index) => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(null)}
>
{visibleChartData.map(d => (
<Cell key={d.name} fill={d.color} className={styles['pie-cell']} />
))}
</Pie>
{/* Inside counts rendered as a second label pass — animation disabled to prevent double-sweep */}
<Pie
data={visibleChartData}
cx="50%"
cy="50%"
dataKey="value"
innerRadius="42%"
outerRadius="78%"
stroke="none"
strokeWidth={0}
labelLine={false}
label={renderInsideCount}
isAnimationActive={false}
style={{ pointerEvents: 'none' }}
>
{visibleChartData.map(d => (
<Cell key={d.name} fill="transparent" />
))}
</Pie>
{animationDone && (
<text
x="50%"
y="50%"
dominantBaseline="middle"
textAnchor="middle"
style={{
fontWeight: 800,
fontSize: '1rem',
fontSize: '1.1rem',
fill: darkMode ? '#f8fafc' : '#0f172a',
}}
>
{total.toLocaleString()}
</text>
</PieChart>
</ResponsiveContainer>
</div>

<DetailsPanel />
</>
)}
</PieChart>
</ResponsiveContainer>
</div>
)}

{!loading && !error && (!chartData || total === 0) && <p>No Data Available 😢</p>}
Expand Down
Loading
Loading