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 (
-
-
-
-
- | Section |
- Instructors |
- Schedule |
- CRN |
- Credits |
- Capacity |
- Seats Taken |
- Seats 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 (
+
+ );
+});
+
+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. 😔
+ )
+ }
+
+
+ : }
+ >
+ {(!currentSoftware || currentSoftware.isDownloadable) ? 'Download Macro Script' : 'Copy Script'}
+
+
+
+
+
+ );
+};
+
+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 ? (
+
+ ) : (
+
+ )
+ }
+
+
+
+ {
+ !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 (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+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 (
+
+
+
+ | Title |
+ Section |
+ Instructors |
+ Schedule |
+ Location |
+ CRN |
+ Credits |
+ {
+ !props.isForCapture && (
+ <>
+ Seats |
+ Go |
+ Remove |
+ >
+ )
+ }
+
+
+
+ {
+ apiState.hasDataForTrackedEndpoints ? (
+
+ ) : (
+
+ {Array.from({length: 4}).map((_, i) => (
+ // eslint-disable-next-line react/no-array-index-key
+
+ ))}
+
+ )
+ }
+
+ );
+};
+
+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[]}) => {
-