Support display of full schedules using browser console.

main
Daniel Perelman 10 months ago
parent c5ab01794b
commit 72a63f8356
  1. 4
      www/app.js
  2. 130
      www/assignment.js
  3. 32
      www/event.js
  4. 19
      www/index.html
  5. 184
      www/main.css
  6. 63
      www/schedule.js
  7. 174
      www/scheduleGrid.js
  8. 108
      www/utils.js

@ -0,0 +1,4 @@
import Schedule from './schedule.js';
// XXX For debugging:
window.Schedule = Schedule;

@ -0,0 +1,130 @@
import Event from './event.js'
import * as utils from './utils.js';
export default class Assignment {
constructor(obj) {
this.location = obj.location;
this.start_time = new utils.Time(obj.start_time);
this.end_time = new utils.Time(obj.end_time);
this.notes = obj.notes;
this.track = obj.track;
this.days = obj.days;
this.people_by_day = obj.people_by_day;
this.squishable = obj.squishable ?? false;
this.all_staff = new Set(Object.values(this.people_by_day)
.flatMap(day => day['staff']));
this.all_students = new Set(Object.values(this.people_by_day)
.flatMap(day => day['students']));
}
asJsonObject() {
return {
'location': this.location,
'start_time': this.start_time,
'end_time': this.end_time,
'notes': this.notes,
'track': this.track,
'days': this.days,
'people_by_day': this.people_by_day,
'squishable': this.squishable,
}
}
asJson() {
return JSON.stringify(this.asJsonObject(), null, 2);
}
get timeRange() {
return new utils.TimeRange(this);
}
asEvent(day, granularity, location_only) {
const people = this.people_by_day[day];
const staff = people['staff'] ?? [];
const students = people['students'] ?? [];
const num_people = staff.length + students.length;
let compact_mode = false;
if (granularity) {
const row_count = this.timeRange.duration.dividedBy(granularity);
const needed_rows = location_only ? 2 : 2 + num_people;
compact_mode = needed_rows > row_count;
}
const classes = [];
if (compact_mode) classes.push('compact');
const description = document.createElement('span');
description.classList.add('event_description');
const locationSpan = document.createElement('span');
locationSpan.innerText = this.location;
locationSpan.classList.add('location');
if (location_only) {
classes.push('location_only');
description.appendChild(locationSpan);
description.appendChild(document.createElement('br'));
} else {
if (compact_mode) locationSpan.classList.add('compact');
description.appendChild(locationSpan);
if (!compact_mode || num_people > 0) {
description.appendChild(document.createElement('br'));
}
function addNames(names, className) {
let first = true;
for (const name of names.sort()) {
if (first) {
first = false;
} else {
if (compact_mode) {
description.appendChild(document.createTextNode(',\n'));
} else {
description.appendChild(document.createElement('br'));
description.appendChild(document.createTextNode('\n'));
}
}
const nameSpan = document.createElement('span');
nameSpan.classList.add(className);
if (compact_mode) nameSpan.classList.add('compact');
nameSpan.innerText = name;
description.appendChild(nameSpan);
}
}
addNames(staff, 'staff');
description.appendChild(document.createElement('br'));
description.appendChild(document.createTextNode('\n'));
addNames(students, 'student');
if (this.notes) {
const notesSpan = document.createElement('span');
notesSpan.classList.add('notes');
notesSpan.innerText = this.notes;
if (compact_mode) {
notesSpan.classList.add('compact');
description.appendChild(document.createTextNode('\n'));
} else {
description.appendChild(document.createElement('br'));
}
description.appendChild(notesSpan);
}
}
return new Event({
'assignment': this,
'start_time': this.start_time,
'end_time': this.end_time,
'track': this.track,
'description': description,
'classes': classes,
'title': this.location,
});
}
}

