├── .env.example ├── .github └── workflows │ ├── ci-dev-pr.yml │ ├── lint-and-format.yml │ └── tests.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc.cjs ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── __tests__ ├── calendar.test.ts └── utils.test.ts ├── eslint.config.js ├── index.html ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public ├── apple-touch-icon.png ├── banner.png ├── favicon.ico ├── favicon.svg ├── help │ ├── access-adelaide.webp │ ├── calendar.webp │ ├── change-week.webp │ ├── click-course.webp │ ├── modal.webp │ ├── ready-button.webp │ ├── search-course.webp │ ├── select-term.webp │ └── welcome.webp ├── mockServiceWorker.js ├── pwa-192x192.png ├── pwa-512x512.png ├── pwa-maskable-192x192.png ├── pwa-maskable-512x512.png └── robots.txt ├── src ├── App.tsx ├── apis │ ├── fetcher.ts │ └── index.ts ├── components │ ├── Calendar.tsx │ ├── CourseModal.tsx │ ├── EnrolledCourses.tsx │ ├── EnrolmentModal.tsx │ ├── Error.tsx │ ├── Footer.tsx │ ├── Header.tsx │ ├── HelpModal.tsx │ ├── SearchForm.tsx │ ├── Tips.tsx │ └── ZoomButtons.tsx ├── constants │ ├── course-colors.ts │ ├── languages.ts │ ├── local-storage-keys.ts │ ├── terms.ts │ ├── week-days.ts │ └── year.ts ├── data │ ├── course-info.ts │ └── enrolled-courses.ts ├── helpers │ ├── calendar-hour-height.ts │ ├── calendar.ts │ ├── dark-mode.ts │ ├── export-calendar.ts │ ├── help-modal.ts │ ├── hours-duration.ts │ └── zoom.ts ├── i18n.ts ├── index.css ├── lib │ ├── dayjs.ts │ └── query.ts ├── locales │ ├── en-au.json │ └── zh-cn.json ├── main.tsx ├── mocks │ ├── browser.ts │ ├── data │ │ ├── adds │ │ │ ├── adds-course-info.json │ │ │ ├── adds-meeting-times.json │ │ │ └── adds-res.json │ │ ├── example-format.json │ │ ├── gccs │ │ │ ├── gccs-course-info.json │ │ │ ├── gccs-meeting-times.json │ │ │ └── gccs-res.json │ │ └── mfds │ │ │ ├── mfds-course-info.json │ │ │ ├── mfds-meeting-times.json │ │ │ └── mfds-res.json │ └── handlers.ts ├── types │ ├── course.ts │ └── key.ts ├── utils │ ├── date.ts │ ├── deduplicate-array.ts │ ├── dnd.ts │ ├── mount.ts │ ├── prefetch-image.ts │ ├── shuffle.ts │ └── time-overlap.ts └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.env.example: -------------------------------------------------------------------------------- 1 | VITE_API_BASE_URL=/mock 2 | VITE_YEAR=2024 3 | VITE_UMAMI_WEBSITE_ID= 4 | VITE_FEEDBACK_FORM_URL= 5 | VITE_FEEDBACK_FORM_URL_PREFILL_ERROR_MESSAGE= 6 | -------------------------------------------------------------------------------- /.github/workflows/ci-dev-pr.yml: -------------------------------------------------------------------------------- 1 | name: Development - Pull Request 2 | on: 3 | pull_request_target: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | lint-format: 9 | name: Linting and Formatting Checks 10 | uses: ./.github/workflows/lint-and-format.yml 11 | -------------------------------------------------------------------------------- /.github/workflows/lint-and-format.yml: -------------------------------------------------------------------------------- 1 | name: Linting and Formatting Checks 2 | on: 3 | workflow_call: 4 | 5 | jobs: 6 | es-lint: 7 | name: ESLint 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: pnpm/action-setup@v4 13 | with: 14 | version: 9 15 | - run: pnpm install 16 | - run: pnpm run lint 17 | 18 | prettier: 19 | name: Prettier 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: pnpm/action-setup@v4 25 | with: 26 | version: 9 27 | - run: pnpm install 28 | - run: pnpm run format:check 29 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Unit tests 2 | on: 3 | push: 4 | env: 5 | PNPM_VERSION: 9 6 | jobs: 7 | build: 8 | runs-on: ubuntu-20.04 9 | strategy: 10 | matrix: 11 | node-version: [20] 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: pnpm/action-setup@v4 15 | with: 16 | version: ${{ env.PNPM_VERSION }} 17 | - name: Use Node.js ${{ matrix.node-version }} 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | cache: 'pnpm' 22 | - name: Install dependencies 23 | run: pnpm install --no-frozen-lockfile 24 | - name: Run tests 25 | run: pnpm run test 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | !.vscode/settings.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | 27 | # Environment files 28 | .env 29 | .env.local 30 | .env.*.local -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | public-hoist-pattern[]=*@heroui/* -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/mockServiceWorker.js -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions & import('@trivago/prettier-plugin-sort-imports').PluginConfig} */ 2 | module.exports = { 3 | singleQuote: true, 4 | trailingComma: 'all', 5 | useTabs: true, 6 | plugins: [ 7 | '@trivago/prettier-plugin-sort-imports', 8 | 'prettier-plugin-tailwindcss', 9 | ], 10 | importOrder: ['', '^[./]'], 11 | importOrderSeparation: true, 12 | endOfLine: 'lf', 13 | }; 14 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "lokalise.i18n-ally", 4 | "esbenp.prettier-vscode", 5 | "dbaeumer.vscode-eslint", 6 | "gruntfuggly.todo-tree" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "tailwindCSS.classAttributes": ["class", "className", "classNames"], 3 | "i18n-ally.localesPaths": ["src/locales"], 4 | "i18n-ally.sourceLanguage": "en-au", 5 | "i18n-ally.keystyle": "nested" 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024-present The University of Adelaide Computer Science Club 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MyTimetable 2 | 3 | ![banner image](public/banner.png) 4 | 5 | MyTimetable is a simple drag-and-drop timetable planner for University of Adelaide students. Easily organise your course classes and create the perfect timetable with this tool by the CS Club Open Source Team. 6 | 7 | ## Development 8 | 9 | 1. Install the dependencies 10 | 11 | ```sh 12 | pnpm i 13 | ``` 14 | 15 | 2. Copy `.env.example` to a new file `.env` 16 | 17 | 3. Run the development server 18 | 19 | ```sh 20 | pnpm run dev 21 | ``` 22 | 23 | 4. Open with your browser to see the result. 24 | 25 | 5. (Optional) Run the tests 26 | 27 | ```sh 28 | pnpm run test 29 | ``` 30 | -------------------------------------------------------------------------------- /__tests__/calendar.test.ts: -------------------------------------------------------------------------------- 1 | import { getStartEndWeek, getWeekCourses } from '../src/helpers/calendar'; 2 | import dayjs from '../src/lib/dayjs'; 3 | import type { DetailedEnrolledCourse, WeekCourses } from '../src/types/course'; 4 | 5 | describe('getStartEndWeek', () => { 6 | it('should return the first date (Monday) of the start and end week', () => { 7 | const [start, end] = getStartEndWeek([ 8 | { start: '09-18', end: '10-12' }, 9 | { start: '11-13', end: '12-01' }, 10 | { start: '12-12', end: '11-11' }, 11 | ]); 12 | expect(start.format('MM-DD')).toBe('09-16'); 13 | expect(end.format('MM-DD')).toBe('11-25'); 14 | }); 15 | }); 16 | 17 | describe('getWeekCourses', () => { 18 | it('should return the courses for each day of the week', () => { 19 | const enrolledCourses: Array = [ 20 | { 21 | id: 'm', 22 | name: { code: 'm', subject: 'm', title: 'math' }, 23 | classes: [ 24 | { 25 | type: 'Lecture', 26 | typeId: 'l', 27 | classNumber: '1', 28 | meetings: [ 29 | { 30 | location: 'bragg', 31 | day: 'Tuesday', 32 | date: { start: '09-09', end: '09-27' }, 33 | time: { start: '09:00', end: '10:00' }, 34 | }, 35 | ], 36 | }, 37 | ], 38 | }, 39 | { 40 | id: 'cs', 41 | name: { code: 'cs', subject: 'cs', title: 'compsci' }, 42 | classes: [ 43 | { 44 | type: 'Practical', 45 | typeId: 'p', 46 | classNumber: '6', 47 | meetings: [ 48 | { 49 | location: 'online', 50 | day: 'Monday', 51 | date: { start: '09-09', end: '09-27' }, 52 | time: { start: '17:00', end: '18:00' }, 53 | }, 54 | ], 55 | }, 56 | { 57 | type: 'Workshop', 58 | typeId: 'w', 59 | classNumber: '3', 60 | meetings: [ 61 | { 62 | location: 'iw', 63 | day: 'Friday', 64 | date: { start: '09-09', end: '09-27' }, 65 | time: { start: '09:00', end: '10:00' }, 66 | }, 67 | ], 68 | }, 69 | ], 70 | }, 71 | ]; 72 | const courses = getWeekCourses(dayjs('2024-09-16'), enrolledCourses); 73 | const expectedRes: WeekCourses = [ 74 | [ 75 | { 76 | time: { start: '17:00', end: '18:00' }, 77 | courses: [ 78 | { 79 | id: 'cs', 80 | name: { code: 'cs', subject: 'cs', title: 'compsci' }, 81 | classTypeId: 'p', 82 | classType: 'Practical', 83 | location: 'online', 84 | classNumber: '6', 85 | }, 86 | ], 87 | }, 88 | ], 89 | [ 90 | { 91 | time: { start: '09:00', end: '10:00' }, 92 | courses: [ 93 | { 94 | id: 'm', 95 | name: { code: 'm', subject: 'm', title: 'math' }, 96 | classTypeId: 'l', 97 | classType: 'Lecture', 98 | location: 'bragg', 99 | classNumber: '1', 100 | }, 101 | ], 102 | }, 103 | ], 104 | [], 105 | [], 106 | [ 107 | { 108 | time: { start: '09:00', end: '10:00' }, 109 | courses: [ 110 | { 111 | id: 'cs', 112 | name: { code: 'cs', subject: 'cs', title: 'compsci' }, 113 | classTypeId: 'w', 114 | classType: 'Workshop', 115 | location: 'iw', 116 | classNumber: '3', 117 | }, 118 | ], 119 | }, 120 | ], 121 | ]; 122 | expect(courses).toEqual(expectedRes); 123 | }); 124 | it('should return the courses if course start at the end of the week', () => { 125 | const enrolledCourses: Array = [ 126 | { 127 | id: 'm', 128 | name: { code: 'm', subject: 'm', title: 'math' }, 129 | classes: [ 130 | { 131 | type: 'Lecture', 132 | typeId: 'l', 133 | classNumber: '1', 134 | meetings: [ 135 | { 136 | location: 'bragg', 137 | day: 'Friday', 138 | date: { start: '09-20', end: '10-04' }, 139 | time: { start: '09:00', end: '10:00' }, 140 | }, 141 | ], 142 | }, 143 | ], 144 | }, 145 | ]; 146 | const courses = getWeekCourses(dayjs('2024-09-16'), enrolledCourses); 147 | const expectedRes: WeekCourses = [ 148 | [], 149 | [], 150 | [], 151 | [], 152 | [ 153 | { 154 | time: { start: '09:00', end: '10:00' }, 155 | courses: [ 156 | { 157 | id: 'm', 158 | name: { code: 'm', subject: 'm', title: 'math' }, 159 | classTypeId: 'l', 160 | classType: 'Lecture', 161 | location: 'bragg', 162 | classNumber: '1', 163 | }, 164 | ], 165 | }, 166 | ], 167 | ]; 168 | expect(courses).toEqual(expectedRes); 169 | }); 170 | it('should return the courses if course end at the start of the week', () => { 171 | const enrolledCourses: Array = [ 172 | { 173 | id: 'm', 174 | name: { code: 'm', subject: 'm', title: 'math' }, 175 | classes: [ 176 | { 177 | type: 'Lecture', 178 | typeId: 'l', 179 | classNumber: '1', 180 | meetings: [ 181 | { 182 | location: 'bragg', 183 | day: 'Monday', 184 | date: { start: '08-12', end: '09-16' }, 185 | time: { start: '09:00', end: '10:00' }, 186 | }, 187 | ], 188 | }, 189 | ], 190 | }, 191 | ]; 192 | const courses = getWeekCourses(dayjs('2024-09-16'), enrolledCourses); 193 | const expectedRes: WeekCourses = [ 194 | [ 195 | { 196 | time: { start: '09:00', end: '10:00' }, 197 | courses: [ 198 | { 199 | id: 'm', 200 | name: { code: 'm', subject: 'm', title: 'math' }, 201 | classTypeId: 'l', 202 | classType: 'Lecture', 203 | location: 'bragg', 204 | classNumber: '1', 205 | }, 206 | ], 207 | }, 208 | ], 209 | [], 210 | [], 211 | [], 212 | [], 213 | ]; 214 | expect(courses).toEqual(expectedRes); 215 | }); 216 | it('should sort courses by start time in a day', () => { 217 | const enrolledCourses: Array = [ 218 | { 219 | id: 'cs', 220 | name: { code: 'cs', subject: 'cs', title: 'compsci' }, 221 | classes: [ 222 | { 223 | type: 'Practical', 224 | typeId: 'p', 225 | classNumber: '6', 226 | meetings: [ 227 | { 228 | location: 'online', 229 | day: 'Monday', 230 | date: { start: '09-09', end: '09-27' }, 231 | time: { start: '17:00', end: '18:00' }, 232 | }, 233 | ], 234 | }, 235 | ], 236 | }, 237 | { 238 | id: 'm', 239 | name: { code: 'm', subject: 'm', title: 'math' }, 240 | classes: [ 241 | { 242 | type: 'Lecture', 243 | typeId: 'l', 244 | classNumber: '1', 245 | meetings: [ 246 | { 247 | location: 'bragg', 248 | day: 'Monday', 249 | date: { start: '09-09', end: '09-27' }, 250 | time: { start: '09:00', end: '10:00' }, 251 | }, 252 | ], 253 | }, 254 | ], 255 | }, 256 | ]; 257 | const courses = getWeekCourses(dayjs('2024-09-16'), enrolledCourses); 258 | const expectedRes: WeekCourses = [ 259 | [ 260 | { 261 | time: { start: '09:00', end: '10:00' }, 262 | courses: [ 263 | { 264 | id: 'm', 265 | name: { code: 'm', subject: 'm', title: 'math' }, 266 | classTypeId: 'l', 267 | classType: 'Lecture', 268 | location: 'bragg', 269 | classNumber: '1', 270 | }, 271 | ], 272 | }, 273 | { 274 | time: { start: '17:00', end: '18:00' }, 275 | courses: [ 276 | { 277 | id: 'cs', 278 | name: { code: 'cs', subject: 'cs', title: 'compsci' }, 279 | classTypeId: 'p', 280 | classType: 'Practical', 281 | location: 'online', 282 | classNumber: '6', 283 | }, 284 | ], 285 | }, 286 | ], 287 | [], 288 | [], 289 | [], 290 | [], 291 | ]; 292 | expect(courses).toEqual(expectedRes); 293 | }); 294 | it('should sort courses by duration (longest first) in a day', () => { 295 | const enrolledCourses: Array = [ 296 | { 297 | id: 'cs', 298 | name: { code: 'cs', subject: 'cs', title: 'compsci' }, 299 | classes: [ 300 | { 301 | type: 'Practical', 302 | typeId: 'p', 303 | classNumber: '6', 304 | meetings: [ 305 | { 306 | location: 'online', 307 | day: 'Monday', 308 | date: { start: '09-09', end: '09-27' }, 309 | time: { start: '09:00', end: '10:00' }, 310 | }, 311 | ], 312 | }, 313 | ], 314 | }, 315 | { 316 | id: 'm', 317 | name: { code: 'm', subject: 'm', title: 'math' }, 318 | classes: [ 319 | { 320 | type: 'Lecture', 321 | typeId: 'l', 322 | classNumber: '1', 323 | meetings: [ 324 | { 325 | location: 'bragg', 326 | day: 'Monday', 327 | date: { start: '09-09', end: '09-27' }, 328 | time: { start: '09:00', end: '12:00' }, 329 | }, 330 | ], 331 | }, 332 | ], 333 | }, 334 | ]; 335 | const courses = getWeekCourses(dayjs('2024-09-16'), enrolledCourses); 336 | const expectedRes: WeekCourses = [ 337 | [ 338 | { 339 | time: { start: '09:00', end: '12:00' }, 340 | courses: [ 341 | { 342 | id: 'm', 343 | name: { code: 'm', subject: 'm', title: 'math' }, 344 | classTypeId: 'l', 345 | classType: 'Lecture', 346 | location: 'bragg', 347 | classNumber: '1', 348 | }, 349 | ], 350 | }, 351 | { 352 | time: { start: '09:00', end: '10:00' }, 353 | courses: [ 354 | { 355 | id: 'cs', 356 | name: { code: 'cs', subject: 'cs', title: 'compsci' }, 357 | classTypeId: 'p', 358 | classType: 'Practical', 359 | location: 'online', 360 | classNumber: '6', 361 | }, 362 | ], 363 | }, 364 | ], 365 | [], 366 | [], 367 | [], 368 | [], 369 | ]; 370 | expect(courses).toEqual(expectedRes); 371 | }); 372 | it('should combine courses when they have the same time', () => { 373 | const enrolledCourses: Array = [ 374 | { 375 | id: 'cs', 376 | name: { code: 'cs', subject: 'cs', title: 'compsci' }, 377 | classes: [ 378 | { 379 | type: 'Practical', 380 | typeId: 'p', 381 | classNumber: '6', 382 | meetings: [ 383 | { 384 | location: 'online', 385 | day: 'Monday', 386 | date: { start: '09-09', end: '09-27' }, 387 | time: { start: '09:00', end: '10:00' }, 388 | }, 389 | ], 390 | }, 391 | ], 392 | }, 393 | { 394 | id: 'm', 395 | name: { code: 'm', subject: 'm', title: 'math' }, 396 | classes: [ 397 | { 398 | type: 'Lecture', 399 | typeId: 'l', 400 | classNumber: '1', 401 | meetings: [ 402 | { 403 | location: 'bragg', 404 | day: 'Monday', 405 | date: { start: '09-09', end: '09-27' }, 406 | time: { start: '09:00', end: '10:00' }, 407 | }, 408 | ], 409 | }, 410 | ], 411 | }, 412 | ]; 413 | const courses = getWeekCourses(dayjs('2024-09-16'), enrolledCourses); 414 | const expectedRes: WeekCourses = [ 415 | [ 416 | { 417 | time: { start: '09:00', end: '10:00' }, 418 | courses: [ 419 | { 420 | id: 'cs', 421 | name: { code: 'cs', subject: 'cs', title: 'compsci' }, 422 | classTypeId: 'p', 423 | classType: 'Practical', 424 | location: 'online', 425 | classNumber: '6', 426 | }, 427 | { 428 | id: 'm', 429 | name: { code: 'm', subject: 'm', title: 'math' }, 430 | classTypeId: 'l', 431 | classType: 'Lecture', 432 | location: 'bragg', 433 | classNumber: '1', 434 | }, 435 | ], 436 | }, 437 | ], 438 | [], 439 | [], 440 | [], 441 | [], 442 | ]; 443 | expect(courses).toEqual(expectedRes); 444 | }); 445 | }); 446 | -------------------------------------------------------------------------------- /__tests__/utils.test.ts: -------------------------------------------------------------------------------- 1 | import type { DateTimeRange } from '../src/types/course'; 2 | import { timeOverlap } from '../src/utils/time-overlap'; 3 | 4 | describe('timeOverlap', () => { 5 | it('should return true if two time ranges overlap', () => { 6 | const a: DateTimeRange = { start: '01:00', end: '03:00' }; 7 | const b: DateTimeRange = { start: '02:00', end: '04:00' }; 8 | expect(timeOverlap(a, b)).toBe(true); 9 | }); 10 | it('should return true if two time ranges are same', () => { 11 | const a: DateTimeRange = { start: '01:00', end: '03:00' }; 12 | const b: DateTimeRange = { start: '01:00', end: '03:00' }; 13 | expect(timeOverlap(a, b)).toBe(true); 14 | }); 15 | it('should return false if two time ranges just touch', () => { 16 | const a: DateTimeRange = { start: '01:00', end: '02:00' }; 17 | const b: DateTimeRange = { start: '02:00', end: '03:00' }; 18 | expect(timeOverlap(a, b)).toBe(false); 19 | }); 20 | it('should return false if time does not overlap', () => { 21 | const a: DateTimeRange = { start: '01:00', end: '02:00' }; 22 | const b: DateTimeRange = { start: '09:00', end: '10:00' }; 23 | expect(timeOverlap(a, b)).toBe(false); 24 | }); 25 | it('should return true if b is before a', () => { 26 | const b: DateTimeRange = { start: '01:00', end: '03:00' }; 27 | const a: DateTimeRange = { start: '02:00', end: '04:00' }; 28 | expect(timeOverlap(a, b)).toBe(true); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js'; 2 | import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; 3 | import reactHooks from 'eslint-plugin-react-hooks'; 4 | import reactRefresh from 'eslint-plugin-react-refresh'; 5 | import globals from 'globals'; 6 | import tseslint from 'typescript-eslint'; 7 | 8 | export default tseslint.config( 9 | { ignores: ['dist'] }, 10 | { 11 | extends: [ 12 | js.configs.recommended, 13 | eslintPluginPrettierRecommended, 14 | ...tseslint.configs.recommended, 15 | ], 16 | files: ['**/*.{ts,tsx}'], 17 | languageOptions: { 18 | ecmaVersion: 'latest', 19 | globals: globals.browser, 20 | }, 21 | plugins: { 22 | 'react-hooks': reactHooks, 23 | 'react-refresh': reactRefresh, 24 | }, 25 | rules: { 26 | ...reactHooks.configs.recommended.rules, 27 | 'react-refresh/only-export-components': [ 28 | 'warn', 29 | { allowConstantExport: true }, 30 | ], 31 | '@typescript-eslint/explicit-function-return-type': 'off', 32 | '@typescript-eslint/explicit-module-boundary-types': 'off', 33 | '@typescript-eslint/no-non-null-assertion': 'off', 34 | '@typescript-eslint/no-unused-vars': [ 35 | 'warn', 36 | { ignoreRestSiblings: true }, 37 | ], 38 | '@typescript-eslint/consistent-type-imports': [ 39 | 'warn', 40 | { disallowTypeAnnotations: false }, 41 | ], 42 | 'no-console': 'warn', 43 | eqeqeq: 'warn', 44 | 'prefer-const': 'warn', 45 | 'no-var': 'warn', 46 | }, 47 | }, 48 | ); 49 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | MyTimetable 10 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 24 | 25 | 26 | 27 | 28 | 29 | 33 | 34 | 38 | 39 | 44 | 45 | 46 |
47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mytimetable", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "prepare": "simple-git-hooks", 8 | "dev": "vite", 9 | "build": "tsc -b && vite build", 10 | "lint": "eslint ./src", 11 | "preview": "vite preview", 12 | "format": "prettier --write \"**/*.{js,ts,tsx,css,md,cjs,mjs,json,html}\"", 13 | "format:check": "prettier --check \"**/*.{js,ts,tsx,css,md,cjs,mjs,json,html}\"", 14 | "test": "vitest" 15 | }, 16 | "dependencies": { 17 | "@atlaskit/pragmatic-drag-and-drop": "^1.5.0", 18 | "@fontsource-variable/outfit": "^5.1.1", 19 | "@heroui/react": "2.7.2", 20 | "@tanstack/react-query": "^5.66.7", 21 | "@tanstack/react-query-devtools": "^5.66.7", 22 | "clsx": "^2.1.1", 23 | "dayjs": "^1.11.13", 24 | "framer-motion": "^11.18.2", 25 | "i18next": "^23.16.8", 26 | "i18next-browser-languagedetector": "^8.0.3", 27 | "ky": "^1.7.5", 28 | "mutative": "^1.1.0", 29 | "react": "^18.3.1", 30 | "react-dom": "^18.3.1", 31 | "react-error-boundary": "^4.1.2", 32 | "react-i18next": "^15.4.1", 33 | "react-icons": "^5.5.0", 34 | "simple-zustand-devtools": "^1.1.0", 35 | "sonner": "^1.7.4", 36 | "zustand": "^4.5.6", 37 | "zustand-mutative": "^1.2.0" 38 | }, 39 | "devDependencies": { 40 | "@eslint/js": "^9.20.0", 41 | "@trivago/prettier-plugin-sort-imports": "^4.3.0", 42 | "@types/react": "^18.3.18", 43 | "@types/react-dom": "^18.3.5", 44 | "@types/umami": "^2.10.0", 45 | "@vitejs/plugin-react": "^4.3.4", 46 | "autoprefixer": "^10.4.20", 47 | "eslint": "^9.20.1", 48 | "eslint-config-prettier": "^9.1.0", 49 | "eslint-plugin-prettier": "^5.2.3", 50 | "eslint-plugin-react-hooks": "^5.1.0", 51 | "eslint-plugin-react-refresh": "^0.4.19", 52 | "globals": "^15.15.0", 53 | "lint-staged": "^15.4.3", 54 | "msw": "^2.7.0", 55 | "postcss": "^8.5.3", 56 | "prettier": "^3.5.1", 57 | "prettier-plugin-tailwindcss": "^0.6.11", 58 | "simple-git-hooks": "^2.11.1", 59 | "tailwindcss": "^3.4.17", 60 | "typescript": "^5.7.3", 61 | "typescript-eslint": "^8.24.1", 62 | "vite": "^5.4.14", 63 | "vite-plugin-pwa": "^0.20.5", 64 | "vitest": "^2.1.9" 65 | }, 66 | "lint-staged": { 67 | "*.{js,ts,tsx,css,md,cjs,mjs,json,html}": [ 68 | "prettier --write" 69 | ] 70 | }, 71 | "simple-git-hooks": { 72 | "pre-commit": "npx lint-staged" 73 | }, 74 | "msw": { 75 | "workerDirectory": [ 76 | "public" 77 | ] 78 | }, 79 | "pnpm": { 80 | "overrides": { 81 | "esbuild@<=0.24.2": ">=0.25.0" 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compsci-adl/mytimetable/f922053fb3464a291a0ac39c3b20fcee5e665405/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compsci-adl/mytimetable/f922053fb3464a291a0ac39c3b20fcee5e665405/public/banner.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compsci-adl/mytimetable/f922053fb3464a291a0ac39c3b20fcee5e665405/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/help/access-adelaide.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compsci-adl/mytimetable/f922053fb3464a291a0ac39c3b20fcee5e665405/public/help/access-adelaide.webp -------------------------------------------------------------------------------- /public/help/calendar.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compsci-adl/mytimetable/f922053fb3464a291a0ac39c3b20fcee5e665405/public/help/calendar.webp -------------------------------------------------------------------------------- /public/help/change-week.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compsci-adl/mytimetable/f922053fb3464a291a0ac39c3b20fcee5e665405/public/help/change-week.webp -------------------------------------------------------------------------------- /public/help/click-course.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compsci-adl/mytimetable/f922053fb3464a291a0ac39c3b20fcee5e665405/public/help/click-course.webp -------------------------------------------------------------------------------- /public/help/modal.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compsci-adl/mytimetable/f922053fb3464a291a0ac39c3b20fcee5e665405/public/help/modal.webp -------------------------------------------------------------------------------- /public/help/ready-button.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compsci-adl/mytimetable/f922053fb3464a291a0ac39c3b20fcee5e665405/public/help/ready-button.webp -------------------------------------------------------------------------------- /public/help/search-course.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compsci-adl/mytimetable/f922053fb3464a291a0ac39c3b20fcee5e665405/public/help/search-course.webp -------------------------------------------------------------------------------- /public/help/select-term.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compsci-adl/mytimetable/f922053fb3464a291a0ac39c3b20fcee5e665405/public/help/select-term.webp -------------------------------------------------------------------------------- /public/help/welcome.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compsci-adl/mytimetable/f922053fb3464a291a0ac39c3b20fcee5e665405/public/help/welcome.webp -------------------------------------------------------------------------------- /public/mockServiceWorker.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | 4 | /** 5 | * Mock Service Worker. 6 | * @see https://github.com/mswjs/msw 7 | * - Please do NOT modify this file. 8 | * - Please do NOT serve this file on production. 9 | */ 10 | 11 | const PACKAGE_VERSION = '2.7.0' 12 | const INTEGRITY_CHECKSUM = '00729d72e3b82faf54ca8b9621dbb96f' 13 | const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') 14 | const activeClientIds = new Set() 15 | 16 | self.addEventListener('install', function () { 17 | self.skipWaiting() 18 | }) 19 | 20 | self.addEventListener('activate', function (event) { 21 | event.waitUntil(self.clients.claim()) 22 | }) 23 | 24 | self.addEventListener('message', async function (event) { 25 | const clientId = event.source.id 26 | 27 | if (!clientId || !self.clients) { 28 | return 29 | } 30 | 31 | const client = await self.clients.get(clientId) 32 | 33 | if (!client) { 34 | return 35 | } 36 | 37 | const allClients = await self.clients.matchAll({ 38 | type: 'window', 39 | }) 40 | 41 | switch (event.data) { 42 | case 'KEEPALIVE_REQUEST': { 43 | sendToClient(client, { 44 | type: 'KEEPALIVE_RESPONSE', 45 | }) 46 | break 47 | } 48 | 49 | case 'INTEGRITY_CHECK_REQUEST': { 50 | sendToClient(client, { 51 | type: 'INTEGRITY_CHECK_RESPONSE', 52 | payload: { 53 | packageVersion: PACKAGE_VERSION, 54 | checksum: INTEGRITY_CHECKSUM, 55 | }, 56 | }) 57 | break 58 | } 59 | 60 | case 'MOCK_ACTIVATE': { 61 | activeClientIds.add(clientId) 62 | 63 | sendToClient(client, { 64 | type: 'MOCKING_ENABLED', 65 | payload: { 66 | client: { 67 | id: client.id, 68 | frameType: client.frameType, 69 | }, 70 | }, 71 | }) 72 | break 73 | } 74 | 75 | case 'MOCK_DEACTIVATE': { 76 | activeClientIds.delete(clientId) 77 | break 78 | } 79 | 80 | case 'CLIENT_CLOSED': { 81 | activeClientIds.delete(clientId) 82 | 83 | const remainingClients = allClients.filter((client) => { 84 | return client.id !== clientId 85 | }) 86 | 87 | // Unregister itself when there are no more clients 88 | if (remainingClients.length === 0) { 89 | self.registration.unregister() 90 | } 91 | 92 | break 93 | } 94 | } 95 | }) 96 | 97 | self.addEventListener('fetch', function (event) { 98 | const { request } = event 99 | 100 | // Bypass navigation requests. 101 | if (request.mode === 'navigate') { 102 | return 103 | } 104 | 105 | // Opening the DevTools triggers the "only-if-cached" request 106 | // that cannot be handled by the worker. Bypass such requests. 107 | if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { 108 | return 109 | } 110 | 111 | // Bypass all requests when there are no active clients. 112 | // Prevents the self-unregistered worked from handling requests 113 | // after it's been deleted (still remains active until the next reload). 114 | if (activeClientIds.size === 0) { 115 | return 116 | } 117 | 118 | // Generate unique request ID. 119 | const requestId = crypto.randomUUID() 120 | event.respondWith(handleRequest(event, requestId)) 121 | }) 122 | 123 | async function handleRequest(event, requestId) { 124 | const client = await resolveMainClient(event) 125 | const response = await getResponse(event, client, requestId) 126 | 127 | // Send back the response clone for the "response:*" life-cycle events. 128 | // Ensure MSW is active and ready to handle the message, otherwise 129 | // this message will pend indefinitely. 130 | if (client && activeClientIds.has(client.id)) { 131 | ;(async function () { 132 | const responseClone = response.clone() 133 | 134 | sendToClient( 135 | client, 136 | { 137 | type: 'RESPONSE', 138 | payload: { 139 | requestId, 140 | isMockedResponse: IS_MOCKED_RESPONSE in response, 141 | type: responseClone.type, 142 | status: responseClone.status, 143 | statusText: responseClone.statusText, 144 | body: responseClone.body, 145 | headers: Object.fromEntries(responseClone.headers.entries()), 146 | }, 147 | }, 148 | [responseClone.body], 149 | ) 150 | })() 151 | } 152 | 153 | return response 154 | } 155 | 156 | // Resolve the main client for the given event. 157 | // Client that issues a request doesn't necessarily equal the client 158 | // that registered the worker. It's with the latter the worker should 159 | // communicate with during the response resolving phase. 160 | async function resolveMainClient(event) { 161 | const client = await self.clients.get(event.clientId) 162 | 163 | if (activeClientIds.has(event.clientId)) { 164 | return client 165 | } 166 | 167 | if (client?.frameType === 'top-level') { 168 | return client 169 | } 170 | 171 | const allClients = await self.clients.matchAll({ 172 | type: 'window', 173 | }) 174 | 175 | return allClients 176 | .filter((client) => { 177 | // Get only those clients that are currently visible. 178 | return client.visibilityState === 'visible' 179 | }) 180 | .find((client) => { 181 | // Find the client ID that's recorded in the 182 | // set of clients that have registered the worker. 183 | return activeClientIds.has(client.id) 184 | }) 185 | } 186 | 187 | async function getResponse(event, client, requestId) { 188 | const { request } = event 189 | 190 | // Clone the request because it might've been already used 191 | // (i.e. its body has been read and sent to the client). 192 | const requestClone = request.clone() 193 | 194 | function passthrough() { 195 | // Cast the request headers to a new Headers instance 196 | // so the headers can be manipulated with. 197 | const headers = new Headers(requestClone.headers) 198 | 199 | // Remove the "accept" header value that marked this request as passthrough. 200 | // This prevents request alteration and also keeps it compliant with the 201 | // user-defined CORS policies. 202 | const acceptHeader = headers.get('accept') 203 | if (acceptHeader) { 204 | const values = acceptHeader.split(',').map((value) => value.trim()) 205 | const filteredValues = values.filter( 206 | (value) => value !== 'msw/passthrough', 207 | ) 208 | 209 | if (filteredValues.length > 0) { 210 | headers.set('accept', filteredValues.join(', ')) 211 | } else { 212 | headers.delete('accept') 213 | } 214 | } 215 | 216 | return fetch(requestClone, { headers }) 217 | } 218 | 219 | // Bypass mocking when the client is not active. 220 | if (!client) { 221 | return passthrough() 222 | } 223 | 224 | // Bypass initial page load requests (i.e. static assets). 225 | // The absence of the immediate/parent client in the map of the active clients 226 | // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet 227 | // and is not ready to handle requests. 228 | if (!activeClientIds.has(client.id)) { 229 | return passthrough() 230 | } 231 | 232 | // Notify the client that a request has been intercepted. 233 | const requestBuffer = await request.arrayBuffer() 234 | const clientMessage = await sendToClient( 235 | client, 236 | { 237 | type: 'REQUEST', 238 | payload: { 239 | id: requestId, 240 | url: request.url, 241 | mode: request.mode, 242 | method: request.method, 243 | headers: Object.fromEntries(request.headers.entries()), 244 | cache: request.cache, 245 | credentials: request.credentials, 246 | destination: request.destination, 247 | integrity: request.integrity, 248 | redirect: request.redirect, 249 | referrer: request.referrer, 250 | referrerPolicy: request.referrerPolicy, 251 | body: requestBuffer, 252 | keepalive: request.keepalive, 253 | }, 254 | }, 255 | [requestBuffer], 256 | ) 257 | 258 | switch (clientMessage.type) { 259 | case 'MOCK_RESPONSE': { 260 | return respondWithMock(clientMessage.data) 261 | } 262 | 263 | case 'PASSTHROUGH': { 264 | return passthrough() 265 | } 266 | } 267 | 268 | return passthrough() 269 | } 270 | 271 | function sendToClient(client, message, transferrables = []) { 272 | return new Promise((resolve, reject) => { 273 | const channel = new MessageChannel() 274 | 275 | channel.port1.onmessage = (event) => { 276 | if (event.data && event.data.error) { 277 | return reject(event.data.error) 278 | } 279 | 280 | resolve(event.data) 281 | } 282 | 283 | client.postMessage( 284 | message, 285 | [channel.port2].concat(transferrables.filter(Boolean)), 286 | ) 287 | }) 288 | } 289 | 290 | async function respondWithMock(response) { 291 | // Setting response status code to 0 is a no-op. 292 | // However, when responding with a "Response.error()", the produced Response 293 | // instance will have status code set to 0. Since it's not possible to create 294 | // a Response instance with status code 0, handle that use-case separately. 295 | if (response.status === 0) { 296 | return Response.error() 297 | } 298 | 299 | const mockedResponse = new Response(response.body, response) 300 | 301 | Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { 302 | value: true, 303 | enumerable: true, 304 | }) 305 | 306 | return mockedResponse 307 | } 308 | -------------------------------------------------------------------------------- /public/pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compsci-adl/mytimetable/f922053fb3464a291a0ac39c3b20fcee5e665405/public/pwa-192x192.png -------------------------------------------------------------------------------- /public/pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compsci-adl/mytimetable/f922053fb3464a291a0ac39c3b20fcee5e665405/public/pwa-512x512.png -------------------------------------------------------------------------------- /public/pwa-maskable-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compsci-adl/mytimetable/f922053fb3464a291a0ac39c3b20fcee5e665405/public/pwa-maskable-192x192.png -------------------------------------------------------------------------------- /public/pwa-maskable-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compsci-adl/mytimetable/f922053fb3464a291a0ac39c3b20fcee5e665405/public/pwa-maskable-512x512.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Calendar } from './components/Calendar'; 2 | import { EnrolledCourses } from './components/EnrolledCourses'; 3 | import { Footer } from './components/Footer'; 4 | import { Header } from './components/Header'; 5 | import { HelpModal } from './components/HelpModal'; 6 | import { SearchForm } from './components/SearchForm'; 7 | import { ZoomButtons } from './components/ZoomButtons'; 8 | import { useCoursesInfo } from './data/course-info'; 9 | import { useFirstTimeHelp } from './helpers/help-modal'; 10 | 11 | export const App = () => { 12 | useCoursesInfo(); 13 | useFirstTimeHelp(); 14 | 15 | return ( 16 | <> 17 |
18 |
19 | 20 | 21 | 22 | 23 | 24 |
25 |
26 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /src/apis/fetcher.ts: -------------------------------------------------------------------------------- 1 | import ky from 'ky'; 2 | 3 | export const fetcher = ky.create({ 4 | prefixUrl: import.meta.env.VITE_API_BASE_URL, 5 | }); 6 | -------------------------------------------------------------------------------- /src/apis/index.ts: -------------------------------------------------------------------------------- 1 | import type { Course } from '../types/course'; 2 | import { fetcher } from './fetcher'; 3 | 4 | type CoursesRes = { 5 | courses: Array<{ 6 | id: string; 7 | name: { 8 | subject: string; 9 | code: string; 10 | title: string; 11 | }; 12 | }>; 13 | }; 14 | 15 | export const getCourses = async (params: { 16 | year: number; 17 | term: string; 18 | subject: string; 19 | }) => { 20 | return fetcher.get('courses', { searchParams: params }).json(); 21 | }; 22 | 23 | export const getCourse = async ({ id }: { id: string }) => { 24 | return fetcher.get(`courses/${id}`).json(); 25 | }; 26 | 27 | type SubjectsRes = { 28 | subjects: Array<{ code: string; name: string }>; 29 | }; 30 | 31 | export const getSubjects = async (params: { year: number; term: string }) => { 32 | return fetcher.get('subjects', { searchParams: params }).json(); 33 | }; 34 | -------------------------------------------------------------------------------- /src/components/Calendar.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Tooltip, useDisclosure } from '@heroui/react'; 2 | import clsx from 'clsx'; 3 | import { useRef, useState } from 'react'; 4 | import { useTranslation } from 'react-i18next'; 5 | import { create } from 'zustand'; 6 | 7 | import { WEEK_DAYS } from '../constants/week-days'; 8 | import { YEAR } from '../constants/year'; 9 | import { 10 | useCourseColor, 11 | useEnrolledCourse, 12 | useEnrolledCourses, 13 | } from '../data/enrolled-courses'; 14 | import { useCalendar, useOtherWeekCourseTimes } from '../helpers/calendar'; 15 | import { useCalendarHourHeight } from '../helpers/calendar-hour-height'; 16 | import { calcHoursDuration } from '../helpers/hours-duration'; 17 | import { useZoom } from '../helpers/zoom'; 18 | import type dayjs from '../lib/dayjs'; 19 | import type { DateTimeRange, WeekCourse, WeekCourses } from '../types/course'; 20 | import { timeToDayjs } from '../utils/date'; 21 | import { useDrag, useDrop } from '../utils/dnd'; 22 | import { EnrolmentModal } from './EnrolmentModal'; 23 | 24 | type DraggingCourseState = { 25 | isDragging: boolean; 26 | course: WeekCourse | null; 27 | start: (course: WeekCourse) => void; 28 | stop: () => void; 29 | }; 30 | const useDraggingCourse = create()((set) => ({ 31 | isDragging: false, 32 | course: null, 33 | start: (course) => set({ isDragging: true, course }), 34 | stop: () => set({ isDragging: false, course: null }), 35 | })); 36 | 37 | // FIXME: Fix grid width to remove this placeholder 38 | const InvisiblePlaceholder = () => { 39 | return ( 40 |
41 | PLACEHOLDER DO NOT REMOVE ME AND I AM VERY LOOOOOONG 42 |
43 | ); 44 | }; 45 | 46 | type CourseCardProps = { 47 | course: WeekCourse; 48 | time: DateTimeRange; 49 | currentWeek: dayjs.Dayjs; 50 | }; 51 | const CourseCard = ({ course, time, currentWeek }: CourseCardProps) => { 52 | const { t } = useTranslation(); 53 | 54 | const otherTimes = useOtherWeekCourseTimes({ 55 | courseId: course.id, 56 | classTypeId: course.classTypeId, 57 | currentWeek, 58 | }); 59 | const isOnlyTime = !otherTimes.some((times) => times.length !== 0); 60 | 61 | const color = useCourseColor(course.id); 62 | 63 | const draggingCourse = useDraggingCourse(); 64 | const [isDragging, setIsDragging] = useState(false); 65 | const ref = useRef(null); 66 | useDrag( 67 | ref, 68 | { 69 | canDrag: () => !isOnlyTime, 70 | onDragStart: () => { 71 | setIsDragging(true); 72 | draggingCourse.start(course); 73 | }, 74 | onDrop: () => { 75 | setIsDragging(false); 76 | draggingCourse.stop(); 77 | }, 78 | getInitialDataForExternal: () => { 79 | return { 'text/plain': course.classNumber }; 80 | }, 81 | }, 82 | [isOnlyTime], 83 | ); 84 | 85 | return ( 86 |
96 |
97 |
{time.start}
98 | {isOnlyTime && ( 99 | 100 |
📌
101 |
102 | )} 103 |
104 |
105 | [{course.classType}] {course.name.title} 106 |
107 |
{course.location}
108 | 109 |
110 | ); 111 | }; 112 | 113 | type CalendarHeaderProps = { 114 | currentWeek: dayjs.Dayjs; 115 | actions: ReturnType['actions']; 116 | status: ReturnType['status']; 117 | }; 118 | const CalendarHeader = ({ 119 | currentWeek, 120 | actions, 121 | status, 122 | }: CalendarHeaderProps) => { 123 | const { t } = useTranslation(); 124 | 125 | const actionButtons = [ 126 | { 127 | icon: '⏪', 128 | description: t('calendar.first-week'), 129 | action: actions.goToStartWeek, 130 | disabled: status.isStartWeek, 131 | }, 132 | { 133 | icon: '◀️', 134 | description: t('calendar.previous-week'), 135 | action: actions.prevWeek, 136 | disabled: status.isStartWeek, 137 | }, 138 | { 139 | icon: '▶️', 140 | description: t('calendar.next-week'), 141 | action: actions.nextWeek, 142 | disabled: status.isEndWeek, 143 | }, 144 | { 145 | icon: '⏩', 146 | description: t('calendar.last-week'), 147 | action: actions.goToEndWeek, 148 | disabled: status.isEndWeek, 149 | }, 150 | ]; 151 | return ( 152 |
153 |

154 | 155 | {/* Month for Wednesday in the week is more accurate than Monday */} 156 | { 157 | (t('calendar.months') as unknown as Array)[ 158 | currentWeek.add(2, 'day').month() 159 | ] 160 | } 161 | 162 | {YEAR} 163 |

164 |
165 | {actionButtons.map((a) => ( 166 | 167 | 176 | 177 | ))} 178 |
179 |
180 | ); 181 | }; 182 | 183 | const EndActions = () => { 184 | const { t } = useTranslation(); 185 | 186 | const blockHeight = useCalendarHourHeight((s) => s.height); 187 | 188 | const { 189 | isOpen: isReadyModalOpen, 190 | onOpen: onReadyModalOpen, 191 | onOpenChange: onReadyModalOpenChange, 192 | } = useDisclosure(); 193 | return ( 194 |
198 | {/* TODO: Share Button */} 199 | 207 | 211 |
212 | ); 213 | }; 214 | 215 | const CalendarBg = ({ currentWeek }: { currentWeek: dayjs.Dayjs }) => { 216 | const { t } = useTranslation(); 217 | 218 | const blockHeight = useCalendarHourHeight((s) => s.height); 219 | 220 | return ( 221 |
222 |
223 | {WEEK_DAYS.map((day, i) => ( 224 |
228 |
229 | { 230 | (t('calendar.week-days') as unknown as Array)[ 231 | WEEK_DAYS.findIndex((d) => d === day) 232 | ] 233 | } 234 |
235 |
{currentWeek.add(i, 'day').date()}
236 |
237 | ))} 238 |
239 | {/* FIXME: Remove the last two grid rows for 21:00 */} 240 |
241 | {Array.from({ length: 15 }, (_, i) => ( 242 |
{String(7 + i).padStart(2, '0')}:00
243 | ))} 244 |
245 |
246 | {Array.from({ length: 5 * 28 }, (_, i) => ( 247 |
255 | ))} 256 |
257 |
258 | ); 259 | }; 260 | 261 | const getGridRow = (time: string) => { 262 | const t = timeToDayjs(time); 263 | return t.hour() * 2 + (t.minute() >= 30 ? 1 : 0) - 13; 264 | }; 265 | const CalendarCourses = ({ 266 | courses: day, 267 | currentWeek, 268 | }: { 269 | courses: WeekCourses; 270 | currentWeek: dayjs.Dayjs; 271 | }) => { 272 | const blockHeight = useCalendarHourHeight((s) => s.height); 273 | 274 | return ( 275 |
276 | {day.map((times, i) => 277 | times.map((time, j) => ( 278 |
289 | {time.courses.map((course) => ( 290 | 296 | ))} 297 |
298 | )), 299 | )} 300 |
301 | ); 302 | }; 303 | 304 | type CourseTimePlaceholderCardProps = { 305 | courseId: string; 306 | classNumber: string; 307 | classTypeId: string; 308 | location: string; 309 | }; 310 | const CourseTimePlaceholderCard = ({ 311 | courseId, 312 | classNumber, 313 | classTypeId, 314 | location, 315 | }: CourseTimePlaceholderCardProps) => { 316 | const color = useCourseColor(courseId); 317 | 318 | const { updateClass } = useEnrolledCourse(courseId); 319 | 320 | const [isDraggedOver, setIsDraggedOver] = useState(false); 321 | const ref = useRef(null); 322 | useDrop(ref, { 323 | onDragEnter: () => setIsDraggedOver(true), 324 | onDragLeave: () => setIsDraggedOver(false), 325 | onDrop: () => { 326 | setIsDraggedOver(false); 327 | updateClass({ classTypeId, classNumber }); 328 | }, 329 | }); 330 | 331 | return ( 332 |
341 | {/* FIXME: Remove placeholder and center the location text by flex */} 342 |
343 | {location} 344 |
345 | 346 |
347 | ); 348 | }; 349 | 350 | const CalendarCourseOtherTimes = ({ 351 | currentWeek, 352 | }: { 353 | currentWeek: dayjs.Dayjs; 354 | }) => { 355 | const blockHeight = useCalendarHourHeight((s) => s.height); 356 | 357 | const course = useDraggingCourse((s) => s.course)!; 358 | const times = useOtherWeekCourseTimes({ 359 | courseId: course.id, 360 | classTypeId: course.classTypeId, 361 | currentWeek, 362 | }); 363 | 364 | if (times.length === 0) return; 365 | return ( 366 |
367 | {times.map((dayTimes, i) => 368 | dayTimes.map((time, j) => ( 369 |
379 | {time.classes.map((c) => ( 380 | 387 | ))} 388 |
389 | )), 390 | )} 391 |
392 | ); 393 | }; 394 | 395 | const WHEEL_SPEED = 0.08; 396 | const PINCH_SPEED = 0.03; 397 | export const Calendar = () => { 398 | const { courses, currentWeek, actions, status } = useCalendar(); 399 | const isDragging = useDraggingCourse((s) => s.isDragging); 400 | 401 | const ref = useRef(null); 402 | const setCalendarHeight = useCalendarHourHeight((s) => s.setHeight); 403 | useZoom({ 404 | ref, 405 | onWheelZoom: (deltaY) => { 406 | setCalendarHeight((h) => h - deltaY * WHEEL_SPEED); 407 | }, 408 | onPinchZoom: (distanceDiff) => { 409 | setCalendarHeight((h) => h + distanceDiff * PINCH_SPEED); 410 | }, 411 | }); 412 | 413 | const noCourses = useEnrolledCourses((s) => s.courses.length === 0); 414 | 415 | return ( 416 |
417 | 422 |
423 | 424 | 425 | {isDragging && } 426 | {!noCourses && } 427 |
428 |
429 | ); 430 | }; 431 | -------------------------------------------------------------------------------- /src/components/CourseModal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Modal, 3 | ModalBody, 4 | ModalContent, 5 | ModalHeader, 6 | Select, 7 | SelectItem, 8 | Table, 9 | TableBody, 10 | TableCell, 11 | TableColumn, 12 | TableHeader, 13 | TableRow, 14 | } from '@heroui/react'; 15 | import { useTranslation } from 'react-i18next'; 16 | import { Fragment } from 'react/jsx-runtime'; 17 | 18 | import { useGetCourseInfo } from '../data/course-info'; 19 | import { useEnrolledCourse } from '../data/enrolled-courses'; 20 | import type dayjs from '../lib/dayjs'; 21 | import type { Meetings } from '../types/course'; 22 | import type { Key } from '../types/key'; 23 | import { dateToDayjs, timeToDayjs } from '../utils/date'; 24 | import { deduplicateArray } from '../utils/deduplicate-array'; 25 | 26 | const DATE_FORMAT = 'D MMM'; 27 | const TIME_FORMAT = 'h:mm A'; 28 | const TIME_FORMAT_SHORT = 'h A'; 29 | const getDisplayDate = (date: { start: string; end: string }) => { 30 | const start = dateToDayjs(date.start); 31 | const end = dateToDayjs(date.end); 32 | if (start.isSame(end, 'day')) { 33 | return start.format(DATE_FORMAT); 34 | } 35 | return `${start.format(DATE_FORMAT)} - ${end.format(DATE_FORMAT)}`; 36 | }; 37 | const formatTime = (time: dayjs.Dayjs) => { 38 | return time.minute() === 0 39 | ? time.format(TIME_FORMAT_SHORT) 40 | : time.format(TIME_FORMAT); 41 | }; 42 | const getDisplayTime = (time: { start: string; end: string }) => { 43 | const start = timeToDayjs(time.start); 44 | const end = timeToDayjs(time.end); 45 | return `${formatTime(start)} - ${formatTime(end)}`; 46 | }; 47 | const MeetingsTime = ({ 48 | meetings, 49 | classType, 50 | }: { 51 | meetings: Meetings; 52 | classType: string; 53 | }) => { 54 | const { t } = useTranslation(); 55 | 56 | return ( 57 | 58 | 59 | {t('course-modal.dates')} 60 | {t('course-modal.days')} 61 | {t('course-modal.time')} 62 | {t('course-modal.location')} 63 | 64 | 65 | {meetings.map((meeting, i) => ( 66 | 67 | {getDisplayDate(meeting.date)} 68 | {meeting.day} 69 | {getDisplayTime(meeting.time)} 70 | {meeting.location} 71 | 72 | ))} 73 | 74 |
75 | ); 76 | }; 77 | 78 | const getPreviewMeetingInfo = (meetings: Meetings) => { 79 | const displayMeetings = meetings.map( 80 | (m) => `${m.day} ${getDisplayTime(m.time)}`, 81 | ); 82 | return deduplicateArray(displayMeetings).join(' & '); 83 | }; 84 | const getKeys = (nullableKey: Key | undefined) => { 85 | return nullableKey ? [nullableKey] : undefined; 86 | }; 87 | type CourseModalProps = { 88 | isOpen: boolean; 89 | onOpenChange: (isOpen: boolean) => void; 90 | id: string; 91 | }; 92 | export const CourseModal = ({ isOpen, onOpenChange, id }: CourseModalProps) => { 93 | const courseInfo = useGetCourseInfo(id); 94 | const { course, updateClass } = useEnrolledCourse(id); 95 | const getSelectedClassNumber = (classTypeId: string) => { 96 | const selectedClass = course?.classes.find((c) => c.id === classTypeId); 97 | return selectedClass?.classNumber; 98 | }; 99 | const getMeetings = (classTypeId: string) => { 100 | const selectedClassNumber = getSelectedClassNumber(classTypeId); 101 | if (!selectedClassNumber) return []; 102 | const selectedClass = courseInfo?.class_list 103 | .find((c) => c.id === classTypeId) 104 | ?.classes.find((c) => c.number === selectedClassNumber); 105 | return selectedClass?.meetings ?? []; 106 | }; 107 | 108 | if (!courseInfo) return; 109 | return ( 110 | 116 | 117 | {() => ( 118 | <> 119 | 120 | {courseInfo.name.subject} {courseInfo.name.code} -{' '} 121 | {courseInfo.name.title} 122 | 123 | 124 | {courseInfo.class_list.map((classType) => ( 125 | 126 | 153 | 157 | 158 | ))} 159 | 160 | 161 | )} 162 | 163 | 164 | ); 165 | }; 166 | -------------------------------------------------------------------------------- /src/components/EnrolledCourses.tsx: -------------------------------------------------------------------------------- 1 | import { Chip, useDisclosure } from '@heroui/react'; 2 | import { useQuery } from '@tanstack/react-query'; 3 | import clsx from 'clsx'; 4 | import { useState } from 'react'; 5 | 6 | import { getCourse } from '../apis'; 7 | import { useCourseColor, useEnrolledCourses } from '../data/enrolled-courses'; 8 | import { CourseModal } from './CourseModal'; 9 | 10 | type CourseChipProps = { 11 | name: string; 12 | id: string; 13 | onOpenModal: (id: string) => void; 14 | }; 15 | const CourseChip = ({ name, id, onOpenModal }: CourseChipProps) => { 16 | const color = useCourseColor(id); 17 | const removeCourse = useEnrolledCourses((s) => s.removeCourse); 18 | 19 | const { isFetching, isError } = useQuery({ 20 | queryKey: ['course', id], 21 | queryFn: () => getCourse({ id }), 22 | }); 23 | 24 | return ( 25 | { 28 | removeCourse(id); 29 | }} 30 | onClick={() => { 31 | if (isFetching || isError) return; 32 | onOpenModal(id); 33 | }} 34 | classNames={{ 35 | base: clsx( 36 | 'border-primary text-primary', 37 | isFetching ? 'cursor-wait' : 'cursor-pointer hover:brightness-125', 38 | ), 39 | dot: color.dot, 40 | }} 41 | > 42 | {isFetching && '⏳ '} 43 | {isError && '❌ '} 44 | {name} 45 | 46 | ); 47 | }; 48 | 49 | export const EnrolledCourses = () => { 50 | const courses = useEnrolledCourses((s) => s.courses); 51 | const [courseModalId, setCourseModalId] = useState(null); 52 | const courseModal = useDisclosure(); 53 | 54 | return ( 55 | <> 56 |
57 | {courses.map((c) => ( 58 | { 63 | setCourseModalId(id); 64 | courseModal.onOpen(); 65 | }} 66 | /> 67 | ))} 68 |
69 | {courseModalId && ( 70 | 75 | )} 76 | 77 | ); 78 | }; 79 | -------------------------------------------------------------------------------- /src/components/EnrolmentModal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Card, 4 | CardBody, 5 | CardHeader, 6 | Divider, 7 | Link, 8 | Modal, 9 | ModalBody, 10 | ModalContent, 11 | ModalFooter, 12 | ModalHeader, 13 | Tooltip, 14 | } from '@heroui/react'; 15 | import clsx from 'clsx'; 16 | import { useTranslation } from 'react-i18next'; 17 | 18 | import { useDetailedEnrolledCourses } from '../data/enrolled-courses'; 19 | import { useExportCalendar } from '../helpers/export-calendar'; 20 | 21 | type ReadyModalProps = { 22 | isOpen: boolean; 23 | onOpenChange: (isOpen: boolean) => void; 24 | }; 25 | export const EnrolmentModal = ({ isOpen, onOpenChange }: ReadyModalProps) => { 26 | const { t } = useTranslation(); 27 | const { copyText } = useExportCalendar(); 28 | 29 | const enrolledCourses = useDetailedEnrolledCourses(); 30 | const isOnlyCourse = enrolledCourses.length === 1; 31 | 32 | return ( 33 | 38 | 39 | 40 |
{t('calendar.end-actions.ready')}
41 |
42 | {t('calendar.end-actions.enrolment-instruction')} 43 |
44 |
45 | 46 | {enrolledCourses.map((c) => ( 47 | 48 | 49 |

50 | {c.name.subject} {c.name.code} 51 |

52 |

{c.name.title}

53 |
54 | 55 | 56 | {c.classes.map((cls) => ( 57 |
61 |
{cls.type}
62 |
{cls.classNumber}
63 |
64 | ))} 65 |
66 |
67 | ))} 68 |
69 | 70 | 71 | 79 | 80 | 88 | 89 |
90 |
91 | ); 92 | }; 93 | -------------------------------------------------------------------------------- /src/components/Error.tsx: -------------------------------------------------------------------------------- 1 | import { Code, Link } from '@heroui/react'; 2 | import type { FallbackProps } from 'react-error-boundary'; 3 | 4 | import { useMount } from '../utils/mount'; 5 | 6 | export const Error = ({ error }: FallbackProps) => { 7 | const errorMessage = String(error); 8 | const prefilledFeedbackForm = `${import.meta.env.VITE_FEEDBACK_FORM_URL_PREFILL_ERROR_MESSAGE}${errorMessage}%0ACause:+`; 9 | 10 | useMount(() => { 11 | // Clear local data to reset the app 12 | localStorage.clear(); 13 | }); 14 | 15 | return ( 16 |
17 |

Oops... Something went wrong!

18 |

19 | We're sorry, but we need to clear all saved data for this website 20 | (including any locally stored courses and times). 21 |

22 | 23 | Error Message:
24 | {errorMessage} 25 |
26 |

27 | Before refreshing the page, would you like to{' '} 28 | 29 | send us feedback 30 | {' '} 31 | along with the error message to help improve our app? 32 |

33 |
34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /src/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Divider, 3 | Modal, 4 | ModalBody, 5 | ModalContent, 6 | ModalHeader, 7 | } from '@heroui/react'; 8 | import { useState } from 'react'; 9 | import { 10 | FaDiscord, 11 | FaEnvelope, 12 | FaFacebook, 13 | FaGithub, 14 | FaInstagram, 15 | FaLinkedin, 16 | FaTiktok, 17 | FaYoutube, 18 | } from 'react-icons/fa'; 19 | 20 | import { Tips } from './Tips'; 21 | 22 | interface FooterModalProps { 23 | title: string; 24 | content: string; 25 | isOpen: boolean; 26 | onClose: () => void; 27 | } 28 | 29 | const FooterModal = ({ title, content, isOpen, onClose }: FooterModalProps) => { 30 | return ( 31 | 32 | 33 | {title} 34 | 35 |

{content}

36 |
37 |
38 |
39 | ); 40 | }; 41 | 42 | const FOOTER_SECTIONS = [ 43 | { 44 | title: 'About', 45 | content: 46 | 'MyTimetable, created by the CS Club Open Source Team, is a drag-and-drop timetable planner designed for University of Adelaide students. It allows students to easily organise, customise, and visualise their class timetables, helping them avoid clashes and optimise their weekly schedules.', 47 | }, 48 | { 49 | title: 'Disclaimer', 50 | content: 51 | 'MyTimetable is NOT an official University of Adelaide website. While we strive to provide accurate and up-to-date information, please be aware that the data may not always reflect the latest changes or updates.', 52 | }, 53 | { 54 | title: 'Privacy', 55 | content: 56 | 'MyTimetable collects anonymous analytics data to help improve user experience and enhance the functionality of the website. We may share collective data with relevant third parties to provide insights into user engagement and improve our services. We are committed to protecting your privacy and will not share any personally identifiable information.', 57 | }, 58 | ]; 59 | 60 | const LINKS = [ 61 | { icon: FaEnvelope, link: 'mailto:dev@csclub.org.au' }, 62 | { icon: FaGithub, link: 'https://github.com/compsci-adl' }, 63 | { icon: FaInstagram, link: 'https://www.instagram.com/csclub.adl/' }, 64 | { icon: FaTiktok, link: 'https://www.tiktok.com/@csclub.adl/' }, 65 | { icon: FaFacebook, link: 'https://www.facebook.com/compsci.adl/' }, 66 | { icon: FaDiscord, link: 'https://discord.gg/UjvVxHA' }, 67 | { icon: FaLinkedin, link: 'https://www.linkedin.com/company/compsci-adl/' }, 68 | { icon: FaYoutube, link: 'https://www.youtube.com/@csclub-adl/' }, 69 | ]; 70 | 71 | export const Footer = () => { 72 | const [openModal, setOpenModal] = useState(null); 73 | 74 | return ( 75 |
76 |
77 | 78 |
79 | 80 |
81 |
82 | Logo 83 |

84 | MyTimetable 85 |

86 |
87 | 88 |
89 | {FOOTER_SECTIONS.map((section, i) => ( 90 |

setOpenModal(section.title)} 94 | > 95 | {section.title} 96 |

97 | ))} 98 |
99 | 100 |
101 | © {new Date().getFullYear()} 102 | 107 | The University of Adelaide Computer Science Club 108 | 109 |
110 | 111 |
112 | {LINKS.map(({ icon: Icon, link }, i) => ( 113 | 119 | 120 | 121 | ))} 122 |
123 |
124 | 125 | {FOOTER_SECTIONS.map((section) => ( 126 | setOpenModal(null)} 132 | /> 133 | ))} 134 |
135 | ); 136 | }; 137 | -------------------------------------------------------------------------------- /src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Link, 4 | Navbar, 5 | NavbarBrand, 6 | NavbarContent, 7 | NavbarItem, 8 | Popover, 9 | PopoverContent, 10 | PopoverTrigger, 11 | Tooltip, 12 | } from '@heroui/react'; 13 | import { useState } from 'react'; 14 | import { useTranslation } from 'react-i18next'; 15 | 16 | import { LANGUAGES } from '../constants/languages'; 17 | import { useDarkMode } from '../helpers/dark-mode'; 18 | import { useHelpModal } from '../helpers/help-modal'; 19 | 20 | const HEADER_BUTTON_PROPS = { 21 | size: 'sm', 22 | isIconOnly: true, 23 | variant: 'flat', 24 | color: 'primary', 25 | className: 'text-xl', 26 | } as const; 27 | 28 | export const Header = () => { 29 | const { t, i18n } = useTranslation(); 30 | 31 | const openHelpModal = useHelpModal((s) => s.open); 32 | 33 | const [isChangeLanguageOpen, setIsChangeLanguageOpen] = useState(false); 34 | 35 | const { isDarkMode, toggleIsDarkMode } = useDarkMode(); 36 | 37 | return ( 38 | 44 | 45 | Logo 46 |

MyTimetable

47 |
48 | 49 | 50 | 51 | 54 | 55 | 56 | 57 | 58 | 65 | 66 | 67 | 68 | 69 | 72 | 73 | 74 | 75 | setIsChangeLanguageOpen(open)} 78 | > 79 | 84 |
85 | 86 | 87 | 88 |
89 |
90 | 91 | {LANGUAGES.map((language) => ( 92 | 104 | ))} 105 | 106 |
107 |
108 |
109 |
110 | ); 111 | }; 112 | -------------------------------------------------------------------------------- /src/components/HelpModal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Card, 4 | CardBody, 5 | Modal, 6 | ModalBody, 7 | ModalContent, 8 | ModalFooter, 9 | ModalHeader, 10 | Tab, 11 | Tabs, 12 | } from '@heroui/react'; 13 | import clsx from 'clsx'; 14 | import { AnimatePresence, motion } from 'framer-motion'; 15 | import { useEffect, useState } from 'react'; 16 | import { useTranslation } from 'react-i18next'; 17 | 18 | import { useHelpModal } from '../helpers/help-modal'; 19 | import { useMount } from '../utils/mount'; 20 | import { prefetchImages } from '../utils/prefetch-image'; 21 | 22 | export const HelpModal = () => { 23 | const { t } = useTranslation(); 24 | 25 | const STEPS = [ 26 | { 27 | content: t('help.steps.welcome'), 28 | image: { 29 | path: '/help/welcome.webp', 30 | alt: 'Website preview', 31 | }, 32 | }, 33 | { 34 | content: t('help.steps.select-term'), 35 | image: { 36 | path: '/help/select-term.webp', 37 | alt: 'Select a term', 38 | }, 39 | }, 40 | { 41 | content: t('help.steps.search-course'), 42 | image: { path: '/help/search-course.webp', alt: 'Search a course' }, 43 | }, 44 | { 45 | content: t('help.steps.calendar-dnd'), 46 | image: { 47 | path: '/help/calendar.webp', 48 | alt: 'Drag and drop a course in calendar', 49 | }, 50 | }, 51 | { 52 | content: t('help.steps.change-week'), 53 | image: { path: '/help/change-week.webp', alt: 'Change calendar week' }, 54 | }, 55 | { 56 | content: t('help.steps.course-details'), 57 | image: { 58 | path: '/help/click-course.webp', 59 | alt: 'Highlighted enrolled course', 60 | }, 61 | }, 62 | { 63 | content: t('help.steps.course-modal'), 64 | image: { 65 | path: '/help/modal.webp', 66 | alt: 'Course modal to change class time', 67 | }, 68 | }, 69 | { 70 | content: t('help.steps.ready-button'), 71 | image: { 72 | path: '/help/ready-button.webp', 73 | alt: 'Ready button at bottom', 74 | }, 75 | }, 76 | { 77 | content: t('help.steps.access-adelaide'), 78 | image: { 79 | path: '/help/access-adelaide.webp', 80 | alt: 'Access Adelaide enrolment', 81 | }, 82 | }, 83 | ]; 84 | 85 | useMount(() => { 86 | const imagePaths = STEPS.map((step) => step.image.path); 87 | prefetchImages(imagePaths); 88 | }); 89 | 90 | const helpModal = useHelpModal(); 91 | 92 | const [direction, setDirection] = useState(true); 93 | const [stepIndexKey, setStepIndexKey] = useState('0'); 94 | const stepIndex = Number(stepIndexKey); 95 | const setStepIndex = (index: number) => { 96 | setDirection(index >= stepIndex); 97 | setStepIndexKey(String(index)); 98 | }; 99 | const slideVariants = { 100 | enter: (direction: boolean) => ({ 101 | x: direction ? '100%' : '-100%', 102 | }), 103 | center: { x: 0 }, 104 | exit: (direction: boolean) => ({ 105 | x: direction ? '-100%' : '100%', 106 | }), 107 | }; 108 | 109 | useEffect(() => { 110 | if (!helpModal.isOpen) { 111 | setStepIndex(0); 112 | } 113 | // eslint-disable-next-line react-hooks/exhaustive-deps 114 | }, [helpModal.isOpen]); 115 | 116 | const step = STEPS[stepIndex]; 117 | 118 | return ( 119 | 125 | 126 | {t('help.title')} 127 | 128 | {/* FIXME: Tabs are missing animation when controlled */} 129 | setStepIndex(Number(step))} 133 | className="self-center" 134 | variant="underlined" 135 | > 136 | {STEPS.map((_, i) => ( 137 | 138 | ))} 139 | 140 |
141 | 142 | 152 | 153 | 154 |
{step.content}
155 |
156 | {step.image.alt} 161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 | 169 | 176 | {stepIndex < STEPS.length - 1 ? ( 177 | 184 | ) : ( 185 | 188 | )} 189 | 190 |
191 |
192 | ); 193 | }; 194 | -------------------------------------------------------------------------------- /src/components/SearchForm.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Autocomplete, 3 | AutocompleteItem, 4 | Button, 5 | Select, 6 | SelectItem, 7 | } from '@heroui/react'; 8 | import { useQuery } from '@tanstack/react-query'; 9 | import React, { useState } from 'react'; 10 | import { useTranslation } from 'react-i18next'; 11 | import { toast } from 'sonner'; 12 | 13 | import { getCourses, getSubjects } from '../apis'; 14 | import { LocalStorageKey } from '../constants/local-storage-keys'; 15 | import { TERMS } from '../constants/terms'; 16 | import { YEAR } from '../constants/year'; 17 | import { useEnrolledCourses } from '../data/enrolled-courses'; 18 | import type { Key } from '../types/key'; 19 | 20 | export const SearchForm = () => { 21 | const { t } = useTranslation(); 22 | 23 | const enrolledCourses = useEnrolledCourses(); 24 | 25 | const [selectedTerm, setSelectedTerm] = useState( 26 | localStorage.getItem(LocalStorageKey.Term) ?? 'sem1', 27 | ); 28 | const changeTerm = (term: string) => { 29 | setSelectedTerm(term); 30 | localStorage.setItem(LocalStorageKey.Term, term); 31 | }; 32 | const isTermSelectDisabled = enrolledCourses.courses.length > 0; 33 | 34 | const subjectsQuery = useQuery({ 35 | queryKey: ['subjects', { year: YEAR, term: selectedTerm }] as const, 36 | queryFn: ({ queryKey }) => getSubjects(queryKey[1]), 37 | }); 38 | const subjectList = 39 | subjectsQuery.data?.subjects.map(({ code, name }) => ({ 40 | code, 41 | name: `${code} - ${name}`, 42 | })) ?? []; 43 | const [subject, setSubject] = useState(null); 44 | 45 | const coursesQuery = useQuery({ 46 | // TODO: Replace params with config data 47 | queryKey: [ 48 | 'courses', 49 | { year: YEAR, term: selectedTerm, subject: subject! }, 50 | ] as const, 51 | queryFn: ({ queryKey }) => getCourses(queryKey[1]), 52 | enabled: subject !== null, 53 | }); 54 | const courses = coursesQuery.data?.courses; 55 | const courseList = 56 | courses?.map((c) => ({ 57 | id: c.id, 58 | name: `${c.name.subject} ${c.name.code} - ${c.name.title}`, 59 | })) ?? []; 60 | const [selectedCourseId, setSelectedCourseId] = useState(null); 61 | 62 | const courseSearchFilter = (text: string, input: string) => { 63 | text = text.normalize('NFC'); 64 | const courseName = text.split(' - ')[1]; 65 | const courseAbbr = ( 66 | courseName.match(/[A-Z]/g)?.join('') ?? '' 67 | ).toLowerCase(); 68 | text = text.toLocaleLowerCase(); 69 | input = input.normalize('NFC').toLocaleLowerCase(); 70 | return text.includes(input) || courseAbbr.includes(input); 71 | }; 72 | 73 | const handleSubmit = async (e: React.FormEvent) => { 74 | e.preventDefault(); 75 | const course = courses?.find((c) => c.id === selectedCourseId); 76 | if (!course) return; 77 | const name = `${course.name.subject} ${course.name.code}`; 78 | await umami.track('Add course', { subject: course.name.subject, name }); 79 | enrolledCourses.addCourse({ 80 | name, 81 | id: course.id, 82 | }); 83 | setSelectedCourseId(null); 84 | }; 85 | 86 | return ( 87 |
88 |
{ 90 | if (!isTermSelectDisabled) return; 91 | toast.warning(t('toast.drop-to-change-term')); 92 | }} 93 | > 94 | 106 |
107 | setSubject(key as string)} 113 | listboxProps={{ emptyContent: t('search.subject-not-found') }} 114 | > 115 | {(subject) => ( 116 | {subject.name} 117 | )} 118 | 119 |
123 | c.id)} 130 | listboxProps={{ emptyContent: t('search.course-not-found') }} 131 | defaultFilter={courseSearchFilter} 132 | > 133 | {(course) => ( 134 | {course.name} 135 | )} 136 | 137 | 145 |
146 |
147 | ); 148 | }; 149 | -------------------------------------------------------------------------------- /src/components/Tips.tsx: -------------------------------------------------------------------------------- 1 | import { AnimatePresence, motion } from 'framer-motion'; 2 | import { useEffect, useState } from 'react'; 3 | 4 | import { shuffle } from '../utils/shuffle'; 5 | 6 | const TIPS = [ 7 | <> 8 | Pinch on your trackpad or touchscreen to zoom in and out of your timetable. 9 | , 10 | <> 11 | Easily export your calendar to your devices via{' '} 12 | AUDIT. 13 | , 14 | <> 15 | Easily export your calendar to your devices via{' '} 16 | AUDIT Monkey. 17 | , 18 | <>The class number will be copied if you drag it outside the window., 19 | <> 20 | We're looking for{' '} 21 | 22 | translations in multiple languages 23 | 24 | . 25 | , 26 | <> 27 | Share your thoughts to help us improve via{' '} 28 | feedback. 29 | , 30 | <> 31 | Check the campus map if 32 | you can't find a location. 33 | , 34 | <> 35 | This project is open-source on{' '} 36 | GitHub 37 | , 38 | <> 39 | Join the CS Club, a community open to 40 | everyone interested in computer science. 41 | , 42 | <> 43 | Join the{' '} 44 | CS Club Open Source Team to 45 | work on projects like this! 46 | , 47 | <>You can search for courses using abbreviations, 48 | ]; 49 | 50 | const tips = shuffle(TIPS); 51 | const TIP_ANIMATION_SPEED = 8; 52 | 53 | export const Tips = () => { 54 | const [tipIndex, setTipIndex] = useState(0); 55 | useEffect(() => { 56 | const interval = setInterval(() => { 57 | setTipIndex((t) => (t + 1) % TIPS.length); 58 | }, TIP_ANIMATION_SPEED * 1000); 59 | return () => clearInterval(interval); 60 | }, []); 61 | 62 | return ( 63 | 64 | 72 | 💡 {tips[tipIndex]} 73 | 74 | 75 | ); 76 | }; 77 | -------------------------------------------------------------------------------- /src/components/ZoomButtons.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Tooltip } from '@heroui/react'; 2 | import { useTranslation } from 'react-i18next'; 3 | import { FaMinus, FaPlus } from 'react-icons/fa'; 4 | 5 | import { 6 | MAX_HOUR_HEIGHT, 7 | MIN_HOUR_HEIGHT, 8 | useCalendarHourHeight, 9 | } from '../helpers/calendar-hour-height'; 10 | 11 | export const ZoomButtons = () => { 12 | const { t } = useTranslation(); 13 | 14 | const { height, setHeight } = useCalendarHourHeight(); 15 | 16 | return ( 17 |
18 | 19 | 27 | 28 | 29 | 37 | 38 |
39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /src/constants/course-colors.ts: -------------------------------------------------------------------------------- 1 | const BLUE = { 2 | bg: 'bg-apple-blue-300', 3 | border: 'border-apple-blue-500', 4 | text: 'text-apple-blue-700', 5 | dot: 'bg-apple-blue-500', 6 | }; 7 | const PURPLE = { 8 | bg: 'bg-apple-purple-300', 9 | border: 'border-apple-purple-500', 10 | text: 'text-apple-purple-700', 11 | dot: 'bg-apple-purple-500', 12 | }; 13 | const GREEN = { 14 | bg: 'bg-apple-green-300', 15 | border: 'border-apple-green-500', 16 | text: 'text-apple-green-700', 17 | dot: 'bg-apple-green-500', 18 | }; 19 | const ORANGE = { 20 | bg: 'bg-apple-orange-300', 21 | border: 'border-apple-orange-500', 22 | text: 'text-apple-orange-700', 23 | dot: 'bg-apple-orange-500', 24 | }; 25 | const YELLOW = { 26 | bg: 'bg-apple-yellow-300', 27 | border: 'border-apple-yellow-500', 28 | text: 'text-apple-yellow-700', 29 | dot: 'bg-apple-yellow-500', 30 | }; 31 | const BROWN = { 32 | bg: 'bg-apple-brown-300', 33 | border: 'border-apple-brown-500', 34 | text: 'text-apple-brown-700', 35 | dot: 'bg-apple-brown-500', 36 | }; 37 | const RED = { 38 | bg: 'bg-apple-red-300', 39 | border: 'border-apple-red-500', 40 | text: 'text-apple-red-700', 41 | dot: 'bg-apple-red-500', 42 | }; 43 | 44 | export const COURSE_COLORS = [BLUE, PURPLE, GREEN, ORANGE, YELLOW, RED, BROWN]; 45 | 46 | export const NOT_FOUND_COLOR = { 47 | bg: 'bg-not-found-300', 48 | border: 'border-not-found-500', 49 | text: 'text-not-found-700', 50 | dot: 'bg-not-found-500', 51 | }; 52 | -------------------------------------------------------------------------------- /src/constants/languages.ts: -------------------------------------------------------------------------------- 1 | export const LANGUAGES = [ 2 | { code: 'en-AU', name: 'English', flag: '🇦🇺' }, 3 | { code: 'zh-CN', name: '中文', flag: '🇨🇳' }, 4 | ]; 5 | -------------------------------------------------------------------------------- /src/constants/local-storage-keys.ts: -------------------------------------------------------------------------------- 1 | export const enum LocalStorageKey { 2 | Term = 'MTT.term', 3 | EnrolledCourses = 'MTT.enrolled-courses', 4 | FirstTime = 'MTT.first-time', 5 | CalendarHeight = 'MTT.calendar-height', 6 | } 7 | -------------------------------------------------------------------------------- /src/constants/terms.ts: -------------------------------------------------------------------------------- 1 | export const TERMS = [ 2 | { alias: 'sem', name: 'Semester', period: 2 }, 3 | { alias: 'tri', name: 'Trimester', period: 3 }, 4 | { alias: 'summer', name: 'Summer School', period: 0 }, 5 | { alias: 'winter', name: 'Winter School', period: 0 }, 6 | { alias: 'elc', name: 'ELC Term', period: 3 }, 7 | { alias: 'term', name: 'Term', period: 4 }, 8 | { alias: 'ol', name: 'Online Teaching Period', period: 6 }, 9 | { alias: 'melb', name: 'Melb Teaching Period', period: 3 }, 10 | { alias: 'pce', name: 'PCE Term', period: 3 }, 11 | ].reduce( 12 | (acc, { alias, name, period }) => { 13 | if (period === 0) { 14 | acc.push({ alias, name }); 15 | return acc; 16 | } 17 | acc.push( 18 | ...Array.from({ length: period }, (_, i) => ({ 19 | alias: `${alias}${i + 1}`, 20 | name: `${name} ${i + 1}`, 21 | })), 22 | ); 23 | return acc; 24 | }, 25 | [] as Array<{ alias: string; name: string }>, 26 | ); 27 | -------------------------------------------------------------------------------- /src/constants/week-days.ts: -------------------------------------------------------------------------------- 1 | export const WEEK_DAYS = [ 2 | 'Monday', 3 | 'Tuesday', 4 | 'Wednesday', 5 | 'Thursday', 6 | 'Friday', 7 | ] as const; 8 | export type WeekDay = (typeof WEEK_DAYS)[number]; 9 | -------------------------------------------------------------------------------- /src/constants/year.ts: -------------------------------------------------------------------------------- 1 | export const YEAR = Number(import.meta.env.VITE_YEAR); 2 | -------------------------------------------------------------------------------- /src/data/course-info.ts: -------------------------------------------------------------------------------- 1 | import { useQueries, useQueryClient } from '@tanstack/react-query'; 2 | import { useEffect, useState } from 'react'; 3 | 4 | import { getCourse } from '../apis'; 5 | import type { Course } from '../types/course'; 6 | import { useEnrolledCourses } from './enrolled-courses'; 7 | 8 | export const useGetCourseInfo = (id: string) => { 9 | const queryClient = useQueryClient(); 10 | const [course, setCourse] = useState(null); 11 | 12 | useEffect(() => { 13 | const data = queryClient.getQueryData(['course', id]); 14 | setCourse(data ?? null); 15 | }, [id, queryClient]); 16 | 17 | return course; 18 | }; 19 | 20 | export const useGetCourseClasses = (courseId: string, classTypeId: string) => { 21 | const course = useGetCourseInfo(courseId); 22 | if (!course) return null; 23 | const classType = course.class_list.find((c) => c.id === classTypeId); 24 | if (!classType) return null; 25 | return classType.classes; 26 | }; 27 | 28 | export const useCoursesInfo = () => { 29 | const courses = useEnrolledCourses((c) => c.courses); 30 | const coursesIds = courses.map((course) => course.id); 31 | const data = useQueries({ 32 | queries: coursesIds.map((id) => ({ 33 | queryKey: ['course', id], 34 | queryFn: () => getCourse({ id }), 35 | })), 36 | }); 37 | return data.map((d) => d.data).filter(Boolean) as Course[]; 38 | }; 39 | -------------------------------------------------------------------------------- /src/data/enrolled-courses.ts: -------------------------------------------------------------------------------- 1 | import { toast } from 'sonner'; 2 | import { create } from 'zustand'; 3 | import { mutative } from 'zustand-mutative'; 4 | import { persist } from 'zustand/middleware'; 5 | 6 | import { getCourse } from '../apis'; 7 | import { COURSE_COLORS, NOT_FOUND_COLOR } from '../constants/course-colors'; 8 | import { LocalStorageKey } from '../constants/local-storage-keys'; 9 | import i18n from '../i18n'; 10 | import { queryClient } from '../lib/query'; 11 | import type { DetailedEnrolledCourse } from '../types/course'; 12 | import { useCoursesInfo } from './course-info'; 13 | 14 | type Course = { 15 | id: string; 16 | name: string; 17 | classes: Array<{ 18 | id: string; // Class type (Lecture / Workshop / etc.) ID 19 | classNumber: string; // Meeting time number (ID) 20 | }>; 21 | color: number; // Color index in COURSE_COLORS 22 | }; 23 | type Courses = Array; 24 | type CoursesState = { 25 | courses: Courses; 26 | addCourse: (course: Omit) => void; 27 | removeCourse: (courseId: string) => void; 28 | updateCourseClass: (props: { 29 | courseId: string; 30 | classTypeId: string; 31 | classNumber: string; 32 | }) => void; 33 | }; 34 | 35 | export const useEnrolledCourses = create()( 36 | persist( 37 | mutative((set, get) => ({ 38 | courses: [], 39 | addCourse: async (course) => { 40 | // Limit to 7 courses 41 | const currentCourses = get().courses; 42 | if (currentCourses.length >= 7) { 43 | toast.error(i18n.t('toast.too-many-courses')); 44 | return currentCourses; 45 | } 46 | // Generate a color index 47 | let color = 0; 48 | if (currentCourses.length > 0) { 49 | const maxColor = Math.max(...currentCourses.map((c) => c.color)); 50 | color = (maxColor + 1) % COURSE_COLORS.length; 51 | } 52 | // Add course to state 53 | set((state) => { 54 | state.courses.push({ ...course, classes: [], color }); 55 | }); 56 | // Fetch course data 57 | const data = await queryClient.ensureQueryData({ 58 | queryKey: ['course', course.id] as const, 59 | queryFn: ({ queryKey }) => getCourse({ id: queryKey[1] }), 60 | }); 61 | // Initialize course classes to default 62 | set((state) => { 63 | const enrolledCourse = state.courses.find((c) => c.id === course.id); 64 | if (!enrolledCourse) return; 65 | enrolledCourse.classes = data.class_list.map((c) => ({ 66 | id: c.id, 67 | classNumber: c.classes[0].number, 68 | })); 69 | }); 70 | }, 71 | removeCourse: (courseId) => { 72 | set((state) => { 73 | state.courses = state.courses.filter((c) => c.id !== courseId); 74 | }); 75 | }, 76 | updateCourseClass: ({ courseId, classTypeId, classNumber }) => { 77 | set((state) => { 78 | const course = state.courses.find((c) => c.id === courseId); 79 | if (!course) return; 80 | const classType = course.classes.find((c) => c.id === classTypeId); 81 | if (!classType) return; 82 | classType.classNumber = classNumber; 83 | }); 84 | }, 85 | })), 86 | { name: LocalStorageKey.EnrolledCourses, version: 0 }, 87 | ), 88 | ); 89 | 90 | export const useEnrolledCourse = (id: string) => { 91 | const course = useEnrolledCourses((s) => s.courses.find((c) => c.id === id)); 92 | const updateCourseClass = useEnrolledCourses((s) => s.updateCourseClass); 93 | const updateClass = (props: { classTypeId: string; classNumber: string }) => { 94 | updateCourseClass({ courseId: id, ...props }); 95 | }; 96 | return { course, updateClass }; 97 | }; 98 | 99 | export const useEnrolledCourseClassNumber = ( 100 | courseId: string, 101 | classTypeId: string, 102 | ) => { 103 | const course = useEnrolledCourses((s) => 104 | s.courses.find((c) => c.id === courseId), 105 | ); 106 | const classType = course?.classes.find((c) => c.id === classTypeId); 107 | return classType?.classNumber; 108 | }; 109 | 110 | export const useDetailedEnrolledCourses = (): Array => { 111 | const coursesInfo = useCoursesInfo(); 112 | 113 | const courses = useEnrolledCourses((s) => s.courses); 114 | const detailedCourses = courses.map((course) => { 115 | const courseInfo = coursesInfo.find((c) => c.id === course.id); 116 | if (!courseInfo) return null; 117 | return { 118 | ...course, 119 | name: courseInfo.name, 120 | classes: course.classes.map((cls) => { 121 | const notFound = { 122 | typeId: cls.id, 123 | type: 'NOT_FOUND', 124 | classNumber: '66666', 125 | meetings: [], 126 | }; 127 | const classInfo = courseInfo.class_list.find((c) => c.id === cls.id); 128 | if (!classInfo) return notFound; 129 | const { type, classes } = classInfo; 130 | const meetings = classes.find( 131 | (c) => c.number === cls.classNumber, 132 | )?.meetings; 133 | if (!meetings) return notFound; 134 | return { typeId: cls.id, type, classNumber: cls.classNumber, meetings }; 135 | }), 136 | }; 137 | }); 138 | 139 | return detailedCourses.filter((c) => c !== null); 140 | }; 141 | 142 | export const useCourseColor = (id: string) => { 143 | const colorIndex = useEnrolledCourses( 144 | (s) => s.courses.find((c) => c.id === id)?.color, 145 | ); 146 | if (colorIndex === undefined) return NOT_FOUND_COLOR; 147 | return COURSE_COLORS[colorIndex]; 148 | }; 149 | -------------------------------------------------------------------------------- /src/helpers/calendar-hour-height.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { persist } from 'zustand/middleware'; 3 | 4 | import { LocalStorageKey } from '../constants/local-storage-keys'; 5 | 6 | export const MIN_HOUR_HEIGHT = 3; 7 | export const MAX_HOUR_HEIGHT = 10; 8 | export const useCalendarHourHeight = create<{ 9 | height: number; 10 | setHeight: (getNewHeight: (height: number) => number) => void; 11 | }>()( 12 | persist( 13 | (set) => ({ 14 | height: 4.5, 15 | setHeight: (getNewHeight) => 16 | set((state) => { 17 | const height = getNewHeight(state.height); 18 | return { 19 | height: Math.min( 20 | Math.max(height, MIN_HOUR_HEIGHT), 21 | MAX_HOUR_HEIGHT, 22 | ), 23 | }; 24 | }), 25 | }), 26 | { name: LocalStorageKey.CalendarHeight }, 27 | ), 28 | ); 29 | -------------------------------------------------------------------------------- /src/helpers/calendar.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { WEEK_DAYS } from '../constants/week-days'; 4 | import { useGetCourseClasses } from '../data/course-info'; 5 | import { 6 | useDetailedEnrolledCourses, 7 | useEnrolledCourseClassNumber, 8 | } from '../data/enrolled-courses'; 9 | import dayjs from '../lib/dayjs'; 10 | import type { 11 | DateTimeRange, 12 | DetailedEnrolledCourse, 13 | OtherWeekCourseTime, 14 | OtherWeekCoursesTimes, 15 | WeekCourse, 16 | WeekCourses, 17 | } from '../types/course'; 18 | import { dateToDayjs, getMonday, timeToDayjs } from '../utils/date'; 19 | 20 | const MAX_DATE = dayjs('2900-12-12'); 21 | const MIN_DATE = dayjs('1900-01-01'); 22 | const CURRENT_MONDAY = getMonday(dayjs()); 23 | 24 | /** 25 | * Get the start date (Monday) of the start and end week 26 | * @param dates Dates for all enrolled meetings 27 | * @returns Tuple of dates of the start and end week 28 | */ 29 | export const getStartEndWeek = ( 30 | dates: Array<{ start: string; end: string }>, 31 | ): [dayjs.Dayjs, dayjs.Dayjs] => { 32 | if (dates.length === 0) return [CURRENT_MONDAY, CURRENT_MONDAY]; 33 | 34 | let startWeek = MAX_DATE; 35 | let endWeek = MIN_DATE; 36 | dates.forEach((date) => { 37 | const start = dateToDayjs(date.start); 38 | const end = dateToDayjs(date.end); 39 | if (start.isBefore(startWeek)) { 40 | startWeek = start; 41 | } 42 | if (end.isAfter(endWeek)) { 43 | endWeek = end; 44 | } 45 | }); 46 | 47 | return [getMonday(startWeek), getMonday(endWeek)]; 48 | }; 49 | 50 | const checkDateRangeInWeek = ( 51 | weekStart: dayjs.Dayjs, 52 | dateRange: DateTimeRange, 53 | ) => { 54 | const weekEnd = weekStart.add(4, 'days'); 55 | return ( 56 | weekEnd.isSameOrAfter(dateToDayjs(dateRange.start)) && 57 | weekStart.isSameOrBefore(dateToDayjs(dateRange.end)) 58 | ); 59 | }; 60 | 61 | /** 62 | * Get courses for each day of the week 63 | * @param weekStart Start of the week (Monday) 64 | * @param enrolledCourses All detailed enrolled courses 65 | * @returns Object with courses for each day of the week 66 | */ 67 | export const getWeekCourses = ( 68 | weekStart: dayjs.Dayjs, 69 | enrolledCourses: Array, 70 | ): WeekCourses => { 71 | const courses: WeekCourses = [[], [], [], [], []]; 72 | 73 | enrolledCourses.forEach((c) => { 74 | c.classes.forEach((cls) => { 75 | cls.meetings.forEach((m) => { 76 | const isMeetingInWeek = checkDateRangeInWeek(weekStart, m.date); 77 | if (!isMeetingInWeek) return; 78 | const course = courses[WEEK_DAYS.indexOf(m.day)]; 79 | const newCourse: WeekCourse = { 80 | id: c.id, 81 | name: c.name, 82 | classTypeId: cls.typeId, 83 | classType: cls.type, 84 | location: m.location, 85 | classNumber: cls.classNumber, 86 | }; 87 | const existingTime = course.find( 88 | (t) => t.time.start === m.time.start && t.time.end === m.time.end, 89 | ); 90 | if (existingTime) { 91 | existingTime.courses.push(newCourse); 92 | return; 93 | } 94 | const newTime = m.time; 95 | course.push({ time: newTime, courses: [newCourse] }); 96 | }); 97 | }); 98 | }); 99 | 100 | // TODO: Remove this sorting after implementing course conflicts #5 101 | courses.forEach((dayCourses) => { 102 | // Sort by duration (longest first) and start time (earliest first) 103 | dayCourses.sort((a, b) => { 104 | const aStart = timeToDayjs(a.time.start); 105 | const aEnd = timeToDayjs(a.time.end); 106 | const bStart = timeToDayjs(b.time.start); 107 | const bEnd = timeToDayjs(b.time.end); 108 | 109 | const aDuration = aEnd.diff(aStart, 'minute'); 110 | const bDuration = bEnd.diff(bStart, 'minute'); 111 | 112 | if (aDuration === bDuration) { 113 | if (aStart.isBefore(bStart)) return -1; 114 | if (aStart.isAfter(bStart)) return 1; 115 | return 0; 116 | } 117 | 118 | return bDuration - aDuration; 119 | }); 120 | }); 121 | 122 | return courses; 123 | }; 124 | 125 | export const useCalendar = () => { 126 | const enrolledCourses = useDetailedEnrolledCourses(); 127 | 128 | const dates = enrolledCourses.flatMap((c) => 129 | c.classes.flatMap((cls) => cls.meetings.flatMap((m) => m.date)), 130 | ); 131 | const [startWeek, endWeek] = getStartEndWeek(dates); 132 | 133 | const [currentWeek, setCurrentWeek] = useState(CURRENT_MONDAY); 134 | 135 | // Every time enrolled courses change, reset the current week to the start week 136 | useEffect(() => { 137 | if (enrolledCourses.length === 0) return; 138 | setCurrentWeek(startWeek); 139 | // eslint-disable-next-line react-hooks/exhaustive-deps 140 | }, [enrolledCourses.length]); 141 | 142 | useEffect(() => { 143 | if (currentWeek.isBefore(startWeek) || currentWeek.isAfter(endWeek)) { 144 | setCurrentWeek(startWeek); 145 | } 146 | /* eslint-disable react-hooks/exhaustive-deps */ 147 | }, [ 148 | startWeek.format('MMDD'), 149 | endWeek.format('MMDD'), 150 | currentWeek.format('MMDD'), 151 | ]); 152 | /* eslint-enable react-hooks/exhaustive-deps */ 153 | 154 | const nextWeek = () => { 155 | if (currentWeek.isSame(endWeek)) return; 156 | setCurrentWeek((c) => c.add(1, 'week')); 157 | }; 158 | const prevWeek = () => { 159 | if (currentWeek.isSame(startWeek)) return; 160 | setCurrentWeek((c) => c.subtract(1, 'week')); 161 | }; 162 | const goToStartWeek = () => { 163 | setCurrentWeek(startWeek); 164 | }; 165 | const goToEndWeek = () => { 166 | setCurrentWeek(endWeek); 167 | }; 168 | 169 | const courses = getWeekCourses(currentWeek, enrolledCourses); 170 | 171 | const isStartWeek = currentWeek.isSame(startWeek); 172 | const isEndWeek = currentWeek.isSame(endWeek); 173 | 174 | return { 175 | courses, 176 | currentWeek, 177 | actions: { prevWeek, nextWeek, goToStartWeek, goToEndWeek }, 178 | status: { isStartWeek, isEndWeek }, 179 | }; 180 | }; 181 | 182 | export const useOtherWeekCourseTimes = ({ 183 | courseId, 184 | classTypeId, 185 | currentWeek, 186 | }: { 187 | courseId: string; 188 | classTypeId: string; 189 | currentWeek: dayjs.Dayjs; 190 | }) => { 191 | const classes = useGetCourseClasses(courseId, classTypeId); 192 | const currentClassNumber = useEnrolledCourseClassNumber( 193 | courseId, 194 | classTypeId, 195 | ); 196 | if (!classes) return []; 197 | const times: OtherWeekCoursesTimes = [[], [], [], [], []]; 198 | classes.forEach((cls) => { 199 | cls.meetings.forEach((m) => { 200 | if (cls.number === currentClassNumber) return; 201 | const isMeetingInWeek = checkDateRangeInWeek(currentWeek, m.date); 202 | if (!isMeetingInWeek) return; 203 | const time = times[WEEK_DAYS.indexOf(m.day)]; 204 | const existingTime = time.find( 205 | (t) => t.time.start === m.time.start && t.time.end === m.time.end, 206 | ); 207 | const newClass = { number: cls.number, location: m.location }; 208 | if (existingTime) { 209 | existingTime.classes.push(newClass); 210 | return; 211 | } 212 | const newTime: OtherWeekCourseTime = { 213 | classes: [newClass], 214 | time: m.time, 215 | }; 216 | time.push(newTime); 217 | }); 218 | }); 219 | 220 | return times; 221 | }; 222 | -------------------------------------------------------------------------------- /src/helpers/dark-mode.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | export const useDarkMode = () => { 4 | const [isDarkMode, setIsDarkMode] = useState( 5 | matchMedia('(prefers-color-scheme: dark)').matches, 6 | ); 7 | const toggleIsDarkMode = () => { 8 | document.body.classList.remove('dark:dark'); 9 | if (isDarkMode) { 10 | document.body.classList.remove('dark'); 11 | } else { 12 | document.body.classList.add('dark'); 13 | } 14 | setIsDarkMode((m) => !m); 15 | }; 16 | 17 | return { isDarkMode, toggleIsDarkMode }; 18 | }; 19 | -------------------------------------------------------------------------------- /src/helpers/export-calendar.ts: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'react-i18next'; 2 | import { toast } from 'sonner'; 3 | 4 | import { useDetailedEnrolledCourses } from '../data/enrolled-courses'; 5 | 6 | export const useExportCalendar = () => { 7 | const { t } = useTranslation(); 8 | 9 | const enrolledCourses = useDetailedEnrolledCourses(); 10 | const copyText = async () => { 11 | const res = enrolledCourses.map((c) => ({ 12 | name: c.name.title + '\n' + c.name.subject + ' ' + c.name.code, 13 | classes: c.classes 14 | .map(({ type, classNumber }) => type + ': ' + classNumber) 15 | .join('\n'), 16 | })); 17 | const resStr = res.map((d) => d.name + '\n\n' + d.classes).join('\n\n'); 18 | const advertisement = 19 | 'Planned with MyTimetable\nhttps://mytimetable.csclub.org.au/'; 20 | await navigator.clipboard.writeText(resStr + '\n\n\n' + advertisement); 21 | toast.success(t('calendar.end-actions.copy-success')); 22 | }; 23 | return { copyText }; 24 | }; 25 | -------------------------------------------------------------------------------- /src/helpers/help-modal.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | 3 | import { LocalStorageKey } from '../constants/local-storage-keys'; 4 | import { useMount } from '../utils/mount'; 5 | 6 | type HelpModalState = { 7 | isOpen: boolean; 8 | close: () => void; 9 | open: () => void; 10 | }; 11 | export const useHelpModal = create()((set) => ({ 12 | isOpen: false, 13 | close: () => set({ isOpen: false }), 14 | open: () => set({ isOpen: true }), 15 | })); 16 | 17 | export const useFirstTimeHelp = () => { 18 | const helpModal = useHelpModal(); 19 | useMount(() => { 20 | const isFirstTime = 21 | localStorage.getItem(LocalStorageKey.FirstTime) === null; 22 | if (!isFirstTime) return; 23 | localStorage.setItem(LocalStorageKey.FirstTime, 'false'); 24 | helpModal.open(); 25 | }); 26 | }; 27 | -------------------------------------------------------------------------------- /src/helpers/hours-duration.ts: -------------------------------------------------------------------------------- 1 | import type { DateTimeRange } from '../types/course'; 2 | import { timeToDayjs } from '../utils/date'; 3 | 4 | export const calcHoursDuration = (range: DateTimeRange) => { 5 | const start = timeToDayjs(range.start); 6 | const end = timeToDayjs(range.end); 7 | const minutes = end.diff(start, 'minute'); 8 | 9 | // Convert to hours and round to nearest 0.5 10 | const hours = minutes / 60; 11 | return Math.round(hours * 2) / 2; 12 | }; 13 | -------------------------------------------------------------------------------- /src/helpers/zoom.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from 'react'; 2 | 3 | type UseZoomProps = { 4 | ref: React.RefObject; 5 | onWheelZoom: (deltaY: number) => void; 6 | onPinchZoom: (distanceDiff: number) => void; 7 | }; 8 | export const useZoom = ({ ref, onPinchZoom, onWheelZoom }: UseZoomProps) => { 9 | // Pinch (Tablet or phone) zoom 10 | // FIXME: Not so smooth on safari, sometimes detect as scroll 11 | const pointers = useRef([]); 12 | const prevDistance = useRef(-1); 13 | const onPointerDown = useCallback((e: PointerEvent) => { 14 | pointers.current.push(e); 15 | }, []); 16 | const onPointerMove = useCallback( 17 | (e: PointerEvent) => { 18 | if (pointers.current.length !== 2) return; 19 | 20 | const index = pointers.current.findIndex( 21 | (p) => p.pointerId === e.pointerId, 22 | ); 23 | if (index === -1) return; 24 | pointers.current[index] = e; 25 | 26 | const firstPointer = pointers.current[0]; 27 | const secondPointer = pointers.current[1]; 28 | const currentDistance = Math.hypot( 29 | secondPointer.clientX - firstPointer.clientX, 30 | secondPointer.clientY - firstPointer.clientY, 31 | ); 32 | const distanceDiff = currentDistance - prevDistance.current; 33 | if (prevDistance.current !== -1) { 34 | onPinchZoom(distanceDiff); 35 | } 36 | prevDistance.current = currentDistance; 37 | }, 38 | [onPinchZoom], 39 | ); 40 | const onPointerUp = useCallback((e: PointerEvent) => { 41 | pointers.current = pointers.current.filter( 42 | (p) => p.pointerId !== e.pointerId, 43 | ); 44 | if (pointers.current.length < 2) { 45 | prevDistance.current = -1; 46 | } 47 | }, []); 48 | 49 | // Trackpad (Laptop) zoom 50 | const onWheel = useCallback( 51 | (e: WheelEvent) => { 52 | // Check if user is scrolling 53 | if (e.deltaY % 1 === 0) return; 54 | e.preventDefault(); 55 | e.stopPropagation(); 56 | onWheelZoom(e.deltaY); 57 | }, 58 | [onWheelZoom], 59 | ); 60 | 61 | useEffect(() => { 62 | const element = ref.current; 63 | element?.addEventListener('wheel', onWheel, { passive: false }); 64 | element?.addEventListener('pointerdown', onPointerDown); 65 | element?.addEventListener('pointermove', onPointerMove); 66 | element?.addEventListener('pointerup', onPointerUp); 67 | element?.addEventListener('pointercancel', onPointerUp); 68 | element?.addEventListener('pointerout', onPointerUp); 69 | element?.addEventListener('pointerleave', onPointerUp); 70 | return () => { 71 | element?.removeEventListener('wheel', onWheel); 72 | element?.removeEventListener('pointerdown', onPointerDown); 73 | element?.removeEventListener('pointermove', onPointerMove); 74 | element?.removeEventListener('pointerup', onPointerUp); 75 | element?.removeEventListener('pointercancel', onPointerUp); 76 | element?.removeEventListener('pointerout', onPointerUp); 77 | element?.removeEventListener('pointerleave', onPointerUp); 78 | }; 79 | // eslint-disable-next-line react-hooks/exhaustive-deps 80 | }, [onWheel, onPointerDown, onPointerMove, onPointerUp]); 81 | }; 82 | -------------------------------------------------------------------------------- /src/i18n.ts: -------------------------------------------------------------------------------- 1 | import i18n from 'i18next'; 2 | import LanguageDetector from 'i18next-browser-languagedetector'; 3 | import { initReactI18next } from 'react-i18next'; 4 | 5 | import enAU from './locales/en-au.json'; 6 | import zhCN from './locales/zh-cn.json'; 7 | 8 | i18n 9 | .use(initReactI18next) 10 | .use(LanguageDetector) 11 | .init({ 12 | resources: { 13 | 'en-AU': { translation: enAU }, 14 | 'zh-CN': { translation: zhCN }, 15 | }, 16 | fallbackLng: 'en-AU', 17 | returnObjects: true, 18 | }); 19 | 20 | export default i18n; 21 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&display=swap'); 2 | 3 | @tailwind base; 4 | @tailwind components; 5 | @tailwind utilities; 6 | 7 | * { 8 | font-family: 'Outfit Variable', sans-serif; 9 | } 10 | -------------------------------------------------------------------------------- /src/lib/dayjs.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import customParseFormat from 'dayjs/plugin/customParseFormat'; 3 | import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'; 4 | import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; 5 | import isoWeek from 'dayjs/plugin/isoWeek'; 6 | 7 | dayjs.extend(customParseFormat); 8 | dayjs.extend(isoWeek); 9 | dayjs.extend(isSameOrBefore); 10 | dayjs.extend(isSameOrAfter); 11 | 12 | export default dayjs; 13 | -------------------------------------------------------------------------------- /src/lib/query.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from '@tanstack/react-query'; 2 | 3 | export const queryClient = new QueryClient({ 4 | defaultOptions: { 5 | queries: { 6 | refetchOnWindowFocus: false, 7 | refetchOnMount: false, 8 | refetchOnReconnect: false, 9 | }, 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /src/locales/en-au.json: -------------------------------------------------------------------------------- 1 | { 2 | "search": { 3 | "select-term": "Select a term", 4 | "search-course": "Search a course", 5 | "add": "Add", 6 | "course-not-found": "No course found", 7 | "choose-subject": "Choose a subject area", 8 | "subject-not-found": "No subject found" 9 | }, 10 | "header": { 11 | "help": "Help", 12 | "feedback": "Feedback", 13 | "change-language": "Change Language", 14 | "toggle-dark-mode": "Toggle Dark Mode" 15 | }, 16 | "course-modal": { 17 | "dates": "Dates", 18 | "days": "Days", 19 | "time": "Time", 20 | "location": "Location" 21 | }, 22 | "calendar": { 23 | "first-week": "First week", 24 | "previous-week": "Previous week", 25 | "next-week": "Next week", 26 | "last-week": "Last week", 27 | "week-days": ["Mon", "Tue", "Wed", "Thu", "Fri"], 28 | "months": [ 29 | "Janurary", 30 | "February", 31 | "March", 32 | "April", 33 | "May", 34 | "June", 35 | "July", 36 | "August", 37 | "September", 38 | "October", 39 | "November", 40 | "December" 41 | ], 42 | "immoveable-course": "Immoveable course", 43 | "end-actions": { 44 | "copy": "Copy to clipboard", 45 | "ready": "Ready for Enrolment", 46 | "copy-success": "Copied to clipboard!", 47 | "enrolment-instruction": "Copy the numbers below and enter them on the enrolment page of Access Adelaide." 48 | } 49 | }, 50 | "help": { 51 | "title": "How to use MyTimetable", 52 | "steps": { 53 | "welcome": "Welcome to MyTimetable! With this tool, you can easily schedule your timetable before enrolment. We highly recommend using a computer or tablet to get started.", 54 | "select-term": "Select a term.", 55 | "search-course": "Choose a subject area, search for a course, and click “Add” to include it in your timetable.", 56 | "calendar-dnd": "Scroll down to see the calendar with your enrolled courses. You can drag a class and drop it in one of the highlighted boxes to change its time.", 57 | "change-week": "Change the calendar week to see more classes.", 58 | "course-details": "Click your enrolled course to see details of your enrolled classes.", 59 | "course-modal": "If you encounter any class clashes when using MyTimetable, you can open the modal to change the class.", 60 | "ready-button": "Once you are satisfied with your class times, click the \"Ready for Enrolment\" button at the bottom of the calendar.", 61 | "access-adelaide": "You can easily enroll in courses on Access Adelaide using the class numbers shown in the modal." 62 | }, 63 | "actions": { 64 | "next-step": "Next Step", 65 | "previous-step": "Previous Step", 66 | "get-started": "Get Started!" 67 | } 68 | }, 69 | "zoom": { 70 | "zoom-in": "Zoom In", 71 | "zoom-out": "Zoom Out" 72 | }, 73 | "toast": { 74 | "drop-to-change-term": "You need to drop all courses to change the term", 75 | "too-many-courses": "8 courses for a term is crazy! 💀" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/locales/zh-cn.json: -------------------------------------------------------------------------------- 1 | { 2 | "search": { 3 | "select-term": "选择学期", 4 | "search-course": "搜索课程", 5 | "add": "添加", 6 | "course-not-found": "未找到课程", 7 | "choose-subject": "选择学科", 8 | "subject-not-found": "未找到学科" 9 | }, 10 | "header": { 11 | "help": "帮助", 12 | "feedback": "反馈", 13 | "change-language": "切换语言", 14 | "toggle-dark-mode": "切换深色模式" 15 | }, 16 | "course-modal": { 17 | "dates": "日期", 18 | "days": "星期", 19 | "time": "时间", 20 | "location": "地点" 21 | }, 22 | "calendar": { 23 | "first-week": "首周", 24 | "previous-week": "上一周", 25 | "next-week": "下一周", 26 | "last-week": "末周", 27 | "week-days": ["周一", "周二", "周三", "周四", "周五"], 28 | "months": [ 29 | "一月", 30 | "二月", 31 | "三月", 32 | "四月", 33 | "五月", 34 | "六月", 35 | "七月", 36 | "八月", 37 | "九月", 38 | "十月", 39 | "十一月", 40 | "十二月" 41 | ], 42 | "immoveable-course": "此课程无法移动", 43 | "end-actions": { 44 | "copy": "复制到剪切板", 45 | "ready": "准备选课", 46 | "copy-success": "已成功复制到剪切板!", 47 | "enrolment-instruction": "复制课程代号并前往 Access Adelaide 的 Enrolment 页面输入以正式选课" 48 | } 49 | }, 50 | "help": { 51 | "title": "如何使用 MyTimetable", 52 | "steps": { 53 | "welcome": "欢迎使用 MyTimetable!选课前你可以在这里轻松安排你的课程表。我们强烈建议你通过电脑或平板使用。", 54 | "select-term": "选择你的学期", 55 | "search-course": "选择一个学科并搜索课程,点击“添加”将课程添加至课程表", 56 | "calendar-dnd": "下滑查看课程表,可以通过托拽课程修改时间", 57 | "change-week": "切换周数查看更多课程", 58 | "course-details": "点击你的选课查看详情", 59 | "course-modal": "如果在使用 MyTimetable 时遇到任何课程冲突,可以随时打开详情弹窗来更改课程", 60 | "ready-button": "课程调整完毕后,点击日历底部的“准备选课”按钮", 61 | "access-adelaide": "你可以用弹窗中的 Class Number 在 Access Adelaide 中进行选课" 62 | }, 63 | "actions": { 64 | "next-step": "下一步", 65 | "previous-step": "上一步", 66 | "get-started": "开始使用" 67 | } 68 | }, 69 | "zoom": { 70 | "zoom-in": "放大", 71 | "zoom-out": "缩小" 72 | }, 73 | "toast": { 74 | "drop-to-change-term": "请删除所有课程以更改学期", 75 | "too-many-courses": "课程过多" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import '@fontsource-variable/outfit'; 2 | import { HeroUIProvider } from '@heroui/react'; 3 | import { QueryClientProvider } from '@tanstack/react-query'; 4 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; 5 | import React from 'react'; 6 | import ReactDOM from 'react-dom/client'; 7 | import { ErrorBoundary } from 'react-error-boundary'; 8 | import { mountStoreDevtool } from 'simple-zustand-devtools'; 9 | import { Toaster } from 'sonner'; 10 | 11 | import { App } from './App'; 12 | import { Error } from './components/Error'; 13 | import { useEnrolledCourses } from './data/enrolled-courses'; 14 | import './i18n'; 15 | import './index.css'; 16 | import { queryClient } from './lib/query'; 17 | 18 | // MSW 19 | const enableMocking = async () => { 20 | const { worker } = await import('./mocks/browser'); 21 | return worker.start({ 22 | onUnhandledRequest(request, print) { 23 | if (request.url.includes('/mock')) { 24 | return print.warning(); 25 | } 26 | }, 27 | }); 28 | }; 29 | if (import.meta.env.DEV) { 30 | await enableMocking(); 31 | } 32 | 33 | // Zustand 34 | if (import.meta.env.DEV) { 35 | mountStoreDevtool('Courses', useEnrolledCourses); 36 | } 37 | 38 | ReactDOM.createRoot(document.getElementById('root')!).render( 39 | 40 | 41 | 42 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | , 53 | ); 54 | -------------------------------------------------------------------------------- /src/mocks/browser.ts: -------------------------------------------------------------------------------- 1 | import { setupWorker } from 'msw/browser'; 2 | 3 | import { handlers } from './handlers'; 4 | 5 | export const worker = setupWorker(...handlers); 6 | -------------------------------------------------------------------------------- /src/mocks/data/adds/adds-course-info.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "success", 3 | "data": { 4 | "query": { 5 | "num_rows": 1, 6 | "queryname": "COURSE_DTL", 7 | "rows": [ 8 | { 9 | "YEAR": "2024", 10 | "COURSE_ID": "107592", 11 | "COURSE_OFFER_NBR": 1, 12 | "ACAD_CAREER": "UGRD", 13 | "ACAD_CAREER_DESCR": "Undergraduate", 14 | "TERM": "4420", 15 | "TERM_DESCR": "Semester 2", 16 | "COURSE_TITLE": "Algorithm Design & Data Structures", 17 | "CAMPUS": "North Terrace", 18 | "CAMPUS_CD": "NTRCE", 19 | "COUNTRY": "AUS", 20 | "SUBJECT": "COMP SCI", 21 | "CATALOG_NBR": "2103", 22 | "CLASS_NBR": 28669, 23 | "SESSION_CD": "1", 24 | "UNITS": 3, 25 | "EFTLS": 0.125, 26 | "FIELD_OF_EDUCATION": "020111", 27 | "SSR_HECS_BAND_ID": "2A", 28 | "CONTACT": "Up to 6 hours per week", 29 | "AVAILABLE_FOR_NON_AWARD_STUDY": "No", 30 | "AVAILABLE_FOR_STUDY_ABROAD": "Y", 31 | "QUOTA": "N", 32 | "QUOTA_TXT": "", 33 | "RESTRICTION": "Y", 34 | "RESTRICTION_TXT": "Not available to B. Information Technology students", 35 | "PRE_REQUISITE": "COMP SCI 1102", 36 | "CO_REQUISITE": "", 37 | "ASSUMED_KNOWLEDGE": "", 38 | "INCOMPATIBLE": "COMP SCI 1103, COMP SCI 2202, COMP SCI 2202B", 39 | "ASSESSMENT": "Written exam and/or assignments", 40 | "BIENNIAL_COURSE": "", 41 | "DISCOVERY_EXPERIENCE_GLOBAL": "N", 42 | "DISCOVERY_EXPERIENCE–WORKING": "N", 43 | "DISCOVERY_EXPERIENCE–COMMUNITY": "N", 44 | "SYLLABUS": "The course is structured to take students from an introductory knowledge of C++ to a higher level, as well as addressing some key areas of computer programming and algorithm design. Topics include: review of class hierarchies, inheritance, friends, polymorphism and type systems; recursion; OO design principles, abstract data types, testing and software reuse; introductory data structures: linked lists, stacks, queues, trees, heaps, algorithmic strategies for searching and sorting data in these structures; introductory complexity analysis.", 45 | "ISLOP": false, 46 | "URL": "https://www.adelaide.edu.au/course-outlines/107592/1/sem-2/2024", 47 | "CRITICAL_DATES": { 48 | "LAST_DAY": "Mon 05/08/2024", 49 | "CENSUS_DT": "Wed 14/08/2024", 50 | "LAST_DAY_TO_WFN": "Fri 13/09/2024", 51 | "LAST_DAY_TO_WF": "Fri 25/10/2024" 52 | } 53 | } 54 | ] 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/mocks/data/adds/adds-meeting-times.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "success", 3 | "data": { 4 | "query": { 5 | "num_rows": 1, 6 | "queryname": "COURSE_CLASS_LIST", 7 | "rows": [ 8 | { 9 | "association_group": "10", 10 | "groups": [ 11 | { 12 | "type": "Enrolment Class: Lecture", 13 | "classes": [ 14 | { 15 | "class_nbr": 21109, 16 | "section": "LE01", 17 | "size": 250, 18 | "enrolled": 187, 19 | "available": 63, 20 | "institution": "UNIAD", 21 | "component": "Enrolment Class: Lecture", 22 | "notes": [ 23 | { 24 | "topic": "", 25 | "note": "For an enriching and interactive learning experience, it is highly recommended to attend the lecture in person. While the lecture will be recorded, it is primarily intended for review purposes and for individuals who cannot attend due to special circumstances. Please check MyUni for details once enrolled." 26 | } 27 | ], 28 | "meetings": [ 29 | { 30 | "dates": "22 Jul - 9 Sep", 31 | "days": "Monday", 32 | "start_time": "4pm", 33 | "end_time": "5pm", 34 | "location": "Horace Lamb, 1022, Horace Lamb Lecture Theatre" 35 | }, 36 | { 37 | "dates": "23 Jul - 10 Sep", 38 | "days": "Tuesday", 39 | "start_time": "3pm", 40 | "end_time": "4pm", 41 | "location": "Helen Mayo Nth, 103N, Florey Lecture Theatre" 42 | }, 43 | { 44 | "dates": "24 Jul - 11 Sep", 45 | "days": "Wednesday", 46 | "start_time": "3pm", 47 | "end_time": "4pm", 48 | "location": "Barr Smith South, 3029, Flentje Lecture Theatre" 49 | }, 50 | { 51 | "dates": "30 Sep - 21 Oct", 52 | "days": "Monday", 53 | "start_time": "4pm", 54 | "end_time": "5pm", 55 | "location": "Horace Lamb, 1022, Horace Lamb Lecture Theatre" 56 | }, 57 | { 58 | "dates": "1 Oct - 22 Oct", 59 | "days": "Tuesday", 60 | "start_time": "3pm", 61 | "end_time": "4pm", 62 | "location": "Helen Mayo Nth, 103N, Florey Lecture Theatre" 63 | }, 64 | { 65 | "dates": "2 Oct - 23 Oct", 66 | "days": "Wednesday", 67 | "start_time": "3pm", 68 | "end_time": "4pm", 69 | "location": "Barr Smith South, 3029, Flentje Lecture Theatre" 70 | } 71 | ] 72 | } 73 | ] 74 | }, 75 | { 76 | "type": "Related Class: Practical", 77 | "classes": [ 78 | { 79 | "class_nbr": 25024, 80 | "section": "PR06", 81 | "size": 80, 82 | "enrolled": 40, 83 | "available": 40, 84 | "institution": "UNIAD", 85 | "component": "Related Class: Practical", 86 | "meetings": [ 87 | { 88 | "dates": "26 Jul - 13 Sep", 89 | "days": "Friday", 90 | "start_time": "11am", 91 | "end_time": "1pm", 92 | "location": "Engineering & Mathematics, EM108, CAT Suite" 93 | }, 94 | { 95 | "dates": "4 Oct - 25 Oct", 96 | "days": "Friday", 97 | "start_time": "11am", 98 | "end_time": "1pm", 99 | "location": "Engineering & Mathematics, EM108, CAT Suite" 100 | } 101 | ] 102 | }, 103 | { 104 | "class_nbr": 25026, 105 | "section": "PR04", 106 | "size": 44, 107 | "enrolled": 37, 108 | "available": 7, 109 | "institution": "UNIAD", 110 | "component": "Related Class: Practical", 111 | "meetings": [ 112 | { 113 | "dates": "25 Jul - 12 Sep", 114 | "days": "Thursday", 115 | "start_time": "11am", 116 | "end_time": "1pm", 117 | "location": "Ingkarni Wardli, B15, CAT Suite" 118 | }, 119 | { 120 | "dates": "3 Oct - 24 Oct", 121 | "days": "Thursday", 122 | "start_time": "11am", 123 | "end_time": "1pm", 124 | "location": "Ingkarni Wardli, B15, CAT Suite" 125 | } 126 | ] 127 | }, 128 | { 129 | "class_nbr": 25027, 130 | "section": "PR03", 131 | "size": 46, 132 | "enrolled": 34, 133 | "available": 12, 134 | "institution": "UNIAD", 135 | "component": "Related Class: Practical", 136 | "meetings": [ 137 | { 138 | "dates": "25 Jul - 12 Sep", 139 | "days": "Thursday", 140 | "start_time": "1pm", 141 | "end_time": "3pm", 142 | "location": "Ingkarni Wardli, B16, CAT Suite" 143 | }, 144 | { 145 | "dates": "3 Oct - 24 Oct", 146 | "days": "Thursday", 147 | "start_time": "1pm", 148 | "end_time": "3pm", 149 | "location": "Ingkarni Wardli, B16, CAT Suite" 150 | } 151 | ] 152 | }, 153 | { 154 | "class_nbr": 25028, 155 | "section": "PR02", 156 | "size": 46, 157 | "enrolled": 38, 158 | "available": 8, 159 | "institution": "UNIAD", 160 | "component": "Related Class: Practical", 161 | "meetings": [ 162 | { 163 | "dates": "24 Jul - 11 Sep", 164 | "days": "Wednesday", 165 | "start_time": "9am", 166 | "end_time": "11am", 167 | "location": "Ingkarni Wardli, B16, CAT Suite" 168 | }, 169 | { 170 | "dates": "2 Oct - 23 Oct", 171 | "days": "Wednesday", 172 | "start_time": "9am", 173 | "end_time": "11am", 174 | "location": "Ingkarni Wardli, B16, CAT Suite" 175 | } 176 | ] 177 | }, 178 | { 179 | "class_nbr": 25029, 180 | "section": "PR01", 181 | "size": 46, 182 | "enrolled": 38, 183 | "available": 8, 184 | "institution": "UNIAD", 185 | "component": "Related Class: Practical", 186 | "meetings": [ 187 | { 188 | "dates": "26 Jul - 13 Sep", 189 | "days": "Friday", 190 | "start_time": "3pm", 191 | "end_time": "5pm", 192 | "location": "Ingkarni Wardli, B16, CAT Suite" 193 | }, 194 | { 195 | "dates": "4 Oct - 25 Oct", 196 | "days": "Friday", 197 | "start_time": "3pm", 198 | "end_time": "5pm", 199 | "location": "Ingkarni Wardli, B16, CAT Suite" 200 | } 201 | ] 202 | } 203 | ] 204 | }, 205 | { 206 | "type": "Related Class: Workshop", 207 | "classes": [ 208 | { 209 | "class_nbr": 25030, 210 | "section": "WR06", 211 | "size": 30, 212 | "enrolled": 11, 213 | "available": 19, 214 | "institution": "UNIAD", 215 | "component": "Related Class: Workshop", 216 | "meetings": [ 217 | { 218 | "dates": "30 Jul - 30 Jul", 219 | "days": "Tuesday", 220 | "start_time": "5pm", 221 | "end_time": "6pm", 222 | "location": "Hughes, 322, Teaching Room" 223 | }, 224 | { 225 | "dates": "13 Aug - 13 Aug", 226 | "days": "Tuesday", 227 | "start_time": "5pm", 228 | "end_time": "6pm", 229 | "location": "Hughes, 322, Teaching Room" 230 | }, 231 | { 232 | "dates": "27 Aug - 27 Aug", 233 | "days": "Tuesday", 234 | "start_time": "5pm", 235 | "end_time": "6pm", 236 | "location": "Hughes, 322, Teaching Room" 237 | }, 238 | { 239 | "dates": "10 Sep - 10 Sep", 240 | "days": "Tuesday", 241 | "start_time": "5pm", 242 | "end_time": "6pm", 243 | "location": "Hughes, 322, Teaching Room" 244 | }, 245 | { 246 | "dates": "8 Oct - 8 Oct", 247 | "days": "Tuesday", 248 | "start_time": "5pm", 249 | "end_time": "6pm", 250 | "location": "Hughes, 322, Teaching Room" 251 | }, 252 | { 253 | "dates": "22 Oct - 22 Oct", 254 | "days": "Tuesday", 255 | "start_time": "5pm", 256 | "end_time": "6pm", 257 | "location": "Hughes, 322, Teaching Room" 258 | } 259 | ] 260 | }, 261 | { 262 | "class_nbr": 25031, 263 | "section": "WR05", 264 | "size": 30, 265 | "enrolled": 27, 266 | "available": 3, 267 | "institution": "UNIAD", 268 | "component": "Related Class: Workshop", 269 | "meetings": [ 270 | { 271 | "dates": "30 Jul - 30 Jul", 272 | "days": "Tuesday", 273 | "start_time": "12pm", 274 | "end_time": "1pm", 275 | "location": "Petroleum Engineering, G04, Teaching Room" 276 | }, 277 | { 278 | "dates": "13 Aug - 13 Aug", 279 | "days": "Tuesday", 280 | "start_time": "12pm", 281 | "end_time": "1pm", 282 | "location": "Petroleum Engineering, G04, Teaching Room" 283 | }, 284 | { 285 | "dates": "27 Aug - 27 Aug", 286 | "days": "Tuesday", 287 | "start_time": "12pm", 288 | "end_time": "1pm", 289 | "location": "Petroleum Engineering, G04, Teaching Room" 290 | }, 291 | { 292 | "dates": "10 Sep - 10 Sep", 293 | "days": "Tuesday", 294 | "start_time": "12pm", 295 | "end_time": "1pm", 296 | "location": "Petroleum Engineering, G04, Teaching Room" 297 | }, 298 | { 299 | "dates": "8 Oct - 8 Oct", 300 | "days": "Tuesday", 301 | "start_time": "12pm", 302 | "end_time": "1pm", 303 | "location": "Petroleum Engineering, G04, Teaching Room" 304 | }, 305 | { 306 | "dates": "22 Oct - 22 Oct", 307 | "days": "Tuesday", 308 | "start_time": "12pm", 309 | "end_time": "1pm", 310 | "location": "Petroleum Engineering, G04, Teaching Room" 311 | } 312 | ] 313 | }, 314 | { 315 | "class_nbr": 25032, 316 | "section": "WR04", 317 | "size": 30, 318 | "enrolled": 30, 319 | "available": "FULL", 320 | "institution": "UNIAD", 321 | "component": "Related Class: Workshop", 322 | "meetings": [ 323 | { 324 | "dates": "30 Jul - 30 Jul", 325 | "days": "Tuesday", 326 | "start_time": "10am", 327 | "end_time": "11am", 328 | "location": "Engineering & Mathematics, EM218, Teaching Room" 329 | }, 330 | { 331 | "dates": "13 Aug - 13 Aug", 332 | "days": "Tuesday", 333 | "start_time": "10am", 334 | "end_time": "11am", 335 | "location": "Engineering & Mathematics, EM218, Teaching Room" 336 | }, 337 | { 338 | "dates": "27 Aug - 27 Aug", 339 | "days": "Tuesday", 340 | "start_time": "10am", 341 | "end_time": "11am", 342 | "location": "Engineering & Mathematics, EM218, Teaching Room" 343 | }, 344 | { 345 | "dates": "10 Sep - 10 Sep", 346 | "days": "Tuesday", 347 | "start_time": "10am", 348 | "end_time": "11am", 349 | "location": "Engineering & Mathematics, EM218, Teaching Room" 350 | }, 351 | { 352 | "dates": "8 Oct - 8 Oct", 353 | "days": "Tuesday", 354 | "start_time": "10am", 355 | "end_time": "11am", 356 | "location": "Engineering & Mathematics, EM218, Teaching Room" 357 | }, 358 | { 359 | "dates": "22 Oct - 22 Oct", 360 | "days": "Tuesday", 361 | "start_time": "10am", 362 | "end_time": "11am", 363 | "location": "Engineering & Mathematics, EM218, Teaching Room" 364 | } 365 | ] 366 | }, 367 | { 368 | "class_nbr": 25033, 369 | "section": "WR03", 370 | "size": 30, 371 | "enrolled": 30, 372 | "available": "FULL", 373 | "institution": "UNIAD", 374 | "component": "Related Class: Workshop", 375 | "meetings": [ 376 | { 377 | "dates": "30 Jul - 30 Jul", 378 | "days": "Tuesday", 379 | "start_time": "11am", 380 | "end_time": "12pm", 381 | "location": "Petroleum Engineering, G04, Teaching Room" 382 | }, 383 | { 384 | "dates": "13 Aug - 13 Aug", 385 | "days": "Tuesday", 386 | "start_time": "11am", 387 | "end_time": "12pm", 388 | "location": "Petroleum Engineering, G04, Teaching Room" 389 | }, 390 | { 391 | "dates": "27 Aug - 27 Aug", 392 | "days": "Tuesday", 393 | "start_time": "11am", 394 | "end_time": "12pm", 395 | "location": "Petroleum Engineering, G04, Teaching Room" 396 | }, 397 | { 398 | "dates": "10 Sep - 10 Sep", 399 | "days": "Tuesday", 400 | "start_time": "11am", 401 | "end_time": "12pm", 402 | "location": "Petroleum Engineering, G04, Teaching Room" 403 | }, 404 | { 405 | "dates": "8 Oct - 8 Oct", 406 | "days": "Tuesday", 407 | "start_time": "11am", 408 | "end_time": "12pm", 409 | "location": "Petroleum Engineering, G04, Teaching Room" 410 | }, 411 | { 412 | "dates": "22 Oct - 22 Oct", 413 | "days": "Tuesday", 414 | "start_time": "11am", 415 | "end_time": "12pm", 416 | "location": "Petroleum Engineering, G04, Teaching Room" 417 | } 418 | ] 419 | }, 420 | { 421 | "class_nbr": 25034, 422 | "section": "WR02", 423 | "size": 30, 424 | "enrolled": 28, 425 | "available": 2, 426 | "institution": "UNIAD", 427 | "component": "Related Class: Workshop", 428 | "meetings": [ 429 | { 430 | "dates": "30 Jul - 30 Jul", 431 | "days": "Tuesday", 432 | "start_time": "1pm", 433 | "end_time": "2pm", 434 | "location": "Petroleum Engineering, G04, Teaching Room" 435 | }, 436 | { 437 | "dates": "13 Aug - 13 Aug", 438 | "days": "Tuesday", 439 | "start_time": "1pm", 440 | "end_time": "2pm", 441 | "location": "Petroleum Engineering, G04, Teaching Room" 442 | }, 443 | { 444 | "dates": "27 Aug - 27 Aug", 445 | "days": "Tuesday", 446 | "start_time": "1pm", 447 | "end_time": "2pm", 448 | "location": "Petroleum Engineering, G04, Teaching Room" 449 | }, 450 | { 451 | "dates": "10 Sep - 10 Sep", 452 | "days": "Tuesday", 453 | "start_time": "1pm", 454 | "end_time": "2pm", 455 | "location": "Petroleum Engineering, G04, Teaching Room" 456 | }, 457 | { 458 | "dates": "8 Oct - 8 Oct", 459 | "days": "Tuesday", 460 | "start_time": "1pm", 461 | "end_time": "2pm", 462 | "location": "Petroleum Engineering, G04, Teaching Room" 463 | }, 464 | { 465 | "dates": "22 Oct - 22 Oct", 466 | "days": "Tuesday", 467 | "start_time": "1pm", 468 | "end_time": "2pm", 469 | "location": "Petroleum Engineering, G04, Teaching Room" 470 | } 471 | ] 472 | }, 473 | { 474 | "class_nbr": 25035, 475 | "section": "WR01", 476 | "size": 30, 477 | "enrolled": 26, 478 | "available": 4, 479 | "institution": "UNIAD", 480 | "component": "Related Class: Workshop", 481 | "meetings": [ 482 | { 483 | "dates": "30 Jul - 30 Jul", 484 | "days": "Tuesday", 485 | "start_time": "4pm", 486 | "end_time": "5pm", 487 | "location": "Hughes, 323, Teaching Room" 488 | }, 489 | { 490 | "dates": "13 Aug - 13 Aug", 491 | "days": "Tuesday", 492 | "start_time": "4pm", 493 | "end_time": "5pm", 494 | "location": "Hughes, 323, Teaching Room" 495 | }, 496 | { 497 | "dates": "27 Aug - 27 Aug", 498 | "days": "Tuesday", 499 | "start_time": "4pm", 500 | "end_time": "5pm", 501 | "location": "Hughes, 323, Teaching Room" 502 | }, 503 | { 504 | "dates": "10 Sep - 10 Sep", 505 | "days": "Tuesday", 506 | "start_time": "4pm", 507 | "end_time": "5pm", 508 | "location": "Hughes, 323, Teaching Room" 509 | }, 510 | { 511 | "dates": "8 Oct - 8 Oct", 512 | "days": "Tuesday", 513 | "start_time": "4pm", 514 | "end_time": "5pm", 515 | "location": "Hughes, 323, Teaching Room" 516 | }, 517 | { 518 | "dates": "22 Oct - 22 Oct", 519 | "days": "Tuesday", 520 | "start_time": "4pm", 521 | "end_time": "5pm", 522 | "location": "Hughes, 323, Teaching Room" 523 | } 524 | ] 525 | }, 526 | { 527 | "class_nbr": 28667, 528 | "section": "WR07", 529 | "size": 30, 530 | "enrolled": 10, 531 | "available": 20, 532 | "institution": "UNIAD", 533 | "component": "Related Class: Workshop", 534 | "meetings": [ 535 | { 536 | "dates": "30 Jul - 30 Jul", 537 | "days": "Tuesday", 538 | "start_time": "9am", 539 | "end_time": "10am", 540 | "location": "Nexus10, UB35, Teaching Room" 541 | }, 542 | { 543 | "dates": "13 Aug - 13 Aug", 544 | "days": "Tuesday", 545 | "start_time": "9am", 546 | "end_time": "10am", 547 | "location": "Nexus10, UB35, Teaching Room" 548 | }, 549 | { 550 | "dates": "27 Aug - 27 Aug", 551 | "days": "Tuesday", 552 | "start_time": "9am", 553 | "end_time": "10am", 554 | "location": "Nexus10, UB35, Teaching Room" 555 | }, 556 | { 557 | "dates": "10 Sep - 10 Sep", 558 | "days": "Tuesday", 559 | "start_time": "9am", 560 | "end_time": "10am", 561 | "location": "Nexus10, UB35, Teaching Room" 562 | }, 563 | { 564 | "dates": "8 Oct - 8 Oct", 565 | "days": "Tuesday", 566 | "start_time": "9am", 567 | "end_time": "10am", 568 | "location": "Nexus10, UB35, Teaching Room" 569 | }, 570 | { 571 | "dates": "22 Oct - 22 Oct", 572 | "days": "Tuesday", 573 | "start_time": "9am", 574 | "end_time": "10am", 575 | "location": "Nexus10, UB35, Teaching Room" 576 | } 577 | ] 578 | }, 579 | { 580 | "class_nbr": 28668, 581 | "section": "WR08", 582 | "size": 30, 583 | "enrolled": 9, 584 | "available": 21, 585 | "institution": "UNIAD", 586 | "component": "Related Class: Workshop", 587 | "meetings": [ 588 | { 589 | "dates": "30 Jul - 30 Jul", 590 | "days": "Tuesday", 591 | "start_time": "5pm", 592 | "end_time": "6pm", 593 | "location": "Engineering & Mathematics, EM218, Teaching Room" 594 | }, 595 | { 596 | "dates": "13 Aug - 13 Aug", 597 | "days": "Tuesday", 598 | "start_time": "5pm", 599 | "end_time": "6pm", 600 | "location": "Engineering & Mathematics, EM218, Teaching Room" 601 | }, 602 | { 603 | "dates": "27 Aug - 27 Aug", 604 | "days": "Tuesday", 605 | "start_time": "5pm", 606 | "end_time": "6pm", 607 | "location": "Engineering & Mathematics, EM218, Teaching Room" 608 | }, 609 | { 610 | "dates": "10 Sep - 10 Sep", 611 | "days": "Tuesday", 612 | "start_time": "5pm", 613 | "end_time": "6pm", 614 | "location": "Engineering & Mathematics, EM218, Teaching Room" 615 | }, 616 | { 617 | "dates": "8 Oct - 8 Oct", 618 | "days": "Tuesday", 619 | "start_time": "5pm", 620 | "end_time": "6pm", 621 | "location": "Engineering & Mathematics, EM218, Teaching Room" 622 | }, 623 | { 624 | "dates": "22 Oct - 22 Oct", 625 | "days": "Tuesday", 626 | "start_time": "5pm", 627 | "end_time": "6pm", 628 | "location": "Engineering & Mathematics, EM218, Teaching Room" 629 | } 630 | ] 631 | }, 632 | { 633 | "class_nbr": 28669, 634 | "section": "WR09", 635 | "size": 30, 636 | "enrolled": 16, 637 | "available": 14, 638 | "institution": "UNIAD", 639 | "component": "Related Class: Workshop", 640 | "meetings": [ 641 | { 642 | "dates": "30 Jul - 30 Jul", 643 | "days": "Tuesday", 644 | "start_time": "4pm", 645 | "end_time": "5pm", 646 | "location": "Engineering & Mathematics, EM218, Teaching Room" 647 | }, 648 | { 649 | "dates": "13 Aug - 13 Aug", 650 | "days": "Tuesday", 651 | "start_time": "4pm", 652 | "end_time": "5pm", 653 | "location": "Engineering & Mathematics, EM218, Teaching Room" 654 | }, 655 | { 656 | "dates": "27 Aug - 27 Aug", 657 | "days": "Tuesday", 658 | "start_time": "4pm", 659 | "end_time": "5pm", 660 | "location": "Engineering & Mathematics, EM218, Teaching Room" 661 | }, 662 | { 663 | "dates": "10 Sep - 10 Sep", 664 | "days": "Tuesday", 665 | "start_time": "4pm", 666 | "end_time": "5pm", 667 | "location": "Engineering & Mathematics, EM218, Teaching Room" 668 | }, 669 | { 670 | "dates": "8 Oct - 8 Oct", 671 | "days": "Tuesday", 672 | "start_time": "4pm", 673 | "end_time": "5pm", 674 | "location": "Engineering & Mathematics, EM218, Teaching Room" 675 | }, 676 | { 677 | "dates": "22 Oct - 22 Oct", 678 | "days": "Tuesday", 679 | "start_time": "4pm", 680 | "end_time": "5pm", 681 | "location": "Engineering & Mathematics, EM218, Teaching Room" 682 | } 683 | ] 684 | } 685 | ] 686 | } 687 | ] 688 | } 689 | ] 690 | } 691 | } 692 | } 693 | -------------------------------------------------------------------------------- /src/mocks/data/adds/adds-res.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "8b8afe1b-449b-4cce-b108-8dd8eef4648e", 3 | "course_id": "107592", 4 | "name": { 5 | "subject": "COMP SCI", 6 | "code": "2103", 7 | "title": "Algorithm Design & Data Structures" 8 | }, 9 | "class_number": 28669, 10 | "year": 2024, 11 | "term": "Semester 2", 12 | "campus": "North Terrace", 13 | "units": 3, 14 | "requirement": { 15 | "restriction": "Not available to B. Information Technology students", 16 | "prerequisite": ["COMP SCI 1102"], 17 | "corequisite": [], 18 | "assumed_knowledge": [], 19 | "incompatible": ["COMP SCI 1103", "COMP SCI 2202", "COMP SCI 2202B"] 20 | }, 21 | "class_list": [ 22 | { 23 | "id": "f437d75b-b29f-469b-ac3a-b63e4f9957fa", 24 | "category": "enrolment", 25 | "type": "Lecture", 26 | "classes": [ 27 | { 28 | "number": "21109", 29 | "meetings": [ 30 | { 31 | "day": "Monday", 32 | "location": "Horace Lamb, 1022, Horace Lamb Lecture Theatre", 33 | "date": { 34 | "start": "07-22", 35 | "end": "09-09" 36 | }, 37 | "time": { 38 | "start": "16:00", 39 | "end": "17:00" 40 | } 41 | }, 42 | { 43 | "day": "Tuesday", 44 | "location": "Helen Mayo Nth, 103N, Florey Lecture Theatre", 45 | "date": { 46 | "start": "07-23", 47 | "end": "09-10" 48 | }, 49 | "time": { 50 | "start": "15:00", 51 | "end": "16:00" 52 | } 53 | }, 54 | { 55 | "day": "Wednesday", 56 | "location": "Barr Smith South, 3029, Flentje Lecture Theatre", 57 | "date": { 58 | "start": "07-24", 59 | "end": "09-11" 60 | }, 61 | "time": { 62 | "start": "15:00", 63 | "end": "16:00" 64 | } 65 | }, 66 | { 67 | "day": "Monday", 68 | "location": "Horace Lamb, 1022, Horace Lamb Lecture Theatre", 69 | "date": { 70 | "start": "09-30", 71 | "end": "10-21" 72 | }, 73 | "time": { 74 | "start": "16:00", 75 | "end": "17:00" 76 | } 77 | }, 78 | { 79 | "day": "Tuesday", 80 | "location": "Helen Mayo Nth, 103N, Florey Lecture Theatre", 81 | "date": { 82 | "start": "10-01", 83 | "end": "10-22" 84 | }, 85 | "time": { 86 | "start": "15:00", 87 | "end": "16:00" 88 | } 89 | }, 90 | { 91 | "day": "Wednesday", 92 | "location": "Barr Smith South, 3029, Flentje Lecture Theatre", 93 | "date": { 94 | "start": "10-02", 95 | "end": "10-23" 96 | }, 97 | "time": { 98 | "start": "15:00", 99 | "end": "16:00" 100 | } 101 | } 102 | ] 103 | } 104 | ] 105 | }, 106 | { 107 | "id": "7af13310-485b-4a42-a882-20ae5a75501a", 108 | "category": "related", 109 | "type": "Practical", 110 | "classes": [ 111 | { 112 | "number": "25024", 113 | "meetings": [ 114 | { 115 | "day": "Friday", 116 | "location": "Engineering & Mathematics, EM108, CAT Suite", 117 | "date": { 118 | "start": "07-26", 119 | "end": "09-13" 120 | }, 121 | "time": { 122 | "start": "11:00", 123 | "end": "13:00" 124 | } 125 | }, 126 | { 127 | "day": "Friday", 128 | "location": "Engineering & Mathematics, EM108, CAT Suite", 129 | "date": { 130 | "start": "10-04", 131 | "end": "10-25" 132 | }, 133 | "time": { 134 | "start": "11:00", 135 | "end": "13:00" 136 | } 137 | } 138 | ] 139 | }, 140 | { 141 | "number": "25026", 142 | "meetings": [ 143 | { 144 | "day": "Thursday", 145 | "location": "Ingkarni Wardli, B15, CAT Suite", 146 | "date": { 147 | "start": "07-25", 148 | "end": "09-12" 149 | }, 150 | "time": { 151 | "start": "11:00", 152 | "end": "13:00" 153 | } 154 | }, 155 | { 156 | "day": "Thursday", 157 | "location": "Ingkarni Wardli, B15, CAT Suite", 158 | "date": { 159 | "start": "10-03", 160 | "end": "10-24" 161 | }, 162 | "time": { 163 | "start": "11:00", 164 | "end": "13:00" 165 | } 166 | } 167 | ] 168 | }, 169 | { 170 | "number": "25027", 171 | "meetings": [ 172 | { 173 | "day": "Thursday", 174 | "location": "Ingkarni Wardli, B16, CAT Suite", 175 | "date": { 176 | "start": "07-25", 177 | "end": "09-12" 178 | }, 179 | "time": { 180 | "start": "13:00", 181 | "end": "15:00" 182 | } 183 | }, 184 | { 185 | "day": "Thursday", 186 | "location": "Ingkarni Wardli, B16, CAT Suite", 187 | "date": { 188 | "start": "10-03", 189 | "end": "10-24" 190 | }, 191 | "time": { 192 | "start": "13:00", 193 | "end": "15:00" 194 | } 195 | } 196 | ] 197 | }, 198 | { 199 | "number": "25028", 200 | "meetings": [ 201 | { 202 | "day": "Wednesday", 203 | "location": "Ingkarni Wardli, B16, CAT Suite", 204 | "date": { 205 | "start": "07-24", 206 | "end": "09-11" 207 | }, 208 | "time": { 209 | "start": "09:00", 210 | "end": "11:00" 211 | } 212 | }, 213 | { 214 | "day": "Wednesday", 215 | "location": "Ingkarni Wardli, B16, CAT Suite", 216 | "date": { 217 | "start": "10-02", 218 | "end": "10-23" 219 | }, 220 | "time": { 221 | "start": "09:00", 222 | "end": "11:00" 223 | } 224 | } 225 | ] 226 | }, 227 | { 228 | "number": "25029", 229 | "meetings": [ 230 | { 231 | "day": "Friday", 232 | "location": "Ingkarni Wardli, B16, CAT Suite", 233 | "date": { 234 | "start": "07-26", 235 | "end": "09-13" 236 | }, 237 | "time": { 238 | "start": "15:00", 239 | "end": "17:00" 240 | } 241 | }, 242 | { 243 | "day": "Friday", 244 | "location": "Ingkarni Wardli, B16, CAT Suite", 245 | "date": { 246 | "start": "10-04", 247 | "end": "10-25" 248 | }, 249 | "time": { 250 | "start": "15:00", 251 | "end": "17:00" 252 | } 253 | } 254 | ] 255 | } 256 | ] 257 | }, 258 | { 259 | "id": "4b3d4cde-ce18-49bc-b73d-4b4732b2c92d", 260 | "category": "related", 261 | "type": "Workshop", 262 | "classes": [ 263 | { 264 | "number": "25030", 265 | "meetings": [ 266 | { 267 | "day": "Tuesday", 268 | "location": "Hughes, 322, Teaching Room", 269 | "date": { 270 | "start": "07-30", 271 | "end": "07-30" 272 | }, 273 | "time": { 274 | "start": "17:00", 275 | "end": "18:00" 276 | } 277 | }, 278 | { 279 | "day": "Tuesday", 280 | "location": "Hughes, 322, Teaching Room", 281 | "date": { 282 | "start": "08-13", 283 | "end": "08-13" 284 | }, 285 | "time": { 286 | "start": "17:00", 287 | "end": "18:00" 288 | } 289 | }, 290 | { 291 | "day": "Tuesday", 292 | "location": "Hughes, 322, Teaching Room", 293 | "date": { 294 | "start": "08-27", 295 | "end": "08-27" 296 | }, 297 | "time": { 298 | "start": "17:00", 299 | "end": "18:00" 300 | } 301 | }, 302 | { 303 | "day": "Tuesday", 304 | "location": "Hughes, 322, Teaching Room", 305 | "date": { 306 | "start": "09-10", 307 | "end": "09-10" 308 | }, 309 | "time": { 310 | "start": "17:00", 311 | "end": "18:00" 312 | } 313 | }, 314 | { 315 | "day": "Tuesday", 316 | "location": "Hughes, 322, Teaching Room", 317 | "date": { 318 | "start": "10-08", 319 | "end": "10-08" 320 | }, 321 | "time": { 322 | "start": "17:00", 323 | "end": "18:00" 324 | } 325 | }, 326 | { 327 | "day": "Tuesday", 328 | "location": "Hughes, 322, Teaching Room", 329 | "date": { 330 | "start": "10-22", 331 | "end": "10-22" 332 | }, 333 | "time": { 334 | "start": "17:00", 335 | "end": "18:00" 336 | } 337 | } 338 | ] 339 | }, 340 | { 341 | "number": "25031", 342 | "meetings": [ 343 | { 344 | "day": "Tuesday", 345 | "location": "Petroleum Engineering, G04, Teaching Room", 346 | "date": { 347 | "start": "07-30", 348 | "end": "07-30" 349 | }, 350 | "time": { 351 | "start": "12:00", 352 | "end": "13:00" 353 | } 354 | }, 355 | { 356 | "day": "Tuesday", 357 | "location": "Petroleum Engineering, G04, Teaching Room", 358 | "date": { 359 | "start": "08-13", 360 | "end": "08-13" 361 | }, 362 | "time": { 363 | "start": "12:00", 364 | "end": "13:00" 365 | } 366 | }, 367 | { 368 | "day": "Tuesday", 369 | "location": "Petroleum Engineering, G04, Teaching Room", 370 | "date": { 371 | "start": "08-27", 372 | "end": "08-27" 373 | }, 374 | "time": { 375 | "start": "12:00", 376 | "end": "13:00" 377 | } 378 | }, 379 | { 380 | "day": "Tuesday", 381 | "location": "Petroleum Engineering, G04, Teaching Room", 382 | "date": { 383 | "start": "09-10", 384 | "end": "09-10" 385 | }, 386 | "time": { 387 | "start": "12:00", 388 | "end": "13:00" 389 | } 390 | }, 391 | { 392 | "day": "Tuesday", 393 | "location": "Petroleum Engineering, G04, Teaching Room", 394 | "date": { 395 | "start": "10-08", 396 | "end": "10-08" 397 | }, 398 | "time": { 399 | "start": "12:00", 400 | "end": "13:00" 401 | } 402 | }, 403 | { 404 | "day": "Tuesday", 405 | "location": "Petroleum Engineering, G04, Teaching Room", 406 | "date": { 407 | "start": "10-22", 408 | "end": "10-22" 409 | }, 410 | "time": { 411 | "start": "12:00", 412 | "end": "13:00" 413 | } 414 | } 415 | ] 416 | }, 417 | { 418 | "number": "25032", 419 | "meetings": [ 420 | { 421 | "day": "Tuesday", 422 | "location": "Engineering & Mathematics, EM218, Teaching Room", 423 | "date": { 424 | "start": "07-30", 425 | "end": "07-30" 426 | }, 427 | "time": { 428 | "start": "10:00", 429 | "end": "11:00" 430 | } 431 | }, 432 | { 433 | "day": "Tuesday", 434 | "location": "Engineering & Mathematics, EM218, Teaching Room", 435 | "date": { 436 | "start": "08-13", 437 | "end": "08-13" 438 | }, 439 | "time": { 440 | "start": "10:00", 441 | "end": "11:00" 442 | } 443 | }, 444 | { 445 | "day": "Tuesday", 446 | "location": "Engineering & Mathematics, EM218, Teaching Room", 447 | "date": { 448 | "start": "08-27", 449 | "end": "08-27" 450 | }, 451 | "time": { 452 | "start": "10:00", 453 | "end": "11:00" 454 | } 455 | }, 456 | { 457 | "day": "Tuesday", 458 | "location": "Engineering & Mathematics, EM218, Teaching Room", 459 | "date": { 460 | "start": "09-10", 461 | "end": "09-10" 462 | }, 463 | "time": { 464 | "start": "10:00", 465 | "end": "11:00" 466 | } 467 | }, 468 | { 469 | "day": "Tuesday", 470 | "location": "Engineering & Mathematics, EM218, Teaching Room", 471 | "date": { 472 | "start": "10-08", 473 | "end": "10-08" 474 | }, 475 | "time": { 476 | "start": "10:00", 477 | "end": "11:00" 478 | } 479 | }, 480 | { 481 | "day": "Tuesday", 482 | "location": "Engineering & Mathematics, EM218, Teaching Room", 483 | "date": { 484 | "start": "10-22", 485 | "end": "10-22" 486 | }, 487 | "time": { 488 | "start": "10:00", 489 | "end": "11:00" 490 | } 491 | } 492 | ] 493 | }, 494 | { 495 | "number": "25033", 496 | "meetings": [ 497 | { 498 | "day": "Tuesday", 499 | "location": "Petroleum Engineering, G04, Teaching Room", 500 | "date": { 501 | "start": "07-30", 502 | "end": "07-30" 503 | }, 504 | "time": { 505 | "start": "11:00", 506 | "end": "12:00" 507 | } 508 | }, 509 | { 510 | "day": "Tuesday", 511 | "location": "Petroleum Engineering, G04, Teaching Room", 512 | "date": { 513 | "start": "08-13", 514 | "end": "08-13" 515 | }, 516 | "time": { 517 | "start": "11:00", 518 | "end": "12:00" 519 | } 520 | }, 521 | { 522 | "day": "Tuesday", 523 | "location": "Petroleum Engineering, G04, Teaching Room", 524 | "date": { 525 | "start": "08-27", 526 | "end": "08-27" 527 | }, 528 | "time": { 529 | "start": "11:00", 530 | "end": "12:00" 531 | } 532 | }, 533 | { 534 | "day": "Tuesday", 535 | "location": "Petroleum Engineering, G04, Teaching Room", 536 | "date": { 537 | "start": "09-10", 538 | "end": "09-10" 539 | }, 540 | "time": { 541 | "start": "11:00", 542 | "end": "12:00" 543 | } 544 | }, 545 | { 546 | "day": "Tuesday", 547 | "location": "Petroleum Engineering, G04, Teaching Room", 548 | "date": { 549 | "start": "10-08", 550 | "end": "10-08" 551 | }, 552 | "time": { 553 | "start": "11:00", 554 | "end": "12:00" 555 | } 556 | }, 557 | { 558 | "day": "Tuesday", 559 | "location": "Petroleum Engineering, G04, Teaching Room", 560 | "date": { 561 | "start": "10-22", 562 | "end": "10-22" 563 | }, 564 | "time": { 565 | "start": "11:00", 566 | "end": "12:00" 567 | } 568 | } 569 | ] 570 | }, 571 | { 572 | "number": "25034", 573 | "meetings": [ 574 | { 575 | "day": "Tuesday", 576 | "location": "Petroleum Engineering, G04, Teaching Room", 577 | "date": { 578 | "start": "07-30", 579 | "end": "07-30" 580 | }, 581 | "time": { 582 | "start": "13:00", 583 | "end": "14:00" 584 | } 585 | }, 586 | { 587 | "day": "Tuesday", 588 | "location": "Petroleum Engineering, G04, Teaching Room", 589 | "date": { 590 | "start": "08-13", 591 | "end": "08-13" 592 | }, 593 | "time": { 594 | "start": "13:00", 595 | "end": "14:00" 596 | } 597 | }, 598 | { 599 | "day": "Tuesday", 600 | "location": "Petroleum Engineering, G04, Teaching Room", 601 | "date": { 602 | "start": "08-27", 603 | "end": "08-27" 604 | }, 605 | "time": { 606 | "start": "13:00", 607 | "end": "14:00" 608 | } 609 | }, 610 | { 611 | "day": "Tuesday", 612 | "location": "Petroleum Engineering, G04, Teaching Room", 613 | "date": { 614 | "start": "09-10", 615 | "end": "09-10" 616 | }, 617 | "time": { 618 | "start": "13:00", 619 | "end": "14:00" 620 | } 621 | }, 622 | { 623 | "day": "Tuesday", 624 | "location": "Petroleum Engineering, G04, Teaching Room", 625 | "date": { 626 | "start": "10-08", 627 | "end": "10-08" 628 | }, 629 | "time": { 630 | "start": "13:00", 631 | "end": "14:00" 632 | } 633 | }, 634 | { 635 | "day": "Tuesday", 636 | "location": "Petroleum Engineering, G04, Teaching Room", 637 | "date": { 638 | "start": "10-22", 639 | "end": "10-22" 640 | }, 641 | "time": { 642 | "start": "13:00", 643 | "end": "14:00" 644 | } 645 | } 646 | ] 647 | }, 648 | { 649 | "number": "25035", 650 | "meetings": [ 651 | { 652 | "day": "Tuesday", 653 | "location": "Hughes, 323, Teaching Room", 654 | "date": { 655 | "start": "07-30", 656 | "end": "07-30" 657 | }, 658 | "time": { 659 | "start": "16:00", 660 | "end": "17:00" 661 | } 662 | }, 663 | { 664 | "day": "Tuesday", 665 | "location": "Hughes, 323, Teaching Room", 666 | "date": { 667 | "start": "08-13", 668 | "end": "08-13" 669 | }, 670 | "time": { 671 | "start": "16:00", 672 | "end": "17:00" 673 | } 674 | }, 675 | { 676 | "day": "Tuesday", 677 | "location": "Hughes, 323, Teaching Room", 678 | "date": { 679 | "start": "08-27", 680 | "end": "08-27" 681 | }, 682 | "time": { 683 | "start": "16:00", 684 | "end": "17:00" 685 | } 686 | }, 687 | { 688 | "day": "Tuesday", 689 | "location": "Hughes, 323, Teaching Room", 690 | "date": { 691 | "start": "09-10", 692 | "end": "09-10" 693 | }, 694 | "time": { 695 | "start": "16:00", 696 | "end": "17:00" 697 | } 698 | }, 699 | { 700 | "day": "Tuesday", 701 | "location": "Hughes, 323, Teaching Room", 702 | "date": { 703 | "start": "10-08", 704 | "end": "10-08" 705 | }, 706 | "time": { 707 | "start": "16:00", 708 | "end": "17:00" 709 | } 710 | }, 711 | { 712 | "day": "Tuesday", 713 | "location": "Hughes, 323, Teaching Room", 714 | "date": { 715 | "start": "10-22", 716 | "end": "10-22" 717 | }, 718 | "time": { 719 | "start": "16:00", 720 | "end": "17:00" 721 | } 722 | } 723 | ] 724 | }, 725 | { 726 | "number": "28667", 727 | "meetings": [ 728 | { 729 | "day": "Tuesday", 730 | "location": "Nexus10, UB35, Teaching Room", 731 | "date": { 732 | "start": "07-30", 733 | "end": "07-30" 734 | }, 735 | "time": { 736 | "start": "09:00", 737 | "end": "10:00" 738 | } 739 | }, 740 | { 741 | "day": "Tuesday", 742 | "location": "Nexus10, UB35, Teaching Room", 743 | "date": { 744 | "start": "08-13", 745 | "end": "08-13" 746 | }, 747 | "time": { 748 | "start": "09:00", 749 | "end": "10:00" 750 | } 751 | }, 752 | { 753 | "day": "Tuesday", 754 | "location": "Nexus10, UB35, Teaching Room", 755 | "date": { 756 | "start": "08-27", 757 | "end": "08-27" 758 | }, 759 | "time": { 760 | "start": "09:00", 761 | "end": "10:00" 762 | } 763 | }, 764 | { 765 | "day": "Tuesday", 766 | "location": "Nexus10, UB35, Teaching Room", 767 | "date": { 768 | "start": "09-10", 769 | "end": "09-10" 770 | }, 771 | "time": { 772 | "start": "09:00", 773 | "end": "10:00" 774 | } 775 | }, 776 | { 777 | "day": "Tuesday", 778 | "location": "Nexus10, UB35, Teaching Room", 779 | "date": { 780 | "start": "10-08", 781 | "end": "10-08" 782 | }, 783 | "time": { 784 | "start": "09:00", 785 | "end": "10:00" 786 | } 787 | }, 788 | { 789 | "day": "Tuesday", 790 | "location": "Nexus10, UB35, Teaching Room", 791 | "date": { 792 | "start": "10-22", 793 | "end": "10-22" 794 | }, 795 | "time": { 796 | "start": "09:00", 797 | "end": "10:00" 798 | } 799 | } 800 | ] 801 | }, 802 | { 803 | "number": "28668", 804 | "meetings": [ 805 | { 806 | "day": "Tuesday", 807 | "location": "Engineering & Mathematics, EM218, Teaching Room", 808 | "date": { 809 | "start": "07-30", 810 | "end": "07-30" 811 | }, 812 | "time": { 813 | "start": "17:00", 814 | "end": "18:00" 815 | } 816 | }, 817 | { 818 | "day": "Tuesday", 819 | "location": "Engineering & Mathematics, EM218, Teaching Room", 820 | "date": { 821 | "start": "08-13", 822 | "end": "08-13" 823 | }, 824 | "time": { 825 | "start": "17:00", 826 | "end": "18:00" 827 | } 828 | }, 829 | { 830 | "day": "Tuesday", 831 | "location": "Engineering & Mathematics, EM218, Teaching Room", 832 | "date": { 833 | "start": "08-27", 834 | "end": "08-27" 835 | }, 836 | "time": { 837 | "start": "17:00", 838 | "end": "18:00" 839 | } 840 | }, 841 | { 842 | "day": "Tuesday", 843 | "location": "Engineering & Mathematics, EM218, Teaching Room", 844 | "date": { 845 | "start": "09-10", 846 | "end": "09-10" 847 | }, 848 | "time": { 849 | "start": "17:00", 850 | "end": "18:00" 851 | } 852 | }, 853 | { 854 | "day": "Tuesday", 855 | "location": "Engineering & Mathematics, EM218, Teaching Room", 856 | "date": { 857 | "start": "10-08", 858 | "end": "10-08" 859 | }, 860 | "time": { 861 | "start": "17:00", 862 | "end": "18:00" 863 | } 864 | }, 865 | { 866 | "day": "Tuesday", 867 | "location": "Engineering & Mathematics, EM218, Teaching Room", 868 | "date": { 869 | "start": "10-22", 870 | "end": "10-22" 871 | }, 872 | "time": { 873 | "start": "17:00", 874 | "end": "18:00" 875 | } 876 | } 877 | ] 878 | }, 879 | { 880 | "number": "28669", 881 | "meetings": [ 882 | { 883 | "day": "Tuesday", 884 | "location": "Engineering & Mathematics, EM218, Teaching Room", 885 | "date": { 886 | "start": "07-30", 887 | "end": "07-30" 888 | }, 889 | "time": { 890 | "start": "16:00", 891 | "end": "17:00" 892 | } 893 | }, 894 | { 895 | "day": "Tuesday", 896 | "location": "Engineering & Mathematics, EM218, Teaching Room", 897 | "date": { 898 | "start": "08-13", 899 | "end": "08-13" 900 | }, 901 | "time": { 902 | "start": "16:00", 903 | "end": "17:00" 904 | } 905 | }, 906 | { 907 | "day": "Tuesday", 908 | "location": "Engineering & Mathematics, EM218, Teaching Room", 909 | "date": { 910 | "start": "08-27", 911 | "end": "08-27" 912 | }, 913 | "time": { 914 | "start": "16:00", 915 | "end": "17:00" 916 | } 917 | }, 918 | { 919 | "day": "Tuesday", 920 | "location": "Engineering & Mathematics, EM218, Teaching Room", 921 | "date": { 922 | "start": "09-10", 923 | "end": "09-10" 924 | }, 925 | "time": { 926 | "start": "16:00", 927 | "end": "17:00" 928 | } 929 | }, 930 | { 931 | "day": "Tuesday", 932 | "location": "Engineering & Mathematics, EM218, Teaching Room", 933 | "date": { 934 | "start": "10-08", 935 | "end": "10-08" 936 | }, 937 | "time": { 938 | "start": "16:00", 939 | "end": "17:00" 940 | } 941 | }, 942 | { 943 | "day": "Tuesday", 944 | "location": "Engineering & Mathematics, EM218, Teaching Room", 945 | "date": { 946 | "start": "10-22", 947 | "end": "10-22" 948 | }, 949 | "time": { 950 | "start": "16:00", 951 | "end": "17:00" 952 | } 953 | } 954 | ] 955 | } 956 | ] 957 | } 958 | ] 959 | } 960 | -------------------------------------------------------------------------------- /src/mocks/data/example-format.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "", 3 | "course_id": "107592", // course ID 4 | "name": { 5 | "subject": "COMP SCI", 6 | "code": "2103", // catalog number aka code 7 | "title": "Algorithm Design & Data Structures" 8 | // "other_names": ["ADDS, "DSA", "Data structures and algorithms"], // case insensitive in frontend search 9 | }, 10 | "class_number": 14897, 11 | "year": 2024, 12 | "term": "Semester 1", 13 | "campus": "North Terrace", 14 | "units": 3, 15 | "requirement": { 16 | "restriction": "Not available to B. Information Technology students", 17 | "prerequisite": ["COMP SCI 1102"], // list of codes or high school classes (can be string for now) 18 | "corequisite": [], // list of codes (can be string for now) 19 | "assumed_knowledge": [], // list of codes (can be string for now) 20 | "incompatible": ["COMP SCI 1103", "COMP SCI 2202", "COMP SCI 2202B"] // list of codes (can be string for now) 21 | }, 22 | "class_list": [ 23 | { 24 | "type": "Workshop", 25 | "id": "", 26 | "classes": [ 27 | { 28 | "number": "11472", 29 | "meetings": [ 30 | { 31 | "day": "Monday", 32 | "location": "Scott Theatre, 001, Scott Theatre", 33 | "date": { 34 | "start": "MM-DD", 35 | "end": "MM-DD" 36 | }, 37 | "time": { 38 | "start": "HH:mm", 39 | "end": "HH:mm" 40 | } 41 | } 42 | ] 43 | } 44 | ] 45 | } 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /src/mocks/data/gccs/gccs-course-info.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "success", 3 | "data": { 4 | "query": { 5 | "num_rows": 1, 6 | "queryname": "COURSE_DTL", 7 | "rows": [ 8 | { 9 | "YEAR": "2024", 10 | "COURSE_ID": "106541", 11 | "COURSE_OFFER_NBR": 1, 12 | "ACAD_CAREER": "UGRD", 13 | "ACAD_CAREER_DESCR": "Undergraduate", 14 | "TERM": "4420", 15 | "TERM_DESCR": "Semester 2", 16 | "COURSE_TITLE": "Grand Challenges in Computer Science", 17 | "CAMPUS": "North Terrace", 18 | "CAMPUS_CD": "NTRCE", 19 | "COUNTRY": "AUS", 20 | "SUBJECT": "COMP SCI", 21 | "CATALOG_NBR": "1104", 22 | "CLASS_NBR": 22520, 23 | "SESSION_CD": "1", 24 | "UNITS": 3, 25 | "EFTLS": 0.125, 26 | "FIELD_OF_EDUCATION": "020100", 27 | "SSR_HECS_BAND_ID": "2A", 28 | "CONTACT": "Up to 5 hours per week", 29 | "AVAILABLE_FOR_NON_AWARD_STUDY": "No", 30 | "AVAILABLE_FOR_STUDY_ABROAD": "N", 31 | "QUOTA": "N", 32 | "QUOTA_TXT": "", 33 | "RESTRICTION": "Y", 34 | "RESTRICTION_TXT": "Available to B.Comp Sc (Advanced) students only, or by permission of the Head of School. Non-B.Comp Sc (Advanced) students must achieve a GPA of at least 6 in Computer Science courses before being considered for entry", 35 | "PRE_REQUISITE": "", 36 | "CO_REQUISITE": "", 37 | "ASSUMED_KNOWLEDGE": "COMP SCI 1101, COMP SCI 1201, ENG 1002, ENG 1003, MECH ENG 1100, MECH ENG 1101, MECH ENG 1102, MECH ENG 1103, MECH ENG 1104 or MECH ENG 1105", 38 | "INCOMPATIBLE": "", 39 | "ASSESSMENT": "Assignments and/or Group Project", 40 | "BIENNIAL_COURSE": "", 41 | "DISCOVERY_EXPERIENCE_GLOBAL": "N", 42 | "DISCOVERY_EXPERIENCE–WORKING": "N", 43 | "DISCOVERY_EXPERIENCE–COMMUNITY": "N", 44 | "SYLLABUS": "This course provides an introduction to key research areas in Computer Science and the 'Grand Challenges'. Topics include AI, Algorithms, Distributed Systems, Networking, Data Mining and Hardware; scholarship and writing in the discipline, critical analysis and thinking skills.", 45 | "ISLOP": false, 46 | "URL": "https://www.adelaide.edu.au/course-outlines/106541/1/sem-2/2024", 47 | "CRITICAL_DATES": { 48 | "LAST_DAY": "Mon 05/08/2024", 49 | "CENSUS_DT": "Wed 14/08/2024", 50 | "LAST_DAY_TO_WFN": "Fri 13/09/2024", 51 | "LAST_DAY_TO_WF": "Fri 25/10/2024" 52 | } 53 | } 54 | ] 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/mocks/data/gccs/gccs-meeting-times.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "success", 3 | "data": { 4 | "query": { 5 | "num_rows": 1, 6 | "queryname": "COURSE_CLASS_LIST", 7 | "rows": [ 8 | { 9 | "association_group": "10", 10 | "groups": [ 11 | { 12 | "type": "Enrolment Class: Seminar", 13 | "classes": [ 14 | { 15 | "class_nbr": 21508, 16 | "section": "SE01", 17 | "size": 200, 18 | "enrolled": 70, 19 | "available": 130, 20 | "institution": "UNIAD", 21 | "component": "Enrolment Class: Seminar", 22 | "meetings": [ 23 | { 24 | "dates": "23 Jul - 10 Sep", 25 | "days": "Tuesday", 26 | "start_time": "1pm", 27 | "end_time": "3pm", 28 | "location": "Helen Mayo Nth, 103N, Florey Lecture Theatre" 29 | }, 30 | { 31 | "dates": "1 Oct - 29 Oct", 32 | "days": "Tuesday", 33 | "start_time": "1pm", 34 | "end_time": "3pm", 35 | "location": "Helen Mayo Nth, 103N, Florey Lecture Theatre" 36 | } 37 | ] 38 | } 39 | ] 40 | }, 41 | { 42 | "type": "Related Class: Workshop", 43 | "classes": [ 44 | { 45 | "class_nbr": 22520, 46 | "section": "WR03", 47 | "size": 60, 48 | "enrolled": 22, 49 | "available": 38, 50 | "institution": "UNIAD", 51 | "component": "Related Class: Workshop", 52 | "meetings": [ 53 | { 54 | "dates": "26 Jul - 13 Sep", 55 | "days": "Friday", 56 | "start_time": "4pm", 57 | "end_time": "5pm", 58 | "location": "Barr Smith South, 2060, Teaching Room" 59 | }, 60 | { 61 | "dates": "4 Oct - 1 Nov", 62 | "days": "Friday", 63 | "start_time": "4pm", 64 | "end_time": "5pm", 65 | "location": "Barr Smith South, 2060, Teaching Room" 66 | } 67 | ] 68 | }, 69 | { 70 | "class_nbr": 22905, 71 | "section": "WR02", 72 | "size": 60, 73 | "enrolled": 17, 74 | "available": 43, 75 | "institution": "UNIAD", 76 | "component": "Related Class: Workshop", 77 | "meetings": [ 78 | { 79 | "dates": "24 Jul - 11 Sep", 80 | "days": "Wednesday", 81 | "start_time": "4pm", 82 | "end_time": "5pm", 83 | "location": "Petroleum Engineering, G04, Teaching Room" 84 | }, 85 | { 86 | "dates": "2 Oct - 30 Oct", 87 | "days": "Wednesday", 88 | "start_time": "4pm", 89 | "end_time": "5pm", 90 | "location": "Petroleum Engineering, G04, Teaching Room" 91 | } 92 | ] 93 | }, 94 | { 95 | "class_nbr": 22906, 96 | "section": "WR01", 97 | "size": 60, 98 | "enrolled": 31, 99 | "available": 29, 100 | "institution": "UNIAD", 101 | "component": "Related Class: Workshop", 102 | "meetings": [ 103 | { 104 | "dates": "25 Jul - 12 Sep", 105 | "days": "Thursday", 106 | "start_time": "4pm", 107 | "end_time": "5pm", 108 | "location": "Petroleum Engineering, G04, Teaching Room" 109 | }, 110 | { 111 | "dates": "3 Oct - 31 Oct", 112 | "days": "Thursday", 113 | "start_time": "4pm", 114 | "end_time": "5pm", 115 | "location": "Petroleum Engineering, G04, Teaching Room" 116 | } 117 | ] 118 | } 119 | ] 120 | }, 121 | { 122 | "type": "Related Class: Project", 123 | "classes": [ 124 | { 125 | "class_nbr": 22521, 126 | "section": "PJ04", 127 | "size": 31, 128 | "enrolled": 25, 129 | "available": 6, 130 | "institution": "UNIAD", 131 | "component": "Related Class: Project", 132 | "meetings": [ 133 | { 134 | "dates": "2 Aug - 13 Sep", 135 | "days": "Friday", 136 | "start_time": "2pm", 137 | "end_time": "4pm", 138 | "location": "Engineering & Mathematics, EM106, Computer Suite" 139 | }, 140 | { 141 | "dates": "4 Oct - 1 Nov", 142 | "days": "Friday", 143 | "start_time": "2pm", 144 | "end_time": "4pm", 145 | "location": "Engineering & Mathematics, EM106, Computer Suite" 146 | } 147 | ] 148 | }, 149 | { 150 | "class_nbr": 22907, 151 | "section": "PJ03", 152 | "size": 32, 153 | "enrolled": 14, 154 | "available": 18, 155 | "institution": "UNIAD", 156 | "component": "Related Class: Project", 157 | "meetings": [ 158 | { 159 | "dates": "30 Jul - 10 Sep", 160 | "days": "Tuesday", 161 | "start_time": "3pm", 162 | "end_time": "5pm", 163 | "location": "Ingkarni Wardli, B16, CAT Suite" 164 | }, 165 | { 166 | "dates": "1 Oct - 29 Oct", 167 | "days": "Tuesday", 168 | "start_time": "3pm", 169 | "end_time": "5pm", 170 | "location": "Ingkarni Wardli, B16, CAT Suite" 171 | } 172 | ] 173 | }, 174 | { 175 | "class_nbr": 22908, 176 | "section": "PJ02", 177 | "size": 31, 178 | "enrolled": 13, 179 | "available": 18, 180 | "institution": "UNIAD", 181 | "component": "Related Class: Project", 182 | "meetings": [ 183 | { 184 | "dates": "1 Aug - 12 Sep", 185 | "days": "Thursday", 186 | "start_time": "9am", 187 | "end_time": "11am", 188 | "location": "Engineering & Mathematics, EM106, Computer Suite" 189 | }, 190 | { 191 | "dates": "3 Oct - 31 Oct", 192 | "days": "Thursday", 193 | "start_time": "9am", 194 | "end_time": "11am", 195 | "location": "Engineering & Mathematics, EM106, Computer Suite" 196 | } 197 | ] 198 | }, 199 | { 200 | "class_nbr": 22909, 201 | "section": "PJ01", 202 | "size": 32, 203 | "enrolled": 18, 204 | "available": 14, 205 | "institution": "UNIAD", 206 | "component": "Related Class: Project", 207 | "meetings": [ 208 | { 209 | "dates": "1 Aug - 12 Sep", 210 | "days": "Thursday", 211 | "start_time": "1pm", 212 | "end_time": "3pm", 213 | "location": "Ingkarni Wardli, 236, CAT Suite" 214 | }, 215 | { 216 | "dates": "3 Oct - 31 Oct", 217 | "days": "Thursday", 218 | "start_time": "1pm", 219 | "end_time": "3pm", 220 | "location": "Ingkarni Wardli, 236, CAT Suite" 221 | } 222 | ] 223 | } 224 | ] 225 | } 226 | ] 227 | } 228 | ] 229 | } 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /src/mocks/data/gccs/gccs-res.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "35dcf831-4888-475a-a172-7842ae3c526e", 3 | "course_id": "106541", 4 | "name": { 5 | "subject": "COMP SCI", 6 | "code": "1104", 7 | "title": "Grand Challenges in Computer Science" 8 | }, 9 | "class_number": 22520, 10 | "year": 2024, 11 | "term": "Semester 2", 12 | "campus": "North Terrace", 13 | "units": 3, 14 | "requirement": { 15 | "restriction": "Available to B.Comp Sc (Advanced) students only, or by permission of the Head of School. Non-B.Comp Sc (Advanced) students must achieve a GPA of at least 6 in Computer Science courses before being considered for entry", 16 | "prerequisite": [], 17 | "corequisite": [], 18 | "assumed_knowledge": [ 19 | "COMP SCI 1101", 20 | "COMP SCI 1201", 21 | "ENG 1002", 22 | "ENG 1003", 23 | "MECH ENG 1100", 24 | "MECH ENG 1101", 25 | "MECH ENG 1102", 26 | "MECH ENG 1103", 27 | "MECH ENG 1104 or MECH ENG 1105" 28 | ], 29 | "incompatible": [] 30 | }, 31 | "class_list": [ 32 | { 33 | "id": "a00cf20d-ddc6-4d7a-870e-47bfd50e514a", 34 | "category": "enrolment", 35 | "type": "Seminar", 36 | "classes": [ 37 | { 38 | "number": "21508", 39 | "meetings": [ 40 | { 41 | "day": "Tuesday", 42 | "location": "Helen Mayo Nth, 103N, Florey Lecture Theatre", 43 | "date": { 44 | "start": "07-23", 45 | "end": "09-10" 46 | }, 47 | "time": { 48 | "start": "13:00", 49 | "end": "15:00" 50 | } 51 | }, 52 | { 53 | "day": "Tuesday", 54 | "location": "Helen Mayo Nth, 103N, Florey Lecture Theatre", 55 | "date": { 56 | "start": "10-01", 57 | "end": "10-29" 58 | }, 59 | "time": { 60 | "start": "13:00", 61 | "end": "15:00" 62 | } 63 | } 64 | ] 65 | } 66 | ] 67 | }, 68 | { 69 | "id": "4771f084-d27e-42e8-ad78-3c40e625445d", 70 | "category": "related", 71 | "type": "Workshop", 72 | "classes": [ 73 | { 74 | "number": "22520", 75 | "meetings": [ 76 | { 77 | "day": "Friday", 78 | "location": "Barr Smith South, 2060, Teaching Room", 79 | "date": { 80 | "start": "07-26", 81 | "end": "09-13" 82 | }, 83 | "time": { 84 | "start": "16:00", 85 | "end": "17:00" 86 | } 87 | }, 88 | { 89 | "day": "Friday", 90 | "location": "Barr Smith South, 2060, Teaching Room", 91 | "date": { 92 | "start": "10-04", 93 | "end": "11-01" 94 | }, 95 | "time": { 96 | "start": "16:00", 97 | "end": "17:00" 98 | } 99 | } 100 | ] 101 | }, 102 | { 103 | "number": "22905", 104 | "meetings": [ 105 | { 106 | "day": "Wednesday", 107 | "location": "Petroleum Engineering, G04, Teaching Room", 108 | "date": { 109 | "start": "07-24", 110 | "end": "09-11" 111 | }, 112 | "time": { 113 | "start": "16:00", 114 | "end": "17:00" 115 | } 116 | }, 117 | { 118 | "day": "Wednesday", 119 | "location": "Petroleum Engineering, G04, Teaching Room", 120 | "date": { 121 | "start": "10-02", 122 | "end": "10-30" 123 | }, 124 | "time": { 125 | "start": "16:00", 126 | "end": "17:00" 127 | } 128 | } 129 | ] 130 | }, 131 | { 132 | "number": "22906", 133 | "meetings": [ 134 | { 135 | "day": "Thursday", 136 | "location": "Petroleum Engineering, G04, Teaching Room", 137 | "date": { 138 | "start": "07-25", 139 | "end": "09-12" 140 | }, 141 | "time": { 142 | "start": "16:00", 143 | "end": "17:00" 144 | } 145 | }, 146 | { 147 | "day": "Thursday", 148 | "location": "Petroleum Engineering, G04, Teaching Room", 149 | "date": { 150 | "start": "10-03", 151 | "end": "10-31" 152 | }, 153 | "time": { 154 | "start": "16:00", 155 | "end": "17:00" 156 | } 157 | } 158 | ] 159 | } 160 | ] 161 | }, 162 | { 163 | "id": "61a9d88c-a6ba-4cb9-a9e8-c785a0b81709", 164 | "category": "related", 165 | "type": "Project", 166 | "classes": [ 167 | { 168 | "number": "22521", 169 | "meetings": [ 170 | { 171 | "day": "Friday", 172 | "location": "Engineering & Mathematics, EM106, Computer Suite", 173 | "date": { 174 | "start": "08-02", 175 | "end": "09-13" 176 | }, 177 | "time": { 178 | "start": "14:00", 179 | "end": "16:00" 180 | } 181 | }, 182 | { 183 | "day": "Friday", 184 | "location": "Engineering & Mathematics, EM106, Computer Suite", 185 | "date": { 186 | "start": "10-04", 187 | "end": "11-01" 188 | }, 189 | "time": { 190 | "start": "14:00", 191 | "end": "16:00" 192 | } 193 | } 194 | ] 195 | }, 196 | { 197 | "number": "22907", 198 | "meetings": [ 199 | { 200 | "day": "Tuesday", 201 | "location": "Ingkarni Wardli, B16, CAT Suite", 202 | "date": { 203 | "start": "07-30", 204 | "end": "09-10" 205 | }, 206 | "time": { 207 | "start": "15:00", 208 | "end": "17:00" 209 | } 210 | }, 211 | { 212 | "day": "Tuesday", 213 | "location": "Ingkarni Wardli, B16, CAT Suite", 214 | "date": { 215 | "start": "10-01", 216 | "end": "10-29" 217 | }, 218 | "time": { 219 | "start": "15:00", 220 | "end": "17:00" 221 | } 222 | } 223 | ] 224 | }, 225 | { 226 | "number": "22908", 227 | "meetings": [ 228 | { 229 | "day": "Thursday", 230 | "location": "Engineering & Mathematics, EM106, Computer Suite", 231 | "date": { 232 | "start": "08-01", 233 | "end": "09-12" 234 | }, 235 | "time": { 236 | "start": "09:00", 237 | "end": "11:00" 238 | } 239 | }, 240 | { 241 | "day": "Thursday", 242 | "location": "Engineering & Mathematics, EM106, Computer Suite", 243 | "date": { 244 | "start": "10-03", 245 | "end": "10-31" 246 | }, 247 | "time": { 248 | "start": "09:00", 249 | "end": "11:00" 250 | } 251 | } 252 | ] 253 | }, 254 | { 255 | "number": "22909", 256 | "meetings": [ 257 | { 258 | "day": "Thursday", 259 | "location": "Ingkarni Wardli, 236, CAT Suite", 260 | "date": { 261 | "start": "08-01", 262 | "end": "09-12" 263 | }, 264 | "time": { 265 | "start": "13:00", 266 | "end": "15:00" 267 | } 268 | }, 269 | { 270 | "day": "Thursday", 271 | "location": "Ingkarni Wardli, 236, CAT Suite", 272 | "date": { 273 | "start": "10-03", 274 | "end": "10-31" 275 | }, 276 | "time": { 277 | "start": "13:00", 278 | "end": "15:00" 279 | } 280 | } 281 | ] 282 | } 283 | ] 284 | } 285 | ] 286 | } 287 | -------------------------------------------------------------------------------- /src/mocks/data/mfds/mfds-course-info.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "success", 3 | "data": { 4 | "query": { 5 | "num_rows": 1, 6 | "queryname": "COURSE_DTL", 7 | "rows": [ 8 | { 9 | "YEAR": "2024", 10 | "COURSE_ID": "109685", 11 | "COURSE_OFFER_NBR": 1, 12 | "ACAD_CAREER": "UGRD", 13 | "ACAD_CAREER_DESCR": "Undergraduate", 14 | "TERM": "4420", 15 | "TERM_DESCR": "Semester 2", 16 | "COURSE_TITLE": "Mathematics for Data Science I", 17 | "CAMPUS": "North Terrace", 18 | "CAMPUS_CD": "NTRCE", 19 | "COUNTRY": "AUS", 20 | "SUBJECT": "MATHS", 21 | "CATALOG_NBR": "1004", 22 | "CLASS_NBR": 23514, 23 | "SESSION_CD": "1", 24 | "UNITS": 3, 25 | "EFTLS": 0.125, 26 | "FIELD_OF_EDUCATION": "010101", 27 | "SSR_HECS_BAND_ID": "1", 28 | "CONTACT": "Up to 3 hours per week", 29 | "AVAILABLE_FOR_NON_AWARD_STUDY": "Yes", 30 | "AVAILABLE_FOR_STUDY_ABROAD": "Y", 31 | "QUOTA": "N", 32 | "QUOTA_TXT": "", 33 | "RESTRICTION": "Y", 34 | "RESTRICTION_TXT": "Not available for BMaSc or BMaSc(Adv) students or BMaSc (Hons) [Direct Entry]", 35 | "PRE_REQUISITE": "At least a C- in SACE Stage 2 Mathematical Methods or IB Mathematics: at least 3 in Applications and Interpretations HL; or 4 in Analysis and Approaches SL", 36 | "CO_REQUISITE": "", 37 | "ASSUMED_KNOWLEDGE": "", 38 | "INCOMPATIBLE": "MATHS 1008, MATHS 1010, MATHS 1012", 39 | "ASSESSMENT": "On-going assessment, exam", 40 | "BIENNIAL_COURSE": "", 41 | "DISCOVERY_EXPERIENCE_GLOBAL": "N", 42 | "DISCOVERY_EXPERIENCE–WORKING": "N", 43 | "DISCOVERY_EXPERIENCE–COMMUNITY": "N", 44 | "SYLLABUS": "Data science is one of the highest-paying graduate jobs, for those with the relevant mathematical training. This course introduces fundamental mathematical concepts relevant to data and computer science and provides a basis for further study in data science, statistics and cybersecurity. Topics covered are probability: sets, counting, probability axioms, Bayes theorem; applications of calculus: integration and continuous probability distributions, series approximations; linear algebra: vectors and matrices, matrix algebra, vector spaces, eigenvalues and diagonalisation. The course draws connections between each of these fundamental mathematical concepts and modern data science applications and introduces mathematical applications using Python programming.", 45 | "ISLOP": false, 46 | "URL": "https://www.adelaide.edu.au/course-outlines/109685/1/sem-2/2024", 47 | "CRITICAL_DATES": { 48 | "LAST_DAY": "Mon 05/08/2024", 49 | "CENSUS_DT": "Wed 14/08/2024", 50 | "LAST_DAY_TO_WFN": "Fri 13/09/2024", 51 | "LAST_DAY_TO_WF": "Fri 25/10/2024" 52 | } 53 | } 54 | ] 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/mocks/handlers.ts: -------------------------------------------------------------------------------- 1 | import { http, HttpResponse } from 'msw'; 2 | 3 | import adds from './data/adds/adds-res.json'; 4 | import gccs from './data/gccs/gccs-res.json'; 5 | import mfds from './data/mfds/mfds-res.json'; 6 | 7 | // Use tanstack query devtools instead of hardcoding loading times 8 | // const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 9 | 10 | const enum CourseId { 11 | ADDS = '8b8afe1b-449b-4cce-b108-8dd8eef4648e', 12 | GCCS = '35dcf831-4888-475a-a172-7842ae3c526e', 13 | MFDS = '33254151-e980-4727-882c-4bece847fdab', 14 | ERROR1 = 'bde0f6b8-9bf8-4f47-a895-2b2cc8fc8a44', 15 | ERROR2 = '4fc2355a-266f-45b6-937c-f91762e22c9a', 16 | ERROR3 = '0e991105-9c36-495e-b8c2-4fac3a3c7510', 17 | ERROR4 = 'fd71215c-75c0-4690-982e-263cbbf13f8a', 18 | ERROR5 = '28c72dfb-1777-4ed1-99a4-eec5c86005f4', 19 | } 20 | 21 | const COURSES = [ 22 | { 23 | id: CourseId.ADDS, 24 | name: { 25 | subject: 'COMP SCI', 26 | code: '2103', 27 | title: 'Algorithm Design & Data Structures', 28 | }, 29 | }, 30 | { 31 | id: CourseId.GCCS, 32 | name: { 33 | subject: 'COMP SCI', 34 | code: '1104', 35 | title: 'Grand Challenges in Computer Science', 36 | }, 37 | }, 38 | { 39 | id: CourseId.MFDS, 40 | name: { 41 | subject: 'MATHS', 42 | code: '1004', 43 | title: 'Mathematics for Data Science I', 44 | }, 45 | }, 46 | { 47 | id: CourseId.ERROR1, 48 | name: { 49 | subject: 'ERROR', 50 | code: '2333', 51 | title: 'Web & Database Computing', 52 | }, 53 | }, 54 | { 55 | id: CourseId.ERROR2, 56 | name: { 57 | subject: 'ERROR', 58 | code: '1145', 59 | title: 'Web & Database Computing II', 60 | }, 61 | }, 62 | { 63 | id: CourseId.ERROR3, 64 | name: { 65 | subject: 'ERROR', 66 | code: '1419', 67 | title: 'Web & Database Computing III', 68 | }, 69 | }, 70 | { 71 | id: CourseId.ERROR4, 72 | name: { 73 | subject: 'ERROR', 74 | code: '1981', 75 | title: 'Web & Database Computing IV', 76 | }, 77 | }, 78 | { 79 | id: CourseId.ERROR5, 80 | name: { 81 | subject: 'ERROR', 82 | code: '0000', 83 | title: 'Web & Database Computing V', 84 | }, 85 | }, 86 | ] as const; 87 | 88 | type SubjectCodes = (typeof COURSES)[number]['name']['subject']; 89 | const availableSubjects: Array<{ code: SubjectCodes; name: string }> = [ 90 | { code: 'COMP SCI', name: 'Computer Science' }, 91 | { code: 'MATHS', name: 'Mathematics' }, 92 | { code: 'ERROR', name: 'Error' }, 93 | ]; 94 | const subjects = [ 95 | ...availableSubjects, 96 | // Long subject 97 | { code: 'PETROGEO', name: 'Petroleum Geology & Geophysics' }, 98 | ]; 99 | 100 | export const handlers = [ 101 | http.get('/mock/subjects', async () => { 102 | return HttpResponse.json({ subjects }); 103 | }), 104 | http.get('/mock/courses', async ({ request }) => { 105 | const url = new URL(request.url); 106 | const subject = url.searchParams.get('subject'); 107 | return HttpResponse.json({ 108 | courses: COURSES.filter((c) => c.name.subject === subject), 109 | }); 110 | }), 111 | http.get('/mock/courses/:id', async ({ params }) => { 112 | const { id } = params as { id: CourseId }; 113 | if (id !== CourseId.ADDS && id !== CourseId.GCCS && id !== CourseId.MFDS) 114 | return HttpResponse.json({ error: 'Course not found' }, { status: 404 }); 115 | const idResMap = { 116 | [CourseId.ADDS]: adds, 117 | [CourseId.GCCS]: gccs, 118 | [CourseId.MFDS]: mfds, 119 | }; 120 | return HttpResponse.json(idResMap[id]); 121 | }), 122 | ]; 123 | -------------------------------------------------------------------------------- /src/types/course.ts: -------------------------------------------------------------------------------- 1 | import type { WeekDay } from '../constants/week-days'; 2 | 3 | export type DateTimeRange = { start: string; end: string }; 4 | 5 | type Meeting = { 6 | day: WeekDay; 7 | location: string; 8 | date: DateTimeRange; 9 | time: DateTimeRange; 10 | }; 11 | export type Meetings = Array; 12 | export type CourseName = { 13 | subject: string; 14 | code: string; 15 | title: string; 16 | }; 17 | 18 | export type Course = { 19 | id: string; 20 | course_id: string; 21 | name: CourseName; 22 | class_number: number; 23 | year: number; 24 | term: string; 25 | campus: string; 26 | units: number; 27 | requirements: unknown; 28 | class_list: Array<{ 29 | id: string; 30 | category: 'enrolment' | 'related'; 31 | type: string; 32 | classes: Array<{ 33 | number: string; 34 | meetings: Meetings; 35 | }>; 36 | }>; 37 | }; 38 | 39 | export type DetailedEnrolledCourse = { 40 | id: string; 41 | name: CourseName; 42 | classes: Array<{ 43 | typeId: string; 44 | type: string; 45 | classNumber: string; 46 | meetings: Meetings; 47 | }>; 48 | }; 49 | 50 | export type WeekCourse = { 51 | id: string; 52 | name: CourseName; 53 | classTypeId: string; 54 | classType: string; 55 | location: string; 56 | classNumber: string; 57 | }; 58 | export type WeekCourses = Array< 59 | Array<{ time: DateTimeRange; courses: Array }> 60 | >; 61 | 62 | export type OtherWeekCourseTime = { 63 | classes: Array<{ number: string; location: string }>; 64 | time: DateTimeRange; 65 | }; 66 | export type OtherWeekCoursesTimes = Array>; 67 | -------------------------------------------------------------------------------- /src/types/key.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/nextui-org/nextui/issues/2182 2 | export type Key = string | number; 3 | -------------------------------------------------------------------------------- /src/utils/date.ts: -------------------------------------------------------------------------------- 1 | import { YEAR } from '../constants/year'; 2 | import dayjs from '../lib/dayjs'; 3 | 4 | export const dateToDayjs = (date: string) => { 5 | return dayjs(date, 'MM-DD').year(YEAR); 6 | }; 7 | export const timeToDayjs = (time: string) => { 8 | return dayjs(time, 'HH:mm'); 9 | }; 10 | 11 | export const getMonday = (date: dayjs.Dayjs) => { 12 | return date.isoWeekday(1); 13 | }; 14 | -------------------------------------------------------------------------------- /src/utils/deduplicate-array.ts: -------------------------------------------------------------------------------- 1 | export const deduplicateArray = (array: Array): Array => { 2 | return [...new Set(array)]; 3 | }; 4 | -------------------------------------------------------------------------------- /src/utils/dnd.ts: -------------------------------------------------------------------------------- 1 | import { 2 | draggable, 3 | dropTargetForElements, 4 | } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; 5 | import type { DependencyList, MutableRefObject } from 'react'; 6 | import { useEffect } from 'react'; 7 | 8 | export const useDrag = ( 9 | ref: MutableRefObject, 10 | props: Omit[0], 'element'>, 11 | deps: DependencyList = [], 12 | ) => { 13 | useEffect(() => { 14 | const element = ref.current; 15 | if (!element) return; 16 | return draggable({ 17 | element, 18 | ...props, 19 | }); 20 | // eslint-disable-next-line react-hooks/exhaustive-deps 21 | }, deps); 22 | }; 23 | 24 | export const useDrop = ( 25 | ref: MutableRefObject, 26 | props: Omit[0], 'element'>, 27 | deps: DependencyList = [], 28 | ) => { 29 | useEffect(() => { 30 | const element = ref.current; 31 | if (!element) return; 32 | return dropTargetForElements({ 33 | element, 34 | ...props, 35 | }); 36 | // eslint-disable-next-line react-hooks/exhaustive-deps 37 | }, deps); 38 | }; 39 | -------------------------------------------------------------------------------- /src/utils/mount.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | export const useMount = (fn: React.EffectCallback) => { 4 | const isMounted = useRef(false); 5 | useEffect(() => { 6 | if (!isMounted.current) { 7 | fn(); 8 | isMounted.current = true; 9 | } 10 | // eslint-disable-next-line react-hooks/exhaustive-deps 11 | }, []); 12 | }; 13 | -------------------------------------------------------------------------------- /src/utils/prefetch-image.ts: -------------------------------------------------------------------------------- 1 | export const prefetchImages = (imagePaths: string[]) => { 2 | imagePaths.forEach((path) => { 3 | const img = new Image(); 4 | img.src = path; 5 | }); 6 | }; 7 | -------------------------------------------------------------------------------- /src/utils/shuffle.ts: -------------------------------------------------------------------------------- 1 | export const shuffle = (array: Array): Array => { 2 | const newArray = [...array]; 3 | for (let i = newArray.length - 1; i > 0; i--) { 4 | const j = Math.floor(Math.random() * (i + 1)); 5 | [newArray[i], newArray[j]] = [newArray[j], newArray[i]]; 6 | } 7 | return newArray; 8 | }; 9 | -------------------------------------------------------------------------------- /src/utils/time-overlap.ts: -------------------------------------------------------------------------------- 1 | import type { DateTimeRange } from '../types/course'; 2 | import { timeToDayjs } from './date'; 3 | 4 | // TODO: Use this function to check if courses clash in a day #5 5 | /** 6 | * Check if two time ranges overlap 7 | * @param a 8 | * @param b 9 | */ 10 | export const timeOverlap = (a: DateTimeRange, b: DateTimeRange) => { 11 | const aStart = timeToDayjs(a.start); 12 | const aEnd = timeToDayjs(a.end); 13 | const bStart = timeToDayjs(b.start); 14 | const bEnd = timeToDayjs(b.end); 15 | return aEnd.isAfter(bStart) && bEnd.isAfter(aStart); 16 | }; 17 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly VITE_API_BASE_URL: string; 5 | readonly VITE_YEAR: string; 6 | readonly VITE_FEEDBACK_FORM_URL: string; 7 | readonly VITE_FEEDBACK_FORM_URL_PREFILL_ERROR_MESSAGE: string; 8 | } 9 | 10 | interface ImportMeta { 11 | readonly env: ImportMetaEnv; 12 | } 13 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | import { heroui } from '@heroui/react'; 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | export default { 5 | content: [ 6 | './index.html', 7 | './src/**/*.{js,ts,jsx,tsx}', 8 | './node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}', 9 | ], 10 | theme: { 11 | extend: { 12 | screens: { 13 | mobile: { max: '767px' }, 14 | }, 15 | fontSize: { 16 | '2xs': ['0.625rem', { lineHeight: '0.75rem' }], 17 | }, 18 | colors: {}, 19 | fontFamily: { 20 | 'noto-emoji': ['"Noto Color Emoji"', 'sans-serif'], 21 | }, 22 | }, 23 | }, 24 | darkMode: 'media', 25 | plugins: [ 26 | heroui({ 27 | themes: { 28 | light: { 29 | colors: { 30 | primary: { DEFAULT: '#FC8500', foreground: '#000000' }, 31 | 'apple-gray': { 300: '#DFDFDF', 500: '#AFAFAF', 700: '#6b6b6b' }, 32 | // Calendar Event Colors 33 | // 300 - bg, 500 - border, 700 - text 34 | 'apple-blue': { 300: '#C9E6FE', 500: '#1D9BF6', 700: '#1D6AA1' }, 35 | 'apple-purple': { 300: '#EACDF4', 500: '#AF38D1', 700: '#762C8B' }, 36 | 'apple-green': { 300: '#D4F6C9', 500: '#4AD321', 700: '#3E8522' }, 37 | 'apple-orange': { 300: '#FEDBC4', 500: '#FA6D0D', 700: '#A75117' }, 38 | 'apple-yellow': { 300: '#FDEEC3', 500: '#FCB80F', 700: '#936E10' }, 39 | 'apple-brown': { 300: '#DFD8CF', 500: '#7D5E3B', 700: '#5E4D39' }, 40 | 'apple-red': { 300: '#FEBFD1', 500: '#F50445', 700: '#BB1644' }, 41 | 'not-found': { 300: '#D3D3D3', 500: '#000000', 700: '#000000' }, 42 | }, 43 | }, 44 | dark: { 45 | colors: { 46 | primary: { DEFAULT: '#FC8500', foreground: '#000000' }, 47 | foreground: '#D5D5D5', 48 | background: '#161718', 49 | 'apple-gray': { 300: '#313131', 500: '#434444', 700: '#8F8F8F' }, 50 | 'apple-blue': { 300: '#19283B', 500: '#1D9BF6', 700: '#1D9BF6' }, 51 | 'apple-purple': { 300: '#2F1E36', 500: '#BF58DA', 700: '#BF57DA' }, 52 | 'apple-green': { 300: '#1D341F', 500: '#30D33B', 700: '#30D33B' }, 53 | 'apple-orange': { 300: '#38271A', 500: '#FD8208', 700: '#FD8208' }, 54 | 'apple-yellow': { 300: '#39341C', 500: '#FECE0F', 700: '#FED10E' }, 55 | 'apple-brown': { 300: '#292621', 500: '#9B7C55', 700: '#9B7C55' }, 56 | 'apple-red': { 300: '#391A21', 500: '#E51167', 700: '#E51166' }, 57 | 'not-found': { 300: '#404040', 500: '#FFFFFF', 700: '#FFFFFF' }, 58 | }, 59 | }, 60 | }, 61 | }), 62 | ], 63 | }; 64 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 5 | "target": "ES2020", 6 | "useDefineForClassFields": true, 7 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 8 | "module": "ESNext", 9 | "skipLibCheck": true, 10 | 11 | /* Bundler mode */ 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "moduleDetection": "force", 17 | "noEmit": true, 18 | "jsx": "react-jsx", 19 | 20 | /* Linting */ 21 | "strict": true, 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | "noFallthroughCasesInSwitch": true, 25 | 26 | "types": ["vitest/globals", "umami"] 27 | }, 28 | "include": ["src", "__tests__"] 29 | } 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./tsconfig.app.json" 6 | }, 7 | { 8 | "path": "./tsconfig.node.json" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 5 | "skipLibCheck": true, 6 | "module": "ESNext", 7 | "moduleResolution": "bundler", 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "noEmit": true 11 | }, 12 | "include": ["vite.config.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react'; 2 | import { VitePWA } from 'vite-plugin-pwa'; 3 | import { defineConfig } from 'vitest/config'; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [ 8 | react(), 9 | VitePWA({ 10 | registerType: 'autoUpdate', 11 | includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'favicon.svg'], 12 | manifest: { 13 | name: 'MyTimetable', 14 | short_name: 'MyTimetable', 15 | description: 16 | 'MyTimetable is a simple drag-and-drop timetable planner for University of Adelaide students.', 17 | display: 'standalone', 18 | background_color: '#FFFFFF', 19 | theme_color: '#FC8500', 20 | icons: [ 21 | { 22 | src: 'pwa-192x192.png', 23 | sizes: '192x192', 24 | type: 'image/png', 25 | purpose: 'any', 26 | }, 27 | { 28 | src: 'pwa-512x512.png', 29 | sizes: '512x512', 30 | type: 'image/png', 31 | purpose: 'any', 32 | }, 33 | { 34 | src: 'pwa-maskable-192x192.png', 35 | sizes: '192x192', 36 | type: 'image/png', 37 | purpose: 'maskable', 38 | }, 39 | { 40 | src: 'pwa-maskable-512x512.png', 41 | sizes: '512x512', 42 | type: 'image/png', 43 | purpose: 'maskable', 44 | }, 45 | ], 46 | }, 47 | }), 48 | ], 49 | test: { 50 | globals: true, 51 | env: { VITE_YEAR: '2024' }, 52 | }, 53 | }); 54 | --------------------------------------------------------------------------------