├── 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 |
2 |
3 |
4 |
7 |
8 |
We couldn't find the page you're looking for.
9 |
10 |
11 |
12 |
13 |
14 |
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 |
2 |
8 |
9 |
10 |
15 | {{ col.value }}
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/components/rightdrawer/SemCourseSelector.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{ title }}
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/components/layout/DetailsSearchBar.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
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 |
2 |
12 |
13 |
14 |
15 | No results
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/pages/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
Welcome to NTU Stars!
13 |
14 | Please select a semester to get started with planning your timetable
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/components/layout/HeaderSection.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | NTU Stars
9 |
10 |
11 |
12 |
13 |
16 |
17 |
18 |
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 |
2 |
3 |
10 |
11 |
12 |
13 |
14 |
19 | {{ col.label }}
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
34 | {{ col.value }}
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
--------------------------------------------------------------------------------
/components/rightdrawer/SelectedList.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Courses
5 |
6 |
7 |
8 |
12 | timetableStore.swapIndex(course.courseCode, index)"
15 | @handle-remove="() => handleRemove(course)" />
16 |
17 |
18 |
19 | Total AU:
20 |
21 | {{ timetableStore.getTotalAus}}
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | No Course Selected
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/components/changelogs/ChangeLogItem.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{title}}
6 | NEW
7 | {{formatElapsedTime(dateTime)}}
8 |
9 | {{description}}
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/components/rightdrawer/SelectPlanBar.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{plan.planName}}
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | New Plan
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
64 |
--------------------------------------------------------------------------------
/app.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 |
10 |
11 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
68 |
69 |
--------------------------------------------------------------------------------
/components/layout/CourseViewSkeleton.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | |
21 |
22 | |
23 |
24 |
25 | |
26 |
27 |
28 | |
29 |
30 |
31 | |
32 |
33 |
34 | |
35 |
36 |
37 | |
38 |
39 |
40 |
41 |
42 |
43 | |
44 |
45 | |
46 |
47 |
48 | |
49 |
50 |
51 | |
52 |
53 |
54 | |
55 |
56 |
57 | |
58 |
59 |
60 | |
61 |
62 |
63 |
64 |
65 |
--------------------------------------------------------------------------------
/components/EventFormDialog.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Create Event
6 |
7 |
8 |
11 |
18 |
29 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/components/layout/FooterSection.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
7 |
8 |
14 |
15 |
16 |
17 | Support This Project on Patreon!
18 |
19 |
20 |
I'm Back — And NTU Stars is Getting Better Than Ever!
21 |
22 |
23 | Hi everyone 👋
24 |
25 |
26 | First of all, I want to sincerely apologize for the lack of updates on NTU Stars over the past few years.
27 | Like many of you, I was juggling a full academic workload while also focusing on landing an internship and securing a job —
28 | and unfortunately, that meant I couldn't give NTU Stars the attention it deserved.
29 |
30 |
31 | But now, I'm happy to share that I'm finally in a more stable place, and I'm ready to recommit to this project that so many
32 | of you have found useful. 🙌
33 |
34 |
35 | 🗳️ Want to Help Decide What Comes Next?
36 |
37 | Got ideas for what NTU Stars should have next? As a member, you'll get to vote in polls and help shape the future of the platform —
38 | from new features to design improvements, your input matters.
39 |
40 |
41 | Learn more and become a patron today to be part of building the next chapter of NTU Stars!
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
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 |
2 |
3 |
4 | Big Thank You To Our Supporters
5 |
6 |
7 |
12 |
16 |
17 | {{supporter.name.charAt(0)}}
18 |
19 | {{supporter.name}}
20 |
21 |
22 |
23 |
26 | If you would like to support this platform, please consider donating via the link below.
27 |
28 |
29 |
30 |
31 |
69 |
70 |
--------------------------------------------------------------------------------
/components/rightdrawer/SearchBar.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | onSubmit(val)"
22 | @filter="(val, done, abort) => handleSearchChange(val, done, abort)"
23 | @update:model-value="(val) => {
24 | if (val) {
25 | onSubmit()
26 | }
27 | }"
28 | >
29 |
30 |
31 |
32 | No results
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/components/rightdrawer/SelectedListItem.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{props.course.courseCode}}
7 |
8 |
9 |
10 |
11 | {{props.course.courseName}} | AU: {{course.au}}
12 |
13 |
14 | Index:
15 |
16 |
17 |
18 |
19 | {{index}}
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
35 |
36 |
37 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
105 |
106 |
--------------------------------------------------------------------------------
/components/changelogs/ChangeLogList.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | CHANGES
7 |
8 |
9 |
10 |
11 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/pages/courses/[year]-[sem]-[code].vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | {{ course.courseCode }} {{ course.courseName }}
11 |
12 |
13 | AU: {{ course.au }}
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | Pre-requisites:
22 |
23 |
24 |
25 |
26 |
27 |
28 | Mutually exclusive with:
29 |
30 |
31 |
32 |
33 |
34 |
35 | Not available to Programme:
36 |
37 |
38 |
39 |
40 |
41 |
42 | Not available to all Programme with:
43 |
44 |
45 |
46 |
47 |
48 | {{ course.remarks }}
49 |
50 |
51 |
52 |
{{course.description}}
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
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 |
2 |
11 |
19 | Welcome to NTU Stars!
20 | This website hopes to alleviate the pain of planning your timetable.
21 |
22 |
23 | All data shown here comes directly from
24 | NTU Class Schedule Site,
25 | so some content might take some time to load.
26 |
27 |
28 | Start by selecting a semester on the right
29 |
30 |
31 |
32 |
33 |
34 |
42 | Start adding NTU courses by searching their course codes.
43 |
44 |
45 | If the course exists it would appear in the timetable.
46 |
47 |
48 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
66 | One of the key features is having the ability to view all possible indexes and see how they fit into your timetable.
67 |
68 |
69 | You can do this by clicking on an event and choosing a different index to swap to.
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
85 | You can also add custom events by selecting the timeslots where your event will occur.
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
101 | That's it for now!
102 |
103 |
104 | Thank you for using NTU Stars and I hope this helps you plan your timetable more efficiently!
105 |
106 |
107 | More features and improvements are coming and it will be great to hear from you too!
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
21 |
22 | #
23 |
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 |
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 |
2 |
3 |
27 |
28 |
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 | }
--------------------------------------------------------------------------------