client/app/bundles/course/statistics/pages/StatisticsIndex/course/StudentProgressionChart.tsx
import { FC, useCallback, useMemo, useState } from 'react';
import { defineMessages } from 'react-intl';
import {
Card,
CardContent,
FormControlLabel,
FormGroup,
Switch,
Typography,
} from '@mui/material';
import { ChartData, ChartTypeRegistry } from 'chart.js';
import { LimitOptions } from 'chartjs-plugin-zoom/types/options';
import {
GREEN_CHART_BACKGROUND,
GREEN_CHART_BORDER,
ORANGE_CHART_BORDER,
RED_CHART_BORDER,
} from 'theme/colors';
import { Assessment, Submission } from 'course/statistics/types';
import {
processAssessment,
processSubmissions,
} from 'course/statistics/utils/parseCourseResponse';
import GeneralChart from 'lib/components/core/charts/GeneralChart';
import useTranslation from 'lib/hooks/useTranslation';
import {
computeLimits,
footerRenderer,
labelRenderer,
processSubmissionsIntoChartData,
titleRenderer,
} from './utils';
const translations = defineMessages({
title: {
id: 'course.statistics.StatisticsIndex.course.StudentProgressionChart.title',
defaultMessage: 'Student Progression',
},
latestSubmission: {
id: 'course.statistics.StatisticsIndex.course.StudentProgressionChart.latestSubmission',
defaultMessage: 'Latest Submission',
},
studentSubmissions: {
id: 'course.statistics.StatisticsIndex.course.StudentProgressionChart.studentSubmissions',
defaultMessage: "{name}'s Submissions",
},
deadlines: {
id: 'course.statistics.StatisticsIndex.course.StudentProgressionChart.deadlines',
defaultMessage: 'Deadlines',
},
openingTimes: {
id: 'course.statistics.StatisticsIndex.course.StudentProgressionChart.openingTimes',
defaultMessage: 'Opening Times',
},
showOpeningTimes: {
id: 'course.statistics.StatisticsIndex.course.StudentProgressionChart.showOpeningTimes',
defaultMessage: 'Show opening times of assessments',
},
phantom: {
id: 'course.statistics.StatisticsIndex.course.StudentProgressionChart.phantom',
defaultMessage: 'Include phantom users',
},
yAxisLabel: {
id: 'course.statistics.StatisticsIndex.course.StudentProgressionChart.yAxisLabel',
defaultMessage: 'Assessment (Sorted by Deadline)',
},
xAxisLabel: {
id: 'course.statistics.StatisticsIndex.course.StudentProgressionChart.xAxisLabel',
defaultMessage: 'Date',
},
note: {
id: 'course.statistics.StatisticsIndex.course.StudentProgressionChart.note',
defaultMessage:
'Note: The chart above only shows assessments with deadlines. Students may also have personalized deadlines.',
},
});
interface Props {
assessments: Assessment[];
submissions: Submission[];
}
const chartGlobalOptions = (t): object => ({
scales: {
x: {
type: 'time',
time: {
tooltipFormat: 'YYYY-MM-DD h:mm:ss a',
},
title: {
display: true,
text: t(translations.xAxisLabel),
},
},
y: {
beginAtZero: true,
title: {
display: true,
text: t(translations.yAxisLabel),
},
},
},
plugins: {
tooltip: {
callbacks: {
title: titleRenderer,
label: labelRenderer(t),
footer: footerRenderer(t),
},
},
},
});
const StudentProgressionChart: FC<Props> = (props) => {
const { assessments, submissions } = props;
const { t } = useTranslation();
const [selectedStudentIndex, setSelectedStudentIndex] = useState(null);
const [showOpeningTimes, setShowOpeningTimes] = useState(true);
const [showPhantoms, setShowPhantoms] = useState(false);
const formattedAssessments = assessments.map(processAssessment);
const formattedSubmissions = submissions.map(processSubmissions);
const onClick = useCallback(
(_, elements) => {
const relevantPoints = elements.filter((e) => e.datasetIndex === 0);
if (relevantPoints.length !== 1) {
return;
}
setSelectedStudentIndex(relevantPoints[0].index);
},
[setSelectedStudentIndex],
);
const studentData = useMemo(
() =>
processSubmissionsIntoChartData(
formattedAssessments,
formattedSubmissions,
showPhantoms,
),
[formattedAssessments, formattedSubmissions, showPhantoms],
);
const limits = useMemo(
() => computeLimits(formattedAssessments, formattedSubmissions),
[formattedAssessments, formattedSubmissions],
);
const data = useMemo(
() => ({
datasets: [
// All students
{
type: 'scatter' as const,
label: t(translations.latestSubmission),
data: studentData.map((s) => {
const latestPoint = s.submissions[s.submissions.length - 1];
return {
x: latestPoint.submittedAt,
y: s.submissions.length - 1,
name: s.name,
title: formattedAssessments[latestPoint.key].title,
};
}),
backgroundColor: RED_CHART_BORDER,
},
// Selected student
...(selectedStudentIndex
? [
{
type: 'line' as const,
label: t(translations.studentSubmissions, {
name: studentData[selectedStudentIndex].name,
}),
data: studentData[selectedStudentIndex].submissions.map(
(s) => s?.submittedAt,
),
spanGaps: true,
backgroundColor: ORANGE_CHART_BORDER,
borderColor: ORANGE_CHART_BORDER,
},
]
: []),
// Deadlines
{
type: 'line' as const,
label: t(translations.deadlines),
data: formattedAssessments.map((a, index) => ({
x: a.endAt,
y: index,
title: a.title,
isStartAt: false,
})),
backgroundColor: GREEN_CHART_BORDER,
borderColor: GREEN_CHART_BORDER,
fill: false,
},
// Opening times
...(showOpeningTimes
? [
{
type: 'line' as const,
label: t(translations.openingTimes),
data: formattedAssessments.map((a, index) => ({
x: a.startAt,
y: index,
title: a.title,
isStartAt: true,
})),
backgroundColor: GREEN_CHART_BORDER,
borderColor: GREEN_CHART_BORDER,
fill: {
target: '-1', // fill until deadline dataset
above: GREEN_CHART_BACKGROUND,
below: GREEN_CHART_BACKGROUND,
},
},
]
: []),
],
}),
[
formattedAssessments,
studentData,
selectedStudentIndex,
showOpeningTimes,
t,
],
);
const options = useMemo(
() => ({
...chartGlobalOptions(t),
onClick,
}),
[onClick, t],
);
return (
<Card variant="outlined">
<CardContent>
<Typography gutterBottom variant="h6">
{t(translations.title)}
</Typography>
<div>
<FormGroup row>
<FormControlLabel
control={
<Switch
checked={showOpeningTimes}
inputProps={{ 'aria-label': 'controlled' }}
onChange={(event) =>
setShowOpeningTimes(event.target.checked)
}
/>
}
label={t(translations.showOpeningTimes)}
/>
<FormControlLabel
control={
<Switch
checked={showPhantoms}
inputProps={{ 'aria-label': 'controlled' }}
onChange={(event) => setShowPhantoms(event.target.checked)}
/>
}
label={t(translations.phantom)}
/>
</FormGroup>
</div>
<GeneralChart
data={data as ChartData<keyof ChartTypeRegistry>}
limits={limits as LimitOptions}
options={options}
type="scatter"
withZoom={studentData.length > 0}
/>
<Typography fontSize="1.4rem" textAlign="center" variant="subtitle1">
{t(translations.note)}
</Typography>
</CardContent>
</Card>
);
};
export default StudentProgressionChart;