diff --git a/website/api/optimiser/_models/models.go b/website/api/optimiser/_models/models.go index 0cebffb776..b6cff72f57 100644 --- a/website/api/optimiser/_models/models.go +++ b/website/api/optimiser/_models/models.go @@ -6,6 +6,11 @@ import ( "strings" ) +type LessonType = string +type ClassNo = string +type LessonIndex = int +type LessonsByLessonTypeByClassNo = map[LessonType]map[ClassNo][]LessonIndex + type OptimiserRequest struct { Modules []string `json:"modules"` // Format: ["CS1010S", "CS2030S"] Recordings []string `json:"recordings"` // Format: ["CS1010S Lecture", "CS2030S Laboratory"] @@ -27,14 +32,14 @@ type TimetableState struct { } type ModuleSlot struct { - ClassNo string `json:"classNo"` + ClassNo ClassNo `json:"classNo"` Day string `json:"day"` EndTime string `json:"endTime"` LessonType string `json:"lessonType"` StartTime string `json:"startTime"` Venue string `json:"venue"` Coordinates Coordinates `json:"coordinates"` - Weeks any `json:"weeks"` + Weeks any `json:"weeks"` // Parsed fields StartMin int // Minutes from 00:00 (e.g., 540 for 09:00) @@ -43,6 +48,7 @@ type ModuleSlot struct { LessonKey string // "MODULE|LessonType" WeeksSet map[int]bool WeeksString string + LessonIndex LessonIndex } // ParseModuleSlotFields parses and populates the parsed fields in ModuleSlot for faster computation diff --git a/website/api/optimiser/_modules/modules.go b/website/api/optimiser/_modules/modules.go index 78fccd17fb..3d4e151913 100644 --- a/website/api/optimiser/_modules/modules.go +++ b/website/api/optimiser/_modules/modules.go @@ -43,6 +43,9 @@ func GetAllModuleSlots(optimiserRequest models.OptimiserRequest) (map[string]map var moduleTimetable []models.ModuleSlot for _, semester := range moduleData.SemesterData { if semester.Semester == optimiserRequest.AcadSem { + for lessonIndex := range semester.Timetable { + semester.Timetable[lessonIndex].LessonIndex = lessonIndex + } moduleTimetable = semester.Timetable break } diff --git a/website/api/optimiser/_solver/nusmods_link.go b/website/api/optimiser/_solver/nusmods_link.go index 12cd326f0f..7534bfe0e4 100644 --- a/website/api/optimiser/_solver/nusmods_link.go +++ b/website/api/optimiser/_solver/nusmods_link.go @@ -9,8 +9,8 @@ import ( ) // Parses the assignments into a map of module codes to lesson types to class numbers -func CreateConfig(assignments map[string]string) map[string]map[string]string { - config := make(map[string]map[string]string) +func CreateConfig(assignments map[string]string, lessonToSlots map[string][][]models.ModuleSlot) map[string]map[string][]models.LessonIndex { + config := make(map[string]map[string][]models.LessonIndex) for lessonKey, classNo := range assignments { // Parse lesson key: "MODULE|LESSONTYPE" @@ -23,30 +23,39 @@ func CreateConfig(assignments map[string]string) map[string]map[string]string { // Initialize module config if not exists if config[moduleCode] == nil { - config[moduleCode] = make(map[string]string) + config[moduleCode] = make(map[string][]models.LessonIndex) } // Add lesson type and class number to config - config[moduleCode][lessonType] = classNo + for _, lessonsWithClassNo := range lessonToSlots[lessonKey] { + if lessonsWithClassNo[0].ClassNo != classNo { + continue + } + + for _, lesson := range lessonsWithClassNo { + config[moduleCode][lessonType] = append(config[moduleCode][lessonType], lesson.LessonIndex) + } + break + } } return config } // Constructs the URL -func SerializeConfig(config map[string]map[string]string) string { +func SerializeConfig(config map[string]map[string][]models.LessonIndex) string { var moduleParams []string for moduleCode, lessons := range config { var lessonParams []string - for lessonType, classNo := range lessons { + for lessonType, lessonIndex := range lessons { // Get abbreviation for lesson type abbrev := constants.LessonTypeAbbrev[strings.ToUpper(lessonType)] - lessonParams = append(lessonParams, fmt.Sprintf("%s:%s", abbrev, classNo)) + lessonParams = append(lessonParams, fmt.Sprintf("%s:%s", abbrev, "("+strings.Trim(strings.Join(strings.Fields(fmt.Sprint(lessonIndex)), ","), "[]"))+")") } if len(lessonParams) > 0 { - moduleParams = append(moduleParams, fmt.Sprintf("%s=%s", moduleCode, strings.Join(lessonParams, ","))) + moduleParams = append(moduleParams, fmt.Sprintf("%s=%s", moduleCode, strings.Join(lessonParams, ";"))) } } @@ -54,8 +63,8 @@ func SerializeConfig(config map[string]map[string]string) string { } // GenerateNUSModsShareableLink creates a shareable NUSMods link from the assignments -func GenerateNUSModsShareableLink(assignments map[string]string, req models.OptimiserRequest) string { - config := CreateConfig(assignments) +func GenerateNUSModsShareableLink(assignments map[string]string, lessonToSlots map[string][][]models.ModuleSlot, req models.OptimiserRequest) string { + config := CreateConfig(assignments, lessonToSlots) serializedConfig := SerializeConfig(config) semesterPath := "" diff --git a/website/api/optimiser/_solver/solver.go b/website/api/optimiser/_solver/solver.go index 7b2ba2aaf3..613d644721 100644 --- a/website/api/optimiser/_solver/solver.go +++ b/website/api/optimiser/_solver/solver.go @@ -54,7 +54,7 @@ func BeamSearch( // iterate over all slot groups for the current lesson for i := 0; i < limit; i++ { group := slotGroups[i] - + // Filters out invalid slots by checking if // DayIndex is not -1 which marks invalid slots when parsing in ParseModuleSlotFields func validGroup := make([]models.ModuleSlot, 0, len(group)) @@ -87,7 +87,7 @@ func BeamSearch( } } - // if no valid partial timetables found then skip to next lesson + // if no valid partial timetables found then skip to next lesson // by keeping the beam to create partial timetables if len(nextBeam) == 0 { continue @@ -394,7 +394,7 @@ func Solve(w http.ResponseWriter, req models.OptimiserRequest) { }) best := BeamSearch(lessons, lessonToSlots, 2500, 100, recordings, req) - shareableLink := GenerateNUSModsShareableLink(best.Assignments, req) + shareableLink := GenerateNUSModsShareableLink(best.Assignments, lessonToSlots, req) response := SolveResponse{ TimetableState: best, ShareableLink: shareableLink, diff --git a/website/src/__mocks__/lessons-array.json b/website/src/__mocks__/lessons-array.json index 46fa7bd392..b2fa36c975 100644 --- a/website/src/__mocks__/lessons-array.json +++ b/website/src/__mocks__/lessons-array.json @@ -8,7 +8,8 @@ "venue": "LT26", "moduleCode": "CS1010S", "title": "Programming Methodology", - "colorIndex": 0 + "colorIndex": 0, + "lessonIndex": 0 }, { "classNo": "1", "lessonType": "Recitation", @@ -19,7 +20,8 @@ "venue": "VCRm", "moduleCode": "CS1010S", "title": "Programming Methodology", - "colorIndex": 0 + "colorIndex": 0, + "lessonIndex": 1 }, { "classNo": "1", "lessonType": "Recitation", @@ -30,7 +32,8 @@ "venue": "VCRm", "moduleCode": "CS1010S", "title": "Programming Methodology", - "colorIndex": 0 + "colorIndex": 0, + "lessonIndex": 2 }, { "classNo": "2", "lessonType": "Tutorial", @@ -41,7 +44,8 @@ "venue": "COM1-0203", "moduleCode": "CS1010S", "title": "Programming Methodology", - "colorIndex": 0 + "colorIndex": 0, + "lessonIndex": 3 }, { "classNo": "2", "lessonType": "Tutorial", @@ -52,7 +56,8 @@ "venue": "COM1-0216", "moduleCode": "CS1010S", "title": "Programming Methodology", - "colorIndex": 0 + "colorIndex": 0, + "lessonIndex": 4 }, { "classNo": "1", "lessonType": "Lecture", @@ -63,5 +68,6 @@ "venue": "VCRm", "moduleCode": "CS3216", "title": "Application Development on Evolving Platforms", - "colorIndex": 1 + "colorIndex": 1, + "lessonIndex": 5 }] diff --git a/website/src/__mocks__/modules/ACC2002.json b/website/src/__mocks__/modules/ACC2002.json index 037e951f16..cb2748670d 100644 --- a/website/src/__mocks__/modules/ACC2002.json +++ b/website/src/__mocks__/modules/ACC2002.json @@ -21,7 +21,8 @@ "day": "Tuesday", "startTime": "0900", "endTime": "1200", - "venue": "BIZ1-0301" + "venue": "BIZ1-0301", + "lessonIndex": 0 }, { "classNo": "K10", @@ -30,7 +31,8 @@ "day": "Wednesday", "startTime": "0800", "endTime": "1100", - "venue": "BIZ2-0510" + "venue": "BIZ2-0510", + "lessonIndex": 1 }, { "classNo": "K11", @@ -39,7 +41,8 @@ "day": "Wednesday", "startTime": "1400", "endTime": "1700", - "venue": "BIZ2-0510" + "venue": "BIZ2-0510", + "lessonIndex": 2 }, { "classNo": "K12", @@ -48,7 +51,8 @@ "day": "Thursday", "startTime": "0800", "endTime": "1100", - "venue": "BIZ2-0510" + "venue": "BIZ2-0510", + "lessonIndex": 3 }, { "classNo": "K13", @@ -57,7 +61,8 @@ "day": "Monday", "startTime": "1300", "endTime": "1600", - "venue": "BIZ2-0202" + "venue": "BIZ2-0202", + "lessonIndex": 4 }, { "classNo": "K14", @@ -66,7 +71,8 @@ "day": "Tuesday", "startTime": "1100", "endTime": "1400", - "venue": "BIZ2-0509" + "venue": "BIZ2-0509", + "lessonIndex": 5 }, { "classNo": "K15", @@ -75,7 +81,8 @@ "day": "Thursday", "startTime": "1500", "endTime": "1800", - "venue": "BIZ2-0509" + "venue": "BIZ2-0509", + "lessonIndex": 6 }, { "classNo": "K2", @@ -84,7 +91,8 @@ "day": "Tuesday", "startTime": "1400", "endTime": "1700", - "venue": "BIZ2-0413B" + "venue": "BIZ2-0413B", + "lessonIndex": 7 }, { "classNo": "K3", @@ -93,7 +101,8 @@ "day": "Wednesday", "startTime": "0900", "endTime": "1200", - "venue": "BIZ1-0205" + "venue": "BIZ1-0205", + "lessonIndex": 8 }, { "classNo": "K4", @@ -102,7 +111,8 @@ "day": "Wednesday", "startTime": "1300", "endTime": "1600", - "venue": "BIZ1-0304" + "venue": "BIZ1-0304", + "lessonIndex": 9 }, { "classNo": "K5", @@ -111,7 +121,8 @@ "day": "Monday", "startTime": "0900", "endTime": "1200", - "venue": "BIZ2-0202" + "venue": "BIZ2-0202", + "lessonIndex": 10 }, { "classNo": "K8", @@ -120,7 +131,8 @@ "day": "Wednesday", "startTime": "1200", "endTime": "1500", - "venue": "BIZ1-0201" + "venue": "BIZ1-0201", + "lessonIndex": 11 }, { "classNo": "K9", @@ -129,7 +141,8 @@ "day": "Thursday", "startTime": "1100", "endTime": "1400", - "venue": "BIZ2-0509" + "venue": "BIZ2-0509", + "lessonIndex": 12 } ], "LecturePeriods": [ @@ -154,7 +167,8 @@ "day": "Tuesday", "startTime": "0900", "endTime": "1200", - "venue": "BIZ2-0413B" + "venue": "BIZ2-0413B", + "lessonIndex": 0 }, { "classNo": "B10", @@ -163,7 +177,8 @@ "day": "Wednesday", "startTime": "1300", "endTime": "1600", - "venue": "BIZ1-0203" + "venue": "BIZ1-0203", + "lessonIndex": 1 }, { "classNo": "B11", @@ -172,7 +187,8 @@ "day": "Friday", "startTime": "1400", "endTime": "1700", - "venue": "BIZ2-0413C" + "venue": "BIZ2-0413C", + "lessonIndex": 2 }, { "classNo": "B2", @@ -181,7 +197,8 @@ "day": "Tuesday", "startTime": "1400", "endTime": "1700", - "venue": "BIZ2-0413B" + "venue": "BIZ2-0413B", + "lessonIndex": 3 }, { "classNo": "B3", @@ -190,7 +207,8 @@ "day": "Wednesday", "startTime": "0900", "endTime": "1200", - "venue": "BIZ2-0413B" + "venue": "BIZ2-0413B", + "lessonIndex": 4 }, { "classNo": "B4", @@ -199,7 +217,8 @@ "day": "Thursday", "startTime": "1100", "endTime": "1400", - "venue": "BIZ2-0413A" + "venue": "BIZ2-0413A", + "lessonIndex": 5 }, { "classNo": "B5", @@ -208,7 +227,8 @@ "day": "Thursday", "startTime": "1500", "endTime": "1800", - "venue": "BIZ2-0413A" + "venue": "BIZ2-0413A", + "lessonIndex": 6 }, { "classNo": "B6", @@ -217,7 +237,8 @@ "day": "Wednesday", "startTime": "0800", "endTime": "1100", - "venue": "BIZ1-0303" + "venue": "BIZ1-0303", + "lessonIndex": 7 }, { "classNo": "B7", @@ -226,7 +247,8 @@ "day": "Wednesday", "startTime": "1400", "endTime": "1700", - "venue": "BIZ1-0303" + "venue": "BIZ1-0303", + "lessonIndex": 8 }, { "classNo": "B8", @@ -235,7 +257,8 @@ "day": "Friday", "startTime": "1500", "endTime": "1800", - "venue": "BIZ1-0204" + "venue": "BIZ1-0204", + "lessonIndex": 9 }, { "classNo": "B9", @@ -244,7 +267,8 @@ "day": "Wednesday", "startTime": "0900", "endTime": "1200", - "venue": "BIZ1-0203" + "venue": "BIZ1-0203", + "lessonIndex": 10 } ], "LecturePeriods": [ diff --git a/website/src/__mocks__/modules/BFS1001.json b/website/src/__mocks__/modules/BFS1001.json index e1df4887b0..fe1df77d26 100644 --- a/website/src/__mocks__/modules/BFS1001.json +++ b/website/src/__mocks__/modules/BFS1001.json @@ -17,7 +17,8 @@ "day": "Monday", "startTime": "1400", "endTime": "1700", - "venue": "BIZ1-0303" + "venue": "BIZ1-0303", + "lessonIndex": 0 }, { "classNo": "A2", @@ -26,7 +27,8 @@ "day": "Wednesday", "startTime": "1100", "endTime": "1400", - "venue": "BIZ1-0203" + "venue": "BIZ1-0203", + "lessonIndex": 1 }, { "classNo": "A3", @@ -35,7 +37,8 @@ "day": "Thursday", "startTime": "1100", "endTime": "1400", - "venue": "BIZ1-0203" + "venue": "BIZ1-0203", + "lessonIndex": 2 }, { "classNo": "A4", @@ -44,7 +47,8 @@ "day": "Monday", "startTime": "1400", "endTime": "1700", - "venue": "BIZ1-0303" + "venue": "BIZ1-0303", + "lessonIndex": 3 }, { "classNo": "A5", @@ -53,7 +57,8 @@ "day": "Wednesday", "startTime": "1100", "endTime": "1400", - "venue": "BIZ1-0203" + "venue": "BIZ1-0203", + "lessonIndex": 4 }, { "classNo": "A6", @@ -62,7 +67,8 @@ "day": "Thursday", "startTime": "1100", "endTime": "1400", - "venue": "BIZ1-0203" + "venue": "BIZ1-0203", + "lessonIndex": 5 } ], "LecturePeriods": ["Monday Afternoon", "Wednesday Morning", "Thursday Morning"] @@ -77,7 +83,8 @@ "day": "Tuesday", "startTime": "1100", "endTime": "1400", - "venue": "BIZ1-0203" + "venue": "BIZ1-0203", + "lessonIndex": 0 }, { "classNo": "A2", @@ -86,7 +93,8 @@ "day": "Wednesday", "startTime": "0900", "endTime": "1200", - "venue": "BIZ1-0203" + "venue": "BIZ1-0203", + "lessonIndex": 1 }, { "classNo": "A3", @@ -95,7 +103,8 @@ "day": "Wednesday", "startTime": "1200", "endTime": "1500", - "venue": "BIZ1-0203" + "venue": "BIZ1-0203", + "lessonIndex": 2 }, { "classNo": "A4", @@ -104,7 +113,8 @@ "day": "Tuesday", "startTime": "1100", "endTime": "1400", - "venue": "BIZ1-0203" + "venue": "BIZ1-0203", + "lessonIndex": 3 }, { "classNo": "A5", @@ -113,7 +123,8 @@ "day": "Wednesday", "startTime": "0900", "endTime": "1200", - "venue": "BIZ1-0203" + "venue": "BIZ1-0203", + "lessonIndex": 4 }, { "classNo": "A6", @@ -122,9 +133,10 @@ "day": "Wednesday", "startTime": "1200", "endTime": "1500", - "venue": "BIZ1-0203" + "venue": "BIZ1-0203", + "lessonIndex": 5 } ] } ] -} +} \ No newline at end of file diff --git a/website/src/__mocks__/modules/CS1010A.json b/website/src/__mocks__/modules/CS1010A.json index a04cd01faa..4148b0363d 100644 --- a/website/src/__mocks__/modules/CS1010A.json +++ b/website/src/__mocks__/modules/CS1010A.json @@ -21,7 +21,8 @@ "day": "Wednesday", "startTime": "1200", "endTime": "1400", - "venue": "LT32" + "venue": "LT32", + "lessonIndex": 0 }, { "classNo": "1", @@ -30,7 +31,8 @@ "day": "Thursday", "startTime": "1600", "endTime": "1700", - "venue": "S14-0620" + "venue": "S14-0620", + "lessonIndex": 1 }, { "classNo": "10", @@ -39,7 +41,8 @@ "day": "Friday", "startTime": "1300", "endTime": "1400", - "venue": "S14-0620" + "venue": "S14-0620", + "lessonIndex": 2 }, { "classNo": "2", @@ -48,7 +51,8 @@ "day": "Thursday", "startTime": "1700", "endTime": "1800", - "venue": "S14-0620" + "venue": "S14-0620", + "lessonIndex": 3 }, { "classNo": "3", @@ -57,7 +61,8 @@ "day": "Friday", "startTime": "0900", "endTime": "1000", - "venue": "S14-0619" + "venue": "S14-0619", + "lessonIndex": 4 }, { "classNo": "4", @@ -66,7 +71,8 @@ "day": "Friday", "startTime": "0900", "endTime": "1000", - "venue": "S14-0620" + "venue": "S14-0620", + "lessonIndex": 5 }, { "classNo": "5", @@ -75,7 +81,8 @@ "day": "Thursday", "startTime": "1100", "endTime": "1200", - "venue": "SR@LT19" + "venue": "SR@LT19", + "lessonIndex": 6 }, { "classNo": "6", @@ -84,7 +91,8 @@ "day": "Thursday", "startTime": "1200", "endTime": "1300", - "venue": "SR@LT19" + "venue": "SR@LT19", + "lessonIndex": 7 }, { "classNo": "7", @@ -93,7 +101,8 @@ "day": "Friday", "startTime": "1400", "endTime": "1500", - "venue": "S14-0619" + "venue": "S14-0619", + "lessonIndex": 8 }, { "classNo": "8", @@ -102,7 +111,8 @@ "day": "Friday", "startTime": "1500", "endTime": "1600", - "venue": "S14-0619" + "venue": "S14-0619", + "lessonIndex": 9 }, { "classNo": "9", @@ -111,7 +121,8 @@ "day": "Friday", "startTime": "1200", "endTime": "1300", - "venue": "S14-0620" + "venue": "S14-0620", + "lessonIndex": 10 }, { "classNo": "1", @@ -120,7 +131,8 @@ "day": "Monday", "startTime": "0900", "endTime": "1000", - "venue": "AS6-0208" + "venue": "AS6-0208", + "lessonIndex": 11 }, { "classNo": "10", @@ -129,7 +141,8 @@ "day": "Tuesday", "startTime": "0900", "endTime": "1000", - "venue": "COM1-0207" + "venue": "COM1-0207", + "lessonIndex": 12 }, { "classNo": "11", @@ -138,7 +151,8 @@ "day": "Tuesday", "startTime": "1000", "endTime": "1100", - "venue": "COM1-0207" + "venue": "COM1-0207", + "lessonIndex": 13 }, { "classNo": "12", @@ -147,7 +161,8 @@ "day": "Tuesday", "startTime": "1100", "endTime": "1200", - "venue": "COM1-0207" + "venue": "COM1-0207", + "lessonIndex": 14 }, { "classNo": "13", @@ -156,7 +171,8 @@ "day": "Tuesday", "startTime": "1200", "endTime": "1300", - "venue": "COM1-0208" + "venue": "COM1-0208", + "lessonIndex": 15 }, { "classNo": "14", @@ -165,7 +181,8 @@ "day": "Tuesday", "startTime": "1300", "endTime": "1400", - "venue": "COM1-0208" + "venue": "COM1-0208", + "lessonIndex": 16 }, { "classNo": "15", @@ -174,7 +191,8 @@ "day": "Tuesday", "startTime": "1400", "endTime": "1500", - "venue": "COM1-0208" + "venue": "COM1-0208", + "lessonIndex": 17 }, { "classNo": "16", @@ -183,7 +201,8 @@ "day": "Tuesday", "startTime": "1500", "endTime": "1600", - "venue": "COM1-0217" + "venue": "COM1-0217", + "lessonIndex": 18 }, { "classNo": "17", @@ -192,7 +211,8 @@ "day": "Tuesday", "startTime": "1600", "endTime": "1700", - "venue": "COM1-0217" + "venue": "COM1-0217", + "lessonIndex": 19 }, { "classNo": "18", @@ -201,7 +221,8 @@ "day": "Tuesday", "startTime": "1700", "endTime": "1800", - "venue": "COM1-0217" + "venue": "COM1-0217", + "lessonIndex": 20 }, { "classNo": "19", @@ -210,7 +231,8 @@ "day": "Monday", "startTime": "1100", "endTime": "1200", - "venue": "COM1-0207" + "venue": "COM1-0207", + "lessonIndex": 21 }, { "classNo": "2", @@ -219,7 +241,8 @@ "day": "Monday", "startTime": "1000", "endTime": "1100", - "venue": "AS6-0208" + "venue": "AS6-0208", + "lessonIndex": 22 }, { "classNo": "20", @@ -228,7 +251,8 @@ "day": "Monday", "startTime": "1200", "endTime": "1300", - "venue": "COM1-0207" + "venue": "COM1-0207", + "lessonIndex": 23 }, { "classNo": "21", @@ -237,7 +261,8 @@ "day": "Monday", "startTime": "1300", "endTime": "1400", - "venue": "COM1-0207" + "venue": "COM1-0207", + "lessonIndex": 24 }, { "classNo": "22", @@ -246,7 +271,8 @@ "day": "Monday", "startTime": "1400", "endTime": "1500", - "venue": "COM1-0207" + "venue": "COM1-0207", + "lessonIndex": 25 }, { "classNo": "23", @@ -255,7 +281,8 @@ "day": "Monday", "startTime": "1700", "endTime": "1800", - "venue": "COM1-0207" + "venue": "COM1-0207", + "lessonIndex": 26 }, { "classNo": "24", @@ -264,7 +291,8 @@ "day": "Tuesday", "startTime": "0900", "endTime": "1000", - "venue": "AS6-0421" + "venue": "AS6-0421", + "lessonIndex": 27 }, { "classNo": "25", @@ -273,7 +301,8 @@ "day": "Tuesday", "startTime": "1000", "endTime": "1100", - "venue": "AS6-0421" + "venue": "AS6-0421", + "lessonIndex": 28 }, { "classNo": "26", @@ -282,7 +311,8 @@ "day": "Tuesday", "startTime": "1100", "endTime": "1200", - "venue": "AS6-0421" + "venue": "AS6-0421", + "lessonIndex": 29 }, { "classNo": "27", @@ -291,7 +321,8 @@ "day": "Tuesday", "startTime": "1200", "endTime": "1300", - "venue": "AS6-0421" + "venue": "AS6-0421", + "lessonIndex": 30 }, { "classNo": "28", @@ -300,7 +331,8 @@ "day": "Tuesday", "startTime": "1300", "endTime": "1400", - "venue": "AS6-0421" + "venue": "AS6-0421", + "lessonIndex": 31 }, { "classNo": "29", @@ -309,7 +341,8 @@ "day": "Tuesday", "startTime": "1400", "endTime": "1500", - "venue": "AS6-0421" + "venue": "AS6-0421", + "lessonIndex": 32 }, { "classNo": "3", @@ -318,7 +351,8 @@ "day": "Monday", "startTime": "1100", "endTime": "1200", - "venue": "AS6-0208" + "venue": "AS6-0208", + "lessonIndex": 33 }, { "classNo": "30", @@ -327,7 +361,8 @@ "day": "Tuesday", "startTime": "1500", "endTime": "1600", - "venue": "AS6-0421" + "venue": "AS6-0421", + "lessonIndex": 34 }, { "classNo": "31", @@ -336,7 +371,8 @@ "day": "Tuesday", "startTime": "1600", "endTime": "1700", - "venue": "AS6-0421" + "venue": "AS6-0421", + "lessonIndex": 35 }, { "classNo": "32", @@ -345,7 +381,8 @@ "day": "Tuesday", "startTime": "1700", "endTime": "1800", - "venue": "AS6-0421" + "venue": "AS6-0421", + "lessonIndex": 36 }, { "classNo": "4", @@ -354,7 +391,8 @@ "day": "Monday", "startTime": "1200", "endTime": "1300", - "venue": "AS6-0208" + "venue": "AS6-0208", + "lessonIndex": 37 }, { "classNo": "5", @@ -363,7 +401,8 @@ "day": "Monday", "startTime": "1300", "endTime": "1400", - "venue": "AS6-0208" + "venue": "AS6-0208", + "lessonIndex": 38 }, { "classNo": "6", @@ -372,7 +411,8 @@ "day": "Monday", "startTime": "1400", "endTime": "1500", - "venue": "AS6-0208" + "venue": "AS6-0208", + "lessonIndex": 39 }, { "classNo": "7", @@ -381,7 +421,8 @@ "day": "Monday", "startTime": "1500", "endTime": "1600", - "venue": "AS6-0208" + "venue": "AS6-0208", + "lessonIndex": 40 }, { "classNo": "8", @@ -390,7 +431,8 @@ "day": "Monday", "startTime": "1600", "endTime": "1700", - "venue": "AS6-0208" + "venue": "AS6-0208", + "lessonIndex": 41 }, { "classNo": "9", @@ -399,7 +441,8 @@ "day": "Monday", "startTime": "1700", "endTime": "1800", - "venue": "AS6-0208" + "venue": "AS6-0208", + "lessonIndex": 42 } ] }, @@ -415,7 +458,8 @@ "day": "Wednesday", "startTime": "1000", "endTime": "1200", - "venue": "LT26" + "venue": "LT26", + "lessonIndex": 0 }, { "classNo": "1", @@ -424,7 +468,8 @@ "day": "Thursday", "startTime": "1200", "endTime": "1300", - "venue": "S14-0619" + "venue": "S14-0619", + "lessonIndex": 1 }, { "classNo": "10", @@ -433,7 +478,8 @@ "day": "Thursday", "startTime": "1200", "endTime": "1300", - "venue": "RMI-SR1" + "venue": "RMI-SR1", + "lessonIndex": 2 }, { "classNo": "2", @@ -442,7 +488,8 @@ "day": "Thursday", "startTime": "1300", "endTime": "1400", - "venue": "S14-0619" + "venue": "S14-0619", + "lessonIndex": 3 }, { "classNo": "3", @@ -451,7 +498,8 @@ "day": "Thursday", "startTime": "1600", "endTime": "1700", - "venue": "S14-0619" + "venue": "S14-0619", + "lessonIndex": 4 }, { "classNo": "4", @@ -460,7 +508,8 @@ "day": "Thursday", "startTime": "1700", "endTime": "1800", - "venue": "S14-0619" + "venue": "S14-0619", + "lessonIndex": 5 }, { "classNo": "5", @@ -469,7 +518,8 @@ "day": "Friday", "startTime": "1200", "endTime": "1300", - "venue": "S14-0619" + "venue": "S14-0619", + "lessonIndex": 6 }, { "classNo": "6", @@ -478,7 +528,8 @@ "day": "Friday", "startTime": "1300", "endTime": "1400", - "venue": "S14-0619" + "venue": "S14-0619", + "lessonIndex": 7 }, { "classNo": "7", @@ -487,7 +538,8 @@ "day": "Friday", "startTime": "1000", "endTime": "1100", - "venue": "S14-0620" + "venue": "S14-0620", + "lessonIndex": 8 }, { "classNo": "8", @@ -496,7 +548,8 @@ "day": "Friday", "startTime": "0900", "endTime": "1000", - "venue": "S14-0620" + "venue": "S14-0620", + "lessonIndex": 9 }, { "classNo": "9", @@ -505,7 +558,8 @@ "day": "Thursday", "startTime": "1100", "endTime": "1200", - "venue": "i3-0344" + "venue": "i3-0344", + "lessonIndex": 10 }, { "classNo": "1", @@ -514,7 +568,8 @@ "day": "Monday", "startTime": "0900", "endTime": "1000", - "venue": "COM1-0203" + "venue": "COM1-0203", + "lessonIndex": 11 }, { "classNo": "10", @@ -523,7 +578,8 @@ "day": "Tuesday", "startTime": "0900", "endTime": "1000", - "venue": "COM1-0209" + "venue": "COM1-0209", + "lessonIndex": 12 }, { "classNo": "11", @@ -532,7 +588,8 @@ "day": "Tuesday", "startTime": "1000", "endTime": "1100", - "venue": "COM1-0208" + "venue": "COM1-0208", + "lessonIndex": 13 }, { "classNo": "12", @@ -541,7 +598,8 @@ "day": "Tuesday", "startTime": "1100", "endTime": "1200", - "venue": "COM1-0207" + "venue": "COM1-0207", + "lessonIndex": 14 }, { "classNo": "13", @@ -550,7 +608,8 @@ "day": "Tuesday", "startTime": "1200", "endTime": "1300", - "venue": "COM1-0114" + "venue": "COM1-0114", + "lessonIndex": 15 }, { "classNo": "14", @@ -559,7 +618,8 @@ "day": "Tuesday", "startTime": "1300", "endTime": "1400", - "venue": "COM1-0114" + "venue": "COM1-0114", + "lessonIndex": 16 }, { "classNo": "15", @@ -568,7 +628,8 @@ "day": "Tuesday", "startTime": "1400", "endTime": "1500", - "venue": "AS6-0208" + "venue": "AS6-0208", + "lessonIndex": 17 }, { "classNo": "16", @@ -577,7 +638,8 @@ "day": "Tuesday", "startTime": "1500", "endTime": "1600", - "venue": "AS6-0208" + "venue": "AS6-0208", + "lessonIndex": 18 }, { "classNo": "17", @@ -586,7 +648,8 @@ "day": "Tuesday", "startTime": "1600", "endTime": "1700", - "venue": "COM1-0217" + "venue": "COM1-0217", + "lessonIndex": 19 }, { "classNo": "18", @@ -595,7 +658,8 @@ "day": "Tuesday", "startTime": "1700", "endTime": "1800", - "venue": "COM1-0217" + "venue": "COM1-0217", + "lessonIndex": 20 }, { "classNo": "2", @@ -604,7 +668,8 @@ "day": "Monday", "startTime": "1000", "endTime": "1100", - "venue": "COM1-0217" + "venue": "COM1-0217", + "lessonIndex": 21 }, { "classNo": "22", @@ -613,7 +678,8 @@ "day": "Monday", "startTime": "1600", "endTime": "1700", - "venue": "COM1-0218" + "venue": "COM1-0218", + "lessonIndex": 22 }, { "classNo": "23", @@ -622,7 +688,8 @@ "day": "Monday", "startTime": "1700", "endTime": "1800", - "venue": "COM1-0218" + "venue": "COM1-0218", + "lessonIndex": 23 }, { "classNo": "24", @@ -631,7 +698,8 @@ "day": "Tuesday", "startTime": "1000", "endTime": "1100", - "venue": "COM1-0209" + "venue": "COM1-0209", + "lessonIndex": 24 }, { "classNo": "25", @@ -640,7 +708,8 @@ "day": "Tuesday", "startTime": "1100", "endTime": "1200", - "venue": "COM1-0209" + "venue": "COM1-0209", + "lessonIndex": 25 }, { "classNo": "26", @@ -649,7 +718,8 @@ "day": "Tuesday", "startTime": "1200", "endTime": "1300", - "venue": "COM1-0208" + "venue": "COM1-0208", + "lessonIndex": 26 }, { "classNo": "27", @@ -658,7 +728,8 @@ "day": "Tuesday", "startTime": "1300", "endTime": "1400", - "venue": "COM1-0208" + "venue": "COM1-0208", + "lessonIndex": 27 }, { "classNo": "28", @@ -667,7 +738,8 @@ "day": "Tuesday", "startTime": "1500", "endTime": "1600", - "venue": "COM1-0113" + "venue": "COM1-0113", + "lessonIndex": 28 }, { "classNo": "29", @@ -676,7 +748,8 @@ "day": "Tuesday", "startTime": "1600", "endTime": "1700", - "venue": "COM1-B103" + "venue": "COM1-B103", + "lessonIndex": 29 }, { "classNo": "3", @@ -685,7 +758,8 @@ "day": "Monday", "startTime": "1100", "endTime": "1200", - "venue": "COM1-0217" + "venue": "COM1-0217", + "lessonIndex": 30 }, { "classNo": "30", @@ -694,7 +768,8 @@ "day": "Tuesday", "startTime": "1700", "endTime": "1800", - "venue": "COM1-B103" + "venue": "COM1-B103", + "lessonIndex": 31 }, { "classNo": "31", @@ -703,7 +778,8 @@ "day": "Monday", "startTime": "0900", "endTime": "1000", - "venue": "COM1-B110" + "venue": "COM1-B110", + "lessonIndex": 32 }, { "classNo": "32", @@ -712,7 +788,8 @@ "day": "Monday", "startTime": "1000", "endTime": "1100", - "venue": "COM1-B110" + "venue": "COM1-B110", + "lessonIndex": 33 }, { "classNo": "33", @@ -721,7 +798,8 @@ "day": "Monday", "startTime": "1100", "endTime": "1200", - "venue": "COM1-B110" + "venue": "COM1-B110", + "lessonIndex": 34 }, { "classNo": "36", @@ -730,7 +808,8 @@ "day": "Tuesday", "startTime": "1400", "endTime": "1500", - "venue": "COM1-B110" + "venue": "COM1-B110", + "lessonIndex": 35 }, { "classNo": "37", @@ -739,7 +818,8 @@ "day": "Tuesday", "startTime": "1600", "endTime": "1700", - "venue": "COM1-0209" + "venue": "COM1-0209", + "lessonIndex": 36 }, { "classNo": "38", @@ -748,7 +828,8 @@ "day": "Tuesday", "startTime": "1700", "endTime": "1800", - "venue": "COM1-0209" + "venue": "COM1-0209", + "lessonIndex": 37 }, { "classNo": "4", @@ -757,7 +838,8 @@ "day": "Monday", "startTime": "1200", "endTime": "1300", - "venue": "COM1-0217" + "venue": "COM1-0217", + "lessonIndex": 38 }, { "classNo": "5", @@ -766,7 +848,8 @@ "day": "Monday", "startTime": "1300", "endTime": "1400", - "venue": "COM1-0217" + "venue": "COM1-0217", + "lessonIndex": 39 }, { "classNo": "6", @@ -775,7 +858,8 @@ "day": "Monday", "startTime": "1400", "endTime": "1500", - "venue": "AS6-0208" + "venue": "AS6-0208", + "lessonIndex": 40 }, { "classNo": "7", @@ -784,7 +868,8 @@ "day": "Monday", "startTime": "1500", "endTime": "1600", - "venue": "AS6-0208" + "venue": "AS6-0208", + "lessonIndex": 41 }, { "classNo": "8", @@ -793,7 +878,8 @@ "day": "Monday", "startTime": "1600", "endTime": "1700", - "venue": "AS6-0208" + "venue": "AS6-0208", + "lessonIndex": 42 }, { "classNo": "9", @@ -802,7 +888,8 @@ "day": "Monday", "startTime": "1700", "endTime": "1800", - "venue": "AS6-0208" + "venue": "AS6-0208", + "lessonIndex": 43 } ] } diff --git a/website/src/__mocks__/modules/CS1010S.json b/website/src/__mocks__/modules/CS1010S.json index 808cb907c7..282248d56c 100644 --- a/website/src/__mocks__/modules/CS1010S.json +++ b/website/src/__mocks__/modules/CS1010S.json @@ -21,7 +21,8 @@ "day": "Wednesday", "startTime": "1200", "endTime": "1400", - "venue": "LT32" + "venue": "LT32", + "lessonIndex": 0 }, { "classNo": "1", @@ -30,7 +31,8 @@ "day": "Thursday", "startTime": "1600", "endTime": "1700", - "venue": "S14-0620" + "venue": "S14-0620", + "lessonIndex": 1 }, { "classNo": "10", @@ -39,7 +41,8 @@ "day": "Friday", "startTime": "1300", "endTime": "1400", - "venue": "S14-0620" + "venue": "S14-0620", + "lessonIndex": 2 }, { "classNo": "2", @@ -48,7 +51,8 @@ "day": "Thursday", "startTime": "1700", "endTime": "1800", - "venue": "S14-0620" + "venue": "S14-0620", + "lessonIndex": 3 }, { "classNo": "3", @@ -57,7 +61,8 @@ "day": "Friday", "startTime": "0900", "endTime": "1000", - "venue": "S14-0619" + "venue": "S14-0619", + "lessonIndex": 4 }, { "classNo": "4", @@ -66,7 +71,8 @@ "day": "Friday", "startTime": "0900", "endTime": "1000", - "venue": "S14-0620" + "venue": "S14-0620", + "lessonIndex": 5 }, { "classNo": "5", @@ -75,7 +81,8 @@ "day": "Thursday", "startTime": "1100", "endTime": "1200", - "venue": "SR@LT19" + "venue": "SR@LT19", + "lessonIndex": 6 }, { "classNo": "6", @@ -84,7 +91,8 @@ "day": "Thursday", "startTime": "1200", "endTime": "1300", - "venue": "SR@LT19" + "venue": "SR@LT19", + "lessonIndex": 7 }, { "classNo": "7", @@ -93,7 +101,8 @@ "day": "Friday", "startTime": "1400", "endTime": "1500", - "venue": "S14-0619" + "venue": "S14-0619", + "lessonIndex": 8 }, { "classNo": "8", @@ -102,7 +111,8 @@ "day": "Friday", "startTime": "1500", "endTime": "1600", - "venue": "S14-0619" + "venue": "S14-0619", + "lessonIndex": 9 }, { "classNo": "9", @@ -111,7 +121,8 @@ "day": "Friday", "startTime": "1200", "endTime": "1300", - "venue": "S14-0620" + "venue": "S14-0620", + "lessonIndex": 10 }, { "classNo": "1", @@ -120,7 +131,8 @@ "day": "Monday", "startTime": "0900", "endTime": "1000", - "venue": "AS6-0208" + "venue": "AS6-0208", + "lessonIndex": 11 }, { "classNo": "10", @@ -129,7 +141,8 @@ "day": "Tuesday", "startTime": "0900", "endTime": "1000", - "venue": "COM1-0207" + "venue": "COM1-0207", + "lessonIndex": 12 }, { "classNo": "11", @@ -138,7 +151,8 @@ "day": "Tuesday", "startTime": "1000", "endTime": "1100", - "venue": "COM1-0207" + "venue": "COM1-0207", + "lessonIndex": 13 }, { "classNo": "12", @@ -147,7 +161,8 @@ "day": "Tuesday", "startTime": "1100", "endTime": "1200", - "venue": "COM1-0207" + "venue": "COM1-0207", + "lessonIndex": 14 }, { "classNo": "13", @@ -156,7 +171,8 @@ "day": "Tuesday", "startTime": "1200", "endTime": "1300", - "venue": "COM1-0208" + "venue": "COM1-0208", + "lessonIndex": 15 }, { "classNo": "14", @@ -165,7 +181,8 @@ "day": "Tuesday", "startTime": "1300", "endTime": "1400", - "venue": "COM1-0208" + "venue": "COM1-0208", + "lessonIndex": 16 }, { "classNo": "15", @@ -174,7 +191,8 @@ "day": "Tuesday", "startTime": "1400", "endTime": "1500", - "venue": "COM1-0208" + "venue": "COM1-0208", + "lessonIndex": 17 }, { "classNo": "16", @@ -183,7 +201,8 @@ "day": "Tuesday", "startTime": "1500", "endTime": "1600", - "venue": "COM1-0217" + "venue": "COM1-0217", + "lessonIndex": 18 }, { "classNo": "17", @@ -192,7 +211,8 @@ "day": "Tuesday", "startTime": "1600", "endTime": "1700", - "venue": "COM1-0217" + "venue": "COM1-0217", + "lessonIndex": 19 }, { "classNo": "18", @@ -201,7 +221,8 @@ "day": "Tuesday", "startTime": "1700", "endTime": "1800", - "venue": "COM1-0217" + "venue": "COM1-0217", + "lessonIndex": 20 }, { "classNo": "19", @@ -210,7 +231,8 @@ "day": "Monday", "startTime": "1100", "endTime": "1200", - "venue": "COM1-0207" + "venue": "COM1-0207", + "lessonIndex": 21 }, { "classNo": "2", @@ -219,7 +241,8 @@ "day": "Monday", "startTime": "1000", "endTime": "1100", - "venue": "AS6-0208" + "venue": "AS6-0208", + "lessonIndex": 22 }, { "classNo": "20", @@ -228,7 +251,8 @@ "day": "Monday", "startTime": "1200", "endTime": "1300", - "venue": "COM1-0207" + "venue": "COM1-0207", + "lessonIndex": 23 }, { "classNo": "21", @@ -237,7 +261,8 @@ "day": "Monday", "startTime": "1300", "endTime": "1400", - "venue": "COM1-0207" + "venue": "COM1-0207", + "lessonIndex": 24 }, { "classNo": "22", @@ -246,7 +271,8 @@ "day": "Monday", "startTime": "1400", "endTime": "1500", - "venue": "COM1-0207" + "venue": "COM1-0207", + "lessonIndex": 25 }, { "classNo": "23", @@ -255,7 +281,8 @@ "day": "Monday", "startTime": "1700", "endTime": "1800", - "venue": "COM1-0207" + "venue": "COM1-0207", + "lessonIndex": 26 }, { "classNo": "24", @@ -264,7 +291,8 @@ "day": "Tuesday", "startTime": "0900", "endTime": "1000", - "venue": "AS6-0421" + "venue": "AS6-0421", + "lessonIndex": 27 }, { "classNo": "25", @@ -273,7 +301,8 @@ "day": "Tuesday", "startTime": "1000", "endTime": "1100", - "venue": "AS6-0421" + "venue": "AS6-0421", + "lessonIndex": 28 }, { "classNo": "26", @@ -282,7 +311,8 @@ "day": "Tuesday", "startTime": "1100", "endTime": "1200", - "venue": "AS6-0421" + "venue": "AS6-0421", + "lessonIndex": 29 }, { "classNo": "27", @@ -291,7 +321,8 @@ "day": "Tuesday", "startTime": "1200", "endTime": "1300", - "venue": "AS6-0421" + "venue": "AS6-0421", + "lessonIndex": 30 }, { "classNo": "28", @@ -300,7 +331,8 @@ "day": "Tuesday", "startTime": "1300", "endTime": "1400", - "venue": "AS6-0421" + "venue": "AS6-0421", + "lessonIndex": 31 }, { "classNo": "29", @@ -309,7 +341,8 @@ "day": "Tuesday", "startTime": "1400", "endTime": "1500", - "venue": "AS6-0421" + "venue": "AS6-0421", + "lessonIndex": 32 }, { "classNo": "3", @@ -318,7 +351,8 @@ "day": "Monday", "startTime": "1100", "endTime": "1200", - "venue": "AS6-0208" + "venue": "AS6-0208", + "lessonIndex": 33 }, { "classNo": "30", @@ -327,7 +361,8 @@ "day": "Tuesday", "startTime": "1500", "endTime": "1600", - "venue": "AS6-0421" + "venue": "AS6-0421", + "lessonIndex": 34 }, { "classNo": "31", @@ -336,7 +371,8 @@ "day": "Tuesday", "startTime": "1600", "endTime": "1700", - "venue": "AS6-0421" + "venue": "AS6-0421", + "lessonIndex": 35 }, { "classNo": "32", @@ -345,7 +381,8 @@ "day": "Tuesday", "startTime": "1700", "endTime": "1800", - "venue": "AS6-0421" + "venue": "AS6-0421", + "lessonIndex": 36 }, { "classNo": "4", @@ -354,7 +391,8 @@ "day": "Monday", "startTime": "1200", "endTime": "1300", - "venue": "AS6-0208" + "venue": "AS6-0208", + "lessonIndex": 37 }, { "classNo": "5", @@ -363,7 +401,8 @@ "day": "Monday", "startTime": "1300", "endTime": "1400", - "venue": "AS6-0208" + "venue": "AS6-0208", + "lessonIndex": 38 }, { "classNo": "6", @@ -372,7 +411,8 @@ "day": "Monday", "startTime": "1400", "endTime": "1500", - "venue": "AS6-0208" + "venue": "AS6-0208", + "lessonIndex": 39 }, { "classNo": "7", @@ -381,7 +421,8 @@ "day": "Monday", "startTime": "1500", "endTime": "1600", - "venue": "AS6-0208" + "venue": "AS6-0208", + "lessonIndex": 40 }, { "classNo": "8", @@ -390,7 +431,8 @@ "day": "Monday", "startTime": "1600", "endTime": "1700", - "venue": "AS6-0208" + "venue": "AS6-0208", + "lessonIndex": 41 }, { "classNo": "9", @@ -399,7 +441,8 @@ "day": "Monday", "startTime": "1700", "endTime": "1800", - "venue": "AS6-0208" + "venue": "AS6-0208", + "lessonIndex": 42 } ] }, @@ -415,7 +458,8 @@ "day": "Wednesday", "startTime": "1000", "endTime": "1200", - "venue": "LT26" + "venue": "LT26", + "lessonIndex": 0 }, { "classNo": "1", @@ -424,7 +468,8 @@ "day": "Thursday", "startTime": "1200", "endTime": "1300", - "venue": "S14-0619" + "venue": "S14-0619", + "lessonIndex": 1 }, { "classNo": "10", @@ -433,7 +478,8 @@ "day": "Thursday", "startTime": "1200", "endTime": "1300", - "venue": "RMI-SR1" + "venue": "RMI-SR1", + "lessonIndex": 2 }, { "classNo": "2", @@ -442,7 +488,8 @@ "day": "Thursday", "startTime": "1300", "endTime": "1400", - "venue": "S14-0619" + "venue": "S14-0619", + "lessonIndex": 3 }, { "classNo": "3", @@ -451,7 +498,8 @@ "day": "Thursday", "startTime": "1600", "endTime": "1700", - "venue": "S14-0619" + "venue": "S14-0619", + "lessonIndex": 4 }, { "classNo": "4", @@ -460,7 +508,8 @@ "day": "Thursday", "startTime": "1700", "endTime": "1800", - "venue": "S14-0619" + "venue": "S14-0619", + "lessonIndex": 5 }, { "classNo": "5", @@ -469,7 +518,8 @@ "day": "Friday", "startTime": "1200", "endTime": "1300", - "venue": "S14-0619" + "venue": "S14-0619", + "lessonIndex": 6 }, { "classNo": "6", @@ -478,7 +528,8 @@ "day": "Friday", "startTime": "1300", "endTime": "1400", - "venue": "S14-0619" + "venue": "S14-0619", + "lessonIndex": 7 }, { "classNo": "7", @@ -487,7 +538,8 @@ "day": "Friday", "startTime": "1000", "endTime": "1100", - "venue": "S14-0620" + "venue": "S14-0620", + "lessonIndex": 8 }, { "classNo": "8", @@ -496,7 +548,8 @@ "day": "Friday", "startTime": "0900", "endTime": "1000", - "venue": "S14-0620" + "venue": "S14-0620", + "lessonIndex": 9 }, { "classNo": "9", @@ -505,7 +558,8 @@ "day": "Thursday", "startTime": "1100", "endTime": "1200", - "venue": "i3-0344" + "venue": "i3-0344", + "lessonIndex": 10 }, { "classNo": "1", @@ -514,7 +568,8 @@ "day": "Monday", "startTime": "0900", "endTime": "1000", - "venue": "COM1-0203" + "venue": "COM1-0203", + "lessonIndex": 11 }, { "classNo": "10", @@ -523,7 +578,8 @@ "day": "Tuesday", "startTime": "0900", "endTime": "1000", - "venue": "COM1-0209" + "venue": "COM1-0209", + "lessonIndex": 12 }, { "classNo": "11", @@ -532,7 +588,8 @@ "day": "Tuesday", "startTime": "1000", "endTime": "1100", - "venue": "COM1-0208" + "venue": "COM1-0208", + "lessonIndex": 13 }, { "classNo": "12", @@ -541,7 +598,8 @@ "day": "Tuesday", "startTime": "1100", "endTime": "1200", - "venue": "COM1-0207" + "venue": "COM1-0207", + "lessonIndex": 14 }, { "classNo": "13", @@ -550,7 +608,8 @@ "day": "Tuesday", "startTime": "1200", "endTime": "1300", - "venue": "COM1-0114" + "venue": "COM1-0114", + "lessonIndex": 15 }, { "classNo": "14", @@ -559,7 +618,8 @@ "day": "Tuesday", "startTime": "1300", "endTime": "1400", - "venue": "COM1-0114" + "venue": "COM1-0114", + "lessonIndex": 16 }, { "classNo": "15", @@ -568,7 +628,8 @@ "day": "Tuesday", "startTime": "1400", "endTime": "1500", - "venue": "AS6-0208" + "venue": "AS6-0208", + "lessonIndex": 17 }, { "classNo": "16", @@ -577,7 +638,8 @@ "day": "Tuesday", "startTime": "1500", "endTime": "1600", - "venue": "AS6-0208" + "venue": "AS6-0208", + "lessonIndex": 18 }, { "classNo": "17", @@ -586,7 +648,8 @@ "day": "Tuesday", "startTime": "1600", "endTime": "1700", - "venue": "COM1-0217" + "venue": "COM1-0217", + "lessonIndex": 19 }, { "classNo": "18", @@ -595,7 +658,8 @@ "day": "Tuesday", "startTime": "1700", "endTime": "1800", - "venue": "COM1-0217" + "venue": "COM1-0217", + "lessonIndex": 20 }, { "classNo": "2", @@ -604,7 +668,8 @@ "day": "Monday", "startTime": "1000", "endTime": "1100", - "venue": "COM1-0217" + "venue": "COM1-0217", + "lessonIndex": 21 }, { "classNo": "22", @@ -613,7 +678,8 @@ "day": "Monday", "startTime": "1600", "endTime": "1700", - "venue": "COM1-0218" + "venue": "COM1-0218", + "lessonIndex": 22 }, { "classNo": "23", @@ -622,7 +688,8 @@ "day": "Monday", "startTime": "1700", "endTime": "1800", - "venue": "COM1-0218" + "venue": "COM1-0218", + "lessonIndex": 23 }, { "classNo": "24", @@ -631,7 +698,8 @@ "day": "Tuesday", "startTime": "1000", "endTime": "1100", - "venue": "COM1-0209" + "venue": "COM1-0209", + "lessonIndex": 24 }, { "classNo": "25", @@ -640,7 +708,8 @@ "day": "Tuesday", "startTime": "1100", "endTime": "1200", - "venue": "COM1-0209" + "venue": "COM1-0209", + "lessonIndex": 25 }, { "classNo": "26", @@ -649,7 +718,8 @@ "day": "Tuesday", "startTime": "1200", "endTime": "1300", - "venue": "COM1-0208" + "venue": "COM1-0208", + "lessonIndex": 26 }, { "classNo": "27", @@ -658,7 +728,8 @@ "day": "Tuesday", "startTime": "1300", "endTime": "1400", - "venue": "COM1-0208" + "venue": "COM1-0208", + "lessonIndex": 27 }, { "classNo": "28", @@ -667,7 +738,8 @@ "day": "Tuesday", "startTime": "1500", "endTime": "1600", - "venue": "COM1-0113" + "venue": "COM1-0113", + "lessonIndex": 28 }, { "classNo": "29", @@ -676,7 +748,8 @@ "day": "Tuesday", "startTime": "1600", "endTime": "1700", - "venue": "COM1-B103" + "venue": "COM1-B103", + "lessonIndex": 29 }, { "classNo": "3", @@ -685,7 +758,8 @@ "day": "Monday", "startTime": "1100", "endTime": "1200", - "venue": "COM1-0217" + "venue": "COM1-0217", + "lessonIndex": 30 }, { "classNo": "30", @@ -694,7 +768,8 @@ "day": "Tuesday", "startTime": "1700", "endTime": "1800", - "venue": "COM1-B103" + "venue": "COM1-B103", + "lessonIndex": 31 }, { "classNo": "31", @@ -703,7 +778,8 @@ "day": "Monday", "startTime": "0900", "endTime": "1000", - "venue": "COM1-B110" + "venue": "COM1-B110", + "lessonIndex": 32 }, { "classNo": "32", @@ -712,7 +788,8 @@ "day": "Monday", "startTime": "1000", "endTime": "1100", - "venue": "COM1-B110" + "venue": "COM1-B110", + "lessonIndex": 33 }, { "classNo": "33", @@ -721,7 +798,8 @@ "day": "Monday", "startTime": "1100", "endTime": "1200", - "venue": "COM1-B110" + "venue": "COM1-B110", + "lessonIndex": 34 }, { "classNo": "36", @@ -730,7 +808,8 @@ "day": "Tuesday", "startTime": "1400", "endTime": "1500", - "venue": "COM1-B110" + "venue": "COM1-B110", + "lessonIndex": 35 }, { "classNo": "37", @@ -739,7 +818,8 @@ "day": "Tuesday", "startTime": "1600", "endTime": "1700", - "venue": "COM1-0209" + "venue": "COM1-0209", + "lessonIndex": 36 }, { "classNo": "38", @@ -748,7 +828,8 @@ "day": "Tuesday", "startTime": "1700", "endTime": "1800", - "venue": "COM1-0209" + "venue": "COM1-0209", + "lessonIndex": 37 }, { "classNo": "4", @@ -757,7 +838,8 @@ "day": "Monday", "startTime": "1200", "endTime": "1300", - "venue": "COM1-0217" + "venue": "COM1-0217", + "lessonIndex": 38 }, { "classNo": "5", @@ -766,7 +848,8 @@ "day": "Monday", "startTime": "1300", "endTime": "1400", - "venue": "COM1-0217" + "venue": "COM1-0217", + "lessonIndex": 39 }, { "classNo": "6", @@ -775,7 +858,8 @@ "day": "Monday", "startTime": "1400", "endTime": "1500", - "venue": "AS6-0208" + "venue": "AS6-0208", + "lessonIndex": 40 }, { "classNo": "7", @@ -784,7 +868,8 @@ "day": "Monday", "startTime": "1500", "endTime": "1600", - "venue": "AS6-0208" + "venue": "AS6-0208", + "lessonIndex": 41 }, { "classNo": "8", @@ -793,7 +878,8 @@ "day": "Monday", "startTime": "1600", "endTime": "1700", - "venue": "AS6-0208" + "venue": "AS6-0208", + "lessonIndex": 42 }, { "classNo": "9", @@ -802,7 +888,8 @@ "day": "Monday", "startTime": "1700", "endTime": "1800", - "venue": "AS6-0208" + "venue": "AS6-0208", + "lessonIndex": 43 } ] } diff --git a/website/src/__mocks__/modules/CS3216.json b/website/src/__mocks__/modules/CS3216.json index 668cab94af..f539b7560c 100644 --- a/website/src/__mocks__/modules/CS3216.json +++ b/website/src/__mocks__/modules/CS3216.json @@ -19,7 +19,8 @@ "day": "Monday", "startTime": "1830", "endTime": "2030", - "venue": "VCRm" + "venue": "VCRm", + "lessonIndex": 0 } ] } diff --git a/website/src/__mocks__/modules/CS4243.json b/website/src/__mocks__/modules/CS4243.json index 65265a08c8..b4d5667430 100644 --- a/website/src/__mocks__/modules/CS4243.json +++ b/website/src/__mocks__/modules/CS4243.json @@ -21,7 +21,8 @@ "day": "Tuesday", "startTime": "1400", "endTime": "1600", - "venue": "AS6-0421" + "venue": "AS6-0421", + "lessonIndex": 0 }, { "classNo": "2", @@ -30,7 +31,8 @@ "day": "Tuesday", "startTime": "1600", "endTime": "1800", - "venue": "AS6-0421" + "venue": "AS6-0421", + "lessonIndex": 1 }, { "classNo": "3", @@ -39,7 +41,8 @@ "day": "Tuesday", "startTime": "1830", "endTime": "2030", - "venue": "AS6-0421" + "venue": "AS6-0421", + "lessonIndex": 2 }, { "classNo": "4", @@ -48,7 +51,8 @@ "day": "Thursday", "startTime": "1600", "endTime": "1800", - "venue": "AS6-0421" + "venue": "AS6-0421", + "lessonIndex": 3 }, { "classNo": "5", @@ -57,7 +61,8 @@ "day": "Friday", "startTime": "1000", "endTime": "1200", - "venue": "AS6-0421" + "venue": "AS6-0421", + "lessonIndex": 4 }, { "classNo": "1", @@ -66,7 +71,8 @@ "day": "Monday", "startTime": "1830", "endTime": "2030", - "venue": "LT15" + "venue": "LT15", + "lessonIndex": 5 } ] } diff --git a/website/src/__mocks__/modules/GER1000.json b/website/src/__mocks__/modules/GER1000.json index f37bdc4a98..ec65725c85 100644 --- a/website/src/__mocks__/modules/GER1000.json +++ b/website/src/__mocks__/modules/GER1000.json @@ -3,6 +3,7 @@ "title": "Quantitative Reasoning", "acadYear": "2017/2018", "department": "Office Of The Provost", + "faculty": "NUS", "description": "This module aims to equip undergraduates with basic reasoning skills on using data to address real world issues. What are some potential complications to keep in mind as we plan what data to collect and how to use them to address our particular issue? When two things are related (e.g. smoking and cancer), how can we tell whether the relationship is causal (e.g. smoking causes cancer)? How can quantitative reasoning help us deal with uncertainty or elucidate complex relationships? These and other questions will be discussed using real world examples.", "moduleCredit": "4", "workload": [2, 1, 0, 3, 4], @@ -18,7 +19,8 @@ "day": "Tuesday", "startTime": "1000", "endTime": "1200", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 0 }, { "classNo": "A02", @@ -27,7 +29,8 @@ "day": "Tuesday", "startTime": "1000", "endTime": "1200", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 1 }, { "classNo": "A03", @@ -36,7 +39,8 @@ "day": "Tuesday", "startTime": "1200", "endTime": "1400", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 2 }, { "classNo": "A04", @@ -45,7 +49,8 @@ "day": "Tuesday", "startTime": "1200", "endTime": "1400", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 3 }, { "classNo": "A07", @@ -54,7 +59,8 @@ "day": "Monday", "startTime": "1000", "endTime": "1200", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 4 }, { "classNo": "A08", @@ -63,7 +69,8 @@ "day": "Monday", "startTime": "1000", "endTime": "1200", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 5 }, { "classNo": "A09", @@ -72,7 +79,8 @@ "day": "Tuesday", "startTime": "1900", "endTime": "2100", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 6 }, { "classNo": "A10", @@ -81,7 +89,8 @@ "day": "Tuesday", "startTime": "1900", "endTime": "2100", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 7 }, { "classNo": "A11", @@ -90,7 +99,8 @@ "day": "Thursday", "startTime": "1200", "endTime": "1400", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 8 }, { "classNo": "A12", @@ -99,7 +109,8 @@ "day": "Thursday", "startTime": "1200", "endTime": "1400", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 9 }, { "classNo": "A13", @@ -108,7 +119,8 @@ "day": "Thursday", "startTime": "1400", "endTime": "1600", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 10 }, { "classNo": "A14", @@ -117,7 +129,8 @@ "day": "Thursday", "startTime": "1400", "endTime": "1600", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 11 }, { "classNo": "A15", @@ -126,7 +139,8 @@ "day": "Friday", "startTime": "1200", "endTime": "1400", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 12 }, { "classNo": "B01", @@ -135,7 +149,8 @@ "day": "Monday", "startTime": "0800", "endTime": "1000", - "venue": "BIZ2-0118" + "venue": "BIZ2-0118", + "lessonIndex": 13 }, { "classNo": "B02", @@ -144,7 +159,8 @@ "day": "Monday", "startTime": "0800", "endTime": "1000", - "venue": "BIZ2-0118" + "venue": "BIZ2-0118", + "lessonIndex": 14 }, { "classNo": "B03", @@ -153,7 +169,8 @@ "day": "Tuesday", "startTime": "1400", "endTime": "1600", - "venue": "BIZ2-0118" + "venue": "BIZ2-0118", + "lessonIndex": 15 }, { "classNo": "B04", @@ -162,7 +179,8 @@ "day": "Tuesday", "startTime": "1400", "endTime": "1600", - "venue": "BIZ2-0118" + "venue": "BIZ2-0118", + "lessonIndex": 16 }, { "classNo": "B05", @@ -171,7 +189,8 @@ "day": "Tuesday", "startTime": "1600", "endTime": "1800", - "venue": "BIZ2-0118" + "venue": "BIZ2-0118", + "lessonIndex": 17 }, { "classNo": "B06", @@ -180,7 +199,8 @@ "day": "Tuesday", "startTime": "1600", "endTime": "1800", - "venue": "BIZ2-0118" + "venue": "BIZ2-0118", + "lessonIndex": 18 }, { "classNo": "B07", @@ -189,7 +209,8 @@ "day": "Wednesday", "startTime": "1600", "endTime": "1800", - "venue": "BIZ2-0118" + "venue": "BIZ2-0118", + "lessonIndex": 19 }, { "classNo": "B08", @@ -198,7 +219,8 @@ "day": "Friday", "startTime": "1400", "endTime": "1600", - "venue": "BIZ2-0224" + "venue": "BIZ2-0224", + "lessonIndex": 20 }, { "classNo": "C01", @@ -207,7 +229,8 @@ "day": "Tuesday", "startTime": "1200", "endTime": "1400", - "venue": "COM2-0108" + "venue": "COM2-0108", + "lessonIndex": 21 }, { "classNo": "C02", @@ -216,7 +239,8 @@ "day": "Tuesday", "startTime": "1200", "endTime": "1400", - "venue": "COM2-0108" + "venue": "COM2-0108", + "lessonIndex": 22 }, { "classNo": "C03", @@ -225,7 +249,8 @@ "day": "Thursday", "startTime": "1600", "endTime": "1800", - "venue": "COM2-0108" + "venue": "COM2-0108", + "lessonIndex": 23 }, { "classNo": "C04", @@ -234,7 +259,8 @@ "day": "Thursday", "startTime": "1600", "endTime": "1800", - "venue": "COM2-0108" + "venue": "COM2-0108", + "lessonIndex": 24 }, { "classNo": "D01", @@ -243,7 +269,8 @@ "day": "Monday", "startTime": "1200", "endTime": "1400", - "venue": "SDE-SR14" + "venue": "SDE-SR14", + "lessonIndex": 25 }, { "classNo": "D02", @@ -252,7 +279,8 @@ "day": "Monday", "startTime": "1200", "endTime": "1400", - "venue": "SDE-SR14" + "venue": "SDE-SR14", + "lessonIndex": 26 }, { "classNo": "D03", @@ -261,7 +289,8 @@ "day": "Thursday", "startTime": "0900", "endTime": "1100", - "venue": "SDE-SR12" + "venue": "SDE-SR12", + "lessonIndex": 27 }, { "classNo": "D04", @@ -270,7 +299,8 @@ "day": "Thursday", "startTime": "0900", "endTime": "1100", - "venue": "SDE-SR12" + "venue": "SDE-SR12", + "lessonIndex": 28 }, { "classNo": "D05", @@ -279,7 +309,8 @@ "day": "Thursday", "startTime": "1000", "endTime": "1200", - "venue": "SDE-SR14" + "venue": "SDE-SR14", + "lessonIndex": 29 }, { "classNo": "D06", @@ -288,7 +319,8 @@ "day": "Thursday", "startTime": "1000", "endTime": "1200", - "venue": "SDE-SR14" + "venue": "SDE-SR14", + "lessonIndex": 30 }, { "classNo": "D07", @@ -297,7 +329,8 @@ "day": "Thursday", "startTime": "1100", "endTime": "1300", - "venue": "SDE-SR12" + "venue": "SDE-SR12", + "lessonIndex": 31 }, { "classNo": "D08", @@ -306,7 +339,8 @@ "day": "Thursday", "startTime": "1100", "endTime": "1300", - "venue": "SDE-SR12" + "venue": "SDE-SR12", + "lessonIndex": 32 }, { "classNo": "E03", @@ -315,7 +349,8 @@ "day": "Monday", "startTime": "1200", "endTime": "1400", - "venue": "E4-04-03" + "venue": "E4-04-03", + "lessonIndex": 33 }, { "classNo": "E04", @@ -324,7 +359,8 @@ "day": "Monday", "startTime": "1200", "endTime": "1400", - "venue": "E4-04-03" + "venue": "E4-04-03", + "lessonIndex": 34 }, { "classNo": "E05", @@ -333,7 +369,8 @@ "day": "Monday", "startTime": "1400", "endTime": "1600", - "venue": "E4-04-03" + "venue": "E4-04-03", + "lessonIndex": 35 }, { "classNo": "E06", @@ -342,7 +379,8 @@ "day": "Monday", "startTime": "1400", "endTime": "1600", - "venue": "E4-04-03" + "venue": "E4-04-03", + "lessonIndex": 36 }, { "classNo": "E07", @@ -351,7 +389,8 @@ "day": "Wednesday", "startTime": "0800", "endTime": "1000", - "venue": "E4-04-03" + "venue": "E4-04-03", + "lessonIndex": 37 }, { "classNo": "E08", @@ -360,7 +399,8 @@ "day": "Wednesday", "startTime": "1200", "endTime": "1400", - "venue": "E4-04-03" + "venue": "E4-04-03", + "lessonIndex": 38 }, { "classNo": "E09", @@ -369,7 +409,8 @@ "day": "Wednesday", "startTime": "1400", "endTime": "1600", - "venue": "E4-04-03" + "venue": "E4-04-03", + "lessonIndex": 39 }, { "classNo": "E10", @@ -378,7 +419,8 @@ "day": "Wednesday", "startTime": "1600", "endTime": "1800", - "venue": "E4-04-03" + "venue": "E4-04-03", + "lessonIndex": 40 }, { "classNo": "E11", @@ -387,7 +429,8 @@ "day": "Thursday", "startTime": "0800", "endTime": "1000", - "venue": "E4-04-03" + "venue": "E4-04-03", + "lessonIndex": 41 }, { "classNo": "E13", @@ -396,7 +439,8 @@ "day": "Thursday", "startTime": "1200", "endTime": "1400", - "venue": "E4-04-03" + "venue": "E4-04-03", + "lessonIndex": 42 }, { "classNo": "E14", @@ -405,7 +449,8 @@ "day": "Thursday", "startTime": "1200", "endTime": "1400", - "venue": "E4-04-03" + "venue": "E4-04-03", + "lessonIndex": 43 }, { "classNo": "S01", @@ -414,7 +459,8 @@ "day": "Monday", "startTime": "1000", "endTime": "1200", - "venue": "S16-0309" + "venue": "S16-0309", + "lessonIndex": 44 }, { "classNo": "S02", @@ -423,7 +469,8 @@ "day": "Monday", "startTime": "1000", "endTime": "1200", - "venue": "S16-0309" + "venue": "S16-0309", + "lessonIndex": 45 }, { "classNo": "S03", @@ -432,7 +479,8 @@ "day": "Tuesday", "startTime": "0800", "endTime": "1000", - "venue": "S16-0309" + "venue": "S16-0309", + "lessonIndex": 46 }, { "classNo": "S04", @@ -441,7 +489,8 @@ "day": "Tuesday", "startTime": "0800", "endTime": "1000", - "venue": "S16-0309" + "venue": "S16-0309", + "lessonIndex": 47 }, { "classNo": "S05", @@ -450,7 +499,8 @@ "day": "Tuesday", "startTime": "1000", "endTime": "1200", - "venue": "S16-0309" + "venue": "S16-0309", + "lessonIndex": 48 }, { "classNo": "S06", @@ -459,7 +509,8 @@ "day": "Tuesday", "startTime": "1000", "endTime": "1200", - "venue": "S16-0309" + "venue": "S16-0309", + "lessonIndex": 49 }, { "classNo": "S08", @@ -468,7 +519,8 @@ "day": "Wednesday", "startTime": "1200", "endTime": "1400", - "venue": "S16-0309" + "venue": "S16-0309", + "lessonIndex": 50 }, { "classNo": "S09", @@ -477,7 +529,8 @@ "day": "Friday", "startTime": "0800", "endTime": "1000", - "venue": "S16-0202" + "venue": "S16-0202", + "lessonIndex": 51 }, { "classNo": "S10", @@ -486,7 +539,8 @@ "day": "Tuesday", "startTime": "1900", "endTime": "2100", - "venue": "S16-0202" + "venue": "S16-0202", + "lessonIndex": 52 }, { "classNo": "S11", @@ -495,7 +549,8 @@ "day": "Tuesday", "startTime": "1900", "endTime": "2100", - "venue": "S16-0202" + "venue": "S16-0202", + "lessonIndex": 53 }, { "classNo": "U01", @@ -504,7 +559,8 @@ "day": "Monday", "startTime": "1000", "endTime": "1200", - "venue": "ERC-SR10" + "venue": "ERC-SR10", + "lessonIndex": 54 }, { "classNo": "U02", @@ -513,7 +569,8 @@ "day": "Monday", "startTime": "1000", "endTime": "1200", - "venue": "ERC-SR10" + "venue": "ERC-SR10", + "lessonIndex": 55 }, { "classNo": "U03", @@ -522,7 +579,8 @@ "day": "Monday", "startTime": "1400", "endTime": "1600", - "venue": "ERC-SR8" + "venue": "ERC-SR8", + "lessonIndex": 56 }, { "classNo": "U04", @@ -531,7 +589,8 @@ "day": "Monday", "startTime": "1400", "endTime": "1600", - "venue": "ERC-SR8" + "venue": "ERC-SR8", + "lessonIndex": 57 }, { "classNo": "U05", @@ -540,7 +599,8 @@ "day": "Tuesday", "startTime": "1400", "endTime": "1600", - "venue": "ERC-SR8" + "venue": "ERC-SR8", + "lessonIndex": 58 }, { "classNo": "U06", @@ -549,7 +609,8 @@ "day": "Tuesday", "startTime": "1400", "endTime": "1600", - "venue": "ERC-SR8" + "venue": "ERC-SR8", + "lessonIndex": 59 }, { "classNo": "U07", @@ -558,7 +619,8 @@ "day": "Wednesday", "startTime": "1100", "endTime": "1300", - "venue": "ERC-GLR" + "venue": "ERC-GLR", + "lessonIndex": 60 }, { "classNo": "U08", @@ -567,7 +629,8 @@ "day": "Wednesday", "startTime": "1300", "endTime": "1500", - "venue": "ERC-GLR" + "venue": "ERC-GLR", + "lessonIndex": 61 }, { "classNo": "U09", @@ -576,7 +639,8 @@ "day": "Thursday", "startTime": "1400", "endTime": "1600", - "venue": "ERC-SR10" + "venue": "ERC-SR10", + "lessonIndex": 62 }, { "classNo": "U10", @@ -585,7 +649,8 @@ "day": "Thursday", "startTime": "1400", "endTime": "1600", - "venue": "ERC-SR10" + "venue": "ERC-SR10", + "lessonIndex": 63 }, { "classNo": "U11", @@ -594,7 +659,8 @@ "day": "Friday", "startTime": "1200", "endTime": "1400", - "venue": "ERC-SR10" + "venue": "ERC-SR10", + "lessonIndex": 64 }, { "classNo": "U12", @@ -603,7 +669,8 @@ "day": "Friday", "startTime": "1600", "endTime": "1800", - "venue": "ERC-SR10" + "venue": "ERC-SR10", + "lessonIndex": 65 }, { "classNo": "U13", @@ -612,7 +679,8 @@ "day": "Wednesday", "startTime": "1400", "endTime": "1600", - "venue": "ERC-SR10" + "venue": "ERC-SR10", + "lessonIndex": 66 }, { "classNo": "U14", @@ -621,7 +689,8 @@ "day": "Friday", "startTime": "1000", "endTime": "1200", - "venue": "ERC-SR8" + "venue": "ERC-SR8", + "lessonIndex": 67 } ] }, @@ -636,7 +705,8 @@ "day": "Monday", "startTime": "1000", "endTime": "1200", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 0 }, { "classNo": "A02", @@ -645,7 +715,8 @@ "day": "Monday", "startTime": "1000", "endTime": "1200", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 1 }, { "classNo": "A03", @@ -654,7 +725,8 @@ "day": "Monday", "startTime": "1200", "endTime": "1400", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 2 }, { "classNo": "A04", @@ -663,7 +735,8 @@ "day": "Monday", "startTime": "1200", "endTime": "1400", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 3 }, { "classNo": "A05", @@ -672,7 +745,8 @@ "day": "Monday", "startTime": "1400", "endTime": "1600", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 4 }, { "classNo": "A06", @@ -681,7 +755,8 @@ "day": "Monday", "startTime": "1400", "endTime": "1600", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 5 }, { "classNo": "A07", @@ -690,7 +765,8 @@ "day": "Tuesday", "startTime": "1000", "endTime": "1200", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 6 }, { "classNo": "A08", @@ -699,7 +775,8 @@ "day": "Tuesday", "startTime": "1000", "endTime": "1200", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 7 }, { "classNo": "A09", @@ -708,7 +785,8 @@ "day": "Tuesday", "startTime": "1200", "endTime": "1400", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 8 }, { "classNo": "A10", @@ -717,7 +795,8 @@ "day": "Tuesday", "startTime": "1200", "endTime": "1400", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 9 }, { "classNo": "A11", @@ -726,7 +805,8 @@ "day": "Tuesday", "startTime": "1400", "endTime": "1600", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 10 }, { "classNo": "A12", @@ -735,7 +815,8 @@ "day": "Tuesday", "startTime": "1400", "endTime": "1600", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 11 }, { "classNo": "A13", @@ -744,7 +825,8 @@ "day": "Tuesday", "startTime": "1600", "endTime": "1800", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 12 }, { "classNo": "A14", @@ -753,7 +835,8 @@ "day": "Tuesday", "startTime": "1600", "endTime": "1800", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 13 }, { "classNo": "A17", @@ -762,7 +845,8 @@ "day": "Wednesday", "startTime": "0800", "endTime": "1000", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 14 }, { "classNo": "A18", @@ -771,7 +855,8 @@ "day": "Wednesday", "startTime": "0800", "endTime": "1000", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 15 }, { "classNo": "A19", @@ -780,7 +865,8 @@ "day": "Wednesday", "startTime": "1000", "endTime": "1200", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 16 }, { "classNo": "A20", @@ -789,7 +875,8 @@ "day": "Wednesday", "startTime": "1000", "endTime": "1200", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 17 }, { "classNo": "A21", @@ -798,7 +885,8 @@ "day": "Wednesday", "startTime": "1200", "endTime": "1400", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 18 }, { "classNo": "A22", @@ -807,7 +895,8 @@ "day": "Wednesday", "startTime": "1200", "endTime": "1400", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 19 }, { "classNo": "A23", @@ -816,7 +905,8 @@ "day": "Wednesday", "startTime": "1400", "endTime": "1600", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 20 }, { "classNo": "A24", @@ -825,7 +915,8 @@ "day": "Wednesday", "startTime": "1400", "endTime": "1600", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 21 }, { "classNo": "A25", @@ -834,7 +925,8 @@ "day": "Wednesday", "startTime": "1600", "endTime": "1800", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 22 }, { "classNo": "A26", @@ -843,7 +935,8 @@ "day": "Wednesday", "startTime": "1600", "endTime": "1800", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 23 }, { "classNo": "A27", @@ -852,7 +945,8 @@ "day": "Thursday", "startTime": "1000", "endTime": "1200", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 24 }, { "classNo": "A28", @@ -861,7 +955,8 @@ "day": "Thursday", "startTime": "1200", "endTime": "1400", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 25 }, { "classNo": "A29", @@ -870,7 +965,8 @@ "day": "Thursday", "startTime": "1400", "endTime": "1600", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 26 }, { "classNo": "A30", @@ -879,7 +975,8 @@ "day": "Thursday", "startTime": "1600", "endTime": "1800", - "venue": "AS1-0207" + "venue": "AS1-0207", + "lessonIndex": 27 }, { "classNo": "B01", @@ -888,7 +985,8 @@ "day": "Monday", "startTime": "1000", "endTime": "1200", - "venue": "BIZ2-0118" + "venue": "BIZ2-0118", + "lessonIndex": 28 }, { "classNo": "B02", @@ -897,7 +995,8 @@ "day": "Monday", "startTime": "1000", "endTime": "1200", - "venue": "BIZ2-0118" + "venue": "BIZ2-0118", + "lessonIndex": 29 }, { "classNo": "B03", @@ -906,7 +1005,8 @@ "day": "Tuesday", "startTime": "1400", "endTime": "1600", - "venue": "BIZ2-0118" + "venue": "BIZ2-0118", + "lessonIndex": 30 }, { "classNo": "B04", @@ -915,7 +1015,8 @@ "day": "Tuesday", "startTime": "1400", "endTime": "1600", - "venue": "BIZ2-0118" + "venue": "BIZ2-0118", + "lessonIndex": 31 }, { "classNo": "B05", @@ -924,7 +1025,8 @@ "day": "Wednesday", "startTime": "1000", "endTime": "1200", - "venue": "BIZ2-0118" + "venue": "BIZ2-0118", + "lessonIndex": 32 }, { "classNo": "B06", @@ -933,7 +1035,8 @@ "day": "Wednesday", "startTime": "1000", "endTime": "1200", - "venue": "BIZ2-0118" + "venue": "BIZ2-0118", + "lessonIndex": 33 }, { "classNo": "B07", @@ -942,7 +1045,8 @@ "day": "Wednesday", "startTime": "1400", "endTime": "1600", - "venue": "BIZ2-0118" + "venue": "BIZ2-0118", + "lessonIndex": 34 }, { "classNo": "B08", @@ -951,7 +1055,8 @@ "day": "Wednesday", "startTime": "1400", "endTime": "1600", - "venue": "BIZ2-0118" + "venue": "BIZ2-0118", + "lessonIndex": 35 }, { "classNo": "B09", @@ -960,7 +1065,8 @@ "day": "Tuesday", "startTime": "1200", "endTime": "1400", - "venue": "BIZ2-0118" + "venue": "BIZ2-0118", + "lessonIndex": 36 }, { "classNo": "B10", @@ -969,7 +1075,8 @@ "day": "Tuesday", "startTime": "1200", "endTime": "1400", - "venue": "BIZ2-0118" + "venue": "BIZ2-0118", + "lessonIndex": 37 }, { "classNo": "B11", @@ -978,7 +1085,8 @@ "day": "Wednesday", "startTime": "1600", "endTime": "1800", - "venue": "BIZ2-0118" + "venue": "BIZ2-0118", + "lessonIndex": 38 }, { "classNo": "B12", @@ -987,7 +1095,8 @@ "day": "Wednesday", "startTime": "1600", "endTime": "1800", - "venue": "BIZ2-0118" + "venue": "BIZ2-0118", + "lessonIndex": 39 }, { "classNo": "B13", @@ -996,7 +1105,8 @@ "day": "Thursday", "startTime": "0800", "endTime": "1000", - "venue": "BIZ2-0118" + "venue": "BIZ2-0118", + "lessonIndex": 40 }, { "classNo": "B14", @@ -1005,7 +1115,8 @@ "day": "Thursday", "startTime": "1200", "endTime": "1400", - "venue": "BIZ2-0224" + "venue": "BIZ2-0224", + "lessonIndex": 41 }, { "classNo": "B15", @@ -1014,7 +1125,8 @@ "day": "Thursday", "startTime": "1200", "endTime": "1400", - "venue": "BIZ2-0118" + "venue": "BIZ2-0118", + "lessonIndex": 42 }, { "classNo": "B16", @@ -1023,7 +1135,8 @@ "day": "Thursday", "startTime": "1600", "endTime": "1800", - "venue": "BIZ2-0224" + "venue": "BIZ2-0224", + "lessonIndex": 43 }, { "classNo": "B17", @@ -1032,7 +1145,8 @@ "day": "Thursday", "startTime": "1600", "endTime": "1800", - "venue": "BIZ2-0118" + "venue": "BIZ2-0118", + "lessonIndex": 44 }, { "classNo": "C01", @@ -1041,7 +1155,8 @@ "day": "Monday", "startTime": "1200", "endTime": "1400", - "venue": "COM2-0108" + "venue": "COM2-0108", + "lessonIndex": 45 }, { "classNo": "C02", @@ -1050,7 +1165,8 @@ "day": "Monday", "startTime": "1200", "endTime": "1400", - "venue": "COM2-0108" + "venue": "COM2-0108", + "lessonIndex": 46 }, { "classNo": "C03", @@ -1059,7 +1175,8 @@ "day": "Tuesday", "startTime": "1600", "endTime": "1800", - "venue": "COM2-0108" + "venue": "COM2-0108", + "lessonIndex": 47 }, { "classNo": "C04", @@ -1068,7 +1185,8 @@ "day": "Tuesday", "startTime": "1600", "endTime": "1800", - "venue": "COM2-0108" + "venue": "COM2-0108", + "lessonIndex": 48 }, { "classNo": "C05", @@ -1077,7 +1195,8 @@ "day": "Thursday", "startTime": "1000", "endTime": "1200", - "venue": "COM2-0108" + "venue": "COM2-0108", + "lessonIndex": 49 }, { "classNo": "C06", @@ -1086,7 +1205,8 @@ "day": "Thursday", "startTime": "1400", "endTime": "1600", - "venue": "COM2-0108" + "venue": "COM2-0108", + "lessonIndex": 50 }, { "classNo": "D01", @@ -1095,7 +1215,8 @@ "day": "Monday", "startTime": "1100", "endTime": "1300", - "venue": "SDE-SR14" + "venue": "SDE-SR14", + "lessonIndex": 51 }, { "classNo": "D02", @@ -1104,7 +1225,8 @@ "day": "Monday", "startTime": "1100", "endTime": "1300", - "venue": "SDE-SR14" + "venue": "SDE-SR14", + "lessonIndex": 52 }, { "classNo": "D03", @@ -1113,7 +1235,8 @@ "day": "Monday", "startTime": "1300", "endTime": "1500", - "venue": "SDE-SR12" + "venue": "SDE-SR12", + "lessonIndex": 53 }, { "classNo": "D04", @@ -1122,7 +1245,8 @@ "day": "Monday", "startTime": "1300", "endTime": "1500", - "venue": "SDE-SR12" + "venue": "SDE-SR12", + "lessonIndex": 54 }, { "classNo": "D05", @@ -1131,7 +1255,8 @@ "day": "Tuesday", "startTime": "0900", "endTime": "1100", - "venue": "SDE-SR14" + "venue": "SDE-SR14", + "lessonIndex": 55 }, { "classNo": "D06", @@ -1140,7 +1265,8 @@ "day": "Tuesday", "startTime": "0900", "endTime": "1100", - "venue": "SDE-SR14" + "venue": "SDE-SR14", + "lessonIndex": 56 }, { "classNo": "D07", @@ -1149,7 +1275,8 @@ "day": "Tuesday", "startTime": "0900", "endTime": "1100", - "venue": "SDE-SR12" + "venue": "SDE-SR12", + "lessonIndex": 57 }, { "classNo": "D08", @@ -1158,7 +1285,8 @@ "day": "Tuesday", "startTime": "0900", "endTime": "1100", - "venue": "SDE-SR12" + "venue": "SDE-SR12", + "lessonIndex": 58 }, { "classNo": "D09", @@ -1167,7 +1295,8 @@ "day": "Thursday", "startTime": "1100", "endTime": "1300", - "venue": "SDE-SR12" + "venue": "SDE-SR12", + "lessonIndex": 59 }, { "classNo": "E01", @@ -1176,7 +1305,8 @@ "day": "Monday", "startTime": "0800", "endTime": "1000", - "venue": "E1-06-16" + "venue": "E1-06-16", + "lessonIndex": 60 }, { "classNo": "E02", @@ -1185,7 +1315,8 @@ "day": "Monday", "startTime": "0800", "endTime": "1000", - "venue": "E1-06-16" + "venue": "E1-06-16", + "lessonIndex": 61 }, { "classNo": "E03", @@ -1194,7 +1325,8 @@ "day": "Monday", "startTime": "1000", "endTime": "1200", - "venue": "E1-06-16" + "venue": "E1-06-16", + "lessonIndex": 62 }, { "classNo": "E04", @@ -1203,7 +1335,8 @@ "day": "Monday", "startTime": "1000", "endTime": "1200", - "venue": "E1-06-16" + "venue": "E1-06-16", + "lessonIndex": 63 }, { "classNo": "E05", @@ -1212,7 +1345,8 @@ "day": "Monday", "startTime": "1200", "endTime": "1400", - "venue": "E1-06-16" + "venue": "E1-06-16", + "lessonIndex": 64 }, { "classNo": "E06", @@ -1221,7 +1355,8 @@ "day": "Monday", "startTime": "1200", "endTime": "1400", - "venue": "E1-06-16" + "venue": "E1-06-16", + "lessonIndex": 65 }, { "classNo": "E07", @@ -1230,7 +1365,8 @@ "day": "Monday", "startTime": "1400", "endTime": "1600", - "venue": "E1-06-16" + "venue": "E1-06-16", + "lessonIndex": 66 }, { "classNo": "E08", @@ -1239,7 +1375,8 @@ "day": "Monday", "startTime": "1400", "endTime": "1600", - "venue": "E1-06-16" + "venue": "E1-06-16", + "lessonIndex": 67 }, { "classNo": "E09", @@ -1248,7 +1385,8 @@ "day": "Tuesday", "startTime": "0800", "endTime": "1000", - "venue": "E1-06-16" + "venue": "E1-06-16", + "lessonIndex": 68 }, { "classNo": "E10", @@ -1257,7 +1395,8 @@ "day": "Tuesday", "startTime": "0800", "endTime": "1000", - "venue": "E1-06-16" + "venue": "E1-06-16", + "lessonIndex": 69 }, { "classNo": "E11", @@ -1266,7 +1405,8 @@ "day": "Tuesday", "startTime": "1000", "endTime": "1200", - "venue": "E1-06-16" + "venue": "E1-06-16", + "lessonIndex": 70 }, { "classNo": "E12", @@ -1275,7 +1415,8 @@ "day": "Tuesday", "startTime": "1000", "endTime": "1200", - "venue": "E1-06-16" + "venue": "E1-06-16", + "lessonIndex": 71 }, { "classNo": "E13", @@ -1284,7 +1425,8 @@ "day": "Tuesday", "startTime": "1200", "endTime": "1400", - "venue": "E1-06-16" + "venue": "E1-06-16", + "lessonIndex": 72 }, { "classNo": "E14", @@ -1293,7 +1435,8 @@ "day": "Tuesday", "startTime": "1200", "endTime": "1400", - "venue": "E1-06-16" + "venue": "E1-06-16", + "lessonIndex": 73 }, { "classNo": "E15", @@ -1302,7 +1445,8 @@ "day": "Tuesday", "startTime": "1400", "endTime": "1600", - "venue": "E1-06-16" + "venue": "E1-06-16", + "lessonIndex": 74 }, { "classNo": "E16", @@ -1311,7 +1455,8 @@ "day": "Tuesday", "startTime": "1400", "endTime": "1600", - "venue": "E1-06-16" + "venue": "E1-06-16", + "lessonIndex": 75 }, { "classNo": "E17", @@ -1320,7 +1465,8 @@ "day": "Tuesday", "startTime": "1600", "endTime": "1800", - "venue": "E1-06-16" + "venue": "E1-06-16", + "lessonIndex": 76 }, { "classNo": "E18", @@ -1329,7 +1475,8 @@ "day": "Tuesday", "startTime": "1600", "endTime": "1800", - "venue": "E1-06-16" + "venue": "E1-06-16", + "lessonIndex": 77 }, { "classNo": "E19", @@ -1338,7 +1485,8 @@ "day": "Wednesday", "startTime": "1000", "endTime": "1200", - "venue": "E1-06-16" + "venue": "E1-06-16", + "lessonIndex": 78 }, { "classNo": "E20", @@ -1347,7 +1495,8 @@ "day": "Wednesday", "startTime": "1000", "endTime": "1200", - "venue": "E1-06-16" + "venue": "E1-06-16", + "lessonIndex": 79 }, { "classNo": "E21", @@ -1356,7 +1505,8 @@ "day": "Wednesday", "startTime": "1400", "endTime": "1600", - "venue": "E1-06-16" + "venue": "E1-06-16", + "lessonIndex": 80 }, { "classNo": "E22", @@ -1365,7 +1515,8 @@ "day": "Wednesday", "startTime": "1400", "endTime": "1600", - "venue": "E1-06-16" + "venue": "E1-06-16", + "lessonIndex": 81 }, { "classNo": "E23", @@ -1374,7 +1525,8 @@ "day": "Thursday", "startTime": "1200", "endTime": "1400", - "venue": "E1-06-16" + "venue": "E1-06-16", + "lessonIndex": 82 }, { "classNo": "E24", @@ -1383,7 +1535,8 @@ "day": "Thursday", "startTime": "1400", "endTime": "1600", - "venue": "E1-06-16" + "venue": "E1-06-16", + "lessonIndex": 83 }, { "classNo": "S01", @@ -1392,7 +1545,8 @@ "day": "Monday", "startTime": "0800", "endTime": "1000", - "venue": "S16-0309" + "venue": "S16-0309", + "lessonIndex": 84 }, { "classNo": "S02", @@ -1401,7 +1555,8 @@ "day": "Monday", "startTime": "0800", "endTime": "1000", - "venue": "S16-0309" + "venue": "S16-0309", + "lessonIndex": 85 }, { "classNo": "S03", @@ -1410,7 +1565,8 @@ "day": "Monday", "startTime": "1000", "endTime": "1200", - "venue": "S16-0309" + "venue": "S16-0309", + "lessonIndex": 86 }, { "classNo": "S04", @@ -1419,7 +1575,8 @@ "day": "Monday", "startTime": "1000", "endTime": "1200", - "venue": "S16-0309" + "venue": "S16-0309", + "lessonIndex": 87 }, { "classNo": "S05", @@ -1428,7 +1585,8 @@ "day": "Monday", "startTime": "1400", "endTime": "1600", - "venue": "S16-0309" + "venue": "S16-0309", + "lessonIndex": 88 }, { "classNo": "S06", @@ -1437,7 +1595,8 @@ "day": "Monday", "startTime": "1400", "endTime": "1600", - "venue": "S16-0309" + "venue": "S16-0309", + "lessonIndex": 89 }, { "classNo": "S07", @@ -1446,7 +1605,8 @@ "day": "Tuesday", "startTime": "0800", "endTime": "1000", - "venue": "S16-0309" + "venue": "S16-0309", + "lessonIndex": 90 }, { "classNo": "S08", @@ -1455,7 +1615,8 @@ "day": "Tuesday", "startTime": "0800", "endTime": "1000", - "venue": "S16-0309" + "venue": "S16-0309", + "lessonIndex": 91 }, { "classNo": "S09", @@ -1464,7 +1625,8 @@ "day": "Tuesday", "startTime": "1000", "endTime": "1200", - "venue": "S16-0309" + "venue": "S16-0309", + "lessonIndex": 92 }, { "classNo": "S10", @@ -1473,7 +1635,8 @@ "day": "Tuesday", "startTime": "1000", "endTime": "1200", - "venue": "S16-0309" + "venue": "S16-0309", + "lessonIndex": 93 }, { "classNo": "S11", @@ -1482,7 +1645,8 @@ "day": "Tuesday", "startTime": "1200", "endTime": "1400", - "venue": "S16-0309" + "venue": "S16-0309", + "lessonIndex": 94 }, { "classNo": "S12", @@ -1491,7 +1655,8 @@ "day": "Tuesday", "startTime": "1200", "endTime": "1400", - "venue": "S16-0309" + "venue": "S16-0309", + "lessonIndex": 95 }, { "classNo": "S13", @@ -1500,7 +1665,8 @@ "day": "Tuesday", "startTime": "1400", "endTime": "1600", - "venue": "S16-0309" + "venue": "S16-0309", + "lessonIndex": 96 }, { "classNo": "S14", @@ -1509,7 +1675,8 @@ "day": "Tuesday", "startTime": "1400", "endTime": "1600", - "venue": "S16-0309" + "venue": "S16-0309", + "lessonIndex": 97 }, { "classNo": "S15", @@ -1518,7 +1685,8 @@ "day": "Wednesday", "startTime": "0800", "endTime": "1000", - "venue": "S16-0309" + "venue": "S16-0309", + "lessonIndex": 98 }, { "classNo": "S16", @@ -1527,7 +1695,8 @@ "day": "Wednesday", "startTime": "0800", "endTime": "1000", - "venue": "S16-0309" + "venue": "S16-0309", + "lessonIndex": 99 }, { "classNo": "S17", @@ -1536,7 +1705,8 @@ "day": "Wednesday", "startTime": "1000", "endTime": "1200", - "venue": "S16-0309" + "venue": "S16-0309", + "lessonIndex": 100 }, { "classNo": "S18", @@ -1545,7 +1715,8 @@ "day": "Wednesday", "startTime": "1000", "endTime": "1200", - "venue": "S16-0309" + "venue": "S16-0309", + "lessonIndex": 101 }, { "classNo": "S19", @@ -1554,7 +1725,8 @@ "day": "Wednesday", "startTime": "1200", "endTime": "1400", - "venue": "S16-0309" + "venue": "S16-0309", + "lessonIndex": 102 }, { "classNo": "S20", @@ -1563,7 +1735,8 @@ "day": "Wednesday", "startTime": "1200", "endTime": "1400", - "venue": "S16-0309" + "venue": "S16-0309", + "lessonIndex": 103 }, { "classNo": "S21", @@ -1572,7 +1745,8 @@ "day": "Wednesday", "startTime": "1400", "endTime": "1600", - "venue": "S16-0309" + "venue": "S16-0309", + "lessonIndex": 104 }, { "classNo": "S22", @@ -1581,7 +1755,8 @@ "day": "Wednesday", "startTime": "1400", "endTime": "1600", - "venue": "S16-0309" + "venue": "S16-0309", + "lessonIndex": 105 }, { "classNo": "S23", @@ -1590,7 +1765,8 @@ "day": "Wednesday", "startTime": "1600", "endTime": "1800", - "venue": "S16-0309" + "venue": "S16-0309", + "lessonIndex": 106 }, { "classNo": "S24", @@ -1599,7 +1775,8 @@ "day": "Wednesday", "startTime": "1600", "endTime": "1800", - "venue": "S16-0309" + "venue": "S16-0309", + "lessonIndex": 107 }, { "classNo": "S25", @@ -1608,7 +1785,8 @@ "day": "Thursday", "startTime": "0800", "endTime": "1000", - "venue": "S16-0309" + "venue": "S16-0309", + "lessonIndex": 108 }, { "classNo": "S26", @@ -1617,7 +1795,8 @@ "day": "Thursday", "startTime": "1000", "endTime": "1200", - "venue": "S16-0309" + "venue": "S16-0309", + "lessonIndex": 109 }, { "classNo": "S27", @@ -1626,7 +1805,8 @@ "day": "Thursday", "startTime": "1400", "endTime": "1600", - "venue": "S16-0309" + "venue": "S16-0309", + "lessonIndex": 110 }, { "classNo": "S28", @@ -1635,7 +1815,8 @@ "day": "Thursday", "startTime": "1000", "endTime": "1200", - "venue": "S16-0437" + "venue": "S16-0437", + "lessonIndex": 111 }, { "classNo": "S29", @@ -1644,7 +1825,8 @@ "day": "Monday", "startTime": "1200", "endTime": "1400", - "venue": "S16-0309" + "venue": "S16-0309", + "lessonIndex": 112 }, { "classNo": "S30", @@ -1653,7 +1835,8 @@ "day": "Monday", "startTime": "1200", "endTime": "1400", - "venue": "S16-0309" + "venue": "S16-0309", + "lessonIndex": 113 }, { "classNo": "U01", @@ -1662,7 +1845,8 @@ "day": "Monday", "startTime": "1400", "endTime": "1600", - "venue": "TP-SR9" + "venue": "TP-SR9", + "lessonIndex": 114 }, { "classNo": "U02", @@ -1671,7 +1855,8 @@ "day": "Monday", "startTime": "1400", "endTime": "1600", - "venue": "TP-SR9" + "venue": "TP-SR9", + "lessonIndex": 115 }, { "classNo": "U03", @@ -1680,7 +1865,8 @@ "day": "Tuesday", "startTime": "1600", "endTime": "1800", - "venue": "UTSRC-GLR" + "venue": "UTSRC-GLR", + "lessonIndex": 116 }, { "classNo": "U04", @@ -1689,7 +1875,8 @@ "day": "Tuesday", "startTime": "1600", "endTime": "1800", - "venue": "UTSRC-GLR" + "venue": "UTSRC-GLR", + "lessonIndex": 117 }, { "classNo": "U05", @@ -1698,7 +1885,8 @@ "day": "Wednesday", "startTime": "1200", "endTime": "1400", - "venue": "ERC-SR9CAM" + "venue": "ERC-SR9CAM", + "lessonIndex": 118 }, { "classNo": "U06", @@ -1707,7 +1895,8 @@ "day": "Wednesday", "startTime": "1200", "endTime": "1400", - "venue": "ERC-SR9CAM" + "venue": "ERC-SR9CAM", + "lessonIndex": 119 }, { "classNo": "U07", @@ -1716,7 +1905,8 @@ "day": "Wednesday", "startTime": "1200", "endTime": "1400", - "venue": "ERC-SR10" + "venue": "ERC-SR10", + "lessonIndex": 120 }, { "classNo": "U08", @@ -1725,7 +1915,8 @@ "day": "Wednesday", "startTime": "1200", "endTime": "1400", - "venue": "ERC-SR10" + "venue": "ERC-SR10", + "lessonIndex": 121 }, { "classNo": "U09", @@ -1734,7 +1925,8 @@ "day": "Thursday", "startTime": "0800", "endTime": "1000", - "venue": "ERC-SR8" + "venue": "ERC-SR8", + "lessonIndex": 122 }, { "classNo": "U10", @@ -1743,7 +1935,8 @@ "day": "Thursday", "startTime": "1000", "endTime": "1200", - "venue": "UTSRC-GLR" + "venue": "UTSRC-GLR", + "lessonIndex": 123 }, { "classNo": "U11", @@ -1752,7 +1945,8 @@ "day": "Tuesday", "startTime": "1900", "endTime": "2100", - "venue": "TP-SR9" + "venue": "TP-SR9", + "lessonIndex": 124 }, { "classNo": "U12", @@ -1761,7 +1955,8 @@ "day": "Tuesday", "startTime": "1900", "endTime": "2100", - "venue": "TP-SR9" + "venue": "TP-SR9", + "lessonIndex": 125 }, { "classNo": "U13", @@ -1770,7 +1965,8 @@ "day": "Wednesday", "startTime": "1900", "endTime": "2100", - "venue": "ERC-SR8" + "venue": "ERC-SR8", + "lessonIndex": 126 }, { "classNo": "U14", @@ -1779,7 +1975,8 @@ "day": "Wednesday", "startTime": "1900", "endTime": "2100", - "venue": "ERC-SR8" + "venue": "ERC-SR8", + "lessonIndex": 127 }, { "classNo": "U15", @@ -1788,7 +1985,8 @@ "day": "Wednesday", "startTime": "1600", "endTime": "1800", - "venue": "UTSRC-GLR" + "venue": "UTSRC-GLR", + "lessonIndex": 128 }, { "classNo": "U16", @@ -1797,7 +1995,8 @@ "day": "Wednesday", "startTime": "1600", "endTime": "1800", - "venue": "UTSRC-GLR" + "venue": "UTSRC-GLR", + "lessonIndex": 129 } ] } diff --git a/website/src/__mocks__/modules/GES1021.json b/website/src/__mocks__/modules/GES1021.json index 8b40e699de..8997d4eccf 100644 --- a/website/src/__mocks__/modules/GES1021.json +++ b/website/src/__mocks__/modules/GES1021.json @@ -26,7 +26,8 @@ "day": "Monday", "startTime": "1600", "endTime": "1800", - "venue": "LT27" + "venue": "LT27", + "lessonIndex": 0 }, { "classNo": "SL1", @@ -35,7 +36,8 @@ "day": "Thursday", "startTime": "1600", "endTime": "1800", - "venue": "LT27" + "venue": "LT27", + "lessonIndex": 1 } ], "LecturePeriods": [ @@ -54,7 +56,8 @@ "day": "Monday", "startTime": "1600", "endTime": "1800", - "venue": "LT27" + "venue": "LT27", + "lessonIndex": 0 }, { "classNo": "SL1", @@ -63,7 +66,8 @@ "day": "Wednesday", "startTime": "1600", "endTime": "1800", - "venue": "LT27" + "venue": "LT27", + "lessonIndex": 1 } ] } diff --git a/website/src/__mocks__/modules/PC1222.json b/website/src/__mocks__/modules/PC1222.json index d1dbc01944..6f7f537dbb 100644 --- a/website/src/__mocks__/modules/PC1222.json +++ b/website/src/__mocks__/modules/PC1222.json @@ -21,7 +21,8 @@ "day": "Monday", "startTime": "1400", "endTime": "1700", - "venue": "S12-0402" + "venue": "S12-0402", + "lessonIndex": 0 }, { "classNo": "U02", @@ -30,7 +31,8 @@ "day": "Tuesday", "startTime": "1400", "endTime": "1700", - "venue": "S12-0402" + "venue": "S12-0402", + "lessonIndex": 1 }, { "classNo": "W01", @@ -39,7 +41,8 @@ "day": "Wednesday", "startTime": "1400", "endTime": "1700", - "venue": "S12-0402" + "venue": "S12-0402", + "lessonIndex": 2 }, { "classNo": "W02", @@ -48,7 +51,8 @@ "day": "Wednesday", "startTime": "1400", "endTime": "1700", - "venue": "S12-0402" + "venue": "S12-0402", + "lessonIndex": 3 }, { "classNo": "SL1", @@ -57,7 +61,8 @@ "day": "Tuesday", "startTime": "1000", "endTime": "1200", - "venue": "LT31" + "venue": "LT31", + "lessonIndex": 4 }, { "classNo": "SL1", @@ -66,7 +71,8 @@ "day": "Friday", "startTime": "1000", "endTime": "1200", - "venue": "LT31" + "venue": "LT31", + "lessonIndex": 5 }, { "classNo": "ST1", @@ -75,7 +81,8 @@ "day": "Monday", "startTime": "1700", "endTime": "1800", - "venue": "S12-0401" + "venue": "S12-0401", + "lessonIndex": 6 }, { "classNo": "ST2", @@ -84,7 +91,8 @@ "day": "Tuesday", "startTime": "1700", "endTime": "1800", - "venue": "S12-0401" + "venue": "S12-0401", + "lessonIndex": 7 } ], "LecturePeriods": ["Tuesday Morning", "Friday Morning"], @@ -101,7 +109,8 @@ "day": "Friday", "startTime": "1400", "endTime": "1700", - "venue": "S12-0402" + "venue": "S12-0402", + "lessonIndex": 0 }, { "classNo": "F02", @@ -110,7 +119,8 @@ "day": "Friday", "startTime": "1400", "endTime": "1700", - "venue": "S12-0402" + "venue": "S12-0402", + "lessonIndex": 1 }, { "classNo": "M01", @@ -119,7 +129,8 @@ "day": "Monday", "startTime": "1400", "endTime": "1700", - "venue": "S12-0402" + "venue": "S12-0402", + "lessonIndex": 2 }, { "classNo": "M02", @@ -128,7 +139,8 @@ "day": "Monday", "startTime": "1400", "endTime": "1700", - "venue": "S12-0402" + "venue": "S12-0402", + "lessonIndex": 3 }, { "classNo": "T01", @@ -137,7 +149,8 @@ "day": "Thursday", "startTime": "1400", "endTime": "1700", - "venue": "S12-0402" + "venue": "S12-0402", + "lessonIndex": 4 }, { "classNo": "T02", @@ -146,7 +159,8 @@ "day": "Thursday", "startTime": "1400", "endTime": "1700", - "venue": "S12-0402" + "venue": "S12-0402", + "lessonIndex": 5 }, { "classNo": "U01", @@ -155,7 +169,8 @@ "day": "Tuesday", "startTime": "1400", "endTime": "1700", - "venue": "S12-0402" + "venue": "S12-0402", + "lessonIndex": 6 }, { "classNo": "U02", @@ -164,7 +179,8 @@ "day": "Tuesday", "startTime": "1400", "endTime": "1700", - "venue": "S12-0402" + "venue": "S12-0402", + "lessonIndex": 7 }, { "classNo": "W02", @@ -173,7 +189,8 @@ "day": "Wednesday", "startTime": "1400", "endTime": "1700", - "venue": "S12-0402" + "venue": "S12-0402", + "lessonIndex": 8 }, { "classNo": "WM2", @@ -182,7 +199,8 @@ "day": "Wednesday", "startTime": "0900", "endTime": "1200", - "venue": "S12-0402" + "venue": "S12-0402", + "lessonIndex": 9 }, { "classNo": "SL1", @@ -191,7 +209,8 @@ "day": "Tuesday", "startTime": "1200", "endTime": "1400", - "venue": "LT25" + "venue": "LT25", + "lessonIndex": 10 }, { "classNo": "SL1", @@ -200,7 +219,8 @@ "day": "Friday", "startTime": "1200", "endTime": "1400", - "venue": "LT25" + "venue": "LT25", + "lessonIndex": 11 }, { "classNo": "T1", @@ -209,7 +229,8 @@ "day": "Monday", "startTime": "1600", "endTime": "1700", - "venue": "S11-0204" + "venue": "S11-0204", + "lessonIndex": 12 }, { "classNo": "T10", @@ -218,7 +239,8 @@ "day": "Friday", "startTime": "1700", "endTime": "1800", - "venue": "S11-0204" + "venue": "S11-0204", + "lessonIndex": 13 }, { "classNo": "T11", @@ -227,7 +249,8 @@ "day": "Wednesday", "startTime": "0900", "endTime": "1000", - "venue": "CQT/SR0315" + "venue": "CQT/SR0315", + "lessonIndex": 14 }, { "classNo": "T2", @@ -236,7 +259,8 @@ "day": "Monday", "startTime": "1700", "endTime": "1800", - "venue": "S11-0204" + "venue": "S11-0204", + "lessonIndex": 15 }, { "classNo": "T3", @@ -245,7 +269,8 @@ "day": "Tuesday", "startTime": "1600", "endTime": "1700", - "venue": "S11-0204" + "venue": "S11-0204", + "lessonIndex": 16 }, { "classNo": "T4", @@ -254,7 +279,8 @@ "day": "Tuesday", "startTime": "1700", "endTime": "1800", - "venue": "S11-0204" + "venue": "S11-0204", + "lessonIndex": 17 }, { "classNo": "T5", @@ -263,7 +289,8 @@ "day": "Thursday", "startTime": "1500", "endTime": "1600", - "venue": "S11-0204" + "venue": "S11-0204", + "lessonIndex": 18 }, { "classNo": "T6", @@ -272,7 +299,8 @@ "day": "Thursday", "startTime": "1600", "endTime": "1700", - "venue": "S11-0204" + "venue": "S11-0204", + "lessonIndex": 19 }, { "classNo": "T7", @@ -281,7 +309,8 @@ "day": "Thursday", "startTime": "1700", "endTime": "1800", - "venue": "S11-0204" + "venue": "S11-0204", + "lessonIndex": 20 }, { "classNo": "T8", @@ -290,7 +319,8 @@ "day": "Friday", "startTime": "1500", "endTime": "1600", - "venue": "S11-0204" + "venue": "S11-0204", + "lessonIndex": 21 }, { "classNo": "T9", @@ -299,7 +329,8 @@ "day": "Friday", "startTime": "1600", "endTime": "1700", - "venue": "S11-0204" + "venue": "S11-0204", + "lessonIndex": 22 } ], "LecturePeriods": ["Tuesday Afternoon", "Friday Afternoon"], diff --git a/website/src/__mocks__/modules/index.ts b/website/src/__mocks__/modules/index.ts index 8d4d9109a2..fe9fa2444b 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 GER1000_JSON from './GER1000.json'; // Have to cast these as Module explicitly, otherwise TS will try to // incorrectly infer the shape from the JSON - specifically Weeks will @@ -22,6 +23,7 @@ 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 PC1222: Module = { ...PC1222_JSON, timestamp: 1572843950000 }; +export const GER1000: Module = { ...GER1000_JSON, timestamp: 1572843950000 }; const modules: Module[] = [ACC2002, BFS1001, CS1010S, CS3216, GES1021, PC1222, CS1010A]; export default modules; diff --git a/website/src/__mocks__/sem-timetable.json b/website/src/__mocks__/sem-timetable.json index fc5e2b0601..c1bd0bf7f9 100644 --- a/website/src/__mocks__/sem-timetable.json +++ b/website/src/__mocks__/sem-timetable.json @@ -9,7 +9,8 @@ "endTime": "1200", "venue": "LT26", "moduleCode": "CS1010S", - "title": "Programming Methodology" + "title": "Programming Methodology", + "lessonIndex": 0 }], "Recitation": [{ "classNo": "1", @@ -20,7 +21,8 @@ "endTime": "1100", "venue": "VCRm", "moduleCode": "CS1010S", - "title": "Programming Methodology" + "title": "Programming Methodology", + "lessonIndex": 1 }, { "classNo": "1", "lessonType": "Recitation", @@ -30,7 +32,8 @@ "endTime": "1300", "venue": "VCRm", "moduleCode": "CS1010S", - "title": "Programming Methodology" + "title": "Programming Methodology", + "lessonIndex": 2 }], "Tutorial": [{ "classNo": "2", @@ -41,7 +44,8 @@ "endTime": "1000", "venue": "COM1-0203", "moduleCode": "CS1010S", - "title": "Programming Methodology" + "title": "Programming Methodology", + "lessonIndex": 3 }, { "classNo": "2", "lessonType": "Tutorial", @@ -51,7 +55,8 @@ "endTime": "1000", "venue": "COM1-0216", "moduleCode": "CS1010S", - "title": "Programming Methodology" + "title": "Programming Methodology", + "lessonIndex": 4 }] }, "CS3216": { @@ -64,7 +69,8 @@ "endTime": "2030", "venue": "VCRm", "moduleCode": "CS3216", - "title": "Application Development on Evolving Platforms" + "title": "Application Development on Evolving Platforms", + "lessonIndex": 0 }] } } diff --git a/website/src/actions/__snapshots__/timetables.test.ts.snap b/website/src/actions/__snapshots__/timetables.test.ts.snap index edba593353..dbcbbcb6e3 100644 --- a/website/src/actions/__snapshots__/timetables.test.ts.snap +++ b/website/src/actions/__snapshots__/timetables.test.ts.snap @@ -10,7 +10,9 @@ exports[`cancelModifyLesson should not have payload 1`] = ` exports[`changeLesson should return updated information to change lesson 1`] = ` { "payload": { - "classNo": "1", + "lessonIndices": [ + 1, + ], "lessonType": "Recitation", "moduleCode": "CS1010S", "semester": 1, @@ -55,6 +57,7 @@ exports[`modifyLesson should return lesson payload 1`] = ` "colorIndex": 0, "day": "Wednesday", "endTime": "1200", + "lessonIndex": 0, "lessonType": "Lecture", "moduleCode": "CS1010S", "startTime": "1000", diff --git a/website/src/actions/export.ts b/website/src/actions/export.ts index 7667b25b32..71012a5ad3 100644 --- a/website/src/actions/export.ts +++ b/website/src/actions/export.ts @@ -1,15 +1,12 @@ import type { Module, ModuleCode, Semester } from 'types/modules'; import type { ExportData } from 'types/export'; import type { Dispatch, GetState } from 'types/redux'; -import { - hydrateSemTimetableWithLessons, - hydrateTaModulesConfigWithLessons, -} from 'utils/timetables'; +import { hydrateSemTimetableWithLessons } from 'utils/timetables'; import { captureException } from 'utils/error'; import retryImport from 'utils/retryImport'; import { getSemesterTimetableLessons } from 'selectors/timetables'; -import { TaModulesConfig } from 'types/timetables'; import { PlannerStateSchema } from 'types/schemas/planner'; +import { TaModulesConfig } from 'types/timetables'; import { SET_EXPORTED_DATA } from './constants'; import { openNotification } from './app'; @@ -38,23 +35,14 @@ export function downloadAsIcal(semester: Semester) { const state = getState(); const { modules } = state.moduleBank; const hiddenModules: ModuleCode[] = state.timetables.hidden[semester] ?? []; - const taModules: TaModulesConfig = state.timetables.ta[semester] ?? {}; + const taModules: TaModulesConfig = state.timetables.ta[semester] ?? []; const timetable = getSemesterTimetableLessons(state)(semester); const timetableWithLessons = hydrateSemTimetableWithLessons(timetable, modules, semester); - const timetableWithTaLessons = hydrateTaModulesConfigWithLessons( - taModules, - modules, - semester, - ); - const filteredTimetableWithLessons = { - ...timetableWithLessons, - ...timetableWithTaLessons, - }; const events = icalUtils.default( semester, - filteredTimetableWithLessons, + timetableWithLessons, modules, hiddenModules, taModules, diff --git a/website/src/actions/timetables.test.ts b/website/src/actions/timetables.test.ts index e021893fa4..cbecc87852 100644 --- a/website/src/actions/timetables.test.ts +++ b/website/src/actions/timetables.test.ts @@ -1,11 +1,20 @@ import { ModuleCode, Semester } from 'types/modules'; -import { SemTimetableConfig, Lesson } from 'types/timetables'; +import { + SemTimetableConfig, + LessonWithIndex, + TaModulesConfig, + ClassNoTimetableConfig, +} from 'types/timetables'; import lessons from '__mocks__/lessons-array.json'; -import { CS1010S, CS3216 } from '__mocks__/modules'; +import { CS1010A, CS1010S, CS3216 } from '__mocks__/modules'; +import { ClassNoTaModulesMap, ModuleBank, TimetablesState } from 'types/reducers'; +import { defaultTimetableState } from 'reducers/timetables'; import * as actions from './timetables'; +const initialState = defaultTimetableState; + jest.mock('storage', () => ({ getItem: jest.fn(), setItem: jest.fn(), @@ -29,14 +38,16 @@ test('removeLesson should return information to remove module', () => { }); test('modifyLesson should return lesson payload', () => { - const activeLesson: Lesson = lessons[0]; + const activeLesson: LessonWithIndex = lessons[0]; expect(actions.modifyLesson(activeLesson)).toMatchSnapshot(); }); test('changeLesson should return updated information to change lesson', () => { const semester: Semester = 1; - const lesson: Lesson = lessons[1]; - expect(actions.changeLesson(semester, lesson)).toMatchSnapshot(); + const lesson: LessonWithIndex = lessons[1]; + expect( + actions.changeLesson(semester, lesson.moduleCode, lesson.lessonType, [lesson.lessonIndex]), + ).toMatchSnapshot(); }); test('cancelModifyLesson should not have payload', () => { @@ -49,24 +60,72 @@ test('select module color should dispatch a select of module color', () => { expect(actions.selectModuleColor(semester, 'CS3216', 1)).toMatchSnapshot(); }); +describe('disableTaMode', () => { + const semester = 1; + const timetablesState = (ta: TaModulesConfig): TimetablesState => ({ + ...initialState, + lessons: { + [semester]: { + CS1010S: { + Lecture: [0], + Tutorial: [11], + Recitation: [1], + }, + }, + }, + ta: { [semester]: ta }, + }); + + test('TA module is removed correctly', () => { + const ta = ['CS1010S']; + + const state: any = { + timetables: timetablesState(ta), + moduleBank: { modules: { CS1010S, CS3216 } }, + }; + const dispatch = jest.fn(); + const action = actions.disableTaModule(semester, 'CS1010S'); + + action(dispatch, () => state); + + expect(dispatch).toHaveBeenCalled(); + }); + + test('TA module is removed even if semesterData cannot be found to create normal mode lessonConfig', () => { + const ta = ['CS1010S']; + + const state: any = { + timetables: timetablesState(ta), + moduleBank: { modules: { CS1010S: { semesterData: [] } } }, + }; + const dispatch = jest.fn(); + const action = actions.disableTaModule(semester, 'CS1010S'); + + action(dispatch, () => state); + + expect(dispatch).toHaveBeenCalled(); + }); +}); + describe('fillTimetableBlanks', () => { - const moduleBank = { modules: { CS1010S, CS3216 } }; - const timetablesState = (semester: Semester, timetable: SemTimetableConfig) => ({ + const moduleBank: Partial = { modules: { CS1010S, CS1010A, CS3216 } }; + const semester: Semester = 1; + const timetablesState = (timetable: SemTimetableConfig): TimetablesState => ({ + ...initialState, lessons: { [semester]: timetable }, }); - const semester = 1; const action = actions.validateTimetable(semester); test('do nothing if timetable is already full', () => { const timetable = { CS1010S: { - Lecture: '1', - Tutorial: '1', - Recitation: '1', + Lecture: [0], + Tutorial: [11], + Recitation: [1], }, }; - const state: any = { timetables: timetablesState(semester, timetable), moduleBank }; + const state: any = { timetables: timetablesState(timetable), moduleBank }; const dispatch = jest.fn(); action(dispatch, () => state); @@ -76,12 +135,12 @@ describe('fillTimetableBlanks', () => { test('fill missing lessons with randomly generated modules', () => { const timetable = { CS1010S: { - Lecture: '1', - Tutorial: '1', + Lecture: [0], + Tutorial: [11], }, CS3216: {}, }; - const state: any = { timetables: timetablesState(semester, timetable), moduleBank }; + const state: any = { timetables: timetablesState(timetable), moduleBank }; const dispatch = jest.fn(); action(dispatch, () => state); @@ -95,9 +154,9 @@ describe('fillTimetableBlanks', () => { semester, moduleCode: 'CS1010S', lessonConfig: { - Lecture: '1', - Tutorial: '1', - Recitation: expect.any(String), + Lecture: [0], + Tutorial: [11], + Recitation: [1], }, }, }); @@ -108,11 +167,84 @@ describe('fillTimetableBlanks', () => { semester, moduleCode: 'CS3216', lessonConfig: { - Lecture: '1', + Lecture: [0], }, }, }); }); + + test('migrate v1 config', () => { + const timetables = { + ...initialState, + lessons: { + [semester]: { + CS1010S: { + Lecture: '1', + Tutorial: '1', + Recitation: '1', + }, + CS3216: { + Lecture: '1', + }, + } as ClassNoTimetableConfig, + }, + ta: { + [semester]: { + CS1010S: [ + ['Lecture', '1'], + ['Tutorial', '2'], + ['Recitation', '2'], + ], + }, + } as ClassNoTaModulesMap, + }; + + const state: any = { timetables, moduleBank }; + const dispatch = jest.fn(); + action(dispatch, () => state); + expect(dispatch).toHaveBeenCalledTimes(1); + const [[firstAction]] = dispatch.mock.calls; + expect(firstAction).toMatchObject({ + type: 'SET_TIMETABLES', + payload: { + lessons: { + [semester]: { + CS1010S: { + Lecture: [0], + Tutorial: [21], + Recitation: [3], + }, + }, + }, + taModules: { + [semester]: ['CS1010S'], + }, + }, + }); + }); + + test('should not error when module cannot be found', () => { + const timetable = { + CS1010S: { + Lecture: [0], + Tutorial: [11], + Recitation: [1], + }, + }; + const moduleBankWithoutModule = { + ...moduleBank, + modules: {}, + }; + + const state: any = { + timetables: timetablesState(timetable), + moduleBank: moduleBankWithoutModule, + }; + const dispatch = jest.fn(); + action(dispatch, () => state); + + expect(dispatch).not.toThrow(TypeError); + }); }); describe('hide/show timetable modules', () => { diff --git a/website/src/actions/timetables.ts b/website/src/actions/timetables.ts index bfa8e1f30a..ed82660ee0 100644 --- a/website/src/actions/timetables.ts +++ b/website/src/actions/timetables.ts @@ -2,31 +2,44 @@ import { each, flatMap } from 'lodash'; import type { ColorIndex, - Lesson, + TaModulesConfig, ModuleLessonConfig, SemTimetableConfig, - TaModulesConfig, + TimetableConfig, + LessonWithIndex, + ClassNoTimetableConfig, } from 'types/timetables'; import type { Dispatch, GetState } from 'types/redux'; -import type { ColorMapping } from 'types/reducers'; -import type { ClassNo, LessonType, Module, ModuleCode, Semester } from 'types/modules'; +import type { ClassNoTaModulesMap, ColorMapping, TaModulesMap } from 'types/reducers'; +import type { LessonIndex, LessonType, Module, ModuleCode, Semester } from 'types/modules'; import { fetchModule } from 'actions/moduleBank'; import { openNotification } from 'actions/app'; import { getModuleCondensed } from 'selectors/moduleBank'; import { + getClosestLessonConfig, + groupLessonsByLessonTypeByClassNo, + migrateTimetableConfigs, randomModuleLessonConfig, validateModuleLessons, validateTimetableModules, } from 'utils/timetables'; -import { getModuleTimetable } from 'utils/modules'; +import { getModuleSemesterData, getModuleTimetable } from 'utils/modules'; // Actions that should not be used directly outside of thunks +export const SET_TIMETABLES = 'SET_TIMETABLES' as const; export const SET_TIMETABLE = 'SET_TIMETABLE' as const; export const ADD_MODULE = 'ADD_MODULE' as const; export const SET_HIDDEN_IMPORTED = 'SET_HIDDEN_IMPORTED' as const; export const SET_TA_IMPORTED = 'SET_TA_IMPORTED' as const; export const Internal = { + setTimetables(lessons: TimetableConfig, taModules: TaModulesMap) { + return { + type: SET_TIMETABLES, + payload: { lessons, taModules }, + }; + }, + setTimetable( semester: Semester, timetable: SemTimetableConfig | undefined, @@ -101,7 +114,7 @@ export function resetTimetable(semester: Semester) { } export const MODIFY_LESSON = 'MODIFY_LESSON' as const; -export function modifyLesson(activeLesson: Lesson) { +export function modifyLesson(activeLesson: LessonWithIndex) { return { type: MODIFY_LESSON, payload: { @@ -115,7 +128,7 @@ export function setLesson( semester: Semester, moduleCode: ModuleCode, lessonType: LessonType, - classNo: ClassNo, + lessonIndices: LessonIndex[], ) { return { type: CHANGE_LESSON, @@ -123,13 +136,54 @@ export function setLesson( semester, moduleCode, lessonType, - classNo, + lessonIndices, }, }; } -export function changeLesson(semester: Semester, lesson: Lesson) { - return setLesson(semester, lesson.moduleCode, lesson.lessonType, lesson.classNo); +export const ADD_LESSON = 'ADD_LESSON' as const; +export function addLesson( + semester: Semester, + moduleCode: ModuleCode, + lessonType: LessonType, + lessonIndices: LessonIndex[], +) { + return { + type: ADD_LESSON, + payload: { + semester, + moduleCode, + lessonType, + lessonIndices, + }, + }; +} + +export const REMOVE_LESSON = 'REMOVE_LESSON' as const; +export function removeLesson( + semester: Semester, + moduleCode: ModuleCode, + lessonType: LessonType, + lessonIndices: LessonIndex[], +) { + return { + type: REMOVE_LESSON, + payload: { + semester, + moduleCode, + lessonType, + lessonIndices, + }, + }; +} + +export function changeLesson( + semester: Semester, + moduleCode: ModuleCode, + lessonType: LessonType, + lessonIndices: LessonIndex[], +) { + return setLesson(semester, moduleCode, lessonType, lessonIndices); } export const SET_LESSON_CONFIG = 'SET_LESSON_CONFIG' as const; @@ -173,19 +227,33 @@ export function setTimetable( validatedTimetable, colors, getState().timetables.hidden[semester] ?? [], - getState().timetables.ta[semester] ?? {}, + getState().timetables.ta[semester] ?? [], ), ); }; } +/** + * Valid non-TA modules must have one and only one classNo for each lesson type\ + * Valid TA modules configs must have lesson indices that belong to the correct lesson type + * @param semester Semester of the timetable config to validate + */ export function validateTimetable(semester: Semester) { return (dispatch: Dispatch, getState: GetState) => { const { timetables, moduleBank } = getState(); + const { lessons, ta, alreadyMigrated } = migrateTimetableConfigs( + timetables.lessons as TimetableConfig | ClassNoTimetableConfig, + timetables.ta as TaModulesMap | ClassNoTaModulesMap, + moduleBank.modules, + ); + + if (!alreadyMigrated) dispatch(Internal.setTimetables(lessons, ta)); + // Extract the timetable and the modules for the semester - const timetable = timetables.lessons[semester]; + const timetable = lessons[semester]; if (!timetable) return; + const taModules = ta[semester]; // Check that all lessons for each module are valid. If they are not, we update it // such that they are @@ -193,22 +261,27 @@ export function validateTimetable(semester: Semester) { const module = moduleBank.modules[moduleCode]; if (!module) return; - const [validatedLessonConfig, changedLessonTypes] = validateModuleLessons( + const isTa = taModules?.includes(moduleCode); + + const { validatedLessonConfig, valid } = validateModuleLessons( semester, lessonConfig, module, + isTa, ); - if (changedLessonTypes.length) { - dispatch(setLessonConfig(semester, moduleCode, validatedLessonConfig)); - } + if (!valid) dispatch(setLessonConfig(semester, moduleCode, validatedLessonConfig)); }); }; } export function fetchTimetableModules(timetables: SemTimetableConfig[]) { + const moduleCodes = new Set(flatMap(timetables, Object.keys)); + return fetchModules(moduleCodes); +} + +export function fetchModules(moduleCodes: Set) { return (dispatch: Dispatch, getState: GetState) => { - const moduleCodes = new Set(flatMap(timetables, Object.keys)); const validateModule = getModuleCondensed(getState()); return Promise.all( @@ -273,36 +346,62 @@ export function showLessonInTimetable(semester: Semester, moduleCode: ModuleCode }; } -export const ADD_TA_LESSON_IN_TIMETABLE = 'ADD_TA_LESSON_IN_TIMETABLE' as const; -export function addTaLessonInTimetable( - semester: Semester, - moduleCode: ModuleCode, - lessonType: LessonType, - classNo: ClassNo, -) { +export const ADD_TA_MODULE = 'ADD_TA_MODULE' as const; +/** + * Adds a module to the semester's TA config + * No changes are made to the lesson config as non-TA lesson configs are valid TA lesson config + * @param semester + * @param moduleCode + * @returns + */ +export function addTaModule(semester: Semester, moduleCode: ModuleCode) { return { - type: ADD_TA_LESSON_IN_TIMETABLE, - payload: { semester, moduleCode, lessonType, classNo }, + type: ADD_TA_MODULE, + payload: { semester, moduleCode }, }; } -export const REMOVE_TA_LESSON_IN_TIMETABLE = 'REMOVE_TA_LESSON_IN_TIMETABLE' as const; -export function removeTaLessonInTimetable( +export const REMOVE_TA_MODULE = 'REMOVE_TA_MODULE' as const; +/** + * A helper function for disableTaModule\ + * Removes a module from the semester's timetable TA modules list\ + * Does not check for the lessonConfig validity as a non-TA lesson config. Use disableTaModule instead. + * @param semester + * @param moduleCode + * @param lessonConfig + * @returns + */ +export function removeTaModule( semester: Semester, moduleCode: ModuleCode, - lessonType: LessonType, - classNo: ClassNo, + lessonConfig: ModuleLessonConfig, ) { return { - type: REMOVE_TA_LESSON_IN_TIMETABLE, - payload: { semester, moduleCode, lessonType, classNo }, + type: REMOVE_TA_MODULE, + payload: { semester, moduleCode, lessonConfig }, }; } -export const DISABLE_TA_MODE_IN_TIMETABLE = 'DISABLE_TA_MODE_IN_TIMETABLE' as const; -export function disableTaModeInTimetable(semester: Semester, moduleCode: ModuleCode) { - return { - type: DISABLE_TA_MODE_IN_TIMETABLE, - payload: { semester, moduleCode }, +/** + * Removes a module from the semester's timetable TA modules list and replaces the lesson config with the closest non-TA module lesson config\ + * The current lesson config is replaced with lessonConfig because TA lesson configs may not be valid non-TA lesson config + * @param semester Semester of timetable to remove the TA module from + * @param moduleCode Module code of the TA module to remove + */ +export function disableTaModule(semester: Semester, moduleCode: ModuleCode) { + return (dispatch: Dispatch, getState: GetState) => { + const { moduleBank, timetables } = getState(); + const module: Module = moduleBank.modules[moduleCode]; + const timetableLessonIndices = timetables.lessons[semester][moduleCode]; + + const semesterData = getModuleSemesterData(module, semester); + if (!semesterData) { + dispatch(removeTaModule(semester, moduleCode, timetableLessonIndices)); + return; + } + const groupedLessons = groupLessonsByLessonTypeByClassNo(semesterData.timetable); + const lessonConfig = getClosestLessonConfig(groupedLessons, timetableLessonIndices); + + dispatch(removeTaModule(semester, moduleCode, lessonConfig)); }; } diff --git a/website/src/entry/export/TimetableOnly.tsx b/website/src/entry/export/TimetableOnly.tsx index 58853ade4a..0ea0b16927 100644 --- a/website/src/entry/export/TimetableOnly.tsx +++ b/website/src/entry/export/TimetableOnly.tsx @@ -20,7 +20,7 @@ export default class TimetableOnly extends Component { timetable: {}, colors: {}, hidden: [], - ta: {}, + ta: [], }; override render() { diff --git a/website/src/reducers/app.test.ts b/website/src/reducers/app.test.ts index 414271123c..918a5f7901 100644 --- a/website/src/reducers/app.test.ts +++ b/website/src/reducers/app.test.ts @@ -8,11 +8,11 @@ import { Semester } from 'types/modules'; import { AppState } from 'types/reducers'; import { initAction } from 'test-utils/redux'; import lessons from '__mocks__/lessons-array.json'; -import { Lesson } from 'types/timetables'; +import { LessonWithIndex } from 'types/timetables'; const semester: Semester = 1; const anotherSemester: Semester = 2; -const lesson: Lesson = lessons[0]; +const lesson: LessonWithIndex = lessons[0]; const appInitialState: AppState = { activeSemester: semester, activeLesson: null, @@ -47,7 +47,7 @@ test('app should instantiate active lesson', () => { }); test('app should set active lesson', () => { - const anotherLesson: Lesson = lessons[1]; + const anotherLesson: LessonWithIndex = lessons[1]; const action = modifyLesson(anotherLesson); const nextState: AppState = reducer(appHasActiveLessonState, action); @@ -55,7 +55,7 @@ test('app should set active lesson', () => { }); test('app should accept lesson change and unset active lesson', () => { - const action = changeLesson(semester, lesson); + const action = changeLesson(semester, lesson.moduleCode, lesson.lessonType, [lesson.lessonIndex]); const nextState: AppState = reducer(appInitialState, action); expect(nextState).toEqual(appInitialState); diff --git a/website/src/reducers/app.ts b/website/src/reducers/app.ts index 1748f527f5..d6429198aa 100644 --- a/website/src/reducers/app.ts +++ b/website/src/reducers/app.ts @@ -3,7 +3,13 @@ import { Actions } from 'types/actions'; import config from 'config'; import { forceRefreshPrompt } from 'utils/debug'; -import { MODIFY_LESSON, CHANGE_LESSON, CANCEL_MODIFY_LESSON } from 'actions/timetables'; +import { + MODIFY_LESSON, + CHANGE_LESSON, + CANCEL_MODIFY_LESSON, + ADD_LESSON, + REMOVE_LESSON, +} from 'actions/timetables'; import { SELECT_SEMESTER } from 'actions/settings'; import { OPEN_NOTIFICATION, @@ -39,6 +45,8 @@ function app(state: AppState = defaultAppState(), action: Actions): AppState { }; case CANCEL_MODIFY_LESSON: case CHANGE_LESSON: + case ADD_LESSON: + case REMOVE_LESSON: return { ...state, activeLesson: null, diff --git a/website/src/reducers/index.test.ts b/website/src/reducers/index.test.ts index fa421a4c5e..713d58fa6e 100644 --- a/website/src/reducers/index.test.ts +++ b/website/src/reducers/index.test.ts @@ -11,16 +11,16 @@ const exportData: ExportData = { semester: 1, timetable: { CS3216: { - Lecture: '1', + Lecture: [0], }, CS1010S: { - Lecture: '1', - Tutorial: '3', - Recitation: '2', + Lecture: [0], + Tutorial: [11], + Recitation: [1], }, PC1222: { - Lecture: '1', - Tutorial: '3', + Lecture: [4], + Tutorial: [6], }, }, colors: { @@ -29,9 +29,7 @@ const exportData: ExportData = { PC1222: 2, }, hidden: ['PC1222'], - ta: { - CS1010S: [['Tutorial', '1']], - }, + ta: ['CS1010S'], theme: { id: 'google', timetableOrientation: VERTICAL, @@ -56,16 +54,16 @@ test('reducers should set export data state', () => { lessons: { [1]: { CS3216: { - Lecture: '1', + Lecture: [0], }, CS1010S: { - Lecture: '1', - Tutorial: '3', - Recitation: '2', + Lecture: [0], + Tutorial: [11], + Recitation: [1], }, PC1222: { - Lecture: '1', - Tutorial: '3', + Lecture: [4], + Tutorial: [6], }, }, }, @@ -77,11 +75,7 @@ test('reducers should set export data state', () => { }, }, hidden: { [1]: ['PC1222'] }, - ta: { - [1]: { - CS1010S: [['Tutorial', '1']], - }, - }, + ta: { [1]: ['CS1010S'] }, academicYear: expect.any(String), archive: {}, }); diff --git a/website/src/reducers/moduleBank.ts b/website/src/reducers/moduleBank.ts index 286f901194..c702995f75 100644 --- a/website/src/reducers/moduleBank.ts +++ b/website/src/reducers/moduleBank.ts @@ -1,5 +1,5 @@ import { produce, Draft } from 'immer'; -import { keyBy, omit, size, zipObject } from 'lodash'; +import { keyBy, map, omit, size, zipObject } from 'lodash'; import { createMigrate, REHYDRATE } from 'redux-persist'; import type { Actions } from 'types/actions'; @@ -49,7 +49,17 @@ function moduleBank(state: ModuleBank = defaultModuleBankState, action: Actions) ...state, modules: { ...state.modules, - [action.payload.moduleCode]: { ...action.payload, timestamp: Date.now() }, + [action.payload.moduleCode]: { + ...action.payload, + timestamp: Date.now(), + semesterData: map(action.payload.semesterData, (semesterData) => ({ + ...semesterData, + timetable: map(semesterData.timetable, (lesson, lessonIndex) => ({ + ...lesson, + lessonIndex, + })), + })), + }, }, }; diff --git a/website/src/reducers/timetables.test.ts b/website/src/reducers/timetables.test.ts index d9f2f3083b..6ea6e23d25 100644 --- a/website/src/reducers/timetables.test.ts +++ b/website/src/reducers/timetables.test.ts @@ -9,8 +9,10 @@ import { showLessonInTimetable, setHiddenImported, Internal, - addTaLessonInTimetable, - removeTaLessonInTimetable, + addTaModule, + addLesson, + removeLesson, + changeLesson, } from 'actions/timetables'; import { TimetablesState } from 'types/reducers'; import config from 'config'; @@ -74,7 +76,7 @@ describe('color reducers', () => { timetable: { CS1010S: {} }, colors: { CS1010S: 0 }, hiddenModules: [], - taModules: {}, + taModules: [], }, }).colors[1], ).toEqual({ @@ -127,32 +129,8 @@ describe('hidden module reducer', () => { }); describe('TA module reducer', () => { - const withTaModules: TimetablesState = { - ...initialState, - ta: { [1]: { CS1010S: [['Tutorial', '1']] }, [2]: { CS1010S: [['Tutorial', '1']] } }, - }; - test('should update TA modules', () => { - expect( - reducer(initialState, addTaLessonInTimetable(1, 'CS3216', 'Tutorial', '1')), - ).toHaveProperty('ta.1', { CS3216: [['Tutorial', '1']] }); - - expect( - reducer(initialState, removeTaLessonInTimetable(1, 'CS1010S', 'Tutorial', '1')), - ).toMatchObject({ - ta: { - [1]: {}, - }, - }); - - expect( - reducer(withTaModules, removeTaLessonInTimetable(1, 'CS1010S', 'Tutorial', '1')), - ).toMatchObject({ - ta: { - [1]: {}, - [2]: { CS1010S: [['Tutorial', '1']] }, - }, - }); + expect(reducer(initialState, addTaModule(1, 'CS3216'))).toHaveProperty('ta.1', ['CS3216']); }); test('should remove modules from list when modules are removed', () => { @@ -160,26 +138,12 @@ describe('TA module reducer', () => { reducer( { ...initialState, - ta: { [1]: { CS1010S: [['Tutorial', '1']] }, [2]: { CS1010S: [['Tutorial', '1']] } }, + ta: { [1]: ['CS1010S'], [2]: ['CS1010S'] }, }, removeModule(1, 'CS1010S'), ), ).toMatchObject({ - ta: { - [1]: {}, - [2]: { CS1010S: [['Tutorial', '1']] }, - }, - }); - }); - - test('should not add duplicate TA lessons', () => { - expect( - reducer(withTaModules, addTaLessonInTimetable(1, 'CS1010S', 'Tutorial', '1')), - ).toMatchObject({ - ta: { - [1]: { CS1010S: [['Tutorial', '1']] }, - [2]: { CS1010S: [['Tutorial', '1']] }, - }, + ta: { [1]: [], [2]: ['CS1010S'] }, }); }); }); @@ -193,46 +157,100 @@ describe('lesson reducer', () => { lessons: { [1]: { CS1010S: { - Lecture: '1', - Recitation: '2', + Lecture: [0], + Recitation: [3], }, CS3216: { - Lecture: '1', + Lecture: [0], }, }, [2]: { CS3217: { - Lecture: '1', + Lecture: [0], }, }, }, }, setLessonConfig(1, 'CS1010S', { - Lecture: '2', - Recitation: '3', - Tutorial: '4', + Lecture: [1], + Recitation: [4], + Tutorial: [11], }), ), ).toMatchObject({ lessons: { [1]: { CS1010S: { - Lecture: '2', - Recitation: '3', - Tutorial: '4', + Lecture: [1], + Recitation: [4], + Tutorial: [11], }, CS3216: { - Lecture: '1', + Lecture: [0], }, }, [2]: { CS3217: { - Lecture: '1', + Lecture: [0], }, }, }, }); }); + + test('should remove lessons in payload', () => { + const timetableState: TimetablesState = { + ...initialState, + lessons: { + 1: { + CS1010S: { + Tutorial: [11, 12, 13], + }, + }, + }, + }; + + expect( + reducer(timetableState, removeLesson(1, 'CS1010S', 'Tutorial', [12, 13])), + ).toHaveProperty('lessons.1.CS1010S.Tutorial', [11]); + }); + + test('should replace lessons with those in payload', () => { + const timetableState: TimetablesState = { + ...initialState, + lessons: { + 1: { + CS1010S: { + Tutorial: [11, 12, 13], + }, + }, + }, + }; + + expect( + reducer(timetableState, changeLesson(1, 'CS1010S', 'Tutorial', [14, 15])), + ).toHaveProperty('lessons.1.CS1010S.Tutorial', [14, 15]); + }); + + test('should not add duplicate TA lessons', () => { + const withTaModules: TimetablesState = { + ...initialState, + lessons: { + 1: { + CS1010S: { + Tutorial: [11], + }, + }, + }, + ta: { + [1]: ['CS1010S'], + }, + }; + + expect(reducer(withTaModules, addLesson(1, 'CS1010S', 'Tutorial', [11]))).toMatchObject( + withTaModules, + ); + }); }); describe('stateReconciler', () => { @@ -240,7 +258,7 @@ describe('stateReconciler', () => { '2015/2016': { [1]: { GET1006: { - Lecture: '1', + Lecture: [0], }, }, }, @@ -249,13 +267,13 @@ describe('stateReconciler', () => { const oldLessons = { [1]: { CS1010S: { - Lecture: '1', - Recitation: '2', + Lecture: [0], + Recitation: [3], }, }, [2]: { CS3217: { - Lecture: '1', + Lecture: [0], }, }, }; @@ -345,3 +363,15 @@ describe('import timetable', () => { }); }); }); + +describe('migrate v1 config', () => { + test('should migrate config to new format', () => { + expect( + reducer(initialState, Internal.setTimetables({ [1]: { CS1010S: {} } }, { [1]: ['CS1010S'] })), + ).toStrictEqual({ + ...initialState, + lessons: { [1]: { CS1010S: {} } }, + ta: { [1]: ['CS1010S'] }, + }); + }); +}); diff --git a/website/src/reducers/timetables.ts b/website/src/reducers/timetables.ts index bcea4e5ea6..43e86410ff 100644 --- a/website/src/reducers/timetables.ts +++ b/website/src/reducers/timetables.ts @@ -1,16 +1,18 @@ -import { get, isEqual, omit, values } from 'lodash'; +import { get, omit, uniq, values } from 'lodash'; import { produce } from 'immer'; import { createMigrate } from 'redux-persist'; import { PersistConfig } from 'storage/persistReducer'; -import { ClassNo, LessonType, ModuleCode } from 'types/modules'; -import { ModuleLessonConfig, SemTimetableConfig, TaModulesConfig } from 'types/timetables'; +import { LessonIndex, ModuleCode } from 'types/modules'; +import { TaModulesConfig, ModuleLessonConfig, SemTimetableConfig } from 'types/timetables'; import { ColorMapping, TimetablesState } from 'types/reducers'; import config from 'config'; import { ADD_MODULE, CHANGE_LESSON, + ADD_LESSON, + REMOVE_LESSON, HIDE_LESSON_IN_TIMETABLE, REMOVE_MODULE, RESET_TIMETABLE, @@ -18,11 +20,11 @@ import { SET_HIDDEN_IMPORTED, SET_LESSON_CONFIG, SET_TA_IMPORTED, - ADD_TA_LESSON_IN_TIMETABLE, + ADD_TA_MODULE, SET_TIMETABLE, SHOW_LESSON_IN_TIMETABLE, - REMOVE_TA_LESSON_IN_TIMETABLE, - DISABLE_TA_MODE_IN_TIMETABLE, + REMOVE_TA_MODULE, + SET_TIMETABLES, } from 'actions/timetables'; import { getNewColor } from 'utils/colors'; import { SET_EXPORTED_DATA } from 'actions/constants'; @@ -93,13 +95,34 @@ function moduleLessonConfig( switch (action.type) { case CHANGE_LESSON: { - const { classNo, lessonType } = action.payload; - if (!(classNo && lessonType)) return state; + const { lessonIndices, lessonType } = action.payload; + if (!(lessonIndices && lessonType)) return state; return { ...state, - [lessonType]: classNo, + [lessonType]: lessonIndices, }; } + case ADD_LESSON: { + const { lessonIndices, lessonType } = action.payload; + if (!(lessonIndices && lessonType)) return state; + return { + ...state, + [lessonType]: uniq([...lessonIndices, ...state[lessonType]]), + }; + } + case REMOVE_LESSON: { + const { lessonIndices: lessonIndicesToExclude, lessonType } = action.payload; + if (!(lessonIndicesToExclude && lessonType)) return state; + return { + ...state, + [lessonType]: [ + ...state[lessonType].filter( + (lessonIndex: LessonIndex) => !lessonIndicesToExclude.includes(lessonIndex), + ), + ], + }; + } + case REMOVE_TA_MODULE: case SET_LESSON_CONFIG: return action.payload.lessonConfig; @@ -126,6 +149,9 @@ function semTimetable( case REMOVE_MODULE: return omit(state, [moduleCode]); case CHANGE_LESSON: + case ADD_LESSON: + case REMOVE_LESSON: + case REMOVE_TA_MODULE: case SET_LESSON_CONFIG: return { ...state, @@ -182,40 +208,23 @@ function semHiddenModules(state = DEFAULT_HIDDEN_STATE, action: Actions) { } // Map of semester to list of TA modules -const DEFAULT_TA_STATE: TaModulesConfig = {}; +const DEFAULT_TA_STATE: TaModulesConfig = []; function semTaModules(state = DEFAULT_TA_STATE, action: Actions): TaModulesConfig { if (!action.payload) { return state; } switch (action.type) { - case ADD_TA_LESSON_IN_TIMETABLE: { - const { moduleCode, lessonType, classNo } = action.payload; - if (!(moduleCode && lessonType && classNo)) return state; - const newLesson: [LessonType, ClassNo] = [lessonType, classNo]; - const curLessons = state[moduleCode] ?? []; - // Prevent duplicate lessons - if (curLessons.some((lesson) => isEqual(lesson, newLesson))) return state; - return { - ...state, - [moduleCode]: [...curLessons, newLesson], - }; - } - case REMOVE_TA_LESSON_IN_TIMETABLE: { - const { moduleCode, lessonType, classNo } = action.payload; - if (!(moduleCode && lessonType && classNo)) return state; - return { - ...state, - [moduleCode]: state[moduleCode]?.filter( - (lesson) => !isEqual(lesson, [lessonType, classNo]), - ), - }; - } - case DISABLE_TA_MODE_IN_TIMETABLE: - case REMOVE_MODULE: { + case ADD_TA_MODULE: { const { moduleCode } = action.payload; if (!moduleCode) return state; - return omit(state, moduleCode); + return uniq([...state, moduleCode]); + } + case REMOVE_TA_MODULE: + case REMOVE_MODULE: { + const { moduleCode: modulesToExclude } = action.payload; + if (!modulesToExclude) return state; + return state.filter((moduleCode: ModuleCode) => !modulesToExclude.includes(moduleCode)); } default: return state; @@ -241,6 +250,15 @@ function timetables( } switch (action.type) { + case SET_TIMETABLES: { + const { lessons, taModules } = action.payload; + return { + ...state, + lessons, + ta: taModules, + }; + } + case SET_TIMETABLE: { const { semester, timetable, colors, hiddenModules, taModules } = action.payload; @@ -248,7 +266,7 @@ function timetables( draft.lessons[semester] = timetable ?? DEFAULT_SEM_TIMETABLE_CONFIG; draft.colors[semester] = colors ?? {}; draft.hidden[semester] = hiddenModules ?? []; - draft.ta[semester] = taModules ?? {}; + draft.ta[semester] = taModules ?? []; }); } @@ -267,19 +285,22 @@ function timetables( case REMOVE_MODULE: case SELECT_MODULE_COLOR: case CHANGE_LESSON: + case ADD_LESSON: + case REMOVE_LESSON: case SET_LESSON_CONFIG: case HIDE_LESSON_IN_TIMETABLE: case SHOW_LESSON_IN_TIMETABLE: - case ADD_TA_LESSON_IN_TIMETABLE: - case REMOVE_TA_LESSON_IN_TIMETABLE: - case DISABLE_TA_MODE_IN_TIMETABLE: { + case ADD_TA_MODULE: + case REMOVE_TA_MODULE: { const { semester } = action.payload; return produce(state, (draft) => { draft.lessons[semester] = semTimetable(draft.lessons[semester], action); draft.colors[semester] = semColors(state.colors[semester], action); draft.hidden[semester] = semHiddenModules(state.hidden[semester], action); - draft.ta[semester] = semTaModules(state.ta[semester], action); + const taModules = state.ta[semester]; + const taModulesList = taModules; + draft.ta[semester] = semTaModules(taModulesList, action); }); } diff --git a/website/src/test-utils/theme.ts b/website/src/test-utils/theme.ts index 79decdbb0b..5ef9003432 100644 --- a/website/src/test-utils/theme.ts +++ b/website/src/test-utils/theme.ts @@ -16,13 +16,11 @@ export function addColors( modules: Module[], isHiddenInTimetable = false, isTaInTimetable = false, - canTa = false, ): ModuleWithColor[] { return modules.map((module, index) => ({ ...module, colorIndex: index, isHiddenInTimetable, isTaInTimetable, - canTa, })); } diff --git a/website/src/types/modules.ts b/website/src/types/modules.ts index b2e3faead1..b0277ed56e 100644 --- a/website/src/types/modules.ts +++ b/website/src/types/modules.ts @@ -25,6 +25,7 @@ export type WeekRange = { // Week intervals for modules with uneven spacing between lessons weeks?: number[]; }; +export type LessonIndex = number; // Recursive tree of module codes and boolean operators for the prereq tree export type PrereqTree = @@ -138,10 +139,18 @@ export type RawLesson = Readonly<{ weeks: Weeks; }>; +export type RawLessonWithIndex = RawLesson & { readonly lessonIndex: LessonIndex }; + +export type LessonsByLessonTypeByClassNo = { + [lessonType: LessonType]: { + [classNo: ClassNo]: LessonIndex[]; + }; +}; + // Semester-specific information of a module. export type SemesterData = { semester: Semester; - timetable: readonly RawLesson[]; + timetable: readonly RawLessonWithIndex[]; // Exam examDate?: string; diff --git a/website/src/types/reducers.ts b/website/src/types/reducers.ts index 33c38c76e6..9d8df19f80 100644 --- a/website/src/types/reducers.ts +++ b/website/src/types/reducers.ts @@ -2,7 +2,13 @@ import { AxiosError } from 'axios'; import { RegPeriodType, ScheduleType } from 'config'; import { ColorSchemePreference } from './settings'; -import { ColorIndex, Lesson, TaModulesConfig, TimetableConfig } from './timetables'; +import { + ClassNoTaModulesConfig, + ColorIndex, + LessonWithIndex, + TaModulesConfig, + TimetableConfig, +} from './timetables'; import { Faculty, Module, @@ -50,7 +56,7 @@ export type NotificationData = { readonly message: string } & NotificationOption export type AppState = { readonly activeSemester: Semester; - readonly activeLesson: Lesson | null; + readonly activeLesson: LessonWithIndex | null; readonly isOnline: boolean; readonly isFeedbackModalOpen: boolean; readonly notifications: NotificationData[]; @@ -110,10 +116,11 @@ export type SettingsState = { /* timetables.js */ // Mapping of module to color index [0, NUM_DIFFERENT_COLORS) -export type ColorMapping = { [moduleCode: string]: ColorIndex }; -export type SemesterColorMap = { [semester: string]: ColorMapping }; -export type HiddenModulesMap = { [semester: string]: ModuleCode[] }; -export type TaModulesMap = { [semester: string]: TaModulesConfig }; +export type ColorMapping = { [moduleCode: ModuleCode]: ColorIndex }; +export type SemesterColorMap = { [semester: Semester]: ColorMapping }; +export type HiddenModulesMap = { [semester: Semester]: ModuleCode[] }; +export type TaModulesMap = { [semester: Semester]: TaModulesConfig }; +export type ClassNoTaModulesMap = { [semester: Semester]: ClassNoTaModulesConfig }; export type TimetablesState = { readonly lessons: TimetableConfig; diff --git a/website/src/types/timetables.ts b/website/src/types/timetables.ts index 34670cddd0..edc578aedd 100644 --- a/website/src/types/timetables.ts +++ b/website/src/types/timetables.ts @@ -1,17 +1,48 @@ -import { ClassNo, LessonType, ModuleCode, ModuleTitle, RawLesson } from './modules'; +import { + ClassNo, + LessonIndex, + LessonType, + ModuleCode, + ModuleTitle, + RawLesson, + Semester, +} from './modules'; -// ModuleLessonConfig is a mapping of lessonType to ClassNo for a module. export type ModuleLessonConfig = { + [lessonType: LessonType]: LessonIndex[]; +}; + +// +/** + * ModuleLessonConfig is the v1 representation of module configs\ + * It is a mapping of lessonType to classNo\ + * It is only used for type annotations in the migration logic + */ +export type ClassNoModuleLessonConfig = { [lessonType: LessonType]: ClassNo; }; -// SemTimetableConfig is the timetable data for each semester. export type SemTimetableConfig = { [moduleCode: ModuleCode]: ModuleLessonConfig; }; -// TaModulesConfig is a mapping of moduleCode to the TA's lesson types. -export type TaModulesConfig = { +/** + * ClassNoSemTimetableConfig is the v1 representation of semester timetables\ + * It is a mapping of module code to the module config\ + * It is only used for type annotations in the migration logic + */ +export type ClassNoSemTimetableConfig = { + [moduleCode: ModuleCode]: ClassNoModuleLessonConfig; +}; + +export type TaModulesConfig = ModuleCode[]; + +/** + * ClassNoTaModulesConfig is the v1 representation of TA modules\ + * It is a mapping of moduleCode to the TA's lesson types\ + * It is only used for type annotations in the migration logic + */ +export type ClassNoTaModulesConfig = { [moduleCode: ModuleCode]: [lessonType: LessonType, classNo: ClassNo][]; }; @@ -21,24 +52,29 @@ export type Lesson = RawLesson & { title: ModuleTitle; }; -export type ColoredLesson = Lesson & { - colorIndex: ColorIndex; - isTaInTimetable?: boolean; -}; +export type LessonWithIndex = Lesson & { readonly lessonIndex: LessonIndex }; + +export type ColoredLesson = Lesson & { colorIndex: ColorIndex }; -type Modifiable = { - isModifiable?: boolean; - isAvailable?: boolean; +/** + * Interactable lessons are lessons that appear on the Timetable page + * + * It provides the properties required to determine whether the user: + * - is currently modifying the lesson + * - is able to replace the currently selected lesson + * - is currently in the lesson config + */ +export type InteractableLesson = ColoredLesson & { + readonly lessonIndex: LessonIndex; + isTaInTimetable?: boolean; + canBeSelectedAsActiveLesson?: boolean; + canBeAddedToLessonConfig?: boolean; isActive?: boolean; - isOptionInTimetable?: boolean; - colorIndex: ColorIndex; }; -export type ModifiableLesson = ColoredLesson & Modifiable; - // The array of Lessons must belong to that lessonType. export type ModuleLessonConfigWithLessons = { - [lessonType: LessonType]: Lesson[]; + [lessonType: LessonType]: LessonWithIndex[]; }; // SemTimetableConfig is the timetable data for each semester with lessons data. @@ -46,22 +82,31 @@ export type SemTimetableConfigWithLessons = { [moduleCode: ModuleCode]: ModuleLessonConfigWithLessons; }; +/** + * ClassNoTimetableConfig is the v1 representation of the timetable data for the whole academic year\ + * It is a mapping of semesters to semester timetables\ + * It is only used for type annotations in the migration logic + */ +export type ClassNoTimetableConfig = { + [semester: Semester]: ClassNoSemTimetableConfig; +}; + // TimetableConfig is the timetable data for the whole academic year. export type TimetableConfig = { - [semester: string]: SemTimetableConfig; + [semester: Semester]: SemTimetableConfig; }; // TimetableDayFormat is timetable data grouped by DayText. -export type TimetableDayFormat = { - [dayText: string]: ColoredLesson[]; +export type TimetableDayFormat = { + [dayText: string]: T[]; }; // TimetableDayArrangement is the arrangement of lessons on the timetable within a day. -export type TimetableDayArrangement = ModifiableLesson[][]; +export type TimetableDayArrangement = T[][]; // TimetableArrangement is the arrangement of lessons on the timetable for a week. -export type TimetableArrangement = { - [dayText: string]: TimetableDayArrangement; +export type TimetableArrangement = { + [dayText: string]: TimetableDayArrangement; }; // Represents the lesson which the user is currently hovering over. @@ -70,6 +115,7 @@ export type HoverLesson = { readonly classNo: ClassNo; readonly moduleCode: ModuleCode; readonly lessonType: LessonType; + readonly lessonIndex: LessonIndex; }; export type ColorIndex = number; diff --git a/website/src/types/venues.ts b/website/src/types/venues.ts index f316cd1f57..460a6ad912 100644 --- a/website/src/types/venues.ts +++ b/website/src/types/venues.ts @@ -9,8 +9,8 @@ export const OCCUPIED: VenueOccupiedState = 'occupied'; export type Availability = { [lessonTime: string]: VenueOccupiedState | undefined }; // E.g. { "1000": "vacant", "1030": "occupied", ... } -// Raw lessons obtained from venue info API includes ModuleCode and without venue -export type VenueLesson = Omit & { +// Raw lessons obtained from venue info API includes ModuleCode and without venue and without lessonIndex +export type VenueLesson = Omit & { moduleCode: ModuleCode; }; diff --git a/website/src/types/views.ts b/website/src/types/views.ts index 714d0f927f..2b022a422e 100644 --- a/website/src/types/views.ts +++ b/website/src/types/views.ts @@ -1,7 +1,7 @@ import type { QueryObject } from 'json2mq'; import { Module, ModuleCondensed } from './modules'; import { ModuleList } from './reducers'; -import { ColorIndex, HoverLesson, Lesson, ModifiableLesson } from './timetables'; +import { ColorIndex, HoverLesson, Lesson, InteractableLesson } from './timetables'; import { Venue, VenueList } from './venues'; import { RegPeriod } from '../config'; @@ -42,7 +42,7 @@ export type SelectedLesson = { date: Date; lesson: Lesson }; export type ExamClashes = { [key: string]: Module[] }; // Timetable event handlers -export type OnModifyCell = (lesson: ModifiableLesson, position: ClientRect) => void; +export type OnModifyCell = (lesson: InteractableLesson, position: ClientRect) => void; export type OnHoverCell = (hoverLesson: HoverLesson | null) => void; // Incomplete typing of Mamoto's API. If you need something not here, feel free @@ -55,7 +55,6 @@ export type ModuleWithColor = Module & { colorIndex: ColorIndex; isHiddenInTimetable: boolean; isTaInTimetable: boolean; - canTa: boolean; }; export type TombstoneModule = ModuleWithColor & { diff --git a/website/src/utils/ical.test.ts b/website/src/utils/ical.test.ts index 15b5b4d614..44dc21f57b 100644 --- a/website/src/utils/ical.test.ts +++ b/website/src/utils/ical.test.ts @@ -345,7 +345,7 @@ describe(iCalForTimetable, () => { CS1010S, CS3216, }; - const actual = iCalForTimetable(1, mockTimetable, moduleData, [], {}); + const actual = iCalForTimetable(1, mockTimetable, moduleData, [], []); // 5 lesson types for cs1010s, 1 for cs3216, 1 exam for cs1010s expect(actual).toHaveLength(7); }); @@ -355,7 +355,7 @@ describe(iCalForTimetable, () => { CS1010S, CS3216, }; - const actual = iCalForTimetable(1, mockTimetable, moduleData, ['CS3216'], {}); + const actual = iCalForTimetable(1, mockTimetable, moduleData, ['CS3216'], []); // 5 lesson types for cs1010s, 1 exam for cs1010s (1 lesson for cs3216 will be excluded) expect(actual).toHaveLength(6); }); @@ -365,9 +365,7 @@ describe(iCalForTimetable, () => { CS1010S, CS3216, }; - const actual = iCalForTimetable(1, mockTimetable, moduleData, [], { - CS1010S: [['Tutorial', '1']], - }); + const actual = iCalForTimetable(1, mockTimetable, moduleData, [], [CS1010S.moduleCode]); // 5 lesson types for cs1010s, 1 for cs3216 (1 exam for cs1010s will be excluded) expect(actual).toHaveLength(6); }); diff --git a/website/src/utils/ical.ts b/website/src/utils/ical.ts index 58d4e0ffae..2819f00d9d 100644 --- a/website/src/utils/ical.ts +++ b/website/src/utils/ical.ts @@ -188,7 +188,7 @@ export default function iCalForTimetable( _.each(timetable, (lessonConfig, moduleCode) => { if (hiddenModules.includes(moduleCode)) return; - const isTa = moduleCode in taModules; + const isTa = taModules.includes(moduleCode); _.each(lessonConfig, (lessons) => { lessons.forEach((lesson) => { diff --git a/website/src/utils/modules.test.ts b/website/src/utils/modules.test.ts index 3b2b6e5199..41e55250bc 100644 --- a/website/src/utils/modules.test.ts +++ b/website/src/utils/modules.test.ts @@ -3,7 +3,6 @@ import { Semester, SemesterData } from 'types/modules'; import { addAcadYear, - areLessonsSameClass, formatExamDate, getExamDate, getFirstAvailableSemester, @@ -16,16 +15,14 @@ import { isGraduateModule, renderExamDuration, getExamDuration, - canTa, - areLessonsDuplicate, } from 'utils/modules'; import { noBreak } from 'utils/react'; import { EVERY_WEEK } from 'test-utils/timetable'; -import { CP3880, CS1010S, CS3216 } from '__mocks__/modules'; -import { Lesson } from 'types/timetables'; +import { CS1010S, CS3216 } from '__mocks__/modules'; +import { LessonWithIndex } from 'types/timetables'; -const mockLesson = _.cloneDeep(CS1010S.semesterData[0].timetable[0]) as Lesson; +const mockLesson = _.cloneDeep(CS1010S.semesterData[0].timetable[0]) as LessonWithIndex; mockLesson.moduleCode = 'CS1010S'; mockLesson.title = 'Programming Methodology'; @@ -43,6 +40,7 @@ test('getModuleSemesterData should return semester data if semester is present', startTime: '1830', endTime: '2030', venue: 'VCRm', + lessonIndex: 0, }, ], }; @@ -55,63 +53,6 @@ test('getModuleSemesterData should return undefined if semester is absent', () = expect(actual).toBe(undefined); }); -function lessonWithDifferentProperty( - lesson: Lesson, - property: keyof Lesson, - newValue: any = 'TEST', -): Lesson { - const anotherLesson: Lesson = _.cloneDeep(lesson); - return { ...anotherLesson, [property]: newValue }; -} - -test('areLessonsSameClass should identify identity lessons as same class', () => { - const deepClonedLesson: Lesson = _.cloneDeep(mockLesson); - expect(areLessonsSameClass(mockLesson, deepClonedLesson)).toBe(true); -}); - -test( - 'areLessonsSameClass should identify lessons from the same ClassNo but ' + - 'with different timings as same class', - () => { - const otherLesson: Lesson = lessonWithDifferentProperty(mockLesson, 'startTime', '0000'); - const otherLesson2: Lesson = lessonWithDifferentProperty(otherLesson, 'endTime', '2300'); - expect(areLessonsSameClass(mockLesson, otherLesson2)).toBe(true); - }, -); - -test('areLessonsSameClass should identify lessons with different ModuleCode as different class', () => { - const otherLesson: Lesson = lessonWithDifferentProperty(mockLesson, 'moduleCode'); - expect(areLessonsSameClass(mockLesson, otherLesson)).toBe(false); -}); - -test('areLessonsSameClass should identify lessons with different ClassNo as different class', () => { - const otherLesson: Lesson = lessonWithDifferentProperty(mockLesson, 'classNo'); - expect(areLessonsSameClass(mockLesson, otherLesson)).toBe(false); -}); - -test('areLessonsSameClass should identify lessons with different lessonType as different class', () => { - const otherLesson: Lesson = lessonWithDifferentProperty(mockLesson, 'lessonType'); - expect(areLessonsSameClass(mockLesson, otherLesson)).toBe(false); -}); - -test( - 'areLessonsDuplicate should identify lessons from the same ClassNo but ' + - 'with different timings as non duplicates', - () => { - const otherLesson: Lesson = lessonWithDifferentProperty(mockLesson, 'startTime', '0000'); - expect(areLessonsDuplicate(mockLesson, otherLesson)).toBe(false); - }, -); - -test( - 'areLessonsDuplicate should identify lessons from the same ClassNo but ' + - 'with different day as non duplicates', - () => { - const otherLesson: Lesson = lessonWithDifferentProperty(mockLesson, 'day', 'Monday'); - expect(areLessonsDuplicate(mockLesson, otherLesson)).toBe(false); - }, -); - test('formatExamDate should format an exam date string correctly', () => { expect(formatExamDate('2016-11-23T01:00:00.000Z')).toBe('23-Nov-2016 9:00\u00a0AM'); expect(formatExamDate('2016-01-23T01:00:00.000Z')).toBe('23-Jan-2016 9:00\u00a0AM'); @@ -264,23 +205,3 @@ describe(isGraduateModule, () => { expect(isGraduateModule({ moduleCode: 'ACC4999X' })).toEqual(false); }); }); - -describe(canTa, () => { - const modules = { CP3880, CS1010S, CS3216 }; - - it('should return true for modules with non-lecture lessons', () => { - expect(canTa(modules, 'CS1010S', 1)).toEqual(true); - }); - - it('should return false for modules with only lecture lessons', () => { - expect(canTa(modules, 'CS3216', 1)).toEqual(false); - }); - - it('should return false for modules without lessons', () => { - expect(canTa(modules, 'CP3880', 1)).toEqual(false); - }); - - it('should return false for unknown modules', () => { - expect(canTa(modules, 'ZZ9999', 1)).toEqual(false); - }); -}); diff --git a/website/src/utils/modules.ts b/website/src/utils/modules.ts index 56b671d0d8..fdba97133f 100644 --- a/website/src/utils/modules.ts +++ b/website/src/utils/modules.ts @@ -3,7 +3,7 @@ import { format } from 'date-fns'; import type { Module, ModuleCode, - RawLesson, + RawLessonWithIndex, Semester, SemesterData, SemesterDataCondensed, @@ -11,8 +11,6 @@ import type { import config from 'config'; import { NBSP, noBreak } from 'utils/react'; -import { Lesson } from 'types/timetables'; -import { ModulesMap } from 'types/reducers'; import { toSingaporeTime } from './timify'; // Look for strings that look like module codes - eg. @@ -31,30 +29,13 @@ export function getModuleSemesterData( } // Returns a flat array of lessons of a module for the corresponding semester. -export function getModuleTimetable(module: Module, semester: Semester): readonly RawLesson[] { +export function getModuleTimetable( + module: Module, + semester: Semester, +): readonly RawLessonWithIndex[] { return _.get(getModuleSemesterData(module, semester), 'timetable', []); } -// Do these two lessons belong to the same class? -export function areLessonsSameClass(lesson1: Lesson, lesson2: Lesson): boolean { - return ( - lesson1.moduleCode === lesson2.moduleCode && - lesson1.classNo === lesson2.classNo && - lesson1.lessonType === lesson2.lessonType - ); -} - -// Are the two lessons exact duplicates of one another -export function areLessonsDuplicate(lesson1: Lesson, lesson2: Lesson): boolean { - return ( - lesson1.moduleCode === lesson2.moduleCode && - lesson1.classNo === lesson2.classNo && - lesson1.lessonType === lesson2.lessonType && - lesson1.day === lesson2.day && - lesson1.startTime === lesson2.startTime - ); -} - /** * Convert exam in ISO format to 12-hour date/time format. */ @@ -157,11 +138,3 @@ export function getYearsBetween(minYear: string, maxYear: string): string[] { export function isGraduateModule(module: { moduleCode: ModuleCode }): boolean { return Boolean(/[A-Z]+(5|6)\d{3}/i.test(module.moduleCode)); } - -// A module is TA-able if it has at least 1 non-lecture lesson -export function canTa(modules: ModulesMap, moduleCode: ModuleCode, semester: Semester): boolean { - const module = modules[moduleCode]; - if (!module) return false; - const moduleTimetable = getModuleTimetable(module, semester); - return moduleTimetable.some((lesson) => lesson.lessonType !== 'Lecture'); -} diff --git a/website/src/utils/timetables.test.ts b/website/src/utils/timetables.test.ts index 846a94186e..d1bf431d73 100644 --- a/website/src/utils/timetables.test.ts +++ b/website/src/utils/timetables.test.ts @@ -1,22 +1,29 @@ import NUSModerator from 'nusmoderator'; -import _ from 'lodash'; +import _, { get } from 'lodash'; import { parseISO } from 'date-fns'; import { + ClassNoTaModulesConfig, ColoredLesson, ModuleLessonConfig, SemTimetableConfig, SemTimetableConfigWithLessons, - TaModulesConfig, TimetableArrangement, TimetableDayArrangement, TimetableDayFormat, } from 'types/timetables'; -import { LessonType, RawLesson, Semester, Weeks } from 'types/modules'; +import { + LessonType, + ModuleCode, + RawLesson, + RawLessonWithIndex, + Semester, + Weeks, +} from 'types/modules'; import { ModulesMap } from 'types/reducers'; import { getModuleSemesterData, getModuleTimetable } from 'utils/modules'; -import { CS1010S, CS3216, CS4243, PC1222, CS1010A } from '__mocks__/modules'; +import { CS1010S, CS3216, CS4243, PC1222, CS1010A, GER1000 } from '__mocks__/modules'; import moduleCodeMapJSON from '__mocks__/module-code-map.json'; import timetable from '__mocks__/sem-timetable.json'; import lessonsArray from '__mocks__/lessons-array.json'; @@ -33,21 +40,22 @@ import { areOtherClassesAvailable, arrangeLessonsForWeek, arrangeLessonsWithinDay, - deserializeTa, deserializeTimetable, doLessonsOverlap, findExamClashes, formatNumericWeeks, + getClosestLessonConfig, getEndTimeAsDate, getStartTimeAsDate, groupLessonsByDay, hydrateSemTimetableWithLessons, - hydrateTaModulesConfigWithLessons, isLessonAvailable, isLessonOngoing, isSameTimetableConfig, isValidSemester, lessonsForLessonType, + migrateModuleLessonConfig, + parseTaModuleCodes, randomModuleLessonConfig, serializeTimetable, timetableLessonsArray, @@ -81,21 +89,22 @@ test('randomModuleLessonConfig should return a random lesson config', () => { }); }); +// TODO: how to test TA config... test('hydrateSemTimetableWithLessons should replace ClassNo with lessons', () => { const sem: Semester = 1; const moduleCode = 'CS1010S'; - const modules: ModulesMap = { [moduleCode]: CS1010S }; + const modulesMap: ModulesMap = { [moduleCode]: CS1010S }; const config: SemTimetableConfig = { [moduleCode]: { - Tutorial: '8', - Recitation: '4', - Lecture: '1', + Tutorial: [42], + Recitation: [5], + Lecture: [0], }, }; const configWithLessons: SemTimetableConfigWithLessons = hydrateSemTimetableWithLessons( config, - modules, + modulesMap, sem, ); expect(configWithLessons[moduleCode].Tutorial[0].classNo).toBe('8'); @@ -103,29 +112,6 @@ test('hydrateSemTimetableWithLessons should replace ClassNo with lessons', () => expect(configWithLessons[moduleCode].Lecture[0].classNo).toBe('1'); }); -test('hydrateTaModulesConfigWithLessons should replace ClassNo with lessons', () => { - const sem: Semester = 1; - const moduleCode = 'CS1010S'; - const modules: ModulesMap = { [moduleCode]: CS1010S }; - const taModules: TaModulesConfig = { - [moduleCode]: [ - ['Tutorial', '1'], - ['Tutorial', '8'], - ['Recitation', '4'], - ], - }; - - const configWithLessons: SemTimetableConfigWithLessons = hydrateTaModulesConfigWithLessons( - taModules, - modules, - sem, - ); - expect(configWithLessons[moduleCode].Tutorial[0].classNo).toBe('1'); - expect(configWithLessons[moduleCode].Tutorial[1].classNo).toBe('8'); - expect(configWithLessons[moduleCode].Recitation[0].classNo).toBe('4'); - expect(configWithLessons[moduleCode]).not.toHaveProperty('Lecture'); -}); - test('lessonsForLessonType should return all lessons belonging to a particular lessonType', () => { const sem: Semester = 1; const moduleTimetable = getModuleTimetable(CS1010S, sem); @@ -152,13 +138,14 @@ test('timetableLessonsArray should return a flat array of lessons', () => { test('groupLessonsByDay should group lessons by DayText', () => { const lessons: ColoredLesson[] = lessonsArray; - const lessonsGroupedByDay: TimetableDayFormat = groupLessonsByDay(lessons); + const lessonsGroupedByDay: TimetableDayFormat = groupLessonsByDay(lessons); expect(lessonsGroupedByDay.Monday.length).toBe(2); expect(lessonsGroupedByDay.Tuesday.length).toBe(1); expect(lessonsGroupedByDay.Wednesday.length).toBe(1); expect(lessonsGroupedByDay.Thursday.length).toBe(2); }); +// TODO: write one for array lesson overlap test('doLessonsOverlap should correctly determine if two lessons overlap', () => { // Same day same time. expect( @@ -256,11 +243,11 @@ test('doLessonsOverlap should correctly determine if two lessons overlap', () => test('arrangeLessonsWithinDay', () => { // Empty array. - const arrangement0: TimetableDayArrangement = arrangeLessonsWithinDay([]); + const arrangement0: TimetableDayArrangement = arrangeLessonsWithinDay([]); expect(arrangement0.length).toBe(1); // Can fit within one row. - const arrangement1: TimetableDayArrangement = arrangeLessonsWithinDay( + const arrangement1: TimetableDayArrangement = arrangeLessonsWithinDay( _.shuffle([ createGenericColoredLesson('Monday', '1000', '1200'), createGenericColoredLesson('Monday', '1600', '1800'), @@ -270,7 +257,7 @@ test('arrangeLessonsWithinDay', () => { expect(arrangement1.length).toBe(1); // Two rows. - const arrangement2: TimetableDayArrangement = arrangeLessonsWithinDay( + const arrangement2: TimetableDayArrangement = arrangeLessonsWithinDay( _.shuffle([ createGenericColoredLesson('Monday', '1000', '1200'), createGenericColoredLesson('Monday', '1600', '1800'), @@ -280,7 +267,7 @@ test('arrangeLessonsWithinDay', () => { expect(arrangement2.length).toBe(2); // Three rows. - const arrangement3: TimetableDayArrangement = arrangeLessonsWithinDay( + const arrangement3: TimetableDayArrangement = arrangeLessonsWithinDay( _.shuffle([ createGenericColoredLesson('Monday', '1000', '1200'), createGenericColoredLesson('Monday', '1100', '1300'), @@ -291,7 +278,7 @@ test('arrangeLessonsWithinDay', () => { }); test('arrangeLessonsForWeek', () => { - const arrangement0: TimetableArrangement = arrangeLessonsForWeek( + const arrangement0: TimetableArrangement = arrangeLessonsForWeek( _.shuffle([ createGenericColoredLesson('Monday', '1000', '1200'), createGenericColoredLesson('Monday', '1600', '1800'), @@ -300,7 +287,7 @@ test('arrangeLessonsForWeek', () => { ); expect(arrangement0.Monday.length).toBe(1); - const arrangement1: TimetableArrangement = arrangeLessonsForWeek( + const arrangement1: TimetableArrangement = arrangeLessonsForWeek( _.shuffle([ createGenericColoredLesson('Monday', '1000', '1200'), createGenericColoredLesson('Monday', '1600', '1800'), @@ -312,7 +299,7 @@ test('arrangeLessonsForWeek', () => { expect(arrangement1.Monday.length).toBe(1); expect(arrangement1.Tuesday.length).toBe(2); - const arrangement2: TimetableArrangement = arrangeLessonsForWeek( + const arrangement2: TimetableArrangement = arrangeLessonsForWeek( _.shuffle([ createGenericColoredLesson('Monday', '1000', '1200'), createGenericColoredLesson('Monday', '1600', '1800'), @@ -324,7 +311,7 @@ test('arrangeLessonsForWeek', () => { expect(arrangement2.Monday.length).toBe(1); expect(arrangement2.Tuesday.length).toBe(1); - const arrangement3: TimetableArrangement = arrangeLessonsForWeek( + const arrangement3: TimetableArrangement = arrangeLessonsForWeek( _.shuffle([ createGenericColoredLesson('Monday', '1000', '1200'), createGenericColoredLesson('Tuesday', '1100', '1300'), @@ -335,7 +322,7 @@ test('arrangeLessonsForWeek', () => { expect(arrangement3.Tuesday.length).toBe(1); expect(arrangement3.Wednesday.length).toBe(1); - const arrangement4: TimetableArrangement = arrangeLessonsForWeek( + const arrangement4: TimetableArrangement = arrangeLessonsForWeek( _.shuffle([ createGenericColoredLesson('Monday', '1000', '1200'), createGenericColoredLesson('Monday', '1600', '1800'), @@ -347,7 +334,7 @@ test('arrangeLessonsForWeek', () => { expect(arrangement4.Tuesday.length).toBe(1); expect(arrangement4.Wednesday.length).toBe(1); - const arrangement5: TimetableArrangement = arrangeLessonsForWeek( + const arrangement5: TimetableArrangement = arrangeLessonsForWeek( _.shuffle([ createGenericColoredLesson('Monday', '1000', '1200'), createGenericColoredLesson('Monday', '1600', '1800'), @@ -413,44 +400,232 @@ test('findExamClashes should return non-empty object if exams starting at differ expect(examClashes).toEqual({ [examDate]: [CS1010S, CS1010A] }); }); -test('timetable serialization/deserialization', () => { - const configs: SemTimetableConfig[] = [ - {}, - { CS1010S: {} }, - { - GER1000: { Tutorial: 'B01' }, - }, - { - CS2104: { Lecture: '1', Tutorial: '2' }, - CS2105: { Lecture: '1', Tutorial: '1' }, - CS2107: { Lecture: '1', Tutorial: '8' }, - CS4212: { Lecture: '1', Tutorial: '1' }, - CS4243: { Laboratory: '2', Lecture: '1' }, - GER1000: { Tutorial: 'B01' }, - }, - ]; +describe('timetable serialization/deserialization', () => { + const mockSemesterTimetable: { [moduleCode: ModuleCode]: readonly RawLessonWithIndex[] } = { + CS1010S: getModuleTimetable(CS1010S, 1), + CS3216: getModuleTimetable(CS3216, 1), + GER1000: getModuleTimetable(GER1000, 1), + CS4243: getModuleTimetable(CS4243, 1), + }; + const mockGetModuleSemesterTimetable = (moduleCode: ModuleCode): readonly RawLessonWithIndex[] => + get(mockSemesterTimetable, moduleCode); + + test('timetable serialization/deserialization', () => { + const configs: SemTimetableConfig[] = [ + {}, + { CS1010S: {} }, + { + GER1000: { Tutorial: [13] }, + }, + { + CS4243: { Laboratory: [2], Lecture: [5] }, + GER1000: { Tutorial: [13] }, + }, + ]; - configs.forEach((config) => { - expect(deserializeTimetable(serializeTimetable(config))).toEqual(config); + configs.forEach((config) => { + expect( + deserializeTimetable(serializeTimetable(config), mockGetModuleSemesterTimetable) + .semTimetableConfig, + ).toEqual(config); + }); }); -}); -test('deserializing edge cases', () => { - // Duplicate module code - expect(deserializeTimetable('CS1010S=LEC:01&CS1010S=REC:11')).toEqual({ - CS1010S: { - Lecture: '01', - Recitation: '11', - }, + test('deserializing ta modules', () => { + expect( + deserializeTimetable('CS1010S=LEC:(0)&ta=CS1010S', mockGetModuleSemesterTimetable), + ).toEqual({ + semTimetableConfig: { + CS1010S: { + Lecture: [0], + }, + }, + ta: ['CS1010S'], + hidden: [], + }); }); - // No lessons - expect(deserializeTimetable('CS1010S&CS3217&CS2105=LEC:1')).toEqual({ - CS1010S: {}, - CS3217: {}, - CS2105: { - Lecture: '1', - }, + describe('deserializing edge cases', () => { + test('duplicate module code', () => { + expect( + deserializeTimetable('CS1010S=LEC:(0)&CS1010S=REC:(1)', mockGetModuleSemesterTimetable) + .semTimetableConfig, + ).toEqual({ + CS1010S: { + Lecture: [0], + Recitation: [1], + }, + }); + }); + + test('no lessons', () => { + expect( + deserializeTimetable( + 'CS2105&CS3217&CS1010S=LEC:(0)&ta=&hidden=', + mockGetModuleSemesterTimetable, + ).semTimetableConfig, + ).toEqual({ + CS2105: {}, + CS3217: {}, + CS1010S: { + Lecture: [0], + }, + }); + }); + + test('should ignore invalid lesson indices', () => { + expect( + deserializeTimetable('CS1010S=LEC:(20)', mockGetModuleSemesterTimetable).semTimetableConfig, + ).toEqual({ + CS1010S: { + Lecture: [], + }, + }); + }); + }); + + test('should return empty array if v2 serialized', () => { + expect(parseTaModuleCodes('(CS1010S,CS3216)')).toEqual([]); + }); + + describe('deserialize v1 config', () => { + test('deserialize v1', () => { + expect( + deserializeTimetable( + 'CS1010S=LEC:1,TUT:8&CS3216=LEC:1&ta=CS3216(LEC:1),CS1010S(LEC:1,TUT:2)&hidden=CS3216', + mockGetModuleSemesterTimetable, + ), + ).toEqual({ + semTimetableConfig: { + CS1010S: { + Lecture: [0], + Tutorial: [21], + }, + CS3216: { + Lecture: [0], + }, + }, + ta: ['CS3216', 'CS1010S'], + hidden: ['CS3216'], + }); + }); + + test('should ignore invalid lesson type', () => { + expect( + deserializeTimetable( + 'CS1010S=LEC:1&ta=CS1010S(TUT:2,INVALIDLESSONTYPE:1)', + mockGetModuleSemesterTimetable, + ), + ).toEqual({ + semTimetableConfig: { + CS1010S: { + Tutorial: [21], + }, + }, + ta: ['CS1010S'], + hidden: [], + }); + }); + + test('should ignore invalid classNo', () => { + expect( + deserializeTimetable('CS1010S=LEC:INVALIDCLASSNO', mockGetModuleSemesterTimetable), + ).toEqual({ + semTimetableConfig: { + CS1010S: { + Lecture: [], + }, + }, + ta: [], + hidden: [], + }); + }); + + test('use only last ta param', () => { + expect( + deserializeTimetable( + 'CS1010S=LEC:1&ta=CS3216(LEC:1)&ta=CS1010S(TUT:2)', + mockGetModuleSemesterTimetable, + ), + ).toEqual({ + semTimetableConfig: { + CS1010S: { + Tutorial: [21], + }, + }, + ta: ['CS1010S'], + hidden: [], + }); + }); + + test('should ignore invalid ta lessons', () => { + expect( + deserializeTimetable('CS1010S=LEC:1&ta=CS1010S(LEC:2)', mockGetModuleSemesterTimetable), + ).toEqual({ + semTimetableConfig: { + CS1010S: { + Lecture: [], + }, + }, + ta: ['CS1010S'], + hidden: [], + }); + }); + + test('ta module config without lessons', () => { + expect( + deserializeTimetable('CS1010S=LEC:1,TUT:3&ta=CS1010S()', mockGetModuleSemesterTimetable), + ).toEqual({ + semTimetableConfig: { + CS1010S: {}, + }, + ta: ['CS1010S'], + hidden: [], + }); + }); + + test('ignore modules without semester data', () => { + expect( + deserializeTimetable( + 'CS1010S=LEC:1,REC:1,TUT:3&ta=CS3217(LEC:1)', + mockGetModuleSemesterTimetable, + ), + ).toEqual({ + semTimetableConfig: { + CS1010S: { + Lecture: [0], + Recitation: [1], + Tutorial: [30], + }, + }, + ta: [], + hidden: [], + }); + }); + + test('should ignore invalid ta module config', () => { + expect( + deserializeTimetable( + 'CS1010S=LEC:1,REC:1,TUT:3&ta=INVALID),CS1010S(LEC:1)', + mockGetModuleSemesterTimetable, + ), + ).toEqual({ + semTimetableConfig: { + CS1010S: { + Lecture: [0], + }, + }, + ta: ['CS1010S'], + hidden: [], + }); + }); + + test('should return array of module codes', () => { + expect(parseTaModuleCodes('CS1010S(LEC:1,TUT:1),CS3216(LEC:1)')).toEqual([ + 'CS1010S', + 'CS3216', + ]); + }); }); }); @@ -461,8 +636,8 @@ test('isSameTimetableConfig', () => { // Change lessonType order expect( isSameTimetableConfig( - { CS2104: { Tutorial: '1', Lecture: '2' } }, - { CS2104: { Lecture: '2', Tutorial: '1' } }, + { CS2104: { Tutorial: [1], Lecture: [2] } }, + { CS2104: { Lecture: [2], Tutorial: [1] } }, ), ).toBe(true); @@ -470,12 +645,12 @@ test('isSameTimetableConfig', () => { expect( isSameTimetableConfig( { - CS2104: { Lecture: '1', Tutorial: '2' }, - CS2105: { Lecture: '1', Tutorial: '1' }, + CS2104: { Lecture: [1], Tutorial: [2] }, + CS2105: { Lecture: [1], Tutorial: [1] }, }, { - CS2105: { Lecture: '1', Tutorial: '1' }, - CS2104: { Lecture: '1', Tutorial: '2' }, + CS2105: { Lecture: [1], Tutorial: [1] }, + CS2104: { Lecture: [1], Tutorial: [2] }, }, ), ).toBe(true); @@ -483,8 +658,8 @@ test('isSameTimetableConfig', () => { // Different values expect( isSameTimetableConfig( - { CS2104: { Lecture: '1', Tutorial: '2' } }, - { CS2104: { Lecture: '2', Tutorial: '1' } }, + { CS2104: { Lecture: [1], Tutorial: [2] } }, + { CS2104: { Lecture: [2], Tutorial: [1] } }, ), ).toBe(false); @@ -492,11 +667,11 @@ test('isSameTimetableConfig', () => { expect( isSameTimetableConfig( { - CS2104: { Tutorial: '1', Lecture: '2' }, + CS2104: { Tutorial: [1], Lecture: [2] }, }, { - CS2104: { Tutorial: '1', Lecture: '2' }, - CS2105: { Lecture: '1', Tutorial: '1' }, + CS2104: { Tutorial: [1], Lecture: [2] }, + CS2105: { Lecture: [1], Tutorial: [1] }, }, ), ).toBe(false); @@ -529,16 +704,26 @@ describe(validateTimetableModules, () => { }); }); -describe('validateModuleLessons', () => { +// TODO: validate module lessons +// - either normal non-TA or TA module +// - remove lesson group if there are lessons of other lesson types +// - if module is a normal non TA module: +// - remove lesson group if any lesson is missing +// - remove lesson group if there are extra lessons +// +describe(validateModuleLessons, () => { const semester: Semester = 1; const lessons: ModuleLessonConfig = { - Lecture: '1', - Recitation: '10', - Tutorial: '11', + Lecture: [0], + Recitation: [2], + Tutorial: [13], }; test('should leave valid lessons untouched', () => { - expect(validateModuleLessons(semester, lessons, CS1010S)).toEqual([lessons, []]); + expect(validateModuleLessons(semester, lessons, CS1010S, false)).toEqual({ + validatedLessonConfig: lessons, + valid: true, + }); }); test('should remove lesson types which do not exist', () => { @@ -547,24 +732,26 @@ describe('validateModuleLessons', () => { semester, { ...lessons, - Laboratory: '2', // CS1010S has no lab + Laboratory: [0], // CS1010S has no lab }, CS1010S, + false, ), - ).toEqual([lessons, ['Laboratory']]); + ).toEqual({ validatedLessonConfig: lessons, valid: false }); }); - test('should replace lessons with invalid class no', () => { + test('should replace lessons that have invalid class no', () => { expect( validateModuleLessons( semester, { ...lessons, - Lecture: '2', // CS1010S has no Lecture 2 + Lecture: [2], // CS1010S lesson index 2 is not a lecture }, CS1010S, + false, ), - ).toEqual([lessons, ['Lecture']]); + ).toEqual({ validatedLessonConfig: lessons, valid: false }); }); test('should add lessons for when they are missing', () => { @@ -572,18 +759,19 @@ describe('validateModuleLessons', () => { validateModuleLessons( semester, { - Tutorial: '10', + Tutorial: [13], }, CS1010S, + false, ), - ).toEqual([ - { - Lecture: '1', - Recitation: '1', - Tutorial: '10', + ).toEqual({ + validatedLessonConfig: { + Lecture: [0], + Recitation: [1], + Tutorial: [13], }, - ['Lecture', 'Recitation'], - ]); + valid: false, + }); }); }); @@ -680,26 +868,67 @@ describe(getEndTimeAsDate, () => { }); }); -describe(deserializeTa, () => { - test('should deserialize TA modules string into object', () => { - expect(deserializeTa('?')).toEqual({}); - expect(deserializeTa('?CS1010S=REC:1,TUT:8')).toEqual({}); - expect(deserializeTa('?CS1010S=REC:1,TUT:8&ta=CS3210(TUT:02)')).toEqual({ - CS3210: [['Tutorial', '02']], +describe('v1 config migration', () => { + const moduleLessonConfig = { + Lecture: '1', + }; + const moduleTimetable = getModuleTimetable(CS1010S, 1); + test('should do nothing if already migrated', () => { + const migrationResult = migrateModuleLessonConfig( + { + Lecture: [0], + }, + [], + 'CS1010S', + moduleTimetable, + ); + expect(migrationResult).toEqual({ + migratedModuleLessonConfig: { + Lecture: [0], + }, + alreadyMigrated: true, }); - expect(deserializeTa('?CS1010S=REC:1,TUT:8&ta=CS3210(TUT:02),CS3219(TUT:15)')).toEqual({ - CS3210: [['Tutorial', '02']], - CS3219: [['Tutorial', '15']], + }); + + test('should not error if ta module config is mismatched', () => { + const migrationResult = migrateModuleLessonConfig( + moduleLessonConfig, + [], + 'CS1010S', + moduleTimetable, + ); + expect(migrationResult).toEqual({ + migratedModuleLessonConfig: { + Lecture: [0], + }, + alreadyMigrated: false, }); - expect( - deserializeTa('?CS1010S=REC:1,TUT:8&ta=CS3210(TUT:02),CS3219(TUT:15),CS3211(LAB:B05,TUT:13)'), - ).toEqual({ - CS3210: [['Tutorial', '02']], - CS3219: [['Tutorial', '15']], - CS3211: [ - ['Laboratory', 'B05'], - ['Tutorial', '13'], + }); + + test('should ignore invalid classNo', () => { + const invalidTaModuleConfig = { + CS1010S: [ + ['Lecture', '1'], + ['Lecture', '2'], ], + } as ClassNoTaModulesConfig; + const migrationResult = migrateModuleLessonConfig( + moduleLessonConfig, + invalidTaModuleConfig, + 'CS1010S', + moduleTimetable, + ); + expect(migrationResult).toEqual({ + migratedModuleLessonConfig: { + Lecture: [0], + }, + alreadyMigrated: false, }); }); }); + +describe(getClosestLessonConfig, () => { + test('ignore if lesson type has no classNo', () => { + expect(getClosestLessonConfig({ Lecture: {} }, { Lecture: [0] })).toEqual({}); + }); +}); diff --git a/website/src/utils/timetables.ts b/website/src/utils/timetables.ts index 01897093bd..65dcfdf7b7 100644 --- a/website/src/utils/timetables.ts +++ b/website/src/utils/timetables.ts @@ -1,55 +1,67 @@ import { AcadWeekInfo } from 'nusmoderator'; import { castArray, - difference, - each, - first, - flatMap, + entries, + filter, flatMapDeep, - forEach, + get, groupBy, + intersection, invert, + isArray, isEmpty, isEqual, + keys, last, map, mapValues, - omit, + maxBy, partition, pick, range, + reduce, sample, + some, values, } from 'lodash'; import { addDays, min as minDate, parseISO, startOfDay } from 'date-fns'; import qs from 'query-string'; import { - ClassNo, consumeWeeks, + LessonsByLessonTypeByClassNo, + LessonIndex, LessonType, + RawLessonWithIndex, Module, ModuleCode, NumericWeeks, RawLesson, Semester, + ClassNo, } from 'types/modules'; import { + ClassNoModuleLessonConfig, + ClassNoSemTimetableConfig, + ClassNoTaModulesConfig, + ClassNoTimetableConfig, ColoredLesson, HoverLesson, + InteractableLesson, Lesson, + LessonWithIndex, ModuleLessonConfig, ModuleLessonConfigWithLessons, SemTimetableConfig, SemTimetableConfigWithLessons, TaModulesConfig, - TimetableArrangement, + TimetableConfig, TimetableDayArrangement, TimetableDayFormat, } from 'types/timetables'; -import { ModuleCodeMap, ModulesMap } from 'types/reducers'; +import { ClassNoTaModulesMap, ModuleCodeMap, ModulesMap, TaModulesMap } from 'types/reducers'; import { ExamClashes } from 'types/views'; import { getTimeAsDate } from './timify'; @@ -78,7 +90,8 @@ export const LESSON_ABBREV_TYPE: { [key: string]: LessonType } = invert(LESSON_T // Used for module config serialization - these must be query string safe // See: https://stackoverflow.com/a/31300627 -export const LESSON_TYPE_SEP = ':'; +export const LESSON_TYPE_SEP = ';'; +export const LESSON_TYPE_KEY_VALUE_SEP = ':'; export const LESSON_SEP = ','; const EMPTY_OBJECT = {}; @@ -94,21 +107,25 @@ export function isValidSemester(semester: Semester): boolean { // [lessonType: string]: ClassNo, // } export function randomModuleLessonConfig(lessons: readonly RawLesson[]): ModuleLessonConfig { - const lessonByGroups: { [lessonType: string]: readonly RawLesson[] } = groupBy( - lessons, + const lessonsWithIndices = map(lessons, (lesson, lessonIndex) => ({ ...lesson, lessonIndex })); + + const lessonByGroups: { [lessonType: string]: readonly RawLessonWithIndex[] } = groupBy( + lessonsWithIndices, (lesson) => lesson.lessonType, ); const lessonByGroupsByClassNo: { - [lessonType: string]: { [classNo: string]: readonly RawLesson[] }; - } = mapValues(lessonByGroups, (lessonsOfSamelessonType: readonly RawLesson[]) => + [lessonType: string]: { [classNo: string]: readonly RawLessonWithIndex[] }; + } = mapValues(lessonByGroups, (lessonsOfSamelessonType: readonly RawLessonWithIndex[]) => groupBy(lessonsOfSamelessonType, (lesson) => lesson.classNo), ); return mapValues( lessonByGroupsByClassNo, - (group: { [classNo: string]: readonly RawLesson[] }) => - (first(sample(group)) as RawLesson).classNo, + (group: { [classNo: string]: readonly RawLessonWithIndex[] }) => { + const randomlySelectedLessons = sample(group); + return map(randomlySelectedLessons, 'lessonIndex'); + }, ); } @@ -129,47 +146,19 @@ export function hydrateSemTimetableWithLessons( ); } -// Replaces LessonType and ClassNo in TaModulesConfig with Array -export function hydrateTaModulesConfigWithLessons( - taModules: TaModulesConfig, - modules: ModulesMap, - semester: Semester, -): SemTimetableConfigWithLessons { - return mapValues( - taModules, - (lessons: [lessonType: LessonType, classNo: ClassNo][], moduleCode: ModuleCode) => { - const module = modules[moduleCode]; - if (!module) return EMPTY_OBJECT; - - const moduleLessonConfigWithLessons: ModuleLessonConfigWithLessons = {}; - forEach(lessons, ([lessonType, classNo]) => { - const moduleConfigWithLessons = hydrateModuleConfigWithLessons( - { [lessonType]: classNo }, - module, - semester, - ); - if (!(lessonType in moduleLessonConfigWithLessons)) { - moduleLessonConfigWithLessons[lessonType] = []; - } - moduleLessonConfigWithLessons[lessonType].push(...moduleConfigWithLessons[lessonType]); - }); - return moduleLessonConfigWithLessons; - }, - ); -} - // Replaces ClassNo in ModuleLessonConfig with Array function hydrateModuleConfigWithLessons( moduleLessonConfig: ModuleLessonConfig, module: Module, semester: Semester, ): ModuleLessonConfigWithLessons { - return mapValues(moduleLessonConfig, (classNo: ClassNo, lessonType: LessonType) => { + return mapValues(moduleLessonConfig, (lessonIndices: LessonIndex[]) => { const lessons = getModuleTimetable(module, semester); - const newLessons = lessons.filter( - (lesson: RawLesson) => lesson.lessonType === lessonType && lesson.classNo === classNo, + const lessonsWithIndices = map(lessons, (lesson, lessonIndex) => ({ ...lesson, lessonIndex })); + const newLessons = lessonsWithIndices.filter((lesson: RawLessonWithIndex) => + lessonIndices.includes(lesson.lessonIndex), ); - return newLessons.map((lesson: RawLesson) => ({ + return newLessons.map((lesson: RawLessonWithIndex) => ({ ...lesson, moduleCode: module.moduleCode, title: module.title, @@ -192,7 +181,7 @@ export function lessonsForLessonType( // [lessonType: string]: [Lesson, ...], // } // } -export function timetableLessonsArray(timetable: SemTimetableConfigWithLessons): Lesson[] { +export function timetableLessonsArray(timetable: SemTimetableConfigWithLessons): LessonWithIndex[] { return flatMapDeep(timetable, values); } @@ -201,12 +190,12 @@ export function timetableLessonsArray(timetable: SemTimetableConfigWithLessons): // Monday: [Lesson, Lesson, ...], // Tuesday: [Lesson, ...], // } -export function groupLessonsByDay(lessons: ColoredLesson[]): TimetableDayFormat { +export function groupLessonsByDay(lessons: T[]): TimetableDayFormat { return groupBy(lessons, (lesson) => lesson.day); } // Determines if two lessons overlap: -export function doLessonsOverlap(lesson1: Lesson, lesson2: Lesson): boolean { +export function doLessonsOverlap(lesson1: RawLesson, lesson2: RawLesson): boolean { return ( lesson1.day === lesson2.day && lesson1.startTime < lesson2.endTime && @@ -221,8 +210,10 @@ export function doLessonsOverlap(lesson1: Lesson, lesson2: Lesson): boolean { // [Lesson, Lesson, ...], // [Lesson, ...], // ] -export function arrangeLessonsWithinDay(lessons: ColoredLesson[]): TimetableDayArrangement { - const rows: TimetableDayArrangement = [[]]; +export function arrangeLessonsWithinDay( + lessons: T[], +): TimetableDayArrangement { + const rows: T[][] = [[]]; if (isEmpty(lessons)) { return rows; } @@ -230,9 +221,9 @@ export function arrangeLessonsWithinDay(lessons: ColoredLesson[]): TimetableDayA const timeDiff = a.startTime.localeCompare(b.startTime); return timeDiff !== 0 ? timeDiff : a.classNo.localeCompare(b.classNo); }); - sortedLessons.forEach((lesson: ColoredLesson) => { + sortedLessons.forEach((lesson: T) => { for (let i = 0; i < rows.length; i++) { - const rowLessons: ColoredLesson[] = rows[i]; + const rowLessons: T[] = rows[i]; const previousLesson = last(rowLessons); if (!previousLesson || !doLessonsOverlap(previousLesson, lesson)) { // Lesson does not overlap with any Lesson in the row. Add it to row. @@ -261,9 +252,9 @@ export function arrangeLessonsWithinDay(lessons: ColoredLesson[]): TimetableDayA // ], // ... // } -export function arrangeLessonsForWeek(lessons: ColoredLesson[]): TimetableArrangement { +export function arrangeLessonsForWeek(lessons: T[]): { [x: string]: T[][] } { const dayLessons = groupLessonsByDay(lessons); - return mapValues(dayLessons, (dayLesson: ColoredLesson[]) => arrangeLessonsWithinDay(dayLesson)); + return mapValues(dayLessons, (dayLesson: T[]) => arrangeLessonsWithinDay(dayLesson)); } // Determines if a Lesson on the timetable can be modifiable / dragged around. @@ -439,6 +430,127 @@ export function validateTimetableModules( return [pick(timetable, valid), invalid]; } +/** + * Valid TA modules configs must have lesson indices that belong to the correct lesson type + * @param lessonConfig lesson configs to validate + * @param validLessons lessons to validate against + * @returns + * - validated TA lesson config + * - whether the input is valid, to signal to skip dispatch + */ +export function validateTaModuleLessons( + lessonConfig: ModuleLessonConfig, + validLessons: readonly RawLessonWithIndex[], +): { + validatedLessonConfig: ModuleLessonConfig; + valid: boolean; +} { + const lessonsByType = groupBy(validLessons, (lesson) => lesson.lessonType); + const { config: validatedLessonConfig, valid } = reduce( + lessonConfig, + (accumulatedValidationResult, configLessonIndices, lessonType) => { + const validLessonIndices = map(lessonsByType[lessonType], 'lessonIndex'); + const hasInvalidLesson = some( + configLessonIndices, + (lessonIndex) => !validLessonIndices.includes(lessonIndex), + ); + return { + config: { + ...accumulatedValidationResult.config, + }, + valid: accumulatedValidationResult.valid && !hasInvalidLesson, + }; + }, + { config: {}, valid: true } as { config: ModuleLessonConfig; valid: boolean }, + ); + + return { + validatedLessonConfig, + valid, + }; +} + +export function getFirstClassNoLessonIndices( + lessonsWithLessonType: RawLessonWithIndex[], +): LessonIndex[] { + const { classNo } = lessonsWithLessonType[0]; + const validLessonIndices = map( + filter(lessonsWithLessonType, (lesson) => lesson.classNo === classNo), + 'lessonIndex', + ); + return validLessonIndices; +} + +/** + * Valid non-TA modules must have one and only one classNo for each lesson type + * @param lessonConfig lesson configs to validate + * @param validLessons lessons to validate against + * @returns + * - validated non-TA lesson config + * - whether the input is valid, to signal to skip dispatch + */ +export function validateNonTaModuleLesson( + lessonConfig: ModuleLessonConfig, + validLessons: readonly RawLessonWithIndex[], +): { + validatedLessonConfig: ModuleLessonConfig; + valid: boolean; +} { + const lessonsByType = groupBy(validLessons, (lesson) => lesson.lessonType); + const lessonTypesInLessonConfig = keys(lessonConfig); + const { config: validatedLessonConfig, valid: configValid } = reduce( + lessonsByType, + (accumulatedValidationResult, lessonsWithLessonType, lessonType) => { + const lessonTypeInLessonConfig = lessonTypesInLessonConfig.includes(lessonType); + const configLessonIndices = lessonConfig[lessonType]; + + if (!lessonTypeInLessonConfig || !configLessonIndices.length) { + // TODO: Open an issue to make recovery use random lessons instead + const validLessonIndices = getFirstClassNoLessonIndices(lessonsWithLessonType); + return { + config: { + ...accumulatedValidationResult.config, + [lessonType]: validLessonIndices, + }, + valid: false, + }; + } + + const { classNo } = validLessons[configLessonIndices[0]]; + const classNoLessonIndices = map( + filter(lessonsWithLessonType, (lesson) => lesson.classNo === classNo), + 'lessonIndex', + ); + const configLessonIndicesValid = isEqual( + new Set(configLessonIndices), + new Set(classNoLessonIndices), + ); + const validLessonIndices = configLessonIndicesValid + ? classNoLessonIndices + : getFirstClassNoLessonIndices(lessonsWithLessonType); + + return { + config: { + ...accumulatedValidationResult.config, + [lessonType]: validLessonIndices, + }, + valid: accumulatedValidationResult.valid && configLessonIndicesValid, + }; + }, + { config: {}, valid: true } as { config: ModuleLessonConfig; valid: boolean }, + ); + + const configLessonTypesValid = isEqual( + new Set(keys(validatedLessonConfig)), + new Set(lessonTypesInLessonConfig), + ); + + return { + validatedLessonConfig, + valid: configValid && configLessonTypesValid, + }; +} + /** * Validates the lesson config for a specific module. It replaces all lessons * which invalid class number with the first available class numbers, and @@ -451,37 +563,34 @@ export function validateModuleLessons( semester: Semester, lessonConfig: ModuleLessonConfig, module: Module, -): [ModuleLessonConfig, LessonType[]] { - const validatedLessonConfig: ModuleLessonConfig = {}; - const updatedLessonTypes: string[] = []; - + isTa: boolean, +): { validatedLessonConfig: ModuleLessonConfig; valid: boolean } { const validLessons = getModuleTimetable(module, semester); - const lessonsByType = groupBy(validLessons, (lesson) => lesson.lessonType); - each(lessonsByType, (lessons: RawLesson[], lessonType: LessonType) => { - const classNo = lessonConfig[lessonType]; - - // Check that the lesson exists and is valid. If it is not, insert a random - // valid lesson. This covers both - // - // - lesson type is not in the original timetable (ie. a new lesson type was introduced) - // in which case classNo is undefined and thus would not match - // - classNo is not valid anymore (ie. the class was removed) - // - // If a lesson type is removed, then it simply won't be copied over - if (!lessons.some((lesson) => lesson.classNo === classNo)) { - validatedLessonConfig[lessonType] = lessons[0].classNo; - updatedLessonTypes.push(lessonType); - } else { - validatedLessonConfig[lessonType] = classNo; - } - }); + if (isTa) { + return validateTaModuleLessons(lessonConfig, validLessons); + } - // Add all of the removed lesson types to the array of updated lesson types - updatedLessonTypes.push(...difference(Object.keys(lessonConfig), Object.keys(lessonsByType))); - return [validatedLessonConfig, updatedLessonTypes]; + return validateNonTaModuleLesson(lessonConfig, validLessons); } +/** + * Group lessons by lesson types then classNo + * @param lessonsWithIndex lessons to group + * @returns lesson indices, not lessons + */ +export const groupLessonsByLessonTypeByClassNo = ( + lessonsWithIndex: readonly RawLessonWithIndex[], +): LessonsByLessonTypeByClassNo => { + const lessonsByLessonType = groupBy(lessonsWithIndex, 'lessonType'); + return mapValues(lessonsByLessonType, (lessonsWithLessonType) => { + const lessonsByClassNo = groupBy(lessonsWithLessonType, 'classNo'); + return mapValues(lessonsByClassNo, (lessonsWithClassNo) => + map(lessonsWithClassNo, 'lessonIndex'), + ); + }); +}; + // Get information for all modules present in a semester timetable config export function getSemesterModules( timetable: { [moduleCode: string]: unknown }, @@ -490,30 +599,6 @@ export function getSemesterModules( return values(pick(modules, Object.keys(timetable))); } -function serializeModuleConfig(config: ModuleLessonConfig): string { - // eg. { Lecture: 1, Laboratory: 2 } => LEC:1,LAB:2 - return map(config, (classNo, lessonType) => - [LESSON_TYPE_ABBREV[lessonType], encodeURIComponent(classNo)].join(LESSON_TYPE_SEP), - ).join(LESSON_SEP); -} - -function parseModuleConfig(serialized: string | string[] | null): ModuleLessonConfig { - const config: ModuleLessonConfig = {}; - if (!serialized) return config; - - castArray(serialized).forEach((serializedModule) => { - serializedModule.split(LESSON_SEP).forEach((lesson) => { - const [lessonTypeAbbr, classNo] = lesson.split(LESSON_TYPE_SEP); - const lessonType = LESSON_ABBREV_TYPE[lessonTypeAbbr]; - // Ignore unparsable/invalid keys - if (!lessonType) return; - config[lessonType] = classNo; - }); - }); - - return config; -} - /** * Formats numeric week number array into something human readable * @@ -568,94 +653,340 @@ export function formatNumericWeeks(unprocessedWeeks: NumericWeeks): string | nul return `Weeks ${processed.join(', ')}`; } -// Converts a timetable config to query string -// eg: -// { -// CS2104: { Lecture: '1', Tutorial: '2' }, -// CS2107: { Lecture: '1', Tutorial: '8' }, -// } -// => CS2104=LEC:1,TUT:2&CS2107=LEC:1,TUT:8 +/** + * Serializes a module's lesson config for sharing\ + * Given input `{ Lecture: [0], Tutorial: [1] }`\ + * Will output `LEC:(0),TUT:(1)` + */ +function serializeModuleConfig(config: ModuleLessonConfig): string { + return map( + config, + (lessonIndex, lessonType) => + `${LESSON_TYPE_ABBREV[lessonType]}${LESSON_TYPE_KEY_VALUE_SEP}(${lessonIndex.join( + LESSON_SEP, + )})`, + ).join(';'); +} + +/** + * Converts a timetable config to query string\ + * Given input ` + * { + * CS2104: { Lecture: [0], Tutorial: [1] }, + * CS2107: { Lecture: [0], Tutorial: [1] }, + * }`\ + * Will output `CS2104=LEC:(0),TUT:(1)&CS2107=LEC:(0),TUT:(1)` + */ export function serializeTimetable(timetable: SemTimetableConfig): string { // We are using query string safe characters, so this encoding is unnecessary return qs.stringify(mapValues(timetable, serializeModuleConfig), { encode: false }); } -export function deserializeTimetable(serialized: string): SemTimetableConfig { - const params = qs.parse(serialized); - return mapValues(omit(params, ['hidden', 'ta']), parseModuleConfig); +// TODO merge logic for TA modules and hidden modules +/** + * Serializes TA modules for sharing\ + * Given input `["CS1010S", "CS3216"]`\ + * Will output `&ta=CS1010S,CS3216` + */ +export function serializeTa(taModules: TaModulesConfig): string { + return `&ta=${taModules.join(LESSON_SEP)}`; } -export function serializeHidden(hiddenModules: ModuleCode[]) { +export function serializeHidden(hiddenModules: ModuleCode[]): string { return `&hidden=${hiddenModules.join(',')}`; } -export function deserializeHidden(serialized: string): ModuleCode[] { - const params = qs.parse(serialized); - if (!params.hidden) return []; - // If user manually enters multiple hidden query keys, use latest one - const hidden = Array.isArray(params.hidden) ? last(params.hidden) : params.hidden; - if (!hidden) return []; - return hidden.split(','); -} - -export function serializeTa(taModules: TaModulesConfig) { - // eg: - // eg: - // { - // CS2100: [ ['Tutorial', '2'], ['Tutorial', '3'], ['Laboratory', '1'] ], - // CS2107: [ ['Tutorial', '8'] ], - // } - // => &ta=CS2100(TUT:2,TUT:3,LAB:1);CS2107(TUT:8) - return `&ta=${flatMap( - taModules, - (lessons, moduleCode) => - `${moduleCode}(${lessons - .map( - ([lessonType, classNo]) => - `${LESSON_TYPE_ABBREV[lessonType]}${LESSON_TYPE_SEP}${encodeURIComponent(classNo)}`, - ) - .join(LESSON_SEP)})`, - ).join(LESSON_SEP)}`; -} - -export function deserializeTa(serialized: string): TaModulesConfig { - const params = qs.parse(serialized); - const deserialized: TaModulesConfig = {}; - if (!params.ta) return deserialized; - // If user manually enters multiple TA query keys, use latest one - const ta = Array.isArray(params.ta) ? last(params.ta) : params.ta; - if (!ta) return deserialized; - ta.split(')').forEach((_moduleConfig) => { - // Second and subsequent matches have a leading comma - const moduleConfig = _moduleConfig.replace(/^,/, ''); - const moduleCodeMatches = moduleConfig.match(/(.*)\(/); - if (moduleCodeMatches === null) { - return; - } +/** + * Parses a classNo format serialized TA module lesson config string for module codes\ + * Prevents a crash if the TA module config includes a module code not inside the non-TA module config\ + * Given input `CS2100(TUT:2,TUT:3,LAB:1),CS2107(TUT:8)`\ + * Will output `["CS2100","CS2107"]` + * @param taSerialized a TA module lesson config string + * @returns TA module codes if the module lesson config is classNo format serialized\ + * Otherwise, returns an empty array + */ +export function parseTaModuleCodes(taSerialized?: string | null): ModuleCode[] { + if (!taSerialized || taSerialized[0] === '(') return []; + const trimmedSerializedTaModulesConfig = taSerialized.slice(0, -1); + return reduce( + trimmedSerializedTaModulesConfig.split(`)${LESSON_SEP}`), + (accumulatedTaModuleCodes, moduleConfig) => { + const [moduleCode] = moduleConfig.split('(', 1); + return [...accumulatedTaModuleCodes, moduleCode]; + }, + [] as ModuleCode[], + ); +} - const lessonsMatches = moduleConfig.match(/\((.*)/); - if (lessonsMatches === null) { - return; - } +/** + * Deserializes a classNo format serialized TA module lesson config string to a module lesson config\ + * Sample input: `CS2100(TUT:2,TUT:3,LAB:1),CS2107(TUT:8)` + * @param taSerialized + * @param getModuleSemesterTimetable + * @returns migrated semester timetable config + */ +export function deserializeClassNoFormattedTaModulesConfig( + taSerialized: string, + getModuleSemesterTimetable: (moduleCode: ModuleCode) => readonly RawLessonWithIndex[], +): SemTimetableConfig { + // CS2100(TUT:2,TUT:3,LAB:1),CS2107(TUT:8) + const trimmedSerializedTaModulesConfig = taSerialized.slice(0, -1); + // CS2100(TUT:2,TUT:3,LAB:1),CS2107(TUT:8 + return reduce( + trimmedSerializedTaModulesConfig.split(`)${LESSON_SEP}`), + (accumulatedTaTimetableConfig, moduleConfig) => { + // CS2100(TUT:2,TUT:3,LAB:1 + // CS2107(TUT:8 + const [moduleCode, lessons] = moduleConfig.split('('); + // ["CS2100", "TUT:2,TUT:3,LAB:1"] + // ["CS2107", "TUT:8"] + const timetable = getModuleSemesterTimetable(moduleCode); + if (!timetable) return accumulatedTaTimetableConfig; + + const moduleLessonConfig = lessons + .split(LESSON_SEP) + .reduce((accumulatedModuleLessonConfig, lesson) => { + // TUT:2 + const [lessonTypeAbbr, classNo] = lesson.split(LESSON_TYPE_KEY_VALUE_SEP); + // ["TUT", "2"] + const lessonType = LESSON_ABBREV_TYPE[lessonTypeAbbr]; + if (!lessonType) return accumulatedModuleLessonConfig; + const lessonsByLessonTypeByClassNo = groupLessonsByLessonTypeByClassNo(timetable); + const lessonIndices = get(get(lessonsByLessonTypeByClassNo, lessonType), classNo); + return { + ...accumulatedModuleLessonConfig, + [lessonType]: [ + ...(accumulatedModuleLessonConfig[lessonType] ?? []), + ...(lessonIndices ?? []), + ], + } as ModuleLessonConfig; + }, {} as ModuleLessonConfig); + + return { + ...accumulatedTaTimetableConfig, + [moduleCode]: moduleLessonConfig, + } as SemTimetableConfig; + }, + {} as SemTimetableConfig, + ); +} - const moduleCode = moduleCodeMatches[1]; - const lessons = lessonsMatches[1]; - lessons.split(LESSON_SEP).forEach((lesson) => { - const [lessonTypeAbbr, classNo] = lesson.split(LESSON_TYPE_SEP); - if (!(moduleCode in deserialized)) { - deserialized[moduleCode] = []; - } +/** + * Deserializes a lessonGroup format serialized string to a module lesson config\ + * Accepts moduleLessonConfig from previously parsed params, if any\ + * Sample input: `LEC:(0,1);TUT:(3)` + * @param moduleLessonConfig + * @param serializedModuleLessonConfig + * @param timetable Array of valid lessons + * @returns Combined moduleLessonConfig + */ +export function deserializeLessonGroupFormattedModuleLessonConfig( + moduleLessonConfig: ModuleLessonConfig, + serializedModuleLessonConfig: string, + timetable: readonly RawLessonWithIndex[], +): ModuleLessonConfig { + const lessonsByLessonType = groupBy(timetable, 'lessonType'); + // LEC:(0,1);TUT:(3) + return reduce( + serializedModuleLessonConfig.split(LESSON_TYPE_SEP), + (accumulatedModuleLessonConfig, lessonTypeSerialized) => { + // LEC:(0,1) + const [lessonTypeAbbr, lessonIndicesSerialized] = + lessonTypeSerialized.split(LESSON_TYPE_KEY_VALUE_SEP); + // ["LEC", "0,1"] + const lessonIndices = map( + lessonIndicesSerialized.slice(1, -1).split(LESSON_SEP), + (lessonIndex) => parseInt(lessonIndex, 10), + ); // [0, 1] const lessonType = LESSON_ABBREV_TYPE[lessonTypeAbbr]; - // Ignore unparsable/invalid keys - if (!lessonType) return; - deserialized[moduleCode].push([lessonType, classNo]); - }); - }); - return deserialized; + const validLessonIndices = map(lessonsByLessonType[lessonType], 'lessonIndex'); + const validatedLessonIndices = filter(lessonIndices, (lessonIndex) => + validLessonIndices.includes(lessonIndex), + ); + return { + ...accumulatedModuleLessonConfig, + [lessonType]: [ + ...(accumulatedModuleLessonConfig[lessonType] ?? []), + ...validatedLessonIndices, + ], + }; + }, + moduleLessonConfig, + ); +} + +/** + * Deserializes a classNo format serialized string to a module lesson config + * Accepts moduleLessonConfig from previously parsed params, if any + * @param moduleLessonConfig + * @param serializedModuleLessonConfig + * @param timetable Array of valid lessons + * @returns Combined moduleLessonConfig + */ +export function deserializeClassNoFormattedModuleLessonConfig( + moduleLessonConfig: ModuleLessonConfig, + serializedModuleLessonConfig: string, + timetable: readonly RawLessonWithIndex[], +): ModuleLessonConfig { + // LEC:1,TUT:1,REC:1 + return reduce( + serializedModuleLessonConfig.split(LESSON_SEP), + (accumulatedModuleLessonConfig, lessonTypeSerialized) => { + // LEC:1 + const [lessonTypeAbbr, classNo] = lessonTypeSerialized.split(LESSON_TYPE_KEY_VALUE_SEP); + // ["LEC", "1"] + const lessonType = LESSON_ABBREV_TYPE[lessonTypeAbbr]; + const lessonsByLessonTypeByClassNo = groupLessonsByLessonTypeByClassNo(timetable); + const lessonIndices = get(get(lessonsByLessonTypeByClassNo, lessonType), classNo); + return { + ...accumulatedModuleLessonConfig, + [lessonType]: [ + ...(accumulatedModuleLessonConfig[lessonType] ?? []), + ...(lessonIndices ?? []), + ], + }; + }, + moduleLessonConfig, + ); +} + +/** + * Deserializes hidden modules config and lesson group format TA modules config + */ +export function deserializeModuleCodes(serialized: string): ModuleCode[] { + return serialized.split(LESSON_SEP); +} + +/** + * Entry point to deserialize a serialized timetable string + * Checks serialization version and parses accordingly + * @param serialized + * @param getModuleSemesterTimetable + * @returns + */ +export function deserializeTimetable( + serialized: string, + getModuleSemesterTimetable: (moduleCode: ModuleCode) => readonly RawLessonWithIndex[], +): { + semTimetableConfig: SemTimetableConfig; + ta: ModuleCode[]; + hidden: ModuleCode[]; +} { + const params = qs.parse(serialized); + const taParams = isArray(params.ta) ? last(params.ta) : params.ta; + // If TA modules were serialized using the old format + // we deserialize it first so we can skip deserializing the module code down the line + // because TA module lesson config overrides the non-TA module lesson config + const taModulesConfig = + taParams && last(taParams) === ')' + ? deserializeClassNoFormattedTaModulesConfig(taParams, getModuleSemesterTimetable) + : {}; + + return reduce( + params, + (accumulatedDeserializedResult, paramsValue, paramsKey) => { + switch (paramsKey) { + case 'hidden': + case 'ta': { + if (!paramsValue) { + return accumulatedDeserializedResult; + } + const moduleCodes = reduce( + castArray(paramsValue), + (accumulatedModules, paramValue) => { + // Skip if the ta param is a serialized with the older classNo format + if (paramsKey === 'ta' && last(paramValue) === ')') return accumulatedModules; + + return [...accumulatedModules, ...deserializeModuleCodes(paramValue)]; + }, + [] as ModuleCode[], + ); + return { + ...accumulatedDeserializedResult, + [paramsKey]: [...accumulatedDeserializedResult[paramsKey], ...moduleCodes], + }; + } + + default: { + const moduleCode = paramsKey; + if (!paramsValue) { + return { + ...accumulatedDeserializedResult, + semTimetableConfig: { + ...accumulatedDeserializedResult.semTimetableConfig, + [moduleCode]: {}, + }, + }; + } + const timetable = getModuleSemesterTimetable(moduleCode); + const moduleLessonConfig = reduce( + castArray(paramsValue), + (accumulatedModuleLessonConfig, serializedModuleLessonConfig) => { + // If using the lesson group serialization (v2) + // paramsKey = CS2103T + // paramsValue = LEC:(0,1);TUT:(3) + if ( + serializedModuleLessonConfig && + serializedModuleLessonConfig[serializedModuleLessonConfig.length - 1] === ')' + ) + return deserializeLessonGroupFormattedModuleLessonConfig( + accumulatedModuleLessonConfig, + serializedModuleLessonConfig, + timetable, + ); + + // TA module lesson config overrides the non-TA module lesson config + if (moduleCode in taModulesConfig) return taModulesConfig[moduleCode]; + + // If using the classNo format serialization (v1) + // paramsKey = CS2103T + // paramsValue = LEC:0,TUT:3 + return deserializeClassNoFormattedModuleLessonConfig( + accumulatedModuleLessonConfig, + serializedModuleLessonConfig, + timetable, + ); + }, + {} as ModuleLessonConfig, + ); + return { + ...accumulatedDeserializedResult, + semTimetableConfig: { + ...accumulatedDeserializedResult.semTimetableConfig, + [moduleCode]: moduleLessonConfig, + }, + }; + } + } + }, + { + semTimetableConfig: {}, + ta: keys(taModulesConfig), + hidden: [], + }, + ); +} + +/** + * A helper function to convert the lesson indices array in a semester timetable config to sets + */ +function convertSemTimetableConfigLessonIndicesFromArrayToSets( + semTimetableConfig: SemTimetableConfig, +): { + [lessonType: LessonType]: { + [classNo: ClassNo]: Set; + }; +} { + return mapValues(semTimetableConfig, (moduleLessonConfig) => + mapValues(moduleLessonConfig, (lessonsInLessonType) => new Set(lessonsInLessonType)), + ); } export function isSameTimetableConfig(t1: SemTimetableConfig, t2: SemTimetableConfig): boolean { - return isEqual(t1, t2); + return isEqual( + convertSemTimetableConfigLessonIndicesFromArrayToSets(t1), + convertSemTimetableConfigLessonIndicesFromArrayToSets(t2), + ); } export function isSameLesson(l1: Lesson, l2: Lesson) { @@ -670,17 +1001,259 @@ export function isSameLesson(l1: Lesson, l2: Lesson) { ); } -export function getHoverLesson(lesson: Lesson): HoverLesson { +export function getHoverLesson(lesson: InteractableLesson): HoverLesson { return { classNo: lesson.classNo, moduleCode: lesson.moduleCode, lessonType: lesson.lessonType, + lessonIndex: lesson.lessonIndex, }; } +/** + * Differentiates between ColoredLesson and InteractableLesson + * @param lesson Must be a ColoredLesson or InteractableLesson + */ +export function isInteractable( + lesson: ColoredLesson | InteractableLesson, +): lesson is InteractableLesson { + return 'lessonIndex' in lesson; +} + /** * Obtain a semi-unique key for a lesson */ export function getLessonIdentifier(lesson: Lesson): string { return `${lesson.moduleCode}-${LESSON_TYPE_ABBREV[lesson.lessonType]}-${lesson.classNo}`; } + +/** + * A helper function for migrateSemTimetableConfig\ + * Migrates a module's lesson config + * @param moduleLessonConfig the module lesson config to migrate + * @param taModulesConfig the TA lesson configs overrides the semester timetable config + * @param moduleCode + * @returns + * - the migrated config + * - whether it was previously migrated, to signal to skip dispatch + */ +export function migrateModuleLessonConfig( + moduleLessonConfig: ModuleLessonConfig | ClassNoModuleLessonConfig, + taModulesConfig: TaModulesConfig | ClassNoTaModulesConfig, + moduleCode: ModuleCode, + timetable: readonly RawLessonWithIndex[], +): { + migratedModuleLessonConfig: ModuleLessonConfig; + alreadyMigrated: boolean; +} { + return reduce( + moduleLessonConfig, + (accumulatedModuleLessonConfig, lessonsIdentifier, lessonType) => { + if (isArray(lessonsIdentifier)) { + return { + ...accumulatedModuleLessonConfig, + migratedModuleLessonConfig: { + ...accumulatedModuleLessonConfig.migratedModuleLessonConfig, + [lessonType]: lessonsIdentifier, + }, + }; + } + + const taClassNos = isArray(taModulesConfig) + ? [] + : filter( + taModulesConfig[moduleCode], + (lessonTypeConfig) => lessonTypeConfig[0] === lessonType, + ); + const classNos = taClassNos.length ? map(taClassNos, '1') : [lessonsIdentifier]; + const lessonsByLessonTypeByClassNo = groupLessonsByLessonTypeByClassNo(timetable); + + const lessonIndices = reduce( + classNos, + (accumulatedLessonIndices, classNo) => { + const lessonIndicesWithClassNo = get( + get(lessonsByLessonTypeByClassNo, lessonType), + classNo, + ); + if (!lessonIndicesWithClassNo) return accumulatedLessonIndices; + return [...accumulatedLessonIndices, ...lessonIndicesWithClassNo]; + }, + [] as LessonIndex[], + ); + + return { + migratedModuleLessonConfig: { + ...accumulatedModuleLessonConfig.migratedModuleLessonConfig, + [lessonType]: lessonIndices, + }, + alreadyMigrated: false, + }; + }, + { + migratedModuleLessonConfig: {}, + alreadyMigrated: true, + } as { + migratedModuleLessonConfig: ModuleLessonConfig; + alreadyMigrated: boolean; + }, + ); +} + +/** + * A helper function for migrateTimetableConfigs\ + * Migrates a semester's timetable config + * @param semTimetableConfig the semester timetable config to migrate + * @param taModulesConfig the TA lesson configs overrides the semester timetable config + * @param modules the modules in the moduleBank, used to find lesson indices of the classNo + * @param semester the semester of the timetable to migrate, used to find lesson indices of the classNo + * @returns + * - the migrated semester timetable config + * - the migrated semester ta config + * - whether it was previously migrated, to signal to skip dispatch + */ +export function migrateSemTimetableConfig( + semTimetableConfig: SemTimetableConfig | ClassNoSemTimetableConfig, + taModulesConfig: TaModulesConfig | ClassNoTaModulesConfig, + getModuleSemesterTimetable: (moduleCode: ModuleCode) => readonly RawLessonWithIndex[], +): { + migratedSemTimetableConfig: SemTimetableConfig; + migratedTaModulesConfig: TaModulesConfig; + alreadyMigrated: boolean; +} { + return reduce( + semTimetableConfig, + (accumulatedSemTimetableConfig, moduleLessonConfig, moduleCode) => { + const isTa = isArray(taModulesConfig) + ? taModulesConfig.includes(moduleCode) + : moduleCode in taModulesConfig; + + const timetable = getModuleSemesterTimetable(moduleCode); + const { migratedModuleLessonConfig, alreadyMigrated } = migrateModuleLessonConfig( + moduleLessonConfig, + taModulesConfig, + moduleCode, + timetable, + ); + + return { + migratedSemTimetableConfig: { + ...accumulatedSemTimetableConfig.migratedSemTimetableConfig, + [moduleCode]: migratedModuleLessonConfig, + }, + migratedTaModulesConfig: isTa + ? [...accumulatedSemTimetableConfig.migratedTaModulesConfig, moduleCode] + : accumulatedSemTimetableConfig.migratedTaModulesConfig, + alreadyMigrated: accumulatedSemTimetableConfig.alreadyMigrated && alreadyMigrated, + }; + }, + { + migratedSemTimetableConfig: {}, + migratedTaModulesConfig: [], + alreadyMigrated: true, + } as { + migratedSemTimetableConfig: SemTimetableConfig; + migratedTaModulesConfig: TaModulesConfig; + alreadyMigrated: boolean; + }, + ); +} + +/** + * Checks the current timetable config and migrate it to v2 format if it is not\ + * Migrates all semesters' timetable config in this academic year + * @param lessons the academic year's timetables + * @param ta the academic year's TA modules config + * @param modules modules in the moduleBank state to use for migration + * @returns + * - the migrated timetable config + * - the migrated TA modules config + * - whether it was previously migrated, to signal to skip dispatch + */ +export function migrateTimetableConfigs( + lessons: TimetableConfig | ClassNoTimetableConfig, + ta: TaModulesMap | ClassNoTaModulesMap, + modules: ModulesMap, +): { + lessons: TimetableConfig; + ta: TaModulesMap; + alreadyMigrated: boolean; +} { + const { + config: migratedLessons, + ta: migratedTa, + alreadyMigrated, + } = reduce( + lessons, + (accumulated, semTimetableConfig, semesterString) => { + const semester = parseInt(semesterString, 10); + const taModulesConfig = get(ta, semester, {}); + + const getModuleSemesterTimetable = (moduleCode: ModuleCode) => + modules[moduleCode] ? getModuleTimetable(modules[moduleCode], semester) : []; + + const migrated = migrateSemTimetableConfig( + semTimetableConfig, + taModulesConfig, + getModuleSemesterTimetable, + ); + + return { + config: { + ...accumulated.config, + [semester]: migrated.migratedSemTimetableConfig, + }, + ta: { + ...accumulated.ta, + [semester]: migrated.migratedTaModulesConfig, + }, + alreadyMigrated: migrated.alreadyMigrated || accumulated.alreadyMigrated, + }; + }, + {} as { config: TimetableConfig; ta: TaModulesMap; alreadyMigrated: boolean }, + ); + + return { + lessons: migratedLessons, + ta: migratedTa, + alreadyMigrated, + }; +} + +/** + * Based on what lessons are currently in the lesson config, find the classNo that most of the lessons belong to + * @param lessonsByLessonTypeByClassNo lessons indices grouped by lesson type, then classNo + * @param timetableLessonIndices lessons currently in lesson config + * @returns a lesson config consisting of lesson indices that best matches the TA lesson config + */ +export function getClosestLessonConfig( + lessonsByLessonTypeByClassNo: LessonsByLessonTypeByClassNo, + timetableLessonIndices: ModuleLessonConfig, +): ModuleLessonConfig { + return reduce( + lessonsByLessonTypeByClassNo, + (accumulatedModuleLessonConfig, lessonsWithLessonType, lessonType) => { + const timetableLessonsWithLessonType = timetableLessonIndices[lessonType]; + const lessonGroupOccurrences = entries( + reduce( + lessonsWithLessonType, + (accumulated, lessonIndices, lessonGroup) => ({ + ...accumulated, + [lessonGroup]: intersection(lessonIndices, timetableLessonsWithLessonType).length, + }), + {} as Record, + ), + ); + + const closestLessonGroups = maxBy(lessonGroupOccurrences, ([, occurrences]) => occurrences); + if (!closestLessonGroups) return accumulatedModuleLessonConfig; + const [closestLessonGroupKey] = closestLessonGroups; + const closestLessonGroup = lessonsByLessonTypeByClassNo[lessonType][closestLessonGroupKey]; + + return { + ...accumulatedModuleLessonConfig, + [lessonType]: closestLessonGroup, + }; + }, + {} as ModuleLessonConfig, + ); +} diff --git a/website/src/views/components/module-info/AddModuleDropdown.test.tsx b/website/src/views/components/module-info/AddModuleDropdown.test.tsx index 9246bbdeda..82367a83ae 100644 --- a/website/src/views/components/module-info/AddModuleDropdown.test.tsx +++ b/website/src/views/components/module-info/AddModuleDropdown.test.tsx @@ -76,7 +76,7 @@ describe(AddModuleDropdownComponent, () => { test('should show remove button when the module is in timetable', () => { // eslint-disable-next-line no-useless-computed-key - const container = make(CS3216, { [1]: { CS3216: { Lecture: '1' } } }); + const container = make(CS3216, { [1]: { CS3216: { Lecture: [0] } } }); const button = container.wrapper.find('button'); expect(button.text()).toMatch('Remove'); diff --git a/website/src/views/components/module-info/LessonTimetable.tsx b/website/src/views/components/module-info/LessonTimetable.tsx index 1d9c66a45f..1247f8a153 100644 --- a/website/src/views/components/module-info/LessonTimetable.tsx +++ b/website/src/views/components/module-info/LessonTimetable.tsx @@ -2,7 +2,7 @@ import { FC, memo, useState } from 'react'; import { useHistory } from 'react-router-dom'; import type { SemesterData } from 'types/modules'; -import type { Lesson } from 'types/timetables'; +import type { ColoredLesson, InteractableLesson, TimetableArrangement } from 'types/timetables'; import Timetable from 'views/timetable/Timetable'; import SemesterPicker from 'views/components/module-info/SemesterPicker'; @@ -23,15 +23,16 @@ const SemesterLessonTimetable: FC<{ semesterData?: SemesterData }> = ({ semester ...lesson, moduleCode: '', title: '', - isModifiable: !!lesson.venue, + canBeSelectedAsActiveLesson: !!lesson.venue, })); - const coloredLessons = colorLessonsByKey(lessons, 'lessonType'); - const arrangedLessons = arrangeLessonsForWeek(coloredLessons); + const coloredLessons: ColoredLesson[] = colorLessonsByKey(lessons, 'lessonType'); + const arrangedLessons: TimetableArrangement = + arrangeLessonsForWeek(coloredLessons); return ( history.push(venuePage(lesson.venue))} + onModifyCell={(lesson: InteractableLesson) => history.push(venuePage(lesson.venue))} /> ); }; diff --git a/website/src/views/settings/previewTimetable.ts b/website/src/views/settings/previewTimetable.ts index 37dabc2e8a..5d145f9995 100644 --- a/website/src/views/settings/previewTimetable.ts +++ b/website/src/views/settings/previewTimetable.ts @@ -1,8 +1,8 @@ -import { TimetableArrangement } from 'types/timetables'; +import { ColoredLesson, TimetableArrangement } from 'types/timetables'; // A sample timetable used to preview themes on the settings page const EVERY_WEEK = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]; -const previewArrangement: TimetableArrangement = { +const previewArrangement: TimetableArrangement = { Tuesday: [ [ { diff --git a/website/src/views/tetris/board.ts b/website/src/views/tetris/board.ts index ad69d38d57..88ac8148c9 100644 --- a/website/src/views/tetris/board.ts +++ b/website/src/views/tetris/board.ts @@ -3,8 +3,8 @@ import { produce } from 'immer'; import { DaysOfWeek } from 'types/modules'; import { - ColoredLesson, ColorIndex, + ColoredLesson, TimetableArrangement, TimetableDayArrangement, } from 'types/timetables'; @@ -305,14 +305,14 @@ function createLessonSquare(color: ColorIndex, row: number): ColoredLesson { } const BORDER_COLOR = 10; -export function boardToTimetableArrangement(board: Board): TimetableArrangement { +export function boardToTimetableArrangement(board: Board): TimetableArrangement { // Assume column count is divisible by 3 const days = DaysOfWeek.slice(0, 5); - const timetable: TimetableArrangement = {}; + const timetable: TimetableArrangement = {}; days.forEach((day) => { if (day === DaysOfWeek[0] || day === DaysOfWeek[4]) { - // Monday and Friday are filled with a single column of mock lessons acting as a border + // Monday and Friday are filled with a single column of mock ColoredLessons acting as a border timetable[day] = [range(ROWS + 2).map((i) => createLessonSquare(BORDER_COLOR, i))]; } else { timetable[day] = range(3).map(() => [ @@ -331,7 +331,9 @@ export function boardToTimetableArrangement(board: Board): TimetableArrangement return timetable; } -export function pieceToTimetableDayArrangement(board: Board): TimetableDayArrangement { +export function pieceToTimetableDayArrangement( + board: Board, +): TimetableDayArrangement { return board.map((column) => column .map((tile, index) => (!tile ? null : createLessonSquare(tile.color, index))) diff --git a/website/src/views/timetable/ModulesTableFooter.test.tsx b/website/src/views/timetable/ModulesTableFooter.test.tsx index 8690ea0930..856d9ebecc 100644 --- a/website/src/views/timetable/ModulesTableFooter.test.tsx +++ b/website/src/views/timetable/ModulesTableFooter.test.tsx @@ -13,15 +13,12 @@ describe(countShownMCs, () => { it('should not count hidden modules', () => { const modules = [BFS1001, CS1010S, CS3216]; const hiddenInTimetable = [CS3216.moduleCode]; - expect(countShownMCs(modules, hiddenInTimetable, {})).toEqual(4); + expect(countShownMCs(modules, hiddenInTimetable, [])).toEqual(4); }); it('should not count TA modules', () => { const modules = [BFS1001, CS1010S, CS3216]; - const taInTimetable: TaModulesConfig = { - [CS1010S.moduleCode]: [], - [CS3216.moduleCode]: [['Tutorial', '1']], - }; + const taInTimetable: TaModulesConfig = [CS3216.moduleCode]; expect(countShownMCs(modules, [], taInTimetable)).toEqual(4); }); }); diff --git a/website/src/views/timetable/ModulesTableFooter.tsx b/website/src/views/timetable/ModulesTableFooter.tsx index c849b06bff..48a6c758e0 100644 --- a/website/src/views/timetable/ModulesTableFooter.tsx +++ b/website/src/views/timetable/ModulesTableFooter.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import classnames from 'classnames'; -import { isEmpty, map, sumBy } from 'lodash'; +import { map, sumBy } from 'lodash'; import { connect } from 'react-redux'; import { ModuleTableOrder } from 'types/reducers'; @@ -37,11 +37,10 @@ export function countShownMCs( taInTimetable: TaModulesConfig, ): number { return sumBy( - modules.filter( - (module) => - !hiddenInTimetable.includes(module.moduleCode) && - isEmpty(taInTimetable[module.moduleCode] ?? []), - ), + modules.filter((module) => { + const { moduleCode } = module; + return !hiddenInTimetable.includes(moduleCode) && !taInTimetable.includes(moduleCode); + }), (module) => parseFloat(module.moduleCredit), ); } diff --git a/website/src/views/timetable/ShareTimetable.test.tsx b/website/src/views/timetable/ShareTimetable.test.tsx index 5ae524c00e..1d8776b316 100644 --- a/website/src/views/timetable/ShareTimetable.test.tsx +++ b/website/src/views/timetable/ShareTimetable.test.tsx @@ -29,7 +29,7 @@ describe('ShareTimetable', () => { const timetable = { CS1010S: { - Lecture: '1', + Lecture: [0], }, }; @@ -48,7 +48,7 @@ describe('ShareTimetable', () => { test('should load short URL when the shorten button is clicked', async () => { const wrapper = shallow( - , + , ); expect(mockAxios.put).not.toHaveBeenCalled(); @@ -62,7 +62,7 @@ describe('ShareTimetable', () => { test('should cache short URL from the API', async () => { const wrapper = shallow( - , + , ); // Open the model, shorten and unshorten again @@ -77,7 +77,7 @@ describe('ShareTimetable', () => { closeModal(wrapper); // Changing the timetable should cause the shorten button to trigger another API call - wrapper.setProps({ timetable: { CS3216: { Lecture: '1' } } }); + wrapper.setProps({ timetable: { CS3216: { Lecture: [0] } } }); // openModal(wrapper); expect(mockAxios.put).toHaveBeenCalledTimes(1); openModal(wrapper); @@ -96,7 +96,7 @@ describe('ShareTimetable', () => { test('should show spinner when loading', () => { const wrapper = shallow( - , + , ); openModal(wrapper); @@ -106,7 +106,7 @@ describe('ShareTimetable', () => { test('should display shortUrl with show original url button if available', async () => { const wrapper = shallow( - , + , ); openModal(wrapper); @@ -119,7 +119,7 @@ describe('ShareTimetable', () => { test('should display long URL if data is corrupted', async () => { mockAxios.put.mockResolvedValue({} as AxiosResponse); // No short URL const wrapper = shallow( - , + , ); openModal(wrapper); @@ -132,7 +132,7 @@ describe('ShareTimetable', () => { test('should display long URL if the endpoint returns an error', async () => { mockAxios.put.mockRejectedValue(new Error()); const wrapper = shallow( - , + , ); openModal(wrapper); @@ -145,7 +145,7 @@ describe('ShareTimetable', () => { test('should not include hidden key in long URL if there are no hidden modules', async () => { mockAxios.put.mockResolvedValue({} as AxiosResponse); // No short URL const wrapper = shallow( - , + , ); openModal(wrapper); @@ -161,7 +161,7 @@ describe('ShareTimetable', () => { semester={1} timetable={timetable} hiddenModules={['CS1010S', 'CS1231S']} - taModules={{}} + taModules={[]} />, ); @@ -178,31 +178,19 @@ describe('ShareTimetable', () => { semester={1} timetable={timetable} hiddenModules={[]} - taModules={{ - MA1521: [['Tutorial', '1']], - CS1010S: [ - ['Tutorial', '1'], - ['Laboratory', '1'], - ], - CS1231S: [ - ['Tutorial', '2'], - ['Tutorial', '3'], - ], - }} + taModules={['CS1010S']} />, ); openModal(wrapper); await shortenAndWait(wrapper); - expect(wrapper.find('input').prop('value')).toContain( - 'ta=MA1521(TUT:1),CS1010S(TUT:1,LAB:1),CS1231S(TUT:2,TUT:3)', - ); + expect(wrapper.find('input').prop('value')).toContain('ta=CS1010S'); }); test('should change to original url and display shorten url button when clicked on show original url button', async () => { const wrapper = shallow( - , + , ); openModal(wrapper); diff --git a/website/src/views/timetable/Timetable.tsx b/website/src/views/timetable/Timetable.tsx index 77775ed7e7..654abac032 100644 --- a/website/src/views/timetable/Timetable.tsx +++ b/website/src/views/timetable/Timetable.tsx @@ -2,7 +2,12 @@ import * as React from 'react'; import { flattenDeep, noop, values } from 'lodash'; import classnames from 'classnames'; -import { ColoredLesson, HoverLesson, TimetableArrangement } from 'types/timetables'; +import { + ColoredLesson, + HoverLesson, + InteractableLesson, + TimetableArrangement, +} from 'types/timetables'; import { OnModifyCell } from 'types/views'; import { @@ -23,7 +28,7 @@ import TimetableTimings from './TimetableTimings'; import TimetableDay from './TimetableDay'; type Props = TimerData & { - lessons: TimetableArrangement; + lessons: TimetableArrangement; // These should be non-optional, but because HOCs currently strip defaultProps // for the sake of our sanity we type these as optional to reduce errors at call sites isVerticalOrientation?: boolean; diff --git a/website/src/views/timetable/TimetableCell.test.tsx b/website/src/views/timetable/TimetableCell.test.tsx index b958b7044b..9b70b11d01 100644 --- a/website/src/views/timetable/TimetableCell.test.tsx +++ b/website/src/views/timetable/TimetableCell.test.tsx @@ -1,10 +1,10 @@ import { shallow } from 'enzyme'; -import { ColoredLesson, HoverLesson } from 'types/timetables'; +import { HoverLesson, InteractableLesson } from 'types/timetables'; import { EVERY_WEEK } from 'test-utils/timetable'; import TimetableCell from './TimetableCell'; -const DEFAULT_LESSON: ColoredLesson = { +const NON_TA_LESSON: InteractableLesson = { moduleCode: 'CS1010', title: 'Intro', classNo: '1', @@ -15,6 +15,7 @@ const DEFAULT_LESSON: ColoredLesson = { endTime: '1200', venue: 'LT26', colorIndex: 1, + lessonIndex: 1, }; type Props = { @@ -24,25 +25,110 @@ type Props = { hoverLesson?: HoverLesson | null; }; -function make(additionalProps: Partial = {}) { - const props = { - onHover: jest.fn(), - showTitle: false, - hoverLesson: null, - transparent: false, - ...additionalProps, +const makeFactory = + (lesson: InteractableLesson) => + (additionalProps: Partial = {}) => { + const props = { + onHover: jest.fn(), + showTitle: false, + hoverLesson: null, + transparent: false, + ...additionalProps, + }; + + const onClick = jest.fn(); + + return { + onClick, + onHover: props.onHover, + wrapper: shallow(), + }; }; - const onClick = jest.fn(); +describe(TimetableCell, () => { + const make = makeFactory(NON_TA_LESSON); + it('simulates click events and renders a button', () => { + const { onClick, wrapper } = make(); - return { - onClick, - onHover: props.onHover, - wrapper: shallow(), - }; -} + const buttons = wrapper.find('button'); + buttons.at(0).simulate('click', { + preventDefault: jest.fn(), + currentTarget: document.createElement('button'), + }); + expect(onClick).toBeCalled(); + }); + + it('has clickable class styling', () => { + const { wrapper } = make(); + + const button = wrapper.find('button').at(0); + expect(button.hasClass('clickable')).toBe(true); + }); + + it('should highlight lesson when module code, classNo and lessonType matches', () => { + const { wrapper } = make({ + hoverLesson: { + moduleCode: 'CS1010', + classNo: '1', + lessonType: 'Lecture', + lessonIndex: 1, + }, + }); + + const button = wrapper.find('button').at(0); + expect(button.hasClass('hover')).toBe(true); + }); + + it('should not highlight lesson when only module code or classNo match', () => { + let button; + + button = make({ + hoverLesson: { + moduleCode: 'CS1010', + classNo: '1', + lessonType: 'Tutorial', + lessonIndex: 2, + }, + }) + .wrapper.find('button') + .at(0); + + expect(button.hasClass('hover')).toBe(false); + + button = make({ + hoverLesson: { + moduleCode: 'CS1010', + classNo: '2', + lessonType: 'Lecture', + lessonIndex: 3, + }, + }) + .wrapper.find('button') + .at(0); + + expect(button.hasClass('hover')).toBe(false); + + button = make({ + hoverLesson: { + moduleCode: 'CS1101S', + classNo: '1', + lessonType: 'Lecture', + lessonIndex: 0, + }, + }) + .wrapper.find('button') + .at(0); + + expect(button.hasClass('hover')).toBe(false); + }); +}); describe(TimetableCell, () => { + const TA_LESSON: InteractableLesson = { + ...NON_TA_LESSON, + isTaInTimetable: true, + }; + const make = makeFactory(TA_LESSON); it('simulates click events and renders a button', () => { const { onClick, wrapper } = make(); @@ -61,12 +147,13 @@ describe(TimetableCell, () => { expect(button.hasClass('clickable')).toBe(true); }); - it('should highlight lesson when module code, classNo and lessonType matches', () => { + it('should highlight lesson when module code, classNo, lessonType and lessonIndex matches', () => { const { wrapper } = make({ hoverLesson: { moduleCode: 'CS1010', classNo: '1', lessonType: 'Lecture', + lessonIndex: 1, }, }); @@ -74,6 +161,20 @@ describe(TimetableCell, () => { expect(button.hasClass('hover')).toBe(true); }); + it('should highlight lesson when only module code, classNo and lessonType matches', () => { + const { wrapper } = make({ + hoverLesson: { + moduleCode: 'CS1010', + classNo: '1', + lessonType: 'Lecture', + lessonIndex: 2, + }, + }); + + const button = wrapper.find('button').at(0); + expect(button.hasClass('hover')).toBe(false); + }); + it('should not highlight lesson when only module code or classNo match', () => { let button; @@ -82,6 +183,7 @@ describe(TimetableCell, () => { moduleCode: 'CS1010', classNo: '1', lessonType: 'Tutorial', + lessonIndex: 2, }, }) .wrapper.find('button') @@ -94,6 +196,7 @@ describe(TimetableCell, () => { moduleCode: 'CS1010', classNo: '2', lessonType: 'Lecture', + lessonIndex: 3, }, }) .wrapper.find('button') @@ -106,6 +209,7 @@ describe(TimetableCell, () => { moduleCode: 'CS1101S', classNo: '1', lessonType: 'Lecture', + lessonIndex: 0, }, }) .wrapper.find('button') diff --git a/website/src/views/timetable/TimetableCell.tsx b/website/src/views/timetable/TimetableCell.tsx index b62b4e28de..d635688b9e 100644 --- a/website/src/views/timetable/TimetableCell.tsx +++ b/website/src/views/timetable/TimetableCell.tsx @@ -1,17 +1,18 @@ import * as React from 'react'; import classnames from 'classnames'; -import { isEqual } from 'lodash'; +import { noop } from 'lodash'; import { addWeeks, format, parseISO } from 'date-fns'; import NUSModerator, { AcadWeekInfo } from 'nusmoderator'; import { consumeWeeks, WeekRange } from 'types/modules'; -import { HoverLesson, ModifiableLesson } from 'types/timetables'; +import { ColoredLesson, HoverLesson, InteractableLesson } from 'types/timetables'; import { OnHoverCell } from 'types/views'; import { formatNumericWeeks, getHoverLesson, getLessonIdentifier, + isInteractable, LESSON_TYPE_ABBREV, } from 'utils/timetables'; import { TRANSPARENT_COLOR_INDEX } from 'utils/colors'; @@ -22,7 +23,7 @@ import styles from './TimetableCell.scss'; type Props = { showTitle: boolean; - lesson: ModifiableLesson; + lesson: ColoredLesson; onHover: OnHoverCell; style?: React.CSSProperties; onClick?: (position: ClientRect) => void; @@ -37,6 +38,29 @@ function formatWeekInfo(weekInfo: AcadWeekInfo) { return weekInfo.type; } +/** + * Determines if the lesson should be highlighted as part of the same lesson group as the lesson currently being hovered over + * @param lesson This cell's lesson + * @param hoverLesson The lesson being hovered over + */ +function checkHover( + lesson: InteractableLesson | ColoredLesson, + hoverLesson: HoverLesson | null | undefined, +): boolean { + if (!hoverLesson) return false; + + if (!isInteractable(lesson)) return false; + + if (lesson.moduleCode !== hoverLesson.moduleCode || lesson.lessonType !== hoverLesson.lessonType) + return false; + + if (!lesson.isTaInTimetable && lesson.classNo === hoverLesson.classNo) return true; + + if (lesson.isTaInTimetable && lesson.lessonIndex === hoverLesson.lessonIndex) return true; + + return false; +} + function formatWeekRange(weekRange: WeekRange) { const start = parseISO(weekRange.start); @@ -92,7 +116,7 @@ const TimetableCell: React.FC = (props) => { const moduleName = showTitle ? `${lesson.moduleCode} ${lesson.title}` : lesson.moduleCode; const Cell = props.onClick ? 'button' : 'div'; - const isHoveredOver = isEqual(getHoverLesson(lesson), hoverLesson); + const isHoveredOver = checkHover(lesson, hoverLesson); const conditionalProps = onClick ? { @@ -115,8 +139,8 @@ const TimetableCell: React.FC = (props) => { { hoverable: !!onClick, [styles.clickable]: !!onClick, - [styles.available]: lesson.isAvailable, - [styles.active]: lesson.isActive, + [styles.available]: isInteractable(lesson) && lesson.canBeAddedToLessonConfig, + [styles.active]: isInteractable(lesson) && lesson.isActive, // Local hover style for the timetable planner timetable, [styles.hover]: isHoveredOver, // Global hover style for module page timetable @@ -128,25 +152,26 @@ const TimetableCell: React.FC = (props) => { onHover(getHoverLesson(lesson))} - onTouchStart={() => onHover(getHoverLesson(lesson))} + onMouseEnter={isInteractable(lesson) ? () => onHover(getHoverLesson(lesson)) : noop} + onTouchStart={isInteractable(lesson) ? () => onHover(getHoverLesson(lesson)) : noop} onMouseLeave={() => onHover(null)} onTouchEnd={() => onHover(null)} - autoFocus={lesson.isActive} + autoFocus={isInteractable(lesson) && lesson.isActive} {...conditionalProps} >
{moduleName} - {lesson.isTaInTimetable && ' (TA)'} + {isInteractable(lesson) && lesson.isTaInTimetable && ' (TA)'}
- {lesson.isTaInTimetable && + {isInteractable(lesson) && + lesson.isTaInTimetable && onClick && isHoveredOver && hoverLesson && - (lesson.isActive || !lesson.isOptionInTimetable ? ( + (lesson.isActive || !lesson.canBeAddedToLessonConfig ? ( ) : ( diff --git a/website/src/views/timetable/TimetableContainer.test.tsx b/website/src/views/timetable/TimetableContainer.test.tsx index a029c37663..6a1aefe7ce 100644 --- a/website/src/views/timetable/TimetableContainer.test.tsx +++ b/website/src/views/timetable/TimetableContainer.test.tsx @@ -128,9 +128,9 @@ describe(TimetableContainerComponent, () => { test('should eventually display imported timetable if there is one', async () => { const semester = 1; const importedTimetable = { - [moduleCodeThatCanBeLoaded]: { 'Sectional Teaching': 'A1' }, // BFS1001 doesn't have Lecture, only SectionalTeaching + [moduleCodeThatCanBeLoaded]: { 'Sectional Teaching': [0] }, // BFS1001 doesn't have Lecture, only SectionalTeaching }; - const location = timetableShare(semester, importedTimetable, [], {}); + const location = timetableShare(semester, importedTimetable, [], []); make(location); // Expect spinner when loading modules @@ -151,8 +151,8 @@ describe(TimetableContainerComponent, () => { test('should eventually display imported timetable without any modules loaded', async () => { const semester = 1; - const importedTimetable = { [moduleCodeThatCanBeLoaded]: { 'Sectional Teaching': 'A1' } }; - const location = timetableShare(semester, importedTimetable, [moduleCodeThatCanBeLoaded], {}); + const importedTimetable = { [moduleCodeThatCanBeLoaded]: { 'Sectional Teaching': [0] } }; + const location = timetableShare(semester, importedTimetable, [moduleCodeThatCanBeLoaded], []); make(location); // Expect spinner when loading modules @@ -173,8 +173,8 @@ describe(TimetableContainerComponent, () => { test('should ignore invalid modules in imported timetable', () => { const semester = 1; - const importedTimetable = { TRUMP2020: { Lecture: '1' } }; - const location = timetableShare(semester, importedTimetable, [], {}); + const importedTimetable = { TRUMP2020: { Lecture: [1] } }; + const location = timetableShare(semester, importedTimetable, [], []); make(location); // Expect nothing to be fetched and the invalid module to be ignored @@ -199,7 +199,7 @@ describe(TimetableContainerComponent, () => { // Populate mock timetable await act(async () => { - const timetable = { CS1010S: { Lecture: '1' }, CS3216: { Lecture: '1' } }; + const timetable = { CS1010S: { Lecture: [0] }, CS3216: { Lecture: [0] } }; (store.dispatch as Dispatch)(setTimetable(semester, timetable)); }); diff --git a/website/src/views/timetable/TimetableContainer.tsx b/website/src/views/timetable/TimetableContainer.tsx index b63fd86622..665ae4e417 100644 --- a/website/src/views/timetable/TimetableContainer.tsx +++ b/website/src/views/timetable/TimetableContainer.tsx @@ -4,7 +4,7 @@ import { Redirect, useHistory, useLocation, useParams } from 'react-router-dom'; import { Repeat } from 'react-feather'; import classnames from 'classnames'; -import type { ModuleCode, Semester } from 'types/modules'; +import type { ModuleCode, RawLessonWithIndex, Semester } from 'types/modules'; import type { ColorMapping } from 'types/reducers'; import type { State } from 'types/state'; import type { SemTimetableConfig, TaModulesConfig } from 'types/timetables'; @@ -12,7 +12,7 @@ import type { SemTimetableConfig, TaModulesConfig } from 'types/timetables'; import { selectSemester } from 'actions/settings'; import { getSemesterTimetableColors, getSemesterTimetableLessons } from 'selectors/timetables'; import { - fetchTimetableModules, + fetchModules, setHiddenModulesFromImport, setTaModulesFromImport, setTimetable, @@ -20,16 +20,19 @@ import { import { openNotification } from 'actions/app'; import { undo } from 'actions/undoHistory'; import { getModuleCondensed } from 'selectors/moduleBank'; -import { deserializeHidden, deserializeTa, deserializeTimetable } from 'utils/timetables'; +import { deserializeTimetable, parseTaModuleCodes } from 'utils/timetables'; import { fillColorMapping } from 'utils/colors'; import { semesterForTimetablePage, TIMETABLE_SHARE, timetablePage } from 'views/routes/paths'; import deferComponentRender from 'views/hocs/deferComponentRender'; import SemesterSwitcher from 'views/components/semester-switcher/SemesterSwitcher'; import LoadingSpinner from 'views/components/LoadingSpinner'; import useScrollToTop from 'views/hooks/useScrollToTop'; -import TimetableContent from './TimetableContent'; +import qs from 'query-string'; +import { isArray, keys, last, omit } from 'lodash'; +import { getModuleTimetable } from 'utils/modules'; import styles from './TimetableContainer.scss'; +import TimetableContent from './TimetableContent'; type Params = { action: string; @@ -177,39 +180,84 @@ export const TimetableContainerComponent: FC = () => { const activeSemester = useSelector(({ app }: State) => app.activeSemester); const location = useLocation(); - const [importedTimetable, setImportedTimetable] = useState(() => - semester && params.action ? deserializeTimetable(location.search) : null, - ); - const importedHidden = useMemo( - () => (semester && params.action ? deserializeHidden(location.search) : null), - [semester, params.action, location.search], + const [importedTimetable, setImportedTimetable] = useState(null); + + const [importedHidden, setImportedHidden] = useState(null); + + const [importedTa, setImportedTa] = useState(null); + + const dispatch = useDispatch(); + + const getModuleSemesterTimetable = useCallback( + (moduleCode: ModuleCode): readonly RawLessonWithIndex[] => { + const module = modules[moduleCode]; + if (!semester || !module) return []; + return getModuleTimetable(module, semester); + }, + [modules, semester], ); - const importedTa = useMemo( - () => (semester && params.action ? deserializeTa(location.search) : null), - [semester, params.action, location.search], + const [isLoading, setLoading] = useState(true); + const isValidModule = useCallback( + (moduleCode: ModuleCode): boolean => !!getModule(moduleCode), + [getModule], ); - const dispatch = useDispatch(); useEffect(() => { - if (importedTimetable) { - dispatch(fetchTimetableModules([importedTimetable])); + if (!(semester && params.action)) { + setLoading(false); + setImportedTimetable(null); + setImportedHidden(null); + setImportedTa(null); + return; + } + + const parsedQuery = qs.parse(location.search); + const serializedTaModuleConfig = isArray(parsedQuery.ta) + ? last(parsedQuery.ta) + : parsedQuery.ta; + const taModuleCodes = parseTaModuleCodes(serializedTaModuleConfig); + + const importedModuleCodes = [...keys(omit(parsedQuery, ['ta', 'hidden'])), ...taModuleCodes]; + + if (!importedModuleCodes.length) return; + + const moduleCodes = keys(modules); + + // Check which modules need to be loaded + const modulesToFetch = importedModuleCodes.filter( + (importedModuleCode) => + !moduleCodes.includes(importedModuleCode) && isValidModule(importedModuleCode), + ); + if (!modulesToFetch.length) { + setLoading(false); + return; + } + setLoading(true); + dispatch(fetchModules(new Set(modulesToFetch))); + }, [semester, params.action, location.search, modules, isValidModule, dispatch]); + + useEffect(() => { + if (isLoading) { + return; } - }, [dispatch, importedTimetable]); - - const isLoading = useMemo(() => { - // Check that all modules are fully loaded into the ModuleBank - const isValidModule = (moduleCode: ModuleCode) => !!getModule(moduleCode); - const moduleCodes = new Set(Object.keys(timetable)); - if (importedTimetable) { - Object.keys(importedTimetable) - .filter(isValidModule) - .forEach((moduleCode) => moduleCodes.add(moduleCode)); + + if (!(semester && params.action)) { + setLoading(false); + return; } - // TODO: Account for loading error - return Array.from(moduleCodes).some((moduleCode) => !modules[moduleCode]); - }, [getModule, importedTimetable, modules, timetable]); + + const { + semTimetableConfig, + hidden: hiddenModules, + ta: taModules, + } = deserializeTimetable(location.search, getModuleSemesterTimetable); + + setImportedTimetable(semTimetableConfig); + setImportedHidden(hiddenModules); + setImportedTa(taModules); + }, [semester, params.action, isLoading, location.search, getModuleSemesterTimetable]); const displayedTimetable = importedTimetable || timetable; const filledColors = useMemo( diff --git a/website/src/views/timetable/TimetableContent.tsx b/website/src/views/timetable/TimetableContent.tsx index 0faf5754f6..3efa6387f3 100644 --- a/website/src/views/timetable/TimetableContent.tsx +++ b/website/src/views/timetable/TimetableContent.tsx @@ -1,38 +1,42 @@ import * as React from 'react'; import classnames from 'classnames'; import { connect } from 'react-redux'; -import { sortBy, difference, values, flatten, mapValues, isEmpty } from 'lodash'; +import { + sortBy, + difference, + values, + flatten, + mapValues, + isEmpty, + groupBy, + map, + filter, + isArray, + keys, +} from 'lodash'; import { ColorMapping, HORIZONTAL, ModulesMap, TimetableOrientation } from 'types/reducers'; -import { ClassNo, LessonType, Module, ModuleCode, Semester } from 'types/modules'; +import { LessonIndex, LessonType, Module, ModuleCode, Semester } from 'types/modules'; import { - ColoredLesson, - Lesson, - ModifiableLesson, SemTimetableConfig, - SemTimetableConfigWithLessons, TaModulesConfig, - TimetableArrangement, + SemTimetableConfigWithLessons, + InteractableLesson, + LessonWithIndex, + ClassNoTaModulesConfig, } from 'types/timetables'; import { addModule, - addTaLessonInTimetable, cancelModifyLesson, changeLesson, + addLesson, + removeLesson, modifyLesson, removeModule, - removeTaLessonInTimetable, resetTimetable, } from 'actions/timetables'; -import { - areLessonsDuplicate, - areLessonsSameClass, - canTa, - formatExamDate, - getExamDate, - getModuleTimetable, -} from 'utils/modules'; +import { formatExamDate, getExamDate, getModuleTimetable } from 'utils/modules'; import { areOtherClassesAvailable, arrangeLessonsForWeek, @@ -40,8 +44,6 @@ import { getLessonIdentifier, getSemesterModules, hydrateSemTimetableWithLessons, - hydrateTaModulesConfigWithLessons, - lessonsForLessonType, timetableLessonsArray, } from 'utils/timetables'; import { resetScrollPosition } from 'utils/react'; @@ -72,14 +74,14 @@ type OwnProps = { timetable: SemTimetableConfig; colors: ColorMapping; hiddenImportedModules: ModuleCode[] | null; - taImportedModules: TaModulesConfig | null; + taImportedModules: TaModulesConfig | ClassNoTaModulesConfig | null; }; type Props = OwnProps & { // From Redux timetableWithLessons: SemTimetableConfigWithLessons; modules: ModulesMap; - activeLesson: Lesson | null; + activeLesson: LessonWithIndex | null; timetableOrientation: TimetableOrientation; showTitle: boolean; hiddenInTimetable: ModuleCode[]; @@ -89,21 +91,26 @@ type Props = OwnProps & { addModule: (semester: Semester, moduleCode: ModuleCode) => void; removeModule: (semester: Semester, moduleCode: ModuleCode) => void; resetTimetable: (semester: Semester) => void; - modifyLesson: (lesson: Lesson) => void; - changeLesson: (semester: Semester, lesson: Lesson) => void; - cancelModifyLesson: () => void; - addTaLessonInTimetable: ( + modifyLesson: (lesson: LessonWithIndex) => void; + addLesson: ( semester: Semester, moduleCode: ModuleCode, lessonType: LessonType, - classNo: ClassNo, + lessonIndices: LessonIndex[], ) => void; - removeTaLessonInTimetable: ( + removeLesson: ( semester: Semester, moduleCode: ModuleCode, lessonType: LessonType, - classNo: ClassNo, + lessonIndices: LessonIndex[], ) => void; + changeLesson: ( + semester: Semester, + moduleCode: ModuleCode, + lessonType: LessonType, + lessonIndices: LessonIndex[], + ) => void; + cancelModifyLesson: () => void; }; type State = { @@ -168,73 +175,86 @@ class TimetableContent extends React.Component { } }; - cancelModifyLesson = () => { - if (this.props.activeLesson) { - this.props.cancelModifyLesson(); - - resetScrollPosition(); - } - }; - - isHiddenInTimetable = (moduleCode: ModuleCode) => - this.props.hiddenInTimetable.includes(moduleCode); - - isTaInTimetable = (moduleCode: ModuleCode) => this.props.taInTimetable[moduleCode]?.length > 0; - - canTa = (moduleCode: ModuleCode) => { - const { semester, modules } = this.props; - return canTa(modules, moduleCode, semester); - }; - - // Adds current non lecture lessons as TA lessons - setTaLessonInTimetable = (semester: Semester, moduleCode: ModuleCode) => { - timetableLessonsArray(this.props.timetableWithLessons) - .filter((lesson) => lesson.moduleCode === moduleCode && lesson.lessonType !== 'Lecture') - .forEach((lesson) => - this.props.addTaLessonInTimetable(semester, moduleCode, lesson.lessonType, lesson.classNo), - ); - }; + modifyTaCell = ( + sameLessonTypeLessons: InteractableLesson[], + lesson: InteractableLesson, + ): void => { + const { moduleCode, lessonType, lessonIndex } = lesson; - modifyTaCell(lesson: ModifiableLesson) { - const { moduleCode, lessonType, classNo } = lesson; - if (lesson.isOptionInTimetable) { + const currentlySelected = sameLessonTypeLessons.filter( + (sameLessonTypeLesson) => !sameLessonTypeLesson.canBeAddedToLessonConfig, + ); + if (lesson.canBeAddedToLessonConfig) { // Allow multiple lessons of the same type to be added for TA lessons - this.props.addTaLessonInTimetable(this.props.semester, moduleCode, lessonType, classNo); - } else if (this.props.taInTimetable[moduleCode].length > 1) { + this.props.addLesson(this.props.semester, moduleCode, lessonType, [lessonIndex]); + } else if (currentlySelected.length > 1) { // If a TA lesson is the last of its type, disallow removing it - this.props.removeTaLessonInTimetable(this.props.semester, moduleCode, lessonType, classNo); + this.props.removeLesson(this.props.semester, moduleCode, lessonType, [lessonIndex]); } else { this.props.cancelModifyLesson(); } resetScrollPosition(); - } + }; - modifyCell = (lesson: ModifiableLesson, position: ClientRect) => { - const { activeLesson } = this.props; - // If activeLesson exists, then the user is choosing a cell to modify - const isChoosing = !!activeLesson; - if (isChoosing) { - if (this.isTaInTimetable(lesson.moduleCode)) { - this.modifyTaCell(lesson); - return; - } + modifyCell = + (moduleTimetable: InteractableLesson[], activeLesson: LessonWithIndex | null) => + (lesson: InteractableLesson, position: ClientRect): void => { + // If activeLesson exists, then the user is choosing a cell to modify + const isChoosing = !!activeLesson; + if (isChoosing) { + const sameLessonTypeLessons = moduleTimetable.filter( + (timetableLesson) => + timetableLesson.moduleCode === lesson.moduleCode && + timetableLesson.lessonType === lesson.lessonType, + ); + + if (this.isTaInTimetable(lesson.moduleCode)) { + this.modifyTaCell(sameLessonTypeLessons, lesson); + return; + } - if (lesson.isAvailable) { - this.props.changeLesson(this.props.semester, lesson); + if (lesson.canBeAddedToLessonConfig) { + const lessonIndices = map( + filter( + sameLessonTypeLessons, + (timetableLessons) => timetableLessons.classNo === lesson.classNo, + ), + (sameLessonTypeLesson) => sameLessonTypeLesson.lessonIndex, + ); + this.props.changeLesson( + this.props.semester, + lesson.moduleCode, + lesson.lessonType, + lessonIndices, + ); + } else { + this.props.cancelModifyLesson(); + } + resetScrollPosition(); } else { - this.props.cancelModifyLesson(); + this.props.modifyLesson(lesson); + + this.modifiedCell = { + position, + className: getLessonIdentifier(lesson), + }; } - resetScrollPosition(); - } else { - this.props.modifyLesson(lesson); + }; - this.modifiedCell = { - position, - className: getLessonIdentifier(lesson), - }; + cancelModifyLesson = (): void => { + if (this.props.activeLesson) { + this.props.cancelModifyLesson(); + + resetScrollPosition(); } }; + isHiddenInTimetable = (moduleCode: ModuleCode): boolean => + this.props.hiddenInTimetable.includes(moduleCode); + + isTaInTimetable = (moduleCode: ModuleCode): boolean => + this.props.taInTimetable.includes(moduleCode); + addModule = (semester: Semester, moduleCode: ModuleCode) => { this.props.addModule(semester, moduleCode); this.resetTombstone(); @@ -270,7 +290,6 @@ class TimetableContent extends React.Component { colorIndex: this.props.colors[module.moduleCode], isHiddenInTimetable: this.isHiddenInTimetable(module.moduleCode), isTaInTimetable: this.isTaInTimetable(module.moduleCode), - canTa: this.canTa(module.moduleCode), }); renderModuleTable = ( @@ -286,7 +305,6 @@ class TimetableContent extends React.Component { readOnly={this.props.readOnly} tombstone={tombstone} resetTombstone={this.resetTombstone} - enableTaModeInTimetable={this.setTaLessonInTimetable} /> ); @@ -338,6 +356,95 @@ class TimetableContent extends React.Component { ); } + /** + * Hydrates a list of lessons to add interactability info\ + * See type defintion of `InteractableLesson` for properties added + */ + hydrateInteractability( + timetableLessons: LessonWithIndex[], + modules: ModulesMap, + semester: Semester, + colors: ColorMapping, + readOnly: boolean, + activeLesson?: LessonWithIndex, + alreadySelectedLessonIndices?: LessonIndex[], + ): InteractableLesson[] { + const moduleTimetables = mapValues(modules, (module) => getModuleTimetable(module, semester)); + + return map(timetableLessons, (lesson) => { + const { moduleCode, lessonType, classNo, lessonIndex } = lesson; + const isSameModuleAndLessonType = + moduleCode === activeLesson?.moduleCode && lessonType === activeLesson?.lessonType; + + const isActive = isSameModuleAndLessonType && lessonIndex === activeLesson?.lessonIndex; + const isTaInTimetable = this.isTaInTimetable(moduleCode); + const canBeSelectedAsActiveLesson = + !readOnly && areOtherClassesAvailable(moduleTimetables[moduleCode], lessonType); + + const alreadyAddedToLessonConfig = alreadySelectedLessonIndices?.includes(lesson.lessonIndex); + const isSameLessonGroupAsActiveLesson = isTaInTimetable + ? lessonIndex === activeLesson?.lessonIndex + : classNo === activeLesson?.classNo; + const canBeAddedToLessonConfig = + isSameModuleAndLessonType && + !alreadyAddedToLessonConfig && + !isSameLessonGroupAsActiveLesson; + + return { + ...lesson, + isActive, + isTaInTimetable, + canBeAddedToLessonConfig, + canBeSelectedAsActiveLesson, + colorIndex: colors[moduleCode], + }; + }); + } + + /** + * Hydrate timetable lessons with interactability info\ + * See type defintion of `InteractableLesson` for properties added + */ + getInteractableLessons( + timetableLessons: LessonWithIndex[], + modules: ModulesMap, + semester: Semester, + colors: ColorMapping, + readOnly: boolean, + activeLesson: LessonWithIndex | null, + ): InteractableLesson[] { + if (!activeLesson) + return this.hydrateInteractability(timetableLessons, modules, semester, colors, readOnly); + const activeModule = modules[activeLesson.moduleCode]; + const activeLessonTypeLessons = map( + filter( + getModuleTimetable(activeModule, semester), + (lesson) => lesson.lessonType === activeLesson.lessonType, + ), + (lesson) => ({ ...lesson, moduleCode: activeModule.moduleCode, title: activeModule.title }), + ); + + const { alreadySelected, otherLessons } = groupBy(timetableLessons, (lesson) => + lesson.moduleCode === activeLesson.moduleCode && lesson.lessonType === activeLesson.lessonType + ? 'alreadySelected' + : 'otherLessons', + ); + const alreadySelectedLessonIndices = map(alreadySelected, 'lessonIndex'); + + return [ + ...this.hydrateInteractability( + activeLessonTypeLessons, + modules, + semester, + colors, + readOnly, + activeLesson, + alreadySelectedLessonIndices, + ), + ...this.hydrateInteractability(otherLessons, modules, semester, colors, readOnly), + ]; + } + override render() { const { semester, @@ -353,79 +460,19 @@ class TimetableContent extends React.Component { const { showExamCalendar } = this.state; - let timetableLessons: Lesson[] = timetableLessonsArray(this.props.timetableWithLessons) - // Omit all lessons for hidden modules - .filter((lesson) => !this.isHiddenInTimetable(lesson.moduleCode)); - - if (activeLesson) { - const { moduleCode } = activeLesson; - // Remove activeLesson because it will appear again - timetableLessons = timetableLessons.filter( - (lesson) => !areLessonsSameClass(lesson, activeLesson), - ); - - const module = modules[moduleCode]; - const moduleTimetable = getModuleTimetable(module, semester); - const lessonOptions = this.isTaInTimetable(moduleCode) - ? moduleTimetable.filter((lesson) => lesson.lessonType !== 'Lecture') - : lessonsForLessonType(moduleTimetable, activeLesson.lessonType); - lessonOptions.forEach((lesson) => { - const modifiableLesson: Omit = { - ...lesson, - // Inject module code in - moduleCode, - title: module.title, - }; - - // Prevent multiple versions of the same lesson - if ( - timetableLessons.some((curLesson) => areLessonsDuplicate(modifiableLesson, curLesson)) - ) { - return; - } - - // All lessons added within this block are options to be added in the timetable - // Except for the activeLesson - modifiableLesson.isOptionInTimetable = true; - if (areLessonsSameClass(modifiableLesson, activeLesson)) { - modifiableLesson.isActive = true; - modifiableLesson.isOptionInTimetable = false; - } else if ( - this.isTaInTimetable(moduleCode) || - lesson.lessonType === activeLesson.lessonType - ) { - modifiableLesson.isAvailable = true; - } - timetableLessons.push(modifiableLesson); - }); - } + const timetableLessons: LessonWithIndex[] = timetableLessonsArray( + this.props.timetableWithLessons, + ).filter((lesson) => !this.isHiddenInTimetable(lesson.moduleCode)); - // Inject color into module - const coloredTimetableLessons = timetableLessons.map( - (lesson: Lesson): ColoredLesson => ({ - ...lesson, - colorIndex: colors[lesson.moduleCode], - isTaInTimetable: this.isTaInTimetable(lesson.moduleCode), - }), + const coloredTimetableLessons: InteractableLesson[] = this.getInteractableLessons( + timetableLessons, + modules, + semester, + colors, + readOnly, + activeLesson, ); - const arrangedLessons = arrangeLessonsForWeek(coloredTimetableLessons); - const arrangedLessonsWithModifiableFlag: TimetableArrangement = mapValues( - arrangedLessons, - (dayRows) => - dayRows.map((row) => - row.map((lesson) => { - const module: Module = modules[lesson.moduleCode]; - const moduleTimetable = getModuleTimetable(module, semester); - - return { - ...lesson, - isModifiable: - !readOnly && areOtherClassesAvailable(moduleTimetable, lesson.lessonType), - }; - }), - ), - ); const isVerticalOrientation = timetableOrientation !== HORIZONTAL; const isShowingTitle = !isVerticalOrientation && showTitle; @@ -464,7 +511,6 @@ class TimetableContent extends React.Component { colorIndex: this.props.colors[module.moduleCode], isHiddenInTimetable: this.isHiddenInTimetable(module.moduleCode), isTaInTimetable: this.isTaInTimetable(module.moduleCode), - canTa: false, }))} /> ) : ( @@ -474,11 +520,11 @@ class TimetableContent extends React.Component { ref={this.timetableRef} >
)} @@ -540,29 +586,23 @@ function mapStateToProps(state: StoreState, ownProps: OwnProps) { const hiddenInTimetable = ownProps.hiddenImportedModules ?? state.timetables.hidden[semester] ?? []; - const taInTimetable = ownProps.taImportedModules ?? state.timetables.ta[semester] ?? {}; + const taInTimetable = ownProps.taImportedModules ?? state.timetables.ta[semester] ?? []; + const taModuleCodes: TaModulesConfig = isArray(taInTimetable) + ? taInTimetable + : keys(taInTimetable); const timetableWithLessons = hydrateSemTimetableWithLessons(timetable, modules, semester); - const timetableWithTaLessons = hydrateTaModulesConfigWithLessons( - taInTimetable, - modules, - semester, - ); - const filteredTimetableWithLessons = { - ...timetableWithLessons, - ...timetableWithTaLessons, - }; return { semester, timetable, - timetableWithLessons: filteredTimetableWithLessons, + timetableWithLessons, modules, activeLesson: state.app.activeLesson, timetableOrientation: state.theme.timetableOrientation, showTitle: state.theme.showTitle, hiddenInTimetable, - taInTimetable, + taInTimetable: taModuleCodes, }; } @@ -572,7 +612,7 @@ export default connect(mapStateToProps, { resetTimetable, modifyLesson, changeLesson, + addLesson, + removeLesson, cancelModifyLesson, - addTaLessonInTimetable, - removeTaLessonInTimetable, })(TimetableContent); diff --git a/website/src/views/timetable/TimetableDay.tsx b/website/src/views/timetable/TimetableDay.tsx index 419c4bf1d0..cbae25a9c6 100644 --- a/website/src/views/timetable/TimetableDay.tsx +++ b/website/src/views/timetable/TimetableDay.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import classnames from 'classnames'; -import { HoverLesson, TimetableDayArrangement } from 'types/timetables'; +import { ColoredLesson, HoverLesson, TimetableDayArrangement } from 'types/timetables'; import { OnHoverCell, OnModifyCell } from 'types/views'; import { convertTimeToIndex, NUM_INTERVALS_PER_HOUR } from 'utils/timify'; @@ -13,7 +13,7 @@ import TimetableHighlight from './TimetableHighlight'; type Props = { day: string; - dayLessonRows: TimetableDayArrangement; + dayLessonRows: TimetableDayArrangement; verticalMode: boolean; showTitle: boolean; isScrolledHorizontally: boolean; diff --git a/website/src/views/timetable/TimetableModulesTable.test.tsx b/website/src/views/timetable/TimetableModulesTable.test.tsx index dd70ceeee9..a8d459a2cd 100644 --- a/website/src/views/timetable/TimetableModulesTable.test.tsx +++ b/website/src/views/timetable/TimetableModulesTable.test.tsx @@ -10,8 +10,8 @@ function make(props: Partial = {}) { const selectModuleColor = jest.fn(); const hideLessonInTimetable = jest.fn(); const showLessonInTimetable = jest.fn(); - const enableTaModeInTimetable = jest.fn(); - const disableTaModeInTimetable = jest.fn(); + const enableTaModule = jest.fn(); + const disableTaModule = jest.fn(); const onRemoveModule = jest.fn(); const resetTombstone = jest.fn(); @@ -26,8 +26,8 @@ function make(props: Partial = {}) { selectModuleColor={selectModuleColor} hideLessonInTimetable={hideLessonInTimetable} showLessonInTimetable={showLessonInTimetable} - enableTaModeInTimetable={enableTaModeInTimetable} - disableTaModeInTimetable={disableTaModeInTimetable} + enableTaModule={enableTaModule} + disableTaModule={disableTaModule} onRemoveModule={onRemoveModule} resetTombstone={resetTombstone} {...props} @@ -81,7 +81,6 @@ describe(TimetableModulesTableComponent, () => { colorIndex: 2, isHiddenInTimetable: false, isTaInTimetable: false, - canTa: false, }; const moduleCodes = getModules( @@ -92,11 +91,7 @@ describe(TimetableModulesTableComponent, () => { }); it('should display buttons correctly', () => { - // TA button is the 3rd button - const withoutTaButton = getButtons(make({ modules: addColors([CS1010S]) }).wrapper); - expect(withoutTaButton.at(0).children()).toHaveLength(2); - - const modulesWithTaAbleModule = addColors([CS1010S], false, false, true); + const modulesWithTaAbleModule = addColors([CS1010S], false, false); const withTaButton = getButtons(make({ modules: modulesWithTaAbleModule }).wrapper); expect(withTaButton.at(0).children()).toHaveLength(3); }); diff --git a/website/src/views/timetable/TimetableModulesTable.tsx b/website/src/views/timetable/TimetableModulesTable.tsx index c37bad9c8b..80b70edb5d 100644 --- a/website/src/views/timetable/TimetableModulesTable.tsx +++ b/website/src/views/timetable/TimetableModulesTable.tsx @@ -17,7 +17,8 @@ import { selectModuleColor, hideLessonInTimetable, showLessonInTimetable, - disableTaModeInTimetable, + addTaModule, + disableTaModule, } from 'actions/timetables'; import { getExamDate, @@ -49,8 +50,8 @@ export type Props = { selectModuleColor: (semester: Semester, moduleCode: ModuleCode, colorIndex: ColorIndex) => void; hideLessonInTimetable: (semester: Semester, moduleCode: ModuleCode) => void; showLessonInTimetable: (semester: Semester, moduleCode: ModuleCode) => void; - enableTaModeInTimetable: (semester: Semester, moduleCode: ModuleCode) => void; - disableTaModeInTimetable: (semester: Semester, moduleCode: ModuleCode) => void; + enableTaModule: (semester: Semester, moduleCode: ModuleCode) => void; + disableTaModule: (semester: Semester, moduleCode: ModuleCode) => void; onRemoveModule: (moduleCode: ModuleCode) => void; resetTombstone: () => void; }; @@ -97,28 +98,26 @@ export const TimetableModulesTableComponent: React.FC = (props) => { )} - {module.canTa && ( - - - - )} + + +
); @@ -207,6 +206,7 @@ export default connect( selectModuleColor, hideLessonInTimetable, showLessonInTimetable, - disableTaModeInTimetable, + enableTaModule: addTaModule, + disableTaModule, }, )(React.memo(TimetableModulesTableComponent)); diff --git a/website/src/views/timetable/TimetableRow.tsx b/website/src/views/timetable/TimetableRow.tsx index 7754d5bc15..522e4de2fc 100644 --- a/website/src/views/timetable/TimetableRow.tsx +++ b/website/src/views/timetable/TimetableRow.tsx @@ -1,9 +1,10 @@ import * as React from 'react'; -import { HoverLesson, ModifiableLesson } from 'types/timetables'; +import { HoverLesson, ColoredLesson, InteractableLesson } from 'types/timetables'; import { OnHoverCell, OnModifyCell } from 'types/views'; import { convertTimeToIndex } from 'utils/timify'; +import { isInteractable } from 'utils/timetables'; import styles from './TimetableRow.scss'; import TimetableCell from './TimetableCell'; @@ -12,7 +13,7 @@ type Props = { showTitle: boolean; startingIndex: number; endingIndex: number; - lessons: ModifiableLesson[]; + lessons: ColoredLesson[] | InteractableLesson[]; hoverLesson?: HoverLesson | null; onCellHover: OnHoverCell; onModifyCell?: OnModifyCell; @@ -55,7 +56,7 @@ const TimetableRow: React.FC = (props) => { lastStartIndex = endIndex; const conditionalProps = - lesson.isModifiable && onModifyCell + isInteractable(lesson) && lesson.canBeSelectedAsActiveLesson && onModifyCell ? { onClick: (position: ClientRect) => onModifyCell(lesson, position), } diff --git a/website/src/views/today/TodayContainer/TodayContainer.test.tsx b/website/src/views/today/TodayContainer/TodayContainer.test.tsx index e4b9d8f5b8..0aa5553460 100644 --- a/website/src/views/today/TodayContainer/TodayContainer.test.tsx +++ b/website/src/views/today/TodayContainer/TodayContainer.test.tsx @@ -35,6 +35,7 @@ const CS3216_LESSONS = { startTime: '1830', endTime: '2030', venue: 'VCRm', + lessonIndex: 0, }, ], }; @@ -51,6 +52,7 @@ const CS1010S_LESSONS = { startTime: '1100', endTime: '1200', venue: 'i3-0344', + lessonIndex: 0, }, ], }; @@ -67,6 +69,7 @@ const PC1222_LESSONS = { startTime: '1400', endTime: '1700', venue: 'S12-0402', + lessonIndex: 0, }, ], Tutorial: [ @@ -80,6 +83,7 @@ const PC1222_LESSONS = { startTime: '0900', endTime: '1000', venue: 'CQT/SR0315', + lessonIndex: 0, }, ], Lecture: [ @@ -93,6 +97,7 @@ const PC1222_LESSONS = { startTime: '1200', endTime: '1400', venue: 'LT25', + lessonIndex: 0, }, { moduleCode: 'PC1222', @@ -104,6 +109,7 @@ const PC1222_LESSONS = { startTime: '1200', endTime: '1400', venue: 'LT25', + lessonIndex: 0, }, ], }; @@ -348,18 +354,18 @@ describe(mapStateToProps, () => { timetables: { lessons: { [1]: {}, - [2]: {}, + [2]: { + CS1010S: { + Tutorial: [0], + }, + }, }, colors: { [1]: COLORS, [2]: COLORS, }, hidden: [], - ta: { - [2]: { - CS1010S: [['Tutorial', 1]], - }, - }, + ta: ['CS1010S'], }, } as any as State; diff --git a/website/src/views/today/TodayContainer/TodayContainer.tsx b/website/src/views/today/TodayContainer/TodayContainer.tsx index 3a8fc41062..1f86904f86 100644 --- a/website/src/views/today/TodayContainer/TodayContainer.tsx +++ b/website/src/views/today/TodayContainer/TodayContainer.tsx @@ -22,7 +22,6 @@ import { EmptyGroupType, SelectedLesson } from 'types/views'; import { groupLessonsByDay, hydrateSemTimetableWithLessons, - hydrateTaModulesConfigWithLessons, isLessonAvailable, isLessonOngoing, timetableLessonsArray, @@ -33,7 +32,6 @@ import { getSemesterTimetableColors, getSemesterTimetableHidden, getSemesterTimetableLessons, - getSemesterTimetableTaLessons, } from 'selectors/timetables'; import ExternalLink from 'views/components/ExternalLink'; import * as weatherAPI from 'apis/weather'; @@ -370,20 +368,14 @@ export const mapStateToProps = (state: StoreState, ownProps: OwnProps) => { const timetable = getSemesterTimetableLessons(state)(semester); const colors = getSemesterTimetableColors(state)(semester); const hidden = getSemesterTimetableHidden(state)(semester); - const ta = getSemesterTimetableTaLessons(state)(semester); const timetableWithLessons = omit( hydrateSemTimetableWithLessons(timetable, modules, semester), hidden, ); - const timetableWithTaLessons = hydrateTaModulesConfigWithLessons(ta, modules, semester); - const filteredTimetableWithLessons = { - ...timetableWithLessons, - ...timetableWithTaLessons, - }; return { colors, - timetableWithLessons: filteredTimetableWithLessons, + timetableWithLessons, }; }; diff --git a/website/src/views/venues/VenueDetails.tsx b/website/src/views/venues/VenueDetails.tsx index 3d7a93d01c..b918a71b5e 100644 --- a/website/src/views/venues/VenueDetails.tsx +++ b/website/src/views/venues/VenueDetails.tsx @@ -37,7 +37,7 @@ const VenueDetailsComponent: FC = ({ const lessons: Lesson[] = flatMap(availability, (day) => day.classes).map((venueLesson) => ({ ...venueLesson, title: '', - isModifiable: true, + canBeSelectedAsActiveLesson: true, venue: '', })); const coloredLessons = colorLessonsByKey(lessons, 'moduleCode');