Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions MAINTENANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ Create a new issue on GitHub with this checklist after the finals every semester
- [ ] 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>
- [ ] Update academic year in `scrapers/nus-v2/src/config.ts`
- **Prepare "PR2"**
- [ ] In `app-config.json`, update `examAvailability` to include only the semesters where exam information is available
- [ ] Update `website/src/data/academic-calendar.json` with data for the new academic year
- [ ] In `app-config.json`, update `academicYear` and `examAvailability` to include only the semesters where exam information is available
- [ ] Update `packages/nusmods-academic-calendar/academic-calendar.json` with data for the new academic year
- [ ] Add announcement to website by updating `website/src/data/holidays.json`
- [ ] 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.

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

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

### 1 week before new AY Semester 1 starts

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.

- **Prepare "PR3"**
- [ ] In `app-config.json`, set `semester` to `1`
- [ ] Ensure `specialTermAcademicYear` is `null` (overlap ends when Semester 1 starts per `packages/nusmods-academic-calendar/academic-calendar.json`)
- [ ] Add the previous academic year to `archiveYears` in `app-config.json`

### When new AY Semester 1 starts

- [ ] **Merge "PR3"** to Master > Production
- [ ] Deploy and monitor Sentry and Telegram for issues

Reference PRs: [PR #3286](https://github.com/nusmodifications/nusmods/pull/3286) and [PR #3287](https://github.com/nusmodifications/nusmods/pull/3287)

## Every Semester
Expand Down
37 changes: 37 additions & 0 deletions packages/nusmods-academic-calendar/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
export type DateTuple = [number, number, number];

export type AcademicCalendar = {
readonly [acadYear: string]: {
readonly [semester: string]: { readonly start: DateTuple };
};
};

export declare const SPECIAL_TERM_SEMESTERS: readonly [3, 4];

export declare const academicCalendar: AcademicCalendar;
export default academicCalendar;

export declare function subtractAcadYear(acadYear: string): string;

export declare function getSemesterStart(acadYear: string, semester: number): Date | null;

export declare function isPreviousAySpecialTermActive(academicYear: string, date?: Date): boolean;

export declare function getEffectiveSpecialTermAcadYear(
academicYear: string,
specialTermAcademicYear?: string | null,
date?: Date,
): string;

export declare function isUsingPreviousAySpecialTermData(
academicYear: string,
specialTermAcademicYear?: string | null,
date?: Date,
): boolean;

export declare function shouldUsePreviousAyForSemester(
semester: number,
academicYear: string,
specialTermAcademicYear?: string | null,
date?: Date,
): boolean;
85 changes: 85 additions & 0 deletions packages/nusmods-academic-calendar/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
const academicCalendar = require('./academic-calendar.json');

const SPECIAL_TERM_SEMESTERS = [3, 4];

function subtractAcadYear(acadYear) {
return acadYear.replace(/\d+/g, (year) => String(parseInt(year, 10) - 1));
}

function getSemesterStart(acadYear, semester) {
const semesterConfig = academicCalendar[acadYear]?.[String(semester)];
if (!semesterConfig) {
return null;
}

const [year, month, day] = semesterConfig.start;
return new Date(year, month - 1, day);
}

/**
* Returns true when previous AY Special Term I or II is still in session after
* config has switched to the new AY. Overlap runs from previous AY ST I start
* until new AY Semester 1 starts.
*/
function isPreviousAySpecialTermActive(academicYear, date = new Date()) {
const previousAcadYear = subtractAcadYear(academicYear);
const specialTermStart = getSemesterStart(previousAcadYear, 3);
const specialTermEnd = getSemesterStart(academicYear, 1);

if (!specialTermStart || !specialTermEnd) {
return false;
}

return date >= specialTermStart && date < specialTermEnd;
}

function getEffectiveSpecialTermAcadYear(
academicYear,
specialTermAcademicYear = null,
date = new Date(),
) {
if (specialTermAcademicYear) {
return specialTermAcademicYear;
}

if (isPreviousAySpecialTermActive(academicYear, date)) {
return subtractAcadYear(academicYear);
}

return academicYear;
}

function isUsingPreviousAySpecialTermData(
academicYear,
specialTermAcademicYear = null,
date = new Date(),
) {
return (
getEffectiveSpecialTermAcadYear(academicYear, specialTermAcademicYear, date) !== academicYear
);
}

function shouldUsePreviousAyForSemester(
semester,
academicYear,
specialTermAcademicYear = null,
date = new Date(),
) {
if (!SPECIAL_TERM_SEMESTERS.includes(semester)) {
return false;
}

return isUsingPreviousAySpecialTermData(academicYear, specialTermAcademicYear, date);
}

module.exports = {
SPECIAL_TERM_SEMESTERS,
academicCalendar,
default: academicCalendar,
subtractAcadYear,
getSemesterStart,
isPreviousAySpecialTermActive,
getEffectiveSpecialTermAcadYear,
isUsingPreviousAySpecialTermData,
shouldUsePreviousAyForSemester,
};
81 changes: 81 additions & 0 deletions packages/nusmods-academic-calendar/index.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest';

import {
getEffectiveSpecialTermAcadYear,
isPreviousAySpecialTermActive,
isUsingPreviousAySpecialTermData,
shouldUsePreviousAyForSemester,
} from './index.js';

// Refer to packages/nusmods-academic-calendar/academic-calendar.json each AY's configs.

describe(isPreviousAySpecialTermActive, () => {
it('returns true during previous AY Special Term I after AY migration', () => {
expect(isPreviousAySpecialTermActive('2025/2026', new Date(2025, 4, 15))).toBe(true);
expect(isPreviousAySpecialTermActive('2025/2026', new Date(2025, 4, 12))).toBe(true);
});

it('returns true during previous AY Special Term II after AY migration', () => {
expect(isPreviousAySpecialTermActive('2025/2026', new Date(2025, 6, 1))).toBe(true);
expect(isPreviousAySpecialTermActive('2025/2026', new Date(2025, 7, 10))).toBe(true);
});

it('returns false before previous AY Special Term I starts', () => {
expect(isPreviousAySpecialTermActive('2025/2026', new Date(2025, 4, 1))).toBe(false);
expect(isPreviousAySpecialTermActive('2025/2026', new Date(2025, 4, 11))).toBe(false);
});

it('returns false after new AY semester 1 starts', () => {
expect(isPreviousAySpecialTermActive('2025/2026', new Date(2025, 7, 11))).toBe(false);
expect(isPreviousAySpecialTermActive('2025/2026', new Date(2026, 0, 1))).toBe(false);
});
});
Comment thread
leslieyip02 marked this conversation as resolved.

describe(getEffectiveSpecialTermAcadYear, () => {
it('auto-detects previous AY during overlap', () => {
expect(getEffectiveSpecialTermAcadYear('2025/2026', null, new Date(2025, 4, 15))).toBe(
'2024/2025',
);
expect(getEffectiveSpecialTermAcadYear('2025/2026', null, new Date(2025, 6, 1))).toBe(
'2024/2025',
);
expect(getEffectiveSpecialTermAcadYear('2025/2026', null, new Date(2025, 7, 10))).toBe(
'2024/2025',
);
});

it('uses current AY outside overlap', () => {
expect(getEffectiveSpecialTermAcadYear('2025/2026', null, new Date(2025, 7, 11))).toBe(
'2025/2026',
);
expect(getEffectiveSpecialTermAcadYear('2025/2026', null, new Date(2025, 8, 1))).toBe(
'2025/2026',
);
});

it('uses manual override when configured', () => {
expect(getEffectiveSpecialTermAcadYear('2025/2026', '2023/2024', new Date(2025, 6, 1))).toBe(
'2023/2024',
);
});
});

describe(isUsingPreviousAySpecialTermData, () => {
it('returns true only during overlap', () => {
expect(isUsingPreviousAySpecialTermData('2025/2026', null, new Date(2025, 4, 15))).toBe(true);
expect(isUsingPreviousAySpecialTermData('2025/2026', null, new Date(2025, 6, 1))).toBe(true);
expect(isUsingPreviousAySpecialTermData('2025/2026', null, new Date(2025, 8, 1))).toBe(false);
});
});

describe(shouldUsePreviousAyForSemester, () => {
it('returns true for special term semesters during overlap', () => {
expect(shouldUsePreviousAyForSemester(3, '2025/2026', null, new Date(2025, 4, 15))).toBe(true);
expect(shouldUsePreviousAyForSemester(4, '2025/2026', null, new Date(2025, 6, 1))).toBe(true);
});

it('returns false for normal semesters during overlap', () => {
expect(shouldUsePreviousAyForSemester(1, '2025/2026', null, new Date(2025, 6, 1))).toBe(false);
expect(shouldUsePreviousAyForSemester(2, '2025/2026', null, new Date(2025, 6, 1))).toBe(false);
});
});
21 changes: 21 additions & 0 deletions packages/nusmods-academic-calendar/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "nusmods-academic-calendar",
"version": "1.0.0",
"private": true,
"description": "NUS academic calendar semester start dates shared across NUSMods packages",
"license": "MIT",
"author": "NUSModifications",
"files": [
"academic-calendar.json",
"index.js",
"index.d.ts"
],
"main": "index.js",
"types": "index.d.ts",
"scripts": {
"test": "vitest run"
},
"devDependencies": {
"vitest": "catalog:"
}
}
26 changes: 15 additions & 11 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions scrapers/nus-v2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"joi": "17.13.3",
"lodash": "4.18.1",
"nusmoderator": "3.0.0",
"nusmods-academic-calendar": "workspace:*",
"oboe": "2.1.7",
"promise-queue": "2.2.5",
"ramda": "0.30.1",
Expand Down
1 change: 1 addition & 0 deletions scrapers/nus-v2/src/__mocks__/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const config: Config = {
elasticConfig: {
node: 'http://localhost:9200',
},
specialTermAcademicYear: null,
};

export default config;
5 changes: 5 additions & 0 deletions scrapers/nus-v2/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ export type Config = Readonly<{
// Config to connect to elasticsearch
elasticConfig?: ClientOptions;

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

studentKey: string;

ttApiKey: string;
Expand Down Expand Up @@ -54,6 +58,7 @@ const config: Config = {
// Other config
academicYear: '2025/2026',
dataPath: path.resolve(__dirname, '../data'),
specialTermAcademicYear: env.specialTermAcademicYear ?? null,
};

export default config;
Loading