...
 
Commits (9)
......@@ -62,6 +62,7 @@ GEM
erubi (1.7.1)
execjs (2.7.0)
ffi (1.9.25)
ffi (1.9.25-x86-mingw32)
globalid (0.4.1)
activesupport (>= 4.2.0)
httparty (0.16.3)
......@@ -98,10 +99,11 @@ GEM
nio4r (2.3.1)
nokogiri (1.8.5)
mini_portile2 (~> 2.3.0)
nokogiri (1.8.5-x86-mingw32)
mini_portile2 (~> 2.3.0)
parallel (1.12.1)
parser (2.5.3.0)
ast (~> 2.4.0)
pg (1.1.3)
powerpack (0.1.2)
pry (0.12.2)
coderay (~> 1.1.0)
......@@ -184,11 +186,14 @@ GEM
activesupport (>= 4.0)
sprockets (>= 3.0.0)
sqlite3 (1.3.13)
sqlite3 (1.3.13-x86-mingw32)
thor (0.20.3)
thread_safe (0.3.6)
tilt (2.0.9)
tzinfo (1.2.5)
thread_safe (~> 0.1)
tzinfo-data (1.2018.7)
tzinfo (>= 1.0.0)
uglifier (4.1.20)
execjs (>= 0.3.0, < 3)
unicode-display_width (1.4.0)
......@@ -206,6 +211,7 @@ GEM
PLATFORMS
ruby
x86-mingw32
DEPENDENCIES
apipie-rails
......@@ -217,7 +223,6 @@ DEPENDENCIES
listen (>= 3.0.5, < 3.2)
maruku
nokogiri
pg
pry
pry-doc
puma (~> 3.7)
......
//= require FileSaver
//= require cart
const elementFromString = string => {
const html = new DOMParser().parseFromString(string, 'text/html');
return html.body.firstChild;
......@@ -21,3 +20,26 @@ const initGlobalListeners = () => {
const semesterSelect = document.getElementById('semester-select');
semesterSelect.onchange = () => setSemester(semesterSelect);
};
// Set a cookie. Overrites the old value if one exists.
const setCookie = (name, value) => {
// Delete our old cookie by setting expiration
document.cookie = name + "=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"
document.cookie = name + "=" + encodeURIComponent(value);
};
// Get a cookie
const getCookie = (name) => {
cookies = document.cookie.split(";");
for (i = 0; i < cookies.length; i++) {
c = cookies[i].split("=");
if (c[0].trim() == name.trim())
return decodeURIComponent(c[1]);
}
return null;
};
const getLocation = () => {
return `${window.location.protocol}//${window.location.hostname}:${window.location.port}`;
};
\ No newline at end of file
document.addEventListener('DOMContentLoaded', () => {
const eventsTemplate = document.querySelector('#events');
if (eventsTemplate) {
const eventsJSON = eventsTemplate.dataset.events;
const events = JSON.parse(eventsJSON);
window.events = events;
$('#calendar').fullCalendar({
var SectionLoader = {
preloadSections: async () => {
this.sections = {};
await SectionLoader.loadSections(JSON.parse(getCookie("cart")).join(","));
},
getSection: async (crn) => {
if (!(crn in this.sections)) {
await SectionLoader.loadSections(crn);
}
return this.sections[crn];
},
loadSections: async (crn) => {
const response = await fetch(`${getLocation()}/api/course_sections?crn=${crn}`);
const sections = await response.json();
console.log(crn);
sections.forEach((sData) => {
this.sections[sData["crn"]]= new Section(sData);
});
}
};
class Schedule {
constructor() {
this.events = [];
this.dayMap = {
"U": 0,
"M": 1,
"T": 2,
"W": 3,
"R": 4,
"F": 5,
"S": 6
}
}
clone() {
var s = new Schedule();
s.events = this.events.slice();
return s;
}
toJSON() {
var data = {
"breaks": [],
"sections": []
};
this.events.forEach((event) => {
if (event.name == "Section")
data["sections"].push(event.crn);
else
data["breaks"].push({"name": event.displayName,
"start_time": event.startTime,
"end_time": event.endTime,
"days": event.days});
});
return JSON.stringify(data);
}
renderEvents(start, end, timezone, callback) {
var eventData = [];
this.events.forEach((event) => {
event.days.forEach((day) => {
var s = event.startTime;
var e = event.endTime;
var d = this.dayMap[day];
eventData.push({
title: event.displayName,
start: new Date(2019, 0, 13 + d, s.getHours(), s.getMinutes()),
end: new Date(2019, 0, 13 + d, e.getHours(), e.getMinutes())
});
});
});
callback(eventData);
}
display(element) {
var obj = this;
$(element).fullCalendar({
defaultDate: new Date(2019, 0, 14),
defaultView: 'agendaWeek',
header: false,
events: renderEvents,
events: (s,e,t,c) => {obj.renderEvents(s,e,t,c)},
columnHeaderFormat: 'dddd',
allDaySlot: false,
});
}
addEvent(event) {
this.events.push(event);
}
async addCRNS(crns) {
crns.forEach(async (crn) => {
this.addEvent(await SectionLoader.getSection(crn));
});
}
static async fromJSON(json) {
var s = new Schedule()
const data = JSON.parse(json);
data["breaks"].forEach((bData) => {
s.addEvent(new Break(bData));
});
s.addCRNS(data["sections"])
return s;
}
}
class Event {
constructor(name, days, start, end) {
this.displayName = name;
this.days = days;
this.startTime = new Date(start);
this.endTime = new Date(end);
}
}
class Break extends Event {
constructor(data) {
super(data["name"], data["days"], data["start_time"], data["end_time"]);
}
}
class Section extends Event {
constructor(data) {
super(data["title"], data["days"], data["start_time"], data["end_time"]);
this.crn = data["crn"];
this.courseID = data["course_id"];
}
}
document.addEventListener('DOMContentLoaded', async () => {
await SectionLoader.preloadSections();
window.schedule = await Schedule.fromJSON(getCookie("schedule"));
window.schedule.display("#calendar");
// const eventsTemplate = document.querySelector('#events');
// if (eventsTemplate) {
// const eventsJSON = eventsTemplate.dataset.events;
// const events = JSON.parse(eventsJSON);
// window.events = events;
// $('#calendar').fullCalendar({
// defaultDate: new Date(2019, 0, 14),
// defaultView: 'agendaWeek',
// header: false,
// events: renderEvents,
// columnHeaderFormat: 'dddd',
// allDaySlot: false,
// });
// }
initListeners();
});
......@@ -31,7 +176,7 @@ const remove = async item => {
* and sets the link in the modal to it.
*/
const setUrlInModal = () => {
document.getElementById('calendar-link').innerText = `${window.location.protocol}//${window.location.hostname}/api/schedules?crns=${window.cart._courses.join(',')}`;
document.getElementById('calendar-link').innerText = `${getLocation()}/api/schedules?crns=${window.cart._courses.join(',')}`;
};
const downloadIcs = async () => {
......@@ -45,6 +190,20 @@ const addToSystemCalendar = () => {
window.open(`webcal://${window.location.hostname}/api/schedules?crns=${window.cart._courses.join(',')}`);
};
const generateSchedules = async () => {
const response = await fetch(`${getLocation()}/schedule/generate`);
const data = await response.json();
potentialSchedules = [];
data.forEach((crns) => {
potentialSchedule = window.schedule.clone();
potentialSchedule.addCRNS(crns);
potentialSchedules.push(potentialSchedule);
});
console.log(potentialSchedules);
};
const initListeners = () => {
const items = Array.from(document.querySelectorAll('.section-item'));
items.forEach(item => (item.onclick = () => remove(item)));
......@@ -53,5 +212,7 @@ const initListeners = () => {
document.getElementById('download-ics').onclick = downloadIcs;
document.getElementById('add-to-system').onclick = addToSystemCalendar;
document.getElementById('generate-button').onclick = generateSchedules;
document.getElementById('share-url').innerText = `${window.location.protocol}//${window.location.hostname}/schedule/view?crns=${window.cart._courses.join(',')}`;
};
document.addEventListener('DOMContentLoaded', () => {
const eventsTemplate = document.querySelector('#events');
if (eventsTemplate) {
......@@ -27,7 +29,7 @@ const renderEvents = (start, end, timezone, callback) => {
* and sets the link in the modal to it.
*/
const setUrlInModal = () => {
document.getElementById('calendar-link').innerText = `${window.location.protocol}//${window.location.hostname}/api/schedules?section_ids=${window.cart._courses.join(',')}`;
document.getElementById('calendar-link').innerText = `${getLocation()}/api/schedules?section_ids=${window.cart._courses.join(',')}`;
};
const downloadIcs = async () => {
......
......@@ -2,6 +2,7 @@
class ApplicationController < ActionController::Base
protect_from_forgery with: :null_session
before_action :set_semester, :set_cookies, :set_cart
after_action :save_schedule
def set_semester
if params.key?(:semester_id)
......@@ -23,4 +24,24 @@ class ApplicationController < ActionController::Base
def set_cookies
cookies.permanent[:cart] = "[]" if cookies.permanent[:cart].nil?
end
def cart
JSON.parse(cookies.permanent[:cart])
end
def schedule
if @schedule.nil?
if cookies[:schedule]
@schedule = ApplicationHelper::Schedule.from_json(cookies[:schedule])
else
@schedule = ApplicationHelper::Schedule.new([])
end
end
@schedule
end
def save_schedule
cookies[:schedule] = @schedule.to_json if @schedule
end
end
......@@ -19,8 +19,8 @@ class InstructorsController < ApplicationController
@past = []
@instructor.course_sections.map(&:course).each do |c|
@past << c unless @past.select { |past| past.full_name == c.full_name }.count.positive?
end
end @past << c unless @past.select { |past| past.full_name == c.full_name }.count.positive?
@past.sort_by!(&:full_name)
end
......
......@@ -20,4 +20,9 @@ class SchedulesController < ApplicationController
}
@events = generate_fullcalender_events(@all)
end
def generate
sections = generate_section_lists(CourseSection.select("course_sections.*, courses.semester_id").joins(:course).where("courses.semester_id = ?", @semester.id).where(crn: cart).to_ary)
render json: sections.map{|a| a.map{|s| s.crn}}
end
end
......@@ -2,8 +2,9 @@ class SearchController < ApplicationController
def index
redirect_to home_url unless params[:query].length > 1
schedule
results = SearchHelper::GenericItem.fetchall(String.new(params[:query]), semester: @semester).group_by(&:type)
@instructors = results[:instructor]&.map(&:data)
@courses = results[:course]&.map(&:data)
end
end
end
\ No newline at end of file
module ApplicationHelper
class Schedule
attr_reader :events
def initialize(events)
@events = events
end
def to_json
data = {}
data["breaks"] = events.select { |e| e.class.name.demodulize == "Break"}
data["sections"] = events.select { |e| e.class.name.demodulize == "CourseSection"}.map(&:crn)
JSON.generate(data)
end
def conflicts_with?(event)
events.each do |other_event|
return true if event.conflicts_with?(other_event)
end
false
end
def self.from_json(json)
data = JSON.parse(json)
events = []
data["sections"].each do |crn|
begin
events.push(CourseSection.find_by(crn: crn))
rescue
end
end
data["breaks"].each do |b|
events.push(Break.new(b["name"],
Time.parse(b["start_time"]),
Time.parse(b["end_time"]),
b["days"].map(&:to_sym)))
end
Schedule.new(events)
end
end
class Break
attr_reader :start_time, :end_time, :days
def initialize(name, start_time, end_time, days)
@name = name
@start_time = start_time
@end_time = end_time
@days = days
end
def to_s
@name
end
end
end
module SchedulesHelper
class Node
attr_accessor :children
attr_accessor :parent
attr_accessor :value
def initialize(value)
@value = value
@parent = nil
@children = []
end
def add_child(value)
node = Node.new(value)
node.parent = self
children.push(node)
end
def to_s
value.to_s
end
# All of this node's ancestors as a block
def ancestors
n = self
while n != nil && n.value != nil
yield n
n = n.parent
end
end
end
DAYS = {
"M": Date.new(2019, 1, 14),
"T": Date.new(2019, 1, 15),
......@@ -11,10 +42,10 @@ module SchedulesHelper
def generate_fullcalender_events(sections)
sections.map do |s|
s.days.split('').map do |day|
formatted_date = DAYS[day.to_sym].to_s.tr('-', '')
time = Time.parse(s.start_time).strftime("%H%M%S")
endtime = Time.parse(s.end_time).strftime("%H%M%S")
s.days.map do |day|
formatted_date = DAYS[day].to_s.tr('-', '')
time = s.start_time.strftime("%H%M%S")
endtime = s.end_time.strftime("%H%M%S")
{
title: s.name,
......@@ -24,4 +55,64 @@ module SchedulesHelper
end
end.flatten
end
def generate_section_lists(sections)
# Put sections into groups of matching courses
grouped_sections = sections.sort_by{ |s| s.course_id }.slice_when{ |a, b| a.course_id != b.course_id }.to_a
# Create a tree and recursively add sections
root = Node.new(schedule) # Use the current schedule as a root (conflicts with this must be invalid)
match_sections(grouped_sections, root, 0)
# Build our results by recursively climbing the tree and adding all the children in lists
result = []
build_child_lists(root, result)
result.reject{ |list| list.length != grouped_sections.length } # Reject all results which contain less than the desired amount of sections
end
# Recursively build a tree of matched sections with the root at node
def match_sections(sections, node, i)
return if i >= sections.length
add_sections(node, sections[i])
node.children.each do |n|
match_sections(sections, n, i + 1)
end
end
# For each section in this node's children, add the given section if and only if it doesn't conflict
def add_sections(node, sections)
sections.each do |section|
next if conflicts(node, section)
node.add_child(section)
end
end
# Check whether or not this section conflicts with the current node or any of its ancestry
def conflicts(node, section)
node.ancestors do |n|
return true if n.value.conflicts_with?(section)
end
false
end
# Treats the given node as a root and recursively builds lists of all the paths down through the tree
def build_child_lists(node, list)
if node.children.empty?
list.push(build_ancestor_list(node))
else
node.children.each {|n| build_child_lists(n, list)}
end
end
# Build a list of the passed node's value and all ancestor values
def build_ancestor_list(node)
sections = []
node.ancestors do |n|
sections.push(n.value) unless n.parent.nil? # don't go to the root
end
sections
end
end
......@@ -11,16 +11,42 @@ class CourseSection < ApplicationRecord
validates :course_id, presence: true
def overlaps?(other)
t1_start, t1_end = Time.parse(start_time), Time.parse(end_time)
t2_start, t2_end = Time.parse(other.start_time), Time.parse(other.end_time)
t1_start, t1_end = start_time, end_time
t2_start, t2_end = other.start_time, other.end_time
(t1_start <= t2_end && t2_start <= t1_end) && Set.new(days.split).intersect?(Set.new(other.days.split))
(t1_start <= t2_end && t2_start <= t1_end) && Set.new(days).intersect?(Set.new(other.days))
rescue ArgumentError => e
false
end
def to_s
name
end
def conflicts_with?(other)
overlaps?(other) || (course_id == other.course_id)
end
def self.latest_by_crn(crn)
where(crn: crn).min_by { |s| s.course.semester.id }
end
def start_time
Time.parse(super())
rescue ArgumentError => e
return nil
end
def end_time
Time.parse(super())
rescue ArgumentError => e
return nil
end
def days
super().strip.split("").map(&:to_sym)
end
# Select all course sections that have an instructor that matches the given name
def self.with_instructor(name: "")
joins(:instructor)
......@@ -29,7 +55,7 @@ class CourseSection < ApplicationRecord
end
def self.from_crn(base_query, crn)
base_query.where(crn: crn)
base_query.where(crn: crn.split(",").map(&:strip))
end
def self.from_course_id(base_query, course_id)
......@@ -38,7 +64,7 @@ class CourseSection < ApplicationRecord
# Select all revelevant course sections given the provided filters
def self.fetch(filters)
query = CourseSection.joins(:course).select("course_sections.*")
query = CourseSection.joins(:course).select("course_sections.*, courses.semester_id").where("courses.semester_id = ?", filters["semester_id"])
filters.each do |filter, value|
case filter
......
......@@ -13,6 +13,16 @@
Export Schedule
</button>
<div id="calendar"></div>
<br/>
<h2 class="float-left">Selected Courses</h2>
<button type="button" id="generate-button" class="btn btn-primary float-right">Generate Schedules</button>
<br/>
<br/>
<%= render partial: 'shared/section', collection: @all %>
<br/>
<h3>Quick add</h3>
<p>Populate your calendar quickly by entering a comma separated list of CRNs.</p>
<form action="/sessions/add_bulk" class="form">
......@@ -36,14 +46,10 @@
<h3 id="share-header">Share</h3>
Want to share your schedule with your friends? Send them this link:<br/>
<blockquote ><code id="share-url"></code></blockquote>
<blockquote><code id="share-url"></code></blockquote>
<template id="events" data-events="<%= @events.to_json %>"></template>
<hr />
<h2>Selected Courses</h2>
<%= render partial: 'shared/section', collection: @all %>
<!-- Export Modal -->
<div class="modal fade" id="exportModal" tabindex="-1" role="dialog" aria-labelledby="exportModalLabel" aria-hidden="true">
......
......@@ -26,5 +26,5 @@
<%= link_to section.instructor.name, section.instructor %><% end %> <br/>
<i class="fas fa-map-marker-alt"></i> <%= section.location %> <br/>
<i class="fas fa-clock"></i> <%= "#{section.days}, #{section.start_time}-#{section.end_time}" %> <br/>
<i class="fas fa-clock"></i> <%= "#{section.days.map(&:to_s).join('')}, #{section.start_time.strftime('%l:%M%P')} - #{section.end_time.strftime('%l:%M%P')}" %> <br/>
</li>
......@@ -10,6 +10,7 @@ Rails.application.routes.draw do
resources :instructors, only: [:index, :show]
get 'schedule', to: 'schedules#show', as: 'schedule'
get 'schedule/view', to: 'schedules#view', as: 'view_schedule'
get 'schedule/generate', to: 'schedules#generate', as: 'generate_schedule'
scope :api, module: 'api' do # Register /api routes
resources :courses, only: [:index, :show], as: 'api_courses'
......
......@@ -10,14 +10,11 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20180927140017) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
ActiveRecord::Schema.define(version: 20181115035458) do
create_table "closures", force: :cascade do |t|
t.date "date"
t.bigint "semester_id"
t.integer "semester_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["semester_id"], name: "index_closures_on_semester_id"
......@@ -38,31 +35,56 @@ ActiveRecord::Schema.define(version: 20180927140017) do
t.string "campus"
t.string "notes"
t.integer "size_limit"
t.bigint "course_id"
t.integer "course_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.bigint "instructor_id"
t.integer "instructor_id"
t.integer "semester_id"
t.index ["course_id"], name: "index_course_sections_on_course_id"
t.index ["instructor_id"], name: "index_course_sections_on_instructor_id"
t.index ["semester_id"], name: "index_course_sections_on_semester_id"
end
create_table "course_tags", force: :cascade do |t|
t.integer "course_id"
t.decimal "score"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "course_subject"
t.string "course_number"
t.integer "tag_id"
t.index ["course_id"], name: "index_course_tags_on_course_id"
t.index ["tag_id"], name: "index_course_tags_on_tag_id"
end
create_table "courses", force: :cascade do |t|
t.string "subject"
t.string "course_number"
t.bigint "semester_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "description"
t.string "credits"
t.string "title"
t.string "prereqs"
t.index ["semester_id"], name: "index_courses_on_semester_id"
t.index ["course_number"], name: "index_courses_on_course_number"
t.index ["subject"], name: "index_courses_on_subject"
end
create_table "instructor_tags", force: :cascade do |t|
t.integer "instructor_id"
t.integer "tag_id"
t.decimal "score"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["instructor_id"], name: "index_instructor_tags_on_instructor_id"
t.index ["tag_id"], name: "index_instructor_tags_on_tag_id"
end
create_table "instructors", force: :cascade do |t|
t.string "name"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["name"], name: "index_instructors_on_name"
end
create_table "semesters", force: :cascade do |t|
......@@ -72,8 +94,13 @@ ActiveRecord::Schema.define(version: 20180927140017) do
t.datetime "updated_at", null: false
end
add_foreign_key "closures", "semesters"
add_foreign_key "course_sections", "courses"
add_foreign_key "course_sections", "instructors"
add_foreign_key "courses", "semesters"
create_table "tags", force: :cascade do |t|
t.string "name"
t.integer "word_count"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["name"], name: "index_tags_on_name"
t.index ["word_count"], name: "index_tags_on_word_count"
end
end