├── .env ├── .eslintignore ├── .eslintrc.cjs ├── .gitignore ├── README.md ├── index.html ├── jest.config.cjs ├── package.json ├── public ├── .gitignore ├── favicon.ico ├── img │ ├── cbs.png │ ├── example.png │ └── icons │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon.png │ │ ├── crossangles-og.png │ │ ├── crossangles.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── mstile-150x150.png │ │ ├── mstile-310x310.png │ │ ├── mstile-70x70.png │ │ └── safari-pinned-tab.svg └── manifest.json ├── src ├── App.tsx ├── AppContainer.tsx ├── AppWrapper.tsx ├── actions │ ├── colours.ts │ ├── fetchData.ts │ ├── history.ts │ ├── index.ts │ ├── notice.ts │ ├── selection.ts │ └── timetable.ts ├── analytics.ts ├── campusConfig.ts ├── changelog.spec.ts ├── changelog.ts ├── components │ ├── AboutCrossAngles.tsx │ ├── ActionButtons.tsx │ ├── AdditionalCourseDisplay.tsx │ ├── AdditionalEvents.tsx │ ├── AppBar.tsx │ ├── AppOptions.tsx │ ├── Autocomplete │ │ ├── Autocomplete.tsx │ │ ├── ListboxComponent.tsx │ │ ├── PaperComponent.tsx │ │ ├── filter.worker.ts │ │ └── index.ts │ ├── Changelog.tsx │ ├── Colour.tsx │ ├── ColourPicker.tsx │ ├── ContactUs.tsx │ ├── CourseActionButton.tsx │ ├── CourseDisplay.tsx │ ├── CourseList.tsx │ ├── CreateCustom.tsx │ ├── InfoText.tsx │ ├── Notice.tsx │ ├── ScoringConfig.tsx │ ├── TimeOption.tsx │ ├── Timetable │ │ ├── BackgroundStripes.tsx │ │ ├── DeliveryModeIcon.tsx │ │ ├── DropzonePlacement.ts │ │ ├── Placement.ts │ │ ├── SessionDetails.tsx │ │ ├── SessionManager.spec.ts │ │ ├── SessionManager.ts │ │ ├── SessionManagerTypes.ts │ │ ├── SessionPlacement.spec.ts │ │ ├── SessionPlacement.ts │ │ ├── SessionPosition.ts │ │ ├── TimetableDropzone.tsx │ │ ├── TimetableGrid.spec.tsx │ │ ├── TimetableGrid.tsx │ │ ├── TimetableSession.tsx │ │ ├── TimetableTable.tsx │ │ ├── dropzone.spec.ts │ │ ├── dropzones.ts │ │ ├── getHours.spec.ts │ │ ├── getHours.ts │ │ ├── index.ts │ │ ├── timetableTypes.ts │ │ ├── timetableUtil.spec.ts │ │ └── timetableUtil.ts │ ├── TimetableControls.tsx │ ├── TimetableOptions.tsx │ └── WebStream.tsx ├── configureStore.ts ├── containers │ ├── CourseSelection.tsx │ └── TimetableContainer.tsx ├── env.ts ├── getCampus.ts ├── hooks.ts ├── index.tsx ├── migrations.ts ├── reducers │ ├── changelogView.ts │ ├── colours.spec.ts │ ├── colours.ts │ ├── courses.spec.ts │ ├── courses.ts │ ├── index.spec.ts │ ├── index.ts │ ├── meta.spec.ts │ ├── meta.ts │ ├── notice.spec.ts │ ├── notice.ts │ ├── options.spec.ts │ ├── options.ts │ ├── timetables.spec.ts │ ├── timetables.ts │ ├── webStreams.spec.ts │ └── webStreams.ts ├── requestData.ts ├── saveAsICS.ts ├── setupTests.mts ├── state │ ├── Colours.ts │ ├── Course.spec.ts │ ├── Course.ts │ ├── Events.spec.ts │ ├── Events.ts │ ├── Meta.spec.ts │ ├── Meta.ts │ ├── Notice.ts │ ├── Options.ts │ ├── Session.spec.ts │ ├── Session.ts │ ├── StateHistory.spec.ts │ ├── StateHistory.ts │ ├── Stream.spec.ts │ ├── Stream.ts │ ├── Timetable.spec.ts │ ├── Timetable.ts │ ├── __snapshots__ │ │ └── Stream.spec.ts.snap │ ├── index.ts │ ├── selectors.spec.ts │ └── selectors.ts ├── submitContact.ts ├── test_util.ts ├── theme.ts ├── timetable │ ├── GeneticSearch.ts │ ├── TimetableScorerCache.test.ts │ ├── TimetableScorerCache.ts │ ├── coursesToComponents.ts │ ├── getClashInfo.spec.ts │ ├── getClashInfo.ts │ ├── scoreTimetable.ts │ ├── search.worker.ts │ ├── timetableSearch.ts │ └── updateTimetable.tsx ├── transforms.ts └── typeHelpers.ts ├── tsconfig.json ├── vite-env.d.ts ├── vite.config.ts └── yarn.lock /.env: -------------------------------------------------------------------------------- 1 | VITE_BASE_URL=staging.crossangles.app 2 | VITE_CAMPUS=unsw 3 | VITE_STAGE_NAME=staging 4 | VITE_CONTACT_ENDPOINT=https://aacdecxwp2.execute-api.ap-southeast-2.amazonaws.com/staging 5 | VITE_DATA_ROOT_URI=https://d24sfwmr7pqe8r.cloudfront.net # production 6 | # VITE_DATA_ROOT_URI=https://d3raq9ust6yiku.cloudfront.net # staging 7 | GENERATE_SOURCEMAP=false 8 | ESLINT_NO_DEV_ERRORS=true 9 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /build 3 | /coverage 4 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | parserOptions: { 5 | project: './tsconfig.json', 6 | ecmaVersion: 2020, 7 | }, 8 | plugins: [ 9 | '@typescript-eslint', 10 | 'import', 11 | 'jest', 12 | 'react', 13 | ], 14 | settings: { 15 | react: { 16 | version: 'detect', 17 | }, 18 | }, 19 | extends: [ 20 | 'airbnb-typescript', 21 | 'plugin:import/errors', 22 | 'plugin:import/warnings', 23 | 'plugin:import/typescript', 24 | 'plugin:jest/recommended', 25 | 'plugin:react/recommended', 26 | ], 27 | rules: { 28 | '@typescript-eslint/lines-between-class-members': ['error', 'always', { exceptAfterSingleLine: true }], 29 | '@typescript-eslint/no-shadow': "error", 30 | '@typescript-eslint/no-use-before-define': ['error', { 'functions': false }], 31 | '@typescript-eslint/no-unused-vars': ['error', { 'argsIgnorePattern': '^_+$' }], 32 | 'arrow-parens': ['error', 'as-needed'], 33 | 'class-methods-use-this': 'off', 34 | 'import/no-cycle': 'off', 35 | 'no-console': ['error', { allow: ['warn', 'error'] }], 36 | 'no-continue': 'off', 37 | 'no-else-return': ['error', { allowElseIf: true }], 38 | 'no-multiple-empty-lines': ['error', { 'max': 2, 'maxEOF': 1 }], 39 | 'no-plusplus': ['error', { 'allowForLoopAfterthoughts': true }], 40 | 'no-underscore-dangle': 'off', 41 | 'no-use-before-define': 'off', 42 | 'no-restricted-syntax': [ 43 | 'error', 44 | // Taken from eslint-airbnb-config and removed rule forbidding `for ... of` loops 45 | { 46 | selector: 'ForInStatement', 47 | message: 'for..in loops iterate over the entire prototype chain, which is virtually never what you want. Use Object.{keys,values,entries}, and iterate over the resulting array.', 48 | }, 49 | { 50 | selector: 'LabeledStatement', 51 | message: 'Labels are a form of GOTO; using them makes code confusing and hard to maintain and understand.', 52 | }, 53 | { 54 | selector: 'WithStatement', 55 | message: '`with` is disallowed in strict mode because it makes code impossible to predict and optimize.', 56 | }, 57 | ], 58 | 'no-shadow': 'off', 59 | 'object-curly-newline': 'off', 60 | 'prefer-destructuring': 'off', 61 | 'radix': 'off', 62 | 'react/destructuring-assignment': 'off', 63 | 'react/jsx-indent': ['error', 2, {checkAttributes: true, indentLogicalExpressions: true}], 64 | 'react/jsx-one-expression-per-line': 'off', 65 | 'react/jsx-props-no-spreading': 'off', 66 | 'react/require-default-props': 'off', 67 | }, 68 | overrides: [ 69 | { 70 | files: ['**/*.worker.ts'], 71 | rules: { 72 | 'no-restricted-globals': 'off', 73 | }, 74 | }, 75 | { 76 | files: ['**/reducers/*'], 77 | rules: { 78 | '@typescript-eslint/default-param-last': 'off', 79 | }, 80 | }, 81 | ], 82 | }; 83 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # other 15 | /public/data*.json 16 | 17 | # misc 18 | .DS_Store 19 | .env.local 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | .vscode 24 | 25 | npm-debug.log* 26 | yarn-debug.log* 27 | yarn-error.log* 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CrossAngles 2 | 3 | Welcome to CrossAngles! This is the official repository for the unofficial 4 | timetable planner. This repository is for the development of CrossAngles, 5 | if you're looking for the app to use it, you can find it at: 6 | https://crossangles.app 7 | 8 | ## Getting started 9 | 10 | To get started with developing locally, clone the repository, and install the 11 | dependencies using: 12 | 13 | ```bash 14 | yarn install 15 | ``` 16 | 17 | ## Running the app development server 18 | 19 | To build and serve the web app locally, you can use: 20 | 21 | ```bash 22 | yarn start 23 | ``` 24 | 25 | ## Running tests 26 | 27 | ```bash 28 | # Run the unit tests 29 | yarn test 30 | 31 | # Lint the code 32 | yarn lint 33 | ``` 34 | 35 | The unit tests use Jest, and linting is done with ESLint. 36 | 37 | ## Contributing 38 | 39 | If you wish to contribute you are very welcome to fork this repository and 40 | submit pull requests, but please refrain from distributing it or any 41 | derivatives. 42 | 43 | ### Adding tests and making changes 44 | When adding new features or touching existing code it is expected to add 45 | suitable test coverage for the code as well. Usually, for `example.ts`, the 46 | tests would go in `example.spec.ts`. 47 | 48 | ### Automated Testing 49 | This repository has automated testing set up, and it will run all the tests on 50 | the code before your PR can be merged. 51 | 52 | ### Code Review 53 | Another quality gate is the code review process. At least one person must review 54 | each pull-request before it can be merged. 55 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | CrossAngles Timetable Planner 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 38 | 39 |
40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest/presets/default-esm', 3 | testEnvironment: 'node', 4 | testMatch: [ "**/__tests__/**/*.ts?(x)", "**/?(*.)+(spec|test).ts?(x)" ], 5 | extensionsToTreatAsEsm: ['.ts'], 6 | setupFilesAfterEnv: ['./src/setupTests.mts'], 7 | moduleNameMapper: { 8 | '^(\\.{1,2}/.*)\\.js$': '$1', 9 | }, 10 | transform: { 11 | '^.+\\.m?[tj]sx?$': [ 12 | 'ts-jest', 13 | { 14 | useESM: true, 15 | }, 16 | ], 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "crossangles", 3 | "version": "0.1.0", 4 | "license": "UNLICENSED", 5 | "private": true, 6 | "type": "module", 7 | "dependencies": { 8 | "@loadable/component": "^5.15.3", 9 | "@material-ui/core": "^4.11.0", 10 | "@material-ui/icons": "^4.9.1", 11 | "@material-ui/lab": "^4.0.0-alpha.56", 12 | "@vitejs/plugin-react": "^4.2.1", 13 | "autosuggest-highlight": "^3.1.1", 14 | "axios": "^0.21.1", 15 | "downloadjs": "^1.4.7", 16 | "ics": "^2.35.0", 17 | "match-sorter": "^6.1.0", 18 | "react": "^18.2.0", 19 | "react-color": "^2.18.1", 20 | "react-dom": "^18.2.0", 21 | "react-draggable": "^4.4.6", 22 | "react-ga": "^3.3.1", 23 | "react-redux": "^9.0.3", 24 | "react-select": "^5.8.0", 25 | "react-transition-group": "^4.4.1", 26 | "react-window": "^1.8.5", 27 | "redux": "^4.0.5", 28 | "redux-persist": "^5.10.0", 29 | "redux-thunk": "^2.3.0", 30 | "reselect": "^4.0.0", 31 | "vite": "^5.0.7", 32 | "vite-tsconfig-paths": "^4.2.2" 33 | }, 34 | "devDependencies": { 35 | "@types/autosuggest-highlight": "^3.1.0", 36 | "@types/downloadjs": "^1.4.2", 37 | "@types/jest": "^29.5.11", 38 | "@types/loadable__component": "^5.13.8", 39 | "@types/node": "^20.10.4", 40 | "@types/react": "^18.2.43", 41 | "@types/react-color": "^3.0.4", 42 | "@types/react-dom": "^18.2.17", 43 | "@types/react-redux": "^7.1.32", 44 | "@types/react-transition-group": "^4.4.0", 45 | "@types/react-window": "^1.8.2", 46 | "@typescript-eslint/eslint-plugin": "^6.13.2", 47 | "@typescript-eslint/parser": "^6.13.2", 48 | "eslint": "^8.55.0", 49 | "eslint-config-airbnb-typescript": "^17.1.0", 50 | "eslint-plugin-import": "^2.22.0", 51 | "eslint-plugin-jest": "^23.20.0", 52 | "eslint-plugin-jsx-a11y": "^6.3.1", 53 | "eslint-plugin-react": "^7.20.5", 54 | "eslint-plugin-react-hooks": "^4.0.8", 55 | "jest": "^29.7.0", 56 | "jest-environment-jsdom": "^29.7.0", 57 | "ts-jest": "^29.1.1", 58 | "typescript": "^4.2.4" 59 | }, 60 | "scripts": { 61 | "start": "vite", 62 | "build": "tsc && vite build", 63 | "preview": "vite preview", 64 | "test": "jest", 65 | "lint": "./node_modules/.bin/eslint src/ --ext .ts,.tsx" 66 | }, 67 | "eslintConfig": { 68 | "extends": "react-app" 69 | }, 70 | "browserslist": { 71 | "production": [ 72 | ">0.2%", 73 | "not dead", 74 | "not op_mini all" 75 | ], 76 | "development": [ 77 | "last 1 chrome version", 78 | "last 1 firefox version", 79 | "last 1 safari version" 80 | ] 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /public/.gitignore: -------------------------------------------------------------------------------- 1 | /unsw 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iota-pi/crossangles/21d914f1220a4e212c27a3ee64c814109092d088/public/favicon.ico -------------------------------------------------------------------------------- /public/img/cbs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iota-pi/crossangles/21d914f1220a4e212c27a3ee64c814109092d088/public/img/cbs.png -------------------------------------------------------------------------------- /public/img/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iota-pi/crossangles/21d914f1220a4e212c27a3ee64c814109092d088/public/img/example.png -------------------------------------------------------------------------------- /public/img/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iota-pi/crossangles/21d914f1220a4e212c27a3ee64c814109092d088/public/img/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/img/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iota-pi/crossangles/21d914f1220a4e212c27a3ee64c814109092d088/public/img/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iota-pi/crossangles/21d914f1220a4e212c27a3ee64c814109092d088/public/img/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /public/img/icons/crossangles-og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iota-pi/crossangles/21d914f1220a4e212c27a3ee64c814109092d088/public/img/icons/crossangles-og.png -------------------------------------------------------------------------------- /public/img/icons/crossangles.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iota-pi/crossangles/21d914f1220a4e212c27a3ee64c814109092d088/public/img/icons/crossangles.png -------------------------------------------------------------------------------- /public/img/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iota-pi/crossangles/21d914f1220a4e212c27a3ee64c814109092d088/public/img/icons/favicon-16x16.png -------------------------------------------------------------------------------- /public/img/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iota-pi/crossangles/21d914f1220a4e212c27a3ee64c814109092d088/public/img/icons/favicon-32x32.png -------------------------------------------------------------------------------- /public/img/icons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iota-pi/crossangles/21d914f1220a4e212c27a3ee64c814109092d088/public/img/icons/mstile-150x150.png -------------------------------------------------------------------------------- /public/img/icons/mstile-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iota-pi/crossangles/21d914f1220a4e212c27a3ee64c814109092d088/public/img/icons/mstile-310x310.png -------------------------------------------------------------------------------- /public/img/icons/mstile-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iota-pi/crossangles/21d914f1220a4e212c27a3ee64c814109092d088/public/img/icons/mstile-70x70.png -------------------------------------------------------------------------------- /public/img/icons/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#303F9F", 14 | "background_color": "#fafafa" 15 | } 16 | -------------------------------------------------------------------------------- /src/AppContainer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | import { ThemeProvider } from '@material-ui/core'; 4 | import loadable from '@loadable/component'; 5 | import { getOptions } from './state/selectors'; 6 | import { theme } from './theme'; 7 | 8 | const App = loadable(() => import('./AppWrapper')); 9 | 10 | export const AppContainer = () => { 11 | const { darkMode } = useSelector(getOptions); 12 | 13 | return ( 14 | 15 | 16 | 17 | ); 18 | }; 19 | export default AppContainer; 20 | -------------------------------------------------------------------------------- /src/AppWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Provider } from 'react-redux'; 3 | import { PersistGate } from 'redux-persist/integration/react'; 4 | import { store, persistor } from './configureStore'; 5 | import { fetchData } from './actions'; 6 | import App from './App'; 7 | 8 | const previousMeta = store.getState().meta; 9 | store.dispatch(fetchData(previousMeta)); 10 | 11 | export function wrapApp(AppComponent: typeof App) { 12 | const AppWraper = () => ( 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | return AppWraper; 20 | } 21 | 22 | export default wrapApp(App); 23 | -------------------------------------------------------------------------------- /src/actions/colours.ts: -------------------------------------------------------------------------------- 1 | import { event } from 'react-ga'; 2 | import { Action } from 'redux'; 3 | import { Colour, CourseId } from '../state'; 4 | import { CATEGORY } from '../analytics'; 5 | 6 | export const SET_COLOUR = 'SET_COLOUR'; 7 | 8 | export interface ColourAction extends Action { 9 | type: typeof SET_COLOUR; 10 | course: CourseId; 11 | colour?: Colour; 12 | } 13 | 14 | export function setColour(course: CourseId, colour?: Colour): ColourAction { 15 | event({ 16 | category: CATEGORY, 17 | action: 'Change Colour', 18 | label: colour, 19 | }); 20 | 21 | return { 22 | type: SET_COLOUR, 23 | course, 24 | colour, 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /src/actions/fetchData.ts: -------------------------------------------------------------------------------- 1 | import { Action, AnyAction } from 'redux'; 2 | import { ThunkAction } from 'redux-thunk'; 3 | import { requestData } from '../requestData'; 4 | import { CourseData, Meta } from '../state'; 5 | 6 | export const SET_COURSE_DATA = 'SET_COURSE_DATA'; 7 | 8 | export interface CourseListAction extends Action { 9 | type: typeof SET_COURSE_DATA; 10 | courses: CourseData[]; 11 | meta: Meta; 12 | isNewTerm: boolean, 13 | } 14 | 15 | type FetchDataAction = ThunkAction, {}, undefined, AnyAction>; 16 | export function fetchData(prevMeta: Meta): FetchDataAction { 17 | return async dispatch => requestData().then(data => { 18 | const { term, year } = data.meta; 19 | const setCourseAction: CourseListAction = { 20 | type: SET_COURSE_DATA, 21 | courses: data.courses, 22 | meta: data.meta, 23 | isNewTerm: prevMeta.year !== year || prevMeta.term !== term, 24 | }; 25 | 26 | return dispatch(setCourseAction); 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /src/actions/history.ts: -------------------------------------------------------------------------------- 1 | import { event } from 'react-ga'; 2 | import { Action } from 'redux'; 3 | import { CATEGORY } from '../analytics'; 4 | 5 | export const UNDO = 'UNDO'; 6 | export const REDO = 'REDO'; 7 | export interface HistoryAction extends Action { 8 | type: typeof UNDO | typeof REDO, 9 | } 10 | 11 | export function undoTimetable() { 12 | event({ 13 | category: CATEGORY, 14 | action: 'History: Undo', 15 | }); 16 | 17 | return { type: UNDO }; 18 | } 19 | 20 | export function redoTimetable() { 21 | event({ 22 | category: CATEGORY, 23 | action: 'History: Redo', 24 | }); 25 | 26 | return { type: REDO }; 27 | } 28 | -------------------------------------------------------------------------------- /src/actions/index.ts: -------------------------------------------------------------------------------- 1 | import { ColourAction } from './colours'; 2 | import { CourseListAction } from './fetchData'; 3 | import { HistoryAction } from './history'; 4 | import { NoticeAction, SetChangelogViewAction } from './notice'; 5 | import { CourseAction, EventAction, ToggleShowEventsAction, ToggleOptionAction, SetScoreConfigAction } from './selection'; 6 | import { SessionManagerAction, SuggestionAction, UnplacedCountAction } from './timetable'; 7 | 8 | export * from './colours'; 9 | export * from './fetchData'; 10 | export * from './history'; 11 | export * from './notice'; 12 | export * from './selection'; 13 | export * from './timetable'; 14 | 15 | export type AllActions = ( 16 | ColourAction | 17 | CourseAction | 18 | CourseListAction | 19 | EventAction | 20 | HistoryAction | 21 | NoticeAction | 22 | SessionManagerAction | 23 | SetChangelogViewAction | 24 | SetScoreConfigAction | 25 | SuggestionAction | 26 | ToggleOptionAction | 27 | ToggleShowEventsAction | 28 | UnplacedCountAction 29 | ); 30 | -------------------------------------------------------------------------------- /src/actions/notice.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import { Action } from 'redux'; 3 | import { Notice, DEFAULT_NOTICE_TIMEOUT } from '../state'; 4 | 5 | export const SET_CHANGELOG_VIEW = 'SET_CHANGELOG_VIEW'; 6 | export const SET_NOTICE = 'SET_NOTICE'; 7 | export const CLEAR_NOTICE = 'CLEAR_NOTICE'; 8 | 9 | export interface SetNoticeAction extends Action, Notice { 10 | type: typeof SET_NOTICE, 11 | } 12 | 13 | export interface ClearNoticeAction extends Action { 14 | type: typeof CLEAR_NOTICE, 15 | } 16 | 17 | export interface SetChangelogViewAction extends Action { 18 | type: typeof SET_CHANGELOG_VIEW, 19 | } 20 | 21 | export type NoticeAction = SetNoticeAction | ClearNoticeAction; 22 | 23 | export function setNotice( 24 | message: string, 25 | actions?: ReactNode[], 26 | timeout: number | null = DEFAULT_NOTICE_TIMEOUT, 27 | callback?: () => void, 28 | ): NoticeAction { 29 | return { 30 | type: SET_NOTICE, 31 | message, 32 | actions: actions || [], 33 | timeout, 34 | callback, 35 | }; 36 | } 37 | 38 | export function clearNotice(): NoticeAction { 39 | return { type: CLEAR_NOTICE }; 40 | } 41 | 42 | export function setChangelogView(): SetChangelogViewAction { 43 | return { type: SET_CHANGELOG_VIEW }; 44 | } 45 | -------------------------------------------------------------------------------- /src/actions/selection.ts: -------------------------------------------------------------------------------- 1 | import { event } from 'react-ga'; 2 | import { Action } from 'redux'; 3 | import { CourseData, CourseId, getCourseId, AdditionalEvent, OptionName } from '../state'; 4 | import { CATEGORY } from '../analytics'; 5 | import { TimetableScoreConfig } from '../timetable/scoreTimetable'; 6 | 7 | // Chosen courses 8 | export const ADD_COURSE = 'ADD_COURSE'; 9 | export const REMOVE_COURSE = 'REMOVE_COURSE'; 10 | export const TOGGLE_WEB_STREAM = 'TOGGLE_WEB_STREAM'; 11 | 12 | export interface CourseAction extends Action { 13 | type: typeof ADD_COURSE | typeof REMOVE_COURSE | typeof TOGGLE_WEB_STREAM; 14 | course: CourseData; 15 | } 16 | 17 | export function addCourse(course: CourseData): CourseAction { 18 | event({ 19 | category: CATEGORY, 20 | action: 'Add Course', 21 | label: getCourseId(course), 22 | }); 23 | 24 | return { 25 | type: ADD_COURSE, 26 | course, 27 | }; 28 | } 29 | 30 | export function removeCourse(course: CourseData): CourseAction { 31 | event({ 32 | category: CATEGORY, 33 | action: 'Remove Course', 34 | label: getCourseId(course), 35 | }); 36 | 37 | return { 38 | type: REMOVE_COURSE, 39 | course, 40 | }; 41 | } 42 | 43 | // Web streams 44 | export function toggleWebStream(course: CourseData): CourseAction { 45 | event({ 46 | category: CATEGORY, 47 | action: 'Toggle Web Stream', 48 | label: getCourseId(course), 49 | }); 50 | 51 | return { 52 | type: TOGGLE_WEB_STREAM, 53 | course, 54 | }; 55 | } 56 | 57 | // Events 58 | export const TOGGLE_EVENT = 'TOGGLE_EVENT'; 59 | 60 | export interface EventAction extends Action { 61 | type: typeof TOGGLE_EVENT; 62 | event: AdditionalEvent; 63 | } 64 | 65 | export function toggleEvent(additionalEvent: AdditionalEvent): EventAction { 66 | event({ 67 | category: CATEGORY, 68 | action: 'Toggle Event', 69 | label: additionalEvent.name, 70 | }); 71 | 72 | return { 73 | type: TOGGLE_EVENT, 74 | event: additionalEvent, 75 | }; 76 | } 77 | 78 | export const TOGGLE_SHOW_EVENTS = 'TOGGLE_SHOW_EVENTS'; 79 | 80 | export interface ToggleShowEventsAction extends Action { 81 | type: typeof TOGGLE_SHOW_EVENTS; 82 | course: CourseId; 83 | } 84 | 85 | export function toggleShowEvents(courseId: CourseId): ToggleShowEventsAction { 86 | event({ 87 | category: CATEGORY, 88 | action: 'Toggle Show Events', 89 | label: courseId, 90 | }); 91 | 92 | return { 93 | type: TOGGLE_SHOW_EVENTS, 94 | course: courseId, 95 | }; 96 | } 97 | 98 | // Options 99 | export const TOGGLE_OPTION = 'TOGGLE_OPTION'; 100 | 101 | export interface ToggleOptionAction extends Action { 102 | type: typeof TOGGLE_OPTION; 103 | option: OptionName; 104 | value?: boolean; 105 | } 106 | 107 | export function toggleOption(option: OptionName, value?: boolean): ToggleOptionAction { 108 | event({ 109 | category: CATEGORY, 110 | action: 'Toggle Option', 111 | label: option, 112 | }); 113 | 114 | return { 115 | type: TOGGLE_OPTION, 116 | option, 117 | value, 118 | }; 119 | } 120 | 121 | // Score Config 122 | export const SET_SCORE_CONFIG = 'SET_SCORE_CONFIG'; 123 | 124 | export interface SetScoreConfigAction extends Action { 125 | type: typeof SET_SCORE_CONFIG; 126 | config: TimetableScoreConfig; 127 | } 128 | 129 | export function setScoreConfig(config: TimetableScoreConfig): SetScoreConfigAction { 130 | return { 131 | type: SET_SCORE_CONFIG, 132 | config, 133 | }; 134 | } 135 | -------------------------------------------------------------------------------- /src/actions/timetable.ts: -------------------------------------------------------------------------------- 1 | import { Action } from 'redux'; 2 | import { YearAndTerm, getCurrentTerm } from '../state'; 3 | import { SessionManagerData } from '../components/Timetable/SessionManagerTypes'; 4 | 5 | export const UPDATE_SESSION_MANAGER = 'UPDATE_SESSION_MANAGER'; 6 | 7 | export interface SessionManagerAction extends Action { 8 | type: typeof UPDATE_SESSION_MANAGER, 9 | sessionManager: SessionManagerData, 10 | term: string, 11 | } 12 | 13 | 14 | export const UPDATE_SUGGESTED_TIMETABLE = 'UPDATE_SUGGESTED_TIMETABLE'; 15 | 16 | export interface SuggestionAction extends Action { 17 | type: typeof UPDATE_SUGGESTED_TIMETABLE, 18 | score: number | null, 19 | } 20 | 21 | 22 | export const UPDATE_UNPLACED_COUNT = 'UPDATE_UNPLACED_COUNT'; 23 | 24 | export interface UnplacedCountAction extends Action { 25 | type: typeof UPDATE_UNPLACED_COUNT, 26 | count: number, 27 | } 28 | 29 | 30 | export function setTimetable( 31 | newTimetable: SessionManagerData, 32 | meta: YearAndTerm, 33 | ): SessionManagerAction { 34 | return { 35 | type: UPDATE_SESSION_MANAGER, 36 | sessionManager: newTimetable, 37 | term: getCurrentTerm(meta), 38 | }; 39 | } 40 | 41 | export function setSuggestionScore(score: number | null): SuggestionAction { 42 | return { 43 | type: UPDATE_SUGGESTED_TIMETABLE, 44 | score, 45 | }; 46 | } 47 | 48 | export function setUnplacedCount(count: number): UnplacedCountAction { 49 | return { 50 | type: UPDATE_UNPLACED_COUNT, 51 | count, 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /src/analytics.ts: -------------------------------------------------------------------------------- 1 | import { initialize, pageview } from 'react-ga'; 2 | 3 | export const initialiseGA = () => { 4 | initialize('UA-101186620-1', { titleCase: false }); 5 | }; 6 | 7 | export const pageView = () => { 8 | pageview(window.location.pathname); 9 | }; 10 | 11 | export const CATEGORY = 'CrossAngles React'; 12 | -------------------------------------------------------------------------------- /src/campusConfig.ts: -------------------------------------------------------------------------------- 1 | import env from './env'; 2 | 3 | export interface AllCampusConfig { 4 | [campus: string]: CampusConfig, 5 | } 6 | 7 | export interface CampusConfig { 8 | dataPath: string, 9 | name: string, 10 | longname: string, 11 | } 12 | 13 | export const DATA_ROOT_URI = env.rootURI; 14 | 15 | export const campusConfig: AllCampusConfig = { 16 | unsw: { 17 | dataPath: `${DATA_ROOT_URI}/unsw/data.json`, 18 | name: 'UNSW', 19 | longname: 'University of New South Wales', 20 | }, 21 | }; 22 | 23 | export default campusConfig; 24 | -------------------------------------------------------------------------------- /src/changelog.spec.ts: -------------------------------------------------------------------------------- 1 | import changelog from './changelog'; 2 | 3 | 4 | describe('changelog entry sanity', () => { 5 | it('is in descending date order', () => { 6 | const sorted = changelog.slice().sort((a, b) => +(a.date < b.date) - +(a.date > b.date)); 7 | expect(changelog).toEqual(sorted); 8 | }); 9 | 10 | it('is not future-dated', () => { 11 | expect(changelog[0].date < new Date()).toBe(true); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/changelog.ts: -------------------------------------------------------------------------------- 1 | export interface LogEntry { 2 | date: Date, 3 | summary: string, 4 | details?: string[], 5 | boring?: boolean, 6 | id: number, 7 | } 8 | 9 | const rawChangelog: Omit[] = [ 10 | { 11 | date: new Date(2021, 1, 17), 12 | summary: 'Improved drag-and-drop', 13 | details: [ 14 | 'Timetable preview', 15 | 'Improve clarity for overlapping streams', 16 | 'General quality of life updates', 17 | ], 18 | }, 19 | { 20 | date: new Date(2021, 1, 19), 21 | summary: 'Added changelog', 22 | }, 23 | { 24 | date: new Date(2021, 2, 20), 25 | summary: 'Bugfixes', 26 | details: [ 27 | 'Dark mode toggle for browsers running in dark mode', 28 | 'Rare issue affecting timetable generation', 29 | ], 30 | boring: true, 31 | }, 32 | { 33 | date: new Date(2021, 3, 20), 34 | summary: 'Customisable auto-timetabling', 35 | details: [ 36 | 'Accessible through settings menu', 37 | ], 38 | }, 39 | { 40 | date: new Date(2022, 6, 3), 41 | summary: 'Save timetable to personal calendar', 42 | details: [ 43 | 'Replaces the previous save-as-image feature', 44 | ], 45 | }, 46 | ]; 47 | 48 | const changelog: LogEntry[] = rawChangelog.map((item, i) => ({ ...item, id: i })).reverse(); 49 | 50 | export default changelog; 51 | 52 | export function getUpdateMessage(updateCount: number) { 53 | const n = updateCount === 1 ? 'A' : updateCount.toString(); 54 | const s = updateCount === 1 ? '' : 's'; 55 | const have = updateCount === 1 ? 'has' : 'have'; 56 | const choices: string[] = []; 57 | if (updateCount === 1) { 58 | choices.push( 59 | 'CrossAngles just got even better!', 60 | ); 61 | } else { 62 | choices.push( 63 | `${n} new update${s} just landed!`, 64 | `There ${have} been ${n} new update${s}`, 65 | ); 66 | } 67 | return choices[Math.floor(Math.random() * choices.length)]; 68 | } 69 | -------------------------------------------------------------------------------- /src/components/AboutCrossAngles.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import makeStyles from '@material-ui/core/styles/makeStyles'; 3 | import IconButton from '@material-ui/core/IconButton'; 4 | import Dialog from '@material-ui/core/Dialog'; 5 | import DialogTitle from '@material-ui/core/DialogTitle'; 6 | import DialogContent from '@material-ui/core/DialogContent'; 7 | import Typography from '@material-ui/core/Typography'; 8 | import InfoIcon from '@material-ui/icons/InfoOutlined'; 9 | import CloseIcon from '@material-ui/icons/Close'; 10 | import { modalview } from 'react-ga'; 11 | 12 | 13 | const useStyles = makeStyles(theme => ({ 14 | dialogTitle: { 15 | display: 'flex', 16 | alignItems: 'center', 17 | paddingBottom: theme.spacing(1), 18 | }, 19 | flexGrow: { 20 | flexGrow: 1, 21 | }, 22 | moveRight: { 23 | marginRight: theme.spacing(-1), 24 | }, 25 | link: { 26 | color: theme.palette.primary.main, 27 | textDecoration: 'underline', 28 | cursor: 'pointer', 29 | }, 30 | })); 31 | 32 | 33 | const AboutCrossAngles = ({ onShowContact }: { onShowContact: () => void }) => { 34 | const classes = useStyles(); 35 | const [showDialog, setShowDialog] = React.useState(false); 36 | 37 | const handleOpen = React.useCallback( 38 | () => { 39 | setShowDialog(true); 40 | modalview('about-crossangles'); 41 | }, 42 | [], 43 | ); 44 | const handleClose = React.useCallback( 45 | () => setShowDialog(false), 46 | [], 47 | ); 48 | 49 | return ( 50 |
51 | 55 | 56 | 57 | 58 | 62 | 63 | 64 | About CrossAngles 65 | 66 | 67 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | CrossAngles is a timetable planning program, which aims to provide 79 | a simple way for students to create their timetable for UNSW. 80 | However, it is not an official part of the UNSW enrolment system, 81 | and planning your timetable here does not mean 82 | that you have registered or enrolled for the term yet. 83 | 84 | 85 | 86 | CrossAngles is provided by 87 | {' '} 88 | 94 | Campus Bible Study 95 | 96 | {' '} 97 | — a group of people at UNSW who are interested in 98 | learning together about Jesus from the Bible. 99 | Whether you follow Jesus, or want to find out what He's all about, 100 | Campus Bible Study is a great place for you to learn more. 101 | If you've never come before, we recommend checking out the Bible talks. 102 | 103 | 104 | 105 | If you have any questions or suggestions, 106 | please 107 | {' '} 108 | { event.preventDefault(); onShowContact(); }} 111 | href="#contact" 112 | > 113 | contact us 114 | 115 | . 116 | 117 | 118 | 119 | If you would like to contribute or view the source code, you can find 120 | {' '} 121 | 127 | CrossAngles on GitHub 128 | 129 | . 130 | 131 | 132 | 133 |
134 | ); 135 | }; 136 | export default AboutCrossAngles; 137 | -------------------------------------------------------------------------------- /src/components/ActionButtons.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react'; 2 | import { Theme, createStyles, WithStyles, withStyles } from '@material-ui/core/styles'; 3 | import { event } from 'react-ga'; 4 | 5 | import Button from '@material-ui/core/Button'; 6 | import OpenInNewIcon from '@material-ui/icons/OpenInNew'; 7 | import CalendarIcon from '@material-ui/icons/CalendarToday'; 8 | import CircularProgress from '@material-ui/core/CircularProgress'; 9 | import { CourseData, Meta } from '../state'; 10 | import { CATEGORY } from '../analytics'; 11 | 12 | const styles = (theme: Theme) => createStyles({ 13 | root: { 14 | display: 'flex', 15 | justifyContent: 'center', 16 | }, 17 | buttonHolder: { 18 | display: 'flex', 19 | flexDirection: 'column', 20 | }, 21 | wrapper: { 22 | position: 'relative', 23 | }, 24 | baseButton: { 25 | flexGrow: 1, 26 | flexBasis: 0, 27 | borderRadius: 26, 28 | minHeight: 52, 29 | marginBottom: theme.spacing(2), 30 | }, 31 | signUpButton: { 32 | borderColor: theme.palette.grey[700], 33 | lineHeight: 1.35, 34 | paddingTop: 5, 35 | paddingBottom: 5, 36 | textTransform: 'none', 37 | }, 38 | buttonProgress: { 39 | position: 'absolute', 40 | top: '50%', 41 | left: '50%', 42 | marginTop: -12, 43 | marginLeft: -12, 44 | }, 45 | fontNormal: { 46 | fontWeight: 500, 47 | }, 48 | fontLight: { 49 | fontWeight: 400, 50 | }, 51 | centredText: { 52 | textAlign: 'center', 53 | paddingLeft: theme.spacing(1.5), 54 | paddingRight: theme.spacing(1.5), 55 | }, 56 | }); 57 | 58 | export interface Props extends WithStyles { 59 | additional: CourseData[], 60 | meta: Meta, 61 | disabled?: boolean, 62 | showSignup: boolean, 63 | isSavingICS: boolean, 64 | onSaveAsICS: () => void, 65 | className?: string, 66 | } 67 | 68 | export const ActionButtons = withStyles(styles)(({ 69 | additional, 70 | meta, 71 | disabled, 72 | showSignup, 73 | isSavingICS, 74 | onSaveAsICS, 75 | className, 76 | classes, 77 | }: Props) => { 78 | // Assumption: only one additional course will be auto-selected and has metadata 79 | const ministry = additional.filter(c => c.autoSelect && c.metadata)[0]; 80 | 81 | const handleLinkClick = (destination?: string) => { 82 | event({ 83 | category: CATEGORY, 84 | action: 'Signup Link', 85 | label: destination, 86 | }); 87 | }; 88 | 89 | let signupButton: ReactNode = null; 90 | if (ministry && showSignup) { 91 | const ministryMeta = ministry.metadata!; 92 | const isValid = ministryMeta.signupValidFor.some( 93 | ({ year, term }) => meta.year === year && meta.term === term, 94 | ); 95 | 96 | if (isValid) { 97 | signupButton = ( 98 | 120 | ); 121 | } 122 | } 123 | 124 | const rootClassList = [classes.root]; 125 | if (className) rootClassList.push(className); 126 | const rootClasses = rootClassList.join(' '); 127 | 128 | return ( 129 |
130 |
131 | 149 | 150 | {signupButton} 151 |
152 |
153 | ); 154 | }); 155 | 156 | export default ActionButtons; 157 | -------------------------------------------------------------------------------- /src/components/AdditionalEvents.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | import makeStyles from '@material-ui/core/styles/makeStyles'; 4 | import FormControlLabel from '@material-ui/core/FormControlLabel'; 5 | import Checkbox from '@material-ui/core/Checkbox'; 6 | import { AdditionalEvent, CourseData, getEvents } from '../state'; 7 | import { getOptions } from '../state/selectors'; 8 | 9 | const useStyles = makeStyles(theme => ({ 10 | root: { 11 | width: '100%', 12 | display: 'flex', 13 | flexWrap: 'wrap', 14 | }, 15 | eventContainer: { 16 | margin: 0, 17 | flexGrow: 0, 18 | maxWidth: '100%', 19 | flexBasis: '100%', 20 | 21 | [theme.breakpoints.only('sm')]: { 22 | maxWidth: '50%', 23 | flexBasis: '50%', 24 | }, 25 | [theme.breakpoints.up('md')]: { 26 | maxWidth: '33.333333%', 27 | flexBasis: '33.333333%', 28 | }, 29 | }, 30 | quarterContainer: { 31 | [theme.breakpoints.up('md')]: { 32 | maxWidth: '25%', 33 | flexBasis: '25%', 34 | }, 35 | }, 36 | lessSpaceAbove: { 37 | marginTop: -theme.spacing(0.75), 38 | }, 39 | secondaryText: { 40 | color: theme.palette.text.secondary, 41 | }, 42 | })); 43 | 44 | export interface Props { 45 | course: CourseData, 46 | events: AdditionalEvent[], 47 | onToggleEvent: (event: AdditionalEvent) => void, 48 | } 49 | 50 | const AdditionalEventsComponent = ({ 51 | course, 52 | events, 53 | onToggleEvent, 54 | }: Props) => { 55 | const classes = useStyles(); 56 | const { darkMode } = useSelector(getOptions); 57 | 58 | const baseEventList = getEvents(course); 59 | const baseEventIds = baseEventList.map(e => e.id); 60 | const selectedEventIds = events.map(e => e.id).filter(e => baseEventIds.indexOf(e) > -1); 61 | const eventList = ( 62 | selectedEventIds.length > 0 63 | ? baseEventList 64 | : baseEventList.filter(e => !e.hideIfOnlyEvent) 65 | ); 66 | 67 | const eventContainerClasses = [classes.eventContainer]; 68 | if (eventList.length === 4) { 69 | eventContainerClasses.push(classes.quarterContainer); 70 | } 71 | 72 | return ( 73 |
74 | {eventList.map(event => ( 75 |
79 | onToggleEvent(event)} 84 | color={darkMode ? 'primary' : 'secondary'} 85 | value={event.id} 86 | /> 87 | )} 88 | className={`${classes.secondaryText} ${classes.lessSpaceAbove}`} 89 | label={event.name} 90 | /> 91 |
92 | ))} 93 |
94 | ); 95 | }; 96 | export const AdditionalEvents = React.memo(AdditionalEventsComponent); 97 | 98 | export default AdditionalEvents; 99 | -------------------------------------------------------------------------------- /src/components/AppBar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector, useDispatch } from 'react-redux'; 3 | import makeStyles from '@material-ui/core/styles/makeStyles'; 4 | import AppBar from '@material-ui/core/AppBar'; 5 | import IconButton from '@material-ui/core/IconButton'; 6 | import Toolbar from '@material-ui/core/Toolbar'; 7 | import Tooltip from '@material-ui/core/Tooltip'; 8 | import Typography from '@material-ui/core/Typography'; 9 | import InvertColors from '@material-ui/icons/InvertColors'; 10 | import InvertColorsOff from '@material-ui/icons/InvertColorsOff'; 11 | import AppOptions from './AppOptions'; 12 | import AboutCrossAngles from './AboutCrossAngles'; 13 | import { RootState } from '../state'; 14 | import { getOptions } from '../state/selectors'; 15 | import { toggleOption } from '../actions'; 16 | import { useMediaQuery, useTheme } from '@material-ui/core'; 17 | 18 | const useStyles = makeStyles(theme => ({ 19 | grow: { 20 | flexGrow: 1, 21 | }, 22 | term: { 23 | marginRight: theme.spacing(1), 24 | fontWeight: 300, 25 | fontSize: '1.1rem', 26 | }, 27 | termNumber: { 28 | fontWeight: 500, 29 | }, 30 | })); 31 | 32 | export interface Props { 33 | onShowContact: () => void, 34 | onViewChangelog: () => void, 35 | } 36 | 37 | 38 | export function CrossAnglesAppBar({ 39 | onShowContact, 40 | onViewChangelog, 41 | }: Props) { 42 | const classes = useStyles(); 43 | const dispatch = useDispatch(); 44 | const { darkMode } = useSelector(getOptions); 45 | const { term, year } = useSelector((state: RootState) => state.meta); 46 | const handleClickDarkMode = React.useCallback( 47 | () => dispatch(toggleOption('darkMode')), 48 | [dispatch], 49 | ); 50 | 51 | const theme = useTheme(); 52 | const xs = useMediaQuery(theme.breakpoints.only('xs')); 53 | 54 | return ( 55 | 59 | 60 | 61 | CrossAngles 62 | 63 | 64 | 70 | 71 | {!xs && 'Term '} 72 | 73 | {xs && 'T'} 74 | {term} 75 | 76 | {!xs && ` ${year}`} 77 | 78 | 79 | 80 | 81 | {darkMode ? ( 82 | 83 | ) : ( 84 | 85 | )} 86 | 87 | 88 | 89 | 90 | 91 | 92 | ); 93 | } 94 | 95 | export default CrossAnglesAppBar; 96 | -------------------------------------------------------------------------------- /src/components/Autocomplete/ListboxComponent.tsx: -------------------------------------------------------------------------------- 1 | import React, { PropsWithChildren } from 'react'; 2 | import { VariableSizeList, ListChildComponentProps } from 'react-window'; 3 | import { useMediaQuery, useTheme } from '@material-ui/core'; 4 | 5 | const LISTBOX_PADDING = 8; 6 | const MAX_ITEMS = 8; 7 | function renderRow(props: ListChildComponentProps) { 8 | const { data, index, style } = props; 9 | return React.cloneElement(data[index], { 10 | style: { 11 | ...style, 12 | top: (style.top as number) + LISTBOX_PADDING, 13 | }, 14 | }); 15 | } 16 | 17 | const OuterElementContext = React.createContext({}); 18 | 19 | /* eslint-disable react/display-name */ 20 | const OuterElementType = React.forwardRef((props, ref) => { 21 | const outerProps = React.useContext(OuterElementContext); 22 | return
; 23 | }); 24 | const InnerElementType = React.forwardRef((props, ref) => ( 25 |
    26 | )); 27 | /* eslint-enable react/display-name */ 28 | 29 | function useResetCache(data: any) { 30 | const ref = React.useRef(null); 31 | React.useEffect(() => { 32 | if (ref.current != null) { 33 | ref.current.resetAfterIndex(0, true); 34 | } 35 | }, [data]); 36 | return ref; 37 | } 38 | 39 | // Adapter for react-window 40 | const ListboxComponent = React.forwardRef( 41 | (props: PropsWithChildren, ref) => { 42 | const { children, ...other } = props; 43 | const itemData = React.Children.toArray(children); 44 | const theme = useTheme(); 45 | const smUp = useMediaQuery(theme.breakpoints.up('sm'), { noSsr: true }); 46 | const itemCount = itemData.length; 47 | const itemSize = smUp ? 36 : 48; 48 | 49 | const getHeight = () => { 50 | if (itemCount > MAX_ITEMS) { 51 | return MAX_ITEMS * itemSize; 52 | } 53 | return itemSize * itemCount; 54 | }; 55 | 56 | const gridRef = useResetCache(itemCount); 57 | 58 | return ( 59 |
    60 | 61 | itemSize} 69 | overscanCount={5} 70 | itemCount={itemCount} 71 | > 72 | {renderRow} 73 | 74 | 75 |
    76 | ); 77 | }, 78 | ); 79 | ListboxComponent.displayName = 'ListboxComponent'; 80 | 81 | export default ListboxComponent; 82 | -------------------------------------------------------------------------------- /src/components/Autocomplete/PaperComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | import { Button, DialogActions, makeStyles, Paper, PaperProps } from '@material-ui/core'; 4 | import EventIcon from '@material-ui/icons/Event'; 5 | import { getOption } from '../../state'; 6 | import { getOptions } from '../../state/selectors'; 7 | 8 | 9 | const useStyles = makeStyles(theme => ({ 10 | actions: { 11 | boxShadow: theme.shadows[2], 12 | padding: 0, 13 | }, 14 | })); 15 | 16 | 17 | export interface Props extends PaperProps { 18 | onAddPersonalEvent: () => void, 19 | } 20 | 21 | 22 | // Adapter for react-window 23 | const PaperComponent: React.FC = (props: Props) => { 24 | const { children, onAddPersonalEvent, ...other } = props; 25 | const options = useSelector(getOptions); 26 | const darkMode = getOption(options, 'darkMode'); 27 | const classes = useStyles(); 28 | 29 | return ( 30 | 31 | {children} 32 | 33 | 34 | 46 | 47 | 48 | ); 49 | }; 50 | 51 | export default PaperComponent; 52 | -------------------------------------------------------------------------------- /src/components/Autocomplete/filter.worker.ts: -------------------------------------------------------------------------------- 1 | import { matchSorter, MatchSorterOptions } from 'match-sorter'; 2 | import type { CourseData } from '../../state'; 3 | 4 | export const matchSorterOptions: MatchSorterOptions = { 5 | keys: ['lowerCode', 'name'], 6 | baseSort: (a, b) => +(a.rankedValue > b.rankedValue) - +(a.rankedValue < b.rankedValue), 7 | }; 8 | 9 | export function runFilter(options: CourseData[], inputValue: string): CourseData[] { 10 | const query = inputValue.toLowerCase().trim(); 11 | const results = matchSorter(options, query, matchSorterOptions); 12 | return results; 13 | } 14 | 15 | self.onmessage = ( 16 | event: MessageEvent<{ options: CourseData[], inputValue: string }>, 17 | ) => { 18 | const { options, inputValue } = event.data; 19 | const results = runFilter(options, inputValue); 20 | self.postMessage(results); 21 | }; 22 | -------------------------------------------------------------------------------- /src/components/Autocomplete/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Autocomplete'; 2 | export { default } from './Autocomplete'; 3 | -------------------------------------------------------------------------------- /src/components/Changelog.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import makeStyles from '@material-ui/core/styles/makeStyles'; 4 | 5 | import CloseIcon from '@material-ui/icons/Close'; 6 | import { 7 | Button, 8 | Dialog, 9 | DialogActions, 10 | DialogTitle, 11 | DialogContent, 12 | IconButton, 13 | Typography, 14 | } from '@material-ui/core'; 15 | import { 16 | Timeline, 17 | TimelineConnector, 18 | TimelineContent, 19 | TimelineDot, 20 | TimelineItem, 21 | TimelineOppositeContent, 22 | TimelineSeparator, 23 | } from '@material-ui/lab'; 24 | import changelog from '../changelog'; 25 | import { setChangelogView } from '../actions'; 26 | import { RootState } from '../state'; 27 | 28 | 29 | const useStyles = makeStyles(theme => ({ 30 | root: { 31 | marginTop: '15vh', 32 | marginBottom: '15vh', 33 | }, 34 | dialogTitle: { 35 | display: 'flex', 36 | alignItems: 'center', 37 | paddingBottom: theme.spacing(1), 38 | }, 39 | dialogActions: { 40 | padding: theme.spacing(1, 3, 2), 41 | }, 42 | flexGrow: { 43 | flexGrow: 1, 44 | }, 45 | moveRight: { 46 | marginRight: theme.spacing(-1), 47 | }, 48 | timeline: { 49 | padding: 0, 50 | }, 51 | timelineDate: { 52 | maxWidth: 135, 53 | }, 54 | detailList: { 55 | position: 'relative', 56 | marginLeft: theme.spacing(2), 57 | 58 | '&>div::before': { 59 | content: '"–"', 60 | position: 'absolute', 61 | left: -theme.spacing(1.5), 62 | }, 63 | }, 64 | })); 65 | 66 | 67 | export interface Props { 68 | onClose: () => void, 69 | open: boolean, 70 | } 71 | 72 | export function formatTimelineDate(date: Date) { 73 | const day = date.getDate(); 74 | const month = date.toLocaleDateString(undefined, { month: 'short' }); 75 | const year = date.getFullYear(); 76 | return `${month} ${day}, ${year}`; 77 | } 78 | 79 | 80 | const Changelog = ({ onClose, open }: Props) => { 81 | const classes = useStyles(); 82 | const dispatch = useDispatch(); 83 | const lastView = useSelector((state: RootState) => state.changelogView); 84 | const handleClose = React.useCallback( 85 | () => { 86 | dispatch(setChangelogView()); 87 | onClose(); 88 | }, 89 | [dispatch, onClose], 90 | ); 91 | 92 | return ( 93 | 100 | 101 | 102 | CrossAngles Changelog 103 | 104 | 105 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | {changelog.map((item, i) => ( 117 | 118 | 119 | {formatTimelineDate(item.date)} 120 | 121 | 122 | 123 | lastView ? 'secondary' : undefined} 125 | /> 126 | {i < changelog.length - 1 && } 127 | 128 | 129 | 130 | {item.summary} 131 | 137 | {item.details?.map(detail => ( 138 |
    {detail}
    139 | ))} 140 |
    141 |
    142 |
    143 | ))} 144 |
    145 |
    146 | 147 | 148 | 156 | 157 |
    158 | ); 159 | }; 160 | 161 | export default Changelog; 162 | -------------------------------------------------------------------------------- /src/components/Colour.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | import makeStyles from '@material-ui/core/styles/makeStyles'; 4 | import ButtonBase from '@material-ui/core/ButtonBase'; 5 | import Check from '@material-ui/icons/Check'; 6 | import { Colour, getColour } from '../state'; 7 | import { getOptions } from '../state/selectors'; 8 | 9 | const useStyles = makeStyles(theme => ({ 10 | root: { 11 | display: 'flex', 12 | alignItems: 'center', 13 | justifyContent: 'center', 14 | color: 'white', 15 | transition: theme.transitions.create('background-color'), 16 | 17 | '&$selected': { 18 | border: '1px solid white', 19 | }, 20 | '&$rounded': { 21 | borderRadius: '50%', 22 | }, 23 | }, 24 | selected: {}, 25 | rounded: {}, 26 | })); 27 | 28 | export interface Props { 29 | colour: Colour, 30 | size: number, 31 | isSelected?: boolean, 32 | isCircle?: boolean, 33 | onClick?: (event: React.MouseEvent) => void, 34 | } 35 | 36 | function ColourComponent({ 37 | colour, 38 | isSelected = false, 39 | isCircle = false, 40 | onClick, 41 | size, 42 | }: Props) { 43 | const classes = useStyles(); 44 | const { darkMode } = useSelector(getOptions); 45 | const appliedClasses = [ 46 | classes.root, 47 | isSelected ? classes.selected : '', 48 | isCircle ? classes.rounded : '', 49 | ].join(' '); 50 | 51 | return ( 52 | 62 | {isSelected ? : null} 63 | 64 | ); 65 | } 66 | 67 | export default ColourComponent; 68 | -------------------------------------------------------------------------------- /src/components/ColourPicker.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import makeStyles from '@material-ui/core/styles/makeStyles'; 3 | import ColourControl from './Colour'; 4 | import { Colour } from '../state'; 5 | 6 | const useStyles = makeStyles(theme => ({ 7 | root: { 8 | padding: theme.spacing(1), 9 | }, 10 | colourContainer: { 11 | display: 'flex', 12 | flexDirection: 'row', 13 | flexWrap: 'wrap', 14 | }, 15 | colour: { 16 | display: 'flex', 17 | alignItems: 'center', 18 | justifyContent: 'center', 19 | color: 'white', 20 | 21 | '&$selected': { 22 | border: '1px solid white', 23 | }, 24 | }, 25 | selected: {}, 26 | })); 27 | 28 | export interface Props { 29 | colours: Colour[], 30 | columns: number, 31 | size: number, 32 | value?: Colour | null, 33 | onChange: (colour: Colour) => void, 34 | } 35 | 36 | export const ColourPicker: React.FC = ( 37 | { colours, columns, onChange, size, value }: Props, 38 | ) => { 39 | const classes = useStyles(); 40 | 41 | return ( 42 |
    43 |
    47 | {colours.map(colour => { 48 | const isSelected = !!value && colour === value; 49 | 50 | return ( 51 | onChange(colour)} 55 | size={size} 56 | isSelected={isSelected} 57 | /> 58 | ); 59 | })} 60 |
    61 |
    62 | ); 63 | }; 64 | 65 | export default ColourPicker; 66 | -------------------------------------------------------------------------------- /src/components/CourseActionButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { PropsWithChildren } from 'react'; 2 | import { makeStyles } from '@material-ui/core'; 3 | import ListItemIcon from '@material-ui/core/ListItemIcon'; 4 | import IconButton from '@material-ui/core/IconButton'; 5 | 6 | 7 | const useStyles = makeStyles(theme => { 8 | const transition = { 9 | duration: theme.transitions.duration.shorter, 10 | }; 11 | 12 | return { 13 | expandIcon: { 14 | transform: 'rotate(0deg)', 15 | transition: theme.transitions.create('transform', transition), 16 | 17 | '&$flipped': { 18 | transform: 'rotate(180deg)', 19 | }, 20 | }, 21 | flipped: {}, 22 | listIcon: { 23 | minWidth: 'initial', 24 | }, 25 | }; 26 | }); 27 | 28 | export interface Props { 29 | flipped?: boolean, 30 | onClick: () => void, 31 | } 32 | 33 | export const CourseActionButton: React.FC> = ({ 34 | children, 35 | flipped, 36 | onClick, 37 | }) => { 38 | const classes = useStyles(); 39 | return ( 40 | 43 | 48 | {children} 49 | 50 | 51 | ); 52 | }; 53 | 54 | export default CourseActionButton; 55 | -------------------------------------------------------------------------------- /src/components/CourseList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { TransitionGroup } from 'react-transition-group'; 3 | import makeStyles from '@material-ui/core/styles/makeStyles'; 4 | import List from '@material-ui/core/List'; 5 | import Divider from '@material-ui/core/Divider'; 6 | import Popover from '@material-ui/core/Popover'; 7 | import { Collapse } from '@material-ui/core'; 8 | import { useSelector } from 'react-redux'; 9 | import { CourseDisplay } from './CourseDisplay'; 10 | import { AdditionalCourseDisplay } from './AdditionalCourseDisplay'; 11 | import { ColourPicker } from './ColourPicker'; 12 | import { 13 | COURSE_COLOURS, 14 | ColourMap, 15 | Colour, 16 | CourseData, 17 | CourseId, 18 | getCourseId, 19 | Meta, 20 | AdditionalEvent, 21 | RootState, 22 | } from '../state'; 23 | 24 | const useStyles = makeStyles(theme => ({ 25 | root: { 26 | backgroundColor: theme.palette.background.paper, 27 | }, 28 | })); 29 | 30 | export interface Props { 31 | chosen: CourseData[], 32 | custom: CourseData[], 33 | additional: CourseData[], 34 | events: AdditionalEvent[], 35 | colours: ColourMap, 36 | webStreams: CourseId[], 37 | hiddenEvents: CourseId[], 38 | meta: Meta, 39 | onEditCustomCourse: (course: CourseData) => void, 40 | onRemoveCourse: (course: CourseData) => void, 41 | onToggleShowEvents: (course: CourseData) => void, 42 | onToggleEvent: (event: AdditionalEvent) => void, 43 | onToggleWeb: (course: CourseData) => void, 44 | onColourChange: (course: CourseData, colour: Colour) => void, 45 | } 46 | 47 | export interface PopoverState { 48 | target: HTMLElement, 49 | course: CourseData, 50 | } 51 | 52 | const CourseListComponent: React.FC = (props: Props) => { 53 | const classes = useStyles(); 54 | const [showPopover, setShowPopover] = React.useState(); 55 | const reducedMotion = useSelector((state: RootState) => state.options.reducedMotion); 56 | const { chosen, custom, additional, onColourChange } = props; 57 | const allCourses = React.useMemo( 58 | () => [...chosen, ...custom, ...additional], 59 | [chosen, custom, additional], 60 | ); 61 | 62 | const handleShowPopover = React.useCallback( 63 | (event: React.MouseEvent, course: CourseData) => { 64 | setShowPopover({ 65 | target: event.currentTarget, 66 | course, 67 | }); 68 | }, 69 | [], 70 | ); 71 | 72 | const handleHidePopover = React.useCallback( 73 | () => { 74 | setShowPopover(undefined); 75 | }, 76 | [], 77 | ); 78 | 79 | const handleChange = React.useCallback( 80 | (colour: Colour) => { 81 | onColourChange(showPopover!.course, colour); 82 | handleHidePopover(); 83 | }, 84 | [showPopover, handleHidePopover, onColourChange], 85 | ); 86 | 87 | return ( 88 | 89 | 90 | {allCourses.map(course => ( 91 | 96 |
    97 | 98 | {!course.isAdditional ? ( 99 | 109 | ) : ( 110 | 120 | )} 121 |
    122 |
    123 | ))} 124 |
    125 | 126 | 127 | 140 | 147 | 148 |
    149 | ); 150 | }; 151 | export const CourseList = React.memo(CourseListComponent); 152 | export default CourseList; 153 | -------------------------------------------------------------------------------- /src/components/InfoText.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react'; 2 | import { event } from 'react-ga'; 3 | import makeStyles from '@material-ui/core/styles/makeStyles'; 4 | import Typography, { TypographyProps } from '@material-ui/core/Typography'; 5 | import { CourseData, Meta } from '../state'; 6 | import { CATEGORY } from '../analytics'; 7 | 8 | export interface Props { 9 | additional: CourseData[], 10 | meta: Meta, 11 | link?: boolean, 12 | disclaimer?: boolean, 13 | typographyProps?: TypographyProps, 14 | className?: string, 15 | onShowContact: () => void, 16 | } 17 | 18 | const useStyles = makeStyles(theme => ({ 19 | link: { 20 | color: theme.palette.primary.main, 21 | textDecoration: 'underline', 22 | cursor: 'pointer', 23 | }, 24 | })); 25 | 26 | const InfoText = ({ 27 | additional, 28 | meta, 29 | typographyProps, 30 | link = true, 31 | disclaimer = false, 32 | className, 33 | onShowContact, 34 | }: Props) => { 35 | const classes = useStyles(); 36 | 37 | // Assumption: only one additional course will be auto-selected and has metadata 38 | const ministry = additional.filter(c => c.autoSelect && c.metadata)[0]; 39 | 40 | const handleLinkClick = (action: string, destination?: string) => { 41 | event({ 42 | category: CATEGORY, 43 | action, 44 | label: destination, 45 | }); 46 | }; 47 | 48 | let ministryPromo: ReactNode = null; 49 | if (ministry) { 50 | const ministryMeta = ministry.metadata!; 51 | 52 | const textParts = ministryMeta.promoText.split('{link}'); 53 | const items: ReactNode[] = [textParts.shift() || '']; 54 | for (const [i, textPart] of textParts.entries()) { 55 | const linkEl = link ? ( 56 | handleLinkClick('Ministry Link', ministryMeta.website)} 63 | > 64 | {ministry.name} 65 | 66 | ) : ( 67 | {ministry.name} 68 | ); 69 | const textEl = {textPart}; 70 | items.push(linkEl, textEl); 71 | } 72 | 73 | ministryPromo = ( 74 | 75 | {items} 76 | 77 | ); 78 | } 79 | 80 | const sources = ( 81 | <> 82 | {meta.sources.map((source, i) => ( 83 | 84 | handleLinkClick('ClassUtil Link', source)} 90 | > 91 | {source.replace(/^https?:\/\/(?:www\.)?/, '')} 92 | 93 | 94 | {i < meta.sources.length - 1 && ' and '} 95 | 96 | ))} 97 | 98 | ); 99 | 100 | 101 | return ( 102 |
    103 | {ministryPromo} 104 | 105 | {disclaimer && ( 106 | 107 | The data was last updated at 108 | {' '} 109 | 110 | {meta.updateTime} ({meta.updateDate}) 111 | 112 | {' '} 113 | from {sources} for 114 | {' '} 115 | 116 | Term {meta.term}, {meta.year} 117 | 118 | . 119 | CrossAngles comes without any guarantee of data accuracy. 120 | If you have any questions or suggestions, 121 | please 122 | {' '} 123 | { e.preventDefault(); onShowContact(); }} 126 | href="#contact" 127 | > 128 | contact us 129 | 130 | . 131 | 132 | )} 133 | 134 | 135 | If you would like to contribute or view the source code, you can find 136 | {' '} 137 | 143 | CrossAngles on GitHub 144 | 145 | . 146 | 147 |
    148 | ); 149 | }; 150 | 151 | export default InfoText; 152 | -------------------------------------------------------------------------------- /src/components/Notice.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Snackbar from '@material-ui/core/Snackbar'; 3 | import { DEFAULT_NOTICE_TIMEOUT, Notice } from '../state'; 4 | 5 | export interface Props { 6 | notice: Notice | null, 7 | onSnackbarClose: () => void, 8 | } 9 | 10 | export const NoticeDisplay = ({ 11 | notice, 12 | onSnackbarClose, 13 | }: Props) => { 14 | const { message = '', actions = null, timeout = DEFAULT_NOTICE_TIMEOUT } = notice || {}; 15 | const paragraphs = React.useMemo( 16 | () => message.split(/\n/g).map(p => ({ text: p, key: Math.random().toString() })), 17 | [message], 18 | ); 19 | const handleClose = React.useCallback( 20 | () => { 21 | if (notice && notice.callback) { 22 | notice.callback(); 23 | } 24 | onSnackbarClose(); 25 | }, 26 | [notice, onSnackbarClose], 27 | ); 28 | 29 | return ( 30 | 39 | {paragraphs.map(p => ( 40 |

    {p.text}

    41 | ))} 42 |
43 | )} 44 | action={actions} 45 | /> 46 | ); 47 | }; 48 | 49 | export default NoticeDisplay; 50 | -------------------------------------------------------------------------------- /src/components/Timetable/BackgroundStripes.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import makeStyles from '@material-ui/core/styles/makeStyles'; 3 | import { useSelector } from 'react-redux'; 4 | import { RootState } from '../../state'; 5 | import { getCellHeight } from './timetableUtil'; 6 | 7 | const useStyles = makeStyles(_ => ({ 8 | backgroundStripes: { 9 | position: 'absolute', 10 | left: 0, 11 | top: 0, 12 | width: '100%', 13 | height: '100%', 14 | }, 15 | })); 16 | 17 | export interface Props { 18 | opacity?: number, 19 | } 20 | 21 | const Stripes: React.FC = ({ opacity = 0.03 }: Props) => { 22 | const classes = useStyles(); 23 | const compact = useSelector((state: RootState) => state.options.compactView || false); 24 | const showMode = useSelector((state: RootState) => state.options.showMode || false); 25 | 26 | const backgroundStripesStyle = React.useMemo( 27 | () => { 28 | const stepSize = (getCellHeight(compact, showMode) * Math.SQRT2) / 4; 29 | return { 30 | background: `repeating-linear-gradient( 31 | 45deg, 32 | rgba(0, 0, 0, ${opacity}), 33 | rgba(0, 0, 0, ${opacity}) ${stepSize / 2}px, 34 | rgba(0, 0, 0, ${opacity * 1.5 + 0.05}) ${stepSize / 2}px, 35 | rgba(0, 0, 0, ${opacity * 1.5 + 0.05}) ${stepSize}px 36 | )`, 37 | }; 38 | }, 39 | [compact, opacity, showMode], 40 | ); 41 | 42 | return ( 43 |
47 | ); 48 | }; 49 | const BackgroundStripes = React.memo(Stripes); 50 | 51 | export default BackgroundStripes; 52 | -------------------------------------------------------------------------------- /src/components/Timetable/DeliveryModeIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import makeStyles from '@material-ui/core/styles/makeStyles'; 3 | import OnlineIcon from '@material-ui/icons/Laptop'; 4 | import PersonIcon from '@material-ui/icons/Person'; 5 | import { DeliveryType } from '../../state'; 6 | 7 | const useStyles = makeStyles(theme => ({ 8 | root: { 9 | display: 'flex', 10 | alignItems: 'center', 11 | justifyContent: 'center', 12 | opacity: 0.85, 13 | }, 14 | iconSlash: { 15 | marginLeft: theme.spacing(1), 16 | marginRight: theme.spacing(0.5), 17 | marginTop: theme.spacing(0.25), 18 | }, 19 | padded: { 20 | paddingTop: theme.spacing(1), 21 | paddingBottom: theme.spacing(1), 22 | }, 23 | })); 24 | 25 | export interface Props { 26 | delivery: DeliveryType, 27 | padded: boolean, 28 | } 29 | 30 | const DeliveryTypeIconBase: React.FC = ({ delivery, padded }: Props) => { 31 | const classes = useStyles(); 32 | const rootClasses = [classes.root]; 33 | if (padded) { 34 | rootClasses.push(classes.padded); 35 | } 36 | 37 | return ( 38 |
39 | {delivery !== DeliveryType.person && } 40 | {delivery === DeliveryType.either || delivery === DeliveryType.mixed ? ( 41 | / 42 | ) : null} 43 | {delivery !== DeliveryType.online && } 44 |
45 | ); 46 | }; 47 | export const DeliveryTypeIcon: React.FC = React.memo(DeliveryTypeIconBase); 48 | 49 | export default DeliveryTypeIcon; 50 | -------------------------------------------------------------------------------- /src/components/Timetable/DropzonePlacement.ts: -------------------------------------------------------------------------------- 1 | import { TimetablePlacement } from './Placement'; 2 | 3 | export class DropzonePlacement extends TimetablePlacement { 4 | protected clashDepthMultiplier = 1.5; 5 | } 6 | export default DropzonePlacement; 7 | -------------------------------------------------------------------------------- /src/components/Timetable/Placement.ts: -------------------------------------------------------------------------------- 1 | import { LinkedSession, getDuration } from '../../state'; 2 | import * as SessionPosition from './SessionPosition'; 3 | import { Dimensions, Placement, TimetablePosition } from './timetableTypes'; 4 | import { 5 | getCellHeight, 6 | TIMETABLE_FIRST_CELL_WIDTH, 7 | TIMETABLE_BORDER_WIDTH, 8 | getCellWidth, 9 | } from './timetableUtil'; 10 | 11 | export abstract class TimetablePlacement { 12 | private _session: LinkedSession; 13 | protected _offset: TimetablePosition; 14 | protected _isSnapped: boolean = true; 15 | protected _isDragging: boolean = false; 16 | protected _isRaised: boolean = false; 17 | private _basePlacement_cachedDeps: (Dimensions | number | boolean)[] = []; 18 | private _basePlacement_cachedResult: Placement | undefined; 19 | private _getPosition_cachedDeps: (Dimensions | number | boolean)[] = []; 20 | private _getPosition_cachedResult: Required | undefined; 21 | protected clashDepthMultiplier = 1; 22 | clashDepth: number = 0; 23 | 24 | constructor(session: LinkedSession) { 25 | this._session = session; 26 | this._offset = { x: 0, y: 0 }; 27 | } 28 | 29 | get session(): LinkedSession { 30 | return this._session; 31 | } 32 | 33 | get id(): string { 34 | return `${this._session.day}~${this._session.start}~${this._session.end}`; 35 | } 36 | 37 | get duration(): number { 38 | return getDuration(this._session); 39 | } 40 | 41 | get isSnapped(): boolean { 42 | return this._isSnapped && !this._isRaised; 43 | } 44 | 45 | get isDragging(): boolean { 46 | return this._isDragging; 47 | } 48 | 49 | get isRaised(): boolean { 50 | return this._isRaised; 51 | } 52 | 53 | basePlacement( 54 | timetableDimensions: Dimensions, 55 | firstHour: number, 56 | compact: boolean, 57 | showMode: boolean, 58 | ): Placement { 59 | const dependencies = [ 60 | timetableDimensions, 61 | firstHour, 62 | compact, 63 | this.session.start, 64 | this.dayIndex, 65 | ]; 66 | if (dependencies.every((dep, i) => this._basePlacement_cachedDeps[i] === dep)) { 67 | if (this._basePlacement_cachedResult !== undefined) { 68 | return this._basePlacement_cachedResult; 69 | } 70 | } 71 | 72 | const { width, height } = this.baseDimensions(timetableDimensions, compact, showMode); 73 | 74 | const hourIndex = this._session.start - firstHour; 75 | 76 | const sessionWidth = getCellWidth(timetableDimensions.width); 77 | 78 | const baseX = TIMETABLE_FIRST_CELL_WIDTH + TIMETABLE_BORDER_WIDTH; 79 | const baseY = getCellHeight(true, false) + TIMETABLE_BORDER_WIDTH; 80 | const dayOffsetX = sessionWidth * this.dayIndex; 81 | const hourOffsetY = getCellHeight(compact, showMode) * hourIndex; 82 | 83 | const x = baseX + dayOffsetX; 84 | const y = baseY + hourOffsetY; 85 | 86 | this._basePlacement_cachedDeps = dependencies; 87 | this._basePlacement_cachedResult = { x, y, width, height }; 88 | return this._basePlacement_cachedResult; 89 | } 90 | 91 | getPosition( 92 | timetableDimensions: Dimensions, 93 | startHour: number, 94 | compact: boolean, 95 | showMode: boolean, 96 | ): Required { 97 | const base = this.basePlacement(timetableDimensions, startHour, compact, showMode); 98 | const dependencies = [ 99 | timetableDimensions, 100 | startHour, 101 | base, 102 | this.clashDepth, 103 | this.isRaised, 104 | this.isSnapped, 105 | this._offset.x, 106 | this._offset.y, 107 | ]; 108 | if (dependencies.every((dep, i) => this._getPosition_cachedDeps[i] === dep)) { 109 | if (this._getPosition_cachedResult) { 110 | return this._getPosition_cachedResult; 111 | } 112 | } 113 | 114 | const { width, height } = base; 115 | const clash = SessionPosition.getClashOffset(this.clashDepth * this.clashDepthMultiplier); 116 | const raised = SessionPosition.getRaisedOffset(this.isRaised); 117 | let x = base.x + clash.x + raised.x + this._offset.x; 118 | let y = base.y + clash.y + raised.y + this._offset.y; 119 | const z = SessionPosition.getZ(this.isSnapped, this.isDragging, this.clashDepth); 120 | 121 | const maxX = timetableDimensions.width - base.width; 122 | const maxY = timetableDimensions.height - base.height; 123 | 124 | x = Math.min(Math.max(x, TIMETABLE_BORDER_WIDTH), maxX); 125 | y = Math.min(Math.max(y, TIMETABLE_BORDER_WIDTH), maxY); 126 | 127 | this._getPosition_cachedDeps = dependencies; 128 | this._getPosition_cachedResult = { x, y, z, width, height }; 129 | return this._getPosition_cachedResult; 130 | } 131 | 132 | private baseDimensions( 133 | timetableDimensions: Dimensions, 134 | compact: boolean, 135 | showMode: boolean, 136 | ): Dimensions { 137 | const sessionWidth = getCellWidth(timetableDimensions.width); 138 | const sessionHeight = this.calculateHeight(compact, showMode); 139 | const width = sessionWidth - TIMETABLE_BORDER_WIDTH; 140 | const height = sessionHeight - TIMETABLE_BORDER_WIDTH; 141 | return { width, height }; 142 | } 143 | 144 | get dayIndex(): number { 145 | return ['M', 'T', 'W', 'H', 'F'].indexOf(this._session.day); 146 | } 147 | 148 | private calculateHeight(compact: boolean, showMode: boolean): number { 149 | return this.duration * getCellHeight(compact, showMode); 150 | } 151 | } 152 | 153 | export default TimetablePlacement; 154 | -------------------------------------------------------------------------------- /src/components/Timetable/SessionManagerTypes.ts: -------------------------------------------------------------------------------- 1 | import { SessionId } from '../../state'; 2 | import { SessionPlacementData } from './SessionPlacement'; 3 | 4 | export type SessionManagerEntriesData = Array<[SessionId, SessionPlacementData]>; 5 | 6 | export interface SessionManagerData { 7 | map: SessionManagerEntriesData, 8 | order: SessionId[], 9 | renderOrder: SessionId[], 10 | version: number, 11 | score: number, 12 | } 13 | 14 | export const getEmptySessionManagerData = (): SessionManagerData => ({ 15 | map: [], 16 | order: [], 17 | renderOrder: [], 18 | score: 0, 19 | version: 0, 20 | }); 21 | -------------------------------------------------------------------------------- /src/components/Timetable/SessionPlacement.ts: -------------------------------------------------------------------------------- 1 | import { TimetablePosition, Dimensions } from './timetableTypes'; 2 | import { TimetablePlacement } from './Placement'; 3 | import * as tt from './timetableUtil'; 4 | import { 5 | CourseData, 6 | getStreamId, 7 | linkStream, 8 | SessionData, 9 | unlinkSession, 10 | linkSession, 11 | } from '../../state'; 12 | 13 | export interface SessionPlacementData { 14 | offset: TimetablePosition, 15 | isSnapped: boolean, 16 | isDragging: boolean, 17 | isRaised: boolean, 18 | touched: boolean, 19 | clashDepth: number, 20 | session: SessionData, 21 | } 22 | 23 | class SessionPlacement extends TimetablePlacement { 24 | private _touched: boolean = false; 25 | 26 | get data(): SessionPlacementData { 27 | return { 28 | offset: { ...this._offset }, 29 | isSnapped: this._isSnapped, 30 | isDragging: this._isDragging, 31 | isRaised: this._isRaised, 32 | touched: this._touched, 33 | clashDepth: this.clashDepth, 34 | session: unlinkSession(this.session), 35 | }; 36 | } 37 | 38 | static from(data: SessionPlacementData, course: CourseData) { 39 | // Get linked session 40 | const streamId = data.session.stream; 41 | const stream = course.streams.filter(s => getStreamId(course, s) === streamId)[0]; 42 | if (stream === undefined) { 43 | return null; 44 | } 45 | 46 | const linkedStream = linkStream(course, stream); 47 | const session = linkSession(course, linkedStream, data.session); 48 | const placement = new SessionPlacement(session); 49 | 50 | // Update placement properties 51 | placement._offset = { ...data.offset }; 52 | placement._isSnapped = data.isSnapped; 53 | placement._isDragging = data.isDragging; 54 | placement._isRaised = data.isRaised; 55 | placement._touched = data.touched; 56 | placement.clashDepth = data.clashDepth; 57 | 58 | return placement; 59 | } 60 | 61 | drag(): void { 62 | // Add clashOffset to current offset 63 | this._offset.x += this.clashDepth * tt.CLASH_OFFSET_X; 64 | this._offset.y += this.clashDepth * tt.CLASH_OFFSET_Y; 65 | 66 | this._isSnapped = false; 67 | this._isDragging = true; 68 | } 69 | 70 | move(delta: TimetablePosition): void { 71 | this._offset.x += delta.x; 72 | this._offset.y += delta.y; 73 | } 74 | 75 | drop( 76 | timetableDimensions: Dimensions, 77 | firstHour: number, 78 | compact: boolean, 79 | showMode: boolean, 80 | ): void { 81 | this._isDragging = false; 82 | 83 | // Update offset based on current (rendered) position and base position 84 | // NB: this is done to ensure the offset stays bounded within the timetable element 85 | const base = this.basePlacement(timetableDimensions, firstHour, compact, showMode); 86 | const current = this.getPosition(timetableDimensions, firstHour, compact, showMode); 87 | this._offset.x = current.x - base.x; 88 | this._offset.y = current.y - base.y; 89 | } 90 | 91 | snap(): void { 92 | this._offset = { x: 0, y: 0 }; 93 | this._isSnapped = true; 94 | this._isRaised = false; 95 | } 96 | 97 | raise(): void { 98 | this._isRaised = true; 99 | } 100 | 101 | lower(): void { 102 | this._isRaised = false; 103 | } 104 | 105 | // Slightly displace this session 106 | // (e.g. if it was in a full stream and can't be anymore) 107 | displace(): void { 108 | let dx = Math.floor(Math.random() * tt.DISPLACE_VARIATION_X * 2) - tt.DISPLACE_VARIATION_X; 109 | let dy = Math.floor(Math.random() * tt.DISPLACE_VARIATION_Y * 2) - tt.DISPLACE_VARIATION_Y; 110 | dx = (dx < 0) ? dx - tt.DISPLACE_RADIUS_X : dx + tt.DISPLACE_RADIUS_X + 1; 111 | dy = (dy < 0) ? dy - tt.DISPLACE_RADIUS_Y : dy + tt.DISPLACE_RADIUS_Y + 1; 112 | 113 | this.displaceBy(dx, dy); 114 | } 115 | 116 | private displaceBy(dx: number, dy: number): void { 117 | if (this._isSnapped) { 118 | this._isSnapped = false; 119 | this._offset.x += dx; 120 | this._offset.y += dy; 121 | } 122 | } 123 | 124 | shouldDisplace(includeFullClasses: boolean): boolean { 125 | const isFull: boolean = !!this.session.stream.full; 126 | return isFull && !includeFullClasses && this.isSnapped; 127 | } 128 | 129 | get touched(): boolean { 130 | return this._touched; 131 | } 132 | 133 | touch(): void { 134 | this._touched = true; 135 | } 136 | } 137 | 138 | export default SessionPlacement; 139 | -------------------------------------------------------------------------------- /src/components/Timetable/SessionPosition.ts: -------------------------------------------------------------------------------- 1 | import * as tt from './timetableUtil'; 2 | 3 | export function getClashOffset(clashDepth: number) { 4 | return { 5 | x: clashDepth * tt.CLASH_OFFSET_X, 6 | y: clashDepth * tt.CLASH_OFFSET_Y, 7 | }; 8 | } 9 | 10 | export function getRaisedOffset(isRaised: boolean) { 11 | return { 12 | x: isRaised ? tt.RAISE_DIST_X : 0, 13 | y: isRaised ? tt.RAISE_DIST_Y : 0, 14 | }; 15 | } 16 | 17 | export function getZ(isSnapped: boolean, isDragging: boolean, clashDepth: number) { 18 | const unsnapZ = (!isSnapped) ? tt.SESSION_DRAG_Z : 0; 19 | const dragZ = (isDragging) ? tt.SESSION_DRAG_Z : 0; 20 | const clashZ = tt.SESSION_LIFT_Z * clashDepth; 21 | return tt.SESSION_BASE_Z + unsnapZ + dragZ + clashZ; 22 | } 23 | -------------------------------------------------------------------------------- /src/components/Timetable/TimetableDropzone.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import makeStyles from '@material-ui/core/styles/makeStyles'; 3 | import { DROPZONE_Z } from './timetableUtil'; 4 | import { Placement } from './timetableTypes'; 5 | import { LinkedSession, Options } from '../../state'; 6 | import SessionDetails from './SessionDetails'; 7 | 8 | const useStyles = makeStyles(theme => ({ 9 | root: { 10 | position: 'absolute', 11 | overflow: 'hidden', 12 | zIndex: DROPZONE_Z, 13 | }, 14 | background: { 15 | transition: theme.transitions.create( 16 | 'background-color', 17 | { 18 | duration: theme.transitions.duration.shortest, 19 | }, 20 | ), 21 | position: 'absolute', 22 | left: 0, 23 | top: 0, 24 | width: '100%', 25 | height: '100%', 26 | zIndex: 0, 27 | borderWidth: 3, 28 | borderStyle: 'solid', 29 | }, 30 | detailContainer: { 31 | position: 'absolute', 32 | left: 0, 33 | top: 0, 34 | width: '100%', 35 | height: '100%', 36 | display: 'flex', 37 | alignItems: 'center', 38 | justifyContent: 'center', 39 | }, 40 | shadow: { 41 | boxShadow: theme.shadows[3], 42 | }, 43 | })); 44 | 45 | export interface Props { 46 | colour?: string, 47 | highlighted: boolean, 48 | options: Options, 49 | position: Placement, 50 | session: LinkedSession, 51 | } 52 | 53 | const Dropzone: React.FC = ({ 54 | colour, 55 | highlighted, 56 | options, 57 | position, 58 | session, 59 | }: Props) => { 60 | const classes = useStyles(); 61 | const styles = React.useMemo( 62 | () => { 63 | const { width, height, x, y, z } = position; 64 | 65 | return { 66 | left: x, 67 | top: y, 68 | zIndex: z, 69 | width, 70 | height, 71 | }; 72 | }, 73 | [position], 74 | ); 75 | const alpha = highlighted ? 'DD' : 'A0'; 76 | const backgroundColor = colour ? `${colour}${alpha}` : 'none'; 77 | const borderColor = colour || 'none'; 78 | const dropzoneOptions: Options = { 79 | ...options, 80 | showEnrolments: true, 81 | showMode: true, 82 | showLocations: false, 83 | showWeeks: false, 84 | }; 85 | 86 | return ( 87 |
91 |
95 |
96 | 103 |
104 |
105 | ); 106 | }; 107 | 108 | const TimetableDropzone: React.FC = React.memo(Dropzone); 109 | export default TimetableDropzone; 110 | -------------------------------------------------------------------------------- /src/components/Timetable/TimetableGrid.spec.tsx: -------------------------------------------------------------------------------- 1 | import { timeToString } from './TimetableGrid'; 2 | 3 | it.each([ 4 | [8, true, ['08', ':00']], 5 | [8, false, ['8', 'am']], 6 | [10, true, ['10', ':00']], 7 | [10, false, ['10', 'am']], 8 | [12, true, ['12', ':00']], 9 | [12, false, ['12', 'pm']], 10 | [14, true, ['14', ':00']], 11 | [14, false, ['2', 'pm']], 12 | [20, true, ['20', ':00']], 13 | [20, false, ['8', 'pm']], 14 | ])('timeToString(%s, %s) = %s', (hour, twentyFourHours, expected) => { 15 | expect(timeToString(hour, twentyFourHours)).toStrictEqual(expected); 16 | }); 17 | -------------------------------------------------------------------------------- /src/components/Timetable/TimetableSession.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { DraggableCore, DraggableData, DraggableEvent } from 'react-draggable'; 3 | import makeStyles from '@material-ui/core/styles/makeStyles'; 4 | import { CSSProperties } from '@material-ui/core/styles/withStyles'; 5 | import { TimetablePosition, Placement } from './timetableTypes'; 6 | import { Options, LinkedSession } from '../../state'; 7 | import { useCache } from '../../hooks'; 8 | import SessionDetails from './SessionDetails'; 9 | 10 | const useStyles = makeStyles(theme => ({ 11 | main: { 12 | position: 'absolute', 13 | display: 'flex', 14 | alignItems: 'center', 15 | justifyContent: 'center', 16 | flexDirection: 'column', 17 | color: 'white', 18 | cursor: 'grab', 19 | overflow: 'hidden', 20 | 21 | transition: theme.transitions.create(['box-shadow', 'transform', 'height']), 22 | boxShadow: theme.shadows[3], 23 | 24 | '&$snapped:not($hovering)': { 25 | boxShadow: theme.shadows[0], 26 | }, 27 | 28 | '&$dragging': { 29 | cursor: 'grabbing', 30 | transition: theme.transitions.create(['box-shadow', 'height']), 31 | boxShadow: theme.shadows[8], 32 | }, 33 | }, 34 | disableTransitions: { 35 | transition: 'none !important', 36 | }, 37 | background: { 38 | transition: theme.transitions.create(['background-color', 'height']), 39 | position: 'absolute', 40 | left: 0, 41 | top: 0, 42 | width: '100%', 43 | height: '100%', 44 | zIndex: 0, 45 | }, 46 | dragging: {}, 47 | snapped: {}, 48 | hovering: {}, 49 | })); 50 | 51 | export interface Props { 52 | session: LinkedSession, 53 | colour: string | undefined, 54 | position: Required, 55 | isDragging: boolean, 56 | isSnapped: boolean, 57 | clashDepth: number, 58 | options: Options, 59 | onDrag?: (session: LinkedSession) => false | void, 60 | onMove?: (session: LinkedSession, delta: TimetablePosition) => void, 61 | onDrop?: (session: LinkedSession) => void, 62 | } 63 | 64 | 65 | const Session: React.FC = ({ 66 | colour: propColour, 67 | clashDepth, 68 | isDragging, 69 | isSnapped, 70 | onDrag, 71 | onMove, 72 | onDrop, 73 | options, 74 | position, 75 | session, 76 | }: Props) => { 77 | const classes = useStyles(); 78 | const rootClasses = [ 79 | classes.main, 80 | isDragging ? classes.dragging : '', 81 | isSnapped ? classes.snapped : '', 82 | clashDepth > 0 ? classes.hovering : '', 83 | options.reducedMotion ? classes.disableTransitions : '', 84 | ].join(' '); 85 | const colour = useCache(propColour); 86 | 87 | const styles: CSSProperties = React.useMemo( 88 | () => { 89 | const { x, y, z, width, height } = position; 90 | 91 | return { 92 | transform: `translate(${x}px, ${y}px)`, 93 | width, 94 | height, 95 | zIndex: z, 96 | }; 97 | }, 98 | [position], 99 | ); 100 | 101 | const backgroundStyle = React.useMemo( 102 | () => ({ backgroundColor: colour }), 103 | [colour], 104 | ); 105 | 106 | const handleStart = React.useCallback( 107 | () => { 108 | if (onDrag) { 109 | onDrag(session); 110 | } 111 | }, 112 | [onDrag, session], 113 | ); 114 | const handleDrag = React.useCallback( 115 | (e: DraggableEvent, data: DraggableData) => { 116 | if (isDragging && onMove) { 117 | const x = data.deltaX; 118 | const y = data.deltaY; 119 | onMove(session, { x, y }); 120 | } 121 | }, 122 | [isDragging, onMove, session], 123 | ); 124 | const handleStop = React.useCallback( 125 | () => { 126 | if (onDrop) { 127 | onDrop(session); 128 | } 129 | }, 130 | [onDrop, session], 131 | ); 132 | 133 | return ( 134 | 139 |
144 |
148 | 149 | 154 |
155 | 156 | ); 157 | }; 158 | 159 | const TimetableSession = React.memo(Session); 160 | export default TimetableSession; 161 | -------------------------------------------------------------------------------- /src/components/Timetable/dropzone.spec.ts: -------------------------------------------------------------------------------- 1 | import { DropzoneManager } from './dropzones'; 2 | import { LinkedStream } from '../../state'; 3 | import { getLinkedStream, getLinkedSession } from '../../test_util'; 4 | import { DropzonePlacement } from './DropzonePlacement'; 5 | 6 | describe('filterStreams', () => { 7 | it('filterStreams([], ...) = []', () => { 8 | const dm = new DropzoneManager(); 9 | const result = dm.filterStreams([], 'LEC', 0, false); 10 | expect(result).toEqual([]); 11 | }); 12 | 13 | it('ignores other components', () => { 14 | const keepStreams: LinkedStream[] = [getLinkedStream(0)]; 15 | const skipStreams: LinkedStream[] = [getLinkedStream(2)]; 16 | const streams = [...keepStreams, ...skipStreams]; 17 | const dm = new DropzoneManager(); 18 | const result = dm.filterStreams(streams, keepStreams[0].component, 0, false); 19 | expect(result).toEqual(keepStreams); 20 | }); 21 | 22 | it('ignores streams without enough sessions', () => { 23 | const component = 'TLA'; 24 | const keepStreams: LinkedStream[] = [getLinkedStream(0, { component })]; 25 | const skipStreams: LinkedStream[] = [getLinkedStream(2, { component })]; 26 | const streams = [...keepStreams, ...skipStreams]; 27 | const dm = new DropzoneManager(); 28 | expect(dm.filterStreams(streams, component, 1, false)).toEqual(keepStreams); 29 | expect(dm.filterStreams(streams, component, 2, false)).toEqual(keepStreams); 30 | expect(dm.filterStreams(streams, component, 3, false)).toEqual([]); 31 | }); 32 | 33 | it('skips full classes if includeFull = False', () => { 34 | const component = 'TLA'; 35 | const keepStreams: LinkedStream[] = [getLinkedStream(0, { component, full: false })]; 36 | const skipStreams: LinkedStream[] = [getLinkedStream(2, { component, full: true })]; 37 | const streams = [...keepStreams, ...skipStreams]; 38 | const dm = new DropzoneManager(); 39 | expect(dm.filterStreams(streams, component, 0, false)).toEqual(keepStreams); 40 | }); 41 | 42 | it('includes full classes if includeFull = True', () => { 43 | const component = 'TLA'; 44 | const streams: LinkedStream[] = [ 45 | getLinkedStream(0, { component, full: false }), 46 | getLinkedStream(2, { component, full: true }), 47 | ]; 48 | const dm = new DropzoneManager(); 49 | expect(dm.filterStreams(streams, component, 0, true)).toEqual(streams); 50 | }); 51 | }); 52 | 53 | describe('streamsToDropzones', () => { 54 | it('returns array of dropzones', () => { 55 | const dm = new DropzoneManager(); 56 | const streams: LinkedStream[] = [ 57 | getLinkedStream(0), 58 | getLinkedStream(1), 59 | ]; 60 | const index = 1; 61 | const result = dm.streamsToDropzones(streams, index); 62 | expect(result).toHaveLength(streams.length); 63 | for (let i = 0; i < streams.length; ++i) { 64 | expect(result[i]).toBeInstanceOf(DropzonePlacement); 65 | expect(result[i].session).toBe(streams[i].sessions[index]); 66 | } 67 | }); 68 | }); 69 | 70 | describe('filterDropzones', () => { 71 | it('removes dropzones with duplicate start times', () => { 72 | const dm = new DropzoneManager(); 73 | const dropzones: DropzonePlacement[] = [ 74 | new DropzonePlacement(getLinkedSession(0, 0, { day: 'M', start: 11, end: 12 })), 75 | new DropzonePlacement(getLinkedSession(0, 0, { day: 'M', start: 11, end: 12 })), 76 | new DropzonePlacement(getLinkedSession(0, 0, { day: 'M', start: 12, end: 13 })), 77 | new DropzonePlacement(getLinkedSession(0, 0, { day: 'T', start: 11, end: 12 })), 78 | ]; 79 | const result = dm.filterDropzones(dropzones, getLinkedSession(1)); 80 | expect(result).toHaveLength(3); 81 | expect(result).toContain(dropzones[0]); 82 | expect(result).not.toContain(dropzones[1]); 83 | expect(result).toContain(dropzones[2]); 84 | expect(result).toContain(dropzones[3]); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /src/components/Timetable/dropzones.ts: -------------------------------------------------------------------------------- 1 | import { DropzonePlacement } from './DropzonePlacement'; 2 | import { LinkedSession, linkStream, LinkedStream, getDuration } from '../../state'; 3 | import { findFreeDepth } from './timetableUtil'; 4 | 5 | 6 | export function dropzoneCompare(a: DropzonePlacement, b: DropzonePlacement) { 7 | const daySort = +(a.dayIndex > b.dayIndex) - +(a.dayIndex < b.dayIndex); 8 | const startSort = +(a.session.start > b.session.start) - +(a.session.start < b.session.start); 9 | const endSort = +(a.session.end < b.session.end) - +(a.session.end > b.session.end); 10 | return daySort || startSort || endSort; 11 | } 12 | 13 | 14 | export class DropzoneManager { 15 | getDropzones(dragging: LinkedSession, includeFull: boolean): DropzonePlacement[] { 16 | const { course, stream: { component }, index } = dragging; 17 | const allStreams = course.streams.map(s => linkStream(course, s)); 18 | const filteredStreams = this.filterStreams(allStreams, component, index, includeFull); 19 | 20 | const dropzones = this.streamsToDropzones(filteredStreams, index); 21 | dropzones.sort(dropzoneCompare); 22 | const uniqueDropzones: DropzonePlacement[] = [dropzones[0]]; 23 | for (let i = 1; i < dropzones.length; i++) { 24 | if (dropzoneCompare(dropzones[i - 1], dropzones[i]) !== 0) { 25 | uniqueDropzones.push(dropzones[i]); 26 | } 27 | } 28 | this.calculateClashDepth(uniqueDropzones); 29 | 30 | return uniqueDropzones; 31 | } 32 | 33 | filterStreams( 34 | streams: LinkedStream[], 35 | component: string, 36 | index: number, 37 | includeFull: boolean, 38 | ): LinkedStream[] { 39 | return streams.filter(s => { 40 | // Skip streams that are for a different component 41 | if (s.component !== component) { return false; } 42 | 43 | // Skip streams which don't have enough sessions 44 | if (index >= s.sessions.length) { return false; } 45 | 46 | // Skip full streams unless asked to include them 47 | if (s.full && !includeFull) { return false; } 48 | 49 | return true; 50 | }); 51 | } 52 | 53 | streamsToDropzones(streams: LinkedStream[], index: number): DropzonePlacement[] { 54 | return streams.map(s => { 55 | const session = s.sessions[index]; 56 | return new DropzonePlacement(session); 57 | }); 58 | } 59 | 60 | filterDropzones(dropzones: DropzonePlacement[], dragging: LinkedSession): DropzonePlacement[] { 61 | const selected = new Map(); 62 | const targetDuration = getDuration(dragging); 63 | 64 | const sortedDropzones = dropzones.slice().sort((a, b) => b.duration - a.duration); 65 | for (const dropzone of sortedDropzones) { 66 | const id = dropzone.id; 67 | const other = selected.get(id); 68 | let select = false; 69 | if (other) { 70 | if (dropzone.session.id === dragging.id) { 71 | select = true; 72 | } else if (dropzone.duration === targetDuration && other.duration !== targetDuration) { 73 | select = true; 74 | } 75 | } else { 76 | select = true; 77 | } 78 | 79 | if (select) { 80 | selected.set(id, dropzone); 81 | } 82 | } 83 | 84 | return dropzones.filter(d => selected.get(d.id) === d); 85 | } 86 | 87 | calculateClashDepth( 88 | dropzones: DropzonePlacement[], 89 | ) { 90 | for (let i = 0; i < dropzones.length; ++i) { 91 | const dropzone1 = dropzones[i]; 92 | const clashingZones = new Set(); 93 | for (let j = 0; j < i; ++j) { 94 | const dropzone2 = dropzones[j]; 95 | if (dropzone2.session.day !== dropzone1.session.day) continue; 96 | if (dropzone2.session.end <= dropzone1.session.start) continue; 97 | 98 | clashingZones.add(dropzone2.clashDepth); 99 | } 100 | 101 | dropzone1.clashDepth = findFreeDepth(clashingZones); 102 | } 103 | } 104 | } 105 | 106 | const dm = new DropzoneManager(); 107 | export const getDropzones = dm.getDropzones.bind(dm); 108 | -------------------------------------------------------------------------------- /src/components/Timetable/getHours.spec.ts: -------------------------------------------------------------------------------- 1 | import { SessionData } from '../../state'; 2 | import { getHours } from './getHours'; 3 | 4 | const baseSession: Omit = { 5 | course: '', 6 | stream: '', 7 | day: 'M', 8 | index: 0, 9 | }; 10 | 11 | 12 | it('returns expected defaults', () => { 13 | expect(getHours([])).toEqual({ start: 11, end: 18 }); 14 | }); 15 | 16 | it('keeps minimum value with one session', () => { 17 | const sessions: SessionData[] = [ 18 | { ...baseSession, start: 17, end: 20 }, 19 | ]; 20 | expect(getHours(sessions)).toEqual({ start: 11, end: 20 }); 21 | }); 22 | 23 | it('keeps minimum value with two sessions', () => { 24 | const sessions: SessionData[] = [ 25 | { ...baseSession, start: 12, end: 13 }, 26 | { ...baseSession, start: 17, end: 20 }, 27 | ]; 28 | expect(getHours(sessions)).toEqual({ start: 11, end: 20 }); 29 | }); 30 | 31 | it('keeps maximum value with one session', () => { 32 | const sessions: SessionData[] = [ 33 | { ...baseSession, start: 8, end: 11 }, 34 | ]; 35 | expect(getHours(sessions)).toEqual({ start: 8, end: 18 }); 36 | }); 37 | 38 | it('keeps maximum value with two sessions', () => { 39 | const sessions: SessionData[] = [ 40 | { ...baseSession, start: 9, end: 10 }, 41 | { ...baseSession, start: 11, end: 12 }, 42 | ]; 43 | expect(getHours(sessions)).toEqual({ start: 9, end: 18 }); 44 | }); 45 | 46 | it('correctly handles a single long session', () => { 47 | const sessions: SessionData[] = [ 48 | { ...baseSession, start: 9, end: 20 }, 49 | ]; 50 | expect(getHours(sessions)).toEqual({ start: 9, end: 20 }); 51 | }); 52 | 53 | it('correctly handles multiple days', () => { 54 | const sessions: SessionData[] = [ 55 | { ...baseSession, start: 12, end: 13, day: 'M' }, 56 | { ...baseSession, start: 17, end: 20, day: 'T' }, 57 | { ...baseSession, start: 9, end: 11, day: 'W' }, 58 | { ...baseSession, start: 10, end: 11, day: 'F' }, 59 | ]; 60 | expect(getHours(sessions)).toEqual({ start: 9, end: 20 }); 61 | }); 62 | 63 | it('rounds half-hours correctly', () => { 64 | const sessions: SessionData[] = [ 65 | { ...baseSession, start: 10.5, end: 12 }, 66 | { ...baseSession, start: 17.5, end: 18.5 }, 67 | ]; 68 | expect(getHours(sessions)).toEqual({ start: 10, end: 19 }); 69 | }); 70 | -------------------------------------------------------------------------------- /src/components/Timetable/getHours.ts: -------------------------------------------------------------------------------- 1 | import { LinkedSession, SessionData } from '../../state'; 2 | 3 | export interface HourSpan { 4 | start: number, 5 | end: number, 6 | } 7 | 8 | export const getHours = (sessions: Array): HourSpan => { 9 | let start = 11; 10 | let end = 18; 11 | 12 | for (const session of sessions) { 13 | if (session.start < start) { 14 | start = Math.floor(session.start); 15 | } 16 | if (session.end > end) { 17 | end = Math.ceil(session.end); 18 | } 19 | } 20 | 21 | return { start, end }; 22 | }; 23 | 24 | export default getHours; 25 | -------------------------------------------------------------------------------- /src/components/Timetable/index.ts: -------------------------------------------------------------------------------- 1 | import TimetableTable from './TimetableTable'; 2 | 3 | export { TimetableTable }; 4 | export default TimetableTable; 5 | -------------------------------------------------------------------------------- /src/components/Timetable/timetableTypes.ts: -------------------------------------------------------------------------------- 1 | export interface Dimensions { 2 | width: number, 3 | height: number, 4 | } 5 | 6 | export interface TimetablePosition { 7 | x: number, 8 | y: number, 9 | z?: number, 10 | } 11 | 12 | export type Placement = Dimensions & TimetablePosition; 13 | -------------------------------------------------------------------------------- /src/components/Timetable/timetableUtil.spec.ts: -------------------------------------------------------------------------------- 1 | import { getOverlapArea, getTimetableHeight } from './timetableUtil'; 2 | 3 | describe('getTimetableHeight', () => { 4 | it.each( 5 | [ 6 | [10, false, false, 650], 7 | [8, false, false, 530], 8 | [10, true, false, 550], 9 | [5, true, false, 300], 10 | [10, false, true, 850], 11 | [10, true, true, 850], 12 | ], 13 | )('returns correct value', (duration, compact, showMode, expected) => { 14 | const result = getTimetableHeight(duration, compact, showMode); 15 | expect(result).toBe(expected); 16 | }); 17 | }); 18 | 19 | 20 | describe('getOverlapArea', () => { 21 | it('handles no overlap', () => { 22 | const result = getOverlapArea( 23 | { x: 5, y: 5, width: 3, height: 3 }, 24 | { x: 5, y: 8, width: 3, height: 3 }, 25 | ); 26 | expect(result).toEqual(0); 27 | }); 28 | 29 | it('handles identical rectangles', () => { 30 | const result = getOverlapArea( 31 | { x: 5, y: 5, width: 3, height: 3 }, 32 | { x: 5, y: 5, width: 3, height: 3 }, 33 | ); 34 | expect(result).toEqual(3 * 3); 35 | }); 36 | 37 | it('handles standard overlap', () => { 38 | const result = getOverlapArea( 39 | { x: 4, y: 3, width: 4, height: 4 }, 40 | { x: 5, y: 5, width: 5, height: 5 }, 41 | ); 42 | expect(result).toEqual(3 * 2); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/components/Timetable/timetableUtil.ts: -------------------------------------------------------------------------------- 1 | import { Placement } from './timetableTypes'; 2 | 3 | export const DROPZONE_Z = 100; 4 | export const SESSION_BASE_Z = 10; 5 | export const SESSION_DRAG_Z = DROPZONE_Z; 6 | export const SESSION_LIFT_Z = 1; 7 | 8 | export const CLASH_OFFSET_X = -9; 9 | export const CLASH_OFFSET_Y = -7; 10 | 11 | export const TIMETABLE_DAYS = 5; 12 | export const TIMETABLE_FIRST_CELL_WIDTH = 62; 13 | const TIMETABLE_COMPACT_CELL_HEIGHT = 50; 14 | const TIMETABLE_CELL_HEIGHT = 60; 15 | const TIMETABLE_CELL_SHOW_MODE_HEIGHT = 80; 16 | export const TIMETABLE_BORDER_WIDTH = 1; 17 | export const TIMETABLE_CELL_MIN_WIDTH = 150; 18 | 19 | export const DISPLACE_VARIATION_X = 15; 20 | export const DISPLACE_VARIATION_Y = 10; 21 | export const DISPLACE_RADIUS_X = 15; 22 | export const DISPLACE_RADIUS_Y = 10; 23 | 24 | export const RAISE_DIST_X = -5; 25 | export const RAISE_DIST_Y = -5; 26 | 27 | 28 | export function arraysEqual(a: T[], b: T[]): boolean { 29 | if (a.length !== b.length) { 30 | return false; 31 | } 32 | 33 | for (let i = 0; i < a.length; ++i) { 34 | if (a[i] !== b[i]) { 35 | return false; 36 | } 37 | } 38 | 39 | return true; 40 | } 41 | 42 | export function getCellWidth(timetableWidth: number): number { 43 | return (timetableWidth - TIMETABLE_FIRST_CELL_WIDTH) / TIMETABLE_DAYS; 44 | } 45 | 46 | export function getCellHeight(compact: boolean, showMode: boolean) { 47 | if (showMode) { 48 | return TIMETABLE_CELL_SHOW_MODE_HEIGHT; 49 | } 50 | return compact ? TIMETABLE_COMPACT_CELL_HEIGHT : TIMETABLE_CELL_HEIGHT; 51 | } 52 | 53 | export function getSnapDistance(sessionHeight: number) { 54 | return 30 + 1.15 * sessionHeight; 55 | } 56 | 57 | export function getOverlapArea(p1: Placement, p2: Placement) { 58 | const left = Math.max(p1.x, p2.x); 59 | const right = Math.min(p1.x + p1.width, p2.x + p2.width); 60 | const overlapX = Math.max(right - left, 0); 61 | 62 | const top = Math.max(p1.y, p2.y); 63 | const bottom = Math.min(p1.y + p1.height, p2.y + p2.height); 64 | const overlapY = Math.max(bottom - top, 0); 65 | return overlapX * overlapY; 66 | } 67 | 68 | export function getTimetableHeight(duration: number, compact: boolean, showMode: boolean) { 69 | const headerRowHeight = getCellHeight(true, false); 70 | const mainRowsHeight = getCellHeight(compact, showMode) * duration; 71 | return headerRowHeight + mainRowsHeight; 72 | } 73 | 74 | export function findFreeDepth(takenDepths: Set): number { 75 | for (let j = 0; j < takenDepths.size; ++j) { 76 | if (!takenDepths.has(j)) { 77 | return j; 78 | } 79 | } 80 | 81 | return takenDepths.size; 82 | } 83 | 84 | export function getCustomCode() { 85 | return `custom_${Math.random()}`; 86 | } 87 | -------------------------------------------------------------------------------- /src/components/TimetableControls.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import makeStyles from '@material-ui/core/styles/makeStyles'; 3 | import ButtonBase from '@material-ui/core/ButtonBase'; 4 | import IconButton from '@material-ui/core/IconButton'; 5 | import Toolbar from '@material-ui/core/Toolbar'; 6 | import Tooltip from '@material-ui/core/Tooltip'; 7 | import Undo from '@material-ui/icons/Undo'; 8 | import Redo from '@material-ui/icons/Redo'; 9 | import Refresh from '@material-ui/icons/Refresh'; 10 | import Warning from '@material-ui/icons/Warning'; 11 | import EventIcon from '@material-ui/icons/Event'; 12 | import { useSelector } from 'react-redux'; 13 | import { HistoryData, RootState } from '../state'; 14 | 15 | const useStyles = makeStyles(theme => ({ 16 | primary: { 17 | transition: theme.transitions.create('color'), 18 | color: theme.palette.primary.main, 19 | }, 20 | amber: { 21 | transition: theme.transitions.create('color'), 22 | color: theme.palette.warning.main, 23 | }, 24 | red: { 25 | transition: theme.transitions.create('color'), 26 | color: theme.palette.error.main, 27 | }, 28 | spacer: { 29 | flexGrow: 1, 30 | }, 31 | unplacedCountContainer: { 32 | display: 'flex', 33 | justifyContent: 'center', 34 | alignItems: 'center', 35 | color: theme.palette.warning.main, 36 | cursor: 'pointer', 37 | marginRight: theme.spacing(1), 38 | }, 39 | unplacedCount: { 40 | ...theme.typography.body1, 41 | fontWeight: 500, 42 | paddingRight: theme.spacing(0.25), 43 | }, 44 | })); 45 | 46 | export interface Props { 47 | history: HistoryData, 48 | improvementScore: number, 49 | isUpdating: boolean, 50 | timetableIsEmpty: boolean, 51 | onUndo?: () => void, 52 | onRedo?: () => void, 53 | onUpdate?: () => void, 54 | onIncludeFull?: () => void, 55 | onCreateCustom?: () => void, 56 | } 57 | 58 | export const TimetableControls = ({ 59 | history, 60 | improvementScore, 61 | isUpdating, 62 | timetableIsEmpty, 63 | onUndo, 64 | onRedo, 65 | onUpdate, 66 | onIncludeFull, 67 | onCreateCustom, 68 | }: Props) => { 69 | const classes = useStyles(); 70 | let updateClass = classes.primary; 71 | if (improvementScore > 100) { 72 | if (improvementScore < 800) { 73 | updateClass = classes.amber; 74 | } else { 75 | updateClass = classes.red; 76 | } 77 | } 78 | const unplacedCount = useSelector((state: RootState) => state.unplacedCount); 79 | const canUndo = history.past.length > 0; 80 | const canRedo = history.future.length > 0; 81 | 82 | const handleKeyDown = React.useCallback( 83 | (event: KeyboardEvent) => { 84 | if (event.ctrlKey) { 85 | if (event.key === 'z') { 86 | if (onUndo && canUndo) onUndo(); 87 | } 88 | 89 | if (event.key === 'Z' || event.key === 'y') { 90 | if (onRedo && canRedo) onRedo(); 91 | } 92 | } 93 | }, 94 | [onUndo, onRedo, canUndo, canRedo], 95 | ); 96 | 97 | React.useEffect(() => { 98 | window.addEventListener('keydown', handleKeyDown); 99 | 100 | return () => window.removeEventListener('keydown', handleKeyDown); 101 | }, [handleKeyDown]); 102 | 103 | const classesPlural = unplacedCount === 1 ? 'class is' : 'classes are'; 104 | const fullClassMessage = `${unplacedCount} full ${classesPlural} not visible`; 105 | 106 | return ( 107 | 108 | {onUndo && ( 109 | 110 | 111 | 116 | 117 | 118 | 119 | 120 | )} 121 | {onRedo && ( 122 | 123 | 124 | 129 | 130 | 131 | 132 | 133 | )} 134 | {onUpdate && ( 135 | 136 | 137 | 142 | 143 | 144 | 145 | 146 | )} 147 | 148 |
149 | 150 | {unplacedCount > 0 && ( 151 | 152 | 156 | {unplacedCount} 157 | 158 | 159 | 160 | )} 161 | 162 | {onCreateCustom && ( 163 | 164 | 168 | 169 | 170 | 171 | )} 172 | 173 | ); 174 | }; 175 | 176 | export default TimetableControls; 177 | -------------------------------------------------------------------------------- /src/components/TimetableOptions.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | import makeStyles from '@material-ui/core/styles/makeStyles'; 4 | import FormControlLabel from '@material-ui/core/FormControlLabel'; 5 | import Switch from '@material-ui/core/Switch'; 6 | import { timetableOptionList, Options, OptionName } from '../state'; 7 | import { getOptions } from '../state/selectors'; 8 | 9 | const useStyles = makeStyles(theme => ({ 10 | root: { 11 | width: '100%', 12 | display: 'flex', 13 | flexWrap: 'wrap', 14 | paddingLeft: theme.spacing(1), 15 | paddingRight: theme.spacing(3), 16 | }, 17 | optionContainer: { 18 | margin: 0, 19 | flexGrow: 1, 20 | flexBasis: '100%', 21 | 22 | [theme.breakpoints.up('sm')]: { 23 | flexBasis: '50%', 24 | }, 25 | }, 26 | lessSpaceAbove: { 27 | marginTop: -theme.spacing(0.5), 28 | }, 29 | secondaryText: { 30 | color: theme.palette.text.secondary, 31 | }, 32 | selectTrack: { 33 | opacity: 0.6 * 0.38, 34 | }, 35 | })); 36 | 37 | export interface Props { 38 | options: Options, 39 | onToggleOption: (option: OptionName) => void, 40 | } 41 | 42 | const TimetableOptionsComponent = ({ 43 | options, 44 | onToggleOption, 45 | }: Props) => { 46 | const classes = useStyles(); 47 | const { darkMode } = useSelector(getOptions); 48 | 49 | return ( 50 |
51 | {timetableOptionList.map(([optionName, label]) => ( 52 |
53 | onToggleOption(optionName)} 58 | color={darkMode ? 'primary' : 'secondary'} 59 | value={optionName} 60 | classes={{ track: classes.selectTrack }} 61 | /> 62 | )} 63 | className={`${classes.secondaryText} ${classes.lessSpaceAbove}`} 64 | label={label} 65 | /> 66 |
67 | ))} 68 |
69 | ); 70 | }; 71 | const TimetableOptions = React.memo(TimetableOptionsComponent); 72 | 73 | export default TimetableOptions; 74 | -------------------------------------------------------------------------------- /src/components/WebStream.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | import makeStyles from '@material-ui/core/styles/makeStyles'; 4 | import FormControlLabel from '@material-ui/core/FormControlLabel'; 5 | import Checkbox from '@material-ui/core/Checkbox'; 6 | import { StreamData } from '../state'; 7 | import { getOptions } from '../state/selectors'; 8 | 9 | 10 | const useStyles = makeStyles(theme => ({ 11 | lessSpaceAbove: { 12 | marginTop: -theme.spacing(1), 13 | }, 14 | secondaryText: { 15 | color: theme.palette.text.secondary, 16 | }, 17 | })); 18 | 19 | export interface Props { 20 | checked: boolean, 21 | stream: StreamData, 22 | onChange: () => void, 23 | } 24 | 25 | function WebStream({ checked, stream, onChange }: Props) { 26 | const classes = useStyles(); 27 | const { darkMode, includeFull } = useSelector(getOptions); 28 | 29 | const disabled = stream.full && !includeFull; 30 | const descriptor = 'watch-later'; 31 | let label = `Choose ${descriptor} lecture stream`; 32 | if (stream.full) { 33 | label += ' (full)'; 34 | } 35 | 36 | return ( 37 | 48 | )} 49 | /> 50 | ); 51 | } 52 | 53 | export default WebStream; 54 | -------------------------------------------------------------------------------- /src/configureStore.ts: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from 'redux'; 2 | import thunk, { ThunkMiddleware } from 'redux-thunk'; 3 | import { persistStore, persistReducer, PersistConfig, createMigrate } from 'redux-persist'; 4 | import storage from 'redux-persist/lib/storage'; 5 | import reducer from './reducers'; 6 | import transforms from './transforms'; 7 | import { RootState } from './state'; 8 | import { AllActions } from './actions'; 9 | import { migrations } from './migrations'; 10 | 11 | const persistConfig: PersistConfig = { 12 | key: 'root', 13 | storage, 14 | transforms, 15 | version: 5, 16 | migrate: createMigrate(migrations), 17 | }; 18 | const persistedReducer = persistReducer(persistConfig, reducer); 19 | export const store = createStore( 20 | persistedReducer, 21 | applyMiddleware(thunk as ThunkMiddleware), 22 | ); 23 | export const persistor = persistStore(store); 24 | -------------------------------------------------------------------------------- /src/env.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | rootURI: import.meta.env.VITE_DATA_ROOT_URI || '', 3 | campus: import.meta.env.VITE_CAMPUS || '', 4 | contactURI: `${import.meta.env.VITE_CONTACT_ENDPOINT}/${import.meta.env.VITE_STAGE_NAME}/`, 5 | }; 6 | -------------------------------------------------------------------------------- /src/getCampus.ts: -------------------------------------------------------------------------------- 1 | import env from './env'; 2 | 3 | export const getCampus = (): string => env.campus; 4 | 5 | export const isUNSW = () => getCampus() === 'unsw'; 6 | 7 | export default getCampus; 8 | -------------------------------------------------------------------------------- /src/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | export function useCache(value: T | undefined): T | undefined { 4 | const [cache, setCache] = useState(value); 5 | useEffect(() => { 6 | if (value !== undefined) { 7 | setCache(value); 8 | } 9 | }, [value]); 10 | return cache; 11 | } 12 | 13 | export default { useCache }; 14 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import { Provider } from 'react-redux'; 4 | import { AppContainer } from './AppContainer'; 5 | import { store } from './configureStore'; 6 | 7 | 8 | const rootElement = document.getElementById('root')!; 9 | const root = createRoot(rootElement); 10 | root.render( 11 | 12 | 13 | , 14 | ); 15 | -------------------------------------------------------------------------------- /src/migrations.ts: -------------------------------------------------------------------------------- 1 | import { 2 | pink, deepPurple, indigo, blue, teal, lightGreen, amber, deepOrange, 3 | } from '@material-ui/core/colors'; 4 | import { SessionManagerData } from './components/Timetable/SessionManager'; 5 | import { CourseMap } from './state'; 6 | 7 | export const migrations = { 8 | 0: (state: any) => { 9 | // Change colours from simple strings to objects 10 | const colours: { [course: string]: any } = state.colours; 11 | for (const c of Object.keys(colours)) { 12 | const COLOUR_MAP: { [colourName: string]: string } = { 13 | [pink[700]]: 'pink', 14 | [deepPurple[700]]: 'deepPurple', 15 | [indigo[700]]: 'indigo', 16 | [blue[700]]: 'blue', 17 | [teal[700]]: 'teal', 18 | [lightGreen[700]]: 'lightGreen', 19 | [amber[700]]: 'amber', 20 | [deepOrange[700]]: 'deepOrange', 21 | }; 22 | if (typeof colours[c] === 'string') { 23 | const colour = (colours[c] as string).toLowerCase(); 24 | colours[c] = COLOUR_MAP[colour]; 25 | } 26 | } 27 | return { 28 | ...state, 29 | colours, 30 | }; 31 | }, 32 | 1: (state: any) => { 33 | const meta = { ...state.meta }; 34 | if (meta.sources === undefined) { 35 | meta.sources = []; 36 | if (meta.source) { 37 | meta.sources.push(meta.source); 38 | } 39 | } 40 | delete meta.source; 41 | return { 42 | ...state, 43 | meta, 44 | }; 45 | }, 46 | 2: (state: any) => { 47 | const timetables = state.timetables; 48 | for (const timetable of Object.values(timetables) as SessionManagerData[]) { 49 | timetable.renderOrder = timetable.order; 50 | } 51 | return { ...state }; 52 | }, 53 | 3: (state: any): any => { 54 | const { compactView, darkMode, reducedMotion, twentyFourHours, options, ...otherState } = state; 55 | return { 56 | ...otherState, 57 | options: { ...options, compactView, darkMode, reducedMotion, twentyFourHours }, 58 | }; 59 | }, 60 | 4: (state: any): any => { 61 | // Ensure times has a non-null value, replace with an empty array instead 62 | const courses: CourseMap = state.courses; 63 | const newCourses: CourseMap = {}; 64 | for (const [code, course] of Object.entries(courses)) { 65 | const streams = course.streams.map(stream => ({ 66 | ...stream, 67 | times: stream.times || [], 68 | })); 69 | newCourses[code] = { ...course, streams }; 70 | } 71 | return { ...state, courses: newCourses }; 72 | }, 73 | 5: (state: any): any => { 74 | // Change of times for term 3, 2021 75 | const term = '2021~T3'; 76 | const timetable: SessionManagerData | undefined = state.timetables[term]; 77 | if (timetable !== undefined) { 78 | const newTimetable: SessionManagerData = { ...timetable }; 79 | const updateIds: { [id: string]: string } = { 80 | 'CBS~Growth Groups~M13-14.5': 'CBS~Growth Groups~M13', 81 | 'CBS~Growth Groups~T11-12.5': 'CBS~Growth Groups~T11', 82 | 'CBS~Growth Groups~W11-12.5': 'CBS~Growth Groups~W12', 83 | 'CBS~Growth Groups~H10-11.5': 'CBS~Growth Groups~H10', 84 | 'CBS~Growth Groups~F11-12.5': 'CBS~Growth Groups~F11', 85 | 'CBS~The Bible Talks~T13': 'CBS~The Bible Talks~T12', 86 | 'CBS~The Bible Talks~H12': 'CBS~The Bible Talks~H11', 87 | }; 88 | newTimetable.map = newTimetable.map.map(([id, placement]) => { 89 | const oldStream = placement.session.stream; 90 | const newStream = updateIds[oldStream] || oldStream; 91 | if (newStream) { 92 | const newPlacement: typeof placement = { 93 | ...placement, 94 | session: { 95 | ...placement.session, 96 | stream: newStream, 97 | }, 98 | }; 99 | return [id, newPlacement]; 100 | } 101 | return [id, placement]; 102 | }); 103 | newTimetable.order = newTimetable.order.map(id => updateIds[id] || id); 104 | newTimetable.renderOrder = newTimetable.renderOrder.map(id => updateIds[id] || id); 105 | return { 106 | ...state, 107 | timetables: { ...state.timetables, [term]: newTimetable }, 108 | }; 109 | } 110 | return state; 111 | }, 112 | }; 113 | 114 | export default migrations; 115 | -------------------------------------------------------------------------------- /src/reducers/changelogView.ts: -------------------------------------------------------------------------------- 1 | import { AllActions, SET_CHANGELOG_VIEW } from '../actions'; 2 | import { initialState } from '../state'; 3 | 4 | export function changelogView( 5 | state: Date | undefined, 6 | action: AllActions, 7 | ): Date { 8 | if (action.type === SET_CHANGELOG_VIEW) { 9 | return new Date(); 10 | } 11 | 12 | return state || initialState.changelogView; 13 | } 14 | 15 | export default changelogView; 16 | -------------------------------------------------------------------------------- /src/reducers/colours.ts: -------------------------------------------------------------------------------- 1 | import { COURSE_COLOURS, ColourMap, Colour, getCourseId, initialState } from '../state'; 2 | import { ADD_COURSE, SET_COLOUR, AllActions, SET_COURSE_DATA, REMOVE_COURSE } from '../actions'; 3 | 4 | export function colours(state = initialState.colours, action: AllActions): ColourMap { 5 | const chosenColours = Object.values(state); 6 | 7 | if (action.type === ADD_COURSE) { 8 | const courseId = getCourseId(action.course); 9 | 10 | // Return state without modifications if there is already a colour for this course 11 | if (state[courseId]) { 12 | return state; 13 | } 14 | 15 | const colour = action.course.defaultColour || pickColour(chosenColours); 16 | return { 17 | ...state, 18 | [courseId]: colour, 19 | }; 20 | } 21 | 22 | if (action.type === REMOVE_COURSE) { 23 | const courseId = getCourseId(action.course); 24 | 25 | const newState = { ...state }; 26 | delete newState[courseId]; 27 | return newState; 28 | } 29 | 30 | if (action.type === SET_COLOUR) { 31 | const newColour = action.colour ? action.colour : pickColour(chosenColours); 32 | return { 33 | ...state, 34 | [action.course]: newColour, 35 | }; 36 | } 37 | 38 | if (action.type === SET_COURSE_DATA) { 39 | const additional = action.courses.filter(c => c.isAdditional && c.autoSelect); 40 | const colourPool = [...COURSE_COLOURS]; 41 | const newState = Object.assign( 42 | {}, 43 | ...additional.map(c => { 44 | const colour = c.defaultColour || pickColour(chosenColours, colourPool); 45 | colourPool.splice(colourPool.indexOf(colour), 1); 46 | return { [getCourseId(c)]: colour }; 47 | }), 48 | state, 49 | ); 50 | 51 | return newState; 52 | } 53 | 54 | return state; 55 | } 56 | 57 | function pickColour(chosenColours: Colour[], colourPool: Colour[] = COURSE_COLOURS): Colour { 58 | // Prefer to choose any colours which haven't been chosen yet 59 | let canChoose = colourPool.filter(c => !chosenColours.includes(c)); 60 | 61 | // All colours have been chosen at least once, so just pick anything 62 | if (canChoose.length === 0) { 63 | canChoose = colourPool; 64 | } 65 | 66 | const i = Math.floor(Math.random() * canChoose.length); 67 | const colour = canChoose[i]; 68 | return colour; 69 | } 70 | 71 | export default colours; 72 | -------------------------------------------------------------------------------- /src/reducers/courses.ts: -------------------------------------------------------------------------------- 1 | import { SET_COURSE_DATA, ADD_COURSE, REMOVE_COURSE, AllActions } from '../actions'; 2 | import { CourseMap, CourseId, getCourseId, initialState } from '../state'; 3 | 4 | export function courses( 5 | state: CourseMap = initialState.courses, 6 | action: AllActions, 7 | ): CourseMap { 8 | if (action.type === SET_COURSE_DATA) { 9 | const continuingCourses: CourseMap = {}; 10 | for (const code of Object.keys(state)) { 11 | const course = state[code]; 12 | if (course.isCustom) { 13 | continuingCourses[code] = course; 14 | } 15 | } 16 | 17 | const allCourses: CourseMap = continuingCourses; 18 | 19 | // Return with new course data 20 | for (const course of action.courses) { 21 | const id = getCourseId(course); 22 | allCourses[id] = course; 23 | } 24 | 25 | return allCourses; 26 | } 27 | 28 | if (action.type === ADD_COURSE && action.course.isCustom) { 29 | return { 30 | ...state, 31 | [getCourseId(action.course)]: action.course, 32 | }; 33 | } 34 | 35 | if (action.type === REMOVE_COURSE && action.course.isCustom) { 36 | const newState = { ...state }; 37 | delete newState[getCourseId(action.course)]; 38 | return newState; 39 | } 40 | 41 | return state; 42 | } 43 | 44 | export function chosen( 45 | state: CourseId[] = [], 46 | action: AllActions, 47 | ): CourseId[] { 48 | if (action.type === ADD_COURSE && !action.course.isCustom) { 49 | return [ 50 | ...state, 51 | getCourseId(action.course), 52 | ]; 53 | } 54 | 55 | if (action.type === REMOVE_COURSE && !action.course.isCustom) { 56 | const i = state.indexOf(getCourseId(action.course)); 57 | return [ 58 | ...state.slice(0, i), 59 | ...state.slice(i + 1), 60 | ]; 61 | } 62 | 63 | if (action.type === SET_COURSE_DATA) { 64 | // Clear chosen courses when moving to new term 65 | if (action.isNewTerm) { 66 | return []; 67 | } 68 | 69 | // Only keep chosen courses which have current data 70 | // This would be necessary if a course stops being offered (or part of its 71 | // id changes) for a particular term 72 | const newIds = new Set(action.courses.map(c => getCourseId(c))); 73 | const newState = state.filter(id => newIds.has(id)); 74 | if (newState.length === state.length) { 75 | return state; 76 | } 77 | return newState; 78 | } 79 | 80 | return state; 81 | } 82 | 83 | export function custom( 84 | state: CourseId[] = [], 85 | action: AllActions, 86 | ): CourseId[] { 87 | if (action.type === ADD_COURSE && action.course.isCustom) { 88 | const courseId = getCourseId(action.course); 89 | 90 | // Don't need to change state for an update event 91 | if (state.includes(courseId)) { 92 | return state; 93 | } 94 | 95 | return [...state, courseId]; 96 | } 97 | 98 | if (action.type === REMOVE_COURSE && action.course.isCustom) { 99 | const i = state.indexOf(getCourseId(action.course)); 100 | return [...state.slice(0, i), ...state.slice(i + 1)]; 101 | } 102 | 103 | return state; 104 | } 105 | 106 | export function additional( 107 | state: CourseId[] = [], 108 | action: AllActions, 109 | ): CourseId[] { 110 | if (action.type === SET_COURSE_DATA) { 111 | return action.courses.filter(c => c.isAdditional && c.autoSelect).map(c => getCourseId(c)); 112 | } else if (action.type === REMOVE_COURSE) { 113 | if (action.course.isAdditional) { 114 | return state.filter(c => c !== getCourseId(action.course)); 115 | } 116 | } 117 | 118 | return state; 119 | } 120 | -------------------------------------------------------------------------------- /src/reducers/index.spec.ts: -------------------------------------------------------------------------------- 1 | import rootReducer from '.'; 2 | import { initialState } from '../state'; 3 | 4 | const NO_OP_ACTION = 'NO_OP_ACTION' as any; 5 | 6 | describe('root reducer', () => { 7 | it('initialises correctly', () => { 8 | expect(rootReducer(undefined, NO_OP_ACTION)).toEqual(initialState); 9 | }); 10 | 11 | it('doesn\'t change on a no-op action', () => { 12 | const state = { ...initialState }; 13 | const history = state.history; 14 | const result = rootReducer(state, NO_OP_ACTION); 15 | expect(result).toBe(state); 16 | expect(result).toEqual(initialState); 17 | expect(result.history).toBe(history); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/reducers/index.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import { meta } from './meta'; 3 | import { courses, chosen, custom, additional } from './courses'; 4 | import { events, hiddenEvents, options, scoreConfig } from './options'; 5 | import { timetables, suggestionScore, unplacedCount } from './timetables'; 6 | import { colours } from './colours'; 7 | import { webStreams } from './webStreams'; 8 | import { notice } from './notice'; 9 | import { changelogView } from './changelogView'; 10 | import { AllActions, UPDATE_SESSION_MANAGER, SET_COURSE_DATA, UNDO, REDO } from '../actions'; 11 | import { 12 | getCurrentTerm, 13 | getTimetableState, 14 | HistoryData, 15 | initialState, 16 | push, 17 | redo, 18 | RootState, 19 | undo, 20 | } from '../state'; 21 | import { getCurrentTimetable } from '../state/selectors'; 22 | import { SessionManagerData } from '../components/Timetable/SessionManagerTypes'; 23 | 24 | const basicReducer = combineReducers({ 25 | additional, 26 | colours, 27 | changelogView, 28 | chosen, 29 | courses, 30 | custom, 31 | events, 32 | hiddenEvents, 33 | history: state => state || initialState.history, 34 | meta, 35 | notice, 36 | options, 37 | scoreConfig, 38 | suggestionScore, 39 | timetables, 40 | unplacedCount, 41 | webStreams, 42 | }); 43 | 44 | 45 | function getStateFromHistory( 46 | history: HistoryData, 47 | nextTimetable: SessionManagerData, 48 | nextState: RootState, 49 | ): RootState { 50 | const { timetable, ...otherHistory } = history.present; 51 | timetable.version = nextTimetable.version + 1; 52 | return { 53 | ...nextState, 54 | ...otherHistory, 55 | timetables: { ...nextState.timetables, [getCurrentTerm(nextState.meta)]: timetable }, 56 | history, 57 | }; 58 | } 59 | 60 | const historyReducer = (nextState: RootState, action: AllActions): RootState => { 61 | const nextTimetable = getCurrentTimetable(nextState); 62 | let history = nextState.history; 63 | 64 | if (action.type === UNDO) { 65 | history = undo(history); 66 | return getStateFromHistory(history, nextTimetable, nextState); 67 | } else if (action.type === REDO) { 68 | history = redo(history); 69 | return getStateFromHistory(history, nextTimetable, nextState); 70 | } else if (action.type === UPDATE_SESSION_MANAGER) { 71 | history = push(history, getTimetableState(nextState)); 72 | return { 73 | ...nextState, 74 | history, 75 | }; 76 | } else if (action.type === SET_COURSE_DATA) { 77 | return { 78 | ...nextState, 79 | history: { 80 | ...history, 81 | present: getTimetableState(nextState), 82 | }, 83 | }; 84 | } 85 | 86 | return nextState; 87 | }; 88 | 89 | const rootReducer = (state: RootState | undefined, action: AllActions): RootState => { 90 | const baseState = state || { ...initialState }; 91 | let nextState = basicReducer(baseState, action); 92 | nextState = historyReducer(nextState, action); 93 | 94 | return nextState; 95 | }; 96 | 97 | export default rootReducer; 98 | -------------------------------------------------------------------------------- /src/reducers/meta.spec.ts: -------------------------------------------------------------------------------- 1 | import { meta } from './meta'; 2 | import { ClearNoticeAction, CLEAR_NOTICE, SET_COURSE_DATA, CourseListAction } from '../actions'; 3 | import { initialState, Meta } from '../state'; 4 | 5 | const otherAction: ClearNoticeAction = { type: CLEAR_NOTICE }; 6 | 7 | describe('meta reducer', () => { 8 | it('initialises correctly', () => { 9 | const state = meta(undefined, otherAction); 10 | expect(state).toEqual(initialState.meta); 11 | }); 12 | 13 | it('doesn\'t change on no-op actions', () => { 14 | const state = { ...initialState.meta }; 15 | const result = meta(state, otherAction); 16 | expect(result).toBe(state); 17 | expect(state).toEqual(initialState.meta); 18 | }); 19 | 20 | it('can be set', () => { 21 | const testMeta = { ...initialState.meta }; 22 | const newMeta: Meta = { 23 | year: 1984, 24 | term: 1, 25 | sources: [], 26 | updateDate: '', 27 | updateTime: '', 28 | termStart: '', 29 | }; 30 | const action: CourseListAction = { 31 | type: SET_COURSE_DATA, 32 | courses: [], 33 | meta: newMeta, 34 | isNewTerm: false, 35 | }; 36 | const state = meta(testMeta, action); 37 | expect(testMeta).toEqual(initialState.meta); 38 | expect(state).toEqual(newMeta); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/reducers/meta.ts: -------------------------------------------------------------------------------- 1 | import { AllActions, SET_COURSE_DATA } from '../actions'; 2 | import { initialState, Meta } from '../state'; 3 | 4 | export function meta(state: Meta | undefined, action: AllActions): Meta { 5 | if (action.type === SET_COURSE_DATA && action.meta) { 6 | return action.meta; 7 | } 8 | 9 | return state || initialState.meta; 10 | } 11 | 12 | export default meta; 13 | -------------------------------------------------------------------------------- /src/reducers/notice.spec.ts: -------------------------------------------------------------------------------- 1 | import { notice } from './notice'; 2 | import { 3 | ClearNoticeAction, CLEAR_NOTICE, SetNoticeAction, SET_NOTICE, CourseAction, TOGGLE_WEB_STREAM, 4 | } from '../actions'; 5 | import { initialState, Notice } from '../state'; 6 | 7 | const otherAction: CourseAction = { type: TOGGLE_WEB_STREAM, course: { code: '', name: '', streams: [] } }; 8 | 9 | describe('notice reducer', () => { 10 | it('initialises correctly', () => { 11 | const state = notice(undefined, otherAction); 12 | expect(state).toEqual(initialState.notice); 13 | }); 14 | 15 | it('doesn\'t change on no-op actions when null', () => { 16 | const result = notice(initialState.notice, otherAction); 17 | expect(result).toBe(initialState.notice); 18 | }); 19 | 20 | it('doesn\'t change on no-op actions', () => { 21 | const initial: Notice = { message: 'hello', actions: null, timeout: null }; 22 | const state = { ...initial }; 23 | const result = notice(state, otherAction); 24 | expect(result).toBe(state); 25 | expect(state).toEqual(initial); 26 | }); 27 | 28 | it('can be set when null', () => { 29 | const testNotice: Notice = { message: 'hello', actions: null, timeout: null }; 30 | const action: SetNoticeAction = { 31 | type: SET_NOTICE, 32 | ...testNotice, 33 | }; 34 | const state = notice(null, action); 35 | expect(state).toEqual(testNotice); 36 | }); 37 | 38 | it('can be overwritten when not null', () => { 39 | const testNotice: Notice = { message: 'hello', actions: null, timeout: null }; 40 | const testNotice2: Notice = { message: 'there', actions: [], timeout: null }; 41 | const action: SetNoticeAction = { 42 | type: SET_NOTICE, 43 | ...testNotice2, 44 | }; 45 | const state = notice(testNotice, action); 46 | expect(testNotice).toEqual({ message: 'hello', actions: null, timeout: null }); 47 | expect(state).toEqual(testNotice2); 48 | }); 49 | 50 | it('can clear notice', () => { 51 | const testNotice: Notice = { message: 'hello', actions: null, timeout: null }; 52 | const action: ClearNoticeAction = { type: CLEAR_NOTICE }; 53 | const state = notice(testNotice, action); 54 | expect(state).toBeNull(); 55 | }); 56 | 57 | it('can clear notice when already null', () => { 58 | const action: ClearNoticeAction = { type: CLEAR_NOTICE }; 59 | const state = notice(null, action); 60 | expect(state).toBeNull(); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/reducers/notice.ts: -------------------------------------------------------------------------------- 1 | import { SET_NOTICE, CLEAR_NOTICE, AllActions } from '../actions'; 2 | import { initialState, Notice } from '../state'; 3 | 4 | export function notice( 5 | state = initialState.notice, 6 | action: AllActions, 7 | ): Notice | null { 8 | if (action.type === SET_NOTICE) { 9 | const { message, actions, timeout, callback } = action; 10 | return { message, actions, timeout, callback }; 11 | } else if (action.type === CLEAR_NOTICE) { 12 | return null; 13 | } 14 | 15 | return state; 16 | } 17 | 18 | export default notice; 19 | -------------------------------------------------------------------------------- /src/reducers/options.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AllActions, 3 | ADD_COURSE, 4 | TOGGLE_EVENT, 5 | TOGGLE_OPTION, 6 | TOGGLE_SHOW_EVENTS, 7 | SET_SCORE_CONFIG, 8 | } from '../actions'; 9 | import { AdditionalEvent, CourseId, getEvents, initialState, Options, exclusiveOptions } from '../state'; 10 | import { TimetableScoreConfig } from '../timetable/scoreTimetable'; 11 | 12 | export function events( 13 | state: readonly AdditionalEvent[] = [], 14 | action: AllActions, 15 | ): AdditionalEvent[] { 16 | if (action.type === TOGGLE_EVENT) { 17 | const stateIds = state.map(e => e.id); 18 | const id = action.event.id; 19 | if (stateIds.includes(id)) { 20 | const i = stateIds.indexOf(id); 21 | return [ 22 | ...state.slice(0, i), 23 | ...state.slice(i + 1), 24 | ]; 25 | } 26 | return [ 27 | ...state, 28 | action.event, 29 | ]; 30 | } else if (action.type === ADD_COURSE) { 31 | if (action.course.isAdditional && !action.course.autoSelect) { 32 | const eventList = getEvents(action.course); 33 | if (eventList.length === 1) { 34 | return [...state, eventList[0]]; 35 | } 36 | } 37 | } 38 | 39 | return state as AdditionalEvent[]; 40 | } 41 | 42 | export function options( 43 | state: Options = initialState.options, 44 | action: AllActions, 45 | ): Options { 46 | if (action.type === TOGGLE_OPTION) { 47 | const excludedOptions = exclusiveOptions[action.option] || []; 48 | const exclusionMap = excludedOptions.reduce((obj, option) => ({ ...obj, [option]: false }), {}); 49 | return { 50 | ...state, 51 | ...exclusionMap, 52 | [action.option]: action.value !== undefined ? action.value : !state[action.option], 53 | }; 54 | } 55 | 56 | return state; 57 | } 58 | 59 | export function hiddenEvents( 60 | state: readonly CourseId[] = initialState.hiddenEvents, 61 | action: AllActions, 62 | ): CourseId[] { 63 | if (action.type === TOGGLE_SHOW_EVENTS) { 64 | if (state.includes(action.course)) { 65 | const i = state.indexOf(action.course); 66 | return [ 67 | ...state.slice(0, i), 68 | ...state.slice(i + 1), 69 | ]; 70 | } 71 | 72 | return [ 73 | ...state, 74 | action.course, 75 | ]; 76 | } 77 | 78 | return state as CourseId[]; 79 | } 80 | 81 | export function scoreConfig( 82 | state: TimetableScoreConfig = initialState.scoreConfig, 83 | action: AllActions, 84 | ): TimetableScoreConfig { 85 | if (action.type === SET_SCORE_CONFIG) { 86 | return { ...action.config }; 87 | } 88 | 89 | return state; 90 | } 91 | -------------------------------------------------------------------------------- /src/reducers/timetables.ts: -------------------------------------------------------------------------------- 1 | import { UPDATE_SESSION_MANAGER, UPDATE_SUGGESTED_TIMETABLE, AllActions, UPDATE_UNPLACED_COUNT, SET_COURSE_DATA } from '../actions'; 2 | import { initialState, Timetables, getCurrentTerm, SessionId, getCourseId, getStreamId, CourseId, CourseData } from '../state'; 3 | import { SessionManagerData, SessionManagerEntriesData } from '../components/Timetable/SessionManager'; 4 | 5 | export function timetables( 6 | state: Timetables = initialState.timetables, 7 | action: AllActions, 8 | ): Timetables { 9 | if (action.type === UPDATE_SESSION_MANAGER) { 10 | const term = action.term; 11 | if (state[term] !== action.sessionManager) { 12 | return { ...state, [term]: action.sessionManager }; 13 | } 14 | return state; 15 | } 16 | 17 | if (action.type === SET_COURSE_DATA) { 18 | const courses = new Map(action.courses.map(c => [getCourseId(c), c])); 19 | const term = getCurrentTerm(action.meta); 20 | const timetable: SessionManagerData | undefined = state[term]; 21 | if (!timetable) { 22 | return state; 23 | } 24 | const sessionsToRemove = findMissingSessions(courses, timetable.map); 25 | const newTimetable = { ...timetable }; 26 | newTimetable.map = newTimetable.map.filter(([id, _]) => !sessionsToRemove.has(id)); 27 | newTimetable.order = newTimetable.order.filter(id => !sessionsToRemove.has(id)); 28 | newTimetable.renderOrder = newTimetable.renderOrder.filter(id => !sessionsToRemove.has(id)); 29 | return { ...state, [term]: newTimetable }; 30 | } 31 | 32 | return state; 33 | } 34 | 35 | function findMissingSessions( 36 | courses: Map, 37 | timetableData: SessionManagerEntriesData, 38 | ): Set { 39 | const sessionsToRemove = new Set(); 40 | for (const [sessionId, placement] of timetableData) { 41 | const course = courses.get(placement.session.course); 42 | if (course === undefined) { 43 | sessionsToRemove.add(sessionId); 44 | } else { 45 | const streamExists = course.streams.find( 46 | s => getStreamId(course, s) === placement.session.stream, 47 | ); 48 | if (streamExists === undefined) { 49 | sessionsToRemove.add(sessionId); 50 | } 51 | } 52 | } 53 | return sessionsToRemove; 54 | } 55 | 56 | export function suggestionScore( 57 | state: number | null | undefined = initialState.suggestionScore, 58 | action: AllActions, 59 | ): number | null { 60 | if (action.type === UPDATE_SUGGESTED_TIMETABLE) { 61 | return action.score; 62 | } 63 | 64 | return state; 65 | } 66 | 67 | export function unplacedCount( 68 | state: number | undefined = initialState.unplacedCount, 69 | action: AllActions, 70 | ): number { 71 | if (action.type === UPDATE_UNPLACED_COUNT) { 72 | return action.count; 73 | } 74 | 75 | return state; 76 | } 77 | -------------------------------------------------------------------------------- /src/reducers/webStreams.spec.ts: -------------------------------------------------------------------------------- 1 | import { webStreams } from './webStreams'; 2 | import { ClearNoticeAction, CLEAR_NOTICE, CourseAction, TOGGLE_WEB_STREAM } from '../actions'; 3 | import { initialState, CourseId } from '../state'; 4 | 5 | const otherAction: ClearNoticeAction = { type: CLEAR_NOTICE }; 6 | 7 | describe('webStreams reducer', () => { 8 | it('initialises correctly', () => { 9 | const state = webStreams(undefined, otherAction); 10 | expect(state).toEqual(initialState.webStreams); 11 | }); 12 | 13 | it('doesn\'t change on no-op actions', () => { 14 | const initial: CourseId[] = []; 15 | const state = [...initial]; 16 | const result = webStreams(state, otherAction); 17 | expect(result).toBe(state); 18 | expect(state).toEqual(initial); 19 | }); 20 | 21 | it('can toggle on', () => { 22 | const state = [...initialState.webStreams]; 23 | const action: CourseAction = { 24 | type: TOGGLE_WEB_STREAM, 25 | course: { code: 'a', name: '', streams: [] }, 26 | }; 27 | const prevState = state; 28 | const result = webStreams(state, action); 29 | expect(state).toBe(prevState); 30 | expect(state).toEqual(initialState.webStreams); 31 | expect(result).toEqual(['a']); 32 | }); 33 | 34 | it('can toggle off', () => { 35 | const state = ['a']; 36 | const action: CourseAction = { 37 | type: TOGGLE_WEB_STREAM, 38 | course: { code: 'a', name: '', streams: [] }, 39 | }; 40 | const prevState = state; 41 | const result = webStreams(state, action); 42 | expect(state).toBe(prevState); 43 | expect(state).toEqual(['a']); 44 | expect(result).toEqual([]); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/reducers/webStreams.ts: -------------------------------------------------------------------------------- 1 | import { AllActions, TOGGLE_WEB_STREAM } from '../actions'; 2 | import { CourseId, getCourseId, initialState } from '../state'; 3 | 4 | export function webStreams( 5 | state: CourseId[] | undefined, 6 | action: AllActions, 7 | ): CourseId[] { 8 | if (action.type === TOGGLE_WEB_STREAM) { 9 | const streams = state ? [...state] : []; 10 | const courseId = getCourseId(action.course); 11 | const index = streams.indexOf(courseId); 12 | if (index > -1) { 13 | streams.splice(index, 1); 14 | } else { 15 | streams.push(courseId); 16 | } 17 | return streams; 18 | } 19 | 20 | return state || initialState.webStreams; 21 | } 22 | 23 | export default webStreams; 24 | -------------------------------------------------------------------------------- /src/requestData.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import config from './campusConfig'; 3 | import { getCampus } from './getCampus'; 4 | import { CourseData, Meta } from './state'; 5 | 6 | 7 | export interface CampusData { 8 | courses: CourseData[], 9 | meta: Meta, 10 | } 11 | 12 | 13 | export async function requestData(): Promise { 14 | const campus = getCampus(); 15 | const uri = config[campus].dataPath; 16 | const { data } = await axios.get(uri); 17 | return data; 18 | } 19 | 20 | export default requestData; 21 | -------------------------------------------------------------------------------- /src/saveAsICS.ts: -------------------------------------------------------------------------------- 1 | import download from 'downloadjs'; 2 | import { createEvents, DateArray, EventAttributes } from 'ics'; 3 | import { DayLetter, getDuration, LinkedSession, Meta } from './state'; 4 | import { getComponentName, getOfferingStart, parseBackwardsDateString } from './state/Stream'; 5 | 6 | 7 | export function saveAsICS({ 8 | sessions, 9 | meta, 10 | }: { 11 | sessions: LinkedSession[], 12 | meta: Meta, 13 | }) { 14 | const eventAttributes = sessions.map((s): EventAttributes => { 15 | const realStartTime = getRealTime({ 16 | day: s.day, 17 | hour: s.start, 18 | termStart: getTermStart(s, meta), 19 | week: s.weeks ? getFirstWeek(s.weeks) : 1, 20 | }); 21 | const duration = getDuration(s); 22 | const isSpecialCourse = s.course.isAdditional || s.course.isCustom || false; 23 | const title = ( 24 | isSpecialCourse 25 | ? s.stream.component 26 | : `${s.course.code} ${getComponentName(s.stream)}` 27 | ); 28 | 29 | const descriptionParts: string[] = []; 30 | if (s.weeks) { 31 | descriptionParts.push(`Weeks: ${s.weeks}`); 32 | } 33 | if (s.stream.notes) { 34 | descriptionParts.push(s.stream.notes); 35 | } 36 | const description = descriptionParts.join('\\n\\n'); 37 | const recurrenceDates = getRecurrenceDates(s, meta); 38 | const rdate = getRDATEParam(recurrenceDates); 39 | 40 | return { 41 | description, 42 | duration: { 43 | hours: Math.floor(duration), 44 | minutes: (duration % 1) * 60, 45 | }, 46 | productId: 'CrossAngles', 47 | start: toDateArray(realStartTime), 48 | startOutputType: 'local', 49 | recurrenceRule: `FREQ=WEEKLY;COUNT=1\r\n${rdate}`, 50 | location: s.location, 51 | title, 52 | }; 53 | }); 54 | 55 | const icsOutput = createEvents(eventAttributes); 56 | if (icsOutput.value) { 57 | const data = new Blob([icsOutput.value]); 58 | const filename = `crossangles-${meta.year}-${meta.term}.ics`; 59 | const mime = 'text/ics'; 60 | return download(data, filename, mime) === true; 61 | } else if (icsOutput.error) { 62 | // TODO: dispatch error instead/as well? 63 | throw icsOutput.error; 64 | } 65 | return false; 66 | } 67 | 68 | export function getTermStart(session: LinkedSession, meta: Meta) { 69 | return ( 70 | session.stream.offering 71 | ? parseBackwardsDateString(getOfferingStart(session.stream.offering)) 72 | : new Date(meta.termStart) 73 | ); 74 | } 75 | 76 | export function getFirstWeek(weeksString: string) { 77 | return weeksToArray(weeksString)[0]; 78 | } 79 | 80 | export function weeksToArray(weeksString: string): number[] { 81 | const resultSet = new Set(); 82 | const ranges = weeksString.split(/,\s*/g); 83 | for (const range of ranges) { 84 | const [start, end] = range.split(/-/).map(x => parseInt(x)); 85 | const stop = end || start; 86 | for (let i = start; i <= stop; i++) { 87 | resultSet.add(i); 88 | } 89 | } 90 | // Sort result numerically 91 | const weekList = Array.from(resultSet.values()).sort((a, b) => +(a > b) - +(a < b)); 92 | return weekList; 93 | } 94 | 95 | export function getRecurrenceDates(session: LinkedSession, meta: Meta): Date[] { 96 | const weeksArray = weeksToArray(session.weeks ? session.weeks : '1-10'); 97 | return weeksArray.map(week => getRealTime({ 98 | day: session.day, 99 | hour: session.start, 100 | termStart: getTermStart(session, meta), 101 | week, 102 | })); 103 | } 104 | 105 | export function getRDATEParam(dates: Date[]): string { 106 | // We can skip the first date since it will be created anyway 107 | const dateString = dates.slice(1).map( 108 | d => { 109 | const year = d.getFullYear().toString(); 110 | const month = (d.getMonth() + 1).toString().padStart(2, '0'); 111 | const day = d.getDate().toString().padStart(2, '0'); 112 | const hours = d.getHours().toString().padStart(2, '0'); 113 | const minutes = d.getMinutes().toString().padStart(2, '0'); 114 | const seconds = d.getSeconds().toString().padStart(2, '0'); 115 | return `${year}${month}${day}T${hours}${minutes}${seconds}`; 116 | }, 117 | ).join(',\r\n '); 118 | return `RDATE;VALUE=DATE-TIME:${dateString}`; 119 | } 120 | 121 | export function getRealTime({ 122 | day, 123 | hour, 124 | termStart, 125 | week, 126 | }: { 127 | day: DayLetter, 128 | hour: number, 129 | termStart: Date, 130 | week: number, 131 | }): Date { 132 | const dayIndex = ['M', 'T', 'W', 'H', 'F'].indexOf(day); 133 | const result = new Date(termStart); 134 | result.setUTCDate(result.getUTCDate() + 7 * (week - 1) + dayIndex); 135 | result.setHours(hour); 136 | return result; 137 | } 138 | 139 | export function toDateArray(date: Date): DateArray { 140 | return [ 141 | date.getFullYear(), 142 | date.getMonth() + 1, 143 | date.getDate(), 144 | date.getHours(), 145 | date.getMinutes(), 146 | ]; 147 | } 148 | -------------------------------------------------------------------------------- /src/setupTests.mts: -------------------------------------------------------------------------------- 1 | jest.mock('./env', () => ({ 2 | rootURI: '', 3 | campus: '', 4 | contactURI: '', 5 | })); 6 | -------------------------------------------------------------------------------- /src/state/Colours.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/indent, key-spacing */ 2 | const COLOUR_MAP = { 3 | pink: { light: '#c2185b', dark: '#d81b60' }, 4 | deepPurple: { light: '#512da8', dark: '#5e35b1' }, 5 | indigo: { light: '#303f9f', dark: '#3949ab' }, 6 | blue: { light: '#1976d2', dark: '#1e88e5' }, 7 | teal: { light: '#00796b', dark: '#00897b' }, 8 | lightGreen: { light: '#689f38', dark: '#7cb342' }, 9 | amber: { light: '#ffa000', dark: '#ffb300' }, 10 | deepOrange: { light: '#e64a19', dark: '#f4511e' }, 11 | }; 12 | export const FALLBACK_COLOUR = { light: '#afb42b', dark: '#c0ca33' }; 13 | 14 | export type Colour = keyof typeof COLOUR_MAP; 15 | 16 | export const getColour = (colourName: Colour, dark?: boolean) => { 17 | const colour = COLOUR_MAP[colourName] || FALLBACK_COLOUR; 18 | return dark ? colour.dark : colour.light; 19 | }; 20 | 21 | export const COURSE_COLOURS = Object.keys(COLOUR_MAP) as Colour[]; 22 | 23 | export interface ColourMap { 24 | [course: string]: Colour, 25 | } 26 | -------------------------------------------------------------------------------- /src/state/Course.ts: -------------------------------------------------------------------------------- 1 | import { StreamData } from './Stream'; 2 | import { MinistryMeta } from './Meta'; 3 | import { Colour } from './Colours'; 4 | 5 | export type CourseId = string; 6 | 7 | export enum Career { 8 | UGRD = 1, 9 | PGRD = 2, 10 | RSCH = 3, 11 | } 12 | 13 | export interface CourseData { 14 | code: string, 15 | name: string, 16 | streams: StreamData[], 17 | term?: string, 18 | section?: string, 19 | career?: Career, 20 | isCustom?: boolean, 21 | isAdditional?: boolean, 22 | autoSelect?: boolean, 23 | defaultColour?: Colour, 24 | description?: string, 25 | metadata?: MinistryMeta, 26 | lowerCode?: string, 27 | } 28 | 29 | export interface CourseMap { 30 | [id: string]: CourseData, 31 | } 32 | 33 | 34 | export function getCourseId(course: CourseData, simple = false): CourseId { 35 | const extraSegments = [ 36 | course.code, 37 | course.term, 38 | !simple && course.section, 39 | !simple && careerToString(course.career), 40 | ]; 41 | return extraSegments.filter(x => !!x).join('~'); 42 | } 43 | 44 | export function careerToString(career?: Career): string | undefined { 45 | if (career === Career.PGRD) { 46 | return 'PGRD'; 47 | } else if (career === Career.RSCH) { 48 | return 'RSCH'; 49 | } else if (career === Career.UGRD) { 50 | return ''; 51 | } 52 | return undefined; 53 | } 54 | 55 | export function careerToName(career?: Career): string | undefined { 56 | if (career === Career.PGRD) { 57 | return 'Postgrad'; 58 | } else if (career === Career.RSCH) { 59 | return 'Research'; 60 | } else if (career === Career.UGRD) { 61 | return 'Undergrad'; 62 | } 63 | return undefined; 64 | } 65 | 66 | export function getWebStream(course: CourseData): StreamData | null { 67 | const streams = course.streams; 68 | for (let i = 0; i < streams.length; ++i) { 69 | if (streams[i].web) { 70 | return streams[i]; 71 | } 72 | } 73 | 74 | return null; 75 | } 76 | 77 | export const hasWebStream = (course: CourseData): boolean => getWebStream(course) !== null; 78 | 79 | export function getComponents(course: CourseData): string[] { 80 | const components = course.streams.map(s => s.component); 81 | return components.filter((c, i) => components.indexOf(c) === i); 82 | } 83 | 84 | export function getClarificationText(course: CourseData): string { 85 | const disciplineRegex = /\b[A-Z]{4}\b/g; 86 | const disciplines: string[] = []; 87 | const maxMatches = 10; 88 | if (course.description) { 89 | for (let i = 0; i < maxMatches; ++i) { 90 | const match = disciplineRegex.exec(course.description); 91 | if (match === null) break; 92 | disciplines.push(match[0]); 93 | } 94 | } 95 | 96 | const discipline = disciplines.join(', ') || undefined; 97 | const career = course.career !== Career.UGRD ? careerToName(course.career) : undefined; 98 | const parts = [discipline || course.section, course.term, career]; 99 | return parts.filter(x => x).join('; '); 100 | } 101 | 102 | export const courseSort = (a: CourseData, b: CourseData) => +(a.code > b.code) - +(a.code < b.code); 103 | export const customSort = (a: CourseData, b: CourseData) => +(a.name > b.name) - +(a.name < b.name); 104 | -------------------------------------------------------------------------------- /src/state/Events.spec.ts: -------------------------------------------------------------------------------- 1 | import { getAutoSelectedEvents, getEvents, getEventId } from './Events'; 2 | import { CourseMap, CourseId, CourseData } from './Course'; 3 | import { ClassTime } from './Stream'; 4 | 5 | 6 | describe('getEventId', () => { 7 | it('gives expected result', () => { 8 | const course: CourseData = { 9 | code: 'RING1379', 10 | name: 'Ring Theory 1A', 11 | streams: [ 12 | { component: 'Secret Forging', times: [{ time: 'M8', location: 'Mount Doom' }] }, 13 | ], 14 | isAdditional: true, 15 | }; 16 | const result = getEventId(course, 'Secret Forging'); 17 | expect(result).toBe('RING1379~Secret Forging'); 18 | }); 19 | }); 20 | 21 | 22 | describe('getEvents', () => { 23 | const baseCourse: CourseData = { 24 | code: 'code', 25 | name: '', 26 | streams: [ 27 | { component: 'a', times: [] }, 28 | { component: 'b', times: [] }, 29 | ], 30 | }; 31 | 32 | it('gets events from additional course', () => { 33 | const course: CourseData = { ...baseCourse, isAdditional: true }; 34 | const result = getEvents(course); 35 | expect(result).toEqual([ 36 | { id: 'code~a', name: 'a' }, 37 | { id: 'code~b', name: 'b' }, 38 | ]); 39 | }); 40 | 41 | it('gives no events for non-additional courses', () => { 42 | expect(getEvents(baseCourse)).toEqual([]); 43 | expect(getEvents({ ...baseCourse, isAdditional: false })).toEqual([]); 44 | }); 45 | 46 | it('doesn\'t give duplicate events', () => { 47 | const course: CourseData = { 48 | code: 'code', 49 | name: '', 50 | isAdditional: true, 51 | streams: [ 52 | { component: 'a', times: [] }, 53 | { component: 'a', times: [] }, 54 | ], 55 | }; 56 | const result = getEvents(course); 57 | expect(result).toEqual([ 58 | { id: 'code~a', name: 'a' }, 59 | ]); 60 | }); 61 | }); 62 | 63 | describe('getAutoSelectedEvents', () => { 64 | it('gets events from auto-selected courses', () => { 65 | const times: ClassTime[] = []; 66 | const courseMap: CourseMap = { 67 | a: { 68 | code: 'a', 69 | name: '', 70 | isAdditional: true, 71 | autoSelect: true, 72 | streams: [ 73 | { component: 'a', times }, 74 | { component: 'b', times }, 75 | ], 76 | }, 77 | b: { 78 | code: 'b', 79 | name: '', 80 | autoSelect: true, 81 | streams: [ 82 | { component: 'c', times }, 83 | ], 84 | }, 85 | d: { 86 | code: 'd', 87 | name: '', 88 | isAdditional: true, 89 | streams: [ 90 | { component: 'd', times }, 91 | ], 92 | }, 93 | }; 94 | const additional: CourseId[] = ['a', 'd']; 95 | const result = getAutoSelectedEvents(courseMap, additional); 96 | expect(result).toEqual([ 97 | { id: 'a~a', name: 'a' }, 98 | { id: 'a~b', name: 'b' }, 99 | ]); 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /src/state/Events.ts: -------------------------------------------------------------------------------- 1 | import { CourseMap, CourseId, CourseData, getCourseId } from './Course'; 2 | 3 | export interface AdditionalEvent { 4 | id: string, 5 | name: string, 6 | hideIfOnlyEvent?: boolean, 7 | } 8 | 9 | 10 | export const getEventId = ( 11 | course: CourseData, 12 | component: string, 13 | ) => `${getCourseId(course)}~${component}`; 14 | 15 | export function getEvents(course: CourseData): AdditionalEvent[] { 16 | if (!course.isAdditional) { 17 | return []; 18 | } 19 | 20 | const events: AdditionalEvent[] = course.streams.map(s => ({ 21 | id: getEventId(course, s.component), 22 | name: s.component, 23 | hideIfOnlyEvent: getEventId(course, s.component) === 'CBS~Lunch' ? true : undefined, 24 | })); 25 | const eventIds = events.map(e => e.id); 26 | const uniqueEvents = events.filter((e, i) => eventIds.indexOf(e.id) === i); 27 | return uniqueEvents; 28 | } 29 | 30 | export function getAutoSelectedEvents( 31 | courseMap: CourseMap, 32 | additional: CourseId[], 33 | ) { 34 | const courseList = additional.map(id => courseMap[id]); 35 | const filtered = courseList.filter(c => c.autoSelect); 36 | const events = []; 37 | for (const course of filtered) { 38 | events.push(...getEvents(course)); 39 | } 40 | return events; 41 | } 42 | -------------------------------------------------------------------------------- /src/state/Meta.spec.ts: -------------------------------------------------------------------------------- 1 | import { getCurrentTerm } from './Meta'; 2 | 3 | it.each` 4 | term | year | expected 5 | ${1} | ${1980} | ${'1980~1'} 6 | ${2} | ${2000} | ${'2000~2'} 7 | ${3} | ${2222} | ${'2222~3'} 8 | `('getCurrentTerm({term:$term, year:$year}) = $expected', ({ term, year, expected }) => { 9 | expect(getCurrentTerm({ term, year })).toBe(expected); 10 | }); 11 | -------------------------------------------------------------------------------- /src/state/Meta.ts: -------------------------------------------------------------------------------- 1 | export interface YearAndTerm { 2 | year: number, 3 | term: number, 4 | } 5 | 6 | export interface Meta extends YearAndTerm { 7 | updateDate: string, 8 | updateTime: string, 9 | termStart: string, 10 | sources: string[], 11 | } 12 | 13 | // Ministry-specific metadata, to be attached to additional courses 14 | export interface MinistryMeta { 15 | promoText: string, 16 | website: string, 17 | signupURL: string, 18 | signupValidFor: YearAndTerm[], 19 | } 20 | 21 | export const getCurrentTerm = (meta: YearAndTerm) => `${meta.year}~${meta.term}`; 22 | -------------------------------------------------------------------------------- /src/state/Notice.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | export const DEFAULT_NOTICE_TIMEOUT = 6000; 4 | 5 | export interface Notice { 6 | message: string, 7 | actions: ReactNode, 8 | timeout: number | null, 9 | callback?: () => void, 10 | } 11 | -------------------------------------------------------------------------------- /src/state/Options.ts: -------------------------------------------------------------------------------- 1 | export interface Options { 2 | compactView?: boolean, 3 | darkMode?: boolean, 4 | includeFull?: boolean, 5 | showEnrolments?: boolean, 6 | showLocations?: boolean, 7 | showWeeks?: boolean, 8 | showMode?: boolean, 9 | reducedMotion?: boolean, 10 | twentyFourHours?: boolean, 11 | } 12 | 13 | export type OptionName = keyof Options; 14 | export interface OptionListItem { 15 | key: OptionName, 16 | title: string, 17 | visible: boolean, 18 | } 19 | export type OptionTuple = [OptionName, string]; 20 | 21 | function filterOptionList(options: OptionListItem[]): OptionTuple[] { 22 | return options.filter(o => o.visible !== false).map(o => { 23 | const tuple: OptionTuple = [o.key, o.title]; 24 | return tuple; 25 | }); 26 | } 27 | 28 | const fullClassName = 'full'; 29 | 30 | const allTimetableOptions: OptionListItem[] = [ 31 | { key: 'showLocations', title: 'Show Locations', visible: true }, 32 | { key: 'showEnrolments', title: 'Show Enrolments', visible: true }, 33 | { key: 'showWeeks', title: 'Show Weeks', visible: true }, 34 | { key: 'showMode', title: 'Show Delivery Mode', visible: true }, 35 | { key: 'includeFull', title: `Include ${fullClassName} classes`, visible: true }, 36 | ]; 37 | export const timetableOptionList = filterOptionList(allTimetableOptions); 38 | 39 | const allGeneralOptions: OptionListItem[] = [ 40 | { key: 'compactView', title: 'Compact display', visible: true }, 41 | { key: 'darkMode', title: 'Dark mode', visible: true }, 42 | { key: 'reducedMotion', title: 'Reduced animations', visible: true }, 43 | ]; 44 | export const generalOptionList = filterOptionList(allGeneralOptions); 45 | 46 | export const exclusiveOptions: { [key in keyof Options]: OptionName[] } = { 47 | compactView: ['showMode'], 48 | showMode: ['compactView'], 49 | }; 50 | 51 | export function getOption(options: Options, option: OptionName): boolean { 52 | return options[option] || false; 53 | } 54 | 55 | export function getDefaultDarkMode(): boolean { 56 | if (window.matchMedia) { 57 | return window.matchMedia('(prefers-color-scheme: dark)').matches; 58 | } 59 | 60 | return false; 61 | } 62 | -------------------------------------------------------------------------------- /src/state/Session.spec.ts: -------------------------------------------------------------------------------- 1 | import { getSessionId, getDuration } from './Session'; 2 | import { getCourse, getLinkedSession } from '../test_util'; 3 | import { getSessions, getStreamId } from './Stream'; 4 | 5 | 6 | describe('getSessionId', () => { 7 | it.each([ 8 | 0, 1, 2, 9 | ])('gives expected result for session #%d', i => { 10 | const course = getCourse(); 11 | const stream = course.streams[0]; 12 | const session = getSessions(course, stream)[i]; 13 | expect(getSessionId(course, stream, session)).toBe(`${getStreamId(course, stream)}~${i}`); 14 | }); 15 | }); 16 | 17 | it.each([ 18 | [11, 10, 1], 19 | [21, 15, 6], 20 | [12, 9, 3], 21 | ])('getDuration({end: %d, start: %d}) = %d', (end, start, expected) => { 22 | const session = getLinkedSession(0, 0, { start, end }); 23 | expect(getDuration(session)).toBe(expected); 24 | }); 25 | -------------------------------------------------------------------------------- /src/state/Session.ts: -------------------------------------------------------------------------------- 1 | import { CourseData, CourseId, getCourseId } from './Course'; 2 | import { StreamData, StreamId, getStreamId, LinkedStream } from './Stream'; 3 | 4 | export type SessionId = string; 5 | 6 | export type DayLetter = 'M' | 'T' | 'W' | 'H' | 'F'; 7 | 8 | export interface SessionCommon { 9 | index: number, 10 | start: number, 11 | end: number, 12 | day: DayLetter, 13 | canClash?: boolean, 14 | location?: string, 15 | weeks?: string, 16 | } 17 | 18 | export interface SessionData extends SessionCommon { 19 | course: CourseId, 20 | stream: StreamId, 21 | } 22 | 23 | export interface LinkedSession extends SessionCommon { 24 | course: CourseData, 25 | stream: LinkedStream, 26 | id: SessionId, 27 | } 28 | 29 | 30 | export function getSessionId(course: CourseData, stream: StreamData, session: SessionData) { 31 | const streamId = getStreamId(course, stream); 32 | return `${streamId}~${session.index}`; 33 | } 34 | 35 | export function getDuration(session: SessionData | LinkedSession): number { 36 | return session.end - session.start; 37 | } 38 | 39 | export function linkSession( 40 | course: CourseData, stream: LinkedStream, session: SessionData, 41 | ): LinkedSession { 42 | const id = getSessionId(course, stream, session); 43 | return { ...session, course, stream, id }; 44 | } 45 | 46 | export function unlinkSession(session: LinkedSession): SessionData { 47 | const course = getCourseId(session.course); 48 | const stream = getStreamId(session.course, session.stream); 49 | const { index, day, start, end, canClash, location, weeks } = session; 50 | return { 51 | course, 52 | stream, 53 | index, 54 | start, 55 | end, 56 | day, 57 | canClash, 58 | location, 59 | weeks, 60 | }; 61 | } 62 | -------------------------------------------------------------------------------- /src/state/StateHistory.ts: -------------------------------------------------------------------------------- 1 | import { TimetableHistoryState } from '.'; 2 | 3 | export interface HistoryData { 4 | past: TimetableHistoryState[], 5 | present: TimetableHistoryState, 6 | future: TimetableHistoryState[], 7 | } 8 | 9 | export function undo(history: HistoryData): HistoryData { 10 | const { past, present, future } = history; 11 | return { 12 | past: [...past.slice(0, past.length - 1)], 13 | present: past[past.length - 1], 14 | future: [present, ...future], 15 | }; 16 | } 17 | 18 | export function redo(history: HistoryData): HistoryData { 19 | const { past, present, future } = history; 20 | return { 21 | past: [...past, present], 22 | present: future[0], 23 | future: future.slice(1), 24 | }; 25 | } 26 | 27 | export function push(history: HistoryData, next: TimetableHistoryState): HistoryData { 28 | const { past, present } = history; 29 | 30 | if (noStateChange(present, next)) { 31 | return history; 32 | } 33 | 34 | return { 35 | past: [...past, present], 36 | present: next, 37 | future: [], 38 | }; 39 | } 40 | 41 | function noStateChange(current: TimetableHistoryState, next: TimetableHistoryState) { 42 | if (current.custom !== next.custom) { 43 | return false; 44 | } 45 | if (current.additional !== next.additional) { 46 | return false; 47 | } 48 | if (current.chosen !== next.chosen) { 49 | return false; 50 | } 51 | if (current.events !== next.events) { 52 | return false; 53 | } 54 | if (current.options !== next.options) { 55 | return false; 56 | } 57 | if (current.colours !== next.colours) { 58 | return false; 59 | } 60 | if (current.webStreams !== next.webStreams) { 61 | return false; 62 | } 63 | if (current.timetable !== next.timetable) { 64 | if (current.timetable.map.length !== next.timetable.map.length) { 65 | return false; 66 | } 67 | if (JSON.stringify(current.timetable.map) !== JSON.stringify(next.timetable.map)) { 68 | return false; 69 | } 70 | } 71 | 72 | return true; 73 | } 74 | -------------------------------------------------------------------------------- /src/state/Stream.spec.ts: -------------------------------------------------------------------------------- 1 | import { getStreamId, getSessions, StreamData, linkStream, getComponentName, closestMonday, parseBackwardsDateString } from './Stream'; 2 | import { getCourse } from '../test_util'; 3 | import { CourseData, getCourseId } from './Course'; 4 | import { SessionData } from './Session'; 5 | 6 | 7 | describe('getStreamId', () => { 8 | it('returns expected value', () => { 9 | const course = getCourse(); 10 | const stream = course.streams[0]; 11 | expect(getStreamId(course, stream)).toBe('RING9731~LEC~M9,H12,T12'); 12 | }); 13 | }); 14 | 15 | describe('getSessions', () => { 16 | it.each([ 17 | true, false, 18 | ])('returns empty list for stream with no times (web=$web)', web => { 19 | const stream: StreamData = { component: 'LEC', enrols: [0, 0], times: [], web }; 20 | const course: CourseData = { 21 | ...getCourse(), 22 | streams: [stream], 23 | }; 24 | expect(getSessions(course, stream)).toEqual([]); 25 | }); 26 | 27 | it('gives expected result', () => { 28 | const stream: StreamData = { 29 | component: 'LEC', 30 | enrols: [0, 0], 31 | times: [{ time: 'M10-20', location: 'foo' }, { time: 'T15', weeks: 'bar' }, { time: 'H8' }], 32 | }; 33 | const course: CourseData = { 34 | ...getCourse(), 35 | streams: [stream], 36 | }; 37 | const courseId = getCourseId(course); 38 | const streamId = getStreamId(course, stream); 39 | const common: Omit = { 40 | course: courseId, 41 | stream: streamId, 42 | canClash: undefined, 43 | weeks: undefined, 44 | location: undefined, 45 | }; 46 | const expected: SessionData[] = [ 47 | { ...common, index: 0, day: 'M', start: 10, end: 20, location: 'foo' }, 48 | { ...common, index: 1, day: 'T', start: 15, end: 16, weeks: 'bar' }, 49 | { ...common, index: 2, day: 'H', start: 8, end: 9 }, 50 | ]; 51 | expect(getSessions(course, stream)).toEqual(expected); 52 | }); 53 | }); 54 | 55 | 56 | describe('linkStream', () => { 57 | it('gives consistent output', () => { 58 | const course = getCourse(); 59 | for (const stream of course.streams) { 60 | expect(linkStream(course, stream)).toMatchSnapshot(); 61 | } 62 | }); 63 | }); 64 | 65 | 66 | it.each([ 67 | ['TUT', 'Tutorial'], 68 | ['LEC', 'Lecture'], 69 | ['LE1', 'Lecture (1)'], 70 | ['QQQ', 'QQQ'], 71 | ])('getComponentName("%s") = "%s"', (code, name) => { 72 | expect(getComponentName({ component: code })).toEqual(name); 73 | }); 74 | 75 | 76 | it.each([ 77 | ['04/07/2022', new Date(2022, 6, 4)], 78 | ['1/2/2023', new Date(2023, 1, 1)], 79 | ])('parseBackwardsDateString(%s) = %s', (input, output) => { 80 | expect(parseBackwardsDateString(input)).toEqual(output); 81 | }); 82 | 83 | 84 | it.each([ 85 | [new Date(2022, 6, 3), new Date(2022, 6, 4)], 86 | [new Date(2022, 6, 4), new Date(2022, 6, 4)], 87 | [new Date(2022, 6, 5), new Date(2022, 6, 4)], 88 | [new Date(2022, 6, 8), new Date(2022, 6, 4)], 89 | [new Date(2022, 6, 9), new Date(2022, 6, 11)], 90 | [new Date(2022, 6, 10), new Date(2022, 6, 11)], 91 | ])('closestMonday(%s) = %s', (input, output) => { 92 | expect(closestMonday(input)).toEqual(output); 93 | }); 94 | -------------------------------------------------------------------------------- /src/state/Stream.ts: -------------------------------------------------------------------------------- 1 | import { SessionData, DayLetter, LinkedSession, linkSession } from './Session'; 2 | import { CourseData, getCourseId } from './Course'; 3 | 4 | const ONE_DAY = 1000 * 60 * 60 * 24; 5 | 6 | export type StreamId = string; 7 | 8 | export enum DeliveryType { 9 | person, 10 | online, 11 | either, 12 | mixed, 13 | } 14 | 15 | export interface StreamData { 16 | component: C, 17 | times: ClassTime[], 18 | enrols?: [number, number], 19 | full?: boolean, 20 | web?: boolean, 21 | offering?: string, 22 | delivery?: DeliveryType, 23 | notes?: string, 24 | } 25 | 26 | export interface LinkedStream extends StreamData { 27 | course: CourseData, 28 | sessions: LinkedSession[], 29 | id: StreamId, 30 | } 31 | 32 | export interface ClassTime { 33 | time: string, 34 | location?: string, 35 | weeks?: string, 36 | canClash?: boolean, 37 | } 38 | 39 | 40 | export function getStreamId(course: CourseData, stream: StreamData, simple = false) { 41 | const timeString = stream.times ? stream.times.map(t => t.time).join(',') : 'WEB'; 42 | const id = `${getComponentId(course, stream, simple)}~${timeString}`; 43 | return id; 44 | } 45 | 46 | export function getComponentId(course: CourseData, stream: StreamData, simple = false) { 47 | const componentString = course.isCustom ? '' : stream.component; 48 | return `${getCourseId(course, simple)}~${componentString}`; 49 | } 50 | 51 | export function getComponentName(stream: Pick) { 52 | const code = stream.component; 53 | const nameMap: { [key: string]: string } = { 54 | CLN: 'Clinical', 55 | EXA: 'Exam', 56 | EXM: 'Exam', 57 | FLD: 'Field Studies', 58 | HON: 'Honours', 59 | LAB: 'Lab', 60 | LA1: 'Lab (1)', 61 | LA2: 'Lab (2)', 62 | LEC: 'Lecture', 63 | LE1: 'Lecture (1)', 64 | LE2: 'Lecture (2)', 65 | OTH: 'Other', 66 | PRJ: 'Project', 67 | SEM: 'Seminar', 68 | STD: 'Studio', 69 | THE: 'Thesis', 70 | TLB: 'Tutorial-Laboratory', 71 | TUT: 'Tutorial', 72 | TU1: 'Tutorial (1)', 73 | TU2: 'Tutorial (2)', 74 | WEB: 'Lecture', 75 | }; 76 | return nameMap[code.toUpperCase()] || code; 77 | } 78 | 79 | export function getSessions(course: CourseData, stream: StreamData): SessionData[] { 80 | const courseId = getCourseId(course); 81 | const streamId = getStreamId(course, stream); 82 | return stream.times.map((t, i): SessionData => { 83 | const [startHour, endHour] = t.time.substr(1).split('-').map(x => parseFloat(x)); 84 | return { 85 | start: startHour, 86 | end: endHour || (startHour + 1), 87 | day: t.time.charAt(0) as DayLetter, 88 | canClash: t.canClash, 89 | location: t.location, 90 | index: i, 91 | weeks: t.weeks, 92 | stream: streamId, 93 | course: courseId, 94 | }; 95 | }); 96 | } 97 | 98 | export function linkStream(course: CourseData, stream: StreamData): LinkedStream { 99 | const sessionData = getSessions(course, stream); 100 | const linkedStream: LinkedStream = { 101 | ...stream, 102 | course, 103 | sessions: [], 104 | id: getStreamId(course, stream), 105 | }; 106 | linkedStream.sessions = sessionData.map(session => linkSession(course, linkedStream, session)); 107 | return linkedStream; 108 | } 109 | 110 | export function getOfferingStart(offering: string) { 111 | return offering.split(/[\s-]+/g)[0]; 112 | } 113 | 114 | export function getTermStart(streams: StreamData[]): Date { 115 | const offeringStarts: Record = {}; 116 | for (const stream of streams) { 117 | if (stream.offering) { 118 | const offeringStart = getOfferingStart(stream.offering); 119 | offeringStarts[offeringStart] = (offeringStarts[offeringStart] || 0) + 1; 120 | } 121 | } 122 | let mostCommonOffering: string | null = null; 123 | let mostCommonOfferingCount = 0; 124 | for (const [offering, count] of Object.entries(offeringStarts)) { 125 | if (count > mostCommonOfferingCount) { 126 | mostCommonOffering = offering; 127 | mostCommonOfferingCount = count; 128 | } 129 | } 130 | const termStart = mostCommonOffering ? parseBackwardsDateString(mostCommonOffering) : new Date(); 131 | return closestMonday(termStart); 132 | } 133 | 134 | // Parse date strings in format: dd/mm/yyyy 135 | export function parseBackwardsDateString(dateString: string) { 136 | const [day, month, year] = dateString.trim().split(/[/-]/g); 137 | return new Date(+year, +month - 1, +day); 138 | } 139 | 140 | export function closestMonday(date: Date) { 141 | const weekday = date.getDay(); 142 | const differenceInDays = weekday < 6 ? 1 - weekday : 2; 143 | const differenceInMS = ONE_DAY * differenceInDays; 144 | return new Date(date.getTime() + differenceInMS); 145 | } 146 | -------------------------------------------------------------------------------- /src/state/Timetable.spec.ts: -------------------------------------------------------------------------------- 1 | import { getCurrentTimetable } from './selectors'; 2 | import { initialState } from '.'; 3 | import { getEmptySessionManagerData, SessionManagerData } from '../components/Timetable/SessionManagerTypes'; 4 | 5 | 6 | describe('getCurrentTimetable', () => { 7 | it('gives the correct value', () => { 8 | const timetable: SessionManagerData = { 9 | map: [], 10 | order: [], 11 | renderOrder: [], 12 | score: 0, 13 | version: 0, 14 | }; 15 | const state = { 16 | timetables: { '2020~2': timetable }, 17 | meta: { 18 | ...initialState.meta, 19 | year: 2020, 20 | term: 2, 21 | }, 22 | }; 23 | expect(getCurrentTimetable(state)).toBe(timetable); 24 | expect(getCurrentTimetable(state)).toBe(timetable); 25 | }); 26 | 27 | it('defaults to an empty timetable', () => { 28 | const state = { 29 | timetables: {}, 30 | meta: initialState.meta, 31 | }; 32 | expect(getCurrentTimetable(state)).toEqual(getEmptySessionManagerData()); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/state/Timetable.ts: -------------------------------------------------------------------------------- 1 | import { SessionManagerData } from '../components/Timetable/SessionManagerTypes'; 2 | import { getCurrentTimetable } from './selectors'; 3 | import { RootState, TimetableHistoryState } from '.'; 4 | 5 | export type Timetables = { [term: string]: SessionManagerData }; 6 | 7 | type NoHistoryState = Omit; 8 | export function getTimetableState(state: NoHistoryState): TimetableHistoryState { 9 | const { 10 | courses, 11 | custom, 12 | additional, 13 | chosen, 14 | events, 15 | options, 16 | colours, 17 | webStreams, 18 | } = state; 19 | const timetable = getCurrentTimetable(state); 20 | return { 21 | courses, 22 | custom, 23 | additional, 24 | chosen, 25 | events, 26 | options, 27 | timetable, 28 | colours, 29 | webStreams, 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /src/state/index.ts: -------------------------------------------------------------------------------- 1 | import { CourseId, CourseMap } from './Course'; 2 | import { HistoryData } from './StateHistory'; 3 | import { AdditionalEvent } from './Events'; 4 | import { ColourMap } from './Colours'; 5 | import { Options } from './Options'; 6 | import { Notice } from './Notice'; 7 | import { getCurrentTerm, Meta } from './Meta'; 8 | import { getEmptySessionManagerData, SessionManagerData } from '../components/Timetable/SessionManagerTypes'; 9 | import { Timetables } from './Timetable'; 10 | import { getCurrentTimetable } from './selectors'; 11 | import { defaultScoreConfig, TimetableScoreConfig } from '../timetable/scoreTimetable'; 12 | 13 | export * from './Colours'; 14 | export * from './Course'; 15 | export * from './Events'; 16 | export * from './Meta'; 17 | export * from './Notice'; 18 | export * from './Options'; 19 | export * from './Session'; 20 | export * from './StateHistory'; 21 | export * from './Stream'; 22 | export * from './Timetable'; 23 | 24 | 25 | export interface TimetableState { 26 | courses: CourseMap, 27 | custom: CourseId[], 28 | additional: CourseId[], 29 | chosen: CourseId[], 30 | events: AdditionalEvent[], 31 | options: Options, 32 | colours: ColourMap, 33 | webStreams: CourseId[], 34 | } 35 | 36 | export interface TimetableHistoryState extends TimetableState { 37 | timetable: SessionManagerData, 38 | } 39 | 40 | export interface RootState extends TimetableState { 41 | changelogView: Date, 42 | hiddenEvents: CourseId[], 43 | history: HistoryData, 44 | meta: Meta, 45 | notice: Notice | null, 46 | timetables: Timetables, 47 | scoreConfig: TimetableScoreConfig, 48 | suggestionScore: number | null, 49 | unplacedCount: number, 50 | } 51 | 52 | 53 | export const initialTimetableState: TimetableState = { 54 | courses: {}, 55 | custom: [], 56 | additional: [], 57 | chosen: [], 58 | events: [], 59 | options: {}, 60 | colours: {}, 61 | webStreams: [], 62 | }; 63 | 64 | export const meta: Meta = { 65 | sources: [], 66 | term: 1, 67 | termStart: '', 68 | updateDate: '', 69 | updateTime: '', 70 | year: 1960, 71 | }; 72 | 73 | export const timetables: Timetables = { [getCurrentTerm(meta)]: getEmptySessionManagerData() }; 74 | 75 | export const history: HistoryData = { 76 | past: [], 77 | present: { 78 | ...initialTimetableState, 79 | timetable: getCurrentTimetable({ timetables, meta }), 80 | }, 81 | future: [], 82 | }; 83 | 84 | export const initialState: RootState = { 85 | ...initialTimetableState, 86 | timetables, 87 | meta, 88 | history, 89 | notice: null, 90 | scoreConfig: defaultScoreConfig, 91 | suggestionScore: null, 92 | unplacedCount: 0, 93 | hiddenEvents: [], 94 | changelogView: new Date(), 95 | }; 96 | -------------------------------------------------------------------------------- /src/state/selectors.spec.ts: -------------------------------------------------------------------------------- 1 | import { getShowSignupCombiner } from './selectors'; 2 | import { getCourse, getAdditionalCourse } from '../test_util'; 3 | import { getCourseId } from './Course'; 4 | import { AdditionalEvent, getEvents } from './Events'; 5 | 6 | describe('getShowSignup', () => { 7 | it.each([ 8 | true, false, 9 | ])('shows and hides signup link correctly (show = %s)', show => { 10 | const courses = [getCourse(), getAdditionalCourse()]; 11 | const additionalId = getCourseId(courses[1]); 12 | const courseMap = { 13 | [getCourseId(courses[0])]: courses[0], 14 | [additionalId]: courses[1], 15 | }; 16 | const events: AdditionalEvent[] = show ? [getEvents(courses[1])[0]] : []; 17 | expect(getShowSignupCombiner(courseMap, [additionalId], events)).toBe(show); 18 | }); 19 | 20 | it('hides signup link if no chosen events exist', () => { 21 | const courses = [getCourse(), getAdditionalCourse()]; 22 | const additionalId = getCourseId(courses[1]); 23 | const courseMap = { 24 | [getCourseId(courses[0])]: courses[0], 25 | [additionalId]: courses[1], 26 | }; 27 | const events: AdditionalEvent[] = [{ id: 'adlkjgsd', name: 'Does not exist' }]; 28 | expect(getShowSignupCombiner(courseMap, [additionalId], events)).toBe(false); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/state/selectors.ts: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | import { RootState } from '.'; 3 | import { courseSort, customSort, CourseId, CourseMap } from './Course'; 4 | import { getEmptySessionManagerData } from '../components/Timetable/SessionManagerTypes'; 5 | import { getCurrentTerm } from './Meta'; 6 | import { getAutoSelectedEvents, AdditionalEvent } from './Events'; 7 | 8 | 9 | const getCourses = (state: RootState) => state.courses; 10 | const getChosen = (state: RootState) => state.chosen; 11 | const getCustom = (state: RootState) => state.custom; 12 | const getAdditional = (state: RootState) => state.additional; 13 | const getEvents = (state: RootState) => state.events; 14 | export const getOptions = (state: RootState) => state.options; 15 | 16 | 17 | export const getCurrentTimetable = ( 18 | { timetables, meta }: Pick, 19 | ) => { 20 | const term = getCurrentTerm(meta); 21 | const timetable = timetables[term]; 22 | if (timetable) { 23 | return timetable; 24 | } 25 | return getEmptySessionManagerData(); 26 | }; 27 | 28 | export const getCourseList = createSelector( 29 | [getCourses], 30 | courses => Object.values(courses).sort(courseSort), 31 | ); 32 | 33 | export const getChosenCourses = createSelector( 34 | [getCourses, getChosen], 35 | (courses, chosen) => chosen.map(c => courses[c]).sort(courseSort), 36 | ); 37 | 38 | export const getCustomCourses = createSelector( 39 | [getCourses, getCustom], 40 | (courses, custom) => custom.map(c => courses[c]).sort(customSort), 41 | ); 42 | 43 | export const getAdditionalCourses = createSelector( 44 | [getCourses, getAdditional], 45 | (courses, additional) => additional.map(c => courses[c]).sort(courseSort), 46 | ); 47 | 48 | export function getShowSignupCombiner( 49 | courses: CourseMap, additional: CourseId[], events: AdditionalEvent[], 50 | ) { 51 | const allEvents = getAutoSelectedEvents(courses, additional); 52 | const eventNames = events.map(e => e.id); 53 | return allEvents.some(e => eventNames.includes(e.id)); 54 | } 55 | 56 | export const getShowSignup = createSelector( 57 | [getCourses, getAdditional, getEvents], 58 | getShowSignupCombiner, 59 | ); 60 | -------------------------------------------------------------------------------- /src/submitContact.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import env from './env'; 3 | 4 | export async function submitContact({ name, email, message }: { 5 | name: string, 6 | email: string, 7 | message: string, 8 | }) { 9 | const url = env.contactURI; 10 | 11 | const response = await axios.post(url, { 12 | email, 13 | name, 14 | message, 15 | }).catch(e => console.error(e)); 16 | 17 | return response; 18 | } 19 | 20 | export default submitContact; 21 | -------------------------------------------------------------------------------- /src/test_util.ts: -------------------------------------------------------------------------------- 1 | import { CourseData, linkStream, LinkedStream, LinkedSession, Meta } from './state'; 2 | import SessionPlacement from './components/Timetable/SessionPlacement'; 3 | import SessionManager from './components/Timetable/SessionManager'; 4 | import { Dimensions } from './components/Timetable/timetableTypes'; 5 | 6 | export function getCourse(override?: Partial): CourseData { 7 | return { 8 | code: 'RING9731', 9 | name: 'Introduction to Ring Theory', 10 | streams: [ 11 | { 12 | component: 'LEC', 13 | enrols: [50, 100], 14 | times: [ 15 | { time: 'M9', location: 'Morder', canClash: true }, 16 | { time: 'H12', location: 'Helm\'s Deep', weeks: '1,3,7-9' }, 17 | { time: 'T12', location: 'Tol Brandir', weeks: '1,3,7-9' }, 18 | ], 19 | }, 20 | { 21 | component: 'LEC', 22 | enrols: [10, 50], 23 | times: [ 24 | { time: 'T9', location: 'Morder', canClash: true }, 25 | { time: 'F9', location: 'Helm\'s Deep', weeks: '1,3,7-9' }, 26 | { time: 'W12', location: 'Tol Brandir', weeks: '1,3,7-9' }, 27 | ], 28 | }, 29 | { 30 | component: 'TUT', 31 | enrols: [5, 10], 32 | times: [ 33 | { time: 'H9', location: 'Hobbiton' }, 34 | ], 35 | }, 36 | { 37 | component: 'TUT', 38 | enrols: [9, 9], 39 | times: [ 40 | { time: 'F19', location: 'Fangorn' }, 41 | ], 42 | }, 43 | ], 44 | ...override, 45 | }; 46 | } 47 | 48 | export function getAdditionalCourse(override?: Partial): CourseData { 49 | return { 50 | code: 'CBS', 51 | name: 'Campus Bible Study', 52 | streams: [ 53 | { component: 'The Bible Talks', enrols: [0, 0], times: [] }, 54 | { component: 'The Bible Talks', enrols: [0, 0], times: [] }, 55 | { component: 'Bible Study', enrols: [0, 0], times: [] }, 56 | ], 57 | isAdditional: true, 58 | autoSelect: true, 59 | ...override, 60 | }; 61 | } 62 | 63 | export function getLinkedStream( 64 | streamIndex = 0, 65 | override?: Partial, 66 | ): LinkedStream { 67 | const course = getCourse(); 68 | const stream = linkStream(course, course.streams[streamIndex]); 69 | return { ...stream, ...override }; 70 | } 71 | 72 | export function getLinkedSession( 73 | streamIndex = 0, 74 | sessionIndex = 0, 75 | override?: Partial, 76 | ): LinkedSession { 77 | const stream = getLinkedStream(streamIndex); 78 | const session = stream.sessions[sessionIndex]; 79 | return { ...session, ...override }; 80 | } 81 | 82 | export function getSessionPlacement(streamIndex = 0, sessionIndex = 0): SessionPlacement { 83 | const session = getLinkedSession(streamIndex, sessionIndex); 84 | return new SessionPlacement(session); 85 | } 86 | 87 | export function getSessionManager() { 88 | const manager = new SessionManager(); 89 | const p1 = getSessionPlacement(0); 90 | const p2 = getSessionPlacement(2); 91 | const p3 = getSessionPlacement(3); 92 | manager.set(p1.session.id, p1); 93 | manager.set(p2.session.id, p2); 94 | manager.set(p3.session.id, p3); 95 | return manager; 96 | } 97 | 98 | export function getDimensions() { 99 | const dimensions: Dimensions = { 100 | width: 800, 101 | height: 500, 102 | }; 103 | return dimensions; 104 | } 105 | 106 | export function getMeta(): Meta { 107 | return { 108 | sources: [], 109 | term: 3, 110 | termStart: '', 111 | updateDate: 'today', 112 | updateTime: 'now', 113 | year: 2020, 114 | }; 115 | } 116 | -------------------------------------------------------------------------------- /src/theme.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-lonely-if */ 2 | import { createTheme } from '@material-ui/core/styles'; 3 | import indigo from '@material-ui/core/colors/indigo'; 4 | import lightBlue from '@material-ui/core/colors/blue'; 5 | import { PaletteOptions } from '@material-ui/core/styles/createPalette'; 6 | 7 | export const theme = (dark = false) => { 8 | let palette: PaletteOptions; 9 | if (dark) { 10 | palette = { 11 | // In dark theme, we swap the primary and secondary colours 12 | secondary: { main: indigo[500] }, 13 | primary: { main: lightBlue[400] }, 14 | type: 'dark', 15 | }; 16 | } else { 17 | palette = { 18 | primary: { main: indigo[600] }, 19 | secondary: { main: lightBlue[600] }, 20 | }; 21 | } 22 | return createTheme({ 23 | palette, 24 | overrides: { 25 | MuiSelect: { 26 | select: { 27 | '&:focus': { 28 | backgroundColor: 'none', 29 | }, 30 | }, 31 | }, 32 | MuiIconButton: { 33 | sizeSmall: { 34 | padding: 6, 35 | }, 36 | }, 37 | }, 38 | typography: {}, 39 | }); 40 | }; 41 | 42 | export default theme; 43 | -------------------------------------------------------------------------------- /src/timetable/GeneticSearch.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * A simple genetic search algorithm 3 | * 4 | * To reduce the chance of getting stuck in a local maxima, run the `search` 5 | * method with a lower `maxIterations` and run it multiple times, keeping 6 | * the best. 7 | * 8 | * Please note that `mutate` and `scoreFunction` are hot (performance-critical) 9 | * code 10 | */ 11 | /* eslint-disable no-param-reassign */ 12 | 13 | export interface GeneticSearchOptionalConfig { 14 | timeout?: number, // max execution time in milliseconds 15 | maxIterations?: number, 16 | checkIters?: number, 17 | initialParents?: number, 18 | maxParents?: number, 19 | biasTop?: number, 20 | } 21 | 22 | export interface GeneticSearchRequiredConfig { 23 | scoreFunction: (result: T[], indexes: number[]) => number, 24 | } 25 | 26 | export type GeneticSearchConfig = GeneticSearchOptionalConfig & GeneticSearchRequiredConfig; 27 | 28 | const defaultConfig: Required = { 29 | timeout: 500, 30 | maxIterations: 5000, 31 | checkIters: 10, 32 | initialParents: 100, 33 | maxParents: 20, 34 | biasTop: 5, 35 | }; 36 | 37 | export interface Parent { 38 | indexes: number[], 39 | values: T[], 40 | score: number, 41 | } 42 | 43 | export class GeneticSearch { 44 | config: Required>; 45 | 46 | constructor(config: GeneticSearchConfig) { 47 | this.config = { ...defaultConfig, ...config }; 48 | } 49 | 50 | search(data: T[][]): Parent { 51 | if (data.length === 0) { 52 | return { 53 | indexes: [], 54 | values: [], 55 | score: -Infinity, 56 | }; 57 | } 58 | 59 | const startTime = performance.now(); 60 | 61 | const parents = this.abiogenesis(data); 62 | 63 | const max = this.config.maxIterations; 64 | const checkIters = this.config.checkIters; 65 | for (let i = 0; i < max; ++i) { 66 | parents.push(this.mutate(this.chooseParent(parents), data)); 67 | 68 | if (i % checkIters === 0) { 69 | // Re-sort parents then remove the worst ones 70 | this.cullParents(parents); 71 | 72 | if (performance.now() - startTime >= this.config.timeout) { 73 | console.warn(`search(): max search time exceeded after ${i} iterations`); 74 | break; 75 | } 76 | } 77 | } 78 | 79 | // Give best result 80 | return parents[0]; 81 | } 82 | 83 | abiogenesis(data: T[][]): Parent[] { 84 | const parents: Parent[] = []; 85 | for (let i = 0; i < this.config.initialParents; ++i) { 86 | const indexes = data.map(x => Math.floor(Math.random() * x.length)); 87 | const values = indexes.map((index, j) => data[j][index]); 88 | const score = this.config.scoreFunction(values, indexes); 89 | parents.push({ indexes, values, score }); 90 | } 91 | 92 | return parents.sort(this.parentSort); 93 | } 94 | 95 | evolve(parents: Parent[], data: T[][]): Parent[] { 96 | // Create new parents 97 | const numParents = parents.length; 98 | for (let i = 0; i < numParents; ++i) { 99 | parents.push(this.mutate(parents[i], data)); 100 | } 101 | 102 | // Sort parents by score 103 | parents.sort(this.parentSort); 104 | 105 | // Throw out excess parents 106 | parents.splice(this.config.maxParents); 107 | 108 | return parents; 109 | } 110 | 111 | mutate(parent: Parent, data: T[][]): Parent { 112 | const values = parent.values.slice(); 113 | let indexes = parent.indexes; 114 | 115 | // Mutate child 116 | const i = this.chooseIndexToMutate(data); 117 | const newIndex = this.chooseNewIndexValue(indexes[i], data[i].length); 118 | 119 | values[i] = data[i][newIndex]; 120 | const score = this.config.scoreFunction(values, indexes); 121 | 122 | // Copy and update indexes only if score is passable (i.e. not -Infinity) 123 | if (Number.isFinite(score)) { 124 | indexes = indexes.slice(); 125 | indexes[i] = newIndex; 126 | } 127 | 128 | return { values, indexes, score }; 129 | } 130 | 131 | chooseIndexToMutate(data: T[][], max_attempts = 10): number { 132 | let i = -1; 133 | let attempts = 0; 134 | while (i < 0 || data[i].length <= 1) { 135 | i = this.randInt(data.length); 136 | attempts += 1; 137 | if (attempts > max_attempts) break; 138 | } 139 | return i; 140 | } 141 | 142 | chooseNewIndexValue(previous: number, max: number, max_attempts = 10): number { 143 | let n = previous; 144 | let attempts = 0; 145 | while (n === previous) { 146 | n = this.randInt(max); 147 | attempts += 1; 148 | if (attempts > max_attempts) break; 149 | } 150 | return n; 151 | } 152 | 153 | private randInt(max: number): number { 154 | return Math.floor(Math.random() * max); 155 | } 156 | 157 | private parentSort(a: Parent, b: Parent) { 158 | return b.score - a.score; 159 | } 160 | 161 | private chooseParent(parents: Parent[]): Parent { 162 | const i = Math.floor(Math.random() * parents.length + this.config.biasTop) % parents.length; 163 | return parents[i]; 164 | } 165 | 166 | private cullParents(parents: Parent[]): void { 167 | parents.sort(this.parentSort); 168 | const max = this.config.maxParents; 169 | if (parents.length > max) { 170 | parents.length = max; 171 | } 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/timetable/TimetableScorerCache.test.ts: -------------------------------------------------------------------------------- 1 | import { TimetableScorerCache } from './TimetableScorerCache'; 2 | 3 | describe('coursesToComponents', () => { 4 | it('can store a single value', () => { 5 | const cache = new TimetableScorerCache(); 6 | const key = [0]; 7 | cache.set(key, 0); 8 | expect(cache.has(key)).toBe(true); 9 | }); 10 | 11 | it('can round-trip a single value', () => { 12 | const cache = new TimetableScorerCache(); 13 | const key = [0]; 14 | cache.set(key, 1); 15 | expect(cache.get(key)).toBe(1); 16 | }); 17 | 18 | it('can round-trip multiple values', () => { 19 | const cache = new TimetableScorerCache(); 20 | cache.set([0], 1); 21 | cache.set([1], 2); 22 | expect(cache.get([0])).toBe(1); 23 | expect(cache.get([1])).toBe(2); 24 | }); 25 | 26 | it('can round-trip values on the second level', () => { 27 | const cache = new TimetableScorerCache(); 28 | cache.set([0, 0], 1); 29 | cache.set([0, 1], 2); 30 | expect(cache.get([0, 0])).toBe(1); 31 | expect(cache.get([0, 1])).toBe(2); 32 | }); 33 | 34 | it('can round-trip two levels', () => { 35 | const cache = new TimetableScorerCache(); 36 | cache.set([0, 0], 1); 37 | cache.set([1, 0], 2); 38 | expect(cache.get([0, 0])).toBe(1); 39 | expect(cache.get([1, 0])).toBe(2); 40 | }); 41 | 42 | it('can round-trip many values, many levels', () => { 43 | const cache = new TimetableScorerCache(); 44 | const key1 = [4, 1, 0, 3]; 45 | const key2 = [0, 2, 1, 0]; 46 | cache.set(key1, 4); 47 | cache.set(key2, 10); 48 | expect(cache.get(key1)).toBe(4); 49 | expect(cache.get(key2)).toBe(10); 50 | }); 51 | 52 | it('can overwrite an existing values', () => { 53 | const cache = new TimetableScorerCache(); 54 | const key = [0, 3]; 55 | cache.set(key, 3); 56 | cache.set(key, 5); 57 | expect(cache.get(key)).toBe(5); 58 | }); 59 | 60 | it('can write at multiple different levels', () => { 61 | const cache = new TimetableScorerCache(); 62 | const key1 = [0, 3]; 63 | const key2 = [0, 2, 2]; 64 | cache.set(key1, 5); 65 | cache.set(key2, 10); 66 | expect(cache.get(key1)).toBe(5); 67 | expect(cache.get(key2)).toBe(10); 68 | }); 69 | 70 | it('can overwrite at a different level', () => { 71 | const cache = new TimetableScorerCache(); 72 | const key1 = [0, 0]; 73 | const key2 = [0, 0, 0]; 74 | cache.set(key1, 5); 75 | cache.set(key2, 10); 76 | expect(cache.get(key1)).toBe(undefined); 77 | expect(cache.get(key2)).toBe(10); 78 | }); 79 | 80 | it('can delete values', () => { 81 | const cache = new TimetableScorerCache(); 82 | const key = [0, 1, 2, 3, 4, 5]; 83 | cache.set(key, 5); 84 | cache.delete(key); 85 | expect(cache.has(key)).toBe(false); 86 | expect(cache.get(key)).toBe(undefined); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /src/timetable/TimetableScorerCache.ts: -------------------------------------------------------------------------------- 1 | export interface ICache extends Array> {} 2 | 3 | export class TimetableScorerCache { 4 | private cache: ICache; 5 | 6 | constructor() { 7 | this.cache = []; 8 | } 9 | 10 | get(key: number[]): T | undefined { 11 | let current = this.cache; 12 | for (let i = 0; i < key.length; ++i) { 13 | const next = current[key[i]]; 14 | if (!Array.isArray(next)) { 15 | return next; 16 | } 17 | current = next; 18 | } 19 | 20 | return undefined; 21 | } 22 | 23 | set(key: number[], value: T): void { 24 | this.setInternal(key, value); 25 | } 26 | 27 | has(key: number[]) { 28 | return this.get(key) !== undefined; 29 | } 30 | 31 | delete(key: number[]): void { 32 | this.setInternal(key, undefined); 33 | } 34 | 35 | clear(): void { 36 | this.cache = []; 37 | } 38 | 39 | private setInternal(key: number[], value: T | undefined): void { 40 | let current = this.cache; 41 | for (let i = 0; i < key.length - 1; ++i) { 42 | const next = current[key[i]]; 43 | if (!Array.isArray(next)) { 44 | const newItem: ICache = []; 45 | current[key[i]] = newItem; 46 | current = newItem; 47 | } else { 48 | current = next; 49 | } 50 | } 51 | 52 | current[key[key.length - 1]] = value; 53 | } 54 | } 55 | 56 | export default TimetableScorerCache; 57 | -------------------------------------------------------------------------------- /src/timetable/coursesToComponents.ts: -------------------------------------------------------------------------------- 1 | import { notUndefined } from '../typeHelpers'; 2 | import { 3 | AdditionalEvent, 4 | CourseData, 5 | CourseId, 6 | getCourseId, 7 | getEventId, 8 | getSessions, 9 | getStreamId, 10 | LinkedSession, 11 | LinkedStream, 12 | linkStream, 13 | SessionData, 14 | } from '../state'; 15 | 16 | export interface Component { 17 | course: CourseData, 18 | name: string, 19 | streams: LinkedStream[], 20 | streamSessions: SessionData[], 21 | id: string, 22 | } 23 | 24 | export function coursesToComponents( 25 | courseList: CourseData[], 26 | events: AdditionalEvent[], 27 | webStreams: CourseId[], 28 | fixedSessions: LinkedSession[], 29 | allowFull: boolean, 30 | ): Component[] { 31 | // Group streams by component for each course 32 | // NB: remove components for which the web stream is chosen 33 | const components: Component[] = []; 34 | for (const course of courseList) { 35 | const streamGroups = groupStreamsByComponent(course, events, webStreams, allowFull); 36 | filterOutWebStreams(course, streamGroups, webStreams); 37 | isolateFixedStreams(streamGroups, fixedSessions); 38 | addComponentsTo(components, course, streamGroups, webStreams); 39 | } 40 | 41 | return components; 42 | } 43 | 44 | function groupStreamsByComponent( 45 | course: CourseData, 46 | events: AdditionalEvent[], 47 | webStreams: CourseId[], 48 | allowFull: boolean, 49 | ) { 50 | const streamGroups = new Map(); 51 | const eventIds = events.map(e => e.id); 52 | 53 | for (const stream of course.streams) { 54 | // Skip any additional events which have been deselected 55 | const eventId = getEventId(course, stream.component); 56 | if (course.isAdditional && !eventIds.includes(eventId)) { 57 | continue; 58 | } 59 | 60 | // Skip any web streams when not enabled for this course 61 | if (stream.web && !webStreams.includes(getCourseId(course))) { 62 | continue; 63 | } 64 | 65 | // Record this component (or retrieve previous record if it exists) 66 | if (!streamGroups.has(stream.component)) { 67 | streamGroups.set(stream.component, []); 68 | } 69 | const component = notUndefined(streamGroups.get(stream.component)); 70 | 71 | // Skip any full streams if full streams aren't allowed 72 | if (stream.full && !allowFull) { 73 | continue; 74 | } 75 | 76 | // Group streams by their component 77 | component.push(linkStream(course, stream)); 78 | } 79 | 80 | return streamGroups; 81 | } 82 | 83 | function filterOutWebStreams( 84 | course: CourseData, 85 | streamGroups: Map, 86 | webStreams: CourseId[], 87 | ) { 88 | // Remove all components which have a web stream option if this course has web streams enabled 89 | 90 | if (webStreams.includes(getCourseId(course))) { 91 | const streamGroupsEntries = Array.from(streamGroups.entries()); 92 | 93 | for (const [component, streams] of streamGroupsEntries) { 94 | // Remove component if web stream has been requested AND this component has a web stream 95 | if (streams.some(s => s.web)) { 96 | streamGroups.delete(component); 97 | } 98 | } 99 | } 100 | } 101 | 102 | function isolateFixedStreams( 103 | streamGroups: Map, 104 | fixedSessions: LinkedSession[], 105 | ) { 106 | const streamGroupsEntries = Array.from(streamGroups.entries()); 107 | const fixedStreamIds = new Set(fixedSessions.map(s => s.stream.id)); 108 | 109 | for (const [component, streams] of streamGroupsEntries) { 110 | for (const stream of streams) { 111 | if (fixedStreamIds.has(stream.id)) { 112 | streamGroups.set(component, [stream]); 113 | break; 114 | } 115 | } 116 | } 117 | } 118 | 119 | function addComponentsTo( 120 | components: Component[], 121 | course: CourseData, 122 | streamGroups: Map, 123 | webStreams: CourseId[], 124 | ) { 125 | const streamGroupsEntries = Array.from(streamGroups.entries()); 126 | 127 | for (const [component, streams] of streamGroupsEntries) { 128 | const courseId = getCourseId(course); 129 | if (!webStreams.includes(courseId) || streams.every(s => !s.web)) { 130 | const idParts: string[] = [courseId, component]; 131 | const streamSessions: SessionData[] = []; 132 | for (const stream of streams) { 133 | const sessions = getSessions(course, stream); 134 | streamSessions.push(...sessions); 135 | idParts.push(getStreamId(course, stream)); 136 | } 137 | const id = idParts.join('~'); 138 | 139 | // Add this component 140 | components.push({ name: component, streams, course, streamSessions, id }); 141 | } 142 | } 143 | } 144 | 145 | export default coursesToComponents; 146 | -------------------------------------------------------------------------------- /src/timetable/getClashInfo.spec.ts: -------------------------------------------------------------------------------- 1 | import { sessionClashLength } from './getClashInfo'; 2 | import { SessionData } from '../state'; 3 | 4 | const baseSession: SessionData = { 5 | course: 'RING1379', 6 | stream: '', 7 | index: 0, 8 | day: 'M', 9 | start: 10, 10 | end: 11, 11 | }; 12 | 13 | it.each` 14 | sessionA | sessionB | expected 15 | ${{ start: 9, end: 10 }} | ${{ start: 10, end: 11 }} | ${0} 16 | ${{ start: 10, end: 11 }} | ${{ start: 10, end: 11 }} | ${1} 17 | ${{ start: 10, end: 11 }} | ${{ start: 11, end: 12 }} | ${0} 18 | ${{ start: 11, end: 13 }} | ${{ start: 10, end: 14 }} | ${2} 19 | ${{ day: 'T', start: 10, end: 11 }} | ${{ start: 10, end: 11 }} | ${0} 20 | ${{ day: 'T', start: 10, end: 11 }} | ${{ day: 'T', start: 10, end: 11 }} | ${1} 21 | ${{ day: 'T', start: 10, end: 11 }} | ${{ day: 'W', start: 10, end: 11 }} | ${0} 22 | ${{ start: 11, end: 13, canClash: true }} | ${{ start: 10, end: 14 }} | ${1} 23 | ${{ start: 11, end: 13 }} | ${{ start: 10, end: 14, canClash: true }} | ${1} 24 | ${{ start: 11, end: 13, canClash: true }} | ${{ start: 10, end: 14, canClash: true }} | ${1} 25 | ${{ start: 11, end: 12, canClash: true }} | ${{ start: 11, end: 12 }} | ${0.5} 26 | `('sessionClashLength($day$start-$end) = $expected', ({ sessionA, sessionB, expected }) => { 27 | const a = { ...baseSession, ...sessionA } as SessionData; 28 | const b = { ...baseSession, ...sessionB } as SessionData; 29 | const result = sessionClashLength(a, b); 30 | expect(result).toBe(expected); 31 | }); 32 | -------------------------------------------------------------------------------- /src/timetable/getClashInfo.ts: -------------------------------------------------------------------------------- 1 | import { LinkedSession, LinkedStream, SessionData } from '../state'; 2 | 3 | export type ClashInfo = Map>; 4 | 5 | const ALLOWED_CLASH_MULTIPLIER = 0.5; 6 | 7 | export function getClashInfo(streams: LinkedStream[]): ClashInfo { 8 | const clashes = new Map>(); 9 | for (let i = 0; i < streams.length; ++i) { 10 | const s1 = streams[i]; 11 | const childMap = new Map(); 12 | for (let j = 0; j < streams.length; ++j) { 13 | if (i === j) continue; 14 | 15 | const s2 = streams[j]; 16 | const clashHours = streamClashLength(s1, s2); 17 | childMap.set(s2, clashHours); 18 | } 19 | clashes.set(s1, childMap); 20 | } 21 | 22 | return clashes; 23 | } 24 | 25 | export function streamClashLength(a: LinkedStream, b: LinkedStream) { 26 | let total = 0; 27 | 28 | for (const s1 of a.sessions) { 29 | for (const s2 of b.sessions) { 30 | total += sessionClashLength(s1, s2); 31 | } 32 | } 33 | 34 | return total; 35 | } 36 | 37 | export function sessionClashLength( 38 | a: LinkedSession | SessionData, 39 | b: LinkedSession | SessionData, 40 | ): number { 41 | if (a.day !== b.day) return 0; 42 | 43 | const length = Math.max(Math.min(a.end, b.end) - Math.max(a.start, b.start), 0); 44 | return length * (a.canClash || b.canClash ? ALLOWED_CLASH_MULTIPLIER : 1); 45 | } 46 | -------------------------------------------------------------------------------- /src/timetable/search.worker.ts: -------------------------------------------------------------------------------- 1 | import type { LinkedSession, LinkedStream } from '../state'; 2 | import { TimetableScorer, TimetableScoreConfig } from './scoreTimetable'; 3 | import { GeneticSearch, GeneticSearchOptionalConfig } from './GeneticSearch'; 4 | import { ClashInfo } from './getClashInfo'; 5 | 6 | export interface RunSearchOptions { 7 | clashInfo: ClashInfo, 8 | fixedSessions: LinkedSession[], 9 | streams: LinkedStream[][], 10 | config: GeneticSearchOptionalConfig, 11 | scoreConfig?: TimetableScoreConfig, 12 | } 13 | 14 | 15 | export function runSearch({ 16 | clashInfo, 17 | fixedSessions, 18 | streams, 19 | config, 20 | scoreConfig, 21 | }: RunSearchOptions) { 22 | const scorer = new TimetableScorer(clashInfo, fixedSessions); 23 | if (scoreConfig) scorer.updateConfig(scoreConfig); 24 | 25 | const searcher = new GeneticSearch({ 26 | ...config, 27 | scoreFunction: scorer.score.bind(scorer), 28 | }); 29 | const result = searcher.search(streams); 30 | return result; 31 | } 32 | 33 | self.onmessage = ( 34 | event: MessageEvent, 35 | ) => { 36 | const results = runSearch(event.data); 37 | self.postMessage(results); 38 | }; 39 | -------------------------------------------------------------------------------- /src/timetable/timetableSearch.ts: -------------------------------------------------------------------------------- 1 | import { GeneticSearchOptionalConfig, Parent } from './GeneticSearch'; 2 | import { Component } from './coursesToComponents'; 3 | import { LinkedSession, LinkedStream } from '../state'; 4 | import { getClashInfo, ClashInfo } from './getClashInfo'; 5 | import { TimetableScoreConfig } from './scoreTimetable'; 6 | // eslint-disable-next-line import/no-unresolved 7 | import SearchWorker from './search.worker?worker'; 8 | import type { RunSearchOptions } from './search.worker'; 9 | 10 | export interface TimetableSearchResult { 11 | timetable: LinkedSession[], 12 | score: number, 13 | unplaced?: LinkedSession[], 14 | } 15 | 16 | 17 | class TimetableSearch { 18 | private cache = new Map(); 19 | private workers: Worker[] = []; 20 | 21 | constructor(workerCount = 5) { 22 | this.spawnWorkers(workerCount); 23 | } 24 | 25 | async search( 26 | components: Component[], 27 | fixedSessions: LinkedSession[], 28 | ignoreCache = true, 29 | config: GeneticSearchOptionalConfig = {}, 30 | scoreConfig?: TimetableScoreConfig, 31 | maxSpawn?: number, 32 | ): Promise { 33 | const cacheKey = this.getCacheKey(components, fixedSessions); 34 | if (!ignoreCache) { 35 | const cachedTimetable = this.cache.get(cacheKey); 36 | if (cachedTimetable !== undefined) { 37 | return cachedTimetable; 38 | } 39 | } 40 | 41 | const clashInfo = this.clashInfoFromComponents(components); 42 | const streams = components.map(c => c.streams); 43 | const results = await this.runSearchInWorkers({ 44 | clashInfo, 45 | config, 46 | fixedSessions, 47 | maxSpawn, 48 | streams, 49 | scoreConfig, 50 | }); 51 | 52 | // Find best result 53 | const best = results.sort((a, b) => b.score - a.score)[0]; 54 | 55 | // Return best result as list of sessions 56 | const timetable = ([] as LinkedSession[]).concat(...best.values.map(s => s.sessions)); 57 | const score = best.score; 58 | const result = { timetable, score }; 59 | this.cache.set(cacheKey, result); 60 | return result; 61 | } 62 | 63 | private getCacheKey(components: Component[], fixedSessions: LinkedSession[]) { 64 | const componentIds = components.map(c => c.id).join('~~~'); 65 | const fixedSessionsIds = fixedSessions.map(s => s.id).join('~~~'); 66 | const cacheKey = `${componentIds}//${fixedSessionsIds}`; 67 | return cacheKey; 68 | } 69 | 70 | private async spawnWorkers(count: number) { 71 | for (let i = 0; i < count; ++i) { 72 | const worker = new SearchWorker(); 73 | this.workers.push(worker); 74 | } 75 | } 76 | 77 | private runSearchInWorkers({ 78 | fixedSessions, 79 | clashInfo, 80 | streams, 81 | config, 82 | scoreConfig, 83 | maxSpawn, 84 | }: { 85 | fixedSessions: LinkedSession[], 86 | clashInfo: ClashInfo, 87 | streams: LinkedStream[][], 88 | config: GeneticSearchOptionalConfig, 89 | scoreConfig?: TimetableScoreConfig, 90 | maxSpawn?: number, 91 | }): Promise[]> { 92 | const promises: Promise>[] = []; 93 | const workers = maxSpawn ? this.workers.slice(0, maxSpawn) : this.workers; 94 | for (const worker of workers) { 95 | promises.push(new Promise>(resolve => { 96 | const options: RunSearchOptions = { 97 | fixedSessions, 98 | clashInfo, 99 | streams, 100 | config, 101 | scoreConfig, 102 | }; 103 | worker.postMessage(options); 104 | worker.onmessage = event => resolve(event.data); 105 | })); 106 | } 107 | return Promise.all(promises); 108 | } 109 | 110 | private clashInfoFromComponents(components: Component[]) { 111 | const allStreams = components.reduce((all, c) => all.concat(c.streams), [] as LinkedStream[]); 112 | return getClashInfo(allStreams); 113 | } 114 | 115 | private async terminateWorkers() { 116 | for (const worker of this.workers) { 117 | worker.terminate(); 118 | } 119 | this.workers = []; 120 | } 121 | } 122 | 123 | const searcher = new TimetableSearch(); 124 | export const search = searcher.search.bind(searcher); 125 | -------------------------------------------------------------------------------- /src/transforms.ts: -------------------------------------------------------------------------------- 1 | import { createTransform } from 'redux-persist'; 2 | import { HistoryData } from './state'; 3 | 4 | export const historyTransform = createTransform( 5 | (inboundState: HistoryData) => ({ 6 | past: [], 7 | present: inboundState.present, 8 | future: [], 9 | }), 10 | (outboundState: HistoryData) => outboundState, 11 | { whitelist: ['history'] }, 12 | ); 13 | 14 | export const noticeTransform = createTransform( 15 | () => null, 16 | () => null, 17 | { whitelist: ['notice'] }, 18 | ); 19 | 20 | export const changelogViewTransform = createTransform( 21 | (inboundState: Date) => inboundState.toString(), 22 | (outboundState: string) => new Date(outboundState), 23 | { whitelist: ['changelogView'] }, 24 | ); 25 | 26 | export default [historyTransform, noticeTransform, changelogViewTransform]; 27 | -------------------------------------------------------------------------------- /src/typeHelpers.ts: -------------------------------------------------------------------------------- 1 | import { ThunkDispatch } from 'redux-thunk'; 2 | 3 | export function notNull(val: T | null): T { 4 | if (val === null) { 5 | throw new Error('notNull given a null value'); 6 | } 7 | return val; 8 | } 9 | 10 | export function notUndefined(val: T | undefined): T { 11 | if (val === undefined) { 12 | throw new TypeError('notUndefined given an undefined value'); 13 | } 14 | return val; 15 | } 16 | 17 | export function isSet(val: T | null | undefined) { 18 | return notNull(notUndefined(val)); 19 | } 20 | 21 | export type WithDispatch = T & { dispatch: ThunkDispatch<{}, {}, any> }; 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "downlevelIteration": true, 13 | "allowSyntheticDefaultImports": true, 14 | "strict": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx", 22 | "types": ["vite/client", "jest"], 23 | "noFallthroughCasesInSwitch": true 24 | }, 25 | "include": [ 26 | "src" 27 | ], 28 | "exclude": [ 29 | "node_modules", 30 | "**/*.js" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | import viteTsconfigPaths from 'vite-tsconfig-paths'; 4 | 5 | export default defineConfig({ 6 | base: '', 7 | plugins: [react(), viteTsconfigPaths()], 8 | server: { 9 | open: true, 10 | port: 3000, 11 | }, 12 | build: { 13 | outDir: './build', 14 | }, 15 | }); 16 | --------------------------------------------------------------------------------