├── public ├── _redirects ├── robots.txt ├── favicon.ico ├── logo192.png ├── logo512.png ├── manifest.json └── index.html ├── .env ├── src ├── react-app-env.d.ts ├── isDebug.ts ├── setupTests.ts ├── App.test.tsx ├── index.css ├── App.scss ├── components │ ├── TimeColumn.tsx │ ├── MyTimetableHelp.tsx │ ├── DayColumn.tsx │ ├── SessionSelectors.scss │ ├── StateErrorBoundary.tsx │ ├── FileInput.tsx │ ├── ModeSelector.tsx │ ├── Modal.tsx │ ├── ColourPickerButton.tsx │ ├── WeekSelector.tsx │ ├── UserInfoView.tsx │ ├── styles │ │ └── CourseColours.tsx │ ├── FirebaseSignIn.tsx │ ├── Timetable.scss │ ├── CourseSearcher.tsx │ ├── TimetableSelector.tsx │ ├── SessionSelectors.tsx │ └── Timetable.tsx ├── hooks │ └── useCourseColours.tsx ├── index.tsx ├── state │ ├── types.ts │ ├── firebase.ts │ ├── uiState.ts │ ├── firebaseEnhancer.ts │ ├── migrations.ts │ ├── schema.ts │ └── persistState.ts ├── logo.svg ├── logic │ ├── importer.ts │ ├── api.ts │ └── functions.ts ├── Main.tsx ├── serviceWorker.ts └── App.tsx ├── .vscode └── settings.json ├── netlify.toml ├── .env.development ├── variables.sh ├── .gitignore ├── tsconfig.json ├── migrate.ts ├── README.md ├── package.json └── LICENSE.txt /public/_redirects: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | REACT_APP_VERSION=$npm_package_version 2 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/isDebug.ts: -------------------------------------------------------------------------------- 1 | export const IS_DEBUG = process.env.NODE_ENV !== 'production'; -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/katrinafyi/uqtp/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/katrinafyi/uqtp/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/katrinafyi/uqtp/HEAD/public/logo512.png -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules\\typescript\\lib", 3 | "editor.tabSize": 2 4 | } -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "source variables.sh && npm run build" 3 | 4 | [context.branch-deploy] 5 | environment = { CI = "" } -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | REACT_APP_API_KEY= 2 | REACT_APP_AUTH_DOMAIN= 3 | REACT_APP_DATABASE_URL= 4 | REACT_APP_PROJECT_ID= 5 | REACT_APP_STORAGE_BUCKET= 6 | REACT_APP_MESSAGING_SENDER_ID= 7 | REACT_APP_APP_ID= 8 | REACT_APP_MEASUREMENT_ID= -------------------------------------------------------------------------------- /variables.sh: -------------------------------------------------------------------------------- 1 | export REACT_APP_CONTEXT=$CONTEXT 2 | export REACT_APP_BRANCH=$BRANCH 3 | export REACT_APP_COMMIT=$COMMIT_REF 4 | export REACT_APP_BUILD_TIME="$(TZ=Australia/Brisbane date --iso-8601=seconds)" 5 | export REACT_APP_BUILD_ID=$BUILD_ID 6 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | const { getByText } = render(); 7 | const linkElement = getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /.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 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /src/App.scss: -------------------------------------------------------------------------------- 1 | @import 'bulma/bulma'; 2 | 3 | // $border-light: #ededed; 4 | 5 | .mdl-button.firebaseui-idp-anonymous { 6 | margin-bottom: 0.75rem; 7 | } 8 | 9 | .bordered { 10 | box-shadow: none; 11 | border: 1px solid $border; 12 | border-radius: 4px; 13 | } 14 | 15 | .button-card { 16 | height: unset; 17 | text-align: left; 18 | line-height: 1.2; 19 | 20 | max-width: 100%; 21 | 22 | white-space: pre-wrap; 23 | justify-content: left; 24 | } 25 | -------------------------------------------------------------------------------- /src/components/TimeColumn.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | interface Props { 4 | } 5 | 6 | const TimeColumn: React.FC = () => { 7 | return 8 | 9 | 10 | 11 | 12 | {Array.from(Array(15).keys()) 13 | .map((i) => )} 14 | 15 |
🕒
{i+8}
; 16 | } 17 | export default TimeColumn; 18 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "UQTP", 3 | "name": "UQ Timetable Planner", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /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 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react" 21 | }, 22 | "include": [ 23 | "src" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /src/components/MyTimetableHelp.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | export const MyTimetableHelp = () => { 4 | return <> 5 |
    6 |
  1. Go to the 7 | UQ Public Timetable. Make sure the <>year and semester are correct.
  2. 8 |
  3. Search for courses you're interested in and select them in the left list.
  4. 9 |
  5. Click the Show Timetable button.
  6. 10 |
  7. Click the export button (next to print) and choose Excel.
  8. 11 |
  9. Import the downloaded Excel .xls file into UQTP using the button above.
  10. 12 |
