├── constants ├── colors.ts ├── dayDict.ts ├── index.ts ├── timetable.ts └── timeDict.ts ├── public ├── robots.txt └── favicon.ico ├── .gitattributes ├── assets ├── demo1.gif ├── demo2.gif ├── not-found.gif ├── NTUStars-icon.png ├── NTUStars-logo.png ├── patreon_logo.png └── css │ ├── fonts │ └── avionbold.woff │ ├── app.css │ └── vars.css ├── types └── day.ts ├── .firebaserc ├── models ├── supporter.ts ├── coursesInfo.ts ├── dbLesson.ts ├── dbSchedule.ts ├── parsedCourse.ts ├── parsedLesson.ts ├── courseDisplay.ts └── dbCourse.ts ├── .vscode └── extensions.json ├── composables ├── helper.ts ├── validator.ts ├── alerts │ └── store-alerts.ts ├── event-utils.ts ├── calendar-generator.ts └── parsers.ts ├── tsconfig.json ├── eslint.config.mjs ├── .prettierrc.json ├── .gitignore ├── firebase.json ├── app.config.ts ├── components ├── layout │ ├── NotFoundSection.vue │ ├── DetailsSearchBar.vue │ ├── HeaderSection.vue │ ├── CourseViewSkeleton.vue │ └── FooterSection.vue ├── details │ ├── IndexDetailTable.vue │ └── IndexTable.vue ├── rightdrawer │ ├── SemCourseSelector.vue │ ├── SelectSemBar.vue │ ├── SelectedList.vue │ ├── SelectPlanBar.vue │ ├── SearchBar.vue │ └── SelectedListItem.vue ├── changelogs │ ├── ChangeLogItem.vue │ └── ChangeLogList.vue ├── EventFormDialog.vue ├── SupportersSection.vue ├── HelpStepper.vue └── TimetableSection.vue ├── package.json ├── nuxt.config.ts ├── pages ├── index.vue └── courses │ └── [year]-[sem]-[code].vue ├── stores ├── settings.ts ├── schedules.ts └── timetable.ts ├── app.vue ├── README.md └── .firebase └── hosting.Lm91dHB1dC9wdWJsaWM.cache /constants/colors.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-Agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /assets/demo1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nosnelmil/NTUStars/HEAD/assets/demo1.gif -------------------------------------------------------------------------------- /assets/demo2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nosnelmil/NTUStars/HEAD/assets/demo2.gif -------------------------------------------------------------------------------- /types/day.ts: -------------------------------------------------------------------------------- 1 | export type Day = "MON" | "TUE" | "WED" | "THU" | "FRI" | "SAT" | "SUN"; -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "ntu-schedule-maker" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nosnelmil/NTUStars/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /assets/not-found.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nosnelmil/NTUStars/HEAD/assets/not-found.gif -------------------------------------------------------------------------------- /models/supporter.ts: -------------------------------------------------------------------------------- 1 | export interface Supporter { 2 | name: string; 3 | amount: number; 4 | } -------------------------------------------------------------------------------- /assets/NTUStars-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nosnelmil/NTUStars/HEAD/assets/NTUStars-icon.png -------------------------------------------------------------------------------- /assets/NTUStars-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nosnelmil/NTUStars/HEAD/assets/NTUStars-logo.png -------------------------------------------------------------------------------- /assets/patreon_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nosnelmil/NTUStars/HEAD/assets/patreon_logo.png -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"] 3 | } 4 | -------------------------------------------------------------------------------- /composables/helper.ts: -------------------------------------------------------------------------------- 1 | export function getCurrentDay(day: string): string { 2 | return "2023-05-0" + day 3 | } -------------------------------------------------------------------------------- /assets/css/fonts/avionbold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nosnelmil/NTUStars/HEAD/assets/css/fonts/avionbold.woff -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import withNuxt from './.nuxt/eslint.config.mjs' 3 | 4 | export default withNuxt( 5 | // Your custom configs here 6 | ) 7 | -------------------------------------------------------------------------------- /assets/css/app.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'avionbold'; 3 | src: url(./fonts/avionbold.woff); 4 | } 5 | 6 | .brand-font { 7 | font-family: 'avionbold'; 8 | } -------------------------------------------------------------------------------- /models/coursesInfo.ts: -------------------------------------------------------------------------------- 1 | import type { ParsedCourse } from "./parsedCourse"; 2 | 3 | export interface CoursesInfo { 4 | [semester: string]: { 5 | [courseCode: string]: ParsedCourse; 6 | } 7 | } -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/prettierrc", 3 | "semi": false, 4 | "tabWidth": 2, 5 | "singleQuote": true, 6 | "printWidth": 100, 7 | "trailingComma": "none" 8 | } -------------------------------------------------------------------------------- /composables/validator.ts: -------------------------------------------------------------------------------- 1 | export function validateCourseCode(courseCode: string): boolean { 2 | return (courseCode.length == 6 && /^[a-zA-Z]{2}(?=(?:.*\d){2,})[a-zA-Z0-9]{4}$/.test(courseCode)); 3 | }; 4 | -------------------------------------------------------------------------------- /models/dbLesson.ts: -------------------------------------------------------------------------------- 1 | import type { Day } from "~/types/day"; 2 | 3 | export interface DBLesson { 4 | day: Day; 5 | group: string; 6 | time: number[]; 7 | type: string; 8 | venue: string; 9 | weeks: number[]; 10 | } -------------------------------------------------------------------------------- /models/dbSchedule.ts: -------------------------------------------------------------------------------- 1 | import type { DBLesson } from "./dbLesson"; 2 | 3 | // schedule is an object with a variable number of keys each key is a number mapped to a index 4 | export interface DBSchedule { 5 | [indexNumber: number]: { 6 | lessons: DBLesson[]; 7 | }; 8 | updatedAt: string; 9 | } -------------------------------------------------------------------------------- /constants/dayDict.ts: -------------------------------------------------------------------------------- 1 | import type { Day } from "~/types/day" 2 | 3 | type DayDict = { 4 | [key in Day]: string; 5 | }; 6 | 7 | export const DayDict: DayDict = { 8 | "MON": "1", 9 | "TUE": "2", 10 | "WED": "3", 11 | "THU": "4", 12 | "FRI": "5", 13 | "SAT": "6", 14 | "SUN": "7", 15 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Nuxt dev/build outputs 2 | .output 3 | .data 4 | .nuxt 5 | .nitro 6 | .cache 7 | dist 8 | 9 | # Node dependencies 10 | node_modules 11 | 12 | # Logs 13 | logs 14 | *.log 15 | 16 | # Misc 17 | .DS_Store 18 | .fleet 19 | .idea 20 | 21 | # Local env files 22 | .env 23 | .env.* 24 | !.env.example 25 | -------------------------------------------------------------------------------- /models/parsedCourse.ts: -------------------------------------------------------------------------------- 1 | import type { ParsedLesson } from "./parsedLesson"; 2 | 3 | export interface ParsedCourse { 4 | lectures: ParsedLesson[]; 5 | lessons: { 6 | [index: string]: ParsedLesson[]; 7 | } 8 | courseName: string; 9 | courseCode: string; 10 | au: string; 11 | description?: string; 12 | } -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": ".output/public", 4 | "ignore": [ 5 | "firebase.json", 6 | "**/.*", 7 | "**/node_modules/**" 8 | ], 9 | "rewrites": [ 10 | { 11 | "source": "**", 12 | "destination": "/index.html" 13 | } 14 | ] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /models/parsedLesson.ts: -------------------------------------------------------------------------------- 1 | export interface ParsedLesson { 2 | id: string; 3 | groupId: string | null; 4 | courseName: string; 5 | courseCode: string; 6 | index: string; 7 | type: string; 8 | group: string; 9 | venue: string; 10 | date: string; 11 | weeks: string; 12 | frequency: string; 13 | start: string; 14 | end: string; 15 | au: string; 16 | } -------------------------------------------------------------------------------- /models/courseDisplay.ts: -------------------------------------------------------------------------------- 1 | import type { ParsedLesson } from "./parsedLesson"; 2 | 3 | export interface CourseDisplay extends ParsedLesson { 4 | editable: boolean; 5 | classNames: string[]; 6 | isPreview: boolean; 7 | backgroundColor: string; 8 | borderColor: string; 9 | textColor: string; 10 | isLoading?: boolean; 11 | isCustom?: boolean; // for custom events 12 | } -------------------------------------------------------------------------------- /app.config.ts: -------------------------------------------------------------------------------- 1 | export default defineAppConfig({ 2 | nuxtQuasar: { 3 | brand: { 4 | primary: '#1b1b1b', 5 | secondary: '#f4a261', 6 | accent: '#666600', 7 | body: '#FFF', 8 | dark: '#1b1b1b', 9 | 'dark-page': '#1b1b1b', 10 | 11 | positive: '#21BA45', 12 | negative: '#C10015', 13 | info: '#31CCEC', 14 | warning: '#F2C037' 15 | } 16 | } 17 | }) -------------------------------------------------------------------------------- /models/dbCourse.ts: -------------------------------------------------------------------------------- 1 | import type { DBSchedule } from "./dbSchedule"; 2 | 3 | export interface DBCourse { 4 | id: string; 5 | au: string; 6 | courseCode: string; 7 | courseName: string; 8 | description: string; 9 | schedule: DBSchedule; 10 | gradeType: string; 11 | mutexWith: string[]; 12 | notAvailTo: string[]; 13 | notAvailWith: string[]; 14 | preRequisites: string; 15 | programme: string; 16 | remarks: string; 17 | } 18 | -------------------------------------------------------------------------------- /constants/index.ts: -------------------------------------------------------------------------------- 1 | export const PATREON_URL = "https://patreon.com/nosnelmil?utm_medium=unknown&utm_source=join_link&utm_campaign=creatorshare_creator&utm_content=copyLink" 2 | export const FEEDBACK_URL = "https://www.patreon.com/posts/general-feedback-131369124?utm_medium=clipboard_copy&utm_source=copyLink&utm_campaign=postshare_creator&utm_content=join_link" 3 | export const BUG_REPORT_URL = "https://www.patreon.com/posts/general-bugs-131369058?utm_medium=clipboard_copy&utm_source=copyLink&utm_campaign=postshare_creator&utm_content=join_link" -------------------------------------------------------------------------------- /composables/alerts/store-alerts.ts: -------------------------------------------------------------------------------- 1 | import { Notify } from "quasar" 2 | 3 | export function codeDoesNotExist(courseCode: string, semester: string): void { 4 | Notify.create({ 5 | message: `${courseCode} does not exist for sem ${semester}`, 6 | color: 'negative' 7 | }) 8 | } 9 | 10 | export function errorFetchingSchedule(courseCode: string, semester: string): void { 11 | Notify.create({ 12 | message: `Unable to load ${courseCode} for sem ${semester}. Please try again. Error Code: 1001 `, 13 | color: 'negative' 14 | }) 15 | } -------------------------------------------------------------------------------- /constants/timetable.ts: -------------------------------------------------------------------------------- 1 | export const COLORS = [ 2 | ['#E53935', '#1E88E5', '#FB8C00', '#00897B', '#8E24AA', '#3949AB', '#43A047', '#D81B60', '#006064', '#703929', '#311B92', '#004D40'], 3 | ['#4A148C', '#00695C', '#C62828', '#AD1457', '#1565C0', '#2E7D32', '#FF8F00', '#E64A19', '#283593', '#0D47A1', '#B71C1C', '#004D40'], 4 | ['#D32F2F', '#1976D2', '#388E3C', '#FBC02D', '#7B1FA2', '#F57C00', '#00796B', '#512DA8', '#C2185B', '#303F9F', '#FFA000', '#0097A7'], 5 | ['#6D4C41', '#455A64', '#78909C', '#A1887F', '#546E7A', '#8D6E63', '#607D8B', '#BCAAA4', '#37474F', '#795548', '#90A4AE', '#D7CCC8'], 6 | ] 7 | 8 | export const DEFAULT_PLAN_NUMBER = 0; -------------------------------------------------------------------------------- /components/layout/NotFoundSection.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | -------------------------------------------------------------------------------- /composables/event-utils.ts: -------------------------------------------------------------------------------- 1 | let eventGuid = 0 2 | // Event Model 3 | // const INITIAL_EVENTS = [ 4 | // { 5 | // id: createEventId(), 6 | // groupId: "group2", 7 | // editable: false, 8 | // courseName: 'Software engineering', 9 | // courseCode: "SC2006", 10 | // index: "10523", 11 | // type: "TUT", 12 | // group: "A21", 13 | // venue: "venue", 14 | // date: "2023-05-02", 15 | // weeks: "1,2,3,4,566,4,6,4", 16 | // frequency: "Every Week", 17 | // start: "2023-05-02" + 'T12:00:00', 18 | // end: "2023-05-02" + 'T14:00:00', 19 | // backgroundColor: '#5C6BC0', 20 | // borderColor: 'red', 21 | // textColor: 'white', 22 | // isPreview: false, 23 | // classNames: [] 24 | // } 25 | // ] 26 | 27 | export function createEventId(): string { 28 | return String(eventGuid++) 29 | } 30 | -------------------------------------------------------------------------------- /constants/timeDict.ts: -------------------------------------------------------------------------------- 1 | interface TimeDict { 2 | [key: number]: string; 3 | } 4 | 5 | export const TimeDict: TimeDict = { 6 | 0: "T08:00:00", 7 | 1: "T08:30:00", 8 | 2: "T09:00:00", 9 | 3: "T09:30:00", 10 | 4: "T10:00:00", 11 | 5: "T10:30:00", 12 | 6: "T11:00:00", 13 | 7: "T11:30:00", 14 | 8: "T12:00:00", 15 | 9: "T12:30:00", 16 | 10: "T13:00:00", 17 | 11: "T13:30:00", 18 | 12: "T14:00:00", 19 | 13: "T14:30:00", 20 | 14: "T15:00:00", 21 | 15: "T15:30:00", 22 | 16: "T16:00:00", 23 | 17: "T16:30:00", 24 | 18: "T17:00:00", 25 | 19: "T17:30:00", 26 | 20: "T18:00:00", 27 | 21: "T18:30:00", 28 | 22: "T19:00:00", 29 | 23: "T19:30:00", 30 | 24: "T20:00:00", 31 | 25: "T20:30:00", 32 | 26: "T21:00:00", 33 | 27: "T21:30:00", 34 | 28: "T22:00:00", 35 | 29: "T22:30:00", 36 | 30: "T23:00:00", 37 | 31: "T23:30:00", 38 | }; -------------------------------------------------------------------------------- /composables/calendar-generator.ts: -------------------------------------------------------------------------------- 1 | import ical, { ICalCalendarMethod } from 'ical-generator'; 2 | 3 | const calendar = ical({ name: 'my first iCal' }); 4 | calendar.method(ICalCalendarMethod.REQUEST); 5 | 6 | const startTime = new Date(); 7 | const endTime = new Date(); 8 | endTime.setHours(startTime.getHours() + 1); 9 | 10 | calendar.createEvent({ 11 | start: startTime, 12 | end: endTime, 13 | summary: 'Example Event', 14 | description: 'It works ;)', 15 | location: 'my room', 16 | url: 'http://sebbo.net/', 17 | }); 18 | 19 | export function useCalendarGenerator() { 20 | const downloadCalendar = () => { 21 | const blob = new Blob([calendar.toString()], { type: 'text/calendar' }); 22 | const url = URL.createObjectURL(blob); 23 | 24 | const a = document.createElement('a'); 25 | a.href = url; 26 | a.download = 'calendar.ics'; 27 | a.click(); 28 | 29 | URL.revokeObjectURL(url); 30 | }; 31 | 32 | return { 33 | downloadCalendar, 34 | }; 35 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nuxt-app", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "build": "nuxt build", 7 | "dev": "nuxt dev", 8 | "dev:local": "nuxt dev --dotenv .env.local", 9 | "generate": "nuxt generate", 10 | "preview": "nuxt preview --dotenv .env.local", 11 | "postinstall": "nuxt prepare", 12 | "deploy": "firebase deploy --only hosting" 13 | }, 14 | "dependencies": { 15 | "@fullcalendar/core": "^6.1.17", 16 | "@fullcalendar/interaction": "^6.1.17", 17 | "@fullcalendar/timegrid": "^6.1.17", 18 | "@fullcalendar/vue3": "^6.1.17", 19 | "@nuxt/eslint": "^1.4.1", 20 | "@nuxt/icon": "^1.13.0", 21 | "@nuxt/image": "^1.10.0", 22 | "@nuxt/test-utils": "^3.19.1", 23 | "@nuxt/ui": "^3.1.3", 24 | "@pinia/nuxt": "^0.11.0", 25 | "@quasar/extras": "^1.17.0", 26 | "eslint": "^9.28.0", 27 | "flexsearch": "^0.8.205", 28 | "ical-generator": "^9.0.0", 29 | "nuxt": "^3.17.4", 30 | "nuxt-quasar-ui": "^2.1.12", 31 | "pinia": "^3.0.2", 32 | "pinia-plugin-persistedstate": "^4.3.0", 33 | "quasar": "^2.18.1", 34 | "vue": "^3.5.15", 35 | "vue-router": "^4.5.1" 36 | }, 37 | "devDependencies": { 38 | "typescript": "^5.8.3", 39 | "vue-tsc": "^2.2.10" 40 | } 41 | } -------------------------------------------------------------------------------- /components/details/IndexDetailTable.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | -------------------------------------------------------------------------------- /components/rightdrawer/SemCourseSelector.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | -------------------------------------------------------------------------------- /components/layout/DetailsSearchBar.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | // https://nuxt.com/docs/api/configuration/nuxt-config 2 | 3 | export default defineNuxtConfig({ 4 | ssr: false, 5 | compatibilityDate: '2025-05-15', 6 | app: { 7 | head: { 8 | title: 'NTUStars | NTU Semester Timetable Planner', 9 | meta: [ 10 | { name: 'description', content: "Plan your STARS / NTU semester timetable with ease. NTU Stars is a website that provides NTU students with a user-friendly tool to create a timetable that fits their class schedule. Easily view how all indexes of one module fit in your timetable and optimize your time. Get started today!" }, 11 | ], 12 | } 13 | }, 14 | devtools: { enabled: true }, 15 | typescript: { 16 | typeCheck: true 17 | }, 18 | runtimeConfig: { 19 | public: { 20 | getcoursecontentEndpoint: "", 21 | getscheduleEndpoint: "", 22 | getsemestersEndpoint: "", 23 | getsearchablecoursesEndpoint: "", 24 | getsupportersEndpoint: "", 25 | }, 26 | }, 27 | css: [ 28 | '@/assets/css/app.css', 29 | '@/assets/css/vars.css', 30 | ], 31 | modules: [ 32 | '@nuxt/eslint', 33 | '@nuxt/icon', 34 | '@nuxt/image', 35 | '@nuxt/ui', 36 | '@nuxt/test-utils', 37 | '@pinia/nuxt', 38 | 'pinia-plugin-persistedstate/nuxt', 39 | 'nuxt-quasar-ui', 40 | ], 41 | quasar: { 42 | plugins: ['Notify', 'Dialog'], 43 | config: { 44 | dark: true 45 | } 46 | }, 47 | piniaPluginPersistedstate: { 48 | debug: true 49 | } 50 | }) -------------------------------------------------------------------------------- /components/rightdrawer/SelectSemBar.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | -------------------------------------------------------------------------------- /pages/index.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /components/layout/HeaderSection.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | -------------------------------------------------------------------------------- /stores/settings.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { Dark } from 'quasar'; 3 | 4 | interface SettingsState { 5 | openedHelpBefore: boolean; 6 | openedChangesBefore: boolean; 7 | leftDrawerOpen: boolean; 8 | rightDrawerOpen?: boolean; 9 | darkMode: boolean; 10 | changesDialogOpen?: boolean; 11 | } 12 | 13 | export const useSettingsStore = defineStore('settings', { 14 | state: (): SettingsState => { 15 | return { 16 | openedHelpBefore: false, 17 | openedChangesBefore: false, 18 | leftDrawerOpen: false, 19 | rightDrawerOpen: false, 20 | darkMode: true, 21 | } 22 | }, 23 | getters: { 24 | getInitalHelpModalState: (state) => { 25 | if (state.openedHelpBefore) { 26 | return false 27 | } 28 | state.openedHelpBefore = true 29 | return true 30 | }, 31 | getInitalChangesModalState: (state) => { 32 | if (state.openedChangesBefore) { 33 | return false 34 | } 35 | state.openedChangesBefore = true 36 | return true 37 | }, 38 | 39 | }, 40 | actions: { 41 | setSettings() { 42 | Dark.set(this.darkMode) 43 | }, 44 | toggleLeftDrawer() { 45 | this.leftDrawerOpen = !this.leftDrawerOpen 46 | }, 47 | setRightDrawerValue(value: boolean) { 48 | this.rightDrawerOpen = value 49 | }, 50 | toggleRightDrawer() { 51 | this.rightDrawerOpen = !this.rightDrawerOpen 52 | }, 53 | toggelDarkMode() { 54 | Dark.toggle() 55 | this.darkMode = Dark.isActive 56 | return Dark.isActive 57 | }, 58 | toggleChangesDialog() { 59 | this.changesDialogOpen = !this.changesDialogOpen 60 | } 61 | }, 62 | persist: true 63 | }) 64 | -------------------------------------------------------------------------------- /components/details/IndexTable.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | -------------------------------------------------------------------------------- /components/rightdrawer/SelectedList.vue: -------------------------------------------------------------------------------- 1 | 40 | -------------------------------------------------------------------------------- /components/changelogs/ChangeLogItem.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | -------------------------------------------------------------------------------- /components/rightdrawer/SelectPlanBar.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 64 | -------------------------------------------------------------------------------- /app.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 68 | 69 | -------------------------------------------------------------------------------- /components/layout/CourseViewSkeleton.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/EventFormDialog.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | -------------------------------------------------------------------------------- /components/layout/FooterSection.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | -------------------------------------------------------------------------------- /assets/css/vars.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --fc-small-font-size: .85em; 3 | --fc-page-bg-color: #131314; 4 | --fc-neutral-bg-color: #bfbfbf; 5 | --fc-neutral-text-color: #808080; 6 | --fc-border-color: #9c9cb46f; 7 | 8 | --fc-button-text-color: #fff; 9 | --fc-button-bg-color: #2C3E50; 10 | --fc-button-border-color: #2c3e5000; 11 | --fc-button-hover-bg-color: #1e2b37; 12 | --fc-button-hover-border-color: #1a252f00; 13 | --fc-button-active-bg-color: #1a252f; 14 | --fc-button-active-border-color: #151e2700; 15 | 16 | --fc-event-bg-color: #2b2d42; 17 | --fc-event-border-color: #2b2d4200; 18 | --fc-event-text-color: #fff; 19 | --fc-event-selected-overlay-color: rgba(0, 0, 0, 0.25); 20 | 21 | --fc-more-link-bg-color: #d0d0d0; 22 | --fc-more-link-text-color: inherit; 23 | 24 | --fc-event-resizer-thickness: 8px; 25 | --fc-event-resizer-dot-total-width: 8px; 26 | --fc-event-resizer-dot-border-width: 1px; 27 | 28 | --fc-non-business-color: rgba(215, 215, 215, 0.3); 29 | --fc-bg-event-color: rgb(143, 223, 130); 30 | --fc-bg-event-opacity: 0.3; 31 | --fc-highlight-color: rgba(188, 232, 241, 0.3); 32 | --fc-today-bg-color: rgba(255, 220, 40, 0.15); 33 | --fc-now-indicator-color: red; 34 | 35 | --track-color: #d0d0d0; 36 | --track-thumb-color: var(--fc-neutral-bg-color); 37 | } 38 | 39 | .body--dark { 40 | --fc-border-color: #2b2d426f; 41 | --fc-neutral-bg-color: #131314; 42 | 43 | --track-color: var(--fc-border-color); 44 | --track-thumb-color: var(--fc-neutral-bg-color); 45 | --track-thumb-hover-color: #808080; 46 | } 47 | 48 | *::-webkit-scrollbar { 49 | width: 6px; 50 | height: 6px; 51 | } 52 | 53 | *::-webkit-scrollbar-track { 54 | background-color: var(--track-color); 55 | border-radius: 10px; 56 | } 57 | 58 | *::-webkit-scrollbar-thumb { 59 | background-color: var(--track-thumb-color); 60 | border-radius: 10px; 61 | transition: background-color 0.3s; 62 | } 63 | 64 | *::-webkit-scrollbar-thumb:hover { 65 | background-color: var(--track-thumb-hover-color); 66 | } 67 | 68 | /* Apply border to all td and th by default */ 69 | .fc-theme-standard td, 70 | .fc-theme-standard th { 71 | border: 2px solid var(--fc-border-color); 72 | } 73 | 74 | 75 | /* Remove border for the first column */ 76 | .fc-theme-standard tr td:first-child, 77 | .fc-theme-standard tr th:first-child { 78 | border: none; 79 | } 80 | 81 | .fc-col-header-cell.fc-day { 82 | border: none; 83 | border-bottom: 1px solid var(--fc-border-color); 84 | } 85 | 86 | .fc-scrollgrid.fc-scrollgrid-liquid { 87 | border: none 88 | } 89 | 90 | 91 | tbody tr td.fc-timegrid-slot.fc-timegrid-slot-lane { 92 | border: none; 93 | } 94 | 95 | tbody tr:nth-child(4n+4) td.fc-timegrid-slot.fc-timegrid-slot-lane { 96 | background-color: color-mix(in srgb, var(--fc-neutral-bg-color) 50%, transparent); 97 | /* Or your desired color */ 98 | } 99 | 100 | tbody tr:nth-child(4n+3) td.fc-timegrid-slot.fc-timegrid-slot-lane { 101 | background-color: color-mix(in srgb, var(--fc-neutral-bg-color) 50%, transparent); 102 | /* Or your desired color */ 103 | } -------------------------------------------------------------------------------- /components/SupportersSection.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 69 | 70 | -------------------------------------------------------------------------------- /components/rightdrawer/SearchBar.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | -------------------------------------------------------------------------------- /components/rightdrawer/SelectedListItem.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 105 | 106 | -------------------------------------------------------------------------------- /components/changelogs/ChangeLogList.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | -------------------------------------------------------------------------------- /pages/courses/[year]-[sem]-[code].vue: -------------------------------------------------------------------------------- 1 | 64 | 65 | -------------------------------------------------------------------------------- /composables/parsers.ts: -------------------------------------------------------------------------------- 1 | import type { DBCourse } from "~/models/dbCourse" 2 | import { getCurrentDay } from "./helper" 3 | import type { ParsedCourse } from "~/models/parsedCourse" 4 | import { TimeDict } from "~/constants/timeDict" 5 | import { DayDict } from "~/constants/dayDict" 6 | import type { DBLesson } from "~/models/dbLesson" 7 | import type { ParsedLesson } from "~/models/parsedLesson" 8 | 9 | 10 | export function parseCourseInfoFromDB(data: DBCourse) { 11 | const { schedule, courseName, courseCode, au } = data 12 | // instantiate result object 13 | const result: ParsedCourse = { 14 | lectures: [], 15 | lessons: {}, 16 | courseName: courseName, 17 | courseCode: courseCode, 18 | au: au 19 | } 20 | // split schedule into lectures and lessons 21 | // lectures are classes of type "LEC/STUDIO" 22 | // lessons are classes of other types (e.g. TUT, LAB, etc.) 23 | 24 | // Prevent duplicate lectures from being added 25 | // check if lectures across indexes are the same or unique 26 | // idea is that lectures are designed to be the same across all indexes in NTU 27 | // if they are not, then we will treat them as unique lectures 28 | let lectureAdded = false 29 | let isUniqueLectures = false 30 | const lectureMap = new Map() 31 | for (const indexSchedule of Object.values(schedule)) { 32 | // Check if is a valid index object 33 | if (typeof indexSchedule !== "object" || indexSchedule.length === 0) continue; 34 | // cast indexSchedule as DBIndex 35 | const castedIndexSchedule = indexSchedule as DBLesson[]; 36 | 37 | for (const classData of castedIndexSchedule) { 38 | if (classData.type == "LEC/STUDIO") { 39 | const start = getCurrentDay(DayDict[classData.day]) + parseStartTime(classData.time) 40 | const end = getCurrentDay(DayDict[classData.day]) + parseEndTime(classData.time) 41 | if (!lectureAdded) { 42 | lectureMap.set(start, end) 43 | } else if (lectureMap.get(start) != end) { 44 | isUniqueLectures = true 45 | break 46 | } 47 | } 48 | } 49 | lectureAdded = true 50 | if (isUniqueLectures == true) { 51 | break 52 | } 53 | } 54 | lectureAdded = false 55 | for (const [index, indexSchedule] of Object.entries(schedule)) { 56 | for (let i = 0; i < indexSchedule.length; i++) { 57 | const classData = indexSchedule[i] as DBLesson 58 | // skip if have already added lecture classes 59 | if (lectureAdded && classData.type == "LEC/STUDIO" && !isUniqueLectures) continue; 60 | // create class info object (A class in an index) 61 | const classInfo: ParsedLesson = { 62 | id: `${courseCode} ${index} ${i}`, 63 | groupId: courseCode + index, 64 | courseName: courseName, 65 | courseCode: courseCode, 66 | index: index, 67 | type: classData.type, 68 | group: classData.group, 69 | venue: classData.venue, 70 | date: getCurrentDay(DayDict[classData.day]), 71 | weeks: classData.weeks.toString(), 72 | frequency: parseWeeks(classData.weeks), 73 | start: getCurrentDay(DayDict[classData.day]) + parseStartTime(classData.time), 74 | end: getCurrentDay(DayDict[classData.day]) + parseEndTime(classData.time), 75 | au: au 76 | } 77 | // if its a lecture, remove unnecessary info 78 | if (classData.type == "LEC/STUDIO" && !isUniqueLectures) { 79 | classInfo.groupId = null 80 | classInfo.index = "" 81 | classInfo.group = "" 82 | result["lectures"].push(classInfo) 83 | } else { 84 | (result["lessons"][index] ??= []).push(classInfo) 85 | } 86 | } 87 | lectureAdded = true 88 | } 89 | return result 90 | } 91 | 92 | export function parseWeeks(weekArray: number[]) { 93 | // convert week array to meaningful text 94 | if (weekArray.length > 4) { 95 | if (weekArray.length > 8) { 96 | return "Every Week" 97 | } else { 98 | if (weekArray[0] % 2 == 0) { 99 | return "Even Week" 100 | } else { 101 | return "Odd Week" 102 | } 103 | } 104 | } 105 | return "Week " + weekArray.toString() 106 | } 107 | 108 | export function parseStartTime(timeArray: number[]) { 109 | // convert time array to required format 110 | return TimeDict[timeArray[0]] 111 | } 112 | export function parseEndTime(timeArray: number[]) { 113 | return TimeDict[timeArray[timeArray.length - 1] + 1] 114 | } -------------------------------------------------------------------------------- /components/HelpStepper.vue: -------------------------------------------------------------------------------- 1 | 119 | 120 | 121 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |
3 | Logo 4 |

