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