Merge pull request #4948 from UniversityOfHelsinkiCS/progress-chart-graduated-category

Progress chart graduated category
This commit is contained in:
Aaro Koivukangas
2026-01-23 12:46:20 +02:00
committed by GitHub
13 changed files with 138 additions and 52 deletions
+15 -13
View File
@@ -398,30 +398,32 @@ describe('Degree programme overview', () => {
it('Student progress data is shown correctly', () => {
const years = getEmptyYears(true)
const tableContents = [
// [Year, All, < 30 credits, 30–60 credits, 60–90 credits, 90–120 credits, 120–150 credits, 150–180 credits, ≥ 180 credits]
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0]),
['2023 - 2024', 8, 8, 0, 0, 0, 0, 0, 0],
['2022 - 2023', 26, 9, 9, 4, 3, 0, 1, 0],
['2021 - 2022', 38, 8, 6, 11, 8, 5, 0, 0],
['2020 - 2021', 30, 2, 1, 3, 7, 4, 4, 9],
['2019 - 2020', 35, 1, 0, 2, 1, 0, 4, 27],
['2018 - 2019', 45, 0, 1, 1, 2, 0, 3, 38],
['2017 - 2018', 47, 0, 1, 3, 0, 1, 2, 40],
['Total', 229, 28, 18, 24, 21, 10, 14, 114],
// [Year, All, < 30 credits, 30–60 credits, 60–90 credits, 90–120 credits, 120–150 credits, 150–180 credits, ≥ 180 credits, Graduated]
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
['2023 - 2024', 8, 8, 0, 0, 0, 0, 0, 0, 0],
['2022 - 2023', 26, 9, 8, 4, 3, 0, 0, 0, 2],
['2021 - 2022', 38, 8, 4, 9, 8, 4, 0, 0, 5],
['2020 - 2021', 30, 2, 1, 2, 5, 0, 0, 1, 19],
['2019 - 2020', 35, 1, 0, 2, 1, 0, 0, 4, 27],
['2018 - 2019', 45, 0, 1, 1, 2, 0, 0, 2, 39],
['2017 - 2018', 47, 0, 1, 3, 0, 0, 1, 0, 42],
['Total', 229, 28, 15, 21, 19, 4, 1, 7, 134],
]
cy.checkTableStats(tableContents, 'study-programme-progress')
})
it('Progress section', () => {
cy.cs('programme-progress-bar-chart-section').scrollIntoView()
cy.cs('programme-progress-bar-chart-section')
.should('contain', 'Less than 30 credits')
.should('contain', '30–60 credits')
.should('contain', 'At least 180 credits')
.should('contain', '49.8%') // The percentage for total, at least 180 credits, to check that the graph renders
.should('contain', '58.5%') // The percentage for total graduated, to check that the graph renders
cy.cs('programme-progress-bar-chart-section').contains('49.8%').trigger('mouseover', { force: true })
cy.contains('At least 180 credits: 114')
cy.cs('programme-progress-bar-chart-section').contains('58.5%').trigger('mouseover', { force: true })
cy.contains('Graduated: 134')
})
it('Average graduation times section', () => {
+14
View File
@@ -71,6 +71,12 @@ router.get('/allprogressstats', async (req: GetProgressStatsRequest, res: Respon
master: {} as Record<string, number[]>,
doctor: {} as Record<string, number[]>,
},
graduatedCount: {
bachelor: {} as Record<string, number>,
bachelorMaster: {} as Record<string, number>,
master: {} as Record<string, number>,
doctor: {} as Record<string, number>,
},
}
const unifyProgressStats = (progStats: number[][][]) => {
@@ -96,6 +102,14 @@ router.get('/allprogressstats', async (req: GetProgressStatsRequest, res: Respon
if (!universityData.creditCounts[fieldName][year]) universityData.creditCounts[fieldName][year] = []
universityData.creditCounts[fieldName][year].push(...facultyData.creditCounts[fieldName][year])
}
for (const fieldName of degreeNames) {
if (!facultyData.graduatedCount[fieldName] || Object.keys(facultyData.graduatedCount[fieldName]).length === 0)
continue
if (!universityData.graduatedCount[fieldName][year]) universityData.graduatedCount[fieldName][year] = 0
universityData.graduatedCount[fieldName][year] += facultyData.graduatedCount[fieldName][year]
}
const progStats = ['bachelorsProgStats', 'bcMsProgStats', 'mastersProgStats', 'doctoralProgStats'] as const
for (const fieldName of progStats) {
if (Object.keys(facultyData[fieldName] || {}).length === 0) continue
@@ -107,6 +107,7 @@ export const combineFacultyStudentProgress = async (
const statsOfProgrammes: Array<Awaited<ReturnType<typeof getStudyTrackStatsForStudyProgramme>>> = []
const allDegreeProgrammes = await getDegreeProgrammesOfOrganization(rootOrgId, false)
const creditCounts: Record<string, Record<string, number[]>> = {}
const graduatedCount: Record<string, Record<string, number>> = {}
const progressStats: Record<string, Record<string, number[][]>> = {}
const yearlyTitles: Record<string, string[][]> = {}
@@ -137,12 +138,14 @@ export const combineFacultyStudentProgress = async (
}
}
const updateCreditCounts = (
const updateCreditAndGraduatedCount = (
level: 'bachelor' | 'bcMs' | 'master' | 'doctor',
creditCountsOfProgramme: Record<string, number[]>
creditCountsOfProgramme: Record<string, number[]>,
graduatedCountOfProgramme: Record<string, number>
) => {
if (!(level in creditCounts)) {
creditCounts[level] = cloneDeep(creditCountsOfProgramme)
graduatedCount[level] = cloneDeep(graduatedCountOfProgramme)
} else {
for (const year of Object.keys(creditCountsOfProgramme)) {
if (!(year in creditCounts[level])) {
@@ -151,6 +154,10 @@ export const combineFacultyStudentProgress = async (
creditCounts[level][year].push(...creditCountsOfProgramme[year])
}
}
for (const year of Object.keys(graduatedCountOfProgramme)) {
graduatedCount[level][year] = cloneDeep(graduatedCountOfProgramme[year])
}
}
}
@@ -163,11 +170,11 @@ export const combineFacultyStudentProgress = async (
const { degreeProgrammeType, progId } = programmeInfo
const level = programmeTypes[degreeProgrammeType]
updateCreditCounts(level, stats.creditCounts)
updateCreditAndGraduatedCount(level, stats.creditCounts, stats.graduatedCount)
calculateProgressStats(level, stats.creditCounts, yearlyTitles, progressStats, progId)
if (level === 'master' && Object.keys(stats.creditCountsCombo).length > 0) {
updateCreditCounts('bcMs', stats.creditCountsCombo)
updateCreditAndGraduatedCount('bcMs', stats.creditCountsCombo, stats.graduatedCount)
calculateProgressStats('bcMs', stats.creditCountsCombo, yearlyTitles, progressStats, progId)
}
}
@@ -180,6 +187,7 @@ export const combineFacultyStudentProgress = async (
bcMsProgStats: progressStats.bcMs,
doctoralProgStats: progressStats.doctor,
creditCounts: {} as Record<string, Record<string, number[]>>,
graduatedCount: {} as Record<string, Record<string, number>>,
yearlyBachelorTitles: yearlyTitles.bachelor,
yearlyBcMsTitles: yearlyTitles.bcMs,
yearlyMasterTitles: yearlyTitles.master,
@@ -202,6 +210,14 @@ export const combineFacultyStudentProgress = async (
}
result.creditCounts[levelName][year] = creditCounts[level][year]
}
for (const year of Object.keys(graduatedCount[level]).toSorted(sortByYear)) {
const levelName = level === 'bcMs' ? 'bachelorMaster' : level
if (!(levelName in result.graduatedCount)) {
result.graduatedCount[levelName] = {}
}
result.graduatedCount[levelName][year] = graduatedCount[level][year]
}
}
return result
@@ -338,9 +338,6 @@ const getMainStatsByTrackAndYear = async (
if (!studyRightElement) {
continue
}
if (!includeGraduated && studyRightElement.graduated) {
continue
}
const [hasTransferredFromProgramme, hasTransferredToProgramme] = hasTransferredFromOrToProgramme(
studyRight,
@@ -378,15 +375,18 @@ const getMainStatsByTrackAndYear = async (
}
const countAsBachelorMaster = doCombo && studyRight.extentCode === ExtentCode.BACHELOR_AND_MASTER
if (countAsBachelorMaster) {
const startedInBachelor = getStudyRightElementsWithPhase(studyRight, 1)[0]?.startDate
if (startedInBachelor && years.includes(defineYear(startedInBachelor, true))) {
creditCountsCombo[defineYear(startedInBachelor, true)].push(
getCreditCount(studyRight.student.credits, startedInBachelor)
)
// TODO: FIXME: This could be written better
if (!studyRightElement.graduated) {
if (countAsBachelorMaster) {
const startedInBachelor = getStudyRightElementsWithPhase(studyRight, 1)[0]?.startDate
if (startedInBachelor && years.includes(defineYear(startedInBachelor, true))) {
creditCountsCombo[defineYear(startedInBachelor, true)].push(
getCreditCount(studyRight.student.credits, startedInBachelor)
)
}
} else {
creditCounts[startYear].push(getCreditCount(studyRight.student.credits, startedInProgramme))
}
} else {
creditCounts[startYear].push(getCreditCount(studyRight.student.credits, startedInProgramme))
}
}
@@ -410,6 +410,15 @@ const getMainStatsByTrackAndYear = async (
}
}
const graduatedCount: Record<string, number> = {}
years.forEach(year => {
if (!yearlyStats[year]) {
graduatedCount[year] = 0
} else {
graduatedCount[year] = yearlyStats[year][studyProgramme].graduated
}
})
const { mainStatsByYear, mainStatsByTrack, otherCountriesCount } = combineStats(
years,
yearlyStats,
@@ -438,6 +447,7 @@ const getMainStatsByTrackAndYear = async (
otherCountriesCount,
creditCounts,
creditCountsCombo,
graduatedCount,
graduationTimes: finalGraduationTimes,
graduationTimesSecondProg: finalCombinedGraduationTimes,
}
@@ -484,6 +494,7 @@ export const getStudyTrackStatsForStudyProgramme = async ({
creditCounts: stats.creditCounts,
creditCountsCombo: stats.creditCountsCombo,
doCombo,
graduatedCount: stats.graduatedCount,
graduationTimes: stats.graduationTimes,
graduationTimesSecondProg: stats.graduationTimesSecondProg,
id: combinedProgramme ? `${studyProgramme}-${combinedProgramme}` : studyProgramme,
+6 -1
View File
@@ -316,7 +316,12 @@ export const getCreditCategories = (
const limitBreaks = cumulative
? creditCategoryArray.map(num => Math.round(minCredits + (num * (maxCredits - minCredits)) / creditCategoryAmount))
: [15, 30, 45, 60].map(limit => limit * (timeDivision === TimeDivision.SEMESTER ? 0.5 : 1))
return range(0, limitBreaks.length + 1).map(i => [limitBreaks[i - 1], limitBreaks[i]])
const limits: (number[] | 'Graduated')[] = range(0, limitBreaks.length + 1).map(i => [
limitBreaks[i - 1],
limitBreaks[i],
])
limits.push('Graduated')
return limits
}
export const validateInputLength = (input: string, minLength: number) => input?.trim().length >= minLength
@@ -19,11 +19,18 @@ export const FacultyProgress = ({
progressStats: GetAllProgressStatsResponse | undefined
}) => {
const creditCounts = progressStats?.creditCounts
const graduatedCount = progressStats?.graduatedCount
const bachelorStats = calculateStats(creditCounts?.bachelor, 180)
const bachelorMasterStats = calculateStats(creditCounts?.bachelorMaster, faculty === 'H90' ? 360 : 300, 180, 7)
const masterStats = calculateStats(creditCounts?.master, 120)
const doctorStats = calculateStats(creditCounts?.doctor, 40, 0, 5)
const bachelorStats = calculateStats(creditCounts?.bachelor, graduatedCount?.bachelor, 180)
const bachelorMasterStats = calculateStats(
creditCounts?.bachelorMaster,
graduatedCount?.bachelorMaster,
faculty === 'H90' ? 360 : 300,
180,
7
)
const masterStats = calculateStats(creditCounts?.master, graduatedCount?.master, 120)
const doctorStats = calculateStats(creditCounts?.doctor, graduatedCount?.doctor, 40, 0, 5)
const sortKeys = (stats: Record<string, number[][]>) => {
return sortProgrammeKeys(
@@ -75,8 +75,6 @@ const hasGraduatedBeforeDate = (student, programme, date) => {
return studyRightElement.graduated && date.isAfter(studyRightElement.endDate, 'day')
}
const GRADUATED = Symbol('GRADUATED')
const getChartData = (
students: any[],
timeSlots: any[],
@@ -87,16 +85,14 @@ const getChartData = (
) => {
const programmeCredits = getTargetCreditsForProgramme(programme) + (combinedProgramme ? 180 : 0)
const limits: Array<number[] | typeof GRADUATED> = getCreditCategories(
const limits: Array<number[] | 'Graduated'> = getCreditCategories(
cumulative,
timeDivision,
programmeCredits,
timeSlots.length,
6
)
const colors = generateGradientColors(limits.length)
limits.push(GRADUATED)
const colors = generateGradientColors(limits.length - 1)
colors.push('#ddd') // Color for graduated (grey)
const data: { y: number; custom: { students: number[] } }[][] = limits.map(() =>
@@ -116,9 +112,9 @@ const getChartData = (
const credits = studentCredits[studentIndex][timeSlotIndex]
const rangeIndex = hasGraduated
? limits.indexOf(GRADUATED)
? limits.indexOf('Graduated')
: limits.findIndex(limit => {
if (limit === GRADUATED) {
if (limit === 'Graduated') {
return false
}
@@ -144,7 +140,7 @@ const getChartData = (
const limit = limits[limitN]
let name
if (limit === GRADUATED) {
if (limit === 'Graduated') {
name = 'Graduated'
} else {
const [min, max] = limit
@@ -29,7 +29,8 @@ export const ProgressBarChart = ({
return null
}
const colors = generateGradientColors(Object.keys(data.stats).length)
const colors = generateGradientColors(Object.keys(data.stats).length - 1)
colors.push('#ddd') // For graduated students
const dataWithColors: SeriesColumnOptions[] = Object.values(data.stats).map((series, index) => ({
...series,
color: colors[index],
@@ -39,15 +39,30 @@ export const ProgressSection = ({
)
}
const bachelorStats = calculateStats(progressStats?.data?.creditCounts?.bachelor, 180)
const bachelorStats = calculateStats(
progressStats?.data?.creditCounts?.bachelor,
progressStats?.data?.graduatedCount?.bachelor,
180
)
const bachelorMasterStats = calculateStats(
progressStats?.data?.creditCounts?.bachelorMaster,
progressStats?.data?.graduatedCount?.bachelorMaster,
faculty.code === 'H90' ? 360 : 300,
180,
7
)
const masterStats = calculateStats(progressStats?.data?.creditCounts?.master, 120)
const doctorStats = calculateStats(progressStats?.data?.creditCounts?.doctor, 40, 0, 5)
const masterStats = calculateStats(
progressStats?.data?.creditCounts?.master,
progressStats?.data?.graduatedCount?.master,
120
)
const doctorStats = calculateStats(
progressStats?.data?.creditCounts?.doctor,
progressStats?.data?.graduatedCount?.doctor,
40,
0,
5
)
const hasNonZeroStats = stats => {
const allValuesZero = values => values.every(value => parseFloat(value) === 0)
@@ -85,7 +85,11 @@ export const StudyTracksAndClassStatisticsTab = ({
const programmeCode = combinedProgramme ? `${studyProgramme}-${combinedProgramme}` : studyProgramme
const progressStats = calculateStats(studyTrackStats?.creditCounts, getTargetCreditsForProgramme(programmeCode))
const progressStats = calculateStats(
studyTrackStats?.creditCounts,
studyTrackStats?.graduatedCount,
getTargetCreditsForProgramme(programmeCode)
)
if (progressStats?.chartStats) {
progressStats.chartStats.forEach(creditCategory => {
const [total, ...years] = creditCategory.data
@@ -95,7 +99,11 @@ export const StudyTracksAndClassStatisticsTab = ({
const progressComboStats =
Object.keys(studyTrackStats?.creditCountsCombo ?? {}).length > 0
? calculateStats(studyTrackStats?.creditCountsCombo, getTargetCreditsForProgramme(programmeCode) + 180)
? calculateStats(
studyTrackStats?.creditCountsCombo,
studyTrackStats?.graduatedCount,
getTargetCreditsForProgramme(programmeCode) + 180
)
: null
if (progressComboStats?.chartStats) {
@@ -76,6 +76,7 @@ export type GetFacultyProgressStatsResponse = {
bachelorsProgStats: Record<string, number[][]>
bcMsProgStats: Record<string, number[][]>
creditCounts: Record<string, Record<string, number[]>>
graduatedCount: Record<string, Record<string, number>>
doctoralProgStats: Record<string, number[][]>
id: string
mastersProgStats: Record<string, number[][]>
@@ -101,6 +102,7 @@ export type GetAllProgressStatsResponse = {
bachelorsProgStats: Record<string, number[][]>
bcMsProgStats: Record<string, number[][]>
creditCounts: Record<string, Record<string, number[]>>
graduatedCount: Record<string, Record<string, number>>
doctoralProgStats: Record<string, number[][]>
mastersProgStats: Record<string, number[][]>
programmeNames: Record<string, NameWithCode>
+12 -4
View File
@@ -52,6 +52,7 @@ const isBetween = (number: number, lowerLimit: number, upperLimit: number) => {
export const calculateStats = (
creditCounts: Record<string, number[]> | undefined,
graduatedCount: Record<string, number> | undefined,
maximumAmountOfCredits: number,
minimumAmountOfCredits = 0,
numberOfCreditCategories = 7
@@ -75,18 +76,25 @@ export const calculateStats = (
)
const tableTitles = ['', 'All']
for (const limit of limits) {
if (limit[0] === undefined) tableTitles.push(`< ${limit[1]} credits`)
if (limit == 'Graduated') tableTitles.push('Graduated')
else if (limit[0] === undefined) tableTitles.push(`< ${limit[1]} credits`)
else if (limit[1] === undefined) tableTitles.push(`≥ ${limit[0]} credits`)
else tableTitles.push(`${limit[0]}–${limit[1]} credits`)
}
Object.keys(creditCounts).forEach(year => {
const yearGraduatedCount = graduatedCount ? graduatedCount[year] : 0
const yearCreditCount = creditCounts[year]
const yearCounts = [year, yearCreditCount.length]
tableStats.push(yearCounts)
const yearCounts = [year, yearCreditCount.length + yearGraduatedCount]
for (const limit of limits) {
yearCounts.push(yearCreditCount.filter(credits => isBetween(credits, limit[0], limit[1])).length)
if (limit === 'Graduated') {
yearCounts.push(yearGraduatedCount)
} else {
yearCounts.push(yearCreditCount.filter(credits => isBetween(credits, limit[0], limit[1])).length)
}
}
tableStats.push(yearCounts)
})
const totalCounts: Array<number | string> = ['Total']
+1
View File
@@ -89,6 +89,7 @@ export type StudyTrackStats = {
creditCounts: Record<string, number[]>
creditCountsCombo: Record<string, number[]>
doCombo: boolean
graduatedCount: Record<string, number>
graduationTimes: GraduationTimes
graduationTimesSecondProg: GraduationTimes
id: string