From 2841ddf75b8653618f67f56cb4b5d278c43759bf Mon Sep 17 00:00:00 2001 From: Akshay Date: Fri, 2 Oct 2026 13:32:30 -0600 Subject: [PATCH 01/10] fix: unify Total Org Summary donut charts on the shared DonutChart Volunteer Status (volunteers + mentors) and Volunteer Hours Distribution now render with the same DonutChart as Blue Square Stats, so labels, legends, center totals and dark mode look consistent. - Stack the legend under the donut in narrow slots (container query) so the volunteer and mentor charts stay side by side. - Let the donut grow to fill its card instead of locking to its min width. - Show volunteer/mentor comparison change as a percent (+12%, not +0.12%). - Round the hours-worked center total and keep 50-59 hrs before 50+ hrs. - Remove the replaced pie chart components and their tests. --- .../TotalOrgSummary/DonutChart/DonutChart.jsx | 2 +- .../DonutChart/DonutChart.module.css | 31 +- .../DonutChart/__tests__/DonutChart.test.js | 19 +- .../HoursWorkedPieChart.jsx | 289 ------------------ .../__tests__/HoursWorkedPieChart.test.jsx | 25 -- .../TotalOrgSummary/TotalOrgSummary.jsx | 3 +- .../VolunteerHoursDistribution.jsx | 119 ++------ .../__tests__/HoursWorkList.test.jsx | 20 -- .../VolunteerHoursDistribution.test.jsx | 49 ++- .../VolunteerStatus/MentorStatusPieChart.jsx | 109 ------- .../MentorStatusPieChart.module.css | 130 -------- .../VolunteerStatus/VolunteerStatusChart.jsx | 37 ++- .../VolunteerStatusChart.module.css | 53 +--- .../VolunteerStatusPieChart.jsx | 116 ------- .../VolunteerStatusPieChart.module.css | 115 ------- 15 files changed, 125 insertions(+), 992 deletions(-) delete mode 100644 src/components/TotalOrgSummary/HoursWorkedPieChart/HoursWorkedPieChart.jsx delete mode 100644 src/components/TotalOrgSummary/HoursWorkedPieChart/__tests__/HoursWorkedPieChart.test.jsx delete mode 100644 src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/HoursWorkList.test.jsx delete mode 100644 src/components/TotalOrgSummary/VolunteerStatus/MentorStatusPieChart.jsx delete mode 100644 src/components/TotalOrgSummary/VolunteerStatus/MentorStatusPieChart.module.css delete mode 100644 src/components/TotalOrgSummary/VolunteerStatus/VolunteerStatusPieChart.jsx delete mode 100644 src/components/TotalOrgSummary/VolunteerStatus/VolunteerStatusPieChart.module.css diff --git a/src/components/TotalOrgSummary/DonutChart/DonutChart.jsx b/src/components/TotalOrgSummary/DonutChart/DonutChart.jsx index 1e4c36722de..6bdbbd10406 100644 --- a/src/components/TotalOrgSummary/DonutChart/DonutChart.jsx +++ b/src/components/TotalOrgSummary/DonutChart/DonutChart.jsx @@ -40,7 +40,7 @@ function DonutChart(props) { const labelTextColor = darkMode ? '#e2e8f0' : '#334155'; const labelBoxBackground = darkMode ? 'rgba(15, 23, 42, 0.96)' : 'rgba(255, 255, 255, 0.96)'; const labelBoxBorder = darkMode ? 'rgba(148, 163, 184, 0.35)' : '#d0d0d0'; - const titleLines = title === 'TOTAL BLUE SQUARES' ? ['TOTAL', 'BLUE SQUARES'] : [title]; + const titleLines = title.startsWith('TOTAL ') ? ['TOTAL', title.slice(6)] : [title]; const filtered = data .map((item, i) => ({ item, color: colors[i] })) diff --git a/src/components/TotalOrgSummary/DonutChart/DonutChart.module.css b/src/components/TotalOrgSummary/DonutChart/DonutChart.module.css index dea72e8114c..d1ffae3da36 100644 --- a/src/components/TotalOrgSummary/DonutChart/DonutChart.module.css +++ b/src/components/TotalOrgSummary/DonutChart/DonutChart.module.css @@ -1,6 +1,8 @@ /* stylelint-disable */ .donutContainer { display: grid; + /* Fill the card instead of shrinking to the chart's min-width. */ + grid-template-columns: minmax(0, 1fr); align-items: center; justify-content: center; width: 100%; @@ -8,6 +10,7 @@ padding: 0.6rem; margin-top: 20px; margin-bottom: -35px; + container-type: inline-size; } .donutContainerDark { @@ -21,14 +24,16 @@ align-items: center; width: 100%; min-width: 32rem; + max-width: 48rem; + margin: 0 auto; } .donutChart { position: relative; width: 100%; min-width: 21.25rem; - max-width: 24rem; - height: 24rem; + max-width: 30rem; + height: 26rem; margin-left: 0; overflow: visible; } @@ -177,4 +182,24 @@ .donutLabel { margin-left: 0; } -} \ No newline at end of file +} +/* Narrow slots (e.g. two charts side by side): stack the legend under the donut. */ +@container (width < 36rem) { + .donutScrollable { + grid-template-columns: 1fr; + justify-items: center; + min-width: 0; + } + + .donutChart { + min-width: 0; + max-width: 26rem; + height: auto; + aspect-ratio: 1; + } + + .donutLabels { + width: auto; + margin-top: 1rem; + } +} diff --git a/src/components/TotalOrgSummary/DonutChart/__tests__/DonutChart.test.js b/src/components/TotalOrgSummary/DonutChart/__tests__/DonutChart.test.js index 925cf1cefd9..b314a2848a1 100644 --- a/src/components/TotalOrgSummary/DonutChart/__tests__/DonutChart.test.js +++ b/src/components/TotalOrgSummary/DonutChart/__tests__/DonutChart.test.js @@ -1,5 +1,5 @@ import { createElement } from 'react'; -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import DonutChart, { buildDonutTooltipOptions, formatLegendLabel } from '../DonutChart'; // Chart.js draws to a jsdom doesn't implement; capture the options instead. @@ -78,3 +78,20 @@ describe('buildDonutTooltipOptions', () => { }); }); }); + +describe('DonutChart center title', () => { + it('splits any "TOTAL ..." title onto two lines so long names stay inside the hole', () => { + render( + createElement(DonutChart, { + title: 'TOTAL VOLUNTEERS*', + totalCount: 3127, + percentageChange: 0, + data: [{ label: 'Existing Active', value: 2049 }], + colors: ['#4C4AF5'], + comparisonType: 'No Comparison', + }), + ); + expect(screen.getByText('TOTAL')).toBeInTheDocument(); + expect(screen.getByText('VOLUNTEERS*')).toBeInTheDocument(); + }); +}); diff --git a/src/components/TotalOrgSummary/HoursWorkedPieChart/HoursWorkedPieChart.jsx b/src/components/TotalOrgSummary/HoursWorkedPieChart/HoursWorkedPieChart.jsx deleted file mode 100644 index 337ca666f75..00000000000 --- a/src/components/TotalOrgSummary/HoursWorkedPieChart/HoursWorkedPieChart.jsx +++ /dev/null @@ -1,289 +0,0 @@ -import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts'; -import PropTypes from 'prop-types'; - -const RADIAN = Math.PI / 180; - -const formatNumber = value => { - 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); -}; - -export 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 }; -}; - -export const channelToLinear = c => { - const normalized = c / 255; - return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4; -}; - -export const relativeLuminance = ({ r, g, b }) => - 0.2126 * channelToLinear(r) + 0.7152 * channelToLinear(g) + 0.0722 * channelToLinear(b); - -export const contrastRatio = (l1, l2) => { - const brightest = Math.max(l1, l2); - const darkest = Math.min(l1, l2); - return (brightest + 0.05) / (darkest + 0.05); -}; - -export 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, - centerLabelLines = ['TOTAL HOURS', 'WORKED'], -}) => { - const centerFill = darkMode ? '#D1D5DB' : '#696969'; - const totalText = formatNumber(totalHours || 0); - const centerValueFontSize = totalText.length > 6 ? (isMobile ? 24 : 30) : isMobile ? 30 : 36; - - return ( - <> - - {centerLabelLines[0]} - - - {centerLabelLines[1]} - - - {totalText} - - - ); -}; - -const renderCustomizedLabel = ({ - cx, - cy, - midAngle, - innerRadius, - outerRadius, - percent, - value, - totalHours, - fill, - payload, - index, - colors, - resolvedSliceColor, - darkMode, - isMobile, -}) => { - if (value <= 0) return null; - - // 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 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); - - 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 ( - <> - {!hideAllLabels && (isTinySlice || useCompactLabel) && ( - - {chartLabelValue} - - )} - {!hideAllLabels && canShowPercent && ( - <> - - {chartLabelValue} - - - {`(${(percent * 100).toFixed(0)}%)`} - - - )} - - ); -}; - -import CustomTooltip from '../../CustomTooltip'; - -export default function HoursWorkedPieChart({ - userData, - windowSize, - colors, - totalHours = 0, - darkMode = false, - centerLabelLines = ['TOTAL HOURS', 'WORKED'], -}) { - 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, - 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(chartData) && - chartData.length > 0 && - chartData.map((entry, index) => ( - - ))} - - {renderCenterLabel({ - darkMode, - isMobile, - totalHours: displayTotalHours, - centerLabelLines, - })} - {/* } /> */} - } - /> - - -
- ); -} - -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, - centerLabelLines: PropTypes.arrayOf(PropTypes.string), -}; diff --git a/src/components/TotalOrgSummary/HoursWorkedPieChart/__tests__/HoursWorkedPieChart.test.jsx b/src/components/TotalOrgSummary/HoursWorkedPieChart/__tests__/HoursWorkedPieChart.test.jsx deleted file mode 100644 index bb3a7aed9df..00000000000 --- a/src/components/TotalOrgSummary/HoursWorkedPieChart/__tests__/HoursWorkedPieChart.test.jsx +++ /dev/null @@ -1,25 +0,0 @@ -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({renderCenterLabel({ darkMode: true, isMobile: false, totalHours: 241989 })}); - - expect(screen.getAllByText('TOTAL HOURS')).toHaveLength(1); - expect(screen.getByText('WORKED')).toBeInTheDocument(); - expect(screen.getByText('241,989')).toBeInTheDocument(); - }); -}); diff --git a/src/components/TotalOrgSummary/TotalOrgSummary.jsx b/src/components/TotalOrgSummary/TotalOrgSummary.jsx index 4bc85566dd4..0f87619a22d 100644 --- a/src/components/TotalOrgSummary/TotalOrgSummary.jsx +++ b/src/components/TotalOrgSummary/TotalOrgSummary.jsx @@ -706,6 +706,7 @@ function TotalOrgSummary(props) { volunteerNumberStats={volunteerStats?.volunteerNumberStats} mentorNumberStats={volunteerStats?.mentorNumberStats} comparisonType={selectedComparison} + darkMode={darkMode} /> @@ -741,14 +742,12 @@ function TotalOrgSummary(props) { } totalHoursData={volunteerStats?.totalHoursWorked} title="Actual Hours Worked" - legendTitle="Actual Hours Worked" /> diff --git a/src/components/TotalOrgSummary/VolunteerHoursDistribution/VolunteerHoursDistribution.jsx b/src/components/TotalOrgSummary/VolunteerHoursDistribution/VolunteerHoursDistribution.jsx index dc9f74fc559..896cee755e2 100644 --- a/src/components/TotalOrgSummary/VolunteerHoursDistribution/VolunteerHoursDistribution.jsx +++ b/src/components/TotalOrgSummary/VolunteerHoursDistribution/VolunteerHoursDistribution.jsx @@ -1,5 +1,4 @@ -import { useEffect, useState } from 'react'; -import HoursWorkedPieChart from '../HoursWorkedPieChart/HoursWorkedPieChart'; +import DonutChart from '../DonutChart/DonutChart'; // Components import Loading from '../../common/Loading'; @@ -15,6 +14,11 @@ function parseRangeStart(rangeStr) { return Number.isFinite(parsed) ? parsed : 0; } +// '50' (50-59) sorts before '50+' even though both start at 50. +const compareBuckets = (a, b) => + parseRangeStart(a._id) - parseRangeStart(b._id) || + Number(String(a._id).includes('+')) - Number(String(b._id).includes('+')); + function normalizeBucketId(rangeStr) { if (!rangeStr) return ''; const trimmed = String(rangeStr).trim(); @@ -38,9 +42,7 @@ function mergeHoursBuckets(hoursData) { 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)); + return [...merged.entries()].map(([id, count]) => ({ _id: id, count })).sort(compareBuckets); } function allocateRoundedHoursByCount(normalizedHoursData, totalHoursWorked) { @@ -73,9 +75,7 @@ function allocateRoundedHoursByCount(normalizedHoursData, totalHoursWorked) { i += 1; } - return byRemainderDesc - .map(({ remainder, ...bucket }) => bucket) - .sort((a, b) => parseRangeStart(a._id) - parseRangeStart(b._id)); + return byRemainderDesc.map(({ remainder, ...bucket }) => bucket).sort(compareBuckets); } export function formatRangeLabel(rangeStr) { @@ -131,46 +131,6 @@ function buildChartData(hoursData, totalHoursData, useBucketCounts = false) { return { normalizedHoursData, userData, totalVolunteers, totalHoursWorked }; } -// --- Sub-Components --- - -function HoursWorkList({ data, darkMode, title = 'Hours Worked', useCommittedLabels = false }) { - if (!data) return
; - - const ranges = data.map((elem, index) => { - return { - name: elem._id, - count: elem.count, - displayName: useCommittedLabels - ? formatCommittedRangeLabel(elem._id) - : formatRangeLabel(elem._id), - color: COLORS[index % COLORS.length], - }; - }); - - return ( -
-
{title}
-
-
    - {ranges.map(item => ( -
  • -
    - {item.displayName} -
  • - ))} -
-
-
- ); -} - // --- Main Exported Component --- export default function VolunteerHoursDistribution({ @@ -179,31 +139,9 @@ export default function VolunteerHoursDistribution({ hoursData, totalHoursData, title = 'Actual Hours Worked', - legendTitle = 'Hours Worked', centerLabelLines = ['TOTAL HOURS', 'WORKED'], useBucketCounts = false, }) { - // FIXED: Comparing with 'undefined' directly instead of using 'typeof' on an object property - const [windowSize, setWindowSize] = useState({ - width: globalThis.window !== undefined ? globalThis.window.innerWidth : 1200, - height: globalThis.window !== undefined ? globalThis.window.innerHeight : 800, - }); - - useEffect(() => { - // FIXED: Removed 'typeof' check on globalThis.window property access - if (globalThis.window !== undefined) { - const updateWindowSize = () => { - setWindowSize({ - width: globalThis.window.innerWidth, - height: globalThis.window.innerHeight, - }); - }; - - globalThis.window.addEventListener('resize', updateWindowSize); - return () => globalThis.window.removeEventListener('resize', updateWindowSize); - } - }, []); - if (isLoading) { return (
+
{title}
-
- - -
+ ({ label: name, value }))} + colors={COLORS} + comparisonType="No Comparison" + darkMode={darkMode} + />
); } // Extra named exports for automated testing -export { HoursWorkList, mergeHoursBuckets }; +export { mergeHoursBuckets }; export function computeDistribution(hoursData, totalHoursData, useBucketCounts = false) { const { userData, totalVolunteers, totalHoursWorked } = buildChartData( diff --git a/src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/HoursWorkList.test.jsx b/src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/HoursWorkList.test.jsx deleted file mode 100644 index 9d836b1d793..00000000000 --- a/src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/HoursWorkList.test.jsx +++ /dev/null @@ -1,20 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { HoursWorkList } from '../VolunteerHoursDistribution'; - -describe('HoursWorkList label formatting', () => { - it('renders cleaned, human-readable labels in the legend list', () => { - // The main component passes normalized/merged structural records down to this list view - const mockNormalizedData = [ - { _id: '10', count: 5 }, - { _id: '40', count: 2 }, - { _id: '40+', count: 4 }, - ]; - - render(); - - // Asserting against the updated, clean text formats - expect(screen.getByText('10-19 hrs')).toBeInTheDocument(); - expect(screen.getByText('40-49 hrs')).toBeInTheDocument(); - expect(screen.getByText('40+ hrs')).toBeInTheDocument(); - }); -}); diff --git a/src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/VolunteerHoursDistribution.test.jsx b/src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/VolunteerHoursDistribution.test.jsx index 4da9235692a..f8d1bf3fddf 100644 --- a/src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/VolunteerHoursDistribution.test.jsx +++ b/src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/VolunteerHoursDistribution.test.jsx @@ -1,15 +1,14 @@ -// 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 { renderCenterLabel } from '../../HoursWorkedPieChart/HoursWorkedPieChart'; import VolunteerHoursDistribution, { computeDistribution } from '../VolunteerHoursDistribution'; +// Chart.js draws to a jsdom doesn't implement; the legend and center label are plain DOM. +vi.mock('react-chartjs-2', () => ({ Doughnut: () => null })); + 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); @@ -38,9 +37,9 @@ describe('VolunteerHoursDistribution wrapper', () => { { container }, ); - // FIXED: Assert using formatted range strings instead of raw bucket IDs - expect(screen.getByText('10-19 hrs')).toBeInTheDocument(); - expect(screen.getByText('20-29 hrs')).toBeInTheDocument(); + expect(screen.getByText('10-19 hrs: 494 (40.0%)')).toBeInTheDocument(); + expect(screen.getByText('20-29 hrs: 740 (60.0%)')).toBeInTheDocument(); + expect(screen.getByText('1234')).toBeInTheDocument(); // Verify computeDistribution now allocates hours to buckets so slices add up to total hours const computed = computeDistribution(hoursData, totalHoursData); @@ -71,16 +70,18 @@ describe('VolunteerHoursDistribution wrapper', () => { darkMode={false} hoursData={committedHoursData} title="Weekly Committed Hours" - legendTitle="Weekly Committed Hours" centerLabelLines={['TOTAL', 'VOLUNTEERS']} useBucketCounts />, { container }, ); - expect(screen.getAllByText('Weekly Committed Hours')).toHaveLength(2); - expect(screen.getByText('40 hrs')).toBeInTheDocument(); - expect(screen.getByText('Over 40 hrs')).toBeInTheDocument(); + expect(screen.getByText('Weekly Committed Hours')).toBeInTheDocument(); + expect(screen.getByText('40 hrs: 1 (12.5%)')).toBeInTheDocument(); + expect(screen.getByText('Over 40 hrs: 1 (12.5%)')).toBeInTheDocument(); + expect(screen.getByText('TOTAL')).toBeInTheDocument(); + expect(screen.getByText('VOLUNTEERS')).toBeInTheDocument(); + expect(screen.getByText('8')).toBeInTheDocument(); expect(computeDistribution(committedHoursData, undefined, true)).toEqual({ userData: [ @@ -94,22 +95,18 @@ describe('VolunteerHoursDistribution wrapper', () => { totalHoursWorked: 8, }); }); +}); - it('renders the committed distribution center label', () => { - render( - - {renderCenterLabel({ - darkMode: false, - isMobile: false, - totalHours: 8, - centerLabelLines: ['TOTAL', 'VOLUNTEERS'], - })} - , - { container }, +describe('bucket ordering', () => { + it('keeps 50-59 before 50+ when both start at 50', () => { + const { userData } = computeDistribution( + [ + { _id: '50+', count: 45 }, + { _id: '50', count: 22 }, + { _id: '10', count: 932 }, + ], + { current: 999 }, ); - - expect(screen.getByText('TOTAL')).toBeInTheDocument(); - expect(screen.getByText('VOLUNTEERS')).toBeInTheDocument(); - expect(screen.getByText('8')).toBeInTheDocument(); + expect(userData.map(d => d.name)).toEqual(['10-19 hrs', '50-59 hrs', '50+ hrs']); }); }); diff --git a/src/components/TotalOrgSummary/VolunteerStatus/MentorStatusPieChart.jsx b/src/components/TotalOrgSummary/VolunteerStatus/MentorStatusPieChart.jsx deleted file mode 100644 index 08f0b384504..00000000000 --- a/src/components/TotalOrgSummary/VolunteerStatus/MentorStatusPieChart.jsx +++ /dev/null @@ -1,109 +0,0 @@ -import PropTypes from 'prop-types'; -import { Doughnut } from 'react-chartjs-2'; -import { Chart, ArcElement } from 'chart.js'; -import styles from './MentorStatusPieChart.module.css'; -import externalLabelGuidesPlugin from './externalLabelGuidesPlugin'; - -Chart.register(ArcElement); - -function MentorStatusPieChart({ - data: { totalMentors, percentageChange, data: mentorData }, - comparisonType, -}) { - const chartData = { - labels: mentorData.map(item => item.label), - datasets: [ - { - data: mentorData.map(item => item.value), - backgroundColor: ['#287D5A', '#2D9DA6', '#F26B38'], - borderWidth: 1, - }, - ], - }; - - const options = { - plugins: { - datalabels: { - display: false, - }, - legend: { - display: false, - }, - tooltip: { - enabled: true, - }, - externalLabelGuides: { - offset: 20, - horizontalSpread: 32, - horizontalSpreadMap: { 0: 32, 1: 46, 2: 5 }, - verticalOffsetMap: { 0: 34, 1: -20, 2: -46 }, - sideMap: { 0: 1, 1: -1, 2: 1 }, - total: totalMentors, - formatter: ({ value, percentage }) => [`${value}`, `(${percentage}%)`], - }, - }, - maintainAspectRatio: false, - cutout: '65%', - layout: { - padding: 20, - }, - }; - - const percentageChangeColor = percentageChange >= 0 ? 'green' : 'red'; - - return ( -
-
- -
-

