[Course refactor] Course stats work

Moving from using only codes to working with group_ids
This commit is contained in:
joonas-a
2026-09-21 15:24:21 +03:00
committed by Aaro Koivukangas
parent 9c40be64e8
commit b7934d642b
19 changed files with 220 additions and 257 deletions
+4 -7
View File
@@ -39,11 +39,9 @@ export type CourseYearlyStatsResBody = CourseYearlyStats[]
router.get<never, CanError<CourseYearlyStatsResBody>, CourseYearlyStatsReqBody, CourseYearlyStatsQuery>(
'/courseyearlystats',
async (req, res) => {
const { courses, combineSubstitutions, separate, fromYearCode, toYearCode } = req.query
const { courses, substitutions, separate, fromYearCode, toYearCode } = req.query
const courseGroupIds = handleQueryArrays(courses)
if (!courseGroupIds.length) {
if (!courses || !courses.length) {
return res.status(422).send({ error: 'Missing required query parameters' })
}
@@ -62,14 +60,13 @@ router.get<never, CanError<CourseYearlyStatsResBody>, CourseYearlyStatsReqBody,
const anonymize = !userHasFullAccessToStudentData && fullStudyProgrammeRights.length === 0
const anonymizationSalt = anonymize ? crypto.randomBytes(12).toString('hex') : null
const useCombineSubstitutions = combineSubstitutions !== 'false'
const useSeparate = separate === 'true'
const results = await getCourseYearlyStats(
courseGroupIds,
handleQueryArrays(courses),
useSeparate,
anonymizationSalt,
useCombineSubstitutions,
substitutions === 'true',
fromYearCode,
toYearCode
)
@@ -56,6 +56,11 @@ export const getCreditsForCourses = async (
},
},
},
{
model: CourseModel,
attributes: ['groupId'],
required: true,
},
],
})
@@ -169,6 +174,7 @@ export const getEnrollmentsForCourses = async (
{
model: CourseModel,
attributes: ['groupId'],
required: true,
},
],
})
+11 -23
View File
@@ -190,38 +190,30 @@ export const getYearlyStatsOfNew =
primaryCourse: Pick<CourseModel, 'id' | 'groupId' | 'code' | 'name' | 'substitutionGroups'>,
separate: boolean,
anonymizationSalt: string | null,
combineSubstitutions: boolean,
substitutions: boolean,
studentNumberToSrElementsMap: Record<string, SISStudyRightElementModel[]>,
from: Date,
to: Date
) =>
async (unification: Unification): Promise<CourseYearlyStats['openStats']> => {
const { id, groupId, name, code: courseCode } = primaryCourse
const { groupId, name, code: courseCode } = primaryCourse
// Includes main course code and substitutions (if enabled)
const creditGroupIds = combineSubstitutions
? [[groupId]].concat(primaryCourse.substitutionGroups ?? [])
: [[groupId]]
const filteredCreditGroupIds =
unification === Unification.REGULAR
? // FIXME: isOpenUniCourseCode does not work yet with groupIds
creditGroupIds.filter(group => group.every(course => !isOpenUniCourseCode(course)))
: creditGroupIds
const creditGroupIds = substitutions ? [[groupId]].concat(primaryCourse.substitutionGroups ?? []) : [[groupId]]
const allCourseIds = (
await CourseModel.findAll({
raw: true,
attributes: ['id'],
where: { groupId: { [Op.in]: filteredCreditGroupIds.flat() } },
where: { groupId: { [Op.in]: creditGroupIds.flat() } },
})
).map(({ id }) => id)
const { semesters, years } = await getSemestersAndYears()
const [creditGroups, enrollmentGroups] = await Promise.all([
getCreditsForCourses(filteredCreditGroupIds, allCourseIds, unification, from, to),
getEnrollmentsForCourses(filteredCreditGroupIds, allCourseIds, unification, from, to),
getCreditsForCourses(creditGroupIds, allCourseIds, unification, from, to),
getEnrollmentsForCourses(creditGroupIds, allCourseIds, unification, from, to),
])
const counter = new CourseYearlyStatsCounter()
@@ -346,7 +338,7 @@ export const getYearlyStatsOfNew =
const statistics = await counter.getFinalStatistics(anonymizationSalt)
const substitutionGroups =
combineSubstitutions && primaryCourse.substitutionGroups?.length
substitutions && primaryCourse.substitutionGroups?.length
? await getSubstitutionGroupDetails(primaryCourse.substitutionGroups)
: [[{ code: courseCode, name, groupId }]]
@@ -398,7 +390,7 @@ export const getCourseYearlyStats = async (
courseGroupIds: string[],
separate: boolean,
anonymizationSalt: string | null,
combineSubstitutions: boolean,
substitutions: boolean,
fromYearCode = yearToYearCode(1950).toString(),
toYearCode: string = yearToYearCode(now().getFullYear()).toString()
) => {
@@ -409,7 +401,7 @@ export const getCourseYearlyStats = async (
console.log('groupIds', courseGroupIds)
console.log('separate', separate)
console.log('anonymizationSalt', anonymizationSalt)
console.log('combineSubstitutions', combineSubstitutions)
console.log('substitutions', substitutions)
console.log('fromYearCode ', fromYearCode)
console.log('toYearCode', toYearCode)
@@ -448,8 +440,6 @@ export const getCourseYearlyStats = async (
}),
])
console.log(from, to, credits.length, enrollments.length)
const studentNumbers = new Set<string>()
credits.forEach(credit => {
@@ -462,8 +452,6 @@ export const getCourseYearlyStats = async (
const studentNumberToSrElementsMap = await getStudentNumberToSrElementsMap([...studentNumbers])
console.log('studentnumbers', [...studentNumbers].join(','))
const stats = await Promise.all(
courseGroupIds.map(async groupId => {
const primaryCourse: Pick<CourseModel, 'id' | 'groupId' | 'code' | 'name' | 'substitutionGroups'> | null =
@@ -474,7 +462,7 @@ export const getCourseYearlyStats = async (
})
if (!primaryCourse) {
logger.error(`Primary course for course stats not found with groupId: ${groupId}`)
logger.error(`Primary course for course stats not found for: ${groupId}`)
return {}
}
@@ -482,7 +470,7 @@ export const getCourseYearlyStats = async (
primaryCourse,
separate,
anonymizationSalt,
combineSubstitutions,
substitutions,
studentNumberToSrElementsMap,
from,
to
@@ -39,14 +39,13 @@ import { useSemesters } from '@/hooks/useSemesters'
import { useGetCourseDetailsQuery } from '@/redux/courseStats'
import { useGetPopulationStatisticsByCourseQuery } from '@/redux/populations'
import { FilteredCourse } from '@/util/coursesOfPopulation'
import { parseQueryParams } from '@/util/queryparams'
import { useParseQueryParams } from '@/util/queryparams'
import { SISStudyRightElement } from '@oodikone/shared/models'
import { FormattedStudent } from '@oodikone/shared/types'
export const CoursePopulation = () => {
useTitle('Course population')
const location = useLocation()
const { getTextIn } = useLanguage()
const [codes, setCodes] = useState<{ allCodes: string[]; mainCodes: string[] }>({
@@ -54,14 +53,21 @@ export const CoursePopulation = () => {
mainCodes: [],
})
const { coursecodes, from, to, separate, unifyCourses, includeSubstitutions } = parseQueryParams(location.search)
const {
coursecodes,
from: [from],
to: [to],
separate: [separate],
unifyCourses: [unifyCourses],
includeSubstitutions: [includeSubstitutions],
} = useParseQueryParams()
const { data: courseDetails } = useGetCourseDetailsQuery(
{ codes: codes.mainCodes },
{ skip: !codes.mainCodes.length }
)
const { data: population } = useGetPopulationStatisticsByCourseQuery({
coursecodes,
coursecodes: coursecodes[0],
from,
to,
separate,
@@ -70,8 +76,7 @@ export const CoursePopulation = () => {
})
useEffect(() => {
const parsedCourseCodes = JSON.parse(coursecodes)
if (parsedCourseCodes.length) setCodes({ ...codes, mainCodes: parsedCourseCodes })
if (coursecodes.length) setCodes({ ...codes, mainCodes: coursecodes })
}, [coursecodes])
useEffect(() => {
@@ -106,7 +111,7 @@ export const CoursePopulation = () => {
}
const isSeparate = separate ? JSON.parse(separate) : false
const { dateFrom, dateTo } = getFromToDates(from, to, isSeparate)
const { dateFrom, dateTo } = getFromToDates(Number(from), Number(to), isSeparate)
// Dates must be set
if (!dateFrom || !dateTo) return null
@@ -15,7 +15,7 @@ import { ProgressTable as ProgressTab } from '@/components/PopulationComponents/
import { TagsTab } from '@/components/PopulationComponents/Students/Table/TagsTab'
import { ExtendedCurriculumDetails } from '@/hooks/useCurriculums'
import { FilteredCourse } from '@/util/coursesOfPopulation'
import { parseQueryParams } from '@/util/queryparams'
import { useParseQueryParams } from '@/util/queryparams'
import { isBachelorOrLicentiateProgramme } from '@/util/studyProgramme'
import { FormattedStudent } from '@oodikone/shared/types'
@@ -74,9 +74,10 @@ export const PopulationStudents = ({
if (!['population', 'customPopulation', 'coursePopulation', 'studyGuidanceGroupPopulation'].includes(variant))
throw new Error(`${variant} is not a proper variant!`)
const { years } = parseQueryParams(location.search)
const { stringYears } = useParseQueryParams()
const years = stringYears.map(Number)
const months = years
? dayjs().diff(dayjs(`${Math.min(years)}-08-01`), 'months')
? dayjs().diff(dayjs(`${Math.min(...years)}-08-01`), 'months')
: studyGuidanceGroup?.tags?.year
? dayjs().diff(dayjs(`${studyGuidanceGroup?.tags?.year}-08-01`), 'months')
: undefined
@@ -46,7 +46,7 @@ import type { PopulationQuery } from '@/types/populationSearch'
import { getFullStudyProgrammeRights } from '@/util/access'
import { getCombinedProgrammeName } from '@/util/combinedProgramme'
import { parseQueryParams } from '@/util/queryparams'
import { useParseQueryParams } from '@/util/queryparams'
import { DegreeProgrammeType } from '@oodikone/shared/types'
import { formatToArray } from '@oodikone/shared/util'
@@ -55,7 +55,7 @@ const getYearText = (years: number[]) => (years.length >= 1 ? `${years[0]} - ${y
const parseQueryFromUrl = (location: Location): [boolean, PopulationQuery] => {
const skipQuery = !location.search
const { years, semesters, programme, studentStatuses, combinedProgramme, studyTrack, showBachelorAndMaster, tag } =
parseQueryParams(location.search)
useParseQueryParams(location.search)
const dirtyQuery = {
years: formatToArray(years).map(year => parseInt(year, 10)),
+1 -1
View File
@@ -1,2 +1,2 @@
// * Increment this if search history code changes so it will be reset for all users
export const SEARCH_HISTORY_VERSION = '1.1'
export const SEARCH_HISTORY_VERSION = '1.2'
+3 -5
View File
@@ -1,11 +1,12 @@
import { useMemo } from 'react'
import { useLocation, useNavigate } from 'react-router'
import { parseQueryParams, queryParamsToString } from '@/util/queryparams'
import { useParseQueryParams, queryParamsToString } from '@/util/queryparams'
export const useTabs = (totalTabs: number, prefix?: string) => {
'use memo'
const navigate = useNavigate()
const location = useLocation()
const params = useParseQueryParams()
const id = prefix ? `${prefix}-tab` : 'tab'
@@ -16,14 +17,11 @@ export const useTabs = (totalTabs: number, prefix?: string) => {
return Number.isNaN(tabIndex) || tabIndex < 0 || tabIndex >= totalTabs ? initialTab : tabIndex
}
const tab = useMemo(() => {
return normalizeTab(parseQueryParams(location.search)[id])
}, [location.search, id, normalizeTab])
const tab = useMemo(() => normalizeTab(params[id]), [id, normalizeTab])
const switchToTab = (newTab: number) => {
const nextTab = normalizeTab(newTab)
const params = parseQueryParams(location.search)
const currentTab = normalizeTab(params[id])
if (currentTab === nextTab) return
@@ -5,10 +5,6 @@ import MenuItem from '@mui/material/MenuItem'
import Select, { SelectChangeEvent } from '@mui/material/Select'
import Typography from '@mui/material/Typography'
import { useAppDispatch } from '@/redux/hooks'
import { setSelectedCourse } from '@/redux/selectedCourse'
export const CourseSelector = ({
courses,
selected,
@@ -18,16 +14,11 @@ export const CourseSelector = ({
selected: string
setSelected: (courseCode: string) => void
}) => {
const dispatch = useAppDispatch()
const onCourseChange = (event: SelectChangeEvent<string>) => {
const selectedCourse = event.target.value
setSelected(selectedCourse)
dispatch(setSelectedCourse(selectedCourse))
}
console.log(courses, selected)
return (
<FormControl fullWidth>
<InputLabel>Select course</InputLabel>
@@ -8,7 +8,7 @@ import type { CourseSearchState } from '@/pages/CourseStatistics'
import { ResultTab } from '@/pages/CourseStatistics/CourseTab/SingleCourseStats/ResultTabs/tabs/ResultTab'
import { PersonIcon, RefreshIcon } from '@/theme'
import { AvailableStats, ProgrammeStats } from '@/types/courseStat'
import { parseQueryParams, queryParamsToString } from '@/util/queryparams'
import { useParseQueryParams, queryParamsToString } from '@/util/queryparams'
export const ResultTabs = ({
availableStats,
@@ -34,26 +34,20 @@ export const ResultTabs = ({
courseCodes: string[]
}) => {
const navigate = useNavigate()
const location = useLocation()
const [tab, setTab] = useState(0)
const params = useParseQueryParams()
if (!primary) {
return null
}
const updateSeparate = (separate: boolean) => {
if (!location.pathname.includes('coursestatistics')) {
return
}
const { courses, ...params } = parseQueryParams(location.search)
const query = {
const newQueryParams = {
...params,
courses: JSON.parse(courses as string),
separate,
}
const queryToString = { ...query, courses: JSON.stringify(query.courses) }
void navigate({ search: queryParamsToString(queryToString) }, { replace: true })
void navigate({ search: queryParamsToString(newQueryParams) }, { replace: true })
}
return (
@@ -86,7 +86,7 @@ export const AttemptsTable = ({
const queryObject = {
from: yearCode,
to: yearCode,
coursecodes: JSON.stringify(courseCodes),
courses: courseCodes,
separate,
unifyCourses: openOrRegular,
includeSubstitutions: combineSubstitutions,
@@ -62,6 +62,7 @@ export const StudentsTable = ({
combineSubstitutions: boolean
courseCodes: string[]
}) => {
'use memo'
const [columnVisibility, setColumnVisibility] = useState<Record<string, boolean>>({})
const showPopulation = useCallback(
@@ -69,7 +70,7 @@ export const StudentsTable = ({
const queryObject = {
from: yearCode,
to: yearCode,
coursecodes: JSON.stringify(courseCodes),
courses: courseCodes,
separate,
unifyCourses: openOrRegular,
includeSubstitutions: combineSubstitutions,
@@ -5,7 +5,7 @@ import Tooltip from '@mui/material/Tooltip'
import { difference, flatten, max, min, pickBy, uniq } from 'lodash-es'
import { useEffect, useState } from 'react'
import { useLocation, useNavigate } from 'react-router'
import { useNavigate } from 'react-router'
import { ProgrammeDropdown } from '@/components/CourseStatistics/ProgrammeDropdown'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
@@ -17,32 +17,26 @@ import { countTotalStats } from '@/pages/CourseStatistics/CourseTab/SingleCourse
import { ResultTabs } from '@/pages/CourseStatistics/CourseTab/SingleCourseStats/ResultTabs'
import { YearFilter } from '@/pages/CourseStatistics/CourseTab/SingleCourseStats/YearFilter'
import { ALL, CourseStudyProgramme } from '@/pages/CourseStatistics/util'
import { useGetCourseStatsQuery } from '@/redux/courseStats'
import { useAppDispatch } from '@/redux/hooks'
import { useGetMaxYearsToCreatePopulationFromQuery } from '@/redux/populations'
import { setSelectedCourse, clearSelectedCourse } from '@/redux/selectedCourse'
import { DoNotDisturbIcon } from '@/theme'
import { AvailableStats, FormattedStats, ProgrammeStats } from '@/types/courseStat'
import { DropdownOption } from '@/types/dropdownOption'
import { parseQueryParams, queryParamsToString } from '@/util/queryparams'
import { useParseQueryParams, queryParamsToString } from '@/util/queryparams'
import { Name } from '@oodikone/shared/types'
import { enrollmentTimeDateThresholdYearCode, yearToYearCode } from '@oodikone/shared/util'
import { Attempts, Enrollment, Students } from '@oodikone/shared/types/courseYearlyStats'
import { Attempts, CourseStat, Enrollment, Students } from '@oodikone/shared/types/courseYearlyStats'
const countFilteredStudents = (stat: Record<string, string[]>, filter: (studentNumber: string) => boolean) => {
if (!stat) {
return {} as Record<string, number>
const countFilteredStudents = (
stat: Record<string, string[]>,
filter: (studentNumber: string) => boolean
): Record<string, number> => {
if (!stat) return {}
const result = {}
for (const [category, students] of Object.entries(stat)) {
result[category] = students.filter(filter).length
}
return Object.entries(stat).reduce(
(acc, entry) => {
const [category, students] = entry
return {
...acc,
[category]: students.filter(filter).length,
}
},
{} as Record<string, number>
)
return result
}
export const SingleCourseStats = ({
@@ -50,67 +44,68 @@ export const SingleCourseStats = ({
availableStats,
userHasAccessToAllStats,
stats,
loading,
toggleOpenAndRegularCourses,
openOrRegular,
programmes,
combineSubstitutions,
substitutions,
toYearCode,
fromYearCode,
setToYearCode,
setFromYearCode,
}: {
courseGroupId: string
availableStats: AvailableStats
userHasAccessToAllStats: boolean
stats: Record<string, CourseStat>
loading: boolean
toggleOpenAndRegularCourses: (state: CourseSearchState) => void
openOrRegular: CourseSearchState
programmes: CourseStudyProgramme[]
combineSubstitutions: boolean
substitutions: boolean
toYearCode: number
fromYearCode: number
setToYearCode: any
setFromYearCode: any
}) => {
'use memo'
const [primary, setPrimary] = useState<string[]>([ALL.value])
const [comparison, setComparison] = useState<string[]>([])
// These are "incorrectly" reversed to allow them to be replaced with min/max when stats arrive
const [minFromYearCode, setMinFromYearCode] = useState(yearToYearCode(new Date().getFullYear()))
const [maxToYearCode, setMaxToYearCode] = useState(yearToYearCode(1950))
const [fromYearCode, setFromYearCode] = useState(yearToYearCode(1950))
const [toYearCode, setToYearCode] = useState(yearToYearCode(new Date().getFullYear()))
const [separate, setSeparate] = useState<boolean>(false)
const navigate = useNavigate()
const location = useLocation()
const dispatch = useAppDispatch()
const { getTextIn } = useLanguage()
const { semesters, years: semesterYears } = useSemesters()
const {
data: courseStatistics,
isFetching: isLoading,
isSuccess,
} = useGetCourseStatsQuery(
{
courses: [courseGroupId],
separate,
combineSubstitutions,
fromYearCode: fromYearCode.toString(),
toYearCode: toYearCode.toString(),
},
{ skip: loading }
)
const params = useParseQueryParams()
const separate = params.separate?.[0] === 'true'
const courseStats = stats[courseGroupId]
const minYearCode = min(courseStats.statistics.map(r => r.yearCode))!
const maxYearCode = max(courseStats.statistics.map(r => r.yearCode))!
const [minFromYearCode, setMinFromYearCode] = useState(minYearCode)
const [maxToYearCode, setMaxToYearCode] = useState(maxYearCode)
// Initialize filters with proper yearcodes
useEffect(() => {
setFromYearCode(minYearCode)
setToYearCode(maxYearCode)
}, [])
const uniqueCourseCodes = [
...new Set(
[courseGroupId].concat(
courseStatistics?.[courseGroupId]?.[openOrRegular]?.substitutionGroups.flatMap(group =>
group.flatMap(({ code }) => code)
) ?? []
courseStats?.substitutionGroups.flatMap(group => group.flatMap(({ groupId }) => groupId)) ?? []
)
),
]
const { data: maxYears } = useGetMaxYearsToCreatePopulationFromQuery(
{ courseCodes: JSON.stringify(uniqueCourseCodes) },
{ skip: isLoading || !isSuccess }
)
const { data: maxYears } = useGetMaxYearsToCreatePopulationFromQuery({
courseCodes: JSON.stringify(uniqueCourseCodes),
})
const semestersReversed = Object.values(semesters ?? [])
.map(({ semestercode, name, yearcode }) => ({
@@ -128,16 +123,6 @@ export const SingleCourseStats = ({
}))
.reverse()
const parseQueryFromUrl = () => {
const { separate } = parseQueryParams(location.search)
return {
separate: JSON.parse((separate ?? 'false') as string),
}
}
// Undefined if query is still querying
const stats = courseStatistics?.[courseGroupId]?.[openOrRegular]
let maxYearsToCreatePopulationFrom = 0
if (maxYears) {
switch (openOrRegular) {
@@ -152,36 +137,6 @@ export const SingleCourseStats = ({
}
}
useEffect(() => {
if (location.search) {
const { separate } = parseQueryFromUrl()
setSeparate([true, false].includes(separate) ? separate : false)
}
dispatch(setSelectedCourse(courseGroupId))
const yearCodes = stats?.statistics.map(stat => stat.yearCode)
const initFromYear = min(yearCodes) ?? yearToYearCode(1950)
const initToYear = max(yearCodes) ?? yearToYearCode(new Date().getFullYear())
setFromYearCode(initFromYear)
setToYearCode(initToYear)
if (yearCodes) {
setMaxToYearCode(max([maxToYearCode, ...yearCodes]))
setMinFromYearCode(min([minFromYearCode, ...yearCodes]))
}
return () => {
dispatch(clearSelectedCourse())
}
}, [isSuccess])
useEffect(() => {
if (location.search) {
const { separate } = parseQueryFromUrl()
setSeparate([true, false].includes(separate) ? separate : false)
}
}, [location.search])
useEffect(() => {
if (primary.every(course => !programmes.map(programme => programme.key).includes(course))) {
setPrimary([ALL.value])
@@ -195,7 +150,7 @@ export const SingleCourseStats = ({
if (programmeCode === 'EXCLUDED') {
return 'Excluded'
}
const name = stats?.programmes[programmeCode]['name']
const name = courseStats?.programmes[programmeCode]['name']
return getTextIn(name)!
}
@@ -216,7 +171,7 @@ export const SingleCourseStats = ({
return () => true
}
const programmes = stats?.programmes
const programmes = courseStats?.programmes
const studentNumbers = new Set()
codes.forEach(code => {
if (programmes?.[code]) {
@@ -229,24 +184,18 @@ export const SingleCourseStats = ({
}
const isValidProgrammeCode = (code: string) => {
return stats?.programmes[code] || code === ALL.value || code === 'EXCLUDED'
return courseStats?.programmes[code] || code === ALL.value || code === 'EXCLUDED'
}
const filteredYearsAndSemesters = () => {
const getFilteredYears = () => {
const from = minFromYearCode
const to = maxToYearCode
if (from == null || to == null) {
return {
filteredYears: semesterYearsReversed,
filteredSemesters: semestersReversed,
}
if (!from || !to) {
return semesterYearsReversed
}
const timeFilter = ({ value }: { value: number }) => value >= from && value <= to
return {
filteredYears: semesterYearsReversed.filter(timeFilter),
filteredSemesters: semestersReversed.filter(timeFilter),
}
return semesterYearsReversed.filter(timeFilter)
}
const isStatInYearRange = ({ name }: { name: Name | string }) => {
@@ -334,10 +283,10 @@ export const SingleCourseStats = ({
}
const statsForProgrammes = (programmeCodes: string[], name: string) => {
if (programmeCodes.length === 0 || !stats) {
if (programmeCodes.length === 0 || !courseStats) {
return undefined
}
const { statistics } = stats
const { statistics } = courseStats
const filter = belongsToAtLeastOneProgramme(programmeCodes)
const allStudents = statistics.filter(isStatInYearRange).reduce(
@@ -375,7 +324,7 @@ export const SingleCourseStats = ({
const parsedName = separate ? getTextIn(name as Name)! : name
return {
name: parsedName,
name: getTextIn(parsedName) ?? '',
students: { ...studentStats, ...studentsEnrollments },
attempts: { ...attemptStats, totalEnrollments },
enrollments: filteredEnrollments,
@@ -483,10 +432,10 @@ export const SingleCourseStats = ({
const queryObject = {
from: fromYearCode,
to: toYearCode,
coursecodes: JSON.stringify([courseGroupId]),
courses: courseGroupId,
separate,
unifyCourses: openOrRegular,
includeSubstitutions: combineSubstitutions,
substitutions: substitutions,
}
const searchString = queryParamsToString(queryObject)
void navigate(`/coursepopulation?${searchString}`)
@@ -504,7 +453,7 @@ export const SingleCourseStats = ({
}
const statistics = filteredProgrammeStatistics()
const { filteredYears } = filteredYearsAndSemesters()
const filteredYears = getFilteredYears()
const timeFilter = (_, value: string) => Number(value) >= fromYearCode && Number(value) <= toYearCode
const filteredProgrammes = programmes
@@ -514,7 +463,7 @@ export const SingleCourseStats = ({
})
.filter(programme => programme.size > 0)
if (!stats?.statistics || stats.statistics.length < 1) {
if (!courseStats?.statistics.length) {
return <Section>No data for selected course</Section>
}
@@ -588,12 +537,12 @@ export const SingleCourseStats = ({
</Stack>
</Section>
) : null}
{isLoading ? (
{loading ? (
<LoadingSkeleton />
) : (
<ResultTabs
availableStats={availableStats}
combineSubstitutions={combineSubstitutions}
combineSubstitutions={substitutions}
comparison={statistics.comparison}
courseCodes={[courseGroupId]}
loading={loading}
@@ -24,9 +24,13 @@ export const CourseTab = ({
openOrRegular,
stats,
availableStats,
combineSubstitutions,
substitutions,
substitutionGroups,
programmes,
toYearCode,
fromYearCode,
setToYearCode,
setFromYearCode,
}: {
selected: string | undefined
setSelected: (courseCode: string) => void
@@ -37,9 +41,14 @@ export const CourseTab = ({
openOrRegular: CourseSearchState
stats: Record<string, CourseStat>
availableStats: AvailableStats
combineSubstitutions: boolean
substitutions: boolean
substitutionGroups: { code: string; name: Name; groupId: string }[][]
programmes: CourseStudyProgramme[]
toYearCode: number
fromYearCode: number
setToYearCode: any
setFromYearCode: any
}) => {
'use memo'
const { getTextIn } = useLanguage()
@@ -52,7 +61,7 @@ export const CourseTab = ({
groupId,
}))
if (courses.length === 0) return null
if (!courses.length) return null
const multipleCourses = courses.length > 1
return (
@@ -68,30 +77,37 @@ export const CourseTab = ({
name={getTextIn(stats[selected].name)!}
/>
</Box>
{substitutionGroups.length ? (
{substitutionGroups.length && (
<>
<Typography component="h6" variant="subtitle2">
Substitution groups
{`Substitution${!substitutions ? 's not enabled' : ' groups'}`}
</Typography>
<Grid container spacing={1}>
{substitutionGroups.map(group => (
<SecondaryCourseLabel getTextIn={getTextIn} group={group} key={JSON.stringify(group)} />
))}
</Grid>
{substitutions && (
<Grid container spacing={1}>
{substitutionGroups.map(group => (
<SecondaryCourseLabel getTextIn={getTextIn} group={group} key={JSON.stringify(group)} />
))}
</Grid>
)}
</>
) : null}
)}
</Stack>
</Stack>
</Section>
<SingleCourseStats
availableStats={availableStats}
combineSubstitutions={combineSubstitutions}
substitutions={substitutions}
courseGroupId={selected}
loading={loading}
stats={stats}
openOrRegular={openOrRegular}
programmes={programmes}
toggleOpenAndRegularCourses={toggleOpenAndRegularCourses}
userHasAccessToAllStats={userHasAccessToAllStats}
toYearCode={toYearCode}
fromYearCode={fromYearCode}
setToYearCode={setToYearCode}
setFromYearCode={setFromYearCode}
/>
</Stack>
)
@@ -33,9 +33,10 @@ export const SearchForm = () => {
'use memo'
const { getTextIn } = useLanguage()
const navigate = useNavigate()
const [combineSubstitutions, toggleCombineSubstitutions] = useToggle(true)
const [substitutions, toggleSubstitutions] = useToggle(true)
const [selectMultipleCourses, toggleSelectMultipleCourses] = useToggle(false)
const [includeSpecialCourses, setIncludeSpecialCourses] = useToggle(false)
const [includeSpecialCourses, toggleIncludeSpecialCourses] = useToggle(false)
const [courseName, setCourseName] = useState('')
const [courseCode, setCourseCode] = useState('')
const [debouncedCourseName, setDebouncedCourseName] = useDebouncedState('')
@@ -82,11 +83,9 @@ export const SearchForm = () => {
}
const pushQueryToUrl = query => {
const { courses, ...rest } = query
const queryObject = {
...rest,
courses: JSON.stringify(courses),
combineSubstitutions: JSON.stringify(combineSubstitutions),
...query,
substitutions,
}
const searchString = queryParamsToString(queryObject)
void navigate({ search: searchString })
@@ -101,7 +100,7 @@ export const SearchForm = () => {
const params = {
courses: groupIds,
separate: false,
combineSubstitutions,
substitutions,
}
const searchHistoryText = groupIds.map(
groupId => `${getTextIn(selectedCourses[groupId].name)} ${selectedCourses[groupId].code}`
@@ -115,16 +114,15 @@ export const SearchForm = () => {
// If course clicked on single course mode, call submit hook
useEffect(() => {
if (Object.keys(selectedCourses).length === 0 || selectMultipleCourses) {
return
if (!selectMultipleCourses && Object.keys(selectedCourses).length === 1) {
onSubmitFormClick()
}
onSubmitFormClick()
}, [selectedCourses])
// Reset state after toggle
useEffect(() => {
setSelectedCourses({})
}, [combineSubstitutions, selectMultipleCourses])
}, [substitutions, selectMultipleCourses])
const courses = matchingCourses
.filter(course => !selectedCourses[course.groupId])
@@ -145,7 +143,7 @@ export const SearchForm = () => {
const addAllCourses = () => {
const newSelectedCourses = courses.reduce<Record<string, SearchResultCourse>>((newSelected, course) => {
newSelected[course.code] = { ...course }
newSelected[course.groupId] = course
return newSelected
}, {})
@@ -207,10 +205,10 @@ export const SearchForm = () => {
onChange={toggleSelectMultipleCourses}
/>
<ToggleWithTooltip
checked={combineSubstitutions}
checked={substitutions}
cypress="combine-substitutions-toggle"
label="Combine substitutions"
onChange={toggleCombineSubstitutions}
onChange={toggleSubstitutions}
tooltipText={getTextIn({
fi: 'Jos "Combine substitutions" on valittuna (oletuksena), niin kurssi ja leikkaavat kurssit yhdistetään tilastoissa.',
en: 'If "Combine substitutions" is on (default behavior), then course and its substitutions are combined in the statistics.',
@@ -220,7 +218,7 @@ export const SearchForm = () => {
checked={includeSpecialCourses}
cypress="show-special-courses-toggle"
label="Show special courses"
onChange={setIncludeSpecialCourses}
onChange={toggleIncludeSpecialCourses}
tooltipText={getTextIn({
fi: 'Sisällyttää hakuun hyväksiluettujen suoritusten pohjalta luodut kurssit',
en: 'Includes course instances formed by transferred credits and other special courses',
@@ -240,7 +238,7 @@ export const SearchForm = () => {
{selectMultipleCourses ? (
<Stack spacing={1} sx={{ mb: 2 }}>
<CourseTable
combineSubstitutions={combineSubstitutions}
combineSubstitutions={substitutions}
courses={selected}
hidden={noSelectedCourses}
onSelectCourse={onSelectCourse}
@@ -255,7 +253,7 @@ export const SearchForm = () => {
</Stack>
) : null}
<CourseTable
combineSubstitutions={combineSubstitutions}
combineSubstitutions={substitutions}
courses={courses}
hidden={false}
onSelectCourse={onSelectCourse}
@@ -5,7 +5,6 @@ import Tab from '@mui/material/Tab'
import Tabs from '@mui/material/Tabs'
import { useEffect, useState } from 'react'
import { useLocation } from 'react-router'
import { PageLayout } from '@/components/common/PageLayout'
import { PageTitle } from '@/components/common/PageTitle'
@@ -27,11 +26,13 @@ import {
import { useGetAuthorizedUserQuery } from '@/redux/auth'
import { useGetCourseStatsQuery } from '@/redux/courseStats'
import { checkUserAccess, getFullStudyProgrammeRights, hasAccessToAllCourseStats } from '@/util/access'
import { parseQueryParams } from '@/util/queryparams'
import { useParseQueryParams } from '@/util/queryparams'
import { yearToYearCode } from '@oodikone/shared/util'
export type CourseSearchState = 'openStats' | 'regularStats' | 'unifyStats'
// TODO: This view would probably benefit from using a context
// (We do _a little bit_ of prop drilling)
export const CourseStatistics = () => {
'use memo'
const { programmeRights, roles } = useGetAuthorizedUserQuery()
@@ -39,41 +40,48 @@ export const CourseStatistics = () => {
const userHasAccessToAllStats = hasAccessToAllCourseStats(roles, fullStudyProgrammeRights)
const [courseSummaryFormProgrammes, setCourseSummaryFormProgrammes] = useState<string[]>([ALL.value])
const [openOrRegular, toggleOpenAndRegularCourses] = useState<CourseSearchState>('unifyStats')
const [openOrRegular, setOpenOrRegular] = useState<CourseSearchState>('unifyStats')
const [tab, setTab] = useTabs(/* max tabs */ 3)
const location = useLocation()
const { courses, separate, combineSubstitutions } = parseQueryParams(location.search)
const [fromYearCode, setFromYearCode] = useState(1)
const [toYearCode, setToYearCode] = useState(yearToYearCode(new Date().getFullYear()))
const courseGroupIds: string[] = JSON.parse(courses ?? '[]')
const [initialCourseCode] = courseGroupIds
const singleCourseStats = courseGroupIds.length === 1
const { courses, separate, substitutions } = useParseQueryParams()
const initialCourseCode = courses?.[0] ?? ''
const singleCourseStats = courses?.length === 1
const [selected, setSelected] = useState<string>('')
const [selected, setSelected] = useState(initialCourseCode)
const shouldShowSearchForm = !initialCourseCode
const coursesKey = courses?.join(',') ?? 'empty'
// Prevent state from becoming stale
useEffect(() => {
setSelected(current => {
if (courses?.includes(current)) return current
return courses?.[0] ?? ''
})
}, [coursesKey])
const noCourseProvided = !initialCourseCode
const {
data: courseStatsData = {},
isFetching: isLoading,
isFetching,
isLoading,
isSuccess,
} = useGetCourseStatsQuery(
{
courses: courseGroupIds,
separate: separate === 'true',
combineSubstitutions: combineSubstitutions === 'true',
fromYearCode: '1',
toYearCode: yearToYearCode(new Date().getFullYear()).toString(),
courses: courses!,
separate: separate?.[0] === 'true',
substitutions: substitutions?.[0] === 'true',
fromYearCode: fromYearCode.toString(),
toYearCode: toYearCode.toString(),
},
{ skip: shouldShowSearchForm }
{ skip: noCourseProvided }
)
useEffect(() => {
setSelected(initialCourseCode)
}, [initialCourseCode])
useTitle(
selected && !isLoading
selected && !isFetching
? `${courseStatsData[selected]?.regularStats.courseCode} - Course statistics`
: 'Course statistics'
)
@@ -82,7 +90,7 @@ export const CourseStatistics = () => {
return <AccessDeniedMessage />
}
if (shouldShowSearchForm)
if (noCourseProvided)
return (
<PageLayout maxWidth="lg">
<PageTitle title="Course statistics" />
@@ -103,14 +111,10 @@ export const CourseStatistics = () => {
const availableStats = getAvailableStats(courseStatsData)
const switchToCourse = (groupId: string) => {
setSelected(groupId)
}
return (
<PageLayout maxWidth="lg">
<Backdrop
open={isLoading}
open={isLoading || !stats[selected]}
sx={theme => ({ color: theme.palette.grey[300], zIndex: theme => theme.zIndex.drawer + 1 })}
>
<CircularProgress color="inherit" size="3em" />
@@ -128,21 +132,25 @@ export const CourseStatistics = () => {
<CourseTab
substitutionGroups={substitutionGroups}
availableStats={availableStats[selected]}
combineSubstitutions={combineSubstitutions === 'true'}
loading={isLoading || !isSuccess}
substitutions={substitutions?.[0] === 'true'}
loading={isFetching || !isSuccess}
openOrRegular={openOrRegular}
programmes={programmes}
selected={selected}
setSelected={setSelected}
stats={stats}
toggleOpenAndRegularCourses={toggleOpenAndRegularCourses}
toggleOpenAndRegularCourses={setOpenOrRegular}
userHasAccessToAllStats={userHasAccessToAllStats}
toYearCode={toYearCode}
fromYearCode={fromYearCode}
setToYearCode={setToYearCode}
setFromYearCode={setFromYearCode}
/>
)}
{tab === 1 && !singleCourseStats && (
<SummaryTab
courseSummaryFormProgrammes={courseSummaryFormProgrammes}
onClickCourse={switchToCourse}
onClickCourse={groupId => setSelected(groupId)}
programmes={programmes}
setCourseSummaryFormProgrammes={setCourseSummaryFormProgrammes}
statistics={summaryStatistics}
+3 -3
View File
@@ -7,18 +7,18 @@ const courseStatsApi = RTKApi.injectEndpoints({
query: ({
courses,
separate,
combineSubstitutions,
substitutions,
fromYearCode,
toYearCode,
}: {
courses: string[]
separate?: boolean
combineSubstitutions?: boolean
substitutions?: boolean
fromYearCode: string
toYearCode: string
}) => ({
url: '/courseyearlystats',
params: { courses, separate, combineSubstitutions, fromYearCode, toYearCode },
params: { courses, separate, substitutions, fromYearCode, toYearCode },
}),
transformResponse: (courseStats: CourseYearlyStats[]) => {
const data: Record<string, CourseYearlyStats> = {}
+18 -7
View File
@@ -1,12 +1,23 @@
export const parseQueryParams = <T extends object = Record<any, any>>(input: string): T => {
const params = new URLSearchParams(input)
import { useLocation } from 'react-router'
const construct = Array.from(params.keys())
.map(key => [key, params.getAll(key)])
// This is done on purpose to mimic query-strings behavior
.map(([key, val]) => [key, val.length <= 1 ? val[0] : val])
/** Reactive query param parser.
*
* @returns all query parameters in object, destructure as needed.
*
* By default all values are string arrays. Array can also be destuctured as follows.
*
* @example
* const args = useParseQueryParams()
* const string1 = args.item1?.[0]
* const string2 = [args.item2!]
* const arrayType = args.noDestructuring
*/
export const useParseQueryParams = (): Record<string, undefined | string[]> => {
const location = useLocation()
const searchString = location.search
const params = new URLSearchParams(searchString)
return Object.fromEntries(construct)
return Object.fromEntries([...params.keys()].map(key => [key, params.getAll(key)]))
}
const setQueryParams = (input: Record<any, any>) => {
+2 -2
View File
@@ -11,9 +11,9 @@ export type CoursesMultiQuery = {
export type CourseYearlyStatsReqBody = never
export type CourseYearlyStatsQuery = {
courses: string[]
courses: string | string[] // Non-array when only one course
separate: string
combineSubstitutions: string
substitutions: string
fromYearCode: string
toYearCode: string
}