NTUStars

5 | 6 |

7 | Solving a decade old problem in NTU! 8 |
9 | View Website » 10 |
11 |
12 | LinkedIn 13 | · 14 | Server Repo 15 | · 16 | Report Bug 17 | · 18 | Request Feature 19 |

20 |
21 | 22 | # 23 | drawing 24 | 25 | # Background 26 | Bring up the phrase "STARS Wars" to any NTU student and the response would be universal - frustration and anger. 27 | STARS refers to a course bidding system provided by NTU in which students engage in two major activities: 28 | - planning their upcoming semester timetable 29 | - bidding and securing their courses 30 | 31 | I have discussed the background more in-depth on LinkedIn, so follow the link if you would like to find out more! [LinkedIn](https://www.linkedin.com/posts/lenson-lim-05974621b_during-the-semester-break-i-finally-attempted-activity-7100377614392950784-J8Md?utm_source=share&utm_medium=member_desktop) 32 | 33 | In summary, this project aims to solve a decade-old problem by providing a tool for NTU students to plan their timetable in an effective and simplified manner. 34 | 35 | # Architecture 36 | Architecture 37 | 38 | Frontend Repository: [NTUStars App](https://github.com/Lebarnon/BetterNotesApp)
39 | Backend Repository: [NTUStars Server](https://github.com/Lebarnon/BetterNotesServer) 40 | 41 | ### **Frontend: Vue3 + Pinia** 42 | For the frontend, I utilized Vue3 composition API and managed most of the application state using Pinia. If you are familiar with React, Pinia is similar to React Redux. 43 | Most of the application logic lies within the three stores I have: 44 | 1. Schedules Store 45 | The schedules store's main job is to interact with my serverless APIs and format incoming data into a structure I want for my specific use cases in the frontend. 46 | 2. Timetable Store 47 | The timetable store contains all functionality related to the actual timetable itself, whether it's adding courses, removing, previewing, etc. In hindsight, I should have done this in Typescript, but refactoring at this point was a lot of work. Instead, I brought the lessons learned to this project: 48 | [BetterNotes](https://github.com/Lebarnon/BetterNotesApp) 49 | 3. Settings Store 50 | The settings store contains all the settings-related stuff in the app like dark mode. 51 | 52 | ### **Backend: Firebase** 53 | Firebase Functions were used as my serverless server mainly due to its always free tier (great for my pocket). 54 | This project could actually be done entirely on the frontend, but I really wanted to play around with a serverless architecture and to hide crucial "business logic". 55 | 56 | The overall logic in Firebase Functions is as follows: 57 | 58 | **User request for data** 59 | 60 | Validate request --> Check Firestore for requested data --> If available, return data from Firestore --> Else start scraping service (see) --> clean & format scraped data --> save into Firestore --> return data 61 | 62 | **Scraping Service** 63 | 64 | [NTU Class Schedule](https://wish.wis.ntu.edu.sg/webexe/owa/aus_schedule.main) is where I get course timetable data. 65 | [NTU Content of Courses](https://wis.ntu.edu.sg/webexe/owa/aus_subj_cont.main) is where I get data on course information like the course description. 66 | Not sure why they must have separate websites for different information. So based on the request and using [Puppeteer](https://pptr.dev/), I first had to analyze the HTML structure of the website, find the appropriate information I want using a variety of CSS selectors, and finally return it to be formatted in whatever way I deemed fit to be saved in Firestore. 67 | 68 | # Considerations 69 | For anyone who might be curious, these are just some of my considerations and thought processes during this project. 70 | 71 | ## Identifying the Issue 72 | Planning a timetable first requires students to add the modules they are interested in. Afterward, students would have to painstakingly iterate through each index for each module to find a timetable that is clash-free and to their preference (no lessons on a particular day, morning/afternoon lessons). Given that each module has dozens or even hundreds of indexes, a simple calculation shows that students have thousands of possible timetable combinations to go through before finding suitable ones. Thousands. 73 | 74 | The most intuitive idea is to help generate all possible combinations, and students just need to select their ideal ones. 75 | 76 | However, existing attempts to solve this problem already involve timetable generators, but through testing and feedback, they often fall short due to the complexity of personal preferences. To truly simplify the process, I recognized the need for flexibility without sacrificing simplicity. 77 | 78 | ## Developing the Solution 79 | Here are the consolidated and summarized considerations: 80 | 81 | **Current Solutions**: Existing attempts to solve this problem involve timetable generators, but they often fall short due to the complexity of personal preferences. To truly simplify the process, I recognized the need for flexibility without sacrificing simplicity. 82 | 83 | **Value**: I was aware that my solution needed to be significantly better than current options that students are familiar with to be truly valuable. This meant optimizing load times, modernizing the user interface while maintaining familiarity, and significantly streamlining the planning process. 84 | 85 | **Cost**: As this project would ultimately be intended for the community, cost-efficiency was essential. Therefore, there was a need to strike a balance between different cloud providers and their solutions that would allow me to scale the project comfortably. 86 | 87 | **Requirements**: I used a simple litmus test, which was to constantly ask, "Would I use it?". Aside from that, there was also a need to continuously seek feedback and user testing from my peers as NTU students were, after all, the key stakeholders. 88 | 89 | All in all, the result was NTUStars! The journey was definitely rewarding, and I've learned so much because I was able to take the time to understand what's going on, explore other possibilities, and simply test out any ideas I had. -------------------------------------------------------------------------------- /stores/schedules.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { parseCourseInfoFromDB } from '../composables/parsers'; 3 | import { codeDoesNotExist, errorFetchingSchedule } from '../composables/alerts/store-alerts'; 4 | import type { DBCourse } from '~/models/dbCourse'; 5 | import type { CoursesInfo } from '~/models/coursesInfo'; 6 | import { Notify } from 'quasar'; 7 | 8 | 9 | interface ScheduleState { 10 | coursesInfo: CoursesInfo; 11 | isLoading: boolean; 12 | semesters: Record; 13 | } 14 | 15 | export const useSchedules = defineStore('schedules', { 16 | state: (): ScheduleState => { 17 | return { 18 | coursesInfo: {}, 19 | isLoading: false, 20 | semesters: {} 21 | } 22 | }, 23 | getters: { 24 | getSchedule: (state) => { 25 | return (semester: string, courseCode: string) => { 26 | return state.coursesInfo[semester]?.[courseCode] || null; 27 | } 28 | }, 29 | getSemesters: (state) => Object.entries(state.semesters) 30 | .map(([key, value]) => { return ({ label: value, value: key }) }) 31 | .sort((a, b) => b.label.localeCompare(a.label)), 32 | 33 | getParsedCourseInfo: (state) => { 34 | return (semester: string, courseCode: string, index: string) => { 35 | const parsedCourse = state.coursesInfo[semester]?.[courseCode]; 36 | console.debug("getParsedCourseInfo", semester, courseCode, index, parsedCourse) 37 | if (parsedCourse && 'lessons' in parsedCourse) { 38 | return parsedCourse.lessons[index] || null; 39 | } 40 | return null; 41 | } 42 | } 43 | }, 44 | actions: { 45 | async fetchCourseSchedule(semester: string, code: string) { 46 | const courseCode = code.toUpperCase(); 47 | if (courseCode === "CUSTOM") { 48 | console.warn("Custom course code provided, skipping fetch for schedule."); 49 | return null; 50 | } 51 | const courseInfo = this.getSchedule(semester, courseCode) 52 | if (courseInfo) { 53 | return courseInfo 54 | } else { 55 | // get from database 56 | const reqBody = JSON.stringify({ 57 | "semester": semester, 58 | "courseCode": courseCode 59 | }) 60 | try { 61 | const response = await fetch( 62 | useRuntimeConfig().public.getscheduleEndpoint, 63 | { 64 | method: 'POST', 65 | headers: { 'Content-Type': 'application/json' }, 66 | body: reqBody 67 | }) 68 | const data = (await response.json()) as DBCourse 69 | if (data) { 70 | const parsedCourseInfo = parseCourseInfoFromDB(data) 71 | if (!(semester in this.coursesInfo)) { 72 | this.coursesInfo[semester] = {} 73 | } 74 | this.coursesInfo[semester][courseCode] = parsedCourseInfo 75 | return parsedCourseInfo 76 | } else { 77 | codeDoesNotExist(courseCode, semester) 78 | } 79 | } catch (e) { 80 | console.error("Error fetching schedule:", e) 81 | errorFetchingSchedule(courseCode, semester) 82 | } 83 | } 84 | }, 85 | async fetchSemesters() { 86 | // get from database 87 | console.debug("Fetching semesters from database at endpoint:", useRuntimeConfig().public.getsemestersEndpoint) 88 | try { 89 | const response = await fetch( 90 | useRuntimeConfig().public.getsemestersEndpoint, 91 | { 92 | method: 'POST', 93 | headers: { 'Content-Type': 'application/json' }, 94 | }) 95 | const data = await response.json() 96 | if (data) { 97 | this.semesters = data.semesters 98 | } else { 99 | Notify.create({ message: `Failed to retrieve semesters`, color: 'negative' }) 100 | } 101 | } catch (e) { 102 | Notify.create({ message: `Failed to retrieve semesters`, color: 'negative' }) 103 | console.error("Error fetching semesters:", e) 104 | } 105 | }, 106 | 107 | async fetchCourseIndexes(semester: string, courseCode: string) { 108 | let parsedCourse = this.getSchedule(semester, courseCode); 109 | if (!parsedCourse) { 110 | await this.fetchCourseSchedule(semester, courseCode); 111 | parsedCourse = this.getSchedule(semester, courseCode); 112 | } 113 | if (parsedCourse && 'lessons' in parsedCourse) { 114 | return Array.from(Object.keys(parsedCourse.lessons)).sort((a, b) => a.localeCompare(b)) 115 | } 116 | return []; 117 | }, 118 | 119 | async fetchSearchableCourses(): Promise { 120 | // get from database 121 | try { 122 | const response = await fetch( 123 | useRuntimeConfig().public.getsearchablecoursesEndpoint, 124 | { 125 | method: 'POST', 126 | headers: { 'Content-Type': 'application/json' }, 127 | }) 128 | const data = await response.json() 129 | if (data) { 130 | return data.values 131 | } else { 132 | Notify.create({ message: `Failed to retrieve searchable courses`, color: 'negative' }) 133 | return [] 134 | } 135 | } catch (e) { 136 | Notify.create({ message: `Failed to retrieve searchable courses`, color: 'negative' }) 137 | console.error("Error fetching searchable courses:", e) 138 | return [] 139 | } 140 | }, 141 | async fetchCourseSpecificDetails(sem: string, code: string) { 142 | const semester = sem.toUpperCase(); 143 | const courseCode = code.toUpperCase(); 144 | const courseInfo = this.getSchedule(semester, courseCode) 145 | if (courseInfo && 'description' in courseInfo) { 146 | return courseInfo 147 | } else { 148 | // get from database 149 | const reqBody = JSON.stringify({ 150 | "semester": semester, 151 | "courseCode": courseCode 152 | }) 153 | try { 154 | const response = await fetch( 155 | useRuntimeConfig().public.getcoursecontentEndpoint, 156 | { 157 | method: 'POST', 158 | headers: { 'Content-Type': 'application/json' }, 159 | body: reqBody 160 | }) 161 | const data = await response.json() 162 | if (data) { 163 | return data 164 | } else { 165 | codeDoesNotExist(courseCode, semester) 166 | return null 167 | } 168 | } catch (e) { 169 | errorFetchingSchedule(courseCode, semester) 170 | console.error("Error fetching course details:", e) 171 | return null 172 | } 173 | } 174 | }, 175 | }, 176 | persist: true 177 | }) 178 | -------------------------------------------------------------------------------- /components/TimetableSection.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 155 | 156 | 157 | 165 | 166 | -------------------------------------------------------------------------------- /.firebase/hosting.Lm91dHB1dC9wdWJsaWM.cache: -------------------------------------------------------------------------------- 1 | robots.txt,1751014694745,8298e9a5d66adbe28804cc44caaa00cd5aa8ca87aee4ca98618362c85f261b1f 2 | index.html,1751014694691,4e7aae791cd7e84917c581dcff3f5ab9714054e247ef06d14912b08bdb56e1af 3 | 404.html,1751014694691,4e7aae791cd7e84917c581dcff3f5ab9714054e247ef06d14912b08bdb56e1af 4 | _nuxt/index.BkQpFzoE.css,1751014694739,6c83f57b71bd698e8309f501cd1d2322cb5494fab8d6e9c4c21679c0cf0b92ab 5 | 200.html,1751014694691,bf6b8f9e90c938b650938670425f2945dc6b5ec65997942f50dec55375485396 6 | _nuxt/error-500.CZqNkBuR.css,1751014694739,001c59c10d0596202e86822d4d82b5143eba8190b534d7b03e72c2ee6885053f 7 | _nuxt/error-404.4oxyXxx0.css,1751014694738,018b1141c0d7f69e1fb3be44c29f9cfdf0f483f06fb122ed8f2ac4bdff249bc1 8 | favicon.ico,1751014694746,058737b97b1440f067017452a1a268c4fb91a6271cceb5fb959f02f43f480da2 9 | _nuxt/DFnsfJeg.js,1751014694737,587bf17fe3e3c1d240c9af472cb778d9347006f99dc03a4c59c7bcea3634bb60 10 | _nuxt/C9HMud49.js,1751014694737,34346f5a0d867e93c240162efb3e8f02f3ffd796e8436c1cd470128b32904e4c 11 | _nuxt/BIHI7g3E.js,1751014694737,67ac747cbbfc6e0238f54e859491576ec33c9bc6e079ac83d5d4898d07efc85e 12 | _nuxt/builds/latest.json,1751014694725,90dfab61b833b44121ed4c7a49aba3ed270d22137b7b238f478c6be4afa7acab 13 | _nuxt/builds/meta/94754586-d732-4d83-8dd6-350c3c353f6d.json,1751014694722,3972f9f08835369428b992e8183b9a90ce1728353cc6e5e99e486d075cdc82b6 14 | _fonts/yh0ak2-oYBSnZxDNj3bqXlr-CViKiu-xNROahZJAseA-AuYYVBa_CRt6_Yghjas2vl8yClsb0ODxgdaUFCQB2pQ.woff2,1751014694732,cabc23aa923d51f183e363ad2c8bfb527850ef87c50124a5c2f10533fe903640 15 | _fonts/pFVHc1vdPmKUOVbC-oPNlu579jq898KZ6kByQAibRcM-0i6jZmGX6oA_9hMLJh9Ux6xU0MPg0P01kzq4iEy0_Uw.woff2,1751014694731,22b66512761a4d60881c6c4b79d50d22128416c330d422eb0498f4a63826b615 16 | _nuxt/BGReHQkV.js,1751014694737,a96ea0c2c5f620655198f3cd0eec1073afd9e0a2a9df53367d4dc9b3615f3793 17 | _fonts/hwdBUbneWNnoMlJaBBo8lCMnw9j_Ex7FrYBFoMmAb6Q-t1dMxNew7e29lOpd3eLXgMGzJ5dMYKfmQERH_B4OgKs.woff2,1751014694731,f1d2d05e8114f037565de1adf7d20cc74adfbd0a6c858ea05b5f51c11d402791 18 | _fonts/QdUA0WfUO-WPJcIRrsY44o3ueRi_9-EsQTU36CIkDoc-AzAZlL_5vF4uJiDmXlj6O5UzlcqwksUoR_0eOndcQQ4.woff2,1751014694731,4ae9976ce216c6cc045920fb0f564831510a7a9d45c7c4c0ecc45fda5569812f 19 | _fonts/JGpV_UcLP8V5a1WH2PeMgam0F8KzAc-NHgo22tNt244-FDLp11ylK_iczPs8ZN51C45za0ZszvJ5mu5s9U9QvNY.woff2,1751014694730,8384e921564188f0e8a6cae2a8b908721d1756ef2cab2836e12e402f3a9bdd81 20 | _fonts/6gxaoD7DQeGZTK54nUXSkdRWC0c-wCuX7MyFieq-1K8-96HsUCz9ebdng7uN_yX_DrA076imlE80DviIiH_lqR8.woff2,1751014694730,53c039e58ed861532e8a9c23cf42e35ef8a8da4c2a055c8542420c8b6e360051 21 | _fonts/jnTaqgqIXQJvRvZjoADo4u9rPrHhIg9x0Pkby2MksBA-Ba4q_Nus_9Hb1rrp3xHX0DxDdiWHVKhpGDu5EEx1URw.woff2,1751014694731,865b63b92aa1e3813b4161852b0e3c9babb3e1242c4f19b0639667e73e8725bf 22 | _fonts/gAUuOnKdT3UqEgWcFzAGXVXhLJCLnuRcUz1vhLLJsGw-VWn3L9q6EkfsdFa_mGN419qTqoy89afNLJ3eX2lIPp4.woff2,1751014694731,6f97469b12c8a4d53ab2cfc5908040153a1fe8234f45ff8f3565f7f1db396f04 23 | _fonts/auXPe3ZKiUcEpUCV6WUns9YnBBbTsWuKwtesi_8WLJQ-C_4tf6pRqrUSm1dprh377II3se4-_1QgBv9H1wGhkcY.woff2,1751014694731,a9696babdc05e130cc43ba6e0240dba315dec312fc65eda290a259e09d4a0281 24 | _fonts/WFnd9i24bHwwi9nCxDYKKobwMl6TIN-k117y2K8oGC4-RY2cOFhi0lLgs6aaIz43iGv5kjHIZY0IR_Xw225AuTw.woff2,1751014694731,3436f0d5b894e0e792177fdf7a488bf38874175aff374482374adc637849f390 25 | _fonts/XDL4h8cIroh1AI30355-6EdXC4VLRTJDQEFnBxyrruE-fnopBCOG0Ji_5mdTyd06fe_g-6sxW_x0zKjfeefDcoQ.woff2,1751014694731,e0d779991c50d76b28e4d1627caaedf6e10496da11a766274d9cd3e850ee9d7f 26 | _fonts/EMrLSPWTh0rHKutQg0CRbT-6CC-7ZCUXI8mUB5xsUzA-DpOpJ8APbxH_1RxpA0CKDAqsUmhKOs9fXzxv509PIxs.woff2,1751014694730,73fe4e45bdba4ccaeaf5ea86cd8ca34467d62633a1ffc1c8183bd77566e53b19 27 | _fonts/BytOw1WpauQKDI26Z7Zi_jjmRfrbDnjpWxdiylsqHmY-Bo9EMunreLSibMb_lVdHsK-0VhTT7mZ3oxLpbp8qbcY.woff2,1751014694730,1fb86282d5e9292a7d6d63e18802fb0962096299dd2b2e1e575ada671861a0ca 28 | _nuxt/avionbold.DCtJytFr.woff,1751014694737,6111c939edc45edb06b834b67e836abc71ee0af0732e1b2aff2a08e08cb5cf02 29 | _nuxt/NTUStars-logo.XXkPp4si.png,1751014694737,f6d4916ed8e7116f08c9d67c4636833d689ac3603cc647ed4912726487351f03 30 | _fonts/qFfsvWamVvQ82W1UHje2avdvsmuGh4qR7PWBbcMLc8s-YQXobM5hPve_GRRIpmJIeIvramCgP9Yx-ryBS60BCGg.woff2,1751014694731,2002590e619cd41ca9dbd43e9d7102fe7b3d684f19efa6d6d58e07e7e0446009 31 | _fonts/n872X1k0xVGHVuWVumTB90UU935PLWlUb-QbVkg5nVo-_j49AFrTjuNeG6SXKN6o_CVPdkrC_aLzdHIvQ_RDPSA.woff2,1751014694731,d4e718c5027367ef0b1f013566406e09b33e572250fb1059d2e1efc953a8e07c 32 | _fonts/Yr7HGIjsxw1ejDRZ1fDEK_uI9N9oVX--72BOJvhGns0-mkxSsgkhvXS_xTTSIO7_VKKXlx4_S7bNm_ngbNj92vY.woff2,1751014694731,44cdd54593722aa1372760f67f83abe8f6579fdcd048d413e934c33f352f98dc 33 | _fonts/H2FXkDRX4aGYE7pLEuvYkNHQSqxd4MIt2393pduYjiU-B3Jzh0T1XEgB7qIQpfOncU27YRrQWt5_bDEwNQxl8aA.woff2,1751014694730,ab50742da93313a5b2db64f2eeea72443b7b143055ed1bf4d32b9bc2e81b111c 34 | _fonts/DjqLtQmWy3Sy26TlSZoazWKlNJfaXSyR1J7pZxNm01w-jpVZYVVXNrIEapUIr4aYMo2dnkNv0CN4IsI1EOp_TcA.woff2,1751014694730,3944da7681fb72f7f2e4041e5e3ca768c5148b2e750300efe968b205ba77c5c3 35 | _nuxt/NTUStars-icon.nc6rD8GN.png,1751014694737,91359d57dcd9b8cd3b0be0f05e8e62d75e7c947f969e8a147ee8e77e97ae0d41 36 | _fonts/w6IyUMb7I-Yy2vbAlgUry4sKIBOdeyt3qoKc4MDVQ-c-gYSQlxs2JcjVQuVG7odfzGqJAn45FJrHqp0Fgf_Xn8s.woff,1751014694732,d10da8f7d09dbd2772f3b88c71c8b4cf14d3b74e393de060e398be938b2494ba 37 | _fonts/_IDQ8Qq9TvL4YR5UDgI4kyxtSzWtqaeiV_-tku-6-hA-xitxed1M0PC8ZI2zh1U7DNW2OR8BdTdZ4DypeXb8KrU.woff,1751014694731,372b001ee5f9800f93ed818c125866de3fe8931341336a1d5c0fc42be8287603 38 | _fonts/P0RDlbyXDZNzN9UqyrROQeFvVRi0giLeDue4p9aI8ro-N4qaai1q45BBoZQdaSUUSvaLCKsZiwmDk5r0XToBMs4.woff,1751014694731,2d7c9a29f2907d68537800f319ea5978bc0b393df26fee806b1ce408aa7e1006 39 | _fonts/OKG6BF0G4wELgEZudHYuNKkKeENfIC8EAxIL0rux22Y-54YWNKw9mDQuACPOQS7Q7KZ9UViafkpByDS2__rP5V8.woff,1751014694730,042e2e2624fc41a94460af6f9f35f27095fb675a5db1a1dd55ab214c20e7ecbb 40 | _fonts/IjRvlL3PwtR7hAe869ramX8nMwF-PXKLv1KnPqWv-10-ObUgLSEmOCDq4Sx4f2QBZeJ9Y6IF6BjjISa8fm01QmY.woff,1751014694731,bc52459695ecc087b20cc97bea5b85b8cb34fc9c88cde9b2771721609646e2e1 41 | _fonts/2ojCvmJDLx2NV2-RBg2hmdCshSrYL_c2WUNtVMJRvnE-VnWj8iGbtXx_3dgUo48cisINGzm5SCjtPmxk8edBiRA.woff,1751014694730,de35f5876b20061e75d1107ff06b9201b71c0d67c3b04caf69bb10ea2af18450 42 | _nuxt/entry.Fxh9pneb.css,1751014694739,ba15f2ebce658b842b98ae30b3a7234d424d8d7643c2e4ce1c349023619cbea9 43 | _nuxt/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.D-x-0Q06.woff2,1751014694739,d14637dd65fcd466e7e66d137606373ab851fb58fdf44057df0270e56998b7a1 44 | _fonts/2WyNPcZmaATeK1mN5QI4WtqOBLy_qY2uDPYvP6z6nbw-_29OTjIK1jCh4XCkDFt8amXSfI_17ontPzfNV0N6rOk.woff,1751014694729,ba49a78b12e6cdd76f27a6adeba944523eebba1c967a5f6d264b04b38ed392a8 45 | _fonts/2ggHYVDCbvazc1p0tuDugeS6Wvthvyw03JcDFsTBGjE-BimFcHkRLzR0Yd7npTqIcFfe1_VuLGJ8klzHfwive14.woff,1751014694730,cfdd3b3b9a988aa78cc55d0767b0a484052eab5c4e08ceb912074295e49b0ea8 46 | _nuxt/B8loiMto.js,1751014694737,576ff117b76348f69cbd1388e289e6cd874279a7cda1baaf8e02858676f2ef60 47 | _nuxt/flUhRq6tzZclQEJ-Vdg-IuiaDsNa.Dr0goTwe.woff,1751014694738,6bf6b1e7c3111391c4042a5c5081944b042b9f0546346b7df1faa8ebc88f3063 48 | _nuxt/patreon_logo.DrW4QZaa.png,1751014694740,fc0e079b4ba6fa73257362f911d03046bb34a2cd8c7218c0b1ccf13571f64ef3 49 | _nuxt/not-found.CB2zZOqk.gif,1751014694741,d83d4a5c9b9f432b9fbaf9af05edc54c547996b312254a58d6aad33f4b0d1b2c 50 | _nuxt/l-Gf6g4E.js,1751014694741,1a7bef3dab1d61502084085237cd8f37fbec9829dfe6c5f1ab085c2db1759572 51 | _nuxt/demo2.DYEOa_n6.gif,1751014694740,aa1be3e5f5959a7ad8d22929194842f6705e2d79d8c8252f773966d6c1fb923c 52 | _nuxt/demo1.B1wSZNHo.gif,1751014694741,983cb452acf6e18b964db87b0ab59f0ac1e114e187514af43fed8226450c124b 53 | -------------------------------------------------------------------------------- /stores/timetable.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-dynamic-delete */ 2 | import { defineStore } from 'pinia' 3 | import type { EventImpl } from '@fullcalendar/core/internal'; 4 | import type FullCalendar from '@fullcalendar/vue3' 5 | import type { DateSelectArg } from '@fullcalendar/core/index.js'; 6 | import { Notify } from 'quasar'; 7 | 8 | import { useSchedules } from './schedules' 9 | import { createEventId } from '../composables/event-utils'; 10 | import type { ParsedLesson } from '~/models/parsedLesson'; 11 | import { COLORS, DEFAULT_PLAN_NUMBER } from '~/constants/timetable'; 12 | import type { CourseDisplay } from '~/models/courseDisplay'; 13 | 14 | interface TimetableState { 15 | calenderApi: typeof FullCalendar | null; 16 | coursesAdded: CoursesAdded; 17 | showingPreview: ShowingPreview; 18 | previewCourseCode: string | null; 19 | showingPreviewIndexCount: number; 20 | totalIndexCount: number; 21 | previewIndexes: { [index: string]: boolean }; 22 | timeTable: Timetable; 23 | colors: { [plan: number]: string[] }; 24 | isLoading: boolean; 25 | semester: string | null; 26 | currentPlan: number; 27 | plans: { [plan: number]: string }; // Plan number: Plan name mapping 28 | } 29 | 30 | interface CoursesAdded { 31 | [plan: number]: { 32 | [courseCode: string]: CourseDisplay 33 | } 34 | } 35 | 36 | interface ShowingPreview { 37 | [plan: number]: CourseDisplay[] 38 | } 39 | 40 | interface Timetable { 41 | [plan: number]: { 42 | [courseCode: string]: { 43 | lectures: CourseDisplay[]; 44 | lessons: CourseDisplay[]; 45 | }; 46 | } & { 47 | custom?: { 48 | events: CourseDisplay[]; 49 | }; 50 | }; 51 | } 52 | 53 | export const useTimetableStore = defineStore('timetable', { 54 | state: (): TimetableState => { 55 | return { 56 | // Full calendar api 57 | calenderApi: null, 58 | // Courses that has been added: Used by right sidebar 59 | coursesAdded: {}, 60 | // Preview that are currently showing 61 | showingPreview: {}, 62 | previewCourseCode: null, 63 | // Total number of Indexes showing as preview for a selected course code 64 | showingPreviewIndexCount: 1, 65 | // Total number of availble indexes for the course selected for preview 66 | totalIndexCount: 0, 67 | // Indexes for preview. Can be set to true(showing on the timetable) or false(hidden) 68 | previewIndexes: {}, 69 | // All Events objects that are shown on the timetable 70 | timeTable: {}, 71 | // All possible event colors left 72 | colors: { 0: [...COLORS[0]] }, 73 | isLoading: false, 74 | // The overall selected semester 75 | semester: null, // Default semester, can be changed later 76 | // The plan number of the timetable 77 | currentPlan: 0, 78 | // Plans that are created 79 | plans: { [DEFAULT_PLAN_NUMBER]: "Default Plan" }, 80 | } 81 | }, 82 | 83 | getters: { 84 | getTimeTable: (state) => { 85 | return state.timeTable[state.currentPlan] 86 | }, 87 | getCourseCodeShowingPreview: (state) => { 88 | if (state.currentPlan in state.showingPreview) { 89 | return state.showingPreview[state.currentPlan].length > 0 90 | ? state.showingPreview[state.currentPlan][0].courseCode 91 | : null 92 | } 93 | }, 94 | getCoursesAdded: (state) => { 95 | return { ...state.coursesAdded[state.currentPlan] } 96 | }, 97 | getSemester: (state) => { 98 | return state.semester 99 | }, 100 | getSemesterProperName: (state) => { 101 | if (state.semester) { 102 | if (state.semester.at(-1) == "S") { 103 | return state.semester.replace(";S", " Special Sem ") 104 | } else { 105 | return state.semester.replace(";", " Semester ") 106 | } 107 | } else { 108 | return "Select Semester" 109 | } 110 | }, 111 | getTotalAus: (state) => { 112 | if (state.currentPlan in state.coursesAdded) { 113 | return Object.keys(state.coursesAdded[state.currentPlan]) 114 | .reduce((prev, key) => 115 | prev + parseInt(state.coursesAdded[state.currentPlan][key].au) 116 | , 0) 117 | } else { 118 | return 0 119 | } 120 | }, 121 | getShowingIndex: (state) => { 122 | return (courseCode: string) => courseCode ? state.coursesAdded[state.currentPlan][courseCode].index : null 123 | }, 124 | getPreviewCourseCode: (state) => { 125 | // if(state.semester in state.showingPreview && state.showingPreview[state.semester].length > 0){ 126 | // return state.showingPreview[state.semester][0].courseCode 127 | // } 128 | return state.previewCourseCode 129 | }, 130 | getPreviewIndexes: (state) => { 131 | return Object.entries(state.previewIndexes) 132 | }, 133 | 134 | getPlans: (state) => { 135 | return Object.entries(state.plans).map(([planNumber, planName]) => ({ 136 | planNumber: parseInt(planNumber), 137 | planName: planName 138 | })) 139 | }, 140 | getCurrentPlan: (state) => { 141 | return { 142 | planNumber: state.currentPlan, 143 | planName: state.plans[state.currentPlan] || "Unnamed Plan" 144 | } 145 | }, 146 | }, 147 | 148 | actions: { 149 | setTimeTable() { 150 | if (!this.timeTable || !this.timeTable[this.currentPlan] || Object.keys(this.timeTable).length === 0) return; 151 | 152 | for (const courseCodeObj of Object.values(this.getTimeTable)) { 153 | if (!courseCodeObj) continue 154 | for (const timeTableItems of Object.values(courseCodeObj)) { 155 | if (timeTableItems) { 156 | for (const timeTableItem of timeTableItems) { 157 | if (!timeTableItem) continue 158 | this.calenderApi?.getApi().view.calendar.addEvent(timeTableItem) 159 | } 160 | } 161 | } 162 | } 163 | }, 164 | setCalendarApi(calenderApi: typeof FullCalendar) { 165 | this.calenderApi = calenderApi 166 | }, 167 | async addCourse(code: string) { 168 | // Validate course code 169 | if (!validateCourseCode(code)) { 170 | Notify.create({ message: "Invalid course code!", color: "negative" }) 171 | return 172 | } 173 | if (this.calenderApi == null) { 174 | Notify.create({ message: "Calendar not ready!", color: "negative" }) 175 | return 176 | } 177 | const courseCode = code.toUpperCase() 178 | const currentPlan = this.currentPlan 179 | 180 | const calendar = this.calenderApi?.getApi().view.calendar 181 | // check if already in timetable 182 | if (this.coursesAdded?.[currentPlan]?.[courseCode]) { 183 | Notify.create({ message: "Course already in timetable!", color: "negative" }) 184 | return 185 | } 186 | // instantiate if needed 187 | // add initial value to coursesAdded (this is so that the right sidebar can show the course) 188 | (this.coursesAdded[currentPlan] ??= {})[courseCode] = { 189 | id: "", 190 | groupId: null, 191 | editable: false, 192 | classNames: [], 193 | isPreview: false, 194 | isLoading: true, 195 | courseName: "", 196 | index: "", 197 | backgroundColor: "", 198 | au: "", 199 | courseCode: courseCode, 200 | start: "", 201 | end: "", 202 | textColor: "white", 203 | borderColor: "", 204 | type: "", 205 | group: "", 206 | venue: "", 207 | date: "", 208 | weeks: "", 209 | frequency: "", 210 | }; 211 | // retrieve the course scheudle 212 | const scheduleStore = useSchedules() 213 | const courseInfo = this.semester ? await scheduleStore.fetchCourseSchedule(this.semester, courseCode) : null 214 | if (!courseInfo) { 215 | // course does not exist / there is no courseInfo 216 | delete this.coursesAdded[currentPlan][courseCode] 217 | return null 218 | } 219 | 220 | // get a color for this course 221 | const backgroundColor = this.getRandomColor() || "#E65100"; 222 | 223 | // store ids in state so its easier to delete later 224 | // instantiate 225 | (this.timeTable[this.currentPlan] ??= {})[courseCode] = { 226 | "lectures": [], 227 | "lessons": [], 228 | }; 229 | 230 | // add the lecture slots for this course 231 | for (const classInfo of courseInfo.lectures) { 232 | const updateClassInfo = addTimetableProp(classInfo, false, backgroundColor) 233 | // updateClassInfo.borderColor = "white" 234 | console.debug("Adding lecture", updateClassInfo) 235 | 236 | calendar.addEvent(updateClassInfo) 237 | this.timeTable[currentPlan][courseCode].lectures.push(updateClassInfo) 238 | } 239 | 240 | // add the first non-lecture slot for this course 241 | let addedIndex = "" 242 | for (const [index, indexSchedule] of Object.entries(courseInfo.lessons)) { 243 | for (const classInfo of indexSchedule) { 244 | const updateClassInfo = addTimetableProp(classInfo, false, backgroundColor) 245 | console.debug("Adding lesson", updateClassInfo) 246 | calendar.addEvent(updateClassInfo) 247 | this.timeTable[currentPlan][courseCode].lessons.push(updateClassInfo) 248 | } 249 | addedIndex = index 250 | break 251 | } 252 | // update courses added state 253 | this.coursesAdded[currentPlan][courseCode] = { 254 | ...this.coursesAdded[currentPlan][courseCode], 255 | isLoading: false, 256 | courseName: courseInfo.courseName, 257 | index: addedIndex, 258 | backgroundColor: backgroundColor, 259 | courseCode: courseCode, 260 | au: courseInfo.au, 261 | } 262 | }, 263 | addCustomEvent(selectInfo: DateSelectArg, name: string) { 264 | const currentPlan = this.currentPlan 265 | const calendar = this.calenderApi?.getApi().view.calendar 266 | // Add to custom events if needed 267 | let color = "" 268 | if (!(currentPlan in this.timeTable)) { 269 | this.timeTable[currentPlan] = {} 270 | } 271 | if (!(this.timeTable?.[currentPlan]?.["custom"]?.["events"])) { 272 | (this.timeTable[currentPlan] ??= {})["custom"] = { 273 | "events": [] 274 | }; 275 | color = this.getRandomColor() 276 | } 277 | else { 278 | color = this.coursesAdded[currentPlan].custom.backgroundColor 279 | } 280 | // Add event to calendar 281 | const classInfo: ParsedLesson = { 282 | id: createEventId() + name, 283 | groupId: createEventId() + name, 284 | courseName: name, 285 | start: selectInfo.startStr, 286 | end: selectInfo.endStr, 287 | courseCode: "custom", 288 | index: "", 289 | au: "0", 290 | type: "", 291 | group: "", 292 | venue: "", 293 | date: "", 294 | weeks: "", 295 | frequency: "", 296 | } 297 | const event = addTimetableProp(classInfo, false, color) 298 | event.isCustom = true // mark as custom event 299 | event.editable = true // allow editing 300 | 301 | // save event to timetable & coursesAdded 302 | this.timeTable[currentPlan].custom.events.push(event); 303 | (this.coursesAdded[currentPlan] ??= {})["custom"] = event; 304 | calendar.addEvent(event) 305 | 306 | }, 307 | updateCustomEvent(event: EventImpl) { 308 | this.timeTable[this.currentPlan].custom?.events.forEach((e) => { 309 | if (e.id == event.id) { 310 | e.courseName = event.extendedProps.courseName 311 | e.start = event.startStr 312 | e.end = event.endStr 313 | } 314 | }) 315 | }, 316 | removeCourse(courseCode: string) { 317 | const currentPlan = this.currentPlan 318 | const calendar = this.calenderApi?.getApi().view.calendar 319 | if (!(currentPlan in this.timeTable) || !(courseCode in this.timeTable[currentPlan])) { 320 | // Notify.create("Unable to remove course") 321 | useToast().add({ title: "Unable to remove course", description: "Please check your timetable.", color: "error", }); 322 | return 323 | } 324 | // remove from calendar 325 | if (courseCode != "custom") { 326 | if ('lectures' in this.timeTable[currentPlan][courseCode]) { 327 | for (const event of this.timeTable[currentPlan][courseCode]['lectures']) { 328 | calendar.getEventById(event.id).remove() 329 | } 330 | this.timeTable[currentPlan][courseCode]['lectures'] = [] 331 | } 332 | if ('lessons' in this.timeTable[currentPlan][courseCode]) { 333 | for (const event of this.timeTable[currentPlan][courseCode]['lessons']) { 334 | calendar.getEventById(event.id).remove() 335 | } 336 | this.timeTable[currentPlan][courseCode]['lessons'] = [] 337 | } 338 | if (currentPlan in this.showingPreview && this.showingPreview[currentPlan].length > 0) { 339 | // remove from showingPreview if showingPreview is the deleted coursecode 340 | if (courseCode == this.showingPreview[currentPlan][0].courseCode) { 341 | this.resetPreview() 342 | // reset all indexes 343 | this.previewIndexes = {} 344 | } 345 | } 346 | } else { 347 | if (this.timeTable[currentPlan].custom?.events) { 348 | for (const event of this.timeTable[currentPlan].custom.events) { 349 | calendar.getEventById(event.id)?.remove() 350 | } 351 | } 352 | delete this.timeTable[currentPlan][courseCode]; 353 | } 354 | 355 | const colorUsed = this.coursesAdded[currentPlan][courseCode].backgroundColor 356 | this.returnColor(colorUsed) 357 | delete this.coursesAdded[currentPlan][courseCode] 358 | }, 359 | 360 | async setPreview(courseCode: string) { 361 | if (!this.semester || !courseCode) { 362 | useToast().add({ title: "Please select a semester and course code", description: "Cannot set preview.", color: "error", }); 363 | return 364 | } 365 | const scheduleStore = useSchedules() 366 | const currentPlan = this.currentPlan 367 | const calendar = this.calenderApi?.getApi().view.calendar 368 | const courseInfo = await scheduleStore.fetchCourseSchedule(this.semester, courseCode) 369 | const showing = this.coursesAdded[currentPlan][courseCode] 370 | const showingPreview = [] 371 | 372 | this.resetPreview() 373 | 374 | if (courseInfo) { 375 | // track number of added preview events 376 | this.previewCourseCode = courseCode 377 | this.totalIndexCount = 0 378 | this.previewIndexes[showing.index] = true; 379 | for (const [index, indexSchedule] of Object.entries(courseInfo.lessons)) { 380 | this.totalIndexCount += 1 381 | if (index == showing.index) { 382 | continue 383 | } 384 | for (const classInfo of indexSchedule) { 385 | const previewEvent = addTimetableProp(classInfo, true, showing.backgroundColor) 386 | showingPreview.push(previewEvent) 387 | calendar.addEvent(previewEvent) 388 | } 389 | this.showingPreviewIndexCount += 1 390 | this.previewIndexes[index] = true 391 | } 392 | useToast().add({ title: `Showing ${this.showingPreviewIndexCount} / ${this.totalIndexCount} available indexes`, color: "primary", }); 393 | } 394 | if (!showingPreview.length) { 395 | // Notify.create({ message: "There are no other indexes for this module.", type: "negative" }) 396 | useToast().add({ title: "There are no other indexes for this module.", description: "Please check the course code.", color: "error", }); 397 | } 398 | this.showingPreview[currentPlan] = showingPreview 399 | }, 400 | 401 | // Temp previews are maintain with the help of paginations 402 | async addTempPreviews(indexesToAdd: string[]) { 403 | const courseCode = this.getPreviewCourseCode 404 | const scheduleStore = useSchedules() 405 | const currentPlan = this.currentPlan 406 | const calendar = this.calenderApi?.getApi().view.calendar 407 | 408 | if (!this.semester || !this.previewCourseCode) { 409 | useToast().add({ title: "Please select a semester and course code", description: "Cannot add preview.", color: "error", }); 410 | return 411 | } 412 | if (!courseCode) { 413 | useToast().add({ title: "No course code selected", description: "Cannot add preview.", color: "error", }); 414 | return 415 | } 416 | const courseInfo = await scheduleStore.fetchCourseSchedule(this.semester, courseCode) 417 | const showing = this.coursesAdded[currentPlan][courseCode] 418 | 419 | if (courseInfo) { 420 | for (const indexToAdd of indexesToAdd) { 421 | for (const classInfo of courseInfo.lessons[indexToAdd]) { 422 | const previewEvent = addTimetableProp(classInfo, true, showing.backgroundColor) 423 | this.showingPreview[currentPlan].push(previewEvent) 424 | calendar.addEvent(previewEvent) 425 | } 426 | this.previewIndexes[indexToAdd] = true 427 | this.showingPreviewIndexCount += 1 428 | } 429 | } 430 | }, 431 | 432 | // remove previews excluding saved ones from the timetable (used during the change in pages in preview list) 433 | removeTempPreviews(indexesToRemove: string[]) { 434 | const currentPlan = this.currentPlan 435 | if (!(currentPlan in this.showingPreview)) return 436 | const calendar = this.calenderApi?.getApi().view.calendar 437 | const indexesToRemoveSet = new Set(indexesToRemove) 438 | for (let i = this.showingPreview[currentPlan].length - 1; i >= 0; i--) { 439 | const event = this.showingPreview[currentPlan][i] 440 | // if the event is saved or showing 441 | if (!indexesToRemoveSet.has(event.index)) continue 442 | calendar.getEventById(event.id)?.remove() 443 | this.showingPreview[currentPlan].splice(i, 1) 444 | this.showingPreviewIndexCount -= 1 445 | this.previewIndexes[event.index] = false 446 | } 447 | }, 448 | 449 | // Saved preview are previews that are selected by users 450 | async addToSavePreview(indexToAdd: string, addToCalendar = false) { 451 | if (addToCalendar) this.addTempPreviews([indexToAdd]); 452 | if (!this.previewCourseCode) { 453 | useToast().add({ title: "No course code selected", description: "Cannot save preview.", color: "error", }); 454 | return 455 | } 456 | }, 457 | async removeFromSavePreview(indexToAdd: string, removeFromCalendar = false) { 458 | if (removeFromCalendar) this.removeTempPreviews([indexToAdd]); 459 | if (!this.previewCourseCode) { 460 | useToast().add({ title: "No course code selected", description: "Cannot remove saved preview.", color: "error", }); 461 | return 462 | } 463 | }, 464 | // Remove all previews included saved ones 465 | resetPreview() { 466 | const currentPlan = this.currentPlan 467 | if (!(currentPlan in this.showingPreview)) return 468 | const calendar = this.calenderApi?.getApi().view.calendar 469 | for (const event of this.showingPreview[currentPlan]) { 470 | calendar.getEventById(event.id)?.remove() 471 | } 472 | this.showingPreview[currentPlan] = [] 473 | this.previewIndexes = {} 474 | this.showingPreviewIndexCount = 1 475 | this.totalIndexCount = 0 476 | this.previewCourseCode = null 477 | }, 478 | // swap two given indexes 479 | async swapIndex(courseCode: string, newIndex: string) { 480 | if (!this.semester || !courseCode || !newIndex) { 481 | useToast().add({ title: "Please select a semester and course code", description: "Cannot swap index.", color: "error", }); 482 | return 483 | } 484 | const currentPlan = this.currentPlan 485 | const calendar = this.calenderApi?.getApi().view.calendar 486 | // get event object of newIndex 487 | let newLessons: CourseDisplay[] = [] 488 | console.debug("Swapping index", courseCode, newIndex, "in plan", currentPlan) 489 | // get from schedule store and create the display object 490 | const scheduleStore = useSchedules() 491 | // fetch incase the course schedule is not fetched yet (happens when the page is refreshed) 492 | await scheduleStore.fetchCourseSchedule(this.semester, courseCode) 493 | const parsedLessons = scheduleStore.getParsedCourseInfo(this.semester, courseCode, newIndex) 494 | if (!parsedLessons) { 495 | Notify.create({ message: "Unable to swap index", color: "negative" }) 496 | return 497 | } 498 | newLessons = parsedLessons.map(e => addTimetableProp(e, false, this.coursesAdded[currentPlan][courseCode].backgroundColor)) 499 | // remove showingPreview 500 | this.resetPreview() 501 | 502 | // reset showingPreview properties 503 | newLessons.forEach(e => { 504 | e.isPreview = false 505 | e.classNames = ['lesson-body'] 506 | e.groupId = null 507 | }) 508 | // remove oldIndex 509 | for (const event of this.timeTable[currentPlan][courseCode]['lessons']) { 510 | calendar.getEventById(event.id).remove() 511 | } 512 | // add back to calendar 513 | const newEvents = [] 514 | for (const event of newLessons) { 515 | calendar.addEvent(event) 516 | newEvents.push(event) 517 | } 518 | this.timeTable[currentPlan][courseCode]['lessons'] = newEvents 519 | // update added course index 520 | this.coursesAdded[currentPlan][courseCode].index = newIndex 521 | }, 522 | // set the value of semester 523 | setSemester(semesterKey: string) { 524 | this.semester = semesterKey 525 | }, 526 | // reset the calendar 527 | reset() { 528 | const calenderApi = this.calenderApi 529 | this.removeAllEvents() 530 | this.$reset() 531 | if (calenderApi) { 532 | this.setCalendarApi(calenderApi) 533 | } 534 | }, 535 | removeAllEvents() { 536 | const events = this.calenderApi?.getApi().view.calendar.getEvents() 537 | for (const event of events) { 538 | event.remove() 539 | } 540 | }, 541 | // resize the calendar 542 | resize() { 543 | if (!this.calenderApi) return 544 | this.calenderApi.getApi().view.calendar.updateSize() 545 | }, 546 | // Return back a color to the state 547 | returnColor(color: string) { 548 | this.colors[this.currentPlan].push(color) 549 | }, 550 | // Get a random color from the state (placed here so it wont be called during store initiation?) 551 | getRandomColor(): string { 552 | const colorOptions = this.colors[this.currentPlan] 553 | const randomIndex = Math.floor(Math.random() * colorOptions.length) 554 | const color = colorOptions.at(randomIndex) 555 | colorOptions.splice(randomIndex, 1) 556 | return color || "#E65100" // fallback color 557 | }, 558 | createNewPlan(planName: string) { 559 | let newPlanNumber = 0; 560 | for (let i = 0; i < 10; i++) { 561 | // Generate a random plan number 562 | newPlanNumber = Math.floor(Math.random() * 10000000) 563 | // Check if the plan number already exists 564 | if (newPlanNumber in this.plans) { 565 | continue 566 | } 567 | } 568 | if (newPlanNumber in this.plans) { 569 | useToast().add({ title: `Wow you are crazy lucky and you caught me. This will only occur with a ~10^-68% chance. Should have bought the lottery instead :(`, description: "Please try again.", color: "error", }); 570 | return 571 | } 572 | // Initialise the new plan 573 | this.plans[newPlanNumber] = planName 574 | this.timeTable[newPlanNumber] = {} 575 | this.coursesAdded[newPlanNumber] = {} 576 | this.showingPreview[newPlanNumber] = [] 577 | this.colors[newPlanNumber] = [...COLORS[0]] 578 | // Switch to the new plan 579 | this.switchPlans(newPlanNumber) 580 | useToast().add({ title: `New plan created: ${planName}`, description: "You can now add courses to this plan.", color: "primary", }); 581 | }, 582 | switchPlans(planNumber: number) { 583 | if (!(planNumber in this.plans)) { 584 | useToast().add({ title: "Plan does not exist", description: "Try again.", color: "error", }); 585 | return 586 | } 587 | this.currentPlan = planNumber 588 | this.resetPreview() 589 | console.debug("Removing all events from calendar") 590 | this.removeAllEvents() 591 | console.debug("Setting timetable") 592 | this.setTimeTable() 593 | }, 594 | deletePlan(planNumber: number) { 595 | if (!(planNumber in this.plans)) { 596 | return 597 | } 598 | if (planNumber === this.currentPlan) { 599 | useToast().add({ title: "Cannot delete current plan", description: "Please switch to another plan first.", color: "error", }); 600 | return 601 | } 602 | // Remove the plan from all states 603 | const planName = this.plans[planNumber] 604 | 605 | delete this.plans[planNumber] 606 | delete this.timeTable[planNumber] 607 | delete this.coursesAdded[planNumber] 608 | delete this.showingPreview[planNumber] 609 | delete this.colors[planNumber] 610 | if (planNumber in this.previewIndexes) { 611 | this.resetPreview() 612 | this.switchPlans(DEFAULT_PLAN_NUMBER) 613 | } 614 | useToast().add({ title: `Plan ${planName} deleted`, description: "", color: "primary", }); 615 | } 616 | }, 617 | persist: { 618 | pick: [ 619 | 'coursesAdded', 620 | 'timeTable', 621 | 'plans', 622 | 'currentPlan', 623 | 'semester', 624 | 'savedPreviewIndexes', 625 | 'colors', 626 | ], 627 | key: 'ntu-stars', 628 | storage: piniaPluginPersistedstate.localStorage(), 629 | }, 630 | }) 631 | 632 | function addTimetableProp(classInfo: ParsedLesson, isPreview: boolean, color: string): CourseDisplay { 633 | return ({ 634 | ...classInfo, 635 | groupId: isPreview ? classInfo.courseCode + classInfo.index : null, 636 | editable: false, 637 | classNames: ['lesson-body'].concat(isPreview ? ['lighten'] : []), 638 | isPreview: isPreview, 639 | backgroundColor: color, 640 | borderColor: '', 641 | textColor: 'white', 642 | }) 643 | } --------------------------------------------------------------------------------