@ -0,0 +1,32 @@
import * as utils from './utils.js';
export default class Event {
constructor(args) {
this.assignment = args.assignment;
this.start_time = args.start_time;
this.end_time = args.end_time;
this.track = args.track;
this.description = args.description ?? "ERROR: MISSING DESCRIPTION";
this.classes = args.classes ?? [];
this.title = args.title;
}
clone() {
return new Event(this);
}
get tracks() {
if (this.track === 'all') return [];
if (Array.isArray(this.track)) return this.track;
// TODO What if track is undefined? This probably isn't called then?
return [this.track];
}
get timeRange() {
return new utils.TimeRange(this);
}
at_same_time_as(other) {
return this.timeRange.overlaps(other.timeRange);
}
}

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Schedule Grid Editor</title>
<link rel="stylesheet" href="main.css" type="text/css">
<script type="module" src="./app.js"></script>
</head>
<body>
<p class="browserVersionWarning noprint">
This website requires
<a href="https://www.google.com/chrome/">Google Chrome 86+</a>
and does not work in Firefox due to using the
<a href="https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API">File System Access API</a>
to store the schedule data locally on your computer. Apologies for the
inconvenience.
</p>
</body>
</html>

@ -0,0 +1,184 @@
table {
padding: 0;
margin: 0;
}
table.schedule, table.schedule tr, table.schedule td {
border: solid black 1px;
border-collapse: collapse;
}
.location {
font-variant: small-caps;
font-size: 1em;
text-decoration: underline;
}
.staff {
font-weight: bold;
}
.staff a, .student a {
text-decoration: none;
}
td.compact {
font-size: .9em;
line-height: 1em;
}
td.location_only {
font-size: 2em;
text-align: center;
vertical-align: center;
}
td.location_only .location {
text-decoration: none;
}
td.location_only.compact {
font-size: 1em;
}
.notes {
left: 0;
bottom: 0.2em;
position: absolute;
}
table.schedule td.event {
position: relative;
vertical-align: top;
}
table.schedule, table.schedule td.event {
border: solid black 2px;
}
table.schedule {
width: 10in;
height: 7.5in;
table-layout: fixed;
font-size: .8em;
}
table.narrow {
width: 3in;
}
table.multiday {
border: none;
border-style: none;
width: 100%;
}
.own-page {
page-break-before: always;
}
h1 {
page-break-before: always;
text-align: center;
font-size: 0.8em;
margin: 0em;
}
h2 {
text-align: center;
font-size: 0.8em;
margin: 0em;
}
table.schedule tr, .row_time {
height: .9em;
}
table.schedule th, .row_time {
max-width: 6ex;
width: 6ex;
}
table.schedule th {
border-left: solid black 2px;
border-right: solid black 2px;
}
.row_time {
font-weight: normal;
}
.row_time.hour {
font-weight: bolder;
}
@media print {
.noprint {
display: none;
}
}
div#assignmentFormDiv {
position: fixed;
top: 0;
right: 0;
z-index: 3000;
height: calc(100% - 4px);
}
form#assignmentForm {
background: lightgreen;
border: 4px solid blue;
z-index: 3000;
overflow-y: scroll;
max-height: calc(100% - 4px);
}
table.peopleTable, table.peopleTable th, table.peopleTable td {
border: 1px solid white;
border-collapse: collapse;
background: white;
height: 100%;
}
table.peopleTable th label, table.peopleTable td label {
display: flex;
flex-direction: column;
text-align: center;
min-width: 2ex;
height: 100%;
vertical-align: middle;
justify-content: center;
}
table.peopleTable input {
display: none;
}
table.peopleTable input + label {
background: lightgray;
font-size: 0.5em;
height: 100%;
min-width: 4ex;
}
table.peopleTable input:checked + label {
background: limegreen;
font-size: 1em;
min-width: 2ex;
}
table.peopleTable th {
font-weight: bold;
}
input:invalid {
background: red;
}
table.schedule td.event.editing {
background: yellow;
border: solid gold 2px;
}
.warningsSection {
background: #c99;
}
#showWarnings + .warningHeader + .warnings {
display: none;
}
#showWarnings:checked + .warningHeader + .warnings {
display: block;
}
#showWarnings + .warningHeader label:before {
content: "[+] ";
font-family: monospace;
}
#showWarnings:checked + .warningHeader label:before {
content: "[-] ";
}
#showWarnings {
display: none;
}
.warningHeader {
color: darkred;
}
.warningHeader label {
cursor: pointer;
}

@ -0,0 +1,63 @@
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;
this.lastModified = obj.lastModified ?? new Date();
}
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,
}
}
fullGridFor(day) {
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 new ScheduleGrid({
'title': this.base_title + ' ' + utils.DayString(day) + ' schedule ['
+ timestampString(this.lastModified) + ']',
'granularity': this.granularity,
'start_time': this.start_time,
'end_time': this.end_time,
'single_column': false,
'events': this.assignments
.filter(a => a.days.includes(day))
.map(a => a.asEvent(day, this.granularity, false)),
})
}
}

