From e04bcec2cc80e342c7f3808c891dc2ae3638490e Mon Sep 17 00:00:00 2001 From: Max Isom Date: Mon, 4 Oct 2021 16:42:34 -0400 Subject: [PATCH 1/5] Expand to future semesters --- src/components/courses-table/details-row.tsx | 53 ++++++++++++++++---- src/components/navbar.tsx | 14 +++++- src/lib/api-types.ts | 1 + src/lib/state/api.ts | 53 +++++++++++++++++--- src/lib/to-title-case.ts | 3 ++ src/pages/about.tsx | 13 +++++ src/pages/index.tsx | 9 +++- 7 files changed, 124 insertions(+), 22 deletions(-) create mode 100644 src/lib/to-title-case.ts diff --git a/src/components/courses-table/details-row.tsx b/src/components/courses-table/details-row.tsx index 6fc8698..4a7acbc 100644 --- a/src/components/courses-table/details-row.tsx +++ b/src/components/courses-table/details-row.tsx @@ -1,5 +1,18 @@ import React from 'react'; -import {Tr, Td, VStack, Text, Box, Heading, Button, Collapse, IconButton, HStack, Spacer} from '@chakra-ui/react'; +import { + Tr, + Td, + VStack, + Text, + Box, + Heading, + Button, + Collapse, + IconButton, + HStack, + Spacer, + Stack, +} 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'; @@ -7,6 +20,7 @@ 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'; const Stats = observer(({courseKey}: {courseKey: string}) => { const store = useStore(); @@ -31,6 +45,8 @@ const Stats = observer(({courseKey}: {courseKey: string}) => { const DetailsRow = ({course, onlyShowSections, onShowEverything, onShareCourse}: {course: ICourseWithFilteredSections; onlyShowSections: boolean; onShowEverything: () => void; onShareCourse: () => void}) => { const courseKey = `${course.course.subject}${course.course.crse}`; + const courseSections = course.sections.wasFiltered ? course.sections.filtered : course.sections.all; + return ( @@ -47,10 +63,21 @@ const DetailsRow = ({course, onlyShowSections, onShowEverything, onShareCourse}: - - Description: - {course.course.description} - + + + 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}/> @@ -70,13 +97,17 @@ const DetailsRow = ({course, onlyShowSections, onShowEverything, onShareCourse}: - - {!onlyShowSections && ( - Sections - )} + { + courseSections.length > 0 && ( + + {!onlyShowSections && ( + Sections + )} - - + + + ) + } diff --git a/src/components/navbar.tsx b/src/components/navbar.tsx index fe8513a..db53387 100644 --- a/src/components/navbar.tsx +++ b/src/components/navbar.tsx @@ -6,6 +6,8 @@ import {observer} from 'mobx-react-lite'; import useStore from 'src/lib/state/context'; import Logo from 'public/images/logo.svg'; import {SEMESTER_DISPLAY_MAPPING} from 'src/lib/constants'; +import {ISemesterFilter} from 'src/lib/state/api'; +import toTitleCase from 'src/lib/to-title-case'; import ColorModeToggle from './color-mode-toggle'; import Link from './link'; @@ -24,6 +26,14 @@ const PAGES = [ }, ]; +const getSemesterDisplayName = (semester: ISemesterFilter) => { + if (semester.isFuture) { + return toTitleCase(`Future ${semester.semester.toLowerCase()} Semester`); + } + + return `${SEMESTER_DISPLAY_MAPPING[semester.semester]} ${semester.year}`; +}; + const Navbar = () => { const router = useRouter(); const store = useStore(); @@ -34,6 +44,7 @@ const Navbar = () => { const handleSemesterSelect = useCallback(async (event: React.ChangeEvent) => { store.apiState.setSelectedSemester(JSON.parse(event.target.value)); + // TODO: move revalidation to reaction inside of APIState. await store.apiState.revalidate(); }, [store]); @@ -82,10 +93,9 @@ const Navbar = () => { store.apiState.sortedSemesters.map(semester => ( )) } diff --git a/src/lib/api-types.ts b/src/lib/api-types.ts index 7e74c42..a075abd 100644 --- a/src/lib/api-types.ts +++ b/src/lib/api-types.ts @@ -89,6 +89,7 @@ export interface ICourseFromAPI { prereqs: string | null; updatedAt: string; deletedAt: string | null; + offered: string[]; } export interface ITransferCourseFromAPI { id: string; diff --git a/src/lib/state/api.ts b/src/lib/state/api.ts index 1946dc7..a307f1d 100644 --- a/src/lib/state/api.ts +++ b/src/lib/state/api.ts @@ -14,11 +14,19 @@ import { import {Schedule} from '../rschedule'; import asyncRequestIdleCallback from '../async-request-idle-callback'; -interface ISemesterFilter { +interface IConcreteSemester { semester: ESemester; year: number; + isFuture?: boolean; } +interface IVirtualSemester { + semester: ESemester; + isFuture: true; +} + +export type ISemesterFilter = IConcreteSemester | IVirtualSemester; + type ENDPOINT = 'courses' | 'sections' | 'instructors' | 'transfer-courses' | 'passfaildrop' | 'buildings'; type DATA_KEYS = 'courses' | 'sections' | 'instructors' | 'transferCourses' | 'passfaildrop' | 'buildings'; @@ -31,6 +39,21 @@ const ENDPOINT_TO_KEY: Record = { buildings: 'buildings', }; +const VIRTUAL_SEMESTERS: IVirtualSemester[] = [ + { + semester: ESemester.SPRING, + isFuture: true, + }, + { + semester: ESemester.SUMMER, + isFuture: true, + }, + { + semester: ESemester.FALL, + isFuture: true, + }, +]; + export class APIState { instructors: IInstructorFromAPI[] = []; passfaildrop: IPassFailDropFromAPI = {}; @@ -42,7 +65,7 @@ export class APIState { errors: Error[] = []; lastUpdatedAt: Date | null = null; - availableSemesters: ISemesterFilter[] = []; + availableSemesters: IConcreteSemester[] = []; selectedSemester?: ISemesterFilter; singleFetchEndpoints: ENDPOINT[] = []; @@ -180,6 +203,8 @@ export class APIState { let hasData = true; + console.log(this.singleFetchEndpoints, this.recurringFetchEndpoints); + for (const endpoint of [...this.singleFetchEndpoints, ...this.recurringFetchEndpoints]) { const currentDataForEndpoint = this[ENDPOINT_TO_KEY[endpoint]]; @@ -202,12 +227,15 @@ export class APIState { FALL: 0.3, }; - return this.availableSemesters.slice().sort((a, b) => (a.year + semesterValueMap[a.semester]) - (b.year + semesterValueMap[b.semester])); + return [ + ...this.availableSemesters.slice().sort((a, b) => (a.year + semesterValueMap[a.semester]) - (b.year + semesterValueMap[b.semester])), + ...VIRTUAL_SEMESTERS, + ]; } async getSemesters() { const url = new URL('/semesters', process.env.NEXT_PUBLIC_API_ENDPOINT).toString(); - const result = await (await fetch(url)).json() as ISemesterFilter[]; + const result = await (await fetch(url)).json() as IConcreteSemester[]; runInAction(() => { this.availableSemesters = result; @@ -319,7 +347,7 @@ export class APIState { const key = ENDPOINT_TO_KEY[path]; try { - const url = new URL(`/${path}`, process.env.NEXT_PUBLIC_API_ENDPOINT); + let url = new URL(`/${path}`, process.env.NEXT_PUBLIC_API_ENDPOINT); if (['courses', 'sections'].includes(key)) { if (!this.selectedSemester) { @@ -327,8 +355,19 @@ export class APIState { return; } - url.searchParams.append('semester', this.selectedSemester.semester); - url.searchParams.append('year', this.selectedSemester.year.toString()); + if (this.selectedSemester.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.selectedSemester.semester); + } else if (key === 'sections') { + successfulHits++; + return; + } + } else { + url.searchParams.append('semester', this.selectedSemester.semester); + url.searchParams.append('year', this.selectedSemester.year.toString()); + } } const keyLastUpdatedAt = this.keysLastUpdatedAt[key]; 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/pages/about.tsx b/src/pages/about.tsx index 7d4ceed..8b09273 100644 --- a/src/pages/about.tsx +++ b/src/pages/about.tsx @@ -168,6 +168,19 @@ const AboutPage = () => ( + + 📝 Notes + + + + All times are in EST and are not adjusted for your current location. If you're in MN and a class section is labeled as starting at 10:00 AM, it would start at 9:00 AM in local time. Generated calendar events are not adjusted either for local time, but because they're based off of UTC will work just fine across timezones (i.e. you can imported a generated calendar in MN before coming up to school and the times will stay consistent). + + + Some courses have wrong "semesters offered" data. This is an issue with Banweb and not this site. + + + + 📜 Disclaimer diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 13a9013..f92777f 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -205,13 +205,18 @@ const HomePage = () => { useEffect(() => { apiState.setSingleFetchEndpoints(['passfaildrop', 'buildings']); - apiState.setRecurringFetchEndpoints(['courses', 'instructors', 'sections']); + + if (apiState.selectedSemester?.isFuture) { + apiState.setRecurringFetchEndpoints(['courses']); + } else { + apiState.setRecurringFetchEndpoints(['courses', 'instructors', 'sections']); + } return () => { apiState.setSingleFetchEndpoints([]); apiState.setRecurringFetchEndpoints([]); }; - }, [apiState]); + }, [apiState.selectedSemester, apiState]); return ( <> From 8186921076f4c36ed48f96b0466a2d0291936c77 Mon Sep 17 00:00:00 2001 From: Max Isom Date: Mon, 4 Oct 2021 19:38:22 -0400 Subject: [PATCH 2/5] Move revalidation on semester change to reaction --- src/components/navbar.tsx | 2 -- src/lib/state/api.ts | 11 ++++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/components/navbar.tsx b/src/components/navbar.tsx index db53387..7819a19 100644 --- a/src/components/navbar.tsx +++ b/src/components/navbar.tsx @@ -44,8 +44,6 @@ const Navbar = () => { const handleSemesterSelect = useCallback(async (event: React.ChangeEvent) => { store.apiState.setSelectedSemester(JSON.parse(event.target.value)); - // TODO: move revalidation to reaction inside of APIState. - await store.apiState.revalidate(); }, [store]); return ( diff --git a/src/lib/state/api.ts b/src/lib/state/api.ts index a307f1d..a1ee9fa 100644 --- a/src/lib/state/api.ts +++ b/src/lib/state/api.ts @@ -1,4 +1,4 @@ -import {makeAutoObservable, runInAction} from 'mobx'; +import {makeAutoObservable, reaction, runInAction} from 'mobx'; import {makePersistable} from 'mobx-persist-store'; import mergeByProperty from '../merge-by-property'; import { @@ -82,6 +82,13 @@ export class APIState { properties: ['selectedSemester'], storage: typeof window === 'undefined' ? undefined : window.localStorage, }); + + reaction( + () => this.selectedSemester, + async () => { + await this.revalidate(); + }, + ); } get subjects() { @@ -203,8 +210,6 @@ export class APIState { let hasData = true; - console.log(this.singleFetchEndpoints, this.recurringFetchEndpoints); - for (const endpoint of [...this.singleFetchEndpoints, ...this.recurringFetchEndpoints]) { const currentDataForEndpoint = this[ENDPOINT_TO_KEY[endpoint]]; From d7cb30f2f17e0f342cc80cd3d9b4cd2fb105d725 Mon Sep 17 00:00:00 2001 From: Max Isom Date: Mon, 4 Oct 2021 19:51:51 -0400 Subject: [PATCH 3/5] Fix lint issues and resolve TODOs --- src/components/courses-table/row.tsx | 5 +- src/components/navbar.tsx | 7 ++- src/components/search-bar.tsx | 53 ++++++++-------- src/lib/do-schedules-conflict.ts | 94 +++++++++++++++------------- 4 files changed, 84 insertions(+), 75 deletions(-) diff --git a/src/components/courses-table/row.tsx b/src/components/courses-table/row.tsx index 013db29..4d397bd 100644 --- a/src/components/courses-table/row.tsx +++ b/src/components/courses-table/row.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useLayoutEffect, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, 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'; @@ -34,8 +34,7 @@ const TableRow = observer(({course, onShareCourse}: {course: ICourseWithFiltered return getCreditsStr(min, max); }, [sections]); - // TODO: can this use useEffect instead? - useLayoutEffect(() => { + useEffect(() => { if (course.sections.wasFiltered !== wasPreviouslyFiltered) { if (course.sections.wasFiltered) { setOnlyShowSections(true); diff --git a/src/components/navbar.tsx b/src/components/navbar.tsx index 7819a19..c831a2a 100644 --- a/src/components/navbar.tsx +++ b/src/components/navbar.tsx @@ -34,6 +34,8 @@ const getSemesterDisplayName = (semester: ISemesterFilter) => { return `${SEMESTER_DISPLAY_MAPPING[semester.semester]} ${semester.year}`; }; +const PATHS_THAT_REQUIRE_SEMESTER_SELECTOR = new Set(['/', '/help/registration-script']); + const Navbar = () => { const router = useRouter(); const store = useStore(); @@ -46,6 +48,8 @@ const Navbar = () => { store.apiState.setSelectedSemester(JSON.parse(event.target.value)); }, [store]); + const shouldShowSemesterSelector = PATHS_THAT_REQUIRE_SEMESTER_SELECTOR.has(router.pathname); + return ( @@ -77,8 +81,7 @@ const Navbar = () => { > { - // TODO: this should be extracted out into a common layout helper attribute - ['/', '/help/registration-script'].includes(router.pathname) && ( + shouldShowSemesterSelector && (