Skip to content

Commit 5bf9ff2

Browse files
committed
feat(statistics): add days-until-application-end x-axis mode for application statistics
- Add checkbox to toggle between calendar dates and days-until-application-end on x-axis - Default to days-until-application-end view for better comparison across programs - Update GraphQL query to include defaultApplicationEnd field - Implement proper cumulative chart logic that never decreases - Fill gaps with zeros to show all days in range without missing data - Sort program dropdown by applicationStart date (most recent first) - Update German translation: 'Nutze das Kalender-Datum auf der X-Achse' - Update English translation: 'Use Calendar Date on X-Axis' The new view allows comparing application patterns across different programs by showing how many days before the deadline applications were submitted.
1 parent fb940f9 commit 5bf9ff2

File tree

5 files changed

+225
-60
lines changed

5 files changed

+225
-60
lines changed

frontend-nx/apps/edu-hub/components/pages/StatisticsContent/statistics/ApplicationStatistics.tsx

Lines changed: 216 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import React, { FC, useMemo, useState } from 'react';
1+
import React, { FC, useMemo, useState, useCallback } from 'react';
22
import useTranslation from 'next-translate/useTranslation';
3+
import { Checkbox, FormControlLabel } from '@mui/material';
34
import { useRoleQuery } from '../../../../hooks/authedQuery';
45
import { MULTI_PROGRAM_ENROLLMENTS } from '../../../../queries/multiProgramEnrollments';
56
import { MultiProgramEnrollments } from '../../../../queries/__generated__/MultiProgramEnrollments';
@@ -12,6 +13,7 @@ import Loading from '../../../common/Loading';
1213
export 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 />}

frontend-nx/apps/edu-hub/locales/de/statistics.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@
2323
},
2424
"daily": "Tägliche Bewerbungen",
2525
"cumulative": "Kumulierte Bewerbungen",
26-
"no_data_available": "Keine Daten verfügbar"
26+
"no_data_available": "Keine Daten verfügbar",
27+
"use_actual_dates": "Nutze das Kalender-Datum auf der X-Achse"
2728
},
2829
"course_statistics": {
2930
"label": "Kursstatistiken",

frontend-nx/apps/edu-hub/locales/en/statistics.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@
2323
},
2424
"daily": "Daily Applications",
2525
"cumulative": "Cumulative Applications",
26-
"no_data_available": "No data available"
26+
"no_data_available": "No data available",
27+
"use_actual_dates": "Use Calendar Date on X-Axis"
2728
},
2829
"course_statistics": {
2930
"label": "Course Statistics",

frontend-nx/apps/edu-hub/queries/__generated__/MultiProgramEnrollments.ts

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend-nx/apps/edu-hub/queries/multiProgramEnrollments.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export const MULTI_PROGRAM_ENROLLMENTS = gql`
66
id
77
title
88
shortTitle
9+
defaultApplicationEnd
910
Courses {
1011
id
1112
title

0 commit comments

Comments
 (0)