11import { useEffect , useState } from 'react' ;
22import HoursWorkedPieChart from '../HoursWorkedPieChart/HoursWorkedPieChart' ;
33
4- // components
4+ // Components
55import Loading from '../../common/Loading' ;
66
77const COLORS = [ '#00AFF4' , '#FFA500' , '#00B030' , '#EC52CB' , '#F8FF00' ] ;
88
9+ // --- Helper Functions ---
10+
911function parseRangeStart ( rangeStr ) {
1012 if ( ! rangeStr ) return 0 ;
1113 const [ first ] = String ( rangeStr ) . split ( / [ - + ] / ) ;
@@ -16,27 +18,26 @@ function parseRangeStart(rangeStr) {
1618function normalizeBucketId ( rangeStr ) {
1719 if ( ! rangeStr ) return '' ;
1820 const trimmed = String ( rangeStr ) . trim ( ) ;
21+
1922 if ( trimmed . includes ( '+' ) ) {
2023 const start = parseRangeStart ( trimmed ) ;
21- return start === 40 ? '50+' : `${ start } +` ;
22- }
23- if ( trimmed . includes ( '-' ) ) {
24- const start = parseRangeStart ( trimmed ) ;
25- if ( start === 40 ) return '40' ;
26- return String ( start ) ;
24+ return `${ start } +` ;
2725 }
26+
2827 return String ( parseRangeStart ( trimmed ) ) ;
2928}
3029
3130function mergeHoursBuckets ( hoursData ) {
3231 const safeHoursData = Array . isArray ( hoursData ) ? hoursData : [ ] ;
3332 const merged = new Map ( ) ;
33+
3434 safeHoursData . forEach ( item => {
3535 const normalizedId = normalizeBucketId ( item ?. _id ) ;
3636 if ( ! normalizedId ) return ;
3737 const existing = merged . get ( normalizedId ) || 0 ;
3838 merged . set ( normalizedId , existing + ( Number ( item ?. count ) || 0 ) ) ;
3939 } ) ;
40+
4041 return [ ...merged . entries ( ) ]
4142 . map ( ( [ id , count ] ) => ( { _id : id , count } ) )
4243 . sort ( ( a , b ) => parseRangeStart ( a . _id ) - parseRangeStart ( b . _id ) ) ;
@@ -76,54 +77,58 @@ function allocateRoundedHoursByCount(normalizedHoursData, totalHoursWorked) {
7677 . sort ( ( a , b ) => parseRangeStart ( a . _id ) - parseRangeStart ( b . _id ) ) ;
7778}
7879
79- // convert backend range string (e.g. "10", "40+", "20-29")
80- // into a user-facing label with units.
8180export function formatRangeLabel ( rangeStr ) {
8281 if ( ! rangeStr ) return '' ;
8382 const normalizedRange = normalizeBucketId ( rangeStr ) ;
84- let displayName = '' ;
83+
8584 if ( normalizedRange . includes ( '+' ) ) {
8685 const num = parseFloat ( normalizedRange . replace ( '+' , '' ) ) ;
87- if ( num === 40 ) {
88- displayName = '50+ hrs' ;
89- } else {
90- displayName = `${ num } + hrs` ;
91- }
86+ return `${ num } + hrs` ;
9287 } else {
9388 const num = parseFloat ( normalizedRange ) ;
94- const next = ( num + 9.99 ) . toFixed ( 2 ) ;
95- displayName = `${ num } -${ next } hrs` ;
89+ return `${ num } -${ num + 9 } hrs` ;
9690 }
97- return displayName ;
9891}
9992
93+ function buildChartData ( hoursData , totalHoursData ) {
94+ const normalizedHoursData = mergeHoursBuckets ( hoursData ) ;
95+ const totalVolunteers = normalizedHoursData . reduce ( ( total , cur ) => total + ( cur . count || 0 ) , 0 ) ;
96+ const totalHoursWorked = Number ( totalHoursData ?. current ?? totalHoursData ?. count ?? 0 ) ;
97+
98+ const hoursByBucket = allocateRoundedHoursByCount ( normalizedHoursData , totalHoursWorked ) ;
99+ const totalAllocatedHours = hoursByBucket . reduce (
100+ ( sum , bucket ) => sum + ( bucket . allocatedHours || 0 ) ,
101+ 0 ,
102+ ) ;
103+
104+ const userData = hoursByBucket . map ( range => {
105+ // FALLBACK: If total hours is 0, visualizes chart layout by volunteer count
106+ // so that the chart displays nicely instead of loading with empty 0 slices.
107+ const value = totalHoursWorked > 0 ? range . allocatedHours || 0 : range . count || 0 ;
108+ const denominator = totalHoursWorked > 0 ? totalAllocatedHours : totalVolunteers ;
109+
110+ return {
111+ name : formatRangeLabel ( range . _id ) ,
112+ value,
113+ percentage : denominator ? Math . round ( ( value / denominator ) * 100 ) : 0 ,
114+ } ;
115+ } ) ;
116+
117+ return { normalizedHoursData, userData, totalVolunteers, totalHoursWorked } ;
118+ }
119+
120+ // --- Sub-Components ---
121+
100122function HoursWorkList ( { data, darkMode } ) {
101123 if ( ! data ) return < div /> ;
102124
103125 const ranges = data . map ( ( elem , index ) => {
104- const rangeStr = elem . _id ;
105- const entry = {
106- name : rangeStr ,
126+ return {
127+ name : elem . _id ,
107128 count : elem . count ,
129+ displayName : formatRangeLabel ( elem . _id ) ,
130+ color : COLORS [ index % COLORS . length ] ,
108131 } ;
109-
110- // derive human-readable label for the bucket
111- const displayName = formatRangeLabel ( rangeStr ) ;
112-
113- entry . displayName = displayName ;
114- entry . color = COLORS [ index ] ;
115-
116- const rangeArr = rangeStr . split ( '-' ) ;
117- if ( rangeArr . length > 1 ) {
118- const [ min , max ] = rangeArr ;
119- entry . min = Number ( min ) ;
120- entry . max = Number ( max ) ;
121- } else {
122- const min = rangeStr . split ( '+' ) ;
123- entry . min = Number ( min ) ;
124- entry . max = Infinity ;
125- }
126- return entry ;
127132 } ) ;
128133
129134 return (
@@ -132,17 +137,16 @@ function HoursWorkList({ data, darkMode }) {
132137 < div >
133138 < ul className = "list-unstyled" >
134139 { ranges . map ( item => (
135- < li key = { item . name } className = "text-secondary d-flex align-items-center" >
140+ < li key = { item . name } className = "text-secondary d-flex align-items-center mb-1 " >
136141 < div
137142 className = "me-2"
138143 style = { {
139144 width : '15px' ,
140145 height : '15px' ,
141- marginRight : '5px' ,
142146 backgroundColor : item . color ,
143147 } }
144148 />
145- < span className = "ms-2" > { item . name } </ span >
149+ < span className = "ms-2" > { item . displayName } </ span >
146150 </ li >
147151 ) ) }
148152 </ ul >
@@ -151,29 +155,7 @@ function HoursWorkList({ data, darkMode }) {
151155 ) ;
152156}
153157
154- // export HoursWorkList separately for testing
155- export { HoursWorkList } ;
156-
157- // shared helper: derives normalizedHoursData, userData, and totals from raw API data
158- function buildChartData ( hoursData , totalHoursData ) {
159- const normalizedHoursData = mergeHoursBuckets ( hoursData ) ;
160- const totalVolunteers = normalizedHoursData . reduce ( ( total , cur ) => total + ( cur . count || 0 ) , 0 ) ;
161- const totalHoursWorked = Number ( totalHoursData ?. current ?? totalHoursData ?. count ?? 0 ) ;
162- const hoursByBucket = allocateRoundedHoursByCount ( normalizedHoursData , totalHoursWorked ) ;
163- const totalAllocatedHours = hoursByBucket . reduce (
164- ( sum , bucket ) => sum + ( bucket . allocatedHours || 0 ) ,
165- 0 ,
166- ) ;
167- const userData = hoursByBucket . map ( range => {
168- const value = range . allocatedHours || 0 ;
169- return {
170- name : range . _id ,
171- value,
172- percentage : totalAllocatedHours ? Math . round ( ( value / totalAllocatedHours ) * 100 ) : 0 ,
173- } ;
174- } ) ;
175- return { normalizedHoursData, userData, totalVolunteers, totalHoursWorked } ;
176- }
158+ // --- Main Exported Component ---
177159
178160export default function VolunteerHoursDistribution ( {
179161 isLoading,
@@ -182,30 +164,31 @@ export default function VolunteerHoursDistribution({
182164 totalHoursData,
183165} ) {
184166 const [ windowSize , setWindowSize ] = useState ( {
185- width : window . innerWidth ,
186- height : window . innerHeight ,
167+ width : typeof window !== 'undefined' ? window . innerWidth : 1200 ,
168+ height : typeof window !== 'undefined' ? window . innerHeight : 800 ,
187169 } ) ;
188170
189- const updateWindowSize = ( ) => {
190- setWindowSize ( {
191- width : window . innerWidth ,
192- height : window . innerHeight ,
193- } ) ;
194- } ;
195-
196171 useEffect ( ( ) => {
197- window . addEventListener ( 'resize' , updateWindowSize ) ;
198- return ( ) => {
199- window . removeEventListener ( 'resize' , updateWindowSize ) ;
172+ if ( typeof window === 'undefined' ) return ;
173+
174+ const updateWindowSize = ( ) => {
175+ setWindowSize ( {
176+ width : window . innerWidth ,
177+ height : window . innerHeight ,
178+ } ) ;
200179 } ;
180+
181+ window . addEventListener ( 'resize' , updateWindowSize ) ;
182+ return ( ) => window . removeEventListener ( 'resize' , updateWindowSize ) ;
201183 } , [ ] ) ;
202184
203185 if ( isLoading ) {
204186 return (
205- < div className = "d-flex justify-content-center align-items-center" >
206- < div className = "w-100vh" >
207- < Loading />
208- </ div >
187+ < div
188+ className = "d-flex justify-content-center align-items-center"
189+ style = { { minHeight : '200px' } }
190+ >
191+ < Loading />
209192 </ div >
210193 ) ;
211194 }
@@ -232,10 +215,10 @@ export default function VolunteerHoursDistribution({
232215 ) ;
233216}
234217
235- // computeDistribution: pure helper to derive the chart payload from API data
218+ // Extra named exports for automated testing
219+ export { HoursWorkList , mergeHoursBuckets } ;
220+
236221export function computeDistribution ( hoursData , totalHoursData ) {
237222 const { userData, totalVolunteers, totalHoursWorked } = buildChartData ( hoursData , totalHoursData ) ;
238223 return { userData, totalVolunteers, totalHoursWorked } ;
239224}
240-
241- export { mergeHoursBuckets } ;
0 commit comments