diff --git a/src/components/CommunityPortal/Reports/Participation/AnalyticsNavigation.jsx b/src/components/CommunityPortal/Reports/Participation/AnalyticsNavigation.jsx index 398bfcc4344..22017394a31 100644 --- a/src/components/CommunityPortal/Reports/Participation/AnalyticsNavigation.jsx +++ b/src/components/CommunityPortal/Reports/Participation/AnalyticsNavigation.jsx @@ -1,4 +1,5 @@ import { useSelector } from 'react-redux'; +import { Link } from 'react-router-dom'; import styles from './Participation.module.css'; function AnalyticsNavigation() { @@ -48,9 +49,9 @@ function AnalyticsNavigation() {
{navigationItems.map(item => ( -
{item.icon}
@@ -60,7 +61,7 @@ function AnalyticsNavigation() {
{item.stats}
-
+ ))} diff --git a/src/components/CommunityPortal/Reports/Participation/DropOffTracking.jsx b/src/components/CommunityPortal/Reports/Participation/DropOffTracking.jsx index 2ceb747caaa..9c5b84dc266 100644 --- a/src/components/CommunityPortal/Reports/Participation/DropOffTracking.jsx +++ b/src/components/CommunityPortal/Reports/Participation/DropOffTracking.jsx @@ -94,8 +94,7 @@ function DropOffTracking() {

- +5%{' '} - since Last week + +5% since Last week

@@ -109,10 +108,7 @@ function DropOffTracking() {

- - -5% - {' '} - since Last week + -5% since Last week

diff --git a/src/components/CommunityPortal/Reports/Participation/EngagementBarChart.jsx b/src/components/CommunityPortal/Reports/Participation/EngagementBarChart.jsx index c7ff255e072..8570527fc26 100644 --- a/src/components/CommunityPortal/Reports/Participation/EngagementBarChart.jsx +++ b/src/components/CommunityPortal/Reports/Participation/EngagementBarChart.jsx @@ -1,25 +1,97 @@ -import { useState } from 'react'; import { useSelector } from 'react-redux'; +import { useMemo } from 'react'; +import { + BarChart, + Bar, + CartesianGrid, + XAxis, + YAxis, + Tooltip, + Legend, + ResponsiveContainer, +} from 'recharts'; import styles from './Participation.module.css'; -function EngagementBarChart() { +const MONTH_COUNT = 6; + +function buildLastMonths(count) { + const months = []; + const now = new Date(); + + for (let i = count - 1; i >= 0; i -= 1) { + const monthDate = new Date(now.getFullYear(), now.getMonth() - i, 1); + months.push({ + key: `${monthDate.getFullYear()}-${monthDate.getMonth()}`, + label: monthDate.toLocaleString('en-US', { month: 'short' }), + year: monthDate.getFullYear(), + monthIndex: monthDate.getMonth(), + }); + } + + return months; +} + +function EngagementBarChart({ events = [] }) { const darkMode = useSelector(state => state.theme.darkMode); - const [tooltip, setTooltip] = useState(null); - - const engagementData = [ - { month: 'Jan', attendance: 35, engagement: 78, events: 6 }, - { month: 'Feb', attendance: 42, engagement: 82, events: 8 }, - { month: 'Mar', attendance: 38, engagement: 75, events: 7 }, - { month: 'Apr', attendance: 45, engagement: 85, events: 9 }, - { month: 'May', attendance: 40, engagement: 80, events: 8 }, - { month: 'Jun', attendance: 48, engagement: 88, events: 10 }, - ]; - - const maxValue = Math.max( - ...engagementData.map(item => item.attendance), - ...engagementData.map(item => item.engagement), + + const engagementData = useMemo(() => { + const months = buildLastMonths(MONTH_COUNT); + + return months.map(({ key, label, year, monthIndex }) => { + const monthEvents = events.filter(event => { + const eventDate = new Date(event.eventDate); + return eventDate.getFullYear() === year && eventDate.getMonth() === monthIndex; + }); + + const attendance = monthEvents.length + ? Math.round( + monthEvents.reduce((sum, event) => sum + (Number(event.attendees) || 0), 0) / + monthEvents.length, + ) + : 0; + + const eventsWithCapacity = monthEvents.filter(event => Number(event.maxAttendees) > 0); + const fillRate = eventsWithCapacity.length + ? Math.round( + eventsWithCapacity.reduce( + (sum, event) => sum + (Number(event.attendees) / Number(event.maxAttendees)) * 100, + 0, + ) / eventsWithCapacity.length, + ) + : 0; + + return { key, month: label, attendance, fillRate, events: monthEvents.length }; + }); + }, [events]); + + const peakMonth = engagementData.reduce( + (peak, item) => (item.attendance > peak.attendance ? item : peak), + engagementData[0] || { month: 'N/A', attendance: 0 }, ); + const firstAttendance = engagementData[0]?.attendance || 0; + const lastAttendance = engagementData[engagementData.length - 1]?.attendance || 0; + const growthTrend = firstAttendance + ? Math.round(((lastAttendance - firstAttendance) / firstAttendance) * 100) + : null; + + let growthTrendLabel = 'N/A'; + if (growthTrend !== null) { + const growthSign = growthTrend >= 0 ? '+' : ''; + const firstMonthLabel = engagementData[0]?.month; + const lastMonthLabel = engagementData[engagementData.length - 1]?.month; + growthTrendLabel = `${growthSign}${growthTrend}% from ${firstMonthLabel} to ${lastMonthLabel}`; + } + + const tooltipStyle = { + backgroundColor: darkMode ? '#1C2541' : '#ffffff', + border: `1px solid ${darkMode ? '#3a4a6b' : '#e0e0e0'}`, + borderRadius: '6px', + color: darkMode ? '#e5e7eb' : '#1a1a1a', + }; + const axisColor = darkMode ? '#b8c5d1' : '#4b5563'; + const gridColor = darkMode ? '#3a4a6b' : '#e0e0e0'; + return (

@@ -27,116 +99,49 @@ function EngagementBarChart() {

-
-
-
- {[0, 20, 40, 60, 80].map(value => ( -
- {value} -
- ))} -
-
Value
-
- -
- {tooltip && ( -
-
- {tooltip.month} -
-
Attendance: {tooltip.attendance}
-
Engagement: {tooltip.engagement}%
-
Events: {tooltip.events}
-
- )} - {engagementData.map(item => ( -
{ - const rect = e.currentTarget.parentElement.getBoundingClientRect(); - const itemRect = e.currentTarget.getBoundingClientRect(); - setTooltip({ - month: item.month, - attendance: item.attendance, - engagement: item.engagement, - events: item.events, - x: itemRect.left - rect.left, - y: -80, - }); - }} - onMouseLeave={() => setTooltip(null)} - > -
-
-
-
-
{item.month}
-
-
{item.attendance}
-
{item.engagement}%
-
-
- ))} -
-
- -
-
-
+ + + + + [ + name === 'fillRate' ? `${value}%` : value, + name === 'fillRate' ? 'Avg Fill Rate' : 'Avg Attendance', + ]} /> - Average Attendance -
-
-
+ value === 'fillRate' ? 'Avg Fill Rate (%)' : 'Average Attendance' + } /> - Engagement Rate (%) -
-
+ + + +
Peak Month: - June (48 avg attendance) + + {peakMonth.month} ({peakMonth.attendance} avg attendance) +
Growth Trend: - +37% from Jan to Jun + {growthTrendLabel}
diff --git a/src/components/CommunityPortal/Reports/Participation/EventParticipation.jsx b/src/components/CommunityPortal/Reports/Participation/EventParticipation.jsx index 6aee55d08d0..e096550f288 100644 --- a/src/components/CommunityPortal/Reports/Participation/EventParticipation.jsx +++ b/src/components/CommunityPortal/Reports/Participation/EventParticipation.jsx @@ -1,6 +1,7 @@ /* eslint-disable testing-library/no-node-access */ import { useSelector } from 'react-redux'; -import { useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { getEvents } from '../../../../actions/eventActions'; import EventParticipationHeader from './EventParticipationHeader'; import EngagementSummaryCards from './EngagementSummaryCards'; import EventTypePieChart from './EventTypePieChart'; @@ -11,9 +12,48 @@ import DropOffTracking from './DropOffTracking'; import NoShowInsights from './NoShowInsights'; import styles from './Participation.module.css'; +const formatEventTime = isoString => + isoString + ? new Date(isoString).toLocaleString('en-US', { + hour: 'numeric', + minute: 'numeric', + hour12: true, + month: 'short', + day: 'numeric', + year: 'numeric', + }) + : ''; + function EventParticipation() { const darkMode = useSelector(state => state.theme.darkMode); const exportRef = useRef(null); + const [events, setEvents] = useState([]); + const [eventsLoading, setEventsLoading] = useState(true); + + useEffect(() => { + let isMounted = true; + + getEvents({ limit: 1000 }).then(response => { + if (!isMounted) return; + const fetchedEvents = response?.data?.events || []; + setEvents( + fetchedEvents.map(event => ({ + id: event._id, + eventType: event.type, + eventDate: event.date, + eventTime: formatEventTime(event.startTime), + eventName: event.title, + attendees: event.currentAttendees, + maxAttendees: event.maxAttendees, + })), + ); + setEventsLoading(false); + }); + + return () => { + isMounted = false; + }; + }, []); return (
- +
- - + +
- +
diff --git a/src/components/CommunityPortal/Reports/Participation/EventParticipationHeader.jsx b/src/components/CommunityPortal/Reports/Participation/EventParticipationHeader.jsx index 618b112e667..417883c8ca9 100644 --- a/src/components/CommunityPortal/Reports/Participation/EventParticipationHeader.jsx +++ b/src/components/CommunityPortal/Reports/Participation/EventParticipationHeader.jsx @@ -1,15 +1,44 @@ import { useSelector } from 'react-redux'; +import { useMemo } from 'react'; +import { Link } from 'react-router-dom'; import styles from './Participation.module.css'; -function EventParticipationHeader() { +function EventParticipationHeader({ events = [], loading = false }) { const darkMode = useSelector(state => state.theme.darkMode); - const eventMetrics = { - totalEvents: 24, - averageAttendance: 35, - highestRatedEvent: 'Yoga Class', - totalParticipants: 840, - }; + const eventMetrics = useMemo(() => { + if (!events.length) { + return { + totalEvents: 0, + averageAttendance: 0, + topEventType: loading ? '…' : 'N/A', + totalParticipants: 0, + }; + } + + const totalParticipants = events.reduce( + (sum, event) => sum + (Number(event.attendees) || 0), + 0, + ); + + const attendanceByType = events.reduce((acc, event) => { + acc[event.eventType] = (acc[event.eventType] || 0) + (Number(event.attendees) || 0); + return acc; + }, {}); + + const topEventType = Object.entries(attendanceByType).reduce( + (top, [eventType, attendance]) => + attendance > top.attendance ? { eventType, attendance } : top, + { eventType: 'N/A', attendance: -1 }, + ).eventType; + + return { + totalEvents: events.length, + averageAttendance: Math.round(totalParticipants / events.length), + topEventType, + totalParticipants, + }; + }, [events, loading]); return (
@@ -53,7 +85,7 @@ function EventParticipationHeader() {
Avg Attendance
-
{eventMetrics.highestRatedEvent}
+
{eventMetrics.topEventType}
Top Event Type
diff --git a/src/components/CommunityPortal/Reports/Participation/EventTypePieChart.jsx b/src/components/CommunityPortal/Reports/Participation/EventTypePieChart.jsx index cec0d1fdabc..2252097e52b 100644 --- a/src/components/CommunityPortal/Reports/Participation/EventTypePieChart.jsx +++ b/src/components/CommunityPortal/Reports/Participation/EventTypePieChart.jsx @@ -1,89 +1,114 @@ import { useSelector } from 'react-redux'; +import { useMemo } from 'react'; +import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts'; import styles from './Participation.module.css'; -function EventTypePieChart() { +const CHART_COLORS = ['#4CAF50', '#2196F3', '#FF9800', '#9C27B0', '#F44336', '#00BCD4']; + +function EventTypePieChart({ events = [] }) { const darkMode = useSelector(state => state.theme.darkMode); - const eventTypeData = [ - { type: 'Yoga Class', count: 8, percentage: 33, color: '#4CAF50' }, - { type: 'Fitness Bootcamp', count: 6, percentage: 25, color: '#2196F3' }, - { type: 'Cooking Workshop', count: 5, percentage: 21, color: '#FF9800' }, - { type: 'Dance Class', count: 5, percentage: 21, color: '#9C27B0' }, - ]; + const eventTypeData = useMemo(() => { + if (!events.length) return []; + + const countsByType = events.reduce((acc, event) => { + acc[event.eventType] = (acc[event.eventType] || 0) + 1; + return acc; + }, {}); + + const total = events.length; + + return Object.entries(countsByType) + .map(([type, count], index) => ({ + type, + count, + percentage: Math.round((count / total) * 100), + color: CHART_COLORS[index % CHART_COLORS.length], + })) + .sort((a, b) => b.count - a.count); + }, [events]); const totalEvents = eventTypeData.reduce((sum, item) => sum + item.count, 0); + const tooltipStyle = { + backgroundColor: darkMode ? '#1C2541' : '#ffffff', + border: `1px solid ${darkMode ? '#3a4a6b' : '#e0e0e0'}`, + borderRadius: '6px', + color: darkMode ? '#e5e7eb' : '#1a1a1a', + }; + return (

Event Type Popularity

-
-
- - - {eventTypeData.map((item, index) => { - const startAngle = eventTypeData - .slice(0, index) - .reduce((sum, prev) => sum + prev.percentage * 3.6, 0); - const endAngle = startAngle + item.percentage * 3.6; - - const startAngleRad = (startAngle - 90) * (Math.PI / 180); - const endAngleRad = (endAngle - 90) * (Math.PI / 180); - - const x1 = 100 + 80 * Math.cos(startAngleRad); - const y1 = 100 + 80 * Math.sin(startAngleRad); - const x2 = 100 + 80 * Math.cos(endAngleRad); - const y2 = 100 + 80 * Math.sin(endAngleRad); - - const largeArcFlag = item.percentage > 50 ? 1 : 0; - - const pathData = [ - `M 100 100`, - `L ${x1} ${y1}`, - `A 80 80 0 ${largeArcFlag} 1 ${x2} ${y2}`, - 'Z', - ].join(' '); - - return ( - - ); - })} - -
+ {eventTypeData.length === 0 ? ( +

No event data available.

+ ) : ( + <> +
+
+ + + `${Math.round(percent * 100)}%`} + > + {eventTypeData.map((item, index) => ( + + ))} + + [ + `${value} events (${tooltipProps.payload.percentage}%)`, + name, + ]} + /> + + +
-
- {eventTypeData.map(item => ( -
-
-
- {item.type} - - {item.count} events ({item.percentage}%) - -
+
+ {eventTypeData.map(item => ( +
+
+
+ {item.type} + + {item.count} events ({item.percentage}%) + +
+
+ ))}
- ))} -
-
+
-
-
- Total Events: - {totalEvents} -
-
- Most Popular: - {eventTypeData[0].type} -
-
+
+
+ Total Events: + {totalEvents} +
+
+ Most Popular: + {eventTypeData[0].type} +
+
+ + )}
); } diff --git a/src/components/CommunityPortal/Reports/Participation/MyCases.jsx b/src/components/CommunityPortal/Reports/Participation/MyCases.jsx index 6f29bc3b28f..d7d2c720eed 100644 --- a/src/components/CommunityPortal/Reports/Participation/MyCases.jsx +++ b/src/components/CommunityPortal/Reports/Participation/MyCases.jsx @@ -1,13 +1,12 @@ import { useState } from 'react'; import { useSelector } from 'react-redux'; import styles from './MyCases.module.css'; -import mockEvents from './mockData'; import CreateEventModal from './CreateEventModal'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faUsers } from '@fortawesome/free-solid-svg-icons'; import { filterEventsByDate } from './FilterByDate'; -function MyCases() { +function MyCases({ events = [] }) { const [view, setView] = useState('card'); const [filter, setFilter] = useState('All Time'); const [expanded, setExpanded] = useState(false); @@ -20,7 +19,7 @@ function MyCases() { const darkMode = useSelector(state => state.theme.darkMode); - const filteredEvents = filterEventsByDate(mockEvents, filter).filter( + const filteredEvents = filterEventsByDate(events, filter).filter( event => new Date(event.eventDate).getTime() >= now.getTime(), ); diff --git a/src/components/CommunityPortal/Reports/Participation/Participation.module.css b/src/components/CommunityPortal/Reports/Participation/Participation.module.css index 32d69bf954a..7408f87e645 100644 --- a/src/components/CommunityPortal/Reports/Participation/Participation.module.css +++ b/src/components/CommunityPortal/Reports/Participation/Participation.module.css @@ -352,12 +352,69 @@ } .trackingRateGreen { - color: green; + /* !important needed: `.trackingRateValue span` (below) has higher specificity + and would otherwise override this to gray even in light mode. */ + color: green !important; font-weight: bold; } .trackingRateRed { - color: red; + /* !important needed: same reason as .trackingRateGreen above. */ + color: red !important; +} + +/* public/index.css sets `body.dark-mode * { color: #fff !important }`, which otherwise + washes out these semantic colors in the per-event table below. Match its + specificity + importance to keep them. The `*` descendant selectors are needed + too: that global rule matches every descendant directly, not just the element + carrying this class. */ +:global(body.dark-mode) .trackingRateGreen, +:global(body.bm-dashboard-dark) .trackingRateGreen, +:global(body.dark-mode) .trackingRateGreen *, +:global(body.bm-dashboard-dark) .trackingRateGreen * { + color: green !important; +} + +:global(body.dark-mode) .trackingRateRed, +:global(body.bm-dashboard-dark) .trackingRateRed, +:global(body.dark-mode) .trackingRateRed *, +:global(body.bm-dashboard-dark) .trackingRateRed * { + color: red !important; +} + +/* Summary-card "since Last week" +/-% values only (NOT the table above) — red/green + in both light and dark mode, same as .trackingRateRed/.trackingRateGreen. Kept as + separate classes in case the two ever need to diverge again; currently identical + in behavior to the table's classes. */ +.summaryRateGreen { + color: green !important; +} + +.summaryRateRed { + color: red !important; +} + +:global(body.dark-mode) .summaryRateGreen, +:global(body.bm-dashboard-dark) .summaryRateGreen, +:global(body.dark-mode) .summaryRateGreen *, +:global(body.bm-dashboard-dark) .summaryRateGreen * { + color: green !important; +} + +:global(body.dark-mode) .summaryRateRed, +:global(body.bm-dashboard-dark) .summaryRateRed, +:global(body.dark-mode) .summaryRateRed *, +:global(body.bm-dashboard-dark) .summaryRateRed * { + color: red !important; +} + +/* The "Last week" summary line's leading +5% is plain text (not wrapped in a + colored span), styled red via .trackingRateValue's own color — but the same + global dark-mode rule above washes it out to white. Restore red here without + affecting the "since Last week" line's spans, which have their own color rules. */ +:global(body.dark-mode) .trackingRateValue, +:global(body.bm-dashboard-dark) .trackingRateValue { + color: red !important; } /* Insights Section */ @@ -620,6 +677,11 @@ color: var(--color-text-dark); } +:global(body.dark-mode) .insightsPercentageDark, +:global(body.bm-dashboard-dark) .insightsPercentageDark { + color: red !important; +} + /* ---------- PDF/Print helpers ---------- */ .pageBreakBefore { break-before: always; } .pageBreakAfter { break-after: always; }