diff --git a/src/components/Collaboration/Collaboration.jsx b/src/components/Collaboration/Collaboration.jsx index e6e3ecac65f..dd31682dfcc 100644 --- a/src/components/Collaboration/Collaboration.jsx +++ b/src/components/Collaboration/Collaboration.jsx @@ -169,6 +169,20 @@ 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,18 +279,43 @@ function Collaboration() { ); }; - const handleCategoryChange = e => { - const selectedValue = e.target.value; - setSelectedCategory(selectedValue || ''); + const selectCategory = (selectedValue = '') => { + setSelectedCategory(selectedValue); setCurrentPage(1); setSummaries(null); setActiveTab('jobPostings'); - // The job refresh runs independently of the category state updates above. - fetchJobAds({ category: selectedValue || '', page: 1 }).catch(() => + if (!selectedValue && !submittedSearchTerm) { + setJobAds([]); + setTotalPages(0); + setLoadingJobs(false); + return; + } + fetchJobAds({ category: selectedValue, page: 1 }).catch(() => toast.error('Error fetching jobs'), ); }; + const handleCategoryChange = e => { + 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'); + globalThis.scrollTo({ top: 0, behavior: 'smooth' }); + }; + const showBothFiltersTooltip = activeTab === 'jobPostings' && !bothFiltersTooltipDismissed && @@ -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,25 @@ 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..589864fd14c 100644 --- a/src/components/Collaboration/JobApplicationForm/JobApplicationForm.jsx +++ b/src/components/Collaboration/JobApplicationForm/JobApplicationForm.jsx @@ -152,16 +152,25 @@ 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 preferredTitles = [ - 'Software Developer', - 'Software Engineer', - 'APPLIED THROUGH SITE - SEEKING SOFTWARE POSITION', - ]; + const activeForms = formsArr.filter(isApplicantFacingForm); + const preferredTitles = ['SEEKING SOFTWARE POSITION', 'Software Developer', 'Software Engineer']; for (const title of preferredTitles) { - const exact = formsArr.find( + const exact = activeForms.find( f => String(f.title || '') .trim() @@ -170,10 +179,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/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/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', ]; diff --git a/src/components/TotalOrgSummary/DonutChart/DonutChart.jsx b/src/components/TotalOrgSummary/DonutChart/DonutChart.jsx index 1e4c36722de..ace7b604d3b 100644 --- a/src/components/TotalOrgSummary/DonutChart/DonutChart.jsx +++ b/src/components/TotalOrgSummary/DonutChart/DonutChart.jsx @@ -10,12 +10,52 @@ 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) => { + 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)}%`; }; -export const buildDonutTooltipOptions = (totalCount, darkMode) => ({ +// "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}: ${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) => { + 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). +// 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', + afterUpdate(chart) { + const innerRadius = chart.getDatasetMeta(0)?.controller?.innerRadius; + const wrapper = chart.canvas?.parentNode; + if (!wrapper || !(innerRadius > 0)) return; + wrapper.style.setProperty('--donut-hole', `${innerRadius * 2}px`); + }, +}; + +export const buildDonutTooltipOptions = (totalCount, darkMode, unitLabel = '') => ({ enabled: true, backgroundColor: darkMode ? '#222' : '#fff', titleColor: darkMode ? '#fff' : '#222', @@ -29,22 +69,37 @@ 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: ${withUnit(count, unitLabel)}`, + `Percentage: ${formatPercent(count, totalCount)}`, + ]; }, }, }); function DonutChart(props) { - const { title, totalCount, percentageChange, data, colors, comparisonType, darkMode } = props; + const { + title, + totalCount, + percentageChange, + data, + colors, + 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)'; 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] })) - .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); @@ -52,10 +107,7 @@ function DonutChart(props) { return (
-
- {title} -
-
No data available yet
+
{emptyMessage}
); @@ -68,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, }, ], }; @@ -77,7 +130,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, @@ -91,7 +144,10 @@ function DonutChart(props) { lineColor: labelTextColor, backgroundColor: labelBoxBackground, borderColor: labelBoxBorder, - formatter: ({ value, percentage }) => [`${value}`, `(${percentage}%)`], + formatter: ({ value }) => + calculatePercentage(value, totalCount) < minLabelPercent + ? null + : [`${value}`, `(${formatPercent(value, totalCount)})`], }, }, interaction: { @@ -115,13 +171,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}
)}
- {filteredData.map((item, index) => ( + {legendItems.map(({ item, color }) => (
- - {formatLegendLabel(item, totalCount)} + + {formatLegendLabel(item, totalCount, unitLabel)}
))}
@@ -173,7 +232,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, @@ -183,10 +243,21 @@ DonutChart.propTypes = { colors: PropTypes.arrayOf(PropTypes.string.isRequired).isRequired, 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 dea72e8114c..15b7702e28a 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; } @@ -49,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; } @@ -61,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 { @@ -79,9 +86,10 @@ .donutCount { color: #828282; - font-size: 2rem; + font-size: clamp(0.85rem, 24cqi, 2rem); line-height: 1; font-weight: 800; + white-space: nowrap; } .donutCountDark { @@ -89,8 +97,13 @@ } .donutComparisonPercent { - font-size: 0.7rem; + 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 { @@ -177,4 +190,28 @@ .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 { + /* .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; + } + + .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..945d8dce588 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 } from '@testing-library/react'; -import DonutChart, { buildDonutTooltipOptions, formatLegendLabel } from '../DonutChart'; +import { render, screen } from '@testing-library/react'; +import DonutChart, { + buildDonutTooltipOptions, + formatComparison, + formatLegendLabel, + formatPercent, + holeSizePlugin, +} from '../DonutChart'; // Chart.js draws to a jsdom doesn't implement; capture the options instead. let lastDoughnutProps; @@ -78,3 +84,144 @@ 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(); + }); +}); + +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', '(98.8%)']); + 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.1%)')).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 after update, ignoring the 0 radius seen mid-layout', () => { + const wrapper = document.createElement('div'); + const canvas = document.createElement('canvas'); + wrapper.appendChild(canvas); + const chartWith = innerRadius => ({ + canvas, + 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('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%)', + ); + }); +}); 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/TeamStats/TeamStatsBarChart.jsx b/src/components/TotalOrgSummary/TeamStats/TeamStatsBarChart.jsx index c47ad5f8de2..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; @@ -77,7 +88,11 @@ function TeamStatsBarChart({ data, yAxisLabel }) { className={styles.teamStatsYAxis} tick={{ fill: darkMode ? 'white' : '#666' }} /> - + {data.map((_, index) => ( 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.jsx b/src/components/TotalOrgSummary/TotalOrgSummary.jsx index a59316bac83..dfcde5c8109 100644 --- a/src/components/TotalOrgSummary/TotalOrgSummary.jsx +++ b/src/components/TotalOrgSummary/TotalOrgSummary.jsx @@ -739,6 +739,7 @@ function TotalOrgSummary(props) { volunteerNumberStats={volunteerStats?.volunteerNumberStats} mentorNumberStats={volunteerStats?.mentorNumberStats} comparisonType={selectedComparison} + darkMode={darkMode} />
@@ -774,14 +775,12 @@ function TotalOrgSummary(props) { } totalHoursData={volunteerStats?.totalHoursWorked} title="Actual Hours Worked" - legendTitle="Actual Hours Worked" /> 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 dc9f74fc559..4b6a498957f 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,44 +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)); -} - -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((a, b) => parseRangeStart(a._id) - parseRangeStart(b._id)); + return [...merged.entries()].map(([id, count]) => ({ _id: id, count })).sort(compareBuckets); } export function formatRangeLabel(rangeStr) { @@ -99,78 +66,29 @@ 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', }; }); 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 +97,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} + emptyMessage={ + useBucketCounts ? 'Weekly committed hours are not available yet' : 'No data available yet' + } + />
); } // 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..998c3d06e7e 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); @@ -21,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 }, @@ -38,18 +37,17 @@ 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(); + // 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, @@ -71,16 +69,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 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(); expect(computeDistribution(committedHoursData, undefined, true)).toEqual({ userData: [ @@ -94,22 +94,35 @@ describe('VolunteerHoursDistribution wrapper', () => { totalHoursWorked: 8, }); }); +}); - it('renders the committed distribution center label', () => { +describe('empty committed hours', () => { + it('explains that committed hours are not available yet instead of a blank chart', () => { render( - - {renderCenterLabel({ - darkMode: false, - isMobile: false, - totalHours: 8, - centerLabelLines: ['TOTAL', 'VOLUNTEERS'], - })} - , + , { container }, ); + expect(screen.getByText('Weekly committed hours are not available yet')).toBeInTheDocument(); + }); +}); - expect(screen.getByText('TOTAL')).toBeInTheDocument(); - expect(screen.getByText('VOLUNTEERS')).toBeInTheDocument(); - expect(screen.getByText('8')).toBeInTheDocument(); +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(userData.map(d => d.name)).toEqual(['10-19 hrs', '50-59 hrs', '50+ hrs']); }); }); 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/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)' }} + /> 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 05ccbe343b1..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(.bgOxfordBlue) .mentorStatusHeading { - color: #f1f5ff; -} - -:global(.bgOxfordBlue) .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 47849e9672b..52787499c7d 100644 --- a/src/components/TotalOrgSummary/VolunteerStatus/VolunteerStatusChart.jsx +++ b/src/components/TotalOrgSummary/VolunteerStatus/VolunteerStatusChart.jsx @@ -2,15 +2,18 @@ import { useMemo } from 'react'; import PropTypes from 'prop-types'; import Loading from '~/components/common/Loading'; import { getMentorTotal } from '~/utils/totalOrgSummary'; -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) { @@ -57,7 +60,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]); @@ -100,8 +104,10 @@ function VolunteerStatusChart({ } return { + // Shared with the Mentors card so the two totals always match. totalMentors: getMentorTotal(mentorNumberStats), - 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]); @@ -117,23 +123,36 @@ function VolunteerStatusChart({ ) : ( <>
-
- {volunteerChartData && ( - + - )} -
+
+ )} {mentorChartData && ( -
- +
+
)}
{(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.

)} @@ -145,6 +164,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 0a1c18a6e77..00000000000 --- a/src/components/TotalOrgSummary/VolunteerStatus/VolunteerStatusPieChart.module.css +++ /dev/null @@ -1,116 +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, -.volunteerStatusCenter > p { - 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; - } -} 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;