Compare commits

..
77 Commits
Author SHA1 Message Date
Santeri Rajamäki 4f4868a32b [E2E] Skip now implemented feature test 2026-05-15 13:04:17 +03:00
Santeri Rajamäki d45bddbeab [DegreeProgramme][StudyTracks] Add ability to view progress by study track 2026-05-15 12:35:29 +03:00
Santeri Rajamäki 2abda1de50 [Frontend] StudyTrack is study track 2026-05-13 14:47:48 +03:00
Santeri Rajamäki 333944d377 [Programme][StudyTracks] Add export option to tables, mirroring faculties view 2026-05-13 13:55:00 +03:00
Santeri Rajamäki 50c301fe82 [Degree programme] Replace study programme with degree programme 2026-05-13 11:34:11 +03:00
Santeri Rajamäki 35ea22e125 [Frontend] Convert ColorizedCoursesTable to typescript 2026-05-12 15:13:38 +03:00
Santeri Rajamäki c3c7b5b479 [Frontend] Rework useSemesters 2026-05-12 14:59:50 +03:00
Santeri Rajamäki b6494f88d0 [Frontend] Cleanup ColorizedCourseTable CSS rules 2026-05-12 14:14:09 +03:00
Santeri Rajamäki 87bc8b24a9 [Frontend] Rework StudentsByStartingYear StudentDataTable and exportStudentTable 2026-05-12 14:14:09 +03:00
Aaro Koivukangas e08d99c801 [Semesters] Remove type assertion and use reduce<type> 2026-05-12 11:47:11 +03:00
Aaro Koivukangas b5f7a8bd86 [ApiTests] Add initial test and rename incorrect test name 2026-05-12 11:45:32 +03:00
Aaro Koivukangas 7d7d71b6be [Admin][Updater] Re-enable all buttons in staging 2026-05-12 11:36:46 +03:00
Aaro Koivukangas c1bacd11cc [Updater] Add hack fix to mainCourseCode being array 2026-05-12 11:36:46 +03:00
Santeri Rajamäki 110994ea46 [Filters] Remove view restriction from actions 2026-05-06 12:27:01 +03:00
Santeri Rajamäki 11bde27811 [Filters] Apply students length filter to Credit Statistics filter buttons 2026-05-06 12:24:51 +03:00
Santeri Rajamäki 0f4ef4a3cd [ClassStatistics][Students] Add emails to courses table 2026-05-06 12:02:47 +03:00
Santeri Rajamäki a3bb84e169 [Frontend] Demangle FilterView 2026-05-06 11:50:20 +03:00
Santeri Rajamäki 915d5c097c [Backend] Remove unused eslint disable 2026-04-24 09:58:35 +03:00
Santeri Rajamäki 523f6aee3e [Backend] Add rudimentary types to studyProgramme and programmeModules routes 2026-04-21 12:28:52 +03:00
Santeri Rajamäki e874454e40 [Backend] Remove reflect-metadata from haunting us 2026-04-21 12:04:24 +03:00
Santeri Rajamäki 45289702d5 [Backend] Update express types 2026-04-21 11:23:17 +03:00
Aaro Koivukangas 1ab6366fd1 [CI] Can't docker:down without .env, reverting adding docker:down 2026-04-21 10:16:52 +03:00
Aaro Koivukangas a01b06b8bc [CI] Remove updater from api-test compose, missing .env 2026-04-21 10:00:27 +03:00
Aaro Koivukangas 11dfa4c6f1 [Updater] Fix logger typo 2026-04-20 15:43:49 +03:00
Aaro Koivukangas b94e4fc25d [Updater] Remove cron jobs in staging environment 2026-04-20 15:37:46 +03:00
Aaro Koivukangas c1bb44bd55 [CI] Restart docker when starting api-ci environment 2026-04-20 15:37:19 +03:00
Aaro Koivukangas 1d8f92c663 [ApiTests] Add api tests for /populationstatisticsbycourse 2026-04-20 15:34:04 +03:00
Aaro Koivukangas b5ee1f2190 [Eslint] Correct ignore rules for backend/tests 2026-04-17 13:28:37 +03:00
Aaro Koivukangas c5649e9d39 [ApiTests] Restructure tests 2026-04-17 13:25:14 +03:00
Aaro Koivukangas 2a979af974 [Lint] Lint fixes 2026-04-17 12:40:12 +03:00
Aaro Koivukangas 06964526dc [Husky] Enable front+back linting again :D 2026-04-17 12:37:12 +03:00
Aaro Koivukangas b2b471dab5 [ApiTests] Add api tests for /populationstatistics/studyprogrammes 2026-04-17 12:32:52 +03:00
Aaro Koivukangas 15b9551f7a [Husky] Lint all files in pre-commit hook and don't lint them twice 2026-04-15 17:17:13 +03:00
Aaro Koivukangas c6f4e6066b [Meta] Remove rapodiff + unused npm commands 2026-04-15 17:10:33 +03:00
Aaro Koivukangas 7ebb60e564 [Staging] Disable updater update buttons in staging, breaks things 2026-04-15 16:32:48 +03:00
Aaro Koivukangas b641cf9ca6 [Scripts] Add script to update staging databases with test data 2026-04-15 16:11:51 +03:00
Santeri Rajamäki 686697f202 [Frontend] Convert LanguageCenterView to tsx 2026-04-14 16:32:00 +03:00
Santeri Rajamäki 296299e635 [Frontend] Simplify ColorizedCoursesTable top-level usage 2026-04-14 16:30:24 +03:00
Santeri Rajamäki c8b8b033ed [Frontend] Drop ProgressBar support 2026-04-14 15:43:45 +03:00
Santeri Rajamäki f64f34b971 [Frontend] Convert DateSelector and DateRangeSelector to tsx 2026-04-14 15:31:22 +03:00
Santeri Rajamäki 9b4f689dd3 [Frontend] Convert LogOutButton to tsx 2026-04-14 15:14:48 +03:00
Santeri Rajamäki cd32860300 [Frontend] Convert FilterDegreeCoursesModal to tsx 2026-04-14 15:10:28 +03:00
Santeri Rajamäki 7ed58eb38a [Frontend] Default CreditCriteriaSection to previous value instead of showing "help text" 2026-04-14 15:06:04 +03:00
Santeri Rajamäki 291082e6c3 [Frontend] Convert ExternalGradeFilterToggle to tsx 2026-04-14 14:44:31 +03:00
Santeri Rajamäki 54f363be6e [META] Drop version numbers from api routes 2026-04-14 14:41:52 +03:00
Santeri Rajamäki 539ed1e3b8 [Backend] Actually return after sending response for close to graduation 2026-04-14 13:24:48 +03:00
Santeri Rajamäki 9d61b50db5 [Frontend] Better type integration for redux users path 2026-04-14 13:22:52 +03:00
Santeri Rajamäki 8ede9de315 [Shared] Move user typings and route types to shared from backend 2026-04-14 13:07:11 +03:00
Santeri Rajamäki 1cb43cee6b [Teachers] Add more handling 2026-04-14 12:38:06 +03:00
Santeri Rajamäki b2617c8933 [Backend] Add handleQueryArrays to other backend routes that crash without it 2026-04-14 12:19:03 +03:00
Santeri Rajamäki beb1130050 [Backend] Add handleQueryArrays to courseYearlyStats 2026-04-14 11:52:38 +03:00
Santeri Rajamäki 1c75204854 [Filters] Map "Koepisteet" to "Valintakoe" as intended 2026-04-14 10:46:14 +03:00
Santeri Rajamäki 80bba6e0fc [Backend] Upgrade from express v4 to v5 2026-04-13 17:27:45 +03:00
Santeri Rajamäki 47b0dabe75 [OT] Remove pre-emptive null returns
OT pagination now supports changing data models which makes these early returns redundant
2026-04-13 15:52:50 +03:00
Santeri Rajamäki da45c8c150 [Backend] Cull user route types 2026-04-13 15:34:57 +03:00
Aaro Koivukangas 8fd7383217 [Staging] Remove unnecessary staging iamgroups 2026-04-13 12:00:04 +03:00
Santeri Rajamäki baa6d87c2c [Filters] Fix student number filter allowlist 2026-04-13 11:48:13 +03:00
Santeri Rajamäki 5ae4c8c7a0 [OT] Change rowsPerPage provider 2026-04-10 14:15:51 +03:00
Santeri Rajamäki cbb7e82d23 [OT] Apply ellipsis to cells table-wide 2026-04-10 14:09:07 +03:00
Aaro KoivukangasandSanteri Rajamäki 2768e95920 [Class stats][Courses of class] Show Master-level modules by fixing incorrect nullish coalesing 2026-04-10 13:39:10 +03:00
Aaro KoivukangasandSanteri Rajamäki 5b7d641ad1 [E2E] More page title e2e test fixing 2026-04-10 13:39:10 +03:00
Aaro KoivukangasandSanteri Rajamäki 37a71d7580 [E2E] Fix changed titles in tests 2026-04-10 13:39:10 +03:00
Aaro KoivukangasandSanteri Rajamäki aaa7b5de27 [E2E] Fix tests 2026-04-10 13:39:10 +03:00
Aaro KoivukangasandSanteri Rajamäki 40bcbc59dc [Class stats][Courses of class] Change empty fields to be 0, not null 2026-04-10 13:39:10 +03:00
Aaro KoivukangasandSanteri Rajamäki 5e46ce0cb0 [Class stats] Improve page title 2026-04-10 13:39:10 +03:00
Santeri Rajamäki 517c08091e [META] Update @types/pg 2026-04-10 13:39:09 +03:00
Santeri Rajamäki e25dfb2a36 [META] Update lodash-es 2026-04-10 13:38:24 +03:00
Santeri Rajamäki aa4e0abb44 [META] Update cypress 2026-04-10 13:37:13 +03:00
Santeri Rajamäki 3e5dc9a9f0 [META] Update globals 2026-04-10 13:37:13 +03:00
Santeri Rajamäki afd9541151 [META] Update eslint-plugin-import 2026-04-10 13:37:13 +03:00
Santeri Rajamäki a5ab3b972c [META] Update stylelint-config-standard 2026-04-10 13:37:13 +03:00
Santeri Rajamäki 4f3350e4f9 [META] Update stylelint 2026-04-10 13:37:13 +03:00
Santeri Rajamäki 2b8e1ed98f [Backend] Convert oriProvider to use module exports instead of CJS module.exports 2026-04-10 13:37:13 +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
192 changed files with 4988 additions and 5422 deletions
-1
View File
@@ -8,7 +8,6 @@ on:
- 'cypress/**'
- 'cypress.config.json'
- 'services/**'
- '!services/backend/src/rapodiff/**'
- 'docker-compose.ci.yml'
- 'package*.json'
-1
View File
@@ -11,7 +11,6 @@ on:
- 'cypress/**'
- 'cypress.config.json'
- 'services/**'
- '!services/backend/src/rapodiff/**'
- 'updater/sis-updater-worker/src/db/migrations/**'
- 'docker-compose.ci.yml'
- 'package*.json'
+4 -5
View File
@@ -5,11 +5,10 @@ const dockerCmdBase = `docker run --rm --volume ${cwd}:/oodikone --workdir /oodi
const relativeFilePaths = files => [...files.map(file => relative(cwd, file))].join(' ')
export default {
'{services,updater}/**/*.{js,jsx,ts,tsx}': files =>
`eslint --fix ${files.join(' ')} --report-unused-disable-directives`,
'services/backend/**/*.{ts,tsx}': () => 'npx tsc --noEmit --project services/backend/tsconfig.json',
'services/frontend/**/*.{ts,tsx}': () => 'npx tsc --noEmit --project services/frontend/tsconfig.json',
'*.{js,jsx,ts,tsx,json,md,yml,yaml,html,css}': files => `prettier --write ${files.join(' ')}`,
'{services,updater}/**/*.{js,jsx,ts,tsx}': () => 'eslint --fix --report-unused-disable-directives',
'services/backend/**/*.{js.jsx,ts,tsx}': () => 'npx tsc --noEmit --project services/backend/tsconfig.json',
'services/frontend/**/*.{js,jsx,ts,tsx}': () => 'npx tsc --noEmit --project services/frontend/tsconfig.json',
'*.{js,jsx,ts,tsx,json,md,yml,yaml,html,css}': () => 'prettier . --write',
'*.css': files => `stylelint --fix ${files.join(' ')}`,
Dockerfile: files =>
`${dockerCmdBase} hadolint/hadolint:v2.12.0 hadolint --ignore DL3006 ${relativeFilePaths(files)}`,
+1 -3
View File
@@ -1,4 +1,5 @@
# Oodikone
[![Maintainability](https://qlty.sh/gh/UniversityOfHelsinkiCS/projects/oodikone/maintainability.svg)](https://qlty.sh/gh/UniversityOfHelsinkiCS/projects/oodikone)
An application for analyzing university data, running at [https://oodikone.helsinki.fi/](https://oodikone.helsinki.fi/).
@@ -129,7 +130,6 @@ Some useful commands are defined in `package.json` and can be run with `npm run
- `npm run docker:down`: stops the whole environment
- `npm run testupdater`: runs tests for updater
- `npm run flushredis`: clears Redis, which forces big calculations to be redone in e.g. studyprogramme overview and faculty views
- `npm run rapodiff`: runs [Rapodiff](./services/backend/src/rapodiff/)
- `npm run install:local`: installs `node_modules` for each package (backend, frontend, sis-updater-scheduler and sis-updater-worker) locally
- `npm run install:docker`: installs `node_modules` for each package inside containers
- `npm run install:both`: runs both `npm run install:local` and `npm run install:docker`
@@ -187,8 +187,6 @@ Continuous integration (CI) works with Github actions and is defined in workflow
- Updater is deployed to production when pushed if tests pass
- Test databases can be updated, instructions in [anonyymioodi README](anonyymioodi/README.md)
[Rapodiff](./services/backend/src/rapodiff/) can be used to check differences between Rapo and Oodikone data.
## ❓FAQ
### Modules are missing after updating package.json
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# This script will first make db dumps of the test environment and upload the dumps to staging
. ../scripts/utils.sh
set -euo pipefail
main() {
declare -r db_names="sis-db kone-db user-db"
declare -A db_url_names
db_url_names["sis-db"]="SIS_DB_URL"
db_url_names["kone-db"]="DB_URL_KONE"
db_url_names["user-db"]="DB_URL_USER"
declare -r dump_directory="dumps"
# Takes the first db-tools pod
check_oc_logged_staging
db_tools_name=$(oc get pods -l app=db-tools --output jsonpath='{.items[0].metadata.name}')
readonly db_tools_name
create_dumps
upload_and_migrate_dumps
}
function create_dumps() {
echo "Starting test/anon containers..."
(npm run docker:down && npm run both) || die "Failed to start containers"
echo
echo "Waiting for containers to start..."
sleep 10 # wait for containers to start
# Create dumps for all dbs
echo
echo "Creating dumps for $db_names"
echo
# Create dir if not exists
if [ ! -d $dump_directory ]; then mkdir -p $dump_directory; fi
for db in $db_names
do
create_dump_from_db "$db"
if [ ! -f "$dump_directory/$db.sqz" ]; then die "Db dump not created"; fi
done
echo "Dumps can be found in $PWD/\*.sqz"
}
# $1 db-name
function create_dump_from_db() {
echo "Dumping db $1..."
docker exec "$1" pg_dump -Fc -U postgres "$1" > "$dump_directory/$1.sqz" || die "Failed to dump db $1"
}
function check_oc_logged_staging() {
echo -n "Checking if oc is logged into toska staging..."
[[ $(oc config current-context) == "toska/api-ocp-test-0-k8s-it-helsinki-fi"* ]] || die "\nNot logged in to toska staging environment: $(oc config current-context)"
echo " OK"
}
function upload_and_migrate_dumps() {
local target_dump_directory="$db_tools_name:/tmp/oodikone-staging-dumps"
echo -n "Dumping $dump_directory/ to $target_dump_directory..."
oc rsync "$dump_directory/" "$target_dump_directory" 1> /dev/null # show errors
echo " OK"
echo
for db in $db_names
do
migrate_dumps "$db"
done
}
function migrate_dumps() {
local db_url_b64
local db_url_raw
local db_url
db_url_b64=$(oc get secrets -l app.kubernetes.io/instance=oodikone --output jsonpath="{.items[0].data.${db_url_names[$1]}}")
db_url_raw=$(echo "$db_url_b64" | base64 --decode)
db_url="${db_url_raw%\?*}"
[[ $db_url != *"oodikone_*_test"* ]] || die "Trying to use non-test database: $db_url"
echo "Migrating $1 to url: $db_url"
oc exec "$db_tools_name" -- pg_restore --clean --no-owner -d "$db_url" "/tmp/oodikone-staging-dumps/$1.sqz"
}
# Run script
main
+9 -9
View File
@@ -92,17 +92,17 @@ const clickAway = () => {
const getCourseSearchHooksForSingleYear = code => {
cy.intercept({
pathname: '/api/v2/coursesmulti',
pathname: '/api/coursesmulti',
query: { code },
}).as('courses')
cy.intercept({
pathname: '/api/v3/courseyearlystats',
pathname: '/api/courseyearlystats',
query: { 'codes[]': code },
}).as('yearlystats')
cy.intercept({
pathname: '/api/v3/populationstatisticsbycourse',
pathname: '/api/populationstatisticsbycourse',
}).as('coursestats')
}
@@ -263,17 +263,17 @@ describe('Basic user', () => {
it('Searching multiple courses having substitution mappings shows course statistics', () => {
cy.intercept({
pathname: '/api/v2/coursesmulti',
pathname: '/api/coursesmulti',
query: { code: 'TKT' },
}).as('courses')
cy.intercept({
pathname: '/api/v3/courseyearlystats',
pathname: '/api/courseyearlystats',
query: { 'codes[]': 'TKT20001', 'codes[]': 'TKT10002' },
}).as('yearlystats')
cy.intercept({
pathname: '/api/v3/populationstatisticsbycourse',
pathname: '/api/populationstatisticsbycourse',
}).as('coursestats')
cy.cs('select-multiple-courses-toggle').should('not.have.class', 'Mui-checked')
@@ -309,17 +309,17 @@ describe('Basic user', () => {
it('On searches with multiple courses, has correct links on the Course tab', () => {
cy.intercept({
pathname: '/api/v2/coursesmulti',
pathname: '/api/coursesmulti',
query: { code: 'BSCS' },
}).as('courses')
cy.intercept({
pathname: '/api/v3/courseyearlystats',
pathname: '/api/courseyearlystats',
query: { 'codes[]': 'BSCS1003', 'codes[]': 'BSCS1001' },
}).as('yearlystats')
cy.intercept({
pathname: '/api/v3/populationstatisticsbycourse',
pathname: '/api/populationstatisticsbycourse',
}).as('coursestats')
cy.cs('select-multiple-courses-toggle').should('not.have.class', 'Mui-checked')
+12 -10
View File
@@ -269,7 +269,7 @@ describe('Degree programme overview', () => {
beforeEach(() => {
cy.intercept(
'GET',
'api/v2/studyprogrammes/KH50_001/studytrackstats?special_groups=SPECIAL_INCLUDED&combined_programme='
'api/studyprogrammes/KH50_001/studytrackstats?special_groups=SPECIAL_INCLUDED&combined_programme='
).as('stQuery')
cy.init('/study-programme')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
@@ -355,14 +355,14 @@ describe('Degree programme overview', () => {
describe('Population link button works for', () => {
it('a single year', () => {
cy.cs('2023-population-link-button').first().click()
cy.contains('Matemaattisten tieteiden kandiohjelma 2023 - 2024')
cy.contains('Class size 8 students')
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Class of 2023 - 2024, 8 students')
})
it('total', () => {
cy.cs('total-population-link-button').click()
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Class size 227 students')
cy.contains('Class of 2017 - 2026, 227 students')
})
it('Links to class statistics page with study track info included work', () => {
@@ -371,9 +371,9 @@ describe('Degree programme overview', () => {
cy.get('a').click()
})
cy.contains('Matemaattisten tieteiden kandiohjelma 2022 - 2023')
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Studytrack MAT-MAT')
cy.contains('Class size 26 students')
cy.contains('Class of 2022 - 2023, 26 students')
cy.contains('Showing 3 out of 26 students')
})
})
@@ -465,13 +465,14 @@ describe('Degree programme overview', () => {
it('Links to class statistics page with study track info included work', () => {
cy.cs('2020-population-link-button').first().click()
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Studytrack MAT-MAT')
cy.contains('Class size 30 students')
cy.contains('Class of 2020 - 2021, 30 students')
cy.contains('Showing 10 out of 30 students')
})
it('Info message about missing progress stats is displayed', () => {
// FIXME: This feature was implemented. The data is no-good, so maybe reimplement this when it's clean.
it.skip('Info message about missing progress stats is displayed', () => {
cy.contains('Progress of students of the study track MAT-MAT by starting year')
cy.contains('Progress data is currently only available for all students of the degree programme.')
})
@@ -825,7 +826,8 @@ describe('Degree programme overview', () => {
cy.contains('Students added to tag')
cy.cs('population-link-button').click()
cy.contains('Matemaattisten tieteiden kandiohjelma 2022 - 2023')
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Class of 2022 - 2023')
cy.contains(`Tagged with: ${tagName}`)
cy.contains('Students (5)')
.parent()
+8 -8
View File
@@ -27,8 +27,8 @@ describe('Population statistics tests', () => {
describe('When using basic user', () => {
beforeEach(() => {
cy.init('/populations')
cy.intercept('/api/v3/populationstatistics/studyprogrammes').as('studyprogrammes')
cy.intercept('/api/v2/studyprogrammes/**/studytracks').as('studytracks')
cy.intercept('/api/populationstatistics/studyprogrammes').as('studyprogrammes')
cy.intercept('/api/studyprogrammes/**/studytracks').as('studytracks')
})
describe('Population search', () => {
@@ -74,8 +74,8 @@ describe('Population statistics tests', () => {
selectStudyProgramme('Matemaattisten tieteiden kandiohjelma')
cy.wait('@studytracks').its('response.statusCode').should('be.oneOf', [200, 304])
cy.contains('See class').click()
cy.contains('Matemaattisten tieteiden kandiohjelma 2017 - 2018')
cy.contains('Class size 47 students')
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Class of 2017 - 2018, 47 students')
})
// TODO: Delete MUI
@@ -95,7 +95,7 @@ describe('Population statistics tests', () => {
cy.visit(pathToMathBSc2020)
cy.cs('filtered-students')
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Class size 30 students')
cy.contains('Class of 2020 - 2021, 30 students')
cy.contains('Excludes exchange students')
cy.contains('Excludes students with non-degree study right')
cy.contains('Excludes students who have transferred out of this programme')
@@ -251,7 +251,7 @@ describe('Population statistics tests', () => {
it('Is displayed and link to individual course stats page works', () => {
cy.visit(pathToMathBSc2020)
cy.contains('Courses of class').click()
cy.intercept('/api/v3/courseyearlystats**').as('coursePage')
cy.intercept('/api/courseyearlystats**').as('coursePage')
cy.cs('toggle-group-module-MAT110').click()
cy.contains('td', 'MAT11001').siblings().find('[data-testid="NorthEastIcon"]').click()
cy.wait('@coursePage')
@@ -401,8 +401,8 @@ describe('Population statistics tests', () => {
describe('When using IAM user', () => {
beforeEach(() => {
cy.init(pathToMathBSc2020, 'onlyiamrights')
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
cy.contains('Class size 30 students')
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Class of 2020 - 2021, 30 students')
})
it('Population statistics is visible', () => {
+2 -2
View File
@@ -162,8 +162,8 @@ describe('Students tests', () => {
.parent()
.within(() => cy.get('a').click())
})
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
cy.contains('Class size 30 students')
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Class of 2020 - 2021, 30 students')
})
it('Grade graph works in all three different modes', () => {
+1 -1
View File
@@ -72,7 +72,7 @@ describe('Users tests', () => {
})
it("only the mocked user's programmes are visible", () => {
cy.intercept('/api/v3/populationstatistics/studyprogrammes').as('studyprogrammes')
cy.intercept('/api/populationstatistics/studyprogrammes').as('studyprogrammes')
cy.visit('/populations')
cy.wait('@studyprogrammes').its('response.statusCode').should('be.oneOf', [200, 304])
+1 -1
View File
@@ -91,6 +91,6 @@ All things listed below should work automatically, if new combinations are done.
## Study guidance groups
- Backend: student data is fetched by bachelor degree programme code in `/v3/populationstatisticsbystudentnumbers`. Separation from code 'KHxx_xxx+MHxx_xxx' is done in backend.
- Backend: student data is fetched by bachelor degree programme code in `/populationstatisticsbystudentnumbers`. Separation from code 'KHxx_xxx+MHxx_xxx' is done in backend.
- Frontend: things are done in the same way as with class stats.
- **Need manual work**: `./oodikone/services/frontend/src/redux/elementdetails.js` add combined programme codes (both bachelor and master) to `combinedProgrammeCodes` and modify `combinedOptions` list accordingly in `useFilterAndFormattedElementDetails()` function.
+56 -63
View File
@@ -25,7 +25,7 @@ export default tseslint.config(
'nyc-config.cjs',
'.lintstagedrc.mjs',
'cypress.config.js',
]
],
},
// Global options (excl. e2e)
@@ -44,8 +44,8 @@ export default tseslint.config(
parser: tseslint.parser,
parserOptions: {
projectService: {
allowDefaultProject: ['*.{js,cjs}']
}
allowDefaultProject: ['*.{js,cjs}'],
},
},
globals: {
...globals.browser,
@@ -53,13 +53,13 @@ export default tseslint.config(
},
},
plugins: {
'import-x': eslintPluginImportX
'import-x': eslintPluginImportX,
},
linterOptions: {
reportUnusedDisableDirectives: 'error',
},
rules: {
'camelcase': 'off', // TODO: Enable. old db/backend logic is still using snake_case every now and then and needs a big refactor
camelcase: 'off', // TODO: Enable. old db/backend logic is still using snake_case every now and then and needs a big refactor
'class-methods-use-this': 'off', // Disabled in favor of @typescript-eslint
'consistent-return': 'off', // Disabled in favor of @typescript-eslint
'id-denylist': ['error', 'err'],
@@ -75,31 +75,42 @@ export default tseslint.config(
'prefer-const': 'error',
'prefer-destructuring': 'off', // Disabled in favor of @typescript-eslint
'dot-notation': 'off', // Disabled in favor of @typescript-eslint
'no-restricted-imports': ['error',
'no-restricted-imports': [
'error',
{
'patterns':
[
{ 'regex': '^@mui/[^/]+$', 'message': 'Import only the needed module. This has no effect in prod due to tree-shake, but speeds up development builds.' },
{ 'regex': '^src/.*', 'message': 'Root imports will not resolve in production build (instead silently crash).' }
]
}],
'quotes': ['error', 'single',
{ avoidEscape: true, allowTemplateLiterals: false }
patterns: [
{
regex: '^@mui/[^/]+$',
message:
'Import only the needed module. This has no effect in prod due to tree-shake, but speeds up development builds.',
},
{
regex: '^src/.*',
message: 'Root imports will not resolve in production build (instead silently crash).',
},
],
},
],
quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: false }],
'import-x/no-commonjs': 'error',
'import-x/no-default-export': 'error',
'import-x/no-extraneous-dependencies': ['error', { devDependencies: false }],
'import-x/no-unused-modules': ['error',
'import-x/no-unused-modules': [
'error',
{
missingExports: true,
unusedExports: true,
src: ['{services,updater}/**/*.{js,jsx,ts,tsx}']
}],
'import-x/order': ['error', {
alphabetize: { order: 'asc', caseInsensitive: true },
groups: [['builtin', 'external'], ['internal'], ['parent'], ['sibling', 'index']],
}],
src: ['{services,updater}/**/*.{js,jsx,ts,tsx}'],
},
],
'import-x/order': [
'error',
{
alphabetize: { order: 'asc', caseInsensitive: true },
groups: [['builtin', 'external'], ['internal'], ['parent'], ['sibling', 'index']],
},
],
// TypeScript
'@typescript-eslint/no-unused-vars': [
@@ -112,9 +123,7 @@ export default tseslint.config(
'@typescript-eslint/consistent-return': 'error', // NOTE: This is weaker than tsconfig.json noImplicitReturns, and should be replaced once ts migration complete
'@typescript-eslint/consistent-type-definitions': 'off',
'@typescript-eslint/dot-notation': 'error',
'@typescript-eslint/prefer-destructuring': ['error',
{ VariableDeclarator: { object: true } }
],
'@typescript-eslint/prefer-destructuring': ['error', { VariableDeclarator: { object: true } }],
'@typescript-eslint/no-empty-object-type': ['error', { allowInterfaces: 'with-single-extends' }],
'@typescript-eslint/no-explicit-any': 'off', // TODO: enable when feeling brave
'@typescript-eslint/no-implied-eval': 'error',
@@ -134,12 +143,12 @@ export default tseslint.config(
'services/frontend/tsconfig.json',
'services/frontend/tsconfig.node.json',
'services/backend/tsconfig.json',
'services/shared/tsconfig.json'
'services/shared/tsconfig.json',
],
}),
createNodeResolver({
extensions: ['.js', '.jsx', 'cjs', 'mjs', '.ts', '.tsx'],
})
}),
],
},
},
@@ -147,22 +156,22 @@ export default tseslint.config(
{
files: ['services/backend/src/routes/*.{ts,js}'],
rules: {
'@typescript-eslint/no-misused-promises': 'off', // Errors come mainly from Express route handlers that are handled correctly by express-async-errors
'@typescript-eslint/no-misused-promises': 'off', // Errors come mainly from Express route handlers that are handled correctly by express-async-errors
'@typescript-eslint/consistent-return': 'off',
}
},
},
// Disable type-aware linting for javascript files
{
files: ['{updater,services}/**/*.{js,cjs}'],
extends: [tseslint.configs.disableTypeChecked]
extends: [tseslint.configs.disableTypeChecked],
},
// Frontend camelCase and react
{
files: ['services/frontend/**/*.{js,jsx,mjs,cjs,ts,tsx}'],
plugins: {
'react': eslintPluginReact,
react: eslintPluginReact,
},
extends: [
eslintPluginReact.configs.flat.recommended,
@@ -173,14 +182,15 @@ export default tseslint.config(
parserOptions: {
ecmaFeatures: {
jsx: true,
}
}
},
},
},
rules: {
'camelcase': 'warn',
camelcase: 'warn',
'react/prop-types': 'off', // Typescript but worse
'react/no-unescaped-entities': 'off',
'react/function-component-definition': ['error',
'react/function-component-definition': [
'error',
{ namedComponents: 'arrow-function', unnamedComponents: 'arrow-function' },
],
'react/no-array-index-key': 'error',
@@ -188,9 +198,7 @@ export default tseslint.config(
'react/no-this-in-sfc': 'error',
'react/prefer-stateless-function': 'error',
'react/jsx-boolean-value': ['error', 'never'],
'react/jsx-filename-extension': ['error',
{ allow: 'as-needed', extensions: ['.jsx', '.tsx'] }
],
'react/jsx-filename-extension': ['error', { allow: 'as-needed', extensions: ['.jsx', '.tsx'] }],
'react/jsx-no-leaked-render': 'warn', // TODO: enable, tho not that useful after full ts migration
'react/jsx-no-useless-fragment': 'error',
'react/jsx-props-no-spreading': 'warn', // TODO: enable, improves readability
@@ -198,29 +206,25 @@ export default tseslint.config(
'react/jsx-tag-spacing': 'warn',
},
settings: {
'react': {
'version': 'detect'
}
}
react: {
version: 'detect',
},
},
},
// Cypress should only use rules provided by the plugin below
{
files: ['cypress/**/*.js'],
extends: [
eslintPluginCypress.configs.recommended
]
extends: [eslintPluginCypress.configs.recommended],
},
// Override for backend api tests
{
files: [
"services/backend/tests**/*.ts"
],
files: ['services/backend/tests/**/*.ts'],
rules: {
"import-x/no-extraneous-dependencies": "off",
'import-x/no-extraneous-dependencies': 'off',
'no-console': 'off',
}
},
},
// Overrides for CommonJS
@@ -228,7 +232,7 @@ export default tseslint.config(
files: [
'services/backend/**/*.js',
'updater/sis-updater-worker/src/db/migrations/*.cjs',
'updater/sis-updater-scheduler/**/*.js'
'updater/sis-updater-scheduler/**/*.js',
],
languageOptions: {
sourceType: 'commonjs',
@@ -239,20 +243,9 @@ export default tseslint.config(
},
},
// Rapodiff
{
files: ['services/backend/src/rapodiff/**/*'],
rules: {
'no-console': 'off',
},
},
// Migration files
{
files: [
'updater/sis-updater-worker/src/db/migrations/*.cjs',
'services/backend/src/database/**/*.js'
],
files: ['updater/sis-updater-worker/src/db/migrations/*.cjs', 'services/backend/src/database/**/*.js'],
rules: {
'import-x/no-unused-modules': 'off',
'@typescript-eslint/no-empty-function': 'off',
@@ -270,5 +263,5 @@ export default tseslint.config(
'no-promise-executor-return': 'off',
},
},
eslintPluginPrettierRecommended, // This must be the last plugin so it can override other configs
eslintPluginPrettierRecommended // This must be the last plugin so it can override other configs
)
+497 -263
View File
File diff suppressed because it is too large Load Diff
+8 -12
View File
@@ -6,9 +6,7 @@
},
"scripts": {
"cypress": "cypress",
"cypress:open": "cypress open",
"cypress:run": "cypress run --browser chrome",
"instrument": "./scripts/instrument.sh",
"cypress:run": "cypress run --browser firefox",
"eslint": "eslint",
"lint:all": "eslint services/ updater/",
"stylelint": "stylelint",
@@ -26,13 +24,11 @@
"testupdater": "docker-compose --file docker-compose.ci.yml up --detach --no-recreate sis-updater-worker && SCHEDULE_IMMEDIATE=weekly EXIT_AFTER_IMMEDIATES=yes docker-compose --file docker-compose.ci.yml run sis-updater-scheduler npm start",
"flushredis": "docker exec -it redis redis-cli flushall",
"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 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: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",
"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",
"ci:both": "npm run ci:local && npm run ci:docker"
},
"repository": {
@@ -46,21 +42,21 @@
"homepage": "https://github.com/UniversityOfHelsinkiCS/oodikone#readme",
"devDependencies": {
"@eslint/js": "^9.28.0",
"cypress": "^15.9.0",
"cypress": "^15.13.0",
"eslint": "^9.28.0",
"eslint-config-prettier": "^10.1.5",
"eslint-import-resolver-typescript": "^4.4.3",
"eslint-plugin-cypress": "^5.1.1",
"eslint-plugin-cypress": "^6.2.3",
"eslint-plugin-import-x": "^4.16.1",
"eslint-plugin-prettier": "^5.4.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"globals": "^16.2.0",
"globals": "^17.4.0",
"husky": "^9.1.7",
"lint-staged": "^16.1.0",
"prettier": "3.5.3",
"stylelint": "^16.20.0",
"stylelint-config-standard": "^39.0.0",
"stylelint": "^17.6.0",
"stylelint-config-standard": "^40.0.0",
"typescript-eslint": "^8.34.0"
}
}
+403 -271
View File
File diff suppressed because it is too large Load Diff
+5 -7
View File
@@ -9,23 +9,19 @@
"@oodikone/shared": "file:../shared",
"@sentry/node": "^10.43.0",
"@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",
"cron": "^4.3.1",
"dayjs": "^1.11.13",
"express": "^4.21.2",
"express-async-errors": "^3.1.1",
"lodash-es": "^4.17.23",
"express": "^5.2.1",
"lodash-es": "^4.18.1",
"lru-cache": "^11.1.0",
"morgan": "^1.10.0",
"papaparse": "^5.5.3",
"pg": "^8.20.0",
"redis": "^4.7.1",
"reflect-metadata": "^0.2.2",
"sequelize": "^6.37.8",
"sequelize-typescript": "^2.1.6",
"umzug": "^3.8.2",
@@ -37,9 +33,11 @@
"devDependencies": {
"@types/compression": "^1.8.1",
"@types/cors": "^2.8.19",
"@types/express": "^4.17.23",
"@types/express": "^5.0.6",
"@types/lodash-es": "^4.17.12",
"@types/morgan": "^1.9.10",
"@types/node": "^24.10.1",
"@types/pg": "^8.20.0",
"@types/supertest": "^6.0.3",
"nodemon": "^3.1.11",
"supertest": "^7.2.2",
-1
View File
@@ -1,4 +1,3 @@
import 'express-async-errors'
import express from 'express'
import { baseUrl, backendPort } from './config'
+1 -3
View File
@@ -7,9 +7,7 @@ export const isProduction = !isStaging && process.env.NODE_ENV === 'production'
export const runningInCI = process.env.CI === 'true'
// IAM group
export const requiredGroup = isStaging
? ['grp-oodikone-staging-users', 'grp-oodikone-basic-staging-users']
: ['grp-oodikone-users', 'grp-oodikone-basic-users']
export const requiredGroup = ['grp-oodikone-users', 'grp-oodikone-basic-users']
// Pate
export const pateToken = process.env.PATE_API_TOKEN ?? ''
@@ -2,7 +2,7 @@ import { Request, Response } from 'express'
import { omit } from 'lodash-es'
import morgan from 'morgan'
import { FormattedUser } from '../types'
import { FormattedUser } from '@oodikone/shared/types'
import { getFullStudyProgrammeRights } from '../util'
import logger from '../util/logger'
-2
View File
@@ -1,2 +0,0 @@
*.csv
*.csv#
-22
View File
@@ -1,22 +0,0 @@
# Rapodiff
Use this to check differences between oodikone and rapo.
## How to load the CSV file from Rapo
1. Go to Rapo and open ”Koulutusohjelmien vuosiseuranta”
2. Navigate to ”Opintopisteet ja opetus / Credits and teaching”
3. From the faculty dropdown menu, select all the faculties
4. From the bottom of the page, click ”Vie -> Tiedot -> CSV”
5. Rename the file to `data.csv` and copy it to this directory
## Usage
1. Have real oodikone running. Then run rapodiff **in the root directory**: `npm run rapodiff -- args`
2. place args with a mode. omit args to print possible modes.
## Known differences in APIs
- rapo does not show students that have graduated (it however takes some days for rapo to notice a graduation)
- rapo does not handle the starting year of transfers properly (shows in old staring population)
- rapo show some students incorrectly passive, see https://github.com/UniversityOfHelsinkiCS/oodikone/issues/3521
@@ -1,188 +0,0 @@
const { orderBy } = require('lodash-es')
// dbConnections must be imported to avoid Sequelize errors even if it's not used
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { dbConnections } = require('../database/connection')
const { computeCreditsProduced } = require('../services/providerCredits')
const { parseCsv, printProgressBar } = require('./helpers')
const diffs = []
let noDiffCounter = 0
// Change weird rapo programmecodes to oodikone format, example: 300-M003 => MH30_003
const transformProgrammeCode = oldCode => `${oldCode[4]}H${oldCode.slice(0, 2)}_${oldCode.slice(5, 8)}`
const formatData = data =>
data.reduce((obj, cur) => {
const parseNum = str => (str === '' ? 0 : parseFloat(str.replace(',', '.')))
const [
year,
facultyCode,
programmeInfo,
basic,
exchange,
otherUni,
openUni,
special,
total,
transferredFromAbroad,
otherTransferred,
] = cur
const programmeCode = transformProgrammeCode(programmeInfo.split(' ')[0])
if (!obj[programmeCode]) obj[programmeCode] = {}
obj[programmeCode][year] = {
year: parseNum(year),
facultyCode,
programmeInfo,
basic: parseNum(basic),
'incoming-exchange': parseNum(exchange),
agreement: parseNum(otherUni),
'open-uni': parseNum(openUni),
separate: parseNum(special),
total: parseNum(total),
transferred: parseNum(transferredFromAbroad) + parseNum(otherTransferred),
}
return obj
}, {})
const diff = (rapoData, okData, code) => {
for (const year of Object.keys(okData)) {
if (year !== '2022' && year !== '2023') continue
const rapo = rapoData[year]
const ok = okData[year]
if (!rapo) {
if (!ok) console.log(`Year ${year} not in rapo for code ${code}, skipping`)
continue
}
for (const field of ['basic', 'incoming-exchange', 'open-uni', 'agreement', 'separate', 'transferred']) {
const okValue = Math.round(ok[field] || 0)
const rapoValue = Math.round(rapo[field] || 0)
const diff = Math.abs(okValue - rapoValue)
if (diff === 0) {
noDiffCounter++
continue
}
const percentage = rapoValue !== 0 ? Number(((diff / rapoValue) * 100).toFixed(2)) : 100
diffs.push({ field, year, code, okValue, rapoValue, diff, 'diff %': percentage })
}
}
}
const processProgrammes = async data => {
let programmeCounter = 0
const rapoProgrammeData = formatData(data.slice(1))
const allProgrammeCodes = [...new Set(Object.keys(rapoProgrammeData))]
for (const programmeCode of allProgrammeCodes) {
const okProgrammeData = await computeCreditsProduced(programmeCode, false, true)
diff(rapoProgrammeData[programmeCode], okProgrammeData.stats, programmeCode)
printProgressBar(allProgrammeCodes.length, ++programmeCounter)
}
const fieldToSortBy = 'diff' // 'diff %'
const orderedDiffs = orderBy(diffs, fieldToSortBy, 'asc')
console.table(orderedDiffs)
console.log(`${diffs.length} diffs found with current settings. Numbers with no diff: ${noDiffCounter}`)
const totalDiff = orderedDiffs.reduce((sum, cur) => cur.diff + sum, 0)
console.log('Total difference: ', totalDiff)
}
const programmeCreditsDiff = async fileName => {
console.log('Running studyprogramme credits diff')
await parseCsv(fileName, processProgrammes)
console.log('Diff completed.')
}
/*
separate
Suoritus on erillisellä opiskeluoikeudella opettajankoulutuksen opintoja suorittavan opiskelijan suorittama
Suoritus on erillisellä opiskeluoikeudella opiskelevan suorittama
agreement
Suoritus on korkeakoulujen välisillä yhteistyösopimuksilla opiskelevan suorittama
open-uni
Suoritus on avoimessa korkeakouluopetuksessa suoritettu
incoming-exchange
Suoritus on tulevan kansainvälisen vaihto-opiskelijan suorittama
basic
perustutkinto-opiskelija = 1
ja
Tuntematon
tai (ehkä):
Suoritus on kotimainen harjoittelujakso
?? Opintosuoritus on täydennyskoulutuksessa suoritettu ??
other
perustutkinto-opiskelija = 0 && tuntematon
*/
const getCategory = (typeName, isBasic, hyvaksiluettu) => {
if (hyvaksiluettu) return 'transferred' // ?? unsure
if (isBasic || ['Suoritus on kotimainen harjoittelujakso', 'Tuntematon'].includes(typeName)) return 'basic'
if (typeName === 'Suoritus on avoimessa korkeakouluopetuksessa suoritettu') return 'open-uni'
if (typeName === 'Suoritus on tulevan kansainvälisen vaihto-opiskelijan suorittama') return 'incoming-exchange'
if (typeName.startsWith('Suoritus on erillisellä opiskeluoikeudella')) return 'separate'
if (typeName === 'Suoritus on korkeakoulujen välisillä yhteistyösopimuksilla opiskelevan suorittama')
return 'agreement'
return 'other'
}
const processIds = async (rawData, code, field) => {
const data = rawData.slice(0, 21628) // 11476 = KTM viimeinen
const rapoData = data
.map(row => ({
id: row[0].slice(11),
type: getCategory(row[1], row[3] === '1', row[6] === '1'),
typeCode: row[8],
isModule: row[9] !== '0',
credits: parseFloat(row[10]),
included: row[4] === '1',
}))
.filter(course => !course.isModule && (field === 'transferred' || course.included))
const rapoIds = rapoData.filter(course => course.credits > 0 && course.type === field).map(row => row.id)
const rapoCredits = rapoData.reduce(
(stats, cur) => {
stats[cur.type] += cur.credits
return stats
},
{
transferred: 0,
total: '_',
basic: 0,
'open-uni': 0,
separate: 0,
'incoming-exchange': 0,
agreement: 0,
other: 0,
}
)
const stats = await computeCreditsProduced(code, false, true, field)
if (!stats.stats.ids) {
console.log({ rapoCredits })
console.log('Edit getCreditsProduced code so that it saves a list of the relevant ids you want to compare.')
return
}
const okIdMap = stats.stats.ids
const rapoIdMap = rapoIds.reduce((obj, cur) => {
obj[cur] = true
return obj
}, {})
const onlyInRapo = Object.keys(rapoIdMap).filter(id => !okIdMap[id])
const onlyInOk = Object.keys(okIdMap).filter(id => !rapoIdMap[id])
console.log('\n onlyInRapo: ', onlyInRapo.length)
console.dir(onlyInRapo.slice(0, 30))
console.log('\n onlyInOk: ', onlyInOk.length)
console.dir(onlyInOk.slice(0, 30))
delete stats.ids
console.log(stats.stats['2022'])
console.log('rapoCredits: ', rapoCredits)
}
const testNewCalc = async (fileName, code, field) => {
await parseCsv(fileName, async data => processIds(data, code, field))
}
module.exports = { programmeCreditsDiff, testNewCalc }
-36
View File
@@ -1,36 +0,0 @@
const fs = require('fs/promises')
const Papa = require('papaparse')
const parseCsv = async (fileName, fun) => {
try {
const file = await fs.readFile(`${__dirname}/${fileName}`, 'utf8')
Papa.parsePromise = file => {
return new Promise((complete, error) => {
Papa.parse(file, { complete, error })
})
}
const data = await Papa.parsePromise(file)
await fun(data?.data)
} catch (error) {
console.log(error)
}
}
const printProgressBar = (total, current) => {
const barLength = 30
const progress = Math.floor((current / total) * barLength)
const empty = barLength - progress
const progressBar = `[${'#'.repeat(progress)}${'-'.repeat(empty)}]`
const percentage = Math.floor((current / total) * 100)
process.stdout.clearLine()
process.stdout.cursorTo(0)
process.stdout.write(`${progressBar} ${percentage} %`)
if (current >= total) {
console.log()
}
}
module.exports = { parseCsv, printProgressBar }
-31
View File
@@ -1,31 +0,0 @@
const { programmeCreditsDiff, testNewCalc } = require('./creditsDiff')
// ** To add new mode, just type key and function here **
const modes = {
// Check credits with current local oodikone data against provided data.csv in rapodiff folder.
credits: () => programmeCreditsDiff('data.csv'),
// Provide programme code as argument. Parses credits from credits.csv in rapodiff folder. This must be obtained from Rapo people. This tells which credit ids are found in oodikone but not rapo, and vice versa.
// notice: check mode requires writing ids in to the stats object in credits calculating function.
// Add something like this there:
// if (category === 'open-uni' && credits > 0 && new Date(attainmentDate).getFullYear() === 2022) stats.ids[id] = true
check: (fileName, code, field) => testNewCalc(fileName, code, field),
}
const main = async () => {
console.log('**RAPODIFF**\n')
const mode = process.argv[2]
const modeFunction = modes[mode]
const arg = process.argv[3]
const arg2 = process.argv[4]
const arg3 = process.argv[5]
if (!modeFunction) {
console.log(
'Unknown or missing mode. Supply the mode as an argument (e.g. `npm run rapodiff -- credits`). Supported modes:\n'
)
console.log(Object.keys(modes).join('\n'))
return
}
await modeFunction(arg, arg2, arg3)
}
main().then(() => process.exit(0))
+4 -4
View File
@@ -42,7 +42,7 @@ const routes = (app: Express, url: string) => {
app.use(compression())
app.use(headersMiddleware)
app.use(currentUserMiddleware) // eslint-disable-line @typescript-eslint/no-misused-promises
app.use(currentUserMiddleware)
app.use(accessLogger)
app.use(`${url}/login`, login)
app.use(url, courses)
@@ -52,8 +52,8 @@ const routes = (app: Express, url: string) => {
app.use(`${url}/semesters/codes`, semesters)
app.use(url, tags)
app.use(url, programmeModules)
app.use(`${url}/v3/course-exclusions`, courseExclusions)
app.use(`${url}/v2/studyprogrammes`, studyProgramme)
app.use(`${url}/course-exclusions`, courseExclusions)
app.use(`${url}/studyprogrammes`, studyProgramme)
app.use(`${url}/programme-criteria`, studyProgrammeCriteria)
app.use(`${url}/openunisearch`, auth.roles(['openUniSearch']), customOpenUniSearch)
app.use(`${url}/changelog`, changelog)
@@ -72,7 +72,7 @@ const routes = (app: Express, url: string) => {
app.use(`${url}/close-to-graduation`, auth.roles(['fullSisuAccess', 'studyGuidanceGroups']), closeToGraduation)
app.use(`${url}/study-programme-pins`, studyProgrammePins)
app.use(`${url}/curriculum-periods`, curriculumPeriods)
app.get('*', (_, res) => {
app.get('', (_, res) => {
res.status(404).json({ error: 'unknown endpoint' })
})
Sentry.setupExpressErrorHandler(app)
@@ -14,7 +14,7 @@ router.get<never, CloseToGraduationResBody>('/', async (req, res) => {
const studentNumbers = await getAllStudentsUserHasInGroups(user.sisPersonId)
const result = await getCloseToGraduationData(studentNumbers)
res.json(result)
return res.json(result)
}
const result = await getCloseToGraduationData()
+12 -5
View File
@@ -12,12 +12,17 @@ import {
import type { CourseWithSubsId } from '@oodikone/shared/types/course'
import { getCourseYearlyStats } from '../services/courses'
import { getCoursesByNameAndOrCode } from '../services/courses/courseFinders'
import { getFullStudyProgrammeRights, hasFullAccessToStudentData, validateParamLength } from '../util'
import {
getFullStudyProgrammeRights,
handleQueryArrays,
hasFullAccessToStudentData,
validateParamLength,
} from '../util'
const router = Router()
router.get<never, CanError<CoursesMultiResBody>, CoursesMultiReqBody, CoursesMultiQuery>(
'/v2/coursesmulti',
'/coursesmulti',
async (req, res) => {
const { name, code, includeSpecial } = req.query
if (!(validateParamLength(name, 5) || validateParamLength(code, 2))) {
@@ -49,11 +54,13 @@ router.get<never, CanError<CoursesMultiResBody>, CoursesMultiReqBody, CoursesMul
type CourseYearlyStatsResBody = Awaited<ReturnType<typeof getCourseYearlyStats>>
router.get<never, CanError<CourseYearlyStatsResBody>, CourseYearlyStatsReqBody, CourseYearlyStatsQuery>(
'/v3/courseyearlystats',
'/courseyearlystats',
async (req, res) => {
const { codes, combineSubstitutions, separate } = req.query
if (!codes) {
const courseCodes = handleQueryArrays(codes)
if (!courseCodes) {
return res.status(422).send({ error: 'Missing required query parameters' })
}
@@ -75,7 +82,7 @@ router.get<never, CanError<CourseYearlyStatsResBody>, CourseYearlyStatsReqBody,
const useCombineSubstitutions = combineSubstitutions !== 'false'
const useSeparate = separate === 'true'
const results = await getCourseYearlyStats(codes, useSeparate, anonymizationSalt, useCombineSubstitutions)
const results = await getCourseYearlyStats(courseCodes, useSeparate, anonymizationSalt, useCombineSubstitutions)
res.json(results)
}
)
+1 -1
View File
@@ -1,8 +1,8 @@
import { Router } from 'express'
import { CanError } from '@oodikone/shared/routes'
import { FormattedUser } from '@oodikone/shared/types'
import { omitKeys } from '@oodikone/shared/util'
import { FormattedUser } from '../types/user'
import { ApplicationError } from '../util/customErrors'
const router = Router()
+12 -13
View File
@@ -26,7 +26,7 @@ import { parseDateRangeFromParams } from '../services/populations/shared'
import { statisticsOf } from '../services/populations/statisticsOf'
import { getStudentNumbersWithStudyRights } from '../services/populations/studentNumbersWithStudyRights'
import { findByCourseAndSemesters } from '../services/students'
import { getFullStudyProgrammeRights, hasFullAccessToStudentData } from '../util'
import { getFullStudyProgrammeRights, handleQueryArrays, hasFullAccessToStudentData } from '../util'
const router = Router()
@@ -98,15 +98,11 @@ const obfuscateStuff = ({
}
router.get<never, CanError<PopulationstatisticsResBody>, PopulationstatisticsReqBody, PopulationstatisticsQuery>(
'/v3/populationstatistics',
'/populationstatistics',
async (req, res) => {
const { id: userId, roles: userRoles, programmeRights: userProgrammeRights, studentsUserCanAccess } = req.user
const { programme, combinedProgramme } = req.query
// NOTE: Make sure we have only arrays, "/...&years=2021" is parsed by express as years: 2021, not [2021]
const handleQueryArrays = <T>(requestField: T | T[]): T[] => {
return Array.isArray(requestField) ? requestField : [requestField]
}
const years = handleQueryArrays(req.query.years)
const semesters = handleQueryArrays(req.query.semesters)
const studentStatuses = handleQueryArrays(req.query.studentStatuses) // NOTE: TRANSFERRED means transferred out of the program
@@ -172,7 +168,7 @@ router.get<
CanError<PopulationstatisticsbycourseResBody>,
PopulationstatisticsbycourseReqBody,
PopulationstatisticsbycourseParams
>('/v3/populationstatisticsbycourse', async (req, res) => {
>('/populationstatisticsbycourse', async (req, res) => {
const {
id: userId,
roles: userRoles,
@@ -222,13 +218,16 @@ router.get<
// Used in custom population and single study guidance groups
router.post<never, GetCustomPopulationResBody, CustomPopulationQuery>(
'/v3/populationstatisticsbystudentnumbers',
'/populationstatisticsbystudentnumbers',
async (req, res) => {
const { studentNumbers, tags } = req.body
const { id: userId, roles, studentsUserCanAccess } = req.user
const students = handleQueryArrays(studentNumbers)
const filteredStudentNumbers = hasFullAccessToStudentData(roles)
? studentNumbers
: intersection(studentNumbers, studentsUserCanAccess)
? students
: intersection(students, studentsUserCanAccess)
const studyProgrammeCode = tags?.studyProgramme?.split('+')[0]
@@ -242,14 +241,14 @@ router.post<never, GetCustomPopulationResBody, CustomPopulationQuery>(
const result = await statisticsOf(filteredStudentNumbers, studyRights, tagMap, startDate)
const resultWithStudyProgramme = { ...result, studyProgramme: tags?.studyProgramme }
const discardedStudentNumbers = difference(studentNumbers, filteredStudentNumbers)
const discardedStudentNumbers = difference(students, filteredStudentNumbers)
res.status(200).json({ ...resultWithStudyProgramme, discardedStudentNumbers })
}
)
router.get<never, PopulationstatisticsStudyprogrammesResBody>(
'/v3/populationstatistics/studyprogrammes',
'/populationstatistics/studyprogrammes',
async (req, res) => {
const { roles, programmeRights } = req.user
const programmes = await getDegreeProgrammesOfOrganization(rootOrgId, false)
@@ -275,7 +274,7 @@ router.get<
PopulationstatisticsMaxYearsToCreatePopulationFormResBody,
never,
PopulationstatisticsMaxYearsToCreatePopulationFormQuery
>('/v3/populationstatistics/maxYearsToCreatePopulationFrom', async (req, res) => {
>('/populationstatistics/maxYearsToCreatePopulationFrom', async (req, res) => {
const courseCodes = JSON.parse(req.query.courseCodes) as string[]
const maxYearsToCreatePopulationFromOpen = await maxYearsToCreatePopulationFrom(courseCodes, Unification.OPEN)
const maxYearsToCreatePopulationFromUni = await maxYearsToCreatePopulationFrom(courseCodes, Unification.REGULAR)
@@ -1,16 +1,21 @@
import { Request, Response, Router } from 'express'
import { Router } from 'express'
import { CurriculumOption } from '@oodikone/shared/types'
import { getCoursesAndModules, getCurriculumOptions } from '../services/programmeModules'
const router = Router()
router.get('/v3/curriculum-options/:code', async (req: Request, res: Response) => {
type CurriculumOptionParams = { code: string }
router.get<CurriculumOptionParams, CurriculumOption[] | null>('/curriculum-options/:code', async (req, res) => {
const { code } = req.params
const result = await getCurriculumOptions(code)
res.json(result)
})
router.get('/v3/curriculum/:code/:periodIds', async (req: Request, res: Response) => {
type CurriculumPeriodParams = { code: string; periodIds: string }
router.get<CurriculumPeriodParams>('/curriculum/:code/:periodIds', async (req, res) => {
const { code, periodIds } = req.params
const { defaultProgrammeCourses, secondProgrammeCourses } = await getCoursesAndModules(code, periodIds)
+63 -66
View File
@@ -1,4 +1,4 @@
import { Request, Response, Router } from 'express'
import { Router } from 'express'
import { Graduated, SpecialGroups, YearType } from '@oodikone/shared/types'
import { CreditStatsPayload } from '@oodikone/shared/types/studyProgramme'
@@ -23,15 +23,15 @@ import { logInfoForGrafana } from '../util/logInfoForGrafana'
const router = Router()
interface GetCreditStatsRequest extends Request {
query: {
codes: string
specialGroups: SpecialGroups
yearType: YearType
}
type StudyProgrammeParams = { id: string }
type UpdateStudyProgrammeQuery = { combined_programme: string }
type CreditStatsQuery = {
codes: string
specialGroups: SpecialGroups
yearType: YearType
}
router.get('/creditstats', async (req: GetCreditStatsRequest, res: Response) => {
router.get<StudyProgrammeParams, unknown, never, CreditStatsQuery>('/creditstats', async (req, res) => {
const { codes, specialGroups, yearType } = req.query
const stats: CreditStatsPayload = {}
for (const code of JSON.parse(codes)) {
@@ -40,15 +40,13 @@ router.get('/creditstats', async (req: GetCreditStatsRequest, res: Response) =>
return res.json(stats)
})
interface GetStatsRequest extends Request {
query: {
year_type: YearType
special_groups: SpecialGroups
combined_programme: string
}
type StatsRequestQuery = {
year_type: YearType
special_groups: SpecialGroups
combined_programme: string
}
router.get('/:id/basicstats', async (req: GetStatsRequest, res: Response) => {
router.get<StudyProgrammeParams, unknown, never, StatsRequestQuery>('/:id/basicstats', async (req, res) => {
const code = req.params.id
const { year_type: yearType, special_groups: specialGroups, combined_programme: combinedProgramme } = req.query
if (!code) {
@@ -75,7 +73,7 @@ router.get('/:id/basicstats', async (req: GetStatsRequest, res: Response) => {
return res.json(updatedStats)
})
router.get('/:id/graduationstats', async (req: GetStatsRequest, res: Response) => {
router.get<StudyProgrammeParams, unknown, never, StatsRequestQuery>('/:id/graduationstats', async (req, res) => {
const code = req.params.id
const { year_type: yearType, special_groups: specialGroups, combined_programme: combinedProgramme } = req.query
if (!code) {
@@ -102,14 +100,12 @@ router.get('/:id/graduationstats', async (req: GetStatsRequest, res: Response) =
return res.json(updatedStats)
})
interface GetCourseStatsRequest extends Request {
query: {
combinedProgramme: string
yearType: YearType
}
type CourseStatsQuery = {
combinedProgramme: string
yearType: YearType
}
router.get('/:id/coursestats', async (req: GetCourseStatsRequest, res: Response) => {
router.get<StudyProgrammeParams, unknown, never, CourseStatsQuery>('/:id/coursestats', async (req, res) => {
const code = req.params.id
const { combinedProgramme, yearType } = req.query
const date = new Date()
@@ -123,15 +119,13 @@ router.get('/:id/coursestats', async (req: GetCourseStatsRequest, res: Response)
}
})
interface GetStudyTrackStatsRequest extends Request {
query: {
graduated: Graduated
special_groups: SpecialGroups
combined_programme: string
}
type StudyTrackStatsQuery = {
graduated: Graduated
special_groups: SpecialGroups
combined_programme: string
}
router.get('/:id/studytrackstats', async (req: GetStudyTrackStatsRequest, res: Response) => {
router.get<StudyProgrammeParams, unknown, never, StudyTrackStatsQuery>('/:id/studytrackstats', async (req, res) => {
const code = req.params.id
const { special_groups: specialGroups, combined_programme: combinedProgramme } = req.query
if (!code) {
@@ -153,13 +147,16 @@ router.get('/:id/studytrackstats', async (req: GetStudyTrackStatsRequest, res: R
},
studyRightsOfProgramme,
})
if (updated) {
await setStudyTrackStats(updated, specialGroups)
return res.json(updated)
}
return res.json(updated)
return res.status(500).end()
})
router.get('/:id/colorizedtablecoursestats', async (req: Request, res: Response) => {
router.get<StudyProgrammeParams>('/:id/colorizedtablecoursestats', async (req, res) => {
const code = req.params.id
try {
const data = await getStudyProgrammeStatsForColorizedCoursesTable(code)
@@ -169,7 +166,7 @@ router.get('/:id/colorizedtablecoursestats', async (req: Request, res: Response)
}
})
router.get('/:id/studytracks', async (req: Request, res: Response) => {
router.get<StudyProgrammeParams>('/:id/studytracks', async (req, res) => {
const code = req.params.id
if (!code) {
return res.status(422).end()
@@ -182,42 +179,42 @@ router.get('/:id/studytracks', async (req: Request, res: Response) => {
}
})
interface GetUpdateViewRequest extends Request {
query: {
combined_programme: string
router.get<StudyProgrammeParams, unknown, never, UpdateStudyProgrammeQuery>(
'/:id/update_basicview',
async (req, res) => {
const code = req.params.id
const combinedProgramme = req.query?.combined_programme
if (!code) {
return res.status(400).json({ error: 'Missing code' })
}
try {
const result = await updateBasicView(code, combinedProgramme)
return res.json(result)
} catch (error) {
const message = `Failed to update basic stats for programme ${code}${combinedProgramme ? `+${combinedProgramme}` : ''}`
logger.error(message, { error })
return res.status(500).json({ error: message })
}
}
}
)
router.get('/:id/update_basicview', async (req: GetUpdateViewRequest, res: Response) => {
const code = req.params.id
const combinedProgramme = req.query?.combined_programme
if (!code) {
return res.status(400).json({ error: 'Missing code' })
router.get<StudyProgrammeParams, unknown, never, UpdateStudyProgrammeQuery>(
'/:id/update_studytrackview',
async (req, res) => {
const code = req.params.id
const combinedProgramme = req.query?.combined_programme
if (!code) {
return res.status(400).json({ error: 'Missing code' })
}
try {
const result = await updateStudyTrackView(code, combinedProgramme)
return res.json(result)
} catch (error) {
const message = `Failed to update study track stats for programme ${code}${combinedProgramme ? `+${combinedProgramme}` : ''}`
logger.error(message, { error })
return res.status(500).json({ error: message })
}
}
try {
const result = await updateBasicView(code, combinedProgramme)
return res.json(result)
} catch (error) {
const message = `Failed to update basic stats for programme ${code}${combinedProgramme ? `+${combinedProgramme}` : ''}`
logger.error(message, { error })
return res.status(500).json({ error: message })
}
})
router.get('/:id/update_studytrackview', async (req: GetUpdateViewRequest, res: Response) => {
const code = req.params.id
const combinedProgramme = req.query?.combined_programme
if (!code) {
return res.status(400).json({ error: 'Missing code' })
}
try {
const result = await updateStudyTrackView(code, combinedProgramme)
return res.json(result)
} catch (error) {
const message = `Failed to update study track stats for programme ${code}${combinedProgramme ? `+${combinedProgramme}` : ''}`
logger.error(message, { error })
return res.status(500).json({ error: message })
}
})
)
export default router
+4 -3
View File
@@ -5,7 +5,7 @@ import * as auth from '../middleware/auth'
import { getProvidersOfFaculty, isFaculty } from '../services/organizations'
import { getTeachersBySearchTerm, getTeacherStatistics, getYearlyStatistics } from '../services/teachers'
import { CategoryID, getTeacherStats, findAndSaveTeachers, getCategoriesAndYears } from '../services/teachers/top'
import { getFullStudyProgrammeRights, validateParamLength } from '../util'
import { getFullStudyProgrammeRights, handleQueryArrays, validateParamLength } from '../util'
const router = Router()
@@ -91,11 +91,12 @@ router.get('/stats', async (req: GetTeacherStatsRequest, res: Response) => {
const { roles, programmeRights, iamGroups } = req.user
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
const { providers, semesterStart, semesterEnd } = req.query
if (!providers || !semesterStart) {
const { providers: providerStack, semesterStart, semesterEnd } = req.query
if (!providerStack || !semesterStart) {
return res.status(422).send('Missing required query parameters')
}
const providers = handleQueryArrays(providerStack)
const providerRights = mapToProviders(fullStudyProgrammeRights)
if (
!(
+1 -7
View File
@@ -14,7 +14,6 @@ import {
setFacultyProgressStats,
} from '../services/faculty/facultyService'
import { combineFacultyStudentProgress, FacultyProgressData } from '../services/faculty/facultyStudentProgress'
import { GraduationTarget } from '../services/graduationHelpers'
import { getMedian } from '../services/studyProgramme/studyProgrammeHelpers'
import logger from '../util/logger'
@@ -138,12 +137,7 @@ router.get('/allgraduationstats', async (_req: Request, res: Response) => {
}
const universityData = {
goals: {
bachelor: GraduationTarget.THREE_YEARS,
bcMsCombo: GraduationTarget.FIVE_YEARS,
master: GraduationTarget.TWO_YEARS,
doctor: GraduationTarget.FOUR_YEARS,
},
goals: { bachelor: 36, bcMsCombo: 60, master: 24, doctor: 48 },
programmeNames: getProgrammeNames(allFaculties),
byGradYear: {
medians: {} as Record<string, LevelGraduationStats[]>,
+4 -4
View File
@@ -26,14 +26,14 @@ const refreshProgrammesByList = (programmeCodes: string[]) => {
return 'Added jobs for refreshing programme'
}
router.get('/update/v2/meta', async (_req: Request, res: Response) => {
router.get('/update/meta', async (_req: Request, res: Response) => {
const response = await updateSISMetadata()
if (response) {
res.status(200).json('Update SIS meta scheduled')
}
})
router.get('/update/v2/students', async (_req: Request, res: Response) => {
router.get('/update/students', async (_req: Request, res: Response) => {
const response = await updateSISStudents()
if (response) {
res.status(200).json('Update SIS students scheduled')
@@ -47,7 +47,7 @@ interface PostCustomListRequest extends Request {
}
}
router.post('/update/v2/customlist/:type', async (req: PostCustomListRequest, res: Response) => {
router.post('/update/customlist/:type', async (req: PostCustomListRequest, res: Response) => {
const { type } = req.params
const list = req.body
const typeToJob = {
@@ -65,7 +65,7 @@ router.post('/update/v2/customlist/:type', async (req: PostCustomListRequest, re
}
})
router.get('/update/v2/programmes', async (_req: Request, res: Response) => {
router.get('/update/programmes', async (_req: Request, res: Response) => {
const response = await updateSISProgrammes()
if (response) {
res.status(200).json('Update SIS programmes scheduled')
+27 -57
View File
@@ -1,33 +1,34 @@
import { Router } from 'express'
import { LANGUAGE_CODES, Language } from '@oodikone/shared/language'
import { LANGUAGE_CODES } from '@oodikone/shared/language'
import { CanError } from '@oodikone/shared/routes'
import { Role } from '@oodikone/shared/types'
import {
ChangeLanguageReqBody,
GetUserByIdParams,
GetUserByIdResBody,
GetUserRolesResBody,
GetUsersResBody,
ModifyUserRolesReqBody,
PostUserEmailReqBody,
UserEmailPreviewResBody,
UserIDElementsParams,
UserIDElementsReqBody,
} from '@oodikone/shared/routes/users'
import { tryCatch } from '@oodikone/shared/util'
import { roles } from '../config/roles'
import * as auth from '../middleware/auth'
import { sendNotificationAboutAccessToUser, previewNotificationAboutAccessToUser } from '../services/mailService'
import * as userService from '../services/userService'
import { FormattedUser } from '../types'
import logger from '../util/logger'
const router = Router()
type GetUsersResBody = Omit<FormattedUser, 'studentsUserCanAccess' | 'isAdmin' | 'mockedBy' | 'userId'>[]
router.get<never, CanError<GetUsersResBody>>('/', auth.roles(['admin']), async (_, res) => {
const results = await userService.findAll()
res.json(results)
})
type GetUserRolesResBody = readonly string[]
router.get<never, CanError<GetUserRolesResBody>>('/roles', auth.roles(['admin']), (_, res) => {
res.json(roles)
})
type GetUserByIdParams = { uid: string }
type GetUserByIdResBody = Omit<FormattedUser, 'studentsUserCanAccess' | 'isAdmin' | 'mockedBy' | 'userId'>
router.get<never, GetUserRolesResBody>('/roles', auth.roles(['admin']), (_, res) => res.json(roles))
router.get<never, CanError<GetUserByIdResBody>, never, GetUserByIdParams>(
'/:uid',
@@ -41,61 +42,35 @@ router.get<never, CanError<GetUserByIdResBody>, never, GetUserByIdParams>(
}
)
type ModifyUserRolesReqBody = {
username: string
roles: Record<Role, boolean>
}
type ModifyUserRolesResBody = never
router.post<never, CanError<ModifyUserRolesResBody, Error>, ModifyUserRolesReqBody>(
router.post<never, CanError<void, Error>, ModifyUserRolesReqBody>(
'/modify-roles',
auth.roles(['admin']),
async (req, res) => {
const { username, roles } = req.body
const { error } = await tryCatch(userService.modifyAccess(username, roles))
if (error) {
return res.status(400).json(error)
}
if (error) return res.status(400).json(error)
return res.status(204).end()
}
)
type UserEmailPreviewResBody = {
subject: string
html: string
}
router.get<never, CanError<UserEmailPreviewResBody>>('/email/preview', auth.roles(['admin']), (_, res) => {
const { accessMessageSubject, accessMessageText } = previewNotificationAboutAccessToUser()
res.json({ subject: accessMessageSubject, html: accessMessageText })
})
type PostUserEmailReqBody = { email: string }
type PostUserEmailResBody = never
router.post<never, CanError<void>, PostUserEmailReqBody>('/email', auth.roles(['admin']), async (req, res) => {
const { email } = req.body
if (!email) return res.status(400).json({ error: 'Email address is missing' })
router.post<never, CanError<PostUserEmailResBody>, PostUserEmailReqBody>(
'/email',
auth.roles(['admin']),
async (req, res) => {
const { email } = req.body
if (!email) return res.status(400).json({ error: 'Email address is missing' })
const { error } = await tryCatch(sendNotificationAboutAccessToUser(email))
if (error) return res.status(500).json({ error: error.message })
const { error } = await tryCatch(sendNotificationAboutAccessToUser(email))
if (error) return res.status(500).json({ error: error.message })
logger.info('Succesfully sent message about Oodikone access to user')
return res.status(200).end()
})
logger.info('Succesfully sent message about Oodikone access to user')
return res.status(200).end()
}
)
type ElementsParams = { uid: string }
type ElementsReqBody = { codes: string[] }
type PostUserUIDElementsResBody = void
router.post<never, CanError<PostUserUIDElementsResBody>, ElementsReqBody, ElementsParams>(
router.post<never, CanError<void>, UserIDElementsReqBody, UserIDElementsParams>(
'/:uid/elements',
auth.roles(['admin']),
async (req, res) => {
@@ -106,9 +81,7 @@ router.post<never, CanError<PostUserUIDElementsResBody>, ElementsReqBody, Elemen
}
)
type DeleteUserUIDElementsResBody = void
router.delete<never, CanError<DeleteUserUIDElementsResBody>, ElementsReqBody, ElementsParams>(
router.delete<never, CanError<void>, UserIDElementsReqBody, UserIDElementsParams>(
'/:uid/elements',
auth.roles(['admin']),
async (req, res) => {
@@ -119,10 +92,7 @@ router.delete<never, CanError<DeleteUserUIDElementsResBody>, ElementsReqBody, El
}
)
type ChangeLanguageReqBody = { language: Language }
type PostChangeLanguageResBody = void
router.post<never, CanError<PostChangeLanguageResBody>, ChangeLanguageReqBody>('/language', async (req, res) => {
router.post<never, CanError<void>, ChangeLanguageReqBody>('/language', async (req, res) => {
const { language } = req.body
if (!LANGUAGE_CODES.includes(language)) {
+1 -1
View File
@@ -1,7 +1,7 @@
import axios from 'axios'
import { FormattedUser } from '@oodikone/shared/types'
import { isProduction, pateToken } from '../config'
import { FormattedUser } from '../types'
import logger from '../util/logger'
const pateClient = axios.create({
+5 -13
View File
@@ -3,7 +3,7 @@ const axios = require('axios')
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 {
@@ -25,20 +25,20 @@ const getSisuAccessToken = async eppn => {
return sisuResponse.data.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,7 +52,7 @@ const personSearchQuery = `query privatePerson($subjectUserId: ID!) {
}
}`
const getGraphqlData = async (accessToken, queryObject) => {
export const getGraphqlData = async (accessToken, queryObject) => {
const graphQlUrl = `${sisUrl}/api`
const authHeader = `Application ${accessToken}`
const config = {
@@ -73,11 +73,3 @@ const getGraphqlData = async (accessToken, queryObject) => {
}
return graphQlResponse.data.data.private_person
}
module.exports = {
getSisuAccessToken,
decodeJwtTokenPayloadToObject,
getSisuAuthData,
personSearchQuery,
getGraphqlData,
}
+2 -2
View File
@@ -55,7 +55,7 @@ export const getCurrentSemester = async () => {
export const getSemestersAndYears = async () => {
const semesters = await SemesterModel.findAll()
return semesters.reduce(
return semesters.reduce<SemestersAndYears>(
(acc, semester) => {
const { semestercode, name, yearcode, yearname, startdate, enddate } = semester
acc.semesters[semestercode] = { semestercode, name, yearcode, startdate, enddate }
@@ -68,6 +68,6 @@ export const getSemestersAndYears = async () => {
return acc
},
{ years: {}, semesters: {} } as SemestersAndYears
{ years: {}, semesters: {} }
)
}
@@ -8,39 +8,39 @@ const client = axios.create({ baseURL: SIS_UPDATER_URL })
const params = { params: { token: SECRET_TOKEN } }
export const abortUpdate = async () => {
const response = await client.get('/v1/abort', params)
const response = await client.get('/abort', params)
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('/courses', data, params)
return response.data
}
export const updateSISMetadata = async () => {
const response = await client.get('/v1/meta', params)
const response = await client.get('/meta', params)
return response.data
}
export const updateSISProgrammes = async () => {
const response = await client.get('/v1/programmes', params)
const response = await client.get('/programmes', params)
return response.data
}
export const updateSISRedisCache = async () => {
const response = await client.get('/v1/rediscache', params)
const response = await client.get('/rediscache', params)
return response.data
}
export const updateSISStudents = async () => {
const response = await client.get('/v1/students', params)
const response = await client.get('/students', params)
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('/students', data, params)
return response.data
}
@@ -54,6 +54,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('studyplans', { studentnumbers }, params)
return response.data
}
@@ -13,34 +13,33 @@ export function getYearsArray(since: number, isAcademicYear: false, yearsCombine
export function getYearsArray(since: number, isAcademicYear: false, yearsCombined?: false): number[]
export function getYearsArray(since: number, isAcademicYear: boolean, yearsCombined?: boolean): Array<string | number>
export function getYearsArray(since: number, isAcademicYear: boolean, yearsCombined?: boolean) {
const years: Array<string | number> = []
const allYears = 'Total'
if (yearsCombined) {
years.push(allYears)
}
const today = new Date()
const until = isAcademicYear && today.getMonth() < 7 ? today.getFullYear() - 1 : today.getFullYear()
for (let i = since; i <= until; i++) {
const year = isAcademicYear ? `${i} - ${i + 1}` : i
years.push(year)
const years: Array<string | number> = []
if (yearsCombined) {
years.push('Total')
}
for (let i = since; i <= until; i++) {
years.push(isAcademicYear ? `${i} - ${i + 1}` : i)
}
return years
}
export function getYearsObject(params: { years: Array<string | number>; emptyArrays: true }): Record<string, []>
export function getYearsObject(params: { years: Array<string | number>; emptyArrays?: false }): Record<string, 0>
export function getYearsObject(params: { years: Array<string | number>; emptyArrays?: false }): Record<string, number>
export function getYearsObject(params: { years: Array<string | number>; emptyArrays?: boolean }) {
const { years, emptyArrays = false } = params
return years.reduce<Record<string, 0 | []>>((acc, year) => {
acc[year] = emptyArrays ? [] : 0
return acc
}, {})
const { years, emptyArrays } = params
return Object.fromEntries(years.map(year => [year, emptyArrays ? [] : 0]))
}
export const getStatsBasis = (years: Array<string | number>) => {
return {
graphStats: new Array<number>(years.length).fill(0),
tableStats: getYearsObject({ years }) as Record<string, number>,
tableStats: getYearsObject({ years }),
}
}
@@ -1,7 +1,7 @@
import { col, fn, Includeable, Op } from 'sequelize'
import { SISStudyRight } from '@oodikone/shared/models'
import { Name, CreditTypeCode } from '@oodikone/shared/types'
import { CreditTypeCode } from '@oodikone/shared/types'
import { StudentModel, SISStudyRightModel, SISStudyRightElementModel, CreditModel } from '../../models'
export const getStudyRightsInProgramme = async (
@@ -73,20 +73,15 @@ export const getStudyTracksForProgramme = async (studyProgramme: string) => {
attributes: [[fn('DISTINCT', col('study_track')), 'studyTrack']],
where: {
code: studyProgramme,
study_track: { [Op.ne]: null },
},
raw: true,
})
return result
.map(studyTrack => studyTrack.studyTrack)
.filter(studyTrack => studyTrack != null)
.reduce<Record<string, Name | 'All students of the programme'>>(
(acc, track) => {
acc[track.code] = track.name
return acc
},
{ [studyProgramme]: 'All students of the programme' }
)
return Object.assign(
{ [studyProgramme]: 'All students of the programme' },
Object.fromEntries(result.map(({ studyTrack }) => [studyTrack!.code, studyTrack!.name]))
)
}
export const getSISStudyRightsOfStudents = async (
@@ -96,11 +91,11 @@ export const getSISStudyRightsOfStudents = async (
> =>
(
await SISStudyRightModel.findAll({
attributes: ['id', 'studentNumber', 'extentCode', 'semesterEnrollments', 'startDate', 'endDate'],
where: {
studentNumber: {
[Op.in]: studentNumbers,
},
},
attributes: ['id', 'studentNumber', 'extentCode', 'semesterEnrollments', 'startDate', 'endDate'],
})
).map(studyRight => studyRight.toJSON())
@@ -206,8 +206,7 @@ const getMainStatsByTrackAndYear = async (
startedInProgramme
)
if (firstPresentAt) {
const firstPresentYear = defineYear(firstPresentAt, true)
if (year === firstPresentYear) {
if (year === defineYear(firstPresentAt, true)) {
yearlyStats[year][programmeOrStudyTrack].started += 1
}
}
@@ -332,33 +331,35 @@ const getMainStatsByTrackAndYear = async (
emptyArrays: true,
})
const creditCountsByTrack: Record<string, Record<string, number[]>> = {}
const creditCountsCombo: Record<string, number[]> = doCombo
? getYearsObject({ years: years.filter(year => year !== 'Total'), emptyArrays: true })
: {}
const creditCountsComboByTrack: Record<string, Record<string, number[]>> = {}
for (const studyRight of studyRightsOfProgramme) {
if (!studyRight.semesterEnrollments || !studyRight.student) {
continue
}
if (!studyRight.semesterEnrollments || !studyRight.student) continue
const studyRightElement = studyRight.studyRightElements.find(element => element.code === studyProgramme)
if (!studyRightElement) {
continue
}
if (!studyRightElement) continue
const [hasTransferredFromProgramme, hasTransferredToProgramme] = hasTransferredFromOrToProgramme(
studyRight,
studyRightElement
)
if (!includeAllSpecials && (hasTransferredToProgramme || hasTransferredFromProgramme)) {
continue
}
if (!includeAllSpecials && (hasTransferredToProgramme || hasTransferredFromProgramme)) continue
const startedInProgramme = new Date(studyRightElement.startDate)
const startYear = defineYear(startedInProgramme, true)
if (!years.includes(startYear)) {
continue
}
if (!years.includes(startYear)) continue
const startedInBachelor = getStudyRightElementsWithPhase(studyRight, 1)[0]?.startDate
const bachelorsStartYear = defineYear(startedInBachelor, true)
updateCounts(
startYear,
studyProgramme,
@@ -380,18 +381,31 @@ const getMainStatsByTrackAndYear = async (
)
}
const countAsBachelorMaster = doCombo && studyRight.extentCode === ExtentCode.BACHELOR_AND_MASTER
// TODO: FIXME: This could be written better
if (!studyRightElement.graduated) {
if (countAsBachelorMaster) {
const startedInBachelor = getStudyRightElementsWithPhase(studyRight, 1)[0]?.startDate
if (startedInBachelor && years.includes(defineYear(startedInBachelor, true))) {
creditCountsCombo[defineYear(startedInBachelor, true)].push(
getCreditCount(studyRight.student.credits, startedInBachelor)
if (doCombo && studyRight.extentCode === ExtentCode.BACHELOR_AND_MASTER && years.includes(bachelorsStartYear)) {
creditCountsCombo[bachelorsStartYear].push(getCreditCount(studyRight.student.credits, startedInBachelor))
if (studyTrack) {
creditCountsComboByTrack[studyTrack] ??= doCombo
? getYearsObject({ years: years.filter(year => year !== 'Total'), emptyArrays: true })
: {}
creditCountsComboByTrack[studyTrack][startYear].push(
getCreditCount(studyRight.student.credits, startedInProgramme)
)
}
} else {
creditCounts[startYear].push(getCreditCount(studyRight.student.credits, startedInProgramme))
if (studyTrack) {
creditCountsByTrack[studyTrack] ??= getYearsObject({
years: years.filter(year => year !== 'Total'),
emptyArrays: true,
})
creditCountsByTrack[studyTrack][startYear].push(
getCreditCount(studyRight.student.credits, startedInProgramme)
)
}
}
}
}
@@ -416,10 +430,9 @@ const getMainStatsByTrackAndYear = async (
}
}
const graduatedCount: Record<string, number> = {}
years.forEach(year => {
graduatedCount[year] = yearlyStats[year] ? yearlyStats[year][studyProgramme].graduated : 0
})
const graduatedCount: Record<string, number> = Object.fromEntries(
years.map(year => [year, yearlyStats[year]?.[studyProgramme].graduated ?? 0])
)
const { mainStatsByYear, mainStatsByTrack, otherCountriesCount } = combineStats(
years,
@@ -428,6 +441,13 @@ const getMainStatsByTrackAndYear = async (
combinedProgramme
)
const graduatedCountByTrack: Record<string, Record<string, number>> = Object.fromEntries(
Object.keys(mainStatsByTrack).map(track => [
track,
Object.fromEntries(years.map(year => [year, yearlyStats[year]?.[track]?.graduated ?? 0])),
])
)
const finalGraduationTimes = await getGraduationTimeStats(
studyProgramme,
graduationTimes,
@@ -448,8 +468,11 @@ const getMainStatsByTrackAndYear = async (
mainStatsByTrack,
otherCountriesCount,
creditCounts,
creditCountsByTrack,
creditCountsCombo,
creditCountsComboByTrack,
graduatedCount,
graduatedCountByTrack,
graduationTimes: finalGraduationTimes,
graduationTimesSecondProg: finalCombinedGraduationTimes,
}
@@ -468,10 +491,7 @@ export const getStudyTrackStatsForStudyProgramme = async ({
settings: { specialGroups: boolean }
studyRightsOfProgramme: SISStudyRight[]
}) => {
const isAcademicYear = true
const includeYearsCombined = true
const since = getStartDate(isAcademicYear)
const years = getYearsArray(since.getFullYear(), isAcademicYear, includeYearsCombined)
const years = getYearsArray(getStartDate(true).getFullYear(), true, true)
const studyTracks = await getStudyTracksForProgramme(studyProgramme)
@@ -485,17 +505,21 @@ export const getStudyTrackStatsForStudyProgramme = async ({
combinedProgramme
)
const getCorrectCombinedTitles = () => {
if (combinedProgramme === 'MH90_001') return tableTitles.studytracksCombined.licentiate
return tableTitles.studytracksCombined.master
}
const graduatedTitles = combinedProgramme ? getCorrectCombinedTitles() : tableTitles.studytracksBasic
const graduatedTitles = combinedProgramme
? combinedProgramme === 'MH90_001'
? tableTitles.studytracksCombined.licentiate
: tableTitles.studytracksCombined.master
: tableTitles.studytracksBasic
// FIXME: Clean this object. Thanks.
const studyTrackStats: StudyTrackStats = {
creditCounts: stats.creditCounts,
creditCountsByTrack: stats.creditCountsByTrack,
creditCountsCombo: stats.creditCountsCombo,
creditCountsComboByTrack: stats.creditCountsComboByTrack,
doCombo,
graduatedCount: stats.graduatedCount,
graduatedCountByTrack: stats.graduatedCountByTrack,
graduationTimes: stats.graduationTimes,
graduationTimesSecondProg: stats.graduationTimesSecondProg,
id: combinedProgramme ? `${studyProgramme}-${combinedProgramme}` : studyProgramme,
+1 -2
View File
@@ -2,11 +2,10 @@ import { isEqual, keyBy, omit, uniq } from 'lodash-es'
import { LRUCache } from 'lru-cache'
import { User } from '@oodikone/shared/models/user'
import { DetailedProgrammeRights, Role } from '@oodikone/shared/types'
import { DetailedProgrammeRights, Role, ExpandedUser, FormattedUser, IamAccess } from '@oodikone/shared/types'
import { roles } from '../config/roles'
import { sequelizeUser } from '../database/connection'
import { UserModel } from '../models/user'
import { ExpandedUser, FormattedUser, IamAccess } from '../types'
import { createLocaleComparator, getFullStudyProgrammeRights, hasFullAccessToStudentData } from '../util'
import * as jami from '../util/jami'
-1
View File
@@ -1 +0,0 @@
export type IamAccess = Record<string, Record<'read' | 'write' | 'admin', boolean>>
-2
View File
@@ -1,3 +1 @@
export { ParsedCourse } from './course'
export { IamAccess } from './iamAccess'
export { ExpandedUser, FormattedUser } from './user'
+1 -1
View File
@@ -1,4 +1,4 @@
import { FormattedUser } from './user'
import { FormattedUser } from '@oodikone/shared/types'
declare global {
namespace Express {
+5
View File
@@ -100,3 +100,8 @@ export const safeJSONParse = async <T>(json: string): Promise<T | null> => {
return data
}
// NOTE: Make sure we have only arrays, "/...&years=2021" is parsed by express as years: 2021, not [2021]
export const handleQueryArrays = <T>(requestField: T | T[]): T[] => {
return Array.isArray(requestField) ? requestField : [requestField]
}
+1 -1
View File
@@ -1,8 +1,8 @@
import * as Sentry from '@sentry/node'
import axios from 'axios'
import { IamAccess } from '@oodikone/shared/types'
import { importerToken, isStaging, jamiUrl } from '../config'
import { IamAccess } from '../types'
import logger from './logger'
const jamiClient = axios.create({
@@ -5,8 +5,8 @@ import { Sequelize } from 'sequelize'
import request, { Response } from 'supertest'
import { PopulationstatisticsResBody } from '@oodikone/shared/routes/populations'
import { dbConnections } from '../src/database/connection'
import { initTests } from './utils'
import { dbConnections } from '../../src/database/connection'
import { initTests } from '../utils'
let app: Express
let connection: Sequelize
@@ -24,7 +24,7 @@ const populationUrl = (
semesters: ('SPRING' | 'FALL')[] = [],
students: string[] = []
) => {
return `/v3/populationstatistics?${programme && 'programme=' + programme}${years.map(year => '&years=' + year).join('')}${semesters.map(sem => '&semesters=' + sem).join('')}${students.map(s => '&studentStatuses=' + s).join('')}`
return `/populationstatistics?${programme && 'programme=' + programme}${years.map(year => '&years=' + year).join('')}${semesters.map(sem => '&semesters=' + sem).join('')}${students.map(s => '&studentStatuses=' + s).join('')}`
}
void describe('Population statistics', async () => {
@@ -0,0 +1,133 @@
import { Express } from 'express'
import assert from 'node:assert/strict'
import { describe, it, before } from 'node:test'
import request from 'supertest'
import { initTests } from '../utils'
let app: Express
before(async () => {
app = await initTests()
})
void describe('Population statistics by course', async () => {
await it('should not return anything with missing parameters', async () => {
const res = await request(app)
.get('/populationstatisticsbycourse')
.set('shib-session-id', 'test')
.set('uid', 'basic')
.set('hygroupcn', 'grp-oodikone-basic-users')
assert.deepEqual(res.status, 400)
assert.deepEqual(res.body.error, 'The body should have a yearcode and coursecode defined')
})
await it('should return correct amount of students for a course', async () => {
const res = await request(app)
.get('/populationstatisticsbycourse?coursecodes=["MAT21005"]&from=70&to=70')
.set('shib-session-id', 'test')
.set('uid', 'basic')
.set('hygroupcn', 'grp-oodikone-basic-users')
// Copied from response
const testStudent = {
firstnames: 'Mira Ilmatar',
lastname: 'Lintula',
started: '2018-08-01T00:00:00.000Z',
studentNumber: '394776',
credits: 265,
hopsCredits: 0,
name: 'Lintula Mira Ilmatar',
gender_code: '2',
email: 'sisutestidata65689@testisisudata.fi',
secondaryEmail: null,
phoneNumber: '+358501234567',
updatedAt: '2024-12-16T15:39:02.937Z',
studyrightStart: '1899-12-31T22:20:11.000Z',
option: null,
birthdate: '1999-03-01T00:00:00.000Z',
sis_person_id: 'hy-hlo-125389506',
citizenships: [
{
en: 'Finland',
fi: 'Suomi',
sv: 'Finland',
},
],
curriculumVersion: null,
hasPersonalIdentityCode: null,
tags: [],
transferredStudyright: false,
studyRights: [
{
admissionType: null,
cancelled: false,
extentCode: 5,
facultyCode: 'H50',
id: 'hy-opinoik-125389507',
semesterEnrollments: [
{ semester: 137, type: 1 },
{ semester: 138, type: 1 },
{ semester: 139, type: 1 },
{ semester: 140, type: 1 },
{ semester: 141, type: 1 },
{ semester: 142, type: 1 },
{ semester: 143, type: 1 },
{ semester: 144, type: 1 },
{ semester: 145, type: 1 },
{ semester: 146, type: 1 },
{ semester: 147, type: 1 },
{ semester: 148, statutoryAbsence: false, type: 2 },
{ semester: 149, type: 3 },
{ semester: 150, type: 3 },
{ semester: 151, type: 3 },
],
startDate: '2018-08-01T00:00:00.000Z',
studyRightElements: [
{
code: 'KH50_001',
degreeProgrammeType: 'urn:code:degree-program-type:bachelors-degree',
endDate: '2021-06-11T00:00:00.000Z',
graduated: true,
name: {
en: "Bachelor's Programme in Mathematical Sciences",
fi: 'Matemaattisten tieteiden kandiohjelma',
sv: 'Kandidatsprogrammet i matematiska vetenskaper',
},
phase: 1,
startDate: '2018-08-01T00:00:00.000Z',
studyTrack: {
code: 'MAT-MAT',
name: {
en: 'Mathematics',
fi: 'Matematiikka',
sv: 'Matematik',
},
},
},
{
code: 'MH50_001',
degreeProgrammeType: 'urn:code:degree-program-type:masters-degree',
endDate: '2025-12-31T00:00:00.000Z',
graduated: false,
name: {
en: "Master 's Programme in Mathematics and Statistics",
fi: 'Matematiikan ja tilastotieteen maisteriohjelma',
sv: 'Magisterprogrammet i matematik och statistik',
},
phase: 2,
startDate: '2021-06-12T00:00:00.000Z',
studyTrack: null,
},
],
tvex: null,
},
],
studyplans: [],
}
assert.strictEqual(res.status, 200)
assert.strictEqual(res.body.students.length, 40)
assert.deepStrictEqual(res.body.students.sort((a, b) => b.studentNumber - a.studentNumber).at(-1), testStudent)
})
})
@@ -0,0 +1,23 @@
import { Express } from 'express'
import assert from 'node:assert/strict'
import { describe, it, before } from 'node:test'
import request from 'supertest'
import { initTests } from '../utils'
let app: Express
before(async () => {
app = await initTests()
})
void describe('Population statistics by student number', async () => {
await it('should not return anything with missing parameters', async () => {
const res = await request(app)
.get('/populationstatisticsbystudentnumbers')
.set('shib-session-id', 'test')
.set('uid', 'basic')
.set('hygroupcn', 'grp-oodikone-basic-users')
assert.deepEqual(res.status, 400)
})
})
@@ -0,0 +1,101 @@
import { Express } from 'express'
import assert from 'node:assert/strict'
import { describe, it, before } from 'node:test'
import request from 'supertest'
import { initTests } from '../utils'
let app: Express
before(async () => {
app = await initTests()
})
void describe('Population statistics (study programme)', async () => {
await it('should return correctly formatted responses', async () => {
const res = await request(app)
.get('/populationstatistics/studyprogrammes')
.set('shib-session-id', 'test')
.set('uid', 'basic')
.set('hygroupcn', 'grp-oodikone-basic-users')
assert.strictEqual(res.status, 200)
assert.strictEqual(Object.keys(res.body).includes('allProgrammes'), true)
assert.strictEqual(Object.keys(res.body).includes('filteredProgrammes'), true)
assert.notStrictEqual(Object.keys(res.body.allProgrammes).length, 0)
assert.notStrictEqual(Object.keys(res.body.filteredProgrammes).length, 0)
})
await it('should return correct programmes to allProgrammes', async () => {
const res = await request(app)
.get('/populationstatistics/studyprogrammes')
.set('shib-session-id', 'test')
.set('uid', 'basic')
.set('hygroupcn', 'grp-oodikone-basic-users')
const testMathProgramme = {
code: 'KH50_001',
curriculumPeriodIds: [
'hy-lv-72',
'hy-lv-73',
'hy-lv-71',
'hy-lv-69',
'hy-lv-68',
'hy-lv-70',
'hy-lv-76',
'hy-lv-74',
'hy-lv-75',
],
degreeProgrammeType: 'urn:code:degree-program-type:bachelors-degree',
name: {
en: "Bachelor's Programme in Mathematical Sciences",
fi: 'Matemaattisten tieteiden kandiohjelma',
sv: 'Kandidatsprogrammet i matematiska vetenskaper',
},
progId: 'MAT',
}
assert.strictEqual(Object.keys(res.body.allProgrammes).length, 972)
assert.deepStrictEqual(res.body.allProgrammes.KH50_001, testMathProgramme)
})
await it('should return the programmes, that user has access to, to filteredProgrammes', async () => {
const res = await request(app)
.get('/populationstatistics/studyprogrammes')
.set('shib-session-id', 'test')
.set('uid', 'basic')
.set('hygroupcn', 'grp-oodikone-basic-users')
const testMathProgramme = {
code: 'KH50_001',
curriculumPeriodIds: [
'hy-lv-72',
'hy-lv-73',
'hy-lv-71',
'hy-lv-69',
'hy-lv-68',
'hy-lv-70',
'hy-lv-76',
'hy-lv-74',
'hy-lv-75',
],
degreeProgrammeType: 'urn:code:degree-program-type:bachelors-degree',
name: {
en: "Bachelor's Programme in Mathematical Sciences",
fi: 'Matemaattisten tieteiden kandiohjelma',
sv: 'Kandidatsprogrammet i matematiska vetenskaper',
},
progId: 'MAT',
}
assert.strictEqual(Object.keys(res.body.filteredProgrammes).length, 2) // Basic has access to math bachelor + master
assert.deepStrictEqual(res.body.filteredProgrammes.KH50_001, testMathProgramme)
assert.deepStrictEqual(res.body.allProgrammes.KH50_001, testMathProgramme) // All programmes should still include filteredProgramme programmes
})
await it.todo(
'should return both degree levels of study programme when searching KH90_001 or MH90_001 (ELK, ELL) in filteredProgrammes'
)
})
+69 -58
View File
@@ -16,16 +16,15 @@
"@reduxjs/toolkit": "^2.11.2",
"@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",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"lodash-es": "^4.17.23",
"highcharts": "^11.4.8",
"lodash-es": "^4.18.1",
"material-react-table": "^3.2.1",
"prop-types": "^15.8.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-highcharts": "^16.1.0",
"react-markdown": "^10.1.0",
"react-range": "^1.10.0",
"react-redux": "^9.2.0",
@@ -34,9 +33,11 @@
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
},
"devDependencies": {
"@types/lodash-es": "^4.17.12",
"@types/node": "^24.10.1",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
"@types/react-highcharts": "^16.0.7",
"@vitejs/plugin-react-swc": "^4.2.3",
"typescript": "^5.9.2",
"vite": "^7.3.1"
@@ -45,6 +46,7 @@
"../shared": {
"name": "@oodikone/shared",
"devDependencies": {
"@types/node": "^25.5.0",
"typescript": "^5.9.2"
}
},
@@ -1863,6 +1865,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -1880,6 +1885,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -1897,6 +1905,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -1914,6 +1925,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -2091,6 +2105,13 @@
"@types/estree": "*"
}
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/hast": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
@@ -2100,16 +2121,28 @@
"@types/unist": "*"
}
},
"node_modules/@types/highcharts": {
"version": "5.0.44",
"resolved": "https://registry.npmjs.org/@types/highcharts/-/highcharts-5.0.44.tgz",
"integrity": "sha512-Rby1gj3GbK7zzsbK/g9T9hoybUfM+x0sBIXrOlTnj25TIvuTkLend3nKRLLz2upvLJNo36GntO6KmTFoVQXWZA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/lodash-es": {
"version": "4.17.12",
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/lodash": "*"
@@ -2172,6 +2205,17 @@
"@types/react": "^18.0.0"
}
},
"node_modules/@types/react-highcharts": {
"version": "16.0.7",
"resolved": "https://registry.npmjs.org/@types/react-highcharts/-/react-highcharts-16.0.7.tgz",
"integrity": "sha512-xQSSWvZjZaK+j6/OBAR016KsC/cKptGbzxE4e6uSAuXpiz65PUAtSTpvT22nkvCR2DGx+5+faYwNkn2C/si4/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/highcharts": "^5.0.44",
"@types/react": "*"
}
},
"node_modules/@types/react-transition-group": {
"version": "4.4.12",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
@@ -2498,30 +2542,6 @@
"node": ">= 0.4"
}
},
"node_modules/echarts": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.0.0"
}
},
"node_modules/echarts-for-react": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/echarts-for-react/-/echarts-for-react-3.0.6.tgz",
"integrity": "sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"size-sensor": "^1.0.1"
},
"peerDependencies": {
"echarts": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0",
"react": "^15.0.0 || >=16.0.0"
}
},
"node_modules/error-ex": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
@@ -2646,12 +2666,6 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -2864,6 +2878,12 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/highcharts": {
"version": "11.4.8",
"resolved": "https://registry.npmjs.org/highcharts/-/highcharts-11.4.8.tgz",
"integrity": "sha512-5Tke9LuzZszC4osaFisxLIcw7xgNGz4Sy3Jc9pRMV+ydm6sYqsPYdU8ELOgpzGNrbrRNDRBtveoR5xS3SzneEA==",
"license": "https://www.highcharts.com/license"
},
"node_modules/highlight-words": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/highlight-words/-/highlight-words-2.0.0.tgz",
@@ -3039,9 +3059,9 @@
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/longest-streak": {
@@ -3971,6 +3991,18 @@
"loose-envify": "^1.1.0"
}
},
"node_modules/react-highcharts": {
"version": "16.1.0",
"resolved": "https://registry.npmjs.org/react-highcharts/-/react-highcharts-16.1.0.tgz",
"integrity": "sha512-CHpCSMN96lXKeTIpx8UJPsUgeNeJqh81NN6cbzraiHQBiQz2mzXa5aaIWYbMEQ2NHhAEWU5uj5DPhZY1f1Rq+A==",
"dependencies": {
"highcharts": "^6.0.4"
},
"peerDependencies": {
"react": "~0.14 || ^15.0.0 || ^16.0.0",
"react-dom": "~0.14 || ^15.0.0 || ^16.0.0"
}
},
"node_modules/react-is": {
"version": "19.1.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.1.tgz",
@@ -4211,12 +4243,6 @@
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
"license": "MIT"
},
"node_modules/size-sensor": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.3.tgz",
"integrity": "sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==",
"license": "ISC"
},
"node_modules/source-map": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
@@ -4333,12 +4359,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.9.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
@@ -4577,15 +4597,6 @@
"node": ">=0.8"
}
},
"node_modules/zrender": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
},
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
+12 -4
View File
@@ -19,16 +19,15 @@
"@reduxjs/toolkit": "^2.11.2",
"@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",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"lodash-es": "^4.17.23",
"highcharts": "^11.4.8",
"lodash-es": "^4.18.1",
"material-react-table": "^3.2.1",
"prop-types": "^15.8.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-highcharts": "^16.1.0",
"react-markdown": "^10.1.0",
"react-range": "^1.10.0",
"react-redux": "^9.2.0",
@@ -37,11 +36,20 @@
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
},
"devDependencies": {
"@types/lodash-es": "^4.17.12",
"@types/node": "^24.10.1",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
"@types/react-highcharts": "^16.0.7",
"@vitejs/plugin-react-swc": "^4.2.3",
"typescript": "^5.9.2",
"vite": "^7.3.1"
},
"overrides": {
"react-highcharts": {
"highcharts": "$highcharts",
"react": "$react",
"react-dom": "$react-dom"
}
}
}
@@ -1,11 +1,9 @@
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import * as Sentry from '@sentry/browser'
import axios from 'axios'
import axios, { AxiosProgressEvent } from 'axios'
import { showAsUserKey } from '@/common'
import { apiBasePath, isDev } from '@/conf'
import { actionHistory } from '@/redux/actionHistory'
import { formatToArray } from '@oodikone/shared/util'
import { queryParamsToString } from '@/util/queryparams'
// Set up dev user for development environment, mimicking production admin user
const baseHeaders = isDev
@@ -33,17 +31,18 @@ const getHeaders = () => {
const api = axios.create({ baseURL: apiBasePath, headers: getHeaders(), timeout: 120_000 })
const actionTypes = prefix => ({
attempt: `${prefix}ATTEMPT`,
failure: `${prefix}FAILURE`,
success: `${prefix}SUCCESS`,
})
export const callApi = async (url, method = 'get', data, params, timeout = 0, progressCallback = null) => {
export const callApi = async (
url,
method = 'get',
data,
params = {},
timeout = 0,
progressCallback?: (progress: number) => void
) => {
const options = { headers: getHeaders(), timeout }
const onDownloadProgress = ({ loaded, total }) => {
if (progressCallback) progressCallback(Math.round((loaded / total) * 100))
const onDownloadProgress = ({ loaded, total }: AxiosProgressEvent) => {
if (progressCallback) progressCallback(Math.min(100, Math.round((loaded / (total ?? 1)) * 100)))
}
switch (method) {
@@ -60,39 +59,6 @@ export const callApi = async (url, method = 'get', data, params, timeout = 0, pr
}
}
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
@@ -117,16 +83,7 @@ export const RTKApi = createApi({
Object.entries(getHeaders()).forEach(([key, value]) => headers.set(key, value))
return headers
},
paramsSerializer: params => {
const searchParams = new URLSearchParams()
Object.entries(params).map(([key, val]) => {
const subfix = Array.isArray(val) ? '[]' : ''
formatToArray(val).forEach(item => searchParams.append(key + subfix, item))
})
return searchParams.toString()
},
paramsSerializer: params => queryParamsToString(params),
}),
endpoints: () => ({}),
})
@@ -20,11 +20,11 @@ export const populationStatisticsToolTips = {
creditAccumulation: `
Opiskelijoiden kumulatiiviset opintopistekertymät opiskelijakohtaisina kuvaajina.
Kun hiiren vie kuvaajien päälle, saa näkyviin jokaista kuvaajaa vastaavan opiskelijanumeron.
Yksittäisen opiskelijan kuvaajaa klikkaamalla siirtyy **student statistics**-sivulle, ja näkee valitun opiskelijan opintotiedot.
Opiskelijanumeron yhteydessä olevaa ympyrää klikkaamalla siirtyy ”student statistics”-sivulle, ja näkee valitun opiskelijan opintotiedot.
Timantin muotoinen ikoni kertoo opiskelijan valmistuneen valitusta koulutusohjelmasta kyseisellä hetkellä.
X-akselin skaalaa voi säätää kuvaajan alla olevaa liukusäädintä käyttäen.
Taulukon korkeutta voi säätää viereisistä painikkeista (small, medium, large).
X-akselin skaalaa voi säätää harmaista painikkeista ”1m”, ”3m”, ”6m”, ”YTD”, ”1y” ja ”ALL” tai kuvaajan alla olevan sinisen näkymän harmaita ”kahvoja” siirtämällä.
Y-akselin skaalaa voi säätää harmaista painikkeista ”small” ”medium” ja "large”
`,
creditStatistics: `
**Credits gained**
@@ -58,7 +58,7 @@ export const populationStatisticsToolTips = {
**Distribution development**
Pylväsdiagrammi kertoo opiskelijoiden jakauman opintopistehaarukoihin kalenterivuosittain, lukuvuosittain tai lukukausittain eriteltynä.
Välien järjestystä pylväässä voi muuttaa valitsemalla **Chart settings** -valikosta haluamansa järjestyksen.
Välien järjestystä pylväässä voi muuttaa valitsemalla **Stack ordering** -valikosta haluamansa järjestyksen.
Pylväiden osat toimivat myös suodattimina. Klikkaamalla pylvään osaa näkyviin jäävät vain kyseiset opiskelijat (esimerkiksi keväällä 2024 0–30 opintopistettä suorittaneet).
+5 -8
View File
@@ -4,8 +4,7 @@ import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
import { maxBy, orderBy, range } from 'lodash-es'
import { GetTextIn } from '@/components/LanguagePicker/useLanguage'
import { SemestersData } from '@/redux/semesters'
import type { SemestersData } from '@/hooks/useSemesters'
import type { Absence } from '@/types/students'
import type { SISStudyRightElement } from '@oodikone/shared/models'
import {
@@ -88,25 +87,23 @@ export const getStudentGradeMeanWeightedByCredits = (student: FormattedStudent,
return mean
}
const openUniTexts = { fi: 'Avoin yo', en: 'Open uni', sv: 'Öppna uni' } as const
const studyModuleTexts = { en: 'Study module', fi: 'Opintokokonaisuus', sv: 'Studiehelhet' } as const
export const getTextInWithOpen = (
course: { code: string; name: Name },
getTextIn: GetTextIn,
getTextIn: any,
isOpenCourse: boolean,
isStudyModuleCredit: boolean
) => {
if (!course) return ''
const openUniTexts = { fi: 'Avoin yo', en: 'Open uni', sv: 'Öppna uni' }
let courseName = getTextIn(course.name)
if (
isOpenCourse &&
!Object.values(openUniTexts).some(text => courseName?.toLowerCase().startsWith(text.toLowerCase()))
!Object.values(openUniTexts).some(text => courseName.toLowerCase().startsWith(text.toLowerCase()))
) {
courseName = `${getTextIn(openUniTexts)}: ${courseName}`
}
if (isStudyModuleCredit) {
courseName += ` [${getTextIn(studyModuleTexts)}]`
courseName += ` [${getTextIn({ en: 'Study module', fi: 'Opintokokonaisuus', sv: 'Studiehelhet' })}]`
}
courseName += ` (${course.code})`
return courseName
@@ -6,6 +6,7 @@ import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider/L
import * as Sentry from '@sentry/browser'
import dayjs, { extend as dayjsExtend } from 'dayjs'
import updateLocale from 'dayjs/plugin/updateLocale'
import HighCharts from 'highcharts' // eslint-disable-line import-x/default
import { useEffect } from 'react'
import { initShibbolethPinger } from 'unfuck-spa-shibboleth-session'
@@ -21,6 +22,21 @@ import { isProduction } from '@/conf'
import { useGetAuthorizedUserQuery } from '@/redux/auth'
import { theme } from '@/theme'
// TODO: What other options should be set globally?
HighCharts.setOptions({
credits: {
enabled: false,
},
plotOptions: {
series: {
animation: false,
},
},
title: {
text: '',
},
})
dayjsExtend(updateLocale)
dayjs.updateLocale('en', {
week: {
@@ -1,81 +0,0 @@
import ReactECharts from 'echarts-for-react'
import { Section } from '@/components/Section'
import { GraphStat } from '@/types/graphStat'
const colors = ['#003E65', '#1392c2', '#036415']
const formatNumber = (value: unknown) => {
const numericValue = typeof value === 'number' ? value : Number(value)
if (!Number.isFinite(numericValue)) {
return ''
}
if (Number.isInteger(numericValue)) {
return `${numericValue}`
}
return `${numericValue.toFixed(1)}`
}
export const BarChart = ({ id, graphStats, years }: { id: string; graphStats: GraphStat[]; years: number[] }) => {
if (!graphStats || !id || !years) {
return null
}
const series = graphStats.map((graphSeries, index) => ({
...graphSeries,
type: 'bar',
itemStyle: {
color: colors[index % colors.length],
},
label: {
show: true,
position: 'top',
distance: 8,
formatter: (params: { value?: unknown }) => formatNumber(params.value),
},
labelLayout: {
hideOverlap: true,
},
emphasis: {
focus: 'series',
},
}))
const option = {
legend: {},
tooltip: {
trigger: 'item',
},
toolbox: {
feature: {
saveAsImage: {
name: `oodikone_graduations_and_thesis_of_degree_programme_${id}`,
},
},
},
grid: {
top: 10,
left: 10,
right: 10,
},
xAxis: {
type: 'category',
data: years.map(String),
},
yAxis: {
type: 'value',
min: 0,
minInterval: 1,
},
series,
}
return (
<Section cypress="graduated-and-thesis-writers-of-the-programme-bar-chart">
<ReactECharts option={option} opts={{ renderer: 'svg' }} style={{ height: 450, width: '100%' }} />
</Section>
)
}
@@ -1,335 +0,0 @@
import Button from '@mui/material/Button'
import FormControlLabel from '@mui/material/FormControlLabel'
import Stack from '@mui/material/Stack'
import Switch from '@mui/material/Switch'
import dayjs from 'dayjs'
import ReactECharts, { EChartsInstance } from 'echarts-for-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { populationStatisticsToolTips } from '@/common/InfoToolTips'
import { DateFormat } from '@/constants/date'
import { reformatDate } from '@/util/timeAndDate'
import { CreditTypeCode, FormattedStudent } from '@oodikone/shared/types'
import { InfoBox } from '../InfoBox/InfoBoxWithTooltip'
import { getGraduationDataPoints, getGraduationsByCodes, getIncludedCourseCodesByProgrammeCodes } from './util'
const INITIAL_GRAPH_HEIGHT = 600
/**
* Credit accumulation graph for **class statistics**
*
* @param students filteredStudents (size must be more than 0)
* @param programmeCodes array of 1 or 2 (for combined programme) codes
* @param studyPlanFilter whether hops filter is active
* @param showBachelorAndMaster (optional)
*/
export const CreditAccumulationGraph = ({
students,
programmeCodes,
studyPlanFilter,
showBachelorAndMaster,
}: {
students: FormattedStudent[]
programmeCodes: string[]
studyPlanFilter: boolean
showBachelorAndMaster?: boolean
}) => {
const [graphHeight, setGraphHeight] = useState(INITIAL_GRAPH_HEIGHT)
const [cutStudyplanCredits, setCutStudyplanCredits] = useState(false)
// Is same for all students in a population
const populationStudyStart = new Date(students[0].studyrightStart).getTime()
const creditDateThreshold = useMemo(
() =>
studyPlanFilter && !cutStudyplanCredits
? Math.min(...students.flatMap(s => s.courses.map(c => new Date(c.date).getTime())))
: populationStudyStart,
[studyPlanFilter, cutStudyplanCredits, students, populationStudyStart]
)
const studyRightStartMarker = studyPlanFilter
? [
{
name: 'Population study start',
type: 'line',
markLine: {
silent: true,
symbol: 'none',
lineStyle: {
color: '#ff0000',
type: 'dashed',
width: 3,
},
label: {
show: true,
formatter: 'Population study start',
position: 'insideEndTop',
},
data: [{ xAxis: populationStudyStart }],
},
},
]
: []
const creditPoints = useMemo(
() =>
students.map(student => {
const includedCourseCodes = getIncludedCourseCodesByProgrammeCodes(student, programmeCodes)
const courses = student.courses
.filter(c => {
const courseCode = c.course_code ?? (c as { course?: { code?: string } }).course?.code
return (
dayjs(c.date).isSameOrAfter(creditDateThreshold) &&
!c.isStudyModuleCredit &&
(studyPlanFilter ? !!courseCode && includedCourseCodes.has(courseCode) : true)
)
})
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
let credits = 0
const points: number[][] = []
const markPointsForStudent: any[] = []
const graduations = getGraduationsByCodes(student, programmeCodes, !!showBachelorAndMaster)
for (const c of courses) {
if (c.credittypecode === CreditTypeCode.PASSED || c.credittypecode === CreditTypeCode.APPROVED) {
credits += c.credits
points.push([new Date(c.date).getTime(), credits])
}
}
// Guard: students can graduate without having a single course in hops
if (points.length) {
graduations.forEach(grad => getGraduationDataPoints(points, markPointsForStudent, grad))
points.sort((a, b) => a[0] - b[0])
}
return {
name: student.studentNumber,
type: 'line',
showSymbol: false,
markPoint: markPointsForStudent.length
? {
symbol: 'diamond',
symbolSize: 20,
data: markPointsForStudent,
}
: undefined,
symbolSize: 8,
symbol: 'emptyCircle',
data: points,
smooth: 0.1,
emphasis: { focus: 'series' },
sampling: 'lttb',
}
}),
[creditDateThreshold, students, studyPlanFilter, programmeCodes, showBachelorAndMaster]
)
const [chartInstance, setChartInstance] = useState<EChartsInstance | null>(null)
const lastHighlightedRef = useRef<{ seriesIndex: number; dataIndex: number } | null>(null)
const handleChartReady = useCallback((chart: EChartsInstance) => {
setChartInstance(chart)
}, [])
const resetHighlight = useCallback((chart: any) => {
const last = lastHighlightedRef.current
if (last) {
chart.dispatchAction({ type: 'downplay', seriesIndex: last.seriesIndex, dataIndex: last.dataIndex })
lastHighlightedRef.current = null
}
chart.dispatchAction({ type: 'hideTip' })
}, [])
useEffect(() => {
const chart = chartInstance
if (!chart) return () => undefined
const zr = chart.getZr()
const handleMouseMove = (event: { offsetX?: number; offsetY?: number }) => {
const offsetX = event?.offsetX
const offsetY = event?.offsetY
if (offsetX == null || offsetY == null) return
if (!chart.containPixel('grid', [offsetX, offsetY])) {
resetHighlight(chart)
return
}
let nearestSeriesIndex: number | null = null
let nearestDataIndex: number | null = null
let nearestDist2 = Number.POSITIVE_INFINITY
creditPoints.forEach((series, seriesIndex) => {
if (!Array.isArray(series.data) || series.data.length === 0) return
series.data.forEach((point, dataIndex) => {
if (!Array.isArray(point) || point.length < 2) return
const pixel = chart.convertToPixel({ xAxisIndex: 0, yAxisIndex: 0 }, point)
if (!Array.isArray(pixel)) return
const dx = pixel[0] - offsetX
const dy = pixel[1] - offsetY
const dist2 = dx * dx + dy * dy
if (dist2 < nearestDist2) {
nearestSeriesIndex = seriesIndex
nearestDataIndex = dataIndex
nearestDist2 = dist2
}
})
})
if (nearestSeriesIndex == null || nearestDataIndex == null) return
const next = { seriesIndex: nearestSeriesIndex, dataIndex: nearestDataIndex }
const last = lastHighlightedRef.current
if (last && last.seriesIndex === next.seriesIndex && last.dataIndex === next.dataIndex) return
if (last) {
chart.dispatchAction({ type: 'downplay', seriesIndex: last.seriesIndex, dataIndex: last.dataIndex })
}
chart.dispatchAction({ type: 'highlight', seriesIndex: next.seriesIndex, dataIndex: next.dataIndex })
chart.dispatchAction({ type: 'showTip', seriesIndex: next.seriesIndex, dataIndex: next.dataIndex })
lastHighlightedRef.current = next
}
const handleGlobalOut = () => resetHighlight(chart)
const handleClick = (event: { offsetX?: number; offsetY?: number }) => {
const offsetX = event?.offsetX
const offsetY = event?.offsetY
if (offsetX == null || offsetY == null) return
if (!chart.containPixel('grid', [offsetX, offsetY])) return
const last = lastHighlightedRef.current
if (!last) return
const studentNumber = creditPoints[last.seriesIndex]?.name
if (!studentNumber) return
window.open(`/students/${studentNumber}`, '_blank', 'noopener,noreferrer')
}
zr.on('mousemove', handleMouseMove)
zr.on('globalout', handleGlobalOut)
zr.on('click', handleClick)
return () => {
zr.off('mousemove', handleMouseMove)
zr.off('globalout', handleGlobalOut)
zr.off('click', handleClick)
}
}, [chartInstance, creditPoints, resetHighlight])
const option = useMemo(
() => ({
tooltip: {
trigger: 'item',
triggerOn: 'none',
position: (_point: unknown, params: { value?: [number, number] }) => {
const value = params?.value
if (!Array.isArray(value)) return null
const chart = chartInstance
if (!chart) return null
const pixel = chart.convertToPixel({ xAxisIndex: 0, yAxisIndex: 0 }, value)
if (!Array.isArray(pixel)) return null
return [pixel[0], pixel[1]]
},
formatter: (params: { seriesName?: string; value?: [number, number] }) => {
const { value } = params
if (!Array.isArray(value)) return params.seriesName ?? ''
const [timestamp, credits] = value
const dateLabel = reformatDate(new Date(timestamp), DateFormat.DISPLAY_DATE)
return `<b>${params.seriesName ?? ''}</b><br/>${dateLabel}: ${credits} cr`
},
},
grid: {
show: true,
width: '100%',
left: 'left',
top: 55,
},
toolbox: {
left: 'left',
feature: {
dataZoom: {
yAxisIndex: 'none',
},
restore: {},
saveAsImage: {},
},
},
dataZoom: [
{ type: 'inside', xAxisIndex: 0 },
{ type: 'slider', xAxisIndex: 0 },
],
xAxis: {
type: 'time',
},
yAxis: {
type: 'value',
name: 'Credits',
position: 'right',
min: 0,
splitLine: { show: true },
scale: true,
},
series: [...creditPoints, ...studyRightStartMarker],
animation: true,
}),
[chartInstance, creditPoints, studyRightStartMarker]
)
const GraphSizeButton = ({ height, label }) => (
<Button
color="primary"
onClick={() => setGraphHeight(height)}
variant={height === graphHeight ? 'contained' : 'outlined'}
>
{label}
</Button>
)
return (
<>
<Stack
direction="row"
sx={{
justifyContent: 'space-between',
mx: '0.5rem',
}}
>
<Stack direction="row" spacing={1}>
<GraphSizeButton height={400} label="Small" />
<GraphSizeButton height={INITIAL_GRAPH_HEIGHT} label="Medium" />
<GraphSizeButton height={900} label="Large" />
{studyPlanFilter ? (
<FormControlLabel
control={
<Switch checked={cutStudyplanCredits} onChange={e => setCutStudyplanCredits(e.target.checked)} />
}
label="Display credits from study right start"
/>
) : null}
</Stack>
<InfoBox content={populationStatisticsToolTips.creditAccumulation} />
</Stack>
<ReactECharts onChartReady={handleChartReady} option={option} style={{ height: graphHeight }} />
</>
)
}
@@ -1,196 +0,0 @@
import Box from '@mui/material/Box'
import useTheme from '@mui/material/styles/useTheme'
import ReactECharts from 'echarts-for-react'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { GraduationStats, Name, NameWithCode } from '@oodikone/shared/types'
import { getOnEvents, getSeriesLabel } from './util'
type GraduationBreakdownProps =
| {
cypress: string
data: GraduationStats[]
fullWidth: true
mode: 'faculty' | 'programme' | 'study track'
yearLabel?: never
handleClick?: never
names?: never
expandKey?: never
}
| {
cypress: string
data: GraduationStats[]
handleClick: (seriesCategory: string) => void
mode: 'faculty' | 'programme' | 'study track'
yearLabel: 'Graduation year' | 'Start year'
names?: never
expandKey?: never
fullWidth?: never
}
| {
cypress: string
data: GraduationStats[]
handleClick: (seriesCategory: string) => void
mode: 'faculty' | 'programme' | 'study track'
names: Record<string, Name | NameWithCode> | Record<string, string | Name> | undefined
expandKey: string | null
yearLabel: 'Graduation year' | 'Start year'
fullWidth?: never
}
export const GraduationBreakdown = ({
cypress,
data,
fullWidth,
handleClick,
mode,
names,
expandKey,
yearLabel,
}: GraduationBreakdownProps) => {
const { language } = useLanguage()
const theme = useTheme()
const categories = data.map(item => item.name)
const codeMap: Record<string, string | undefined> = {}
if (expandKey) {
for (const item of data) {
codeMap[item.name || item.code!] = item.code
}
}
const getHeight = () => {
if (data.length === 1) {
return 250
}
const multiplier = data.length > 8 ? 80 : 110
return data.length * multiplier + 100
}
const getFacultyOrProgrammeName = (code: string) => {
if (!names) {
return ''
}
const name = names[code]
if (typeof name === 'string') {
return name
}
return name?.[language] ?? name?.fi ?? ''
}
const getTooltipText = (id: string, seriesName: string, amount: number) => {
if (expandKey) {
const code = codeMap[id] ?? ''
return `<b>${getFacultyOrProgrammeName(code)}</b> • ${code}<br /><b>${seriesName}</b>: ${amount}`
}
return `<b>${seriesName}</b>: ${amount}`
}
const barWidth = data.length > 8 ? 16 : 20
const series = [
{
name: 'On time',
color: theme.palette.graduationTimes.onTime,
data: data.map(item => item.statistics.onTime),
},
{
name: 'Max. year overtime',
color: theme.palette.graduationTimes.yearOver,
data: data.map(item => item.statistics.yearOver),
},
{
name: 'Overtime',
color: theme.palette.graduationTimes.wayOver,
data: data.map(item => item.statistics.wayOver),
},
].map(seriesItem => ({
type: 'bar',
name: seriesItem.name,
barWidth,
data: seriesItem.data,
itemStyle: {
color: seriesItem.color,
},
label: {
show: true,
position: 'right',
color: '#424949',
formatter: (params: { value?: number }) => {
const value = typeof params.value === 'number' ? params.value : Number(params.value)
return value ? `${value}` : ''
},
},
emphasis: {
focus: 'series',
},
}))
const option = {
title: {
text: expandKey ? `Year ${expandKey} by ${yearLabel?.toLowerCase()}` : '',
left: 'center',
},
tooltip: {
trigger: 'item',
backgroundColor: 'white',
formatter: (params: { name?: string; seriesName?: string; value?: number }) => {
const id = params.name ?? ''
const seriesName = params.seriesName ?? ''
const amount = typeof params.value === 'number' ? params.value : Number(params.value)
return getTooltipText(id, seriesName, amount)
},
},
toolbox: {
feature: {
dataView: {
readOnly: true,
title: 'View table data',
},
saveAsImage: {
name: 'Oodikone-graduation-breakdown',
},
},
},
grid: {
top: 60,
left: 10,
right: 20,
bottom: 60,
containLabel: true,
},
legend: {
bottom: 0,
},
xAxis: {
type: 'value',
min: 0,
minInterval: 1,
name: 'Number of students',
nameLocation: 'middle',
nameGap: 32,
},
yAxis: {
type: 'category',
data: categories,
name: getSeriesLabel(expandKey, yearLabel, mode),
nameLocation: 'start',
nameGap: 12,
inverse: true,
z: 10,
},
series,
}
return (
<Box data-cy={cypress} width={{ sm: '100%', md: fullWidth ? '100%' : '50%' }}>
<ReactECharts onEvents={getOnEvents(handleClick)} option={option} opts={{ renderer: 'svg' }} style={{ height: getHeight() }} />
</Box>
)
}
@@ -1,77 +0,0 @@
import ReactECharts from 'echarts-for-react'
import { Section } from '@/components/Section'
import { theme } from '@/theme'
import { GraphStat } from '@/types/graphStat'
export const LineGraph = ({
cypress,
exportFileName,
graphStats,
years,
}: {
cypress: string
exportFileName: string
graphStats: GraphStat[]
years: number[]
}) => {
if (!graphStats) {
return null
}
const series = graphStats.map((graphStat, index) => {
const color = theme.palette.graphColors[index % theme.palette.graphColors.length]
return {
...graphStat,
type: 'line',
showSymbol: true,
symbol: 'circle',
symbolSize: 7,
itemStyle: {
color,
},
lineStyle: {
color,
},
emphasis: {
focus: 'series',
},
}
})
const option = {
legend: {},
tooltip: {
trigger: 'axis',
},
toolbox: {
feature: {
saveAsImage: {
name: exportFileName,
},
},
},
grid: {
left: 10,
top: 10,
right: 10,
},
xAxis: {
type: 'category',
data: years.map(String),
},
yAxis: {
type: 'value',
min: 0,
minInterval: 1,
},
series,
}
return (
<Section cypress={`${cypress}-line-graph`}>
<ReactECharts option={option} opts={{ renderer: 'svg' }} style={{ height: 450 }} />
</Section>
)
}
@@ -1,266 +0,0 @@
import Box from '@mui/material/Box'
import useTheme from '@mui/material/styles/useTheme'
import ReactECharts from 'echarts-for-react'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import {
FacultyClassSizes,
GraduationStatistics,
GraduationStats,
Name,
NameWithCode,
ProgrammeClassSizes,
} from '@oodikone/shared/types'
import { getOnEvents, getSeriesLabel } from './util'
type GraduationPoint = GraduationStats & { color?: string; realGoal?: number }
export const MedianGraduations = ({
classSizes,
cypress,
data,
expandKey,
fullWidth,
goal,
goalExceptions,
handleClick,
level,
mode,
names,
title,
yearLabel,
}: {
classSizes?: FacultyClassSizes['programmes'] | ProgrammeClassSizes['studyTracks' | 'programme']
cypress: string
data: GraduationStats[]
expandKey?: string
facultyGraph?: boolean
fullWidth?: true,
goal: number
goalExceptions?: Record<string, number> | { needed: boolean }
handleClick: (seriesCategory: string) => void | undefined
level?: string
mode: 'faculty' | 'programme' | 'study track'
names?: Record<string, Name | NameWithCode>
title: string
yearLabel: 'Graduation year' | 'Start year'
}) => {
const { language } = useLanguage()
const theme = useTheme()
let modData: GraduationPoint[] | null = null
if (!expandKey && goalExceptions?.needed && level && ['master', 'bcMsCombo'].includes(level)) {
// change colors for longer medicine goal times
modData = JSON.parse(JSON.stringify(data))
if (modData) {
for (const item of modData) {
if (item.code && Object.keys(goalExceptions).includes(item.code)) {
const realGoal = goal + goalExceptions[item.code]
if (item.median <= realGoal) {
item.color = theme.palette.graduationTimes.onTime
} else if (item.median <= realGoal + 12) {
item.color = theme.palette.graduationTimes.yearOver
} else {
item.color = theme.palette.graduationTimes.wayOver
}
item.realGoal = realGoal
}
}
}
}
const maxValue = data.reduce((max, { median }) => {
return median > max ? median : max
}, goal * 2)
const getClassSize = (category: string) => {
return expandKey ? classSizes?.[category]?.[expandKey ?? ''] : classSizes?.[category]
}
const getPercentage = (amount: number, category: string) => {
const percent = Math.round((amount / getClassSize(category)) * 100 * 10) / 10
return Number.isNaN(percent) ? 0 : percent
}
const getDataLabel = (amount: number, category: string) => {
if (yearLabel === 'Start year' && title === 'Bachelor study right') {
return `${amount} graduated (${getPercentage(amount, category)} % of class)`
}
return `${amount} graduated`
}
const getHeight = () => {
const multiplier = data.length > 8 ? 35 : 55
return data.length * multiplier + 100
}
const getFacultyOrProgrammeName = (code: string) => {
if (!names) {
return ''
}
return names[code]?.[language] ?? names[code]?.fi
}
const getTooltipText = (
name: string,
code: string,
amount: number,
median: number,
statistics: GraduationStatistics | undefined,
realGoal: number | undefined
) => {
const sortingText =
yearLabel === 'Start year'
? `<b>From class of ${!expandKey ? name : expandKey}, ${amount}/${getClassSize(code)} students have graduated</b>`
: `<b>${amount} students graduated in year ${!expandKey ? name : expandKey}</b>`
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 (expandKey) {
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}`
}
const resolveBarColor = (median: number, override?: string) => {
if (override) {
return override
}
if (median <= goal) {
return theme.palette.graduationTimes.onTime
}
if (median <= goal + 2) {
return theme.palette.graduationTimes.yearOver
}
return theme.palette.graduationTimes.wayOver
}
const dataWithOverrides: GraduationPoint[] = modData ?? data
const seriesData = dataWithOverrides.map(item => ({
value: item.median,
name: item.name.toString(),
code: item.code ?? item.name,
amount: item.amount,
statistics: item.statistics,
realGoal: item.realGoal,
itemStyle: {
color: resolveBarColor(item.median, item.color),
},
}))
const option = {
title: {
text: expandKey ? `Year ${expandKey} by ${yearLabel.toLowerCase()}` : '',
left: 'center',
},
tooltip: {
trigger: 'item',
backgroundColor: 'white',
confine: true,
formatter: (params: {
name?: string
value?: number
data?: {
code?: string
amount?: number
statistics?: GraduationStatistics
realGoal?: number
name?: string
}
}) => {
const chartData = params.data
if (!chartData) {
return ''
}
const name = params.name ?? chartData.name ?? ''
const code = chartData.code ?? name
const amount = chartData.amount ?? 0
const median = typeof params.value === 'number' ? params.value : Number(params.value)
return getTooltipText(name, code, amount, median, chartData.statistics, chartData.realGoal)
},
},
grid: {
top: 60,
left: 15,
right: 20,
bottom: 30,
containLabel: true,
},
toolbox: {
feature: {
saveAsImage: {
name: 'Oodikone-graduation-medians',
},
},
},
xAxis: {
type: 'value',
min: 0,
max: maxValue,
name: 'Graduation time (semesters)',
nameLocation: 'middle',
nameGap: 32,
minInterval: 1,
},
yAxis: {
type: 'category',
data: seriesData.map(item => item.name),
name: getSeriesLabel(expandKey, yearLabel, mode),
nameLocation: 'start',
nameGap: 12,
inverse: true,
z: 10,
},
series: [
{
type: 'bar',
data: seriesData,
label: {
show: true,
position: 'insideLeft',
color: '#424949',
formatter: (params: { name?: string; data?: { amount?: number; code?: string } }) => {
const amount = params.data?.amount ?? 0
const code = params.data?.code ?? params.name ?? ''
return getDataLabel(amount, code)
},
},
labelLayout: {
hideOverlap: true,
},
markLine: {
symbol: 'none',
label: {
show: false,
},
lineStyle: {
width: 2,
},
data: [
{
xAxis: goal,
lineStyle: {
color: theme.palette.graduationTimes.onTime,
type: 'dashed',
},
},
{
xAxis: goal + 2,
lineStyle: {
color: theme.palette.graduationTimes.yearOver,
type: 'dashed',
},
},
],
},
},
],
}
return (
<Box data-cy={cypress} width={{ sm: '100%', md: fullWidth ? '100%' : '50%' }}>
<ReactECharts onEvents={getOnEvents(handleClick)} option={option} opts={{ renderer: 'svg' }} style={{ height: getHeight() }} />
</Box>
)
}
@@ -1,147 +0,0 @@
import ReactECharts from 'echarts-for-react'
import { useEffect, useMemo, useState } from 'react'
import { Section } from '@/components/Section'
import { generateGradientColors } from '@/util/color'
const getNumericValue = (value: unknown) => {
const numericValue = typeof value === 'number' ? value : Number(value)
return Number.isFinite(numericValue) ? numericValue : 0
}
const formatPercentage = (value: number) => `${value.toFixed(1)}%`
type ProgressData = {
id: string
stats: {
data: number[]
name: string
}[]
years: string[]
}
export const ProgressBarChart = ({ cypress, data }: { cypress: string; data: ProgressData }) => {
const [legendSelection, setLegendSelection] = useState<Record<string, boolean>>({})
const stats = data.stats ?? []
const seriesNamesKey = stats.map(stat => stat.name).join('|')
useEffect(() => {
setLegendSelection({})
}, [data.id, seriesNamesKey])
const seriesCount = stats.length
const gradientColors = seriesCount > 1 ? generateGradientColors(seriesCount - 1) : []
const colors = [...gradientColors, '#ddd']
const pointCount = Math.max(data.years?.length ?? 0, ...stats.map(series => series.data.length))
const categories = Array.from({ length: pointCount }, (_, index) => data.years?.[index] ?? '')
const isSeriesVisible = (name: string) => legendSelection[name] !== false
const totals = Array.from({ length: pointCount }, (_, index) =>
stats.reduce((sum, currentSeries) => {
if (!isSeriesVisible(currentSeries.name)) {
return sum
}
return sum + getNumericValue(currentSeries.data[index])
}, 0)
)
const series = stats.map((graphSeries, index) => ({
name: graphSeries.name,
type: 'bar',
stack: 'total',
itemStyle: {
color: colors[index],
},
label: {
show: true,
position: 'inside',
formatter: (params: { data?: { percentage?: number } }) => {
const percentage = getNumericValue(params.data?.percentage)
return formatPercentage(percentage)
},
},
labelLayout: {
hideOverlap: true,
},
emphasis: {
focus: 'none',
},
data: Array.from({ length: pointCount }, (_, pointIndex) => {
const rawValue = getNumericValue(graphSeries.data[pointIndex])
const total = totals[pointIndex]
const percentage = isSeriesVisible(graphSeries.name) && total ? (rawValue / total) * 100 : 0
return {
value: percentage,
rawValue,
percentage,
}
}),
}))
const onEvents = useMemo(
() => ({
legendselectchanged: (params: { selected?: Record<string, boolean> }) => {
if (!params.selected) {
return
}
setLegendSelection(params.selected)
},
}),
[]
)
const option = {
legend: {
selected: Object.keys(legendSelection).length ? legendSelection : undefined,
},
tooltip: {
trigger: 'item',
formatter: (params: { data?: { rawValue?: number; percentage?: number }; seriesName?: string }) => {
const rawValue = getNumericValue(params.data?.rawValue)
const percentage = getNumericValue(params.data?.percentage)
return `<b>${params.seriesName ?? ''}: ${rawValue}</b><br/>${formatPercentage(percentage)}`
},
},
toolbox: {
top: -10,
feature: {
saveAsImage: {
name: `oodikone_progress_of_students_in_${data.id}_by_starting_year`,
},
},
},
grid: {
left: 10,
top: 48,
right: 10,
},
xAxis: {
type: 'category',
data: categories,
z: 10,
},
yAxis: {
type: 'value',
inverse: true,
min: 0,
max: 100,
axisLabel: {
formatter: '{value}%',
},
},
series,
}
return (
<Section cypress={`${cypress}-progress-bar-chart`}>
<ReactECharts notMerge onEvents={onEvents} option={option} opts={{ renderer: 'svg' }} style={{ height: 500 }} />
</Section>
)
}
@@ -1,106 +0,0 @@
import ReactECharts from 'echarts-for-react'
import { Section } from '@/components/Section'
import { theme } from '@/theme'
const formatNumber = (value: unknown) => {
const numericValue = typeof value === 'number' ? value : Number(value)
if (!Number.isFinite(numericValue)) {
return ''
}
if (Number.isInteger(numericValue)) {
return `${numericValue}`
}
return `${numericValue.toFixed(1)}`
}
const getNumericValue = (value: unknown) => {
const numericValue = typeof value === 'number' ? value : Number(value)
return Number.isFinite(numericValue) ? numericValue : 0
}
export const StackedBarChart = ({
cypress,
data,
labels,
exportFileName = 'chart',
}: {
cypress: string
data?: { data: number[]; name: string }[]
labels?: string[]
exportFileName: string
}) => {
if (!data) {
return null
}
const series = data.map((graphSeries, index) => {
const color = theme.palette.graphColors[index % theme.palette.graphColors.length]
return {
...graphSeries,
type: 'bar',
stack: 'total',
itemStyle: {
color,
},
label: {
show: true,
formatter: (params: { value?: unknown }) => formatNumber(params.value),
},
labelLayout: {
hideOverlap: true,
},
emphasis: {
focus: 'series',
},
}
})
const option = {
legend: {},
tooltip: {
trigger: 'item',
formatter: (params: { seriesName?: string; value?: unknown; dataIndex?: number }) => {
const dataIndex = params.dataIndex ?? 0
const value = getNumericValue(params.value)
const total = data.reduce((sum, currentSeries) => sum + getNumericValue(currentSeries.data[dataIndex]), 0)
const percentage = total ? ((value / total) * 100).toFixed(1) : '0.0'
return `<b>${params.seriesName ?? ''}: ${percentage} %</b>`
},
},
toolbox: {
feature: {
saveAsImage: {
name: exportFileName,
},
},
},
grid: {
left: 10,
top: 10,
right: 10,
},
xAxis: {
type: 'category',
data: labels,
},
yAxis: {
type: 'value',
min: 0,
minInterval: 1,
},
series,
}
return (
<Section cypress={`${cypress}-stacked-bar-chart`}>
<ReactECharts option={option} opts={{ renderer: 'svg' }} style={{ height: 450 }} />
</Section>
)
}
@@ -1,410 +0,0 @@
import Button from '@mui/material/Button'
import Stack from '@mui/material/Stack'
import ReactECharts from 'echarts-for-react'
import { useMemo, useState } from 'react'
import { getStudyRightElementTargetDates } from '@/common'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { DateFormat } from '@/constants/date'
import type { Absence } from '@/types/students'
import { reformatDate } from '@/util/timeAndDate'
import { CreditTypeCode } from '@oodikone/shared/types'
import type { StudentPageStudent } from '@oodikone/shared/types/studentData'
import {
createGoalLineData,
filterCoursesByStudyPlan,
getGraduationDataPoints,
getStudentTransferMarkers,
} from './util'
const INITIAL_GRAPH_HEIGHT = 700
type StudentCreditAccumulationProps = {
student: StudentPageStudent
absences: Absence[]
startDate: Date
endDate: number
studyRightId?: string
selectedStudyPlan?: {
programme_code: string
included_courses: string[]
}
}
const GraphSizeButton = ({
graphHeight,
height,
label,
onSelect,
}: {
graphHeight: number
height: number
label: string
onSelect: (height: number) => void
}) => (
<Button color="primary" onClick={() => onSelect(height)} variant={height === graphHeight ? 'contained' : 'outlined'}>
{label}
</Button>
)
export const StudentCreditAccumulation = ({
student,
absences,
startDate,
endDate,
studyRightId,
selectedStudyPlan,
}: StudentCreditAccumulationProps) => {
const { getTextIn } = useLanguage()
const [graphHeight, setGraphHeight] = useState(INITIAL_GRAPH_HEIGHT)
const hasStudyRights = student.studyRights.length > 0
const selectedStudyRight = useMemo(
() => (studyRightId ? student.studyRights.find(studyRight => studyRight.id === studyRightId) : undefined),
[student.studyRights, studyRightId]
)
const earliestStudyRightStart = useMemo(() => {
const scope = selectedStudyRight ? [selectedStudyRight] : student.studyRights
const times = scope
.map(studyRight => new Date(studyRight.startDate).getTime())
.filter(time => Number.isFinite(time))
return times.length ? Math.min(...times) : undefined
}, [selectedStudyRight, student.studyRights])
const pointData = useMemo(() => {
const filteredCourses = filterCoursesByStudyPlan(student.courses, selectedStudyPlan)
.filter(course => new Date(course.date).getTime() <= Date.now())
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
let credits = 0
const points: Array<{ value: [number, number]; tooltipHtml: string }> = []
const graduationMarkPoints: Array<{ coord: number[]; name: string }> = []
for (const course of filteredCourses) {
if (![CreditTypeCode.PASSED, CreditTypeCode.APPROVED].includes(course.credittypecode)) continue
const gainedCredits = !course.isStudyModuleCredit ? course.credits : 0
credits += gainedCredits
const x = new Date(course.date).getTime()
const y = credits
const courseCode = course.course?.code
const courseName = course.course?.name ? getTextIn(course.course.name) : ''
const tooltipHtml = [
`<strong>${courseName}${courseCode ? ` (${courseCode})` : ''}</strong>`,
`Credits: ${course.credits}`,
`Grade: ${course.grade || '-'}`,
`Date: ${reformatDate(course.date, DateFormat.DISPLAY_DATE)}`,
course.isStudyModuleCredit ? '[Study Module]' : '',
]
.filter(Boolean)
.join('<br/>')
points.push({
value: [x, y],
tooltipHtml,
})
}
const graduationData = (selectedStudyRight ? [selectedStudyRight] : student.studyRights)
.flatMap(studyRight => studyRight.studyRightElements)
.filter(element => !!element.graduated)
.map(element => ({
date: new Date(element.endDate).getTime(),
label: `Graduated from ${getTextIn(element.name) ?? ''}`.trim(),
}))
.sort((a, b) => a.date - b.date)
if (points.length) {
const numericalPoints = points.map(point => [point.value[0], point.value[1]])
graduationData.forEach(graduation =>
getGraduationDataPoints(numericalPoints, graduationMarkPoints, graduation.date)
)
const pointLookup = new Map(points.map(point => [`${point.value[0]}-${point.value[1]}`, point]))
const pointsWithGraduationNodes = numericalPoints.map(value => {
const key = `${value[0]}-${value[1]}`
return pointLookup.get(key) ?? { value: [value[0], value[1]] as [number, number], tooltipHtml: '' }
})
return { points: pointsWithGraduationNodes, graduationMarkPoints, graduationData }
}
return { points, graduationMarkPoints, graduationData }
}, [student, selectedStudyPlan, selectedStudyRight, getTextIn])
const goalSeries = useMemo(() => {
const studyRightElement = selectedStudyRight?.studyRightElements.find(
element => element.code === selectedStudyPlan?.programme_code
)
const [targetStart, targetEnd] = getStudyRightElementTargetDates(studyRightElement, absences)
const graphStart = (targetStart ? new Date(targetStart) : new Date(earliestStudyRightStart ?? startDate)).getTime()
const graphEnd = (targetEnd ? new Date(targetEnd) : new Date(endDate)).getTime()
const filteredAbsences = absences.filter(absence => {
const absenceStart = new Date(absence.startDate).getTime()
const absenceEnd = new Date(absence.endDate).getTime()
return absenceStart <= graphEnd && absenceEnd >= graphStart
})
return createGoalLineData(graphStart, graphEnd, filteredAbsences)
}, [selectedStudyRight, selectedStudyPlan, absences, startDate, endDate, earliestStudyRightStart])
const xAxisMin = useMemo(() => {
const defaultMin = new Date(startDate).getTime()
if (!hasStudyRights) return defaultMin
const goalStart = goalSeries.points[0]?.[0]
if (typeof goalStart !== 'number' || Number.isNaN(goalStart)) return defaultMin
return Math.min(defaultMin, goalStart)
}, [startDate, hasStudyRights, goalSeries.points])
const transferMarkers = useMemo(
() => getStudentTransferMarkers(student, text => getTextIn(text as never)),
[student, getTextIn]
)
const yAxisMax = useMemo(() => {
const studentMax = pointData.points.reduce((max, point) => Math.max(max, point.value[1]), 0)
const goalMax = hasStudyRights
? goalSeries.points.reduce((max, [, credits]) => (Number.isFinite(credits) ? Math.max(max, credits) : max), 0)
: 0
const maxCredits = Math.max(studentMax, goalMax)
if (maxCredits <= 0) return undefined
return Math.ceil(maxCredits / 10) * 10
}, [pointData.points, goalSeries.points, hasStudyRights])
const tooltipsByTimestamp = useMemo(() => {
const byTimestamp = new Map<number, string[]>()
for (const point of pointData.points) {
if (!point.tooltipHtml) continue
const timestamp = point.value[0]
const existing = byTimestamp.get(timestamp) ?? []
existing.push(point.tooltipHtml)
byTimestamp.set(timestamp, existing)
}
return byTimestamp
}, [pointData.points])
const option = useMemo(
() => ({
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'line',
snap: true,
},
formatter: (
rawParams:
| {
seriesName?: string
data?: { tooltipHtml?: string }
value?: [number, number]
}
| Array<{
seriesName?: string
data?: { tooltipHtml?: string }
value?: [number, number]
}>
) => {
const params = Array.isArray(rawParams) ? rawParams : [rawParams]
const timestamp = params.find(param => param.value)?.value?.[0]
if (typeof timestamp === 'number') {
const tooltipsAtTimestamp = tooltipsByTimestamp.get(timestamp)
if (tooltipsAtTimestamp?.length) {
return tooltipsAtTimestamp.join('<br/><br/>')
}
}
const studentPoint = params.find(param => param.data?.tooltipHtml)
if (studentPoint?.data?.tooltipHtml) return studentPoint.data.tooltipHtml
return ''
},
},
grid: {
show: true,
width: '100%',
left: 'left',
top: 55,
},
toolbox: {
left: 'left',
feature: {
dataZoom: {
yAxisIndex: 'none',
},
restore: {},
saveAsImage: {},
},
},
dataZoom: [
{ type: 'inside', xAxisIndex: [0, 1], filterMode: 'none' },
{ type: 'slider', xAxisIndex: [0, 1], filterMode: 'none' },
],
xAxis: [
{
type: 'time',
min: xAxisMin,
max: endDate,
},
{
type: 'time',
min: xAxisMin,
max: endDate,
show: false,
axisPointer: {
show: false,
},
},
],
yAxis: {
type: 'value',
name: 'Credits',
position: 'right',
min: 0,
max: yAxisMax,
splitLine: { show: true },
},
series: [
{
name: student.studentNumber,
type: 'line',
showSymbol: true,
symbol: 'circle',
symbolSize: 8,
smooth: 0.1,
itemStyle: {
borderWidth: 2,
},
emphasis: {
focus: 'self',
scale: true,
symbol: 'circle',
symbolSize: 11,
itemStyle: {
color: '#1976d2',
borderColor: '#1976d2',
},
},
data: pointData.points,
markPoint: pointData.graduationMarkPoints.length
? {
symbol: 'diamond',
symbolSize: 20,
data: pointData.graduationMarkPoints,
}
: undefined,
},
...(hasStudyRights
? [
{
name: 'Goal',
type: 'line',
xAxisIndex: 1,
data: goalSeries.points,
showSymbol: false,
silent: true,
tooltip: { show: false },
lineStyle: {
color: '#99d8c4',
width: 2,
},
markArea: {
silent: true,
label: {
show: true,
position: 'insideTop',
color: '#1f2937',
fontSize: 11,
padding: [2, 4],
backgroundColor: 'rgba(255, 255, 255, 0.65)',
borderRadius: 3,
},
data: goalSeries.markAreas.map(area => [
{
name: area.label,
xAxis: area.range[0].xAxis,
itemStyle: {
color: `${area.color}33`,
borderColor: area.color,
borderType: area.borderType,
},
},
{ xAxis: area.range[1].xAxis },
]),
},
},
]
: []),
{
name: 'Graduations and transfers',
type: 'line',
data: [],
silent: true,
markLine: {
silent: true,
symbol: 'none',
data: [
...transferMarkers.map(marker => ({
xAxis: marker.value,
label: {
formatter: marker.label,
position: 'insideEndTop',
},
lineStyle: { color: '#cbd128', type: 'dashed', width: 2 },
})),
...pointData.graduationMarkPoints.map(marker => ({
xAxis: marker.coord[0],
label: {
formatter:
pointData.graduationData.find(graduation => graduation.date === marker.coord[0])?.label ??
'Graduation',
position: 'insideEndTop',
},
lineStyle: { color: '#a333c8', type: 'dashed', width: 2 },
})),
],
},
},
],
animation: true,
}),
[
student.studentNumber,
pointData,
goalSeries,
transferMarkers,
endDate,
tooltipsByTimestamp,
hasStudyRights,
xAxisMin,
yAxisMax,
]
)
return (
<>
<Stack direction="row" spacing={1} sx={{ mx: '0.5rem' }}>
<GraphSizeButton graphHeight={graphHeight} height={400} label="Small" onSelect={setGraphHeight} />
<GraphSizeButton
graphHeight={graphHeight}
height={INITIAL_GRAPH_HEIGHT}
label="Medium"
onSelect={setGraphHeight}
/>
<GraphSizeButton graphHeight={graphHeight} height={1000} label="Large" onSelect={setGraphHeight} />
</Stack>
<ReactECharts option={option} style={{ height: graphHeight }} />
</>
)
}
@@ -1,202 +0,0 @@
import dayjs, { extend as dayjsExtend } from 'dayjs'
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
import ReactECharts from 'echarts-for-react'
import chunk from 'lodash-es/chunk'
import groupBy from 'lodash-es/groupBy'
import { useMemo } from 'react'
import { DateFormat } from '@/constants/date'
import { SemestersData, useGetSemestersQuery } from '@/redux/semesters'
import { reformatDate } from '@/util/timeAndDate'
import { StudentPageStudent } from '@oodikone/shared/types/studentData'
import { type GetTextIn, useLanguage } from '../LanguagePicker/useLanguage'
dayjsExtend(isSameOrAfter)
type SeriesData = {
x: number
y: number
name?: string
}
const semesterChunkify = (courses, semesters, getTextIn: GetTextIn): SeriesData[] => {
const semesterChunks = courses.reduce((acc, curr) => {
const semester = semesters.find(
semester => dayjs(curr.date).isSameOrAfter(semester.startdate) && dayjs(curr.date).isBefore(semester.enddate)
)
const semesterData = acc.find(data => data.semester === semester.name)
if (semesterData) {
semesterData.data.push(curr)
} else {
acc.push({ data: [curr], semester: semester.name })
}
return acc
}, [])
const semesterMeans = semesterChunks.reduce((acc, curr) => {
const gradeSum = curr.data.reduce((a, b) => a + b.grade * b.credits, 0)
const creditSum = curr.data.reduce((a, b) => a + b.credits, 0)
if (curr.data.length > 0)
acc.push({
name: getTextIn(curr.semester),
y: gradeSum / creditSum,
x: new Date(curr.data[curr.data.length - 1].date).getTime(),
})
return acc
}, [])
return semesterMeans
}
const gradeMeanSeries = (
student: StudentPageStudent,
chunksize: number,
semesters: SemestersData['semesters'] | undefined,
getTextIn: ReturnType<typeof useLanguage>['getTextIn']
) => {
const filteredCourses = student.courses.filter(
course => !Number.isNaN(Number(course.grade)) && !course.isStudyModuleCredit && course.passed
)
const coursesGroupedByDate = groupBy(filteredCourses, 'date')
const gradesAndMeans = Object.values(coursesGroupedByDate).reduce(
(acc, courses) => {
for (const course of courses) {
acc.grades.push({
grade: Number(course.grade),
date: course.date.toString(),
credits: course.credits,
})
// Weighted average: each grade is multiplied by the amount of credits the course is worth
acc.totalGradeSum += Number(course.grade) * course.credits
acc.totalCredits += course.credits
}
acc.mean.push({ y: acc.totalGradeSum / acc.totalCredits, x: new Date(courses[0].date).getTime() })
return acc
},
{
grades: [] as Array<{ grade: number; date: string; credits: number }>,
mean: [] as SeriesData[],
totalGradeSum: 0,
totalCredits: 0,
}
)
const size = Number(chunksize) ? chunksize : 3
const chunks = chunk(gradesAndMeans.grades, size)
const groupMeans = chunks.reduce<SeriesData[]>((acc, curr) => {
const gradeSum = curr.reduce((a, b) => a + b.grade * b.credits, 0)
const creditSum = curr.reduce((a, b) => a + b.credits, 0)
if (curr.length > 0)
acc.push({
name: `${curr.length} courses between ${reformatDate(curr[0].date, DateFormat.DISPLAY_DATE)} and ${reformatDate(curr[curr.length - 1].date, DateFormat.DISPLAY_DATE)}`,
y: gradeSum / creditSum,
x: new Date(curr[curr.length - 1].date).getTime(),
})
return acc
}, [])
const semesterMeans = semesterChunkify(gradesAndMeans.grades, Object.values(semesters ?? {}), getTextIn)
return {
totalMeans: [{ data: gradesAndMeans.mean }],
groupMeans: [{ data: groupMeans }],
semesterMeans: [{ data: semesterMeans }],
}
}
export const StudentGradeGraphs = ({
student,
graphMode,
groupSize,
}: {
student: StudentPageStudent
graphMode: 'total' | 'group' | 'semester'
groupSize: number
}) => {
const { getTextIn } = useLanguage()
const { data: semesters } = useGetSemestersQuery()
const { semesters: allSemesters } = semesters ?? { semesters: {} }
const { totalMeans, groupMeans, semesterMeans } = useMemo(
() => gradeMeanSeries(student, groupSize, allSemesters, getTextIn),
[student, groupSize, allSemesters, getTextIn]
)
const selectedSeries: SeriesData[] =
graphMode === 'group' ? groupMeans[0].data : graphMode === 'semester' ? semesterMeans[0].data : totalMeans[0].data
const gradeOption = useMemo(
() => ({
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'line',
snap: true,
},
formatter: (
rawParams:
| { data?: { x?: number; y?: number; name?: string } }
| Array<{ data?: { x?: number; y?: number; name?: string } }>
) => {
const params = Array.isArray(rawParams) ? rawParams : [rawParams]
const point = params.find(param => param.data)
const data = point?.data
if (!data) return ''
const date = data.x ? reformatDate(new Date(data.x), DateFormat.DISPLAY_DATE) : ''
const description = data.name ? `<br/>${data.name}` : ''
return `${data.y?.toFixed(2) ?? ''}${date ? `<br/>${date}` : ''}${description}`
},
},
grid: {
show: true,
width: '100%',
left: 'left',
top: 55,
},
toolbox: {
left: 'left',
feature: {
dataZoom: {
yAxisIndex: 'none',
},
restore: {},
saveAsImage: {},
},
},
dataZoom: [
{ type: 'inside', xAxisIndex: 0, filterMode: 'none' },
{ type: 'slider', xAxisIndex: 0, filterMode: 'none' },
],
xAxis: {
type: 'time',
},
yAxis: {
type: 'value',
position: 'right',
min: 1,
max: 5.1,
splitLine: { show: true },
},
series: [
{
type: 'line',
smooth: 0.1,
showSymbol: false,
data: selectedSeries.map(point => ({
value: [point.x, point.y],
x: point.x,
y: point.y,
name: point.name,
})),
},
],
animation: true,
}),
[selectedSeries]
)
return <ReactECharts option={gradeOption} style={{ height: 420 }} />
}
@@ -1,250 +0,0 @@
import { FormattedStudent, Phase } from '@oodikone/shared/types'
type CourseWithCode = {
course_code?: string
course?: {
code?: string
}
}
type StudyPlanLike = {
included_courses: string[]
}
export const getOnEvents = (handleClick: ((category: string) => void) | undefined) => (
handleClick ? {
click: (params: { name: string }) => {
handleClick(params.name)
},
} : undefined
)
const resolveCourseCode = (course: CourseWithCode) => course.course_code ?? course.course?.code
const toMillis = (date: Date | string) => new Date(date).getTime()
/** expandKey is only provided to the expanded instance of a chart */
export const getSeriesLabel = (expandKey: string | undefined | null, yearLabel: string | undefined, mode: string) => (
expandKey ? `${mode.charAt(0).toUpperCase()}${mode.slice(1)}` : yearLabel
)
const getEnrollmentLabel = (enrollmentType: number, statutoryAbsence: boolean) => {
if (enrollmentType === 2) return statutoryAbsence ? 'Absent (statutory)' : 'Absent'
if (enrollmentType === 3) return 'No enrollment'
return 'No study right'
}
const getTermLabel = (startDate: number) => {
const date = new Date(startDate)
const month = date.getMonth()
const year = date.getFullYear()
const season = month >= 7 ? 'Fall' : 'Spring'
return `${season} ${year}`
}
const getTermRangeLabel = (startDate: number, endTermDate: number) => {
const startTerm = getTermLabel(startDate)
const endTerm = getTermLabel(endTermDate)
if (startTerm === endTerm) return startTerm
return `${startTerm} - ${endTerm}`
}
export const getIncludedCourseCodesByProgrammeCodes = (
student: FormattedStudent,
programmeCodes: string[]
): Set<string> => {
if (!programmeCodes) return new Set()
const studyplans = student.studyplans.filter(studyplan => programmeCodes.includes(studyplan.programme_code))
return new Set(studyplans.flatMap(studyplan => studyplan.included_courses))
}
export const getGraduationsByCodes = (
student: FormattedStudent,
programmeCodes: string[],
showBachelorAndMaster: boolean
): number[] => {
if (!programmeCodes) return []
if (showBachelorAndMaster) {
return (
student.studyRights
.find(studyRight => studyRight.studyRightElements.some(element => programmeCodes.includes(element.code)))
?.studyRightElements.filter(({ graduated }) => !!graduated) ?? []
)
.map(({ endDate }) => new Date(endDate).getTime())
.sort((a, b) => a - b)
}
return student.studyRights
.flatMap(studyRight => studyRight.studyRightElements)
.filter(({ graduated, code }) => !!graduated && programmeCodes.includes(code))
.map(({ endDate }) => new Date(endDate).getTime())
.sort((a, b) => a - b)
}
export const filterCoursesByStudyPlan = <TCourse extends CourseWithCode>(
courses: TCourse[],
studyPlan?: StudyPlanLike
): TCourse[] => {
if (!studyPlan) return courses
return courses.filter(course => {
const code = resolveCourseCode(course)
return !!code && studyPlan.included_courses.includes(code)
})
}
export const createGoalLineData = (
graphStartDate: number,
graphEndDate: number,
absences: {
startDate: Date | string
endDate: Date | string
enrollmenttype: number
statutoryAbsence: boolean
}[]
) => {
const DAY_MS = 24 * 60 * 60 * 1000
const getColor = (type: number) => {
if (type === 2) return '#ffb300'
if (type === 3) return '#e53935'
return '#96d7c3'
}
let totalAbsenceYears = 0
const now = Date.now()
type ClippedAbsence = {
start: number
end: number
enrollmenttype: number
statutoryAbsence: boolean
labelStart: number
labelEnd: number
}
const relevantAbsences = absences
.map(absence => {
const start = toMillis(absence.startDate)
const end = toMillis(absence.endDate)
if (!Number.isFinite(start) || !Number.isFinite(end)) return null
const clippedStart = Math.max(start, graphStartDate)
const clippedEndByGraph = Math.min(end, graphEndDate)
const clippedEnd = absence.enrollmenttype === 3 ? Math.min(clippedEndByGraph, now) : clippedEndByGraph
if (clippedEnd <= clippedStart) return null
return {
start: clippedStart,
end: clippedEnd,
enrollmenttype: absence.enrollmenttype,
statutoryAbsence: absence.statutoryAbsence,
labelStart: clippedStart,
labelEnd: clippedStart,
}
})
.filter((absence): absence is ClippedAbsence => absence !== null)
.sort((a, b) => a.start - b.start)
const mergedAbsences = relevantAbsences.reduce<ClippedAbsence[]>((merged, absence) => {
const previous = merged[merged.length - 1]
if (
previous &&
previous.enrollmenttype === absence.enrollmenttype &&
previous.statutoryAbsence === absence.statutoryAbsence &&
absence.start <= previous.end + DAY_MS
) {
previous.end = Math.max(previous.end, absence.end)
previous.labelEnd = absence.labelEnd
return merged
}
merged.push({ ...absence })
return merged
}, [])
const points: number[][] = [[graphStartDate, 0]]
const markAreas: Array<{
range: [{ xAxis: number }, { xAxis: number }]
color: string
borderType: 'solid' | 'dashed'
label: string
}> = []
for (const absence of mergedAbsences) {
const absenceStart = absence.start
const absenceEnd = absence.end
const targetCreditsBeforeAbsence =
((absenceStart - graphStartDate) / (365.25 * 24 * 60 * 60 * 1000) - totalAbsenceYears) * 60
const absenceInYears = (absenceEnd - absenceStart) / (365.25 * 24 * 60 * 60 * 1000)
totalAbsenceYears += absenceInYears
points.push([absenceStart, targetCreditsBeforeAbsence])
points.push([absenceEnd, targetCreditsBeforeAbsence])
const enrollmentLabel = getEnrollmentLabel(absence.enrollmenttype, absence.statutoryAbsence)
const termLabel = getTermRangeLabel(absence.labelStart, absence.labelEnd)
const text = `${enrollmentLabel}\n(${termLabel})`
markAreas.push({
range: [{ xAxis: absenceStart }, { xAxis: absenceEnd }],
color: getColor(absence.enrollmenttype),
borderType: absence.statutoryAbsence ? 'dashed' : 'solid',
label: text,
})
}
const yearsFromStart = (graphEndDate - graphStartDate) / (365.25 * 24 * 60 * 60 * 1000)
const endingCredits = (yearsFromStart - totalAbsenceYears) * 60
points.push([graphEndDate, endingCredits])
points.sort((a, b) => a[0] - b[0])
return { points, markAreas }
}
export const getStudentTransferMarkers = (
student: Pick<FormattedStudent, 'studyRights'>,
getTextIn: (text: unknown) => string | null | undefined
): Array<{ value: number; label: string }> =>
student.studyRights.reduce<Array<{ value: number; label: string }>>((transfers, studyRight) => {
const phase1Programmes = studyRight.studyRightElements.filter(element => element.phase === Phase.ANY)
const phase2Programmes = studyRight.studyRightElements.filter(element => element.phase === Phase.MASTER)
for (const programmes of [phase1Programmes, phase2Programmes]) {
for (const element of programmes.slice(0, -1)) {
transfers.push({
value: new Date(element.startDate).getTime(),
label: `Transfer to ${getTextIn(element.name) ?? ''}`,
})
}
}
return transfers
}, [])
/** HACK: markPoint (in this case the graduation marker) needs a datapoint to attach to. This creates a synthetic data point
to the time of graduation and attaches the markPoint to it (remember to sort the datapoints again) */
export const getGraduationDataPoints = (
dataPoints: number[][],
markPoints: { coord: number[]; name: string }[],
graduationDate: number
) => {
dataPoints.sort((a, b) => a[0] - b[0])
const index = dataPoints.findIndex(point => point[0] > graduationDate)
let y: number
if (index <= 0) {
y = graduationDate > (dataPoints.at(0)?.[0] ?? 0) ? dataPoints[dataPoints.length - 1][1] : dataPoints[0][1]
} else {
const yBefore = dataPoints[index - 1][1]
const yAfter = dataPoints[index][1]
y = Math.round((yBefore + yAfter) / 2)
}
const coord = [graduationDate, y]
dataPoints.push(coord)
dataPoints.sort((a, b) => a[0] - b[0])
markPoints.push({ coord, name: `Graduation` })
}
@@ -90,8 +90,6 @@ export const FacultiesTab = () => {
totalRow.bySemesters[numberMode]
)
if (!facultyMap) return null
const tableOptions = {
initialState: { columnPinning: { left: ['Course'] } },
state: {
@@ -4,25 +4,14 @@ import Typography from '@mui/material/Typography'
import { createColumnHelper } from '@tanstack/react-table'
import { useMemo } from 'react'
import { getColor } from '@/components/ColorizedCoursesTable/common'
import { getColor, CourseContainer } from '@/components/ColorizedCoursesTable/common'
const getFacultyTitle = code => {
if (!code) return 'No faculty' // Shouldn't happen
if (code === 'OPEN') return 'Open\nuni'
if (code === 'OTHER') return 'Other\nfaculty'
return code
return code ?? 'No faculty'
}
const CourseContainer = ({ getTextIn, name, code }) => (
<div
style={{ display: 'flex', flexDirection: 'column', alignItems: 'baseline', height: 'auto', padding: '0.5em 0' }}
title={getTextIn(name) ?? code}
>
<b>{code}</b>
<i style={{ color: 'gray', maxWidth: '100%' }}>{getTextIn(name) ?? null}</i>
</div>
)
const columnHelper = createColumnHelper()
export const useColumns = (getTextIn, faculties, numberMode, colorMode, facultyMap, allTotal) => {
@@ -1,18 +1,9 @@
import Box from '@mui/material/Box'
import { createColumnHelper } from '@tanstack/react-table'
import { useMemo } from 'react'
import { getColor } from '@/components/ColorizedCoursesTable/common'
const CourseContainer = ({ getTextIn, name, code }) => (
<div
style={{ display: 'flex', flexDirection: 'column', alignItems: 'baseline', height: 'auto', padding: '0.5em 0' }}
title={getTextIn(name) ?? code}
>
<b>{code}</b>
<i style={{ color: 'gray', maxWidth: '100%' }}>{getTextIn(name) ?? null}</i>
</div>
)
import { getColor, CourseContainer } from '@/components/ColorizedCoursesTable/common'
const columnHelper = createColumnHelper()
@@ -1,7 +1,8 @@
import Stack from '@mui/material/Stack'
import TextField from '@mui/material/TextField'
import { createContext, useContext, useState, useEffect } from 'react'
export const ColorizedCoursesTableContext = createContext(null)
export const ColorizedCoursesTableContext = createContext()
export const useColorizedCoursesTableContext = () => useContext(ColorizedCoursesTableContext)
@@ -95,3 +96,10 @@ export const CourseFilter = ({ setCourseFilter }) => {
<TextField label="Course name/code filter" onChange={event => setTextField(event.target.value)} value={textField} />
)
}
export const CourseContainer = ({ getTextIn, name, code }) => (
<Stack alignItems="baseline" flexDirection="column" py={1} title={getTextIn(name) ?? code}>
<b>{code}</b>
<i className="colored-info-expander">{getTextIn(name) ?? null}</i>
</Stack>
)
@@ -1,17 +1,3 @@
.colorized-courses-table {
display: flex;
flex-direction: column;
align-items: center;
}
.colorized-courses-table > div {
max-width: 100%;
}
input[type='radio'] {
margin-left: 2em;
}
.options-container {
display: flex;
flex-direction: row;
@@ -28,9 +14,9 @@ input[type='radio'] {
max-width: 25vw;
}
table tr th,
td > div > i {
max-width: 340px;
.colored-info-expander {
color: gray;
width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
@@ -1,39 +1,36 @@
import Divider from '@mui/material/Divider'
import Paper from '@mui/material/Paper'
import Stack from '@mui/material/Stack'
import Tab from '@mui/material/Tab'
import Tabs from '@mui/material/Tabs'
import Typography from '@mui/material/Typography'
import { useEffect, useMemo, useState } from 'react'
import { LoadingSection } from '@/components/Loading'
import { useTitle } from '@/hooks/title'
import { useGetSemestersQuery } from '@/redux/semesters'
import { useSemesters } from '@/hooks/useSemesters'
import { ColorizedCoursesTableContext } from './common'
import { FacultiesTab } from './FacultiesTab'
import { SemestersTab } from './SemestersTab'
import './index.css'
export const ColorizedCoursesTable = ({ fetchDataHook, studyProgramme, title, panes, dividerText }) => {
useTitle(title)
const { data: semesterData } = useGetSemestersQuery()
const { semesters: allSemesters, currentSemester } = semesterData ?? { semesters: {}, currentSemester: null }
export const ColorizedCoursesTable = ({ fetchDataHook, fetchDataHookParams, panes }) => {
const { semesters: allSemesters, currentSemester } = useSemesters()
const semesters = Object.values(allSemesters).filter(
// 135 = Fall 2017
semester => semester.semestercode >= 135 && semester.semestercode <= currentSemester.semestercode
// INFO: If semesters is defined, currentSemester will be defined.
semester => semester.semestercode >= 135 && semester.semestercode <= currentSemester!.semestercode
)
const { data, isFetching, isLoading, isError } = fetchDataHook({ id: studyProgramme })
const { data, isFetching, isLoading, isError } = fetchDataHook(fetchDataHookParams)
const [tab, setTab] = useState(0)
const [numberMode, setNumberMode] = useState('completions')
const [colorMode, setColorMode] = useState('none')
const [semesterFilter, setSemesterFilter] = useState(null)
const [semesterFilter, setSemesterFilter] = useState<{ start: number; end: number }>({ start: 135, end: 135 })
const [filterEmptyCourses, setFilterEmptyCourses] = useState(true)
const selectedSemesters = useMemo(() => {
const selectedSemestersArray = []
const selectedSemestersArray: number[] = []
if (semesterFilter?.start && semesterFilter?.end) {
for (let i = parseInt(semesterFilter.start, 10); i <= parseInt(semesterFilter.end, 10); i++) {
for (let i = semesterFilter.start; i <= semesterFilter.end; i++) {
selectedSemestersArray.push(i)
}
}
@@ -91,25 +88,19 @@ export const ColorizedCoursesTable = ({ fetchDataHook, studyProgramme, title, pa
}
return (
<ColorizedCoursesTableContext.Provider value={settingsContext}>
<div className="colorized-courses-table">
{dividerText ? (
<Divider sx={{ width: '100%', mt: 3, mb: 1 }}>
<Typography fontSize="1.3rem" variant="overline">
{dividerText}
</Typography>
</Divider>
) : null}
{displayedPanes.length > 1 ? (
<Tabs onChange={(_, newTab) => setTab(newTab)} value={tab}>
{displayedPanes.map(({ label }) => (
<Tab key={label} label={label} />
))}
</Tabs>
) : null}
<Paper variant="outlined">{displayedPanes.at(tab)?.render() ?? null}</Paper>
</div>
</ColorizedCoursesTableContext.Provider>
<Stack alignItems="center">
{displayedPanes.length > 1 && (
<Tabs onChange={(_, newTab) => setTab(newTab)} value={tab}>
{displayedPanes.map(({ label }) => (
<Tab key={label} label={label} />
))}
</Tabs>
)}
<Paper variant="outlined">
<ColorizedCoursesTableContext.Provider value={settingsContext}>
{displayedPanes.at(tab)?.render() ?? null}
</ColorizedCoursesTableContext.Provider>
</Paper>
</Stack>
)
}
@@ -12,8 +12,8 @@ import { StyledTable } from '@/components/common/StyledTable'
import { findCorrectProgramme } from '@/components/CustomPopulation/CustomPopulationProgrammeDist/util'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { Loading } from '@/components/Loading'
import { useSemesters } from '@/hooks/useSemesters'
import { useGetFacultiesQuery } from '@/redux/facultyStats'
import { useGetSemestersQuery } from '@/redux/semesters'
import { FormattedStudent, Name } from '@oodikone/shared/types'
type CreditGainObj = Record<string, { credits: number; name: Name; students: string[] }>
@@ -76,8 +76,7 @@ export const CoursePopulationCreditGainTable = ({
const [tab, setTab] = useState(0)
const { data: faculties } = useGetFacultiesQuery()
const { data: semesters } = useGetSemestersQuery()
const { semesters: allSemesters, currentSemester } = semesters ?? { semesters: {}, currentSemester: null }
const { semesters, currentSemester } = useSemesters()
if (!faculties || !currentSemester) {
return <Loading />
@@ -95,7 +94,7 @@ export const CoursePopulationCreditGainTable = ({
const programme = findCorrectProgramme(
student,
codes,
allSemesters,
semesters,
new Date(from),
new Date(to),
currentSemester.semestercode
@@ -12,6 +12,7 @@ export const ExternalGradeFilterToggle = ({ grade }) => {
active={isActive}
applyFilter={() => filterDispatch(gradeFilter.actions.selectGrade(grade))}
clearFilter={() => filterDispatch(gradeFilter.actions.unselectGrade(grade))}
disabled={false}
popupContent="Rajaa opiskelijat kurssin arvosanan perusteella."
/>
)
@@ -28,8 +28,8 @@ import { PopulationStudents } from '@/components/PopulationStudents'
import { useFormat as formatGeneralTab } from '@/components/PopulationStudents/StudentTable/GeneralTab/format/index'
import { useDebouncedState } from '@/hooks/debouncedState'
import { useTitle } from '@/hooks/title'
import { useSemesters } from '@/hooks/useSemesters'
import { useGetPopulationStatisticsByCourseQuery } from '@/redux/populations'
import { useGetSemestersQuery } from '@/redux/semesters'
import { FilteredCourse } from '@/util/coursesOfPopulation'
import { parseQueryParams } from '@/util/queryparams'
import { SISStudyRightElement } from '@oodikone/shared/models'
@@ -81,12 +81,12 @@ export const CoursePopulation = () => {
[population?.students, codes]
)
const { data: semesters, isFetching: semestersFetching } = useGetSemestersQuery()
const {
semesters: allSemesters,
years: semesterYears,
currentSemester,
} = semesters ?? { semesters: {}, years: {}, currentSemester: null }
isLoading: semestersFetching,
} = useSemesters()
const getFromToDates = (from: number, to: number, separate: boolean) => {
if (semestersFetching) return {}
@@ -115,7 +115,7 @@ export const CoursePopulation = () => {
? (getTextIn(singleSemester.name) ?? '')
: `${new Date(dateFrom).getFullYear()}-${new Date(dateTo).getFullYear()}`
if (!population || !semesters) return <PageLoading isLoading />
if (!population || !currentSemester) return <PageLoading isLoading />
const getStudentRelevantProgrammes = (students: FormattedStudent[]) =>
students.reduce<Map<string, string>>((programmes, student) => {
@@ -199,48 +199,49 @@ export const CoursePopulation = () => {
},
]
const filters = [
genderFilter(),
studentNumberFilter(),
ageFilter(),
courseFilter({ courses: population?.coursestatistics.courses }),
creditsEarnedFilter(),
hetuFilter(),
startYearAtUniFilter(),
programmeFilter({
additionalModes: [
{
key: 'attainment',
label: 'Attainment',
predicate: (student: FormattedStudent, studyRightElement: SISStudyRightElement) => {
const correctProgramme = findCorrectProgramme(
student,
codes,
allSemesters,
new Date(dateFrom),
new Date(dateTo),
currentSemester?.semestercode
)
return correctProgramme?.code === studyRightElement.code
},
description: 'Student had an active study right at the time of course attainment.',
},
],
}),
gradeFilter({
courseCodes: codes,
from: dateFrom,
to: dateTo,
}),
]
return (
<FilterView
coursestatistics={population?.coursestatistics}
displayTray={!isFetching}
filters={[
genderFilter(),
studentNumberFilter(),
ageFilter(),
courseFilter({ courses: population?.coursestatistics.courses }),
creditsEarnedFilter(),
hetuFilter(),
startYearAtUniFilter(),
programmeFilter({
additionalModes: [
{
key: 'attainment',
label: 'Attainment',
predicate: (student: FormattedStudent, studyRightElement: SISStudyRightElement) => {
const correctProgramme = findCorrectProgramme(
student,
codes,
allSemesters,
new Date(dateFrom),
new Date(dateTo),
currentSemester?.semestercode
)
return correctProgramme?.code === studyRightElement.code
},
description: 'Student had an active study right at the time of course attainment.',
},
],
}),
gradeFilter({
courseCodes: codes,
from: dateFrom,
to: dateTo,
}),
]}
filters={filters}
initialOptions={{
[programmeFilter.key]: { mode: 'attainment', selectedProgrammes: [] },
}}
name="CoursePopulation"
students={population?.students ?? []}
>
{(filteredStudents, filteredCourses) => (
@@ -0,0 +1,32 @@
import Card from '@mui/material/Card'
import CardContent from '@mui/material/CardContent'
import CardHeader from '@mui/material/CardHeader'
const Tile = ({ children }) => (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', fontWeight: 'bold' }}>{children}</div>
)
const TileValue = ({ children }) => <div style={{ fontSize: '1.25em' }}>{children}</div>
export const CreditGraphTooltip = ({ credits, date, grade, isStudyModuleCredit, courseCode, courseName }) => {
const title = `${courseName} (${courseCode})`
return (
<Card variant="outlined">
<CardHeader
content={`${title}${isStudyModuleCredit ? ' [Study Module]' : ''}`}
style={{ whiteSpace: 'normal' }}
/>
<CardContent style={{ display: 'grid', grid: '1fr 1fr 1fr', gridRowGap: '0.25rem' }}>
<Tile>
Credits <TileValue>{credits}</TileValue>
</Tile>
<Tile>
Grade <TileValue>{grade}</TileValue>
</Tile>
<Tile>
Date <TileValue>{date}</TileValue>
</Tile>
</CardContent>
</Card>
)
}
@@ -0,0 +1,569 @@
// @ts-check
import Button from '@mui/material/Button'
import FormControlLabel from '@mui/material/FormControlLabel'
import Switch from '@mui/material/Switch'
import dayjs from 'dayjs'
import exportData from 'highcharts/modules/export-data'
import exporting from 'highcharts/modules/exporting'
import { flatten } from 'lodash-es'
import { useRef, useState } from 'react'
import { renderToString } from 'react-dom/server'
import ReactHighstock from 'react-highcharts/ReactHighstock'
import { getStudyRightElementTargetDates } from '@/common'
import { InfoBox } from '@/components/InfoBox/InfoBoxWithTooltip'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { DateFormat } from '@/constants/date'
import { useDeepMemo } from '@/hooks/deepMemo'
import { reformatDate } from '@/util/timeAndDate'
import { CreditGraphTooltip } from './CreditGraphTooltip'
exporting(ReactHighstock.Highcharts)
exportData(ReactHighstock.Highcharts)
const SINGLE_GRAPH_GOAL_SERIES_NAME = 'Goal'
const createGraphOptions = ({
singleStudent,
seriesData,
graphHeight,
tooltipFormatter,
onPointClicked,
startDate,
endDate,
graduations,
transfers,
studyRightStartLine,
}) => {
const tooltip = {
shared: false,
split: false,
}
if (singleStudent) {
Object.assign(tooltip, {
formatter() {
return tooltipFormatter(this)
},
useHTML: true,
style: {
all: 'unset',
display: 'none',
},
})
}
return {
plotOptions: {
series: {
findNearestPointBy: 'xy',
point: {
events: {
click() {
onPointClicked(this)
},
},
cursor: 'pointer',
},
},
},
yAxis: {
title: { text: 'Credits' },
min: 0,
max: undefined,
},
xAxis: {
ordinal: false,
min: startDate,
max: endDate,
plotLines: graduations
.map(graduation => ({
value: graduation.value,
color: '#a333c8',
width: 2,
dashStyle: 'dash',
label: {
text: `Graduation ${graduation.studyright}`,
fontSize: 30,
},
}))
.concat(
studyRightStartLine.map(value => ({
value,
color: 'red',
width: 3,
dashStyle: 'dash',
label: {
text: 'Population study start',
fontSize: 30,
},
}))
)
.concat(
transfers.map(transfer => ({
value: transfer.value,
color: '#cbd128',
width: 2,
dashStyle: 'dash',
label: {
text: `Transfer ${transfer.studyright}`,
fontSize: 30,
},
}))
),
},
rangeSelector: {
selected: 5, // Set default zoom to 'All'
},
series: seriesData,
chart: {
height: graphHeight,
},
tooltip,
accessibility: {
// Accessibility module doesn't seem to work with Highstock, at least for large amounts of data
enabled: false,
},
}
}
const filterCoursesByStudyPlan = (plan, courses) => {
if (!plan) {
return courses
}
return courses.filter(({ course, course_code }) => {
if (!course?.code && !course_code) {
return false
}
return plan.included_courses.includes(course_code ?? course.code)
})
}
const filterCoursesByDate = (courses, date) => courses.filter(course => dayjs(course.date).isSameOrAfter(dayjs(date)))
const filterCourses = (student, singleStudent, byStudyPlanOfCode, cutStudyPlanCredits, startDate, studyrightid) => {
if (byStudyPlanOfCode && cutStudyPlanCredits)
return filterCoursesByDate(
filterCoursesByStudyPlan(
student.studyplans.find(
plan => plan.programme_code === byStudyPlanOfCode && plan.sis_study_right_id === studyrightid
),
student.courses
),
student.studyrightStart
)
if (byStudyPlanOfCode)
return filterCoursesByStudyPlan(
student.studyplans.find(
plan => plan.programme_code === byStudyPlanOfCode && plan.sis_study_right_id === studyrightid
),
student.courses
)
if (singleStudent) return student.courses
return filterCoursesByDate(student.courses, startDate)
}
const singleStudentTooltipFormatter = (point, student, getTextIn) => {
const targetCourse = student.courses.find(
({ course, date }) => point.key === course.code && point.x === new Date(date).getTime()
)
if (!targetCourse.course) return null
const payload = {
...targetCourse,
courseCode: targetCourse.course.code,
courseName: getTextIn(targetCourse.course.name),
date: reformatDate(targetCourse.date, DateFormat.DISPLAY_DATE),
}
return renderToString(<CreditGraphTooltip {...payload} />)
}
const createGoalSeries = (graphStartDate, graphEndDate, absences) => {
const getColor = type => {
if (type === 2) return '#ffb300'
if (type === 3) return '#e53935'
return '#96d7c3'
}
const absenceIsBetweenGraphDates = (startDate, endDate) =>
(graphStartDate <= startDate && startDate <= graphEndDate) || (graphStartDate <= endDate && endDate <= graphEndDate)
let totalAbsenceYears = 0
const absencePoints = absences
.filter(a =>
a.enrollmenttype === 3
? absenceIsBetweenGraphDates(a.startDate, a.endDate) && a.startDate <= new Date()
: absenceIsBetweenGraphDates(a.startDate, a.endDate)
)
.reduce((res, { startDate, endDate, enrollmenttype, statutoryAbsence }) => {
const targetCreditsBeforeAbsence =
(dayjs(startDate).diff(dayjs(graphStartDate), 'years', true) - totalAbsenceYears) * 60
const absenceInYears = dayjs(endDate).diff(dayjs(startDate), 'years', true)
totalAbsenceYears += absenceInYears
res.push([startDate.getTime(), targetCreditsBeforeAbsence, null, null])
res.push([endDate.getTime(), targetCreditsBeforeAbsence, enrollmenttype, statutoryAbsence])
return res
}, [])
const zones = []
for (const [start, , enrollmenttype, statutoryAbsence] of absencePoints) {
zones.push({
value: start,
color: getColor(enrollmenttype),
dashStyle: statutoryAbsence ? 'Dash' : 'Solid',
})
}
zones.push({ color: '#96d7c3' })
const yearsFromStart = dayjs(graphEndDate).diff(dayjs(graphStartDate), 'years', true)
const endingCredits = (yearsFromStart - totalAbsenceYears) * 60
const data = [[graphStartDate, 0], ...absencePoints, [graphEndDate, endingCredits]].sort((a, b) => a[0] - b[0])
return {
name: SINGLE_GRAPH_GOAL_SERIES_NAME,
data,
enableMouseTracking: false,
zones,
zoneAxis: 'x',
}
}
const filterGraduations = (student, selectedStudyRight, getTextIn) => {
const graduations = student.studyRights
.flatMap(studyRight => studyRight.studyRightElements)
.filter(element => element.graduated)
if (!selectedStudyRight)
return graduations.map(({ endDate, name }) => ({
value: new Date(endDate).getTime(),
studyright: getTextIn(name),
}))
const selectedGraduation = graduations.filter(({ studyRightId }) => studyRightId === selectedStudyRight.id)
if (selectedGraduation.length === 0) return []
return selectedGraduation.map(({ endDate, name }) => ({
value: new Date(endDate).getTime(),
studyright: getTextIn(name),
}))
}
const addGraduation = (points, graduationDate, notFirst) => {
const index = points.findIndex(point => point.x > graduationDate)
let graduationY
if (index <= 0) {
graduationY = graduationDate > points[0].x ? points[points.length - 1].y : points[0].y
} else {
const yBefore = points[index - 1].y
const yAfter = points[index].y
graduationY = Math.round((yBefore + yAfter) / 2)
}
const marker = {
radius: 10,
symbol: 'diamond',
enabled: true,
}
const masterMarker = {
radius: 12,
symbol: 'circle',
lineWidth: 2,
lineColor: '#ffffff',
enabled: true,
}
points.push({
x: graduationDate,
y: graduationY,
marker: notFirst ? masterMarker : marker,
})
points.sort((a, b) => a.x - b.x)
}
const findGraduationsByCodes = (student, programmeCodes, showBachelorAndMaster) => {
if (showBachelorAndMaster) {
return (
student.studyRights
.find(studyRight => studyRight.studyRightElements.some(element => programmeCodes.includes(element.code)))
?.studyRightElements.filter(({ graduated }) => graduated === true) ?? []
)
.map(({ endDate }) => new Date(endDate).getTime())
.sort((a, b) => a - b)
}
return student.studyRights
.flatMap(studyRight => studyRight.studyRightElements)
.filter(({ graduated, code }) => graduated === true && programmeCodes.includes(code))
.map(({ endDate }) => new Date(endDate).getTime())
.sort((a, b) => a - b)
}
const createStudentCreditLines = (
students,
singleStudent,
studyRightId,
studyPlanFilterIsActive,
cutStudyPlanCredits,
programmeCodes,
selectedStudyPlan,
showBachelorAndMaster
) =>
students.map(student => {
const { studyrightStart } = student
const code = selectedStudyPlan?.programme_code
const studyPlanProgrammeCode = singleStudent
? code
: studyPlanFilterIsActive && programmeCodes?.length > 0 && programmeCodes[0]
const { points } = filterCourses(
student,
singleStudent,
studyPlanProgrammeCode,
cutStudyPlanCredits,
studyrightStart,
studyRightId
)
.filter(({ date }) => new Date(date) <= new Date())
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
.reduce(
(acc, course) => {
// Only include passed and transferred courses
if (![4, 9].includes(course.credittypecode)) return acc
const gainedCredits = !course.isStudyModuleCredit ? course.credits : 0
const point = {
x: new Date(course.date).getTime(),
y: acc.credits + gainedCredits,
}
if (singleStudent) point.name = course.course.code
acc.points.push(point)
return {
...acc,
credits: acc.credits + gainedCredits,
}
},
{ credits: 0, points: [] }
)
const graduationDates = programmeCodes ? findGraduationsByCodes(student, programmeCodes, showBachelorAndMaster) : []
if (points?.length > 0) {
if (!singleStudent && points[0].y !== 0 && students.length < 100) {
const xMinusTwoMonths = dayjs(new Date(points[0].x)).subtract(2, 'months').toDate().getTime()
points.unshift({
x: studyPlanFilterIsActive ? xMinusTwoMonths : Math.max(new Date(studyrightStart).getTime(), xMinusTwoMonths),
y: 0,
})
}
graduationDates.forEach((graduationDate, index) => addGraduation(points, graduationDate, index > 0))
}
return {
name: student.studentNumber,
data: points,
seriesThreshold: 150,
marker: {
enabled: !!singleStudent,
radius: 4,
},
}
})
const filterTransfers = (student, getTextIn) =>
student.studyRights.reduce((transfers, studyRight) => {
const phase1Programmes = studyRight.studyRightElements.filter(element => element.phase === 1)
const phase2Programmes = studyRight.studyRightElements.filter(element => element.phase === 2)
for (const programmes of [phase1Programmes, phase2Programmes]) {
// The oldest study right element is ignored, as there can be no transfer to it
for (const element of programmes.toSpliced(-1)) {
transfers.push({
value: new Date(element.startDate),
studyright: `to ${getTextIn(element.name)}`,
})
}
}
return transfers
}, [])
export const CreditAccumulationGraphHighCharts = ({
students,
singleStudent,
absences,
startDate,
endDate,
studyRightId,
programmeCodes,
customPopulation = false,
studyPlanFilterIsActive,
selectedStudyPlan,
showBachelorAndMaster,
infoBoxContent = '',
}) => {
const chartRef = useRef()
const { getTextIn } = useLanguage()
const [graphHeight, setGraphHeight] = useState(700)
const [cutStudyPlanCredits, setCutStudyPlanCredits] = useState(false)
const selectedStudyRight =
singleStudent && studyRightId ? students[0].studyRights.find(({ id }) => id === studyRightId) : null
const seriesData = useDeepMemo(
() =>
createStudentCreditLines(
students,
singleStudent,
studyRightId,
studyPlanFilterIsActive,
cutStudyPlanCredits,
programmeCodes,
selectedStudyPlan,
showBachelorAndMaster
),
[
students,
singleStudent,
studyRightId,
studyPlanFilterIsActive,
cutStudyPlanCredits,
programmeCodes,
selectedStudyPlan,
showBachelorAndMaster,
]
)
if (singleStudent) {
const studyPlanProgrammeCode = selectedStudyPlan ? selectedStudyPlan.programme_code : null
const correctStudyRightElement = selectedStudyRight?.studyRightElements.find(
element => element.code === studyPlanProgrammeCode
)
const startDates = selectedStudyRight
? selectedStudyRight.studyRightElements
.filter(e => e.phase === correctStudyRightElement.phase)
.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime())
.map(e => e.startDate)
: (students[0].studyRights ?? students[0].courses ?? []).map(e => e.startDate ?? e.date)
const firstDate = startDates.length
? startDates.map(date => new Date(date)).sort((a, b) => a.getTime() - b.getTime())[0]
: new Date()
const startDate = firstDate.getTime()
const [, studyRightTargetEnd] = getStudyRightElementTargetDates(
selectedStudyRight?.studyRightElements
.filter(element => element.phase === correctStudyRightElement.phase)
.sort(({ startDate: a }, { startDate: b }) => new Date(a).getTime() - new Date(b).getTime())
.at(0),
absences
)
const ending = selectedStudyRight ? new Date(studyRightTargetEnd) : new Date(endDate ?? new Date())
const starting = new Date(startDate)
const filteredAbsences = selectedStudyRight
? absences.filter(({ startDate, endDate }) => startDate >= starting && endDate <= ending)
: absences
seriesData.push(createGoalSeries(starting.getTime(), ending.getTime(), filteredAbsences))
}
const getStudyRightStart = () => {
const studyRightStartFromStudent = new Date(students[0]?.studyrightStart ?? new Date())
if (studyRightStartFromStudent.getFullYear() < 2000)
return Math.min(...flatten(students.map(({ courses }) => courses.map(({ date }) => new Date(date).getTime()))))
return studyRightStartFromStudent.getTime()
}
const getGraphStart = () => {
if (startDate) return new Date(startDate).getTime()
if (customPopulation)
return Math.min(
...flatten(
students.map(({ courses }) => {
return courses.map(({ date }) => new Date(date).getTime())
})
)
)
const studyRightStart = getStudyRightStart()
if (studyPlanFilterIsActive && cutStudyPlanCredits) return studyRightStart
if (studyPlanFilterIsActive) {
// math.min may return a infinite value, if beginning of course credits is selected and student's courses are filtered accordingly.
const startPoint = Math.min(
...flatten(students.map(({ courses }) => courses.map(({ date }) => new Date(date).getTime())))
)
return startPoint !== Infinity ? startPoint : studyRightStart
}
return studyRightStart
}
const graduations = singleStudent ? filterGraduations(students[0], selectedStudyRight, getTextIn) : []
const transfers = singleStudent ? filterTransfers(students[0], getTextIn) : []
const studyRightStartLine =
!singleStudent && studyPlanFilterIsActive && students[0]?.studyrightStart
? [new Date(students[0].studyrightStart).getTime()]
: []
const options = createGraphOptions({
singleStudent,
seriesData,
graphHeight,
startDate: getGraphStart(),
endDate,
tooltipFormatter: point => singleStudent && singleStudentTooltipFormatter(point, students[0], getTextIn),
onPointClicked: point => {
if (!singleStudent) {
if (point.series.name.length > 10) return
window.open(`/students/${point.series.name}`, '_blank')
}
},
graduations,
transfers,
studyRightStartLine,
})
const makeGraphSizeButton = (height, label) => (
<Button
color="primary"
onClick={() => setGraphHeight(height)}
variant={graphHeight === height ? 'contained' : 'outlined'}
>
{label}
</Button>
)
return (
<div style={{ minWidth: '400px', marginBottom: '15px' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
flexDirection: 'row-reverse',
alignItems: 'center',
margin: '1rem 0',
}}
>
{!!infoBoxContent && <InfoBox content={infoBoxContent} />}
<div>
{!singleStudent && studyPlanFilterIsActive ? (
<FormControlLabel
control={<Switch />}
label="Display credits from study right start"
onClick={() => setCutStudyPlanCredits(!cutStudyPlanCredits)}
/>
) : null}
</div>
<div>
{makeGraphSizeButton(400, 'Small')}
{makeGraphSizeButton(700, 'Medium')}
{makeGraphSizeButton(1000, 'Large')}
</div>
</div>
<ReactHighstock config={options} ref={chartRef} />
</div>
)
}
@@ -9,22 +9,21 @@ import { useEffect } from 'react'
import { populationStatisticsToolTips } from '@/common/InfoToolTips'
import { PanelView } from '@/components/common/PanelView'
import { StyledMessage } from '@/components/common/StyledMessage'
import { CreditAccumulationGraphHighCharts } from '@/components/CreditAccumulationGraphHighCharts'
import { InfoBox } from '@/components/InfoBox/InfoBoxWithTooltip'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { PopulationCourseStatsFlat } from '@/components/PopulationCourseStats/PopulationCourseStatsFlat'
import { PopulationStudents } from '@/components/PopulationStudents'
import { useFormat as formatGeneralTab } from '@/components/PopulationStudents/StudentTable/GeneralTab/format/index'
import { ProgressBar } from '@/components/ProgressBar'
import { RightsNotification } from '@/components/RightsNotification'
import { useDebouncedState } from '@/hooks/debouncedState'
import { useProgress } from '@/hooks/progress'
import { useFilteredAndFormattedStudyProgrammes } from '@/redux/studyProgramme'
import { KeyboardBackspaceIcon, LabelIcon } from '@/theme'
import { FilteredCourse } from '@/util/coursesOfPopulation'
import { FormattedStudent } from '@oodikone/shared/types'
import { CreditAccumulationGraph } from '../Charts/CreditAccumulation'
import { PageTitle } from '../common/PageTitle'
import { StudentAmountLimiter } from '../common/StudentAmountLimiter'
import { Loading } from '../Loading'
import { CustomPopulationProgrammeDist } from './CustomPopulationProgrammeDist'
import { useColumns as columnsGeneralTab } from './studentColumns'
import { UnihowDataExport } from './UnihowDataExport'
@@ -61,16 +60,22 @@ export const CustomPopulationContent = ({
setStudentAmountLimit(Number.isNaN(Number(value)) ? studentAmountLimit : Number(value))
}
const { progress } = useProgress(isFetchingPopulation)
const panels = [
{
title: `Credit accumulation (for ${filteredStudents.length} students)`,
content: (
<CreditAccumulationGraph
programmeCodes={[associatedProgramme].filter(Boolean) as string[]}
<CreditAccumulationGraphHighCharts
absences={null}
customPopulation
endDate={null}
programmeCodes={null}
selectedStudyPlan={null}
showBachelorAndMaster={null}
singleStudent={false}
startDate={null}
students={filteredStudents}
studyPlanFilter={false} // Not enabled for custom population yet.
studyPlanFilterIsActive={null} // Atm the filter is not enabled in the view
studyRightId={null}
/>
),
},
@@ -139,7 +144,7 @@ export const CustomPopulationContent = ({
return (
<Box>
{isFetchingPopulation ? (
<ProgressBar progress={progress} />
<Loading />
) : (
<>
<PageTitle title={populationName ? `Custom population: ${populationName}` : 'Custom population'}>
@@ -11,7 +11,7 @@ import { StyledTable } from '@/components/common/StyledTable'
import { isProgrammeSelected, toggleProgrammeSelection } from '@/components/FilterView/filters/programmes'
import { useFilters } from '@/components/FilterView/useFilters'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { useGetSemestersQuery } from '@/redux/semesters'
import { useSemesters } from '@/hooks/useSemesters'
import { FormattedStudent, Name } from '@oodikone/shared/types'
import { findCorrectProgramme, NO_PROGRAMME } from './util'
@@ -27,9 +27,8 @@ export const CustomPopulationProgrammeDist = ({
to?: string
}) => {
const { getTextIn } = useLanguage()
const { data: semesters } = useGetSemestersQuery()
const { semesters: allSemesters, currentSemester } = semesters ?? { semesters: {}, currentSemester: null }
if (!allSemesters || !currentSemester) return null
const { semesters, currentSemester } = useSemesters()
if (!currentSemester) return null
const allProgrammes: Record<string, { name: Name; programmeStudents: number }> = {}
for (const student of students) {
@@ -39,7 +38,7 @@ export const CustomPopulationProgrammeDist = ({
programme = findCorrectProgramme(
student,
coursecodes,
allSemesters,
semesters,
new Date(from!),
new Date(to!),
currentSemester.semestercode
@@ -3,7 +3,7 @@ import isBetween from 'dayjs/plugin/isBetween'
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
import { getNewestProgrammeOfStudentAt, getSemesterCodeAt } from '@/common'
import { SemestersData } from '@/redux/semesters'
import type { SemestersData } from '@/hooks/useSemesters'
import { CreditTypeCode, FormattedStudent } from '@oodikone/shared/types'
dayjsExtend(isBetween)
@@ -15,9 +15,8 @@ import {
tagsFilter,
transferredToProgrammeFilter,
} from '@/components/FilterView/filters'
import { useSemesters } from '@/hooks/useSemesters'
import { useGetCustomPopulationQuery } from '@/redux/populations'
import { useGetSemestersQuery } from '@/redux/semesters'
import { CustomPopulationState } from '.'
import { CustomPopulationContent } from './CustomPopulationContent'
@@ -28,8 +27,7 @@ export const CustomPopulationWrapper = ({
customPopulationState: CustomPopulationState
resetState: () => void
}) => {
const { data: semesters } = useGetSemestersQuery()
const { semesters: allSemesters } = semesters ?? { semesters: {} }
const { semesters } = useSemesters()
const { data: population, isFetching } = useGetCustomPopulationQuery(
{
@@ -56,7 +54,7 @@ export const CustomPopulationWrapper = ({
programmeFilter(),
creditDateFilter(),
enrollmentStatusFilter({
allSemesters,
allSemesters: semesters,
programme: associatedProgramme,
}),
]
@@ -64,7 +62,7 @@ export const CustomPopulationWrapper = ({
filtersList.push(hopsFilter({ programmeCode: associatedProgramme, combinedProgrammeCode: '' }))
}
return filtersList
}, [population, allSemesters, associatedProgramme])
}, [population, semesters, associatedProgramme])
return (
<FilterView
@@ -72,7 +70,6 @@ export const CustomPopulationWrapper = ({
displayTray={populationStudents.length > 0}
filters={filters}
initialOptions={{}}
name="CustomPopulation"
students={populationStudents}
>
{(filteredStudents, filteredCourses) => (
@@ -6,27 +6,38 @@ import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { DatePicker } from '@mui/x-date-pickers/DatePicker/DatePicker'
import dayjs from 'dayjs'
import { useRef, type MutableRefObject } from 'react'
import dayjs, { type Dayjs } from 'dayjs'
import { Ref, useRef, type MutableRefObject } from 'react'
import 'dayjs/locale/fi'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { DateFormat } from '@/constants/date'
import { useGetSemestersQuery, type SemestersData } from '@/redux/semesters'
import { useSemesters } from '@/hooks/useSemesters'
export const DateSelector = ({ value, onChange, before, after, showSemesters }) => {
const datetimeRef: MutableRefObject<HTMLInputElement | null> = useRef(null)
const { data: semesters, isFetching } = useGetSemestersQuery()
const { semesters: allSemesters } = semesters ?? ({ semesters: {} } as SemestersData)
export type DateSelectorValue = Dayjs | null
export const DateSelector = ({
value,
onChange,
before,
after,
showSemesters,
}: {
value: DateSelectorValue
onChange: (input: DateSelectorValue) => void
before?: DateSelectorValue
after?: DateSelectorValue
showSemesters: boolean
}) => {
const datetimeRef: MutableRefObject<Dayjs | null> = useRef(null)
const { semesters, isLoading } = useSemesters()
const { getTextIn } = useLanguage()
if (isFetching) return null
if (!Object.keys(allSemesters).length) return null
if (isLoading || !Object.keys(semesters).length) return null
const today = dayjs().startOf('day')
const triggerOnChange = date => {
const triggerOnChange = (date: Dayjs) => {
if (datetimeRef.current !== date) {
datetimeRef.current = date
onChange(datetimeRef.current)
@@ -62,7 +73,7 @@ export const DateSelector = ({ value, onChange, before, after, showSemesters })
borderTop: '1px solid #f9f9f9',
}}
>
{Object.values(allSemesters)
{Object.values(semesters)
.filter(({ startdate, enddate }) => {
const start = dayjs(startdate)
const end = dayjs(enddate)
@@ -91,7 +102,7 @@ export const DateSelector = ({ value, onChange, before, after, showSemesters })
className="date-picker"
format={DateFormat.DISPLAY_DATE}
formatDensity="dense"
inputRef={datetimeRef}
inputRef={datetimeRef as Ref<HTMLInputElement>}
maxDate={dayjs(before ?? today)}
minDate={after ? dayjs(after) : undefined}
onChange={onChange}
@@ -2,8 +2,8 @@ import Button from '@mui/material/Button'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import ReactECharts from 'echarts-for-react'
import { random } from 'lodash-es'
import ReactHighchart from 'react-highcharts'
import toskaLogo from '@/assets/toska.svg'
import { Backdrop } from '@/components/common/Backdrop'
@@ -12,83 +12,63 @@ import { useTitle } from '@/hooks/title'
import { useLogoutMutation } from '@/redux/auth'
import { LogoutIcon, RefreshIcon } from '@/theme'
const dummyData = [...Array(18).keys()].map(_ => [...Array(new Date().getFullYear() - 2016).keys()].reduce<number[]>((acc, i) => {
acc[i] = (acc[i - 1] || 0) + (random(0, 3) === 0 ? 0 : random(10.0, 100.0))
return acc
}, []))
const names = [
'mluukkai',
'jakousa',
'totutotu',
'sasumaki',
'ikuisma',
'eero3',
'mitiaine',
'rimi',
'esakemp',
'woltsu',
'cxcorp',
'ajhaa',
'joonashak',
'vaahtokarkki',
'otahontas',
'saarasat',
'popalmu',
'ollikehy',
] as const
const dummyData = names.map(name => ({
name,
data: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].reduce((acc, i) => {
acc[i] = (acc[i - 1] || 0) + (random(0, 3) === 0 ? 0 : random(10.0, 100.0))
return acc
}, [] as number[]),
type: 'line' as const,
}))
export const ErrorBackground = ({ header, content }) => {
useTitle(header)
const [logout] = useLogoutMutation()
const yearLabels = dummyData[0]?.map((_, index) => (2017 + index).toString()) ?? []
const logout = useLogoutMutation()
return (
<PageLayout maxWidth="lg">
<Stack sx={{ alignItems: 'stretch', height: '100vh', justifyContent: 'space-evenly' }}>
<ReactECharts
option={{
title: {
text: `Students of Computer Science 2017-${new Date().getFullYear()}`,
},
tooltip: {
trigger: 'axis',
},
grid: {
left: 40,
right: 20,
top: 40,
bottom: 30,
containLabel: true,
},
xAxis: {
type: 'category',
data: yearLabels,
},
yAxis: {
type: 'value',
name: 'Cumulative credits',
},
series: dummyData.map(data => ({
type: 'line',
data,
})),
<ReactHighchart
config={{
plotOptions: { series: { label: { connectorAllowed: false }, pointStart: 2010 } },
series: dummyData,
title: { text: 'Students of Computer Science 2018-2020' },
yAxis: { title: { text: 'Cumulative credits' } },
}}
opts={{ renderer: 'svg' }}
style={{ height: 300, width: '100%' }}
/>
<ReactECharts
option={{
title: {
text: "Your students' future",
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
grid: {
left: 40,
right: 20,
top: 40,
bottom: 30,
containLabel: true,
},
xAxis: {
type: 'category',
data: ['2018'],
},
yAxis: {
type: 'value',
name: 'Cumulative credits',
},
series: dummyData.map(data => ({
type: 'bar',
data: [Math.max(...data)],
<ReactHighchart
config={{
chart: { type: 'column' },
series: dummyData.map(element => ({
name: element.name,
data: [Math.max(...element.data)],
type: 'column',
})),
title: { text: "Your students' future" },
xAxis: { categories: ['2018'] },
yAxis: { title: { text: 'Cumulative credits' } },
}}
opts={{ renderer: 'svg' }}
style={{ height: 300, width: '100%' }}
/>
</Stack>
<Backdrop
@@ -1,7 +1,7 @@
import Stack from '@mui/material/Stack'
import { facultyToolTips } from '@/common/InfoToolTips'
import { ProgressBarChart } from '@/components/Charts/ProgressBarChart'
import { ProgressBarChart } from '@/components/common/ProgressBarChart'
import { FacultyProgressTable } from '@/components/Faculties/FacultyProgressTable'
import { Section } from '@/components/Section'
import { GetAllProgressStatsResponse } from '@/types/api/faculty'
@@ -1,11 +1,20 @@
/* eslint-disable react/no-this-in-sfc */
import Typography from '@mui/material/Typography'
import ReactECharts from 'echarts-for-react'
import { useEffect, useMemo, useState } from 'react'
import HighCharts from 'highcharts' // eslint-disable-line import-x/default
import accessibility from 'highcharts/modules/accessibility'
import exportData from 'highcharts/modules/export-data'
import exporting from 'highcharts/modules/exporting'
import ReactHighcharts from 'react-highcharts'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { generateGradientColors } from '@/util/color'
import { NameWithCode } from '@oodikone/shared/types'
exporting(ReactHighcharts.Highcharts)
exportData(ReactHighcharts.Highcharts)
accessibility(ReactHighcharts.Highcharts)
export const ProgrammeProgressChart = ({
data,
labels,
@@ -18,159 +27,70 @@ export const ProgrammeProgressChart = ({
names: string[]
}) => {
const { getTextIn } = useLanguage()
const [legendSelection, setLegendSelection] = useState<Record<string, boolean>>({})
const labelsKey = labels.join('|')
const seriesNamesKey = names.join('|')
useEffect(() => {
setLegendSelection({})
}, [labelsKey, seriesNamesKey])
const onEvents = useMemo(
() => ({
legendselectchanged: (params: { selected?: Record<string, boolean> }) => {
if (!params.selected) {
return
}
setLegendSelection(params.selected)
},
}),
[]
)
if (!data || data.length === 0) {
return <Typography variant="caption">No data available for this year</Typography>
}
const getNumericValue = (value: unknown) => {
const numericValue = typeof value === 'number' ? value : Number(value)
return Number.isFinite(numericValue) ? numericValue : 0
}
const transpose = (matrix: number[][]): number[][] => {
return matrix.reduce<number[][]>((prev, next) => next.map((_, i) => (prev[i] || []).concat(next[i])), [])
}
const colors = generateGradientColors(data[0]?.length || 6)
const dataTranspose = transpose(data).map((obj, index) => ({
const dataTranspose: HighCharts.SeriesBarOptions[] = transpose(data).map((obj, index) => ({
name: names[index],
data: obj,
color: colors[index],
type: 'bar',
}))
const isSeriesVisible = (name: string) => legendSelection[name] !== false
const totals = labels.map((_, index) =>
dataTranspose.reduce((sum, series) => {
if (!isSeriesVisible(series.name)) {
return sum
}
return sum + getNumericValue(series.data[index])
}, 0)
)
const series = dataTranspose.map((seriesItem, index) => ({
name: seriesItem.name,
type: 'bar',
stack: 'total',
itemStyle: {
color: colors[index],
const config: HighCharts.Options = {
chart: {
type: 'bar',
marginTop: 60,
height: 80 + 45 * labels.length,
},
label: {
show: true,
position: 'inside',
formatter: (params: { data?: { percentage?: number } }) => {
const percentage = getNumericValue(params.data?.percentage)
return percentage > 3 ? `${percentage.toFixed(1)}%` : ''
series: dataTranspose,
xAxis: {
categories: labels,
},
yAxis: {
min: 0,
reversed: true,
title: {
text: '',
},
},
labelLayout: {
hideOverlap: true,
},
emphasis: {
focus: 'none',
},
data: labels.map((_, dataIndex) => {
const rawValue = getNumericValue(seriesItem.data[dataIndex])
const total = totals[dataIndex]
const percentage = total ? (rawValue / total) * 100 : 0
return {
value: percentage,
rawValue,
percentage,
}
}),
}))
const option = {
legend: {
borderColor: '#CCC',
borderWidth: 1,
bottom: 0,
selected: Object.keys(legendSelection).length ? legendSelection : undefined,
verticalAlign: 'top',
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
shared: true,
backgroundColor: 'white',
formatter: (
params: Array<{ name?: string; seriesName?: string; color?: string; data?: { rawValue?: number } }>
) => {
const tooltipParams = Array.isArray(params) ? params : [params]
const label = tooltipParams[0]?.name ?? ''
const title = getTextIn(longLabels[label]) ?? ''
let tooltipString = `<b>${title}</b><br /><p>${label}</p><br />`
tooltipParams.forEach(point => {
const rawValue = getNumericValue(point.data?.rawValue)
tooltipString += `<span style="color:${point.color}">●</span> ${point.seriesName}: <b>${rawValue}</b><br />`
formatter(this: any) {
let tooltipString = `<b>${getTextIn(longLabels[this.x])}</b><br /><p>${this.x}</p><br />`
this.points.forEach((point: any) => {
tooltipString += `<span style="color:${point.color}">●</span> ${point.series.name}: <b>${point.y}</b><br />`
})
return tooltipString
},
},
toolbox: {
feature: {
saveAsImage: {
name: 'Oodikone-progress-of-students',
plotOptions: {
series: {
stacking: 'percent',
dataLabels: {
enabled: true,
format: '{point.percentage:.1f}%',
filter: {
property: 'percentage',
operator: '>',
value: 3,
},
},
},
},
grid: {
top: 50,
left: 10,
right: 20,
bottom: 50,
containLabel: true,
},
xAxis: {
type: 'value',
min: 0,
max: 100,
axisLabel: {
formatter: '{value}%',
},
},
yAxis: {
type: 'category',
data: labels,
inverse: true,
z: 10,
},
series,
}
return (
<ReactECharts
notMerge
onEvents={onEvents}
option={option}
opts={{ renderer: 'svg' }}
style={{ height: 80 + 45 * labels.length, width: '100%' }}
/>
)
return <ReactHighcharts config={config} />
}
@@ -4,8 +4,8 @@ import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { useContext } from 'react'
import { setViewFilterOptions, resetViewFilter, resetAllViewFilters, selectViewFilters } from '@/redux/filters'
import { useAppDispatch, useAppSelector } from '@/redux/hooks'
import { setViewFilterOptions, resetViewFilter, resetAllViewFilters } from '@/redux/filters'
import { useAppDispatch } from '@/redux/hooks'
import type { FormattedStudent as Student } from '@oodikone/shared/types/studentData'
import { FilterViewContext } from './context'
import type { FilterContext } from './context'
@@ -18,42 +18,18 @@ export type FilterTrayProps = {
} & FilterContext
export const FilterTray = ({
numberOfFilteredStudents,
allStudents,
filters,
numberOfFilteredStudents,
filtersInUse,
}: {
numberOfFilteredStudents: number
allStudents: Student[]
filters: Filter[]
numberOfFilteredStudents: number
filtersInUse: boolean
}) => {
const { viewName, getContextByKey } = useContext(FilterViewContext)
const dispatch = useAppDispatch()
const storedOptions = useAppSelector(state => selectViewFilters(state, viewName))
const resetAllFilters = () => dispatch(resetAllViewFilters({ view: viewName }))
const filterSet = filters
.sort(({ title: a }, { title: b }) => a.localeCompare(b))
.map(filter => {
const { key, isActive, render } = filter
const ctx = getContextByKey(key)
const active = isActive(ctx.options)
const onClear = () => dispatch(resetViewFilter({ view: viewName, filter: key }))
const props: FilterTrayProps = {
students: allStudents,
onOptionsChange: options => dispatch(setViewFilterOptions({ view: viewName, filter: key, options })),
...ctx,
}
return (
<FilterCard active={active} filter={filter} key={key} onClear={onClear}>
{render(props)}
</FilterCard>
)
})
const { getContextByKey } = useContext(FilterViewContext)
return (
<Paper
@@ -61,8 +37,8 @@ export const FilterTray = ({
py: 3,
px: 2,
borderRadius: 0,
maxWidth: '18em',
flex: '0 0 18em',
width: '20em',
minWidth: '20em',
height: 'fit-content',
}}
variant="outlined"
@@ -78,14 +54,36 @@ export const FilterTray = ({
color="inherit"
data-cy="reset-all-filters"
disableElevation
disabled={!filters.some(({ key }) => !!storedOptions[key])}
onClick={resetAllFilters}
disabled={!filtersInUse}
onClick={() => dispatch(resetAllViewFilters())}
variant="contained"
>
Reset All Filters
</Button>
</Stack>
{filterSet}
{filters
.sort(({ title: a }, { title: b }) => a.localeCompare(b))
.map(filter => {
const { key, isActive, render } = filter
const ctx = getContextByKey(key)
const props: FilterTrayProps = {
...ctx,
students: allStudents,
onOptionsChange: options => dispatch(setViewFilterOptions({ filter: key, options })),
}
return (
<FilterCard
active={isActive(ctx.options)}
filter={filter}
key={key}
onClear={() => dispatch(resetViewFilter({ filter: key }))}
>
{render(props)}
</FilterCard>
)
})}
</Paper>
)
}
@@ -7,12 +7,10 @@ export type FilterContext = {
}
export type FilterViewContextState = {
viewName: string
getContextByKey: (key: string) => FilterContext
}
const defaultState: FilterViewContextState = {
viewName: '<Unset>',
getContextByKey: () => ({
precomputed: null,
options: {},
@@ -66,7 +66,7 @@ export const admissionTypeFilter = createFilter({
.flatMap(student =>
findAllStudyRightsForProgramme(student, args.programme)
.filter(studyRight => !studyRight.cancelled && !!studyRight.admissionType)
.map(studyRight => studyRight.admissionType)
.map(studyRight => (studyRight.admissionType !== 'Koepisteet' ? studyRight.admissionType : 'Valintakoe'))
)
.reduce((acc, cur) => {
acc[cur] ??= 0
@@ -1,4 +1,4 @@
import { useCallback, useMemo } from 'react'
import { useMemo } from 'react'
import { useDebounce } from '@/hooks/debounce'
import { getAge } from '@/util/timeAndDate'
@@ -9,12 +9,9 @@ import { createFilter } from './createFilter'
const AgeFilterCard = ({ options, onOptionsChange, precomputed: bounds }: FilterTrayProps) => {
const { min, max } = bounds
const onChange = useCallback(
([min, max]) => {
onOptionsChange({ min, max })
},
[onOptionsChange]
)
const onChange = ([min, max]) => {
onOptionsChange({ min, max })
}
const value: [number, number] = useMemo(
() => [options.min ?? min, options.max ?? max],
@@ -32,24 +32,20 @@ export const FilterCard = ({
const handleOnClick = () => setOpened(state => !state)
const iconStyle = {
transition: 'transform 300ms',
...(opened ? { transform: 'rotate(90deg)' } : {}),
}
return (
<Stack
data-active={active}
data-cy={`${key}-filter-card`}
padding={0.5}
spacing={Number(opened) * 1.2}
sx={{ width: '100%' }}
>
<Stack data-active={active} data-cy={`${key}-filter-card`} padding={0.5} spacing={Number(opened) * 1.2}>
<Box
data-cy={`${key}-header`}
sx={{ alignItems: 'center', justifyContent: 'center', cursor: 'pointer', display: 'inline-flex' }}
>
<IconButton onClick={handleOnClick} size="small" sx={iconStyle}>
<IconButton
onClick={handleOnClick}
size="small"
sx={{
transition: 'transform 300ms',
...(opened && { transform: 'rotate(90deg)' }),
}}
>
<KeyboardArrowRightIcon />
</IconButton>
<Typography
@@ -64,7 +60,7 @@ export const FilterCard = ({
{active ? (
<ClearIcon
data-cy={`${key}-clear`}
onClick={() => onClear()}
onClick={onClear}
sx={{
color: theme => theme.palette.error.dark,
m: '0 0.5',
@@ -78,10 +78,7 @@ type FilterFactory = {
key: string
actions: Record<
string,
<P>(payload: P) => (
view: string,
getContext: FilterViewContextState['getContextByKey']
) => {
<P>(payload: P) => (getContext: FilterViewContextState['getContextByKey']) => {
payload: P
type: string
}
@@ -136,11 +133,10 @@ export const createFilter = (options: FilterOptions): FilterFactory => {
const actions = mapValues(opt_actions, ([key, action]) => {
return [
key,
payload => (view: string, getContext: FilterViewContextState['getContextByKey']) => {
payload => (getContext: FilterViewContextState['getContextByKey']) => {
const ctx = getContext(options.key)
return setViewFilterOptions({
view,
filter: options.key,
options: action(structuredClone(ctx.options), payload),
})
@@ -2,7 +2,7 @@ import Stack from '@mui/material/Stack'
import { filterToolTips } from '@/common/InfoToolTips'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import type { SemestersData } from '@/redux/semesters'
import type { SemestersData } from '@/hooks/useSemesters'
import { FilterTrayProps } from '../FilterTray'
import { FilterSelect } from './common/FilterSelect'
import { createFilter } from './createFilter'
@@ -71,8 +71,9 @@ export const studentNumberFilter = createFilter({
filter(student, { options }) {
const { allowlist, blocklist } = options
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
return allowlist.includes(student.studentNumber) || !blocklist.includes(student.studentNumber)
return (
(!allowlist.length || allowlist.includes(student.studentNumber)) && !blocklist.includes(student.studentNumber)
)
},
render: StudentNumberFilterCard,
@@ -100,6 +101,6 @@ export const studentNumberFilter = createFilter({
},
selectors: {
studentListIsEqualToAllowlist: ({ allowlist }, students) => isEqual(allowlist, students),
studentListIsEqualToAllowlist: ({ allowlist }, students) => students.length && isEqual(students, allowlist),
},
})
@@ -1,29 +1,31 @@
import Stack from '@mui/material/Stack'
import { useMemo, type FC, type ReactNode } from 'react'
import { useMemo } from 'react'
import type { FC, ReactNode } from 'react'
import { selectViewFilters } from '@/redux/filters'
import { useAppSelector } from '@/redux/hooks'
import type { ExpandedCourseStats } from '@/redux/populations/util'
import { filterCourses, type FilteredCourse } from '@/util/coursesOfPopulation'
import { filterCourses } from '@/util/coursesOfPopulation'
import type { FilteredCourse } from '@/util/coursesOfPopulation'
import type { FormattedStudent as Student } from '@oodikone/shared/types/studentData'
import { PageLayout } from '../common/PageLayout'
import { FilterViewContext } from './context'
import type { FilterContext, FilterViewContextState } from './context'
import type { FilterContext } from './context'
import type { Filter } from './filters/createFilter'
import { FilterTray } from './FilterTray'
export const FilterView: FC<{
children: (filteredStudents: Student[], filteredCourses: FilteredCourse[]) => ReactNode
name: string
filters: Filter[]
students: Student[]
coursestatistics: ExpandedCourseStats | undefined
children: (filteredStudents: Student[], filteredCourses: FilteredCourse[]) => ReactNode
filters: Filter[]
displayTray: boolean
initialOptions: Record<Filter['key'], Filter['defaultOptions']>
}> = ({ children, name, filters, students, coursestatistics, displayTray, initialOptions }) => {
const storedOptions = useAppSelector(state => selectViewFilters(state, name))
}> = ({ children, filters, students, coursestatistics, initialOptions }) => {
const storedOptions = useAppSelector(state => selectViewFilters(state))
const filtersInUse = filters.some(({ key }) => !!storedOptions[key])
const filterArgs = Object.fromEntries(filters.map(({ key, args }) => [key, args]))
const filterOptions = useMemo(
@@ -69,14 +71,17 @@ export const FilterView: FC<{
return [fstudents, fcourses]
}, [filters, filterOptions])
const ctxState: FilterViewContextState = { viewName: name, getContextByKey: getFilterContext }
return (
<FilterViewContext.Provider value={ctxState}>
<Stack direction="row" id="filterview-stack">
{displayTray ? (
<FilterTray allStudents={students} filters={filters} numberOfFilteredStudents={filteredStudents.length} />
) : null}
<FilterViewContext.Provider value={{ getContextByKey: getFilterContext }}>
<Stack direction="row">
{!!coursestatistics && (
<FilterTray
allStudents={students}
filters={filters}
filtersInUse={filtersInUse}
numberOfFilteredStudents={filteredStudents.length}
/>
)}
<PageLayout maxWidth="80vw">{children(filteredStudents, filteredCourses)}</PageLayout>
</Stack>
</FilterViewContext.Provider>
@@ -1,12 +1,13 @@
import { useContext } from 'react'
import { useAppDispatch } from '@/redux/hooks'
import { FilterViewContext } from './context'
export const useFilters = () => {
const dispatch = useAppDispatch()
const { getContextByKey, viewName } = useContext(FilterViewContext)
const { getContextByKey } = useContext(FilterViewContext)
const filterDispatch = filterAction => dispatch(filterAction(viewName, getContextByKey))
const filterDispatch = filterAction => dispatch(filterAction(getContextByKey))
const useFilterSelector = filterSelector => {
const { options } = getContextByKey(filterSelector.filter)
@@ -0,0 +1,162 @@
/* eslint-disable react/no-this-in-sfc */
import Box from '@mui/material/Box'
import useTheme from '@mui/material/styles/useTheme'
import HighCharts from 'highcharts' // eslint-disable-line import-x/default
import accessibility from 'highcharts/modules/accessibility'
import exportData from 'highcharts/modules/export-data'
import exporting from 'highcharts/modules/exporting'
import ReactHighcharts from 'react-highcharts'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { GraduationStats, Name, NameWithCode } from '@oodikone/shared/types'
exporting(ReactHighcharts.Highcharts)
exportData(ReactHighcharts.Highcharts)
accessibility(ReactHighcharts.Highcharts)
export const BreakdownBarChart = ({
cypress,
data,
facultyGraph = true,
handleClick,
mode,
names,
year = null,
yearLabel,
}: {
cypress: string
data: GraduationStats[]
facultyGraph?: boolean
handleClick: (event, isFacultyGraph: boolean, seriesCategory?: number | string) => void
mode: 'faculty' | 'programme' | 'study track'
names?: Record<string, Name | NameWithCode> | Record<string, string | Name>
year?: number | null
yearLabel?: 'Graduation year' | 'Start year'
}) => {
const { language } = useLanguage()
const theme = useTheme()
const statData: HighCharts.SeriesBarOptions[] = [
{ type: 'bar', name: 'On time', color: theme.palette.graduationTimes.onTime, data: [] },
{ type: 'bar', name: 'Max. year overtime', color: theme.palette.graduationTimes.yearOver, data: [] },
{ type: 'bar', name: 'Overtime', color: theme.palette.graduationTimes.wayOver, data: [] },
]
let categories: string[] = []
const codeMap = {}
for (const item of data) {
statData[0].data?.push(item.statistics.onTime)
statData[1].data?.push(item.statistics.yearOver)
statData[2].data?.push(item.statistics.wayOver)
categories = [...categories, item.name]
if (!facultyGraph) {
codeMap[item.name || item.code!] = item.code
}
}
const getHeight = () => {
if (data.length === 1) {
return 250
}
const multiplier = data.length > 8 ? 80 : 110
return data.length * multiplier + 100
}
const getFacultyOrProgrammeName = (code: string) => {
if (!names) {
return ''
}
const name = names[code]
if (typeof name === 'string') {
return name
}
return name[language] ?? name.fi
}
const getTooltipText = (id: string, seriesName: string, amount: number) => {
if (!facultyGraph) {
const code = codeMap[id]
return `<b>${getFacultyOrProgrammeName(code)}</b> • ${code}<br /><b>${seriesName}</b>: ${amount}`
}
return `<b>${seriesName}</b>: ${amount}`
}
const getLabel = () => {
if (mode === 'faculty') {
return facultyGraph ? 'Graduation year' : 'Faculty'
}
return facultyGraph ? yearLabel! : `${mode.charAt(0).toUpperCase()}${mode.slice(1)}`
}
const config: HighCharts.Options = {
chart: {
type: 'bar',
height: getHeight(),
margin: [70, 0],
},
title: { text: !facultyGraph ? `Year ${year} by ${yearLabel!.toLowerCase()}` : '' },
series: statData.map(series => ({
...series,
pointWidth: data.length > 8 ? 16 : 20,
groupPadding: 0.15,
})),
tooltip: {
backgroundColor: 'white',
formatter() {
return getTooltipText(this.x as string, this.series.name, this.y!)
},
},
xAxis: {
type: 'category',
categories: categories.map(category => category.toString()),
title: {
text: getLabel(),
align: 'high',
offset: 0,
rotation: 0,
y: -10,
},
},
yAxis: {
min: 0,
title: { text: 'Number of students' },
labels: {
overflow: 'justify',
},
allowDecimals: false,
showFirstLabel: false,
},
plotOptions: {
bar: {
dataLabels: {
enabled: true,
align: 'right',
color: '#424949',
style: {
textOutline: 'none',
},
formatter() {
return this.y !== 0 ? this.y : ''
},
},
},
series: {
point: {
events: {
click(event) {
handleClick(event, facultyGraph, categories[this.x])
},
},
},
},
},
}
return (
<Box data-cy={cypress} width={{ sm: '100%', md: '50%' }}>
<ReactHighcharts config={config} />
</Box>
)
}
@@ -0,0 +1,101 @@
/* eslint-disable react/no-this-in-sfc */
import accessibility from 'highcharts/modules/accessibility'
import exportData from 'highcharts/modules/export-data'
import exporting from 'highcharts/modules/exporting'
import ReactHighcharts from 'react-highcharts'
import { Section } from '@/components/Section'
exporting(ReactHighcharts.Highcharts)
exportData(ReactHighcharts.Highcharts)
accessibility(ReactHighcharts.Highcharts)
// TODO: Combine with @/components/GraduationTimes/BreakdownDisplay/BreakdownBarChart
export const BreakdownBarChart = ({ data, title, byStartYear = false }) => {
const statData = [
{ name: 'On time', color: '#90A959', data: [] },
{ name: 'Max. year overtime', color: '#FEE191', data: [] },
{ name: 'Overtime', color: '#FB6962', data: [] },
]
let categories = []
for (const item of data) {
statData[0].data = [...statData[0].data, item.statistics.onTime]
statData[1].data = [...statData[1].data, item.statistics.yearOver]
statData[2].data = [...statData[2].data, item.statistics.wayOver]
categories = [...categories, item.name]
}
const getHeight = () => {
const multiplier = data.length > 8 ? 80 : 110
return data.length * multiplier + 100
}
const getTooltipText = (seriesName, amount) => {
return `<b>Graduated ${seriesName}</b>: ${amount} ${amount === 1 ? 'student' : 'students'}`
}
const config = {
chart: {
type: 'bar',
height: getHeight(),
margin: [70, 0],
},
title: { text: title },
series: statData,
tooltip: {
backgroundColor: 'white',
fontSize: '25px',
// eslint-disable-next-line
formatter: function () {
return getTooltipText(this.series.name, this.y)
},
},
xAxis: {
type: 'category',
categories,
title: {
text: byStartYear ? 'Start year' : 'Graduation year',
align: 'high',
offset: 0,
rotation: 0,
y: -10,
},
},
yAxis: {
min: 0,
title: { text: 'Number of students' },
labels: {
overflow: 'justify',
},
allowDecimals: false,
showFirstLabel: false,
},
plotOptions: {
bar: {
dataLabels: {
enabled: true,
align: 'right',
color: '#424949',
style: {
textOutline: 'none',
},
formatter() {
return this.y !== 0 ? this.y : ''
},
},
},
series: {
pointWidth: data.length > 8 ? 16 : 20,
groupPadding: 0.15,
},
},
}
return (
<Section cypress={`${title.split(' ')[0].toLowerCase()}-breakdown-bar-chart`}>
<ReactHighcharts config={config} />
</Section>
)
}
@@ -2,65 +2,44 @@ import Box from '@mui/material/Box'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { GraduationBreakdown } from '@/components/Charts/GraduationBreakdown'
import { BreakdownBarChart } from '@/components/GraduationTimes/BreakdownDisplay/BreakdownBarChart'
import { GraduationStats, Name, NameWithCode, ProgrammeMedians } from '@oodikone/shared/types'
export const BreakdownDisplay = ({
data,
handleClick,
allowExpand,
expandKey,
level,
levelProgrammeData,
mode,
names,
programmeDataVisible,
year,
yearLabel,
}: {
data: GraduationStats[]
handleClick: (seriesCategory: string) => void
allowExpand: boolean
expandKey: string | null,
handleClick: (event, isFacultyGraph: boolean, seriesCategory?: number | string) => void
level: string
levelProgrammeData?: ProgrammeMedians
levelProgrammeData: ProgrammeMedians
mode: 'faculty' | 'programme' | 'study track'
names: Record<string, Name | NameWithCode> | Record<string, string | Name> | undefined
programmeDataVisible: boolean
year: number | null
yearLabel: 'Graduation year' | 'Start year'
}) => {
if (!allowExpand) {
return (
<Box>
<GraduationBreakdown
cypress={`${level}-breakdown-bar-chart`}
data={data}
mode={mode}
fullWidth
/>
</Box>
)
}
return (
<Box>
<Typography>Click a bar to view that year's {mode} level breakdown</Typography>
<Stack direction={{ sm: 'column', md: 'row' }}>
<GraduationBreakdown
cypress={`${level}-breakdown-bar-chart`}
data={data}
handleClick={handleClick}
mode={mode}
yearLabel={yearLabel}
/>
{programmeDataVisible && expandKey && levelProgrammeData && expandKey in levelProgrammeData ? (
<GraduationBreakdown
<BreakdownBarChart cypress={`${level}-breakdown-bar-chart`} data={data} handleClick={handleClick} mode={mode} />
{programmeDataVisible && year && year in levelProgrammeData ? (
<BreakdownBarChart
cypress={`${level}BreakdownBarChartFaculty`}
data={levelProgrammeData[expandKey].data}
data={levelProgrammeData[year].data}
facultyGraph={false}
handleClick={handleClick}
mode={mode}
names={names}
expandKey={expandKey}
year={year}
yearLabel={yearLabel}
/>
) : null}

Some files were not shown because too many files have changed in this diff Show More