diff --git a/index.html b/index.html
index 46604c7e44..c92b873176 100644
--- a/index.html
+++ b/index.html
@@ -1,7 +1,6 @@
-
-
- {name}
+ return formatNumber(rounded);
+};
+
+const getIsDarkMode = () => {
+ if (typeof window === 'undefined' || !window.matchMedia) return false;
+ return window.matchMedia('(prefers-color-scheme: dark)').matches;
+};
+
+const getTooltipData = (payload, label) => {
+ const data = (payload && payload[0] && payload[0].payload) || {};
+ return {
+ name: data._id || data.name || label || '',
+ percentage: data.percentage,
+ hoursValue: data.value,
+ totalHours: data.totalHours !== undefined ? data.totalHours : data.value,
+ change: data.change,
+ };
+};
+
+function CustomTooltip({ active, payload, label, tooltipType }) {
+ if (!active || !payload || !payload.length) return null;
+
+ const isDarkMode = getIsDarkMode();
+ const { name, percentage, hoursValue, totalHours, change } = getTooltipData(payload, label);
+ const textColor = isDarkMode ? '#fff' : '#222';
+
+ const renderMainValue = () => {
+ if (tooltipType === 'hoursDistribution' && hoursValue !== undefined) {
+ const exactHours = formatNumber(hoursValue);
+ const compactHours = formatCompactNumber(hoursValue);
+ const showCompactAndExact = compactHours.toLowerCase().includes('k');
+ return (
+
+ Hours: {showCompactAndExact ? `${compactHours} (${exactHours})` : exactHours}
- {totalHours !== undefined && (
-
- Total Hours: {totalHours}
-
- )}
- {percentage !== undefined && (
-
Percentage: {percentage}
- )}
- {/* For other charts, fallback to value, change, etc. */}
- {data.change !== undefined && (
-
- Change: {data.change}
-
- )}
-
- );
- }
- return null;
+ );
+ }
+
+ if (totalHours !== undefined) {
+ return
Total Hours: {totalHours}
;
+ }
+
+ return null;
+ };
+
+ const renderChange = () => {
+ if (change === undefined) return null;
+ const changeColor = change < 0 ? 'red' : isDarkMode ? 'lightgreen' : 'green';
+ return
Change: {change}
;
+ };
+
+ return (
+
+
{name}
+ {renderMainValue()}
+ {percentage !== undefined && (
+
Percentage: {percentage}%
+ )}
+ {renderChange()}
+
+ );
}
export default CustomTooltip;
diff --git a/src/components/Header/Header.jsx b/src/components/Header/Header.jsx
index 9a171d540e..a0d4dfd610 100644
--- a/src/components/Header/Header.jsx
+++ b/src/components/Header/Header.jsx
@@ -765,14 +765,6 @@ export function Header(props) {
>
PR Team Analytics
-
- PR Analytics
-
{canAccessBlueSquareEmailManagement && (
{
+ if (!Number.isFinite(value)) return '0';
+ return new Intl.NumberFormat('en-US').format(Math.round(value));
+};
+
+export const formatChartLabelValue = value => {
+ if (!Number.isFinite(value)) return '0';
+ const rounded = Math.round(value);
+ const absoluteValue = Math.abs(rounded);
+
+ if (absoluteValue < 1000) {
+ return formatNumber(rounded);
+ }
+
+ return new Intl.NumberFormat('en-US', {
+ notation: 'compact',
+ maximumFractionDigits: absoluteValue >= 100000 ? 0 : 1,
+ }).format(rounded);
+};
+
+const parseHexColor = color => {
+ if (!color || typeof color !== 'string' || !color.startsWith('#')) return null;
+ const hex = color.replace('#', '');
+ const normalizedHex =
+ hex.length === 3
+ ? `${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}`
+ : hex.padEnd(6, '0').slice(0, 6);
+
+ const r = parseInt(normalizedHex.slice(0, 2), 16);
+ const g = parseInt(normalizedHex.slice(2, 4), 16);
+ const b = parseInt(normalizedHex.slice(4, 6), 16);
+
+ if ([r, g, b].some(Number.isNaN)) return null;
+ return { r, g, b };
+};
+
+const channelToLinear = c => {
+ const normalized = c / 255;
+ return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
+};
+
+const relativeLuminance = ({ r, g, b }) =>
+ 0.2126 * channelToLinear(r) + 0.7152 * channelToLinear(g) + 0.0722 * channelToLinear(b);
+
+const contrastRatio = (l1, l2) => {
+ const brightest = Math.max(l1, l2);
+ const darkest = Math.min(l1, l2);
+ return (brightest + 0.05) / (darkest + 0.05);
+};
+
+const getReadableTextColor = (bgColor, fallbackDarkMode) => {
+ const rgb = parseHexColor(bgColor);
+ if (!rgb) return fallbackDarkMode ? '#F8FAFC' : '#111827';
+
+ const bgL = relativeLuminance(rgb);
+ const whiteContrast = contrastRatio(bgL, 1);
+ const blackContrast = contrastRatio(bgL, 0);
+ return whiteContrast >= blackContrast ? '#FFFFFF' : '#111111';
+};
+
+const getLabelToneClass = color =>
+ color === '#FFFFFF' ? 'hours-distribution-label-light' : 'hours-distribution-label-dark';
+
+export const renderCenterLabel = ({ darkMode, isMobile, totalHours }) => {
+ const centerFill = darkMode ? '#D1D5DB' : '#696969';
+ const totalText = formatNumber(totalHours || 0);
+ const centerValueFontSize = totalText.length > 6 ? (isMobile ? 24 : 30) : isMobile ? 30 : 36;
+
+ return (
+ <>
+
+ TOTAL HOURS
+
+
+ WORKED
+
+
+ {totalText}
+
+ >
+ );
+};
+
const renderCustomizedLabel = ({
cx,
cy,
@@ -11,98 +116,159 @@ const renderCustomizedLabel = ({
percent,
value,
totalHours,
- title,
- comparisonPercentage,
- comparisonType,
+ fill,
+ payload,
+ index,
+ colors,
+ resolvedSliceColor,
+ darkMode,
+ isMobile,
}) => {
- const radius = innerRadius + (outerRadius - innerRadius) * 0.4;
- const x = cx + radius * Math.cos(-midAngle * RADIAN);
- const y = cy + radius * Math.sin(-midAngle * RADIAN);
+ if (value <= 0) return null;
- const percentage = Math.round(comparisonPercentage);
- const fillColor = comparisonPercentage > 1 ? 'green' : 'red';
+ // Thin wedges cannot fit two long lines; use adaptive compact labels.
+ const hideAllLabels = percent < 0.005; // < 0.5%
+ const isTinySlice = percent < 0.025; // < 2.5%
+ const useCompactLabel = percent < 0.1; // < 10%
+ const canShowPercent = percent >= 0.1; // >= 10% has room for percentage
+ // Keep small-slice labels centered in each wedge band for better visual alignment.
+ // Reduced radiusFactors to keep numbers more centered and prevent overflow
+ const radiusFactor = isTinySlice ? 0.58 : useCompactLabel ? 0.54 : 0.48;
+ const radius = innerRadius + (outerRadius - innerRadius) * radiusFactor;
+ const cos = Math.cos(-midAngle * RADIAN);
+ const sin = Math.sin(-midAngle * RADIAN);
+ const x = Math.round(cx + radius * cos);
+ const y = Math.round(cy + radius * sin);
- const textContent =
- comparisonType !== 'No Comparison' ? `${percentage}% ${comparisonType.toLowerCase()}` : '';
- const fontSize = 10;
- const maxTextLength = Math.floor((innerRadius / fontSize) * 4);
+ const fallbackIndexedColor =
+ Array.isArray(colors) && typeof index === 'number' ? colors[index % colors.length] : undefined;
+ const sliceColor = resolvedSliceColor || payload?.sliceColor || fill || fallbackIndexedColor;
+ const numberTextColor = getReadableTextColor(sliceColor, darkMode);
+ const labelToneClass = getLabelToneClass(numberTextColor);
- let adjustedText = textContent;
- if (textContent.length > maxTextLength) {
- adjustedText = `${textContent.substring(0, maxTextLength - 3)}...`;
- }
+ const valueFontSize = isMobile ? 11 : 12;
+ const percentFontSize = isMobile ? 8 : 9;
+ const valueY = y - (isMobile ? 7 : 9); // More breathing room above
+ const percentY = y + (isMobile ? 7 : 9); // More breathing room below
+ const chartLabelValue = formatChartLabelValue(value);
return (
<>
- cx ? 'start' : 'end'}
- dominantBaseline="central"
- fontWeight="bold"
- >
- {value > 0 && value.toFixed(0)}
-
- cx ? 'start' : 'end'}
- dominantBaseline="central"
- fontSize="12"
- fontWeight="bold"
- >
- {percent > 0 && `(${(percent * 100).toFixed(0)}%)`}
-
-
- {title}
-
-
- {totalHours.toFixed(0)}
-
- {comparisonType !== 'No Comparison' && (
-
- {adjustedText}
+ {!hideAllLabels && (isTinySlice || useCompactLabel) && (
+
+ {chartLabelValue}
)}
+ {!hideAllLabels && canShowPercent && (
+ <>
+
+ {chartLabelValue}
+
+
+ {`(${(percent * 100).toFixed(0)}%)`}
+
+ >
+ )}
>
);
};
import CustomTooltip from '../../CustomTooltip';
-export default function HoursWorkedPieChart({ userData, windowSize, comparisonType, colors }) {
+export default function HoursWorkedPieChart({
+ userData,
+ windowSize,
+ colors,
+ totalHours = 0,
+ darkMode = false,
+}) {
let innerRadius = 80;
let outerRadius = 160;
if (windowSize.width <= 650) {
innerRadius = 65;
outerRadius = 130;
}
+ const isMobile = windowSize.width <= 650;
+ const chartData = Array.isArray(userData)
+ ? userData.map((entry, index) => ({
+ ...entry,
+ sliceColor: colors[index % colors.length],
+ }))
+ : [];
+
+ // We'll display totalHours in centre
+ const displayTotalHours = totalHours || 0;
return (
renderCustomizedLabel({ ...props, comparisonType })}
+ label={props =>
+ renderCustomizedLabel({
+ ...props,
+ totalHours: displayTotalHours,
+ darkMode,
+ isMobile,
+ colors,
+ resolvedSliceColor:
+ typeof props.index === 'number' ? chartData[props.index]?.sliceColor : undefined,
+ })
+ }
innerRadius={innerRadius}
outerRadius={outerRadius}
fill="#8884d8"
dataKey="value"
>
- {Array.isArray(userData) &&
- userData.length > 0 &&
- userData.map((entry, index) => (
- |
+ {Array.isArray(chartData) &&
+ chartData.length > 0 &&
+ chartData.map((entry, index) => (
+ |
))}
- } />
+ {renderCenterLabel({ darkMode, isMobile, totalHours: displayTotalHours })}
+ } />
);
}
+
+HoursWorkedPieChart.propTypes = {
+ userData: PropTypes.array.isRequired,
+ windowSize: PropTypes.shape({ width: PropTypes.number, height: PropTypes.number }).isRequired,
+ colors: PropTypes.array,
+ totalHours: PropTypes.number,
+ darkMode: PropTypes.bool,
+};
diff --git a/src/components/TotalOrgSummary/HoursWorkedPieChart/__tests__/HoursWorkedPieChart.test.jsx b/src/components/TotalOrgSummary/HoursWorkedPieChart/__tests__/HoursWorkedPieChart.test.jsx
new file mode 100644
index 0000000000..bb3a7aed9d
--- /dev/null
+++ b/src/components/TotalOrgSummary/HoursWorkedPieChart/__tests__/HoursWorkedPieChart.test.jsx
@@ -0,0 +1,25 @@
+import { render, screen } from '@testing-library/react';
+import React from 'react';
+
+import { formatChartLabelValue, renderCenterLabel } from '../HoursWorkedPieChart';
+
+describe('formatChartLabelValue', () => {
+ it('formats all chart labels in a consistent compact style', () => {
+ expect(formatChartLabelValue(190134)).toBe('190K');
+ expect(formatChartLabelValue(26713)).toBe('26.7K');
+ expect(formatChartLabelValue(11000)).toBe('11K');
+ expect(formatChartLabelValue(7857)).toBe('7.9K');
+ expect(formatChartLabelValue(6285)).toBe('6.3K');
+ expect(formatChartLabelValue(999)).toBe('999');
+ });
+});
+
+describe('renderCenterLabel', () => {
+ it('renders the donut center text once with the total hour value', () => {
+ render();
+
+ expect(screen.getAllByText('TOTAL HOURS')).toHaveLength(1);
+ expect(screen.getByText('WORKED')).toBeInTheDocument();
+ expect(screen.getByText('241,989')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/TotalOrgSummary/NumbersVolunteerWorked/NumbersVolunteerWorked.jsx b/src/components/TotalOrgSummary/NumbersVolunteerWorked/NumbersVolunteerWorked.jsx
index 2001a607d4..73ef9da4d4 100644
--- a/src/components/TotalOrgSummary/NumbersVolunteerWorked/NumbersVolunteerWorked.jsx
+++ b/src/components/TotalOrgSummary/NumbersVolunteerWorked/NumbersVolunteerWorked.jsx
@@ -1,6 +1,23 @@
import styles from '../TotalOrgSummary.module.css';
import cx from 'clsx';
-export default function NumbersVolunteerWorked({ isLoading, data, darkMode }) {
+
+function formatPercentage(value) {
+ if (!Number.isFinite(value) || value <= 0) return '0';
+ if (value < 1) return value.toFixed(2);
+ if (value < 10) return Number(value.toFixed(1)).toString();
+ return Math.round(value).toString();
+}
+
+export default function NumbersVolunteerWorked({
+ isLoading,
+ data,
+ totalVolunteers = 0,
+ rangeText = '1+ hours',
+ darkMode,
+}) {
+ const count = data?.count ?? 0;
+ const rawPercentage = totalVolunteers ? (count / totalVolunteers) * 100 : 0;
+ const percentage = formatPercentage(rawPercentage);
return (
- {isLoading ? '...' : data?.count ?? 0} Volunteers worked 1+ hours over assigned time
+ {isLoading
+ ? '...'
+ : `${count} volunteer${
+ count === 1 ? '' : 's'
+ } (${percentage}%) logged ${rangeText} over the assigned time period`}
);
diff --git a/src/components/TotalOrgSummary/NumbersVolunteerWorked/__tests__/NumbersVolunteerWorked.test.jsx b/src/components/TotalOrgSummary/NumbersVolunteerWorked/__tests__/NumbersVolunteerWorked.test.jsx
new file mode 100644
index 0000000000..39a4203b91
--- /dev/null
+++ b/src/components/TotalOrgSummary/NumbersVolunteerWorked/__tests__/NumbersVolunteerWorked.test.jsx
@@ -0,0 +1,53 @@
+import { render, screen } from '@testing-library/react';
+import NumbersVolunteerWorked from '../NumbersVolunteerWorked';
+
+let container = null;
+beforeEach(() => {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+});
+
+afterEach(() => {
+ container.remove();
+ container = null;
+});
+
+describe('NumbersVolunteerWorked component', () => {
+ it('shows count, percentage and default range when totalVolunteers is provided', () => {
+ render(
+ ,
+ { container },
+ );
+ expect(screen.getByText(/5 volunteers/i)).toBeInTheDocument();
+ expect(screen.getByText(/25%/i)).toBeInTheDocument();
+ expect(screen.getByText(/1\+ hours/i)).toBeInTheDocument();
+ });
+
+ it('accepts a custom rangeText prop for the label', () => {
+ render(
+ ,
+ { container },
+ );
+ expect(screen.getByText(/2 volunteers/i)).toBeInTheDocument();
+ expect(screen.getByText(/100%/i)).toBeInTheDocument();
+ expect(screen.getByText(/10-19.99 hrs/i)).toBeInTheDocument();
+ });
+
+ it('displays ellipsis when loading', () => {
+ render(
+ ,
+ { container },
+ );
+ expect(screen.getByText(/\.\.\./)).toBeInTheDocument();
+ });
+});
diff --git a/src/components/TotalOrgSummary/TotalOrgSummary.jsx b/src/components/TotalOrgSummary/TotalOrgSummary.jsx
index cfc085613f..4bd5a758ce 100644
--- a/src/components/TotalOrgSummary/TotalOrgSummary.jsx
+++ b/src/components/TotalOrgSummary/TotalOrgSummary.jsx
@@ -28,6 +28,9 @@ import hasPermission from '~/utils/permissions';
import { getTaskAndProjectStats, getTotalOrgSummary } from '~/actions/totalOrgSummary';
import { clsx } from 'clsx';
+import VolunteerHoursDistribution, {
+ mergeHoursBuckets,
+} from './VolunteerHoursDistribution/VolunteerHoursDistribution';
import '../Header/index.css';
import AccordianWrapper from './AccordianWrapper/AccordianWrapper';
import AnniversaryCelebrated from './AnniversaryCelebrated/AnniversaryCelebrated';
@@ -39,7 +42,6 @@ import TaskCompletedBarChart from './TaskCompleted/TaskCompletedBarChart';
import TeamStats from './TeamStats/TeamStats';
import styles from './TotalOrgSummary.module.css';
import VolunteerActivities from './VolunteerActivities/VolunteerActivities';
-import VolunteerHoursDistribution from './VolunteerHoursDistribution/VolunteerHoursDistribution';
import RoleDistributionPieChart from './VolunteerRolesTeamDynamics/RoleDistributionPieChart';
import WorkDistributionBarChart from './VolunteerRolesTeamDynamics/WorkDistributionBarChart';
import VolunteerStatus from './VolunteerStatus/VolunteerStatus';
@@ -47,7 +49,6 @@ import VolunteerStatusChart from './VolunteerStatus/VolunteerStatusChart';
import VolunteerTrendsLineChart from './VolunteerTrendsLineChart/VolunteerTrendsLineChart';
function calculateStartDate() {
- // returns a string date in YYYY-MM-DD format of the start of the previous week
const currentDate = new Date();
currentDate.setHours(0, 0, 0, 0);
const dayOfWeek = currentDate.getDay();
@@ -57,7 +58,6 @@ function calculateStartDate() {
}
function calculateEndDate() {
- // returns a string date in YYYY-MM-DD format of the end of the previous week
const currentDate = new Date();
currentDate.setHours(0, 0, 0, 0);
const dayOfWeek = currentDate.getDay();
@@ -66,46 +66,395 @@ function calculateEndDate() {
return currentDate.toISOString().split('T')[0];
}
+function shiftDate(date, diffDays, type) {
+ if (type === 'Week Over Week') return new Date(date.setDate(date.getDate() - diffDays));
+ if (type === 'Month Over Month') return new Date(date.setMonth(date.getMonth() - 1));
+ if (type === 'Year Over Year') return new Date(date.setFullYear(date.getFullYear() - 1));
+ return null;
+}
+
function calculateComparisonDates(comparisonType, fromDate, toDate) {
const start = new Date(fromDate);
const end = new Date(toDate);
- const diffTime = Math.abs(end - start);
- const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
-
- switch (comparisonType) {
- case 'Week Over Week':
- return {
- comparisonStartDate: new Date(start.setDate(start.getDate() - diffDays))
- .toISOString()
- .split('T')[0],
- comparisonEndDate: new Date(end.setDate(end.getDate() - diffDays))
- .toISOString()
- .split('T')[0],
- };
- case 'Month Over Month':
- return {
- comparisonStartDate: new Date(start.setMonth(start.getMonth() - 1))
- .toISOString()
- .split('T')[0],
- comparisonEndDate: new Date(end.setMonth(end.getMonth() - 1)).toISOString().split('T')[0],
- };
- case 'Year Over Year':
- return {
- comparisonStartDate: new Date(start.setFullYear(start.getFullYear() - 1))
- .toISOString()
- .split('T')[0],
- comparisonEndDate: new Date(end.setFullYear(end.getFullYear() - 1))
- .toISOString()
- .split('T')[0],
- };
- default:
- return {
- comparisonStartDate: null,
- comparisonEndDate: null,
- };
+ const diffDays = Math.ceil(Math.abs(end - start) / (1000 * 60 * 60 * 24));
+ const shiftedStart = shiftDate(start, diffDays, comparisonType);
+ const shiftedEnd = shiftDate(end, diffDays, comparisonType);
+ return {
+ comparisonStartDate: shiftedStart ? shiftedStart.toISOString().split('T')[0] : null,
+ comparisonEndDate: shiftedEnd ? shiftedEnd.toISOString().split('T')[0] : null,
+ };
+}
+
+function validatePDFPrerequisites(volunteerStats, isLoading) {
+ if (typeof jsPDF === 'undefined' || typeof html2canvas === 'undefined') {
+ // eslint-disable-next-line no-alert
+ alert('Required PDF libraries not loaded. Please refresh the page.');
+ return false;
+ }
+ if (!volunteerStats || isLoading) {
+ // eslint-disable-next-line no-alert
+ alert('Please wait for data to load before generating PDF.');
+ return false;
+ }
+ return true;
+}
+
+function replaceCanvasesWithImages(canvasElements) {
+ const originalCanvases = [];
+ canvasElements.forEach(canvasElem => {
+ try {
+ const img = document.createElement('img');
+ img.src = canvasElem.toDataURL('image/png');
+ img.width = canvasElem.width;
+ img.height = canvasElem.height;
+ img.style.cssText = canvasElem.style.cssText;
+ originalCanvases.push({ canvas: canvasElem, parent: canvasElem.parentNode });
+ canvasElem.parentNode.replaceChild(img, canvasElem);
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.error('Error converting canvas to image:', err);
+ }
+ });
+ return originalCanvases;
+}
+
+function restoreCanvases(originalCanvases) {
+ originalCanvases.forEach(({ canvas, parent }) => {
+ const img = parent.querySelector('img');
+ if (img) img.replaceWith(canvas);
+ });
+}
+
+function copyLiveCanvasesToClone(liveCanvases, clonedCanvases) {
+ clonedCanvases.forEach(clonedCanvas => {
+ try {
+ const match = liveCanvases.find(
+ liveCanvas =>
+ liveCanvas.width === clonedCanvas.width &&
+ liveCanvas.height === clonedCanvas.height &&
+ liveCanvas.parentNode?.className === clonedCanvas.parentNode?.className,
+ );
+ if (match) {
+ const ctx = clonedCanvas.getContext('2d');
+ ctx.clearRect(0, 0, clonedCanvas.width, clonedCanvas.height);
+ ctx.drawImage(match, 0, 0);
+ }
+ if (clonedCanvas.width > 0 && clonedCanvas.height > 0) {
+ const img = document.createElement('img');
+ img.src = clonedCanvas.toDataURL('image/png');
+ img.width = clonedCanvas.width;
+ img.height = clonedCanvas.height;
+ img.style.cssText = clonedCanvas.style.cssText;
+ clonedCanvas.parentNode.replaceChild(img, clonedCanvas);
+ }
+ } catch (err) {
+ /* ignore */
+ }
+ });
+}
+
+function adjustTitleRowForPDF(clonedContent) {
+ const titleRow = clonedContent.querySelector('[data-pdf-title-row]');
+ if (!titleRow) return;
+ const titleCol = titleRow.querySelector('[data-pdf-title-col]');
+ if (titleCol) titleCol.style.width = '100%';
+ const mainTitle = titleRow.querySelector('h3');
+ if (mainTitle) {
+ mainTitle.style.fontSize = '24pt';
+ mainTitle.style.fontWeight = 'bold';
+ mainTitle.style.textAlign = 'left';
+ mainTitle.style.color = '#000';
+ mainTitle.style.margin = '0';
}
}
+function removeCollapsedSections(clonedContent) {
+ clonedContent.querySelectorAll('.Collapsible').forEach(collapsible => {
+ const trigger = collapsible.querySelector('.Collapsible__trigger');
+ if (!trigger || !trigger.classList.contains('is-open')) {
+ collapsible.remove();
+ }
+ });
+}
+
+function buildPDFContainer() {
+ const pdfContainer = document.createElement('div');
+ pdfContainer.id = 'pdf-export-container';
+ Object.assign(pdfContainer.style, {
+ width: '100%',
+ padding: '0',
+ position: 'absolute',
+ left: '-9999px',
+ top: '0',
+ zIndex: '9999',
+ boxSizing: 'border-box',
+ minHeight: '100%',
+ margin: '0',
+ });
+ return pdfContainer;
+}
+
+async function captureAndSavePDF(pdfContainer) {
+ const screenshotCanvas = await html2canvas(pdfContainer, {
+ scale: 2,
+ useCORS: true,
+ windowWidth: pdfContainer.scrollWidth,
+ windowHeight: pdfContainer.scrollHeight,
+ logging: false,
+ });
+ if (!screenshotCanvas) throw new Error('html2canvas failed to capture the content.');
+ const imgData = screenshotCanvas.toDataURL('image/png');
+ if (!imgData || imgData.length < 100) throw new Error('Invalid image data generated.');
+ const pdfWidth = 210;
+ const imgHeight = (screenshotCanvas.height * pdfWidth) / screenshotCanvas.width;
+ const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: [pdfWidth, imgHeight] });
+ doc.addImage(imgData, 'PNG', 0, 0, pdfWidth, imgHeight);
+ doc.save(`volunteer-report-${new Date().toLocaleDateString('en-CA')}.pdf`);
+}
+
+function buildPDFStyles(darkMode) {
+ const styleElem = document.createElement('style');
+ styleElem.textContent = `
+ [data-pdf-root] {
+ padding: 20px !important; margin: 0 !important; box-shadow: none !important;
+ border: none !important; width: 100% !important; min-height: 100% !important;
+ }
+ [data-pdf-title-row] {
+ display: flex !important; justify-content: space-between !important;
+ align-items: center !important; margin-bottom: 20px !important;
+ width: 100% !important; padding: 0 !important;
+ }
+ [data-pdf-block] {
+ page-break-inside: avoid; break-inside: avoid; margin: 15px 0 !important;
+ padding: 20px !important;
+ background-color: ${darkMode ? '#1C2541' : '#fff'} !important;
+ border: 1px solid ${darkMode ? '#3a3a3a' : '#e0e0e0'} !important;
+ border-radius: 10px !important;
+ box-shadow: ${
+ darkMode
+ ? '0 2px 12px 0 rgba(255,255,255,0.18), 0 1.5px 8px 0 rgba(255,255,255,0.10)'
+ : '0 2px 4px rgba(0, 0, 0, 0.08)'
+ } !important;
+ overflow: hidden !important;
+ }
+ img, svg { max-width: 100% !important; height: auto !important; page-break-inside: avoid !important; }
+ .recharts-wrapper { width: 100% !important; height: auto !important; }
+ table { page-break-inside: avoid !important; }
+ .Collapsible__trigger {
+ background-color: ${darkMode ? '#1C2541' : '#fff'} !important;
+ color: ${darkMode ? '#fff' : '#000'} !important;
+ }
+ .volunteerStatusGrid {
+ display: grid !important; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)) !important;
+ gap: 1.5rem !important; width: 100% !important; margin-top: 15px !important;
+ }
+ .component-pie-chart-label {
+ font-size: 12px !important; font-weight: 600 !important; color: #000 !important;
+ overflow: hidden !important; text-overflow: ellipsis !important;
+ }
+ [data-pdf-title] p {
+ font-size: 1.5em !important; font-weight: bold !important; text-align: center !important;
+ margin: 10px !important; color: #333 !important;
+ }
+ ${
+ darkMode
+ ? `
+ .componentContainer h1, .componentContainer h2, .componentContainer h3,
+ .componentContainer h4, .componentContainer h5, .componentContainer h6,
+ .componentContainer p, .componentContainer .totalOrgChartTitle,
+ .componentContainer .volunteerStatusGrid h3, .componentContainer .card-title,
+ .componentContainer .statistics-title, .componentContainer .Collapsible__trigger,
+ .componentContainer .volunteer-status-header, .componentContainer .volunteer-status-title {
+ color: #fff !important; text-shadow: 0 1px 4px #000, 0 0 2px #000 !important;
+ }
+ .componentContainer [data-pdf-title] p, .componentContainer [data-pdf-title] {
+ color: #fff !important; text-shadow: 0 1px 4px #000, 0 0 2px #000 !important;
+ }`
+ : ''
+ }
+ `;
+ return styleElem;
+}
+
+async function fetchOrgStats(props, selectedComparison, currentFromDate, currentToDate) {
+ const { comparisonStartDate, comparisonEndDate } = calculateComparisonDates(
+ selectedComparison,
+ currentFromDate,
+ currentToDate,
+ );
+ const volunteerStatsResponse = await props.getTotalOrgSummary(
+ currentFromDate,
+ currentToDate,
+ comparisonStartDate,
+ comparisonEndDate,
+ );
+ const taskAndProjectStatsResponse = await props.getTaskAndProjectStats(
+ currentFromDate,
+ currentToDate,
+ );
+ return { ...volunteerStatsResponse.data, taskAndProjectStats: taskAndProjectStatsResponse };
+}
+
+function getPreviousWeekDates(fromDate, toDate) {
+ const prevWeekStart = new Date(fromDate);
+ const prevWeekEnd = new Date(toDate);
+ prevWeekStart.setDate(prevWeekStart.getDate() - 7);
+ prevWeekEnd.setDate(prevWeekEnd.getDate() - 7);
+ return {
+ start: prevWeekStart.toISOString().split('T')[0],
+ end: prevWeekEnd.toISOString().split('T')[0],
+ };
+}
+
+async function generateTotalOrgPdf({ rootRef, darkMode, volunteerStats, isLoading }) {
+ if (!validatePDFPrerequisites(volunteerStats, isLoading)) return;
+ await new Promise(resolve => setTimeout(resolve, 5000));
+ const chartCanvases = document.querySelectorAll(
+ '[data-chart="volunteer-status"] canvas, [data-chart="mentor-status"] canvas',
+ );
+ const originalCanvases = replaceCanvasesWithImages(chartCanvases);
+ const pdfContainer = buildPDFContainer();
+ const clonedContent = rootRef.current.cloneNode(true);
+ clonedContent
+ .querySelectorAll('[data-pdf-hide], .controls, .no-print')
+ .forEach(el => el.remove());
+ removeCollapsedSections(clonedContent);
+ copyLiveCanvasesToClone(
+ Array.from(document.querySelectorAll('canvas')),
+ Array.from(clonedContent.querySelectorAll('canvas')),
+ );
+ adjustTitleRowForPDF(clonedContent);
+ pdfContainer.appendChild(buildPDFStyles(darkMode));
+ pdfContainer.appendChild(clonedContent);
+ document.body.appendChild(pdfContainer);
+ await captureAndSavePDF(pdfContainer);
+ restoreCanvases(originalCanvases);
+ document.body.removeChild(pdfContainer);
+}
+
+function ReportHeader({
+ darkMode,
+ selectedDateRange,
+ selectedComparison,
+ dateRangeDropdownOpen,
+ comparisonDropdownOpen,
+ isGeneratingPDF,
+ onDateRangeToggle,
+ onComparisonToggle,
+ onDateRangeSelect,
+ onComparisonSelect,
+ onSavePDF,
+}) {
+ return (
+
+
+
Total Org Summary
+
+
+
+ {selectedDateRange}
+
+ onDateRangeSelect('Current Week')}>
+ Current Week
+
+ onDateRangeSelect('Previous Week')}>
+ Previous Week
+
+ onDateRangeSelect('Select Date Range')}>
+ Select Date Range
+
+
+
+
+ {selectedComparison}
+
+ onComparisonSelect('No Comparison')}>
+ No Comparison
+
+ onComparisonSelect('Week Over Week')}>
+ Week Over Week
+
+ onComparisonSelect('Month Over Month')}>
+ Month Over Month
+
+ onComparisonSelect('Year Over Year')}>
+ Year Over Year
+
+
+
+
+
+
+ );
+}
+
+function DateRangeModal({
+ showDatePicker,
+ startDate,
+ endDate,
+ onToggle,
+ onStartChange,
+ onEndChange,
+ onCancel,
+ onApply,
+}) {
+ return (
+
+ Select Date Range
+
+
+
+
+
+
+
+
+ );
+}
+
const fromDate = calculateStartDate();
const toDate = calculateEndDate();
@@ -185,293 +534,9 @@ function TotalOrgSummary(props) {
const handleSaveAsPDF = async () => {
if (isGeneratingPDF) return;
-
setIsGeneratingPDF(true);
-
try {
- // Ensure required libraries are present.
- if (typeof jsPDF === 'undefined' || typeof html2canvas === 'undefined') {
- // eslint-disable-next-line no-alert
- alert('Required PDF libraries not loaded. Please refresh the page.');
- return;
- }
-
- // Ensure data is ready.
- if (!volunteerStats || isLoading) {
- // eslint-disable-next-line no-alert
- alert('Please wait for data to load before generating PDF.');
- return;
- }
-
- // 2. Wait longer for charts/maps to render (5 seconds)
- await new Promise(resolve => setTimeout(resolve, 5000));
-
- // 3. Replace Chart.js canvas elements with images in the live DOM.
- const chartCanvases = document.querySelectorAll(
- '[data-chart="volunteer-status"] canvas, [data-chart="mentor-status"] canvas',
- );
- const originalCanvases = [];
- chartCanvases.forEach(canvasElem => {
- try {
- const img = document.createElement('img');
- img.src = canvasElem.toDataURL('image/png');
- img.width = canvasElem.width;
- img.height = canvasElem.height;
- img.style.cssText = canvasElem.style.cssText;
- originalCanvases.push({
- canvas: canvasElem,
- parent: canvasElem.parentNode,
- });
- canvasElem.parentNode.replaceChild(img, canvasElem);
- } catch (err) {
- // eslint-disable-next-line no-console
- console.error('Error converting canvas to image:', err);
- }
- });
-
- // 4. Create a temporary container for PDF generation
- const pdfContainer = document.createElement('div');
- pdfContainer.id = 'pdf-export-container';
- pdfContainer.style.width = '100%';
- pdfContainer.style.padding = '0';
- pdfContainer.style.position = 'absolute';
- pdfContainer.style.left = '-9999px';
- pdfContainer.style.top = '0';
- pdfContainer.style.zIndex = '9999';
- pdfContainer.style.boxSizing = 'border-box';
- pdfContainer.style.minHeight = '100%';
- pdfContainer.style.margin = '0';
-
- // Clone the main content area
-
- const originalContent = rootRef.current;
- const clonedContent = originalContent.cloneNode(true);
-
- // Remove interactive or unwanted elements from the clone
-
- // Remove interactive or unwanted elements from the clone
- clonedContent
- .querySelectorAll('[data-pdf-hide], .controls, .no-print')
- .forEach(el => el.remove());
-
- // Remove all collapsed AccordianWrapper (Collapsible) sections from the PDF
- clonedContent.querySelectorAll('.Collapsible').forEach(collapsible => {
- // If it does NOT have the 'is-open' class, it's collapsed
- const trigger = collapsible.querySelector('.Collapsible__trigger');
- if (!trigger || !trigger.classList.contains('is-open')) {
- collapsible.remove();
- }
- });
-
- // Copy canvas bitmap from live DOM to cloned DOM before converting to image
- // This includes Chart.js, Leaflet, and heatmap overlays
- const liveCanvases = Array.from(document.querySelectorAll('canvas'));
- const clonedCanvases = Array.from(clonedContent.querySelectorAll('canvas'));
- clonedCanvases.forEach(clonedCanvas => {
- try {
- // Try to find a matching live canvas by size and position
- const match = liveCanvases.find(
- liveCanvas =>
- liveCanvas.width === clonedCanvas.width &&
- liveCanvas.height === clonedCanvas.height &&
- liveCanvas.parentNode?.className === clonedCanvas.parentNode?.className,
- );
- if (match) {
- const ctx = clonedCanvas.getContext('2d');
- ctx.clearRect(0, 0, clonedCanvas.width, clonedCanvas.height);
- ctx.drawImage(match, 0, 0);
- }
- // Now convert to image if canvas has content
- if (clonedCanvas.width > 0 && clonedCanvas.height > 0) {
- const img = document.createElement('img');
- img.src = clonedCanvas.toDataURL('image/png');
- img.width = clonedCanvas.width;
- img.height = clonedCanvas.height;
- img.style.cssText = clonedCanvas.style.cssText;
- clonedCanvas.parentNode.replaceChild(img, clonedCanvas);
- }
- } catch (err) {
- /* ignore */
- }
- });
-
- // Adjust title row styling for a clean layout
- const titleRow = clonedContent.querySelector('[data-pdf-title-row]');
- if (titleRow) {
- const titleCol = titleRow.querySelector('[data-pdf-title-col]');
- if (titleCol) {
- titleCol.style.width = '100%';
- }
- const mainTitle = titleRow.querySelector('h3');
- if (mainTitle) {
- mainTitle.style.fontSize = '24pt';
- mainTitle.style.fontWeight = 'bold';
- mainTitle.style.textAlign = 'left';
- mainTitle.style.color = '#000';
- mainTitle.style.margin = '0';
- }
- }
-
- // Create a style element for the PDF container
- const styleElem = document.createElement('style');
- styleElem.textContent = `
- [data-pdf-root] {
- padding: 20px !important;
- margin: 0 !important;
- box-shadow: none !important;
- border: none !important;
- width: 100% !important;
- min-height: 100% !important;
- }
-
- [data-pdf-title-row] {
- display: flex !important;
- justify-content: space-between !important;
- align-items: center !important;
- margin-bottom: 20px !important;
- width: 100% !important;
- padding: 0 !important;
- }
-
- /* PDF block container: border and shadow, dark mode fidelity */
- [data-pdf-block] {
- page-break-inside: avoid;
- break-inside: avoid;
- margin: 15px 0 !important;
- padding: 20px !important;
- background-color: ${darkMode ? '#1C2541' : '#fff'} !important;
- border: 1px solid ${darkMode ? '#3a3a3a' : '#e0e0e0'} !important;
- border-radius: 10px !important;
- box-shadow: ${
- darkMode
- ? '0 2px 12px 0 rgba(255,255,255,0.18), 0 1.5px 8px 0 rgba(255,255,255,0.10)'
- : '0 2px 4px rgba(0, 0, 0, 0.08)'
- } !important;
- overflow: hidden !important;
- }
-
- img, svg {
- max-width: 100% !important;
- height: auto !important;
- page-break-inside: avoid !important;
- }
-
- .recharts-wrapper {
- width: 100% !important;
- height: auto !important;
- }
-
- table {
- page-break-inside: avoid !important;
- }
-
- .Collapsible__trigger {
- background-color: ${darkMode ? '#1C2541' : '#fff'} !important;
- color: ${darkMode ? '#fff' : '#000'} !important;
- }
-
- .volunteerStatusGrid {
- display: grid !important;
- grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)) !important;
- gap: 1.5rem !important;
- width: 100% !important;
- margin-top: 15px !important;
- }
-
- .component-pie-chart-label {
- font-size: 12px !important;
- font-weight: 600 !important;
- color: #000 !important;
- overflow: hidden !important;
- text-overflow: ellipsis !important;
- }
-
- [data-pdf-title] p {
- font-size: 1.5em !important;
- font-weight: bold !important;
- text-align: center !important;
- margin: 10px !important;
- color: #333 !important;
- }
-
- /* Force all chart and card titles to white in dark mode for PDF */
-${
- darkMode
- ? `
- .componentContainer h1,
- .componentContainer h2,
- .componentContainer h3,
- .componentContainer h4,
- .componentContainer h5,
- .componentContainer h6,
- .componentContainer p,
- .componentContainer .totalOrgChartTitle,
- .componentContainer .volunteerStatusGrid h3,
- .componentContainer .card-title,
- .componentContainer .statistics-title,
- .componentContainer .Collapsible__trigger,
- .componentContainer .volunteer-status-header,
- .componentContainer .volunteer-status-title {
- color: #fff !important;
- text-shadow: 0 1px 4px #000, 0 0 2px #000 !important;
- }
- .componentContainer [data-pdf-title] p {
- color: #fff !important;
- text-shadow: 0 1px 4px #000, 0 0 2px #000 !important;
- }
- .componentContainer [data-pdf-title] {
- color: #fff !important;
- text-shadow: 0 1px 4px #000, 0 0 2px #000 !important;
- }
-`
- : ''
-}
- `;
-
- // Add content to the PDF container
- pdfContainer.appendChild(styleElem);
- pdfContainer.appendChild(clonedContent);
- document.body.appendChild(pdfContainer);
-
- // 5. Use html2canvas to capture the rendered container
- const screenshotCanvas = await html2canvas(pdfContainer, {
- scale: 2,
- useCORS: true,
- windowWidth: pdfContainer.scrollWidth,
- windowHeight: pdfContainer.scrollHeight,
- logging: false,
- });
-
- if (!screenshotCanvas) {
- throw new Error('html2canvas failed to capture the content.');
- }
-
- const imgData = screenshotCanvas.toDataURL('image/png');
- if (!imgData || imgData.length < 100) {
- throw new Error('Invalid image data generated.');
- }
-
- // 6. Create a single-page PDF
- const pdfWidth = 210; // A4 width in mm
- const imgHeight = (screenshotCanvas.height * pdfWidth) / screenshotCanvas.width;
- const doc = new jsPDF({
- orientation: 'portrait',
- unit: 'mm',
- format: [pdfWidth, imgHeight],
- });
- doc.addImage(imgData, 'PNG', 0, 0, pdfWidth, imgHeight);
- const now = new Date();
- const localDate = now.toLocaleDateString('en-CA'); // Using YYYY-MM-DD format
- doc.save(`volunteer-report-${localDate}.pdf`);
-
- // Cleanup: restore original canvases and remove temporary container
- originalCanvases.forEach(({ canvas, parent }) => {
- const img = parent.querySelector('img');
- if (img) {
- parent.replaceChild(canvas, img);
- }
- });
- document.body.removeChild(pdfContainer);
+ await generateTotalOrgPdf({ rootRef, darkMode, volunteerStats, isLoading });
} catch (pdfError) {
// eslint-disable-next-line no-console
console.error('PDF generation failed:', pdfError);
@@ -485,22 +550,18 @@ ${
const handleDateRangeSelect = option => {
if (option === 'Select Date Range') {
setShowDatePicker(true);
- } else {
- setSelectedDateRange(option);
- setShowDatePicker(false);
- setSelectedComparison('No Comparison');
-
- if (option === 'Current Week') {
- setCurrentFromDate(fromDate);
- setCurrentToDate(toDate);
- } else if (option === 'Previous Week') {
- const prevWeekStart = new Date(fromDate);
- const prevWeekEnd = new Date(toDate);
- prevWeekStart.setDate(prevWeekStart.getDate() - 7);
- prevWeekEnd.setDate(prevWeekEnd.getDate() - 7);
- setCurrentFromDate(prevWeekStart.toISOString().split('T')[0]);
- setCurrentToDate(prevWeekEnd.toISOString().split('T')[0]);
- }
+ return;
+ }
+ setSelectedDateRange(option);
+ setShowDatePicker(false);
+ setSelectedComparison('No Comparison');
+ if (option === 'Current Week') {
+ setCurrentFromDate(fromDate);
+ setCurrentToDate(toDate);
+ } else if (option === 'Previous Week') {
+ const { start, end } = getPreviousWeekDates(fromDate, toDate);
+ setCurrentFromDate(start);
+ setCurrentToDate(end);
}
};
@@ -509,7 +570,6 @@ ${
setSelectedDateRange(`${startDate.toLocaleDateString()} - ${endDate.toLocaleDateString()}`);
setShowDatePicker(false);
setSelectedComparison('No Comparison');
-
setCurrentFromDate(startDate.toISOString().split('T')[0]);
setCurrentToDate(endDate.toISOString().split('T')[0]);
}
@@ -517,12 +577,7 @@ ${
if (error || isVolunteerFetchingError) {
return (
-
+
-
-
-
Total Org Summary
-
-
- setDateRangeDropdownOpen(!dateRangeDropdownOpen)}
- >
- {selectedDateRange}
-
- handleDateRangeSelect('Current Week')}>
- Current Week
-
- handleDateRangeSelect('Previous Week')}>
- Previous Week
-
- handleDateRangeSelect('Select Date Range')}>
- Select Date Range
-
-
-
- setComparisonDropdownOpen(!comparisonDropdownOpen)}
- >
- {selectedComparison}
-
- setSelectedComparison('No Comparison')}>
- No Comparison
-
- setSelectedComparison('Week Over Week')}>
- Week Over Week
-
- setSelectedComparison('Month Over Month')}>
- Month Over Month
-
- setSelectedComparison('Year Over Year')}>
- Year Over Year
-
-
-
-
-
-
-
-
setShowDatePicker(!showDatePicker)}>
- setShowDatePicker(!showDatePicker)}>
- Select Date Range
-
-
-
-
-
-
- setStartDate(date)}
- className="form-control"
- dateFormat="MM/dd/yyyy"
- placeholderText="Select start date"
- />
-
-
-
-
-
-
- setEndDate(date)}
- className="form-control"
- dateFormat="MM/dd/yyyy"
- placeholderText="Select end date"
- minDate={startDate}
- />
-
-
-
-
-
-
-
-
-
+
setDateRangeDropdownOpen(!dateRangeDropdownOpen)}
+ onComparisonToggle={() => setComparisonDropdownOpen(!comparisonDropdownOpen)}
+ onDateRangeSelect={handleDateRangeSelect}
+ onComparisonSelect={setSelectedComparison}
+ onSavePDF={handleSaveAsPDF}
+ />
+ setShowDatePicker(!showDatePicker)}
+ onStartChange={date => setStartDate(date)}
+ onEndChange={date => setEndDate(date)}
+ onCancel={() => setShowDatePicker(false)}
+ onApply={handleDatePickerSubmit}
+ />
@@ -756,12 +728,18 @@ ${
darkMode={darkMode}
hoursData={volunteerStats?.volunteerHoursStats}
totalHoursData={volunteerStats?.totalHoursWorked}
- comparisonType={selectedComparison}
/>
sum + (Number(bucket?.count) || 0),
+ 0,
+ ),
+ }}
+ totalVolunteers={volunteerStats?.volunteerNumberStats?.totalVolunteers?.count}
+ rangeText="1+ hours"
darkMode={darkMode}
/>
diff --git a/src/components/TotalOrgSummary/TotalOrgSummary.module.css b/src/components/TotalOrgSummary/TotalOrgSummary.module.css
index e0d58cb125..7d6a4d3a74 100644
--- a/src/components/TotalOrgSummary/TotalOrgSummary.module.css
+++ b/src/components/TotalOrgSummary/TotalOrgSummary.module.css
@@ -1,10 +1,13 @@
+/* stylelint-disable no-descending-specificity */
+
/* Chart labels stay dark in dark mode for readability against light label chips */
.containerTotalOrgWrapper:global(.bg-oxford-blue) .componentContainer .recharts-wrapper text,
.containerTotalOrgWrapper:global(.bg-oxford-blue) .componentContainer .recharts-wrapper tspan {
fill: #000 !important;
color: #000 !important;
- text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.25), 0 0 2px #fff;
+ text-shadow: 1px 1px 3px rgb(0 0 0 / 25%), 0 0 2px #fff;
}
+
/* Chart and graph titles/text should be white in dark mode for visibility */
.containerTotalOrgWrapper:global(.bg-oxford-blue) .componentContainer h3,
.containerTotalOrgWrapper:global(.bg-oxford-blue) .componentContainer p,
@@ -12,6 +15,7 @@
.containerTotalOrgWrapper:global(.bg-oxford-blue) .componentContainer .volunteerStatusGrid h3 {
color: #fff !important;
}
+
.containerTotalOrgWrapper {
min-height: 100%;
background-color: #fff;
@@ -109,13 +113,13 @@
:global(.dropdown-toggle):hover {
background-color: #5a359a !important;
transform: translateY(-2px) !important;
- box-shadow: 0 4px 12px rgba(111, 66, 193, 0.4) !important;
+ box-shadow: 0 4px 12px rgb(111 66 193 / 40%) !important;
}
.containerTotalOrgWrapper:global(.bg-oxford-blue) .reportHeaderActions :global(.dropdown-menu) {
background-color: #34495e !important;
border: 1px solid #6f42c1 !important;
- box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3) !important;
+ box-shadow: 0 8px 25px rgb(0 0 0 / 30%) !important;
}
.containerTotalOrgWrapper:global(.bg-oxford-blue) .reportHeaderActions :global(.dropdown-item) {
@@ -135,13 +139,13 @@
.containerTotalOrgWrapper:global(.bg-oxford-blue) .reportHeaderActions button {
background-color: #007bff !important;
color: white !important;
- box-shadow: 0 2px 6px rgba(0, 123, 255, 0.3) !important;
+ box-shadow: 0 2px 6px rgb(0 123 255 / 30%) !important;
}
.containerTotalOrgWrapper:global(.bg-oxford-blue) .reportHeaderActions button:hover {
background-color: #0056b3 !important;
transform: translateY(-2px) !important;
- box-shadow: 0 4px 12px rgba(0, 123, 255, 0.4) !important;
+ box-shadow: 0 4px 12px rgb(0 123 255 / 40%) !important;
}
.containerTotalOrgWrapper:global(.bg-oxford-blue) .componentContainer {
@@ -188,13 +192,37 @@
fill: #fff !important;
}
+/* Explicit overrides for Volunteer Hours Distribution slice labels.
+ These must win over global forced-white chart text rules above. */
+.containerTotalOrgWrapper:global(.bg-oxford-blue)
+ .componentContainer
+ :global(.hours-distribution-label-dark),
+.containerTotalOrgWrapper:global(.bg-oxford-blue)
+ .componentContainer
+ :global(.hours-distribution-label-dark)
+ tspan {
+ fill: #111 !important;
+ color: #111 !important;
+}
+
+.containerTotalOrgWrapper:global(.bg-oxford-blue)
+ .componentContainer
+ :global(.hours-distribution-label-light),
+.containerTotalOrgWrapper:global(.bg-oxford-blue)
+ .componentContainer
+ :global(.hours-distribution-label-light)
+ tspan {
+ fill: #fff !important;
+ color: #fff !important;
+}
+
.containerTotalOrgWrapper.bg-oxford-blue h3 {
color: #fff;
}
.pageRow {
width: 100% !important;
- margin: 1px 10px 15px 10px;
+ margin: 1px 10px 15px;
}
/* Header section styles */
@@ -243,8 +271,9 @@
/* Dark mode header styling */
.containerTotalOrgWrapper:global(.bg-oxford-blue) .reportHeaderTitle h3 {
- color: #ffffff !important;
+ color: #fff !important;
}
+
.reportHeaderActions {
display: flex;
align-items: center;
@@ -275,18 +304,18 @@
min-width: 120px !important;
background-color: #6f42c1 !important;
color: white !important;
- box-shadow: 0 2px 6px rgba(111, 66, 193, 0.3) !important;
+ box-shadow: 0 2px 6px rgb(111 66 193 / 30%) !important;
}
.reportHeaderActions :global(.dropdown-toggle):hover {
background-color: #5a359a !important;
transform: translateY(-2px) !important;
- box-shadow: 0 4px 12px rgba(111, 66, 193, 0.4) !important;
+ box-shadow: 0 4px 12px rgb(111 66 193 / 40%) !important;
}
.reportHeaderActions :global(.dropdown-toggle):focus {
outline: none !important;
- box-shadow: 0 0 0 3px rgba(111, 66, 193, 0.3) !important;
+ box-shadow: 0 0 0 3px rgb(111 66 193 / 30%) !important;
}
/* Beautiful button styling */
@@ -304,7 +333,7 @@
min-height: 44px !important;
background-color: #007bff !important;
color: white !important;
- box-shadow: 0 2px 6px rgba(0, 123, 255, 0.3) !important;
+ box-shadow: 0 2px 6px rgb(0 123 255 / 30%) !important;
text-transform: none !important;
letter-spacing: 0.3px !important;
}
@@ -312,7 +341,7 @@
.reportHeaderActions button:hover {
background-color: #0056b3 !important;
transform: translateY(-2px) !important;
- box-shadow: 0 4px 12px rgba(0, 123, 255, 0.4) !important;
+ box-shadow: 0 4px 12px rgb(0 123 255 / 40%) !important;
}
.reportHeaderActions button:disabled {
@@ -320,19 +349,19 @@
cursor: not-allowed !important;
opacity: 0.6 !important;
transform: none !important;
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1) !important;
+ box-shadow: 0 2px 4px rgb(0 0 0 / 10%) !important;
}
.reportHeaderActions button:focus {
outline: none !important;
- box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.3) !important;
+ box-shadow: 0 0 0 3px rgb(0 123 255 / 30%) !important;
}
/* Enhanced dropdown menu styling */
.reportHeaderActions :global(.dropdown-menu) {
border: none !important;
border-radius: 12px !important;
- box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15) !important;
+ box-shadow: 0 8px 25px rgb(0 0 0 / 15%) !important;
padding: 8px 0 !important;
margin-top: 8px !important;
min-width: 180px !important;
@@ -355,14 +384,15 @@
}
/* Dark mode dropdown consistency */
+
/* Component containers - Clean borderless design */
.componentContainer {
- margin: 0 0 15px 0;
+ margin: 0 0 15px;
padding: 20px;
background-color: #fff;
border-radius: 10px;
border: 1.5px solid #e0e0e0;
- box-shadow: 0 4px 16px rgba(37, 99, 235, 0.1), 0 1.5px 4px rgba(0, 0, 0, 0.08);
+ box-shadow: 0 4px 16px rgb(37 99 235 / 10%), 0 1.5px 4px rgb(0 0 0 / 8%);
height: 100%;
display: flex;
flex-direction: column;
@@ -373,7 +403,7 @@
.componentBorder {
border: 1.5px solid #e0e0e0;
border-radius: 10px;
- box-shadow: 0 4px 16px rgba(37, 99, 235, 0.1), 0 1.5px 4px rgba(0, 0, 0, 0.08);
+ box-shadow: 0 4px 16px rgb(37 99 235 / 10%), 0 1.5px 4px rgb(0 0 0 / 8%);
background-color: #fff;
overflow: hidden;
height: 100%;
@@ -386,10 +416,11 @@
.componentBorderLoose {
overflow: visible;
}
+
.containerTotalOrgWrapper:global(.bg-oxford-blue) .componentBorder {
background-color: #1c2541 !important;
border: 1.5px solid #2f4157 !important;
- box-shadow: 0 4px 24px rgba(255, 255, 255, 0.13), 0 1.5px 4px rgba(255, 255, 255, 0.1) !important;
+ box-shadow: 0 4px 24px rgb(255 255 255 / 13%), 0 1.5px 4px rgb(255 255 255 / 10%) !important;
}
/* Grid layouts */
@@ -421,6 +452,7 @@
}
/* Report Header Styles */
+
/* .totalOrgReportHeaderRow {
display: flex;
justify-content: space-between;
@@ -671,3 +703,5 @@
padding-left: 1% !important;
}
}
+
+/* stylelint-enable no-descending-specificity */
diff --git a/src/components/TotalOrgSummary/VolunteerHoursDistribution/VolunteerHoursDistribution.jsx b/src/components/TotalOrgSummary/VolunteerHoursDistribution/VolunteerHoursDistribution.jsx
index bb5221a30b..c96c3eba24 100644
--- a/src/components/TotalOrgSummary/VolunteerHoursDistribution/VolunteerHoursDistribution.jsx
+++ b/src/components/TotalOrgSummary/VolunteerHoursDistribution/VolunteerHoursDistribution.jsx
@@ -6,6 +6,97 @@ import Loading from '../../common/Loading';
const COLORS = ['#00AFF4', '#FFA500', '#00B030', '#EC52CB', '#F8FF00'];
+function parseRangeStart(rangeStr) {
+ if (!rangeStr) return 0;
+ const [first] = String(rangeStr).split(/[-+]/);
+ const parsed = Number(first);
+ return Number.isFinite(parsed) ? parsed : 0;
+}
+
+function normalizeBucketId(rangeStr) {
+ if (!rangeStr) return '';
+ const trimmed = String(rangeStr).trim();
+ if (trimmed.includes('+')) {
+ const start = parseRangeStart(trimmed);
+ return start === 40 ? '50+' : `${start}+`;
+ }
+ if (trimmed.includes('-')) {
+ const start = parseRangeStart(trimmed);
+ if (start === 40) return '40';
+ return String(start);
+ }
+ return String(parseRangeStart(trimmed));
+}
+
+function mergeHoursBuckets(hoursData) {
+ const safeHoursData = Array.isArray(hoursData) ? hoursData : [];
+ const merged = new Map();
+ safeHoursData.forEach(item => {
+ const normalizedId = normalizeBucketId(item?._id);
+ if (!normalizedId) return;
+ const existing = merged.get(normalizedId) || 0;
+ merged.set(normalizedId, existing + (Number(item?.count) || 0));
+ });
+ return [...merged.entries()]
+ .map(([id, count]) => ({ _id: id, count }))
+ .sort((a, b) => parseRangeStart(a._id) - parseRangeStart(b._id));
+}
+
+function allocateRoundedHoursByCount(normalizedHoursData, totalHoursWorked) {
+ const roundedTotalHours = Math.max(0, Math.round(Number(totalHoursWorked) || 0));
+ const totalCount = normalizedHoursData.reduce(
+ (sum, bucket) => sum + (Number(bucket.count) || 0),
+ 0,
+ );
+
+ if (!totalCount || !roundedTotalHours) {
+ return normalizedHoursData.map(bucket => ({ ...bucket, allocatedHours: 0 }));
+ }
+
+ const provisional = normalizedHoursData.map(bucket => {
+ const count = Number(bucket.count) || 0;
+ const exact = (count / totalCount) * roundedTotalHours;
+ const base = Math.floor(exact);
+ return { ...bucket, allocatedHours: base, remainder: exact - base };
+ });
+
+ let assigned = provisional.reduce((sum, bucket) => sum + bucket.allocatedHours, 0);
+ let remaining = roundedTotalHours - assigned;
+
+ const byRemainderDesc = [...provisional].sort((a, b) => b.remainder - a.remainder);
+ let i = 0;
+ while (remaining > 0 && byRemainderDesc.length > 0) {
+ byRemainderDesc[i % byRemainderDesc.length].allocatedHours += 1;
+ remaining -= 1;
+ i += 1;
+ }
+
+ return byRemainderDesc
+ .map(({ remainder, ...bucket }) => bucket)
+ .sort((a, b) => parseRangeStart(a._id) - parseRangeStart(b._id));
+}
+
+// convert backend range string (e.g. "10", "40+", "20-29")
+// into a user-facing label with units.
+export function formatRangeLabel(rangeStr) {
+ if (!rangeStr) return '';
+ const normalizedRange = normalizeBucketId(rangeStr);
+ let displayName = '';
+ if (normalizedRange.includes('+')) {
+ const num = parseFloat(normalizedRange.replace('+', ''));
+ if (num === 40) {
+ displayName = '50+ hrs';
+ } else {
+ displayName = `${num}+ hrs`;
+ }
+ } else {
+ const num = parseFloat(normalizedRange);
+ const next = (num + 9.99).toFixed(2);
+ displayName = `${num}-${next} hrs`;
+ }
+ return displayName;
+}
+
function HoursWorkList({ data, darkMode }) {
if (!data) return ;
@@ -13,11 +104,16 @@ function HoursWorkList({ data, darkMode }) {
const rangeStr = elem._id;
const entry = {
name: rangeStr,
+ count: elem.count,
};
- const rangeArr = rangeStr.split('-');
+ // derive human-readable label for the bucket
+ const displayName = formatRangeLabel(rangeStr);
+
+ entry.displayName = displayName;
entry.color = COLORS[index];
+ const rangeArr = rangeStr.split('-');
if (rangeArr.length > 1) {
const [min, max] = rangeArr;
entry.min = Number(min);
@@ -55,12 +151,35 @@ function HoursWorkList({ data, darkMode }) {
);
}
+// export HoursWorkList separately for testing
+export { HoursWorkList };
+
+// shared helper: derives normalizedHoursData, userData, and totals from raw API data
+function buildChartData(hoursData, totalHoursData) {
+ const normalizedHoursData = mergeHoursBuckets(hoursData);
+ const totalVolunteers = normalizedHoursData.reduce((total, cur) => total + (cur.count || 0), 0);
+ const totalHoursWorked = Number(totalHoursData?.current ?? totalHoursData?.count ?? 0);
+ const hoursByBucket = allocateRoundedHoursByCount(normalizedHoursData, totalHoursWorked);
+ const totalAllocatedHours = hoursByBucket.reduce(
+ (sum, bucket) => sum + (bucket.allocatedHours || 0),
+ 0,
+ );
+ const userData = hoursByBucket.map(range => {
+ const value = range.allocatedHours || 0;
+ return {
+ name: range._id,
+ value,
+ percentage: totalAllocatedHours ? Math.round((value / totalAllocatedHours) * 100) : 0,
+ };
+ });
+ return { normalizedHoursData, userData, totalVolunteers, totalHoursWorked };
+}
+
export default function VolunteerHoursDistribution({
isLoading,
darkMode,
hoursData,
totalHoursData,
- comparisonType,
}) {
const [windowSize, setWindowSize] = useState({
width: window.innerWidth,
@@ -91,17 +210,10 @@ export default function VolunteerHoursDistribution({
);
}
- const totalHours = hoursData.reduce((total, cur) => total + cur.count, 0);
-
- const userData = hoursData.map(range => {
- return {
- name: range._id,
- value: range.count,
- totalHours,
- title: 'HOURS WORKED',
- comparisonPercentage: totalHoursData.comparison,
- };
- });
+ const { normalizedHoursData, userData, totalHoursWorked } = buildChartData(
+ hoursData,
+ totalHoursData,
+ );
return (
-
+
);
}
+
+// computeDistribution: pure helper to derive the chart payload from API data
+export function computeDistribution(hoursData, totalHoursData) {
+ const { userData, totalVolunteers, totalHoursWorked } = buildChartData(hoursData, totalHoursData);
+ return { userData, totalVolunteers, totalHoursWorked };
+}
+
+export { mergeHoursBuckets };
diff --git a/src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/HoursWorkList.test.jsx b/src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/HoursWorkList.test.jsx
new file mode 100644
index 0000000000..6cd9a37060
--- /dev/null
+++ b/src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/HoursWorkList.test.jsx
@@ -0,0 +1,32 @@
+import { render, screen } from '@testing-library/react';
+import { HoursWorkList } from '../VolunteerHoursDistribution';
+
+let container = null;
+beforeEach(() => {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+});
+
+afterEach(() => {
+ container.remove();
+ container = null;
+});
+
+describe('HoursWorkList label formatting', () => {
+ it('renders plain bucket ids in the legend and merges 40+ into 50+', () => {
+ const mockData = [
+ { _id: '10', count: 5 },
+ { _id: '40', count: 2 },
+ { _id: '50+', count: 3 },
+ { _id: '40+', count: 1 },
+ ];
+
+ render(, { container });
+
+ // legend now shows plain bucket IDs (no range suffix, no count)
+ expect(screen.getByText('10')).toBeInTheDocument();
+ expect(screen.getByText('40')).toBeInTheDocument();
+ // 50+ and 40+ are merged into a single 50+ bucket
+ expect(screen.getByText('50+')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/VolunteerHoursDistribution.test.jsx b/src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/VolunteerHoursDistribution.test.jsx
new file mode 100644
index 0000000000..970fbc36cc
--- /dev/null
+++ b/src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/VolunteerHoursDistribution.test.jsx
@@ -0,0 +1,53 @@
+// Note: render real chart in a sized container so Recharts can mount in tests.
+
+import { render, screen } from '@testing-library/react';
+import React from 'react';
+
+import VolunteerHoursDistribution, { computeDistribution } from '../VolunteerHoursDistribution';
+
+let container = null;
+beforeEach(() => {
+ container = document.createElement('div');
+ // give the container explicit size so ResponsiveContainer can compute dimensions
+ container.style.width = '800px';
+ container.style.height = '600px';
+ document.body.appendChild(container);
+});
+afterEach(() => {
+ container.remove();
+ container = null;
+});
+
+describe('VolunteerHoursDistribution wrapper', () => {
+ it('passes totalHoursData.current to child and computes userData percentages', () => {
+ const hoursData = [
+ { _id: '10', count: 2 },
+ { _id: '20', count: 3 },
+ ];
+ const totalHoursData = { current: 1234 };
+ render(
+ ,
+ { container },
+ );
+
+ // legend now shows plain bucket IDs only
+ expect(screen.getByText('10')).toBeInTheDocument();
+ expect(screen.getByText('20')).toBeInTheDocument();
+
+ // verify computeDistribution now allocates hours to buckets so slices add up to total hours
+ const computed = computeDistribution(hoursData, totalHoursData);
+ expect(computed).toEqual({
+ userData: [
+ { name: '10', value: 494, percentage: 40 },
+ { name: '20', value: 740, percentage: 60 },
+ ],
+ totalVolunteers: 5,
+ totalHoursWorked: 1234,
+ });
+ });
+});
diff --git a/src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/formatRangeLabel.test.jsx b/src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/formatRangeLabel.test.jsx
new file mode 100644
index 0000000000..b95e7b5154
--- /dev/null
+++ b/src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/formatRangeLabel.test.jsx
@@ -0,0 +1,19 @@
+import { formatRangeLabel } from '../VolunteerHoursDistribution';
+
+describe('formatRangeLabel helper', () => {
+ it('formats simple numeric ranges correctly', () => {
+ expect(formatRangeLabel('10')).toBe('10-19.99 hrs');
+ expect(formatRangeLabel('0')).toBe('0-9.99 hrs');
+ });
+
+ it('formats open-ended ranges correctly', () => {
+ expect(formatRangeLabel('50+')).toBe('50+ hrs');
+ expect(formatRangeLabel('40+')).toBe('50+ hrs'); // special-case remap
+ });
+
+ it('handles empty or undefined input gracefully', () => {
+ expect(formatRangeLabel('')).toBe('');
+ expect(formatRangeLabel(null)).toBe('');
+ expect(formatRangeLabel(undefined)).toBe('');
+ });
+});
diff --git a/src/components/__tests__/CustomTooltip.test.jsx b/src/components/__tests__/CustomTooltip.test.jsx
new file mode 100644
index 0000000000..53ccfa2347
--- /dev/null
+++ b/src/components/__tests__/CustomTooltip.test.jsx
@@ -0,0 +1,31 @@
+import { render, screen } from '@testing-library/react';
+import CustomTooltip from '../CustomTooltip';
+
+let container = null;
+beforeEach(() => {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+});
+afterEach(() => {
+ container.remove();
+ container = null;
+});
+
+describe('CustomTooltip', () => {
+ const payload = [{ payload: { name: 'Bucket', value: 7, percentage: 70 } }];
+
+ it('renders hours and percentage when hoursDistribution type', () => {
+ render(, container);
+
+ expect(screen.getByText(/Bucket/)).toBeInTheDocument();
+ expect(screen.getByText(/Hours: 7/)).toBeInTheDocument();
+ expect(screen.getByText(/Percentage: 70%/)).toBeInTheDocument();
+ });
+
+ it('falls back to total hours when no tooltipType provided', () => {
+ const payload2 = [{ payload: { name: 'Foo', totalHours: 12, percentage: 50 } }];
+ render(, container);
+ expect(screen.getByText(/Total Hours: 12/)).toBeInTheDocument();
+ expect(screen.getByText(/Percentage: 50%/)).toBeInTheDocument();
+ });
+});