diff --git a/assets/translations/en.json b/assets/translations/en.json index 3ed634b4..6cd15657 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -309,6 +309,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" }, @@ -390,7 +468,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 47e3ba16..f57c4d6a 100644 --- a/assets/translations/it.json +++ b/assets/translations/it.json @@ -309,6 +309,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" }, @@ -390,7 +468,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 1eb4638c..69302438 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,12 +14,11 @@ "@fortawesome/free-regular-svg-icons": "^6.2.1", "@fortawesome/free-solid-svg-icons": "^6.2.1", "@fortawesome/react-native-fontawesome": "^0.3.0", - "@gorhom/bottom-sheet": "^4.5.1", - "@kyupss/native-swipeable": "^1.0.1", + "@gorhom/bottom-sheet": "^4.6.1", "@miblanchard/react-native-slider": "^2.2.0", "@openspacelabs/react-native-zoomable-view": "^2.1.5", "@orama/orama": "^2.0.0-beta.8", - "@polito/api-client": "^1.0.0-ALPHA.58", + "@polito/api-client": "^1.0.0-ALPHA.59", "@react-native-async-storage/async-storage": "^1.17.11", "@react-native-clipboard/clipboard": "^1.12.1", "@react-native-community/blur": "^4.3.0", @@ -64,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", @@ -2801,8 +2802,9 @@ } }, "node_modules/@gorhom/bottom-sheet": { - "version": "4.5.1", - "license": "MIT", + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-4.6.1.tgz", + "integrity": "sha512-sXqsYqX1/rAbmCC5fb9o6hwSF3KXriC0EGUGvLlhFvjaEEMBrRKFTNndiluRK1HmpUzazVaYdTm/lLkSiA2ooQ==", "dependencies": { "@gorhom/portal": "1.0.14", "invariant": "^2.2.4" @@ -3657,17 +3659,6 @@ "react-native": "*" } }, - "node_modules/@kyupss/native-swipeable": { - "version": "1.0.1", - "license": "MIT", - "dependencies": { - "prop-types": "15.8.1" - }, - "peerDependencies": { - "deprecated-react-native-prop-types": ">=2.2.0", - "react-native": ">=0.69.0" - } - }, "node_modules/@miblanchard/react-native-slider": { "version": "2.3.1", "license": "MIT", @@ -3828,9 +3819,9 @@ } }, "node_modules/@polito/api-client": { - "version": "1.0.0-ALPHA.58", - "resolved": "https://npm.pkg.github.com/download/@polito/api-client/1.0.0-ALPHA.58/b4c2734ab51495a2c98a7e7640479448b8ab5e09", - "integrity": "sha512-DdQY3kIVOWzhyI3VvBAo30zx9TAa8lJ4yDByRnSDy72oopSKNb2V0XVLouC0dw9wYgd4fG9DLuMHrAG5g7FBnQ==" + "version": "1.0.0-ALPHA.59", + "resolved": "https://npm.pkg.github.com/download/@polito/api-client/1.0.0-ALPHA.59/c63de66a809aa4cabbeca2ee4f207d1f6f7022a2", + "integrity": "sha512-3/ozilxN709YLgtss+g9gQPNtQ126uSU3GkUVABABrTZEjnR7koCUcU4HJER05nqlBCSu2YGfvg9RnbvY7Ba2w==" }, "node_modules/@react-native-async-storage/async-storage": { "version": "1.19.8", @@ -5780,11 +5771,6 @@ "version": "2.1.0", "license": "MIT" }, - "node_modules/@react-native/normalize-colors": { - "version": "0.73.2", - "license": "MIT", - "peer": true - }, "node_modules/@react-native/polyfills": { "version": "2.0.0", "license": "MIT" @@ -9029,16 +9015,13 @@ } }, "node_modules/deprecated-react-native-prop-types": { - "version": "5.0.0", - "license": "MIT", - "peer": true, + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/deprecated-react-native-prop-types/-/deprecated-react-native-prop-types-2.3.0.tgz", + "integrity": "sha512-pWD0voFtNYxrVqvBMYf5gq3NA2GCpfodS1yNynTPc93AYA/KEMGeWDqqeUB6R2Z9ZofVhks2aeJXiuQqKNpesA==", "dependencies": { - "@react-native/normalize-colors": "^0.73.0", - "invariant": "^2.2.4", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=18" + "@react-native/normalize-color": "*", + "invariant": "*", + "prop-types": "*" } }, "node_modules/destroy": { @@ -10622,6 +10605,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#7693b58ea1ef8d139cc413a2b19d0a48cc1c7930", + "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, @@ -16755,6 +16750,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-gradle-plugin": { "version": "0.71.19", "license": "MIT" @@ -16801,6 +16821,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", @@ -16863,15 +16892,6 @@ "react-native-blob-util": ">=0.13.7" } }, - "node_modules/react-native-pdf/node_modules/deprecated-react-native-prop-types": { - "version": "2.3.0", - "license": "MIT", - "dependencies": { - "@react-native/normalize-color": "*", - "invariant": "*", - "prop-types": "*" - } - }, "node_modules/react-native-permissions": { "version": "3.10.1", "license": "MIT", @@ -16986,15 +17006,6 @@ "shaka-player": "^2.5.9" } }, - "node_modules/react-native-video/node_modules/deprecated-react-native-prop-types": { - "version": "2.3.0", - "license": "MIT", - "dependencies": { - "@react-native/normalize-color": "*", - "invariant": "*", - "prop-types": "*" - } - }, "node_modules/react-native/node_modules/@jest/types": { "version": "26.6.2", "license": "MIT", diff --git a/package.json b/package.json index 93134d06..061cd573 100644 --- a/package.json +++ b/package.json @@ -24,12 +24,11 @@ "@fortawesome/free-regular-svg-icons": "^6.2.1", "@fortawesome/free-solid-svg-icons": "^6.2.1", "@fortawesome/react-native-fontawesome": "^0.3.0", - "@gorhom/bottom-sheet": "^4.5.1", - "@kyupss/native-swipeable": "^1.0.1", + "@gorhom/bottom-sheet": "^4.6.1", "@miblanchard/react-native-slider": "^2.2.0", "@openspacelabs/react-native-zoomable-view": "^2.1.5", "@orama/orama": "^2.0.0-beta.8", - "@polito/api-client": "^1.0.0-ALPHA.58", + "@polito/api-client": "^1.0.0-ALPHA.59", "@react-native-async-storage/async-storage": "^1.17.11", "@react-native-clipboard/clipboard": "^1.12.1", "@react-native-community/blur": "^4.3.0", @@ -74,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..c78ee095 --- /dev/null +++ b/src/features/courses/components/CourseChart.tsx @@ -0,0 +1,623 @@ +import { 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 = gradesToChartValues(statistics?.firstYear?.grades ?? []); + const data1 = gradesToChartValues(statistics?.secondYear?.grades ?? []); + const data2 = gradesToChartValues(statistics?.otherYears?.grades ?? []); + + const hasData = [data, data1, data2].some(e => e.length > 0); + + return ( + + + + + + + + + {t('courseStatisticsScreen.gradesDetailLegend.averageGrade')} + + + + + + + ); +}; + +export const EnrolledExamDetailChart = ({ + statistics, + width, +}: { + width: number; + statistics: undefined | CourseStatistics; +}) => { + 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, +}: { + width: number; + statistics: undefined | CourseStatistics; +}) => { + 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 barWidth = 14; + let graphSpacing = Math.max( + width / ((statistics?.previousYearsToCompare?.length ?? 0) + 1) - + initialSpacing - + ((statistics?.previousYearsToCompare?.length ?? 0) * barWidth) / 2, + 35, + ); + + 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: index === 0 ? 100 : 80, + labelTextStyle: { + fontSize: fontSizes['2xs'], + position: 'relative', + left: index === 0 ? '-40%' : '-30%', // Warning: you will get different behaviours while changing these values if hot reload is enabled. To ensure your positioning is ok, set your value, navigate away and come back to see the actual result + 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: 80, + labelTextStyle: { + fontSize: fontSizes['2xs'], + position: 'relative', + left: '-30%', + }, + 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, + fontWeight: mode === 'single' ? '500' : undefined, + backgroundColor: + mode === 'single' ? selectedSwitchColor : undefined, + }} + > + {t('courseStatisticsScreen.enrolledExamVisualization.single')} + + onChangeVisualizationMode('compare')} + accessibilityLabel={t( + 'courseStatisticsScreen.enrolledExamVisualization.compare', + )} + style={{ + ...styles.buttonSwitchLabel, + fontWeight: mode === 'compare' ? '500' : undefined, + backgroundColor: + mode === 'compare' ? selectedSwitchColor : undefined, + }} + > + {t('courseStatisticsScreen.enrolledExamVisualization.compare')} + + + + + + + + + + + + + + ); +}; + +const createStyles = ({ dark, spacing, colors, fontSizes }: Theme) => + StyleSheet.create({ + buttonSwitchLabel: { + fontSize: fontSizes.sm, + paddingVertical: spacing[1], + paddingHorizontal: spacing[2], + borderRadius: spacing[1], + }, + 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['2xs'], + }, + 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..e7300103 --- /dev/null +++ b/src/features/courses/components/CourseStatisticsBottomSheetContent.tsx @@ -0,0 +1,87 @@ +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..fb865b6d --- /dev/null +++ b/src/features/courses/components/CourseStatisticsFilters.tsx @@ -0,0 +1,100 @@ +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} + > + + + {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: { + flex: 1, + color: colors.heading, + fontSize: fontSizes.md, + }, + }); 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'), }} /> + { ); 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..04096d35 --- /dev/null +++ b/src/features/courses/screens/CourseStatisticsScreen.tsx @@ -0,0 +1,200 @@ +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, isLoading } = 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..8f0f8b01 --- /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.firstName} ${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 18c2c9de..4d6900bd 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, @@ -159,63 +160,73 @@ export const DegreeCourseScreen = ({ route }: Props) => { - {offeringCourse?.hours && - offeringCourse.hours?.lecture && - offeringCourse.hours?.classroomExercise && - offeringCourse.hours?.labExercise && - offeringCourse.hours?.tutoring && ( - - {!!offeringCourse?.hours?.lecture && ( - } - /> - )} - {!!offeringCourse?.hours?.classroomExercise && ( - } - /> - )} - {!!offeringCourse?.hours?.labExercise && ( - } - /> - )} - {!!offeringCourse?.hours?.tutoring && ( - } - /> - )} - + + + {!!offeringCourse?.hours?.lecture && ( + } + /> + )} + {!!offeringCourse?.hours?.classroomExercise && ( + } + /> + )} + {!!offeringCourse?.hours?.labExercise && ( + } + /> + )} + {!!offeringCourse?.hours?.tutoring && ( + } + /> + )} + {offeringCourse && ( + } + /> )} + +
+ `${params.courseId}${params.courseShortcode}`} + options={{ + headerTitle: t('courseStatisticsScreen.title'), + headerBackTitle: t('common.course'), + }} + />