Skip to content
Open
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
16 changes: 16 additions & 0 deletions website/src/actions/planner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,22 @@ export function setPlaceholderModule(id: string, moduleCode: ModuleCode) {
};
}

export const ADD_PLANNER_PROGRAMME = 'ADD_PLANNER_PROGRAMME' as const;
export function addPlannerProgramme(programmeId: string) {
return {
type: ADD_PLANNER_PROGRAMME,
payload: { programmeId },
};
}

export const REMOVE_PLANNER_PROGRAMME = 'REMOVE_PLANNER_PROGRAMME' as const;
export function removePlannerProgramme(programmeId: string) {
return {
type: REMOVE_PLANNER_PROGRAMME,
payload: { programmeId },
};
}

export const ADD_CUSTOM_PLANNER_DATA = 'ADD_CUSTOM_PLANNER_DATA' as const;
export function addCustomModule(moduleCode: ModuleCode, data: CustomModule) {
return {
Expand Down
139 changes: 139 additions & 0 deletions website/src/data/programmes/cs-focus-areas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { ProgrammeMap } from 'types/programmes';

// CS focus areas are satisfied by completing 3 courses from the Area
// Primaries, with at least one course at level-4000 or above. This is
// modelled as two requirements: one level-4000 primary and two other
// primaries. Electives do not count towards satisfying a focus area, but
// are tracked as a pool so their MCs count towards the focus area total.
//
// Not modelled (see the * and # footnotes on the source page): courses that
// are cores in the student's degree cannot double-count towards CS Breadth
// and Depth, and some courses have an enforced selection process.
const programmes: ProgrammeMap = {
'cs-focus-artificial-intelligence': {
id: 'cs-focus-artificial-intelligence',
name: 'Artificial Intelligence',
type: 'focusArea',
faculty: 'School of Computing',
source: 'https://www.comp.nus.edu.sg/programmes/ug/focus/',
lastVerified: '2026-07-09',
requirements: [
{
id: 'primaries-4000',
name: 'One level-4000 Area Primary',
minModules: 1,
matchers: [
{
kind: 'modules',
codes: ['CS4243', 'CS4244', 'CS4246', 'CS4248', 'CS4262', 'CS4263'],
},
],
},
{
id: 'primaries',
name: 'Two other Area Primaries',
minModules: 2,
matchers: [
{
kind: 'modules',
codes: [
'CS2109S',
'CS3243',
'CS3244',
'CS3263',
'CS3264',
'CS3268',
'CS4243',
'CS4244',
'CS4246',
'CS4248',
'CS4262',
'CS4263',
],
},
],
},
{
id: 'electives',
name: 'Electives',
description: 'Electives do not count towards satisfying the focus area',
matchers: [
{
kind: 'modules',
codes: [
'CS4220',
'CS4261',
'CS4269',
'CS4277',
'CS4278',
'CS5215',
'CS5228',
'CS5242',
'CS5260',
'CS5339',
'CS5340',
],
},
],
},
],
},

'cs-focus-software-engineering': {
id: 'cs-focus-software-engineering',
name: 'Software Engineering',
type: 'focusArea',
faculty: 'School of Computing',
source: 'https://www.comp.nus.edu.sg/programmes/ug/focus/',
lastVerified: '2026-07-09',
requirements: [
{
id: 'primaries-4000',
name: 'One level-4000 Area Primary',
minModules: 1,
matchers: [
{
kind: 'modules',
codes: ['CS4211', 'CS4218', 'CS4239'],
},
],
},
{
id: 'primaries',
name: 'Two other Area Primaries',
minModules: 2,
matchers: [
{
kind: 'modules',
codes: [
// CS2103/T on the source page
'CS2103',
'CS2103T',
'CS3213',
'CS3217',
'CS3219',
'CS3227',
'CS3282',
'CS4211',
'CS4218',
'CS4239',
],
},
],
},
{
id: 'electives',
name: 'Electives',
description: 'Electives do not count towards satisfying the focus area',
matchers: [
{
kind: 'modules',
codes: ['CS3203', 'CS3216', 'CS3226', 'CS3234', 'CS3281', 'CS5219', 'CS5232', 'CS5272'],
},
],
},
],
},
};

export default programmes;
49 changes: 49 additions & 0 deletions website/src/data/programmes/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import programmes, { programmeList } from '.';

const MODULE_CODE_FORMAT = /^[A-Z]{2,4}\d{4}[A-Z]{0,3}$/;

describe('programmes', () => {
test('should use the programme id as its key', () => {
Object.entries(programmes).forEach(([id, programme]) => {
expect(programme.id).toEqual(id);
});
});

test('should have data provenance fields', () => {
programmeList.forEach((programme) => {
expect(programme.source).toMatch(/^https:\/\//);
expect(programme.lastVerified).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
});

test('should have unique requirement ids within each programme', () => {
programmeList.forEach((programme) => {
const ids = programme.requirements.map((requirement) => requirement.id);
expect(new Set(ids).size).toEqual(ids.length);
});
});

test('should have at least one matcher for every requirement', () => {
programmeList.forEach((programme) =>
programme.requirements.forEach((requirement) => {
expect(requirement.matchers.length).toBeGreaterThan(0);
}),
);
});

test('should only contain well-formed module codes and prefixes', () => {
programmeList.forEach((programme) =>
programme.requirements.forEach((requirement) =>
requirement.matchers.forEach((matcher) => {
if (matcher.kind === 'modules') {
expect(matcher.codes.length).toBeGreaterThan(0);
matcher.codes.forEach((code) => expect(code).toMatch(MODULE_CODE_FORMAT));
} else {
expect(matcher.prefixes.length).toBeGreaterThan(0);
matcher.prefixes.forEach((prefix) => expect(prefix).toMatch(/^[A-Z]{2,4}$/));
}
}),
),
);
});
});
19 changes: 19 additions & 0 deletions website/src/data/programmes/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Programme, ProgrammeMap } from 'types/programmes';

import csFocusAreas from './cs-focus-areas';
import socMinors from './soc-minors';

// Community-maintained programme requirement data. Each programme records
// the official page it was transcribed from (source) and when it was last
// checked (lastVerified). To add a programme, create or extend a file in
// this folder and merge it here.
const programmes: ProgrammeMap = {
...csFocusAreas,
...socMinors,
};

export const programmeList: Programme[] = Object.values(programmes).sort(
(a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name),
);

export default programmes;
90 changes: 90 additions & 0 deletions website/src/data/programmes/soc-minors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { ProgrammeMap } from 'types/programmes';

const programmes: ProgrammeMap = {
'soc-minor-computer-science': {
id: 'soc-minor-computer-science',
name: 'Minor in Computer Science',
type: 'minor',
faculty: 'School of Computing',
source: 'https://www.comp.nus.edu.sg/programmes/ug/minorc/cs-minor/',
lastVerified: '2026-07-09',
cohort: 'AY2021/22 and after',
totalMCs: 20,
requirements: [
{
id: 'category-i',
name: 'Category I: Programming Methodology',
description: 'CS1010 or an equivalent course',
minModules: 1,
matchers: [
{
kind: 'modules',
codes: [
'CS1010',
// Equivalents listed on the source page
'CS1010E',
'CS1010FC',
'CS1010X',
'CS1010J',
'CS1010S',
'CS1101S',
'CS1020E',
],
},
],
},
{
id: 'category-ii',
name: 'Category II: Three foundation courses',
minModules: 3,
minMCs: 12,
matchers: [
{
kind: 'modules',
codes: [
// Alternatives from the source page footnotes: CS1231S or
// MA1100/T for CS1231, CS2030S for CS2030, CS2040C/S for
// CS2040, CS2113 for CS2103, EE4204 for CS2105
'CS1231',
'CS1231S',
'MA1100',
'MA1100T',
'CS2030',
'CS2030S',
'CS2040',
'CS2040C',
'CS2040S',
'CS2100',
'CS2102',
'CS2103',
'CS2113',
'CS2104',
'CS2105',
'EE4204',
'CS2106',
'CS2107',
'CS2108',
'CS2109S',
],
},
],
},
{
id: 'category-iii',
name: 'Category III: Level-3000/4000 CS courses',
description: 'CS-coded courses at level-3000 and 4000 to reach 20 units',
minMCs: 4,
matchers: [
{
kind: 'prefix',
prefixes: ['CS'],
minLevel: 3000,
maxLevel: 4999,
},
],
},
],
},
};

export default programmes;
44 changes: 43 additions & 1 deletion website/src/reducers/planner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,13 @@ import {
importPlanner,
CLEAR_PLANNER,
clearPlanner,
ADD_PLANNER_PROGRAMME,
addPlannerProgramme,
REMOVE_PLANNER_PROGRAMME,
removePlannerProgramme,
} from 'actions/planner';
import { PlannerState } from 'types/reducers';
import reducer, { defaultPlannerState, migrateV0toV1, nextId } from './planner';
import reducer, { defaultPlannerState, migrateV0toV1, migrateV1toV2, nextId } from './planner';

const defaultState: PlannerState = {
...defaultPlannerState,
Expand Down Expand Up @@ -220,6 +224,7 @@ describe(IMPORT_PLANNER, () => {
custom: {
CS1010A: { title: 'CS1010B', moduleCredit: 4 },
},
programmes: ['soc-minor-computer-science'],
};
test('should import the given planner', () => {
expect(reducer(initial, importPlanner(importedState))).toEqual(importedState);
Expand All @@ -240,6 +245,35 @@ describe(CLEAR_PLANNER, () => {
});
});

describe(ADD_PLANNER_PROGRAMME, () => {
test('should add a programme', () => {
expect(
reducer(defaultState, addPlannerProgramme('soc-minor-computer-science')).programmes,
).toEqual(['soc-minor-computer-science']);
});

test('should not add a programme twice', () => {
const initial: PlannerState = { ...defaultState, programmes: ['soc-minor-computer-science'] };

expect(reducer(initial, addPlannerProgramme('soc-minor-computer-science')).programmes).toEqual([
'soc-minor-computer-science',
]);
});
});

describe(REMOVE_PLANNER_PROGRAMME, () => {
test('should remove a programme', () => {
const initial: PlannerState = {
...defaultState,
programmes: ['cs-focus-artificial-intelligence', 'soc-minor-computer-science'],
};

expect(
reducer(initial, removePlannerProgramme('cs-focus-artificial-intelligence')).programmes,
).toEqual(['soc-minor-computer-science']);
});
});

describe(migrateV0toV1, () => {
test('should migrate old modules state to new modules state', () => {
expect(
Expand All @@ -259,3 +293,11 @@ describe(migrateV0toV1, () => {
});
});
});

describe(migrateV1toV2, () => {
test('should add an empty programmes array', () => {
const { programmes, ...v1State } = defaultState;

expect(migrateV1toV2({ _persist: {} as any, ...v1State })).toHaveProperty('programmes', []);
});
});
Loading