@ -0,0 +1,174 @@
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()
if (args.single_column) {
// TODO Handle this.
}
}
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.
tracks.forEach((track, trackNum) => {
if (event.track === undefined) {
const hasOverlappingEvents = track.some(e => e.at_same_time_as(event));
if (!hasOverlappingEvents) {
track.push(event);
event.track = trackNum;
}
}
});
// No track had space for it, create a new track to put it in.
if (event.track === undefined) {
event.track = tracks.length;
tracks.push([event]);
}
}
// 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 events.flatMap(fixOverlappingAllTrackEvents);
}
get timeRange() {
return new utils.TimeRange(this);
}
get numRows() {
return this.timeRange.duration.dividedBy(this.granularity);
}
toHtml(tableClasses) {
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 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(rowTime) <= 0 && rowTime.cmp(e.end_time) < 0);
if (events.length === 0) {
row.appendChild(document.createElement('td'));
} else if (events.length > 1) {
throw "Overlapping events: " + events;
} else {
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
&& (!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);
row.appendChild(cell);
}
}
}
row.appendChild(timeCell.cloneNode(true));
table.appendChild(row);
}
return table;
}
}

@ -0,0 +1,108 @@
export class Time {
constructor(obj) {
this.hour = obj.hour ?? 0;
this.minute = obj.minute ?? 0;
this.second = obj.second ?? 0;
}
asJsonObject() {
let res = {}
if (this.hour !== 0) res.hour = this.hour;
if (this.minute !== 0) res.minute = this.minute;
if (this.second !== 0) res.second = this.second;
return res;
}
cmp(other) {
if (this.hour < other.hour) return -1;
if (this.hour > other.hour) return 1;
if (this.minute < other.minute) return -1;
if (this.minute > other.minute) return 1;
if (this.second < other.second) return -1;
if (this.second > other.second) return 1;
return 0;
}
get durationSinceMidnight() {
return new TimeRange({start_time: new Time({}), end_time: this}).duration;
}
plus(duration) {
const total = this.durationSinceMidnight.total_seconds
+ duration.total_seconds;
const second = total % 60;
const minute = Math.floor(total / 60) % 60;
const hour = Math.floor(total / 60 / 60) % 24;
return new Time({hour, minute, second});
}
// TODO Just ignoring seconds?
to12HourString() {
let hour = this.hour % 12;
if (hour === 0) hour = 12;
return hour + ':' + this.minute.toString().padStart(2, '0');
}
}
export class Duration {
constructor(obj) {
this.hour = obj.hour ?? 0;
this.minute = obj.minute ?? 0;
this.second = obj.second ?? 0;
this.total_seconds = ((this.hour * 60) + this.minute) * 60 + this.second;
}
asJsonObject() {
let res = {}
if (this.hour !== 0) res.hour = this.hour;
if (this.minute !== 0) res.minute = this.minute;
if (this.second !== 0) res.second = this.second;
return res;
}
dividedBy(other) {
return this.total_seconds / other.total_seconds;
}
times(num) {
const total = this.total_seconds * num;
const second = total % 60;
const minute = Math.floor(total / 60) % 60;
const hour = Math.floor(total / 60 / 60);
return new Duration({hour, minute, second});
}
}
export class TimeRange {
constructor(args) {
this.start_time = args.start_time;
this.end_time = args.end_time;
}
get duration() {
return new Duration({
'hour': this.end_time.hour - this.start_time.hour,
'minute': this.end_time.minute - this.start_time.minute,
'second': this.end_time.second - this.start_time.second,
});
}
overlaps(other) {
return (other.start_time.cmp(this.start_time) <= 0
&& this.start_time.cmp(other.end_time) < 0)
|| (this.start_time.cmp(other.start_time) <= 0
&& other.start_time.cmp(this.end_time) < 0);
}
}
export function DayString(day) {
return {
'M': 'Monday',
'T': 'Tuesday',
'W': 'Wednesday',
'R': 'Thursday',
'F': 'Friday',
'S': 'Saturday',
'U': 'Sunday',
}[day];
}
Loading…
Cancel
Save