diff --git a/.env.example b/.env.example index ef67af0..4e5d9af 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ NEXT_PUBLIC_API_ENDPOINT=https://api.michigantechcourses.com NEXT_PUBLIC_THUMOR_ENDPOINT=https://thumbor.michigantechcourses.com +NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN= # Set by CI/CD NEXT_PUBLIC_GIT_REVISION= diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index d257d1c..32c7c52 100644 --- a/.github/workflows/lighthouse.yml +++ b/.github/workflows/lighthouse.yml @@ -22,19 +22,25 @@ jobs: - name: Install dependencies run: yarn install - - name: Build & export - run: yarn build:export + - name: Build + run: yarn build env: NEXT_PUBLIC_API_ENDPOINT: https://api.michigantechcourses.com NEXT_PUBLIC_GIT_REVISION: random-sha NEXT_PUBLIC_LIGHTHOUSE: "true" + - name: Serve + run: | + yarn start & + - name: Run Lighthouse against output id: lighthouse - uses: treosh/lighthouse-ci-action@v3 + uses: treosh/lighthouse-ci-action@v8 env: LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }} with: + urls: | + http://localhost:3000/ configPath: './lighthouserc.json' uploadArtifacts: true temporaryPublicStorage: true diff --git a/components/courses-table/details-row.tsx b/components/courses-table/details-row.tsx deleted file mode 100644 index aed1498..0000000 --- a/components/courses-table/details-row.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import React from 'react'; -import {Tr, Td, VStack, Text, Box, Heading, Button, Collapse, IconButton, HStack, Spacer} from '@chakra-ui/react'; -import {observer} from 'mobx-react-lite'; -import SectionsTable from '../sections-table'; -import CourseStats from '../course-stats'; -import useStore from '../../lib/state-context'; -import {ICourseWithFilteredSections} from '../../lib/ui-state'; -import {ArrowUpIcon} from '@chakra-ui/icons'; - -const Stats = observer(({courseKey}: {courseKey: string}) => { - const store = useStore(); - - const data = store.apiState.passfaildrop[courseKey]; - - if (!data) { - return null; - } - - return ( - - Stats - - - - - - ); -}); - -const DetailsRow = ({course, onlyShowSections, onShowEverything, onShareCourse}: {course: ICourseWithFilteredSections; onlyShowSections: boolean; onShowEverything: () => void; onShareCourse: () => void}) => { - const courseKey = `${course.course.subject}${course.course.crse}`; - - return ( - - - - { - onlyShowSections && ( - - ) - } - - - - - - - Description: - {course.course.description} - - - - } aria-label="Share course" variant="ghost" colorScheme="brand" title="Share course" onClick={onShareCourse}/> - - - { - course.course.prereqs && ( - - Prereqs: - {course.course.prereqs} - - ) - } - - - - - - - - {!onlyShowSections && ( - Sections - )} - - - - - - - ); -}; - -export default DetailsRow; diff --git a/components/courses-table/styles/table.module.scss b/components/courses-table/styles/table.module.scss deleted file mode 100644 index c286a78..0000000 --- a/components/courses-table/styles/table.module.scss +++ /dev/null @@ -1,5 +0,0 @@ -.hideBottomBorder { - td { - border-bottom-width: 0; - } -} diff --git a/components/data-filter-stats-bar.tsx b/components/data-filter-stats-bar.tsx deleted file mode 100644 index b64a361..0000000 --- a/components/data-filter-stats-bar.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React, {useMemo} from 'react'; -import {HStack, Skeleton, Spacer, Text} from '@chakra-ui/react'; -import InlineStat from './inline-stat'; -import dayjs from 'dayjs'; -import relativeTime from 'dayjs/plugin/relativeTime.js'; -import useCurrentDate from '../lib/use-current-date'; - -dayjs.extend(relativeTime); - -const LastUpdatedAt = ({updatedAt}: {updatedAt: Date}) => { - const now = useCurrentDate(5000); - - const lastUpdatedString = useMemo(() => dayjs(updatedAt).from(now), [updatedAt, now]); - - return data last updated {lastUpdatedString}; -}; - -type Props = { - isLoaded: boolean; - matched: string; - total: string; - updatedAt: Date; - label: string; -}; - -const DataFilterStatsBar = (options: Props) => { - return ( - - - - - - - - - - - - ); -}; - -export default DataFilterStatsBar; diff --git a/components/inline-stat.tsx b/components/inline-stat.tsx deleted file mode 100644 index 9724f0f..0000000 --- a/components/inline-stat.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react'; -import {Box, Text} from '@chakra-ui/react'; - -const InlineStat = ({label, number, help}: {label: string; number: string; help: string}) => { - return ( - - {label} - - {number} - - {help} - - ); -}; - -export default InlineStat; diff --git a/components/revision-toaster.tsx b/components/revision-toaster.tsx deleted file mode 100644 index 78c0b2f..0000000 --- a/components/revision-toaster.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React, {useRef, useState} from 'react'; -import {useToast} from '@chakra-ui/toast'; -import {Box, Button} from '@chakra-ui/react'; -import { - Alert, - AlertDescription, - AlertIcon, - AlertTitle -} from '@chakra-ui/alert'; -import useRevalidation from '../lib/use-revalidation'; - -const RevisionToaster = () => { - const [loadDate] = useState(new Date()); - const toast = useToast(); - const toastRef = useRef(); - - useRevalidation(true, async () => { - // Prevents a popup appearing while a new service worker is being installed - const isBefore10Seconds = Date.now() - loadDate.getTime() < 10 * 1000; - if (toastRef.current || process.env.NEXT_PUBLIC_LIGHTHOUSE || isBefore10Seconds) { - return; - } - - try { - const revision = await (await fetch('/api/revision')).text(); - - if (revision !== process.env.NEXT_PUBLIC_GIT_REVISION) { - toastRef.current = toast({ - duration: null, - render: () => ( - - - - Upgrade Available - - There's a new version available. to upgrade. - - - - ) - }); - } - } catch {} - }, 30 * 1000); - - return null; -}; - -export default RevisionToaster; diff --git a/components/search-bar.tsx b/components/search-bar.tsx deleted file mode 100644 index 9b626d5..0000000 --- a/components/search-bar.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import React, {useCallback, useEffect, useRef, useState} from 'react'; -import {Input, Container, InputGroup, InputLeftElement, Text, Kbd, Button, HStack} from '@chakra-ui/react'; -import {Modal, ModalOverlay} from '@chakra-ui/modal'; -import {Search2Icon} from '@chakra-ui/icons'; -import useHeldKey from '../lib/use-held-key'; - -type Props = { - innerRef: React.Ref; - children?: React.ReactElement; - placeholder: string; - isEnabled: boolean; - onChange: (newValue: string) => void; - value: string; -}; - -const SearchBar = ({innerRef, children, placeholder, isEnabled, onChange, value}: Props) => { - const inputRef = useRef(null); - - const [showHelp, setShowHelp] = useState(false); - const [isKeyHeld, handleKeydown] = useHeldKey({key: '/'}); - - // Autofocus - useEffect(() => { - if (isEnabled) { - inputRef.current?.focus(); - } - }, [isEnabled]); - - useEffect(() => { - if (isKeyHeld) { - setShowHelp(true); - } else { - setShowHelp(false); - } - }, [isKeyHeld]); - - const handleShowHelp = useCallback(() => { - setShowHelp(true); - }, []); - - const handleModalClose = useCallback(() => { - setShowHelp(false); - }, []); - - return ( - - - } - /> - - { - onChange(event.target.value); - }} - aria-label="Search for courses or sections" - disabled={!isEnabled} - onKeyDown={handleKeydown} - /> - - - {children && ( - - - hold / to see - - - - )} - - - - {children} - - - ); -}; - -export default SearchBar; diff --git a/components/sections-table.tsx b/components/sections-table.tsx deleted file mode 100644 index ec5f357..0000000 --- a/components/sections-table.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import React from 'react'; -import {Table, Thead, Tbody, Tr, Th, Td, Tag, useBreakpointValue, TableProps, Wrap, WrapItem, Tooltip, TableContainer} from '@chakra-ui/react'; -import {observer} from 'mobx-react-lite'; -import {IInstructorFromAPI, ISectionFromAPI} from '../lib/types'; -import getCreditsStr from '../lib/get-credits-str'; -import {Schedule} from '../lib/rschedule'; -import {DATE_DAY_CHAR_MAP} from '../lib/constants'; -import InstructorWithPopover from './instructor-with-popover'; - -interface ISectionsTableProps { - sections: ISectionFromAPI[]; -} - -const padTime = (v: number) => v.toString().padStart(2, '0'); - -const DAYS_95_IN_MS = 95 * 24 * 60 * 60 * 1000; - -const getFormattedTimeFromSchedule = (jsonSchedule: Record) => { - const schedule = Schedule.fromJSON(jsonSchedule as any); - - let days = ''; - let time = ''; - - const occurences = schedule.collections({granularity: 'week', weekStart: 'SU'}).toArray(); - - if (occurences.length > 0) { - for (const d of occurences[0].dates) { - days += DATE_DAY_CHAR_MAP[d.date.getDay()]; - - const start = d.date; - const end = d.end; - - time = `${padTime(start.getHours())}:${padTime(start.getMinutes())} ${start.getHours() >= 12 ? 'PM' : 'AM'} - ${padTime(end?.getHours() ?? 0)}:${padTime(end?.getMinutes() ?? 0)} ${end?.getHours() ?? 0 >= 12 ? 'PM' : 'AM'}`; - } - } - - const start = schedule.firstDate?.toDateTime().date ?? new Date(); - const end = schedule.lastDate?.toDateTime().date ?? new Date(); - - return { - days, - time, - start: start.toLocaleDateString('en-US'), - end: end.toLocaleDateString('en-US'), - isHalf: (end.getTime() - start.getTime() < DAYS_95_IN_MS) - }; -}; - -const InstructorList = observer(({instructors}: {instructors: Array<{id: IInstructorFromAPI['id']}>}) => ( - - { - instructors.length > 0 ? - instructors.map(instructor => ( - - - - )) : ( - - ¯\_(ツ)_/¯ - - ) - } - -)); - -const TimeDisplay = observer(({schedule}: {schedule: Record}) => { - const {days, time, start, end, isHalf} = getFormattedTimeFromSchedule(schedule); - - if (time === '') { - return <>¯\_(ツ)_/¯; - } - - return ( - - - {days} - {time} - - - ); -}); - -const Row = observer(({section}: {section: ISectionFromAPI}) => { - const creditsString = getCreditsStr(section.minCredits, section.maxCredits); - - return ( - - {section.section} - - - - - - - {section.crn} - {creditsString} - {section.totalSeats} - {section.takenSeats} - - {section.availableSeats} - - - ); -}); - -const TableBody = observer(({sections}: {sections: ISectionFromAPI[]}) => { - return ( - - { - sections.map(s => ( - - )) - } - - ); -}); - -const SectionsTable = ({sections, ...props}: TableProps & ISectionsTableProps) => { - const tableSize = useBreakpointValue({base: 'sm', lg: 'md'}); - - return ( - - - - - - - - - - - - - - - - -
SectionInstructorsScheduleCRNCreditsCapacitySeats TakenSeats Available
-
- ); -}; - -export default observer(SectionsTable); diff --git a/components/transfer-courses-table/row.tsx b/components/transfer-courses-table/row.tsx deleted file mode 100644 index 736127c..0000000 --- a/components/transfer-courses-table/row.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import {Td} from '@chakra-ui/react'; -import {Tr} from '@chakra-ui/table'; -import {observer} from 'mobx-react-lite'; -import React from 'react'; -import {ITransferCourseFromAPI} from '../../lib/types'; - -const Row = ({course}: {course: ITransferCourseFromAPI}) => { - return ( - - - - {course.fromSubject}{course.fromCRSE} - - - - - - {course.toSubject}{course.toCRSE} - - - - - {course.title} - - - - {course.fromCollege} - - - - {course.fromCollegeState} - - - - {course.toCredits} - - - ); -}; - -export default observer(Row); diff --git a/lib/api-state.ts b/lib/api-state.ts deleted file mode 100644 index 2160c50..0000000 --- a/lib/api-state.ts +++ /dev/null @@ -1,337 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx'; -import mergeByProperty from './merge-by-property'; -import {RootState} from './state'; -import {ESemester, ICourseFromAPI, IFullCourseFromAPI, IInstructorFromAPI, IPassFailDropFromAPI, ISectionFromAPI, ITransferCourseFromAPI} from './types'; - -interface ISemesterFilter { - semester: ESemester; - year: number; -} - -type ENDPOINT = 'courses' | 'sections' | 'instructors' | 'transfer-courses' | 'passfaildrop'; -type DATA_KEYS = 'courses' | 'sections' | 'instructors' | 'transferCourses' | 'passfaildrop'; - -const ENDPOINT_TO_KEY: Record = { - courses: 'courses', - sections: 'sections', - instructors: 'instructors', - 'transfer-courses': 'transferCourses', - passfaildrop: 'passfaildrop' -}; - -export type TSeedCourse = {course: IFullCourseFromAPI; stats: IPassFailDropFromAPI}; - -export class APIState { - instructors: IInstructorFromAPI[] = []; - passfaildrop: IPassFailDropFromAPI = {}; - sections: ISectionFromAPI[] = []; - courses: ICourseFromAPI[] = []; - transferCourses: ITransferCourseFromAPI[] = []; - loading = false; - errors: Error[] = []; - lastUpdatedAt: Date | null = null; - - availableSemesters: ISemesterFilter[] = []; - selectedSemester?: ISemesterFilter; - - singleFetchEndpoints: ENDPOINT[] = []; - recurringFetchEndpoints: ENDPOINT[] = []; - - private readonly rootState: RootState; - - constructor(rootState: RootState) { - makeAutoObservable(this); - - this.rootState = rootState; - } - - get subjects() { - const s = new Map(); - - for (const course of this.courses) { - s.set(course.subject.toLowerCase(), course.subject.toLowerCase()); - } - - return [...s.keys()]; - } - - get coursesNotDeleted() { - return this.courses.filter(c => !c.deletedAt); - } - - get sectionsNotDeleted() { - return this.sections.filter(s => !s.deletedAt); - } - - get instructorsById() { - const map = new Map(); - - for (const instructor of this.instructors) { - map.set(instructor.id, instructor); - } - - return map; - } - - get courseById() { - const map = new Map(); - - for (const course of this.courses) { - map.set(course.id, course); - } - - return map; - } - - get sectionById() { - const map = new Map(); - - for (const s of this.sections) { - map.set(s.id, s); - } - - return map; - } - - get keysLastUpdatedAt(): Record { - const reducer = (array: Array<{updatedAt: string; deletedAt?: string | null}>) => array.reduce((maxDate, element) => { - const prospectiveDates = [maxDate, new Date(element.updatedAt)]; - - if (element.deletedAt) { - prospectiveDates.push(new Date(element.deletedAt)); - } - - return prospectiveDates.sort((a, b) => b.getTime() - a.getTime())[0]; - }, new Date(0)); - - return { - instructors: reducer(this.instructors), - courses: reducer(this.courses), - sections: reducer(this.sections), - transferCourses: reducer(this.transferCourses), - passfaildrop: new Date(0) - }; - } - - get dataLastUpdatedAt() { - const dates = Object.values(this.keysLastUpdatedAt).sort((a, b) => b.getTime() - a.getTime()); - - return dates[0]; - } - - get hasDataForTrackedEndpoints() { - if (!this.lastUpdatedAt) { - return false; - } - - let hasData = true; - - for (const endpoint of [...this.singleFetchEndpoints, ...this.recurringFetchEndpoints]) { - const currentDataForEndpoint = this[ENDPOINT_TO_KEY[endpoint]]; - - if ((currentDataForEndpoint as Record).constructor === Object) { - hasData = Object.keys(currentDataForEndpoint).length > 0; - } - - if ((currentDataForEndpoint as Record).constructor === Array) { - hasData = (currentDataForEndpoint as APIState[Exclude]).length > 0; - } - } - - return hasData; - } - - get sortedSemesters() { - const semesterValueMap = { - SPRING: 0.1, - SUMMER: 0.2, - FALL: 0.3 - }; - - return this.availableSemesters.slice().sort((a, b) => { - return (a.year + semesterValueMap[a.semester]) - (b.year + semesterValueMap[b.semester]); - }); - } - - async getSemesters() { - const result = await (await fetch(new URL('/semesters', process.env.NEXT_PUBLIC_API_ENDPOINT).toString())).json(); - - runInAction(() => { - this.availableSemesters = result; - }); - } - - setSelectedSemester(semester: ISemesterFilter) { - this.selectedSemester = semester; - this.courses = []; - this.sections = []; - this.lastUpdatedAt = null; - } - - setSeedCourse({course, stats}: TSeedCourse) { - this.courses = [course]; - this.selectedSemester = {semester: course.semester, year: course.year}; - this.availableSemesters = [{semester: course.semester, year: course.year}]; - this.sections = course.sections; - this.passfaildrop = stats; - this.lastUpdatedAt = new Date(); - - this.instructors = course.sections.reduce((accum, section) => { - for (const instructor of section.instructors) { - if (!accum.some(i => i.id === instructor.id)) { - accum.push({...instructor, thumbnailURL: null}); - } - } - - return accum; - }, []); - } - - setSingleFetchEndpoints(endpoints: ENDPOINT[], shouldInvalidateData = false) { - if (shouldInvalidateData) { - for (const endpoint of endpoints) { - if (Array.isArray(this[ENDPOINT_TO_KEY[endpoint]])) { - (this[ENDPOINT_TO_KEY[endpoint]] as APIState[Exclude]) = []; - } else { - (this[ENDPOINT_TO_KEY[endpoint]] as APIState['passfaildrop']) = {}; - } - } - - this.availableSemesters = []; - } - - this.singleFetchEndpoints = endpoints; - } - - setRecurringFetchEndpoints(endpoints: ENDPOINT[], shouldInvalidateData = false) { - if (shouldInvalidateData) { - for (const endpoint of endpoints) { - if (Array.isArray(this[ENDPOINT_TO_KEY[endpoint]])) { - (this[ENDPOINT_TO_KEY[endpoint]] as APIState[Exclude]) = []; - } else { - (this[ENDPOINT_TO_KEY[endpoint]] as APIState['passfaildrop']) = {}; - } - } - - this.availableSemesters = []; - } - - this.recurringFetchEndpoints = endpoints; - } - - // Poll for updates - async revalidate() { - if (this.loading) { - return; - } - - performance.mark('start-revalidation'); - - this.loading = true; - - // Get semesters first - if (this.availableSemesters.length === 0 && (this.recurringFetchEndpoints.includes('courses') || this.recurringFetchEndpoints.includes('sections'))) { - await this.getSemesters(); - const semesters = this.sortedSemesters; - - if (semesters) { - this.setSelectedSemester(semesters[semesters.length - 1]); - } - } - - let successfulHits = 0; - - const startedUpdatingAt = new Date(); - - const promises: Array> = []; - const newErrors: Error[] = []; - - promises.push(...this.singleFetchEndpoints.map(async endpoint => { - const currentDataForEndpoint = this[ENDPOINT_TO_KEY[endpoint]]; - - let shouldFetch = false; - - if ((currentDataForEndpoint as Record).constructor === Object) { - shouldFetch = Object.keys(currentDataForEndpoint).length === 0; - } - - if ((currentDataForEndpoint as Record).constructor === Array) { - shouldFetch = (currentDataForEndpoint as APIState[Exclude]).length === 0; - } - - if (shouldFetch) { - try { - const url = new URL(`/${endpoint}`, process.env.NEXT_PUBLIC_API_ENDPOINT); - - const result = await (await fetch(url.toString())).json(); - - runInAction(() => { - this[ENDPOINT_TO_KEY[endpoint]] = result; - }); - } catch (error: unknown) { - newErrors.push(error as Error); - } - } - })); - - // Load courses, sections, instructors - // eslint-disable-next-line unicorn/no-array-push-push - promises.push(...this.recurringFetchEndpoints.map(async path => { - const key = ENDPOINT_TO_KEY[path]; - - try { - const url = new URL(`/${path}`, process.env.NEXT_PUBLIC_API_ENDPOINT); - - if (['courses', 'sections'].includes(key)) { - if (!this.selectedSemester) { - successfulHits++; - return; - } - - url.searchParams.append('semester', this.selectedSemester.semester); - url.searchParams.append('year', this.selectedSemester.year.toString()); - } - - const keyLastUpdatedAt = this.keysLastUpdatedAt[key]; - - if (keyLastUpdatedAt && keyLastUpdatedAt.getTime() !== 0) { - url.searchParams.append('updatedSince', keyLastUpdatedAt.toISOString()); - } - - const result = await (await fetch(url.toString())).json(); - - if (result.length > 0) { - runInAction(() => { - // Merge - // Spent way too long trying to get TS to recognize this as valid... - // YOLOing with any - // Might be relevant: https://github.com/microsoft/TypeScript/issues/16756 - this[key] = mergeByProperty(this[key] as any, result, 'id') as any; - }); - } - - successfulHits++; - } catch (error: unknown) { - newErrors.push(error as Error); - } - })); - - // Wait for all calls to complete - await Promise.all(promises); - - runInAction(() => { - this.lastUpdatedAt = startedUpdatingAt; - - this.loading = false; - - if (newErrors.length > 0) { - this.errors = newErrors; - } else if (successfulHits === 3) { - this.errors = []; - } - }); - - performance.mark('end-revalidation'); - performance.measure('Revalidated Data', 'start-revalidation', 'end-revalidation'); - } -} diff --git a/lib/api.ts b/lib/api.ts deleted file mode 100644 index cef779a..0000000 --- a/lib/api.ts +++ /dev/null @@ -1,25 +0,0 @@ -import {IFullCourseFromAPI, IPassFailDropFromAPI} from './types'; - -const API = { - findFirstCourse: async (options: {semester: string; year: number; subject: string; crse: string}): Promise => { - const url = new URL('/courses/first', process.env.NEXT_PUBLIC_API_ENDPOINT); - - url.searchParams.set('semester', options.semester); - url.searchParams.set('year', options.year.toString()); - url.searchParams.set('subject', options.subject); - url.searchParams.set('crse', options.crse); - - return (await fetch(url.toString())).json(); - }, - - getStats: async (options: {crse: string; subject: string}): Promise => { - const url = new URL('/passfaildrop', process.env.NEXT_PUBLIC_API_ENDPOINT); - - url.searchParams.set('courseSubject', options.subject); - url.searchParams.set('courseCrse', options.crse); - - return (await fetch(url.toString())).json(); - } -}; - -export default API; diff --git a/lib/sharables.ts b/lib/sharables.ts deleted file mode 100644 index 9f37741..0000000 --- a/lib/sharables.ts +++ /dev/null @@ -1,19 +0,0 @@ -interface ShareCourseStruct { - type: 'SHARE_COURSE'; - data: { - year: number; - semester: string; - subject: string; - crse: string; - }; -} - -export type ShareableStruct = {version: number} & (ShareCourseStruct); - -export const encodeShareable = (data: ShareableStruct) => { - return Buffer.from(JSON.stringify(data)).toString('base64'); -}; - -export const decodeShareable = (packed: string): ShareableStruct => { - return JSON.parse(Buffer.from(packed, 'base64').toString()) as ShareableStruct; -}; diff --git a/lib/state.ts b/lib/state.ts deleted file mode 100644 index 1f7518a..0000000 --- a/lib/state.ts +++ /dev/null @@ -1,15 +0,0 @@ -import {APIState} from './api-state'; -import {TransferCoursesState} from './transfer-courses-state'; -import {UIState} from './ui-state'; - -export class RootState { - public uiState!: UIState; - public apiState!: APIState; - public transferCoursesState!: TransferCoursesState; - - constructor() { - this.apiState = new APIState(this); - this.uiState = new UIState(this); - this.transferCoursesState = new TransferCoursesState(this); - } -} diff --git a/lib/use-interval.ts b/lib/use-interval.ts deleted file mode 100644 index 147c044..0000000 --- a/lib/use-interval.ts +++ /dev/null @@ -1,29 +0,0 @@ -import {useRef, useEffect} from 'react'; - -// https://usehooks-typescript.com/react-hook/use-interval -const useInterval = (callback: () => void, delay: number | null) => { - const savedCallback = useRef<() => void | null>(); - - // Remember the latest callback. - useEffect(() => { - savedCallback.current = callback; - }); - - useEffect(() => { - function tick() { - if (typeof savedCallback?.current !== 'undefined') { - savedCallback?.current(); - } - } - - if (delay !== null) { - const id = setInterval(tick, delay); - - return () => { - clearInterval(id); - }; - } - }, [delay]); -}; - -export default useInterval; diff --git a/lib/use-previous.ts b/lib/use-previous.ts deleted file mode 100644 index 2073e2e..0000000 --- a/lib/use-previous.ts +++ /dev/null @@ -1,13 +0,0 @@ -import {useEffect, useRef} from 'react'; - -const usePrevious = (value: T) => { - const ref = useRef(); - - useEffect(() => { - ref.current = value; - }, [value]); - - return ref.current; -}; - -export default usePrevious; diff --git a/lighthouserc.json b/lighthouserc.json index 0929a90..9a31610 100644 --- a/lighthouserc.json +++ b/lighthouserc.json @@ -1,8 +1,5 @@ { "ci": { - "collect": { - "staticDistDir": "./out" - }, "assert": { "assertMatrix": [ { @@ -15,7 +12,9 @@ "unused-javascript": "off", "unminified-javascript": "off", "link-name": "off", - "tap-targets": "off" + "tap-targets": "off", + "csp-xss": "off", + "tabindex": "off" } } ] diff --git a/modules.d.ts b/modules.d.ts index 6dfaf57..bd8d336 100644 --- a/modules.d.ts +++ b/modules.d.ts @@ -1,7 +1,38 @@ -declare module 'jest-next-dynamic'; +import type {Workbox} from 'workbox-window'; + +declare module 'jest-next-dynamic'{ + const preloadAll: () => Promise; + export default preloadAll; +} declare module '*.svg' { import {ReactElement, SVGProps} from 'react'; + const content: (props: SVGProps) => ReactElement; export default content; } + +interface ClipboardItem { + readonly types: string[]; + readonly presentationStyle: 'unspecified' | 'inline' | 'attachment'; + getType(): Promise; +} + +type ClipboardItemData = Record>; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +declare const ClipboardItem: { + prototype: ClipboardItem; + new (itemData: ClipboardItemData): ClipboardItem; +}; + +interface Clipboard { + read(): Promise; + write(data: ClipboardItem[]): Promise; +} + +export declare global { + interface Window { + workbox: Workbox; + } +} diff --git a/next-env.d.ts b/next-env.d.ts index 7b7aa2c..9bc3dd4 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,2 +1,6 @@ /// /// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/next.config.js b/next.config.js index 124ca5c..2efe6b5 100644 --- a/next.config.js +++ b/next.config.js @@ -2,7 +2,7 @@ const withPlugins = require('next-compose-plugins'); const withPWA = require('next-pwa'); const withBundleAnalyzer = require('@next/bundle-analyzer')({ - enabled: process.env.ANALYZE === 'true' + enabled: process.env.ANALYZE === 'true', }); module.exports = withPlugins([ @@ -11,24 +11,30 @@ module.exports = withPlugins([ pwa: { dest: 'public', disable: process.env.NODE_ENV !== 'production', - dynamicStartUrl: false - } - }] + dynamicStartUrl: false, + register: false, + skipWaiting: false, + }, + }], ], { + productionBrowserSourceMaps: true, webpack: config => { - config.module.rules.push({ - test: /react-spring/, - sideEffects: true - }, { - test: /\.svg$/, - use: ['@svgr/webpack'] - }); + config.module.rules.push( + { + test: /react-spring/, + sideEffects: true, + }, + { + test: /\.svg$/, + use: ['@svgr/webpack'], + }, + ); if (process.env.PROFILE === 'true') { config.resolve.alias = { ...config.resolve.alias, 'react-dom$': 'react-dom/profiling', - 'scheduler/tracing': 'scheduler/tracing-profiling' + 'scheduler/tracing': 'scheduler/tracing-profiling', }; const terser = config.optimization.minimizer.find(plugin => plugin.options && plugin.options.terserOptions); @@ -37,11 +43,11 @@ module.exports = withPlugins([ terser.options.terserOptions = { ...terser.options.terserOptions, keep_classnames: true, - keep_fnames: true + keep_fnames: true, }; } } return config; - } + }, }); diff --git a/package.json b/package.json index be6ca4b..66d85a8 100644 --- a/package.json +++ b/package.json @@ -26,32 +26,37 @@ "@svgr/webpack": "^5.5.0", "@testing-library/jest-dom": "^5.11.9", "@testing-library/react": "^11.2.5", + "@types/dom-to-image": "^2.6.3", "@types/jest": "^26.0.20", - "@types/lunr": "^2.3.3", - "@types/node": "^14.14.31", - "@types/react": "^17.0.2", - "@types/react-dom": "^17.0.3", - "@typescript-eslint/eslint-plugin": "^4.15.2", - "@typescript-eslint/parser": "^4.15.2", + "@types/lunr": "^2.3.4", + "@types/node": "^16.9.1", + "@types/react": "^17.0.20", + "@types/react-big-calendar": "^0.33.1", + "@types/react-dom": "^17.0.9", + "@types/workbox-window": "^4.3.4", + "@typescript-eslint/eslint-plugin": "^4.31.0", + "@typescript-eslint/parser": "^4.31.0", "babel-plugin-inline-react-svg": "^2.0.1", - "eslint": "^7.20.0", - "eslint-config-xo": "^0.35.0", - "eslint-config-xo-typescript": "^0.38.0", + "eslint": "^7.32.0", + "eslint-config-xo": "^0.38.0", + "eslint-config-xo-react": "^0.25.0", + "eslint-config-xo-typescript": "^0.44.0", + "eslint-plugin-react": "^7.25.1", + "eslint-plugin-react-hooks": "^4.2.0", "eslint-plugin-unused-imports": "^1.1.0", "husky": "^5.1.1", "jest": "^26.6.3", "jest-css-modules": "^2.1.0", - "jest-next-dynamic": "^1.0.1", "lint-staged": "^10.5.4", - "next-pwa": "^5.2.0", + "next-pwa": "^5.2.24", "next-unused": "^0.0.6", "nodemon": "^2.0.7", "sass": "^1.32.8", "ts-node": "^9.1.1", "type-fest": "^1.0.2", - "typescript": "^4.2.2", - "webpack": "4.44.1", - "xo": "^0.38.1" + "typescript": "^4.4.3", + "webpack": "5.52.1", + "xo": "^0.44.0" }, "next-unused": { "include": [ @@ -59,26 +64,27 @@ ] }, "xo": { - "plugins": [ - "unused-imports" - ], + "extends": "xo-react", "rules": { "import/extensions": "off", "unicorn/no-array-reduce": "off", - "@typescript-eslint/no-unused-vars": "off", - "unused-imports/no-unused-imports-ts": "error", "node/file-extension-in-import": "off", - "import/no-extraneous-dependencies": "off", - "unused-imports/no-unused-vars-ts": [ - "warn", - { - "vars": "all", - "varsIgnorePattern": "^_", - "args": "after-used", - "argsIgnorePattern": "^_" - } - ] - } + "react/no-unescaped-entities": "off", + "react/prop-types": "off", + "react/jsx-no-useless-fragment": "off", + "node/prefer-global/process": "off", + "unicorn/prefer-module": "off", + "node/prefer-global/buffer": "off", + "import/no-cycle": "off" + }, + "envs": [ + "browser", + "node", + "commonjs" + ], + "ignores": [ + "next-env.d.ts" + ] }, "lint-staged": { "*.{js,ts,tsx}": [ @@ -98,7 +104,8 @@ ], "testEnvironment": "jsdom", "testMatch": [ - "**/test/*.integration.ts*" + "**/test/*.integration.ts*", + "**/*.test*" ], "setupFilesAfterEnv": [ "@testing-library/jest-dom/extend-expect", @@ -114,35 +121,55 @@ ], "coverageDirectory": "coverage", "moduleNameMapper": { - "\\.(css|less|scss|sss|styl)$": "/node_modules/jest-css-modules" + "\\.(css|less|scss|sss|styl)$": "/node_modules/jest-css-modules", + "^src/(.*)$": "/src/$1" } }, "dependencies": { - "@chakra-ui/icons": "^1.0.12", - "@chakra-ui/react": "^1.6.0", - "@chakra-ui/system": "^1.6.4", - "@emotion/react": "^11.1.5", + "@chakra-ui/icons": "^1.0.15", + "@chakra-ui/react": "^1.6.7", + "@chakra-ui/system": "^1.7.3", + "@chakra-ui/theme-tools": "^1.2.0", + "@emotion/react": "^11.4.1", "@emotion/styled": "^11.3.0", - "@nivo/core": "^0.68.0", - "@nivo/line": "^0.68.0", - "@nivo/tooltip": "^0.68.0", + "@fortawesome/fontawesome-svg-core": "^1.2.36", + "@fortawesome/free-solid-svg-icons": "^5.15.4", + "@fortawesome/react-fontawesome": "^0.1.15", + "@nivo/core": "^0.73.0", + "@nivo/line": "^0.73.0", + "@nivo/tooltip": "^0.73.0", "@rschedule/core": "^1.2.3", "@rschedule/json-tools": "^1.2.3", "@rschedule/standard-date-adapter": "^1.2.3", + "@veccu/react-calendar": "^2.3.2", + "bowser": "^2.11.0", "chrome-aws-lambda": "^8.0.2", - "dayjs": "^1.10.4", - "framer-motion": "^4.1.9", + "date-fns": "^2.23.0", + "date-fns-tz": "^1.1.6", + "datebook": "^6.5.7", + "dom-to-image": "^2.6.0", + "framer-motion": "^4.1.17", "lunr": "^2.3.9", - "mobx": "^6.3.0", - "mobx-react-lite": "^3.2.0", - "next": "^10.1.3", + "mapbox-gl": "^2.4.1", + "memoize-one": "^5.2.1", + "mobx": "^6.3.3", + "mobx-persist-store": "^1.0.3", + "mobx-react-lite": "^3.2.1", + "mobx-shallow-undo": "^1.0.0", + "nanoid": "^3.1.29", + "next": "^11.1.2", "next-absolute-url": "^1.2.2", "next-compose-plugins": "^2.2.1", - "next-seo": "^4.24.0", + "next-seo": "^4.26.0", "p-throttle": "^4.1.1", "prop-types": "^15.7.2", "puppeteer-core": "^9.0.0", "react": "^17.0.2", - "react-dom": "^17.0.2" + "react-dom": "^17.0.2", + "react-draggable": "^4.4.4", + "react-hotkeys-hook": "^3.4.0", + "react-mapbox-gl": "^5.1.1", + "react-reverse-portal": "^2.1.0", + "react-use": "^17.3.1" } } diff --git a/pages/about.tsx b/pages/about.tsx deleted file mode 100644 index 835fba5..0000000 --- a/pages/about.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import {Container, Heading, ListItem, Text, UnorderedList, VStack} from '@chakra-ui/react'; -import {NextSeo} from 'next-seo'; -import React from 'react'; -import WrappedLink from '../components/link'; - -const AboutPage = () => ( - - - - About - - - - - 👋 Hi! I'm Max, a student at Michigan Tech. - - - - I made this tool because Banweb can be a pain to use when you're just trying to take a first pass at figuring out what courses to take. This also makes information available that's not accessible via Banweb as well, like the pass, fail, drop, and class size data. - - - - It currently costs ~$9 / month to host this, so if you found it useful feel free to sponsor me or buy me a coffee. - - - - - 🤔 Planned improvements - - - 📱 Better mobile view - 🧺 Add "baskets" that can be used to plan out a semester by adding sections and checking the overall schedule - - - - - 📮 Contact - - - Ran into a nasty bug? Or have a cool idea that you'd like to see implemented? - - - - If you have a GitHub account, feel free to make an issue. Otherwise, you can email me directly. - - - - - ❤️ Open source - - - We're completely open-source! - - - - Hopefully some of the above repositories are useful for your own projects. - - - - - 🧱 Tech stack - - - I'm currently using: - - - - - Frontend: - - - React - - Next.JS - - - Chakra UI - - - Lunr - - - MobX - - - - - - Backend: - - - - NestJS - - - Prisma - - - Bull - - Redis - PostgreSQL - Kubernetes - - Thumbor - - - - - - Hosting / third party: - - - - Vercel - - - MaxKVM - - - Datadog - - - - - - - - ↔️ API - - - The API is open and free to use, but please don't abuse it. - - - - - Disclaimer - - - Although every effort is made to keep the information listed here up-to-date, I make no guarantees about the correctness of the information. Please check Banweb for the latest information. - - - - That being said, the most critical information here (course and section data) should be at most 10 minutes out of date at any given moment. - - - - -); - -export default AboutPage; diff --git a/pages/index.tsx b/pages/index.tsx deleted file mode 100644 index d3de231..0000000 --- a/pages/index.tsx +++ /dev/null @@ -1,251 +0,0 @@ -import React, {useCallback, useRef, useEffect, useState} from 'react'; -import Head from 'next/head'; -import {NextSeo} from 'next-seo'; -import {Box, Code, Heading, VStack, Text, useToast, usePrevious} from '@chakra-ui/react'; -import {ModalContent, ModalBody, ModalCloseButton, ModalHeader} from '@chakra-ui/modal'; -import {observer} from 'mobx-react-lite'; -import SearchBar from '../components/search-bar'; -import CoursesTable from '../components/courses-table'; -import ErrorToaster from '../components/error-toaster'; -import useStore from '../lib/state-context'; -import {NextPage} from 'next'; -import {decodeShareable} from '../lib/sharables'; -import API from '../lib/api'; -import {TSeedCourse} from '../lib/api-state'; -import {getCoursePreviewUrl} from '../lib/preview-url'; - -const FILTER_EXAMPLES = [ - { - label: 'Subject', - examples: [ - { - label: 'filter by Computer Science courses', - query: 'subject:cs' - } - ] - }, - { - label: 'Course Level', - examples: [ - { - label: 'filter only by 1000-2000 level courses', - query: 'level:1000' - }, - { - label: 'filter by courses that are at least 1000 level', - query: 'level:1000+' - }, - { - label: 'filter by courses that are between 1000 and 3000 level', - query: 'level:1000-3000' - } - ] - }, - { - label: 'Section Seats', - examples: [ - { - label: 'filter by sections with available seats', - query: 'has:seats' - } - ] - }, - { - label: 'Credits', - examples: [ - { - label: 'filter by 3 credit sections', - query: 'credits:3' - }, - { - label: 'filter by sections that are at least 3 credits', - query: 'credits:3+' - }, - { - label: 'filter by sections that are between 1 and 3 credits', - query: 'credits:1-3' - } - ] - } -]; - -const isFirstRender = typeof window === 'undefined'; - -interface Props { - seedCourse?: TSeedCourse; - previewImg?: string; -} - -const HomePage: NextPage = props => { - const toast = useToast(); - const toastRef = useRef(); - const [seedCourse, setSeedCourse] = useState(props.seedCourse); - const previousSeedCourse = usePrevious(seedCourse); - const store = useStore(); - const searchBarRef = useRef(null); - - const handleScrollToTop = useCallback(() => { - if (searchBarRef.current) { - const y = searchBarRef.current.getBoundingClientRect().top + window.pageYOffset - 30; - - window.scrollTo({top: y, behavior: 'smooth'}); - } - }, [searchBarRef]); - - const handleSearchChange = useCallback((newValue: string) => { - store.uiState.setSearchValue(newValue); - }, [store]); - - useEffect(() => { - if (seedCourse) { - store.apiState.setSeedCourse(seedCourse); - store.uiState.setSearchValue(`${seedCourse.course.subject}${seedCourse.course.crse}`); - - if (!toastRef.current) { - toastRef.current = toast({ - title: 'Load Data', - description: 'Only data for one course is loaded right now. Close this notification to load all data.', - duration: null, - isClosable: true, - onCloseComplete: () => { - setSeedCourse(undefined); - window.history.pushState({}, document.title, '/'); - } - }); - } - } else { - store.apiState.setSingleFetchEndpoints(['passfaildrop'], previousSeedCourse !== seedCourse); - store.apiState.setRecurringFetchEndpoints(['courses', 'instructors', 'sections'], previousSeedCourse !== seedCourse); - - return () => { - store.apiState.setSingleFetchEndpoints([]); - store.apiState.setRecurringFetchEndpoints([]); - }; - } - }, [seedCourse, previousSeedCourse, toast]); - - return ( - <> - { - seedCourse ? ( - - ) : ( - - ) - } - { - props.previewImg && ( - - ) - } - - - {isFirstRender && ( - <> - - - - - )} - - - - - - Filter Cheatsheet - - - - { - FILTER_EXAMPLES.map(exampleGroup => ( - - {exampleGroup.label} - - {exampleGroup.examples.map(example => ( - - - {example.query} - - - {example.label} - - ))} - - )) - } - - - Tips - - - Don't be afraid to mix and match! Queries like subject:cs has:seats ureel work just fine. - - - - - - - - - - - - - ); -}; - -// Use instead of getServerSideProps so next export still works. -// Only actually runs on server because we check for context.req. -HomePage.getInitialProps = async context => { - if (context.query.share && context.req) { - const shareable = decodeShareable(context.query.share as string); - - switch (shareable.type) { - case 'SHARE_COURSE': { - const [course, stats] = await Promise.all([ - API.findFirstCourse(shareable.data), - API.getStats({crse: shareable.data.crse, subject: shareable.data.subject}) - ]); - - if (course) { - return { - seedCourse: {course, stats}, - previewImg: getCoursePreviewUrl(course, context.req) - }; - } - - break; - } - - default: - break; - } - } - - return {}; -}; - -export default observer(HomePage); diff --git a/public/images/grumpy.gif b/public/images/grumpy.gif new file mode 100644 index 0000000..c0d3c9a Binary files /dev/null and b/public/images/grumpy.gif differ diff --git a/public/images/lightning.gif b/public/images/lightning.gif new file mode 100644 index 0000000..7aa76d4 Binary files /dev/null and b/public/images/lightning.gif differ diff --git a/src/components/basket/basket-select-and-edit.tsx b/src/components/basket/basket-select-and-edit.tsx new file mode 100644 index 0000000..37cf314 --- /dev/null +++ b/src/components/basket/basket-select-and-edit.tsx @@ -0,0 +1,105 @@ +import React, {useCallback, useEffect, useState} from 'react'; +import { + Button, + Editable, + EditableInput, + HStack, + IconButton, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + Tooltip, + useDisclosure, + usePrevious, +} from '@chakra-ui/react'; +import {DeleteIcon} from '@chakra-ui/icons'; +import {observer} from 'mobx-react-lite'; +import useStore from 'src/lib/state/context'; +import EditableControls from '../editable-controls'; +import BasketSelector from './basket-selector'; + +type BasketsSelectAndEditProps = { + onCreateNewBasket: () => void; +}; + +const BasketsSelectAndEdit = observer((props: BasketsSelectAndEditProps) => { + const deleteBasketDisclosure = useDisclosure(); + const {allBasketsState} = useStore(); + const {currentBasket} = allBasketsState; + + const [basketName, setBasketName] = useState(currentBasket?.name ?? ''); + + const previousName = usePrevious(currentBasket?.name); + useEffect(() => { + if (currentBasket && previousName !== currentBasket.name) { + setBasketName(currentBasket.name); + } + }, [currentBasket, previousName]); + + const handleDelete = useCallback(() => { + if (!currentBasket) { + return; + } + + allBasketsState.removeBasket(currentBasket.id); + deleteBasketDisclosure.onClose(); + }, [allBasketsState, currentBasket, deleteBasketDisclosure]); + + if (!currentBasket) { + return <>¯\_(ツ)_/¯; + } + + return ( + <> + { + currentBasket.setName(newName.trim()); + }} + > + + + + + + + } + colorScheme="red" + aria-label="Delete basket" + onClick={deleteBasketDisclosure.onOpen}/> + + + + + + + Confirm Deletion + + Are you sure you want to delete this basket? + + + + + + + + + + ); +}); + +export default BasketsSelectAndEdit; diff --git a/src/components/basket/basket-selector.tsx b/src/components/basket/basket-selector.tsx new file mode 100644 index 0000000..caefd81 --- /dev/null +++ b/src/components/basket/basket-selector.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { + Button, + EditableInput, + EditablePreview, + Menu, + MenuButton, + MenuItem, + MenuList, + useEditableControls, +} from '@chakra-ui/react'; +import {ChevronDownIcon, AddIcon} from '@chakra-ui/icons'; +import {observer} from 'mobx-react-lite'; +import useStore from 'src/lib/state/context'; + +type BasketSelectorProps = { + onCreateNewBasket: () => void; +}; + +const BasketSelector = observer((props: BasketSelectorProps) => { + const {isEditing} = useEditableControls(); + const {allBasketsState, apiState} = useStore(); + const {currentBasket} = allBasketsState; + + if (!currentBasket) { + return <>¯\_(ツ)_/¯; + } + + return ( + + {({isOpen}) => ( + <> + } + display={isEditing ? 'none' : 'block'} + > + + + + + } onClick={props.onCreateNewBasket}> + Add basket + + + { + apiState.selectedTerm && allBasketsState.getBasketsFor(apiState.selectedTerm).map(basket => ( + { + allBasketsState.setSelectedBasket(basket.id); + }} + > + {basket.name} + + )) + } + + + )} + + ); +}); + +export default BasketSelector; diff --git a/src/components/basket/calendar/calendar.tsx b/src/components/basket/calendar/calendar.tsx new file mode 100644 index 0000000..8b4ba50 --- /dev/null +++ b/src/components/basket/calendar/calendar.tsx @@ -0,0 +1,127 @@ +import React, {useMemo, useContext, useEffect, useState} from 'react'; +import {Table, Skeleton} from '@chakra-ui/react'; +import useCalendar from '@veccu/react-calendar'; +import {format, add} from 'date-fns'; +import {observer} from 'mobx-react-lite'; +import useStore from 'src/lib/state/context'; +import occurrenceGeneratorCache from 'src/lib/occurrence-generator-cache'; +import {CalendarEvent} from './types'; +import CalendarToolbar from './toolbar'; +import MonthView from './views/month'; +import WeekView from './views/week'; +import styles from './styles/calendar.module.scss'; + +const BasketCalendarContext = React.createContext>(undefined as any); + +export const BasketCalendarProvider = ({children}: {children: React.ReactElement | React.ReactElement[]}) => ( + + {children} + +); + +type BasketCalendarProps = { + onEventClick: (event: CalendarEvent) => void; +}; + +const BasketCalendar = (props: BasketCalendarProps) => { + const {allBasketsState: {currentBasket}, apiState} = useStore(); + const {headers, body, view, navigation, cursorDate} = useContext(BasketCalendarContext); + const [hasSetCalendarStartDate, setHasSetCalendarStartDate] = useState(false); + + const bodyWithEvents = useMemo(() => ({ + ...body, + value: body.value.map(week => ({ + ...week, + value: week.value.map(day => { + const events = []; + + const start = day.value; + const end = add(day.value, {days: 1}); + + for (const section of currentBasket?.sections ?? []) { + if (section.parsedTime) { + for (const occurrence of occurrenceGeneratorCache(JSON.stringify(section.time), start, end, section.parsedTime)) { + events.push({ + section, + start: occurrence.date as Date, + end: occurrence.end as Date ?? new Date(), + }); + } + } + } + + return { + ...day, + events: events.sort((a, b) => a.start.getTime() - b.start.getTime()).map(event => ({ + ...event, + key: `${event.section.id}-${event.start.toISOString()}-${event.end.toISOString()}`, + label: `${event.section.course.title} ${event.section.section} (${event.section.course.subject}${event.section.course.crse})`, + })), + }; + }), + })), + }), [body, currentBasket?.sections]); + + const firstDate = useMemo(() => { + const dates = []; + + for (const section of currentBasket?.sections ?? []) { + if (section.parsedTime?.firstDate) { + dates.push(section.parsedTime.firstDate.date); + } + } + + return dates.sort((a, b) => a.getTime() - b.getTime())[0]; + }, [currentBasket?.sections]); + + // Jump to first event in calendar if we haven't yet + useEffect(() => { + if (firstDate && !hasSetCalendarStartDate) { + setHasSetCalendarStartDate(true); + navigation.setDate(firstDate); + } + }, [firstDate, hasSetCalendarStartDate, navigation]); + + // Reset calendar jump status if basket becomes empty + useEffect(() => { + if (currentBasket?.sectionIds.length === 0) { + setHasSetCalendarStartDate(false); + } + }, [currentBasket?.sectionIds]); + + return ( + + + + + { + view.isMonthView && ( + + ) + } + + { + view.isWeekView && ( + + ) + } +
+
+ ); +}; + +export default observer(BasketCalendar); diff --git a/src/components/basket/calendar/styles/calendar.module.scss b/src/components/basket/calendar/styles/calendar.module.scss new file mode 100644 index 0000000..2dc0ee6 --- /dev/null +++ b/src/components/basket/calendar/styles/calendar.module.scss @@ -0,0 +1,9 @@ +.table { + table-layout: fixed; + position: relative; + --table-width: 68rem; + + tr th { + width: calc(var(--table-width) / 7); + } +} diff --git a/src/components/basket/calendar/toolbar.tsx b/src/components/basket/calendar/toolbar.tsx new file mode 100644 index 0000000..be59b81 --- /dev/null +++ b/src/components/basket/calendar/toolbar.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { + HStack, + Button, + Text, + Box, + IconButton, +} from '@chakra-ui/react'; +import {ChevronLeftIcon, ChevronRightIcon} from '@chakra-ui/icons'; +import useCalendar from '@veccu/react-calendar'; + +type CalendarToolbarProps = { + label: string; + navigation: ReturnType['navigation']; + view: ReturnType['view']; +}; + +const CalendarToolbar = (props: CalendarToolbarProps) => ( + + + + + + + + + {props.label} + + + + } + roundedRight={0} + zIndex={2} + onClick={props.navigation.toPrev}/> + + + } + roundedLeft={0} + zIndex={2} + onClick={props.navigation.toNext}/> + + +); + +export default CalendarToolbar; diff --git a/src/components/basket/calendar/types.ts b/src/components/basket/calendar/types.ts new file mode 100644 index 0000000..ef42d93 --- /dev/null +++ b/src/components/basket/calendar/types.ts @@ -0,0 +1,28 @@ +import {BasketState} from 'src/lib/state/basket'; + +export type CalendarEvent = { + key: string; + label: string; + start: Date; + end: Date; + section: BasketState['sections'][0]; +}; + +// Couldn't get these types merged correctly for some reason, so duplicating internal package type here. :( +export type CalendarBodyWithEvents = { + value: Array<{ + key: string; + value: Array<{ + value: Date; + } & { + date: number; + isCurrentMonth: boolean; + isCurrentDate: boolean; + isWeekend: boolean; + } & { + key: string; + } & { + events: CalendarEvent[]; + }>; + }>; +}; diff --git a/src/components/basket/calendar/views/month.tsx b/src/components/basket/calendar/views/month.tsx new file mode 100644 index 0000000..98e2da7 --- /dev/null +++ b/src/components/basket/calendar/views/month.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import { + HStack, + Spacer, + Text, + Thead, + Tr, + Td, + Th, + Tbody, + Box, + VStack, + useColorModeValue, + Flex, + Tooltip, +} from '@chakra-ui/react'; +import {format} from 'date-fns'; +import enUS from 'date-fns/locale/en-US'; +import useCalendar from '@veccu/react-calendar'; +import {CalendarBodyWithEvents, CalendarEvent} from 'src/components/basket/calendar/types'; +import styles from './styles/month.module.scss'; + +type MonthViewProps = { + body: CalendarBodyWithEvents; + headers: ReturnType['headers']; + onEventClick: (event: CalendarEvent) => void; +}; + +const MonthView = ({body, headers, onEventClick}: MonthViewProps) => { + const borderColor = useColorModeValue('gray.100', 'gray.700'); + + return ( + <> + + + {headers.weekDays.map(({key, value}) => ( + + {format(value, 'E', {locale: enUS})} + + ))} + + + + + {body.value.map(({key, value: days}) => ( + + {days.map(({key, value, isCurrentMonth, isCurrentDate, events}, i) => ( + + + {format(value, 'dd')} + + + { + events.length > 0 && ( + + { + events.map(event => ( + + { + onEventClick(event); + }} + > + + {event.section.course.title} + + + + {format(event.start, 'hh:mm a')} + + + + )) + } + + + ) + } + + ))} + + ))} + + + ); +}; + +export default MonthView; diff --git a/src/components/basket/calendar/views/styles/month.module.scss b/src/components/basket/calendar/views/styles/month.module.scss new file mode 100644 index 0000000..6b227e5 --- /dev/null +++ b/src/components/basket/calendar/views/styles/month.module.scss @@ -0,0 +1,5 @@ +.monthlyView { + tr { + height: calc(var(--table-width) / 7); + } +} diff --git a/src/components/basket/calendar/views/styles/week.module.scss b/src/components/basket/calendar/views/styles/week.module.scss new file mode 100644 index 0000000..f60b385 --- /dev/null +++ b/src/components/basket/calendar/views/styles/week.module.scss @@ -0,0 +1,26 @@ +.weeklyView { + --row-height: var(--chakra-sizes-28); + --vertical-header-width: var(--chakra-sizes-24); + --cell-width: calc((var(--table-width) - var(--vertical-header-width)) / 7); +} + +thead.weeklyView { + tr > th:first-child { + width: var(--vertical-header-width); + } + + tr > th { + width: var(--cell-width); + } +} + +tbody.weeklyView { + tr { + height: var(--row-height); + + th { + width: var(--vertical-header-width); + vertical-align: top; + } + } +} diff --git a/src/components/basket/calendar/views/week.tsx b/src/components/basket/calendar/views/week.tsx new file mode 100644 index 0000000..edaaef0 --- /dev/null +++ b/src/components/basket/calendar/views/week.tsx @@ -0,0 +1,171 @@ +import React, {useMemo} from 'react'; +import { + Tbody, + Tr, + Td, + Th, + Thead, + Box, + VStack, + HStack, + Tooltip, + Spacer, +} from '@chakra-ui/react'; +import useCalendar from '@veccu/react-calendar'; +import enUS from 'date-fns/locale/en-US'; +import { + add, + sub, + format, + areIntervalsOverlapping, + eachHourOfInterval, + differenceInMinutes, + differenceInDays, +} from 'date-fns'; +import {CalendarBodyWithEvents, CalendarEvent} from 'src/components/basket/calendar/types'; +import compareTimes from 'src/lib/compare-times'; +import matchDateOnTime from 'src/lib/match-date-on-time'; +import styles from './styles/week.module.scss'; + +const TIME_STEPS_HOURS = 2; +const TIME_STEPS_MINUTES = TIME_STEPS_HOURS * 60; + +type WeekViewProps = { + body: CalendarBodyWithEvents; + headers: ReturnType['headers']; + onEventClick: (event: CalendarEvent) => void; +}; + +const WeekView = ({body, headers, onEventClick}: WeekViewProps) => { + const startDate = useMemo(() => body.value[0].value[0].value, [body]); + + const events = useMemo(() => { + const events = body.value.map(({value}) => value.map(({events}) => events)).flat(2); + + const eventsWithMetadata: Array<(typeof events)[0] & {overlapOffset: number; dayOffset: number}> = []; + + for (const event of events) { + eventsWithMetadata.push({ + ...event, + dayOffset: differenceInDays(event.start, startDate), + overlapOffset: eventsWithMetadata.reduce((accum, eventToCompareOverlap) => { + if (areIntervalsOverlapping(event, eventToCompareOverlap)) { + return accum + 1; + } + + return accum; + }, 0), + }); + } + + return eventsWithMetadata; + }, [body, startDate]); + + const {minTime, maxTime} = useMemo(() => { + let min = new Date(); + let max = new Date(1970, 0, 1, 1, 0); + + for (const event of events) { + if (compareTimes(min, event.start) === 1) { + min = event.start; + } + + if (compareTimes(max, event.end) === -1) { + max = event.end; + } + } + + // Add padding + min = sub(min, {hours: 1}); + max = matchDateOnTime(min, add(max, {hours: 1})); + + return { + minTime: min, + // Guard against max < min, happens when events.length === 0 + maxTime: max < min ? new Date(min.getTime() + 1) : max, + }; + }, [events]); + + const twoHourIntervals = useMemo(() => eachHourOfInterval({start: minTime, end: maxTime}, {step: TIME_STEPS_HOURS}), [minTime, maxTime]); + + return ( + <> + + + + Time + + {headers.weekDays.map(({key, value}) => ( + + {format(value, 'E dd', {locale: enUS})} + + ))} + + + + { + twoHourIntervals.map(interval => ( + + + {format(interval, 'h a')} + + { + body.value[0].value.map(({key}) => ( + + )) + } + + + )) + } + { + events.map(event => ( + + { + onEventClick(event); + }} + > + + + {event.section.course.title} + + + + {format(event.start, 'hh:mm')} + + {format(event.end, 'hh:mm')} + + + + + )) + } + + + ); +}; + +export default WeekView; diff --git a/src/components/basket/content.tsx b/src/components/basket/content.tsx new file mode 100644 index 0000000..f0e731e --- /dev/null +++ b/src/components/basket/content.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { + HStack, + Spacer, + VStack, + Text, + useBreakpointValue, + Alert, + AlertIcon, + AlertTitle, + AlertDescription, + Box, +} from '@chakra-ui/react'; +import {observer} from 'mobx-react-lite'; +import dynamic from 'next/dynamic'; +import useScreenSize from 'src/lib/hooks/use-screen-size'; +import useStore from 'src/lib/state/context'; +import BasketTable from './table'; + +const ExportOptions = dynamic(async () => import('./export-options')); + +const ULTRAWIDE_BREAKPOINT_IN_PX = 3072; + +type BasketContentProps = { + onClose: () => void; +}; + +const BasketContent = (props: BasketContentProps) => { + const {width: totalScreenWidth} = useScreenSize(); + const isCurrentlyUltrawide = useBreakpointValue({base: false, '4xl': true}); + + const {allBasketsState: {currentBasket}} = useStore(); + + if (!currentBasket) { + return null; + } + + return ( + + { + currentBasket.numOfItems === 0 ? ( + + There's nothing in your basket. Go add some courses! + + ) : ( + + ) + } + + { + currentBasket.warnings.length > 0 && ( + + + + Warning: + + {currentBasket.warnings.map(warning => ( +
{warning}
+ ))} +
+
+
+ ) + } + + + = ULTRAWIDE_BREAKPOINT_IN_PX) && !isCurrentlyUltrawide ? 'block' : 'none'} + > + ✨ tip: looks like you have a really wide screen - make this window bigger to always see your basket + + + + + { + currentBasket.numOfItems !== 0 && ( + + ) + } + +
+ ); +}; + +export default observer(BasketContent); diff --git a/src/components/basket/export-options/calendar.tsx b/src/components/basket/export-options/calendar.tsx new file mode 100644 index 0000000..e7f8c44 --- /dev/null +++ b/src/components/basket/export-options/calendar.tsx @@ -0,0 +1,112 @@ +import React, {useState} from 'react'; +import {observer} from 'mobx-react-lite'; +import { + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalCloseButton, + ModalBody, + Button, + FormControl, + FormLabel, + Stack, + Text, + Radio, + RadioGroup, +} from '@chakra-ui/react'; +import saveAs from 'src/lib/save-as'; +import sectionsToICS, {ALERT_TIMINGS, LocationStyle, TitleStyle} from 'src/lib/sections-to-ics'; +import useStore from 'src/lib/state/context'; + +type ExportCalendarProps = { + isOpen: boolean; + onClose: () => void; +}; + +const ExportCalendar = ({isOpen, onClose}: ExportCalendarProps) => { + const {allBasketsState: {currentBasket}, apiState} = useStore(); + const [titleStyle, setTitleStyle] = useState(TitleStyle.CRSE_FIRST); + const [locationStyle, setLocationStyle] = useState(LocationStyle.SHORT); + const [alertTime, setAlertTime] = useState(ALERT_TIMINGS[2].toString()); + + const handleCalendarExport = (event: React.FormEvent) => { + event.preventDefault(); + + if (!currentBasket) { + return false; + } + + const ics = sectionsToICS(currentBasket.sections, apiState.buildings, { + titleStyle: titleStyle as TitleStyle, + locationStyle: locationStyle as LocationStyle, + alertTiming: Number.parseInt(alertTime, 10) as typeof ALERT_TIMINGS[0], + }); + saveAs(`data:text/calendar;charset=utf-8,${encodeURIComponent(ics)}`, `${currentBasket.name}.ics`); + }; + + return ( + + + + Generate Calendar + + + + + Feel free to customize how the events are generated before downloading. + + + + Title style: + + CS1000 (Intro to Programming) + Intro to Programming + Into to Programming (CS1000) + + + + + Location style: + + Walker 0120A + Walker - Arts & Humanities 0120A + + + + + Alert: + + { + ALERT_TIMINGS.map(time => ( + + {time === 0 ? 'no alert' : `${time} minutes before`} + + )) + } + + + + + + + + + ); +}; + +export default observer(ExportCalendar); diff --git a/src/components/basket/export-options/crn-script.tsx b/src/components/basket/export-options/crn-script.tsx new file mode 100644 index 0000000..a27aa06 --- /dev/null +++ b/src/components/basket/export-options/crn-script.tsx @@ -0,0 +1,261 @@ +import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import {observer} from 'mobx-react-lite'; +import { + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalCloseButton, + ModalBody, + Button, + FormControl, + FormLabel, + Stack, + Text, + Radio, + RadioGroup, + usePrevious, + Box, + Input, + Kbd, + Alert, + AlertDescription, + AlertIcon, + AlertTitle, + useClipboard, +} from '@chakra-ui/react'; +import Bowser from 'bowser'; +import useStore from 'src/lib/state/context'; +import WrappedLink from 'src/components/link'; +import saveKeyboardScriptFor, {getKeyboardScriptFor, SupportedSoftware} from 'src/lib/save-keyboard-script-for-software'; +import {CopyIcon, DownloadIcon} from '@chakra-ui/icons'; + +type CRNScriptProps = { + isOpen: boolean; + onClose: () => void; +}; + +enum OS { + WINDOWS = 'Windows', + LINUX = 'Linux', + MACOS = 'macOS', +} + +type Software = { + label: SupportedSoftware; + href: string; + isDownloadable: boolean; + supportsShortcut: boolean; +}; + +const SOFTWARES: Record = { + Windows: [ + { + label: 'AutoHotkey', + href: 'https://www.autohotkey.com/', + isDownloadable: true, + supportsShortcut: true, + }, + ], + Linux: [ + { + label: 'Autokey', + href: 'https://github.com/autokey/autokey', + isDownloadable: false, + supportsShortcut: false, + }, + ], + macOS: [], +}; + +const CRNScript = ({isOpen, onClose}: CRNScriptProps) => { + const {allBasketsState: {currentBasket}} = useStore(); + const [shortcutKey, setShortcutKey] = useState('c'); + const [platform, setPlatform] = useState(undefined); + const [softwareLabel, setSoftwareLabel] = useState(); + + useEffect(() => { + const browser = Bowser.getParser(window.navigator.userAgent); + const {name} = browser.getOS(); + if (name && Object.values(OS).includes(name as OS)) { + setPlatform(name as OS); + } + }, []); + + // Update selected software when changing platforms + const previousPlatform = usePrevious(platform); + useEffect(() => { + if ( + previousPlatform + && platform + && previousPlatform !== platform + && !SOFTWARES[platform].some(s => s.label === softwareLabel)) { + setSoftwareLabel(SOFTWARES[platform][0]?.label ?? undefined); + } + }, [previousPlatform, platform, softwareLabel]); + + const isFormValid = useMemo(() => platform && softwareLabel, [platform, softwareLabel]); + + const currentSoftware = platform ? SOFTWARES[platform].find(s => s.label === softwareLabel) : undefined; + + // We're just using this for UI + const {onCopy, hasCopied} = useClipboard(''); + + const handleSubmit = useCallback((event: React.FormEvent) => { + event.preventDefault(); + + if (!currentSoftware || !currentBasket) { + return; + } + + if (currentSoftware.isDownloadable) { + saveKeyboardScriptFor( + currentSoftware.label, + currentBasket.sections.slice(0, 10), + currentBasket.name, + shortcutKey, + ); + } else { + void navigator.clipboard.writeText(getKeyboardScriptFor(currentSoftware.label, currentBasket.sections.slice(0, 10), shortcutKey)); + onCopy(); + } + }, [currentSoftware, currentBasket, shortcutKey, onCopy]); + + return ( + + + + Generate keyboard macro + + + + + + A keyboard macro can be used to register for all your courses at the press of a button, rather than copy/pasting each CRN individually. It's less error prone and might even give you a slight advantage when registering for sections that quickly fill. + + + + Want to test your macro? + + + + Don't see your favorite macro software listed? Open an issue. + + + + { + currentBasket && currentBasket.sectionIds.length > 10 && ( + + + Warning: + + You have more than 10 sections. The registration only supports entering 10 sections at a time, so the generated script will only cover the first 10 sections. + + + ) + } + + { + currentBasket && currentBasket.courseIds.length > 0 && ( + + + Warning: + + You have {currentBasket.courseIds.length} {currentBasket.courseIds.length > 2 ? 'courses' : 'course'} (instead of {currentBasket.courseIds.length > 2 ? 'sections' : 'section'}) in your basket. {currentBasket.courseIds.length > 2 ? 'They' : 'It'} will not be added to the generated script. + + + ) + } + + { + currentBasket && currentBasket.searchQueries.length > 0 && ( + + + Warning: + + You have {currentBasket.searchQueries.length} search {currentBasket.searchQueries.length > 2 ? 'queries' : 'query'} in your basket. {currentBasket.searchQueries.length > 2 ? 'They' : 'It'} will not be added to the generated script. + + + ) + } + + + Operating system: + + { + setPlatform(nextValue as OS); + }} + > + 🪟 Windows + 🍎 macOS + 🐧 Linux + + + + { + currentSoftware?.supportsShortcut && ( + + Shortcut: + + alt + {' + '} + { + setShortcutKey(event.target.value); + }}/> + + + ) + } + + + Software: + { + setSoftwareLabel(nextValue as SupportedSoftware); + }} + > + { + platform && SOFTWARES[platform].map(software => ( + + + {software.label} + + + )) + } + + + { + (!platform || SOFTWARES[platform].length === 0) && ( + Not currently supported. 😔 + ) + } + + + + + + + + ); +}; + +export default observer(CRNScript); diff --git a/src/components/basket/export-options/image.tsx b/src/components/basket/export-options/image.tsx new file mode 100644 index 0000000..f0ebb90 --- /dev/null +++ b/src/components/basket/export-options/image.tsx @@ -0,0 +1,174 @@ +import React, {useRef, useState, useMemo, useEffect} from 'react'; +import { + HStack, + Spacer, + Box, + LightMode, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalHeader, + ModalOverlay, + IconButton, + VStack, + Text, + Tooltip, + Spinner, +} from '@chakra-ui/react'; +import {CheckIcon, CopyIcon, DownloadIcon} from '@chakra-ui/icons'; +import {observer} from 'mobx-react-lite'; +import {captureToBlob} from 'src/lib/export-image'; +import saveAs from 'src/lib/save-as'; +import useEphemeralValue from 'src/lib/hooks/use-ephemeral-value'; +import useStore from 'src/lib/state/context'; + +import WrappedLink from 'src/components/link'; +import requestIdleCallbackGuard from 'src/lib/request-idle-callback-guard'; +import BasketTable from '../table'; + +type ExportImageProps = { + isOpen: boolean; + onClose: () => void; +}; + +const ExportImage = ({isOpen, onClose}: ExportImageProps) => { + const {allBasketsState: {currentBasket}, apiState} = useStore(); + const [hasCopied, setHasCopied] = useEphemeralValue(false, 500); + const [blob, setBlob] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const componentToCaptureRef = useRef(null); + + // Enable after data loads + useEffect(() => { + if (apiState.hasDataForTrackedEndpoints) { + setIsLoading(false); + } + }, [apiState.hasDataForTrackedEndpoints]); + + // Lazily render basket table offscreen to reduce jank + useEffect(() => { + if (isOpen) { + setIsLoading(true); + + // Wait 50ms for avatars to load in (should be cached) + requestIdleCallbackGuard(() => { + void captureToBlob(componentToCaptureRef).then(blob => { + setBlob(blob); + setIsLoading(false); + }); + }); + } + }, [isOpen]); + + const handleImageCopy = async () => { + if (blob) { + // Bad lib typings? + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const item = new ClipboardItem({'image/png': blob as any}); + await navigator.clipboard.write([item]); + setHasCopied(true); + } + }; + + const handleImageDownload = () => { + if (blob && currentBasket) { + saveAs(URL.createObjectURL(blob), currentBasket.name); + } + }; + + const canCopyImage = typeof ClipboardItem !== 'undefined'; + + const pngUri = useMemo(() => blob === null ? '' : URL.createObjectURL(blob), [blob]); + + return ( + <> + + + + { + isOpen && ( + + ) + } + + + + + + + + Share Image + + + + + { + isLoading ? ( + + ) : ( + Courses + ) + } + + + + { + !canCopyImage && ( + + ✨ tip: looks like you may need to + {' '} + + manually enable image copy + + {' '} + for your browser + + ) + } + + + + + : } + colorScheme={hasCopied ? 'green' : undefined} + aria-label="Copy image" + variant="ghost" + disabled={!canCopyImage || hasCopied} + onClick={handleImageCopy}/> + + + + } + aria-label="Download image" + variant="ghost" + onClick={handleImageDownload}/> + + + + + + + + ); +}; + +export default observer(ExportImage); diff --git a/src/components/basket/export-options/index.tsx b/src/components/basket/export-options/index.tsx new file mode 100644 index 0000000..2cb0d07 --- /dev/null +++ b/src/components/basket/export-options/index.tsx @@ -0,0 +1,89 @@ +import React, {useState, useEffect} from 'react'; +import { + Menu, + Button, + MenuButton, + MenuItem, + MenuList, + Box, + useDisclosure, +} from '@chakra-ui/react'; +import {ChevronDownIcon} from '@chakra-ui/icons'; +import {faShare} from '@fortawesome/free-solid-svg-icons'; +import {observer} from 'mobx-react-lite'; +import saveAs from 'src/lib/save-as'; +import useStore from 'src/lib/state/context'; +import WrappedFontAwesomeIcon from 'src/components/wrapped-font-awesome-icon'; +import ExportImage from './image'; +import ExportCalendar from './calendar'; +import CRNScript from './crn-script'; + +const ExportOptions = () => { + const {allBasketsState: {currentBasket}, apiState} = useStore(); + const [isLoading, setIsLoading] = useState(true); + const imageDisclosure = useDisclosure(); + const calendarDisclosure = useDisclosure(); + const crnDisclosure = useDisclosure(); + + // Enable after data loads + useEffect(() => { + if (apiState.hasDataForTrackedEndpoints) { + setIsLoading(false); + } + }, [apiState.hasDataForTrackedEndpoints]); + + const handleCSVExport = () => { + if (!currentBasket) { + return; + } + + const tsv = currentBasket.toTSV(); + saveAs(`data:text/plain;charset=utf-8,${encodeURIComponent(tsv)}`, `${currentBasket.name}.tsv`); + }; + + return ( + <> + + + {({isOpen}) => ( + <> + } + rightIcon={} + isLoading={isLoading} + > + Share & Export + + + Image + Calendar + CSV + Get registration macro script + + + )} + + + + + + + + + + ); +}; + +export default observer(ExportOptions); diff --git a/src/components/basket/floating-button.tsx b/src/components/basket/floating-button.tsx new file mode 100644 index 0000000..956d5a9 --- /dev/null +++ b/src/components/basket/floating-button.tsx @@ -0,0 +1,94 @@ +import React, {useEffect} from 'react'; +import {Box, useColorModeValue, useMultiStyleConfig, usePrevious} from '@chakra-ui/react'; +import {observer} from 'mobx-react-lite'; +import useEphemeralValue from 'src/lib/hooks/use-ephemeral-value'; +import useStore from 'src/lib/state/context'; + +type FloatingButtonProps = { + onOpen: () => void; +}; + +enum BasketSizeChange { + NONE, + ADDED, + REMOVED, +} + +const getBarColor = (forState: BasketSizeChange) => { + switch (forState) { + case BasketSizeChange.ADDED: + return 'green.400'; + case BasketSizeChange.REMOVED: + return 'red.400'; + default: + return 'gray.300'; + } +}; + +const FloatingButton = (props: FloatingButtonProps) => { + const [wasBasketSizeChanged, setWasBasketSizeChanged] = useEphemeralValue(BasketSizeChange.NONE); + const styles = useMultiStyleConfig('Drawer', {placement: 'bottom'}); + const bgColor = useColorModeValue('gray.100', styles.dialog.bg as string); + const {allBasketsState: {currentBasket}} = useStore(); + + const previousBasketSize = usePrevious(currentBasket?.numOfItems); + useEffect(() => { + if (previousBasketSize === undefined || !currentBasket) { + return; + } + + if (currentBasket.numOfItems !== previousBasketSize) { + if (currentBasket.numOfItems > previousBasketSize) { + setWasBasketSizeChanged(BasketSizeChange.ADDED); + } else { + setWasBasketSizeChanged(BasketSizeChange.REMOVED); + } + } + }, [currentBasket, previousBasketSize, setWasBasketSizeChanged]); + + return ( + + + + + + ); +}; + +export default observer(FloatingButton); diff --git a/src/components/basket/index.tsx b/src/components/basket/index.tsx new file mode 100644 index 0000000..7ad72b2 --- /dev/null +++ b/src/components/basket/index.tsx @@ -0,0 +1,279 @@ +import React, {useEffect, useCallback, useState, useMemo} from 'react'; +import { + Drawer, + DrawerBody, + DrawerFooter, + DrawerOverlay, + DrawerContent, + DrawerCloseButton, + Box, + useDisclosure, + useBreakpointValue, + usePrevious, + useToast, + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalCloseButton, + ModalBody, + HStack, + Text, + Kbd, + Button, + Spacer, + Heading, + useTimeout, +} from '@chakra-ui/react'; +import * as portals from 'react-reverse-portal'; +import {useHotkeys} from 'react-hotkeys-hook'; +import {observer} from 'mobx-react-lite'; +import useStore from 'src/lib/state/context'; +import useTip from 'src/lib/hooks/use-tip'; +import useHeldKey from 'src/lib/hooks/use-held-key'; +import {AddIcon} from '@chakra-ui/icons'; +import BasketContent from './content'; +import FloatingButton from './floating-button'; +import BasketCalendar, {BasketCalendarProvider} from './calendar/calendar'; +import {CalendarEvent} from './calendar/types'; +import BasketsSelectAndEdit from './basket-select-and-edit'; + +const Basket = observer(() => { + const toast = useToast(); + const {onOpen, isOpen, onClose} = useDisclosure(); + + const {allBasketsState, uiState, apiState} = useStore(); + const {currentBasket} = allBasketsState; + const previousBasketSize = usePrevious(currentBasket?.numOfItems); + + const isUltrawide = useBreakpointValue({base: false, '4xl': true}); + const wasPreviouslyUltrawide = usePrevious(isUltrawide); + + const onShowBasketTip = useTip('Tap the floating bar at the bottom, then \'Create a new basket\' to enable the add-to-basket buttons on courses and sections.', 'Tap \'Create a new basket\' to enable the add-to-basket buttons on courses and sections.'); + + useTimeout(() => { + if (allBasketsState.baskets.length === 0) { + onShowBasketTip(); + } + }, 8 * 1000); + + const onShowUndoTip = useTip('You can use normal undo / redo keyboard shortcuts.'); + useEffect(() => { + if (previousBasketSize && currentBasket?.numOfItems !== previousBasketSize) { + onShowUndoTip(); + } + }, [onShowUndoTip, currentBasket, previousBasketSize]); + + useHotkeys('ctrl+z, command+z', event => { + event.preventDefault(); + + const stateDidChange = currentBasket?.undoLastAction(); + + if (!stateDidChange) { + toast({ + title: 'Whoops', + status: 'warning', + description: 'Nothing to undo.', + duration: 400, + }); + } + }, [currentBasket, toast]); + useHotkeys('ctrl+shift+z, command+shift+z', event => { + event.preventDefault(); + + const stateDidChange = currentBasket?.redoLastAction(); + + if (!stateDidChange) { + toast({ + title: 'Whoops', + status: 'warning', + description: 'Nothing to redo.', + duration: 400, + }); + } + }, [currentBasket, toast]); + + const [isHeld] = useHeldKey({key: 'c', stopPropagation: false}); + const wasPreviouslyHeld = usePrevious(isHeld); + const calendarDisclosure = useDisclosure(); + + useEffect(() => { + if (isHeld && !wasPreviouslyHeld && currentBasket) { + calendarDisclosure.onToggle(); + } + }, [calendarDisclosure, isHeld, wasPreviouslyHeld, currentBasket]); + + // Ensure drawer state is synced when window is resized + useEffect(() => { + if (isUltrawide && !wasPreviouslyUltrawide) { + onClose(); + } + }, [isUltrawide, wasPreviouslyUltrawide, onClose]); + + const handleEventClick = useCallback((event: CalendarEvent) => { + calendarDisclosure.onClose(); + onClose(); + uiState.setSearchValue(`id:${event.section.id}`); + }, [uiState, calendarDisclosure, onClose]); + + const handleNewBasketCreation = () => { + if (apiState.selectedTerm) { + const newBasket = allBasketsState.addBasket(apiState.selectedTerm); + allBasketsState.setSelectedBasket(newBasket.id); + } + }; + + // Can't render Portals on server because library relies on document.* + const [canRenderPortals, setCanRenderPortals] = useState(false); + useEffect(() => { + setCanRenderPortals(true); + }, []); + + const contentPortalNode = useMemo(() => { + if (!canRenderPortals) { + return null; + } + + return portals.createHtmlPortalNode(); + }, [canRenderPortals]); + + const calendarPortalNode = useMemo(() => { + if (!canRenderPortals) { + return null; + } + + return portals.createHtmlPortalNode(); + }, [canRenderPortals]); + + return ( + + { + contentPortalNode ? ( + + { + currentBasket ? ( + + ) : ( + + + + ) + } + + ) :
+ } + + { + calendarPortalNode ? ( + + + + ) :
+ } + + { + isUltrawide ? ( + + + + + + { + contentPortalNode && ( + + ) + } + + + + + Calendar Preview + + + { + // Portals break if more than one OutPortal renders with + // the same node. + calendarPortalNode && !calendarDisclosure.isOpen && ( + + ) + } + + ) : ( + + ) + } + + + + + + + + + + + + + + hold c to see + + + + + + + + + + { + contentPortalNode && ( + + ) + } + + + + + + + + + + Calendar + + + + { + calendarPortalNode && ( + + ) + } + + + + + + ); +}); + +const BasketWithCalendarProvider = () => ( + + + +); + +export default BasketWithCalendarProvider; diff --git a/src/components/basket/styles/table.module.scss b/src/components/basket/styles/table.module.scss new file mode 100644 index 0000000..e5e12af --- /dev/null +++ b/src/components/basket/styles/table.module.scss @@ -0,0 +1,49 @@ +.table { + table-layout: fixed; + + td { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + thead tr th:nth-child(1) { + width: 16rem; + } + + thead tr th:nth-child(2) { + width: 7rem; + } + + thead tr th:nth-child(3) { + width: 15rem; + } + + thead tr th:nth-child(4) { + width: 18rem; + } + + thead tr th:nth-child(5) { // location + width: 8rem; + } + + thead tr th:nth-child(6) { + width: 8rem; + } + + thead tr th:nth-child(7) { + width: 8rem; + } + + thead tr th:nth-child(8) { + width: 8rem; + } + + thead tr th:nth-child(9) { + width: 5rem; + } + + thead tr th:nth-child(10) { + width: 6.5rem; + } +} diff --git a/src/components/basket/table.tsx b/src/components/basket/table.tsx new file mode 100644 index 0000000..dadf58f --- /dev/null +++ b/src/components/basket/table.tsx @@ -0,0 +1,356 @@ +import React from 'react'; +import {Search2Icon, DeleteIcon} from '@chakra-ui/icons'; +import {Table, Thead, Tr, Th, Tbody, Td, Tag, IconButton, TableProps, Tooltip, Wrap, Skeleton} from '@chakra-ui/react'; +import {observer} from 'mobx-react-lite'; +import InstructorList from 'src/components/sections-table/instructor-list'; +import LocationWithPopover from 'src/components/location-with-popover'; +import TimeDisplay from 'src/components/sections-table/time-display'; +import useStore from 'src/lib/state/context'; +import getCreditsString from 'src/lib/get-credits-str'; +import {BasketState} from 'src/lib/state/basket'; +import {ICourseFromAPI} from 'src/lib/api-types'; +import styles from './styles/table.module.scss'; + +const SkeletonRow = () => ( + + { + Array.from({length: 8}).map((_, i) => ( + // eslint-disable-next-line react/no-array-index-key + + + Some text + + + )) + } + + } + size="sm" + aria-label="Go to section"/> + + + } + size="sm" + aria-label="Remove from basket"/> + + +); + +type RowProps = { + isForCapture?: boolean; + handleSearch: (query: string) => void; +}; + +type SectionRowProps = RowProps & { + section: BasketState['sections'][0]; +}; + +const SectionRow = observer(({section, isForCapture, handleSearch}: SectionRowProps) => { + const {allBasketsState: {currentBasket}, apiState} = useStore(); + + const wasDeleted = section.deletedAt !== null; + + return ( + + + {section.course.subject} + {section.course.crse} + {' '} + {section.course.title} + + {section.section} + + + + + + + + + + {section.crn} + {getCreditsString(section.minCredits, section.maxCredits)} + { + !isForCapture && ( + <> + + + + {section.availableSeats} + + + {' / '} + + {section.totalSeats} + + + + } + size="sm" + aria-label="Go to section" + isDisabled={wasDeleted} + onClick={() => { + handleSearch(`id:${section.id}`); + }}/> + + + } + size="sm" + aria-label="Remove from basket" + onClick={() => { + currentBasket?.removeSection(section.id); + }}/> + + + ) + } + + ); +}); + +type CourseRowProps = RowProps & { + course: ICourseFromAPI; +}; + +const CourseRow = observer(({isForCapture, handleSearch, course}: CourseRowProps) => { + const {allBasketsState: {currentBasket}} = useStore(); + + const wasDeleted = course.deletedAt !== null; + + return ( + + + {course.subject} + {course.crse} + {' '} + {course.title} + + + + {getCreditsString(course.minCredits, course.maxCredits)} + + + { + !isForCapture && ( + <> + + + } + size="sm" + aria-label="Go to course" + isDisabled={wasDeleted} + onClick={() => { + handleSearch(`${course.subject}${course.crse}`); + }}/> + + + } + size="sm" + aria-label="Remove from basket" + onClick={() => { + currentBasket?.removeCourse(course.id); + }}/> + + + ) + } + + ); +}); + +type SearchQueryRowProps = RowProps & { + query: { + query: string; + credits?: [number, number]; + }; +}; + +const SearchQueryRow = observer(({isForCapture, handleSearch, query}: SearchQueryRowProps) => { + const {allBasketsState: {currentBasket}} = useStore(); + + return ( + + + + {query.query} + + + + + {query.credits ? getCreditsString(query.credits[0], query.credits[1] === Number.MAX_SAFE_INTEGER ? 4 : query.credits[1]) : ''} + + + { + !isForCapture && ( + <> + + + } + size="sm" + aria-label="Go to section" + onClick={() => { + handleSearch(query.query); + }}/> + + + } + size="sm" + aria-label="Remove from basket" + onClick={() => { + currentBasket?.removeSearchQuery(query.query); + }}/> + + + ) + } + + ); +}); + +type BasketTableProps = { + onClose?: () => void; + isForCapture?: boolean; + tableProps?: TableProps; +}; + +const BodyWithData = observer(({onClose, isForCapture}: BasketTableProps) => { + const {allBasketsState: {currentBasket}, uiState} = useStore(); + + const handleSearch = (query: string) => { + uiState.setSearchValue(query); + if (onClose) { + onClose(); + } + }; + + return ( + + { + currentBasket?.sections.map(section => ( + + )) + } + + { + currentBasket?.courses.map(course => ( + + )) + } + + { + currentBasket?.parsedQueries.map(query => ( + + )) + } + + + Total: + + + {currentBasket ? getCreditsString(...currentBasket.totalCredits) : ''} + + { + !isForCapture && ( + <> + + + + + ) + } + + + ); +}); + +const BasketTable = (props: BasketTableProps) => { + const {apiState} = useStore(); + + return ( + + + + + + + + + + + { + !props.isForCapture && ( + <> + + + + + ) + } + + + + { + apiState.hasDataForTrackedEndpoints ? ( + + ) : ( + + {Array.from({length: 4}).map((_, i) => ( + // eslint-disable-next-line react/no-array-index-key + + ))} + + ) + } +
TitleSectionInstructorsScheduleLocationCRNCreditsSeatsGoRemove
+ ); +}; + +export default observer(BasketTable); diff --git a/components/color-mode-toggle.tsx b/src/components/color-mode-toggle.tsx similarity index 76% rename from components/color-mode-toggle.tsx rename to src/components/color-mode-toggle.tsx index f30ff1f..5d775c5 100644 --- a/components/color-mode-toggle.tsx +++ b/src/components/color-mode-toggle.tsx @@ -6,7 +6,7 @@ const ColorModeToggle = () => { const {colorMode, toggleColorMode} = useColorMode(); return ( - + {colorMode === 'light' ? : } ); diff --git a/components/conditional-wrapper.tsx b/src/components/conditional-wrapper.tsx similarity index 100% rename from components/conditional-wrapper.tsx rename to src/components/conditional-wrapper.tsx diff --git a/components/course-fail-drop-chart.tsx b/src/components/course-fail-drop-chart.tsx similarity index 73% rename from components/course-fail-drop-chart.tsx rename to src/components/course-fail-drop-chart.tsx index c5e71b4..abb9910 100644 --- a/components/course-fail-drop-chart.tsx +++ b/src/components/course-fail-drop-chart.tsx @@ -1,19 +1,19 @@ +import React, {useMemo} from 'react'; import dynamic from 'next/dynamic'; import {useColorModeValue, useToken} from '@chakra-ui/react'; import {observer} from 'mobx-react-lite'; -import {IPassFailDropRecord} from '../lib/types'; -import {SEMESTER_DISPLAY_MAPPING} from '../lib/constants'; -import {useMemo} from 'react'; +import {IPassFailDropRecord} from 'src/lib/api-types'; +import {SEMESTER_DISPLAY_MAPPING} from 'src/lib/constants'; -const LazyLoadedResponsiveLne = dynamic(async () => import('./custom-responsive-line')); +const LazyLoadedResponsiveLine = dynamic(async () => import('./custom-responsive-line')); const MyResponsiveLine = ({data}: {data: IPassFailDropRecord[]}) => { - const [darkText, red, yellow] = useToken('colors', ['white', 'red.400', 'yellow.400']); + const [darkText, red, yellow] = useToken('colors', ['white', 'red.400', 'yellow.400']) as string[]; const chartTheme = useColorModeValue( // Light theme { - background: 'transparent' + background: 'transparent', }, // Dark theme { @@ -21,15 +21,15 @@ const MyResponsiveLine = ({data}: {data: IPassFailDropRecord[]}) => { textColor: darkText, tooltip: { container: { - color: 'black' - } + color: 'black', + }, }, crosshair: { line: { - stroke: 'white' - } - } - } + stroke: 'white', + }, + }, + }, ); const transformedData = useMemo(() => { @@ -41,29 +41,30 @@ const MyResponsiveLine = ({data}: {data: IPassFailDropRecord[]}) => { droppedData.push({ x: key, - y: record.dropped / record.total + y: record.dropped / record.total, }); failedData.push({ x: key, - y: record.failed / record.total + y: record.failed / record.total, }); } return [ { id: 'dropped', - data: droppedData + data: droppedData, }, { id: 'failed', - data: failedData - } + data: failedData, + }, ]; }, [data]); return ( - { yFormat=" >-.2%" axisTop={null} axisRight={null} - axisBottom={{ - orient: 'bottom' - }} axisLeft={{ - orient: 'left', - format: '>-.2%' + format: '>-.2%', }} pointSize={10} pointBorderWidth={2} pointLabelYOffset={-12} - useMesh legends={[ { anchor: 'right', @@ -98,8 +94,8 @@ const MyResponsiveLine = ({data}: {data: IPassFailDropRecord[]}) => { itemOpacity: 0.75, symbolSize: 12, symbolShape: 'circle', - symbolBorderColor: 'rgba(0, 0, 0, .5)' - } + symbolBorderColor: 'rgba(0, 0, 0, .5)', + }, ]} /> ); diff --git a/components/course-stats.tsx b/src/components/course-stats.tsx similarity index 85% rename from components/course-stats.tsx rename to src/components/course-stats.tsx index 7cfbc62..4f669ca 100644 --- a/components/course-stats.tsx +++ b/src/components/course-stats.tsx @@ -2,19 +2,17 @@ import React, {useCallback, useState} from 'react'; import {Spacer, HStack, VStack, useDisclosure, Box, Stat, StatHelpText, StatLabel, StatNumber, Button, Collapse, StackProps, useBreakpointValue} from '@chakra-ui/react'; import {CalendarIcon} from '@chakra-ui/icons'; import {observer} from 'mobx-react-lite'; +import {IPassFailDropRecord} from 'src/lib/api-types'; +import {SEMESTER_DISPLAY_MAPPING} from 'src/lib/constants'; import Chart from './course-fail-drop-chart'; -import {IPassFailDropRecord} from '../lib/types'; -import {SEMESTER_DISPLAY_MAPPING} from '../lib/constants'; const SEMESTER_VALUES = { SPRING: 0.1, SUMMER: 0.2, - FALL: 0.3 + FALL: 0.3, }; -const formatPercentage = (value: number) => { - return `${(100 * value).toFixed(2)}%`; -}; +const formatPercentage = (value: number) => `${(100 * value).toFixed(2)}%`; const CourseStats = (props: StackProps & {data: IPassFailDropRecord[]}) => { const [shouldLoadChart, setShouldLoadChart] = useState(false); @@ -64,12 +62,12 @@ const CourseStats = (props: StackProps & {data: IPassFailDropRecord[]}) => { - - + diff --git a/src/components/courses-table/details-row.tsx b/src/components/courses-table/details-row.tsx new file mode 100644 index 0000000..f99a8fa --- /dev/null +++ b/src/components/courses-table/details-row.tsx @@ -0,0 +1,149 @@ +import React from 'react'; +import { + Tr, + Td, + VStack, + Text, + Box, + Heading, + Button, + Collapse, + IconButton, + HStack, + Spacer, + Stack, + Tooltip, +} from '@chakra-ui/react'; +import {observer} from 'mobx-react-lite'; +import {faShare} from '@fortawesome/free-solid-svg-icons'; +import SectionsTable from 'src/components/sections-table'; +import CourseStats from 'src/components/course-stats'; +import useStore from 'src/lib/state/context'; +import {ICourseWithFilteredSections} from 'src/lib/state/ui'; +import WrappedFontAwesomeIcon from 'src/components/wrapped-font-awesome-icon'; +import toTitleCase from 'src/lib/to-title-case'; +import {AddIcon, DeleteIcon} from '@chakra-ui/icons'; + +const Stats = observer(({courseKey}: {courseKey: string}) => { + const store = useStore(); + + const data = store.apiState.passfaildrop[courseKey]; + + if (!data) { + return null; + } + + return ( + + Stats + + + + + + ); +}); + +const DetailsRow = ({course, onlyShowSections, onShowEverything, onShareCourse}: {course: ICourseWithFilteredSections; onlyShowSections: boolean; onShowEverything: () => void; onShareCourse: () => void}) => { + const {allBasketsState: {currentBasket}} = useStore(); + const courseKey = `${course.course.subject}${course.course.crse}`; + + const courseSections = course.sections.wasFiltered ? course.sections.filtered : course.sections.all; + + const isCourseInBasket = currentBasket?.hasCourse(course.course.id); + + const handleBasketAction = () => { + if (isCourseInBasket) { + currentBasket?.removeCourse(course.course.id); + } else { + currentBasket?.addCourse(course.course.id); + } + }; + + return ( + + + + { + onlyShowSections && ( + + ) + } + + + + + + + + Description: + {course.course.description} + + + { + course.course.offered.length > 0 && ( + + Semesters offered: + {toTitleCase(course.course.offered.join(', '))} + + ) + } + + + + + + } + aria-label="Share course" + variant="ghost" + colorScheme="brand" + title="Share course" + onClick={onShareCourse}/> + + + : } + isDisabled={!currentBasket} + aria-label="Add course to basket" + size="xs" + colorScheme={isCourseInBasket ? 'red' : undefined} + onClick={handleBasketAction}/> + + + + + { + course.course.prereqs && ( + + Prereqs: + {course.course.prereqs} + + ) + } + + + + + + + { + courseSections.length > 0 && ( + + {!onlyShowSections && ( + Sections + )} + + + + ) + } + + + + ); +}; + +export default observer(DetailsRow); diff --git a/components/courses-table/index.tsx b/src/components/courses-table/index.tsx similarity index 63% rename from components/courses-table/index.tsx rename to src/components/courses-table/index.tsx index 891329e..b80eda5 100644 --- a/components/courses-table/index.tsx +++ b/src/components/courses-table/index.tsx @@ -1,26 +1,32 @@ -import React, {useCallback, useEffect} from 'react'; +import React, {useCallback, useEffect, useMemo} from 'react'; import {Table, Thead, Tbody, Tr, Th, VStack, useBreakpointValue, useToast} from '@chakra-ui/react'; import {observer} from 'mobx-react-lite'; -import useStore from '../../lib/state-context'; -import TableRow from './row'; +import useStore from 'src/lib/state/context'; +import DataFilterStatsBar from 'src/components/data-filter-stats-bar'; +import TablePageControls from 'src/components/table-page-controls'; +import useTablePagination from 'src/lib/hooks/use-table-pagination'; +import {ICourseFromAPI} from 'src/lib/api-types'; +import {encodeShareable} from 'src/lib/sharables'; import SkeletonRow from './skeleton-row'; -import DataFilterStatsBar from '../data-filter-stats-bar'; -import TablePageControls from '../table-page-controls'; -import useTablePagination from '../../lib/use-table-pagination'; -import {ICourseFromAPI} from '../../lib/types'; -import {encodeShareable} from '../../lib/sharables'; +import TableRow from './row'; +import styles from './styles/table.module.scss'; const TableBody = observer(({startAt, endAt, onShareCourse}: {startAt: number; endAt: number; onShareCourse: (course: ICourseFromAPI) => void}) => { const store = useStore(); + const slicedCourses = useMemo(() => store.uiState.filteredCourses.slice(startAt, endAt), [store.uiState.filteredCourses, startAt, endAt]); + return ( { - store.apiState.hasDataForTrackedEndpoints ? - store.uiState.filteredCourses.slice(startAt, endAt).map(course => { - onShareCourse(course.course); - }}/>) : - Array.from(Array.from({length: endAt - startAt}).keys()).map(i => ( + store.apiState.hasDataForTrackedEndpoints + ? slicedCourses.map(course => ( + { + onShareCourse(course.course); + }}/> + )) + : Array.from(Array.from({length: endAt - startAt}).keys()).map(i => ( )) } @@ -41,12 +47,12 @@ const CoursesTable = ({onScrollToTop}: {onScrollToTop: () => void}) => { page, pageSize, availableSizes, - numberOfPages + numberOfPages, } = useTablePagination({ len: (store.uiState.filteredCourses.length > 0 ? store.uiState.filteredCourses.length : 1), onPageChange: () => { onScrollToTop(); - } + }, }); const totalCoursesString = store.apiState.coursesNotDeleted.length.toLocaleString(); @@ -54,10 +60,10 @@ const CoursesTable = ({onScrollToTop}: {onScrollToTop: () => void}) => { // Reset page when # of search results change useEffect(() => { setPage(0); - }, [store.uiState.filteredCourses.length]); + }, [store.uiState.filteredCourses.length, setPage]); const handleShareCourse = useCallback(async (course: ICourseFromAPI) => { - const url = new URL('/', window.location.origin); + const url = new URL('/shared', window.location.origin); url.searchParams.set( 'share', @@ -65,12 +71,14 @@ const CoursesTable = ({onScrollToTop}: {onScrollToTop: () => void}) => { version: 1, type: 'SHARE_COURSE', data: { - year: course.year, - semester: course.semester, + term: { + semester: course.semester, + year: course.year, + }, subject: course.subject, - crse: course.crse - } - }) + crse: course.crse, + }, + }), ); try { @@ -81,13 +89,14 @@ const CoursesTable = ({onScrollToTop}: {onScrollToTop: () => void}) => { title: 'Copied', description: `A link to ${course.title} was copied to your clipboard.`, status: 'success', - duration: 4000 + duration: 4000, + position: 'bottom-right', }); } }, [toast]); return ( - + void}) => { updatedAt={store.apiState.dataLastUpdatedAt} label="courses" /> - +
- - + + diff --git a/components/courses-table/row.tsx b/src/components/courses-table/row.tsx similarity index 53% rename from components/courses-table/row.tsx rename to src/components/courses-table/row.tsx index 945d85f..fdd7b5b 100644 --- a/components/courses-table/row.tsx +++ b/src/components/courses-table/row.tsx @@ -1,10 +1,10 @@ -import React, {useCallback, useLayoutEffect, useMemo, useState} from 'react'; -import {Tr, Td, IconButton, Text, useDisclosure, usePrevious, Box} from '@chakra-ui/react'; +import React, {useCallback, useEffect, useState} from 'react'; +import {Tr, Td, IconButton, useDisclosure, usePrevious} from '@chakra-ui/react'; import {InfoIcon, InfoOutlineIcon} from '@chakra-ui/icons'; import {observer} from 'mobx-react-lite'; +import getCreditsString from 'src/lib/get-credits-str'; +import {ICourseWithFilteredSections} from 'src/lib/state/ui'; import styles from './styles/table.module.scss'; -import getCreditsStr from '../../lib/get-credits-str'; -import {ICourseWithFilteredSections} from '../../lib/ui-state'; import DetailsRow from './details-row'; const TableRow = observer(({course, onShareCourse}: {course: ICourseWithFilteredSections; onShareCourse: () => void}) => { @@ -12,29 +12,7 @@ const TableRow = observer(({course, onShareCourse}: {course: ICourseWithFiltered const [onlyShowSections, setOnlyShowSections] = useState(false); const wasPreviouslyFiltered = usePrevious(course.sections.wasFiltered); - const sections = course.sections.all; - - const creditsString: string = useMemo(() => { - if (sections.length === 0) { - return ''; - } - - let min = 10000; - let max = 0; - for (const s of sections) { - if (s.minCredits < min) { - min = s.minCredits; - } - - if (s.maxCredits > max) { - max = s.maxCredits; - } - } - - return getCreditsStr(min, max); - }, [sections]); - - useLayoutEffect(() => { + useEffect(() => { if (course.sections.wasFiltered !== wasPreviouslyFiltered) { if (course.sections.wasFiltered) { setOnlyShowSections(true); @@ -60,37 +38,24 @@ const TableRow = observer(({course, onShareCourse}: {course: ICourseWithFiltered <> - - - - + diff --git a/components/courses-table/skeleton-row.tsx b/src/components/courses-table/skeleton-row.tsx similarity index 71% rename from components/courses-table/skeleton-row.tsx rename to src/components/courses-table/skeleton-row.tsx index 893046f..f89e14f 100644 --- a/components/courses-table/skeleton-row.tsx +++ b/src/components/courses-table/skeleton-row.tsx @@ -24,11 +24,14 @@ const SkeletonRow = () => ( left={0} right={0} top={0} - bottom={0}> - + - An introduction to programming with Java. An introduction to programming with Java. An introduction to programming with Java. An introduction to programming with Java. An introduction to programming with Java. + textOverflow="ellipsis" + > + An introduction to programming with Java. An introduction to programming with Java. An introduction to programming with Java. An introduction to programming with Java. An introduction to programming with Java. @@ -38,7 +41,8 @@ const SkeletonRow = () => ( variant="ghost" colorScheme="blue" aria-label="Loading..." - data-testid="course-details-button"> + data-testid="course-details-button" + > diff --git a/src/components/courses-table/styles/table.module.scss b/src/components/courses-table/styles/table.module.scss new file mode 100644 index 0000000..074d81d --- /dev/null +++ b/src/components/courses-table/styles/table.module.scss @@ -0,0 +1,31 @@ +.hideBottomBorder { + td { + border-bottom-width: 0; + } +} + +.table { + table-layout: fixed; + + td { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + thead tr th:nth-child(1) { + width: 8rem; + } + + thead tr th:nth-child(2) { + width: 16rem; + } + + thead tr th:nth-child(4) { + width: 7rem; + } + + thead tr th:nth-child(5) { + width: 8rem; + } +} diff --git a/components/custom-responsive-line.tsx b/src/components/custom-responsive-line.tsx similarity index 100% rename from components/custom-responsive-line.tsx rename to src/components/custom-responsive-line.tsx diff --git a/src/components/data-filter-stats-bar.tsx b/src/components/data-filter-stats-bar.tsx new file mode 100644 index 0000000..3939e5d --- /dev/null +++ b/src/components/data-filter-stats-bar.tsx @@ -0,0 +1,37 @@ +import React, {useMemo} from 'react'; +import {HStack, Skeleton, Spacer, Text} from '@chakra-ui/react'; +import {formatDistance} from 'date-fns'; +import useCurrentDate from 'src/lib/hooks/use-current-date'; +import InlineStat from './inline-stat'; + +const LastUpdatedAt = ({updatedAt}: {updatedAt: Date}) => { + const now = useCurrentDate(5000); + + const lastUpdatedString = useMemo(() => formatDistance(updatedAt, now, {addSuffix: true}), [updatedAt, now]); + + return data last updated {lastUpdatedString}; +}; + +type Props = { + isLoaded: boolean; + matched: string; + total: string; + updatedAt: Date; + label: string; +}; + +const DataFilterStatsBar = (options: Props) => ( + + + + + + + + + + + +); + +export default DataFilterStatsBar; diff --git a/src/components/editable-controls.tsx b/src/components/editable-controls.tsx new file mode 100644 index 0000000..7fadbc2 --- /dev/null +++ b/src/components/editable-controls.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { + ButtonGroup, + IconButton, + useEditableControls, + Flex, + Tooltip, +} from '@chakra-ui/react'; +import {CheckIcon, CloseIcon, EditIcon} from '@chakra-ui/icons'; + +const EditableControls = () => { + const { + isEditing, + getSubmitButtonProps, + getCancelButtonProps, + getEditButtonProps, + } = useEditableControls(); + + return isEditing ? ( + + } {...getSubmitButtonProps()} aria-label="save"/> + } {...getCancelButtonProps()} aria-label="cancel"/> + + ) : ( + + + } {...getEditButtonProps()} aria-label="edit"/> + + + ); +}; + +export default EditableControls; diff --git a/components/error-toaster.tsx b/src/components/error-toaster.tsx similarity index 76% rename from components/error-toaster.tsx rename to src/components/error-toaster.tsx index 32307c8..f10975b 100644 --- a/components/error-toaster.tsx +++ b/src/components/error-toaster.tsx @@ -1,8 +1,8 @@ import {useRef, useEffect} from 'react'; -import {useToast} from '@chakra-ui/toast'; +import {useToast} from '@chakra-ui/react'; import {observer} from 'mobx-react-lite'; -import useStore from '../lib/state-context'; -import useIsOffline from '../lib/use-is-offline'; +import useStore from 'src/lib/state/context'; +import useIsOffline from 'src/lib/hooks/use-is-offline'; const ErrorToaster = observer(() => { const store = useStore(); @@ -21,7 +21,8 @@ const ErrorToaster = observer(() => { description: 'Looks like you\'re offline.', status: 'warning', duration: null, - isClosable: false + isClosable: false, + position: 'bottom-right', }); return; } @@ -36,7 +37,8 @@ const ErrorToaster = observer(() => { description: 'There was an error fetching data.', status: 'error', duration: null, - isClosable: false + isClosable: false, + position: 'bottom-right', }); return; } @@ -45,7 +47,7 @@ const ErrorToaster = observer(() => { toast.close(toastRef.current); toastRef.current = undefined; } - }, [store.apiState.errors.length, isOffline]); + }, [store.apiState.errors.length, isOffline, toast]); return null; }); diff --git a/src/components/inline-stat.tsx b/src/components/inline-stat.tsx new file mode 100644 index 0000000..cad3997 --- /dev/null +++ b/src/components/inline-stat.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import {Box, Text} from '@chakra-ui/react'; + +const InlineStat = ({label, number, help}: {label: string; number: string; help: string}) => ( + + {label} + + {number} + + {help} + +); + +export default InlineStat; diff --git a/components/instructor-with-popover.tsx b/src/components/instructor-with-popover.tsx similarity index 73% rename from components/instructor-with-popover.tsx rename to src/components/instructor-with-popover.tsx index ce6916e..f976a15 100644 --- a/components/instructor-with-popover.tsx +++ b/src/components/instructor-with-popover.tsx @@ -1,23 +1,44 @@ import React from 'react'; -import {Avatar, Button, PopoverContent, Popover, PopoverArrow, PopoverBody, PopoverCloseButton, PopoverTrigger, Text, Divider, VStack, HStack, Spacer} from '@chakra-ui/react'; +import { + Avatar, + Button, + PopoverContent, + Popover, + PopoverArrow, + PopoverBody, + PopoverCloseButton, + PopoverTrigger, + Text, + Divider, + VStack, + HStack, + Spacer, +} from '@chakra-ui/react'; import {observer} from 'mobx-react-lite'; -import Link from './link'; -import {IInstructorFromAPI} from '../lib/types'; -import useStore from '../lib/state-context'; -import rmpIdToURL from '../lib/rmp-id-to-url'; +import {IInstructorFromAPI} from 'src/lib/api-types'; +import useStore from 'src/lib/state/context'; +import rmpIdToURL from 'src/lib/rmp-id-to-url'; import {EmailIcon, PhoneIcon} from '@chakra-ui/icons'; +import Link from './link'; interface IInstructorWithPopoverProps { id: IInstructorFromAPI['id']; showName: boolean; + showAvatar?: boolean; } const INSTRUCTORS_WITH_ALTERNATIVE_IMAGES = ['ureel']; const InstructorWithPopover = (props: IInstructorWithPopoverProps) => { - const store = useStore(); + const { + id, + showName, + showAvatar = true, + } = props; - const instructor = store.apiState.instructorsById.get(props.id); + const {apiState} = useStore(); + + const instructor = apiState.instructorsById.get(id); if (!instructor) { return null; @@ -30,24 +51,38 @@ const InstructorWithPopover = (props: IInstructorWithPopoverProps) => { return ( - - - + + diff --git a/components/link.tsx b/src/components/link.tsx similarity index 85% rename from components/link.tsx rename to src/components/link.tsx index 06c4f0a..743a638 100644 --- a/components/link.tsx +++ b/src/components/link.tsx @@ -5,12 +5,13 @@ import {ExternalLinkIcon} from '@chakra-ui/icons'; interface IWrappedLinkProps { href: string; + isExternal?: boolean; } const WrappedLink = (props: LinkProps & IWrappedLinkProps) => { - const {href, ...otherProps} = props; + const {href, isExternal, ...otherProps} = props; - if (href.includes(':')) { + if (href.includes(':') || isExternal) { // External link return ( diff --git a/src/components/location-with-popover.tsx b/src/components/location-with-popover.tsx new file mode 100644 index 0000000..f52385b --- /dev/null +++ b/src/components/location-with-popover.tsx @@ -0,0 +1,95 @@ +import React, {useMemo} from 'react'; +import dynamic from 'next/dynamic'; +import {observer} from 'mobx-react-lite'; +import { + Box, + Button, + Popover, + PopoverArrow, + PopoverBody, + PopoverCloseButton, + PopoverContent, + PopoverTrigger, +} from '@chakra-ui/react'; +import {faLocationArrow} from '@fortawesome/free-solid-svg-icons'; +import {ELocationType, IBuildingFromAPI, ISectionFromAPI} from 'src/lib/api-types'; +import {Props} from 'react-mapbox-gl/lib/marker'; +import WrappedFontAwesomeIcon from './wrapped-font-awesome-icon'; + +const Marker = dynamic(async () => import('react-mapbox-gl').then(mod => mod.Marker)); +const WrappedMap = dynamic(async () => import('./wrapped-map')); + +type LocationWithPopoverProps = Pick & { + building?: IBuildingFromAPI; + hasLabelOnly?: boolean; +}; + +const LocationWithPopover = (props: LocationWithPopoverProps) => { + const label = useMemo(() => { + switch (props.locationType) { + case ELocationType.ONLINE: + return 'Online'; + case ELocationType.REMOTE: + return 'Remote'; + case ELocationType.PHYSICAL: + if (props.building) { + return `${props.building.shortName ?? ''} ${props.room ?? ''}`.trim(); + } + + return '¯\\_(ツ)_/¯'; + + default: + return '¯\\_(ツ)_/¯'; + } + }, [props]); + + if (props.locationType === ELocationType.PHYSICAL && props.building && !props.hasLabelOnly) { + return ( + + + + + + + + + + + + + + + + + + + + ); + } + + return ( + + {label} + + ); +}; + +export default observer(LocationWithPopover); diff --git a/src/components/mobile-device-warning.tsx b/src/components/mobile-device-warning.tsx new file mode 100644 index 0000000..6d0133b --- /dev/null +++ b/src/components/mobile-device-warning.tsx @@ -0,0 +1,44 @@ +import React, {useEffect} from 'react'; +import { + Button, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + useDisclosure, +} from '@chakra-ui/react'; +import Bowser from 'bowser'; + +const MobileDeviceWarning = () => { + const {isOpen, onClose, onOpen} = useDisclosure(); + + useEffect(() => { + const browser = Bowser.getParser(window.navigator.userAgent); + + if (browser.getPlatform().type !== 'desktop') { + onOpen(); + } + }, [onOpen]); + + return ( + + + + Warning + + This site is primarily made for laptop and desktop use. There's just too much information to effectively display it on mobile devices. + + + + + + + + ); +}; + +export default MobileDeviceWarning; diff --git a/components/navbar.tsx b/src/components/navbar.tsx similarity index 52% rename from components/navbar.tsx rename to src/components/navbar.tsx index 30ae8c9..7f74b28 100644 --- a/components/navbar.tsx +++ b/src/components/navbar.tsx @@ -3,27 +3,39 @@ import {Flex, Box, Select, IconButton, HStack} from '@chakra-ui/react'; import {CloseIcon, HamburgerIcon} from '@chakra-ui/icons'; import {useRouter} from 'next/router'; import {observer} from 'mobx-react-lite'; -import useStore from '../lib/state-context'; +import useStore from 'src/lib/state/context'; +import Logo from 'public/images/logo.svg'; +import {SEMESTER_DISPLAY_MAPPING} from 'src/lib/constants'; +import {IPotentialFutureTerm} from 'src/lib/types'; +import toTitleCase from 'src/lib/to-title-case'; import ColorModeToggle from './color-mode-toggle'; -import Logo from '../public/images/logo.svg'; import Link from './link'; -import {SEMESTER_DISPLAY_MAPPING} from '../lib/constants'; const PAGES = [ { label: 'Courses', - href: '/' + href: '/', }, { label: 'Transfer Courses', - href: '/transfer-courses' + href: '/transfer-courses', }, { label: 'About', - href: '/about' - } + href: '/about', + }, ]; +const getTermDisplayName = (term: IPotentialFutureTerm) => { + if (term.isFuture) { + return toTitleCase(`Future ${term.semester.toLowerCase()} Semester`); + } + + return `${SEMESTER_DISPLAY_MAPPING[term.semester]} ${term.year}`; +}; + +const PATHS_THAT_REQUIRE_TERM_SELECTOR = new Set(['/', '/help/registration-script']); + const Navbar = () => { const router = useRouter(); const store = useStore(); @@ -32,18 +44,19 @@ const Navbar = () => { setIsOpen(o => !o); }, []); - const handleSemesterSelect = useCallback(async (event: React.ChangeEvent) => { - store.apiState.setSelectedSemester(JSON.parse(event.target.value)); - await store.apiState.revalidate(); + const handleTermSelect = useCallback(async (event: React.ChangeEvent) => { + store.apiState.setSelectedTerm(JSON.parse(event.target.value)); }, [store]); + const shouldShowTermSelector = PATHS_THAT_REQUIRE_TERM_SELECTOR.has(router.pathname); + return ( - + {isOpen ? : } @@ -53,10 +66,20 @@ const Navbar = () => { display={{base: isOpen ? 'block' : 'none', md: 'flex'}} width={{base: 'full', md: 'auto'}} alignItems="center" - flexGrow={1}> + flexGrow={1} + > { PAGES.map(page => ( - {page.label} + + {page.label} + )) } @@ -67,23 +90,22 @@ const Navbar = () => { > { - router.pathname === '/' && ( + shouldShowTermSelector && ( { + props.onChange(event.target.value); + }} + onKeyDown={handleKeydown} + /> + + + {props.value !== '' && ( + + {props.rightButtons} + + } + aria-label="Clear query" + rounded="full" + size="xs" + onClick={() => { + props.onChange(''); + }}/> + + )} + + + + {props.children && ( + + + hold / to see + + + + )} + + + + {props.children} + + + ); +}; + +export default DefaultSearchBar; diff --git a/src/components/sections-table/index.tsx b/src/components/sections-table/index.tsx new file mode 100644 index 0000000..cb8be6a --- /dev/null +++ b/src/components/sections-table/index.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import {Table, Thead, Tbody, Tr, Th, Td, Tag, useBreakpointValue, TableProps, TableContainer, IconButton, Wrap, Tooltip} from '@chakra-ui/react'; +import {observer} from 'mobx-react-lite'; +import {ISectionFromAPIWithSchedule} from 'src/lib/api-types'; +import getCreditsStr from 'src/lib/get-credits-str'; +import {AddIcon, DeleteIcon} from '@chakra-ui/icons'; +import useStore from 'src/lib/state/context'; +import LocationWithPopover from 'src/components/location-with-popover'; +import InstructorList from './instructor-list'; +import TimeDisplay from './time-display'; +import styles from './styles/table.module.scss'; + +interface ISectionsTableProps { + sections: ISectionFromAPIWithSchedule[]; +} + +const Row = observer(({section}: {section: ISectionFromAPIWithSchedule}) => { + const {allBasketsState: {currentBasket}, apiState} = useStore(); + const creditsString = getCreditsStr(section.minCredits, section.maxCredits); + + const isSectionInBasket = currentBasket?.hasSection(section.id); + + const handleBasketAction = () => { + if (isSectionInBasket) { + currentBasket?.removeSection(section.id); + } else { + currentBasket?.addSection(section.id); + } + }; + + return ( + + + + + + + + + + + + + ); +}); + +const TableBody = observer(({sections}: {sections: ISectionFromAPIWithSchedule[]}) => ( + + { + sections.map(s => ( + + )) + } + +)); + +const SectionsTable = ({sections, ...props}: TableProps & ISectionsTableProps) => { + const tableSize = useBreakpointValue({base: 'sm', lg: 'md'}); + + return ( + +
Course TitleCredits DescriptionDetailsCreditsDetails
- - {course.course.subject}{course.course.crse} - + {course.course.subject}{course.course.crse} + {course.course.title} {creditsString} - - {course.course.description} - + + {course.course.description} + {getCreditsString(course.course.minCredits, course.course.maxCredits)} + data-testid="course-details-button" + onClick={onToggle} + > {isOpen ? : }
{section.section} + + + + + + {section.crn}{creditsString} + + + {section.availableSeats} + + + {' / '} + + {section.totalSeats} + + + : } + isDisabled={!currentBasket} + aria-label={isSectionInBasket ? 'Remove from basket' : 'Add to basket'} + onClick={handleBasketAction}/> +
+ + + + + + + + + + + + + + +
SectionInstructorsScheduleLocationCRNCreditsSeatsBasket
+ + ); +}; + +export default observer(SectionsTable); diff --git a/src/components/sections-table/instructor-list.tsx b/src/components/sections-table/instructor-list.tsx new file mode 100644 index 0000000..9d7def4 --- /dev/null +++ b/src/components/sections-table/instructor-list.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import {observer} from 'mobx-react-lite'; +import {Wrap, WrapItem} from '@chakra-ui/react'; +import {IInstructorFromAPI} from 'src/lib/api-types'; +import InstructorWithPopover from 'src/components/instructor-with-popover'; + +type InstructorListProps = { + instructors: Array<{id: IInstructorFromAPI['id']}>; + showAvatar?: boolean; +}; + +const InstructorList = observer(({instructors, showAvatar}: InstructorListProps) => ( + + { + instructors.length > 0 + ? instructors.map(instructor => ( + + + + )) : ( + + ¯\_(ツ)_/¯ + + ) + } + +)); + +export default InstructorList; diff --git a/src/components/sections-table/styles/table.module.scss b/src/components/sections-table/styles/table.module.scss new file mode 100644 index 0000000..b9e2af6 --- /dev/null +++ b/src/components/sections-table/styles/table.module.scss @@ -0,0 +1,37 @@ +.table { + table-layout: fixed; + + td { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + thead tr th:nth-child(1) { // section + width: 6rem; + } + + thead tr th:nth-child(3) { // schedule + width: 16rem; + } + + thead tr th:nth-child(4) { // location + width: 10rem; + } + + thead tr th:nth-child(5) { // crn + width: 7rem; + } + + thead tr th:nth-child(6) { // credits + width: 7rem; + } + + thead tr th:nth-child(7) { // seats + width: 10rem; + } + + thead tr th:nth-child(8) { // basket button + width: 6rem; + } +} diff --git a/src/components/sections-table/time-display.tsx b/src/components/sections-table/time-display.tsx new file mode 100644 index 0000000..8513285 --- /dev/null +++ b/src/components/sections-table/time-display.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import {Tooltip, Tag, TagProps, ThemingProps} from '@chakra-ui/react'; +import {observer} from 'mobx-react-lite'; +import {Schedule} from 'src/lib/rschedule'; +import {DATE_DAY_CHAR_MAP} from 'src/lib/constants'; + +const padTime = (v: number) => v.toString().padStart(2, '0'); + +const DAYS_95_IN_MS = 95 * 24 * 60 * 60 * 1000; + +export const getFormattedTimeFromSchedule = (schedule?: Schedule | null) => { + let days = ''; + let time = ''; + let start = new Date(); + let end = new Date(); + + if (schedule) { + const occurences = schedule.collections({granularity: 'week', weekStart: 'SU'}).toArray(); + + if (occurences.length > 0) { + for (const d of occurences[0].dates) { + days += DATE_DAY_CHAR_MAP[d.date.getDay()]; + + const start = d.date; + const end = d.end; + + time = `${padTime(start.getHours())}:${padTime(start.getMinutes())} ${start.getHours() >= 12 ? 'PM' : 'AM'} - ${padTime(end?.getHours() ?? 0)}:${padTime(end?.getMinutes() ?? 0)} ${(end?.getHours() ?? 0) >= 12 ? 'PM' : 'AM'}`; + } + } + + start = schedule.firstDate?.toDateTime().date ?? new Date(); + end = schedule.lastDate?.toDateTime().date ?? new Date(); + } + + return { + days, + time, + start: start.toLocaleDateString('en-US'), + end: end.toLocaleDateString('en-US'), + isHalf: (end.getTime() - start.getTime() < DAYS_95_IN_MS), + }; +}; + +type TimeDisplayProps = { + schedule?: Schedule | null; + size?: TagProps['size']; + colorScheme?: ThemingProps['colorScheme']; +}; + +const TimeDisplay = observer((props: TimeDisplayProps) => { + const {days, time, start, end, isHalf} = getFormattedTimeFromSchedule(props.schedule); + + if (time === '') { + return <>¯\_(ツ)_/¯; + } + + let colorScheme = isHalf ? 'yellow' : 'green'; + + if (props.colorScheme) { + colorScheme = props.colorScheme; + } + + return ( + + + {days} + {time} + + + ); +}); + +export default TimeDisplay; diff --git a/components/social-previews/course.tsx b/src/components/social-previews/course.tsx similarity index 86% rename from components/social-previews/course.tsx rename to src/components/social-previews/course.tsx index 7f59b17..55b9188 100644 --- a/components/social-previews/course.tsx +++ b/src/components/social-previews/course.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import {Box, Text, VStack} from '@chakra-ui/layout'; -import HuskyIcon from '../../public/images/husky-icon.svg'; +import {Box, Text, VStack} from '@chakra-ui/react'; +import HuskyIcon from 'public/images/husky-icon.svg'; type Props = { title: string; @@ -17,7 +17,8 @@ const SocialPreviewCourse = ({title, semester}: Props) => ( d="flex" justifyContent="space-between" position="relative" - p={12}> + p={12} + > ( textOverflow="ellipsis" overflow="hidden" noOfLines={2} - mb="4rem"> + mb="4rem" + > {title} diff --git a/components/social-previews/wrapper.tsx b/src/components/social-previews/wrapper.tsx similarity index 100% rename from components/social-previews/wrapper.tsx rename to src/components/social-previews/wrapper.tsx diff --git a/components/table-page-controls.tsx b/src/components/table-page-controls.tsx similarity index 96% rename from components/table-page-controls.tsx rename to src/components/table-page-controls.tsx index 2fc2a68..a3afd35 100644 --- a/components/table-page-controls.tsx +++ b/src/components/table-page-controls.tsx @@ -1,5 +1,5 @@ -import {ArrowLeftIcon, ChevronLeftIcon, ChevronRightIcon, ArrowRightIcon} from '@chakra-ui/icons'; import React, {Dispatch, SetStateAction} from 'react'; +import {ArrowLeftIcon, ChevronLeftIcon, ChevronRightIcon, ArrowRightIcon} from '@chakra-ui/icons'; import {TableCaption, HStack, IconButton, Spacer, Skeleton, Select, Text} from '@chakra-ui/react'; type Props = { @@ -49,13 +49,13 @@ const TablePageControls = ({page, pageSize, setPage, isEnabled, numberOfPages, o size="sm" aria-label="Change number of rows per page" value={pageSize} + disabled={!isEnabled} onChange={event => { onPageSizeChange(Number.parseInt(event.target.value, 10)); }} - disabled={!isEnabled} > {availableSizes.map(o => ( - + ))} diff --git a/components/transfer-courses-table/index.tsx b/src/components/transfer-courses-table/index.tsx similarity index 78% rename from components/transfer-courses-table/index.tsx rename to src/components/transfer-courses-table/index.tsx index d1b6c5c..1aac808 100644 --- a/components/transfer-courses-table/index.tsx +++ b/src/components/transfer-courses-table/index.tsx @@ -1,10 +1,10 @@ import React, {useEffect} from 'react'; import {observer} from 'mobx-react-lite'; import {Table, TableContainer, Tbody, Th, Thead, Tr, useBreakpointValue, VStack} from '@chakra-ui/react'; -import DataFilterStatsBar from '../data-filter-stats-bar'; -import TablePageControls from '../table-page-controls'; -import useStore from '../../lib/state-context'; -import useTablePagination from '../../lib/use-table-pagination'; +import DataFilterStatsBar from 'src/components/data-filter-stats-bar'; +import TablePageControls from 'src/components/table-page-controls'; +import useStore from 'src/lib/state/context'; +import useTablePagination from 'src/lib/hooks/use-table-pagination'; import TableRow from './row'; import SkeletonRow from './skeleton-row'; @@ -14,9 +14,9 @@ const TableBody = observer(({startAt, endAt}: {startAt: number; endAt: number}) return ( { - store.transferCoursesState.hasData ? - store.transferCoursesState.filteredCourses.slice(startAt, endAt).map(course => ) : - Array.from(Array.from({length: endAt - startAt}).keys()).map(i => ( + store.transferCoursesState.hasData + ? store.transferCoursesState.filteredCourses.slice(startAt, endAt).map(course => ) + : Array.from(Array.from({length: endAt - startAt}).keys()).map(i => ( )) } @@ -37,18 +37,18 @@ const TransferCoursesTable = ({onScrollToTop}: {onScrollToTop: () => void}) => { page, pageSize, availableSizes, - numberOfPages + numberOfPages, } = useTablePagination({ len: (store.transferCoursesState.filteredCourses.length > 0 ? store.transferCoursesState.filteredCourses.length : 1), onPageChange: () => { onScrollToTop(); - } + }, }); // Reset page when # of search results change useEffect(() => { setPage(0); - }, [store.transferCoursesState.filteredCourses.length]); + }, [store.transferCoursesState.filteredCourses.length, setPage]); return ( @@ -68,8 +68,8 @@ const TransferCoursesTable = ({onScrollToTop}: {onScrollToTop: () => void}) => { setPage={setPage} isEnabled={store.transferCoursesState.hasData} numberOfPages={numberOfPages} - onPageSizeChange={handlePageSizeChange} availableSizes={availableSizes} + onPageSizeChange={handlePageSizeChange} /> diff --git a/src/components/transfer-courses-table/row.tsx b/src/components/transfer-courses-table/row.tsx new file mode 100644 index 0000000..53da878 --- /dev/null +++ b/src/components/transfer-courses-table/row.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import {Td, Tr} from '@chakra-ui/react'; +import {observer} from 'mobx-react-lite'; +import {ITransferCourseFromAPI} from 'src/lib/api-types'; + +const Row = ({course}: {course: ITransferCourseFromAPI}) => ( + + + + {course.fromSubject}{course.fromCRSE} + + + + + + {course.toSubject}{course.toCRSE} + + + + + {course.title} + + + + {course.fromCollege} + + + + {course.fromCollegeState} + + + + {course.toCredits} + + +); + +export default observer(Row); diff --git a/components/transfer-courses-table/skeleton-row.tsx b/src/components/transfer-courses-table/skeleton-row.tsx similarity index 100% rename from components/transfer-courses-table/skeleton-row.tsx rename to src/components/transfer-courses-table/skeleton-row.tsx diff --git a/src/components/wrapped-font-awesome-icon.tsx b/src/components/wrapped-font-awesome-icon.tsx new file mode 100644 index 0000000..c8a8062 --- /dev/null +++ b/src/components/wrapped-font-awesome-icon.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import {Icon, IconProps} from '@chakra-ui/react'; +import {FontAwesomeIcon, FontAwesomeIconProps} from '@fortawesome/react-fontawesome'; + +const WrappedFontAwesomeIcon = (props: IconProps & Pick) => ( + +); + +export default WrappedFontAwesomeIcon; diff --git a/src/components/wrapped-map.tsx b/src/components/wrapped-map.tsx new file mode 100644 index 0000000..9fc7a08 --- /dev/null +++ b/src/components/wrapped-map.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import {Except} from 'type-fest'; +import {useColorModeValue, chakra} from '@chakra-ui/react'; +import ReactMapboxGl from 'react-mapbox-gl'; +import 'mapbox-gl/dist/mapbox-gl.css'; + +// eslint-disable-next-line new-cap +const Map = ReactMapboxGl({ + accessToken: process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN!, +}); + +const ColorModeAwareMap = (props: Except[0], 'style'>) => { + const mapStyle = useColorModeValue('mapbox://styles/mapbox/light-v9', 'mapbox://styles/mapbox/dark-v9'); + + return ; +}; + +const WrappedMap = chakra(ColorModeAwareMap); + +export default WrappedMap; + +export {Marker} from 'react-mapbox-gl'; diff --git a/lib/types.ts b/src/lib/api-types.ts similarity index 76% rename from lib/types.ts rename to src/lib/api-types.ts index 94a42c4..337ed76 100644 --- a/lib/types.ts +++ b/src/lib/api-types.ts @@ -1,9 +1,17 @@ import {Except} from 'type-fest'; +import {Schedule} from './rschedule'; export enum ESemester { SPRING = 'SPRING', FALL = 'FALL', - SUMMER = 'SUMMER' + SUMMER = 'SUMMER', +} + +export enum ELocationType { + PHYSICAL = 'PHYSICAL', + ONLINE = 'ONLINE', + REMOTE = 'REMOTE', + UNKNOWN = 'UNKNOWN', } export interface IInstructorFromAPI { @@ -36,6 +44,13 @@ export type IPassFailDropRecord = { export type IPassFailDropFromAPI = Record; +export interface IBuildingFromAPI { + name: string; + shortName: string; + lat: number; + lon: number; +} + export interface ISectionFromAPI { id: string; courseId: string; @@ -44,7 +59,7 @@ export interface ISectionFromAPI { cmp: string; minCredits: number; maxCredits: number; - time: Record; + time: Schedule.JSON; totalSeats: number; takenSeats: number; availableSeats: number; @@ -52,10 +67,17 @@ export interface ISectionFromAPI { instructors: Array<{ id: number; }>; + locationType: ELocationType; + buildingName: string | null; + room: string | null; updatedAt: string; deletedAt: string | null; } +export type ISectionFromAPIWithSchedule = ISectionFromAPI & { + parsedTime: Schedule | null; +}; + export interface ICourseFromAPI { id: string; year: number; @@ -67,6 +89,9 @@ export interface ICourseFromAPI { prereqs: string | null; updatedAt: string; deletedAt: string | null; + offered: string[]; + minCredits: number; + maxCredits: number; } export interface ITransferCourseFromAPI { id: string; diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..5254f65 --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,38 @@ +import {ESemester, IFullCourseFromAPI, IPassFailDropFromAPI} from './api-types'; + +export interface IFindFirstCourseParameters { + semester?: ESemester; + year?: number; + subject: string; + crse: string; +} + +const API = { + findFirstCourse: async (options: IFindFirstCourseParameters): Promise => { + const url = new URL('/courses/first', process.env.NEXT_PUBLIC_API_ENDPOINT); + + if (options.semester) { + url.searchParams.set('semester', options.semester); + } + + if (options.year) { + url.searchParams.set('year', options.year.toString()); + } + + url.searchParams.set('subject', options.subject); + url.searchParams.set('crse', options.crse); + + return (await fetch(url.toString())).json() as Promise; + }, + + getStats: async (options: {crse: string; subject: string}): Promise => { + const url = new URL('/passfaildrop', process.env.NEXT_PUBLIC_API_ENDPOINT); + + url.searchParams.set('courseSubject', options.subject); + url.searchParams.set('courseCrse', options.crse); + + return (await fetch(url.toString())).json() as Promise; + }, +}; + +export default API; diff --git a/src/lib/are-terms-equal.ts b/src/lib/are-terms-equal.ts new file mode 100644 index 0000000..351b2e2 --- /dev/null +++ b/src/lib/are-terms-equal.ts @@ -0,0 +1,22 @@ +import {IPotentialFutureTerm} from './types'; + +const areTermsEqual = (firstTerm: IPotentialFutureTerm, secondTerm: IPotentialFutureTerm) => { + if (firstTerm.isFuture) { + if ( + secondTerm.isFuture + && firstTerm.semester === secondTerm.semester) { + return true; + } + + return false; + } + + if (!secondTerm.isFuture && firstTerm.semester === secondTerm.semester + && firstTerm.year === secondTerm.year) { + return true; + } + + return false; +}; + +export default areTermsEqual; diff --git a/lib/arr-map.ts b/src/lib/arr-map.ts similarity index 100% rename from lib/arr-map.ts rename to src/lib/arr-map.ts diff --git a/src/lib/async-request-idle-callback.ts b/src/lib/async-request-idle-callback.ts new file mode 100644 index 0000000..96cd3a6 --- /dev/null +++ b/src/lib/async-request-idle-callback.ts @@ -0,0 +1,14 @@ +import requestIdleCallbackGuard from './request-idle-callback-guard'; + +const asyncRequestIdleCallback = async (callback: () => Promise, options?: IdleRequestOptions): Promise => new Promise((resolve, reject) => { + requestIdleCallbackGuard(async () => { + try { + await callback(); + resolve(); + } catch (error: unknown) { + reject(error); + } + }, options); +}); + +export default asyncRequestIdleCallback; diff --git a/src/lib/compare-times.ts b/src/lib/compare-times.ts new file mode 100644 index 0000000..20b7c05 --- /dev/null +++ b/src/lib/compare-times.ts @@ -0,0 +1,26 @@ +/** Compare two times. + * Returns 0 if equal, -1 if first time is earlier, 1 if first time is later + */ +const compareTimes = (time1: Date, time2: Date) => { + const hours1 = time1.getHours(); + const minutes1 = time1.getMinutes(); + const hours2 = time2.getHours(); + const minutes2 = time2.getMinutes(); + + const totalMinutes1 = (hours1 * 60) + minutes1; + const totalMinutes2 = (hours2 * 60) + minutes2; + + const diff = totalMinutes1 - totalMinutes2; + + if (diff === 0) { + return 0; + } + + if (diff > 0) { + return 1; + } + + return -1; +}; + +export default compareTimes; diff --git a/lib/constants.ts b/src/lib/constants.ts similarity index 89% rename from lib/constants.ts rename to src/lib/constants.ts index 1ade290..ea30417 100644 --- a/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,7 +1,7 @@ export const SEMESTER_DISPLAY_MAPPING = { SPRING: 'Spring', SUMMER: 'Summer', - FALL: 'Fall' + FALL: 'Fall', }; export const DATE_DAY_CHAR_MAP: Record = { @@ -11,5 +11,5 @@ export const DATE_DAY_CHAR_MAP: Record = { 3: 'W', 4: 'R', 5: 'F', - 6: 'A' + 6: 'A', }; diff --git a/src/lib/do-schedules-conflict.test.ts b/src/lib/do-schedules-conflict.test.ts new file mode 100644 index 0000000..5c53ed7 --- /dev/null +++ b/src/lib/do-schedules-conflict.test.ts @@ -0,0 +1,143 @@ +import doSchedulesConflict from './do-schedules-conflict'; +import {Schedule} from './rschedule'; + +const ONE_HOUR_IN_MS = 60 * 60 * 1000; +const ONE_WEEK_IN_MS = 7 * 24 * ONE_HOUR_IN_MS; + +const startOfSemester = new Date('07/01/2021 10:00 AM'); + +const THRICE_WEEKLY = new Schedule({ + rrules: [ + { + frequency: 'WEEKLY', + duration: ONE_HOUR_IN_MS, + byDayOfWeek: ['MO', 'WE', 'FR'], + start: startOfSemester, + end: new Date(startOfSemester.getTime() + (26 * ONE_WEEK_IN_MS)), + }, + ], +}); + +const THRICE_WEEKLY_LATER_IN_DAY = new Schedule({ + rrules: [ + { + frequency: 'WEEKLY', + duration: ONE_HOUR_IN_MS, + byDayOfWeek: ['MO', 'WE', 'FR'], + start: new Date(startOfSemester.getTime() + (2 * ONE_HOUR_IN_MS)), + end: new Date(startOfSemester.getTime() + (26 * ONE_WEEK_IN_MS)), + }, + ], +}); + +const THRICE_WEEKLY_BEGINNING_OF_SEMESTER = new Schedule({ + rrules: [ + { + frequency: 'WEEKLY', + duration: ONE_HOUR_IN_MS, + byDayOfWeek: ['MO', 'WE', 'FR'], + start: startOfSemester, + end: new Date(startOfSemester.getTime() + (13 * ONE_WEEK_IN_MS)), + }, + ], +}); + +const THRICE_WEEKLY_END_OF_SEMESTER = new Schedule({ + rrules: [ + { + frequency: 'WEEKLY', + duration: ONE_HOUR_IN_MS, + byDayOfWeek: ['MO', 'WE', 'FR'], + start: new Date(startOfSemester.getTime() + (13 * ONE_WEEK_IN_MS)), + end: new Date(startOfSemester.getTime() + (26 * ONE_WEEK_IN_MS)), + }, + ], +}); + +const THRICE_WEEKLY_SLIGHTLY_BEFORE = new Schedule({ + rrules: [ + { + frequency: 'WEEKLY', + duration: ONE_HOUR_IN_MS, + byDayOfWeek: ['MO', 'WE', 'FR'], + start: new Date(startOfSemester.getTime() - (ONE_HOUR_IN_MS / 2)), + end: new Date(startOfSemester.getTime() + (26 * ONE_WEEK_IN_MS)), + }, + ], +}); + +const THRICE_WEEKLY_SLIGHTLY_LATER = new Schedule({ + rrules: [ + { + frequency: 'WEEKLY', + duration: ONE_HOUR_IN_MS, + byDayOfWeek: ['MO', 'WE', 'FR'], + start: new Date(startOfSemester.getTime() + (ONE_HOUR_IN_MS / 2)), + end: new Date(startOfSemester.getTime() + (26 * ONE_WEEK_IN_MS)), + }, + ], +}); + +const TWICE_WEEKLY = new Schedule({ + rrules: [ + { + frequency: 'WEEKLY', + duration: ONE_HOUR_IN_MS, + byDayOfWeek: ['TU', 'TH'], + start: startOfSemester, + end: new Date(startOfSemester.getTime() + (26 * ONE_WEEK_IN_MS)), + }, + ], +}); + +const ONCE_WEEKLY_SLIGHTLY_BEFORE_END_OF_SEMESTER = new Schedule({ + rrules: [ + { + frequency: 'WEEKLY', + duration: ONE_HOUR_IN_MS, + byDayOfWeek: ['FR'], + start: new Date(startOfSemester.getTime() + (13 * ONE_WEEK_IN_MS)), + end: new Date(startOfSemester.getTime() + (26 * ONE_WEEK_IN_MS)), + }, + ], +}); + +test('true if identical schedules', () => { + expect(doSchedulesConflict(THRICE_WEEKLY, THRICE_WEEKLY)).toBe(true); +}); + +test('false if different schedules', () => { + expect(doSchedulesConflict(THRICE_WEEKLY, TWICE_WEEKLY)).toBe(false); +}); + +test('false if schedules are on same day but later', () => { + expect(doSchedulesConflict(THRICE_WEEKLY, THRICE_WEEKLY_LATER_IN_DAY)).toBe(false); +}); + +test('true if schedules slightly overlap (1)', () => { + expect(doSchedulesConflict(THRICE_WEEKLY, THRICE_WEEKLY_SLIGHTLY_BEFORE)).toBe(true); +}); + +test('true if schedules slightly overlap (2)', () => { + expect(doSchedulesConflict(THRICE_WEEKLY, THRICE_WEEKLY_SLIGHTLY_LATER)).toBe(true); +}); + +test('false if schedules are identical but occur different calendar periods (1)', () => { + expect(doSchedulesConflict(THRICE_WEEKLY_BEGINNING_OF_SEMESTER, THRICE_WEEKLY_END_OF_SEMESTER)).toBe(false); +}); + +test('false if schedules are identical but occur different calendar periods (2)', () => { + expect(doSchedulesConflict(THRICE_WEEKLY_END_OF_SEMESTER, THRICE_WEEKLY_BEGINNING_OF_SEMESTER)).toBe(false); +}); + +test('true if schedules overlap at beginning of semester', () => { + expect(doSchedulesConflict(THRICE_WEEKLY_BEGINNING_OF_SEMESTER, THRICE_WEEKLY_SLIGHTLY_BEFORE)).toBe(true); +}); + +test('true if schedules overlap at end of semester (1)', () => { + expect(doSchedulesConflict(THRICE_WEEKLY_END_OF_SEMESTER, THRICE_WEEKLY_SLIGHTLY_LATER)).toBe(true); +}); + +test('true if schedules overlap at end of semester (2)', () => { + expect(doSchedulesConflict(THRICE_WEEKLY, ONCE_WEEKLY_SLIGHTLY_BEFORE_END_OF_SEMESTER)).toBe(true); +}); diff --git a/src/lib/do-schedules-conflict.ts b/src/lib/do-schedules-conflict.ts new file mode 100644 index 0000000..ed0846f --- /dev/null +++ b/src/lib/do-schedules-conflict.ts @@ -0,0 +1,91 @@ +import compareTimes from './compare-times'; +import {Schedule, Rule} from './rschedule'; + +const getCommonElementsInArrays = (array1: T[], array2: T[]) => { + const common: T[] = []; + + for (const element of array1) { + if (array2.includes(element)) { + common.push(element); + } + } + + return common; +}; + +const addDuration = (date: Date, ms: number) => new Date(date.getTime() + ms); + +const doTwoRulesConflict = (firstRule: Rule, secondRule: Rule) => { + if (!firstRule.firstDate?.date || !firstRule.lastDate?.date) { + return false; + } + + if (!secondRule.firstDate?.date || !secondRule.lastDate?.date) { + return false; + } + + // If rules occur during completely separate calendar periods + if (firstRule.lastDate.date < secondRule.firstDate.date + || secondRule.lastDate.date < firstRule.firstDate.date) { + return false; + } + + const firstByDayOfWeek = firstRule.options.byDayOfWeek; + const secondByDayOfWeek = secondRule.options.byDayOfWeek; + + if (!firstByDayOfWeek || !secondByDayOfWeek) { + return false; + } + + const firstStart = firstRule.firstDate.date; + const firstEnd = addDuration(firstStart, firstRule.duration!); + const secondStart = secondRule.firstDate.date; + const secondEnd = addDuration(secondStart, secondRule.duration!); + + if (getCommonElementsInArrays(firstByDayOfWeek, secondByDayOfWeek).length > 0) { + const compareStartResult = compareTimes(firstStart, secondStart); + + if (compareStartResult === 0) { + return true; + } + + if (compareStartResult === -1) { // Check if first starts before second + // Check if end overlaps + if (compareTimes(firstEnd, secondStart) === 1) { + return true; + } + } else if (compareStartResult === 1 // Check if first starts after second + // Check if start overlaps + && compareTimes(firstStart, secondEnd) === -1) { + return true; + } + } +}; + +const doSchedulesConflict = (firstSchedule: Schedule, secondSchedule: Schedule) => { + // There's a much more elegant solution to this using the intersection operator from rSchedule. + // However, static analysis is far faster. + // See: https://gitlab.com/john.carroll.p/rschedule/-/issues/61 + + // Quick & cheap check + if (firstSchedule.firstDate?.date.getTime() === secondSchedule.firstDate?.date.getTime()) { + return true; + } + + const firstRuleSet = firstSchedule.rrules; + const secondRuleSet = secondSchedule.rrules; + + // In the vast majority of cases there will be a single rule in each set, + // so O(n^2) shouldn't matter too much. (Famous last words...) + for (const currentFirstRuleSetRule of firstRuleSet) { + for (const currentSecondRuleSetRule of secondRuleSet) { + if (doTwoRulesConflict(currentFirstRuleSetRule, currentSecondRuleSetRule)) { + return true; + } + } + } + + return false; +}; + +export default doSchedulesConflict; diff --git a/src/lib/export-image.tsx b/src/lib/export-image.tsx new file mode 100644 index 0000000..4f3eeb5 --- /dev/null +++ b/src/lib/export-image.tsx @@ -0,0 +1,14 @@ +import {RefObject} from 'react'; +import domToImage from 'dom-to-image'; + +export const captureToBlob = async (node: RefObject) => { + if (!node.current) { + throw new Error('Could not find element by ref.'); + } + + const blob = await domToImage.toBlob(node.current, { + bgcolor: 'transparent', + }); + + return blob; +}; diff --git a/lib/get-credits-str.ts b/src/lib/get-credits-str.ts similarity index 100% rename from lib/get-credits-str.ts rename to src/lib/get-credits-str.ts diff --git a/lib/use-background-color.ts b/src/lib/hooks/use-background-color.ts similarity index 88% rename from lib/use-background-color.ts rename to src/lib/hooks/use-background-color.ts index ef22766..e26b038 100644 --- a/lib/use-background-color.ts +++ b/src/lib/hooks/use-background-color.ts @@ -1,7 +1,7 @@ import {useToken, useColorModeValue} from '@chakra-ui/react'; const useBackgroundColor = () => { - const [lightBackground, darkBackground] = useToken('colors', ['white', 'gray.800']); + const [lightBackground, darkBackground] = useToken('colors', ['white', 'gray.800']) as string[]; return useColorModeValue(lightBackground, darkBackground); }; diff --git a/lib/use-current-date.tsx b/src/lib/hooks/use-current-date.tsx similarity index 100% rename from lib/use-current-date.tsx rename to src/lib/hooks/use-current-date.tsx diff --git a/src/lib/hooks/use-ephemeral-value.ts b/src/lib/hooks/use-ephemeral-value.ts new file mode 100644 index 0000000..1cbacd5 --- /dev/null +++ b/src/lib/hooks/use-ephemeral-value.ts @@ -0,0 +1,21 @@ +import {Dispatch, SetStateAction, useEffect, useState} from 'react'; + +const useEphemeralValue = (defaultState: T, duration = 200): [T, Dispatch>] => { + const [state, setState] = useState(defaultState); + + useEffect(() => { + if (state !== defaultState) { + const timerId = setTimeout(() => { + setState(defaultState); + }, duration); + + return () => { + clearTimeout(timerId); + }; + } + }, [state, duration, defaultState]); + + return [state, setState]; +}; + +export default useEphemeralValue; diff --git a/lib/use-held-key.ts b/src/lib/hooks/use-held-key.ts similarity index 79% rename from lib/use-held-key.ts rename to src/lib/hooks/use-held-key.ts index 3993602..0ec5f0f 100644 --- a/lib/use-held-key.ts +++ b/src/lib/hooks/use-held-key.ts @@ -2,14 +2,16 @@ import {useCallback, useEffect, useRef, useState} from 'react'; type TReturnType = [boolean, (event: KeyboardEvent | React.KeyboardEvent) => void]; -const useHeldKey = ({key, minDuration = 100}: {key: string; minDuration?: number}): TReturnType => { +const useHeldKey = ({key, minDuration = 100, stopPropagation = true}: {key: string; minDuration?: number; stopPropagation?: boolean}): TReturnType => { const [triggered, setTriggered] = useState(false); const timeoutRef = useRef(null); const handleKeydown = useCallback((event: KeyboardEvent | React.KeyboardEvent) => { if (event.key === key) { - event.stopPropagation(); - event.preventDefault(); + if (stopPropagation) { + event.stopPropagation(); + event.preventDefault(); + } if (!triggered && !timeoutRef.current) { timeoutRef.current = setTimeout(() => { @@ -17,7 +19,7 @@ const useHeldKey = ({key, minDuration = 100}: {key: string; minDuration?: number }, minDuration); } } - }, [triggered]); + }, [triggered, key, minDuration, stopPropagation]); const handleKeyup = useCallback((event: KeyboardEvent) => { if (event.key === key) { @@ -30,7 +32,7 @@ const useHeldKey = ({key, minDuration = 100}: {key: string; minDuration?: number setTriggered(false); } - }, []); + }, [key]); useEffect(() => { document.addEventListener('keydown', handleKeydown); diff --git a/lib/use-is-offline.ts b/src/lib/hooks/use-is-offline.ts similarity index 74% rename from lib/use-is-offline.ts rename to src/lib/hooks/use-is-offline.ts index 44d04d3..487e799 100644 --- a/lib/use-is-offline.ts +++ b/src/lib/hooks/use-is-offline.ts @@ -1,13 +1,15 @@ import {useState, useEffect} from 'react'; -const useIsOffline = () => { - if (typeof window === 'undefined') { - return false; - } +const isServer = typeof window === 'undefined'; - const [isOnline, setOnlineStatus] = useState(window.navigator.onLine); +const useIsOffline = () => { + const [isOnline, setOnlineStatus] = useState(isServer ? true : window.navigator.onLine); useEffect(() => { + if (isServer) { + return; + } + const toggleOnlineStatus = () => { setOnlineStatus(window.navigator.onLine); }; diff --git a/lib/use-revalidation.ts b/src/lib/hooks/use-revalidation.ts similarity index 53% rename from lib/use-revalidation.ts rename to src/lib/hooks/use-revalidation.ts index 6e53779..5eb67de 100644 --- a/lib/use-revalidation.ts +++ b/src/lib/hooks/use-revalidation.ts @@ -1,35 +1,38 @@ -import {useCallback, useEffect, useMemo, useState} from 'react'; +import {useEffect, useMemo, useState} from 'react'; import pThrottle from 'p-throttle'; -import useInterval from './use-interval'; -import useWindowFocus from './use-window-focus'; +import {useCallbackRef, useInterval} from '@chakra-ui/react'; +import useWindowFocus, {UseWindowFocusParameters} from './use-window-focus'; const useRevalidation = (doRevalidation: boolean, revalidate: () => Promise, interval = 3000) => { + const revalidateRef = useCallbackRef(revalidate); const [shouldRefetchAtInterval, setShouldRefetchAtInterval] = useState(doRevalidation); - const throttledRevalidation = useMemo(() => pThrottle({limit: 1, interval: 1000})(revalidate), [revalidate]); + const throttledRevalidation = useMemo(() => pThrottle({limit: 1, interval})(revalidateRef), [revalidateRef, interval]); - useWindowFocus({ - onFocus: useCallback(() => { + const focusParameters: UseWindowFocusParameters = useMemo(() => ({ + onFocus: () => { setShouldRefetchAtInterval(true); void throttledRevalidation(); - }, [revalidate]), - onBlur: useCallback(() => { + }, + onBlur: () => { setShouldRefetchAtInterval(false); - }, []) - }); + }, + }), [throttledRevalidation]); + + useWindowFocus(focusParameters); useEffect(() => { if (doRevalidation) { void throttledRevalidation(); setShouldRefetchAtInterval(doRevalidation); } - }, [doRevalidation]); + }, [doRevalidation, throttledRevalidation]); useInterval( async () => { void throttledRevalidation(); }, - shouldRefetchAtInterval ? interval : null + shouldRefetchAtInterval ? interval : null, ); }; diff --git a/src/lib/hooks/use-screen-size.ts b/src/lib/hooks/use-screen-size.ts new file mode 100644 index 0000000..4927cbe --- /dev/null +++ b/src/lib/hooks/use-screen-size.ts @@ -0,0 +1,14 @@ +const useScreenSize = () => { + if (typeof window !== 'undefined') { + const {width, height} = window.screen; + + return {width, height}; + } + + return { + width: 0, + height: 0, + }; +}; + +export default useScreenSize; diff --git a/lib/use-table-pagination.ts b/src/lib/hooks/use-table-pagination.ts similarity index 97% rename from lib/use-table-pagination.ts rename to src/lib/hooks/use-table-pagination.ts index 1c2afed..4ddba46 100644 --- a/lib/use-table-pagination.ts +++ b/src/lib/hooks/use-table-pagination.ts @@ -14,7 +14,7 @@ const useTablePagination = ({len, onPageChange}: {len: number; onPageChange: () const newNumberOfPages = Math.ceil(len / newPageSize); setPage(p => Math.floor((p / numberOfPages) * newNumberOfPages)); setPageSize(newPageSize); - }, [numberOfPages]); + }, [numberOfPages, len]); const startAt = page * pageSize; const endAt = (page + 1) * pageSize; diff --git a/src/lib/hooks/use-tip.ts b/src/lib/hooks/use-tip.ts new file mode 100644 index 0000000..b6e087b --- /dev/null +++ b/src/lib/hooks/use-tip.ts @@ -0,0 +1,29 @@ +import {useCallback} from 'react'; +import {useToast, useBreakpointValue} from '@chakra-ui/react'; +import {useLocalStorage} from 'react-use'; + +const useTip = (tip: string, tipSpecificToUltrawides?: string) => { + const resolvedTip = useBreakpointValue({base: tip, '4xl': tipSpecificToUltrawides ?? tip}) ?? tip; + const isServer = typeof window === 'undefined'; + + const toast = useToast(); + const [hasShown, setHasShown] = useLocalStorage(isServer ? '' : btoa(resolvedTip), false); + + const onShowTip = useCallback(() => { + if (!hasShown) { + toast({ + title: '✨ tip:', + status: 'info', + description: resolvedTip, + isClosable: true, + duration: 10_000, + position: 'bottom-right', + }); + setHasShown(true); + } + }, [hasShown, setHasShown, toast, resolvedTip]); + + return onShowTip; +}; + +export default useTip; diff --git a/lib/use-window-focus.ts b/src/lib/hooks/use-window-focus.ts similarity index 66% rename from lib/use-window-focus.ts rename to src/lib/hooks/use-window-focus.ts index b66800c..c10bdd2 100644 --- a/lib/use-window-focus.ts +++ b/src/lib/hooks/use-window-focus.ts @@ -1,6 +1,11 @@ import {useEffect} from 'react'; -const useWindowFocus = ({onFocus, onBlur}: {onFocus: () => void; onBlur: () => void}) => { +export type UseWindowFocusParameters = { + onFocus: () => void; + onBlur: () => void; +}; + +const useWindowFocus = ({onFocus, onBlur}: UseWindowFocusParameters) => { useEffect(() => { window.addEventListener('focus', onFocus); window.addEventListener('blur', onBlur); diff --git a/src/lib/match-date-on-time.ts b/src/lib/match-date-on-time.ts new file mode 100644 index 0000000..6b05865 --- /dev/null +++ b/src/lib/match-date-on-time.ts @@ -0,0 +1,13 @@ +/** Copies day-of-year from first argument to second. */ +const matchDateOnTime = (date: Date, onToTime: Date) => { + const d = new Date(date); + + d.setMilliseconds(onToTime.getMilliseconds()); + d.setSeconds(onToTime.getSeconds()); + d.setMinutes(onToTime.getMinutes()); + d.setHours(onToTime.getHours()); + + return d; +}; + +export default matchDateOnTime; diff --git a/lib/merge-by-property.ts b/src/lib/merge-by-property.ts similarity index 81% rename from lib/merge-by-property.ts rename to src/lib/merge-by-property.ts index 31872a9..55bd478 100644 --- a/lib/merge-by-property.ts +++ b/src/lib/merge-by-property.ts @@ -17,7 +17,13 @@ const mergeByProperty = (oldArray: T[], newArray: T[], pro } } - return Array.from(oldMap).map(([,value]) => value); + const results = []; + + for (const [,value] of Array.from(oldMap)) { + results.push(value); + } + + return results; }; export default mergeByProperty; diff --git a/src/lib/occurrence-generator-cache.ts b/src/lib/occurrence-generator-cache.ts new file mode 100644 index 0000000..ebcc1da --- /dev/null +++ b/src/lib/occurrence-generator-cache.ts @@ -0,0 +1,15 @@ +import {OccurrenceGenerator, Schedule} from './rschedule'; + +const cache = new Map['toArray']>>(); + +const occurrenceGeneratorCache = (key: string, start: Date, end: Date, schedule: Schedule) => { + const fullKey = `${key}-${start.toISOString()}-${end.toISOString()}`; + + if (!cache.get(fullKey)) { + cache.set(fullKey, schedule.occurrences({start, end}).toArray()); + } + + return cache.get(fullKey)!; +}; + +export default occurrenceGeneratorCache; diff --git a/src/lib/parse-credits-filter.ts b/src/lib/parse-credits-filter.ts new file mode 100644 index 0000000..3870627 --- /dev/null +++ b/src/lib/parse-credits-filter.ts @@ -0,0 +1,21 @@ +const parseCreditsFilter = (value: string): [number, number] => { + let min = 0; + let max = 0; + + if (value.includes('-')) { + const fragments = value.split('-'); + min = Number.parseFloat(fragments[0]); + max = Number.parseFloat(fragments[1]); + } else if (value.includes('+')) { + const fragments = value.split('+'); + min = Number.parseFloat(fragments[0]); + max = Number.MAX_SAFE_INTEGER; + } else { + min = Number.parseFloat(value); + max = min; + } + + return [min, max]; +}; + +export default parseCreditsFilter; diff --git a/src/lib/parse-search-query.ts b/src/lib/parse-search-query.ts new file mode 100644 index 0000000..ce493e3 --- /dev/null +++ b/src/lib/parse-search-query.ts @@ -0,0 +1,45 @@ +import {qualifiers} from './search-filters'; + +const searchPairExpr = /((\w*):([\w+-.]*))/g; +const searchPairExprWithAtLeast1Character = /((\w*):([\w+-.]+))/g; + +const parseSearchQuery = (query: string) => { + const searchPairs: Array<[string, string]> = query.match(searchPairExprWithAtLeast1Character)?.map(s => s.split(':')) as Array<[string, string]> ?? []; + const cleanedSearchValue = query + .toLowerCase() + .replace(searchPairExpr, '') + .replace(/[^A-Za-z\d" ]/g, '') + .trim() + .split(' ') + .filter(token => { + let includeToken = true; + for (const q of qualifiers) { + if (q.includes(token)) { + includeToken = false; + } + } + + return includeToken; + }) + .reduce((tokens, token) => { + // Check if token is of form subjectcrse (i.e. CS1000) + if (/([A-z]+)(\d+)/g.test(token)) { + const subject = token.match(/[A-z]+/g); + const crse = token.match(/\d+/g); + + if (subject && crse) { + searchPairs.push(['subject', subject[0]]); + tokens.push(crse[0]); + } + } else { + tokens.push(token); + } + + return tokens; + }, []) + .join(' '); + + return {searchPairs, cleanedSearchValue}; +}; + +export default parseSearchQuery; diff --git a/lib/preview-url.ts b/src/lib/preview-url.ts similarity index 86% rename from lib/preview-url.ts rename to src/lib/preview-url.ts index 262a1d4..38ab6fe 100644 --- a/lib/preview-url.ts +++ b/src/lib/preview-url.ts @@ -1,6 +1,6 @@ -import {IncomingMessage} from 'http'; +import {IncomingMessage} from 'node:http'; import absoluteUrl from 'next-absolute-url'; -import {ICourseFromAPI} from './types'; +import {ICourseFromAPI} from './api-types'; const toTitleCase = (string: string): string => string.split(' ').map(w => w[0].toUpperCase() + w.slice(1).toLowerCase()).join(' '); diff --git a/src/lib/request-idle-callback-guard.ts b/src/lib/request-idle-callback-guard.ts new file mode 100644 index 0000000..8c10da3 --- /dev/null +++ b/src/lib/request-idle-callback-guard.ts @@ -0,0 +1,10 @@ +const requestIdleCallbackGuard: typeof requestIdleCallback = (callback, options) => { + if (typeof requestIdleCallback === 'undefined') { + (callback as () => void)(); + return 0; + } + + return requestIdleCallback(callback, options); +}; + +export default requestIdleCallbackGuard; diff --git a/lib/rmp-id-to-url.ts b/src/lib/rmp-id-to-url.ts similarity index 100% rename from lib/rmp-id-to-url.ts rename to src/lib/rmp-id-to-url.ts diff --git a/lib/rschedule.ts b/src/lib/rschedule.ts similarity index 100% rename from lib/rschedule.ts rename to src/lib/rschedule.ts diff --git a/src/lib/save-as.ts b/src/lib/save-as.ts new file mode 100644 index 0000000..5b4b9d1 --- /dev/null +++ b/src/lib/save-as.ts @@ -0,0 +1,10 @@ +const saveAs = (uri: string, filename: string) => { + const link = document.createElement('a'); + link.href = uri.toString(); + link.download = filename; + document.body.append(link); + link.click(); + link.remove(); +}; + +export default saveAs; diff --git a/src/lib/save-keyboard-script-for-software.ts b/src/lib/save-keyboard-script-for-software.ts new file mode 100644 index 0000000..66fbf7f --- /dev/null +++ b/src/lib/save-keyboard-script-for-software.ts @@ -0,0 +1,74 @@ +import {ISectionFromAPI} from './api-types'; +import saveAs from './save-as'; + +export type SupportedSoftware = 'AutoHotkey' | 'Autokey'; + +type ScriptGen = (sections: ISectionFromAPI[], shortcutKey: string) => string; + +const getAutoHotkeyScript: ScriptGen = (sections, shortcutKey) => { + let script = `!${shortcutKey}::\n`; + + const sectionSends = []; + + for (const section of sections) { + sectionSends.push(` Send, ${section.crn}\n`); + } + + script += sectionSends.join(' Send, %A_Tab%\n'); + script += ' Send, {enter}'; + script += 'Return'; + + return script; +}; + +const getAutokeyScript: ScriptGen = sections => { + const scriptLines = []; + + for (const section of sections) { + scriptLines.push(`keyboard.send_keys("${section.crn}")`, 'keyboard.send_key("")'); + } + + // Remove last tab send + scriptLines.pop(); + + scriptLines.push('keyboard.send_key("")'); + + return scriptLines.join('\n'); +}; + +export const getKeyboardScriptFor = ( + software: SupportedSoftware, + sections: ISectionFromAPI[], + shortcutKey: string, +) => { + switch (software) { + case 'AutoHotkey': + return getAutoHotkeyScript(sections, shortcutKey); + case 'Autokey': + return getAutokeyScript(sections, shortcutKey); + default: + throw new Error('Unknown software.'); + } +}; + +const saveWithExtension = (data: string, name: string) => { + saveAs(`data:text/plain;charset=utf-8,${encodeURIComponent(data)}`, name); +}; + +const saveKeyboardScriptFor = ( + software: SupportedSoftware, + sections: ISectionFromAPI[], + name: string, + shortcutKey: string, +) => { + switch (software) { + case 'AutoHotkey': + saveWithExtension(getAutoHotkeyScript(sections, shortcutKey), `${name}.ahk`); + break; + case 'Autokey': + throw new Error('Not yet implemented.'); + default: + } +}; + +export default saveKeyboardScriptFor; diff --git a/lib/search-filters.ts b/src/lib/search-filters.ts similarity index 59% rename from lib/search-filters.ts rename to src/lib/search-filters.ts index 8918213..af60194 100644 --- a/lib/search-filters.ts +++ b/src/lib/search-filters.ts @@ -1,8 +1,10 @@ -import {ICourseFromAPI, ISectionFromAPI} from './types'; +import memoizeOne from 'memoize-one'; +import {ELocationType, ICourseFromAPI, ISectionFromAPI, ISectionFromAPIWithSchedule} from './api-types'; +import parseCreditsFilter from './parse-credits-filter'; -export const qualifiers = ['subject', 'level', 'has', 'credits']; +export const qualifiers = ['subject', 'level', 'has', 'credits', 'id']; -const generateArrayFromRange = (low: number, high: number): number[] => { +const generateArrayFromRange = memoizeOne((low: number, high: number): number[] => { const result = []; for (let i = low; i <= high; i++) { @@ -10,7 +12,7 @@ const generateArrayFromRange = (low: number, high: number): number[] => { } return result; -}; +}); export const filterCourse = (tokenPairs: Array<[string, string]>, course: ICourseFromAPI) => { for (const pair of tokenPairs) { @@ -64,7 +66,11 @@ export const filterCourse = (tokenPairs: Array<[string, string]>, course: ICours // 3 states: MATCHED, NOMATCH, REMOVE export type TQualifierResult = 'MATCHED' | 'NOMATCH' | 'REMOVE'; -export const filterSection = (tokenPairs: Array<[string, string]>, section: ISectionFromAPI): TQualifierResult => { +export const filterSection = ( + tokenPairs: Array<[string, string]>, + section: ISectionFromAPIWithSchedule, + isSectionScheduleCompatibleMap: Map, +): TQualifierResult => { let result: TQualifierResult = 'NOMATCH'; for (const pair of tokenPairs) { @@ -77,31 +83,54 @@ export const filterSection = (tokenPairs: Array<[string, string]>, section: ISec const value = pair[1]; switch (qualifier) { + case 'id': { + result = section.id === value ? 'MATCHED' : 'REMOVE'; + + break; + } + case 'has': { if (value === 'seats') { result = section.availableSeats <= 0 ? 'REMOVE' : 'MATCHED'; + } else if (value === 'time') { + result = (section.parsedTime?.firstDate) ? 'MATCHED' : 'REMOVE'; } break; } - case 'credits': { - let min = 0; - let max = 0; + case 'is': { + switch (value) { + case 'compatible': { + result = isSectionScheduleCompatibleMap.get(section.id) ? 'MATCHED' : 'REMOVE'; + break; + } - if (value.includes('-')) { - const fragments = value.split('-'); - min = Number.parseFloat(fragments[0]); - max = Number.parseFloat(fragments[1]); - } else if (value.includes('+')) { - const fragments = value.split('+'); - min = Number.parseFloat(fragments[0]); - max = Number.MAX_SAFE_INTEGER; - } else { - min = Number.parseFloat(value); - max = min; + case 'remote': { + result = section.locationType === ELocationType.REMOTE ? 'MATCHED' : 'REMOVE'; + break; + } + + case 'online': { + result = section.locationType === ELocationType.ONLINE ? 'MATCHED' : 'REMOVE'; + break; + } + + case 'classroom': { + result = section.locationType === ELocationType.PHYSICAL ? 'MATCHED' : 'REMOVE'; + break; + } + + default: + break; } + break; + } + + case 'credits': { + const [min, max] = parseCreditsFilter(value); + if (!Number.isNaN(min) && !Number.isNaN(max)) { for (const possibleCredit of generateArrayFromRange(section.minCredits, section.maxCredits)) { if (min <= possibleCredit && possibleCredit <= max) { diff --git a/src/lib/sections-to-ics.ts b/src/lib/sections-to-ics.ts new file mode 100644 index 0000000..31cb6c1 --- /dev/null +++ b/src/lib/sections-to-ics.ts @@ -0,0 +1,124 @@ +import {zonedTimeToUtc} from 'date-fns-tz'; +import {CalendarRecurrence, ICalendar} from 'datebook'; +import {ELocationType, IBuildingFromAPI, ICourseFromAPI, ISectionFromAPI} from './api-types'; +import {Schedule} from './rschedule'; + +export enum TitleStyle { + CRSE_FIRST = 'CRSE_FIRST', + CRSE_LAST = 'CRSE_LAST', + NO_CRSE = 'NO_CRSE', +} + +export enum LocationStyle { + FULL = 'FULL', + SHORT = 'SHORT', +} + +export const ALERT_TIMINGS = [ + 0, + 5, + 10, + 15, + 20, +] as const; + +interface Options { + titleStyle?: TitleStyle; + locationStyle?: LocationStyle; + alertTiming?: typeof ALERT_TIMINGS[0]; +} + +const sectionsToICS = (sections: Array, buildings: IBuildingFromAPI[], options?: Options): string => { + let calendar; + + const { + titleStyle = TitleStyle.CRSE_FIRST, + locationStyle = LocationStyle.SHORT, + alertTiming = ALERT_TIMINGS[2], + } = options ?? {}; + + for (const section of sections) { + const schedule = Schedule.fromJSON(section.time); + + for (const rule of schedule.rrules) { + const recurrence: CalendarRecurrence = { + frequency: rule.options.frequency, + end: new Date(rule.options.end?.toISOString() ?? ''), + }; + + if (rule.options.frequency === 'WEEKLY') { + recurrence.weekdays = rule.options.byDayOfWeek as string[]; + } + + const start = zonedTimeToUtc(schedule.firstDate?.date ?? new Date(), 'America/New_York'); + const end = zonedTimeToUtc(schedule.firstDate?.end ?? new Date(), 'America/New_York'); + + let location = ''; + + const building = buildings.find(b => b.name === section.buildingName); + + switch (section.locationType) { + case ELocationType.PHYSICAL: { + location = `${(locationStyle === LocationStyle.FULL ? section.buildingName : building?.shortName) ?? ''} ${section.room ?? ''}`.trim(); + + break; + } + + case ELocationType.ONLINE: { + location = 'Online'; + + break; + } + + case ELocationType.REMOTE: { + location = 'Remote'; + + break; + } + + default: { + location = ''; + break; + } + } + + let title = section.course.title; + + if (titleStyle === TitleStyle.CRSE_FIRST) { + title = `${section.course.subject}${section.course.crse} (${section.course.title})`; + } else if (titleStyle === TitleStyle.CRSE_LAST) { + title = `${section.course.title} (${section.course.subject}${section.course.crse})`; + } + + const event = new ICalendar({ + title, + location, + description: section.course.description ?? '', + start, + end, + recurrence, + }); + + if (alertTiming !== 0) { + event.addAlarm({ + action: 'DISPLAY', + trigger: { + minutes: alertTiming, + }, + }); + } + + event.setMeta('UID', section.id); + + if (calendar) { + calendar.addEvent(event); + } else { + calendar = event; + } + } + } + + return calendar?.render() ?? ''; +}; + +export default sectionsToICS; diff --git a/src/lib/sharables.ts b/src/lib/sharables.ts new file mode 100644 index 0000000..cc419cb --- /dev/null +++ b/src/lib/sharables.ts @@ -0,0 +1,16 @@ +import {IPotentialFutureTerm} from './types'; + +interface ShareCourseStruct { + type: 'SHARE_COURSE'; + data: { + subject: string; + crse: string; + term: IPotentialFutureTerm; + }; +} + +export type ShareableStruct = {version: number} & (ShareCourseStruct); + +export const encodeShareable = (data: ShareableStruct) => Buffer.from(JSON.stringify(data)).toString('base64'); + +export const decodeShareable = (packed: string): ShareableStruct => JSON.parse(Buffer.from(packed, 'base64').toString()) as ShareableStruct; diff --git a/src/lib/state/api.ts b/src/lib/state/api.ts new file mode 100644 index 0000000..57fa9c9 --- /dev/null +++ b/src/lib/state/api.ts @@ -0,0 +1,413 @@ +import {makeAutoObservable, reaction, runInAction} from 'mobx'; +import {makePersistable} from 'mobx-persist-store'; +import mergeByProperty from '../merge-by-property'; +import { + ESemester, + IBuildingFromAPI, + ICourseFromAPI, + IInstructorFromAPI, + IPassFailDropFromAPI, + ISectionFromAPI, + ISectionFromAPIWithSchedule, + ITransferCourseFromAPI, +} from '../api-types'; +import {Schedule} from '../rschedule'; +import asyncRequestIdleCallback from '../async-request-idle-callback'; +import {IConcreteTerm, IPotentialFutureTerm, IVirtualTerm} from '../types'; + +type ENDPOINT = 'courses' | 'sections' | 'instructors' | 'transfer-courses' | 'passfaildrop' | 'buildings'; +type DATA_KEYS = 'courses' | 'sections' | 'instructors' | 'transferCourses' | 'passfaildrop' | 'buildings'; + +const ENDPOINT_TO_KEY: Record = { + courses: 'courses', + sections: 'sections', + instructors: 'instructors', + 'transfer-courses': 'transferCourses', + passfaildrop: 'passfaildrop', + buildings: 'buildings', +}; + +const VIRTUAL_TERMS: IVirtualTerm[] = [ + { + semester: ESemester.SPRING, + isFuture: true, + }, + { + semester: ESemester.SUMMER, + isFuture: true, + }, + { + semester: ESemester.FALL, + isFuture: true, + }, +]; + +export class APIState { + instructors: IInstructorFromAPI[] = []; + passfaildrop: IPassFailDropFromAPI = {}; + buildings: IBuildingFromAPI[] = []; + sections: ISectionFromAPI[] = []; + courses: ICourseFromAPI[] = []; + transferCourses: ITransferCourseFromAPI[] = []; + loading = false; + errors: Error[] = []; + lastUpdatedAt: Date | null = null; + + availableTerms: IConcreteTerm[] = []; + selectedTerm?: IPotentialFutureTerm; + + singleFetchEndpoints: ENDPOINT[] = []; + recurringFetchEndpoints: ENDPOINT[] = []; + + constructor() { + makeAutoObservable(this, {}, { + deep: false, + proxy: false, + }); + + void makePersistable(this, { + name: 'APIState', + properties: ['selectedTerm'], + storage: typeof window === 'undefined' ? undefined : window.localStorage, + }); + + reaction( + () => this.selectedTerm, + async () => { + await this.revalidate(); + }, + ); + } + + get subjects() { + const s = new Map(); + + for (const course of this.courses) { + s.set(course.subject.toLowerCase(), course.subject.toLowerCase()); + } + + return [...s.keys()]; + } + + get coursesNotDeleted() { + const courses = []; + for (const course of this.courses) { + if (!course.deletedAt) { + courses.push(course); + } + } + + return courses; + } + + get sectionsNotDeleted() { + const sections = []; + for (const section of this.sectionsWithParsedSchedules) { + if (!section.deletedAt) { + sections.push(section); + } + } + + return sections; + } + + get sectionsWithParsedSchedules() { + const sections = []; + + for (const section of this.sections) { + sections.push({ + ...section, + parsedTime: Schedule.fromJSON(section.time), + }); + } + + return sections; + } + + get buildingsByName() { + const map = new Map(); + + for (const building of this.buildings) { + map.set(building.name, building); + } + + return map; + } + + get instructorsById() { + const map = new Map(); + + for (const instructor of this.instructors) { + map.set(instructor.id, instructor); + } + + return map; + } + + get courseById() { + const map = new Map(); + + for (const course of this.courses) { + map.set(course.id, course); + } + + return map; + } + + get sectionById() { + const map = new Map(); + + for (const s of this.sectionsWithParsedSchedules) { + map.set(s.id, s); + } + + return map; + } + + get keysLastUpdatedAt(): Record { + const reducer = (array: Array<{updatedAt: string; deletedAt?: string | null}>) => array.reduce((maxDate, element) => { + const prospectiveDates = [maxDate, new Date(element.updatedAt)]; + + if (element.deletedAt) { + prospectiveDates.push(new Date(element.deletedAt)); + } + + return prospectiveDates.sort((a, b) => b.getTime() - a.getTime())[0]; + }, new Date(0)); + + return { + instructors: reducer(this.instructors), + courses: reducer(this.courses), + sections: reducer(this.sections), + transferCourses: reducer(this.transferCourses), + passfaildrop: new Date(0), + buildings: new Date(0), + }; + } + + get dataLastUpdatedAt() { + const dates = Object.values(this.keysLastUpdatedAt).sort((a, b) => b.getTime() - a.getTime()); + + return dates[0]; + } + + get hasDataForTrackedEndpoints() { + if (!this.lastUpdatedAt) { + return false; + } + + let hasData = true; + + for (const endpoint of [...this.singleFetchEndpoints, ...this.recurringFetchEndpoints]) { + const currentDataForEndpoint = this[ENDPOINT_TO_KEY[endpoint]]; + + if ((currentDataForEndpoint as Record).constructor === Object) { + hasData = Object.keys(currentDataForEndpoint).length > 0; + } + + if ((currentDataForEndpoint as Record).constructor === Array) { + hasData = (currentDataForEndpoint as APIState[Exclude]).length > 0; + } + } + + return hasData; + } + + get sortedTerms() { + const termValueMap = { + SPRING: 0.1, + SUMMER: 0.2, + FALL: 0.3, + }; + + return [ + ...this.availableTerms.slice().sort((a, b) => (a.year + termValueMap[a.semester]) - (b.year + termValueMap[b.semester])), + ...VIRTUAL_TERMS, + ]; + } + + async getTerms() { + const url = new URL('/semesters', process.env.NEXT_PUBLIC_API_ENDPOINT).toString(); + const result = await (await fetch(url)).json() as IConcreteTerm[]; + + runInAction(() => { + this.availableTerms = result; + }); + } + + setSelectedTerm(term: IPotentialFutureTerm) { + this.selectedTerm = term; + this.courses = []; + this.sections = []; + this.lastUpdatedAt = null; + } + + setSingleFetchEndpoints(endpoints: ENDPOINT[], shouldInvalidateData = false) { + if (shouldInvalidateData) { + for (const endpoint of endpoints) { + if (Array.isArray(this[ENDPOINT_TO_KEY[endpoint]])) { + (this[ENDPOINT_TO_KEY[endpoint]] as APIState[Exclude]) = []; + } else { + (this[ENDPOINT_TO_KEY[endpoint]] as APIState['passfaildrop']) = {}; + } + } + + this.availableTerms = []; + } + + this.singleFetchEndpoints = endpoints; + } + + setRecurringFetchEndpoints(endpoints: ENDPOINT[], shouldInvalidateData = false) { + if (shouldInvalidateData) { + for (const endpoint of endpoints) { + if (Array.isArray(this[ENDPOINT_TO_KEY[endpoint]])) { + (this[ENDPOINT_TO_KEY[endpoint]] as APIState[Exclude]) = []; + } else { + (this[ENDPOINT_TO_KEY[endpoint]] as APIState['passfaildrop']) = {}; + } + } + + this.availableTerms = []; + } + + this.recurringFetchEndpoints = endpoints; + } + + // Poll for updates + async revalidate() { + if (this.loading) { + return; + } + + await asyncRequestIdleCallback(async () => { + performance.mark('start-revalidation'); + + runInAction(() => { + this.loading = true; + }); + + // Get semesters first + if (this.availableTerms.length === 0 && (this.recurringFetchEndpoints.includes('courses') || this.recurringFetchEndpoints.includes('sections'))) { + await this.getTerms(); + const semesters = this.sortedTerms; + + if (semesters && !this.selectedTerm) { + const concreteSemesters = semesters.filter(s => !s.isFuture); + this.setSelectedTerm(concreteSemesters[concreteSemesters.length - 2]); + } + } + + let successfulHits = 0; + + const startedUpdatingAt = new Date(); + + const promises: Array> = []; + const newErrors: Error[] = []; + + promises.push(...this.singleFetchEndpoints.map(async endpoint => { + const currentDataForEndpoint = this[ENDPOINT_TO_KEY[endpoint]]; + + let shouldFetch = false; + + if ((currentDataForEndpoint as Record).constructor === Object) { + shouldFetch = Object.keys(currentDataForEndpoint).length === 0; + } + + if ((currentDataForEndpoint as Record).constructor === Array) { + shouldFetch = (currentDataForEndpoint as APIState[Exclude]).length === 0; + } + + if (shouldFetch) { + try { + const url = new URL(`/${endpoint}`, process.env.NEXT_PUBLIC_API_ENDPOINT); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const result = await (await fetch(url.toString())).json(); + + runInAction(() => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + this[ENDPOINT_TO_KEY[endpoint]] = result; + }); + } catch (error: unknown) { + newErrors.push(error as Error); + } + } + })); + + // Load courses, sections, instructors + // eslint-disable-next-line unicorn/no-array-push-push + promises.push(...this.recurringFetchEndpoints.map(async path => { + const key = ENDPOINT_TO_KEY[path]; + + try { + let url = new URL(`/${path}`, process.env.NEXT_PUBLIC_API_ENDPOINT); + + if (['courses', 'sections'].includes(key)) { + if (!this.selectedTerm) { + successfulHits++; + return; + } + + if (this.selectedTerm.isFuture) { + if (key === 'courses') { + url = new URL(`/${path}/unique`, process.env.NEXT_PUBLIC_API_ENDPOINT); + url.searchParams.append('startYear', (new Date().getFullYear() - 2).toString()); + url.searchParams.append('semester', this.selectedTerm.semester); + } else if (key === 'sections') { + successfulHits++; + return; + } + } else { + url.searchParams.append('semester', this.selectedTerm.semester); + url.searchParams.append('year', this.selectedTerm.year.toString()); + } + } + + const keyLastUpdatedAt = this.keysLastUpdatedAt[key]; + + if (keyLastUpdatedAt && keyLastUpdatedAt.getTime() !== 0) { + url.searchParams.append('updatedSince', keyLastUpdatedAt.toISOString()); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const result = await (await fetch(url.toString())).json(); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (result.length > 0) { + runInAction(() => { + // Merge + // Spent way too long trying to get TS to recognize this as valid... + // YOLOing with any + // Might be relevant: https://github.com/microsoft/TypeScript/issues/16756 + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + this[key] = mergeByProperty(this[key] as any, result, 'id') as any; + }); + } + + successfulHits++; + } catch (error: unknown) { + newErrors.push(error as Error); + } + })); + + // Wait for all calls to complete + await Promise.all(promises); + + runInAction(() => { + this.lastUpdatedAt = startedUpdatingAt; + + this.loading = false; + + if (newErrors.length > 0) { + this.errors = newErrors; + } else if (successfulHits === 3) { + this.errors = []; + } + }); + + performance.mark('end-revalidation'); + performance.measure('Revalidated Data', 'start-revalidation', 'end-revalidation'); + }); + } +} diff --git a/src/lib/state/basket.ts b/src/lib/state/basket.ts new file mode 100644 index 0000000..a6ea769 --- /dev/null +++ b/src/lib/state/basket.ts @@ -0,0 +1,357 @@ +import {nanoid} from 'nanoid'; +import {autorun, makeAutoObservable, runInAction} from 'mobx'; +import {trackUndo} from 'mobx-shallow-undo'; +import {getFormattedTimeFromSchedule} from 'src/components/sections-table/time-display'; +import doSchedulesConflict from '../do-schedules-conflict'; +import getCreditsString from '../get-credits-str'; +import {ICourseFromAPI, IInstructorFromAPI, ISectionFromAPI, ISectionFromAPIWithSchedule} from '../api-types'; +import requestIdleCallbackGuard from '../request-idle-callback-guard'; +import parseSearchQuery from '../parse-search-query'; +import parseCreditsFilter from '../parse-credits-filter'; +import {IPotentialFutureTerm, WritableKeys} from '../types'; +import {APIState} from './api'; + +export class BasketState { + id = nanoid(); + name: string; + forTerm: IPotentialFutureTerm; + sectionIds: Array = []; + courseIds: Array = []; + searchQueries: string[] = []; + isSectionScheduleCompatibleMap = new Map(); + private readonly apiState: APIState; + private readonly undoRedo?: ReturnType; + + constructor(apiState: APIState, term: IPotentialFutureTerm, name: string, json?: Partial) { + this.undoRedo = trackUndo( + () => ({ + sectionIds: this.sectionIds, + courseIds: this.courseIds, + searchQueries: this.searchQueries, + }), value => { + this.sectionIds = value.sectionIds; + this.courseIds = value.courseIds; + this.searchQueries = value.searchQueries; + }); + + this.apiState = apiState; + this.forTerm = term; + this.name = name; + + // Deseralizeable properties + if (json?.id) { + this.id = json.id; + } + + if (json?.name) { + this.name = json.name; + } + + if (json?.forTerm) { + this.forTerm = json.forTerm; + } + + if (json?.sectionIds) { + this.sectionIds = json.sectionIds; + } + + if (json?.courseIds) { + this.courseIds = json.courseIds; + } + + if (json?.searchQueries) { + this.searchQueries = json.searchQueries; + } + + makeAutoObservable(this, {}, { + deep: false, + proxy: false, + }); + + autorun(() => { + const { + sectionsWithParsedSchedules, + } = this.apiState; + + // This is expensive so we update it here as a property rather than a computed getter. + const map = new Map(); + for (const section of sectionsWithParsedSchedules) { + let doOverlap = false; + + if (section.parsedTime) { + for (const {parsedTime} of this.sections) { + if (!parsedTime) { + continue; + } + + doOverlap = doSchedulesConflict(section.parsedTime, parsedTime); + + if (doOverlap) { + break; + } + } + + map.set(section.id, !doOverlap); + } + } + + runInAction(() => { + this.isSectionScheduleCompatibleMap = map; + }); + }, {scheduler: run => requestIdleCallbackGuard(run)}); + } + + static serialize(fromData: Partial) { + const properties: Array> = [ + 'id', + 'name', + 'forTerm', + 'sectionIds', + 'courseIds', + 'searchQueries', + ]; + + const serializeResult: Partial>> = {}; + + for (const p of properties) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + serializeResult[p] = fromData[p] as any; + } + + return serializeResult; + } + + /** Returns true if state ends up changing. */ + undoLastAction() { + const lastSectionIds = this.sectionIds; + const lastSearchQueries = this.searchQueries; + const lastCourseIds = this.courseIds; + + this.undoRedo?.undo(); + + if ( + lastSectionIds !== this.sectionIds + || lastSearchQueries !== this.searchQueries + || lastCourseIds !== this.courseIds) { + return true; + } + + return false; + } + + /** Returns true if state ends up changing. */ + redoLastAction() { + const lastSectionIds = this.sectionIds; + const lastSearchQueries = this.searchQueries; + const lastCourseIds = this.courseIds; + + this.undoRedo?.redo(); + + if ( + lastSectionIds !== this.sectionIds + || lastSearchQueries !== this.searchQueries + || lastCourseIds !== this.courseIds) { + return true; + } + + return false; + } + + addSearchQuery(query: string) { + // Don't add duplicates + if (!this.searchQueries.includes(query)) { + this.searchQueries = [...this.searchQueries, query]; + } + } + + removeSearchQuery(query: string) { + this.searchQueries = this.searchQueries.filter(q => q !== query); + } + + addSection(id: ISectionFromAPI['id']) { + if (!this.sectionIds.includes(id)) { + this.sectionIds = [...this.sectionIds, id]; + } + } + + removeSection(id: ISectionFromAPI['id']) { + this.sectionIds = this.sectionIds.filter(i => i !== id); + } + + hasSection(id: ISectionFromAPI['id']) { + return this.sectionIds.includes(id); + } + + addCourse(id: ICourseFromAPI['id']) { + if (!this.courseIds.includes(id)) { + this.courseIds = [...this.courseIds, id]; + } + } + + removeCourse(id: ICourseFromAPI['id']) { + this.courseIds = this.courseIds.filter(i => i !== id); + } + + hasCourse(id: ICourseFromAPI['id']) { + return this.courseIds.includes(id); + } + + setName(newName: string) { + this.name = newName; + } + + get numOfItems() { + return this.sectionIds.length + this.searchQueries.length + this.courseIds.length; + } + + get sections() { + return this.sectionIds.reduce>((accum, id) => { + const section = this.apiState.sectionById.get(id); + + if (!section) { + return accum; + } + + const course = this.apiState.courseById.get(section.courseId); + + if (!course) { + return accum; + } + + accum.push({...section, course}); + return accum; + }, []); + } + + get courses() { + return this.courseIds.reduce((accum, id) => { + const course = this.apiState.courseById.get(id); + + if (!course) { + return accum; + } + + return [...accum, course]; + }, []); + } + + get parsedQueries(): Array<{query: string; credits?: [number, number]}> { + return this.searchQueries.map(query => { + const {searchPairs} = parseSearchQuery(query); + const creditsFilter = searchPairs.find(([token]) => token === 'credits'); + + if (creditsFilter) { + const [,creditsString] = creditsFilter; + const [min, max] = parseCreditsFilter(creditsString); + + if (!Number.isNaN(min) && !Number.isNaN(max)) { + return { + query, + credits: [min, max], + }; + } + } + + return {query}; + }); + } + + get totalCredits(): [number, number] { + let minCredits = 0; + let maxCredits = 0; + + for (const section of this.sections) { + minCredits += section.minCredits; + maxCredits += section.maxCredits; + } + + for (const course of this.courses) { + minCredits += course.minCredits; + maxCredits += course.maxCredits; + } + + for (const {credits: creditsForQuery} of this.parsedQueries) { + if (creditsForQuery) { + minCredits += creditsForQuery[0]; + maxCredits += creditsForQuery[1] === Number.MAX_SAFE_INTEGER ? 4 : creditsForQuery[1]; + } + } + + return [minCredits, maxCredits]; + } + + get sectionsInBasketThatConflict() { + const conflicts = []; + for (let i = 0; i < this.sections.length; i++) { + for (let j = i + 1; j < this.sections.length; j++) { + const firstSection = this.sections[i]; + const secondSection = this.sections[j]; + + if (!firstSection.parsedTime || !secondSection.parsedTime) { + continue; + } + + if (doSchedulesConflict(firstSection.parsedTime, secondSection.parsedTime)) { + conflicts.push([firstSection, secondSection]); + } + } + } + + return conflicts; + } + + get doesSectionInBasketConflictMap() { + const map = new Map(); + + for (const [first, second] of this.sectionsInBasketThatConflict) { + map.set(first.id, true); + map.set(second.id, true); + } + + return map; + } + + get warnings() { + const conflictWarnings = this.sectionsInBasketThatConflict.map(([firstSection, secondSection]) => `${firstSection.course.subject}${firstSection.course.crse} ${firstSection.section} conflicts with ${secondSection.course.subject}${secondSection.course.crse} ${secondSection.section}`); + + const deletionWarnings = []; + + for (const section of this.sections) { + if (section.deletedAt) { + deletionWarnings.push(`${section.course.subject}${section.course.crse} ${section.section} was removed from Banweb`); + } + } + + for (const course of this.courses) { + if (course.deletedAt) { + deletionWarnings.push(`${course.subject}${course.crse} was removed from Banweb`); + } + } + + return [...conflictWarnings, ...deletionWarnings]; + } + + toTSV() { + let content = 'Title Section Instructors Schedule CRN Credits\n'; + + const getInstructorsString = (instructors: Array<{id: IInstructorFromAPI['id']}>) => instructors.map(({id}) => this.apiState.instructorsById.get(id)?.fullName).join(', '); + + for (const section of this.sections) { + let timeString = ''; + + if (section.parsedTime) { + const {days, time} = getFormattedTimeFromSchedule(section.parsedTime); + + timeString = `${days} ${time}`; + } + + content += `${section.course.title} ${section.section} ${getInstructorsString(section.instructors)} ${timeString} ${section.crn} ${getCreditsString(section.minCredits, section.maxCredits)}\n`; + } + + for (const query of this.searchQueries) { + content += `${query} \n`; + } + + return content; + } +} diff --git a/src/lib/state/baskets.ts b/src/lib/state/baskets.ts new file mode 100644 index 0000000..ea76f0b --- /dev/null +++ b/src/lib/state/baskets.ts @@ -0,0 +1,141 @@ +import {autorun, makeAutoObservable, runInAction} from 'mobx'; +import {makePersistable, StorageController} from 'mobx-persist-store'; +import areTermsEqual from '../are-terms-equal'; +import toTitleCase from '../to-title-case'; +import {IPotentialFutureTerm} from '../types'; +import {APIState} from './api'; +import {BasketState} from './basket'; + +type SerializedData = Partial>; + +const storageController = (apiState: APIState): StorageController => ({ + getItem: (key: string) => { + const stringifiedData = window.localStorage.getItem(key); + if (!stringifiedData) { + return null; + } + + const data = JSON.parse(stringifiedData) as SerializedData; + + const parsed = { + ...data, + baskets: data.baskets?.map((parsedBasket: Partial & Pick) => new BasketState(apiState, parsedBasket.forTerm, '', parsedBasket)) ?? [], + // Gotta manually deserialize Map + selectedBasketIdForTerm: new Map(data.selectedBasketIdForTerm ?? []), + }; + + return parsed as unknown as T; + }, + setItem: (key, data: SerializedData) => { + window.localStorage.setItem(key, JSON.stringify({ + ...data, + baskets: data.baskets?.map(basket => BasketState.serialize(basket)) ?? [], + // Gotta manually serialize Map + selectedBasketIdForTerm: data.selectedBasketIdForTerm ? Array.from(data.selectedBasketIdForTerm.entries()) : [], + })); + }, + removeItem: key => { + window.localStorage.removeItem(key); + }, +}); + +export class AllBasketsState { + baskets: BasketState[] = []; + selectedBasketIdForTerm = new Map(); + + private readonly apiState: APIState; + + constructor(apiState: APIState) { + this.apiState = apiState; + + makeAutoObservable(this); + + void makePersistable(this, { + name: 'Baskets', + properties: ['baskets', 'selectedBasketIdForTerm'], + stringify: false, + storage: typeof window === 'undefined' ? undefined : storageController(apiState), + }); + + // Automatically set/change basket when switching terms (and on first load) + autorun(() => { + const {selectedTerm} = this.apiState; + + if (!selectedTerm) { + return; + } + + runInAction(() => { + // Check if we have a basket for this term in history + let lastViewedBasketIdForThisTerm = this.selectedBasketIdForTerm.get(JSON.stringify(selectedTerm)); + // Map might have old data + if (!this.baskets.some(b => b.id === lastViewedBasketIdForThisTerm)) { + lastViewedBasketIdForThisTerm = undefined; + this.selectedBasketIdForTerm.delete(JSON.stringify(selectedTerm)); + } + + if (lastViewedBasketIdForThisTerm // Check if basket was deleted + && this.baskets.some(b => b.id === this.selectedBasketId)) { + this.setSelectedBasket(lastViewedBasketIdForThisTerm); + return; + } + + // Default to first valid basket found + const firstBasketForTerm = this.baskets.find(b => areTermsEqual(b.forTerm, selectedTerm)); + if (firstBasketForTerm) { + this.setSelectedBasket(firstBasketForTerm.id); + } else { + this.selectedBasketIdForTerm.delete(JSON.stringify(selectedTerm)); + } + }); + }); + } + + addBasket(forTerm: IPotentialFutureTerm) { + let basketNameIndexSuffix = 0; + const initialNewBasketName = toTitleCase(forTerm.isFuture ? `Future ${forTerm.semester} Semester` : `${forTerm.semester} ${forTerm.year}`); + + let newBasketName = initialNewBasketName; + // eslint-disable-next-line @typescript-eslint/no-loop-func + while (this.baskets.some(b => b.name === newBasketName)) { + basketNameIndexSuffix++; + newBasketName = `${initialNewBasketName} (${basketNameIndexSuffix})`; + } + + const newBasket = new BasketState(this.apiState, forTerm, newBasketName); + this.baskets = [...this.baskets, newBasket]; + return newBasket; + } + + removeBasket(basketId: string) { + this.baskets = this.baskets.filter(b => b.id !== basketId); + } + + getBasketsFor(term: IPotentialFutureTerm) { + return this.baskets.filter(b => areTermsEqual(term, b.forTerm)); + } + + get currentBasket() { + return this.baskets.find(b => b.id === this.selectedBasketId); + } + + get selectedBasketId() { + if (!this.apiState.selectedTerm) { + return undefined; + } + + return this.selectedBasketIdForTerm.get(JSON.stringify(this.apiState.selectedTerm)); + } + + setSelectedBasket(id: string) { + if (!this.apiState.selectedTerm) { + return; + } + + const basket = this.baskets.find(b => b.id === id); + + if (basket && areTermsEqual(basket.forTerm, this.apiState.selectedTerm)) { + this.selectedBasketIdForTerm.set(JSON.stringify(this.apiState.selectedTerm), id); + } + } +} diff --git a/lib/state-context.tsx b/src/lib/state/context.tsx similarity index 63% rename from lib/state-context.tsx rename to src/lib/state/context.tsx index dd95934..e1d9781 100644 --- a/lib/state-context.tsx +++ b/src/lib/state/context.tsx @@ -1,17 +1,15 @@ import React, {createContext, useContext} from 'react'; -import {RootState} from './state'; +import {RootState} from './root'; const state = new RootState(); export const StateContext = createContext(state); -export const Provider = ({children}: {children: React.ReactElement | React.ReactElement[]}) => { - return ( - - {children} - - ); -}; +export const Provider = ({children}: {children: React.ReactElement | React.ReactElement[]}) => ( + + {children} + +); const useStore = () => useContext(StateContext); diff --git a/src/lib/state/root.ts b/src/lib/state/root.ts new file mode 100644 index 0000000..e6d86f7 --- /dev/null +++ b/src/lib/state/root.ts @@ -0,0 +1,18 @@ +import {APIState} from './api'; +import {AllBasketsState} from './baskets'; +import {TransferCoursesState} from './transfer-courses'; +import {UIState} from './ui'; + +export class RootState { + public uiState!: UIState; + public apiState!: APIState; + public allBasketsState!: AllBasketsState; + public transferCoursesState!: TransferCoursesState; + + constructor() { + this.apiState = new APIState(); + this.uiState = new UIState(this); + this.transferCoursesState = new TransferCoursesState(this); + this.allBasketsState = new AllBasketsState(this.apiState); + } +} diff --git a/lib/transfer-courses-state.ts b/src/lib/state/transfer-courses.ts similarity index 78% rename from lib/transfer-courses-state.ts rename to src/lib/state/transfer-courses.ts index 2df3b59..3df2f35 100644 --- a/lib/transfer-courses-state.ts +++ b/src/lib/state/transfer-courses.ts @@ -1,7 +1,8 @@ import lunr from 'lunr'; import {autorun, computed, makeAutoObservable} from 'mobx'; -import {RootState} from './state'; -import {ITransferCourseFromAPI} from './types'; +import {ITransferCourseFromAPI} from '../api-types'; +import requestIdleCallbackGuard from '../request-idle-callback-guard'; +import {RootState} from './root'; export class TransferCoursesState { searchValue = ''; @@ -11,7 +12,7 @@ export class TransferCoursesState { constructor(rootState: RootState) { makeAutoObservable(this, { lunr: computed({requiresReaction: true, keepAlive: true}), - courseByIdMap: computed({requiresReaction: true, keepAlive: true}) + courseByIdMap: computed({requiresReaction: true, keepAlive: true}), }); this.rootState = rootState; @@ -21,7 +22,7 @@ export class TransferCoursesState { if (!this.rootState.apiState.loading) { return this.lunr && this.courseByIdMap; } - }); + }, {scheduler: run => requestIdleCallbackGuard(run)}); } setSearchValue(newValue: string) { @@ -55,13 +56,17 @@ export class TransferCoursesState { return this.rootState.apiState.transferCourses.slice().sort((a, b) => a.fromCollege.localeCompare(b.fromCollege)); } - return this.lunr.search(cleanedSearchValue).map(({ref}) => { - return this.courseByIdMap.get(ref); - }); + const results = []; + + for (const {ref} of this.lunr.search(cleanedSearchValue)) { + results.push(this.courseByIdMap.get(ref)!); + } + + return results; } get courseByIdMap() { - const m = new Map(); + const m = new Map(); for (const course of this.rootState.apiState.transferCourses) { m.set(course.id, course); @@ -81,11 +86,11 @@ export class TransferCoursesState { builder.field('toCRSE'); builder.field('fromCourse', { - extractor: doc => `${(doc as ITransferCourseFromAPI).fromSubject}${(doc as ITransferCourseFromAPI).fromCRSE}` + extractor: doc => `${(doc as ITransferCourseFromAPI).fromSubject}${(doc as ITransferCourseFromAPI).fromCRSE}`, }); builder.field('toCourse', { - extractor: doc => `${(doc as ITransferCourseFromAPI).toSubject}${(doc as ITransferCourseFromAPI).toCRSE}` + extractor: doc => `${(doc as ITransferCourseFromAPI).toSubject}${(doc as ITransferCourseFromAPI).toCRSE}`, }); for (const section of this.rootState.apiState.transferCourses) { diff --git a/lib/ui-state.ts b/src/lib/state/ui.ts similarity index 75% rename from lib/ui-state.ts rename to src/lib/state/ui.ts index e603cfb..d73bbb8 100644 --- a/lib/ui-state.ts +++ b/src/lib/state/ui.ts @@ -1,22 +1,22 @@ import {autorun, computed, makeAutoObservable} from 'mobx'; import lunr from 'lunr'; -import {ArrayMap} from './arr-map'; -import {ICourseFromAPI, ISectionFromAPI} from './types'; -import {filterCourse, filterSection, qualifiers} from './search-filters'; -import {RootState} from './state'; +import {ArrayMap} from '../arr-map'; +import {ICourseFromAPI, ISectionFromAPI, ISectionFromAPIWithSchedule} from '../api-types'; +import {filterCourse, filterSection} from '../search-filters'; +import requestIdleCallbackGuard from '../request-idle-callback-guard'; +import parseSearchQuery from '../parse-search-query'; +import {RootState} from './root'; export type ICourseWithFilteredSections = { course: ICourseFromAPI; sections: { - all: ISectionFromAPI[]; - filtered: ISectionFromAPI[]; + all: ISectionFromAPIWithSchedule[]; + filtered: ISectionFromAPIWithSchedule[]; wasFiltered: boolean; }; }; -const isNumeric = (string: string) => { - return !Number.isNaN(string as unknown as number) && !Number.isNaN(Number.parseFloat(string)); -}; +const isNumeric = (string: string) => !Number.isNaN(string as unknown as number) && !Number.isNaN(Number.parseFloat(string)); export class UIState { searchValue = ''; @@ -24,26 +24,28 @@ export class UIState { private readonly rootState: RootState; constructor(rootState: RootState) { + this.rootState = rootState; + makeAutoObservable(this, { sectionLunr: computed({requiresReaction: true, keepAlive: true}), instructorLunr: computed({requiresReaction: true, keepAlive: true}), courseLunr: computed({requiresReaction: true, keepAlive: true}), - sectionsByInstructorId: computed({requiresReaction: true, keepAlive: true}) + sectionsByInstructorId: computed({requiresReaction: true, keepAlive: true}), + filteredCourses: computed({requiresReaction: true}), }); - this.rootState = rootState; - // Pre-computes search indices (otherwise they're lazily computed, not a great experience when entering a query). // Normally we want to GC autorun handlers, but this will be kept alive for the entire lifecycle. - autorun(() => { + autorun(async () => { if (!this.rootState.apiState.loading) { - return this.sectionLunr && this.instructorLunr && this.courseLunr && this.sectionsByInstructorId; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _ = this.sectionLunr && this.instructorLunr && this.courseLunr && this.sectionsByInstructorId; } - }); + }, {scheduler: run => requestIdleCallbackGuard(run)}); } get sectionsByCourseId() { - const map = new ArrayMap(); + const map = new ArrayMap(); for (const section of this.rootState.apiState.sectionsNotDeleted) { map.put(section.courseId, section); @@ -66,54 +68,18 @@ export class UIState { // This looks scary. It is every bit as complex as it looks. get filteredCourses(): ICourseWithFilteredSections[] { - // Extract qualifier:token pairs from query - const searchPairExpr = /((\w*):([\w+-.]*))/g; - const searchPairExprWithAtLeast1Character = /((\w*):([\w+-.]+))/g; - - const searchPairs: Array<[string, string]> = this.searchValue.match(searchPairExprWithAtLeast1Character)?.map(s => s.split(':')) as Array<[string, string]> ?? []; - const cleanedSearchValue = this.searchValue - .toLowerCase() - .replace(searchPairExpr, '') - .replace(/[^A-Za-z\d" ]/g, '') - .trim() - .split(' ') - .filter(token => { - let includeToken = true; - for (const q of qualifiers) { - if (q.includes(token)) { - includeToken = false; - } - } - - return includeToken; - }) - .reduce((tokens, token) => { - // Check if token is of form subjectcrse (i.e. CS1000) - if (/([A-z]+)(\d+)/g.test(token)) { - const subject = token.match(/[A-z]+/g); - const crse = token.match(/\d+/g); - - if (subject && crse) { - searchPairs.push(['subject', subject[0]]); - tokens.push(crse[0]); - } - } else { - tokens.push(token); - } - - return tokens; - }, []) - .join(' '); + const {cleanedSearchValue, searchPairs} = parseSearchQuery(this.searchValue); // Keeps track of course IDs that should be included in result set - let courseScoresArray: Array<{id: ICourseFromAPI['id']; score: number | string}> = []; + const courseScoresArray: Array<{id: ICourseFromAPI['id']; score: number | string}> = []; // Keeps track of filtered sections by course ID const filteredSections = new ArrayMap(); if (cleanedSearchValue === '') { // If fuzzy search is empty; default to all courses - courseScoresArray = this.rootState.apiState.coursesNotDeleted - .map(c => ({id: c.id, score: `${c.subject}${c.crse}`})); + for (const c of this.rootState.apiState.coursesNotDeleted) { + courseScoresArray.push({id: c.id, score: `${c.subject}${c.crse}`}); + } } else { // This block is the fun bit @@ -191,11 +157,17 @@ export class UIState { // Two types of filtered sections: // (a) qualifier filtered: sections filtered with qualifier:token // (b) query filtered: sections filtered with words - const qualifierFilteredSections = sections.map(s => filterSection(searchPairs, s)); + const qualifierFilteredSections = []; + if (this.rootState.allBasketsState.currentBasket) { + for (const section of sections) { + qualifierFilteredSections.push(filterSection(searchPairs, section, this.rootState.allBasketsState.currentBasket.isSectionScheduleCompatibleMap)); + } + } + const queryFilteredSections = filteredSections.get(id) ?? []; let wereSectionsFiltered = filteredSections.get(id) !== null; - const mergedFilteredSections: ISectionFromAPI[] = []; + const mergedFilteredSections: ISectionFromAPIWithSchedule[] = []; // Merge sections filtered by qualifiers and query for (const [i, filterResult] of qualifierFilteredSections.entries()) { @@ -221,8 +193,8 @@ export class UIState { sections: { all: this.sectionsByCourseId.get(id)?.sort((a, b) => a.section.localeCompare(b.section)) ?? [], filtered: mergedFilteredSections.sort((a, b) => a.section.localeCompare(b.section)), - wasFiltered: wereSectionsFiltered - } + wasFiltered: wereSectionsFiltered, + }, }); } @@ -264,6 +236,7 @@ export class UIState { return lunr(builder => { builder.field('crn'); builder.field('section'); + builder.field('id'); for (const section of this.rootState.apiState.sectionsNotDeleted) { builder.add(section); diff --git a/src/lib/theme.ts b/src/lib/theme.ts new file mode 100644 index 0000000..dc5ccdb --- /dev/null +++ b/src/lib/theme.ts @@ -0,0 +1,50 @@ +import {ComponentStyleConfig, extendTheme} from '@chakra-ui/react'; +import {createBreakpoints} from '@chakra-ui/theme-tools'; + +const FormLabel: ComponentStyleConfig = { + baseStyle: { + fontWeight: 'bold', + }, +}; + +const Link: ComponentStyleConfig = { + baseStyle: ({colorMode}) => ({ + color: colorMode === 'light' ? 'blue.500' : 'blue.300', + }), +}; + +const theme = extendTheme({ + colors: { + brand: { + 50: '#fffae4', + 100: '#ffeea8', + 200: '#ffdf60', + 300: '#ffcd06', + 400: '#eebe00', + 500: '#d9ae00', + 600: '#c29b00', + 700: '#a78500', + 800: '#836900', + 900: '#4d3e00', + }, + }, + breakpoints: createBreakpoints({ + sm: '30em', + md: '48em', + lg: '62em', + xl: '80em', + '2xl': '96em', + '4xl': '192em', + }), + sizes: { + container: { + '2xl': '1600px', + }, + }, + components: { + FormLabel, + Link, + }, +}); + +export default theme; diff --git a/src/lib/to-title-case.ts b/src/lib/to-title-case.ts new file mode 100644 index 0000000..3fc462c --- /dev/null +++ b/src/lib/to-title-case.ts @@ -0,0 +1,3 @@ +const toTitleCase = (string_: string) => string_.split(' ').map(word => `${word[0].toUpperCase()}${word.slice(1).toLowerCase()}`).join(' '); + +export default toTitleCase; diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..2dc738e --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,32 @@ +import {NextPage} from 'next'; +import {ESemester} from './api-types'; + +export type CustomNextPage = NextPage & { + useStaticHeight?: boolean; +}; + +export interface IConcreteTerm { + semester: ESemester; + year: number; + isFuture?: boolean; +} + +export interface IVirtualTerm { + semester: ESemester; + isFuture: true; +} + +export type IPotentialFutureTerm = IConcreteTerm | IVirtualTerm; + +// https://stackoverflow.com/a/49579497/12638523 +export type IfEquals = + (() => T extends X ? 1 : 2) extends + (() => T extends Y ? 1 : 2) ? A : B; + +export type WritableKeys = { + [P in keyof T]-?: IfEquals<{[Q in P]: T[P]}, {-readonly [Q in P]: T[P]}, P> +}[keyof T]; + +export type ReadonlyKeys = { + [P in keyof T]-?: IfEquals<{[Q in P]: T[P]}, {-readonly [Q in P]: T[P]}, never, P> +}[keyof T]; diff --git a/pages/404.tsx b/src/pages/404.tsx similarity index 74% rename from pages/404.tsx rename to src/pages/404.tsx index 34e265d..077dfb7 100644 --- a/pages/404.tsx +++ b/src/pages/404.tsx @@ -1,6 +1,6 @@ import React from 'react'; import {Container, Heading, VStack, Text, Image} from '@chakra-ui/react'; -import WrappedLink from '../components/link'; +import WrappedLink from 'src/components/link'; const NotFoundPage = () => ( @@ -10,7 +10,7 @@ const NotFoundPage = () => ( - Maybe try going home? + Maybe try going home? diff --git a/pages/_app.tsx b/src/pages/_app.tsx similarity index 52% rename from pages/_app.tsx rename to src/pages/_app.tsx index 00a6cfb..1950769 100644 --- a/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,32 +1,35 @@ +import React, {useMemo} from 'react'; import type {AppProps} from 'next/app'; import Head from 'next/head'; -import {ChakraProvider, extendTheme} from '@chakra-ui/react'; -import useStore, {Provider as StateProvider} from '../lib/state-context'; -import Navbar from '../components/navbar'; -import RevisionToaster from '../components/revision-toaster'; -import useRevalidation from '../lib/use-revalidation'; +import {Box, BoxProps, ChakraProvider} from '@chakra-ui/react'; +import useStore, {Provider as StateProvider} from 'src/lib/state/context'; +import Navbar from 'src/components/navbar'; +import RegisterPWA from 'src/components/register-pwa'; +import useRevalidation from 'src/lib/hooks/use-revalidation'; +import {CustomNextPage} from 'src/lib/types'; +import MobileDeviceWarning from 'src/components/mobile-device-warning'; +import theme from 'src/lib/theme'; -const theme = extendTheme({ - colors: { - brand: { - 50: '#fffae4', - 100: '#ffeea8', - 200: '#ffdf60', - 300: '#ffcd06', - 400: '#eebe00', - 500: '#d9ae00', - 600: '#c29b00', - 700: '#a78500', - 800: '#836900', - 900: '#4d3e00' - } - } -}); - -function MyApp({Component, pageProps}: AppProps) { +const MyApp = ({Component, pageProps}: AppProps & {Component: CustomNextPage}) => { const state = useStore(); - useRevalidation(true, async () => state.apiState.revalidate()); + useRevalidation(true, async () => { + await state.apiState.revalidate(); + }); + + const wrapperProps: BoxProps = useMemo(() => { + if (Component.useStaticHeight) { + return { + h: '100vh', + display: 'flex', + flexDir: 'column', + pos: 'relative', + overflow: 'hidden', + }; + } + + return {}; + }, [Component.useStaticHeight]); return ( @@ -38,7 +41,7 @@ function MyApp({Component, pageProps}: AppProps) { { process.env.NODE_ENV === 'production' && ( -