import Assignment from './assignment.js'; import ScheduleGrid from './scheduleGrid.js'; import * as utils from './utils.js'; export default class Schedule { constructor(obj) { this.base_title = obj.title; this.assignments = obj.assignments.map(a => new Assignment(a)); // TODO Custom day names? Schedules might not be weekly. this.all_days = obj.all_days; // TODO Remove hard-coded participant kinds. this.all_students = obj.all_students ?? []; this.all_staff = obj.all_staff ?? []; this.all_teachers = obj.all_teachers ?? []; this.granularity = obj.granularity !== undefined ? new utils.Duration(obj.granularity) : new utils.Duration({minute: 10}); this.start_time = new utils.Time(obj.start_time); this.end_time = new utils.Time(obj.end_time); this.people_info = obj.people_info ?? {staff: {}, students: {}}; this.lastModified = obj.lastModified ?? new Date(); for (const teacher of this.all_teachers) { if (!this.all_staff.includes(teacher)) { this.all_staff.push(teacher); } } this.all_students.sort(); this.all_staff.sort(); this.all_teachers.sort(); this.recomputeNotAssigned(); } recomputeNotAssigned() { this.assignedStaff = new Set(this.assignments.flatMap(a => [...a.all_staff])); this.assignedStudents = new Set(this.assignments.flatMap(a => [...a.all_students])); this.notAssignedStaff = utils.setDifference(this.all_staff, this.assignedStaff); this.notAssignedStudents = utils.setDifference(this.all_students, this.assignedStudents); } asJsonObject() { return { 'title': this.base_title, 'assignments': this.assignments.map(a => a.asJsonObject()), // TODO Custom day names? Schedules might not be weekly. 'all_days': this.all_days, // TODO Remove hard-coded participant kinds. 'all_students': this.all_students, 'all_staff': this.all_staff, 'all_teachers': this.all_teachers, 'granularity': this.granularity.asJsonObject(), 'start_time': this.start_time.asJsonObject(), 'end_time': this.end_time.asJsonObject(), 'people_info': this.people_info, } } get timeRange() { return new utils.TimeRange(this); } fullGridFor(day, versionName) { return this.gridFor(undefined, day, versionName); } titleFor(person, day, versionName) { function timestampString(date) { function f(format) { return new Intl.DateTimeFormat('en', format).format(date); } return f({month: 'short'}) + ' ' + f({day: 'numeric'}) + ' ' + f({timeStyle: 'short'}); } return this.base_title + (day ? ' ' + utils.DayString(day) : '') + ' schedule' + (person ? ' for ' + person.name : '') + (versionName ? ' (' + versionName + ')' : '') + ' [' + timestampString(this.lastModified) + ']'; } infoFor(person) { if (!person) return {}; if (!this.people_info) this.people_info = {staff: {}, students: {}}; const kind = person.kind === 'student' ? 'students' : person.kind; if (!(kind in this.people_info)) return {}; return this.people_info[kind][person.name] ?? {}; } setInfoFor(person, args) { if (!this.people_info) this.people_info = {staff: {}, students: {}}; const kind = person.kind === 'student' ? 'students' : person.kind; if (!(kind in this.people_info)) this.people_info[kind] = {}; const newInfo = {...this.infoFor(person), ...args}; this.people_info[kind][person.name] = Object.values(newInfo).some(i => i !== undefined) ? newInfo : undefined; } gridFor(person, day, versionName) { const assignments = this.filterAssignments(person, day); const forStudent = person && person.kind === 'student'; const info = this.infoFor(person); const start_time = new utils.Time(info.start_time ?? this.start_time); const end_time = new utils.Time(info.end_time ?? this.end_time); return new ScheduleGrid({ 'granularity': this.granularity, 'start_time': start_time, 'end_time': end_time, 'single_column': !!person, 'events': assignments .map(a => { const e = a.asEvent(day, this.granularity, forStudent); if (e.start_time.cmp(start_time) < 0) { e.start_time = start_time; e.classes.push('invalid_start_time'); } if (e.end_time.cmp(end_time) > 0) { e.end_time = end_time; e.classes.push('invalid_end_time'); } return e; }), }) } filterAssignments(person, day) { let assignments = this.assignments.filter(a => a.days.includes(day)); if (!person) return assignments; assignments = assignments .filter(a => a.hasPersonOnDay(person, day)) .map(a => a.cloneWithoutTrack()); function omitOrSquish(optionalAssignment) { const latestEndBeforeOA = assignments .filter(a => a !== optionalAssignment && a.start_time.cmp(optionalAssignment.start_time) <= 0) .map(a => a.end_time) .sort((a, b) => a.cmp(b)) .pop(); // pop = last item or undefined const earliestStartAfterOA = assignments .filter(a => a !== optionalAssignment && a.start_time.cmp(optionalAssignment.start_time) > 0) .map(a => a.start_time) .sort((a, b) => a.cmp(b)) .shift(); // shift = first item or undefined let omitted = false; let newStartTime = false; let newEndTime = false; if (latestEndBeforeOA) { if (latestEndBeforeOA.cmp(optionalAssignment.end_time) >= 0) { omitted = true; } else if (latestEndBeforeOA.cmp(optionalAssignment.start_time > 0)) { newStartTime = true; } } if (earliestStartAfterOA) { if (earliestStartAfterOA.cmp(optionalAssignment.start_time) <= 0) { omitted = true; } else if (earliestStartAfterOA.cmp(optionalAssignment.end_time) < 0) { newEndTime = true; } } if (omitted) { assignments.splice(assignments.indexOf(optionalAssignment), 1); } // optionalAssignment is already a clone, so these aren't // modifying the actual assignment. if (newStartTime) { optionalAssignment.start_time = latestEndBeforeOA; } if (newEndTime) { optionalAssignment.end_time = earliestStartAfterOA; } } // Prioritize non-all-track assignments because they explicitly // include the person. assignments.filter(a => a.squishable && a.track === 'all') .forEach(omitOrSquish); assignments.filter(a => a.squishable && a.track !== 'all') .forEach(omitOrSquish); return assignments; } get all_non_teacher_staff() { return [...utils.setDifference(this.all_staff, this.all_teachers)].sort(); } get all_staff_teachers_first() { return [...this.all_teachers, ...this.all_non_teacher_staff]; } get all_people() { return [...this.all_teachers.concat(this.all_non_teacher_staff) .map(name => ({name, kind: 'staff'})), ...this.all_students.map(name => ({name, kind: 'student'}))]; } addStudent(name) { if (this.all_students.includes(name)) return false; this.all_students.push(name); this.all_students.sort(); this.notAssignedStudents.add(name); return true; } addStaff(name) { if (this.all_staff.includes(name)) return false; this.all_staff.push(name); this.all_staff.sort(); this.notAssignedStaff.add(name); return true; } addTeacher(name) { if (!this.addStaff(name)) return false; this.all_teachers.push(name); this.all_teachers.sort(); this.notAssignedStaff.add(name); return true; } delStaff(name) { if (!this.notAssignedStaff.has(name)) { throw "Tried to remove staff " + name + " who has assignments."; } this.notAssignedStaff.delete(name); this.all_staff.splice(this.all_staff.indexOf(name), 1); const teacherIdx = this.all_teachers.indexOf(name); if (teacherIdx > -1) this.all_teachers.splice(teacherIdx, 1); } delStudent(name) { if (!this.notAssignedStudents.has(name)) { throw "Tried to remove student " + name + " who has assignments."; } this.notAssignedStudents.delete(name); this.all_students.splice(this.all_students.indexOf(name), 1); } generateWarningsFor(person) { const assignments = this.assignments .filter(a => a.hasPersonExplicitlyOnAnyDay(person)); if (assignments.length === 0) { return [warning("Not assigned to any events.", [])]; } const info = this.infoFor(person); const start_time = new utils.Time(info.start_time ?? this.start_time); const end_time = new utils.Time(info.end_time ?? this.end_time); const days = info.days ?? this.all_days; const res = []; if (info.start_time && !Number.isInteger( new utils.TimeRange({start_time: this.start_time, end_time: start_time}) .duration.dividedBy(this.granularity))) { res.push(warning("Person's start time of " + start_time.to12HourString() + ' is not a valid row.', [])); } if (info.end_time && !Number.isInteger( new utils.TimeRange({start_time: this.start_time, end_time: end_time}) .duration.dividedBy(this.granularity))) { res.push(warning("Person's end time of " + end_time.to12HourString() + ' is not a valid row.', [])); } for (const a of assignments) { if (info.start_time && a.start_time.cmp(start_time) < 0) { res.push(warning( 'Event starts before ' + person.name + "'s start time of " + start_time.to12HourString(), a)); } if (info.end_time && a.end_time.cmp(end_time) > 0) { res.push(warning('Event ends after ' + person.name + "'s end time of " + end_time.to12HourString(), a)); } if (info.days) { for (const day of utils.setDifference(this.all_days, days)) { if (a.hasPersonExplicitlyOnDay(person, day)) { res.push(warning('Assigned to event on ' + utils.DayString(day) + ', but is not in on that day', a)); } } } } const numRows = new utils.TimeRange({start_time, end_time}) .duration.dividedBy(this.granularity); const granularity = this.granularity; function idxToTime(rowIdx) { return start_time.plus(granularity.times(rowIdx)); } for (const day of days) { const aToTimes = {}; for (let rowIdx = 0; rowIdx < numRows; rowIdx++) { const rowTime = idxToTime(rowIdx); const assignmentsAtTime = assignments .filter(a => a.hasPersonExplicitlyOnDay(person, day) && a.isOnRowForTime(rowTime)) .map(a => this.assignments.indexOf(a)) .join(','); const everyoneAssignmentsAtTime = this.assignments .filter(a => a.days.includes(day) && a.isOnRowForTime(rowTime) && a.isOnEverySchedule()) .map(a => this.assignments.indexOf(a)) .join(','); const key = assignmentsAtTime + ';' + everyoneAssignmentsAtTime; if (!(key in aToTimes)) aToTimes[key] = []; aToTimes[key].push(rowIdx); } for (const [allAssignmentsAtTime, timeIndexes] of Object.entries(aToTimes)) { function idxListToIdxRanges(l) { return l.sort().reduce((acc, num) => { if (acc.length > 0 && acc[acc.length-1].end === num-1) { const prev = acc.pop(); acc.push({start: prev.start, end: num}); } else { acc.push({start: num, end: num}); } return acc; }, []); } function idxListToTimeRanges(l) { return idxListToIdxRanges(l) .map(range => new utils.TimeRange({ start_time: new utils.Time(idxToTime(range.start)), end_time: new utils.Time(idxToTime(range.end)), }).to12HourString()) .join(', '); } const [assignmentsAtTime, everyoneAssignmentsAtTime] = allAssignmentsAtTime.split(';') .map(l => l.split(',').map(idx => this.assignments[idx])); if (assignmentsAtTime.length === 0 && everyoneAssignmentsAtTime.length === 0) { res.push(warning('No event on ' + utils.DayString(day) + ' at ' + idxListToTimeRanges(timeIndexes), [])); } else if (assignmentsAtTime.length > 1) { res.push(warning('Multiple events on ' + utils.DayString(day) + ' at ' + idxListToTimeRanges(timeIndexes), assignmentsAtTime)); } } } return res; } generateNonPersonWarnings() { return [ ...this.assignments.filter(a => utils.setIntersection( a.days, this.all_days).size === 0) .map(a => warning("Event occurs on no days", a)), ...this.assignments.filter(a => a.all_students.size > 0 && a.all_staff.size === 0) .map(a => warning("Event has no staff", a)), ...this.assignments.filter(a => !Number.isInteger( new utils.TimeRange({start_time: this.start_time, end_time: a.start_time}) .duration.dividedBy(this.granularity))) .map(a => warning("Event start time is not a valid row", a)), ...this.assignments.filter(a => !Number.isInteger( new utils.TimeRange({start_time: this.start_time, end_time: a.end_time}) .duration.dividedBy(this.granularity))) .map(a => warning("Event end time is not a valid row", a)), ...this.assignments.filter(a => a.start_time.cmp(this.start_time) < 0) .map(a => warning("Event starts before schedule start", a)), ...this.assignments.filter(a => a.end_time.cmp(this.end_time) > 0) .map(a => warning("Event ends after schedule end", a)), ]; } generateWarnings() { return [ { warnings: this.generateNonPersonWarnings() }, ...this.all_people.map(person => ({ person, warnings: this.generateWarningsFor(person) })) ].filter(obj => obj.warnings.length > 0); } } function warning(message, assignments) { if (!Array.isArray(assignments)) { assignments = [assignments]; } return {message, assignments}; }