import { strict as assert } from 'assert' ; import { has , last , map , mapValues , values } from 'lodash' ; import NUSModerator , { Semester as SemesterName } from 'nusmoderator' ; import { compareAsc , differenceInDays , format , parseISO } from 'date-fns' ; import getCovidZones , { getVenueCovidZone } from '../services/getCovidZones' ; import getVenueLocations from '../services/getVenueLocations' ; import { RawLesson , Semester , WeekRange , Weeks } from '../types/modules' ; import { Task } from '../types/tasks' ; import { TimetableLesson } from '../types/api' ; import { Cache } from '../types/persist' ; import { Logger } from '../services/logger' ; import BaseTask from './BaseTask' ; import config from '../config' ; import { getTermCode , retry } from '../utils/api' ; import { validateLesson , validateSemester } from '../services/validation' ; import { activityLessonType , dayTextMap , unrecognizedLessonTypes } from '../utils/data' ; import { allEqual , deltas } from '../utils/arrays' ; import { ISO8601_DATE_FORMAT } from '../utils/time' ; const SEMESTER_NAMES = { 1 : 'Semester 1' , 2 : 'Semester 2' , 3 : 'Special Sem 1' , 4 : 'Special Sem 2' , } ; type TempRawLesson = Omit < RawLesson , 'weeks' | 'covidZone' > & { weeks : string [ ] ; } ; /**
 * For deduplicating timetable lessons - we need a unique string identifier
 * for each lesson, so we leave out props that changes every lesson like eventdate.
 *
 * Session is not viable for differentiating lessons because things like having
 * different lecturers may also cause lessons to be marked as having different
 * sessions.
 */ const getLessonKey = ( lesson ) => [ lesson . activity , lesson . modgrp , lesson . day , lesson . start_time , lesson . end_time , lesson . room , ] . join ( '|' ) ; /**
 * Map date of lessons to either an array of numbers or an object representing
 * the range of date and the intervals between lessons
 */ export function mapLessonWeeks ( dates , semester , logger ) { if ( dates . length !== new Set ( dates ) . size ) { logger . error ( 'Lesson has duplicate dates' ) ; } const semesterName = SEMESTER_NAMES [ semester ] ; const lessonDates = dates . map ( ( date ) => parseISO ( date ) ) . sort ( compareAsc ) ; const weekInfo = lessonDates . map ( NUSModerator . academicCalendar . getAcadWeekInfo ) ; if ( weekInfo . every ( ( week ) => week . type === 'Instructional' && week . sem === semesterName ) ) { return weekInfo . map ( ( week ) => week . num ! ) ; } const firstDay = lessonDates [ 0 ] ; const weeks = lessonDates . map ( ( date ) => differenceInDays ( date , firstDay ) / 7 + 1 ) ; const intervals = deltas ( weeks ) ; const weekRange = { start : format ( lessonDates [ 0 ] , ISO8601_DATE_FORMAT ) , end : format ( last ( lessonDates ) ! , ISO8601_DATE_FORMAT ) , } ; const weekInterval = Math . min ( ... intervals ) ; if ( intervals . length > 0 && weekInterval !== 1 ) weekRange . weekInterval = weekInterval ; if ( ! allEqual ( intervals ) ) weekRange . weeks = weeks ; if ( intervals . some ( ( interval ) => interval % 1 !== 0 ) ) { logger . error ( { intervals , lessonDates } , 'Uneven week intervals' ) ; } return weekRange ; } /**
 * Mod group contains the activity at the start - this function removes that
 * because it is redundant.
 */ export function transformModgrpToClassNo ( modgrp , activity ) { const trimmedModgrp = modgrp . trim ( ) ; if ( trimmedModgrp . startsWith ( activity ) && trimmedModgrp !== activity ) { return trimmedModgrp . substring ( activity . length ) ; } return trimmedModgrp ; } export function mapTimetableLesson ( lesson , logger ) { const { room , start_time , end_time , day , module , modgrp , activity , eventdate , csize } = lesson ; if ( has ( unrecognizedLessonTypes , activity ) ) { logger . warn ( { moduleCode : module , activity } , 'Lesson type not recognized by the frontend used' ) ; } return { classNo : transformModgrpToClassNo ( modgrp , activity ) , startTime : start_time . replace ( ':' , '' ) , endTime : end_time . replace ( ':' , '' ) , weeks : [ eventdate ] , venue : room || '' , day : dayTextMap [ day ] , lessonType : activityLessonType [ activity ] , size : csize , } ; } type Input = void ; interface Output { [ moduleCode ] : RawLesson [ ] ; } /**
 * Download the timetable for a specific semester. Since the format provided by the
 * school is so inefficient, we make a separate request for each department and
 * reduce its size before moving onto the next one to reduce memory usage.
 *
 * Output:
 *  - <semester>/<module code>/timetable.json
 */ export default class GetSemesterTimetable extends BaseTask implements Task < Input , Output > { semester : Semester ; academicYear : string ; private readonly timetableCache : Cache < TimetableLesson [ ] > ; private readonly covidZones = getCovidZones ( ) ; private readonly venueLocations = getVenueLocations ( ) ; get name ( ) { return `Get timetable for semester ${ this . semester }` ; } constructor ( semester , academicYear = config . academicYear ) { super ( academicYear ) ; assert ( validateSemester ( semester ) , `${ semester } is not a valid semester` ) ; this . semester = semester ; this . academicYear = academicYear ; this . logger = this . rootLogger . child ( { semester , task : GetSemesterTimetable . name , year : academicYear , } ) ; this . timetableCache = this . getCache ( `semester-${ semester }-cache` ) ; } getTimetable = async ( ) => { const term = getTermCode ( this . semester , this . academicYear ) ; const timetables = { } ; let valid = 0 ; let invalid = 0 ; await this . api . getSemesterTimetables ( term , ( lesson ) => { if ( ! validateLesson ( lesson , this . logger ) ) { if ( lesson . module && ! timetables [ lesson . module ] ) { timetables [ lesson . module ] = { } ; } if ( ! lesson . start_time || ! lesson . end_time || lesson . start_time === lesson . end_time ) { const { start_time , end_time , module } = lesson ; this . logger . error ( { moduleCode : module , end_time , start_time } , 'Lesson has no start and/or end time' ) ; } invalid += 1 ; return ; } valid += 1 ; const key = getLessonKey ( lesson ) ; if ( ! timetables [ lesson . module ] ) timetables [ lesson . module ] = { } ; const timetable = timetables [ lesson . module ] ; const rawLesson = timetable [ key ] ; if ( rawLesson ) { rawLesson . weeks . push ( lesson . eventdate ) ; } else { timetable [ key ] = mapTimetableLesson ( lesson , this . logger ) ; } } ) ; this . logger . info ( { valid , invalid } , 'Processed and removed invalid lessons' ) ; return mapValues ( timetables , ( timetableObject , moduleCode ) => values ( timetableObject ) . map ( ( lesson ) => ( { ... lesson , weeks : mapLessonWeeks ( lesson . weeks , this . semester , this . logger . child ( { moduleCode , lesson } ) ) , } ) ) ) ; } ; async run ( ) { const timetablesWithoutCovidZones = await retry ( this . getTimetable , 3 ) ; const [ covidZones , venues ] = await Promise . all ( [ this . covidZones , this . venueLocations ] ) ; const timetables = mapValues ( timetablesWithoutCovidZones , ( lessons ) => lessons . map ( ( lesson ) => ( { ... lesson , covidZone : getVenueCovidZone ( venues , covidZones , lesson . venue ) , } ) ) ) ; await Promise . all ( map ( timetables , ( timetable , moduleCode ) => this . io . timetable ( this . semester , moduleCode , timetable ) ) ) ; return timetables ; } }