Skip to content

Commit ccb4c36

Browse files
Fix membership calculation (#2771)
This PR fixes two bugs that were causing students to be assigned a start year 1 year later than reality (e.g., 2024 instead of 2023). ### Fix loop overwrite in empty semesters The logic for estimating progress during empty semesters (like bachelor semester 4) was flawed. We iterated through all previous semesters to guess the current year, but we were overwriting the result in every iteration. Now we `Math#max` the current and local values to find the highest instead. ### Fix off-by-one rounding error We were converting semesters to years using `Math.floor(semester / 2)`. This failed for spring semesters. > Example: semester 3 (spring of 2nd year) became `floor(1.5) = 1`. This made the system think 2nd-year students were only in their 1st year. This is fixed by changing it to `Math.round(semester / 2)`, so that semester 3 correctly resolves to 2 years.
1 parent 947a065 commit ccb4c36

File tree

4 files changed

+57
-23
lines changed

4 files changed

+57
-23
lines changed

apps/rpc/src/modules/user/membership-service.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ import type { NTNUGroup } from "../feide/feide-groups-repository"
55
import { getLogger } from "@dotkomonline/logger"
66

77
export interface MembershipService {
8-
findApproximateMasterStartYear(courses: NTNUGroup[]): number
9-
findApproximateBachelorStartYear(courses: NTNUGroup[]): number
8+
findMasterStartYearDelta(courses: NTNUGroup[]): number
9+
findBachelorStartYearDelta(courses: NTNUGroup[]): number
1010
}
1111

12-
const BACHELOR_STUDY_PLAN_COURSES = [
12+
const BACHELOR_STUDY_PLAN = [
1313
{
1414
semester: 0,
1515
courses: ["IT2805", "MA0001", "TDT4109"],
@@ -28,7 +28,7 @@ const BACHELOR_STUDY_PLAN_COURSES = [
2828
},
2929
{
3030
semester: 4,
31-
// NOTE: Semester 1 in Year 3 are all elective courses, so we do not use any of them to determine which year somebody
31+
// Semester 1 in year 3 are all elective courses, so we do not use any of them to determine which year somebody
3232
// started studying
3333
courses: [],
3434
},
@@ -57,13 +57,13 @@ const MASTER_STUDY_PLAN = [
5757
},
5858
] as const
5959

60-
type StudyPlanCourseSet = typeof BACHELOR_STUDY_PLAN_COURSES | typeof MASTER_STUDY_PLAN
60+
type StudyPlanCourseSet = typeof BACHELOR_STUDY_PLAN | typeof MASTER_STUDY_PLAN
6161

6262
export function getMembershipService(): MembershipService {
6363
const logger = getLogger("membership-service")
6464
// The study plan course set makes some assumptions for the approximation code to work as expected. In order to make
6565
// it easier for future dotkom developers, the invariants are checked here.
66-
validateStudyPlanCourseSet(BACHELOR_STUDY_PLAN_COURSES)
66+
validateStudyPlanCourseSet(BACHELOR_STUDY_PLAN)
6767
validateStudyPlanCourseSet(MASTER_STUDY_PLAN)
6868

6969
function validateStudyPlanCourseSet(courseSet: StudyPlanCourseSet) {
@@ -143,7 +143,9 @@ export function getMembershipService(): MembershipService {
143143

144144
// Take the mean distance for this semester
145145
const sum = previousSemesterDistances.reduce((acc, curr) => acc + curr, 0)
146-
largestLocalSemester = Math.ceil(sum / previousSemesterDistances.length)
146+
const currentSemesterEstimate = Math.ceil(sum / previousSemesterDistances.length)
147+
148+
largestLocalSemester = Math.max(largestLocalSemester, currentSemesterEstimate)
147149
}
148150

149151
largestSemester = Math.max(largestSemester, largestLocalSemester)
@@ -167,15 +169,20 @@ export function getMembershipService(): MembershipService {
167169
}
168170

169171
// Give the value back in years (two school semesters in a year).
170-
return Math.floor(largestSemester / 2)
172+
// We use Math#round because it will give us the correct year delta:
173+
// Year 1 fall (value 0) : round(0 / 2) = 0 (Start year = current year)
174+
// Year 1 spring (value 1): round(1 / 2) = 1 (Start year = current - 1, since spring is in the next calendar year)
175+
// Year 2 fall (value 2) : round(2 / 2) = 1 (Start year = current - 1)
176+
// Year 2 spring (value 3): round(3 / 2) = 2 (Start year = current - 2)
177+
return Math.round(largestSemester / 2)
171178
}
172179
return {
173-
findApproximateMasterStartYear(courses) {
180+
findMasterStartYearDelta(courses) {
174181
return findApproximateStartYear(courses, MASTER_STUDY_PLAN)
175182
},
176183

177-
findApproximateBachelorStartYear(courses) {
178-
return findApproximateStartYear(courses, BACHELOR_STUDY_PLAN_COURSES)
184+
findBachelorStartYearDelta(courses) {
185+
return findApproximateStartYear(courses, BACHELOR_STUDY_PLAN)
179186
},
180187
}
181188
}

apps/rpc/src/modules/user/membership.e2e-spec.ts

Whitespace-only changes.

apps/rpc/src/modules/user/user-service.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,8 @@ export function getUserService(
122122
// Master degree always takes precedence over bachelor every single time.
123123
const isMasterStudent = masterProgramme !== undefined
124124
const distanceFromStartInYears = isMasterStudent
125-
? membershipService.findApproximateMasterStartYear(courses)
126-
: membershipService.findApproximateBachelorStartYear(courses)
125+
? membershipService.findMasterStartYearDelta(courses)
126+
: membershipService.findBachelorStartYearDelta(courses)
127127
const estimatedStudyStart = subYears(getAcademicStart(getCurrentUTC()), distanceFromStartInYears)
128128

129129
// NOTE: We grant memberships for at most one year at a time. If you are granted membership after new-years, you
@@ -215,7 +215,7 @@ export function getUserService(
215215
},
216216

217217
async register(handle, userId) {
218-
// NOTE: The registrer function here has a few responsibilities because of our data strategy:
218+
// NOTE: The register function here has a few responsibilities because of our data strategy:
219219
//
220220
// 1. The database is the source of truth, and is ALWAYS intended to be as such.
221221
// 2. Unfortunately, there was a period in time where Auth0 was the source of truth, most notably right after we

packages/types/src/user.ts

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { TZDate } from "@date-fns/tz"
22
import { schemas } from "@dotkomonline/db/schemas"
33
import { getCurrentUTC, slugify } from "@dotkomonline/utils"
4-
import { addYears, differenceInYears, isAfter, isBefore, setMonth, startOfMonth } from "date-fns"
4+
import { addYears, isAfter, isBefore, setMonth, startOfMonth } from "date-fns"
55
import { z } from "zod"
66
import { buildSearchFilter } from "./filters"
77

@@ -31,7 +31,7 @@ export type UserId = User["id"]
3131
export type UserProfileSlug = User["profileSlug"]
3232

3333
export const NAME_REGEX = /^[\p{L}\p{M}\s'-]+$/u
34-
export const PHONE_REGEX = /^[0-9+-\s]*$/
34+
export const PHONE_REGEX = /^[0-9-+\s]*$/
3535
export const PROFILE_SLUG_REGEX = /^[a-z0-9-]+$/
3636

3737
// These max and min values are arbitrary
@@ -93,25 +93,30 @@ export function findActiveMembership(user: User): Membership | null {
9393
}
9494

9595
export function getMembershipGrade(membership: Membership): 1 | 2 | 3 | 4 | 5 | null {
96-
// Take the difference, and add one because if `startYear == currentYear` they are in their first year
97-
const delta = differenceInYears(getAcademicStart(getCurrentUTC()), getAcademicStart(membership.start)) + 1
96+
const now = getCurrentUTC()
97+
98+
// Make sure we clamp the value to a minimum of 1
99+
const delta = Math.max(1, getAcademicYearDelta(membership.start, now))
98100

99101
switch (membership.type) {
100102
case "KNIGHT":
101103
case "PHD_STUDENT":
102104
return 5
105+
103106
case "SOCIAL_MEMBER":
104107
return 1
108+
105109
case "BACHELOR_STUDENT": {
106110
// Bachelor students are clamped at 1-3, regardless of how many years they used to take the degree.
107-
return Math.max(1, Math.min(3, delta)) as 1 | 2 | 3
111+
return Math.min(3, delta) as 1 | 2 | 3
108112
}
113+
109114
case "MASTER_STUDENT": {
110-
// Master students must be clamped at 4-5 because they can only be in their first or second year, but are always
111-
// considered to have a bachelor's degree from beforehand.
112-
const yearsGivenBachelors = delta + 3
113-
return Math.max(4, Math.min(5, yearsGivenBachelors)) as 4 | 5
115+
// Master students are clamped at 4-5, and are always considered to have a bachelor's degree from beforehand.
116+
const yearsWithBachelors = delta + 3
117+
return Math.min(5, yearsWithBachelors) as 4 | 5
114118
}
119+
115120
case "OTHER":
116121
return null
117122
}
@@ -161,3 +166,25 @@ export function getNextAcademicStart(): TZDate {
161166
const isBeforeAugust = isBefore(now, firstAugust)
162167
return isBeforeAugust ? firstAugust : addYears(firstAugust, 1)
163168
}
169+
170+
/**
171+
* Calculates how many academic years have passed since the start date.
172+
* If start is "last August" (current academic year), returns 1.
173+
* If start was the August before that, returns 2.
174+
*/
175+
function getAcademicYearDelta(startDate: Date | TZDate, now: Date | TZDate = getCurrentUTC()): number {
176+
const currentYear = now.getFullYear()
177+
const currentMonth = now.getMonth() // 0-indexed (Jan=0, Aug=7)
178+
179+
// If we are in Jan-July (0-6), the academic year started in the PREVIOUS calendar year
180+
// If we are in Aug-Dec (7-11), the academic year started in THIS calendar year
181+
const academicYearCurrent = currentMonth >= 7 ? currentYear : currentYear - 1
182+
183+
// We do the same normalization for the membership start date
184+
// (Handling cases where a member might join in Jan/Feb)
185+
const startYear = startDate.getFullYear()
186+
const startMonth = startDate.getMonth()
187+
const academicYearStart = startMonth >= 7 ? startYear : startYear - 1
188+
189+
return academicYearCurrent - academicYearStart + 1
190+
}

0 commit comments

Comments
 (0)