├── .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 |
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 | }
103 | href={ministryMeta.signupURL}
104 | disabled={disabled}
105 | target="_blank"
106 | rel="noopener noreferrer"
107 | onClick={() => handleLinkClick(ministryMeta.signupURL)}
108 | >
109 |
110 |
111 | Sign Up for Term
112 | {' '}
113 | {meta.term}
114 |
115 |
116 | {ministry.name}
117 |
118 |
119 |
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 | }
137 | onClick={onSaveAsICS}
138 | disabled={disabled || isSavingICS}
139 | >
140 | Add to Calendar
141 |
142 | {isSavingICS && (
143 |
147 | )}
148 |
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 | }
40 | onClick={onAddPersonalEvent}
41 | // Prevent default to stop blur on autocomplete input from triggering too early
42 | onMouseDown={e => e.preventDefault()}
43 | >
44 | Add Personal Event
45 |
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 |
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 |
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 |
--------------------------------------------------------------------------------