diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/App.vue | 19 | ||||
| -rw-r--r-- | src/assets/logo.png | bin | 6849 -> 0 bytes | |||
| -rw-r--r-- | src/components/ActionBar.vue | 75 | ||||
| -rw-r--r-- | src/components/FileUpload.vue | 41 | ||||
| -rw-r--r-- | src/components/HelloWorld.vue | 60 | ||||
| -rw-r--r-- | src/components/List.vue | 41 | ||||
| -rw-r--r-- | src/features/parser.js | 138 | ||||
| -rw-r--r-- | src/models/EventInfo.js | 41 | ||||
| -rw-r--r-- | src/models/Lab.js | 17 | ||||
| -rw-r--r-- | src/models/PeerTeacher.js | 29 | ||||
| -rw-r--r-- | src/router/index.js | 14 | ||||
| -rw-r--r-- | src/store/index.js | 22 | ||||
| -rw-r--r-- | src/views/About.vue | 27 | ||||
| -rw-r--r-- | src/views/Editor.vue | 123 | ||||
| -rw-r--r-- | src/views/Home.vue | 18 |
15 files changed, 575 insertions, 90 deletions
diff --git a/src/App.vue b/src/App.vue index b964355..c3a04c9 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,13 +1,22 @@ <template> - <div id="nav"> - <router-link to="/">Home</router-link> | - <router-link to="/about">About</router-link> - </div> <router-view/> </template> <style> +html { + box-sizing: border-box; +} + +*, *:before, *:after { + box-sizing: inherit; +} + #app { + height: 100vh; + max-height: 100vh; +} + +/* #app { font-family: Avenir, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; @@ -26,5 +35,5 @@ #nav a.router-link-exact-active { color: #42b983; -} +} */ </style> diff --git a/src/assets/logo.png b/src/assets/logo.png Binary files differdeleted file mode 100644 index f3d2503..0000000 --- a/src/assets/logo.png +++ /dev/null diff --git a/src/components/ActionBar.vue b/src/components/ActionBar.vue new file mode 100644 index 0000000..96dbaae --- /dev/null +++ b/src/components/ActionBar.vue @@ -0,0 +1,75 @@ +<template> + <div> + <file-upload + :accept="'text/plain'" + :multiple="true" + @file-changed="handlePtChange">Upload PT Schedule</file-upload> + <file-upload + :accept="'application/json'" + @file-changed="handleLabChange">Upload Lab Schedule</file-upload> + <button @click="save">Export</button> + </div> +</template> + +<script> +import FileUpload from '@/components/FileUpload.vue'; + +import { parseLabFile, parsePtSchedule } from '@/features/parser'; + +export default { + name: 'ActionBar', + components: { + FileUpload, + }, + methods: { + handleLabChange(files) { + parseLabFile(files[0]) + .then((data) => { + this.$store.commit('importLabs', data); + }) + .catch((error) => { + console.error(error); + }); + }, + handlePtChange(files) { + const result = []; + const promises = []; + + files.forEach((file) => { + const p = parsePtSchedule(file).then((data) => result.push(data)); + promises.push(p); + }); + + Promise.all(promises) + .then(() => { + this.$store.commit('addPeerTeachers', result); + }); + }, + save() { + const database = { + labs: Object.fromEntries(this.$store.state.labs), + peerTeachers: Object.fromEntries(this.$store.state.peerTeachers), + }; + + const jsonObj = JSON.stringify(database, (_, value) => { + if (typeof value === 'object' && value instanceof Set) { + return [...value]; + } + return value; + }); + + const blob = new Blob([jsonObj], { type: 'text/json' }); + const anchor = document.createElement('a'); + const url = window.URL.createObjectURL(blob); + anchor.href = url; + anchor.download = 'pt-db.json'; + anchor.style = 'display: none'; + + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + window.URL.revokeObjectURL(url); + }, + }, +}; +</script> diff --git a/src/components/FileUpload.vue b/src/components/FileUpload.vue new file mode 100644 index 0000000..b2fdb14 --- /dev/null +++ b/src/components/FileUpload.vue @@ -0,0 +1,41 @@ +<template> + <label class="file-upload-lbl"> + <input + type="file" + :accept="accept" + @change="$emit('fileChanged', $event.target.files)" + :multiple="multiple" hidden /> + <slot>Upload</slot> + </label> +</template> + +<script> +export default { + name: 'FileUpload', + props: { + accept: String, + multiple: { + type: Boolean, + default: false, + }, + }, + emits: { + selectionChanged: null, + }, +}; +</script> + +<style scoped> +.file-upload-lbl { + background-color: #500000; + color: white; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + padding: 5px; + text-align: center; +} + +.file-upload-lbl:hover { + color: grey; + cursor: pointer; +} +</style> diff --git a/src/components/HelloWorld.vue b/src/components/HelloWorld.vue deleted file mode 100644 index 94ceb6b..0000000 --- a/src/components/HelloWorld.vue +++ /dev/null @@ -1,60 +0,0 @@ -<template> - <div class="hello"> - <h1>{{ msg }}</h1> - <p> - For a guide and recipes on how to configure / customize this project,<br> - check out the - <a href="https://cli.vuejs.org" target="_blank" rel="noopener">vue-cli documentation</a>. - </p> - <h3>Installed CLI Plugins</h3> - <ul> - <li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel" target="_blank" rel="noopener">babel</a></li> - <li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-router" target="_blank" rel="noopener">router</a></li> - <li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-vuex" target="_blank" rel="noopener">vuex</a></li> - <li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint" target="_blank" rel="noopener">eslint</a></li> - </ul> - <h3>Essential Links</h3> - <ul> - <li><a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a></li> - <li><a href="https://forum.vuejs.org" target="_blank" rel="noopener">Forum</a></li> - <li><a href="https://chat.vuejs.org" target="_blank" rel="noopener">Community Chat</a></li> - <li><a href="https://twitter.com/vuejs" target="_blank" rel="noopener">Twitter</a></li> - <li><a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a></li> - </ul> - <h3>Ecosystem</h3> - <ul> - <li><a href="https://router.vuejs.org" target="_blank" rel="noopener">vue-router</a></li> - <li><a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a></li> - <li><a href="https://github.com/vuejs/vue-devtools#vue-devtools" target="_blank" rel="noopener">vue-devtools</a></li> - <li><a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener">vue-loader</a></li> - <li><a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">awesome-vue</a></li> - </ul> - </div> -</template> - -<script> -export default { - name: 'HelloWorld', - props: { - msg: String, - }, -}; -</script> - -<!-- Add "scoped" attribute to limit CSS to this component only --> -<style scoped> -h3 { - margin: 40px 0 0; -} -ul { - list-style-type: none; - padding: 0; -} -li { - display: inline-block; - margin: 0 10px; -} -a { - color: #42b983; -} -</style> diff --git a/src/components/List.vue b/src/components/List.vue new file mode 100644 index 0000000..031f708 --- /dev/null +++ b/src/components/List.vue @@ -0,0 +1,41 @@ +<template> + <ul class="list"> + <li + v-for="item in items" + :key="item.id" + @click="$emit('selectionChanged', item)" + class="list-item"> + <slot :item="item">{{ item }}</slot> + </li> + </ul> +</template> + +<script> +export default { + name: 'List', + props: { + items: Array, + }, + emits: { + selectionChanged: null, + }, +}; +</script> + +<style scoped> +.list { + list-style-type: none; +} + +.list-item { + border: 1px solid grey; + border-radius: 5px; + margin: 5px 0; + padding: 0.15em; + text-align: center; +} + +.list-item:hover { + background-color: rgb(182, 182, 182); +} +</style> diff --git a/src/features/parser.js b/src/features/parser.js new file mode 100644 index 0000000..f91d926 --- /dev/null +++ b/src/features/parser.js @@ -0,0 +1,138 @@ +import Lab from '@/models/Lab'; +import PeerTeacher from '@/models/PeerTeacher'; +import EventInfo from '../models/EventInfo'; + +export function parseLabFile(file) { + const validCourses = [ + '110', + '111', + '121', + '206', + '221', + // '222', + '312', + '313', + // '314', + '315', + ]; + + const reader = new FileReader(); + + return new Promise((resolve, reject) => { + const result = []; + + reader.onload = (event) => { + let jsonData; + try { + jsonData = JSON.parse(event.target.result); + } catch (e) { + reject(new Error(e)); + } + const labs = jsonData.data; + + labs.forEach((lab) => { + if (validCourses.includes(lab.courseNumber)) { + const newLab = new Lab(lab.courseNumber, lab.sequenceNumber); + + if (lab.instructionalMethod !== 'Web Based') { + lab.meetingsFaculty.every((meeting) => { + const { meetingTime } = meeting; + + if (meetingTime.meetingType === 'LAB') { + let days = ''; + days += meetingTime.monday ? 'M' : ''; + days += meetingTime.tuesday ? 'T' : ''; + days += meetingTime.wednesday ? 'W' : ''; + days += meetingTime.thursday ? 'R' : ''; + days += meetingTime.friday ? 'F' : ''; + + newLab.event.days = days; + newLab.event.start = parseInt(meetingTime.beginTime, 10); + newLab.event.end = parseInt(meetingTime.endTime, 10); + + return false; + } + + return true; + }); + } + + result.push(newLab); + } + }); + + resolve(result); + }; + + reader.readAsText(file); + }); +} + +export function parsePtSchedule(file) { + const reader = new FileReader(); + + return new Promise((resolve) => { + reader.onload = (event) => { + const peerTeacher = new PeerTeacher(); + const namePattern = /^(.*)\s(.*)\s(\d{9})/; + const eventPattern = /^(M?T?W?R?F?)\s(\d{1,2}:\d{2})\s?-\s?(\d{1,2}:\d{2})/; + const lines = event.target.result.split('\n').filter((line) => line.trim()); + + lines.forEach((line) => { + const ptName = line.match(namePattern); + if (ptName) { + [, peerTeacher.firstname, peerTeacher.lastname] = ptName; + peerTeacher.uin = parseInt(ptName[3], 10); + } + + const eventMatch = line.match(eventPattern); + if (eventMatch) { + const newEvent = new EventInfo(eventMatch[1]); + newEvent.start = parseInt(eventMatch[2].replace(':', ''), 10); + newEvent.end = parseInt(eventMatch[3].replace(':', ''), 10); + peerTeacher.events.push(newEvent); + } + }); + + resolve(peerTeacher); + }; + + reader.readAsText(file); + }); +} + +export function parsePtDatabase(file) { + const reader = new FileReader(); + + return new Promise((resolve) => { + reader.onload = (event) => { + const jsonObj = JSON.parse(event.target.result); + const result = { + labs: new Map(), + peerTeachers: new Map(), + }; + + Object.keys(jsonObj.labs).forEach((key) => { + const lab = jsonObj.labs[key]; + result.labs.set(key, new Lab(lab.course, + lab.section, new EventInfo(lab.event.days, lab.event.start, lab.event.end))); + }); + + Object.keys(jsonObj.peerTeachers).forEach((key) => { + const pt = jsonObj.peerTeachers[key]; + const ptObj = new PeerTeacher(pt.firstname, pt.lastname, pt.uin); + ptObj.events = pt.events.map((eventObj) => new EventInfo(eventObj.days, + eventObj.start, eventObj.end)); + ptObj.assignedLabs = new Set(); + pt.assignedLabs.forEach((labId) => { + ptObj.assignedLabs.add(labId); + }); + result.peerTeachers.set(key, ptObj); + }); + + resolve(result); + }; + + reader.readAsText(file); + }); +} diff --git a/src/models/EventInfo.js b/src/models/EventInfo.js new file mode 100644 index 0000000..c1624a8 --- /dev/null +++ b/src/models/EventInfo.js @@ -0,0 +1,41 @@ +export default class EventInfo { + constructor(days = '', start = 0, end = 0) { + this.days = days; + this.start = start; + this.end = end; + } + + static timeToStr(time) { + let hour = Math.floor(time / 100); + let minute = time % 100; + const meridiem = (hour < 12) ? 'AM' : 'PM'; + + if (hour === 0) { + hour = 12; + } else if (hour > 12) { + hour -= 12; + } + + if (minute < 10) { + minute = `0${minute}`; + } + + return `${hour}:${minute} ${meridiem}`; + } + + conflictsWith(event) { + const daysConflict = event.days.match(new RegExp(`[${this.days}]`)); + + if (daysConflict) { + return (this.start <= event.end) && (event.start <= this.end); + } + return false; + } + + get info() { + if (this.days === '') { + return 'ONLINE'; + } + return `${this.days} ${EventInfo.timeToStr(this.start)}-${EventInfo.timeToStr(this.end)}`; + } +} diff --git a/src/models/Lab.js b/src/models/Lab.js new file mode 100644 index 0000000..2e4412d --- /dev/null +++ b/src/models/Lab.js @@ -0,0 +1,17 @@ +import EventInfo from '@/models/EventInfo'; + +export default class Lab { + constructor(course = 0, section = 0, event = new EventInfo()) { + this.course = course; + this.section = section; + this.event = event; + } + + get id() { + return `${this.course}-${this.section}`; + } + + get fullInfo() { + return `${this.id} ${this.event.info}`; + } +} diff --git a/src/models/PeerTeacher.js b/src/models/PeerTeacher.js new file mode 100644 index 0000000..00a2f0d --- /dev/null +++ b/src/models/PeerTeacher.js @@ -0,0 +1,29 @@ +export default class PeerTeacher { + constructor(firstname = '', lastname = '', uin = 0) { + this.firstname = firstname; + this.lastname = lastname; + this.uin = uin; + this.events = []; + this.assignedLabs = new Set(); + } + + conflictsWith(event) { + let conflicts = false; + this.events.every((item) => { + if (item.conflictsWith(event)) { + conflicts = true; + return false; + } + return true; + }); + return conflicts; + } + + get name() { + return `${this.firstname} ${this.lastname}`; + } + + get id() { + return this.uin; + } +} diff --git a/src/router/index.js b/src/router/index.js index 8f52523..7a598b3 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -1,19 +1,21 @@ import { createRouter, createWebHashHistory } from 'vue-router'; -import Home from '../views/Home.vue'; +import Editor from '../views/Editor.vue'; +import About from '../views/About.vue'; const routes = [ { path: '/', - name: 'Home', - component: Home, + name: 'About', + component: About, }, { - path: '/about', - name: 'About', + path: '/editor', + name: 'Editor', // route level code-splitting // this generates a separate chunk (about.[hash].js) for this route // which is lazy-loaded when the route is visited. - component: () => import(/* webpackChunkName: "about" */ '../views/About.vue'), + // component: () => import(/* webpackChunkName: "about" */ '../views/Editor.vue'), + component: Editor, }, ]; diff --git a/src/store/index.js b/src/store/index.js index af6cee0..0c3a5c9 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -2,8 +2,30 @@ import { createStore } from 'vuex'; export default createStore({ state: { + labs: new Map(), + peerTeachers: new Map(), }, mutations: { + setLabs(state, labs) { + state.labs = labs; + }, + setPeerTeachers(state, peerTeachers) { + state.peerTeachers = peerTeachers; + }, + importLabs(state, labs) { + state.labs.clear(); + labs.forEach((lab) => { + state.labs.set(lab.id, lab); + }); + }, + addPeerTeachers(state, peerTeachers) { + peerTeachers.forEach((pt) => { + state.peerTeachers.set(pt.id, pt); + }); + }, + deletePeerTeacher(state, id) { + state.peerTeachers.delete(id); + }, }, actions: { }, diff --git a/src/views/About.vue b/src/views/About.vue index 3fa2807..ab4725e 100644 --- a/src/views/About.vue +++ b/src/views/About.vue @@ -1,5 +1,30 @@ <template> <div class="about"> - <h1>This is an about page</h1> + <router-link to="/editor">Create new database</router-link> + <file-upload + :accept="'application/json'" + @file-changed="handleDatabaseChange">Use existing database</file-upload> </div> </template> + +<script> +import FileUpload from '../components/FileUpload.vue'; + +import { parsePtDatabase } from '../features/parser'; + +export default { + name: 'About', + components: { + FileUpload, + }, + methods: { + handleDatabaseChange(files) { + parsePtDatabase(files[0]).then((result) => { + this.$store.commit('setLabs', result.labs); + this.$store.commit('setPeerTeachers', result.peerTeachers); + this.$router.push({ name: 'Editor' }); + }); + }, + }, +}; +</script> diff --git a/src/views/Editor.vue b/src/views/Editor.vue new file mode 100644 index 0000000..5ef0131 --- /dev/null +++ b/src/views/Editor.vue @@ -0,0 +1,123 @@ +<template> + <div id="home"> + <action-bar /> + <div id="editor"> + <div class="column"> + <list :items="peerTeachers" @selection-changed="handlePtClick" #default="pt"> + {{ pt.item.name }} <button @click.stop="deletePeerTeacher(pt.item.id)">X</button> + </list> + </div> + <div class="column"> + <list :items="compatibleLabs" #default="lab"> + {{ lab.item.fullInfo }} <button @click.stop="assignLab(lab.item.id)">+</button> + </list> + </div> + <div class="column"> + <list :items="selectedPeerTeacherAssignments" #default="lab"> + {{ lab.item.fullInfo }} <button @click.stop="unassignLab(lab.item.id)">X</button> + </list> + </div> + </div> + </div> +</template> + +<script> +import ActionBar from '@/components/ActionBar.vue'; +import List from '@/components/List.vue'; +import PeerTeacher from '@/models/PeerTeacher'; + +export default { + name: 'Editor', + components: { + ActionBar, + List, + }, + computed: { + labs() { + return Array.from(this.$store.state.labs.values()).sort((a, b) => a.id + .localeCompare(b.id)); + }, + peerTeachers() { + return Array.from(this.$store.state.peerTeachers.values()).sort((a, b) => a.lastname + .toUpperCase().localeCompare(b.lastname.toUpperCase())); + }, + compatibleLabs() { + const temp = this.labs.filter((lab) => (!this.selectedPeerTeacher.assignedLabs.has(lab.id) + && !this.selectedPeerTeacher.conflictsWith(lab.event))); + + const currentAssignments = this.selectedPeerTeacherAssignments; + return temp.filter((lab) => { + let compatible = true; + currentAssignments.every((assignedLab) => { + if (lab.event.conflictsWith(assignedLab.event)) { + compatible = false; + return false; + } + return true; + }); + return compatible; + }); + }, + selectedPeerTeacherAssignments() { + return [...this.selectedPeerTeacher.assignedLabs.values()].map((id) => this.$store.state.labs + .get(id)).sort((a, b) => { + if (a.course < b.course) { + return -1; + } + if (b.course < a.course) { + return 1; + } + + if (a.section < b.section) { + return -1; + } + if (b.section < a.section) { + return 1; + } + + return 0; + }); + }, + }, + data() { + return { + selectedPeerTeacher: new PeerTeacher(), + }; + }, + methods: { + handlePtClick(peerTeacher) { + this.selectedPeerTeacher = peerTeacher; + }, + deletePeerTeacher(id) { + if (this.selectedPeerTeacher.id === id) { + this.selectedPeerTeacher = new PeerTeacher(); + } + this.$store.commit('deletePeerTeacher', id); + }, + assignLab(id) { + if (this.selectedPeerTeacher.id !== 0) { + this.selectedPeerTeacher.assignedLabs.add(id); + } + }, + unassignLab(id) { + this.selectedPeerTeacher.assignedLabs.delete(id); + }, + }, +}; +</script> + +<style> +#home { + max-height: inherit; +} + +#editor { + display: flex; + max-height: inherit; +} + +.column { + flex: 1; + overflow: auto; +} +</style> diff --git a/src/views/Home.vue b/src/views/Home.vue deleted file mode 100644 index e91ef23..0000000 --- a/src/views/Home.vue +++ /dev/null @@ -1,18 +0,0 @@ -<template> - <div class="home"> - <img alt="Vue logo" src="../assets/logo.png"> - <HelloWorld msg="Welcome to Your Vue.js App"/> - </div> -</template> - -<script> -// @ is an alias to /src -import HelloWorld from '@/components/HelloWorld.vue'; - -export default { - name: 'Home', - components: { - HelloWorld, - }, -}; -</script> |
