421 lines
15 KiB
JavaScript
421 lines
15 KiB
JavaScript
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};
|
|
}
|