import "../css/main.css" ; import "../css/course.pcss" ; const ics = require ( "./vendor/ics-0.2.0.min.js" ) ; const sortable = require ( "html5sortable/dist/html5sortable.cjs" ) ; import Clipboard from "clipboard" ; import { jsPDF } from "jspdf" ; import * as Course from "./lib/course" ; import * as SortKey from "./lib/sort-key" ; import * as Util from "./lib/util" ; import * as TimeString from "./lib/time-string" ; import * as Sidebar from "./view/sidebar" ; import * as CourseDetails from "./view/course-details" ; import * as SearchResults from "./view/search-results" ; import * as _ from "lodash/fp" ; interface ApiData { data : { terms : Record < string , Term > ; courses : Record < string , Course . CourseV3 > ; } ; until : number ; } type Weekday = "U" | "M" | "T" | "W" | "R" | "F" | "S" ; interface Term { termCode : string ; termSortKey : SortKey . SortKey ; termName : string ; } const millisecondsPerHour = 3600 * 1000 ; const pacificTimeZoneValues = [ - 8.0 , - 7.0 ] ; const pacificTimeZoneId = 5 ; const apiURL = process . env . API_URL || "https://hyperschedule.herokuapp.com" ; const greyConflictCoursesOptions = [ "none" , "starred" , "all" ] ; const filterKeywords = { "dept:" : [ "dept:" , "department:" ] , "college:" : [ "college:" , "col:" , "school:" , "sch:" ] , "days:" : [ "days:" , "day:" ] , "credit:" : [ "credit:" , "cred:" , "credits:" , "creds:" ] , } ; const filterKeywordsValues = Object . values ( filterKeywords ) . flat ( ) ; const filterInequalities = [ "<=" , ">=" , "<" , ">" , "=" ] ; const pacificScheduleDays = [ "Sunday" , "Monday" , "Tuesday" , "Wednesday" , "Thursday" , "Friday" , "Saturday" , ] ; const pacificScheduleTimes = [ "07:00" , "08:00" , "09:00" , "10:00" , "11:00" , "12:00" , "13:00" , "14:00" , "15:00" , "16:00" , "17:00" , "18:00" , "19:00" , "20:00" , "21:00" , "22:00" , ] ; const motd = document . getElementById ( "motd" ) ! ; const courseSearchToggle = document . getElementById ( "course-search-toggle" ) ! ; const scheduleToggle = document . getElementById ( "schedule-toggle" ) ! ; const closedCoursesToggle = < HTMLInputElement > ( document . getElementById ( "closed-courses-toggle" ) ! ) ; const hideAllConflictingCoursesToggle = < HTMLInputElement > ( document . getElementById ( "all-conflicting-courses-toggle" ) ! ) ; const hideStarredConflictingCoursesToggle = < HTMLInputElement > ( document . getElementById ( "star-conflicting-courses-toggle" ) ! ) ; const courseSearchColumn = document . getElementById ( "course-search-column" ) ! ; const scheduleColumn = document . getElementById ( "schedule-column" ) ! ; const courseSearchInput = < HTMLInputElement > ( document . getElementById ( "course-search-course-name-input" ) ! ) ; const printAllButton = document . getElementById ( "print-button-all" ) ! ; const printStarredButton = document . getElementById ( "print-button-starred" ) ! ; const conflictCoursesRadios = < NodeListOf < HTMLInputElement > > ( document . getElementsByName ( "conflict-courses" ) ) ; const timeZoneDropdown = < HTMLSelectElement > ( document . getElementById ( "time-zone-dropdown" ) ! ) ; const selectedCoursesList = document . getElementById ( "selected-courses-list" ) ! ; const scheduleTable = document . getElementById ( "schedule-table" ) ! ; const scheduleTableDays = document . getElementsByClassName ( "schedule-day" ) ; const scheduleTableHours = document . getElementsByClassName ( "schedule-hour" ) ; const creditCountText = document . getElementById ( "credit-count" ) ! ; const importExportTextArea = < HTMLInputElement > ( document . getElementById ( "import-export-text-area" ) ! ) ; const importExportICalButton = document . getElementById ( "import-export-ical-button" ) ! ; const importExportSaveChangesButton = document . getElementById ( "import-export-save-changes-button" ) ! ; const importExportCopyButton = document . getElementById ( "import-export-copy-button" ) ! ; let gApiData = null ; let gSelectedCourses = [ ] ; let gScheduleTabSelected = false ; let gShowClosedCourses = true ; let gHideAllConflictingCourses = false ; let gHideStarredConflictingCourses = false ; let gGreyConflictCourses = greyConflictCoursesOptions [ 0 ] ; let gTimeZoneValues = pacificTimeZoneValues ; let gTimeZoneId = pacificTimeZoneId ; let gTimeZoneSavings = 0 ; let gPacificTimeSavings = 0 ; let gCurrentlySorting = false ; let gCourseEntityHeight = 0 ; let gFilteredCourseKeys = [ ] ; let gCourseSelected = null ; function parseDate ( dateStr ) { const [ year , month , day ] = dateStr . split ( "-" ) ; return new Date ( parseInt ( year ) , parseInt ( month ) - 1 , parseInt ( day ) ) ; } function quoteRegexp ( str ) { return ( str + "" ) . replace ( /[.?*+^$[\]\\(){}|-]/g , "\\$&" ) ; } function deepCopy ( obj ) { return JSON . parse ( JSON . stringify ( obj ) ) ; } function weekdayCharToInteger ( weekday ) { const index = "UMTWRFS" . indexOf ( weekday ) ; if ( index < 0 ) { throw Error ( "Invalid weekday: " + weekday ) ; } return index ; } function readFromLocalStorage < T > ( key , pred , def ) { const jsonString = localStorage . getItem ( key ) ; if ( ! jsonString ) { return def ; } try { const obj = JSON . parse ( jsonString ) ; if ( pred ( obj ) ) { return obj ; } else { return def ; } } catch ( err ) { return def ; } } function checkPST ( ) { let today = new Date ( ) ; let start = nthSundayOfMonth ( 10 , 1 , 2 , 0 , pacificTimeZoneValues [ gPacificTimeSavings ] ) ; let end = nthSundayOfMonth ( 2 , 2 , 2 , 0 , pacificTimeZoneValues [ gPacificTimeSavings ] ) ; return today > start || today < end ? 0 : 1 ; } function checkTimeZoneSavings ( ) { let selectedTimeZoneClassList = timeZoneDropdown . options [ timeZoneDropdown . selectedIndex ] . classList ; if ( selectedTimeZoneClassList . length == 0 ) { return 0 ; } let savingTimeZone = selectedTimeZoneClassList [ 0 ] . substring ( 0 , selectedTimeZoneClassList [ 0 ] . indexOf ( "-" ) ) ; let start , end ; let timeZoneValue = gTimeZoneValues [ 0 ] ; switch ( savingTimeZone ) { case "nz" : start = nthSundayOfMonth ( 8 , - 1 , 2 , 0 , timeZoneValue ) ; end = nthSundayOfMonth ( 3 , 1 , 3 , 0 , timeZoneValue ) ; break ; case "us" : start = nthSundayOfMonth ( 10 , 1 , 2 , 0 , timeZoneValue ) ; end = nthSundayOfMonth ( 2 , 2 , 2 , 0 , timeZoneValue ) ; break ; case "mex" : start = nthSundayOfMonth ( 9 , - 1 , 2 , 0 , timeZoneValue ) ; end = nthSundayOfMonth ( 3 , 1 , 2 , 0 , timeZoneValue ) ; break ; case "chile" : start = nthSundayOfMonth ( 8 , 1 , 1 , 0 , timeZoneValue ) ; end = nthSundayOfMonth ( 3 , 1 , 1 , 0 , timeZoneValue ) ; break ; case "eu" : start = nthSundayOfMonth ( 9 , - 1 , 1 , 0 , 0 ) ; end = nthSundayOfMonth ( 2 , - 1 , 1 , 0 , 0 ) ; break ; case "amman" : start = nthSundayOfMonth ( 9 , - 1 , 1 , 5 , timeZoneValue ) ; end = nthSundayOfMonth ( 2 , - 1 , 1 , 5 , timeZoneValue ) ; break ; case "israel" : start = nthSundayOfMonth ( 9 , - 1 , 2 , 0 , timeZoneValue ) ; end = nthSundayOfMonth ( 2 , - 1 , 1 , 0 , timeZoneValue ) ; end . setDate ( end . getDate ( ) - 2 ) ; break ; case "iran" : let year = new Date ( ) . getFullYear ( ) ; let [ timeZoneSign , timeZoneString ] = getTimeZoneString ( timeZoneValue ) ; start = new Date ( year . toString ( ) + "-09-22T00:00:00.000" + timeZoneSign + timeZoneString ) ; end = new Date ( year . toString ( ) + "-03-22T00:00:00.000" + timeZoneSign + timeZoneString ) ; break ; case "aus" : start = nthSundayOfMonth ( 9 , 1 , 2 , 0 , timeZoneValue ) ; end = nthSundayOfMonth ( 3 , 1 , 2 , 0 , timeZoneValue ) ; break ; case "fiji" : start = nthSundayOfMonth ( 10 , 2 , 2 , 0 , timeZoneValue ) ; end = nthSundayOfMonth ( 0 , 2 , 2 , 0 , timeZoneValue ) ; break ; default : return 0 ; } let today = new Date ( ) ; return today > start || today < end ? 0 : 1 ; } function nthSundayOfMonth ( month , n , hours , dayOfWeek , timeZoneValue ) { let fullDate = new Date ( ) ; let year = fullDate . getFullYear ( ) ; const lastDay = [ 31 , 28 , 31 , 30 , 31 , 30 , 31 , 31 , 30 , 31 , 30 , 31 ] ; if ( year % 4 === 0 && ( year % 100 !== 0 || year % 400 === 0 ) ) { lastDay [ 2 ] = 29 ; } if ( n === - 1 ) { fullDate . setFullYear ( year , month , lastDay [ month ] ) ; if ( fullDate . getDay ( ) < dayOfWeek ) { fullDate . setDate ( fullDate . getDate ( ) - 7 ) ; } fullDate . setDate ( fullDate . getDate ( ) - ( fullDate . getDay ( ) - dayOfWeek ) ) ; } else { fullDate . setFullYear ( year , month , 1 ) ; fullDate . setDate ( ( ( 1 + dayOfWeek + 7 - fullDate . getDay ( ) ) % 7 ) + 7 * ( n - 1 ) ) ; } const [ timeZoneSign , timeZoneString ] = getTimeZoneString ( timeZoneValue ) ; fullDate = new Date ( year . toString ( ) + "-" + ( month + 1 ) . toString ( ) . padStart ( 2 , "0" ) + "-" + fullDate . getDate ( ) . toString ( ) . padStart ( 2 , "0" ) + "T" + hours . toString ( ) . padStart ( 2 , "0" ) + ":00:00.000" + timeZoneSign + timeZoneString ) ; return fullDate ; } function getTimeZoneString ( timeZoneValue ) { let timeZoneSign = timeZoneValue >= 0 ? "+" : "-" ; let timeZoneHour = Math . abs ( timeZoneValue ) ; let timeZoneMin = Math . round ( ( timeZoneValue % 1 ) * 60 ) ; let timeZoneString = timeZoneHour . toString ( ) . padStart ( 2 , "0" ) + ":" + timeZoneMin . toString ( ) . padEnd ( 2 , "0" ) ; return [ timeZoneSign , timeZoneString ] ; } async function runWithExponentialBackoff ( task , failureDelay , backoffFactor , fetchDesc ) { while ( true ) { console . log ( `Attempting to ${ fetchDesc }...` ) ; try { await task ( ) ; break ; } catch ( err ) { console . error ( err ) ; console . log ( `Trying again to ${ fetchDesc } in ${ failureDelay }ms` ) ; await new Promise ( ( resolve ) => setTimeout ( resolve , failureDelay ) ) ; failureDelay *= backoffFactor ; } } } function hideEntity ( entity ) { entity . classList . add ( "hidden" ) ; } function showEntity ( entity ) { entity . classList . remove ( "hidden" ) ; } function setEntityVisibility ( entity , visible ) { ( visible ? showEntity : hideEntity ) ( entity ) ; } function setButtonSelected ( button , selected ) { const classAdded = selected ? "btn-primary" : "btn-light" ; const classRemoved = selected ? "btn-light" : "btn-primary" ; button . classList . add ( classAdded ) ; button . classList . remove ( classRemoved ) ; } function removeEntityChildren ( entity ) { while ( entity . lastChild !== null ) { entity . removeChild ( entity . lastChild ) ; } } function courseMatchesSearchQuery ( course , query ) { for ( let subquery of query ) { if ( course . courseCode . match ( subquery ) || course . courseCode . replace ( / /g , "" ) . match ( subquery ) || course . courseName . match ( subquery ) ) { continue ; } let foundMatch = false ; if ( course . courseInstructors !== null ) { for ( let instructor of course . courseInstructors ) { if ( instructor . match ( subquery ) ) { foundMatch = true ; break ; } } } if ( foundMatch ) { continue ; } return false ; } return true ; } function coursePassesTextFilters ( course , textFilters ) { const lowerCourseCode = course . courseCode . toLowerCase ( ) ; const dept = lowerCourseCode . slice ( 0 , 4 ) . trim ( ) ; const col = lowerCourseCode . split ( "-" ) [ 0 ] . slice ( - 2 ) ; if ( ( textFilters [ "dept:" ] && ! dept . match ( textFilters [ "dept:" ] ) ) || ( textFilters [ "college:" ] && ! col . match ( textFilters [ "college:" ] ) ) || ( textFilters [ "days:" ] && ! coursePassesDayFilter ( course , textFilters [ "days:" ] ) ) || ( textFilters [ "credit:" ] && ! coursePassesCreditFilter ( course , textFilters [ "credit:" ] ) ) ) { return false ; } return true ; } function parseDaysInequality ( inputDays ) { for ( const rel of filterInequalities ) if ( inputDays . startsWith ( rel ) ) return rel ; return "" ; } function generateDayFilter ( course ) { const scheduleList = course . courseSchedule ; let days = new Set ( ) ; for ( let schedule of scheduleList ) { const str1 = schedule . scheduleDays . toLowerCase ( ) ; const arr1 = [ ... str1 ] ; arr1 . forEach ( days . add , days ) ; } return days ; } function generateInputDays ( input ) { let days = new Set ( ) ; const arr1 = [ ... input ] ; arr1 . forEach ( days . add , days ) ; return days ; } function coursePassesDayFilter ( course , inputString ) { const courseDays = generateDayFilter ( course ) ; const rel = parseDaysInequality ( inputString ) ; const inputDays = generateInputDays ( inputString . substring ( rel . length ) . toLowerCase ( ) ) ; switch ( rel ) { case "<=" : return setSubset ( courseDays , inputDays ) ; case "" : case ">=" : return setSubset ( inputDays , courseDays ) ; case "=" : const difference1 = new Set ( [ ... courseDays ] . filter ( ( x ) => ! inputDays . has ( x ) ) ) ; const difference2 = new Set ( [ ... inputDays ] . filter ( ( x ) => ! courseDays . has ( x ) ) ) ; return difference1 . size == 0 && difference2 . size == 0 ; case "<" : return ( setSubset ( courseDays , inputDays ) && inputDays . size != courseDays . size ) ; case ">" : return ( setSubset ( inputDays , courseDays ) && inputDays . size != courseDays . size ) ; default : return false ; } } function coursePassesCreditFilter ( course , filter ) { const match = filter . match ( /^(<|<=|>|>=|=)?([0-9.]+)$/ ) ; if ( match === null ) return false ; const nonMuddMultiplier = course . courseCode . match ( /[A-Z]* *[0-9A-Z ]*? *([A-Z]{2})-[0-9]{2}/ ) ?. [ 1 ] === "HM" ? 1 : 3 ; const [ , rel , val ] = match ; const credits = parseFloat ( val ) / nonMuddMultiplier ; switch ( rel ) { case "<" : return course . courseCredits < credits ; case "<=" : return course . courseCredits <= credits ; case ">" : return course . courseCredits > credits ; case ">=" : return course . courseCredits >= credits ; case undefined : case "=" : return course . courseCredits === credits ; } } function setSubset < T > ( a , b ) { if ( a . size > b . size ) return false ; for ( const elem of a ) if ( ! b . has ( elem ) ) return false ; return true ; } function courseConflictWithSchedule ( course , starredOnly ) { const schedule = computeSchedule ( gSelectedCourses ) ; for ( let existingCourse of schedule ) { if ( ( ! starredOnly || existingCourse . starred === starredOnly ) && ! Course . equal ( existingCourse , course ) && Course . conflict ( course , existingCourse ) ) { return true ; } } return false ; } function courseInSchedule ( course ) { const schedule = computeSchedule ( gSelectedCourses ) ; for ( let existingCourse of schedule ) { if ( Course . equal ( existingCourse , course ) ) { return true ; } } return false ; } function computeSchedule ( courses ) { const schedule = [ ] ; for ( let course of courses ) { if ( course . selected && course . starred ) { schedule . unshift ( course ) ; } } for ( let course of courses ) { if ( ! course . selected || course . starred ) { continue ; } let conflicts = false ; for ( let existingCourse of schedule ) { if ( Course . mutuallyExclusive ( course , existingCourse ) || Course . conflict ( course , existingCourse ) ) { conflicts = true ; break ; } } if ( ! conflicts ) { schedule . push ( course ) ; } } return schedule ; } function computeCreditCountDescription ( schedule ) { let totalCredits = 0 ; let starredCredits = 0 ; for ( let course of schedule ) { let credits = course . courseCredits ; const nonMuddMultiplier = course . courseCode . match ( /[A-Z]* *[0-9A-Z ]*? *([A-Z]{2})-[0-9]{2}/ ) ?. [ 1 ] === "HM" ? 1 : 3 ; totalCredits += nonMuddMultiplier * credits ; if ( course . starred ) { starredCredits += nonMuddMultiplier * credits ; } } return ( "Scheduled: " + totalCredits + " credit" + ( totalCredits !== 1 ? "s" : "" ) + " (" + starredCredits + " starred)" ) ; } function courseAlreadyAdded ( course ) { return _ . some ( ( selectedCourse ) => { return selectedCourse . courseCode === course . courseCode ; } , gSelectedCourses ) ; } async function retrieveAPI ( endpoint ) { const httpResponse = await fetch ( apiURL + endpoint ) ; if ( ! httpResponse . ok ) { throw Error ( `Received API error for endpoint ${ endpoint }: ` + `${ httpResponse . status } ${ httpResponse . statusText }` ) ; } return await httpResponse . json ( ) ; } function setMotd ( ) { } const attachModal = ( { button , modal , pre , } ) => { const btnEl = document . getElementById ( button ) ! ; const modalEl = document . getElementById ( modal ) ! ; const layerEl = document . getElementById ( "modal-layer" ) ! ; btnEl . addEventListener ( "click" , ( ) => { if ( pre ) pre ( ) ; layerEl . classList . add ( "show" ) ; modalEl . classList . add ( "show" ) ; } ) ; layerEl . addEventListener ( "click" , ( ) => { layerEl . classList . remove ( "show" ) ; modalEl . classList . remove ( "show" ) ; } ) ; modalEl . addEventListener ( "click" , ( e ) => e . stopPropagation ( ) ) ; for ( const close of modalEl . getElementsByClassName ( "close" ) ) { close . addEventListener ( "click" , ( ) => { layerEl . classList . remove ( "show" ) ; modalEl . classList . remove ( "show" ) ; } ) ; } } ; async function attachListeners ( ) { attachModal ( { button : "help-button" , modal : "help-modal" } ) ; attachModal ( { button : "settings-button" , modal : "settings-modal" } ) ; attachModal ( { button : "import-export-data-button" , modal : "import-export-modal" , pre : showImportExportModal , } ) ; courseSearchToggle . addEventListener ( "click" , displayCourseSearchColumn ) ; scheduleToggle . addEventListener ( "click" , displayScheduleColumn ) ; closedCoursesToggle . addEventListener ( "click" , toggleClosedCourses ) ; hideAllConflictingCoursesToggle . addEventListener ( "click" , toggleAllConflictingCourses ) ; hideStarredConflictingCoursesToggle . addEventListener ( "click" , toggleStarredConflictingCourses ) ; courseSearchInput . addEventListener ( "keyup" , handleCourseSearchInputUpdate ) ; importExportICalButton . addEventListener ( "click" , downloadICalFile ) ; importExportSaveChangesButton . addEventListener ( "click" , saveImportExportModalChanges ) ; sortable ( ".sortable-list" , { forcePlaceholderSize : true , placeholder : Course . createEntity ( "placeholder" ) . outerHTML , } ) ; printAllButton . addEventListener ( "click" , ( ) => { downloadPDF ( false ) ; } ) ; printStarredButton . addEventListener ( "click" , ( ) => { downloadPDF ( true ) ; } ) ; selectedCoursesList . addEventListener ( "sortupdate" , readSelectedCoursesList ) ; selectedCoursesList . addEventListener ( "sortstart" , ( ) => { gCurrentlySorting = true ; } ) ; selectedCoursesList . addEventListener ( "sortstop" , ( ) => { gCurrentlySorting = false ; } ) ; const filterButton = document . getElementById ( "filter-button" ) ! ; const filterButtonWrapper = document . getElementById ( "filter-button-wrapper" ) ! ; filterButton . addEventListener ( "click" , ( ) => { filterButtonWrapper . classList . toggle ( "show" ) ; } ) ; const printBtn = document . getElementById ( "print-dropdown" ) ! ; const printBtnWrapper = document . getElementById ( "print-dropdown-wrapper" ) ! ; printBtn . addEventListener ( "click" , ( ) => { printBtnWrapper . classList . toggle ( "show" ) ; } ) ; for ( let i = 0 ; conflictCoursesRadios [ i ] ; i ++ ) { conflictCoursesRadios [ i ] . addEventListener ( "click" , ( ) => { gGreyConflictCourses = greyConflictCoursesOptions [ i ] ; toggleConflictCourses ( ) ; } ) ; } timeZoneDropdown . addEventListener ( "change" , ( ) => { let selectedTimeZone = timeZoneDropdown . options [ timeZoneDropdown . selectedIndex ] ; let timeZoneValue = parseFloat ( selectedTimeZone . value ) ; gTimeZoneValues = [ ] ; gTimeZoneValues . push ( timeZoneValue ) ; if ( selectedTimeZone . classList . length > 0 ) { gTimeZoneValues . push ( timeZoneValue + 1 ) ; } gTimeZoneId = parseInt ( selectedTimeZone . id . substring ( 19 ) ) ; gTimeZoneSavings = checkTimeZoneSavings ( ) ; toggleTimeZone ( ) ; } ) ; window . addEventListener ( "resize" , rerenderCourseSearchResults ) ; let clipboard = new Clipboard ( "#import-export-copy-button" ) ; clipboard . on ( "success" , ( ) => { if ( ! importExportCopyButton . classList . contains ( "copy-button-copied" ) ) { importExportCopyButton . classList . add ( "copy-button-copied" ) ; } } ) ; clipboard . on ( "error" , ( ) => { importExportCopyButton . classList . add ( "copy-button-error" ) ; } ) ; } function processSearchText ( ) { const searchText = courseSearchInput . value . trim ( ) . split ( /\s+/ ) ; const filtersText = [ ] ; const queryText = [ ] ; for ( const text of searchText ) { const lower = text . toLowerCase ( ) ; if ( filterKeywordsValues . some ( ( filter ) => lower . startsWith ( filter ) ) ) { filtersText . push ( lower ) ; } else { queryText . push ( lower ) ; } } const query = getSearchQuery ( queryText ) ; const filters = getSearchTextFilters ( filtersText ) ; return { query , filters } ; } function getSearchQuery ( searchTextArray ) { return searchTextArray . map ( ( subquery ) => { return new RegExp ( quoteRegexp ( subquery ) , "i" ) ; } ) ; } function getSearchTextFilters ( filtersTextArray ) { const filter = { } ; for ( let text of filtersTextArray ) { let keyword = text . split ( ":" ) [ 0 ] + ":" ; const filterText = text . split ( ":" ) [ 1 ] ; if ( ! ( keyword in Object . keys ( filterKeywords ) ) ) { for ( const key of Object . keys ( filterKeywords ) ) { if ( filterKeywords [ key ] . includes ( keyword ) ) { keyword = key ; break ; } } } filter [ keyword ] = filterText ; } console . log ( filter ) ; return filter ; } function updateTabToggle ( ) { setEntityVisibility ( scheduleColumn , gScheduleTabSelected ) ; setButtonSelected ( scheduleToggle , gScheduleTabSelected ) ; setEntityVisibility ( courseSearchColumn , ! gScheduleTabSelected ) ; setButtonSelected ( courseSearchToggle , ! gScheduleTabSelected ) ; if ( ! gScheduleTabSelected ) { updateCourseSearchResults ( ) ; } } function updateShowClosedCoursesCheckbox ( ) { closedCoursesToggle . checked = gShowClosedCourses ; } function updateShowConflictingCoursesCheckbox ( ) { hideAllConflictingCoursesToggle . checked = gHideAllConflictingCourses ; hideStarredConflictingCoursesToggle . checked = gHideStarredConflictingCourses ; } function updateConflictCoursesRadio ( ) { switch ( gGreyConflictCourses ) { case greyConflictCoursesOptions [ 0 ] : conflictCoursesRadios [ 0 ] . checked = true ; break ; case greyConflictCoursesOptions [ 1 ] : conflictCoursesRadios [ 1 ] . checked = true ; break ; case greyConflictCoursesOptions [ 2 ] : conflictCoursesRadios [ 2 ] . checked = true ; break ; default : conflictCoursesRadios [ 0 ] . checked = true ; } } function updateTimeZoneDropdown ( ) { ( document . getElementById ( "time-zone-dropdown-" + gTimeZoneId . toString ( ) ) as HTMLOptionElement ) . selected = true ; timeZoneDropdown . dispatchEvent ( new Event ( "change" ) ) ; } function updateCourseSearchResults ( ) { if ( gApiData === null ) { gFilteredCourseKeys = [ ] ; } else { const { query , filters } = processSearchText ( ) ; gFilteredCourseKeys = gApiData === null ? [ ] : Object . keys ( gApiData . data . courses ) . filter ( ( key ) => { const course = gApiData ! . data . courses [ key ] ; return ( courseMatchesSearchQuery ( course , query ) && coursePassesTextFilters ( course , filters ) && ( gShowClosedCourses || ! Course . isClosed ( course ) ) && ( ! gHideAllConflictingCourses || ! courseConflictWithSchedule ( course , false ) ) && ( ! gHideStarredConflictingCourses || ! courseConflictWithSchedule ( course , true ) ) ) ; } ) ; } rerenderCourseSearchResults ( ) ; } function rerenderCourseSearchResults ( ) { if ( gApiData === null ) { return ; } const apiData = gApiData . data ; SearchResults . update ( gFilteredCourseKeys . length , ( i ) => apiData . courses [ gFilteredCourseKeys [ i ] ] , courseAlreadyAdded , addCourse , CourseDetails . setCourse ) ; } function updateSelectedCoursesList ( ) { if ( gCurrentlySorting ) { setTimeout ( updateSelectedCoursesList , 100 ) ; return ; } while ( selectedCoursesList . lastChild !== null ) { selectedCoursesList . removeChild ( selectedCoursesList . lastChild ) ; } for ( let idx = 0 ; idx < gSelectedCourses . length ; ++ idx ) { const course = gSelectedCourses [ idx ] ; selectedCoursesList . appendChild ( Course . createEntity ( course , { add : addCourse , remove : removeCourse , toggleStarred : toggleCourseStarred , toggleSelected : toggleCourseSelected , focus : CourseDetails . setCourse , } , { idx , alreadyAdded : true } ) ) ; } sortable ( ".sortable-list" ) ; } function updateSchedule ( ) { updateScheduleTimeZone ( ) ; const schedule = computeSchedule ( gSelectedCourses ) ; while ( scheduleTable . getElementsByClassName ( "schedule-slot" ) . length > 0 ) { const element = scheduleTable . getElementsByClassName ( "schedule-slot-wrapper" ) [ 0 ] ; element . parentNode ! . removeChild ( element ) ; } for ( const course of schedule ) { const entities = Course . createSlotEntities ( course , CourseDetails . setCourse ) ; entities . forEach ( ( e ) => scheduleTable . appendChild ( e ) ) ; } creditCountText . textContent = computeCreditCountDescription ( schedule ) ; } function updateScheduleTimeZone ( ) { for ( let i = 0 ; scheduleTableDays [ i ] ; i ++ ) { if ( gTimeZoneValues [ gTimeZoneSavings ] >= 9.0 ) { scheduleTableDays [ i ] . textContent = pacificScheduleDays [ i + 2 ] ; } else { scheduleTableDays [ i ] . textContent = pacificScheduleDays [ i + 1 ] ; } } for ( let i = 0 ; scheduleTableHours [ i ] ; i ++ ) { scheduleTableHours [ i ] . textContent = TimeString . tzAdjusted ( pacificScheduleTimes [ i ] , gTimeZoneValues [ gTimeZoneSavings ] - pacificTimeZoneValues [ gPacificTimeSavings ] ) ; } } function showImportExportModal ( ) { importExportTextArea . value = JSON . stringify ( gSelectedCourses ) ; } function updateCourseDescriptionTimeZone ( ) { if ( gCourseSelected !== null ) CourseDetails . setCourse ( gCourseSelected ) ; } function handleCourseSearchInputUpdate ( ) { updateCourseSearchResults ( ) ; } function handleSelectedCoursesUpdate ( ) { rerenderCourseSearchResults ( ) ; updateSelectedCoursesList ( ) ; updateSchedule ( ) ; writeStateToLocalStorage ( ) ; } function handleGlobalStateUpdate ( ) { updateTabToggle ( ) ; updateShowClosedCoursesCheckbox ( ) ; updateShowConflictingCoursesCheckbox ( ) ; updateConflictCoursesRadio ( ) ; updateTimeZoneDropdown ( ) ; updateCourseSearchResults ( ) ; updateCourseDisplays ( ) ; writeStateToLocalStorage ( ) ; } function addCourse ( course ) { if ( courseAlreadyAdded ( course ) ) { return ; } course = deepCopy ( course ) ; course . selected = true ; course . starred = false ; gSelectedCourses . push ( course ) ; handleSelectedCoursesUpdate ( ) ; } function removeCourse ( course ) { gSelectedCourses . splice ( gSelectedCourses . indexOf ( course ) , 1 ) ; handleSelectedCoursesUpdate ( ) ; } function readSelectedCoursesList ( ) { const newSelectedCourses = [ ] ; for ( let entity of selectedCoursesList . children ) { const idx = parseInt ( entity . getAttribute ( "data-course-index" ) ! , 10 ) ; if ( ! isNaN ( idx ) && idx >= 0 && idx < gSelectedCourses . length ) { newSelectedCourses . push ( gSelectedCourses [ idx ] ) ; } else { alert ( "An internal error occurred. This is bad." ) ; updateSelectedCoursesList ( ) ; return ; } } gSelectedCourses = newSelectedCourses ; handleSelectedCoursesUpdate ( ) ; } function saveImportExportModalChanges ( ) { let obj ; try { obj = JSON . parse ( importExportTextArea . value ) ; if ( ! Array . isArray ( obj ) ) { throw Error ( ) ; } } catch ( err ) { alert ( "Malformed JSON. Refusing to save." ) ; return ; } gSelectedCourses = obj . map ( Course . upgrade ) ; handleSelectedCoursesUpdate ( ) ; } function toggleClosedCourses ( ) { gShowClosedCourses = ! gShowClosedCourses ; updateCourseSearchResults ( ) ; writeStateToLocalStorage ( ) ; } function toggleAllConflictingCourses ( ) { gHideAllConflictingCourses = ! gHideAllConflictingCourses ; updateCourseSearchResults ( ) ; } function toggleStarredConflictingCourses ( ) { gHideStarredConflictingCourses = ! gHideStarredConflictingCourses ; updateCourseSearchResults ( ) ; } function toggleConflictCourses ( ) { updateCourseDisplays ( ) ; writeStateToLocalStorage ( ) ; } function toggleTimeZone ( ) { updateCourseDisplays ( ) ; writeStateToLocalStorage ( ) ; } function toggleCourseSelected ( course ) { course . selected = ! course . selected ; updateCourseDisplays ( ) ; writeStateToLocalStorage ( ) ; } function toggleCourseStarred ( course ) { course . starred = ! course . starred ; updateCourseDisplays ( ) ; writeStateToLocalStorage ( ) ; } function updateCourseDisplays ( ) { updateCourseSearchResults ( ) ; updateSelectedCoursesList ( ) ; updateSchedule ( ) ; } function displayCourseSearchColumn ( ev ) { ( < HTMLElement > ev . target ) . blur ( ) ; gScheduleTabSelected = false ; updateTabToggle ( ) ; writeStateToLocalStorage ( ) ; } function displayScheduleColumn ( ev ) { ( < HTMLElement > ev . target ) . blur ( ) ; gScheduleTabSelected = true ; updateTabToggle ( ) ; writeStateToLocalStorage ( ) ; } /**
 * Given an arbitrary data value and an arbitrary diff (see the
 * documentation for the Hyperschedule backend for information about
 * the format of diffs), apply the diff and return a new data object.
 * The original data object may have been mutated.
 */ function applyDiff ( data , diff ) { console . log ( "applying diff" ) ; if ( ! _ . isObject ( data ) || ! _ . isObject ( diff ) ) { return diff ; } for ( const key in diff ) { const val = ( < any > diff ) [ key ] ; if ( val === "$delete" ) { delete ( < any > data ) [ key ] ; } else if ( ! data . hasOwnProperty ( key ) ) { ( < any > data ) [ key ] = val ; } else { ( < any > data ) [ key ] = applyDiff ( ( < any > data ) [ key ] , val ) ; } } return data ; } async function retrieveCourseData ( ) { let apiEndpoint = "/api/v3/courses?school=hmc" ; if ( gApiData !== null && gApiData . until !== undefined ) { apiEndpoint += `&since=${ gApiData . until }` ; } const apiResponse = await retrieveAPI ( apiEndpoint ) ; if ( apiResponse . error ) { throw Error ( `API error: ${ apiResponse . error }` ) ; } let apiData = gApiData ; let wasUpdated = false ; console . log ( `retrieved course data, full is ${ apiResponse . full }` ) ; if ( apiData === null || apiResponse . full ) { apiData = { data : apiResponse . data , until : apiResponse . until } ; wasUpdated = true ; } else { const diff = apiResponse . data ; if ( ! _ . isEmpty ( diff ) ) { wasUpdated = true ; apiData . data = applyDiff ( apiData . data , diff ) ; } } apiData . until = apiResponse . until ; for ( const selectedCourse of gSelectedCourses ) { if ( apiData . data . courses . hasOwnProperty ( selectedCourse . courseCode ) ) { Object . assign ( selectedCourse , apiData ! . data . courses [ selectedCourse . courseCode ] ) ; } } if ( wasUpdated ) { const terms = Object . values ( apiData . data . terms ) ; terms . sort ( ( t1 , t2 ) => SortKey . compare ( t1 . termSortKey , t2 . termSortKey ) ) ; apiData . data . terms = { } ; terms . forEach ( ( t ) => { apiData ! . data . terms [ t . termCode ] = t ; } ) ; const courses = Object . values ( apiData . data . courses ) ; courses . sort ( ( t1 , t2 ) => SortKey . compare ( t1 . courseSortKey , t2 . courseSortKey ) ) ; apiData . data . courses = { } ; courses . forEach ( ( c ) => { apiData ! . data . courses [ c . courseCode ] = c ; } ) ; } gApiData = apiData ; if ( wasUpdated ) { for ( let i = 0 ; i < gSelectedCourses . length ; ++ i ) { const old = gSelectedCourses [ i ] ; gSelectedCourses [ i ] = { ... ( apiData . data . courses [ old . courseCode ] || old ) , selected : old . selected , starred : old . starred , } ; } updateCourseSearchResults ( ) ; updateSelectedCoursesList ( ) ; updateSchedule ( ) ; } writeStateToLocalStorage ( ) ; } async function retrieveCourseDataUntilSuccessful ( ) { const pollInterval = 60 * 1000 ; await runWithExponentialBackoff ( async ( ) => { await retrieveCourseData ( ) ; console . log ( "Successfully fetched course data." ) ; console . log ( `Polling again in ${ pollInterval }ms.` ) ; setTimeout ( retrieveCourseDataUntilSuccessful , pollInterval ) ; } , 500 , 1.5 , "fetch course data" ) ; } function writeStateToLocalStorage ( ) { localStorage . setItem ( "apiData" , JSON . stringify ( gApiData ) ) ; localStorage . setItem ( "selectedCourses" , JSON . stringify ( gSelectedCourses ) ) ; localStorage . setItem ( "scheduleTabSelected" , JSON . stringify ( gScheduleTabSelected ) ) ; localStorage . setItem ( "showClosedCourses" , JSON . stringify ( gShowClosedCourses ) ) ; localStorage . setItem ( "hideAllConflictingCourses" , JSON . stringify ( gHideAllConflictingCourses ) ) ; localStorage . setItem ( "hideStarredConflictingCourses" , JSON . stringify ( gHideStarredConflictingCourses ) ) ; localStorage . setItem ( "greyConflictCourses" , JSON . stringify ( gGreyConflictCourses ) ) ; localStorage . setItem ( "timeZoneValues" , JSON . stringify ( gTimeZoneValues ) ) ; localStorage . setItem ( "timeZoneId" , JSON . stringify ( gTimeZoneId ) ) ; localStorage . setItem ( "timeZoneSavings" , JSON . stringify ( gTimeZoneSavings ) ) ; } function readStateFromLocalStorage ( ) { gApiData = readFromLocalStorage ( "apiData" , _ . isObject , null ) ; gSelectedCourses = readFromLocalStorage ( "selectedCourses" , _ . isArray , [ ] ) . map ( Course . upgrade ) ; gScheduleTabSelected = readFromLocalStorage ( "scheduleTabSelected" , _ . isBoolean , false ) ; gShowClosedCourses = readFromLocalStorage ( "showClosedCourses" , _ . isBoolean , true ) ; gHideAllConflictingCourses = readFromLocalStorage ( "hideAllConflictingCourses" , _ . isBoolean , false ) ; gHideStarredConflictingCourses = readFromLocalStorage ( "hideStarredConflictingCourses" , _ . isBoolean , false ) ; gGreyConflictCourses = readFromLocalStorage ( "greyConflictCourses" , validateGGreyConflictCourses , greyConflictCoursesOptions [ 0 ] ) ; gTimeZoneValues = readFromLocalStorage ( "timeZoneValues" , validateGTimeZoneValues , pacificTimeZoneValues ) ; gTimeZoneId = readFromLocalStorage ( "timeZoneId" , _ . isNumber , pacificTimeZoneId ) ; gPacificTimeSavings = checkPST ( ) ; gTimeZoneSavings = readFromLocalStorage ( "timeZoneSavings" , _ . isNumber , gPacificTimeSavings ) ; } function validateGGreyConflictCourses ( value ) { if ( ! _ . isString ( value ) ) { return false ; } return greyConflictCoursesOptions . includes ( value ) ; } function validateGTimeZoneValues ( value ) { return _ . isArray ( value ) || _ . isNumber ( value ) ; } function downloadPDF ( starredOnly ) { const pdf = new jsPDF ( { unit : "pt" , format : "letter" , orientation : "l" , } ) ; const tableWidth = ( 11 - 1 ) * 72 ; const tableHeight = ( 8.5 - 1 ) * 72 ; const bodyWidth = tableWidth - 0.75 * 72 ; const bodyHeight = tableHeight - 0.25 * 72 ; const columnWidth = bodyWidth / 7 ; const rowHeight = bodyHeight / 16 ; pdf . setFont ( "Helvetica" ) ; pdf . setFontSize ( 6 ) ; pdf . setLineWidth ( 0.5 ) ; pdf . setDrawColor ( 192 ) ; pdf . setFillColor ( "1" ) ; pdf . rect ( 0 , 0 , 11 * 72 , 8.5 * 72 , "F" ) ; for ( let i = 0 ; i < 7 ; ++ i ) { const x = i * columnWidth + 1.25 * 72 ; pdf . setFillColor ( i & 1 ? "1" : ( 230 / 255 ) . toString ( ) ) ; pdf . rect ( x , 0.5 * 72 , columnWidth , tableHeight , "F" ) ; pdf . setFont ( "Helvetica" , "bold" ) ; pdf . text ( pacificScheduleDays [ i ] , x + columnWidth / 2 , 0.5 * 72 + ( 0.25 * 72 ) / 2 + pdf . getLineHeight ( ) / 2 , { align : "center" } ) ; } pdf . setFont ( "Helvetica" , "normal" ) ; for ( let i = 0 ; i < 15 ; ++ i ) { const y = i * rowHeight + 0.75 * 72 ; pdf . line ( 0.5 * 72 , y , 0.5 * 72 + tableWidth , y ) ; pdf . text ( TimeString . tzAdjusted ( pacificScheduleTimes [ i ] , gTimeZoneValues [ gTimeZoneSavings ] - pacificTimeZoneValues [ gPacificTimeSavings ] ) , 1.25 * 72 - 6 , y + pdf . getLineHeight ( ) + 3 , { align : "right" } ) ; } pdf . line ( 1.25 * 72 , 0.5 * 72 , 1.25 * 72 , 0.5 * 72 + tableHeight ) ; let pdfCourses = [ ] ; if ( ! starredOnly ) { pdfCourses = gSelectedCourses ; } else { for ( const course of gSelectedCourses ) { if ( course . starred ) { pdfCourses . push ( course ) ; } } } for ( const course of computeSchedule ( pdfCourses ) ) { for ( const slot of course . courseSchedule ) { const [ startHours , startMinutes ] = TimeString . toHoursMinutes ( slot . scheduleStartTime ) ; const [ endHours , endMinutes ] = TimeString . toHoursMinutes ( slot . scheduleEndTime ) ; for ( const day of slot . scheduleDays ) { for ( const [ left , right ] of Util . getConsecutiveRanges ( slot . scheduleTerms ) ) { const weekdayAdjustment = weekdayCharToInteger ( day ) + ( gTimeZoneValues [ gTimeZoneSavings ] >= 9.0 ? 1 : 0 ) ; const x = weekdayAdjustment * columnWidth + 1.25 * 72 + columnWidth * ( left / slot . scheduleTermCount ) ; const width = ( columnWidth * ( right - left + 1 ) ) / slot . scheduleTermCount ; const yStart = ( startHours - 7 + startMinutes / 60 ) * rowHeight + 0.75 * 72 ; const yEnd = ( endHours - 7 + endMinutes / 60 ) * rowHeight + 0.75 * 72 ; pdf . setFillColor ( ... ( < [ number , number , number ] > ( ( < unknown > Course . getColor ( course , "rgbArray" ) ) ) ) ) ; pdf . rect ( x , yStart , width , yEnd - yStart , "F" ) ; const courseCodeLines = pdf . splitTextToSize ( course . courseCode , width - 12 ) ; const courseNameLines = pdf . splitTextToSize ( course . courseName , width - 12 ) ; const courseLocationLines = pdf . splitTextToSize ( slot . scheduleLocation , width - 12 ) ; const courseInstructorsLines = pdf . splitTextToSize ( Course . toInstructorLastNames ( course ) , width - 12 ) ; const entriesByPreference = [ "code" , "location" , "name" , "instructor" , ] ; const entriesByOrder = [ "code" , "name" , "instructor" , "location" ] ; let entryNameToText = { code : courseCodeLines , location : courseLocationLines , name : courseNameLines , instructor : courseInstructorsLines , } ; const maxTextLength = yEnd - yStart - 2 * pdf . getLineHeight ( ) ; let numEntries = 0 ; let totalLength = 0 ; while ( totalLength * pdf . getLineHeight ( ) < maxTextLength && numEntries < entriesByPreference . length ) { totalLength += entryNameToText [ entriesByPreference [ numEntries ] ] . length ; numEntries += 1 ; } const xText = x + width / 2 ; let yText = ( yStart + yEnd ) / 2 - ( totalLength * pdf . getLineHeight ( ) ) / 2 + pdf . getLineHeight ( ) ; pdf . setFont ( "Helvetica" , "bold" ) ; pdf . text ( courseCodeLines , xText , yText , { align : "center" } ) ; yText += courseCodeLines . length * pdf . getLineHeight ( ) ; pdf . setFont ( "Helvetica" , "normal" ) ; for ( let entry of entriesByOrder ) { if ( entriesByPreference . slice ( 1 , numEntries ) . includes ( entry ) ) { pdf . text ( entryNameToText [ entry ] , xText , yText , { align : "center" , } ) ; yText += entryNameToText [ entry ] . length * pdf . getLineHeight ( ) ; } } } } } } pdf . rect ( 0.5 * 72 , 0.5 * 72 , tableWidth , tableHeight , "S" ) ; pdf . save ( "hyperschedule.pdf" ) ; } function convertDayToICal ( weekday ) { switch ( weekday ) { case "U" : return "SU" ; case "M" : return "MO" ; case "T" : return "TU" ; case "W" : return "WE" ; case "R" : return "TH" ; case "F" : return "FR" ; case "S" : return "SA" ; } throw Error ( "Invalid weekday: " + weekday ) ; } function uglyHack ( input ) { return input . replace ( /\n/g , "\\n" ) . replace ( /,/g , "\\," ) ; } function downloadICalFile ( ) { if ( gSelectedCourses . length === 0 ) { alert ( "You have not added any courses to export." ) ; return ; } const cal = ics ( ) ; let anyStarred = false ; let anySelected = false ; for ( let course of gSelectedCourses ) { if ( course . selected && course . starred ) { anyStarred = true ; } if ( course . selected ) { anySelected = true ; } } for ( let course of gSelectedCourses ) { if ( ( ! anySelected || course . selected ) && ( ! anyStarred || course . starred ) ) { const subject = course . courseName ; const description = uglyHack ( course . courseCode + " " + course . courseName + "\n" + Util . formatList ( course . courseInstructors || [ ] ) ) ; for ( let slot of course . courseSchedule ) { const listedStartDay = parseDate ( slot . scheduleStartDate ) ; const listedStartWeekday = listedStartDay . getDay ( ) ; const listedEndDay = parseDate ( slot . scheduleEndDate ) ; listedEndDay . setDate ( listedEndDay . getDate ( ) + 1 ) ; const location = uglyHack ( slot . scheduleLocation ) ; let startWeekday = null ; let weekdayDifference = 7 ; for ( let weekday of slot . scheduleDays ) { const possibleStartWeekday = weekdayCharToInteger ( weekday ) ; const possibleWeekdayDifference = Util . mod ( possibleStartWeekday - listedStartWeekday , 7 ) ; if ( possibleWeekdayDifference < weekdayDifference ) { startWeekday = possibleStartWeekday ; weekdayDifference = possibleWeekdayDifference ; } } const start = new Date ( listedStartDay . valueOf ( ) ) ; start . setDate ( start . getDate ( ) + weekdayDifference ) ; const [ startHours , startMinutes ] = TimeString . toHoursMinutes ( slot . scheduleStartTime ) ; start . setHours ( startHours ) ; start . setMinutes ( startMinutes ) ; const end = new Date ( start . valueOf ( ) ) ; const [ endHours , endMinutes ] = TimeString . toHoursMinutes ( slot . scheduleEndTime ) ; end . setHours ( endHours ) ; end . setMinutes ( endMinutes ) ; const freq = "WEEKLY" ; const until = listedEndDay ; const interval = 1 ; const byday = ( < Weekday [ ] > slot . scheduleDays . split ( "" ) ) . map ( convertDayToICal ) ; const rrule = { freq , until , interval , byday , } ; cal . addEvent ( subject , description , location , start , end , rrule ) ; } } } cal . download ( "hyperschedule-export" ) ; } setMotd ( ) ; attachListeners ( ) ; readStateFromLocalStorage ( ) ; handleGlobalStateUpdate ( ) ; retrieveCourseDataUntilSuccessful ( ) ;