diff --git a/website/README.md b/website/README.md index 962042a012..61981497c0 100644 --- a/website/README.md +++ b/website/README.md @@ -267,7 +267,9 @@ If a community libdef is not available, you can try writing your own and placing ### Testing and Linting -We use [Jest][jest] with [Enzyme][enzyme] to test our code and React components, [TypeScript][ts] for typechecking, [Stylelint][stylelint] and [ESLint][eslint] using [Airbnb config][eslint-airbnb] and [Prettier][prettier] for linting and formatting. +We use [Jest][jest] with [Enzyme][enzyme] and [Testing Library][testing-library] to test our code and React components, [TypeScript][ts] for typechecking, [Stylelint][stylelint] and [ESLint][eslint] using [Airbnb config][eslint-airbnb] and [Prettier][prettier] for linting and formatting. + +**Note: The majority of React tests are written with Enzyme. For new unit tests, please try to use [Testing Library][testing-library] instead!** ```sh # Run all tests once with code coverage @@ -404,6 +406,7 @@ Components should keep their styles and tests in the same directory with the sam [bootstrap]: https://getbootstrap.com/ [jest]: https://facebook.github.io/jest/ [enzyme]: http://airbnb.io/enzyme/ +[testing-library]: https://testing-library.com/docs/react-testing-library/intro/ [ts]: https://www.typescriptlang.org/ [eslint]: https://eslint.org/ [svgr]: https://github.com/smooth-code/svgr diff --git a/website/package.json b/website/package.json index c064dc05f3..64645bfb93 100644 --- a/website/package.json +++ b/website/package.json @@ -41,8 +41,8 @@ "@pmmmwh/react-refresh-webpack-plugin": "0.5.15", "@svgr/webpack": "8.1.0", "@testing-library/dom": "10.4.0", - "@testing-library/jest-dom": "6.6.3", - "@testing-library/react": "16.1.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", "@testing-library/user-event": "14.5.2", "@types/body-scroll-lock": "2.6.2", "@types/enzyme": "3.10.18", diff --git a/website/src/__mocks__/modules/MA1521.json b/website/src/__mocks__/modules/MA1521.json new file mode 100644 index 0000000000..c09ace32b6 --- /dev/null +++ b/website/src/__mocks__/modules/MA1521.json @@ -0,0 +1,991 @@ +{ + "acadYear": "2025/2026", + "preclusion": "If undertaking an Undergraduate Degree THEN (( must not have completed 1 of MA1312/MA1505/MA1511/MA2002/YSC1216 at a grade of at least D) AND (must not be undertaking 1 of 0601BMEHON Bachelor of Engineering (Biomedical Engineering) (Hons), 0602CHEHON Bachelor of Engineering (Chemical Engineering) (Hons), 0604ELEHON Bachelor of Engineering (Electrical Engineering) (Hons), 0605ESPHON Bachelor of Engineering (Engineering Science) (Hons), 0607ISEHON Bachelor of Engineering (Industrial and Systems Engineering) (Hons), 0608MSEHON Bachelor of Engineering (Materials Science and Engineering) (Hons), 0609MEHON Bachelor of Engineering (Mechanical Engineering) (Hons), 0611CHEHON Bachelor of Technology (Chemical Engineering) (Hons), 0611ELCHON Bachelor of Technology (Electronics Engineering) (Hons), 0611IMEHON Bachelor of Technology (Industrial & Mgt Engineering) (Hons), 0611MEHON Bachelor of Technology (Mechanical Engineering) (Hons), 0613CEHON Bachelor of Engineering (Civil Engineering) (Hons), 0613EVEHON Bachelor of Engineering (Environmental Engineering) (Hons), 2001CEGHON Bachelor of Engineering (Computer Engineering) (Hons), 1003MAHON Bachelor of Science - Mathematics (Hons), 1003QFNHON Bachelor of Science - Quantitative Finance (Hons), 1006DSAHON Bachelor of Science (Data Science and Analytics) (Hons), 1006DSEXDP Bachelor of Science - Data Sci & Econs (Hons) XDP, 1006STHON Bachelor of Science - Statistics (Hons)))", + "preclusionRule": "PROGRAM_TYPES IF_IN Undergraduate Degree THEN ((COURSES (1) MA1312:D, MA1505:D, MA1511:D, MA2002:D, YSC1216:D) AND (PROGRAMS MUST_NOT_BE_IN (1) 0601BMEHON, 0602CHEHON, 0604ELEHON, 0605ESPHON, 0607ISEHON, 0608MSEHON, 0609MEHON, 0611CHEHON, 0611ELCHON, 0611IMEHON, 0611MEHON, 0613CEHON, 0613EVEHON, 2001CEGHON, 1003MAHON, 1003QFNHON, 1006DSAHON, 1006DSEXDP, 1006STHON))", + "description": "This course provides a basic foundation for calculus and its related subjects required by computing students. The objective is to train the students to be able to handle calculus techniques arising in their courses of specialisation. In addition to the standard calculus material, the course also covers simple mathematical modeling techniques and numerical methods in connection with ordinary differential equations. Major topics: Preliminaries on sets and number systems. Calculus of functions of one variable and applications. Sequences, series and power series. Functions of several variables. Extrema. First and second order differential equations. Basic numerical methods for ordinary differential equations.", + "title": "Calculus for Computing", + "additionalInformation": "", + "department": "Mathematics", + "faculty": "Science", + "workload": [ + 3, + 1, + 0, + 0, + 6 + ], + "gradingBasisDescription": "Graded", + "prerequisite": "If undertaking an Undergraduate Degree THEN (( must have completed 1 of 06 MATHEMATICS/07 FURTHER MATHEMATICS at a grade of at least E) OR ( must have completed 1 of MA1301/MA1301X at a grade of at least D))", + "prerequisiteRule": "PROGRAM_TYPES IF_IN Undergraduate Degree THEN ((SUBJECTS (1) 06:E, 07:E) OR (COURSES (1) MA1301:D, MA1301X:D))", + "moduleCredit": "4", + "moduleCode": "MA1521", + "attributes": { + "mpes1": true, + "mpes2": true, + "su": true + }, + "semesterData": [ + { + "semester": 1, + "timetable": [ + { + "classNo": "28", + "startTime": "1400", + "endTime": "1500", + "weeks": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "venue": "S17-0405", + "day": "Friday", + "lessonType": "Tutorial", + "size": 30, + "covidZone": "B" + }, + { + "classNo": "27", + "startTime": "1300", + "endTime": "1400", + "weeks": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "venue": "S17-0611", + "day": "Friday", + "lessonType": "Tutorial", + "size": 30, + "covidZone": "B" + }, + { + "classNo": "29", + "startTime": "1500", + "endTime": "1600", + "weeks": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "venue": "S17-0405", + "day": "Friday", + "lessonType": "Tutorial", + "size": 30, + "covidZone": "B" + }, + { + "classNo": "30", + "startTime": "1600", + "endTime": "1700", + "weeks": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "venue": "S17-0405", + "day": "Friday", + "lessonType": "Tutorial", + "size": 30, + "covidZone": "B" + }, + { + "classNo": "2", + "startTime": "1000", + "endTime": "1200", + "weeks": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "venue": "LT11", + "day": "Thursday", + "lessonType": "Lecture", + "size": 450, + "covidZone": "B" + }, + { + "classNo": "1", + "startTime": "0800", + "endTime": "1000", + "weeks": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "venue": "LT11", + "day": "Friday", + "lessonType": "Lecture", + "size": 450, + "covidZone": "B" + }, + { + "classNo": "14", + "startTime": "1000", + "endTime": "1100", + "weeks": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "venue": "S16-0309", + "day": "Wednesday", + "lessonType": "Tutorial", + "size": 30, + "covidZone": "B" + }, + { + "classNo": "18", + "startTime": "1200", + "endTime": "1300", + "weeks": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "venue": "S16-0304", + "day": "Thursday", + "lessonType": "Tutorial", + "size": 30, + "covidZone": "B" + }, + { + "classNo": "11", + "startTime": "1600", + "endTime": "1700", + "weeks": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "venue": "S16-0435", + "day": "Tuesday", + "lessonType": "Tutorial", + "size": 30, + "covidZone": "B" + }, + { + "classNo": "19", + "startTime": "1300", + "endTime": "1400", + "weeks": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "venue": "S16-0304", + "day": "Thursday", + "lessonType": "Tutorial", + "size": 30, + "covidZone": "B" + }, + { + "classNo": "9", + "startTime": "1400", + "endTime": "1500", + "weeks": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "venue": "S17-0404", + "day": "Tuesday", + "lessonType": "Tutorial", + "size": 30, + "covidZone": "B" + }, + { + "classNo": "2", + "startTime": "1500", + "endTime": "1600", + "weeks": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "venue": "S16-0309", + "day": "Monday", + "lessonType": "Tutorial", + "size": 30, + "covidZone": "B" + }, + { + "classNo": "22", + "startTime": "1600", + "endTime": "1700", + "weeks": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "venue": "S17-0404", + "day": "Thursday", + "lessonType": "Tutorial", + "size": 30, + "covidZone": "B" + }, + { + "classNo": "21", + "startTime": "1500", + "endTime": "1600", + "weeks": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "venue": "S16-0304", + "day": "Thursday", + "lessonType": "Tutorial", + "size": 30, + "covidZone": "B" + }, + { + "classNo": "24", + "startTime": "1000", + "endTime": "1100", + "weeks": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "venue": "S17-0611", + "day": "Friday", + "lessonType": "Tutorial", + "size": 30, + "covidZone": "B" + }, + { + "classNo": "10", + "startTime": "1500", + "endTime": "1600", + "weeks": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "venue": "S17-0404", + "day": "Tuesday", + "lessonType": "Tutorial", + "size": 30, + "covidZone": "B" + }, + { + "classNo": "13", + "startTime": "0900", + "endTime": "1000", + "weeks": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "venue": "S16-0309", + "day": "Wednesday", + "lessonType": "Tutorial", + "size": 30, + "covidZone": "B" + }, + { + "classNo": "8", + "startTime": "1300", + "endTime": "1400", + "weeks": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "venue": "S17-0404", + "day": "Tuesday", + "lessonType": "Tutorial", + "size": 30, + "covidZone": "B" + }, + { + "classNo": "20", + "startTime": "1400", + "endTime": "1500", + "weeks": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "venue": "S16-0304", + "day": "Thursday", + "lessonType": "Tutorial", + "size": 30, + "covidZone": "B" + }, + { + "classNo": "5", + "startTime": "1000", + "endTime": "1100", + "weeks": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "venue": "S17-0512", + "day": "Tuesday", + "lessonType": "Tutorial", + "size": 30, + "covidZone": "B" + }, + { + "classNo": "4", + "startTime": "1700", + "endTime": "1800", + "weeks": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "venue": "S16-0307", + "day": "Monday", + "lessonType": "Tutorial", + "size": 30, + "covidZone": "B" + }, + { + "classNo": "7", + "startTime": "1200", + "endTime": "1300", + "weeks": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "venue": "S17-0404", + "day": "Tuesday", + "lessonType": "Tutorial", + "size": 30, + "covidZone": "B" + }, + { + "classNo": "12", + "startTime": "1700", + "endTime": "1800", + "weeks": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "venue": "S16-0435", + "day": "Tuesday", + "lessonType": "Tutorial", + "size": 30, + "covidZone": "B" + }, + { + "classNo": "17", + "startTime": "1300", + "endTime": "1400", + "weeks": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "venue": "S16-0309", + "day": "Wednesday", + "lessonType": "Tutorial", + "size": 30, + "covidZone": "B" + }, + { + "classNo": "16", + "startTime": "1200", + "endTime": "1300", + "weeks": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "venue": "S16-0309", + "day": "Wednesday", + "lessonType": "Tutorial", + "size": 30, + "covidZone": "B" + }, + { + "classNo": "1", + "startTime": "0800", + "endTime": "1000", + "weeks": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "venue": "LT11", + "day": "Tuesday", + "lessonType": "Lecture", + "size": 450, + "covidZone": "B" + }, + { + "classNo": "2", + "startTime": "1000", + "endTime": "1200", + "weeks": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "venue": "LT11", + "day": "Monday", + "lessonType": "Lecture", + "size": 450, + "covidZone": "B" + }, + { + "classNo": "1", + "startTime": "1400", + "endTime": "1500", + "weeks": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "venue": "S16-0309", + "day": "Monday", + "lessonType": "Tutorial", + "size": 30, + "covidZone": "B" + }, + { + "classNo": "15", + "startTime": "1100", + "endTime": "1200", + "weeks": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "venue": "S16-0309", + "day": "Wednesday", + "lessonType": "Tutorial", + "size": 30, + "covidZone": "B" + }, + { + "classNo": "23", + "startTime": "1700", + "endTime": "1800", + "weeks": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "venue": "S17-0404", + "day": "Thursday", + "lessonType": "Tutorial", + "size": 30, + "covidZone": "B" + }, + { + "classNo": "26", + "startTime": "1200", + "endTime": "1300", + "weeks": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "venue": "S17-0611", + "day": "Friday", + "lessonType": "Tutorial", + "size": 30, + "covidZone": "B" + }, + { + "classNo": "3", + "startTime": "1600", + "endTime": "1700", + "weeks": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "venue": "S16-0307", + "day": "Monday", + "lessonType": "Tutorial", + "size": 30, + "covidZone": "B" + }, + { + "classNo": "25", + "startTime": "1100", + "endTime": "1200", + "weeks": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "venue": "S17-0611", + "day": "Friday", + "lessonType": "Tutorial", + "size": 30, + "covidZone": "B" + }, + { + "classNo": "6", + "startTime": "1100", + "endTime": "1200", + "weeks": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "venue": "S17-0512", + "day": "Tuesday", + "lessonType": "Tutorial", + "size": 30, + "covidZone": "B" + } + ], + "covidZones": [ + "B" + ], + "examDate": "2025-11-24T01:00:00.000Z", + "examDuration": 120 + }, + { + "semester": 2, + "timetable": [ + { + "classNo": "2", + "startTime": "1400", + "endTime": "1600", + "weeks": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "venue": "LT27", + "day": "Monday", + "lessonType": "Lecture", + "size": 375, + "covidZone": "B" + }, + { + "classNo": "2", + "startTime": "1400", + "endTime": "1600", + "weeks": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "venue": "LT27", + "day": "Thursday", + "lessonType": "Lecture", + "size": 375, + "covidZone": "B" + }, + { + "classNo": "1", + "startTime": "0800", + "endTime": "1000", + "weeks": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "venue": "LT32", + "day": "Thursday", + "lessonType": "Lecture", + "size": 375, + "covidZone": "B" + }, + { + "classNo": "1", + "startTime": "0800", + "endTime": "1000", + "weeks": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "venue": "LT32", + "day": "Monday", + "lessonType": "Lecture", + "size": 375, + "covidZone": "B" + } + ], + "covidZones": [ + "B" + ], + "examDate": "2026-04-25T01:00:00.000Z", + "examDuration": 120 + } + ], + "prereqTree": { + "or": [ + "MA1301:D", + "MA1301X:D" + ] + }, + "fulfillRequirements": [ + "DSC3214", + "CS3244", + "CS4243", + "CS4248", + "CS5240", + "CS5241", + "CS5249", + "CS3242", + "CS4347", + "CS5343", + "CS3218", + "CS5332", + "IT3011", + "CS4278", + "CS5478", + "CS2109S", + "CS5284", + "CS2251", + "BT2101", + "BT4240", + "IS4242", + "BT3104", + "IS2109", + "MA2108", + "MA2108S", + "MA2213", + "MA2219", + "MA2311", + "MA2312", + "MA3220", + "QF3101", + "DSA2102", + "MA2104", + "QF4103", + "MA2116", + "MA4275", + "PC3236", + "PC3238", + "PC3274A", + "PC2031", + "PC2032", + "ST2334", + "ST2131", + "DSA3361" + ] +} \ No newline at end of file diff --git a/website/src/__mocks__/modules/index.ts b/website/src/__mocks__/modules/index.ts index 8d4d9109a2..dbd6dd209c 100644 --- a/website/src/__mocks__/modules/index.ts +++ b/website/src/__mocks__/modules/index.ts @@ -9,6 +9,7 @@ import CS3216_JSON from './CS3216.json'; import CS4243_JSON from './CS4243.json'; import GES1021_JSON from './GES1021.json'; import PC1222_JSON from './PC1222.json'; +import MA151_JSON from './MA1521.json'; // Have to cast these as Module explicitly, otherwise TS will try to // incorrectly infer the shape from the JSON - specifically Weeks will @@ -21,6 +22,7 @@ export const CS1010S: Module = { ...CS1010S_JSON, timestamp: 1572843950000 }; export const CS3216: Module = { ...CS3216_JSON, timestamp: 1572843950000 }; export const CS4243: Module = { ...CS4243_JSON, timestamp: 1572843950000 }; export const GES1021: Module = { ...GES1021_JSON, timestamp: 1572843950000 }; +export const MA1521: Module = { ...MA151_JSON, timestamp: 1572843950000 }; export const PC1222: Module = { ...PC1222_JSON, timestamp: 1572843950000 }; const modules: Module[] = [ACC2002, BFS1001, CS1010S, CS3216, GES1021, PC1222, CS1010A]; diff --git a/website/src/apis/optimiser.ts b/website/src/apis/optimiser.ts index cce8a0e5eb..bee6cfc0aa 100644 --- a/website/src/apis/optimiser.ts +++ b/website/src/apis/optimiser.ts @@ -35,6 +35,7 @@ export interface OptimiseResponse { DaySlots?: (LessonSlot | null)[][]; // TODO: implement type + // eslint-disable-next-line @typescript-eslint/no-explicit-any [key: string]: any; } diff --git a/website/src/test-utils/optimiser.ts b/website/src/test-utils/optimiser.ts new file mode 100644 index 0000000000..6f319a05dd --- /dev/null +++ b/website/src/test-utils/optimiser.ts @@ -0,0 +1,71 @@ +import { LessonSlot } from 'apis/optimiser'; +import { LessonOption } from 'types/optimiser'; + +export const defaultLectureOption: LessonOption = { + moduleCode: 'CS1010S', + lessonType: 'Lecture', + colorIndex: 0, + lessonKey: 'CS1010S|Lecture', + displayText: 'CS1010S Lecture', + days: ['Wednesday'], +}; + +export const defaultRecitationOption: LessonOption = { + moduleCode: 'CS1010S', + lessonType: 'Recitation', + colorIndex: 0, + lessonKey: 'CS1010S|Recitation', + displayText: 'CS1010S Recitation', + days: ['Thursday', 'Friday'], +}; + +export const defaultTutorialOption: LessonOption = { + moduleCode: 'CS1010S', + lessonType: 'Tutorial', + colorIndex: 0, + lessonKey: 'CS1010S|Tutorial', + displayText: 'CS1010S Tutorial', + days: ['Monday', 'Tuesday'], +}; + +export const defaultLectureSlot: LessonSlot = { + classNo: '1', + day: 'Wednesday', + endTime: '1000', + lessonType: 'Lecture', + startTime: '0800', + venue: 'LT27', + coordinates: { x: 103.7809, y: 1.2969925 }, + StartMin: 480, + EndMin: 600, + DayIndex: 2, + LessonKey: 'CS1010S|Lecture', +}; + +export const defaultRecitationSlot: LessonSlot = { + classNo: '17', + day: 'Friday', + endTime: '1800', + lessonType: 'Recitation', + startTime: '1700', + venue: 'BIZ2-0201', + coordinates: { x: 103.7748, y: 1.2935857 }, + StartMin: 1020, + EndMin: 1080, + DayIndex: 4, + LessonKey: 'CS1010S|Recitation', +}; + +export const defaultTutorialSlot: LessonSlot = { + classNo: '16', + day: 'Monday', + endTime: '1600', + lessonType: 'Tutorial', + startTime: '1500', + venue: 'BIZ2-0226', + coordinates: { x: 103.7752, y: 1.2932994 }, + StartMin: 900, + EndMin: 960, + DayIndex: 0, + LessonKey: 'CS1010S|Tutorial', +}; diff --git a/website/src/types/optimiser.ts b/website/src/types/optimiser.ts new file mode 100644 index 0000000000..79217f21c1 --- /dev/null +++ b/website/src/types/optimiser.ts @@ -0,0 +1,26 @@ +import { DayText, LessonTime, LessonType, ModuleCode } from './modules'; +import { ColorIndex } from './timetables'; + +export type LessonKey = string; +export type DisplayText = string; + +export type TimeRange = { + earliest: LessonTime; + latest: LessonTime; +}; + +export type LessonOption = { + moduleCode: ModuleCode; + lessonType: LessonType; + colorIndex: ColorIndex; + lessonKey: LessonKey; + displayText: DisplayText; + days: DayText[]; +}; + +export type FreeDayConflict = { + moduleCode: ModuleCode; + lessonType: LessonType; + displayText: DisplayText; + days: DayText[]; +}; diff --git a/website/src/utils/optimiser.test.ts b/website/src/utils/optimiser.test.ts new file mode 100644 index 0000000000..31cc231d96 --- /dev/null +++ b/website/src/utils/optimiser.test.ts @@ -0,0 +1,273 @@ +import { CS1010S, CS3216, MA1521 } from '__mocks__/modules'; +import { LessonOption, TimeRange } from 'types/optimiser'; +import { Module, WorkingDays } from 'types/modules'; +import { shuffle } from 'lodash'; +import { OptimiseResponse } from 'apis/optimiser'; +import { + defaultLectureOption, + defaultLectureSlot, + defaultRecitationOption, + defaultRecitationSlot, + defaultTutorialOption, + defaultTutorialSlot, +} from 'test-utils/optimiser'; +import { + getConflictingDays, + getDaysForLessonType, + getDisplayText, + getFreeDayConflicts, + getLessonOptions, + getLessonTypes, + getRecordedLessonOptions, + getLessonKey, + isSaturdayInOptions, + sortDays, + getUnassignedLessonOptions, + getOptimiserAcadYear, + getTimeValues, + getOptimiserTime, +} from './optimiser'; +import { getModuleTimetable } from './modules'; + +const defaultModule = CS1010S; + +describe('getLessonKey', () => { + it('should format unique key', () => { + expect(getLessonKey('CS1010S', 'Lecture')).toEqual('CS1010S|Lecture'); + }); +}); + +describe('getDisplayText', () => { + it('getDisplayText should format display text', () => { + expect(getDisplayText('CS1010S', 'Lecture')).toEqual('CS1010S Lecture'); + }); +}); + +describe('getOptimiserAcadYear', () => { + it('getOptimiserAcadYear should format academic year', () => { + expect(getOptimiserAcadYear('2024/2025')).toEqual('2024-2025'); + }); +}); + +describe('getOptimiserTime', () => { + it('getOptimiserTime should format time', () => { + expect(getOptimiserTime('0800')).toEqual('08:00'); + expect(getOptimiserTime('1000')).toEqual('10:00'); + expect(getOptimiserTime('1030')).toEqual('10:30'); + }); +}); + +describe('getLessonTypes', () => { + it('should map lessons to unique lesson types', () => { + const lessons = getModuleTimetable(defaultModule, 1); + const lessonTypes = getLessonTypes(lessons); + expect(lessonTypes).toHaveLength(3); + expect(lessonTypes).toContain(defaultLectureOption.lessonType); + expect(lessonTypes).toContain(defaultRecitationOption.lessonType); + expect(lessonTypes).toContain(defaultTutorialOption.lessonType); + }); +}); + +describe('getDaysForLessonType', () => { + it('should get unique days for the lesson type', () => { + const lessons = getModuleTimetable(defaultModule, 1); + const lessonOptions = [defaultLectureOption, defaultRecitationOption, defaultTutorialOption]; + lessonOptions.forEach((lessonOption) => { + const days = getDaysForLessonType(lessons, lessonOption.lessonType); + expect(days).toEqual(lessonOption.days); + }); + }); +}); + +describe('getLessonOptions', () => { + it('should map modules to lesson options', () => { + const modules = [defaultModule, CS3216]; + const colors = { [defaultModule.moduleCode]: 0, CS3216: 1 }; + const expected: LessonOption[] = [ + defaultLectureOption, + defaultRecitationOption, + defaultTutorialOption, + { + moduleCode: 'CS3216', + lessonType: 'Lecture', + colorIndex: 1, + lessonKey: 'CS3216|Lecture', + displayText: 'CS3216 Lecture', + days: ['Monday'], + }, + ]; + expect(getLessonOptions(modules, 1, colors)).toEqual(expected); + }); + + it('should return no options if the module is not offered', () => { + const modules = [CS3216]; + const colors = { CS3216: 0 }; + expect(getLessonOptions(modules, 2, colors)).toHaveLength(0); + }); +}); + +describe('getRecordedLessonOptions', () => { + it('should filter out physical lesson options', () => { + const lessonOptions: LessonOption[] = [ + defaultLectureOption, + defaultRecitationOption, + defaultTutorialOption, + ]; + const physicalLessonOptions: LessonOption[] = [defaultTutorialOption]; + const recordedLessonOptions = getRecordedLessonOptions(lessonOptions, physicalLessonOptions); + expect(recordedLessonOptions).toContain(defaultLectureOption); + expect(recordedLessonOptions).toContain(defaultRecitationOption); + }); +}); + +describe('getConflictingDays', () => { + it('should return conflicting days', () => { + const lessons = getModuleTimetable(MA1521, 1).filter( + (lesson) => lesson.lessonType === 'Lecture', + ); + const selectedFreeDays = new Set(['Monday', 'Tuesday']); + const conflictingDays = getConflictingDays(lessons, selectedFreeDays); + expect(conflictingDays).toHaveLength(2); + expect(conflictingDays).toContain('Monday'); + expect(conflictingDays).toContain('Tuesday'); + }); + + it('should return no conflicts if another class can be taken', () => { + const lessons = getModuleTimetable(MA1521, 1).filter( + (lesson) => lesson.lessonType === 'Lecture', + ); + const selectedFreeDays = new Set(['Monday', 'Thursday']); + expect(getConflictingDays(lessons, selectedFreeDays)).toHaveLength(0); + }); +}); + +describe('sortDays', () => { + it('should sort days in order', () => { + const days = shuffle([...WorkingDays]); + expect(sortDays(days)).toEqual(WorkingDays); + }); +}); + +describe('getFreeDayConflicts', () => { + it('should return all free day conflicts', () => { + const modules: Module[] = [defaultModule, MA1521]; + const physicalLessonOptions: LessonOption[] = [ + defaultLectureOption, + defaultRecitationOption, + defaultTutorialOption, + { + moduleCode: 'MA1521', + lessonType: 'Lecture', + colorIndex: 1, + lessonKey: 'MA1521|Lecture', + displayText: 'MA1521 Lecture', + days: ['Monday', 'Tuesday', 'Thursday', 'Friday'], + }, + ]; + const selectedFreeDays = new Set(['Monday', 'Tuesday', 'Thursday']); + const expected = [ + { + moduleCode: defaultTutorialOption.moduleCode, + lessonType: defaultTutorialOption.lessonType, + displayText: defaultTutorialOption.displayText, + days: ['Monday', 'Tuesday'], + }, + { + moduleCode: 'MA1521', + lessonType: 'Lecture', + displayText: 'MA1521 Lecture', + days: ['Monday', 'Tuesday', 'Thursday'], + }, + ]; + const conflicts = getFreeDayConflicts(modules, 1, physicalLessonOptions, selectedFreeDays); + expect(conflicts).toEqual(expected); + }); + + it('should return no conflicts if another class can be taken', () => { + const modules: Module[] = [defaultModule, MA1521]; + const physicalLessonOptions: LessonOption[] = [ + defaultLectureOption, + defaultRecitationOption, + defaultTutorialOption, + { + moduleCode: 'MA1521', + lessonType: 'Lecture', + colorIndex: 1, + lessonKey: 'MA1521|Lecture', + displayText: 'MA1521 Lecture', + days: ['Monday', 'Tuesday', 'Thursday', 'Friday'], + }, + ]; + const selectedFreeDays = new Set(['Monday', 'Thursday']); + const conflicts = getFreeDayConflicts(modules, 1, physicalLessonOptions, selectedFreeDays); + expect(conflicts).toHaveLength(0); + }); +}); + +describe('getUnassignedLessOptions', () => { + it('should return unassigned lesson options', () => { + const lessonOptions: LessonOption[] = [ + defaultLectureOption, + defaultRecitationOption, + defaultTutorialOption, + ]; + const optimiseResponse: OptimiseResponse = { + DaySlots: [[], [], [defaultLectureSlot], [], [defaultRecitationSlot]], + }; + const unassignedLessonOptions = getUnassignedLessonOptions(lessonOptions, optimiseResponse); + expect(unassignedLessonOptions).toHaveLength(1); + expect(unassignedLessonOptions).toContain(defaultTutorialOption); + }); + + it('should return empty array if all lesson options are assigned', () => { + const lessonOptions: LessonOption[] = [ + defaultLectureOption, + defaultRecitationOption, + defaultTutorialOption, + ]; + const optimiseResponse: OptimiseResponse = { + DaySlots: [[defaultTutorialSlot], [], [defaultLectureSlot], [], [defaultRecitationSlot]], + }; + const unassignedLessonOptions = getUnassignedLessonOptions(lessonOptions, optimiseResponse); + expect(unassignedLessonOptions).toHaveLength(0); + }); +}); + +describe('isSaturdayInOptions', () => { + it('should return false if there are no saturday classes', () => { + const lessonOptions: LessonOption[] = [ + defaultLectureOption, + defaultRecitationOption, + defaultTutorialOption, + ]; + expect(isSaturdayInOptions(lessonOptions)).toBe(false); + }); + + it('should return true if a saturday class is possible', () => { + const lessonOptions: LessonOption[] = [ + defaultLectureOption, + defaultRecitationOption, + defaultTutorialOption, + { + moduleCode: 'UTC3103', + lessonType: 'Seminar-Style Module Class', + colorIndex: 1, + lessonKey: 'UTC3103|Seminar-Style Module Class', + displayText: 'UTC3103 Seminar-Style Module Class', + days: ['Saturday'], + }, + ]; + expect(isSaturdayInOptions(lessonOptions)).toBe(true); + }); +}); + +describe('getTimeValues', () => { + it('should return a range of times', () => { + const timeRange: TimeRange = { + earliest: '0800', + latest: '1030', + }; + const expected = ['0800', '0830', '0900', '0930', '1000', '1030']; + expect(getTimeValues(timeRange)).toEqual(expected); + }); +}); diff --git a/website/src/utils/optimiser.ts b/website/src/utils/optimiser.ts new file mode 100644 index 0000000000..e6788d63ca --- /dev/null +++ b/website/src/utils/optimiser.ts @@ -0,0 +1,181 @@ +import { + compact, + difference, + differenceBy, + flatten, + get, + groupBy, + isEmpty, + padStart, + range, + uniq, + values, +} from 'lodash'; +import { + AcadYear, + Day, + DayText, + LessonTime, + LessonType, + Module, + ModuleCode, + RawLesson, + Semester, + WorkingDays, +} from 'types/modules'; +import { DisplayText, FreeDayConflict, LessonOption, LessonKey, TimeRange } from 'types/optimiser'; +import { ColorMapping } from 'types/reducers'; +import { LessonSlot, OptimiseResponse } from 'apis/optimiser'; +import { getModuleTimetable } from './modules'; +import { + convertIndexToTime, + convertTimeToIndex, + getLessonTimeHours, + getLessonTimeMinutes, + NUM_INTERVALS_PER_HOUR, +} from './timify'; + +export function getLessonKey(moduleCode: ModuleCode, lessonType: LessonType): LessonKey { + return `${moduleCode}|${lessonType}`; +} + +export function getDisplayText(moduleCode: ModuleCode, lessonType: LessonType): DisplayText { + return `${moduleCode} ${lessonType}`; +} + +export function getOptimiserAcadYear(acadYear: AcadYear): string { + const [from, to] = acadYear.split('/'); + return `${from}-${to}`; +} + +export function getOptimiserTime(time: LessonTime): string { + const hh = padStart(String(getLessonTimeHours(time)), 2, '0'); + const mm = padStart(String(getLessonTimeMinutes(time)), 2, '0'); + return `${hh}:${mm}`; +} + +export function getLessonTypes(lessons: readonly RawLesson[]): LessonType[] { + return uniq(lessons.map((lesson) => lesson.lessonType)); +} + +export function getDaysForLessonType( + lessons: readonly RawLesson[], + lessonType: LessonType, +): DayText[] { + return uniq( + lessons.filter((lesson) => lesson.lessonType === lessonType).map((lesson) => lesson.day), + ); +} + +// Creates a LessonOption for each lessonType +export function getLessonOptions( + modules: Module[], + semester: Semester, + colors: ColorMapping, +): LessonOption[] { + return modules.flatMap((module) => { + const { moduleCode } = module; + const colorIndex = colors[moduleCode]; + const lessons = getModuleTimetable(module, semester); + const lessonTypes = getLessonTypes(lessons); + return lessonTypes.map((lessonType) => ({ + moduleCode, + lessonType, + colorIndex, + lessonKey: getLessonKey(moduleCode, lessonType), + displayText: getDisplayText(moduleCode, lessonType), + days: getDaysForLessonType(lessons, lessonType), + })); + }); +} + +// Filters out physical lesson options to obtain recorded lesson options +export function getRecordedLessonOptions( + lessonOptions: LessonOption[], + physicalLessonOptions: LessonOption[], +): LessonOption[] { + return differenceBy( + lessonOptions, + physicalLessonOptions, + (lessonOption) => lessonOption.lessonKey, + ); +} + +export function sortDays(days: DayText[]) { + return days.sort((a, b) => WorkingDays.indexOf(a as Day) - WorkingDays.indexOf(b as Day)); +} + +// For each classNo, check if it's possible to fit the lessons within the free days constraint +export function getConflictingDays( + lessons: readonly RawLesson[], + selectedFreeDays: Set, +): DayText[] { + const groupedLessons = groupBy(lessons, (lesson) => lesson.classNo); + const conflictingDays = values(groupedLessons).map((classLessons) => { + const days = uniq(classLessons.flatMap((lesson) => lesson.day)); + return days.filter((day) => selectedFreeDays.has(day)); + }); + const isConflictFree = conflictingDays.some((conflicts) => isEmpty(conflicts)); + return isConflictFree ? [] : sortDays(uniq(flatten(conflictingDays))); +} + +// For each physical lesson option, check if that lessonType will have conflicts +export function getFreeDayConflicts( + modules: Module[], + semester: Semester, + physicalLessonOptions: LessonOption[], + selectedFreeDays: Set, +): FreeDayConflict[] { + const groupedPhysicalLessonOptions = groupBy( + physicalLessonOptions, + (lessonOption) => lessonOption.moduleCode, + ); + + return modules.flatMap((module) => { + const { moduleCode } = module; + const lessons = getModuleTimetable(module, semester); + const lessonOptions = get(groupedPhysicalLessonOptions, moduleCode, []); + return compact( + lessonOptions.map((lessonOption) => { + const { lessonType, displayText } = lessonOption; + const filteredLessons = lessons.filter((lesson) => lesson.lessonType === lessonType); + const conflictingDays = getConflictingDays(filteredLessons, selectedFreeDays); + return isEmpty(conflictingDays) + ? null + : { + moduleCode, + lessonType, + displayText, + days: conflictingDays, + }; + }), + ); + }); +} + +export function getUnassignedLessonOptions( + lessonOptions: LessonOption[], + optimiserResponse: OptimiseResponse, +): LessonOption[] { + const daySlots = optimiserResponse.DaySlots ?? []; + const assignedLessonKeys = uniq( + compact(flatten(daySlots).flatMap((slot: LessonSlot | null) => slot?.LessonKey)), + ); + + const allLessonKeys = lessonOptions.map((lessonOption) => lessonOption.lessonKey); + const unassignedLessonKeys = new Set(difference(allLessonKeys, assignedLessonKeys)); + return lessonOptions.filter((lessonOption) => unassignedLessonKeys.has(lessonOption.lessonKey)); +} + +export function isSaturdayInOptions(lessonOptions: LessonOption[]): boolean { + return lessonOptions + .flatMap((lessonOption) => lessonOption.days) + .some((day) => day === 'Saturday'); +} + +export function getTimeValues(timeRange: TimeRange) { + const earliestIndex = convertTimeToIndex(timeRange.earliest); + const latestIndex = convertTimeToIndex(timeRange.latest) + 1; + const stride = NUM_INTERVALS_PER_HOUR / 2; + return range(earliestIndex, latestIndex, stride).map((index) => convertIndexToTime(index)); +} diff --git a/website/src/views/hooks/useOptimiserForm.tsx b/website/src/views/hooks/useOptimiserForm.tsx new file mode 100644 index 0000000000..16e0765a58 --- /dev/null +++ b/website/src/views/hooks/useOptimiserForm.tsx @@ -0,0 +1,51 @@ +import { Dispatch, SetStateAction, useState } from 'react'; +import { DayText } from 'types/modules'; +import { LessonOption, TimeRange } from 'types/optimiser'; + +const defaultLessonTimeRange: TimeRange = { + earliest: '0800', + latest: '1900', +}; + +const defaultLunchTimeRange: TimeRange = { + earliest: '1200', + latest: '1400', +}; + +const defaultMaxConsecutiveHours = 4; + +export type OptimiserFormFields = { + liveLessonOptions: LessonOption[]; + setLiveLessonOptions: Dispatch>; + freeDays: Set; + setFreeDays: Dispatch>>; + lessonTimeRange: TimeRange; + setLessonTimeRange: Dispatch>; + lunchTimeRange: TimeRange; + setLunchTimeRange: Dispatch>; + maxConsecutiveHours: number; + setMaxConsecutiveHours: Dispatch>; +}; + +// TODO: leslieyip02 - consider using react-hook-form +// https://github.com/nusmodifications/nusmods/pull/4094#discussion_r2209166244 +export default function useOptimiserForm(): OptimiserFormFields { + const [liveLessonOptions, setLiveLessonOptions] = useState([]); + const [freeDays, setFreeDays] = useState(new Set()); + const [lessonTimeRange, setLessonTimeRange] = useState(defaultLessonTimeRange); + const [maxConsecutiveHours, setMaxConsecutiveHours] = useState(defaultMaxConsecutiveHours); + const [lunchTimeRange, setLunchTimeRange] = useState(defaultLunchTimeRange); + + return { + liveLessonOptions, + setLiveLessonOptions, + freeDays, + setFreeDays, + lessonTimeRange, + setLessonTimeRange, + lunchTimeRange, + setLunchTimeRange, + maxConsecutiveHours, + setMaxConsecutiveHours, + }; +} diff --git a/website/src/views/optimiser/OptimiserButton.scss b/website/src/views/optimiser/OptimiserButton.scss index 81df24f2cc..ede2918c6a 100644 --- a/website/src/views/optimiser/OptimiserButton.scss +++ b/website/src/views/optimiser/OptimiserButton.scss @@ -1,12 +1,13 @@ @import '~styles/utils/modules-entry.scss'; @keyframes grow { - 0%, 100% { - opacity: 0.5; + 0%, + 100% { + opacity: 0.5; transform: scale(0.8); } 50% { - opacity: 1; + opacity: 1; transform: scale(1.2); } } @@ -19,7 +20,6 @@ animation: grow 1s ease-in-out infinite; } -// Optimize button section .optimizeButtonSection { display: flex; flex-direction: column; @@ -90,4 +90,12 @@ .estimateTimeValue { font-weight: bold; -} \ No newline at end of file +} + +.zapIcon { + fill: #ff5138; + + &.disabled { + fill: #69707a; + } +} diff --git a/website/src/views/optimiser/OptimiserButton.test.tsx b/website/src/views/optimiser/OptimiserButton.test.tsx new file mode 100644 index 0000000000..56f72673b0 --- /dev/null +++ b/website/src/views/optimiser/OptimiserButton.test.tsx @@ -0,0 +1,78 @@ +import { render, screen } from '@testing-library/react'; +import { defaultLectureOption } from 'test-utils/optimiser'; +import OptimiserButton, { OptimiserButtonProps } from './OptimiserButton'; + +describe('OptimiserButton', () => { + it('should be enabled when there are lesson options', () => { + const props: OptimiserButtonProps = { + isOptimising: false, + lessonOptions: [defaultLectureOption], + freeDayConflicts: [], + onClick: jest.fn(), + }; + render(); + expect(screen.getByRole('button')).toBeEnabled(); + }); + + it('should be disabled when there are no lesson options', () => { + const props: OptimiserButtonProps = { + isOptimising: false, + lessonOptions: [], + freeDayConflicts: [], + onClick: jest.fn(), + }; + render(); + expect(screen.getByRole('button')).toBeDisabled(); + }); + + it('should be disabled when optimising', () => { + const props: OptimiserButtonProps = { + isOptimising: true, + lessonOptions: [defaultLectureOption], + freeDayConflicts: [], + onClick: jest.fn(), + }; + render(); + expect(screen.getByRole('button')).toBeDisabled(); + }); + + it('should be disabled when there are free day conflicts', () => { + const props: OptimiserButtonProps = { + isOptimising: false, + lessonOptions: [], + freeDayConflicts: [ + { + moduleCode: defaultLectureOption.moduleCode, + lessonType: defaultLectureOption.lessonType, + displayText: defaultLectureOption.displayText, + days: [], + }, + ], + onClick: jest.fn(), + }; + render(); + expect(screen.getByRole('button')).toBeDisabled(); + }); + + it('should show "Searching and optimising..." when optimising', () => { + const props: OptimiserButtonProps = { + isOptimising: true, + lessonOptions: [defaultLectureOption], + freeDayConflicts: [], + onClick: jest.fn(), + }; + render(); + expect(screen.getByRole('button')).toHaveTextContent('Searching and optimising...'); + }); + + it('should show "Optimise Timetable" when not optimising', () => { + const props: OptimiserButtonProps = { + isOptimising: false, + lessonOptions: [defaultLectureOption], + freeDayConflicts: [], + onClick: jest.fn(), + }; + render(); + expect(screen.getByRole('button')).toHaveTextContent('Optimise Timetable'); + }); +}); diff --git a/website/src/views/optimiser/OptimiserButton.tsx b/website/src/views/optimiser/OptimiserButton.tsx index c0f094f98c..03178919b6 100644 --- a/website/src/views/optimiser/OptimiserButton.tsx +++ b/website/src/views/optimiser/OptimiserButton.tsx @@ -1,13 +1,13 @@ -import React from 'react'; import classnames from 'classnames'; import { Zap } from 'react-feather'; -import { FreeDayConflict, LessonOption } from './types'; +import { FreeDayConflict, LessonOption } from 'types/optimiser'; +import { isEmpty } from 'lodash'; import styles from './OptimiserButton.scss'; -interface OptimiserButtonProps { - freeDayConflicts: FreeDayConflict[]; - lessonOptions: LessonOption[]; +export interface OptimiserButtonProps { isOptimising: boolean; + lessonOptions: LessonOption[]; + freeDayConflicts: FreeDayConflict[]; onClick: () => void; } @@ -16,41 +16,45 @@ const OptimiserButton: React.FC = ({ lessonOptions, isOptimising, onClick, -}) => ( -
- -
-
estimated time:
-
5s - 40s
+}) => { + const isDisabled = isOptimising || isEmpty(lessonOptions) || !isEmpty(freeDayConflicts); + + return ( +
+ + +
+
estimated time:
+
5s - 40s
+
-
-); + ); +}; export default OptimiserButton; diff --git a/website/src/views/optimiser/OptimiserContent.scss b/website/src/views/optimiser/OptimiserContainer/OptimiserContent.scss similarity index 59% rename from website/src/views/optimiser/OptimiserContent.scss rename to website/src/views/optimiser/OptimiserContainer/OptimiserContent.scss index a1923f2932..5059ced6c9 100644 --- a/website/src/views/optimiser/OptimiserContent.scss +++ b/website/src/views/optimiser/OptimiserContainer/OptimiserContent.scss @@ -1,12 +1,8 @@ @import '~styles/utils/modules-entry.scss'; -// Main container .container { display: flex; flex-direction: column; + padding: 0 1rem; margin-top: 1rem; - - @include media-breakpoint-down(md) { - padding: 0 1rem; - } } \ No newline at end of file diff --git a/website/src/views/optimiser/OptimiserContainer/OptimiserContent.tsx b/website/src/views/optimiser/OptimiserContainer/OptimiserContent.tsx new file mode 100644 index 0000000000..cd96f3ae92 --- /dev/null +++ b/website/src/views/optimiser/OptimiserContainer/OptimiserContent.tsx @@ -0,0 +1,146 @@ +import React, { useState, useMemo, useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import { getSemesterTimetableColors, getSemesterTimetableLessons } from 'selectors/timetables'; +import { State } from 'types/state'; +import { ColorMapping } from 'types/reducers'; +import { OptimiseRequest, OptimiseResponse, sendOptimiseRequest } from 'apis/optimiser'; +import Title from 'views/components/Title'; +import ApiError from 'views/errors/ApiError'; +import { SemTimetableConfig } from 'types/timetables'; +import { getSemesterModules } from 'utils/timetables'; +import { + getFreeDayConflicts, + getLessonOptions, + getOptimiserAcadYear, + getRecordedLessonOptions, + getUnassignedLessonOptions, + isSaturdayInOptions, +} from 'utils/optimiser'; +import { FreeDayConflict, LessonOption } from 'types/optimiser'; +import useOptimiserForm from 'views/hooks/useOptimiserForm'; +import styles from './OptimiserContent.scss'; +import OptimiserHeader from '../OptimiserHeader'; +import OptimiserForm from '../OptimiserForm/OptimiserForm'; +import OptimiserButton from '../OptimiserButton'; +import OptimiserResults from '../OptimiserResults'; + +const OptimiserContent: React.FC = () => { + const activeSemester = useSelector(({ app }: State) => app.activeSemester); + const colors: ColorMapping = useSelector(getSemesterTimetableColors)(activeSemester); + const timetable: SemTimetableConfig = useSelector(getSemesterTimetableLessons)(activeSemester); + const modulesMap = useSelector(({ moduleBank }: State) => moduleBank.modules); + const acadYear = useSelector((state: State) => state.timetables.academicYear); + + const optimiserFormFields = useOptimiserForm(); + const { + liveLessonOptions: physicalLessonOptions, + setLiveLessonOptions: setPhysicalLessonOptions, + freeDays, + lessonTimeRange, + lunchTimeRange, + maxConsecutiveHours, + } = optimiserFormFields; + + const [unassignedLessons, setUnassignedLessons] = useState([]); + const [shareableLink, setShareableLink] = useState(null); + const [isOptimising, setIsOptimising] = useState(false); + const [error, setError] = useState(null); + + const lessonOptions = useMemo(() => { + const modules = getSemesterModules(timetable, modulesMap); + return getLessonOptions(modules, activeSemester, colors); + }, [timetable, modulesMap, activeSemester, colors]); + + const freeDayConflicts: FreeDayConflict[] = useMemo(() => { + const modules = getSemesterModules(timetable, modulesMap); + return getFreeDayConflicts(modules, activeSemester, physicalLessonOptions, freeDays); + }, [timetable, modulesMap, activeSemester, physicalLessonOptions, freeDays]); + + const recordedLessonOptions: LessonOption[] = useMemo( + () => getRecordedLessonOptions(lessonOptions, physicalLessonOptions), + [lessonOptions, physicalLessonOptions], + ); + + const hasSaturday = useMemo(() => isSaturdayInOptions(lessonOptions), [lessonOptions]); + + useEffect(() => { + const availableKeys = new Set(lessonOptions.map((option) => option.lessonKey)); + setPhysicalLessonOptions((prev) => + prev.filter((lesson) => availableKeys.has(lesson.lessonKey)), + ); + }, [lessonOptions, setPhysicalLessonOptions]); + + const buttonOnClick = async () => { + setShareableLink(null); + setIsOptimising(true); + setError(null); + + const params: OptimiseRequest = { + modules: Object.keys(timetable), + acadYear: getOptimiserAcadYear(acadYear), + acadSem: activeSemester, + freeDays: Array.from(freeDays), + earliestTime: lessonTimeRange.earliest, + latestTime: lessonTimeRange.latest, + recordings: recordedLessonOptions.map((lessonOption) => lessonOption.displayText), + lunchStart: lunchTimeRange.earliest, + lunchEnd: lunchTimeRange.latest, + maxConsecutiveHours, + }; + + sendOptimiseRequest(params) + .then(parseData) + .catch((e) => { + setError(e); + + // FIXME: temporarily log errors into the console for beta + // eslint-disable-next-line no-console + console.log(e); + }) + .finally(() => setIsOptimising(false)); + }; + + const parseData = async (data: OptimiseResponse | null) => { + const link = data?.shareableLink; + if (!link) { + throw new Error('expected shareable link to be created'); + } + setShareableLink(link); + + const unassignedLessonOptions = getUnassignedLessonOptions(lessonOptions, data); + setUnassignedLessons(unassignedLessonOptions); + }; + + return ( +
+ Optimiser + + + + + + + + {!!error && ( + + )} + + +
+ ); +}; + +export default OptimiserContent; diff --git a/website/src/views/optimiser/index.tsx b/website/src/views/optimiser/OptimiserContainer/index.tsx similarity index 100% rename from website/src/views/optimiser/index.tsx rename to website/src/views/optimiser/OptimiserContainer/index.tsx diff --git a/website/src/views/optimiser/OptimiserContent.tsx b/website/src/views/optimiser/OptimiserContent.tsx deleted file mode 100644 index b2d3ba000a..0000000000 --- a/website/src/views/optimiser/OptimiserContent.tsx +++ /dev/null @@ -1,298 +0,0 @@ -import React, { useState, useCallback, useMemo, useEffect } from 'react'; -import { useSelector } from 'react-redux'; -import { getSemesterTimetableColors, getSemesterTimetableLessons } from 'selectors/timetables'; -import { State } from 'types/state'; -import { ColorMapping } from 'types/reducers'; -import { getModuleTimetable } from 'utils/modules'; -import { LessonSlot, OptimiseRequest, OptimiseResponse, sendOptimiseRequest } from 'apis/optimiser'; -import Title from 'views/components/Title'; -import { flatten } from 'lodash'; -import ApiError from 'views/errors/ApiError'; -import styles from './OptimiserContent.scss'; -import OptimiserHeader from './OptimiserHeader'; -import OptimiserForm from './OptimiserForm'; -import OptimiserButton from './OptimiserButton'; -import OptimiserResults from './OptimiserResults'; -import { LessonOption, FreeDayConflict } from './types'; - -const OptimiserContent: React.FC = () => { - const activeSemester = useSelector(({ app }: State) => app.activeSemester); - const colors: ColorMapping = useSelector(getSemesterTimetableColors)(activeSemester); - const timetable = useSelector(getSemesterTimetableLessons)(activeSemester); - const modules = useSelector(({ moduleBank }: State) => moduleBank.modules); - const acadYear = useSelector((state: State) => state.timetables.academicYear); - - const [selectedLessons, setSelectedLessons] = useState([]); - const [selectedFreeDays, setSelectedFreeDays] = useState>(new Set()); - const [earliestTime, setEarliestTime] = useState('0800'); - const [latestTime, setLatestTime] = useState('1900'); - const [earliestLunchTime, setEarliestLunchTime] = useState('1200'); - const [latestLunchTime, setLatestLunchTime] = useState('1400'); - const [freeDayConflicts, setFreeDayConflicts] = useState([]); - const [unAssignedLessons, setUnAssignedLessons] = useState([]); - const [shareableLink, setShareableLink] = useState(''); - const [hasSaturday, setHasSaturday] = useState(false); - const [maxConsecutiveHours, setMaxConsecutiveHours] = useState(4); - - // button - const [isOptimising, setIsOptimising] = useState(false); - const [error, setError] = useState(null); - - // Generate lesson options from current timetable - // find the lesson and the type in lessonOptions, and then find the days of that combination in lessonDaysData - const lessonOptions = useMemo(() => { - const options: LessonOption[] = []; - - Object.keys(timetable).forEach((moduleCode) => { - const module = modules[moduleCode]; - if (!module) return; - - const moduleTimetable = getModuleTimetable(module, activeSemester); - const colorIndex = colors[moduleCode] || 0; - - // Get unique lesson types for this module - const lessonTypes = Array.from(new Set(moduleTimetable.map((lesson) => lesson.lessonType))); - - lessonTypes.forEach((lessonType) => { - const uniqueKey = `${moduleCode}-${lessonType}`; - const displayText = `${moduleCode} ${lessonType}`; - - options.push({ - moduleCode, - lessonType, - colorIndex, - displayText, - uniqueKey, - }); - }); - }); - - return options; - }, [timetable, modules, activeSemester, colors]); - - // group by classNo - const lessonGroupsData = useMemo(() => { - const moduleGroupMap = new Map>(); - // each item has moduleCode-lessonType, combination, so get all the groups with days for that group - lessonOptions.forEach((option) => { - const module = modules[option.moduleCode]; - // get the timetable so that you can get the groups and the days for that group - const moduleTimetable = getModuleTimetable(module, activeSemester); - // now get the groups and the days for that group - // the DS for this is a list of maps, {groupName(string): days(list type)} - const groupDayMap = new Map(); - - moduleTimetable.forEach((lesson) => { - if (lesson.lessonType === option.lessonType) { - if (groupDayMap.has(lesson.classNo)) { - // find the the item with that key and push the day to the days array - const days = groupDayMap.get(lesson.classNo) || []; - days.push(lesson.day); - groupDayMap.set(lesson.classNo, days); - } else { - groupDayMap.set(lesson.classNo, [lesson.day]); - } - } - }); - moduleGroupMap.set(option.uniqueKey, groupDayMap); - // now you have all the groups for that module-lessonType combination - }); - return moduleGroupMap; - }, [lessonOptions, modules, activeSemester]); - - // Unselected module-lessonType combinations are recorded lessons `module lessonType` - const recordings = useMemo(() => { - const selectedKeys = new Set(selectedLessons.map((lesson) => lesson.uniqueKey)); - return lessonOptions - .filter((lesson) => !selectedKeys.has(lesson.uniqueKey)) - .map((lesson) => lesson.displayText); - }, [selectedLessons, lessonOptions]); - - // Validate free days against non-recorded lessons - useEffect(() => { - const conflicts: FreeDayConflict[] = []; - - // check if all groups are not possible to attend, then it's a conflict, then day of conflict is the days of one of the groups - // go thorugh each module-lessonType combination, and within that, - // go through each group and within that check if any of the selected free days are in them, if so, that group is invalid - lessonGroupsData.forEach((groupMap, uniqueKey) => { - let validGroups = 0; - const groupDays = new Set(); - groupMap.forEach((days) => { - if ( - recordings.includes(uniqueKey) || // if it is a recorded lesson, dont trigger a conflict - !days.some((day) => selectedFreeDays.has(day)) - ) { - validGroups += 1; - } - days.forEach((day) => groupDays.add(day)); - }); - if (selectedFreeDays.size > 0 && validGroups === 0) { - // check if conflict with the same moduleCode and lessonType already exists, if so, remove the old one and add the new one - const existingConflict = conflicts.find( - (conflict) => - conflict.moduleCode === uniqueKey.split('-')[0] && - conflict.lessonType === uniqueKey.split('-')[1], - ); - if (existingConflict) { - conflicts.splice(conflicts.indexOf(existingConflict), 1); - } - if (groupDays.has('Saturday')) { - setHasSaturday(true); - } - conflicts.push({ - moduleCode: uniqueKey.split('-')[0], - lessonType: uniqueKey.split('-')[1], - displayText: uniqueKey.split('-').join(' '), - conflictingDays: Array.from(selectedFreeDays).filter((day) => groupDays.has(day)), - }); - } - }); - setFreeDayConflicts(conflicts); - }, [selectedFreeDays, lessonGroupsData, recordings]); - - useEffect(() => { - const availableKeys = new Set(lessonOptions.map((option) => option.uniqueKey)); - setSelectedLessons((prev) => prev.filter((lesson) => availableKeys.has(lesson.uniqueKey))); - }, [lessonOptions]); - - const toggleLessonSelection = useCallback( - (option: LessonOption) => { - const isSelected = selectedLessons.some((lesson) => lesson.uniqueKey === option.uniqueKey); - - if (isSelected) { - setSelectedLessons((prev) => - prev.filter((lesson) => lesson.uniqueKey !== option.uniqueKey), - ); - } else { - setSelectedLessons((prev) => [...prev, option]); - } - }, - [selectedLessons], - ); - - const toggleFreeDay = useCallback((day: string) => { - setSelectedFreeDays((prev) => { - const newSet = new Set(prev); - if (newSet.has(day)) { - newSet.delete(day); - } else { - newSet.add(day); - } - return newSet; - }); - }, []); - - const buttonOnClick = async () => { - setShareableLink(''); // Reset shareable link - setIsOptimising(true); - setError(null); - - const modulesList = Object.keys(timetable); - const acadYearFormatted = `${acadYear.split('/')[0]}-${acadYear.split('/')[1]}`; - - const params: OptimiseRequest = { - modules: modulesList, - acadYear: acadYearFormatted, - acadSem: activeSemester, - freeDays: Array.from(selectedFreeDays), - earliestTime, - latestTime, - recordings, - lunchStart: earliestLunchTime, - lunchEnd: latestLunchTime, - maxConsecutiveHours, - }; - - sendOptimiseRequest(params) - .then(parseData) - .catch((e) => setError(e)) - .finally(() => setIsOptimising(false)); - }; - - const parseData = async (data: OptimiseResponse | null) => { - const link = data?.shareableLink; - if (!link) { - return; - } - setShareableLink(link); - - const daySlots = data.DaySlots ?? []; - const assignedLessons = new Set( - flatten(daySlots) - .map((slot: LessonSlot | null) => { - const lessonKey = slot?.LessonKey; - if (!lessonKey) { - return null; - } - const [moduleCode, lessonType] = lessonKey.split('|'); - return `${moduleCode} ${lessonType}`; - }) - .filter((lesson) => !!lesson), - ); - - setUnAssignedLessons( - lessonOptions.filter((lesson) => !assignedLessons.has(lesson.displayText)), - ); - }; - - const openOptimisedTimetable = () => { - if (shareableLink) { - window.open(shareableLink, '_blank'); - } - }; - - return ( -
- Optimiser - - {/* Optimiser header */} - - - {/* All the form elements */} - - - {/* Optimiser button */} - - - {!!error && ( - - )} - - {/* Optimiser results */} - -
- ); -}; - -export default OptimiserContent; diff --git a/website/src/views/optimiser/OptimiserForm.scss b/website/src/views/optimiser/OptimiserForm.scss deleted file mode 100644 index 13f97ccbcd..0000000000 --- a/website/src/views/optimiser/OptimiserForm.scss +++ /dev/null @@ -1,370 +0,0 @@ -@import '~styles/utils/modules-entry.scss'; - -.lessonTag { - display: inline-flex; - align-items: center; - padding: 0.25rem 0.5rem; - border-bottom: 3px solid; - border-radius: 0.25rem; - font-weight: 500; - font-size: 0.8rem; - transition: transform 0.15s ease, filter 0.15s ease; - gap: 0.25rem; - - &:hover { - filter: brightness(0.8); - } -} - -.tag { - cursor: pointer; -} - -// Main content section -.mainContent { - display: flex; - flex-direction: column; - margin-top: 2rem; -} - -// Section headers -.sectionHeader { - display: flex; - flex-direction: row; - flex-wrap: wrap; - align-items: center; - margin-bottom: 1rem; - font-size: 1rem; - gap: 0.5rem; -} - -// Lesson selection section -.lessonButtons { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; -} - -.lessonButton { - display: inline-flex; - align-items: center; - padding: 0.5rem 0.5rem; - border-top: none; - border-right: none; - border-left: none; - border-radius: 0.25rem; - font-weight: 500; - font-size: 0.8rem; - cursor: pointer; - transition: all 0.15s ease; - gap: 0.25rem; - - &.selected { - opacity: 1; - filter: brightness(1); - transform: scale(1); - } - - &.unselected { - opacity: 0.6; - filter: brightness(0.8); - transform: scale(0.98); - } - - &:hover { - opacity: 1 !important; - filter: brightness(1.1) !important; - transform: scale(1.02) !important; - } -} - -.lessonButtonText { - font-weight: bold; -} - -// Free days section -.freeDaysSection { - display: flex; - align-items: center; - margin-top: 2rem; - font-size: 1rem; - gap: 0.5rem; -} - -.freeDaysButtons { - display: flex; - flex-direction: row; - flex-wrap: wrap; - margin-top: 1rem; - gap: 0.5rem; -} - -// Conflict warning -.conflictWarning { - display: flex; - flex-direction: column; - padding: 1rem; - margin-top: 1rem; - border: 1px solid rgba(255, 81, 56, 0.3); - border-radius: 0.5rem; - background-color: rgba(255, 81, 56, 0.1); - gap: 0.5rem; -} - -.conflictHeader { - display: flex; - align-items: center; - font-weight: bold; - font-size: 1rem; - color: #ff5138; - gap: 0.5rem; -} - -.conflictDescription { - font-size: 0.9rem; - color: #69707a; -} - -.conflictItem { - margin-left: 1rem; - font-weight: 500; - font-size: 0.9rem; - color: #ff5138; -} - -.conflictFooter { - margin-top: 0.5rem; - font-style: italic; - font-size: 0.8rem; - color: #69707a; -} - -// Time controls section -.timeControls { - display: flex; - flex-wrap: wrap; - margin-top: 2rem; - gap: 3rem; - - @include media-breakpoint-down(md) { - flex-direction: column; - gap: 1.5rem; - } -} - -.timeControlWrapper { - display: flex; - flex-direction: row; - align-items: center; - gap: 0.5rem; -} - -.timeControlGroup { - display: flex; - flex-direction: column; - width: auto; - min-width: 200px; - gap: 0.5rem; - - @include media-breakpoint-down(md) { - width: 100%; - min-width: auto; - } -} - -.timeControlHeader { - display: flex; - align-items: center; - font-size: 1rem; - gap: 0.5rem; -} - -.timeControlRow { - display: flex; - flex-direction: row; - align-items: center; - gap: 0.5rem; -} - -.timeSelect { - width: 7.1rem; - padding: 0.25rem 0.75rem; - padding-left: 0.9rem; - border: 1px solid var(--gray-lighter); - border-radius: 0.25rem; - outline: none; - font-size: 1.3rem; - font-family: monospace; - color: inherit; - background-image: url("data:image/svg+xml;charset=US-ASCII,"); - background-position: right 0.7rem center; - background-size: 0.85rem; - background-repeat: no-repeat; - background-color: transparent; - appearance: none; - cursor: pointer; -} - -.timeLabel { - font-weight: 500; - font-size: 1.2rem; - font-family: monospace; - color: inherit; -} - -// Lunch controls section -.lunchControls { - display: flex; - flex-wrap: wrap; - margin-top: 2rem; - gap: 3rem; - - @include media-breakpoint-down(md) { - flex-direction: column; - gap: 1.5rem; - } -} - -.lunchControlGroup { - display: flex; - flex-direction: column; - width: auto; - min-width: 200px; - gap: 0.5rem; - - @include media-breakpoint-down(md) { - width: 100%; - min-width: auto; - } -} - -.lunchControlHeader { - display: flex; - align-items: center; - font-size: 1rem; - gap: 0.5rem; -} - -.lunchControlRow { - display: flex; - flex-direction: row; - align-items: center; - gap: 0.5rem; -} - -.lunchTimeLabel { - font-weight: 500; - font-size: 1.2rem; - font-family: monospace; - color: inherit; -} - -.lunchTimeSeparator { - margin: 0 1.5rem; - font-size: 1rem; - color: #69707a; -} - -.noLessonsFound { - display: flex; - flex-direction: column; - width: 100%; - padding: 1.5rem; - margin-top: 0.5rem; - border: 1px solid rgba(255, 193, 7, 0.3); - border-radius: 0.75rem; - background-color: rgba(255, 193, 7, 0.1); - gap: 1rem; -} - -.noLessonsHeader { - display: flex; - align-items: center; - font-weight: bold; - font-size: 1.1rem; - color: #ff8c00; - gap: 0.75rem; -} - -.noLessonsDescription { - display: flex; - font-size: 0.95rem; - line-height: 1.5; - color: rgba(255, 193, 7, 0.5); -} - -.timetableLink { - color: inherit; - &:hover { - text-decoration: none; - } -} - -// Icon styling -.infoIcon { - color: #69707a; -} - -// Priority notice section -.priorityNotice { - padding-top: 1rem; - margin-top: 2rem; - border-top: 1px solid var(--gray-lighter); - font-size: 1.1rem; - - .prioritised { - opacity: 0.85; - font-weight: bold; - color: #ff5138; - } - - .notGuaranteed { - font-weight: 600; - text-decoration: underline; - text-decoration-color: #ff5138; - text-decoration-thickness: 2px; - text-underline-offset: 2px; - } -} - -.maxConsecutiveHours { - display: flex; - align-items: center; - margin-top: 2rem; - font-size: 1rem; - gap: 0.5rem; -} - -.maxConsecutiveHoursGroup { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.maxConsecutiveHoursHeader { - display: flex; - flex-direction: row; - flex-wrap: wrap; - align-items: center; - font-size: 1rem; - gap: 0.5rem; -} - -.maxConsecutiveHoursInput { - width: 4.1rem; - padding: 0.25rem 0.75rem; - padding-left: 0.9rem; - border: 1px solid var(--gray-lighter); - border-radius: 0.25rem; - outline: none; - font-size: 1.3rem; - font-family: monospace; - color: inherit; - background-image: url("data:image/svg+xml;charset=US-ASCII,"); - background-position: right 0.7rem center; - background-size: 0.85rem; - background-repeat: no-repeat; - background-color: transparent; - appearance: none; - cursor: pointer; -} \ No newline at end of file diff --git a/website/src/views/optimiser/OptimiserForm.tsx b/website/src/views/optimiser/OptimiserForm.tsx deleted file mode 100644 index af1362fcd8..0000000000 --- a/website/src/views/optimiser/OptimiserForm.tsx +++ /dev/null @@ -1,392 +0,0 @@ -import React, { useCallback } from 'react'; -import classnames from 'classnames'; -import { Info, X, AlertTriangle } from 'react-feather'; -import Tooltip from 'views/components/Tooltip'; -import { WorkingDays, Day } from 'types/modules'; -import { LessonOption, FreeDayConflict } from './types'; -import styles from './OptimiserForm.scss'; - -interface OptimiserFormProps { - lessonOptions: LessonOption[]; - selectedLessons: LessonOption[]; - selectedFreeDays: Set; - earliestTime: string; - latestTime: string; - earliestLunchTime: string; - latestLunchTime: string; - freeDayConflicts: FreeDayConflict[]; - hasSaturday: boolean; - maxConsecutiveHours: number; - onToggleLessonSelection: (option: LessonOption) => void; - onToggleFreeDay: (day: string) => void; - onEarliestTimeChange: (time: string) => void; - onLatestTimeChange: (time: string) => void; - onEarliestLunchTimeChange: (time: string) => void; - onLatestLunchTimeChange: (time: string) => void; - onMaxConsecutiveHoursChange: (hours: number) => void; -} - -const OptimiserForm: React.FC = ({ - lessonOptions, - selectedLessons, - selectedFreeDays, - earliestTime, - latestTime, - earliestLunchTime, - latestLunchTime, - freeDayConflicts, - hasSaturday, - maxConsecutiveHours, - onToggleLessonSelection, - onToggleFreeDay, - onEarliestTimeChange, - onLatestTimeChange, - onEarliestLunchTimeChange, - onLatestLunchTimeChange, - onMaxConsecutiveHoursChange, -}) => { - const toggleLessonSelection = useCallback( - (option: LessonOption) => { - onToggleLessonSelection(option); - }, - [onToggleLessonSelection], - ); - - const toggleFreeDay = useCallback( - (day: string) => { - onToggleFreeDay(day); - }, - [onToggleFreeDay], - ); - - return ( -
-
-
- Select lessons you plan to attend live (in person, online, or other format) - - - -
-
- - {/* Lesson Selection Buttons */} -
- {lessonOptions.length === 0 && ( -
-
- - No Lessons Found -
-
- Add modules to your timetable to see lesson options here -
-
- )} - {lessonOptions.map((option) => { - const isSelected = selectedLessons.some( - (lesson) => lesson.uniqueKey === option.uniqueKey, - ); - return ( - - ); - })} -
- -
- Select days you would like to be free - - - -
-
- - - - - - {hasSaturday && ( - - )} -
- - {/* Free Day Conflicts Display */} - {freeDayConflicts.length > 0 && ( -
-
- - Free Day Conflicts -
-
- The following lessons require physical attendance on your selected free days: -
- {freeDayConflicts.map((conflict, index) => ( -
- • {conflict.displayText} cannot be assigned due to your free days:{' '} - {conflict.conflictingDays - .filter((d): d is Day => WorkingDays.includes(d as Day)) - .sort((a, b) => WorkingDays.indexOf(a as Day) - WorkingDays.indexOf(b as Day)) - .join(', ')} -
- ))} -
- Consider disabling live attendance for these lessons or selecting different free days. -
-
- )} - -
-
-
-
- Earliest start time - - - -
-
- -
-
-
-
- Latest end time - - - -
-
- -
-
-
-
- -
- Following preferences will be prioritised{' '} - but not guaranteed : -
- -
-
-
-
- Select maximum consecutive hours of live lessons - - - -
-
- -
-
-
-
-
- Select range for preferred lunch break timings - - - -
-
- -
to
- -
-
-
-
- ); -}; - -export default OptimiserForm; diff --git a/website/src/views/optimiser/OptimiserForm/OptimiserForm.scss b/website/src/views/optimiser/OptimiserForm/OptimiserForm.scss new file mode 100644 index 0000000000..58be2b301d --- /dev/null +++ b/website/src/views/optimiser/OptimiserForm/OptimiserForm.scss @@ -0,0 +1,51 @@ +@import '~styles/utils/modules-entry.scss'; +@import '../common.scss'; + +.optimiserForm { + display: flex; + flex-direction: column; + margin-top: $form-fields-gap; +} + +.priorityNotice { + padding-top: 1rem; + margin-top: 2rem; + border-top: 1px solid var(--gray-lighter); + font-size: 1.1rem; + + .prioritised { + opacity: 0.85; + font-weight: bold; + color: #ff5138; + } + + .notGuaranteed { + font-weight: 600; + text-decoration: underline; + text-decoration-color: #ff5138; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } +} + +.optimiserDescription { + margin: 0; + margin-bottom: 0.5rem; + font-size: medium; + + @include media-breakpoint-down(xs) { + font-size: small; + } +} + +.optimiserDropdown { + composes: form-control from global; + padding: 0.5rem; + border: 1px solid var(--gray-lighter); + border-radius: 0.25rem; + color: inherit; + + @include night-mode { + background-color: var(--gray-lightest); + } +} diff --git a/website/src/views/optimiser/OptimiserForm/OptimiserForm.tsx b/website/src/views/optimiser/OptimiserForm/OptimiserForm.tsx new file mode 100644 index 0000000000..8643d65506 --- /dev/null +++ b/website/src/views/optimiser/OptimiserForm/OptimiserForm.tsx @@ -0,0 +1,49 @@ +import { FreeDayConflict, LessonOption } from 'types/optimiser'; +import { OptimiserFormFields } from 'views/hooks/useOptimiserForm'; +import styles from './OptimiserForm.scss'; +import OptimiserLessonOptionSelect from './OptimiserLessonOptionSelect'; +import OptimiserFreeDaySelect from './OptimiserFreeDaySelect'; +import OptimiserFreeDayConflicts from './OptimiserFreeDayConflicts'; +import { + OptimiserLessonTimeRangeSelect, + OptimiserLunchTimeRangeSelect, +} from './OptimiserTimeRangeSelect'; +import OptimiserMaxConsecutiveHoursSelect from './OptimiserMaxConsecutiveHoursSelect'; + +interface OptimiserFormProps { + lessonOptions: LessonOption[]; + freeDayConflicts: FreeDayConflict[]; + hasSaturday: boolean; + optimiserFormFields: OptimiserFormFields; +} + +const OptimiserFormComponent: React.FC = ({ + lessonOptions, + freeDayConflicts, + hasSaturday, + optimiserFormFields, +}) => ( +
+ + + + + + + + +
+ Following preferences will be prioritised but{' '} + not guaranteed : +
+ + + + + +); + +export default OptimiserFormComponent; diff --git a/website/src/views/optimiser/OptimiserForm/OptimiserFormTooltip.scss b/website/src/views/optimiser/OptimiserForm/OptimiserFormTooltip.scss new file mode 100644 index 0000000000..f9506ec9a7 --- /dev/null +++ b/website/src/views/optimiser/OptimiserForm/OptimiserFormTooltip.scss @@ -0,0 +1,13 @@ +@import '~styles/utils/modules-entry.scss'; + +.optimiserTooltipIcon { + composes: svg svg-small from global; + margin: auto; + margin-left: 1ch; + color: #69707a; + cursor: pointer; + + @include media-breakpoint-down(xs) { + width: 0.8rem; + } +} diff --git a/website/src/views/optimiser/OptimiserForm/OptimiserFormTooltip.tsx b/website/src/views/optimiser/OptimiserForm/OptimiserFormTooltip.tsx new file mode 100644 index 0000000000..f4b8b9e38d --- /dev/null +++ b/website/src/views/optimiser/OptimiserForm/OptimiserFormTooltip.tsx @@ -0,0 +1,16 @@ +import { Info } from 'react-feather'; +import Tooltip from 'views/components/Tooltip/Tooltip'; + +import styles from './OptimiserFormTooltip.scss'; + +type Props = { + content: string; +}; + +const OptimiserFormTooltip: React.FC = ({ content }) => ( + + + +); + +export default OptimiserFormTooltip; diff --git a/website/src/views/optimiser/OptimiserForm/OptimiserFreeDayConflicts.scss b/website/src/views/optimiser/OptimiserForm/OptimiserFreeDayConflicts.scss new file mode 100644 index 0000000000..181f75940d --- /dev/null +++ b/website/src/views/optimiser/OptimiserForm/OptimiserFreeDayConflicts.scss @@ -0,0 +1,44 @@ +@import '~styles/utils/modules-entry.scss'; +@import './OptimiserForm.scss'; + +.conflictWarning { + composes: alert alert-warning from global; + display: flex; + flex-direction: column; + padding: 1.5rem; + margin-top: $form-fields-gap; + gap: 0.5rem; + + h3 { + display: flex; + align-items: center; + margin: 0; + font-weight: bold; + font-size: 1rem; + color: #ff5138; + gap: 0.5rem; + } + + h4 { + margin: 0; + font-size: 0.9rem; + } + + ul { + margin: 0; + + li { + margin: 0; + font-weight: 500; + font-size: 0.9rem; + color: #ff5138; + } + } + + h5 { + margin: 0.5rem 0; + font-style: italic; + font-size: 0.8rem; + color: #69707a; + } +} diff --git a/website/src/views/optimiser/OptimiserForm/OptimiserFreeDayConflicts.test.tsx b/website/src/views/optimiser/OptimiserForm/OptimiserFreeDayConflicts.test.tsx new file mode 100644 index 0000000000..664b19c601 --- /dev/null +++ b/website/src/views/optimiser/OptimiserForm/OptimiserFreeDayConflicts.test.tsx @@ -0,0 +1,24 @@ +import { render } from '@testing-library/react'; +import { defaultTutorialOption } from 'test-utils/optimiser'; +import OptimiserFreeDayConflicts from './OptimiserFreeDayConflicts'; + +describe('OptimiserFreeDayConflicts', () => { + it('should show a warning when there are conflicts', () => { + const freeDayConflicts = [ + { + moduleCode: defaultTutorialOption.moduleCode, + lessonType: defaultTutorialOption.lessonType, + displayText: defaultTutorialOption.displayText, + days: ['Monday', 'Tuesday'], + }, + ]; + const { container } = render(); + expect(container).toHaveTextContent('Free Day Conflicts'); + expect(container).toHaveTextContent(defaultTutorialOption.displayText); + }); + + it('should not render when there are no conflicts', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/website/src/views/optimiser/OptimiserForm/OptimiserFreeDayConflicts.tsx b/website/src/views/optimiser/OptimiserForm/OptimiserFreeDayConflicts.tsx new file mode 100644 index 0000000000..b869d6cffe --- /dev/null +++ b/website/src/views/optimiser/OptimiserForm/OptimiserFreeDayConflicts.tsx @@ -0,0 +1,36 @@ +import { isEmpty } from 'lodash'; +import { FreeDayConflict } from 'types/optimiser'; + +import { X } from 'react-feather'; +import styles from './OptimiserFreeDayConflicts.scss'; + +type Props = { + freeDayConflicts: FreeDayConflict[]; +}; + +const OptimiserFreeDayConflicts: React.FC = ({ freeDayConflicts }) => + !isEmpty(freeDayConflicts) && ( +
+

+ + Free Day Conflicts +

+ +

The following lesson(s) require physical attendance on your selected free days:

+ +
    + {freeDayConflicts.map((conflict, index) => ( +
  • + {conflict.displayText} cannot be assigned due to your free days:{' '} + {conflict.days.join(', ')} +
  • + ))} +
+ +
+ Consider disabling live attendance for these lessons or selecting different free days. +
+
+ ); + +export default OptimiserFreeDayConflicts; diff --git a/website/src/views/optimiser/OptimiserForm/OptimiserFreeDaySelect.scss b/website/src/views/optimiser/OptimiserForm/OptimiserFreeDaySelect.scss new file mode 100644 index 0000000000..a373b1f09b --- /dev/null +++ b/website/src/views/optimiser/OptimiserForm/OptimiserFreeDaySelect.scss @@ -0,0 +1,22 @@ +@import '~styles/utils/modules-entry.scss'; +@import './OptimiserForm.scss'; + +.freeDaysSection { + display: flex; + flex-direction: column; + margin-top: $form-fields-gap; +} + +.freeDaysButtons { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 0.5rem; +} + +.freeDaysButton { + composes: btn btn-link btn-svg from global; + display: flex; + flex-direction: row; + gap: 0.3rem; +} diff --git a/website/src/views/optimiser/OptimiserForm/OptimiserFreeDaySelect.test.tsx b/website/src/views/optimiser/OptimiserForm/OptimiserFreeDaySelect.test.tsx new file mode 100644 index 0000000000..82161d6a1f --- /dev/null +++ b/website/src/views/optimiser/OptimiserForm/OptimiserFreeDaySelect.test.tsx @@ -0,0 +1,54 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import useOptimiserForm from 'views/hooks/useOptimiserForm'; +import OptimiserFreeDaySelect from './OptimiserFreeDaySelect'; + +jest.mock('./OptimiserFormTooltip', () => ({ + __esModule: true, + default: () =>
, +})); + +describe('OptimiserLessonOptionSelect', () => { + type Props = { + hasSaturday: boolean; + }; + + const Helper: React.FC = ({ hasSaturday }) => { + const optimiserFormFields = useOptimiserForm(); + return ( + + ); + }; + + it('should not show saturday', () => { + const { container } = render(); + expect(container).toHaveTextContent('Monday'); + expect(container).toHaveTextContent('Tuesday'); + expect(container).toHaveTextContent('Wednesday'); + expect(container).toHaveTextContent('Thursday'); + expect(container).toHaveTextContent('Friday'); + expect(container).not.toHaveTextContent('Saturday'); + }); + + it('should show saturday', () => { + const { container } = render(); + expect(container).toHaveTextContent('Monday'); + expect(container).toHaveTextContent('Tuesday'); + expect(container).toHaveTextContent('Wednesday'); + expect(container).toHaveTextContent('Thursday'); + expect(container).toHaveTextContent('Friday'); + expect(container).toHaveTextContent('Saturday'); + }); + + it('should toggle the selected day', async () => { + render(); + const monday = screen.getByText('Monday'); + expect(monday).not.toHaveClass('active'); + + await userEvent.click(screen.getByText('Monday')); + expect(screen.getByText('Monday')).toHaveClass('active'); + + await userEvent.click(screen.getByText('Monday')); + expect(screen.getByText('Monday')).not.toHaveClass('active'); + }); +}); diff --git a/website/src/views/optimiser/OptimiserForm/OptimiserFreeDaySelect.tsx b/website/src/views/optimiser/OptimiserForm/OptimiserFreeDaySelect.tsx new file mode 100644 index 0000000000..ff164261aa --- /dev/null +++ b/website/src/views/optimiser/OptimiserForm/OptimiserFreeDaySelect.tsx @@ -0,0 +1,64 @@ +import classNames from 'classnames'; +import { dropRight } from 'lodash'; +import { useCallback } from 'react'; +import { DayText, WorkingDays } from 'types/modules'; +import { OptimiserFormFields } from 'views/hooks/useOptimiserForm'; + +import { CheckSquare, Square } from 'react-feather'; +import styles from './OptimiserFreeDaySelect.scss'; +import OptimiserFormTooltip from './OptimiserFormTooltip'; + +type Props = { + hasSaturday: boolean; + optimiserFormFields: OptimiserFormFields; +}; + +const OptimiserFreeDaySelect: React.FC = ({ hasSaturday, optimiserFormFields }) => { + const { freeDays, setFreeDays } = optimiserFormFields; + const days = hasSaturday ? [...WorkingDays] : dropRight([...WorkingDays], 1); + + const toggleDay = useCallback( + (day: DayText) => { + setFreeDays((prev) => { + const isSelected = prev.has(day); + return new Set( + isSelected ? [...prev].filter((existing) => existing !== day) : [...prev, day], + ); + }); + }, + [setFreeDays], + ); + + return ( +
+

+ Select days you would like to be free + +

+ +
+ {days.map((day) => { + const checked = freeDays.has(day); + + // TODO: consider using checkbox instead if redesigning + // https://getbootstrap.com/docs/4.6/components/forms/#custom-forms + return ( + + ); + })} +
+
+ ); +}; + +export default OptimiserFreeDaySelect; diff --git a/website/src/views/optimiser/OptimiserForm/OptimiserLessonOptionSelect.scss b/website/src/views/optimiser/OptimiserForm/OptimiserLessonOptionSelect.scss new file mode 100644 index 0000000000..cb1ac6fbb8 --- /dev/null +++ b/website/src/views/optimiser/OptimiserForm/OptimiserLessonOptionSelect.scss @@ -0,0 +1,56 @@ +@import '~styles/utils/modules-entry.scss'; +@import './OptimiserForm.scss'; + +.lessonButtons { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.lessonButton { + composes: lessonTag from '../common.scss'; + + &.selected { + opacity: 1; + filter: brightness(1); + transform: scale(1); + } + + &.unselected { + opacity: 0.6; + filter: brightness(0.8); + transform: scale(0.98); + } + + &:hover { + opacity: 1 !important; + filter: brightness(1.1) !important; + transform: scale(1.02) !important; + } +} + +.noLessonsWarning { + composes: alert alert-warning from global; + display: flex; + flex-direction: column; + padding: 1.5rem; + margin-top: $form-fields-gap; + gap: 1rem; + + h3 { + display: flex; + align-items: center; + margin: 0; + font-weight: bold; + font-size: 1.1rem; + color: #ff8c00; + gap: 0.75rem; + } + + h4 { + display: flex; + margin: 0; + font-size: 0.95rem; + line-height: 1.5; + } +} diff --git a/website/src/views/optimiser/OptimiserForm/OptimiserLessonOptionSelect.test.tsx b/website/src/views/optimiser/OptimiserForm/OptimiserLessonOptionSelect.test.tsx new file mode 100644 index 0000000000..7e3fe0824c --- /dev/null +++ b/website/src/views/optimiser/OptimiserForm/OptimiserLessonOptionSelect.test.tsx @@ -0,0 +1,59 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { defaultLectureOption, defaultTutorialOption } from 'test-utils/optimiser'; +import { LessonOption } from 'types/optimiser'; +import useOptimiserForm from 'views/hooks/useOptimiserForm'; +import OptimiserLessonOptionSelect from './OptimiserLessonOptionSelect'; + +import styles from './OptimiserLessonOptionSelect.scss'; + +jest.mock('./OptimiserFormTooltip', () => ({ + __esModule: true, + default: () =>
, +})); + +describe('OptimiserLessonOptionSelect', () => { + type Props = { + lessonOptions: LessonOption[]; + }; + + const Helper: React.FC = ({ lessonOptions }) => { + const optimiserFormFields = useOptimiserForm(); + return ( + + ); + }; + + it('should show a warning when there are no lesson options', () => { + render(); + expect(screen.getByRole('alert')).toHaveTextContent('No Lessons Found'); + }); + + it('should show all lesson options', () => { + const lessonOptions = [defaultLectureOption, defaultTutorialOption]; + const { container } = render(); + expect(container).not.toHaveTextContent('No Lessons Found'); + expect(container).toHaveTextContent(defaultLectureOption.displayText); + expect(container).toHaveTextContent(defaultTutorialOption.displayText); + }); + + it('should toggle lesson option', async () => { + const lessonOptions = [defaultLectureOption]; + render(); + + const lectureButton = screen.getByRole('button'); + expect(lectureButton).not.toHaveClass(styles.selected); + expect(lectureButton).toHaveClass(styles.unselected); + + await userEvent.click(lectureButton); + expect(lectureButton).toHaveClass(styles.selected); + expect(lectureButton).not.toHaveClass(styles.unselected); + + await userEvent.click(lectureButton); + expect(lectureButton).not.toHaveClass(styles.selected); + expect(lectureButton).toHaveClass(styles.unselected); + }); +}); diff --git a/website/src/views/optimiser/OptimiserForm/OptimiserLessonOptionSelect.tsx b/website/src/views/optimiser/OptimiserForm/OptimiserLessonOptionSelect.tsx new file mode 100644 index 0000000000..b9c6fbf05e --- /dev/null +++ b/website/src/views/optimiser/OptimiserForm/OptimiserLessonOptionSelect.tsx @@ -0,0 +1,77 @@ +import { isEmpty } from 'lodash'; +import { LessonOption } from 'types/optimiser'; + +import { AlertTriangle } from 'react-feather'; +import { OptimiserFormFields } from 'views/hooks/useOptimiserForm'; +import { useCallback } from 'react'; +import classNames from 'classnames'; +import styles from './OptimiserLessonOptionSelect.scss'; +import OptimiserFormTooltip from './OptimiserFormTooltip'; + +type Props = { + lessonOptions: LessonOption[]; + optimiserFormFields: OptimiserFormFields; +}; + +const OptimiserLessonOptionSelect: React.FC = ({ lessonOptions, optimiserFormFields }) => { + const { liveLessonOptions, setLiveLessonOptions } = optimiserFormFields; + + const toggleLiveLessonOption = useCallback( + (target: LessonOption) => { + const isSelected = liveLessonOptions.some( + (selected) => selected.lessonKey === target.lessonKey, + ); + setLiveLessonOptions((prev) => + isSelected + ? prev.filter((option) => option.lessonKey !== target.lessonKey) + : [...prev, target], + ); + }, + [liveLessonOptions, setLiveLessonOptions], + ); + + return ( +
+

+ Select lessons you plan to attend live (in person/online) + +

+ + {isEmpty(lessonOptions) ? ( +
+

+ + No Lessons Found +

+

Add modules to your timetable to see lesson options here

+
+ ) : ( +
+ {lessonOptions.map((option) => { + const isSelected = liveLessonOptions.some( + (lessonOption) => lessonOption.lessonKey === option.lessonKey, + ); + const className = classNames( + `color-${option.colorIndex}`, + styles.lessonButton, + isSelected ? styles.selected : styles.unselected, + ); + + return ( + + ); + })} +
+ )} +
+ ); +}; + +export default OptimiserLessonOptionSelect; diff --git a/website/src/views/optimiser/OptimiserForm/OptimiserMaxConsecutiveHoursSelect.scss b/website/src/views/optimiser/OptimiserForm/OptimiserMaxConsecutiveHoursSelect.scss new file mode 100644 index 0000000000..64d893a08d --- /dev/null +++ b/website/src/views/optimiser/OptimiserForm/OptimiserMaxConsecutiveHoursSelect.scss @@ -0,0 +1,19 @@ +@import '~styles/utils/modules-entry.scss'; +@import './OptimiserForm.scss'; + +.maxConsecutiveHours { + display: flex; + flex-direction: column; + justify-content: flex-start; + width: fit-content; + margin-top: $form-fields-gap; +} + +.maxConsecutiveHoursHeader { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + font-size: 1rem; + gap: 0.5rem; +} diff --git a/website/src/views/optimiser/OptimiserForm/OptimiserMaxConsecutiveHoursSelect.tsx b/website/src/views/optimiser/OptimiserForm/OptimiserMaxConsecutiveHoursSelect.tsx new file mode 100644 index 0000000000..935f99092d --- /dev/null +++ b/website/src/views/optimiser/OptimiserForm/OptimiserMaxConsecutiveHoursSelect.tsx @@ -0,0 +1,38 @@ +import { OptimiserFormFields } from 'views/hooks/useOptimiserForm'; +import { range } from 'lodash'; + +import styles from './OptimiserMaxConsecutiveHoursSelect.scss'; +import OptimiserFormTooltip from './OptimiserFormTooltip'; + +type Props = { + optimiserFormFields: OptimiserFormFields; +}; + +const OptimiserMaxConsecutiveHoursSelect: React.FC = ({ optimiserFormFields }) => { + const { maxConsecutiveHours, setMaxConsecutiveHours } = optimiserFormFields; + + const values = range(1, 7); + + return ( +
+

+ Maximum consecutive hours of live lessons + +

+ + +
+ ); +}; + +export default OptimiserMaxConsecutiveHoursSelect; diff --git a/website/src/views/optimiser/OptimiserForm/OptimiserTimeRangeSelect.scss b/website/src/views/optimiser/OptimiserForm/OptimiserTimeRangeSelect.scss new file mode 100644 index 0000000000..5d20179ecb --- /dev/null +++ b/website/src/views/optimiser/OptimiserForm/OptimiserTimeRangeSelect.scss @@ -0,0 +1,31 @@ +@import '~styles/utils/modules-entry.scss'; +@import './OptimiserForm.scss'; + +.timeControls { + display: flex; + flex-direction: row; + margin-top: $form-fields-gap; + gap: 3rem; + + .timeColumn { + display: flex; + flex-direction: column; + justify-content: flex-start; + } + + .timeRow { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 1rem; + + .optimiserDropdown { + flex-grow: 1; + } + } + + @include media-breakpoint-down(xs) { + gap: 2rem; + } +} diff --git a/website/src/views/optimiser/OptimiserForm/OptimiserTimeRangeSelect.test.tsx b/website/src/views/optimiser/OptimiserForm/OptimiserTimeRangeSelect.test.tsx new file mode 100644 index 0000000000..76ce9e1d8b --- /dev/null +++ b/website/src/views/optimiser/OptimiserForm/OptimiserTimeRangeSelect.test.tsx @@ -0,0 +1,81 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import useOptimiserForm from 'views/hooks/useOptimiserForm'; +import { + OptimiserLessonTimeRangeSelect, + OptimiserLunchTimeRangeSelect, + OptimiserTimeRangeSelect, + TimeRangeSelectProps, +} from './OptimiserTimeRangeSelect'; + +jest.mock('./OptimiserFormTooltip', () => ({ + __esModule: true, + default: () =>
, +})); + +const SELECT_LABEL_TEXT = 'Choose a time from the given range'; + +describe('OptimiserTimeRangeSelect', () => { + it('should call setTime when valuue is changed', async () => { + const setTime = jest.fn(); + const props: TimeRangeSelectProps = { + id: 'test', + currentValue: '0800', + timeValues: ['0800', '0830', '0900'], + setTime, + }; + render(); + const select = screen.getByLabelText(SELECT_LABEL_TEXT); + expect(select).toHaveValue('0800'); + expect(select).toHaveDisplayValue('08:00'); + + await userEvent.selectOptions(select, '08:30'); + expect(setTime).toHaveBeenCalledWith('0830'); + }); +}); + +describe('OptimiserLessonTimeRangeSelect', () => { + const Helper: React.FC = () => { + const optimiserFormFields = useOptimiserForm(); + return ; + }; + + it('should update the lesson time range', async () => { + render(); + const selects = screen.getAllByLabelText(SELECT_LABEL_TEXT); + expect(selects).toHaveLength(2); + expect(selects.at(0)).toHaveValue('0800'); + expect(selects.at(1)).toHaveValue('1900'); + + await userEvent.selectOptions(selects.at(0)!, '0830'); + expect(selects.at(0)).toHaveValue('0830'); + expect(selects.at(1)).toHaveValue('1900'); + + await userEvent.selectOptions(selects.at(1)!, '1200'); + expect(selects.at(0)).toHaveValue('0830'); + expect(selects.at(1)).toHaveValue('1200'); + }); +}); + +describe('OptimiserLunchTimeRangeSelect', () => { + const Helper: React.FC = () => { + const optimiserFormFields = useOptimiserForm(); + return ; + }; + + it('should update the lunch time range', async () => { + render(); + const selects = screen.getAllByLabelText(SELECT_LABEL_TEXT); + expect(selects).toHaveLength(2); + expect(selects.at(0)).toHaveValue('1200'); + expect(selects.at(1)).toHaveValue('1400'); + + await userEvent.selectOptions(selects.at(0)!, '1100'); + expect(selects.at(0)).toHaveValue('1100'); + expect(selects.at(1)).toHaveValue('1400'); + + await userEvent.selectOptions(selects.at(1)!, '1700'); + expect(selects.at(0)).toHaveValue('1100'); + expect(selects.at(1)).toHaveValue('1700'); + }); +}); diff --git a/website/src/views/optimiser/OptimiserForm/OptimiserTimeRangeSelect.tsx b/website/src/views/optimiser/OptimiserForm/OptimiserTimeRangeSelect.tsx new file mode 100644 index 0000000000..4d76337b19 --- /dev/null +++ b/website/src/views/optimiser/OptimiserForm/OptimiserTimeRangeSelect.tsx @@ -0,0 +1,169 @@ +import { useCallback } from 'react'; +import { getOptimiserTime, getTimeValues } from 'utils/optimiser'; +import { LessonTime } from 'types/modules'; +import { OptimiserFormFields } from 'views/hooks/useOptimiserForm'; +import OptimiserFormTooltip from './OptimiserFormTooltip'; + +import styles from './OptimiserTimeRangeSelect.scss'; + +type TimeRangeSelectProps = { + id: string; + currentValue: LessonTime; + timeValues: LessonTime[]; + setTime: (lessonTime: LessonTime) => void; +}; + +const OptimiserTimeRangeSelect: React.FC = ({ + id, + currentValue, + timeValues, + setTime, +}) => ( + <> + + + +); + +type LessonTimeRangeSelectProps = { + optimiserFormFields: OptimiserFormFields; +}; + +const OptimiserLessonTimeRangeSelect: React.FC = ({ + optimiserFormFields, +}) => { + const { lessonTimeRange, setLessonTimeRange } = optimiserFormFields; + + const earliestTimeValues = getTimeValues({ + earliest: '0800', + latest: '2200', + }); + const setEarliestTime = useCallback( + (lessonTime: LessonTime) => { + setLessonTimeRange((prev) => ({ ...prev, earliest: lessonTime })); + }, + [setLessonTimeRange], + ); + + const latestTimeValues = getTimeValues({ + earliest: '0900', + latest: '2300', + }); + const setLatestTime = useCallback( + (lessonTime: LessonTime) => { + setLessonTimeRange((prev) => ({ ...prev, latest: lessonTime })); + }, + [setLessonTimeRange], + ); + + return ( +
+
+

+ Earliest start time + +

+ + +
+ +
+

+ Latest end time + +

+ + +
+
+ ); +}; + +type LunchTimeRangeSelectProps = { + optimiserFormFields: OptimiserFormFields; +}; + +const OptimiserLunchTimeRangeSelect: React.FC = ({ + optimiserFormFields, +}) => { + const { lunchTimeRange, setLunchTimeRange } = optimiserFormFields; + + const earliestTimeValues = getTimeValues({ + earliest: '1000', + latest: '1630', + }); + const setEarliestTime = useCallback( + (lessonTime: LessonTime) => { + setLunchTimeRange((prev) => ({ ...prev, earliest: lessonTime })); + }, + [setLunchTimeRange], + ); + + const latestTimeValues = getTimeValues({ + earliest: '1100', + latest: '1730', + }); + const setLatestTime = useCallback( + (lessonTime: LessonTime) => { + setLunchTimeRange((prev) => ({ ...prev, latest: lessonTime })); + }, + [setLunchTimeRange], + ); + + return ( +
+
+

+ Preferred lunch break timing range + +

+ +
+ + to + +
+
+
+ ); +}; + +export { + TimeRangeSelectProps, + OptimiserTimeRangeSelect, + OptimiserLessonTimeRangeSelect, + OptimiserLunchTimeRangeSelect, +}; diff --git a/website/src/views/optimiser/OptimiserHeader.scss b/website/src/views/optimiser/OptimiserHeader.scss index 4822b5cd6b..c8785881b7 100644 --- a/website/src/views/optimiser/OptimiserHeader.scss +++ b/website/src/views/optimiser/OptimiserHeader.scss @@ -37,20 +37,14 @@ } } -// Description section .description { display: flex; flex-direction: column; margin-top: 0.5rem; - font-size: 0.8rem; + font-size: 1rem; gap: 0.1rem; } -.descriptionText { - max-width: 32rem; -} - -// Icon styling .titleIcon { color: #ff5138; } \ No newline at end of file diff --git a/website/src/views/optimiser/OptimiserHeader.tsx b/website/src/views/optimiser/OptimiserHeader.tsx index 029d4b0e66..74f6598667 100644 --- a/website/src/views/optimiser/OptimiserHeader.tsx +++ b/website/src/views/optimiser/OptimiserHeader.tsx @@ -24,10 +24,11 @@ const OptimiserHeader: React.FC = () => { Beta - Leave Feedback
+
- Intelligently explores millions of combinations to generate your optimal timetable - tailored to your preferences + Explore thousands of combinations to generate a timetable that is tailored to your + preferences.
diff --git a/website/src/views/optimiser/OptimiserResults.scss b/website/src/views/optimiser/OptimiserResults.scss index 2c7fa0cdc7..ba5a80a65e 100644 --- a/website/src/views/optimiser/OptimiserResults.scss +++ b/website/src/views/optimiser/OptimiserResults.scss @@ -1,34 +1,12 @@ @import '~styles/utils/modules-entry.scss'; +@import './common.scss'; -.lessonTag { - display: inline-flex; - align-items: center; - padding: 0.25rem 0.5rem; - border-bottom: 3px solid; - border-radius: 0.25rem; - font-weight: 500; - font-size: 0.8rem; - transition: transform 0.15s ease, filter 0.15s ease; - gap: 0.25rem; - - &:hover { - filter: brightness(0.8); - } -} - -.tag { - cursor: pointer; -} - -// Unassigned lessons warning .unassignedWarning { + composes: alert alert-warning from global; display: flex; flex-direction: column; padding: 1.5rem; margin-top: 2rem; - border: 1px solid rgba(255, 193, 7, 0.3); - border-radius: 0.75rem; - background-color: rgba(255, 193, 7, 0.1); gap: 1rem; } @@ -97,7 +75,6 @@ color: #69707a; } -// Shareable link section .shareableLinkSection { display: flex; flex-direction: column; diff --git a/website/src/views/optimiser/OptimiserResults.test.tsx b/website/src/views/optimiser/OptimiserResults.test.tsx new file mode 100644 index 0000000000..2a3c626265 --- /dev/null +++ b/website/src/views/optimiser/OptimiserResults.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from '@testing-library/react'; +import { defaultLectureOption } from 'test-utils/optimiser'; +import OptimiserResults, { OptimiserResultsProps } from './OptimiserResults'; + +const shareableLink = 'https://nusmods.com/timetable/sem-1/share?CS1231S=TUT:01A,LEC:1'; + +describe('OptimiserResults', () => { + beforeEach(() => { + // Provide a dummy implementation to silence the error + Element.prototype.scrollIntoView = jest.fn(); + }); + + it('should render when there is a shareable link', () => { + const props: OptimiserResultsProps = { + shareableLink, + unassignedLessons: [], + }; + const { container } = render(); + expect(container).not.toBeEmptyDOMElement(); + }); + + it('should not render when there is no shareable link', () => { + const props: OptimiserResultsProps = { + shareableLink: '', + unassignedLessons: [], + }; + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('should show full timetable when there are no unassigned lessons', () => { + const props: OptimiserResultsProps = { + shareableLink, + unassignedLessons: [], + }; + render(); + expect(screen.getByRole('link')).toHaveTextContent('Open Optimised Timetable'); + }); + + it('should show partial timetable when there are unassigned lessons', () => { + const props: OptimiserResultsProps = { + shareableLink, + unassignedLessons: [defaultLectureOption], + }; + render(); + expect(screen.getByRole('link')).toHaveTextContent('Open Partial Timetable'); + }); +}); diff --git a/website/src/views/optimiser/OptimiserResults.tsx b/website/src/views/optimiser/OptimiserResults.tsx index 4eb2f3e59b..e58db0804b 100644 --- a/website/src/views/optimiser/OptimiserResults.tsx +++ b/website/src/views/optimiser/OptimiserResults.tsx @@ -1,33 +1,26 @@ import React, { useEffect, useRef } from 'react'; import classnames from 'classnames'; import { AlertTriangle, Zap, ExternalLink } from 'react-feather'; -import { LessonOption } from './types'; +import { LessonOption } from 'types/optimiser'; +import { isEmpty } from 'lodash'; import styles from './OptimiserResults.scss'; -interface OptimiserResultsProps { - shareableLink: string; - unAssignedLessons: LessonOption[]; - openOptimisedTimetable: () => void; +export interface OptimiserResultsProps { + shareableLink: string | null; + unassignedLessons: LessonOption[]; } const OptimiserResults: React.FC = ({ shareableLink, - unAssignedLessons, - openOptimisedTimetable, + unassignedLessons, }) => { const optimiserResultsRef = useRef(null); - const scrollToBottom = () => { + useEffect(() => { const element = optimiserResultsRef.current; - if (element) { + if (element && shareableLink) { element.scrollIntoView({ behavior: 'smooth' }); } - }; - - useEffect(() => { - if (shareableLink) { - scrollToBottom(); - } }, [shareableLink]); if (!shareableLink) { @@ -36,88 +29,103 @@ const OptimiserResults: React.FC = ({ return (
- {/* Partially optimised timetable */} - {unAssignedLessons.length > 0 && ( -
-
- - Optimiser Warning : Unassigned Lessons -
- -
- The following lessons couldn't be assigned to your optimised timetable: -
- -
- {unAssignedLessons.map((lesson, index) => ( -
- {lesson.displayText} -
- ))} -
- -
-
Why did this happen?
-
- • Venue constraints: NUSMods may not have complete or accurate venue - data for these lessons -
-
- • Scheduling conflicts: There is no possible way to schedule these - lessons with your selected preferences (free days, time ranges, etc.) -
-
- -
- -
- -
- You may need to manually add these lessons to your timetable or adjust your optimisation - preferences -
-
- )} - - {/* Fully optimised timetable */} - {unAssignedLessons.length === 0 && ( -
-
-
- - Optimisation Complete! -
-
- Your optimised timetable is ready. Click below to view it in a new tab. -
-
- -
+ {isEmpty(unassignedLessons) ? ( + + ) : ( + )}
); }; +interface OptimiserResultsPartialProps { + shareableLink: string; + unassignedLessons: LessonOption[]; +} + +const OptimiserResultPartialTimetable: React.FC = ({ + shareableLink, + unassignedLessons, +}) => ( +
+
+ + Optimiser Warning : Unassigned Lessons +
+ +
+ The following lessons couldn't be assigned to your optimised timetable: +
+ +
+ {unassignedLessons.map((lesson, index) => ( +
+ {lesson.displayText} +
+ ))} +
+ +
+
Why did this happen?
+
    +
  • + Venue constraints: NUSMods may not have complete or accurate venue data + for these lessons +
  • +
  • + Scheduling conflicts: There is no possible way to schedule these lessons + with your selected preferences (free days, time ranges, etc.) +
  • +
+
+ + + +
+ You may need to manually add these lessons to your timetable or adjust your optimisation + preferences +
+
+); + +interface OptimiserResultsFullTimetableProps { + shareableLink: string; +} + +const OptimiserResultsFullTimetable: React.FC = ({ + shareableLink, +}) => ( +
+
+
+ + Optimisation Complete! +
+
+ Your optimised timetable is ready. Click below to view it in a new tab. +
+
+ + + + Open Optimised Timetable + +
+); + export default OptimiserResults; diff --git a/website/src/views/optimiser/common.scss b/website/src/views/optimiser/common.scss new file mode 100644 index 0000000000..a740621173 --- /dev/null +++ b/website/src/views/optimiser/common.scss @@ -0,0 +1,19 @@ +$form-fields-gap: 2rem; + +.lessonTag { + display: inline-flex; + align-items: center; + padding: 0.5rem 0.5rem; + border-width: 0 0 0.2rem; + border-style: solid; + border-radius: 0.25rem; + font-weight: bold; + font-size: 0.8rem; + cursor: pointer; + transition: all 0.15s ease; + gap: 0.25rem; + + &:hover { + filter: brightness(0.8); + } +} diff --git a/website/src/views/optimiser/types.ts b/website/src/views/optimiser/types.ts deleted file mode 100644 index 88f70a42b3..0000000000 --- a/website/src/views/optimiser/types.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { LessonType, ModuleCode } from 'types/modules'; - -export interface LessonOption { - moduleCode: ModuleCode; - lessonType: LessonType; - colorIndex: number; - displayText: string; - uniqueKey: string; -} - -export interface LessonDaysData { - uniqueKey: string; - moduleCode: ModuleCode; - lessonType: LessonType; - displayText: string; - days: Set; -} - -export interface LessonGroupData { - groupName: string; - lessonType: LessonType; - moduleCode: ModuleCode; - displayText: string; - classNo: string; - days: Set; -} - -export interface FreeDayConflict { - moduleCode: ModuleCode; - lessonType: LessonType; - displayText: string; - conflictingDays: string[]; -} diff --git a/website/src/views/routes/Routes.tsx b/website/src/views/routes/Routes.tsx index 24b41657e1..1e4e6a52e9 100644 --- a/website/src/views/routes/Routes.tsx +++ b/website/src/views/routes/Routes.tsx @@ -18,7 +18,7 @@ import TodayContainer from 'views/today/TodayContainer'; import PlannerContainer from 'views/planner/PlannerContainer'; import TetrisContainer from 'views/tetris/TetrisContainer'; import MpeContainer from 'views/mpe/MpeContainer'; -import OptimiserContainer from 'views/optimiser/OptimiserContent'; +import OptimiserContainer from 'views/optimiser/OptimiserContainer'; import ExternalRedirect from './ExternalRedirect'; // IMPORTANT: Remember to update any route changes on the sitemap diff --git a/website/yarn.lock b/website/yarn.lock index e198e423ff..b5eeb33008 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -2024,7 +2024,7 @@ lz-string "^1.5.0" pretty-format "^27.0.2" -"@testing-library/jest-dom@6.6.3": +"@testing-library/jest-dom@^6.6.3": version "6.6.3" resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz#26ba906cf928c0f8172e182c6fe214eb4f9f2bd2" integrity sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA== @@ -2037,10 +2037,10 @@ lodash "^4.17.21" redent "^3.0.0" -"@testing-library/react@16.1.0": - version "16.1.0" - resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-16.1.0.tgz#aa0c61398bac82eaf89776967e97de41ac742d71" - integrity sha512-Q2ToPvg0KsVL0ohND9A3zLJWcOXXcO8IDu3fj11KhNt0UlCWyFyvnCIBkd12tidB2lkiVRG8VFqdhcqhqnAQtg== +"@testing-library/react@^16.3.0": + version "16.3.0" + resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-16.3.0.tgz#3a85bb9bdebf180cd76dba16454e242564d598a6" + integrity sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw== dependencies: "@babel/runtime" "^7.12.5"