13 | 14 | } -------------------------------------------------------------------------------- /src/components/DayColumn.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import TimeColumn from "./TimeColumn" 3 | 4 | interface Props { 5 | } 6 | 7 | const DayColumn: React.FC = () => { 8 | return
9 |
10 | 11 |
12 |
13 | 14 | 15 | 16 | 17 | 18 | {Array.from(Array(15).keys()).map((i) => )} 19 | 20 |
Monday
{"Course " + i}
21 |
22 |
23 | } 24 | export default DayColumn; 25 | -------------------------------------------------------------------------------- /src/components/SessionSelectors.scss: -------------------------------------------------------------------------------- 1 | @import "bulma/sass/utilities/initial-variables"; 2 | @import "bulma/sass/utilities/derived-variables"; 3 | 4 | .session-selector { 5 | 6 | // background-color: transparent; 7 | 8 | & .message-header { 9 | // background-color: $white-ter; 10 | // color: $black; 11 | // border-bottom: 1px solid lightgrey; 12 | // flex-wrap: wrap; 13 | 14 | & label { 15 | overflow: hidden; 16 | text-overflow: ellipsis; 17 | white-space: nowrap; 18 | } 19 | 20 | & .buttons { 21 | flex-wrap: nowrap; 22 | } 23 | } 24 | 25 | & .message-body { 26 | // border-top-width: 1px; 27 | & .column { 28 | max-width: 10rem; 29 | } 30 | } 31 | 32 | border: 1px solid lightgrey; 33 | 34 | // background-color: transparent; 35 | } 36 | -------------------------------------------------------------------------------- /migrate.ts: -------------------------------------------------------------------------------- 1 | import 'firebase'; 2 | 3 | // one-off script to migrate data from firestore to realtime database. 4 | 5 | const FIREBASE: firebase.app.App = null; 6 | let oldData: { [s: string]: unknown; } | ArrayLike; 7 | 8 | function sleep(ms) { 9 | return new Promise(resolve => setTimeout(resolve, ms)); 10 | } 11 | 12 | oldData = {}; 13 | async function a() { 14 | const list = await FIREBASE.firestore().collection('users').get(); 15 | list.forEach((doc) => { 16 | oldData[doc.id] = doc.data(); 17 | }); 18 | 19 | 20 | const exists = new Set(); 21 | const newDoc = await FIREBASE.database().ref('data').once('value'); 22 | newDoc.forEach(x => { 23 | exists.add(x.key); 24 | }); 25 | 26 | for (const [id, data] of Object.entries(oldData)) { 27 | if (exists.has(id)) { 28 | console.log('skip ' + id); 29 | continue; 30 | } 31 | console.log(id, data); 32 | await FIREBASE.database().ref(`data/${id}`).set(data); 33 | await sleep(100); 34 | } 35 | 36 | } 37 | a(); 38 | -------------------------------------------------------------------------------- /src/components/StateErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export interface Props { 4 | 5 | } 6 | 7 | type State = { 8 | error: Error | null, 9 | } 10 | 11 | export default class StateErrorBoundary extends React.Component { 12 | constructor(props: Props) { 13 | super(props); 14 | this.state = { error: null, } 15 | } 16 | 17 | static getDerivedStateFromError(error: Error) { 18 | // Update state so the next render will show the fallback UI. 19 | return { error: error }; 20 | } 21 | 22 | render() { 23 | if (this.state.error) { 24 | return
25 |

Error

26 |
27 |

An error occured while rendering the view ({this.state.error.toString()}).

28 |

Check the browser console for more details. Report bugs to k@rina.fyi.

29 |
30 |
; 31 | } else { 32 | return this.props.children; 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/components/FileInput.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react'; 2 | import { FaFileUpload } from 'react-icons/fa'; 3 | 4 | export interface Props extends React.HTMLAttributes{ 5 | fileName?: string, 6 | setFile: (f: File) => any, 7 | } 8 | 9 | export const FileInput = ({setFile, fileName, ...other}: Props) => { 10 | 11 | const onChange = (ev: React.ChangeEvent) => { 12 | setFile(ev.target.files![0]); 13 | }; 14 | 15 | other.className = (other.className || '') + " file"; 16 | 17 | return
18 | 33 |
; 34 | }; 35 | 36 | export default memo(FileInput); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # UQ Toilet Paper 🧻 (a timetable planner) 2 | 3 | [![Netlify Status](https://api.netlify.com/api/v1/badges/6eeee884-eaa1-4ad7-b194-8f6d53342471/deploy-status)](https://app.netlify.com/sites/uqtp/deploys) 4 | 5 | Timetable planner for UQ classes, made in Typescript and React. Uses React Redux and Firebase. 6 | 7 | ### Features 8 | - Sign-in using Google Firebase, saving to an online database. 9 | - Easily search for courses from UQ's public timetable. 10 | - Manage multiple timetable profiles. 11 | - Display and select from all options for a class. 12 | - Select any number of options for a particular course activity. 13 | - Colours labels depending on class type. 14 | - Manually import classes from exported Excel file. 15 | 16 | ## Usage 17 | The app can be built using a simple command: 18 | ``` 19 | npm run build 20 | ``` 21 | The built site is in build/ and can be hosted on a static web server of your choice. 22 | 23 | ## Screenshots 24 | 25 | ### Main view 26 | ![image](https://user-images.githubusercontent.com/39479354/103518130-ebb20f80-4ebe-11eb-97e7-1e36be68d283.png) 27 | ![image](https://user-images.githubusercontent.com/39479354/103518387-68dd8480-4ebf-11eb-93c0-bd06b46ecbf4.png) 28 | 29 | ### Timetable 30 | ![image](https://user-images.githubusercontent.com/39479354/103518352-56634b00-4ebf-11eb-9799-be57c9e1c865.png) 31 | -------------------------------------------------------------------------------- /src/components/ModeSelector.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FaRegEdit, FaRegEye, FaRegCalendarAlt } from 'react-icons/fa'; 3 | import { UIStore, TimetableMode } from '../state/uiState'; 4 | 5 | export const ModeSelector = () => { 6 | const mode = UIStore.useStoreState(s => s.timetableMode); 7 | const setMode = UIStore.useStoreActions(s => s.setTimetableMode); 8 | 9 | const makeSetMode = (m: TimetableMode) => () => setMode(m); 10 | const makeClass = (m: TimetableMode) => 'button' + (mode === m ? ' is-active' : ''); 11 | 12 | return
13 | 20 | 27 | 34 |
; 35 | } -------------------------------------------------------------------------------- /src/components/Modal.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from "react" 2 | 3 | type Props = { 4 | visible: boolean, 5 | setVisible: (visible: boolean) => any, 6 | narrow?: boolean, 7 | 8 | title?: ReactNode, 9 | children?: ReactNode, 10 | footer?: ReactNode, 11 | } 12 | 13 | export const ModalCard = ({ visible, setVisible, title, children, footer }: Props) => { 14 | const hideModal = () => setVisible(false); 15 | 16 | return
17 |
18 |
19 |
20 |

{title}

21 | 22 |
23 |
24 | {children} 25 |
26 |
27 | {footer} 28 |
29 |
30 |
; 31 | } 32 | 33 | export const Modal = ({ visible, setVisible, narrow, children }: Props) => { 34 | const hideModal = () => setVisible(false); 35 | 36 | const style = (narrow ?? false) ? { width: 'fit-content' } : undefined; 37 | 38 | return
39 |
40 |
41 | {children} 42 |
43 | 44 |
; 45 | } -------------------------------------------------------------------------------- /src/hooks/useCourseColours.tsx: -------------------------------------------------------------------------------- 1 | import { useStoreState } from "../state/persistState" 2 | import { useEffect } from "react"; 3 | 4 | import jss, { StyleSheet } from 'jss'; 5 | import preset from 'jss-preset-default'; 6 | import tinycolor from "tinycolor2"; 7 | 8 | jss.setup(preset()); 9 | 10 | const hoverAmount = 4; 11 | const borderAmount = 6; 12 | 13 | const makeStylesForColour = (c: string) => { 14 | 15 | const colour = tinycolor(c); 16 | const text = tinycolor.mostReadable(colour, ['#fff', '#363636']).toRgbString(); 17 | 18 | if (colour.isDark()) 19 | colour.brighten(hoverAmount); 20 | else 21 | colour.darken(hoverAmount); 22 | const hover = colour.toRgbString(); 23 | 24 | if (colour.isDark()) 25 | colour.brighten(borderAmount); 26 | else 27 | colour.darken(borderAmount); 28 | const border = colour.toRgbString(); 29 | 30 | return { 31 | '& .text, &.text': { 32 | color: text, 33 | }, 34 | '& .border:hover, &.border:hover': { 35 | borderColor: border, 36 | }, 37 | '& .background, &.background': { 38 | background: c, 39 | }, 40 | '& .hover:hover, &.hover:hover': { 41 | background: hover, 42 | } 43 | }; 44 | }; 45 | 46 | let sheet: StyleSheet | null = null; 47 | 48 | export const useCourseColours = () => { 49 | const colours = useStoreState(s => s.currentTimetable.courseColours) ?? {}; 50 | 51 | useEffect(() => { 52 | if (sheet) return; 53 | 54 | const styles: any = {}; 55 | for (const course of Object.keys(colours)) { 56 | styles[course] = makeStylesForColour(colours[course]); 57 | } 58 | 59 | sheet = jss.createStyleSheet(styles).attach(); 60 | 61 | return () => { 62 | sheet!.detach(); 63 | sheet = null; 64 | } 65 | }, [colours]); 66 | 67 | return sheet?.classes ?? {}; 68 | } -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | UQTP 📆✨ 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /src/components/ColourPickerButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect } from 'react'; 2 | import { RGBAColour, DEFAULT_COURSE_COLOUR } from '../state/types'; 3 | 4 | const inputStyle = { 5 | width: '30px', 6 | padding: '1px', 7 | }; 8 | 9 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 10 | 11 | export type ColourPickerProps = { 12 | colour: RGBAColour, 13 | setColour: (c: RGBAColour) => any 14 | }; 15 | 16 | export const ColourPickerButton = ({ colour, setColour, ...rest }: ColourPickerProps) => { 17 | 18 | const value = typeof colour == 'string' ? colour : DEFAULT_COURSE_COLOUR; 19 | 20 | const ref = useRef(null); 21 | 22 | useEffect(() => { 23 | const onChange = (e: any) => setColour(e.target.value); 24 | 25 | const el = ref.current!; 26 | el.addEventListener('change', onChange); 27 | return () => el.removeEventListener('change', onChange); 28 | }, [setColour]); 29 | 30 | useEffect(() => { 31 | ref.current!.value = value; 32 | }, [value]); 33 | 34 | const onClick = (ev: React.MouseEvent) => { 35 | if (ev.ctrlKey) { 36 | ev.stopPropagation(); 37 | ev.preventDefault(); 38 | setColour(DEFAULT_COURSE_COLOUR); 39 | } 40 | } 41 | 42 | return <> 43 | 47 | {/* 36 | 37 |
38 |
39 | 40 | 43 | 44 |
45 |
46 | ; 47 | }); -------------------------------------------------------------------------------- /src/components/UserInfoView.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from "react" 2 | import { useAuthState } from "react-firebase-hooks/auth"; 3 | import { auth } from "../state/firebase"; 4 | 5 | const UserInfoView = () => { 6 | const [authUser] = useAuthState(auth); 7 | 8 | const user = useMemo(() => { 9 | if (!authUser) return; 10 | return { 11 | uid: authUser.uid, 12 | name: authUser.displayName, 13 | email: authUser.email, 14 | photo: authUser.photoURL, 15 | phone: authUser.phoneNumber, 16 | providers: authUser.providerData?.map(x => x?.providerId ?? JSON.stringify(x)), 17 | isAnon: authUser.isAnonymous, 18 | } 19 | }, [authUser]); 20 | 21 | // useEffect(() => { 22 | // if (!authUser) return; 23 | // setUser(authUser); 24 | // }, [setUser, authUser]); 25 | 26 | if (!user) { 27 | // console.warn("Attempting to render UserInfoView with no user set."); 28 | return <>; 29 | } 30 | 31 | return <> 32 |
33 | 34 |
35 | 36 |
37 |
38 |
39 | 40 | 41 |
42 |
43 | 44 |
45 | 46 |
47 |
48 | {/*
49 | 50 | 51 |
*/} 52 | {/* {firebaseUI &&
53 | 54 |
{firebaseUI}
55 |
} */} 56 | ; 57 | }; 58 | 59 | export default UserInfoView; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "uqtp", 3 | "version": "1.3.0", 4 | "private": true, 5 | "license": "Apache-2.0", 6 | "dependencies": { 7 | "@testing-library/jest-dom": "^4.2.4", 8 | "@testing-library/react": "^9.5.0", 9 | "@testing-library/user-event": "^7.2.1", 10 | "@types/jest": "^24.9.1", 11 | "@types/node": "^12.19.9", 12 | "@types/react": "^16.14.2", 13 | "@types/react-dom": "^16.9.10", 14 | "@types/react-helmet": "^6.1.0", 15 | "@welldone-software/why-did-you-render": "^4.3.2", 16 | "a11y-react-emoji": "^1.1.2", 17 | "bulma": "^0.9.1", 18 | "classnames": "^2.2.6", 19 | "date-fns": "^2.16.1", 20 | "easy-peasy": "^3.3.1", 21 | "firebase": "^7.24.0", 22 | "firebaseui": "^4.7.1", 23 | "immer": "^8.0.1", 24 | "jss": "^10.5.0", 25 | "jss-preset-default": "^10.3.0", 26 | "lodash": "^4.17.20", 27 | "node-sass": "^4.14.1", 28 | "react": "^16.14.0", 29 | "react-dom": "^16.14.0", 30 | "react-firebase-hooks": "^2.2.0", 31 | "react-helmet": "^6.1.0", 32 | "react-icons": "^3.11.0", 33 | "react-redux": "^7.2.2", 34 | "react-scripts": "^3.4.4", 35 | "react-use": "^13.27.1", 36 | "redux": "^4.0.5", 37 | "redux-thunk": "^2.3.0", 38 | "tinycolor2": "^1.4.2", 39 | "typescript": "^3.9.7", 40 | "unstated-next": "^1.1.0", 41 | "uuid": "^3.4.0", 42 | "xlsx": "^0.15.6" 43 | }, 44 | "scripts": { 45 | "start": "react-scripts start", 46 | "build": "react-scripts build", 47 | "test": "react-scripts test", 48 | "eject": "react-scripts eject" 49 | }, 50 | "eslintConfig": { 51 | "extends": "react-app" 52 | }, 53 | "browserslist": { 54 | "production": [ 55 | ">0.2%", 56 | "not dead", 57 | "not op_mini all" 58 | ], 59 | "development": [ 60 | "last 1 chrome version", 61 | "last 1 firefox version", 62 | "last 1 safari version" 63 | ] 64 | }, 65 | "devDependencies": { 66 | "@types/classnames": "^2.2.11", 67 | "@types/lodash": "^4.14.165", 68 | "@types/react-color": "^3.0.4", 69 | "@types/react-redux": "^7.1.12", 70 | "@types/tinycolor2": "^1.4.2", 71 | "@types/uuid": "^3.4.9" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import * as serviceWorker from './serviceWorker'; 6 | import { DEFAULT_PERSIST, CURRENT_VERSION, PersistState } from './state/schema'; 7 | import { migratePeristState } from './state/migrations'; 8 | import { model, cleanState } from './state/persistState'; 9 | import { createStore, StoreProvider } from 'easy-peasy'; 10 | import { attachFirebasePersistListener, firebaseModel } from './state/firebaseEnhancer'; 11 | import _ from 'lodash'; 12 | 13 | const LOCALSTORAGE_KEY = 'timetableState'; 14 | 15 | const previousJSON = localStorage.getItem(LOCALSTORAGE_KEY); 16 | 17 | let previousState: any = DEFAULT_PERSIST; 18 | try { 19 | if (previousJSON != null) { 20 | const parsed = JSON.parse(previousJSON); 21 | if (parsed) 22 | previousState = parsed; 23 | } 24 | } catch (e) { 25 | console.error("failed to load state from local storage", e); 26 | } 27 | 28 | const migratedState = migratePeristState(previousState, CURRENT_VERSION); 29 | 30 | const saveLocalStorage = (s: PersistState) => { 31 | //console.log('saving to localStorage'); 32 | localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(s)); 33 | } 34 | 35 | if (migratedState) { 36 | saveLocalStorage(migratedState); 37 | } 38 | 39 | const initialState = migratedState ?? previousState; 40 | // debugger; 41 | 42 | const rootStore = createStore({ ...firebaseModel, ...model }, 43 | { initialState }); 44 | 45 | attachFirebasePersistListener(rootStore, DEFAULT_PERSIST, cleanState, 46 | rootStore.getActions().onSetState); 47 | 48 | // attachFirebaseListeners(rootStore); 49 | 50 | rootStore.subscribe(_.throttle(() => { 51 | const s = rootStore.getState() as any; 52 | //console.log("subscribed to state: ", s); 53 | saveLocalStorage(cleanState(s)); 54 | }, 2000)); 55 | 56 | ReactDOM.render( 57 | 58 | 59 | , 60 | document.getElementById('root')); 61 | 62 | 63 | // If you want your app to work offline and load faster, you can change 64 | // unregister() to register() below. Note this comes with some pitfalls. 65 | // Learn more about service workers: https://bit.ly/CRA-PWA 66 | serviceWorker.unregister(); 67 | -------------------------------------------------------------------------------- /src/state/types.ts: -------------------------------------------------------------------------------- 1 | export type ClockTime = { 2 | hour: number, minute: number, 3 | }; 4 | 5 | export enum DayOfWeek { 6 | Monday = 0, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday 7 | } 8 | 9 | 10 | export const DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; 11 | export type DayNames = typeof DAY_NAMES; 12 | 13 | export const DEFAULT_WEEK_PATTERN = '1'.repeat(65); 14 | 15 | export const DEFAULT_COURSE_COLOUR: RGBAColour = '#FAFAFA'; 16 | 17 | 18 | export type Course = { 19 | course: string, 20 | } 21 | 22 | export type CourseActivity = Course & { 23 | activity: string, 24 | activityType?: string, 25 | // numGroups?: number 26 | } 27 | 28 | export type CourseActivityGroup = CourseActivity & { 29 | group: string, 30 | } 31 | 32 | export type CourseEvent = CourseActivityGroup & { 33 | description: string, 34 | 35 | dates: string, 36 | 37 | day: DayOfWeek, 38 | time: ClockTime, 39 | campus: string, 40 | location: string, 41 | /** Duration of activity is in minutes. */ 42 | duration: number, 43 | 44 | startDate?: string, 45 | weekPattern?: string, 46 | } 47 | 48 | export type RGBAColour = string; 49 | // export type RGBAColour = {r: number, g: number, b: number, a?: number}; 50 | 51 | // helper classes for various levels of data structures. 52 | export type CourseMap = { [course: string]: V }; 53 | export type CourseActivityMap = CourseMap<{ [activity: string]: V }> 54 | export type CourseActivityGroupMap = CourseActivityMap<{ [group: string]: V }> 55 | 56 | export type SelectionsByGroup = CourseActivityGroupMap; 57 | export type SessionsByGroup = CourseActivityGroupMap<{ [sessionId: string]: CourseEvent }> 58 | 59 | export type CourseVisibility = CourseMap; 60 | export type CourseColours = CourseMap; 61 | 62 | 63 | export type Timetable = { 64 | name: string, 65 | sessions: SessionsByGroup, 66 | selections: SelectionsByGroup, 67 | courseVisibility?: CourseVisibility, 68 | courseColours?: CourseColours 69 | } 70 | 71 | export const EMPTY_TIMETABLE: Timetable = { 72 | name: 'empty timetable', 73 | sessions: {}, 74 | selections: {}, 75 | courseVisibility: {}, 76 | courseColours: {}, 77 | } 78 | 79 | export type TimetablesState = { 80 | [id: string]: Timetable, 81 | } -------------------------------------------------------------------------------- /src/components/styles/CourseColours.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, useState } from "react"; 2 | import { useStoreState } from "../../state/persistState" 3 | import { useEffect } from "react"; 4 | 5 | import jss from 'jss'; 6 | import preset from 'jss-preset-default'; 7 | import tinycolor from "tinycolor2"; 8 | 9 | import { createContainer } from 'unstated-next'; 10 | import { Helmet } from "react-helmet"; 11 | import { CourseColours } from "../../state/types"; 12 | import { toCSSColour } from "../../logic/functions"; 13 | 14 | jss.setup(preset()); 15 | 16 | const hoverAmount = 3; 17 | const borderAmount = 9; 18 | 19 | const makeStylesForColour = (c: string) => { 20 | 21 | const colour = tinycolor(c); 22 | const text = tinycolor.mostReadable(colour, ['#fff', '#363636']).toRgbString(); 23 | 24 | colour.darken(hoverAmount); 25 | const hover = colour.toRgbString(); 26 | 27 | colour.darken(borderAmount); 28 | const border = colour.toRgbString(); 29 | 30 | return { 31 | '& .text, &.text': { 32 | color: text, 33 | }, 34 | '& .border, &.border': { 35 | borderColor: border, 36 | }, 37 | '& .background, &.background': { 38 | background: toCSSColour(c), 39 | }, 40 | '& .hover:hover, &.hover:hover': { 41 | background: hover, 42 | } 43 | }; 44 | }; 45 | 46 | const createCourseColoursState = (colours?: CourseColours) => { 47 | const styles: any = { 48 | 'default': makeStylesForColour('#fafafa'), 49 | }; 50 | const isDark: {[c: string]: boolean} = {}; 51 | for (const course of Object.keys(colours ?? {})) { 52 | styles[course] = makeStylesForColour(colours![course]); 53 | isDark[course] = tinycolor(colours![course]).isDark(); 54 | } 55 | 56 | const sheet = jss.createStyleSheet(styles); 57 | return { sheet: sheet, classes: sheet.classes, isDark }; 58 | } 59 | 60 | export const useCourseColours = () => { 61 | 62 | const colours = useStoreState(s => s.currentTimetable.courseColours); 63 | 64 | const [data, setData] = useState(() => createCourseColoursState(colours)); 65 | 66 | useEffect(() => { 67 | setData(createCourseColoursState(colours)); 68 | }, [colours]); 69 | 70 | return data; 71 | } 72 | 73 | export const CourseColoursContainer = createContainer(useCourseColours); 74 | 75 | export const CourseColoursStylesheet = memo(() => { 76 | const { sheet } = CourseColoursContainer.useContainer(); 77 | return 78 | 81 | ; 82 | }); 83 | -------------------------------------------------------------------------------- /src/state/firebase.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase/app'; 2 | import 'firebase/auth'; 3 | // import 'firebase/firestore'; 4 | import 'firebase/database'; 5 | import { PersistState } from './schema'; 6 | import 'firebase/analytics'; 7 | import { migratePeristState } from './migrations'; 8 | 9 | export const firebaseConfig = { 10 | apiKey: process.env.REACT_APP_API_KEY, 11 | authDomain: process.env.REACT_APP_AUTH_DOMAIN, 12 | databaseURL: process.env.REACT_APP_DATABASE_URL, 13 | projectId: process.env.REACT_APP_PROJECT_ID, 14 | storageBucket: process.env.REACT_APP_STORAGE_BUCKET, 15 | messagingSenderId: process.env.REACT_APP_MESSAGING_SENDER_ID, 16 | appId: process.env.REACT_APP_APP_ID, 17 | measurementId: process.env.REACT_APP_MEASUREMENT_ID, 18 | }; 19 | 20 | firebase.initializeApp(firebaseConfig); 21 | export const database = firebase.database(); 22 | // export const firestore = firebase.firestore(); 23 | export const auth = firebase.auth(); 24 | firebase.analytics(); 25 | 26 | export const userFirestoreDocRef = (userOrUid: firebase.User | string | null) => { 27 | if (userOrUid == null) 28 | return null; 29 | 30 | let uid = ''; 31 | if (typeof userOrUid == 'string') { 32 | uid = userOrUid; 33 | } else { 34 | uid = userOrUid.uid; 35 | } 36 | console.assert(uid != null); 37 | return database.ref('data/' + uid); 38 | }; 39 | 40 | export const mergeData = (oldData: PersistState, newData: PersistState) => { 41 | return {...newData, ...oldData, timetables: { ...newData.timetables, ...oldData.timetables }}; 42 | } 43 | 44 | export const mergeAnonymousData = async (newCredential: firebase.auth.AuthCredential) => { 45 | const oldUser = auth.currentUser; 46 | 47 | //console.log("merging data from accounts:", oldUser, newCredential); 48 | 49 | const oldDocRef = userFirestoreDocRef(oldUser); 50 | if (!oldDocRef) { 51 | return; 52 | } 53 | 54 | let oldData = (await oldDocRef.once('value')).val() as PersistState; 55 | const oldMigrated = oldData && migratePeristState(oldData); 56 | if (oldMigrated) 57 | oldData = oldMigrated; 58 | await oldDocRef.remove(); 59 | 60 | const newUser = await auth.signInWithCredential(newCredential); 61 | 62 | const newDocRef = userFirestoreDocRef(newUser.user)!; 63 | let newData = (await newDocRef.once('value')).val() as PersistState; 64 | const newMigrated = newData && migratePeristState(newData); 65 | if (newMigrated) 66 | newData = newMigrated; 67 | 68 | await newDocRef.set(mergeData(oldData ?? {}, newData ?? {})); 69 | }; -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/components/FirebaseSignIn.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from "../state/firebase"; 2 | import React, { useEffect } from "react"; 3 | import firebase from "firebase/app"; 4 | import 'firebase/auth'; 5 | 6 | import * as firebaseui from 'firebaseui'; 7 | import 'firebaseui/dist/firebaseui.css'; 8 | 9 | export const getFirebaseUIConfig = 10 | (signInSuccess?: NewFirebaseLoginProps['signInSuccess'], 11 | mergeResolver?: NewFirebaseLoginProps['anonymousMergeConflict']): firebaseui.auth.Config => 12 | ({ 13 | signInFlow: 'popup', 14 | signInOptions: [ 15 | firebaseui.auth.AnonymousAuthProvider.PROVIDER_ID, 16 | firebase.auth.GoogleAuthProvider.PROVIDER_ID, 17 | firebase.auth.FacebookAuthProvider.PROVIDER_ID, 18 | firebase.auth.GithubAuthProvider.PROVIDER_ID, 19 | { 20 | provider: firebase.auth.EmailAuthProvider.PROVIDER_ID, 21 | signInMethod: firebase.auth.EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD, 22 | }, 23 | { 24 | provider: firebase.auth.PhoneAuthProvider.PROVIDER_ID, 25 | defaultCountry: 'AU', 26 | } 27 | ], 28 | autoUpgradeAnonymousUsers: true, 29 | credentialHelper: firebaseui.auth.CredentialHelper.GOOGLE_YOLO, 30 | privacyPolicyUrl: 'https://kentonlam.xyz/uqtp-privacy/', 31 | callbacks: { 32 | // Avoid redirects after sign-in. 33 | signInSuccessWithAuthResult: (userCredential) => { 34 | //console.log(userCredential); 35 | return signInSuccess?.(userCredential) ?? false; 36 | }, 37 | signInFailure: async (error) => { 38 | // ignore non-merge conflict errors. 39 | if (error.code !== 'firebaseui/anonymous-upgrade-merge-conflict') { 40 | return; 41 | } 42 | if (mergeResolver) { 43 | await mergeResolver(error.credential); 44 | } 45 | } 46 | } 47 | }); 48 | 49 | export type NewFirebaseLoginProps = { 50 | anonymousMergeConflict?: (newCredential: firebase.auth.AuthCredential) => Promise, 51 | signInSuccess?: (authResult: firebase.auth.UserCredential) => boolean, 52 | } 53 | 54 | export const NewFirebaseLogin = (props: NewFirebaseLoginProps) => { 55 | const config = getFirebaseUIConfig(props.signInSuccess, props.anonymousMergeConflict); 56 | 57 | // const uiCallback = useCallback((ui: firebaseui.auth.AuthUI) => ui.reset(), []); 58 | 59 | const id = "new-firebaseui-login"; 60 | 61 | // this component should only be instantiated once! 62 | useEffect(() => { 63 | const ui = firebaseui.auth.AuthUI.getInstance() ?? new firebaseui.auth.AuthUI(auth); 64 | ui.start('#'+id, config); 65 | }, [config]); 66 | 67 | return
; 68 | } 69 | -------------------------------------------------------------------------------- /src/logic/importer.ts: -------------------------------------------------------------------------------- 1 | import XLSX from 'xlsx'; 2 | import _ from 'lodash'; 3 | import { CourseEvent } from '../state/types'; 4 | import parse from 'date-fns/parse' 5 | 6 | export const parseExcelFile = (file: Blob) => { 7 | const f = new FileReader(); 8 | 9 | return new Promise((resolve, reject) => { 10 | f.onload = () => { 11 | //console.log(f.result); 12 | const arr = new Uint8Array(f.result as ArrayBuffer); 13 | const a = XLSX.read(arr, {type: 'array'}); 14 | const sheet = a.SheetNames[0]; 15 | //console.log(a); 16 | //debugger; 17 | resolve(XLSX.utils.sheet_to_json(a.Sheets[sheet], {header: 1})); 18 | }; 19 | 20 | f.onerror = (ev: ProgressEvent) => { 21 | f.abort(); 22 | reject(new Error("error reading excel sheet.")); 23 | } 24 | f.readAsArrayBuffer(file); 25 | }) 26 | }; 27 | 28 | const oldColumns = [ 29 | 'Subject Code', 'Description', 'Group', 'Activity', 'Day', 'Time', 'Campus', 'Location', 'Duration', 'Dates', 30 | ]; 31 | 32 | const newColumns: (keyof CourseEvent)[] = [ 33 | 'course', 'description', 'activity', 'group', 'day', 'time', 'campus', 'location', 'duration', 'dates', 34 | ]; 35 | 36 | const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; 37 | 38 | export const parseSheetRows = (rows: string[][]) => { 39 | if (rows[0] && rows[0].length === 0) 40 | rows = rows.slice(1); 41 | if (rows.length === 0) { 42 | console.error("spreadsheet has no rows."); 43 | return null; 44 | } 45 | 46 | const expectedCols = 10; 47 | 48 | if (!rows.every((r) => r.length === expectedCols)) { 49 | console.error("spreadsheet has uneven columns."); 50 | return null; 51 | } 52 | 53 | const headings = rows[0]; 54 | if (!_.isEqual(headings, oldColumns)) { 55 | console.error("spreadsheet headings do not match expected."); 56 | return null; 57 | } 58 | 59 | const parseRow = (row: string[]) => 60 | Object.fromEntries(row.map((x, i) => [newColumns[i], x])); 61 | 62 | const parseTime = (str: string) => { 63 | const t = parse(str, 'HH:mm', new Date()); 64 | return {hour: t.getHours(), minute: t.getMinutes()}; 65 | } 66 | 67 | const fixObject = (obj: {[key: string]: string}) => { 68 | return { 69 | ...obj, 70 | activityType: obj.activity.replace(/\d+$/, ''), 71 | duration: 60 * parseFloat(obj.duration.replace(/ hrs?$/, '')), 72 | day: days.indexOf(obj.day), 73 | time: parseTime(obj.time), 74 | } as CourseEvent; 75 | }; 76 | 77 | 78 | return rows.slice(1).map(parseRow).map(fixObject); 79 | }; 80 | 81 | -------------------------------------------------------------------------------- /src/logic/api.ts: -------------------------------------------------------------------------------- 1 | import { CourseEvent } from "../state/types"; 2 | import { parse } from "date-fns"; 3 | 4 | const ENDPOINT = 'https://asia-northeast1-uq-toilet-paper.cloudfunctions.net/proxy/odd/rest/timetable/subjects'; 5 | 6 | export const fetchCourseData = async (query: string) => 7 | fetch(ENDPOINT, { 8 | "headers": { 9 | "content-type": "application/x-www-form-urlencoded; charset=UTF-8", 10 | }, 11 | "body": `search-term=${encodeURIComponent(query)}&semester=ALL&campus=ALL&faculty=ALL&type=ALL&days=1&days=2&days=3&days=4&days=5&days=6&days=0&start-time=00%3A00&end-time=23%3A00`, 12 | "method": "POST" 13 | }).then(x => { 14 | if (!x.ok) { 15 | throw new Error(`Error ${x.status} while fetching API data.`); 16 | } 17 | return x.json(); 18 | }); 19 | 20 | export type CourseSearchResult = { 21 | course: string, 22 | semester: string, 23 | coordinator: string, 24 | name: string, 25 | 26 | activities: CourseEvent[] 27 | } 28 | 29 | export type FullSearchResult = { 30 | [course: string]: CourseSearchResult 31 | } 32 | 33 | const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; 34 | 35 | const mappings = { 36 | course: 'subject_code', 37 | activity: 'activity_group_code', 38 | activityType: (data: any) => data.activity_group_code.replace(/\d+$/, ''), 39 | group: 'activity_code', 40 | description: 'description', 41 | dates: () => 'invalid date', 42 | day: (data: any) => days.indexOf(data.day_of_week), 43 | time: (data: any) => { 44 | const timeSplit = data.start_time.split(':') 45 | .map((x: string) => parseInt(x)); 46 | return {hour: timeSplit[0], minute: timeSplit[1]}; 47 | }, 48 | campus: 'campus', 49 | location: 'location', 50 | duration: (data: any) => parseFloat(data.duration), 51 | startDate: (data: any) => parse(data.start_date, 'd/M/yyyy', new Date()).toJSON(), 52 | weekPattern: 'week_pattern', 53 | }; 54 | 55 | const convertActivity = (data: any): CourseEvent => { 56 | const event: any = {}; 57 | for (const [to, from] of Object.entries(mappings)) { 58 | event[to] = typeof from == 'string' ? data[from] : from(data); 59 | } 60 | console.assert(event.day !== -1); 61 | return event; 62 | } 63 | 64 | export const searchCourses = async (query: string): Promise => { 65 | const response: {[c: string]: any} = await fetchCourseData(query); 66 | for (const [course, data] of Object.entries(response)) { 67 | data.course = course; 68 | data.name = data.description; 69 | data.coordinator = data.manager; 70 | data.activities = Object.values(data.activities).map(convertActivity); 71 | } 72 | return response; 73 | }; 74 | 75 | export const compareSearchResultSemesters = (a: CourseSearchResult, b: CourseSearchResult) => { 76 | let x = a.semester.localeCompare(b.semester); 77 | if (x !== 0) return x; 78 | x = a.course.localeCompare(b.course); 79 | return x; 80 | }; -------------------------------------------------------------------------------- /src/components/Timetable.scss: -------------------------------------------------------------------------------- 1 | @import "bulma/sass/utilities/initial-variables"; 2 | @import "bulma/sass/utilities/derived-variables"; 3 | 4 | 5 | // https://codepen.io/davidelrizzo/pen/GobVLe 6 | // https://codepen.io/davidelrizzo/pen/eJwqzp 7 | .timetable { 8 | // max-width: 70rem; 9 | 10 | display: grid; 11 | grid-template-columns: min-content repeat(5, minmax(0, 1fr)); 12 | grid-template-rows: 2rem; 13 | grid-auto-rows: 3rem; 14 | column-gap: 0.15rem; 15 | 16 | @media screen and (min-width: $tablet) { 17 | column-gap: 1rem; 18 | grid-auto-rows: 3.5rem; 19 | } 20 | } 21 | 22 | .is-no-wrap { 23 | white-space: nowrap; 24 | } 25 | 26 | .is-clickable { 27 | cursor: pointer; 28 | } 29 | 30 | .cell { 31 | padding: 0.3rem 0.3rem; 32 | line-height: 1; 33 | font-size: 0.85rem; 34 | 35 | @media screen and (max-width: $tablet) { 36 | padding: 0.15rem; 37 | font-size: 0.75rem; 38 | } 39 | overflow: hidden; 40 | // white-space: nowrap; 41 | // text-overflow: ellipsis; 42 | 43 | &.col-time { 44 | border: 1px solid transparent; 45 | } 46 | } 47 | 48 | .hour { 49 | height: 100%; 50 | overflow-y: visible; 51 | background: linear-gradient(to top, $border-light 0px 1px, transparent 1px), 52 | linear-gradient(to bottom, $border-light 0px 1px, transparent 1px); 53 | } 54 | 55 | .editing { 56 | & .hour { 57 | cursor: pointer; 58 | &.empty:hover { 59 | background: linear-gradient(to top, $border 0px 2px, transparent 2px), 60 | linear-gradient(to bottom, $border 0px 2px, transparent 2px), 61 | linear-gradient(to left, $border 0px 2px, transparent 2px), 62 | linear-gradient(to right, $border 0px 2px, transparent 2px); 63 | } 64 | } 65 | } 66 | 67 | .highlighting .session:not(.highlighted) { 68 | opacity: 0.35; 69 | } 70 | 71 | .session { 72 | z-index: 2; 73 | // margin: 0 -0.25rem; 74 | 75 | & > * { 76 | // margin: 0 0.25rem; 77 | } 78 | 79 | // margin: 0 0.25rem; 80 | 81 | display: inline-block; 82 | position: relative; 83 | vertical-align: top; 84 | // float: left; 85 | 86 | // word-break: keep-all; 87 | 88 | height: 100%; 89 | 90 | overflow: hidden; 91 | // overflow-wrap: break-word; 92 | text-overflow: clip; 93 | // word-wrap: break-word; 94 | // white-space: nowrap; 95 | 96 | // padding-top: 0.5em; 97 | //line-height: 1; 98 | 99 | cursor: pointer; 100 | 101 | // margin-left: 2px; 102 | // margin-right: 2px ; 103 | // margin-right: -1px; 104 | 105 | border: 2px solid $border; 106 | // border-left-width: 10px; 107 | // border-right-width: 10px; 108 | // border-bottom-color: transparent; 109 | // margin: 1px; 110 | // box-sizing: border-box; 111 | 112 | border-radius: 0px; 113 | background-color: $white-bis; 114 | 115 | transition: opacity 200ms; 116 | 117 | // color: black; 118 | 119 | &.not-highlighted { 120 | color: $black !important; 121 | border-color: $border !important; 122 | background-color: #fafafa !important; 123 | } 124 | 125 | &.highlighted { 126 | // background-color: hsl(219, 70%, 96%) !important; //hsl(0; 0%; 99.9%); 127 | // border-color: $grey-dark; 128 | // overflow: visible; 129 | z-index: 10; 130 | } 131 | 132 | &.clash { 133 | // background: hsl(48, 100%, 96%); 134 | } 135 | 136 | &.empty { 137 | background-color: transparent; 138 | border-color: transparent; 139 | z-index: -1; 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/state/uiState.ts: -------------------------------------------------------------------------------- 1 | import { action, computed, Computed, Action, createContextStore, memo } from 'easy-peasy'; 2 | import { CourseActivity, CourseActivityGroup, CourseEvent } from './types'; 3 | import { PersistModel } from './persistState'; 4 | import { addWeeks, startOfWeek, differenceInCalendarDays } from 'date-fns'; 5 | 6 | export const WEEK_START_MONDAY = { weekStartsOn: 1 } as const; 7 | 8 | const parseDate = memo((d: string) => new Date(d), 3); 9 | 10 | export enum TimetableMode { 11 | VIEW, EDIT, CUSTOM 12 | } 13 | 14 | export type UIState = { 15 | timetableMode: TimetableMode, 16 | 17 | highlight: CourseActivityGroup | null, 18 | 19 | weekStart: Date, 20 | allWeeks: boolean, 21 | 22 | replaceActivityGroup: (payload: PersistModel['replaceOneSelectedGroup']['payload']) => any 23 | }; 24 | 25 | export type UIModel = UIState & { 26 | setTimetableMode: Action, 27 | 28 | setHighlight: Action, 29 | isHighlighted: Computed boolean>, 30 | selectHighlightedGroup: Computed any> 31 | 32 | setAllWeeks: Action, 33 | setWeek: Action, 34 | shiftWeek: Action, 35 | isWeekVisible: Computed boolean>, 36 | 37 | reset: Action, 38 | }; 39 | 40 | const DEFAULT_WEEK_START = startOfWeek(new Date(), WEEK_START_MONDAY); 41 | 42 | const initialState = { 43 | highlight: null, 44 | weekStart: DEFAULT_WEEK_START, 45 | allWeeks: true, 46 | timetableMode: TimetableMode.EDIT, 47 | } 48 | 49 | export type UIModelParams = { 50 | replaceActivityGroup: UIState['replaceActivityGroup'] 51 | } 52 | 53 | export const model = ({ replaceActivityGroup }: UIModelParams): UIModel => ({ 54 | ...initialState, 55 | replaceActivityGroup, 56 | 57 | setTimetableMode: action((s, mode) => { 58 | if (mode !== s.timetableMode) 59 | s.highlight = null; 60 | s.timetableMode = mode; 61 | }), 62 | 63 | setHighlight: action((s, group) => { 64 | s.highlight = group; 65 | }), 66 | 67 | isHighlighted: computed((s) => (session) => { 68 | return session.course === s.highlight?.course && session.activity === s.highlight.activity; 69 | }), 70 | 71 | setAllWeeks: action((s, b) => { 72 | s.allWeeks = b; 73 | }), 74 | 75 | setWeek: action((s, week) => { 76 | s.weekStart = week ?? DEFAULT_WEEK_START; 77 | }), 78 | 79 | shiftWeek: action((s, n) => { 80 | if (!s.weekStart) return; 81 | s.weekStart = addWeeks(s.weekStart, n); 82 | }), 83 | 84 | isWeekVisible: computed([ 85 | s => s.weekStart, 86 | s => s.allWeeks, 87 | ], memo((start: Date, all: boolean) => (session: CourseEvent) => { 88 | if (all || !session.startDate || !session.weekPattern) return true; 89 | 90 | const diff = differenceInCalendarDays(start, parseDate(session.startDate)); 91 | const index = Math.floor(diff / 7); 92 | if (index < 0) return false; 93 | return (session.weekPattern[index] ?? '1') === '1'; 94 | }, 1)), 95 | 96 | reset: action(s => { 97 | s.weekStart = DEFAULT_WEEK_START; 98 | s.allWeeks = true; 99 | s.highlight = null; 100 | s.timetableMode = TimetableMode.EDIT; 101 | }), 102 | 103 | selectHighlightedGroup: computed(s => group => { 104 | if (!s.highlight) return; 105 | 106 | s.replaceActivityGroup({ 107 | course: s.highlight.course, 108 | activity: s.highlight.activity, 109 | old: s.highlight.group, 110 | new: group 111 | }); 112 | }), 113 | }); 114 | 115 | // @ts-ignore 116 | export const UIStore = createContextStore(model); -------------------------------------------------------------------------------- /src/components/CourseSearcher.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, memo } from "react"; 2 | import { FaSearch } from "react-icons/fa"; 3 | import { searchCourses, FullSearchResult, CourseSearchResult, compareSearchResultSemesters } from "../logic/api"; 4 | import _ from "lodash"; 5 | import { useStoreActions } from "../state/persistState"; 6 | 7 | export const SearchResult = (props: { result: CourseSearchResult }) => { 8 | const updateSessions = useStoreActions(s => s.updateCourseSessions); 9 | 10 | const result = props.result; 11 | 12 | return ( 13 |
14 | 25 |
); 26 | }; 27 | 28 | enum SearchState { 29 | LOADING, ERROR, DONE, 30 | } 31 | 32 | export const CourseSearcher = memo(() => { 33 | const [query, setQuery] = useState(''); 34 | const [loading, setLoading] = useState(SearchState.DONE); 35 | const [results, setResults] = useState(null); 36 | 37 | const search = async (ev: React.MouseEvent) => { 38 | ev.stopPropagation(); 39 | ev.preventDefault(); 40 | 41 | if (loading !== SearchState.DONE) 42 | return; 43 | 44 | setLoading(SearchState.LOADING); 45 | 46 | try { 47 | const results = await searchCourses(query); 48 | setLoading(SearchState.DONE); 49 | setResults(results); 50 | } catch (e) { 51 | console.error('error while searching courses', e); 52 | setLoading(SearchState.ERROR); 53 | setResults(null); 54 | } 55 | }; 56 | 57 | const isDone = loading === SearchState.DONE; 58 | const isLoading = loading === SearchState.LOADING; 59 | const isError = loading === SearchState.ERROR; 60 | const resultsEmpty = results != null && _.isEmpty(results); 61 | const resultsPresent = results != null && !_.isEmpty(results); 62 | 63 | return <> 64 |

65 | Updated for 2023! 66 |

67 | 68 |
69 |
70 | 71 |
72 | setQuery(e.target.value)} /> 74 |
75 | 76 |
77 | 80 |
81 | 82 |
83 |
84 | 85 | {resultsPresent && <> 86 | {/*
Results
*/} 87 |
88 | {Object.values(results!).slice(0, 20).sort(compareSearchResultSemesters) 89 | .map(r => )} 90 |
91 | } 92 | 93 | {resultsEmpty &&
No results.
} 94 | {isError &&
Error while searching courses.
} 95 | 96 | {isDone && results == null &&
97 | Search for your courses here. You can also include the semester or delivery mode, e.g. “MATH1051 S2 EX”. 98 |
} 99 | ; 100 | }); 101 | 102 | export default CourseSearcher; 103 | -------------------------------------------------------------------------------- /src/state/firebaseEnhancer.ts: -------------------------------------------------------------------------------- 1 | import { userFirestoreDocRef, auth } from "./firebase"; 2 | import firebase from "firebase"; 3 | import { Thunk, Action, thunk, State, action, Store } from "easy-peasy"; 4 | import { produceWithPatches, enablePatches } from 'immer'; 5 | import { IS_DEBUG } from "../isDebug"; 6 | 7 | enablePatches(); 8 | 9 | type FirebaseRef = firebase.database.Reference; 10 | 11 | export type FirebaseState = { 12 | __ref: firebase.database.Reference | null, 13 | }; 14 | 15 | export type FirebaseModel = FirebaseState & { 16 | __setFirebaseState: Action, 17 | __setFirebaseRef: Action, 18 | }; 19 | 20 | export const firebaseModel: FirebaseModel = { 21 | __ref: null, 22 | 23 | __setFirebaseState: action((_, state) => { 24 | //console.log("__setFirebaseState", state); 25 | return state as State; 26 | }), 27 | 28 | __setFirebaseRef: action((s, ref) => { 29 | s.__ref = ref; 30 | }), 31 | } 32 | 33 | 34 | export const attachFirebasePersistListener = ( 35 | store: Store, 36 | defaultState?: State, 37 | cleanState?: (t: Model) => State, 38 | onSetState?: (x: void) => any, 39 | ) => { 40 | 41 | const { __setFirebaseState, __setFirebaseRef } = store.getActions(); 42 | 43 | let listener: Function | null = null; 44 | let docRef: FirebaseRef | null = null; 45 | let user: firebase.User | null = null; 46 | 47 | auth.onAuthStateChanged((newUser: firebase.User | null) => { 48 | 49 | if (listener) 50 | docRef?.off('value', listener as any); 51 | listener = null; 52 | 53 | user = newUser; 54 | 55 | IS_DEBUG && console.log('auth state changed: ' + user?.uid); 56 | //console.log(user); 57 | docRef = userFirestoreDocRef(user); 58 | __setFirebaseRef(docRef); 59 | 60 | let first = true; // only upload data on first connect. 61 | if (user) { 62 | listener = docRef!.on('value', doc => { 63 | if (doc?.exists()) { 64 | // previous data exists. load from online. 65 | const data = doc.val()! as FirebaseState; 66 | data.__ref = docRef; 67 | IS_DEBUG && console.log('... got snapshot from firebase', data); 68 | __setFirebaseState(data as FirebaseState); 69 | onSetState && onSetState(); 70 | } else if (first) { 71 | IS_DEBUG && console.log('... no data on firebase, uploading.'); 72 | // no previous data exists. upload our data. 73 | const data = cleanState ? cleanState(store.getState() as Model) : store.getState(); 74 | IS_DEBUG && console.log(data); 75 | docRef?.set(data); 76 | // store.dispatch(firebaseSnapshotAction(defaultState as unknown as S)); 77 | } 78 | first = false; 79 | }); 80 | } else { 81 | // new user state is signed out. delete data. 82 | __setFirebaseState(defaultState as unknown as FirebaseState); 83 | onSetState && onSetState(); 84 | } 85 | }); 86 | }; 87 | 88 | type ActionFunction = (state: State, payload: Payload) => void | State; 89 | 90 | export const firebaseAction = (actionFunction: ActionFunction): Thunk => { 91 | return thunk(async (actions, payload, { getState, getStoreState, meta }) => { 92 | 93 | const s = getStoreState(); 94 | 95 | const prevState = getState(); 96 | const [, patches] = produceWithPatches(prevState, (s: State) => actionFunction(s, payload)); 97 | 98 | const updates: any = {}; 99 | const basePath = meta.parent.join('/'); 100 | for (const patch of patches) { 101 | const path = basePath + patch.path.join('/'); 102 | updates[path] = patch.op === 'remove' ? null : (patch.value ?? null); 103 | } 104 | 105 | IS_DEBUG && console.log("patches", patches); 106 | IS_DEBUG && console.log("update", updates); 107 | // @ts-ignore 108 | IS_DEBUG && console.log(s.__ref); 109 | // debugger; 110 | 111 | if (Object.keys(updates)) { 112 | // @ts-ignore 113 | await (s.__ref as FirebaseModel['__ref'])?.update(updates); 114 | } 115 | }); 116 | }; 117 | 118 | export type FirebaseThunk< 119 | Model extends object = {}, 120 | Payload = void, 121 | Injections = any, 122 | StoreModel extends object = {}, 123 | Result = any 124 | > = Thunk; -------------------------------------------------------------------------------- /src/state/migrations.ts: -------------------------------------------------------------------------------- 1 | import { CourseEvent, CourseVisibility, CourseColours, SessionsByGroup } from "./types"; 2 | import { StateMetadata, PersistState, CURRENT_VERSION } from "./schema"; 3 | import { UserState } from "./schema"; 4 | import _ from "lodash"; 5 | import uuidv4 from 'uuid/v4'; 6 | import { makeActivitySessionKey } from "../logic/functions"; 7 | 8 | type OldTimetableState = { 9 | allSessions: CourseEvent[], 10 | selectedGroups: { [course: string]: { [activity: string]: string[] | string } }, 11 | } 12 | 13 | type Schema0 = { 14 | timetables: { 15 | [name: string]: OldTimetableState, 16 | }, 17 | current: string, 18 | } 19 | 20 | type Schema10 = { 21 | timetables: { 22 | [name: string]: OldTimetableState, 23 | }, 24 | current: string, 25 | } & StateMetadata<10>; 26 | 27 | type Schema11 = { 28 | timetables: { 29 | [name: string]: OldTimetableState, 30 | }, 31 | current: string, 32 | user: UserState, 33 | } & StateMetadata<11>; 34 | 35 | type Schema12 = { 36 | timetables: { 37 | [id: string]: OldTimetableState & { 38 | name: string, 39 | courseVisibility?: CourseVisibility, 40 | courseColours?: CourseColours 41 | }, 42 | }, 43 | current: string, 44 | user: UserState, 45 | } & StateMetadata<12>; 46 | 47 | type Schema13 = PersistState & StateMetadata<13>; 48 | 49 | 50 | const migrate0To10 = (state: Schema0): Schema10 => { 51 | return { ...state, _meta: { version: 10 } }; 52 | } 53 | 54 | const migrate10to11 = (state: Schema10): Schema11 => { 55 | return { ...state, user: null, _meta: {...state._meta, version: 11} }; 56 | } 57 | 58 | const migrate11To12 = (state: Schema11): Schema12 => { 59 | const newState = _.cloneDeep(state); 60 | const currentID = uuidv4(); 61 | const newEntries = Object.entries(newState.timetables) 62 | .map(([name, timetable]) => 63 | [name === state.current ? currentID : uuidv4(), {...timetable, name}] as const); 64 | const newTimetables = Object.fromEntries(newEntries); 65 | return { ...state, timetables: newTimetables, current: currentID, 66 | _meta: {...state._meta, version: 12} }; 67 | } 68 | 69 | const migrate12To13 = (state: Schema12): Schema13 => { 70 | for (const id of Object.keys(state.timetables)) { 71 | const timetable = state.timetables[id]; 72 | const sessions: SessionsByGroup = {}; 73 | 74 | for (const s of timetable.allSessions ?? []) { 75 | _.set(sessions, [s.course, s.activity, s.group, makeActivitySessionKey(s)], s); 76 | } 77 | 78 | for (const c of Object.keys(timetable.selectedGroups ?? {})) { 79 | for (const a of Object.keys(timetable.selectedGroups[c])) { 80 | const selected: { [g: string]: boolean } = {}; 81 | for (const g of timetable.selectedGroups[c][a]) { 82 | selected[g] = true; 83 | } 84 | _.set(timetable, ['selections', c, a], selected); 85 | } 86 | } 87 | 88 | // @ts-ignore 89 | timetable.sessions = sessions; 90 | delete timetable.allSessions; 91 | delete timetable.selectedGroups; 92 | } 93 | 94 | // @ts-ignore 95 | state._meta = {...state._meta, version: 13 }; 96 | return state as unknown as Schema13; 97 | } 98 | 99 | 100 | export type AllSchemas = Schema0 | Schema10 | Schema11 | Schema12 | Schema13; 101 | 102 | // any type o.O 103 | const MIGRATIONS: {[prev: number]: (a: any) => AllSchemas} = { 104 | 0: migrate0To10, 105 | 10: migrate10to11, 106 | 11: migrate11To12, 107 | 12: migrate12To13, 108 | } 109 | 110 | const getStateSchemaVersion = (state: StateMetadata) => { 111 | return state?._meta?.version ?? 0; 112 | } 113 | 114 | export const migratePeristState = (state: AllSchemas, latest: number = CURRENT_VERSION): PersistState | null => { 115 | let stateVer = getStateSchemaVersion(state as StateMetadata); 116 | if (stateVer === latest) return null; 117 | 118 | while (stateVer < latest) { 119 | if (MIGRATIONS[stateVer] === undefined) 120 | throw new Error("No migration from " + stateVer); 121 | //console.log("Applying migration from state " + stateVer); 122 | state = MIGRATIONS[stateVer](state); 123 | stateVer = getStateSchemaVersion(state as StateMetadata); 124 | } 125 | if (stateVer > latest) 126 | throw new Error("State has version greater than expected. Latest is " 127 | +latest+" but current state is "+stateVer); 128 | // @ts-ignore 129 | return state; 130 | } -------------------------------------------------------------------------------- /src/Main.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import './App.scss'; 3 | import FileInput from './components/FileInput'; 4 | import { parseExcelFile, parseSheetRows } from './logic/importer'; 5 | import { MyTimetableHelp } from './components/MyTimetableHelp'; 6 | import SessionSelectors from './components/SessionSelectors'; 7 | import Timetable from './components/Timetable'; 8 | import TimetableSelector from './components/TimetableSelector'; 9 | import CourseSearcher from './components/CourseSearcher'; 10 | import { useStoreActions } from './state/persistState'; 11 | import { UIStore } from './state/uiState'; 12 | import { WeekSelector } from './components/WeekSelector'; 13 | import { CourseColoursContainer, CourseColoursStylesheet } from './components/styles/CourseColours'; 14 | import { ModeSelector } from './components/ModeSelector'; 15 | 16 | 17 | const Main = () => { 18 | 19 | const replaceActivityGroup = useStoreActions(s => s.replaceOneSelectedGroup); 20 | const updateSessions = useStoreActions(s => s.updateCourseSessions); 21 | 22 | const [importError, setImportError] = useState(null); 23 | 24 | const importFile = async (file: File | undefined) => { 25 | if (!file) return; 26 | 27 | try { 28 | const rows = await parseExcelFile(file!); 29 | const parsed = parseSheetRows(rows); 30 | 31 | if (!parsed) { 32 | setImportError("invalid timetable."); 33 | return; 34 | } 35 | 36 | //console.log(JSON.stringify(parsed)); 37 | updateSessions(parsed); 38 | } catch (e) { 39 | setImportError("error while importing: " + e.toString()); 40 | return; 41 | } 42 | setImportError(null); 43 | }; 44 | 45 | //console.log(visibleSessions); 46 | 47 | return ( 48 |
49 | 50 | 51 | {} 52 | {/*
53 | Managing multiple timetables is currently not supported. The buttons below do nothing. 54 |
*/} 55 | 56 |
57 | 58 |

Search Courses

59 | 60 | 61 |
62 | Manual import from Allocate+ 63 | {/*
Data
*/} 64 |
65 |
66 | 67 |
68 |
69 | {importError &&
{importError}
} 70 |

71 | If you can't find your courses by searching, you can manually import specific classes from the 72 | UQ Public Timetable. 73 |

74 | 75 |
76 |
77 | 78 | 79 | {/*
80 | Changes to your selected classes are saved automatically. 81 |
*/} 82 | {/*

Selected Classes

*/} 83 | 84 | 85 |

Timetable

86 | 87 |
88 |
89 | 90 | 91 |
92 | 93 |
94 | 95 |
96 |
    97 |
  • Changes to your timetable and classes are saved automatically.
  • 98 |
  • Be careful not to mix up semester 1 and semester 2!
  • 99 |
  • Some classes do not run every week. Always double check with your personal timetable.
  • 100 |
  • Sometimes, timetables for a course are updated or changed by UQ. To update a course in UQTP, just click the update button.
  • 101 |
  • Click in a black space to add a custom timetable activity. You can use this to plan things like lunch and work hours.
  • 102 |
103 |
104 |
105 |
106 |
107 | ); 108 | } 109 | 110 | export default Main; -------------------------------------------------------------------------------- /src/serviceWorker.ts: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | type Config = { 24 | onSuccess?: (registration: ServiceWorkerRegistration) => void; 25 | onUpdate?: (registration: ServiceWorkerRegistration) => void; 26 | }; 27 | 28 | export function register(config?: Config) { 29 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 30 | // The URL constructor is available in all browsers that support SW. 31 | const publicUrl = new URL( 32 | process.env.PUBLIC_URL, 33 | window.location.href 34 | ); 35 | if (publicUrl.origin !== window.location.origin) { 36 | // Our service worker won't work if PUBLIC_URL is on a different origin 37 | // from what our page is served on. This might happen if a CDN is used to 38 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 39 | return; 40 | } 41 | 42 | window.addEventListener('load', () => { 43 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 44 | 45 | if (isLocalhost) { 46 | // This is running on localhost. Let's check if a service worker still exists or not. 47 | checkValidServiceWorker(swUrl, config); 48 | 49 | // Add some additional logging to localhost, pointing developers to the 50 | // service worker/PWA documentation. 51 | navigator.serviceWorker.ready.then(() => { 52 | console.log( 53 | 'This web app is being served cache-first by a service ' + 54 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 55 | ); 56 | }); 57 | } else { 58 | // Is not localhost. Just register service worker 59 | registerValidSW(swUrl, config); 60 | } 61 | }); 62 | } 63 | } 64 | 65 | function registerValidSW(swUrl: string, config?: Config) { 66 | navigator.serviceWorker 67 | .register(swUrl) 68 | .then(registration => { 69 | registration.onupdatefound = () => { 70 | const installingWorker = registration.installing; 71 | if (installingWorker == null) { 72 | return; 73 | } 74 | installingWorker.onstatechange = () => { 75 | if (installingWorker.state === 'installed') { 76 | if (navigator.serviceWorker.controller) { 77 | // At this point, the updated precached content has been fetched, 78 | // but the previous service worker will still serve the older 79 | // content until all client tabs are closed. 80 | console.log( 81 | 'New content is available and will be used when all ' + 82 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 83 | ); 84 | 85 | // Execute callback 86 | if (config && config.onUpdate) { 87 | config.onUpdate(registration); 88 | } 89 | } else { 90 | // At this point, everything has been precached. 91 | // It's the perfect time to display a 92 | // "Content is cached for offline use." message. 93 | console.log('Content is cached for offline use.'); 94 | 95 | // Execute callback 96 | if (config && config.onSuccess) { 97 | config.onSuccess(registration); 98 | } 99 | } 100 | } 101 | }; 102 | }; 103 | }) 104 | .catch(error => { 105 | console.error('Error during service worker registration:', error); 106 | }); 107 | } 108 | 109 | function checkValidServiceWorker(swUrl: string, config?: Config) { 110 | // Check if the service worker can be found. If it can't reload the page. 111 | fetch(swUrl, { 112 | headers: { 'Service-Worker': 'script' } 113 | }) 114 | .then(response => { 115 | // Ensure service worker exists, and that we really are getting a JS file. 116 | const contentType = response.headers.get('content-type'); 117 | if ( 118 | response.status === 404 || 119 | (contentType != null && contentType.indexOf('javascript') === -1) 120 | ) { 121 | // No service worker found. Probably a different app. Reload the page. 122 | navigator.serviceWorker.ready.then(registration => { 123 | registration.unregister().then(() => { 124 | window.location.reload(); 125 | }); 126 | }); 127 | } else { 128 | // Service worker found. Proceed as normal. 129 | registerValidSW(swUrl, config); 130 | } 131 | }) 132 | .catch(() => { 133 | console.log( 134 | 'No internet connection found. App is running in offline mode.' 135 | ); 136 | }); 137 | } 138 | 139 | export function unregister() { 140 | if ('serviceWorker' in navigator) { 141 | navigator.serviceWorker.ready.then(registration => { 142 | registration.unregister(); 143 | }); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useReducer } from 'react'; 2 | import Emoji from 'a11y-react-emoji' 3 | import './App.scss'; 4 | 5 | import StateErrorBoundary from './components/StateErrorBoundary'; 6 | import Main from './Main'; 7 | import { FaSignInAlt, FaSignOutAlt, FaUser } from 'react-icons/fa'; 8 | import { auth, userFirestoreDocRef, mergeAnonymousData } from './state/firebase'; 9 | import { NewFirebaseLoginProps, NewFirebaseLogin } from './components/FirebaseSignIn'; 10 | import { Modal, ModalCard } from './components/Modal'; 11 | import UserInfoView from './components/UserInfoView'; 12 | import { useAuthState } from 'react-firebase-hooks/auth'; 13 | import { IS_DEBUG } from './isDebug'; 14 | 15 | 16 | const App = () => { 17 | // const user = useStoreState(s => s.user); 18 | // const setUser = useStoreActions(s => s.setUser); 19 | 20 | const [user, authLoading] = useAuthState(auth); 21 | const forceUpdate = useReducer(x => !x, false)[1]; 22 | const showMainSignIn = user == null; 23 | //console.log({authUser, authLoading, authError}); 24 | 25 | const [showSignInModal, setShowSignIn] = useState(false); 26 | const [showUserInfo, setShowUserInfo] = useState(false); 27 | 28 | const signOut = async () => { 29 | setShowUserInfo(false); 30 | setShowSignIn(false); 31 | 32 | try { 33 | const ref = userFirestoreDocRef(user?.uid ?? null); 34 | IS_DEBUG && console.log(user, ref); 35 | if ((user?.isAnonymous ?? false) && ref) 36 | await ref.remove(); 37 | } catch (e) { 38 | console.error('failed to delete anonymous data', e); 39 | } 40 | 41 | await auth.signOut(); 42 | }; 43 | 44 | const signInSuccess = () => { 45 | setShowUserInfo(false); 46 | setShowSignIn(false); 47 | forceUpdate(); 48 | return false; 49 | }; 50 | 51 | const signInConfig: NewFirebaseLoginProps = { 52 | signInSuccess, 53 | anonymousMergeConflict: async (cred: firebase.auth.AuthCredential) => { 54 | await mergeAnonymousData(cred); 55 | signInSuccess(); 56 | }, 57 | }; 58 | 59 | const [firebaseLoginElement] = useState(() => ); 60 | 61 | const displayName = user?.displayName ?? user?.email ?? user?.phoneNumber; 62 | const isAnon = user?.isAnonymous ?? false; 63 | const uid = user?.uid; 64 | const photo = user?.photoURL; 65 | 66 | const isEmailLink = auth.isSignInWithEmailLink(window.location.href); 67 | 68 | return <> 69 | 70 |
71 | {showSignInModal && firebaseLoginElement} 72 |
73 |
74 | 77 |
78 | 79 |
80 |
81 | {isAnon &&
Anonymous data is deleted on log out.
} 82 |
83 | 85 |
86 |
87 | }> 88 | {user && } 89 |
90 |
91 |
92 |
93 |
94 |
95 |

 UQTP  {process.env.REACT_APP_VERSION} Unofficial

96 |

97 | Plan your timetable where Allocate+ can't hurt you. 98 |

99 |

100 | UQTP is a new timetable planner for UQ. Works on mobile and syncs to the cloud! 101 |

102 |
103 |
104 | {uid &&
105 |
setShowUserInfo(true)}> 106 | 107 | {(photo && displayName) 108 | ? {displayName} 109 | : } 110 | 111 | {displayName ?? <>(anonymous {uid?.substr(0, 4)})} 112 |
113 | {isAnon && } 116 |
} 117 |
118 |
119 |
120 |
121 |
122 |
123 | 124 |
125 | {!showSignInModal && (showMainSignIn || isEmailLink) && firebaseLoginElement} 126 |
127 | {user &&
} 128 |
129 |
130 |
131 |
132 |

133 | UQTP is an (unofficial) timetable planner for UQ, built by  134 | Kait Lam. 135 | The source code is available on GitHub. 136 |

137 |

138 | 139 | {process.env.REACT_APP_BUILD_TIME} / {process.env.REACT_APP_COMMIT} 140 | 141 |

142 |
143 |
; 144 | ; 145 | } 146 | 147 | export default App; 148 | -------------------------------------------------------------------------------- /src/components/TimetableSelector.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, createRef, useEffect, memo, useMemo } from "react" 2 | import { FaSave, FaPencilAlt, FaCopy, FaPlus, FaTrash } from "react-icons/fa"; 3 | import { Timetable } from "../state/types"; 4 | import _ from "lodash"; 5 | import { useStoreState, useTimetableActions } from "../state/persistState"; 6 | import { UIStore } from "../state/uiState"; 7 | 8 | type TimetableTagProps = { 9 | id: string, 10 | timetable: Timetable, 11 | current: string, 12 | onClick: (ev: React.MouseEvent) => any, 13 | } 14 | 15 | const TimetableTag = (props: TimetableTagProps) => 16 |
17 |
18 | 22 | {/* */} 23 |
24 |
; 25 | 26 | export const TimetableSelector = memo(() => { 27 | const timetables = useStoreState(s => s.timetables); 28 | const current = useStoreState(s => s.current); 29 | const { select, new: new_, copy, delete: delete_, rename } = useTimetableActions(); 30 | 31 | const reset = UIStore.useStoreActions(s => s.reset); 32 | 33 | const savedValid = !!current; 34 | 35 | const renameRef = createRef(); 36 | const [isRenaming, setIsRenaming] = useState(false); 37 | const currentName = timetables?.[current]?.name ?? 'invalid timetable name'; 38 | const [name, setName] = useState(currentName); 39 | const [confirmDelete, setConfirmDelete] = useState(false); 40 | 41 | useEffect(() => { 42 | if (!isRenaming) setName(currentName); 43 | }, [isRenaming, currentName]); 44 | 45 | const callbacks = useMemo(() => { 46 | const onClickTag = (ev: React.MouseEvent) => { 47 | setIsRenaming(false); 48 | const newTimetable = (ev.target as HTMLButtonElement).value; 49 | if (newTimetable !== current) { 50 | select(newTimetable); 51 | // reset(); 52 | } 53 | }; 54 | 55 | const onClickRename = (ev: React.MouseEvent) => { 56 | if (isRenaming) { 57 | //console.log('clicked while renaming'); 58 | rename(name); 59 | setIsRenaming(false); 60 | } else { 61 | //console.log('entering renaming mode'); 62 | setName(currentName); 63 | renameRef.current!.focus(); 64 | setIsRenaming(true); 65 | } 66 | ev.stopPropagation(); 67 | ev.preventDefault(); 68 | } 69 | 70 | const onClickNew = () => { 71 | new_(); 72 | reset(); 73 | }; 74 | 75 | const onClickDuplicate = () => copy(); 76 | 77 | const onClickDelete = () => { 78 | if (confirmDelete) { 79 | delete_(current); 80 | setConfirmDelete(false); 81 | } else { 82 | setConfirmDelete(true); 83 | } 84 | }; 85 | 86 | const onClickCancel = () => { 87 | setConfirmDelete(false); 88 | }; 89 | 90 | return {onClickTag, onClickRename, onClickNew, onClickDuplicate, onClickDelete, onClickCancel}; 91 | }, [confirmDelete, copy, current, currentName, delete_, isRenaming, name, new_, rename, renameRef, reset, select]); 92 | 93 | return
94 |
95 |
96 | isRenaming && setName(ev.target.value)} 99 | placeholder="timetable name…" 100 | readOnly={!isRenaming} 101 | style={{ width: '100%', border: 'none', outline: 'none', lineHeight: 1 }} /> 102 |
103 |
104 |
105 |
106 |
107 | 111 | {!isRenaming && <> 112 | 115 | 118 | } 119 |
120 |
121 | {!isRenaming &&
122 | 125 |
} 126 | {/* {name !== null &&

Timetable "{name}" already exists.

} */} 127 |
128 |
129 | 130 |
131 |
132 | {_.sortBy(Object.entries(timetables), ([, v]) => v.name).map( 133 | ([id, t]) => 134 | )} 135 |
136 |
137 |
138 | 139 | {!savedValid &&
140 |
141 | Error: The selected timetable "{currentName}" could not be loaded. 142 |
143 |
} 144 | 145 |
146 |
147 |
148 |
149 |

Delete timetable?

150 | 151 |
152 |
153 | Are you sure you want to delete "{currentName}"? 154 |
155 |
156 | 159 | 160 |
161 |
162 |
163 |
; 164 | }); 165 | 166 | export default TimetableSelector; -------------------------------------------------------------------------------- /src/logic/functions.ts: -------------------------------------------------------------------------------- 1 | import { CourseEvent, CourseActivity, CourseActivityGroup, ClockTime, RGBAColour } from "../state/types"; 2 | import _ from "lodash"; 3 | import { memo } from "easy-peasy"; 4 | 5 | export const computeDayTimeArrays = (sessions: CourseEvent[]) => { 6 | const byDayTime = _.range(7) 7 | .map(() => _.range(24).map(() => [] as (CourseEvent | null)[])); 8 | 9 | 10 | sessions.sort(compareCourseEvents); 11 | 12 | let currentDay = 0; 13 | let currentHour = 0; 14 | let matrix: (CourseEvent | null)[][] = []; 15 | let matrixColumns = 0; 16 | // last hour in matrix is currentHour + currentMatrix.length - 1 17 | 18 | const checkFinishedMatrix = (next: CourseEvent | null) => { 19 | if (next == null || next.day > currentDay 20 | || next.time.hour > currentHour + matrix.length - 1) { 21 | for (let r = 0; r < matrix.length; r++) { 22 | if (r + currentHour >= 24) break; 23 | for (let c = 0; c < matrixColumns; c++) { 24 | byDayTime[currentDay][r + currentHour][c] = matrix[r][c]; //c === 0 ? matrix[r][c] : null; 25 | } 26 | } 27 | //console.log('flushed matrix', matrix); 28 | 29 | if (next != null) { 30 | currentDay = next.day; 31 | currentHour = next.time.hour; 32 | matrix = []; 33 | matrixColumns = 0; 34 | } 35 | } 36 | } 37 | 38 | const validInsertPosition = (event: CourseEvent, row: number, col: number) => { 39 | const hours = Math.ceil(event.duration / 60); 40 | for (let i = 0; i < hours; i++) { 41 | if (matrix[row+i][col] != null) 42 | return false; 43 | } 44 | return true; 45 | }; 46 | 47 | const insertIntoMatrix = (event: CourseEvent) => { 48 | const startRow = event.time.hour - currentHour; 49 | const hours = Math.ceil(event.duration / 60); 50 | 51 | // add rows as necessary 52 | while (startRow + hours > matrix.length) { 53 | matrix.push([]); 54 | } 55 | 56 | // find first available position from the left 57 | let col; 58 | for (col = 0; col < matrixColumns; col++) { 59 | if (validInsertPosition(event, startRow, col)) { 60 | break; 61 | } 62 | } 63 | 64 | if (col + 1 > matrixColumns) 65 | matrixColumns = col + 1; 66 | 67 | // place into this position 68 | for (let i = 0; i < hours; i++) { 69 | matrix[startRow + i][col] = event; 70 | } 71 | 72 | } 73 | 74 | 75 | for (const event of sessions) { 76 | checkFinishedMatrix(event); 77 | insertIntoMatrix(event); 78 | } 79 | checkFinishedMatrix(null); 80 | 81 | 82 | 83 | // sessions.forEach(session => { 84 | // //console.log(session); 85 | // // we know the event starts somewhere in this hour. 86 | // byDayTime[session.day][session.time.hour].push(session); 87 | // // start time of event, in minutes past midnight 88 | // const startTime = session.time.hour * 60; 89 | // // compute ending time of event 90 | // const endHour = session.time.hour * 60 + session.time.minute + session.duration; 91 | // for (let i = session.time.hour + 1; i * 60 < endHour; i++) { 92 | // if (i >= 24) throw new Error('event has continued into next day. unsupported!'); 93 | // //console.log(session); 94 | // //console.log('continued into hour ' + i); 95 | 96 | // byDayTime[session.day][i].push(session); 97 | // } 98 | // }); 99 | return byDayTime; 100 | }; 101 | 102 | export const compareCourseEvents = (a: CourseEvent, b: CourseEvent) => { 103 | if (a.day !== b.day) 104 | return a.day - b.day; 105 | if (a.time.hour !== b.time.hour) 106 | return a.time.hour - b.time.hour; 107 | if (a.time.minute !== b.time.minute) 108 | return a.time.minute - b.time.minute; 109 | if (a.duration !== b.duration) 110 | return a.duration - b.duration; 111 | if (a.course !== b.course) 112 | return a.course.localeCompare(b.course); 113 | return 0; 114 | }; 115 | 116 | export const getCourseGroups = memo((events: CourseEvent[]) => { 117 | //console.log("computing getCourseGroups for ", events); 118 | return _(events) 119 | .map(({course, activity, activityType, group}) => ({course, activity, activityType, group}) as CourseActivityGroup) 120 | .uniqWith(_.isEqual).value(); 121 | }, 10); 122 | 123 | // returns a string like CSSE2310|PRA1 124 | export const makeActivityKey = (session: CourseActivity) => 125 | session.course + '|' + session.activity; 126 | 127 | export const sessionEndTime = (e: CourseEvent) => { 128 | const endMinutes = e.time.hour * 60 + e.time.minute + e.duration; 129 | return { 130 | hour: Math.floor(endMinutes / 60), 131 | minute: endMinutes % 60, 132 | }; 133 | } 134 | 135 | export const formatTime = (t: ClockTime) => { 136 | const d = new Date(2020, 1, 1, t.hour, t.minute); 137 | return d.toLocaleTimeString(undefined, {hour: 'numeric', minute: 'numeric'}); 138 | } 139 | 140 | export const makeActivityGroupKey = (g: CourseActivityGroup) => 141 | `${g.course}|${g.activity}|${g.group}`; 142 | 143 | export const makeActivitySessionKey = (s: CourseEvent) => 144 | `${s.course}|${s.activity}|${s.group}|${s.day}${s.time.hour}|${s.time.minute}|${s.duration}|${s.campus}`; 145 | 146 | export const getCourseCode = (longCode: string) => longCode.split('_')[0]; 147 | 148 | export const isHighlighted = (session: CourseActivityGroup, highlight: CourseActivity | null) => 149 | session.course === highlight?.course && session.activity === highlight.activity; 150 | 151 | export const coerceToArray = (arg?: T | T[]) => 152 | arg === undefined ? [] : (Array.isArray(arg) ? arg : [arg]); 153 | 154 | const removeNullValues = (arg: T): T => { 155 | for (const key of Object.keys(arg)) { 156 | // @ts-ignore 157 | if (arg[key] == null) 158 | // @ts-ignore 159 | delete arg[key]; 160 | } 161 | return arg; 162 | } 163 | 164 | export const coerceToObject = (arg: {[k: string]: T} | T[]): {[k: string]: T} => 165 | // @ts-ignore 166 | Array.isArray(arg) ? removeNullValues(Object.assign({}, arg)) : (arg ?? {}); 167 | 168 | export const CUSTOM_COURSE = '(custom)'; 169 | 170 | export const makeCustomSession = (label: string, day: number, hour: number, duration: number, group: string): CourseEvent => { 171 | return { 172 | course: CUSTOM_COURSE, 173 | activity: label, 174 | group, 175 | day, 176 | time: {hour: hour, minute: 0}, 177 | duration, 178 | 179 | description: '', 180 | dates: '', 181 | campus: '', 182 | location: '', 183 | }; 184 | }; 185 | 186 | export const toCSSColour = (c?: RGBAColour): any => 187 | // @ts-ignore 188 | typeof c == 'string' ? c : (c ? `rgba(${c.r}, ${c.g}, ${c.b}, ${c.a ?? 1})` : undefined); -------------------------------------------------------------------------------- /src/components/SessionSelectors.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo, useCallback, useState, memo } from 'react'; 2 | import { CourseActivityGroup, CourseActivity, Course, RGBAColour, DEFAULT_COURSE_COLOUR } from '../state/types'; 3 | import { CUSTOM_COURSE, coerceToObject } from '../logic/functions'; 4 | import { useStoreActions, useStoreState } from '../state/persistState'; 5 | import { searchCourses } from '../logic/api'; 6 | import { FaSyncAlt, FaCheck, FaExclamationTriangle, FaTimes } from 'react-icons/fa'; 7 | import classNames from 'classnames'; 8 | 9 | import './SessionSelectors.scss'; 10 | import { ColourPickerButton } from './ColourPickerButton'; 11 | 12 | const ActivityGroupCheckbox = ({ course, activity, group, selected }: CourseActivityGroup & { selected: boolean }) => { 13 | const setOneSelectedGroup = useStoreActions(s => s.setOneSelectedGroup); 14 | 15 | const onChange = (ev: React.ChangeEvent) => 16 | setOneSelectedGroup({ course, activity, group, selected: ev.target.checked }); 17 | 18 | const id = `${course}-${activity}-${group}`; 19 | 20 | return ; 25 | }; 26 | 27 | // component for selecting groups of a particular activity, e.g. LEC1 01 02 03... 28 | const ActivityGroupSelector = memo(({ course, activity }: CourseActivity) => { 29 | const selected = useStoreState(s => s.selected?.[course]?.[activity]) ?? {}; 30 | const groups = coerceToObject(useStoreState(s => s.currentTimetable.sessions?.[course]?.[activity]) ?? {}); 31 | 32 | const numSelected = Object.keys(selected).length; 33 | const groupKeys = useMemo(() => Object.keys(groups), [groups]); 34 | 35 | let countClass = 'has-text-success-dark has-text-weight-medium '; 36 | if (numSelected === 0) 37 | countClass = 'has-text-danger-dark has-text-weight-medium '; 38 | else if (numSelected === 1 && groupKeys.length === 1) 39 | countClass = 'has-text-grey '; 40 | 41 | const countText = `(${numSelected}/${groupKeys.length})`; 42 | 43 | return ( 44 |
45 |
46 | 47 | 48 | {activity} 49 |   50 | {countText} 51 | 52 | 53 |
54 | {groupKeys.sort().map(group => )} 56 |
57 | 58 |
59 |
60 | ); 61 | }); 62 | 63 | enum UpdatingState { 64 | IDLE, UPDATING, DONE, ERROR 65 | } 66 | 67 | const CourseSessionSelector = memo(({ course }: Course) => { 68 | 69 | const activities = useStoreState(s => s.activities[course]) ?? {}; 70 | const visible = useStoreState(s => s.currentTimetable.courseVisibility?.[course]) ?? true; 71 | 72 | const setCourseVisibility = useStoreActions(s => s.setCourseVisibility); 73 | const deleteCourse = useStoreActions(s => s.deleteCourse); 74 | const updateSessions = useStoreActions(s => s.updateCourseSessions); 75 | 76 | const colour = useStoreState(s => s.currentTimetable.courseColours?.[course]) ?? DEFAULT_COURSE_COLOUR; 77 | const setCourseColour = useStoreActions(s => s.setCourseColour); 78 | 79 | const setColour = useCallback((colour: RGBAColour) => setCourseColour({ course, colour }), [course, setCourseColour]); 80 | 81 | const setVisibleCallback = useCallback(() => { 82 | setCourseVisibility({ course, visible: !visible }); 83 | }, [setCourseVisibility, course, visible]); 84 | 85 | const deleteCourseCallback = useCallback(() => { 86 | deleteCourse(course); 87 | }, [deleteCourse, course]); 88 | 89 | //console.log(activities); 90 | const activityTypes = useMemo(() => Object.keys(activities).sort(), [activities]); 91 | 92 | 93 | const [updating, setUpdating] = useState(UpdatingState.IDLE); 94 | const [updateError, setUpdateError] = useState(''); 95 | 96 | const update = useCallback(async () => { 97 | try { 98 | setUpdating(UpdatingState.UPDATING); 99 | setUpdateError(''); 100 | const results = Object.values(await searchCourses(course)); 101 | if (results.length !== 1) { 102 | throw new Error(`Found ${results.length} courses matching ${course}.`); 103 | } 104 | updateSessions(results[0].activities); 105 | setUpdating(UpdatingState.DONE); 106 | } catch (e) { 107 | setUpdateError(e.toString()); 108 | setUpdating(UpdatingState.ERROR); 109 | console.error(e); 110 | } 111 | }, [course, updateSessions]); 112 | 113 | const [icon, iconClass] = useMemo(() => { 114 | switch (updating) { 115 | case UpdatingState.IDLE: 116 | return [, null]; 117 | case UpdatingState.UPDATING: 118 | return [null, 'is-loading']; 119 | case UpdatingState.DONE: 120 | return [, null]; 121 | case UpdatingState.ERROR: 122 | return [, 'is-danger']; 123 | } 124 | }, [updating]); 125 | 126 | return ( 127 |
128 |
129 | 134 |
135 | {} 142 | 143 | 149 |
150 |
151 | 152 |
153 |
154 | {activityTypes.map((activity) => 155 | )} 156 |
157 |
158 | 159 |
); 160 | }); 161 | 162 | const SessionSelectors = memo(() => { 163 | 164 | const sessions = useStoreState(s => s.sessions); 165 | const courses = useMemo(() => Object.keys(sessions).sort(), [sessions]); 166 | 167 | return
168 | {courses.map(c =>
169 | 170 |
)} 171 |
; 172 | }); 173 | 174 | export default SessionSelectors; -------------------------------------------------------------------------------- /src/components/Timetable.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, useMemo, useCallback } from 'react'; 2 | import _ from 'lodash'; 3 | import { CourseEvent, DAY_NAMES } from '../state/types'; 4 | import { computeDayTimeArrays, makeActivitySessionKey, getCourseCode, formatTime, sessionEndTime, CUSTOM_COURSE } from '../logic/functions'; 5 | 6 | import { FaLock } from 'react-icons/fa'; 7 | 8 | import './Timetable.scss'; 9 | import { UIStore, TimetableMode } from '../state/uiState'; 10 | import { useStoreState, useStoreActions } from '../state/persistState'; 11 | 12 | import { CourseColoursContainer } from './styles/CourseColours'; 13 | import classNames from 'classnames'; 14 | 15 | 16 | const START_HOUR = 8; 17 | const END_HOUR = 19; 18 | 19 | const HOURS = _.range(START_HOUR, END_HOUR + 1); 20 | const DAYS = _.range(5); 21 | 22 | const EMPTY_SESSION: CourseEvent = { 23 | course: '(empty)', 24 | activity: '', 25 | group: '', 26 | time: { hour: -1, minute: 0 }, 27 | description: '', 28 | dates: '', 29 | day: 0, 30 | campus: '', 31 | location: '', 32 | duration: 60, 33 | }; 34 | 35 | 36 | type TimetableSessionProps = { 37 | session: CourseEvent, 38 | clash?: boolean, 39 | numInHour: number 40 | } 41 | 42 | const TimetableSession = memo(({ session, clash, numInHour }: TimetableSessionProps) => { 43 | 44 | const deleteSession = useStoreActions(s => s.deleteActivitySession); 45 | const numGroups = useStoreState(s => Object.keys(s.sessions[session.course]?.[session.activity] ?? {}).length); 46 | 47 | const highlight = UIStore.useStoreState(s => s.highlight); 48 | const thisHighlighted = UIStore.useStoreState(s => s.isHighlighted(session)); 49 | const selectHighlightedGroup = UIStore.useStoreState(s => s.selectHighlightedGroup); 50 | const setHighlight = UIStore.useStoreActions(s => s.setHighlight); 51 | const mode = UIStore.useStoreState(s => s.timetableMode); 52 | 53 | const colourClass = CourseColoursContainer.useContainer().classes[session.course] ?? ''; 54 | 55 | const s = session; 56 | const isCustom = s.course === CUSTOM_COURSE; 57 | const isEmpty = session.course === EMPTY_SESSION.course; 58 | 59 | const courseClass = isEmpty ? 'empty' : colourClass; 60 | 61 | 62 | const text = `${s.activity} ${s.group} 63 | ${s.course} 64 | ${DAY_NAMES[session.day]} ${formatTime(s.time)} - ${formatTime(sessionEndTime(s))} (${s.duration} minutes) 65 | ${s.location}`; 66 | 67 | const locked = numGroups <= 1; 68 | let highlightClass = locked ? 'locked ' : ''; 69 | highlightClass += clash ? 'clash ' : ''; 70 | highlightClass += thisHighlighted ? 'highlighted ' : ''; 71 | 72 | const startMinute = session.time.minute; 73 | const minutes = Math.min(60 * (END_HOUR + 1 - session.time.hour), session.duration); 74 | 75 | const styles = useMemo(() => { 76 | return { 77 | // left: `${100*index/numInHour}%`, 78 | width: `${100 / numInHour}%`, 79 | height: `${minutes * 100 / 60}%`, 80 | top: `${startMinute * 100 / 60}%`, 81 | // visibility: isEmpty ? 'hidden' : 'unset', 82 | } as const; 83 | }, [minutes, numInHour, startMinute]); 84 | 85 | 86 | const onClick = useCallback((ev: React.MouseEvent) => { 87 | ev.stopPropagation(); 88 | ev.preventDefault(); 89 | 90 | if (isEmpty) return; 91 | 92 | switch (mode) { 93 | case TimetableMode.VIEW: 94 | alert(text); 95 | break; 96 | case TimetableMode.CUSTOM: 97 | if (isCustom) { 98 | if (window.confirm('Delete this custom activity?\n\n' + text)) { 99 | deleteSession(session); 100 | } 101 | break; 102 | } 103 | // eslint-disable-next-line no-fallthrough 104 | case TimetableMode.EDIT: 105 | if (highlight && thisHighlighted) { 106 | selectHighlightedGroup(session.group); 107 | setHighlight(null); 108 | } else { 109 | setHighlight(session); 110 | } 111 | break; 112 | } 113 | }, [deleteSession, highlight, isCustom, isEmpty, mode, selectHighlightedGroup, session, setHighlight, text, thisHighlighted]); 114 | 115 | return ( 116 |
118 | 119 | {!isEmpty && <> 120 | 121 | {session.activity} 122 |   123 | {locked ? : session.group} 124 | 125 | 126 |
127 | 128 | {getCourseCode(session.course)} 129 | 130 | 131 |
132 | {session.location.split(' - ')[0]} 133 | } 134 |
135 | ); 136 | }); 137 | 138 | type HourCellProps = { 139 | day: number, 140 | hour: number, 141 | 142 | sessions: (CourseEvent | null)[], 143 | } 144 | 145 | const HourCell = memo(({ day, hour, sessions }: HourCellProps) => { 146 | 147 | const mode = UIStore.useStoreState(s => s.timetableMode); 148 | const addCustomEvent = useStoreActions(s => s.addCustomEvent); 149 | 150 | const onClickEmpty = useCallback(() => { 151 | if (mode !== TimetableMode.CUSTOM) return; 152 | const input = prompt('Enter label and (optional) duration for custom activity (e.g. "Work 60"):')?.trim(); 153 | if (!input) return; 154 | 155 | let durationInput = NaN; 156 | let label = input; 157 | const match = /(\d+)$/.exec(input); 158 | if (match) { 159 | durationInput = parseInt(match[1]); 160 | label = input.slice(0, -match[1].length).trimRight(); 161 | } 162 | const duration = isNaN(durationInput) ? 60 : durationInput; 163 | 164 | addCustomEvent({ day, hour, label: label || 'activity', duration }); 165 | }, [addCustomEvent, day, hour, mode]); 166 | 167 | const hourEmpty = sessions.length === 0; 168 | 169 | return
170 |
171 | {sessions.map((s, i) => { 172 | const empty = s == null || s.time.hour !== hour; 173 | const session = empty ? EMPTY_SESSION : s!; 174 | const key = empty ? `empty-${i}` : makeActivitySessionKey(session); 175 | return 1} 177 | numInHour={sessions.length} /> 178 | })} 179 |
180 |
; 181 | }); 182 | 183 | type CustomEvent = { day: number, hour: number, duration: number, label: string }; 184 | 185 | const Timetable = () => { 186 | 187 | const sessions = useStoreState(s => s.sessions); 188 | const isSessionVisible = useStoreState(s => s.isSessionVisible); 189 | 190 | const isHighlighted = UIStore.useStoreState(s => s.isHighlighted); 191 | const highlight = UIStore.useStoreState(s => s.highlight); 192 | const isWeekVisible = UIStore.useStoreState(s => s.isWeekVisible); 193 | const mode = UIStore.useStoreState(s => s.timetableMode); 194 | 195 | const visibleSessions = useMemo(() => { 196 | const visible = []; 197 | 198 | for (const c of Object.keys(sessions)) { 199 | for (const a of Object.keys(sessions[c])) { 200 | for (const g of Object.keys(sessions[c][a])) { 201 | 202 | const vals = Object.values(sessions[c][a][g]); 203 | if (vals.length === 0) continue; 204 | 205 | const first = vals[0]; 206 | if (isSessionVisible(first) || isHighlighted(first)) { 207 | for (const session of vals) { 208 | if (isWeekVisible(session)) 209 | visible.push(session); 210 | } 211 | } 212 | } 213 | } 214 | } 215 | return visible; 216 | }, [sessions, isSessionVisible, isHighlighted, isWeekVisible]); 217 | 218 | const byDayTime = useMemo(() => computeDayTimeArrays(visibleSessions), 219 | [visibleSessions]); 220 | 221 | 222 | const classes = classNames("timetable", { 223 | highlighting: highlight, 224 | editing: mode === TimetableMode.CUSTOM, 225 | }); 226 | return
227 |
228 | {DAY_NAMES.slice(0, 5).map(d => 229 |
{d}
230 | )} 231 | 232 | {HOURS.map((h) => 233 | 234 |
{h}
235 | {DAYS.map(d => 236 | 237 | )} 238 |
)} 239 |
; 240 | }; 241 | 242 | export default memo(Timetable); -------------------------------------------------------------------------------- /src/state/schema.ts: -------------------------------------------------------------------------------- 1 | import { EMPTY_TIMETABLE, TimetablesState } from "./types" 2 | import uuidv4 from 'uuid/v4'; 3 | 4 | export type UserState = { 5 | uid: string, 6 | name: string | null, 7 | email: string | null, 8 | photo: string | null, 9 | phone: string | null, 10 | providers: string[] | null, 11 | isAnon: boolean, 12 | } | null; 13 | 14 | export type StateMetadata = { 15 | _meta?: { 16 | version: T, 17 | } 18 | } 19 | 20 | export type PersistState = { 21 | timetables: TimetablesState, 22 | current: string, 23 | user: UserState, 24 | } & StateMetadata<13>; 25 | 26 | export const CURRENT_VERSION = 13; 27 | 28 | // const testData = [{"course":"MATH3401_S1_STLUC_IN","description":"Complex Analysis","activity":"LEC1","group":"01","day":1,"time":{"hour":9,"minute":0},"campus":"STLUC","location":"07-222 - Parnell Building, Lecture Theatre","duration":60,"dates":"25/2-7/4, 21/4-26/5","activityType":"LEC"},{"course":"MATH3401_S1_STLUC_IN","description":"Complex Analysis","activity":"LEC2","group":"01","day":3,"time":{"hour":10,"minute":0},"campus":"STLUC","location":"07-222 - Parnell Building, Lecture Theatre","duration":60,"dates":"27/2-9/4, 23/4-28/5","activityType":"LEC"},{"course":"MATH3401_S1_STLUC_IN","description":"Complex Analysis","activity":"LEC3","group":"01","day":4,"time":{"hour":10,"minute":0},"campus":"STLUC","location":"07-222 - Parnell Building, Lecture Theatre","duration":60,"dates":"28/2-10/4, 24/4-29/5","activityType":"LEC"},{"course":"MATH3401_S1_STLUC_IN","description":"Complex Analysis","activity":"TUT1","group":"01","day":1,"time":{"hour":10,"minute":0},"campus":"STLUC","location":"83-C416 - Hartley Teakle Building, Collaborative Room","duration":60,"dates":"3/3-7/4, 21/4-26/5","activityType":"TUT"},{"course":"MATH3401_S1_STLUC_IN","description":"Complex Analysis","activity":"TUT1","group":"02","day":0,"time":{"hour":9,"minute":0},"campus":"STLUC","location":"32-208 - Gordon Greenwood Building, Collaborative Room","duration":60,"dates":"2/3-6/4, 20/4-25/5","activityType":"TUT"},{"course":"MATH3401_S1_STLUC_IN","description":"Complex Analysis","activity":"TUT1","group":"03","day":1,"time":{"hour":12,"minute":0},"campus":"STLUC","location":"09-222 - Michie Building, Tutorial Room","duration":60,"dates":"3/3-7/4, 21/4-26/5","activityType":"TUT"},{"course":"MATH3401_S1_STLUC_IN","description":"Complex Analysis","activity":"TUT1","group":"04","day":1,"time":{"hour":11,"minute":0},"campus":"STLUC","location":"78-224 - General Purpose South, Collaborative Room","duration":60,"dates":"3/3-7/4, 21/4-26/5","activityType":"TUT"},{"course":"MATH3401_S1_STLUC_IN","description":"Complex Analysis","activity":"TUT1","group":"05","day":0,"time":{"hour":10,"minute":0},"campus":"STLUC","location":"78-224 - General Purpose South, Collaborative Room","duration":60,"dates":"2/3-6/4, 20/4-25/5","activityType":"TUT"},{"course":"MATH3401_S1_STLUC_IN","description":"Complex Analysis","activity":"TUT1","group":"06","day":3,"time":{"hour":11,"minute":0},"campus":"STLUC","location":"78-224 - General Purpose South, Collaborative Room","duration":60,"dates":"5/3-9/4, 23/4-28/5","activityType":"TUT"},{"course":"MATH3401_S1_STLUC_IN","description":"Complex Analysis","activity":"TUT1","group":"07","day":3,"time":{"hour":12,"minute":0},"campus":"STLUC","location":"78-224 - General Purpose South, Collaborative Room","duration":60,"dates":"5/3-9/4, 23/4-28/5","activityType":"TUT"},{"course":"MATH3401_S1_STLUC_IN","description":"Complex Analysis","activity":"TUT1","group":"08","day":3,"time":{"hour":13,"minute":0},"campus":"STLUC","location":"78-224 - General Purpose South, Collaborative Room","duration":60,"dates":"5/3-9/4, 23/4-28/5","activityType":"TUT"},{"course":"STAT3001_S1_STLUC_IN","description":"Mathematical Statistics","activity":"LEC1","group":"01","day":0,"time":{"hour":14,"minute":0},"campus":"STLUC","location":"05-213 - Richards Building, Lecture Theatre","duration":60,"dates":"24/2-6/4, 20/4-25/5","activityType":"LEC"},{"course":"STAT3001_S1_STLUC_IN","description":"Mathematical Statistics","activity":"LEC2","group":"01","day":2,"time":{"hour":13,"minute":0},"campus":"STLUC","location":"76-228 - Molecular Biosciences, Lecture Theatre","duration":60,"dates":"26/2-8/4, 22/4-27/5","activityType":"LEC"},{"course":"STAT3001_S1_STLUC_IN","description":"Mathematical Statistics","activity":"LEC3","group":"01","day":4,"time":{"hour":14,"minute":0},"campus":"STLUC","location":"81-313 - Otto Hirschfeld Building, Lecture Theatre","duration":60,"dates":"28/2-10/4, 24/4-29/5","activityType":"LEC"},{"course":"STAT3001_S1_STLUC_IN","description":"Mathematical Statistics","activity":"TUT1","group":"01","day":3,"time":{"hour":13,"minute":0},"campus":"STLUC","location":"09-201 - Michie Building, Tutorial Room","duration":60,"dates":"5/3-9/4, 23/4-28/5","activityType":"TUT"},{"course":"STAT3001_S1_STLUC_IN","description":"Mathematical Statistics","activity":"TUT1","group":"02","day":4,"time":{"hour":15,"minute":0},"campus":"STLUC","location":"35-215 - Chamberlain Building, Tutorial Room","duration":60,"dates":"6/3-10/4, 24/4-29/5","activityType":"TUT"},{"course":"STAT3001_S1_STLUC_IN","description":"Mathematical Statistics","activity":"TUT1","group":"03","day":3,"time":{"hour":8,"minute":0},"campus":"STLUC","location":"35-207 - Chamberlain Building, Tutorial Room","duration":60,"dates":"5/3-9/4, 23/4-28/5","activityType":"TUT"},{"course":"STAT3004_S1_STLUC_IN","description":"Probability Models & Stochastic Processes","activity":"LEC1","group":"01","day":1,"time":{"hour":11,"minute":0},"campus":"STLUC","location":"03-309 - Steele Building, Lecture Theatre","duration":60,"dates":"25/2-7/4, 21/4-26/5","activityType":"LEC"},{"course":"STAT3004_S1_STLUC_IN","description":"Probability Models & Stochastic Processes","activity":"LEC2","group":"01","day":3,"time":{"hour":9,"minute":0},"campus":"STLUC","location":"03-309 - Steele Building, Lecture Theatre","duration":60,"dates":"27/2-9/4, 23/4-28/5","activityType":"LEC"},{"course":"STAT3004_S1_STLUC_IN","description":"Probability Models & Stochastic Processes","activity":"LEC3","group":"01","day":4,"time":{"hour":11,"minute":0},"campus":"STLUC","location":"07-222 - Parnell Building, Lecture Theatre","duration":60,"dates":"28/2-10/4, 24/4-29/5","activityType":"LEC"},{"course":"STAT3004_S1_STLUC_IN","description":"Probability Models & Stochastic Processes","activity":"TUT1","group":"01","day":4,"time":{"hour":12,"minute":0},"campus":"STLUC","location":"09-803 - Michie Building, Tutorial Room","duration":60,"dates":"6/3-10/4, 24/4-29/5","activityType":"TUT"},{"course":"STAT3004_S1_STLUC_IN","description":"Probability Models & Stochastic Processes","activity":"TUT1","group":"02","day":4,"time":{"hour":13,"minute":0},"campus":"STLUC","location":"09-803 - Michie Building, Tutorial Room","duration":60,"dates":"6/3-10/4, 24/4-29/5","activityType":"TUT"},{"course":"STAT3004_S1_STLUC_IN","description":"Probability Models & Stochastic Processes","activity":"TUT1","group":"03","day":4,"time":{"hour":15,"minute":0},"campus":"STLUC","location":"09-803 - Michie Building, Tutorial Room","duration":60,"dates":"6/3-10/4, 24/4-29/5","activityType":"TUT"},{"course":"COMP4403_S1_STLUC_IN","description":"Compilers and Interpreters","activity":"LEC1","group":"01","day":0,"time":{"hour":8,"minute":0},"campus":"STLUC","location":"05-213 - Richards Building, Lecture Theatre","duration":120,"dates":"24/2-6/4, 20/4-25/5","activityType":"LEC"},{"course":"COMP4403_S1_STLUC_IN","description":"Compilers and Interpreters","activity":"LEC2","group":"01","day":1,"time":{"hour":11,"minute":0},"campus":"STLUC","location":"81-313 - Otto Hirschfeld Building, Lecture Theatre","duration":60,"dates":"25/2-7/4, 21/4-26/5","activityType":"LEC"},{"course":"COMP4403_S1_STLUC_IN","description":"Compilers and Interpreters","activity":"TUT1","group":"01","day":1,"time":{"hour":12,"minute":0},"campus":"STLUC","location":"49-316 - Advanced Engineering Building, Simulation & Modelling 1","duration":60,"dates":"25/2-7/4, 21/4-26/5","activityType":"TUT"},{"course":"COMP4403_S1_STLUC_IN","description":"Compilers and Interpreters","activity":"TUT1","group":"02","day":1,"time":{"hour":13,"minute":0},"campus":"STLUC","location":"49-316 - Advanced Engineering Building, Simulation & Modelling 1","duration":60,"dates":"25/2-7/4, 21/4-26/5","activityType":"TUT"},]; 29 | 30 | const DEFAULT_CURRENT = uuidv4(); 31 | 32 | export const BLANK_PERSIST: PersistState = { 33 | timetables: {}, 34 | current: 'a blank state has no timetables', 35 | user: null, 36 | _meta: { 37 | version: CURRENT_VERSION 38 | } 39 | } 40 | 41 | export const DEFAULT_PERSIST: PersistState = { 42 | ...BLANK_PERSIST, 43 | timetables: { 44 | // [DEFAULT_CURRENT]: { 45 | // name: 'default', 46 | // allSessions: testData, 47 | // selectedGroups: {"MATH3401_S1_STLUC_IN":{"LEC1":"01","LEC2":"01","LEC3":"01","TUT1":"01"},"STAT3001_S1_STLUC_IN":{"LEC1":"01","LEC2":"01","LEC3":"01","TUT1":"01"},"STAT3004_S1_STLUC_IN":{"LEC1":"01","LEC2":"01","LEC3":"01","TUT1":"01"},"COMP4403_S1_STLUC_IN":{"LEC1":"01","LEC2":"01","TUT1":"01"}}, 48 | // }, 49 | [DEFAULT_CURRENT]: EMPTY_TIMETABLE, 50 | }, 51 | current: DEFAULT_CURRENT, 52 | } -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /src/state/persistState.ts: -------------------------------------------------------------------------------- 1 | import { action, computed, Computed, Action, createTypedHooks, Actions, memo, State, Thunk } from 'easy-peasy'; 2 | import { PersistState, BLANK_PERSIST } from './schema'; 3 | import { Timetable, CourseEvent, CourseActivity, EMPTY_TIMETABLE, CourseActivityGroup, CourseVisibility, SelectionsByGroup, Course, RGBAColour, SessionsByGroup, CourseActivityGroupMap } from './types'; 4 | import { v4 as uuidv4 } from 'uuid'; 5 | import { makeActivityKey, makeCustomSession, CUSTOM_COURSE, makeActivitySessionKey, coerceToObject } from '../logic/functions'; 6 | import _ from 'lodash'; 7 | import { firebaseAction, FirebaseModel } from './firebaseEnhancer'; 8 | import { IS_DEBUG } from '../isDebug'; 9 | 10 | 11 | export type ActivitiesNested = CourseActivityGroupMap; 12 | export type SelectedNested = SelectionsByGroup; 13 | 14 | 15 | const ensureSelectionExists = (s: PersistState, x: CourseEvent, force?: true) => { 16 | if (s.timetables[s.current]!.selections?.[x.course]?.[x.activity] == null) { 17 | _.setWith(s.timetables[s.current].selections, [x.course, x.activity, x.group], true, Object); 18 | } 19 | if (force ?? false) { 20 | s.timetables[s.current].selections[x.course][x.activity][x.group] = true; 21 | } 22 | } 23 | 24 | 25 | export type PersistModel = PersistState & { 26 | onSetState: Action, 27 | 28 | setUser: Thunk, 29 | 30 | currentTimetable: Computed, 31 | activities: Computed, 32 | sessions: Computed, 33 | selected: Computed, 34 | 35 | new: Thunk, 36 | select: Thunk, 37 | delete: Thunk, 38 | rename: Thunk, 39 | copy: Thunk, 40 | 41 | updateCourseSessions: Thunk, 42 | updateActivitySessions: Thunk, 43 | 44 | deleteCourse: Thunk, 45 | deleteActivitySession: Thunk, 46 | 47 | setSelectedGroups: Thunk, 48 | setOneSelectedGroup: Thunk, 49 | replaceOneSelectedGroup: Thunk, 50 | 51 | addCustomEvent: Thunk, 52 | 53 | setCourseVisibility: Thunk, 54 | isSessionVisible: Computed boolean>, 55 | 56 | setCourseColour: Thunk, 57 | }; 58 | 59 | 60 | export const model: PersistModel = { 61 | ...BLANK_PERSIST, 62 | 63 | onSetState: action((s) => { 64 | // const current = s.timetables[s.current]!; 65 | 66 | if (!s.timetables[s.current]!.sessions) { 67 | // @ts-ignore 68 | s.timetables[s.current]!.sessions = s.timetables[s.current]!.session ?? {}; 69 | } 70 | // @ts-ignore 71 | delete s.timetables[s.current]!.session; 72 | 73 | if (!s.timetables[s.current]!.selections) 74 | s.timetables[s.current]!.selections = {}; 75 | 76 | IS_DEBUG && console.log("on set state", s.timetables[s.current]); 77 | } 78 | ), 79 | 80 | setUser: firebaseAction((s, user) => { 81 | if (!user) { 82 | s.user = null; 83 | return; 84 | } 85 | s.user = { 86 | uid: user.uid, 87 | name: user.displayName, 88 | email: user.email, 89 | photo: user.photoURL, 90 | phone: user.phoneNumber, 91 | providers: user.providerData?.map(x => x?.providerId ?? JSON.stringify(x)), 92 | isAnon: user.isAnonymous, 93 | }; 94 | }), 95 | 96 | currentTimetable: computed(memo((s: State) => { 97 | //console.log('timetable', s); 98 | //console.log(s.currentTimetable); 99 | // debugger; 100 | return s.timetables?.[s.current]; 101 | }, 2)), 102 | 103 | activities: computed( 104 | [s => s.sessions], 105 | memo((sessions: SessionsByGroup) => { 106 | // console.error("recomputing activities"); 107 | 108 | const activities: ActivitiesNested = {}; 109 | 110 | for (const c of Object.keys(coerceToObject(sessions))) { 111 | activities[c] = {}; 112 | for (const a of Object.keys(sessions[c])) { 113 | activities[c][a] = {}; 114 | for (const g of Object.keys(sessions[c][a])) { 115 | if (!sessions[c][a][g]) continue; 116 | activities[c][a][g] = Object.values(sessions[c][a][g]); 117 | } 118 | } 119 | } 120 | 121 | return activities; 122 | }, 1) 123 | ), 124 | 125 | sessions: computed( 126 | [s => s?.timetables?.[s.current]?.sessions], 127 | memo((sessions: SessionsByGroup) => { 128 | // console.error("recomputing activities"); 129 | 130 | const out: SessionsByGroup = {}; 131 | 132 | for (const c of Object.keys(coerceToObject(sessions))) { 133 | out[c] = {}; 134 | for (const a of Object.keys(coerceToObject(sessions[c]))) { 135 | out[c][a] = {}; 136 | for (const g of Object.keys(coerceToObject(sessions[c][a]))) { 137 | if (!sessions[c][a][g]) continue; 138 | out[c][a][g] = coerceToObject(sessions[c][a][g]); 139 | } 140 | } 141 | } 142 | 143 | return out; 144 | }, 1) 145 | ), 146 | 147 | selected: computed([ 148 | s => s.currentTimetable?.selections, 149 | s => s.sessions, 150 | ], memo((selections: SelectionsByGroup, sessions: SessionsByGroup) => { 151 | 152 | const selected: SelectionsByGroup = {}; 153 | 154 | for (const c of Object.keys(coerceToObject(selections))) { 155 | selected[c] = {}; 156 | for (const a of Object.keys(coerceToObject(selections[c]))) { 157 | selected[c][a] = {}; 158 | for (const g of Object.keys(coerceToObject(selections[c][a]))) { 159 | if (selections[c][a][g] && sessions?.[c]?.[a]?.[g] != null) 160 | selected[c][a][g] = true; 161 | } 162 | } 163 | } 164 | 165 | return selected; 166 | }, 1)), 167 | 168 | new: firebaseAction((s, name) => { 169 | const id = uuidv4(); 170 | s.timetables[id] = EMPTY_TIMETABLE; 171 | s.timetables[id].name = name ? name : "new timetable"; 172 | s.current = id; 173 | }), 174 | 175 | select: firebaseAction((s, id) => { 176 | s.current = id; 177 | }), 178 | 179 | delete: firebaseAction((s, id) => { 180 | if (Object.keys(s.timetables).length === 1) { 181 | console.error("refusing to delete the last timetable"); 182 | return; 183 | } 184 | delete s.timetables[id]; 185 | const newID = _.minBy( 186 | Object.keys(s.timetables), 187 | k => s.timetables[k].name 188 | ); 189 | console.assert(newID != null, "next ID after deleting cannot be null"); 190 | s.current = newID!; 191 | }), 192 | 193 | rename: firebaseAction((s, name) => { 194 | s.timetables[s.current]!.name = name; 195 | }), 196 | 197 | copy: firebaseAction(s => { 198 | const old = s.currentTimetable; 199 | const newID = uuidv4(); 200 | s.timetables[newID] = { ...old }; 201 | s.timetables[newID].name = old.name.trimRight() + ' (copy)'; 202 | s.current = newID; 203 | }), 204 | 205 | 206 | updateCourseSessions: firebaseAction((s, sessions) => { 207 | const courses = new Set(sessions.map(x => x.course)); 208 | // console.assert(courses.size === 1); 209 | for (const c of courses.values()) { 210 | _.setWith(s.timetables[s.current]!.sessions, [c], {}, Object); 211 | } 212 | // debugger; 213 | for (const x of sessions) { 214 | _.setWith(s.timetables[s.current].sessions[x.course], 215 | [x.activity, x.group, makeActivitySessionKey(x)], x, Object); 216 | 217 | ensureSelectionExists(s, x); 218 | } 219 | }), 220 | 221 | updateActivitySessions: firebaseAction((s, sessions) => { 222 | const newActivities = new Set(sessions.map(makeActivityKey)); 223 | console.assert(newActivities.size === 1); 224 | 225 | 226 | const x = sessions[0]; 227 | _.setWith(s.timetables[s.current]!.sessions, [x.course, x.activity], {}, Object); 228 | 229 | for (const x of sessions) { 230 | _.setWith(s.timetables[s.current].sessions[x.course][x.activity], 231 | [x.group, makeActivitySessionKey(x)], x, Object); 232 | 233 | ensureSelectionExists(s, x); 234 | } 235 | 236 | }), 237 | 238 | deleteActivitySession: firebaseAction((s, c) => { 239 | // i am sorry for the length 240 | if (s.timetables[s.current]?.sessions?.[c.course]?.[c.activity]?.[c.group]?.[makeActivitySessionKey(c)] != null) { 241 | delete s.timetables[s.current].sessions[c.course][c.activity][c.group][makeActivitySessionKey(c)]; 242 | } 243 | }), 244 | 245 | addCustomEvent: firebaseAction((s, { day, hour, label, duration }) => { 246 | const customGroups = Object.keys(s.activities?.[CUSTOM_COURSE]?.[label] ?? []); 247 | if (customGroups.length === 0) { 248 | customGroups.push('0'); 249 | } 250 | const max = Math.max(...customGroups.map(x => parseInt(x))); 251 | 252 | const newGroup = `${max + 1}`.padStart(2, '0'); 253 | const newEvent = makeCustomSession(label, day, hour, duration, newGroup); 254 | const key = makeActivitySessionKey(newEvent); 255 | _.setWith(s.timetables[s.current]!.sessions, 256 | [CUSTOM_COURSE, label, newGroup, key], newEvent, Object); 257 | 258 | ensureSelectionExists(s, newEvent, true); 259 | }), 260 | 261 | deleteCourse: firebaseAction((s, course) => { 262 | _.unset(s.timetables[s.current]!.sessions, [course]); 263 | }), 264 | 265 | setCourseVisibility: firebaseAction((s, { course, visible }) => { 266 | if (s.timetables[s.current]!.courseVisibility == null) 267 | s.timetables[s.current]!.courseVisibility = {}; 268 | s.timetables[s.current]!.courseVisibility![course] = visible; 269 | }), 270 | 271 | setSelectedGroups: firebaseAction((s, { course, activity, group }) => { 272 | for (const g of group) { 273 | _.setWith(s.timetables[s.current]!.selections, [course, activity, g], true, Object); 274 | } 275 | }), 276 | 277 | setOneSelectedGroup: firebaseAction((s, { course, activity, group, selected }) => { 278 | _.setWith(s.timetables[s.current]!.selections, [course, activity, group], selected, Object); 279 | }), 280 | 281 | replaceOneSelectedGroup: firebaseAction((s, payload) => { 282 | const {course, activity, old, new: new_} = payload; 283 | if (old === new_) return; 284 | 285 | _.unset(s.timetables[s.current]!.selections, [course, activity, old]); 286 | _.setWith(s.timetables[s.current]!.selections, [course, activity, new_], true, Object); 287 | }), 288 | 289 | isSessionVisible: computed([ 290 | s => s.currentTimetable?.selections, 291 | s => s.currentTimetable?.courseVisibility, 292 | ], memo((selected?: SelectionsByGroup, visibilities?: CourseVisibility) => (c: CourseEvent) => { 293 | return (selected?.[c.course]?.[c.activity]?.[c.group] ?? false) 294 | && (visibilities?.[c.course] ?? true); 295 | }, 1) 296 | ), 297 | 298 | setCourseColour: firebaseAction((s, { course, colour }) => { 299 | if (s.timetables[s.current]!.courseColours == null) 300 | s.timetables[s.current]!.courseColours = {}; 301 | if (colour) 302 | s.timetables[s.current]!.courseColours![course] = colour; 303 | else 304 | delete s.timetables[s.current]!.courseColours![course]; 305 | }), 306 | }; 307 | 308 | const typedHooks = createTypedHooks(); 309 | 310 | export const useStoreActions = typedHooks.useStoreActions; 311 | export const useStoreDispatch = typedHooks.useStoreDispatch; 312 | export const useStoreState = typedHooks.useStoreState; 313 | 314 | export const useTimetableActions = () => ({ 315 | select: useStoreActions(s => s.select), 316 | new: useStoreActions(s => s.new), 317 | copy: useStoreActions(s => s.copy), 318 | delete: useStoreActions(s => s.delete), 319 | rename: useStoreActions(s => s.rename), 320 | }); 321 | 322 | export const mapCurrentTimetableActions = (a: Actions) => ({ 323 | updateSessions: a.updateCourseSessions, 324 | deleteCourse: a.deleteCourse, 325 | setCourseVisibility: a.setCourseVisibility, 326 | setSelectedGroups: a.setSelectedGroups, 327 | replaceOneSelectedGroup: a.replaceOneSelectedGroup, 328 | }); 329 | 330 | export const cleanState = (s: PersistModel) => { 331 | return { 332 | timetables: s.timetables, user: s.user ?? null, current: s.current, _meta: s._meta 333 | }; 334 | } --------------------------------------------------------------------------------