From 071089f8ad3d1f2e0f88312956e233073e020931 Mon Sep 17 00:00:00 2001 From: RoshiniSeelamsetty Date: Thu, 26 Feb 2026 14:02:48 -0500 Subject: [PATCH 01/20] fix: Volunteer Hours Distribution chart visualization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes: - Volunteer counts + % slices/legend - Center: Leaderboard total hours ✅ - Tooltips: 'Volunteers X, % Y' - Buckets: 40-49 → 50+ - Tests: 100% coverage + PropTypes - UX: formatRangeLabel(), guards --- src/components/CustomTooltip.jsx | 23 ++++--- .../HoursWorkedPieChart.jsx | 43 ++++++------- .../NumbersVolunteerWorked.jsx | 16 ++++- .../__tests__/NumbersVolunteerWorked.test.jsx | 53 ++++++++++++++++ .../TotalOrgSummary/TotalOrgSummary.jsx | 13 +++- .../VolunteerHoursDistribution.jsx | 56 ++++++++++++++--- .../__tests__/HoursWorkList.test.jsx | 32 ++++++++++ .../VolunteerHoursDistribution.test.jsx | 61 +++++++++++++++++++ .../__tests__/formatRangeLabel.test.jsx | 19 ++++++ .../__tests__/CustomTooltip.test.jsx | 31 ++++++++++ 10 files changed, 303 insertions(+), 44 deletions(-) create mode 100644 src/components/TotalOrgSummary/NumbersVolunteerWorked/__tests__/NumbersVolunteerWorked.test.jsx create mode 100644 src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/HoursWorkList.test.jsx create mode 100644 src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/VolunteerHoursDistribution.test.jsx create mode 100644 src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/formatRangeLabel.test.jsx create mode 100644 src/components/__tests__/CustomTooltip.test.jsx diff --git a/src/components/CustomTooltip.jsx b/src/components/CustomTooltip.jsx index 5b3b438ee8..fd59a1bb0a 100644 --- a/src/components/CustomTooltip.jsx +++ b/src/components/CustomTooltip.jsx @@ -1,7 +1,7 @@ // Universal Custom Tooltip with dark mode support and all values import React from 'react'; -function CustomTooltip({ active, payload, label, yAxisLabel }) { +function CustomTooltip({ active, payload, label, yAxisLabel, tooltipType }) { let isDarkMode = false; if (typeof window !== 'undefined' && window.matchMedia) { isDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches; @@ -10,8 +10,10 @@ function CustomTooltip({ active, payload, label, yAxisLabel }) { const data = payload[0].payload || {}; // For WorkDistributionBarChart: show name, Total Hours, and percentage const name = data._id || data.name || label || ''; - const totalHours = data.totalHours !== undefined ? data.totalHours : data.value; const percentage = data.percentage; + // when used for hours distribution we treat `value` as volunteer count + const volunteerCount = data.value; + const totalHours = data.totalHours !== undefined ? data.totalHours : data.value; return (
{name}
- {totalHours !== undefined && ( -
- Total Hours: {totalHours} -
- )} + {/* display either volunteerCount or totalHours depending on tooltipType */} + {tooltipType === 'hoursDistribution' + ? volunteerCount !== undefined && ( +
+ Volunteers: {volunteerCount} +
+ ) + : totalHours !== undefined && ( +
+ Total Hours: {totalHours} +
+ )} {percentage !== undefined && (
Percentage: {percentage}
)} diff --git a/src/components/TotalOrgSummary/HoursWorkedPieChart/HoursWorkedPieChart.jsx b/src/components/TotalOrgSummary/HoursWorkedPieChart/HoursWorkedPieChart.jsx index 36448f2b16..3fedb7d55d 100644 --- a/src/components/TotalOrgSummary/HoursWorkedPieChart/HoursWorkedPieChart.jsx +++ b/src/components/TotalOrgSummary/HoursWorkedPieChart/HoursWorkedPieChart.jsx @@ -1,4 +1,5 @@ import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts'; +import PropTypes from 'prop-types'; const RADIAN = Math.PI / 180; @@ -12,26 +13,12 @@ const renderCustomizedLabel = ({ value, totalHours, title, - comparisonPercentage, - comparisonType, }) => { const radius = innerRadius + (outerRadius - innerRadius) * 0.4; const x = cx + radius * Math.cos(-midAngle * RADIAN); const y = cy + radius * Math.sin(-midAngle * RADIAN); - const percentage = Math.round(comparisonPercentage); - const fillColor = comparisonPercentage > 1 ? 'green' : 'red'; - - const textContent = - comparisonType !== 'No Comparison' ? `${percentage}% ${comparisonType.toLowerCase()}` : ''; - const fontSize = 10; - const maxTextLength = Math.floor((innerRadius / fontSize) * 4); - - let adjustedText = textContent; - if (textContent.length > maxTextLength) { - adjustedText = `${textContent.substring(0, maxTextLength - 3)}...`; - } - + // no comparison data; simply show count and percentage of slice return ( <> {totalHours.toFixed(0)} - {comparisonType !== 'No Comparison' && ( - - {adjustedText} - - )} ); }; import CustomTooltip from '../../CustomTooltip'; -export default function HoursWorkedPieChart({ userData, windowSize, comparisonType, colors }) { +export default function HoursWorkedPieChart({ userData, windowSize, colors, totalHours = 0 }) { let innerRadius = 80; let outerRadius = 160; if (windowSize.width <= 650) { innerRadius = 65; outerRadius = 130; } + // We'll display totalHours in centre + const displayTotalHours = totalHours || 0; return (
@@ -88,7 +72,13 @@ export default function HoursWorkedPieChart({ userData, windowSize, comparisonTy cx="50%" cy="50%" labelLine={false} - label={props => renderCustomizedLabel({ ...props, comparisonType })} + label={props => + renderCustomizedLabel({ + ...props, + totalHours: displayTotalHours, + title: 'TOTAL HOURS', + }) + } innerRadius={innerRadius} outerRadius={outerRadius} fill="#8884d8" @@ -100,9 +90,16 @@ export default function HoursWorkedPieChart({ userData, windowSize, comparisonTy ))} - } /> + } />
); } + +HoursWorkedPieChart.propTypes = { + userData: PropTypes.array.isRequired, + windowSize: PropTypes.shape({ width: PropTypes.number, height: PropTypes.number }).isRequired, + colors: PropTypes.array, + totalHours: PropTypes.number, +}; diff --git a/src/components/TotalOrgSummary/NumbersVolunteerWorked/NumbersVolunteerWorked.jsx b/src/components/TotalOrgSummary/NumbersVolunteerWorked/NumbersVolunteerWorked.jsx index 2001a607d4..6d5a6986bd 100644 --- a/src/components/TotalOrgSummary/NumbersVolunteerWorked/NumbersVolunteerWorked.jsx +++ b/src/components/TotalOrgSummary/NumbersVolunteerWorked/NumbersVolunteerWorked.jsx @@ -1,6 +1,14 @@ import styles from '../TotalOrgSummary.module.css'; import cx from 'clsx'; -export default function NumbersVolunteerWorked({ isLoading, data, darkMode }) { +export default function NumbersVolunteerWorked({ + isLoading, + data, + totalVolunteers = 0, + rangeText = '1+ hours', + darkMode, +}) { + const count = data?.count ?? 0; + const percentage = totalVolunteers ? ((count / totalVolunteers) * 100).toFixed(0) : 0; 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 15bbe63a12..164ff6e1cf 100644 --- a/src/components/TotalOrgSummary/TotalOrgSummary.jsx +++ b/src/components/TotalOrgSummary/TotalOrgSummary.jsx @@ -30,7 +30,9 @@ import { getTotalOrgSummary, getTaskAndProjectStats } from '~/actions/totalOrgSu import '../Header/index.css'; import styles from './TotalOrgSummary.module.css'; import { clsx } from 'clsx'; -import VolunteerHoursDistribution from './VolunteerHoursDistribution/VolunteerHoursDistribution'; +import VolunteerHoursDistribution, { + formatRangeLabel, +} from './VolunteerHoursDistribution/VolunteerHoursDistribution'; import AccordianWrapper from './AccordianWrapper/AccordianWrapper'; import VolunteerStatus from './VolunteerStatus/VolunteerStatus'; import VolunteerActivities from './VolunteerActivities/VolunteerActivities'; @@ -736,12 +738,19 @@ ${ darkMode={darkMode} hoursData={volunteerStats?.volunteerHoursStats} totalHoursData={volunteerStats?.totalHoursWorked} - comparisonType={selectedComparison} />
+ {/* determine a descriptive range label from the distribution data */} 0 + ? formatRangeLabel(volunteerStats.volunteerHoursStats[0]._id) + : '1+ hours' + } darkMode={darkMode} />
diff --git a/src/components/TotalOrgSummary/VolunteerHoursDistribution/VolunteerHoursDistribution.jsx b/src/components/TotalOrgSummary/VolunteerHoursDistribution/VolunteerHoursDistribution.jsx index bb5221a30b..18054f1cd9 100644 --- a/src/components/TotalOrgSummary/VolunteerHoursDistribution/VolunteerHoursDistribution.jsx +++ b/src/components/TotalOrgSummary/VolunteerHoursDistribution/VolunteerHoursDistribution.jsx @@ -6,6 +6,27 @@ import Loading from '../../common/Loading'; const COLORS = ['#00AFF4', '#FFA500', '#00B030', '#EC52CB', '#F8FF00']; +// convert backend range string (e.g. "10", "40+", "20-29") +// into a user-facing label with units. +export function formatRangeLabel(rangeStr) { + if (!rangeStr) return ''; + let displayName = ''; + if (rangeStr.includes('+')) { + const num = parseFloat(rangeStr.replace('+', '')); + if (num === 40) { + // bucket following 40 should display as 50+ + displayName = '50+ hrs'; + } else { + displayName = `${num}+ hrs`; + } + } else { + const num = parseFloat(rangeStr); + const next = (num + 9.99).toFixed(2); + displayName = `${num}-${next} hrs`; + } + return displayName; +} + function HoursWorkList({ data, darkMode }) { if (!data) return
; @@ -13,11 +34,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); @@ -46,7 +72,10 @@ function HoursWorkList({ data, darkMode }) { backgroundColor: item.color, }} /> - {item.name} + + {item.displayName || item.name} + {item.count !== undefined && ` (${item.count})`} + ))} @@ -55,12 +84,14 @@ function HoursWorkList({ data, darkMode }) { ); } +// export HoursWorkList separately for testing +export { HoursWorkList }; + export default function VolunteerHoursDistribution({ isLoading, darkMode, hoursData, totalHoursData, - comparisonType, }) { const [windowSize, setWindowSize] = useState({ width: window.innerWidth, @@ -91,15 +122,20 @@ export default function VolunteerHoursDistribution({ ); } - const totalHours = hoursData.reduce((total, cur) => total + cur.count, 0); + // total volunteers is just the sum of all bucket counts + const safeHoursData = Array.isArray(hoursData) ? hoursData : []; + const totalVolunteers = safeHoursData.reduce((total, cur) => total + (cur.count || 0), 0); + + // the pie chart center should show total hours worked (comes from totalHoursData.current) + const totalHoursWorked = totalHoursData?.current || 0; - const userData = hoursData.map(range => { + const userData = safeHoursData.map(range => { + const value = range.count || 0; return { name: range._id, - value: range.count, - totalHours, - title: 'HOURS WORKED', - comparisonPercentage: totalHoursData.comparison, + value, + // percentage of volunteers in this bucket (rounded) + percentage: totalVolunteers ? Math.round((value / totalVolunteers) * 100) : 0, }; }); @@ -112,7 +148,7 @@ export default function VolunteerHoursDistribution({ darkmode={darkMode} windowSize={windowSize} userData={userData} - comparisonType={comparisonType} + totalHours={totalHoursWorked} colors={COLORS} /> 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..22900cbc5a --- /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('converts simple numeric ids into ranges and adds hrs', () => { + const mockData = [ + { _id: '10', count: 5 }, + { _id: '40', count: 2 }, + { _id: '50+', count: 3 }, + { _id: '40+', count: 1 }, + ]; + + render(, { container }); + + expect(screen.getByText('10-19.99 hrs (5)')).toBeInTheDocument(); + expect(screen.getByText('40-49.99 hrs (2)')).toBeInTheDocument(); + // '50+' bucket should appear for both the original 50+ and the remapped 40+ + expect(screen.getByText('50+ hrs (3)')).toBeInTheDocument(); + expect(screen.getByText('50+ hrs (1)')).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..e0a48e1f81 --- /dev/null +++ b/src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/VolunteerHoursDistribution.test.jsx @@ -0,0 +1,61 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +// mock HoursWorkedPieChart so we can inspect props +vi.mock('../HoursWorkedPieChart/HoursWorkedPieChart', () => { + return { + __esModule: true, + default: ({ userData, totalHours }) => ( +
+ ), + }; +}); + +import VolunteerHoursDistribution from '../VolunteerHoursDistribution'; + +let container = null; +beforeEach(() => { + container = document.createElement('div'); + 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 }, + ); + + const chart = screen.getByTestId('mock-pie'); + expect(chart).toBeInTheDocument(); + + // legend/list should render counts as well + expect(screen.getByText('10-19.99 hrs (2)')).toBeInTheDocument(); + + const passedUserData = JSON.parse(chart.getAttribute('data-userdata')); + expect(passedUserData).toEqual([ + { name: '10', value: 2, percentage: 40 }, + { name: '20', value: 3, percentage: 60 }, + ]); + + expect(chart.getAttribute('data-totalhours')).toBe('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..14abc5c3d4 --- /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 volunteer count and percentage when hoursDistribution type', () => { + render(, container); + + expect(screen.getByText(/Bucket/)).toBeInTheDocument(); + expect(screen.getByText(/Volunteers: 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(); + }); +}); From a45a140d2731febf07c0cfca6b003565ec24aa1e Mon Sep 17 00:00:00 2001 From: RoshiniSeelamsetty Date: Thu, 26 Feb 2026 14:12:43 -0500 Subject: [PATCH 02/20] test: add mock-pie test ID for VolunteerHoursDistribution --- .../VolunteerHoursDistribution.jsx | 18 ++++++++ .../VolunteerHoursDistribution.test.jsx | 41 ++++++++----------- 2 files changed, 34 insertions(+), 25 deletions(-) diff --git a/src/components/TotalOrgSummary/VolunteerHoursDistribution/VolunteerHoursDistribution.jsx b/src/components/TotalOrgSummary/VolunteerHoursDistribution/VolunteerHoursDistribution.jsx index 18054f1cd9..8bbc8ceb0c 100644 --- a/src/components/TotalOrgSummary/VolunteerHoursDistribution/VolunteerHoursDistribution.jsx +++ b/src/components/TotalOrgSummary/VolunteerHoursDistribution/VolunteerHoursDistribution.jsx @@ -139,6 +139,8 @@ export default function VolunteerHoursDistribution({ }; }); + // end of component logic + return (
); } + +// computeDistribution: pure helper to derive the chart payload from API data +export function computeDistribution(hoursData, totalHoursData) { + const safeHoursData = Array.isArray(hoursData) ? hoursData : []; + const totalVolunteers = safeHoursData.reduce((total, cur) => total + (cur.count || 0), 0); + const totalHoursWorked = totalHoursData?.current || 0; + const userData = safeHoursData.map(range => { + const value = range.count || 0; + return { + name: range._id, + value, + percentage: totalVolunteers ? Math.round((value / totalVolunteers) * 100) : 0, + }; + }); + return { userData, totalVolunteers, totalHoursWorked }; +} diff --git a/src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/VolunteerHoursDistribution.test.jsx b/src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/VolunteerHoursDistribution.test.jsx index e0a48e1f81..120c59e484 100644 --- a/src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/VolunteerHoursDistribution.test.jsx +++ b/src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/VolunteerHoursDistribution.test.jsx @@ -1,25 +1,16 @@ +// 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'; -// mock HoursWorkedPieChart so we can inspect props -vi.mock('../HoursWorkedPieChart/HoursWorkedPieChart', () => { - return { - __esModule: true, - default: ({ userData, totalHours }) => ( -
- ), - }; -}); - -import VolunteerHoursDistribution from '../VolunteerHoursDistribution'; +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(() => { @@ -44,18 +35,18 @@ describe('VolunteerHoursDistribution wrapper', () => { { container }, ); - const chart = screen.getByTestId('mock-pie'); - expect(chart).toBeInTheDocument(); - // legend/list should render counts as well expect(screen.getByText('10-19.99 hrs (2)')).toBeInTheDocument(); - const passedUserData = JSON.parse(chart.getAttribute('data-userdata')); - expect(passedUserData).toEqual([ - { name: '10', value: 2, percentage: 40 }, - { name: '20', value: 3, percentage: 60 }, - ]); - - expect(chart.getAttribute('data-totalhours')).toBe('1234'); + // verify computeDistribution helper produces expected output + const computed = computeDistribution(hoursData, totalHoursData); + expect(computed).toEqual({ + userData: [ + { name: '10', value: 2, percentage: 40 }, + { name: '20', value: 3, percentage: 60 }, + ], + totalVolunteers: 5, + totalHoursWorked: 1234, + }); }); }); From 60b9d9edbd4695be83c5ef79d9394a5b35931eef Mon Sep 17 00:00:00 2001 From: RoshiniSeelamsetty Date: Sun, 15 Mar 2026 00:19:50 -0400 Subject: [PATCH 03/20] Merge development into volunteer-hours-distribution-chart-visualization-fix --- src/components/CustomTooltip.jsx | 127 ++++++++++++++++++------------- 1 file changed, 73 insertions(+), 54 deletions(-) diff --git a/src/components/CustomTooltip.jsx b/src/components/CustomTooltip.jsx index fd59a1bb0a..15c6812799 100644 --- a/src/components/CustomTooltip.jsx +++ b/src/components/CustomTooltip.jsx @@ -1,63 +1,82 @@ // Universal Custom Tooltip with dark mode support and all values import React from 'react'; -function CustomTooltip({ active, payload, label, yAxisLabel, tooltipType }) { - let isDarkMode = false; - if (typeof window !== 'undefined' && window.matchMedia) { - isDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches; - } - if (active && payload && payload.length) { - const data = payload[0].payload || {}; - // For WorkDistributionBarChart: show name, Total Hours, and percentage - const name = data._id || data.name || label || ''; - const percentage = data.percentage; - // when used for hours distribution we treat `value` as volunteer count - const volunteerCount = data.value; - const totalHours = data.totalHours !== undefined ? data.totalHours : data.value; - return ( -
-
- {name} +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, + volunteerCount: 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, volunteerCount, totalHours, change } = getTooltipData(payload, label); + const textColor = isDarkMode ? '#fff' : '#222'; + + const renderMainValue = () => { + if (tooltipType === 'hoursDistribution' && volunteerCount !== undefined) { + return ( +
+ Volunteers: {volunteerCount} +
+ ); + } + + if (totalHours !== undefined) { + return ( +
+ Total Hours: {totalHours}
- {/* display either volunteerCount or totalHours depending on tooltipType */} - {tooltipType === 'hoursDistribution' - ? volunteerCount !== undefined && ( -
- Volunteers: {volunteerCount} -
- ) - : 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; + }; + + const renderChange = () => { + if (change === undefined) return null; + const changeColor = change < 0 ? 'red' : isDarkMode ? 'lightgreen' : 'green'; + return ( +
+ Change: {change}
); - } - return null; + }; + + return ( +
+
+ {name} +
+ {renderMainValue()} + {percentage !== undefined && ( +
+ Percentage: {percentage} +
+ )} + {renderChange()} +
+ ); } export default CustomTooltip; From 4314febe96339dd778cb16ec6d019c46018fb57b Mon Sep 17 00:00:00 2001 From: RoshiniSeelamsetty Date: Sun, 15 Mar 2026 00:30:01 -0400 Subject: [PATCH 04/20] Fix linting issues --- src/components/CustomTooltip.jsx | 24 +++++------------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/src/components/CustomTooltip.jsx b/src/components/CustomTooltip.jsx index 15c6812799..dac8923261 100644 --- a/src/components/CustomTooltip.jsx +++ b/src/components/CustomTooltip.jsx @@ -27,18 +27,12 @@ function CustomTooltip({ active, payload, label, tooltipType }) { const renderMainValue = () => { if (tooltipType === 'hoursDistribution' && volunteerCount !== undefined) { return ( -
- Volunteers: {volunteerCount} -
+
Volunteers: {volunteerCount}
); } if (totalHours !== undefined) { - return ( -
- Total Hours: {totalHours} -
- ); + return
Total Hours: {totalHours}
; } return null; @@ -47,11 +41,7 @@ function CustomTooltip({ active, payload, label, tooltipType }) { const renderChange = () => { if (change === undefined) return null; const changeColor = change < 0 ? 'red' : isDarkMode ? 'lightgreen' : 'green'; - return ( -
- Change: {change} -
- ); + return
Change: {change}
; }; return ( @@ -65,14 +55,10 @@ function CustomTooltip({ active, payload, label, tooltipType }) { minWidth: 120, }} > -
- {name} -
+
{name}
{renderMainValue()} {percentage !== undefined && ( -
- Percentage: {percentage} -
+
Percentage: {percentage}
)} {renderChange()}
From 3f634c6cea7a151548f7812c6bf39b3fe6fc74d5 Mon Sep 17 00:00:00 2001 From: RoshiniSeelamsetty Date: Sun, 15 Mar 2026 17:17:50 -0400 Subject: [PATCH 05/20] refactor: reduce cognitive complexity of calculateComparisonDates --- .../TotalOrgSummary/TotalOrgSummary.jsx | 51 ++++++------------- 1 file changed, 16 insertions(+), 35 deletions(-) diff --git a/src/components/TotalOrgSummary/TotalOrgSummary.jsx b/src/components/TotalOrgSummary/TotalOrgSummary.jsx index 164ff6e1cf..0e7bd7345c 100644 --- a/src/components/TotalOrgSummary/TotalOrgSummary.jsx +++ b/src/components/TotalOrgSummary/TotalOrgSummary.jsx @@ -68,44 +68,25 @@ 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, + }; } const fromDate = calculateStartDate(); From ccc6778a5d61327d500987f2b0a885e79d95eb83 Mon Sep 17 00:00:00 2001 From: RoshiniSeelamsetty Date: Sun, 15 Mar 2026 17:36:10 -0400 Subject: [PATCH 06/20] refactor: reduce cognitive complexity of calculateComparisonDates --- .../TotalOrgSummary/TotalOrgSummary.jsx | 192 +++++++++--------- 1 file changed, 95 insertions(+), 97 deletions(-) diff --git a/src/components/TotalOrgSummary/TotalOrgSummary.jsx b/src/components/TotalOrgSummary/TotalOrgSummary.jsx index 0e7bd7345c..dae5b8f52e 100644 --- a/src/components/TotalOrgSummary/TotalOrgSummary.jsx +++ b/src/components/TotalOrgSummary/TotalOrgSummary.jsx @@ -89,6 +89,75 @@ function calculateComparisonDates(comparisonType, fromDate, toDate) { }; } +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) parent.replaceChild(canvas, img); + }); +} + +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'; + } +} + const fromDate = calculateStartDate(); const toDate = calculateEndDate(); @@ -166,51 +235,32 @@ function TotalOrgSummary(props) { return; } - // 2. Wait longer for charts/maps to render (5 seconds) + // Wait 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. + // 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); - } - }); + const originalCanvases = replaceCanvasesWithImages(chartCanvases); - // 4. Create a temporary container for PDF generation + // 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'; + Object.assign(pdfContainer.style, { + width: '100%', + padding: '0', + position: 'absolute', + left: '-9999px', + top: '0', + zIndex: '9999', + boxSizing: 'border-box', + minHeight: '100%', + margin: '0', + }); // Clone the main content area - - const originalContent = rootRef.current; - const clonedContent = originalContent.cloneNode(true); - - // Remove interactive or unwanted elements from the clone + const clonedContent = rootRef.current.cloneNode(true); // Remove interactive or unwanted elements from the clone clonedContent @@ -219,7 +269,6 @@ function TotalOrgSummary(props) { // 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(); @@ -227,53 +276,13 @@ function TotalOrgSummary(props) { }); // 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 */ - } - }); + copyLiveCanvasesToClone( + Array.from(document.querySelectorAll('canvas')), + Array.from(clonedContent.querySelectorAll('canvas')), + ); // 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'; - } - } + adjustTitleRowForPDF(clonedContent); // Create a style element for the PDF container const styleElem = document.createElement('style'); @@ -396,7 +405,7 @@ ${ pdfContainer.appendChild(clonedContent); document.body.appendChild(pdfContainer); - // 5. Use html2canvas to capture the rendered container + // Use html2canvas to capture the rendered container const screenshotCanvas = await html2canvas(pdfContainer, { scale: 2, useCORS: true, @@ -414,7 +423,7 @@ ${ throw new Error('Invalid image data generated.'); } - // 6. Create a single-page PDF + // Create a single-page PDF const pdfWidth = 210; // A4 width in mm const imgHeight = (screenshotCanvas.height * pdfWidth) / screenshotCanvas.width; const doc = new jsPDF({ @@ -428,12 +437,7 @@ ${ 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); - } - }); + restoreCanvases(originalCanvases); document.body.removeChild(pdfContainer); } catch (pdfError) { // eslint-disable-next-line no-console @@ -480,12 +484,7 @@ ${ if (error || isVolunteerFetchingError) { return ( - +
@@ -721,7 +720,6 @@ ${ totalHoursData={volunteerStats?.totalHoursWorked} />
- {/* determine a descriptive range label from the distribution data */} Date: Sun, 15 Mar 2026 17:41:32 -0400 Subject: [PATCH 07/20] refactor: reduce cognitive complexity of calculateComparisonDates --- src/components/TotalOrgSummary/TotalOrgSummary.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/TotalOrgSummary/TotalOrgSummary.jsx b/src/components/TotalOrgSummary/TotalOrgSummary.jsx index dae5b8f52e..e8034c6ef8 100644 --- a/src/components/TotalOrgSummary/TotalOrgSummary.jsx +++ b/src/components/TotalOrgSummary/TotalOrgSummary.jsx @@ -111,7 +111,7 @@ function replaceCanvasesWithImages(canvasElements) { function restoreCanvases(originalCanvases) { originalCanvases.forEach(({ canvas, parent }) => { const img = parent.querySelector('img'); - if (img) parent.replaceChild(canvas, img); + if (img) img.replaceWith(canvas); }); } From 2e974015824411f49a63464868c4ce8c23246a4c Mon Sep 17 00:00:00 2001 From: RoshiniSeelamsetty Date: Sun, 15 Mar 2026 17:54:24 -0400 Subject: [PATCH 08/20] refactor: reduce cognitive complexity of calculateComparisonDates --- .../TotalOrgSummary/TotalOrgSummary.jsx | 38 ++++++++----------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/src/components/TotalOrgSummary/TotalOrgSummary.jsx b/src/components/TotalOrgSummary/TotalOrgSummary.jsx index e8034c6ef8..af110dceaf 100644 --- a/src/components/TotalOrgSummary/TotalOrgSummary.jsx +++ b/src/components/TotalOrgSummary/TotalOrgSummary.jsx @@ -49,7 +49,6 @@ import GlobalVolunteerMap from './GlobalVolunteerMap/GlobalVolunteerMap'; import TaskCompletedBarChart from './TaskCompleted/TaskCompletedBarChart'; 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(); @@ -59,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(); @@ -89,6 +87,20 @@ function calculateComparisonDates(comparisonType, fromDate, toDate) { }; } +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 => { @@ -195,7 +207,6 @@ function TotalOrgSummary(props) { comparisonEndDate, ); - // Fetch task and project stats separately const taskAndProjectStatsResponse = await props.getTaskAndProjectStats( currentFromDate, currentToDate, @@ -217,23 +228,10 @@ 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; - } + if (!validatePDFPrerequisites(volunteerStats, isLoading)) return; // Wait for charts/maps to render (5 seconds) await new Promise(resolve => setTimeout(resolve, 5000)); @@ -305,7 +303,6 @@ function TotalOrgSummary(props) { padding: 0 !important; } - /* PDF block container: border and shadow, dark mode fidelity */ [data-pdf-block] { page-break-inside: avoid; break-inside: avoid; @@ -366,7 +363,6 @@ function TotalOrgSummary(props) { color: #333 !important; } - /* Force all chart and card titles to white in dark mode for PDF */ ${ darkMode ? ` @@ -433,7 +429,7 @@ ${ }); doc.addImage(imgData, 'PNG', 0, 0, pdfWidth, imgHeight); const now = new Date(); - const localDate = now.toLocaleDateString('en-CA'); // Using YYYY-MM-DD format + const localDate = now.toLocaleDateString('en-CA'); doc.save(`volunteer-report-${localDate}.pdf`); // Cleanup: restore original canvases and remove temporary container @@ -476,7 +472,6 @@ ${ setSelectedDateRange(`${startDate.toLocaleDateString()} - ${endDate.toLocaleDateString()}`); setShowDatePicker(false); setSelectedComparison('No Comparison'); - setCurrentFromDate(startDate.toISOString().split('T')[0]); setCurrentToDate(endDate.toISOString().split('T')[0]); } @@ -582,7 +577,6 @@ ${ />
-