parent
c5ab01794b
commit
72a63f8356
@ -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…
Reference in new issue