Compare commits

...
54 Commits
Author SHA1 Message Date
Santeri Rajamäki 3c367f5b93 [META] Fix Fetchios lint error 2026-04-10 13:34:54 +03:00
Santeri Rajamäki ee2240f457 [META] Update Fetchios return value handling 2026-04-10 13:34:54 +03:00
Santeri Rajamäki fb4ecc7785 [META] Convert Fetchios to use Result 2026-04-10 13:34:54 +03:00
Santeri Rajamäki 594ffb460b [META] Add relative path support for browsers 2026-04-10 13:34:54 +03:00
Santeri Rajamäki d9d7f2ae80 [META] Rearrange fetchios url in one-shots 2026-04-10 13:33:01 +03:00
Santeri Rajamäki 8425643639 [META] Fix fetchios url appending 2026-04-10 13:33:01 +03:00
Santeri Rajamäki 33d5a04dc8 [META] Add node types to tsconfig 2026-04-10 13:33:01 +03:00
Santeri Rajamäki abbecf412e [Shared] Convert fetchios fetch to globalThis.fetch 2026-04-10 13:33:01 +03:00
Santeri Rajamäki 6809c668e0 [META] Change axios to fetchios 2026-04-10 13:33:01 +03:00
Santeri Rajamäki 0db4db47ac [Backend] Convert oriProvider to use module exports instead of CJS module.exports 2026-04-10 13:33:01 +03:00
Santeri Rajamäki ab420572eb [META] Introduce Fetchios 2026-04-10 13:33:01 +03:00
Santeri Rajamäki 657a96abc0 [Frontend] Remove axios dependency 2026-04-10 13:33:01 +03:00
Santeri Rajamäki 1760a165e4 [Frontend] Swap api caller from axios to fetch 2026-04-10 13:33:01 +03:00
Santeri Rajamäki 3f311dee69 [Frontend] Remove legacy RTKApi middleware 2026-04-01 13:57:39 +03:00
Santeri Rajamäki eb6b683296 [Frontend] Convert apiConnections to typescript 2026-04-01 13:56:02 +03:00
Aaro Koivukangas b2153ec3dd [ClassStats][Courses] Add better handling for null substitution_groups (only in test-data) 2026-04-01 13:34:40 +03:00
Aaro Koivukangas 8949e7baf0 [Class stats][Courses] Better substitution group type handling 2026-03-31 15:18:47 +03:00
joonas-a 1fa10f7812 [Scripts] fix incorrect container name 2026-03-31 14:47:33 +03:00
Aaro Koivukangas fbe5db8a43 [E2E] Fix lint errors and e2e tests 2026-03-31 14:42:57 +03:00
Aaro Koivukangas d4c7a4014b [Class stats][Courses] Handle substitution groups correctly + fix
include substitutions button
2026-03-31 14:42:57 +03:00
Santeri Rajamäki 1177c2f848 [OT] Use 0th index in pagination when table data changes 2026-03-31 14:40:53 +03:00
Santeri Rajamäki 0f8d72fd1d [Frontend] Remove manual pagination from OT implementations 2026-03-31 14:36:45 +03:00
Santeri Rajamäki 47e3e2e26e [OT] Fix pagination 2026-03-31 14:36:05 +03:00
Santeri Rajamäki a312a14944 [Frontend] Disallow prop forwarding for all internal StyledTable attributes 2026-03-31 12:01:33 +03:00
joonas-a e3ec33f8dc [Course stat tables] try to add 0s 2026-03-30 16:10:22 +03:00
joonas-a 9c55d3b2ab [Highcharts] fix median time bar chart threshold lines 2026-03-30 16:07:51 +03:00
joonas-a 17ce83660c [e2e] programme overview graduation time fixes 2026-03-30 15:40:59 +03:00
joonas-a 22d3877b8e [Faculty][Graduation times] update graduation time targets in highcharts 2026-03-30 14:58:20 +03:00
joonas-a c60bee34fd [Backend][Faculty] update graduation time targets 2026-03-30 14:51:32 +03:00
joonas-a 15240050b7 [Faculty] graduation time infobox update 2026-03-30 14:31:18 +03:00
joonas-a 7da09c8b02 [Programme overview] update graduation time infoboxes 2026-03-30 14:31:18 +03:00
joonas-a cfb2d4d5c0 [Frontend][Graduation times] replace months with semesters 2026-03-30 14:31:18 +03:00
joonas-a 39b594d795 [Backend] Use semesters with graduation times 2026-03-30 14:31:18 +03:00
Santeri Rajamäki ff5bce1713 [E2E] Scroll coursestatistics data table into view 2026-03-30 10:26:56 +03:00
Santeri Rajamäki 8e288f3b26 [Frontend] Remove columnOrder from course statistics tables 2026-03-30 10:07:06 +03:00
Santeri Rajamäki 4134c8b64b [Frontend] Use manualPagination on course stats tables 2026-03-27 15:47:17 +02:00
Santeri Rajamäki ddc1a22492 [Frontend] Cleanup 2026-03-27 15:47:17 +02:00
Santeri Rajamäki 8ff1cb54dc [Frontend] Convert AttemptsTable to OT 2026-03-27 15:47:17 +02:00
Santeri Rajamäki 777de520f9 [Frontend] Convert StudentsTable to OT 2026-03-27 15:47:17 +02:00
joonas-a a9e325fe72 [Updater] Add transfer info to study rights 2026-03-26 17:41:17 +02:00
joonas-a befc3db3a8 [Filters][Studentnumber] Fix blacklist blacklisting all students always 2026-03-26 17:41:17 +02:00
Santeri Rajamäki e1b8ebd17f [Frontend] Readd EquivalenceGroupChip 2026-03-26 13:13:05 +02:00
Santeri Rajamäki 883129bee0 [Frontend] Unify course page secondary chip with course search table chips 2026-03-26 12:13:51 +02:00
Santeri Rajamäki 18eb04beeb [Frontend] Rework course search table and equivalence group chips 2026-03-26 12:12:19 +02:00
Santeri Rajamäki eb12d7e5ec [OT] Add cell width under size attribute 2026-03-26 12:08:56 +02:00
Santeri Rajamäki 26ca0a3bc1 [META] Remove redundant reset_jami_data script from setup script 2026-03-25 17:34:47 +02:00
Santeri Rajamäki b79c97780b [META] Update JAMI url in docker-compose 2026-03-25 17:29:04 +02:00
Santeri Rajamäki 0cd3bf28ff [Frontend] Replace MRT with StyledTable in StudyProgrammeTable 2026-03-25 16:20:55 +02:00
Santeri Rajamäki 20b0ba5a30 [Frontend] Enhance StyledTable 2026-03-25 16:19:12 +02:00
Aaro Koivukangas 67001a9db1 [Updater] Remove limit of 1000 entries from update-meta in dev environment 2026-03-23 14:15:51 +02:00
Aaro Koivukangas 8c268395b0 [E2E] Fix broken test 2026-03-20 16:32:41 +02:00
Aaro Koivukangas 235d2b03b2 [CourseStat][Search] Use substitution_groups when searching for courses 2026-03-20 15:53:11 +02:00
Aaro Koivukangas cbe20f1ad3 [Updater] Handle substitution_groups with null/undefined codes better 2026-03-20 15:53:11 +02:00
Matti Luukkainen 47211dcf89 [faculty/study programme] moar compact credits produced table (fits in screen) 2026-03-20 12:48:41 +02:00
68 changed files with 1098 additions and 1103 deletions
+1 -1
View File
@@ -33,6 +33,7 @@ const checkGradeTable = gradesTableContents => {
}
const checkTableContents = contents => {
cy.get('table tbody').scrollIntoView()
cy.get('table tbody').within(() => {
contents.forEach((values, trIndex) => {
cy.get('tr')
@@ -396,7 +397,6 @@ describe('Basic user', () => {
cy.contains('Search for courses')
searchByCourseCode('TKT')
cy.cs('select-multiple-courses-toggle').should('not.have.class', 'Mui-checked')
cy.contains('TKT10004, BSCS2001, 581328, A581328, AYTKT10004')
cy.contains('Select all search results').should('not.exist')
cy.cs('select-multiple-courses-toggle').click()
cy.cs('select-multiple-courses-toggle').should('have.class', 'Mui-checked')
+1 -1
View File
@@ -12,7 +12,7 @@ const save = () => {
}
const selectSavedPopulation = name => {
cy.cs('history-search').click().type(`${name}{enter}`)
cy.cs('history-search').type(`${name}{enter}`)
}
const deleteAllSearches = () => {
+9 -9
View File
@@ -214,9 +214,9 @@ describe('Degree programme overview', () => {
cy.contains('2022')
cy.contains('48 graduated').trigger('mouseover')
cy.contains('48 students graduated in year 2022')
cy.contains('median study time: 36 months')
cy.contains('27 graduated on time')
cy.contains('9 graduated max year overtime')
cy.contains('median study time: 6 semesters')
cy.contains('26 graduated on time')
cy.contains('10 graduated max year overtime')
cy.contains('12 graduated over year late')
})
@@ -245,7 +245,7 @@ describe('Degree programme overview', () => {
cy.contains('2021')
cy.contains('2 graduated').trigger('mouseover')
cy.contains('2 students graduated in year 2021')
cy.contains('median study time: 25 months')
cy.contains('median study time: 4.5 semesters')
cy.contains('1 graduated on time')
cy.contains('1 graduated max year overtime')
cy.contains('0 graduated over year late')
@@ -257,7 +257,7 @@ describe('Degree programme overview', () => {
cy.contains('2023')
cy.contains('11 graduated').trigger('mouseover')
cy.contains('11 students graduated in year 2023')
cy.contains('median study time: 67 months')
cy.contains('median study time: 12 semesters')
cy.contains('4 graduated on time')
cy.contains('6 graduated max year overtime')
cy.contains('1 graduated over year late')
@@ -415,12 +415,12 @@ describe('Degree programme overview', () => {
cy.get('[aria-label="2020 - 2021, 16. On time."]').trigger('mouseover')
cy.contains('On time: 16')
cy.contains("Click a bar to view that year's study track level breakdown")
cy.get('[aria-label="2019 - 2020, 23. On time."]').click()
cy.get('[aria-label="2019 - 2020, 22. On time."]').click()
cy.contains('Year 2019 - 2020 by start year')
cy.get('[aria-label="MAT-MAT, 14. On time."]').trigger('mouseover')
cy.get('[aria-label="MAT-MAT, 13. On time."]').trigger('mouseover')
cy.contains('Matematiikka')
cy.contains('MAT-MAT')
cy.contains('On time: 14')
cy.contains('On time: 13')
})
cy.cs('graduation-time-toggle').click()
@@ -429,7 +429,7 @@ describe('Degree programme overview', () => {
cy.contains('2020 - 2021')
cy.contains('19 graduated').trigger('mouseover')
cy.contains('From class of 2020 - 2021, 19/30 students have graduated')
cy.contains('median study time: 33 months')
cy.contains('median study time: 6 semesters')
cy.contains('16 graduated on time')
cy.contains('2 graduated max year overtime')
cy.contains('1 graduated over year late')
+1 -1
View File
@@ -383,7 +383,7 @@ describe('Population statistics tests', () => {
cy.contains(nonExisting).should('not.exist')
cy.contains('button', 'Check student numbers').click()
cy.contains('Check for student numbers')
cy.cs('check-student-numbers').click().type(`${existing}{enter}${nonExisting}`)
cy.cs('check-student-numbers').type(`${existing}{enter}${nonExisting}`)
cy.contains('button', 'Check students').click()
cy.get('#checkstudentsresults').within(() => {
cy.cs('found-title').click()
+1 -1
View File
@@ -170,7 +170,7 @@ services:
- npm_config_cache=/tmp
- PORT=3003
- NODE_ENV=dev
- DATABASE_URL=postgres://postgres:postgres@jami-db:5432/postgres
- DATABASE_URL=postgres://postgres:postgres@jami-db:5432/jami-db-real
ports:
- 3003:3003
container_name: jami
+3 -2
View File
@@ -28,9 +28,10 @@
"prepare": "husky",
"rapodiff": "docker exec -t oodikone-backend /opt/app-root/backend/node_modules/.bin/tsx /opt/app-root/backend/src/rapodiff/rapodiff.js",
"install:local": "npm i && npm i --prefix services/shared && npm i --prefix services/frontend && npm i --prefix services/backend && npm i --prefix updater/sis-updater-scheduler && npm i --prefix updater/sis-updater-worker",
"i:both": "npm run install:both",
"ci:local": "npm ci && npm ci --prefix services/shared && npm ci --prefix services/frontend && npm ci --prefix services/backend && npm ci --prefix updater/sis-updater-scheduler && npm ci --prefix updater/sis-updater-worker",
"install:docker": "docker exec frontend npm i && docker exec backend npm i && docker exec sis-updater-scheduler npm i && docker exec oodikone-sis-updater-worker npm i",
"ci:docker": "docker exec frontend npm ci && docker exec backend npm ci && docker exec sis-updater-scheduler npm ci && docker exec oodikone-sis-updater-worker npm ci",
"install:docker": "docker exec frontend npm i && docker exec backend npm i && docker exec sis-updater-scheduler npm i && docker exec sis-updater-worker npm i",
"ci:docker": "docker exec frontend npm ci && docker exec backend npm ci && docker exec sis-updater-scheduler npm ci && docker exec sis-updater-worker npm ci",
"install:both": "npm run install:local && npm run install:docker",
"ci:both": "npm run ci:local && npm run ci:docker"
},
-24
View File
@@ -11,7 +11,6 @@ PROJECT_ROOT="$(git rev-parse --show-toplevel)"
DUMP_DIR="$PROJECT_ROOT/.databasedumps"
S3_CONFIG_FILE=~/.s3cfg
S3_BUCKET="s3://psyduck"
DOCKER_COMPOSE=$PROJECT_ROOT/docker-compose.yml
## Following the naming convention in docker-compose, these are names for database
## services. The real data databases inside service will have names suffixed by "-real".
@@ -139,29 +138,6 @@ reset_databases() {
successmsg "Database setup finished"
}
reset_jami_data() {
local database="jami-db"
local dump_destination="$DUMP_DIR/$database.sql.gz"
local s3_path="jami"
infomsg "Removing old data"
infomsg "Removing database and related volume"
docker volume rm oodikone-jami-data || warningmsg "This is okay, continuing"
docker-compose -f "$DOCKER_COMPOSE" down "$database" || warningmsg "This is okay, continuing"
infomsg "Starting postgres in the background"
docker-compose -f "$DOCKER_COMPOSE" up -d "$database"
retry docker-compose -f "$DOCKER_COMPOSE" exec "$database" pg_isready --dbname="postgres"
infomsg "Populating Jami"
docker exec -i "$database" /bin/bash -c "gunzip | psql -U postgres" < "$dump_destination" 2> /dev/null
docker-compose stop "$database"
msg ""
}
reset_all_real_data() {
infomsg "Downloading real data dumps"
for i in ${!DATABASES[*]}; do
+6 -38
View File
@@ -11,7 +11,6 @@
"@sentry/profiling-node": "^10.43.0",
"@types/lodash-es": "^4.17.12",
"@types/pg": "^8.18.0",
"axios": "^1.9.0",
"bullmq": "^5.53.2",
"compression": "^1.8.0",
"cors": "^2.8.5",
@@ -2388,19 +2387,9 @@
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/axios": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz",
"integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -2658,6 +2647,7 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
@@ -2818,6 +2808,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
@@ -2958,6 +2949,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -3160,30 +3152,11 @@
"integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==",
"license": "MIT"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"dev": true,
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
@@ -3448,6 +3421,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@@ -4440,12 +4414,6 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/pstree.remy": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
+1 -2
View File
@@ -2,7 +2,7 @@
"name": "backend",
"private": true,
"scripts": {
"build": "tsc",
"build": "tsc -p .",
"test": "node --import tsx --test --test-reporter spec --test-force-exit"
},
"dependencies": {
@@ -11,7 +11,6 @@
"@sentry/profiling-node": "^10.43.0",
"@types/lodash-es": "^4.17.12",
"@types/pg": "^8.18.0",
"axios": "^1.9.0",
"bullmq": "^5.53.2",
"compression": "^1.8.0",
"cors": "^2.8.5",
@@ -71,6 +71,9 @@ export class SISStudyRightModel extends Model implements SISStudyRight {
@Column(DataType.JSONB)
declare semesterEnrollments: SISStudyRight['semesterEnrollments']
@Column(DataType.JSONB)
declare transferInfo: SISStudyRight['transferInfo']
@HasMany(() => CreditModel, { foreignKey: 'studyright_id', sourceKey: 'id' })
declare credits: SISStudyRight['credits']
+18 -10
View File
@@ -1,7 +1,7 @@
import axios from 'axios'
import { Router } from 'express'
import { Release } from '@oodikone/shared/types'
import { Fetchios } from '@oodikone/shared/util/fetchios'
import { isDev, isTest } from '../config'
const router = Router()
@@ -39,16 +39,24 @@ router.get<never, Release[]>('/', async (_, res) => {
return res.status(200).json(fakeRelease)
}
const response = await axios.get('https://api.github.com/repos/UniversityOfHelsinkiCS/oodikone/releases')
const releasesFromAPI: Release[] = response.data.map((release: Record<string, any>) => ({
description: release.body,
time: release.published_at,
title: release.name,
version: release.tag_name,
}))
const { data } = await Fetchios.get<Record<string, any>[]>(
'https://api.github.com/repos/UniversityOfHelsinkiCS/oodikone/releases',
{}
)
changelog.data = releasesFromAPI
res.status(200).json(releasesFromAPI)
if (data) {
const releasesFromAPI: Release[] = data.map(release => ({
description: release.body,
time: release.published_at,
title: release.name,
version: release.tag_name,
}))
changelog.data = releasesFromAPI
res.status(200).json(releasesFromAPI)
}
res.status(500).send()
})
export default router
@@ -46,7 +46,7 @@ router.get<never, SearchResBody, SearchReqBody, SearchQuery>('/', async (req, re
// the students have enrolled to their courses in last 8 months
// (acual logic a bit different, see Importer)
const { data: teacherRightsToStudents, error } = importerClient
? await tryCatch<{ data: string[] }>(importerClient.post('/teacher-rights/', { teacherId, studentNumbers }))
? await tryCatch(importerClient.post<string[]>('/teacher-rights/', { teacherId, studentNumbers }, {}))
: { data: null, error: null }
if (error) {
+1 -1
View File
@@ -57,7 +57,7 @@ router.post('/update/v2/customlist/:type', async (req: PostCustomListRequest, re
programmes: refreshProgrammesByList,
}
if (typeof typeToJob[type] !== 'function') {
return res.status(400).json('Bad job type')
return res.status(400).json('Updater does not recognize this job type: ' + type)
}
const response = await typeToJob[type](list)
if (response) {
@@ -3,6 +3,7 @@ import { cloneDeep, omit } from 'lodash-es'
import { Name, DegreeProgrammeType, Unarray } from '@oodikone/shared/types'
import { ProgrammeGraduationStats } from '@oodikone/shared/types/studyProgramme'
import { getGraduationStats, getStudyTrackStats, setGraduationStats, setStudyTrackStats } from '../analyticsService'
import { GraduationTarget } from '../graduationHelpers'
import { getGraduationStatsForStudyTrack, GraduationTimes } from '../studyProgramme/studyProgrammeGraduations'
import { getMedian } from '../studyProgramme/studyProgrammeHelpers'
import { getStudyRightsInProgramme } from '../studyProgramme/studyRightFinders'
@@ -253,21 +254,22 @@ export const countGraduationTimes = async (faculty: string, programmesOfFaculty:
} = await getStatsByStartYear(programmesOfFaculty)
const goals = {
bachelor: 36,
bcMsCombo: faculty === 'H90' ? 72 : 60,
master: 24,
doctor: 48,
bachelor: GraduationTarget.THREE_YEARS,
bcMsCombo: faculty === 'H90' ? GraduationTarget.SIX_YEARS : GraduationTarget.FIVE_YEARS,
master: GraduationTarget.TWO_YEARS,
doctor: GraduationTarget.FOUR_YEARS,
exceptions: {
MH30_004: 6, // months more
'420420-ma': 6,
'420074-ma': 6,
'420119-ma': 6,
MH30_001: 48,
'320011-ma': 48,
'320001-ma': 48,
MH30_003: 42,
'320002-ma': 42,
'320009-ma': 42,
// Exceptions are additions to the base amount
MH30_004: GraduationTarget.HALF_YEAR,
'420420-ma': GraduationTarget.HALF_YEAR,
'420074-ma': GraduationTarget.HALF_YEAR,
'420119-ma': GraduationTarget.HALF_YEAR,
MH30_001: GraduationTarget.FOUR_YEARS,
'320011-ma': GraduationTarget.FOUR_YEARS,
'320001-ma': GraduationTarget.FOUR_YEARS,
MH30_003: GraduationTarget.THREE_POINT_FIVE_YEARS,
'320002-ma': GraduationTarget.THREE_POINT_FIVE_YEARS,
'320009-ma': GraduationTarget.THREE_POINT_FIVE_YEARS,
} as Record<string, number>,
}
@@ -1,13 +1,27 @@
export const countTimeCategories = (times: number[], goal: number) => {
/** Map years to semesters */
export const GraduationTarget = {
HALF_YEAR: 1,
ONE_YEAR: 2,
TWO_YEARS: 4,
THREE_YEARS: 6,
THREE_POINT_FIVE_YEARS: 7,
FOUR_YEARS: 8,
FIVE_YEARS: 10,
SIX_YEARS: 12,
} as const
/** Uses semesters, not months */
export const countTimeCategories = (times: number[], target: number) => {
const statistics = { onTime: 0, yearOver: 0, wayOver: 0 }
times.forEach(time => {
if (time <= goal) {
statistics.onTime += 1
} else if (time <= goal + 12) {
statistics.yearOver += 1
for (const time of times) {
if (time <= target) {
statistics.onTime++
} else if (time <= target + GraduationTarget.ONE_YEAR) {
statistics.yearOver++
} else {
statistics.wayOver += 1
statistics.wayOver++
}
})
}
return statistics
}
+4 -5
View File
@@ -1,11 +1,10 @@
import axios from 'axios'
import { Fetchios } from '@oodikone/shared/util/fetchios'
import { isProduction, pateToken } from '../config'
import { FormattedUser } from '../types'
import logger from '../util/logger'
const pateClient = axios.create({
baseURL: 'https://api-toska.apps.ocp-prod-0.k8s.it.helsinki.fi/pate/',
const pateClient = Fetchios.create({
baseUrl: 'https://api-toska.apps.ocp-prod-0.k8s.it.helsinki.fi/pate/',
params: {
token: pateToken,
},
@@ -24,7 +23,7 @@ const baseSettings = {
const sendEmail = async (options = {}) => {
if (!isProduction) return logger.error('Email sending is disabled in development mode.')
if (!pateToken) return logger.error('Email sending failed because pate token is missing.')
return await pateClient.post('/', options)
return await pateClient.post('', options, {})
}
export const sendFeedback = async ({ feedbackContent, user }: { feedbackContent: string; user: FormattedUser }) => {
+28 -42
View File
@@ -1,44 +1,45 @@
const axios = require('axios')
import { Fetchios } from '@oodikone/shared/util/fetchios'
const { sisUrl, sisGrapqlAppAccount, sisGrapqlAppKey } = require('../config')
const { ApplicationError } = require('../util/customErrors')
const getSisuAccessToken = async eppn => {
export const getSisuAccessToken = async eppn => {
const oriAppAuthPath = `${sisUrl}/ori/application-auth`
let sisuResponse
try {
sisuResponse = await axios.post(
oriAppAuthPath,
{
username: sisGrapqlAppAccount,
key: sisGrapqlAppKey,
executingEppn: eppn,
},
{ 'content-type': 'application/json' }
)
} catch (error) {
const { data: sisuResponse, error } = await Fetchios.post(
oriAppAuthPath,
{
username: sisGrapqlAppAccount,
key: sisGrapqlAppKey,
executingEppn: eppn,
},
{ 'content-type': 'application/json' }
)
if (error) {
throw new ApplicationError(
'Could not find the user in Sisu, either with the eppn of the user or the new person.',
404
)
}
return sisuResponse.data.authToken
return sisuResponse.authToken
}
const decodeJwtTokenPayloadToObject = token => {
export const decodeJwtTokenPayloadToObject = token => {
const payload = token.split('.')[1]
const paddedPayload = `${payload}==`
const bufferObj = Buffer.from(paddedPayload, 'base64')
return JSON.parse(bufferObj.toString('utf-8'))
}
const getSisuAuthData = async eppn => {
export const getSisuAuthData = async eppn => {
const accessToken = await getSisuAccessToken(eppn)
const tokenData = decodeJwtTokenPayloadToObject(accessToken)
return { accessToken, tokenData }
}
const personSearchQuery = `query privatePerson($subjectUserId: ID!) {
export const personSearchQuery = `query privatePerson($subjectUserId: ID!) {
private_person(id: $subjectUserId) {
id
first_name:firstNames
@@ -52,32 +53,17 @@ const personSearchQuery = `query privatePerson($subjectUserId: ID!) {
}
}`
const getGraphqlData = async (accessToken, queryObject) => {
const graphQlUrl = `${sisUrl}/api`
const authHeader = `Application ${accessToken}`
const config = {
url: graphQlUrl,
method: 'post',
headers: {
Authorization: authHeader,
'Accept-Charset': 'UTF-8',
'Content-Type': 'application/json',
},
data: queryObject,
}
let graphQlResponse
export const getGraphqlData = async (accessToken, queryObject) => {
try {
graphQlResponse = await axios(config)
const graphQlResponse = await Fetchios.post(`${sisUrl}/api`, queryObject, {
headers: {
Authorization: `Application ${accessToken}`,
'Accept-Charset': 'UTF-8',
'Content-Type': 'application/json',
},
})
return graphQlResponse.data.data.private_person
} catch (error) {
throw new ApplicationError('Graphql request failed for unclear reasons.', 500)
}
return graphQlResponse.data.data.private_person
}
module.exports = {
getSisuAccessToken,
decodeJwtTokenPayloadToObject,
getSisuAuthData,
personSearchQuery,
getGraphqlData,
}
@@ -78,7 +78,7 @@ export const getOptionsForStudents = (
export const getCourses = (courses: string[]): Promise<Array<CourseStats>> =>
CourseModel.findAll({
attributes: ['code', 'name', 'substitutions', 'is_study_module'],
attributes: ['code', 'name', 'substitutions', 'substitution_groups', 'is_study_module'],
where: {
code: { [Op.in]: courses },
},
@@ -1,46 +1,48 @@
import axios from 'axios'
import { Op } from 'sequelize'
import { Fetchios } from '@oodikone/shared/util/fetchios'
import { SECRET_TOKEN, SIS_UPDATER_URL } from '../config'
import { StudyplanModel } from '../models'
const client = axios.create({ baseURL: SIS_UPDATER_URL })
const params = { params: { token: SECRET_TOKEN } }
const client = Fetchios.create({
baseUrl: SIS_UPDATER_URL,
params: { token: SECRET_TOKEN ?? '' },
})
export const abortUpdate = async () => {
const response = await client.get('/v1/abort', params)
const response = await client.get<{ message: string }>('/v1/abort', {})
return response.data?.message
}
export const updateSISCoursesByCourseCode = async (coursecodes: string[]) => {
const data = { coursecodes }
const response = await client.post('/v1/courses', data, params)
const response = await client.post('/v1/courses', data, {})
return response.data
}
export const updateSISMetadata = async () => {
const response = await client.get('/v1/meta', params)
const response = await client.get('/v1/meta', {})
return response.data
}
export const updateSISProgrammes = async () => {
const response = await client.get('/v1/programmes', params)
const response = await client.get('/v1/programmes', {})
return response.data
}
export const updateSISRedisCache = async () => {
const response = await client.get('/v1/rediscache', params)
const response = await client.get('/v1/rediscache', {})
return response.data
}
export const updateSISStudents = async () => {
const response = await client.get('/v1/students', params)
const response = await client.get('/v1/students', {})
return response.data
}
export const updateSISStudentsByStudentNumber = async (studentnumbers: string[]) => {
const data = { studentnumbers }
const response = await client.post('/v1/students', data, params)
const response = await client.post('/v1/students', data, {})
return response.data
}
@@ -54,6 +56,6 @@ export const updateSISStudyPlans = async (days: number) => {
limit: 1000,
})
const studentnumbers = result.map(r => r.studentnumber)
const response = await client.post('v1/studyplans', { studentnumbers }, params)
const response = await client.post('v1/studyplans', { studentnumbers }, {})
return response.data
}
@@ -1,5 +1,4 @@
import * as Sentry from '@sentry/node'
import { AxiosError } from 'axios'
import { uniq } from 'lodash-es'
import type { StudyGuidanceGroup, Tags, TagsByGroupId, GroupsWithTags } from '@oodikone/shared/types/studyGuidanceGroup'
@@ -12,22 +11,23 @@ const importerClient = getImporterClient()
const getGroupsFromImporter = async (sisPersonId: string): Promise<StudyGuidanceGroup[]> => {
if (!importerClient || !sisPersonId) return []
try {
const response: { data: Record<string, StudyGuidanceGroup> } = await importerClient.get(
`/person-groups/person/${sisPersonId}`
)
const { data, error } = await importerClient.get<Record<string, StudyGuidanceGroup>>(
`/person-groups/person/${sisPersonId}`,
{}
)
return response?.data ? Object.values(response.data) : []
} catch (error) {
if (error) {
logger.error('Could not fetch study guidance groups!')
if (error instanceof AxiosError) {
if (error instanceof Error) {
logger.error(error.stack)
logger.error(JSON.stringify(error.response?.data))
logger.error(JSON.stringify(error.message))
Sentry.captureException(error)
}
return []
}
return []
return data ? Object.values(data) : []
}
export const getAllGroupsAndStudents = async (sisPersonId: string) => {
@@ -1,4 +1,5 @@
import dayjs from 'dayjs'
import dayjs, { Dayjs } from 'dayjs'
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
import { indexOf, orderBy } from 'lodash-es'
import {
@@ -10,12 +11,14 @@ import {
ExtentCode,
Phase,
EnrollmentType,
TransferInfo,
} from '@oodikone/shared/types'
import { ChartGraduationTimes } from '@oodikone/shared/types/graduations'
import { ProgrammeGraduationStats } from '@oodikone/shared/types/studyProgramme'
import { mapToProviders } from '@oodikone/shared/util'
import { getDegreeProgrammeType, getMinimumCreditsOfProgramme, sortByProgrammeCode } from '../../util'
import { countTimeCategories } from '../graduationHelpers'
import logger from '../../util/logger'
import { countTimeCategories, GraduationTarget } from '../graduationHelpers'
import { getSemestersAndYears } from '../semesters'
import { getStudyRightThesisCredits, getOrganizationThesisCredits } from './creditGetters'
import { getGraduatedStats } from './studyProgrammeBasics'
@@ -34,6 +37,8 @@ import {
} from './studyProgrammeHelpers'
import { getStudyRightsInProgramme } from './studyRightFinders'
dayjs.extend(isSameOrAfter)
/**
* For bachelor + master study right combo. This rules out master's programmes that don't
* follow the two-phase degree structure (e.g. degree programmes in medicine and dentistry)
@@ -47,19 +52,6 @@ export const shouldIncludeComboStats = async (studyProgramme: string) => {
return minimumCredits !== null && minimumCredits <= 180
}
const calculateAbsenceInMonths = (
absence: Awaited<ReturnType<typeof getSemestersAndYears>>['semesters'][string],
startDate: Date,
endDate: Date
) => {
if (endDate < absence.startdate || absence.enddate < startDate) {
return 0
}
// Without 'true' as the third argument, the result will be truncated, not rounded (e.g. 4.999 would be 4, not 5). This is why we use Math.round() instead
return Math.round(dayjs(absence.enddate).diff(absence.startdate, 'months', true))
}
/**
* Absence rules:
* 1. Student can have any number of statutory absences (lakisääteinen poissaolo)
@@ -67,29 +59,58 @@ const calculateAbsenceInMonths = (
* 2b. If study right started before 1.8.2015, student can have 4 non-statutory absences instead of 2
*
* Additional _non-statutory_ absences will consume semesters
*
* @returns duration of studies in SEMESTERS (not months!)
*/
export const calculateDurationOfStudies = (
startDate: Date,
graduationDate: Date,
semesterEnrollments: SemesterEnrollment[],
semesters: Awaited<ReturnType<typeof getSemestersAndYears>>['semesters']
semesters: Awaited<ReturnType<typeof getSemestersAndYears>>['semesters'],
transferInfo?: TransferInfo | null
) => {
const allowedNonStatutoryAbsences = dayjs(startDate).isBefore(dayjs('2015-08-01'), 'day') ? 4 : 2
/** Transferred students: unclear whether this should be based on the original start date or not. Likely doesn't matter. */
const allowedNonStatutoryAbsences = dayjs(transferInfo?.originalStartDate ?? startDate).isBefore(
dayjs('2015-08-01'),
'day'
)
? 4
: 2
const semestersWithStatutoryAbsence = semesterEnrollments
.filter(enrollment => enrollment.statutoryAbsence)
.map(enrollment => enrollment.semester)
/** A semester ending and the next one starting overlap, this takes care of that */
const getCorrectSemester = (date: Dayjs) =>
Object.values(semesters).find(
({ startdate: semesterStart, enddate: semesterEnd }) =>
date.isSameOrAfter(semesterStart, 'day') && date.isBefore(semesterEnd, 'day')
)?.semestercode
const semestersWithNonStatutoryAbsence = semesterEnrollments
.filter(enrollment => enrollment.type === EnrollmentType.ABSENT && !enrollment.statutoryAbsence)
.map(enrollment => enrollment.semester)
.slice(0, allowedNonStatutoryAbsences)
const startSemester = getCorrectSemester(dayjs(startDate))
const endSemester = getCorrectSemester(dayjs(graduationDate))
const monthsToSubtract = [...semestersWithStatutoryAbsence, ...semestersWithNonStatutoryAbsence].reduce(
(acc, semester) => acc + calculateAbsenceInMonths(semesters[semester], startDate, graduationDate),
0
if (!startSemester || !endSemester) {
logger.error(`calculateDurationOfStudies(): No semesters found! start={${startDate}}, end={${graduationDate}}`)
return -1
}
const validSemesters = semesterEnrollments.filter(se => se.semester <= endSemester && se.semester >= startSemester)
const semestersWithStatutoryAbsence =
validSemesters.filter(e => e.statutoryAbsence).length + (transferInfo?.usedStatutoryAbsenceTerms ?? 0)
const semestersWithNonStatutoryAbsence = Math.min(
validSemesters.filter(enrollment => enrollment.type === EnrollmentType.ABSENT && !enrollment.statutoryAbsence)
.length + (transferInfo?.usedAbsenceTerms ?? 0),
allowedNonStatutoryAbsences
)
return (
(transferInfo?.usedTerms ?? 0) +
endSemester -
startSemester -
semestersWithStatutoryAbsence -
semestersWithNonStatutoryAbsence +
1
)
return Math.round(dayjs(graduationDate).subtract(monthsToSubtract, 'months').diff(startDate, 'months', true))
}
export type GraduationTimes = {
@@ -188,7 +209,13 @@ const getGraduationTimeAndThesisWriterStats = async ({
const graduationDate = correctStudyRightElement.endDate
const duration = calculateDurationOfStudies(startDate, graduationDate, studyRight.semesterEnrollments, semesters)
const duration = calculateDurationOfStudies(
startDate,
graduationDate,
studyRight.semesterEnrollments,
semesters,
studyRight.transferInfo
)
const graduationYear = defineYear(graduationDate, isAcademicYear)
if (!graduationTimes[graduationYear] || !graduationTimesCombo[graduationYear]) {
@@ -204,7 +231,7 @@ const getGraduationTimeAndThesisWriterStats = async ({
const goal = await getGoal(studyProgramme)
const times: ChartGraduationTimes = { medians: [], goal }
const comboTimes: ChartGraduationTimes = { medians: [], goal: goal + 36 }
const comboTimes: ChartGraduationTimes = { medians: [], goal: goal + GraduationTarget.THREE_YEARS }
for (const year of years.toReversed()) {
const median = getMedian(graduationTimes[year])
@@ -219,7 +246,7 @@ const getGraduationTimeAndThesisWriterStats = async ({
if (doCombo) {
const median = getMedian(graduationTimesCombo[year])
const statistics = countTimeCategories(graduationTimesCombo[year], goal + 36)
const statistics = countTimeCategories(graduationTimesCombo[year], goal + GraduationTarget.THREE_YEARS)
comboTimes.medians.push({
y: median,
amount: graduationTimesCombo[year].length,
@@ -6,6 +6,7 @@ import { DegreeProgrammeType, Phase } from '@oodikone/shared/types'
import { programmeCodes } from '../../config/programmeCodes'
import { ProgrammeModuleModel } from '../../models'
import { getDegreeProgrammeType } from '../../util'
import { GraduationTarget } from '../graduationHelpers'
export function getYearsArray(since: number, isAcademicYear: true, yearsCombined?: boolean): string[]
export function getYearsArray(since: number, isAcademicYear: false, yearsCombined: true): Array<'Total' | number>
@@ -44,15 +45,16 @@ export const getStatsBasis = (years: Array<string | number>) => {
}
export const getMedian = (values: number[]) => {
if (values.length === 0) {
return 0
if (!values.length) return 0
// compareFn mandatory
const sorted = values.toSorted((a, b) => a - b)
const half = Math.floor(sorted.length / 2)
if (sorted.length % 2) {
return sorted[half]
}
values.sort((a, b) => a - b)
const half = Math.floor(values.length / 2)
if (values.length % 2) {
return values[half]
}
return (values[half - 1] + values[half]) / 2.0
return (sorted[half - 1] + sorted[half]) / 2.0
}
export function defineYear(date: Date, isAcademicYear: true): string
@@ -183,9 +185,9 @@ export const getGoal = async (programme?: string) => {
return 0
}
if ([DegreeProgrammeType.DOCTOR, DegreeProgrammeType.LICENTIATE].includes(degreeProgrammeType)) {
return 48
return GraduationTarget.FOUR_YEARS
}
return (minimumCredits / 60) * 12
return ((minimumCredits / 60) * 12) / 6 // 60 Credits per year divided to semesters
}
export const isRelevantProgramme = (code: string) => /^(KH|MH)\d{2}_\d{3}$/.test(code) || /^T\d{6}$/.test(code)
@@ -14,15 +14,18 @@ export const getStudyRightsInProgramme = async (
where.graduated = true
}
const studyRights = await SISStudyRightModel.findAll({
attributes: ['id'],
include: {
model: SISStudyRightElementModel,
as: 'studyRightElements',
attributes: [],
where,
},
})
const studyRightIds = (
await SISStudyRightModel.findAll({
attributes: ['id'],
include: {
model: SISStudyRightElementModel,
as: 'studyRightElements',
attributes: [],
where,
},
raw: true,
})
).map(sr => sr.id)
const include: Includeable[] = [
{
@@ -54,11 +57,11 @@ export const getStudyRightsInProgramme = async (
return (
await SISStudyRightModel.findAll({
attributes: ['id', 'extentCode', 'semesterEnrollments', 'studentNumber'],
attributes: ['id', 'extentCode', 'semesterEnrollments', 'studentNumber', 'transferInfo'],
include,
where: {
id: {
[Op.in]: studyRights.map(studyRight => studyRight.toJSON().id),
[Op.in]: studyRightIds,
},
},
})
@@ -10,7 +10,7 @@ import {
SemesterEnrollment,
} from '@oodikone/shared/types'
import { createLocaleComparator, keysOf } from '../../util'
import { countTimeCategories } from '../graduationHelpers'
import { countTimeCategories, GraduationTarget } from '../graduationHelpers'
import { getSemestersAndYears } from '../semesters'
import { getDateOfFirstSemesterPresent } from './studyProgrammeBasics'
import { calculateDurationOfStudies, shouldIncludeComboStats } from './studyProgrammeGraduations'
@@ -39,7 +39,7 @@ const getGraduationTimeStats = async (
combinedProgramme?: string
) => {
const goal = await getGoal(combinedProgramme ?? studyProgramme)
const finalGraduationTimes = { goals: { basic: goal, combo: goal + 36 } }
const finalGraduationTimes = { goals: { basic: goal, combo: goal + GraduationTarget.THREE_YEARS } }
const calculateGraduationTimes = (
graduationTimes: Record<string, Record<string, number[]>>,
@@ -276,7 +276,13 @@ const getMainStatsByTrackAndYear = async (
const graduationDate = studyRightElement.endDate
const duration = calculateDurationOfStudies(startDate, graduationDate, studyRight.semesterEnrollments, semesters)
const duration = calculateDurationOfStudies(
startDate,
graduationDate,
studyRight.semesterEnrollments,
semesters,
studyRight.transferInfo
)
if (countAsBachelorMaster) {
if (!graduationTimesCombo[programmeOrStudyTrack]) {
@@ -313,7 +319,8 @@ const getMainStatsByTrackAndYear = async (
bachelorStartDate,
studyRight.studyRightElements.find(element => element.code === combinedProgramme)!.endDate,
studyRight.semesterEnrollments,
semesters
semesters,
studyRight.transferInfo
)
if (graduationTimesCombinedProgrammeCombo[combinedProgramme!][startYearInNewBachelorProgramme]) {
graduationTimesCombinedProgrammeCombo[combinedProgramme!][startYearInNewBachelorProgramme].push(combinedDuration)
+3 -4
View File
@@ -1,11 +1,10 @@
import axios from 'axios'
import { Fetchios } from '@oodikone/shared/util/fetchios'
import { importerToken, importerUrl } from '../config'
import logger from './logger'
const importerClient = axios.create({
const importerClient = Fetchios.create({
headers: { token: importerToken },
baseURL: importerUrl,
baseUrl: importerUrl,
timeout: 10000,
})
+22 -17
View File
@@ -1,12 +1,12 @@
import * as Sentry from '@sentry/node'
import axios from 'axios'
import { Fetchios } from '@oodikone/shared/util/fetchios'
import { importerToken, isStaging, jamiUrl } from '../config'
import { IamAccess } from '../types'
import logger from './logger'
const jamiClient = axios.create({
baseURL: jamiUrl,
const jamiClient = Fetchios.create({
baseUrl: jamiUrl,
params: {
token: importerToken,
},
@@ -24,29 +24,34 @@ export const getUserIamAccess = async (
): Promise<UserIamAccess> => {
if (iamGroups.length === 0) return {}
try {
const { data } = await jamiClient.post('/', {
const { data, error } = await jamiClient.post<Pick<UserIamAccess, 'specialGroup'>>(
'',
{
userId: sisPersonId,
getSisuAccess: !isStaging && !!sisPersonId,
iamGroups,
})
},
{}
)
if (error === null) {
const { specialGroup, ...resetOfIamAccess } = data
return { iamAccess: resetOfIamAccess, specialGroup }
} catch (error) {
if (attempt > 3) {
logger.error('[Jami] error: ', error)
Sentry.captureException(error)
return {}
}
return getUserIamAccess(sisPersonId, iamGroups, attempt + 1)
}
if (attempt > 3) {
logger.error('[Jami] error: ', error)
Sentry.captureException(error)
return {}
}
return getUserIamAccess(sisPersonId, iamGroups, attempt + 1)
}
export const getUserIams = async (userId: string) => {
try {
const { data } = await jamiClient.get(`/${userId}`)
const { data } = await jamiClient.get<any>(userId, {})
return data.iamGroups as string[]
} catch (error: any) {
if (error.response.status !== 404) {
@@ -65,12 +70,12 @@ type Access = {
}
export const getAllUserAccess = async (userIds: string[]) => {
const { data } = await jamiClient.post('access-and-special-groups', { userIds })
const { data } = await jamiClient.post('access-and-special-groups', { userIds }, {})
return data as Access[]
}
jamiClient
.get('/ping', { timeout: 4000 })
.get('ping', { timeout: 4000 })
.then(() => {
logger.info('JAMI connected')
})
+2 -1
View File
@@ -17,7 +17,8 @@
"sourceMap": true,
"sourceRoot": "/",
"inlineSources": true,
"resolveJsonModule": true
"resolveJsonModule": true,
"types": ["node"]
},
"include": ["index.ts", "src", "tests"],
"exclude": ["../frontend"]
-259
View File
@@ -17,7 +17,6 @@
"@sentry/browser": "^10.43.0",
"@tanstack/react-table": "^8.21.3",
"@types/lodash-es": "^4.17.12",
"axios": "^1.9.0",
"dayjs": "^1.11.13",
"highcharts": "^11.4.8",
"lodash-es": "^4.17.23",
@@ -2257,23 +2256,6 @@
"vite": "^4 || ^5 || ^6 || ^7"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz",
"integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/babel-plugin-macros": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
@@ -2299,19 +2281,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -2380,18 +2349,6 @@
"node": ">=6"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/comma-separated-tokens": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
@@ -2484,15 +2441,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@@ -2525,20 +2473,6 @@
"csstype": "^3.0.2"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/error-ex": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
@@ -2548,51 +2482,6 @@
"is-arrayish": "^0.2.1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
@@ -2687,42 +2576,6 @@
"integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==",
"license": "MIT"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -2747,82 +2600,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -3144,15 +2921,6 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mdast-util-from-markdown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz",
@@ -3748,27 +3516,6 @@
],
"license": "MIT"
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -3948,12 +3695,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
-1
View File
@@ -20,7 +20,6 @@
"@sentry/browser": "^10.43.0",
"@tanstack/react-table": "^8.21.3",
"@types/lodash-es": "^4.17.12",
"axios": "^1.9.0",
"dayjs": "^1.11.13",
"highcharts": "^11.4.8",
"lodash-es": "^4.17.23",
@@ -1,14 +1,12 @@
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import * as Sentry from '@sentry/browser'
import axios from 'axios'
import { showAsUserKey } from '@/common'
import { apiBasePath, isDev } from '@/conf'
import { actionHistory } from '@/redux/actionHistory'
import { formatToArray } from '@oodikone/shared/util'
import { Fetchios } from '@oodikone/shared/util/fetchios'
// Set up dev user for development environment, mimicking production admin user
const baseHeaders = isDev
const baseHeaders: Record<string, string> = isDev
? {
uid: 'mluukkai',
displayName: 'Matti Luukkainen',
@@ -31,68 +29,53 @@ const getHeaders = () => {
return headers
}
const api = axios.create({ baseURL: apiBasePath, headers: getHeaders(), timeout: 120_000 })
const api = Fetchios.create({ baseUrl: apiBasePath })
const actionTypes = prefix => ({
attempt: `${prefix}ATTEMPT`,
failure: `${prefix}FAILURE`,
success: `${prefix}SUCCESS`,
})
type ApiMethods = 'get' | 'post' | 'put' | 'delete'
export const callApi = async (url, method = 'get', data, params, timeout = 0, progressCallback = null) => {
const options = { headers: getHeaders(), timeout }
/**
* Calls API with minimal preprocessing.
* NOTE! `data` -attribute has to be JSON.stringified in-order for the predefined
* 'Content-Type' to work and the receiving Express instance to parse the body correctly.
*/
export const callApi = async (
url: string,
method: ApiMethods = 'get',
data?: any,
params: Record<string, any> = {},
timeout = 30_000
) => {
const apiRequestController = new AbortController()
const signal = timeout !== 0 ? apiRequestController.signal : undefined
const onDownloadProgress = ({ loaded, total }) => {
if (progressCallback) progressCallback(Math.round((loaded / total) * 100))
const options = {
params,
signal,
headers: {
'Content-Type': 'application/json',
...getHeaders(),
},
}
// const buildUrl = () => e{
// const urlParams = new URLSearchParams(params)
// return apiBasePath + url + urlParams.toString()
// }
switch (method) {
case 'get':
return api.get(url, { ...options, params, onDownloadProgress })
return api.get(url, options)
case 'post':
return api.post(url, data, options)
case 'put':
return api.put(url, data, options)
case 'delete':
return api.delete(url, { headers: options.headers, data })
return api.delete(url, options)
default:
return Promise.reject(new Error('Invalid http method'))
throw new Error('Invalid method used for API call.')
}
}
const handleError = (error, actionHistory = []) => {
const { config, request, response } = error
if (response && response.status) {
Sentry.withScope(scope => {
scope.setExtra('config', config)
scope.setExtra('request', request)
scope.setExtra('response', response)
scope.setExtra('actionHistory', JSON.stringify(actionHistory))
Sentry.captureException(error)
})
}
}
export const handleRequest = store => next => async action => {
if (action.requestSettings) {
const { success, failure } = actionTypes(prefix)
const { route, method, data, prefix, query, params, onProgress } = action.requestSettings
// Based no the previous config. We don't actually care when this finishes.
void callApi(route, method, data, params, 0, onProgress)
.then(res => store.dispatch({ type: success, response: res.data, query }))
.catch(error => {
// Handle error first to avoid redux minified error #3 in production.
// We don't need to add the error to the actionHistory as it is already passed to Sentry.
handleError(error, actionHistory)
store.dispatch({ type: failure, response: error, query })
})
}
next(action)
}
// Redux-toolkit query based API
// Api can be extended with enhanceEndpoints method
// All tags used for invalidating cache must be defined here
@@ -47,12 +47,11 @@ export const facultyToolTips: Record<string, string> = {
Toisin sanoen luvut vastaavat *Degree programme overview* -näkymän lukuja, kun näkymässä on valittuna *Special study rights excluded*.
- **Breakdown**/**Median study times**:
- **Breakdown**: Näyttää, kuinka moni opiskelija valmistui tavoiteajassa (vihreä palkki), 12 kuukauden sisällä tavoiteajasta (keltainen palkki) tai tätä myöhemmin (punainen palkki).
- **Median study times**: Näyttää kyseisenä kalenteri- tai lukuvuonna aloittaneiden/valmistuneiden opiskelijoiden opintojen keston **mediaanin** kuukausina. Vihreä **katkoviiva** kuvastaa tavoiteaikaa. Keltainen katkoviiva on tavoiteaika + 12 kuukautta.
- **Breakdown**: Näyttää, kuinka moni opiskelija valmistui tavoiteajassa (vihreä palkki), kahden lukukauden sisällä tavoiteajasta (keltainen palkki) tai tätä myöhemmin (punainen palkki).
- **Median study times**: Näyttää kyseisenä kalenteri- tai lukuvuonna aloittaneiden/valmistuneiden opiskelijoiden opintojen keston **mediaanin** lukukausina. Vihreä **katkoviiva** kuvastaa tavoiteaikaa. Keltainen katkoviiva on tavoiteaika + 2 lukukautta.
- **Graduation year**/**Starting year**:
- **Graduation year**: Ryhmittelee opiskelijat valmistumisvuosittain (kalenterivuosi).
- **Starting year**: Ryhmittelee opiskelijat opiskeluoikeuden alkamisvuoden (lukuvuosi) perusteella. Bachelor + Master -kuvaajassa aloitusvuosi on aloitusvuosi kandiohjelmassa ja koulutusohjelma on maisteriohjelma, johon opiskelija on siirtynyt kandidaatiksi valmistumisen jälkeen.
`,
- **Starting year**: Ryhmittelee opiskelijat opiskeluoikeuden alkamisvuoden (lukuvuosi) perusteella. Bachelor + Master -kuvaajassa aloitusvuosi on aloitusvuosi kandiohjelmassa ja koulutusohjelma on maisteriohjelma, johon opiskelija on siirtynyt kandidaatiksi valmistumisen jälkeen.`,
studentProgress: `
Kuvaa tiedekuntaan kuuluvien eri ohjelmien opinto-oikeudella varustettujen opiskelijoiden etenemistä opintopisteissä mitattuna.
Esimerkiksi 2021-2022 aloittaneiden opintopistejakaumaa.
@@ -72,22 +72,22 @@ export const studyProgrammeToolTips: Record<string, string> = {
averageGraduationTimes: `
Yksittäinen palkki kertoo, kuinka moni opiskelija on valmistunut kyseisenä vuonna/lukuvuonna.
Vihreä **vaakapalkki** kuvaa tavoiteajassa valmistuneita, keltainen 12 kuukauden sisällä tavoiteajasta valmistuneita ja punainen yli 12 kuukautta tavoiteajan ylittäneitä.
Vihreä **vaakapalkki** kuvaa tavoiteajassa valmistuneita, keltainen kahden lukukauden sisällä tavoiteajasta valmistuneita ja punainen yli 2 lukukautta tavoiteajan ylittäneitä.
**Breakdown**: Näyttää, kuinka moni kyseisenä kalenteri- tai lukuvuotena valmistuneista opiskelijoista valmistui tavoiteajassa, 12 kuukauden sisällä tavoiteajasta tai tätä myöhemmin.
**Breakdown**: Näyttää, kuinka moni kyseisenä kalenteri- tai lukuvuotena valmistuneista opiskelijoista valmistui tavoiteajassa, kahden lukukauden sisällä tavoiteajasta tai tätä myöhemmin.
**Median time**: Näyttää kyseisenä kalenteri- tai lukuvuotena valmistuneiden opiskelijoiden opintojen keston **mediaanin** kuukausina. Vihreä **katkoviiva** kuvastaa tavoiteaikaa. Keltainen katkoviiva on tavoiteaika + 12 kuukautta.
**Median time**: Näyttää kyseisenä kalenteri- tai lukuvuotena valmistuneiden opiskelijoiden opintojen keston **mediaanin** kuukausina. Vihreä **katkoviiva** kuvastaa tavoiteaikaa. Keltainen katkoviiva on tavoiteaika + 2 lukukautta.
Valmistumisajoista on vähennetty lakisääteiset poissaolot.
`,
averageGraduationTimesStudyTracks: `
Yksittäinen palkki kertoo, kuinka moni kyseisenä **lukuvuotena ALOITTANEISTA** opiskelijoista on valmistunut.
Vihreä **vaakapalkki** kuvaa tavoiteajassa valmistuneita, keltainen 12 kuukauden sisällä tavoiteajasta valmistuneita ja punainen yli 12 kuukautta tavoiteajan ylittäneitä.
Vihreä **vaakapalkki** kuvaa tavoiteajassa valmistuneita, keltainen kahden lukukauden sisällä tavoiteajasta valmistuneita ja punainen yli 2 lukukautta tavoiteajan ylittäneitä.
**Breakdown**: Näyttää, kuinka moni kyseisenä lukuvuotena aloittaneista, jo valmistuneista, opiskelijoista valmistui tavoiteajassa, 12 kuukauden sisällä tavoiteajasta tai tätä myöhemmin.
**Breakdown**: Näyttää, kuinka moni kyseisenä lukuvuotena aloittaneista, jo valmistuneista, opiskelijoista valmistui tavoiteajassa, kahden lukukauden sisällä tavoiteajasta tai tätä myöhemmin.
**Median time**: Näyttää valmistuneiden opiskelijoiden opintojen keston **mediaanin** kuukausina sekä heidän prosentuaalisen osuutensa koko vuosikurssista. Vihreä **katkoviiva** kuvastaa tavoiteaikaa. Keltainen katkoviiva on tavoiteaika + 12 kuukautta.
**Median time**: Näyttää valmistuneiden opiskelijoiden opintojen keston **mediaanin** kuukausina sekä heidän prosentuaalisen osuutensa koko vuosikurssista. Vihreä **katkoviiva** kuvastaa tavoiteaikaa. Keltainen katkoviiva on tavoiteaika + 2 lukukautta.
Valmistumisajoista on vähennetty lakisääteiset poissaolot.
@@ -71,7 +71,8 @@ export const studentNumberFilter = createFilter({
filter(student, { options }) {
const { allowlist, blocklist } = options
return allowlist.includes(student.studentNumber) && !blocklist.includes(student.studentNumber)
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
return allowlist.includes(student.studentNumber) || !blocklist.includes(student.studentNumber)
},
render: StudentNumberFilterCard,
@@ -120,11 +120,11 @@ export const MedianBarChart = ({
yearLabel === 'Start year'
? `<b>From class of ${facultyGraph ? name : year}, ${amount}/${getClassSize(code)} students have graduated</b>`
: `<b>${amount} students graduated in year ${facultyGraph ? name : year}</b>`
const timeText = `<br />${sortingText}<br /><b>median study time: ${median} months</b><br />`
const timeText = `<br />${sortingText}<br /><b>median study time: ${median} semesters</b><br />`
const statisticsText = `<br />${statistics.onTime} graduated on time<br />${statistics.yearOver} graduated max year overtime<br />${statistics.wayOver} graduated over year late`
if (!facultyGraph) {
const goalText = realGoal ? `<br /><p><b>** Exceptional goal time: ${realGoal} months **</b></p>` : ''
const goalText = realGoal ? `<br /><p><b>** Exceptional goal time: ${realGoal} semesters **</b></p>` : ''
return `<b>${getFacultyOrProgrammeName(code)}</b> • ${code}<br />${timeText}${statisticsText}${goalText}`
}
return `${timeText}${statisticsText}`
@@ -201,7 +201,7 @@ export const MedianBarChart = ({
color: theme.palette.graduationTimes.onTime,
},
{
value: goal + 12.1,
value: goal + 2.1,
color: theme.palette.graduationTimes.yearOver,
},
{
@@ -230,7 +230,7 @@ export const MedianBarChart = ({
yAxis: {
min: 0,
max: maxValue,
title: { text: 'Graduation time (months)' },
title: { text: 'Graduation time (semesters)' },
labels: {
overflow: 'justify',
},
@@ -246,7 +246,7 @@ export const MedianBarChart = ({
{
color: theme.palette.graduationTimes.yearOver,
width: 2,
value: goal + 12,
value: goal + 2,
dashStyle: 'ShortDash',
},
],
@@ -10,13 +10,14 @@ import { flexRender } from './util'
export const OodiTableDataRow = <OTData,>(row: Row<OTData>, aggregate = false) => (
<TableRow key={row.id}>
{row.getVisibleCells().map(cell => {
const { maxSize } = cell.column.columnDef
const { size, maxSize } = cell.column.columnDef
return (
<TableCell
className="ot-data-cell"
key={cell.id}
sx={{
...getCommonPinningStyles(cell.column),
width: size ?? undefined,
maxWidth: maxSize !== Number.MAX_SAFE_INTEGER ? maxSize : '20em',
}}
>
@@ -1,19 +1,34 @@
import TablePagination from '@mui/material/TablePagination'
import type { Table } from '@tanstack/react-table'
import { useEffect, useState } from 'react'
export const OodiTablePagination = <TData,>({ table }: { table: Table<TData> }) => {
const tableRows = table.getRowCount()
const pageRowOptions = [100, 200, 500, 1000, tableRows].filter(count => count <= tableRows)
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: Math.min(200, tableRows),
})
useEffect(() => table.setPageSize(pagination.pageSize), [pagination.pageSize])
useEffect(() => table.setPageIndex(pagination.pageIndex), [pagination.pageIndex])
useEffect(
() =>
setPagination({
pageIndex: 0,
pageSize: Math.max(pagination.pageSize, pageRowOptions[0]),
}),
[table.getCoreRowModel().rows.length]
)
return (
<TablePagination
component="div"
count={table.getFilteredRowModel().rows.length}
onPageChange={(_, newPage) => table.setPageIndex(newPage)}
onRowsPerPageChange={event => {
table.setPageSize(Number(event.target.value))
table.setPageIndex(0)
}}
onPageChange={(_, newPage) => setPagination({ ...pagination, pageIndex: newPage })}
onRowsPerPageChange={event => setPagination({ pageIndex: 0, pageSize: Number(event.target.value) })}
page={table.getState().pagination.pageIndex}
rowsPerPage={table.getState().pagination.pageSize}
rowsPerPageOptions={pageRowOptions}
@@ -1,6 +1,6 @@
import type { ColumnDef, TableOptions } from '@tanstack/react-table'
import { useReactTable, getCoreRowModel, getPaginationRowModel, getSortedRowModel } from '@tanstack/react-table'
import { ReactNode, useState } from 'react'
import type { ReactNode } from 'react'
import { AggregationRowFeature, VerticalHeaderFeature, ZebrastripesFeature } from './features'
import { OodiTableContainer } from './OodiTable'
@@ -34,15 +34,8 @@ export const OodiTable = <TData,>({
toolbarContent?: ReactNode
cy?: string
}) => {
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: Math.min(200, data.length),
})
// should maybe use a deep merging tool or write own
const config: Partial<TableOptions<TData>> = {
...options,
}
const { ...config }: Partial<TableOptions<TData>> = options
const table = useReactTable<TData>({
_features: [VerticalHeaderFeature, AggregationRowFeature, ZebrastripesFeature],
@@ -51,9 +44,7 @@ export const OodiTable = <TData,>({
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
onPaginationChange: setPagination,
...config,
state: { pagination, ...config.state },
})
return <OodiTableContainer cy={cy} isExportView={isExportView} table={table} toolbarContent={toolbarContent} />
}
@@ -26,6 +26,11 @@ const getSortingValue = (course: Courses['number'] | undefined) => {
return 1
}
const isCourseCodes = (courses: unknown): courses is string[] => {
if (Array.isArray(courses) && courses.length && typeof courses.at(0) === 'string') return true
return false
}
export const useGetColumnDefinitions = (modules: Map<string, CourseTabModule>): ColumnDef<CourseTabStudent, any>[] => {
const { getTextIn } = useLanguage()
const theme = useTheme()
@@ -153,35 +158,14 @@ export const useGetColumnDefinitions = (modules: Map<string, CourseTabModule>):
if (!correctCourse) return null
const sub = correctCourse.substitutedBy
const substitutePrefix = sub ? `Substituting course: ${sub}\n` : ''
if (correctCourse.grade) {
const isTransferred = correctCourse.credittypecode === CreditTypeCode.APPROVED
const title =
substitutePrefix +
(isTransferred
? `Grade: ${correctCourse.grade}\nTransferred on: ${formatDate(correctCourse.completionDate, dateFormat)}`
: `Grade: ${correctCourse.grade}\nCompleted on: ${formatDate(correctCourse.completionDate, dateFormat)}`)
if (correctCourse.enrollmentDate) {
const subPrefix =
sub && !isCourseCodes(sub)
? `Substituted by: ${sub?.map(course => course.course_code)?.join(', ')}\n`
: ''
return (
<div title={title}>
{isTransferred ? (
<SwapHorizIcon
sx={{ color: sub ? theme.palette.ooditable.hops : theme.palette.ooditable.success }}
/>
) : (
<CheckIcon
sx={{ color: sub ? theme.palette.ooditable.hops : theme.palette.ooditable.success }}
/>
)}
</div>
)
} else if (correctCourse.enrollmentDate) {
return (
<div
title={
substitutePrefix + `Last enrollment ${formatDate(correctCourse.enrollmentDate, dateFormat)}`
}
>
<div title={`${subPrefix}Last enrollment ${formatDate(correctCourse.enrollmentDate, dateFormat)}`}>
<RemoveIcon
sx={{
color: isWithinSixMonths(correctCourse.enrollmentDate)
@@ -192,11 +176,45 @@ export const useGetColumnDefinitions = (modules: Map<string, CourseTabModule>):
</div>
)
} else if (correctCourse.inHops) {
const subString = sub && isCourseCodes(sub) ? `Substituted by: ${sub?.join(', ')}\n\n` : ''
return (
<div title={substitutePrefix + 'In primary studyplan'}>
<div title={`${subString}In primary studyplan`}>
<CropSquareIcon sx={{ color: theme.palette.ooditable.hops }} />
</div>
)
} else if (correctCourse.grade) {
// NOTE: Contains only normally-passed-courses because substitutions don't have a grade field
const isTransferred = correctCourse.credittypecode === CreditTypeCode.APPROVED
const title = isTransferred
? `Grade: ${correctCourse.grade}\nTransferred on: ${formatDate(correctCourse.completionDate, dateFormat)}`
: `Grade: ${correctCourse.grade}\nCompleted on: ${formatDate(correctCourse.completionDate, dateFormat)}`
return (
<div title={title}>
{isTransferred ? (
<SwapHorizIcon sx={{ color: theme.palette.ooditable.success }} />
) : (
<CheckIcon sx={{ color: theme.palette.ooditable.success }} />
)}
</div>
)
} else if (sub) {
const subStringPrefix = `Substituted by:\n\n`
const subString = isCourseCodes(sub)
? subStringPrefix + sub.join(', ')
: subStringPrefix +
sub
.map(
course =>
`${course.course_code}\nGrade: ${course.grade}\nCompleted on: ${formatDate(course.date, dateFormat)}`
)
.join('\n\n')
return (
<div title={subString}>
<CheckIcon sx={{ color: theme.palette.ooditable.hops }} />
</div>
)
}
return null
},
@@ -7,7 +7,7 @@ import { useToggle } from '@/hooks/toggle'
import { ExtendedCurriculumDetails } from '@/hooks/useCurriculums'
import { FilteredCourse } from '@/util/coursesOfPopulation'
import { CreditTypeCode, Name, ProgrammeCourse } from '@oodikone/shared/types'
import { FormattedStudent } from '@oodikone/shared/types/studentData'
import { FormattedStudent, StudentCourse } from '@oodikone/shared/types/studentData'
import { IncludeSubstitutionsToggle } from '../../IncludeSubstitutionsToggle'
import { useGetColumnDefinitions } from './columnDefinitions'
@@ -22,7 +22,7 @@ export type Courses = Record<
{
grade?: string
completionDate?: string
substitutedBy?: string
substitutedBy?: StudentCourse[] | string[]
inHops?: boolean
enrollmentDate?: Date
passed?: boolean
@@ -48,11 +48,12 @@ const studentMapper = (
student: FormattedStudent,
includeSubstitutions: boolean,
curriculumCourseCodes: string[],
substituteToPrimaryCodes: Record<string, string[]>
substitutionsForCourseCode: Record<string, string[][]>
) => {
const courseMap = {}
// NB: there can be many attainments/enrollments for each course code
// All passed courses that are included in curriculum
const passedCourses = student.courses.filter(
course => curriculumCourseCodes.includes(course.course_code) && course.passed
)
@@ -61,74 +62,152 @@ const studentMapper = (
studyPlan.included_courses.filter(code => curriculumCourseCodes.includes(code))
)
const passedSubstituteCourses = student.courses.filter(course => !!substituteToPrimaryCodes[course.course_code])
const substituteEnrollments = student.enrollments.filter(
enrollment => !!substituteToPrimaryCodes[enrollment.course_code]
)
const substituteHopsItems = student.studyplans.flatMap(studyPlan =>
studyPlan.included_courses.filter(code => !!substituteToPrimaryCodes[code])
// All passed courses including random AY-codes etc
const allPassedCourseCodes = student.courses.filter(course => course.passed).map(course => course.course_code)
const substitutionsToCurriculumCourses = curriculumCourseCodes.reduce<Record<string, string[][]>>((acc, code) => {
const substitutionsToCurriculumCourse = substitutionsForCourseCode[code]
if (substitutionsToCurriculumCourse) {
acc[code] = substitutionsToCurriculumCourse
}
return acc
}, {})
// curriculumCourseCode -> passedSubstGroup
const passedSubstitutionsToCurriculumCourses = Object.keys(substitutionsToCurriculumCourses).reduce<
Record<string, StudentCourse[]>
>((acc, code) => {
const passedSubstitutionGroups = substitutionsToCurriculumCourses[code].filter(substGroup =>
substGroup.every(sgCode => allPassedCourseCodes.includes(sgCode))
)
// TODO: Implement better logic to select the most optimal substitution_groups, now we select shortest and first group
// Also this .find (and at(0)!) should never be undefined because the codes are student's completed courses => they exist under student.courses
const passedSubstitutionGroupCourses = passedSubstitutionGroups
.map(sg => sg.map(code => student.courses.find(course => course.course_code === code)!))
.toSorted((a, b) => b.length - a.length)
.at(0)! // We know that this will exist
if (passedSubstitutionGroups.length) {
acc[code] = passedSubstitutionGroupCourses
}
return acc
}, {})
const enrollmentCodes = enrollments.map(e => e.course_code)
const enrollmentsWithSubstitutions = Object.keys(substitutionsToCurriculumCourses).reduce<
Record<string, FormattedStudent['enrollments']>
>((acc, code) => {
const enrolledSubstitutionGroups = substitutionsToCurriculumCourses[code].filter(substGroup =>
substGroup.every(sgCode => enrollmentCodes.includes(sgCode))
)
// TODO: Same as above
const enrolledSubstitutionGroupCourses = enrolledSubstitutionGroups
.map(sg => sg.map(code => student.enrollments.find(course => course.course_code === code)!))
.toSorted((a, b) => b.length - a.length)
.at(0)! // We know that this will exist
if (enrolledSubstitutionGroupCourses?.length) {
acc[code] = enrolledSubstitutionGroupCourses
}
return acc
}, {})
const hopsItemsWithSubstitutions = Object.keys(substitutionsToCurriculumCourses).reduce<Record<string, string[]>>(
(acc, code) => {
// TODO: Same as above
const hopsSubstitutionGroups = substitutionsToCurriculumCourses[code]
.filter(substGroup => substGroup.every(sgCode => hopsItems.includes(sgCode)))
.toSorted((a, b) => b.length - a.length)
.at(0)!
if (hopsSubstitutionGroups?.length) {
acc[code] = hopsSubstitutionGroups
}
return acc
},
{}
)
const mapCourses = (coursesToAdd: typeof passedCourses, substitutions: boolean) => {
const mapSubstitutionCourses = (coursesToAdd: typeof passedSubstitutionsToCurriculumCourses) => {
for (const [code, substitutionGroup] of Object.entries(coursesToAdd)) {
courseMap[code] ??= {
substitutedBy: substitutionGroup,
exportValue: `Substitutes ${code}`,
}
}
}
const mapCourses = (coursesToAdd: typeof passedCourses) => {
for (const course of coursesToAdd) {
const codes = substitutions ? substituteToPrimaryCodes[course.course_code] : [course.course_code]
codes.forEach(code => {
if (!courseMap[code] || compareCourseGrades(courseMap[code], course)) {
courseMap[code] = {
grade: course.grade,
completionDate: course.date,
passed: course.passed,
substitutedBy: substitutions ? course.course_code : undefined,
credittypecode: course.credittypecode,
exportValue: course.grade,
}
const code = course.course_code
if (!courseMap[code] || compareCourseGrades(courseMap[code], course)) {
courseMap[code] = {
grade: course.grade,
completionDate: course.date,
passed: course.passed,
substitutedBy: undefined,
credittypecode: course.credittypecode,
exportValue: course.grade,
}
})
}
}
}
const mapEnrollments = (enrollmentsToAdd: typeof enrollments, substitutions: boolean) => {
const mapSubstitutionEnrollments = (enrollmentsToAdd: typeof enrollmentsWithSubstitutions) => {
for (const [code, substitutionGroup] of Object.entries(enrollmentsToAdd)) {
courseMap[code] ??= {
substitutedBy: substitutionGroup,
enrollmentDate: substitutionGroup.at(0)!.enrollment_date_time,
exportValue: 'HOPS',
}
}
}
const mapEnrollments = (enrollmentsToAdd: typeof enrollments) => {
for (const enrollment of enrollmentsToAdd) {
const codes = substitutions ? substituteToPrimaryCodes[enrollment.course_code] : [enrollment.course_code]
codes.forEach(code => {
if (
!courseMap[code] ||
(!!courseMap[code].enrollmentDate &&
new Date(courseMap[code].enrollmentDate) < new Date(enrollment.enrollment_date_time))
)
courseMap[code] = {
enrollmentDate: enrollment.enrollment_date_time,
substitutedBy: substitutions ? enrollment.course_code : undefined,
exportValue: 'HOPS',
}
})
}
}
const mapHopsSelections = (selectionsToAdd: typeof hopsItems, substitutions: boolean) => {
for (const selection of selectionsToAdd) {
const codes = substitutions ? substituteToPrimaryCodes[selection] : [selection]
codes.forEach(code => {
courseMap[code] ??= {
inHops: true,
substitutedBy: substitutions ? selection : undefined,
const code = enrollment.course_code
if (
!courseMap[code] ||
(!!courseMap[code].enrollmentDate &&
new Date(courseMap[code].enrollmentDate) < new Date(enrollment.enrollment_date_time))
)
courseMap[code] = {
enrollmentDate: enrollment.enrollment_date_time,
substitutedBy: undefined,
exportValue: 'HOPS',
}
})
}
}
mapCourses(passedCourses, false)
if (includeSubstitutions) mapCourses(passedSubstituteCourses, true)
const mapHopsSelections = (selectionsToAdd: typeof hopsItems) => {
for (const selection of selectionsToAdd) {
const code = selection
courseMap[code] ??= {
inHops: true,
substitutedBy: undefined,
exportValue: 'HOPS',
}
}
}
const mapSubstitutionHopsSelections = (selectionsToAdd: typeof hopsItemsWithSubstitutions) => {
for (const [code, substitutionGroup] of Object.entries(selectionsToAdd)) {
courseMap[code] ??= {
inHops: true,
substitutedBy: substitutionGroup,
exporValue: 'HOPS',
}
}
}
mapCourses(passedCourses)
if (includeSubstitutions) mapSubstitutionCourses(passedSubstitutionsToCurriculumCourses)
mapEnrollments(enrollments)
if (includeSubstitutions) mapSubstitutionEnrollments(enrollmentsWithSubstitutions)
mapHopsSelections(hopsItems)
if (includeSubstitutions) mapSubstitutionHopsSelections(hopsItemsWithSubstitutions)
const totalPassed = Object.keys(courseMap).length
mapEnrollments(enrollments, false)
if (includeSubstitutions) mapEnrollments(substituteEnrollments, true)
mapHopsSelections(hopsItems, false)
if (includeSubstitutions) mapHopsSelections(substituteHopsItems, true)
const totalPlanned = Object.keys(courseMap).length - totalPassed
return {
@@ -163,7 +242,7 @@ const gradeOrdering = ['0', 'Hyl.', 'TT', 'HT', '1', '2', '3', '4', '5', 'Hyv.']
/**
* @returns true if current course (the latter) has the same or better grade than old course
*/
const compareCourseGrades = (previous, current) =>
const compareCourseGrades = (previous: StudentCourse, current: StudentCourse) =>
gradeOrdering.indexOf(previous.grade) <= gradeOrdering.indexOf(current.grade)
export const CoursesTabContainer = ({ curriculum, students, courses }: CoursesTabContainerProps) => {
@@ -182,34 +261,27 @@ export const CoursesTabContainer = ({ curriculum, students, courses }: CoursesTa
const curriculumCourseCodes = useMemo(() => curriculumCourses.map(course => course.code), [curriculumCourses])
const substitutionCourseCodes = useMemo(
// All substitution_groups entries as codes
const substitutionGroupsCourseCodes = useMemo(
() =>
courses
.filter(course => curriculumCourseCodes.includes(course.course.code))
.flatMap(course => course.course.substitutions),
.flatMap(course => course.course.substitution_groups?.map(sbGr => sbGr) ?? []),
[courses, curriculumCourseCodes]
)
/**
* Justification for using array:
* there are instances where a course substitutes multiple different courses currently present in curriculum
* g.g. course code TKT20015 -> BSCS2002/TKT10001 in curriculum 2020-2023
* but most of the time there is only one main code
*/
const substituteToPrimaryCodes: Record<string, string[]> = useMemo(
// All substitutionGroups for a given course code => course.substitution_groups
const substitutionsForCourseCode: Record<string, string[][]> = useMemo(
() =>
courses
.filter(course => substitutionCourseCodes.includes(course.course.code))
.reduce((acc, course) => {
const mainCodes = course.course.substitutions.filter(sub => curriculumCourseCodes.includes(sub))
// If no mainCode in curriculum, leave blank as nothing would be shown in course tab anyway
if (mainCodes.length) {
acc[course.course.code] ??= []
acc[course.course.code].push(...mainCodes)
}
return acc
}, {}),
[curriculumCourseCodes, substitutionCourseCodes, courses]
courses.reduce((acc, course) => {
const substitutionGroups = course.course.substitution_groups ?? []
if (substitutionGroups.length) {
acc[course.course.code] ??= []
acc[course.course.code].push(...substitutionGroups.filter(group => group.length))
}
return acc
}, {}),
[substitutionGroupsCourseCodes, courses]
)
const coursesByParentModule = useMemo(() => {
@@ -232,9 +304,9 @@ export const CoursesTabContainer = ({ curriculum, students, courses }: CoursesTa
const formattedStudents = useMemo(
() =>
students.map(student =>
studentMapper(student, includeSubstitutions, curriculumCourseCodes, substituteToPrimaryCodes)
studentMapper(student, includeSubstitutions, curriculumCourseCodes, substitutionsForCourseCode)
),
[students, studentMapper]
[students, studentMapper, includeSubstitutions]
)
const columns = useGetColumnDefinitions(coursesByParentModule)
@@ -0,0 +1,10 @@
import Chip from '@mui/material/Chip'
import { SwapHorizIcon } from '@/theme'
export const GroupChip = ({ group }: { group: string[] }) => (
<Chip
icon={<SwapHorizIcon color="primary" fontSize="small" />}
label={group.join(', ')}
sx={{ my: 0.25, width: 'fit-content' }}
/>
)
@@ -28,7 +28,7 @@ export const ExpandableRow = ({
return (
<TableRow>
{yearArray?.map((value, index) => (
<TableCell align="right" key={getKey(value, index)}>
<TableCell align="right" key={getKey(value, index)} sx={{ px: 0.5, py: 0.75 }}>
{index === 0 ? (
<Box alignItems="center" display="flex" justifyContent="left">
<IconButton data-cy={`${cypress}${yearIndex}`} onClick={toggleVisibility} size="small">
@@ -61,7 +61,7 @@ export const MedianTimeBarChart = ({
? `<b>From class of ${year}, ${amount}/${classSize} students have graduated</b>`
: `<b>${amount} students graduated in year ${year}</b>`
const timeText = `<br />${sortingText}<br /><b>median study time: ${y} months</b><br />`
const timeText = `<br />${sortingText}<br /><b>median study time: ${y} semesters</b><br />`
const statisticsText = `<br />${statistics.onTime} graduated on time<br />${statistics.yearOver} graduated max year overtime<br />${statistics.wayOver} graduated over year late`
@@ -119,7 +119,7 @@ export const MedianTimeBarChart = ({
color: '#90A959',
},
{
value: goal + 12.1,
value: goal + 2.1,
color: '#FEE191',
},
{
@@ -141,7 +141,7 @@ export const MedianTimeBarChart = ({
yAxis: {
min: 0,
max: maxValue,
title: { text: 'Graduation time (months)' },
title: { text: 'Graduation time (semesters)' },
labels: {
overflow: 'justify',
},
@@ -157,7 +157,7 @@ export const MedianTimeBarChart = ({
{
color: '#FEE191',
width: 2,
value: goal + 12,
value: goal + 2,
dashStyle: 'ShortDash',
},
],
@@ -6,13 +6,34 @@ import Table from '@mui/material/Table'
* To be paired with StyledCell
*/
export const StyledTable = styled(Table, {
shouldForwardProp: prop => prop !== 'showCellBorders' && prop !== 'zebraStriped',
})<{ showCellBorders?: true; zebraStriped?: true }>(({ theme, showCellBorders, zebraStriped }) => {
shouldForwardProp: prop =>
prop !== 'showCellBorders' && prop !== 'zebraStriped' && prop !== 'slimHeader' && prop !== 'slimBody',
})<{ showCellBorders?: true; zebraStriped?: true; slimHeader?: true; slimBody?: true }>(({
theme,
showCellBorders,
zebraStriped,
slimHeader,
slimBody,
}) => {
return {
'&': {
border: `1px solid ${theme.palette.grey[300]}`,
},
'& .MuiTableHead-root .MuiTableCell-root': slimHeader
? {
paddingTop: '0.5em',
paddingBottom: '0.5em',
}
: {},
'& .MuiTableBody-root .MuiTableCell-root': slimBody
? {
paddingTop: '0.5em',
paddingBottom: '0.5em',
}
: {},
'& .MuiTableHead-root': {
backgroundColor: theme.palette.grey[100],
border: `1px solid ${theme.palette.grey[300]}`,
@@ -1,11 +1,12 @@
import Chip from '@mui/material/Chip'
import { StarIcon, SwapHorizIcon } from '@/theme'
import { GroupChip } from '@/components/common/EquivalenceGroupChip'
import { StarIcon } from '@/theme'
export const PrimaryCourseLabel = ({ code, name }: { code: string; name: string }) => (
<Chip color={'primary'} icon={<StarIcon fontSize="small" />} label={`${code} • ${name}`} />
)
export const SecondaryCourseLabel = ({ code, name }: { code: string; name: string }) => (
<Chip icon={<SwapHorizIcon fontSize="small" />} label={`${code} • ${name}`} />
<GroupChip group={[`${code} • ${name}`]} />
)
@@ -1,18 +1,30 @@
import { MaterialReactTable, MRT_ColumnDef, useMaterialReactTable } from 'material-react-table'
import { createColumnHelper, TableOptions } from '@tanstack/react-table'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { ExportToExcelDialog } from '@/components/common/MRTExcelExport'
import { TotalsDisclaimer } from '@/components/common/TotalsDisclaimer'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { OodiTable } from '@/components/OodiTable'
import { OodiTableExcelExport } from '@/components/OodiTable/excelExport'
import { CourseSearchState } from '@/pages/CourseStatistics'
import { FormattedStats } from '@/types/courseStat'
import { getDefaultMRTOptions } from '@/util/getDefaultMRTOptions'
import { FormattedStats, ProgrammeStats } from '@/types/courseStat'
import { queryParamsToString } from '@/util/queryparams'
import { getGradeSpread, getThesisGradeSpread, isThesisGrades } from '../util'
import { ObfuscatedCell } from './ObfuscatedCell'
import { TimeCell } from './TimeCell'
import { commonOptions, formatPercentage, getGradeColumns, resolveGrades } from './util'
import { formatPercentage, getGradeColumns, resolveGrades } from './util'
const getTableData = (stats: FormattedStats[], useThesisGrades: boolean) => {
type TableData = {
name: string
code: number
totalAttempts: number
passed: number
failed: number
passRate: string
enrollments?: number
rowObfuscated?: boolean
grades: Record<string, number>
}
const getTableData = (stats: FormattedStats[], useThesisGrades: boolean): TableData[] => {
return stats.map(stat => {
const {
name,
@@ -42,7 +54,7 @@ const getTableData = (stats: FormattedStats[], useThesisGrades: boolean) => {
}
export const AttemptsTable = ({
data: { name, stats },
data: { stats },
separate,
showGrades,
userHasAccessToAllStats,
@@ -50,7 +62,7 @@ export const AttemptsTable = ({
openOrRegular,
alternatives,
}: {
data: { name: string; stats: FormattedStats[] }
data: ProgrammeStats
separate: boolean
showGrades: boolean
userHasAccessToAllStats: boolean
@@ -58,14 +70,10 @@ export const AttemptsTable = ({
openOrRegular: CourseSearchState
alternatives: string[]
}) => {
const { language } = useLanguage()
const [exportModalOpen, setExportModalOpen] = useState(false)
const [exportData, setExportData] = useState<Record<string, unknown>[]>([])
const [columnVisibility, setColumnVisibility] = useState<Record<string, boolean>>({})
const showPopulation = useCallback(
(yearCode: string) => {
(yearCode: number) => {
const queryObject = {
from: yearCode,
to: yearCode,
@@ -81,51 +89,12 @@ export const AttemptsTable = ({
const useThesisGrades = isThesisGrades(stats[0].attempts.grades)
const data = useMemo(() => getTableData(stats, useThesisGrades), [stats, useThesisGrades])
const columns = useMemo<MRT_ColumnDef<any>[]>(
() => [
{
accessorKey: 'name',
header: 'Time',
Cell: ({ cell, row }) => (
<TimeCell
href={showPopulation(row.original.code)}
isEmptyRow={row.original.totalAttempts === 0}
name={cell.getValue<string>()}
userHasAccessToAllStats={userHasAccessToAllStats}
/>
),
sortingFn: (rowA, rowB) => rowB.original.code - rowA.original.code,
},
{
accessorKey: 'totalAttempts',
header: 'Total attempts',
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>()),
},
{
accessorKey: 'passed',
header: 'Passed',
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>() || 0),
},
{
accessorKey: 'failed',
header: 'Failed',
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>() || 0),
},
{
accessorKey: 'passRate',
header: 'Pass rate',
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<string>()),
},
{
accessorKey: 'enrollments',
header: 'Enrollments',
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>()),
},
...getGradeColumns(resolveGrades(stats)),
],
[showPopulation, stats, userHasAccessToAllStats]
)
const excelData = useMemo(() => {
return data.map(({ grades, ...rest }) => ({
...rest,
...Object.fromEntries(Object.entries(grades).map(([key, val]) => [key, val])),
}))
}, [data])
useEffect(() => {
setColumnVisibility(prev => {
@@ -142,51 +111,74 @@ export const AttemptsTable = ({
updatedVisibility.passRate = !showGrades
updatedVisibility.enrollments = !showGrades
return { ...updatedVisibility }
return {
...updatedVisibility,
name: true,
totalAttempts: true,
}
})
}, [showGrades, stats])
const defaultOptions = getDefaultMRTOptions(setExportData, setExportModalOpen, language)
const columnHelper = createColumnHelper<TableData>()
const ooditable_columns = useMemo(
() => [
columnHelper.accessor('name', {
header: 'Time',
cell: ctx => (
<TimeCell
href={showPopulation(ctx.row.original.code)}
isEmptyRow={ctx.row.original.totalAttempts === 0}
name={ctx.getValue()}
userHasAccessToAllStats={userHasAccessToAllStats}
/>
),
sortingFn: (rowA, rowB) => rowB.original.code - rowA.original.code,
}),
columnHelper.accessor('totalAttempts', {
header: 'Total attempts',
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue()),
}),
columnHelper.accessor('passed', {
header: 'Passed',
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue() || 0),
}),
columnHelper.accessor('failed', {
header: 'Failed',
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue() || 0),
}),
columnHelper.accessor('passRate', {
header: 'Pass rate',
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue()),
}),
columnHelper.accessor('enrollments', {
header: 'Enrollments',
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue()),
}),
...getGradeColumns<TableData>(resolveGrades(stats)),
],
[showPopulation, stats, userHasAccessToAllStats]
)
const table = useMaterialReactTable({
...defaultOptions,
...commonOptions,
columns,
data,
const ooditable_table: Partial<TableOptions<TableData>> = {
enableSortingRemoval: false,
initialState: {
...defaultOptions.initialState,
...commonOptions.initialState,
},
state: {
columnVisibility,
columnOrder: [
'name',
'totalAttempts',
'passed',
'failed',
...resolveGrades(stats).map(({ key }) => `grades.${key}`),
'passRate',
'enrollments',
],
sorting: [{ id: 'name', desc: false }],
},
state: { columnVisibility },
onColumnVisibilityChange: setColumnVisibility,
muiTableBodyCellProps: {
...defaultOptions?.muiTableHeadCellProps,
...commonOptions?.muiTableBodyCellProps,
},
})
}
return (
<div>
<ExportToExcelDialog
exportColumns={columns}
exportData={exportData}
featureName={`attempt_statistics_${name}`}
onClose={() => setExportModalOpen(false)}
open={exportModalOpen}
<>
<OodiTable
columns={ooditable_columns}
data={data}
options={ooditable_table}
toolbarContent={
<OodiTableExcelExport data={excelData} exportColumnKeys={ooditable_table.state?.columnOrder ?? []} />
}
/>
<MaterialReactTable table={table} />
<TotalsDisclaimer userHasAccessToAllStats={userHasAccessToAllStats} />
</div>
</>
)
}
@@ -1,18 +1,30 @@
import { MaterialReactTable, MRT_ColumnDef, useMaterialReactTable } from 'material-react-table'
import { createColumnHelper, TableOptions } from '@tanstack/react-table'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { ExportToExcelDialog } from '@/components/common/MRTExcelExport'
import { TableHeaderWithTooltip } from '@/components/common/TableHeaderWithTooltip'
import { TotalsDisclaimer } from '@/components/common/TotalsDisclaimer'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { OodiTable } from '@/components/OodiTable'
import { OodiTableExcelExport } from '@/components/OodiTable/excelExport'
import { CourseSearchState } from '@/pages/CourseStatistics'
import { FormattedStats } from '@/types/courseStat'
import { getDefaultMRTOptions } from '@/util/getDefaultMRTOptions'
import { FormattedStats, ProgrammeStats } from '@/types/courseStat'
import { queryParamsToString } from '@/util/queryparams'
import { ObfuscatedCell } from './ObfuscatedCell'
import { TimeCell } from './TimeCell'
import { commonOptions, formatPercentage, getGradeColumns, resolveGrades } from './util'
import { formatPercentage, getGradeColumns, resolveGrades } from './util'
const getTableData = (stats: FormattedStats[]) => {
type TableData = {
name: string
code: number
totalStudents: number
passed: number
failed: number
enrolledNoGrade?: number
passRate: string
failRate: string
grades: Record<string, number>
rowObfuscated?: boolean
}
const getTableData = (stats: FormattedStats[]): TableData[] => {
return stats.map(stat => ({
name: stat.name,
code: stat.code,
@@ -23,11 +35,12 @@ const getTableData = (stats: FormattedStats[]) => {
passRate: formatPercentage(stat.students.passRate * 100),
failRate: formatPercentage(stat.students.failRate * 100),
grades: stat.students.grades,
rowObfuscated: stat.rowObfuscated,
}))
}
export const StudentsTable = ({
data: { name, stats },
data: { stats },
separate,
showGrades,
userHasAccessToAllStats,
@@ -35,7 +48,7 @@ export const StudentsTable = ({
openOrRegular,
alternatives,
}: {
data: { name: string; stats: FormattedStats[] }
data: ProgrammeStats
separate: boolean
showGrades: boolean
userHasAccessToAllStats: boolean
@@ -43,14 +56,10 @@ export const StudentsTable = ({
openOrRegular: CourseSearchState
alternatives: string[]
}) => {
const { language } = useLanguage()
const [exportModalOpen, setExportModalOpen] = useState(false)
const [exportData, setExportData] = useState<Record<string, unknown>[]>([])
const [columnVisibility, setColumnVisibility] = useState<Record<string, boolean>>({})
const showPopulation = useCallback(
(yearCode: string) => {
(yearCode: number) => {
const queryObject = {
from: yearCode,
to: yearCode,
@@ -65,128 +74,111 @@ export const StudentsTable = ({
)
const data = useMemo(() => getTableData(stats), [stats])
const columns = useMemo<MRT_ColumnDef<any>[]>(
() => [
{
accessorKey: 'name',
header: 'Time',
Cell: ({ cell, row }) => (
<TimeCell
href={showPopulation(row.original.code)}
isEmptyRow={row.original.totalStudents === 0}
name={cell.getValue<string>()}
userHasAccessToAllStats={userHasAccessToAllStats}
/>
),
sortingFn: (rowA, rowB) => rowB.original.code - rowA.original.code,
},
{
accessorKey: 'totalStudents',
header: 'Total students',
Header: (
<TableHeaderWithTooltip
header="Total students"
tooltipText="Total count of students, including enrolled students with no grade"
/>
),
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>()),
},
{
accessorKey: 'passed',
header: 'Passed',
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>() || 0),
},
{
accessorKey: 'failed',
header: 'Failed',
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>() || 0),
},
...getGradeColumns(resolveGrades(stats)),
{
accessorKey: 'enrolledNoGrade',
header: 'Enrolled, no grade',
Header: (
<TableHeaderWithTooltip
header="Enrolled, no grade"
tooltipText="Total count of students with a valid enrollment and no passing or failing grade"
/>
),
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>()),
},
{
accessorKey: 'passRate',
header: 'Pass rate',
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>()),
},
{
accessorKey: 'failRate',
header: 'Fail rate',
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>()),
},
],
[showPopulation, stats, userHasAccessToAllStats]
)
const excelData = useMemo(() => {
return data.map(({ grades, ...rest }) => ({
...rest,
...Object.fromEntries(Object.entries(grades).map(([key, val]) => [key, val])),
}))
}, [data])
useEffect(() => {
setColumnVisibility(prev => {
const updatedVisibility: Record<string, boolean> = { ...prev }
const gradeColumns = resolveGrades(stats).map(({ key }) => `grades.${key}`)
gradeColumns.forEach(key => {
updatedVisibility[key] = showGrades
})
resolveGrades(stats)
.map(({ key }) => `grades.${key}`)
.forEach(key => {
updatedVisibility[key] = showGrades
})
updatedVisibility.passed = !showGrades
updatedVisibility.failed = !showGrades
return { ...updatedVisibility }
return {
...updatedVisibility,
name: true,
totalStudents: true,
enrolledNoGrade: true,
passRate: true,
failRate: true,
}
})
}, [showGrades, stats])
const defaultOptions = getDefaultMRTOptions(setExportData, setExportModalOpen, language)
const columnHelper = createColumnHelper<TableData>()
const ooditable_columns = useMemo(
() => [
columnHelper.accessor('name', {
header: 'Time',
cell: ctx => (
<TimeCell
href={showPopulation(ctx.row.original.code)}
isEmptyRow={ctx.row.original.totalStudents === 0}
name={ctx.getValue()}
userHasAccessToAllStats={userHasAccessToAllStats}
/>
),
sortingFn: (rowA, rowB) => rowB.original.code - rowA.original.code,
}),
columnHelper.accessor('totalStudents', {
header: _ => (
<TableHeaderWithTooltip
header="Total students"
tooltipText="Total count of students, including enrolled students with no grade"
/>
),
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue() || 0),
}),
columnHelper.accessor('passed', {
header: 'Passed',
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue() || 0),
}),
columnHelper.accessor('failed', {
header: 'Failed',
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue() || 0),
}),
...getGradeColumns<TableData>(resolveGrades(stats)),
columnHelper.accessor('enrolledNoGrade', {
header: _ => (
<TableHeaderWithTooltip
header="Enrolled, no grade"
tooltipText="Total count of students with a valid enrollment and no passing or failing grade"
/>
),
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue()),
}),
columnHelper.accessor('passRate', {
header: 'Pass rate',
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue()),
}),
columnHelper.accessor('failRate', {
header: 'Fail rate',
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue()),
}),
],
[showPopulation, stats, userHasAccessToAllStats]
)
const table = useMaterialReactTable({
...defaultOptions,
...commonOptions,
columns,
data,
const ooditable_table: Partial<TableOptions<TableData>> = {
enableSortingRemoval: false,
initialState: {
...defaultOptions.initialState,
...commonOptions.initialState,
},
state: {
columnVisibility,
columnOrder: [
'name',
'totalStudents',
'passed',
'failed',
...resolveGrades(stats).map(({ key }) => `grades.${key}`),
'enrolledNoGrade',
'passRate',
'failRate',
],
sorting: [{ id: 'name', desc: false }],
},
state: { columnVisibility },
onColumnVisibilityChange: setColumnVisibility,
muiTableBodyCellProps: {
...defaultOptions?.muiTableHeadCellProps,
...commonOptions?.muiTableBodyCellProps,
},
})
}
return (
<div>
<ExportToExcelDialog
exportColumns={columns}
exportData={exportData}
featureName={`student_statistics_${name}`}
onClose={() => setExportModalOpen(false)}
open={exportModalOpen}
<>
<OodiTable
columns={ooditable_columns}
data={data}
options={ooditable_table}
toolbarContent={
<OodiTableExcelExport data={excelData} exportColumnKeys={ooditable_table.state?.columnOrder ?? []} />
}
/>
<MaterialReactTable table={table} />
<TotalsDisclaimer userHasAccessToAllStats={userHasAccessToAllStats} />
</div>
</>
)
}
@@ -1,3 +1,4 @@
import { ColumnDef } from '@tanstack/react-table'
import { flatten } from 'lodash-es'
import { FAILED_GRADES, NUMERIC_GRADES, OTHER_PASSED_GRADES } from '@/constants/grades'
@@ -8,14 +9,15 @@ export const formatPercentage = (rate: number) => {
return Number.isNaN(rate) ? '–' : `${rate.toFixed(2)} %`
}
export const getGradeColumns = (grades: { key: string; title: string }[]) => {
return grades.map(({ key, title }) => ({
export const getGradeColumns = <T extends { grades; rowObfuscated?: boolean }>(
grades: { key: string; title: string }[]
): ColumnDef<T>[] =>
grades.map(({ key, title }) => ({
id: `grades.${key}`,
accessorFn: row => row.grades[key],
header: title,
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : (cell.getValue() ?? 0)),
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : (ctx.getValue() ?? 0)),
}))
}
const gradesOrder = {
0: 0,
@@ -73,19 +75,3 @@ export const resolveGrades = (stats: FormattedStats[]) => {
const grades = [...new Set(allGrades)]
return getSortedGrades(grades)
}
export const commonOptions = {
defaultColumn: { size: 0 },
enableColumnFilters: false,
enableColumnOrdering: false,
enableDensityToggle: false,
enableGlobalFilter: false,
enableHiding: false,
initialState: {
sorting: [{ id: 'name', desc: false }],
showColumnFilters: false,
},
muiTableBodyCellProps: {
align: 'right' as const,
},
}
@@ -1,14 +1,16 @@
import IconButton from '@mui/material/IconButton'
import Table from '@mui/material/Table'
import Stack from '@mui/material/Stack'
import TableBody from '@mui/material/TableBody'
import TableCell from '@mui/material/TableCell'
import TableContainer from '@mui/material/TableContainer'
import TableHead from '@mui/material/TableHead'
import TableRow from '@mui/material/TableRow'
import Typography from '@mui/material/Typography'
import { GroupChip } from '@/components/common/EquivalenceGroupChip'
import { StyledTable } from '@/components/common/StyledTable'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { DeleteIcon } from '@/theme'
import { SearchResultCourse } from '@/types/api/courses'
import { getActiveYears } from '../util'
export const CourseTable = ({
@@ -29,53 +31,67 @@ export const CourseTable = ({
const getEmptyListRow = () => (
<TableRow>
<TableCell align="center" colSpan={2}>
<TableCell align="center" colSpan={3}>
No results.
</TableCell>
</TableRow>
)
const toCourseRow = course => (
<TableRow
data-cy={`course-${course.code}`}
hover
key={course.id}
onClick={() => (course.min_attainment_date ? onSelectCourse(course) : null)}
style={{ cursor: course.min_attainment_date ? 'pointer' : 'default' }}
>
<TableCell>
<Typography variant="subtitle1">{getTextIn(course.name)}</Typography>
<Typography color="text.secondary" variant="body2">
{getActiveYears(course)}
</Typography>
</TableCell>
<TableCell>{combineSubstitutions ? [course.code, ...course.substitutions].join(', ') : course.code}</TableCell>
{title === 'Selected courses' && (
<TableCell align="right">
<IconButton>
<DeleteIcon />
</IconButton>
const toCourseRow = (course: SearchResultCourse) => {
return (
<TableRow
data-cy={`course-${course.code}`}
hover
key={course.id}
onClick={() => (course.min_attainment_date ? onSelectCourse(course) : null)}
style={{ cursor: course.min_attainment_date ? 'pointer' : 'default' }}
>
<TableCell>
<Typography variant="subtitle1">{getTextIn(course.name)}</Typography>
<Typography color="text.secondary" variant="body2">
{getActiveYears(course)}
</Typography>
</TableCell>
)}
</TableRow>
)
<TableCell>
<Typography fontSize="0.9rem">{course.code}</Typography>
</TableCell>
<TableCell>
{combineSubstitutions ? (
<Stack>
{course.substitution_groups?.map(group => <GroupChip group={group} key={group.join(':')} />) ?? (
<Typography fontSize="0.9rem">Equivalent groups not available!</Typography>
)}
</Stack>
) : (
course.code
)}
</TableCell>
{title === 'Selected courses' && (
<TableCell align="right">
<IconButton>
<DeleteIcon />
</IconButton>
</TableCell>
)}
</TableRow>
)
}
if (hidden) {
return null
}
return (
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>{title}</TableCell>
<TableCell>Code(s)</TableCell>
{title === 'Selected courses' && <TableCell />}
</TableRow>
</TableHead>
<TableBody>{noContent ? getEmptyListRow() : courses.map(toCourseRow)}</TableBody>
</Table>
</TableContainer>
<StyledTable>
<TableHead>
<TableRow>
<TableCell>{title}</TableCell>
<TableCell>Main course code</TableCell>
<TableCell>Equivalent groups</TableCell>
{title === 'Selected courses' && <TableCell />}
</TableRow>
</TableHead>
<TableBody>{noContent ? getEmptyListRow() : courses.map(toCourseRow)}</TableBody>
</StyledTable>
)
}
@@ -21,6 +21,7 @@ import { useSearchHistory } from '@/hooks/searchHistory'
import { useToggle } from '@/hooks/toggle'
import { useGetCourseSearchResultQuery } from '@/redux/courseSearch'
import { AddIcon, SearchIcon } from '@/theme'
import { SearchResultCourse } from '@/types/api/courses'
import { SearchHistoryItem } from '@/types/searchHistory'
import { queryParamsToString } from '@/util/queryparams'
import { CourseTable } from './CourseTable'
@@ -37,7 +38,7 @@ export const SearchForm = () => {
const [courseCode, setCourseCode] = useState('')
const [debouncedCourseName, setDebouncedCourseName] = useDebouncedState('')
const [debouncedCourseCode, setDebouncedCourseCode] = useDebouncedState('')
const [selectedCourses, setSelectedCourses] = useState({})
const [selectedCourses, setSelectedCourses] = useState<Record<string, SearchResultCourse>>({})
const isInputValid = validateInputLength(courseName, 5) || validateInputLength(courseCode, 2)
const isDebouncedInputValid =
@@ -65,7 +66,7 @@ export const SearchForm = () => {
setDebouncedCourseCode(event.target.value)
}
const onSelectCourse = course => {
const onSelectCourse = (course: SearchResultCourse) => {
const isSelected = !!selectedCourses[course.code]
if (isSelected) {
@@ -148,7 +149,7 @@ export const SearchForm = () => {
const noSelectedCourses = !selected.length
const addAllCourses = () => {
const newSelectedCourses = courses.reduce((newSelected, course) => {
const newSelectedCourses = courses.reduce<Record<string, SearchResultCourse>>((newSelected, course) => {
newSelected[course.code] = { ...course }
return newSelected
}, {})
@@ -123,7 +123,7 @@ export const InteractiveDataTable = ({
<TableHead>
<TableRow>
{titles.map((title, index) => (
<TableCell key={title}>
<TableCell key={title} sx={{ px: 0, py: 0.5 }}>
<Box alignItems="center" display="flex" justifyContent={index === 0 ? 'left' : 'right'}>
{index === 0 && (
<Box sx={{ marginRight: 1 }}>
@@ -1,11 +1,13 @@
import IconButton from '@mui/material/IconButton'
import Stack from '@mui/material/Stack'
import TableBody from '@mui/material/TableBody'
import TableCell from '@mui/material/TableCell'
import TableHead from '@mui/material/TableHead'
import TableRow from '@mui/material/TableRow'
import Typography from '@mui/material/Typography'
import { MaterialReactTable, MRT_ColumnDef, useMaterialReactTable } from 'material-react-table'
import { useMemo } from 'react'
import { Link } from '@/components/common/Link'
import { StyledTable } from '@/components/common/StyledTable'
import { TableHeaderWithTooltip } from '@/components/common/TableHeaderWithTooltip'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { useAddStudyProgrammePinMutation, useRemoveStudyProgrammePinMutation } from '@/redux/studyProgrammePins'
@@ -29,81 +31,6 @@ export const StudyProgrammeTable = ({
const [addStudyProgrammePins] = useAddStudyProgrammePinMutation()
const [removeStudyProgrammePins] = useRemoveStudyProgrammePinMutation()
const columns = useMemo<MRT_ColumnDef<any>[]>(
() => [
{
accessorKey: 'code',
header: 'Code',
size: 160,
},
{
accessorKey: 'progId',
header: 'Id',
size: 100,
},
{
accessorKey: 'name',
header: 'Name',
Cell: ({ row }) => <Link to={`/study-programme/${row.original.code}`}>{getTextIn(row.original.name)}</Link>,
minSize: 490,
},
{
id: 'pin',
header: 'Pin',
Header: (
<TableHeaderWithTooltip
header="Pin"
tooltipText={`
Click the pin icon to pin/unpin a programme.
Pinned programmes are shown first here and in the degree programme search in Class statistics.
`}
/>
),
Cell: ({ row }) => {
const isPinned = pinnedProgrammes.includes(row.original.code)
return (
<IconButton
data-cy={`pin-programme-${row.original.code}-button`}
onClick={
isPinned
? () => void removeStudyProgrammePins({ programmeCode: row.original.code })
: () => void addStudyProgrammePins({ programmeCode: row.original.code })
}
sx={{
color: theme =>
isPinned ? theme.palette.studyProgrammePin.pinned : theme.palette.studyProgrammePin.unpinned,
}}
>
<PushPinIcon fontSize="small" />
</IconButton>
)
},
size: 100,
muiTableHeadCellProps: {
align: 'center',
},
muiTableBodyCellProps: {
align: 'center',
},
},
],
[addStudyProgrammePins, getTextIn, pinnedProgrammes, removeStudyProgrammePins]
)
const table = useMaterialReactTable({
columns,
data: programmes,
enableBottomToolbar: false,
enableColumnActions: false,
enablePagination: false,
enableSorting: false,
enableTopToolbar: false,
layoutMode: 'grid',
initialState: {
density: 'compact',
},
})
if (!visible || programmes == null || programmes.length === 0) {
return null
}
@@ -113,7 +40,55 @@ export const StudyProgrammeTable = ({
<Typography component="h3" variant="h6">
{header}
</Typography>
<MaterialReactTable table={table} />
<StyledTable slimBody slimHeader>
<TableHead>
<TableRow>
<TableCell sx={{ width: 100 }}>Code</TableCell>
<TableCell sx={{ width: 100 }}>Id</TableCell>
<TableCell sx={{ minWidth: 160 }}>Name</TableCell>
<TableCell sx={{ width: 80 }}>
<TableHeaderWithTooltip
header="Pin"
tooltipText={`
Click the pin icon to pin/unpin a programme.
Pinned programmes are shown first here and in the degree programme search in Class statistics.
`}
/>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{programmes.map(programme => (
<TableRow key={programme?.code}>
<TableCell>{programme?.code}</TableCell>
<TableCell>{programme?.progId}</TableCell>
<TableCell>
<Link to={`/study-programme/${programme?.code}`}>{getTextIn(programme?.name)}</Link>
</TableCell>
<TableCell>
{
<IconButton
data-cy={`pin-programme-${programme?.code}-button`}
onClick={
pinnedProgrammes.includes(programme?.code)
? () => void removeStudyProgrammePins({ programmeCode: programme?.code })
: () => void addStudyProgrammePins({ programmeCode: programme?.code })
}
sx={{
color: theme =>
pinnedProgrammes.includes(programme?.code)
? theme.palette.studyProgrammePin.pinned
: theme.palette.studyProgrammePin.unpinned,
}}
>
<PushPinIcon fontSize="small" />
</IconButton>
}
</TableCell>
</TableRow>
))}
</TableBody>
</StyledTable>
</Stack>
)
}
+1 -2
View File
@@ -1,7 +1,7 @@
import { setupListeners } from '@reduxjs/toolkit/query/react'
import { configureStore } from '@reduxjs/toolkit/react'
import { handleRequest, RTKApi } from '@/apiConnection'
import { RTKApi } from '@/apiConnection'
import { isDev } from '@/conf'
import { actionHistoryMiddleware } from './actionHistory'
import { reducer as filters } from './filters'
@@ -22,7 +22,6 @@ export const store = configureStore({
middleware: getDefaultMiddleware =>
getDefaultMiddleware({ immutableCheck: false, serializableCheck: false }).concat(
RTKApi.middleware,
handleRequest,
actionHistoryMiddleware
),
})
+5 -16
View File
@@ -1,21 +1,10 @@
import { Name } from '@oodikone/shared/types'
import { Unarray } from '@oodikone/shared/types'
import { CourseWithSubsId } from '@oodikone/shared/types/course'
export type SearchResultCourse = Unarray<GetCourseSearchResultResponse['courses']>
export type GetCourseSearchResultResponse = {
courses: Array<{
code: string
course_unit_type: string
coursetypecode: string
createdAt: string
id: string
is_study_module: boolean
mainCourseCode: string | null
max_attainment_date: string
min_attainment_date: string
name: Name
subsId: number
substitutions: string[]
updatedAt: string
}>
courses: Array<Omit<CourseWithSubsId, 'courseType' | 'credits' | 'enrollments' | 'organizations'>>
}
export type GetCourseSearchResultRequest = {
@@ -86,11 +86,11 @@ export const makeTableStats = (data: Record<string, Record<string, number>> | un
const titles = [
'Code',
'Total',
'Degree students',
'Exchange students',
'Open university (hetu)',
'Open university (no hetu)',
'Other university',
'Degree',
'Exchange',
'Open (hetu)',
'Open (no hetu)',
'Other univ',
'Separate',
'Transferred',
'Other',
+2 -1
View File
@@ -1,4 +1,4 @@
import type { ExtentCode, SemesterEnrollment } from '../types'
import type { ExtentCode, SemesterEnrollment, TransferInfo } from '../types'
import type { Credit } from './credit'
import type { Enrollment } from './enrollment'
@@ -22,6 +22,7 @@ export type SISStudyRight = {
extentCode: ExtentCode
admissionType: string
semesterEnrollments: SemesterEnrollment[] | null
transferInfo: TransferInfo | null
credits: Credit[]
enrollments: Enrollment[]
tvex: boolean
+18
View File
@@ -6,9 +6,20 @@
"": {
"name": "@oodikone/shared",
"devDependencies": {
"@types/node": "^25.5.0",
"typescript": "^5.9.2"
}
},
"node_modules/@types/node": {
"version": "25.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/typescript": {
"version": "5.9.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
@@ -22,6 +33,13 @@
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
}
}
}
+1
View File
@@ -5,6 +5,7 @@
"build": "tsc"
},
"devDependencies": {
"@types/node": "^25.5.0",
"typescript": "^5.9.2"
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ import {
} from '../types'
import { FormattedStudent } from '../types/studentData'
export type CourseStats = Pick<Course, 'code' | 'name' | 'substitutions' | 'is_study_module'>
export type CourseStats = Pick<Course, 'code' | 'name' | 'is_study_module' | 'substitution_groups'>
export type AttainmentDates = {
latestTotal?: Date
+2 -1
View File
@@ -17,6 +17,7 @@
"sourceMap": true,
"sourceRoot": "/",
"inlineSources": true,
"resolveJsonModule": true
"resolveJsonModule": true,
"types": ["node"]
}
}
+1
View File
@@ -49,6 +49,7 @@ export type { SemesterEnrollment } from './semesterEnrollment'
export type { StudyTrack } from './studyTrack'
export type { UnifyStatus } from './unification'
export type { FormattedStudent } from './studentData'
export type { TransferInfo } from './transferInfo'
// ENUMS
export { CreditTypeCode } from './creditTypeCode'
+8
View File
@@ -0,0 +1,8 @@
export type TransferInfo = {
originalStartDate: Date
usedTerms?: number
usedAbsenceTerms?: number
usedStatutoryAbsenceTerms?: number
originalUniversityUrn?: string
transferComments?: string
}
+149
View File
@@ -0,0 +1,149 @@
import type { Result } from '../util'
type Params = Record<string, string> | string
// eslint-disable-next-line import-x/no-unused-modules
export type BaseConfig = { baseUrl?: string; params?: Params; timeout?: number } & RequestInit
// eslint-disable-next-line import-x/no-unused-modules
export type RequestConfig = { params?: Params; timeout?: number } & RequestInit
const buildBaseUrl = (baseUrl: string) => {
try {
return new URL(baseUrl)
} catch (_) {
// Invalid URLs will be caught here if the baseUrl cannot be extended on top of page origin.
return new URL(baseUrl, globalThis?.location.origin)
}
}
const buildUrl = (
baseUrl: string | undefined = '',
url: string | undefined = '',
baseParams?: Params,
params?: Params
) => {
const base = baseUrl ? buildBaseUrl(url ? baseUrl.replace(/\/?$/, '/') : baseUrl) : ''
const path = new URL(url.replace(/^\//, ''), base)
const baseUrlParams = new URLSearchParams(baseParams)
const urlParams = new URLSearchParams(params)
Array.from(baseUrlParams.entries()).forEach(([key, val]) => path.searchParams.set(key, val))
Array.from(urlParams.entries()).forEach(([key, val]) => path.searchParams.set(key, val))
return path.href
}
const buildSignal = (timeout: number | undefined): AbortSignal | undefined =>
timeout !== undefined ? AbortSignal.timeout(timeout) : undefined
const prepData = <T>(data: T) => JSON.stringify(data)
// We only really use JSON in Oodikone, so I didn't fell like implementing this function.
const getMimeType = <T>(_: T) => {
return 'application/json'
}
const convertDataBasedOnCT = <R>(contentType: string, res: Response): Promise<R> => {
// Should be the most common type, as express and JAMI return JSON
if (contentType.startsWith('application/json')) return res.json() as Promise<R>
else if (contentType.startsWith('application/octet-stream')) return res.arrayBuffer() as Promise<R>
else if (contentType.startsWith('multipart/form-data')) return res.formData() as Promise<R>
else if (contentType.startsWith('text/')) return res.text() as Promise<R>
else return res.blob() as Promise<R>
}
const handleRequestData = <R>(url: string, options: RequestInit): Promise<Result<R>> =>
globalThis
.fetch(url, options)
.then(async res => {
const ct = res.headers.get('content-type') ?? ''
return {
...res,
data: await convertDataBasedOnCT<R>(ct, res),
error: null,
}
})
.catch(error => ({
data: null,
error,
}))
const fetcher = <T>({
baseUrl,
params: baseParams,
timeout: baseTimeout,
headers: baseHeaders,
...baseRest
}: BaseConfig) => ({
get: <R>(url: string | undefined, { params, headers, timeout, ...rest }: RequestConfig): Promise<Result<R>> =>
handleRequestData(buildUrl(baseUrl, url, baseParams, params), {
signal: buildSignal(timeout ?? baseTimeout),
headers: {
...baseHeaders,
...headers,
},
method: 'GET',
...baseRest,
...rest,
}),
post: <R>(
url: string | undefined,
data: T,
{ params, headers, timeout, ...rest }: RequestConfig
): Promise<Result<R>> =>
handleRequestData(buildUrl(baseUrl, url, baseParams, params), {
signal: buildSignal(timeout ?? baseTimeout),
headers: {
'Content-Type': getMimeType(data),
...baseHeaders,
...headers,
},
method: 'POST',
body: prepData(data),
...baseRest,
...rest,
}),
put: <R>(
url: string | undefined,
data: T,
{ params, headers, timeout, ...rest }: RequestConfig
): Promise<Result<R>> =>
handleRequestData(buildUrl(baseUrl, url, baseParams, params), {
signal: buildSignal(timeout ?? baseTimeout),
headers: {
'Content-Type': getMimeType(data),
...baseHeaders,
...headers,
},
method: 'PUT',
body: prepData(data),
...baseRest,
...rest,
}),
delete: <R>(url: string | undefined, { params, headers, timeout, ...rest }: RequestConfig): Promise<Result<R>> =>
handleRequestData(buildUrl(baseUrl, url, baseParams, params), {
signal: buildSignal(timeout ?? baseTimeout),
headers: {
...baseHeaders,
...headers,
},
method: 'DELETE',
...baseRest,
...rest,
}),
})
export const Fetchios = {
create: <R>(config: BaseConfig) => fetcher<R>(config),
get: <R>(url: string | undefined, config: RequestConfig): Promise<Result<R>> =>
fetcher<R>({ baseUrl: url }).get(undefined, config),
post: <R, T = unknown>(url: string | undefined, data: T, config: RequestConfig): Promise<Result<R>> =>
fetcher<T>({ baseUrl: url }).post<R>(undefined, data, config),
put: <R, T = unknown>(url: string | undefined, data: T, config: RequestConfig): Promise<Result<R>> =>
fetcher<T>({ baseUrl: url }).put<R>(undefined, data, config),
delete: <R>(url: string | undefined, config: RequestConfig): Promise<Result<R>> =>
fetcher<R>({ baseUrl: url }).delete(undefined, config),
}
+1 -1
View File
@@ -41,7 +41,7 @@ type Err<E> = {
error: E
}
type Result<T, E = Error> = Ok<T> | Err<E>
export type Result<T, E = Error> = Ok<T> | Err<E>
/**
* @returns data, error object with distinct values.
@@ -82,7 +82,7 @@ const scheduleMeta = async (clean = true) => {
table: IMPORTER_TABLES.courseUnits,
distinct: 'group_id',
pluck: 'group_id',
limit: isDev ? DEV_SCHEDULE_COUNT : null,
limit: null,
clean,
})
@@ -92,7 +92,7 @@ const scheduleMeta = async (clean = true) => {
whereIn: ['type', ['StudyModule', 'DegreeProgramme']],
distinct: 'group_id',
pluck: 'group_id',
limit: isDev ? DEV_SCHEDULE_COUNT : null,
limit: null,
clean,
})
@@ -0,0 +1,10 @@
const { JSONB } = require('sequelize')
module.exports = {
up: async queryInterface => {
await queryInterface.addColumn('sis_study_rights', 'transfer_info', JSONB)
},
down: async queryInterface => {
await queryInterface.removeColumn('sis_study_rights', 'transfer_info')
},
}
@@ -17,6 +17,7 @@ SISStudyRight.init(
extentCode: INTEGER,
admissionType: STRING,
semesterEnrollments: JSONB,
transferInfo: JSONB,
facultyCode: STRING,
expirationRuleUrns: ARRAY(STRING),
tvex: BOOLEAN,
@@ -127,8 +127,8 @@ const updateCourses = async (courseIdToAttainments, groupIdToCourse) => {
const idToCodeMap = new Map(idToCodePairs)
course.substitution_groups = course.substitution_groups
.map(subGroup => subGroup.map(id => idToCodeMap.get(id)))
.filter(sg => !sg.includes(undefined))
.map(subGroup => subGroup.map(id => idToCodeMap.get(id) ?? null))
.map(subGroup => subGroup.filter(substitution_groups => !!substitution_groups)) // Filter out undefined/null entries
course.mainCourseCode = [course.code, ...course.substitution_groups]
.sort((a, b) => getSubstitutionPriority(b) - getSubstitutionPriority(a))
@@ -63,6 +63,7 @@ const studyRightMapper = (personIdToStudentNumber, admissionNamesById, semesterE
extentCode,
admissionType: admissionNamesById[studyRight.admission_type_urn],
semesterEnrollments: getStudyRightSemesterEnrollments(semesterEnrollmentsForStudyRight),
transferInfo: studyRight.study_right_transfer,
facultyCode: getOrganisationCode(studyRight.organisation_id),
expirationRuleUrns: studyRight.study_right_expiration_rules_urn,
tvex: !!studyRight.code_urns?.includes(TVEX_URN_CODE),