diff --git a/index.html b/index.html index 46604c7e44..c92b873176 100644 --- a/index.html +++ b/index.html @@ -1,7 +1,6 @@ - diff --git a/public/index.css b/public/index.css index 339428ae6c..327097ff20 100644 --- a/public/index.css +++ b/public/index.css @@ -1,338 +1,261 @@ -/* public/index.css */ +/* Root background (light mode default) */ +#root { + background-color: #ffffff; +} + +/* ========================= */ +/* THEME ROOT (BODY / ROOT) */ +/* ========================= */ -/* Global Dark Mode Styles */ -body.dark-mode, -body.bm-dashboard-dark { +body.dark-mode:not(.no-global-theme), +body.bm-dashboard-dark:not(.no-global-theme) { background-color: #1b2a41 !important; color: #ffffff !important; } - -body.dark-mode #root, -body.bm-dashboard-dark #root { +body.dark-mode:not(.no-global-theme) #root, +body.bm-dashboard-dark:not(.no-global-theme) #root { background-color: #1b2a41 !important; color: #ffffff !important; } -/* Enhanced text visibility for dark mode */ -body.dark-mode *, -body.bm-dashboard-dark * { - color: #ffffff !important; -} +/* ========================= */ +/* TYPOGRAPHY (DARK MODE) */ +/* ========================= */ -body.dark-mode h1, body.dark-mode h2, body.dark-mode h3, -body.dark-mode h4, body.dark-mode h5, body.dark-mode h6, -body.bm-dashboard-dark h1, body.bm-dashboard-dark h2, body.bm-dashboard-dark h3, -body.bm-dashboard-dark h4, body.bm-dashboard-dark h5, body.bm-dashboard-dark h6 { +/* Text elements (avoid targeting all divs) */ +body.dark-mode:not(.no-global-theme) p, +body.dark-mode:not(.no-global-theme) span, +body.dark-mode:not(.no-global-theme) label, +body.bm-dashboard-dark:not(.no-global-theme) p, +body.bm-dashboard-dark:not(.no-global-theme) span, +body.bm-dashboard-dark:not(.no-global-theme) label { color: #ffffff !important; - text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); } -body.dark-mode p, body.dark-mode span, body.dark-mode div, -body.dark-mode label, body.dark-mode a, body.dark-mode li, -body.bm-dashboard-dark p, body.bm-dashboard-dark span, body.bm-dashboard-dark div, -body.bm-dashboard-dark label, body.bm-dashboard-dark a, body.bm-dashboard-dark li { +/* Headings */ +body.dark-mode:not(.no-global-theme) h1, +body.dark-mode:not(.no-global-theme) h2, +body.dark-mode:not(.no-global-theme) h3, +body.dark-mode:not(.no-global-theme) h4, +body.dark-mode:not(.no-global-theme) h5, +body.dark-mode:not(.no-global-theme) h6, +body.bm-dashboard-dark:not(.no-global-theme) h1, +body.bm-dashboard-dark:not(.no-global-theme) h2, +body.bm-dashboard-dark:not(.no-global-theme) h3, +body.bm-dashboard-dark:not(.no-global-theme) h4, +body.bm-dashboard-dark:not(.no-global-theme) h5, +body.bm-dashboard-dark:not(.no-global-theme) h6 { color: #ffffff !important; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); } -body.dark-mode .text-muted, -body.bm-dashboard-dark .text-muted { +/* Muted text */ +body.dark-mode:not(.no-global-theme) .text-muted, +body.bm-dashboard-dark:not(.no-global-theme) .text-muted { color: #b8c5d1 !important; } -body.dark-mode .text-secondary, -body.bm-dashboard-dark .text-secondary { - color: #d1d8e0 !important; -} - -body.dark-mode .text-primary, -body.bm-dashboard-dark .text-primary { - color: #66b3ff !important; -} - -body.dark-mode .text-success, -body.bm-dashboard-dark .text-success { - color: #66ff99 !important; -} - -body.dark-mode .text-warning, -body.bm-dashboard-dark .text-warning { - color: #ffcc66 !important; -} - -body.dark-mode .text-danger, -body.bm-dashboard-dark .text-danger { - color: #ff6666 !important; -} - -body.dark-mode .text-info, -body.bm-dashboard-dark .text-info { - color: #66ffff !important; -} +/* ========================= */ +/* LAYOUT CONTAINERS (DARK) */ +/* ========================= */ -/* Dark mode for common elements */ -body.dark-mode .container, -body.dark-mode .container-fluid, -body.bm-dashboard-dark .container, -body.bm-dashboard-dark .container-fluid { +body.dark-mode:not(.no-global-theme) .container, +body.dark-mode:not(.no-global-theme) .container-fluid, +body.bm-dashboard-dark:not(.no-global-theme) .container, +body.bm-dashboard-dark:not(.no-global-theme) .container-fluid { background-color: #1b2a41; color: #ffffff; } -body.dark-mode .card, -body.bm-dashboard-dark .card { +/* Cards */ +body.dark-mode:not(.no-global-theme) .card, +body.bm-dashboard-dark:not(.no-global-theme) .card { background-color: #2e5061; border-color: #3a506b; color: #ffffff; } -body.dark-mode .card-body, -body.bm-dashboard-dark .card-body { - color: #ffffff; -} - -body.dark-mode .card-title, -body.bm-dashboard-dark .card-title { - color: #ffffff !important; -} +/* ========================= */ +/* TABLES (DARK) */ +/* ========================= */ -body.dark-mode .card-text, -body.bm-dashboard-dark .card-text { +body.dark-mode:not(.no-global-theme) .table, +body.bm-dashboard-dark:not(.no-global-theme) .table { color: #ffffff !important; } -body.dark-mode .card-header, -body.bm-dashboard-dark .card-header { - background-color: #3a506b; - border-color: #4a6072; +body.dark-mode:not(.no-global-theme) .table th, +body.dark-mode:not(.no-global-theme) .table td, +body.bm-dashboard-dark:not(.no-global-theme) .table th, +body.bm-dashboard-dark:not(.no-global-theme) .table td { color: #ffffff !important; -} - -body.dark-mode .form-control, -body.bm-dashboard-dark .form-control { - background-color: #2e5061; border-color: #3a506b; - color: #ffffff !important; -} - -body.dark-mode .form-control::placeholder, -body.bm-dashboard-dark .form-control::placeholder { - color: #b8c5d1 !important; -} - -body.dark-mode .form-control:focus, -body.bm-dashboard-dark .form-control:focus { - background-color: #2e5061; - border-color: #4a6072; - color: #ffffff !important; - box-shadow: 0 0 0 0.2rem rgba(74, 96, 114, 0.25); -} - -body.dark-mode .form-label, -body.bm-dashboard-dark .form-label { - color: #ffffff !important; } -body.dark-mode .btn-primary, -body.bm-dashboard-dark .btn-primary { - background-color: #3a506b; - border-color: #3a506b; - color: #ffffff !important; +body.bm-dashboard-dark:not(.no-global-theme) .table th { + background-color: #2e5061 !important; } -body.dark-mode .btn-primary:hover, -body.bm-dashboard-dark .btn-primary:hover { - background-color: #4a6072; - border-color: #4a6072; - color: #ffffff !important; -} +/* ========================= */ +/* BUTTONS (DARK) */ +/* ========================= */ -body.dark-mode .btn-secondary, -body.bm-dashboard-dark .btn-secondary { - background-color: #2e5061; - border-color: #2e5061; +body.dark-mode:not(.no-global-theme) .btn, +body.bm-dashboard-dark:not(.no-global-theme) .btn { color: #ffffff !important; } -body.dark-mode .btn-secondary:hover, -body.bm-dashboard-dark .btn-secondary:hover { +body.dark-mode:not(.no-global-theme) .btn-primary, +body.bm-dashboard-dark:not(.no-global-theme) .btn-primary { background-color: #3a506b; border-color: #3a506b; - color: #ffffff !important; -} - -body.dark-mode .btn, -body.bm-dashboard-dark .btn { - color: #ffffff !important; -} - -body.dark-mode .table, -body.bm-dashboard-dark .table { - color: #ffffff !important; -} - -body.dark-mode .table th, -body.dark-mode .table td, -body.bm-dashboard-dark .table th, -body.bm-dashboard-dark .table td { - color: #ffffff !important; - border-color: #3a506b; -} - -body.dark-mode .table-striped > tbody > tr:nth-of-type(odd), -body.bm-dashboard-dark .table-striped > tbody > tr:nth-of-type(odd) { - background-color: rgba(46, 80, 97, 0.5); - color: #ffffff !important; } -body.dark-mode .modal-content, -body.bm-dashboard-dark .modal-content { - background-color: #2e5061; - color: #ffffff !important; -} +/* ========================= */ +/* FORMS (DARK + LIGHT) */ +/* ========================= */ -body.dark-mode .modal-header, -body.bm-dashboard-dark .modal-header { - background-color: #3a506b; - border-color: #4a6072; +/* Dark-mode form controls (SAFE: does not touch checkbox/radio/switch) */ +body.dark-mode:not(.no-global-theme) .form-control, +body.bm-dashboard-dark:not(.no-global-theme) .form-control, +body.dark-mode:not(.no-global-theme) select.form-control, +body.bm-dashboard-dark:not(.no-global-theme) select.form-control { + background-color: #1e293b !important; color: #ffffff !important; + border: 1px solid #334155 !important; } -body.dark-mode .modal-title, -body.bm-dashboard-dark .modal-title { - color: #ffffff !important; +/* Dark-mode placeholders */ +body.dark-mode:not(.no-global-theme) .form-control::placeholder, +body.bm-dashboard-dark:not(.no-global-theme) .form-control::placeholder { + color: #94a3b8 !important; } -body.dark-mode .modal-body, -body.bm-dashboard-dark .modal-body { - color: #ffffff !important; +/* Dark-mode select arrow styling (Bootstrap selects) */ +body.dark-mode:not(.no-global-theme) select.form-control, +body.bm-dashboard-dark:not(.no-global-theme) select.form-control { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23ffffff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e") !important; + background-repeat: no-repeat !important; + background-position: right 0.75rem center !important; + background-size: 16px 12px !important; } -body.dark-mode .modal-footer, -body.bm-dashboard-dark .modal-footer { - background-color: #3a506b; - border-color: #4a6072; +/* Dark-mode option colors (browser-dependent, but helps) */ +body.dark-mode:not(.no-global-theme) option, +body.bm-dashboard-dark:not(.no-global-theme) option { + background-color: #1e293b !important; color: #ffffff !important; } -body.dark-mode .nav-tabs .nav-link, -body.bm-dashboard-dark .nav-tabs .nav-link { - background-color: #2e5061; - border-color: #3a506b; - color: #ffffff !important; +/* Light-mode form controls (Option B): Bootstrap `.form-control` only */ +body:not(.dark-mode) .form-control, +body:not(.dark-mode) textarea.form-control, +body:not(.dark-mode) select.form-control { + background-color: #ffffff !important; + color: #212529 !important; + border-color: #ced4da !important; } -body.dark-mode .nav-tabs .nav-link.active, -body.bm-dashboard-dark .nav-tabs .nav-link.active { - background-color: #3a506b; - border-color: #4a6072; - color: #ffffff !important; -} +/* ========================= */ +/* MODALS (DARK) */ +/* ========================= */ -/* Navigation and menu items */ -body.dark-mode .nav-link, -body.bm-dashboard-dark .nav-link { +body.dark-mode:not(.no-global-theme) .modal-content, +body.bm-dashboard-dark:not(.no-global-theme) .modal-content { + background-color: #1b2a41 !important; + border: 1px solid #2e3d55 !important; color: #ffffff !important; } -body.dark-mode .navbar, -body.bm-dashboard-dark .navbar { +body.dark-mode:not(.no-global-theme) .modal-header, +body.dark-mode:not(.no-global-theme) .modal-body, +body.dark-mode:not(.no-global-theme) .modal-footer, +body.bm-dashboard-dark:not(.no-global-theme) .modal-header, +body.bm-dashboard-dark:not(.no-global-theme) .modal-body, +body.bm-dashboard-dark:not(.no-global-theme) .modal-footer { background-color: #1b2a41 !important; -} - -body.dark-mode .navbar-brand, -body.bm-dashboard-dark .navbar-brand { color: #ffffff !important; + border-color: #2e3d55 !important; } -body.dark-mode .dropdown-menu, -body.bm-dashboard-dark .dropdown-menu { - background-color: #2e5061; - border-color: #3a506b; -} - -body.dark-mode .dropdown-item, -body.bm-dashboard-dark .dropdown-item { +body.dark-mode:not(.no-global-theme) .modal-header, +body.bm-dashboard-dark:not(.no-global-theme) .modal-header { + background-color: #24344d !important; + border-bottom: 1px solid #334155 !important; color: #ffffff !important; + padding: 1rem 1.5rem; } -body.dark-mode .dropdown-item:hover, -body.bm-dashboard-dark .dropdown-item:hover { - background-color: #3a506b; - color: #ffffff !important; +body.dark-mode:not(.no-global-theme) .modal-title, +body.bm-dashboard-dark:not(.no-global-theme) .modal-title { + font-weight: 600; + letter-spacing: 0.5px; } -/* Alert components */ -body.dark-mode .alert, -body.bm-dashboard-dark .alert { +/* Modal table header */ +body.dark-mode:not(.no-global-theme) .modal-content .table thead th, +body.bm-dashboard-dark:not(.no-global-theme) .modal-content .table thead th { + background-color: #2d3d5a !important; color: #ffffff !important; + border-bottom: 2px solid #334155 !important; + font-size: 0.85rem; + letter-spacing: 1px; } -body.dark-mode .alert-primary, -body.bm-dashboard-dark .alert-primary { - background-color: #3a506b; - border-color: #4a6072; - color: #ffffff !important; -} +/* ========================= */ +/* DROPDOWNS (DARK) */ +/* ========================= */ -body.dark-mode .alert-secondary, -body.bm-dashboard-dark .alert-secondary { +body.dark-mode:not(.no-global-theme) .dropdown-menu, +body.bm-dashboard-dark:not(.no-global-theme) .dropdown-menu { background-color: #2e5061; border-color: #3a506b; - color: #ffffff !important; } -/* List groups */ -body.dark-mode .list-group-item, -body.bm-dashboard-dark .list-group-item { +/* ========================= */ +/* PAGINATION (DARK) */ +/* ========================= */ + +body.dark-mode:not(.no-global-theme) .page-link, +body.bm-dashboard-dark:not(.no-global-theme) .page-link { background-color: #2e5061; border-color: #3a506b; color: #ffffff !important; } -/* Breadcrumbs */ -body.dark-mode .breadcrumb, -body.bm-dashboard-dark .breadcrumb { - background-color: #2e5061; -} +/* ========================= */ +/* UTILITIES */ +/* ========================= */ -body.dark-mode .breadcrumb-item, -body.bm-dashboard-dark .breadcrumb-item { - color: #ffffff !important; +/* container scrolling */ +.container-fluid { + overflow-x: auto; } -body.dark-mode .breadcrumb-item.active, -body.bm-dashboard-dark .breadcrumb-item.active { - color: #b8c5d1 !important; +.container-fluid::-webkit-scrollbar { + display: none; } -/* Pagination */ -body.dark-mode .page-link, -body.bm-dashboard-dark .page-link { - background-color: #2e5061; - border-color: #3a506b; - color: #ffffff !important; +/* back-to-top button */ +.back-to-top { + position: fixed; + bottom: 24px; + right: 24px; + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + background-color: #2a1b3d; + color: #ffffff; + border-radius: 50%; + font-size: 22px; + text-decoration: none; + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25); + z-index: 1000; } -body.dark-mode .page-item.active .page-link, -body.bm-dashboard-dark .page-item.active .page-link { - background-color: #3a506b; - border-color: #4a6072; - color: #ffffff !important; +.back-to-top:hover { + transform: translateY(-4px); + opacity: 0.9; } - -/* Fix the position of the header at the top */ -.top { - position: fixed; - right: 10px; - bottom: 10px; - } - - /* Allow the page content to scroll horizontally */ - .container-fluid { - overflow-x: auto; - } - - /* Hide the horizontal scrollbar */ - .container-fluid::-webkit-scrollbar { - display: none; - } \ No newline at end of file diff --git a/src/components/CustomTooltip.jsx b/src/components/CustomTooltip.jsx index 5b3b438ee8..cb776733d7 100644 --- a/src/components/CustomTooltip.jsx +++ b/src/components/CustomTooltip.jsx @@ -1,54 +1,92 @@ // Universal Custom Tooltip with dark mode support and all values import React from 'react'; -function CustomTooltip({ active, payload, label, yAxisLabel }) { - let isDarkMode = false; - if (typeof window !== 'undefined' && window.matchMedia) { - isDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches; +const formatNumber = value => { + if (!Number.isFinite(value)) return '0'; + return new Intl.NumberFormat('en-US').format(Math.round(value)); +}; + +const formatCompactNumber = value => { + if (!Number.isFinite(value)) return '0'; + const rounded = Math.round(value); + if (Math.abs(rounded) >= 10000) { + const inThousands = rounded / 1000; + if (Math.abs(inThousands) < 10) { + const oneDecimal = Number(inThousands.toFixed(1)); + return `${oneDecimal}k`; + } + return `${Math.round(inThousands)}k`; } - if (active && payload && payload.length) { - const data = payload[0].payload || {}; - // For WorkDistributionBarChart: show name, Total Hours, and percentage - const name = data._id || data.name || label || ''; - const totalHours = data.totalHours !== undefined ? data.totalHours : data.value; - const percentage = data.percentage; - return ( -
-
- {name} + return formatNumber(rounded); +}; + +const getIsDarkMode = () => { + if (typeof window === 'undefined' || !window.matchMedia) return false; + return window.matchMedia('(prefers-color-scheme: dark)').matches; +}; + +const getTooltipData = (payload, label) => { + const data = (payload && payload[0] && payload[0].payload) || {}; + return { + name: data._id || data.name || label || '', + percentage: data.percentage, + hoursValue: data.value, + totalHours: data.totalHours !== undefined ? data.totalHours : data.value, + change: data.change, + }; +}; + +function CustomTooltip({ active, payload, label, tooltipType }) { + if (!active || !payload || !payload.length) return null; + + const isDarkMode = getIsDarkMode(); + const { name, percentage, hoursValue, totalHours, change } = getTooltipData(payload, label); + const textColor = isDarkMode ? '#fff' : '#222'; + + const renderMainValue = () => { + if (tooltipType === 'hoursDistribution' && hoursValue !== undefined) { + const exactHours = formatNumber(hoursValue); + const compactHours = formatCompactNumber(hoursValue); + const showCompactAndExact = compactHours.toLowerCase().includes('k'); + return ( +
+ Hours: {showCompactAndExact ? `${compactHours} (${exactHours})` : exactHours}
- {totalHours !== undefined && ( -
- Total Hours: {totalHours} -
- )} - {percentage !== undefined && ( -
Percentage: {percentage}
- )} - {/* For other charts, fallback to value, change, etc. */} - {data.change !== undefined && ( -
- Change: {data.change} -
- )} -
- ); - } - return null; + ); + } + + if (totalHours !== undefined) { + return
Total Hours: {totalHours}
; + } + + return null; + }; + + const renderChange = () => { + if (change === undefined) return null; + const changeColor = change < 0 ? 'red' : isDarkMode ? 'lightgreen' : 'green'; + return
Change: {change}
; + }; + + return ( +
+
{name}
+ {renderMainValue()} + {percentage !== undefined && ( +
Percentage: {percentage}%
+ )} + {renderChange()} +
+ ); } export default CustomTooltip; diff --git a/src/components/Header/Header.jsx b/src/components/Header/Header.jsx index 9a171d540e..a0d4dfd610 100644 --- a/src/components/Header/Header.jsx +++ b/src/components/Header/Header.jsx @@ -765,14 +765,6 @@ export function Header(props) { > PR Team Analytics - - PR Analytics - {canAccessBlueSquareEmailManagement && ( { + if (!Number.isFinite(value)) return '0'; + return new Intl.NumberFormat('en-US').format(Math.round(value)); +}; + +export const formatChartLabelValue = value => { + if (!Number.isFinite(value)) return '0'; + const rounded = Math.round(value); + const absoluteValue = Math.abs(rounded); + + if (absoluteValue < 1000) { + return formatNumber(rounded); + } + + return new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: absoluteValue >= 100000 ? 0 : 1, + }).format(rounded); +}; + +const parseHexColor = color => { + if (!color || typeof color !== 'string' || !color.startsWith('#')) return null; + const hex = color.replace('#', ''); + const normalizedHex = + hex.length === 3 + ? `${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}` + : hex.padEnd(6, '0').slice(0, 6); + + const r = parseInt(normalizedHex.slice(0, 2), 16); + const g = parseInt(normalizedHex.slice(2, 4), 16); + const b = parseInt(normalizedHex.slice(4, 6), 16); + + if ([r, g, b].some(Number.isNaN)) return null; + return { r, g, b }; +}; + +const channelToLinear = c => { + const normalized = c / 255; + return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4; +}; + +const relativeLuminance = ({ r, g, b }) => + 0.2126 * channelToLinear(r) + 0.7152 * channelToLinear(g) + 0.0722 * channelToLinear(b); + +const contrastRatio = (l1, l2) => { + const brightest = Math.max(l1, l2); + const darkest = Math.min(l1, l2); + return (brightest + 0.05) / (darkest + 0.05); +}; + +const getReadableTextColor = (bgColor, fallbackDarkMode) => { + const rgb = parseHexColor(bgColor); + if (!rgb) return fallbackDarkMode ? '#F8FAFC' : '#111827'; + + const bgL = relativeLuminance(rgb); + const whiteContrast = contrastRatio(bgL, 1); + const blackContrast = contrastRatio(bgL, 0); + return whiteContrast >= blackContrast ? '#FFFFFF' : '#111111'; +}; + +const getLabelToneClass = color => + color === '#FFFFFF' ? 'hours-distribution-label-light' : 'hours-distribution-label-dark'; + +export const renderCenterLabel = ({ darkMode, isMobile, totalHours }) => { + const centerFill = darkMode ? '#D1D5DB' : '#696969'; + const totalText = formatNumber(totalHours || 0); + const centerValueFontSize = totalText.length > 6 ? (isMobile ? 24 : 30) : isMobile ? 30 : 36; + + return ( + <> + + TOTAL HOURS + + + WORKED + + + {totalText} + + + ); +}; + const renderCustomizedLabel = ({ cx, cy, @@ -11,98 +116,159 @@ const renderCustomizedLabel = ({ percent, value, totalHours, - title, - comparisonPercentage, - comparisonType, + fill, + payload, + index, + colors, + resolvedSliceColor, + darkMode, + isMobile, }) => { - const radius = innerRadius + (outerRadius - innerRadius) * 0.4; - const x = cx + radius * Math.cos(-midAngle * RADIAN); - const y = cy + radius * Math.sin(-midAngle * RADIAN); + if (value <= 0) return null; - const percentage = Math.round(comparisonPercentage); - const fillColor = comparisonPercentage > 1 ? 'green' : 'red'; + // Thin wedges cannot fit two long lines; use adaptive compact labels. + const hideAllLabels = percent < 0.005; // < 0.5% + const isTinySlice = percent < 0.025; // < 2.5% + const useCompactLabel = percent < 0.1; // < 10% + const canShowPercent = percent >= 0.1; // >= 10% has room for percentage + // Keep small-slice labels centered in each wedge band for better visual alignment. + // Reduced radiusFactors to keep numbers more centered and prevent overflow + const radiusFactor = isTinySlice ? 0.58 : useCompactLabel ? 0.54 : 0.48; + const radius = innerRadius + (outerRadius - innerRadius) * radiusFactor; + const cos = Math.cos(-midAngle * RADIAN); + const sin = Math.sin(-midAngle * RADIAN); + const x = Math.round(cx + radius * cos); + const y = Math.round(cy + radius * sin); - const textContent = - comparisonType !== 'No Comparison' ? `${percentage}% ${comparisonType.toLowerCase()}` : ''; - const fontSize = 10; - const maxTextLength = Math.floor((innerRadius / fontSize) * 4); + const fallbackIndexedColor = + Array.isArray(colors) && typeof index === 'number' ? colors[index % colors.length] : undefined; + const sliceColor = resolvedSliceColor || payload?.sliceColor || fill || fallbackIndexedColor; + const numberTextColor = getReadableTextColor(sliceColor, darkMode); + const labelToneClass = getLabelToneClass(numberTextColor); - let adjustedText = textContent; - if (textContent.length > maxTextLength) { - adjustedText = `${textContent.substring(0, maxTextLength - 3)}...`; - } + const valueFontSize = isMobile ? 11 : 12; + const percentFontSize = isMobile ? 8 : 9; + const valueY = y - (isMobile ? 7 : 9); // More breathing room above + const percentY = y + (isMobile ? 7 : 9); // More breathing room below + const chartLabelValue = formatChartLabelValue(value); return ( <> - cx ? 'start' : 'end'} - dominantBaseline="central" - fontWeight="bold" - > - {value > 0 && value.toFixed(0)} - - cx ? 'start' : 'end'} - dominantBaseline="central" - fontSize="12" - fontWeight="bold" - > - {percent > 0 && `(${(percent * 100).toFixed(0)}%)`} - - - {title} - - - {totalHours.toFixed(0)} - - {comparisonType !== 'No Comparison' && ( - - {adjustedText} + {!hideAllLabels && (isTinySlice || useCompactLabel) && ( + + {chartLabelValue} )} + {!hideAllLabels && canShowPercent && ( + <> + + {chartLabelValue} + + + {`(${(percent * 100).toFixed(0)}%)`} + + + )} ); }; import CustomTooltip from '../../CustomTooltip'; -export default function HoursWorkedPieChart({ userData, windowSize, comparisonType, colors }) { +export default function HoursWorkedPieChart({ + userData, + windowSize, + colors, + totalHours = 0, + darkMode = false, +}) { let innerRadius = 80; let outerRadius = 160; if (windowSize.width <= 650) { innerRadius = 65; outerRadius = 130; } + const isMobile = windowSize.width <= 650; + const chartData = Array.isArray(userData) + ? userData.map((entry, index) => ({ + ...entry, + sliceColor: colors[index % colors.length], + })) + : []; + + // We'll display totalHours in centre + const displayTotalHours = totalHours || 0; return (
renderCustomizedLabel({ ...props, comparisonType })} + label={props => + renderCustomizedLabel({ + ...props, + totalHours: displayTotalHours, + darkMode, + isMobile, + colors, + resolvedSliceColor: + typeof props.index === 'number' ? chartData[props.index]?.sliceColor : undefined, + }) + } innerRadius={innerRadius} outerRadius={outerRadius} fill="#8884d8" dataKey="value" > - {Array.isArray(userData) && - userData.length > 0 && - userData.map((entry, index) => ( - + {Array.isArray(chartData) && + chartData.length > 0 && + chartData.map((entry, index) => ( + ))} - } /> + {renderCenterLabel({ darkMode, isMobile, totalHours: displayTotalHours })} + } />
); } + +HoursWorkedPieChart.propTypes = { + userData: PropTypes.array.isRequired, + windowSize: PropTypes.shape({ width: PropTypes.number, height: PropTypes.number }).isRequired, + colors: PropTypes.array, + totalHours: PropTypes.number, + darkMode: PropTypes.bool, +}; diff --git a/src/components/TotalOrgSummary/HoursWorkedPieChart/__tests__/HoursWorkedPieChart.test.jsx b/src/components/TotalOrgSummary/HoursWorkedPieChart/__tests__/HoursWorkedPieChart.test.jsx new file mode 100644 index 0000000000..bb3a7aed9d --- /dev/null +++ b/src/components/TotalOrgSummary/HoursWorkedPieChart/__tests__/HoursWorkedPieChart.test.jsx @@ -0,0 +1,25 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { formatChartLabelValue, renderCenterLabel } from '../HoursWorkedPieChart'; + +describe('formatChartLabelValue', () => { + it('formats all chart labels in a consistent compact style', () => { + expect(formatChartLabelValue(190134)).toBe('190K'); + expect(formatChartLabelValue(26713)).toBe('26.7K'); + expect(formatChartLabelValue(11000)).toBe('11K'); + expect(formatChartLabelValue(7857)).toBe('7.9K'); + expect(formatChartLabelValue(6285)).toBe('6.3K'); + expect(formatChartLabelValue(999)).toBe('999'); + }); +}); + +describe('renderCenterLabel', () => { + it('renders the donut center text once with the total hour value', () => { + render({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/NumbersVolunteerWorked/NumbersVolunteerWorked.jsx b/src/components/TotalOrgSummary/NumbersVolunteerWorked/NumbersVolunteerWorked.jsx index 2001a607d4..73ef9da4d4 100644 --- a/src/components/TotalOrgSummary/NumbersVolunteerWorked/NumbersVolunteerWorked.jsx +++ b/src/components/TotalOrgSummary/NumbersVolunteerWorked/NumbersVolunteerWorked.jsx @@ -1,6 +1,23 @@ import styles from '../TotalOrgSummary.module.css'; import cx from 'clsx'; -export default function NumbersVolunteerWorked({ isLoading, data, darkMode }) { + +function formatPercentage(value) { + if (!Number.isFinite(value) || value <= 0) return '0'; + if (value < 1) return value.toFixed(2); + if (value < 10) return Number(value.toFixed(1)).toString(); + return Math.round(value).toString(); +} + +export default function NumbersVolunteerWorked({ + isLoading, + data, + totalVolunteers = 0, + rangeText = '1+ hours', + darkMode, +}) { + const count = data?.count ?? 0; + const rawPercentage = totalVolunteers ? (count / totalVolunteers) * 100 : 0; + const percentage = formatPercentage(rawPercentage); return (

- {isLoading ? '...' : data?.count ?? 0} Volunteers worked 1+ hours over assigned time + {isLoading + ? '...' + : `${count} volunteer${ + count === 1 ? '' : 's' + } (${percentage}%) logged ${rangeText} over the assigned time period`}

); diff --git a/src/components/TotalOrgSummary/NumbersVolunteerWorked/__tests__/NumbersVolunteerWorked.test.jsx b/src/components/TotalOrgSummary/NumbersVolunteerWorked/__tests__/NumbersVolunteerWorked.test.jsx new file mode 100644 index 0000000000..39a4203b91 --- /dev/null +++ b/src/components/TotalOrgSummary/NumbersVolunteerWorked/__tests__/NumbersVolunteerWorked.test.jsx @@ -0,0 +1,53 @@ +import { render, screen } from '@testing-library/react'; +import NumbersVolunteerWorked from '../NumbersVolunteerWorked'; + +let container = null; +beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); +}); + +afterEach(() => { + container.remove(); + container = null; +}); + +describe('NumbersVolunteerWorked component', () => { + it('shows count, percentage and default range when totalVolunteers is provided', () => { + render( + , + { container }, + ); + expect(screen.getByText(/5 volunteers/i)).toBeInTheDocument(); + expect(screen.getByText(/25%/i)).toBeInTheDocument(); + expect(screen.getByText(/1\+ hours/i)).toBeInTheDocument(); + }); + + it('accepts a custom rangeText prop for the label', () => { + render( + , + { container }, + ); + expect(screen.getByText(/2 volunteers/i)).toBeInTheDocument(); + expect(screen.getByText(/100%/i)).toBeInTheDocument(); + expect(screen.getByText(/10-19.99 hrs/i)).toBeInTheDocument(); + }); + + it('displays ellipsis when loading', () => { + render( + , + { container }, + ); + expect(screen.getByText(/\.\.\./)).toBeInTheDocument(); + }); +}); diff --git a/src/components/TotalOrgSummary/TotalOrgSummary.jsx b/src/components/TotalOrgSummary/TotalOrgSummary.jsx index cfc085613f..4bd5a758ce 100644 --- a/src/components/TotalOrgSummary/TotalOrgSummary.jsx +++ b/src/components/TotalOrgSummary/TotalOrgSummary.jsx @@ -28,6 +28,9 @@ import hasPermission from '~/utils/permissions'; import { getTaskAndProjectStats, getTotalOrgSummary } from '~/actions/totalOrgSummary'; import { clsx } from 'clsx'; +import VolunteerHoursDistribution, { + mergeHoursBuckets, +} from './VolunteerHoursDistribution/VolunteerHoursDistribution'; import '../Header/index.css'; import AccordianWrapper from './AccordianWrapper/AccordianWrapper'; import AnniversaryCelebrated from './AnniversaryCelebrated/AnniversaryCelebrated'; @@ -39,7 +42,6 @@ import TaskCompletedBarChart from './TaskCompleted/TaskCompletedBarChart'; import TeamStats from './TeamStats/TeamStats'; import styles from './TotalOrgSummary.module.css'; import VolunteerActivities from './VolunteerActivities/VolunteerActivities'; -import VolunteerHoursDistribution from './VolunteerHoursDistribution/VolunteerHoursDistribution'; import RoleDistributionPieChart from './VolunteerRolesTeamDynamics/RoleDistributionPieChart'; import WorkDistributionBarChart from './VolunteerRolesTeamDynamics/WorkDistributionBarChart'; import VolunteerStatus from './VolunteerStatus/VolunteerStatus'; @@ -47,7 +49,6 @@ import VolunteerStatusChart from './VolunteerStatus/VolunteerStatusChart'; import VolunteerTrendsLineChart from './VolunteerTrendsLineChart/VolunteerTrendsLineChart'; function calculateStartDate() { - // returns a string date in YYYY-MM-DD format of the start of the previous week const currentDate = new Date(); currentDate.setHours(0, 0, 0, 0); const dayOfWeek = currentDate.getDay(); @@ -57,7 +58,6 @@ function calculateStartDate() { } function calculateEndDate() { - // returns a string date in YYYY-MM-DD format of the end of the previous week const currentDate = new Date(); currentDate.setHours(0, 0, 0, 0); const dayOfWeek = currentDate.getDay(); @@ -66,46 +66,395 @@ function calculateEndDate() { return currentDate.toISOString().split('T')[0]; } +function shiftDate(date, diffDays, type) { + if (type === 'Week Over Week') return new Date(date.setDate(date.getDate() - diffDays)); + if (type === 'Month Over Month') return new Date(date.setMonth(date.getMonth() - 1)); + if (type === 'Year Over Year') return new Date(date.setFullYear(date.getFullYear() - 1)); + return null; +} + function calculateComparisonDates(comparisonType, fromDate, toDate) { const start = new Date(fromDate); const end = new Date(toDate); - const diffTime = Math.abs(end - start); - const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); - - switch (comparisonType) { - case 'Week Over Week': - return { - comparisonStartDate: new Date(start.setDate(start.getDate() - diffDays)) - .toISOString() - .split('T')[0], - comparisonEndDate: new Date(end.setDate(end.getDate() - diffDays)) - .toISOString() - .split('T')[0], - }; - case 'Month Over Month': - return { - comparisonStartDate: new Date(start.setMonth(start.getMonth() - 1)) - .toISOString() - .split('T')[0], - comparisonEndDate: new Date(end.setMonth(end.getMonth() - 1)).toISOString().split('T')[0], - }; - case 'Year Over Year': - return { - comparisonStartDate: new Date(start.setFullYear(start.getFullYear() - 1)) - .toISOString() - .split('T')[0], - comparisonEndDate: new Date(end.setFullYear(end.getFullYear() - 1)) - .toISOString() - .split('T')[0], - }; - default: - return { - comparisonStartDate: null, - comparisonEndDate: null, - }; + const diffDays = Math.ceil(Math.abs(end - start) / (1000 * 60 * 60 * 24)); + const shiftedStart = shiftDate(start, diffDays, comparisonType); + const shiftedEnd = shiftDate(end, diffDays, comparisonType); + return { + comparisonStartDate: shiftedStart ? shiftedStart.toISOString().split('T')[0] : null, + comparisonEndDate: shiftedEnd ? shiftedEnd.toISOString().split('T')[0] : null, + }; +} + +function validatePDFPrerequisites(volunteerStats, isLoading) { + if (typeof jsPDF === 'undefined' || typeof html2canvas === 'undefined') { + // eslint-disable-next-line no-alert + alert('Required PDF libraries not loaded. Please refresh the page.'); + return false; + } + if (!volunteerStats || isLoading) { + // eslint-disable-next-line no-alert + alert('Please wait for data to load before generating PDF.'); + return false; + } + return true; +} + +function replaceCanvasesWithImages(canvasElements) { + const originalCanvases = []; + canvasElements.forEach(canvasElem => { + try { + const img = document.createElement('img'); + img.src = canvasElem.toDataURL('image/png'); + img.width = canvasElem.width; + img.height = canvasElem.height; + img.style.cssText = canvasElem.style.cssText; + originalCanvases.push({ canvas: canvasElem, parent: canvasElem.parentNode }); + canvasElem.parentNode.replaceChild(img, canvasElem); + } catch (err) { + // eslint-disable-next-line no-console + console.error('Error converting canvas to image:', err); + } + }); + return originalCanvases; +} + +function restoreCanvases(originalCanvases) { + originalCanvases.forEach(({ canvas, parent }) => { + const img = parent.querySelector('img'); + if (img) img.replaceWith(canvas); + }); +} + +function copyLiveCanvasesToClone(liveCanvases, clonedCanvases) { + clonedCanvases.forEach(clonedCanvas => { + try { + const match = liveCanvases.find( + liveCanvas => + liveCanvas.width === clonedCanvas.width && + liveCanvas.height === clonedCanvas.height && + liveCanvas.parentNode?.className === clonedCanvas.parentNode?.className, + ); + if (match) { + const ctx = clonedCanvas.getContext('2d'); + ctx.clearRect(0, 0, clonedCanvas.width, clonedCanvas.height); + ctx.drawImage(match, 0, 0); + } + if (clonedCanvas.width > 0 && clonedCanvas.height > 0) { + const img = document.createElement('img'); + img.src = clonedCanvas.toDataURL('image/png'); + img.width = clonedCanvas.width; + img.height = clonedCanvas.height; + img.style.cssText = clonedCanvas.style.cssText; + clonedCanvas.parentNode.replaceChild(img, clonedCanvas); + } + } catch (err) { + /* ignore */ + } + }); +} + +function adjustTitleRowForPDF(clonedContent) { + const titleRow = clonedContent.querySelector('[data-pdf-title-row]'); + if (!titleRow) return; + const titleCol = titleRow.querySelector('[data-pdf-title-col]'); + if (titleCol) titleCol.style.width = '100%'; + const mainTitle = titleRow.querySelector('h3'); + if (mainTitle) { + mainTitle.style.fontSize = '24pt'; + mainTitle.style.fontWeight = 'bold'; + mainTitle.style.textAlign = 'left'; + mainTitle.style.color = '#000'; + mainTitle.style.margin = '0'; } } +function removeCollapsedSections(clonedContent) { + clonedContent.querySelectorAll('.Collapsible').forEach(collapsible => { + const trigger = collapsible.querySelector('.Collapsible__trigger'); + if (!trigger || !trigger.classList.contains('is-open')) { + collapsible.remove(); + } + }); +} + +function buildPDFContainer() { + const pdfContainer = document.createElement('div'); + pdfContainer.id = 'pdf-export-container'; + Object.assign(pdfContainer.style, { + width: '100%', + padding: '0', + position: 'absolute', + left: '-9999px', + top: '0', + zIndex: '9999', + boxSizing: 'border-box', + minHeight: '100%', + margin: '0', + }); + return pdfContainer; +} + +async function captureAndSavePDF(pdfContainer) { + const screenshotCanvas = await html2canvas(pdfContainer, { + scale: 2, + useCORS: true, + windowWidth: pdfContainer.scrollWidth, + windowHeight: pdfContainer.scrollHeight, + logging: false, + }); + if (!screenshotCanvas) throw new Error('html2canvas failed to capture the content.'); + const imgData = screenshotCanvas.toDataURL('image/png'); + if (!imgData || imgData.length < 100) throw new Error('Invalid image data generated.'); + const pdfWidth = 210; + const imgHeight = (screenshotCanvas.height * pdfWidth) / screenshotCanvas.width; + const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: [pdfWidth, imgHeight] }); + doc.addImage(imgData, 'PNG', 0, 0, pdfWidth, imgHeight); + doc.save(`volunteer-report-${new Date().toLocaleDateString('en-CA')}.pdf`); +} + +function buildPDFStyles(darkMode) { + const styleElem = document.createElement('style'); + styleElem.textContent = ` + [data-pdf-root] { + padding: 20px !important; margin: 0 !important; box-shadow: none !important; + border: none !important; width: 100% !important; min-height: 100% !important; + } + [data-pdf-title-row] { + display: flex !important; justify-content: space-between !important; + align-items: center !important; margin-bottom: 20px !important; + width: 100% !important; padding: 0 !important; + } + [data-pdf-block] { + page-break-inside: avoid; break-inside: avoid; margin: 15px 0 !important; + padding: 20px !important; + background-color: ${darkMode ? '#1C2541' : '#fff'} !important; + border: 1px solid ${darkMode ? '#3a3a3a' : '#e0e0e0'} !important; + border-radius: 10px !important; + box-shadow: ${ + darkMode + ? '0 2px 12px 0 rgba(255,255,255,0.18), 0 1.5px 8px 0 rgba(255,255,255,0.10)' + : '0 2px 4px rgba(0, 0, 0, 0.08)' + } !important; + overflow: hidden !important; + } + img, svg { max-width: 100% !important; height: auto !important; page-break-inside: avoid !important; } + .recharts-wrapper { width: 100% !important; height: auto !important; } + table { page-break-inside: avoid !important; } + .Collapsible__trigger { + background-color: ${darkMode ? '#1C2541' : '#fff'} !important; + color: ${darkMode ? '#fff' : '#000'} !important; + } + .volunteerStatusGrid { + display: grid !important; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)) !important; + gap: 1.5rem !important; width: 100% !important; margin-top: 15px !important; + } + .component-pie-chart-label { + font-size: 12px !important; font-weight: 600 !important; color: #000 !important; + overflow: hidden !important; text-overflow: ellipsis !important; + } + [data-pdf-title] p { + font-size: 1.5em !important; font-weight: bold !important; text-align: center !important; + margin: 10px !important; color: #333 !important; + } + ${ + darkMode + ? ` + .componentContainer h1, .componentContainer h2, .componentContainer h3, + .componentContainer h4, .componentContainer h5, .componentContainer h6, + .componentContainer p, .componentContainer .totalOrgChartTitle, + .componentContainer .volunteerStatusGrid h3, .componentContainer .card-title, + .componentContainer .statistics-title, .componentContainer .Collapsible__trigger, + .componentContainer .volunteer-status-header, .componentContainer .volunteer-status-title { + color: #fff !important; text-shadow: 0 1px 4px #000, 0 0 2px #000 !important; + } + .componentContainer [data-pdf-title] p, .componentContainer [data-pdf-title] { + color: #fff !important; text-shadow: 0 1px 4px #000, 0 0 2px #000 !important; + }` + : '' + } + `; + return styleElem; +} + +async function fetchOrgStats(props, selectedComparison, currentFromDate, currentToDate) { + const { comparisonStartDate, comparisonEndDate } = calculateComparisonDates( + selectedComparison, + currentFromDate, + currentToDate, + ); + const volunteerStatsResponse = await props.getTotalOrgSummary( + currentFromDate, + currentToDate, + comparisonStartDate, + comparisonEndDate, + ); + const taskAndProjectStatsResponse = await props.getTaskAndProjectStats( + currentFromDate, + currentToDate, + ); + return { ...volunteerStatsResponse.data, taskAndProjectStats: taskAndProjectStatsResponse }; +} + +function getPreviousWeekDates(fromDate, toDate) { + const prevWeekStart = new Date(fromDate); + const prevWeekEnd = new Date(toDate); + prevWeekStart.setDate(prevWeekStart.getDate() - 7); + prevWeekEnd.setDate(prevWeekEnd.getDate() - 7); + return { + start: prevWeekStart.toISOString().split('T')[0], + end: prevWeekEnd.toISOString().split('T')[0], + }; +} + +async function generateTotalOrgPdf({ rootRef, darkMode, volunteerStats, isLoading }) { + if (!validatePDFPrerequisites(volunteerStats, isLoading)) return; + await new Promise(resolve => setTimeout(resolve, 5000)); + const chartCanvases = document.querySelectorAll( + '[data-chart="volunteer-status"] canvas, [data-chart="mentor-status"] canvas', + ); + const originalCanvases = replaceCanvasesWithImages(chartCanvases); + const pdfContainer = buildPDFContainer(); + const clonedContent = rootRef.current.cloneNode(true); + clonedContent + .querySelectorAll('[data-pdf-hide], .controls, .no-print') + .forEach(el => el.remove()); + removeCollapsedSections(clonedContent); + copyLiveCanvasesToClone( + Array.from(document.querySelectorAll('canvas')), + Array.from(clonedContent.querySelectorAll('canvas')), + ); + adjustTitleRowForPDF(clonedContent); + pdfContainer.appendChild(buildPDFStyles(darkMode)); + pdfContainer.appendChild(clonedContent); + document.body.appendChild(pdfContainer); + await captureAndSavePDF(pdfContainer); + restoreCanvases(originalCanvases); + document.body.removeChild(pdfContainer); +} + +function ReportHeader({ + darkMode, + selectedDateRange, + selectedComparison, + dateRangeDropdownOpen, + comparisonDropdownOpen, + isGeneratingPDF, + onDateRangeToggle, + onComparisonToggle, + onDateRangeSelect, + onComparisonSelect, + onSavePDF, +}) { + return ( + +
+

Total Org Summary

+
+
+ + {selectedDateRange} + + onDateRangeSelect('Current Week')}> + Current Week + + onDateRangeSelect('Previous Week')}> + Previous Week + + onDateRangeSelect('Select Date Range')}> + Select Date Range + + + + + {selectedComparison} + + onComparisonSelect('No Comparison')}> + No Comparison + + onComparisonSelect('Week Over Week')}> + Week Over Week + + onComparisonSelect('Month Over Month')}> + Month Over Month + + onComparisonSelect('Year Over Year')}> + Year Over Year + + + + +
+
+ ); +} + +function DateRangeModal({ + showDatePicker, + startDate, + endDate, + onToggle, + onStartChange, + onEndChange, + onCancel, + onApply, +}) { + return ( + + Select Date Range + +
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ + + + +
+ ); +} + const fromDate = calculateStartDate(); const toDate = calculateEndDate(); @@ -185,293 +534,9 @@ function TotalOrgSummary(props) { const handleSaveAsPDF = async () => { if (isGeneratingPDF) return; - setIsGeneratingPDF(true); - try { - // Ensure required libraries are present. - if (typeof jsPDF === 'undefined' || typeof html2canvas === 'undefined') { - // eslint-disable-next-line no-alert - alert('Required PDF libraries not loaded. Please refresh the page.'); - return; - } - - // Ensure data is ready. - if (!volunteerStats || isLoading) { - // eslint-disable-next-line no-alert - alert('Please wait for data to load before generating PDF.'); - return; - } - - // 2. Wait longer for charts/maps to render (5 seconds) - await new Promise(resolve => setTimeout(resolve, 5000)); - - // 3. Replace Chart.js canvas elements with images in the live DOM. - const chartCanvases = document.querySelectorAll( - '[data-chart="volunteer-status"] canvas, [data-chart="mentor-status"] canvas', - ); - const originalCanvases = []; - chartCanvases.forEach(canvasElem => { - try { - const img = document.createElement('img'); - img.src = canvasElem.toDataURL('image/png'); - img.width = canvasElem.width; - img.height = canvasElem.height; - img.style.cssText = canvasElem.style.cssText; - originalCanvases.push({ - canvas: canvasElem, - parent: canvasElem.parentNode, - }); - canvasElem.parentNode.replaceChild(img, canvasElem); - } catch (err) { - // eslint-disable-next-line no-console - console.error('Error converting canvas to image:', err); - } - }); - - // 4. Create a temporary container for PDF generation - const pdfContainer = document.createElement('div'); - pdfContainer.id = 'pdf-export-container'; - pdfContainer.style.width = '100%'; - pdfContainer.style.padding = '0'; - pdfContainer.style.position = 'absolute'; - pdfContainer.style.left = '-9999px'; - pdfContainer.style.top = '0'; - pdfContainer.style.zIndex = '9999'; - pdfContainer.style.boxSizing = 'border-box'; - pdfContainer.style.minHeight = '100%'; - pdfContainer.style.margin = '0'; - - // Clone the main content area - - const originalContent = rootRef.current; - const clonedContent = originalContent.cloneNode(true); - - // Remove interactive or unwanted elements from the clone - - // Remove interactive or unwanted elements from the clone - clonedContent - .querySelectorAll('[data-pdf-hide], .controls, .no-print') - .forEach(el => el.remove()); - - // Remove all collapsed AccordianWrapper (Collapsible) sections from the PDF - clonedContent.querySelectorAll('.Collapsible').forEach(collapsible => { - // If it does NOT have the 'is-open' class, it's collapsed - const trigger = collapsible.querySelector('.Collapsible__trigger'); - if (!trigger || !trigger.classList.contains('is-open')) { - collapsible.remove(); - } - }); - - // Copy canvas bitmap from live DOM to cloned DOM before converting to image - // This includes Chart.js, Leaflet, and heatmap overlays - const liveCanvases = Array.from(document.querySelectorAll('canvas')); - const clonedCanvases = Array.from(clonedContent.querySelectorAll('canvas')); - clonedCanvases.forEach(clonedCanvas => { - try { - // Try to find a matching live canvas by size and position - const match = liveCanvases.find( - liveCanvas => - liveCanvas.width === clonedCanvas.width && - liveCanvas.height === clonedCanvas.height && - liveCanvas.parentNode?.className === clonedCanvas.parentNode?.className, - ); - if (match) { - const ctx = clonedCanvas.getContext('2d'); - ctx.clearRect(0, 0, clonedCanvas.width, clonedCanvas.height); - ctx.drawImage(match, 0, 0); - } - // Now convert to image if canvas has content - if (clonedCanvas.width > 0 && clonedCanvas.height > 0) { - const img = document.createElement('img'); - img.src = clonedCanvas.toDataURL('image/png'); - img.width = clonedCanvas.width; - img.height = clonedCanvas.height; - img.style.cssText = clonedCanvas.style.cssText; - clonedCanvas.parentNode.replaceChild(img, clonedCanvas); - } - } catch (err) { - /* ignore */ - } - }); - - // Adjust title row styling for a clean layout - const titleRow = clonedContent.querySelector('[data-pdf-title-row]'); - if (titleRow) { - const titleCol = titleRow.querySelector('[data-pdf-title-col]'); - if (titleCol) { - titleCol.style.width = '100%'; - } - const mainTitle = titleRow.querySelector('h3'); - if (mainTitle) { - mainTitle.style.fontSize = '24pt'; - mainTitle.style.fontWeight = 'bold'; - mainTitle.style.textAlign = 'left'; - mainTitle.style.color = '#000'; - mainTitle.style.margin = '0'; - } - } - - // Create a style element for the PDF container - const styleElem = document.createElement('style'); - styleElem.textContent = ` - [data-pdf-root] { - padding: 20px !important; - margin: 0 !important; - box-shadow: none !important; - border: none !important; - width: 100% !important; - min-height: 100% !important; - } - - [data-pdf-title-row] { - display: flex !important; - justify-content: space-between !important; - align-items: center !important; - margin-bottom: 20px !important; - width: 100% !important; - padding: 0 !important; - } - - /* PDF block container: border and shadow, dark mode fidelity */ - [data-pdf-block] { - page-break-inside: avoid; - break-inside: avoid; - margin: 15px 0 !important; - padding: 20px !important; - background-color: ${darkMode ? '#1C2541' : '#fff'} !important; - border: 1px solid ${darkMode ? '#3a3a3a' : '#e0e0e0'} !important; - border-radius: 10px !important; - box-shadow: ${ - darkMode - ? '0 2px 12px 0 rgba(255,255,255,0.18), 0 1.5px 8px 0 rgba(255,255,255,0.10)' - : '0 2px 4px rgba(0, 0, 0, 0.08)' - } !important; - overflow: hidden !important; - } - - img, svg { - max-width: 100% !important; - height: auto !important; - page-break-inside: avoid !important; - } - - .recharts-wrapper { - width: 100% !important; - height: auto !important; - } - - table { - page-break-inside: avoid !important; - } - - .Collapsible__trigger { - background-color: ${darkMode ? '#1C2541' : '#fff'} !important; - color: ${darkMode ? '#fff' : '#000'} !important; - } - - .volunteerStatusGrid { - display: grid !important; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)) !important; - gap: 1.5rem !important; - width: 100% !important; - margin-top: 15px !important; - } - - .component-pie-chart-label { - font-size: 12px !important; - font-weight: 600 !important; - color: #000 !important; - overflow: hidden !important; - text-overflow: ellipsis !important; - } - - [data-pdf-title] p { - font-size: 1.5em !important; - font-weight: bold !important; - text-align: center !important; - margin: 10px !important; - color: #333 !important; - } - - /* Force all chart and card titles to white in dark mode for PDF */ -${ - darkMode - ? ` - .componentContainer h1, - .componentContainer h2, - .componentContainer h3, - .componentContainer h4, - .componentContainer h5, - .componentContainer h6, - .componentContainer p, - .componentContainer .totalOrgChartTitle, - .componentContainer .volunteerStatusGrid h3, - .componentContainer .card-title, - .componentContainer .statistics-title, - .componentContainer .Collapsible__trigger, - .componentContainer .volunteer-status-header, - .componentContainer .volunteer-status-title { - color: #fff !important; - text-shadow: 0 1px 4px #000, 0 0 2px #000 !important; - } - .componentContainer [data-pdf-title] p { - color: #fff !important; - text-shadow: 0 1px 4px #000, 0 0 2px #000 !important; - } - .componentContainer [data-pdf-title] { - color: #fff !important; - text-shadow: 0 1px 4px #000, 0 0 2px #000 !important; - } -` - : '' -} - `; - - // Add content to the PDF container - pdfContainer.appendChild(styleElem); - pdfContainer.appendChild(clonedContent); - document.body.appendChild(pdfContainer); - - // 5. Use html2canvas to capture the rendered container - const screenshotCanvas = await html2canvas(pdfContainer, { - scale: 2, - useCORS: true, - windowWidth: pdfContainer.scrollWidth, - windowHeight: pdfContainer.scrollHeight, - logging: false, - }); - - if (!screenshotCanvas) { - throw new Error('html2canvas failed to capture the content.'); - } - - const imgData = screenshotCanvas.toDataURL('image/png'); - if (!imgData || imgData.length < 100) { - throw new Error('Invalid image data generated.'); - } - - // 6. Create a single-page PDF - const pdfWidth = 210; // A4 width in mm - const imgHeight = (screenshotCanvas.height * pdfWidth) / screenshotCanvas.width; - const doc = new jsPDF({ - orientation: 'portrait', - unit: 'mm', - format: [pdfWidth, imgHeight], - }); - doc.addImage(imgData, 'PNG', 0, 0, pdfWidth, imgHeight); - const now = new Date(); - const localDate = now.toLocaleDateString('en-CA'); // Using YYYY-MM-DD format - doc.save(`volunteer-report-${localDate}.pdf`); - - // Cleanup: restore original canvases and remove temporary container - originalCanvases.forEach(({ canvas, parent }) => { - const img = parent.querySelector('img'); - if (img) { - parent.replaceChild(canvas, img); - } - }); - document.body.removeChild(pdfContainer); + await generateTotalOrgPdf({ rootRef, darkMode, volunteerStats, isLoading }); } catch (pdfError) { // eslint-disable-next-line no-console console.error('PDF generation failed:', pdfError); @@ -485,22 +550,18 @@ ${ const handleDateRangeSelect = option => { if (option === 'Select Date Range') { setShowDatePicker(true); - } else { - setSelectedDateRange(option); - setShowDatePicker(false); - setSelectedComparison('No Comparison'); - - if (option === 'Current Week') { - setCurrentFromDate(fromDate); - setCurrentToDate(toDate); - } else if (option === 'Previous Week') { - const prevWeekStart = new Date(fromDate); - const prevWeekEnd = new Date(toDate); - prevWeekStart.setDate(prevWeekStart.getDate() - 7); - prevWeekEnd.setDate(prevWeekEnd.getDate() - 7); - setCurrentFromDate(prevWeekStart.toISOString().split('T')[0]); - setCurrentToDate(prevWeekEnd.toISOString().split('T')[0]); - } + return; + } + setSelectedDateRange(option); + setShowDatePicker(false); + setSelectedComparison('No Comparison'); + if (option === 'Current Week') { + setCurrentFromDate(fromDate); + setCurrentToDate(toDate); + } else if (option === 'Previous Week') { + const { start, end } = getPreviousWeekDates(fromDate, toDate); + setCurrentFromDate(start); + setCurrentToDate(end); } }; @@ -509,7 +570,6 @@ ${ setSelectedDateRange(`${startDate.toLocaleDateString()} - ${endDate.toLocaleDateString()}`); setShowDatePicker(false); setSelectedComparison('No Comparison'); - setCurrentFromDate(startDate.toISOString().split('T')[0]); setCurrentToDate(endDate.toISOString().split('T')[0]); } @@ -517,12 +577,7 @@ ${ if (error || isVolunteerFetchingError) { return ( - +
- -
-

Total Org Summary

-
-
- setDateRangeDropdownOpen(!dateRangeDropdownOpen)} - > - {selectedDateRange} - - handleDateRangeSelect('Current Week')}> - Current Week - - handleDateRangeSelect('Previous Week')}> - Previous Week - - handleDateRangeSelect('Select Date Range')}> - Select Date Range - - - - setComparisonDropdownOpen(!comparisonDropdownOpen)} - > - {selectedComparison} - - setSelectedComparison('No Comparison')}> - No Comparison - - setSelectedComparison('Week Over Week')}> - Week Over Week - - setSelectedComparison('Month Over Month')}> - Month Over Month - - setSelectedComparison('Year Over Year')}> - Year Over Year - - - - -
-
- - setShowDatePicker(!showDatePicker)}> - setShowDatePicker(!showDatePicker)}> - Select Date Range - - -
-
- -
- setStartDate(date)} - className="form-control" - dateFormat="MM/dd/yyyy" - placeholderText="Select start date" - /> -
-
- -
- -
- setEndDate(date)} - className="form-control" - dateFormat="MM/dd/yyyy" - placeholderText="Select end date" - minDate={startDate} - /> -
-
-
-
- - - - -
+ setDateRangeDropdownOpen(!dateRangeDropdownOpen)} + onComparisonToggle={() => setComparisonDropdownOpen(!comparisonDropdownOpen)} + onDateRangeSelect={handleDateRangeSelect} + onComparisonSelect={setSelectedComparison} + onSavePDF={handleSaveAsPDF} + /> + setShowDatePicker(!showDatePicker)} + onStartChange={date => setStartDate(date)} + onEndChange={date => setEndDate(date)} + onCancel={() => setShowDatePicker(false)} + onApply={handleDatePickerSubmit} + />
@@ -756,12 +728,18 @@ ${ darkMode={darkMode} hoursData={volunteerStats?.volunteerHoursStats} totalHoursData={volunteerStats?.totalHoursWorked} - comparisonType={selectedComparison} />
sum + (Number(bucket?.count) || 0), + 0, + ), + }} + totalVolunteers={volunteerStats?.volunteerNumberStats?.totalVolunteers?.count} + rangeText="1+ hours" darkMode={darkMode} />
diff --git a/src/components/TotalOrgSummary/TotalOrgSummary.module.css b/src/components/TotalOrgSummary/TotalOrgSummary.module.css index e0d58cb125..7d6a4d3a74 100644 --- a/src/components/TotalOrgSummary/TotalOrgSummary.module.css +++ b/src/components/TotalOrgSummary/TotalOrgSummary.module.css @@ -1,10 +1,13 @@ +/* stylelint-disable no-descending-specificity */ + /* Chart labels stay dark in dark mode for readability against light label chips */ .containerTotalOrgWrapper:global(.bg-oxford-blue) .componentContainer .recharts-wrapper text, .containerTotalOrgWrapper:global(.bg-oxford-blue) .componentContainer .recharts-wrapper tspan { fill: #000 !important; color: #000 !important; - text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.25), 0 0 2px #fff; + text-shadow: 1px 1px 3px rgb(0 0 0 / 25%), 0 0 2px #fff; } + /* Chart and graph titles/text should be white in dark mode for visibility */ .containerTotalOrgWrapper:global(.bg-oxford-blue) .componentContainer h3, .containerTotalOrgWrapper:global(.bg-oxford-blue) .componentContainer p, @@ -12,6 +15,7 @@ .containerTotalOrgWrapper:global(.bg-oxford-blue) .componentContainer .volunteerStatusGrid h3 { color: #fff !important; } + .containerTotalOrgWrapper { min-height: 100%; background-color: #fff; @@ -109,13 +113,13 @@ :global(.dropdown-toggle):hover { background-color: #5a359a !important; transform: translateY(-2px) !important; - box-shadow: 0 4px 12px rgba(111, 66, 193, 0.4) !important; + box-shadow: 0 4px 12px rgb(111 66 193 / 40%) !important; } .containerTotalOrgWrapper:global(.bg-oxford-blue) .reportHeaderActions :global(.dropdown-menu) { background-color: #34495e !important; border: 1px solid #6f42c1 !important; - box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3) !important; + box-shadow: 0 8px 25px rgb(0 0 0 / 30%) !important; } .containerTotalOrgWrapper:global(.bg-oxford-blue) .reportHeaderActions :global(.dropdown-item) { @@ -135,13 +139,13 @@ .containerTotalOrgWrapper:global(.bg-oxford-blue) .reportHeaderActions button { background-color: #007bff !important; color: white !important; - box-shadow: 0 2px 6px rgba(0, 123, 255, 0.3) !important; + box-shadow: 0 2px 6px rgb(0 123 255 / 30%) !important; } .containerTotalOrgWrapper:global(.bg-oxford-blue) .reportHeaderActions button:hover { background-color: #0056b3 !important; transform: translateY(-2px) !important; - box-shadow: 0 4px 12px rgba(0, 123, 255, 0.4) !important; + box-shadow: 0 4px 12px rgb(0 123 255 / 40%) !important; } .containerTotalOrgWrapper:global(.bg-oxford-blue) .componentContainer { @@ -188,13 +192,37 @@ fill: #fff !important; } +/* Explicit overrides for Volunteer Hours Distribution slice labels. + These must win over global forced-white chart text rules above. */ +.containerTotalOrgWrapper:global(.bg-oxford-blue) + .componentContainer + :global(.hours-distribution-label-dark), +.containerTotalOrgWrapper:global(.bg-oxford-blue) + .componentContainer + :global(.hours-distribution-label-dark) + tspan { + fill: #111 !important; + color: #111 !important; +} + +.containerTotalOrgWrapper:global(.bg-oxford-blue) + .componentContainer + :global(.hours-distribution-label-light), +.containerTotalOrgWrapper:global(.bg-oxford-blue) + .componentContainer + :global(.hours-distribution-label-light) + tspan { + fill: #fff !important; + color: #fff !important; +} + .containerTotalOrgWrapper.bg-oxford-blue h3 { color: #fff; } .pageRow { width: 100% !important; - margin: 1px 10px 15px 10px; + margin: 1px 10px 15px; } /* Header section styles */ @@ -243,8 +271,9 @@ /* Dark mode header styling */ .containerTotalOrgWrapper:global(.bg-oxford-blue) .reportHeaderTitle h3 { - color: #ffffff !important; + color: #fff !important; } + .reportHeaderActions { display: flex; align-items: center; @@ -275,18 +304,18 @@ min-width: 120px !important; background-color: #6f42c1 !important; color: white !important; - box-shadow: 0 2px 6px rgba(111, 66, 193, 0.3) !important; + box-shadow: 0 2px 6px rgb(111 66 193 / 30%) !important; } .reportHeaderActions :global(.dropdown-toggle):hover { background-color: #5a359a !important; transform: translateY(-2px) !important; - box-shadow: 0 4px 12px rgba(111, 66, 193, 0.4) !important; + box-shadow: 0 4px 12px rgb(111 66 193 / 40%) !important; } .reportHeaderActions :global(.dropdown-toggle):focus { outline: none !important; - box-shadow: 0 0 0 3px rgba(111, 66, 193, 0.3) !important; + box-shadow: 0 0 0 3px rgb(111 66 193 / 30%) !important; } /* Beautiful button styling */ @@ -304,7 +333,7 @@ min-height: 44px !important; background-color: #007bff !important; color: white !important; - box-shadow: 0 2px 6px rgba(0, 123, 255, 0.3) !important; + box-shadow: 0 2px 6px rgb(0 123 255 / 30%) !important; text-transform: none !important; letter-spacing: 0.3px !important; } @@ -312,7 +341,7 @@ .reportHeaderActions button:hover { background-color: #0056b3 !important; transform: translateY(-2px) !important; - box-shadow: 0 4px 12px rgba(0, 123, 255, 0.4) !important; + box-shadow: 0 4px 12px rgb(0 123 255 / 40%) !important; } .reportHeaderActions button:disabled { @@ -320,19 +349,19 @@ cursor: not-allowed !important; opacity: 0.6 !important; transform: none !important; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1) !important; + box-shadow: 0 2px 4px rgb(0 0 0 / 10%) !important; } .reportHeaderActions button:focus { outline: none !important; - box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.3) !important; + box-shadow: 0 0 0 3px rgb(0 123 255 / 30%) !important; } /* Enhanced dropdown menu styling */ .reportHeaderActions :global(.dropdown-menu) { border: none !important; border-radius: 12px !important; - box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15) !important; + box-shadow: 0 8px 25px rgb(0 0 0 / 15%) !important; padding: 8px 0 !important; margin-top: 8px !important; min-width: 180px !important; @@ -355,14 +384,15 @@ } /* Dark mode dropdown consistency */ + /* Component containers - Clean borderless design */ .componentContainer { - margin: 0 0 15px 0; + margin: 0 0 15px; padding: 20px; background-color: #fff; border-radius: 10px; border: 1.5px solid #e0e0e0; - box-shadow: 0 4px 16px rgba(37, 99, 235, 0.1), 0 1.5px 4px rgba(0, 0, 0, 0.08); + box-shadow: 0 4px 16px rgb(37 99 235 / 10%), 0 1.5px 4px rgb(0 0 0 / 8%); height: 100%; display: flex; flex-direction: column; @@ -373,7 +403,7 @@ .componentBorder { border: 1.5px solid #e0e0e0; border-radius: 10px; - box-shadow: 0 4px 16px rgba(37, 99, 235, 0.1), 0 1.5px 4px rgba(0, 0, 0, 0.08); + box-shadow: 0 4px 16px rgb(37 99 235 / 10%), 0 1.5px 4px rgb(0 0 0 / 8%); background-color: #fff; overflow: hidden; height: 100%; @@ -386,10 +416,11 @@ .componentBorderLoose { overflow: visible; } + .containerTotalOrgWrapper:global(.bg-oxford-blue) .componentBorder { background-color: #1c2541 !important; border: 1.5px solid #2f4157 !important; - box-shadow: 0 4px 24px rgba(255, 255, 255, 0.13), 0 1.5px 4px rgba(255, 255, 255, 0.1) !important; + box-shadow: 0 4px 24px rgb(255 255 255 / 13%), 0 1.5px 4px rgb(255 255 255 / 10%) !important; } /* Grid layouts */ @@ -421,6 +452,7 @@ } /* Report Header Styles */ + /* .totalOrgReportHeaderRow { display: flex; justify-content: space-between; @@ -671,3 +703,5 @@ padding-left: 1% !important; } } + +/* stylelint-enable no-descending-specificity */ diff --git a/src/components/TotalOrgSummary/VolunteerHoursDistribution/VolunteerHoursDistribution.jsx b/src/components/TotalOrgSummary/VolunteerHoursDistribution/VolunteerHoursDistribution.jsx index bb5221a30b..c96c3eba24 100644 --- a/src/components/TotalOrgSummary/VolunteerHoursDistribution/VolunteerHoursDistribution.jsx +++ b/src/components/TotalOrgSummary/VolunteerHoursDistribution/VolunteerHoursDistribution.jsx @@ -6,6 +6,97 @@ import Loading from '../../common/Loading'; const COLORS = ['#00AFF4', '#FFA500', '#00B030', '#EC52CB', '#F8FF00']; +function parseRangeStart(rangeStr) { + if (!rangeStr) return 0; + const [first] = String(rangeStr).split(/[-+]/); + const parsed = Number(first); + return Number.isFinite(parsed) ? parsed : 0; +} + +function normalizeBucketId(rangeStr) { + if (!rangeStr) return ''; + const trimmed = String(rangeStr).trim(); + if (trimmed.includes('+')) { + const start = parseRangeStart(trimmed); + return start === 40 ? '50+' : `${start}+`; + } + if (trimmed.includes('-')) { + const start = parseRangeStart(trimmed); + if (start === 40) return '40'; + return String(start); + } + return String(parseRangeStart(trimmed)); +} + +function mergeHoursBuckets(hoursData) { + const safeHoursData = Array.isArray(hoursData) ? hoursData : []; + const merged = new Map(); + safeHoursData.forEach(item => { + const normalizedId = normalizeBucketId(item?._id); + if (!normalizedId) return; + const existing = merged.get(normalizedId) || 0; + merged.set(normalizedId, existing + (Number(item?.count) || 0)); + }); + return [...merged.entries()] + .map(([id, count]) => ({ _id: id, count })) + .sort((a, b) => parseRangeStart(a._id) - parseRangeStart(b._id)); +} + +function allocateRoundedHoursByCount(normalizedHoursData, totalHoursWorked) { + const roundedTotalHours = Math.max(0, Math.round(Number(totalHoursWorked) || 0)); + const totalCount = normalizedHoursData.reduce( + (sum, bucket) => sum + (Number(bucket.count) || 0), + 0, + ); + + if (!totalCount || !roundedTotalHours) { + return normalizedHoursData.map(bucket => ({ ...bucket, allocatedHours: 0 })); + } + + const provisional = normalizedHoursData.map(bucket => { + const count = Number(bucket.count) || 0; + const exact = (count / totalCount) * roundedTotalHours; + const base = Math.floor(exact); + return { ...bucket, allocatedHours: base, remainder: exact - base }; + }); + + let assigned = provisional.reduce((sum, bucket) => sum + bucket.allocatedHours, 0); + let remaining = roundedTotalHours - assigned; + + const byRemainderDesc = [...provisional].sort((a, b) => b.remainder - a.remainder); + let i = 0; + while (remaining > 0 && byRemainderDesc.length > 0) { + byRemainderDesc[i % byRemainderDesc.length].allocatedHours += 1; + remaining -= 1; + i += 1; + } + + return byRemainderDesc + .map(({ remainder, ...bucket }) => bucket) + .sort((a, b) => parseRangeStart(a._id) - parseRangeStart(b._id)); +} + +// convert backend range string (e.g. "10", "40+", "20-29") +// into a user-facing label with units. +export function formatRangeLabel(rangeStr) { + if (!rangeStr) return ''; + const normalizedRange = normalizeBucketId(rangeStr); + let displayName = ''; + if (normalizedRange.includes('+')) { + const num = parseFloat(normalizedRange.replace('+', '')); + if (num === 40) { + displayName = '50+ hrs'; + } else { + displayName = `${num}+ hrs`; + } + } else { + const num = parseFloat(normalizedRange); + const next = (num + 9.99).toFixed(2); + displayName = `${num}-${next} hrs`; + } + return displayName; +} + function HoursWorkList({ data, darkMode }) { if (!data) return
; @@ -13,11 +104,16 @@ function HoursWorkList({ data, darkMode }) { const rangeStr = elem._id; const entry = { name: rangeStr, + count: elem.count, }; - const rangeArr = rangeStr.split('-'); + // derive human-readable label for the bucket + const displayName = formatRangeLabel(rangeStr); + + entry.displayName = displayName; entry.color = COLORS[index]; + const rangeArr = rangeStr.split('-'); if (rangeArr.length > 1) { const [min, max] = rangeArr; entry.min = Number(min); @@ -55,12 +151,35 @@ function HoursWorkList({ data, darkMode }) { ); } +// export HoursWorkList separately for testing +export { HoursWorkList }; + +// shared helper: derives normalizedHoursData, userData, and totals from raw API data +function buildChartData(hoursData, totalHoursData) { + const normalizedHoursData = mergeHoursBuckets(hoursData); + const totalVolunteers = normalizedHoursData.reduce((total, cur) => total + (cur.count || 0), 0); + const totalHoursWorked = Number(totalHoursData?.current ?? totalHoursData?.count ?? 0); + const hoursByBucket = allocateRoundedHoursByCount(normalizedHoursData, totalHoursWorked); + const totalAllocatedHours = hoursByBucket.reduce( + (sum, bucket) => sum + (bucket.allocatedHours || 0), + 0, + ); + const userData = hoursByBucket.map(range => { + const value = range.allocatedHours || 0; + return { + name: range._id, + value, + percentage: totalAllocatedHours ? Math.round((value / totalAllocatedHours) * 100) : 0, + }; + }); + return { normalizedHoursData, userData, totalVolunteers, totalHoursWorked }; +} + export default function VolunteerHoursDistribution({ isLoading, darkMode, hoursData, totalHoursData, - comparisonType, }) { const [windowSize, setWindowSize] = useState({ width: window.innerWidth, @@ -91,17 +210,10 @@ export default function VolunteerHoursDistribution({ ); } - const totalHours = hoursData.reduce((total, cur) => total + cur.count, 0); - - const userData = hoursData.map(range => { - return { - name: range._id, - value: range.count, - totalHours, - title: 'HOURS WORKED', - comparisonPercentage: totalHoursData.comparison, - }; - }); + const { normalizedHoursData, userData, totalHoursWorked } = buildChartData( + hoursData, + totalHoursData, + ); return (
- +
); } + +// computeDistribution: pure helper to derive the chart payload from API data +export function computeDistribution(hoursData, totalHoursData) { + const { userData, totalVolunteers, totalHoursWorked } = buildChartData(hoursData, totalHoursData); + return { userData, totalVolunteers, totalHoursWorked }; +} + +export { mergeHoursBuckets }; diff --git a/src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/HoursWorkList.test.jsx b/src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/HoursWorkList.test.jsx new file mode 100644 index 0000000000..6cd9a37060 --- /dev/null +++ b/src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/HoursWorkList.test.jsx @@ -0,0 +1,32 @@ +import { render, screen } from '@testing-library/react'; +import { HoursWorkList } from '../VolunteerHoursDistribution'; + +let container = null; +beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); +}); + +afterEach(() => { + container.remove(); + container = null; +}); + +describe('HoursWorkList label formatting', () => { + it('renders plain bucket ids in the legend and merges 40+ into 50+', () => { + const mockData = [ + { _id: '10', count: 5 }, + { _id: '40', count: 2 }, + { _id: '50+', count: 3 }, + { _id: '40+', count: 1 }, + ]; + + render(, { container }); + + // legend now shows plain bucket IDs (no range suffix, no count) + expect(screen.getByText('10')).toBeInTheDocument(); + expect(screen.getByText('40')).toBeInTheDocument(); + // 50+ and 40+ are merged into a single 50+ bucket + expect(screen.getByText('50+')).toBeInTheDocument(); + }); +}); diff --git a/src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/VolunteerHoursDistribution.test.jsx b/src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/VolunteerHoursDistribution.test.jsx new file mode 100644 index 0000000000..970fbc36cc --- /dev/null +++ b/src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/VolunteerHoursDistribution.test.jsx @@ -0,0 +1,53 @@ +// Note: render real chart in a sized container so Recharts can mount in tests. + +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import VolunteerHoursDistribution, { computeDistribution } from '../VolunteerHoursDistribution'; + +let container = null; +beforeEach(() => { + container = document.createElement('div'); + // give the container explicit size so ResponsiveContainer can compute dimensions + container.style.width = '800px'; + container.style.height = '600px'; + document.body.appendChild(container); +}); +afterEach(() => { + container.remove(); + container = null; +}); + +describe('VolunteerHoursDistribution wrapper', () => { + it('passes totalHoursData.current to child and computes userData percentages', () => { + const hoursData = [ + { _id: '10', count: 2 }, + { _id: '20', count: 3 }, + ]; + const totalHoursData = { current: 1234 }; + render( + , + { container }, + ); + + // legend now shows plain bucket IDs only + expect(screen.getByText('10')).toBeInTheDocument(); + expect(screen.getByText('20')).toBeInTheDocument(); + + // verify computeDistribution now allocates hours to buckets so slices add up to total hours + const computed = computeDistribution(hoursData, totalHoursData); + expect(computed).toEqual({ + userData: [ + { name: '10', value: 494, percentage: 40 }, + { name: '20', value: 740, percentage: 60 }, + ], + totalVolunteers: 5, + totalHoursWorked: 1234, + }); + }); +}); diff --git a/src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/formatRangeLabel.test.jsx b/src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/formatRangeLabel.test.jsx new file mode 100644 index 0000000000..b95e7b5154 --- /dev/null +++ b/src/components/TotalOrgSummary/VolunteerHoursDistribution/__tests__/formatRangeLabel.test.jsx @@ -0,0 +1,19 @@ +import { formatRangeLabel } from '../VolunteerHoursDistribution'; + +describe('formatRangeLabel helper', () => { + it('formats simple numeric ranges correctly', () => { + expect(formatRangeLabel('10')).toBe('10-19.99 hrs'); + expect(formatRangeLabel('0')).toBe('0-9.99 hrs'); + }); + + it('formats open-ended ranges correctly', () => { + expect(formatRangeLabel('50+')).toBe('50+ hrs'); + expect(formatRangeLabel('40+')).toBe('50+ hrs'); // special-case remap + }); + + it('handles empty or undefined input gracefully', () => { + expect(formatRangeLabel('')).toBe(''); + expect(formatRangeLabel(null)).toBe(''); + expect(formatRangeLabel(undefined)).toBe(''); + }); +}); diff --git a/src/components/__tests__/CustomTooltip.test.jsx b/src/components/__tests__/CustomTooltip.test.jsx new file mode 100644 index 0000000000..53ccfa2347 --- /dev/null +++ b/src/components/__tests__/CustomTooltip.test.jsx @@ -0,0 +1,31 @@ +import { render, screen } from '@testing-library/react'; +import CustomTooltip from '../CustomTooltip'; + +let container = null; +beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); +}); +afterEach(() => { + container.remove(); + container = null; +}); + +describe('CustomTooltip', () => { + const payload = [{ payload: { name: 'Bucket', value: 7, percentage: 70 } }]; + + it('renders hours and percentage when hoursDistribution type', () => { + render(, container); + + expect(screen.getByText(/Bucket/)).toBeInTheDocument(); + expect(screen.getByText(/Hours: 7/)).toBeInTheDocument(); + expect(screen.getByText(/Percentage: 70%/)).toBeInTheDocument(); + }); + + it('falls back to total hours when no tooltipType provided', () => { + const payload2 = [{ payload: { name: 'Foo', totalHours: 12, percentage: 50 } }]; + render(, container); + expect(screen.getByText(/Total Hours: 12/)).toBeInTheDocument(); + expect(screen.getByText(/Percentage: 50%/)).toBeInTheDocument(); + }); +});