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