Compare commits

..

1 Commits

Author SHA1 Message Date
Hykilpikonna 8b41296507 deploy 2020-08-02 12:45:47 -04:00
103 changed files with 383 additions and 18595 deletions
-21
View File
@@ -1,21 +0,0 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
View File
+1
View File
@@ -0,0 +1 @@
demo.vera.hydev.org
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2019 HyDEV
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-56
View File
@@ -1,56 +0,0 @@
<h1 align="center"><br><br>
VeracrossAnalyzer UI
</h1>
<h4 align="center">
A Website, A Visual Representation of Students' Grade Data on Veracross
</h4>
<h5 align="center">
<a href="#intro">Introduction</a>&nbsp;&nbsp;
<a href="#setup">Project Setup</a>&nbsp;&nbsp;
<a href="#license">License</a>
</h5><br><br><br>
<a name="intro"></a>
Introduction:
--------
This is a website that generates visual representation of students' grade data on Veracross. Currently there is only one graph and one numerical data representing the GPA. But also it just released yesterday! (Yay!) What do you expect this soon lol?
**Here's how it looks like right now:** *(Now all of you know my grades ;-;)*
![](https://user-images.githubusercontent.com/22280294/65841599-155ead00-e2f2-11e9-9d9f-c2f23c45d9a4.png)
<br>
<a name="setup"></a>
Project Setup:
--------
TODO: Actually write a project setup tutorial that's not generated by Vue on initialization ;-;.
### Install
```
npm install
```
### Compiles and hot-reloads for development
```
npm run serve
```
### Compiles and minifies for production
```
npm run build
```
<br>
<a name="license"></a>
License: [MIT](https://choosealicense.com/licenses/mit/)
--------
The MIT license basically means that this project is open-soucred and you can do whatever you want with it, as long as you include a copy of this license in your distribution. You don't have to ask for permissions to use or anything. However, if you do bad things with it, I'm not responsible.
-5
View File
@@ -1,5 +0,0 @@
module.exports = {
presets: [
'@vue/app'
]
};
File diff suppressed because one or more lines are too long
-25
View File
@@ -1,25 +0,0 @@
#!/usr/bin/env bash
echo "Switch to production database settings"
exit
# abort on errors
set -e
# build
npm run build
# navigate into the build output directory
cd dist
# if you are deploying to a custom domain
echo 'demo.vera.hydev.org' > CNAME
git init
git add -A
git commit -m 'deploy'
# if you are deploying to https://<USERNAME>.github.io/<REPO>
git push -f git@github.com:Hykilpikonna/VeracrossAnalyzerDemo.git master:gh-pages
cd -
View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

+5
View File
@@ -0,0 +1,5 @@
<!DOCTYPE html><html lang=en><head><meta charset=utf-8><meta http-equiv=X-UA-Compatible content="IE=edge"><meta name=viewport content="width=1024"><link rel=icon href=/logo@32px.png><title>Veracross Analyzer</title><link href=/css/app.72ceade9.css rel=preload as=style><link href=/js/app.c7702e9a.js rel=preload as=script><link href=/js/chunk-vendors.4383782d.js rel=preload as=script><link href=/css/app.72ceade9.css rel=stylesheet></head><body style="margin: 0"><noscript><strong>We're sorry but veracross-analyzer doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id=app></div><script src=https://cdn.jsdelivr.net/npm/echarts/dist/echarts.min.js></script><script src=https://cdn.jsdelivr.net/npm/v-charts/lib/index.min.js></script><link rel=stylesheet href=https://cdn.jsdelivr.net/npm/v-charts/lib/style.min.css><link rel=stylesheet href=https://unpkg.com/element-ui/lib/theme-chalk/index.css><link href="https://fonts.googleapis.com/css?family=Nunito+Sans&display=swap" rel=stylesheet><script src=/js/chunk-vendors.4383782d.js></script><script src=/js/app.c7702e9a.js></script></body><script async src="https://www.googletagmanager.com/gtag/js?id=G-Q615K1KFLC"></script><script>window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-Q615K1KFLC');</script></html>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

-12812
View File
File diff suppressed because it is too large Load Diff
-44
View File
@@ -1,44 +0,0 @@
{
"name": "veracross-analyzer",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
"dependencies": {
"@types/chroma-js": "^2.0.0",
"@types/md5": "^2.2.0",
"chroma-js": "^2.1.0",
"core-js": "^2.6.10",
"echarts": "^4.8.0",
"element-ui": "^2.13.2",
"md5": "^2.2.1",
"moment": "^2.27.0",
"p-wait-for": "^3.1.0",
"v-charts": "^1.19.0",
"vue": "^2.6.11",
"vue-class-component": "^7.2.5",
"vue-cookies": "^1.7.3",
"vue-property-decorator": "^8.5.1"
},
"devDependencies": {
"@vue/cli-plugin-babel": "^3.12.1",
"@vue/cli-plugin-typescript": "^3.12.1",
"@vue/cli-service": "^4.4.6",
"node-sass": "^4.14.1",
"sass-loader": "^8.0.2",
"typescript": "^3.9.7",
"vue-template-compiler": "^2.6.11"
},
"postcss": {
"plugins": {
"autoprefixer": {}
}
},
"browserslist": [
"> 1%",
"last 2 versions"
]
}
-41
View File
@@ -1,41 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--meta name="viewport" content="width=device-width,initial-scale=1.0"-->
<meta name="viewport" content="width=1024">
<link rel="icon" href="<%= BASE_URL %>logo@32px.png">
<title>Veracross Analyzer</title>
</head>
<body style="margin: 0">
<noscript>
<strong>We're sorry but veracross-analyzer doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- V-Charts -->
<script src="https://cdn.jsdelivr.net/npm/echarts/dist/echarts.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/v-charts/lib/index.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/v-charts/lib/style.min.css">
<!-- ElementUI -->
<link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
<!-- Fonts -->
<link href="https://fonts.googleapis.com/css?family=Nunito+Sans&display=swap" rel="stylesheet">
</body>
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-Q615K1KFLC"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-Q615K1KFLC');
</script>
</html>
-45
View File
@@ -1,45 +0,0 @@
import LoginUser from '@/logic/login-user';
import App from '@/components/app/app';
import pWaitFor from 'p-wait-for';
export default class AppDemo
{
static loadDemo(app: App)
{
app.logLoading('1. Logging in...')
App.http.get('./demo-data/token.json').then(response =>
{
app.user = new LoginUser(response.data)
app.courses = app.user.courses
app.logLoading('1. Loading assignments...')
app.courses.forEach(course =>
{
App.http.get(`./demo-data/assignments-${course.assignmentsId}.json`).then(response =>
{
course.loadAssignments(response.data);
})
})
pWaitFor(() => app.courses.every(c => c.rawAssignments)).then(() =>
{
app.gradedCourses = app.courses.filter(c => c.isGraded)
app.gradedCourses.forEach(c => [0, 1, 2, 3].forEach(i => c.termGrading[i] = {method: 'TOTAL_MEAN', weightingMap: {}}))
app.logLoading('');
app.assignmentsReady = true
app.showRating = true
app.$alert(
'This demo analyzes an offline snapshot of my data from Jun 6, 2020, ' +
'which displays my academic results from Junior year.<br/>' +
'<br/>' +
'Feel free to click around! 😇<br/>' +
'<br/>' +
'-- The Veracross Analyzer Team (YGui)<br/>' +
'-- Made with 🧡 in SJP',
'🥳 Welcome to VeracrossAnalyzer Demo!',
{dangerouslyUseHTMLString: true, confirmButtonText: 'OK'});
})
})
}
}
-160
View File
@@ -1,160 +0,0 @@
div
{
font-family: -apple-system, Nunito Sans, Avenir, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue,
Hiragino Sans GB, Microsoft YaHei, WenQuanYi Micro Hei, Helvetica, Arial, sans-serif;
}
#app
{
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}
#app-content
{
// Limit max width
max-width: 1300px;
text-align: center;
margin: auto;
}
.theme-default
{
--unread: #ff6c00;
--main: #0c6dad;
--assignment-type-2: #3f991e;
--assignment-type-3: #ff9900;
--assignment-type-4: #b02b02;
}
.dark
{
--dark-layer-1: #383838;
--dark-layer-2: #525252;
--dark-layer-3: #6c6c6c;
--dark-foreground: #e9e9e9;
background: var(--dark-layer-1) !important;
div, ul
{
background: var(--dark-layer-2) !important;
color: var(--dark-foreground) !important;
}
span, button
{
color: var(--dark-foreground) !important;
}
.el-card
{
border: none !important;
}
// Overall
#app-inner, #overall, #overall-course, .overall-span, #app-content
{
background: var(--dark-layer-1) !important;
}
// Course card
.entry-box, .none .unread-number {background: #797979 !important}
.entry-box.max {background-color: #949494 !important}
.entry-box.percent {background-color: #a7a490 !important}
.course-name {color: #cffff6 !important}
#block-grade #updates.none #unread-number {background: #757575 !important}
.course-card-content.expand, .assignment-entry, .unread-row,
.unread-row .el-col, #assignment-type-head, .course-page-graph.el-col
{
background-color: var(--dark-layer-3) !important;
}
// Nav bar
.el-menu--horizontal>.el-menu-item.is-active {color: var(--dark-foreground) !important;}
}
// ##############
// # Global CSS #
// ##############
.el-card
{
margin: 10px;
padding: 0;
}
.el-card.large
{
height: 494px;
}
// Fix padding
.el-card__body
{
padding-top: 0 !important;
padding-bottom: 0 !important;
}
// Vertical centering
.vertical-center
{
// Vertical center
display: flex;
justify-content: center;
flex-direction: column;
}
// Remove card padding for styling issues
div.el-card.course-card > div.el-card__body
{
padding-right: 0 !important;
padding-left: 0 !important;
}
// Clickable text
.clickable:hover
{
text-decoration: underline;
cursor: pointer;
}
// Non-selectable text
.unselectable
{
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.el-dropdown-menu__item
{
font-family: Nunito Sans, Helvetica Neue, Microsoft YaHei, "微软雅黑", Arial, sans-serif;
}
// Fix word breaking
.el-dialog__body
{
word-break: unset !important;
}
.comic
{
font-family: "Comic Sans MS", Nunito Sans, Helvetica Neue, Microsoft YaHei, "微软雅黑", Arial, sans-serif;
}
#demo-not-available
{
padding-top: 40vh;
font-size: 2em;
color: #bbbbbb;
margin: 0 50px;
}
-274
View File
@@ -1,274 +0,0 @@
import {Component, Vue} from 'vue-property-decorator';
import Login from '@/components/login/login.vue';
import Navigation from '@/components/navigation/navigation.vue';
import Overall from '@/pages/overall/overall.vue';
import Constants from '@/constants';
import pWaitFor from 'p-wait-for';
import {HttpUtils} from '@/logic/utils/http-utils';
import Loading from '@/components/overlays/loading.vue';
import CoursePage from '@/pages/course/course-page.vue';
import Course from '@/logic/course';
import LoginUser from '@/logic/login-user';
import NavController from '@/logic/nav-controller';
import Info from '@/statics/Info.vue';
import CourseSelection from '@/pages/course-selection/course-selection.vue';
import AppDemo from '@/components/app/app-demo';
@Component({
components: {Login, Navigation, Overall, Loading, CoursePage, Info, CourseSelection},
})
export default class App extends Vue
{
// List of course that the student takes
courses: Course[] = [];
gradedCourses: Course[] = [];
// Are the course assignments loaded from the server.
assignmentsReady: boolean = false;
// Token
user: LoginUser = null as any;
// Loading text
loading: string = '';
// Loading error
loadingError: boolean = false;
// Navigation controller
nav: NavController = new NavController();
// Http Client
static http: HttpUtils = new HttpUtils();
// Instance
static instance: App;
// Static page
staticPage: string = '';
// Dark mode
darkMode: boolean = this.$cookies.isKey('dark');
// Show rating
showRating: boolean = this.$cookies.get('show-rating') == 'set=yes';
// Demo mode
demoMode: boolean = window.location.hostname == 'demo.vera.hydev.org' || this.$cookies.isKey('demo-mode')
// Is the login panel shown
showLogin: boolean = !this.demoMode
/**
* This is called when the instance is created.
*/
created()
{
// Show splash
console.log(Constants.SPLASH);
// Update instance
App.instance = this;
// Check location
if (window.location.hash == '#info')
{
this.staticPage = 'info';
}
// Default config
if (!this.$cookies.isKey('show-rating'))
{
this.showRating = Constants.CURRENT_TERM == 3;
}
// Demo
if (this.demoMode)
{
AppDemo.loadDemo(this);
}
}
/**
* This is called when the user logs in.
*
* @param user Authorization user
*/
onLogin(user: LoginUser)
{
// Hide login bar
this.showLogin = false;
// Show loading message
this.logLoading('1. Logging in...');
// Store user
this.user = user;
this.courses = user.courses
// Assign user to http client
App.http.user = user;
// Load assignments
this.loadAssignments();
}
/**
* Load the assignments of the courses
*/
loadAssignments()
{
// Show loading message
this.logLoading('1. Loading assignments...');
// Get assignments for all the courses
this.courses.forEach(course =>
{
// Send request to get assignments
App.http.post('/assignments', {'assignmentsId': course.assignmentsId}).then(response =>
{
// Check success
if (response.success)
{
course.loadAssignments(response.data);
}
else throw new Error(response.data);
})
.catch(e => this.showError(`Error: Assignments data failed to load.\n(${e})`));
});
// Wait for assignments to be ready.
pWaitFor(() => this.courses.every(c => c.rawAssignments != null)).then(() =>
{
// Filter courses
this.gradedCourses = this.courses.filter(c => c.isGraded);
// Check grading algorithms
this.checkGradingAlgorithms();
});
}
/**
* Check the courses' grading algorithms. (Total-mean or percent-type)
*/
checkGradingAlgorithms()
{
// Show loading message
this.logLoading('2. Checking grading algorithms...');
// Loop through all the courses
for (const course of this.gradedCourses)
{
for (const i of [0, 1, 2, 3])
{
const cookieIndex = `va.grading.${i}.${course.assignmentsId}`;
// Check if already exist in cookies
if (this.$cookies.isKey(cookieIndex))
{
course.termGrading[i] = {method: 'TOTAL_MEAN', weightingMap: {}};
continue;
}
// Request grading scheme for this course at this grading period
App.http.post('/grading/term', {assignmentsId: course.assignmentsId, term: i}).then(resp =>
{
// Check success
if (resp.success)
{
// Add it to course
course.termGrading[i] = resp.data;
// If it's total_mean, cache it to cookies
// This is because only percent_type can update over time
if (course.termGrading[i].method == 'TOTAL_MEAN')
{
this.$cookies.set(cookieIndex, 'TOTAL_MEAN', '3d');
}
}
else throw new Error(resp.data);
})
.catch(e => this.showError(`Error: Grading data failed to load.\n(${e})`))
}
}
// Wait for done
pWaitFor(() => this.gradedCourses.every(c => c.termGrading.every(g => g != null))).then(() =>
{
this.assignmentsReady = true;
// Remove loading
this.logLoading('');
// Check if rating notification should be displayed
if (this.courses.filter(c => c.rated).length == 0 && this.showRating &&
!this.$cookies.isKey('rating-notified'))
{
// Show notification
this.$cookies.set('rating-notified', true);
this.showUpdates()
}
})
}
showUpdates()
{
this.$alert(
'<b>TL;DR:</b><br/>' +
'📅 Added a Course Selection tab to help you schedule for next year!<br/>' +
'🤩 You can now give star ratings to your courses!<br/>' +
'😮 You can also see others\' ratings in the course selection tab!<br/>' +
'<br/>' +
'That\'s it, try things out and have fun! 😇<br/>' +
'<br/>' +
'-- The Veracross Analyzer Team<br/>' +
'-- Made with 🧡 in SJP',
'🥳 Huge updates!',
{dangerouslyUseHTMLString: true, confirmButtonText: 'OK', customClass: 'comic'});
}
/**
* Log a message to loading screen
*
* @param message Message
*/
logLoading(message: string)
{
if (message == '') this.loading = '';
else this.loading += '\n' + message;
}
/**
* Show error message on loading screen
*
* @param message Error message
*/
showError(message: string)
{
this.loadingError = true;
this.loading = message;
}
/**
* Sign out
*/
signOut()
{
// Clear all cookies
this.$cookies.keys().forEach(key => this.$cookies.remove(key));
// Refresh
window.location.reload();
}
/**
* Select time (Eg. Term 1, Term 2, All Year, etc.)
*
* @param code
*/
selectTime(code: number)
{
// TODO: Optimize
window.location.reload();
}
}
-34
View File
@@ -1,34 +0,0 @@
<template>
<div id="app" class="theme-default">
<div id="app-inner" v-if="staticPage === ''" :class="{dark: darkMode, padding: nav.id !== 'course-selection'}">
<login v-if="showLogin" v-on:login:user="onLogin"/>
<navigation v-if="user != null"
:app="this" :user="user" :nav="nav"
@sign-out="signOut" @select-time="selectTime">
</navigation>
<div id="app-content" v-if="assignmentsReady && loading === ''">
<overall v-if="nav.id === 'overall'" :courses="gradedCourses"></overall>
<course-page v-if="nav.id === 'course'" :course="gradedCourses.find(c => +c.id === +nav.info.id)"></course-page>
<course-selection v-if="nav.id === 'course-selection' && !demoMode" :app="this"></course-selection>
<div id="demo-not-available" class="unselectable" v-if="nav.id === 'course-selection' && demoMode">
Course selection page is not available in demo mode.
</div>
</div>
<loading v-if="loading !== ''" :text="loading" :error="loadingError"/>
</div>
<Info v-if="staticPage === 'info'"/>
</div>
</template>
<script src="./app.ts" lang="ts"></script>
<style src="./app.scss" lang="scss"/>
<style lang="scss" scoped>
.padding
{
padding-bottom: 100px;
}
</style>
-29
View File
@@ -1,29 +0,0 @@
<template>
<div class="el-loading-spinner" :class="{'not-centered': !centered}">
<svg viewBox="25 25 50 50" class="circular" :style="{width: size + 'px', height: size + 'px'}">
<circle cx="50" cy="50" r="20" fill="none" class="path"/>
</svg>
</div>
</template>
<script lang="ts">
import {Component, Prop, Vue} from 'vue-property-decorator'
@Component
export default class LoadingSpinner extends Vue
{
@Prop({default: '42'}) size: string
@Prop({default: true}) centered: string
}
</script>
<style lang="scss" scoped>
.not-centered
{
top: unset;
margin-top: unset;
width: unset;
text-align: unset;
position: unset;
}
</style>
-106
View File
@@ -1,106 +0,0 @@
// Parent div for login
#login
{
}
// Logo image
#login-logo-image
{
width: 80%;
margin-bottom: -15px;
}
// Parent overlay
.login-overlay
{
// Credit to w3schools.com:
// https://www.w3schools.com/howto/howto_js_fullscreen_overlay.asp
// Fill entire screen
height: 100%;
width: 100%;
left: 0;
top: 0;
// Stay in place
position: fixed;
// Sit on top layer
z-index: 1;
// Overlay color
background-color: rgba(0,0,0, 0.65);
// Disable horizontal scroll
overflow-x: hidden;
// Make it a table for vertical centering
display: table;
}
.login-vertical-center
{
// Vertically center
display: table-cell;
vertical-align: middle;
}
// The user interacting panel
.login-panel
{
// Make it smaller
width: 256px;
// Center
margin-left: auto;
margin-right: auto;
// Borders
padding: 15px;
border-radius: 10px;
// box-shadow: 0 0 20px 0 white;
border: 1px solid #DCDFE6;
// Make it white
background-color: white;
// Input bars
.el-input
{
margin: 5px 0;
}
// Button
.el-button
{
margin: 5px 0;
width: 100%;
}
}
// Error
.input-error
{
.el-input__inner
{
color: #ff3a3a6b !important;
border-color: #ff3a3a6b !important;
background-color: #ffdddd3b !important;
}
.el-input__inner:focus
{
background-color: white !important;
}
}
// Fix error message
.el-form-item__error.custom
{
padding-top: 0;
position: relative;
top: auto;
float: left;
}
-130
View File
@@ -1,130 +0,0 @@
import {Component, Vue} from 'vue-property-decorator';
import Constants from '@/constants';
import App from '@/components/app/app';
import VersionUtils from '@/logic/utils/version-utils';
import LoginUser from '@/logic/login-user';
import Maintenance from '@/components/overlays/maintenance.vue';
/**
* This component handles user login, and obtains data from the server.
*/
@Component({components: {Maintenance}})
export default class Login extends Vue
{
username = '';
password = '';
loading = false;
error = '';
disableInput = false;
maintenance = '';
/**
* This is called when the instance is created.
*/
created()
{
// TODO: Check maintenance
// Check login cookies
if (this.$cookies.isKey('va.token'))
{
// Check cookies version
if (this.needToUpdateCookies()) this.clearCookies();
else
{
// Login with token
this.login('/login/token', {token: this.$cookies.get('va.token')});
}
}
}
/**
* Check version number
*
* @returns boolean Need to clear cookies or not
*/
needToUpdateCookies(): boolean
{
// Version doesn't exist
if (!this.$cookies.isKey('va.version')) return true;
// Bug
if (this.$cookies.get('va.token') == 'undefined') return true
// If the current version is less than the min supported version
return VersionUtils.compare(this.$cookies.get('va.version'), Constants.MIN_SUPPORTED_VERSION) == -1;
}
/**
* When the user clicks, post the login request and process the response
* This is also called when the user hits enter on the input boxes.
*/
loginClick()
{
// Simple checks
if (this.username == '')
{
this.error = 'Username cannot be blank 🤔';
}
// Format it
this.username = this.username.toLowerCase().replace(/ /g, '').replace(/@.*/g, '');
// Actually login
this.login('/login', {username: this.username, password: this.password})
}
/**
* Actually post the request and process the response
*/
login(url: string, data: any)
{
// Show loading
this.disableInput = this.loading = true;
// Fetch request
App.http.post(url, data).then(response =>
{
// Check success
if (response.success)
{
// Save token to cookies
this.$cookies.set('va.token', response.data.token, '27d');
this.$cookies.set('va.version', Constants.VERSION, '27d');
// Call a custom event with the token
this.$emit('login:user', new LoginUser(response.data));
}
else
{
// Login expired -> clear cookies
if (response.data == 'Error: Login expired')
{
this.clearCookies();
}
// Show error message & allow user to retry
// TODO: Automatic report error
this.error = response.data;
this.disableInput = this.loading = false;
}
})
.catch(err =>
{
// Show error message & allow user to retry
this.error = err;
this.disableInput = this.loading = false;
});
}
/**
* Clear cookies
*/
clearCookies()
{
this.$cookies.keys().forEach(key => this.$cookies.remove(key));
}
}
-36
View File
@@ -1,36 +0,0 @@
<template>
<div id="login" class="login-overlay">
<div class="login-vertical-center">
<div class="login-panel">
<img id="login-logo-image" alt="logo" src="../../assets/logo.png">
<h1>Veracross Analyzer</h1>
<form id="login-form">
<el-input v-model="username"
placeholder="SJP Username (Eg. flast21)"
:class="{'input-error': error !== ''}"
v-if="!disableInput"
@keyup.enter.native="loginClick">
</el-input>
<el-input v-model="password"
placeholder="SJP Password"
show-password=""
:class="{'input-error': error !== ''}"
v-if="!disableInput"
@keyup.enter.native="loginClick">
</el-input>
<div class="el-form-item__error custom">{{error}}</div>
<el-button plain type="primary" @click="loginClick" :loading="loading">Login</el-button>
</form>
</div>
</div>
<Maintenance v-if="maintenance" :message="maintenance"/>
</div>
</template>
<script src="./login.ts" lang="ts"></script>
<style src="./login.scss" lang="scss"/>
-154
View File
@@ -1,154 +0,0 @@
.el-menu.centered li
{
display: inline-block !important;
float: none !important;
}
// Borders
#navigation
{
box-shadow: 0 2px 12px 0 rgba(0,0,0,.1);
ul
{
border-bottom-width: 0;
}
}
#nav-avatar
{
position: absolute;
right: 0;
margin: 10px 20px;
}
#sign-out-button
{
// Float right
position: absolute;
right: 0;
// Set width and height
height: 60px;
width: 110px;
}
#nav-grading-period
{
// Float right
position: absolute;
right: 80px;
// Margins
margin-top: 12px;
margin-bottom: 12px;
}
#nav-title
{
// Float left
position: absolute;
left: 0;
// Set height
height: 60px;
// Center text
display: inline-flex;
align-items: center;
// Margins
margin-left: 20px;
margin-right: 8px;
// Make it non-clickable
pointer-events: none;
#nav-logo
{
height: 70%;
margin-right: 10px;
}
#nav-logo-text
{
// Color
color: #6bbeff !important;
background: linear-gradient(90deg,
rgba(90,177,239,1) 0%,
rgba(25,212,174,1) 100%) !important;
// Font
font-weight: 500;
font-size: 18px;
}
#nav-logo-text.logo-text
{
// Override the background
-webkit-text-fill-color: transparent !important;
-webkit-background-clip: text !important;
}
#nav-logo-version
{
color: #a7a7a7;
margin-left: 5px;
margin-top: 8px;
font-size: 10px;
}
}
#next-course
{
// Down center
width: 50%;
position: absolute;
bottom: 0;
left: 25%;
padding-top: 2px;
box-shadow: 0 -2px 9px 0 #00000029;
}
footer
{
position: fixed;
left: 0;
bottom: 0;
width: 100%;
z-index: 1000;
}
#prev-course
{
// Up center
width: 50%;
position: absolute;
top: 61px;
left: 25%;
padding-bottom: 2px;
box-shadow: 0 2px 9px 0 #00000029;
z-index: 1001;
}
.nav-course-operations
{
// Background
background-color: rgba(214, 214, 214, 0.67);
opacity: 0.85;
// Font
font-size: 14px;
color: #ab8585;
// Cursor
cursor: pointer;
}
.el-submenu__title
{
padding-right: 5px !important;
}
-165
View File
@@ -1,165 +0,0 @@
import {Component, Prop, Vue} from 'vue-property-decorator';
import Course from '@/logic/course';
import Constants from '@/constants';
import LoginUser from '@/logic/login-user';
import NavController from '@/logic/nav-controller';
import App from '@/components/app/app.ts';
/**
* This component is the top navigation bar
*/
@Component
export default class Navigation extends Vue
{
@Prop({required: true}) app: App;
@Prop({required: true}) nav: NavController;
@Prop({required: true}) user: LoginUser;
private gradingPeriod: string = 'All Year';
// Instance
static instance: Navigation;
/**
* This is called when the instance is created.
*/
created()
{
// Check selected time
if (!this.$cookies.isKey('va.grading-period'))
{
this.$cookies.set('va.grading-period', this.gradingPeriod, '10y');
}
this.gradingPeriod = this.$cookies.get('va.grading-period');
}
/**
* This is called when the instance is loaded.
*/
mounted()
{
Navigation.instance = this;
}
/**
* This function is called when the selection changes.
*
* @param index The index selected
* @param indexPath The path of the index
*/
onSelect(index: string, indexPath: string)
{
// Update active index
try
{
// Is json
this.nav.updateIndex(JSON.parse(index))
}
catch (e)
{
// Not json
this.nav.updateIndex(index);
}
}
/**
* Move to the next course
*
* @param indexOffset Index offset (Eg. 1 for next)
*/
nextCourse(indexOffset: number)
{
// Set tab to the next index
this.nav.updateIndex(this.findNextCourse(indexOffset).urlIndex)
}
/**
* Find the next course
*
* @param indexOffset Index offset (Eg. 1 for next)
*/
findNextCourse(indexOffset: number)
{
return this.findCourse(this.nav.info.id, indexOffset);
}
/**
* Find course
*
* @param courseId Course ID
* @param indexOffset Index offset (Eg. 1 for next)
*/
findCourse(courseId: string, indexOffset: number)
{
// Find current course index
let courseIndex = this.app.gradedCourses.findIndex(c => c.id == +courseId);
// Find next course
return this.app.gradedCourses[courseIndex + indexOffset];
}
/**
* Select grading period
*
* @param command Term 1, Term 2, All Year, etc.
*/
selectGradingPeriod(command: string)
{
this.gradingPeriod = command;
this.$cookies.set('va.grading-period', command, '10y');
// Call event
this.$emit('select-time', this.getSelectedTerm());
}
/**
* Get code for selected time
*/
getSelectedTerm(): number
{
if (this.gradingPeriod == 'All Year') return -1;
else return +this.gradingPeriod.replace('Term ', '') - 1;
}
/**
* Avatar dropdown menu event
*
* @param cmd Command: sign-out
*/
onAvatarMenu(cmd: string)
{
switch (cmd)
{
case 'sign-out':
{
this.$emit('sign-out');
break
}
case 'switch-dark':
{
this.app.darkMode = !this.app.darkMode;
if (this.app.darkMode) this.$cookies.set('dark', true);
else this.$cookies.remove('dark');
break
}
case 'switch-rating':
{
this.app.showRating = !this.app.showRating;
if (this.app.showRating) this.$cookies.set('show-rating', 'set=yes', '30d');
else this.$cookies.set('show-rating', 'set=no', '30d');
break
}
case 'updates':
{
this.app.showUpdates()
break
}
}
}
get version() {return Constants.VERSION}
}
-78
View File
@@ -1,78 +0,0 @@
<template>
<div id="navigation">
<el-menu style="margin-bottom: 10px;" class="centered" mode="horizontal"
:default-active="nav.id" @select="onSelect">
<div id="nav-title">
<img id="nav-logo" alt="logo" src="../../assets/logo.png">
<span id="nav-logo-text" class="logo-text">Veracross Analyzer</span>
<span id="nav-logo-version">v{{version}}</span>
</div>
<el-menu-item index="overall">Overall</el-menu-item>
<el-submenu index="">
<template slot="title">Courses</template>
<el-menu-item v-for="course in app.gradedCourses"
:index="JSON.stringify(course.urlIndex)"
:key="course.id">{{course.name}}</el-menu-item>
</el-submenu>
<el-menu-item index="course-selection">Course Selection</el-menu-item>
<!-- Grading period selection -->
<el-dropdown id="nav-grading-period" @command="selectGradingPeriod">
<el-button type="primary" size="medium">
{{gradingPeriod}}<i class="el-icon-arrow-down el-icon--right"/>
</el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="Term 1">Term 1</el-dropdown-item>
<el-dropdown-item command="Term 2">Term 2</el-dropdown-item>
<el-dropdown-item command="Term 3">Term 3</el-dropdown-item>
<el-dropdown-item command="Term 4">Term 4</el-dropdown-item>
<!-- TODO: Auto enable / disable quarters -->
<el-dropdown-item command="All Year" divided>All Year</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
<!-- User avatar -->
<el-dropdown id="nav-avatar" trigger="click" @command="onAvatarMenu">
<el-avatar :src="user.avatarUrl"/>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item style="text-align: center">{{user.firstName}}</el-dropdown-item>
<el-dropdown-item icon="el-icon-sunrise" command="switch-dark" divided>
{{!app.darkMode ? 'Dark Mode (Unfinished)' : 'Light Mode'}}
</el-dropdown-item>
<el-dropdown-item icon="el-icon-edit-outline" command="switch-rating">
{{app.showRating ? 'Hide rating buttons' : 'Show rating button'}}
</el-dropdown-item>
<el-dropdown-item icon="el-icon-cold-drink" command="updates">
Check out the updates
</el-dropdown-item>
<el-dropdown-item icon="el-icon-switch-button" command="sign-out" divided>Sign Out</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</el-menu>
<!-- Previous course / Next course (Only when the page is courses) -->
<div v-if="nav.id === 'course' && findNextCourse(-1) != null"
@click="nextCourse(-1)" id="prev-course" class="nav-course-operations unselectable">
PREVIOUS COURSE
</div>
<footer>
<div v-if="nav.id === 'course' && findNextCourse(1) != null"
@click="nextCourse(1)" id="next-course" class="nav-course-operations unselectable">
NEXT COURSE
</div>
</footer>
<!-- Back to top -->
<el-backtop style="box-shadow: rgba(0, 0, 0, 0.23) 0 3px 11px 0;"/>
</div>
</template>
<script src="./navigation.ts" lang="ts"></script>
<style src="./navigation.scss" lang="scss"/>
-112
View File
@@ -1,112 +0,0 @@
<template>
<div id="loading">
<div id="text" :class="message">
{{message}}
<div v-if="!error" class="el-loading-spinner">
<svg viewBox="25 25 50 50" class="circular">
<circle cx="50" cy="50" r="20" fill="none" class="path" />
</svg>
</div>
<div v-if="error" id="error-details">
<span v-for="(line, index) in split" :style="`font-size: ${-index === 0 ? 16 : 12}px;`">
{{line}}
<br>
</span>
</div>
</div>
<div v-if="!error" id="details">
<span v-for="(line, index) in split" :style="`font-size: ${16 - split.length + index}px;`">
{{line}}
<br>
</span>
</div>
</div>
</template>
<script lang="ts">
import {Component, Prop, Vue} from 'vue-property-decorator';
@Component
export default class Loading extends Vue
{
@Prop({required: true}) text: string;
@Prop({required: true}) error: boolean;
get split()
{
return this.text.split('\n');
}
get message()
{
return this.error ? 'Error' : 'Loading';
}
}
</script>
<style scoped>
#loading
{
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
box-shadow: inset 0 0 1px 1px rgba(0,0,0,.1);
background: -webkit-linear-gradient(left, rgba(95, 18, 72, 0.4), rgba(42, 81, 117, 0.4) 100%);
text-align: center;
}
.Error
{
color: #ffdddd !important;
}
#text
{
color: white;
margin: 0;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 46px;
}
#details
{
width: 100%;
position: absolute;
bottom: 0;
left: 0;
margin-top: -5px;
font-size: 16px;
color: #f9f9f9;
}
#error-details
{
font-size: 16px;
}
.el-loading-spinner
{
top: unset !important;
margin-top: 0 !important;
width: unset !important;
position: unset !important;
}
.el-loading-spinner .path
{
stroke: white;
}
</style>
-63
View File
@@ -1,63 +0,0 @@
<template>
<div id="maintenance">
<div id="maintenance-content">
<h1>We&rsquo;ll be back soon!</h1>
<div>
<p>Sorry for the inconvenience but we&rsquo;re performing some maintenance at the moment.
We&rsquo;ll be back online shortly!</p>
<p>What went wrong: {{json.reason}}</p>
<p>Estimated fix: {{json.eta}}</p>
<p>&mdash; An Average SJP Junior</p>
</div>
</div>
</div>
</template>
<script lang="ts">
import {Component, Prop, Vue} from 'vue-property-decorator';
@Component
export default class Maintenance extends Vue
{
@Prop({required: true}) message: any;
get json()
{
return JSON.parse(this.message);
}
}
</script>
<style lang="scss" scoped>
#maintenance
{
z-index: 1000;
background: white;
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
#maintenance-content
{
font: 20px Helvetica, sans-serif;
color: #333;
display: block;
text-align: left;
margin: 150px;
h1
{
font-size: 50px;
}
a {color: #dc8100; text-decoration: none;}
a:hover {color: #333; text-decoration: none;}
}
}
</style>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

-36
View File
@@ -1,36 +0,0 @@
<template>
<div class="background">
<span :style="{width: (score / 5 * 100).toFixed(2) + '%'}" class="rating"/>
</div>
</template>
<script lang="ts">
import {Component, Prop, Vue} from 'vue-property-decorator'
@Component
export default class StarRating extends Vue
{
@Prop({required: true}) score: number;
}
</script>
<style lang="scss" scoped>
.background
{
background: url("./star-rating-sprite.png") repeat-x;
font-size: 0;
height: 21px;
line-height: 0;
overflow: hidden;
width: 110px;
margin: 0 auto;
span.rating
{
background: url("./star-rating-sprite.png") repeat-x 0 100%;
float: left;
height: 21px;
display: block;
}
}
</style>
-81
View File
@@ -1,81 +0,0 @@
/**
* This class stores the static constants.
*/
import {findLastIndex} from '@/logic/utils/general-utils';
export default class Constants
{
/** Base url for api access */
static API_URL: string = 'https://va.hydev.org/api';
// static API_URL: string = 'http://localhost:24021/api';
/** Current version */
static VERSION: string = '0.5.6.1761';
/** The minimum version that still supports the same cookies */
static MIN_SUPPORTED_VERSION: string = '0.4.6.1087';
static GITHUB: string = 'https://github.com/HyDevelop/VeracrossAnalyzer.Client';
static SPLASH: string =
'. , ,---. | \n' +
'| |. , |---|,---.,---.| , .,---,,---.,---.\n' +
' \\ / >< | || |,---|| | | .-\' |---\'| \n' +
' `\' \' ` ` \'` \'`---^`---\'`---|\'---\'`---\'` \n' +
' `---\' \n' +
` Version v${Constants.VERSION} by Hykilpikonna (YGui21)\n` +
` Github: ${Constants.GITHUB}`;
// Graph Theme
static THEME =
{
// Colors
colors:
[
'#19d4ae',
'#5ab1ef',
'#fa6e86',
'#ffb980',
'#0067a6',
'#c4b4e4',
'#d87a80',
'#9cbbff',
'#d9d0c7',
'#87a997',
'#d49ea2',
'#5b4947',
'#7ba3a8',
'#fc97af',
'#919e8b',
'#d7ab82',
'#6e7074',
'#61a0a8',
'#efa18d',
'#787464',
'#cc7e63',
'#724e58',
'#4b565b'
]
};
// Terms (TODO: Actually get the terms dynamically
static TERMS =
[
new Date('Sep 04 2019'),
new Date('Nov 03 2019'),
new Date('Jan 19 2020'),
new Date('Mar 22 2020'),
new Date('Jun 05 2020'),
];
static CURRENT_TERM = Constants.getTerm(new Date());
/**
* Find out the specified date is in which term
*
* @param date
*/
static getTerm(date: Date)
{
return findLastIndex(Constants.TERMS, d => d <= date);
}
}
-176
View File
@@ -1,176 +0,0 @@
import {CourseUtils} from '@/logic/utils/course-utils';
export default class CourseInfo
{
id_ci: number
year: number
name: string
teacher: string
level: string
courseIds: number[]
uniqueName: string
courseCount: number
gradeLevels: number[]
enrollments: number
classes: ClassInfo[]
levelID: number;
levelFull: string
rating: AnalyzedRating = null as any as AnalyzedRating
/**
* Construct with a json object
*
* @param json
*/
constructor(json: any)
{
this.id_ci = json.id_ci
this.year = json.year
this.name = json.name.trim().replace('&amp;', '&').replace('&quot;', '"')
this.teacher = json.teacher
this.level = json.level
this.courseIds = json.courseIds.split('|').map((id: string) => +id);
this.courseCount = this.courseIds.length;
this.gradeLevels = [];
this.uniqueName = CourseInfo.toUniqueName(this.name);
this.enrollments = 0;
this.classes = []
this.levelID = CourseUtils.getLevelID(this.level);
this.levelFull = CourseUtils.getLevelFullName(this.level);
}
static toUniqueName(name: string)
{
return name
.replace(/( Semester| Full Year|)/g, '')
.replace(/( Accelerated| Honors| College Prep|)/g, '')
.replace(/( A| Acc| CP| H| \(.*\))$/g, '');
}
}
export class UniqueCourse
{
name: string
courses: CourseInfo[]
enrollments: number
constructor(name: string, courses: CourseInfo[], enrollments: number)
{
this.name = name;
this.courses = courses;
this.enrollments = enrollments;
}
get classes()
{
return this.courses.flatMap(c => c.classes);
}
}
export class ClassInfo
{
id: number
name: string
teacher: string
level: string
uniqueName: string
/**
* Construct with a json object
*
* @param json
*/
constructor(json: any)
{
this.id = json.id;
this.name = json.name.trim().replace('&amp;', '&').replace('&quot;', '"')
this.teacher = json.teacher
this.level = json.level;
this.uniqueName = CourseInfo.toUniqueName(this.name);
}
}
export class CourseInfoRating
{
id_ci: number
id_user: number
firstName: string
lastName: string
anonymous: boolean
ratings: number[]
comment: string
averageRating: number = 0
constructor(json: any)
{
this.id_ci = json.id_ci;
this.id_user = json.id_user;
this.anonymous = this.id_user == -1;
this.ratings = json.ratings;
this.comment = json.comment;
if (json.userFullName != null)
{
let nameSplit = json.userFullName.split(']=[');
this.firstName = nameSplit[0];
this.lastName = nameSplit[1];
}
this.ratings.forEach(r => this.averageRating += r);
this.averageRating /= 5;
}
/**
* Create new for posting to the server
* @param id_ci
*/
public static createNew(id_ci: number)
{
return new CourseInfoRating({id_ci: id_ci, id_user: -2, userFullName: null,
anonymous: false, ratings: [0,0,0,0,0], comment: ''})
}
}
export class AnalyzedRating
{
ratingCounts: number[][] // ratingCounts[criteria][stars] = count
ratingSums: number[] // ratingSums[criteria] = total stars
totalCount: number
ratingAverages: number[] = [] // ratingAverages[criteria] = average
overallRating: number = 0
constructor(json: any)
{
this.ratingCounts = json.ratingCounts;
this.ratingSums = json.ratingSums;
this.totalCount = json.totalCount;
// No ratings
if (this.totalCount == 0)
{
this.overallRating = -1;
return;
}
// Calculate overall rating
this.ratingSums.forEach((criteriaScore, i) => this.overallRating += this.ratingAverages[i] = criteriaScore / this.totalCount);
this.overallRating /= this.ratingAverages.length;
}
}
export const RATING_CRITERIA: {title: string, desc: string}[] =
[
{title: 'Enjoyable', desc: 'How enjoyable is the course?'},
{title: 'Knowledge', desc: 'How interesting is the content of the course? ' +
'Is it something you feel worth learning?'},
{title: 'Interactivity', desc: 'How interesting is the teacher? Is the teacher interactive?'},
{title: 'Eloquence', desc: `Are the teacher's lectures easy to understand?`},
{title: 'Fairness', desc: `How fair is the teacher's grading? Is credit given in proportion to effort?`}
];
-436
View File
@@ -1,436 +0,0 @@
import {FormatUtils} from '@/logic/utils/format-utils';
import {CourseUtils} from '@/logic/utils/course-utils';
import Navigation from '@/components/navigation/navigation';
import {GPAUtils} from '@/logic/utils/gpa-utils';
import CacheUtils from '@/logic/utils/cache-utils';
import Constants from '@/constants';
import {Index} from '@/logic/nav-controller';
import App from '@/components/app/app';
import {CourseInfoRating} from '@/logic/course-info';
/**
* Objects of this interface represent assignment grades.
*/
export class Assignment
{
id: number;
scoreId: number;
type: string;
typeId: number;
description: string;
time: number;
complete: string;
include: boolean;
display: boolean;
unread: boolean;
scoreMax: number;
score: number;
gradingPeriod: number;
// Callbacks when this object updates
private updateCallbacks: (() => void)[] = [];
/**
* Construct assignment with json object
*
* @param json Json object
*/
constructor(json: any)
{
this.id = json.assignment_id;
this.scoreId = json.score_id;
this.type = json.assignment_type;
this.typeId = json.assignment_type_id;
this.description = json.assignment_description;
this.time = new Date(json._date).getTime();
this.complete = json.completion_status;
this.include = json.include_in_calculated_grade == 1;
this.display = json.display_grade == 1;
this.unread = json.is_unread == 1;
this.scoreMax = json.maximum_score;
this.score = +json.raw_score;
// 0, 1, 2, 3 contains quarter assignments, 4 contains final assignments
if (json.grading_period.toLowerCase() == 'all') this.gradingPeriod = 4;
else this.gradingPeriod = +json.grading_period.replace('Quarter ', '') - 1;
}
/**
* Graded or not
*/
get graded()
{
// TODO: Add more cases
// Incomplete doesn't mean that the teacher didn't grade it yet, which is "Pending".
// NREQ is not graded.
return this.include && (this.complete == 'Complete' || this.complete == 'Late' || this.complete == 'Incomplete' || this.complete == 'Not Turned In');
}
/**
* What is the problem with this assignment
*
* @return string Empty string if complete, otherwise return problem.
*/
get problem()
{
switch (this.complete)
{
case 'Pending': return 'Pending'; // ID: 0
case 'Not Turned In': return 'Not Turned In'; // ID: 1
case 'Incomplete': return 'Incomplete'; // ID: 2
case 'Complete': return ''; // ID: 3
case 'NREQ': return 'Dropped'; // ID: 4
case 'Late': return 'Late';
default: return this.complete;
}
}
/**
* Get the text color of the problem
*/
get problemColor()
{
switch (this.complete)
{
case 'Pending': return '#b1b1b1';
case 'Not Turned In': return '#ff0036';
case 'Incomplete': return '#ff7a2f';
case 'NREQ': return '#41b141';
case 'Late': return '#ff7a2f';
}
}
/**
* Add callback
*
* @param callback
*/
addCallback(callback: () => void)
{
this.updateCallbacks.push(callback);
}
/**
* Mark as read
*/
markAsRead(): Promise<void>
{
return new Promise((resolve, reject) => {
App.http.post('/mark-as-read', {scoreId: this.scoreId})
.then(response =>
{
// Check success
if (response.success)
{
this.unread = false;
this.updateCallbacks.forEach(callback => callback());
resolve();
}
else reject(response.data);
})
.catch(reject)
})
}
}
export interface AssignmentType
{
id: number
name: string
weight: number
scoreMax: number
score: number
percent: number
assignmentCount: number
graded: boolean
}
export interface Grading
{
method: string
weightingMap: {[index: string]: number}
}
export default class Course
{
id: number
id_ci: number
assignmentsId: number
name: string
teacherName: string
status: string
rawAssignments: Assignment[]
rating: CourseInfoRating
rated: boolean
rawLetterGrade?: string
rawNumericGrade?: number
level: string
scaleUp: number
termGrading: Grading[]
termAssignments: Assignment[][]
cache: CacheUtils = new CacheUtils();
/**
* Construct a course with a course json object
*
* @param courseJson Course json object
*/
constructor(courseJson: any)
{
this.id = courseJson.id;
this.id_ci = courseJson.id_ci;
this.assignmentsId = courseJson.assignmentsId;
this.name = FormatUtils.parseText(courseJson.name).trim();
this.teacherName = courseJson.teacherName;
this.status = courseJson.status;
this.rated = courseJson.rating != null;
this.rating = this.rated ? new CourseInfoRating(courseJson.rating) : CourseInfoRating.createNew(this.id_ci);
this.rawLetterGrade = courseJson.letterGrade;
this.rawNumericGrade = courseJson.numericGrade;
// Other api issue
if (this.rawLetterGrade == '')
{
this.rawNumericGrade = undefined;
this.rawLetterGrade = undefined;
}
// Level and scaleUp TODO: Use server course level
let level = CourseUtils.getLevel(courseJson.level);
this.level = level.level;
this.scaleUp = level.scaleUp;
this.termGrading = new Array(4).fill(null);
}
/**
* Load in assignments data
*
* @param data Assignments data
*/
loadAssignments(data: any)
{
// Load assignments
// Parse json and filter it
this.rawAssignments = data.assignments.map((a: any) => new Assignment(a));
// Sort by date (Latest is at 0)
this.rawAssignments.sort((a, b) => b.time - a.time);
// Filter assignments into terms
this.termAssignments = [[], [], [], [], []];
// Loop through it by time order
this.rawAssignments.forEach(a => this.termAssignments[a.gradingPeriod].push(a));
}
/**
* Is graded or not
*/
get isGraded(): boolean
{
// Skip future or past courses
if (this.status != 'active') return false;
// Skip courses without levels TODO: Ask for user input
if (this.level == 'None' || this.level == 'Unknown' || this.scaleUp == -1) return false;
// Skip courses without graded assignments
if (this.assignments.length == 0) return false;
// Skip if there are no grading scale
// if (course.grading.method == 'NOT_GRADED') return;
// Is graded
return true;
}
/**
* Get currently selected grading periods
*/
get gradingPeriods(): number[]
{
return this.cache.get('GradingPeriods', () =>
{
return (this.rawSelectedTerm == -1 ? [0, 1, 2, 3] : [this.rawSelectedTerm]).filter(term =>
this.termAssignments[term].filter(a => a.graded).length != 0);
})
}
/**
* Get currently selected grading periods
*/
get allGradingPeriods(): number[]
{
return this.cache.get('AllGradingPeriods', () =>
{
return [0, 1, 2, 3].filter(term => this.termAssignments[term].filter(a => a.graded).length != 0);
})
}
/**
* Get assignments of the selected grading periods
*/
get assignments(): Assignment[]
{
return this.gradingPeriods
.flatMap(term => this.termAssignments[term])
.sort((a, b) => b.time - a.time);
}
/**
* Get assignments before a certain date
*
* @param time
*/
getAssignmentsBefore(time: number): {term: number, assignments: Assignment[]}
{
let term = Constants.getTerm(new Date(time));
let assignments = this.assignments.filter(a => a.gradingPeriod == term && a.time <= time);
return {term: term, assignments: assignments}
}
/**
* Get letter grade
*/
get letterGrade(): string
{
return this.cache.get('LetterGrade', () =>
{
// Get scale
let scale = GPAUtils.findScale(this.numericGrade);
// Scale not found
return scale == undefined ? '--' : scale.letter;
})
}
/**
* Get letter grade by term
*
* @param term
*/
letterGradeTerm(term: number): string
{
return this.cache.get('LetterGrade' + term, () =>
{
// Get scale
let scale = GPAUtils.findScale(this.numericGradeTerm(term));
// Scale not found
return scale == undefined ? '--' : scale.letter;
})
}
/**
* Get numeric grade
*/
get numericGrade()
{
return this.cache.get('NumericGrade', () =>
{
return this.gradingPeriods.map(term => this.numericGradeTerm(term))
.reduce((p, v) => p + v) / this.gradingPeriods.length
})
}
/**
* Get numeric grade by term
*
* @param term
*/
numericGradeTerm(term: number): number
{
return this.cache.get('NumericGrade' + term, () =>
{
// Calculate
if (this.termGrading[term].method == 'PERCENT_TYPE')
{
return GPAUtils.getPercentTypeAverage(this.termGrading[term], this.termAssignments[term]);
}
else if (this.termGrading[term].method == 'TOTAL_MEAN')
{
return GPAUtils.getTotalMeanAverage(this.termAssignments[term]);
}
else return -1;
})
}
/**
* Get assignment types
*/
get assignmentTypes(): AssignmentType[]
{
return this.cache.get('AssignmentTypes', () =>
{
// Get all types
let types = this.assignments.map(a => a.type);
// Remove duplicates
types = types.filter((type, i, a) => a.indexOf(type) == i);
// Get total possible score for weight calculation
let totalScoreMax = this.assignments.reduce((sum, a) => sum + a.scoreMax, 0);
// For every type...
return types.map(type =>
{
// Get assignments of the type
let typeAssignments = this.assignments.filter(a => a.type == type);
// Get graded assignments
let gradedAssignments = typeAssignments.filter(a => a.graded);
// Count scores and max scores
let score = gradedAssignments.reduce((sum, a) => sum + a.score, 0);
let scoreMax = gradedAssignments.reduce((sum, a) => sum + a.scoreMax, 0);
// Calculate weight
let weight = this.termGrading[0].method == 'PERCENT_TYPE'
? this.termGrading[0].weightingMap[type] : scoreMax / totalScoreMax;
// Return
return {name: type, id: typeAssignments[0].typeId, weight: +(weight * 100).toFixed(2),
scoreMax: scoreMax, score: score, percent: +(score / scoreMax * 100).toFixed(2),
assignmentCount: typeAssignments.length, graded: gradedAssignments.length > 0}
})
})
}
/**
* Get url hash code
*/
get urlHash(): string
{
return `course/${this.id}`
}
/**
* Get navigation index
*/
get urlIndex(): Index
{
return {hash: this.urlHash, title: this.name, identifier: 'course', info: {id: this.id}}
}
/**
* Selected term
*/
get rawSelectedTerm(): number
{
return Navigation.instance.getSelectedTerm()
}
}
-56
View File
@@ -1,56 +0,0 @@
import {GPAUtils} from '@/logic/utils/gpa-utils';
import Course from '@/logic/course';
const md5 = require('md5');
export default class LoginUser
{
id: number
schoolPersonPk: number
username: string
lastLogin: Date
firstLogin: Date
firstName: string
lastName: string
graduationYear: number
emails: string[]
classes: string[]
avatarUrl: string
token: string
courses: Course[]
gradeLevel: number
gradeLevelName: string
constructor(jsonData: any)
{
let json = jsonData.user
this.id = json.id;
this.schoolPersonPk = json.schoolPersonPk;
this.username = json.username;
this.lastLogin = new Date(json.lastLogin);
this.firstLogin = new Date(json.firstLogin);
this.firstName = json.firstName;
this.lastName = json.lastName;
this.graduationYear = +json.graduationYear;
this.emails = json.emails.split('|').map((e: any) => e.toLowerCase().trim());
this.classes = json.classes.split('|');
this.avatarUrl = json.avatarUrl;
// Extracted in newer versions
this.token = jsonData.token;
this.courses = jsonData.courses.map((courseJson: any) => new Course(courseJson));
// Calculated grade level
this.gradeLevel = GPAUtils.getGradeLevel(this.graduationYear);
this.gradeLevelName = GPAUtils.gradeLevelName(this.gradeLevel);
// Generate default avatar
if (this.avatarUrl == null || this.avatarUrl == '')
{
this.avatarUrl = `https://www.gravatar.com/avatar/${md5(this.emails[0])}?d=404` + encodeURIComponent(
`https://ui-avatars.com/api/${this.firstName.charAt(0)}${this.lastName.charAt(0)}/128`);
}
}
}
-160
View File
@@ -1,160 +0,0 @@
import {FormatUtils} from '@/logic/utils/format-utils';
import pWaitFor from 'p-wait-for';
import App from '@/components/app/app';
export interface Index
{
hash: string
title?: string
identifier: string
info?: any
}
export default class NavController
{
// Current index
index: Index;
// Callback
updateCallback?: () => void;
constructor()
{
// Create history state listener
window.onpopstate = (e: any) =>
{
if (e.state)
{
// Restore previous tab
//console.log(`onPopState: Current: ${this.index.hash}, Previous: ${e.state.hash}`);
this.updateIndex(e.state, false);
}
};
// Initialize
this.init()
}
/**
* Initialize from last location
*/
private init()
{
if (window.location.hash == '#info') return;
// Check history from last session
if (window.history.state != undefined && window.history.state.hash != undefined)
{
// Last history exists
this.index = window.history.state;
return;
}
// Last history doesn't exist but hash url might exist
let hash = window.location.hash.replace('#', '');
// Check hash
if (hash == '')
{
// No location info in url, set page to overall
window.history.replaceState(this.convertIndex('overall'), '', '/#overall');
this.updateIndex('overall', false);
return;
}
// There is hash info in url
let split = hash.split('/');
// Not course -> don't know what to do with this url, so just refresh
if (split[0] != 'course')
{
this.initClear();
return;
}
// Is course -> Update index with placeholder title
this.updateIndex({hash: hash, title: `Loading...`, identifier: 'course', info: {id: +split[1]}}, false);
// Wait for courses to finish loading
pWaitFor(() => App.instance != undefined && App.instance.assignmentsReady).then(() =>
{
// Find course
let course = App.instance.courses.find(c => c.id == +split[1]);
// This person has no such course, refresh to overall
if (course == null)
{
this.initClear();
return;
}
window.history.replaceState(course.urlIndex, '', '/#' + course.urlHash);
this.updateIndex(course.urlIndex, false);
})
}
private initClear()
{
window.location.hash = '';
window.location.reload();
}
/**
* Update index
*
* @param index Hash and title | Hash only
* @param history Record in history or not (Default true)
*/
updateIndex(index: Index | string, history: boolean = true)
{
index = this.convertIndex(index);
// Call custom event
if (this.updateCallback != null) this.updateCallback();
// Record history or not
if (history)
{
//console.log(`history: Current: ${this.index.hash}, New: ${index.hash}`);
// Check url
let url = `/#${index.hash}`;
// Push history state
window.history.pushState(index, '', url);
}
// Update title
document.title = 'Veracross Analyzer - ' + index.title;
// Scroll to top
window.scrollTo(0, 0);
// Update selected index
this.index = index;
}
/**
* Check index conversion
*
* @param index Hash and title | Hash only
* @return Index Hash and title
*/
private convertIndex(index: Index | string): Index
{
// Convert index format if it is hash only
if (typeof index == 'string') index = {hash: index, identifier: index};
if (index.title == null) index.title = FormatUtils.toTitleCase(index.hash);
return index;
}
get id(): string
{
return this.index.identifier
}
get info(): any
{
return this.index.info
}
}
-20
View File
@@ -1,20 +0,0 @@
export default class CacheUtils
{
map: Map<string, any> = new Map();
/**
* Get a cached value, or if not cached, cache it.
*
* @param name Name of the cached value
* @param callback Callback function
*/
public get(name: string, callback: () => any)
{
if (!this.map.has(name))
{
this.map.set(name, callback());
}
return this.map.get(name);
}
}
-96
View File
@@ -1,96 +0,0 @@
import Navigation from '@/components/navigation/navigation';
import Constants from '@/constants';
import {isNumeric} from '@/logic/utils/general-utils';
const LEVEL_AP = {level: 'AP', scaleUp: 1};
const LEVEL_H = {level: 'H', scaleUp: 0.75};
const LEVEL_A = {level: 'A', scaleUp: 0.5};
const LEVEL_CP = {level: 'CP', scaleUp: 0.25};
const LEVEL_CLUB = {level: 'Club', scaleUp: -1};
const LEVEL_SPORT = {level: 'Sport', scaleUp: -1};
const LEVEL_NONE = {level: 'None', scaleUp: -1};
const LEVEL_UNKNOWN = {level: 'Unknown', scaleUp: -1};
export class CourseUtils
{
/**
* Get the begin date of the selected term
*/
static getTermBeginDate()
{
let selected = Navigation.instance.getSelectedTerm();
return selected == -1 ? Constants.TERMS[0] : Constants.TERMS[selected];
}
/**
* Get the end date of the selected term
*/
static getTermEndDate()
{
let selected = Navigation.instance.getSelectedTerm();
return selected == -1 ? Constants.TERMS[4] : Constants.TERMS[selected + 1];
}
static getLevelID(level: string)
{
if (level == undefined) return -1;
level = level.toLowerCase();
if (level == 'ap' || level == 'advanced placement') return 1;
if (level == 'h' || level == 'honors') return 2;
if (level == 'a' || level == 'acc' || level == 'accelerated') return 3;
if (level == 'cp' || level == 'college prep') return 4;
if (level == 'club') return 101;
if (level == 'sport') return 102;
if (level == 'none') return 201;
if (isNumeric(level)) return +level;
return -1;
}
/**
* Get full name of a level from short name
*
* @param level Any level name
*/
static getLevelFullName(level: string)
{
switch (this.getLevelID(level))
{
case 1: return 'AP';
case 2: return 'Honors';
case 3: return 'Accelerated';
case 4: return 'CP';
case 101: return 'Club';
case 102: return 'Sport';
case 201: return 'None';
default: return '--';
}
}
/**
* Get full name of a level from short name
*
* @param level Any level name
*/
static getLevel(level: string)
{
switch (this.getLevelID(level))
{
case 1: return LEVEL_AP;
case 2: return LEVEL_H;
case 3: return LEVEL_A;
case 4: return LEVEL_CP;
case 101: return LEVEL_CLUB;
case 102: return LEVEL_SPORT;
case 201: return LEVEL_NONE;
default: return LEVEL_UNKNOWN;
}
}
}
-34
View File
@@ -1,34 +0,0 @@
export class FormatUtils
{
/**
* Limit string length
*
* @param str String
* @param length Max length
*/
public static limit(str: string, length: number): string
{
return str.length <= length ? str : str.substr(0, length - 2) + '...'
}
/**
* To Title Case
*
* @param str oRigInAL sTrING
* @return string Original String
*/
public static toTitleCase(str: string)
{
return str.replace(/\w\S*/g, s => s.charAt(0).toUpperCase() + s.substr(1).toLowerCase())
}
/**
* Parse html text
*
* @param str
*/
public static parseText(str: string): string
{
return str.replace(/&amp;/g, '&');
}
}
-12
View File
@@ -1,12 +0,0 @@
export function findLastIndex<T>(array: T[], callback: (v: T) => boolean): number
{
let arr2 = array.slice().reverse();
let result = arr2.findIndex(callback);
return result == -1 ? -1 : arr2.length - result - 1;
}
export function isNumeric(str: string)
{
return !isNaN(parseFloat(str)) && isFinite(+str);
}
-232
View File
@@ -1,232 +0,0 @@
import Course, {Assignment, Grading} from '@/logic/course';
export interface Scale
{
min: number
letter: string
gp: number
}
/**
* This is an utility class to calculate GPA.
*/
export class GPAUtils
{
// [[Min score, Letter grade, Base GPA], ...]
public static SCALE: Scale[] =
[
{min: 96.5, letter: 'A+', gp: 4.00},
{min: 92.5, letter: 'A' , gp: 3.75},
{min: 89.5, letter: 'A-', gp: 3.50},
{min: 86.5, letter: 'B+', gp: 3.25},
{min: 82.5, letter: 'B' , gp: 3.00},
{min: 79.5, letter: 'B-', gp: 2.75},
{min: 76.5, letter: 'C+', gp: 2.50},
{min: 72.5, letter: 'C' , gp: 2.25},
{min: 70.5, letter: 'C-', gp: 2.00},
{min: 69.5, letter: 'D' , gp: 1.00},
{min: 0 , letter: 'F' , gp: 0.00}
];
/**
* Calculate GPA for a list of couses
*
* @param coursesOriginal List of courses
*/
public static getGPA(coursesOriginal: Course[]): {gpa: number, accurate: boolean, max: number}
{
// Clone array
let courses: Course[] = [];
// Accurate or not
let accurate: boolean = true;
// Remove all courses that does not have a grade
coursesOriginal.forEach(course =>
{
if (course.letterGrade == null || course.letterGrade == '')
{
accurate = false;
}
else if (course.level != 'none' && !isNaN(course.numericGrade))
{
courses.push(course);
}
});
// If no course have grade, return -1
if (courses.length == 0)
{
return {gpa: -1, accurate: false, max: -1};
}
// Count total GPA
let totalGPA = 0;
let maxTotal = 0;
courses.forEach(course =>
{
totalGPA += this.getGP(course, course.numericGrade);
maxTotal += this.getGP(course, 'A+');
});
// Get average GPA, round to two decimal places
let gpa = Math.round(totalGPA / courses.length * 100) / 100;
let maxGPA = Math.round(maxTotal / courses.length * 100) / 100;
// Return results
return {gpa: gpa, accurate: accurate, max: maxGPA};
}
/**
* Calculate GPA for a course
*
* @param course Course
* @param letterGrade Letter grade
*/
public static getGP(course: Course, letterGrade: string | number): number
{
// Get scale
let scale = this.findScale(letterGrade);
// No scale
if (scale == undefined) return -1;
// Add scaleUp if not failed.
return scale.gp == 0 ? 0 : scale.gp + course.scaleUp;
}
/**
* Find the scale for a grade
*
* @param grade Letter grade or numeric grade
*/
public static findScale(grade: string | number): Scale | undefined
{
// Letter grade
if (typeof grade == 'string')
{
return this.SCALE.find(scale => scale.letter == grade);
}
// Numeric grade
return this.SCALE.find(scale => grade >= scale.min);
}
/**
* Calculate the total-mean (total/max) average
*
* @param assignments
*/
public static getTotalMeanAverage(assignments: Assignment[])
{
let score = 0;
let max = 0;
// Loop through assignments
assignments.forEach(assignment =>
{
// If assignment should be displayed
if (!assignment.graded) return;
// Record scores
score += assignment.score;
max += assignment.scoreMax;
});
// Return
return +(score / max * 100).toFixed(2);
}
/**
* Calculate the percent type
*
* @param grading
* @param assignments
*/
public static getPercentTypeAverage(grading: Grading, assignments: Assignment[])
{
let typeScores: {[index: string]: any} = {};
let typeCounts: {[index: string]: any} = {};
// Loop through assignments
assignments.forEach(assignment =>
{
// If assignment should be displayed
if (!assignment.graded) return;
// Record scores
if (typeScores[assignment.type] == undefined) typeScores[assignment.type] = 0;
typeScores[assignment.type] += assignment.score / assignment.scoreMax;
if (typeCounts[assignment.type] == undefined) typeCounts[assignment.type] = 0;
typeCounts[assignment.type] ++;
});
// Count total percentage (This is to avoid less than expected cases)
// Eg. If HW = 25% and Quiz = 75%, I have 1 hw and 0 quiz
// Without total percentage, the avg grade I get is 25%.
let totalPercentage = 0;
for (let type in grading.weightingMap)
{
if (typeScores[type] != undefined)
{
totalPercentage += grading.weightingMap[type];
}
}
// Count
let score = 0;
for (let type in typeScores)
{
let typeFactor = grading.weightingMap[type] / totalPercentage;
score += typeScores[type] * typeFactor / typeCounts[type];
}
// Add average to the row
return +(score * 100).toFixed(2);
}
/**
* Get current school year
*/
public static getSchoolYear(): number
{
// Get current year
let currentYear = new Date().getFullYear();
// Convert current year to current school year: +1 if it's after August
if (new Date().getMonth() > 7) currentYear ++;
return currentYear;
}
/**
* Get grade level from graduation year
*
* @param graduationYear
*/
public static getGradeLevel(graduationYear: number): number
{
// Calculate grade level
return 12 - (graduationYear - this.getSchoolYear());
}
/**
* Get grade level name from grade level. (Eg. Freshman, Sophomore, etc.)
*
* @param gradeLevel
*/
public static gradeLevelName(gradeLevel: number): string
{
switch (gradeLevel)
{
case 9: return 'Freshman';
case 10: return 'Sophomore';
case 11: return 'Junior';
case 12: return 'Senior';
default: return 'Grade ' + gradeLevel;
}
}
}
-103
View File
@@ -1,103 +0,0 @@
import Constants from '@/constants';
import App from '@/components/app/app';
export default class GraphUtils
{
static DOT = '<span style="display:inline-block;margin-right:5px;border-radius:10px;width:9px;height:9px;background-color:{color}"></span>';
/**
* Base settings
*
* @param title
* @param subtitle
*/
static getBaseSettings(title?: String, subtitle?: String)
{
return {
// Color
color: Constants.THEME.colors,
backgroundColor: 'transparent',
// Title
title:
{
show: title != null,
textStyle:
{
fontSize: 13
},
text: title,
subtext: subtitle,
x: 'center'
},
}
}
/**
* Get term mark lines
*/
static getTermLines()
{
return {
silent: true,
symbol: 'none',
lineStyle: {color: Constants.THEME.colors[2]},
animationDuration: 500,
data: Constants.TERMS.map((term, index) =>
{
return {xAxis: term.getTime(), label: {formatter: `Term ${index + 1}`}}
})
}
}
/**
* Get mark areas for percentage scores
*/
static getGradeMarkAreas(opacity: number)
{
// TODO: Auto update after switching dark mode (possibly by refreshing)
opacity = App.instance.darkMode ? 0.1 : opacity;
return {
silent: true,
data:
[
// Above 100
[{itemStyle: {color: 'rgba(230,253,255)', opacity: opacity}, yAxis: 120}, {yAxis: 100}],
// 90 to 100
[{itemStyle: {color: 'rgba(241,255,237)', opacity: opacity}, yAxis: 100}, {yAxis: 90}],
// 80 to 90
[{itemStyle: {color: 'rgba(255,250,216)', opacity: opacity}, yAxis: 90}, {yAxis: 80}],
// 70 to 80
[{itemStyle: {color: 'rgba(255,225,199)', opacity: opacity}, yAxis: 80}, {yAxis: 70}],
// Below 70 (Fail)
[{itemStyle: {color: 'rgb(255,190,184)', opacity: opacity}, yAxis: 70}, {yAxis: -100}]
]
}
}
/**
* Text style for pie graphs or radar graphs
*/
static pieTextStyle()
{
return {
fontSize: 14,
textShadowColor: '#cfcfcf',
textShadowBlur: 2,
textShadowOffsetX: 1,
textShadowOffsetY: 1,
backgroundColor: '#f6f6f6',
borderRadius: 3,
padding: [3, 5]
}
}
/**
* CSS shadow string (extraCssText) for tooltip
*/
static tooltipCssShadow()
{
return {extraCssText: 'box-shadow: 0 0 3px rgba(0, 0, 0, 0.3);'}
}
}
-52
View File
@@ -1,52 +0,0 @@
import Constants from '@/constants';
import LoginUser from '@/logic/login-user';
export class HttpUtils
{
public user: LoginUser;
public post(node: string, body: any): Promise<any>
{
// Add token
if (this.user != null) body['token'] = this.user.token;
// Create promise
return new Promise<any>((resolve, reject) =>
{
// Fetch request
fetch(`${Constants.API_URL}${node}`, {method: 'POST', body: JSON.stringify(body)}).then(res =>
{
// Get response body text
res.text().then(text =>
{
// Parse response
let response = JSON.parse(text);
resolve(response);
})
.catch(reject)
})
.catch(reject)
});
}
public get(url: string): Promise<any>
{
// Create promise
return new Promise<any>((resolve, reject) =>
{
// Fetch request
fetch(url, {method: 'GET'}).then(res =>
{
// Get response body text
res.text().then(text =>
{
// Parse response
let response = JSON.parse(text);
resolve(response);
})
.catch(reject)
})
.catch(reject)
});
}
}
-41
View File
@@ -1,41 +0,0 @@
export default class VersionUtils
{
/**
* Compare two version numbers
*
* Eg.
* compare('0.1.2', '0.1.3') = -1
* compare('1.0.0', '0.1.3') = 1
* compare('0.0.1', '0.0.1') = 0
*
* @param ver1 Version 1
* @param ver2 Version 2
* @return number (-1 if ver1 < ver2), (1 if ver1 > ver2), (0 if equal)
*/
public static compare(ver1: string, ver2: string): number
{
// Equal case
if (ver1 == ver2) return 0;
// Split
let split1 = ver1.split('.');
let split2 = ver2.split('.');
// Detect each number
for (let i in split1)
{
// Get numbers
let num1 = split1[i];
let num2 = split2[i];
// Current number is equal
if (num1 == num2) continue;
// Current number is different
return +num1 < +num2 ? -1 : 1;
}
// Equal
return 0;
}
}
-22
View File
@@ -1,22 +0,0 @@
import Vue from 'vue';
import ElementUI from 'element-ui';
import App from './components/app/app.vue';
import VueCookies from 'vue-cookies';
const VCharts = require('v-charts');
Vue.config.productionTip = false;
// Use Element UI
Vue.use(ElementUI, {locale: 'en-us'});
// Use VCharts
Vue.use(VCharts);
// Use Cookies
Vue.use(VueCookies);
// Init app
new Vue({
render: (h) => h(App),
}).$mount('#app');
@@ -1,81 +0,0 @@
#course-list
{
margin-right: 20px;
height: 70vh;
.padding-fix
{
// Fix width issue
padding-left: 20px;
padding-right: 20px;
border-radius: 4px;
}
.header
{
.text
{
margin-top: 15px;
margin-bottom: 10px;
font-size: 24px;
}
.search
{
margin-bottom: 20px;
}
}
.list
{
overflow: scroll;
overflow-x: hidden;
height: 377px;
}
// Remove scrollbar
.list::-webkit-scrollbar
{
width: 0;
}
.item
{
text-align: left;
margin-bottom: 15px;
background: #fbfbfb;
border-radius: 4px;
padding: 5px 10px;
.name
{
// Text too long
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.data
{
// text-align: right;
color: #a5a5a5;
span
{
display: inline-block;
width: 40px;
// text-align: left;
font-size: 12px;
}
}
}
}
// Cards
.left
{
margin-left: 20px;
}
@@ -1,197 +0,0 @@
import {Component, Prop, Vue} from 'vue-property-decorator'
import App from '@/components/app/app';
import CourseInfo, {ClassInfo, UniqueCourse} from '@/logic/course-info';
import {GPAUtils} from '@/logic/utils/gpa-utils';
// @ts-ignore
import SearchSettingsComponent, {SearchSettings} from '@/pages/course-selection/pages/search-settings.vue';
import Welcome from '@/pages/course-selection/pages/welcome.vue';
import CourseDetail from '@/pages/course-selection/pages/course-detail.vue';
import LoadingSpinner from '@/components/loading-spinner.vue';
@Component({components: {SearchSettings: SearchSettingsComponent, Welcome, CourseDetail, LoadingSpinner}})
export default class CourseSelection extends Vue
{
@Prop({required: true}) app: App
search: string = ''
courseInfo: CourseInfo[] = []
courseIdIndex: any = {} // Map<CourseID, index in courseInfo>
directory: {gradeLevel: number, classes: string}[] = []
classes: ClassInfo[] = []
loading = true
courseListHeight: number = 0;
cardsHeight: number = 0;
openedPage: string = '';
settings: SearchSettings = new SearchSettings();
activeCourse: UniqueCourse = new UniqueCourse('', [], -1);
/**
* Called before rendering
*/
created()
{
// Update width dynamically
window.addEventListener('resize', this.updateHeight);
// Get courses
App.http.post('/course-info', {}).then(result =>
{
if (result.success)
{
// Parse data
this.classes = result.data.classes.map((json: any) => new ClassInfo(json));
this.courseInfo = result.data.courseInfos.map((json: any, index: number) =>
{
let info = new CourseInfo(json);
// Index
info.courseIds.forEach(id =>
{
this.courseIdIndex[id] = index;
// Add class info into course
let classInfo = this.classes.find(c => c.id == id)
if (classInfo == null) return;
info.classes.push(classInfo);
});
return info;
});
this.directory = result.data.studentInfos;
// Use directory data
this.directory.forEach(d =>
{
d.classes.split('|').forEach(classId =>
{
// Get info by class id
let info = this.courseInfo[this.courseIdIndex[+classId]];
if (info as any != null)
{
// Add grade level
if (!info.gradeLevels.includes(d.gradeLevel))
{
info.gradeLevels.push(d.gradeLevel);
}
// Count enrollments
info.enrollments ++;
}
})
})
this.loading = false;
}
});
}
/**
* Called on destroy
*/
destroyed()
{
// Remove width updater
window.removeEventListener('resize', this.updateHeight);
}
/**
* Called on vue update
*/
updated()
{
this.updateHeight()
}
/**
* Update header height. (CSS doesn't work)
*/
updateHeight()
{
// Get element
let cl = this.$refs.cl as Vue;
if (cl as any == null) return;
let el = cl.$el;
// Calculate height
this.cardsHeight = window.innerHeight - el.getBoundingClientRect().top - 20;
this.courseListHeight = this.cardsHeight - 15 - 102;
}
/**
* Open the settings page.
*/
openSettings()
{
this.openedPage = this.openedPage == 'settings' ? '' : 'settings';
}
/**
* Open course page.
*/
openCourse(course: UniqueCourse)
{
if (this.activeCourse == course)
{
this.openedPage = '';
this.activeCourse = null as any as UniqueCourse;
}
else
{
this.activeCourse = course;
this.openedPage = 'course'
}
}
get filteredCourses()
{
let year = GPAUtils.getSchoolYear();
return this.courseInfo.filter(c =>
c.uniqueName.toLowerCase().includes(this.search.toLowerCase()) &&
c.level != null && this.settings.levels.includes(c.level) &&
c.year == year &&
(this.settings.showAllCourses || c.gradeLevels.includes(this.app.user.gradeLevel + 1))
)
}
/**
* Gets unique courses by name, even though many different teachers might teach it.
*/
get uniqueCourses(): UniqueCourse[]
{
let names: string[] = [];
let list: UniqueCourse[] = [];
this.filteredCourses.forEach(c =>
{
// Create the course list if doesn't exist
if (!names.includes(c.uniqueName))
{
names.push(c.uniqueName);
list.push(new UniqueCourse(c.uniqueName, [], 0))
}
// Add the course
list[names.indexOf(c.uniqueName)].courses.push(c);
list[names.indexOf(c.uniqueName)].enrollments += c.enrollments;
})
// Sorting
switch (this.settings.sortBy)
{
case 'Popularity':
{
list.sort((a, b) => b.enrollments - a.enrollments);
break
}
default:
{
list.sort((a, b) => a.name.localeCompare(b.name));
break
}
}
return list;
}
}
@@ -1,49 +0,0 @@
<template>
<div id="course-selection">
<div v-if="loading" class="loading vertical-center" style="height: 100%">
<LoadingSpinner style="left: 0"/>
</div>
<el-row v-else>
<el-col :span="16" class="overall-span">
<el-card class="left" :style="{height: cardsHeight + 'px'}">
<SearchSettings v-if="openedPage === 'settings'" ref="settings" :settings="settings"/>
<Welcome v-if="openedPage === ''" :app="app"/>
<CourseDetail v-if="openedPage === 'course'" :unique-course="activeCourse"/>
</el-card>
</el-col>
<!-- Course list card -->
<el-col :span="8" class="overall-span">
<el-card id="course-list" class="right" ref="cl" body-style="padding: 0" :style="{height: cardsHeight + 'px'}">
<div class="header padding-fix">
<div class="text">Course List</div>
<!-- Search -->
<el-input class="search" placeholder="Search..." prefix-icon="el-icon-search" v-model="search">
<el-button slot="append" icon="el-icon-s-tools" @click="openSettings"/>
</el-input>
</div>
<!-- Actual course list -->
<div class="list padding-fix" :style="{height: courseListHeight + 'px'}">
<!-- Every course -->
<div v-for="(course, index) in uniqueCourses" class="item vertical-center clickable unselectable"
@click="openCourse(course)">
<div class="name">{{course.name}}</div>
<div class="data">
<span class="classes"><i class="el-icon-s-home"/> {{course.classes.length}}</span>
<span class="teachers"><i class="el-icon-user-solid"/> {{course.courses.length}}</span>
<span class="enrollments"><i class="el-icon-user"/> {{course.enrollments}}</span>
</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script src="./course-selection.ts" lang="ts"/>
<style src="./course-selection.scss" lang="scss" scoped/>
@@ -1,269 +0,0 @@
<template>
<div id="course-detail">
<div class="header">Course: <span style="color: #229fff">{{uniqueCourse.name}}</span></div>
<el-divider class="divider"><i class="el-icon-reading"></i></el-divider>
<!-- All course-infos -->
<div class="item clickable unselectable" v-for="c in sortedCourses" @click="openDetails(c)">
<div class="float-left">
<div>{{c.levelFull}} - <i>{{c.teacher}}</i></div>
<div class="info">
<span class="name">{{c.name}} : </span>
<span class="classes"><i class="el-icon-s-home"/> {{c.classes.length}}</span>
<span class="enrollments"><i class="el-icon-user"/> {{c.enrollments}}</span>
</div>
</div>
<div class="float-right">
<LoadingSpinner v-if="c.rating == null" class="loading" size="30" :centered="false"/>
<div v-else class="rating">
<span v-if="c.rating.totalCount === 0" class="text">No ratings yet...</span>
<span v-else class="stars">
<StarRating :score="c.rating.overallRating"></StarRating>
<span class="info">
<span class="numeric-rating">{{c.rating.overallRating.toFixed(2)}} / 5</span>
<span>({{c.rating.totalCount}} rating{{c.rating.totalCount > 1?'s':''}})</span>
</span>
</span>
</div>
</div>
</div>
<!-- Detail / Comments Popup -->
<el-dialog id="detail-popup" v-if="detailsCourse" :visible="detailsCourse != null" width="50%" top="10vh"
:before-close="closeDetails">
<span slot="title" class="header">
<div class="title">Ratings for {{detailsCourse.name}}</div>
<span class="subtitle">And for {{detailsCourse.teacher}}</span>
</span>
<div class="rating-item" v-for="(criteria, index) of ratingCriteria">
<div class="title float-left">{{criteria.title}}</div>
<div class="stars float-right">
<span class="info numeric-rating">{{rating.ratingAverages[index].toFixed(2)}} / 5</span>
<StarRating :score="rating.ratingAverages[index]" style="display: inline-block"></StarRating>
</div>
</div>
<div class="comments">
<div class="header">
Comments
</div>
<LoadingSpinner v-if="detailsComments == null"/>
<div class="comment" v-else v-for="comment of detailsComments">
<div class="user">
<i class="el-icon-user-solid"/>
{{comment.firstName}} {{comment.lastName}}:
<span class="info numeric-rating" style="margin-left: 5px">{{comment.averageRating.toFixed(2)}} / 5</span>
</div>
<div class="text">
<blockquote>{{comment.comment}}</blockquote>
</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script lang="ts">
import {Component, Prop, Vue} from 'vue-property-decorator'
import CourseInfo, {AnalyzedRating, CourseInfoRating, RATING_CRITERIA, UniqueCourse} from '@/logic/course-info';
import App from '@/components/app/app';
import course from '@/logic/course';
import LoadingSpinner from '@/components/loading-spinner.vue';
import loading from '@/components/overlays/loading.vue';
import StarRating from '@/components/star-rating.vue';
@Component({components: {StarRating, LoadingSpinner}})
export default class CourseDetail extends Vue
{
@Prop({required: true}) uniqueCourse: UniqueCourse;
detailsCourse: CourseInfo = null as any as CourseInfo
detailsComments: CourseInfoRating[] = null as any as []
get ratingCriteria() {return RATING_CRITERIA}
get rating() {return this.detailsCourse.rating}
mounted()
{
this.checkRatings()
}
updated()
{
this.checkRatings()
}
checkRatings()
{
// Load ratings
this.sortedCourses.forEach(c =>
{
// Already has rating
if (c.rating as any != null) return;
// Get rating
App.http.post('/course-info/rating/get', {condition: 'course', value: c.id_ci}).then(result =>
{
if (result.success)
{
// Assign rating
c.rating = new AnalyzedRating(result.data);
}
else
{
this.$message.error(`Rating data for ${c.name} / ${c.teacher} failed to load.`)
console.log(result.data);
}
})
})
}
get sortedCourses(): CourseInfo[]
{
return this.uniqueCourse.courses.sort((a, b) => a.levelID - b.levelID);
}
openDetails(course: CourseInfo)
{
let c = this.detailsCourse = this.detailsCourse == course ? null as any as CourseInfo : course;
// Load comments
App.http.post('/course-info/rating/get', {condition: 'course-comments', value: c.id_ci}).then(result =>
{
if (result.success)
{
this.detailsComments = result.data.map((r:any) => new CourseInfoRating(r));
}
else
{
this.$message.error(`Rating data for ${c.name} / ${c.teacher} failed to load.`)
console.log(result.data);
}
})
// TODO: Finish comment section
}
closeDetails()
{
this.detailsCourse = null as any as CourseInfo;
this.detailsComments = null as any as []
}
}
</script>
<style src="./pages.scss" lang="scss" scoped/>
<style lang="scss" scoped>
.item
{
text-align: left;
margin-bottom: 15px;
background: #f8fdff;
border-radius: 4px;
padding: 5px 10px;
height: 40px;
}
.info
{
font-size: 12px;
color: gray;
.classes
{
display: inline-block;
margin-right: 10px;
}
}
.numeric-rating
{
margin-right: 10px;
}
.float-left
{
text-align: left;
float: left;
}
.float-right
{
text-align: right;
float: right;
}
.loading
{
margin-top: 5px !important;
}
.rating
{
.text
{
font-size: 14px;
}
}
#detail-popup
{
text-align: left;
.header
{
.title
{
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: -10px;
}
.subtitle
{
margin-left: 10px;
font-size: 12px;
font-style: italic;
color: #999999;
}
}
.rating-item
{
height: 30px;
.title
{
font-size: 18px;
}
}
.rating-item:first-child
{
margin-top: -15px;
}
.comments
{
margin-top: 40px;
.comment
{
margin-bottom: 20px;
}
blockquote
{
padding: 0 1em;
/* color: #6a737d; */
border-left: .25em solid #dfe2e5;
margin: 5px 0 0 0;
}
}
}
</style>
@@ -1,16 +0,0 @@
.header
{
margin-top: 20px;
margin-bottom: 10px;
font-size: 24px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.divider
{
margin-top: 20px !important;
}
@@ -1,70 +0,0 @@
<template>
<div id="settings">
<div class="header">Settings</div>
<el-divider class="divider"><i class="el-icon-s-tools"></i></el-divider>
<div class="content">
<el-switch v-model="settings.showAllCourses" class="item"
active-text="Show all courses (including the ones not listed on your grade level)"/>
<div class="item">
<span class="item-label">Sort by:</span>
<el-radio-group v-model="settings.sortBy">
<el-radio label="Name"></el-radio>
<el-radio label="Popularity"></el-radio>
<el-radio label="Classes"></el-radio>
<el-radio label="Level"></el-radio>
<el-radio disabled label="Peer Rating (Coming soon)"></el-radio>
</el-radio-group>
</div>
<div class="item">
<span class="item-label">Levels:</span>
<el-checkbox-group v-model="settings.levels" style="display: inline-block;">
<el-checkbox label="AP">AP</el-checkbox>
<el-checkbox label="H">Honors</el-checkbox>
<el-checkbox label="A">Accelerated</el-checkbox>
<el-checkbox label="CP">CP</el-checkbox>
</el-checkbox-group>
</div>
</div>
</div>
</template>
<script lang="ts">
import {Component, Prop, Vue} from 'vue-property-decorator'
import App from '@/components/app/app';
@Component
export default class SearchSettingsComponent extends Vue
{
@Prop({required: true}) settings: SearchSettings;
// TODO: Show all courses option
}
export class SearchSettings
{
showAllCourses: boolean = App.instance.user.gradeLevel == 12;
sortBy: string = 'Name'
levels: string[] = ['AP','H','A','CP']
}
</script>
<style src="./pages.scss" lang="scss" scoped/>
<style lang="scss" scoped>
.content
{
text-align: left;
.item
{
margin-bottom: 20px;
font-size: 14px;
.item-label,.el-radio,.el-checkbox
{
margin-right: 15px;
}
}
}
</style>
@@ -1,63 +0,0 @@
<template>
<div id="welcome">
<div class="header">Welcome</div>
<el-divider class="divider"><i class="el-icon-cold-drink"></i></el-divider>
<div class="content" style="color: #ff3d3d" v-if="app.user.gradeLevel >= 12">
You are a senior, what are you doing over here lol. <br>
Unfortunately I can't help you with college course selection.<br>
(But you can still view course ratings)<br><br>
</div>
<div class="content">
<span style="color:#409EFF">
This new page is designed to help you with your course selection for your {{nextGrade}} year,
providing more information such as how many people are currently enrolled in a course.
</span>
<br><br>
The courses displayed are from the current year,
but since they are unlikely to change,
they can provide a good view for the courses next year.
However, this also means that the new courses
and courses that didn't open this year are not going to be displayed here.
For 2020, the new courses are Financial Algebra and Acc Psychology.
Also, by default, only the courses that current {{nextGrade.toLowerCase()}}s take are displayed,
and you can enable "show all courses" in settings if you want to see all courses.
<br><br>
<b>Notations:</b><br>
<i class="el-icon-s-home"/>: How many classes (blocks) did the course open this year.<br>
<i class="el-icon-user-solid"/>: How many teachers are teaching this course.<br>
<i class="el-icon-user"/> How many students are enrolled.<br>
<br>
<b>Sorting:</b><br>
By default the courses are sorted by name,
but you can change the settings to sort by popularity, by classes, or by level.
</div>
</div>
</template>
<script lang="ts">
import {Component, Prop, Vue} from 'vue-property-decorator'
import App from '@/components/app/app';
import {GPAUtils} from '@/logic/utils/gpa-utils';
@Component
export default class Welcome extends Vue
{
@Prop({required: true}) app: App
get nextGrade()
{
return GPAUtils.gradeLevelName(this.app.user.gradeLevel + 1)
}
}
</script>
<style src="./pages.scss" lang="scss" scoped/>
<style lang="scss" scoped>
.content
{
text-align: justify;
color: #585858;
}
</style>
@@ -1,80 +0,0 @@
<template>
<div id="assignment-type-head">
<el-card :body-style="{padding: '0px'}">
<div id="type-info-card">
<span id="type-name">{{type.name}}</span>
<span class="type-average" v-if="type.graded">Average: {{type.percent}}%</span>
<span class="type-average" v-if="!type.graded">No grades yet!</span>
</div>
<AssignmentEntry v-for="(assignment, index) of filteredAssignments" :key="assignment.id"
:assignment="assignment" :unread="false"
:backgroundColor="index % 2 === 1 ? '#ffffff' : '#f7f7f7'" narrow="true">
</AssignmentEntry>
</el-card>
</div>
</template>
<script lang="ts">
import {Component, Prop, Vue} from 'vue-property-decorator';
import AssignmentEntry from '@/pages/overall/overall-course/assignment-entry/assignment-entry.vue';
import {Assignment, AssignmentType} from '@/logic/course';
@Component({
components: {AssignmentEntry}
})
export default class AssignmentTypeHead extends Vue
{
@Prop({required: true}) type: AssignmentType;
@Prop({required: true}) assignments: Assignment[];
get filteredAssignments()
{
// Filter assignments to only this type
return this.assignments.filter(a => a.typeId == this.type.id);
}
}
</script>
<style lang="scss" scoped>
#assignment-type-head
{
margin-bottom: 20px;
}
#type-info-card
{
height: 60px;
}
#type-name
{
// Font
font-size: 22px;
color: var(--main);
// Center
height: 60px;
line-height: 60px;
// Alignment
padding-left: 20px;
float: left;
}
.type-average
{
// Font
font-size: 14px;
color: #8db3e4;
// Center
height: 60px;
line-height: 64px;
// Alignment
float: left;
margin-left: 15px;
display: inline-block;
}
</style>
-26
View File
@@ -1,26 +0,0 @@
// Card
.el-card.course-card
{
// Margins
margin-right: 20px;
margin-left: 20px;
// Limit name length
white-space: nowrap;
// Expansion color
background: #f4f6f9;
}
.course-card-content.expand
{
// Top shadow
// https://stackoverflow.com/questions/17572619/inset-box-shadow-only-on-one-side
box-shadow: inset 0 7px 9px -7px rgba(0,0,0,0.1);
}
.type-graph
{
padding-top: 23px;
height: 420px !important;
}
-71
View File
@@ -1,71 +0,0 @@
<template>
<el-card id="course-card" class="course-card">
<course-head :clickable="false" :course="course" :unread="countUnread()"/>
<div class="course-card-content expand">
<el-row>
<el-col :span="24" class="course-page-graph">
<el-card class="large overall-line-card vertical-center">
<course-scatter :course="course"/>
</el-card>
</el-col>
</el-row>
<el-row>
<el-col :span="12" class="course-page-graph">
<el-card class="large overall-line-card vertical-center type-graph"
body-style="padding: 0">
<TypeRadar :course="course"/>
</el-card>
</el-col>
<el-col :span="12" class="course-page-graph">
<el-card class="large overall-line-card vertical-center type-graph"
body-style="padding: 0">
<TypePie :course="course"/>
</el-card>
</el-col>
</el-row>
<AssignmentTypeHead v-for="type in course.assignmentTypes" :key="type.id"
:type="type" :assignments="course.assignments">
</AssignmentTypeHead>
</div>
</el-card>
</template>
<script lang="ts">
import {Component, Prop, Vue} from 'vue-property-decorator';
import CourseHead from '@/pages/overall/overall-course/course-head/course-head.vue';
import CourseScatter from '@/pages/course/course-scatter/course-scatter';
import AssignmentEntry from '@/pages/overall/overall-course/assignment-entry/assignment-entry.vue';
import AssignmentTypeHead from '@/pages/course/assignment-type-head/assignment-type-head.vue';
import Course, {Assignment} from '@/logic/course';
import TypeRadar from '@/pages/course/type-radar/type-radar';
import TypePie from '@/pages/course/type-pie/type-pie';
@Component({
components: {TypeRadar, TypePie, AssignmentEntry, CourseHead, CourseScatter, AssignmentTypeHead}
})
export default class CoursePage extends Vue
{
@Prop({required: true}) course: Course;
private unread: number = -1;
private unreadAssignments: Assignment[] = [];
/**
* Count the number of unread assignments with cache
*/
countUnread(): number
{
if (this.unread == -1)
{
this.unreadAssignments = this.course.assignments.filter(a => a.unread);
return this.unread = this.unreadAssignments.length;
}
else return this.unread;
}
}
</script>
<style src="./course-page.scss" lang="scss" scoped/>
@@ -1,170 +0,0 @@
import {Component, Prop, Vue} from 'vue-property-decorator';
import Constants from '@/constants';
import {FormatUtils} from '@/logic/utils/format-utils';
import moment, {min, Moment} from 'moment';
import Course, {Assignment} from '@/logic/course';
import GraphUtils from '@/logic/utils/graph-utils';
import chroma from 'chroma-js';
import Navigation from '@/components/navigation/navigation';
@Component
export default class CourseScatter extends Vue
{
@Prop({required: true}) course: Course;
/**
* Override options
*
* @param options Original options (Unused)
*/
afterConfig(options: any)
{
return this.chartSettings;
}
/**
* Generate settings
*/
get chartSettings()
{
let term = Navigation.instance.getSelectedTerm()
// Create settings
let settings =
{
// Base settings
...GraphUtils.getBaseSettings('Assignments', 'Assignment scores for ' + this.course.name),
// X axis represents course names
xAxis:
{
type: 'time',
axisLabel:
{
formatter: (name: any) => moment(name).format('MMM DD')
},
min: Constants.TERMS[term == -1 ? 0 : term].getTime(),
max: term == -1 ? moment.min(moment(), moment(Constants.TERMS[4])).toDate().getTime() :
Constants.TERMS[term + 1].getTime()
},
// Y axis represents GPAs and MaxGPAs
yAxis:
{
type: 'value',
name: 'Percentage Score',
nameLocation: 'center',
nameGap: 38,
axisLabel:
{
formatter: (name: any) => name + '%'
},
min: (value: any) => Math.floor(value.min) - 5,
max: (value: any) => Math.min(Math.ceil(value.max), 110)
},
// Tooltip
tooltip:
{
...GraphUtils.tooltipCssShadow(),
trigger: 'axis',
axisPointer:
{
type: 'cross'
},
formatter: (ps: any[]) => moment(ps[0].data[0]).format('MMM DD, YYYY') + '<br>' + ps.map(p =>
`${GraphUtils.DOT.replace('{color}', p.color.colorStops[1].color)}
${FormatUtils.limit(p.data[2].description, 22)}: ${p.data[1]}%<br>`).join('')
},
// Legend
legend:
{
bottom: 24,
itemWidth: 14,
textStyle:
{
color: '#777',
fontSize: 11
}
},
// Data
series: this.series()
};
return settings;
}
/**
* Get series data
*/
private series()
{
// Create scatter plots
let series: any[] = this.course.assignmentTypes.filter(t => t.graded).map((type, i) =>
{
return {
type: 'scatter',
name: type.name,
data: CourseScatter.assignmentsData(this.course.assignments.filter(a => a.typeId == type.id)),
symbolSize: (data: any) => Math.max(Math.sqrt(type.weight * data[2].scoreMax / type.scoreMax) * 12, 12),
label:
{
emphasis:
{
show: true,
formatter: (p: any) => p.data[2].description,
position: 'top'
}
},
itemStyle:
{
normal:
{
opacity: 0.7,
shadowBlur: 10,
shadowOffsetX: 0,
shadowOffsetY: 0,
shadowColor: 'rgba(0, 0, 0, 0.2)',
color:
{
type: 'radial',
x: 0.4,
y: 0.3,
colorStops:
[
{offset: 0, color: chroma(Constants.THEME.colors[i]).set('hsl.l', 0.9).css()},
{offset: 1, color: Constants.THEME.colors[i]}
]
}
}
}
}
});
// Push other stuff
series.push(
{
type: 'line',
markLine: GraphUtils.getTermLines(),
markArea: GraphUtils.getGradeMarkAreas(0.4)
});
return series;
}
/**
* Convert assignments to series data
*
* @param assignments Assignments
*/
private static assignmentsData(assignments: Assignment[])
{
return assignments.filter(a => a.complete == 'Complete')
.map(a => [a.time, (a.score / a.scoreMax * 100).toFixed(2), a]);
}
}
@@ -1,7 +0,0 @@
<template>
<div id="course-scatter">
<ve-scatter height="450px" class="graph" :extend="{a: this.course.name}" :after-config="afterConfig"/>
</div>
</template>
<script src="./course-scatter.ts" lang="ts"></script>
-56
View File
@@ -1,56 +0,0 @@
import {Component, Prop, Vue} from 'vue-property-decorator';
import Constants from '@/constants';
import Course from '@/logic/course';
import GraphUtils from '@/logic/utils/graph-utils';
@Component
export default class TypePie extends Vue
{
@Prop({required: true}) course: Course;
/**
* Override options
*
* @param options Original options (Unused)
*/
afterConfig(options: any)
{
return this.chartSettings;
}
/**
* Generate settings
*/
get chartSettings()
{
// Create settings
let settings =
{
...GraphUtils.getBaseSettings('Assignment Type Weight',
'How much each type of assignment affect your average'),
// Data
series:
{
type: 'pie',
avoidLabelOverlap: false,
radius: ['40%', '60%'],
center: ['50%', '55%'],
label: GraphUtils.pieTextStyle(),
data: this.course.assignmentTypes.filter(t => t.graded).map((t, i) => {return {
value: t.weight,
name: `${t.name}\n${t.weight}%`,
itemStyle:
{
color: Constants.THEME.colors[i],
opacity: 0.8,
shadowColor: 'rgba(0,0,0,0.22)',
shadowBlur: 10
}
}}).sort((a, b) => a.value - b.value)
}
};
return settings;
}
}
-8
View File
@@ -1,8 +0,0 @@
<template>
<div id="type-pie">
<ve-pie height="420px" class="graph" :extend="{a: this.course.name}" :after-config="afterConfig"></ve-pie>
</div>
</template>
<script src="./type-pie.ts" lang="ts"></script>
<style lang="scss" scoped></style>
-102
View File
@@ -1,102 +0,0 @@
import {Component, Prop, Vue} from 'vue-property-decorator';
import Constants from '@/constants';
import Course from '@/logic/course';
import GraphUtils from '@/logic/utils/graph-utils';
@Component
export default class TypeRadar extends Vue
{
@Prop({required: true}) course: Course;
/**
* Override options
*
* @param options Original options (Unused)
*/
afterConfig(options: any)
{
return this.chartSettings;
}
/**
* Generate settings
*/
get chartSettings()
{
let min = this.course.assignmentTypes.filter(t => t.graded).reduce((min, t) => Math.min(min, t.percent), 100);
// Create settings
let settings =
{
...GraphUtils.getBaseSettings('Assignment Type Radar',
'How are you doing for different types of assignment'),
// Radar settings
radar:
{
// shape: 'circle',
name:
{
textStyle: GraphUtils.pieTextStyle()
},
splitArea:
{
areaStyle:
{
color:
[
'rgb(255,161,151)',
'rgb(255,190,184)',
'rgba(255,225,199)',
'rgba(255,250,216)',
'rgba(241,255,237)',
],
opacity: 0.4
}
},
indicator: this.course.assignmentTypes.filter(t => t.graded).map((t, i) => {return {
name: `${t.name}\n${t.percent}%`,
max: 100,
min: min - 30,
color: Constants.THEME.colors[i]
}}),
radius: '60%',
center: ['50%', '55%']
},
// Data
series:
{
type: 'radar',
data:
[
{
name: 'Score',
symbol: 'circle',
areaStyle:
{
color:
{
type: 'radial',
x: 0.5, y: 0.55, r: 0.5,
colorStops:
[
{offset: 0, color: '#ffa0a0'},
{offset: 0.5, color: '#fffead'},
{offset: 1, color: '#d1ffde'}
],
global: false // 缺省为 false
},
opacity: 0.2
},
value: this.course.assignmentTypes.filter(t => t.graded).map(t => t.percent)
}
]
},
color: '#6771c1'
};
return settings;
}
}
@@ -1,7 +0,0 @@
<template>
<div id="type-radar">
<ve-radar height="420px" class="graph" :extend="{a: this.course.name}" :after-config="afterConfig"/>
</div>
</template>
<script src="./type-radar.ts" lang="ts"></script>
@@ -1,116 +0,0 @@
import {Component, Prop, Vue} from 'vue-property-decorator';
import Course from '@/logic/course';
import {GPAUtils} from '@/logic/utils/gpa-utils';
import Constants from '@/constants';
import {FormatUtils} from '@/logic/utils/format-utils';
@Component
export default class OverallBar extends Vue
{
@Prop({required: true}) courses: Course[];
/**
* Generate settings
*/
get chartSettings()
{
let settings =
{
// Title
title:
{
show: true,
textStyle:
{
fontSize: 12
},
text: 'Course GPA',
subtext: 'Current GPA for every course',
x: 'center'
},
// X axis represents course names
xAxis:
{
type: 'category',
axisLabel: {
interval: 0,
inside: false,
rotate: 90,
// Truncate text length
formatter: (value: string) => FormatUtils.limit(value, 16)
},
},
// Y axis represents GPAs and MaxGPAs
yAxis:
{
type: 'value'
},
// Data
series:
[
// Max GP
{
type: 'bar',
barGap: '-100%',
data: this.courses.map(course =>
{
return {value: [course.name, GPAUtils.getGP(course, 'A+')], itemStyle: {color: '#d8d8d8'}}
}),
},
// Current GP
{
type: 'bar',
barGap: '-100%',
data: this.generateGPData(),
label:
{
show: true,
rotate: 90
}
}
],
// Disable tooltip
tooltip:
{
show: false
}
};
return settings;
}
/**
* Generate GP data for each course
*/
private generateGPData()
{
let data: any = [];
this.courses.forEach((course, index) =>
{
// Get GP
let gp = GPAUtils.getGP(course, course.letterGrade);
// No grade cases
if (gp == -1) return;
// Push data
data.push(
{
value: [course.name, gp],
itemStyle:
{
color: Constants.THEME.colors[index]
}
});
});
return data;
}
}
@@ -1,16 +0,0 @@
<template>
<div id="overall-bar">
<ve-bar height="450px" class="graph" :extend="chartSettings"/>
</div>
</template>
<script src="./overall-bar.ts" lang="ts"></script>
<style lang="scss" scoped>
#overall-bar
{
.graph
{
margin-top: 50px;
}
}
</style>
@@ -1,152 +0,0 @@
// Row
.assignment-entry
{
height: 40px;
padding: 0 10px 0 20px;
background: #f5f7fa;
text-align: left;
// Date
.el-col.date
{
min-width: 150px;
span.month
{
margin-right: 5px;
// Unified width
display: inline-block;
min-width: 50px;
}
span.now
{
font-size: 11px;
color: #888;
}
}
// Description
.el-col.description
{
width: unset;
span.type
{
display: inline-block;
font-size: 13px;
font-weight: 700;
background: #eee;
border-left: 2px solid #000;
height: 22px;
line-height: 22px;
margin-right: 8px;
}
}
// Grade
.el-col.grade
{
text-align: right;
float: right;
// Fix smaller screen display issues.
width: unset;
// Status / Problems
span.status
{
margin-right: 8px;
}
// Percentage score
span.percent
{
font-style: italic;
background: #ffc;
color: #555;
margin-right: 8px;
.symbol
{
padding: 0 1px;
}
}
// Score you got
span.score
{
background: #f2f2f2;
color: #555;
}
// Max score
span.max
{
background: #ddd;
color: #333;
}
// Mark as read
button.mark-as-read
{
margin-left: 8px;
color: #aaa;
padding: 4px;
}
}
.entry-box
{
height: 22px;
padding: 0 5px;
}
// Unified width
.entry-box.score, .entry-box.max
{
min-width: 30px;
display: inline-block;
text-align: center;
}
// Unified width
.entry-box.percent
{
min-width: 60px;
display: inline-block;
text-align: right;
}
}
// Narrow layout
.assignment-entry.narrow
{
height: 34px;
}
// Unread
.no-unread
{
visibility: hidden !important;
width: 0 !important;
margin-left: 0 !important;
padding: 0 0 0 10px !important;
}
.assignment-entry:first-child
{
padding-top: 3px;
// Top shadow
// https://stackoverflow.com/questions/17572619/inset-box-shadow-only-on-one-side
box-shadow: inset 0 7px 9px -7px rgba(0,0,0,0.1);
}
@@ -1,74 +0,0 @@
<template>
<div class="assignment-entry vertical-center"
:class="narrow ? 'narrow' : ''"
:style="`background: ${backgroundColor}`">
<el-row class="unread-row">
<el-col :span="3" class="date">
<span class="month">{{getMoment(assignment.time).format("MMM D")}}</span>
<span class="now">({{getMoment(assignment.time).fromNow()}})</span>
</el-col>
<el-col :span="15" class="description">
<span class="type entry-box"
:style="`border-color: var(--assignment-type-${assignment.typeId})`">
{{assignment.type}}
</span>
<span class="text">{{assignment.description}}</span>
</el-col>
<el-col :span="6" class="grade">
<span v-if="assignment.problem" class="status entry-box" :style="{color: assignment.problemColor}">
{{assignment.problem}}
</span>
<span v-if="assignment.graded" class="percent entry-box">
{{(assignment.score / assignment.scoreMax * 100).toFixed(1)}}
<span class="symbol">%</span>
</span>
<span v-if="assignment.graded" class="score entry-box">{{assignment.score}}</span>
<span v-if="assignment.graded" class="max entry-box">{{assignment.scoreMax}}</span>
<el-button class="mark-as-read" :class="unread ? 'unread' : 'no-unread'"
size="mini" type="text" icon="el-icon-close"
@click="markAsRead">
</el-button>
</el-col>
</el-row>
</div>
</template>
<script lang="ts">
import {Component, Prop, Vue} from 'vue-property-decorator';
import moment from 'moment';
import {Assignment} from '@/logic/course';
@Component
export default class AssignmentEntry extends Vue
{
@Prop({required: true}) assignment: Assignment;
@Prop({default: false}) unread: boolean;
@Prop({default: '#f5f7fa'}) backgroundColor: string;
@Prop({default: false}) narrow: boolean;
/**
* Format a date to the displayed format
*
* @param date Date
*/
getMoment(date: number)
{
return moment(new Date(date));
}
/**
* Mark this unread assignment as read
*/
markAsRead()
{
// Call custom event
this.$emit('mark-as-read', this.assignment)
}
}
</script>
<style src="./assignment-entry.scss" lang="scss" scoped/>
@@ -1,216 +0,0 @@
// Main card content
.course-card-content.main
{
// Main color
background: white;
// Alignment
display: block;
padding: 20px;
height: 50px;
}
#block-info
{
// Align left
text-align: left;
float: left;
#name
{
overflow: hidden;
font-size: 22px;
color: var(--main);
}
#teacher
{
font-size: 12px;
color: #999999;
font-style: italic;
}
}
#block-grade
{
// Align right
text-align: right;
float: right;
// Adjust position
margin-top: -2px;
margin-left: 10px;
#grade
{
font-size: 21px;
}
#updates
{
font-size: 14px;
#unread-number
{
display: inline-block;
width: 20px;
text-align: center;
border-radius: 5px;
padding-left: 3px;
padding-right: 3px;
margin-right: 3px;
}
#unread-text
{
font-style: italic;
}
}
#updates.unread
{
#unread-number
{
background: var(--unread);
color: white;
}
#unread-text
{
color: var(--unread);
}
}
#updates.none
{
color: #999999;
#unread-number
{
background: #eeeeee;
}
}
}
#content
{
margin-top: -20px;
padding: 20px 0 20px 20px;
height: 50px;
margin-left: -20px;
}
#block-rate
{
// Align right
width: 55px;
float: right;
margin-left: 20px;
padding: 0 10px;
color: white;
background: #84c0ff;
border-radius: 0 4px 4px 0;
height: 90px;
margin-top: -20px;
margin-right: -20px;
box-shadow: inset 8px 0 11px -4px rgba(0,0,0,.1);
}
#block-rate.rated
{
background: #66eaad;
}
.dark #block-rate > span
{
color: #84c0ff !important;
}
.dark #block-rate.rated > span
{
color: #66eaad !important;
}
#rating-popup
{
text-align: left;
.header
{
.title
{
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.subtitle
{
margin-left: 10px;
font-size: 12px;
font-style: italic;
color: #999999;
}
}
.item
{
margin-bottom: 10px;
white-space: normal;
.title
{
font-size: 18px;
color: var(--main);
}
.description
{
font-size: 14px;
}
.stars
{
font-size: 20px;
color: #FFB300;
}
.el-textarea
{
margin-top: 5px;
margin-bottom: 5px;
}
}
.item:first-child
{
margin-top: -15px;
}
}
#block-term-grades
{
// Align right
width: auto;
float: right;
margin-right: 10px;
color: gray;
#term, #term-numeric
{
font-size: 11px;
color: #b3b3b3;
}
#term-letter
{
font-size: 14px;
}
}
@@ -1,198 +0,0 @@
<template>
<div id="course-head" class="course-card-content main vertical-center">
<!-- Rating button -->
<div id="block-rate" v-if="displayRate" class="vertical-center clickable"
@click="ratingDialog = true" :class="{rated: course.rated}">
<span v-if="!course.rated">Give a<br>Rating!</span>
<span v-else>Rating<br>Entered</span>
</div>
<!-- Main content -->
<div id="content" @click="redirect" :class="clickable ? 'clickable' : ''">
<!-- Left -->
<div id="block-info">
<div id="name">{{course.name}}</div>
<div id="teacher">{{course.teacherName}}</div>
</div>
<!-- Right -->
<div id="block-grade">
<div id="grade">
<span id="letter">{{course.letterGrade}} </span>
<span id="numeric">{{course.numericGrade === undefined ? '--' : course.numericGrade.toFixed(2)}}</span>
<span id="percent" v-if="course.numericGrade !== undefined">%</span>
</div>
<div id="updates" @click="redirect" :class="unread === 0 ? 'none' : 'unread'">
<span id="unread-number">{{unread}}</span>
<span id="unread-text" :class="clickable ? 'clickable' : ''">new update{{unread >= 2 ? 's' : ''}}</span>
</div>
</div>
<div id="block-term-grades" v-if="course.rawSelectedTerm === -1"
v-for="term in course.allGradingPeriods.slice().reverse()">
<div id="term">Term {{term + 1}}</div>
<div id="term-letter">{{course.letterGradeTerm(term)}}</div>
<div id="term-numeric">{{course.numericGradeTerm(term).toFixed(1)}}%</div>
</div>
</div>
<!-- Rating Popup -->
<el-dialog id="rating-popup" :visible.sync="ratingDialog" width="50%" top="5vh"
:show-close="false" :close-on-click-modal="false" :close-on-press-escape="false">
<span slot="title" class="header">
<div class="title">Give a Rating for {{course.name}}</div>
<span class="subtitle">And for {{course.teacherName}}<br></span>
<span class="subtitle" style="color: #e67b0d;">(might need to scroll down to find the submit button)</span>
</span>
<div class="item" v-for="(criteria, index) of ratingCriteria">
<div class="title">{{criteria.title}}</div>
<div class="description">{{criteria.desc}}</div>
<div class="stars">
<span class="star clickable" v-for="star in [0,1,2,3,4]" @click="changeStars(index, star)">
<i v-if="rating.ratings[index] > star" class="el-icon-star-on"/>
<i v-else class="el-icon-star-off"/>
</span>
</div>
</div>
<div class="item">
<div class="title">Comments</div>
<div class="description">Any additional comments? (this is optional)</div>
<el-input type="textarea" placeholder="Comments... (Optional)"
v-model="rating.comment" maxlength="4500" show-word-limit :autosize="{minRows: 2, maxRows: 4}">
</el-input>
<el-checkbox v-model="rating.anonymous">Anonymous</el-checkbox>
</div>
<span slot="footer" class="dialog-footer">
<el-button @click="ratingDialog = false" :disabled="ratingPosting">Cancel</el-button>
<el-button type="primary" @click="submitRating()" :disabled="canSubmit">
{{course.rated ? 'Update' : 'Submit'}}
</el-button>
</span>
</el-dialog>
</div>
</template>
<script lang="ts">
import {Component, Prop, Vue} from 'vue-property-decorator';
import Course from '@/logic/course';
import App from '@/components/app/app';
import {GPAUtils} from '@/logic/utils/gpa-utils';
import Constants from '@/constants';
import {RATING_CRITERIA} from '@/logic/course-info';
import Navigation from '@/components/navigation/navigation';
@Component
export default class CourseHead extends Vue
{
@Prop({required: true}) unread: number;
@Prop({required: true}) course: Course;
@Prop({required: true}) clickable: boolean;
ratingDialog = false;
ratingPosting = false;
rating = this.course.rating;
get canSubmit()
{
return this.ratingPosting || App.instance.demoMode
}
/**
* Redirect to the course page
*/
redirect()
{
if (!this.clickable) return;
App.instance.nav.updateIndex(this.course.urlIndex);
}
/**
* Display rate button or not
*/
get displayRate()
{
return this.clickable && App.instance.showRating;
}
get ratingCriteria() {return RATING_CRITERIA}
/**
* Change star rating data
*
* @param index Index of the rating
* @param star Change to how many stars
*/
changeStars(index: number, star: number)
{
this.rating.ratings[index] = star + 1;
this.$forceUpdate();
}
/**
* Submit a rating
*/
submitRating()
{
if (this.rating.ratings.includes(0))
{
this.$message.error('You haven\'t rated all of the criteria yet!');
return;
}
this.ratingPosting = true;
App.http.post('/course-info/rating/set', {rating: this.rating}).then(response =>
{
if (response.success)
{
this.ratingDialog = false;
this.ratingPosting = false;
this.$message.success('Rating successfully posted, thank you!');
// First rating (Updating the first review doesn't count as first review)
if (this.course.rated) return;
this.course.rated = true;
if (App.instance.courses.filter(c => c.rated).length == 1)
{
this.$alert('You can view other courses\'' +
' ratings in the Course Selection tab, or review yours by clicking' +
' the green button that says "Rating Entered." There are also option to turn off the rating buttons ' +
' by clicking on your avatar on the top right corner.',
'Thank you for submitting your fist rating!', {confirmButtonText: 'OK'}
);
}
// Last rating
if (App.instance.courses.filter(c => c.isGraded && !c.rated).length == 0)
{
this.$confirm('You have rated all of your courses! Do you want to turn off the rating buttons?' +
' (there are option to toggle them on again by clicking your avatar on the top right corner.)',
'Thank you for submitting rating!',
{confirmButtonText: 'Sure!', cancelButtonText: 'Nope.'}).then(() =>
{
// Disable rating buttons
Navigation.instance.onAvatarMenu('switch-rating');
this.$message.success('Rating buttons are disabled');
});
}
}
else
{
this.$message.error('Sorry, but rating failed to post, please try again or email me if you continues to have issues. ' +
'But wait! The email system isn\'t created yet... oops!. (Technical error message: ' + response.data + ')');
this.ratingPosting = false;
}
});
}
}
</script>
<style src="./course-head.scss" lang="scss" scoped/>
@@ -1,16 +0,0 @@
// Card
.el-card.course-card
{
// Margins
margin-right: 20px;
margin-left: 20px;
// Height limit
max-height: 250px;
// Limit name length
white-space: nowrap;
// Expansion color
background: #f4f6f9;
}
@@ -1,47 +0,0 @@
<template>
<div id="overall-course">
<el-card class="course-card">
<course-head :clickable="true" :course="course" :unread="unread()"/>
<div class="course-card-content expand"
v-if="unread() !== 0">
<assignment-entry v-for="assignment in unreadAssignments()"
:assignment="assignment"
:key="assignment.id"
unread="true"
v-on:mark-as-read="assignment.markAsRead()">
</assignment-entry>
</div>
</el-card>
</div>
</template>
<script lang="ts">
import {Component, Prop, Vue} from 'vue-property-decorator';
import AssignmentEntry from '@/pages/overall/overall-course/assignment-entry/assignment-entry.vue';
import CourseHead from '@/pages/overall/overall-course/course-head/course-head.vue';
import Course, {Assignment} from '@/logic/course';
@Component({
components: {AssignmentEntry, CourseHead}
})
export default class OverallCourse extends Vue
{
@Prop({required: true}) course: Course;
mounted()
{
this.unreadAssignments().forEach(a => a.addCallback(() => this.$forceUpdate()));
}
unreadAssignments(): Assignment[]
{
return this.course.assignments.filter(a => a.unread);
}
unread(): number
{
return this.unreadAssignments().length;
}
}
</script>
<style src="./overall-course.scss" lang="scss" scoped/>
@@ -1,180 +0,0 @@
import {Component, Prop, Vue} from 'vue-property-decorator';
import moment from 'moment';
import Course from '@/logic/course';
import {CourseUtils} from '@/logic/utils/course-utils';
import GraphUtils from '@/logic/utils/graph-utils';
import {GPAUtils} from '@/logic/utils/gpa-utils';
import Constants from '@/constants';
import Navigation from '@/components/navigation/navigation';
@Component
export default class OverallLine extends Vue
{
@Prop({required: true}) courses: Course[];
filteredCourses: Course[];
settings: any;
/**
* When this component is created
*/
created()
{
// Filter courses
this.filteredCourses = this.courses.filter(c => c.isGraded && c.assignments.length > 0);
// Generate settings
this.settings =
{
...GraphUtils.getBaseSettings('Average Grade', 'Average score trend for every course'),
// Zoom bar
dataZoom:
[
{
type: 'slider',
startValue: this.getStartDate(),
// Minimum zoom: 1 week
minValueSpan: 7 * 24 * 60 * 60 * 1000
}
],
// Tooltip
tooltip:
{
... GraphUtils.tooltipCssShadow(),
trigger: 'axis'
},
// Axis
xAxis:
{
type: 'time',
axisLabel:
{
formatter: (name: any) => moment(name).format('MMM DD')
},
},
yAxis:
{
axisLabel:
{
formatter: (name: any) => name + '%'
},
min: (value: any) => Math.floor(value.min),
max: (value: any) => Math.min(Math.ceil(value.max), 110)
},
// Series data
series: this.series()
}
}
/**
* Override options
*
* @param options Original options (Unused)
*/
afterConfig(options: any)
{
return this.settings;
}
/**
* Get starting date
*/
private getStartDate()
{
// If it's a past term, use the term's end date, else use today.
let selected = Navigation.instance.getSelectedTerm();
let end = selected == Constants.CURRENT_TERM || selected == -1
? moment() : moment(CourseUtils.getTermEndDate());
return Math.max(end.subtract(30, 'days').toDate().getTime(),
CourseUtils.getTermBeginDate().getTime())
}
/**
* Generate series data
*/
private series()
{
// Each course
let series: any[] = this.filteredCourses.map(course => this.getCourseSeries(course));
// Push other stuff
series.push(
{
type: 'line',
markLine: GraphUtils.getTermLines(),
markArea: GraphUtils.getGradeMarkAreas(0.4)
});
return series
}
/**
* Generate series data for a course
*
* @param course
*/
private getCourseSeries(course: Course)
{
// Graded assignments
let assignments = course.assignments.slice().reverse();
// Create series
return {
name: course.name,
type: 'line',
smooth: true,
symbol: 'circle', // circle, diamond, emptyCircle, none
data: this.toDateRange([...new Set(assignments.map(a => a.time))].map(time =>
{
// Find subset before this assignment
let subset = course.getAssignmentsBefore(time);
// Find grade
if (course.termGrading[subset.term].method == 'PERCENT_TYPE')
return [time, GPAUtils.getPercentTypeAverage(course.termGrading[subset.term], subset.assignments)];
else return [time, GPAUtils.getTotalMeanAverage(subset.assignments)];
}))
}
}
/**
* Convert point data to date range data.
* Eg. [[Mon, 10], [Wed, 5]] to [[Mon, 10], [Tue, 10], [Wed, 5]]
*
* @param data
*/
private toDateRange(data: any[])
{
// Find the min date
let minDate: Date = new Date(data[0][0]);
// Find the dates in between
let now = new Date(Math.min(new Date().getTime(), CourseUtils.getTermEndDate().getTime()));
let times: number[] = [];
for (let date = minDate; date <= now; date.setDate(date.getDate() + 1)) times.push(date.getTime());
// Map the points
let lastValue: any = null;
return times.map(time =>
{
// Data point on this specific date
let thisValue = data.find(a => a[0] == time);
// Switching terms
if (Constants.TERMS.find(t => t.getTime() == time))
lastValue = null;
// Find value
return thisValue == null
? lastValue == null ? null : [time, lastValue[1]]
: [time, (lastValue = thisValue)[1]];
});
}
}
@@ -1,10 +0,0 @@
<template>
<div id="overall-line">
<ve-line :extend="{a: this.courses}" :after-config="afterConfig"/>
</div>
</template>
<script src="./overall-line.ts" lang="ts"></script>
<style lang="scss" scoped>
</style>
-60
View File
@@ -1,60 +0,0 @@
.gpa-card
{
margin-left: 20px;
min-width: 136px;
}
.gpa
{
display: block;
}
.gpa.header
{
font-size: 14px;
}
.gpa.text
{
font-size: 35px;
font-family: var(--font);
}
.gpa.max
{
margin-top: -10px;
margin-bottom: 10px;
font-size: 12px;
color: #409eff;
}
.gpa.time
{
font-size: 11px;
}
.no-grade
{
font-size: 30px;
color: #b1b1b1;
// Disable selecting
display:block;
pointer-events: none;
user-select: none;
}
// Cards
.el-card.overall-bar-card
{
margin-right: 20px;
min-width: 170px;
}
.dialog-checkbox
{
display: block;
margin-top: 20px;
margin-bottom: -20px;
}
-141
View File
@@ -1,141 +0,0 @@
<template>
<div id="overall">
<el-progress v-if="started" :text-inside="true" :percentage="progress()"
:stroke-width="20" status="success" style="margin: 0 20px"/>
<el-dialog title="Notice" :visible.sync="clearUnreadPrompt" @close="clearUnread(false)"
width="30%" style="word-break: unset;">
<span>You have too many new grade notifications. Clear them now?</span>
<img src="./too-many-unread.png" alt=""/>
<el-checkbox class="dialog-checkbox" v-model="dontAskAgain">Don't Ask Again</el-checkbox>
<span slot="footer" class="dialog-footer">
<el-button @click="clearUnread(false)" style="float: left">Nope</el-button>
<el-button type="primary" @click="clearUnread(true)">Sure!</el-button>
</span>
</el-dialog>
<el-row v-if="getGPA().gpa !== -1">
<el-col :span="4" class="overall-span">
<el-card class="large gpa-card vertical-center" body-style="padding: 0">
<div style="padding: 14px;">
<span class="gpa header">GPA:</span>
<span class="gpa text">{{getGPA().gpa}}</span>
<span class="gpa max">(Out of {{getGPA().max}})</span>
<div class="bottom clearfix gpa time">
<time>{{ new Date().toDateString() }}</time>
</div>
</div>
</el-card>
</el-col>
<el-col :span="14" class="overall-span">
<el-card class="large overall-line-card vertical-center" body-style="padding: 0 10px">
<overall-line :courses="courses"/>
</el-card>
</el-col>
<el-col :span="6" class="overall-span">
<el-card class="large overall-bar-card vertical-center" body-style="padding: 0 10px">
<overall-bar :courses="courses"/>
</el-card>
</el-col>
</el-row>
<el-row v-if="getGPA().gpa === -1">
<el-card class="large gpa-card vertical-center">
<div class="no-grade">This quarter has no grades yet...</div>
</el-card>
</el-row>
<overall-course v-for="course in courses" :course="course" :key="course.id"/>
</div>
</template>
<script lang="ts">
import {Component, Prop, Vue} from 'vue-property-decorator';
import OverallLine from '@/pages/overall/overall-line/overall-line';
import OverallBar from '@/pages/overall/overall-bar/overall-bar';
import OverallCourse from '@/pages/overall/overall-course/overall-course.vue';
import Course, {Assignment} from '@/logic/course';
import {GPAUtils} from '@/logic/utils/gpa-utils';
@Component({
components: {OverallLine, OverallBar, OverallCourse}
})
export default class Overall extends Vue
{
@Prop({required: true}) courses: Course[];
/**
* This function is called to get gpa since I can't import another
* class in the Vue file.
*/
getGPA()
{
return GPAUtils.getGPA(this.courses);
}
// For clear unread prompt
unread: Assignment[];
clearUnreadPrompt = false;
dontAskAgain = false;
started = false;
/**
* Mark as read progress
*/
progress()
{
return +(this.unread.filter(a => !a.unread).length / this.unread.length * 100).toFixed(1);
}
/**
* On page load - check if the user has too many notifications
*/
mounted()
{
// Check unread
if (!this.$cookies.isKey('va.ignore-unread'))
{
// Count unread
this.unread = this.courses.flatMap(c => c.assignments.filter(a => a.unread));
// Prompt clear
if (this.unread.length > 15)
{
this.clearUnreadPrompt = true;
}
}
}
/**
* Clear unread
*
* @param confirmed
*/
clearUnread(confirmed: boolean)
{
// Hide prompt
this.clearUnreadPrompt = false;
// Not confirmed, do nothing
if (!confirmed)
{
if (!this.dontAskAgain) return;
// Don't ask again
this.$cookies.set('va.ignore-unread', true);
return;
}
// Clear unread
this.started = true;
this.unread.forEach((a, i) =>
{
// Delay: 100ms per assignment
// I don't want my server to explode lol
setTimeout(() => a.markAsRead().then(() => this.$forceUpdate()), 100 * i);
});
}
}
</script>
<style src="./overall.scss" lang="scss" scoped/>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

-13
View File
@@ -1,13 +0,0 @@
import Vue, {VNode} from 'vue';
declare global {
namespace JSX {
// tslint:disable no-empty-interface
interface Element extends VNode {}
// tslint:disable no-empty-interface
interface ElementClass extends Vue {}
interface IntrinsicElements {
[elem: string]: any;
}
}
}
-5
View File
@@ -1,5 +0,0 @@
declare module '*.vue'
{
import Vue from 'vue';
export default Vue;
}
-45
View File
@@ -1,45 +0,0 @@
<template>
<div id="info">
<div id="top">
<div id="title">Veracross Analyzer for SJP</div>
<div id="subtitle">Know your grades better.</div>
</div>
</div>
</template>
<script lang="ts">
import {Component, Vue} from 'vue-property-decorator';
@Component
export default class Info extends Vue
{
}
</script>
<style lang="scss" scoped>
#top
{
padding: 40vh 0;
// Center and scale the image nicely
background-position: center;
background-repeat: no-repeat;
background-size: cover;
text-align: right;
#title
{
font-size: 30px;
margin-right: 10vw;
color: white;
}
#subtitle
{
margin-right: 10vw;
color: white;
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More