Skip to content

Commit 09f759e

Browse files
Merge pull request #3481 from OneCommunityGlobal/strallia/volunteer-trends-by-time
Strallia/volunteer trends by time
2 parents fa389ce + 669f25f commit 09f759e

4 files changed

Lines changed: 346 additions & 1 deletion

File tree

src/components/TotalOrgSummary/TotalOrgSummary.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import NumbersVolunteerWorked from './NumbersVolunteerWorked/NumbersVolunteerWor
2828
import AnniversaryCelebrated from './AnniversaryCelebrated/AnniversaryCelebrated';
2929
import RoleDistributionPieChart from './VolunteerRolesTeamDynamics/RoleDistributionPieChart';
3030
import WorkDistributionBarChart from './VolunteerRolesTeamDynamics/WorkDistributionBarChart';
31+
import VolunteerTrendsLineChart from './VolunteerTrendsLineChart/VolunteerTrendsLineChart';
3132
import GlobalVolunteerMap from './GlobalVolunteerMap/GlobalVolunteerMap';
3233
import TaskCompletedBarChart from './TaskCompleted/TaskCompletedBarChart';
3334

@@ -335,7 +336,7 @@ function TotalOrgSummary(props) {
335336
<div className={`chart-title ${darkMode ? 'dark-mode' : ''}`}>
336337
<p>Volunteer Trends by Time</p>
337338
</div>
338-
<span className="text-center"> Work in progres...</span>
339+
<VolunteerTrendsLineChart darkMode={darkMode} />
339340
</div>
340341
</Col>
341342
<Col lg={{ size: 5 }}>
Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
import { LineChart, Line, CartesianGrid, XAxis, YAxis, Tooltip } from 'recharts';
2+
import './styles.css';
3+
import { useEffect, useState } from 'react';
4+
import { ENDPOINTS } from 'utils/URL';
5+
import axios from 'axios';
6+
import Loading from 'components/common/Loading';
7+
import DatePicker from 'react-datepicker';
8+
import 'react-datepicker/dist/react-datepicker.css';
9+
10+
const formatChartData = rawData => {
11+
if (rawData[0]._id.month) {
12+
// for monthly intervals
13+
const integerToMonths = {
14+
1: 'Jan',
15+
2: 'Feb',
16+
3: 'Mar',
17+
4: 'Apr',
18+
5: 'May',
19+
6: 'Jun',
20+
7: 'Jul',
21+
8: 'Aug',
22+
9: 'Sep',
23+
10: 'Oct',
24+
11: 'Nov',
25+
12: 'Dec',
26+
};
27+
28+
return rawData.map(data => {
29+
return {
30+
xLabel: integerToMonths[data._id.month],
31+
totalHours: data.totalHours,
32+
year: data._id.year,
33+
interval: 'month',
34+
};
35+
});
36+
}
37+
38+
// for weekly intervals
39+
return rawData.map(data => {
40+
return {
41+
xLabel: data._id.week,
42+
totalHours: data.totalHours,
43+
year: data._id.year,
44+
interval: 'week',
45+
};
46+
});
47+
};
48+
49+
const dateToYYYYMMDD = date => {
50+
return date.toISOString().split('T')[0];
51+
};
52+
53+
export default function VolunteerTrendsLineChart({ darkMode }) {
54+
const [isLoading, setIsLoading] = useState(true);
55+
const [data, setData] = useState(null);
56+
const [fetchError, setFetchError] = useState(false);
57+
const latestNumberOfHours = data?.[data.length - 1].totalHours || 0;
58+
const [chartSize, setChartSize] = useState({ width: null, height: null });
59+
const [requestTimeFrame, setRequestTimeFrame] = useState(1);
60+
const [requestOffset, setRequestOffset] = useState('week');
61+
const [showDatePicker, setShowDatePicker] = useState(false);
62+
const [customDateRange, setCustomDateRange] = useState([null, null]);
63+
const [customStartDate = new Date(), customEndDate = new Date()] = customDateRange;
64+
65+
useEffect(() => {
66+
// Gets backend data
67+
const getData = async () => {
68+
// TODO: NEED TO ABSTRACT THIS TO ITS OWN REDUX REDUCER
69+
let url;
70+
if (customDateRange.every(date => date)) {
71+
// URL for custom dates
72+
const formattedDateRange = customDateRange.map(date => dateToYYYYMMDD(date));
73+
url = ENDPOINTS.VOLUNTEER_TRENDS(
74+
requestTimeFrame,
75+
requestOffset,
76+
formattedDateRange[0],
77+
formattedDateRange[1],
78+
);
79+
} else {
80+
// URL for pre-set timeframes
81+
url = ENDPOINTS.VOLUNTEER_TRENDS(requestTimeFrame, requestOffset);
82+
}
83+
84+
try {
85+
const response = await axios.get(url);
86+
const rawData = formatChartData(response.data);
87+
setData(rawData);
88+
} catch (err) {
89+
setFetchError(err);
90+
} finally {
91+
setIsLoading(false);
92+
}
93+
};
94+
getData();
95+
}, [requestTimeFrame, requestOffset, customDateRange]);
96+
97+
useEffect(() => {
98+
// Add event listener to set chart width on window resize
99+
const updateChartSize = () => {
100+
// Default sizes
101+
let width = 600;
102+
let height = 350;
103+
if (window.innerWidth < 650) {
104+
// Mobile
105+
width = 400;
106+
height = 250;
107+
} else if (window.innerWidth < 1200) {
108+
// Tablet
109+
width = 500;
110+
}
111+
setChartSize({ width, height });
112+
};
113+
updateChartSize();
114+
window.addEventListener('resize', updateChartSize);
115+
return () => {
116+
window.removeEventListener('resize', updateChartSize);
117+
};
118+
}, []);
119+
120+
const formatNumber = number => {
121+
// Add comma every third digit (e.g. makes 1000 a 1,000)
122+
return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
123+
};
124+
125+
const renderCustomDot = ({ cx, cy, index }) => {
126+
// Highlight and show value of last dot on the line
127+
const isLastPoint = index === data.length - 1;
128+
const formattedNumber = formatNumber(latestNumberOfHours);
129+
if (isLastPoint) {
130+
return (
131+
<g key={index}>
132+
<circle cx={cx} cy={cy} r={24} opacity="0.1" fill={darkMode ? 'white' : 'black'} />
133+
<circle cx={cx} cy={cy} r={6} fill={darkMode ? 'white' : 'black'} />
134+
<text
135+
x={cx}
136+
y={cy - 30}
137+
fill={darkMode ? 'white' : 'black'}
138+
textAnchor="middle"
139+
fontWeight={600}
140+
fontSize={16}
141+
>
142+
{formattedNumber}
143+
</text>
144+
</g>
145+
);
146+
}
147+
return null;
148+
};
149+
150+
const renderCustomTooltip = ({ active, payload, label }) => {
151+
if (active && payload && payload.length) {
152+
const { year, interval } = payload[0].payload;
153+
return (
154+
<div
155+
style={{
156+
backgroundColor: 'white',
157+
border: '1px solid #ccc',
158+
minWidth: '180px',
159+
padding: '10px',
160+
}}
161+
>
162+
<h6 style={{ color: 'black' }}>
163+
{interval === 'week' ? 'Week ' : ''}
164+
{label}, {year}
165+
</h6>
166+
<h6 style={{ color: '#328D1B' }}>{payload[0].value} hours</h6>
167+
</div>
168+
);
169+
}
170+
return null;
171+
};
172+
173+
const setTimeframeFilter = e => {
174+
if (e.target.value === 'yearsCustom') {
175+
return setShowDatePicker(true);
176+
}
177+
178+
setCustomDateRange([null, null]);
179+
setShowDatePicker(false);
180+
const numberOfYears = e.target.value.substring(5);
181+
setIsLoading(true);
182+
setRequestTimeFrame(numberOfYears);
183+
return undefined;
184+
};
185+
186+
const setOffsetFilter = e => {
187+
const offset = e.target.value;
188+
setRequestOffset(offset);
189+
};
190+
191+
const handleCustomDateRange = updatedDateRange => {
192+
setCustomDateRange(updatedDateRange);
193+
if (updatedDateRange[1]) {
194+
setShowDatePicker(false);
195+
setIsLoading(true);
196+
}
197+
};
198+
199+
if (fetchError) {
200+
return <div>Error fetching data!</div>;
201+
}
202+
203+
return (
204+
<div className="chart-container">
205+
{/* DATE FILTERS */}
206+
<div className="date-filter-container">
207+
<select name="timeframe-filter" id="timeframe-filter" onChange={setTimeframeFilter}>
208+
<option value="years1">This year</option>
209+
<option value="years2">Last 2 years</option>
210+
<option value="years3">Last 3 years</option>
211+
<option value="years5">Last 5 years</option>
212+
<option value="years10">Last 10 years</option>
213+
<option value="years0">All-time</option>
214+
<option value="yearsCustom">Choose Date Range</option>
215+
</select>
216+
by
217+
<select name="offset-filter" id="offset-filter" onChange={setOffsetFilter}>
218+
<option value="week">week</option>
219+
<option value="month">month</option>
220+
</select>
221+
</div>
222+
223+
{/* DATE PICKER */}
224+
<div className="date-picker-container">
225+
{showDatePicker && (
226+
<DatePicker
227+
selected={customStartDate}
228+
onChange={handleCustomDateRange}
229+
startDate={customStartDate}
230+
endDate={customEndDate}
231+
selectsRange
232+
inline
233+
dateFormat="MM-dd-yyyy"
234+
className="date-picker"
235+
/>
236+
)}
237+
</div>
238+
239+
{/* CUSTOM DATE RANGE */}
240+
{customDateRange.every(date => date) && (
241+
<div className="custom-date-range">
242+
<span>{dateToYYYYMMDD(customDateRange[0])}</span> to{' '}
243+
<span>{dateToYYYYMMDD(customDateRange[1])}</span>
244+
</div>
245+
)}
246+
247+
{/* LINE CHART */}
248+
{isLoading ? (
249+
<div className="d-flex justify-content-center align-items-center">
250+
<div className="w-100vh">
251+
<Loading />
252+
</div>
253+
</div>
254+
) : (
255+
<LineChart
256+
width={chartSize.width}
257+
height={chartSize.height}
258+
data={data}
259+
margin={{ right: 50, top: 50, left: 20 }}
260+
>
261+
<CartesianGrid stroke="#ccc" vertical={false} />
262+
<XAxis
263+
dataKey="xLabel"
264+
axisLine={false}
265+
tickLine={false}
266+
tick={{ fill: darkMode ? '#ccc' : undefined }}
267+
/>
268+
<YAxis
269+
tickFormatter={formatNumber}
270+
axisLine={false}
271+
tickLine={false}
272+
tick={{ fill: darkMode ? '#ccc' : undefined }}
273+
label={{
274+
value: 'Total Hours',
275+
angle: -90,
276+
position: 'insideLeft',
277+
dy: 20,
278+
dx: -15,
279+
style: { fontSize: 18, fill: darkMode ? '#ccc' : undefined },
280+
}}
281+
/>
282+
<Line
283+
type="linear"
284+
dataKey="totalHours"
285+
stroke="#328D1B"
286+
strokeWidth={4}
287+
dot={renderCustomDot}
288+
strokeLinecap="round"
289+
/>
290+
<Tooltip content={renderCustomTooltip} />
291+
</LineChart>
292+
)}
293+
</div>
294+
);
295+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
.date-picker-container {
2+
position: relative;
3+
}
4+
5+
.react-datepicker {
6+
position: absolute !important;
7+
z-index: 1;
8+
transform: translateX(-50%);
9+
box-shadow: 2px 4px 8px rgba(0, 0, 0, 0.2) ;
10+
}
11+
12+
.chart-container {
13+
display: grid;
14+
justify-content: center;
15+
text-align: center;
16+
padding: 10px;
17+
position: relative;
18+
margin-top: 15px;
19+
}
20+
21+
.custom-date-range {
22+
margin-top: 8px;
23+
}
24+
25+
.custom-date-range > span {
26+
font-weight: bold;
27+
}
28+
29+
.date-filter-container {
30+
display: flex;
31+
gap: 10px;
32+
align-items: center;
33+
justify-content: center;
34+
}
35+
36+
.date-filter-container > select {
37+
margin-top: 0;
38+
width: min-content
39+
}
40+
41+
@media (max-width: 500px) {
42+
.chart-container {
43+
justify-content: start;
44+
overflow: scroll;
45+
}
46+
}

src/utils/URL.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ export const ENDPOINTS = {
9494
`${APIEndpoint}/userProfile/authorizeUser/weeeklySummaries`,
9595
TOTAL_ORG_SUMMARY: (startDate, endDate, comparisonStartDate, comparisonEndDate) =>
9696
`${APIEndpoint}/reports/volunteerstats?startDate=${startDate}&endDate=${endDate}&comparisonStartDate=${comparisonStartDate}&comparisonEndDate=${comparisonEndDate}`,
97+
VOLUNTEER_TRENDS: (timeFrame, offset, customStartDate, customEndDate ) =>
98+
`${APIEndpoint}/reports/volunteertrends?timeFrame=${timeFrame}&offset=${offset}${customStartDate ? `&customStartDate=${customStartDate}`: ''}${customEndDate ? `&customEndDate=${customEndDate}`: ''}`
99+
,
97100
HOURS_TOTAL_ORG_SUMMARY: (startDate, endDate) =>
98101
`${APIEndpoint}/reports/overviewsummaries/taskandprojectstats?startDate=${startDate}&endDate=${endDate}`,
99102
VOLUNTEER_ROLES_TEAM_STATS: (endDate, activeMembersMinimum) =>

0 commit comments

Comments
 (0)