From 11bcb39c2cfcbf2fc16a13c29e68832c02bda6b6 Mon Sep 17 00:00:00 2001 From: eugenewong22 <250109759+eugenewong22@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:23:56 +0800 Subject: [PATCH 1/4] feat(website): add programme requirement data and fulfilment checker Adds a declarative schema for specialisation/focus area/minor requirements, a hand-curated pilot dataset (CS Artificial Intelligence and Software Engineering focus areas, Minor in Computer Science) transcribed from official SoC pages, and a pure fulfilment checker. Double-counting within a programme is resolved with maximum bipartite matching (Kuhn's augmenting paths) over requirement slots, with a top-up pass for requirements measured in units rather than courses. Part of #4390 Co-Authored-By: Claude Fable 5 --- website/src/data/programmes/cs-focus-areas.ts | 139 +++++++++ website/src/data/programmes/index.test.ts | 49 +++ website/src/data/programmes/index.ts | 19 ++ website/src/data/programmes/soc-minors.ts | 90 ++++++ website/src/types/programmes.ts | 64 ++++ website/src/utils/programmes.test.ts | 287 ++++++++++++++++++ website/src/utils/programmes.ts | 206 +++++++++++++ 7 files changed, 854 insertions(+) create mode 100644 website/src/data/programmes/cs-focus-areas.ts create mode 100644 website/src/data/programmes/index.test.ts create mode 100644 website/src/data/programmes/index.ts create mode 100644 website/src/data/programmes/soc-minors.ts create mode 100644 website/src/types/programmes.ts create mode 100644 website/src/utils/programmes.test.ts create mode 100644 website/src/utils/programmes.ts diff --git a/website/src/data/programmes/cs-focus-areas.ts b/website/src/data/programmes/cs-focus-areas.ts new file mode 100644 index 0000000000..285c63f346 --- /dev/null +++ b/website/src/data/programmes/cs-focus-areas.ts @@ -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; diff --git a/website/src/data/programmes/index.test.ts b/website/src/data/programmes/index.test.ts new file mode 100644 index 0000000000..2e1df8ea87 --- /dev/null +++ b/website/src/data/programmes/index.test.ts @@ -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}$/)); + } + }), + ), + ); + }); +}); diff --git a/website/src/data/programmes/index.ts b/website/src/data/programmes/index.ts new file mode 100644 index 0000000000..84e0e192c0 --- /dev/null +++ b/website/src/data/programmes/index.ts @@ -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; diff --git a/website/src/data/programmes/soc-minors.ts b/website/src/data/programmes/soc-minors.ts new file mode 100644 index 0000000000..824338d2b4 --- /dev/null +++ b/website/src/data/programmes/soc-minors.ts @@ -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; diff --git a/website/src/types/programmes.ts b/website/src/types/programmes.ts new file mode 100644 index 0000000000..251a7ebe7d --- /dev/null +++ b/website/src/types/programmes.ts @@ -0,0 +1,64 @@ +import { ModuleCode } from './modules'; + +export type ProgrammeType = 'specialisation' | 'focusArea' | 'minor'; + +// Matchers are declarative (no functions) so that programme data remains +// serializable and can move to the API or a JSON dataset without changes. +export type ModuleMatcher = + | { kind: 'modules'; codes: readonly ModuleCode[] } + | { kind: 'prefix'; prefixes: readonly string[]; minLevel?: number; maxLevel?: number }; + +export type ProgrammeRequirement = { + // Unique within the programme + id: string; + name: string; + // Minimum MCs this requirement needs to be satisfied + minMCs?: number; + // Minimum number of modules this requirement needs to be satisfied + minModules?: number; + // A module fulfils this requirement if it matches ANY of these matchers. + // A requirement with no minMCs and no minModules is an elective pool — + // always satisfied, but matching modules still count towards the + // programme's total MCs. + matchers: ModuleMatcher[]; + description?: string; +}; + +export type Programme = { + // Globally unique and stable — persisted in PlannerState + id: string; + name: string; + type: ProgrammeType; + faculty: string; + // Minimum total MCs for the programme, if it has an MC floor. Programmes + // such as CS focus areas are defined purely in terms of module counts. + totalMCs?: number; + requirements: ProgrammeRequirement[]; + // Official page this data was transcribed from + source: string; + // ISO date the data was last checked against source + lastVerified: string; + // Cohorts these requirements apply to, e.g. 'AY2021/22 and after' + cohort?: string; +}; + +export type ProgrammeMap = { + [id: string]: Programme; +}; + +// Output of the requirement fulfilment check in utils/programmes.ts + +export type RequirementFulfilment = { + requirement: ProgrammeRequirement; + assignedModules: ModuleCode[]; + fulfilledMCs: number; + satisfied: boolean; +}; + +export type ProgrammeFulfilment = { + programme: Programme; + requirements: RequirementFulfilment[]; + // Total MCs of all planned modules counting towards this programme + totalMCs: number; + satisfied: boolean; +}; diff --git a/website/src/utils/programmes.test.ts b/website/src/utils/programmes.test.ts new file mode 100644 index 0000000000..3b3352b498 --- /dev/null +++ b/website/src/utils/programmes.test.ts @@ -0,0 +1,287 @@ +import { Programme, ProgrammeFulfilment } from 'types/programmes'; + +import { + checkProgramme, + moduleMatchesMatcher, + moduleMatchesRequirement, + ProgrammeModule, +} from 'utils/programmes'; + +const mc4 = (moduleCode: string): ProgrammeModule => ({ moduleCode, moduleCredit: 4 }); + +function makeProgramme(overrides: Partial): Programme { + return { + id: 'test-programme', + name: 'Test Programme', + type: 'minor', + faculty: 'Test Faculty', + requirements: [], + source: 'https://example.com', + lastVerified: '2026-07-09', + ...overrides, + }; +} + +function fulfilmentByRequirement(fulfilment: ProgrammeFulfilment, id: string) { + const requirement = fulfilment.requirements.find((r) => r.requirement.id === id); + if (!requirement) throw new Error(`No requirement ${id}`); + return requirement; +} + +describe(moduleMatchesMatcher, () => { + test('should match explicit module codes', () => { + const matcher = { kind: 'modules', codes: ['CS1010', 'CS1101S'] } as const; + + expect(moduleMatchesMatcher('CS1010', matcher)).toBe(true); + expect(moduleMatchesMatcher('CS1101S', matcher)).toBe(true); + expect(moduleMatchesMatcher('CS1010S', matcher)).toBe(false); + }); + + test('should match prefixes with level bounds', () => { + const matcher = { kind: 'prefix', prefixes: ['CS'], minLevel: 3000, maxLevel: 4999 } as const; + + expect(moduleMatchesMatcher('CS3230', matcher)).toBe(true); + expect(moduleMatchesMatcher('CS4248', matcher)).toBe(true); + // Modules with a suffix still have a numeric level + expect(moduleMatchesMatcher('CS3216R', matcher)).toBe(true); + expect(moduleMatchesMatcher('CS2030', matcher)).toBe(false); + expect(moduleMatchesMatcher('CS5228', matcher)).toBe(false); + expect(moduleMatchesMatcher('MA3110', matcher)).toBe(false); + // Prefix must match the full department code, not a substring of it + expect(moduleMatchesMatcher('CSA3001', matcher)).toBe(false); + }); + + test('should match prefixes without level bounds', () => { + const matcher = { kind: 'prefix', prefixes: ['MA', 'ST'] } as const; + + expect(moduleMatchesMatcher('MA1100', matcher)).toBe(true); + expect(moduleMatchesMatcher('ST2131', matcher)).toBe(true); + expect(moduleMatchesMatcher('CS1010', matcher)).toBe(false); + }); +}); + +describe(moduleMatchesRequirement, () => { + test('should match if any matcher matches', () => { + const requirement = { + id: 'req', + name: 'Requirement', + matchers: [ + { kind: 'modules', codes: ['GEA1000'] } as const, + { kind: 'prefix', prefixes: ['CS'], minLevel: 3000 } as const, + ], + }; + + expect(moduleMatchesRequirement('GEA1000', requirement)).toBe(true); + expect(moduleMatchesRequirement('CS3230', requirement)).toBe(true); + expect(moduleMatchesRequirement('CS1010', requirement)).toBe(false); + }); +}); + +describe(checkProgramme, () => { + const programme = makeProgramme({ + totalMCs: 12, + requirements: [ + { + id: 'core', + name: 'Core', + minModules: 1, + matchers: [{ kind: 'modules', codes: ['AA1000', 'AA1001'] }], + }, + { + id: 'advanced', + name: 'Advanced', + minMCs: 8, + matchers: [{ kind: 'prefix', prefixes: ['AA'], minLevel: 4000 }], + }, + ], + }); + + test('should report nothing fulfilled for an empty plan', () => { + const fulfilment = checkProgramme([], programme); + + expect(fulfilment.satisfied).toBe(false); + expect(fulfilment.totalMCs).toBe(0); + expect(fulfilmentByRequirement(fulfilment, 'core').satisfied).toBe(false); + expect(fulfilmentByRequirement(fulfilment, 'advanced').assignedModules).toEqual([]); + }); + + test('should satisfy a programme when all requirements are met', () => { + const fulfilment = checkProgramme( + [mc4('AA1000'), mc4('AA4000'), mc4('AA4001'), mc4('ZZ1000')], + programme, + ); + + expect(fulfilment.satisfied).toBe(true); + expect(fulfilment.totalMCs).toBe(12); + expect(fulfilmentByRequirement(fulfilment, 'core').assignedModules).toEqual(['AA1000']); + expect(fulfilmentByRequirement(fulfilment, 'advanced').fulfilledMCs).toBe(8); + }); + + test('should not double count one module towards two requirements', () => { + const overlapping = makeProgramme({ + requirements: [ + { + id: 'first', + name: 'First', + minModules: 1, + matchers: [{ kind: 'modules', codes: ['AA1000'] }], + }, + { + id: 'second', + name: 'Second', + minModules: 1, + matchers: [{ kind: 'modules', codes: ['AA1000', 'AA1001'] }], + }, + ], + }); + + const partial = checkProgramme([mc4('AA1000')], overlapping); + expect(partial.satisfied).toBe(false); + expect(partial.totalMCs).toBe(4); + + const full = checkProgramme([mc4('AA1000'), mc4('AA1001')], overlapping); + expect(full.satisfied).toBe(true); + expect(fulfilmentByRequirement(full, 'first').assignedModules).toEqual(['AA1000']); + expect(fulfilmentByRequirement(full, 'second').assignedModules).toEqual(['AA1001']); + }); + + test('should reassign modules when a naive greedy assignment fails', () => { + // AA1000 is assigned to 'broad' first, then must move to 'narrow' when + // AA1001 arrives and only fits 'broad' + const needsAugmenting = makeProgramme({ + requirements: [ + { + id: 'broad', + name: 'Broad', + minModules: 1, + matchers: [{ kind: 'modules', codes: ['AA1000', 'AA1001'] }], + }, + { + id: 'narrow', + name: 'Narrow', + minModules: 1, + matchers: [{ kind: 'modules', codes: ['AA1000'] }], + }, + ], + }); + + const fulfilment = checkProgramme([mc4('AA1000'), mc4('AA1001')], needsAugmenting); + + expect(fulfilment.satisfied).toBe(true); + expect(fulfilmentByRequirement(fulfilment, 'narrow').assignedModules).toEqual(['AA1000']); + expect(fulfilmentByRequirement(fulfilment, 'broad').assignedModules).toEqual(['AA1001']); + }); + + test('should model focus area style requirements', () => { + // 3 primaries with at least one at level-4000, expressed as 1 + 2 + const focusArea = makeProgramme({ + requirements: [ + { + id: 'primaries-4000', + name: 'One level-4000 Area Primary', + minModules: 1, + matchers: [{ kind: 'modules', codes: ['AA4000', 'AA4001'] }], + }, + { + id: 'primaries', + name: 'Two other Area Primaries', + minModules: 2, + matchers: [{ kind: 'modules', codes: ['AA3000', 'AA3001', 'AA4000', 'AA4001'] }], + }, + ], + }); + + // No level-4000 primary + expect(checkProgramme([mc4('AA3000'), mc4('AA3001')], focusArea).satisfied).toBe(false); + + // All three at level-4000: one routes to primaries-4000, two to primaries + expect(checkProgramme([mc4('AA4000'), mc4('AA4001'), mc4('AA3000')], focusArea).satisfied).toBe( + true, + ); + }); + + test('should top up MC floors when low MC modules fill all slots', () => { + const mcFloor = makeProgramme({ + requirements: [ + { + id: 'floor', + name: 'MC floor', + minMCs: 8, + matchers: [{ kind: 'prefix', prefixes: ['AA'] }], + }, + ], + }); + + const twoMC = (code: string): ProgrammeModule => ({ moduleCode: code, moduleCredit: 2 }); + const fulfilment = checkProgramme( + [twoMC('AA1000'), twoMC('AA1001'), twoMC('AA1002'), twoMC('AA1003')], + mcFloor, + ); + + expect(fulfilmentByRequirement(fulfilment, 'floor').fulfilledMCs).toBe(8); + expect(fulfilment.satisfied).toBe(true); + }); + + test('should count elective pool modules towards total MCs only', () => { + const withPool = makeProgramme({ + requirements: [ + { + id: 'core', + name: 'Core', + minModules: 1, + matchers: [{ kind: 'modules', codes: ['AA1000'] }], + }, + { + id: 'electives', + name: 'Electives', + matchers: [{ kind: 'modules', codes: ['BB2000'] }], + }, + ], + }); + + const fulfilment = checkProgramme([mc4('AA1000'), mc4('BB2000'), mc4('ZZ1000')], withPool); + + expect(fulfilmentByRequirement(fulfilment, 'electives').satisfied).toBe(true); + expect(fulfilmentByRequirement(fulfilment, 'electives').assignedModules).toEqual(['BB2000']); + // ZZ1000 matches nothing so only AA1000 and BB2000 count + expect(fulfilment.totalMCs).toBe(8); + expect(fulfilment.satisfied).toBe(true); + }); + + test('should count duplicated module codes once', () => { + const fulfilment = checkProgramme([mc4('AA1000'), mc4('AA1000')], programme); + + expect(fulfilmentByRequirement(fulfilment, 'core').assignedModules).toEqual(['AA1000']); + expect(fulfilment.totalMCs).toBe(4); + }); + + test('should not satisfy the programme if the total MC floor is unmet', () => { + const highTotal = makeProgramme({ + totalMCs: 8, + requirements: [ + { + id: 'core', + name: 'Core', + minModules: 1, + matchers: [{ kind: 'modules', codes: ['AA1000'] }], + }, + ], + }); + + const fulfilment = checkProgramme([mc4('AA1000')], highTotal); + + expect(fulfilmentByRequirement(fulfilment, 'core').satisfied).toBe(true); + expect(fulfilment.satisfied).toBe(false); + }); + + test('should assign surplus matching modules so their MCs count', () => { + const fulfilment = checkProgramme( + [mc4('AA1000'), mc4('AA1001'), mc4('AA4000'), mc4('AA4001'), mc4('AA4002')], + programme, + ); + + // core needs 1, advanced needs 8 MCs; all 5 modules match something + expect(fulfilment.totalMCs).toBe(20); + expect(fulfilment.satisfied).toBe(true); + }); +}); diff --git a/website/src/utils/programmes.ts b/website/src/utils/programmes.ts new file mode 100644 index 0000000000..ca228e9252 --- /dev/null +++ b/website/src/utils/programmes.ts @@ -0,0 +1,206 @@ +import { sumBy } from 'lodash-es'; + +import { ModuleCode } from 'types/modules'; +import { + ModuleMatcher, + Programme, + ProgrammeFulfilment, + ProgrammeRequirement, + ProgrammeType, + RequirementFulfilment, +} from 'types/programmes'; + +export type ProgrammeModule = { + moduleCode: ModuleCode; + moduleCredit: number; +}; + +export const programmeTypeLabels: { [type in ProgrammeType]: string } = { + specialisation: 'Specialisation', + focusArea: 'Focus Area', + minor: 'Minor', +}; + +// Requirements with an MC floor are expanded into ceil(minMCs / 4) slots for +// the matching step. 4 MCs is by far the most common module credit value, and +// is also what planner placeholders assume. +const MODAL_MODULE_CREDIT = 4; + +function moduleLevel(moduleCode: ModuleCode): number | null { + const match = moduleCode.match(/\d{4}/); + return match ? parseInt(match[0], 10) : null; +} + +export function moduleMatchesMatcher(moduleCode: ModuleCode, matcher: ModuleMatcher): boolean { + if (matcher.kind === 'modules') { + return matcher.codes.includes(moduleCode); + } + + const matchesPrefix = matcher.prefixes.some( + // The prefix must be the module's entire department code, so it has to be + // followed by the module's level digits + (prefix) => moduleCode.startsWith(prefix) && /^\d/.test(moduleCode.slice(prefix.length)), + ); + if (!matchesPrefix) return false; + if (matcher.minLevel == null && matcher.maxLevel == null) return true; + + const level = moduleLevel(moduleCode); + if (level == null) return false; + return ( + (matcher.minLevel == null || level >= matcher.minLevel) && + (matcher.maxLevel == null || level <= matcher.maxLevel) + ); +} + +export function moduleMatchesRequirement( + moduleCode: ModuleCode, + requirement: Pick, +): boolean { + return requirement.matchers.some((matcher) => moduleMatchesMatcher(moduleCode, matcher)); +} + +/** + * Checks a set of planned modules against a programme's requirements. + * + * Within a programme each module is counted towards at most one requirement. + * Modules are assigned to requirements using maximum bipartite matching + * (Kuhn's augmenting path algorithm) over requirement "slots", so a module + * that matches several requirements is routed to wherever it is needed. + * This is exact when all modules are worth 4 MCs — the overwhelmingly common + * case — and a good approximation otherwise, since a top-up pass fills any + * MC floor left unmet by the slot approximation. + */ +export function checkProgramme( + modules: ProgrammeModule[], + programme: Programme, +): ProgrammeFulfilment { + const { requirements } = programme; + + // Modules may appear in multiple semesters (e.g. failed and retaken), but + // only count once. Sort by descending MC so that MC floors are met with as + // few slots as possible, then by module code for determinism. + const seen = new Set(); + const uniqueModules = modules + .filter((module) => { + if (seen.has(module.moduleCode)) return false; + seen.add(module.moduleCode); + return true; + }) + .sort((a, b) => b.moduleCredit - a.moduleCredit || a.moduleCode.localeCompare(b.moduleCode)); + + // Requirement indices each module can count towards + const candidates = uniqueModules.map((module) => { + const indices: number[] = []; + requirements.forEach((requirement, index) => { + if (moduleMatchesRequirement(module.moduleCode, requirement)) indices.push(index); + }); + return indices; + }); + + // Expand each requirement's floor into slots. Requirements without floors + // are elective pools and get no slots — they absorb leftovers below. + const slotRequirement: number[] = []; + const slotsByRequirement: number[][] = requirements.map((requirement, index) => { + const count = Math.max( + requirement.minModules ?? 0, + Math.ceil((requirement.minMCs ?? 0) / MODAL_MODULE_CREDIT), + ); + const slotIndices = []; + for (let i = 0; i < count; i++) { + slotIndices.push(slotRequirement.length); + slotRequirement.push(index); + } + return slotIndices; + }); + + const slotOccupant: (number | null)[] = slotRequirement.map(() => null); + const assignedRequirement: (number | null)[] = uniqueModules.map(() => null); + + // Kuhn's augmenting path: assign the module to a free slot, or displace an + // occupant that can be re-seated elsewhere + function tryAssign(moduleIndex: number, visited: Set): boolean { + for (const requirementIndex of candidates[moduleIndex]) { + for (const slotIndex of slotsByRequirement[requirementIndex]) { + if (visited.has(slotIndex)) continue; + visited.add(slotIndex); + + const occupant = slotOccupant[slotIndex]; + if (occupant == null || tryAssign(occupant, visited)) { + slotOccupant[slotIndex] = moduleIndex; + assignedRequirement[moduleIndex] = requirementIndex; + return true; + } + } + } + return false; + } + + uniqueModules.forEach((_module, moduleIndex) => { + if (candidates[moduleIndex].length > 0) tryAssign(moduleIndex, new Set()); + }); + + const assignedModuleIndices: number[][] = requirements.map(() => []); + assignedRequirement.forEach((requirementIndex, moduleIndex) => { + if (requirementIndex != null) assignedModuleIndices[requirementIndex].push(moduleIndex); + }); + + const assignedMCs = (requirementIndex: number) => + sumBy(assignedModuleIndices[requirementIndex], (index) => uniqueModules[index].moduleCredit); + + // Top-up pass: slots underestimate MC floors when modules are worth less + // than 4 MCs, so give unassigned matching modules to requirements whose + // MC floor is still unmet + requirements.forEach((requirement, requirementIndex) => { + const minMCs = requirement.minMCs; + if (!minMCs) return; + let fulfilledMCs = assignedMCs(requirementIndex); + uniqueModules.forEach((module, moduleIndex) => { + if (fulfilledMCs >= minMCs) return; + if (assignedRequirement[moduleIndex] != null) return; + if (!candidates[moduleIndex].includes(requirementIndex)) return; + assignedRequirement[moduleIndex] = requirementIndex; + assignedModuleIndices[requirementIndex].push(moduleIndex); + fulfilledMCs += module.moduleCredit; + }); + }); + + // Every remaining module that matches some requirement still counts + // towards the programme's total MCs. Prefer floor-less elective pools — + // they exist to absorb these modules. + uniqueModules.forEach((_module, moduleIndex) => { + if (assignedRequirement[moduleIndex] != null || candidates[moduleIndex].length === 0) return; + const pool = candidates[moduleIndex].find( + (index) => !requirements[index].minMCs && !requirements[index].minModules, + ); + const target = pool ?? candidates[moduleIndex][0]; + assignedRequirement[moduleIndex] = target; + assignedModuleIndices[target].push(moduleIndex); + }); + + const requirementFulfilments: RequirementFulfilment[] = requirements.map( + (requirement, requirementIndex) => { + const assignedModules = assignedModuleIndices[requirementIndex] + .map((index) => uniqueModules[index].moduleCode) + .sort(); + return { + requirement, + assignedModules, + fulfilledMCs: assignedMCs(requirementIndex), + satisfied: + assignedModules.length >= (requirement.minModules ?? 0) && + assignedMCs(requirementIndex) >= (requirement.minMCs ?? 0), + }; + }, + ); + + const totalMCs = sumBy(requirementFulfilments, (fulfilment) => fulfilment.fulfilledMCs); + + return { + programme, + requirements: requirementFulfilments, + totalMCs, + satisfied: + requirementFulfilments.every((fulfilment) => fulfilment.satisfied) && + (programme.totalMCs == null || totalMCs >= programme.totalMCs), + }; +} From 90806a3bfad1693faf34da6497d035e730f4f111 Mon Sep 17 00:00:00 2001 From: eugenewong22 <250109759+eugenewong22@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:24:09 +0800 Subject: [PATCH 2/4] feat(website): track selected programmes in planner state Adds a persisted programmes field (programme IDs only, so dataset fixes reach existing users), add/remove actions, a redux-persist migration, and the Zod schema field so planner import/export round trips the selection. Part of #4390 Co-Authored-By: Claude Fable 5 --- website/src/actions/planner.ts | 16 +++++++++ website/src/reducers/planner.test.ts | 44 ++++++++++++++++++++++- website/src/reducers/planner.ts | 30 +++++++++++++++- website/src/types/reducers.ts | 4 +++ website/src/types/schemas/planner.test.ts | 28 ++++++++++++++- website/src/types/schemas/planner.ts | 2 ++ 6 files changed, 121 insertions(+), 3 deletions(-) diff --git a/website/src/actions/planner.ts b/website/src/actions/planner.ts index d87b781c39..1c09f28035 100644 --- a/website/src/actions/planner.ts +++ b/website/src/actions/planner.ts @@ -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 { diff --git a/website/src/reducers/planner.test.ts b/website/src/reducers/planner.test.ts index 3aacbccc54..fba60cd3d1 100644 --- a/website/src/reducers/planner.test.ts +++ b/website/src/reducers/planner.test.ts @@ -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, @@ -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); @@ -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( @@ -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', []); + }); +}); diff --git a/website/src/reducers/planner.ts b/website/src/reducers/planner.ts index 7c9b109a60..6f0029a271 100644 --- a/website/src/reducers/planner.ts +++ b/website/src/reducers/planner.ts @@ -9,6 +9,8 @@ import { Semester } from 'types/modules'; import { ADD_CUSTOM_PLANNER_DATA, ADD_PLANNER_MODULE, + ADD_PLANNER_PROGRAMME, + REMOVE_PLANNER_PROGRAMME, MOVE_PLANNER_MODULE, REMOVE_PLANNER_MODULE, SET_PLACEHOLDER_MODULE, @@ -30,6 +32,7 @@ export const defaultPlannerState: PlannerState = { modules: {}, custom: {}, + programmes: [], }; /** @@ -79,6 +82,18 @@ export default function planner( case SET_PLANNER_IBLOCS: return { ...state, iblocs: action.payload }; + case ADD_PLANNER_PROGRAMME: + if (state.programmes.includes(action.payload.programmeId)) return state; + return { ...state, programmes: [...state.programmes, action.payload.programmeId] }; + + case REMOVE_PLANNER_PROGRAMME: + return { + ...state, + programmes: state.programmes.filter( + (programmeId) => programmeId !== action.payload.programmeId, + ), + }; + case SET_IGNORE_PREREQUISITES_CHECK: return { ...state, ignorePrereqCheck: action.payload }; @@ -198,11 +213,24 @@ export function migrateV0toV1( }; } +// Migration from state V1 -> V2: adds the programmes field +type PlannerStateV1 = Omit; +export function migrateV1toV2( + oldState: PlannerStateV1 & PersistedState, +): PlannerState & PersistedState { + return { + ...oldState, + programmes: [], + }; +} + export const persistConfig = { - version: 1, + version: 2, migrate: createMigrate({ // The typings for this seems really weird // eslint-disable-next-line @typescript-eslint/no-explicit-any 1: migrateV0toV1 as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + 2: migrateV1toV2 as any, }), }; diff --git a/website/src/types/reducers.ts b/website/src/types/reducers.ts index c44eb808ec..f8ebbea983 100644 --- a/website/src/types/reducers.ts +++ b/website/src/types/reducers.ts @@ -176,6 +176,10 @@ export type PlannerState = Readonly<{ modules: { [id: string]: PlannerTime }; custom: CustomModuleData; + // IDs of programmes (specialisations, focus areas, minors) the student is + // pursuing, referencing data/programmes. Unknown IDs are ignored at read + // time so that programmes can be renamed or removed from the dataset. + programmes: string[]; }>; /* moduleBank.js */ diff --git a/website/src/types/schemas/planner.test.ts b/website/src/types/schemas/planner.test.ts index 11e622bdd2..f4e7bf014b 100644 --- a/website/src/types/schemas/planner.test.ts +++ b/website/src/types/schemas/planner.test.ts @@ -169,6 +169,32 @@ describe('PlannerStateSchema', () => { const parsed = PlannerStateSchema.safeParse(dataWithPersistConfig); expect(parsed.success).toBe(true); - expect(parsed.data).toEqual(data); + expect(parsed.data).toEqual({ ...data, programmes: [] }); + }); + + it('round trips programmes', () => { + const data = { + minYear: '2024', + maxYear: '2028', + iblocs: false, + modules: {}, + custom: {}, + programmes: ['soc-minor-computer-science'], + }; + + const parsed = PlannerStateSchema.parse(data); + expect(parsed.programmes).toEqual(['soc-minor-computer-science']); + }); + + it('defaults programmes for exports created before the field existed', () => { + const legacyData = { + minYear: '2024', + maxYear: '2028', + iblocs: false, + modules: {}, + custom: {}, + }; + + expect(PlannerStateSchema.parse(legacyData).programmes).toEqual([]); }); }); diff --git a/website/src/types/schemas/planner.ts b/website/src/types/schemas/planner.ts index 95df693554..fbe48dd93c 100644 --- a/website/src/types/schemas/planner.ts +++ b/website/src/types/schemas/planner.ts @@ -29,5 +29,7 @@ export const PlannerStateSchema = z ignorePrereqCheck: z.boolean().optional(), modules: z.record(PlannerTimeSchema), custom: CustomModuleDataSchema, + // Default so that exports from before this field existed remain importable + programmes: z.array(z.string()).default([]), }) .strip(); From cf372d017b374a42744bd63670b90ec58a308b1b Mon Sep 17 00:00:00 2001 From: eugenewong22 <250109759+eugenewong22@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:24:10 +0800 Subject: [PATCH 3/4] feat(website): add programme fulfilment selector Checks scheduled and exempted modules (but not plan-to-take ones) against each selected programme, resolving unit counts from the module bank with custom module overrides. Part of #4390 Co-Authored-By: Claude Fable 5 --- website/src/selectors/planner.test.ts | 104 +++++++++++++++++++++++++- website/src/selectors/planner.ts | 41 ++++++++++ 2 files changed, 144 insertions(+), 1 deletion(-) diff --git a/website/src/selectors/planner.test.ts b/website/src/selectors/planner.test.ts index 5e184fc586..edb14f4e7c 100644 --- a/website/src/selectors/planner.test.ts +++ b/website/src/selectors/planner.test.ts @@ -1,5 +1,15 @@ import { clone } from 'lodash-es'; -import { getAcadYearModules, getPrereqModuleCode } from 'selectors/planner'; +import { + getAcadYearModules, + getPrereqModuleCode, + getProgrammeFulfilments, +} from 'selectors/planner'; +import { + EXEMPTION_SEMESTER, + EXEMPTION_YEAR, + PLAN_TO_TAKE_SEMESTER, + PLAN_TO_TAKE_YEAR, +} from 'utils/planner'; import { PlannerState } from 'types/reducers'; import { ModuleCode } from 'types/modules'; @@ -22,6 +32,7 @@ const defaultState: PlannerState = { ignorePrereqCheck: false, modules: {}, custom: {}, + programmes: [], }; describe(getPrereqModuleCode, () => { @@ -538,3 +549,94 @@ describe(getAcadYearModules, () => { expect(getAcadYearModules(state)).toHaveProperty('2018/2019.1.0.conflicts', []); }); }); + +describe(getProgrammeFulfilments, () => { + const getState = (planner: PlannerState): State => + ({ + planner, + moduleBank: { + modules: {}, + moduleCodes: {}, + }, + }) as any; + + const plannerWithModules = (moduleCodes: ModuleCode[], year = '2018/2019'): PlannerState => ({ + ...defaultState, + programmes: ['soc-minor-computer-science'], + modules: Object.fromEntries( + moduleCodes.map((moduleCode, index) => [ + index, + { id: String(index), moduleCode, year, semester: 1, index }, + ]), + ), + }); + + test('should check planned modules against the selected programmes', () => { + const state = getState( + plannerWithModules(['CS1010S', 'CS2030S', 'CS2040S', 'CS2100', 'CS3230']), + ); + + const fulfilments = getProgrammeFulfilments(state); + expect(fulfilments).toHaveLength(1); + expect(fulfilments[0].programme.id).toEqual('soc-minor-computer-science'); + // Module credits default to 4 MCs when the module bank has no data + expect(fulfilments[0].totalMCs).toEqual(20); + expect(fulfilments[0].satisfied).toBe(true); + }); + + test('should ignore unknown programme ids', () => { + const state = getState({ ...defaultState, programmes: ['no-longer-exists'] }); + + expect(getProgrammeFulfilments(state)).toEqual([]); + }); + + test('should count exemptions but not plan to take modules', () => { + const exempted = getState({ + ...plannerWithModules(['CS1010S'], EXEMPTION_YEAR), + modules: { + 0: { + id: '0', + moduleCode: 'CS1010S', + year: EXEMPTION_YEAR, + semester: EXEMPTION_SEMESTER, + index: 0, + }, + }, + }); + expect(getProgrammeFulfilments(exempted)[0].requirements[0].satisfied).toBe(true); + + const planToTake = getState({ + ...plannerWithModules(['CS1010S']), + modules: { + 0: { + id: '0', + moduleCode: 'CS1010S', + year: PLAN_TO_TAKE_YEAR, + semester: PLAN_TO_TAKE_SEMESTER, + index: 0, + }, + }, + }); + expect(getProgrammeFulfilments(planToTake)[0].requirements[0].satisfied).toBe(false); + }); + + test('should skip placeholders and use custom module credits', () => { + const state = getState({ + ...plannerWithModules(['CS3230']), + modules: { + 0: { id: '0', moduleCode: 'CS3230', year: '2018/2019', semester: 1, index: 0 }, + 1: { id: '1', placeholderId: 'ulr-ge', year: '2018/2019', semester: 1, index: 1 }, + }, + custom: { + CS3230: { title: null, moduleCredit: 6 }, + }, + }); + + const [fulfilment] = getProgrammeFulfilments(state); + const categoryIII = fulfilment.requirements.find( + (requirement) => requirement.requirement.id === 'category-iii', + ); + expect(categoryIII?.fulfilledMCs).toEqual(6); + expect(fulfilment.totalMCs).toEqual(6); + }); +}); diff --git a/website/src/selectors/planner.ts b/website/src/selectors/planner.ts index e3d8048ebf..1eeb6db358 100644 --- a/website/src/selectors/planner.ts +++ b/website/src/selectors/planner.ts @@ -1,4 +1,5 @@ import { flatMap, get, sortBy, values } from 'lodash-es'; +import { createSelector } from 'reselect'; import { ModuleCode, Semester, Semesters } from 'types/modules'; import { PlannerState, @@ -16,7 +17,11 @@ import { IBLOCS_SEMESTER, PLAN_TO_TAKE_SEMESTER, PLAN_TO_TAKE_YEAR, + getModuleCredit, } from 'utils/planner'; +import { checkProgramme, ProgrammeModule } from 'utils/programmes'; +import { ProgrammeFulfilment } from 'types/programmes'; +import programmes from 'data/programmes'; import { findExamClashes } from 'utils/timetables'; import { Conflict, PlannerModuleInfo, PlannerModulesWithInfo } from 'types/planner'; import placeholders from 'utils/placeholders'; @@ -264,3 +269,39 @@ export function getAcadYearModules(state: State): PlannerModulesWithInfo { return modules; } + +// Assumed when neither the module bank nor the student's custom data has MC +// information for a module, matching the assumption made for placeholders +const DEFAULT_MODULE_CREDIT = 4; + +/** + * Checks the planned modules against each programme (specialisation, focus + * area or minor) the student has selected in the planner settings. + * + * Scheduled and exempted modules count towards programme requirements; + * "plan to take" modules do not, since they represent modules the student has + * not committed to a semester yet. + */ +export const getProgrammeFulfilments = createSelector( + (state: State) => state.planner.programmes, + (state: State) => state.planner.modules, + (state: State) => state.planner.custom, + (state: State) => state.moduleBank.modules, + (programmeIds, plannerModules, custom, modulesMap): ProgrammeFulfilment[] => { + const modules: ProgrammeModule[] = values(plannerModules) + .filter((time) => time.year !== PLAN_TO_TAKE_YEAR) + .map((time) => time.moduleCode) + .filter(notNull) + .map((moduleCode) => ({ + moduleCode, + moduleCredit: + getModuleCredit({ moduleInfo: modulesMap[moduleCode], customInfo: custom[moduleCode] }) ?? + DEFAULT_MODULE_CREDIT, + })); + + return programmeIds + .map((programmeId) => programmes[programmeId]) + .filter(notNull) + .map((programme) => checkProgramme(modules, programme)); + }, +); From 02c3e68de1389e4396e76c0c3feb852e66e09983 Mon Sep 17 00:00:00 2001 From: eugenewong22 <250109759+eugenewong22@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:24:11 +0800 Subject: [PATCH 4/4] feat(website): show programme requirement progress in the planner Adds a programme picker to the planner settings modal and a progress panel above the year columns showing per-requirement progress bars, which courses counted towards each requirement, and total units. Part of #4390 Co-Authored-By: Claude Fable 5 --- .../PlannerContainer.test.tsx | 1 + .../PlannerContainer/PlannerContainer.tsx | 3 + .../src/views/planner/PlannerProgrammes.scss | 107 ++++++++++++++ .../views/planner/PlannerProgrammes.test.tsx | 54 +++++++ .../src/views/planner/PlannerProgrammes.tsx | 132 ++++++++++++++++++ .../src/views/planner/PlannerSettings.scss | 24 ++++ .../views/planner/PlannerSettings.test.tsx | 39 +++++- website/src/views/planner/PlannerSettings.tsx | 77 ++++++++++ 8 files changed, 436 insertions(+), 1 deletion(-) create mode 100644 website/src/views/planner/PlannerProgrammes.scss create mode 100644 website/src/views/planner/PlannerProgrammes.test.tsx create mode 100644 website/src/views/planner/PlannerProgrammes.tsx diff --git a/website/src/views/planner/PlannerContainer/PlannerContainer.test.tsx b/website/src/views/planner/PlannerContainer/PlannerContainer.test.tsx index d288b9bd1a..7fef5e5f61 100644 --- a/website/src/views/planner/PlannerContainer/PlannerContainer.test.tsx +++ b/website/src/views/planner/PlannerContainer/PlannerContainer.test.tsx @@ -53,6 +53,7 @@ function importedStateWith(modules: PlannerState['modules']): PlannerState { iblocs: false, modules, custom: {}, + programmes: [], }; } diff --git a/website/src/views/planner/PlannerContainer/PlannerContainer.tsx b/website/src/views/planner/PlannerContainer/PlannerContainer.tsx index 200aeb5846..83532708be 100644 --- a/website/src/views/planner/PlannerContainer/PlannerContainer.tsx +++ b/website/src/views/planner/PlannerContainer/PlannerContainer.tsx @@ -38,6 +38,7 @@ import { PlannerState } from 'types/reducers'; import PlannerSemester from '../PlannerSemester'; import PlannerYear from '../PlannerYear'; import PlannerSettings from '../PlannerSettings'; +import PlannerProgrammes from '../PlannerProgrammes'; import PlannerClearButton from '../PlannerClearButton'; import PlannerImportButton from '../PlannerImportButton'; import PlannerExportButton from '../PlannerExportButton'; @@ -230,6 +231,8 @@ export class PlannerContainerComponent extends PureComponent { {this.renderHeader()} + +
{iblocs && ( diff --git a/website/src/views/planner/PlannerProgrammes.scss b/website/src/views/planner/PlannerProgrammes.scss new file mode 100644 index 0000000000..ad6c03ff88 --- /dev/null +++ b/website/src/views/planner/PlannerProgrammes.scss @@ -0,0 +1,107 @@ +@import '~styles/utils/modules-entry'; +@import 'variables'; + +.programmes { + margin-bottom: 1rem; +} + +.programme { + margin-bottom: 0.5rem; + border: 1px solid var(--gray-lighter); + border-radius: 0.3rem; +} + +.programmeHeader { + display: flex; + align-items: center; + width: 100%; + padding: 0.6rem 0.8rem; + border: 0; + text-align: left; + background: none; + gap: 0.5rem; + + :global(.badge) { + flex-shrink: 0; + } +} + +.programmeName { + margin: 0; + font-weight: bold; + font-size: 1.1rem; +} + +.programmeMCs { + flex-shrink: 0; + margin-left: auto; + color: var(--gray); +} + +.statusIcon { + flex-shrink: 0; + width: 1.2rem; + height: 1.2rem; + + &.satisfied { + color: theme-color('success'); + } + + &.unsatisfied { + color: theme-color('warning'); + } +} + +.requirements { + padding: 0 0.8rem 0.6rem; +} + +.requirement { + margin-bottom: 0.6rem; + + &:last-child { + margin-bottom: 0; + } + + h4 { + margin: 0; + font-size: 1rem; + } +} + +.requirementHeader { + display: flex; + justify-content: space-between; + align-items: baseline; +} + +.progressLabel { + flex-shrink: 0; + margin-left: 0.5rem; + font-size: 0.9rem; + color: var(--gray); +} + +.progress { + height: 0.4rem; + margin: 0.25rem 0; +} + +.barSatisfied { + background-color: theme-color('success'); +} + +.barUnsatisfied { + background-color: theme-color('warning'); +} + +.assignedModules { + margin: 0.15rem 0 0; + font-size: 0.9rem; +} + +.description { + margin: 0.15rem 0 0; + font-size: 0.85rem; + color: var(--gray); +} diff --git a/website/src/views/planner/PlannerProgrammes.test.tsx b/website/src/views/planner/PlannerProgrammes.test.tsx new file mode 100644 index 0000000000..07d4ddf0c7 --- /dev/null +++ b/website/src/views/planner/PlannerProgrammes.test.tsx @@ -0,0 +1,54 @@ +import { mount } from 'enzyme'; +import { Provider } from 'react-redux'; +import { Router } from 'react-router-dom'; + +import { ModuleCode } from 'types/modules'; +import configureStore from 'bootstrapping/configure-store'; +import createHistory from 'test-utils/createHistory'; +import { addPlannerModule, addPlannerProgramme } from 'actions/planner'; +import PlannerProgrammes from './PlannerProgrammes'; + +function makePlannerProgrammes(programmeIds: string[], moduleCodes: ModuleCode[] = []) { + const { store } = configureStore(); + const { history } = createHistory(); + + programmeIds.forEach((programmeId) => store.dispatch(addPlannerProgramme(programmeId))); + moduleCodes.forEach((moduleCode) => + store.dispatch(addPlannerModule('2023/2024', 1, { type: 'module', moduleCode })), + ); + + return mount( + + + + + , + ); +} + +test('should render nothing when no programmes are selected', () => { + const wrapper = makePlannerProgrammes([]); + expect(wrapper.find(PlannerProgrammes).isEmptyRender()).toBe(true); +}); + +test('should show unfulfilled requirements for an empty plan', () => { + const wrapper = makePlannerProgrammes(['cs-focus-artificial-intelligence']); + + expect(wrapper.text()).toContain('Artificial Intelligence'); + expect(wrapper.text()).toContain('Focus Area'); + expect(wrapper.text()).toContain('0/1 courses'); + expect(wrapper.text()).toContain('0/2 courses'); +}); + +test('should show progress once matching modules are planned', () => { + const wrapper = makePlannerProgrammes( + ['cs-focus-artificial-intelligence'], + ['CS3243', 'CS3244', 'CS4243'], + ); + + const text = wrapper.text(); + // CS4243 routes to the level-4000 requirement, CS3243 and CS3244 to the rest + expect(text).toContain('1/1 courses'); + expect(text).toContain('2/2 courses'); + expect(text).toContain('CS3243, CS3244'); +}); diff --git a/website/src/views/planner/PlannerProgrammes.tsx b/website/src/views/planner/PlannerProgrammes.tsx new file mode 100644 index 0000000000..0223b4a25b --- /dev/null +++ b/website/src/views/planner/PlannerProgrammes.tsx @@ -0,0 +1,132 @@ +import { useState } from 'react'; +import { useSelector } from 'react-redux'; +import classnames from 'classnames'; +import { AlertTriangle, CheckCircle, ChevronDown, ChevronUp } from 'react-feather'; + +import { ProgrammeFulfilment, RequirementFulfilment } from 'types/programmes'; +import { getProgrammeFulfilments } from 'selectors/planner'; +import { programmeTypeLabels } from 'utils/programmes'; +import LinkModuleCodes from 'views/components/LinkModuleCodes'; +import styles from './PlannerProgrammes.scss'; + +type RequirementProps = { + readonly fulfilment: RequirementFulfilment; +}; + +const ProgrammeRequirementRow: React.FC = ({ fulfilment }) => { + const { requirement, assignedModules, fulfilledMCs, satisfied } = fulfilment; + const isElectivePool = requirement.minModules == null && requirement.minMCs == null; + + // Elective pools only matter once they have modules counting towards them + if (isElectivePool && assignedModules.length === 0) return null; + + const targets = []; + if (requirement.minModules) { + targets.push( + `${Math.min(assignedModules.length, requirement.minModules)}/${ + requirement.minModules + } courses`, + ); + } + if (requirement.minMCs) { + targets.push(`${Math.min(fulfilledMCs, requirement.minMCs)}/${requirement.minMCs} units`); + } + + const ratios = [ + requirement.minModules ? assignedModules.length / requirement.minModules : null, + requirement.minMCs ? fulfilledMCs / requirement.minMCs : null, + ].filter((ratio): ratio is number => ratio != null); + const progress = ratios.length > 0 ? Math.min(1, ...ratios) : 1; + + return ( +
+
+

{requirement.name}

+ {targets.length > 0 && {targets.join(', ')}} +
+ + {!isElectivePool && ( +
+
+
+ )} + + {assignedModules.length > 0 && ( +

+ {assignedModules.join(', ')} +

+ )} + {requirement.description &&

{requirement.description}

} +
+ ); +}; + +type ProgrammeProps = { + readonly fulfilment: ProgrammeFulfilment; +}; + +const ProgrammeCard: React.FC = ({ fulfilment }) => { + const { programme, requirements, totalMCs, satisfied } = fulfilment; + const [expanded, setExpanded] = useState(true); + + return ( +
+ + + {expanded && ( +
+ {requirements.map((requirement) => ( + + ))} +
+ )} +
+ ); +}; + +/** + * Shows progress towards the specialisations, focus areas and minors selected + * in the planner settings + */ +const PlannerProgrammes: React.FC = () => { + const fulfilments = useSelector(getProgrammeFulfilments); + + if (fulfilments.length === 0) return null; + + return ( +
+ {fulfilments.map((fulfilment) => ( + + ))} +
+ ); +}; + +export default PlannerProgrammes; diff --git a/website/src/views/planner/PlannerSettings.scss b/website/src/views/planner/PlannerSettings.scss index f46467d505..41f15d10c9 100644 --- a/website/src/views/planner/PlannerSettings.scss +++ b/website/src/views/planner/PlannerSettings.scss @@ -47,3 +47,27 @@ display: flex; align-items: center; } + +.programmeList { + composes: list-unstyled from global; + margin-bottom: 0.5rem; +} + +.programmeItem { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.4rem 0; + border-bottom: 1px solid var(--gray-lighter); + gap: 0.5rem; + + .programmeName { + font-weight: bold; + } + + .subtitle { + display: block; + font-weight: normal; + font-size: 0.9rem; + } +} diff --git a/website/src/views/planner/PlannerSettings.test.tsx b/website/src/views/planner/PlannerSettings.test.tsx index 3320a44462..0fa8e877a8 100644 --- a/website/src/views/planner/PlannerSettings.test.tsx +++ b/website/src/views/planner/PlannerSettings.test.tsx @@ -1,4 +1,9 @@ -import { getYearLabels } from 'views/planner/PlannerSettings'; +import { mount } from 'enzyme'; +import { Provider } from 'react-redux'; + +import configureStore from 'bootstrapping/configure-store'; +import { addPlannerProgramme } from 'actions/planner'; +import PlannerSettings, { getYearLabels } from 'views/planner/PlannerSettings'; describe(getYearLabels, () => { test('should support negative offset', () => { @@ -11,3 +16,35 @@ describe(getYearLabels, () => { ]); }); }); + +describe('programme selection', () => { + function makeSettings(initialProgrammes: string[] = []) { + const { store } = configureStore(); + initialProgrammes.forEach((programmeId) => store.dispatch(addPlannerProgramme(programmeId))); + const wrapper = mount( + + + , + ); + return { store, wrapper }; + } + + test('should add the selected programme', () => { + const { store, wrapper } = makeSettings(); + + wrapper + .find('select') + .simulate('change', { target: { value: 'cs-focus-artificial-intelligence' } }); + + expect(store.getState().planner.programmes).toEqual(['cs-focus-artificial-intelligence']); + }); + + test('should remove a selected programme', () => { + const { store, wrapper } = makeSettings(['soc-minor-computer-science']); + + expect(wrapper.text()).toContain('Minor in Computer Science'); + wrapper.find('button.btn-outline-danger').simulate('click'); + + expect(store.getState().planner.programmes).toEqual([]); + }); +}); diff --git a/website/src/views/planner/PlannerSettings.tsx b/website/src/views/planner/PlannerSettings.tsx index f23bba45d1..1140430fda 100644 --- a/website/src/views/planner/PlannerSettings.tsx +++ b/website/src/views/planner/PlannerSettings.tsx @@ -5,7 +5,11 @@ import classnames from 'classnames'; import config from 'config'; import { getYearsBetween, offsetAcadYear } from 'utils/modules'; import { acadYearLabel } from 'utils/planner'; +import { programmeTypeLabels } from 'utils/programmes'; +import programmes, { programmeList } from 'data/programmes'; import { + addPlannerProgramme, + removePlannerProgramme, setPlannerIBLOCs, setPlannerMaxYear, setPlannerMinYear, @@ -15,6 +19,7 @@ import ExternalLink from 'views/components/ExternalLink'; import Toggle from 'views/components/Toggle'; import CloseButton from 'views/components/CloseButton'; import { State } from 'types/state'; +import { ProgrammeType } from 'types/programmes'; import styles from './PlannerSettings.scss'; type Props = { @@ -22,6 +27,7 @@ type Props = { readonly maxYear: string; readonly iblocs: boolean; readonly ignorePrereqCheck?: boolean; + readonly selectedProgrammes: string[]; // Actions readonly onCloseButtonClicked: () => void; @@ -29,6 +35,8 @@ type Props = { readonly setMaxYear: (str: string) => void; readonly setIBLOCs: (boolean: boolean) => void; readonly setPrereqsCheck: (boolean: boolean) => void; + readonly addProgramme: (programmeId: string) => void; + readonly removeProgramme: (programmeId: string) => void; }; const MIN_YEARS = -5; // Studying year 6 @@ -115,6 +123,72 @@ export const PlannerSettingsComponent: React.FC = (props) => { +
+

Specialisations & Minors

+ +

+ Track your progress towards focus areas, specialisations and minors. Requirement data is + community maintained — always double-check against the official programme page and your + faculty's double-counting rules. +

+ + {props.selectedProgrammes.length > 0 && ( +
    + {props.selectedProgrammes.map((programmeId) => { + const programme = programmes[programmeId]; + if (!programme) return null; + + return ( +
  • + + {programme.name} + + {programmeTypeLabels[programme.type]} · {programme.faculty} ·{' '} + Official page + + + +
  • + ); + })} +
+ )} + + +
+

Taking iBLOCs

@@ -161,12 +235,15 @@ const PlannerSettings = connect( maxYear: state.planner.maxYear, iblocs: state.planner.iblocs, ignorePrereqCheck: state.planner.ignorePrereqCheck, + selectedProgrammes: state.planner.programmes, }), { setMaxYear: setPlannerMaxYear, setMinYear: setPlannerMinYear, setIBLOCs: setPlannerIBLOCs, setPrereqsCheck: setIgnorePrerequisitesCheck, + addProgramme: addPlannerProgramme, + removeProgramme: removePlannerProgramme, }, )(PlannerSettingsComponent);