1- import React , { FC , useMemo , useState } from 'react' ;
1+ import React , { FC , useMemo , useState , useCallback } from 'react' ;
22import useTranslation from 'next-translate/useTranslation' ;
3+ import { Checkbox , FormControlLabel } from '@mui/material' ;
34import { useRoleQuery } from '../../../../hooks/authedQuery' ;
45import { MULTI_PROGRAM_ENROLLMENTS } from '../../../../queries/multiProgramEnrollments' ;
56import { MultiProgramEnrollments } from '../../../../queries/__generated__/MultiProgramEnrollments' ;
@@ -12,6 +13,7 @@ import Loading from '../../../common/Loading';
1213export const ApplicationStatistics : FC = ( ) => {
1314 const { t } = useTranslation ( 'statistics' ) ;
1415 const [ selectedPrograms , setSelectedPrograms ] = useState < { id : number ; name : string } [ ] > ( [ ] ) ;
16+ const [ useActualDates , setUseActualDates ] = useState ( false ) ;
1517
1618 // Query for program list (for selector)
1719 const { data : programListData } = useRoleQuery < ProgramStatistics > ( PROGRAM_LIST ) ;
@@ -24,89 +26,234 @@ export const ApplicationStatistics: FC = () => {
2426 skip : selectedPrograms . length === 0 ,
2527 } ) ;
2628
27- // Transform programs data for tag selector
29+ // Transform programs data for tag selector, sorted by application start date (future/upcoming first)
2830 const programOptions = useMemo (
2931 ( ) =>
30- programListData ?. Program . map ( ( program ) => ( {
31- id : program . id ,
32- name : program . title ,
33- } ) ) || [ ] ,
32+ programListData ?. Program
33+ . slice ( ) // Create a copy to avoid mutating the original array
34+ . sort ( ( a , b ) => {
35+ // Sort by applicationStart in descending order (most recent/future first)
36+ // Programs without applicationStart go to the end
37+ if ( ! a . applicationStart && ! b . applicationStart ) return 0 ;
38+ if ( ! a . applicationStart ) return 1 ;
39+ if ( ! b . applicationStart ) return - 1 ;
40+
41+ const dateA = new Date ( a . applicationStart ) . getTime ( ) ;
42+ const dateB = new Date ( b . applicationStart ) . getTime ( ) ;
43+ return dateB - dateA ; // Descending order: future/recent dates first
44+ } )
45+ . map ( ( program ) => ( {
46+ id : program . id ,
47+ name : program . title ,
48+ } ) ) || [ ] ,
3449 [ programListData ]
3550 ) ;
3651
52+ // Helper function to calculate days until application end
53+ const calculateDaysUntilEnd = useCallback ( ( enrollmentDate : string , applicationEndDate : string | null ) : number => {
54+ if ( ! applicationEndDate ) return 0 ;
55+
56+ const enrollment = new Date ( enrollmentDate ) ;
57+ const applicationEnd = new Date ( applicationEndDate ) ;
58+ const diffTime = applicationEnd . getTime ( ) - enrollment . getTime ( ) ;
59+ const diffDays = Math . ceil ( diffTime / ( 1000 * 60 * 60 * 24 ) ) ;
60+
61+ return diffDays ;
62+ } , [ ] ) ;
63+
3764 // Process data for cumulative chart
3865 const cumulativeChartData = useMemo ( ( ) => {
3966 if ( ! data ?. Program . length ) return [ ] ;
4067
41- // Sort all enrollments by date first
42- const allEnrollments = data . Program . flatMap ( ( program ) =>
43- program . Courses . flatMap ( ( course ) =>
44- course . CourseEnrollments . map ( ( enrollment ) => ( {
45- date : new Date ( enrollment . created_at ) . toISOString ( ) . split ( 'T' ) [ 0 ] ,
46- program : program . title ,
68+ // Get list of all programs for filling in zeros
69+ const allPrograms = data . Program . map ( ( p ) => p . title ) ;
70+
71+ if ( useActualDates ) {
72+ // Process by actual dates - chronological order
73+ const allEnrollments = data . Program . flatMap ( ( program ) =>
74+ program . Courses . flatMap ( ( course ) =>
75+ course . CourseEnrollments . map ( ( enrollment ) => ( {
76+ date : new Date ( enrollment . created_at ) . toISOString ( ) . split ( 'T' ) [ 0 ] ,
77+ program : program . title ,
78+ } ) )
79+ )
80+ ) . sort ( ( a , b ) => a . date . localeCompare ( b . date ) ) ;
81+
82+ const dateMap = new Map < string , { [ key : string ] : number } > ( ) ;
83+ const programCounts = new Map < string , number > ( ) ;
84+
85+ // Initialize all programs with 0
86+ allPrograms . forEach ( ( program ) => {
87+ programCounts . set ( program , 0 ) ;
88+ } ) ;
89+
90+ // Process enrollments in chronological order
91+ allEnrollments . forEach ( ( { date, program } ) => {
92+ programCounts . set ( program , ( programCounts . get ( program ) || 0 ) + 1 ) ;
93+
94+ if ( ! dateMap . has ( date ) ) {
95+ dateMap . set ( date , { } ) ;
96+ }
97+
98+ const dateEntry = dateMap . get ( date ) ;
99+ if ( dateEntry ) {
100+ programCounts . forEach ( ( count , programTitle ) => {
101+ dateEntry [ programTitle ] = count ;
102+ } ) ;
103+ }
104+ } ) ;
105+
106+ return Array . from ( dateMap . entries ( ) )
107+ . map ( ( [ date , values ] ) => ( {
108+ date,
109+ ...values ,
47110 } ) )
48- )
49- ) . sort ( ( a , b ) => a . date . localeCompare ( b . date ) ) ;
111+ . sort ( ( a , b ) => a . date . localeCompare ( b . date ) ) ;
112+ } else {
113+ // Process by days until end
114+ // First, group enrollments by days until end and count them
115+ const daysMap = new Map < number , { [ key : string ] : number } > ( ) ;
50116
51- const dateMap = new Map < string , { [ key : string ] : number } > ( ) ;
52- const programCounts = new Map < string , number > ( ) ;
117+ data . Program . forEach ( ( program ) => {
118+ program . Courses . forEach ( ( course ) => {
119+ course . CourseEnrollments . forEach ( ( enrollment ) => {
120+ const date = new Date ( enrollment . created_at ) . toISOString ( ) . split ( 'T' ) [ 0 ] ;
121+ const daysUntilEnd = calculateDaysUntilEnd ( date , program . defaultApplicationEnd ) ;
53122
54- // Process enrollments in chronological order
55- allEnrollments . forEach ( ( { date, program } ) => {
56- // Initialize or increment program count
57- programCounts . set ( program , ( programCounts . get ( program ) || 0 ) + 1 ) ;
123+ if ( ! daysMap . has ( daysUntilEnd ) ) {
124+ daysMap . set ( daysUntilEnd , { } ) ;
125+ }
58126
59- // Create or update date entry
60- if ( ! dateMap . has ( date ) ) {
61- dateMap . set ( date , { } ) ;
62- }
127+ const dayEntry = daysMap . get ( daysUntilEnd ) ;
128+ if ( dayEntry ) {
129+ dayEntry [ program . title ] = ( dayEntry [ program . title ] || 0 ) + 1 ;
130+ }
131+ } ) ;
132+ } ) ;
133+ } ) ;
134+
135+ // Find the range of days (max to 0)
136+ const allDays = Array . from ( daysMap . keys ( ) ) ;
137+ if ( allDays . length === 0 ) return [ ] ;
138+
139+ const maxDays = Math . max ( ...allDays ) ;
140+ const minDays = Math . min ( ...allDays , 0 ) ;
141+
142+ // Build cumulative counts from max days down to min days
143+ const programCumulativeCounts = new Map < string , number > ( ) ;
144+ const result : Array < { date : string ; [ key : string ] : any } > = [ ] ;
63145
64- // Copy all current cumulative counts to this date
65- const dateEntry = dateMap . get ( date ) ;
66- if ( dateEntry ) {
67- programCounts . forEach ( ( count , programTitle ) => {
68- dateEntry [ programTitle ] = count ;
146+ // Initialize all programs with 0
147+ allPrograms . forEach ( ( program ) => {
148+ programCumulativeCounts . set ( program , 0 ) ;
149+ } ) ;
150+
151+ // Iterate from max days down to min days
152+ for ( let days = maxDays ; days >= minDays ; days -- ) {
153+ // Add counts for this day if they exist
154+ if ( daysMap . has ( days ) ) {
155+ const counts = daysMap . get ( days ) ;
156+ if ( counts ) {
157+ Object . entries ( counts ) . forEach ( ( [ program , count ] ) => {
158+ programCumulativeCounts . set ( program , ( programCumulativeCounts . get ( program ) || 0 ) + count ) ;
159+ } ) ;
160+ }
161+ }
162+
163+ // Create entry with all cumulative counts (even if no change for this day)
164+ const entry : { date : string ; [ key : string ] : any } = { date : days . toString ( ) } ;
165+ programCumulativeCounts . forEach ( ( count , program ) => {
166+ entry [ program ] = count ;
69167 } ) ;
168+
169+ result . push ( entry ) ;
70170 }
71- } ) ;
72171
73- return Array . from ( dateMap . entries ( ) )
74- . map ( ( [ date , values ] ) => ( {
75- date,
76- ...values ,
77- } ) )
78- . sort ( ( a , b ) => a . date . localeCompare ( b . date ) ) ;
79- } , [ data ] ) ;
172+ return result ;
173+ }
174+ } , [ data , useActualDates , calculateDaysUntilEnd ] ) ;
80175
81176 // Process data for daily chart
82177 const dailyChartData = useMemo ( ( ) => {
83178 if ( ! data ?. Program . length ) return [ ] ;
84179
85- const dateMap = new Map < string , { [ key : string ] : number } > ( ) ;
180+ // Get list of all programs for filling in zeros
181+ const allPrograms = data . Program . map ( ( p ) => p . title ) ;
86182
87- data . Program . forEach ( ( program ) => {
88- program . Courses . forEach ( ( course ) => {
89- course . CourseEnrollments . forEach ( ( enrollment ) => {
90- const date = new Date ( enrollment . created_at ) . toISOString ( ) . split ( 'T' ) [ 0 ] ;
91- if ( ! dateMap . has ( date ) ) {
92- dateMap . set ( date , { } ) ;
93- }
183+ if ( useActualDates ) {
184+ // Process by actual dates
185+ const dateMap = new Map < string , { [ key : string ] : number } > ( ) ;
94186
95- const dateEntry = dateMap . get ( date ) ;
96- if ( dateEntry ) {
97- dateEntry [ program . title ] = ( dateEntry [ program . title ] || 0 ) + 1 ;
98- }
187+ data . Program . forEach ( ( program ) => {
188+ program . Courses . forEach ( ( course ) => {
189+ course . CourseEnrollments . forEach ( ( enrollment ) => {
190+ const date = new Date ( enrollment . created_at ) . toISOString ( ) . split ( 'T' ) [ 0 ] ;
191+
192+ if ( ! dateMap . has ( date ) ) {
193+ dateMap . set ( date , { } ) ;
194+ }
195+
196+ const dateEntry = dateMap . get ( date ) ;
197+ if ( dateEntry ) {
198+ dateEntry [ program . title ] = ( dateEntry [ program . title ] || 0 ) + 1 ;
199+ }
200+ } ) ;
201+ } ) ;
202+ } ) ;
203+
204+ return Array . from ( dateMap . entries ( ) )
205+ . map ( ( [ date , values ] ) => ( {
206+ date,
207+ ...values ,
208+ } ) )
209+ . sort ( ( a , b ) => a . date . localeCompare ( b . date ) ) ;
210+ } else {
211+ // Process by days until end
212+ const daysMap = new Map < number , { [ key : string ] : number } > ( ) ;
213+
214+ data . Program . forEach ( ( program ) => {
215+ program . Courses . forEach ( ( course ) => {
216+ course . CourseEnrollments . forEach ( ( enrollment ) => {
217+ const date = new Date ( enrollment . created_at ) . toISOString ( ) . split ( 'T' ) [ 0 ] ;
218+ const daysUntilEnd = calculateDaysUntilEnd ( date , program . defaultApplicationEnd ) ;
219+
220+ if ( ! daysMap . has ( daysUntilEnd ) ) {
221+ daysMap . set ( daysUntilEnd , { } ) ;
222+ }
223+
224+ const dayEntry = daysMap . get ( daysUntilEnd ) ;
225+ if ( dayEntry ) {
226+ dayEntry [ program . title ] = ( dayEntry [ program . title ] || 0 ) + 1 ;
227+ }
228+ } ) ;
99229 } ) ;
100230 } ) ;
101- } ) ;
102231
103- return Array . from ( dateMap . entries ( ) )
104- . map ( ( [ date , values ] ) => ( {
105- date,
106- ...values ,
107- } ) )
108- . sort ( ( a , b ) => a . date . localeCompare ( b . date ) ) ;
109- } , [ data ] ) ;
232+ // Find the range of days (max to 0)
233+ const allDays = Array . from ( daysMap . keys ( ) ) ;
234+ if ( allDays . length === 0 ) return [ ] ;
235+
236+ const maxDays = Math . max ( ...allDays ) ;
237+ const minDays = Math . min ( ...allDays , 0 ) ;
238+
239+ // Build result with all days from max to min, filling zeros where needed
240+ const result : Array < { date : string ; [ key : string ] : any } > = [ ] ;
241+
242+ for ( let days = maxDays ; days >= minDays ; days -- ) {
243+ const entry : { date : string ; [ key : string ] : any } = { date : days . toString ( ) } ;
244+
245+ // Fill in counts for each program (0 if no enrollments for this day)
246+ allPrograms . forEach ( ( program ) => {
247+ const counts = daysMap . get ( days ) ;
248+ entry [ program ] = counts ?. [ program ] || 0 ;
249+ } ) ;
250+
251+ result . push ( entry ) ;
252+ }
253+
254+ return result ;
255+ }
256+ } , [ data , useActualDates , calculateDaysUntilEnd ] ) ;
110257
111258 // Create series configuration for charts
112259 const series = useMemo (
@@ -128,7 +275,7 @@ export const ApplicationStatistics: FC = () => {
128275
129276 return (
130277 < div className = "space-y-6" >
131- < div className = "bg-white p-4 rounded´´ -lg" >
278+ < div className = "bg-white p-4 rounded-lg space-y-4 " >
132279 < TagSelector
133280 variant = "material"
134281 label = { t ( 'application_statistics.select_programs.label' ) }
@@ -140,6 +287,17 @@ export const ApplicationStatistics: FC = () => {
140287 refetchQueries = { [ ] }
141288 className = "text-gray-800"
142289 />
290+ < FormControlLabel
291+ control = {
292+ < Checkbox
293+ checked = { useActualDates }
294+ onChange = { ( e ) => setUseActualDates ( e . target . checked ) }
295+ color = "primary"
296+ />
297+ }
298+ label = { t ( 'application_statistics.use_actual_dates' ) }
299+ className = "text-gray-800"
300+ />
143301 </ div >
144302
145303 { loading && < Loading /> }
0 commit comments