From f120a141262233cccb6fd813afb0c258d0d900a4 Mon Sep 17 00:00:00 2001 From: axelbjo Date: Thu, 16 Oct 2025 11:17:31 +0200 Subject: [PATCH 1/9] fix(KUI-2067): added mapping and helper function for syllabusValid object --- package-lock.json | 275 +++++++++++++++++++++++- package.json | 2 +- public/js/app/utils-shared/helpers.js | 6 +- server/controllers/choiceOptionsCtrl.js | 2 - server/controllers/memoCtrl.js | 33 ++- server/ladokApi.js | 11 + server/utils/formatLadokData.js | 2 +- server/utils/getValidUntilTerm.js | 52 +++++ server/utils/getValidUntilTerm.test.js | 24 +++ test/unit/memoCtrl.test.js | 25 ++- 10 files changed, 404 insertions(+), 28 deletions(-) create mode 100644 server/utils/getValidUntilTerm.js create mode 100644 server/utils/getValidUntilTerm.test.js diff --git a/package-lock.json b/package-lock.json index 04aa4039..121959ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -304,6 +304,7 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -3307,6 +3308,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -3536,6 +3538,27 @@ "@parcel/watcher-win32-x64": "2.5.0" } }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.0.tgz", + "integrity": "sha512-qlX4eS28bUcQCdribHkg/herLe+0A9RyYC+mm2PXpncit8z5b3nSqGVzMNR3CmtAOgRutiZ02eIJJgP/b1iEFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/@parcel/watcher-darwin-arm64": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.0.tgz", @@ -3557,6 +3580,237 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.0.tgz", + "integrity": "sha512-9rhlwd78saKf18fT869/poydQK8YqlU26TMiNg7AIu7eBp9adqbJZqmdFOsbZ5cnLp5XvRo9wcFmNHgHdWaGYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.0.tgz", + "integrity": "sha512-syvfhZzyM8kErg3VF0xpV8dixJ+RzbUaaGaeb7uDuz0D3FK97/mZ5AJQ3XNnDsXX7KkFNtyQyFrXZzQIcN49Tw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.0.tgz", + "integrity": "sha512-0VQY1K35DQET3dVYWpOaPFecqOT9dbuCfzjxoQyif1Wc574t3kOSkKevULddcR9znz1TcklCE7Ht6NIxjvTqLA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.0.tgz", + "integrity": "sha512-6uHywSIzz8+vi2lAzFeltnYbdHsDm3iIB57d4g5oaB9vKwjb6N6dRIgZMujw4nm5r6v9/BQH0noq6DzHrqr2pA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.0.tgz", + "integrity": "sha512-BfNjXwZKxBy4WibDb/LDCriWSKLz+jJRL3cM/DllnHH5QUyoiUNEp3GmL80ZqxeumoADfCCP19+qiYiC8gUBjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.0.tgz", + "integrity": "sha512-S1qARKOphxfiBEkwLUbHjCY9BWPdWnW9j7f7Hb2jPplu8UZ3nes7zpPOW9bkLbHRvWM0WDTsjdOTUgW0xLBN1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.0.tgz", + "integrity": "sha512-d9AOkusyXARkFD66S6zlGXyzx5RvY+chTP9Jp0ypSTC9d4lzyRs9ovGf/80VCxjKddcUvnsGwCHWuF2EoPgWjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.0.tgz", + "integrity": "sha512-iqOC+GoTDoFyk/VYSFHwjHhYrk8bljW6zOhPuhi5t9ulqiYq1togGJB5e3PwYVFFfeVgc6pbz3JdQyDoBszVaA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.0.tgz", + "integrity": "sha512-twtft1d+JRNkM5YbmexfcH/N4znDtjgysFaV9zvZmmJezQsKpkfLYJ+JFV3uygugK6AtIM2oADPkB2AdhBrNig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.0.tgz", + "integrity": "sha512-+rgpsNRKwo8A53elqbbHXdOMtY/tAtTzManTWShB5Kk54N8Q9mzNWV7tV+IbGueCbcj826MfWGU3mprWtuf1TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.0.tgz", + "integrity": "sha512-lPrxve92zEHdgeff3aiu4gDOIt4u7sJYha6wbdEZDCDUhtjTsOMiaJzG5lMY4GkWH8p0fMmO2Ppq5G5XXG+DQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -3585,6 +3839,7 @@ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/popperjs" @@ -3696,6 +3951,7 @@ "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4037,8 +4293,7 @@ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.14", @@ -4537,6 +4792,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4628,6 +4884,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5443,6 +5700,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -7360,6 +7618,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -7416,6 +7675,7 @@ "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -10198,6 +10458,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -12611,6 +12872,7 @@ "resolved": "https://registry.npmjs.org/mobx/-/mobx-6.13.7.tgz", "integrity": "sha512-aChaVU/DO5aRPmk1GX8L+whocagUUpBQqoPtJk+cm7UOXUk87J4PeWCh6nNmTTIfEhiR9DI/+FnA8dln/hTK7g==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/mobx" @@ -12958,6 +13220,7 @@ "integrity": "sha512-YX7TSyDukOZ0g+gmzjB6abKu+hTGvO8+8+gIFDsRCU2t8fLV/P2unmt+LGFaIa4y64aX98Qksa97rgz4vMNeLQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "growly": "^1.3.0", "is-wsl": "^2.2.0", @@ -13894,6 +14157,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", @@ -14003,6 +14267,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -14299,6 +14564,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -14311,6 +14577,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -14388,6 +14655,7 @@ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz", "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==", "license": "MIT", + "peer": true, "dependencies": { "@remix-run/router": "1.23.0", "react-router": "6.30.1" @@ -15100,6 +15368,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -16666,6 +16935,7 @@ "integrity": "sha512-7b0dTKR3Ed//AD/6kkx/o7duS8H3f1a4w3BYpIriX4BzIhjkn4teo05cptsxvLesHFKK5KObnadmCHBwGc+51A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -16715,6 +16985,7 @@ "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", diff --git a/package.json b/package.json index 0ac55ae9..3204ba49 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "prepare": "husky install", "start": "bash -c 'cat /KTH_NODEJS; NODE_ENV=production node app.js'", "start-dev": "bash -c 'NODE_ENV=development concurrently --kill-others -n build,app \"npm run build-dev\" \"nodemon app.js\"'", - "test": "cross-env NODE_ENV=test jest --testPathIgnorePatterns=test/e2e", + "test": "cross-env NODE_ENV=test jest --testPathIgnorePatterns=test/e2e --", "test:mockapi": "docker-compose -f test/mock-api/docker-compose.yml up --build --force-recreate", "test-win": "cross-env NODE_ENV=test jest --testPathIgnorePatterns=test/e2e", "test-preview": "NODE_ENV=test jest ./test/unit/PreviewContainerFilledIn.test.js --watch --notify", diff --git a/public/js/app/utils-shared/helpers.js b/public/js/app/utils-shared/helpers.js index 455ad036..f949390f 100644 --- a/public/js/app/utils-shared/helpers.js +++ b/public/js/app/utils-shared/helpers.js @@ -7,9 +7,9 @@ const seasonStr = (language, semesterRaw) => { const isLangANumber = typeof language === 'number' const langIndex = isLangANumber ? language : convertLangToIndex(language) const { extraInfo } = i18n.messages[langIndex] - const termStringAsSeason = `${extraInfo.season[semesterRaw.toString()[4]]}${semesterRaw.toString().slice(0, 4)}` - - return termStringAsSeason + return /^(HT|VT)\d{4}$/.test(semesterRaw) + ? `${semesterRaw.slice(0, 2)} ${semesterRaw.slice(2, 6)}` + : `${extraInfo.season[semesterRaw.toString()[4]]}${semesterRaw.toString().slice(0, 4)}` } export { convertLangToIndex, seasonStr } diff --git a/server/controllers/choiceOptionsCtrl.js b/server/controllers/choiceOptionsCtrl.js index d757b456..03d65746 100644 --- a/server/controllers/choiceOptionsCtrl.js +++ b/server/controllers/choiceOptionsCtrl.js @@ -59,9 +59,7 @@ async function getCourseOptionsPage(req, res, next) { const ladokCourseRounds = await getCourseRoundsData(courseCode, lang, user) const ladokCourseData = await getLadokCourseData(courseCode, lang) - const ladokCourseRoundTerms = formatLadokData(ladokCourseRounds, ladokCourseData) - applicationStore.miniLadokObj = ladokCourseRoundTerms const memoParams = getMemosParams(courseCode, applicationStore.miniLadokObj) diff --git a/server/controllers/memoCtrl.js b/server/controllers/memoCtrl.js index 6a00c001..15782e3a 100644 --- a/server/controllers/memoCtrl.js +++ b/server/controllers/memoCtrl.js @@ -8,9 +8,10 @@ const apis = require('../api') const { getServerSideFunctions } = require('../utils/serverSideRendering') const { getCourseInfo } = require('../kursinfoApi') -const { getLadokCourseData, getLadokCourseSyllabus } = require('../ladokApi') +const { getLadokCourseData, getLadokCourseSyllabus, getLadokCourseSyllabuses } = require('../ladokApi') const { getMemoApiData, changeMemoApiData } = require('../kursPmDataApi') const { getCourseEmployees } = require('../ugRestApi') +const { getValidUntilTerm } = require('../utils/getValidUntilTerm') const serverPaths = require('../server').getPaths() const { browser, server } = require('../configuration') const i18n = require('../../i18n') @@ -29,15 +30,14 @@ const mergeAllData = async ( courseInfoApiData, ladokCourseData, ladokCourseSyllabusData, + syllabusValidUntilTerm, ugEmployeesData, staticData ) => { const memoDataWithDefaults = addDefaultValues(memoApiData) // Source: kursinfo-api - const courseInfoApiValues = { - prerequisites: courseInfoApiData.recommendedPrerequisites, - } + const courseInfoApiValues = { prerequisites: courseInfoApiData.recommendedPrerequisites } // Source: Ladok const ladokCourseValues = { @@ -60,6 +60,10 @@ const mergeAllData = async ( otherRequirementsForFinalGrade: ladokCourseSyllabusData.kursplan.ovrigakravforslutbetyg, ethicalApproach: ladokCourseSyllabusData.kursplan.etisktforhallningssatt, additionalRegulations: ladokCourseSyllabusData.kursplan.faststallande, + syllabusValid: { + validFromTerm: ladokCourseSyllabusData.kursplan.giltigfrom, + validUntilTerm: syllabusValidUntilTerm ?? '', + }, } // Source: UG Admin @@ -70,9 +74,7 @@ const mergeAllData = async ( } // Source: Static data - const staticValues = { - permanentDisability: staticData.permanentDisability, - } + const staticValues = { permanentDisability: staticData.permanentDisability } return { ...memoDataWithDefaults, @@ -105,16 +107,14 @@ async function renderMemoEditorPage(req, res, next) { applicationStore.doSetLanguageIndex(userLang) - applicationStore.setMemoBasicInfo({ - courseCode, - memoEndPoint, - semester, - memoLangAbbr, - }) + applicationStore.setMemoBasicInfo({ courseCode, memoEndPoint, semester, memoLangAbbr }) const courseInfoApiData = await getCourseInfo(courseCode, memoLangAbbr) const ladokCourseData = await getLadokCourseData(courseCode, memoLangAbbr) const ladokCourseSyllabusData = await getLadokCourseSyllabus(courseCode, semester, memoLangAbbr) + + const ladokCourseSyllabusesData = await getLadokCourseSyllabuses(courseCode, semester, memoLangAbbr) + const syllabusValidUntilTerm = getValidUntilTerm(ladokCourseSyllabusesData, ladokCourseSyllabusData) const ugEmployeesData = await getCourseEmployees(memoApiData) const staticData = i18n.messages[memoLangAbbr === 'en' ? 0 : 1].staticMemoBodyByUserLang @@ -123,6 +123,7 @@ async function renderMemoEditorPage(req, res, next) { courseInfoApiData, ladokCourseData, ladokCourseSyllabusData, + syllabusValidUntilTerm, ugEmployeesData, staticData ) @@ -177,8 +178,4 @@ async function updateContentByEndpoint(req, res, next) { } } -module.exports = { - mergeAllData, - renderMemoEditorPage, - updateContentByEndpoint, -} +module.exports = { mergeAllData, renderMemoEditorPage, updateContentByEndpoint } diff --git a/server/ladokApi.js b/server/ladokApi.js index f46e60b3..6a3c1dbc 100644 --- a/server/ladokApi.js +++ b/server/ladokApi.js @@ -70,9 +70,20 @@ async function getLadokCourseSyllabus(courseCode, semester, lang) { } } +async function getLadokCourseSyllabuses(courseCode, semester, lang) { + try { + const courseSyllabuses = await client.getCourseSyllabuses(courseCode, semester, lang) + + return courseSyllabuses + } catch (error) { + throw new Error(error.message) + } +} + module.exports = { getLadokCourseData, getCourseRoundsData, getCourseSchoolCode, getLadokCourseSyllabus, + getLadokCourseSyllabuses, } diff --git a/server/utils/formatLadokData.js b/server/utils/formatLadokData.js index ac9e5bb3..c04d57df 100644 --- a/server/utils/formatLadokData.js +++ b/server/utils/formatLadokData.js @@ -22,7 +22,7 @@ const formatLadokData = (ladokCourseRounds, ladokCourseData) => { const ladokCourseRoundTerms = { course: { - title: ladokCourseData.benamning.name, + title: ladokCourseData.benamning?.name, credits: ladokCourseData.omfattning, creditUnitLabel: ladokCourseData.utbildningstyp?.creditsUnit, creditUnitAbbr: ladokCourseData.utbildningstyp?.creditsUnit.code.toLowerCase(), diff --git a/server/utils/getValidUntilTerm.js b/server/utils/getValidUntilTerm.js new file mode 100644 index 00000000..98d3c38e --- /dev/null +++ b/server/utils/getValidUntilTerm.js @@ -0,0 +1,52 @@ +const SemesterPrefix = { + AUTUMN: 'HT', + SPRING: 'VT', +} + +const getSemesterPrefix = semester => semester.slice(0, 2) +const getSemesterYear = semester => semester.slice(2, 6) + +const getValidUntilTerm = (syllabuses, currentSyllabus) => { + // Sort syllabuses by semester in ascending order + const sorted = syllabuses.sort((a, b) => { + const parse = s => { + const term = s.slice(0, 2) + const year = parseInt(s.slice(2)) + const termOrder = term === 'VT' ? 0 : 1 + return { year, termOrder } + } + + const A = parse(a.kursplan.giltigfrom) + const B = parse(b.kursplan.giltigfrom) + + // First sort by year, then by termOrder + if (A.year !== B.year) return A.year - B.year + return A.termOrder - B.termOrder + }) + // Prevent duplicates + const seen = new Set() + const syllabusesNoValidFromTermDups = sorted.filter(item => { + const key = item.kursplan.giltigfrom + if (seen.has(key)) return false + seen.add(key) + return true + }) + + const index = syllabusesNoValidFromTermDups.indexOf( + syllabusesNoValidFromTermDups.find(syllabus => syllabus.kursplan.giltigfrom === currentSyllabus.kursplan.giltigfrom) + ) + // validUntilTerm will be the term/semester before the next syllabus validFromTerm semester + if (index !== -1 && index < syllabusesNoValidFromTermDups.length - 1) { + const yearOffset = 1 + const nextSyllabusValidFrom = syllabusesNoValidFromTermDups[index + 1].kursplan.giltigfrom + return getSemesterPrefix(nextSyllabusValidFrom) === SemesterPrefix.AUTUMN + ? SemesterPrefix.SPRING + getSemesterYear(nextSyllabusValidFrom) + : SemesterPrefix.AUTUMN + (getSemesterYear(nextSyllabusValidFrom) - yearOffset) + } else { + return undefined + } +} + +module.exports = { + getValidUntilTerm, +} diff --git a/server/utils/getValidUntilTerm.test.js b/server/utils/getValidUntilTerm.test.js new file mode 100644 index 00000000..bb28df32 --- /dev/null +++ b/server/utils/getValidUntilTerm.test.js @@ -0,0 +1,24 @@ +const { getValidUntilTerm } = require('../utils/getValidUntilTerm') + +describe('getValidUntilTerm', () => { + const make = term => ({ kursplan: { giltigfrom: term } }) + + it('returns the term before the next syllabus validFrom date', () => { + const syllabuses = [make('HT2018'), make('VT2020'), make('HT2022'), make('VT2023')] + const result = getValidUntilTerm(syllabuses, make('HT2018')) + expect(result).toBe('HT2019') + }) + + it('deduplicates by giltigfrom', () => { + const syllabuses = [make('HT2018'), make('HT2018'), make('VT2019'), make('VT2019'), make('HT2019')] + + const result = getValidUntilTerm(syllabuses, make('HT2018')) + expect(result).toBe('HT2018') // Next unique is VT2019 + }) + + it('should return undefined if no other syllabuses are present', () => { + const syllabuses = [make('HT2018')] + const result = getValidUntilTerm(syllabuses, make('HT2018')) + expect(result).toBe(undefined) + }) +}) diff --git a/test/unit/memoCtrl.test.js b/test/unit/memoCtrl.test.js index 712f671e..e341b6e4 100644 --- a/test/unit/memoCtrl.test.js +++ b/test/unit/memoCtrl.test.js @@ -7,6 +7,7 @@ jest.mock('../../server/kursinfoApi', () => ({ jest.mock('../../server/ladokApi', () => ({ getLadokCourseData: jest.fn(), getLadokCourseSyllabus: jest.fn(), + getLadokCourseSyllabuses: jest.fn(), })) jest.mock('../../server/kursPmDataApi', () => ({ getMemoApiData: jest.fn(), @@ -36,7 +37,7 @@ jest.mock('@kth/log', () => ({ const { getMemoApiData, changeMemoApiData } = require('../../server/kursPmDataApi') const { getCourseInfo } = require('../../server/kursinfoApi') -const { getLadokCourseData, getLadokCourseSyllabus } = require('../../server/ladokApi') +const { getLadokCourseData, getLadokCourseSyllabus, getLadokCourseSyllabuses } = require('../../server/ladokApi') const { getCourseEmployees } = require('../../server/ugRestApi') const { getServerSideFunctions } = require('../../server/utils/serverSideRendering') @@ -85,6 +86,26 @@ const ladokCourseSyllabusDataMock = { betygsskala: 'A, B, C, D, E, FX, F', }, } +const ladokCourseSyllabusesDataMock = [ + { + kursplan: { + kursinnehall: 'Course content example', + larandemal: 'Learning outcomes example', + examinationModules: { + completeExaminationStrings: '
  • TEN1 - Tentamen, 7.5 credits
  • ', + titles: '

    TEN1 - Tentamen, 7.5 credits

    ', + }, + kommentartillexamination: '

    Exam comment

    ', + ovrigakravforslutbetyg: 'Additional requirements for final grade', + etisktforhallningssatt: 'Ethical approach', + faststallande: 'Additional regulations', + giltigfrom: 'HT2020', + }, + course: { + betygsskala: 'A, B, C, D, E, FX, F', + }, + }, +] const courseInfoApiDataMock = { recommendedPrerequisites: 'Some recommended prerequisites', @@ -100,6 +121,7 @@ describe('mergeAllData', () => { courseInfoApiDataMock, ladokCourseDataMock, ladokCourseSyllabusDataMock, + ladokCourseSyllabusesDataMock, ugAdminEmployeesDataMock, staticData ) @@ -137,6 +159,7 @@ describe('renderMemoEditorPage', () => { getCourseInfo.mockResolvedValue(courseInfoApiDataMock) getLadokCourseData.mockResolvedValue(ladokCourseDataMock) getLadokCourseSyllabus.mockResolvedValue(ladokCourseSyllabusDataMock) + getLadokCourseSyllabuses.mockResolvedValue(ladokCourseSyllabusesDataMock) getServerSideFunctions.mockReturnValue({ createStore: () => ({ From affc563b5185568ce6fc18a3f82a03278d56b8a1 Mon Sep 17 00:00:00 2001 From: axelbjo Date: Tue, 11 Nov 2025 16:39:20 +0100 Subject: [PATCH 2/9] fix(KUI-2067): requested changes --- public/js/app/utils-shared/helpers.js | 6 +- server/utils/getValidUntilTerm.js | 38 +++---- server/utils/getValidUntilTerm.test.js | 5 + server/utils/semesterUtils.js | 141 +++++++++++++++++++++++++ server/utils/semesterUtils.test.js | 129 ++++++++++++++++++++++ 5 files changed, 298 insertions(+), 21 deletions(-) create mode 100644 server/utils/semesterUtils.js create mode 100644 server/utils/semesterUtils.test.js diff --git a/public/js/app/utils-shared/helpers.js b/public/js/app/utils-shared/helpers.js index f949390f..1857de58 100644 --- a/public/js/app/utils-shared/helpers.js +++ b/public/js/app/utils-shared/helpers.js @@ -1,3 +1,5 @@ +import { parseSemesterIntoYearSemesterNumber } from '../../../../server/utils/semesterUtils' + const i18n = require('../../../../i18n') const convertLangToIndex = langShortName => (langShortName === 'en' ? 0 : 1) @@ -7,9 +9,7 @@ const seasonStr = (language, semesterRaw) => { const isLangANumber = typeof language === 'number' const langIndex = isLangANumber ? language : convertLangToIndex(language) const { extraInfo } = i18n.messages[langIndex] - return /^(HT|VT)\d{4}$/.test(semesterRaw) - ? `${semesterRaw.slice(0, 2)} ${semesterRaw.slice(2, 6)}` - : `${extraInfo.season[semesterRaw.toString()[4]]}${semesterRaw.toString().slice(0, 4)}` + return `${extraInfo.season[parseSemesterIntoYearSemesterNumber(semesterRaw).semesterNumber]}${parseSemesterIntoYearSemesterNumber(semesterRaw).year}` } export { convertLangToIndex, seasonStr } diff --git a/server/utils/getValidUntilTerm.js b/server/utils/getValidUntilTerm.js index 98d3c38e..74b60c94 100644 --- a/server/utils/getValidUntilTerm.js +++ b/server/utils/getValidUntilTerm.js @@ -1,13 +1,15 @@ -const SemesterPrefix = { - AUTUMN: 'HT', - SPRING: 'VT', -} - -const getSemesterPrefix = semester => semester.slice(0, 2) -const getSemesterYear = semester => semester.slice(2, 6) +import { + calcPreviousSemester, + parseSemesterIntoYearSemesterNumber, + SemesterNumber, + LadokSemesterPrefix, +} from './semesterUtils' const getValidUntilTerm = (syllabuses, currentSyllabus) => { // Sort syllabuses by semester in ascending order + if (syllabuses.length === 0) { + return undefined + } const sorted = syllabuses.sort((a, b) => { const parse = s => { const term = s.slice(0, 2) @@ -16,8 +18,8 @@ const getValidUntilTerm = (syllabuses, currentSyllabus) => { return { year, termOrder } } - const A = parse(a.kursplan.giltigfrom) - const B = parse(b.kursplan.giltigfrom) + const A = parse(a.kursplan?.giltigfrom) + const B = parse(b.kursplan?.giltigfrom) // First sort by year, then by termOrder if (A.year !== B.year) return A.year - B.year @@ -25,23 +27,23 @@ const getValidUntilTerm = (syllabuses, currentSyllabus) => { }) // Prevent duplicates const seen = new Set() - const syllabusesNoValidFromTermDups = sorted.filter(item => { + const syllabusesNoGiltigFromDups = sorted.filter(item => { const key = item.kursplan.giltigfrom if (seen.has(key)) return false seen.add(key) return true }) - const index = syllabusesNoValidFromTermDups.indexOf( - syllabusesNoValidFromTermDups.find(syllabus => syllabus.kursplan.giltigfrom === currentSyllabus.kursplan.giltigfrom) + const index = syllabusesNoGiltigFromDups.indexOf( + syllabusesNoGiltigFromDups.find(syllabus => syllabus.kursplan.giltigfrom === currentSyllabus.kursplan.giltigfrom) ) // validUntilTerm will be the term/semester before the next syllabus validFromTerm semester - if (index !== -1 && index < syllabusesNoValidFromTermDups.length - 1) { - const yearOffset = 1 - const nextSyllabusValidFrom = syllabusesNoValidFromTermDups[index + 1].kursplan.giltigfrom - return getSemesterPrefix(nextSyllabusValidFrom) === SemesterPrefix.AUTUMN - ? SemesterPrefix.SPRING + getSemesterYear(nextSyllabusValidFrom) - : SemesterPrefix.AUTUMN + (getSemesterYear(nextSyllabusValidFrom) - yearOffset) + if (index !== -1 && index < syllabusesNoGiltigFromDups.length - 1) { + const nextSyllabusValidFrom = syllabusesNoGiltigFromDups[index + 1].kursplan.giltigfrom + const semester = parseSemesterIntoYearSemesterNumber(nextSyllabusValidFrom) + return semester.semesterNumber === SemesterNumber.Autumn + ? LadokSemesterPrefix.Spring + semester.year + : LadokSemesterPrefix.Autumn + calcPreviousSemester(semester).year } else { return undefined } diff --git a/server/utils/getValidUntilTerm.test.js b/server/utils/getValidUntilTerm.test.js index bb28df32..7e4d64b8 100644 --- a/server/utils/getValidUntilTerm.test.js +++ b/server/utils/getValidUntilTerm.test.js @@ -21,4 +21,9 @@ describe('getValidUntilTerm', () => { const result = getValidUntilTerm(syllabuses, make('HT2018')) expect(result).toBe(undefined) }) + it('should return undefined if syllabuses array is empty', () => { + const syllabuses = [] + const result = getValidUntilTerm(syllabuses, make('HT2018')) + expect(result).toBe(undefined) + }) }) diff --git a/server/utils/semesterUtils.js b/server/utils/semesterUtils.js new file mode 100644 index 00000000..9fb93732 --- /dev/null +++ b/server/utils/semesterUtils.js @@ -0,0 +1,141 @@ +/** + * This file is reproduced in multiple repos. See confluence for more info: + * confluence.sys.kth.se/confluence/x/6wYJDQ + */ + +/** + * semester: 20242 (Number) -> Autumn 2024 + */ + +export const SemesterNumber = { + Spring: 1, + Autumn: 2, +} + +export const LadokSemesterPrefix = { + Spring: 'VT', + Autumn: 'HT', +} + +/** + * Takes a yearSemesterNumber and returns a yearSemesterNumber representing the semester previous to the given semester + * + * @param param0 YearSemesterNumber, e.g. { year: 2024, semesterNumber: 1 } + * @returns YearSemesterNumber, e.g. { year: 2023, semesterNumber: 2 } + */ +export const calcPreviousSemester = ({ year, semesterNumber }) => { + if (semesterNumber === 2) { + return { + year, + semesterNumber: SemesterNumber.Spring, + } + } + + return { + year: year - 1, + semesterNumber: SemesterNumber.Autumn, + } +} + +/** + * Takes a KTH semester in string or number format, returns an array with year at index 0 + * and semester number at index 1. + * YearSemesterNumberArray is a legacy format, which is used in some parts of our code. + * The preferred format is YearSemesterNumber. + * + * @param semester "20241" or 20241 + * @returns [2024, 1] + */ +export const parseSemesterIntoYearSemesterNumberArray = semester => { + const yearSemesterNumberArrayStrings = semester.toString().match(/.{1,4}/g) + + return yearSemesterNumberArrayStrings.map(str => Number(str)) +} + +// TODO Refactor this, confusing name as "Period" is ladok-speak, but here we create a KTH semester string +/** + * Returns a KTH semester string. + * + * @param date + * @returns a KTH semester string "20241" + */ +export const getPeriodCodeForDate = date => { + const JULY = 6 + const year = date.getFullYear() + const month = date.getMonth() + const semester = month < JULY ? SemesterNumber.Spring : SemesterNumber.Autumn + return `${year}${semester}` +} + +/** + * Takes a string in LadokPeriod format and returns a YearSemesterNumberArray + * Note that YearSemesterNumberArray is a legacy format. + * YearSemesterNumber or AcademicSemester are preferred. + * If possible, use {parseSemesterIntoYearSemesterNumber}. + * + * @param semester Semester string in ladok format, e.g. "VT2024" + * @returns YearSemesterNumberArray, e.g. [2024, 1] + */ +const parseLadokSemester = semester => { + let match = undefined + if (semester) { + match = semester.match(/(HT|VT)(\d{4})/) + } + + if (!match) throw new Error("Invalid semester format. Expected 'HTYYYY' or 'VTYYYY'.") + + const [, term, year] = match + const semesterNumber = term === 'VT' ? SemesterNumber.Spring : SemesterNumber.Autumn + + return [Number(year), semesterNumber] +} + +/** + * Takes a string in KTH semester format and returns a YearSemesterNumberArray + * Note that YearSemesterNumberArray is a legacy format. + * YearSemesterNumber or AcademicSemester are preferred. + * If possible, use {parseSemesterIntoYearSemesterNumber}. + * + * @param semester Semester string in KTH format, e.g. 20241 + * @returns YearSemesterNumberArray, e.g. [2024, 1] + */ +export const parseSemester = semester => { + let match = undefined + if (semester) { + match = semester.match(/^(\d{4})([1|2])$/) + } + + if (!match) throw new Error("Invalid semester format. Expected 'YYYYS' where S is 1 for VT or 2 for HT.") + + const [, year, parsedSemesterNumber] = match + const semesterNumber = + parsedSemesterNumber === SemesterNumber.Spring.toString() ? SemesterNumber.Spring : SemesterNumber.Autumn + + return [Number(year), semesterNumber] +} + +/** + * Takes a semester, either in LadokPeriod string format or KTH Semester string or number format. + * Returns a YearSemesterNumber. + * + * @param semester A semester string|number in either KTH or Ladok format, e.g. "VT2024", "20241", 20241 + * @returns YearSemesterNumber, e.g. { year: 2024, semesterNumber: 1 } + */ +export const parseSemesterIntoYearSemesterNumber = semester => { + const semesterString = semester.toString() + const semesterRegex = /^([A-Za-z]{2}\d{4})$/ + const ladokFormat = semesterRegex.test(semesterString) + if (ladokFormat) { + const [year, semesterNumber] = parseLadokSemester(semesterString) + return { + year, + semesterNumber, + } + } else { + const [year, semesterNumber] = parseSemester(String(semester)) + return { + year, + semesterNumber, + } + } +} diff --git a/server/utils/semesterUtils.test.js b/server/utils/semesterUtils.test.js new file mode 100644 index 00000000..e0b9ca96 --- /dev/null +++ b/server/utils/semesterUtils.test.js @@ -0,0 +1,129 @@ +import { + calcPreviousSemester, + getPeriodCodeForDate, + parseSemesterIntoYearSemesterNumber, + parseSemesterIntoYearSemesterNumberArray, + SemesterNumber, + // convertToYearSemesterNumberOrGetCurrent, +} from './semesterUtils' + +describe('semesterUtils', () => { + describe('calcPreviousSemester', () => { + test.each([2024, 2025, 1990])('returns yearTerm with semesterNumber: 1 if semesterNumber is 2', year => { + expect(calcPreviousSemester({ year, semesterNumber: SemesterNumber.Autumn })).toStrictEqual({ + year, + semesterNumber: SemesterNumber.Spring, + }) + }) + + test.each([2024, 2025, 1990])('returns yearTerm with year -1 and semesterNumber 2 if semesterNumber is 1', year => { + expect(calcPreviousSemester({ year, semesterNumber: SemesterNumber.Spring })).toStrictEqual({ + year: year - 1, + semesterNumber: SemesterNumber.Autumn, + }) + }) + }) + + describe('parseSemesterIntoYearSemesterNumber', () => { + describe('works in KTH format', () => { + test('correctly parses terms with semesterNumber 1', () => { + expect(parseSemesterIntoYearSemesterNumber(19901)).toEqual({ + year: 1990, + semesterNumber: SemesterNumber.Spring, + }) + expect(parseSemesterIntoYearSemesterNumber(20241)).toEqual({ + year: 2024, + semesterNumber: SemesterNumber.Spring, + }) + expect(parseSemesterIntoYearSemesterNumber('19901')).toEqual({ + year: 1990, + semesterNumber: SemesterNumber.Spring, + }) + expect(parseSemesterIntoYearSemesterNumber('20241')).toEqual({ + year: 2024, + semesterNumber: SemesterNumber.Spring, + }) + }) + + test('correctly parses terms with semesterNumber 2', () => { + expect(parseSemesterIntoYearSemesterNumber(20002)).toEqual({ + year: 2000, + semesterNumber: SemesterNumber.Autumn, + }) + expect(parseSemesterIntoYearSemesterNumber(20202)).toEqual({ + year: 2020, + semesterNumber: SemesterNumber.Autumn, + }) + expect(parseSemesterIntoYearSemesterNumber('20002')).toEqual({ + year: 2000, + semesterNumber: SemesterNumber.Autumn, + }) + expect(parseSemesterIntoYearSemesterNumber('20202')).toEqual({ + year: 2020, + semesterNumber: SemesterNumber.Autumn, + }) + }) + + test.each(['99999', 1111111, '2024T', 'TTT'])('throws error if invalid KTHSemester format "%s"', illegalInput => { + expect(() => parseSemesterIntoYearSemesterNumber(illegalInput)).toThrow( + "Invalid semester format. Expected 'YYYYS' where S is 1 for VT or 2 for HT." + ) + }) + }) + + describe('works in ladok format', () => { + test('correctly parses terms in Spring', () => { + expect(parseSemesterIntoYearSemesterNumber('VT1990')).toEqual({ + year: 1990, + semesterNumber: SemesterNumber.Spring, + }) + expect(parseSemesterIntoYearSemesterNumber('VT2024')).toEqual({ + year: 2024, + semesterNumber: SemesterNumber.Spring, + }) + }) + + test('correctly parses terms in autumn', () => { + expect(parseSemesterIntoYearSemesterNumber('HT2000')).toEqual({ + year: 2000, + semesterNumber: SemesterNumber.Autumn, + }) + expect(parseSemesterIntoYearSemesterNumber('HT2020')).toEqual({ + year: 2020, + semesterNumber: SemesterNumber.Autumn, + }) + }) + + test.each(['TT2020', 'vt2021'])('throws error if invalid ladok format', illegalInput => { + expect(() => parseSemesterIntoYearSemesterNumber(illegalInput)).toThrow( + "Invalid semester format. Expected 'HTYYYY' or 'VTYYYY'." + ) + expect(() => parseSemesterIntoYearSemesterNumber(illegalInput)).toThrow( + "Invalid semester format. Expected 'HTYYYY' or 'VTYYYY'." + ) + }) + }) + }) + + describe('parseSemesterIntoYearSemesterNumberArray', () => { + test('parseSemesterIntoYearSemesterNumberArray', () => { + expect(parseSemesterIntoYearSemesterNumberArray('20241')).toEqual([2024, 1]) + expect(parseSemesterIntoYearSemesterNumberArray('11112')).toEqual([1111, 2]) + }) + }) + + describe('getPeriodCodeForDate', () => { + it.each(['2020-01-01', '2020-02-13', '2020-03-15', '2020-04-20', '2020-05-10', '2020-06-30'])( + 'returns spring for dates within january through june: %s', + date => { + expect(getPeriodCodeForDate(new Date(date))).toEqual(`20201`) + } + ) + it.each(['1999-07-01', '1999-08-13', '1999-09-15', '1999-10-20', '1999-11-10', '1999-12-31'])( + 'returns autumn for dates within july through december: %s', + date => { + expect(getPeriodCodeForDate(new Date(date))).toEqual(`19992`) + } + ) + }) +}) From 2cda744650e1292091e09f79737cc7f1ac9c7f67 Mon Sep 17 00:00:00 2001 From: axelbjo Date: Wed, 12 Nov 2025 10:21:01 +0100 Subject: [PATCH 3/9] fix(KUI-2067): more null checking --- server/utils/getValidUntilTerm.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/utils/getValidUntilTerm.js b/server/utils/getValidUntilTerm.js index 74b60c94..62c17c2d 100644 --- a/server/utils/getValidUntilTerm.js +++ b/server/utils/getValidUntilTerm.js @@ -7,7 +7,7 @@ import { const getValidUntilTerm = (syllabuses, currentSyllabus) => { // Sort syllabuses by semester in ascending order - if (syllabuses.length === 0) { + if (!Array.isArray(syllabuses) || syllabuses.length === 0 || !currentSyllabus) { return undefined } const sorted = syllabuses.sort((a, b) => { @@ -25,6 +25,7 @@ const getValidUntilTerm = (syllabuses, currentSyllabus) => { if (A.year !== B.year) return A.year - B.year return A.termOrder - B.termOrder }) + if (sorted.length === 0) return undefined // Prevent duplicates const seen = new Set() const syllabusesNoGiltigFromDups = sorted.filter(item => { From 4b77587483fa86e486fba49e2a7a567544a26b7b Mon Sep 17 00:00:00 2001 From: axelbjo Date: Thu, 13 Nov 2025 14:35:31 +0100 Subject: [PATCH 4/9] fix(KUI-2067): requested changes --- public/js/app/utils-shared/helpers.js | 5 ++-- server/utils/getValidUntilTerm.js | 29 +++++++------------ {server/utils => shared}/semesterUtils.js | 2 +- .../utils => shared}/semesterUtils.test.js | 1 - 4 files changed, 15 insertions(+), 22 deletions(-) rename {server/utils => shared}/semesterUtils.js (98%) rename {server/utils => shared}/semesterUtils.test.js (99%) diff --git a/public/js/app/utils-shared/helpers.js b/public/js/app/utils-shared/helpers.js index 1857de58..acb40de7 100644 --- a/public/js/app/utils-shared/helpers.js +++ b/public/js/app/utils-shared/helpers.js @@ -1,4 +1,4 @@ -import { parseSemesterIntoYearSemesterNumber } from '../../../../server/utils/semesterUtils' +import { parseSemesterIntoYearSemesterNumber } from '../../../../shared/semesterUtils' const i18n = require('../../../../i18n') @@ -9,7 +9,8 @@ const seasonStr = (language, semesterRaw) => { const isLangANumber = typeof language === 'number' const langIndex = isLangANumber ? language : convertLangToIndex(language) const { extraInfo } = i18n.messages[langIndex] - return `${extraInfo.season[parseSemesterIntoYearSemesterNumber(semesterRaw).semesterNumber]}${parseSemesterIntoYearSemesterNumber(semesterRaw).year}` + const { year, semesterNumber } = parseSemesterIntoYearSemesterNumber(semesterRaw) + return `${extraInfo.season[semesterNumber]}${year}` } export { convertLangToIndex, seasonStr } diff --git a/server/utils/getValidUntilTerm.js b/server/utils/getValidUntilTerm.js index 62c17c2d..a91fc145 100644 --- a/server/utils/getValidUntilTerm.js +++ b/server/utils/getValidUntilTerm.js @@ -3,7 +3,8 @@ import { parseSemesterIntoYearSemesterNumber, SemesterNumber, LadokSemesterPrefix, -} from './semesterUtils' + parseLadokSemester, +} from '../../shared/semesterUtils' const getValidUntilTerm = (syllabuses, currentSyllabus) => { // Sort syllabuses by semester in ascending order @@ -11,36 +12,28 @@ const getValidUntilTerm = (syllabuses, currentSyllabus) => { return undefined } const sorted = syllabuses.sort((a, b) => { - const parse = s => { - const term = s.slice(0, 2) - const year = parseInt(s.slice(2)) - const termOrder = term === 'VT' ? 0 : 1 - return { year, termOrder } - } - - const A = parse(a.kursplan?.giltigfrom) - const B = parse(b.kursplan?.giltigfrom) + const [yearA, semesterNumberA] = parseLadokSemester(a.kursplan?.giltigfrom) + const [yearB, semesterNumberB] = parseLadokSemester(b.kursplan?.giltigfrom) // First sort by year, then by termOrder - if (A.year !== B.year) return A.year - B.year - return A.termOrder - B.termOrder + if (yearA !== yearB) return yearA - yearB + return semesterNumberA - semesterNumberB }) - if (sorted.length === 0) return undefined // Prevent duplicates const seen = new Set() - const syllabusesNoGiltigFromDups = sorted.filter(item => { + const syllabusesUniqueGiltigFrom = sorted.filter(item => { const key = item.kursplan.giltigfrom if (seen.has(key)) return false seen.add(key) return true }) - const index = syllabusesNoGiltigFromDups.indexOf( - syllabusesNoGiltigFromDups.find(syllabus => syllabus.kursplan.giltigfrom === currentSyllabus.kursplan.giltigfrom) + const index = syllabusesUniqueGiltigFrom.indexOf( + syllabusesUniqueGiltigFrom.find(syllabus => syllabus.kursplan.giltigfrom === currentSyllabus.kursplan.giltigfrom) ) // validUntilTerm will be the term/semester before the next syllabus validFromTerm semester - if (index !== -1 && index < syllabusesNoGiltigFromDups.length - 1) { - const nextSyllabusValidFrom = syllabusesNoGiltigFromDups[index + 1].kursplan.giltigfrom + if (index !== -1 && index < syllabusesUniqueGiltigFrom.length - 1) { + const nextSyllabusValidFrom = syllabusesUniqueGiltigFrom[index + 1].kursplan.giltigfrom const semester = parseSemesterIntoYearSemesterNumber(nextSyllabusValidFrom) return semester.semesterNumber === SemesterNumber.Autumn ? LadokSemesterPrefix.Spring + semester.year diff --git a/server/utils/semesterUtils.js b/shared/semesterUtils.js similarity index 98% rename from server/utils/semesterUtils.js rename to shared/semesterUtils.js index 9fb93732..2186267e 100644 --- a/server/utils/semesterUtils.js +++ b/shared/semesterUtils.js @@ -76,7 +76,7 @@ export const getPeriodCodeForDate = date => { * @param semester Semester string in ladok format, e.g. "VT2024" * @returns YearSemesterNumberArray, e.g. [2024, 1] */ -const parseLadokSemester = semester => { +export const parseLadokSemester = semester => { let match = undefined if (semester) { match = semester.match(/(HT|VT)(\d{4})/) diff --git a/server/utils/semesterUtils.test.js b/shared/semesterUtils.test.js similarity index 99% rename from server/utils/semesterUtils.test.js rename to shared/semesterUtils.test.js index e0b9ca96..6dba1bcd 100644 --- a/server/utils/semesterUtils.test.js +++ b/shared/semesterUtils.test.js @@ -4,7 +4,6 @@ import { parseSemesterIntoYearSemesterNumber, parseSemesterIntoYearSemesterNumberArray, SemesterNumber, - // convertToYearSemesterNumberOrGetCurrent, } from './semesterUtils' describe('semesterUtils', () => { From 34c5a50413960b205a1ccdc9521d06f5f1f7fd30 Mon Sep 17 00:00:00 2001 From: axelbjo Date: Thu, 13 Nov 2025 14:53:32 +0100 Subject: [PATCH 5/9] test(KUI-2067): added test for parseLadokSemester --- shared/semesterUtils.test.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/shared/semesterUtils.test.js b/shared/semesterUtils.test.js index 6dba1bcd..efaaed37 100644 --- a/shared/semesterUtils.test.js +++ b/shared/semesterUtils.test.js @@ -1,6 +1,7 @@ import { calcPreviousSemester, getPeriodCodeForDate, + parseLadokSemester, parseSemesterIntoYearSemesterNumber, parseSemesterIntoYearSemesterNumberArray, SemesterNumber, @@ -104,6 +105,12 @@ describe('semesterUtils', () => { }) }) + describe('parseLadokSemester', () => { + test('parseLadokSemester', () => { + expect(parseLadokSemester('HT2024')).toEqual([2024, 2]) + }) + }) + describe('parseSemesterIntoYearSemesterNumberArray', () => { test('parseSemesterIntoYearSemesterNumberArray', () => { expect(parseSemesterIntoYearSemesterNumberArray('20241')).toEqual([2024, 1]) From 5c1d1b73c5e4c3f780b1bcafa99bb0b642414232 Mon Sep 17 00:00:00 2001 From: axelbjo Date: Fri, 14 Nov 2025 15:27:41 +0100 Subject: [PATCH 6/9] fix(KUI-2067): requested changes --- server/utils/getValidUntilTerm.js | 4 ++-- shared/semesterUtils.test.js | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/server/utils/getValidUntilTerm.js b/server/utils/getValidUntilTerm.js index a91fc145..e1ae673a 100644 --- a/server/utils/getValidUntilTerm.js +++ b/server/utils/getValidUntilTerm.js @@ -12,8 +12,8 @@ const getValidUntilTerm = (syllabuses, currentSyllabus) => { return undefined } const sorted = syllabuses.sort((a, b) => { - const [yearA, semesterNumberA] = parseLadokSemester(a.kursplan?.giltigfrom) - const [yearB, semesterNumberB] = parseLadokSemester(b.kursplan?.giltigfrom) + const { yearA, semesterNumberA } = parseSemesterIntoYearSemesterNumber(a.kursplan?.giltigfrom) + const { yearB, semesterNumberB } = parseSemesterIntoYearSemesterNumber(b.kursplan?.giltigfrom) // First sort by year, then by termOrder if (yearA !== yearB) return yearA - yearB diff --git a/shared/semesterUtils.test.js b/shared/semesterUtils.test.js index efaaed37..43ff5250 100644 --- a/shared/semesterUtils.test.js +++ b/shared/semesterUtils.test.js @@ -108,6 +108,7 @@ describe('semesterUtils', () => { describe('parseLadokSemester', () => { test('parseLadokSemester', () => { expect(parseLadokSemester('HT2024')).toEqual([2024, 2]) + expect(parseLadokSemester('VT2024')).toEqual([2024, 1]) }) }) From b1621d100d667098b8022f413600d6e58122771e Mon Sep 17 00:00:00 2001 From: axelbjo Date: Wed, 19 Nov 2025 11:53:54 +0100 Subject: [PATCH 7/9] chore(KUI-2067): cleanup --- package.json | 2 +- server/utils/getValidUntilTerm.js | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/package.json b/package.json index 3204ba49..0ac55ae9 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "prepare": "husky install", "start": "bash -c 'cat /KTH_NODEJS; NODE_ENV=production node app.js'", "start-dev": "bash -c 'NODE_ENV=development concurrently --kill-others -n build,app \"npm run build-dev\" \"nodemon app.js\"'", - "test": "cross-env NODE_ENV=test jest --testPathIgnorePatterns=test/e2e --", + "test": "cross-env NODE_ENV=test jest --testPathIgnorePatterns=test/e2e", "test:mockapi": "docker-compose -f test/mock-api/docker-compose.yml up --build --force-recreate", "test-win": "cross-env NODE_ENV=test jest --testPathIgnorePatterns=test/e2e", "test-preview": "NODE_ENV=test jest ./test/unit/PreviewContainerFilledIn.test.js --watch --notify", diff --git a/server/utils/getValidUntilTerm.js b/server/utils/getValidUntilTerm.js index e1ae673a..0c757364 100644 --- a/server/utils/getValidUntilTerm.js +++ b/server/utils/getValidUntilTerm.js @@ -3,7 +3,6 @@ import { parseSemesterIntoYearSemesterNumber, SemesterNumber, LadokSemesterPrefix, - parseLadokSemester, } from '../../shared/semesterUtils' const getValidUntilTerm = (syllabuses, currentSyllabus) => { From ae67642e691931d1b33a081236553573d7c321de Mon Sep 17 00:00:00 2001 From: axelbjo Date: Wed, 19 Nov 2025 13:52:51 +0100 Subject: [PATCH 8/9] chore(KUI-2067): cleanup --- package-lock.json | 16 ++++++++-------- package.json | 2 +- server/utils/getValidUntilTerm.js | 4 ++-- shared/semesterUtils.js | 27 +++++++++++++++++++-------- 4 files changed, 30 insertions(+), 19 deletions(-) diff --git a/package-lock.json b/package-lock.json index 121959ff..2463da89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "@kth/kth-reactstrap": "^0.5.0", "@kth/log": "^4.0.7", "@kth/monitor": "^4.3.1", - "@kth/om-kursen-ladok-client": "2.5.0", + "@kth/om-kursen-ladok-client": "2.5.4", "@kth/server": "^4.1.0", "@kth/session": "^3.0.9", "@kth/style": "^1.13.0", @@ -3156,9 +3156,9 @@ "integrity": "sha512-NYsGOiqVJVBXC/uH/6P4pWjyZ6+jlHWcsNTvLoskIoHI7nBETJuFXkmHtSE1SR7Vd3Nx1l3IFqJMYEM57mIj0A==" }, "node_modules/@kth/ladok-mellanlager-client": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@kth/ladok-mellanlager-client/-/ladok-mellanlager-client-1.1.0.tgz", - "integrity": "sha512-wyBAxYsoEkTqznamKPDSnuF/Hq5WTeRtMwqIVGj/71bqT+TGPVASKqFC/p683Z+VxKdpQSYdo4c0fRbIo4jGzw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@kth/ladok-mellanlager-client/-/ladok-mellanlager-client-1.2.0.tgz", + "integrity": "sha512-yhzXHcNKYctDqoV4bIL+DBwzEPe21IlHzAYbcAA3HBtZWQQYxx/mHyo/A+GU/WSLCzfsKRWuNg0+eeXy55fsuA==", "dependencies": { "openapi-fetch": "^0.13.0" } @@ -3183,12 +3183,12 @@ } }, "node_modules/@kth/om-kursen-ladok-client": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@kth/om-kursen-ladok-client/-/om-kursen-ladok-client-2.5.0.tgz", - "integrity": "sha512-diXhR+ep3uffOEXUbpjAiIzF0Xvkyk99kPug8OnaS2aYMV8r5XDvZnhJkuXDvysXzq9Fsb1v+Rnr/fsuPKRBnw==", + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@kth/om-kursen-ladok-client/-/om-kursen-ladok-client-2.5.4.tgz", + "integrity": "sha512-m/6E+Rg0c9k+X9uPnzzbYrArj9HjZISMtuuj7Izde5Cz/ySVr24lWnu8g8QONAmgVKReCPJz1m+FHF7w4tXQ1w==", "dependencies": { "@kth/ladok-attributvarde-utils": "1.0.4", - "@kth/ladok-mellanlager-client": "1.1.0", + "@kth/ladok-mellanlager-client": "1.2.0", "date-fns": "^4.1.0", "showdown": "^2.1.0" } diff --git a/package.json b/package.json index 0ac55ae9..a6feb34b 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "@kth/kth-reactstrap": "^0.5.0", "@kth/log": "^4.0.7", "@kth/monitor": "^4.3.1", - "@kth/om-kursen-ladok-client": "2.5.0", + "@kth/om-kursen-ladok-client": "2.5.4", "@kth/server": "^4.1.0", "@kth/session": "^3.0.9", "@kth/style": "^1.13.0", diff --git a/server/utils/getValidUntilTerm.js b/server/utils/getValidUntilTerm.js index 0c757364..f15d7dcb 100644 --- a/server/utils/getValidUntilTerm.js +++ b/server/utils/getValidUntilTerm.js @@ -1,9 +1,9 @@ -import { +const { calcPreviousSemester, parseSemesterIntoYearSemesterNumber, SemesterNumber, LadokSemesterPrefix, -} from '../../shared/semesterUtils' +} = require('../../shared/semesterUtils') const getValidUntilTerm = (syllabuses, currentSyllabus) => { // Sort syllabuses by semester in ascending order diff --git a/shared/semesterUtils.js b/shared/semesterUtils.js index 2186267e..8bcc2919 100644 --- a/shared/semesterUtils.js +++ b/shared/semesterUtils.js @@ -7,12 +7,12 @@ * semester: 20242 (Number) -> Autumn 2024 */ -export const SemesterNumber = { +const SemesterNumber = { Spring: 1, Autumn: 2, } -export const LadokSemesterPrefix = { +const LadokSemesterPrefix = { Spring: 'VT', Autumn: 'HT', } @@ -23,7 +23,7 @@ export const LadokSemesterPrefix = { * @param param0 YearSemesterNumber, e.g. { year: 2024, semesterNumber: 1 } * @returns YearSemesterNumber, e.g. { year: 2023, semesterNumber: 2 } */ -export const calcPreviousSemester = ({ year, semesterNumber }) => { +const calcPreviousSemester = ({ year, semesterNumber }) => { if (semesterNumber === 2) { return { year, @@ -46,7 +46,7 @@ export const calcPreviousSemester = ({ year, semesterNumber }) => { * @param semester "20241" or 20241 * @returns [2024, 1] */ -export const parseSemesterIntoYearSemesterNumberArray = semester => { +const parseSemesterIntoYearSemesterNumberArray = semester => { const yearSemesterNumberArrayStrings = semester.toString().match(/.{1,4}/g) return yearSemesterNumberArrayStrings.map(str => Number(str)) @@ -59,7 +59,7 @@ export const parseSemesterIntoYearSemesterNumberArray = semester => { * @param date * @returns a KTH semester string "20241" */ -export const getPeriodCodeForDate = date => { +const getPeriodCodeForDate = date => { const JULY = 6 const year = date.getFullYear() const month = date.getMonth() @@ -76,7 +76,7 @@ export const getPeriodCodeForDate = date => { * @param semester Semester string in ladok format, e.g. "VT2024" * @returns YearSemesterNumberArray, e.g. [2024, 1] */ -export const parseLadokSemester = semester => { +const parseLadokSemester = semester => { let match = undefined if (semester) { match = semester.match(/(HT|VT)(\d{4})/) @@ -99,7 +99,7 @@ export const parseLadokSemester = semester => { * @param semester Semester string in KTH format, e.g. 20241 * @returns YearSemesterNumberArray, e.g. [2024, 1] */ -export const parseSemester = semester => { +const parseSemester = semester => { let match = undefined if (semester) { match = semester.match(/^(\d{4})([1|2])$/) @@ -121,7 +121,7 @@ export const parseSemester = semester => { * @param semester A semester string|number in either KTH or Ladok format, e.g. "VT2024", "20241", 20241 * @returns YearSemesterNumber, e.g. { year: 2024, semesterNumber: 1 } */ -export const parseSemesterIntoYearSemesterNumber = semester => { +const parseSemesterIntoYearSemesterNumber = semester => { const semesterString = semester.toString() const semesterRegex = /^([A-Za-z]{2}\d{4})$/ const ladokFormat = semesterRegex.test(semesterString) @@ -139,3 +139,14 @@ export const parseSemesterIntoYearSemesterNumber = semester => { } } } + +module.exports = { + SemesterNumber, + LadokSemesterPrefix, + calcPreviousSemester, + parseSemesterIntoYearSemesterNumberArray, + getPeriodCodeForDate, + parseLadokSemester, + parseSemester, + parseSemesterIntoYearSemesterNumber, +} From bdbf78f117dd6d38da0c094c1bc93c97939c1ca9 Mon Sep 17 00:00:00 2001 From: axelbjo Date: Wed, 26 Nov 2025 14:27:19 +0100 Subject: [PATCH 9/9] fix(KUI-2067): fixed incorrect extraction of object values --- server/utils/getValidUntilTerm.js | 23 ++++++++++++++--------- server/utils/getValidUntilTerm.test.js | 13 ++++++++++++- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/server/utils/getValidUntilTerm.js b/server/utils/getValidUntilTerm.js index f15d7dcb..d8aa05e4 100644 --- a/server/utils/getValidUntilTerm.js +++ b/server/utils/getValidUntilTerm.js @@ -5,22 +5,26 @@ const { LadokSemesterPrefix, } = require('../../shared/semesterUtils') -const getValidUntilTerm = (syllabuses, currentSyllabus) => { - // Sort syllabuses by semester in ascending order - if (!Array.isArray(syllabuses) || syllabuses.length === 0 || !currentSyllabus) { - return undefined - } - const sorted = syllabuses.sort((a, b) => { - const { yearA, semesterNumberA } = parseSemesterIntoYearSemesterNumber(a.kursplan?.giltigfrom) - const { yearB, semesterNumberB } = parseSemesterIntoYearSemesterNumber(b.kursplan?.giltigfrom) +const sortSyllabusesByGiltigFrom = syllabuses => + syllabuses.sort((a, b) => { + const { year: yearA, semesterNumber: semesterNumberA } = parseSemesterIntoYearSemesterNumber(a.kursplan?.giltigfrom) + + const { year: yearB, semesterNumber: semesterNumberB } = parseSemesterIntoYearSemesterNumber(b.kursplan?.giltigfrom) // First sort by year, then by termOrder if (yearA !== yearB) return yearA - yearB return semesterNumberA - semesterNumberB }) + +const getValidUntilTerm = (syllabuses, currentSyllabus) => { + // Sort syllabuses by semester in ascending order + if (!Array.isArray(syllabuses) || syllabuses.length === 0 || !currentSyllabus) { + return undefined + } + const syllabusesSortedByGiltigFrom = sortSyllabusesByGiltigFrom(syllabuses) // Prevent duplicates const seen = new Set() - const syllabusesUniqueGiltigFrom = sorted.filter(item => { + const syllabusesUniqueGiltigFrom = syllabusesSortedByGiltigFrom.filter(item => { const key = item.kursplan.giltigfrom if (seen.has(key)) return false seen.add(key) @@ -44,4 +48,5 @@ const getValidUntilTerm = (syllabuses, currentSyllabus) => { module.exports = { getValidUntilTerm, + sortSyllabusesByGiltigFrom, } diff --git a/server/utils/getValidUntilTerm.test.js b/server/utils/getValidUntilTerm.test.js index 7e4d64b8..71a2dbd5 100644 --- a/server/utils/getValidUntilTerm.test.js +++ b/server/utils/getValidUntilTerm.test.js @@ -1,4 +1,4 @@ -const { getValidUntilTerm } = require('../utils/getValidUntilTerm') +const { getValidUntilTerm, sortSyllabusesByGiltigFrom } = require('../utils/getValidUntilTerm') describe('getValidUntilTerm', () => { const make = term => ({ kursplan: { giltigfrom: term } }) @@ -26,4 +26,15 @@ describe('getValidUntilTerm', () => { const result = getValidUntilTerm(syllabuses, make('HT2018')) expect(result).toBe(undefined) }) + it('should sort syllabuses correctly', () => { + const syllabuses = [make('HT2023'), make('VT2021'), make('VT2019'), make('VT2019'), make('HT2020')] + const result = sortSyllabusesByGiltigFrom(syllabuses) + expect(result).toStrictEqual([ + { kursplan: { giltigfrom: 'VT2019' } }, + { kursplan: { giltigfrom: 'VT2019' } }, + { kursplan: { giltigfrom: 'HT2020' } }, + { kursplan: { giltigfrom: 'VT2021' } }, + { kursplan: { giltigfrom: 'HT2023' } }, + ]) + }) })