From e63d8304a42d01541f2ab2727a6c42f8b67ff4f4 Mon Sep 17 00:00:00 2001 From: daniele-dematteo Date: Wed, 15 May 2024 14:37:31 +0200 Subject: [PATCH] feat(statistics): new statistics section --- assets/translations/en.json | 82 ++- assets/translations/it.json | 82 ++- lib/ui/components/SectionHeader.tsx | 73 +- package-lock.json | 48 ++ package.json | 2 + src/core/queries/offeringHooks.ts | 29 +- .../courses/components/CourseChart.tsx | 653 ++++++++++++++++++ .../CourseStatisticsBottomSheetContent.tsx | 95 +++ .../CourseStatisticsBottomSheets.tsx | 183 +++++ .../components/CourseStatisticsFilters.tsx | 105 +++ .../components/NoChartDataContainer.tsx | 56 ++ .../navigation/CourseSharedScreens.tsx | 1 + .../courses/screens/CourseInfoScreen.tsx | 18 +- .../screens/CourseStatisticsScreen.tsx | 209 ++++++ .../courses/utils/computeStatisticsFilters.ts | 56 ++ .../offering/screens/DegreeCourseScreen.tsx | 17 + src/shared/navigation/SharedScreens.tsx | 16 + 17 files changed, 1692 insertions(+), 33 deletions(-) create mode 100644 src/features/courses/components/CourseChart.tsx create mode 100644 src/features/courses/components/CourseStatisticsBottomSheetContent.tsx create mode 100644 src/features/courses/components/CourseStatisticsBottomSheets.tsx create mode 100644 src/features/courses/components/CourseStatisticsFilters.tsx create mode 100644 src/features/courses/components/NoChartDataContainer.tsx create mode 100644 src/features/courses/screens/CourseStatisticsScreen.tsx create mode 100644 src/features/courses/utils/computeStatisticsFilters.ts diff --git a/assets/translations/en.json b/assets/translations/en.json index 653382b7..87bab7d6 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -307,6 +307,84 @@ "courseGuideScreen": { "title": "Course guide" }, + "courseStatisticsScreen": { + "title": "TODO", + "subtitle": "TODO", + "noData": "TODO", + "period": "TODO", + "teacher": "TODO", + "enrolledExamTitle": "TODO", + "enrolledExamInfo": "TODO", + "enrolledExamDetailTitle": "TODO", + "enrolledExamDetailInfo": "TODO", + "examGradeDetailTitle": "TODO", + "examGradeDetailInfo": "TODO", + "enrolledExamBottomSheet": { + "title": "TODO", + "content": "TODO", + "item1": { + "title": "TODO", + "content": "TODO" + }, + "item2": { + "title": "TODO", + "content": "TODO" + } + }, + "enrolledExamDetailBottomSheet": { + "title": "TODO", + "content": "TODO", + "item1": { + "title": "TODO", + "content": "TODO" + }, + "item2": { + "title": "TODO", + "content": "TODO" + }, + "item3": { + "title": "TODO", + "content": "TODO" + } + }, + "gradesDetailBottomSheet": { + "title": "TODO", + "content": "TODO", + "item1": { + "title": "TODO", + "content": "TODO" + }, + "item2": { + "title": "TODO", + "content": "TODO" + }, + "item3": { + "title": "TODO", + "content": "TODO" + } + }, + "gradesDetailLegend": { + "averageGrade": "TODO", + "firstYear": "TODO", + "secondYear": "TODO", + "otherYears": "TODO" + }, + "enrolledExamChartLabel": { + "firstYear": "TODO", + "secondYear": "TODO", + "otherYears": "TODO" + }, + "enrolledExamChartLegend": { + "passed": "TODO", + "failed": "TODO" + }, + "enrolledExamVisualization": { + "title": "TODO", + "single": "TODO", + "compare": "TODO", + "error": "TODO" + } + }, "courseIconPickerScreen": { "title": "Pick an icon" }, @@ -391,7 +469,9 @@ "period": "Period", "staff": "Staff", "title": "Course", - "tutoring": "Tutoring" + "tutoring": "Tutoring", + "statistics": "Statistics", + "statisticsSubtitle": "See exam statistics" }, "degreeScreen": { "cohort": "Coorte", diff --git a/assets/translations/it.json b/assets/translations/it.json index fd165e6a..41e392d0 100644 --- a/assets/translations/it.json +++ b/assets/translations/it.json @@ -307,6 +307,84 @@ "courseGuideScreen": { "title": "Guida del corso" }, + "courseStatisticsScreen": { + "title": "Statistiche", + "subtitle": "Accedi alle statistiche d'esame", + "noData": "Dati non disponibili", + "period": "Periodo", + "teacher": "Docente", + "enrolledExamTitle": "Superamento esame", + "enrolledExamInfo": "Informazioni superamento esame", + "enrolledExamDetailTitle": "Dettaglio superamento esame", + "enrolledExamDetailInfo": "Informazioni dettaglio superamento esame", + "examGradeDetailTitle": "Dettaglio voti", + "examGradeDetailInfo": "Informazioni dettaglio voti", + "enrolledExamBottomSheet": { + "title": "Superamento esame", + "content": "Statistica riguardante il numero di superi totali all’esame con due tipi di visualizzazione:", + "item1": { + "title": "Singola", + "content": "statistica del singolo anno selezionato." + }, + "item2": { + "title": "Comparativa", + "content": "comparazione tra l’anno selezionato e i due anni precedenti." + } + }, + "enrolledExamDetailBottomSheet": { + "title": "Dettaglio superamento esame", + "content": "Statistica riguardante il numero di superi all’esame suddiviso in tre sotto gruppi:", + "item1": { + "title": "iscritti al 1° anno", + "content": " studenti che si sono iscritti all’esame durante il primo anno di frequenza." + }, + "item2": { + "title": "iscritti al 2° anno", + "content": " studenti che si sono iscritti all’esame durante il secondo anno di frequenza." + }, + "item3": { + "title": "iscritti dal 3° anno in poi", + "content": " studenti che si sono iscritti all’esame dal terzo anno di frequenza in poi." + } + }, + "gradesDetailBottomSheet": { + "title": "Dettaglio voti", + "content": "Statistica riguardante il dettaglio delle valutazioni d’esame suddiviso in tre sotto gruppi:", + "item1": { + "title": "iscritti al 1° anno", + "content": " studenti che si sono iscritti all’esame durante il primo anno di frequenza." + }, + "item2": { + "title": "iscritti al 2° anno", + "content": " studenti che si sono iscritti all’esame durante il secondo anno di frequenza." + }, + "item3": { + "title": "iscritti dal 3° anno in poi", + "content": " studenti che si sono iscritti all’esame dal terzo anno di frequenza in poi." + } + }, + "gradesDetailLegend": { + "averageGrade": "Votazione media", + "firstYear": "Iscritti al 1° anno", + "secondYear": "Iscritti al 2° anno", + "otherYears": "Iscritti dal 3° anno in poi" + }, + "enrolledExamChartLabel": { + "firstYear": "1° anno", + "secondYear": "2° anno", + "otherYears": "3° anno in poi" + }, + "enrolledExamChartLegend": { + "passed": "Esame superato", + "failed": "Esame non superato" + }, + "enrolledExamVisualization": { + "title": "Visualizzazione", + "single": "Singola", + "compare": "Comparativa", + "error": "Visualizzazione comparativa non disponibile per mancanza di dati nei periodi precedenti" + } + }, "courseIconPickerScreen": { "title": "Seleziona un'icona" }, @@ -391,7 +469,9 @@ "period": "Periodo", "staff": "Docenti", "title": "Corso", - "tutoring": "Tutoraggio" + "tutoring": "Tutoraggio", + "statistics": "Statistiche", + "statisticsSubtitle": "Accedi alle statistiche d'esame" }, "degreeScreen": { "cohort": "Coorte", diff --git a/lib/ui/components/SectionHeader.tsx b/lib/ui/components/SectionHeader.tsx index 713edf6c..394a8aa0 100644 --- a/lib/ui/components/SectionHeader.tsx +++ b/lib/ui/components/SectionHeader.tsx @@ -5,9 +5,12 @@ import { TextProps, TextStyle, TouchableOpacity, + TouchableOpacityProps, View, } from 'react-native'; +import { Props as FAProps } from '@fortawesome/react-native-fontawesome'; +import { IconButton } from '@lib/ui/components/IconButton'; import { Separator } from '@lib/ui/components/Separator'; import { Link, useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; @@ -23,12 +26,16 @@ interface Props { subtitle?: string; subtitleStyle?: StyleProp; ellipsizeTitle?: boolean; - linkTo?: To; linkToMoreCount?: number; - trailingItem?: JSX.Element; separator?: boolean; accessible?: boolean; accessibilityLabel?: string | undefined; + linkTo?: To; + trailingItem?: JSX.Element; + trailingIcon?: Pick & + TouchableOpacityProps & { + iconStyle?: FAProps['style']; + }; } /** @@ -46,6 +53,7 @@ export const SectionHeader = ({ linkToMoreCount, separator = true, trailingItem, + trailingIcon, }: Props) => { const styles = useStylesheet(createStyles); const { t } = useTranslation(); @@ -59,18 +67,25 @@ export const SectionHeader = ({ const Header = () => { return ( - + {separator && } - - {title} - + + + + {title} + + {trailingIcon && ( + + )} + + {subtitle && ( )} - {trailingItem - ? trailingItem - : linkTo && ( - - - {t('sectionHeader.cta')} - {(linkToMoreCount ?? 0) > 0 && - ' ' + - t('sectionHeader.ctaMoreSuffix', { - count: linkToMoreCount, - })} - - - )} + {trailingItem && trailingItem} + + {linkTo && ( + + + {t('sectionHeader.cta')} + {(linkToMoreCount ?? 0) > 0 && + ' ' + + t('sectionHeader.ctaMoreSuffix', { + count: linkToMoreCount, + })} + + + )} ); }; @@ -152,4 +167,10 @@ const createStyles = ({ spacing, colors }: Theme) => titleContainer: { flex: 1, }, + innerTitleContainer: { + alignItems: 'center', + flexDirection: 'row', + padding: 0, + margin: 0, + }, }); diff --git a/package-lock.json b/package-lock.json index 4bf426e5..c964abd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,11 +63,13 @@ "react-native-file-viewer": "^2.1.5", "react-native-fs": "^2.20.0", "react-native-gesture-handler": "^2.13.1", + "react-native-gifted-charts": "github:daniele-dematteo/react-native-gifted-charts", "react-native-html-to-pdf": "^0.12.0", "react-native-image-crop-picker": "^0.38.0", "react-native-keyboard-accessory": "^0.1.16", "react-native-keyboard-aware-scroll-view": "^0.9.5", "react-native-keychain": "^8.1.1", + "react-native-linear-gradient": "^2.8.3", "react-native-mime-types": "^2.3.0", "react-native-modal": "^13.0.1", "react-native-override-color-scheme": "^1.0.3", @@ -10463,6 +10465,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gifted-charts-core": { + "version": "0.0.21", + "resolved": "git+ssh://git@github.com/daniele-dematteo/gifted-charts-core.git#c78850c92ee2a02f3b993763804885e19ff6e54c", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "@types/react-native": "*", + "react": "*", + "react-native": "*", + "react-native-svg": "*" + } + }, "node_modules/git-raw-commits": { "version": "2.0.11", "dev": true, @@ -16372,6 +16386,31 @@ "react-native": "*" } }, + "node_modules/react-native-gifted-charts": { + "version": "1.4.9", + "resolved": "git+ssh://git@github.com/daniele-dematteo/react-native-gifted-charts.git#3eb6c2a55ce788e5461b45c84fef7700f776f3ee", + "license": "MIT", + "dependencies": { + "gifted-charts-core": "github:daniele-dematteo/gifted-charts-core" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-native": "*", + "expo-linear-gradient": "*", + "react": "*", + "react-native": "*", + "react-native-linear-gradient": "*", + "react-native-svg": "*" + }, + "peerDependenciesMeta": { + "expo-linear-gradient": { + "optional": true + }, + "react-native-linear-gradient": { + "optional": true + } + } + }, "node_modules/react-native-html-to-pdf": { "version": "0.12.0", "license": "MIT" @@ -16414,6 +16453,15 @@ "version": "8.1.2", "license": "MIT" }, + "node_modules/react-native-linear-gradient": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/react-native-linear-gradient/-/react-native-linear-gradient-2.8.3.tgz", + "integrity": "sha512-KflAXZcEg54PXkLyflaSZQ3PJp4uC4whM7nT/Uot9m0e/qxFV3p6uor1983D1YOBJbJN7rrWdqIjq0T42jOJyA==", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-mime-types": { "version": "2.4.0", "license": "MIT", diff --git a/package.json b/package.json index 9aa5b12f..07e32ee6 100644 --- a/package.json +++ b/package.json @@ -73,11 +73,13 @@ "react-native-file-viewer": "^2.1.5", "react-native-fs": "^2.20.0", "react-native-gesture-handler": "^2.13.1", + "react-native-gifted-charts": "github:daniele-dematteo/react-native-gifted-charts", "react-native-html-to-pdf": "^0.12.0", "react-native-image-crop-picker": "^0.38.0", "react-native-keyboard-accessory": "^0.1.16", "react-native-keyboard-aware-scroll-view": "^0.9.5", "react-native-keychain": "^8.1.1", + "react-native-linear-gradient": "^2.8.3", "react-native-mime-types": "^2.3.0", "react-native-modal": "^13.0.1", "react-native-override-color-scheme": "^1.0.3", diff --git a/src/core/queries/offeringHooks.ts b/src/core/queries/offeringHooks.ts index ee5fb4e1..53e35576 100644 --- a/src/core/queries/offeringHooks.ts +++ b/src/core/queries/offeringHooks.ts @@ -1,4 +1,9 @@ -import { Degree as ApiDegree, OfferingApi } from '@polito/api-client'; +import { + Degree as ApiDegree, + CourseStatistics, + OfferingApi, +} from '@polito/api-client'; +import { GetCourseStatisticsRequest } from '@polito/api-client/apis/OfferingApi'; import { MenuAction } from '@react-native-menu/menu'; import { useQuery } from '@tanstack/react-query'; @@ -84,3 +89,25 @@ export const useGetOfferingCourse = ({ }), ); }; + +export const useGetCourseStatistics = ({ + courseShortcode, + teacherId, + year, +}: GetCourseStatisticsRequest) => { + const offeringClient = useOfferingClient(); + + return useQuery( + compact([ + DEGREES_QUERY_PREFIX, + COURSES_QUERY_PREFIX, + courseShortcode, + year, + teacherId, + ]), + () => + offeringClient + .getCourseStatistics({ courseShortcode, teacherId, year }) + .then(pluckData), + ); +}; diff --git a/src/features/courses/components/CourseChart.tsx b/src/features/courses/components/CourseChart.tsx new file mode 100644 index 00000000..25288375 --- /dev/null +++ b/src/features/courses/components/CourseChart.tsx @@ -0,0 +1,653 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; +import { BarChart, LineChart, barDataItem } from 'react-native-gifted-charts'; + +import { Col } from '@lib/ui/components/Col'; +import { Row } from '@lib/ui/components/Row'; +import { Text } from '@lib/ui/components/Text'; +import { useStylesheet } from '@lib/ui/hooks/useStylesheet'; +import { useTheme } from '@lib/ui/hooks/useTheme'; +import type { Theme } from '@lib/ui/types/Theme'; +import type { CourseStatistics } from '@polito/api-client'; +import { + GradeCount, + GradeCountGradeEnum, +} from '@polito/api-client/models/GradeCount'; + +import { useFeedbackContext } from '../../../core/contexts/FeedbackContext'; +import { NoChartDataContainer } from './NoChartDataContainer'; + +const kChartAnimationDuration = 200; // ms + +const emptyChartData = [ + { value: 0 }, + { value: 0 }, + { value: 0 }, + { value: 0 }, + { value: 0 }, + { value: 0 }, + { value: 0 }, + { value: 0 }, +]; + +const LegendItem = ({ + bulletColor, + text, + trailingText, +}: { + bulletColor: string; + text: string; + trailingText?: string; +}) => { + const styles = useStylesheet(createStyles); + return ( + + + + {text} + + {trailingText && ( + + {trailingText} + + )} + + ); +}; + +// groups "grade counts" by "grade value" and returns the list of the sums sorted by grade +// E.g. +// Think of our grades as a list: [18, 19, 20, ...] +// This helper will return [howMany18, howMany19, ...] +const gradesToChartValues = (gradeCounts: GradeCount[]) => { + const groupedCounts: { + [key in string]: number; + } = {}; + for (const value of Object.values(GradeCountGradeEnum)) { + groupedCounts[value] = 0; + } + + for (const gradeCount of gradeCounts) { + groupedCounts[gradeCount.grade] += gradeCount.count; + } + return Object.values(groupedCounts).map(it => ({ value: it })); +}; + +export const CourseGradesChart = ({ + statistics, + width, +}: { + width: number; + statistics: undefined | CourseStatistics; +}) => { + const { palettes, colors } = useTheme(); + const { t } = useTranslation(); + const styles = useStylesheet(createStyles); + const chartColors = [ + palettes.secondary[600], + palettes.lightBlue[600], + palettes.lightBlue[200], + ]; + + const [data, setData] = useState( + gradesToChartValues(statistics?.firstYear?.grades ?? []), + ); + const [data1, setData1] = useState( + gradesToChartValues(statistics?.secondYear?.grades ?? []), + ); + const [data2, setData2] = useState( + gradesToChartValues(statistics?.otherYears?.grades ?? []), + ); + + useEffect(() => { + setData(gradesToChartValues(statistics?.firstYear?.grades ?? [])); + setData1(gradesToChartValues(statistics?.secondYear?.grades ?? [])); + setData2(gradesToChartValues(statistics?.otherYears?.grades ?? [])); + }, [statistics]); + + const hasData = [data, data1, data2].some(e => e.length > 0); + const maxYValue = Math.max( + ...[...data, ...data1, ...data2].map(it => it.value), + ); + + return ( + + + + + + + + + {t('courseStatisticsScreen.gradesDetailLegend.averageGrade')} + + + + + + + ); +}; + +export const EnrolledExamDetailChart = ({ + statistics, + width, + noOfSections, +}: { + width: number; + statistics: undefined | CourseStatistics; + noOfSections?: number; +}) => { + const { palettes, colors, fontSizes, spacing } = useTheme(); + const { t } = useTranslation(); + const styles = useStylesheet(createStyles); + const chartColors = [palettes.green[400], palettes.red[500]]; + const topLabelSpacing = spacing['1']; + const barRadius = spacing['0.5']; + + const barData: barDataItem[] = [ + { + value: statistics?.firstYear?.succeeded ?? 0, + label: t('courseStatisticsScreen.enrolledExamChartLabel.firstYear'), + spacing: 2, + labelWidth: 40, + labelTextStyle: { + fontSize: fontSizes['2xs'], + }, + frontColor: palettes.green[400], + topLabelComponent: () => ( + + {statistics?.firstYear?.succeeded ?? 0} + + ), + }, + { + value: statistics?.firstYear?.failed ?? 0, + frontColor: palettes.red[500], + topLabelComponent: () => ( + + {statistics?.firstYear?.failed ?? 0} + + ), + }, + { + value: statistics?.secondYear?.succeeded ?? 0, + label: t('courseStatisticsScreen.enrolledExamChartLabel.secondYear'), + spacing: 2, + labelWidth: 40, + labelTextStyle: { + fontSize: fontSizes['2xs'], + position: 'relative', + left: '-5%', + }, + frontColor: palettes.green[400], + topLabelComponent: () => ( + + {statistics?.secondYear?.succeeded ?? 0} + + ), + }, + { + value: statistics?.secondYear?.failed ?? 0, + frontColor: palettes.red[500], + topLabelComponent: () => ( + + {statistics?.secondYear?.failed ?? 0} + + ), + }, + { + value: statistics?.otherYears?.succeeded ?? 0, + label: t('courseStatisticsScreen.enrolledExamChartLabel.otherYears'), + spacing: 2, + labelWidth: 80, + labelTextStyle: { + fontSize: fontSizes['2xs'], + position: 'relative', + left: '-25%', + }, + frontColor: palettes.green[400], + topLabelComponent: () => ( + + {statistics?.otherYears?.succeeded ?? 0} + + ), + }, + { + value: statistics?.otherYears?.failed ?? 0, + frontColor: palettes.red[500], + topLabelComponent: () => ( + + {statistics?.otherYears?.failed ?? 0} + + ), + }, + ]; + + const hasData = barData.length > 0; + + return ( + + + + + + + + + + + ); +}; + +type VisualizationMode = 'single' | 'compare'; +export const EnrolledExamChart = ({ + statistics, + width, + noOfSections, +}: { + width: number; + statistics: undefined | CourseStatistics; + noOfSections?: number; +}) => { + const { dark, palettes, colors, fontSizes, spacing } = useTheme(); + const { t } = useTranslation(); + const styles = useStylesheet(createStyles); + + const selectedSwitchColor = dark ? palettes.primary[600] : colors.surface; + const chartColors = [palettes.green[400], palettes.red[500]]; + const topLabelSpacing = spacing['1']; + const barRadius = spacing['0.5']; + const [mode, setMode] = useState('single'); + + const { setFeedback } = useFeedbackContext(); + let initialSpacing = spacing['4']; + + let graphSpacing = 14; + + let barWidth = + (width - + initialSpacing - + ((statistics?.previousYearsToCompare?.length ?? 0) + 1) * 2 - + (statistics?.previousYearsToCompare?.length ?? 0) * graphSpacing) / + (((statistics?.previousYearsToCompare?.length ?? 0) + 1) * 2); + + let barData: barDataItem[] = []; + + if (mode === 'single') { + initialSpacing = width / 6; + barWidth = width / 3; + graphSpacing = width / 8; + barData = [ + { + value: statistics?.totalSucceeded ?? 0, + frontColor: palettes.green[400], + topLabelComponent: () => ( + + {statistics?.totalSucceeded ?? 0} + + ), + }, + { + value: statistics?.totalFailed ?? 0, + frontColor: palettes.red[500], + topLabelComponent: () => ( + + {statistics?.totalFailed ?? 0} + + ), + }, + ]; + } + + if (mode === 'compare') { + const prevYears = + statistics?.previousYearsToCompare?.flatMap((prevYear, index) => { + return [ + { + value: prevYear.succeeded, + label: `${prevYear.year - 1} - ${prevYear.year}`, + spacing: 2, + labelWidth: 100, + labelTextStyle: { + fontSize: fontSizes['2xs'], + position: 'relative', + left: index === 0 ? '-30%' : '-20%', + color: colors.title, + }, + frontColor: `${palettes.green[400]}bb`, + topLabelComponent: () => ( + + {prevYear.succeeded} + + ), + }, + { + value: prevYear.failed, + frontColor: `${palettes.red[500]}bb`, + topLabelComponent: () => ( + + {prevYear.failed} + + ), + }, + ]; + }) ?? []; + + barData = [ + ...prevYears, + { + value: statistics?.totalSucceeded ?? 0, + label: `${statistics ? statistics?.year - 1 : 0} - ${ + statistics?.year ?? 0 + }`, + spacing: 2, + labelWidth: 100, + labelTextStyle: { + fontSize: fontSizes['2xs'], + position: 'relative', + left: '-10%', + }, + frontColor: palettes.green[400], + topLabelComponent: () => ( + + {statistics?.totalSucceeded ?? 0} + + ), + }, + { + value: statistics?.totalFailed ?? 0, + frontColor: palettes.red[500], + topLabelComponent: () => ( + + {statistics?.totalFailed ?? 0} + + ), + }, + ]; + } + + const onChangeVisualizationMode = (nextMode: VisualizationMode) => { + if (nextMode === mode) { + return; + } + + if ( + nextMode === 'compare' && + (statistics?.previousYearsToCompare === undefined || + statistics?.previousYearsToCompare?.length === 0) + ) { + setFeedback({ + text: t('courseStatisticsScreen.enrolledExamVisualization.error'), + }); + return; + } + + setMode(nextMode); + }; + + const hasData = barData.length > 0; + + return ( + + + + {t('courseStatisticsScreen.enrolledExamVisualization.title')} + + + onChangeVisualizationMode('single')} + accessibilityLabel={t( + 'courseStatisticsScreen.enrolledExamVisualization.single', + )} + style={{ + ...styles.buttonSwitchLabel, + backgroundColor: + mode === 'single' ? selectedSwitchColor : undefined, + color: mode !== 'single' ? colors.secondaryText : colors.title, + }} + > + {t('courseStatisticsScreen.enrolledExamVisualization.single')} + + onChangeVisualizationMode('compare')} + accessibilityLabel={t( + 'courseStatisticsScreen.enrolledExamVisualization.compare', + )} + style={{ + ...styles.buttonSwitchLabel, + backgroundColor: + mode === 'compare' ? selectedSwitchColor : undefined, + color: mode !== 'compare' ? colors.secondaryText : colors.title, + }} + > + {t('courseStatisticsScreen.enrolledExamVisualization.compare')} + + + + + + + + + + + + + + ); +}; + +const createStyles = ({ + dark, + spacing, + colors, + fontSizes, + fontWeights, +}: Theme) => + StyleSheet.create({ + buttonSwitchLabel: { + fontSize: fontSizes.sm, + paddingVertical: spacing[1], + paddingHorizontal: spacing[2], + borderRadius: spacing[1], + fontWeight: fontWeights.medium, + }, + buttonSwitchTitle: { + alignSelf: 'flex-end', + fontSize: fontSizes.sm, + marginBottom: spacing[2], + }, + buttonSwitch: { + flexDirection: 'row', + alignSelf: 'flex-end', + backgroundColor: dark ? colors.surfaceDark : colors.background, + padding: spacing[1], + borderRadius: spacing[1], + gap: spacing[1], + }, + graphCard: { + padding: spacing[4], + }, + gradesChartLegendTitle: { + textAlign: 'right', + fontSize: fontSizes.xs, + marginVertical: spacing['4'], + }, + chartAxisLabel: { + fontSize: fontSizes['2xs'], + color: colors.title, + }, + chartLegendBullet: { + height: 8, + width: 8, + borderRadius: 8, + }, + chartLegendText: { + fontSize: fontSizes.xs, + }, + chartLegendTrailingText: { + marginLeft: 'auto', + fontSize: fontSizes.sm, + }, + }); diff --git a/src/features/courses/components/CourseStatisticsBottomSheetContent.tsx b/src/features/courses/components/CourseStatisticsBottomSheetContent.tsx new file mode 100644 index 00000000..b42d7244 --- /dev/null +++ b/src/features/courses/components/CourseStatisticsBottomSheetContent.tsx @@ -0,0 +1,95 @@ +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; + +import { faClose } from '@fortawesome/free-solid-svg-icons'; +import { BottomSheetView } from '@gorhom/bottom-sheet'; +import { IconButton } from '@lib/ui/components/IconButton'; +import { Text } from '@lib/ui/components/Text'; +import { useStylesheet } from '@lib/ui/hooks/useStylesheet'; +import { Theme } from '@lib/ui/types/Theme'; + +export const CourseStatisticsBottomSheetContent = ({ + title, + content, + itemList, + onDismiss, +}: { + title: string; + content: string; + itemList: { title: string; content: string }[]; + onDismiss: () => void; +}) => { + const { t } = useTranslation(); + const styles = useStylesheet(createStyles); + return ( + + + + + {title} + + + + + {content} + {itemList.map((item, index) => { + return ( + + {`\u2022`} + + + {`${item.title}:`}{' '} + + {item.content} + + + + + ); + })} + + + + ); +}; + +const createStyles = ({ dark, fontSizes, colors, spacing }: Theme) => + StyleSheet.create({ + container: { + backgroundColor: colors.surface, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: dark ? colors.surfaceDark : colors.background, + paddingVertical: spacing[2], + }, + headerTitle: { + marginLeft: 'auto', + marginRight: 'auto', + fontSize: fontSizes.lg, + textAlign: 'center', + }, + content: { + padding: spacing[4], + gap: spacing[4], + }, + listItem: { + flexDirection: 'row', + }, + listItemTitle: { + fontWeight: 'bold', + }, + }); diff --git a/src/features/courses/components/CourseStatisticsBottomSheets.tsx b/src/features/courses/components/CourseStatisticsBottomSheets.tsx new file mode 100644 index 00000000..d5a27022 --- /dev/null +++ b/src/features/courses/components/CourseStatisticsBottomSheets.tsx @@ -0,0 +1,183 @@ +import { GestureHandlerRootView } from 'react-native-gesture-handler'; + +import React, { useCallback, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + BottomSheetModal, + BottomSheetModalProvider, +} from '@gorhom/bottom-sheet'; +import { BottomSheetMethods } from '@gorhom/bottom-sheet/lib/typescript/types'; +import { BottomSheet } from '@lib/ui/components/BottomSheet'; + +import { GlobalStyles } from '../../../core/styles/GlobalStyles'; +import { CourseStatisticsBottomSheetContent } from './CourseStatisticsBottomSheetContent'; + +type ChildrenParams = { + onPresentEnrolledExamModalPress: () => void; + onPresentEnrolledExamDetailModalPress: () => void; + onPresentGradesDetailModalPress: () => void; +}; +type Props = { + children: (params: ChildrenParams) => React.ReactElement; +}; +export const CourseStatisticsBottomSheets = ({ children }: Props) => { + const { t } = useTranslation(); + const enrolledExamBottomSheetModalRef = useRef(null); + const enrolledExamDetailBottomSheetModalRef = useRef(null); + const gradesDetailBottomSheetModalRef = useRef(null); + + const onPresentEnrolledExamModalPress = useCallback(() => { + enrolledExamBottomSheetModalRef.current?.snapToIndex(1); + }, []); + + const onDismissEnrolledExamModal = useCallback(() => { + enrolledExamBottomSheetModalRef.current?.close(); + }, []); + const onPresentEnrolledExamDetailModalPress = useCallback(() => { + enrolledExamDetailBottomSheetModalRef.current?.snapToIndex(1); + }, []); + const onDismissEnrolledExamDetailModal = useCallback(() => { + enrolledExamDetailBottomSheetModalRef.current?.close(); + }, []); + const onPresentGradesDetailModalPress = useCallback(() => { + gradesDetailBottomSheetModalRef.current?.snapToIndex(1); + }, []); + const onDismissGradesDetailModal = useCallback(() => { + gradesDetailBottomSheetModalRef.current?.close(); + }, []); + + const snapPoints = [1, '60%', '100%']; + + return ( + + + {children({ + onPresentEnrolledExamModalPress, + onPresentEnrolledExamDetailModalPress, + onPresentGradesDetailModalPress, + })} + + + + + + + + + + + + ); +}; diff --git a/src/features/courses/components/CourseStatisticsFilters.tsx b/src/features/courses/components/CourseStatisticsFilters.tsx new file mode 100644 index 00000000..14dc655f --- /dev/null +++ b/src/features/courses/components/CourseStatisticsFilters.tsx @@ -0,0 +1,105 @@ +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; + +import { faChevronDown } from '@fortawesome/free-solid-svg-icons'; +import { Card } from '@lib/ui/components/Card'; +import { Grid } from '@lib/ui/components/Grid'; +import { Icon } from '@lib/ui/components/Icon'; +import { Row } from '@lib/ui/components/Row'; +import { StatefulMenuView } from '@lib/ui/components/StatefulMenuView'; +import { Text } from '@lib/ui/components/Text'; +import { useStylesheet } from '@lib/ui/hooks/useStylesheet'; +import { Theme } from '@lib/ui/types/Theme'; + +import { GlobalStyles } from '../../../core/styles/GlobalStyles'; +import { StatisticsFilters } from '../utils/computeStatisticsFilters'; + +export const CourseStatisticsFilters = ({ + teachers, + years, + onTeacherChanged, + onYearChanged, + currentYear, + currentTeacher, +}: StatisticsFilters & { + onTeacherChanged: (teacherId: string) => void; + onYearChanged: (year: string) => void; +}) => { + const { t } = useTranslation(); + const styles = useStylesheet(createStyles); + return ( + + + + + {t('courseStatisticsScreen.period')} + + { + onYearChanged(nativeEvent.event); + }} + actions={years} + disabled={years.length === 1} + > + + + {currentYear?.title ?? '--'} + + + + + + + + {t('courseStatisticsScreen.teacher')} + + { + onTeacherChanged(nativeEvent.event); + }} + actions={teachers} + > + + + {currentTeacher?.title ?? '--'} + + + + + + + + ); +}; + +const createStyles = ({ spacing, fontSizes, colors }: Theme) => + StyleSheet.create({ + container: { + marginBottom: spacing[4], + }, + label: { + fontSize: fontSizes.xs, + marginBottom: spacing[0.5], + }, + dropdownText: { + color: colors.heading, + fontSize: fontSizes.md, + }, + chevronStyle: { + marginLeft: 10, + }, + }); diff --git a/src/features/courses/components/NoChartDataContainer.tsx b/src/features/courses/components/NoChartDataContainer.tsx new file mode 100644 index 00000000..bcc30834 --- /dev/null +++ b/src/features/courses/components/NoChartDataContainer.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet, Text, View } from 'react-native'; + +import { faChartSimple } from '@fortawesome/free-solid-svg-icons'; +import { Icon } from '@lib/ui/components/Icon'; +import { useStylesheet } from '@lib/ui/hooks/useStylesheet'; +import { useTheme } from '@lib/ui/hooks/useTheme'; +import type { Theme } from '@lib/ui/types/Theme'; + +export const NoChartDataContainer = ({ + hasData, + children, +}: { + hasData: boolean; + children: React.ReactNode; +}) => { + const { colors } = useTheme(); + const { t } = useTranslation(); + const styles = useStylesheet(createStyles); + + if (hasData) return {children}; + + return ( + + + + {t('courseStatisticsScreen.noData')} + + + {children} + + ); +}; + +const createStyles = ({ colors, fontSizes, fontWeights }: Theme) => + StyleSheet.create({ + view: { + position: 'relative', + }, + title: { + color: colors.tabBarInactive, + fontSize: fontSizes.md, + fontWeight: fontWeights.semibold, + }, + container: { + position: 'absolute', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + top: 0, + zIndex: 1, + alignSelf: 'center', + height: '100%', + }, + }); diff --git a/src/features/courses/navigation/CourseSharedScreens.tsx b/src/features/courses/navigation/CourseSharedScreens.tsx index 2223303d..0bdc09be 100644 --- a/src/features/courses/navigation/CourseSharedScreens.tsx +++ b/src/features/courses/navigation/CourseSharedScreens.tsx @@ -130,6 +130,7 @@ export const CourseSharedScreens = ( headerBackTitle: t('common.course'), }} /> + { [courseId, queryClient], ); const isGuideDisabled = useOfflineDisabled(isGuideDataMissing); + const isStatisticsDisabled = !courseQuery.data?.shortcode; return ( { /> - {/*
- - -
*/}
{ linkTo={{ screen: 'CourseGuide', params: { courseId } }} disabled={isGuideDisabled} /> +
diff --git a/src/features/courses/screens/CourseStatisticsScreen.tsx b/src/features/courses/screens/CourseStatisticsScreen.tsx new file mode 100644 index 00000000..5dc25d16 --- /dev/null +++ b/src/features/courses/screens/CourseStatisticsScreen.tsx @@ -0,0 +1,209 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Dimensions, + Platform, + SafeAreaView, + ScrollView, + StyleSheet, + View, +} from 'react-native'; + +import { faQuestionCircle } from '@fortawesome/free-regular-svg-icons'; +import { Card } from '@lib/ui/components/Card'; +import { LoadingContainer } from '@lib/ui/components/LoadingContainer'; +import { SectionHeader } from '@lib/ui/components/SectionHeader'; +import { useStylesheet } from '@lib/ui/hooks/useStylesheet'; +import { useTheme } from '@lib/ui/hooks/useTheme'; +import { Theme } from '@lib/ui/types/Theme'; +import { NativeStackScreenProps } from '@react-navigation/native-stack'; + +import { useGetCourseStatistics } from '../../../core/queries/offeringHooks'; +import { SharedScreensParamList } from '../../../shared/navigation/SharedScreens'; +import { + CourseGradesChart, + EnrolledExamChart, + EnrolledExamDetailChart, +} from '../components/CourseChart'; +import { CourseStatisticsBottomSheets } from '../components/CourseStatisticsBottomSheets'; +import { CourseStatisticsFilters } from '../components/CourseStatisticsFilters'; +import { computeStatisticsFilters } from '../utils/computeStatisticsFilters'; + +type Props = NativeStackScreenProps; +export const CourseStatisticsScreen = ({ route }: Props) => { + const { t } = useTranslation(); + const { courseShortcode: shortCode, year, teacherId } = route.params; + + const { spacing, colors } = useTheme(); + const [currentFilters, setCurrentFilters] = useState<{ + currentYear?: string; + currentTeacherId?: string; + }>({ + currentYear: year ? String(year) : undefined, + currentTeacherId: teacherId ? String(teacherId) : undefined, + }); + + const { data: statistics, isFetching } = useGetCourseStatistics({ + courseShortcode: shortCode, + teacherId: currentFilters.currentTeacherId, + year: currentFilters.currentYear, + }); + + const filters = useMemo(() => { + return computeStatisticsFilters( + statistics, + currentFilters.currentYear, + currentFilters.currentTeacherId, + ); + }, [currentFilters.currentYear, statistics, currentFilters.currentTeacherId]); + + useEffect(() => { + const shouldSetYearFromStatistics = + currentFilters.currentYear === undefined && + statistics?.year !== undefined; + const shouldSetTeacherFromStatistics = + currentFilters.currentTeacherId === undefined && + statistics?.teacher !== undefined; + + if (shouldSetYearFromStatistics || shouldSetTeacherFromStatistics) { + setCurrentFilters(prev => ({ + ...prev, + currentYear: statistics?.year ? String(statistics.year) : undefined, + currentTeacherId: statistics?.teacher + ? String(statistics.teacher.id) + : undefined, + })); + } + }, [statistics, currentFilters]); + + const styles = useStylesheet(createStyles); + + const graphWidth = + Dimensions.get('window').width - + Platform.select({ ios: 128, android: 100 })!; + + return ( + + {({ + onPresentEnrolledExamModalPress, + onPresentEnrolledExamDetailModalPress, + onPresentGradesDetailModalPress, + }) => { + return ( + + + { + setCurrentFilters(prev => ({ + ...prev, + currentTeacherId: nextTeacherId, + })); + }} + onYearChanged={nextYear => { + setCurrentFilters(prev => ({ + ...prev, + currentYear: nextYear, + })); + }} + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); + }} + + ); +}; + +const createStyles = ({ spacing }: Theme) => + StyleSheet.create({ + container: { + gap: spacing[2], + }, + }); diff --git a/src/features/courses/utils/computeStatisticsFilters.ts b/src/features/courses/utils/computeStatisticsFilters.ts new file mode 100644 index 00000000..6d74be47 --- /dev/null +++ b/src/features/courses/utils/computeStatisticsFilters.ts @@ -0,0 +1,56 @@ +import { CourseStatistics, Teacher } from '@polito/api-client'; +import { MenuAction } from '@react-native-menu/menu'; + +const teachersToMenuAction = ( + teachers: Teacher[], + currentTeacherId?: string, +): MenuAction[] => + teachers.map(t => { + return { + id: String(t.id), + title: `${t.lastName}`, + state: currentTeacherId === t.id.toString() ? 'on' : 'off', + }; + }); + +export const formatYearPeriod = (year: number) => { + const formattedYear = String(year).slice(-2); + return `${year - 1}/${formattedYear}`; +}; + +const yearsToMenuAction = ( + years: number[], + selectedYear?: string, +): MenuAction[] => + years.map(y => { + return { + id: String(y), + title: formatYearPeriod(y), + state: selectedYear && parseInt(selectedYear, 10) === y ? 'on' : 'off', + }; + }); + +export type StatisticsFilters = { + currentTeacher?: MenuAction; + teachers: MenuAction[]; + currentYear?: MenuAction; + years: MenuAction[]; +}; + +export const computeStatisticsFilters = ( + statistics: undefined | CourseStatistics, + year: undefined | string, + teacherId: undefined | string, +): StatisticsFilters => { + const teachers = teachersToMenuAction(statistics?.teachers ?? [], teacherId); + const years = yearsToMenuAction(statistics?.years ?? [], year); + const currentTeacher = teachers.find(it => it.state === 'on'); + const currentYear = years.find(it => it.state === 'on'); + + return { + currentTeacher, + teachers, + currentYear, + years, + }; +}; diff --git a/src/features/offering/screens/DegreeCourseScreen.tsx b/src/features/offering/screens/DegreeCourseScreen.tsx index 66086ef1..734a2473 100644 --- a/src/features/offering/screens/DegreeCourseScreen.tsx +++ b/src/features/offering/screens/DegreeCourseScreen.tsx @@ -11,6 +11,7 @@ import { import { faAngleDown, faBriefcase, + faChartLine, faFlaskVial, faMicroscope, faPersonChalkboard, @@ -214,8 +215,24 @@ export const DegreeCourseScreen = ({ route }: Props) => { leadingItem={} /> )} + {offeringCourse && ( + } + /> + )} )} +
+ `${params.courseId}${params.courseShortcode}`} + options={{ + headerTitle: t('courseStatisticsScreen.title'), + headerBackTitle: t('common.course'), + }} + />