Compare commits

...
120 Commits
Author SHA1 Message Date
joonas-a 3d0fd3563f Delete highcharts 2026-05-15 17:49:11 +03:00
joonas-a a929613e83 [Course stats] enable data view on chart + cleanup 2026-05-15 17:48:36 +03:00
joonas-a d64aca5285 [Course stats] convert grade dist to echarts 2026-05-15 17:46:38 +03:00
joonas-a 9e1307595c [Course stats] convert pass rate chart to echarts 2026-05-15 17:37:15 +03:00
joonas-a 0e9ba8490d Delete old files 2026-05-15 17:17:47 +03:00
joonas-a 50b4166a7f [Charts] cleanup 2026-05-15 17:01:03 +03:00
joonas-a 3843937429 [Graduation times chart] more refactor 2026-05-15 15:55:42 +03:00
joonas-a b60b1dd88b [Graduation times chart] refactor 2026-05-15 14:56:49 +03:00
joonas-a ed513c14bf [Error background] put veterans to rest 2026-05-12 16:23:38 +03:00
joonas-a 8e2b8e5443 [University][Backend] fix graduation targets
from months to semesters like frontend expects
2026-05-12 15:39:58 +03:00
joonas-a bb215bb785 [Charts] bring back missing legends oops 2026-05-12 15:38:27 +03:00
joonas-a 635fb97b25 [Charts] final tweaks 2026-05-12 14:54:07 +03:00
joonas-a a5334c8e2f [Progres bar chart] Change emphasis strategy
fixes incorrect highlighting bug when clicking categories from the
legend
2026-05-12 14:25:18 +03:00
joonas-a 18b8daabff [Charts] convert horizontal stacked bar chart 2026-05-12 14:24:26 +03:00
joonas-a b181fda7fc [Charts] Fix styling issues 2026-05-12 13:27:23 +03:00
joonas-a 747c37697c [Median graduations] add guard for undefined data and fix style 2026-05-12 12:05:12 +03:00
joonas-a 76d8fef4eb [Dist devlpmnt] Update infobox 2026-05-12 11:51:06 +03:00
joonas-a 743ccb6797 [Credit accumulation] infobox update 2026-05-11 18:00:13 +03:00
joonas-a dab81f994d [Charts][Crdt accmltn] bring back click to open individual student 2026-05-11 16:51:42 +03:00
joonas-a b17fa2a11d [Charts][Graduation times] Convert breakdown chart 2026-05-11 15:25:09 +03:00
joonas-a ea8a84420f [Charts][Graduation times] Invert order to match previous behaviour 2026-05-11 15:24:43 +03:00
joonas-a 56d98e16cd [Charts][Graduation times] convert median chart 2026-05-11 13:56:52 +03:00
joonas-a b9b90876f1 [Charts][Year expansion chart] first attempt at echarts conversion 2026-04-29 16:08:35 +03:00
joonas-a 777a19b291 [Charts][LineGraph] Add emphasis strategy
Will make it so when hovering only one item blurs all the others
2026-04-29 11:13:15 +03:00
joonas-a 362930c481 [Student tab] change credit acc graph dot icon 2026-04-28 11:42:52 +03:00
joonas-a 8ca1636517 [Charts][Progress of students] convert to echarts 2026-04-27 17:22:29 +03:00
joonas-a d6beafa13a [Charts][Graduated/thesis writers] convert graph to echart 2026-04-27 14:03:49 +03:00
joonas-a f92d72b77a [Charts][StackedBarChart] echart conversion 2026-04-27 13:42:46 +03:00
joonas-a dca144d58b [Charts] Replace students of x with echarts 2026-04-27 13:08:21 +03:00
joonas-a 9a9f723b46 [Charts][Distribution development] Migrate to echarts 2026-04-21 12:41:39 +03:00
joonas-a e6686c024a [Charts][Student details] final touches 2026-04-20 14:12:21 +03:00
joonas-a 2aa6e9c549 [Student grade graph] Move legend to right for consistency 2026-04-20 12:39:44 +03:00
joonas-a 898a2b1f1b [Charts][Student details] Renew grade graphs 2026-04-20 12:25:39 +03:00
joonas-a 6a9a49952b [Frontend] Further enhance StyledMessage 2026-04-20 12:02:49 +03:00
joonas-a 0f76e50e4b [Frontend] Enhance StyledMessage 2026-04-20 11:59:16 +03:00
joonas-a d9695901b2 [Charts][Student details] remake credit graph 2026-04-16 16:08:15 +03:00
joonas-a b85edaf6f0 [Charts][Custom pop] Use new credit acc
Also add graduations if programme set.
2026-04-16 14:30:53 +03:00
joonas-a b165ece770 [Lint] fix 2026-04-16 14:19:51 +03:00
joonas-a a90e82a31e [Charts][SGG] use new credit acc 2026-04-16 14:18:58 +03:00
joonas-a 651e37051d [Charts][Credit acc] fix combined programme graduation markers 2026-04-16 13:55:21 +03:00
joonas-a 77b6d1cb16 [Charts] cleanup and edge case handling 2026-04-16 12:48:30 +03:00
joonas-a afa6804d09 [Charts] Credit acc done for non bscmsc classes 2026-04-14 16:19:46 +03:00
joonas-a a57f2f01ca [Charts][Credit accmltn] hops filter functionality 2026-04-13 14:40:44 +03:00
joonas-a 1b4bdff37e [Charts][poc] credit accumulation 2026-04-02 16:14:04 +03:00
joonas-a 1fa10f7812 [Scripts] fix incorrect container name 2026-03-31 14:47:33 +03:00
Aaro Koivukangas fbe5db8a43 [E2E] Fix lint errors and e2e tests 2026-03-31 14:42:57 +03:00
Aaro Koivukangas d4c7a4014b [Class stats][Courses] Handle substitution groups correctly + fix
include substitutions button
2026-03-31 14:42:57 +03:00
Santeri Rajamäki 1177c2f848 [OT] Use 0th index in pagination when table data changes 2026-03-31 14:40:53 +03:00
Santeri Rajamäki 0f8d72fd1d [Frontend] Remove manual pagination from OT implementations 2026-03-31 14:36:45 +03:00
Santeri Rajamäki 47e3e2e26e [OT] Fix pagination 2026-03-31 14:36:05 +03:00
Santeri Rajamäki a312a14944 [Frontend] Disallow prop forwarding for all internal StyledTable attributes 2026-03-31 12:01:33 +03:00
joonas-a e3ec33f8dc [Course stat tables] try to add 0s 2026-03-30 16:10:22 +03:00
joonas-a 9c55d3b2ab [Highcharts] fix median time bar chart threshold lines 2026-03-30 16:07:51 +03:00
joonas-a 17ce83660c [e2e] programme overview graduation time fixes 2026-03-30 15:40:59 +03:00
joonas-a 22d3877b8e [Faculty][Graduation times] update graduation time targets in highcharts 2026-03-30 14:58:20 +03:00
joonas-a c60bee34fd [Backend][Faculty] update graduation time targets 2026-03-30 14:51:32 +03:00
joonas-a 15240050b7 [Faculty] graduation time infobox update 2026-03-30 14:31:18 +03:00
joonas-a 7da09c8b02 [Programme overview] update graduation time infoboxes 2026-03-30 14:31:18 +03:00
joonas-a cfb2d4d5c0 [Frontend][Graduation times] replace months with semesters 2026-03-30 14:31:18 +03:00
joonas-a 39b594d795 [Backend] Use semesters with graduation times 2026-03-30 14:31:18 +03:00
Santeri Rajamäki ff5bce1713 [E2E] Scroll coursestatistics data table into view 2026-03-30 10:26:56 +03:00
Santeri Rajamäki 8e288f3b26 [Frontend] Remove columnOrder from course statistics tables 2026-03-30 10:07:06 +03:00
Santeri Rajamäki 4134c8b64b [Frontend] Use manualPagination on course stats tables 2026-03-27 15:47:17 +02:00
Santeri Rajamäki ddc1a22492 [Frontend] Cleanup 2026-03-27 15:47:17 +02:00
Santeri Rajamäki 8ff1cb54dc [Frontend] Convert AttemptsTable to OT 2026-03-27 15:47:17 +02:00
Santeri Rajamäki 777de520f9 [Frontend] Convert StudentsTable to OT 2026-03-27 15:47:17 +02:00
joonas-a a9e325fe72 [Updater] Add transfer info to study rights 2026-03-26 17:41:17 +02:00
joonas-a befc3db3a8 [Filters][Studentnumber] Fix blacklist blacklisting all students always 2026-03-26 17:41:17 +02:00
Santeri Rajamäki e1b8ebd17f [Frontend] Readd EquivalenceGroupChip 2026-03-26 13:13:05 +02:00
Santeri Rajamäki 883129bee0 [Frontend] Unify course page secondary chip with course search table chips 2026-03-26 12:13:51 +02:00
Santeri Rajamäki 18eb04beeb [Frontend] Rework course search table and equivalence group chips 2026-03-26 12:12:19 +02:00
Santeri Rajamäki eb12d7e5ec [OT] Add cell width under size attribute 2026-03-26 12:08:56 +02:00
Santeri Rajamäki 26ca0a3bc1 [META] Remove redundant reset_jami_data script from setup script 2026-03-25 17:34:47 +02:00
Santeri Rajamäki b79c97780b [META] Update JAMI url in docker-compose 2026-03-25 17:29:04 +02:00
Santeri Rajamäki 0cd3bf28ff [Frontend] Replace MRT with StyledTable in StudyProgrammeTable 2026-03-25 16:20:55 +02:00
Santeri Rajamäki 20b0ba5a30 [Frontend] Enhance StyledTable 2026-03-25 16:19:12 +02:00
Aaro Koivukangas 67001a9db1 [Updater] Remove limit of 1000 entries from update-meta in dev environment 2026-03-23 14:15:51 +02:00
Aaro Koivukangas 8c268395b0 [E2E] Fix broken test 2026-03-20 16:32:41 +02:00
Aaro Koivukangas 235d2b03b2 [CourseStat][Search] Use substitution_groups when searching for courses 2026-03-20 15:53:11 +02:00
Aaro Koivukangas cbe20f1ad3 [Updater] Handle substitution_groups with null/undefined codes better 2026-03-20 15:53:11 +02:00
Matti Luukkainen 47211dcf89 [faculty/study programme] moar compact credits produced table (fits in screen) 2026-03-20 12:48:41 +02:00
joonas-a 26c6c653e5 [Custom pop] Enable hetu filter 2026-03-19 15:52:56 +02:00
Aaro Koivukangas 13bdf698cf [CI] Skip very flaky test 2026-03-19 15:29:25 +02:00
Aaro Koivukangas fd6a65925a [CI][DegreeProgrammes] Fix hetu-e2e tests once again 2026-03-19 15:03:14 +02:00
Aaro Koivukangas 435d2920fb [CI][DegreeProgramme] More e2e test fixes 2026-03-19 14:47:27 +02:00
Aaro Koivukangas 4ae93e0b7e [Updater] Add latest executed migration to starting logs 2026-03-19 14:45:18 +02:00
Aaro Koivukangas dc5200da2f [CI] Fix broken hetu-tests 2026-03-19 14:27:20 +02:00
joonas-a 373c8f9de8 [CI] remove debug print 2026-03-19 13:34:00 +02:00
joonas-a 34cc022830 [Custom pop][General tab] Fetch hops credits from acual hops
Also integrated CI test
2026-03-19 13:32:03 +02:00
Aaro Koivukangas ab311ddc66 [CI] Don't trigger all workflows if lint fails 2026-03-19 13:27:50 +02:00
Aaro Koivukangas 833fdad3bb [CI] Fix lint errors 2026-03-19 13:21:52 +02:00
joonas-a 4b634e032d [Eslint] Disable root imports
These cause silent backend crashes which are hard to debug
2026-03-19 13:18:41 +02:00
Aaro Koivukangas a16fd13413 [CI] Fix broken import in mailServices 2026-03-19 13:11:46 +02:00
Aaro Koivukangas 58913b4684 [Docker] Make .dockerignore work by moving it to context root 2026-03-19 12:58:55 +02:00
joonas-a 05c8993ba3 [Backend] Fix path import 2026-03-19 12:52:49 +02:00
joonas-a d3090ae725 [Ooditable] Highlight hovered row 2026-03-19 12:18:49 +02:00
joonas-a c870061fec [Excel exports] Show acual values in preview
No more empty strings
2026-03-19 12:00:49 +02:00
Aaro Koivukangas ec43699c3c [CI] Add backend and frontend logs to e2e tests 2026-03-19 11:57:56 +02:00
joonas-a 1b1e380a13 [Frontend] make lowercasing consistent 2026-03-17 17:21:14 +02:00
joonas-a 5c13b5c0ab [Programme overview][Faculties] hetu/ton credits produced 2026-03-17 17:18:02 +02:00
joonas-a f35fd41bfb [Backend] Include hetu/ton split in provider credits computation 2026-03-17 17:15:31 +02:00
joonas-a 6548cdd42b [Course population] Enable HETU filter 2026-03-17 15:16:30 +02:00
joonas-a 49b0da760d [Filters] New filter: Personal identity code 2026-03-17 15:16:30 +02:00
joonas-a 34e9cf5667 [Backend] Add "has hetu" to students 2026-03-17 15:16:30 +02:00
Aaro Koivukangas 288d32c591 [Backend][MailService] Backend doesn't crash if it tries to send emails in non-production environments 2026-03-17 13:16:04 +02:00
joonas-a 5001d55843 [Programme courses] Display hetuttomat/hetulliset 2026-03-17 12:25:41 +02:00
joonas-a eaedfe99db [Backend][Programme courses] Split open uni students (hetuton/hetu on) 2026-03-17 12:25:41 +02:00
Aaro Koivukangas 6cf375936a [Meta] Lint fixes 2026-03-17 12:02:12 +02:00
Aaro Koivukangas 8bc5c3ea65 [Updater][Substitutions] Add og. substitutions back and move the new substitutions to substitution_groups 2026-03-17 11:49:49 +02:00
Santeri RajamäkiandAaro Koivukangas 3e4e749625 [CoursesSearch] Rebuild substitution combination 2026-03-17 11:49:49 +02:00
Santeri RajamäkiandAaro Koivukangas 114af7522d [Backend] Try apply fix for courseFinder 2026-03-17 11:49:49 +02:00
Santeri RajamäkiandAaro Koivukangas c0b29d69c7 [Updater] Fix substitution .map() for modules 2026-03-17 11:49:49 +02:00
Santeri RajamäkiandAaro Koivukangas fb541ffa3a [Updater] Build course unit substitutions as substitution groups/arrays 2026-03-17 11:49:20 +02:00
Santeri RajamäkiandAaro Koivukangas 0ed4ce7bb4 [Updater] Clarify updateCourses function 2026-03-17 11:49:20 +02:00
Santeri RajamäkiandAaro Koivukangas 87e1e332a8 [Frontend] Convert sentry util to typescript 2026-03-17 11:49:20 +02:00
joonas-a 914440cef9 [Programme courses] Enable stripes in table 2026-03-17 10:52:06 +02:00
Aaro Koivukangas 901a4de21c [Updater] Allow updater workflow to trigger 2026-03-12 13:35:45 +02:00
Aaro Koivukangas 2bc28c7dd3 [Student] Convert BachelorHonors to ts 2026-03-12 13:00:57 +02:00
Aaro Koivukangas 2dd43b1ccd [StudyGuidanceGroups] Typo fix 2026-03-12 10:51:59 +02:00
Aaro Koivukangas 3fc1bc161b [Models] Add substitution_groups to course table in sis-db 2026-03-11 18:54:38 +02:00
133 changed files with 4397 additions and 3332 deletions
+1 -1
View File
@@ -47,4 +47,4 @@ runs:
- name: 'Print logs on failure'
shell: bash
if: failure()
run: docker compose --file docker-compose.ci.yml logs
run: docker compose --file docker-compose.ci.yml --profile oodikone logs
+1 -1
View File
@@ -182,7 +182,7 @@ jobs:
- build_and_push_anon_kone_db
- build_and_push_anon_user_db
- lint_packages
if: always() && !cancelled()
if: always() && !cancelled() && needs.lint_packages.result == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
-2
View File
@@ -115,7 +115,6 @@ jobs:
name: 'Build and release Updater worker'
runs-on: ubuntu-latest
needs: test_updater
if: github.event_name == 'push'
steps:
- uses: actions/checkout@v6
@@ -145,7 +144,6 @@ jobs:
name: 'Build and release Updater scheduler'
runs-on: ubuntu-latest
needs: test_updater
if: github.event_name == 'push'
steps:
- uses: actions/checkout@v6
+1 -1
View File
@@ -33,6 +33,7 @@ const checkGradeTable = gradesTableContents => {
}
const checkTableContents = contents => {
cy.get('table tbody').scrollIntoView()
cy.get('table tbody').within(() => {
contents.forEach((values, trIndex) => {
cy.get('tr')
@@ -396,7 +397,6 @@ describe('Basic user', () => {
cy.contains('Search for courses')
searchByCourseCode('TKT')
cy.cs('select-multiple-courses-toggle').should('not.have.class', 'Mui-checked')
cy.contains('TKT10004, BSCS2001, 581328, A581328, AYTKT10004')
cy.contains('Select all search results').should('not.exist')
cy.cs('select-multiple-courses-toggle').click()
cy.cs('select-multiple-courses-toggle').should('have.class', 'Mui-checked')
+1 -1
View File
@@ -12,7 +12,7 @@ const save = () => {
}
const selectSavedPopulation = name => {
cy.cs('history-search').click().type(`${name}{enter}`)
cy.cs('history-search').type(`${name}{enter}`)
}
const deleteAllSearches = () => {
+33 -31
View File
@@ -93,14 +93,14 @@ describe('Degree programme overview', () => {
it('Basic information contains correct credits', () => {
const years = getEmptyYears()
const tableContents = [
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0]),
[2023, 1519, 1519, 0, 0, 0, 0, 222, 0],
[2022, 3235, 3205, 0, 30, 0, 0, 209, 0],
[2021, 5133, 5108, 0, 25, 0, 0, 428, 25],
[2020, 5801, 5796, 0, 5, 0, 0, 94, 0],
[2019, 5305, 5305, 0, 0, 0, 0, 162, 0],
[2018, 3442, 3432, 0, 10, 0, 0, 21, 0],
[2017, 1211, 1211, 0, 0, 0, 0, 189, 0],
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
[2023, 1519, 1519, 0, 0, 0, 0, 0, 222, 0],
[2022, 3235, 3205, 0, 0, 30, 0, 0, 209, 0],
[2021, 5133, 5108, 0, 0, 25, 0, 0, 428, 25],
[2020, 5801, 5796, 0, 0, 5, 0, 0, 94, 0],
[2019, 5305, 5305, 0, 0, 0, 0, 0, 162, 0],
[2018, 3442, 3432, 0, 0, 10, 0, 0, 21, 0],
[2017, 1211, 1211, 0, 0, 0, 0, 0, 189, 0],
]
cy.checkTableStats(tableContents, 'credits-produced-by-the-study-programme')
@@ -173,14 +173,14 @@ describe('Degree programme overview', () => {
cy.checkTableStats(studentTableContents, 'students-of-the-study-programme')
const creditTableContents = [
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0]),
['2023 - 2024', 160, 160, 0, 0, 0, 0, 67, 0],
['2022 - 2023', 2725, 2720, 0, 5, 0, 0, 337, 0],
['2021 - 2022', 4092, 4042, 0, 50, 0, 0, 198, 0],
['2020 - 2021', 5420, 5415, 0, 5, 0, 0, 321, 25],
['2019 - 2020', 6043, 6043, 0, 0, 0, 0, 101, 0],
['2018 - 2019', 4856, 4851, 0, 5, 0, 0, 107, 0],
['2017 - 2018', 2350, 2345, 0, 5, 0, 0, 26, 0],
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
['2023 - 2024', 160, 160, 0, 0, 0, 0, 0, 67, 0],
['2022 - 2023', 2725, 2720, 0, 0, 5, 0, 0, 337, 0],
['2021 - 2022', 4092, 4042, 0, 0, 50, 0, 0, 198, 0],
['2020 - 2021', 5420, 5415, 0, 0, 5, 0, 0, 321, 25],
['2019 - 2020', 6043, 6043, 0, 0, 0, 0, 0, 101, 0],
['2018 - 2019', 4856, 4851, 0, 0, 5, 0, 0, 107, 0],
['2017 - 2018', 2350, 2345, 0, 0, 5, 0, 0, 26, 0],
]
cy.checkTableStats(creditTableContents, 'credits-produced-by-the-study-programme')
@@ -214,9 +214,9 @@ describe('Degree programme overview', () => {
cy.contains('2022')
cy.contains('48 graduated').trigger('mouseover')
cy.contains('48 students graduated in year 2022')
cy.contains('median study time: 36 months')
cy.contains('27 graduated on time')
cy.contains('9 graduated max year overtime')
cy.contains('median study time: 6 semesters')
cy.contains('26 graduated on time')
cy.contains('10 graduated max year overtime')
cy.contains('12 graduated over year late')
})
@@ -245,7 +245,7 @@ describe('Degree programme overview', () => {
cy.contains('2021')
cy.contains('2 graduated').trigger('mouseover')
cy.contains('2 students graduated in year 2021')
cy.contains('median study time: 25 months')
cy.contains('median study time: 4.5 semesters')
cy.contains('1 graduated on time')
cy.contains('1 graduated max year overtime')
cy.contains('0 graduated over year late')
@@ -257,7 +257,7 @@ describe('Degree programme overview', () => {
cy.contains('2023')
cy.contains('11 graduated').trigger('mouseover')
cy.contains('11 students graduated in year 2023')
cy.contains('median study time: 67 months')
cy.contains('median study time: 12 semesters')
cy.contains('4 graduated on time')
cy.contains('6 graduated max year overtime')
cy.contains('1 graduated over year late')
@@ -415,12 +415,12 @@ describe('Degree programme overview', () => {
cy.get('[aria-label="2020 - 2021, 16. On time."]').trigger('mouseover')
cy.contains('On time: 16')
cy.contains("Click a bar to view that year's study track level breakdown")
cy.get('[aria-label="2019 - 2020, 23. On time."]').click()
cy.get('[aria-label="2019 - 2020, 22. On time."]').click()
cy.contains('Year 2019 - 2020 by start year')
cy.get('[aria-label="MAT-MAT, 14. On time."]').trigger('mouseover')
cy.get('[aria-label="MAT-MAT, 13. On time."]').trigger('mouseover')
cy.contains('Matematiikka')
cy.contains('MAT-MAT')
cy.contains('On time: 14')
cy.contains('On time: 13')
})
cy.cs('graduation-time-toggle').click()
@@ -429,7 +429,7 @@ describe('Degree programme overview', () => {
cy.contains('2020 - 2021')
cy.contains('19 graduated').trigger('mouseover')
cy.contains('From class of 2020 - 2021, 19/30 students have graduated')
cy.contains('median study time: 33 months')
cy.contains('median study time: 6 semesters')
cy.contains('16 graduated on time')
cy.contains('2 graduated max year overtime')
cy.contains('1 graduated over year late')
@@ -636,10 +636,11 @@ describe('Degree programme overview', () => {
cy.get('thead tr')
.eq(1)
.within(() => {
cy.get('th').should('have.length', 6)
cy.get('th').should('have.length', 7)
const headers = [
'Degree students',
'Open university',
'Open university (hetu)',
'Open university (no hetu)',
'Exchange students',
'Other universities',
'Separate studies',
@@ -660,7 +661,7 @@ describe('Degree programme overview', () => {
'Type',
'Code',
'Name',
'Total',
'Total students',
'Breakdown of total',
'Breakdown of passed',
'Not included in total or passed',
@@ -672,12 +673,13 @@ describe('Degree programme overview', () => {
cy.get('thead tr')
.eq(1)
.within(() => {
cy.get('th').should('have.length', 9)
cy.get('th').should('have.length', 10)
const headers = [
'Passed',
'Not completed',
'Degree students',
'Open uni students',
'Open university students (with hetu)',
'Open university students (without hetu)',
'Exchange students',
'Other university students',
'Separate studies',
@@ -708,7 +710,7 @@ describe('Degree programme overview', () => {
})
// FIXME: Flaky
it('test', () => {
it.skip('test', () => {
// TODO: Implement some real tests for this tab
cy.contains('From')
})
+1 -1
View File
@@ -383,7 +383,7 @@ describe('Population statistics tests', () => {
cy.contains(nonExisting).should('not.exist')
cy.contains('button', 'Check student numbers').click()
cy.contains('Check for student numbers')
cy.cs('check-student-numbers').click().type(`${existing}{enter}${nonExisting}`)
cy.cs('check-student-numbers').type(`${existing}{enter}${nonExisting}`)
cy.contains('button', 'Check students').click()
cy.get('#checkstudentsresults').within(() => {
cy.cs('found-title').click()
+1 -1
View File
@@ -170,7 +170,7 @@ services:
- npm_config_cache=/tmp
- PORT=3003
- NODE_ENV=dev
- DATABASE_URL=postgres://postgres:postgres@jami-db:5432/postgres
- DATABASE_URL=postgres://postgres:postgres@jami-db:5432/jami-db-real
ports:
- 3003:3003
container_name: jami
+8 -1
View File
@@ -75,7 +75,14 @@ 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', { 'patterns': [{ 'regex': '^@mui/[^/]+$' }] }],
'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 }
],
+3 -2
View File
@@ -28,9 +28,10 @@
"prepare": "husky",
"rapodiff": "docker exec -t oodikone-backend /opt/app-root/backend/node_modules/.bin/tsx /opt/app-root/backend/src/rapodiff/rapodiff.js",
"install:local": "npm i && npm i --prefix services/shared && npm i --prefix services/frontend && npm i --prefix services/backend && npm i --prefix updater/sis-updater-scheduler && npm i --prefix updater/sis-updater-worker",
"i:both": "npm run install:both",
"ci:local": "npm ci && npm ci --prefix services/shared && npm ci --prefix services/frontend && npm ci --prefix services/backend && npm ci --prefix updater/sis-updater-scheduler && npm ci --prefix updater/sis-updater-worker",
"install:docker": "docker exec frontend npm i && docker exec backend npm i && docker exec sis-updater-scheduler npm i && docker exec oodikone-sis-updater-worker npm i",
"ci:docker": "docker exec frontend npm ci && docker exec backend npm ci && docker exec sis-updater-scheduler npm ci && docker exec oodikone-sis-updater-worker npm ci",
"install:docker": "docker exec frontend npm i && docker exec backend npm i && docker exec sis-updater-scheduler npm i && docker exec sis-updater-worker npm i",
"ci:docker": "docker exec frontend npm ci && docker exec backend npm ci && docker exec sis-updater-scheduler npm ci && docker exec sis-updater-worker npm ci",
"install:both": "npm run install:local && npm run install:docker",
"ci:both": "npm run ci:local && npm run ci:docker"
},
-24
View File
@@ -11,7 +11,6 @@ PROJECT_ROOT="$(git rev-parse --show-toplevel)"
DUMP_DIR="$PROJECT_ROOT/.databasedumps"
S3_CONFIG_FILE=~/.s3cfg
S3_BUCKET="s3://psyduck"
DOCKER_COMPOSE=$PROJECT_ROOT/docker-compose.yml
## Following the naming convention in docker-compose, these are names for database
## services. The real data databases inside service will have names suffixed by "-real".
@@ -139,29 +138,6 @@ reset_databases() {
successmsg "Database setup finished"
}
reset_jami_data() {
local database="jami-db"
local dump_destination="$DUMP_DIR/$database.sql.gz"
local s3_path="jami"
infomsg "Removing old data"
infomsg "Removing database and related volume"
docker volume rm oodikone-jami-data || warningmsg "This is okay, continuing"
docker-compose -f "$DOCKER_COMPOSE" down "$database" || warningmsg "This is okay, continuing"
infomsg "Starting postgres in the background"
docker-compose -f "$DOCKER_COMPOSE" up -d "$database"
retry docker-compose -f "$DOCKER_COMPOSE" exec "$database" pg_isready --dbname="postgres"
infomsg "Populating Jami"
docker exec -i "$database" /bin/bash -c "gunzip | psql -U postgres" < "$dump_destination" 2> /dev/null
docker-compose stop "$database"
msg ""
}
reset_all_real_data() {
infomsg "Downloading real data dumps"
for i in ${!DATABASES[*]}; do
+7
View File
@@ -0,0 +1,7 @@
*/.dockerignore
*/.eslintrc.json
*/.gitignore
*/*Dockerfile
*/build
*/dist
*/node_modules
-3
View File
@@ -1,3 +0,0 @@
.dockerignore
*Dockerfile
node_modules/
@@ -71,6 +71,9 @@ export class SISStudyRightModel extends Model implements SISStudyRight {
@Column(DataType.JSONB)
declare semesterEnrollments: SISStudyRight['semesterEnrollments']
@Column(DataType.JSONB)
declare transferInfo: SISStudyRight['transferInfo']
@HasMany(() => CreditModel, { foreignKey: 'studyright_id', sourceKey: 'id' })
declare credits: SISStudyRight['credits']
+3
View File
@@ -73,6 +73,9 @@ export class CourseModel extends Model implements Course {
@Column(DataType.JSONB)
declare substitutions: Course['substitutions']
@Column(DataType.JSONB)
declare substitution_groups: Course['substitution_groups']
@Column(DataType.STRING)
declare course_unit_type: Course['course_unit_type']
+11 -31
View File
@@ -10,9 +10,8 @@ import {
CourseYearlyStatsQuery,
} from '@oodikone/shared/routes/courses'
import type { CourseWithSubsId } from '@oodikone/shared/types/course'
import { uniq } from '@oodikone/shared/util'
import { getCourseYearlyStats } from '../services/courses'
import { getCoursesByNameAndOrCode, getCoursesByCodes } from '../services/courses/courseFinders'
import { getCoursesByNameAndOrCode } from '../services/courses/courseFinders'
import { getFullStudyProgrammeRights, hasFullAccessToStudentData, validateParamLength } from '../util'
const router = Router()
@@ -20,49 +19,30 @@ const router = Router()
router.get<never, CanError<CoursesMultiResBody>, CoursesMultiReqBody, CoursesMultiQuery>(
'/v2/coursesmulti',
async (req, res) => {
const { name, code, combineSubstitutions, includeSpecial } = req.query
const { name, code, includeSpecial } = req.query
if (!(validateParamLength(name, 5) || validateParamLength(code, 2))) {
return res.status(400).json({ error: 'Query parameter name or code is invalid' })
}
const courses = await getCoursesByNameAndOrCode(name, code, includeSpecial === 'true')
if (combineSubstitutions === 'false') {
const courseCodes = courses.map(course => course.code)
const substitutions = uniq(
courses.flatMap(course => course.substitutions).filter(code => !courseCodes.includes(code))
)
const substitutionDetails = await getCoursesByCodes(substitutions)
for (const substitution of substitutionDetails) {
courses.push(substitution.toJSON())
}
}
const mergedCourses: Record<string, CourseWithSubsId> = {}
const mergedCourses = new Map<string, CourseWithSubsId>()
for (const course of courses) {
if (!(course.max_attainment_date && course.min_attainment_date)) continue
const groupId = combineSubstitutions === 'true' ? course.subsId?.toString() : course.code
if (!groupId) continue
if (!mergedCourses.has(course.code)) mergedCourses.set(course.code, structuredClone(course))
const mergedCourse = mergedCourses.get(course.code)!
if (!mergedCourses[groupId]) {
mergedCourses[groupId] = {
...course,
substitutions: combineSubstitutions === 'true' ? course.substitutions : [],
}
} else {
const mergedCourse = mergedCourses[groupId]
if (mergedCourse.max_attainment_date < course.max_attainment_date) {
mergedCourse.max_attainment_date = course.max_attainment_date
}
if (mergedCourse.min_attainment_date > course.min_attainment_date) {
mergedCourse.min_attainment_date = course.min_attainment_date
}
if (mergedCourse.max_attainment_date < course.max_attainment_date) {
mergedCourse.max_attainment_date = course.max_attainment_date
}
if (mergedCourse.min_attainment_date > course.min_attainment_date) {
mergedCourse.min_attainment_date = course.min_attainment_date
}
}
res.json({ courses: Object.values(mergedCourses) })
res.json({ courses: Array.from(mergedCourses.values()) })
}
)
+7 -1
View File
@@ -14,6 +14,7 @@ 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'
@@ -137,7 +138,12 @@ router.get('/allgraduationstats', async (_req: Request, res: Response) => {
}
const universityData = {
goals: { bachelor: 36, bcMsCombo: 60, master: 24, doctor: 48 },
goals: {
bachelor: GraduationTarget.THREE_YEARS,
bcMsCombo: GraduationTarget.FIVE_YEARS,
master: GraduationTarget.TWO_YEARS,
doctor: GraduationTarget.FOUR_YEARS,
},
programmeNames: getProgrammeNames(allFaculties),
byGradYear: {
medians: {} as Record<string, LevelGraduationStats[]>,
@@ -47,18 +47,19 @@ const getRawCourses = async (name: string, code: string, includeSpecial: boolean
...nameLikeTerm(name),
...codeLikeTerm(code),
},
raw: true,
})
const getCourses = async (name: string, code: string, includeSpecial: boolean) => {
const rawCourses = await getRawCourses(name, code, includeSpecial)
return rawCourses
.map(course => ({
...course.toJSON(),
...course,
substitutions: orderBy(course.substitutions, [
substitution => {
if (/^A/.exec(substitution)) return 4 // open university codes come last
if (/^\d/.exec(substitution)) return 2 // old numeric codes come second
if (/^[A-Za-z]/.exec(substitution)) return 1 // new letter based codes come first
if (/^A/.exec(substitution.at(0) ?? '')) return 4 // open university codes come last
if (/^\d/.exec(substitution.at(0) ?? '')) return 2 // old numeric codes come second
if (/^[A-Za-z]/.exec(substitution.at(0) ?? '')) return 1 // new letter based codes come first
return 3 // unknown, comes before open uni?
},
]),
@@ -3,6 +3,7 @@ import { cloneDeep, omit } from 'lodash-es'
import { Name, DegreeProgrammeType, Unarray } from '@oodikone/shared/types'
import { ProgrammeGraduationStats } from '@oodikone/shared/types/studyProgramme'
import { getGraduationStats, getStudyTrackStats, setGraduationStats, setStudyTrackStats } from '../analyticsService'
import { GraduationTarget } from '../graduationHelpers'
import { getGraduationStatsForStudyTrack, GraduationTimes } from '../studyProgramme/studyProgrammeGraduations'
import { getMedian } from '../studyProgramme/studyProgrammeHelpers'
import { getStudyRightsInProgramme } from '../studyProgramme/studyRightFinders'
@@ -253,21 +254,22 @@ export const countGraduationTimes = async (faculty: string, programmesOfFaculty:
} = await getStatsByStartYear(programmesOfFaculty)
const goals = {
bachelor: 36,
bcMsCombo: faculty === 'H90' ? 72 : 60,
master: 24,
doctor: 48,
bachelor: GraduationTarget.THREE_YEARS,
bcMsCombo: faculty === 'H90' ? GraduationTarget.SIX_YEARS : GraduationTarget.FIVE_YEARS,
master: GraduationTarget.TWO_YEARS,
doctor: GraduationTarget.FOUR_YEARS,
exceptions: {
MH30_004: 6, // months more
'420420-ma': 6,
'420074-ma': 6,
'420119-ma': 6,
MH30_001: 48,
'320011-ma': 48,
'320001-ma': 48,
MH30_003: 42,
'320002-ma': 42,
'320009-ma': 42,
// Exceptions are additions to the base amount
MH30_004: GraduationTarget.HALF_YEAR,
'420420-ma': GraduationTarget.HALF_YEAR,
'420074-ma': GraduationTarget.HALF_YEAR,
'420119-ma': GraduationTarget.HALF_YEAR,
MH30_001: GraduationTarget.FOUR_YEARS,
'320011-ma': GraduationTarget.FOUR_YEARS,
'320001-ma': GraduationTarget.FOUR_YEARS,
MH30_003: GraduationTarget.THREE_POINT_FIVE_YEARS,
'320002-ma': GraduationTarget.THREE_POINT_FIVE_YEARS,
'320009-ma': GraduationTarget.THREE_POINT_FIVE_YEARS,
} as Record<string, number>,
}
@@ -1,13 +1,27 @@
export const countTimeCategories = (times: number[], goal: number) => {
/** Map years to semesters */
export const GraduationTarget = {
HALF_YEAR: 1,
ONE_YEAR: 2,
TWO_YEARS: 4,
THREE_YEARS: 6,
THREE_POINT_FIVE_YEARS: 7,
FOUR_YEARS: 8,
FIVE_YEARS: 10,
SIX_YEARS: 12,
} as const
/** Uses semesters, not months */
export const countTimeCategories = (times: number[], target: number) => {
const statistics = { onTime: 0, yearOver: 0, wayOver: 0 }
times.forEach(time => {
if (time <= goal) {
statistics.onTime += 1
} else if (time <= goal + 12) {
statistics.yearOver += 1
for (const time of times) {
if (time <= target) {
statistics.onTime++
} else if (time <= target + GraduationTarget.ONE_YEAR) {
statistics.yearOver++
} else {
statistics.wayOver += 1
statistics.wayOver++
}
})
}
return statistics
}
+3 -3
View File
@@ -2,7 +2,7 @@ import axios from 'axios'
import { isProduction, pateToken } from '../config'
import { FormattedUser } from '../types'
import { ApplicationError } from '../util/customErrors'
import logger from '../util/logger'
const pateClient = axios.create({
baseURL: 'https://api-toska.apps.ocp-prod-0.k8s.it.helsinki.fi/pate/',
@@ -22,8 +22,8 @@ const baseSettings = {
}
const sendEmail = async (options = {}) => {
if (!isProduction) throw new ApplicationError('Email sending is disabled in development mode.')
if (!pateToken) throw new ApplicationError('Email sending failed because pate token is missing.')
if (!isProduction) return logger.error('Email sending is disabled in development mode.')
if (!pateToken) return logger.error('Email sending failed because pate token is missing.')
return await pateClient.post('/', options)
}
@@ -69,6 +69,7 @@ export const formatStudentForAPI = (
sis_person_id: student.sis_person_id,
citizenships: student.citizenships,
curriculumVersion: getCurriculumVersion(hops?.curriculum_period_id),
hasPersonalIdentityCode: student.hasPersonalIdentityCode,
tags,
transferredStudyright,
@@ -79,6 +79,7 @@ type StudentPersonalData = Pick<
| 'gender_code'
| 'birthdate'
| 'sis_person_id'
| 'hasPersonalIdentityCode'
>
type StudentStudyRightElement = Pick<
@@ -125,6 +126,7 @@ export const getStudents = (studentNumbers: string[]): Promise<Array<StudentData
'gender_code',
'birthdate',
'sis_person_id',
'hasPersonalIdentityCode',
],
include: [
{
@@ -78,7 +78,7 @@ export const getOptionsForStudents = (
export const getCourses = (courses: string[]): Promise<Array<CourseStats>> =>
CourseModel.findAll({
attributes: ['code', 'name', 'substitutions', 'is_study_module'],
attributes: ['code', 'name', 'substitutions', 'substitution_groups', 'is_study_module'],
where: {
code: { [Op.in]: courses },
},
@@ -5,6 +5,7 @@ import { mapToProviders } from '@oodikone/shared/util'
import { getCreditStats, setCreditStats } from './analyticsService'
import { getCourseCodesOfProvider } from './providers'
import { getCreditsForProvider, getTransferredCredits } from './studyProgramme/creditGetters'
import { getStudentHetuStateMap } from './studyProgramme/studentGetters'
import { defineYear } from './studyProgramme/studyProgrammeHelpers'
import { getSISStudyRightsOfStudents } from './studyProgramme/studyRightFinders'
@@ -47,13 +48,17 @@ const basicDegreeExtentCodes = [ExtentCode.BACHELOR, ExtentCode.MASTER, ExtentCo
This is probably very rare to cause differences, only one such was found in MH60_001
*/
const getCategory = (extentCode?: ExtentCode, degreeStudyRightExtentCode?: ExtentCode) => {
const getCategory = (hasHetu: boolean, extentCode?: ExtentCode, degreeStudyRightExtentCode?: ExtentCode) => {
if (degreeStudyRightExtentCode && basicDegreeExtentCodes.includes(degreeStudyRightExtentCode)) {
return 'basic'
}
switch (extentCode) {
case ExtentCode.OPEN_UNIVERSITY_STUDIES:
return 'open-uni'
if (hasHetu) {
return 'open-uni-hetu'
} else {
return 'open-uni-no-hetu'
}
case ExtentCode.MASTER:
case ExtentCode.BACHELOR_AND_MASTER:
case ExtentCode.BACHELOR:
@@ -121,38 +126,43 @@ export const computeCreditsProduced = async (providerCode: string, isAcademicYea
const students = [...new Set(credits.map(({ studentNumber }) => studentNumber))]
const studyRights = await getSISStudyRightsOfStudents(students)
const [studyRights, studentToHetuStateMap] = await Promise.all([
getSISStudyRightsOfStudents(students),
getStudentHetuStateMap(students),
])
const studyRightIdToStudyRightMap = studyRights.reduce<Record<string, (typeof studyRights)[number]>>((obj, cur) => {
obj[cur.id] = cur
return obj
}, {})
const studyRightIdToStudyRightMap = new Map(studyRights.map(sr => [sr.id, sr]))
const studentNumberToStudyRightsMap = studyRights.reduce<Record<string, typeof studyRights>>((obj, cur) => {
obj[cur.studentNumber] ??= []
obj[cur.studentNumber].push(cur)
return obj
}, {})
const studentNumberToStudyRightsMap = studyRights.reduce((map, cur) => {
// Node does not support getOrInsert as of writing :-(
if (map.has(cur.studentNumber)) {
map.get(cur.studentNumber)!.push(cur)
} else {
map.set(cur.studentNumber, [cur])
}
return map
}, new Map<string, [(typeof studyRights)[number]]>())
const stats: Record<string, Record<string, number>> = {}
for (const { attainmentDate, studentNumber, credits: numOfCredits, studyrightId, semestercode } of credits) {
const studyRightLinkedToAttainment = studyRightIdToStudyRightMap[studyrightId]
const studyRightLinkedToAttainment = studyRightIdToStudyRightMap.get(studyrightId)
const attainmentYear = defineYear(attainmentDate, isAcademicYear)
const degreeStudyRight = getBasicDegreeStudyRight(
studentNumberToStudyRightsMap[studentNumber],
studentNumberToStudyRightsMap.get(studentNumber),
attainmentDate,
semestercode
)
const hasHetu = !!studentToHetuStateMap.get(studentNumber)
if (!studyRightLinkedToAttainment && !degreeStudyRight) {
continue
}
const category = getCategory(studyRightLinkedToAttainment?.extentCode, degreeStudyRight?.extentCode)
const category = getCategory(hasHetu, studyRightLinkedToAttainment?.extentCode, degreeStudyRight?.extentCode)
stats[attainmentYear] ??= {}
stats[attainmentYear][category] ??= 0
stats[attainmentYear][category] += numOfCredits || 0
stats[attainmentYear][category] += numOfCredits
}
const transferredCredits = await getTransferredCredits(rapoFormattedProviderCode, since)
@@ -164,7 +174,7 @@ export const computeCreditsProduced = async (providerCode: string, isAcademicYea
const attainmentYear = defineYear(createdate, isAcademicYear)
stats[attainmentYear] ??= {}
stats[attainmentYear].transferred ??= 0
stats[attainmentYear].transferred += credits || 0
stats[attainmentYear].transferred += credits
})
return { stats, id: providerCode }
@@ -1,7 +1,8 @@
import { QueryTypes } from 'sequelize'
import { Op, QueryTypes } from 'sequelize'
import { CreditTypeCode, ExtentCode, Name } from '@oodikone/shared/types'
import { dbConnections } from '../../database/connection'
import { StudentModel } from '../../models'
import logger from '../../util/logger'
// Categories here are as per Linjaukset.md in internal docs.
@@ -235,3 +236,26 @@ export const getTransferCourseAggregates = async (params: {
return []
}
}
export const getStudentHetuStateMap = async (studentNumbers: string[]): Promise<Map<string, boolean>> => {
if (!studentNumbers.length) return new Map()
try {
return new Map(
(
await StudentModel.findAll({
attributes: ['studentnumber', 'hasPersonalIdentityCode'],
where: {
studentnumber: {
[Op.in]: studentNumbers,
},
},
raw: true,
})
).map(student => [student.studentnumber, student.hasPersonalIdentityCode])
)
} catch (error) {
logger.error('getOpenUniHetutonSplit failed', { error })
return new Map()
}
}
@@ -5,7 +5,7 @@ import { mapToProviders } from '@oodikone/shared/util'
import { createEmptyStats, YearStats } from '@oodikone/shared/util/studyProgramme'
import { getAllProgrammeCourses, getCurrentStudyYearStartDate, getNotCompletedForProgrammeCourses } from '.'
import { getProgrammeCourseAggregates, getTransferCourseAggregates } from './studentGetters'
import { getStudentHetuStateMap, getProgrammeCourseAggregates, getTransferCourseAggregates } from './studentGetters'
const START_YEAR = 2017
const JULY = 6
@@ -52,7 +52,8 @@ type YearAccumulator = {
separateStudents: Set<string>
otherStudents: Set<string>
openStudents: Set<string>
openStudentsHasHetu: Set<string>
openStudentsNoHetu: Set<string>
transferStudents: Set<string>
}
@@ -72,7 +73,8 @@ const createYearAccumulator = (isStudyModule: boolean): YearAccumulator => ({
separateStudents: new Set(),
otherStudents: new Set(),
openStudents: new Set(),
openStudentsHasHetu: new Set(),
openStudentsNoHetu: new Set(),
transferStudents: new Set(),
})
@@ -117,7 +119,8 @@ const finalizeYearStats = (yearAccumulator: YearAccumulator): YearStats => {
stats.separateStudents = yearAccumulator.separateStudents.size
stats.otherStudents = yearAccumulator.otherStudents.size
stats.openStudents = yearAccumulator.openStudents.size
stats.openStudentsHasHetu = yearAccumulator.openStudentsHasHetu.size
stats.openStudentsNoHetu = yearAccumulator.openStudentsNoHetu.size
stats.transferStudents = yearAccumulator.transferStudents.size
stats.allStudents = stats.allPassed + stats.allNotPassed
@@ -131,6 +134,9 @@ const getYearRange = async (unixMillis: number, isAcademicYear: boolean) => {
return range(START_YEAR, lastYear + 1)
}
/**
* @endpoint "programme courses"
*/
export const getStudyProgrammeCoursesForStudyTrack = async (
unixMillis: number,
studyProgramme: string,
@@ -169,6 +175,10 @@ export const getStudyProgrammeCoursesForStudyTrack = async (
}),
])
const openUniStudentHetuMap = await getStudentHetuStateMap(
programmeCredits.filter(c => c.variant === 'openUni').map(c => c.studentNumber)
)
const courseMap = new Map<string, CourseAccumulator>()
let maxYear = 0
@@ -208,8 +218,13 @@ export const getStudyProgrammeCoursesForStudyTrack = async (
stats.otherStudentsCredits += row.credits
break
case 'openUni':
yearAccumulator.openStudents.add(row.studentNumber)
stats.openStudentsCredits += row.credits
if (openUniStudentHetuMap.get(row.studentNumber)) {
stats.openStudentsHasHetuCredits += row.credits
yearAccumulator.openStudentsHasHetu.add(row.studentNumber)
} else {
stats.openStudentsNoHetuCredits += row.credits
yearAccumulator.openStudentsNoHetu.add(row.studentNumber)
}
break
default:
break
@@ -1,4 +1,5 @@
import dayjs from 'dayjs'
import dayjs, { Dayjs } from 'dayjs'
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
import { indexOf, orderBy } from 'lodash-es'
import {
@@ -10,12 +11,14 @@ import {
ExtentCode,
Phase,
EnrollmentType,
TransferInfo,
} from '@oodikone/shared/types'
import { ChartGraduationTimes } from '@oodikone/shared/types/graduations'
import { ProgrammeGraduationStats } from '@oodikone/shared/types/studyProgramme'
import { mapToProviders } from '@oodikone/shared/util'
import { getDegreeProgrammeType, getMinimumCreditsOfProgramme, sortByProgrammeCode } from '../../util'
import { countTimeCategories } from '../graduationHelpers'
import logger from '../../util/logger'
import { countTimeCategories, GraduationTarget } from '../graduationHelpers'
import { getSemestersAndYears } from '../semesters'
import { getStudyRightThesisCredits, getOrganizationThesisCredits } from './creditGetters'
import { getGraduatedStats } from './studyProgrammeBasics'
@@ -34,6 +37,8 @@ import {
} from './studyProgrammeHelpers'
import { getStudyRightsInProgramme } from './studyRightFinders'
dayjs.extend(isSameOrAfter)
/**
* For bachelor + master study right combo. This rules out master's programmes that don't
* follow the two-phase degree structure (e.g. degree programmes in medicine and dentistry)
@@ -47,19 +52,6 @@ export const shouldIncludeComboStats = async (studyProgramme: string) => {
return minimumCredits !== null && minimumCredits <= 180
}
const calculateAbsenceInMonths = (
absence: Awaited<ReturnType<typeof getSemestersAndYears>>['semesters'][string],
startDate: Date,
endDate: Date
) => {
if (endDate < absence.startdate || absence.enddate < startDate) {
return 0
}
// Without 'true' as the third argument, the result will be truncated, not rounded (e.g. 4.999 would be 4, not 5). This is why we use Math.round() instead
return Math.round(dayjs(absence.enddate).diff(absence.startdate, 'months', true))
}
/**
* Absence rules:
* 1. Student can have any number of statutory absences (lakisääteinen poissaolo)
@@ -67,29 +59,58 @@ const calculateAbsenceInMonths = (
* 2b. If study right started before 1.8.2015, student can have 4 non-statutory absences instead of 2
*
* Additional _non-statutory_ absences will consume semesters
*
* @returns duration of studies in SEMESTERS (not months!)
*/
export const calculateDurationOfStudies = (
startDate: Date,
graduationDate: Date,
semesterEnrollments: SemesterEnrollment[],
semesters: Awaited<ReturnType<typeof getSemestersAndYears>>['semesters']
semesters: Awaited<ReturnType<typeof getSemestersAndYears>>['semesters'],
transferInfo?: TransferInfo | null
) => {
const allowedNonStatutoryAbsences = dayjs(startDate).isBefore(dayjs('2015-08-01'), 'day') ? 4 : 2
/** Transferred students: unclear whether this should be based on the original start date or not. Likely doesn't matter. */
const allowedNonStatutoryAbsences = dayjs(transferInfo?.originalStartDate ?? startDate).isBefore(
dayjs('2015-08-01'),
'day'
)
? 4
: 2
const semestersWithStatutoryAbsence = semesterEnrollments
.filter(enrollment => enrollment.statutoryAbsence)
.map(enrollment => enrollment.semester)
/** A semester ending and the next one starting overlap, this takes care of that */
const getCorrectSemester = (date: Dayjs) =>
Object.values(semesters).find(
({ startdate: semesterStart, enddate: semesterEnd }) =>
date.isSameOrAfter(semesterStart, 'day') && date.isBefore(semesterEnd, 'day')
)?.semestercode
const semestersWithNonStatutoryAbsence = semesterEnrollments
.filter(enrollment => enrollment.type === EnrollmentType.ABSENT && !enrollment.statutoryAbsence)
.map(enrollment => enrollment.semester)
.slice(0, allowedNonStatutoryAbsences)
const startSemester = getCorrectSemester(dayjs(startDate))
const endSemester = getCorrectSemester(dayjs(graduationDate))
const monthsToSubtract = [...semestersWithStatutoryAbsence, ...semestersWithNonStatutoryAbsence].reduce(
(acc, semester) => acc + calculateAbsenceInMonths(semesters[semester], startDate, graduationDate),
0
if (!startSemester || !endSemester) {
logger.error(`calculateDurationOfStudies(): No semesters found! start={${startDate}}, end={${graduationDate}}`)
return -1
}
const validSemesters = semesterEnrollments.filter(se => se.semester <= endSemester && se.semester >= startSemester)
const semestersWithStatutoryAbsence =
validSemesters.filter(e => e.statutoryAbsence).length + (transferInfo?.usedStatutoryAbsenceTerms ?? 0)
const semestersWithNonStatutoryAbsence = Math.min(
validSemesters.filter(enrollment => enrollment.type === EnrollmentType.ABSENT && !enrollment.statutoryAbsence)
.length + (transferInfo?.usedAbsenceTerms ?? 0),
allowedNonStatutoryAbsences
)
return (
(transferInfo?.usedTerms ?? 0) +
endSemester -
startSemester -
semestersWithStatutoryAbsence -
semestersWithNonStatutoryAbsence +
1
)
return Math.round(dayjs(graduationDate).subtract(monthsToSubtract, 'months').diff(startDate, 'months', true))
}
export type GraduationTimes = {
@@ -188,7 +209,13 @@ const getGraduationTimeAndThesisWriterStats = async ({
const graduationDate = correctStudyRightElement.endDate
const duration = calculateDurationOfStudies(startDate, graduationDate, studyRight.semesterEnrollments, semesters)
const duration = calculateDurationOfStudies(
startDate,
graduationDate,
studyRight.semesterEnrollments,
semesters,
studyRight.transferInfo
)
const graduationYear = defineYear(graduationDate, isAcademicYear)
if (!graduationTimes[graduationYear] || !graduationTimesCombo[graduationYear]) {
@@ -204,7 +231,7 @@ const getGraduationTimeAndThesisWriterStats = async ({
const goal = await getGoal(studyProgramme)
const times: ChartGraduationTimes = { medians: [], goal }
const comboTimes: ChartGraduationTimes = { medians: [], goal: goal + 36 }
const comboTimes: ChartGraduationTimes = { medians: [], goal: goal + GraduationTarget.THREE_YEARS }
for (const year of years.toReversed()) {
const median = getMedian(graduationTimes[year])
@@ -219,7 +246,7 @@ const getGraduationTimeAndThesisWriterStats = async ({
if (doCombo) {
const median = getMedian(graduationTimesCombo[year])
const statistics = countTimeCategories(graduationTimesCombo[year], goal + 36)
const statistics = countTimeCategories(graduationTimesCombo[year], goal + GraduationTarget.THREE_YEARS)
comboTimes.medians.push({
y: median,
amount: graduationTimesCombo[year].length,
@@ -6,6 +6,7 @@ import { DegreeProgrammeType, Phase } from '@oodikone/shared/types'
import { programmeCodes } from '../../config/programmeCodes'
import { ProgrammeModuleModel } from '../../models'
import { getDegreeProgrammeType } from '../../util'
import { GraduationTarget } from '../graduationHelpers'
export function getYearsArray(since: number, isAcademicYear: true, yearsCombined?: boolean): string[]
export function getYearsArray(since: number, isAcademicYear: false, yearsCombined: true): Array<'Total' | number>
@@ -44,15 +45,16 @@ export const getStatsBasis = (years: Array<string | number>) => {
}
export const getMedian = (values: number[]) => {
if (values.length === 0) {
return 0
if (!values.length) return 0
// compareFn mandatory
const sorted = values.toSorted((a, b) => a - b)
const half = Math.floor(sorted.length / 2)
if (sorted.length % 2) {
return sorted[half]
}
values.sort((a, b) => a - b)
const half = Math.floor(values.length / 2)
if (values.length % 2) {
return values[half]
}
return (values[half - 1] + values[half]) / 2.0
return (sorted[half - 1] + sorted[half]) / 2.0
}
export function defineYear(date: Date, isAcademicYear: true): string
@@ -183,9 +185,9 @@ export const getGoal = async (programme?: string) => {
return 0
}
if ([DegreeProgrammeType.DOCTOR, DegreeProgrammeType.LICENTIATE].includes(degreeProgrammeType)) {
return 48
return GraduationTarget.FOUR_YEARS
}
return (minimumCredits / 60) * 12
return ((minimumCredits / 60) * 12) / 6 // 60 Credits per year divided to semesters
}
export const isRelevantProgramme = (code: string) => /^(KH|MH)\d{2}_\d{3}$/.test(code) || /^T\d{6}$/.test(code)
@@ -14,15 +14,18 @@ export const getStudyRightsInProgramme = async (
where.graduated = true
}
const studyRights = await SISStudyRightModel.findAll({
attributes: ['id'],
include: {
model: SISStudyRightElementModel,
as: 'studyRightElements',
attributes: [],
where,
},
})
const studyRightIds = (
await SISStudyRightModel.findAll({
attributes: ['id'],
include: {
model: SISStudyRightElementModel,
as: 'studyRightElements',
attributes: [],
where,
},
raw: true,
})
).map(sr => sr.id)
const include: Includeable[] = [
{
@@ -54,11 +57,11 @@ export const getStudyRightsInProgramme = async (
return (
await SISStudyRightModel.findAll({
attributes: ['id', 'extentCode', 'semesterEnrollments', 'studentNumber'],
attributes: ['id', 'extentCode', 'semesterEnrollments', 'studentNumber', 'transferInfo'],
include,
where: {
id: {
[Op.in]: studyRights.map(studyRight => studyRight.toJSON().id),
[Op.in]: studyRightIds,
},
},
})
@@ -10,7 +10,7 @@ import {
SemesterEnrollment,
} from '@oodikone/shared/types'
import { createLocaleComparator, keysOf } from '../../util'
import { countTimeCategories } from '../graduationHelpers'
import { countTimeCategories, GraduationTarget } from '../graduationHelpers'
import { getSemestersAndYears } from '../semesters'
import { getDateOfFirstSemesterPresent } from './studyProgrammeBasics'
import { calculateDurationOfStudies, shouldIncludeComboStats } from './studyProgrammeGraduations'
@@ -39,7 +39,7 @@ const getGraduationTimeStats = async (
combinedProgramme?: string
) => {
const goal = await getGoal(combinedProgramme ?? studyProgramme)
const finalGraduationTimes = { goals: { basic: goal, combo: goal + 36 } }
const finalGraduationTimes = { goals: { basic: goal, combo: goal + GraduationTarget.THREE_YEARS } }
const calculateGraduationTimes = (
graduationTimes: Record<string, Record<string, number[]>>,
@@ -276,7 +276,13 @@ const getMainStatsByTrackAndYear = async (
const graduationDate = studyRightElement.endDate
const duration = calculateDurationOfStudies(startDate, graduationDate, studyRight.semesterEnrollments, semesters)
const duration = calculateDurationOfStudies(
startDate,
graduationDate,
studyRight.semesterEnrollments,
semesters,
studyRight.transferInfo
)
if (countAsBachelorMaster) {
if (!graduationTimesCombo[programmeOrStudyTrack]) {
@@ -313,7 +319,8 @@ const getMainStatsByTrackAndYear = async (
bachelorStartDate,
studyRight.studyRightElements.find(element => element.code === combinedProgramme)!.endDate,
studyRight.semesterEnrollments,
semesters
semesters,
studyRight.transferInfo
)
if (graduationTimesCombinedProgrammeCombo[combinedProgramme!][startYearInNewBachelorProgramme]) {
graduationTimesCombinedProgrammeCombo[combinedProgramme!][startYearInNewBachelorProgramme].push(combinedDuration)
-6
View File
@@ -1,6 +0,0 @@
.dockerignore
.eslintrc.json
.gitignore
*Dockerfile
build/
node_modules/
+3 -1
View File
@@ -28,6 +28,8 @@ ENV REACT_APP_LANGUAGE_CENTER_VIEW_ENABLED=$LANGUAGE_CENTER_VIEW_ENABLED
ARG SIS_URL='https://sisu.helsinki.fi'
ENV REACT_APP_SIS_URL=$SIS_URL
RUN npm install -g serve@^14
WORKDIR /opt/app-root/shared
COPY ./shared .
@@ -36,7 +38,7 @@ COPY ./frontend/package*.json .
RUN npm ci --include=dev
COPY ./frontend .
RUN npm run build && npm install -g serve@^14
RUN npm run build
EXPOSE 5000
CMD ["serve", "--single", "--listen", "5000", "build"]
+53 -61
View File
@@ -19,13 +19,13 @@
"@types/lodash-es": "^4.17.12",
"axios": "^1.9.0",
"dayjs": "^1.11.13",
"highcharts": "^11.4.8",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"lodash-es": "^4.17.23",
"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,7 +37,6 @@
"@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"
@@ -1864,9 +1863,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -1884,9 +1880,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -1904,9 +1897,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -1924,9 +1914,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -2104,13 +2091,6 @@
"@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",
@@ -2120,16 +2100,6 @@
"@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",
@@ -2202,17 +2172,6 @@
"@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",
@@ -2539,6 +2498,30 @@
"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",
@@ -2663,6 +2646,12 @@
"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",
@@ -2875,12 +2864,6 @@
"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",
@@ -3988,18 +3971,6 @@
"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",
@@ -4240,6 +4211,12 @@
"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",
@@ -4356,6 +4333,12 @@
"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",
@@ -4594,6 +4577,15 @@
"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",
+2 -10
View File
@@ -22,13 +22,13 @@
"@types/lodash-es": "^4.17.12",
"axios": "^1.9.0",
"dayjs": "^1.11.13",
"highcharts": "^11.4.8",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"lodash-es": "^4.17.23",
"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",
@@ -40,16 +40,8 @@
"@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"
}
}
}
@@ -47,12 +47,11 @@ export const facultyToolTips: Record<string, string> = {
Toisin sanoen luvut vastaavat *Degree programme overview* -näkymän lukuja, kun näkymässä on valittuna *Special study rights excluded*.
- **Breakdown**/**Median study times**:
- **Breakdown**: Näyttää, kuinka moni opiskelija valmistui tavoiteajassa (vihreä palkki), 12 kuukauden sisällä tavoiteajasta (keltainen palkki) tai tätä myöhemmin (punainen palkki).
- **Median study times**: Näyttää kyseisenä kalenteri- tai lukuvuonna aloittaneiden/valmistuneiden opiskelijoiden opintojen keston **mediaanin** kuukausina. Vihreä **katkoviiva** kuvastaa tavoiteaikaa. Keltainen katkoviiva on tavoiteaika + 12 kuukautta.
- **Breakdown**: Näyttää, kuinka moni opiskelija valmistui tavoiteajassa (vihreä palkki), kahden lukukauden sisällä tavoiteajasta (keltainen palkki) tai tätä myöhemmin (punainen palkki).
- **Median study times**: Näyttää kyseisenä kalenteri- tai lukuvuonna aloittaneiden/valmistuneiden opiskelijoiden opintojen keston **mediaanin** lukukausina. Vihreä **katkoviiva** kuvastaa tavoiteaikaa. Keltainen katkoviiva on tavoiteaika + 2 lukukautta.
- **Graduation year**/**Starting year**:
- **Graduation year**: Ryhmittelee opiskelijat valmistumisvuosittain (kalenterivuosi).
- **Starting year**: Ryhmittelee opiskelijat opiskeluoikeuden alkamisvuoden (lukuvuosi) perusteella. Bachelor + Master -kuvaajassa aloitusvuosi on aloitusvuosi kandiohjelmassa ja koulutusohjelma on maisteriohjelma, johon opiskelija on siirtynyt kandidaatiksi valmistumisen jälkeen.
`,
- **Starting year**: Ryhmittelee opiskelijat opiskeluoikeuden alkamisvuoden (lukuvuosi) perusteella. Bachelor + Master -kuvaajassa aloitusvuosi on aloitusvuosi kandiohjelmassa ja koulutusohjelma on maisteriohjelma, johon opiskelija on siirtynyt kandidaatiksi valmistumisen jälkeen.`,
studentProgress: `
Kuvaa tiedekuntaan kuuluvien eri ohjelmien opinto-oikeudella varustettujen opiskelijoiden etenemistä opintopisteissä mitattuna.
Esimerkiksi 2021-2022 aloittaneiden opintopistejakaumaa.
@@ -8,6 +8,7 @@ export const filterToolTips = {
Jos opiskelijan opinto-oikeus ei ole ollut voimassa valitun lukukauden aikana, häntä ei lasketa mukaan mihinkään kategoriaan. Huomioi myös, että osa ilmoittautumisen laiminlyöneistä opiskelijoista on saattanut valmistua valitusta ohjelmasta.
`,
hetu: 'Rajaa opiskelijoita sen mukaan onko heillä suomalaista henkilötunnusta. Filters students based on if they have a Finnish personal identity code.',
startingYear:
'Valitse opiskelijoita sen mukaan, minä vuonna heidän ensimmäinen opinto-oikeutensa yliopistolla alkoi (ei siis ainoastaan tarkasteltavan tutkinnon oikeus). Avoimia opinto-oikeuksia ei oteta huomioon.',
studentNumber:
@@ -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.
Opiskelijanumeron yhteydessä olevaa ympyrää klikkaamalla siirtyy ”student statistics”-sivulle, ja näkee valitun opiskelijan opintotiedot.
Yksittäisen opiskelijan kuvaajaa 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ää 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”
X-akselin skaalaa voi säätää kuvaajan alla olevaa liukusäädintä käyttäen.
Taulukon korkeutta voi säätää viereisistä painikkeista (small, medium, 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 **Stack ordering** -valikosta haluamansa järjestyksen.
Välien järjestystä pylväässä voi muuttaa valitsemalla **Chart settings** -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).
@@ -29,7 +29,7 @@ export const studyProgrammeToolTips: Record<string, string> = {
**Degree students**: Niiden opiskelijoiden opintopisteet, joilla on oikeus suorittaa alempi tai ylempi korkeakoulututkinto.
**Open university**: Avoimen opiskeluoikeus, ei tutkinto-opiskelija: opintosuoritukset, joiden suorituksen luokittelu on "avoimena yliopisto-opintona suoritettu" ja opiskelija ei ole tutkinto-opiskelija tai kansainvälinen vaihto-opiskelija.
**Open university**: Avoimen opiskeluoikeus, ei tutkinto-opiskelija: opintosuoritukset, joiden suorituksen luokittelu on "avoimena yliopisto-opintona suoritettu" ja opiskelija ei ole tutkinto-opiskelija tai kansainvälinen vaihto-opiskelija. Avoimen opiskelijat ovat eroteltu suomalaisen henkilötunnuksen omaaviin / hetuttomiin.
**Exchange students**: Saapuvat vaihto-opiskelijat: opintosuoritukset, jotka ovat saapuvan kansainvälisen opiskelijan suorittamia. Sisältää kaikki vaihto-opiskelijan opiskeluoikeuden aikana syntyneet opintopisteet.
@@ -72,22 +72,22 @@ export const studyProgrammeToolTips: Record<string, string> = {
averageGraduationTimes: `
Yksittäinen palkki kertoo, kuinka moni opiskelija on valmistunut kyseisenä vuonna/lukuvuonna.
Vihreä **vaakapalkki** kuvaa tavoiteajassa valmistuneita, keltainen 12 kuukauden sisällä tavoiteajasta valmistuneita ja punainen yli 12 kuukautta tavoiteajan ylittäneitä.
Vihreä **vaakapalkki** kuvaa tavoiteajassa valmistuneita, keltainen kahden lukukauden sisällä tavoiteajasta valmistuneita ja punainen yli 2 lukukautta tavoiteajan ylittäneitä.
**Breakdown**: Näyttää, kuinka moni kyseisenä kalenteri- tai lukuvuotena valmistuneista opiskelijoista valmistui tavoiteajassa, 12 kuukauden sisällä tavoiteajasta tai tätä myöhemmin.
**Breakdown**: Näyttää, kuinka moni kyseisenä kalenteri- tai lukuvuotena valmistuneista opiskelijoista valmistui tavoiteajassa, kahden lukukauden sisällä tavoiteajasta tai tätä myöhemmin.
**Median time**: Näyttää kyseisenä kalenteri- tai lukuvuotena valmistuneiden opiskelijoiden opintojen keston **mediaanin** kuukausina. Vihreä **katkoviiva** kuvastaa tavoiteaikaa. Keltainen katkoviiva on tavoiteaika + 12 kuukautta.
**Median time**: Näyttää kyseisenä kalenteri- tai lukuvuotena valmistuneiden opiskelijoiden opintojen keston **mediaanin** kuukausina. Vihreä **katkoviiva** kuvastaa tavoiteaikaa. Keltainen katkoviiva on tavoiteaika + 2 lukukautta.
Valmistumisajoista on vähennetty lakisääteiset poissaolot.
`,
averageGraduationTimesStudyTracks: `
Yksittäinen palkki kertoo, kuinka moni kyseisenä **lukuvuotena ALOITTANEISTA** opiskelijoista on valmistunut.
Vihreä **vaakapalkki** kuvaa tavoiteajassa valmistuneita, keltainen 12 kuukauden sisällä tavoiteajasta valmistuneita ja punainen yli 12 kuukautta tavoiteajan ylittäneitä.
Vihreä **vaakapalkki** kuvaa tavoiteajassa valmistuneita, keltainen kahden lukukauden sisällä tavoiteajasta valmistuneita ja punainen yli 2 lukukautta tavoiteajan ylittäneitä.
**Breakdown**: Näyttää, kuinka moni kyseisenä lukuvuotena aloittaneista, jo valmistuneista, opiskelijoista valmistui tavoiteajassa, 12 kuukauden sisällä tavoiteajasta tai tätä myöhemmin.
**Breakdown**: Näyttää, kuinka moni kyseisenä lukuvuotena aloittaneista, jo valmistuneista, opiskelijoista valmistui tavoiteajassa, kahden lukukauden sisällä tavoiteajasta tai tätä myöhemmin.
**Median time**: Näyttää valmistuneiden opiskelijoiden opintojen keston **mediaanin** kuukausina sekä heidän prosentuaalisen osuutensa koko vuosikurssista. Vihreä **katkoviiva** kuvastaa tavoiteaikaa. Keltainen katkoviiva on tavoiteaika + 12 kuukautta.
**Median time**: Näyttää valmistuneiden opiskelijoiden opintojen keston **mediaanin** kuukausina sekä heidän prosentuaalisen osuutensa koko vuosikurssista. Vihreä **katkoviiva** kuvastaa tavoiteaikaa. Keltainen katkoviiva on tavoiteaika + 2 lukukautta.
Valmistumisajoista on vähennetty lakisääteiset poissaolot.
+7 -4
View File
@@ -4,6 +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 { Absence } from '@/types/students'
import type { SISStudyRightElement } from '@oodikone/shared/models'
@@ -87,23 +88,25 @@ 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: any,
getTextIn: GetTextIn,
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({ en: 'Study module', fi: 'Opintokokonaisuus', sv: 'Studiehelhet' })}]`
courseName += ` [${getTextIn(studyModuleTexts)}]`
}
courseName += ` (${course.code})`
return courseName
@@ -6,7 +6,6 @@ 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'
@@ -22,21 +21,6 @@ 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: {
@@ -0,0 +1,81 @@
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>
)
}
@@ -0,0 +1,335 @@
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 }} />
</>
)
}
@@ -0,0 +1,196 @@
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>
)
}
@@ -0,0 +1,77 @@
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>
)
}
@@ -0,0 +1,266 @@
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>
)
}
@@ -0,0 +1,147 @@
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>
)
}
@@ -0,0 +1,106 @@
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>
)
}
@@ -0,0 +1,410 @@
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 }} />
</>
)
}
@@ -0,0 +1,202 @@
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 }} />
}
@@ -0,0 +1,250 @@
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` })
}
@@ -13,6 +13,7 @@ import {
courseFilter,
creditsEarnedFilter,
genderFilter,
hetuFilter,
gradeFilter,
programmeFilter,
startYearAtUniFilter,
@@ -208,6 +209,7 @@ export const CoursePopulation = () => {
ageFilter(),
courseFilter({ courses: population?.coursestatistics.courses }),
creditsEarnedFilter(),
hetuFilter(),
startYearAtUniFilter(),
programmeFilter({
additionalModes: [
@@ -1,32 +0,0 @@
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>
)
}
@@ -1,569 +0,0 @@
// @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,7 +9,6 @@ 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'
@@ -23,6 +22,7 @@ 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 { CustomPopulationProgrammeDist } from './CustomPopulationProgrammeDist'
@@ -67,18 +67,10 @@ export const CustomPopulationContent = ({
{
title: `Credit accumulation (for ${filteredStudents.length} students)`,
content: (
<CreditAccumulationGraphHighCharts
absences={null}
customPopulation
endDate={null}
programmeCodes={null}
selectedStudyPlan={null}
showBachelorAndMaster={null}
singleStudent={false}
startDate={null}
<CreditAccumulationGraph
programmeCodes={[associatedProgramme].filter(Boolean) as string[]}
students={filteredStudents}
studyPlanFilterIsActive={null} // Atm the filter is not enabled in the view
studyRightId={null}
studyPlanFilter={false} // Not enabled for custom population yet.
/>
),
},
@@ -6,6 +6,7 @@ import {
courseFilter,
creditDateFilter,
creditsEarnedFilter,
hetuFilter,
enrollmentStatusFilter,
genderFilter,
hopsFilter,
@@ -51,6 +52,7 @@ export const CustomPopulationWrapper = ({
transferredToProgrammeFilter(),
startYearAtUniFilter(),
tagsFilter(),
hetuFilter(),
programmeFilter(),
creditDateFilter(),
enrollmentStatusFilter({
@@ -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,63 +12,83 @@ import { useTitle } from '@/hooks/title'
import { useLogoutMutation } from '@/redux/auth'
import { LogoutIcon, RefreshIcon } from '@/theme'
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,
}))
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
}, []))
export const ErrorBackground = ({ header, content }) => {
useTitle(header)
const [logout] = useLogoutMutation()
const yearLabels = dummyData[0]?.map((_, index) => (2017 + index).toString()) ?? []
return (
<PageLayout maxWidth="lg">
<Stack sx={{ alignItems: 'stretch', height: '100vh', justifyContent: 'space-evenly' }}>
<ReactHighchart
config={{
plotOptions: { series: { label: { connectorAllowed: false }, pointStart: 2010 } },
series: dummyData,
title: { text: 'Students of Computer Science 2018-2020' },
yAxis: { title: { text: 'Cumulative credits' } },
}}
/>
<ReactHighchart
config={{
chart: { type: 'column' },
series: dummyData.map(element => ({
name: element.name,
data: [Math.max(...element.data)],
type: 'column',
<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,
})),
title: { text: "Your students' future" },
xAxis: { categories: ['2018'] },
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)],
})),
}}
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/common/ProgressBarChart'
import { ProgressBarChart } from '@/components/Charts/ProgressBarChart'
import { FacultyProgressTable } from '@/components/Faculties/FacultyProgressTable'
import { Section } from '@/components/Section'
import { GetAllProgressStatsResponse } from '@/types/api/faculty'
@@ -1,20 +1,11 @@
/* eslint-disable react/no-this-in-sfc */
import Typography from '@mui/material/Typography'
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 ReactECharts from 'echarts-for-react'
import { useEffect, useMemo, useState } from 'react'
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,
@@ -27,70 +18,159 @@ 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: HighCharts.SeriesBarOptions[] = transpose(data).map((obj, index) => ({
const dataTranspose = transpose(data).map((obj, index) => ({
name: names[index],
data: obj,
color: colors[index],
type: 'bar',
}))
const config: HighCharts.Options = {
chart: {
type: 'bar',
marginTop: 60,
height: 80 + 45 * labels.length,
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],
},
series: dataTranspose,
xAxis: {
categories: labels,
},
yAxis: {
min: 0,
reversed: true,
title: {
text: '',
label: {
show: true,
position: 'inside',
formatter: (params: { data?: { percentage?: number } }) => {
const percentage = getNumericValue(params.data?.percentage)
return percentage > 3 ? `${percentage.toFixed(1)}%` : ''
},
},
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,
verticalAlign: 'top',
bottom: 0,
selected: Object.keys(legendSelection).length ? legendSelection : undefined,
},
tooltip: {
shared: true,
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
backgroundColor: 'white',
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 />`
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 />`
})
return tooltipString
},
},
plotOptions: {
series: {
stacking: 'percent',
dataLabels: {
enabled: true,
format: '{point.percentage:.1f}%',
filter: {
property: 'percentage',
operator: '>',
value: 3,
},
toolbox: {
feature: {
saveAsImage: {
name: 'Oodikone-progress-of-students',
},
},
},
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 <ReactHighcharts config={config} />
return (
<ReactECharts
notMerge
onEvents={onEvents}
option={option}
opts={{ renderer: 'svg' }}
style={{ height: 80 + 45 * labels.length, width: '100%' }}
/>
)
}
@@ -0,0 +1,60 @@
import { filterToolTips } from '@/common/InfoToolTips'
import { FilterTrayProps } from '../FilterTray'
import { FilterRadio } from './common/FilterRadio'
import { createFilter } from './createFilter'
const DEFAULT_STATE = 0
const HetuFilterCard = ({ onOptionsChange }: FilterTrayProps) => {
const modeOptions = [
{ key: 'All', text: 'All', value: 0 },
{ key: 'Has hetu', text: 'Has hetu', value: 1 },
{ key: 'Does not have hetu', text: 'Does not have hetu', value: 2 },
]
return (
<FilterRadio
defaultValue={DEFAULT_STATE}
filterKey={hetuFilter.key}
onChange={({ target }) => onOptionsChange({ mode: target.value })}
options={modeOptions}
/>
)
}
/**
* @options
* 0 - All
* 1 - Has hetu
* 2 - No hetu
*/
export const hetuFilter = createFilter({
key: 'hetuFilter',
title: 'Personal identity code',
info: filterToolTips.hetu,
defaultOptions: {
mode: 0,
},
isActive: ({ mode }) => mode !== 0,
filter(student, { options: { mode } }) {
const hetu = student.hasPersonalIdentityCode
switch (Number(mode)) {
case 0:
return true
case 1:
return hetu
case 2:
return !hetu
default:
// never reached
return true
}
},
render: HetuFilterCard,
})
@@ -18,3 +18,4 @@ export { studyRightTypeFilter } from './studyRightType'
export { studyTrackFilter } from './studyTrack'
export { tagsFilter } from './tags'
export { transferredToProgrammeFilter } from './transferredToProgramme'
export { hetuFilter } from './hetu'
@@ -71,7 +71,8 @@ export const studentNumberFilter = createFilter({
filter(student, { options }) {
const { allowlist, blocklist } = options
return allowlist.includes(student.studentNumber) && !blocklist.includes(student.studentNumber)
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
return allowlist.includes(student.studentNumber) || !blocklist.includes(student.studentNumber)
},
render: StudentNumberFilterCard,
@@ -1,162 +0,0 @@
/* 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>
)
}
@@ -1,101 +0,0 @@
/* 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,44 +2,65 @@ import Box from '@mui/material/Box'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { BreakdownBarChart } from '@/components/GraduationTimes/BreakdownDisplay/BreakdownBarChart'
import { GraduationBreakdown } from '@/components/Charts/GraduationBreakdown'
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: (event, isFacultyGraph: boolean, seriesCategory?: number | string) => void
handleClick: (seriesCategory: string) => void
allowExpand: boolean
expandKey: string | null,
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' }}>
<BreakdownBarChart cypress={`${level}-breakdown-bar-chart`} data={data} handleClick={handleClick} mode={mode} />
{programmeDataVisible && year && year in levelProgrammeData ? (
<BreakdownBarChart
<GraduationBreakdown
cypress={`${level}-breakdown-bar-chart`}
data={data}
handleClick={handleClick}
mode={mode}
yearLabel={yearLabel}
/>
{programmeDataVisible && expandKey && levelProgrammeData && expandKey in levelProgrammeData ? (
<GraduationBreakdown
cypress={`${level}BreakdownBarChartFaculty`}
data={levelProgrammeData[year].data}
facultyGraph={false}
data={levelProgrammeData[expandKey].data}
handleClick={handleClick}
mode={mode}
names={names}
year={year}
expandKey={expandKey}
yearLabel={yearLabel}
/>
) : null}
@@ -1,6 +1,6 @@
import Stack from '@mui/material/Stack'
import { GraduationTimes } from '@/components/GraduationTimes'
import { GraduationTimes, GraduationTimesProps } from '@/components/GraduationTimes'
import { GetAllGraduationStatsResponse } from '@/types/api/university'
export const FacultyGraduations = ({
@@ -24,17 +24,19 @@ export const FacultyGraduations = ({
const programmeData = data?.byGradYear.programmes.medians
const facultyNames = data?.programmeNames
const classSizes = data?.classSizes
const commonProps = {
const commonProps: Omit<GraduationTimesProps, 'data' | 'goal' | 'level' | 'title'> = {
allowExpand: true,
classSizes,
names: facultyNames,
goalExceptions,
groupBy: 'byGradYear' as const,
isError,
isLoading,
mode: universityMode ? ('faculty' as const) : ('programme' as const),
mode: universityMode ? 'faculty' as const : 'programme' as const,
showMedian,
yearLabel: 'Graduation year' as const,
}
} as const
return (
<Stack gap={2}>
@@ -1,261 +0,0 @@
/* 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 {
FacultyClassSizes,
GraduationStatistics,
GraduationStats,
Name,
NameWithCode,
ProgrammeClassSizes,
} from '@oodikone/shared/types'
exporting(ReactHighcharts.Highcharts)
exportData(ReactHighcharts.Highcharts)
accessibility(ReactHighcharts.Highcharts)
export const MedianBarChart = ({
classSizes,
cypress,
data,
facultyGraph = true,
goal,
goalExceptions,
handleClick,
level,
mode,
names,
title,
year,
yearLabel,
}: {
classSizes: FacultyClassSizes['programmes'] | ProgrammeClassSizes['studyTracks']
cypress: string
data: GraduationStats[]
facultyGraph?: boolean
goal: number
goalExceptions?: Record<string, number> | { needed: boolean }
handleClick: (event, isFacultyGraph: boolean, seriesCategory?: number) => void
level?: string
mode: 'faculty' | 'programme' | 'study track'
names?: Record<string, Name | NameWithCode>
title: string
year?: number | null
yearLabel: 'Graduation year' | 'Start year'
}) => {
const { language } = useLanguage()
const theme = useTheme()
let modData: Array<GraduationStats & { code: string; color: string; realGoal?: number }> | null = null
if (!facultyGraph && goalExceptions?.needed && level && ['master', 'bcMsCombo'].includes(level)) {
// change colors for longer medicine goal times
modData = JSON.parse(JSON.stringify(data))
for (const data of modData!) {
if (Object.keys(goalExceptions).includes(data.code)) {
const realGoal = goal + goalExceptions[data.code]
if (data.median <= realGoal) {
data.color = theme.palette.graduationTimes.onTime
} else if (data.median <= realGoal + 12) {
data.color = theme.palette.graduationTimes.yearOver
} else {
data.color = theme.palette.graduationTimes.wayOver
}
data.realGoal = realGoal
}
}
}
const maxValue = data.reduce((max, { median }) => {
return median > max ? median : max
}, goal * 2)
const getClassSize = (category: string) => {
if (facultyGraph) {
return classSizes[category]
}
return classSizes[category][year!]
}
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,
realGoal: number | undefined
) => {
const sortingText =
yearLabel === 'Start year'
? `<b>From class of ${facultyGraph ? name : year}, ${amount}/${getClassSize(code)} students have graduated</b>`
: `<b>${amount} students graduated in year ${facultyGraph ? name : year}</b>`
const timeText = `<br />${sortingText}<br /><b>median study time: ${median} months</b><br />`
const statisticsText = `<br />${statistics.onTime} graduated on time<br />${statistics.yearOver} graduated max year overtime<br />${statistics.wayOver} graduated over year late`
if (!facultyGraph) {
const goalText = realGoal ? `<br /><p><b>** Exceptional goal time: ${realGoal} months **</b></p>` : ''
return `<b>${getFacultyOrProgrammeName(code)}</b> • ${code}<br />${timeText}${statisticsText}${goalText}`
}
return `${timeText}${statisticsText}`
}
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',
margin: [70, 0],
height: getHeight(),
},
title: { text: !facultyGraph ? `Year ${year} by ${yearLabel.toLowerCase()}` : '' },
tooltip: {
backgroundColor: 'white',
formatter() {
const point = this.point as Highcharts.Point & {
code?: string
amount?: number
statistics?: GraduationStatistics
realGoal?: number
}
return getTooltipText(
point.name,
point.code ?? point.name,
point.amount!,
this.y!,
point.statistics!,
point.realGoal
)
},
},
plotOptions: {
series: {
dataLabels: {
enabled: true,
inside: true,
overflow: 'allow',
},
},
},
series: [
{
type: 'bar',
data: (modData ?? data).map(item => ({
...item,
y: item.median,
name: item.name.toString(),
})),
dataLabels: {
align: 'left',
color: '#424949',
style: {
textOutline: 'none',
},
formatter() {
const point = this.point as Highcharts.Point & {
code?: string
amount?: number
}
return getDataLabel(point.amount!, point.code ?? point.name)
},
},
showInLegend: false,
zones: [
{
value: goal + 0.1,
color: theme.palette.graduationTimes.onTime,
},
{
value: goal + 12.1,
color: theme.palette.graduationTimes.yearOver,
},
{
color: theme.palette.graduationTimes.wayOver,
},
],
point: {
events: {
click(event) {
handleClick(event, facultyGraph)
},
},
},
},
],
xAxis: {
type: 'category',
title: {
text: getLabel(),
align: 'high',
offset: 0,
rotation: 0,
y: -10,
},
},
yAxis: {
min: 0,
max: maxValue,
title: { text: 'Graduation time (months)' },
labels: {
overflow: 'justify',
},
allowDecimals: false,
showFirstLabel: false,
plotLines: [
{
color: theme.palette.graduationTimes.onTime,
width: 2,
value: goal,
dashStyle: 'ShortDash',
},
{
color: theme.palette.graduationTimes.yearOver,
width: 2,
value: goal + 12,
dashStyle: 'ShortDash',
},
],
},
}
return (
<Box data-cy={cypress} width={{ sm: '100%', md: '50%' }}>
<ReactHighcharts config={config} />
</Box>
)
}
@@ -2,7 +2,7 @@ import Box from '@mui/material/Box'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { MedianBarChart } from '@/components/GraduationTimes/MedianDisplay/MedianBarChart'
import { MedianGraduations } from '@/components/Charts/MedianGraduations'
import {
FacultyClassSizes,
GraduationStats,
@@ -12,9 +12,29 @@ import {
ProgrammeMedians,
} from '@oodikone/shared/types'
type MedianDisplayProps = {
mode: 'faculty' | 'programme' | 'study track'
classSizes: FacultyClassSizes | ProgrammeClassSizes | undefined
allowExpand: boolean
names: Record<string, string | Name> | Record<string, Name | NameWithCode> | undefined
data: GraduationStats[]
expandKey: string | null
goal: number | undefined
goalExceptions: Record<string, number> | { needed: boolean }
groupBy: 'byGradYear' | 'byStartYear'
handleClick: (seriesCategory: string) => void
level: string
levelProgrammeData: ProgrammeMedians | undefined
programmeDataVisible: boolean
title: string
yearLabel: 'Graduation year' | 'Start year'
}
export const MedianDisplay = ({
allowExpand,
classSizes,
data,
expandKey,
goal,
goalExceptions,
groupBy,
@@ -25,24 +45,8 @@ export const MedianDisplay = ({
names,
programmeDataVisible,
title,
year,
yearLabel,
}: {
classSizes: FacultyClassSizes | ProgrammeClassSizes
data: GraduationStats[]
goal: number
goalExceptions: Record<string, number> | { needed: boolean }
groupBy: 'byGradYear' | 'byStartYear'
handleClick: (event, isFacultyGraph: boolean, seriesCategory?: number) => void
level: string
levelProgrammeData: ProgrammeMedians
mode: 'faculty' | 'programme' | 'study track'
names: Record<string, Name | NameWithCode> | Record<string, string | Name>
programmeDataVisible: boolean
title: string
year: number | null
yearLabel: 'Graduation year' | 'Start year'
}) => {
}: MedianDisplayProps) => {
return (
<Box>
{level === 'bcMsCombo' && groupBy === 'byStartYear' && (
@@ -51,43 +55,58 @@ export const MedianDisplay = ({
data in Sisu
</Typography>
)}
{goalExceptions.needed && ['master', 'bcMsCombo'].includes(level) ? (
{goalExceptions.needed && ['master', 'bcMsCombo'].includes(level) && (
<Typography>
<b>Different goal times</b> have been taken into account in all numbers and programme level bar coloring, but
the faculty level bar color is based on the typical goal time of {goal} months
</Typography>
) : null}
<Typography>Click a bar to view that year's {mode} level breakdown</Typography>
<Stack direction={{ sm: 'column', md: 'row' }}>
<MedianBarChart
classSizes={classSizes?.[level] ?? {}}
)}
{allowExpand ? <Typography>Click a bar to view that year's {mode} level breakdown</Typography> : null}
{!allowExpand ? (
<MedianGraduations
cypress={`${level}-median-bar-chart`}
classSizes={!!classSizes && 'programme' in classSizes ? classSizes.programme : {}}
data={data}
goal={goal}
fullWidth
goal={goal ?? 0}
handleClick={handleClick}
mode={mode}
names={mode === 'faculty' ? (names as Record<string, Name | NameWithCode>) : undefined}
title={title}
yearLabel={yearLabel}
/>
{programmeDataVisible && year && year in levelProgrammeData ? (
<MedianBarChart
classSizes={'programmes' in classSizes ? classSizes.programmes : classSizes.studyTracks}
cypress={`${level}-median-bar-chart-faculty`}
data={levelProgrammeData[year].data}
facultyGraph={false}
goal={goal}
goalExceptions={goalExceptions}
) : (
<Stack direction={{ sm: 'column', md: 'row' }}>
<MedianGraduations
classSizes={!!classSizes ? ('programme' in classSizes ? classSizes.programme : level in classSizes ? classSizes[level] : {}) : {}}
cypress={`${level}-median-bar-chart`}
data={data}
goal={goal ?? 0}
handleClick={handleClick}
level={level}
mode={mode}
names={mode === 'faculty' ? (names as Record<string, Name | NameWithCode>) : undefined}
title={title}
year={year}
yearLabel={yearLabel}
/>
) : null}
</Stack>
</Box>
{programmeDataVisible && expandKey && levelProgrammeData && expandKey in levelProgrammeData && (
<MedianGraduations
classSizes={!!classSizes ? ('programmes' in classSizes ? classSizes.programmes : classSizes.studyTracks) : {}}
cypress={`${level}-median-bar-chart-faculty`}
data={levelProgrammeData[expandKey].data}
expandKey={expandKey}
goal={goal ?? 0}
goalExceptions={goalExceptions}
handleClick={handleClick}
level={level}
mode={mode}
names={mode === 'faculty' ? (names as Record<string, Name | NameWithCode>) as any : undefined}
title={title}
yearLabel={yearLabel}
/>
)}
</Stack>
)}
</Box >
)
}
@@ -12,63 +12,79 @@ import {
ProgrammeMedians,
} from '@oodikone/shared/types'
export type GraduationTimesProps = {
mode: 'faculty' | 'programme' | 'study track' // "Mode" is one step below the current view in hierarchy e.g. viewing a programme overview -> mode should be study track
classSizes?: FacultyClassSizes | ProgrammeClassSizes | undefined
data: GraduationStats[] | undefined
goal?: number | undefined
goalExceptions?: Record<string, number> | { needed: boolean }
groupBy?: 'byGradYear' | 'byStartYear'
allowExpand?: boolean
isLoading: boolean
isError: boolean
level?: 'unset' | 'bachelor' | 'master' | 'bcMsCombo' | 'doctor'
levelProgrammeData?: ProgrammeMedians
names?: Record<string, Name | NameWithCode> | Record<string, string | Name>
showMedian: boolean
title: string
yearLabel: 'Graduation year' | 'Start year'
}
export const GraduationTimes = ({
classSizes,
data,
goal,
goalExceptions,
goalExceptions = { needed: false },
groupBy = 'byStartYear',
isError,
allowExpand = true,
isLoading,
level,
isError,
level = 'unset',
levelProgrammeData,
mode,
names,
showMedian,
title,
yearLabel,
}: {
classSizes?: FacultyClassSizes | ProgrammeClassSizes
data: GraduationStats[] | undefined
goal: number | undefined
goalExceptions: Record<string, number> | { needed: boolean }
groupBy?: 'byGradYear' | 'byStartYear'
isError: boolean
isLoading: boolean
level: string
levelProgrammeData?: ProgrammeMedians
mode: 'faculty' | 'programme' | 'study track'
names?: Record<string, Name | NameWithCode> | Record<string, string | Name>
showMedian: boolean
title: string
yearLabel: 'Graduation year' | 'Start year'
}) => {
}: GraduationTimesProps) => {
const [programmeDataVisible, setProgrammeDataVisible] = useState(false)
const [year, setYear] = useState<number | null>(null)
const [expandKey, setExpandKey] = useState<string | null>(null)
const handleClick = (event, isFacultyGraph: boolean, seriesCategory: number | string | null = null) => {
if (isFacultyGraph) {
setYear(seriesCategory ?? event.point.name)
const handleClick = (category: string) => {
if (category) {
setExpandKey(category)
setProgrammeDataVisible(true)
} else {
setProgrammeDataVisible(false)
setYear(null)
setExpandKey(null)
}
}
const dataIsLoaded = classSizes && data && names && goal && levelProgrammeData
return (
<Section
cypress={`${level}-graduation-times`}
isError={isError}
isLoading={isLoading ? !dataIsLoaded : false}
title={title}
isLoading={isLoading}
>
{showMedian && dataIsLoaded ? (
{!showMedian ? (
<BreakdownDisplay
allowExpand={allowExpand}
data={data!}
expandKey={expandKey}
handleClick={handleClick}
level={level}
levelProgrammeData={levelProgrammeData}
mode={mode}
names={names}
programmeDataVisible={programmeDataVisible}
yearLabel={yearLabel}
/>
) : (
<MedianDisplay
allowExpand={allowExpand}
classSizes={classSizes}
data={data}
data={data!}
goal={goal}
goalExceptions={goalExceptions}
groupBy={groupBy}
@@ -76,26 +92,13 @@ export const GraduationTimes = ({
level={level}
levelProgrammeData={levelProgrammeData}
mode={mode}
names={names}
names={names ?? {}}
programmeDataVisible={programmeDataVisible}
title={title}
year={year}
expandKey={expandKey}
yearLabel={yearLabel}
/>
) : null}
{!showMedian && dataIsLoaded ? (
<BreakdownDisplay
data={data}
handleClick={handleClick}
level={level}
levelProgrammeData={levelProgrammeData}
mode={mode}
names={names}
programmeDataVisible={programmeDataVisible}
year={year}
yearLabel={yearLabel}
/>
) : null}
)}
</Section>
)
}
@@ -66,6 +66,9 @@ export const OodiTableContainer = <OTData,>({
backgroundColor: 'grey.100',
}
: {},
'& tr:hover > td': {
backgroundColor: 'grey.200',
},
}}
>
{table.getAggregationRowModel().rows.map(row => OodiTableDataRow(row, true))}
@@ -10,13 +10,14 @@ import { flexRender } from './util'
export const OodiTableDataRow = <OTData,>(row: Row<OTData>, aggregate = false) => (
<TableRow key={row.id}>
{row.getVisibleCells().map(cell => {
const { maxSize } = cell.column.columnDef
const { size, maxSize } = cell.column.columnDef
return (
<TableCell
className="ot-data-cell"
key={cell.id}
sx={{
...getCommonPinningStyles(cell.column),
width: size ?? undefined,
maxWidth: maxSize !== Number.MAX_SAFE_INTEGER ? maxSize : '20em',
}}
>
@@ -1,19 +1,34 @@
import TablePagination from '@mui/material/TablePagination'
import type { Table } from '@tanstack/react-table'
import { useEffect, useState } from 'react'
export const OodiTablePagination = <TData,>({ table }: { table: Table<TData> }) => {
const tableRows = table.getRowCount()
const pageRowOptions = [100, 200, 500, 1000, tableRows].filter(count => count <= tableRows)
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: Math.min(200, tableRows),
})
useEffect(() => table.setPageSize(pagination.pageSize), [pagination.pageSize])
useEffect(() => table.setPageIndex(pagination.pageIndex), [pagination.pageIndex])
useEffect(
() =>
setPagination({
pageIndex: 0,
pageSize: Math.max(pagination.pageSize, pageRowOptions[0]),
}),
[table.getCoreRowModel().rows.length]
)
return (
<TablePagination
component="div"
count={table.getFilteredRowModel().rows.length}
onPageChange={(_, newPage) => table.setPageIndex(newPage)}
onRowsPerPageChange={event => {
table.setPageSize(Number(event.target.value))
table.setPageIndex(0)
}}
onPageChange={(_, newPage) => setPagination({ ...pagination, pageIndex: newPage })}
onRowsPerPageChange={event => setPagination({ pageIndex: 0, pageSize: Number(event.target.value) })}
page={table.getState().pagination.pageIndex}
rowsPerPage={table.getState().pagination.pageSize}
rowsPerPageOptions={pageRowOptions}
@@ -93,6 +93,7 @@ export const OodiTableExcelExport = <TData extends object>({
}),
columnHelper.accessor('sample', {
header: 'Sample values',
enableSorting: false,
cell: cell => {
const value = cell.getValue<React.ReactNode>()
return (
@@ -112,15 +113,14 @@ export const OodiTableExcelExport = <TData extends object>({
[data]
)
const prepData = exportData.slice(0, 10)
const pivotedData = useMemo(
() =>
exportColumnKeys.map(accessorKey => ({
header: accessorKey,
sample: prepData
sample: data
.map(row => row[accessorKey])
.filter(value => value !== undefined)
.filter(Boolean)
.slice(0, 8)
.map(churnSampleData),
})),
[exportColumnKeys, exportData]
@@ -1,6 +1,6 @@
import type { ColumnDef, TableOptions } from '@tanstack/react-table'
import { useReactTable, getCoreRowModel, getPaginationRowModel, getSortedRowModel } from '@tanstack/react-table'
import { ReactNode, useState } from 'react'
import type { ReactNode } from 'react'
import { AggregationRowFeature, VerticalHeaderFeature, ZebrastripesFeature } from './features'
import { OodiTableContainer } from './OodiTable'
@@ -28,21 +28,14 @@ export const OodiTable = <TData,>({
cy,
}: {
data: TData[]
columns: ColumnDef<TData>[]
columns: ColumnDef<TData, any>[]
isExportView?: true
options?: Partial<TableOptions<TData>>
toolbarContent?: ReactNode
cy?: string
}) => {
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: Math.min(200, data.length),
})
// should maybe use a deep merging tool or write own
const config: Partial<TableOptions<TData>> = {
...options,
}
const { ...config }: Partial<TableOptions<TData>> = options
const table = useReactTable<TData>({
_features: [VerticalHeaderFeature, AggregationRowFeature, ZebrastripesFeature],
@@ -51,9 +44,7 @@ export const OodiTable = <TData,>({
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
onPaginationChange: setPagination,
...config,
state: { pagination, ...config.state },
})
return <OodiTableContainer cy={cy} isExportView={isExportView} table={table} toolbarContent={toolbarContent} />
}
@@ -5,15 +5,11 @@ import FormLabel from '@mui/material/FormLabel'
import Paper from '@mui/material/Paper'
import RadioMui from '@mui/material/Radio'
import RadioGroup from '@mui/material/RadioGroup'
import dayjs from 'dayjs'
import { Options } from 'highcharts'
import accessibility from 'highcharts/modules/accessibility'
import exportData from 'highcharts/modules/export-data'
import exporting from 'highcharts/modules/exporting'
import dayjs, { Dayjs } from 'dayjs'
import ReactECharts from 'echarts-for-react'
import { groupBy, range } from 'lodash-es'
import { useState } from 'react'
import ReactHighcharts from 'react-highcharts'
import { useMemo, useState } from 'react'
import { getCreditCategories, getTargetCreditsForProgramme, TimeDivision } from '@/common'
import { Toggle } from '@/components/common/toggle/Toggle'
@@ -26,23 +22,15 @@ import { SemestersData, useGetSemestersQuery } from '@/redux/semesters'
import { generateGradientColors } from '@/util/color'
import { FormattedStudent } from '@oodikone/shared/types'
exporting(ReactHighcharts.Highcharts)
exportData(ReactHighcharts.Highcharts)
accessibility(ReactHighcharts.Highcharts)
/*
This file is bit of a legacy mess still and imo not worth the effort refactoring unless replacing legacy react-highcharts
(last publish 6 years ago as of 2025 btw) with the official newer version or something else entirely
*/
const splitStudentCredits = (student, timeSlots, cumulative) => {
const splitStudentCredits = (student: FormattedStudent, timeSlots: any[], cumulative: boolean) => {
if (!timeSlots.length) return []
let timeSlotN = 0
const results = new Array(timeSlots.length).fill(0)
const results: number[] = new Array(timeSlots.length).fill(0)
student.courses
.filter(course => course.passed && !course.isStudyModuleCredit && dayjs(course.date).isAfter(timeSlots[0].start))
.sort((a, b) => dayjs(a.date) > dayjs(b.date))
.sort((a, b) => Number(dayjs(a.date) > dayjs(b.date)))
.forEach(course => {
while (timeSlotN < timeSlots.length && dayjs(course.date).isAfter(timeSlots[timeSlotN].end)) timeSlotN++
if (timeSlots.length <= timeSlotN) return
@@ -58,17 +46,17 @@ const splitStudentCredits = (student, timeSlots, cumulative) => {
return results
}
const hasGraduatedBeforeDate = (student, programme, date) => {
const hasGraduatedBeforeDate = (student: FormattedStudent, programme: string, date: Dayjs) => {
const correctStudyRight = student.studyRights.find(studyRight =>
studyRight.studyRightElements.some(el => el.code === programme)
)
if (!correctStudyRight) return false
const studyRightElement = correctStudyRight.studyRightElements.find(el => el.code === programme)
return studyRightElement.graduated && date.isAfter(studyRightElement.endDate, 'day')
return Boolean(studyRightElement?.graduated && date.isAfter(studyRightElement?.endDate, 'day'))
}
const getChartData = (
students: any[],
students: FormattedStudent[],
timeSlots: any[],
programme: string,
timeDivision: TimeDivision,
@@ -87,43 +75,41 @@ const getChartData = (
const colors = generateGradientColors(limits.length - 1)
colors.push('#ddd') // Color for graduated (grey)
const data: { y: number; custom: { students: number[] } }[][] = limits.map(() =>
const data: { value: number; students: Array<FormattedStudent['studentNumber']> }[][] = limits.map(() =>
timeSlots.map(() => ({
y: 0,
custom: { students: [] },
value: 0,
students: [],
}))
)
const studentCredits = students.map(student => splitStudentCredits(student, timeSlots, cumulative))
timeSlots.forEach((slot, timeSlotIndex) => {
students
.map((student, i) => [student, i])
.forEach(([student, studentIndex]) => {
const hasGraduated = programme && hasGraduatedBeforeDate(student, programme, dayjs(slot.end))
const credits = studentCredits[studentIndex][timeSlotIndex]
students.forEach((student, studentIndex) => {
const hasGraduated = !!programme && hasGraduatedBeforeDate(student, programme, dayjs(slot.end))
const credits = studentCredits[studentIndex][timeSlotIndex]
const rangeIndex = hasGraduated
? limits.indexOf('Graduated')
: limits.findIndex(limit => {
if (limit === 'Graduated') {
return false
}
const rangeIndex = hasGraduated
? limits.indexOf('Graduated')
: limits.findIndex(limit => {
if (limit === 'Graduated') {
return false
}
const [min, max] = limit
const [min, max] = limit
if (min == null) {
return credits < max
}
if (max == null) {
return credits >= min
}
return credits >= min && credits < max
})
if (min == null) {
return credits < max
}
if (max == null) {
return credits >= min
}
return credits >= min && credits < max
})
data[rangeIndex][timeSlotIndex].y += 1
data[rangeIndex][timeSlotIndex].custom.students.push(student.studentNumber)
})
data[rangeIndex][timeSlotIndex].value += 1
data[rangeIndex][timeSlotIndex].students.push(student.studentNumber)
})
})
const series = data.map((slots, limitN) => {
@@ -170,7 +156,7 @@ export const CreditDistributionDevelopment = ({
}: CreditDistributionDevelopmentProps) => {
const [cumulative, setCumulative] = useState(true)
const [timeDivision, setTimeDivision] = useState<TimeDivision>(TimeDivision.SEMESTER)
const [isAscending, setIsAscending] = useState(true)
const [isAscending, setIsAscending] = useState(false)
const { getTextIn } = useLanguage()
const { filterDispatch } = useFilters()
@@ -220,56 +206,112 @@ export const CreditDistributionDevelopment = ({
const labels = timeSlots.map(ts => ts.label)
const bcMsTitle = combinedProgramme === 'MH90_001' ? 'Bachelor + Licentiate' : 'Bachelor + Master'
const title = combinedProgramme ? bcMsTitle : ''
const ascendingSeries = useMemo(() => {
const graduatedSeries = series.find(seriesItem => seriesItem.name === 'Graduated')
const nonGraduatedSeries = series.filter(seriesItem => seriesItem.name !== 'Graduated')
return graduatedSeries ? [...nonGraduatedSeries, graduatedSeries] : nonGraduatedSeries
}, [series])
const config: Options = {
series: isAscending ? series : series.toReversed(),
title: { text: title },
tooltip: {
formatter() {
// Highcharts formatter only accepts a subset of HTML as a string
// eslint-disable-next-line react/no-this-in-sfc
return `<div style="text-align: center; width: 100%"><b>${this.x}</b>, ${this.series.name}<br/>${this.y}/${this.total} students (${Math.round(this.percentage)}%)</div>`
},
},
xAxis: {
categories: labels,
},
yAxis: {
allowDecimals: false,
min: 0,
max: filteredStudents.length,
endOnTick: false,
reversed: false,
title: { text: 'Students' },
},
chart: {
type: 'column',
height: '450px',
},
plotOptions: {
column: {
stacking: 'normal',
dataLabels: {
enabled: true,
const displayedSeries = useMemo(
() => (isAscending ? ascendingSeries : ascendingSeries.toReversed()),
[isAscending, ascendingSeries]
)
const option = useMemo(
() => ({
title: title
? {
text: title,
left: 'center',
}
: undefined,
tooltip: {
trigger: 'item',
formatter: (params: { name?: string; seriesName?: string; value?: unknown; dataIndex?: number }) => {
const value =
typeof params.value === 'number'
? params.value
: Number.isFinite(Number(params.value))
? Number(params.value)
: 0
const dataIndex = params.dataIndex ?? 0
const total = displayedSeries.reduce(
(sum, currentSeries) => sum + (currentSeries.data[dataIndex]?.value ?? 0),
0
)
const percentage = total ? Math.round((value / total) * 100) : 0
return `<div style="text-align: left; width: 100%"><b>${params.name ?? ''}</b>, ${params.seriesName ?? ''}<br/>${value}/${total} students (${percentage}%)</div>`
},
},
series: {
cursor: 'pointer',
point: {
events: {
click(event) {
const point = event.point as Highcharts.Point & { custom: { students: number[] } }
filterDispatch(studentNumberFilter.actions.addToAllowlist(point.custom.students))
},
legend: {},
xAxis: {
type: 'category',
data: labels,
},
grid: {
show: false,
top: 60,
left: '15%',
right: '15%',
bottom: 90,
},
toolbox: {
feature: {
saveAsImage: {},
dataView: {
readOnly: true,
},
},
},
},
}
yAxis: {
type: 'value',
min: 0,
max: filteredStudents.length,
minInterval: 1,
name: 'Students',
},
series: displayedSeries.map(seriesItem => ({
name: seriesItem.name,
type: 'bar',
stack: 'students',
data: seriesItem.data,
itemStyle: {
color: seriesItem.color,
},
label: {
show: true,
formatter: (params: { value?: unknown }) => {
const numericValue =
typeof params.value === 'number'
? params.value
: Number.isFinite(Number(params.value))
? Number(params.value)
: 0
return numericValue ? `${numericValue}` : ''
},
},
})),
animation: true,
}),
[title, labels, filteredStudents.length, displayedSeries]
)
const onEvents = useMemo(
() => ({
click: (params: { componentType?: string; data?: { students?: Array<FormattedStudent['studentNumber']> } }) => {
if (params.componentType !== 'series') return
if (!params.data?.students?.length) return
filterDispatch(studentNumberFilter.actions.addToAllowlist(params.data.students))
},
}),
[filterDispatch]
)
return (
<Paper sx={{ p: 2, my: 2 }} variant="outlined">
<Box sx={{ m: 2, width: '100%', display: 'flex', justifyContent: 'center', gap: '5em' }}>
<Box sx={{ mt: 2, mx: 2, width: '100%', display: 'flex', justifyContent: 'center', gap: '5em' }}>
<ToggleContainer>
<FormLabel sx={{ mb: 1 }}>Chart settings</FormLabel>
<Toggle
@@ -282,7 +324,7 @@ export const CreditDistributionDevelopment = ({
firstLabel="Ascending"
secondLabel="Descending"
setValue={() => setIsAscending(prev => !prev)}
value={isAscending}
value={!isAscending}
/>
</ToggleContainer>
<FormControl>
@@ -294,7 +336,7 @@ export const CreditDistributionDevelopment = ({
</RadioGroup>
</FormControl>
</Box>
<ReactHighcharts config={config} />
<ReactECharts notMerge onEvents={onEvents} option={option} replaceMerge={['series']} style={{ height: 450 }} />
</Paper>
)
}
@@ -5,12 +5,11 @@ import FormGroup from '@mui/material/FormGroup'
import Paper from '@mui/material/Paper'
import Stack from '@mui/material/Stack'
import Switch from '@mui/material/Switch'
import Typography from '@mui/material/Typography'
import { useEffect, useState } from 'react'
import { populationStatisticsToolTips } from '@/common/InfoToolTips'
import { Link } from '@/components/common/Link'
import { PanelView } from '@/components/common/PanelView'
import { CreditAccumulationGraphHighCharts } from '@/components/CreditAccumulationGraphHighCharts'
import {
hopsFilter as studyPlanFilter,
hopsFilter,
@@ -28,6 +27,7 @@ import { getFullStudyProgrammeRights } from '@/util/access'
import { FilteredCourse } from '@/util/coursesOfPopulation'
import { FormattedStudent } from '@oodikone/shared/types'
import { CreditAccumulationGraph } from '../Charts/CreditAccumulation'
import { AdvancedSettings } from './AdvancedSettings'
import { AgeStats } from './AgeStats'
import { CourseTableModeSelector } from './CourseTableModeSelector'
@@ -88,20 +88,16 @@ export const PopulationDetails = ({
const panels = [
{
title: `Credit accumulation (for ${filteredStudents.length} students)`,
content: (
<CreditAccumulationGraphHighCharts
absences={null}
endDate={null}
infoBoxContent={populationStatisticsToolTips.creditAccumulation}
programmeCodes={[programme, combinedProgramme].filter(Boolean)}
selectedStudyPlan={null}
content: filteredStudents.length ? (
// TODO: make wrapper for when no data.
<CreditAccumulationGraph
programmeCodes={[programme, combinedProgramme].filter(c => c !== undefined)}
showBachelorAndMaster={!!showBachelorAndMaster}
singleStudent={false}
startDate={null}
students={filteredStudents}
studyPlanFilterIsActive={studyPlanFilterIsActive}
studyRightId={null}
studyPlanFilter={studyPlanFilterIsActive}
/>
) : (
<Typography>Nothing to show!</Typography>
),
},
query.years.length <= 1
@@ -153,35 +153,11 @@ export const useGetColumnDefinitions = (modules: Map<string, CourseTabModule>):
if (!correctCourse) return null
const sub = correctCourse.substitutedBy
const substitutePrefix = sub ? `Substituting course: ${sub}\n` : ''
if (correctCourse.grade) {
const isTransferred = correctCourse.credittypecode === CreditTypeCode.APPROVED
const title =
substitutePrefix +
(isTransferred
? `Grade: ${correctCourse.grade}\nTransferred on: ${formatDate(correctCourse.completionDate, dateFormat)}`
: `Grade: ${correctCourse.grade}\nCompleted on: ${formatDate(correctCourse.completionDate, dateFormat)}`)
if (correctCourse.enrollmentDate) {
const subPrefix = sub ? `Substituted by: ${sub?.map(course => course.course_code)?.join(', ')}\n` : ''
return (
<div title={title}>
{isTransferred ? (
<SwapHorizIcon
sx={{ color: sub ? theme.palette.ooditable.hops : theme.palette.ooditable.success }}
/>
) : (
<CheckIcon
sx={{ color: sub ? theme.palette.ooditable.hops : theme.palette.ooditable.success }}
/>
)}
</div>
)
} else if (correctCourse.enrollmentDate) {
return (
<div
title={
substitutePrefix + `Last enrollment ${formatDate(correctCourse.enrollmentDate, dateFormat)}`
}
>
<div title={`${subPrefix}Last enrollment ${formatDate(correctCourse.enrollmentDate, dateFormat)}`}>
<RemoveIcon
sx={{
color: isWithinSixMonths(correctCourse.enrollmentDate)
@@ -192,11 +168,46 @@ export const useGetColumnDefinitions = (modules: Map<string, CourseTabModule>):
</div>
)
} else if (correctCourse.inHops) {
const subString = sub
? `Substituted by: ${sub?.map(course => course.course_code).join(', ')}\n\n`
: ''
return (
<div title={substitutePrefix + 'In primary studyplan'}>
<div title={`${subString}In primary studyplan`}>
<CropSquareIcon sx={{ color: theme.palette.ooditable.hops }} />
</div>
)
} else if (correctCourse.grade) {
// NOTE: Contains only normally-passed-courses because substitutions don't have a grade field
const isTransferred = correctCourse.credittypecode === CreditTypeCode.APPROVED
const title = isTransferred
? `Grade: ${correctCourse.grade}\nTransferred on: ${formatDate(correctCourse.completionDate, dateFormat)}`
: `Grade: ${correctCourse.grade}\nCompleted on: ${formatDate(correctCourse.completionDate, dateFormat)}`
return (
<div title={title}>
{isTransferred ? (
<SwapHorizIcon sx={{ color: theme.palette.ooditable.success }} />
) : (
<CheckIcon sx={{ color: theme.palette.ooditable.success }} />
)}
</div>
)
} else if (sub) {
const subStringPrefix = `Substituted by:\n\n`
const subString =
subStringPrefix +
sub
.map(
course =>
`${course.course_code}\nGrade: ${course.grade}\nCompleted on: ${formatDate(course.date, dateFormat)}`
)
.join('\n\n')
return (
<div title={subString}>
<CheckIcon sx={{ color: theme.palette.ooditable.hops }} />
</div>
)
}
return null
},
@@ -7,7 +7,7 @@ import { useToggle } from '@/hooks/toggle'
import { ExtendedCurriculumDetails } from '@/hooks/useCurriculums'
import { FilteredCourse } from '@/util/coursesOfPopulation'
import { CreditTypeCode, Name, ProgrammeCourse } from '@oodikone/shared/types'
import { FormattedStudent } from '@oodikone/shared/types/studentData'
import { FormattedStudent, StudentCourse } from '@oodikone/shared/types/studentData'
import { IncludeSubstitutionsToggle } from '../../IncludeSubstitutionsToggle'
import { useGetColumnDefinitions } from './columnDefinitions'
@@ -22,7 +22,7 @@ export type Courses = Record<
{
grade?: string
completionDate?: string
substitutedBy?: string
substitutedBy?: StudentCourse[]
inHops?: boolean
enrollmentDate?: Date
passed?: boolean
@@ -48,11 +48,12 @@ const studentMapper = (
student: FormattedStudent,
includeSubstitutions: boolean,
curriculumCourseCodes: string[],
substituteToPrimaryCodes: Record<string, string[]>
substitutionsForCourseCode: Record<string, string[][]>
) => {
const courseMap = {}
// NB: there can be many attainments/enrollments for each course code
// All passed courses that are included in curriculum
const passedCourses = student.courses.filter(
course => curriculumCourseCodes.includes(course.course_code) && course.passed
)
@@ -61,74 +62,152 @@ const studentMapper = (
studyPlan.included_courses.filter(code => curriculumCourseCodes.includes(code))
)
const passedSubstituteCourses = student.courses.filter(course => !!substituteToPrimaryCodes[course.course_code])
const substituteEnrollments = student.enrollments.filter(
enrollment => !!substituteToPrimaryCodes[enrollment.course_code]
)
const substituteHopsItems = student.studyplans.flatMap(studyPlan =>
studyPlan.included_courses.filter(code => !!substituteToPrimaryCodes[code])
// All passed courses including random AY-codes etc
const allPassedCourseCodes = student.courses.filter(course => course.passed).map(course => course.course_code)
const substitutionsToCurriculumCourses = curriculumCourseCodes.reduce<Record<string, string[][]>>((acc, code) => {
const substitutionsToCurriculumCourse = substitutionsForCourseCode[code]
if (substitutionsToCurriculumCourse) {
acc[code] = substitutionsToCurriculumCourse
}
return acc
}, {})
// curriculumCourseCode -> passedSubstGroup
const passedSubstitutionsToCurriculumCourses = Object.keys(substitutionsToCurriculumCourses).reduce<
Record<string, StudentCourse[]>
>((acc, code) => {
const passedSubstitutionGroups = substitutionsToCurriculumCourses[code].filter(substGroup =>
substGroup.every(sgCode => allPassedCourseCodes.includes(sgCode))
)
// TODO: Implement better logic to select the most optimal substitution_groups, now we select shortest and first group
// Also this .find (and at(0)!) should never be undefined because the codes are student's completed courses => they exist under student.courses
const passedSubstitutionGroupCourses = passedSubstitutionGroups
.map(sg => sg.map(code => student.courses.find(course => course.course_code === code)!))
.toSorted((a, b) => b.length - a.length)
.at(0)! // We know that this will exist
if (passedSubstitutionGroups.length) {
acc[code] = passedSubstitutionGroupCourses
}
return acc
}, {})
const enrollmentCodes = enrollments.map(e => e.course_code)
const enrollmentsWithSubstitutions = Object.keys(substitutionsToCurriculumCourses).reduce<
Record<string, FormattedStudent['enrollments']>
>((acc, code) => {
const enrolledSubstitutionGroups = substitutionsToCurriculumCourses[code].filter(substGroup =>
substGroup.every(sgCode => enrollmentCodes.includes(sgCode))
)
// TODO: Same as above
const enrolledSubstitutionGroupCourses = enrolledSubstitutionGroups
.map(sg => sg.map(code => student.enrollments.find(course => course.course_code === code)!))
.toSorted((a, b) => b.length - a.length)
.at(0)! // We know that this will exist
if (enrolledSubstitutionGroupCourses?.length) {
acc[code] = enrolledSubstitutionGroupCourses
}
return acc
}, {})
const hopsItemsWithSubstitutions = Object.keys(substitutionsToCurriculumCourses).reduce<Record<string, string[]>>(
(acc, code) => {
// TODO: Same as above
const hopsSubstitutionGroups = substitutionsToCurriculumCourses[code]
.filter(substGroup => substGroup.every(sgCode => hopsItems.includes(sgCode)))
.toSorted((a, b) => b.length - a.length)
.at(0)!
if (hopsSubstitutionGroups?.length) {
acc[code] = hopsSubstitutionGroups
}
return acc
},
{}
)
const mapCourses = (coursesToAdd: typeof passedCourses, substitutions: boolean) => {
const mapSubstitutionCourses = (coursesToAdd: typeof passedSubstitutionsToCurriculumCourses) => {
for (const [code, substitutionGroup] of Object.entries(coursesToAdd)) {
courseMap[code] ??= {
substitutedBy: substitutionGroup,
exportValue: `Substitutes ${code}`,
}
}
}
const mapCourses = (coursesToAdd: typeof passedCourses) => {
for (const course of coursesToAdd) {
const codes = substitutions ? substituteToPrimaryCodes[course.course_code] : [course.course_code]
codes.forEach(code => {
if (!courseMap[code] || compareCourseGrades(courseMap[code], course)) {
courseMap[code] = {
grade: course.grade,
completionDate: course.date,
passed: course.passed,
substitutedBy: substitutions ? course.course_code : undefined,
credittypecode: course.credittypecode,
exportValue: course.grade,
}
const code = course.course_code
if (!courseMap[code] || compareCourseGrades(courseMap[code], course)) {
courseMap[code] = {
grade: course.grade,
completionDate: course.date,
passed: course.passed,
substitutedBy: undefined,
credittypecode: course.credittypecode,
exportValue: course.grade,
}
})
}
}
}
const mapEnrollments = (enrollmentsToAdd: typeof enrollments, substitutions: boolean) => {
const mapSubstitutionEnrollments = (enrollmentsToAdd: typeof enrollmentsWithSubstitutions) => {
for (const [code, substitutionGroup] of Object.entries(enrollmentsToAdd)) {
courseMap[code] ??= {
substitutedBy: substitutionGroup,
enrollmentDate: substitutionGroup.at(0)!.enrollment_date_time,
exportValue: 'HOPS',
}
}
}
const mapEnrollments = (enrollmentsToAdd: typeof enrollments) => {
for (const enrollment of enrollmentsToAdd) {
const codes = substitutions ? substituteToPrimaryCodes[enrollment.course_code] : [enrollment.course_code]
codes.forEach(code => {
if (
!courseMap[code] ||
(!!courseMap[code].enrollmentDate &&
new Date(courseMap[code].enrollmentDate) < new Date(enrollment.enrollment_date_time))
)
courseMap[code] = {
enrollmentDate: enrollment.enrollment_date_time,
substitutedBy: substitutions ? enrollment.course_code : undefined,
exportValue: 'HOPS',
}
})
}
}
const mapHopsSelections = (selectionsToAdd: typeof hopsItems, substitutions: boolean) => {
for (const selection of selectionsToAdd) {
const codes = substitutions ? substituteToPrimaryCodes[selection] : [selection]
codes.forEach(code => {
courseMap[code] ??= {
inHops: true,
substitutedBy: substitutions ? selection : undefined,
const code = enrollment.course_code
if (
!courseMap[code] ||
(!!courseMap[code].enrollmentDate &&
new Date(courseMap[code].enrollmentDate) < new Date(enrollment.enrollment_date_time))
)
courseMap[code] = {
enrollmentDate: enrollment.enrollment_date_time,
substitutedBy: undefined,
exportValue: 'HOPS',
}
})
}
}
mapCourses(passedCourses, false)
if (includeSubstitutions) mapCourses(passedSubstituteCourses, true)
const mapHopsSelections = (selectionsToAdd: typeof hopsItems) => {
for (const selection of selectionsToAdd) {
const code = selection
courseMap[code] ??= {
inHops: true,
substitutedBy: undefined,
exportValue: 'HOPS',
}
}
}
const mapSubstitutionHopsSelections = (selectionsToAdd: typeof hopsItemsWithSubstitutions) => {
for (const [code, substitutionGroup] of Object.entries(selectionsToAdd)) {
courseMap[code] ??= {
inHops: true,
substitutedBy: substitutionGroup,
exporValue: 'HOPS',
}
}
}
mapCourses(passedCourses)
if (includeSubstitutions) mapSubstitutionCourses(passedSubstitutionsToCurriculumCourses)
mapEnrollments(enrollments)
if (includeSubstitutions) mapSubstitutionEnrollments(enrollmentsWithSubstitutions)
mapHopsSelections(hopsItems)
if (includeSubstitutions) mapSubstitutionHopsSelections(hopsItemsWithSubstitutions)
const totalPassed = Object.keys(courseMap).length
mapEnrollments(enrollments, false)
if (includeSubstitutions) mapEnrollments(substituteEnrollments, true)
mapHopsSelections(hopsItems, false)
if (includeSubstitutions) mapHopsSelections(substituteHopsItems, true)
const totalPlanned = Object.keys(courseMap).length - totalPassed
return {
@@ -163,7 +242,7 @@ const gradeOrdering = ['0', 'Hyl.', 'TT', 'HT', '1', '2', '3', '4', '5', 'Hyv.']
/**
* @returns true if current course (the latter) has the same or better grade than old course
*/
const compareCourseGrades = (previous, current) =>
const compareCourseGrades = (previous: StudentCourse, current: StudentCourse) =>
gradeOrdering.indexOf(previous.grade) <= gradeOrdering.indexOf(current.grade)
export const CoursesTabContainer = ({ curriculum, students, courses }: CoursesTabContainerProps) => {
@@ -182,11 +261,12 @@ export const CoursesTabContainer = ({ curriculum, students, courses }: CoursesTa
const curriculumCourseCodes = useMemo(() => curriculumCourses.map(course => course.code), [curriculumCourses])
const substitutionCourseCodes = useMemo(
// All substitution_groups entries as codes
const substitutionGroupsCourseCodes = useMemo(
() =>
courses
.filter(course => curriculumCourseCodes.includes(course.course.code))
.flatMap(course => course.course.substitutions),
.flatMap(course => course.course.substitution_groups.map(sbGr => sbGr)),
[courses, curriculumCourseCodes]
)
@@ -196,20 +276,19 @@ export const CoursesTabContainer = ({ curriculum, students, courses }: CoursesTa
* g.g. course code TKT20015 -> BSCS2002/TKT10001 in curriculum 2020-2023
* but most of the time there is only one main code
*/
const substituteToPrimaryCodes: Record<string, string[]> = useMemo(
// All substitutionGroups for a given course code => course.substitution_groups
const substitutionsForCourseCode: Record<string, string[][]> = useMemo(
() =>
courses
.filter(course => substitutionCourseCodes.includes(course.course.code))
.reduce((acc, course) => {
const mainCodes = course.course.substitutions.filter(sub => curriculumCourseCodes.includes(sub))
// If no mainCode in curriculum, leave blank as nothing would be shown in course tab anyway
if (mainCodes.length) {
acc[course.course.code] ??= []
acc[course.course.code].push(...mainCodes)
}
return acc
}, {}),
[curriculumCourseCodes, substitutionCourseCodes, courses]
courses.reduce((acc, course) => {
const substitutionGroups = course.course.substitution_groups ?? []
if (substitutionGroups.length) {
acc[course.course.code] ??= []
acc[course.course.code].push(...substitutionGroups.filter(group => group.length))
}
return acc
}, {}),
[substitutionGroupsCourseCodes, courses]
)
const coursesByParentModule = useMemo(() => {
@@ -232,9 +311,9 @@ export const CoursesTabContainer = ({ curriculum, students, courses }: CoursesTa
const formattedStudents = useMemo(
() =>
students.map(student =>
studentMapper(student, includeSubstitutions, curriculumCourseCodes, substituteToPrimaryCodes)
studentMapper(student, includeSubstitutions, curriculumCourseCodes, substitutionsForCourseCode)
),
[students, studentMapper]
[students, studentMapper, includeSubstitutions]
)
const columns = useGetColumnDefinitions(coursesByParentModule)
@@ -168,7 +168,7 @@ export const useFormat = ({
/* BASE COLUMNS */
studentNumber: student.obfuscated ? 'Hidden' : student.studentNumber,
creditsTotal: student.credits,
creditsHops: student.hopsCredits,
creditsHops: programmeDetails?.primaryStudyplan?.completed_credits ?? 0,
creditsCombinedProg: !!combinedProgramme || showBachelorAndMaster ? getCombinedCredits(studentBlob) : null,
creditsSince: getCreditsBetween(
@@ -1,10 +1,8 @@
import dayjs, { extend as dayjsExtend } from 'dayjs'
import isBetween from 'dayjs/plugin/isBetween'
import { populationStatisticsToolTips } from '@/common/InfoToolTips'
import { PanelView } from '@/components/common/PanelView'
import { ToggleWithTooltip } from '@/components/common/toggle/ToggleWithTooltip'
import { CreditAccumulationGraphHighCharts } from '@/components/CreditAccumulationGraphHighCharts'
import { creditDateFilter, hopsFilter as studyPlanFilter } from '@/components/FilterView/filters'
import { useFilters } from '@/components/FilterView/useFilters'
import { AgeStats } from '@/components/PopulationDetails/AgeStats'
@@ -19,6 +17,7 @@ import { useCurriculumState } from '../../hooks/useCurriculums'
import { useColumns as columnsGeneralTab } from './studentColumns'
import { StudyGuidanceGroupPopulationCourses } from './StudyGuidanceGroupPopulationCourses'
import { createAcademicYearStartDate } from './utils'
import { CreditAccumulationGraph } from '../Charts/CreditAccumulation'
dayjsExtend(isBetween)
@@ -73,27 +72,19 @@ export const SingleStudyGuidanceGroupPanels = ({
onChange={toggleCreditDateFilter}
/>
)}
<CreditAccumulationGraphHighCharts
absences={null}
endDate={null}
infoBoxContent={populationStatisticsToolTips.creditAccumulation}
<CreditAccumulationGraph
programmeCodes={group?.tags?.studyProgramme ? [programme, combinedProgramme] : []}
selectedStudyPlan={null}
showBachelorAndMaster={null}
singleStudent={false}
startDate={null}
students={filteredStudents}
studyPlanFilterIsActive={studyPlanFilterIsActive}
studyRightId={null}
studyPlanFilter={studyPlanFilterIsActive}
/>
</>
),
},
(programme || group?.tags?.studyProgramme) && groupYear
? {
title: 'Credit statistics',
content: <CreditStatistics filteredStudents={filteredStudents} query={query} />,
}
title: 'Credit statistics',
content: <CreditStatistics filteredStudents={filteredStudents} query={query} />,
}
: null,
{
title: 'Age distribution',
@@ -285,8 +285,8 @@ export const StudyGuidanceGroupOverview = ({ groups }: { groups: GroupsWithTags[
lisäominaisuuksia.
</p>
<p>
<Link to="/close-to-graduation">Students close to graduation</Link> voit tarkastella ohjausryhmiesi lähellä
valmistumista olevia opiskelijoita (sekä opiskelijoita, joiden tietoihin sinulla on lukuoikeudet).
<Link to="/close-to-graduation">Students close to graduation</Link> näkymässä voit tarkastella ohjausryhmiesi
lähellä valmistumista olevia opiskelijoita (sekä opiskelijoita, joiden tietoihin sinulla on lukuoikeudet).
</p>
</StyledMessage>
<StyledTable data-cy="study-guidance-group-overview-data-table" showCellBorders>
@@ -0,0 +1,10 @@
import Chip from '@mui/material/Chip'
import { SwapHorizIcon } from '@/theme'
export const GroupChip = ({ group }: { group: string[] }) => (
<Chip
icon={<SwapHorizIcon color="primary" fontSize="small" />}
label={group.join(', ')}
sx={{ my: 0.25, width: 'fit-content' }}
/>
)
@@ -28,7 +28,7 @@ export const ExpandableRow = ({
return (
<TableRow>
{yearArray?.map((value, index) => (
<TableCell align="right" key={getKey(value, index)}>
<TableCell align="right" key={getKey(value, index)} sx={{ px: 0.5, py: 0.75 }}>
{index === 0 ? (
<Box alignItems="center" display="flex" justifyContent="left">
<IconButton data-cy={`${cypress}${yearIndex}`} onClick={toggleVisibility} size="small">
@@ -1,61 +0,0 @@
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'
import { theme } from '@/theme'
import { GraphStat } from '@/types/graphStat'
exporting(ReactHighcharts.Highcharts)
exportData(ReactHighcharts.Highcharts)
accessibility(ReactHighcharts.Highcharts)
export const LineGraph = ({
cypress,
exportFileName,
graphStats,
years,
}: {
cypress: string
exportFileName: string
graphStats: GraphStat[]
years: number[]
}) => {
if (!graphStats) {
return null
}
const dataWithColors = graphStats.map((series, index) => ({
...series,
color: theme.palette.graphColors[index],
type: 'line' as const,
}))
const config = {
series: dataWithColors,
exporting: {
filename: exportFileName,
},
chart: {
height: '450px',
},
xAxis: {
categories: years.map(String),
},
yAxis: {
allowDecimals: false,
min: 0,
reversed: false,
title: {
text: '',
},
},
}
return (
<Section cypress={`${cypress}-line-graph`}>
<ReactHighcharts config={config} />
</Section>
)
}
@@ -1,172 +0,0 @@
/* eslint-disable react/no-this-in-sfc */
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 { Section } from '@/components/Section'
import type { GraduationStatistics } from '@oodikone/shared/types'
import { ChartGraduationTimes } from '@oodikone/shared/types/graduations'
exporting(ReactHighcharts.Highcharts)
exportData(ReactHighcharts.Highcharts)
accessibility(ReactHighcharts.Highcharts)
export const MedianTimeBarChart = ({
byStartYear,
data,
goal,
title,
}: {
byStartYear: boolean
data: ChartGraduationTimes['medians'] | undefined
goal: ChartGraduationTimes['goal']
title: string
}) => {
if (!data) {
return null
}
const maxValue = data.reduce((max: number, { y }) => {
return y > max ? y : max
}, goal * 2)
const getPercentage = (amount: number, classSize: number) => {
const percent = Math.round((amount / classSize) * 100 * 10) / 10
return Number.isNaN(percent) ? 0 : percent
}
const getDataLabel = (amount: number, classSize: number) => {
if (byStartYear && title !== 'Bachelor + master study right') {
return `${amount} graduated (${getPercentage(amount, classSize)} % of class)`
}
return `${amount} graduated`
}
const getHeight = () => {
const multiplier = data.length > 8 ? 35 : 55
return data.length * multiplier + 100
}
const getTooltipText = (
amount: number,
y: number,
year: string,
statistics: GraduationStatistics,
classSize: number
) => {
const sortingText =
byStartYear && title !== 'Bachelor + master study right'
? `<b>From class of ${year}, ${amount}/${classSize} students have graduated</b>`
: `<b>${amount} students graduated in year ${year}</b>`
const timeText = `<br />${sortingText}<br /><b>median study time: ${y} months</b><br />`
const statisticsText = `<br />${statistics.onTime} graduated on time<br />${statistics.yearOver} graduated max year overtime<br />${statistics.wayOver} graduated over year late`
return `${timeText}${statisticsText}`
}
const config: Highcharts.Options = {
chart: {
type: 'bar',
width: 700,
margin: [70, 0],
height: getHeight(),
},
title: { text: title },
tooltip: {
backgroundColor: 'white',
formatter() {
const point = this.point as Highcharts.Point & {
amount: number
name: string
statistics: GraduationStatistics
classSize: number
}
return getTooltipText(point.amount, this.y!, point.name, point.statistics, point.classSize)
},
},
plotOptions: {
series: {
dataLabels: {
enabled: true,
inside: true,
overflow: 'allow' as const,
},
},
},
series: [
{
type: 'bar',
data,
dataLabels: {
align: 'left',
color: '#424949',
style: {
textOutline: 'none',
},
formatter() {
const point = this.point as Highcharts.Point & { amount: number; classSize: number }
return getDataLabel(point.amount, point.classSize)
},
},
showInLegend: false,
zones: [
{
value: goal + 0.1,
color: '#90A959',
},
{
value: goal + 12.1,
color: '#FEE191',
},
{
color: '#FB6962',
},
],
},
],
xAxis: {
type: 'category',
title: {
text: byStartYear ? 'Start year' : 'Graduation year',
align: 'high',
offset: 0,
rotation: 0,
y: -10,
},
},
yAxis: {
min: 0,
max: maxValue,
title: { text: 'Graduation time (months)' },
labels: {
overflow: 'justify',
},
allowDecimals: false,
showFirstLabel: false,
plotLines: [
{
color: '#90A959',
width: 2,
value: goal,
dashStyle: 'ShortDash',
},
{
color: '#FEE191',
width: 2,
value: goal + 12,
dashStyle: 'ShortDash',
},
],
},
}
return (
<Section cypress={`${title.split(' ')[0].toLowerCase()}-median-time-bar-chart`}>
<ReactHighcharts config={config} />
</Section>
)
}
@@ -1,76 +0,0 @@
import { OptionsStackingValue, SeriesColumnOptions } from 'highcharts'
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'
import { generateGradientColors } from '@/util/color'
exporting(ReactHighcharts.Highcharts)
exportData(ReactHighcharts.Highcharts)
accessibility(ReactHighcharts.Highcharts)
export const ProgressBarChart = ({
cypress,
data,
}: {
cypress: string
data: {
id: string
stats: {
data: number[]
name: string
}[]
years: string[]
}
}) => {
if (!data.stats) {
return null
}
const colors = generateGradientColors(Object.keys(data.stats).length - 1)
colors.push('#ddd') // For graduated students
const dataWithColors: SeriesColumnOptions[] = Object.values(data.stats).map((series, index) => ({
...series,
color: colors[index],
type: 'column',
}))
const config = {
series: dataWithColors,
xAxis: {
categories: data?.years,
},
chart: {
type: 'column',
height: '450px',
},
plotOptions: {
column: {
stacking: 'percent' as OptionsStackingValue,
dataLabels: {
enabled: true,
format: '{point.percentage:.1f}%',
},
},
},
exporting: {
filename: `oodikone_progress_of_students_in_${data?.id}_by_starting_year`,
},
yAxis: {
allowDecimals: false,
min: 0,
reversed: false,
title: {
text: '',
},
},
}
return (
<Section cypress={`${cypress}-progress-bar-chart`}>
<ReactHighcharts config={config} />
</Section>
)
}
@@ -1,79 +0,0 @@
/* eslint-disable react/no-this-in-sfc */
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 { Section } from '@/components/Section'
import { theme } from '@/theme'
exporting(ReactHighcharts.Highcharts)
exportData(ReactHighcharts.Highcharts)
accessibility(ReactHighcharts.Highcharts)
export const StackedBarChart = ({
cypress,
data,
labels,
exportFileName = 'chart',
}: {
cypress: string
data?: { data: number[]; name: string }[]
labels?: string[]
exportFileName: string
}) => {
if (!data) {
return null
}
const dataWithColors = data?.map((series, index) => ({
...series,
color: theme.palette.graphColors[index],
type: 'column' as const,
}))
const config: HighCharts.Options = {
series: dataWithColors,
xAxis: {
categories: labels,
allowDecimals: false,
},
chart: {
type: 'column',
height: '450px',
},
exporting: {
filename: exportFileName,
},
plotOptions: {
column: {
stacking: 'normal',
dataLabels: {
enabled: true,
formatter() {
if (Number.isInteger(this.y)) return `${this.y}`
return `${this.y?.toFixed(1)}`
},
},
},
},
tooltip: {
pointFormat: '<b>{series.name}: {point.percentage:.1f} %</b>',
},
yAxis: {
allowDecimals: false,
min: 0,
reversed: false,
title: {
text: '',
},
},
}
return (
<Section cypress={`${cypress}-stacked-bar-chart`}>
<ReactHighcharts config={config} />
</Section>
)
}
@@ -1,8 +1,27 @@
import Alert from '@mui/material/Alert'
import AlertTitle from '@mui/material/AlertTitle'
import type { SxProps } from '@mui/material/styles'
export const StyledMessage = ({ children, sx: propSx }: { children: React.ReactNode; sx?: SxProps }) => (
<Alert icon={false} severity="info" sx={{ margin: 'auto', maxWidth: '800px', ...propSx }} variant="outlined">
export const StyledMessage = ({
children,
variant = 'outlined',
sx: propSx,
showIcon,
title,
}: {
children: React.ReactNode
variant?: 'outlined' | 'standard' | 'filled'
sx?: SxProps
showIcon?: boolean
title?: string
}) => (
<Alert
icon={showIcon ? null : false} // Null keeps icon enabled (default behaviour)
severity="info"
sx={{ margin: 'auto', maxWidth: '800px', ...propSx }}
variant={variant}
>
{title?.length ? <AlertTitle>{title}</AlertTitle> : null}
{children}
</Alert>
)
@@ -6,13 +6,34 @@ import Table from '@mui/material/Table'
* To be paired with StyledCell
*/
export const StyledTable = styled(Table, {
shouldForwardProp: prop => prop !== 'showCellBorders' && prop !== 'zebraStriped',
})<{ showCellBorders?: true; zebraStriped?: true }>(({ theme, showCellBorders, zebraStriped }) => {
shouldForwardProp: prop =>
prop !== 'showCellBorders' && prop !== 'zebraStriped' && prop !== 'slimHeader' && prop !== 'slimBody',
})<{ showCellBorders?: true; zebraStriped?: true; slimHeader?: true; slimBody?: true }>(({
theme,
showCellBorders,
zebraStriped,
slimHeader,
slimBody,
}) => {
return {
'&': {
border: `1px solid ${theme.palette.grey[300]}`,
},
'& .MuiTableHead-root .MuiTableCell-root': slimHeader
? {
paddingTop: '0.5em',
paddingBottom: '0.5em',
}
: {},
'& .MuiTableBody-root .MuiTableCell-root': slimBody
? {
paddingTop: '0.5em',
paddingBottom: '0.5em',
}
: {},
'& .MuiTableHead-root': {
backgroundColor: theme.palette.grey[100],
border: `1px solid ${theme.palette.grey[300]}`,
@@ -1,11 +1,12 @@
import Chip from '@mui/material/Chip'
import { StarIcon, SwapHorizIcon } from '@/theme'
import { GroupChip } from '@/components/common/EquivalenceGroupChip'
import { StarIcon } from '@/theme'
export const PrimaryCourseLabel = ({ code, name }: { code: string; name: string }) => (
<Chip color={'primary'} icon={<StarIcon fontSize="small" />} label={`${code} • ${name}`} />
)
export const SecondaryCourseLabel = ({ code, name }: { code: string; name: string }) => (
<Chip icon={<SwapHorizIcon fontSize="small" />} label={`${code} • ${name}`} />
<GroupChip group={[`${code} • ${name}`]} />
)
@@ -1,18 +1,30 @@
import useTheme from '@mui/material/styles/useTheme'
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 ReactECharts from 'echarts-for-react'
import { TotalsDisclaimer } from '@/components/common/TotalsDisclaimer'
import { ProgrammeStats, ViewMode } from '@/types/courseStat'
import { getGradeSpread, getSeriesType, getThesisGradeSpread } from '../util'
import { absoluteToRelative, getDataObject, getGraphOptions, getMaxValueOfSeries } from './util'
import { absoluteToRelative, getDataObject, getMaxValueOfSeries } from './util'
exporting(ReactHighcharts.Highcharts)
exportData(ReactHighcharts.Highcharts)
accessibility(ReactHighcharts.Highcharts)
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(2)}`
}
const getNumericValue = (value: unknown) => {
const numericValue = typeof value === 'number' ? value : Number(value)
return Number.isFinite(numericValue) ? numericValue : 0
}
const calculateSumAll = (newSeries: Record<string, number[]>) => {
return Object.values(newSeries)[0].map((_, index) =>
@@ -100,19 +112,83 @@ export const GradeDistributionChart = ({
const seriesType = getSeriesType(grades)
const maxGradeValue = isRelative ? 100 : getMaxValueOfSeries(series.absolute)
const graphOptions = getGraphOptions(
colors[seriesType],
colors[seriesType],
isRelative,
maxGradeValue,
statYears,
`Grades for group ${data.name}`,
viewMode
)
const chartSeries = isRelative ? series.relative : series.absolute
const chartColors = colors[seriesType]
const yAxisTitle = isRelative ? `Share of ${viewMode.toLowerCase()}` : `Number of ${viewMode.toLowerCase()}`
const option = {
color: chartColors,
title: {
text: `Grades for group ${data.name}`,
left: 'center',
},
toolbox: {
feature: {
dataView: {
readOnly: true,
},
},
},
tooltip: {
trigger: 'item',
formatter: (params: { seriesName?: string; value?: unknown; dataIndex?: number; name?: string }) => {
const value = getNumericValue(params.value)
const category = params.name ?? ''
const categoryLine = category ? `<b>${category}</b><br/>` : ''
if (isRelative) {
const dataIndex = params.dataIndex ?? 0
const total = chartSeries.reduce((sum, seriesItem) => sum + getNumericValue(seriesItem.data[dataIndex]), 0)
const percentage = total ? (value / total) * 100 : 0
return `${categoryLine}<b>${params.seriesName ?? ''}</b>: ${formatNumber(percentage)}%`
}
return `${categoryLine}<b>${params.seriesName ?? ''}</b>: ${formatNumber(value)}`
},
},
legend: {
bottom: 0,
},
grid: {
top: 60,
left: 10,
right: 10,
bottom: 60,
containLabel: true,
},
xAxis: {
type: 'category',
data: statYears,
},
yAxis: {
type: 'value',
min: 0,
max: isRelative ? 100 : maxGradeValue,
minInterval: 1,
name: yAxisTitle,
axisLabel: {
margin: 50,
formatter: isRelative ? '{value}%' : '{value}',
},
},
series: chartSeries.map((seriesItem, index) => ({
name: seriesItem.name,
type: 'bar',
stack: seriesItem.stack,
data: seriesItem.data,
itemStyle: {
borderRadius: 1,
color: chartColors[index],
},
emphasis: {
focus: 'series',
},
})),
}
return (
<div>
<ReactHighcharts config={{ ...graphOptions, series: isRelative ? series.relative : series.absolute }} />
<ReactECharts notMerge option={option} opts={{ renderer: 'svg' }} style={{ height: 450 }} />
<TotalsDisclaimer shownAsZero userHasAccessToAllStats={userHasAccessToAllStats} />
</div>
)
@@ -1,17 +1,29 @@
import useTheme from '@mui/material/styles/useTheme'
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 ReactECharts from 'echarts-for-react'
import { TotalsDisclaimer } from '@/components/common/TotalsDisclaimer'
import { FormattedStats, ProgrammeStats, ViewMode } from '@/types/courseStat'
import { absoluteToRelative, getDataObject, getGraphOptions, getMaxValueOfSeries } from './util'
import { absoluteToRelative, getDataObject } from './util'
exporting(ReactHighcharts.Highcharts)
exportData(ReactHighcharts.Highcharts)
accessibility(ReactHighcharts.Highcharts)
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(2)}`
}
const getNumericValue = (value: unknown) => {
const numericValue = typeof value === 'number' ? value : Number(value)
return Number.isFinite(numericValue) ? numericValue : 0
}
const getPassRateSeries = (stats: FormattedStats[], viewMode: ViewMode) => {
const all: number[] = []
@@ -73,21 +85,83 @@ export const PassRateChart = ({
const statYears = stats.map(year => year.name)
const series = getPassRateSeries(stats, viewMode)
const maxPassRateVal = isRelative ? 100 : getMaxValueOfSeries(series.absolute)
const chartSeries = isRelative ? series.relative : series.absolute
const chartColors = isRelative ? colorsRelative : colors
const yAxisTitle = isRelative ? `Share of ${viewMode.toLowerCase()}` : `Number of ${viewMode.toLowerCase()}`
const graphOptions = getGraphOptions(
colors,
colorsRelative,
isRelative,
maxPassRateVal,
statYears,
`Pass rate for group ${data.name}`,
viewMode
)
const option = {
color: chartColors,
title: {
text: `Pass rate for group ${data.name}`,
left: 'center',
},
toolbox: {
feature: {
dataView: {
readOnly: true,
},
},
},
tooltip: {
trigger: 'item',
formatter: (params: { seriesName?: string; value?: unknown; dataIndex?: number; name?: string }) => {
const value = getNumericValue(params.value)
const category = params.name ?? ''
const categoryLine = category ? `<b>${category}</b><br/>` : ''
if (isRelative) {
const dataIndex = params.dataIndex ?? 0
const total = chartSeries.reduce((sum, seriesItem) => sum + getNumericValue(seriesItem.data[dataIndex]), 0)
const percentage = total ? (value / total) * 100 : 0
return `${categoryLine}<b>${params.seriesName ?? ''}</b>: ${formatNumber(percentage)}%`
}
return `${categoryLine}<b>${params.seriesName ?? ''}</b>: ${formatNumber(value)}`
},
},
legend: {
bottom: 0,
},
grid: {
top: 60,
left: 10,
right: 10,
bottom: 60,
containLabel: true,
},
xAxis: {
type: 'category',
data: statYears,
},
yAxis: {
type: 'value',
min: 0,
max: isRelative ? 100 : undefined,
minInterval: 1,
name: yAxisTitle,
axisLabel: {
margin: 50,
formatter: isRelative ? '{value}%' : '{value}',
},
},
series: chartSeries.map((seriesItem, index) => ({
name: seriesItem.name,
type: 'bar',
stack: seriesItem.stack,
data: seriesItem.data,
itemStyle: {
borderRadius: 0,
color: chartColors[index],
},
emphasis: {
focus: 'series',
},
})),
}
return (
<div>
<ReactHighcharts config={{ ...graphOptions, series: isRelative ? series.relative : series.absolute }} />
<ReactECharts notMerge option={option} opts={{ renderer: 'svg' }} style={{ height: 450 }} />
<TotalsDisclaimer shownAsZero userHasAccessToAllStats={userHasAccessToAllStats} />
</div>
)
@@ -1,18 +1,30 @@
import { MaterialReactTable, MRT_ColumnDef, useMaterialReactTable } from 'material-react-table'
import { createColumnHelper, TableOptions } from '@tanstack/react-table'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { ExportToExcelDialog } from '@/components/common/MRTExcelExport'
import { TotalsDisclaimer } from '@/components/common/TotalsDisclaimer'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { OodiTable } from '@/components/OodiTable'
import { OodiTableExcelExport } from '@/components/OodiTable/excelExport'
import { CourseSearchState } from '@/pages/CourseStatistics'
import { FormattedStats } from '@/types/courseStat'
import { getDefaultMRTOptions } from '@/util/getDefaultMRTOptions'
import { FormattedStats, ProgrammeStats } from '@/types/courseStat'
import { queryParamsToString } from '@/util/queryparams'
import { getGradeSpread, getThesisGradeSpread, isThesisGrades } from '../util'
import { ObfuscatedCell } from './ObfuscatedCell'
import { TimeCell } from './TimeCell'
import { commonOptions, formatPercentage, getGradeColumns, resolveGrades } from './util'
import { formatPercentage, getGradeColumns, resolveGrades } from './util'
const getTableData = (stats: FormattedStats[], useThesisGrades: boolean) => {
type TableData = {
name: string
code: number
totalAttempts: number
passed: number
failed: number
passRate: string
enrollments?: number
rowObfuscated?: boolean
grades: Record<string, number>
}
const getTableData = (stats: FormattedStats[], useThesisGrades: boolean): TableData[] => {
return stats.map(stat => {
const {
name,
@@ -42,7 +54,7 @@ const getTableData = (stats: FormattedStats[], useThesisGrades: boolean) => {
}
export const AttemptsTable = ({
data: { name, stats },
data: { stats },
separate,
showGrades,
userHasAccessToAllStats,
@@ -50,7 +62,7 @@ export const AttemptsTable = ({
openOrRegular,
alternatives,
}: {
data: { name: string; stats: FormattedStats[] }
data: ProgrammeStats
separate: boolean
showGrades: boolean
userHasAccessToAllStats: boolean
@@ -58,14 +70,10 @@ export const AttemptsTable = ({
openOrRegular: CourseSearchState
alternatives: string[]
}) => {
const { language } = useLanguage()
const [exportModalOpen, setExportModalOpen] = useState(false)
const [exportData, setExportData] = useState<Record<string, unknown>[]>([])
const [columnVisibility, setColumnVisibility] = useState<Record<string, boolean>>({})
const showPopulation = useCallback(
(yearCode: string) => {
(yearCode: number) => {
const queryObject = {
from: yearCode,
to: yearCode,
@@ -81,51 +89,12 @@ export const AttemptsTable = ({
const useThesisGrades = isThesisGrades(stats[0].attempts.grades)
const data = useMemo(() => getTableData(stats, useThesisGrades), [stats, useThesisGrades])
const columns = useMemo<MRT_ColumnDef<any>[]>(
() => [
{
accessorKey: 'name',
header: 'Time',
Cell: ({ cell, row }) => (
<TimeCell
href={showPopulation(row.original.code)}
isEmptyRow={row.original.totalAttempts === 0}
name={cell.getValue<string>()}
userHasAccessToAllStats={userHasAccessToAllStats}
/>
),
sortingFn: (rowA, rowB) => rowB.original.code - rowA.original.code,
},
{
accessorKey: 'totalAttempts',
header: 'Total attempts',
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>()),
},
{
accessorKey: 'passed',
header: 'Passed',
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>() || 0),
},
{
accessorKey: 'failed',
header: 'Failed',
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>() || 0),
},
{
accessorKey: 'passRate',
header: 'Pass rate',
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<string>()),
},
{
accessorKey: 'enrollments',
header: 'Enrollments',
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>()),
},
...getGradeColumns(resolveGrades(stats)),
],
[showPopulation, stats, userHasAccessToAllStats]
)
const excelData = useMemo(() => {
return data.map(({ grades, ...rest }) => ({
...rest,
...Object.fromEntries(Object.entries(grades).map(([key, val]) => [key, val])),
}))
}, [data])
useEffect(() => {
setColumnVisibility(prev => {
@@ -142,51 +111,74 @@ export const AttemptsTable = ({
updatedVisibility.passRate = !showGrades
updatedVisibility.enrollments = !showGrades
return { ...updatedVisibility }
return {
...updatedVisibility,
name: true,
totalAttempts: true,
}
})
}, [showGrades, stats])
const defaultOptions = getDefaultMRTOptions(setExportData, setExportModalOpen, language)
const columnHelper = createColumnHelper<TableData>()
const ooditable_columns = useMemo(
() => [
columnHelper.accessor('name', {
header: 'Time',
cell: ctx => (
<TimeCell
href={showPopulation(ctx.row.original.code)}
isEmptyRow={ctx.row.original.totalAttempts === 0}
name={ctx.getValue()}
userHasAccessToAllStats={userHasAccessToAllStats}
/>
),
sortingFn: (rowA, rowB) => rowB.original.code - rowA.original.code,
}),
columnHelper.accessor('totalAttempts', {
header: 'Total attempts',
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue()),
}),
columnHelper.accessor('passed', {
header: 'Passed',
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue() || 0),
}),
columnHelper.accessor('failed', {
header: 'Failed',
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue() || 0),
}),
columnHelper.accessor('passRate', {
header: 'Pass rate',
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue()),
}),
columnHelper.accessor('enrollments', {
header: 'Enrollments',
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue()),
}),
...getGradeColumns<TableData>(resolveGrades(stats)),
],
[showPopulation, stats, userHasAccessToAllStats]
)
const table = useMaterialReactTable({
...defaultOptions,
...commonOptions,
columns,
data,
const ooditable_table: Partial<TableOptions<TableData>> = {
enableSortingRemoval: false,
initialState: {
...defaultOptions.initialState,
...commonOptions.initialState,
},
state: {
columnVisibility,
columnOrder: [
'name',
'totalAttempts',
'passed',
'failed',
...resolveGrades(stats).map(({ key }) => `grades.${key}`),
'passRate',
'enrollments',
],
sorting: [{ id: 'name', desc: false }],
},
state: { columnVisibility },
onColumnVisibilityChange: setColumnVisibility,
muiTableBodyCellProps: {
...defaultOptions?.muiTableHeadCellProps,
...commonOptions?.muiTableBodyCellProps,
},
})
}
return (
<div>
<ExportToExcelDialog
exportColumns={columns}
exportData={exportData}
featureName={`attempt_statistics_${name}`}
onClose={() => setExportModalOpen(false)}
open={exportModalOpen}
<>
<OodiTable
columns={ooditable_columns}
data={data}
options={ooditable_table}
toolbarContent={
<OodiTableExcelExport data={excelData} exportColumnKeys={ooditable_table.state?.columnOrder ?? []} />
}
/>
<MaterialReactTable table={table} />
<TotalsDisclaimer userHasAccessToAllStats={userHasAccessToAllStats} />
</div>
</>
)
}
@@ -1,18 +1,30 @@
import { MaterialReactTable, MRT_ColumnDef, useMaterialReactTable } from 'material-react-table'
import { createColumnHelper, TableOptions } from '@tanstack/react-table'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { ExportToExcelDialog } from '@/components/common/MRTExcelExport'
import { TableHeaderWithTooltip } from '@/components/common/TableHeaderWithTooltip'
import { TotalsDisclaimer } from '@/components/common/TotalsDisclaimer'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { OodiTable } from '@/components/OodiTable'
import { OodiTableExcelExport } from '@/components/OodiTable/excelExport'
import { CourseSearchState } from '@/pages/CourseStatistics'
import { FormattedStats } from '@/types/courseStat'
import { getDefaultMRTOptions } from '@/util/getDefaultMRTOptions'
import { FormattedStats, ProgrammeStats } from '@/types/courseStat'
import { queryParamsToString } from '@/util/queryparams'
import { ObfuscatedCell } from './ObfuscatedCell'
import { TimeCell } from './TimeCell'
import { commonOptions, formatPercentage, getGradeColumns, resolveGrades } from './util'
import { formatPercentage, getGradeColumns, resolveGrades } from './util'
const getTableData = (stats: FormattedStats[]) => {
type TableData = {
name: string
code: number
totalStudents: number
passed: number
failed: number
enrolledNoGrade?: number
passRate: string
failRate: string
grades: Record<string, number>
rowObfuscated?: boolean
}
const getTableData = (stats: FormattedStats[]): TableData[] => {
return stats.map(stat => ({
name: stat.name,
code: stat.code,
@@ -23,11 +35,12 @@ const getTableData = (stats: FormattedStats[]) => {
passRate: formatPercentage(stat.students.passRate * 100),
failRate: formatPercentage(stat.students.failRate * 100),
grades: stat.students.grades,
rowObfuscated: stat.rowObfuscated,
}))
}
export const StudentsTable = ({
data: { name, stats },
data: { stats },
separate,
showGrades,
userHasAccessToAllStats,
@@ -35,7 +48,7 @@ export const StudentsTable = ({
openOrRegular,
alternatives,
}: {
data: { name: string; stats: FormattedStats[] }
data: ProgrammeStats
separate: boolean
showGrades: boolean
userHasAccessToAllStats: boolean
@@ -43,14 +56,10 @@ export const StudentsTable = ({
openOrRegular: CourseSearchState
alternatives: string[]
}) => {
const { language } = useLanguage()
const [exportModalOpen, setExportModalOpen] = useState(false)
const [exportData, setExportData] = useState<Record<string, unknown>[]>([])
const [columnVisibility, setColumnVisibility] = useState<Record<string, boolean>>({})
const showPopulation = useCallback(
(yearCode: string) => {
(yearCode: number) => {
const queryObject = {
from: yearCode,
to: yearCode,
@@ -65,128 +74,111 @@ export const StudentsTable = ({
)
const data = useMemo(() => getTableData(stats), [stats])
const columns = useMemo<MRT_ColumnDef<any>[]>(
() => [
{
accessorKey: 'name',
header: 'Time',
Cell: ({ cell, row }) => (
<TimeCell
href={showPopulation(row.original.code)}
isEmptyRow={row.original.totalStudents === 0}
name={cell.getValue<string>()}
userHasAccessToAllStats={userHasAccessToAllStats}
/>
),
sortingFn: (rowA, rowB) => rowB.original.code - rowA.original.code,
},
{
accessorKey: 'totalStudents',
header: 'Total students',
Header: (
<TableHeaderWithTooltip
header="Total students"
tooltipText="Total count of students, including enrolled students with no grade"
/>
),
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>()),
},
{
accessorKey: 'passed',
header: 'Passed',
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>() || 0),
},
{
accessorKey: 'failed',
header: 'Failed',
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>() || 0),
},
...getGradeColumns(resolveGrades(stats)),
{
accessorKey: 'enrolledNoGrade',
header: 'Enrolled, no grade',
Header: (
<TableHeaderWithTooltip
header="Enrolled, no grade"
tooltipText="Total count of students with a valid enrollment and no passing or failing grade"
/>
),
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>()),
},
{
accessorKey: 'passRate',
header: 'Pass rate',
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>()),
},
{
accessorKey: 'failRate',
header: 'Fail rate',
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>()),
},
],
[showPopulation, stats, userHasAccessToAllStats]
)
const excelData = useMemo(() => {
return data.map(({ grades, ...rest }) => ({
...rest,
...Object.fromEntries(Object.entries(grades).map(([key, val]) => [key, val])),
}))
}, [data])
useEffect(() => {
setColumnVisibility(prev => {
const updatedVisibility: Record<string, boolean> = { ...prev }
const gradeColumns = resolveGrades(stats).map(({ key }) => `grades.${key}`)
gradeColumns.forEach(key => {
updatedVisibility[key] = showGrades
})
resolveGrades(stats)
.map(({ key }) => `grades.${key}`)
.forEach(key => {
updatedVisibility[key] = showGrades
})
updatedVisibility.passed = !showGrades
updatedVisibility.failed = !showGrades
return { ...updatedVisibility }
return {
...updatedVisibility,
name: true,
totalStudents: true,
enrolledNoGrade: true,
passRate: true,
failRate: true,
}
})
}, [showGrades, stats])
const defaultOptions = getDefaultMRTOptions(setExportData, setExportModalOpen, language)
const columnHelper = createColumnHelper<TableData>()
const ooditable_columns = useMemo(
() => [
columnHelper.accessor('name', {
header: 'Time',
cell: ctx => (
<TimeCell
href={showPopulation(ctx.row.original.code)}
isEmptyRow={ctx.row.original.totalStudents === 0}
name={ctx.getValue()}
userHasAccessToAllStats={userHasAccessToAllStats}
/>
),
sortingFn: (rowA, rowB) => rowB.original.code - rowA.original.code,
}),
columnHelper.accessor('totalStudents', {
header: _ => (
<TableHeaderWithTooltip
header="Total students"
tooltipText="Total count of students, including enrolled students with no grade"
/>
),
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue() || 0),
}),
columnHelper.accessor('passed', {
header: 'Passed',
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue() || 0),
}),
columnHelper.accessor('failed', {
header: 'Failed',
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue() || 0),
}),
...getGradeColumns<TableData>(resolveGrades(stats)),
columnHelper.accessor('enrolledNoGrade', {
header: _ => (
<TableHeaderWithTooltip
header="Enrolled, no grade"
tooltipText="Total count of students with a valid enrollment and no passing or failing grade"
/>
),
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue()),
}),
columnHelper.accessor('passRate', {
header: 'Pass rate',
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue()),
}),
columnHelper.accessor('failRate', {
header: 'Fail rate',
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue()),
}),
],
[showPopulation, stats, userHasAccessToAllStats]
)
const table = useMaterialReactTable({
...defaultOptions,
...commonOptions,
columns,
data,
const ooditable_table: Partial<TableOptions<TableData>> = {
enableSortingRemoval: false,
initialState: {
...defaultOptions.initialState,
...commonOptions.initialState,
},
state: {
columnVisibility,
columnOrder: [
'name',
'totalStudents',
'passed',
'failed',
...resolveGrades(stats).map(({ key }) => `grades.${key}`),
'enrolledNoGrade',
'passRate',
'failRate',
],
sorting: [{ id: 'name', desc: false }],
},
state: { columnVisibility },
onColumnVisibilityChange: setColumnVisibility,
muiTableBodyCellProps: {
...defaultOptions?.muiTableHeadCellProps,
...commonOptions?.muiTableBodyCellProps,
},
})
}
return (
<div>
<ExportToExcelDialog
exportColumns={columns}
exportData={exportData}
featureName={`student_statistics_${name}`}
onClose={() => setExportModalOpen(false)}
open={exportModalOpen}
<>
<OodiTable
columns={ooditable_columns}
data={data}
options={ooditable_table}
toolbarContent={
<OodiTableExcelExport data={excelData} exportColumnKeys={ooditable_table.state?.columnOrder ?? []} />
}
/>
<MaterialReactTable table={table} />
<TotalsDisclaimer userHasAccessToAllStats={userHasAccessToAllStats} />
</div>
</>
)
}
@@ -1,3 +1,4 @@
import { ColumnDef } from '@tanstack/react-table'
import { flatten } from 'lodash-es'
import { FAILED_GRADES, NUMERIC_GRADES, OTHER_PASSED_GRADES } from '@/constants/grades'
@@ -8,14 +9,15 @@ export const formatPercentage = (rate: number) => {
return Number.isNaN(rate) ? '–' : `${rate.toFixed(2)} %`
}
export const getGradeColumns = (grades: { key: string; title: string }[]) => {
return grades.map(({ key, title }) => ({
export const getGradeColumns = <T extends { grades; rowObfuscated?: boolean }>(
grades: { key: string; title: string }[]
): ColumnDef<T>[] =>
grades.map(({ key, title }) => ({
id: `grades.${key}`,
accessorFn: row => row.grades[key],
header: title,
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : (cell.getValue() ?? 0)),
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : (ctx.getValue() ?? 0)),
}))
}
const gradesOrder = {
0: 0,
@@ -73,19 +75,3 @@ export const resolveGrades = (stats: FormattedStats[]) => {
const grades = [...new Set(allGrades)]
return getSortedGrades(grades)
}
export const commonOptions = {
defaultColumn: { size: 0 },
enableColumnFilters: false,
enableColumnOrdering: false,
enableDensityToggle: false,
enableGlobalFilter: false,
enableHiding: false,
initialState: {
sorting: [{ id: 'name', desc: false }],
showColumnFilters: false,
},
muiTableBodyCellProps: {
align: 'right' as const,
},
}
@@ -1,24 +1,26 @@
import IconButton from '@mui/material/IconButton'
import Table from '@mui/material/Table'
import Stack from '@mui/material/Stack'
import TableBody from '@mui/material/TableBody'
import TableCell from '@mui/material/TableCell'
import TableContainer from '@mui/material/TableContainer'
import TableHead from '@mui/material/TableHead'
import TableRow from '@mui/material/TableRow'
import Typography from '@mui/material/Typography'
import { memo } from 'react'
import { GroupChip } from '@/components/common/EquivalenceGroupChip'
import { StyledTable } from '@/components/common/StyledTable'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { DeleteIcon } from '@/theme'
import { SearchResultCourse } from '@/types/api/courses'
import { getActiveYears } from '../util'
const CourseTable = ({
export const CourseTable = ({
combineSubstitutions,
courses,
hidden,
onSelectCourse,
title,
}: {
combineSubstitutions: boolean
courses: any[]
hidden: boolean
onSelectCourse: (course: any) => void
@@ -29,62 +31,67 @@ const CourseTable = ({
const getEmptyListRow = () => (
<TableRow>
<TableCell align="center" colSpan={2}>
<TableCell align="center" colSpan={3}>
No results.
</TableCell>
</TableRow>
)
const toCourseRow = course => (
<TableRow
data-cy={`course-${course.code}`}
hover
key={course.id}
onClick={() => (course.min_attainment_date ? onSelectCourse(course) : null)}
style={{ cursor: course.min_attainment_date ? 'pointer' : 'default' }}
>
<TableCell>
<Typography variant="subtitle1">{getTextIn(course.name)}</Typography>
<Typography color="text.secondary" variant="body2">
{getActiveYears(course)}
</Typography>
</TableCell>
<TableCell>{[course.code, ...course.substitutions].join(', ')}</TableCell>
{title === 'Selected courses' && (
<TableCell align="right">
<IconButton>
<DeleteIcon />
</IconButton>
const toCourseRow = (course: SearchResultCourse) => {
return (
<TableRow
data-cy={`course-${course.code}`}
hover
key={course.id}
onClick={() => (course.min_attainment_date ? onSelectCourse(course) : null)}
style={{ cursor: course.min_attainment_date ? 'pointer' : 'default' }}
>
<TableCell>
<Typography variant="subtitle1">{getTextIn(course.name)}</Typography>
<Typography color="text.secondary" variant="body2">
{getActiveYears(course)}
</Typography>
</TableCell>
)}
</TableRow>
)
<TableCell>
<Typography fontSize="0.9rem">{course.code}</Typography>
</TableCell>
<TableCell>
{combineSubstitutions ? (
<Stack>
{course.substitution_groups?.map(group => <GroupChip group={group} key={group.join(':')} />) ?? (
<Typography fontSize="0.9rem">Equivalent groups not available!</Typography>
)}
</Stack>
) : (
course.code
)}
</TableCell>
{title === 'Selected courses' && (
<TableCell align="right">
<IconButton>
<DeleteIcon />
</IconButton>
</TableCell>
)}
</TableRow>
)
}
if (hidden) {
return null
}
return (
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>{title}</TableCell>
<TableCell>Code(s)</TableCell>
{title === 'Selected courses' && <TableCell />}
</TableRow>
</TableHead>
<TableBody>{noContent ? getEmptyListRow() : courses.map(toCourseRow)}</TableBody>
</Table>
</TableContainer>
<StyledTable>
<TableHead>
<TableRow>
<TableCell>{title}</TableCell>
<TableCell>Main course code</TableCell>
<TableCell>Equivalent groups</TableCell>
{title === 'Selected courses' && <TableCell />}
</TableRow>
</TableHead>
<TableBody>{noContent ? getEmptyListRow() : courses.map(toCourseRow)}</TableBody>
</StyledTable>
)
}
const areEqual = (prevProps, nextProps) => {
if (prevProps.courses.length !== nextProps.courses.length) {
return false
}
return prevProps.courses.every(c1 => nextProps.courses.some(c2 => c1.code === c2.code))
}
export const MemoizedCourseTable = memo(CourseTable, areEqual)
@@ -21,9 +21,10 @@ import { useSearchHistory } from '@/hooks/searchHistory'
import { useToggle } from '@/hooks/toggle'
import { useGetCourseSearchResultQuery } from '@/redux/courseSearch'
import { AddIcon, SearchIcon } from '@/theme'
import { SearchResultCourse } from '@/types/api/courses'
import { SearchHistoryItem } from '@/types/searchHistory'
import { queryParamsToString } from '@/util/queryparams'
import { MemoizedCourseTable as CourseTable } from './CourseTable'
import { CourseTable } from './CourseTable'
import { FetchStatisticsButton } from './FetchStatisticsButton'
import { MultipleCoursesAlert } from './MultipleCoursesAlert'
@@ -37,7 +38,7 @@ export const SearchForm = () => {
const [courseCode, setCourseCode] = useState('')
const [debouncedCourseName, setDebouncedCourseName] = useDebouncedState('')
const [debouncedCourseCode, setDebouncedCourseCode] = useDebouncedState('')
const [selectedCourses, setSelectedCourses] = useState({})
const [selectedCourses, setSelectedCourses] = useState<Record<string, SearchResultCourse>>({})
const isInputValid = validateInputLength(courseName, 5) || validateInputLength(courseCode, 2)
const isDebouncedInputValid =
@@ -49,7 +50,6 @@ export const SearchForm = () => {
{
name: debouncedCourseName,
code: debouncedCourseCode,
combineSubstitutions,
includeSpecial: includeSpecialCourses,
},
{ skip: !isDebouncedInputValid }
@@ -66,7 +66,7 @@ export const SearchForm = () => {
setDebouncedCourseCode(event.target.value)
}
const onSelectCourse = course => {
const onSelectCourse = (course: SearchResultCourse) => {
const isSelected = !!selectedCourses[course.code]
if (isSelected) {
@@ -149,7 +149,7 @@ export const SearchForm = () => {
const noSelectedCourses = !selected.length
const addAllCourses = () => {
const newSelectedCourses = courses.reduce((newSelected, course) => {
const newSelectedCourses = courses.reduce<Record<string, SearchResultCourse>>((newSelected, course) => {
newSelected[course.code] = { ...course }
return newSelected
}, {})
@@ -245,6 +245,7 @@ export const SearchForm = () => {
{selected.length > 0 && selectMultipleCourses ? (
<Stack gap={2}>
<CourseTable
combineSubstitutions={combineSubstitutions}
courses={selected}
hidden={noSelectedCourses}
onSelectCourse={onSelectCourse}
@@ -259,6 +260,7 @@ export const SearchForm = () => {
</Stack>
) : null}
<CourseTable
combineSubstitutions={combineSubstitutions}
courses={courses}
hidden={false}
onSelectCourse={onSelectCourse}
@@ -1,19 +1,9 @@
/* eslint-disable react/no-this-in-sfc */
/* eslint-disable-next-line import-x/default */
import HighCharts from 'highcharts'
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 ReactECharts from 'echarts-for-react'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { theme } from '@/theme'
import { NameWithCode } from '@oodikone/shared/types'
exporting(ReactHighcharts.Highcharts)
exportData(ReactHighcharts.Highcharts)
accessibility(ReactHighcharts.Highcharts)
export const CollapsedStackedBar = ({
data,
differenceData,
@@ -30,20 +20,18 @@ export const CollapsedStackedBar = ({
plotLinePlaces: string[][]
}) => {
const { getTextIn } = useLanguage()
const transpose = (matrix: number[][]) => {
return matrix.reduce((prev, next) => next.map((_item, i) => (prev[i] || []).concat(next[i])), [] as number[][])
}
if (names[0] === 'Started studying') {
names[0] += ' (new in faculty)'
}
const dataTranspose = transpose(data)
.map((obj, index) => ({
name: names[index],
data: obj,
color: theme.palette.graphColors[index],
type: 'bar' as const,
}))
.reverse()
const seriesNames = names.map((name, index) =>
index === 0 && name === 'Started studying' ? 'Started studying (new in faculty)' : name
)
const dataTranspose = transpose(data).map((obj, index) => ({
name: seriesNames[index],
data: obj,
color: theme.palette.graphColors[index],
}))
const differenceArray: Record<string, Record<string, number>> = Object.keys(differenceData).reduce(
(programmes, programme) => ({
@@ -51,7 +39,7 @@ export const CollapsedStackedBar = ({
[programme]: differenceData[programme].reduce(
(results, value, currentIndex) => ({
...results,
[names[currentIndex]]: value,
[seriesNames[currentIndex]]: value,
}),
{}
),
@@ -70,19 +58,8 @@ export const CollapsedStackedBar = ({
? plotLinePlaces.map(value => ({
color: '#90A959',
width: 1,
value: Number(value[0]) - 0.5,
dashStyle: 'Solid' as const,
label: {
text: value[1],
style: {
color: 'black',
fontWeight: 'bold',
position: 'absolute',
},
align: 'right' as const,
x: 0,
y: 5,
},
value: Number(value[0]),
label: value[1],
}))
: []
@@ -101,82 +78,181 @@ export const CollapsedStackedBar = ({
return '#7B9FCF'
}
const config: HighCharts.Options = {
chart: {
type: 'bar',
marginTop: 60,
height: getFlexHeight(labels.length),
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
}
const totals = labels.map((_, index) =>
dataTranspose.reduce((sum, series) => sum + getNumericValue(series.data[index]), 0)
)
// Prevents clutter
const labelCutoff = Math.round(Math.max(...totals) * 0.05)
const totalMarkPoints = labels.map((label, index) => ({
coord: [totals[index], label],
value: totals[index],
}))
const series = dataTranspose.map((graphSeries, index) => ({
name: graphSeries.name,
type: 'bar',
stack: 'total',
data: graphSeries.data,
itemStyle: {
color: graphSeries.color,
},
series: dataTranspose,
xAxis: {
categories: labels,
title: {
text: '',
label: {
show: true,
position: 'insideLeft',
formatter: (params: { value?: unknown }) => {
const value = getNumericValue(params.value)
if (value <= labelCutoff) return ''
return formatNumber(value)
},
plotLines: chartPlotLinePlaces,
},
yAxis: {
min: 0,
title: {
text: '',
},
stackLabels: {
enabled: true,
},
labelLayout: {
hideOverlap: true,
},
markPoint:
index === dataTranspose.length - 1
? {
showSymbol: false,
symbolSize: 0,
label: {
show: true,
position: 'right',
distance: 10,
formatter: (params: { value?: unknown }) => `(${formatNumber(params.value)})`,
},
data: totalMarkPoints,
}
: undefined,
}))
const plotLineSeries = chartPlotLinePlaces.length
? [
{
name: 'Plot lines',
type: 'line',
data: [],
silent: true,
tooltip: { show: false },
markLine: {
silent: true,
symbol: 'none',
lineStyle: {
color: '#000',
width: 1,
},
data: chartPlotLinePlaces.map(place => ({
yAxis: place.value,
lineStyle: {
width: 0,
},
label: {
show: true,
formatter: place.label,
position: 'insideEnd',
color: '#000',
fontWeight: 'bold',
distance: 5,
},
})),
},
},
]
: []
const option = {
grid: {
top: 20,
left: 10,
right: 10,
bottom: 75,
show: true,
},
legend: {
layout: 'horizontal',
align: 'left',
x: 20,
verticalAlign: 'top',
y: -10,
floating: true,
backgroundColor: 'white',
borderColor: '#CCC',
borderWidth: 1,
shadow: false,
borderWidth: 0,
data: series.map(item => item.name),
},
tooltip: {
shared: true,
backgroundColor: 'white',
formatter() {
let tooltipString = `<b>${getTextIn(longLabels[this.x!])}</b><br /><p>${this.x} - ${
longLabels[this.x!]?.code
}</p><br />`
const diffArray = differenceArray[this.x!]
this.points?.forEach(point => {
tooltipString += `<span style="color:${point.color as string}">●</span> <b>${point.series.name}: ${point.y}</b>
(<span style="color:${getColor(diffArray[point.series.name])};font-weight:bold">${getCorrectSign(
diffArray[point.series.name]
)}</span>)<br />`
confine: true,
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
backgroundColor: '#fff',
borderColor: '#CCC',
borderWidth: 1,
formatter: (rawParams: unknown) => {
const params = Array.isArray(rawParams) ? rawParams : [rawParams]
const barParams = params.filter(
param =>
(param as { seriesType?: string })?.seriesType === 'bar' &&
(param as { componentType?: string })?.componentType === 'series'
) as Array<{ dataIndex?: number; seriesName?: string; value?: unknown; color?: string }>
const firstPoint = barParams[0]
if (!firstPoint) return ''
const dataIndex = firstPoint.dataIndex ?? 0
const label = labels[dataIndex] ?? ''
const labelInfo = longLabels[label]
let tooltipString = `<b>${getTextIn(labelInfo) ?? ''}</b><p>${label} - ${labelInfo?.code ?? ''}</p>`
const diffArray = differenceArray[label] ?? {}
barParams.forEach(point => {
const value = getNumericValue(point.value)
const seriesName = point.seriesName ?? ''
const diffValue = diffArray[seriesName]
tooltipString += `<span style="color:${point.color ?? '#000'}">●</span> <b>${seriesName}: ${formatNumber(
value
)}</b>`
if (typeof diffValue === 'number') {
tooltipString += ` (<span style="color:${getColor(diffValue)};font-weight:bold">${getCorrectSign(
diffValue
)}</span>)`
}
tooltipString += '<br />'
})
tooltipString += `<b>Total: ${this.points?.reduce((prev, current) => prev + current.y!, 0)}</b>`
const total = barParams.reduce((sum, current) => sum + getNumericValue(current.value), 0)
tooltipString += `<b>Total: ${formatNumber(total)}</b>`
return tooltipString
},
},
plotOptions: {
series: {
stacking: 'normal',
dataLabels: {
enabled: true,
align: 'left',
formatter() {
if (Number.isInteger(this.y)) return `${this.y}`
return `${this.y?.toFixed(1)}`
},
filter: {
property: 'y',
operator: '>',
value: 2,
},
},
},
xAxis: {
type: 'value',
axisLine: { show: true },
axisTick: { show: false },
min: 0,
},
yAxis: {
type: 'category',
data: labels,
inverse: true,
axisLine: {
show: true,
},
z: 10,
},
series: [...series, ...plotLineSeries],
}
if (!dataTranspose) {
return <>No data provided</>
}
return <ReactHighcharts config={config} />
return <ReactECharts option={option} opts={{ renderer: 'svg' }} style={{ height: getFlexHeight(labels.length) }} />
}
@@ -123,7 +123,7 @@ export const InteractiveDataTable = ({
<TableHead>
<TableRow>
{titles.map((title, index) => (
<TableCell key={title}>
<TableCell key={title} sx={{ px: 0, py: 0.5 }}>
<Box alignItems="center" display="flex" justifyContent={index === 0 ? 'left' : 'right'}>
{index === 0 && (
<Box sx={{ marginRight: 1 }}>
@@ -4,8 +4,8 @@ import Stack from '@mui/material/Stack'
import { useState, type Dispatch, type SetStateAction } from 'react'
import { facultyToolTips } from '@/common/InfoToolTips'
import { LineGraph } from '@/components/common/LineGraph'
import { StackedBarChart } from '@/components/common/StackedBarChart'
import { LineGraph } from '@/components/Charts/LineGraph'
import { StackedBarChart } from '@/components/Charts/StackedBarChart'
import { Toggle } from '@/components/common/toggle/Toggle'
import { ToggleContainer } from '@/components/common/toggle/ToggleContainer'
import { Section } from '@/components/Section'
@@ -286,7 +286,7 @@ export const BasicInformationTab = ({
<Stack gap={2}>
<LineGraph
cypress="graduated-of-the-faculty"
exportFileName={`oodikon"graduated-of-the-faculty"_${faculty.code}_${getTimestamp()}`}
exportFileName={`oodikone_graduated-of-the-faculty"_${faculty.code}_${getTimestamp()}`}
graphStats={basics.data.graduationInfo.graphStats}
years={basics.data.years}
/>

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