schedule-grid-js/www/app.js

1260 lines
43 KiB
JavaScript

import { get as idb_get,
set as idb_set,
del as idb_del } from './idb-keyval.js';
import Assignment from './assignment.js';
import Schedule from './schedule.js';
import * as utils from './utils.js';
const openDirButton = document.getElementById('openDir');
const reopenDirButton = document.getElementById('reopenDir');
const closeDirButton = document.getElementById('closeDir');
const importButton = document.getElementById('import');
const importFileInput = document.getElementById('importFile');
const exportButton = document.getElementById('export');
const schedulesDiv = document.getElementById('allSchedules');
const scheduleSettingsDiv = document.getElementById('scheduleSettings');
const scheduleMetadataDiv = document.getElementById('scheduleMetadata')
const displaySettingsDiv = document.getElementById('displaySettings');
const displayDays = document.getElementById('displayDays');
const displayPeople = document.getElementById('displayPeople');
const assignmentFormDiv = document.getElementById('assignmentFormDiv');
const assignmentForm = document.getElementById('assignmentForm');
const assignmentSelector = assignmentForm.assignments;
const peopleTable = document.getElementById('peopleTable');
let peopleCheckboxes = undefined;
let selectedAssignment = undefined;
const personInfoDiv = document.getElementById('personInfo');
const personStartTime = document.getElementById('person_start_time');
const personEndTime = document.getElementById('person_end_time');
const personDays = document.getElementById('person_days');
const warningsSection = document.getElementById('warningsSection');
const warningsDiv = document.getElementById('warnings');
const errorTitle = "ERROR loading schedule; try a backup";
let dirHandle = null;
let backupsDir = null;
let fileHandle = null;
let basefileHandle = null;
let schedule = null;
let hash = {};
try {
if (window.location.hash) {
hash = JSON.parse(decodeURIComponent(window.location.hash.substring(1)));
}
} catch (SyntaxError) {
// Ignore invalid hash.
hash = {};
}
function updateHash(obj) {
for (const key in obj) {
hash[key] = obj[key];
}
window.location.hash = JSON.stringify(hash);
}
function closeDir() {
dirHandle = null;
idb_del('dirHandle');
openDirButton.style.display = '';
closeDirButton.style.display = 'none';
window.location.reload();
}
async function saveSchedule() {
if (fileHandle !== basefileHandle) {
alert("Cannot modify backups. This should not be reachable.");
return;
}
schedule.lastModified = new Date();
const writable = await basefileHandle.createWritable();
await writable.write(JSON.stringify(schedule.asJsonObject(), null, 2));
await writable.close();
await refreshDisplay();
}
async function updateBackupsList() {
backupsDir = await dirHandle.getDirectoryHandle(
basefileHandle.name.slice(0, -5), {create: true});
const backupsList = document.getElementById('backups');
utils.clearChildren(backupsList);
const options = [];
for await (const entry of backupsDir.values()) {
if (entry.kind === 'file' && entry.name.endsWith('.json')) {
const fileObj = await entry.getFile()
const timestamp = fileObj.lastModified;
const option = document.createElement('option');
option.innerText = entry.name.slice(0, -5) + " ["
+ new Date(timestamp) + "]";
option.timestamp = timestamp;
option.file = entry;
option.isBackup = true;
if (entry.name === fileHandle.name) option.selected = true;
options.push(option);
}
}
options.sort((a, b) => b.timestamp - a.timestamp);
const current = document.createElement('option');
const currentTimestamp = (await basefileHandle.getFile()).lastModified;
current.innerText = "(latest) [" + new Date(currentTimestamp) + "]";
current.timestamp = currentTimestamp;
current.file = basefileHandle;
current.isBackup = false;
options.unshift(current);
options.forEach(opt => backupsList.appendChild(opt));
}
async function createBackup(force) {
let name = document.getElementById('backupName').value;
let attempts = 0;
const dateName = new Date().toISOString().replaceAll(':', '-');
if (name.length === 0) {
name = dateName;
attempts++;
}
let file = null;
do {
file = await backupsDir.getFileHandle(name + '.json', {create: true});
if ((await file.getFile()).size !== 0) {
if (!force) {
alert("Failed to create backup. Backup \"" + name + "\" already exists.");
return;
} else {
file = null;
name = dateName + "-" + attempts++;
}
}
} while (!file);
const writable = await file.createWritable();
await writable.write(JSON.stringify(schedule.asJsonObject(), null, 2));
await writable.close();
await updateBackupsList();
}
async function restoreBackup() {
if (!hash.backup) return;
// swap back to base file...
const backupFile = fileHandle;
await loadFile(basefileHandle, false);
// ... create a new backup...
await createBackup(true);
// ... then reload the backup file...
await loadFile(backupFile, true);
// ... and save over the base file.
fileHandle = basefileHandle;
await saveSchedule();
await loadFile(basefileHandle, false);
}
document.getElementById('createBackup').addEventListener('click', async e => {
createBackup();
});
document.getElementById('restoreBackup').addEventListener('click', async e => {
restoreBackup();
});
function download(filename, text, mimeType) {
const element = document.createElement('a');
element.setAttribute('href', 'data:' + mimeType + ',' + encodeURIComponent(text));
element.setAttribute('download', filename);
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}
async function exportToFile() {
const name = basefileHandle.name.slice(0, -5);
const dateName = new Date().toISOString().replaceAll(':', '-');
download(name + '-' + dateName + '.json',
JSON.stringify(schedule.asJsonObject(), null, 2),
'application/json');
}
async function importFromFile() {
const fileList = importFileInput.files;
const file = fileList[0];
const reader = new FileReader();
if (hash.backup) {
// swap back to base file...
await loadFile(basefileHandle, false);
}
// ... create a new backup...
await createBackup(true);
reader.onload = async (e) => {
const json = e.target.result;
schedule = new Schedule(JSON.parse(json));
schedule.lastModified = file.lastModified;
await saveSchedule();
await loadFile(basefileHandle, false);
}
reader.readAsText(file);
}
exportButton.addEventListener('click', async e => {
exportToFile();
});
importButton.addEventListener('click', async e => {
importFileInput.click();
});
importFileInput.addEventListener('change', async e => {
importFromFile();
});
async function cloneSchedule(includeAssignments) {
let name = document.getElementById('cloneName').value;
if (name.length === 0) {
name = new Date().toISOString().replaceAll(':', '-');
}
const file = await dirHandle.getFileHandle(name + '.json', {create: true});
if ((await file.getFile()).size !== 0) {
alert("Failed to create new schedule. Schedule \"" + name
+ "\" already exists.");
return;
}
const writable = await file.createWritable();
if (!includeAssignments) schedule.assignments = [];
await writable.write(JSON.stringify(schedule.asJsonObject(), null, 2));
await writable.close();
window.location.reload();
}
document.getElementById('cloneSch').addEventListener('click', async e => {
await cloneSchedule(true);
});
document.getElementById('cloneSchNoAssignments').addEventListener('click', async e => {
await cloneSchedule(false);
});
const titleInput = document.getElementById('title');
const schStartTime = document.getElementById('sch_start_time')
const schEndTime = document.getElementById('sch_end_time')
const schGranularity = document.getElementById('granularity')
let allowEdits = false;
async function updateScheduleSettings() {
await updateBackupsList();
titleInput.value = schedule.base_title;
schStartTime.value = schedule.start_time.inputValue;
schEndTime.value = schedule.end_time.inputValue;
schGranularity.value = schedule.granularity.inputValue;
const daysSpan = document.getElementById('sch-days');
utils.clearChildren(daysSpan);
for (const day of utils.allDays) {
const dayLabel = document.createElement('label');
const dayName = utils.DayString(day);
const dayCheckbox = document.createElement('input');
dayCheckbox.type = 'checkbox';
dayCheckbox.checked = schedule.all_days.includes(day);
dayCheckbox.addEventListener('change', async e => {
if (!e.target.checked) {
if (schedule.assignments.some(a => day in a.people_by_day)) {
alert("Cannot remove schedule for " + dayName
+ " because there are events on that day.");
e.target.checked = true;
return;
}
schedule.all_days.splice(schedule.all_days.indexOf(day), 1);
} else {
schedule.all_days = utils.allDays.filter(d => schedule.all_days.includes(d) || d === day)
}
await saveSchedule();
});
dayLabel.appendChild(dayCheckbox);
dayLabel.appendChild(document.createTextNode(dayName));
daysSpan.appendChild(dayLabel);
}
scheduleMetadataDiv
.querySelectorAll('*')
.forEach(el => el.disabled = !allowEdits);
schedule.recomputeNotAssigned();
const staffList = document.getElementById('removeStaff');
utils.clearChildren(staffList);
schedule.all_staff_teachers_first.forEach(name => {
const option = document.createElement('option');
option.value = name;
option.innerText = (schedule.assignedStaff.has(name) ? '(!) ' : '') + name;
staffList.appendChild(option);
});
const canDelStaff = allowEdits;
staffList.disabled = !canDelStaff;
document.getElementById('delStaff').disabled = !canDelStaff;
const studentList = document.getElementById('removeStudent');
utils.clearChildren(studentList);
schedule.all_students.forEach(name => {
const option = document.createElement('option');
option.value = name;
option.innerText = (schedule.assignedStudents.has(name) ? '(!) ' : '') + name;
studentList.appendChild(option);
});
const canDelStudent = allowEdits;
studentList.disabled = !canDelStudent;
document.getElementById('delStudent').disabled = !canDelStudent;
updateToTeacherButtons();
scheduleSettingsDiv.style.display = '';
}
async function loadFile(file, isBackup) {
const name = file.name.slice(0, -5);
fileHandle = file;
if (!isBackup) {
basefileHandle = fileHandle;
updateHash({backup: undefined, schedule: name});
} else {
updateHash({backup: name});
}
document.getElementById('restoreBackup').disabled = !isBackup;
allowEdits = !isBackup;
const fileObj = await file.getFile();
try {
schedule = new Schedule(JSON.parse(await fileObj.text()));
schedulesDiv.style.display = '';
scheduleMetadataDiv.style.display = '';
displaySettingsDiv.style.display = '';
} catch {
schedule = new Schedule({
title: errorTitle,
assignments: [],
all_days: ['M', 'T', 'W', 'R', 'F'],
start_time: { hour: 9 },
end_time: { hour: 17 },
});
schedulesDiv.style.display = 'none';
scheduleMetadataDiv.style.display = 'none';
displaySettingsDiv.style.display = 'none';
}
schedule.lastModified = fileObj.lastModified;
await refreshDisplay();
}
document.getElementById('schedules').addEventListener('change', async e => {
loadFile(e.target.selectedOptions[0].file, false);
});
document.getElementById('backups').addEventListener('change', async e => {
const opt = e.target.selectedOptions[0];
loadFile(opt.file, opt.isBackup);
});
async function loadDir(dir) {
dirHandle = dir;
openDirButton.style.display = 'none';
reopenDirButton.style.display = 'none';
closeDirButton.style.display = '';
const schedulesList = document.getElementById('schedules');
utils.clearChildren(schedulesList);
const options = [];
let newest = undefined;
let newestTimestamp = undefined;
for await (const entry of dir.values()) {
if (entry.kind === 'file' && !entry.name.startsWith('.') && entry.name.endsWith('.json')) {
const fileObj = await entry.getFile()
const timestamp = fileObj.lastModified;
if (!newestTimestamp || timestamp > newestTimestamp) {
newest = entry;
newestTimestamp = timestamp;
}
const option = document.createElement('option');
let title;
try {
title = JSON.parse(await fileObj.text()).title;
} catch {
title = errorTitle;
}
const name = entry.name.slice(0, -5);
option.innerText = title + " (" + name + ") ["
+ new Date(timestamp) + "]";
option.timestamp = timestamp;
option.file = entry;
option.value = name;
options.push(option);
}
}
options.sort((a, b) => b.timestamp - a.timestamp).forEach(opt =>
schedulesList.appendChild(opt));
if (newest === undefined) {
const file = await dirHandle.getFileHandle('default.json', {create: true});
const writable = await file.createWritable();
await writable.write(JSON.stringify(new Schedule({
title: "DEFAULT TITLE",
assignments: [],
all_days: ['M', 'T', 'W', 'R', 'F'],
start_time: { hour: 9 },
end_time: { hour: 17 },
}).asJsonObject(), null, 2));
await writable.close();
window.location.reload();
return;
}
if (hash.schedule) {
const schOpt = options.find(o => o.value === hash.schedule);
if (schOpt) {
schOpt.selected = true;
// If there's an invalid backup name, just load the base schedule.
basefileHandle = newest = schOpt.file;
if (hash.backup) {
backupsDir = await dirHandle.getDirectoryHandle(
basefileHandle.name.slice(0, -5), {create: true});
const backupFile = await backupsDir
.getFileHandle(hash.backup + '.json');
if (backupFile) {
await loadFile(backupFile, true);
return;
}
}
}
}
await loadFile(newest, false);
}
if (window.showDirectoryPicker) {
document.getElementById('browserVersionWarning').style.display = 'none';
idb_get('dirHandle').then(async dir => {
if (dir) {
if (await dir.queryPermission({mode: 'readwrite'}) === 'granted') {
await loadDir(dir);
} else {
reopenDirButton.style.display = '';
reopenDirButton.addEventListener('click', async e => {
if (await dir.requestPermission({mode: 'readwrite'}) === 'granted') {
await loadDir(dir);
}
reopenDirButton.style.display = 'none';
});
}
}
});
closeDirButton.addEventListener('click', e => closeDir());
openDirButton.style.display = '';
openDirButton.addEventListener('click', async e => {
const dir = await window.showDirectoryPicker({mode: 'readwrite'});
if (dir) {
await idb_set('dirHandle', dir);
await loadDir(dir);
}
});
}
else {
// Can't use real file system, so use OPFS instead.
await loadDir(await navigator.storage.getDirectory());
closeDirButton.addEventListener('click', e => closeDir());
}
function displayFullSchedule() {
(hash.day ? [hash.day] : schedule.all_days)
.forEach(day => {
const grid = schedule.fullGridFor(day);
const title = document.createElement('h1');
title.innerText = schedule.titleFor(null, day, hash.backup);
schedulesDiv.appendChild(title);
schedulesDiv.appendChild(grid.toHtml([], allowEdits ? selectAssignment : null));
})
}
function displayIndividualSchedule(person) {
if (person.kind === 'staff' && !schedule.all_staff.includes(person.name)
|| person.kind === 'student' && !schedule.all_students.includes(person.name)) {
updateHash({person: undefined});
displaySchedule();
return;
}
if (hash.day) {
const grid = schedule.gridFor(person, hash.day);
const title = document.createElement('h1');
title.innerText = schedule.titleFor(person, hash.day, hash.backup);
schedulesDiv.appendChild(title);
schedulesDiv.appendChild(grid.toHtml([], allowEdits ? selectAssignment : null));
} else {
const title = document.createElement('h1');
title.innerText = schedule.titleFor(person, null, hash.backup);
schedulesDiv.appendChild(title);
const daysTable = document.createElement('table');
daysTable.classList.add('multiday');
const row = document.createElement('tr');
(schedule.infoFor(person).days ?? schedule.all_days)
.forEach(day => {
const cell = document.createElement('td');
const grid = schedule.gridFor(person, day, hash.backup);
const dayTitle = document.createElement('h2');
dayTitle.innerText = person.name + ' on ' + utils.DayString(day);
cell.appendChild(dayTitle);
cell.appendChild(grid.toHtml(['narrow'], allowEdits ? selectAssignment : null));
row.appendChild(cell);
});
daysTable.appendChild(row);
schedulesDiv.appendChild(daysTable);
}
}
function updateDisplayOptions() {
utils.clearChildren(displayDays);
for (const day of [undefined, ...schedule.all_days]) {
const dayOption = document.createElement('option');
dayOption.value = day;
dayOption.dayHash = day;
dayOption.selected = hash.day === day;
dayOption.innerText = day ? utils.DayString(day) : "All Days";
displayDays.appendChild(dayOption);
}
utils.clearChildren(displayPeople);
for (const person of ['(full)', null,
'All Teachers', 'All Staff', 'All Students', null,
...schedule.all_teachers
.map(name => ({kind: 'staff', name})), null,
...schedule.all_non_teacher_staff
.map(name => ({kind: 'staff', name})), null,
...schedule.all_students
.map(name => ({kind: 'student', name}))]) {
const personOption = document.createElement('option');
if (person) {
personOption.personHash = person === '(full)' ? undefined : person;
personOption.selected = !hash.person
? person === '(full)'
: JSON.stringify(hash.person) === JSON.stringify(person);
personOption.innerText = person.name ?? person;
} else {
personOption.disabled = true;
personOption.innerText = "--------";
}
displayPeople.appendChild(personOption);
}
displaySettingsDiv.style.display = '';
}
displayDays.addEventListener('change', e => {
updateHash({day: e.target.selectedOptions[0].dayHash});
displaySchedule();
});
displayPeople.addEventListener('change', e => {
updateHash({person: e.target.selectedOptions[0].personHash});
displaySchedule();
});
async function refreshDisplay() {
await updateScheduleSettings();
updateDisplayOptions();
updateAssignmentEditor();
displayWarnings();
displaySchedule();
}
function displayWarnings() {
utils.clearChildren(warningsDiv);
const allWarningGroups = schedule.generateWarnings();
warningsSection.style.display = allWarningGroups.length === 0 ? 'none' : '';
for (const warningGroup of allWarningGroups) {
if (!warningGroup.person) {
const groupHeader = document.createElement('h5');
groupHeader.innerText = 'Events';
warningsDiv.appendChild(groupHeader);
} else {
const groupHeader = document.createElement('h5');
groupHeader.innerText = warningGroup.person.name;
groupHeader.classList.add('clickable');
groupHeader.addEventListener('click', e => {
updateHash({person: warningGroup.person});
[...displayPeople.getElementsByTagName('option')]
.find(o => o.personHash
&& o.personHash.name === warningGroup.person.name
&& o.personHash.kind === warningGroup.person.kind)
.selected = true;
displaySchedule();
});
warningsDiv.appendChild(groupHeader);
}
const list = document.createElement('ul');
for (const warning of warningGroup.warnings) {
const el = document.createElement('li');
el.appendChild(document.createTextNode(warning.message));
let seenFirst = false;
for (const a of warning.assignments) {
el.appendChild(document.createTextNode(seenFirst ? ', ' : ': '));
seenFirst = true;
const aLink = document.createElement('a');
aLink.innerText = a.toString();
if (allowEdits) {
aLink.classList.add('clickable');
aLink.addEventListener('click', e => selectAssignment(a));
}
el.appendChild(aLink);
}
list.appendChild(el);
}
warningsDiv.appendChild(list);
}
}
function displayPersonInfoDiv() {
const singlePerson = hash.person && hash.person.name;
personInfoDiv.style.display = singlePerson ? '' : 'none';
if (!singlePerson) return;
const info = schedule.infoFor(hash.person);
personStartTime.value = new utils.Time(info.start_time ?? schedule.start_time).inputValue;
personEndTime.value = new utils.Time(info.end_time ?? schedule.end_time).inputValue;
personStartTime.step = schedule.granularity.total_seconds;
personEndTime.step = schedule.granularity.total_seconds;
utils.clearChildren(personDays);
for (const day of utils.allDays.filter(d => schedule.all_days.includes(d)
|| info.days && info.days.includes(d))) {
const dayLabel = document.createElement('label');
const dayName = utils.DayString(day);
const dayCheckbox = document.createElement('input');
dayCheckbox.type = 'checkbox';
dayCheckbox.checked = (info.days ?? schedule.all_days).includes(day);
dayCheckbox.addEventListener('change', async e => {
let newDays = info.days;
if (!e.target.checked) {
if (schedule.assignments.some(a => a.hasPersonExplicitlyOnDay(hash.person, day))) {
alert("Cannot remove schedule for " + dayName
+ " because " + hash.person.name
+ " is in events on that day.");
e.target.checked = true;
return;
}
if (!newDays) newDays = [...schedule.all_days];
newDays.splice(schedule.all_days.indexOf(day), 1);
} else {
newDays = schedule.all_days.filter(d => (info.days ?? schedule.all_days).includes(d) || d === day);
}
if (newDays && utils.setEqual(newDays, schedule.all_days)) {
newDays = undefined;
}
schedule.setInfoFor(hash.person, {days: newDays});
await saveSchedule();
});
dayLabel.appendChild(dayCheckbox);
dayLabel.appendChild(document.createTextNode(dayName));
personDays.appendChild(dayLabel);
}
}
personStartTime.addEventListener('change', utils.debounceAsync(async e => {
const newStartTime = utils.Time.fromInputValue(e.target.value);
const assignments = schedule.assignments.filter(a => a.hasPersonExplicitlyOnAnyDay(hash.person));
if (assignments.length > 0) {
const earliestStart = assignments.map(a => a.start_time).sort((a, b) => a.cmp(b))[0];
if (earliestStart.cmp(newStartTime) < 0) {
alert("Cannot change start time to " + newStartTime.to12HourString()
+ " because it is after the earliest assignment for "
+ hash.person.name + " starts at "
+ earliestStart.to12HourString() + ".");
return;
}
}
schedule.setInfoFor(hash.person, {start_time:
newStartTime.cmp(schedule.start_time) !== 0
? newStartTime.asJsonObject()
: undefined});
await saveSchedule();
}, 1000));
personEndTime.addEventListener('change', utils.debounceAsync(async e => {
const newEndTime = utils.Time.fromInputValue(e.target.value);
const assignments = schedule.assignments.filter(a => a.hasPersonExplicitlyOnAnyDay(hash.person));
if (assignments.length > 0) {
const latestEnd = assignments.map(a => a.end_time).sort((a, b) => b.cmp(a))[0];
if (latestEnd.cmp(newEndTime) > 0) {
alert("Cannot change end time to " + newEndTime.to12HourString()
+ " because it is before the latest assignment for "
+ hash.person.name + " ends at "
+ latestEnd.to12HourString() + ".");
return;
}
}
schedule.setInfoFor(hash.person, {end_time:
newEndTime.cmp(schedule.end_time) !== 0
? newEndTime.asJsonObject()
: undefined});
await saveSchedule();
}, 1000));
function displaySchedule() {
displayPersonInfoDiv();
utils.clearChildren(schedulesDiv);
if (hash.day && !schedule.all_days.includes(hash.day)) {
updateHash({day: undefined});
}
if (!hash.person) {
displayFullSchedule();
} else if (hash.person.kind) {
displayIndividualSchedule(hash.person);
} else if (hash.person === 'All Teachers') {
schedule.all_teachers.forEach(name =>
displayIndividualSchedule({kind: 'staff', name}));
} else if (hash.person === 'All Staff') {
schedule.all_staff.forEach(name =>
displayIndividualSchedule({kind: 'staff', name}));
} else if (hash.person === 'All Students') {
schedule.all_students.forEach(name =>
displayIndividualSchedule({kind: 'student', name}));
} else {
updateHash({person: undefined});
displayFullSchedule();
}
}
document.getElementById('changeTitle').addEventListener('click', async e => {
schedule.base_title = document.getElementById('title').value;
await saveSchedule();
});
schStartTime.addEventListener('change', utils.debounceAsync(async e => {
const newStartTime = utils.Time.fromInputValue(e.target.value);
if (schedule.assignments) {
const earliestStart = schedule.assignments.map(a => a.start_time).sort((a, b) => a.cmp(b))[0];
if (earliestStart.cmp(newStartTime) < 0) {
alert("Cannot change start time to " + newStartTime.to12HourString()
+ " because it is after the earliest assignment starts at "
+ earliestStart.to12HourString() + ".");
return;
}
}
schedule.start_time = newStartTime;
await saveSchedule();
}, 1000));
schEndTime.addEventListener('change', utils.debounceAsync(async e => {
const newEndTime = utils.Time.fromInputValue(e.target.value);
if (schedule.assignments) {
const latestEnd = schedule.assignments.map(a => a.end_time).sort((a, b) => b.cmp(a))[0];
if (latestEnd.cmp(newEndTime) > 0) {
alert("Cannot change end time to " + newEndTime.to12HourString()
+ " because it is before the latest assignment ends at "
+ latestEnd.to12HourString() + ".");
return;
}
}
schedule.end_time = newEndTime;
await saveSchedule();
}, 1000));
schGranularity.addEventListener('change', utils.debounceAsync(async e => {
const newGranularity = utils.Time.fromInputValue(e.target.value)
.durationSinceMidnight;
const schDuration = schedule.timeRange.duration;
const newNumRows = schDuration.dividedBy(newGranularity);
if (!Number.isInteger(newNumRows)) {
alert("Cannot change granularity to " + newGranularity.toString()
+ " because it is does not divide the " + schDuration.toString()
+ " duration from " + schedule.start_time.to12HourString()
+ " to " + schedule.end_time.to12HourString() + ".");
return;
}
schedule.granularity = newGranularity;
await saveSchedule();
}, 1000));
const personInput = document.getElementById('person');
document.getElementById('addStudent').addEventListener('click', async e => {
schedule.addStudent(personInput.value);
personInput.value = '';
await saveSchedule();
});
document.getElementById('addStaff').addEventListener('click', async e => {
schedule.addStaff(personInput.value);
personInput.value = '';
await saveSchedule();
});
document.getElementById('addTeacher').addEventListener('click', async e => {
schedule.addTeacher(personInput.value);
personInput.value = '';
await saveSchedule();
});
document.getElementById('delStudent').addEventListener('click', async e => {
const name = document.getElementById('removeStudent').value;
const person = {name, kind: 'student'};
if (schedule.assignedStudents.has(name)) {
if (!window.confirm("Are you sure you want to delete the student " + name
+ " who is still assigned to events?")) {
return;
}
schedule.assignments.filter(a => a.hasPersonExplicitlyOnAnyDay(person))
.forEach(a => a.removePerson(person));
schedule.recomputeNotAssigned();
}
schedule.delStudent(name);
await saveSchedule();
});
document.getElementById('delStaff').addEventListener('click', async e => {
const name = document.getElementById('removeStaff').value;
const person = {name, kind: 'staff'};
if (schedule.assignedStudents.has(name)) {
if (!window.confirm("Are you sure you want to delete the staff person "
+ name + " who is still assigned to events?")) {
return;
}
schedule.assignments.filter(a => a.hasPersonExplicitlyOnAnyDay(person))
.forEach(a => a.removePerson(person));
schedule.recomputeNotAssigned();
}
schedule.delStaff(name);
await saveSchedule();
});
document.getElementById('renameStudent').addEventListener('click', async e => {
const newName = document.getElementById('nameStudent').value;
if (schedule.all_students.includes(newName)) {
window.alert("A student named " + newName + " already exists.");
return;
}
const name = document.getElementById('removeStudent').value;
const person = {name, kind: 'student'};
schedule.all_students.splice(schedule.all_students.indexOf(name), 1);
schedule.all_students.push(newName);
schedule.all_students.sort();
schedule.assignments.filter(a => a.hasPersonExplicitlyOnAnyDay(person))
.forEach(a => a.renamePerson(person, newName));
schedule.recomputeNotAssigned();
await saveSchedule();
});
document.getElementById('renameStaff').addEventListener('click', async e => {
const newName = document.getElementById('nameStaff').value;
if (schedule.all_staff.includes(newName)) {
window.alert("A staff person named " + newName + " already exists.");
return;
}
const name = document.getElementById('removeStaff').value;
const person = {name, kind: 'staff'};
if (schedule.all_teachers.includes(name)) {
schedule.all_teachers.splice(schedule.all_teachers.indexOf(name), 1);
schedule.all_teachers.push(newName);
schedule.all_teachers.sort();
}
schedule.all_staff.splice(schedule.all_staff.indexOf(name), 1);
schedule.all_staff.push(newName);
schedule.all_staff.sort();
schedule.assignments.filter(a => a.hasPersonExplicitlyOnAnyDay(person))
.forEach(a => a.renamePerson(person, newName));
schedule.recomputeNotAssigned();
await saveSchedule();
});
function updateToTeacherButtons() {
const staffOptions = document.getElementById('removeStaff').selectedOptions;
const isTeacher = staffOptions.length > 0 && schedule.all_teachers.includes(
staffOptions[0].value);
document.getElementById('toTeacher').style.display = isTeacher ? 'none' : '';
document.getElementById('toStaff').style.display = isTeacher ? '' : 'none';
}
document.getElementById('removeStaff').addEventListener('change', updateToTeacherButtons);
document.getElementById('toTeacher').addEventListener('click', async e => {
const name = document.getElementById('removeStaff').value;
schedule.all_teachers.push(name);
schedule.all_teachers.sort();
await saveSchedule();
[...document.getElementById('removeStaff').getElementsByTagName('option')]
.forEach(o => o.selected = o.value === name);
updateToTeacherButtons();
});
document.getElementById('toStaff').addEventListener('click', async e => {
const name = document.getElementById('removeStaff').value;
schedule.all_teachers.splice(schedule.all_teachers.indexOf(name), 1);
await saveSchedule();
[...document.getElementById('removeStaff').getElementsByTagName('option')]
.forEach(o => o.selected = o.value === name);
updateToTeacherButtons();
});
assignmentSelector.addEventListener('change', e => {
initializeAssignmentForm(e.target.selectedOptions[0].assignment);
});
document.getElementById('delAssignment').addEventListener('click', async e => {
if (window.confirm("Are you sure you want to delete "
+ selectedAssignment + "?")) {
schedule.assignments.splice(
schedule.assignments.indexOf(selectedAssignment), 1);
await saveSchedule();
}
});
document.getElementById('newAssignment').addEventListener('click', async e => {
const newAssignment = new Assignment({
location: "enter event location",
start_time: schedule.start_time,
end_time: schedule.end_time,
days: schedule.all_days,
});
schedule.assignments.push(newAssignment);
selectedAssignment = newAssignment;
await saveSchedule();
});
function updateAssignmentEditor() {
if (!allowEdits) {
assignmentFormDiv.style.display = 'none';
return;
}
assignmentFormDiv.style.display = '';
utils.clearChildren(assignmentSelector);
let currentAssignment = schedule.assignments.length === 0
? undefined
: schedule.assignments[0];
schedule.assignments.forEach(a => {
const option = document.createElement('option');
option.innerText = a.toString();
option.assignment = a;
if (a === selectedAssignment) {
currentAssignment = a;
option.selected = true;
}
assignmentSelector.appendChild(option);
});
assignmentForm.start_time.step = schedule.granularity.total_seconds;
assignmentForm.end_time.step = schedule.granularity.total_seconds;
rebuildPeopleTable();
if (currentAssignment) initializeAssignmentForm(currentAssignment);
else selectedAssignment = undefined;
}
function rebuildPeopleTable() {
// Check if necessary.
if (peopleCheckboxes
&& utils.setEqual(schedule.all_days, Object.keys(peopleCheckboxes.days))
&& utils.setEqual(schedule.all_teachers, peopleCheckboxes.teachers)
&& utils.setEqual(schedule.all_staff, Object.keys(peopleCheckboxes.staff))
&& utils.setEqual(schedule.all_students, Object.keys(peopleCheckboxes.students))) {
return;
}
peopleCheckboxes = { days: {}, staff: {}, students: {},
teachers: [...schedule.all_teachers] };
utils.clearChildren(peopleTable);
const tbody = document.createElement('tbody');
const daysRow = document.createElement('tr');
daysRow.appendChild(document.createElement('th'));
for (const day of schedule.all_days) {
const dayHeader = document.createElement('th');
const dayCheckbox = document.createElement('input');
const dayLabel = document.createElement('label');
dayCheckbox.type = 'checkbox';
dayCheckbox.name = 'days';
dayCheckbox.value = day;
dayCheckbox.id = day;
dayCheckbox.classList.add('day');
dayCheckbox.addEventListener('change', async e => {
if (e.target.checked) {
// All staff/students on any day should be included on this new day.
selectedAssignment.people_by_day[day] = {
staff: [...selectedAssignment.all_staff],
students: [...selectedAssignment.all_students],
};
} else {
delete selectedAssignment.people_by_day[day];
}
selectedAssignment.days = schedule.all_days
.filter(d => d in selectedAssignment.people_by_day);
await saveSchedule();
});
peopleCheckboxes.days[day] = dayCheckbox;
dayLabel.htmlFor = dayCheckbox.id;
dayLabel.innerText = day;
dayLabel.title = utils.DayString(day);
dayHeader.appendChild(dayCheckbox);
dayHeader.appendChild(dayLabel);
daysRow.appendChild(dayHeader);
}
tbody.appendChild(daysRow);
function addPeopleKindHeader(kind) {
const headerRow = document.createElement('tr');
const header = document.createElement('th');
header.innerText = kind;
header.colSpan = schedule.all_days.length + 1;
headerRow.appendChild(header);
tbody.appendChild(headerRow);
}
let numPeople = 0;
function addPersonRow(name, kind) {
const idNum = ++numPeople;
const id = "person" + idNum;
const row = document.createElement('tr');
const personCell = document.createElement('th');
const personCheckbox = document.createElement('input');
const personLabel = document.createElement('label');
personCheckbox.type = 'checkbox';
personCheckbox.name = 'people';
personCheckbox.value = name;
personCheckbox.id = id;
personCheckbox.classList.add('person');
personCheckbox.addEventListener('change', async e => {
if (e.target.checked) {
// All days should be included for this new person.
for (const people_for_day of Object.values(selectedAssignment.people_by_day)) {
const list = people_for_day[kind];
list.push(name);
list.sort();
}
selectedAssignment['all_' + kind].add(name);
} else {
for (const people_for_day of Object.values(selectedAssignment.people_by_day)) {
const list = people_for_day[kind];
list.splice(list.indexOf(name), 1);
}
selectedAssignment['all_' + kind].delete(name);
}
await saveSchedule();
});
peopleCheckboxes[kind][name] = { all: personCheckbox, days: {} }
personLabel.htmlFor = personCheckbox.id;
personLabel.innerText = name;
personLabel.title = name;
personCell.appendChild(personCheckbox);
personCell.appendChild(personLabel);
row.appendChild(personCell);
for (const day of schedule.all_days) {
const personDayCell = document.createElement('td');
const personDayCheckbox = document.createElement('input');
const personDayLabel = document.createElement('label');
personDayCheckbox.type = 'checkbox';
personDayCheckbox.name = day + kind;
personDayCheckbox.value = name;
personDayCheckbox.id = day + idNum;
personDayCheckbox.classList.add('person_for_day');
personDayCheckbox.classList.add('person_for_' + day);
personDayCheckbox.classList.add(id);
personDayCheckbox.addEventListener('change', async e => {
if (e.target.checked) {
if (!(day in selectedAssignment.people_by_day)) {
selectedAssignment.people_by_day[day] = {staff: [], students: []};
selectedAssignment.days = schedule.all_days
.filter(d => d in selectedAssignment.people_by_day);
}
const list = selectedAssignment.people_by_day[day][kind];
list.push(name);
list.sort();
selectedAssignment['all_' + kind].add(name);
} else {
const list = selectedAssignment.people_by_day[day][kind];
list.splice(list.indexOf(name), 1);
if (!Object.values(selectedAssignment.people_by_day)
.some(people_for_day => people_for_day[kind].includes(name))) {
selectedAssignment['all_' + kind].delete(name);
}
}
await saveSchedule();
});
peopleCheckboxes[kind][name].days[day] = personDayCheckbox;
personDayLabel.htmlFor = personDayCheckbox.id;
personDayLabel.title = name + ' on ' + utils.DayString(day);
personDayCell.appendChild(personDayCheckbox);
personDayCell.appendChild(personDayLabel);
row.appendChild(personDayCell);
}
tbody.appendChild(row);
}
addPeopleKindHeader("Teachers");
schedule.all_teachers.forEach(name => addPersonRow(name, 'staff'));
addPeopleKindHeader("Staff");
utils.setDifference(schedule.all_staff, schedule.all_teachers)
.forEach(name => addPersonRow(name, 'staff'));
addPeopleKindHeader("Students");
schedule.all_students.forEach(name => addPersonRow(name, 'students'));
peopleTable.appendChild(tbody);
}
const assignmentFoldCheckbox = document.getElementById('showAssignmentForm');
function selectAssignment(assignment) {
assignmentFoldCheckbox.checked = true;
[...assignmentSelector.getElementsByTagName('option')]
.filter(o => o.assignment === assignment)
.forEach(o => o.selected = true);
initializeAssignmentForm(assignment);
}
function initializeAssignmentForm(assignment) {
selectedAssignment = assignment;
assignmentForm.location.value = assignment.location;
assignmentForm.start_time.value = assignment.start_time.inputValue;
assignmentForm.end_time.value = assignment.end_time.inputValue;
assignmentForm.squishable.value = assignment.squishable;
assignmentForm.width.value = assignment.width;
assignmentForm.track.value = Array.isArray(assignment.track)
? assignment.track[0]
: (assignment.track ?? "auto");
assignmentForm.notes.value = assignment.notes ?? "";
for (const day of schedule.all_days) {
peopleCheckboxes.days[day].checked = day in assignment.people_by_day;
}
for (const staff of schedule.all_staff) {
peopleCheckboxes.staff[staff].all.checked = assignment.all_staff.has(staff);
for (const day of schedule.all_days) {
peopleCheckboxes.staff[staff].days[day].checked =
day in assignment.people_by_day
&& assignment.people_by_day[day]['staff'].includes(staff);
}
}
for (const student of schedule.all_students) {
peopleCheckboxes.students[student].all.checked = assignment.all_students.has(student);
for (const day of schedule.all_days) {
peopleCheckboxes.students[student].days[day].checked =
day in assignment.people_by_day
&& assignment.people_by_day[day]['students'].includes(student);
}
}
}
assignmentForm.location.addEventListener('change', async e => {
selectedAssignment.location = e.target.value;
await saveSchedule();
});
assignmentForm.start_time.addEventListener('change', utils.debounceAsync(async e => {
selectedAssignment.start_time = utils.Time.fromInputValue(e.target.value);
await saveSchedule();
}, 1000));
assignmentForm.end_time.addEventListener('change', utils.debounceAsync(async e => {
selectedAssignment.end_time = utils.Time.fromInputValue(e.target.value);
await saveSchedule();
}, 1000));
assignmentForm.squishable.addEventListener('change', async e => {
selectedAssignment.squishable = e.target.checked;
await saveSchedule();
});
async function setAssignmentTrackAndWidth() {
const width = parseInt(assignmentForm.width.value);
selectedAssignment.width = width;
const str = assignmentForm.track.selectedOptions[0].value;
if (str === 'auto') selectedAssignment.track = undefined;
else if (str === 'all') selectedAssignment.track = 'all';
else {
const track = parseInt(str);
if (width === 1) selectedAssignment.track = track;
else {
let tracks = [];
for (let i = track; i < track + width; i++) tracks.push(i);
selectedAssignment.track = tracks;
}
}
await saveSchedule();
}
assignmentForm.width.addEventListener('change', setAssignmentTrackAndWidth);
assignmentForm.track.addEventListener('change', setAssignmentTrackAndWidth);
assignmentForm.notes.addEventListener('change', async e => {
selectedAssignment.notes = e.target.value;
await saveSchedule();
});