schedule-grid-js/www/scheduleGrid.js

217 lines
7.6 KiB
JavaScript

import * as utils from './utils.js';
export default class ScheduleGrid {
constructor(args) {
this.title = args.title;
this.granularity = args.granularity;
this.start_time = args.start_time;
this.end_time = args.end_time;
this.events = ScheduleGrid.assignTracks(args.events);
this.tracks = [...new Set(this.events.flatMap(e => e.tracks))]
.sort((a, b) => a-b);
// If there's supposed to be only a single column but there's
// actually multiple tracks, make as many events as possible full width.
if (args.single_column) {
this.events.filter(e => this.events.every(other => e === other
|| !e.at_same_time_as(other)))
.forEach(e => e.track = this.tracks);
}
}
static assignTracks(events) {
const eventsWithoutTracks = events.filter(e => e.track === undefined);
if (eventsWithoutTracks.length === 0) return events;
// A track is a column in the schedule grid. An event must be in at
// least one track. This is a list of tracks where each track is a
// collection of the events in that track. Two events may not be in
// the same track at the same time, unless one is "squishable".
const tracks = [];
// Events may have explicitly specified tracks. Fill in that
// information first.
const eventsWithTracks = events.filter(e => e.track !== undefined);
for (const event of eventsWithTracks) {
const eventTracks = event.tracks;
// If the track is "all", then eventTracks will be empty.
if (eventTracks.length > 0) {
const maxTrack = Math.max(...eventTracks);
if (!Number.isInteger(maxTrack)) {
throw "Invalid tracks for event: " + event.track;
}
while (maxTrack >= tracks.length) tracks.push([]);
for (const eventTrack of eventTracks) tracks[eventTrack].push(event);
}
}
// Greedily assign events to tracks, sorted by start time.
eventsWithoutTracks.sort((a, b) => a.start_time.cmp(b.start_time));
for (const event of eventsWithoutTracks) {
// Put the event in the first track with space for it.
const tracksWithSpace = Object.keys(tracks).map(t => parseInt(t)).filter(trackNum => {
const end = Math.min(trackNum + event.width, tracks.length);
for (let i = trackNum; i < end; i++) {
if (tracks[i].some(e => e.at_same_time_as(event))) {
return false;
}
}
return true;
})
.map(trackNum => {
const staffLists = [...tracks[trackNum], event]
.map(e => e.assignment.people_by_day[e.day].staff)
.filter(staff => staff.length > 0);
const matchingStaff = staffLists.length === 0
? []
: staffLists.reduce(utils.setIntersection);
return {
trackNum,
matchingStaff,
matchingStaffCount: matchingStaff.size,
};
})
.sort((a, b) => b.matchingStaffCount - a.matchingStaffCount)
.map(t => t.trackNum);
const trackNum = tracksWithSpace.length > 0 ? tracksWithSpace[0] : tracks.length;
const eventTracks = [];
for (let i = trackNum; i < trackNum + event.width; i++) {
eventTracks.push(i);
if (i < tracks.length) {
tracks[i].push(event);
} else {
tracks.push([event]);
}
}
event.track = eventTracks.length === 1 ? eventTracks[0] : eventTracks;
}
// The above code doesn't consider events with track "all".
// If another event happens at the same time, we give that event
// precedence and split the "all" event to only be in the other
// tracks.
function fixOverlappingAllTrackEvents(event) {
if (event.track !== 'all') return [event];
const overlappingEvents = events
.filter(e => e !== event && e.at_same_time_as(event));
if (overlappingEvents.length === 0) return [event];
const overlappingTracks = new Set(overlappingEvents.flatMap(e => e.tracks));
const eventTracks = [[]];
for (let i = 0; i < tracks.length; i++) {
if (!overlappingTracks.has(i)) {
const last = eventTracks[eventTracks.length-1];
if (last.length === 0 || last[last.length-1] === i-1) {
last.push(i);
} else {
eventTracks.push([i]);
}
}
}
return eventTracks.map(t => {
const eventClone = event.clone();
eventClone.track = t;
return eventClone;
});
}
return events.flatMap(fixOverlappingAllTrackEvents);
}
get timeRange() {
return new utils.TimeRange(this);
}
get numRows() {
return this.timeRange.duration.dividedBy(this.granularity);
}
toHtml(tableClasses, selectAssignment) {
const table = document.createElement('table');
table.classList.add('schedule');
if (tableClasses) {
for (const cls of tableClasses) {
table.classList.add(cls);
}
}
const numRows = this.numRows;
for (let rowIdx = 0; rowIdx < numRows; rowIdx++) {
const row = document.createElement('tr');
const rowTime = this.start_time.plus(this.granularity.times(rowIdx));
const nextRowTime = rowTime.plus(this.granularity);
const timeCell = document.createElement('th');
const timeSpan = document.createElement('span');
timeSpan.innerText = rowTime.to12HourString();
timeSpan.classList.add('row_time');
// TODO Custom granularity to highlight?
if (rowTime.minute === 0 && rowTime.second === 0) {
timeSpan.classList.add('hour');
}
timeCell.appendChild(timeSpan);
row.appendChild(timeCell);
for (const track of this.tracks) {
// Select events of this track in this row.
const events = this.events
.filter(e => (e.track === 'all' || e.tracks.includes(track))
&& e.start_time.cmp(nextRowTime) < 0
&& rowTime.cmp(e.end_time) < 0);
if (events.length === 0) {
row.appendChild(document.createElement('td'));
} else {
// Should only be one, but display something if there's multiple.
for (const event of events) {
const event = events[0];
const allTracksEvent = event.track === 'all';
const allTracksTrack = event.tracks.length > 1
? track === Math.min(...event.tracks)
: track === 0;
if ((event.start_time.cmp(rowTime) === 0
|| (event.start_time.cmp(rowTime) > 0
&& event.start_time.cmp(nextRowTime) < 0))
&& (!allTracksEvent || allTracksTrack)
&& (event.tracks.length <= 1 || allTracksTrack)) {
const eventNumRows = event.timeRange.duration
.dividedBy(this.granularity);
const cell = document.createElement('td');
cell.rowSpan = eventNumRows;
cell.colSpan = allTracksEvent
? this.tracks.length
: event.tracks.length;
cell.classList.add('event');
for (const eventClass of event.classes) {
cell.classList.add(eventClass);
}
cell.appendChild(event.description);
if (selectAssignment) {
for (const loc of event.description.querySelectorAll('.location')) {
loc.classList.add('clickable');
loc.addEventListener('click', e =>
selectAssignment(event.assignment));
}
}
row.appendChild(cell);
}
}
}
}
row.appendChild(timeCell.cloneNode(true));
table.appendChild(row);
}
return table;
}
}