TOTAL MENTORS

-

{totalMentors}

- {comparisonType !== 'No Comparison' && ( -

- {percentageChange >= 0 - ? `+${percentageChange}% ${comparisonType.toUpperCase()}` - : `${percentageChange}% ${comparisonType.toUpperCase()}`} -

- )} -
-
-
- {mentorData.map((item, index) => ( -
-
- ))} -
-
- ); -} - -MentorStatusPieChart.propTypes = { - data: PropTypes.shape({ - totalMentors: PropTypes.number.isRequired, - percentageChange: PropTypes.number.isRequired, - data: PropTypes.arrayOf( - PropTypes.shape({ - label: PropTypes.string.isRequired, - value: PropTypes.number.isRequired, - }), - ).isRequired, - }).isRequired, - comparisonType: PropTypes.string.isRequired, -}; - -export default MentorStatusPieChart; diff --git a/src/components/TotalOrgSummary/VolunteerStatus/MentorStatusPieChart.module.css b/src/components/TotalOrgSummary/VolunteerStatus/MentorStatusPieChart.module.css deleted file mode 100644 index 9a3280b6d1c..00000000000 --- a/src/components/TotalOrgSummary/VolunteerStatus/MentorStatusPieChart.module.css +++ /dev/null @@ -1,130 +0,0 @@ -/* stylelint-disable */ -.mentorStatusContainer { - margin-top: 24px; - display: flex; - flex-direction: column; - align-items: center; - justify-content: flex-start; - width: 100%; - height: 100%; - gap: 12px; -} - -.mentorStatusChart { - position: relative; - width: min(320px, 100%); - max-width: 320px; - aspect-ratio: 1 / 1; - overflow: visible; - - /* Enable container queries to scale font-size based on chart container width */ - container-type: inline-size; -} - -.mentorStatusCenter { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - text-align: center; - font-size: 14px; - display: flex; - flex-direction: column; - align-items: center; - gap: 6px; -} - -.mentorStatusHeading { - color: #828282; - letter-spacing: 0.5px; - text-transform: uppercase; - transform: translateY(6px); - width: 100%; - text-align: center; - - /* Scale font-size based on chart container width (not viewport width) */ - - /* At 280px container: 0.95rem, At 320px container: 1.2rem */ - - /* Uses container query width units (cqw): 1cqw = 1% of container width */ - - /* Percentage-based scaling: ~5.43-6% of container width */ - font-size: clamp(0.95rem, 5.71cqw, 1.2rem); -} - -.mentorCount { - color: #6c6c6c; - font-weight: 800; - line-height: 1.18; - margin-top: -10px; - - /* Scale font-size based on chart container width (not viewport width) */ - - /* At 280px container: 1.35rem, At 320px container: 1.8rem */ - - /* Uses container query width units (cqw): 1cqw = 1% of container width */ - - /* Percentage-based scaling: ~7.71-9% of container width */ - font-size: clamp(1.35rem, 8.36cqw, 1.8rem); -} - -.mentorPercentageChange { - font-weight: 600; -} - -.mentorStatusLabels { - display: flex; - justify-content: center; - align-items: center; - gap: 16px; - margin-top: 48px; - margin-bottom: 24px; - flex-wrap: wrap; - overflow-x: visible; - padding: 0 8px; - width: 100%; - max-width: 100%; - box-sizing: border-box; -} - -.mentorStatusLabel { - display: flex; - align-items: center; - gap: 6px; - - /* Responsive font-size: scales from 0.875rem at 1200px to 0.95rem at 1920px - INCREASES with screen size */ - font-size: clamp(0.875rem, calc(0.75rem + 0.01042vw), 0.95rem); - white-space: nowrap; - flex-shrink: 0; -} - -.mentorStatusColor { - display: inline-block; - width: 12px; - height: 12px; - border-radius: 2px; -} - -:global(.bg-oxford-blue) .mentorStatusHeading { - color: #f1f5ff; -} - -:global(.bg-oxford-blue) .mentorCount { - color: #fff; -} - -@media (width <= 768px) { - .mentorStatusChart { - width: min(280px, 100%); - } - - .mentorStatusLabels { - gap: 12px; - } -} - -@media (width >= 768px) { - .mentorStatusChart { - max-width: 280px; - } -} diff --git a/src/components/TotalOrgSummary/VolunteerStatus/VolunteerStatusChart.jsx b/src/components/TotalOrgSummary/VolunteerStatus/VolunteerStatusChart.jsx index 7748f8e1433..a72eed745d8 100644 --- a/src/components/TotalOrgSummary/VolunteerStatus/VolunteerStatusChart.jsx +++ b/src/components/TotalOrgSummary/VolunteerStatus/VolunteerStatusChart.jsx @@ -1,15 +1,18 @@ import { useMemo } from 'react'; import PropTypes from 'prop-types'; import Loading from '~/components/common/Loading'; -import VolunteerStatusPieChart from './VolunteerStatusPieChart'; -import MentorStatusPieChart from './MentorStatusPieChart'; +import DonutChart from '../DonutChart/DonutChart'; import styles from './VolunteerStatusChart.module.css'; +const VOLUNTEER_COLORS = ['#4C4AF5', '#2CCCF8', '#FF00C3']; +const MENTOR_COLORS = ['#287D5A', '#2D9DA6', '#F26B38']; + function VolunteerStatusChart({ isLoading, volunteerNumberStats, mentorNumberStats, comparisonType, + darkMode, }) { const volunteerChartData = useMemo(() => { if (!volunteerNumberStats) { @@ -95,17 +98,30 @@ function VolunteerStatusChart({ ) : ( <>
-
- {volunteerChartData && ( - + - )} -
+
+ )} {mentorChartData && ( -
- +
+
)}
@@ -123,6 +139,7 @@ function VolunteerStatusChart({ VolunteerStatusChart.propTypes = { isLoading: PropTypes.bool, comparisonType: PropTypes.string, + darkMode: PropTypes.bool, volunteerNumberStats: PropTypes.shape({ donutChartData: PropTypes.shape({ existingActive: PropTypes.shape({ diff --git a/src/components/TotalOrgSummary/VolunteerStatus/VolunteerStatusChart.module.css b/src/components/TotalOrgSummary/VolunteerStatus/VolunteerStatusChart.module.css index 712d3eccd08..5acd02c4346 100644 --- a/src/components/TotalOrgSummary/VolunteerStatus/VolunteerStatusChart.module.css +++ b/src/components/TotalOrgSummary/VolunteerStatus/VolunteerStatusChart.module.css @@ -1,6 +1,5 @@ /* stylelint-disable */ .chartRoot { - margin-top: 1.5rem; height: 100%; display: flex; flex-direction: column; @@ -8,64 +7,20 @@ .volunteerMentorChartsWrapper { display: flex; - flex-direction: column; - align-items: center; - justify-content: center; + flex-wrap: wrap; gap: 24px; width: 100%; - flex: 1 1 auto; } -.volunteerChartSection, -.mentorChartSection { - width: 100%; - display: flex; - justify-content: center; - align-items: stretch; - flex: 1 1 100%; +.chartSection { + flex: 1 1 18rem; min-width: 0; } -@media (width >= 768px) { - .volunteerMentorChartsWrapper { - flex-flow: row wrap; - align-items: stretch; - justify-content: center; - } - - .volunteerChartSection, - .mentorChartSection { - flex: 1 1 280px; - max-width: 360px; - } - - .chartRoot { - min-height: 520px; - } -} - -@media (width >= 1200px) { - .volunteerMentorChartsWrapper { - flex-wrap: nowrap; - justify-content: space-evenly; - } - - .volunteerChartSection { - flex: 1 1 340px; - max-width: 400px; - } - - .mentorChartSection { - flex: 1 1 320px; - max-width: 400px; - } -} - .volunteerMentorFootnote { - margin-top: 105px; + margin-top: 48px; text-align: center; font-size: 0.75rem; - color: #4f4f4f; max-width: 560px; align-self: center; } diff --git a/src/components/TotalOrgSummary/VolunteerStatus/VolunteerStatusPieChart.jsx b/src/components/TotalOrgSummary/VolunteerStatus/VolunteerStatusPieChart.jsx deleted file mode 100644 index 52124e49ae4..00000000000 --- a/src/components/TotalOrgSummary/VolunteerStatus/VolunteerStatusPieChart.jsx +++ /dev/null @@ -1,116 +0,0 @@ -import PropTypes from 'prop-types'; -import { Doughnut } from 'react-chartjs-2'; -import { Chart, ArcElement } from 'chart.js'; -import styles from './VolunteerStatusPieChart.module.css'; -import externalLabelGuidesPlugin from './externalLabelGuidesPlugin'; - -Chart.register(ArcElement); - -function VolunteerStatusPieChart({ - data: { totalVolunteers, percentageChange, data: volunteerData }, - comparisonType, -}) { - const chartData = { - labels: volunteerData.map(item => item.label), - datasets: [ - { - data: volunteerData.map(item => item.value), - backgroundColor: ['#4C4AF5', '#2CCCF8', '#FF00C3'], - borderWidth: 1, - }, - ], - }; - - const options = { - responsive: true, - maintainAspectRatio: false, - cutout: '65%', - layout: { - padding: { - top: 36, - bottom: 36, - left: 48, - right: 48, - }, - }, - plugins: { - datalabels: { - display: false, - }, - legend: { - display: false, - }, - tooltip: { - enabled: true, - }, - externalLabelGuides: { - offset: 16, - horizontalSpread: 28, - horizontalSpreadMap: { 0: 28, 1: 36, 2: 8 }, - verticalOffsetMap: { 0: 30, 1: -20, 2: -40 }, - sideMap: { 0: 1, 1: -1, 2: 1 }, - total: totalVolunteers, - formatter: ({ value, percentage }) => [`${value}`, `(${percentage}%)`], - }, - }, - }; - - const percentageChangeColor = percentageChange >= 0 ? 'green' : 'red'; - - return ( -
-
- -
- TOTAL VOLUNTEERS* - {totalVolunteers} - {comparisonType && comparisonType !== 'No Comparison' && ( - - {percentageChange >= 0 - ? `+${percentageChange}% ${comparisonType.toUpperCase()}` - : `${percentageChange}% ${comparisonType.toUpperCase()}`} - - )} -
-
- -
- {volunteerData.map((item, index) => ( -
-
- ))} -
-
- ); -} - -VolunteerStatusPieChart.propTypes = { - data: PropTypes.shape({ - totalVolunteers: PropTypes.number.isRequired, - percentageChange: PropTypes.number.isRequired, - data: PropTypes.arrayOf( - PropTypes.shape({ - label: PropTypes.string.isRequired, - value: PropTypes.number.isRequired, - }), - ).isRequired, - }).isRequired, - comparisonType: PropTypes.string.isRequired, -}; - -export default VolunteerStatusPieChart; diff --git a/src/components/TotalOrgSummary/VolunteerStatus/VolunteerStatusPieChart.module.css b/src/components/TotalOrgSummary/VolunteerStatus/VolunteerStatusPieChart.module.css deleted file mode 100644 index ee9133be7ac..00000000000 --- a/src/components/TotalOrgSummary/VolunteerStatus/VolunteerStatusPieChart.module.css +++ /dev/null @@ -1,115 +0,0 @@ -/* stylelint-disable */ -.volunteerStatusContainer { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - width: 100%; - min-height: 0; -} - -.volunteerStatusChart { - position: relative; - width: min(320px, 100%); - max-width: 320px; - height: 320px; - aspect-ratio: 1 / 1; - overflow: visible; - container-type: inline-size; -} - -.volunteerStatusChart canvas { - max-width: 100% !important; - max-height: 100% !important; -} - -.volunteerStatusCenter { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - text-align: center; - width: 55%; - pointer-events: none; - z-index: 2; -} - -.volunteerStatusHeading { - color: #828282; - text-align: center; - line-height: 1.1; - max-width: 90%; - margin: 0; - font-weight: 600; - font-size: clamp(0.75rem, 4.5cqw, 0.95rem); -} - -.volunteerCount { - color: #4a4a4a; - font-weight: 700; - line-height: 1.2; - margin: 4px 0 0 0; - font-size: clamp(1.2rem, 7.5cqw, 1.6rem); -} - -.comparisonText { - font-weight: bold; - font-size: clamp(0.7rem, 4cqw, 0.85rem); - margin-top: 2px; -} - -.volunteerStatusLabels { - display: flex; - justify-content: center; - align-items: center; - gap: 16px; - margin-top: 32px; - flex-wrap: wrap; - margin-bottom: 24px; - overflow-x: visible; - padding: 0 8px; - width: 100%; - max-width: 100%; - box-sizing: border-box; -} - -.volunteerStatusLabel { - display: flex; - align-items: center; - gap: 6px; - margin: 0; - font-size: clamp(0.875rem, calc(0.75rem + 0.01042vw), 0.95rem); - white-space: nowrap; - flex-shrink: 0; -} - -.volunteerStatusColor { - display: inline-block; - width: 12px; - height: 12px; - margin-right: 0; - border-radius: 2px; -} - -:global(.bg-oxford-blue) .volunteerStatusHeading { - color: #f1f5ff; -} - -:global(.bg-oxford-blue) .volunteerCount { - color: #fff; -} - -@media (width <= 768px) { - .volunteerStatusChart { - width: min(280px, 100%); - height: 280px; - } - - .volunteerStatusLabels { - gap: 12px; - } -} From f12098faae80e6a5ec1698f0018b187904d058cb Mon Sep 17 00:00:00 2001 From: Akshay Date: Fri, 2 Oct 2026 13:40:02 -0600 Subject: [PATCH 02/10] fix: render Role Distribution with the shared DonutChart Role Distribution now matches the other Total Org Summary donuts. - Add minLabelPercent to DonutChart so only roles at 2%+ get outside labels; small roles no longer pile up at the top of the ring. - Keep every non-zero item in the DonutChart legend, even slivers too thin to draw (e.g. a role with 1 member). - externalLabelGuidesPlugin skips a label when the formatter returns null. --- .../TotalOrgSummary/DonutChart/DonutChart.jsx | 32 ++- .../DonutChart/__tests__/DonutChart.test.js | 32 +++ .../RoleDistributionPieChart.jsx | 221 ++---------------- .../externalLabelGuidesPlugin.js | 3 + 4 files changed, 72 insertions(+), 216 deletions(-) diff --git a/src/components/TotalOrgSummary/DonutChart/DonutChart.jsx b/src/components/TotalOrgSummary/DonutChart/DonutChart.jsx index 6bdbbd10406..615ed459057 100644 --- a/src/components/TotalOrgSummary/DonutChart/DonutChart.jsx +++ b/src/components/TotalOrgSummary/DonutChart/DonutChart.jsx @@ -36,15 +36,25 @@ export const buildDonutTooltipOptions = (totalCount, darkMode) => ({ }); function DonutChart(props) { - const { title, totalCount, percentageChange, data, colors, comparisonType, darkMode } = props; + const { + title, + totalCount, + percentageChange, + data, + colors, + comparisonType, + darkMode, + minLabelPercent, + } = props; const labelTextColor = darkMode ? '#e2e8f0' : '#334155'; const labelBoxBackground = darkMode ? 'rgba(15, 23, 42, 0.96)' : 'rgba(255, 255, 255, 0.96)'; const labelBoxBorder = darkMode ? 'rgba(148, 163, 184, 0.35)' : '#d0d0d0'; const titleLines = title.startsWith('TOTAL ') ? ['TOTAL', title.slice(6)] : [title]; - const filtered = data - .map((item, i) => ({ item, color: colors[i] })) - .filter(({ item }) => (item.value / totalCount) * 100 >= 0.05); + const withColors = data.map((item, i) => ({ item, color: colors[i] })); + // Slivers under 0.05% are not drawn, but every non-zero item stays in the legend. + const legendItems = withColors.filter(({ item }) => item.value > 0); + const filtered = withColors.filter(({ item }) => (item.value / totalCount) * 100 >= 0.05); const filteredData = filtered.map(({ item }) => item); const filteredColors = filtered.map(({ color }) => color); @@ -91,7 +101,10 @@ function DonutChart(props) { lineColor: labelTextColor, backgroundColor: labelBoxBackground, borderColor: labelBoxBorder, - formatter: ({ value, percentage }) => [`${value}`, `(${percentage}%)`], + formatter: ({ value, percentage }) => + calculatePercentage(value, totalCount) < minLabelPercent + ? null + : [`${value}`, `(${percentage}%)`], }, }, interaction: { @@ -155,12 +168,9 @@ function DonutChart(props) {
- {filteredData.map((item, index) => ( + {legendItems.map(({ item, color }) => (
- + {formatLegendLabel(item, totalCount)}
))} @@ -183,10 +193,12 @@ DonutChart.propTypes = { colors: PropTypes.arrayOf(PropTypes.string.isRequired).isRequired, comparisonType: PropTypes.string.isRequired, darkMode: PropTypes.bool, + minLabelPercent: PropTypes.number, }; DonutChart.defaultProps = { darkMode: false, + minLabelPercent: 0, }; export default DonutChart; diff --git a/src/components/TotalOrgSummary/DonutChart/__tests__/DonutChart.test.js b/src/components/TotalOrgSummary/DonutChart/__tests__/DonutChart.test.js index b314a2848a1..859c76a0994 100644 --- a/src/components/TotalOrgSummary/DonutChart/__tests__/DonutChart.test.js +++ b/src/components/TotalOrgSummary/DonutChart/__tests__/DonutChart.test.js @@ -95,3 +95,35 @@ describe('DonutChart center title', () => { expect(screen.getByText('VOLUNTEERS*')).toBeInTheDocument(); }); }); + +describe('DonutChart small slices', () => { + const renderRoles = minLabelPercent => + render( + createElement(DonutChart, { + title: 'TOTAL MEMBERS', + totalCount: 2685, + percentageChange: 0, + data: [ + { label: 'Volunteer', value: 2654 }, + { label: 'TestRole', value: 30 }, + { label: 'Soham Admin', value: 1 }, + ], + colors: ['#8ebfff', '#2F80ED', '#56CCF2'], + comparisonType: 'No Comparison', + minLabelPercent, + }), + ); + + it('skips outside labels below minLabelPercent', () => { + renderRoles(2); + const { formatter } = lastDoughnutProps.options.plugins.externalLabelGuides; + expect(formatter({ value: 2654, percentage: 99 })).toEqual(['2654', '(99%)']); + expect(formatter({ value: 30, percentage: 1 })).toBeNull(); + }); + + it('keeps slivers too small to draw in the legend', () => { + renderRoles(0); + expect(lastDoughnutProps.data.labels).not.toContain('Soham Admin'); + expect(screen.getByText('Soham Admin: 1 (0.0%)')).toBeInTheDocument(); + }); +}); diff --git a/src/components/TotalOrgSummary/VolunteerRolesTeamDynamics/RoleDistributionPieChart.jsx b/src/components/TotalOrgSummary/VolunteerRolesTeamDynamics/RoleDistributionPieChart.jsx index c55c6a42234..fc219d40779 100644 --- a/src/components/TotalOrgSummary/VolunteerRolesTeamDynamics/RoleDistributionPieChart.jsx +++ b/src/components/TotalOrgSummary/VolunteerRolesTeamDynamics/RoleDistributionPieChart.jsx @@ -1,7 +1,5 @@ -import React from 'react'; -import { ResponsiveContainer, PieChart, Pie, Cell, Legend, Tooltip } from 'recharts'; import Loading from '~/components/common/Loading'; -import CustomTooltip from '../../CustomTooltip'; +import DonutChart from '../DonutChart/DonutChart'; const COLORS = [ '#2F80ED', @@ -26,33 +24,7 @@ const ROLE_COLOR_MAP = { Mentor: '#f2ff00', }; -const RADIAN = Math.PI / 180; - -const getContrastTextColor = hexColor => { - const hex = hexColor.replace('#', ''); - - const r = parseInt(hex.substring(0, 2), 16); - const g = parseInt(hex.substring(2, 4), 16); - const b = parseInt(hex.substring(4, 6), 16); - - const brightness = (r * 299 + g * 587 + b * 114) / 1000; - - return brightness > 160 ? '#000000' : '#FFFFFF'; -}; - const RoleDistributionPieChart = ({ roleDistributionStats = [], isLoading, darkMode }) => { - // Reusable function to sort data and assign colors. - const sortDataAndAssignColors = statsData => { - statsData?.sort((a, b) => b.count - a.count); - const mappedData = statsData?.map((item, index) => ({ - name: item._id, - value: item.count, - // Use a stable role mapping first, otherwise fallback by index. - color: ROLE_COLOR_MAP[item._id] || COLORS[index % COLORS.length], - })); - return mappedData; - }; - if (isLoading) { return (
@@ -68,187 +40,24 @@ const RoleDistributionPieChart = ({ roleDistributionStats = [], isLoading, darkM ? roleDistributionStats : roleDistributionStats?.comparison || []; - // Sort by count descending const sortedStats = [...rawData].sort((a, b) => (b.count || 0) - (a.count || 0)); - - const data = sortedStats.map((item, index) => ({ - name: item._id, - value: item.count, - color: ROLE_COLOR_MAP[item._id] || COLORS[index % COLORS.length], - })); - - const totalValue = data.reduce((sum, entry) => sum + entry.value, 0); - - const renderLabel = ({ cx, cy, midAngle, innerRadius, outerRadius, percent, index }) => { - if (percent <= 0.01 || !data[index]) return null; - - const slice = data[index]; - - const isSmallSlice = percent < 0.1; - - const labelColor = getContrastTextColor(slice.color); - - const labelClass = - labelColor === '#000000' ? 'role-distribution-label-dark' : 'role-distribution-label-light'; - - const radius = - innerRadius + (outerRadius - innerRadius) * (slice.name === 'Mentor' ? 0.34 : 0.52); - - const x = cx + radius * Math.cos(-midAngle * RADIAN); - - const y = cy + radius * Math.sin(-midAngle * RADIAN); - - if (isSmallSlice) { - return ( - - {`${slice.value} (${(percent * 100).toFixed(0)}%)`} - - ); - } - - return ( - - - {slice.value} - - - - {`(${(percent * 100).toFixed(0)}%)`} - - - ); - }; - - const renderCustomLegend = ({ payload }) => ( -
    - {payload.map(entry => { - const itemName = entry.value; - - const itemData = data.find(d => d.name === itemName); - - if (!itemData) return null; - - const { value, color } = itemData; - - const percentage = totalValue > 0 ? (value / totalValue) * 100 : 0; - - return ( -
  • -
    - - - {`${itemName}: ${value} (${percentage.toFixed(1)}%)`} - -
  • - ); - })} -
+ const data = sortedStats.map(item => ({ label: item._id, value: item.count || 0 })); + const colors = sortedStats.map( + (item, index) => ROLE_COLOR_MAP[item._id] || COLORS[index % COLORS.length], ); + const totalCount = data.reduce((sum, entry) => sum + entry.value, 0); return ( -
-
- - - - {data.map(entry => ( - - ))} - - - - - } /> - - -
-
+ ); }; diff --git a/src/components/TotalOrgSummary/VolunteerStatus/externalLabelGuidesPlugin.js b/src/components/TotalOrgSummary/VolunteerStatus/externalLabelGuidesPlugin.js index 9027f586d3c..d1089a0b59d 100644 --- a/src/components/TotalOrgSummary/VolunteerStatus/externalLabelGuidesPlugin.js +++ b/src/components/TotalOrgSummary/VolunteerStatus/externalLabelGuidesPlugin.js @@ -433,6 +433,9 @@ const externalLabelGuidesPlugin = { const percentage = options.total ? Math.round((value / options.total) * 100) : 0; const lines = options.formatter({ value, percentage, index }); + if (!lines) { + return; + } const labelLines = Array.isArray(lines) ? lines : [String(lines)]; const angle = (arc.startAngle + arc.endAngle) / 2; From 01c659b5d014933b71049820e6c35dc32bba30bd Mon Sep 17 00:00:00 2001 From: Akshay Date: Fri, 2 Oct 2026 13:59:35 -0600 Subject: [PATCH 03/10] fix: soften bar hover highlight on Work Distribution and Team Stats Recharts' default #ccc hover band glared on the dark cards. Use a faint tint instead (white 8% in dark mode, black 5% in light mode). --- .../TotalOrgSummary/TeamStats/TeamStatsBarChart.jsx | 6 +++++- .../VolunteerRolesTeamDynamics/WorkDistributionBarChart.jsx | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/components/TotalOrgSummary/TeamStats/TeamStatsBarChart.jsx b/src/components/TotalOrgSummary/TeamStats/TeamStatsBarChart.jsx index c47ad5f8de2..d3c1012b123 100644 --- a/src/components/TotalOrgSummary/TeamStats/TeamStatsBarChart.jsx +++ b/src/components/TotalOrgSummary/TeamStats/TeamStatsBarChart.jsx @@ -77,7 +77,11 @@ function TeamStatsBarChart({ data, yAxisLabel }) { className={styles.teamStatsYAxis} tick={{ fill: darkMode ? 'white' : '#666' }} /> - + {data.map((_, index) => ( diff --git a/src/components/TotalOrgSummary/VolunteerRolesTeamDynamics/WorkDistributionBarChart.jsx b/src/components/TotalOrgSummary/VolunteerRolesTeamDynamics/WorkDistributionBarChart.jsx index f79aa5d9747..0b651d01feb 100644 --- a/src/components/TotalOrgSummary/VolunteerRolesTeamDynamics/WorkDistributionBarChart.jsx +++ b/src/components/TotalOrgSummary/VolunteerRolesTeamDynamics/WorkDistributionBarChart.jsx @@ -111,7 +111,11 @@ export default function WorkDistributionBarChart({ isLoading, workDistributionSt fontSize: 14, }} /> - } /> + } + // Default hover band is #ccc, which glares on the dark card. + cursor={{ fill: isDarkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.05)' }} + /> Date: Thu, 8 Oct 2026 18:35:24 -0600 Subject: [PATCH 04/10] fix: address Total Org Summary donut review feedback (#5608) - Centre text sizes itself from the measured hole (new holeSizePlugin sets --donut-hole), so titles, counts and the comparison line stay inside the ring at any width instead of being clipped (seen at ~1470px). - Slice labels, legend and tooltip share one percent format with one decimal, so a small slice no longer shows 0% beside 0.3% in the legend. - Comparison shows N/A when the backend sends "No Comparison Data", instead of NaN (Blue Square) or a misleading +0% (Volunteers, Mentors). - Hours Distribution slices are volunteer counts per bucket; the centre still shows total hours. Removes the proportional hours estimate that read like counts. Weekly Committed Hours explains it is not available yet (data comes with backend #2388). - Team Stats shows the change as a signed percent ("-2%") instead of "--0.02 volunteers". - Chart titles get a line-height so wrapped headings are not cramped. --- .../TotalOrgSummary/DonutChart/DonutChart.jsx | 86 +++++++++++++++---- .../DonutChart/DonutChart.module.css | 19 ++-- .../DonutChart/__tests__/DonutChart.test.js | 74 +++++++++++++++- .../TeamStats/TeamStatsBarLabel.jsx | 3 +- .../TotalOrgSummary.module.css | 3 + .../VolunteerHoursDistribution.jsx | 72 +++++----------- .../VolunteerHoursDistribution.test.jsx | 36 +++++--- .../VolunteerStatus/VolunteerStatusChart.jsx | 6 +- 8 files changed, 205 insertions(+), 94 deletions(-) diff --git a/src/components/TotalOrgSummary/DonutChart/DonutChart.jsx b/src/components/TotalOrgSummary/DonutChart/DonutChart.jsx index 615ed459057..2459b7a256f 100644 --- a/src/components/TotalOrgSummary/DonutChart/DonutChart.jsx +++ b/src/components/TotalOrgSummary/DonutChart/DonutChart.jsx @@ -10,12 +10,39 @@ Chart.register(ArcElement, Tooltip, Legend); const calculatePercentage = (value, totalCount) => Number.isFinite(totalCount) && totalCount > 0 ? (value / totalCount) * 100 : 0; -export const formatLegendLabel = ({ label, value }, totalCount) => { - const percentage = calculatePercentage(value, totalCount); - return `${label}: ${value} (${percentage.toFixed(1)}%)`; +// One format for slice labels, legend and tooltip so they never disagree +// (a label used to round 8 of 2,573 to "0%" while the legend said "0.3%"). +export const formatPercent = (value, totalCount) => + `${calculatePercentage(value, totalCount).toFixed(1)}%`; + +export const formatLegendLabel = ({ label, value }, totalCount, unitLabel = '') => + `${label}: ${value}${unitLabel ? ` ${unitLabel}` : ''} (${formatPercent(value, totalCount)})`; + +// The backend sends "No Comparison Data" (a string) when the previous period was 0. +export const formatComparison = (percentageChange, comparisonType) => { + const type = comparisonType.toUpperCase(); + const change = Number(percentageChange); + if (percentageChange === null || percentageChange === '' || !Number.isFinite(change)) { + return `N/A ${type}`; + } + const pct = (change * 100).toFixed(0); + return `${change >= 0 ? '+' : ''}${pct}% ${type}`; +}; + +// Publishes the doughnut's real hole diameter as --donut-hole on the chart wrapper, +// so the centre text can size itself to fit instead of spilling under the ring +// when the chart is narrow (e.g. Volunteers and Mentors side by side at ~1470px). +export const holeSizePlugin = { + id: 'donutHoleSize', + afterLayout(chart) { + const arc = chart.getDatasetMeta(0)?.data?.[0]; + const wrapper = chart.canvas?.parentNode; + if (!arc || !wrapper) return; + wrapper.style.setProperty('--donut-hole', `${Math.max(0, arc.innerRadius * 2)}px`); + }, }; -export const buildDonutTooltipOptions = (totalCount, darkMode) => ({ +export const buildDonutTooltipOptions = (totalCount, darkMode, unitLabel = '') => ({ enabled: true, backgroundColor: darkMode ? '#222' : '#fff', titleColor: darkMode ? '#fff' : '#222', @@ -29,8 +56,10 @@ export const buildDonutTooltipOptions = (totalCount, darkMode) => ({ title: items => items?.[0]?.label || '', label: context => { const count = Number.isFinite(context.raw) ? context.raw : 0; - const percentage = calculatePercentage(count, totalCount); - return [`Count: ${count}`, `Percentage: ${percentage.toFixed(1)}%`]; + return [ + `Count: ${count}${unitLabel ? ` ${unitLabel}` : ''}`, + `Percentage: ${formatPercent(count, totalCount)}`, + ]; }, }, }); @@ -45,6 +74,9 @@ function DonutChart(props) { comparisonType, darkMode, minLabelPercent, + centerCount, + unitLabel, + emptyMessage, } = props; const labelTextColor = darkMode ? '#e2e8f0' : '#334155'; const labelBoxBackground = darkMode ? 'rgba(15, 23, 42, 0.96)' : 'rgba(255, 255, 255, 0.96)'; @@ -65,7 +97,7 @@ function DonutChart(props) {
{title}
-
No data available yet
+
{emptyMessage}
); @@ -87,7 +119,7 @@ function DonutChart(props) { plugins: { datalabels: { display: false }, legend: { display: false }, - tooltip: buildDonutTooltipOptions(totalCount, darkMode), + tooltip: buildDonutTooltipOptions(totalCount, darkMode, unitLabel), externalLabelGuides: { placement: 'outside', outsideGap: 12, @@ -101,10 +133,10 @@ function DonutChart(props) { lineColor: labelTextColor, backgroundColor: labelBoxBackground, borderColor: labelBoxBorder, - formatter: ({ value, percentage }) => + formatter: ({ value }) => calculatePercentage(value, totalCount) < minLabelPercent ? null - : [`${value}`, `(${percentage}%)`], + : [`${value}`, `(${formatPercent(value, totalCount)})`], }, }, interaction: { @@ -128,13 +160,21 @@ function DonutChart(props) { }, }; - const percentageChangeColor = percentageChange >= 0 ? 'var(--success)' : 'var(--danger)'; + const comparisonText = + comparisonType !== 'No Comparison' ? formatComparison(percentageChange, comparisonType) : null; + let percentageChangeColor = 'var(--success)'; + if (comparisonText?.startsWith('N/A')) percentageChangeColor = undefined; + else if (Number(percentageChange) < 0) percentageChangeColor = 'var(--danger)'; return (
- +
- {totalCount} + {centerCount ?? totalCount}
- {comparisonType !== 'No Comparison' && ( + {comparisonText && (
- {percentageChange >= 0 - ? `+${(percentageChange * 100).toFixed(0)}% ${comparisonType.toUpperCase()}` - : `${(percentageChange * 100).toFixed(0)}% ${comparisonType.toUpperCase()}`} + {comparisonText}
)}
@@ -171,7 +209,7 @@ function DonutChart(props) { {legendItems.map(({ item, color }) => (
- {formatLegendLabel(item, totalCount)} + {formatLegendLabel(item, totalCount, unitLabel)}
))}
@@ -183,7 +221,8 @@ function DonutChart(props) { DonutChart.propTypes = { title: PropTypes.string.isRequired, totalCount: PropTypes.number.isRequired, - percentageChange: PropTypes.number.isRequired, + // number, or the backend's "No Comparison Data" string (shown as N/A) + percentageChange: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), data: PropTypes.arrayOf( PropTypes.shape({ label: PropTypes.string.isRequired, @@ -194,11 +233,20 @@ DonutChart.propTypes = { comparisonType: PropTypes.string.isRequired, darkMode: PropTypes.bool, minLabelPercent: PropTypes.number, + // number shown in the centre when it differs from the slice total (e.g. total hours) + centerCount: PropTypes.number, + // unit appended to legend/tooltip counts, e.g. "volunteers" + unitLabel: PropTypes.string, + emptyMessage: PropTypes.string, }; DonutChart.defaultProps = { darkMode: false, + percentageChange: null, minLabelPercent: 0, + centerCount: undefined, + unitLabel: '', + emptyMessage: 'No data available yet', }; export default DonutChart; diff --git a/src/components/TotalOrgSummary/DonutChart/DonutChart.module.css b/src/components/TotalOrgSummary/DonutChart/DonutChart.module.css index d1ffae3da36..4520508c416 100644 --- a/src/components/TotalOrgSummary/DonutChart/DonutChart.module.css +++ b/src/components/TotalOrgSummary/DonutChart/DonutChart.module.css @@ -54,9 +54,12 @@ flex-direction: column; align-items: center; justify-content: center; - gap: 0.28rem; - width: 52%; - max-width: 6rem; + gap: 0.2rem; + /* --donut-hole is the measured hole diameter (set by holeSizePlugin). Stay + inside it so the ring, drawn on top, never cuts the text off. The text + below sizes itself in cqi (% of this box) so it shrinks with the donut. */ + width: calc(var(--donut-hole, 8rem) * 0.8); + container-type: inline-size; pointer-events: none; } @@ -66,12 +69,11 @@ .donutHeading { color: #828282; - font-size: 0.88rem; + font-size: clamp(0.5rem, 10cqi, 0.88rem); letter-spacing: 0.03em; line-height: 1.15; text-transform: uppercase; - white-space: normal; - overflow-wrap: anywhere; + white-space: nowrap; } .donutHeadingLine { @@ -84,7 +86,7 @@ .donutCount { color: #828282; - font-size: 2rem; + font-size: clamp(0.85rem, 24cqi, 2rem); line-height: 1; font-weight: 800; } @@ -94,8 +96,9 @@ } .donutComparisonPercent { - font-size: 0.7rem; + font-size: clamp(0.45rem, 8cqi, 0.7rem); line-height: 1.1; + font-weight: 700; } .donutLabels { diff --git a/src/components/TotalOrgSummary/DonutChart/__tests__/DonutChart.test.js b/src/components/TotalOrgSummary/DonutChart/__tests__/DonutChart.test.js index 859c76a0994..5e406eaf6f1 100644 --- a/src/components/TotalOrgSummary/DonutChart/__tests__/DonutChart.test.js +++ b/src/components/TotalOrgSummary/DonutChart/__tests__/DonutChart.test.js @@ -1,6 +1,12 @@ import { createElement } from 'react'; import { render, screen } from '@testing-library/react'; -import DonutChart, { buildDonutTooltipOptions, formatLegendLabel } from '../DonutChart'; +import DonutChart, { + buildDonutTooltipOptions, + formatComparison, + formatLegendLabel, + formatPercent, + holeSizePlugin, +} from '../DonutChart'; // Chart.js draws to a jsdom doesn't implement; capture the options instead. let lastDoughnutProps; @@ -117,7 +123,7 @@ describe('DonutChart small slices', () => { it('skips outside labels below minLabelPercent', () => { renderRoles(2); const { formatter } = lastDoughnutProps.options.plugins.externalLabelGuides; - expect(formatter({ value: 2654, percentage: 99 })).toEqual(['2654', '(99%)']); + expect(formatter({ value: 2654, percentage: 99 })).toEqual(['2654', '(98.8%)']); expect(formatter({ value: 30, percentage: 1 })).toBeNull(); }); @@ -127,3 +133,67 @@ describe('DonutChart small slices', () => { expect(screen.getByText('Soham Admin: 1 (0.0%)')).toBeInTheDocument(); }); }); + +const renderSmall = () => + render( + createElement(DonutChart, { + title: 'TOTAL VOLUNTEERS*', + totalCount: 2573, + percentageChange: 0, + data: [ + { label: 'Existing Active', value: 2565 }, + { label: 'New Active', value: 8 }, + ], + colors: ['#4C4AF5', '#2CCCF8'], + comparisonType: 'No Comparison', + }), + ); + +describe('DonutChart review fixes (#5608)', () => { + it('never rounds a non-zero slice to 0%: label and legend use the same value', () => { + expect(formatPercent(8, 2573)).toBe('0.3%'); + renderSmall(); + const { formatter } = lastDoughnutProps.options.plugins.externalLabelGuides; + // the plugin's own whole-number percentage is ignored + expect(formatter({ value: 8, percentage: 0 })).toEqual(['8', '(0.3%)']); + expect(screen.getByText('New Active: 8 (0.3%)')).toBeInTheDocument(); + }); + + it('shows N/A instead of NaN when the backend sends No Comparison Data', () => { + expect(formatComparison('No Comparison Data', 'Year Over Year')).toBe('N/A YEAR OVER YEAR'); + expect(formatComparison(null, 'Week Over Week')).toBe('N/A WEEK OVER WEEK'); + expect(formatComparison(Number.NaN, 'Week Over Week')).toBe('N/A WEEK OVER WEEK'); + expect(formatComparison(0.12, 'Week Over Week')).toBe('+12% WEEK OVER WEEK'); + expect(formatComparison(-0.05, 'Month Over Month')).toBe('-5% MONTH OVER MONTH'); + }); + + it('shows centerCount in the centre while percentages use the slice total', () => { + render( + createElement(DonutChart, { + title: 'TOTAL HOURS WORKED', + totalCount: 5, + centerCount: 1234, + unitLabel: 'volunteers', + data: [ + { label: '10-19 hrs', value: 2 }, + { label: '20-29 hrs', value: 3 }, + ], + colors: ['#00AFF4', '#FFA500'], + comparisonType: 'No Comparison', + }), + ); + expect(screen.getByText('1234')).toBeInTheDocument(); + expect(screen.getByText('10-19 hrs: 2 volunteers (40.0%)')).toBeInTheDocument(); + }); + + it('publishes the hole diameter so the centre text can fit inside the ring', () => { + const wrapper = document.createElement('div'); + const canvas = document.createElement('canvas'); + wrapper.appendChild(canvas); + holeSizePlugin.afterLayout({ + canvas, + getDatasetMeta: () => ({ data: [{ innerRadius: 53 }] }), + }); + expect(wrapper.style.getPropertyValue('--donut-hole')).toBe('106px'); + }); +}); diff --git a/src/components/TotalOrgSummary/TeamStats/TeamStatsBarLabel.jsx b/src/components/TotalOrgSummary/TeamStats/TeamStatsBarLabel.jsx index c2168bc8192..f6f95abdde3 100644 --- a/src/components/TotalOrgSummary/TeamStats/TeamStatsBarLabel.jsx +++ b/src/components/TotalOrgSummary/TeamStats/TeamStatsBarLabel.jsx @@ -41,7 +41,8 @@ function TeamStatsBarLabel({ x, y, width, height, value, change, percentage }) { dominantBaseline="middle" className="team-stats-value" > - {change >= 0 ? `+${change} volunteers` : `-${change} volunteers`} + {/* change is a growth fraction (-0.02 = -2%), not a volunteer count */} + {`${change >= 0 ? '+' : ''}${Math.round(change * 100)}%`} ); diff --git a/src/components/TotalOrgSummary/TotalOrgSummary.module.css b/src/components/TotalOrgSummary/TotalOrgSummary.module.css index 49bc0e60802..3d591d762eb 100644 --- a/src/components/TotalOrgSummary/TotalOrgSummary.module.css +++ b/src/components/TotalOrgSummary/TotalOrgSummary.module.css @@ -464,6 +464,9 @@ .totalOrgChartTitle p { font-size: 1.8rem !important; + + /* long titles (e.g. Global Volunteer Network) wrap on narrow cards */ + line-height: 1.3; } /* Report Header Styles */ diff --git a/src/components/TotalOrgSummary/VolunteerHoursDistribution/VolunteerHoursDistribution.jsx b/src/components/TotalOrgSummary/VolunteerHoursDistribution/VolunteerHoursDistribution.jsx index 896cee755e2..4b6a498957f 100644 --- a/src/components/TotalOrgSummary/VolunteerHoursDistribution/VolunteerHoursDistribution.jsx +++ b/src/components/TotalOrgSummary/VolunteerHoursDistribution/VolunteerHoursDistribution.jsx @@ -45,39 +45,6 @@ function mergeHoursBuckets(hoursData) { return [...merged.entries()].map(([id, count]) => ({ _id: id, count })).sort(compareBuckets); } -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) { - // FIX: Avoiding direct property mutation on array references being re-sorted - byRemainderDesc[i % byRemainderDesc.length].allocatedHours += 1; - remaining -= 1; - i += 1; - } - - return byRemainderDesc.map(({ remainder, ...bucket }) => bucket).sort(compareBuckets); -} - export function formatRangeLabel(rangeStr) { if (!rangeStr) return ''; const normalizedRange = normalizeBucketId(rangeStr); @@ -99,32 +66,23 @@ export function formatCommittedRangeLabel(rangeStr) { return formatRangeLabel(normalizedRange); } +// Slices are always volunteer counts per bucket. The old version split the total +// hours across buckets in proportion to those counts, so the legend showed +// estimated hours that read like counts (e.g. "10-19 hrs: 41"). function buildChartData(hoursData, totalHoursData, useBucketCounts = false) { const normalizedHoursData = mergeHoursBuckets(hoursData); const totalVolunteers = normalizedHoursData.reduce((total, cur) => total + (cur.count || 0), 0); const totalHoursWorked = useBucketCounts ? totalVolunteers - : 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 => { - let value = range.count || 0; - let denominator = totalVolunteers; - if (!useBucketCounts && totalHoursWorked > 0) { - value = range.allocatedHours || 0; - denominator = totalAllocatedHours; - } + : Math.round(Number(totalHoursData?.current ?? totalHoursData?.count ?? 0)); + const userData = normalizedHoursData.map(range => { + const value = range.count || 0; return { name: useBucketCounts ? formatCommittedRangeLabel(range._id) : formatRangeLabel(range._id), value, - percentage: denominator ? Math.round((value / denominator) * 100) : 0, - ...(useBucketCounts && { valueType: 'volunteers' }), + percentage: totalVolunteers ? Math.round((value / totalVolunteers) * 100) : 0, + valueType: 'volunteers', }; }); @@ -153,7 +111,11 @@ export default function VolunteerHoursDistribution({ ); } - const { userData, totalHoursWorked } = buildChartData(hoursData, totalHoursData, useBucketCounts); + const { userData, totalVolunteers, totalHoursWorked } = buildChartData( + hoursData, + totalHoursData, + useBucketCounts, + ); return (
{title} ({ label: name, value }))} colors={COLORS} comparisonType="No Comparison" darkMode={darkMode} + emptyMessage={ + useBucketCounts ? 'Weekly committed hours are not available yet' : 'No data available yet' + } />
); diff --git a/src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/VolunteerHoursDistribution.test.jsx b/src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/VolunteerHoursDistribution.test.jsx index f8d1bf3fddf..5b7bd0f9999 100644 --- a/src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/VolunteerHoursDistribution.test.jsx +++ b/src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/VolunteerHoursDistribution.test.jsx @@ -20,7 +20,7 @@ afterEach(() => { }); describe('VolunteerHoursDistribution wrapper', () => { - it('passes totalHoursData.current to child and computes userData percentages', () => { + it('shows volunteer counts per bucket and total hours in the centre', () => { const hoursData = [ { _id: '10', count: 2 }, { _id: '20', count: 3 }, @@ -37,18 +37,17 @@ describe('VolunteerHoursDistribution wrapper', () => { { container }, ); - expect(screen.getByText('10-19 hrs: 494 (40.0%)')).toBeInTheDocument(); - expect(screen.getByText('20-29 hrs: 740 (60.0%)')).toBeInTheDocument(); + // slices are volunteers (2 and 3), not hours split across buckets + expect(screen.getByText('10-19 hrs: 2 volunteers (40.0%)')).toBeInTheDocument(); + expect(screen.getByText('20-29 hrs: 3 volunteers (60.0%)')).toBeInTheDocument(); + // centre still shows the headline total hours expect(screen.getByText('1234')).toBeInTheDocument(); - // Verify computeDistribution now allocates hours to buckets so slices add up to total hours const computed = computeDistribution(hoursData, totalHoursData); - - // FIXED: Assert that names in userData match the updated formatRangeLabel output expect(computed).toEqual({ userData: [ - { name: '10-19 hrs', value: 494, percentage: 40 }, - { name: '20-29 hrs', value: 740, percentage: 60 }, + { name: '10-19 hrs', value: 2, percentage: 40, valueType: 'volunteers' }, + { name: '20-29 hrs', value: 3, percentage: 60, valueType: 'volunteers' }, ], totalVolunteers: 5, totalHoursWorked: 1234, @@ -77,8 +76,8 @@ describe('VolunteerHoursDistribution wrapper', () => { ); expect(screen.getByText('Weekly Committed Hours')).toBeInTheDocument(); - expect(screen.getByText('40 hrs: 1 (12.5%)')).toBeInTheDocument(); - expect(screen.getByText('Over 40 hrs: 1 (12.5%)')).toBeInTheDocument(); + expect(screen.getByText('40 hrs: 1 volunteers (12.5%)')).toBeInTheDocument(); + expect(screen.getByText('Over 40 hrs: 1 volunteers (12.5%)')).toBeInTheDocument(); expect(screen.getByText('TOTAL')).toBeInTheDocument(); expect(screen.getByText('VOLUNTEERS')).toBeInTheDocument(); expect(screen.getByText('8')).toBeInTheDocument(); @@ -97,6 +96,23 @@ describe('VolunteerHoursDistribution wrapper', () => { }); }); +describe('empty committed hours', () => { + it('explains that committed hours are not available yet instead of a blank chart', () => { + render( + , + { container }, + ); + expect(screen.getByText('Weekly committed hours are not available yet')).toBeInTheDocument(); + }); +}); + describe('bucket ordering', () => { it('keeps 50-59 before 50+ when both start at 50', () => { const { userData } = computeDistribution( diff --git a/src/components/TotalOrgSummary/VolunteerStatus/VolunteerStatusChart.jsx b/src/components/TotalOrgSummary/VolunteerStatus/VolunteerStatusChart.jsx index 67fff5f9eb2..b34d448bd4f 100644 --- a/src/components/TotalOrgSummary/VolunteerStatus/VolunteerStatusChart.jsx +++ b/src/components/TotalOrgSummary/VolunteerStatus/VolunteerStatusChart.jsx @@ -59,7 +59,8 @@ function VolunteerStatusChart({ return { totalVolunteers: computedTotal, - percentageChange: Number(totalVolunteers?.comparisonPercentage) || 0, + // raw value: DonutChart shows N/A for "No Comparison Data" instead of a fake +0% + percentageChange: totalVolunteers?.comparisonPercentage ?? null, data: chartDataValues, }; }, [volunteerNumberStats]); @@ -108,7 +109,8 @@ function VolunteerStatusChart({ return { totalMentors: computedTotal, - percentageChange: Number(totalMentors?.comparisonPercentage) || 0, + // raw value: DonutChart shows N/A for "No Comparison Data" instead of a fake +0% + percentageChange: totalMentors?.comparisonPercentage ?? null, data: chartDataValues, }; }, [mentorNumberStats]); From 4585e08e7da286899066310a1720a7c3a268aed2 Mon Sep 17 00:00:00 2001 From: Akshay Date: Thu, 8 Oct 2026 18:50:21 -0600 Subject: [PATCH 05/10] fix: donut centre collapsing on mobile and tiny slices showing 0.0% (#5608) - holeSizePlugin read the hole during layout, before Chart.js sets the radius, so it published 0px and the centre wrapped one character per line ("2 / 6 / 8 / 3"). Read the controller's final innerRadius after update and ignore 0. Counts never wrap between digits; the comparison wraps only between words. - Stacked (narrow) layout: cancel the container's -35px bottom margin so the legend no longer overlaps the next chart's heading. - A non-zero slice under 0.05% shows "<0.1%" instead of "0.0%"; a count of 1 reads "1 volunteer". - Empty state shows only the message, without the meaningless centre title. --- .../TotalOrgSummary/DonutChart/DonutChart.jsx | 32 ++++++++++++------- .../DonutChart/DonutChart.module.css | 9 ++++++ .../DonutChart/__tests__/DonutChart.test.js | 23 ++++++++++--- .../VolunteerHoursDistribution.test.jsx | 4 +-- 4 files changed, 51 insertions(+), 17 deletions(-) diff --git a/src/components/TotalOrgSummary/DonutChart/DonutChart.jsx b/src/components/TotalOrgSummary/DonutChart/DonutChart.jsx index 2459b7a256f..23818d14ef4 100644 --- a/src/components/TotalOrgSummary/DonutChart/DonutChart.jsx +++ b/src/components/TotalOrgSummary/DonutChart/DonutChart.jsx @@ -12,11 +12,21 @@ const calculatePercentage = (value, totalCount) => // One format for slice labels, legend and tooltip so they never disagree // (a label used to round 8 of 2,573 to "0%" while the legend said "0.3%"). -export const formatPercent = (value, totalCount) => - `${calculatePercentage(value, totalCount).toFixed(1)}%`; +export const formatPercent = (value, totalCount) => { + const pct = calculatePercentage(value, totalCount); + // e.g. 1 of 2,708 is 0.04%: show "<0.1%" rather than a misleading "0.0%" + if (value > 0 && pct < 0.05) return '<0.1%'; + return `${pct.toFixed(1)}%`; +}; + +// "1 volunteer", "2 volunteers" +const withUnit = (value, unitLabel) => { + if (!unitLabel) return `${value}`; + return `${value} ${value === 1 ? unitLabel.replace(/s$/, '') : unitLabel}`; +}; export const formatLegendLabel = ({ label, value }, totalCount, unitLabel = '') => - `${label}: ${value}${unitLabel ? ` ${unitLabel}` : ''} (${formatPercent(value, totalCount)})`; + `${label}: ${withUnit(value, unitLabel)} (${formatPercent(value, totalCount)})`; // The backend sends "No Comparison Data" (a string) when the previous period was 0. export const formatComparison = (percentageChange, comparisonType) => { @@ -32,13 +42,16 @@ export const formatComparison = (percentageChange, comparisonType) => { // Publishes the doughnut's real hole diameter as --donut-hole on the chart wrapper, // so the centre text can size itself to fit instead of spilling under the ring // when the chart is narrow (e.g. Volunteers and Mentors side by side at ~1470px). +// Read after the update, from the controller: during layout (and while an arc is +// animating in) the radius is still 0, which collapsed the centre to one +// character per line. export const holeSizePlugin = { id: 'donutHoleSize', - afterLayout(chart) { - const arc = chart.getDatasetMeta(0)?.data?.[0]; + afterUpdate(chart) { + const innerRadius = chart.getDatasetMeta(0)?.controller?.innerRadius; const wrapper = chart.canvas?.parentNode; - if (!arc || !wrapper) return; - wrapper.style.setProperty('--donut-hole', `${Math.max(0, arc.innerRadius * 2)}px`); + if (!wrapper || !(innerRadius > 0)) return; + wrapper.style.setProperty('--donut-hole', `${innerRadius * 2}px`); }, }; @@ -57,7 +70,7 @@ export const buildDonutTooltipOptions = (totalCount, darkMode, unitLabel = '') = label: context => { const count = Number.isFinite(context.raw) ? context.raw : 0; return [ - `Count: ${count}${unitLabel ? ` ${unitLabel}` : ''}`, + `Count: ${withUnit(count, unitLabel)}`, `Percentage: ${formatPercent(count, totalCount)}`, ]; }, @@ -94,9 +107,6 @@ function DonutChart(props) { return (
-
- {title} -
{emptyMessage}
diff --git a/src/components/TotalOrgSummary/DonutChart/DonutChart.module.css b/src/components/TotalOrgSummary/DonutChart/DonutChart.module.css index 4520508c416..15b7702e28a 100644 --- a/src/components/TotalOrgSummary/DonutChart/DonutChart.module.css +++ b/src/components/TotalOrgSummary/DonutChart/DonutChart.module.css @@ -89,6 +89,7 @@ font-size: clamp(0.85rem, 24cqi, 2rem); line-height: 1; font-weight: 800; + white-space: nowrap; } .donutCountDark { @@ -99,6 +100,10 @@ font-size: clamp(0.45rem, 8cqi, 0.7rem); line-height: 1.1; font-weight: 700; + + /* wrap only between words ("+12% / WEEK OVER WEEK"), never inside one */ + overflow-wrap: normal; + word-break: normal; } .donutLabels { @@ -189,6 +194,10 @@ /* Narrow slots (e.g. two charts side by side): stack the legend under the donut. */ @container (width < 36rem) { .donutScrollable { + /* .donutContainer's -35px bottom margin suits the side legend; stacked, it + pulled the next heading up over the legend. The container can't style + itself in its own query, so cancel it here. */ + padding-bottom: 35px; grid-template-columns: 1fr; justify-items: center; min-width: 0; diff --git a/src/components/TotalOrgSummary/DonutChart/__tests__/DonutChart.test.js b/src/components/TotalOrgSummary/DonutChart/__tests__/DonutChart.test.js index 5e406eaf6f1..e585474812a 100644 --- a/src/components/TotalOrgSummary/DonutChart/__tests__/DonutChart.test.js +++ b/src/components/TotalOrgSummary/DonutChart/__tests__/DonutChart.test.js @@ -130,7 +130,7 @@ describe('DonutChart small slices', () => { it('keeps slivers too small to draw in the legend', () => { renderRoles(0); expect(lastDoughnutProps.data.labels).not.toContain('Soham Admin'); - expect(screen.getByText('Soham Admin: 1 (0.0%)')).toBeInTheDocument(); + expect(screen.getByText('Soham Admin: 1 (<0.1%)')).toBeInTheDocument(); }); }); @@ -186,14 +186,29 @@ describe('DonutChart review fixes (#5608)', () => { expect(screen.getByText('10-19 hrs: 2 volunteers (40.0%)')).toBeInTheDocument(); }); - it('publishes the hole diameter so the centre text can fit inside the ring', () => { + it('publishes the hole diameter after update, ignoring the 0 radius seen mid-layout', () => { const wrapper = document.createElement('div'); const canvas = document.createElement('canvas'); wrapper.appendChild(canvas); - holeSizePlugin.afterLayout({ + const chartWith = innerRadius => ({ canvas, - getDatasetMeta: () => ({ data: [{ innerRadius: 53 }] }), + getDatasetMeta: () => ({ controller: { innerRadius } }), }); + holeSizePlugin.afterUpdate(chartWith(53)); expect(wrapper.style.getPropertyValue('--donut-hole')).toBe('106px'); + // a 0 reading must not collapse the centre to one character per line + holeSizePlugin.afterUpdate(chartWith(0)); + expect(wrapper.style.getPropertyValue('--donut-hole')).toBe('106px'); + }); + + it('shows <0.1% for a tiny non-zero slice instead of 0.0%', () => { + expect(formatPercent(1, 2708)).toBe('<0.1%'); + expect(formatPercent(0, 2708)).toBe('0.0%'); + }); + + it('uses the singular unit for a count of 1', () => { + expect(formatLegendLabel({ label: '10-19 hrs', value: 1 }, 2, 'volunteers')).toBe( + '10-19 hrs: 1 volunteer (50.0%)', + ); }); }); diff --git a/src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/VolunteerHoursDistribution.test.jsx b/src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/VolunteerHoursDistribution.test.jsx index 5b7bd0f9999..998c3d06e7e 100644 --- a/src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/VolunteerHoursDistribution.test.jsx +++ b/src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/VolunteerHoursDistribution.test.jsx @@ -76,8 +76,8 @@ describe('VolunteerHoursDistribution wrapper', () => { ); expect(screen.getByText('Weekly Committed Hours')).toBeInTheDocument(); - expect(screen.getByText('40 hrs: 1 volunteers (12.5%)')).toBeInTheDocument(); - expect(screen.getByText('Over 40 hrs: 1 volunteers (12.5%)')).toBeInTheDocument(); + expect(screen.getByText('40 hrs: 1 volunteer (12.5%)')).toBeInTheDocument(); + expect(screen.getByText('Over 40 hrs: 1 volunteer (12.5%)')).toBeInTheDocument(); expect(screen.getByText('TOTAL')).toBeInTheDocument(); expect(screen.getByText('VOLUNTEERS')).toBeInTheDocument(); expect(screen.getByText('8')).toBeInTheDocument(); From 6da3e8897e8f0046175a0b7c44e4c4c063876a9a Mon Sep 17 00:00:00 2001 From: Akshay Date: Thu, 8 Oct 2026 18:52:53 -0600 Subject: [PATCH 06/10] fix: draw a single-slice donut as a full ring (#5608) The 2px slice spacing left a notch at the top when one category held 100% (e.g. all volunteers in one hour range). Only space slices when there are two or more. --- .../TotalOrgSummary/DonutChart/DonutChart.jsx | 3 ++- .../DonutChart/__tests__/DonutChart.test.js | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/components/TotalOrgSummary/DonutChart/DonutChart.jsx b/src/components/TotalOrgSummary/DonutChart/DonutChart.jsx index 23818d14ef4..ace7b604d3b 100644 --- a/src/components/TotalOrgSummary/DonutChart/DonutChart.jsx +++ b/src/components/TotalOrgSummary/DonutChart/DonutChart.jsx @@ -120,7 +120,8 @@ function DonutChart(props) { data: filteredData.map(item => item.value), backgroundColor: filteredColors, borderWidth: 0, - spacing: 2, + // a gap only makes sense between slices; with one slice it leaves a notch + spacing: filteredData.length > 1 ? 2 : 0, }, ], }; diff --git a/src/components/TotalOrgSummary/DonutChart/__tests__/DonutChart.test.js b/src/components/TotalOrgSummary/DonutChart/__tests__/DonutChart.test.js index e585474812a..945d8dce588 100644 --- a/src/components/TotalOrgSummary/DonutChart/__tests__/DonutChart.test.js +++ b/src/components/TotalOrgSummary/DonutChart/__tests__/DonutChart.test.js @@ -206,6 +206,19 @@ describe('DonutChart review fixes (#5608)', () => { expect(formatPercent(0, 2708)).toBe('0.0%'); }); + it('draws a single 100% slice as a full ring, with no spacing notch', () => { + render( + createElement(DonutChart, { + title: 'TOTAL HOURS WORKED', + totalCount: 3, + data: [{ label: '10-19 hrs', value: 3 }], + colors: ['#00AFF4'], + comparisonType: 'No Comparison', + }), + ); + expect(lastDoughnutProps.data.datasets[0].spacing).toBe(0); + }); + it('uses the singular unit for a count of 1', () => { expect(formatLegendLabel({ label: '10-19 hrs', value: 1 }, 2, 'volunteers')).toBe( '10-19 hrs: 1 volunteer (50.0%)', From 32afff1662eeb44b1d1f922823acf23f3b25df08 Mon Sep 17 00:00:00 2001 From: Akshay Date: Thu, 8 Oct 2026 18:54:55 -0600 Subject: [PATCH 07/10] fix: match Team Stats tooltip to its label and neutral Mentors footnote (#5608) - Team Stats tooltip showed the raw fraction (Change: -0.02) while the bar label shows -2%. Format it the same way in the Team Stats tooltip wrapper; the shared tooltip now also colours a preformatted negative value red. - The Volunteers footnote said Mentors are 'to the right', which is wrong when the charts stack on narrow screens. --- src/components/CustomTooltip.jsx | 2 +- .../TotalOrgSummary/TeamStats/TeamStatsBarChart.jsx | 13 ++++++++++++- .../VolunteerStatus/VolunteerStatusChart.jsx | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/components/CustomTooltip.jsx b/src/components/CustomTooltip.jsx index 96a75950e64..11fd7aa67b8 100644 --- a/src/components/CustomTooltip.jsx +++ b/src/components/CustomTooltip.jsx @@ -71,7 +71,7 @@ function CustomTooltip({ active, payload, label, tooltipType, darkMode = false } const renderChange = () => { if (change === undefined) return null; - const changeColor = change < 0 ? 'red' : isDarkMode ? 'lightgreen' : 'green'; + const changeColor = Number.parseFloat(change) < 0 ? 'red' : isDarkMode ? 'lightgreen' : 'green'; return
Change: {change}
; }; diff --git a/src/components/TotalOrgSummary/TeamStats/TeamStatsBarChart.jsx b/src/components/TotalOrgSummary/TeamStats/TeamStatsBarChart.jsx index d3c1012b123..da582103fa6 100644 --- a/src/components/TotalOrgSummary/TeamStats/TeamStatsBarChart.jsx +++ b/src/components/TotalOrgSummary/TeamStats/TeamStatsBarChart.jsx @@ -36,9 +36,20 @@ function renderTeamStatsBarLabel(data, totalValue) { return TeamStatsBarLabelContent; } +// change is a growth fraction (-0.02); show it as the bar label does ("-2%") +const formatChangePercent = change => + `${change >= 0 ? '+' : ''}${Math.round((Number(change) || 0) * 100)}%`; + function createTeamStatsTooltipContent(yAxisLabel, darkMode) { function TeamStatsTooltipContent(props) { - return ; + const { payload } = props; + const formatted = payload?.map(item => ({ + ...item, + payload: { ...item.payload, change: formatChangePercent(item.payload?.change) }, + })); + return ( + + ); } TeamStatsTooltipContent.displayName = 'TeamStatsTooltipContent'; return TeamStatsTooltipContent; diff --git a/src/components/TotalOrgSummary/VolunteerStatus/VolunteerStatusChart.jsx b/src/components/TotalOrgSummary/VolunteerStatus/VolunteerStatusChart.jsx index b34d448bd4f..d3b4a1e866d 100644 --- a/src/components/TotalOrgSummary/VolunteerStatus/VolunteerStatusChart.jsx +++ b/src/components/TotalOrgSummary/VolunteerStatus/VolunteerStatusChart.jsx @@ -155,7 +155,7 @@ function VolunteerStatusChart({
{(volunteerChartData || mentorChartData) && (

- *Does not include the “Mentor” members shown in the graph to the right. + *Does not include the “Mentor” members shown in the Total Mentors graph.

)} From 7d149bcb40d6d1ae9d3f52015693d2478567e377 Mon Sep 17 00:00:00 2001 From: pixelpix13 Date: Tue, 13 Oct 2026 03:22:44 -0700 Subject: [PATCH 08/10] refactor(Collaboration): enhance category selection and job fetching logic - Added new category images and improved the category selection process, allowing users to browse job categories before viewing job listings. - Updated the job fetching logic to ensure jobs are only loaded when a category is selected or a search term is submitted, improving performance and user experience. - Introduced a back navigation feature to return to category selection, enhancing usability. - Adjusted the initial fetch to prioritize categories over jobs, streamlining the loading process. These changes improve the overall user experience by making job browsing more intuitive and efficient. --- .../Collaboration/Collaboration.jsx | 118 +++++++++++++++--- .../Collaboration/Collaboration.module.css | 30 +++++ .../JobApplicationForm/JobApplicationForm.jsx | 26 +++- .../__tests__/Collaboration.test.jsx | 50 ++++++-- .../JobFormManagement/jobFormPositions.js | 21 ++-- 5 files changed, 196 insertions(+), 49 deletions(-) diff --git a/src/components/Collaboration/Collaboration.jsx b/src/components/Collaboration/Collaboration.jsx index e6e3ecac65f..e07c7a6fd63 100644 --- a/src/components/Collaboration/Collaboration.jsx +++ b/src/components/Collaboration/Collaboration.jsx @@ -169,6 +169,21 @@ function Collaboration() { url: 'https://images.unsplash.com/photo-1581092160562-40aa08e78837?w=640&h=480&fit=crop&q=80', }, + { + keywords: ['architecture', 'landscape', 'environment'], + url: + 'https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?w=640&h=480&fit=crop&q=80', + }, + { + keywords: ['creative', 'media', 'graphic'], + url: + 'https://images.unsplash.com/photo-1561070791-2526d30994b5?w=640&h=480&fit=crop&q=80', + }, + { + keywords: ['skilled', 'trades', 'craft', 'carpenter'], + url: + 'https://images.unsplash.com/photo-1504148455328-c376907d081c?w=640&h=480&fit=crop&q=80', + }, ]; // Find matching category @@ -265,16 +280,40 @@ function Collaboration() { ); }; + const selectCategory = selectedValue => { + const nextCategory = selectedValue || ''; + setSelectedCategory(nextCategory); + setCurrentPage(1); + setSummaries(null); + setActiveTab('jobPostings'); + if (!nextCategory && !submittedSearchTerm) { + setJobAds([]); + setTotalPages(0); + setLoadingJobs(false); + return; + } + fetchJobAds({ category: nextCategory, page: 1 }).catch(() => toast.error('Error fetching jobs')); + }; + const handleCategoryChange = e => { - const selectedValue = e.target.value; - setSelectedCategory(selectedValue || ''); + selectCategory(e.target.value); + }; + + const handleCategoryCardClick = event => { + const { category } = event.currentTarget.dataset; + if (category) selectCategory(category); + }; + + const handleBackToCategories = () => { + setSelectedCategory(''); + setSubmittedSearchTerm(''); + setSearchTerm(''); setCurrentPage(1); + setJobAds([]); + setTotalPages(0); setSummaries(null); setActiveTab('jobPostings'); - // The job refresh runs independently of the category state updates above. - fetchJobAds({ category: selectedValue || '', page: 1 }).catch(() => - toast.error('Error fetching jobs'), - ); + globalThis.scrollTo({ top: 0, behavior: 'smooth' }); }; const showBothFiltersTooltip = @@ -298,19 +337,21 @@ function Collaboration() { if (!response.ok) throw new Error(`Failed to reset filters: ${response.statusText}`); - const data = await response.json(); if (requestId !== requestIdRef.current) return; setSearchTerm(''); setSubmittedSearchTerm(''); setSelectedCategory(''); setCurrentPage(1); - setJobAds(dedupeJobsByTitle(Array.isArray(data?.jobs) ? data.jobs : [])); - setTotalPages(data?.pagination?.totalPages || 0); + // Return to category cards; jobs load after the next category/search choice. + setJobAds([]); + setTotalPages(0); + setJobsFetchError(null); setSummaries(null); setSummariesAll([]); setSummariesPage(1); setSummariesTotalPages(0); setActiveTab('jobPostings'); + await fetchCategories(); globalThis.scrollTo({ top: 0, behavior: 'smooth' }); } catch { if (requestId !== requestIdRef.current) return; @@ -398,12 +439,33 @@ function Collaboration() { setPage(Number(event.currentTarget.dataset.page)); }; + const showCategoryBrowse = !selectedCategory && !submittedSearchTerm; + const renderCategoryOption = category => ( ); + const renderCategoryCard = category => ( + + ); + const renderJobAd = ad => { if (!ad?._id) return null; const jobTitle = ad.title || 'Untitled Position'; @@ -433,6 +495,13 @@ function Collaboration() { }; const renderJobContent = () => { + if (showCategoryBrowse) { + if (!categories.length) { + return

No job categories available.

; + } + return <>{categories.map(renderCategoryCard)}; + } + if (loadingJobs) { return

Loading jobs...

; } @@ -459,10 +528,9 @@ function Collaboration() { ); - // Initial fetch and setup + // Initial fetch and setup — categories first; jobs load after category/search. useEffect(() => { - // Initial API requests intentionally run in the background during mount. - fetchJobAds().catch(() => toast.error('Error fetching jobs')); + setLoadingJobs(false); fetchCategories().catch(() => toast.error('Error fetching categories')); const handleResize = () => { if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current); @@ -472,8 +540,9 @@ function Collaboration() { columnsRef.current = newCols; setColumns(newCols); setCurrentPage(1); - // Resize-triggered refresh intentionally runs in the background to keep resizing responsive. - fetchJobAds({ page: 1 }).catch(() => toast.error('Error fetching jobs')); + if (selectedCategory || submittedSearchTerm) { + fetchJobAds({ page: 1 }).catch(() => toast.error('Error fetching jobs')); + } }, 200); }; globalThis.addEventListener('resize', handleResize); @@ -546,7 +615,7 @@ function Collaboration() { value={selectedCategory} onChange={handleCategoryChange} > - + {categories.map(c => ( + {categories.map(renderCategoryOption)}
@@ -683,12 +752,27 @@ function Collaboration() { <>

LIKE TO WORK WITH US? APPLY NOW!

+ {showCategoryBrowse && ( +

Choose a category to see open positions.

+ )} + {!showCategoryBrowse && selectedCategory && ( +
+ +

{`Positions in '${selectedCategory}'`}

+
+ )} {submittedSearchTerm &&

{`Showing results for '${submittedSearchTerm}'`}

}
{renderJobContent()}
- {totalPages > 1 && ( + {!showCategoryBrowse && totalPages > 1 && (
{Array.from({ length: totalPages }, renderPaginationButton)}
diff --git a/src/components/Collaboration/Collaboration.module.css b/src/components/Collaboration/Collaboration.module.css index aad4b7d456f..492db38ffdb 100644 --- a/src/components/Collaboration/Collaboration.module.css +++ b/src/components/Collaboration/Collaboration.module.css @@ -274,6 +274,36 @@ letter-spacing: 0.5px; } +.categoryBrowseHeader { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.35rem; + margin-top: 0.5rem; +} + +.backToCategories { + border: none; + background: transparent; + color: #2b6ee8; + font-weight: 600; + cursor: pointer; + padding: 0.25rem 0.5rem; + text-decoration: underline; +} + +.backToCategories:hover { + color: #1a4fb0; +} + +.jobLandingDark .backToCategories { + color: #9ec1ff; +} + +.jobLandingDark .backToCategories:hover { + color: #c9dcff; +} + .jobAd:hover { transform: translateY(-5px); box-shadow: 0 6px 12px rgb(0 0 0 / 20%); diff --git a/src/components/Collaboration/JobApplicationForm/JobApplicationForm.jsx b/src/components/Collaboration/JobApplicationForm/JobApplicationForm.jsx index 2bc4d27e85f..b32f1bc0b36 100644 --- a/src/components/Collaboration/JobApplicationForm/JobApplicationForm.jsx +++ b/src/components/Collaboration/JobApplicationForm/JobApplicationForm.jsx @@ -152,16 +152,29 @@ function getFormIdFromNavigation(location) { return ''; } +/** Skip retired / internal ATS templates when picking applicant forms. */ +function isApplicantFacingForm(form) { + const title = String(form?.title || '') + .trim() + .toUpperCase(); + if (!title) return false; + if (title.startsWith('[RETIRED]')) return false; + if (title.includes('APPLIED THROUGH SITE')) return false; + if (title.includes('TEST CREATE FORM')) return false; + return true; +} + /** Default questionnaire when a job listing has no dedicated form. */ function findDefaultSoftwareForm(formsArr) { if (!formsArr?.length) return null; + const activeForms = formsArr.filter(isApplicantFacingForm); const preferredTitles = [ + 'SEEKING SOFTWARE POSITION', 'Software Developer', 'Software Engineer', - 'APPLIED THROUGH SITE - SEEKING SOFTWARE POSITION', ]; for (const title of preferredTitles) { - const exact = formsArr.find( + const exact = activeForms.find( f => String(f.title || '') .trim() @@ -170,10 +183,11 @@ function findDefaultSoftwareForm(formsArr) { if (exact) return exact; } return ( - findFormForJobTitle(formsArr, 'Software Developer') || - findFormForJobTitle(formsArr, 'Software Engineer') || - formsArr.find(f => f.questions?.length) || - formsArr[0] || + findFormForJobTitle(activeForms, 'SEEKING SOFTWARE POSITION') || + findFormForJobTitle(activeForms, 'Software Developer') || + findFormForJobTitle(activeForms, 'Software Engineer') || + activeForms.find(f => f.questions?.length) || + activeForms[0] || null ); } diff --git a/src/components/Collaboration/__tests__/Collaboration.test.jsx b/src/components/Collaboration/__tests__/Collaboration.test.jsx index 5f0e1a2392d..46811ef9f9e 100644 --- a/src/components/Collaboration/__tests__/Collaboration.test.jsx +++ b/src/components/Collaboration/__tests__/Collaboration.test.jsx @@ -140,7 +140,7 @@ describe('Collaboration', () => { expect(screen.getByRole('button', { name: 'What We Do' })).toBeInTheDocument(); }); - it('fetches jobs and categories on initial render', async () => { + it('fetches categories on initial render without loading all jobs', async () => { renderComponent(); await waitFor(() => { @@ -151,12 +151,32 @@ describe('Collaboration', () => { true, ); - expect(globalThis.fetch.mock.calls.some(([url]) => url.includes('/jobs?'))).toBe(true); + expect(globalThis.fetch.mock.calls.some(([url]) => url.includes('/jobs?'))).toBe(false); }); - it('shows individual jobs when there is no search or category filter', async () => { + it('shows category cards when there is no search or category filter', async () => { renderComponent(); + await waitFor(() => { + expect(screen.getByRole('button', { name: /Browse Engineering jobs/i })).toBeInTheDocument(); + }); + + expect(screen.getByRole('button', { name: /Browse Administrative jobs/i })).toBeInTheDocument(); + expect(screen.getByText('Choose a category to see open positions.')).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /Frontend Engineer - Engineering/i }), + ).not.toBeInTheDocument(); + }); + + it('shows jobs after a category card is selected', async () => { + renderComponent(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /Browse Engineering jobs/i })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: /Browse Engineering jobs/i })); + await waitFor(() => { expect( screen.getByRole('button', { name: /Frontend Engineer - Engineering/i }), @@ -166,6 +186,7 @@ describe('Collaboration', () => { expect( screen.getByRole('button', { name: /Backend Engineer - Engineering/i }), ).toBeInTheDocument(); + expect(screen.getByText("Positions in 'Engineering'")).toBeInTheDocument(); }); it('allows the user to search for a job title', async () => { @@ -387,15 +408,6 @@ describe('Collaboration', () => { ok: true, json: () => Promise.resolve({ - jobs: [ - { - _id: '1', - title: 'Frontend Engineer', - category: 'Engineering', - description: 'Build UI components', - imageUrl: 'https://example.com/frontend-engineer.jpg', - }, - ], categories: ['Engineering'], }), }); @@ -422,6 +434,12 @@ describe('Collaboration', () => { renderComponent(); + await waitFor(() => { + expect(screen.getByRole('button', { name: /Browse Engineering jobs/i })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: /Browse Engineering jobs/i })); + await waitFor(() => { expect(screen.getByRole('button', { name: '1', exact: true })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '2', exact: true })).toBeInTheDocument(); @@ -436,7 +454,7 @@ describe('Collaboration', () => { ok: true, json: () => Promise.resolve({ - categories: [], + categories: ['Engineering'], }), }); } @@ -450,6 +468,12 @@ describe('Collaboration', () => { renderComponent(); + await waitFor(() => { + expect(screen.getByRole('button', { name: /Browse Engineering jobs/i })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: /Browse Engineering jobs/i })); + await waitFor(() => { expect( screen.getByText( diff --git a/src/components/JobFormManagement/jobFormPositions.js b/src/components/JobFormManagement/jobFormPositions.js index 839c6c79dd4..f572ab14974 100644 --- a/src/components/JobFormManagement/jobFormPositions.js +++ b/src/components/JobFormManagement/jobFormPositions.js @@ -1,43 +1,38 @@ -/** Position titles for the job application form builder dropdown (matches legacy list). */ +/** Position titles for the job application form builder dropdown (Jae active catalog). */ export const JOB_FORM_POSITION_OPTIONS = [ - 'APPLIED THROUGH SITE - SEEKING SOFTWARE POSITION', - 'APPLIED THROUGH SITE - GENERAL', - 'APPLIED THROUGH SITE - ADMINISTRATIVE ASSISTANT', - 'APPLIED THROUGH SITE - GRAPHIC DESIGNER', 'SEEKING SOFTWARE POSITION', 'SEEKING ADMINISTRATIVE ASSISTANT', + 'GENERAL', 'EARTHBAG 4-DOME CLUSTER PLUMBING DESIGNS', 'PLUMBING ENGINEER/MEP FOR EARTHBAG VILLAGE', 'CIVIL ENGINEER FOR COST ANALYSIS OF FOOD PRODUCTION STRUCTURES', 'CIVIL ENGINEER TO FINALIZE TEST MATERIALS AND EQUIPMENT FOR FOOD PRODUCTION STRUCTURES', + 'CIVIL OR MECHANICAL ENGINEER TO FINALIZE DESIGN FOR FOOD PRODUCTION STRUCTURES', + 'MECHANICAL ENGINEER FOR FEA AND MECHANICAL COVER DESIGN OF MAXIMALLY SUSTAINABLE ECO-HOT TUB FOR OPEN SOURCE CITY CENTER DESIGNS', 'MECHANICAL ENGINEER FOR HVAC FOR FOOD PRODUCTION STRUCTURES', 'CITY CENTER PLUMBING DESIGNS', 'ELECTRICAL DESIGNER FOR EARTHBAG VILLAGE', 'ELECTRICAL DESIGNER FOR STRAW BALE CLASSROOM', 'ELECTRICAL ENGINEER/DESIGNER FOR DUPLICABLE CITY CENTER', 'CITY CENTER GEODESIC DOME AUTODESK INVENTOR SIMULATIONS', + '4-DOME CLUSTER ROOF DESIGN', '4-DOME CLUSTER ELECTRICAL DESIGN', 'PHOTOSHOP/GRAPHIC DESIGNER', 'PASSIVE GREENHOUSE DESIGN', - 'LANDSCAPE ARCHITECT FOR AQUAPINI/WALIPINI STRUCTURES', + 'BOTANIST/HORTICULTURIST TO DESIGN LANDSCAPE FOR OPEN SOURCE SUSTAINABLE GREENHOUSE', + 'MECHANICAL ENGINEER TO DESIGN CLIMATE CONTROL SYSTEMS FOR SUSTAINABLE GREENHOUSE', 'SEEKING VIRTUAL ASSISTANT', 'FUNDRAISING HELP', 'STRAW BALE CLASSROOM STRUCTURAL', 'PERMACULTURALIST FOR SOIL AMENDMENT', - 'NUTRITIONIST FOR NUTRITION CALCULATIONS AND MENU PLANNING', - 'CHEF OR CULINARY PROFESSIONAL FOR MENU IMPLEMENTATION TUTORIALS', - 'CHEF OR CULINARY PROFESSIONAL TO HELP WITH REMOTE/DISASTER KITCHEN SUPPLY AND STORAGE PLAN', 'LUMION 2024.1.1 OR HIGHER FOR CITY CENTER', - 'GENERAL', 'VERMICULTURE', 'MASTER CARPENTER', 'FINAL CUT PRO VIDEO EDITOR', 'INDUSTRIAL DESIGNER FOR DORMER', - 'LANDSCAPE ARCHITECT FOR SKETCHUP AND LUMION RENDER HELP', + 'ARCHITECT OR LANDSCAPE ARCHITECT FOR CITY CENTER SKETCHUP AND LUMION RENDER HELP', 'T.A.S.T. - NEED RESUME & WORK SAMPLES', 'T.A.S.T. - ENGINEER/ARCHITECT OFFERING POSITION', 'T.A.S.T. - HGN', - 'ADMIN OF PR REVIEW TEAM AND FRONTEND TESTER', - 'ADMIN OF PR REVIEW TEAM AND FRONTEND TESTER - APPLIED THROUGH SITE', 'DATA ANALYST APPLICATION', ]; From 1496b4bcc1963543d59660e3f017801122d85eff Mon Sep 17 00:00:00 2001 From: pixelpix13 Date: Tue, 13 Oct 2026 03:35:02 -0700 Subject: [PATCH 09/10] fix(Collaboration): use default param in selectCategory for Sonar Co-authored-by: Cursor --- src/components/Collaboration/Collaboration.jsx | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/components/Collaboration/Collaboration.jsx b/src/components/Collaboration/Collaboration.jsx index e07c7a6fd63..dd31682dfcc 100644 --- a/src/components/Collaboration/Collaboration.jsx +++ b/src/components/Collaboration/Collaboration.jsx @@ -176,8 +176,7 @@ function Collaboration() { }, { keywords: ['creative', 'media', 'graphic'], - url: - 'https://images.unsplash.com/photo-1561070791-2526d30994b5?w=640&h=480&fit=crop&q=80', + url: 'https://images.unsplash.com/photo-1561070791-2526d30994b5?w=640&h=480&fit=crop&q=80', }, { keywords: ['skilled', 'trades', 'craft', 'carpenter'], @@ -280,19 +279,20 @@ function Collaboration() { ); }; - const selectCategory = selectedValue => { - const nextCategory = selectedValue || ''; - setSelectedCategory(nextCategory); + const selectCategory = (selectedValue = '') => { + setSelectedCategory(selectedValue); setCurrentPage(1); setSummaries(null); setActiveTab('jobPostings'); - if (!nextCategory && !submittedSearchTerm) { + if (!selectedValue && !submittedSearchTerm) { setJobAds([]); setTotalPages(0); setLoadingJobs(false); return; } - fetchJobAds({ category: nextCategory, page: 1 }).catch(() => toast.error('Error fetching jobs')); + fetchJobAds({ category: selectedValue, page: 1 }).catch(() => + toast.error('Error fetching jobs'), + ); }; const handleCategoryChange = e => { @@ -752,9 +752,7 @@ function Collaboration() { <>

LIKE TO WORK WITH US? APPLY NOW!

- {showCategoryBrowse && ( -

Choose a category to see open positions.

- )} + {showCategoryBrowse &&

Choose a category to see open positions.

} {!showCategoryBrowse && selectedCategory && (