|
1 | | -import { getAcademicStart, getNextAcademicStart } from "@dotkomonline/types" |
| 1 | +import { getNextAutumnSemesterStart, getAutumnSemesterStart, isSpringSemester } from "@dotkomonline/types" |
2 | 2 | import { differenceInMonths, subYears } from "date-fns" |
3 | 3 | import invariant from "tiny-invariant" |
4 | 4 | import type { NTNUGroup } from "../feide/feide-groups-repository" |
5 | 5 | import { getLogger } from "@dotkomonline/logger" |
6 | | -import { getCurrentUTC } from "@dotkomonline/utils" |
7 | | -import type { TZDate } from "@date-fns/tz" |
8 | 6 |
|
9 | 7 | export interface MembershipService { |
10 | | - findEstimatedStudyStart( |
11 | | - study: "BACHELOR" | "MASTER", |
12 | | - courses: NTNUGroup[] |
13 | | - ): { start: TZDate; semester: number; grade: number } |
| 8 | + /** |
| 9 | + * Find the approximate semester based on a student's courses against a hard-coded set of courses. |
| 10 | + * |
| 11 | + * NOTE: The value is 0-indexed. |
| 12 | + * |
| 13 | + * Master studies begin at semester 6. |
| 14 | + * |
| 15 | + * @see getCourseStart(semester) in types package |
| 16 | + * @see getStudyGrade(semester) in types package |
| 17 | + * |
| 18 | + * @example |
| 19 | + * findEstimatedSemester(...) -> 0 // 1st semester Bachelor (Autumn) |
| 20 | + * findEstimatedSemester(...) -> 1 // 2nd semester Bachelor (Spring) |
| 21 | + * findEstimatedSemester(...) -> 2 // 3rd semester Bachelor |
| 22 | + * findEstimatedSemester(...) -> 3 // 4th semester Bachelor |
| 23 | + * findEstimatedSemester(...) -> 4 // 5th semester Bachelor |
| 24 | + * findEstimatedSemester(...) -> 5 // 6th semester Bachelor |
| 25 | + * findEstimatedSemester(...) -> 6 // 1st semester Master (regardless of prior Bachelor length) |
| 26 | + * findEstimatedSemester(...) -> 7 // 2nd semester Master |
| 27 | + * findEstimatedSemester(...) -> 8 // 3rd semester Master |
| 28 | + * findEstimatedSemester(...) -> 9 // 4th semester Master |
| 29 | + */ |
| 30 | + findEstimatedSemester(study: "BACHELOR" | "MASTER", courses: ReadonlyArray<NTNUGroup>): number |
14 | 31 | } |
15 | 32 |
|
16 | 33 | const BACHELOR_STUDY_PLAN = [ |
17 | 34 | { |
18 | 35 | semester: 0, |
19 | | - courses: ["IT2805", "MA0001", "TDT4109"], |
| 36 | + courses: ["IT2805", "MA0001", "TDT4109", "EXPH0300"], |
| 37 | + minimumEnrolledCourses: 3, |
20 | 38 | }, |
21 | 39 | { |
22 | 40 | semester: 1, |
23 | 41 | courses: ["MA0301", "TDT4100", "TDT4180", "TTM4100"], |
| 42 | + minimumEnrolledCourses: 3, |
24 | 43 | }, |
25 | 44 | { |
26 | 45 | semester: 2, |
27 | 46 | courses: ["IT1901", "TDT4120", "TDT4160"], |
| 47 | + minimumEnrolledCourses: 3, |
28 | 48 | }, |
29 | 49 | { |
30 | 50 | semester: 3, |
31 | 51 | courses: ["TDT4140", "TDT4145"], |
| 52 | + minimumEnrolledCourses: 2, |
32 | 53 | }, |
33 | 54 | { |
34 | 55 | semester: 4, |
35 | 56 | // Semester 1 in year 3 are all elective courses, so we do not use any of them to determine which year somebody |
36 | 57 | // started studying |
37 | 58 | courses: [], |
| 59 | + minimumEnrolledCourses: 0, |
38 | 60 | }, |
39 | 61 | { |
40 | 62 | semester: 5, |
41 | 63 | courses: ["IT2901"], |
| 64 | + minimumEnrolledCourses: 1, |
42 | 65 | }, |
43 | 66 | ] as const |
44 | 67 |
|
| 68 | +export const MASTER_SEMESTER_OFFSET = 6 as const |
45 | 69 | const MASTER_STUDY_PLAN = [ |
46 | 70 | { |
47 | | - semester: 0, |
| 71 | + semester: MASTER_SEMESTER_OFFSET + 0, |
48 | 72 | courses: [], |
| 73 | + minimumEnrolledCourses: 0, |
49 | 74 | }, |
50 | 75 | { |
51 | | - semester: 1, |
| 76 | + semester: MASTER_SEMESTER_OFFSET + 1, |
52 | 77 | courses: [], |
| 78 | + minimumEnrolledCourses: 0, |
53 | 79 | }, |
54 | 80 | { |
55 | | - semester: 2, |
| 81 | + semester: MASTER_SEMESTER_OFFSET + 2, |
56 | 82 | courses: ["IT3915"], |
| 83 | + minimumEnrolledCourses: 1, |
57 | 84 | }, |
58 | 85 | { |
59 | | - semester: 3, |
| 86 | + semester: MASTER_SEMESTER_OFFSET + 3, |
60 | 87 | courses: ["IT3920"], |
| 88 | + minimumEnrolledCourses: 1, |
61 | 89 | }, |
62 | 90 | ] as const |
63 | 91 |
|
@@ -90,119 +118,127 @@ export function getMembershipService(): MembershipService { |
90 | 118 | /** |
91 | 119 | * Find the approximate semester based on a student's courses against a hard-coded set of courses. |
92 | 120 | */ |
93 | | - function findEstimatedSemester(courseSet: StudyPlanCourseSet, studentCourses: NTNUGroup[]): number { |
94 | | - logger.info("Searching for applicable membership based on courses %o and study plan %o", studentCourses, courseSet) |
95 | | - let largestSemester = 0 |
| 121 | + function estimateSemester( |
| 122 | + courseSet: StudyPlanCourseSet, |
| 123 | + studentCourses: ReadonlyArray<NTNUGroup>, |
| 124 | + semesterOffset: number |
| 125 | + ): number { |
| 126 | + let largestSemester = semesterOffset |
| 127 | + |
96 | 128 | for (let i = 0; i < courseSet.length; i++) { |
97 | 129 | const semester = courseSet[i] |
98 | 130 |
|
99 | | - // Semesters with zero mandatory courses possibly increment `largestSemester` by determining the number of |
100 | | - // years since the last mandatory courses. However, if the current semester we are iterating over is the first |
101 | | - // in the study plan, we cannot get any more information that could be used to increment `largestSemester`, so |
102 | | - // we do not try. |
103 | | - const isFirstYear = i === 0 || i === 1 |
104 | | - |
105 | | - if (semester.courses.length === 0 && !isFirstYear) { |
106 | | - const previousSemesters = courseSet.slice(0, i).filter((semester) => semester.courses.length !== 0) |
107 | | - // By invariant that this is not the first year, and that there are maximum two semesters without mandatory |
108 | | - // courses, `previousSemesters` is guaranteed to have at least one element. |
109 | | - invariant(previousSemesters.length !== 0) |
110 | | - |
111 | | - // We take the ceil(mean(distances)) across all past semesters. |
112 | | - let largestLocalSemester = 0 |
113 | | - for (const previousSemester of previousSemesters.toReversed()) { |
114 | | - // The criteria here is the same as below, except we also require the course to have a finished date. |
115 | | - const hasPassedPreviousSemester = previousSemester.courses.every((course) => |
116 | | - studentCourses.some( |
117 | | - (studentCourse) => course === studentCourse.code && studentCourse.finished !== undefined |
118 | | - ) |
119 | | - ) |
| 131 | + // Has mandatory courses for this semester |
| 132 | + if (semester.courses.length > 0) { |
| 133 | + const mandatoryCoursesEnrolledIn = semester.courses.filter((course) => |
| 134 | + studentCourses.some((studentCourse) => course === studentCourse.code) |
| 135 | + ) |
| 136 | + |
| 137 | + if (mandatoryCoursesEnrolledIn.length >= semester.minimumEnrolledCourses) { |
| 138 | + largestSemester = semester.semester |
| 139 | + continue |
| 140 | + } else { |
| 141 | + // If the user does not have all the courses required for this semester, we would much rather prefer to give |
| 142 | + // them a lower year than a higher one. Chances are a student would notify HS (basically our administration) |
| 143 | + // if they cannot attend events they should be able to, while someone might not notify HS about them being able |
| 144 | + // to attend company events they are not supposed to be at. |
| 145 | + break |
| 146 | + } |
| 147 | + } |
| 148 | + |
| 149 | + // Since there are no mandatory courses for this course set, we need to estimate from the previous course sets. |
| 150 | + // To be able to determine this, there cannot be more than two semesters in a row without mandatory courses. This |
| 151 | + // is validated in `validateStudyPlanCourseSet`. |
| 152 | + const previousCourseSetsWithMandatoryCourses = courseSet |
| 153 | + .slice(0, i) |
| 154 | + .filter((semester) => semester.courses.length !== 0) |
| 155 | + |
| 156 | + // If there are no previous mandatory courses, we cannot really make any estimation. We |
| 157 | + const previousMandatoryCoursesCount = previousCourseSetsWithMandatoryCourses.reduce( |
| 158 | + (acc, semester) => acc + semester.courses.length, |
| 159 | + 0 |
| 160 | + ) |
120 | 161 |
|
121 | | - if (!hasPassedPreviousSemester) { |
122 | | - continue |
123 | | - } |
124 | | - |
125 | | - // There is a scenario where a user failed a course in year 1, but passed in year 2. This is why we take the |
126 | | - // mean distance, which is later ceiled. |
127 | | - const previousSemesterDistances = previousSemester.courses.map((course) => { |
128 | | - const studentCourse = studentCourses.find((studentCourse) => studentCourse.code === course) |
129 | | - |
130 | | - // -1 because length is 1-indexed |
131 | | - const semesterDistanceFromEnd = courseSet.length - 1 - previousSemester.semester |
132 | | - // If you were supposed to finish your degree this year, how far away would the semester in question be? |
133 | | - // For example; if previousSemester=3 (algdat+itp+datdig), then the distance would be 2. |
134 | | - const years = Math.ceil(semesterDistanceFromEnd / 2) |
135 | | - const courseEndAssumingLastYearStudent = subYears(getNextAcademicStart(), years) |
136 | | - |
137 | | - // INVARIANT: The course should exist, and it should be finished according to `hasPassedPreviousSemester`. |
138 | | - invariant(studentCourse !== undefined && studentCourse.finished !== undefined) |
139 | | - // We divide by six because we have two school semesters in a year, effectively turnings months in a year |
140 | | - // into semesters |
141 | | - const distance = Math.floor( |
142 | | - differenceInMonths(getAcademicStart(studentCourse.finished), courseEndAssumingLastYearStudent) / 6 |
143 | | - ) |
144 | | - |
145 | | - return previousSemester.semester + (semesterDistanceFromEnd - distance) |
146 | | - }) |
147 | | - |
148 | | - // Take the mean distance for this semester |
149 | | - const sum = previousSemesterDistances.reduce((acc, curr) => acc + curr, 0) |
150 | | - const currentSemesterEstimate = Math.ceil(sum / previousSemesterDistances.length) |
151 | | - |
152 | | - largestLocalSemester = Math.max(largestLocalSemester, currentSemesterEstimate) |
| 162 | + // If it is the second semester, and the first semester had no mandatory courses, we cannot really make any real |
| 163 | + // estimation. But we feel comfortable incrementing the semester by one, since it would still be the first year |
| 164 | + // (relative to the semester offset). |
| 165 | + if (previousMandatoryCoursesCount === 0) { |
| 166 | + // We need to determine if we are in spring or autumn semester to be able to increment correctly. |
| 167 | + const isEvenOffset = semesterOffset % 2 === 0 |
| 168 | + const isSpring = isSpringSemester() |
| 169 | + |
| 170 | + // In practice, with the current (2026) Master study plan, the first two semesters have no mandatory courses, |
| 171 | + // but we would still like them to be put into semester 6 and 7 respectively. |
| 172 | + // - If even offset (start autumn) we increment if it is currently spring. |
| 173 | + // - If odd offset (start spring) we increment if it is currently autumn. |
| 174 | + // NOTE: This scenario is not possible with the current study plans, but we keep it for future-proofing. |
| 175 | + if (isEvenOffset === isSpring) { |
| 176 | + largestSemester = semesterOffset + 1 |
| 177 | + } else { |
| 178 | + largestSemester = semesterOffset |
153 | 179 | } |
154 | 180 |
|
155 | | - largestSemester = Math.max(largestSemester, largestLocalSemester) |
156 | 181 | continue |
157 | 182 | } |
158 | 183 |
|
159 | | - // If the user has all the courses that are mandatory |
160 | | - const isGroupMemberOfMandatoryCourses = semester.courses.every((course) => |
161 | | - studentCourses.some((studentCourse) => course === studentCourse.code) |
162 | | - ) |
| 184 | + // By invariant that this is not the first year, and that there are maximum two semesters without mandatory |
| 185 | + // courses, `previousSemesters` is guaranteed to have at least one element. |
| 186 | + invariant(previousCourseSetsWithMandatoryCourses.length !== 0) |
163 | 187 |
|
164 | | - if (isGroupMemberOfMandatoryCourses) { |
165 | | - largestSemester = semester.semester |
166 | | - } else { |
167 | | - // If the user does not have all the courses required for this semester, we would much rather prefer to give |
168 | | - // them a lower year than a higher one. Chances are a student would notify HS (basically our administration) |
169 | | - // if they cannot attend events they should be able to, while someone might not notify HS about them being able |
170 | | - // to attend company events they are not supposed to be at. |
171 | | - break |
| 188 | + // We take the ceil(mean(distances)) across all past semesters. |
| 189 | + let largestLocalSemester = 0 |
| 190 | + for (const previousSemester of previousCourseSetsWithMandatoryCourses.toReversed()) { |
| 191 | + // The criteria here is the same as below, except we also require the course to have a finished date. |
| 192 | + const hasPassedPreviousSemester = previousSemester.courses.every((course) => |
| 193 | + studentCourses.some((studentCourse) => course === studentCourse.code && studentCourse.finished !== undefined) |
| 194 | + ) |
| 195 | + |
| 196 | + if (!hasPassedPreviousSemester) { |
| 197 | + continue |
| 198 | + } |
| 199 | + |
| 200 | + // There is a scenario where a user failed a course in year 1, but passed in year 2. This is why we take the |
| 201 | + // mean distance, which is later ceiled. |
| 202 | + const previousSemesterDistances = previousSemester.courses.map((course) => { |
| 203 | + const studentCourse = studentCourses.find((studentCourse) => studentCourse.code === course) |
| 204 | + |
| 205 | + // -1 because length is 1-indexed |
| 206 | + const semesterDistanceFromEnd = courseSet.length - 1 - previousSemester.semester |
| 207 | + // If you were supposed to finish your degree this year, how far away would the semester in question be? |
| 208 | + // For example; if previousSemester=3 (algdat+itp+datdig), then the distance would be 2. |
| 209 | + const years = Math.ceil(semesterDistanceFromEnd / 2) |
| 210 | + const courseEndAssumingLastYearStudent = subYears(getNextAutumnSemesterStart(), years) |
| 211 | + |
| 212 | + // INVARIANT: The course should exist, and it should be finished according to `hasPassedPreviousSemester`. |
| 213 | + invariant(studentCourse !== undefined && studentCourse.finished !== undefined) |
| 214 | + // We divide by six because we have two school semesters in a year, effectively turnings months in a year |
| 215 | + // into semesters |
| 216 | + const distance = Math.floor( |
| 217 | + differenceInMonths(getAutumnSemesterStart(studentCourse.finished), courseEndAssumingLastYearStudent) / 6 |
| 218 | + ) |
| 219 | + |
| 220 | + return previousSemester.semester + (semesterDistanceFromEnd - distance) |
| 221 | + }) |
| 222 | + |
| 223 | + // Take the mean distance for this semester |
| 224 | + const sum = previousSemesterDistances.reduce((acc, curr) => acc + curr, 0) |
| 225 | + const currentSemesterEstimate = Math.ceil(sum / previousSemesterDistances.length) |
| 226 | + |
| 227 | + largestLocalSemester = Math.max(largestLocalSemester, currentSemesterEstimate) |
172 | 228 | } |
| 229 | + |
| 230 | + largestSemester = Math.max(largestSemester, largestLocalSemester) |
173 | 231 | } |
174 | 232 |
|
175 | 233 | return largestSemester |
176 | 234 | } |
177 | 235 |
|
178 | 236 | return { |
179 | | - findEstimatedStudyStart(study, courses) { |
| 237 | + findEstimatedSemester(study, courses) { |
180 | 238 | const studyPlan = study === "MASTER" ? MASTER_STUDY_PLAN : BACHELOR_STUDY_PLAN |
181 | | - const semester = findEstimatedSemester(studyPlan, courses) |
182 | | - |
183 | | - // We use Math#round because it will give us the correct year delta: |
184 | | - // Year 1 autumn (value 0) : round(0 / 2) = 0 (Start year = current year) |
185 | | - // Year 1 spring (value 1): round(1 / 2) = 1 (Start year = current - 1, since spring is in the next calendar year) |
186 | | - // Year 2 autumn (value 2) : round(2 / 2) = 1 (Start year = current - 1) |
187 | | - // Year 2 spring (value 3): round(3 / 2) = 2 (Start year = current - 2) |
188 | | - // ... |
189 | | - const yearOffset = Math.round(semester / 2) |
190 | | - const start = subYears(getAcademicStart(getCurrentUTC()), yearOffset) |
191 | | - |
192 | | - // A school year consists of two semesters (Autumn and Spring). So this formula will give us the year: |
193 | | - // Year 1 autumn (value 0): floor(0 / 2) + 1 = 1 (Year 1) |
194 | | - // Year 1 spring (value 1): floor(1 / 2) + 1 = 1 (Year 1) |
195 | | - // Year 2 autumn (value 2): floor(2 / 2) + 1 = 2 (Year 2) |
196 | | - // Year 2 spring (value 3): floor(3 / 2) + 1 = 2 (Year 2) |
197 | | - // Year 3 autumn (value 4): floor(4 / 2) + 1 = 3 (Year 3) |
198 | | - // ... |
199 | | - const grade = Math.floor(semester / 2) + 1 |
200 | | - |
201 | | - return { |
202 | | - semester, |
203 | | - start, |
204 | | - grade, |
205 | | - } |
| 239 | + const offset = study === "MASTER" ? MASTER_SEMESTER_OFFSET : 0 |
| 240 | + |
| 241 | + return estimateSemester(studyPlan, courses, offset) |
206 | 242 | }, |
207 | 243 | } |
208 | 244 | } |
0 commit comments