schedule-grid-js/www/schedule.js

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};
}