6 |
7 | [ScheduleStorm.com](http://schedulestorm.com)
8 |
9 | [You Can Find the Server Repo Here](https://github.com/Step7750/ScheduleStorm_Server)
10 |
11 | Schedule Storm is a schedule generator web app that lets you input your courses and preferences to generate possible schedules.
12 |
13 | Rather than just supporting one university, Schedule Storm is a platform in which you can support your university by extending the modules.
14 |
15 | ## Table of Contents
16 | * [Supported Universities](https://github.com/Step7750/ScheduleStorm#supported-universities)
17 | * [Features](https://github.com/Step7750/ScheduleStorm#features)
18 | * [How does it work?](https://github.com/Step7750/ScheduleStorm#how-does-it-work)
19 | * [Why is it better?](https://github.com/Step7750/ScheduleStorm#why-is-it-better)
20 | * [Schedule Generator Implementation](https://github.com/Step7750/ScheduleStorm#schedule-generator-implementation)
21 | * [Tech Stack](https://github.com/Step7750/ScheduleStorm#tech-stack)
22 | * [How to Transpile (using Babel)](https://github.com/Step7750/ScheduleStorm#how-to-transpile-using-babel)
23 |
24 | ## Supported Universities
25 | * University of Calgary
26 | * University of Alberta
27 | * Mount Royal University
28 | * University of Lethbridge
29 | * University of Waterloo
30 |
31 | Want us to support your university?
32 | * If you can, feel free to send pull requests with additional functionality
33 | * If not, file a new issue and we'll look into it!
34 |
35 | ## Features
36 | * Fast and responsive accordion for searching courses
37 | * Unified UI with your proposed schedules, class search, and selected courses
38 | * Add specific classes and allow the generator to fill out the rest
39 | * Fast client side schedule generation when adding courses
40 | * Inline RMP Ratings
41 | * Dynamic Scoring that takes into account your preferences
42 | * Download a photo of your schedule, share it to Facebook or Imgur, or copy it to clipboard
43 | * Create groups of classes and let the generator use "One of", "Two Of", etc.. of them
44 | * Block off timeslots by dragging on the calendar
45 | * Supports many Unis, with a framework for adding more
46 |
47 | ## How does it work?
48 |
49 | The front-end of the site is hosted on Github pages and proceeds to query a central API server that we host. The API server holds the university data and replies to the client with whatever they need.
50 |
51 | When a client chooses a specific term and uni, we send over all of the class data and ratemyprofessor ratings in a gzipped response (~350KB compressed, ~3MB uncompressed).
52 |
53 | **All requests are cached for 6 hours**
54 |
55 | Since the client has all the data they need, class searching and generation can all be done client side now. Due to this, there is no additional latency in sending requests back and forth.
56 |
57 | ## Why is it better?
58 |
59 | From the very beginning, we wanted to have a very unified experience when using Schedule Storm. When you add a new course, you can instantly see how your schedules changed. When you change your schedule scoring preferences, you can instantly see the new sorting in the background.
60 |
61 | Class searching, scoring, and generation are all client side with web workers used to minimize UI lag.
62 |
63 | When most people look for classes, they want to know how "good" the teacher is and compare that to how good the time slot is. As a result, Schedule Storm takes ratemyprofessor ratings into account when generating schedules. If you believe RMP does not give a good indication of a professor's quality, you can simply change your preferences for score weighting.
64 |
65 | Another feature that we saw lacking in other schedule generators was the ability to choose a specific class and let it figure out the proper tutorials, labs, etc... This lets you choose that specific tutorial with your friends, but let it figure out what are the best lectures and labs for it.
66 |
67 | We decided to expand upon Winston's class group features and allow you to have All of, one of, two of, etc... of a certain number of classes. This allows you to make a new group, add a bunch of options to it, and only want it to select 2 or 3 of them.
68 |
69 | For the classlist, we wanted to provide the user with a new means of browsing for possible specific classes rather than using their school's (probably) archaic class searching system. When we find a match, professors have a little number next to their name to indicate their RMP rating. We also have in-line course descriptions, requirements, and notes; you won't have to go anywhere else to check whether you have the prerequisites.
70 |
71 |
72 | ## Schedule Generator Implementation
73 |
74 | The schedule generator uses a Backtracking algorithm with Forward Checking and the Minimum Remaining Values (MRV) heuristic. The generator and sorter are both client side and in web workers to minimize UI lag during generation, but there is additional overhead with data transfer to the parent page for highly complex schedules.
75 |
76 | For the vast majority of users, schedule generation will only take a couple of milliseconds (without transport overhead). Using a deliberately intensive example, out of a search space of 4435200 schedules, the generator found the 178080 possible schedules in ~3s.
77 |
78 | Testing was done on the possiblility of using a SAT solver (such as MiniSat) and Emscripten as the LLVM to JavaScript compiler, but there was too much overhead in the creation of the object and didn't have very good solutions with the class grouping support and reusing previous results effectively.
79 |
80 | #### Generator Steps:
81 |
82 | * All duplicate classes with virtually the same attributes are removed
83 | * Class objects are modified so that time conflicts are easier to compute and some attributes are added (ex. "Manual" for manually specified classes)
84 | * A dictionary is created where the keys are the class ids and the values their objects. This minimizes the total data being thrown and copied around and when we need the attributes of a given class, we can retrieve its attributes in O(1) time complexity.
85 | * All of the relevant course data and user settings are sent to the web worker for further processing
86 | * All of the possible combinations for each course group is found and stored ("All of", "Two of", etc...)
87 | * A dictionary is created that contains, for every class id as the key, an array of class ids that conflict with this class
88 | * The recursive backtracking algo is then called which finds the domains for the current combination. The generator must satisfy at least one element in each domain for there to be a possible schedule. The domains are sorted using the MRV heuristic to reduce early on branching factor and the contents of each domain is sorted in ascending order for binary search later on.
89 | * Given the current proposed class in the current domain for a given schedule, it then removes classes from subsequent domains that conflict with this class (Forward Checking). If there is ever an empty domain, we know that a solution is impossible and backtrack.
90 | * Once the depth level is the same as the domain length, we check if we've gone through every class group for this schedule, if so, we append a copy of the current solution to a global variable. If not, we repeat the previous steps and add to the domain.
91 | * The possible schedules are returned to the parent page and sent for sorting.
92 |
93 |
94 | ## Tech Stack
95 |
96 | * MongoDB
97 | * Python Backend w/ Falcon for the API Server and threads for each University
98 | * ES6 JS OOP Frontend (transpiled to ES5 in production to support more clients)
99 | * Heavy use of JQuery to manipulate the DOM
100 | * Bootstrap, HTML2Canvas, Clipboard, Operative
101 |
102 | ### Backend
103 |
104 | Each university has an instantiated thread from it's module (ex. UCalgary.py). The backend handles API requests and necessary scraping in an all-in-one package. Each supported university must have an entry in the settings file and have an enabled flag. RMP gets a dedicated thread that looks at the currently enabled Unis and scrapes the ratings for them in the specified interval. Each university has it's settings passed into it's thread upon creation. Each university is required to handle scraping, db management, and API response handlers for itself.
105 |
106 |
107 | ## How to Transpile (using Babel)
108 |
109 | **Ensure you have Node.js and npm installed**
110 |
111 | 1. Copy the `js/core` directory to a place outside of the Github directory (unless you want to explicitly ignore the node files)
112 | 2. Change the directory to the parent of the new copied `core` directory
113 | 2. Install Babel CLI with: `npm install --save-dev babel-cli`
114 | 3. Install the ES2015 preset with: `npm install --save-dev babel-preset-es2015`
115 | 4. Transpile the core folder with: `babel core --out-file production_core.js --presets es2015`
116 | 5. Copy the resultant `production_core.js` file to the `js` folder in the Github Schedule Storm directory
117 |
118 | **Note: Ensure that any changes you make are on the original ES6 `core` file AND the transpiled `production_core.js`**
119 |
120 | ## Inspiration from:
121 |
122 | * [Hey Winston for University of Alberta](https://github.com/ahoskins/winston)
123 |
124 |
125 |
126 |
--------------------------------------------------------------------------------
/js/operative.min.js:
--------------------------------------------------------------------------------
1 | /** Operative v0.4.4 (c) 2013 James padolsey, MIT-licensed, http://github.com/padolsey/operative **/
2 | (function(){function e(t,n){var o=e.getBaseURL,i=e.getSelfURL,s=e.hasWorkerSupport?e.Operative.BrowserWorker:e.Operative.Iframe;if("function"==typeof t){var a=new s({main:t},n,o,i),u=function(){return a.api.main.apply(a,arguments)};u.transfer=function(){return a.api.main.transfer.apply(a,arguments)};for(var l in a.api)r.call(a.api,l)&&(u[l]=a.api[l]);return u}return new s(t,n,o,i).api}if("undefined"!=typeof window||!self.importScripts){var r={}.hasOwnProperty,t=document.getElementsByTagName("script"),n=t[t.length-1],o=/operative/.test(n.src)&&n.src;e.pool=function(r,t,n){r=0|Math.abs(r)||1;for(var o=[],i=0,s=0;r>s;++s)o.push(e(t,n));return{terminate:function(){for(var e=0;r>e;++e)o[e].destroy()},next:function(){return i=i+1===r?0:i+1,o[i]}}},e.hasWorkerSupport=!!window.Worker,e.hasWorkerViaBlobSupport=!1,e.hasTransferSupport=!1;var i=(location.protocol+"//"+location.hostname+(location.port?":"+location.port:"")+location.pathname).replace(/[^\/]+$/,"");e.objCreate=Object.create||function(e){function r(){}return r.prototype=e,new r},e.setSelfURL=function(e){o=e},e.getSelfURL=function(){return o},e.setBaseURL=function(e){i=e},e.getBaseURL=function(){return i},window.operative=e}})(),function(){function e(e){this.value=e}function r(e,r,n,o){var i=this;e.get=e.get||function(e){return this[e]},e.set=e.set||function(e,r){return this[e]=r},this._curToken=0,this._queue=[],this._getBaseURL=n,this._getSelfURL=o,this.isDestroyed=!1,this.isContextReady=!1,this.module=e,this.dependencies=r||[],this.dataProperties={},this.api={},this.callbacks={},this.deferreds={},this._fixDependencyURLs(),this._setup();for(var s in e)t.call(e,s)&&this._createExposedMethod(s);this.api.__operative__=this,this.api.destroy=this.api.terminate=function(){return i.destroy()}}if("undefined"!=typeof window||!self.importScripts){var t={}.hasOwnProperty,n=[].slice,o={}.toString;operative.Operative=r;var i=r.Promise=window.Promise;r.prototype={_marshal:function(e){return e},_demarshal:function(e){return e},_enqueue:function(e){this._queue.push(e)},_fixDependencyURLs:function(){for(var e=this.dependencies,r=0,t=e.length;t>r;++r){var n=e[r];/\/\//.test(n)||(e[r]=n.replace(/^\/?/,this._getBaseURL().replace(/([^\/])$/,"$1/")))}},_dequeueAll:function(){for(var e=0,r=this._queue.length;r>e;++e)this._queue[e].call(this);this._queue=[]},_buildContextScript:function(e){var r,t=[],n=this.module,o=this.dataProperties;for(var i in n)r=n[i],"function"==typeof r?t.push(' self["'+i.replace(/"/g,'\\"')+'"] = '+(""+r)+";"):o[i]=r;return t.join("\n")+(e?"\n("+(""+e)+"());":"")},_createExposedMethod:function(r){var t=this,s=this.api[r]=function(){function o(){t.isContextReady?t._runMethod(r,s,a,l):t._enqueue(o)}if(t.isDestroyed)throw Error("Operative: Cannot run method. Operative has already been destroyed");var s=++t._curToken,a=n.call(arguments),u="function"==typeof a[a.length-1]&&a.pop(),l=a[a.length-1]instanceof e&&a.pop();if(!u&&!i)throw Error("Operative: No callback has been passed. Assumed that you want a promise. But `operative.Promise` is null. Please provide Promise polyfill/lib.");if(u)t.callbacks[s]=u,setTimeout(function(){o()},1);else if(i)return new i(function(e,r){var n;e.fulfil||e.fulfill?(n=e,n.fulfil=n.fulfill=e.fulfil||e.fulfill):n={fulfil:e,fulfill:e,resolve:e,reject:r,transferResolve:e,transferReject:r},t.deferreds[s]=n,o()})};s.transfer=function(){var r=[].slice.call(arguments),t="function"==typeof r[r.length-1]?r.length-2:r.length-1,n=r[t],i=o.call(n);if("[object Array]"!==i)throw Error("Operative:transfer() must be passed an Array of transfers as its last arguments (Expected: [object Array], Received: "+i+")");return r[t]=new e(n),s.apply(null,r)}},destroy:function(){this.isDestroyed=!0}}}}(),function(){function makeBlobURI(e){var r;try{r=new Blob([e],{type:"text/javascript"})}catch(t){r=new BlobBuilder,r.append(e),r=r.getBlob()}return URL.createObjectURL(r)}function workerBoilerScript(){var postMessage=self.postMessage,structuredCloningSupport=null,toString={}.toString;self.console={},self.isWorker=!0,["log","debug","error","info","warn","time","timeEnd"].forEach(function(e){self.console[e]=function(){postMessage({cmd:"console",method:e,args:[].slice.call(arguments)})}}),self.addEventListener("message",function(e){function callback(){returnResult({args:[].slice.call(arguments)})}function returnResult(e,r){postMessage({cmd:"result",token:data.token,result:e},hasTransferSupport&&r||[])}function extractTransfers(e){var r=e[e.length-1];if("[object Array]"!==toString.call(r))throw Error("Operative: callback.transfer() must be passed an Array of transfers as its last arguments");return r}var data=e.data;if("string"==typeof data&&0===data.indexOf("EVAL|"))return eval(data.substring(5)),void 0;if(null==structuredCloningSupport)return structuredCloningSupport="PING"===e.data[0],self.postMessage(structuredCloningSupport?"pingback:structuredCloningSupport=YES":"pingback:structuredCloningSupport=NO"),structuredCloningSupport||(postMessage=function(e){return self.postMessage(JSON.stringify(e))}),void 0;structuredCloningSupport||(data=JSON.parse(data));var defs=data.definitions,isDeferred=!1,args=data.args;if(defs)for(var i in defs)self[i]=defs[i];else{callback.transfer=function(){var e=[].slice.call(arguments),r=extractTransfers(e);returnResult({args:e},r)},args.push(callback),self.deferred=function(){function e(e,r){return returnResult({isDeferred:!0,action:"resolve",args:[e]},r),t}function r(e,r){returnResult({isDeferred:!0,action:"reject",args:[e]},r)}isDeferred=!0;var t={};return t.fulfil=t.fulfill=t.resolve=function(r){return e(r)},t.reject=function(e){return r(e)},t.transferResolve=function(r){var t=extractTransfers(arguments);return e(r,t)},t.transferReject=function(e){var t=extractTransfers(arguments);return r(e,t)},t};var result=self[data.method].apply(self,args);isDeferred||void 0===result||returnResult({args:[result]}),self.deferred=function(){throw Error("Operative: deferred() called at odd time")}}})}if("undefined"==typeof window&&self.importScripts)return workerBoilerScript(),void 0;var Operative=operative.Operative,URL=window.URL||window.webkitURL,BlobBuilder=window.BlobBuilder||window.WebKitBlobBuilder||window.MozBlobBuilder,workerViaBlobSupport=function(){try{new Worker(makeBlobURI(";"))}catch(e){return!1}return!0}(),transferrableObjSupport=function(){try{var e=new ArrayBuffer(1);return new Worker(makeBlobURI(";")).postMessage(e,[e]),!e.byteLength}catch(r){return!1}}();operative.hasWorkerViaBlobSupport=workerViaBlobSupport,operative.hasTransferSupport=transferrableObjSupport,Operative.BrowserWorker=function BrowserWorker(){Operative.apply(this,arguments)};var WorkerProto=Operative.BrowserWorker.prototype=operative.objCreate(Operative.prototype);WorkerProto._onWorkerMessage=function(e){var r=e.data;if("string"==typeof r&&0===r.indexOf("pingback"))return"pingback:structuredCloningSupport=NO"===r&&(this._marshal=function(e){return JSON.stringify(e)},this._demarshal=function(e){return JSON.parse(e)}),this.isContextReady=!0,this._postMessage({definitions:this.dataProperties}),this._dequeueAll(),void 0;switch(r=this._demarshal(r),r.cmd){case"console":window.console&&window.console[r.method].apply(window.console,r.args);break;case"result":var t=this.callbacks[r.token],n=this.deferreds[r.token],o=r.result&&r.result.isDeferred&&r.result.action;n&&o?n[o](r.result.args[0]):t?t.apply(this,r.result.args):n&&n.fulfil(r.result.args[0])}},WorkerProto._isWorkerViaBlobSupported=function(){return workerViaBlobSupport},WorkerProto._setup=function(){var e,r=this,t=this._getSelfURL(),n=this._isWorkerViaBlobSupported(),o=this._buildContextScript(n?workerBoilerScript:"");if(this.dependencies.length&&(o='importScripts("'+this.dependencies.join('", "')+'");\n'+o),n)e=this.worker=new Worker(makeBlobURI(o));else{if(!t)throw Error("Operaritve: No operative.js URL available. Please set via operative.setSelfURL(...)");e=this.worker=new Worker(t),e.postMessage("EVAL|"+o)}e.postMessage("EVAL|self.hasTransferSupport="+transferrableObjSupport),e.postMessage(["PING"]),e.addEventListener("message",function(e){r._onWorkerMessage(e)})},WorkerProto._postMessage=function(e){var r=transferrableObjSupport&&e.transfers;return r?this.worker.postMessage(e,r.value):this.worker.postMessage(this._marshal(e))},WorkerProto._runMethod=function(e,r,t,n){this._postMessage({method:e,args:t,token:r,transfers:n})},WorkerProto.destroy=function(){this.worker.terminate(),Operative.prototype.destroy.call(this)}}(),function(){function e(){window.__run__=function(e,r,t,n){function o(){return t.apply(this,arguments)}var i=!1;window.deferred=function(){return i=!0,n},o.transfer=function(){return t.apply(this,[].slice.call(arguments,0,arguments.length-1))},t&&r.push(o);var s=window[e].apply(window,r);window.deferred=function(){throw Error("Operative: deferred() called at odd time")},i||void 0===s||o(s)}}if("undefined"!=typeof window||!self.importScripts){var r=operative.Operative;r.Iframe=function(){r.apply(this,arguments)};var t=r.Iframe.prototype=operative.objCreate(r.prototype),n=0;t._setup=function(){var r=this,t="__operativeIFrameLoaded"+ ++n;this.module.isWorker=!1;var o=this.iframe=document.body.appendChild(document.createElement("iframe"));o.style.display="none";var i=this.iframeWindow=o.contentWindow,s=i.document;window[t]=function(){window[t]=null;var n=s.createElement("script"),o=r._buildContextScript(e);void 0!==n.text?n.text=o:n.innerHTML=o,s.documentElement.appendChild(n);for(var a in r.dataProperties)i[a]=r.dataProperties[a];r.isContextReady=!0,r._dequeueAll()},s.open();var a="";this.dependencies.length&&(a+='\n'),s.write(a+"\n"),s.close()},t._runMethod=function(e,r,t){var n=this,o=this.callbacks[r],i=this.deferreds[r];this.iframeWindow.__run__(e,t,function(e){var r=o,t=i;r?r.apply(n,arguments):t&&t.fulfil(e)},i)},t.destroy=function(){this.iframe.parentNode.removeChild(this.iframe),r.prototype.destroy.call(this)}}}();
--------------------------------------------------------------------------------
/js/core/Tutorial.js:
--------------------------------------------------------------------------------
1 | class Tutorial {
2 | constructor() {
3 | var self = this;
4 |
5 | // check localstorage to see whether we should start the tut or not
6 | if (localStorage.getItem("tour_end") == null && window.tourInProgress != true) {
7 | // Set a global defining our progress
8 | window.tourInProgress = true;
9 |
10 | // scroll to the top of the class data wraper
11 | $("#classdatawraper").scrollTop(0);
12 |
13 | // Hide scrollbars
14 | // This is for firefox since the pointer events can still move scrollbars
15 | // (which can raise events and cause the tour element to disappear)
16 | $("#classdatawraper").css('overflow', 'hidden');
17 | $("#courseList").css('overflow', 'hidden');
18 |
19 | // Repopulate the accordion to the default view
20 | classList.repopulateAccordion();
21 |
22 | setTimeout(function () {
23 | self.openAccordion();
24 | }, 500);
25 | }
26 | }
27 |
28 | /*
29 | Open the first top level for every level
30 | */
31 | openAccordion() {
32 | this.openedAccordion = true;
33 |
34 | this.openChildRow($('#classdatawraper').children(0));
35 | }
36 |
37 | /*
38 | Opens the first row in the child of the specified element
39 | */
40 | openChildRow(element) {
41 | var self = this;
42 |
43 | // Get the row
44 | var row = element.parent().find('.has-children').eq(0);
45 |
46 | if (row.length > 0) {
47 |
48 | // Ensure the row isn't open already, if not, click it
49 | if (row.find("ul").length == 1 && row.find(".accordiontableparent").length == 0) row.find('label').click();
50 |
51 | // Call the next row
52 | setTimeout(function () {
53 | self.openChildRow(row.find('label').eq(0));
54 | }, 50);
55 | }
56 | else {
57 | // start up the tour
58 | self.createIntro();
59 | }
60 | }
61 |
62 | /*
63 | Initialize and start the tour
64 | */
65 | createIntro() {
66 | var self = this;
67 |
68 | window.tour = new Tour({
69 | steps: [
70 | {
71 | title: "What is this?",
72 | content: "Schedule Storm is a student schedule generator that lets you input your courses and preferences to generate possible schedules.
You can always restart this tour by going to preferences"
73 | },
74 | {
75 | element: document.querySelector('#classdatawraper'),
76 | title: "Course List",
77 | content: "In this accordion, you can add and look at courses. Clicking on labels opens their contents."
78 | },
79 | {
80 | element: $("#classdatawraper").find('.addCourseButton')[0],
81 | title: "Add Courses",
82 | content: "If you want to add a course, simply click on the 'plus' icon next to its name"
83 | },
84 | {
85 | element: $("#classdatawraper").find('[classid]')[0],
86 | title: "Add Specific Classes",
87 | content: "If you want to add a specific class, you can click on the 'plus' icon next to it.
All other required classes will automatically be filled by the generator"
88 | },
89 | {
90 | element: $("#classdatawraper").find("td")[1],
91 | title: "Rate My Professor Ratings",
92 | content: "If you see a number beside a teacher's name, that is their Rate My Professor rating out of 5
You can specify the weighting of the RMP rating in the generator in preferences"
93 | },
94 | {
95 | element: $("#searchcourses")[0],
96 | title: "Search Courses",
97 | content: "Here you can search for teachers, courses, classes, rooms, descriptions, faculties, subjects, prerequisites...
Almost anything!"
98 | },
99 | {
100 | element: $("#locationselect")[0],
101 | title: "Change Location",
102 | content: "You can limit the location for classes to specific campuses or areas"
103 | },
104 | {
105 | element: $("#courseSelector").find(".input-group-btn")[1],
106 | title: "Change Term",
107 | content: "You can change the term you're viewing in this university"
108 | },
109 | {
110 | element: $("#MyCourses"),
111 | title: "My Courses",
112 | content: "All of your chosen courses are displayed here",
113 | placement: "left"
114 | },
115 | {
116 | element: $("#coursegroups"),
117 | title: "Course Groups",
118 | content: "You can create groups of courses where the generator fulfills every group. You can change/remove the group type by clicking on its 'pill'"
119 | },
120 | {
121 | element: $("#addGroupbtn"),
122 | title: "Adding Course Groups",
123 | content: "Clicking this will create a new course group
This is useful for electives where you only want one or two of the courses selected in the group"
124 | },
125 | {
126 | element: $("#schedule"),
127 | title: "Calendar",
128 | content: "You can look through possible schedules on this calendar",
129 | placement: "left"
130 | },
131 | {
132 | element: $("#calendarStatus"),
133 | title: "Blocking Timeslots",
134 | content: "You can block specific timeslots for the generator by clicking and dragging on the calendar
Clicking on a banned timeslot will unban it",
135 | placement: "left"
136 | },
137 | {
138 | element: $("#prevSchedule"),
139 | title: "Browsing Schedules",
140 | content: "You can browse possible schedules by clicking the previous and next buttons here",
141 | placement: "left"
142 | },
143 | {
144 | element: $("#scheduleutilities"),
145 | title: "Schedule Utilities",
146 | content: "Useful schedule utilities can be found here, you can: * Download a picture of your schedule * Copy your schedule to clipboard * Remove all blocked timeslots * Share your schedule to Facebook"
147 | },
148 | {
149 | element: $("#preferencesbutton"),
150 | title: "Preferences",
151 | content: "You can change your schedule preferences and edit settings by clicking this button
You can change your preferences for morning/night classes, consecutive classes, and teacher quality over time slots.
You can also specify that you only want the generator to allow open classes (some universities have custom settings)",
152 | placement: "left",
153 | },
154 | {
155 | element: $("#MyUniversity"),
156 | title: "Change University",
157 | content: "You can click here to open a dropdown and change your university",
158 | placement: "left"
159 | },
160 | {
161 | element: $("#aboutbutton"),
162 | title: "About Us",
163 | content: "We are two Computer Science students that thought there was a better way to make university schedules
Please contact us using Github or Email if you'd like to say 'Hi', file a bug report, want us to add your university, or add a new feature!",
164 | placement: "left"
165 | },
166 | {
167 | title: "That ended too soon!",
168 | content: "It looks like thats the end of our tour, remember you can always look at it again by going to preferences.
159 | Schedule Storm is a multi-university schedule generator
160 | that tries to find the best schedule given your criteria
161 |
162 |
163 |
164 |
What's Better About It?
165 |
166 | We wanted to make a schedule generator that was fast, expandable, customizable,
167 | and with client side schedule generation that takes into account Rate My Professor ratings.
168 |
169 | Want a specific lecture or tutorial with other generated classes?
170 | Want to take into account RMP ratings and time slots?
171 | Hate mourning classes and just want some consecutive mid-day classes?
172 |
You can do all of that with Schedule Storm
173 |
174 | When you retrieve all the classes for a given Uni and Term, you get all the data.
175 | The total size for an average term is around 350KB (gzipped), so it's about the same size as a photo!
176 | When you're generating schedules or searching for classes, it is all done locally on your machine; so you won't have to worry about it being too slow!
177 |
')
125 |
126 | html.find("a:first").click(function(e){
127 | // If a pill is already selected, open the dropdown
128 | // If not, set the pill as active
129 |
130 | // check if this group is already active
131 | var groupid = $(this).parent().attr("groupid");
132 |
133 | // Check if we need to set this as active
134 | if (groupid != self.activeGroup) {
135 | // we don't want the dropdown to open for this item
136 | e.stopPropagation();
137 |
138 | // check if the dropdown for the old active pill is open
139 | // if so, close it
140 | var isopen = $('li[groupid="' + self.activeGroup + '"]').hasClass("open");
141 |
142 | if (isopen == true) {
143 | // close it
144 | $('li[groupid="' + self.activeGroup + '"]').find('.dropdown-menu').dropdown('toggle');
145 | }
146 |
147 | // set this group as active
148 | self.setGroupActive(groupid);
149 | }
150 | });
151 |
152 | // Populate the dropdown
153 | html.find('.dropdown-menu').append(this.generatePillDropdown(noremove));
154 |
155 | // Bind the dropdown click handler
156 | html.find('li').click(function (event) {
157 | // find the group type
158 | var grouptype = $(this).attr("grouptype");
159 | // find the group id
160 | var groupid = $(this).parent().parent().attr("groupid");
161 |
162 | if (grouptype == "remove") {
163 | // wants to remove this group
164 | self.removeGroup(groupid);
165 | }
166 | else {
167 | // Change the group type
168 | self.changeGroupType(groupid, grouptype);
169 | }
170 | });
171 |
172 | $("#addGroupbtn").before(html);
173 | }
174 |
175 | /*
176 | Removes the specified group and removes the appropriate HTML elements
177 | */
178 | removeGroup(groupid) {
179 | groupid = parseInt(groupid);
180 |
181 | // we need to remove this pill
182 | $('li[groupid="' + groupid + '"]').remove();
183 |
184 | // set the previous group to active
185 | this.setGroupActive(groupid-1);
186 |
187 | // we need to change the HTML groupid tags of the groups after this one
188 | if ((groupid+1) < this.courses.length) {
189 | // this is not the last group
190 |
191 | // decrement the groupid of every subsequent group
192 | for (var x = (groupid+1); x < this.courses.length; x++) {
193 | $('li[groupid="' + x + '"]').attr("groupid", x-1);
194 | }
195 | }
196 |
197 | // now we need to splice the array
198 | this.courses.splice(groupid, 1);
199 |
200 | // Check if we can display the add button again
201 | if (this.courses.length < 4) $("#addGroupbtn").show();
202 |
203 | // regenerate the schedules
204 | this.startGeneration();
205 | }
206 |
207 | /*
208 | Changes the type of a group type and updates the element
209 | */
210 | changeGroupType(id, type) {
211 | this.courses[id]["type"] = type;
212 |
213 | // Change the HTML
214 | $('li[groupid="' + id + '"]').find("a:first").html(this.numConvert[type] + ' of');
215 |
216 | this.startGeneration();
217 | }
218 |
219 | /*
220 | Sets the specified group to active
221 | */
222 | setGroupActive(id) {
223 | // remove old active class
224 | if (this.activeGroup != undefined) {
225 | $('li[groupid="' + this.activeGroup + '"]').removeClass("active");
226 | }
227 |
228 | this.activeGroup = id;
229 | $('li[groupid="' + id + '"]').addClass("active");
230 |
231 | // now display all the courses in the group
232 | this.displayGroup(this.activeGroup);
233 | }
234 |
235 | /*
236 | Populates the courses in the specified group
237 | */
238 | displayGroup(group) {
239 | var self = this;
240 |
241 | // empty out any current courses
242 | $("#courseList").empty();
243 |
244 | for (var course in self.courses[self.activeGroup]["courses"]) {
245 | var course = self.courses[self.activeGroup]["courses"][course];
246 |
247 | self.displayCourse(course["obj"], course["obj"]["path"]);
248 | }
249 | }
250 |
251 | /*
252 | Generates the dropdown HTML for a group pill
253 | */
254 | generatePillDropdown(noremove) {
255 | var html = '';
256 |
257 | // Add 'all of' to the top
258 | html += '
';
268 | }
269 |
270 | return html;
271 | }
272 |
273 | /*
274 | Expands the type name (LEC = Lecture, TUT = Tutorial)
275 | */
276 | typeExpand(type) {
277 | var map = {
278 | "LEC": "Lecture",
279 | "TUT": "Tutorial",
280 | "LAB": "Lab",
281 | "SEM": "Seminar",
282 | "LCL": "Lecture/Lab",
283 | "LBL": "Lab/Lecture",
284 | "CLN": "Clinic",
285 | "DD": "Distance Delivery",
286 | "BL": "Blended Delivery",
287 | "WKT": "Work Term",
288 | "FLD": "Field Work",
289 | "PRC": "Practicum",
290 | "CLI": "Clinical",
291 | "IDS": "Internship"
292 | }
293 |
294 | if (map[type] != undefined) {
295 | return map[type];
296 | }
297 | else {
298 | return type;
299 | }
300 | }
301 |
302 | /*
303 | Deletes the given course in any group except the passed in one
304 | */
305 | deleteCourseFromNonSafe(delcourse, safegroup) {
306 | // iterate the groups
307 | for (var group in this.courses) {
308 | if (group != safegroup) {
309 | // we can delete in this group
310 | for (var course in this.courses[group]["courses"]) {
311 | if (course == delcourse) {
312 | delete this.courses[group]["courses"][course];
313 | }
314 | }
315 | }
316 | }
317 | }
318 |
319 | /*
320 | Adds the specified course to the current active group and populates the HTML
321 | */
322 | addCourse(course, path, classid) {
323 | var self = this;
324 |
325 | // We want a separate copy of the obj to work on
326 | course = jQuery.extend({}, course);
327 |
328 | // add the path to the obj
329 | course["path"] = path;
330 |
331 | var subject = path.split("\\");
332 |
333 | var coursenum = subject[subject.length-1] // 203
334 | var subject = subject[subject.length-2]; // CPSC
335 |
336 | var coursecode = subject + " " + coursenum; // CPSC 203
337 |
338 |
339 | // Add the key if it isn't there
340 | if (self.courses[self.activeGroup]["courses"][coursecode] == undefined) {
341 | self.courses[self.activeGroup]["courses"][coursecode] = {};
342 | self.courses[self.activeGroup]["courses"][coursecode]["types"] = {};
343 |
344 | // add the possible types
345 | for (var classv in course["classes"]) {
346 | if (course["classes"][classv]["type"] != undefined) {
347 | var thistype = course["classes"][classv]["type"];
348 | self.courses[self.activeGroup]["courses"][coursecode]["types"][thistype] = true;
349 | }
350 | }
351 |
352 | // check to see if any other groups have this course, is so, delete the course from them
353 | self.deleteCourseFromNonSafe(coursecode, self.activeGroup);
354 |
355 | self.displayCourse(course, path, undefined, true);
356 | }
357 |
358 | var thiscourse = self.courses[self.activeGroup]["courses"][coursecode];
359 |
360 | // set the course obj
361 | thiscourse["obj"] = course;
362 |
363 | if (classid != undefined) {
364 | var classtype = true;
365 |
366 | // figure out the class type
367 | for (var classv in course["classes"]) {
368 | if (course["classes"][classv]["id"] == classid) {
369 | classtype = course["classes"][classv]["type"];
370 | break;
371 | }
372 | }
373 |
374 | if (thiscourse["types"][classtype] != true) {
375 | // update the class list button (remove the old class button)
376 | window.classList.updateRemovedClass(thiscourse["types"][classtype]);
377 | }
378 |
379 | thiscourse["types"][classtype] = classid;
380 |
381 | // Update the accordion if its open
382 | self.updateAccordion(coursecode);
383 |
384 | // update the classlist buttons
385 | window.classList.updateAddedCourse(coursecode);
386 | }
387 |
388 | this.startGeneration();
389 | }
390 |
391 | /*
392 | Updates the data in the given open accordion
393 | */
394 | updateAccordion(course) {
395 | var self = this;
396 |
397 | // get the label
398 | var label = $('label[path="' + course + '"]');
399 |
400 | // Check if its open
401 | if (label.attr("accordopen") == "true") {
402 | // update it
403 | label.attr("accordopen", "false");
404 | label.parent().find("ul:first").slideUp(function () {
405 | $(this).empty();
406 | self.bindButton(label, "course");
407 | });
408 | }
409 | }
410 |
411 | /*
412 | Removes a course from the UI and courses obj
413 | */
414 | removeCourse(course) {
415 | for (var group in this.courses) {
416 | var thisgroup = this.courses[group];
417 |
418 | if (thisgroup["courses"][course] != undefined) {
419 |
420 | // Remove any remove class buttons since those classes are no longer added
421 | for (var classval in thisgroup["courses"][course]["types"]) {
422 | var thisclassval = thisgroup["courses"][course]["types"][classval];
423 |
424 | if (thisclassval != true) {
425 | window.classList.updateRemovedClass(thisclassval);
426 | }
427 | }
428 |
429 | // Delete this course
430 | delete thisgroup["courses"][course];
431 |
432 | // check if its the active group
433 | // if so, remove the UI element
434 | if (group == this.activeGroup) {
435 | var label = $('label[path="' + course + '"]');
436 | label.parent().slideUp(function () {
437 | $(this).empty();
438 | });
439 | }
440 | }
441 | }
442 |
443 | // Restart generation
444 | this.startGeneration();
445 | }
446 |
447 | /*
448 | Returns a boolean as to whether a specified course is in any selected group
449 | */
450 | hasCourse(course) {
451 | for (var group in this.courses) {
452 | var thisgroup = this.courses[group];
453 |
454 | if (thisgroup["courses"][course] != undefined) {
455 | return true;
456 | }
457 | }
458 |
459 | // We didn't find a result
460 | return false;
461 | }
462 |
463 | /*
464 | Returns a boolean as to whether the specified class id has been selected in any group
465 | */
466 | hasClass(classid) {
467 | for (var group in this.courses) {
468 | var thisgroup = this.courses[group];
469 |
470 | for (var course in thisgroup["courses"]) {
471 | for (var classv in thisgroup["courses"][course]["types"]) {
472 | if (thisgroup["courses"][course]["types"][classv] == classid) {
473 | return true;
474 | }
475 | }
476 | }
477 | }
478 | return false;
479 | }
480 |
481 | /*
482 | Removes the specified class from the UI and generation
483 | */
484 | removeClass(classid) {
485 | for (var group in this.courses) {
486 | var thisgroup = this.courses[group];
487 |
488 | for (var course in thisgroup["courses"]) {
489 | for (var classv in thisgroup["courses"][course]["types"]) {
490 | if (thisgroup["courses"][course]["types"][classv] == classid) {
491 | thisgroup["courses"][course]["types"][classv] = true;
492 |
493 | // update UI
494 | this.updateAccordion(course);
495 |
496 | // update the generation
497 | this.startGeneration();
498 |
499 | return true;
500 | }
501 | }
502 | }
503 | }
504 |
505 | return false;
506 | }
507 |
508 | /*
509 | Appends the given course to the current courselist HTML
510 | */
511 | displayCourse(course, path, classid, animated) {
512 | var self = this;
513 |
514 | var html = "";
515 | if (classid == undefined) {
516 | html = $(this.generateCourseHTML(course, path));
517 |
518 | html.find("label").click(function (event) {
519 | event.stopPropagation();
520 | self.bindButton(this, "course");
521 | });
522 |
523 | // bind remove button
524 | html.find(".removeBtn").click(function (event) {
525 | event.stopPropagation();
526 |
527 | var coursecode = $(this).parent().attr("path");
528 |
529 | // remove the course in My Courses
530 | self.removeCourse(coursecode);
531 |
532 | // we want to update the general course remove button
533 | window.classList.updateRemovedCourse($(this).parent().attr("path"));
534 | })
535 | }
536 |
537 | if (animated) {
538 | html.hide().prependTo("#courseList").slideDown();
539 | }
540 | else {
541 | $("#courseList").prepend(html);
542 | }
543 | }
544 |
545 | /*
546 | Binds an accordion click
547 | */
548 | bindButton(button, type) {
549 | var self = this;
550 |
551 | // Onclick handler
552 |
553 | // do we need to close the element?
554 | if ($(button).attr("accordopen") == "true") {
555 | // Close the element
556 | $(button).attr("accordopen", "false");
557 |
558 | $(button).parent().find("ul").slideUp(function () {
559 | $(this).empty();
560 | });
561 |
562 | }
563 | else {
564 |
565 | // Open accordion
566 | var thispath = $(button).attr("path");
567 | $(button).attr("accordopen", "true");
568 |
569 | var element = $(button).parent().find("ul");
570 |
571 | // Populate depending on type
572 | if (type == "course") {
573 | // Element to populate
574 | self.displayCourseDropDown(element, thispath);
575 | }
576 | }
577 | }
578 |
579 | /*
580 | Generates the dropdown when clicking on a course in MyCourses
581 | */
582 | displayCourseDropDown(element, coursecode) {
583 | var self = this;
584 |
585 | element.slideUp(function () {
586 |
587 | var thiscourse = self.courses[self.activeGroup]["courses"][coursecode];
588 |
589 | // iterate through each class type
590 | for (var type in thiscourse["types"]) {
591 | var thistype = thiscourse["types"][type];
592 | if (thistype == true) {
593 | // They don't have a specific selection, we'll have to generate it
594 | var html = '
' + self.typeExpand(type) + '
';
595 | element.append(html);
596 | }
597 | else if (thistype != false) {
598 | // this is a specific class
599 |
600 | // find the obj of the class
601 | var data = {"classes": []};
602 |
603 | for (var classv in thiscourse["obj"]["classes"]) {
604 | var thisclass = thiscourse["obj"]["classes"][classv];
605 | if (thisclass["id"] == thistype) {
606 | // we found the obj for this class
607 | data["classes"].push(thisclass);
608 | break;
609 | }
610 | }
611 |
612 | if (data["classes"].length > 0) {
613 | // generate the table
614 | var html = window.classList.generateClasses(data, element, false, false);
615 |
616 | // add the remove button
617 | var removebtn = $('
');
618 |
619 | // bind class removing button
620 | removebtn.find("button").click(function (event) {
621 | event.stopPropagation();
622 | var type = $(this).attr("type");
623 | var coursecode = $(this).attr("code");
624 |
625 | // set to generic class
626 | self.courses[self.activeGroup]["courses"][coursecode]["types"][type] = true;
627 |
628 | // update the class list
629 | window.classList.updateRemovedClass($(this).attr("myclassid"));
630 |
631 | // update UI
632 | self.updateAccordion(coursecode);
633 |
634 | // update the generation
635 | self.startGeneration();
636 | })
637 |
638 | html.find("tr:first").append(removebtn);
639 | //
×
640 |
641 | // edit the css
642 | html.css("padding-left", "50px");
643 | html.css("padding-right", "15px");
644 | }
645 | }
646 | }
647 |
648 | element.slideDown();
649 | });
650 | }
651 |
652 | /*
653 | Initiates schedule generation given the current chosen classes
654 | */
655 | startGeneration() {
656 | // we want to terminate the previous generator if its still running
657 | if (this.generator != false) this.generator.stop();
658 |
659 | // generate the schedules
660 | this.generator = new Generator(this.courses);
661 |
662 | // save the current state to localStorage
663 | this.saveState();
664 | }
665 |
666 | /*
667 | Generates the course HTML
668 | */
669 | generateCourseHTML(course, path) {
670 | var subject = path.split("\\");
671 | var coursenum = subject[subject.length-1]
672 | var subject = subject[subject.length-2];
673 |
674 | var title = subject + " " + coursenum;
675 |
676 | if (course["description"] != undefined && course["description"]["name"] != undefined) {
677 | title += " - " + course["description"]["name"];
678 | }
679 |
680 | return this.generateAccordionHTML(title, subject + " " + coursenum);
681 | }
682 |
683 | /*
684 | Generates the course remove button HTML
685 | */
686 | generateRemoveButton() {
687 | return '';
688 | }
689 |
690 | /*
691 | Generates the general accordian structure HTML given a value
692 | */
693 | generateAccordionHTML(value, path) {
694 | return '
';
695 | }
696 | }
--------------------------------------------------------------------------------
/js/core/Calendar.js:
--------------------------------------------------------------------------------
1 | class Calendar {
2 | // Handles the UI construction of the calendar
3 | constructor() {
4 | var self = this;
5 |
6 | this.weekdays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
7 |
8 | this.resetCalendar();
9 |
10 | this.removeTimes = false;
11 |
12 | this.isLoading = false;
13 |
14 | this.currentSchedule = [];
15 |
16 | this.bindNextPrev();
17 | this.initializeTooltips();
18 |
19 | // Bind left side buttons
20 | this.bindSchedulePhotoDL();
21 | this.bindRemoveBlockedTimes();
22 | this.bindCopyScheduleToClipboard();
23 | this.bindFacebookSharing();
24 | this.bindImgurUpload();
25 |
26 | // Key binds
27 | this.keyBinds();
28 |
29 | // Bind resize event
30 | this.bindResize();
31 |
32 | this.eventcolours = {
33 | "#FF5E3A": false,
34 | "#099e12": false,
35 | "#1D62F0": false,
36 | "#FF2D55": false,
37 | "#8E8E93": false,
38 | "#0b498c": false,
39 | "#34AADC": false,
40 | "#5AD427": false
41 | }
42 |
43 | // We want to bind the mouse up handler for blocking times
44 | $(document).mouseup(function () {
45 | self.mouseDown = false;
46 |
47 | // Change each deep array to strings for comparison
48 | var blockedTimesString = JSON.stringify(self.blockedTimes);
49 | var prevBlockedTimesString = JSON.stringify(self.prevBlockedTimes);
50 |
51 | // Check if the blocked times changed, if so, restart generation
52 | if (blockedTimesString != prevBlockedTimesString) {
53 | window.mycourses.startGeneration();
54 | }
55 |
56 | // Reset prev
57 | self.prevBlockedTimes = self.blockedTimes;
58 | });
59 | }
60 |
61 | /*
62 | Initializes the tooltips associated with buttons on the calendar
63 | */
64 | initializeTooltips() {
65 | // Initialize prev/next sched tooltips
66 | $("#prevSchedule").tooltip();
67 | $("#nextSchedule").tooltip();
68 |
69 | // Initialize left side button tooltips
70 | $("#dlSchedulePhoto").tooltip();
71 | $("#removeBlockedTimes").tooltip();
72 | $("#copySchedToClipboard").tooltip();
73 | $("#shareToFacebook").tooltip();
74 | $("#uploadToImgur").tooltip();
75 | }
76 |
77 | /*
78 | Binds an event handler to redraw the current schedule when the window is resized (since the event sizes will change)
79 |
80 | Waits for 500ms since the latest resize event
81 | */
82 | bindResize() {
83 | var self = this;
84 | var resizeTimer;
85 |
86 | $(window).resize(function () {
87 | clearTimeout(resizeTimer);
88 | resizeTimer = setTimeout(function () {self.redrawSchedule()}, 500);
89 | });
90 | }
91 |
92 | /*
93 | Binds the Schedule Photo Download button and implements the DL functionality
94 | */
95 | bindSchedulePhotoDL() {
96 | var self = this;
97 |
98 | // on click
99 | $("#dlSchedulePhoto").click(function () {
100 | // Take the screenshot
101 | self.takeCalendarHighResScreenshot(1.6, 2, function (canvas) {
102 | // Download the picture
103 | var a = document.createElement('a');
104 | a.href = canvas.replace("image/png", "image/octet-stream");
105 |
106 | // Set the name of the file
107 | if (window.uni != null && window.term != null) a.download = window.uni + '_' + window.term + '_ScheduleStorm.png';
108 | else a.download = 'ScheduleStorm_Schedule.png';
109 |
110 | // Append it to the body
111 | document.body.appendChild(a);
112 |
113 | a.click();
114 |
115 | // Remove it from the body
116 | document.body.removeChild(a);
117 | });
118 | });
119 | }
120 |
121 | /*
122 | Binds the imgur button to upload a photo of the schedule to imgur and open it
123 | */
124 | bindImgurUpload() {
125 | var self = this;
126 |
127 | $("#uploadToImgur").click(function () {
128 | /*
129 | Why do we make a separate window/tab now?
130 |
131 | If we simply open up a new window/tab after we already have the photo uploaded
132 | and the imgur link, we lose the "trusted" event that came from a user click.
133 | As a result, the window/tab would be blocked as a popup. If we create the window
134 | now while we have a trusted event and then change its location when we're ready,
135 | we can bypass this.
136 | */
137 | var imgurwindow = window.open("http://schedulestorm.com/assets/imgurloading.png",'Uploading to Imgur...', "width=900,height=500");
138 |
139 | // Upload the image to imgur and get the link
140 | self.uploadToImgur(1.6, function (link) {
141 | if (link != false) {
142 | imgurwindow.location.href = link + ".png";
143 | }
144 | else {
145 | // There was an error, show the error screen
146 | imgurwindow.location.href = "http://schedulestorm.com/assets/imgurerror.png"
147 | }
148 | });
149 | })
150 | }
151 |
152 | /*
153 | Uploads the current schedule to imgur and returns the URL if successful
154 | If not, returns false
155 | */
156 | uploadToImgur(ratio, cb) {
157 | var self = this;
158 |
159 | // Takes a screenshot of the calendar
160 | self.takeCalendarHighResScreenshot(ratio, 2, function (canvas) {
161 | // Send AJAX request to imgur with the photo to upload
162 | $.ajax({
163 | url: 'https://api.imgur.com/3/image',
164 | type: 'POST',
165 | headers: {
166 | Authorization: 'Client-ID 9bdb3669a12eeb2'
167 | },
168 | data: {
169 | type: 'base64',
170 | name: 'schedulestorm.png',
171 | title: 'Schedule Storm',
172 | description: "Made using ScheduleStorm.com for " +
173 | window.unis[window.uni]["name"] + " - " +
174 | window.unis[window.uni]["terms"][window.term],
175 | image: canvas.split(',')[1]
176 | },
177 | dataType: 'json'
178 | }).success(function(data) {
179 | cb(data.data.link);
180 | }).error(function() {
181 | cb(false);
182 | });
183 |
184 | });
185 | }
186 |
187 | /*
188 | Binds the Facebook share button to actually share on click
189 | */
190 | bindFacebookSharing() {
191 | var self = this;
192 |
193 | $("#shareToFacebook").click(function () {
194 | // We have to preserve this "trusted" event and thus have to make the window now
195 | var facebookwindow = window.open("http://schedulestorm.com/assets/facebookshare.png",'Sharing to Facebook...', "width=575,height=592");
196 |
197 | self.uploadToImgur(1.91, function (link) {
198 | // Set the default image if no image
199 | if (link == false) {
200 | link = "https://camo.githubusercontent.com/ac09e7e7a60799733396a0f4d496d7be8116c542/687474703a2f2f692e696d6775722e636f6d2f5a425258656d342e706e67";
201 | }
202 |
203 | var url = self.generateFacebookFeedURL(link);
204 | facebookwindow.location.href = url;
205 | });
206 | });
207 | }
208 |
209 | /*
210 | Generates the URL to use to share this schedule to Facebook
211 | */
212 | generateFacebookFeedURL(picture) {
213 |
214 | var url = "https://www.facebook.com/v2.8/dialog/feed";
215 | var parameters = {
216 | "app_id": "138997789901870",
217 | "caption": "University Student Schedule Generator",
218 | "display": "popup",
219 | "e2e": "{}",
220 | "link": "http://schedulestorm.com",
221 | "locale": "en_US",
222 | "name": "Schedule Storm",
223 | "domain": "schedulestorm.com",
224 | "relation": "opener",
225 | "result": '"xxRESULTTOKENxx"',
226 | "sdk": "joey",
227 | "version": "v2.8"
228 | }
229 | var index = 0;
230 |
231 | for (var parameter in parameters) {
232 | if (index > 0) url += "&";
233 | else url += "?";
234 |
235 | url += parameter + "=" + encodeURIComponent(parameters[parameter]);
236 | index += 1;
237 | }
238 |
239 | url += "&description=" + encodeURIComponent(this.generateFacebookDescription(this.currentSchedule));
240 | url += "&picture=" + encodeURIComponent(picture);
241 |
242 | return url;
243 | }
244 |
245 | /*
246 | Generates the Facebook description text given a schedule
247 | */
248 | generateFacebookDescription(schedule) {
249 | var returnText = window.unis[window.uni]["name"] + " - " +
250 | window.unis[window.uni]["terms"][window.term];
251 |
252 | // Make sure we actully have a possible schedule
253 | if (schedule.length > 0) {
254 | returnText += " --- Classes: ";
255 |
256 | var coursesdict = {};
257 |
258 | // Iterate through each class and populate the return Text
259 | for (var classv in schedule) {
260 | var thisclass = schedule[classv];
261 | if (typeof thisclass == "object") {
262 |
263 | if (coursesdict[thisclass["name"]] == undefined) {
264 | coursesdict[thisclass["name"]] = [];
265 | }
266 |
267 | coursesdict[thisclass["name"]].push(thisclass["id"]);
268 | }
269 | }
270 |
271 | // Iterate through the dict keys and add the values to the returnText
272 | var keylength = Object.keys(coursesdict).length;
273 | var index = 0;
274 | for (var key in coursesdict) {
275 | index += 1;
276 | returnText += key + " (" + coursesdict[key] + ")";
277 |
278 | if (index < keylength) {
279 | returnText += ", ";
280 | }
281 | }
282 | }
283 |
284 | return returnText;
285 | }
286 |
287 | /*
288 | Takes a high-res screenshot of the calendar with the specified aspect ratio and downloads it as a png to the system
289 |
290 | Thanks to: https://github.com/niklasvh/html2canvas/issues/241#issuecomment-247705673
291 | */
292 | takeCalendarHighResScreenshot(aspectratio, scaleFactor, cb) {
293 | var self = this;
294 |
295 | var srcEl = document.getElementById("maincalendar");
296 |
297 | var wrapdiv = $(srcEl).find('.wrap');
298 |
299 | var beforeHeight = wrapdiv.height();
300 |
301 | // Want to remove any scrollbars
302 | wrapdiv.removeClass('wrap');
303 |
304 | // If removing the size caused the rows to be smaller, add the class again
305 | if (beforeHeight > wrapdiv.height()) {
306 | wrapdiv.addClass('wrap');
307 | }
308 |
309 | // Save original size of element
310 | var originalWidth = srcEl.offsetWidth;
311 | var originalHeight = wrapdiv.height() + $(srcEl).find("table").eq(0).height();
312 |
313 | // see if we can scale the width for it to look right for the aspect ratio
314 | if ((originalHeight * aspectratio) <= $(window).width()) {
315 | originalWidth = originalHeight * aspectratio;
316 | }
317 |
318 | // Force px size (no %, EMs, etc)
319 | srcEl.style.width = originalWidth + "px";
320 | srcEl.style.height = originalHeight + "px";
321 |
322 | // Position the element at the top left of the document because of bugs in html2canvas.
323 | // See html2canvas issues #790, #820, #893, #922
324 | srcEl.style.position = "fixed";
325 | srcEl.style.top = "0";
326 | srcEl.style.left = "0";
327 |
328 | // Create scaled canvas
329 | var scaledCanvas = document.createElement("canvas");
330 | scaledCanvas.width = originalWidth * scaleFactor;
331 | scaledCanvas.height = originalHeight * scaleFactor;
332 | scaledCanvas.style.width = originalWidth + "px";
333 | scaledCanvas.style.height = originalHeight + "px";
334 | var scaledContext = scaledCanvas.getContext("2d");
335 | scaledContext.scale(scaleFactor, scaleFactor);
336 |
337 | // Force the schedule to be redrawn
338 | this.redrawSchedule();
339 |
340 | html2canvas(srcEl, { canvas: scaledCanvas })
341 | .then(function(canvas) {
342 |
343 | // Reset the styling of the source element
344 | srcEl.style.position = "";
345 | srcEl.style.top = "";
346 | srcEl.style.left = "";
347 | srcEl.style.width = "";
348 | srcEl.style.height = "";
349 |
350 | wrapdiv.addClass('wrap');
351 |
352 | self.redrawSchedule();
353 |
354 | // return the data
355 | cb(canvas.toDataURL("image/png"));
356 | });
357 | };
358 |
359 | /*
360 | Binds button that allows you to remove all blocked times
361 | */
362 | bindRemoveBlockedTimes() {
363 | var self = this;
364 |
365 | $("#removeBlockedTimes").click(function () {
366 | // Make sure there are actually blocked times before regenning
367 | if (JSON.stringify(self.blockedTimes) != "[]") {
368 | self.blockedTimes = [];
369 | self.prevBlockedTimes = [];
370 |
371 | // Visually remove all of the blocked times
372 | self.removeAllBlockedTimeUI();
373 |
374 | window.mycourses.startGeneration();
375 | }
376 | })
377 | }
378 |
379 | /*
380 | Binds the copy schedule to clipboard button
381 | */
382 | bindCopyScheduleToClipboard() {
383 | var self = this;
384 |
385 | self.copyschedclipboard = new Clipboard('#copySchedToClipboard', {
386 | text: function(trigger) {
387 | return self.generateScheduleText(self.currentSchedule);
388 | }
389 | });
390 | }
391 |
392 | /*
393 | Visually removes all blocked times from the Schedule UI
394 | */
395 | removeAllBlockedTimeUI() {
396 | $(".calendar").find(".blockedTime").toggleClass("blockedTime");
397 | }
398 |
399 | /*
400 | Starts loading animation
401 | */
402 | startLoading(message) {
403 | this.clearEvents();
404 |
405 | // If it is already loading, don't add another loading sign
406 | if (this.isLoading == false) {
407 | this.loading = new Loading($("#schedule").find(".wrap:first"), message, "position: absolute; top: 20%; left: 40%;");
408 | this.isLoading = true;
409 | }
410 | }
411 |
412 | /*
413 | If there is a loading animation, stops it
414 | */
415 | doneLoading(cb) {
416 | var self = this;
417 | self.loadingcb = cb;
418 |
419 | if (self.isLoading) {
420 | self.loading.remove(function () {
421 | self.isLoading = false;
422 | self.loadingcb();
423 | });
424 | }
425 | else {
426 | self.isLoading = false;
427 | cb();
428 | }
429 | }
430 |
431 | /*
432 | Sets loading status of the animation
433 | */
434 | setLoadingStatus(message) {
435 | this.loading.setStatus(message);
436 | }
437 |
438 | /*
439 | Empties out the calendar
440 | */
441 | emptyCalendar() {
442 | $("#schedule").find(".outer:first").empty();
443 | }
444 |
445 | /*
446 | Sets the calendar status to the defined text
447 | */
448 | setCalendarStatus(text) {
449 | $("#schedule").find("#calendarStatus").text(text);
450 | }
451 |
452 | /*
453 | Resets the calendar status to an empty string
454 | */
455 | resetCalendarStatus() {
456 | this.setCalendarStatus("");
457 | }
458 |
459 | /*
460 | Displays the given schedule
461 | */
462 | displaySchedule(schedule) {
463 | var self = this;
464 |
465 | // set the score
466 | // make sure its a number
467 | if (typeof schedule[0] == "number") $("#scheduleScore").text(schedule[0].toFixed(2));
468 |
469 | // Destroy all the tooltips from previous events
470 | self.destroyEventTooltips();
471 |
472 | // Clear all the current events on the calendar
473 | self.clearEvents();
474 |
475 | console.log("This schedule");
476 | console.log(schedule);
477 |
478 | self.currentSchedule = schedule;
479 |
480 | self.setScheduleConstraints(schedule);
481 |
482 |
483 | for (var classv in schedule) {
484 | var thisclass = schedule[classv];
485 |
486 | var text = thisclass["name"] + " - " + thisclass["type"] + " - " + thisclass["id"];
487 |
488 | // for every time
489 | for (var time in thisclass["times"]) {
490 | var thistime = thisclass["times"][time];
491 |
492 | // make sure there isn't a -1 in the days
493 | if (thistime[0].indexOf(-1) == -1) {
494 | this.addEvent(Generator.totalMinutesToTime(thistime[1][0]), Generator.totalMinutesToTime(thistime[1][1]), thistime[0], text, thisclass);
495 | }
496 | }
497 | }
498 |
499 | // reset the colour ids
500 | self.resetColours();
501 | }
502 |
503 | /*
504 | Redraws the current schedule
505 | */
506 | redrawSchedule() {
507 | if (this.currentSchedule.length > 0) {
508 | this.displaySchedule(this.currentSchedule);
509 | }
510 | }
511 |
512 | /*
513 | Destroys every currently displayed event tooltip
514 | */
515 | destroyEventTooltips() {
516 | // Destroy the tooltips
517 | $("#schedule").find('.event').each(function (index) {
518 | $(this).tooltip('destroy');
519 | });
520 |
521 | // Remove any open tooltip div
522 | $('[role=tooltip]').each(function (index) {
523 | $(this).remove();
524 | })
525 | }
526 |
527 | /*
528 | Returns copy-paste schedule text
529 | */
530 | generateScheduleText(schedule) {
531 | var returnText = "Generated by ScheduleStorm.com for " +
532 | window.unis[window.uni]["name"] + " " +
533 | window.unis[window.uni]["terms"][window.term] + "\n\n";
534 |
535 | var allowedAttributes = ["id", "name", "type", "rooms", "teachers", "times", "section"];
536 |
537 | if (schedule.length > 0) {
538 | // Iterate through each class and populate the return Text
539 | for (var classv in schedule) {
540 | var thisclass = schedule[classv];
541 |
542 | var thisrow = "";
543 |
544 | // Make sure this is a class object
545 | if (typeof thisclass != "number") {
546 |
547 | // Fill up the row with the correct formatting and order of attributes
548 | if (thisclass["id"] != undefined) thisrow += thisclass["id"] + " | ";
549 |
550 | if (thisclass["name"] != undefined) thisrow += thisclass["name"] + " | ";
551 |
552 | if (thisclass["section"] != undefined) {
553 | thisrow += thisclass["type"] + "-" + thisclass["section"] + " (" + thisclass["id"] + ")" + " | ";
554 | }
555 | else if (thisclass["group"] != undefined) {
556 | thisrow += thisclass["type"] + "-" + thisclass["group"] + " (" + thisclass["id"] + ")" + " | ";
557 | }
558 |
559 | thisrow += thisclass["teachers"] + " | ";
560 | thisrow += thisclass["rooms"] + " | ";
561 | thisrow += thisclass["oldtimes"] + " | "
562 | thisrow += thisclass["status"];
563 | }
564 |
565 | // Add the row if it was actually populated
566 | if (thisrow != "") returnText += thisrow + "\n";
567 | }
568 | }
569 | else {
570 | returnText += "There were no possible schedules generated :(";
571 | }
572 |
573 | return returnText;
574 | }
575 |
576 | /*
577 | Resets the allocation of colours to each class
578 | */
579 | resetColours() {
580 | for (var colour in this.eventcolours) {
581 | this.eventcolours[colour] = false;
582 | }
583 | }
584 |
585 | /*
586 | Given a classname, returns the div bg colour
587 | */
588 | getEventColour(classname) {
589 | // check if we already have a colour for this class
590 | for (var colour in this.eventcolours) {
591 | if (this.eventcolours[colour] == classname) {
592 | return colour;
593 | }
594 | }
595 |
596 | // add a new colour for this class
597 | for (var colour in this.eventcolours) {
598 | if (this.eventcolours[colour] == false) {
599 | this.eventcolours[colour] = classname;
600 | return colour;
601 | }
602 | }
603 |
604 | // there are no colours left, return a default colour
605 | return "#0275d8";
606 |
607 | }
608 |
609 | /*
610 | Sets the time constraints of the calendar given a schedule
611 | */
612 | setScheduleConstraints(schedule) {
613 | var maxDay = 4; // we always want to show Mon-Fri unless there are Sat or Sun classes
614 | var minDay = 0;
615 | var minHour = 24;
616 | var maxHour = 0;
617 |
618 | for (var classv in schedule) {
619 | var thisclass = schedule[classv];
620 |
621 | // for every time
622 | for (var time in thisclass["times"]) {
623 | var thistime = thisclass["times"][time];
624 |
625 | // make sure there isn't a -1 in the days
626 | if (thistime[0].indexOf(-1) == -1) {
627 | // check whether the date changes constraints
628 | var thisMaxDay = Math.max.apply(null, thistime[0]);
629 |
630 | if (thisMaxDay > maxDay) {
631 | maxDay = thisMaxDay;
632 | }
633 |
634 | // check whether these times change the constraints
635 | var startTime = Generator.totalMinutesToTime(thistime[1][0]);
636 | var startHour = parseInt(startTime.split(":")[0])
637 |
638 | if (startHour < minHour) {
639 | minHour = startHour;
640 | }
641 |
642 | var endTime = Generator.totalMinutesToTime(thistime[1][1]);
643 | var endHour = parseInt(endTime.split(":")[0]) + 1;
644 |
645 | if (endHour > maxHour) {
646 | maxHour = endHour;
647 | }
648 | }
649 | }
650 | }
651 |
652 | if (maxDay == 4 && minDay == 0 && minHour == 24 && maxHour == 0) {
653 | // Just set a default scale
654 | this.resizeCalendarNoScroll(0, 4, 9, 17);
655 | }
656 | else {
657 | this.resizeCalendarNoScroll(minDay, maxDay, minHour, maxHour);
658 | }
659 | }
660 |
661 | /*
662 | Sets the current generated index
663 | */
664 | setCurrentIndex(index) {
665 | var self = this;
666 |
667 | if (index > (self.totalGenerated-1)) {
668 | // go down to the start at 0
669 | index = 0;
670 | }
671 | if (index < 0) {
672 | // go to the max index
673 | index = self.totalGenerated-1;
674 | }
675 |
676 | self.curIndex = index;
677 |
678 | // show it on the UI
679 | self.updateIndexUI(self.curIndex+1);
680 | }
681 |
682 | /*
683 | Updates the UI with the passed in current schedule index
684 | */
685 | updateIndexUI(index) {
686 | $("#curGenIndex").text(index);
687 | }
688 |
689 | /*
690 | Updates the UI with the passed in total generated schedules
691 | */
692 | updateTotalUI(total) {
693 | $("#totalGen").text(total);
694 | }
695 |
696 | /*
697 | Sets the total amount of generated schedules for the UI
698 | */
699 | setTotalGenerated(total) {
700 | var self = this;
701 |
702 | self.totalGenerated = total;
703 |
704 | self.updateTotalUI(self.totalGenerated);
705 | }
706 |
707 | /*
708 | Goes to the previous schedule
709 | */
710 | goToPrev() {
711 | var self = this;
712 |
713 | if (self.totalGenerated > 0) {
714 | self.setCurrentIndex(self.curIndex-1);
715 |
716 | // get the schedule
717 | var newschedules = window.mycourses.generator.getSchedule(self.curIndex);
718 |
719 | if (newschedules != false) {
720 | // we got the schedule, now populate it
721 | self.displaySchedule(newschedules);
722 | }
723 | }
724 | }
725 |
726 | /*
727 | Goes to the next schedule
728 | */
729 | goToNext() {
730 | var self = this;
731 |
732 | if (self.totalGenerated > 0) {
733 | self.setCurrentIndex(self.curIndex+1);
734 |
735 | // get the schedule
736 | var newschedules = window.mycourses.generator.getSchedule(self.curIndex);
737 |
738 | if (newschedules != false) {
739 | // we got the schedule, now populate it
740 | self.displaySchedule(newschedules);
741 | }
742 | }
743 | }
744 |
745 | /*
746 | Binds the buttons that let you go through each generated schedule
747 | */
748 | bindNextPrev() {
749 | var self = this;
750 | // unbind any current binds
751 | $("#prevSchedule").unbind();
752 | $("#nextSchedule").unbind();
753 |
754 | $("#prevSchedule").click(function () {
755 | self.goToPrev();
756 | });
757 |
758 | $("#nextSchedule").click(function () {
759 | self.goToNext();
760 | });
761 | }
762 |
763 | /*
764 | Binds the arrow keys and Ctrl+C
765 | */
766 | keyBinds() {
767 | var self = this;
768 |
769 | // Bind arrow keys
770 | $(document).on('keydown', function (e){
771 | var tag = e.target.tagName.toLowerCase();
772 |
773 | // We don't want to do anything if they have an input focused
774 | if (tag != "input" && !window.tourInProgress) {
775 | if (e.keyCode == 37) self.goToPrev();
776 | else if (e.keyCode == 39) self.goToNext();
777 | else if (e.keyCode == 67 && (e.metaKey || e.ctrlKey)) $("#copySchedToClipboard").click();
778 | }
779 | });
780 | }
781 |
782 | /*
783 | Visually clears all of the events on the calendar
784 | */
785 | clearEvents() {
786 | $("#schedule").find(".event").each(function() {
787 | $(this).remove();
788 | });
789 | }
790 |
791 | /*
792 | Generates the HTML for a calendar event tooltip given a class object
793 | */
794 | generateTooltip(classobj) {
795 | // Return html string
796 | var htmlString = "";
797 |
798 | // Define the attributes and their names to add
799 | var allowedAttributes = [
800 | {
801 | "id": "id",
802 | "name": "Class ID"
803 | },
804 | {
805 | "id": "teachers",
806 | "name": "Teachers"
807 | },
808 | {
809 | "id": "oldtimes",
810 | "name": "Times"
811 | },
812 | {
813 | "id": "rooms",
814 | "name": "Rooms"
815 | },
816 | {
817 | "id": "location",
818 | "name": "Location"
819 | },
820 | {
821 | "id": "scheduletype",
822 | "name": "Type"
823 | },
824 | {
825 | "id": "status",
826 | "name": "Status"
827 | }
828 | ];
829 |
830 | // Iterate through every attribute
831 | for (var attribute in allowedAttributes) {
832 | attribute = allowedAttributes[attribute];
833 |
834 | // Make sure its id is defined in the class
835 | if (classobj[attribute["id"]] != undefined) {
836 | if (typeof classobj[attribute["id"]] != "object") {
837 | htmlString += "" + attribute["name"] + ": " + classobj[attribute["id"]] + " ";
838 | }
839 | else {
840 | // Prevent dupes
841 | var alreadyAdded = [];
842 |
843 | // Iterate through the elements and add them
844 | htmlString += "" + attribute["name"] + ": ";
845 | for (var index in classobj[attribute["id"]]) {
846 |
847 | // Check if we've already added this element
848 | if (alreadyAdded.indexOf(classobj[attribute["id"]][index]) == -1) {
849 | // we haven't already added this element
850 |
851 | if (attribute["id"] == "teachers") {
852 | var thisteacher = classobj[attribute["id"]][index];
853 |
854 | htmlString += thisteacher;
855 |
856 | // If this teacher has an RMP score, add it
857 | if (classList.rmpdata[thisteacher] != undefined && classList.rmpdata[thisteacher]["rating"] != undefined) {
858 | htmlString += " (" + classList.rmpdata[thisteacher]["rating"] + ")";
859 | }
860 |
861 | htmlString += " ";
862 | }
863 | else {
864 | // Just add the element
865 | htmlString += classobj[attribute["id"]][index] + " ";
866 | }
867 |
868 | // push it to added elements
869 | alreadyAdded.push(classobj[attribute["id"]][index]);
870 | }
871 | }
872 | }
873 | }
874 | }
875 |
876 | return htmlString;
877 | }
878 |
879 | /*
880 | Add an event with start and end time (24 hours)
881 |
882 | Days is an array containing the integers that represent the days that this event is on
883 | */
884 | addEvent(starttime, endtime, days, text, classobj) {
885 |
886 | var rowheight = $("#schedule").find("td:first").height() + 1;
887 |
888 | var starthour = parseInt(starttime.split(":")[0]);
889 | var startmin = parseInt(starttime.split(":")[1]);
890 |
891 | var endhour = parseInt(endtime.split(":")[0]);
892 | var endmin = parseInt(endtime.split(":")[1]);
893 |
894 | // round down to closest 30min or hour
895 | var roundedstartmin = Math.floor(startmin/30) * 30;
896 |
897 | // figure out how many minutes are in between the two times
898 | var totalstartmin = starthour*60 + startmin;
899 | var totalendmin = endhour*60 + endmin;
900 |
901 | var totalmin = totalendmin - totalstartmin;
902 |
903 | // Calculate the height of the box
904 | var totalheight = 0;
905 |
906 | // Every 30min is rowheight
907 | totalheight += (totalmin/30)*rowheight;
908 |
909 | // calculate how far from the top the element is
910 | var topoffset = ((startmin % 30)/30) * rowheight;
911 |
912 | // draw the events
913 | for (var day in days) {
914 | day = days[day];
915 |
916 | // find the parent
917 | var tdelement = $("#schedule").find("#" + starthour + "-" + roundedstartmin);
918 | tdelement = tdelement.find("td:eq(" + (day+1) + ")");
919 |
920 | // empty it
921 | tdelement.empty();
922 |
923 | // create the element and append it
924 | var html = '
';
927 |
928 | html += text;
929 |
930 | html += '
';
931 |
932 | // Initialize the tooltip
933 | html = $(html).tooltip({container: 'body', html: true});
934 |
935 | tdelement.append(html);
936 | }
937 | }
938 |
939 | /*
940 | Resizes the calendar to the specified constraints
941 | */
942 | resizeCalendarNoScroll(startDay, endDay, startHour, endHour) {
943 |
944 | // If the difference between the start and end hours is less than 6, extend the end hour
945 | // This is to make sure the appearance of the calendar doesn't look weird and
946 | // that every row is 20px high
947 |
948 | var self = this;
949 |
950 | if ((endHour - startHour) < 6) {
951 | endHour += 6 - (endHour - startHour);
952 | }
953 |
954 | if (endHour > 24) {
955 | endHour = 24;
956 | }
957 |
958 | var windowheight = $(window).height();
959 | var calendarheight = windowheight * 0.49;
960 |
961 |
962 | this.emptyCalendar();
963 |
964 | // all parameters are inclusive
965 |
966 | // build header
967 | var header = '
';
968 |
969 | for (var x = startDay; x <= endDay; x++) {
970 | header += "