Skip to content

Commit 94fe511

Browse files
jloh02cursoragentleslieyip02
authored
feat: support previous special term when academic year changes (#4402)
Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Leslie Yip <leslieyip02@gmail.com>
1 parent d424d32 commit 94fe511

24 files changed

Lines changed: 868 additions & 87 deletions

MAINTENANCE.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@ Create a new issue on GitHub with this checklist after the finals every semester
1414
- [ ] Update with next year's holiday data from academic calendar to `website/src/data/holidays.json` - Singapore & NUS Holidays (e.g. Well-Being day): <https://www.nus.edu.sg/registrar/calendar>
1515
- [ ] Update academic year in `scrapers/nus-v2/src/config.ts`
1616
- **Prepare "PR2"**
17-
- [ ] In `app-config.json`, update `examAvailability` to include only the semesters where exam information is available
18-
- [ ] Update `website/src/data/academic-calendar.json` with data for the new academic year
17+
- [ ] In `app-config.json`, update `academicYear` and `examAvailability` to include only the semesters where exam information is available
18+
- [ ] Update `packages/nusmods-academic-calendar/academic-calendar.json` with data for the new academic year
1919
- [ ] Add announcement to website by updating `website/src/data/holidays.json`
20+
- [ ] Leave `specialTermAcademicYear` as `null` — overlap with previous AY Special Term I and II is handled automatically until new AY Semester 1 starts (see PR3). Set manually (e.g. `"2024/2025"`) only if auto-detection from the academic calendar is insufficient.
2021

2122
### 1-2 days before NUS IT Data Update
2223

@@ -31,6 +32,20 @@ Create a new issue on GitHub with this checklist after the finals every semester
3132
- [ ] Deploy! :tada: :tada:
3233
- [ ] Monitor Sentry and Telegram for issues
3334

35+
### 1 week before new AY Semester 1 starts
36+
37+
PR2 is usually merged around July, while previous AY Special Term I and II run until new AY Semester 1 starts. During this window, sem 1–2 use the new AY and sem 3–4 continue to use the previous AY automatically — no action needed unless `specialTermAcademicYear` was set manually in PR2.
38+
39+
- **Prepare "PR3"**
40+
- [ ] In `app-config.json`, set `semester` to `1`
41+
- [ ] Ensure `specialTermAcademicYear` is `null` (overlap ends when Semester 1 starts per `packages/nusmods-academic-calendar/academic-calendar.json`)
42+
- [ ] Add the previous academic year to `archiveYears` in `app-config.json`
43+
44+
### When new AY Semester 1 starts
45+
46+
- [ ] **Merge "PR3"** to Master > Production
47+
- [ ] Deploy and monitor Sentry and Telegram for issues
48+
3449
Reference PRs: [PR #3286](https://github.com/nusmodifications/nusmods/pull/3286) and [PR #3287](https://github.com/nusmodifications/nusmods/pull/3287)
3550

3651
## Every Semester
File renamed without changes.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
export type DateTuple = [number, number, number];
2+
3+
export type AcademicCalendar = {
4+
readonly [acadYear: string]: {
5+
readonly [semester: string]: { readonly start: DateTuple };
6+
};
7+
};
8+
9+
export declare const SPECIAL_TERM_SEMESTERS: readonly [3, 4];
10+
11+
export declare const academicCalendar: AcademicCalendar;
12+
export default academicCalendar;
13+
14+
export declare function subtractAcadYear(acadYear: string): string;
15+
16+
export declare function getSemesterStart(acadYear: string, semester: number): Date | null;
17+
18+
export declare function isPreviousAySpecialTermActive(academicYear: string, date?: Date): boolean;
19+
20+
export declare function getEffectiveSpecialTermAcadYear(
21+
academicYear: string,
22+
specialTermAcademicYear?: string | null,
23+
date?: Date,
24+
): string;
25+
26+
export declare function isUsingPreviousAySpecialTermData(
27+
academicYear: string,
28+
specialTermAcademicYear?: string | null,
29+
date?: Date,
30+
): boolean;
31+
32+
export declare function shouldUsePreviousAyForSemester(
33+
semester: number,
34+
academicYear: string,
35+
specialTermAcademicYear?: string | null,
36+
date?: Date,
37+
): boolean;
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
const academicCalendar = require('./academic-calendar.json');
2+
3+
const SPECIAL_TERM_SEMESTERS = [3, 4];
4+
5+
function subtractAcadYear(acadYear) {
6+
return acadYear.replace(/\d+/g, (year) => String(parseInt(year, 10) - 1));
7+
}
8+
9+
function getSemesterStart(acadYear, semester) {
10+
const semesterConfig = academicCalendar[acadYear]?.[String(semester)];
11+
if (!semesterConfig) {
12+
return null;
13+
}
14+
15+
const [year, month, day] = semesterConfig.start;
16+
return new Date(year, month - 1, day);
17+
}
18+
19+
/**
20+
* Returns true when previous AY Special Term I or II is still in session after
21+
* config has switched to the new AY. Overlap runs from previous AY ST I start
22+
* until new AY Semester 1 starts.
23+
*/
24+
function isPreviousAySpecialTermActive(academicYear, date = new Date()) {
25+
const previousAcadYear = subtractAcadYear(academicYear);
26+
const specialTermStart = getSemesterStart(previousAcadYear, 3);
27+
const specialTermEnd = getSemesterStart(academicYear, 1);
28+
29+
if (!specialTermStart || !specialTermEnd) {
30+
return false;
31+
}
32+
33+
return date >= specialTermStart && date < specialTermEnd;
34+
}
35+
36+
function getEffectiveSpecialTermAcadYear(
37+
academicYear,
38+
specialTermAcademicYear = null,
39+
date = new Date(),
40+
) {
41+
if (specialTermAcademicYear) {
42+
return specialTermAcademicYear;
43+
}
44+
45+
if (isPreviousAySpecialTermActive(academicYear, date)) {
46+
return subtractAcadYear(academicYear);
47+
}
48+
49+
return academicYear;
50+
}
51+
52+
function isUsingPreviousAySpecialTermData(
53+
academicYear,
54+
specialTermAcademicYear = null,
55+
date = new Date(),
56+
) {
57+
return (
58+
getEffectiveSpecialTermAcadYear(academicYear, specialTermAcademicYear, date) !== academicYear
59+
);
60+
}
61+
62+
function shouldUsePreviousAyForSemester(
63+
semester,
64+
academicYear,
65+
specialTermAcademicYear = null,
66+
date = new Date(),
67+
) {
68+
if (!SPECIAL_TERM_SEMESTERS.includes(semester)) {
69+
return false;
70+
}
71+
72+
return isUsingPreviousAySpecialTermData(academicYear, specialTermAcademicYear, date);
73+
}
74+
75+
module.exports = {
76+
SPECIAL_TERM_SEMESTERS,
77+
academicCalendar,
78+
default: academicCalendar,
79+
subtractAcadYear,
80+
getSemesterStart,
81+
isPreviousAySpecialTermActive,
82+
getEffectiveSpecialTermAcadYear,
83+
isUsingPreviousAySpecialTermData,
84+
shouldUsePreviousAyForSemester,
85+
};
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import {
4+
getEffectiveSpecialTermAcadYear,
5+
isPreviousAySpecialTermActive,
6+
isUsingPreviousAySpecialTermData,
7+
shouldUsePreviousAyForSemester,
8+
} from './index.js';
9+
10+
// Refer to packages/nusmods-academic-calendar/academic-calendar.json each AY's configs.
11+
12+
describe(isPreviousAySpecialTermActive, () => {
13+
it('returns true during previous AY Special Term I after AY migration', () => {
14+
expect(isPreviousAySpecialTermActive('2025/2026', new Date(2025, 4, 15))).toBe(true);
15+
expect(isPreviousAySpecialTermActive('2025/2026', new Date(2025, 4, 12))).toBe(true);
16+
});
17+
18+
it('returns true during previous AY Special Term II after AY migration', () => {
19+
expect(isPreviousAySpecialTermActive('2025/2026', new Date(2025, 6, 1))).toBe(true);
20+
expect(isPreviousAySpecialTermActive('2025/2026', new Date(2025, 7, 10))).toBe(true);
21+
});
22+
23+
it('returns false before previous AY Special Term I starts', () => {
24+
expect(isPreviousAySpecialTermActive('2025/2026', new Date(2025, 4, 1))).toBe(false);
25+
expect(isPreviousAySpecialTermActive('2025/2026', new Date(2025, 4, 11))).toBe(false);
26+
});
27+
28+
it('returns false after new AY semester 1 starts', () => {
29+
expect(isPreviousAySpecialTermActive('2025/2026', new Date(2025, 7, 11))).toBe(false);
30+
expect(isPreviousAySpecialTermActive('2025/2026', new Date(2026, 0, 1))).toBe(false);
31+
});
32+
});
33+
34+
describe(getEffectiveSpecialTermAcadYear, () => {
35+
it('auto-detects previous AY during overlap', () => {
36+
expect(getEffectiveSpecialTermAcadYear('2025/2026', null, new Date(2025, 4, 15))).toBe(
37+
'2024/2025',
38+
);
39+
expect(getEffectiveSpecialTermAcadYear('2025/2026', null, new Date(2025, 6, 1))).toBe(
40+
'2024/2025',
41+
);
42+
expect(getEffectiveSpecialTermAcadYear('2025/2026', null, new Date(2025, 7, 10))).toBe(
43+
'2024/2025',
44+
);
45+
});
46+
47+
it('uses current AY outside overlap', () => {
48+
expect(getEffectiveSpecialTermAcadYear('2025/2026', null, new Date(2025, 7, 11))).toBe(
49+
'2025/2026',
50+
);
51+
expect(getEffectiveSpecialTermAcadYear('2025/2026', null, new Date(2025, 8, 1))).toBe(
52+
'2025/2026',
53+
);
54+
});
55+
56+
it('uses manual override when configured', () => {
57+
expect(getEffectiveSpecialTermAcadYear('2025/2026', '2023/2024', new Date(2025, 6, 1))).toBe(
58+
'2023/2024',
59+
);
60+
});
61+
});
62+
63+
describe(isUsingPreviousAySpecialTermData, () => {
64+
it('returns true only during overlap', () => {
65+
expect(isUsingPreviousAySpecialTermData('2025/2026', null, new Date(2025, 4, 15))).toBe(true);
66+
expect(isUsingPreviousAySpecialTermData('2025/2026', null, new Date(2025, 6, 1))).toBe(true);
67+
expect(isUsingPreviousAySpecialTermData('2025/2026', null, new Date(2025, 8, 1))).toBe(false);
68+
});
69+
});
70+
71+
describe(shouldUsePreviousAyForSemester, () => {
72+
it('returns true for special term semesters during overlap', () => {
73+
expect(shouldUsePreviousAyForSemester(3, '2025/2026', null, new Date(2025, 4, 15))).toBe(true);
74+
expect(shouldUsePreviousAyForSemester(4, '2025/2026', null, new Date(2025, 6, 1))).toBe(true);
75+
});
76+
77+
it('returns false for normal semesters during overlap', () => {
78+
expect(shouldUsePreviousAyForSemester(1, '2025/2026', null, new Date(2025, 6, 1))).toBe(false);
79+
expect(shouldUsePreviousAyForSemester(2, '2025/2026', null, new Date(2025, 6, 1))).toBe(false);
80+
});
81+
});
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"name": "nusmods-academic-calendar",
3+
"version": "1.0.0",
4+
"private": true,
5+
"description": "NUS academic calendar semester start dates shared across NUSMods packages",
6+
"license": "MIT",
7+
"author": "NUSModifications",
8+
"files": [
9+
"academic-calendar.json",
10+
"index.js",
11+
"index.d.ts"
12+
],
13+
"main": "index.js",
14+
"types": "index.d.ts",
15+
"scripts": {
16+
"test": "vitest run"
17+
},
18+
"devDependencies": {
19+
"vitest": "catalog:"
20+
}
21+
}

pnpm-lock.yaml

Lines changed: 15 additions & 11 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scrapers/nus-v2/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"joi": "17.13.3",
3232
"lodash": "4.18.1",
3333
"nusmoderator": "3.0.0",
34+
"nusmods-academic-calendar": "workspace:*",
3435
"oboe": "2.1.7",
3536
"promise-queue": "2.2.5",
3637
"ramda": "0.30.1",

scrapers/nus-v2/src/__mocks__/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const config: Config = {
1717
elasticConfig: {
1818
node: 'http://localhost:9200',
1919
},
20+
specialTermAcademicYear: null,
2021
};
2122

2223
export default config;

scrapers/nus-v2/src/config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ export type Config = Readonly<{
2222
// Config to connect to elasticsearch
2323
elasticConfig?: ClientOptions;
2424

25+
// When set, source Special Term I and II data from this AY instead of academicYear.
26+
// When null, auto-detects the overlap window after AY migration.
27+
specialTermAcademicYear: string | null;
28+
2529
studentKey: string;
2630

2731
ttApiKey: string;
@@ -54,6 +58,7 @@ const config: Config = {
5458
// Other config
5559
academicYear: '2025/2026',
5660
dataPath: path.resolve(__dirname, '../data'),
61+
specialTermAcademicYear: env.specialTermAcademicYear ?? null,
5762
};
5863

5964
export default config;

0 commit comments

Comments
 (0)