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}
>
-
{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
-
+
- {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
@@ -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